Skip to content

feat(sdk): v1.3 settlement wired through the canonical registry — and the ABI that could never settle, fixed - #49

Open
syedhassan-aifinpay wants to merge 5 commits into
mainfrom
feat/v13-execution
Open

feat(sdk): v1.3 settlement wired through the canonical registry — and the ABI that could never settle, fixed#49
syedhassan-aifinpay wants to merge 5 commits into
mainfrom
feat/v13-execution

Conversation

@syedhassan-aifinpay

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

Copy link
Copy Markdown
Member

The v1.3 execution PR, as specified on 27 Aug. Rebased onto main after #40 merged — three commits of its own. Merge #46 first (rc.4), then this (rc.5).

The path

protocol route → chain → resolveSettlingSplitterRoute → v1.3 tuple ABI → signing → splitter
  • trustedPinFromRegistry(routeClass, chain) derives the pin — address, runtime hash, owner — from the canonical registry. No fallback to SPLITTER_DEPLOYMENTS; nothing from a server. It throws unless the route is enabled.
  • verifySettlementRouteOnChain now also reads owner() and requires the governance Safe, alongside the existing bytecode-hash and bps checks.
  • A backend invoice naming a different splitter is a hard reject (trusted_pin_mismatch) — tested with the legacy v1.2 Polygon address, a real working contract with the wrong economics.
  • settleInvoice() is the one-call form. AiFinPayAgent.fetchPaid settles AIFP-1 through it instead of throwing.

Found on the way: the existing v1.3 path could never have settled

settlement.ts on main encodes payNative/payStable with six flat parameters — selector 0x8e4a8903. The deployed B2BSplitterV13 takes one struct and carries only 0x27a3bbaf. Proven by eth_call against Polygon merchant-aifp1:

calldata contract's answer
flat 0x8e4a8903… execution reverted, data 0x — no such function
tuple 0x27a3bbaf… IncorrectNativeValue(1000, 0) — reached the logic

