From a54a4c107de7bb42f54e411e621d3897938bfc31 Mon Sep 17 00:00:00 2001 From: coinsecuritiescompany Date: Sun, 13 Sep 2026 15:12:29 +0300 Subject: [PATCH 1/3] fix: quarantine unsafe v1.4 deployments --- .github/workflows/ci.yml | 35 ++---- config/v14-production-config.ts | 14 +-- deployments/amoy-v14-amoy-latest.json | 4 +- deployments/arbitrum-v14-arbitrum-latest.json | 5 +- .../avalanche-v14-avalanche-latest.json | 5 +- deployments/base-v14-base-latest.json | 12 +- deployments/bnb-v14-bnb-latest.json | 5 +- deployments/optimism-v14-optimism-latest.json | 5 +- deployments/polygon-v14-polygon-latest.json | 19 ++- .../robinhood-v14-robinhood-latest.json | 6 +- deployments/unichain-v14-unichain-latest.json | 5 +- deployments/xrplevm-v14-xrplevm-latest.json | 5 +- package.json | 1 + scripts/check-deployment-v14.ts | 112 ++++++++++++++---- scripts/check-multisig.ts | 12 +- scripts/deploy-splitter-v14-local.ts | 4 + scripts/deploy-splitter-v14.ts | 46 ++++++- scripts/lib/types.ts | 18 ++- scripts/validate-v14-deployments.mjs | 94 +++++++++++++++ test/V14ProductionConfig.test.ts | 21 ++++ 20 files changed, 349 insertions(+), 79 deletions(-) create mode 100644 scripts/validate-v14-deployments.mjs create mode 100644 test/V14ProductionConfig.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aeb36b8..74eef6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,12 +8,10 @@ on: branches: [main, dev] pull_request: branches: [main, dev] - # The registry job reads live chain state. Between pull requests nothing - # would notice a governance transaction, a whitelist change or a provider - # disagreement, so it also runs on a clock. A scheduled failure is a page, - # not a nuisance: it means chain state moved without a matching review. + # Keep the payment-artifact gate scheduled as a defence against workflow or + # branch-policy drift even when no pull request is open. schedule: - - cron: '17 */6 * * *' + - cron: "17 */6 * * *" workflow_dispatch: # CodeQL actions/missing-workflow-permissions: an unrestricted GITHUB_TOKEN is @@ -50,7 +48,7 @@ jobs: run: bun run prettify:check registry: - name: Canonical registry · drift + chain state + name: v1.4 deployment artifacts · fail-closed validation runs-on: ubuntu-latest steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.3.1 @@ -67,22 +65,9 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - # The generated table is a derived artifact. Hand-editing it would change - # where the SDK sends money without touching the registry, so the gate is - # a regenerate-and-compare rather than a review convention. - - name: Generated splitter table matches the registry - run: node scripts/generate-sdk-table.mjs --check - - # Chain state is the source of truth. This fails when a required chain is - # unreachable, on purpose: "could not check" must not pass as "verified". - # Every value is read from two independent providers and must agree. - - name: Registry matches live chain state - run: node scripts/verify-registry.mjs - - # The timelock documents claimed a 48-hour delay that was never deployed, - # and nothing could notice. A status banner fixes today; a machine-readable - # marker held to the chain keeps it fixed. Fails in both directions, so it - # also catches docs still describing direct ownership after a timelock - # goes live. - - name: Governance documents match on-chain ownership - run: node scripts/verify-governance-docs.mjs + # The legacy v1.1-v1.3 registry was intentionally removed and cannot + # model v1.4's splitter + TokenList + Profiles topology. Validate the + # checked-in v1.4 records instead; live RPC verification remains a + # release-gate operation and must never silently pass on provider errors. + - name: Validate v1.4 deployment records and quarantine state + run: bun run check:deployments:offline diff --git a/config/v14-production-config.ts b/config/v14-production-config.ts index 0ac8a84..005a7bd 100644 --- a/config/v14-production-config.ts +++ b/config/v14-production-config.ts @@ -27,6 +27,8 @@ export interface V14ProductionNetwork { const CIRCLE_USDC_SOURCE = "Circle USDC contract-address registry, verified 2026-08-27"; const TETHER_USDT_SOURCE = "Tether supported-protocols registry, verified 2026-08-27"; +const POLYGON_BRIDGED_USDC_SOURCE = + "Polygon PoS bridged USDC.e contract, corrected during AIFINP-223 review 2026-09-13"; const CREATE3_SALTS = { TokenList: "0x8be94b4c2852b83d5f69de83ba79859073143caceb89a1cdda67fb51455c4606", @@ -73,9 +75,9 @@ export const V14_PRODUCTION_NETWORKS: Record = { }, { address: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", - symbol: "USDT", - name: "USDT", - source: TETHER_USDT_SOURCE, + symbol: "USDC.e", + name: "Bridged USDC", + source: POLYGON_BRIDGED_USDC_SOURCE, }, ], salts: CREATE3_SALTS, @@ -160,12 +162,6 @@ export const V14_PRODUCTION_NETWORKS: Record = { ], salts: CREATE3_SALTS, }, - 677: { - name: "BOT Chain", - chainId: 677, - stablecoins: [], - salts: CREATE3_SALTS, - }, 1440000: { name: "XRPL EVM", chainId: 1440000, diff --git a/deployments/amoy-v14-amoy-latest.json b/deployments/amoy-v14-amoy-latest.json index 5d16e7a..dba47b6 100644 --- a/deployments/amoy-v14-amoy-latest.json +++ b/deployments/amoy-v14-amoy-latest.json @@ -14,5 +14,7 @@ "usdc": "0x41E94Eb019C0762f9Bfcf9Fb1E58725BfB0e7582", "usdt": "0x0000000000000000000000000000000000000000" }, - "runtimeCodeHash": "0xd4990487312c00916aa218bdcd697bbf1a2729335b6a4fda903517b9d3153a27" + "runtimeCodeHash": "0xd4990487312c00916aa218bdcd697bbf1a2729335b6a4fda903517b9d3153a27", + "status": "enabled", + "settlementEnabled": true } diff --git a/deployments/arbitrum-v14-arbitrum-latest.json b/deployments/arbitrum-v14-arbitrum-latest.json index e2ac18c..90a2571 100644 --- a/deployments/arbitrum-v14-arbitrum-latest.json +++ b/deployments/arbitrum-v14-arbitrum-latest.json @@ -14,5 +14,8 @@ "usdc": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", "usdt": "0x0000000000000000000000000000000000000000" }, - "runtimeCodeHash": "0xc15837e6f438d0fd2f23e0c5eb7a2f655b2af7dc717bd2861365819de45757f7" + "runtimeCodeHash": "0xc15837e6f438d0fd2f23e0c5eb7a2f655b2af7dc717bd2861365819de45757f7", + "status": "disabled", + "settlementEnabled": false, + "disabledReason": "Backend v1.4 receipt verification and end-to-end settlement gate are incomplete" } diff --git a/deployments/avalanche-v14-avalanche-latest.json b/deployments/avalanche-v14-avalanche-latest.json index 4cea42c..1904404 100644 --- a/deployments/avalanche-v14-avalanche-latest.json +++ b/deployments/avalanche-v14-avalanche-latest.json @@ -14,5 +14,8 @@ "usdc": "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E", "usdt": "0x9702230a8ea53601f5cd2dc00fdbc13d4df4a8c7" }, - "runtimeCodeHash": "0x65db1830b1d15400cba35b4ef54dd6c5852d5baa077a732bdc84f7c2218952ef" + "runtimeCodeHash": "0x65db1830b1d15400cba35b4ef54dd6c5852d5baa077a732bdc84f7c2218952ef", + "status": "disabled", + "settlementEnabled": false, + "disabledReason": "Backend v1.4 receipt verification and end-to-end settlement gate are incomplete" } diff --git a/deployments/base-v14-base-latest.json b/deployments/base-v14-base-latest.json index e3630a1..81e399e 100644 --- a/deployments/base-v14-base-latest.json +++ b/deployments/base-v14-base-latest.json @@ -11,8 +11,18 @@ "treasury": "0x5afe07483886dfa0b77c6d60212b6e52d78ac11e", "tokenList": "0xbA98C0797707611787B04680E260036573D9D7a1", "profiles": "0x4dcDd923d9c45bd306aA21c4438B3D325f8F783C", + "stablecoins": [ + { + "symbol": "USDC", + "name": "USDC", + "address": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + } + ], "usdc": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", "usdt": "0x0000000000000000000000000000000000000000" }, - "runtimeCodeHash": "0xc724e02657817177d1394e8d41189dd7f8299bb5cf2da04946572378c935883c" + "runtimeCodeHash": "0xc724e02657817177d1394e8d41189dd7f8299bb5cf2da04946572378c935883c", + "status": "invalid", + "settlementEnabled": false, + "disabledReason": "INVALID DEPLOYMENT: splitter address equals TokenList address and Profiles has no code; redeploy required" } diff --git a/deployments/bnb-v14-bnb-latest.json b/deployments/bnb-v14-bnb-latest.json index 0ff0f87..dc3e93a 100644 --- a/deployments/bnb-v14-bnb-latest.json +++ b/deployments/bnb-v14-bnb-latest.json @@ -14,5 +14,8 @@ "usdc": "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", "usdt": "0x55d398326f99059fF775485246999027B3197955" }, - "runtimeCodeHash": "0x8aa8c5999a1a0ee87198e380188faaa9d6df88819b47d68682096cc33db81638" + "runtimeCodeHash": "0x8aa8c5999a1a0ee87198e380188faaa9d6df88819b47d68682096cc33db81638", + "status": "disabled", + "settlementEnabled": false, + "disabledReason": "Backend v1.4 receipt verification and end-to-end settlement gate are incomplete" } diff --git a/deployments/optimism-v14-optimism-latest.json b/deployments/optimism-v14-optimism-latest.json index 9db2053..9ded164 100644 --- a/deployments/optimism-v14-optimism-latest.json +++ b/deployments/optimism-v14-optimism-latest.json @@ -14,5 +14,8 @@ "usdc": "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", "usdt": "0x0000000000000000000000000000000000000000" }, - "runtimeCodeHash": "0x5a2d0ffb996d5655fa483e41d3e5870c6a032fd00bea71c3107f95a4b405e152" + "runtimeCodeHash": "0x5a2d0ffb996d5655fa483e41d3e5870c6a032fd00bea71c3107f95a4b405e152", + "status": "disabled", + "settlementEnabled": false, + "disabledReason": "Backend v1.4 receipt verification and end-to-end settlement gate are incomplete" } diff --git a/deployments/polygon-v14-polygon-latest.json b/deployments/polygon-v14-polygon-latest.json index d578ea0..2c6d46b 100644 --- a/deployments/polygon-v14-polygon-latest.json +++ b/deployments/polygon-v14-polygon-latest.json @@ -11,8 +11,23 @@ "treasury": "0x01b80329ff81ce1d22a9e2e8807df5f92414c3c3", "tokenList": "0xbA98C0797707611787B04680E260036573D9D7a1", "profiles": "0x4dcDd923d9c45bd306aA21c4438B3D325f8F783C", + "stablecoins": [ + { + "symbol": "USDC", + "name": "USDC", + "address": "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359" + }, + { + "symbol": "USDC.e", + "name": "Bridged USDC", + "address": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" + } + ], "usdc": "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", - "usdt": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" + "usdt": "0x0000000000000000000000000000000000000000" }, - "runtimeCodeHash": "0x974b871ac79082d92a7e3bba89ba52794f7906f99119dfad7959017e5b0bf038" + "runtimeCodeHash": "0x974b871ac79082d92a7e3bba89ba52794f7906f99119dfad7959017e5b0bf038", + "status": "disabled", + "settlementEnabled": false, + "disabledReason": "Backend v1.4 verification and end-to-end settlement gate are not complete" } diff --git a/deployments/robinhood-v14-robinhood-latest.json b/deployments/robinhood-v14-robinhood-latest.json index c9af7ed..1f60a5c 100644 --- a/deployments/robinhood-v14-robinhood-latest.json +++ b/deployments/robinhood-v14-robinhood-latest.json @@ -11,8 +11,12 @@ "treasury": "0x5AFe07483886DFa0B77C6d60212B6E52D78ac11e", "tokenList": "0xbA98C0797707611787B04680E260036573D9D7a1", "profiles": "0x4dcDd923d9c45bd306aA21c4438B3D325f8F783C", + "stablecoins": [], "usdc": "0x0000000000000000000000000000000000000000", "usdt": "0x0000000000000000000000000000000000000000" }, - "runtimeCodeHash": "0x96812eb70224f48c095b240bc0e1d739a7fcb878c3237b5f795629c239d7ef72" + "runtimeCodeHash": "0x96812eb70224f48c095b240bc0e1d739a7fcb878c3237b5f795629c239d7ef72", + "status": "disabled", + "settlementEnabled": false, + "disabledReason": "TokenList was deployed empty; USDe/USDG require a 3-of-4 Safe allowlist transaction and backend verification" } diff --git a/deployments/unichain-v14-unichain-latest.json b/deployments/unichain-v14-unichain-latest.json index 272fb00..eb72a6d 100644 --- a/deployments/unichain-v14-unichain-latest.json +++ b/deployments/unichain-v14-unichain-latest.json @@ -14,5 +14,8 @@ "usdc": "0x078D782b760474a361dDA0AF3839290b0EF57AD6", "usdt": "0x0000000000000000000000000000000000000000" }, - "runtimeCodeHash": "0xcb4f1c15a87720c326daa574a7ab52c116c208805167b46dd055f47c9c8695ca" + "runtimeCodeHash": "0xcb4f1c15a87720c326daa574a7ab52c116c208805167b46dd055f47c9c8695ca", + "status": "disabled", + "settlementEnabled": false, + "disabledReason": "Backend v1.4 receipt verification and end-to-end settlement gate are incomplete" } diff --git a/deployments/xrplevm-v14-xrplevm-latest.json b/deployments/xrplevm-v14-xrplevm-latest.json index ebe05c6..aea457d 100644 --- a/deployments/xrplevm-v14-xrplevm-latest.json +++ b/deployments/xrplevm-v14-xrplevm-latest.json @@ -14,5 +14,8 @@ "usdc": "0x0000000000000000000000000000000000000000", "usdt": "0x0000000000000000000000000000000000000000" }, - "runtimeCodeHash": "0xd26862dbc501481675f50924ca013acd5c20aca339f2a1aca818becedf7b5aa0" + "runtimeCodeHash": "0xd26862dbc501481675f50924ca013acd5c20aca339f2a1aca818becedf7b5aa0", + "status": "disabled", + "settlementEnabled": false, + "disabledReason": "No stablecoin is configured and backend verification is incomplete" } diff --git a/package.json b/package.json index 47e3738..e3b5caf 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "check:wallet": "hardhat run scripts/check-wallet.ts", "check:multisig": "bun scripts/check-multisig.ts", "check:deployment": "hardhat run scripts/check-deployment-v14.ts", + "check:deployments:offline": "node scripts/validate-v14-deployments.mjs", "init:multisig": "bun scripts/init-safe-multisig.ts", "verify": "hardhat run scripts/verify.ts --build-profile production", "lint": "solhint 'contracts/**/*.sol'", diff --git a/scripts/check-deployment-v14.ts b/scripts/check-deployment-v14.ts index 6bc227a..8f9d0ce 100644 --- a/scripts/check-deployment-v14.ts +++ b/scripts/check-deployment-v14.ts @@ -2,8 +2,9 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { network } from "hardhat"; -import { isAddress } from "ethers"; +import { ZeroAddress, isAddress, keccak256 } from "ethers"; import type { DeploymentRecord } from "./lib/types.js"; +import { routeIdsV14 } from "../config/v14-production-config.js"; const { ethers, networkName } = await network.create(); @@ -20,6 +21,20 @@ async function hasCode(_address: string, _label: string): Promise { return ok; } +function configuredStablecoins( + record: DeploymentRecord, +): Array<{ symbol: string; address: string }> { + const splitter = record.splitter; + if (!splitter) return []; + if (Array.isArray(splitter.stablecoins)) return splitter.stablecoins; + + // Backwards-compatible reader for records written before the generic asset schema. + return [ + { symbol: "USDC", address: splitter.usdc ?? ZeroAddress }, + { symbol: "USDT", address: splitter.usdt ?? ZeroAddress }, + ].filter((asset) => asset.address !== ZeroAddress); +} + async function main() { const chainId = Number((await ethers.provider.getNetwork()).chainId); const deploymentsDir = path.join(__dirname, "../deployments"); @@ -47,6 +62,10 @@ async function main() { ); } + if (!record.splitter) { + throw new Error("Deployment record does not contain a v1.4 splitter payload."); + } + const s = record.splitter; let ok = true; @@ -59,6 +78,19 @@ async function main() { ok &&= await hasCode(s.tokenList, "TokenList"); ok &&= await hasCode(s.profiles, "Profiles"); + const runtimeCode = await ethers.provider.getCode(s.address); + const actualRuntimeCodeHash = runtimeCode.length > 2 ? keccak256(runtimeCode) : null; + const runtimeHashMatches = + typeof record.runtimeCodeHash === "string" && + actualRuntimeCodeHash?.toLowerCase() === record.runtimeCodeHash.toLowerCase(); + console.log( + status( + runtimeHashMatches, + `runtime code hash = ${actualRuntimeCodeHash ?? "no code"} (record: ${record.runtimeCodeHash ?? "missing"})`, + ), + ); + ok &&= runtimeHashMatches; + // ── Address validity ── console.log("\n--- Address validity ---"); for (const [label, addr] of Object.entries({ @@ -68,14 +100,24 @@ async function main() { treasury: s.treasury, tokenList: s.tokenList, profiles: s.profiles, - usdc: s.usdc, - usdt: s.usdt, + ...Object.fromEntries( + configuredStablecoins(record).map((asset) => [`asset ${asset.symbol}`, asset.address]), + ), })) { - const valid = isAddress(addr); + const valid = isAddress(addr) && addr.toLowerCase() !== ZeroAddress; console.log(status(valid, `${label}: ${addr}`)); ok &&= valid; } + const contractAddresses = [s.address, s.tokenList, s.profiles].map((address) => + address.toLowerCase(), + ); + const contractsAreDistinct = new Set(contractAddresses).size === contractAddresses.length; + console.log( + status(contractsAreDistinct, "splitter, TokenList and Profiles addresses are distinct"), + ); + ok &&= contractsAreDistinct; + // ── B2BSplitterV14 state ── console.log("\n--- B2BSplitterV14 ---"); const splitter = await ethers.getContractAt("B2BSplitterV14", s.address); @@ -110,16 +152,23 @@ async function main() { ok &&= hasAdmin && hasSigner && hasPauser; // ── Deployer / admin separation ── - const deployerAddress = await (await ethers.getSigners())[0].getAddress(); + const deployerAddress = process.env.AIFINPAY_DEPLOYER_ADDRESS?.trim(); const defaultAdminRole = ethers.ZeroHash; - const splitterDeployerIsAdmin = deployerAddress.toLowerCase() === s.admin.toLowerCase(); - const splitterDeployerHasAdmin = await splitter.hasRole(adminRole, deployerAddress); - if (splitterDeployerIsAdmin || splitterDeployerHasAdmin) { - console.warn( - `⚠️ Deployer ${deployerAddress} holds ADMIN_ROLE on B2BSplitterV14. ` + - `For production governance, ADMIN_ROLE should be transferred to a Safe/Timelock and renounced by the deployer.`, - ); + if (deployerAddress) { + if (!isAddress(deployerAddress) || deployerAddress.toLowerCase() === ZeroAddress) { + throw new Error("AIFINPAY_DEPLOYER_ADDRESS is not a valid non-zero address."); + } + const splitterDeployerIsAdmin = deployerAddress.toLowerCase() === s.admin.toLowerCase(); + const splitterDeployerHasAdmin = await splitter.hasRole(adminRole, deployerAddress); + if (splitterDeployerIsAdmin || splitterDeployerHasAdmin) { + console.warn( + `⚠️ Deployer ${deployerAddress} holds ADMIN_ROLE on B2BSplitterV14. ` + + `For production governance, ADMIN_ROLE should be transferred to a Safe/Timelock and renounced by the deployer.`, + ); + } + } else { + console.log(" Deployer role check skipped (AIFINPAY_DEPLOYER_ADDRESS not set)."); } // ── Paused state ── @@ -132,9 +181,8 @@ async function main() { // ── TokenList ── console.log("\n--- TokenList ---"); const tokenListAdminOk = await tokenList.hasRole(defaultAdminRole, s.admin); - const tokenListDeployerHasAdmin = await tokenList.hasRole(defaultAdminRole, deployerAddress); console.log(status(tokenListAdminOk, `DEFAULT_ADMIN_ROLE granted to ${s.admin}`)); - if (tokenListDeployerHasAdmin) { + if (deployerAddress && (await tokenList.hasRole(defaultAdminRole, deployerAddress))) { console.warn( `⚠️ Deployer ${deployerAddress} holds DEFAULT_ADMIN_ROLE on TokenList. ` + `Transfer admin to the governance Safe and renounce the deployer.`, @@ -142,21 +190,21 @@ async function main() { } ok &&= tokenListAdminOk; - for (const [symbol, addr] of [ - ["USDC", s.usdc], - ["USDT", s.usdt], - ]) { - const allowed = await tokenList.isAllowed(addr); - console.log(status(allowed, `${symbol} (${addr}) is allowed`)); + const zeroAddressAllowed = await tokenList.isAllowed(ZeroAddress); + console.log(status(!zeroAddressAllowed, "address(0) is not allowed")); + ok &&= !zeroAddressAllowed; + + for (const { symbol, address } of configuredStablecoins(record)) { + const allowed = await tokenList.isAllowed(address); + console.log(status(allowed, `${symbol} (${address}) is allowed`)); ok &&= allowed; } // ── Profiles ── console.log("\n--- Profiles ---"); const profilesAdminOk = await profiles.hasRole(defaultAdminRole, s.admin); - const profilesDeployerHasAdmin = await profiles.hasRole(defaultAdminRole, deployerAddress); console.log(status(profilesAdminOk, `DEFAULT_ADMIN_ROLE granted to ${s.admin}`)); - if (profilesDeployerHasAdmin) { + if (deployerAddress && (await profiles.hasRole(defaultAdminRole, deployerAddress))) { console.warn( `⚠️ Deployer ${deployerAddress} holds DEFAULT_ADMIN_ROLE on Profiles. ` + `Transfer admin to the governance Safe and renounce the deployer.`, @@ -166,16 +214,32 @@ async function main() { const routeIds = await profiles.routeIds(); console.log(` Configured routes: ${routeIds.length}`); + const expectedRouteIds = routeIdsV14(); + const expectedProfiles = new Map([ + [expectedRouteIds.agent.toLowerCase(), { treasuryBps: 0n, ipCreatorBps: 0n }], + [expectedRouteIds.merchant.toLowerCase(), { treasuryBps: 100n, ipCreatorBps: 0n }], + ]); + const exactRouteSet = + routeIds.length === expectedProfiles.size && + routeIds.every((routeId: string) => expectedProfiles.has(routeId.toLowerCase())); + console.log(status(exactRouteSet, "enabled route set matches agent-x402 + merchant-aifp1")); + ok &&= exactRouteSet; for (const routeId of routeIds) { const profile = await profiles.getProfile(routeId); const enabled = await profiles.isEnabled(routeId); + const expected = expectedProfiles.get(routeId.toLowerCase()); + const economicsMatch = + expected !== undefined && + profile.treasuryBps === expected.treasuryBps && + profile.ipCreatorBps === expected.ipCreatorBps && + profile.routeTreasury === ZeroAddress; console.log( status( - enabled, + enabled && economicsMatch, `route ${routeId}: treasuryBps=${profile.treasuryBps}, ipCreatorBps=${profile.ipCreatorBps}, enabled=${enabled}, treasury=${profile.routeTreasury}`, ), ); - ok &&= enabled; + ok &&= enabled && economicsMatch; } // ── Summary ── diff --git a/scripts/check-multisig.ts b/scripts/check-multisig.ts index f2a6630..96942c4 100644 --- a/scripts/check-multisig.ts +++ b/scripts/check-multisig.ts @@ -63,16 +63,16 @@ const NETWORK_RPC_ENV: Record asset.address !== ethers.ZeroAddress), usdc: usdcAddr, usdt: usdtAddr, }, diff --git a/scripts/deploy-splitter-v14.ts b/scripts/deploy-splitter-v14.ts index 5391d9f..1b9ce6d 100644 --- a/scripts/deploy-splitter-v14.ts +++ b/scripts/deploy-splitter-v14.ts @@ -32,6 +32,7 @@ import { V14_PRODUCTION_NETWORKS, configuredSalt, configuredStableAddress, + configuredStablecoins, governanceEnv, initialSignerEnv, pauserEnv, @@ -75,11 +76,13 @@ async function main() { console.log("\nStep 4/6: Resolving route and stablecoin configuration..."); const { routeIds, treasuryBps, ipCreatorBps } = routeDeploymentConfigV14(); + const configuredAssets = configuredStablecoins(chainId); + const stablecoins = configuredAssets.map((asset) => asset.address); const usdc = configuredStableAddress(chainId, "USDC"); const usdt = configuredStableAddress(chainId, "USDT"); - const stablecoins = [usdc, usdt].filter((t) => t !== ZeroAddress); - console.log(` USDC = ${usdc}`); - console.log(` USDT = ${usdt}`); + for (const asset of configuredAssets) { + console.log(` ${asset.symbol.padEnd(10)} = ${asset.address}`); + } console.log(` Stablecoins used = [${stablecoins.join(", ")}]`); console.log(` Routes = [${routeIds.join(", ")}]`); console.log(` Treasury bps = [${treasuryBps.join(", ")}]`); @@ -120,6 +123,19 @@ async function main() { console.log( ` TokenList = ${tokenListAddr} (predicted ${predictedTokenList})${tokenListSkipped ? " [reused existing]" : ""}`, ); + if (tokenListSkipped) { + const tokenList = await ethers.getContractAt("TokenList", tokenListAddr); + if (!(await tokenList.hasRole(ethers.ZeroHash, gov.admin))) { + throw new Error(`Reused TokenList ${tokenListAddr} is not administered by ${gov.admin}.`); + } + for (const asset of configuredAssets) { + if (!(await tokenList.isAllowed(asset.address))) { + throw new Error( + `Reused TokenList ${tokenListAddr} does not allow ${asset.symbol} (${asset.address}).`, + ); + } + } + } const { address: profilesAddr, @@ -142,6 +158,26 @@ async function main() { console.log( ` Profiles = ${profilesAddr} (predicted ${predictedProfiles})${profilesSkipped ? " [reused existing]" : ""}`, ); + if (profilesSkipped) { + const profiles = await ethers.getContractAt("Profiles", profilesAddr); + if (!(await profiles.hasRole(ethers.ZeroHash, gov.admin))) { + throw new Error(`Reused Profiles ${profilesAddr} is not administered by ${gov.admin}.`); + } + for (let index = 0; index < routeIds.length; index += 1) { + const routeId = routeIds[index]; + const profile = await profiles.getProfile(routeId); + if ( + !(await profiles.isEnabled(routeId)) || + profile.treasuryBps !== BigInt(treasuryBps[index]) || + profile.ipCreatorBps !== BigInt(ipCreatorBps[index]) || + profile.routeTreasury !== ZeroAddress + ) { + throw new Error( + `Reused Profiles ${profilesAddr} has unexpected state for route ${routeId}.`, + ); + } + } + } console.log("\n Deploying B2BSplitterV14..."); const splitterArgs = [ @@ -200,10 +236,14 @@ async function main() { treasury: gov.treasury, tokenList: tokenListAddr, profiles: profilesAddr, + stablecoins: configuredAssets, usdc, usdt, }, runtimeCodeHash, + status: "disabled", + settlementEnabled: false, + disabledReason: "New deployment requires independent verification before settlement", }; const { latest } = writeDeploymentRecord( diff --git a/scripts/lib/types.ts b/scripts/lib/types.ts index 6d7e515..3fdc3c1 100644 --- a/scripts/lib/types.ts +++ b/scripts/lib/types.ts @@ -1,3 +1,10 @@ +export interface StablecoinDeployment { + symbol: string; + address: string; + name?: string; + source?: string | null; +} + export interface SplitterV14Deployment { address: string; admin: string; @@ -6,8 +13,12 @@ export interface SplitterV14Deployment { treasury: string; tokenList: string; profiles: string; - usdc: string; - usdt: string; + /** Canonical asset list. Symbols are display metadata; addresses are identities. */ + stablecoins: StablecoinDeployment[]; + /** @deprecated Compatibility fields for older SDK readers. */ + usdc?: string; + /** @deprecated Compatibility fields for older SDK readers. */ + usdt?: string; } export interface DeploymentRecord { @@ -17,4 +28,7 @@ export interface DeploymentRecord { splitterVersion?: string; splitter?: SplitterV14Deployment; runtimeCodeHash?: string; + status?: "enabled" | "disabled" | "invalid" | "retired"; + settlementEnabled?: boolean; + disabledReason?: string; } diff --git a/scripts/validate-v14-deployments.mjs b/scripts/validate-v14-deployments.mjs new file mode 100644 index 0000000..49799ca --- /dev/null +++ b/scripts/validate-v14-deployments.mjs @@ -0,0 +1,94 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const deploymentsDir = path.join(root, "deployments"); +const files = fs + .readdirSync(deploymentsDir) + .filter((file) => /^(.+)-v14-\1-latest\.json$/.test(file)) + .sort(); +const expected = new Set([ + "amoy", + "arbitrum", + "avalanche", + "base", + "bnb", + "optimism", + "polygon", + "robinhood", + "unichain", + "xrplevm", +]); +const zeroAddress = "0x0000000000000000000000000000000000000000"; + +function fail(file, message) { + throw new Error(`${file}: ${message}`); +} + +for (const file of files) { + const record = JSON.parse(fs.readFileSync(path.join(deploymentsDir, file), "utf8")); + expected.delete(record.network); + if (record.network === "botchain") fail(file, "BOT Chain v1.4 is forbidden by ADR-0001"); + if (record.splitterVersion !== "1.4" || !Number.isInteger(record.chainId)) { + fail(file, "invalid version or chainId"); + } + if (!record.splitter) fail(file, "missing splitter payload"); + + const componentAddresses = [ + record.splitter.address, + record.splitter.tokenList, + record.splitter.profiles, + ]; + for (const address of componentAddresses) { + if (!/^0x[0-9a-fA-F]{40}$/.test(address) || address.toLowerCase() === zeroAddress) { + fail(file, `invalid component address ${address}`); + } + } + + const componentsAreDistinct = + new Set(componentAddresses.map((address) => address.toLowerCase())).size === 3; + if (!componentsAreDistinct && record.status !== "invalid") { + fail(file, "component addresses overlap without status=invalid"); + } + if (!/^0x[0-9a-fA-F]{64}$/.test(record.runtimeCodeHash ?? "")) { + fail(file, "missing or invalid runtimeCodeHash"); + } + + if (record.network === "amoy") { + if (record.status !== "enabled" || record.settlementEnabled !== true) { + fail(file, "Amoy must be explicitly enabled for test settlement"); + } + } else if ( + record.settlementEnabled !== false || + !["disabled", "invalid"].includes(record.status) || + !record.disabledReason + ) { + fail(file, "production deployment must be explicitly quarantined with a reason"); + } + + const assets = Array.isArray(record.splitter.stablecoins) + ? record.splitter.stablecoins + : [ + { symbol: "USDC", address: record.splitter.usdc }, + { symbol: "USDT", address: record.splitter.usdt }, + ].filter((asset) => asset.address && asset.address.toLowerCase() !== zeroAddress); + for (const asset of assets) { + if (!asset.symbol || !/^0x[0-9a-fA-F]{40}$/.test(asset.address)) { + fail(file, "invalid stablecoin entry"); + } + } + + if (record.network === "polygon") { + const bridgedUsdc = assets.find( + (asset) => asset.address.toLowerCase() === "0x2791bca1f2de4661ed88a30c99a7a9449aa84174", + ); + if (bridgedUsdc?.symbol !== "USDC.e") fail(file, "0x2791… must be USDC.e, not USDT"); + } + if (record.network === "robinhood" && assets.length !== 0) { + fail(file, "record must reflect the currently empty on-chain TokenList"); + } +} + +if (expected.size > 0) throw new Error(`Missing v1.4 latest records: ${[...expected].join(", ")}`); +console.log(`Validated ${files.length} v1.4 deployment records; production remains fail-closed.`); diff --git a/test/V14ProductionConfig.test.ts b/test/V14ProductionConfig.test.ts new file mode 100644 index 0000000..e85c0b3 --- /dev/null +++ b/test/V14ProductionConfig.test.ts @@ -0,0 +1,21 @@ +import { expect } from "chai"; +import { ZeroAddress } from "ethers"; +import { configuredStableAddress, configuredStablecoins } from "../config/v14-production-config.js"; + +describe("v1.4 production asset configuration", () => { + it("does not mislabel Polygon bridged USDC as USDT", () => { + const assets = configuredStablecoins(137); + expect(assets.map((asset) => asset.symbol)).to.deep.equal(["USDC", "USDC.e"]); + expect(configuredStableAddress(137, "USDT")).to.equal(ZeroAddress); + }); + + it("returns all Robinhood assets instead of assuming USDC/USDT", () => { + const assets = configuredStablecoins(4663); + expect(assets.map((asset) => asset.symbol)).to.deep.equal(["USDe", "USDG"]); + expect(assets.every((asset) => asset.address !== ZeroAddress)).to.equal(true); + }); + + it("hard-disables BOT Chain for v1.4 deployment", () => { + expect(() => configuredStablecoins(677)).to.throw("Unsupported AiFinPay v1.4 chainId 677"); + }); +}); From 726fb1d7b151b31f7ad9a2f95731496ef72463c2 Mon Sep 17 00:00:00 2001 From: coinsecuritiescompany Date: Sun, 13 Sep 2026 15:15:37 +0300 Subject: [PATCH 2/3] test: assert CREATE3 duplicate guard --- test/Create3.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/test/Create3.test.ts b/test/Create3.test.ts index 2d74c9c..89f2d97 100644 --- a/test/Create3.test.ts +++ b/test/Create3.test.ts @@ -44,8 +44,13 @@ describe("CREATE3 deployment", () => { const predicted = await predictCreate3Address(ethers, factoryAddress, deployerAddress, salt); expect(deployedA).to.equal(predicted); - await expect( - deployViaCreate3(ethers, factoryAddress, "TokenList", salt, [deployerAddress, []]), - ).to.be.revertedWith("DEPLOYMENT_FAILED"); + let duplicateError: unknown; + try { + await deployViaCreate3(ethers, factoryAddress, "TokenList", salt, [deployerAddress, []]); + } catch (error) { + duplicateError = error; + } + expect(duplicateError).to.be.instanceOf(Error); + expect((duplicateError as Error).message).to.include("already exists at predicted address"); }); }); From 6617b8a362a6e417937a80c84b69a46323110174 Mon Sep 17 00:00:00 2001 From: coinsecuritiescompany Date: Sun, 13 Sep 2026 15:59:50 +0300 Subject: [PATCH 3/3] refactor: use ethers ZeroAddress in validator --- scripts/validate-v14-deployments.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/validate-v14-deployments.mjs b/scripts/validate-v14-deployments.mjs index 49799ca..41bf10f 100644 --- a/scripts/validate-v14-deployments.mjs +++ b/scripts/validate-v14-deployments.mjs @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { ZeroAddress } from "ethers"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const deploymentsDir = path.join(root, "deployments"); @@ -20,7 +21,6 @@ const expected = new Set([ "unichain", "xrplevm", ]); -const zeroAddress = "0x0000000000000000000000000000000000000000"; function fail(file, message) { throw new Error(`${file}: ${message}`); @@ -41,7 +41,7 @@ for (const file of files) { record.splitter.profiles, ]; for (const address of componentAddresses) { - if (!/^0x[0-9a-fA-F]{40}$/.test(address) || address.toLowerCase() === zeroAddress) { + if (!/^0x[0-9a-fA-F]{40}$/.test(address) || address.toLowerCase() === ZeroAddress) { fail(file, `invalid component address ${address}`); } } @@ -72,7 +72,7 @@ for (const file of files) { : [ { symbol: "USDC", address: record.splitter.usdc }, { symbol: "USDT", address: record.splitter.usdt }, - ].filter((asset) => asset.address && asset.address.toLowerCase() !== zeroAddress); + ].filter((asset) => asset.address && asset.address.toLowerCase() !== ZeroAddress); for (const asset of assets) { if (!asset.symbol || !/^0x[0-9a-fA-F]{40}$/.test(asset.address)) { fail(file, "invalid stablecoin entry");