Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**
Expand Down
4 changes: 2 additions & 2 deletions node/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion node/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
14 changes: 13 additions & 1 deletion node/scripts/generate-splitter-routes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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}",
Expand Down
174 changes: 174 additions & 0 deletions node/scripts/supervised-settle.mjs
Original file line number Diff line number Diff line change
@@ -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<txhash>
*
* `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 <calldata|verify> <chain> <route> … (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} <txhash>`);
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.`);
7 changes: 7 additions & 0 deletions node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading