diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a035aa7..56e4cf0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,28 @@ jobs: - name: Install dependencies run: npm ci --no-audit --no-fund + # The route table is generated from a byte-for-byte copy of the canonical + # registry artifact in AiFinPay/evm-contract, where every payment-critical + # field was read from the chain. This regenerates it and compares, so a + # hand-edited payout address, fee split or settlement flag turns CI red + # instead of shipping. It also re-hashes the vendored artifact against its + # recorded provenance, so editing the copy fails the same way. + # + # Offline and deterministic on purpose: this job must not depend on a + # network read of another repository to know whether it is in sync. + - name: Registry — SPLITTER_ROUTES matches the canonical registry + run: npm run registry:check + + # The check above is offline and self-consistent: a writer who can change + # this repository can change the artifact, its recorded provenance and the + # generated table together, and the offline check would bless the set. + # This step breaks that loop by fetching the artifact from the OTHER + # repository at the exact commit provenance names and requiring the bytes + # to match. Both repositories are public, so no token is involved and + # there is nothing here for a compromised writer to forge. + - name: Registry — vendored artifact is byte-identical to evm-contract at the recorded commit + run: node scripts/check-registry-provenance.mjs + - name: Build (tsc) run: npm run build diff --git a/node/package.json b/node/package.json index 43e66a3..bad0e3a 100644 --- a/node/package.json +++ b/node/package.json @@ -1,6 +1,6 @@ { "name": "@aifinpay/agent", - "version": "2.0.0-rc.2", + "version": "2.0.0-rc.3", "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", @@ -15,23 +15,62 @@ "default": "./dist/wallet.js" } }, - "files": ["dist", "README.md", "LICENSE"], + "files": [ + "dist", + "README.md", + "LICENSE" + ], "scripts": { "build": "tsc -p tsconfig.json", + "registry:sync": "node scripts/generate-splitter-routes.mjs", + "registry:check": "node scripts/generate-splitter-routes.mjs --check", "test": "vitest run", "prepublishOnly": "npm run build" }, "keywords": [ - "aifinpay", "ai-agent", "ai-agents", "ai-payments", "ai-agent-payments", - "agentic-ai", "autonomous-agents", "autonomous-payments", "agent-payments", - "agent-payment-protocol", "agent-commerce", "agentic-commerce", "agent-economy", - "ai-commerce", "ai-payment-infrastructure", "ai-financial-infrastructure", - "payment-rails", "financial-rails", "x402", "x402-payments", "http-402", - "mcp", "mcp-payments", "mcp-monetization", "model-context-protocol", - "payments", "api-payments", "api-monetization", "paid-apis", "machine-payments", - "m2m-payments", "programmable-payments", "crypto-payments", "stablecoin-payments", - "stablecoin", "usdc", "agent-wallet", "agent-passport", "non-custodial", - "fintech", "polygon", "solana", "ed25519" + "aifinpay", + "ai-agent", + "ai-agents", + "ai-payments", + "ai-agent-payments", + "agentic-ai", + "autonomous-agents", + "autonomous-payments", + "agent-payments", + "agent-payment-protocol", + "agent-commerce", + "agentic-commerce", + "agent-economy", + "ai-commerce", + "ai-payment-infrastructure", + "ai-financial-infrastructure", + "payment-rails", + "financial-rails", + "x402", + "x402-payments", + "http-402", + "mcp", + "mcp-payments", + "mcp-monetization", + "model-context-protocol", + "payments", + "api-payments", + "api-monetization", + "paid-apis", + "machine-payments", + "m2m-payments", + "programmable-payments", + "crypto-payments", + "stablecoin-payments", + "stablecoin", + "usdc", + "agent-wallet", + "agent-passport", + "non-custodial", + "fintech", + "polygon", + "solana", + "ed25519" ], "author": "CoinSecurities (SECCO) ", "license": "MIT", @@ -55,6 +94,10 @@ "typescript": "^5.4.0", "vitest": "^4.1.5" }, - "engines": { "node": ">=18" }, - "publishConfig": { "access": "public" } + "engines": { + "node": ">=18" + }, + "publishConfig": { + "access": "public" + } } diff --git a/node/registry/source.json b/node/registry/source.json new file mode 100644 index 0000000..1b09b0c --- /dev/null +++ b/node/registry/source.json @@ -0,0 +1,14 @@ +{ + "$comment": [ + "Provenance for registry/splitter-table.json, which is a byte-for-byte copy of", + "the canonical artifact in AiFinPay/evm-contract. Nothing here is", + "hand-maintained: 'npm run registry:sync -- --from '", + "rewrites both files together, and 'npm run registry:check' fails if the copy", + "no longer hashes to what is recorded here, or if src/splitterRoutes.generated.ts", + "no longer regenerates from it byte-for-byte." + ], + "repo": "AiFinPay/evm-contract", + "path": "registry/generated/splitter-table.json", + "commit": "8577d568932cae1e551fc08c7e7f22202cdffeb1", + "sha256": "ba556b411304c33861c778538d13921ecfbcec31937e0866a74f5e26e21e7a9f" +} diff --git a/node/registry/splitter-table.json b/node/registry/splitter-table.json new file mode 100644 index 0000000..ba0c002 --- /dev/null +++ b/node/registry/splitter-table.json @@ -0,0 +1,542 @@ +{ + "$generated": [ + "DO NOT EDIT. Generated from registry/registry.json by", + "scripts/generate-sdk-table.mjs. CI regenerates this and fails on any", + "difference, so hand-edits are rejected rather than shipped." + ], + "schemaVersion": 4, + "sourceUpdatedAt": "2026-08-28", + "governance": { + "safe": "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + "threshold": 3, + "owners": [ + "0x2118c57dEBD53f614DDfE464Ff2941BE6646cA82", + "0x3C31dd9daCeC5473cC9B660CD69247A20701cF19", + "0x25A834b6fEC79e9ee6ED04Ef5b97440149C6Cc24", + "0x1D5eF769A024B3157c76884fbd10302d8d83fAB9", + "0x849930eB20ED0a697c71BcE565f18702D202C0F8" + ], + "singleton": "0x29fcB43b46531BcA003ddC8FCB67FFE91900C762", + "fallbackHandler": "0xfd0732Dc9E303f09fCEf3a7388Ad10A83459Ec99", + "guard": "0x0000000000000000000000000000000000000000", + "modules": [] + }, + "build": { + "contract": "B2BSplitterV13", + "solcVersion": "0.8.35", + "evmVersion": "cancun", + "contractsTreeHash": "1711d24b711c9d7c80136c5e5716691f35e45eb4" + }, + "routes": { + "polygon:legacy": { + "chain": "polygon", + "route": "legacy", + "chainId": 137, + "version": "1.2", + "superseded": true, + "splitter": "0xbD1fa5453f212F096c0213788a645eC597FB4DDe", + "runtimeCodeHash": "0x9001fbb7ec70097909415325dc70c5b2102c4312dcd8e01e7495cfcaca2edaff", + "owner": "0xD31d82c4b35DABaA2ad7023C89A78A052D1f3c8e", + "treasury": "0xD31d82c4b35DABaA2ad7023C89A78A052D1f3c8e", + "treasuryBps": 100, + "ipCreatorBps": 1, + "stablecoins": null, + "rpcQuorum": 2, + "validFrom": "2026-08-04T00:00:00.000Z", + "validUntil": "2026-09-03T00:00:00.000Z", + "settlementEnabled": false, + "verifiedAt": "2026-08-07" + }, + "base:legacy": { + "chain": "base", + "route": "legacy", + "chainId": 8453, + "version": "1.1", + "superseded": true, + "splitter": "0x8Ad9830D16b1f10333866a3f38C949CbB19f4BAD", + "runtimeCodeHash": "0x545b3a4ba195edc6b728df8cc64f28da528c9e7805c15f1aa61ef58c3c562197", + "owner": "0x1D5eF769A024B3157c76884fbd10302d8d83fAB9", + "treasury": "0x1D5eF769A024B3157c76884fbd10302d8d83fAB9", + "treasuryBps": 100, + "ipCreatorBps": 1, + "stablecoins": null, + "rpcQuorum": 2, + "validFrom": "2026-08-04T00:00:00.000Z", + "validUntil": "2026-09-03T00:00:00.000Z", + "settlementEnabled": false, + "verifiedAt": "2026-08-07" + }, + "optimism:legacy": { + "chain": "optimism", + "route": "legacy", + "chainId": 10, + "version": "1.2", + "superseded": true, + "splitter": "0xF03B3387415D557b6ab709D06E8aF0b4ABD6Eb74", + "runtimeCodeHash": "0xcdf939fd4f9a189e3dba991c5d538bd77b3d493d2ce4e356b61e5742dbde1899", + "owner": "0x1D5eF769A024B3157c76884fbd10302d8d83fAB9", + "treasury": "0x1D5eF769A024B3157c76884fbd10302d8d83fAB9", + "treasuryBps": 100, + "ipCreatorBps": 1, + "stablecoins": null, + "rpcQuorum": 2, + "validFrom": "2026-08-04T00:00:00.000Z", + "validUntil": "2026-09-03T00:00:00.000Z", + "settlementEnabled": false, + "verifiedAt": "2026-08-07" + }, + "unichain:legacy": { + "chain": "unichain", + "route": "legacy", + "chainId": 130, + "version": "1.1", + "superseded": true, + "splitter": "0xeE92807decAa3A02F1e165dd7Efcd92ab9aA83CB", + "runtimeCodeHash": "0x545b3a4ba195edc6b728df8cc64f28da528c9e7805c15f1aa61ef58c3c562197", + "owner": "0x1D5eF769A024B3157c76884fbd10302d8d83fAB9", + "treasury": "0x1D5eF769A024B3157c76884fbd10302d8d83fAB9", + "treasuryBps": 100, + "ipCreatorBps": 1, + "stablecoins": null, + "rpcQuorum": 2, + "validFrom": "2026-08-04T00:00:00.000Z", + "validUntil": "2026-09-03T00:00:00.000Z", + "settlementEnabled": false, + "verifiedAt": "2026-08-07" + }, + "botchain:legacy": { + "chain": "botchain", + "route": "legacy", + "chainId": 677, + "version": "1.2", + "superseded": true, + "splitter": "0x147d8fF8c027E24303b5B99CbC8843e1D3dF94cC", + "runtimeCodeHash": "0xabd084ff64e98bb8ac7db7783d80c6a6bcc69716dfb8f64a4686bed5cf428d96", + "owner": "0x1D5eF769A024B3157c76884fbd10302d8d83fAB9", + "treasury": "0x1D5eF769A024B3157c76884fbd10302d8d83fAB9", + "treasuryBps": 100, + "ipCreatorBps": 1, + "stablecoins": null, + "rpcQuorum": 1, + "validFrom": "2026-08-04T00:00:00.000Z", + "validUntil": "2026-09-03T00:00:00.000Z", + "settlementEnabled": false, + "verifiedAt": "2026-08-07" + }, + "xrplevm:legacy": { + "chain": "xrplevm", + "route": "legacy", + "chainId": 1440000, + "version": "1.2", + "superseded": true, + "splitter": "0x147d8fF8c027E24303b5B99CbC8843e1D3dF94cC", + "runtimeCodeHash": "0xeb68cf314d335f888726a527dec10d989c26e2c5d6a8df68d117cc7d4dcec239", + "owner": "0x1D5eF769A024B3157c76884fbd10302d8d83fAB9", + "treasury": "0x1D5eF769A024B3157c76884fbd10302d8d83fAB9", + "treasuryBps": 100, + "ipCreatorBps": 1, + "stablecoins": null, + "rpcQuorum": 1, + "validFrom": "2026-08-04T00:00:00.000Z", + "validUntil": "2026-09-03T00:00:00.000Z", + "settlementEnabled": false, + "verifiedAt": "2026-08-07" + }, + "polygon:merchant-aifp1": { + "chain": "polygon", + "route": "merchant-aifp1", + "chainId": 137, + "version": "1.3", + "superseded": false, + "splitter": "0x27C1C07563c92C1AEa52cC9b4452dF49dC5a7942", + "runtimeCodeHash": "0x4ba01815b55bf6ed2d608bed91f480c179fd644d706680c3e4a91d8181ba5c6b", + "owner": "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + "treasury": "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + "treasuryBps": 100, + "ipCreatorBps": 0, + "stablecoins": { + "USDC": "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", + "USDT": null + }, + "rpcQuorum": 2, + "validFrom": "2026-08-27T00:00:00.000Z", + "validUntil": "2026-11-25T00:00:00.000Z", + "settlementEnabled": false, + "verifiedAt": "2026-08-27" + }, + "polygon:agent-x402": { + "chain": "polygon", + "route": "agent-x402", + "chainId": 137, + "version": "1.3", + "superseded": false, + "splitter": "0x660Cd915Fc54A7EaE5CEA6854505638bd2A08531", + "runtimeCodeHash": "0x0eb0f8ca7792b13ab70f2aa3e779609cd352d279e925ddcd9e901fd9fd68b1b0", + "owner": "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + "treasury": "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + "treasuryBps": 0, + "ipCreatorBps": 0, + "stablecoins": { + "USDC": "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", + "USDT": null + }, + "rpcQuorum": 2, + "validFrom": "2026-08-27T00:00:00.000Z", + "validUntil": "2026-11-25T00:00:00.000Z", + "settlementEnabled": false, + "verifiedAt": "2026-08-27" + }, + "optimism:merchant-aifp1": { + "chain": "optimism", + "route": "merchant-aifp1", + "chainId": 10, + "version": "1.3", + "superseded": false, + "splitter": "0x1Fe2021336596655Fac72bC7bC40F7FFFA501d55", + "runtimeCodeHash": "0x4ba01815b55bf6ed2d608bed91f480c179fd644d706680c3e4a91d8181ba5c6b", + "owner": "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + "treasury": "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + "treasuryBps": 100, + "ipCreatorBps": 0, + "stablecoins": { + "USDC": "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", + "USDT": null + }, + "rpcQuorum": 2, + "validFrom": "2026-08-27T00:00:00.000Z", + "validUntil": "2026-11-25T00:00:00.000Z", + "settlementEnabled": false, + "verifiedAt": "2026-08-27" + }, + "optimism:agent-x402": { + "chain": "optimism", + "route": "agent-x402", + "chainId": 10, + "version": "1.3", + "superseded": false, + "splitter": "0x38Ef6173ce0AC540f129680C2Aa4Ef739787bdBf", + "runtimeCodeHash": "0x0eb0f8ca7792b13ab70f2aa3e779609cd352d279e925ddcd9e901fd9fd68b1b0", + "owner": "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + "treasury": "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + "treasuryBps": 0, + "ipCreatorBps": 0, + "stablecoins": { + "USDC": "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", + "USDT": null + }, + "rpcQuorum": 2, + "validFrom": "2026-08-27T00:00:00.000Z", + "validUntil": "2026-11-25T00:00:00.000Z", + "settlementEnabled": false, + "verifiedAt": "2026-08-27" + }, + "bnb:merchant-aifp1": { + "chain": "bnb", + "route": "merchant-aifp1", + "chainId": 56, + "version": "1.3", + "superseded": false, + "splitter": "0x79D481B835Cb050FAb7a045E619A6Fb9Cd73f510", + "runtimeCodeHash": "0x4ba01815b55bf6ed2d608bed91f480c179fd644d706680c3e4a91d8181ba5c6b", + "owner": "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + "treasury": "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + "treasuryBps": 100, + "ipCreatorBps": 0, + "stablecoins": { + "USDC": null, + "USDT": null + }, + "rpcQuorum": 2, + "validFrom": "2026-08-27T00:00:00.000Z", + "validUntil": "2026-11-25T00:00:00.000Z", + "settlementEnabled": false, + "verifiedAt": "2026-08-27" + }, + "bnb:agent-x402": { + "chain": "bnb", + "route": "agent-x402", + "chainId": 56, + "version": "1.3", + "superseded": false, + "splitter": "0x7656fb8B6627311A7d87273913D31b837Bb2b5A4", + "runtimeCodeHash": "0x0eb0f8ca7792b13ab70f2aa3e779609cd352d279e925ddcd9e901fd9fd68b1b0", + "owner": "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + "treasury": "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + "treasuryBps": 0, + "ipCreatorBps": 0, + "stablecoins": { + "USDC": null, + "USDT": null + }, + "rpcQuorum": 2, + "validFrom": "2026-08-27T00:00:00.000Z", + "validUntil": "2026-11-25T00:00:00.000Z", + "settlementEnabled": false, + "verifiedAt": "2026-08-27" + }, + "unichain:merchant-aifp1": { + "chain": "unichain", + "route": "merchant-aifp1", + "chainId": 130, + "version": "1.3", + "superseded": false, + "splitter": "0xF03B3387415D557b6ab709D06E8aF0b4ABD6Eb74", + "runtimeCodeHash": "0x4ba01815b55bf6ed2d608bed91f480c179fd644d706680c3e4a91d8181ba5c6b", + "owner": "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + "treasury": "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + "treasuryBps": 100, + "ipCreatorBps": 0, + "stablecoins": { + "USDC": "0x078D782b760474a361dDA0AF3839290b0EF57AD6", + "USDT": null + }, + "rpcQuorum": 2, + "validFrom": "2026-08-27T00:00:00.000Z", + "validUntil": "2026-11-25T00:00:00.000Z", + "settlementEnabled": false, + "verifiedAt": "2026-08-27" + }, + "unichain:agent-x402": { + "chain": "unichain", + "route": "agent-x402", + "chainId": 130, + "version": "1.3", + "superseded": false, + "splitter": "0xC701F45b3Bae9CA3a58cB33fCBA6291594D17843", + "runtimeCodeHash": "0x0eb0f8ca7792b13ab70f2aa3e779609cd352d279e925ddcd9e901fd9fd68b1b0", + "owner": "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + "treasury": "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + "treasuryBps": 0, + "ipCreatorBps": 0, + "stablecoins": { + "USDC": "0x078D782b760474a361dDA0AF3839290b0EF57AD6", + "USDT": null + }, + "rpcQuorum": 2, + "validFrom": "2026-08-27T00:00:00.000Z", + "validUntil": "2026-11-25T00:00:00.000Z", + "settlementEnabled": false, + "verifiedAt": "2026-08-27" + }, + "botchain:merchant-aifp1": { + "chain": "botchain", + "route": "merchant-aifp1", + "chainId": 677, + "version": "1.3", + "superseded": false, + "splitter": "0xe855e491D0950140704DB9Cec6B7b3F725360a56", + "runtimeCodeHash": "0x4ba01815b55bf6ed2d608bed91f480c179fd644d706680c3e4a91d8181ba5c6b", + "owner": "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + "treasury": "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + "treasuryBps": 100, + "ipCreatorBps": 0, + "stablecoins": { + "USDC": null, + "USDT": null + }, + "rpcQuorum": 1, + "validFrom": "2026-08-27T00:00:00.000Z", + "validUntil": "2026-11-25T00:00:00.000Z", + "settlementEnabled": false, + "verifiedAt": "2026-08-27" + }, + "botchain:agent-x402": { + "chain": "botchain", + "route": "agent-x402", + "chainId": 677, + "version": "1.3", + "superseded": false, + "splitter": "0x7E92FbE28aAc3a3942FDf019d29172bd02c96Cf0", + "runtimeCodeHash": "0x0eb0f8ca7792b13ab70f2aa3e779609cd352d279e925ddcd9e901fd9fd68b1b0", + "owner": "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + "treasury": "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + "treasuryBps": 0, + "ipCreatorBps": 0, + "stablecoins": { + "USDC": null, + "USDT": null + }, + "rpcQuorum": 1, + "validFrom": "2026-08-27T00:00:00.000Z", + "validUntil": "2026-11-25T00:00:00.000Z", + "settlementEnabled": false, + "verifiedAt": "2026-08-27" + }, + "base:merchant-aifp1": { + "chain": "base", + "route": "merchant-aifp1", + "chainId": 8453, + "version": "1.3", + "superseded": false, + "splitter": "0xB385Cc32fe39CF5B5778DF0Df0e8E9978b5F662a", + "runtimeCodeHash": "0x4ba01815b55bf6ed2d608bed91f480c179fd644d706680c3e4a91d8181ba5c6b", + "owner": "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + "treasury": "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + "treasuryBps": 100, + "ipCreatorBps": 0, + "stablecoins": { + "USDC": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "USDT": null + }, + "rpcQuorum": 2, + "validFrom": "2026-08-27T00:00:00.000Z", + "validUntil": "2026-11-25T00:00:00.000Z", + "settlementEnabled": false, + "verifiedAt": "2026-08-27" + }, + "base:agent-x402": { + "chain": "base", + "route": "agent-x402", + "chainId": 8453, + "version": "1.3", + "superseded": false, + "splitter": "0x1Fe2021336596655Fac72bC7bC40F7FFFA501d55", + "runtimeCodeHash": "0x0eb0f8ca7792b13ab70f2aa3e779609cd352d279e925ddcd9e901fd9fd68b1b0", + "owner": "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + "treasury": "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + "treasuryBps": 0, + "ipCreatorBps": 0, + "stablecoins": { + "USDC": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "USDT": null + }, + "rpcQuorum": 2, + "validFrom": "2026-08-27T00:00:00.000Z", + "validUntil": "2026-11-25T00:00:00.000Z", + "settlementEnabled": false, + "verifiedAt": "2026-08-27" + }, + "arbitrum:merchant-aifp1": { + "chain": "arbitrum", + "route": "merchant-aifp1", + "chainId": 42161, + "version": "1.3", + "superseded": false, + "splitter": "0x80e2B445DFc44B3B2254aa376B31AEdDd3Ff934a", + "runtimeCodeHash": "0x4ba01815b55bf6ed2d608bed91f480c179fd644d706680c3e4a91d8181ba5c6b", + "owner": "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + "treasury": "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + "treasuryBps": 100, + "ipCreatorBps": 0, + "stablecoins": { + "USDC": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "USDT": null + }, + "rpcQuorum": 2, + "validFrom": "2026-08-27T00:00:00.000Z", + "validUntil": "2026-11-25T00:00:00.000Z", + "settlementEnabled": false, + "verifiedAt": "2026-08-27" + }, + "arbitrum:agent-x402": { + "chain": "arbitrum", + "route": "agent-x402", + "chainId": 42161, + "version": "1.3", + "superseded": false, + "splitter": "0xE34Fc0E6694821c600Fa0955C0F74720ea6d8440", + "runtimeCodeHash": "0x0eb0f8ca7792b13ab70f2aa3e779609cd352d279e925ddcd9e901fd9fd68b1b0", + "owner": "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + "treasury": "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + "treasuryBps": 0, + "ipCreatorBps": 0, + "stablecoins": { + "USDC": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "USDT": null + }, + "rpcQuorum": 2, + "validFrom": "2026-08-27T00:00:00.000Z", + "validUntil": "2026-11-25T00:00:00.000Z", + "settlementEnabled": false, + "verifiedAt": "2026-08-27" + }, + "avalanche:merchant-aifp1": { + "chain": "avalanche", + "route": "merchant-aifp1", + "chainId": 43114, + "version": "1.3", + "superseded": false, + "splitter": "0xE34Fc0E6694821c600Fa0955C0F74720ea6d8440", + "runtimeCodeHash": "0x4ba01815b55bf6ed2d608bed91f480c179fd644d706680c3e4a91d8181ba5c6b", + "owner": "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + "treasury": "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + "treasuryBps": 100, + "ipCreatorBps": 0, + "stablecoins": { + "USDC": "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E", + "USDT": "0x9702230a8ea53601f5cd2dc00fdbc13d4df4a8c7" + }, + "rpcQuorum": 2, + "validFrom": "2026-08-27T00:00:00.000Z", + "validUntil": "2026-11-25T00:00:00.000Z", + "settlementEnabled": false, + "verifiedAt": "2026-08-27" + }, + "avalanche:agent-x402": { + "chain": "avalanche", + "route": "agent-x402", + "chainId": 43114, + "version": "1.3", + "superseded": false, + "splitter": "0xF03B3387415D557b6ab709D06E8aF0b4ABD6Eb74", + "runtimeCodeHash": "0x0eb0f8ca7792b13ab70f2aa3e779609cd352d279e925ddcd9e901fd9fd68b1b0", + "owner": "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + "treasury": "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + "treasuryBps": 0, + "ipCreatorBps": 0, + "stablecoins": { + "USDC": "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E", + "USDT": "0x9702230a8ea53601f5cd2dc00fdbc13d4df4a8c7" + }, + "rpcQuorum": 2, + "validFrom": "2026-08-27T00:00:00.000Z", + "validUntil": "2026-11-25T00:00:00.000Z", + "settlementEnabled": false, + "verifiedAt": "2026-08-27" + }, + "xrplevm:merchant-aifp1": { + "chain": "xrplevm", + "route": "merchant-aifp1", + "chainId": 1440000, + "version": "1.3", + "superseded": false, + "splitter": "0xe855e491D0950140704DB9Cec6B7b3F725360a56", + "runtimeCodeHash": "0x4ba01815b55bf6ed2d608bed91f480c179fd644d706680c3e4a91d8181ba5c6b", + "owner": "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + "treasury": "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + "treasuryBps": 100, + "ipCreatorBps": 0, + "stablecoins": { + "USDC": null, + "USDT": null + }, + "rpcQuorum": 1, + "validFrom": "2026-08-27T00:00:00.000Z", + "validUntil": "2026-11-25T00:00:00.000Z", + "settlementEnabled": false, + "verifiedAt": "2026-08-27" + }, + "xrplevm:agent-x402": { + "chain": "xrplevm", + "route": "agent-x402", + "chainId": 1440000, + "version": "1.3", + "superseded": false, + "splitter": "0x7E92FbE28aAc3a3942FDf019d29172bd02c96Cf0", + "runtimeCodeHash": "0x0eb0f8ca7792b13ab70f2aa3e779609cd352d279e925ddcd9e901fd9fd68b1b0", + "owner": "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + "treasury": "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + "treasuryBps": 0, + "ipCreatorBps": 0, + "stablecoins": { + "USDC": null, + "USDT": null + }, + "rpcQuorum": 1, + "validFrom": "2026-08-27T00:00:00.000Z", + "validUntil": "2026-11-25T00:00:00.000Z", + "settlementEnabled": false, + "verifiedAt": "2026-08-27" + } + } +} diff --git a/node/scripts/check-registry-provenance.mjs b/node/scripts/check-registry-provenance.mjs new file mode 100644 index 0000000..2aadc9e --- /dev/null +++ b/node/scripts/check-registry-provenance.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node +/** + * Independent cross-repository check of the vendored registry artifact. + * + * registry/source.json names the AiFinPay/evm-contract commit the artifact was + * copied from and its sha256. registry:check proves the copy still hashes to + * that — but everything it compares lives in this repository, so a writer here + * could change all three together and the check would pass. This fetches the + * artifact from evm-contract itself, at that exact commit, over HTTPS from + * GitHub, and requires the bytes to be identical. The commit is immutable and + * the repository is public; nothing in this check can be satisfied by editing + * files here. + * + * Fails closed: an unreachable GitHub is a failure, not a pass. + */ +import { readFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const source = JSON.parse(readFileSync(join(ROOT, "registry/source.json"), "utf8")); +const local = readFileSync(join(ROOT, "registry/splitter-table.json")); +const sha256 = (b) => createHash("sha256").update(b).digest("hex"); + +if (!/^[0-9a-f]{40}$/.test(source.commit)) { + console.error(`✗ source.json commit "${source.commit}" is not a full 40-hex SHA — a branch or tag can move`); + process.exit(1); +} + +const url = `https://raw.githubusercontent.com/${source.repo}/${source.commit}/${source.path}`; +let remote; +try { + const res = await fetch(url, { signal: AbortSignal.timeout(20_000) }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + remote = Buffer.from(await res.arrayBuffer()); +} catch (error) { + console.error(`✗ could not fetch ${url}: ${error.message}`); + console.error(" An unverifiable artifact is a failure, not a skip."); + process.exit(1); +} + +const localHash = sha256(local); +const remoteHash = sha256(remote); +if (localHash !== source.sha256) { + console.error(`✗ local artifact sha256 ${localHash} ≠ recorded ${source.sha256}`); + process.exit(1); +} +if (remoteHash !== localHash) { + console.error(`✗ evm-contract@${source.commit.slice(0, 8)} serves sha256 ${remoteHash}, local is ${localHash}`); + console.error(" The vendored artifact is not what the canonical repository holds at that commit."); + process.exit(1); +} +console.log(`✓ registry/splitter-table.json is byte-identical to ${source.repo}@${source.commit.slice(0, 8)}:${source.path}`); +console.log(` sha256 ${localHash}`); diff --git a/node/scripts/generate-splitter-routes.mjs b/node/scripts/generate-splitter-routes.mjs new file mode 100644 index 0000000..9df455d --- /dev/null +++ b/node/scripts/generate-splitter-routes.mjs @@ -0,0 +1,271 @@ +#!/usr/bin/env node +/** + * Generate src/splitterRoutes.generated.ts from the canonical registry artifact. + * + * The addresses, code hashes, fee splits, owner, policy dates and settlement + * flags below decide where money goes and who can redirect it. They are + * maintained in exactly one place — registry/registry.json in + * AiFinPay/evm-contract, where every one of them is read from the chain by + * verify-registry.mjs — and copied here as a byte-for-byte artifact. Nothing in + * that set is typed by a human twice. + * + * That is the whole point of this script. Two repositories holding the same + * payment-critical table, each edited by hand, disagree eventually, and the + * failure is silent: the amounts still look plausible in every log. So: + * + * npm run registry:sync -- --from ../../evm-contract refresh + regenerate + * npm run registry:check CI gate, fails on drift + * + * `--check` regenerates in memory and compares byte-for-byte, so hand-editing + * the generated file turns CI red rather than quietly changing a payout address. + * It also re-hashes the vendored artifact against registry/source.json, so + * editing the artifact instead of syncing it fails the same way. + * + * NOT generated, and deliberately so: viemChain, defaultRpc and explorer. Those + * are transport and presentation — a wrong RPC URL fails loudly and pays nobody, + * whereas a wrong splitter address pays the wrong party successfully. They live + * in CHAIN_TRANSPORT below, and a chain appearing in the artifact without an + * entry there is an error rather than a default. + */ + +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname, join, resolve } from "node:path"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const ARTIFACT = join(ROOT, "registry/splitter-table.json"); +const SOURCE = join(ROOT, "registry/source.json"); +const OUTPUT = join(ROOT, "src/splitterRoutes.generated.ts"); + +const args = process.argv.slice(2); +const CHECK = args.includes("--check"); +const fromIndex = args.indexOf("--from"); +const FROM = fromIndex === -1 ? null : args[fromIndex + 1]; + +/** viem's chain export name, a default RPC and an explorer, per chain. */ +const CHAIN_TRANSPORT = { + polygon: { viem: "polygon", rpc: "https://polygon-bor-rpc.publicnode.com", explorer: "https://polygonscan.com" }, + optimism: { viem: "optimism", rpc: "https://mainnet.optimism.io", explorer: "https://optimistic.etherscan.io" }, + bnb: { viem: "bsc", rpc: "https://bsc-dataseed.bnbchain.org", explorer: "https://bscscan.com" }, + unichain: { viem: "unichain", rpc: "https://mainnet.unichain.org", explorer: "https://uniscan.xyz" }, + botchain: { viem: "botchain", rpc: "https://rpc.botchain.ai", explorer: "https://scan.botchain.ai" }, + base: { viem: "base", rpc: "https://mainnet.base.org", explorer: "https://basescan.org" }, + arbitrum: { viem: "arbitrum", rpc: "https://arb1.arbitrum.io/rpc", explorer: "https://arbiscan.io" }, + avalanche: { viem: "avalanche", rpc: "https://api.avax.network/ext/bc/C/rpc", explorer: "https://snowtrace.io" }, + xrplevm: { viem: "xrplevm", rpc: "https://rpc.xrplevm.org", explorer: "https://explorer.xrplevm.org" }, +}; + +/** Chains whose viem export comes from ./chains.js rather than viem/chains. */ +const LOCAL_CHAINS = new Set(["botchain", "xrplevm"]); + +/** The two v1.3 protocol routes. An unexpected route is an error, not a pass. */ +const ROUTES = new Set(["merchant-aifp1", "agent-x402"]); + +const EXPECTED_ROUTE_COUNT = 18; + +function sha256(buffer) { + return createHash("sha256").update(buffer).digest("hex"); +} + +function loadArtifact() { + if (!existsSync(ARTIFACT)) { + throw new Error(`${ARTIFACT} is missing. Run: npm run registry:sync -- --from `); + } + const raw = readFileSync(ARTIFACT); + const source = JSON.parse(readFileSync(SOURCE, "utf8")); + const actual = sha256(raw); + if (actual !== source.sha256) { + throw new Error( + "registry/splitter-table.json does not hash to what registry/source.json records.\n" + + ` recorded ${source.sha256}\n actual ${actual}\n` + + "The artifact is a copy of the canonical registry, not a file to edit here. " + + "Re-run: npm run registry:sync -- --from ", + ); + } + return { artifact: JSON.parse(raw.toString("utf8")), source }; +} + +/** + * Only current v1.3 routes reach the SDK table. The superseded v1.1/v1.2 + * entries stay in the canonical registry as deployment evidence, but they are + * not representable here on purpose: a resolver that cannot name a legacy + * splitter cannot silently fall back to one. + */ +function selectRoutes(artifact) { + const selected = Object.entries(artifact.routes) + .filter(([, route]) => route.version === "1.3" && !route.superseded) + .sort(([a], [b]) => (a < b ? -1 : 1)); + + if (selected.length !== EXPECTED_ROUTE_COUNT) { + throw new Error( + `expected ${EXPECTED_ROUTE_COUNT} current v1.3 routes, found ${selected.length}. ` + + "Adding or removing a settlement route is not a regeneration; say so in the PR.", + ); + } + + for (const [key, route] of selected) { + if (!CHAIN_TRANSPORT[route.chain]) { + throw new Error( + `${key}: no transport entry for chain "${route.chain}". Add it to CHAIN_TRANSPORT — ` + + "guessing an RPC for an unknown chain is how a route ends up pointing at nothing.", + ); + } + if (!ROUTES.has(route.route)) { + throw new Error(`${key}: unknown protocol route "${route.route}".`); + } + if (route.owner.toLowerCase() !== artifact.governance.safe.toLowerCase()) { + throw new Error( + `${key}: owner ${route.owner} is not the governance Safe ${artifact.governance.safe}.`, + ); + } + if (route.settlementEnabled !== false && route.settlementEnabled !== true) { + throw new Error(`${key}: settlementEnabled must be a boolean.`); + } + // The registry already refuses to enable a single-provider route; mirrored + // here so a hand-edited artifact cannot smuggle one past the SDK either. + if (route.settlementEnabled && (route.rpcQuorum ?? 0) < 2) { + throw new Error(`${key}: enabled for settlement but verified from ${route.rpcQuorum} provider(s).`); + } + if (!route.stablecoins || typeof route.stablecoins !== "object") { + throw new Error(`${key}: no stablecoins block — the allowlist is owner-mutable and must be recorded.`); + } + } + + return selected; +} + +function render({ artifact, source }, selected) { + const chains = [...new Set(selected.map(([, r]) => r.chain))]; + const viemImports = chains + .filter((c) => !LOCAL_CHAINS.has(c)) + .map((c) => CHAIN_TRANSPORT[c].viem) + .sort(); + const localImports = chains.filter((c) => LOCAL_CHAINS.has(c)).map((c) => CHAIN_TRANSPORT[c].viem).sort(); + + const entries = selected + .map(([key, r]) => { + const t = CHAIN_TRANSPORT[r.chain]; + return ` "${key}": { + chain: "${r.chain}", + route: "${r.route}", + chainId: ${r.chainId}, + viemChain: ${t.viem}, + splitter: "${r.splitter}", + owner: "${r.owner}", + treasury: "${r.treasury}", + treasuryBps: ${r.treasuryBps}, + ipCreatorBps: ${r.ipCreatorBps}, + runtimeCodeHash: "${r.runtimeCodeHash}", + settlementEnabled: ${r.settlementEnabled}, + rpcQuorum: ${r.rpcQuorum}, + stablecoins: ${JSON.stringify(r.stablecoins)}, + validFrom: "${r.validFrom}", + validUntil: "${r.validUntil}", + defaultRpc: "${t.rpc}", + explorer: "${t.explorer}", + verifiedAt: "${r.verifiedAt}", + },`; + }) + .join("\n"); + + return `// DO NOT EDIT. Generated by scripts/generate-splitter-routes.mjs from +// registry/splitter-table.json, a byte-for-byte copy of the canonical registry +// artifact in AiFinPay/evm-contract. CI regenerates this file and fails on any +// difference, so a hand-edited payout address turns the build red instead of +// shipping. +// +// To change anything here: change registry/registry.json in evm-contract, let +// verify-registry.mjs read it back off the chain, then run +// npm run registry:sync -- --from +import { ${viemImports.join(", ")} } from "viem/chains"; +import { ${localImports.join(", ")} } from "./chains.js"; +import type { SplitterRouteDeployment, SplitterRouteKey } from "./splitterRoutes.js"; + +/** Where this table came from, so a deployed build can be traced to a commit. */ +export const SPLITTER_REGISTRY_SOURCE = { + repo: "${source.repo}", + path: "${source.path}", + commit: "${source.commit}", + artifactSha256: "${source.sha256}", + schemaVersion: ${artifact.schemaVersion}, + registryUpdatedAt: "${artifact.sourceUpdatedAt}", +} as const; + +/** + * The governance Safe that owns every splitter below, and the exact signer + * shape it was verified under. Read from the chain by verify-registry.mjs, which + * compares the signer set and threshold exactly rather than as a floor. + */ +export const SPLITTER_GOVERNANCE = { + safe: "${artifact.governance.safe}", + threshold: ${artifact.governance.threshold}, + owners: [ +${artifact.governance.owners.map((o) => ` "${o}",`).join("\n")} + ], +} as const; + +export const SPLITTER_ROUTES: Record = { +${entries} +}; +`; +} + +function syncFrom(evmContractPath) { + const root = resolve(evmContractPath); + const artifactPath = join(root, "registry/generated/splitter-table.json"); + if (!existsSync(artifactPath)) { + throw new Error(`${artifactPath} does not exist — is ${root} an evm-contract checkout?`); + } + const raw = readFileSync(artifactPath); + const commit = execFileSync("git", ["-C", root, "rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + const dirty = execFileSync("git", ["-C", root, "status", "--porcelain", "registry/"], { + encoding: "utf8", + }).trim(); + if (dirty) { + throw new Error( + `${root} has uncommitted changes under registry/. Commit them first — provenance ` + + "recorded against a dirty tree points at a commit that does not contain this artifact.", + ); + } + writeFileSync(ARTIFACT, raw); + const source = JSON.parse(readFileSync(SOURCE, "utf8")); + source.commit = commit; + source.sha256 = sha256(raw); + writeFileSync(SOURCE, `${JSON.stringify(source, null, 2)}\n`); + console.log(`Synced registry/splitter-table.json from ${source.repo}@${commit.slice(0, 8)}`); +} + +try { + if (FROM) syncFrom(FROM); + + const loaded = loadArtifact(); + const selected = selectRoutes(loaded.artifact); + const generated = render(loaded, selected); + + if (!CHECK) { + writeFileSync(OUTPUT, generated); + const enabled = selected.filter(([, r]) => r.settlementEnabled).length; + console.log(`Wrote src/splitterRoutes.generated.ts`); + console.log(` ${selected.length} v1.3 routes, ${enabled} with settlement enabled`); + console.log(` source ${loaded.source.repo}@${loaded.source.commit.slice(0, 8)}`); + process.exit(0); + } + + if (!existsSync(OUTPUT)) { + throw new Error(`${OUTPUT} is missing. Run: npm run registry:sync`); + } + if (readFileSync(OUTPUT, "utf8") !== generated) { + throw new Error( + "src/splitterRoutes.generated.ts has drifted from the canonical registry artifact.\n" + + " Either it was hand-edited, or the artifact changed and it was not regenerated.\n" + + " Run: npm run registry:sync", + ); + } + console.log("✓ SPLITTER_ROUTES matches the canonical registry artifact."); + console.log(` ${selected.length} v1.3 routes · ${loaded.source.repo}@${loaded.source.commit.slice(0, 8)}`); +} catch (error) { + console.error(`✗ ${error.message}`); + process.exit(1); +} diff --git a/node/src/chains.ts b/node/src/chains.ts new file mode 100644 index 0000000..870f563 --- /dev/null +++ b/node/src/chains.ts @@ -0,0 +1,25 @@ +// BOT Chain (677) and XRPL EVM (1440000) are not shipped with viem/chains, so +// they are defined here rather than in each module that needs them. Two +// definitions of the same chain is a drift risk: they would be edited +// separately and eventually disagree about an RPC or a chain id. +import { defineChain, type Chain } from "viem"; + +export const botchain: Chain = defineChain({ + id: 677, + name: "BOT Chain", + nativeCurrency: { name: "BOT", symbol: "BOT", decimals: 18 }, + rpcUrls: { default: { http: ["https://rpc.botchain.ai"] } }, + blockExplorers: { + default: { name: "BOT Chain Explorer", url: "https://scan.botchain.ai" }, + }, +}); + +export const xrplevm: Chain = defineChain({ + id: 1440000, + name: "XRPL EVM", + nativeCurrency: { name: "XRP", symbol: "XRP", decimals: 18 }, + rpcUrls: { default: { http: ["https://rpc.xrplevm.org"] } }, + blockExplorers: { + default: { name: "XRPL EVM Explorer", url: "https://explorer.xrplevm.org" }, + }, +}); diff --git a/node/src/index.ts b/node/src/index.ts index 2b5a0a7..cf3433f 100644 --- a/node/src/index.ts +++ b/node/src/index.ts @@ -46,6 +46,23 @@ export type { } from "./settlement.js"; // ── Unified surface (Phase 1+ / legacy callers remain source-compatible) ── +export { + SPLITTER_ROUTES, + SPLITTER_GOVERNANCE, + SPLITTER_REGISTRY_SOURCE, + resolveSplitterRoute, + resolveSettlingSplitterRoute, + UnknownSplitterRouteError, + SplitterRouteNotSettlingError, +} from "./splitterRoutes.js"; +export type { + SplitterRoute, + SplitterRouteChain, + SplitterRouteKey, + SplitterRouteDeployment, +} from "./splitterRoutes.js"; +export { botchain, xrplevm } from "./chains.js"; + export { AiFinPayAgent, SPLITTER_DEPLOYMENTS, paymentIdFor } from "./unifiedAgent.js"; export type { AiFinPayAgentOptions, diff --git a/node/src/splitterRoutes.generated.ts b/node/src/splitterRoutes.generated.ts new file mode 100644 index 0000000..08afc9e --- /dev/null +++ b/node/src/splitterRoutes.generated.ts @@ -0,0 +1,402 @@ +// DO NOT EDIT. Generated by scripts/generate-splitter-routes.mjs from +// registry/splitter-table.json, a byte-for-byte copy of the canonical registry +// artifact in AiFinPay/evm-contract. CI regenerates this file and fails on any +// difference, so a hand-edited payout address turns the build red instead of +// shipping. +// +// To change anything here: change registry/registry.json in evm-contract, let +// verify-registry.mjs read it back off the chain, then run +// npm run registry:sync -- --from +import { arbitrum, avalanche, base, bsc, optimism, polygon, unichain } from "viem/chains"; +import { botchain, xrplevm } from "./chains.js"; +import type { SplitterRouteDeployment, SplitterRouteKey } from "./splitterRoutes.js"; + +/** Where this table came from, so a deployed build can be traced to a commit. */ +export const SPLITTER_REGISTRY_SOURCE = { + repo: "AiFinPay/evm-contract", + path: "registry/generated/splitter-table.json", + commit: "8577d568932cae1e551fc08c7e7f22202cdffeb1", + artifactSha256: "ba556b411304c33861c778538d13921ecfbcec31937e0866a74f5e26e21e7a9f", + schemaVersion: 4, + registryUpdatedAt: "2026-08-28", +} as const; + +/** + * The governance Safe that owns every splitter below, and the exact signer + * shape it was verified under. Read from the chain by verify-registry.mjs, which + * compares the signer set and threshold exactly rather than as a floor. + */ +export const SPLITTER_GOVERNANCE = { + safe: "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + threshold: 3, + owners: [ + "0x2118c57dEBD53f614DDfE464Ff2941BE6646cA82", + "0x3C31dd9daCeC5473cC9B660CD69247A20701cF19", + "0x25A834b6fEC79e9ee6ED04Ef5b97440149C6Cc24", + "0x1D5eF769A024B3157c76884fbd10302d8d83fAB9", + "0x849930eB20ED0a697c71BcE565f18702D202C0F8", + ], +} as const; + +export const SPLITTER_ROUTES: Record = { + "arbitrum:agent-x402": { + chain: "arbitrum", + route: "agent-x402", + chainId: 42161, + viemChain: arbitrum, + splitter: "0xE34Fc0E6694821c600Fa0955C0F74720ea6d8440", + owner: "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + treasury: "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + treasuryBps: 0, + ipCreatorBps: 0, + runtimeCodeHash: "0x0eb0f8ca7792b13ab70f2aa3e779609cd352d279e925ddcd9e901fd9fd68b1b0", + settlementEnabled: false, + rpcQuorum: 2, + stablecoins: {"USDC":"0xaf88d065e77c8cC2239327C5EDb3A432268e5831","USDT":null}, + validFrom: "2026-08-27T00:00:00.000Z", + validUntil: "2026-11-25T00:00:00.000Z", + defaultRpc: "https://arb1.arbitrum.io/rpc", + explorer: "https://arbiscan.io", + verifiedAt: "2026-08-27", + }, + "arbitrum:merchant-aifp1": { + chain: "arbitrum", + route: "merchant-aifp1", + chainId: 42161, + viemChain: arbitrum, + splitter: "0x80e2B445DFc44B3B2254aa376B31AEdDd3Ff934a", + owner: "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + treasury: "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + treasuryBps: 100, + ipCreatorBps: 0, + runtimeCodeHash: "0x4ba01815b55bf6ed2d608bed91f480c179fd644d706680c3e4a91d8181ba5c6b", + settlementEnabled: false, + rpcQuorum: 2, + stablecoins: {"USDC":"0xaf88d065e77c8cC2239327C5EDb3A432268e5831","USDT":null}, + validFrom: "2026-08-27T00:00:00.000Z", + validUntil: "2026-11-25T00:00:00.000Z", + defaultRpc: "https://arb1.arbitrum.io/rpc", + explorer: "https://arbiscan.io", + verifiedAt: "2026-08-27", + }, + "avalanche:agent-x402": { + chain: "avalanche", + route: "agent-x402", + chainId: 43114, + viemChain: avalanche, + splitter: "0xF03B3387415D557b6ab709D06E8aF0b4ABD6Eb74", + owner: "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + treasury: "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + treasuryBps: 0, + ipCreatorBps: 0, + runtimeCodeHash: "0x0eb0f8ca7792b13ab70f2aa3e779609cd352d279e925ddcd9e901fd9fd68b1b0", + settlementEnabled: false, + rpcQuorum: 2, + stablecoins: {"USDC":"0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E","USDT":"0x9702230a8ea53601f5cd2dc00fdbc13d4df4a8c7"}, + validFrom: "2026-08-27T00:00:00.000Z", + validUntil: "2026-11-25T00:00:00.000Z", + defaultRpc: "https://api.avax.network/ext/bc/C/rpc", + explorer: "https://snowtrace.io", + verifiedAt: "2026-08-27", + }, + "avalanche:merchant-aifp1": { + chain: "avalanche", + route: "merchant-aifp1", + chainId: 43114, + viemChain: avalanche, + splitter: "0xE34Fc0E6694821c600Fa0955C0F74720ea6d8440", + owner: "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + treasury: "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + treasuryBps: 100, + ipCreatorBps: 0, + runtimeCodeHash: "0x4ba01815b55bf6ed2d608bed91f480c179fd644d706680c3e4a91d8181ba5c6b", + settlementEnabled: false, + rpcQuorum: 2, + stablecoins: {"USDC":"0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E","USDT":"0x9702230a8ea53601f5cd2dc00fdbc13d4df4a8c7"}, + validFrom: "2026-08-27T00:00:00.000Z", + validUntil: "2026-11-25T00:00:00.000Z", + defaultRpc: "https://api.avax.network/ext/bc/C/rpc", + explorer: "https://snowtrace.io", + verifiedAt: "2026-08-27", + }, + "base:agent-x402": { + chain: "base", + route: "agent-x402", + chainId: 8453, + viemChain: base, + splitter: "0x1Fe2021336596655Fac72bC7bC40F7FFFA501d55", + owner: "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + treasury: "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + treasuryBps: 0, + ipCreatorBps: 0, + runtimeCodeHash: "0x0eb0f8ca7792b13ab70f2aa3e779609cd352d279e925ddcd9e901fd9fd68b1b0", + settlementEnabled: false, + rpcQuorum: 2, + stablecoins: {"USDC":"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913","USDT":null}, + validFrom: "2026-08-27T00:00:00.000Z", + validUntil: "2026-11-25T00:00:00.000Z", + defaultRpc: "https://mainnet.base.org", + explorer: "https://basescan.org", + verifiedAt: "2026-08-27", + }, + "base:merchant-aifp1": { + chain: "base", + route: "merchant-aifp1", + chainId: 8453, + viemChain: base, + splitter: "0xB385Cc32fe39CF5B5778DF0Df0e8E9978b5F662a", + owner: "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + treasury: "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + treasuryBps: 100, + ipCreatorBps: 0, + runtimeCodeHash: "0x4ba01815b55bf6ed2d608bed91f480c179fd644d706680c3e4a91d8181ba5c6b", + settlementEnabled: false, + rpcQuorum: 2, + stablecoins: {"USDC":"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913","USDT":null}, + validFrom: "2026-08-27T00:00:00.000Z", + validUntil: "2026-11-25T00:00:00.000Z", + defaultRpc: "https://mainnet.base.org", + explorer: "https://basescan.org", + verifiedAt: "2026-08-27", + }, + "bnb:agent-x402": { + chain: "bnb", + route: "agent-x402", + chainId: 56, + viemChain: bsc, + splitter: "0x7656fb8B6627311A7d87273913D31b837Bb2b5A4", + owner: "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + treasury: "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + treasuryBps: 0, + ipCreatorBps: 0, + runtimeCodeHash: "0x0eb0f8ca7792b13ab70f2aa3e779609cd352d279e925ddcd9e901fd9fd68b1b0", + settlementEnabled: false, + rpcQuorum: 2, + stablecoins: {"USDC":null,"USDT":null}, + validFrom: "2026-08-27T00:00:00.000Z", + validUntil: "2026-11-25T00:00:00.000Z", + defaultRpc: "https://bsc-dataseed.bnbchain.org", + explorer: "https://bscscan.com", + verifiedAt: "2026-08-27", + }, + "bnb:merchant-aifp1": { + chain: "bnb", + route: "merchant-aifp1", + chainId: 56, + viemChain: bsc, + splitter: "0x79D481B835Cb050FAb7a045E619A6Fb9Cd73f510", + owner: "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + treasury: "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + treasuryBps: 100, + ipCreatorBps: 0, + runtimeCodeHash: "0x4ba01815b55bf6ed2d608bed91f480c179fd644d706680c3e4a91d8181ba5c6b", + settlementEnabled: false, + rpcQuorum: 2, + stablecoins: {"USDC":null,"USDT":null}, + validFrom: "2026-08-27T00:00:00.000Z", + validUntil: "2026-11-25T00:00:00.000Z", + defaultRpc: "https://bsc-dataseed.bnbchain.org", + explorer: "https://bscscan.com", + verifiedAt: "2026-08-27", + }, + "botchain:agent-x402": { + chain: "botchain", + route: "agent-x402", + chainId: 677, + viemChain: botchain, + splitter: "0x7E92FbE28aAc3a3942FDf019d29172bd02c96Cf0", + owner: "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + treasury: "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + treasuryBps: 0, + ipCreatorBps: 0, + runtimeCodeHash: "0x0eb0f8ca7792b13ab70f2aa3e779609cd352d279e925ddcd9e901fd9fd68b1b0", + settlementEnabled: false, + rpcQuorum: 1, + stablecoins: {"USDC":null,"USDT":null}, + validFrom: "2026-08-27T00:00:00.000Z", + validUntil: "2026-11-25T00:00:00.000Z", + defaultRpc: "https://rpc.botchain.ai", + explorer: "https://scan.botchain.ai", + verifiedAt: "2026-08-27", + }, + "botchain:merchant-aifp1": { + chain: "botchain", + route: "merchant-aifp1", + chainId: 677, + viemChain: botchain, + splitter: "0xe855e491D0950140704DB9Cec6B7b3F725360a56", + owner: "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + treasury: "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + treasuryBps: 100, + ipCreatorBps: 0, + runtimeCodeHash: "0x4ba01815b55bf6ed2d608bed91f480c179fd644d706680c3e4a91d8181ba5c6b", + settlementEnabled: false, + rpcQuorum: 1, + stablecoins: {"USDC":null,"USDT":null}, + validFrom: "2026-08-27T00:00:00.000Z", + validUntil: "2026-11-25T00:00:00.000Z", + defaultRpc: "https://rpc.botchain.ai", + explorer: "https://scan.botchain.ai", + verifiedAt: "2026-08-27", + }, + "optimism:agent-x402": { + chain: "optimism", + route: "agent-x402", + chainId: 10, + viemChain: optimism, + splitter: "0x38Ef6173ce0AC540f129680C2Aa4Ef739787bdBf", + owner: "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + treasury: "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + treasuryBps: 0, + ipCreatorBps: 0, + runtimeCodeHash: "0x0eb0f8ca7792b13ab70f2aa3e779609cd352d279e925ddcd9e901fd9fd68b1b0", + settlementEnabled: false, + rpcQuorum: 2, + stablecoins: {"USDC":"0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85","USDT":null}, + validFrom: "2026-08-27T00:00:00.000Z", + validUntil: "2026-11-25T00:00:00.000Z", + defaultRpc: "https://mainnet.optimism.io", + explorer: "https://optimistic.etherscan.io", + verifiedAt: "2026-08-27", + }, + "optimism:merchant-aifp1": { + chain: "optimism", + route: "merchant-aifp1", + chainId: 10, + viemChain: optimism, + splitter: "0x1Fe2021336596655Fac72bC7bC40F7FFFA501d55", + owner: "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + treasury: "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + treasuryBps: 100, + ipCreatorBps: 0, + runtimeCodeHash: "0x4ba01815b55bf6ed2d608bed91f480c179fd644d706680c3e4a91d8181ba5c6b", + settlementEnabled: false, + rpcQuorum: 2, + stablecoins: {"USDC":"0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85","USDT":null}, + validFrom: "2026-08-27T00:00:00.000Z", + validUntil: "2026-11-25T00:00:00.000Z", + defaultRpc: "https://mainnet.optimism.io", + explorer: "https://optimistic.etherscan.io", + verifiedAt: "2026-08-27", + }, + "polygon:agent-x402": { + chain: "polygon", + route: "agent-x402", + chainId: 137, + viemChain: polygon, + splitter: "0x660Cd915Fc54A7EaE5CEA6854505638bd2A08531", + owner: "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + treasury: "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + treasuryBps: 0, + ipCreatorBps: 0, + runtimeCodeHash: "0x0eb0f8ca7792b13ab70f2aa3e779609cd352d279e925ddcd9e901fd9fd68b1b0", + settlementEnabled: false, + rpcQuorum: 2, + stablecoins: {"USDC":"0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359","USDT":null}, + validFrom: "2026-08-27T00:00:00.000Z", + validUntil: "2026-11-25T00:00:00.000Z", + defaultRpc: "https://polygon-bor-rpc.publicnode.com", + explorer: "https://polygonscan.com", + verifiedAt: "2026-08-27", + }, + "polygon:merchant-aifp1": { + chain: "polygon", + route: "merchant-aifp1", + chainId: 137, + viemChain: polygon, + splitter: "0x27C1C07563c92C1AEa52cC9b4452dF49dC5a7942", + owner: "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + treasury: "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + treasuryBps: 100, + ipCreatorBps: 0, + runtimeCodeHash: "0x4ba01815b55bf6ed2d608bed91f480c179fd644d706680c3e4a91d8181ba5c6b", + settlementEnabled: false, + rpcQuorum: 2, + stablecoins: {"USDC":"0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359","USDT":null}, + validFrom: "2026-08-27T00:00:00.000Z", + validUntil: "2026-11-25T00:00:00.000Z", + defaultRpc: "https://polygon-bor-rpc.publicnode.com", + explorer: "https://polygonscan.com", + verifiedAt: "2026-08-27", + }, + "unichain:agent-x402": { + chain: "unichain", + route: "agent-x402", + chainId: 130, + viemChain: unichain, + splitter: "0xC701F45b3Bae9CA3a58cB33fCBA6291594D17843", + owner: "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + treasury: "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + treasuryBps: 0, + ipCreatorBps: 0, + runtimeCodeHash: "0x0eb0f8ca7792b13ab70f2aa3e779609cd352d279e925ddcd9e901fd9fd68b1b0", + settlementEnabled: false, + rpcQuorum: 2, + stablecoins: {"USDC":"0x078D782b760474a361dDA0AF3839290b0EF57AD6","USDT":null}, + validFrom: "2026-08-27T00:00:00.000Z", + validUntil: "2026-11-25T00:00:00.000Z", + defaultRpc: "https://mainnet.unichain.org", + explorer: "https://uniscan.xyz", + verifiedAt: "2026-08-27", + }, + "unichain:merchant-aifp1": { + chain: "unichain", + route: "merchant-aifp1", + chainId: 130, + viemChain: unichain, + splitter: "0xF03B3387415D557b6ab709D06E8aF0b4ABD6Eb74", + owner: "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + treasury: "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + treasuryBps: 100, + ipCreatorBps: 0, + runtimeCodeHash: "0x4ba01815b55bf6ed2d608bed91f480c179fd644d706680c3e4a91d8181ba5c6b", + settlementEnabled: false, + rpcQuorum: 2, + stablecoins: {"USDC":"0x078D782b760474a361dDA0AF3839290b0EF57AD6","USDT":null}, + validFrom: "2026-08-27T00:00:00.000Z", + validUntil: "2026-11-25T00:00:00.000Z", + defaultRpc: "https://mainnet.unichain.org", + explorer: "https://uniscan.xyz", + verifiedAt: "2026-08-27", + }, + "xrplevm:agent-x402": { + chain: "xrplevm", + route: "agent-x402", + chainId: 1440000, + viemChain: xrplevm, + splitter: "0x7E92FbE28aAc3a3942FDf019d29172bd02c96Cf0", + owner: "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + treasury: "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + treasuryBps: 0, + ipCreatorBps: 0, + runtimeCodeHash: "0x0eb0f8ca7792b13ab70f2aa3e779609cd352d279e925ddcd9e901fd9fd68b1b0", + settlementEnabled: false, + rpcQuorum: 1, + stablecoins: {"USDC":null,"USDT":null}, + validFrom: "2026-08-27T00:00:00.000Z", + validUntil: "2026-11-25T00:00:00.000Z", + defaultRpc: "https://rpc.xrplevm.org", + explorer: "https://explorer.xrplevm.org", + verifiedAt: "2026-08-27", + }, + "xrplevm:merchant-aifp1": { + chain: "xrplevm", + route: "merchant-aifp1", + chainId: 1440000, + viemChain: xrplevm, + splitter: "0xe855e491D0950140704DB9Cec6B7b3F725360a56", + owner: "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + treasury: "0xFd936f75D9221949f2FEaB54Cd342F7527154eD5", + treasuryBps: 100, + ipCreatorBps: 0, + runtimeCodeHash: "0x4ba01815b55bf6ed2d608bed91f480c179fd644d706680c3e4a91d8181ba5c6b", + settlementEnabled: false, + rpcQuorum: 1, + stablecoins: {"USDC":null,"USDT":null}, + validFrom: "2026-08-27T00:00:00.000Z", + validUntil: "2026-11-25T00:00:00.000Z", + defaultRpc: "https://rpc.xrplevm.org", + explorer: "https://explorer.xrplevm.org", + verifiedAt: "2026-08-27", + }, +}; diff --git a/node/src/splitterRoutes.ts b/node/src/splitterRoutes.ts new file mode 100644 index 0000000..8de8d92 --- /dev/null +++ b/node/src/splitterRoutes.ts @@ -0,0 +1,210 @@ +/** + * v1.3 splitter selection, keyed by chain AND protocol route. + * + * From v1.3 a chain carries one splitter per protocol route, because the fee + * split is immutable at construction and the two protocols need different + * economics: + * + * merchant-aifp1 (100/0) — the agent pays the quoted gross amount, the + * AiFinPay treasury receives 1%, the merchant receives 99%. + * agent-x402 (0/0) — the provider receives 100% of the provider-defined + * price and the AiFinPay fee is 0% for now. This route is NOT fee-on-top; + * fee-on-top semantics arrive in a future contract version. + * + * Selection must use both chain and route, and must never fall back from one + * route to the other. That is not a style preference. The splitters were + * deployed with CREATE, so an address derives from deployer and nonce and the + * same address recurs on other chains for the other route: + * + * 0x1Fe2021336596655Fac72bC7bC40F7FFFA501d55 + * is OP's merchant-aifp1 AND Base's agent-x402 + * 0xF03B3387415D557b6ab709D06E8aF0b4ABD6Eb74 + * is Unichain's merchant-aifp1, Avalanche's agent-x402, AND the legacy + * v1.2 splitter on Optimism + * + * An address on its own therefore says nothing about which economics apply. + * Resolving by chain alone would settle at the wrong fee split, silently, + * and the amounts would still look plausible in every log. + * + * The table itself is NOT in this file. It is generated into + * splitterRoutes.generated.ts from registry/splitter-table.json, a byte-for-byte + * copy of the canonical artifact in AiFinPay/evm-contract, where every + * payment-critical field — splitter, owner, treasury, both bps values, the + * runtime code hash — was read from the chain by verify-registry.mjs. Two + * repositories hand-maintaining the same payout addresses is the failure this + * split prevents; `npm run registry:check` fails CI if they disagree. + * + * What stays here is the part worth reading: the types, the errors, and the two + * resolvers. Logic belongs in a reviewed file, not in a generated one. + */ +import type { Chain } from "viem/chains"; + +import { SPLITTER_ROUTES } from "./splitterRoutes.generated.js"; + +export { + SPLITTER_ROUTES, + SPLITTER_GOVERNANCE, + SPLITTER_REGISTRY_SOURCE, +} from "./splitterRoutes.generated.js"; + +/** Protocol routes. A route is a fee profile fixed at construction. */ +export type SplitterRoute = "merchant-aifp1" | "agent-x402"; + +/** Chains carrying v1.3 route splitters. */ +export type SplitterRouteChain = + | "polygon" + | "optimism" + | "bnb" + | "unichain" + | "botchain" + | "base" + | "arbitrum" + | "avalanche" + | "xrplevm"; + +/** Key into SPLITTER_ROUTES. Both halves are required. */ +export type SplitterRouteKey = `${SplitterRouteChain}:${SplitterRoute}`; + +export interface SplitterRouteDeployment { + chain: SplitterRouteChain; + route: SplitterRoute; + chainId: number; + viemChain: Chain; + splitter: `0x${string}`; + /** + * The governance Safe, read from the contract's own owner(). It controls + * pause/unpause, the treasury address and the stablecoin whitelist, so it is + * carried here rather than assumed: every other field is only as trustworthy + * as whoever can change it. + */ + owner: `0x${string}`; + /** Owner and treasury are the same governance Safe on every chain. */ + treasury: `0x${string}`; + /** Immutable, baked into runtime code. 100 = 1%. */ + treasuryBps: number; + ipCreatorBps: number; + /** keccak-256 of the deployed runtime bytecode, read from chain. */ + runtimeCodeHash: `0x${string}`; + /** + * Deployed and verified is not the same as payable. A route is enabled + * individually, and only after a successful mainnet paid end-to-end + * settlement on that exact route with verified balance deltas. + */ + settlementEnabled: 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 + * enabled — BOT Chain and XRPL EVM have exactly one public provider each. + */ + rpcQuorum: number; + /** + * Stablecoins the splitter accepts, read live via whitelistedTokens() and + * owner-mutable, so pinned separately from the runtime hash. null = not + * accepted on this chain; a chain with both null settles native only. + */ + stablecoins: { USDC: `0x${string}` | null; USDT: `0x${string}` | null }; + /** Policy review window. Outside it, a route must not settle. */ + validFrom: string; + validUntil: string; + defaultRpc: string; + explorer: string; + /** The date the fields above were last read from the chain. */ + verifiedAt: string; +} + +export class UnknownSplitterRouteError extends Error { + constructor(chain: string, route: string) { + super( + `No v1.3 splitter registered for ${chain}:${route}. Supported: ` + + `${Object.keys(SPLITTER_ROUTES).join(", ")}. There is deliberately no ` + + `fallback between routes — merchant-aifp1 and agent-x402 have different ` + + `immutable fee splits, so substituting one for the other would settle ` + + `at the wrong amount.`, + ); + this.name = "UnknownSplitterRouteError"; + } +} + +export class SplitterRouteNotSettlingError extends Error { + constructor(key: string, reason: string) { + super(`Splitter route ${key} must not settle: ${reason}`); + this.name = "SplitterRouteNotSettlingError"; + } +} + +/** + * Resolve a splitter by chain AND route. Throws on an unknown pair rather + * than falling back, because the fallback is the bug: every route resolves to + * a real, deployed, working contract with the wrong economics. + */ +export function resolveSplitterRoute( + chain: SplitterRouteChain | string, + route: SplitterRoute | string, +): SplitterRouteDeployment { + const entry = (SPLITTER_ROUTES as Partial>)[ + `${chain}:${route}` + ]; + if (!entry) throw new UnknownSplitterRouteError(String(chain), String(route)); + return entry; +} + +/** + * Resolve a route that is cleared to move money. Separate from + * resolveSplitterRoute on purpose: reading the registry and being allowed to + * settle are different questions, and conflating them is how a disabled route + * ends up paying. + */ +export function resolveSettlingSplitterRoute( + chain: SplitterRouteChain | string, + route: SplitterRoute | string, + now: Date = new Date(), +): SplitterRouteDeployment { + const entry = resolveSplitterRoute(chain, route); + const key = `${entry.chain}:${entry.route}`; + if (!entry.settlementEnabled) { + throw new SplitterRouteNotSettlingError( + key, + "settlement is not enabled for this route yet — it is enabled only after a " + + "successful mainnet paid end-to-end settlement with verified balance deltas", + ); + } + // Every comparison below is written as "prove it is inside the window", never + // "prove it is outside". With `t < from` / `t >= until`, one malformed date + // parses to NaN, both comparisons are false, and the route settles with no + // time gate at all — the gate fails OPEN on exactly the input you cannot + // trust. Requiring the positive fact instead means NaN fails every check. + const from = Date.parse(entry.validFrom); + const until = Date.parse(entry.validUntil); + const t = now.getTime(); + + if (!Number.isFinite(from) || !Number.isFinite(until)) { + throw new SplitterRouteNotSettlingError( + key, + `its policy window is unreadable (validFrom ${entry.validFrom}, validUntil ` + + `${entry.validUntil}) — a window that cannot be parsed is not a window that has opened`, + ); + } + if (!Number.isFinite(t)) { + throw new SplitterRouteNotSettlingError( + key, + "the current time was passed as an invalid Date, so the policy window cannot be evaluated", + ); + } + if (from >= until) { + throw new SplitterRouteNotSettlingError( + key, + `its policy window is inverted (validFrom ${entry.validFrom} is not before ` + + `validUntil ${entry.validUntil})`, + ); + } + if (!(t >= from)) { + throw new SplitterRouteNotSettlingError(key, `its policy window opens ${entry.validFrom}`); + } + if (!(t < until)) { + throw new SplitterRouteNotSettlingError( + key, + `its policy window expired ${entry.validUntil} and has not been re-reviewed`, + ); + } + return entry; +} diff --git a/node/src/unifiedAgent.ts b/node/src/unifiedAgent.ts index ee1eb5d..ef7a705 100644 --- a/node/src/unifiedAgent.ts +++ b/node/src/unifiedAgent.ts @@ -28,8 +28,8 @@ import { privateKeyToAccount, type PrivateKeyAccount, } from "viem/accounts"; -import { defineChain } from "viem"; import { polygon, base, arbitrum, optimism, bsc, mainnet, unichain, type Chain } from "viem/chains"; +import { botchain, xrplevm } from "./chains.js"; import { Connection, Keypair, @@ -319,28 +319,9 @@ export function paymentIdFor(orderId: string): `0x${string}` { // on-chain (eth_getCode returned bytecode for every address below, // 2026-07-15). Do NOT add chains here without re-running that check. // -// BOT Chain (677) and XRPL EVM (1440000) are not shipped with viem/chains, -// so we defineChain() them locally. - -const botchain: Chain = defineChain({ - id: 677, - name: "BOT Chain", - nativeCurrency: { name: "BOT", symbol: "BOT", decimals: 18 }, - rpcUrls: { default: { http: ["https://rpc.botchain.ai"] } }, - blockExplorers: { - default: { name: "BOT Chain Explorer", url: "https://scan.botchain.ai" }, - }, -}); - -const xrplevm: Chain = defineChain({ - id: 1440000, - name: "XRPL EVM", - nativeCurrency: { name: "XRP", symbol: "XRP", decimals: 18 }, - rpcUrls: { default: { http: ["https://rpc.xrplevm.org"] } }, - blockExplorers: { - default: { name: "XRPL EVM Explorer", url: "https://explorer.xrplevm.org" }, - }, -}); +// BOT Chain (677) and XRPL EVM (1440000) are not shipped with viem/chains. +// They live in ./chains.js so this module and splitterRoutes.ts cannot drift +// apart on a chain id or RPC. /** EVM chains with a live, on-chain-verified B2BSplitter deployment. */ export type SplitterChainName = diff --git a/node/tests/splitterRoutes.test.ts b/node/tests/splitterRoutes.test.ts new file mode 100644 index 0000000..4f0070b --- /dev/null +++ b/node/tests/splitterRoutes.test.ts @@ -0,0 +1,260 @@ +// v1.3 route selection. Static-shape tests only, no network calls — every +// value here was read from chain by verify-registry.mjs in evm-contract when +// the registry was authored (2026-08-27). +import { afterEach, describe, expect, it } from "vitest"; +import { getAddress } from "viem"; +import { + SPLITTER_ROUTES, + SPLITTER_GOVERNANCE, + SPLITTER_REGISTRY_SOURCE, + resolveSplitterRoute, + resolveSettlingSplitterRoute, + UnknownSplitterRouteError, + SplitterRouteNotSettlingError, + type SplitterRouteDeployment, +} from "../src/index.js"; + +const CHAIN_IDS: Record = { + polygon: 137, optimism: 10, bnb: 56, unichain: 130, botchain: 677, + base: 8453, arbitrum: 42161, avalanche: 43114, xrplevm: 1440000, +}; +const BPS: Record = { "merchant-aifp1": 100, "agent-x402": 0 }; +const entries = Object.entries(SPLITTER_ROUTES); + +describe("SPLITTER_ROUTES", () => { + it("has both routes on all nine chains", () => { + expect(entries).toHaveLength(18); + for (const chain of Object.keys(CHAIN_IDS)) { + for (const route of Object.keys(BPS)) { + expect(SPLITTER_ROUTES[`${chain}:${route}` as keyof typeof SPLITTER_ROUTES], `${chain}:${route}`).toBeDefined(); + } + } + }); + + it("every key agrees with the chain and route inside it", () => { + for (const [key, d] of entries) expect(`${d.chain}:${d.route}`, key).toBe(key); + }); + + it("chainId and viem chain agree", () => { + for (const [key, d] of entries) { + expect(d.chainId, key).toBe(CHAIN_IDS[d.chain]); + expect(d.viemChain.id, key).toBe(d.chainId); + } + }); + + it("fee split matches the route, and no route carries a creator leg", () => { + for (const [key, d] of entries) { + expect(d.treasuryBps, key).toBe(BPS[d.route]); + expect(d.ipCreatorBps, key).toBe(0); + } + }); + + it("addresses are checksummed and the treasury is the same Safe everywhere", () => { + const treasuries = new Set(entries.map(([, d]) => d.treasury)); + expect(treasuries.size).toBe(1); + for (const [key, d] of entries) { + expect(getAddress(d.splitter), key).toBe(d.splitter); + expect(getAddress(d.treasury), key).toBe(d.treasury); + } + }); + + it("there are exactly two runtime code hashes, one per route", () => { + // The bps are immutable and baked into runtime code, so a route with the + // wrong profile would hash differently. Two hashes across eighteen + // contracts is the evidence that the right profile reached every chain. + const byRoute = new Map>(); + for (const [, d] of entries) { + if (!byRoute.has(d.route)) byRoute.set(d.route, new Set()); + byRoute.get(d.route)!.add(d.runtimeCodeHash); + } + expect([...byRoute.keys()].sort()).toEqual(["agent-x402", "merchant-aifp1"]); + for (const [route, hashes] of byRoute) expect(hashes.size, route).toBe(1); + expect(new Set(entries.map(([, d]) => d.runtimeCodeHash)).size).toBe(2); + }); + + it("ships with settlement disabled on every route", () => { + // Deployed and verified is not payable. Flipping these on is a deliberate + // per-route act after a paid mainnet E2E, never a side effect of a release. + for (const [key, d] of entries) expect(d.settlementEnabled, key).toBe(false); + }); +}); + +describe("address reuse across chains", () => { + // The splitters were deployed with CREATE, so the same address recurs on + // other chains for the OTHER route. This is the reason selection is keyed on + // chain AND route, and the reason an address must never be used as a key. + it("the same address really does appear under more than one route", () => { + const byAddress = new Map(); + for (const [key, d] of entries) { + const k = d.splitter.toLowerCase(); + byAddress.set(k, [...(byAddress.get(k) ?? []), key]); + } + const shared = [...byAddress.values()].filter((keys) => keys.length > 1); + expect(shared.length, "expected CREATE address reuse across chains").toBeGreaterThan(0); + // and every reuse spans different chains, never the same chain twice + for (const keys of shared) { + const chains = keys.map((k) => k.split(":")[0]); + expect(new Set(chains).size, keys.join(" / ")).toBe(chains.length); + } + }); + + it("a shared address still resolves to the right economics per chain", () => { + const op = resolveSplitterRoute("optimism", "merchant-aifp1"); + const base = resolveSplitterRoute("base", "agent-x402"); + expect(op.splitter).toBe(base.splitter); // same address, different chains + expect(op.treasuryBps).toBe(100); + expect(base.treasuryBps).toBe(0); + }); +}); + +describe("resolveSplitterRoute", () => { + it("returns the entry for a known pair", () => { + const d: SplitterRouteDeployment = resolveSplitterRoute("polygon", "merchant-aifp1"); + expect(d.chainId).toBe(137); + expect(d.treasuryBps).toBe(100); + }); + + it("throws rather than falling back to the other route", () => { + expect(() => resolveSplitterRoute("polygon", "not-a-route")).toThrow(UnknownSplitterRouteError); + expect(() => resolveSplitterRoute("ethereum", "agent-x402")).toThrow(UnknownSplitterRouteError); + }); + + it("refuses a chain-only lookup", () => { + expect(() => resolveSplitterRoute("polygon", "")).toThrow(UnknownSplitterRouteError); + }); +}); + +describe("resolveSettlingSplitterRoute", () => { + it("refuses a route that is not enabled for settlement", () => { + expect(() => resolveSettlingSplitterRoute("polygon", "merchant-aifp1")) + .toThrow(/settlement is not enabled/); + }); + + it("still refuses an unknown pair", () => { + expect(() => resolveSettlingSplitterRoute("polygon", "nope")).toThrow(UnknownSplitterRouteError); + }); +}); + +/** + * The policy window, tested against a route that is actually enabled. + * + * Every shipped route has settlementEnabled false, so resolveSettlingSplitterRoute + * rejects on that before it ever looks at validFrom/validUntil. A window test + * written against a shipped route therefore passes without exercising the window + * at all — it proves the settlement flag works, twice. These install a synthetic + * enabled route instead, and assert on the REASON rather than just the throw, so + * a test cannot pass for the wrong reason again. + */ +describe("policy window (enabled route)", () => { + const KEY = "testchain:merchant-aifp1"; + const FROM = "2026-08-27T00:00:00.000Z"; + const UNTIL = "2026-11-25T00:00:00.000Z"; + + const install = (overrides: Partial = {}) => { + const table = SPLITTER_ROUTES as unknown as Record; + table[KEY] = { + ...SPLITTER_ROUTES["polygon:merchant-aifp1"], + settlementEnabled: true, + validFrom: FROM, + validUntil: UNTIL, + ...overrides, + }; + }; + + afterEach(() => { + delete (SPLITTER_ROUTES as unknown as Record)[KEY]; + }); + + const resolve = (now: Date) => resolveSettlingSplitterRoute("testchain", "merchant-aifp1", now); + + it("the synthetic route is genuinely enabled, so these tests exercise the window", () => { + install(); + expect(SPLITTER_ROUTES[KEY as keyof typeof SPLITTER_ROUTES].settlementEnabled).toBe(true); + expect(() => resolve(new Date(FROM))).not.toThrow(); + }); + + it("rejects before validFrom", () => { + install(); + const before = new Date(Date.parse(FROM) - 1); + expect(() => resolve(before)).toThrow(SplitterRouteNotSettlingError); + expect(() => resolve(before)).toThrow(/policy window opens/); + }); + + it("allows exactly at validFrom — the window is inclusive at its start", () => { + install(); + expect(resolve(new Date(FROM)).chain).toBe("polygon"); + }); + + it("allows inside the window", () => { + install(); + const middle = new Date((Date.parse(FROM) + Date.parse(UNTIL)) / 2); + expect(resolve(middle).settlementEnabled).toBe(true); + }); + + it("allows one millisecond before validUntil", () => { + install(); + expect(() => resolve(new Date(Date.parse(UNTIL) - 1))).not.toThrow(); + }); + + it("rejects exactly at validUntil — the window is exclusive at its end", () => { + install(); + expect(() => resolve(new Date(UNTIL))).toThrow(/policy window expired/); + }); + + it("rejects after validUntil", () => { + install(); + const after = new Date(Date.parse(UNTIL) + 86_400_000); + expect(() => resolve(after)).toThrow(/policy window expired/); + }); + + // The reason the comparisons are written as "prove it is inside the window". + // Date.parse("nonsense") is NaN, and NaN fails every comparison — so with + // `t < from` / `t >= until` both gates are false and the route settles with no + // time check at all. Fail-open, on precisely the input you cannot trust. + it.each([ + ["validFrom", { validFrom: "not a date" }], + ["validUntil", { validUntil: "2026-13-45T99:99:99Z" }], + ["both", { validFrom: "", validUntil: "" }], + ])("fails closed when %s is malformed", (_label, overrides) => { + install(overrides as Partial); + const middle = new Date((Date.parse(FROM) + Date.parse(UNTIL)) / 2); + expect(() => resolve(middle)).toThrow(SplitterRouteNotSettlingError); + expect(() => resolve(middle)).toThrow(/policy window is unreadable/); + }); + + it("fails closed when the window is inverted", () => { + install({ validFrom: UNTIL, validUntil: FROM }); + expect(() => resolve(new Date(FROM))).toThrow(/policy window is inverted/); + }); + + it("fails closed when the caller passes an invalid Date as now", () => { + install(); + expect(() => resolve(new Date("nonsense"))).toThrow(/invalid Date/); + }); + + it("the settlement flag still wins over a valid window", () => { + install({ settlementEnabled: false }); + const middle = new Date((Date.parse(FROM) + Date.parse(UNTIL)) / 2); + expect(() => resolve(middle)).toThrow(/settlement is not enabled/); + }); +}); + +describe("registry provenance", () => { + it("records the evm-contract commit the route table was generated from", () => { + expect(SPLITTER_REGISTRY_SOURCE.repo).toBe("AiFinPay/evm-contract"); + expect(SPLITTER_REGISTRY_SOURCE.commit).toMatch(/^[0-9a-f]{40}$/); + expect(SPLITTER_REGISTRY_SOURCE.artifactSha256).toMatch(/^[0-9a-f]{64}$/); + }); + + it("every route is owned by the governance Safe the registry verified", () => { + for (const [key, d] of entries) { + expect(getAddress(d.owner), key).toBe(getAddress(SPLITTER_GOVERNANCE.safe)); + } + }); + + it("governance is recorded as an exact shape, 3 of 5", () => { + expect(SPLITTER_GOVERNANCE.threshold).toBe(3); + expect(SPLITTER_GOVERNANCE.owners).toHaveLength(5); + expect(new Set(SPLITTER_GOVERNANCE.owners).size).toBe(5); + }); +});