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/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/scripts/supervised-settle.mjs b/node/scripts/supervised-settle.mjs new file mode 100644 index 0000000..8b3be8c --- /dev/null +++ b/node/scripts/supervised-settle.mjs @@ -0,0 +1,174 @@ +#!/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`); +const tx = await client.getTransaction({ hash: txHash }); + +// 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); +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 (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; +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), +]); +// 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 - 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. + 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}`); +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)`); +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/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/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, 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);