Skip to content

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

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

refactor: rewrite & modernization - @polygonlabs/pos-sdk@1.0.0 #476
MaximusHaximus wants to merge 5 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. Zero Buffer, zero node:*, zero dynamic import() — runs unchanged in Node ≥20 and modern browsers. The byte code (RLP / merkle / proofs) uses Uint8Array + ethereum-cryptography; byte-pinning tests prove parity. A Vite+Playwright test-app package loads the bundle in a real browser to keep it honest.

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 (Joyent verror's TS-first, browser-friendly port — findCauseByName / info / fullStack, zero deps). Closed 27-code discriminator union; codes match the 0.x ErrorHelper keys so existing dashboards keep matching. The vague BRIDGE_ADAPTER_NOT_FOUND and the now-unreachable UNSUPPORTED_PROVIDER were removed; CONTRACT_NOT_AVAILABLE_ON_NETWORK added for honest network-capability conditions.

Cleanup. webpack 4 → tsup (ESM + CJS + DTS; index + three adapter subpath entries, libraries externalized). abstracts/, implementation/, enums/ deleted. Source ~3,900 lines (from 5,674).

Commits

  1. chore(workspace) — tooling, CI (incl. test secrets + nightly e2e), the as unknown as lint ban, plan docs.
  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
  • Record real burn-tx fixtures for tests/integration/exit-payload.test.ts; 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
constructor(config: ProofApiClientConfig) {
// Strip a single trailing slash so route composition never produces a
// double slash (`https://host//api/...`).
this.#base = config.baseUrl.replace(/\/+$/, '');
Workspace-level scaffolding for the @polygonlabs/pos-sdk 1.0 rewrite;
no package source. Wires up the build/lint/CI gates and the planning
record so the rewrite lands cleanly in the per-package commits on top.

- 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 (same reason); pnpm-lock
  regenerated with the SDK's deps (@polygonlabs/verror, p-limit,
  ethereum-cryptography, @ethereumjs/*) and the test-app's vite/playwright.
- 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). `: any` / `as any`
  remain banned globally by the preset.
- CI: ci-trigger.yml forwards POS_SDK_TEST_{PARENT_RPC,CHILD_RPC,
  PRIVATE_KEY} via job.env and builds @polygonlabs/pos-sdk on Node
  20/22/24; ci-nightly.yml (new) runs the gated e2e cycle daily.
- plans/pos-sdk-1.0-rewrite.md + PLAN.md + PLAN_BACKUP.md: the
  agent-executable plan and strategic notes, retained as the design
  record.
- Removes the stale empty .changeset/old-dolls-prove.md.
…-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 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 `kind` discriminator, no dynamic
  imports anywhere — fully static.

Cross-environment
- Zero `Buffer`, zero `node:*`, zero dynamic import: runs unchanged in
  Node >=20 and modern browsers. Byte code (RLP / merkle / proofs) uses
  Uint8Array + ethereum-cryptography; byte-pinning tests prove parity.

Core surface
- POSClient.init({ network, parent, child }) — only construction path.
- TxResult = { hash, confirmed() } (observe-only; idempotent).
- bigint everywhere; `as const` ABIs with no runtime ABI dependency
  (the 0.x runtime-CDN ABI fetch is gone); parent/child namespaces;
  method renames (startWithdraw, completeWithdraw[Fast], soliditySha3).
- prepareXxx sibling on every write returns unsigned { to, data, value? }
  for smart wallets / batching / offline signing.
- Dynamic contract addresses via stale-while-revalidate (1h TTL);
  config.addresses override for air-gapped.
- Reorg-safe checkpoint reads default to the 'safe' block tag
  (rootChainDefaultBlock to tune).

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 (the npm face of
  0xPolygon/static) — by scripts/generate-abis.ts, emitting local
  `as const` modules under src/abi/. 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 meta and consumers
  install nothing extra. The lockfile pins the exact meta version built
  against, and the shared codegen-drift-check workflow (codegen-drift-check
  script + .gitattributes linguist-generated marker) fails CI if the
  committed ABIs ever diverge from meta — so they cannot silently drift.

Errors
- POSBridgeError extends VError (Joyent verror's TS-first,
  browser-friendly port): findCauseByName / info / fullStack, code
  discriminator, name pinned for log aggregation. Closed 27-code union.

Fast exits / bridge helpers
- Optional proofGenerationApiUrl (no default; opt-in, matching 0.x
  setProofApi semantics) wires an internal client to the real
  proof-generation-api routes (/api/v1/<matic|amoy>/...), fixing the
  0.x mainnet-only network hardcode.
- Flat on POSClient: buildExitPayload, buildExitPayloads,
  buildExitPayloadOnIndex, isCheckpointed, isDeposited, isWithdrawn[
  OnIndex], getBlockProof, getPredicateAddress — restoring the
  non-token capabilities (buildMultiplePayloadsForExit, isDeposited)
  that consumers like proof-generation-api depend on.
- Mintable ERC-1155 predicate wired through init.

Tooling / tests / docs
- webpack 4 → tsup (ESM + CJS + DTS; index + three adapter subpath
  entries; viem/ethers externalized). Source ~3,900 lines (from 5,674);
  abstracts/, implementation/, enums/ deleted.
- Unit + skipIf-gated live-chain integration + gated e2e tests.
- README + MIGRATION (full removed-API replacement tables, factory
  adapter API, proof config, error codes) + examples for all three
  libraries.

Includes the major changeset for the rename + redesign.

- Escape hatch (replaces 0.x `.method(...)`): `pos.getAddresses()` surfaces
  the resolved bridge addresses (through the same stale-while-revalidate
  cache), and the `as const` ABIs (codegenned from @polygonlabs/meta at
  build time) are exported at the `@polygonlabs/pos-sdk/abi` subpath.
  Consumers pair the two with their own client to call contract methods
  the SDK does not wrap — fully typed by their library, no SDK-specific
  call surface.
…right

Private workspace package that bundles the SDK through Vite and loads
it in a real Chromium browser via Playwright, asserting every public
symbol is reachable and runs without tripping a Node-only global. This
is the cross-environment safety net the Node-based Vitest suites can't
provide: a Buffer/process reference inside a transitive dep only breaks
when bundled for the browser.

Exercises POSClient.init via the viemAdapter subpath factory,
prepareApprove (static viem encodeFunctionData), POSBridgeError /
VError, sanitiseError, noopLogger, the address-fetcher override, and
the ethereum-cryptography keccak path. Asserts no console errors fired.

The skip guard probes by actually launching the browser in beforeAll
and skips the suite on failure, so `pnpm -r run test` degrades to a
clean skip when the Playwright browser binary is absent (a static
executablePath() check is insufficient — headless runs use the
separate chrome-headless-shell binary). CI installs the browser via
`playwright install --with-deps chromium` and runs it for real.
httpGet checked res.ok but still parsed the body with a bare res.json(),
so a CDN/WAF serving a 200 HTML challenge page produced a context-free
SyntaxError with no URL and no body. Read the body as text first and
throw a POSBridgeError carrying the URL, status, content-type, and a
200-char body snippet for both failure shapes (non-2xx now includes the
snippet too). Completes the 1.0 counterpart of the 0.x metadata-fetch
error-visibility fix (2dc5cc7); the 0.x retry counterpart (9dc06a2) is
intentionally not ported — it existed for node-fetch's stale keep-alive
'Premature close', and 1.0 uses native fetch only.
Dependabot (the org's dependency bot; Renovate is not installed) keeps
the workspace current — most importantly the @polygonlabs/meta
devDependency, whose releases arrive as individual ungrouped PRs where
the codegen-drift-check gate regenerates the ABIs and the suite runs
against them. Routine npm bumps are grouped weekly; GitHub Actions
likewise.

meta 1.1.1 lowers its published engines.node floor to >=20 (the package
is pure data; 24 was only ever the build-toolchain requirement) —
verified the generated ABIs are byte-identical to 1.1.0.
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