Skip to content

feat(sdk): generate SPLITTER_ROUTES from the canonical registry, and fail closed on the policy window - #40

Merged
enot3615 merged 6 commits into
mainfrom
feat/splitter-route-selection
Aug 29, 2026
Merged

feat(sdk): generate SPLITTER_ROUTES from the canonical registry, and fail closed on the policy window#40
enot3615 merged 6 commits into
mainfrom
feat/splitter-route-selection

Conversation

@syedhassan-aifinpay

@syedhassan-aifinpay syedhassan-aifinpay commented Aug 27, 2026

Copy link
Copy Markdown
Member

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 artifact
  • node/registry/source.json — the evm-contract commit it came from, and its sha256
  • node/src/splitterRoutes.generated.ts — generated from it, marked DO NOT EDIT
npm run registry:sync -- --from ../evm-contract   # refresh + regenerate
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. 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 --from refuses a source tree with uncommitted changes under registry/, 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-edited settlementEnabled in 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 owner against 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.ts keeps 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") is NaN, and NaN fails every comparison. With t < 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 NaN fails all of them. Unparseable dates, an inverted window, and 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:

Case Expected
before validFrom reject — policy window opens
exactly at validFrom allow (inclusive start)
mid-window allow
1 ms before validUntil allow
exactly at validUntil reject — policy window expired
after validUntil reject
malformed validFrom / validUntil / both reject — policy window is unreadable
inverted window reject — policy window is inverted
invalid now reject
valid window but settlementEnabled: false reject — flag still wins

Verified 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.

syedhassan-aifinpay and others added 3 commits August 27, 2026 19:23
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.
@syedhassan-aifinpay syedhassan-aifinpay changed the title feat(sdk): select v1.3 splitters by chain AND route, with no fallback feat(sdk): generate SPLITTER_ROUTES from the canonical registry, and fail closed on the policy window Aug 27, 2026
Syed Hassan added 2 commits August 28, 2026 13:30
…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.
@enot3615

Copy link
Copy Markdown
Collaborator

Reviewed. LGTM — merge it. Verified rather than taken on trust:

Check Result
Vendored artifact vs evm-contract@8577d56 ba556b41… = ba556b41…byte-identical, re-derived independently
Provenance fetches raw.githubusercontent.com/{repo}/{commit}/{path} — pinned to a commit, not a branch. That closes the "anyone with push to evm-contract changes what the SDK ships" hole, which was my main worry going in
Fail-closed fetch error → exit 1; hash compared twice (local vs recorded, local vs remote)
Money path unifiedAgent.ts change is a pure refactor — the two defineChain blocks moved to chains.js. No behavioural change
Addresses nothing removed; the registry data is added
Retarget applies to main cleanly (tested) — see #47 for why main

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

splitterRoutes.ts:164 throws SplitterRouteNotSettlingError when settlementEnabled is false — and the canonical table has 24 routes, 0 enabled.

This PR is safe because the module is inert: unifiedAgent.ts never calls it (the only match in that file is a comment). Nothing on the payment path changes today.

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.

Version

Only node/package.json conflicts with #46 (both take it rc.2 → rc.3) — verified by test-merging the two heads. It is the single conflicting file.

Smaller than it looks: @aifinpay/agent has no 2.0.0-rc published at all (npm latest is 1.8.4), so its rc number is internal bookkeeping. @aifinpay/mcp@2.0.0-rc.3 is published, which makes #46's mcp bump to rc.4 necessary rather than optional.

Whoever merges second edits one line: node/package.json2.0.0-rc.4. Either order works.

@enot3615

Copy link
Copy Markdown
Collaborator

Retargeted from dev to main, per the branch decision — verified beforehand that it applies to main cleanly, so this costs nothing.

Reason in short: dev does not have the #42 royalty fix (_splitter_treasury(pm[ is still in python/aifinpay/unified_agent.py there), so anything built on it takes the merchant's royalty. dev has also never produced a release, CI triggers on bare push:/pull_request: with no branch filter so it buys no extra safety, and main requires 4 status checks to dev's 3.

#48 brings dev's one unique feature (the token list, AIFINP-78) onto main so nothing is lost. It replaces #47, which could not merge because main is strict and dev was 3 commits behind.

That is the answer to the base-branch question for the v1.3 execution PR: main.

@enot3615
enot3615 merged commit 5ced4a5 into main Aug 29, 2026
16 checks passed
@enot3615
enot3615 deleted the feat/splitter-route-selection branch August 29, 2026 18:11
syedhassan-aifinpay pushed a commit that referenced this pull request Aug 29, 2026
…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.
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