From 625dc47e1547411c3b8e462378cbb9327c4e2081 Mon Sep 17 00:00:00 2001 From: pawmmm Date: Thu, 17 Sep 2026 11:17:19 -0600 Subject: [PATCH 1/4] Describe the develop, sandbox and main branch model CONTRIBUTING.md still called main the release branch that deploys key off, and the release-readiness checklist only named main. The stack now integrates on develop, releases to testnet from sandbox, which only the release coordinator writes through the develop to sandbox release pull request, and marks production with main, which the production coordinator moves by fast-forward to a release commit of sandbox. Say so in both documents so nobody merges into sandbox or main by hand, and keep the statement that moving a branch never deploys a contract. Documentation only; CI already runs on every push, so the workflow is unchanged. No contract, ABI or deployment change: history-compatible, no new epoch. --- CONTRIBUTING.md | 9 ++++++--- docs/release-readiness.md | 5 ++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5a8b9a0..9726deb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,8 +17,11 @@ test with any reproducer. ## Branching `develop` is the integration branch — all work and PRs target `develop`. -`main` is the release branch (deploys across the Daski stack key off it), so -`develop` → `main` merges are deliberate, explicitly authorized release steps -only. +`sandbox` is the testnet release branch: only the release coordinator writes +it, through the `develop` → `sandbox` release pull request. `main` marks +production and moves only by fast-forward to a release commit of `sandbox`, +performed by the production coordinator. Nobody merges into `sandbox` or +`main` by hand, and moving a branch never deploys a contract: on-chain actions +stay separate governance steps. Every push to `develop` must satisfy the [release readiness](docs/release-readiness.md) definition of done, because the release coordinator only checks that CI passed on the exact `develop` commit it ships. diff --git a/docs/release-readiness.md b/docs/release-readiness.md index 818d146..9b8c977 100644 --- a/docs/release-readiness.md +++ b/docs/release-readiness.md @@ -29,7 +29,10 @@ A change may be pushed to `develop` only when all of the following hold. - [ ] Deployment scripts under `script/` remain runnable against a fork (`forge script ... --fork-url ...` with the `STANDARD_RAIL_*` inputs named in their source). -- [ ] Nothing is merged to `main` or tagged by hand. +- [ ] Nothing is merged to `sandbox` or `main`, or tagged, by hand. The release + coordinator writes `sandbox`, the testnet release branch, through the + `develop` to `sandbox` release pull request; the production coordinator + moves `main` only by fast-forward to a release commit of `sandbox`. - [ ] On-chain actions (deploy, upgrade, Safe batches) happen only through the coordinator's epoch or upgrade runbooks with explicit owner authorization. Running tests or pushing this repository never From 2b09dc878118094740349827f5debd6b872b4087 Mon Sep 17 00:00:00 2001 From: pawmmm Date: Thu, 17 Sep 2026 11:41:30 -0600 Subject: [PATCH 2/4] Let the splitter scripts run on Base as well as Base Sepolia DeployOutcomeSplitterFactory and DeployOutcomeSplitter required chain 84532 and StandardRailCircleUSDC pinned the Base Sepolia chain id and USDC address, so the standard rail could not be deployed to Base through reviewed scripts. StandardRailCircleUSDC now names the two supported chains, Base (8453) and Base Sepolia (84532), returns the reviewed canonical Circle USDC for each, and refuses every other chain and every other token; the decimals, pause and blacklist checks are unchanged. The deploy scripts bind a splitter to the executing chain and that chain's reviewed token, and WriteOutcomeSplitterManifest follows through the same library, so a reviewed init code hash prepared for the other chain or for another token is refused. The Base USDC address 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 is the reviewed 8453 entry of REVIEWED_USDC_DOMAINS in the gateway's src/payment/usdcDomain.ts (daski-gateway develop at 1c3ecc1), checksummed with cast. The Base Sepolia address is unchanged. DeployOutcomeSplitter now carries its inputs in a memory struct so it also compiles without the IR pipeline: coverage builds compile whatever the tests import, and this script had no test before. Its environment variable names, read order, checks and returned address are unchanged. New tests run both deploy scripts under both chain ids and refuse an unsupported chain and a review made for another chain or token; the library tests cover both chains the same way. Deployment tooling and README only. No contract, ABI or deployment change: history-compatible, no new epoch. --- README.md | 25 ++- script/DeployOutcomeSplitter.s.sol | 136 ++++++++----- script/DeployOutcomeSplitterFactory.s.sol | 5 +- script/StandardRailCircleUSDC.sol | 21 +- test/OutcomeSplitterDeploymentScripts.t.sol | 203 ++++++++++++++++++++ test/OutcomeSplitterLiveness.t.sol | 98 +++++++++- 6 files changed, 418 insertions(+), 70 deletions(-) create mode 100644 test/OutcomeSplitterDeploymentScripts.t.sol diff --git a/README.md b/README.md index 55be544..394e6c3 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ forge coverage --skip script --exclude-tests --no-match-coverage 'script/' --rep Before pushing to `develop`, satisfy [docs/release-readiness.md](docs/release-readiness.md): `develop` must always be releasable, and the release coordinator only checks that CI passed on the exact commit. -## Testnet deployment inputs +## Deployment inputs Deploy and finalize the fresh standard-order reputation resolver with `DeployReputationStorage.s.sol`. Deploy the shared factory with @@ -62,8 +62,15 @@ Deploy and finalize the fresh standard-order reputation resolver with with `DeployOutcomeSplitter.s.sol`. Validate and write the public artifact with `WriteOutcomeSplitterManifest.s.sol`. +The splitter scripts run on Base and Base Sepolia and refuse every other chain. +They bind each splitter to the executing chain and to the reviewed canonical +Circle USDC address for that chain, and the activation gate refuses any other +token. The commands are the same on both chains; supply the RPC endpoints of +the chain being deployed to. + `WriteOutcomeSplitterManifest.s.sol` is the sole activation gate and must run -against a Base Sepolia fork pinned to the claimed activation block: +against a fork of that chain pinned to the claimed activation block (Base +Sepolia shown): ```bash export STANDARD_RAIL_PRIMARY_RPC_URL="$BASE_SEPOLIA_RPC_URL" @@ -122,13 +129,13 @@ construction, fee-on-transfer behavior, partial release, and reentrancy. The factory applies the same deployability checks before returning a predicted CREATE2 address. -Base Sepolia release tooling requires Circle's canonical USDC address to contain -token code and report six decimals. It refuses to activate a route while USDC is -paused or the splitter or either recipient is blacklisted. Circle's pause and -blacklist controls can still stop an existing immutable route; recipients cannot -be rotated and the splitter has no rescue path. Direct native-currency transfers -revert, while EVM-forced native currency remains outside token accounting and -cannot be withdrawn. +Release tooling requires Circle's canonical USDC address for the executing chain +to contain token code and report six decimals. It refuses to activate a route +while USDC is paused or the splitter or either recipient is blacklisted. +Circle's pause and blacklist controls can still stop an existing immutable +route; recipients cannot be rotated and the splitter has no rescue path. Direct +native-currency transfers revert, while EVM-forced native currency remains +outside token accounting and cannot be withdrawn. ## License diff --git a/script/DeployOutcomeSplitter.s.sol b/script/DeployOutcomeSplitter.s.sol index b1415ab..01dd546 100644 --- a/script/DeployOutcomeSplitter.s.sol +++ b/script/DeployOutcomeSplitter.s.sol @@ -4,70 +4,110 @@ pragma solidity ^0.8.24; import {OutcomeSplitterFactory} from "../src/OutcomeSplitterFactory.sol"; import {OutcomeSplitterCreate2} from "../src/utils/OutcomeSplitterCreate2.sol"; import {OutcomeSplitterScriptBase} from "./OutcomeSplitterScriptBase.sol"; +import {StandardRailCircleUSDC} from "./StandardRailCircleUSDC.sol"; -/// @notice Deploys one reviewed outcome splitter through the shared factory. +/// @notice Deploys one reviewed outcome splitter through the shared factory, +/// bound to the executing chain and that chain's reviewed Circle USDC. contract DeployOutcomeSplitter is OutcomeSplitterScriptBase { - uint256 private constant BASE_SEPOLIA_CHAIN_ID = 84532; - address private constant BASE_SEPOLIA_USDC = 0x036CbD53842c5426634e7929541eC2318f3dCF7e; + /// @dev Carried in memory instead of stack locals so the script also + /// compiles without the IR pipeline, as coverage builds do. + struct SplitterInput { + OutcomeSplitterFactory factory; + address provider; + address daski; + uint256 commissionBps; + bytes32 policyHash; + bytes32 outcomeHash; + bytes32 listingHash; + uint256 listingEpoch; + bytes32 salt; + bytes32 factoryRuntimeCodeHash; + bytes32 creationCodeHash; + bytes32 initCodeHash; + } function run() external returns (address splitter) { - OutcomeSplitterFactory factory = OutcomeSplitterFactory(vm.envAddress("STANDARD_RAIL_SPLITTER_FACTORY")); - address provider = vm.envAddress("STANDARD_RAIL_PROVIDER_PAYEE"); - address daski = vm.envAddress("STANDARD_RAIL_DASKI_COMMISSION_RECEIVER"); - uint256 commissionBpsRaw = vm.envOr("MARKETPLACE_COMMISSION_BPS", uint256(500)); - bytes32 policyHash = vm.envBytes32("STANDARD_RAIL_POLICY_VERSION_HASH"); - bytes32 outcomeHash = vm.envBytes32("STANDARD_RAIL_OUTCOME_ID_HASH"); - bytes32 listingHash = vm.envBytes32("STANDARD_RAIL_LISTING_COMMITMENT_HASH"); - uint256 listingEpochRaw = vm.envUint("STANDARD_RAIL_LISTING_EPOCH"); - bytes32 salt = vm.envBytes32("STANDARD_RAIL_DEPLOYMENT_SALT"); - bytes32 reviewedFactoryHash = _reviewedFactoryRuntimeCodeHash(); - bytes32 reviewedCreationCodeHash = vm.envBytes32("STANDARD_RAIL_SPLITTER_CREATION_CODE_HASH"); - bytes32 reviewedInitCodeHash = vm.envBytes32("STANDARD_RAIL_SPLITTER_INIT_CODE_HASH"); + splitter = _deploy(_readInput()); + } - require(block.chainid == BASE_SEPOLIA_CHAIN_ID, "standard Testnet rail is Base Sepolia only"); - require(commissionBpsRaw > 0 && commissionBpsRaw < 10_000, "invalid commission bps"); - require(listingEpochRaw > 0 && listingEpochRaw <= type(uint64).max, "invalid listing epoch"); + function _readInput() internal view returns (SplitterInput memory input) { + input.factory = OutcomeSplitterFactory(vm.envAddress("STANDARD_RAIL_SPLITTER_FACTORY")); + input.provider = vm.envAddress("STANDARD_RAIL_PROVIDER_PAYEE"); + input.daski = vm.envAddress("STANDARD_RAIL_DASKI_COMMISSION_RECEIVER"); + input.commissionBps = vm.envOr("MARKETPLACE_COMMISSION_BPS", uint256(500)); + input.policyHash = vm.envBytes32("STANDARD_RAIL_POLICY_VERSION_HASH"); + input.outcomeHash = vm.envBytes32("STANDARD_RAIL_OUTCOME_ID_HASH"); + input.listingHash = vm.envBytes32("STANDARD_RAIL_LISTING_COMMITMENT_HASH"); + input.listingEpoch = vm.envUint("STANDARD_RAIL_LISTING_EPOCH"); + input.salt = vm.envBytes32("STANDARD_RAIL_DEPLOYMENT_SALT"); + input.factoryRuntimeCodeHash = _reviewedFactoryRuntimeCodeHash(); + input.creationCodeHash = vm.envBytes32("STANDARD_RAIL_SPLITTER_CREATION_CODE_HASH"); + input.initCodeHash = vm.envBytes32("STANDARD_RAIL_SPLITTER_INIT_CODE_HASH"); + } + + function _deploy(SplitterInput memory input) internal returns (address splitter) { + address canonicalToken = StandardRailCircleUSDC.canonicalToken(block.chainid); + require(input.commissionBps > 0 && input.commissionBps < 10_000, "invalid commission bps"); + require(input.listingEpoch > 0 && input.listingEpoch <= type(uint64).max, "invalid listing epoch"); // Both values are range-checked immediately above before narrowing. // forge-lint: disable-next-line(unsafe-typecast) - uint16 commissionBps = uint16(commissionBpsRaw); + uint16 commissionBps = uint16(input.commissionBps); // forge-lint: disable-next-line(unsafe-typecast) - uint64 listingEpoch = uint64(listingEpochRaw); + uint64 listingEpoch = uint64(input.listingEpoch); - _validateReviewedFactory(address(factory), reviewedFactoryHash); + _validateReviewedFactory(address(input.factory), input.factoryRuntimeCodeHash); require( - OutcomeSplitterCreate2.creationCodeHash() == reviewedCreationCodeHash, - "splitter creation code hash mismatch" + OutcomeSplitterCreate2.creationCodeHash() == input.creationCodeHash, "splitter creation code hash mismatch" ); - bytes32 localInitCodeHash = OutcomeSplitterCreate2.initCodeHash( - BASE_SEPOLIA_CHAIN_ID, - BASE_SEPOLIA_USDC, - provider, - daski, - commissionBps, - policyHash, - outcomeHash, - listingHash, - listingEpoch - ); - require(localInitCodeHash == reviewedInitCodeHash, "splitter init code hash mismatch"); - address predicted = OutcomeSplitterCreate2.computeAddress(address(factory), salt, localInitCodeHash); + bytes32 localInitCodeHash = _initCodeHash(input, canonicalToken, commissionBps, listingEpoch); + require(localInitCodeHash == input.initCodeHash, "splitter init code hash mismatch"); + address predicted = OutcomeSplitterCreate2.computeAddress(address(input.factory), input.salt, localInitCodeHash); vm.startBroadcast(); - splitter = factory.deploy( - salt, - BASE_SEPOLIA_CHAIN_ID, - BASE_SEPOLIA_USDC, - provider, - daski, - commissionBps, - policyHash, - outcomeHash, - listingHash, - listingEpoch - ); + splitter = _deployThroughFactory(input, canonicalToken, commissionBps, listingEpoch); vm.stopBroadcast(); require(splitter == predicted, "splitter address mismatch"); require(splitter.code.length != 0, "splitter deployment missing code"); } + + function _initCodeHash( + SplitterInput memory input, + address canonicalToken, + uint16 commissionBps, + uint64 listingEpoch + ) private view returns (bytes32) { + return OutcomeSplitterCreate2.initCodeHash( + block.chainid, + canonicalToken, + input.provider, + input.daski, + commissionBps, + input.policyHash, + input.outcomeHash, + input.listingHash, + listingEpoch + ); + } + + function _deployThroughFactory( + SplitterInput memory input, + address canonicalToken, + uint16 commissionBps, + uint64 listingEpoch + ) private returns (address) { + return input.factory + .deploy( + input.salt, + block.chainid, + canonicalToken, + input.provider, + input.daski, + commissionBps, + input.policyHash, + input.outcomeHash, + input.listingHash, + listingEpoch + ); + } } diff --git a/script/DeployOutcomeSplitterFactory.s.sol b/script/DeployOutcomeSplitterFactory.s.sol index 90fd854..61d5b9f 100644 --- a/script/DeployOutcomeSplitterFactory.s.sol +++ b/script/DeployOutcomeSplitterFactory.s.sol @@ -3,13 +3,12 @@ pragma solidity ^0.8.24; import {OutcomeSplitterFactory} from "../src/OutcomeSplitterFactory.sol"; import {OutcomeSplitterScriptBase} from "./OutcomeSplitterScriptBase.sol"; +import {StandardRailCircleUSDC} from "./StandardRailCircleUSDC.sol"; /// @notice Deploys the one permissionless factory used by all standard-rail outcomes. contract DeployOutcomeSplitterFactory is OutcomeSplitterScriptBase { - uint256 private constant BASE_SEPOLIA_CHAIN_ID = 84532; - function run() external returns (OutcomeSplitterFactory factory) { - require(block.chainid == BASE_SEPOLIA_CHAIN_ID, "standard Testnet rail is Base Sepolia only"); + StandardRailCircleUSDC.requireSupportedChain(block.chainid); bytes32 reviewedHash = _reviewedFactoryRuntimeCodeHash(); vm.startBroadcast(); factory = new OutcomeSplitterFactory(); diff --git a/script/StandardRailCircleUSDC.sol b/script/StandardRailCircleUSDC.sol index d03917f..218e8f1 100644 --- a/script/StandardRailCircleUSDC.sol +++ b/script/StandardRailCircleUSDC.sol @@ -7,14 +7,29 @@ interface ICircleUSDC { function isBlacklisted(address account) external view returns (bool); } -/// @notice Activation-readiness checks for canonical Base Sepolia USDC. +/// @notice Reviewed canonical Circle USDC for the two standard-rail chains, +/// Base and Base Sepolia, and the activation-readiness checks for it. library StandardRailCircleUSDC { + uint256 internal constant BASE_CHAIN_ID = 8_453; uint256 internal constant BASE_SEPOLIA_CHAIN_ID = 84_532; + address internal constant BASE_USDC = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913; address internal constant BASE_SEPOLIA_USDC = 0x036CbD53842c5426634e7929541eC2318f3dCF7e; + function requireSupportedChain(uint256 chainId) internal pure { + require( + chainId == BASE_CHAIN_ID || chainId == BASE_SEPOLIA_CHAIN_ID, + "standard rail supports Base and Base Sepolia only" + ); + } + + /// @notice The only token the standard rail accepts on `chainId`. + function canonicalToken(uint256 chainId) internal pure returns (address) { + requireSupportedChain(chainId); + return chainId == BASE_CHAIN_ID ? BASE_USDC : BASE_SEPOLIA_USDC; + } + function validate(address token, address splitter, address provider, address daski) internal view { - require(block.chainid == BASE_SEPOLIA_CHAIN_ID, "standard Testnet rail is Base Sepolia only"); - require(token == BASE_SEPOLIA_USDC, "canonical token address mismatch"); + require(token == canonicalToken(block.chainid), "canonical token address mismatch"); require(token.code.length != 0, "canonical token has no code"); ICircleUSDC usdc = ICircleUSDC(token); diff --git a/test/OutcomeSplitterDeploymentScripts.t.sol b/test/OutcomeSplitterDeploymentScripts.t.sol new file mode 100644 index 0000000..bf48d1e --- /dev/null +++ b/test/OutcomeSplitterDeploymentScripts.t.sol @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Test} from "forge-std/Test.sol"; +import {OutcomeSplitter} from "../src/OutcomeSplitter.sol"; +import {OutcomeSplitterFactory} from "../src/OutcomeSplitterFactory.sol"; +import {OutcomeSplitterCreate2} from "../src/utils/OutcomeSplitterCreate2.sol"; +import {DeployOutcomeSplitter} from "../script/DeployOutcomeSplitter.s.sol"; +import {DeployOutcomeSplitterFactory} from "../script/DeployOutcomeSplitterFactory.s.sol"; +import {MockCircleUSDC} from "./mocks/MockCircleUSDC.sol"; + +contract DeployOutcomeSplitterHarness is DeployOutcomeSplitter { + function deploy(SplitterInput memory input) external returns (address) { + return _deploy(input); + } +} + +contract OutcomeSplitterDeploymentScriptsTest is Test { + uint256 private constant BASE_CHAIN_ID = 8_453; + uint256 private constant BASE_SEPOLIA_CHAIN_ID = 84_532; + address private constant BASE_USDC = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913; + address private constant BASE_SEPOLIA_USDC = 0x036CbD53842c5426634e7929541eC2318f3dCF7e; + bytes private constant UNSUPPORTED_CHAIN = "standard rail supports Base and Base Sepolia only"; + string private constant FACTORY_RUNTIME_CODE_HASH_ENV = "STANDARD_RAIL_SPLITTER_FACTORY_RUNTIME_CODE_HASH"; + + DeployOutcomeSplitterFactory private factoryScript; + DeployOutcomeSplitterHarness private splitterScript; + OutcomeSplitterFactory private factory; + + address private provider = makeAddr("provider"); + address private daski = makeAddr("daski"); + bytes32 private policyHash = keccak256("policy"); + bytes32 private outcomeHash = keccak256("outcome"); + bytes32 private listingHash = keccak256("listing"); + bytes32 private salt = keccak256("salt"); + + function setUp() public { + factoryScript = new DeployOutcomeSplitterFactory(); + splitterScript = new DeployOutcomeSplitterHarness(); + factory = new OutcomeSplitterFactory(); + + MockCircleUSDC tokenCodeSource = new MockCircleUSDC(); + vm.etch(BASE_USDC, address(tokenCodeSource).code); + vm.etch(BASE_SEPOLIA_USDC, address(tokenCodeSource).code); + // Every test that reads this variable sets the same build-derived value. + _setEnv(FACTORY_RUNTIME_CODE_HASH_ENV, vm.toString(factoryScript.compiledFactoryRuntimeCodeHash())); + } + + function testFactoryScriptDeploysReviewedFactoryOnBothChainsOnly() public { + bytes32 reviewedHash = factoryScript.compiledFactoryRuntimeCodeHash(); + + vm.chainId(BASE_SEPOLIA_CHAIN_ID); + assertEq(address(factoryScript.run()).codehash, reviewedHash); + + vm.chainId(BASE_CHAIN_ID); + assertEq(address(factoryScript.run()).codehash, reviewedHash); + + uint256[3] memory unsupported = [uint256(1), 31_337, 11_155_111]; + for (uint256 i = 0; i < unsupported.length; i++) { + vm.chainId(unsupported[i]); + vm.expectRevert(UNSUPPORTED_CHAIN); + factoryScript.run(); + } + } + + function testSplitterScriptBindsBaseSepoliaAndItsReviewedToken() public { + _checkSplitterBoundToChainAndReviewedToken(BASE_SEPOLIA_CHAIN_ID, BASE_SEPOLIA_USDC); + } + + function testSplitterScriptBindsBaseAndItsReviewedToken() public { + _checkSplitterBoundToChainAndReviewedToken(BASE_CHAIN_ID, BASE_USDC); + } + + function testSplitterScriptRefusesUnsupportedChain() public { + uint256[3] memory unsupported = [uint256(1), 31_337, 11_155_111]; + for (uint256 i = 0; i < unsupported.length; i++) { + vm.chainId(unsupported[i]); + DeployOutcomeSplitter.SplitterInput memory input = _input(unsupported[i], BASE_USDC); + vm.expectRevert(UNSUPPORTED_CHAIN); + splitterScript.deploy(input); + } + } + + function testSplitterScriptRefusesReviewForAnotherChainOrToken() public { + vm.chainId(BASE_CHAIN_ID); + + // Reviewed for Base Sepolia, executed on Base. + DeployOutcomeSplitter.SplitterInput memory input = _input(BASE_SEPOLIA_CHAIN_ID, BASE_SEPOLIA_USDC); + vm.expectRevert(bytes("splitter init code hash mismatch")); + splitterScript.deploy(input); + + // Reviewed for the executing chain, but with a token other than its reviewed one. + address[2] memory wrongTokens = [BASE_SEPOLIA_USDC, address(new MockCircleUSDC())]; + for (uint256 i = 0; i < wrongTokens.length; i++) { + input = _input(BASE_CHAIN_ID, wrongTokens[i]); + vm.expectRevert(bytes("splitter init code hash mismatch")); + splitterScript.deploy(input); + } + + vm.chainId(BASE_SEPOLIA_CHAIN_ID); + input = _input(BASE_SEPOLIA_CHAIN_ID, BASE_USDC); + vm.expectRevert(bytes("splitter init code hash mismatch")); + splitterScript.deploy(input); + } + + function testSplitterScriptKeepsInputRangeAndProvenanceChecks() public { + vm.chainId(BASE_CHAIN_ID); + + DeployOutcomeSplitter.SplitterInput memory input = _input(BASE_CHAIN_ID, BASE_USDC); + input.commissionBps = 10_000; + vm.expectRevert(bytes("invalid commission bps")); + splitterScript.deploy(input); + + input = _input(BASE_CHAIN_ID, BASE_USDC); + input.listingEpoch = uint256(type(uint64).max) + 1; + vm.expectRevert(bytes("invalid listing epoch")); + splitterScript.deploy(input); + + input = _input(BASE_CHAIN_ID, BASE_USDC); + input.factory = OutcomeSplitterFactory(address(new MockCircleUSDC())); + vm.expectRevert(bytes("factory runtime code hash mismatch")); + splitterScript.deploy(input); + + input = _input(BASE_CHAIN_ID, BASE_USDC); + input.creationCodeHash = bytes32(uint256(input.creationCodeHash) ^ 1); + vm.expectRevert(bytes("splitter creation code hash mismatch")); + splitterScript.deploy(input); + } + + function testSplitterScriptRunReadsReviewedInputsFromEnvironment() public { + vm.chainId(BASE_CHAIN_ID); + DeployOutcomeSplitter.SplitterInput memory input = _input(BASE_CHAIN_ID, BASE_USDC); + // No other test reads or writes these variables, so parallel tests cannot interfere. + _setEnv("STANDARD_RAIL_SPLITTER_FACTORY", vm.toString(address(input.factory))); + _setEnv("STANDARD_RAIL_PROVIDER_PAYEE", vm.toString(input.provider)); + _setEnv("STANDARD_RAIL_DASKI_COMMISSION_RECEIVER", vm.toString(input.daski)); + _setEnv("MARKETPLACE_COMMISSION_BPS", vm.toString(input.commissionBps)); + _setEnv("STANDARD_RAIL_POLICY_VERSION_HASH", vm.toString(input.policyHash)); + _setEnv("STANDARD_RAIL_OUTCOME_ID_HASH", vm.toString(input.outcomeHash)); + _setEnv("STANDARD_RAIL_LISTING_COMMITMENT_HASH", vm.toString(input.listingHash)); + _setEnv("STANDARD_RAIL_LISTING_EPOCH", vm.toString(input.listingEpoch)); + _setEnv("STANDARD_RAIL_DEPLOYMENT_SALT", vm.toString(input.salt)); + _setEnv("STANDARD_RAIL_SPLITTER_CREATION_CODE_HASH", vm.toString(input.creationCodeHash)); + _setEnv("STANDARD_RAIL_SPLITTER_INIT_CODE_HASH", vm.toString(input.initCodeHash)); + + OutcomeSplitter splitter = OutcomeSplitter(payable(splitterScript.run())); + + assertEq(splitter.canonicalChainId(), BASE_CHAIN_ID); + assertEq(splitter.canonicalToken(), BASE_USDC); + assertEq(splitter.providerPayee(), provider); + assertEq(splitter.daskiCommissionReceiver(), daski); + } + + function _checkSplitterBoundToChainAndReviewedToken(uint256 chainId, address reviewedToken) private { + vm.chainId(chainId); + DeployOutcomeSplitter.SplitterInput memory input = _input(chainId, reviewedToken); + + OutcomeSplitter splitter = OutcomeSplitter(payable(splitterScript.deploy(input))); + + assertEq( + address(splitter), + factory.computeAddress( + salt, chainId, reviewedToken, provider, daski, 500, policyHash, outcomeHash, listingHash, 1 + ) + ); + assertEq(splitter.canonicalChainId(), chainId); + assertEq(splitter.canonicalToken(), reviewedToken); + assertEq(splitter.providerPayee(), provider); + assertEq(splitter.daskiCommissionReceiver(), daski); + assertEq(splitter.commissionBps(), 500); + assertEq(splitter.policyVersionHash(), policyHash); + assertEq(splitter.outcomeIdHash(), outcomeHash); + assertEq(splitter.listingCommitmentHash(), listingHash); + assertEq(splitter.listingEpoch(), 1); + } + + function _setEnv(string memory name, string memory value) private { + // forge-lint: disable-next-line(unsafe-cheatcode) + vm.setEnv(name, value); + } + + /// @dev Inputs as a reviewer would prepare them for `reviewedChainId` and `reviewedToken`. + function _input(uint256 reviewedChainId, address reviewedToken) + private + view + returns (DeployOutcomeSplitter.SplitterInput memory input) + { + input.factory = factory; + input.provider = provider; + input.daski = daski; + input.commissionBps = 500; + input.policyHash = policyHash; + input.outcomeHash = outcomeHash; + input.listingHash = listingHash; + input.listingEpoch = 1; + input.salt = salt; + input.factoryRuntimeCodeHash = factoryScript.compiledFactoryRuntimeCodeHash(); + input.creationCodeHash = OutcomeSplitterCreate2.creationCodeHash(); + input.initCodeHash = OutcomeSplitterCreate2.initCodeHash( + reviewedChainId, reviewedToken, provider, daski, 500, policyHash, outcomeHash, listingHash, 1 + ); + } +} diff --git a/test/OutcomeSplitterLiveness.t.sol b/test/OutcomeSplitterLiveness.t.sol index 486899c..33475c1 100644 --- a/test/OutcomeSplitterLiveness.t.sol +++ b/test/OutcomeSplitterLiveness.t.sol @@ -7,6 +7,14 @@ import {StandardRailCircleUSDC} from "../script/StandardRailCircleUSDC.sol"; import {MockCircleUSDC} from "./mocks/MockCircleUSDC.sol"; contract StandardRailCircleUSDCHarness { + function requireSupportedChain(uint256 chainId) external pure { + StandardRailCircleUSDC.requireSupportedChain(chainId); + } + + function canonicalToken(uint256 chainId) external pure returns (address) { + return StandardRailCircleUSDC.canonicalToken(chainId); + } + function validate(address token, address splitter, address provider, address daski) external view { StandardRailCircleUSDC.validate(token, splitter, provider, daski); } @@ -19,7 +27,11 @@ contract WrongDecimalsCircleUSDC is MockCircleUSDC { } contract OutcomeSplitterLivenessTest is Test { + uint256 private constant BASE_CHAIN_ID = 8_453; + uint256 private constant BASE_SEPOLIA_CHAIN_ID = 84_532; + address private constant BASE_USDC = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913; address private constant BASE_SEPOLIA_USDC = 0x036CbD53842c5426634e7929541eC2318f3dCF7e; + bytes private constant UNSUPPORTED_CHAIN = "standard rail supports Base and Base Sepolia only"; MockCircleUSDC private token; MockCircleUSDC private tokenCodeSource; @@ -28,21 +40,84 @@ contract OutcomeSplitterLivenessTest is Test { address private daski = makeAddr("daski"); function setUp() public { - vm.chainId(84_532); tokenCodeSource = new MockCircleUSDC(); - vm.etch(BASE_SEPOLIA_USDC, address(tokenCodeSource).code); - token = MockCircleUSDC(BASE_SEPOLIA_USDC); circleHarness = new StandardRailCircleUSDCHarness(); + _selectChain(BASE_SEPOLIA_CHAIN_ID); + } + + function testReviewedTokenIsFixedPerSupportedChain() public { + assertEq(circleHarness.canonicalToken(BASE_CHAIN_ID), BASE_USDC); + assertEq(circleHarness.canonicalToken(BASE_SEPOLIA_CHAIN_ID), BASE_SEPOLIA_USDC); + circleHarness.requireSupportedChain(BASE_CHAIN_ID); + circleHarness.requireSupportedChain(BASE_SEPOLIA_CHAIN_ID); + + uint256[4] memory unsupported = [uint256(0), 1, 31_337, 11_155_111]; + for (uint256 i = 0; i < unsupported.length; i++) { + vm.expectRevert(UNSUPPORTED_CHAIN); + circleHarness.requireSupportedChain(unsupported[i]); + vm.expectRevert(UNSUPPORTED_CHAIN); + circleHarness.canonicalToken(unsupported[i]); + } + } + + function testCanonicalTokenAddressCodeAndDecimalsChecksOnBaseSepolia() public { + _checkCanonicalTokenAddressCodeAndDecimals(BASE_SEPOLIA_CHAIN_ID); + } + + function testCanonicalTokenAddressCodeAndDecimalsChecksOnBase() public { + _checkCanonicalTokenAddressCodeAndDecimals(BASE_CHAIN_ID); + } + + function testReviewedTokenOfTheOtherChainIsRefused() public { + address splitter = makeAddr("splitter"); + vm.etch(BASE_USDC, address(tokenCodeSource).code); + vm.etch(BASE_SEPOLIA_USDC, address(tokenCodeSource).code); + + vm.chainId(BASE_CHAIN_ID); + circleHarness.validate(BASE_USDC, splitter, provider, daski); + vm.expectRevert(bytes("canonical token address mismatch")); + circleHarness.validate(BASE_SEPOLIA_USDC, splitter, provider, daski); + + vm.chainId(BASE_SEPOLIA_CHAIN_ID); + circleHarness.validate(BASE_SEPOLIA_USDC, splitter, provider, daski); + vm.expectRevert(bytes("canonical token address mismatch")); + circleHarness.validate(BASE_USDC, splitter, provider, daski); } - function testCanonicalTokenAddressCodeAndDecimalsChecks() public { + function testUnsupportedChainIsRefusedForEveryToken() public { + address splitter = makeAddr("splitter"); + vm.etch(BASE_USDC, address(tokenCodeSource).code); + vm.etch(BASE_SEPOLIA_USDC, address(tokenCodeSource).code); + + uint256[3] memory unsupported = [uint256(1), 31_337, 11_155_111]; + for (uint256 i = 0; i < unsupported.length; i++) { + vm.chainId(unsupported[i]); + vm.expectRevert(UNSUPPORTED_CHAIN); + circleHarness.validate(BASE_USDC, splitter, provider, daski); + vm.expectRevert(UNSUPPORTED_CHAIN); + circleHarness.validate(BASE_SEPOLIA_USDC, splitter, provider, daski); + vm.expectRevert(UNSUPPORTED_CHAIN); + circleHarness.validate(address(tokenCodeSource), splitter, provider, daski); + } + } + + function testCircleReadinessRejectsPauseAndBlacklistsOnBaseSepolia() public { + _checkCircleReadinessRejectsPauseAndBlacklists(BASE_SEPOLIA_CHAIN_ID); + } + + function testCircleReadinessRejectsPauseAndBlacklistsOnBase() public { + _checkCircleReadinessRejectsPauseAndBlacklists(BASE_CHAIN_ID); + } + + function _checkCanonicalTokenAddressCodeAndDecimals(uint256 chainId) private { + _selectChain(chainId); address splitter = makeAddr("splitter"); circleHarness.validate(address(token), splitter, provider, daski); vm.chainId(1); - vm.expectRevert(bytes("standard Testnet rail is Base Sepolia only")); + vm.expectRevert(UNSUPPORTED_CHAIN); circleHarness.validate(address(token), splitter, provider, daski); - vm.chainId(84_532); + vm.chainId(chainId); vm.expectRevert(bytes("canonical token address mismatch")); circleHarness.validate(address(tokenCodeSource), splitter, provider, daski); @@ -57,7 +132,8 @@ contract OutcomeSplitterLivenessTest is Test { circleHarness.validate(address(token), splitter, provider, daski); } - function testCircleReadinessRejectsPauseAndBlacklists() public { + function _checkCircleReadinessRejectsPauseAndBlacklists(uint256 chainId) private { + _selectChain(chainId); address splitter = makeAddr("splitter"); token.setPaused(true); vm.expectRevert(bytes("canonical token is paused")); @@ -127,6 +203,14 @@ contract OutcomeSplitterLivenessTest is Test { assertEq(token.balanceOf(daski), 0); } + /// @dev Installs the Circle-style test token at the reviewed address of `chainId`. + function _selectChain(uint256 chainId) private { + vm.chainId(chainId); + address reviewed = chainId == BASE_CHAIN_ID ? BASE_USDC : BASE_SEPOLIA_USDC; + vm.etch(reviewed, address(tokenCodeSource).code); + token = MockCircleUSDC(reviewed); + } + function _deploySplitter() private returns (OutcomeSplitter) { return new OutcomeSplitter( block.chainid, From fccee52646a2707f84143d943c32c9cf44fcf823 Mon Sep 17 00:00:00 2001 From: pawmmm Date: Thu, 17 Sep 2026 12:12:19 -0600 Subject: [PATCH 3/4] Add a genesis script and a read-only verifier for the marketplace registries The genesis scripts for AgentIndex, ValidationRegistry, ProviderRegistry and ServiceRegistry were removed on 2026-08-12 in 3372e00 together with the retired payment stack, so the registries could not be deployed to a new chain through reviewed scripts. DeployMarketplaceRegistries recovers the registry part of the old Deploy.s.sol and nothing of the payment stack: no router, adapters, mock token, mock sanctions list, facilitator, payment treasury or commission. It runs on Base and Base Sepolia only and deploys the four registries as ERC-1967 UUPS proxies with the initializer arguments the current contracts take. The identity registry, sanctions oracle, treasury and listing fee are environment inputs; the listing-fee token is the reviewed Circle USDC of the executing chain. The final admin must pass the existing ReputationSafeValidation rules, so an externally owned account or a 1-of-1 Safe is refused on both chains. The broadcaster comes from vm.startBroadcast() without a key, so a hardware wallet or keystore can sign and no script reads a private key. It is only the bootstrap admin: each proxy is paused, given its guardian and offered to the Safe right after it is created, and the script ends, like DeployReputationStorage, with paused proxies whose pending admin is the Safe and a readiness check of that state. Only the Safe can accept and then unpause; after acceptance the broadcaster holds no role. VerifyMarketplaceRegistries is a view-only check for the state after the Safe has accepted: code and an ERC-1967 implementation behind each proxy, the expected registry type at each address, one identity registry and sanctions oracle, the reviewed token, ServiceRegistry wired to the given ProviderRegistry, the reviewed Safe as admin and no pending admin. DeploySafe and ExecuteGovernanceBatches are not restored; neither script needs them. Tests deploy on the local EVM under both chain ids with a Safe stub that satisfies the validation, cover the refusals, show that the broadcaster keeps no authority once the Safe has accepted, and run the verifier against a fresh and a tampered deployment. The genesis script was also simulated, and the verifier run, against a Base Sepolia fork without sending a transaction. Deployment tooling, tests and documentation only. No contract, ABI or deployment change: history-compatible, no new epoch. --- README.md | 31 +- docs/release-readiness.md | 4 +- script/DeployMarketplaceRegistries.s.sol | 207 ++++++ script/MarketplaceRegistryValidation.sol | 161 +++++ script/VerifyMarketplaceRegistries.s.sol | 74 ++ test/DeployMarketplaceRegistries.t.sol | 680 ++++++++++++++++++ test/helpers/MarketplaceDeploymentHarness.sol | 64 ++ 7 files changed, 1217 insertions(+), 4 deletions(-) create mode 100644 script/DeployMarketplaceRegistries.s.sol create mode 100644 script/MarketplaceRegistryValidation.sol create mode 100644 script/VerifyMarketplaceRegistries.s.sol create mode 100644 test/DeployMarketplaceRegistries.t.sol create mode 100644 test/helpers/MarketplaceDeploymentHarness.sol diff --git a/README.md b/README.md index 394e6c3..d7618e4 100644 --- a/README.md +++ b/README.md @@ -117,8 +117,35 @@ and pause the resolver immediately if signer integrity is in doubt. The deployment scripts default `MARKETPLACE_COMMISSION_BPS` to 500. A later fee change is represented by a new immutable splitter and listing epoch. -The scripts require the `STANDARD_RAIL_*` values named in their source. They -are deployment tooling only; running tests or pushing this repository does not +Deploy the four marketplace registries with `DeployMarketplaceRegistries.s.sol`, +on Base or Base Sepolia only. It deploys AgentIndex, ValidationRegistry, +ProviderRegistry and ServiceRegistry as ERC-1967 UUPS proxies against +`IDENTITY_REGISTRY_ADDRESS` and `SANCTIONS_ORACLE_ADDRESS`, with the reviewed +canonical Circle USDC of the executing chain as the listing-fee token, +`PROVIDER_REGISTRY_TREASURY` as the fee recipient and +`PROVIDER_REGISTRY_LISTING_FEE` in atomic units. None of the identity registry, +sanctions oracle and token has a setter, so review those addresses +independently before deploying. +`MARKETPLACE_REGISTRIES_FINAL_ADMIN` must satisfy the same Safe rules as the +reputation deployment, and `MARKETPLACE_REGISTRIES_PAUSE_GUARDIAN` must be a +nonzero address distinct from the Safe and the broadcaster. The broadcaster +comes from the standard Foundry wallet options; the script reads no private +key. It is only the bootstrap admin: the script pauses each registry, sets the +guardian and proposes the Safe, so it ends with four paused proxies whose +pending admin is the Safe. The Safe must accept administration before it can +unpause a registry, and after acceptance the broadcaster holds no role. + +After the Safe has accepted, `VerifyMarketplaceRegistries.s.sol` checks the +deployment without sending anything. Given the four proxy addresses, the +identity registry, the sanctions oracle and the Safe, it requires code and an +ERC-1967 implementation behind every proxy, the expected registry type at each +address, one shared identity registry and sanctions oracle, the reviewed USDC +as listing-fee token, ServiceRegistry pointing at the given ProviderRegistry, +and the Safe as admin with no pending admin. It returns the implementation +addresses for the release record. + +The scripts require the environment values named in their source. They are +deployment tooling only; running tests or pushing this repository does not deploy contracts. ## Security diff --git a/docs/release-readiness.md b/docs/release-readiness.md index 9b8c977..2d06752 100644 --- a/docs/release-readiness.md +++ b/docs/release-readiness.md @@ -27,8 +27,8 @@ A change may be pushed to `develop` only when all of the following hold. their own ABI fragments; they do not import this repository's build output. - [ ] Deployment scripts under `script/` remain runnable against a fork - (`forge script ... --fork-url ...` with the `STANDARD_RAIL_*` inputs - named in their source). + (`forge script ... --fork-url ...` with the environment inputs named in + their source). - [ ] Nothing is merged to `sandbox` or `main`, or tagged, by hand. The release coordinator writes `sandbox`, the testnet release branch, through the `develop` to `sandbox` release pull request; the production coordinator diff --git a/script/DeployMarketplaceRegistries.s.sol b/script/DeployMarketplaceRegistries.s.sol new file mode 100644 index 0000000..4c98513 --- /dev/null +++ b/script/DeployMarketplaceRegistries.s.sol @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {VmSafe} from "forge-std/Vm.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {AgentIndex} from "../src/AgentIndex.sol"; +import {ProviderRegistry} from "../src/ProviderRegistry.sol"; +import {ServiceRegistry} from "../src/ServiceRegistry.sol"; +import {ValidationRegistry} from "../src/ValidationRegistry.sol"; +import {Admin2StepUpgradeable} from "../src/utils/Admin2StepUpgradeable.sol"; +import {MarketplaceRegistryValidation} from "./MarketplaceRegistryValidation.sol"; +import {ReputationSafeValidation} from "./ReputationSafeValidation.sol"; +import {StandardRailCircleUSDC} from "./StandardRailCircleUSDC.sol"; + +/// @notice Genesis deployment of AgentIndex, ValidationRegistry, +/// ProviderRegistry and ServiceRegistry as ERC-1967 UUPS proxies on +/// Base or Base Sepolia. +/// +/// The broadcaster is only the bootstrap admin. The script pauses +/// every registry, sets its pause guardian and proposes the reviewed +/// Safe as admin, so it ends with four paused proxies whose pending +/// admin is the Safe. Administration ends with the Safe: it accepts +/// each proxy and only then can unpause it. The broadcaster stays +/// admin of the paused proxies until that acceptance and holds no +/// role after it. Run VerifyMarketplaceRegistries once the Safe has +/// accepted. +/// +/// The broadcaster comes from the standard Foundry wallet options +/// (hardware wallet, keystore or --sender); no key is read from the +/// environment. +/// +/// IDENTITY_REGISTRY_ADDRESS canonical ERC-8004 IdentityRegistry +/// SANCTIONS_ORACLE_ADDRESS Chainalysis-compatible sanctions oracle +/// MARKETPLACE_REGISTRIES_FINAL_ADMIN reviewed governance Safe +/// MARKETPLACE_REGISTRIES_PAUSE_GUARDIAN distinct nonzero pause guardian +/// PROVIDER_REGISTRY_TREASURY listing-fee recipient +/// PROVIDER_REGISTRY_LISTING_FEE listing fee in atomic USDC units +/// +/// The listing-fee token is the reviewed canonical Circle USDC of the +/// executing chain. +contract DeployMarketplaceRegistries is MarketplaceRegistryValidation, ReputationSafeValidation { + struct Config { + address identityRegistry; + address sanctionsOracle; + address finalAdmin; + address pauseGuardian; + address treasury; + uint256 listingFee; + } + + error DependencyHasNoCode(address dependency); + error InvalidPauseGuardian(); + error InvalidTreasury(); + error GovernanceRoleConflict(); + error BroadcasterUnavailable(); + error DeploymentNotReady(); + + function run() + external + returns (address agentIndex, address validationRegistry, address providerRegistry, address serviceRegistry) + { + Registries memory registries = _deploy(_readConfig()); + return + ( + registries.agentIndex, + registries.validationRegistry, + registries.providerRegistry, + registries.serviceRegistry + ); + } + + function _readConfig() internal view returns (Config memory config) { + config.identityRegistry = vm.envAddress("IDENTITY_REGISTRY_ADDRESS"); + config.sanctionsOracle = vm.envAddress("SANCTIONS_ORACLE_ADDRESS"); + config.finalAdmin = vm.envAddress("MARKETPLACE_REGISTRIES_FINAL_ADMIN"); + config.pauseGuardian = vm.envAddress("MARKETPLACE_REGISTRIES_PAUSE_GUARDIAN"); + config.treasury = vm.envAddress("PROVIDER_REGISTRY_TREASURY"); + config.listingFee = vm.envUint("PROVIDER_REGISTRY_LISTING_FEE"); + } + + function _deploy(Config memory config) internal returns (Registries memory registries) { + Dependencies memory dependencies = _validateConfig(config); + address deployer = _broadcaster(); + _requireSeparateDeployer(config, deployer); + + vm.startBroadcast(); + registries = _deployProxies(config, dependencies, deployer); + vm.stopBroadcast(); + + _requireHandoffReady(registries, config, dependencies, deployer); + } + + function _validateConfig(Config memory config) internal view returns (Dependencies memory dependencies) { + _requireSupportedChain(); + dependencies = Dependencies({ + identityRegistry: config.identityRegistry, + sanctionsOracle: config.sanctionsOracle, + canonicalToken: StandardRailCircleUSDC.canonicalToken(block.chainid) + }); + if (dependencies.identityRegistry.code.length == 0) revert DependencyHasNoCode(dependencies.identityRegistry); + if (dependencies.sanctionsOracle.code.length == 0) revert DependencyHasNoCode(dependencies.sanctionsOracle); + if (dependencies.canonicalToken.code.length == 0) revert DependencyHasNoCode(dependencies.canonicalToken); + + _validateSafe(config.finalAdmin); + if (config.pauseGuardian == address(0) || config.pauseGuardian == config.finalAdmin) { + revert InvalidPauseGuardian(); + } + if (config.treasury == address(0)) revert InvalidTreasury(); + } + + /// @dev The account that signs the broadcast, whichever wallet option + /// supplied it. It is read in a broadcast window of its own that sends + /// nothing, so every refusal happens before the first transaction. + function _broadcaster() private returns (address deployer) { + vm.startBroadcast(); + VmSafe.CallerMode mode; + (mode, deployer,) = vm.readCallers(); + vm.stopBroadcast(); + if (mode != VmSafe.CallerMode.RecurrentBroadcast || deployer == address(0)) revert BroadcasterUnavailable(); + } + + /// @dev The bootstrap admin keeps nothing once the Safe has accepted. + function _requireSeparateDeployer(Config memory config, address deployer) private pure { + if (deployer == config.finalAdmin || deployer == config.treasury) revert GovernanceRoleConflict(); + if (deployer == config.pauseGuardian) revert InvalidPauseGuardian(); + } + + function _deployProxies(Config memory config, Dependencies memory dependencies, address deployer) + private + returns (Registries memory registries) + { + registries.agentIndex = _deployProxy( + address(new AgentIndex()), + abi.encodeCall( + AgentIndex.initialize, (dependencies.identityRegistry, dependencies.sanctionsOracle, deployer) + ), + config + ); + registries.validationRegistry = _deployProxy( + address(new ValidationRegistry()), + abi.encodeCall( + ValidationRegistry.initialize, (dependencies.identityRegistry, dependencies.sanctionsOracle, deployer) + ), + config + ); + registries.providerRegistry = _deployProxy( + address(new ProviderRegistry()), + abi.encodeCall( + ProviderRegistry.initialize, + ( + dependencies.identityRegistry, + dependencies.canonicalToken, + config.treasury, + config.listingFee, + dependencies.sanctionsOracle, + deployer + ) + ), + config + ); + registries.serviceRegistry = _deployProxy( + address(new ServiceRegistry()), + abi.encodeCall( + ServiceRegistry.initialize, + (dependencies.identityRegistry, registries.providerRegistry, dependencies.sanctionsOracle, deployer) + ), + config + ); + } + + /// @dev Creates and initializes one proxy in a single transaction, then + /// pauses it, sets its guardian and proposes the Safe before the next + /// registry is deployed, so an interrupted run leaves at most one + /// registry unpaused. + function _deployProxy(address implementation, bytes memory initializer, Config memory config) + private + returns (address proxy) + { + proxy = address(new ERC1967Proxy(implementation, initializer)); + Admin2StepUpgradeable registry = Admin2StepUpgradeable(proxy); + registry.pauseExternalDependency(); + registry.setPauseGuardian(config.pauseGuardian); + registry.transferAdmin(config.finalAdmin); + } + + function _requireHandoffReady( + Registries memory registries, + Config memory config, + Dependencies memory dependencies, + address deployer + ) internal view { + _validateSafe(config.finalAdmin); + _requireImplementations(registries); + _requireWiring(registries, dependencies); + _requireAdministration(registries, deployer, config.finalAdmin); + + ProviderRegistry providerRegistry = ProviderRegistry(registries.providerRegistry); + bool ready = + providerRegistry.treasury() == config.treasury && providerRegistry.listingFee() == config.listingFee; + address[4] memory proxies = _list(registries); + for (uint256 i = 0; i < proxies.length; i++) { + Admin2StepUpgradeable registry = Admin2StepUpgradeable(proxies[i]); + ready = ready && registry.pauseGuardian() == config.pauseGuardian && registry.externalDependencyPaused(); + } + if (!ready) revert DeploymentNotReady(); + } +} diff --git a/script/MarketplaceRegistryValidation.sol b/script/MarketplaceRegistryValidation.sol new file mode 100644 index 0000000..1fa053f --- /dev/null +++ b/script/MarketplaceRegistryValidation.sol @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Script} from "forge-std/Script.sol"; +import {AgentIndex} from "../src/AgentIndex.sol"; +import {ProviderRegistry} from "../src/ProviderRegistry.sol"; +import {ServiceRegistry} from "../src/ServiceRegistry.sol"; +import {ValidationRegistry} from "../src/ValidationRegistry.sol"; +import {ISanctionsGuard} from "../src/interfaces/ISanctionsGuard.sol"; +import {Admin2StepUpgradeable} from "../src/utils/Admin2StepUpgradeable.sol"; + +/// @notice Read-only proxy, wiring and administration checks shared by the +/// marketplace registry genesis script and its verifier. +abstract contract MarketplaceRegistryValidation is Script { + uint256 private constant BASE_CHAIN_ID = 8_453; + uint256 private constant BASE_SEPOLIA_CHAIN_ID = 84_532; + bytes32 private constant ERC1967_IMPLEMENTATION_SLOT = + 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; + + struct Registries { + address agentIndex; + address validationRegistry; + address providerRegistry; + address serviceRegistry; + } + + struct Dependencies { + address identityRegistry; + address sanctionsOracle; + address canonicalToken; + } + + error UnsupportedRegistryChain(uint256 chainId); + error RegistryHasNoCode(address registry); + error RegistryImplementationNotSet(address registry); + error RegistryTypeMismatch(address registry); + error RegistryIdentityMismatch(address registry, address expected, address actual); + error RegistrySanctionsOracleMismatch(address registry, address expected, address actual); + error ProviderRegistryTokenMismatch(address expected, address actual); + error ServiceRegistryProviderMismatch(address expected, address actual); + error RegistryAdminMismatch(address registry, address expected, address actual); + error RegistryPendingAdminMismatch(address registry, address expected, address actual); + + function _requireSupportedChain() internal view { + if (block.chainid != BASE_CHAIN_ID && block.chainid != BASE_SEPOLIA_CHAIN_ID) { + revert UnsupportedRegistryChain(block.chainid); + } + } + + function _list(Registries memory registries) internal pure returns (address[4] memory) { + return + [ + registries.agentIndex, + registries.validationRegistry, + registries.providerRegistry, + registries.serviceRegistry + ]; + } + + /// @notice Every registry is an ERC-1967 proxy with code whose + /// implementation slot names a contract. + function _requireImplementations(Registries memory registries) + internal + view + returns (Registries memory implementations) + { + implementations.agentIndex = _requireProxy(registries.agentIndex); + implementations.validationRegistry = _requireProxy(registries.validationRegistry); + implementations.providerRegistry = _requireProxy(registries.providerRegistry); + implementations.serviceRegistry = _requireProxy(registries.serviceRegistry); + } + + /// @notice Each address is the registry it is named as, and the four share + /// one identity registry, one sanctions oracle, the expected + /// listing-fee token and the expected provider registry. + function _requireWiring(Registries memory registries, Dependencies memory expected) internal view { + _requireRegistryTypes(registries); + + _requireIdentity( + registries.agentIndex, expected.identityRegistry, AgentIndex(registries.agentIndex).getIdentityRegistry() + ); + _requireIdentity( + registries.validationRegistry, + expected.identityRegistry, + ValidationRegistry(registries.validationRegistry).getIdentityRegistry() + ); + _requireIdentity( + registries.providerRegistry, + expected.identityRegistry, + address(ProviderRegistry(registries.providerRegistry).identity()) + ); + _requireIdentity( + registries.serviceRegistry, + expected.identityRegistry, + address(ServiceRegistry(registries.serviceRegistry).identity()) + ); + + address token = address(ProviderRegistry(registries.providerRegistry).usdc()); + if (token != expected.canonicalToken) revert ProviderRegistryTokenMismatch(expected.canonicalToken, token); + address providerRegistry = address(ServiceRegistry(registries.serviceRegistry).providerRegistry()); + if (providerRegistry != registries.providerRegistry) { + revert ServiceRegistryProviderMismatch(registries.providerRegistry, providerRegistry); + } + + address[4] memory proxies = _list(registries); + for (uint256 i = 0; i < proxies.length; i++) { + address oracle = address(ISanctionsGuard(proxies[i]).sanctionsOracle()); + if (oracle != expected.sanctionsOracle) { + revert RegistrySanctionsOracleMismatch(proxies[i], expected.sanctionsOracle, oracle); + } + } + } + + function _requireAdministration(Registries memory registries, address expectedAdmin, address expectedPendingAdmin) + internal + view + { + address[4] memory proxies = _list(registries); + for (uint256 i = 0; i < proxies.length; i++) { + Admin2StepUpgradeable registry = Admin2StepUpgradeable(proxies[i]); + address admin = registry.admin(); + if (admin != expectedAdmin) revert RegistryAdminMismatch(proxies[i], expectedAdmin, admin); + address pendingAdmin = registry.pendingAdmin(); + if (pendingAdmin != expectedPendingAdmin) { + revert RegistryPendingAdminMismatch(proxies[i], expectedPendingAdmin, pendingAdmin); + } + } + } + + function _requireProxy(address proxy) private view returns (address implementation) { + if (proxy.code.length == 0) revert RegistryHasNoCode(proxy); + implementation = address(uint160(uint256(vm.load(proxy, ERC1967_IMPLEMENTATION_SLOT)))); + if (implementation.code.length == 0) revert RegistryImplementationNotSet(proxy); + } + + /// @dev One selector per registry that no other registry answers, so two + /// addresses cannot be swapped and a registry of another type cannot + /// stand in. + function _requireRegistryTypes(Registries memory registries) private view { + try AgentIndex(registries.agentIndex).registrationNonce(address(0)) returns (uint256) {} + catch { + revert RegistryTypeMismatch(registries.agentIndex); + } + try ValidationRegistry(registries.validationRegistry).computeValidationKey(0, bytes32(0)) returns (bytes32) {} + catch { + revert RegistryTypeMismatch(registries.validationRegistry); + } + try ProviderRegistry(registries.providerRegistry).listingFee() returns (uint256) {} + catch { + revert RegistryTypeMismatch(registries.providerRegistry); + } + try ServiceRegistry(registries.serviceRegistry).getServiceCountByProvider(0) returns (uint256) {} + catch { + revert RegistryTypeMismatch(registries.serviceRegistry); + } + } + + function _requireIdentity(address registry, address expected, address actual) private pure { + if (actual != expected) revert RegistryIdentityMismatch(registry, expected, actual); + } +} diff --git a/script/VerifyMarketplaceRegistries.s.sol b/script/VerifyMarketplaceRegistries.s.sol new file mode 100644 index 0000000..7136ed2 --- /dev/null +++ b/script/VerifyMarketplaceRegistries.s.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {MarketplaceRegistryValidation} from "./MarketplaceRegistryValidation.sol"; +import {ReputationSafeValidation} from "./ReputationSafeValidation.sol"; +import {StandardRailCircleUSDC} from "./StandardRailCircleUSDC.sol"; + +/// @notice Read-only check of a marketplace registry deployment after the +/// governance Safe has accepted administration. It sends nothing and +/// changes no state. +/// +/// Every proxy must have code and an ERC-1967 implementation with +/// code, be the registry it is named as, share the expected identity +/// registry and sanctions oracle, use the reviewed Circle USDC of the +/// executing chain for listing fees, and have the reviewed Safe as +/// admin with no pending admin. ServiceRegistry must point at the +/// given ProviderRegistry. The implementation addresses are returned +/// for the release record. +/// +/// AGENT_INDEX_ADDRESS, VALIDATION_REGISTRY_ADDRESS, +/// PROVIDER_REGISTRY_ADDRESS, SERVICE_REGISTRY_ADDRESS the four proxies +/// IDENTITY_REGISTRY_ADDRESS expected identity registry +/// SANCTIONS_ORACLE_ADDRESS expected sanctions oracle +/// MARKETPLACE_REGISTRIES_FINAL_ADMIN expected governance Safe +contract VerifyMarketplaceRegistries is MarketplaceRegistryValidation, ReputationSafeValidation { + function run() + external + view + returns ( + address agentIndexImplementation, + address validationRegistryImplementation, + address providerRegistryImplementation, + address serviceRegistryImplementation + ) + { + Registries memory registries = Registries({ + agentIndex: vm.envAddress("AGENT_INDEX_ADDRESS"), + validationRegistry: vm.envAddress("VALIDATION_REGISTRY_ADDRESS"), + providerRegistry: vm.envAddress("PROVIDER_REGISTRY_ADDRESS"), + serviceRegistry: vm.envAddress("SERVICE_REGISTRY_ADDRESS") + }); + Registries memory implementations = _verify( + registries, + vm.envAddress("IDENTITY_REGISTRY_ADDRESS"), + vm.envAddress("SANCTIONS_ORACLE_ADDRESS"), + vm.envAddress("MARKETPLACE_REGISTRIES_FINAL_ADMIN") + ); + return ( + implementations.agentIndex, + implementations.validationRegistry, + implementations.providerRegistry, + implementations.serviceRegistry + ); + } + + function _verify(Registries memory registries, address identityRegistry, address sanctionsOracle, address safe) + internal + view + returns (Registries memory implementations) + { + _requireSupportedChain(); + _validateSafe(safe); + implementations = _requireImplementations(registries); + _requireWiring( + registries, + Dependencies({ + identityRegistry: identityRegistry, + sanctionsOracle: sanctionsOracle, + canonicalToken: StandardRailCircleUSDC.canonicalToken(block.chainid) + }) + ); + _requireAdministration(registries, safe, address(0)); + } +} diff --git a/test/DeployMarketplaceRegistries.t.sol b/test/DeployMarketplaceRegistries.t.sol new file mode 100644 index 0000000..3e66e9b --- /dev/null +++ b/test/DeployMarketplaceRegistries.t.sol @@ -0,0 +1,680 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Test} from "forge-std/Test.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {DeployMarketplaceRegistries} from "../script/DeployMarketplaceRegistries.s.sol"; +import {MarketplaceRegistryValidation} from "../script/MarketplaceRegistryValidation.sol"; +import {ReputationSafeValidation} from "../script/ReputationSafeValidation.sol"; +import {AgentIndex} from "../src/AgentIndex.sol"; +import {ProviderRegistry} from "../src/ProviderRegistry.sol"; +import {ServiceRegistry} from "../src/ServiceRegistry.sol"; +import {ValidationRegistry} from "../src/ValidationRegistry.sol"; +import {ISanctionsGuard} from "../src/interfaces/ISanctionsGuard.sol"; +import {Admin2StepUpgradeable} from "../src/utils/Admin2StepUpgradeable.sol"; +import { + DeployMarketplaceRegistriesHarness, + VerifyMarketplaceRegistriesHarness +} from "./helpers/MarketplaceDeploymentHarness.sol"; +import { + ReputationSafeFallbackHandlerStub, + ReputationSafeSingletonStub, + ThresholdSafeStub +} from "./helpers/ReputationDeploymentHarness.sol"; +import {MockCanonicalIdentityRegistry} from "./mocks/MockCanonicalIdentityRegistry.sol"; +import {MockSanctionsList} from "./mocks/MockSanctionsList.sol"; +import {MockUSDC} from "./mocks/MockUSDC.sol"; + +contract DeployMarketplaceRegistriesTest is Test { + uint256 private constant BASE_CHAIN_ID = 8_453; + uint256 private constant BASE_SEPOLIA_CHAIN_ID = 84_532; + address private constant BASE_USDC = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913; + address private constant BASE_SEPOLIA_USDC = 0x036CbD53842c5426634e7929541eC2318f3dCF7e; + bytes32 private constant ERC1967_IMPLEMENTATION_SLOT = + 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; + uint256 private constant LISTING_FEE = 1_000_000; + + DeployMarketplaceRegistriesHarness private script; + VerifyMarketplaceRegistriesHarness private verifier; + ReputationSafeSingletonStub private safeSingleton; + ReputationSafeFallbackHandlerStub private safeHandler; + MockCanonicalIdentityRegistry private identity; + MockSanctionsList private sanctions; + ThresholdSafeStub private safe; + + address private guardian = makeAddr("pause-guardian"); + address private treasury = makeAddr("treasury"); + address private provider = makeAddr("provider"); + + function setUp() public { + script = new DeployMarketplaceRegistriesHarness(); + verifier = new VerifyMarketplaceRegistriesHarness(); + safeSingleton = new ReputationSafeSingletonStub(); + safeHandler = new ReputationSafeFallbackHandlerStub(); + script.setReviewedSafeContracts(address(safeSingleton), address(safeHandler)); + verifier.setReviewedSafeContracts(address(safeSingleton), address(safeHandler)); + + identity = new MockCanonicalIdentityRegistry(); + sanctions = new MockSanctionsList(); + safe = _safe(2, _owners(2)); + + MockUSDC tokenCodeSource = new MockUSDC(); + vm.etch(BASE_USDC, address(tokenCodeSource).code); + vm.etch(BASE_SEPOLIA_USDC, address(tokenCodeSource).code); + vm.chainId(BASE_CHAIN_ID); + } + + // ------------------------------------------------------------------ + // Genesis deployment + // ------------------------------------------------------------------ + + function test_deploysPausedRegistriesPendingTheSafeOnBase() public { + _checkGenesisState(BASE_CHAIN_ID, BASE_USDC); + } + + function test_deploysPausedRegistriesPendingTheSafeOnBaseSepolia() public { + _checkGenesisState(BASE_SEPOLIA_CHAIN_ID, BASE_SEPOLIA_USDC); + } + + function test_safeAcceptanceEndsDeployerAuthorityOnBase() public { + _checkHandoffEndsDeployerAuthority(BASE_CHAIN_ID, BASE_USDC); + } + + function test_safeAcceptanceEndsDeployerAuthorityOnBaseSepolia() public { + _checkHandoffEndsDeployerAuthority(BASE_SEPOLIA_CHAIN_ID, BASE_SEPOLIA_USDC); + } + + function test_bootstrapAdminCannotActivateWhileTheSafeIsPending() public { + MarketplaceRegistryValidation.Registries memory registries = script.deploy(_config()); + address deployer = Admin2StepUpgradeable(registries.agentIndex).admin(); + address[4] memory proxies = _list(registries); + for (uint256 i = 0; i < proxies.length; i++) { + vm.prank(deployer); + vm.expectRevert("admin transfer pending"); + Admin2StepUpgradeable(proxies[i]).unpauseExternalDependency(); + } + + vm.prank(provider); + uint256 agentId = identity.register("ipfs://provider"); + vm.prank(provider); + vm.expectRevert("external dependency paused"); + ProviderRegistry(registries.providerRegistry).register(agentId); + vm.prank(provider); + vm.expectRevert("external dependency paused"); + AgentIndex(registries.agentIndex).claim(agentId); + } + + function test_handoffReadinessRefusesDriftFromTheReviewedInputs() public { + MarketplaceRegistryValidation.Registries memory registries = script.deploy(_config()); + address deployer = _broadcaster(); + script.requireHandoffReady(registries, _config(), deployer); + + // Until the Safe accepts, the bootstrap admin can still change a registry; the check refuses each change. + uint256 snapshot = vm.snapshotState(); + vm.prank(deployer); + ProviderRegistry(registries.providerRegistry).setListingFee(LISTING_FEE + 1); + vm.expectRevert(DeployMarketplaceRegistries.DeploymentNotReady.selector); + script.requireHandoffReady(registries, _config(), deployer); + vm.revertToState(snapshot); + + vm.prank(deployer); + ProviderRegistry(registries.providerRegistry).setTreasury(makeAddr("other-treasury")); + vm.expectRevert(DeployMarketplaceRegistries.DeploymentNotReady.selector); + script.requireHandoffReady(registries, _config(), deployer); + vm.revertToState(snapshot); + + vm.prank(deployer); + Admin2StepUpgradeable(registries.agentIndex).setPauseGuardian(makeAddr("other-guardian")); + vm.expectRevert(DeployMarketplaceRegistries.DeploymentNotReady.selector); + script.requireHandoffReady(registries, _config(), deployer); + vm.revertToState(snapshot); + + address otherAdmin = makeAddr("other-admin"); + vm.prank(deployer); + Admin2StepUpgradeable(registries.serviceRegistry).transferAdmin(otherAdmin); + vm.expectRevert( + abi.encodeWithSelector( + MarketplaceRegistryValidation.RegistryPendingAdminMismatch.selector, + registries.serviceRegistry, + address(safe), + otherAdmin + ) + ); + script.requireHandoffReady(registries, _config(), deployer); + } + + // ------------------------------------------------------------------ + // Refusals + // ------------------------------------------------------------------ + + function test_refusesUnsupportedChain() public { + uint256[3] memory unsupported = [uint256(1), 31_337, 11_155_111]; + for (uint256 i = 0; i < unsupported.length; i++) { + vm.chainId(unsupported[i]); + vm.expectRevert( + abi.encodeWithSelector(MarketplaceRegistryValidation.UnsupportedRegistryChain.selector, unsupported[i]) + ); + script.deploy(_config()); + } + } + + function test_refusesExternallyOwnedAdminOnBothChains() public { + DeployMarketplaceRegistries.Config memory config = _config(); + config.finalAdmin = makeAddr("eoa-admin"); + uint256[2] memory chains = [BASE_CHAIN_ID, BASE_SEPOLIA_CHAIN_ID]; + for (uint256 i = 0; i < chains.length; i++) { + vm.chainId(chains[i]); + vm.expectRevert( + abi.encodeWithSelector( + ReputationSafeValidation.FinalAdminMustBeReviewedSafe.selector, config.finalAdmin + ) + ); + script.deploy(config); + } + } + + function test_refusesOneOfOneSafeOnBothChains() public { + DeployMarketplaceRegistries.Config memory oneOfOne = _config(); + oneOfOne.finalAdmin = address(_safe(1, _owners(1))); + DeployMarketplaceRegistries.Config memory oneOfTwo = _config(); + oneOfTwo.finalAdmin = address(_safe(1, _owners(2))); + + uint256[2] memory chains = [BASE_CHAIN_ID, BASE_SEPOLIA_CHAIN_ID]; + for (uint256 i = 0; i < chains.length; i++) { + vm.chainId(chains[i]); + vm.expectRevert(ReputationSafeValidation.InvalidSafeOwners.selector); + script.deploy(oneOfOne); + vm.expectRevert(abi.encodeWithSelector(ReputationSafeValidation.InvalidSafeThreshold.selector, 1, 2)); + script.deploy(oneOfTwo); + } + } + + function test_refusesSafeWithModuleOrGuard() public { + ThresholdSafeStub withModule = _safe(2, _owners(2)); + address[] memory modules = new address[](1); + modules[0] = makeAddr("module"); + withModule.setModules(modules); + DeployMarketplaceRegistries.Config memory config = _config(); + config.finalAdmin = address(withModule); + vm.expectRevert(ReputationSafeValidation.InvalidSafeModules.selector); + script.deploy(config); + + ThresholdSafeStub withGuard = _safe(2, _owners(2)); + withGuard.setGuard(makeAddr("guard")); + config.finalAdmin = address(withGuard); + vm.expectPartialRevert(ReputationSafeValidation.InvalidSafeGuard.selector); + script.deploy(config); + } + + function test_refusesGuardianOrTreasuryThatKeepsAuthorityTogether() public { + address deployer = _broadcaster(); + + address[3] memory invalidGuardians = [address(0), address(safe), deployer]; + for (uint256 i = 0; i < invalidGuardians.length; i++) { + DeployMarketplaceRegistries.Config memory config = _config(); + config.pauseGuardian = invalidGuardians[i]; + vm.expectRevert(DeployMarketplaceRegistries.InvalidPauseGuardian.selector); + script.deploy(config); + } + + DeployMarketplaceRegistries.Config memory treasuryConfig = _config(); + treasuryConfig.treasury = address(0); + vm.expectRevert(DeployMarketplaceRegistries.InvalidTreasury.selector); + script.deploy(treasuryConfig); + + treasuryConfig.treasury = deployer; + vm.expectRevert(DeployMarketplaceRegistries.GovernanceRoleConflict.selector); + script.deploy(treasuryConfig); + } + + function test_refusesDependenciesWithoutCode() public { + address missing = makeAddr("missing"); + + DeployMarketplaceRegistries.Config memory config = _config(); + config.identityRegistry = missing; + vm.expectRevert(abi.encodeWithSelector(DeployMarketplaceRegistries.DependencyHasNoCode.selector, missing)); + script.deploy(config); + + config = _config(); + config.sanctionsOracle = missing; + vm.expectRevert(abi.encodeWithSelector(DeployMarketplaceRegistries.DependencyHasNoCode.selector, missing)); + script.deploy(config); + + vm.etch(BASE_USDC, bytes("")); + vm.expectRevert(abi.encodeWithSelector(DeployMarketplaceRegistries.DependencyHasNoCode.selector, BASE_USDC)); + script.deploy(_config()); + } + + // ------------------------------------------------------------------ + // Verifier + // ------------------------------------------------------------------ + + function test_verifierAcceptsFreshDeploymentOnBothChainsWithoutWriting() public { + uint256[2] memory chains = [BASE_CHAIN_ID, BASE_SEPOLIA_CHAIN_ID]; + for (uint256 i = 0; i < chains.length; i++) { + vm.chainId(chains[i]); + MarketplaceRegistryValidation.Registries memory registries = _deployAndAccept(); + + // A successful static call cannot have written state. + (bool success, bytes memory result) = address(verifier) + .staticcall( + abi.encodeCall(verifier.verify, (registries, address(identity), address(sanctions), address(safe))) + ); + assertTrue(success); + MarketplaceRegistryValidation.Registries memory implementations = + abi.decode(result, (MarketplaceRegistryValidation.Registries)); + assertEq(implementations.agentIndex, _implementation(registries.agentIndex)); + assertEq(implementations.validationRegistry, _implementation(registries.validationRegistry)); + assertEq(implementations.providerRegistry, _implementation(registries.providerRegistry)); + assertEq(implementations.serviceRegistry, _implementation(registries.serviceRegistry)); + } + } + + function test_verifierRefusesUnfinishedHandoff() public { + MarketplaceRegistryValidation.Registries memory registries = script.deploy(_config()); + address deployer = Admin2StepUpgradeable(registries.agentIndex).admin(); + + vm.expectRevert( + abi.encodeWithSelector( + MarketplaceRegistryValidation.RegistryAdminMismatch.selector, + registries.agentIndex, + address(safe), + deployer + ) + ); + _verify(registries); + + // Three of four accepted is still unfinished. + address[4] memory proxies = _list(registries); + for (uint256 i = 0; i < 3; i++) { + vm.prank(address(safe)); + Admin2StepUpgradeable(proxies[i]).acceptAdmin(); + } + vm.expectRevert( + abi.encodeWithSelector( + MarketplaceRegistryValidation.RegistryAdminMismatch.selector, + registries.serviceRegistry, + address(safe), + deployer + ) + ); + _verify(registries); + + vm.prank(address(safe)); + Admin2StepUpgradeable(registries.serviceRegistry).acceptAdmin(); + _verify(registries); + + // A later pending transfer is reported as well. + address proposed = makeAddr("proposed-admin"); + vm.prank(address(safe)); + Admin2StepUpgradeable(registries.providerRegistry).transferAdmin(proposed); + vm.expectRevert( + abi.encodeWithSelector( + MarketplaceRegistryValidation.RegistryPendingAdminMismatch.selector, + registries.providerRegistry, + address(0), + proposed + ) + ); + _verify(registries); + } + + function test_verifierRefusesAdminOtherThanTheExpectedSafe() public { + MarketplaceRegistryValidation.Registries memory registries = _deployAndAccept(); + + ThresholdSafeStub otherSafe = _safe(2, _owners(2)); + vm.expectRevert( + abi.encodeWithSelector( + MarketplaceRegistryValidation.RegistryAdminMismatch.selector, + registries.agentIndex, + address(otherSafe), + address(safe) + ) + ); + verifier.verify(registries, address(identity), address(sanctions), address(otherSafe)); + + address eoa = makeAddr("eoa-admin"); + vm.expectRevert(abi.encodeWithSelector(ReputationSafeValidation.FinalAdminMustBeReviewedSafe.selector, eoa)); + verifier.verify(registries, address(identity), address(sanctions), eoa); + + // The Safe must still satisfy the reviewed controls when verified. + safe.setThreshold(1); + vm.expectRevert(abi.encodeWithSelector(ReputationSafeValidation.InvalidSafeThreshold.selector, 1, 2)); + _verify(registries); + } + + function test_verifierRefusesTamperedProxy() public { + MarketplaceRegistryValidation.Registries memory registries = _deployAndAccept(); + _verify(registries); + + uint256 snapshot = vm.snapshotState(); + vm.store(registries.validationRegistry, ERC1967_IMPLEMENTATION_SLOT, bytes32(0)); + vm.expectRevert( + abi.encodeWithSelector( + MarketplaceRegistryValidation.RegistryImplementationNotSet.selector, registries.validationRegistry + ) + ); + _verify(registries); + vm.revertToState(snapshot); + + vm.store( + registries.validationRegistry, + ERC1967_IMPLEMENTATION_SLOT, + bytes32(uint256(uint160(makeAddr("implementation-without-code")))) + ); + vm.expectRevert( + abi.encodeWithSelector( + MarketplaceRegistryValidation.RegistryImplementationNotSet.selector, registries.validationRegistry + ) + ); + _verify(registries); + vm.revertToState(snapshot); + + vm.etch(registries.serviceRegistry, bytes("")); + vm.expectRevert( + abi.encodeWithSelector(MarketplaceRegistryValidation.RegistryHasNoCode.selector, registries.serviceRegistry) + ); + _verify(registries); + } + + function test_verifierRefusesWrongWiring() public { + MarketplaceRegistryValidation.Registries memory registries = _deployAndAccept(); + MarketplaceRegistryValidation.Registries memory other = _deployAndAccept(); + + // A service registry that settles against another provider registry. + MarketplaceRegistryValidation.Registries memory crossed = _copy(registries); + crossed.serviceRegistry = other.serviceRegistry; + vm.expectRevert( + abi.encodeWithSelector( + MarketplaceRegistryValidation.ServiceRegistryProviderMismatch.selector, + registries.providerRegistry, + other.providerRegistry + ) + ); + _verify(crossed); + + // Two addresses swapped, and one registry standing in for another. + MarketplaceRegistryValidation.Registries memory swapped = _copy(registries); + swapped.agentIndex = registries.validationRegistry; + swapped.validationRegistry = registries.agentIndex; + vm.expectRevert( + abi.encodeWithSelector( + MarketplaceRegistryValidation.RegistryTypeMismatch.selector, registries.validationRegistry + ) + ); + _verify(swapped); + + MarketplaceRegistryValidation.Registries memory standIn = _copy(registries); + standIn.agentIndex = other.serviceRegistry; + vm.expectRevert( + abi.encodeWithSelector(MarketplaceRegistryValidation.RegistryTypeMismatch.selector, other.serviceRegistry) + ); + _verify(standIn); + } + + function test_verifierRefusesUnexpectedDependencies() public { + MarketplaceRegistryValidation.Registries memory registries = _deployAndAccept(); + + MockCanonicalIdentityRegistry otherIdentity = new MockCanonicalIdentityRegistry(); + vm.expectRevert( + abi.encodeWithSelector( + MarketplaceRegistryValidation.RegistryIdentityMismatch.selector, + registries.agentIndex, + address(otherIdentity), + address(identity) + ) + ); + verifier.verify(registries, address(otherIdentity), address(sanctions), address(safe)); + + MockSanctionsList otherOracle = new MockSanctionsList(); + vm.expectRevert( + abi.encodeWithSelector( + MarketplaceRegistryValidation.RegistrySanctionsOracleMismatch.selector, + registries.agentIndex, + address(otherOracle), + address(sanctions) + ) + ); + verifier.verify(registries, address(identity), address(otherOracle), address(safe)); + + // Registries deployed for Base use Base USDC and do not verify as a Base Sepolia deployment. + vm.chainId(BASE_SEPOLIA_CHAIN_ID); + vm.expectRevert( + abi.encodeWithSelector( + MarketplaceRegistryValidation.ProviderRegistryTokenMismatch.selector, BASE_SEPOLIA_USDC, BASE_USDC + ) + ); + _verify(registries); + + vm.chainId(1); + vm.expectRevert(abi.encodeWithSelector(MarketplaceRegistryValidation.UnsupportedRegistryChain.selector, 1)); + _verify(registries); + } + + // ------------------------------------------------------------------ + // Environment inputs + // ------------------------------------------------------------------ + + function test_runAndVerifyReadTheirEnvironmentInputs() public { + // No other test reads or writes these variables, so parallel tests cannot interfere. + _setEnv("IDENTITY_REGISTRY_ADDRESS", vm.toString(address(identity))); + _setEnv("SANCTIONS_ORACLE_ADDRESS", vm.toString(address(sanctions))); + _setEnv("MARKETPLACE_REGISTRIES_FINAL_ADMIN", vm.toString(address(safe))); + _setEnv("MARKETPLACE_REGISTRIES_PAUSE_GUARDIAN", vm.toString(guardian)); + _setEnv("PROVIDER_REGISTRY_TREASURY", vm.toString(treasury)); + _setEnv("PROVIDER_REGISTRY_LISTING_FEE", vm.toString(LISTING_FEE)); + + MarketplaceRegistryValidation.Registries memory registries; + ( + registries.agentIndex, + registries.validationRegistry, + registries.providerRegistry, + registries.serviceRegistry + ) = script.run(); + _requireGenesisState(registries, BASE_USDC); + + _accept(registries); + _setEnv("AGENT_INDEX_ADDRESS", vm.toString(registries.agentIndex)); + _setEnv("VALIDATION_REGISTRY_ADDRESS", vm.toString(registries.validationRegistry)); + _setEnv("PROVIDER_REGISTRY_ADDRESS", vm.toString(registries.providerRegistry)); + _setEnv("SERVICE_REGISTRY_ADDRESS", vm.toString(registries.serviceRegistry)); + (address agentIndexImplementation,,, address serviceRegistryImplementation) = verifier.run(); + assertEq(agentIndexImplementation, _implementation(registries.agentIndex)); + assertEq(serviceRegistryImplementation, _implementation(registries.serviceRegistry)); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + function _checkGenesisState(uint256 chainId, address reviewedToken) private { + vm.chainId(chainId); + MarketplaceRegistryValidation.Registries memory registries = script.deploy(_config()); + _requireGenesisState(registries, reviewedToken); + + // The proxies are initialized exactly once, and the implementations never. + vm.expectRevert(Initializable.InvalidInitialization.selector); + AgentIndex(registries.agentIndex).initialize(address(identity), address(sanctions), address(this)); + vm.expectRevert(Initializable.InvalidInitialization.selector); + AgentIndex(_implementation(registries.agentIndex)) + .initialize(address(identity), address(sanctions), address(this)); + } + + function _requireGenesisState(MarketplaceRegistryValidation.Registries memory registries, address reviewedToken) + private + view + { + address deployer = _broadcaster(); + address[4] memory proxies = _list(registries); + for (uint256 i = 0; i < proxies.length; i++) { + Admin2StepUpgradeable registry = Admin2StepUpgradeable(proxies[i]); + assertEq(registry.admin(), deployer); + assertEq(registry.pendingAdmin(), address(safe)); + assertEq(registry.pauseGuardian(), guardian); + assertTrue(registry.externalDependencyPaused()); + assertEq(address(ISanctionsGuard(proxies[i]).sanctionsOracle()), address(sanctions)); + assertTrue(_implementation(proxies[i]).code.length != 0); + } + + assertEq(AgentIndex(registries.agentIndex).getIdentityRegistry(), address(identity)); + assertEq(ValidationRegistry(registries.validationRegistry).getIdentityRegistry(), address(identity)); + ProviderRegistry providers = ProviderRegistry(registries.providerRegistry); + assertEq(address(providers.identity()), address(identity)); + assertEq(address(providers.usdc()), reviewedToken); + assertEq(providers.treasury(), treasury); + assertEq(providers.listingFee(), LISTING_FEE); + ServiceRegistry services = ServiceRegistry(registries.serviceRegistry); + assertEq(address(services.identity()), address(identity)); + assertEq(address(services.providerRegistry()), registries.providerRegistry); + } + + function _checkHandoffEndsDeployerAuthority(uint256 chainId, address reviewedToken) private { + vm.chainId(chainId); + MarketplaceRegistryValidation.Registries memory registries = script.deploy(_config()); + address deployer = Admin2StepUpgradeable(registries.agentIndex).admin(); + assertEq(deployer, _broadcaster()); + + _accept(registries); + + address[4] memory proxies = _list(registries); + address newImplementation = address(new ValidationRegistry()); + for (uint256 i = 0; i < proxies.length; i++) { + Admin2StepUpgradeable registry = Admin2StepUpgradeable(proxies[i]); + assertEq(registry.admin(), address(safe)); + assertEq(registry.pendingAdmin(), address(0)); + assertFalse(registry.externalDependencyPaused()); + assertTrue(registry.pauseGuardian() != deployer); + + vm.startPrank(deployer); + vm.expectRevert("not admin"); + registry.transferAdmin(deployer); + vm.expectRevert("not pending admin"); + registry.acceptAdmin(); + vm.expectRevert("not admin"); + registry.setPauseGuardian(deployer); + vm.expectRevert("not admin or guardian"); + registry.pauseExternalDependency(); + vm.expectRevert("not admin"); + registry.unpauseExternalDependency(); + vm.expectRevert("not admin"); + UUPSUpgradeable(proxies[i]).upgradeToAndCall(newImplementation, ""); + vm.stopPrank(); + } + + vm.startPrank(deployer); + vm.expectRevert("not admin"); + ProviderRegistry(registries.providerRegistry).setListingFee(0); + vm.expectRevert("not admin"); + ProviderRegistry(registries.providerRegistry).setTreasury(deployer); + vm.stopPrank(); + + _checkRegistriesOperate(registries, reviewedToken); + } + + /// @dev One provider goes through all four registries against the shared identity registry. + function _checkRegistriesOperate(MarketplaceRegistryValidation.Registries memory registries, address reviewedToken) + private + { + MockUSDC(reviewedToken).mint(provider, LISTING_FEE); + vm.startPrank(provider); + uint256 agentId = identity.register("ipfs://provider"); + MockUSDC(reviewedToken).approve(registries.providerRegistry, LISTING_FEE); + ProviderRegistry(registries.providerRegistry).register(agentId); + bytes32 serviceId = ServiceRegistry(registries.serviceRegistry) + .registerService(agentId, "service", "1", "ipfs://service", address(0)); + AgentIndex(registries.agentIndex).claim(agentId); + ValidationRegistry(registries.validationRegistry) + .validationRequest(makeAddr("validator"), agentId, "ipfs://request", keccak256("request")); + vm.stopPrank(); + + assertEq(MockUSDC(reviewedToken).balanceOf(treasury), LISTING_FEE); + assertTrue(ServiceRegistry(registries.serviceRegistry).isActive(serviceId)); + (uint256 resolved, bool found) = AgentIndex(registries.agentIndex).resolve(provider); + assertTrue(found); + assertEq(resolved, agentId); + assertEq(ValidationRegistry(registries.validationRegistry).getAgentValidationCount(agentId), 1); + } + + function _deployAndAccept() private returns (MarketplaceRegistryValidation.Registries memory registries) { + registries = script.deploy(_config()); + _accept(registries); + } + + /// @dev The Safe's acceptance batch: accept administration, then activate. + function _accept(MarketplaceRegistryValidation.Registries memory registries) private { + address[4] memory proxies = _list(registries); + vm.startPrank(address(safe)); + for (uint256 i = 0; i < proxies.length; i++) { + Admin2StepUpgradeable(proxies[i]).acceptAdmin(); + Admin2StepUpgradeable(proxies[i]).unpauseExternalDependency(); + } + vm.stopPrank(); + } + + function _verify(MarketplaceRegistryValidation.Registries memory registries) private view { + verifier.verify(registries, address(identity), address(sanctions), address(safe)); + } + + function _config() private view returns (DeployMarketplaceRegistries.Config memory config) { + config.identityRegistry = address(identity); + config.sanctionsOracle = address(sanctions); + config.finalAdmin = address(safe); + config.pauseGuardian = guardian; + config.treasury = treasury; + config.listingFee = LISTING_FEE; + } + + /// @dev `vm.startBroadcast()` without a key signs as the configured sender, which is the transaction origin here. + function _broadcaster() private view returns (address) { + return tx.origin; + } + + function _implementation(address proxy) private view returns (address) { + return address(uint160(uint256(vm.load(proxy, ERC1967_IMPLEMENTATION_SLOT)))); + } + + function _list(MarketplaceRegistryValidation.Registries memory registries) + private + pure + returns (address[4] memory) + { + return [ + registries.agentIndex, + registries.validationRegistry, + registries.providerRegistry, + registries.serviceRegistry + ]; + } + + function _copy(MarketplaceRegistryValidation.Registries memory registries) + private + pure + returns (MarketplaceRegistryValidation.Registries memory) + { + return MarketplaceRegistryValidation.Registries({ + agentIndex: registries.agentIndex, + validationRegistry: registries.validationRegistry, + providerRegistry: registries.providerRegistry, + serviceRegistry: registries.serviceRegistry + }); + } + + function _safe(uint256 threshold, address[] memory owners) private returns (ThresholdSafeStub) { + address[] memory modules = new address[](0); + return + new ThresholdSafeStub(address(safeSingleton), threshold, owners, modules, address(0), address(safeHandler)); + } + + function _owners(uint256 count) private returns (address[] memory owners) { + owners = new address[](count); + for (uint256 i = 0; i < count; i++) { + owners[i] = makeAddr(string.concat("safe-owner-", vm.toString(i))); + } + } + + function _setEnv(string memory name, string memory value) private { + // forge-lint: disable-next-line(unsafe-cheatcode) + vm.setEnv(name, value); + } +} diff --git a/test/helpers/MarketplaceDeploymentHarness.sol b/test/helpers/MarketplaceDeploymentHarness.sol new file mode 100644 index 0000000..eaaaee7 --- /dev/null +++ b/test/helpers/MarketplaceDeploymentHarness.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {DeployMarketplaceRegistries} from "../../script/DeployMarketplaceRegistries.s.sol"; +import {VerifyMarketplaceRegistries} from "../../script/VerifyMarketplaceRegistries.s.sol"; +import {ReputationSafeSingletonStub, ThresholdSafeStub} from "./ReputationDeploymentHarness.sol"; + +/// @dev Both harnesses swap only the reviewed Safe identities for the local +/// stubs. The production chain gate of `reviewedSafeDeployment` still runs. +contract DeployMarketplaceRegistriesHarness is DeployMarketplaceRegistries { + address private _reviewedSafeSingleton; + address private _reviewedSafeHandler; + + function setReviewedSafeContracts(address singleton, address handler) external { + _reviewedSafeSingleton = singleton; + _reviewedSafeHandler = handler; + } + + function reviewedSafeDeployment(uint256 chainId) public view override returns (bytes32, address, bytes32, address) { + super.reviewedSafeDeployment(chainId); + return ( + keccak256(type(ThresholdSafeStub).runtimeCode), + _reviewedSafeSingleton, + keccak256(type(ReputationSafeSingletonStub).runtimeCode), + _reviewedSafeHandler + ); + } + + function deploy(Config memory config) external returns (Registries memory) { + return _deploy(config); + } + + function requireHandoffReady(Registries memory registries, Config memory config, address deployer) external view { + _requireHandoffReady(registries, config, _validateConfig(config), deployer); + } +} + +contract VerifyMarketplaceRegistriesHarness is VerifyMarketplaceRegistries { + address private _reviewedSafeSingleton; + address private _reviewedSafeHandler; + + function setReviewedSafeContracts(address singleton, address handler) external { + _reviewedSafeSingleton = singleton; + _reviewedSafeHandler = handler; + } + + function reviewedSafeDeployment(uint256 chainId) public view override returns (bytes32, address, bytes32, address) { + super.reviewedSafeDeployment(chainId); + return ( + keccak256(type(ThresholdSafeStub).runtimeCode), + _reviewedSafeSingleton, + keccak256(type(ReputationSafeSingletonStub).runtimeCode), + _reviewedSafeHandler + ); + } + + function verify(Registries memory registries, address identityRegistry, address sanctionsOracle, address safe) + external + view + returns (Registries memory) + { + return _verify(registries, identityRegistry, sanctionsOracle, safe); + } +} From b1e9f630b6f4e7c2db94e216c358c360c0e07005 Mon Sep 17 00:00:00 2001 From: pawmmm Date: Thu, 17 Sep 2026 12:37:36 -0600 Subject: [PATCH 4/4] Declare the owner's part of the branch model and the genesis scripts in the hand-off The commits that moved this repository to the develop, sandbox and main model did not say what they leave for the owner. The release agent reads nothing but commits, so that part is declared here, on a commit that changes no file. Both release coordinators list an owner task once, however many repositories declare it. The genesis script added in fccee52 takes the identity registry and the sanctions oracle as inputs, and until the Safe accepts, the broadcaster is still admin of the paused proxies. Both are the owner's to settle before the script is used on Base. Release-Owner-Task: Create the production and rehearsal release GitHub Apps and install them on the five shipped repositories, see the release-apps-and-rulesets task in deploy-mainnet Release-Owner-Task: Before the registry genesis script is used on Base, decide whether to pin the identity registry and sanctions oracle for 8453 instead of passing them as inputs Release-Owner-Task: When the genesis script has run, check admin, pending admin, the paused flag and the implementations on chain before the Safe accepts Release-Rollback: deployment scripts, tests and documentation only; nothing is deployed or upgraded by this release, so nothing needs undoing