diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..e439f0a --- /dev/null +++ b/test/README.md @@ -0,0 +1,134 @@ +# IRSB Protocol Test Suite + +## Testing Philosophy + +IRSB follows a **Moloch DAO-inspired testing methodology** combined with Foundry-native patterns: + +1. **Trigger every require/revert** - Every revert path in every contract has a dedicated test +2. **Test every modifier** - Each custom modifier has allow/reject test pairs +3. **Verify all state transitions** - Post-condition assertions check ALL affected fields, not just the obvious ones +4. **Test boundary conditions** - Systematic 0, 1, MAX-1, MAX, MAX+1 for every numeric parameter +5. **Fuzz for invariants** - Property-based testing for protocol-wide invariants +6. **Security regressions** - Named tests for every discovered vulnerability (IRSB-SEC-NNN) + +## Directory Structure + +``` +test/ +├── SolverRegistry.t.sol # Core unit tests + security regressions +├── IntentReceiptHub.t.sol # Receipt lifecycle + challenger bonds +├── DisputeModule.t.sol # Evidence, escalation, arbitration +├── EscrowVault.t.sol # Native ETH escrow lifecycle +├── EscrowVaultERC20.t.sol # ERC20 escrow lifecycle +├── WalletDelegate.t.sol # EIP-7702 delegation + execution +├── X402Facilitator.t.sol # x402 payment settlement +├── OptimisticDispute.t.sol # Counter-bond dispute resolution +├── ReceiptV2Extension.t.sol # Dual attestation receipts +├── ERC8004Adapter.t.sol # Validation signal adapter +├── ERC8004Integration.t.sol # End-to-end ERC-8004 flow +├── CredibilityRegistry.t.sol # Credibility tracking +├── AcrossAdapter.t.sol # Across bridge integration +│ +├── moloch/ # Moloch DAO-style systematic tests +│ ├── RequireAudit.t.sol # Every untested revert path (~43 tests) +│ ├── StateTransitions.t.sol # Comprehensive state verification (~10 tests) +│ ├── BoundaryTests.t.sol # 0/1/MAX boundary conditions (~27 tests) +│ └── ModifierTests.t.sol # Modifier allow/reject pairs (~17 tests) +│ +├── enforcers/ # Caveat enforcer tests +│ ├── SpendLimitEnforcer.t.sol +│ ├── TimeWindowEnforcer.t.sol +│ ├── AllowedTargetsEnforcer.t.sol +│ ├── AllowedMethodsEnforcer.t.sol +│ └── NonceEnforcer.t.sol +│ +├── fuzz/ # Fuzz tests (256 runs default, 10k in CI) +│ ├── SolverRegistryFuzz.t.sol +│ ├── IntentReceiptHubFuzz.t.sol +│ ├── EscrowVaultFuzz.t.sol +│ ├── ReceiptV2Fuzz.t.sol +│ ├── OptimisticDisputeFuzz.t.sol +│ ├── SpendLimitEnforcer.fuzz.t.sol +│ └── WalletDelegate.fuzz.t.sol +│ +├── invariants/ # Invariant tests +│ ├── SolverRegistry.invariants.t.sol +│ ├── IntentReceiptHub.invariants.t.sol +│ └── DisputeModule.invariants.t.sol +│ +├── helpers/ # Test utilities +│ ├── MockTarget.sol # Simple target for delegation tests +│ ├── MockETHRejecter.sol # Rejects ETH (tests transfer failures) +│ └── VerificationHelpers.sol # Reusable state-checking assertions +│ +└── security-exercise/ # Vulnerability demonstration + ├── Attacker.sol + ├── VulnerableVault.sol + ├── SecureVault.sol + └── VulnerableVault.t.sol +``` + +## Naming Conventions + +| Category | Pattern | Example | +|----------|---------|---------| +| Core unit | `test_[FunctionName]` | `test_RegisterSolver` | +| Revert | `test_[FunctionName]_Revert[Reason]` | `test_DepositBond_RevertZeroAmount` | +| Require audit | `test_requireFail_[Contract]_[function]_[reason]` | `test_requireFail_SolverRegistry_slash_transferFailed` | +| Boundary | `test_boundary_[Contract]_[parameter]_[condition]` | `test_boundary_IntentReceiptHub_batchSize_max` | +| State transition | `test_stateTransition_[action]_[assertion]` | `test_stateTransition_depositBond_activationThreshold` | +| Modifier | `test_modifier_[name]_[allows\|rejects]_[who]` | `test_modifier_onlyOperator_rejects_nonOperator` | +| Security | `test_IRSB_SEC_NNN_[description]` | `test_IRSB_SEC_005_zeroSlashAmountReverts` | +| Fuzz | `testFuzz_[Action]([params])` | `testFuzz_DepositAndWithdraw(uint256 amount)` | +| Invariant | `invariant_[property]` | `invariant_totalBondedMatchesSum` | + +## Running Tests + +```bash +# All tests +forge test + +# Specific category +forge test --match-path "test/moloch/*" # All Moloch-style tests +forge test --match-test "test_requireFail" # Require audit only +forge test --match-test "test_boundary" # Boundary tests only +forge test --match-test "test_stateTransition" # State transitions only +forge test --match-test "test_modifier" # Modifier pairs only + +# Verbose (see revert messages) +forge test --match-path "test/moloch/*" -vvv + +# Gas report +forge test --gas-report + +# Fuzz (CI profile: 10k runs) +FOUNDRY_PROFILE=ci forge test --match-path "test/fuzz/*" + +# Single test file +forge test --match-path "test/SolverRegistry.t.sol" +``` + +## Key Parameters + +| Parameter | Value | Tested Boundaries | +|-----------|-------|-------------------| +| MINIMUM_BOND | 0.1 ETH | 0, 1 wei, MIN-1, MIN, MIN+1 | +| MAX_BATCH_SIZE | 50 | 0, 1, 50, 51 | +| CHALLENGE_WINDOW | 1 hour | 14m59s, 15m, 24h, 24h+1s | +| MAX_JAILS | 3 | Jail #2 (jailed), Jail #3 (banned) | +| WITHDRAWAL_COOLDOWN | 7 days | 7d (fail), 7d+1s (pass) | +| ARBITRATION_TIMEOUT | 7 days | Before/after timeout | + +## Security Regression Policy + +Every discovered vulnerability gets a permanent regression test named `test_IRSB_SEC_NNN_*`: + +| ID | Vulnerability | Test | +|----|--------------|------| +| IRSB-SEC-001 | Cross-chain/contract replay | `test_IRSB_SEC_001_crossChainReplayPrevented` | +| IRSB-SEC-002 | Escalation DoS by third parties | Checked in DisputeModule tests | +| IRSB-SEC-003 | Re-challenge after rejected dispute | `test_IRSB_SEC_003_rejectedDisputeCannotBeRechallenged` | +| IRSB-SEC-005 | Zero-amount slash silent no-op | `test_IRSB_SEC_005_zeroSlashAmountReverts` | +| IRSB-SEC-006 | Same-chain nonce replay | Verified in all receipt posting tests | +| IRSB-SEC-009 | Batch post skipping validation | Verified in batch post tests | +| IRSB-SEC-010 | Zero-slash rounding in arbitration | Checked in DisputeModule resolve | diff --git a/test/helpers/MockETHRejecter.sol b/test/helpers/MockETHRejecter.sol new file mode 100644 index 0000000..95e2501 --- /dev/null +++ b/test/helpers/MockETHRejecter.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +/// @title MockETHRejecter +/// @notice Contract that rejects ETH transfers (no receive/fallback) +/// @dev Used to test all "Transfer failed" revert paths +contract MockETHRejecter { + // Intentionally no receive() or fallback() + // Any ETH sent to this contract will revert +} diff --git a/test/helpers/VerificationHelpers.sol b/test/helpers/VerificationHelpers.sol new file mode 100644 index 0000000..936d467 --- /dev/null +++ b/test/helpers/VerificationHelpers.sol @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import { Test } from "forge-std/Test.sol"; +import { SolverRegistry } from "../../src/SolverRegistry.sol"; +import { IntentReceiptHub } from "../../src/IntentReceiptHub.sol"; +import { EscrowVault } from "../../src/EscrowVault.sol"; +import { IEscrowVault } from "../../src/interfaces/IEscrowVault.sol"; +import { Types } from "../../src/libraries/Types.sol"; + +/// @title VerificationHelpers - Moloch DAO-Style State Verification +/// @notice Reusable assertions for checking ALL fields after state transitions +/// @dev Inherit this in test contracts to get comprehensive verification functions +abstract contract VerificationHelpers is Test { + /// @notice Verify solver state after a bond deposit + function verifyPostDeposit( + SolverRegistry registry, + bytes32 solverId, + uint256 expectedBond, + Types.SolverStatus expectedStatus, + uint256 expectedTotalBonded + ) internal view { + Types.Solver memory solver = registry.getSolver(solverId); + assertEq(solver.bondBalance, expectedBond, "Bond balance mismatch"); + assertEq(uint256(solver.status), uint256(expectedStatus), "Status mismatch after deposit"); + assertEq(registry.totalBonded(), expectedTotalBonded, "Total bonded mismatch"); + } + + /// @notice Verify solver state after a slash + function verifyPostSlash( + SolverRegistry registry, + bytes32 solverId, + uint256 expectedBond, + uint256 expectedLocked, + uint64 expectedDisputesLost, + Types.SolverStatus expectedStatus + ) internal view { + Types.Solver memory solver = registry.getSolver(solverId); + assertEq(solver.bondBalance, expectedBond, "Bond balance mismatch after slash"); + assertEq(solver.lockedBalance, expectedLocked, "Locked balance mismatch after slash"); + assertEq(solver.score.disputesLost, expectedDisputesLost, "Disputes lost mismatch"); + assertEq(uint256(solver.status), uint256(expectedStatus), "Status mismatch after slash"); + } + + /// @notice Verify receipt and dispute state after dispute opening + function verifyPostDispute( + IntentReceiptHub hub, + bytes32 receiptId, + Types.ReceiptStatus expectedStatus, + address expectedChallenger, + uint256 expectedBond + ) internal view { + (, Types.ReceiptStatus status) = hub.getReceipt(receiptId); + assertEq(uint256(status), uint256(expectedStatus), "Receipt status mismatch after dispute"); + + Types.Dispute memory dispute = hub.getDispute(receiptId); + assertEq(dispute.challenger, expectedChallenger, "Challenger mismatch"); + assertFalse(dispute.resolved, "Dispute should not be resolved yet"); + assertEq(hub.getChallengerBond(receiptId), expectedBond, "Challenger bond mismatch"); + } + + /// @notice Verify receipt state after finalization + function verifyPostFinalization( + IntentReceiptHub hub, + SolverRegistry registry, + bytes32 receiptId, + bytes32 solverId, + Types.ReceiptStatus expectedStatus, + uint64 expectedTotalFills + ) internal view { + (, Types.ReceiptStatus status) = hub.getReceipt(receiptId); + assertEq(uint256(status), uint256(expectedStatus), "Receipt status mismatch after finalization"); + + Types.IntentScore memory score = registry.getIntentScore(solverId); + assertEq(score.totalFills, expectedTotalFills, "Total fills mismatch after finalization"); + } + + /// @notice Verify escrow state + function verifyEscrowState( + EscrowVault vault, + bytes32 escrowId, + IEscrowVault.EscrowStatus expectedStatus, + uint256 expectedAmount + ) internal view { + IEscrowVault.Escrow memory escrow = vault.getEscrow(escrowId); + assertEq(uint256(escrow.status), uint256(expectedStatus), "Escrow status mismatch"); + assertEq(escrow.amount, expectedAmount, "Escrow amount mismatch"); + } +} diff --git a/test/moloch/BoundaryTests.t.sol b/test/moloch/BoundaryTests.t.sol new file mode 100644 index 0000000..f0f6752 --- /dev/null +++ b/test/moloch/BoundaryTests.t.sol @@ -0,0 +1,492 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import { Test } from "forge-std/Test.sol"; +import { SolverRegistry } from "../../src/SolverRegistry.sol"; +import { IntentReceiptHub } from "../../src/IntentReceiptHub.sol"; +import { EscrowVault } from "../../src/EscrowVault.sol"; +import { IEscrowVault } from "../../src/interfaces/IEscrowVault.sol"; +import { Types } from "../../src/libraries/Types.sol"; +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import { MessageHashUtils } from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; + +/// @title BoundaryTests - Moloch DAO-Style Boundary Condition Testing +/// @notice Systematic 0, 1, MAX, MAX-1, MAX+1 for every numeric parameter +/// @dev Naming: test_boundary_[Contract]_[parameter]_[condition] +contract BoundaryTestsTest is Test { + using ECDSA for bytes32; + using MessageHashUtils for bytes32; + + SolverRegistry public registry; + IntentReceiptHub public hub; + EscrowVault public vault; + + address public owner; + uint256 public operatorKey = 0x1234; + address public operator; + address public challenger = address(0x2); + + uint256 public constant MINIMUM_BOND = 0.1 ether; + + function setUp() public { + owner = address(this); + operator = vm.addr(operatorKey); + + vm.deal(owner, 100 ether); + vm.deal(operator, 100 ether); + vm.deal(challenger, 100 ether); + + registry = new SolverRegistry(); + hub = new IntentReceiptHub(address(registry)); + vault = new EscrowVault(); + + registry.setAuthorizedCaller(address(hub), true); + registry.setAuthorizedCaller(address(this), true); + vault.setAuthorizedHub(address(this), true); + } + + receive() external payable { } + + // ============ Helpers ============ + + function _registerAndActivate() internal returns (bytes32 solverId) { + solverId = registry.registerSolver("ipfs://test", operator); + vm.prank(operator); + registry.depositBond{ value: 0.5 ether }(solverId); + } + + function _createSignedReceipt(bytes32 solverId, bytes32 intentHash, uint64 expiry) + internal + view + returns (Types.IntentReceipt memory receipt) + { + receipt = Types.IntentReceipt({ + intentHash: intentHash, + constraintsHash: keccak256("constraints"), + routeHash: keccak256("route"), + outcomeHash: keccak256("outcome"), + evidenceHash: keccak256("evidence"), + createdAt: uint64(block.timestamp), + expiry: expiry, + solverId: solverId, + solverSig: "" + }); + + uint256 currentNonce = hub.solverNonces(solverId); + bytes32 messageHash = keccak256( + abi.encode( + block.chainid, + address(hub), + currentNonce, + receipt.intentHash, + receipt.constraintsHash, + receipt.routeHash, + receipt.outcomeHash, + receipt.evidenceHash, + receipt.createdAt, + receipt.expiry, + receipt.solverId + ) + ); + bytes32 ethSignedHash = messageHash.toEthSignedMessageHash(); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(operatorKey, ethSignedHash); + receipt.solverSig = abi.encodePacked(r, s, v); + } + + // ================================================================ + // BOND AMOUNT BOUNDARIES (SolverRegistry) + // ================================================================ + + /// @notice 0 ETH deposit reverts + function test_boundary_SolverRegistry_bondDeposit_zero() public { + bytes32 solverId = registry.registerSolver("ipfs://test", operator); + + vm.prank(operator); + vm.expectRevert("Zero deposit"); + registry.depositBond{ value: 0 }(solverId); + } + + /// @notice 1 wei deposit: valid but stays Inactive + function test_boundary_SolverRegistry_bondDeposit_oneWei() public { + bytes32 solverId = registry.registerSolver("ipfs://test", operator); + + vm.prank(operator); + registry.depositBond{ value: 1 }(solverId); + + Types.Solver memory solver = registry.getSolver(solverId); + assertEq(solver.bondBalance, 1); + assertEq(uint256(solver.status), uint256(Types.SolverStatus.Inactive)); + } + + /// @notice MINIMUM_BOND - 1: stays Inactive + function test_boundary_SolverRegistry_bondDeposit_minimumMinusOne() public { + bytes32 solverId = registry.registerSolver("ipfs://test", operator); + + vm.prank(operator); + registry.depositBond{ value: MINIMUM_BOND - 1 }(solverId); + + Types.Solver memory solver = registry.getSolver(solverId); + assertEq(uint256(solver.status), uint256(Types.SolverStatus.Inactive)); + } + + /// @notice MINIMUM_BOND exact: activates + function test_boundary_SolverRegistry_bondDeposit_minimumExact() public { + bytes32 solverId = registry.registerSolver("ipfs://test", operator); + + vm.prank(operator); + registry.depositBond{ value: MINIMUM_BOND }(solverId); + + Types.Solver memory solver = registry.getSolver(solverId); + assertEq(uint256(solver.status), uint256(Types.SolverStatus.Active)); + } + + /// @notice MINIMUM_BOND + 1: activates + function test_boundary_SolverRegistry_bondDeposit_minimumPlusOne() public { + bytes32 solverId = registry.registerSolver("ipfs://test", operator); + + vm.prank(operator); + registry.depositBond{ value: MINIMUM_BOND + 1 }(solverId); + + Types.Solver memory solver = registry.getSolver(solverId); + assertEq(uint256(solver.status), uint256(Types.SolverStatus.Active)); + assertEq(solver.bondBalance, MINIMUM_BOND + 1); + } + + // ================================================================ + // CHALLENGE WINDOW BOUNDARIES (IntentReceiptHub) + // ================================================================ + + /// @notice 14m59s: fails (too short) + function test_boundary_IntentReceiptHub_challengeWindow_14m59s() public { + vm.expectRevert("Window too short"); + hub.setChallengeWindow(14 minutes + 59 seconds); + } + + /// @notice 15m exact: passes (minimum valid) + function test_boundary_IntentReceiptHub_challengeWindow_15m() public { + hub.setChallengeWindow(15 minutes); + assertEq(hub.challengeWindow(), 15 minutes); + } + + /// @notice 24h exact: passes (maximum valid) + function test_boundary_IntentReceiptHub_challengeWindow_24h() public { + hub.setChallengeWindow(24 hours); + assertEq(hub.challengeWindow(), 24 hours); + } + + /// @notice 24h+1s: fails (too long) + function test_boundary_IntentReceiptHub_challengeWindow_24h1s() public { + vm.expectRevert("Window too long"); + hub.setChallengeWindow(24 hours + 1 seconds); + } + + // ================================================================ + // BATCH SIZE BOUNDARIES (IntentReceiptHub) + // ================================================================ + + /// @notice 0 items: fails + function test_boundary_IntentReceiptHub_batchSize_zero() public { + Types.IntentReceipt[] memory empty = new Types.IntentReceipt[](0); + + vm.prank(operator); + vm.expectRevert("Empty batch"); + hub.batchPostReceipts(empty); + } + + /// @notice 1 item: valid minimum batch + function test_boundary_IntentReceiptHub_batchSize_one() public { + bytes32 solverId = _registerAndActivate(); + + Types.IntentReceipt[] memory batch = new Types.IntentReceipt[](1); + batch[0] = _createSignedReceipt(solverId, keccak256("intent1"), uint64(block.timestamp + 1 hours)); + + vm.prank(operator); + bytes32[] memory ids = hub.batchPostReceipts(batch); + assertEq(ids.length, 1); + assertTrue(ids[0] != bytes32(0)); + } + + /// @notice 50 items: maximum valid batch + function test_boundary_IntentReceiptHub_batchSize_max() public { + bytes32 solverId = _registerAndActivate(); + + Types.IntentReceipt[] memory batch = new Types.IntentReceipt[](50); + uint256 baseNonce = hub.solverNonces(solverId); + + for (uint256 i = 0; i < 50; i++) { + batch[i] = Types.IntentReceipt({ + intentHash: keccak256(abi.encodePacked("intent", i)), + constraintsHash: keccak256("constraints"), + routeHash: keccak256("route"), + outcomeHash: keccak256("outcome"), + evidenceHash: keccak256("evidence"), + createdAt: uint64(block.timestamp + i), + expiry: uint64(block.timestamp + 1 hours + i), + solverId: solverId, + solverSig: "" + }); + + // Sign with sequential nonce + bytes32 messageHash = keccak256( + abi.encode( + block.chainid, + address(hub), + baseNonce + i, + batch[i].intentHash, + batch[i].constraintsHash, + batch[i].routeHash, + batch[i].outcomeHash, + batch[i].evidenceHash, + batch[i].createdAt, + batch[i].expiry, + batch[i].solverId + ) + ); + bytes32 ethSignedHash = messageHash.toEthSignedMessageHash(); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(operatorKey, ethSignedHash); + batch[i].solverSig = abi.encodePacked(r, s, v); + } + + vm.prank(operator); + bytes32[] memory ids = hub.batchPostReceipts(batch); + assertEq(ids.length, 50); + } + + /// @notice 51 items: exceeds maximum + function test_boundary_IntentReceiptHub_batchSize_maxPlusOne() public { + Types.IntentReceipt[] memory batch = new Types.IntentReceipt[](51); + + vm.prank(operator); + vm.expectRevert("Batch too large"); + hub.batchPostReceipts(batch); + } + + // ================================================================ + // ESCROW DEADLINE BOUNDARIES (EscrowVault) + // ================================================================ + + /// @notice deadline == block.timestamp: fails (not strictly future) + function test_boundary_EscrowVault_deadline_currentTimestamp() public { + vm.warp(1000); + vm.expectRevert(abi.encodeWithSignature("InvalidDeadline()")); + vault.createEscrow{ value: 1 ether }( + keccak256("e"), keccak256("r"), address(this), uint64(block.timestamp) + ); + } + + /// @notice deadline == block.timestamp + 1: passes (minimum valid) + function test_boundary_EscrowVault_deadline_currentPlusOne() public { + vm.warp(1000); + vault.createEscrow{ value: 1 ether }( + keccak256("e"), keccak256("r"), address(this), uint64(block.timestamp + 1) + ); + + IEscrowVault.Escrow memory escrow = vault.getEscrow(keccak256("e")); + assertEq(uint256(escrow.status), uint256(IEscrowVault.EscrowStatus.Active)); + } + + // ================================================================ + // FINALIZATION TIMING BOUNDARIES (IntentReceiptHub) + // ================================================================ + + /// @notice Finalize at exact challenge window end: fails (must be strictly after) + function test_boundary_IntentReceiptHub_finalize_exactWindowEnd() public { + bytes32 solverId = _registerAndActivate(); + Types.IntentReceipt memory receipt = + _createSignedReceipt(solverId, keccak256("intent"), uint64(block.timestamp + 1 hours)); + + vm.prank(operator); + bytes32 receiptId = hub.postReceipt(receipt); + + uint64 windowEnd = receipt.createdAt + hub.challengeWindow(); + vm.warp(windowEnd); + + vm.expectRevert(abi.encodeWithSignature("ChallengeWindowActive()")); + hub.finalize(receiptId); + } + + /// @notice Finalize 1 second after challenge window: passes + function test_boundary_IntentReceiptHub_finalize_windowEndPlusOne() public { + bytes32 solverId = _registerAndActivate(); + Types.IntentReceipt memory receipt = + _createSignedReceipt(solverId, keccak256("intent2"), uint64(block.timestamp + 1 hours)); + + vm.prank(operator); + bytes32 receiptId = hub.postReceipt(receipt); + + uint64 windowEnd = receipt.createdAt + hub.challengeWindow(); + vm.warp(windowEnd + 1); + + hub.finalize(receiptId); + + (, Types.ReceiptStatus status) = hub.getReceipt(receiptId); + assertEq(uint256(status), uint256(Types.ReceiptStatus.Finalized)); + } + + // ================================================================ + // JAIL COUNT BOUNDARIES (SolverRegistry) + // ================================================================ + + /// @notice MAX_JAILS - 1 (2nd jail): solver is Jailed + function test_boundary_SolverRegistry_jailCount_maxMinusOne() public { + bytes32 solverId = _registerAndActivate(); + + // First jail + registry.jailSolver(solverId); + assertEq(uint256(registry.getSolverStatus(solverId)), uint256(Types.SolverStatus.Jailed)); + + // Unjail + registry.unjailSolver(solverId); + + // Second jail (MAX_JAILS=3, this is jail #2) + registry.jailSolver(solverId); + assertEq(uint256(registry.getSolverStatus(solverId)), uint256(Types.SolverStatus.Jailed)); + } + + /// @notice MAX_JAILS (3rd jail): solver is permanently Banned + function test_boundary_SolverRegistry_jailCount_max() public { + bytes32 solverId = _registerAndActivate(); + + // Jail 1 → unjail + registry.jailSolver(solverId); + registry.unjailSolver(solverId); + + // Jail 2 → unjail + registry.jailSolver(solverId); + registry.unjailSolver(solverId); + + // Jail 3 → BANNED + registry.jailSolver(solverId); + assertEq(uint256(registry.getSolverStatus(solverId)), uint256(Types.SolverStatus.Banned)); + } + + // ================================================================ + // SLASH AMOUNT BOUNDARIES (SolverRegistry) + // ================================================================ + + /// @notice Zero-amount slash: reverts (IRSB-SEC-005) + function test_boundary_SolverRegistry_slashAmount_zero() public { + bytes32 solverId = _registerAndActivate(); + registry.lockBond(solverId, 0.1 ether); + + vm.expectRevert(abi.encodeWithSignature("ZeroSlashAmount()")); + registry.slash(solverId, 0, bytes32(uint256(1)), Types.DisputeReason.Timeout, address(0x5)); + } + + /// @notice Slash exact locked amount + function test_boundary_SolverRegistry_slashAmount_exactLocked() public { + bytes32 solverId = _registerAndActivate(); + registry.lockBond(solverId, 0.2 ether); + + address recipient = address(0x5); + registry.slash(solverId, 0.2 ether, bytes32(uint256(1)), Types.DisputeReason.Timeout, recipient); + + Types.Solver memory solver = registry.getSolver(solverId); + assertEq(solver.lockedBalance, 0, "Locked should be zeroed"); + assertEq(solver.bondBalance, 0.3 ether, "Available should be unchanged"); + } + + /// @notice Slash locked + 1 wei spills to available + function test_boundary_SolverRegistry_slashAmount_lockedPlusOne() public { + bytes32 solverId = _registerAndActivate(); + registry.lockBond(solverId, 0.2 ether); + // bondBalance = 0.3 ether, lockedBalance = 0.2 ether + + address recipient = address(0x6); + registry.slash(solverId, 0.2 ether + 1, bytes32(uint256(2)), Types.DisputeReason.Timeout, recipient); + + Types.Solver memory solver = registry.getSolver(solverId); + assertEq(solver.lockedBalance, 0, "Locked should be zeroed"); + assertEq(solver.bondBalance, 0.3 ether - 1, "1 wei should spill from available"); + } + + /// @notice Slash exact total bond (locked + available) + function test_boundary_SolverRegistry_slashAmount_exactTotalBond() public { + bytes32 solverId = _registerAndActivate(); + registry.lockBond(solverId, 0.2 ether); + // bondBalance = 0.3, lockedBalance = 0.2, total = 0.5 + + address recipient = address(0x7); + registry.slash(solverId, 0.5 ether, bytes32(uint256(3)), Types.DisputeReason.Timeout, recipient); + + Types.Solver memory solver = registry.getSolver(solverId); + assertEq(solver.lockedBalance, 0); + assertEq(solver.bondBalance, 0); + assertEq(uint256(solver.status), uint256(Types.SolverStatus.Inactive)); + } + + /// @notice Slash exceeding total bond: reverts + function test_boundary_SolverRegistry_slashAmount_exceedsTotalBond() public { + bytes32 solverId = _registerAndActivate(); + registry.lockBond(solverId, 0.2 ether); + + vm.expectRevert("Insufficient total bond"); + registry.slash(solverId, 0.5 ether + 1, bytes32(uint256(4)), Types.DisputeReason.Timeout, address(0x8)); + } + + // ================================================================ + // WITHDRAWAL COOLDOWN BOUNDARIES (SolverRegistry) + // ================================================================ + + /// @notice Withdraw at cooldown - 1 second: fails + function test_boundary_SolverRegistry_withdrawalCooldown_beforeExpiry() public { + bytes32 solverId = _registerAndActivate(); + vm.startPrank(operator); + registry.initiateWithdrawal(solverId); + + vm.warp(block.timestamp + 7 days - 1); + + vm.expectRevert(abi.encodeWithSignature("WithdrawalCooldownActive()")); + registry.withdrawBond(solverId, 0.1 ether); + vm.stopPrank(); + } + + /// @notice Withdraw at cooldown + 1 second: succeeds + function test_boundary_SolverRegistry_withdrawalCooldown_afterExpiry() public { + bytes32 solverId = _registerAndActivate(); + vm.startPrank(operator); + registry.initiateWithdrawal(solverId); + + vm.warp(block.timestamp + 7 days + 1); + + uint256 balBefore = operator.balance; + registry.withdrawBond(solverId, 0.1 ether); + assertEq(operator.balance - balBefore, 0.1 ether); + vm.stopPrank(); + } + + // ================================================================ + // CHALLENGER BOND BOUNDARIES (IntentReceiptHub) + // ================================================================ + + /// @notice Challenger bond exactly minimum: passes + function test_boundary_IntentReceiptHub_challengerBond_exactMinimum() public { + bytes32 solverId = _registerAndActivate(); + Types.IntentReceipt memory receipt = + _createSignedReceipt(solverId, keccak256("intent3"), uint64(block.timestamp + 1 hours)); + + vm.prank(operator); + bytes32 receiptId = hub.postReceipt(receipt); + + uint256 bondMin = hub.challengerBondMin(); + vm.prank(challenger); + hub.openDispute{ value: bondMin }(receiptId, Types.DisputeReason.Timeout, keccak256("ev")); + + assertEq(hub.getChallengerBond(receiptId), bondMin); + } + + /// @notice Challenger bond minimum - 1: fails + function test_boundary_IntentReceiptHub_challengerBond_belowMinimum() public { + bytes32 solverId = _registerAndActivate(); + Types.IntentReceipt memory receipt = + _createSignedReceipt(solverId, keccak256("intent4"), uint64(block.timestamp + 1 hours)); + + vm.prank(operator); + bytes32 receiptId = hub.postReceipt(receipt); + + uint256 bondMin = hub.challengerBondMin(); + vm.prank(challenger); + vm.expectRevert(abi.encodeWithSignature("InsufficientChallengerBond()")); + hub.openDispute{ value: bondMin - 1 }(receiptId, Types.DisputeReason.Timeout, keccak256("ev")); + } +} diff --git a/test/moloch/ModifierTests.t.sol b/test/moloch/ModifierTests.t.sol new file mode 100644 index 0000000..27d65a8 --- /dev/null +++ b/test/moloch/ModifierTests.t.sol @@ -0,0 +1,288 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import { Test } from "forge-std/Test.sol"; +import { SolverRegistry } from "../../src/SolverRegistry.sol"; +import { IntentReceiptHub } from "../../src/IntentReceiptHub.sol"; +import { DisputeModule } from "../../src/DisputeModule.sol"; +import { EscrowVault } from "../../src/EscrowVault.sol"; +import { Types } from "../../src/libraries/Types.sol"; +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import { MessageHashUtils } from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; + +/// @title ModifierTests - Moloch DAO-Style Modifier Pair Tests +/// @notice Tests each custom modifier with (allowed, rejected) pair +/// @dev Naming: test_modifier_[modifier]_[allows|rejects]_[who] +contract ModifierTestsTest is Test { + using ECDSA for bytes32; + using MessageHashUtils for bytes32; + + SolverRegistry public registry; + IntentReceiptHub public hub; + DisputeModule public disputeModule; + EscrowVault public vault; + + address public owner; + uint256 public operatorKey = 0x1234; + address public operator; + address public unauthorized = address(0x4); + address public arbitrator = address(0x3); + + bytes32 public solverId; + + function setUp() public { + owner = address(this); + operator = vm.addr(operatorKey); + + vm.deal(owner, 100 ether); + vm.deal(operator, 100 ether); + + registry = new SolverRegistry(); + hub = new IntentReceiptHub(address(registry)); + disputeModule = new DisputeModule(address(hub), address(registry), arbitrator); + vault = new EscrowVault(); + + registry.setAuthorizedCaller(address(hub), true); + hub.setDisputeModule(address(disputeModule)); + vault.setAuthorizedHub(address(hub), true); + + solverId = registry.registerSolver("ipfs://metadata", operator); + vm.prank(operator); + registry.depositBond{ value: 0.5 ether }(solverId); + } + + receive() external payable { } + + function _createSignedReceipt(bytes32 intentHash, uint64 expiry) + internal + view + returns (Types.IntentReceipt memory receipt) + { + receipt = Types.IntentReceipt({ + intentHash: intentHash, + constraintsHash: keccak256("constraints"), + routeHash: keccak256("route"), + outcomeHash: keccak256("outcome"), + evidenceHash: keccak256("evidence"), + createdAt: uint64(block.timestamp), + expiry: expiry, + solverId: solverId, + solverSig: "" + }); + + uint256 currentNonce = hub.solverNonces(solverId); + bytes32 messageHash = keccak256( + abi.encode( + block.chainid, + address(hub), + currentNonce, + receipt.intentHash, + receipt.constraintsHash, + receipt.routeHash, + receipt.outcomeHash, + receipt.evidenceHash, + receipt.createdAt, + receipt.expiry, + receipt.solverId + ) + ); + bytes32 ethSignedHash = messageHash.toEthSignedMessageHash(); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(operatorKey, ethSignedHash); + receipt.solverSig = abi.encodePacked(r, s, v); + } + + // ================================================================ + // onlyOperator (SolverRegistry) + // ================================================================ + + /// @notice onlyOperator allows the registered operator + function test_modifier_onlyOperator_allows_operator() public { + vm.prank(operator); + registry.initiateWithdrawal(solverId); + // Success - no revert + } + + /// @notice onlyOperator rejects non-operator + function test_modifier_onlyOperator_rejects_nonOperator() public { + vm.prank(unauthorized); + vm.expectRevert(abi.encodeWithSignature("NotSolverOperator()")); + registry.initiateWithdrawal(solverId); + } + + // ================================================================ + // solverExists (SolverRegistry) + // ================================================================ + + /// @notice solverExists allows existing solver + function test_modifier_solverExists_allows_existing() public { + Types.Solver memory solver = registry.getSolver(solverId); + assertTrue(solver.registeredAt > 0, "Solver should exist"); + + // depositBond uses solverExists - should succeed + vm.prank(operator); + registry.depositBond{ value: 0.01 ether }(solverId); + } + + /// @notice solverExists rejects non-existent solver + function test_modifier_solverExists_rejects_nonExistent() public { + bytes32 fakeSolverId = keccak256("nonexistent"); + + vm.expectRevert(abi.encodeWithSignature("SolverNotFound()")); + registry.depositBond{ value: 0.01 ether }(fakeSolverId); + } + + // ================================================================ + // receiptExists (IntentReceiptHub) + // ================================================================ + + /// @notice receiptExists allows existing receipt + function test_modifier_receiptExists_allows_existing() public { + Types.IntentReceipt memory receipt = + _createSignedReceipt(keccak256("intent"), uint64(block.timestamp + 1 hours)); + + vm.prank(operator); + bytes32 receiptId = hub.postReceipt(receipt); + + // finalize uses receiptExists - check we get ChallengeWindowActive (not ReceiptNotFound) + vm.expectRevert(abi.encodeWithSignature("ChallengeWindowActive()")); + hub.finalize(receiptId); + } + + /// @notice receiptExists rejects non-existent receipt + function test_modifier_receiptExists_rejects_nonExistent() public { + bytes32 fakeReceiptId = keccak256("nonexistent"); + + vm.expectRevert(abi.encodeWithSignature("ReceiptNotFound()")); + hub.finalize(fakeReceiptId); + } + + // ================================================================ + // onlyAuthorized (SolverRegistry) + // ================================================================ + + /// @notice onlyAuthorized allows authorized caller + function test_modifier_onlyAuthorized_allows_authorizedCaller() public { + // hub is authorized - use lockBond which requires onlyAuthorized + registry.setAuthorizedCaller(address(this), true); + registry.lockBond(solverId, 0.01 ether); + // Success - no revert + } + + /// @notice onlyAuthorized allows owner + function test_modifier_onlyAuthorized_allows_owner() public { + // owner can call onlyAuthorized functions directly + registry.updateScore(solverId, true, 100); + // Success - no revert + } + + /// @notice onlyAuthorized rejects unauthorized caller + function test_modifier_onlyAuthorized_rejects_unauthorized() public { + vm.prank(unauthorized); + vm.expectRevert("Not authorized"); + registry.lockBond(solverId, 0.01 ether); + } + + // ================================================================ + // onlyHub (EscrowVault) + // ================================================================ + + /// @notice onlyHub allows authorized hub + function test_modifier_onlyHub_allows_hub() public { + bytes32 escrowId = keccak256("escrow"); + vault.createEscrow{ value: 1 ether }( + escrowId, keccak256("receipt"), address(this), uint64(block.timestamp + 1 hours) + ); + + // Test contract is authorized hub (set in setUp) + vault.release(escrowId, address(this)); + // Success - no revert + } + + /// @notice onlyHub allows owner (as fallback) + function test_modifier_onlyHub_allows_owner() public { + bytes32 escrowId = keccak256("escrow2"); + vault.createEscrow{ value: 1 ether }( + escrowId, keccak256("receipt2"), address(this), uint64(block.timestamp + 1 hours) + ); + + // Owner (address(this)) can call onlyHub functions + vault.release(escrowId, address(this)); + } + + /// @notice onlyHub rejects unauthorized caller + function test_modifier_onlyHub_rejects_other() public { + bytes32 escrowId = keccak256("escrow3"); + vault.createEscrow{ value: 1 ether }( + escrowId, keccak256("receipt3"), address(this), uint64(block.timestamp + 1 hours) + ); + + vm.prank(unauthorized); + vm.expectRevert(abi.encodeWithSignature("UnauthorizedCaller()")); + vault.release(escrowId, address(this)); + } + + // ================================================================ + // onlyArbitrator (DisputeModule) + // ================================================================ + + /// @notice onlyArbitrator allows the arbitrator + function test_modifier_onlyArbitrator_allows_arbitrator() public { + // resolve requires escalated dispute, so just test the modifier reverts correctly + // with non-arbitrator + vm.prank(arbitrator); + // Will fail with "Not escalated" (past the modifier), proving modifier passed + vm.expectRevert("Not escalated"); + disputeModule.resolve(keccak256("fake"), true, 50, "reason"); + } + + /// @notice onlyArbitrator rejects non-arbitrator + function test_modifier_onlyArbitrator_rejects_nonArbitrator() public { + vm.prank(unauthorized); + vm.expectRevert(abi.encodeWithSignature("NotAuthorizedArbitrator()")); + disputeModule.resolve(keccak256("fake"), true, 50, "reason"); + } + + // ================================================================ + // onlyDisputeModule (IntentReceiptHub) + // ================================================================ + + /// @notice onlyDisputeModule allows dispute module + function test_modifier_onlyDisputeModule_allows_module() public { + Types.IntentReceipt memory receipt = + _createSignedReceipt(keccak256("intent2"), uint64(block.timestamp + 1 hours)); + + vm.prank(operator); + bytes32 receiptId = hub.postReceipt(receipt); + + // Call from dispute module - will fail with ReceiptNotPending (past modifier) + vm.prank(address(disputeModule)); + vm.expectRevert(abi.encodeWithSignature("ReceiptNotPending()")); + hub.resolveEscalatedDispute(receiptId, true); + } + + /// @notice onlyDisputeModule allows owner as fallback + function test_modifier_onlyDisputeModule_allows_owner() public { + Types.IntentReceipt memory receipt = + _createSignedReceipt(keccak256("intent3"), uint64(block.timestamp + 1 hours)); + + vm.prank(operator); + bytes32 receiptId = hub.postReceipt(receipt); + + // Owner can call onlyDisputeModule functions - will fail with ReceiptNotPending + vm.expectRevert(abi.encodeWithSignature("ReceiptNotPending()")); + hub.resolveEscalatedDispute(receiptId, true); + } + + /// @notice onlyDisputeModule rejects other callers + function test_modifier_onlyDisputeModule_rejects_other() public { + Types.IntentReceipt memory receipt = + _createSignedReceipt(keccak256("intent4"), uint64(block.timestamp + 1 hours)); + + vm.prank(operator); + bytes32 receiptId = hub.postReceipt(receipt); + + vm.prank(unauthorized); + vm.expectRevert("Not dispute module"); + hub.resolveEscalatedDispute(receiptId, true); + } +} diff --git a/test/moloch/RequireAudit.t.sol b/test/moloch/RequireAudit.t.sol new file mode 100644 index 0000000..1222761 --- /dev/null +++ b/test/moloch/RequireAudit.t.sol @@ -0,0 +1,707 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import { Test } from "forge-std/Test.sol"; +import { SolverRegistry } from "../../src/SolverRegistry.sol"; +import { IntentReceiptHub } from "../../src/IntentReceiptHub.sol"; +import { DisputeModule } from "../../src/DisputeModule.sol"; +import { EscrowVault } from "../../src/EscrowVault.sol"; +import { WalletDelegate } from "../../src/delegation/WalletDelegate.sol"; +import { X402Facilitator } from "../../src/X402Facilitator.sol"; +import { Types } from "../../src/libraries/Types.sol"; +import { TypesDelegation } from "../../src/libraries/TypesDelegation.sol"; +import { IWalletDelegate } from "../../src/interfaces/IWalletDelegate.sol"; +import { MockERC20 } from "../../src/mocks/MockERC20.sol"; +import { MockETHRejecter } from "../helpers/MockETHRejecter.sol"; +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import { MessageHashUtils } from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; + +/// @title RequireAudit - Moloch DAO-Style Require/Revert Coverage +/// @notice Triggers every untested require/revert path across IRSB contracts +/// @dev Naming: test_requireFail_[Contract]_[function]_[reason] +contract RequireAuditTest is Test { + using ECDSA for bytes32; + using MessageHashUtils for bytes32; + + // ============ Contracts ============ + + SolverRegistry public registry; + IntentReceiptHub public hub; + DisputeModule public disputeModule; + EscrowVault public vault; + WalletDelegate public walletDelegate; + X402Facilitator public facilitator; + MockERC20 public usdc; + MockETHRejecter public rejecter; + + // ============ Actors ============ + + address public owner; + uint256 public operatorKey = 0x1234; + address public operator; + address public challenger = address(0x2); + address public arbitrator = address(0x3); + address public unauthorized = address(0x4); + + uint256 public constant MINIMUM_BOND = 0.1 ether; + + bytes32 public solverId; + + // ============ Setup ============ + + function setUp() public { + owner = address(this); + operator = vm.addr(operatorKey); + + vm.deal(owner, 100 ether); + vm.deal(operator, 100 ether); + vm.deal(challenger, 100 ether); + vm.deal(arbitrator, 10 ether); + + // Deploy core contracts + registry = new SolverRegistry(); + hub = new IntentReceiptHub(address(registry)); + disputeModule = new DisputeModule(address(hub), address(registry), arbitrator); + vault = new EscrowVault(); + walletDelegate = new WalletDelegate(); + facilitator = new X402Facilitator(address(walletDelegate), address(hub)); + usdc = new MockERC20("USD Coin", "USDC", 6); + rejecter = new MockETHRejecter(); + + // Wire up + registry.setAuthorizedCaller(address(hub), true); + hub.setDisputeModule(address(disputeModule)); + vault.setAuthorizedHub(address(hub), true); + + // Register and activate solver + solverId = registry.registerSolver("ipfs://metadata", operator); + registry.depositBond{ value: 0.5 ether }(solverId); + } + + receive() external payable { } + + // ============ Helpers ============ + + function _createSignedReceipt(bytes32 intentHash, uint64 expiry) + internal + view + returns (Types.IntentReceipt memory receipt) + { + receipt = Types.IntentReceipt({ + intentHash: intentHash, + constraintsHash: keccak256("constraints"), + routeHash: keccak256("route"), + outcomeHash: keccak256("outcome"), + evidenceHash: keccak256("evidence"), + createdAt: uint64(block.timestamp), + expiry: expiry, + solverId: solverId, + solverSig: "" + }); + + uint256 currentNonce = hub.solverNonces(solverId); + bytes32 messageHash = keccak256( + abi.encode( + block.chainid, + address(hub), + currentNonce, + receipt.intentHash, + receipt.constraintsHash, + receipt.routeHash, + receipt.outcomeHash, + receipt.evidenceHash, + receipt.createdAt, + receipt.expiry, + receipt.solverId + ) + ); + bytes32 ethSignedHash = messageHash.toEthSignedMessageHash(); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(operatorKey, ethSignedHash); + receipt.solverSig = abi.encodePacked(r, s, v); + } + + function _postReceipt(bytes32 intentHash, uint64 expiry) internal returns (bytes32 receiptId) { + Types.IntentReceipt memory receipt = _createSignedReceipt(intentHash, expiry); + vm.prank(operator); + receiptId = hub.postReceipt(receipt); + } + + function _openDispute(bytes32 receiptId) internal { + vm.prank(challenger); + hub.openDispute{ value: hub.challengerBondMin() }( + receiptId, Types.DisputeReason.Timeout, keccak256("evidence") + ); + } + + // ================================================================ + // SOLVER REGISTRY TESTS + // ================================================================ + + /// @notice depositBond reverts when caller is not operator or owner + function test_requireFail_SolverRegistry_depositBond_unauthorizedDepositor() public { + vm.deal(unauthorized, 1 ether); + vm.prank(unauthorized); + vm.expectRevert("Not authorized to deposit"); + registry.depositBond{ value: MINIMUM_BOND }(solverId); + } + + /// @notice withdrawBond reverts when amount exceeds available balance + function test_requireFail_SolverRegistry_withdrawBond_insufficientBond() public { + vm.startPrank(operator); + registry.initiateWithdrawal(solverId); + vm.warp(block.timestamp + 7 days + 1); + vm.expectRevert(abi.encodeWithSignature("InsufficientBond()")); + registry.withdrawBond(solverId, 100 ether); // way more than deposited + vm.stopPrank(); + } + + /// @notice withdrawBond reverts on ETH transfer to rejecting contract + function test_requireFail_SolverRegistry_withdrawBond_transferFailed() public { + // Register solver with rejecter as operator + address rejecterAddr = address(rejecter); + bytes32 rejecterId = registry.registerSolver("ipfs://rejecter", rejecterAddr); + registry.depositBond{ value: 0.2 ether }(rejecterId); + + // Initiate withdrawal as rejecter + vm.prank(rejecterAddr); + registry.initiateWithdrawal(rejecterId); + + vm.warp(block.timestamp + 7 days + 1); + + vm.prank(rejecterAddr); + vm.expectRevert("Transfer failed"); + registry.withdrawBond(rejecterId, 0.1 ether); + } + + /// @notice slash reverts when total bond is insufficient + function test_requireFail_SolverRegistry_slash_insufficientTotalBond() public { + // Lock some bond first + registry.setAuthorizedCaller(address(this), true); + registry.lockBond(solverId, 0.1 ether); + + // Try to slash more than total (locked + available) + vm.expectRevert("Insufficient total bond"); + registry.slash(solverId, 10 ether, bytes32(uint256(1)), Types.DisputeReason.Timeout, address(0x5)); + } + + /// @notice slash reverts on ETH transfer to rejecting recipient + function test_requireFail_SolverRegistry_slash_transferFailed() public { + registry.setAuthorizedCaller(address(this), true); + registry.lockBond(solverId, 0.1 ether); + + vm.expectRevert("Slash transfer failed"); + registry.slash(solverId, 0.05 ether, bytes32(uint256(1)), Types.DisputeReason.Timeout, address(rejecter)); + } + + /// @notice unjailSolver reverts when called on Active solver + function test_requireFail_SolverRegistry_unjailSolver_notJailed_active() public { + // solver is Active + vm.expectRevert("Not jailed"); + registry.unjailSolver(solverId); + } + + /// @notice unjailSolver reverts when called on Banned solver + function test_requireFail_SolverRegistry_unjailSolver_notJailed_banned() public { + registry.banSolver(solverId); + + vm.expectRevert("Not jailed"); + registry.unjailSolver(solverId); + } + + /// @notice setSolverKey reverts when new operator is already registered + function test_requireFail_SolverRegistry_setSolverKey_alreadyRegistered() public { + address operator2 = address(0x10); + registry.registerSolver("ipfs://other", operator2); + + vm.prank(operator); + vm.expectRevert(abi.encodeWithSignature("SolverAlreadyRegistered()")); + registry.setSolverKey(solverId, operator2); + } + + /// @notice setSolverKey reverts when new operator is zero address + function test_requireFail_SolverRegistry_setSolverKey_zeroAddress() public { + vm.prank(operator); + vm.expectRevert(abi.encodeWithSignature("InvalidOperatorAddress()")); + registry.setSolverKey(solverId, address(0)); + } + + /// @notice registerSolver emits exact EnforcedPause error when paused + function test_requireFail_SolverRegistry_registerSolver_paused() public { + registry.pause(); + + vm.expectRevert(abi.encodeWithSignature("EnforcedPause()")); + registry.registerSolver("ipfs://new", address(0x20)); + } + + /// @notice initiateWithdrawal reverts when bond is locked + function test_requireFail_SolverRegistry_initiateWithdrawal_bondLocked() public { + registry.setAuthorizedCaller(address(this), true); + registry.lockBond(solverId, 0.1 ether); + + vm.prank(operator); + vm.expectRevert(abi.encodeWithSignature("BondLocked()")); + registry.initiateWithdrawal(solverId); + } + + /// @notice setAuthorizedCaller reverts for non-owner + function test_requireFail_SolverRegistry_setAuthorizedCaller_nonOwner() public { + vm.prank(unauthorized); + vm.expectRevert(); + registry.setAuthorizedCaller(unauthorized, true); + } + + /// @notice updateScore reverts for non-authorized caller + function test_requireFail_SolverRegistry_updateScore_unauthorized() public { + vm.prank(unauthorized); + vm.expectRevert("Not authorized"); + registry.updateScore(solverId, true, 100); + } + + // ================================================================ + // INTENT RECEIPT HUB TESTS + // ================================================================ + + /// @notice batchPostReceipts reverts on empty array + function test_requireFail_IntentReceiptHub_batchPostReceipts_emptyBatch() public { + Types.IntentReceipt[] memory empty = new Types.IntentReceipt[](0); + + vm.prank(operator); + vm.expectRevert("Empty batch"); + hub.batchPostReceipts(empty); + } + + /// @notice batchPostReceipts reverts when array exceeds MAX_BATCH_SIZE (51) + function test_requireFail_IntentReceiptHub_batchPostReceipts_batchTooLarge() public { + Types.IntentReceipt[] memory big = new Types.IntentReceipt[](51); + + vm.prank(operator); + vm.expectRevert("Batch too large"); + hub.batchPostReceipts(big); + } + + /// @notice openDispute reverts on non-existent receipt ID + function test_requireFail_IntentReceiptHub_openDispute_receiptNotFound() public { + bytes32 fakeReceiptId = keccak256("nonexistent"); + uint256 bondMin = hub.challengerBondMin(); + + vm.prank(challenger); + vm.expectRevert(abi.encodeWithSignature("ReceiptNotFound()")); + hub.openDispute{ value: bondMin }( + fakeReceiptId, Types.DisputeReason.Timeout, keccak256("evidence") + ); + } + + /// @notice resolveEscalatedDispute reverts for non-dispute-module caller + function test_requireFail_IntentReceiptHub_resolveEscalatedDispute_notDisputeModule() public { + bytes32 receiptId = _postReceipt(keccak256("intent"), uint64(block.timestamp + 1 hours)); + + vm.prank(unauthorized); + vm.expectRevert("Not dispute module"); + hub.resolveEscalatedDispute(receiptId, true); + } + + /// @notice resolveEscalatedDispute reverts when dispute is already resolved + function test_requireFail_IntentReceiptHub_resolveEscalatedDispute_alreadyResolved() public { + bytes32 receiptId = _postReceipt(keccak256("intent"), uint64(block.timestamp + 30 minutes)); + _openDispute(receiptId); + + // Resolve via deterministic first + vm.warp(block.timestamp + 31 minutes); + hub.resolveDeterministic(receiptId); + + // Try to resolve again via dispute module (owner can act as disputeModule) + vm.expectRevert(abi.encodeWithSignature("ReceiptNotPending()")); + hub.resolveEscalatedDispute(receiptId, true); + } + + /// @notice sweepForfeitedBonds reverts on failing treasury transfer + function test_requireFail_IntentReceiptHub_sweepForfeitedBonds_transferFailed() public { + // Create forfeited bonds + bytes32 receiptId = _postReceipt(keccak256("intent"), uint64(block.timestamp + 30 minutes)); + vm.prank(operator); + hub.submitSettlementProof(receiptId, keccak256("proof")); + _openDispute(receiptId); + vm.warp(block.timestamp + 31 minutes); + hub.resolveDeterministic(receiptId); + + // Sweep to rejecter + vm.expectRevert(abi.encodeWithSignature("SweepTransferFailed()")); + hub.sweepForfeitedBonds(address(rejecter)); + } + + /// @notice setChallengeWindow boundary: 14m59s fails + function test_requireFail_IntentReceiptHub_setChallengeWindow_tooShort() public { + vm.expectRevert("Window too short"); + hub.setChallengeWindow(14 minutes + 59 seconds); + } + + /// @notice setChallengeWindow boundary: 24h+1s fails + function test_requireFail_IntentReceiptHub_setChallengeWindow_tooLong() public { + vm.expectRevert("Window too long"); + hub.setChallengeWindow(24 hours + 1 seconds); + } + + /// @notice finalize reverts on non-existent receipt + function test_requireFail_IntentReceiptHub_finalize_receiptNotFound() public { + bytes32 fakeId = keccak256("nonexistent"); + vm.expectRevert(abi.encodeWithSignature("ReceiptNotFound()")); + hub.finalize(fakeId); + } + + /// @notice resolveEscalatedDispute reverts on receipt not in Disputed status + function test_requireFail_IntentReceiptHub_resolveEscalatedDispute_notDisputed() public { + bytes32 receiptId = _postReceipt(keccak256("intent"), uint64(block.timestamp + 1 hours)); + + // Try to resolve escalated dispute on Pending receipt (from disputeModule/owner) + vm.expectRevert(abi.encodeWithSignature("ReceiptNotPending()")); + hub.resolveEscalatedDispute(receiptId, true); + } + + // ================================================================ + // DISPUTE MODULE TESTS + // ================================================================ + + /// @notice resolve reverts when dispute is not escalated + function test_requireFail_DisputeModule_resolve_notEscalated() public { + bytes32 receiptId = _postReceipt(keccak256("intent"), uint64(block.timestamp + 1 hours)); + _openDispute(receiptId); + + vm.prank(arbitrator); + vm.expectRevert("Not escalated"); + disputeModule.resolve(receiptId, true, 50, "reason"); + } + + /// @notice setArbitrator reverts on zero address + function test_requireFail_DisputeModule_setArbitrator_zeroAddress() public { + vm.expectRevert("Zero address"); + disputeModule.setArbitrator(address(0)); + } + + /// @notice setTreasury reverts on zero address + function test_requireFail_DisputeModule_setTreasury_zeroAddress() public { + vm.expectRevert("Zero address"); + disputeModule.setTreasury(address(0)); + } + + /// @notice withdrawFees reverts when nothing to withdraw + function test_requireFail_DisputeModule_withdrawFees_noFees() public { + vm.expectRevert(abi.encodeWithSignature("NoFeesToWithdraw()")); + disputeModule.withdrawFees(); + } + + /// @notice resolve reverts for non-arbitrator caller + function test_requireFail_DisputeModule_resolve_notArbitrator() public { + vm.prank(unauthorized); + vm.expectRevert(abi.encodeWithSignature("NotAuthorizedArbitrator()")); + disputeModule.resolve(keccak256("fake"), true, 50, "reason"); + } + + // ================================================================ + // ESCROW VAULT TESTS + // ================================================================ + + /// @notice release reverts on non-existent escrow + function test_requireFail_EscrowVault_release_escrowNotFound() public { + bytes32 fakeEscrowId = keccak256("nonexistent"); + + vm.expectRevert(abi.encodeWithSignature("EscrowNotFound()")); + vault.release(fakeEscrowId, address(0x5)); + } + + /// @notice refund reverts on non-existent escrow + function test_requireFail_EscrowVault_refund_escrowNotFound() public { + bytes32 fakeEscrowId = keccak256("nonexistent"); + + vm.expectRevert(abi.encodeWithSignature("EscrowNotFound()")); + vault.refund(fakeEscrowId); + } + + /// @notice emergencyWithdraw reverts with zero address + function test_requireFail_EscrowVault_emergencyWithdraw_zeroAddress() public { + vm.expectRevert(abi.encodeWithSignature("TransferFailed()")); + vault.emergencyWithdraw(address(0), 1 ether, address(0)); + } + + /// @notice emergencyWithdraw reverts on failed ETH transfer + function test_requireFail_EscrowVault_emergencyWithdraw_transferFailed() public { + vm.deal(address(vault), 1 ether); + + vm.expectRevert(abi.encodeWithSignature("TransferFailed()")); + vault.emergencyWithdraw(address(0), 1 ether, address(rejecter)); + } + + /// @notice release reverts on ETH transfer to rejecting recipient + function test_requireFail_EscrowVault_release_transferFailed() public { + bytes32 escrowId = keccak256("escrow1"); + bytes32 receiptId = keccak256("receipt1"); + + vault.createEscrow{ value: 1 ether }(escrowId, receiptId, address(this), uint64(block.timestamp + 1 hours)); + + vm.expectRevert(abi.encodeWithSignature("TransferFailed()")); + vault.release(escrowId, address(rejecter)); + } + + /// @notice refund reverts on ETH transfer to rejecting depositor + function test_requireFail_EscrowVault_refund_transferFailed() public { + bytes32 escrowId = keccak256("escrow2"); + bytes32 receiptId = keccak256("receipt2"); + + // Create escrow with rejecter as depositor + vault.createEscrow{ value: 1 ether }(escrowId, receiptId, address(rejecter), uint64(block.timestamp + 1 hours)); + + vm.expectRevert(abi.encodeWithSignature("TransferFailed()")); + vault.refund(escrowId); + } + + /// @notice release reverts with zero address recipient + function test_requireFail_EscrowVault_release_zeroRecipient() public { + bytes32 escrowId = keccak256("escrow3"); + bytes32 receiptId = keccak256("receipt3"); + + vault.createEscrow{ value: 1 ether }(escrowId, receiptId, address(this), uint64(block.timestamp + 1 hours)); + + vm.expectRevert(abi.encodeWithSignature("TransferFailed()")); + vault.release(escrowId, address(0)); + } + + /// @notice release/refund revert on non-active (already released) escrow + function test_requireFail_EscrowVault_release_escrowNotActive() public { + bytes32 escrowId = keccak256("escrow4"); + bytes32 receiptId = keccak256("receipt4"); + + vault.createEscrow{ value: 1 ether }(escrowId, receiptId, address(this), uint64(block.timestamp + 1 hours)); + vault.release(escrowId, address(this)); + + vm.expectRevert(abi.encodeWithSignature("EscrowNotActive()")); + vault.release(escrowId, address(this)); + } + + /// @notice createEscrow reverts with zero value + function test_requireFail_EscrowVault_createEscrow_invalidAmount() public { + vm.expectRevert(abi.encodeWithSignature("InvalidAmount()")); + vault.createEscrow(keccak256("e"), keccak256("r"), address(this), uint64(block.timestamp + 1)); + } + + /// @notice createEscrow reverts with zero receiptId + function test_requireFail_EscrowVault_createEscrow_invalidReceiptId() public { + vm.expectRevert(abi.encodeWithSignature("InvalidReceiptId()")); + vault.createEscrow{ value: 1 ether }(keccak256("e"), bytes32(0), address(this), uint64(block.timestamp + 1)); + } + + /// @notice createEscrow reverts when deadline is in the past + function test_requireFail_EscrowVault_createEscrow_invalidDeadline() public { + vm.warp(1000); + vm.expectRevert(abi.encodeWithSignature("InvalidDeadline()")); + vault.createEscrow{ value: 1 ether }( + keccak256("e"), keccak256("r"), address(this), uint64(block.timestamp) + ); + } + + /// @notice Unauthorized caller cannot release escrow + function test_requireFail_EscrowVault_release_unauthorizedCaller() public { + bytes32 escrowId = keccak256("escrow5"); + bytes32 receiptId = keccak256("receipt5"); + vault.createEscrow{ value: 1 ether }(escrowId, receiptId, address(this), uint64(block.timestamp + 1 hours)); + + vm.prank(unauthorized); + vm.expectRevert(abi.encodeWithSignature("UnauthorizedCaller()")); + vault.release(escrowId, address(this)); + } + + // ================================================================ + // WALLET DELEGATE TESTS + // ================================================================ + + /// @notice redeemDelegations reverts on length mismatch + function test_requireFail_WalletDelegate_redeemDelegations_lengthMismatch() public { + TypesDelegation.Delegation[] memory delegations = new TypesDelegation.Delegation[](1); + uint256[] memory modes = new uint256[](2); // Mismatch! + bytes[] memory execCalldata = new bytes[](1); + + vm.expectRevert(abi.encodeWithSelector(IWalletDelegate.LengthMismatch.selector)); + walletDelegate.redeemDelegations(delegations, modes, execCalldata); + } + + /// @notice redeemDelegations reverts on unsupported mode + function test_requireFail_WalletDelegate_redeemDelegations_unsupportedMode() public { + // Build a valid delegation first + uint256 delegatorKey_ = 0xA11CE; + address delegatorAddr = vm.addr(delegatorKey_); + + TypesDelegation.Caveat[] memory caveats = new TypesDelegation.Caveat[](0); + TypesDelegation.Delegation memory delegation; + delegation.delegator = delegatorAddr; + delegation.delegate = address(walletDelegate); + delegation.authority = bytes32(0); + delegation.caveats = caveats; + delegation.salt = 1; + + bytes32 structHash = TypesDelegation.hashDelegation(delegation); + bytes32 digest = + keccak256(abi.encodePacked("\x19\x01", walletDelegate.DOMAIN_SEPARATOR(), structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(delegatorKey_, digest); + delegation.signature = abi.encodePacked(r, s, v); + + walletDelegate.setupDelegation(delegation); + + TypesDelegation.Delegation[] memory delegations = new TypesDelegation.Delegation[](1); + delegations[0] = delegation; + + uint256[] memory modes = new uint256[](1); + modes[0] = 1; // Unsupported - only mode 0 allowed + + bytes[] memory execCalldata = new bytes[](1); + execCalldata[0] = abi.encode(TypesDelegation.ExecutionParams({ target: address(0x1), callData: "", value: 0 })); + + vm.expectRevert("Only call mode (0) supported"); + walletDelegate.redeemDelegations(delegations, modes, execCalldata); + } + + /// @notice redeemDelegations reverts when delegation not found + function test_requireFail_WalletDelegate_redeemDelegations_notFound() public { + TypesDelegation.Delegation[] memory delegations = new TypesDelegation.Delegation[](1); + // delegations[0] is default/zeroed — won't match any stored delegation + delegations[0].delegate = address(walletDelegate); + delegations[0].caveats = new TypesDelegation.Caveat[](0); + + uint256[] memory modes = new uint256[](1); + bytes[] memory execCalldata = new bytes[](1); + execCalldata[0] = abi.encode(TypesDelegation.ExecutionParams({ target: address(0x1), callData: "", value: 0 })); + + vm.expectRevert(abi.encodeWithSelector(IWalletDelegate.DelegationNotFound.selector)); + walletDelegate.redeemDelegations(delegations, modes, execCalldata); + } + + /// @notice revokeDelegation reverts on double revoke (DelegationNotActive) + function test_requireFail_WalletDelegate_revokeDelegation_doubleRevoke() public { + uint256 delegatorKey_ = 0xA11CE; + address delegatorAddr = vm.addr(delegatorKey_); + + TypesDelegation.Caveat[] memory caveats = new TypesDelegation.Caveat[](0); + TypesDelegation.Delegation memory delegation; + delegation.delegator = delegatorAddr; + delegation.delegate = address(walletDelegate); + delegation.authority = bytes32(0); + delegation.caveats = caveats; + delegation.salt = 100; + + bytes32 structHash = TypesDelegation.hashDelegation(delegation); + bytes32 digest = + keccak256(abi.encodePacked("\x19\x01", walletDelegate.DOMAIN_SEPARATOR(), structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(delegatorKey_, digest); + delegation.signature = abi.encodePacked(r, s, v); + + walletDelegate.setupDelegation(delegation); + bytes32 delegationHash = TypesDelegation.hashDelegation(delegation); + + vm.startPrank(delegatorAddr); + walletDelegate.revokeDelegation(delegationHash); + + vm.expectRevert(abi.encodeWithSelector(IWalletDelegate.DelegationNotActive.selector)); + walletDelegate.revokeDelegation(delegationHash); + vm.stopPrank(); + } + + /// @notice executeDelegated reverts when paused + function test_requireFail_WalletDelegate_executeDelegated_whenPaused() public { + uint256 delegatorKey_ = 0xA11CE; + address delegatorAddr = vm.addr(delegatorKey_); + + TypesDelegation.Caveat[] memory caveats = new TypesDelegation.Caveat[](0); + TypesDelegation.Delegation memory delegation; + delegation.delegator = delegatorAddr; + delegation.delegate = address(walletDelegate); + delegation.authority = bytes32(0); + delegation.caveats = caveats; + delegation.salt = 200; + + bytes32 structHash = TypesDelegation.hashDelegation(delegation); + bytes32 digest = + keccak256(abi.encodePacked("\x19\x01", walletDelegate.DOMAIN_SEPARATOR(), structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(delegatorKey_, digest); + delegation.signature = abi.encodePacked(r, s, v); + + walletDelegate.setupDelegation(delegation); + bytes32 delegationHash = TypesDelegation.hashDelegation(delegation); + + walletDelegate.pause(); + + vm.expectRevert(abi.encodeWithSignature("EnforcedPause()")); + walletDelegate.executeDelegated(delegationHash, address(0x1), "", 0); + } + + // ================================================================ + // X402 FACILITATOR TESTS + // ================================================================ + + function _makeSettlementParams(bytes32 paymentHash, uint256 amount) + internal + view + returns (TypesDelegation.SettlementParams memory) + { + return TypesDelegation.SettlementParams({ + paymentHash: paymentHash, + token: address(usdc), + amount: amount, + seller: address(0x5), + buyer: address(this), + receiptId: keccak256("receipt"), + intentHash: keccak256("intent"), + proof: "proof", + expiry: uint64(block.timestamp + 1 hours) + }); + } + + /// @notice settlePayment reverts with invalid (zero) paymentHash + function test_requireFail_X402Facilitator_settlePayment_invalidHash() public { + TypesDelegation.SettlementParams memory params = _makeSettlementParams(bytes32(0), 100e6); + + vm.expectRevert(abi.encodeWithSelector(X402Facilitator.InvalidPaymentHash.selector)); + facilitator.settlePayment(params); + } + + /// @notice settlePayment reverts with zero amount + function test_requireFail_X402Facilitator_settlePayment_zeroAmount() public { + TypesDelegation.SettlementParams memory params = _makeSettlementParams(keccak256("pay"), 0); + + vm.expectRevert(abi.encodeWithSelector(X402Facilitator.InvalidAmount.selector)); + facilitator.settlePayment(params); + } + + /// @notice settlePayment reverts with zero seller address + function test_requireFail_X402Facilitator_settlePayment_zeroSeller() public { + TypesDelegation.SettlementParams memory params = _makeSettlementParams(keccak256("pay"), 100e6); + params.seller = address(0); + + vm.expectRevert(abi.encodeWithSelector(X402Facilitator.InvalidSeller.selector)); + facilitator.settlePayment(params); + } + + /// @notice settlePayment reverts with zero buyer address + function test_requireFail_X402Facilitator_settlePayment_zeroBuyer() public { + TypesDelegation.SettlementParams memory params = _makeSettlementParams(keccak256("pay"), 100e6); + params.buyer = address(0); + + vm.expectRevert(abi.encodeWithSelector(X402Facilitator.InvalidBuyer.selector)); + facilitator.settlePayment(params); + } + + /// @notice settlePayment reverts with zero token address + function test_requireFail_X402Facilitator_settlePayment_zeroToken() public { + TypesDelegation.SettlementParams memory params = _makeSettlementParams(keccak256("pay"), 100e6); + params.token = address(0); + + vm.expectRevert(abi.encodeWithSelector(X402Facilitator.InvalidToken.selector)); + facilitator.settlePayment(params); + } + + /// @notice settlePayment reverts when expired + function test_requireFail_X402Facilitator_settlePayment_expired() public { + vm.warp(1000); + TypesDelegation.SettlementParams memory params = _makeSettlementParams(keccak256("pay"), 100e6); + params.expiry = uint64(block.timestamp - 1); + + vm.expectRevert(abi.encodeWithSelector(X402Facilitator.PaymentExpired.selector)); + facilitator.settlePayment(params); + } +} diff --git a/test/moloch/StateTransitions.t.sol b/test/moloch/StateTransitions.t.sol new file mode 100644 index 0000000..9ea5477 --- /dev/null +++ b/test/moloch/StateTransitions.t.sol @@ -0,0 +1,310 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import { Test } from "forge-std/Test.sol"; +import { SolverRegistry } from "../../src/SolverRegistry.sol"; +import { IntentReceiptHub } from "../../src/IntentReceiptHub.sol"; +import { EscrowVault } from "../../src/EscrowVault.sol"; +import { IEscrowVault } from "../../src/interfaces/IEscrowVault.sol"; +import { Types } from "../../src/libraries/Types.sol"; +import { VerificationHelpers } from "../helpers/VerificationHelpers.sol"; +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import { MessageHashUtils } from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; + +/// @title StateTransitions - Moloch DAO-Style State Verification Tests +/// @notice Verifies ALL fields change correctly during key state transitions +/// @dev Uses VerificationHelpers for comprehensive post-condition assertions +contract StateTransitionsTest is VerificationHelpers { + using ECDSA for bytes32; + using MessageHashUtils for bytes32; + + SolverRegistry public registry; + IntentReceiptHub public hub; + EscrowVault public vault; + + address public owner; + uint256 public operatorKey = 0x1234; + address public operator; + address public challenger = address(0x2); + + uint256 public constant MINIMUM_BOND = 0.1 ether; + + function setUp() public { + owner = address(this); + operator = vm.addr(operatorKey); + + vm.deal(owner, 100 ether); + vm.deal(operator, 100 ether); + vm.deal(challenger, 100 ether); + + registry = new SolverRegistry(); + hub = new IntentReceiptHub(address(registry)); + vault = new EscrowVault(); + + registry.setAuthorizedCaller(address(hub), true); + registry.setAuthorizedCaller(address(this), true); + vault.setAuthorizedHub(address(this), true); + } + + receive() external payable { } + + // ============ Helpers ============ + + function _createSignedReceipt(bytes32 solverId, bytes32 intentHash, uint64 expiry) + internal + view + returns (Types.IntentReceipt memory receipt) + { + receipt = Types.IntentReceipt({ + intentHash: intentHash, + constraintsHash: keccak256("constraints"), + routeHash: keccak256("route"), + outcomeHash: keccak256("outcome"), + evidenceHash: keccak256("evidence"), + createdAt: uint64(block.timestamp), + expiry: expiry, + solverId: solverId, + solverSig: "" + }); + + uint256 currentNonce = hub.solverNonces(solverId); + bytes32 messageHash = keccak256( + abi.encode( + block.chainid, + address(hub), + currentNonce, + receipt.intentHash, + receipt.constraintsHash, + receipt.routeHash, + receipt.outcomeHash, + receipt.evidenceHash, + receipt.createdAt, + receipt.expiry, + receipt.solverId + ) + ); + bytes32 ethSignedHash = messageHash.toEthSignedMessageHash(); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(operatorKey, ethSignedHash); + receipt.solverSig = abi.encodePacked(r, s, v); + } + + // ================================================================ + // SOLVER REGISTRY TRANSITIONS + // ================================================================ + + /// @notice Verify ALL fields correct after registerSolver + function test_stateTransition_registerSolver_allFieldsCorrect() public { + uint256 solversBefore = registry.totalSolvers(); + + bytes32 solverId = registry.registerSolver("ipfs://test", operator); + + Types.Solver memory solver = registry.getSolver(solverId); + assertEq(solver.operator, operator, "Operator mismatch"); + assertEq(solver.metadataURI, "ipfs://test", "Metadata mismatch"); + assertEq(solver.bondBalance, 0, "Bond should be 0"); + assertEq(solver.lockedBalance, 0, "Locked should be 0"); + assertEq(uint256(solver.status), uint256(Types.SolverStatus.Inactive), "Should be Inactive"); + assertEq(solver.score.totalFills, 0, "Score should be zeroed"); + assertEq(solver.score.successfulFills, 0, "Score should be zeroed"); + assertEq(solver.score.disputesOpened, 0, "Score should be zeroed"); + assertEq(solver.score.disputesLost, 0, "Score should be zeroed"); + assertEq(solver.score.volumeProcessed, 0, "Score should be zeroed"); + assertEq(solver.score.totalSlashed, 0, "Score should be zeroed"); + assertEq(solver.registeredAt, uint64(block.timestamp), "RegisteredAt mismatch"); + assertEq(solver.lastActivityAt, uint64(block.timestamp), "LastActivityAt mismatch"); + assertEq(registry.totalSolvers(), solversBefore + 1, "Total solvers mismatch"); + assertEq(registry.getSolverByOperator(operator), solverId, "Operator mapping mismatch"); + } + + /// @notice Verify bond deposit triggers activation at threshold + function test_stateTransition_depositBond_activationThreshold() public { + bytes32 solverId = registry.registerSolver("ipfs://test", operator); + uint256 totalBondedBefore = registry.totalBonded(); + + // Deposit below minimum — stays Inactive + vm.prank(operator); + registry.depositBond{ value: MINIMUM_BOND - 1 }(solverId); + + verifyPostDeposit( + registry, solverId, MINIMUM_BOND - 1, Types.SolverStatus.Inactive, totalBondedBefore + MINIMUM_BOND - 1 + ); + + // Deposit 1 more wei — activates + vm.prank(operator); + registry.depositBond{ value: 1 }(solverId); + + verifyPostDeposit( + registry, solverId, MINIMUM_BOND, Types.SolverStatus.Active, totalBondedBefore + MINIMUM_BOND + ); + } + + /// @notice Verify ALL fields after slash from locked balance + function test_stateTransition_slash_fromLocked_allFieldsCorrect() public { + bytes32 solverId = registry.registerSolver("ipfs://test", operator); + vm.prank(operator); + registry.depositBond{ value: 0.5 ether }(solverId); + + // Lock 0.2 ETH + registry.lockBond(solverId, 0.2 ether); + + uint256 totalBondedBefore = registry.totalBonded(); + uint256 slashAmount = 0.15 ether; + address recipient = address(0x7); + + // Slash 0.15 ETH (all from locked) + registry.slash(solverId, slashAmount, bytes32(uint256(1)), Types.DisputeReason.Timeout, recipient); + + verifyPostSlash( + registry, + solverId, + 0.3 ether, // bondBalance unchanged (slash was from locked) + 0.05 ether, // lockedBalance: 0.2 - 0.15 + 1, // disputesLost + Types.SolverStatus.Active // still above minimum + ); + assertEq(registry.totalBonded(), totalBondedBefore - slashAmount, "Total bonded mismatch"); + assertEq(recipient.balance, slashAmount, "Recipient didn't receive slash"); + } + + /// @notice Verify slash spills from locked to available + function test_stateTransition_slash_spillToAvailable() public { + bytes32 solverId = registry.registerSolver("ipfs://test", operator); + vm.prank(operator); + registry.depositBond{ value: 0.5 ether }(solverId); + + // Lock 0.1 ETH + registry.lockBond(solverId, 0.1 ether); + // bondBalance = 0.4, lockedBalance = 0.1 + + address recipient = address(0x8); + uint256 slashAmount = 0.15 ether; // More than locked + + registry.slash(solverId, slashAmount, bytes32(uint256(2)), Types.DisputeReason.Timeout, recipient); + + // 0.1 from locked (now 0), 0.05 from available (0.4 → 0.35) + verifyPostSlash(registry, solverId, 0.35 ether, 0, 1, Types.SolverStatus.Active); + } + + // ================================================================ + // RECEIPT HUB TRANSITIONS + // ================================================================ + + /// @notice Verify ALL fields after posting and finalizing a receipt + function test_stateTransition_finalize_allFieldsCorrect() public { + bytes32 solverId = registry.registerSolver("ipfs://test", operator); + vm.prank(operator); + registry.depositBond{ value: MINIMUM_BOND }(solverId); + + bytes32 intentHash = keccak256("intent1"); + uint64 expiry = uint64(block.timestamp + 1 hours); + Types.IntentReceipt memory receipt = _createSignedReceipt(solverId, intentHash, expiry); + + vm.prank(operator); + bytes32 receiptId = hub.postReceipt(receipt); + + // Verify Pending state + (, Types.ReceiptStatus status) = hub.getReceipt(receiptId); + assertEq(uint256(status), uint256(Types.ReceiptStatus.Pending), "Should be Pending"); + assertEq(hub.totalReceipts(), 1, "Receipt count mismatch"); + + // Warp past challenge window and finalize + vm.warp(block.timestamp + 2 hours); + hub.finalize(receiptId); + + verifyPostFinalization(hub, registry, receiptId, solverId, Types.ReceiptStatus.Finalized, 1); + + // Solver score should be updated + Types.IntentScore memory score = registry.getIntentScore(solverId); + assertEq(score.successfulFills, 1, "Successful fills should increment"); + } + + /// @notice Verify dispute resolution (slash path) full state + function test_stateTransition_resolveDeterministic_slashPath() public { + bytes32 solverId = registry.registerSolver("ipfs://test", operator); + vm.prank(operator); + registry.depositBond{ value: 0.5 ether }(solverId); + + bytes32 intentHash = keccak256("intent2"); + uint64 expiry = uint64(block.timestamp + 30 minutes); + Types.IntentReceipt memory receipt = _createSignedReceipt(solverId, intentHash, expiry); + + vm.prank(operator); + bytes32 receiptId = hub.postReceipt(receipt); + + // Open dispute + uint256 challengerBond = hub.challengerBondMin(); + vm.prank(challenger); + hub.openDispute{ value: challengerBond }(receiptId, Types.DisputeReason.Timeout, keccak256("evidence")); + + verifyPostDispute(hub, receiptId, Types.ReceiptStatus.Disputed, challenger, challengerBond); + + // Warp past expiry, no settlement proof → slash + vm.warp(expiry + 1); + hub.resolveDeterministic(receiptId); + + (, Types.ReceiptStatus finalStatus) = hub.getReceipt(receiptId); + assertEq(uint256(finalStatus), uint256(Types.ReceiptStatus.Slashed), "Should be Slashed"); + assertTrue(hub.totalSlashed() > 0, "Slash amount should be tracked"); + assertEq(hub.getChallengerBond(receiptId), 0, "Challenger bond should be zeroed after return"); + } + + // ================================================================ + // ESCROW VAULT TRANSITIONS + // ================================================================ + + /// @notice Verify full escrow lifecycle: create → release + function test_stateTransition_escrowCreate_release_allFieldsCorrect() public { + bytes32 escrowId = keccak256("escrow1"); + bytes32 receiptId = keccak256("receipt1"); + address depositor = address(0x900); + uint64 deadline = uint64(block.timestamp + 1 hours); + uint256 amount = 1 ether; + + uint256 totalEscrowsBefore = vault.totalEscrows(); + + vault.createEscrow{ value: amount }(escrowId, receiptId, depositor, deadline); + + // Verify Active state + verifyEscrowState(vault, escrowId, IEscrowVault.EscrowStatus.Active, amount); + assertEq(vault.totalEscrows(), totalEscrowsBefore + 1, "Total escrows mismatch"); + assertEq(vault.getEscrowByReceipt(receiptId), escrowId, "Receipt mapping mismatch"); + + IEscrowVault.Escrow memory escrow = vault.getEscrow(escrowId); + assertEq(escrow.receiptId, receiptId, "Receipt ID mismatch"); + assertEq(escrow.depositor, depositor, "Depositor mismatch"); + assertEq(escrow.token, address(0), "Token should be native"); + assertEq(escrow.createdAt, uint64(block.timestamp), "CreatedAt mismatch"); + assertEq(escrow.deadline, deadline, "Deadline mismatch"); + + // Release to a non-precompile address + address recipient = address(0xBEEF); + uint256 releasedBefore = vault.totalReleasedNative(); + + vault.release(escrowId, recipient); + + verifyEscrowState(vault, escrowId, IEscrowVault.EscrowStatus.Released, 0); + assertEq(recipient.balance, amount, "Recipient should receive funds"); + assertEq(vault.totalReleasedNative(), releasedBefore + amount, "Released tracker mismatch"); + } + + /// @notice Verify full escrow lifecycle: create → refund + function test_stateTransition_escrowCreate_refund_allFieldsCorrect() public { + bytes32 escrowId = keccak256("escrow2"); + bytes32 receiptId = keccak256("receipt2"); + address depositor = address(0xB00B); + vm.deal(depositor, 0); // Ensure starts at 0 + uint64 deadline = uint64(block.timestamp + 1 hours); + uint256 amount = 2 ether; + + vault.createEscrow{ value: amount }(escrowId, receiptId, depositor, deadline); + verifyEscrowState(vault, escrowId, IEscrowVault.EscrowStatus.Active, amount); + + uint256 refundedBefore = vault.totalRefundedNative(); + + vault.refund(escrowId); + + verifyEscrowState(vault, escrowId, IEscrowVault.EscrowStatus.Refunded, 0); + assertEq(depositor.balance, amount, "Depositor should receive refund"); + assertEq(vault.totalRefundedNative(), refundedBefore + amount, "Refunded tracker mismatch"); + } +}