No test caught it because no test reached a contract. Fixed: V13_ABI is the tuple form, SETTLEMENT_V13_SELECTORS pins the real selectors, the canonical invoice function label is the tuple signature (AIFINP-179's "canonical instruction shape"). The new tests were run against the old ABI: 3 of 10 fail, as they should.

Every route stays closed

All 18 are settlementEnabled: false, so the path throws SplitterRouteNotSettlingError today — asserted by a test at the agent level. That's the gate working. It opens per chain-and-route in the registry after a supervised paid settlement.

The circle-breaker (AIFINP-213) — scripts/supervised-settle.mjs

Deliberately not the SDK path and never reads the SDK gate. calldata re-verifies chain id, bytecode hash, owner(), bps and treasury() against the chain, then prints the exact to / value / data for a wallet to sign. verify <tx> proves it from chain state: Payment event, payer/merchant/treasury balance deltas at block−1 vs block, gas accounted, splitter retaining nothing — and fails closed on any disagreement. That output is the evidence for flipping the route in registry.json. It never holds a key.

Dry run against live Polygon just now:

✓ polygon:merchant-aifp1 0x27C1C075… — chain 137, hash, owner (0xFd936f75…), 100/0 bps, treasury 0xFd936f75… all match the registry
selector 0x27a3bbaf = payNative((bytes32,address,uint256,address,uint256,string))
expected split: merchant 0.495, treasury 0.005, creator 0
✗ no canonical route polygon:legacy — selection is by chain AND route, never by address

Numbers

20 files, 197 tests. @aifinpay/agent2.0.0-rc.5 (#40 landed as rc.3, #46 takes rc.4). Lockfile regenerated under npm 10.

Proposed first activation, for Dimitry's decision: Polygon merchant-aifp1, ~0.5 POL, from a wallet he names, verify output posted on AIFINP-213 before the registry flips.

Syed Hassan added 3 commits August 29, 2026 23:38
…he ABI that could never settle

The v1.3 execution path, as specified on 2026-08-27:

  protocol route → chain → resolveSettlingSplitterRoute → v1.3 tuple ABI →
  signing → splitter

No fallback to SPLITTER_DEPLOYMENTS. Nothing taken from a server. The
contract address, its runtime hash and its owner come from the canonical
registry via trustedPinFromRegistry(routeClass, chain); the chain re-confirms
all three plus the bps immediately before the wallet signs; a backend invoice
naming a different splitter is a hard reject. AiFinPayAgent.fetchPaid settles
AIFP-1 through it instead of throwing.

Found on the way, and fixed: settlement.ts could never have settled a real
payment. It encoded payNative/payStable with six flat parameters — selector
0x8e4a8903 — while the deployed B2BSplitterV13 takes one struct and carries
only 0x27a3bbaf. Proven by eth_call against Polygon merchant-aifp1: the flat
shape gets an empty revert before any logic runs; the tuple shape reaches the
contract and reverts with IncorrectNativeValue(1000, 0). No test caught it
because no test reached a contract. V13_ABI is now the tuple form,
SETTLEMENT_V13_SELECTORS pins the real selectors, and a test asserts them —
verified to fail against the old ABI (3 of 10) and pass against the new.

Every route stays closed. All 18 carry settlementEnabled: false, so the path
throws SplitterRouteNotSettlingError today, and a test asserts exactly that.
That is the gate working. It opens per chain-and-route in the registry after
a supervised paid settlement — and the tool for that is here too:
scripts/supervised-settle.mjs (AIFINP-213). `calldata` re-verifies hash,
owner, bps and treasury against the chain, then prints the exact transaction
for a wallet to sign; `verify` proves a hash from chain state — Payment
event, payer/merchant/treasury balance deltas, splitter retaining nothing —
and is the evidence that enables the route. It never holds a key and never
reads the SDK gate. Dry-run against live Polygon: all checks match, selector
0x27a3bbaf, split 0.495/0.005/0 on 0.5 POL.

20 files, 197 tests. @aifinpay/agent 2.0.0-rc.4.
… treasury are one address

The first supervised run pays the governance Safe as merchant, and the Safe
is also the treasury, so both legs land on one balance. Checked as a single
combined delta in that case; separate deltas otherwise.
…uire the event to come from the splitter

The first supervised settlement on Polygon (0xa93a45a0…) was sent by a MetaMask
smart account: an EIP-7702 transaction to MetaMask's delegation manager, which
executed the call AS the account. tx.to was therefore not the splitter, and the
verifier stopped there — while the Payment event, emitted by the registry
splitter, named the sender as payer and every balance delta was exact.

verify now accepts either path. Direct: tx.to is the splitter and the calldata
selector is payNative. Delegated: the sender carries 0xef0100 delegation code
or the transaction has an authorizationList, and the path is printed. In both
cases the Payment event must be emitted BY the registry splitter address and
name tx.from as payer, and the deltas must match the profile exactly. A
transaction to some other contract from a plain EOA is still refused rather
than guessed at.
Syed Hassan added 2 commits August 30, 2026 12:14
…ement

evm-contract #30 lets the canonical registry hold a testnet entry (testnet:
true, only on a chain in a closed set) so a v1.3 rehearsal can happen on Amoy
before mainnet. Such an entry is owned by a deployer key and verified from one
provider — right for a rehearsal, wrong for anything this SDK settles real
money through.

The generator now excludes testnet entries from the production table, so the
resolver cannot name them at all, and resolveSettlingSplitterRoute refuses one
regardless of its flags in case an artifact ever reaches it another way. Both
are tested; every shipped route asserts testnet: false. 199 tests.
…'s expected delta

On Base, Optimism and Unichain the sender also pays an L1 data fee that is
not in gasUsed × effectiveGasPrice. viem's op-stack formatters expose it as
receipt.l1Fee; without it every OP-stack settlement read as a payer mismatch
of a few hundred gwei while the merchant and treasury legs were exact. Found
verifying the 30 Aug batch: 6 of the 13 routes are OP-stack.
enot3615 added a commit that referenced this pull request Sep 2, 2026
The published-files gate is right: unifiedAgent.ts ships to users, so two
different builds must not both call themselves rc.3.

rc.6 rather than rc.4 on purpose. rc.4 is claimed by #46 and rc.5 by #49, both
still open. Taking the next number above every open claim means this can merge
in any order without making anyone renumber — and a royalty fix should not wait
on a release-numbering negotiation.
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.

1 participant