feat(sdk): generate SPLITTER_ROUTES from the canonical registry, and fail closed on the policy window - #40
Conversation
From v1.3 a chain carries one splitter per protocol route, because the fee split is immutable at construction and the two protocols need different economics: merchant-aifp1 is 100/0 and agent-x402 is 0/0. The existing SPLITTER_DEPLOYMENTS map is keyed by chain alone and its version union is "1.1" | "1.2", so it can express neither. Adds SPLITTER_ROUTES, keyed "<chain>:<route>", covering all eighteen v1.3 deployments across the nine chains, with resolveSplitterRoute() that throws on an unknown pair instead of falling back. The no-fallback rule is not a style preference. The splitters were deployed with CREATE, so an address derives from deployer and nonce and the same address recurs on other chains for the other route: 0x1Fe2021336596655Fac72bC7bC40F7FFFA501d55 is OP's merchant-aifp1 and also Base's agent-x402. 0xF03B3387415D557b6ab709D06E8aF0b4ABD6Eb74 is Unichain's merchant-aifp1, Avalanche's agent-x402, and the legacy v1.2 splitter on Optimism. Every route therefore resolves to a real, deployed, working contract. A fallback would not fail — it would settle at the wrong fee split, and the amounts would look plausible in every log. A test asserts that this reuse exists and that a shared address still resolves to different economics per chain. resolveSettlingSplitterRoute() is deliberately separate: reading the registry and being cleared to move money are different questions. Settlement is disabled on all eighteen and each route is enabled individually after a paid mainnet end-to-end with verified balance deltas, and the policy window is enforced so an unreviewed route stops settling rather than drifting on. Values are generated from the canonical registry in evm-contract (registry/generated/splitter-table.json, schemaVersion 2), where treasury, both bps values and the runtime code hash were read from chain rather than transcribed. A test pins the consequence: exactly two distinct runtime code hashes across eighteen contracts, one per route, which is what proves the right immutable profile reached every chain. Also moves the botchain and xrplevm chain definitions into src/chains.ts so unifiedAgent and splitterRoutes cannot drift apart on a chain id. SPLITTER_DEPLOYMENTS is left untouched; the v1.1/v1.2 entries it serves are marked superseded with settlement disabled in the registry. Build clean; 170 tests passing across 18 files, 15 of them new.
…files The version gate is right to fail: splitterRoutes.ts, chains.ts and the new index exports all ship in the package, so consumers can pin them. Additive only — SPLITTER_DEPLOYMENTS and every existing export are unchanged, so this is a prerelease bump rather than a breaking one.
… closed on time
Two repositories were hand-maintaining the same payment-critical table:
addresses, code hashes, fee splits, policy dates and settlement flags lived in
evm-contract's registry AND were typed out again in splitterRoutes.ts. Those
disagree eventually, and the failure is silent — the amounts still look
plausible in every log.
They are now one table. registry/splitter-table.json is a byte-for-byte copy of
the canonical artifact, registry/source.json records the evm-contract commit and
its sha256, and src/splitterRoutes.generated.ts is produced from it:
npm run registry:sync -- --from ../evm-contract
npm run registry:check (CI gate)
`registry:check` regenerates and compares byte-for-byte, and re-hashes the
vendored artifact against its recorded provenance, so hand-editing either one
turns CI red. Offline and deterministic: this gate must not need a network read
of another repository to know whether it is in sync. Rejected, as it should:
a hand-edited payout address, a hand-edited fee split, a hand-edited settlement
flag in the artifact.
Only the 18 current v1.3 routes are generated. The superseded v1.1/v1.2 entries
stay in the canonical registry as deployment evidence but are deliberately not
representable here — a resolver that cannot name a legacy splitter cannot
silently fall back to one. owner is carried through and asserted against the
governance Safe, in the generator and again in the tests.
Not generated, deliberately: viemChain, defaultRpc and explorer. A wrong RPC
fails loudly and pays nobody; a wrong splitter address pays the wrong party
successfully. A chain with no transport entry is an error, not a default.
resolveSettlingSplitterRoute failed OPEN on the input it could least trust.
Date.parse("nonsense") is NaN, NaN fails every comparison, so with `t < from` /
`t >= until` both gates were false and a route with a malformed policy window
settled with no time check at all. Every comparison is now written as "prove it
is inside the window", and an unparseable window, an inverted window or an
invalid `now` are each rejected explicitly.
The old expiry test could not have caught this: every shipped route has
settlementEnabled false, so it rejected on the flag before reaching validUntil
— it proved the settlement flag worked, twice. The window is now tested against
a synthetic ENABLED route, asserting the reason and not just the throw: before
validFrom, exactly at validFrom, mid-window, one ms before validUntil, exactly
at validUntil, after it, and malformed/inverted/invalid-now failing closed.
Verified against the pre-fix implementation: the five fail-closed tests fail,
the boundary tests still pass. 185 tests, 18 files.
…e recorded commit registry:check is offline and self-consistent — a writer who can change this repository can change the artifact, its provenance and the generated table together, and the check blesses the set. That was the audit's HIGH: a green CI can certify a self-consistent forged snapshot. This step fetches the artifact from AiFinPay/evm-contract itself, at the exact 40-hex commit source.json records, over HTTPS from GitHub, and requires the bytes to be identical to the vendored copy. The commit is immutable and both repositories are public, so nothing in this check can be satisfied by editing files here. An unreachable GitHub fails; it does not skip.
…um and allowlist carried through The canonical artifact now carries the fields #20 added: rpcQuorum, the per-chain stablecoin allowlist, the full Safe shape and build provenance. Re-vendored byte-for-byte from evm-contract main and regenerated. rpcQuorum and stablecoins are carried into SplitterRouteDeployment. The generator refuses an artifact that marks a single-provider route enabled, mirroring the registry's own gate so a hand-edited copy cannot get past the SDK either, and refuses a route with no recorded allowlist. All 18 routes remain settlementEnabled: false.
|
Reviewed. LGTM — merge it. Verified rather than taken on trust:
The window check is written as "prove it is inside" rather than "prove it is outside", so a malformed date fails closed instead of settling with no time gate. That is the right instinct and worth keeping as the house style. One thing before the execution PR
This PR is safe because the module is inert: But the v1.3 execution PR wires exactly that function in, and on day one it throws for all 18 routes. The policy in the error message is circular as written — settlement is enabled only after a successful mainnet paid settlement, and that payment cannot happen through a disabled route. Someone has to break the circle deliberately: enable one route, pay through it under supervision, verify the balance deltas, enable the rest on that evidence. Worth settling before the execution PR is written rather than discovering it in review. Tracked on AIFINP-213, with AIFINP-179. VersionOnly Smaller than it looks: Whoever merges second edits one line: |
|
Retargeted from Reason in short: #48 brings That is the answer to the base-branch question for the v1.3 execution PR: |
…ctions to SHAs
All eight open Dependabot alerts on this repository were the same finding:
uuid < 11.1.1 (missing buffer bounds check in v3/v5/v6), reached transitively
through @solana/web3.js → jayson → uuid@8.3.2 in every package that installs
the Solana client. jayson only calls v4, so the vulnerable code path is not
reachable from anything here — but "not reachable" is an argument that has to
be re-made every time someone reads the alerts page, and an alert that stays
open for a known-safe reason teaches people to stop reading it.
Each install root now carries `"overrides": { "uuid": "^11.1.1" }` and its
lockfile resolves uuid@11.1.1 (rpc-websockets already pulled 14.0.1). node,
wallet and mcp build and pass their suites unchanged: 155, 13 and 74 tests.
package.json is a published file, so the version gate requires a bump:
@aifinpay/agent 2.0.0-rc.2 → rc.3, @aifinpay/mcp patch. Note for the
main↔dev reconcile: sdk PR #40 on dev also bumps agent to rc.3 with different
content; whichever lands second takes rc.4. The gate exists to make exactly
that collision visible rather than silent.
GitHub Actions are pinned to commit SHAs with the tag in a trailing comment.
A moving tag is a supply-chain input into a pipeline that publishes a payment
SDK; a SHA is not. Closes the MEDIUM "CI dependencies/actions are not fully
immutable" from the 27 Aug audit.
Closes points 2 and 3 of the 2026-08-27 review, on top of the original route-selection work.
Point 2 — one table, not two
Two repositories were hand-maintaining the same payment-critical data: addresses, code hashes, fee splits, policy dates and settlement flags lived in evm-contract's registry and were typed out again in
splitterRoutes.ts. Those disagree eventually, and the failure is silent — the amounts still look plausible in every log.They are now one table:
node/registry/splitter-table.json— a byte-for-byte copy of the canonical artifactnode/registry/source.json— the evm-contract commit it came from, and its sha256node/src/splitterRoutes.generated.ts— generated from it, marked DO NOT EDITregistry:checkregenerates and compares byte-for-byte, and re-hashes the vendored artifact against its recorded provenance — so hand-editing either one turns CI red. It is offline and deterministic on purpose: this gate must not need a network read of another repository to know whether it is in sync.registry:sync --fromrefuses a source tree with uncommitted changes underregistry/, since provenance recorded against a dirty tree points at a commit that does not contain the artifact.Rejected, verified: a hand-edited payout address, a hand-edited
treasuryBps, and a hand-editedsettlementEnabledin the artifact all fail the gate.Only the 18 current v1.3 routes are generated. The superseded v1.1/v1.2 entries stay in the canonical registry as deployment evidence but are deliberately not representable here — a resolver that cannot name a legacy splitter cannot silently fall back to one. The generator also asserts every route's
owneragainst the governance Safe, and the tests assert it again.Not generated, deliberately:
viemChain,defaultRpc,explorer. A wrong RPC fails loudly and pays nobody; a wrong splitter address pays the wrong party successfully. A chain with no transport entry is an error, not a default.Logic stays hand-written and reviewable —
splitterRoutes.tskeeps the types, errors and both resolvers, and only the data is generated.Point 3 — the policy window failed OPEN
You were right, and it was worse than a weak test.
Date.parse("nonsense")isNaN, andNaNfails every comparison. Witht < from/t >= until, both gates were false — so a route with a malformed policy window settled with no time check at all. Fail-open, on exactly the input you cannot trust.Every comparison is now written as prove it is inside the window, so
NaNfails all of them. Unparseable dates, an inverted window, and an invalidnoware each rejected explicitly.The old expiry test could not have caught this: every shipped route has
settlementEnabled: false, so it rejected on the flag before reachingvalidUntil— it proved the settlement flag worked, twice.The window is now tested against a synthetic enabled route, asserting the reason and not just the throw:
validFromvalidFromvalidUntilvalidUntilvalidUntilvalidFrom/validUntil/ bothnowsettlementEnabled: falseVerified against the pre-fix implementation: the five fail-closed tests fail, the boundary tests still pass. So the tests genuinely exercise the window, and the fix is what closes it.
185 tests across 18 files. All 18 routes remain
settlementEnabled: false.