Skip to content

refactor: rewrite & modernization - @polygonlabs/pos-sdk@1.0.0 #476

Draft
MaximusHaximus wants to merge 3 commits into
masterfrom
pos-sdk-1.0-rewrite
Draft

refactor: rewrite & modernization - @polygonlabs/pos-sdk@1.0.0 #476
MaximusHaximus wants to merge 3 commits into
masterfrom
pos-sdk-1.0-rewrite

Conversation

@MaximusHaximus

@MaximusHaximus MaximusHaximus commented May 1, 2026

Copy link
Copy Markdown
Contributor

Why

@maticnetwork/maticjs 3.9.x accumulated a decade of abstraction debt with concrete failure modes: the plugin model mutated module globals (utils.Web3Client = X), making multi-tenant use unsafe; the lazy ITransactionWriteResult conflated "submitted" vs "confirmed" (a caller assuming getTransactionHash() was idempotent caused a production double-broadcast); the BaseToken → POSToken → ERC20 chain forced non-token contracts to extend a token base; ABIs loaded from a single CDN at runtime (a global SPOF); BaseBigNumber predated native bigint; and 27 : any + 50 as casts left consumers with no compile-time safety.

Scope: PoS only

This rewrite covers the PoS bridge. The zkEVM bridge is intentionally not ported — the zkEVM chain is winding down, so consumers using ZkEvmClient stay on @maticnetwork/maticjs (the two install side by side during the window) rather than migrating twice. Details in MIGRATION.md.

What we got

Construction — static, tree-shakeable adapter factories. Consumers wrap their existing client with a per-library factory imported from a subpath and pass it to POSClient.init:

import { POSClient } from '@polygonlabs/pos-sdk';
import { viemAdapter } from '@polygonlabs/pos-sdk/viem';   // or /ethers-v5, /ethers-v6
const pos = await POSClient.init({
  network: 'amoy',
  parent: viemAdapter({ public: parentPublic, wallet: parentWallet }),
  child:  viemAdapter({ public: childPublic,  wallet: childWallet  }),
});

The main entry imports no web3 library; you ship only the one you use. No plugin, no global state, no kind discriminator.

Cross-environment — proven, not claimed. Zero Buffer, zero node:*, zero dynamic import(), zero Node-only transitives: the @ethereumjs v5-line is replaced by @ethereumjs/mpt v10 (trie ops verified byte-identical), which removes the events/buffer/readable-stream chains that would have forced browser polyfills. The Vite+Playwright test-app bundles the built dist with no stubs and no polyfills — the build itself fails if a Node builtin re-enters the graph — and runs the result in real Chromium on every PR.

Type safety. 0 : any, 0 as any. The remaining as unknown as casts (all at the viem/ethers boundary in src/adapters/) are now fenced by a path-scoped ESLint rule that bans the double-assertion everywhere else. as const ABIs give viem-typed inference at every internal call site.

Correctness / capability (driven by a parity audit against the old SDK).

  • Fast exits actually work: optional proofGenerationApiUrl (opt-in, no default — matches 0.x setProofApi) wires an internal client to the real proof-generation-api routes, fixing a 0.x mainnet-only network hardcode. The earlier rewrite accepted the URL but silently no-op'd it — a blocker the audit caught.
  • Restored lost capabilities: pos.buildExitPayloads (← buildMultiplePayloadsForExit), pos.isDeposited (state-sync deposit confirmation), exposed flat alongside buildExitPayload, isCheckpointed, isWithdrawn, getBlockProof, getPredicateAddress — the non-token surface proof-generation-api and similar consumers rely on.
  • Reorg-safe checkpoint reads default to the 'safe' block tag (rootChainDefaultBlock to tune), restoring a guarantee the rewrite had dropped (it read at latest).
  • Mintable ERC-1155 predicate wired through init (was always throwing).

API ergonomics.

  • TxResult = { hash, confirmed() } — observe-only, idempotent.
  • prepareXxx sibling on every write returns unsigned { to, data, value? } for smart wallets (Safe / Sequence / AA bundlers), batching, and offline signing.
  • parent / child namespaces replace the invertible isParent: boolean.
  • Escape hatch (replaces 0.x .method(...)): pos.getAddresses() surfaces the resolved bridge addresses (via the SWR cache) and the as const ABIs export at @polygonlabs/pos-sdk/abi — pair them with your own client to call any unwrapped contract method, fully typed by your library.
  • Native bigint throughout; method renames (startWithdraw, completeWithdraw[Fast], soliditySha3).

