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. 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 vendored 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).

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; vendored `as const` ABIs (CDN dep for ABIs 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).

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 vendored `as const` ABIs 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.
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