From 0f53f35c3570f57902145925041bb6d3fbe88845 Mon Sep 17 00:00:00 2001 From: Syed Hassan Date: Sat, 29 Aug 2026 12:24:40 +0500 Subject: [PATCH 1/5] feat(sdk): wire v1.3 settlement through the canonical registry, fix the ABI that could never settle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 35 ++++ node/package-lock.json | 4 +- node/package.json | 2 +- node/scripts/supervised-settle.mjs | 141 ++++++++++++++++ node/src/index.ts | 7 + node/src/settlement.ts | 207 +++++++++++++++++++----- node/src/unifiedAgent.ts | 114 ++++++++++++- node/tests/aifp1-v13-gate.test.ts | 32 ++++ node/tests/settlement-execution.test.ts | 120 ++++++++++++++ node/tests/settlement-v13.test.ts | 4 +- 10 files changed, 616 insertions(+), 50 deletions(-) create mode 100644 node/scripts/supervised-settle.mjs create mode 100644 node/tests/aifp1-v13-gate.test.ts create mode 100644 node/tests/settlement-execution.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 91d6188..512eef0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,41 @@ All notable changes to the AiFinPay SDK packages are documented here. Versioning follows [Semantic Versioning](https://semver.org/). From `1.0.0` onward the public API is stable and changes follow semver. +## @aifinpay/agent 2.0.0-rc.5 + +### Fixed + +- **The v1.3 settlement path could never have settled a real payment.** + `settlement.ts` encoded `payNative`/`payStable` with six flat parameters, + selector `0x8e4a8903`. The deployed `B2BSplitterV13` takes one struct and + carries only `0x27a3bbaf`. Proven on Polygon `merchant-aifp1` by `eth_call`: + the flat shape gets an empty revert before any logic runs; the tuple shape + reaches the contract and reverts with `IncorrectNativeValue`. 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. + The canonical invoice `function` label is the tuple signature. (AIFINP-179) + +### Added + +- **The v1.3 execution path is wired**, exactly as specified on 2026-08-27: + protocol route → chain → `resolveSettlingSplitterRoute` → v1.3 tuple ABI → + signing → splitter. `trustedPinFromRegistry(routeClass, chain)` derives the + pin — address, runtime hash, owner — from the canonical registry with no + fallback to `SPLITTER_DEPLOYMENTS` and nothing taken from a server; a + backend invoice naming a different splitter is a hard reject. + `settleInvoice()` is the one-call form. `AiFinPayAgent.fetchPaid` now settles + AIFP-1 through it instead of throwing. `verifySettlementRouteOnChain` also + reads `owner()` and requires the governance Safe. +- **Every route is still closed.** All 18 carry `settlementEnabled: false`, + so the path throws `SplitterRouteNotSettlingError` today; that is the gate + working, not a bug. It opens per chain-and-route in the registry, after a + supervised paid settlement. +- `scripts/supervised-settle.mjs` — the deliberate circle-breaker (AIFINP-213). + `calldata` prints the exact transaction for a wallet to sign after + re-verifying hash, owner, bps and treasury against the chain; `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. ## aifinpay-agent 1.5.0 · @aifinpay/mcp 2.0.0-rc.3 — 2026-08-27 **aifinpay-agent 1.5.0 changes where money goes. Read this before upgrading.** diff --git a/node/package-lock.json b/node/package-lock.json index 08724d4..34a428e 100644 --- a/node/package-lock.json +++ b/node/package-lock.json @@ -1,12 +1,12 @@ { "name": "@aifinpay/agent", - "version": "2.0.0-rc.2", + "version": "2.0.0-rc.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@aifinpay/agent", - "version": "2.0.0-rc.2", + "version": "2.0.0-rc.5", "license": "MIT", "dependencies": { "@noble/curves": "^1.9.0", diff --git a/node/package.json b/node/package.json index bad0e3a..dc23f07 100644 --- a/node/package.json +++ b/node/package.json @@ -1,6 +1,6 @@ { "name": "@aifinpay/agent", - "version": "2.0.0-rc.3", + "version": "2.0.0-rc.5", "description": "AiFinPay SDK for global Agent Passport identity and route-verified AIFP-1/AIFP-2 settlement for autonomous AI agents.", "type": "module", "main": "dist/index.js", diff --git a/node/scripts/supervised-settle.mjs b/node/scripts/supervised-settle.mjs new file mode 100644 index 0000000..da364f3 --- /dev/null +++ b/node/scripts/supervised-settle.mjs @@ -0,0 +1,141 @@ +#!/usr/bin/env node +/** + * Supervised v1.3 settlement — the deliberate circle-breaker (AIFINP-213). + * + * The SDK refuses to settle on a route the registry has not enabled, and the + * registry enables a route only after a paid mainnet settlement with verified + * balance deltas. Someone has to make the first payment on purpose. This is + * that tool, and it is deliberately NOT the SDK path: + * + * - it reads the route from the canonical table by chain AND route, never + * by address, and re-verifies the contract's bytecode hash and owner() + * against the chain before it will print anything; + * - it does not read `settlementEnabled` at all — that flag is the SDK's + * gate, and this script exists to produce the evidence that flips it; + * - it never holds a key. `calldata` prints the exact transaction for a + * wallet to sign (to, value, data) with the selector shown; `verify` + * takes the resulting hash and proves what happened from chain state. + * + * node scripts/supervised-settle.mjs calldata polygon merchant-aifp1 \ + * --merchant 0x… --amount 0.5 --order supervised-001 [--ttl 900] + * + * node scripts/supervised-settle.mjs verify polygon merchant-aifp1 0x + * + * `verify` fails closed: it re-derives the expected split from the route's + * bps, reads the payer/merchant/treasury balances at the block before and the + * block of the transaction, decodes the Payment event, and requires all three + * to agree. Gas is accounted for on the payer side from the receipt. + * + * Build first: `npm run build` — this imports the compiled table so the + * addresses are exactly what the SDK would use. + */ +import { createPublicClient, http, encodeFunctionData, decodeEventLog, keccak256, toHex, parseEther, formatEther, getAddress } from "viem"; +import { SPLITTER_ROUTES, SPLITTER_GOVERNANCE } from "../dist/splitterRoutes.generated.js"; +import { V13_ABI, SETTLEMENT_V13_SELECTORS } from "../dist/settlement.js"; + +const [mode, chain, routeName, ...rest] = process.argv.slice(2); +const flag = (name, fallback) => { const i = rest.indexOf(`--${name}`); return i === -1 ? fallback : rest[i + 1]; }; +const die = (msg) => { console.error(`✗ ${msg}`); process.exit(1); }; + +if (!["calldata", "verify"].includes(mode) || !chain || !routeName) { + die("usage: supervised-settle.mjs … (see header)"); +} +const route = SPLITTER_ROUTES[`${chain}:${routeName}`]; +if (!route) die(`no canonical route ${chain}:${routeName} — selection is by chain AND route, never by address`); + +const rpc = flag("rpc", route.defaultRpc); +const client = createPublicClient({ chain: route.viemChain, transport: http(rpc) }); +const lc = (a) => a.toLowerCase(); + +// Chain must agree with the registry before anything else happens. +const chainId = await client.getChainId(); +if (chainId !== route.chainId) die(`RPC serves chain ${chainId}, registry says ${route.chainId}`); +const code = await client.getBytecode({ address: route.splitter }); +if (!code || code === "0x") die(`no code at ${route.splitter}`); +if (lc(keccak256(code)) !== lc(route.runtimeCodeHash)) die("runtime code hash does not match the registry"); +const owner = await client.readContract({ address: route.splitter, abi: [{ type: "function", name: "owner", stateMutability: "view", inputs: [], outputs: [{ type: "address" }] }], functionName: "owner" }); +if (lc(owner) !== lc(route.owner) || lc(owner) !== lc(SPLITTER_GOVERNANCE.safe)) die(`owner() is ${owner}, expected the governance Safe ${SPLITTER_GOVERNANCE.safe}`); +const [treasuryBps, ipCreatorBps, treasury] = await Promise.all([ + client.readContract({ address: route.splitter, abi: [{ type: "function", name: "treasuryBps", stateMutability: "view", inputs: [], outputs: [{ type: "uint256" }] }], functionName: "treasuryBps" }), + client.readContract({ address: route.splitter, abi: [{ type: "function", name: "ipCreatorBps", stateMutability: "view", inputs: [], outputs: [{ type: "uint256" }] }], functionName: "ipCreatorBps" }), + client.readContract({ address: route.splitter, abi: [{ type: "function", name: "treasury", stateMutability: "view", inputs: [], outputs: [{ type: "address" }] }], functionName: "treasury" }), +]); +if (Number(treasuryBps) !== route.treasuryBps || Number(ipCreatorBps) !== route.ipCreatorBps) die("on-chain bps do not match the registry"); +if (lc(treasury) !== lc(route.treasury)) die(`treasury() is ${treasury}, registry says ${route.treasury}`); +console.log(`✓ ${chain}:${routeName} ${route.splitter} — chain ${chainId}, hash, owner (${owner.slice(0, 10)}…), ${route.treasuryBps}/${route.ipCreatorBps} bps, treasury ${treasury.slice(0, 10)}… all match the registry`); + +const split = (gross) => { + const t = (gross * BigInt(route.treasuryBps)) / 10_000n; + const c = (gross * BigInt(route.ipCreatorBps)) / 10_000n; + return { merchant: gross - t - c, treasury: t, creator: c }; +}; + +if (mode === "calldata") { + const merchant = getAddress(flag("merchant") ?? die("--merchant required")); + const amount = flag("amount") ?? die("--amount required (native units, e.g. 0.5)"); + const orderId = flag("order") ?? die("--order required"); + const ttl = Number(flag("ttl", "900")); + const gross = parseEther(amount); + const validUntil = BigInt(Math.floor(Date.now() / 1000) + ttl); + const paymentId = keccak256(toHex(orderId)); + const s = split(gross); + if (s.treasury === 0n && route.treasuryBps > 0) die("amount too small — treasury leg rounds to zero and the contract reverts"); + const data = encodeFunctionData({ abi: V13_ABI, functionName: "payNative", args: [{ paymentId, merchant, grossAmount: gross, ipCreator: "0x0000000000000000000000000000000000000000", validUntil, orderId }] }); + if (data.slice(0, 10) !== SETTLEMENT_V13_SELECTORS.payNative) die("encoded selector is not the deployed payNative selector"); + console.log(`\nSign this from the payer wallet on ${route.viemChain.name} (chain ${route.chainId}):`); + console.log(` to ${route.splitter}`); + console.log(` value ${gross} wei (${amount} ${route.viemChain.nativeCurrency.symbol})`); + console.log(` data ${data}`); + console.log(`\nselector ${data.slice(0, 10)} = payNative((bytes32,address,uint256,address,uint256,string))`); + console.log(`paymentId ${paymentId} (keccak256("${orderId}"))`); + console.log(`validUntil ${validUntil} (${new Date(Number(validUntil) * 1000).toISOString()})`); + console.log(`expected split: merchant ${formatEther(s.merchant)}, treasury ${formatEther(s.treasury)}, creator ${formatEther(s.creator)}`); + console.log(`\nthen: node scripts/supervised-settle.mjs verify ${chain} ${routeName} `); + process.exit(0); +} + +// verify +const txHash = rest[0]; +if (!/^0x[0-9a-fA-F]{64}$/.test(txHash ?? "")) die("verify needs a 0x transaction hash"); +const receipt = await client.getTransactionReceipt({ hash: txHash }); +if (receipt.status !== "success") die(`transaction ${txHash} reverted`); +if (lc(receipt.to) !== lc(route.splitter)) die(`transaction went to ${receipt.to}, not the registry splitter ${route.splitter}`); +const tx = await client.getTransaction({ hash: txHash }); +if (tx.input.slice(0, 10) !== SETTLEMENT_V13_SELECTORS.payNative) die(`calldata selector ${tx.input.slice(0, 10)} is not payNative (${SETTLEMENT_V13_SELECTORS.payNative})`); + +const paymentLog = receipt.logs.map((l) => { try { return decodeEventLog({ abi: V13_ABI, data: l.data, topics: l.topics }); } catch { return null; } }).find((e) => e?.eventName === "Payment"); +if (!paymentLog) die("no Payment event in the receipt"); +const ev = paymentLog.args; +const gross = ev.totalAmount; +const expected = split(gross); +const problems = []; +if (ev.merchantAmount !== expected.merchant) problems.push(`event merchantAmount ${ev.merchantAmount} ≠ expected ${expected.merchant}`); +if (ev.treasuryAmount !== expected.treasury) problems.push(`event treasuryAmount ${ev.treasuryAmount} ≠ expected ${expected.treasury}`); +if (ev.ipCreatorAmount !== expected.creator) problems.push(`event ipCreatorAmount ${ev.ipCreatorAmount} ≠ expected ${expected.creator}`); +if (tx.value !== gross) problems.push(`tx value ${tx.value} ≠ event totalAmount ${gross}`); +if (lc(ev.payer) !== lc(tx.from)) problems.push(`event payer ${ev.payer} ≠ tx.from ${tx.from}`); + +const before = receipt.blockNumber - 1n, at = receipt.blockNumber; +const bal = async (a, b) => client.getBalance({ address: a, blockNumber: b }); +const [payer0, payer1, merch0, merch1, treas0, treas1, splitter0, splitter1] = await Promise.all([ + bal(tx.from, before), bal(tx.from, at), bal(ev.merchant, before), bal(ev.merchant, at), + bal(treasury, before), bal(treasury, at), bal(route.splitter, before), bal(route.splitter, at), +]); +const gasPaid = receipt.gasUsed * receipt.effectiveGasPrice; +const payerDelta = payer0 - payer1; +// Same-block noise (other txs touching these accounts) would show here as a +// mismatch; that is a reason to look, not a reason to explain it away. +if (payerDelta !== gross + gasPaid) problems.push(`payer balance fell by ${payerDelta}, expected gross ${gross} + gas ${gasPaid} = ${gross + gasPaid}`); +if (merch1 - merch0 !== expected.merchant) problems.push(`merchant balance rose by ${merch1 - merch0}, expected ${expected.merchant}`); +if (treas1 - treas0 !== expected.treasury) problems.push(`treasury balance rose by ${treas1 - treas0}, expected ${expected.treasury}`); +if (splitter1 !== splitter0) problems.push(`splitter balance changed by ${splitter1 - splitter0}; it must retain nothing`); + +console.log(`\nPayment ${ev.paymentId} in block ${at}, tx ${txHash}`); +console.log(` payer ${ev.payer} −${formatEther(gross)} −gas ${formatEther(gasPaid)}`); +console.log(` merchant ${ev.merchant} +${formatEther(ev.merchantAmount)}`); +console.log(` treasury ${treasury} +${formatEther(ev.treasuryAmount)}`); +console.log(` creator ${formatEther(ev.ipCreatorAmount)} (route carries ${route.ipCreatorBps} bps)`); +console.log(` orderId "${ev.orderId}" validUntil ${ev.validUntil}`); +if (problems.length) { console.error("\n✗ NOT VERIFIED:"); for (const p of problems) console.error(` ${p}`); process.exit(1); } +console.log(`\n✓ VERIFIED — balance deltas match the ${route.treasuryBps}/${route.ipCreatorBps} bps profile exactly; the splitter retained nothing.`); +console.log(`This is the evidence for setting ${chain}:${routeName} settlementEnabled: true in registry/registry.json (evm-contract), then npm run registry:sync.`); diff --git a/node/src/index.ts b/node/src/index.ts index cf3433f..522fff7 100644 --- a/node/src/index.ts +++ b/node/src/index.ts @@ -31,6 +31,13 @@ export { executeSettlementInvoice, SETTLEMENT_CHAIN_IDS, SETTLEMENT_EXPECTED_BPS, + settleInvoice, + trustedPinFromRegistry, + ROUTE_FOR_CLASS, + SETTLEMENT_V13_SELECTORS, + V13_ABI, + V13_NATIVE_SIGNATURE, + V13_STABLE_SIGNATURE, } from "./settlement.js"; export type { SettlementRouteClass, diff --git a/node/src/settlement.ts b/node/src/settlement.ts index ed89393..fed3c73 100644 --- a/node/src/settlement.ts +++ b/node/src/settlement.ts @@ -1,10 +1,17 @@ import { keccak256, + toFunctionSelector, type Address, type Hex, type PublicClient, type WalletClient, } from "viem"; +import { + SPLITTER_GOVERNANCE, + resolveSettlingSplitterRoute, + type SplitterRoute, + type SplitterRouteDeployment, +} from "./splitterRoutes.js"; export type SettlementRouteClass = "AIFP-1" | "AIFP-2"; export type SettlementEvmNetwork = @@ -45,6 +52,13 @@ export interface TrustedSettlementRoutePin { splitter_version: "1.3"; splitter: Address; runtime_code_hash: Hex; + /** + * The governance Safe the splitter must report as owner(). Present on every + * pin derived from the canonical registry; verified live before signing. + * The owner can move the treasury and rewrite the whitelist, so a pin + * without it is trusting a contract whose controller nobody checked. + */ + owner?: Address; } export type TrustedSettlementRouteRegistry = Partial>>>; @@ -90,7 +104,7 @@ interface SettlementInvoiceBase { export interface NativeSettlementInvoice extends SettlementInvoiceBase { transaction: { kind: "evm_contract_call"; - function: "payNative(bytes32,address,uint256,address,uint256,string)"; + function: typeof V13_NATIVE_SIGNATURE; args: { paymentId: Hex; merchant: Address; @@ -115,7 +129,7 @@ export interface StableSettlementInvoice extends SettlementInvoiceBase { amount: string; }; settle: { - function: "payStable(bytes32,address,uint256,address,address,uint256,string)"; + function: typeof V13_STABLE_SIGNATURE; args: { paymentId: Hex; token: Address; @@ -175,32 +189,76 @@ const PROFILE_ABI = [ { type: "function", name: "ipCreatorBps", stateMutability: "view", inputs: [], outputs: [{ type: "uint256" }] }, ] as const; -const V13_ABI = [ +/** + * The deployed v1.3 entrypoints take ONE struct argument. The selector is + * derived from the tuple signature — payNative((bytes32,address,uint256, + * address,uint256,string)) = 0x27a3bbaf — and that is the only selector the + * deployed bytecode carries. The flat six-parameter spelling hashes to + * 0x8e4a8903, which the contract does not have: calldata in that shape gets + * an empty revert before any logic runs. This module encoded the flat shape + * until 2026-08-29, and no test caught it because no test reached a contract. + * SETTLEMENT_V13_SELECTORS below exists so a test can pin the real one. + */ +export const V13_NATIVE_SIGNATURE = "payNative((bytes32,address,uint256,address,uint256,string))" as const; +export const V13_STABLE_SIGNATURE = "payStable((bytes32,address,uint256,address,address,uint256,string))" as const; + +const NATIVE_PAYMENT_COMPONENTS = [ + { type: "bytes32", name: "paymentId" }, + { type: "address", name: "merchant" }, + { type: "uint256", name: "grossAmount" }, + { type: "address", name: "ipCreator" }, + { type: "uint256", name: "validUntil" }, + { type: "string", name: "orderId" }, +] as const; + +const STABLE_PAYMENT_COMPONENTS = [ + { type: "bytes32", name: "paymentId" }, + { type: "address", name: "token" }, + { type: "uint256", name: "grossAmount" }, + { type: "address", name: "merchant" }, + { type: "address", name: "ipCreator" }, + { type: "uint256", name: "validUntil" }, + { type: "string", name: "orderId" }, +] as const; + +export const V13_ABI = [ { type: "function", name: "payNative", stateMutability: "payable", - inputs: [ - { type: "bytes32", name: "paymentId" }, - { type: "address", name: "merchant" }, - { type: "uint256", name: "grossAmount" }, - { type: "address", name: "ipCreator" }, - { type: "uint256", name: "validUntil" }, - { type: "string", name: "orderId" }, - ], outputs: [], + inputs: [{ type: "tuple", name: "_payment", components: NATIVE_PAYMENT_COMPONENTS }], + outputs: [], }, { type: "function", name: "payStable", stateMutability: "nonpayable", + inputs: [{ type: "tuple", name: "_payment", components: STABLE_PAYMENT_COMPONENTS }], + outputs: [], + }, + { + type: "event", name: "Payment", inputs: [ - { type: "bytes32", name: "paymentId" }, - { type: "address", name: "token" }, - { type: "uint256", name: "grossAmount" }, - { type: "address", name: "merchant" }, - { type: "address", name: "ipCreator" }, - { type: "uint256", name: "validUntil" }, - { type: "string", name: "orderId" }, - ], outputs: [], + { type: "bytes32", name: "paymentId", indexed: true }, + { type: "address", name: "payer", indexed: true }, + { type: "address", name: "merchant", indexed: true }, + { type: "address", name: "token", indexed: false }, + { type: "uint256", name: "totalAmount", indexed: false }, + { type: "uint256", name: "merchantAmount", indexed: false }, + { type: "uint256", name: "treasuryAmount", indexed: false }, + { type: "uint256", name: "ipCreatorAmount", indexed: false }, + { type: "uint256", name: "validUntil", indexed: false }, + { type: "string", name: "orderId", indexed: false }, + ], }, ] as const; +/** The selectors the deployed v1.3 bytecode actually carries. */ +export const SETTLEMENT_V13_SELECTORS = Object.freeze({ + payNative: toFunctionSelector(V13_NATIVE_SIGNATURE), + payStable: toFunctionSelector(V13_STABLE_SIGNATURE), +}); + +const OWNER_ABI = [ + { type: "function", name: "owner", stateMutability: "view", inputs: [], outputs: [{ type: "address" }] }, +] as const; + const ERC20_ABI = [ { type: "function", name: "allowance", stateMutability: "view", inputs: [{ type: "address", name: "owner" }, { type: "address", name: "spender" }], outputs: [{ type: "uint256" }] }, { type: "function", name: "approve", stateMutability: "nonpayable", inputs: [{ type: "address", name: "spender" }, { type: "uint256", name: "amount" }], outputs: [{ type: "bool" }] }, @@ -324,7 +382,7 @@ export function validateSettlementInvoice(invoice: SettlementInvoice): void { ) { throw new SettlementProtocolError("stable approval does not match invoice"); } - if (invoice.transaction.settle.function !== "payStable(bytes32,address,uint256,address,address,uint256,string)") { + if (invoice.transaction.settle.function !== V13_STABLE_SIGNATURE) { throw new SettlementProtocolError("unexpected stable v1.3 function signature"); } if ( @@ -338,7 +396,7 @@ export function validateSettlementInvoice(invoice: SettlementInvoice): void { return; } - if (invoice.transaction.function !== "payNative(bytes32,address,uint256,address,uint256,string)") { + if (invoice.transaction.function !== V13_NATIVE_SIGNATURE) { throw new SettlementProtocolError("unexpected native v1.3 function signature"); } if (BigInt(invoice.transaction.value) !== gross || BigInt(invoice.transaction.args.grossAmount) !== gross) { @@ -377,6 +435,68 @@ export function validateTrustedSettlementRoutePin( } } +/** Protocol route class → canonical registry route. The only mapping there is. */ +export const ROUTE_FOR_CLASS: Record = Object.freeze({ + "AIFP-1": "merchant-aifp1", + "AIFP-2": "agent-x402", +}); + +/** + * Derive the trusted pin from the canonical registry, by protocol route and + * chain — never from the invoice, never from a chain-keyed table, never with + * a fallback. This is the resolver Dimitry specified on 2026-08-27: + * + * protocol route → chain → resolveSettlingSplitterRoute → v1.3 tuple ABI → + * signing → splitter + * + * It throws when the route is not enabled for settlement, and that is the + * point: enabling happens in the registry, one chain-and-route at a time, + * after a supervised paid settlement — not here, and not because a backend + * said so. The invoice's own splitter address is then checked AGAINST this + * pin by validateTrustedSettlementRoutePin; a mismatch is a hard reject. + */ +export function trustedPinFromRegistry( + routeClass: SettlementRouteClass, + chain: SettlementEvmNetwork, + now: Date = new Date(), +): TrustedSettlementRoutePin { + const route: SplitterRouteDeployment = resolveSettlingSplitterRoute(chain, ROUTE_FOR_CLASS[routeClass], now); + const expected = EXPECTED_BPS[routeClass]; + if (route.treasuryBps !== expected.treasury || route.ipCreatorBps !== expected.creator) { + // The registry and this module disagree about what the route class means. + // Neither is allowed to win silently. + throw new SettlementProtocolError( + `registry route ${chain}:${route.route} is ${route.treasuryBps}/${route.ipCreatorBps} but ` + + `${routeClass} is ${expected.treasury}/${expected.creator}`, + "profile_mismatch", + ); + } + return { + route_class: routeClass, + chain, + chain_id: route.chainId, + splitter_version: "1.3", + splitter: route.splitter, + runtime_code_hash: route.runtimeCodeHash, + owner: route.owner, + }; +} + +/** + * Settle an invoice through the canonical registry. The pin comes from + * trustedPinFromRegistry, so there is nothing for a caller to get wrong and + * nothing for a backend to override. + */ +export async function settleInvoice( + invoice: SettlementInvoice, + walletClient: WalletClient, + publicClient: PublicClient, + now: Date = new Date(), +): Promise { + const pin = trustedPinFromRegistry(invoice.route_class, invoice.chain, now); + return executeSettlementInvoice(invoice, walletClient, publicClient, pin); +} + export class SettlementClient { readonly baseUrl: string; @@ -434,6 +554,17 @@ export async function verifySettlementRouteOnChain( if (Number(treasuryBps) !== expected.treasury || Number(creatorBps) !== expected.creator) { throw new SettlementProtocolError("on-chain route economics do not match trusted route class", "profile_mismatch"); } + // Who controls the contract that is about to receive the money. A pin from + // the registry names the governance Safe; the contract must agree, or the + // treasury and whitelist above are only true until someone else changes them. + const expectedOwner = trustedPin.owner ?? SPLITTER_GOVERNANCE.safe; + const owner = await publicClient.readContract({ address: trustedPin.splitter, abi: OWNER_ABI, functionName: "owner" }); + if (lc(owner) !== lc(expectedOwner)) { + throw new SettlementProtocolError( + `splitter owner is ${owner}, expected the governance Safe ${expectedOwner}`, + "owner_mismatch", + ); + } } async function waitSuccess(publicClient: PublicClient, hash: Hex): Promise { @@ -505,15 +636,15 @@ export async function executeSettlementInvoice( address: trustedPin.splitter, abi: V13_ABI, functionName: "payStable", - args: [ - invoice.transaction.settle.args.paymentId, - invoice.transaction.settle.args.token, - BigInt(invoice.transaction.settle.args.grossAmount), - invoice.transaction.settle.args.merchant, - invoice.transaction.settle.args.ipCreator, - BigInt(invoice.transaction.settle.args.validUntil), - invoice.transaction.settle.args.orderId, - ], + args: [{ + paymentId: invoice.transaction.settle.args.paymentId, + token: invoice.transaction.settle.args.token, + grossAmount: BigInt(invoice.transaction.settle.args.grossAmount), + merchant: invoice.transaction.settle.args.merchant, + ipCreator: invoice.transaction.settle.args.ipCreator, + validUntil: BigInt(invoice.transaction.settle.args.validUntil), + orderId: invoice.transaction.settle.args.orderId, + }], account, chain: walletClient.chain, }); @@ -522,14 +653,14 @@ export async function executeSettlementInvoice( address: trustedPin.splitter, abi: V13_ABI, functionName: "payNative", - args: [ - invoice.transaction.args.paymentId, - invoice.transaction.args.merchant, - BigInt(invoice.transaction.args.grossAmount), - invoice.transaction.args.ipCreator, - BigInt(invoice.transaction.args.validUntil), - invoice.transaction.args.orderId, - ], + args: [{ + paymentId: invoice.transaction.args.paymentId, + merchant: invoice.transaction.args.merchant, + grossAmount: BigInt(invoice.transaction.args.grossAmount), + ipCreator: invoice.transaction.args.ipCreator, + validUntil: BigInt(invoice.transaction.args.validUntil), + orderId: invoice.transaction.args.orderId, + }], value: BigInt(invoice.transaction.value), account, chain: walletClient.chain, diff --git a/node/src/unifiedAgent.ts b/node/src/unifiedAgent.ts index ef7a705..c4fca01 100644 --- a/node/src/unifiedAgent.ts +++ b/node/src/unifiedAgent.ts @@ -30,6 +30,13 @@ import { } from "viem/accounts"; import { polygon, base, arbitrum, optimism, bsc, mainnet, unichain, type Chain } from "viem/chains"; import { botchain, xrplevm } from "./chains.js"; +import { + V13_ABI, + trustedPinFromRegistry, + verifySettlementRouteOnChain, + type TrustedSettlementRoutePin, +} from "./settlement.js"; +import { resolveSettlingSplitterRoute, type SplitterRouteDeployment } from "./splitterRoutes.js"; import { Connection, Keypair, @@ -1678,13 +1685,27 @@ export class AiFinPayAgent { get aifp1Receipts(): Aifp1ReceiptCache { return this._aifp1Cache; } /** - * Legacy fetchPaid cannot safely execute a v1.3 quote yet: it has no - * independently pinned runtime hash/profile input. Keep it fail-closed until - * it is wired through SettlementClient + executeSettlementInvoice. + * AIFP-1 v1.3 settlement, wired exactly as specified on 2026-08-27: + * + * protocol route → chain → resolveSettlingSplitterRoute → v1.3 tuple ABI + * → signing → splitter + * + * with no fallback to SPLITTER_DEPLOYMENTS and nothing taken from a server. + * The contract address, its runtime hash and its owner all come from the + * canonical registry; the chain re-confirms each of them immediately before + * the wallet signs. The route must be enabled for settlement in that + * registry — today none is, so this throws SplitterRouteNotSettlingError — + * and enabling it is a registry decision made per chain-and-route after a + * supervised paid settlement (scripts/supervised-settle.mjs), not something + * this method can do. + * + * AIFP-1 quotes are denominated on Polygon (`quote.pay_to.polygon`), so the + * chain is fixed here rather than read from the quote: a quote cannot move a + * settlement to a chain the registry did not enable. * - * Tests replace this method with a chain stub; production never signs here. + * Tests replace this method with a chain stub; production signs here. */ - private async settleAifp1NativeV13(_p: { + private async settleAifp1NativeV13(p: { merchantWallet: `0x${string}`; grossWei: bigint; merchantWei: bigint; @@ -1693,9 +1714,88 @@ export class AiFinPayAgent { validUntil: bigint; orderId: string; }): Promise<`0x${string}`> { - throw new AiFinPayError( - "AIFP-1 fetchPaid settlement is disabled until a trusted v1.3 deployment/runtime profile is supplied; use SettlementClient with an independent TrustedSettlementRoutePin", + const chain = "polygon" as const; + // Throws unless the registry has enabled polygon:merchant-aifp1. + const pin: TrustedSettlementRoutePin = trustedPinFromRegistry("AIFP-1", chain); + const route: SplitterRouteDeployment = resolveSettlingSplitterRoute(chain, "merchant-aifp1"); + + // The quote's split must be the registry's split. aifp1.ts has already + // validated the quote's own arithmetic; this checks it against the + // contract's immutable economics rather than against itself. + const expectedTreasury = (p.grossWei * BigInt(route.treasuryBps)) / 10_000n; + const expectedCreator = (p.grossWei * BigInt(route.ipCreatorBps)) / 10_000n; + const expectedMerchant = p.grossWei - expectedTreasury - expectedCreator; + if (p.treasuryWei !== expectedTreasury || p.creatorWei !== expectedCreator || p.merchantWei !== expectedMerchant) { + throw new AiFinPayError( + `AIFP-1 quote split ${p.merchantWei}/${p.treasuryWei}/${p.creatorWei} does not match the ` + + `${route.treasuryBps}/${route.ipCreatorBps} bps profile of ${chain}:${route.route}`, + ); + } + if (route.ipCreatorBps === 0 && p.creatorWei !== 0n) { + throw new AiFinPayError("AIFP-1 route carries no creator leg; a non-zero creator amount is not settleable"); + } + + const { publicClient, walletClient } = this.v13Clients(route); + // Bytecode hash, bps and owner() read from the chain and compared to the + // registry pin. Fails closed on any disagreement or unreachable RPC. + await verifySettlementRouteOnChain( + { + route_class: "AIFP-1", + chain, + chain_id: route.chainId, + splitter_version: "1.3", + splitter: route.splitter, + runtime_code_hash: route.runtimeCodeHash, + } as unknown as Parameters[0], + publicClient, + pin, ); + + const balance = await publicClient.getBalance({ address: this.evmAccount.address }); + if (balance < p.grossWei) { + throw new AiFinPayError( + `insufficient ${route.viemChain.nativeCurrency.symbol} on ${chain}: need ${p.grossWei} wei, have ${balance}`, + ); + } + + const txHash = await walletClient.writeContract({ + address: route.splitter, + abi: V13_ABI, + functionName: "payNative", + args: [{ + // The Payment event's paymentId and orderId are what the gateway + // verifies against the quote id (order_id_mismatch otherwise). + paymentId: paymentIdFor(p.orderId), + merchant: p.merchantWallet, + grossAmount: p.grossWei, + ipCreator: "0x0000000000000000000000000000000000000000", + validUntil: p.validUntil, + orderId: p.orderId, + }], + value: p.grossWei, + chain: route.viemChain, + account: this.evmAccount, + }); + const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash }); + if (receipt.status !== "success") { + throw new AiFinPayError(`${route.viemChain.name} v1.3 settlement reverted: ${txHash}`); + } + return txHash; + } + + /** + * Clients for a v1.3 registry route. Transport comes from the route entry + * (or the caller's evmRpcUrls override) — never from SPLITTER_DEPLOYMENTS, + * so the v1.3 path has no dependency on the legacy table at all. + */ + private v13Clients(route: SplitterRouteDeployment): { publicClient: PublicClient; walletClient: WalletClient } { + const override = (this.evmRpcUrls as Partial>)[route.chain] + ?? (route.chain === "polygon" ? this.polygonRpc : undefined); + const transport = http(override ?? route.defaultRpc); + return { + publicClient: createPublicClient({ chain: route.viemChain, transport }), + walletClient: createWalletClient({ chain: route.viemChain, transport, account: this.evmAccount }), + }; } /** diff --git a/node/tests/aifp1-v13-gate.test.ts b/node/tests/aifp1-v13-gate.test.ts new file mode 100644 index 0000000..c1e26dc --- /dev/null +++ b/node/tests/aifp1-v13-gate.test.ts @@ -0,0 +1,32 @@ +// The agent's AIFP-1 settlement is wired through the registry resolver and is +// closed until the registry opens it. No network: the gate throws before any +// client is built or any RPC is touched. +import { describe, expect, it } from "vitest"; +import { AiFinPayAgent, SplitterRouteNotSettlingError } from "../src/index.js"; + +type Settle = (p: { + merchantWallet: `0x${string}`; grossWei: bigint; merchantWei: bigint; treasuryWei: bigint; + creatorWei: bigint; validUntil: bigint; orderId: string; +}) => Promise<`0x${string}`>; + +describe("AIFP-1 v1.3 settlement gate", () => { + it("refuses to settle while polygon:merchant-aifp1 is not enabled in the registry", async () => { + const agent = await AiFinPayAgent.new({}); + const settle = (agent as unknown as { settleAifp1NativeV13: Settle }).settleAifp1NativeV13.bind(agent); + await expect(settle({ + merchantWallet: "0x2222222222222222222222222222222222222222", + grossWei: 10_000n, merchantWei: 9_900n, treasuryWei: 100n, creatorWei: 0n, + validUntil: BigInt(Math.floor(Date.now() / 1000) + 300), orderId: "q-1", + })).rejects.toBeInstanceOf(SplitterRouteNotSettlingError); + }); + + it("names the reason: settlement is enabled only after a supervised paid settlement", async () => { + const agent = await AiFinPayAgent.new({}); + const settle = (agent as unknown as { settleAifp1NativeV13: Settle }).settleAifp1NativeV13.bind(agent); + await expect(settle({ + merchantWallet: "0x2222222222222222222222222222222222222222", + grossWei: 10_000n, merchantWei: 9_900n, treasuryWei: 100n, creatorWei: 0n, + validUntil: BigInt(Math.floor(Date.now() / 1000) + 300), orderId: "q-1", + })).rejects.toThrow(/settlement is not enabled for this route yet/); + }); +}); diff --git a/node/tests/settlement-execution.test.ts b/node/tests/settlement-execution.test.ts new file mode 100644 index 0000000..89f39bc --- /dev/null +++ b/node/tests/settlement-execution.test.ts @@ -0,0 +1,120 @@ +// The v1.3 execution path: the ABI the SDK signs with, the pin it settles +// against, and the gate that keeps every route closed until the registry +// says otherwise. No network — the one thing that needs a chain (the flat +// selector getting an empty revert on Polygon) was proven by eth_call on +// 2026-08-29 and is pinned here as a constant instead. +import { afterEach, describe, expect, it } from "vitest"; +import { encodeFunctionData, toFunctionSelector } from "viem"; +import { + SETTLEMENT_V13_SELECTORS, + V13_ABI, + V13_NATIVE_SIGNATURE, + V13_STABLE_SIGNATURE, + ROUTE_FOR_CLASS, + trustedPinFromRegistry, + validateTrustedSettlementRoutePin, + SettlementProtocolError, + SPLITTER_ROUTES, + SPLITTER_GOVERNANCE, + SplitterRouteNotSettlingError, + type NativeSettlementInvoice, + type SplitterRouteDeployment, +} from "../src/index.js"; + +const zero = "0x0000000000000000000000000000000000000000" as const; +const merchant = "0x2222222222222222222222222222222222222222" as const; +const paymentId = `0x${"33".repeat(32)}` as `0x${string}`; + +describe("v1.3 ABI is the deployed one", () => { + it("payNative is the tuple selector 0x27a3bbaf, which the bytecode carries", () => { + expect(SETTLEMENT_V13_SELECTORS.payNative).toBe("0x27a3bbaf"); + expect(toFunctionSelector(V13_NATIVE_SIGNATURE)).toBe("0x27a3bbaf"); + }); + + it("is NOT the flat six-parameter selector the module used to encode", () => { + // Proven on Polygon merchant-aifp1 (0x27C1C075…) by eth_call: + // 0x8e4a8903… → execution reverted, data 0x (no such function) + // 0x27a3bbaf… → IncorrectNativeValue(1000, 0) (reached the logic) + const flat = toFunctionSelector("payNative(bytes32,address,uint256,address,uint256,string)"); + expect(flat).toBe("0x8e4a8903"); + expect(SETTLEMENT_V13_SELECTORS.payNative).not.toBe(flat); + }); + + it("payStable is likewise the tuple form", () => { + expect(SETTLEMENT_V13_SELECTORS.payStable).toBe(toFunctionSelector(V13_STABLE_SIGNATURE)); + expect(V13_STABLE_SIGNATURE.startsWith("payStable((")).toBe(true); + }); + + it("calldata built from V13_ABI starts with the deployed selector", () => { + const data = encodeFunctionData({ + abi: V13_ABI, + functionName: "payNative", + args: [{ paymentId, merchant, grossAmount: 10_000n, ipCreator: zero, validUntil: 4_102_444_800n, orderId: "q" }], + }); + expect(data.slice(0, 10)).toBe("0x27a3bbaf"); + }); +}); + +describe("route class → registry route", () => { + it("maps the two protocol classes to the two canonical routes and nothing else", () => { + expect(ROUTE_FOR_CLASS).toEqual({ "AIFP-1": "merchant-aifp1", "AIFP-2": "agent-x402" }); + }); +}); + +describe("trustedPinFromRegistry", () => { + const KEY = "polygon:merchant-aifp1"; + const table = SPLITTER_ROUTES as unknown as Record; + const original = { ...table[KEY] }; + afterEach(() => { table[KEY] = original; }); + + it("refuses every shipped route — none is enabled for settlement", () => { + for (const [key, route] of Object.entries(SPLITTER_ROUTES)) { + const cls = route.route === "merchant-aifp1" ? "AIFP-1" : "AIFP-2"; + expect(() => trustedPinFromRegistry(cls, route.chain), key).toThrow(SplitterRouteNotSettlingError); + } + }); + + it("derives the pin from the registry once a route is enabled — address, hash and owner", () => { + table[KEY] = { ...original, settlementEnabled: true }; + const pin = trustedPinFromRegistry("AIFP-1", "polygon"); + expect(pin).toEqual({ + route_class: "AIFP-1", + chain: "polygon", + chain_id: 137, + splitter_version: "1.3", + splitter: original.splitter, + runtime_code_hash: original.runtimeCodeHash, + owner: original.owner, + }); + expect(pin.owner).toBe(SPLITTER_GOVERNANCE.safe); + }); + + it("never crosses route classes: AIFP-2 on a merchant-aifp1 entry is a different key", () => { + table[KEY] = { ...original, settlementEnabled: true }; + expect(() => trustedPinFromRegistry("AIFP-2", "polygon")).toThrow(SplitterRouteNotSettlingError); + }); + + it("refuses a registry entry whose bps contradict the route class", () => { + table[KEY] = { ...original, settlementEnabled: true, treasuryBps: 0 }; + expect(() => trustedPinFromRegistry("AIFP-1", "polygon")).toThrow(/is 0\/0 but AIFP-1 is 100\/0/); + }); + + it("a backend invoice naming a different splitter is rejected against the registry pin", () => { + table[KEY] = { ...original, settlementEnabled: true }; + const pin = trustedPinFromRegistry("AIFP-1", "polygon"); + const validUntil = Math.floor(Date.now() / 1000) + 300; + const invoice: NativeSettlementInvoice = { + route_class: "AIFP-1", chain: "polygon", chain_id: 137, splitter_version: "1.3", + // The legacy v1.2 Polygon splitter — a real, working contract with the wrong economics. + splitter: "0xbD1fa5453f212F096c0213788a645eC597FB4DDe", + runtime_code_hash: original.runtimeCodeHash, + settlement_semantics: "gross-inclusive", fee_on_top: false, asset: "POL", + payment_id: paymentId, order_id: "q", valid_until: validUntil, merchant_wallet: merchant, + breakdown: { gross_amount: "10000", merchant_amount: "9900", protocol_fee_amount: "100", creator_amount: "0", protocol_fee_bps: 100, creator_bps: 0 }, + transaction: { kind: "evm_contract_call", function: V13_NATIVE_SIGNATURE, args: { paymentId, merchant, grossAmount: "10000", ipCreator: zero, validUntil, orderId: "q" }, value: "10000" }, + authorization: "wallet signature required", + }; + expect(() => validateTrustedSettlementRoutePin(invoice, pin)).toThrow(SettlementProtocolError); + expect(() => validateTrustedSettlementRoutePin(invoice, pin)).toThrow(/does not match the independently trusted deployment pin/); + }); +}); diff --git a/node/tests/settlement-v13.test.ts b/node/tests/settlement-v13.test.ts index 98f66aa..e078780 100644 --- a/node/tests/settlement-v13.test.ts +++ b/node/tests/settlement-v13.test.ts @@ -43,7 +43,7 @@ function nativeAifp1(): NativeSettlementInvoice { }, transaction: { kind: "evm_contract_call", - function: "payNative(bytes32,address,uint256,address,uint256,string)", + function: "payNative((bytes32,address,uint256,address,uint256,string))", args: { paymentId, merchant, @@ -93,7 +93,7 @@ function stableAifp2(): StableSettlementInvoice { amount: "1", }, settle: { - function: "payStable(bytes32,address,uint256,address,address,uint256,string)", + function: "payStable((bytes32,address,uint256,address,address,uint256,string))", args: { paymentId, token, From fe352d016fa7250c98e9d7648acf70506b6795e4 Mon Sep 17 00:00:00 2001 From: Syed Hassan Date: Sat, 29 Aug 2026 22:33:08 +0500 Subject: [PATCH 2/5] chore(supervised-settle): verify the combined delta when merchant and 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. --- node/scripts/supervised-settle.mjs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/node/scripts/supervised-settle.mjs b/node/scripts/supervised-settle.mjs index da364f3..9d43fc2 100644 --- a/node/scripts/supervised-settle.mjs +++ b/node/scripts/supervised-settle.mjs @@ -126,8 +126,15 @@ const payerDelta = payer0 - payer1; // Same-block noise (other txs touching these accounts) would show here as a // mismatch; that is a reason to look, not a reason to explain it away. if (payerDelta !== gross + gasPaid) problems.push(`payer balance fell by ${payerDelta}, expected gross ${gross} + gas ${gasPaid} = ${gross + gasPaid}`); -if (merch1 - merch0 !== expected.merchant) problems.push(`merchant balance rose by ${merch1 - merch0}, expected ${expected.merchant}`); -if (treas1 - treas0 !== expected.treasury) problems.push(`treasury balance rose by ${treas1 - treas0}, expected ${expected.treasury}`); +if (lc(ev.merchant) === lc(treasury)) { + // Merchant and treasury are the same address (the governance Safe paying + // itself, as in the first supervised run): one balance, both legs. + const combined = expected.merchant + expected.treasury; + if (merch1 - merch0 !== combined) problems.push(`merchant=treasury balance rose by ${merch1 - merch0}, expected merchant ${expected.merchant} + treasury ${expected.treasury} = ${combined}`); +} else { + if (merch1 - merch0 !== expected.merchant) problems.push(`merchant balance rose by ${merch1 - merch0}, expected ${expected.merchant}`); + if (treas1 - treas0 !== expected.treasury) problems.push(`treasury balance rose by ${treas1 - treas0}, expected ${expected.treasury}`); +} if (splitter1 !== splitter0) problems.push(`splitter balance changed by ${splitter1 - splitter0}; it must retain nothing`); console.log(`\nPayment ${ev.paymentId} in block ${at}, tx ${txHash}`); From ed3aaf3e63c6897f143437bb1e1aa2457a85a6d2 Mon Sep 17 00:00:00 2001 From: Syed Hassan Date: Sat, 29 Aug 2026 23:10:04 +0500 Subject: [PATCH 3/5] chore(supervised-settle): verify the EIP-7702 delegated path, and require the event to come from the splitter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- node/scripts/supervised-settle.mjs | 31 +++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/node/scripts/supervised-settle.mjs b/node/scripts/supervised-settle.mjs index 9d43fc2..cb770c7 100644 --- a/node/scripts/supervised-settle.mjs +++ b/node/scripts/supervised-settle.mjs @@ -99,12 +99,33 @@ const txHash = rest[0]; if (!/^0x[0-9a-fA-F]{64}$/.test(txHash ?? "")) die("verify needs a 0x transaction hash"); const receipt = await client.getTransactionReceipt({ hash: txHash }); if (receipt.status !== "success") die(`transaction ${txHash} reverted`); -if (lc(receipt.to) !== lc(route.splitter)) die(`transaction went to ${receipt.to}, not the registry splitter ${route.splitter}`); const tx = await client.getTransaction({ hash: txHash }); -if (tx.input.slice(0, 10) !== SETTLEMENT_V13_SELECTORS.payNative) die(`calldata selector ${tx.input.slice(0, 10)} is not payNative (${SETTLEMENT_V13_SELECTORS.payNative})`); -const paymentLog = receipt.logs.map((l) => { try { return decodeEventLog({ abi: V13_ABI, data: l.data, topics: l.topics }); } catch { return null; } }).find((e) => e?.eventName === "Payment"); -if (!paymentLog) die("no Payment event in the receipt"); +// Two ways a wallet reaches the splitter. Direct: tx.to is the splitter and +// the calldata is payNative. Delegated: an EIP-7702 account (MetaMask "smart +// account") sends through its delegation manager, which executes the call AS +// the account — msg.sender stays the payer, so the Payment event still names +// them and the balance deltas still land on them. Either is acceptable; what +// is not negotiable is that the Payment event comes from the registry +// splitter and names tx.from as payer. That is asserted below in both cases. +const direct = lc(receipt.to) === lc(route.splitter); +if (direct && tx.input.slice(0, 10) !== SETTLEMENT_V13_SELECTORS.payNative) { + die(`calldata selector ${tx.input.slice(0, 10)} is not payNative (${SETTLEMENT_V13_SELECTORS.payNative})`); +} +if (!direct) { + const auth = tx.authorizationList?.[0]; + const senderCode = await client.getBytecode({ address: tx.from }); + const delegated = senderCode?.startsWith("0xef0100") ? `0x${senderCode.slice(8, 48)}` : null; + if (!delegated && !auth) die(`transaction went to ${receipt.to}, not the registry splitter, and the sender is not an EIP-7702 account — refusing to guess`); + console.log(`ℹ delegated path: tx.to ${receipt.to} (${tx.type}); sender ${tx.from} delegates to ${delegated ?? auth?.address} — the splitter must still name the sender as payer`); +} + +// The event must be emitted BY the registry splitter, not merely be present. +const paymentLog = receipt.logs + .filter((l) => lc(l.address) === lc(route.splitter)) + .map((l) => { try { return decodeEventLog({ abi: V13_ABI, data: l.data, topics: l.topics }); } catch { return null; } }) + .find((e) => e?.eventName === "Payment"); +if (!paymentLog) die(`no Payment event emitted by the registry splitter ${route.splitter} in this receipt`); const ev = paymentLog.args; const gross = ev.totalAmount; const expected = split(gross); @@ -112,7 +133,7 @@ const problems = []; if (ev.merchantAmount !== expected.merchant) problems.push(`event merchantAmount ${ev.merchantAmount} ≠ expected ${expected.merchant}`); if (ev.treasuryAmount !== expected.treasury) problems.push(`event treasuryAmount ${ev.treasuryAmount} ≠ expected ${expected.treasury}`); if (ev.ipCreatorAmount !== expected.creator) problems.push(`event ipCreatorAmount ${ev.ipCreatorAmount} ≠ expected ${expected.creator}`); -if (tx.value !== gross) problems.push(`tx value ${tx.value} ≠ event totalAmount ${gross}`); +if (direct && tx.value !== gross) problems.push(`tx value ${tx.value} ≠ event totalAmount ${gross}`); if (lc(ev.payer) !== lc(tx.from)) problems.push(`event payer ${ev.payer} ≠ tx.from ${tx.from}`); const before = receipt.blockNumber - 1n, at = receipt.blockNumber; From 4e26980fb80b2e218071f248069b5046f060ae56 Mon Sep 17 00:00:00 2001 From: Syed Hassan Date: Sun, 30 Aug 2026 12:14:59 +0500 Subject: [PATCH 4/5] feat(sdk): a testnet route can never be selected for production settlement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- node/scripts/generate-splitter-routes.mjs | 14 +++++++++++++- node/src/splitterRoutes.generated.ts | 18 ++++++++++++++++++ node/src/splitterRoutes.ts | 13 +++++++++++++ node/tests/splitterRoutes.test.ts | 11 +++++++++++ 4 files changed, 55 insertions(+), 1 deletion(-) diff --git a/node/scripts/generate-splitter-routes.mjs b/node/scripts/generate-splitter-routes.mjs index 9df455d..410acd1 100644 --- a/node/scripts/generate-splitter-routes.mjs +++ b/node/scripts/generate-splitter-routes.mjs @@ -94,9 +94,20 @@ function loadArtifact() { * splitter cannot silently fall back to one. */ function selectRoutes(artifact) { + // A testnet entry (evm-contract #30: `testnet: true`, only ever on a chain + // in the registry's closed testnet set) is deliberately NOT representable + // in the production table. It is owned by a deployer key and verified from + // one provider — both fine for a rehearsal, neither acceptable for a route + // this SDK will settle real money through. Excluded here, so the resolver + // cannot name it at all; and refused again in resolveSettlingSplitterRoute + // in case an artifact ever reaches it another way. + const testnet = Object.entries(artifact.routes).filter(([, r]) => r.testnet === true); const selected = Object.entries(artifact.routes) - .filter(([, route]) => route.version === "1.3" && !route.superseded) + .filter(([, route]) => route.version === "1.3" && !route.superseded && route.testnet !== true) .sort(([a], [b]) => (a < b ? -1 : 1)); + if (testnet.length) { + console.log(` ${testnet.length} testnet route(s) in the artifact, excluded from the production table: ${testnet.map(([k]) => k).join(", ")}`); + } if (selected.length !== EXPECTED_ROUTE_COUNT) { throw new Error( @@ -159,6 +170,7 @@ function render({ artifact, source }, selected) { ipCreatorBps: ${r.ipCreatorBps}, runtimeCodeHash: "${r.runtimeCodeHash}", settlementEnabled: ${r.settlementEnabled}, + testnet: false, rpcQuorum: ${r.rpcQuorum}, stablecoins: ${JSON.stringify(r.stablecoins)}, validFrom: "${r.validFrom}", diff --git a/node/src/splitterRoutes.generated.ts b/node/src/splitterRoutes.generated.ts index 08afc9e..5bf700d 100644 --- a/node/src/splitterRoutes.generated.ts +++ b/node/src/splitterRoutes.generated.ts @@ -51,6 +51,7 @@ export const SPLITTER_ROUTES: Record ipCreatorBps: 0, runtimeCodeHash: "0x0eb0f8ca7792b13ab70f2aa3e779609cd352d279e925ddcd9e901fd9fd68b1b0", settlementEnabled: false, + testnet: false, rpcQuorum: 2, stablecoins: {"USDC":"0xaf88d065e77c8cC2239327C5EDb3A432268e5831","USDT":null}, validFrom: "2026-08-27T00:00:00.000Z", @@ -71,6 +72,7 @@ export const SPLITTER_ROUTES: Record ipCreatorBps: 0, runtimeCodeHash: "0x4ba01815b55bf6ed2d608bed91f480c179fd644d706680c3e4a91d8181ba5c6b", settlementEnabled: false, + testnet: false, rpcQuorum: 2, stablecoins: {"USDC":"0xaf88d065e77c8cC2239327C5EDb3A432268e5831","USDT":null}, validFrom: "2026-08-27T00:00:00.000Z", @@ -91,6 +93,7 @@ export const SPLITTER_ROUTES: Record ipCreatorBps: 0, runtimeCodeHash: "0x0eb0f8ca7792b13ab70f2aa3e779609cd352d279e925ddcd9e901fd9fd68b1b0", settlementEnabled: false, + testnet: false, rpcQuorum: 2, stablecoins: {"USDC":"0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E","USDT":"0x9702230a8ea53601f5cd2dc00fdbc13d4df4a8c7"}, validFrom: "2026-08-27T00:00:00.000Z", @@ -111,6 +114,7 @@ export const SPLITTER_ROUTES: Record ipCreatorBps: 0, runtimeCodeHash: "0x4ba01815b55bf6ed2d608bed91f480c179fd644d706680c3e4a91d8181ba5c6b", settlementEnabled: false, + testnet: false, rpcQuorum: 2, stablecoins: {"USDC":"0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E","USDT":"0x9702230a8ea53601f5cd2dc00fdbc13d4df4a8c7"}, validFrom: "2026-08-27T00:00:00.000Z", @@ -131,6 +135,7 @@ export const SPLITTER_ROUTES: Record ipCreatorBps: 0, runtimeCodeHash: "0x0eb0f8ca7792b13ab70f2aa3e779609cd352d279e925ddcd9e901fd9fd68b1b0", settlementEnabled: false, + testnet: false, rpcQuorum: 2, stablecoins: {"USDC":"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913","USDT":null}, validFrom: "2026-08-27T00:00:00.000Z", @@ -151,6 +156,7 @@ export const SPLITTER_ROUTES: Record ipCreatorBps: 0, runtimeCodeHash: "0x4ba01815b55bf6ed2d608bed91f480c179fd644d706680c3e4a91d8181ba5c6b", settlementEnabled: false, + testnet: false, rpcQuorum: 2, stablecoins: {"USDC":"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913","USDT":null}, validFrom: "2026-08-27T00:00:00.000Z", @@ -171,6 +177,7 @@ export const SPLITTER_ROUTES: Record ipCreatorBps: 0, runtimeCodeHash: "0x0eb0f8ca7792b13ab70f2aa3e779609cd352d279e925ddcd9e901fd9fd68b1b0", settlementEnabled: false, + testnet: false, rpcQuorum: 2, stablecoins: {"USDC":null,"USDT":null}, validFrom: "2026-08-27T00:00:00.000Z", @@ -191,6 +198,7 @@ export const SPLITTER_ROUTES: Record ipCreatorBps: 0, runtimeCodeHash: "0x4ba01815b55bf6ed2d608bed91f480c179fd644d706680c3e4a91d8181ba5c6b", settlementEnabled: false, + testnet: false, rpcQuorum: 2, stablecoins: {"USDC":null,"USDT":null}, validFrom: "2026-08-27T00:00:00.000Z", @@ -211,6 +219,7 @@ export const SPLITTER_ROUTES: Record ipCreatorBps: 0, runtimeCodeHash: "0x0eb0f8ca7792b13ab70f2aa3e779609cd352d279e925ddcd9e901fd9fd68b1b0", settlementEnabled: false, + testnet: false, rpcQuorum: 1, stablecoins: {"USDC":null,"USDT":null}, validFrom: "2026-08-27T00:00:00.000Z", @@ -231,6 +240,7 @@ export const SPLITTER_ROUTES: Record ipCreatorBps: 0, runtimeCodeHash: "0x4ba01815b55bf6ed2d608bed91f480c179fd644d706680c3e4a91d8181ba5c6b", settlementEnabled: false, + testnet: false, rpcQuorum: 1, stablecoins: {"USDC":null,"USDT":null}, validFrom: "2026-08-27T00:00:00.000Z", @@ -251,6 +261,7 @@ export const SPLITTER_ROUTES: Record ipCreatorBps: 0, runtimeCodeHash: "0x0eb0f8ca7792b13ab70f2aa3e779609cd352d279e925ddcd9e901fd9fd68b1b0", settlementEnabled: false, + testnet: false, rpcQuorum: 2, stablecoins: {"USDC":"0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85","USDT":null}, validFrom: "2026-08-27T00:00:00.000Z", @@ -271,6 +282,7 @@ export const SPLITTER_ROUTES: Record ipCreatorBps: 0, runtimeCodeHash: "0x4ba01815b55bf6ed2d608bed91f480c179fd644d706680c3e4a91d8181ba5c6b", settlementEnabled: false, + testnet: false, rpcQuorum: 2, stablecoins: {"USDC":"0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85","USDT":null}, validFrom: "2026-08-27T00:00:00.000Z", @@ -291,6 +303,7 @@ export const SPLITTER_ROUTES: Record ipCreatorBps: 0, runtimeCodeHash: "0x0eb0f8ca7792b13ab70f2aa3e779609cd352d279e925ddcd9e901fd9fd68b1b0", settlementEnabled: false, + testnet: false, rpcQuorum: 2, stablecoins: {"USDC":"0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359","USDT":null}, validFrom: "2026-08-27T00:00:00.000Z", @@ -311,6 +324,7 @@ export const SPLITTER_ROUTES: Record ipCreatorBps: 0, runtimeCodeHash: "0x4ba01815b55bf6ed2d608bed91f480c179fd644d706680c3e4a91d8181ba5c6b", settlementEnabled: false, + testnet: false, rpcQuorum: 2, stablecoins: {"USDC":"0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359","USDT":null}, validFrom: "2026-08-27T00:00:00.000Z", @@ -331,6 +345,7 @@ export const SPLITTER_ROUTES: Record ipCreatorBps: 0, runtimeCodeHash: "0x0eb0f8ca7792b13ab70f2aa3e779609cd352d279e925ddcd9e901fd9fd68b1b0", settlementEnabled: false, + testnet: false, rpcQuorum: 2, stablecoins: {"USDC":"0x078D782b760474a361dDA0AF3839290b0EF57AD6","USDT":null}, validFrom: "2026-08-27T00:00:00.000Z", @@ -351,6 +366,7 @@ export const SPLITTER_ROUTES: Record ipCreatorBps: 0, runtimeCodeHash: "0x4ba01815b55bf6ed2d608bed91f480c179fd644d706680c3e4a91d8181ba5c6b", settlementEnabled: false, + testnet: false, rpcQuorum: 2, stablecoins: {"USDC":"0x078D782b760474a361dDA0AF3839290b0EF57AD6","USDT":null}, validFrom: "2026-08-27T00:00:00.000Z", @@ -371,6 +387,7 @@ export const SPLITTER_ROUTES: Record ipCreatorBps: 0, runtimeCodeHash: "0x0eb0f8ca7792b13ab70f2aa3e779609cd352d279e925ddcd9e901fd9fd68b1b0", settlementEnabled: false, + testnet: false, rpcQuorum: 1, stablecoins: {"USDC":null,"USDT":null}, validFrom: "2026-08-27T00:00:00.000Z", @@ -391,6 +408,7 @@ export const SPLITTER_ROUTES: Record ipCreatorBps: 0, runtimeCodeHash: "0x4ba01815b55bf6ed2d608bed91f480c179fd644d706680c3e4a91d8181ba5c6b", settlementEnabled: false, + testnet: false, rpcQuorum: 1, stablecoins: {"USDC":null,"USDT":null}, validFrom: "2026-08-27T00:00:00.000Z", diff --git a/node/src/splitterRoutes.ts b/node/src/splitterRoutes.ts index 8de8d92..777a611 100644 --- a/node/src/splitterRoutes.ts +++ b/node/src/splitterRoutes.ts @@ -91,6 +91,12 @@ export interface SplitterRouteDeployment { * settlement on that exact route with verified balance deltas. */ settlementEnabled: boolean; + /** + * Always false in the production table: the generator excludes any + * registry entry marked testnet. The field exists so the resolver can + * refuse one that arrives by any other path. + */ + testnet: boolean; /** * How many independent RPC providers agreed on every field above when the * registry was verified. A route verified from one provider can never be @@ -161,6 +167,13 @@ export function resolveSettlingSplitterRoute( ): SplitterRouteDeployment { const entry = resolveSplitterRoute(chain, route); const key = `${entry.chain}:${entry.route}`; + if (entry.testnet === true) { + throw new SplitterRouteNotSettlingError( + key, + "it is a testnet deployment — owned by a deployer key and verified from one provider — " + + "and this SDK settles real money only through governance-owned, multi-provider routes", + ); + } if (!entry.settlementEnabled) { throw new SplitterRouteNotSettlingError( key, diff --git a/node/tests/splitterRoutes.test.ts b/node/tests/splitterRoutes.test.ts index 4f0070b..f6238b3 100644 --- a/node/tests/splitterRoutes.test.ts +++ b/node/tests/splitterRoutes.test.ts @@ -232,6 +232,17 @@ describe("policy window (enabled route)", () => { expect(() => resolve(new Date("nonsense"))).toThrow(/invalid Date/); }); + it("refuses a testnet route even when it is enabled and inside its window", () => { + install({ testnet: true, settlementEnabled: true }); + const middle = new Date((Date.parse(FROM) + Date.parse(UNTIL)) / 2); + expect(() => resolve(middle)).toThrow(SplitterRouteNotSettlingError); + expect(() => resolve(middle)).toThrow(/testnet deployment/); + }); + + it("no shipped route is a testnet", () => { + for (const [key, d] of entries) expect(d.testnet, key).toBe(false); + }); + it("the settlement flag still wins over a valid window", () => { install({ settlementEnabled: false }); const middle = new Date((Date.parse(FROM) + Date.parse(UNTIL)) / 2); From 0f363f5e8fb6a51941377797787c1b2be31d7dca Mon Sep 17 00:00:00 2001 From: Syed Hassan Date: Sun, 30 Aug 2026 12:40:42 +0500 Subject: [PATCH 5/5] chore(supervised-settle): count the OP-stack L1 data fee in the payer's expected delta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- node/scripts/supervised-settle.mjs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/node/scripts/supervised-settle.mjs b/node/scripts/supervised-settle.mjs index cb770c7..8b3be8c 100644 --- a/node/scripts/supervised-settle.mjs +++ b/node/scripts/supervised-settle.mjs @@ -142,11 +142,16 @@ const [payer0, payer1, merch0, merch1, treas0, treas1, splitter0, splitter1] = a bal(tx.from, before), bal(tx.from, at), bal(ev.merchant, before), bal(ev.merchant, at), bal(treasury, before), bal(treasury, at), bal(route.splitter, before), bal(route.splitter, at), ]); -const gasPaid = receipt.gasUsed * receipt.effectiveGasPrice; +// OP-stack chains (Base, Optimism, Unichain) charge an L1 data fee on top of +// L2 gas. viem's op-stack formatters expose it as receipt.l1Fee; it is paid by +// the sender and is not in gasUsed × effectiveGasPrice, so it belongs in the +// payer's expected delta or every OP-stack payment reads as a mismatch. +const l1Fee = typeof receipt.l1Fee === "bigint" ? receipt.l1Fee : 0n; +const gasPaid = receipt.gasUsed * receipt.effectiveGasPrice + l1Fee; const payerDelta = payer0 - payer1; // Same-block noise (other txs touching these accounts) would show here as a // mismatch; that is a reason to look, not a reason to explain it away. -if (payerDelta !== gross + gasPaid) problems.push(`payer balance fell by ${payerDelta}, expected gross ${gross} + gas ${gasPaid} = ${gross + gasPaid}`); +if (payerDelta !== gross + gasPaid) problems.push(`payer balance fell by ${payerDelta}, expected gross ${gross} + gas ${gasPaid - l1Fee}${l1Fee ? ` + L1 fee ${l1Fee}` : ""} = ${gross + gasPaid}`); if (lc(ev.merchant) === lc(treasury)) { // Merchant and treasury are the same address (the governance Safe paying // itself, as in the first supervised run): one balance, both legs. @@ -159,7 +164,7 @@ if (lc(ev.merchant) === lc(treasury)) { if (splitter1 !== splitter0) problems.push(`splitter balance changed by ${splitter1 - splitter0}; it must retain nothing`); console.log(`\nPayment ${ev.paymentId} in block ${at}, tx ${txHash}`); -console.log(` payer ${ev.payer} −${formatEther(gross)} −gas ${formatEther(gasPaid)}`); +console.log(` payer ${ev.payer} −${formatEther(gross)} −gas ${formatEther(gasPaid)}${l1Fee ? ` (incl. L1 fee ${formatEther(l1Fee)})` : ""}`); console.log(` merchant ${ev.merchant} +${formatEther(ev.merchantAmount)}`); console.log(` treasury ${treasury} +${formatEther(ev.treasuryAmount)}`); console.log(` creator ${formatEther(ev.ipCreatorAmount)} (route carries ${route.ipCreatorBps} bps)`);