ABIs sourced from @polygonlabs/meta (single source of truth). The contract ABIs are codegenned from @polygonlabs/meta — the published source of truth for Polygon ABIs — into local as const modules (scripts/generate-abis.tssrc/abi/). @polygonlabs/meta is a build-time (dev) dependency only: tsup inlines the ABI bytes and types into dist/, so the published package has no runtime dependency on it and consumers install nothing extra. The lockfile pins the exact meta version built against, and a codegen-drift-check CI gate (via the shared apps-codegen-drift-check workflow, with the generated modules marked linguist-generated) fails if the committed ABIs ever diverge from their source — eliminating hand-vendored-ABI drift while keeping the runtime self-contained.

Errors. POSBridgeError extends VError (@polygonlabs/verror ^1.1.0 — findCauseByName / info / fullStack, zero deps). Closed 31-code discriminator union with exhaustiveness-checked switches — and 0.x compatibility done honestly: every condition that existed in the legacy SDK also carries the exact old ERROR_TYPE string (typos preserved verbatim) on the same type field the old SDK used, so migrated consumer switches keep matching. New honest semantics: ALLOWED_ON_ROOT/ALLOWED_ON_CHILD chain guards, TRANSACTION_NOT_FOUND for transient not-mined states, HTTP_REQUEST_FAILED for index/proof-API fetches (network-layer errors wrapped with cause). RPC tokens are redacted at error-mint time, and the exported sanitiseError deep-walks info objects/arrays/cause chains for everything else.

