diff --git a/docs/agent-escrow-genesis-migration.md b/docs/agent-escrow-genesis-migration.md new file mode 100644 index 0000000..184f6ea --- /dev/null +++ b/docs/agent-escrow-genesis-migration.md @@ -0,0 +1,150 @@ +# AgentEscrow Genesis Migration Plan + +Status: draft for issue #86 +Last updated: 2026-06-06 + +## Summary + +`AgentEscrow` should ship as an ordinary Solidity contract placed into genesis state at a fixed well-known address, not as a system contract that can only be changed through chain forks. The genesis deployment should be byte-for-byte equivalent to a normal `forge create` deployment with the same constructor arguments, zero escrow balance, Council multisig ownership, and no in-flight payments. + +This plan defines the address-selection process, allowed state transitions during testnet-to-mainnet cutover, the smoke-test corpus for proving genesis parity, and the future upgrade path. + +## Goals + +- Keep `AgentEscrow` redeployable by governance without giving kcolbchain system-contract control over downstream chains. +- Make genesis state auditable before mainnet launch. +- Prove the genesis deployment behaves the same as a fresh contract deployment. +- Preserve in-flight payment safety during testnet and mainnet cutovers. + +## Non-Goals + +- This document does not change `AgentEscrow.sol`. +- This document does not choose the final mainnet address by itself. +- This document does not define bridge or oracle committee governance beyond the owner and cutover checks needed for `AgentEscrow`. + +## Target Address + +The candidate address for the genesis deploy is: + +```text +0x000000000000000000000000000000000000A002 +``` + +The final address MUST be confirmed against the Lux subnet genesis config before mainnet freeze. The address is valid only if all of the following are true: + +| Check | Requirement | +| --- | --- | +| Namespace collision | No existing precompile, system contract, bridge, oracle, or reserved account uses the address. | +| Explorer metadata | The address can be labelled as `AgentEscrow` before public testnet cutover. | +| Genesis reproducibility | The genesis account code hash matches the locally deployed `AgentEscrow` runtime bytecode hash. | +| Operator documentation | Validators can find the address in public docs before mainnet genesis state ships. | + +If any check fails, the Council should choose the next reserved application address in the `0xA0xx` range and repeat the same checks. + +## Genesis State + +The genesis allocation for `AgentEscrow` MUST contain contract code and constructor-derived storage only. It MUST NOT carry testnet payment state into mainnet. + +| Field | Required value at genesis | +| --- | --- | +| ETH balance | `0` | +| Owner | Council multisig | +| Chain id constructor arg | Mainnet chain id | +| Oracle aggregator constructor arg | Mainnet aggregator address, or `address(0)` if attestation release is not enabled at launch | +| In-flight payments | none | +| Registered agents | none unless explicitly approved in the public RFC | +| Paused/deprecated flag | not applicable unless added by a future contract version | + +The testnet genesis state MAY use a test Council multisig and test oracle aggregator, but it MUST retain the same zero-balance and no-in-flight-payment invariants. + +## Valid Cutover State Transitions + +Cutover has three phases. + +| Phase | Allowed actions | Disallowed actions | +| --- | --- | --- | +| Pre-freeze | Deploy test contracts, run payments, update docs, publish candidate address. | Claim finality for any address not yet confirmed in subnet config. | +| Freeze window | Stop creating new testnet payments, snapshot open request ids, run genesis parity tests, publish RFC. | Mutate genesis code, migrate in-flight testnet payments into mainnet, change owner without Council sign-off. | +| Mainnet launch | Ship zero-balance genesis deploy, verify owner, verify code hash, run smoke tests. | Treat testnet tx hashes as mainnet continuity, accept payments before owner and code hash are confirmed. | + +In-flight testnet payments should be settled, refunded, or explicitly abandoned before the freeze window closes. They MUST NOT be copied into mainnet storage. Mainnet payment request ids should be domain-separated from testnet ids at the application layer. + +## Constructor and Storage Invariants + +The smoke-test suite should assert the following invariants immediately after genesis import and after a fresh `forge create` deployment: + +| Invariant | Expected result | +| --- | --- | +| Runtime code hash | Genesis deployment equals fresh deployment runtime bytecode. | +| Owner | `owner()` returns the Council multisig. | +| Chain id | Any exposed chain-id dependent state matches the configured mainnet chain id. | +| Oracle aggregator | Aggregator address equals the launch configuration. | +| Balance | Contract balance is zero. | +| Payment lookup | A never-created request id returns empty/default state. | +| Agent registry | No unapproved agent is registered. | + +If constructor arguments make exact storage roots differ across testnet and mainnet, the test should compare the expected decoded fields rather than asserting a single hard-coded storage root. + +## Smoke Test Corpus + +The implementation target for this plan is `test/genesis_cutover.t.sol`. + +| Test | Purpose | +| --- | --- | +| `testGenesisRuntimeCodeMatchesFreshDeploy` | Compare runtime bytecode hash of the genesis address and a fresh `new AgentEscrow(...)`. | +| `testGenesisOwnerIsCouncilMultisig` | Ensure ownership is not left with a deployer EOA. | +| `testGenesisStartsWithNoBalanceOrPayments` | Assert zero ETH balance and no initialized request state. | +| `testCreatePaymentHappyPathAfterGenesis` | Run `createPayment{value: 0.01 ether}` and release through the payer-confirmed happy path. | +| `testTimeoutRefundAfterGenesis` | Create a payment, advance blocks past timeout, and verify payer refund behavior. | +| `testChallengePathAfterGenesis` | Exercise challenge-period behavior using the existing AgentEscrow test pattern. | +| `testCouncilOwnershipTransfer` | Transfer ownership from Council multisig to a replacement Council address and assert old owner cannot mutate registry state. | +| `testPreGenesisRequestIdDoesNotCollide` | Use a testnet-style request id and a mainnet-domain request id, proving application-level separation. | + +The first test proves bytecode parity. The remaining tests prove the genesis deployment is operationally equivalent to a fresh deployment. + +## Testnet Dry Run + +At least 14 days before mainnet, run a public testnet cutover dry run: + +1. Publish the candidate address, owner, chain id, oracle aggregator, and expected runtime bytecode hash. +2. Stop creating new testnet payments for the dry-run window. +3. Resolve or list all in-flight testnet request ids. +4. Import the genesis state into a new testnet fork. +5. Run `test/genesis_cutover.t.sol`. +6. Publish a short report with the code hash, owner, smoke-test output, and any unresolved gaps. + +The mainnet launch should not proceed until the dry-run report has no critical failures. + +## Upgrade Path + +Future upgrades should follow this path: + +1. Council vote approves `AgentEscrow_v2` and the migration window. +2. Deploy `AgentEscrow_v2` at a new well-known address. +3. Publish a deprecation notice for v1, including the final block for new v1 payments. +4. Keep v1 live long enough for existing locked payments to release, refund, cancel, or be manually resolved. +5. Update the chain proxy/router or application config to point new payments at v2. +6. Archive v1 docs with final state, event ranges, and migration notes. + +This is explicitly not a system-contract upgrade-via-fork. Forks may include a new genesis allocation for new networks, but live-network upgrades should be governed redeployments plus routing updates. + +## Public RFC Checklist + +The public RFC should be posted at least 14 days before mainnet and include: + +- Final `AgentEscrow` address. +- Council multisig address and signer policy. +- Oracle aggregator address or launch-time disabled status. +- Runtime bytecode hash. +- Constructor arguments. +- Smoke-test command and output. +- Testnet dry-run report link. +- Migration and deprecation path for future versions. + +## Open Questions + +- Is `0x000000000000000000000000000000000000A002` reserved in all target Lux subnet configurations? +- Will launch enable oracle-attested release, or should the genesis constructor use `address(0)` for the aggregator? +- Should registered agents be empty at genesis, or should a small allowlist be approved in the public RFC? +- Which public surface should host the dry-run report: repository docs, kcolbchain.com, or both? + diff --git a/test/genesis_cutover.t.sol b/test/genesis_cutover.t.sol new file mode 100644 index 0000000..73960f3 --- /dev/null +++ b/test/genesis_cutover.t.sol @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {AgentEscrow} from "../contracts/AgentEscrow.sol"; +import {IOracleAggregator} from "../contracts/IOracleAggregator.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; + +contract AgentEscrowGenesisCutoverTest is Test { + address internal constant CANDIDATE_GENESIS_ADDRESS = address(0xA002); + uint256 internal constant MAINNET_CHAIN_ID = 7777777; + uint256 internal constant TIMEOUT = 100; + uint256 internal constant CHALLENGE = 10; + uint256 internal constant AMOUNT = 0.01 ether; + + AgentEscrow internal genesisEscrow; + + address internal council = address(0xC011C11); + address internal replacementCouncil = address(0xC011C12); + address internal payer = address(0xB0B); + address internal payee = address(0xC0DE); + address internal unapprovedAgent = address(0xA6E17); + + function setUp() public { + vm.prank(council); + genesisEscrow = new AgentEscrow(MAINNET_CHAIN_ID, IOracleAggregator(address(0))); + + vm.deal(payer, 1 ether); + } + + function testGenesisRuntimeCodeMatchesFreshDeploy() public { + vm.prank(council); + AgentEscrow freshEscrow = new AgentEscrow(MAINNET_CHAIN_ID, IOracleAggregator(address(0))); + + assertEq(address(genesisEscrow).codehash, address(freshEscrow).codehash); + assertEq(CANDIDATE_GENESIS_ADDRESS, address(0x000000000000000000000000000000000000A002)); + } + + function testGenesisOwnerIsCouncilMultisig() public { + assertEq(genesisEscrow.owner(), council); + } + + function testGenesisStartsWithNoBalanceOrPayments() public { + assertEq(address(genesisEscrow).balance, 0); + assertEq(genesisEscrow.chainId(), MAINNET_CHAIN_ID); + assertEq(address(genesisEscrow.oracleAggregator()), address(0)); + assertFalse(genesisEscrow.registeredAgents(unapprovedAgent)); + + AgentEscrow.Payment memory neverCreated = genesisEscrow.getPayment("never-created"); + assertEq(neverCreated.payer, address(0)); + assertEq(neverCreated.payee, address(0)); + assertEq(neverCreated.amount, 0); + assertEq(neverCreated.createdAt, 0); + assertEq(uint8(neverCreated.state), uint8(AgentEscrow.State.Created)); + } + + function testCreatePaymentHappyPathAfterGenesis() public { + uint256 payeeBalanceBefore = payee.balance; + + vm.prank(payer); + genesisEscrow.createPayment{value: AMOUNT}("mainnet:req-1", payee, TIMEOUT, CHALLENGE); + + assertTrue(genesisEscrow.isState("mainnet:req-1", AgentEscrow.State.Locked)); + + vm.prank(payer); + genesisEscrow.confirmPayment("mainnet:req-1"); + + assertTrue(genesisEscrow.isState("mainnet:req-1", AgentEscrow.State.Released)); + assertEq(payee.balance, payeeBalanceBefore + AMOUNT); + assertEq(address(genesisEscrow).balance, 0); + } + + function testTimeoutRefundAfterGenesis() public { + uint256 payerBalanceBefore = payer.balance; + + vm.prank(payer); + genesisEscrow.createPayment{value: AMOUNT}("mainnet:req-refund", payee, TIMEOUT, CHALLENGE); + + vm.roll(block.number + TIMEOUT + CHALLENGE + 1); + + vm.prank(payer); + genesisEscrow.requestRefund("mainnet:req-refund"); + + assertTrue(genesisEscrow.isState("mainnet:req-refund", AgentEscrow.State.Refunded)); + assertEq(payer.balance, payerBalanceBefore); + assertEq(address(genesisEscrow).balance, 0); + } + + function testChallengePathAfterGenesis() public { + vm.prank(payer); + genesisEscrow.createPayment{value: AMOUNT}("mainnet:req-challenge", payee, TIMEOUT, CHALLENGE); + + vm.roll(block.number + TIMEOUT); + + assertTrue(genesisEscrow.isExpired("mainnet:req-challenge")); + + vm.prank(payer); + vm.expectRevert(bytes("Challenge period not over")); + genesisEscrow.requestRefund("mainnet:req-challenge"); + + vm.roll(block.number + CHALLENGE + 1); + + vm.prank(payer); + genesisEscrow.requestRefund("mainnet:req-challenge"); + + assertTrue(genesisEscrow.isState("mainnet:req-challenge", AgentEscrow.State.Refunded)); + } + + function testCouncilOwnershipTransfer() public { + vm.prank(council); + genesisEscrow.transferOwnership(replacementCouncil); + + vm.prank(council); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, council)); + genesisEscrow.registerAgent(unapprovedAgent); + + vm.prank(replacementCouncil); + genesisEscrow.registerAgent(unapprovedAgent); + + assertEq(genesisEscrow.owner(), replacementCouncil); + assertTrue(genesisEscrow.registeredAgents(unapprovedAgent)); + } + + function testPreGenesisRequestIdDoesNotCollide() public { + vm.prank(payer); + genesisEscrow.createPayment{value: AMOUNT}("testnet:req-1", payee, TIMEOUT, CHALLENGE); + + vm.prank(payer); + genesisEscrow.createPayment{value: AMOUNT}("mainnet:req-1", payee, TIMEOUT, CHALLENGE); + + AgentEscrow.Payment memory testnetPayment = genesisEscrow.getPayment("testnet:req-1"); + AgentEscrow.Payment memory mainnetPayment = genesisEscrow.getPayment("mainnet:req-1"); + + assertEq(testnetPayment.requestId, "testnet:req-1"); + assertEq(mainnetPayment.requestId, "mainnet:req-1"); + assertTrue(genesisEscrow.isState("testnet:req-1", AgentEscrow.State.Locked)); + assertTrue(genesisEscrow.isState("mainnet:req-1", AgentEscrow.State.Locked)); + } +}