Packaging. webpack 4 → tsup, ESM-only (the SDK's runtime deps are ESM-only, so CJS artifacts could never load; require(esm) / await import() paths documented). Code-splitting keeps one POSBridgeError class identity across all five entries so cross-entry instanceof works. prepublishOnly builds; publishConfig.exports strips the internal source condition; a post-build step rewrites the ethers-v5 devDep alias in shipped declarations and hard-fails if any dist artifact references a build-time-only package. Version sits at 0.0.0 — changesets cuts 1.0.0; the first publish is manual per our bootstrap process. abstracts/, implementation/, enums/ deleted; source ~3,900 lines (from 5,674).

Adversarial go-live review + full remediation. A 167-agent review (12 lenses, every finding adversarially verified) produced 8 blockers, 25 majors, 40 minors — all resolved on this branch. Highlights: the address parser was reading an index shape that never existed on the wire (now parses the real nested CDN files 3.9.x reads, at the correct per-network paths, verified against the Cloudflare-served endpoints); viem's positional tuple decode broke checkpoint lookups (now shape-tolerant); totalDifficulty removed by geth 1.14+ crashed block parsing (now optional); confirmed() cached rejections forever (now evicts); fast exits lost the 0.x local fallback (restored). The full review + fix history is in this branch's PR discussion.

Tests exercise the real world. 119 unit/integration tests, and the gates the review found missing now run on every PR: a live-CDN test resolving both networks against static.polygon.technology; an exit-payload ground-truth suite that locally rebuilds REAL mainnet burns (one per token standard) creds-free over public RPCs and byte-compares against payloads recorded from the production proof-generation API (re-recordable via scripts/record-exit-fixture.mjs); a packed-tarball smoke test on all three Node versions (clean npm consumers, every subpath imported, skipLibCheck:false type probes); and the browser smoke in real Chromium.

Commits

  1. chore(workspace) — tooling, CI gates (matrix + pack-smoke + browser-smoke + drift-check), Dependabot, the as unknown as lint ban.
  2. refactor(pos-sdk)! — the SDK + examples + README + MIGRATION + the major changeset.
  3. test(test-app) — the browser smoke test.

Test plan

  • Add repo secrets POS_SDK_TEST_PARENT_RPC, POS_SDK_TEST_CHILD_RPC, POS_SDK_TEST_PRIVATE_KEY so PR CI runs the integration tier (skips cleanly without them)
  • Verify PR CI green: lint, typecheck, unit, integration, browser smoke (Playwright installs the browser in CI)
  • Verify nightly CI green at least once (gated 4h deposit-withdraw cycle per adapter)
  • Verify the auto-generated changeset-release/master PR appears after merge
  • First-publish bootstrap: if CI publish 403s on the initial @polygonlabs/pos-sdk release, recover with pnpm exec changeset publish on the merge commit
  • Migrate proof-generation-api to pos.buildExitPayload/buildExitPayloads/isCheckpointed/getBlockProof; audit portal and staking-ui for legacy pos.client.parent.X calls per MIGRATION.md
  • Real burn-tx fixtures recorded (mainnet, all three standards) and byte-verified against the proof-generation API; the suite runs creds-free in CI
  • Verify test-token addresses in tests/fixtures/networks.ts
  • After merge: deprecate @maticnetwork/maticjs on npm (PoS users → @polygonlabs/pos-sdk; zkEVM users stay until chain shutdown)

# is not blown by checkpoint waits.
POS_SDK_TEST_E2E_ENABLED: 'true'
steps:
- uses: 0xPolygon/pipelines/.github/actions/ci@main
@MaximusHaximus
MaximusHaximus force-pushed the pos-sdk-1.0-rewrite branch 2 times, most recently from e410c24 to a4870f3 Compare May 1, 2026 14:57
@MaximusHaximus MaximusHaximus changed the title refactor: 1.0 rewrite — @polygonlabs/pos-sdk + @polygonlabs/zkevm-sdk refactor: 1.0 rewrite — @polygonlabs/pos-sdk May 1, 2026
@MaximusHaximus MaximusHaximus changed the title refactor: 1.0 rewrite — @polygonlabs/pos-sdk refactor: @polygonlabs/pos-sdk / 1.0 rewrite & modernization May 1, 2026
@MaximusHaximus MaximusHaximus changed the title refactor: @polygonlabs/pos-sdk / 1.0 rewrite & modernization refactor: rewrite & modernization - @polygonlabs/pos-sdk@1.0.0 May 1, 2026
Comment thread packages/pos-sdk/src/internal/proof-api-client.ts Fixed
@MaximusHaximus
MaximusHaximus force-pushed the pos-sdk-1.0-rewrite branch 2 times, most recently from 4162952 to bf978ef Compare July 1, 2026 14:25
…e 1.0 monorepo

Workspace-level scaffolding for the @polygonlabs/pos-sdk 1.0 rewrite;
no package source. Wires up the build/lint/test/packaging gates the
per-package commits land on top of.

Build / lint plumbing
- tsconfig.json: project reference to packages/pos-sdk/tsconfig.build.json.
- pnpm-workspace.yaml: publicHoistPattern @tsconfig/* so tsup's
  load-tsconfig (resolving from node_modules/.pnpm/...) can follow
  `extends: "@tsconfig/node20"`.
- package.json: @tsconfig/node20 devDep; root `test` script
  (`pnpm -r --if-present run test`) so the shared CI action actually
  executes the workspace test suites — previously it silently no-op'd
  and "CI green" meant lint+typecheck only.
- eslint.config.js: prunes stale maticjs ignore patterns; adds a
  path-scoped `no-restricted-syntax` banning `as unknown as`
  double-assertions across packages/pos-sdk/src/** with src/adapters/**
  exempted (the sanctioned viem/ethers boundary).
- .prettierignore: packages/pos-sdk/src/abi/** — codegenned modules the
  drift gate compares against raw codegen output; formatting them would
  fail the gate on a false positive.
- .gitattributes: marks the codegenned ABI modules linguist-generated
  so they collapse in PR diffs.

CI
- ci-trigger.yml: forwards POS_SDK_TEST_* secrets via job.env; builds
  @polygonlabs/pos-sdk on Node 20/22/24 and runs the packed-tarball
  smoke test (test:pack) on each matrix leg; new browser-smoke job
  builds the test-app bundle with Vite (itself the no-polyfill gate)
  and runs the Playwright suite in real Chromium.
- ci-nightly.yml (new): runs the gated e2e cycle daily.
- codegen-drift-check-trigger.yml (new): thin trigger for the shared
  apps-codegen-drift-check workflow — regenerates the ABIs from
  @polygonlabs/meta and fails on any diff, so the committed modules
  cannot silently drift from their source.
- dependabot.yml (new): weekly npm + github-actions updates. The
  @polygonlabs/meta devDep is deliberately excluded from the grouped
  batch — a meta release changes the generated ABIs, so it arrives as
  its own PR where the drift gate and test suite review it.

Removes the stale PLAN.md stub from the repo root — internal working
documents don't ship on the public default branch.
export async function httpGet<T>(url: string): Promise<T> {
let res: Response;
try {
res = await fetch(url, {
…-sdk 1.0

Ground-up rewrite of the Polygon PoS bridge SDK. Renames the package,
removes the plugin layer, dismantles the BaseToken inheritance chain in
favour of composition, and re-bases the whole surface on modern,
cross-environment, statically-analysable primitives. zkEVM is out of
scope — those consumers stay on @maticnetwork/maticjs (which remains
published, in maintenance mode) until the chain winds down.

Why
- The plugin model mutated module globals (utils.Web3Client = X),
  making multi-tenant use unsafe — a production hazard.
- The lazy ITransactionWriteResult conflated submitted vs confirmed;
  a caller assuming getTransactionHash() was idempotent caused a
  production double-broadcast.
- BaseToken → POSToken → ERC20 forced non-token contracts to extend a
  token base, blocking new wrappers.
- ABIs loaded from a single CDN at runtime — a global SPOF.
- BaseBigNumber predated native bigint; 27 `: any` + 50 `as` casts gave
  consumers no compile-time safety.

Construction — per-library adapter factories (static, tree-shakeable)
- Consumers wrap their viem / ethers v5 / ethers v6 client with a
  factory imported from a subpath (`viemAdapter` from
  @polygonlabs/pos-sdk/viem, `ethersV5Adapter` from /ethers-v5,
  `ethersV6Adapter` from /ethers-v6) and pass it as parent/child. The
  main entry imports no web3 library; you ship only the one you use.
  No plugin, no global state, no dynamic imports — fully static.
- WriteRequest.chainId is enforced as a pre-broadcast cross-chain
  mis-send guard uniformly in all three adapters.
- TxResult = { hash, confirmed() }; confirmed() memoises the receipt
  wait but evicts on rejection, so a transient RPC failure never
  permanently poisons the result.

Cross-environment — genuinely, and gated
- Zero `Buffer`, zero `node:*`, zero dynamic import, zero Node-only
  transitives: the @ethereumjs v5-line (block/common/trie/util) is
  replaced by @ethereumjs/mpt v10 (trie ops verified byte-identical),
  which drops the events/buffer/readable-stream chains that previously
  forced browser consumers to polyfill. setLengthLeft is inlined into
  utils/bytes.ts; the sole remaining @ethereumjs dependency is the
  merkle-patricia trie.
- The Vite browser bundle of the test-app builds with NO stubs and NO
  polyfills — the build itself now fails if a Node builtin re-enters
  the graph.

Addresses — resolved from the same live index 3.9.x reads
- POSClient.init fetches the SAME nested CDN index files the legacy SDK
  consumes (network/mainnet/v1, network/testnet/amoy — served via
  Cloudflare), selecting the live *Proxy entries the legacy SDK read;
  1-hour stale-while-revalidate cache so long-running services pick up
  redeployments without restart. A flat pre-resolved NetworkAddresses
  object is accepted for the config.addresses override and custom
  mirrors, and overrides get the same shape validation as fetched
  indexes (full 20-byte-address checks).
- MintableERC1155Predicate and GasSwapper flow through the parser
  (optional per network); predicate lookups are promise-cached per
  token with eviction on failure.

Errors — typed, layered, and 0.x-compatible
- POSBridgeError extends VError (@polygonlabs/verror ^1.1.0):
  findCauseByName / info / fullStack, closed 31-code discriminator
  union, exhaustiveness-checked switches.
- Every condition that existed in 0.x ALSO carries the exact legacy
  ERROR_TYPE string (typos preserved verbatim) on the same `type` field
  the old SDK used — consumer switches written against 0.x keep
  matching; LegacyErrorType is exported.
- Honest semantics: ALLOWED_ON_ROOT/ALLOWED_ON_CHILD for chain guards,
  TRANSACTION_NOT_FOUND for transient not-mined states,
  HTTP_REQUEST_FAILED for index/proof-API fetch failures (network-layer
  fetch TypeErrors wrapped with cause), ROOT_HASH_RPC_FAILED reserved
  for bor_getRootHash.
- httpGet redacts RPC tokens at MINT time (URL + body snippets), has a
  30s timeout, and surfaces non-JSON 2xx bodies (CDN challenge pages)
  with content-type + snippet instead of a bare SyntaxError.
- sanitiseError deep-walks message, stack, own props (info objects,
  string arrays, nested request objects) and the cause chain — the
  logger-agnostic layer over verror/logger's fingerprint-gated
  auto-redaction.

Encapsulation policy — inspectable by design
- No hard-private (#) fields anywhere: adapters expose the exact
  provider/signer (ethers) and publicClient/walletClient (viem) the
  consumer passed in; token wrappers expose tokenAddress/isParent.
  Internals are TypeScript-private — hidden from the typed API (and
  still nominal in the d.ts) but plain properties at runtime, so
  instances are debuggable, identity-comparable, and survive
  Proxy-based reactivity (Vue reactive()/Pinia) that throws on
  #-field libraries. An SDK has no security boundary against its own
  consumer; the typed API is the supported surface.

Fast exits / bridge helpers
- Optional proofGenerationApiUrl (no default; opt-in, matching 0.x
  setProofApi semantics), with correct per-network segments. If the
  API is configured but fails, fast exits FALL BACK to local
  construction (0.x parity) — an outage degrades fast exits to slow
  ones instead of breaking withdrawals.
- Flat on POSClient: buildExitPayload(s)(OnIndex), isCheckpointed,
  isDeposited, isWithdrawn(OnIndex), getBlockProof,
  getPredicateAddress, getAddresses, getProofApi — restoring the
  non-token surface downstream services rely on. Escape hatch:
  pos.getAddresses() + the ABIs at @polygonlabs/pos-sdk/abi replace the
  0.x `.method(...)` accessor.
- Reorg-safe checkpoint reads default to the 'safe' block tag; RPC
  block parsing tolerates geth 1.14+ (no totalDifficulty); multi-output
  contract reads decode positionally so viem's array decode and ethers'
  Result both work.

ABIs — codegenned from @polygonlabs/meta, drift-gated
- src/abi/* is generated from @polygonlabs/meta (build-time devDep) by
  scripts/generate-abis.ts; tsup inlines bytes AND declarations so the
  published package has no runtime meta dependency. CI's
  codegen-drift-check regenerates and fails on any diff.

Packaging — ESM-only, publish-safe
- version 0.0.0 (the 1.0.0 release is cut by changesets); prepublishOnly
  builds; publishConfig.exports strips the internal @polygonlabs/source
  condition; CHANGELOG.md ships; ethers peer floor ^5.6.0 (the adapter
  requires BigNumber.toBigInt).
- ESM-only by necessity (runtime deps are ESM-only; the previous .cjs
  artifacts could neither be reached nor loaded); require(esm)/dynamic
  import paths documented. tsup code-splitting keeps one POSBridgeError
  class identity across every entry, so cross-entry instanceof works.
- scripts/fix-dts-specifiers.mjs rewrites the ethers-v5 devDep alias to
  'ethers' in shipped declarations and hard-fails the build if any dist
  artifact references a build-time-only package.

Tests — the suite exercises the real world
- 119 unit/integration tests. Live-CDN test resolves BOTH networks
  against static.polygon.technology on every run; parser fixtures pin
  the nested-index extraction to @polygonlabs/meta's generated
  snapshots.
- Exit-payload ground truth: fixtures record REAL mainnet burns (one
  per token standard) with payloads from the production
  proof-generation API, independently verified byte-identical to local
  construction at recording time; the test rebuilds them creds-free
  over public RPCs on every run. scripts/record-exit-fixture.mjs
  automates re-recording (discover burn → API ground truth → verify
  local === API → write).
- pack-smoke (scripts/pack-smoke.mjs) installs the packed tarball into
  clean npm consumers (viem+ethers6, ethers5), imports every subpath,
  checks cross-entry instanceof + legacy error types, and typechecks
  probes with skipLibCheck:false.
- Examples are consumer-shaped (link:ed SDK, own install), runnable as
  documented.

MIGRATION.md documents every breaking change with before/after blocks:
adapter factories, bigint, method renames, error codes + legacy type
mapping, per-tx options (gasPrice/type replacement), dropped config
and methods with replacements, address resolution, fast exits, ESM.
Includes the major changeset for the rename + redesign.
constructor(config: ProofApiClientConfig) {
// Strip a single trailing slash so route composition never produces a
// double slash (`https://host//api/...`).
this.base = config.baseUrl.replace(/\/+$/, '');
…right

Private workspace package that bundles the SDK's built dist through
Vite and loads it in real Chromium via Playwright, asserting every
public symbol is reachable and runs without console errors.

The Vite build is itself the cross-environment gate: it uses NO Node
polyfills and NO builtin stubs, so it fails outright if the SDK's
dependency graph ever pulls in `events`, `buffer`, or another Node
builtin — the failure mode the Node-based Vitest suites cannot see.
(An earlier iteration papered over @ethereumjs v5-line transitives with
throw-on-call stubs; the @ethereumjs/mpt v10 migration made the claim
true and the stubs were deleted so the gate is real.)

Exercises POSClient.init via the viemAdapter subpath factory,
prepareApprove, POSBridgeError / VError, sanitiseError, noopLogger,
the address-fetcher override, and the ethereum-cryptography keccak
path.

The script is `test:browser` (not `test`) so the root recursive
`pnpm -r run test` never invokes Playwright in a job without browsers
or a built bundle; the dedicated browser-smoke CI job builds the SDK,
builds this bundle, installs Chromium, and runs it on every PR. The
local skip guard probes by actually launching the browser, so a
machine without the binary degrades to a clean skip.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants