diff --git a/.github/workflows/foundry.yml b/.github/workflows/foundry.yml new file mode 100644 index 0000000..ba4b6f3 --- /dev/null +++ b/.github/workflows/foundry.yml @@ -0,0 +1,82 @@ +name: contracts + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + foundry: + name: forge build / test / coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: foundry-rs/foundry-toolchain@v1 + with: + version: v1.8.5 + + - name: Build + run: forge build --sizes + + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Forge runtime == deploy runtime + run: npm ci && node --test test/forge-parity.test.mjs + + # Tests named test_BUG_* deliberately fail: each one pins a known issue reported in the + # self-audit and stays red until the contract is fixed. They are run separately below. + - name: Test (unit, fuzz, invariant) + run: forge test -vvv --no-match-test test_BUG_ + env: + FOUNDRY_PROFILE: ci + + - name: Known-issue tests (expected to fail until fixed) + run: forge test --match-test test_BUG_ || true + + - name: Coverage + run: | + forge coverage --no-match-test test_BUG_ --no-match-coverage "test/" --report summary --report lcov + - uses: actions/upload-artifact@v4 + with: + name: lcov + path: lcov.info + + bytecode: + name: JS tests (plan + bytecode match against live deployments) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm ci + - run: npm test + + slither: + name: slither (informational) + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install slither + solc 0.8.26 + run: | + pip install slither-analyzer==0.11.6 solc-select==1.2.0 + solc-select install 0.8.26 && solc-select use 0.8.26 + - name: Run slither on each contract with the deploy compiler settings + run: | + for f in contracts/*.sol; do + slither "$f" --compile-force-framework solc \ + --solc-args "--optimize --optimize-runs 200 --evm-version cancun" \ + --exclude-informational --exclude-optimization || true + done diff --git a/.gitignore b/.gitignore index fd82614..d0e8641 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,11 @@ node_modules/ *.key deployer.json .DS_Store +# audit / foundry +.audit-venv/ +.audit-venv-myth/ +out-foundry/ +cache-foundry/ +lcov.info +gambit_out/ +cache/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..248c35a --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "foundry-lib/forge-std"] + path = foundry-lib/forge-std + url = https://github.com/foundry-rs/forge-std diff --git a/README.md b/README.md index d3e1f2b..a04a1f9 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,26 @@ Verification tries these in order: Anything still unverified is written into `deployments/X.json` as a TODO. The standard-JSON input for manual verification goes to `deployments/verify/`. +## Tests + +```bash +git submodule update --init # forge-std +npm test # plan + live bytecode match (+ forge parity if built) +forge build && forge test --no-match-test test_BUG_ # unit, fuzz, invariant +forge coverage --no-match-test test_BUG_ --no-match-coverage "test/" --report summary +``` + +`test/foundry/` holds unit tests for every external function and revert path of all six contracts, +fuzz tests, and handler-based invariant suites (escrow/bond conservation, no double payout, no +reclaim inside the window, write-once verdicts and filings, role checks). Line, statement, branch and +function coverage is 100%. `foundry.toml` uses the same compiler settings as `lib/compile.mjs`, and +`test/forge-parity.test.mjs` checks that forge's runtime bytecode equals the deployed runtime +(metadata hash excluded), so the suite tests exactly what is live. + +Tests named `test_BUG_*` fail on purpose: each one pins an open self-audit finding and stays red +until the contract is fixed. Tests named `test_finding_*` pass and document current behaviour that is +a design or operational risk. + ## ERC-8004 registries The official ERC-8004 v2.0.0 registries sit at the same addresses wherever they exist. `getVersion()` returns `2.0.0`. diff --git a/deployments/rootstock.json b/deployments/rootstock.json new file mode 100644 index 0000000..8f42a72 --- /dev/null +++ b/deployments/rootstock.json @@ -0,0 +1,265 @@ +{ + "chain": "rootstock", + "chainId": 30, + "deployer": "0x5d016C1B4d7A42eB7c982da1Ae7fFdB958CFCAe9", + "operator": "0x5d016C1B4d7A42eB7c982da1Ae7fFdB958CFCAe9", + "setVersion": "2026-10-16", + "compiler": { + "solc": "v0.8.26+commit.8a97fa7a", + "settings": { + "optimizer": { + "enabled": true, + "runs": 200 + }, + "evmVersion": "cancun" + } + }, + "deployedAt": "2026-10-23T10:47:24.835Z", + "explorer": "https://explorer.rootstock.io", + "contracts": { + "PredgeAgentValidator": { + "name": "PredgeAgentValidator", + "address": "0x925767fdce6C819F027f55CC50464e3bCdfD2671", + "nonce": 0, + "args": [ + "0x5d016C1B4d7A42eB7c982da1Ae7fFdB958CFCAe9" + ], + "constructorArgs": "0x0000000000000000000000005d016c1b4d7a42eb7c982da1ae7ffdb958cfcae9", + "sourceSha256": "7c452fef0a77be8d886fbf251d918db95f1ac851268c2e3c13ed7c65ae9fee56", + "source": "predge-arc@0b48435 (= erc8004-outcome-validator monad/contracts)", + "txHash": "0x6fbbe762af5ea3bf52643c52590c4b85cb4c5bd5cb5ca81648d46dcf4fad05c1", + "block": 9349449, + "gasUsed": "912160", + "effectiveGasPrice": "26065600", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "AgentJob": { + "name": "AgentJob", + "address": "0xcBaB4a9FA4d756b4C5e2a18F055d2E0132CE7D1D", + "nonce": 1, + "args": [], + "constructorArgs": "0x", + "sourceSha256": "9d958d5266d1876e16f95f26b2b437be32e2f45fe8bf10879ab93eea9a8ccba5", + "source": "predge-arc@0b48435", + "txHash": "0x96fbccebb7be61a31b58e59a88eaabee4468591a7f19a77c0265c470a720a5f9", + "block": 9349450, + "gasUsed": "569497", + "effectiveGasPrice": "26065600", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "PredgeValidatorBond": { + "name": "PredgeValidatorBond", + "address": "0x800B5596a8540e752dE8f8a8AF99E927d139A643", + "nonce": 2, + "args": [ + "0x5d016C1B4d7A42eB7c982da1Ae7fFdB958CFCAe9", + "0xcBaB4a9FA4d756b4C5e2a18F055d2E0132CE7D1D", + "86400" + ], + "constructorArgs": "0x0000000000000000000000005d016c1b4d7a42eb7c982da1ae7ffdb958cfcae9000000000000000000000000cbab4a9fa4d756b4c5e2a18f055d2e0132ce7d1d0000000000000000000000000000000000000000000000000000000000015180", + "sourceSha256": "085e2deaf0c32a45ff4de240215f68e8c80841ac135d762f5ddea421f46b064a", + "source": "predge-robinhood@bc57c52 = predge-arc@c0762ea (v2.1, audit fix F1)", + "txHash": "0x4bb954dc7b2a090e376ea208653d0f58728729cf1c30d283660d2672ee4b554b", + "block": 9349452, + "gasUsed": "1046606", + "effectiveGasPrice": "26065600", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "PredgeSettlement": { + "name": "PredgeSettlement", + "address": "0x47BE8a7d461De505723a0Ac1d74982e53f8E412b", + "nonce": 3, + "args": [], + "constructorArgs": "0x", + "sourceSha256": "fca0ec609a3374d93ee68e973618ae675d93abf43fd5bea3f03b653f23690711", + "source": "predge-arc@6f29672", + "txHash": "0xe600e2581b743056232267bd6ec404e9e3efc5df20c5553086c51c0e057a8806", + "block": 9349453, + "gasUsed": "272960", + "effectiveGasPrice": "26065600", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "MockRefundProtocol": { + "name": "MockRefundProtocol", + "address": "0xcaC30945CDF0F963f38972d939e111B87b80Cf79", + "nonce": 4, + "args": [ + "0xb2972B4944Eec83946EB7B23D572AbE04F46E22c" + ], + "constructorArgs": "0x000000000000000000000000b2972b4944eec83946eb7b23d572abe04f46e22c", + "sourceSha256": "c37ac127c5eb469af4e9adf7293e4a3db6c483e178efc2cc735fc3b99c841230", + "source": "predge-arc@293bd70", + "txHash": "0x69250f2bbd46bd304b9aa03685d9fc0dd5f3a04fef19ae0dcf7657d60da86764", + "block": 9349454, + "gasUsed": "268629", + "effectiveGasPrice": "26065600", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + }, + "PredgeRefundArbiter": { + "name": "PredgeRefundArbiter", + "address": "0xb2972B4944Eec83946EB7B23D572AbE04F46E22c", + "nonce": 5, + "args": [ + "0xcaC30945CDF0F963f38972d939e111B87b80Cf79", + "0x5d016C1B4d7A42eB7c982da1Ae7fFdB958CFCAe9", + "86400", + "1000000000000000" + ], + "constructorArgs": "0x000000000000000000000000cac30945cdf0f963f38972d939e111b87b80cf790000000000000000000000005d016c1b4d7a42eb7c982da1ae7ffdb958cfcae9000000000000000000000000000000000000000000000000000000000001518000000000000000000000000000000000000000000000000000038d7ea4c68000", + "sourceSha256": "4a2c59b0067800de698f33c10e64c14441c2cc56004a94d39e0d4eea90e6c465", + "source": "predge-arc@95b723d (v2, audit fix F2)", + "txHash": "0x1cf5b6529ae2452dd5e51422fca794900d001d01d6011b8ef3e20d34989d5c52", + "block": 9349456, + "gasUsed": "1139332", + "effectiveGasPrice": "26065600", + "verified": { + "ok": true, + "via": "sourcify", + "match": "exact_match" + } + } + }, + "erc8004": { + "IdentityRegistry": null, + "ReputationRegistry": null, + "ValidationRegistry": null + }, + "smoke": [ + { + "check": "PredgeAgentValidator runtime == compiled", + "ok": true, + "detail": "0x925767fdce6C819F027f55CC50464e3bCdfD2671" + }, + { + "check": "AgentJob runtime == compiled", + "ok": true, + "detail": "0xcBaB4a9FA4d756b4C5e2a18F055d2E0132CE7D1D" + }, + { + "check": "PredgeValidatorBond runtime == compiled", + "ok": true, + "detail": "0x800B5596a8540e752dE8f8a8AF99E927d139A643" + }, + { + "check": "PredgeSettlement runtime == compiled", + "ok": true, + "detail": "0x47BE8a7d461De505723a0Ac1d74982e53f8E412b" + }, + { + "check": "MockRefundProtocol runtime == compiled", + "ok": true, + "detail": "0xcaC30945CDF0F963f38972d939e111B87b80Cf79" + }, + { + "check": "PredgeRefundArbiter runtime == compiled", + "ok": true, + "detail": "0xb2972B4944Eec83946EB7B23D572AbE04F46E22c" + }, + { + "check": "validator.owner == deployer", + "ok": true, + "detail": "0x5d016C1B4d7A42eB7c982da1Ae7fFdB958CFCAe9" + }, + { + "check": "validator.validator == operator", + "ok": true, + "detail": "0x5d016C1B4d7A42eB7c982da1Ae7fFdB958CFCAe9" + }, + { + "check": "validator.getValidationStatus(unknown) is empty", + "ok": true, + "detail": "0x0000000000000000000000000000000000000000" + }, + { + "check": "job.jobCount readable", + "ok": true, + "detail": "0" + }, + { + "check": "job.createJob(value 0) reverts ZeroEscrow (eth_call)", + "ok": true, + "detail": "" + }, + { + "check": "bond.owner == deployer", + "ok": true, + "detail": "0x5d016C1B4d7A42eB7c982da1Ae7fFdB958CFCAe9" + }, + { + "check": "bond.validator == operator", + "ok": true, + "detail": "0x5d016C1B4d7A42eB7c982da1Ae7fFdB958CFCAe9" + }, + { + "check": "bond.job == AgentJob", + "ok": true, + "detail": "0xcBaB4a9FA4d756b4C5e2a18F055d2E0132CE7D1D" + }, + { + "check": "bond.disputeWindow", + "ok": true, + "detail": "86400" + }, + { + "check": "bond.wouldSlash(unknown) == false", + "ok": true, + "detail": "" + }, + { + "check": "settlement.owner == deployer", + "ok": true, + "detail": "0x5d016C1B4d7A42eB7c982da1Ae7fFdB958CFCAe9" + }, + { + "check": "arbiter.owner == deployer", + "ok": true, + "detail": "0x5d016C1B4d7A42eB7c982da1Ae7fFdB958CFCAe9" + }, + { + "check": "arbiter.operator == operator", + "ok": true, + "detail": "0x5d016C1B4d7A42eB7c982da1Ae7fFdB958CFCAe9" + }, + { + "check": "arbiter.refundProtocol == mock", + "ok": true, + "detail": "0xcaC30945CDF0F963f38972d939e111B87b80Cf79" + }, + { + "check": "mock.arbiter == arbiter", + "ok": true, + "detail": "0xb2972B4944Eec83946EB7B23D572AbE04F46E22c" + }, + { + "check": "arbiter.challengeWindow/minBond", + "ok": true, + "detail": "" + }, + { + "check": "arbiter.rulingOf(0) empty", + "ok": true, + "detail": "" + } + ], + "legacy": null +} diff --git a/foundry-lib/forge-std b/foundry-lib/forge-std new file mode 160000 index 0000000..8e40513 --- /dev/null +++ b/foundry-lib/forge-std @@ -0,0 +1 @@ +Subproject commit 8e40513d678f392f398620b3ef2b418648b33e89 diff --git a/foundry.toml b/foundry.toml new file mode 100644 index 0000000..cf22bf7 --- /dev/null +++ b/foundry.toml @@ -0,0 +1,35 @@ +# Foundry test suite for the canonical Predge contract set. +# Compiler settings mirror lib/compile.mjs exactly (solc 0.8.26, optimizer 200, cancun). +# The deployable bytecode is still produced and checked by lib/compile.mjs + test/bytecode-match.test.mjs; +# this profile only compiles the same sources for testing and never writes into contracts/. +[profile.default] +src = "contracts" +test = "test/foundry" +out = "out-foundry" +cache_path = "cache-foundry" +libs = ["foundry-lib"] +remappings = ["forge-std/=foundry-lib/forge-std/src/"] +solc_version = "0.8.26" +optimizer = true +optimizer_runs = 200 +evm_version = "cancun" + +[profile.default.fuzz] +runs = 1000 +failure_persist_dir = "cache-foundry/fuzz" + +[profile.default.invariant] +runs = 256 +depth = 64 +fail_on_revert = false +failure_persist_dir = "cache-foundry/invariant" + +[profile.ci.fuzz] +runs = 5000 +failure_persist_dir = "cache-foundry/fuzz" + +[profile.ci.invariant] +runs = 512 +depth = 100 +fail_on_revert = false +failure_persist_dir = "cache-foundry/invariant" diff --git a/test/forge-parity.test.mjs b/test/forge-parity.test.mjs new file mode 100644 index 0000000..365ca5d --- /dev/null +++ b/test/forge-parity.test.mjs @@ -0,0 +1,23 @@ +// The Foundry suite (test/foundry) must exercise the same runtime code we deploy. forge and +// lib/compile.mjs use different source paths, so only the CBOR metadata tail may differ; the +// executable runtime must be byte-identical. Skipped when `forge build` has not been run. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, readFileSync } from "node:fs"; +import { compile } from "../lib/compile.mjs"; + +const OUT = new URL("../out-foundry/", import.meta.url); +const NAMES = ["AgentJob", "MockRefundProtocol", "PredgeAgentValidator", "PredgeRefundArbiter", "PredgeSettlement", "PredgeValidatorBond"]; +const stripMetadata = (hex) => { + const h = hex.replace(/^0x/, ""); + const len = parseInt(h.slice(-4), 16); + return h.slice(0, h.length - 4 - len * 2); +}; + +for (const name of NAMES) { + const artifact = new URL(`${name}.sol/${name}.json`, OUT); + test(`${name}: forge runtime == deploy runtime (metadata excluded)`, { skip: !existsSync(artifact) && "run forge build first" }, () => { + const forge = JSON.parse(readFileSync(artifact, "utf8")).deployedBytecode.object; + assert.equal(stripMetadata(forge), stripMetadata(compile(name).deployedBytecode)); + }); +} diff --git a/test/foundry/invariant/AgentJob.invariant.t.sol b/test/foundry/invariant/AgentJob.invariant.t.sol new file mode 100644 index 0000000..2f5a19e --- /dev/null +++ b/test/foundry/invariant/AgentJob.invariant.t.sol @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {AgentJob} from "../../../contracts/AgentJob.sol"; + +contract AgentJobHandler is Test { + AgentJob public job; + address[3] internal clients = [address(0xC1), address(0xC2), address(0xC3)]; + address[3] internal providers = [address(0xA1), address(0xA2), address(0xA3)]; + address[2] internal evaluators = [address(0xE1), address(0xE2)]; + + uint256 public deposited; + uint256 public paidOut; + uint256 public refunded; + mapping(uint256 => uint256) public settlements; // per job: successful complete+reject + mapping(uint256 => uint8) public lastState; + bool public regressed; + bool public unauthorizedSucceeded; + + constructor(AgentJob j) { + job = j; + } + + function _id(uint256 seed) internal view returns (uint256) { + uint256 n = job.jobCount(); + return n == 0 ? 1 : bound(seed, 1, n); + } + + function _track(uint256 id) internal { + (AgentJob.State s, , , , ) = job.jobState(id); + if (uint8(s) < lastState[id]) regressed = true; + lastState[id] = uint8(s); + } + + function create(uint256 c, uint256 p, uint256 e, uint96 amt) external { + amt = uint96(bound(amt, 1, 100 ether)); + address client = clients[c % 3]; + vm.deal(client, client.balance + amt); + vm.prank(client); + uint256 id = job.createJob{value: amt}(providers[p % 3], evaluators[e % 2], keccak256(abi.encode(id_(), c))); + deposited += amt; + _track(id); + } + + function id_() internal view returns (uint256) { + return job.jobCount() + 1; + } + + function submit(uint256 seed, bytes32 d) external { + uint256 id = _id(seed); + (, address provider, , , , , , ) = job.jobs(id); + vm.prank(provider); + try job.submit(id, d, "") {} catch {} + _track(id); + } + + function complete(uint256 seed, bytes32 r) external { + uint256 id = _id(seed); + (, address provider, address evaluator, uint96 esc, , , , ) = job.jobs(id); + uint256 before = provider.balance; + vm.prank(evaluator); + try job.complete(id, r, "") { + settlements[id]++; + paidOut += esc; + assertEq(provider.balance, before + esc); + } catch {} + _track(id); + } + + function reject(uint256 seed, bytes32 r) external { + uint256 id = _id(seed); + (address client, , address evaluator, uint96 esc, , , , ) = job.jobs(id); + uint256 before = client.balance; + vm.prank(evaluator); + try job.reject(id, r, "") { + settlements[id]++; + refunded += esc; + assertEq(client.balance, before + esc); + } catch {} + _track(id); + } + + /// Random non-evaluator tries to settle; random non-provider tries to submit. + function intruder(uint256 seed, address who, bool pass) external { + uint256 id = _id(seed); + (, address provider, address evaluator, , , , , ) = job.jobs(id); + if (who == evaluator || who == provider) return; + vm.startPrank(who); + try job.submit(id, bytes32("x"), "") { + unauthorizedSucceeded = true; + } catch {} + if (pass) { + try job.complete(id, bytes32("x"), "") { + unauthorizedSucceeded = true; + } catch {} + } else { + try job.reject(id, bytes32("x"), "") { + unauthorizedSucceeded = true; + } catch {} + } + vm.stopPrank(); + _track(id); + } +} + +contract AgentJobInvariantTest is Test { + AgentJob job; + AgentJobHandler h; + + function setUp() public { + job = new AgentJob(); + h = new AgentJobHandler(job); + targetContract(address(h)); + } + + /// Contract balance == sum of escrow of jobs not yet settled. + function invariant_balanceEqualsOutstandingEscrow() public view { + uint256 sum; + for (uint256 i = 1; i <= job.jobCount(); i++) { + (AgentJob.State s, , , , uint96 esc) = job.jobState(i); + if (s == AgentJob.State.Open || s == AgentJob.State.Submitted) sum += esc; + else assertEq(esc, 0, "settled job keeps escrow"); + } + assertEq(address(job).balance, sum); + } + + function invariant_valueConserved() public view { + assertEq(h.deposited(), h.paidOut() + h.refunded() + address(job).balance); + } + + function invariant_settledAtMostOnce() public view { + for (uint256 i = 1; i <= job.jobCount(); i++) assertLe(h.settlements(i), 1); + } + + function invariant_stateOnlyForward() public view { + assertFalse(h.regressed()); + } + + function invariant_onlyRolesAct() public view { + assertFalse(h.unauthorizedSucceeded()); + } +} diff --git a/test/foundry/invariant/PredgeRefundArbiter.invariant.t.sol b/test/foundry/invariant/PredgeRefundArbiter.invariant.t.sol new file mode 100644 index 0000000..f4d4dc9 --- /dev/null +++ b/test/foundry/invariant/PredgeRefundArbiter.invariant.t.sol @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {PredgeRefundArbiter} from "../../../contracts/PredgeRefundArbiter.sol"; +import {MockRefundProtocol} from "../../../contracts/MockRefundProtocol.sol"; + +contract ArbiterHandler is Test { + PredgeRefundArbiter public arb; + MockRefundProtocol public rp; + address public op; + address[3] internal parties = [address(0xA11CE), address(0xB0B), address(0xCA401)]; + uint256 internal constant PIDS = 8; + + uint256 public rulings; + uint256 public refunds; + uint256 public totalIn; + uint256 public totalOut; + mapping(uint256 => uint256) public exits; + bool public earlyReclaim; + bool public unauthorizedSucceeded; + bool public filingChanged; + mapping(uint256 => mapping(address => bytes32)) public firstFiling; + + constructor(PredgeRefundArbiter a, MockRefundProtocol r, address o) { + arb = a; + rp = r; + op = o; + } + + function file(uint256 pSeed, uint256 pid, bytes32 h) external { + address p = parties[pSeed % 3]; + pid = pid % PIDS; + vm.prank(p); + try arb.fileEvidence(pid, h) { + if (firstFiling[pid][p] != bytes32(0)) filingChanged = true; + firstFiling[pid][p] = h; + } catch {} + } + + function rule(uint256 pid, uint256 pSeed, bool refund, uint96 amt, bool citeFiled, bytes32 random) external { + pid = pid % PIDS; + address p = parties[pSeed % 3]; + bytes32 h = citeFiled ? arb.filedEvidence(pid, p) : random; + amt = uint96(bound(amt, 0, 5 ether)); + vm.deal(op, op.balance + amt); + vm.prank(op); + try arb.rule{value: amt}(pid, p, h, refund ? PredgeRefundArbiter.Direction.Refund : PredgeRefundArbiter.Direction.Release) { + rulings++; + if (refund) refunds++; + totalIn += amt; + } catch {} + } + + function challenge(uint256 pid, address who) external { + pid = pid % PIDS; + if (who.code.length > 0 || uint160(who) < 0x10000) return; + (, , uint96 b, , , ) = arb.rulingOf(pid); + vm.prank(who); + try arb.challenge(pid) { + exits[pid]++; + totalOut += b; + } catch {} + } + + function reclaim(uint256 pid) external { + pid = pid % PIDS; + (, , uint96 b, uint64 at, , ) = arb.rulingOf(pid); + vm.prank(op); + try arb.reclaim(pid) { + if (block.timestamp < uint256(at) + arb.challengeWindow()) earlyReclaim = true; + exits[pid]++; + totalOut += b; + } catch {} + } + + function intruder(uint256 pid, address who) external { + pid = pid % PIDS; + // skip the operator, the owner (the test contract) and any contract: vm.deal on the + // arbiter itself would fake a balance change + if (who == op || who == arb.owner() || who.code.length > 0 || uint160(who) < 0x10000) return; + vm.deal(who, 1 ether); + vm.startPrank(who); + try arb.reclaim(pid) { + unauthorizedSucceeded = true; + } catch {} + try arb.rule{value: 1 ether}(pid, parties[0], arb.filedEvidence(pid, parties[0]), PredgeRefundArbiter.Direction.Refund) { + unauthorizedSucceeded = true; + } catch {} + try arb.setParams(0, 0) { + unauthorizedSucceeded = true; + } catch {} + try arb.setOperator(who) { + unauthorizedSucceeded = true; + } catch {} + vm.stopPrank(); + } + + function warp(uint32 dt) external { + vm.warp(block.timestamp + bound(dt, 0, 3 days)); + } +} + +contract PredgeRefundArbiterInvariantTest is Test { + PredgeRefundArbiter arb; + MockRefundProtocol rp; + ArbiterHandler h; + address op = address(0x0BE7); + + function setUp() public { + vm.warp(1_700_000_000); + address predicted = vm.computeCreateAddress(address(this), vm.getNonce(address(this)) + 1); + rp = new MockRefundProtocol(predicted); + arb = new PredgeRefundArbiter(address(rp), op, 1 days, 0.001 ether); + h = new ArbiterHandler(arb, rp, op); + targetContract(address(h)); + } + + function invariant_balanceEqualsUnsettledBonds() public view { + uint256 sum; + for (uint256 pid; pid < 8; pid++) { + (, , uint96 b, , , bool settled) = arb.rulingOf(pid); + if (settled) assertEq(b, 0); + else sum += b; + } + assertEq(address(arb).balance, sum); + assertEq(h.totalIn(), h.totalOut() + address(arb).balance); + } + + /// RA-01: with write-once filings and rule() requiring a byte-exact citation, no ruling is + /// ever slashable. + function invariant_noRulingEverSlashed() public view { + assertEq(arb.slashCount(), 0); + for (uint256 pid; pid < 8; pid++) assertFalse(arb.wouldSlash(pid)); + } + + function invariant_countersMatch() public view { + assertEq(arb.rulingCount(), h.rulings()); + assertEq(rp.refundCount(), h.refunds()); + } + + function invariant_bondExitsAtMostOnce() public view { + for (uint256 pid; pid < 8; pid++) assertLe(h.exits(pid), 1); + } + + function invariant_noReclaimInsideWindow() public view { + assertFalse(h.earlyReclaim()); + } + + function invariant_filingsWriteOnce() public view { + assertFalse(h.filingChanged()); + } + + function invariant_onlyOperatorAndOwnerAct() public view { + assertFalse(h.unauthorizedSucceeded()); + } +} diff --git a/test/foundry/invariant/PredgeValidatorBond.invariant.t.sol b/test/foundry/invariant/PredgeValidatorBond.invariant.t.sol new file mode 100644 index 0000000..eafcdba --- /dev/null +++ b/test/foundry/invariant/PredgeValidatorBond.invariant.t.sol @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {AgentJob} from "../../../contracts/AgentJob.sol"; +import {PredgeValidatorBond} from "../../../contracts/PredgeValidatorBond.sol"; + +contract BondHandler is Test { + AgentJob public job; + PredgeValidatorBond public bond; + address public val; + address internal provider = address(0xBEEF); + address internal client = address(0xC1); + uint64 public window; + + bytes32[] public reqs; + mapping(bytes32 => uint256) public exits; // slashes + reclaims + uint256 public totalIn; + uint256 public totalOut; + bool public earlyReclaim; + bool public honestSlashed; + bool public unauthorizedSucceeded; + + constructor(AgentJob j, PredgeValidatorBond b, address v, uint64 w) { + job = j; + bond = b; + val = v; + window = w; + } + + function reqCount() external view returns (uint256) { + return reqs.length; + } + + function _req(uint256 seed) internal view returns (bytes32) { + return reqs.length == 0 ? bytes32(0) : reqs[seed % reqs.length]; + } + + function open(uint96 amt, bytes32 expected) external { + if (expected == bytes32(0)) expected = bytes32(uint256(1)); + amt = uint96(bound(amt, 1, 10 ether)); + bytes32 r = keccak256(abi.encode("req", reqs.length)); + vm.deal(client, 1 ether); + vm.prank(client); + uint256 id = job.createJob{value: 1}(provider, val, r); + vm.deal(val, val.balance + amt); + vm.prank(val); + bond.stakeAndCommit{value: amt}(r, expected, id); + reqs.push(r); + totalIn += amt; + } + + function submit(uint256 seed, bool matchExpected, bytes32 other) external { + bytes32 r = _req(seed); + if (r == bytes32(0)) return; + (bytes32 expected, , , , , , uint256 id, ) = bond.stakes(r); + vm.prank(provider); + try job.submit(id, matchExpected ? expected : other, "") {} catch {} + } + + function score(uint256 seed, bool pass) external { + bytes32 r = _req(seed); + if (r == bytes32(0)) return; + vm.prank(val); + try bond.recordScore(r, pass ? 100 : 0) {} catch {} + } + + function challenge(uint256 seed, address who) external { + bytes32 r = _req(seed); + if (r == bytes32(0) || who.code.length > 0 || uint160(who) < 0x10000) return; + bool lie = bond.wouldSlash(r); + (, uint96 b, , , , , , ) = bond.stakes(r); + uint256 before = who.balance; + vm.prank(who); + try bond.challenge(r) { + if (!lie) honestSlashed = true; + exits[r]++; + totalOut += b; + assertEq(who.balance, before + b); + } catch {} + } + + function reclaim(uint256 seed) external { + bytes32 r = _req(seed); + if (r == bytes32(0)) return; + (, uint96 b, , , , , , uint64 scoredAt) = bond.stakes(r); + vm.prank(val); + try bond.reclaim(r) { + if (block.timestamp < uint256(scoredAt) + window) earlyReclaim = true; + exits[r]++; + totalOut += b; + } catch {} + } + + function intruderReclaim(uint256 seed, address who) external { + bytes32 r = _req(seed); + if (r == bytes32(0) || who == val) return; + vm.prank(who); + try bond.reclaim(r) { + unauthorizedSucceeded = true; + } catch {} + vm.prank(who); + try bond.recordScore(r, 100) { + unauthorizedSucceeded = true; + } catch {} + } + + function warp(uint32 dt) external { + vm.warp(block.timestamp + bound(dt, 0, 3 days)); + } +} + +contract PredgeValidatorBondInvariantTest is Test { + AgentJob job; + PredgeValidatorBond bond; + BondHandler h; + address val = address(0x7A1); + + function setUp() public { + vm.warp(1_700_000_000); + job = new AgentJob(); + bond = new PredgeValidatorBond(val, address(job), 1 days); + h = new BondHandler(job, bond, val, 1 days); + targetContract(address(h)); + } + + function invariant_balanceCoversTotalBonded() public view { + assertEq(address(bond).balance, bond.totalBonded()); + } + + function invariant_totalBondedIsSumOfOpenBonds() public view { + uint256 sum; + for (uint256 i; i < h.reqCount(); i++) { + (, uint96 b, , , , bool closed, , ) = bond.stakes(h.reqs(i)); + if (closed) assertEq(b, 0, "closed stake keeps bond"); + else sum += b; + } + assertEq(sum, bond.totalBonded()); + } + + function invariant_conservation() public view { + assertEq(h.totalIn(), h.totalOut() + address(bond).balance); + } + + function invariant_noDoubleExit() public view { + for (uint256 i; i < h.reqCount(); i++) assertLe(h.exits(h.reqs(i)), 1); + } + + function invariant_noReclaimInsideWindow() public view { + assertFalse(h.earlyReclaim()); + } + + function invariant_honestVerdictNeverSlashed() public view { + assertFalse(h.honestSlashed()); + } + + function invariant_onlyValidatorScoresAndReclaims() public view { + assertFalse(h.unauthorizedSucceeded()); + } +} diff --git a/test/foundry/invariant/Registry.invariant.t.sol b/test/foundry/invariant/Registry.invariant.t.sol new file mode 100644 index 0000000..5a69ccc --- /dev/null +++ b/test/foundry/invariant/Registry.invariant.t.sol @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {PredgeAgentValidator} from "../../../contracts/PredgeAgentValidator.sol"; +import {PredgeSettlement} from "../../../contracts/PredgeSettlement.sol"; + +contract RegistryHandler is Test { + PredgeAgentValidator public reg; + PredgeSettlement public settle; + address public val; + address public owner; + bytes32[] public hashes; + mapping(bytes32 => uint8) public firstScore; + mapping(bytes32 => bytes32) public firstHash; + uint256 public responses; + uint256 public paid; + uint256 public withdrawn; + + constructor(PredgeAgentValidator r, PredgeSettlement s, address v, address o) { + reg = r; + settle = s; + val = v; + owner = o; + } + + function hashCount() external view returns (uint256) { + return hashes.length; + } + + function request(uint8 hSeed, uint256 agentId) external { + bytes32 h = keccak256(abi.encode(hSeed % 16)); + try reg.validationRequest(val, agentId, "u", h) { + hashes.push(h); + } catch {} + } + + function respond(uint8 hSeed, uint8 score, bytes32 rh) external { + bytes32 h = keccak256(abi.encode(hSeed % 16)); + score = uint8(bound(score, 0, 100)); + if (rh == bytes32(0)) rh = bytes32(uint256(1)); + vm.warp(block.timestamp + 1); + vm.prank(val); + try reg.validationResponse(h, score, "r", rh, "t") { + firstScore[h] = score; + firstHash[h] = rh; + responses++; + } catch {} + } + + function pay(uint96 amt, bool direct) external { + amt = uint96(bound(amt, 1, 10 ether)); + vm.deal(address(this), amt); + if (direct) { + (bool ok, ) = address(settle).call{value: amt}(""); + require(ok); + } else { + settle.payForRoute{value: amt}(bytes32("r"), ""); + } + paid += amt; + } + + function withdraw() external { + uint256 b = address(settle).balance; + vm.prank(owner); + settle.withdraw(payable(address(0xFEE))); + withdrawn += b; + } +} + +contract RegistryInvariantTest is Test { + PredgeAgentValidator reg; + PredgeSettlement settle; + RegistryHandler h; + address val = address(0x7A1); + address owner = address(0x0A); + + function setUp() public { + vm.warp(1_700_000_000); + vm.startPrank(owner); + reg = new PredgeAgentValidator(val); + settle = new PredgeSettlement(); + vm.stopPrank(); + h = new RegistryHandler(reg, settle, val, owner); + targetContract(address(h)); + } + + /// A written verdict never changes. + function invariant_verdictsAreWriteOnce() public view { + for (uint256 i; i < h.hashCount(); i++) { + bytes32 x = h.hashes(i); + if (!reg.isValidated(x)) continue; + (, , uint8 r, bytes32 rh, , ) = reg.getValidationStatus(x); + assertEq(r, h.firstScore(x)); + assertEq(rh, h.firstHash(x)); + assertGt(reg.commitLeadTime(x), 0); + } + } + + function invariant_responsesNeverExceedRequests() public view { + assertLe(reg.responseCount(), reg.requestCount()); + assertEq(reg.responseCount(), h.responses()); + assertEq(reg.requestCount(), h.hashCount()); + } + + function invariant_settlementBalance() public view { + assertEq(address(settle).balance, h.paid() - h.withdrawn()); + } +} diff --git a/test/foundry/mocks/MockJob.sol b/test/foundry/mocks/MockJob.sol new file mode 100644 index 0000000..f5c1761 --- /dev/null +++ b/test/foundry/mocks/MockJob.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +/// @notice Programmable stand-in for AgentJob's `jobs(uint256)` getter, so the bond can be +/// driven through job states the real AgentJob only reaches via its own flow. +contract MockJob { + struct J { + address client; + address provider; + address evaluator; + uint96 escrow; + bytes32 specHash; + bytes32 deliverable; + bytes32 reason; + uint8 state; + } + + mapping(uint256 => J) internal _j; + + function set(uint256 id, address provider, address evaluator, bytes32 specHash, bytes32 deliverable, uint8 state) external { + _j[id] = J(address(0xC11E), provider, evaluator, 0, specHash, deliverable, bytes32(0), state); + } + + function jobs(uint256 id) + external + view + returns (address, address, address, uint96, bytes32, bytes32, bytes32, uint8) + { + J storage j = _j[id]; + return (j.client, j.provider, j.evaluator, j.escrow, j.specHash, j.deliverable, j.reason, j.state); + } +} diff --git a/test/foundry/mocks/MockTokens.sol b/test/foundry/mocks/MockTokens.sol new file mode 100644 index 0000000..a20feab --- /dev/null +++ b/test/foundry/mocks/MockTokens.sol @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +/// @notice Minimal standard ERC20 (returns bool). +contract MockERC20 { + string public name; + string public symbol; + uint8 public immutable decimals; + uint256 public totalSupply; + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + constructor(string memory n, string memory s, uint8 d) { + name = n; + symbol = s; + decimals = d; + } + + function mint(address to, uint256 amt) external { + totalSupply += amt; + balanceOf[to] += amt; + emit Transfer(address(0), to, amt); + } + + function approve(address spender, uint256 amt) external returns (bool) { + allowance[msg.sender][spender] = amt; + emit Approval(msg.sender, spender, amt); + return true; + } + + function transfer(address to, uint256 amt) external virtual returns (bool) { + _move(msg.sender, to, amt); + return true; + } + + function transferFrom(address from, address to, uint256 amt) external virtual returns (bool) { + _spend(from, amt); + _move(from, to, amt); + return true; + } + + function _spend(address from, uint256 amt) internal { + uint256 a = allowance[from][msg.sender]; + if (a != type(uint256).max) allowance[from][msg.sender] = a - amt; + } + + function _move(address from, address to, uint256 amt) internal virtual { + balanceOf[from] -= amt; + balanceOf[to] += amt; + emit Transfer(from, to, amt); + } +} + +/// @notice Fee-on-transfer token: burns `feeBps` of every transfer. +contract FeeOnTransferToken is MockERC20 { + uint256 public immutable feeBps; + + constructor(uint256 feeBps_) MockERC20("Fee", "FEE", 18) { + feeBps = feeBps_; + } + + function _move(address from, address to, uint256 amt) internal override { + uint256 fee = (amt * feeBps) / 10_000; + balanceOf[from] -= amt; + balanceOf[to] += amt - fee; + totalSupply -= fee; + emit Transfer(from, to, amt - fee); + } +} + +/// @notice USDT-style token whose transfer/transferFrom return nothing. +contract NoReturnToken { + mapping(address => uint256) public balanceOf; + + function mint(address to, uint256 amt) external { + balanceOf[to] += amt; + } + + function transfer(address to, uint256 amt) external { + balanceOf[msg.sender] -= amt; + balanceOf[to] += amt; + } +} + +/// @notice USDC-like token: 6 decimals, admin blacklist on sender and recipient. +contract BlacklistUSDC is MockERC20 { + mapping(address => bool) public blacklisted; + + constructor() MockERC20("USD Coin", "USDC", 6) {} + + function blacklist(address a, bool b) external { + blacklisted[a] = b; + } + + function _move(address from, address to, uint256 amt) internal override { + require(!blacklisted[from] && !blacklisted[to], "Blacklistable: account is blacklisted"); + super._move(from, to, amt); + } +} diff --git a/test/foundry/unit/AgentJob.t.sol b/test/foundry/unit/AgentJob.t.sol new file mode 100644 index 0000000..edb8754 --- /dev/null +++ b/test/foundry/unit/AgentJob.t.sol @@ -0,0 +1,348 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {AgentJob} from "../../../contracts/AgentJob.sol"; +import {ToggleReceiver, Reenterer} from "../utils/Actors.sol"; + +contract AgentJobTest is Test { + AgentJob job; + address client = makeAddr("client"); + address provider = makeAddr("provider"); + address evaluator = makeAddr("evaluator"); + address stranger = makeAddr("stranger"); + bytes32 constant SPEC = keccak256("spec"); + bytes32 constant DELIV = keccak256("deliverable"); + bytes32 constant REASON = keccak256("reason"); + + event JobCreated(uint256 indexed jobId, address indexed client, address indexed evaluator, address provider, uint96 escrow, bytes32 specHash); + event Submitted(uint256 indexed jobId, bytes32 deliverable); + event Completed(uint256 indexed jobId, bytes32 reason, address provider, uint96 paid); + event Rejected(uint256 indexed jobId, bytes32 reason, address client, uint96 refunded); + + function setUp() public { + job = new AgentJob(); + vm.deal(client, 1_000 ether); + } + + function _create(uint256 amt) internal returns (uint256 id) { + vm.prank(client); + id = job.createJob{value: amt}(provider, evaluator, SPEC); + } + + function _submitted(uint256 amt) internal returns (uint256 id) { + id = _create(amt); + vm.prank(provider); + job.submit(id, DELIV, ""); + } + + function _assertJob(uint256 id, AgentJob.State st, bytes32 d, bytes32 r, uint96 esc) internal view { + (AgentJob.State s, bytes32 spec, bytes32 dd, bytes32 rr, uint96 e) = job.jobState(id); + assertEq(uint8(s), uint8(st), "state"); + assertEq(spec, SPEC, "spec"); + assertEq(dd, d, "deliverable"); + assertEq(rr, r, "reason"); + assertEq(e, esc, "escrow"); + } + + // ───────────── createJob ───────────── + function test_createJob_storesEverything() public { + vm.expectEmit(true, true, true, true, address(job)); + emit JobCreated(1, client, evaluator, provider, 1 ether, SPEC); + uint256 id = _create(1 ether); + assertEq(id, 1); + assertEq(job.jobCount(), 1); + assertEq(address(job).balance, 1 ether); + (address c, address p, address e, uint96 esc, bytes32 spec, bytes32 d, bytes32 r, AgentJob.State s) = job.jobs(id); + assertEq(c, client); + assertEq(p, provider); + assertEq(e, evaluator); + assertEq(esc, 1 ether); + assertEq(spec, SPEC); + assertEq(d, bytes32(0)); + assertEq(r, bytes32(0)); + assertEq(uint8(s), uint8(AgentJob.State.Open)); + } + + function test_createJob_idsIncrement() public { + assertEq(_create(1), 1); + assertEq(_create(2), 2); + assertEq(_create(3), 3); + assertEq(job.jobCount(), 3); + assertEq(address(job).balance, 6); + } + + function test_createJob_revert_zeroProvider() public { + vm.prank(client); + vm.expectRevert(AgentJob.ZeroAddress.selector); + job.createJob{value: 1}(address(0), evaluator, SPEC); + } + + function test_createJob_revert_zeroEvaluator() public { + vm.prank(client); + vm.expectRevert(AgentJob.ZeroAddress.selector); + job.createJob{value: 1}(provider, address(0), SPEC); + } + + function test_createJob_revert_zeroEscrow() public { + vm.prank(client); + vm.expectRevert(AgentJob.ZeroEscrow.selector); + job.createJob(provider, evaluator, SPEC); + } + + function testFuzz_createJob_escrowEqualsValue(uint96 amt) public { + amt = uint96(bound(amt, 1, type(uint96).max)); + vm.deal(client, amt); + uint256 id = _create(amt); + (, , , uint96 esc, , , , ) = job.jobs(id); + assertEq(esc, amt); + assertEq(address(job).balance, amt); + } + + /// BUG (AJ-01): escrow is stored as uint96(msg.value) with no bound check. Any msg.value + /// >= 2^96 wei is silently truncated: the contract keeps the full value but only the low 96 + /// bits are ever paid out, so the remainder is locked forever. Requires ~7.9e10 units of an + /// 18-decimal native token, so practically unreachable on most chains, but the escrow + /// invariant (escrow == value received) is not enforced. + function test_BUG_createJob_truncatesEscrowAbove2pow96() public { + uint256 amt = uint256(type(uint96).max) + 2; + vm.deal(client, amt); + uint256 id = _create(amt); + (, , , uint96 esc, , , , ) = job.jobs(id); + assertEq(uint256(esc), amt, "escrow truncated to uint96"); + } + + // ───────────── submit ───────────── + function test_submit_setsDeliverable() public { + uint256 id = _create(1 ether); + vm.expectEmit(true, false, false, true, address(job)); + emit Submitted(id, DELIV); + vm.prank(provider); + job.submit(id, DELIV, hex"beef"); + _assertJob(id, AgentJob.State.Submitted, DELIV, bytes32(0), 1 ether); + } + + function test_submit_revert_notProvider() public { + uint256 id = _create(1 ether); + vm.prank(stranger); + vm.expectRevert(AgentJob.NotProvider.selector); + job.submit(id, DELIV, ""); + } + + function test_submit_revert_nonexistentJob() public { + vm.prank(provider); + vm.expectRevert(AgentJob.NotProvider.selector); + job.submit(42, DELIV, ""); + } + + function test_submit_revert_twice() public { + uint256 id = _submitted(1 ether); + vm.prank(provider); + vm.expectRevert(AgentJob.BadState.selector); + job.submit(id, keccak256("other"), ""); + } + + function test_submit_revert_afterComplete() public { + uint256 id = _submitted(1 ether); + vm.prank(evaluator); + job.complete(id, REASON, ""); + vm.prank(provider); + vm.expectRevert(AgentJob.BadState.selector); + job.submit(id, DELIV, ""); + } + + // ───────────── complete ───────────── + function test_complete_paysProvider() public { + uint256 id = _submitted(3 ether); + vm.expectEmit(true, false, false, true, address(job)); + emit Completed(id, REASON, provider, 3 ether); + vm.prank(evaluator); + job.complete(id, REASON, ""); + assertEq(provider.balance, 3 ether); + assertEq(address(job).balance, 0); + _assertJob(id, AgentJob.State.Completed, DELIV, REASON, 0); + } + + function test_complete_revert_notEvaluator() public { + uint256 id = _submitted(1 ether); + address[3] memory who = [client, provider, stranger]; + for (uint256 i; i < 3; i++) { + vm.prank(who[i]); + vm.expectRevert(AgentJob.NotEvaluator.selector); + job.complete(id, REASON, ""); + } + } + + function test_complete_revert_whileOpen() public { + uint256 id = _create(1 ether); + vm.prank(evaluator); + vm.expectRevert(AgentJob.BadState.selector); + job.complete(id, REASON, ""); + } + + function test_complete_revert_twice_noDoublePay() public { + uint256 id = _submitted(1 ether); + vm.prank(evaluator); + job.complete(id, REASON, ""); + vm.prank(evaluator); + vm.expectRevert(AgentJob.BadState.selector); + job.complete(id, REASON, ""); + assertEq(provider.balance, 1 ether); + } + + function test_complete_revert_afterReject() public { + uint256 id = _submitted(1 ether); + vm.prank(evaluator); + job.reject(id, REASON, ""); + vm.prank(evaluator); + vm.expectRevert(AgentJob.BadState.selector); + job.complete(id, REASON, ""); + } + + function test_complete_revert_providerCannotReceive() public { + ToggleReceiver p = new ToggleReceiver(); + vm.prank(client); + uint256 id = job.createJob{value: 1 ether}(address(p), evaluator, SPEC); + p.exec(address(job), 0, abi.encodeCall(AgentJob.submit, (id, DELIV, ""))); + p.setAccept(false); + vm.prank(evaluator); + vm.expectRevert(AgentJob.TransferFailed.selector); + job.complete(id, REASON, ""); + // state rolled back: evaluator can still reject and refund the client + _assertJob(id, AgentJob.State.Submitted, DELIV, bytes32(0), 1 ether); + vm.prank(evaluator); + job.reject(id, REASON, ""); + assertEq(client.balance, 1_000 ether); + } + + function test_complete_reentrancyByProviderFails() public { + Reenterer p = new Reenterer(); + vm.prank(client); + uint256 id = job.createJob{value: 1 ether}(address(p), address(p), SPEC); + p.exec(address(job), 0, abi.encodeCall(AgentJob.submit, (id, DELIV, ""))); + p.arm(address(job), abi.encodeCall(AgentJob.reject, (id, REASON, ""))); + p.exec(address(job), 0, abi.encodeCall(AgentJob.complete, (id, REASON, ""))); + assertTrue(p.reentered()); + assertFalse(p.reentrySucceeded(), "re-entrant reject must fail"); + assertEq(address(p).balance, 1 ether); + assertEq(address(job).balance, 0); + } + + // ───────────── reject ───────────── + function test_reject_refundsClient() public { + uint256 id = _submitted(2 ether); + uint256 before = client.balance; + vm.expectEmit(true, false, false, true, address(job)); + emit Rejected(id, REASON, client, 2 ether); + vm.prank(evaluator); + job.reject(id, REASON, ""); + assertEq(client.balance, before + 2 ether); + assertEq(provider.balance, 0); + _assertJob(id, AgentJob.State.Rejected, DELIV, REASON, 0); + } + + function test_reject_revert_notEvaluator() public { + uint256 id = _submitted(1 ether); + vm.prank(client); + vm.expectRevert(AgentJob.NotEvaluator.selector); + job.reject(id, REASON, ""); + } + + function test_reject_revert_whileOpen() public { + uint256 id = _create(1 ether); + vm.prank(evaluator); + vm.expectRevert(AgentJob.BadState.selector); + job.reject(id, REASON, ""); + } + + function test_reject_revert_twice_noDoubleRefund() public { + uint256 id = _submitted(1 ether); + vm.prank(evaluator); + job.reject(id, REASON, ""); + uint256 bal = client.balance; + vm.prank(evaluator); + vm.expectRevert(AgentJob.BadState.selector); + job.reject(id, REASON, ""); + assertEq(client.balance, bal); + } + + function test_reject_revert_clientCannotReceive() public { + ToggleReceiver c = new ToggleReceiver(); + vm.deal(address(c), 1 ether); + bytes memory ret = c.exec(address(job), 1 ether, abi.encodeCall(AgentJob.createJob, (provider, evaluator, SPEC))); + uint256 id = abi.decode(ret, (uint256)); + vm.prank(provider); + job.submit(id, DELIV, ""); + c.setAccept(false); + vm.prank(evaluator); + vm.expectRevert(AgentJob.TransferFailed.selector); + job.reject(id, REASON, ""); + } + + /// Finding AJ-02 (documented behaviour): if BOTH the provider and the client cannot receive + /// native value (e.g. blacklisted / non-payable contracts), the escrow is locked forever: + /// neither complete nor reject can succeed and there is no timeout or rescue path. + function test_finding_escrowLockedWhenNeitherPartyCanReceive() public { + ToggleReceiver c = new ToggleReceiver(); + ToggleReceiver p = new ToggleReceiver(); + vm.deal(address(c), 1 ether); + uint256 id = abi.decode( + c.exec(address(job), 1 ether, abi.encodeCall(AgentJob.createJob, (address(p), evaluator, SPEC))), (uint256) + ); + p.exec(address(job), 0, abi.encodeCall(AgentJob.submit, (id, DELIV, ""))); + c.setAccept(false); + p.setAccept(false); + vm.startPrank(evaluator); + vm.expectRevert(AgentJob.TransferFailed.selector); + job.complete(id, REASON, ""); + vm.expectRevert(AgentJob.TransferFailed.selector); + job.reject(id, REASON, ""); + vm.stopPrank(); + assertEq(address(job).balance, 1 ether); + } + + /// Finding AJ-03 (documented behaviour): a job that is never submitted has no expiry, so + /// the client's escrow stays locked unless the provider submits. + function test_finding_openJobHasNoTimeoutRefund() public { + uint256 id = _create(1 ether); + vm.warp(block.timestamp + 3650 days); + vm.prank(evaluator); + vm.expectRevert(AgentJob.BadState.selector); + job.reject(id, REASON, ""); + } + + // ───────────── fuzz: full lifecycle conserves value ───────────── + function testFuzz_lifecycle_conservesValue(uint96 amt, bool pass, bytes32 d, bytes32 r) public { + amt = uint96(bound(amt, 1, 1_000 ether)); + uint256 id = _create(amt); + vm.prank(provider); + job.submit(id, d, ""); + uint256 cBefore = client.balance; + vm.prank(evaluator); + if (pass) job.complete(id, r, ""); + else job.reject(id, r, ""); + assertEq(address(job).balance, 0); + if (pass) assertEq(provider.balance, amt); + else assertEq(client.balance, cBefore + amt); + _assertJobFuzz(id, pass ? AgentJob.State.Completed : AgentJob.State.Rejected, d, r); + } + + function _assertJobFuzz(uint256 id, AgentJob.State st, bytes32 d, bytes32 r) internal view { + (AgentJob.State s, , bytes32 dd, bytes32 rr, uint96 e) = job.jobState(id); + assertEq(uint8(s), uint8(st)); + assertEq(dd, d); + assertEq(rr, r); + assertEq(e, 0); + } + + function testFuzz_onlyEvaluatorCanSettle(address caller) public { + vm.assume(caller != evaluator); + uint256 id = _submitted(1 ether); + vm.startPrank(caller); + vm.expectRevert(AgentJob.NotEvaluator.selector); + job.complete(id, REASON, ""); + vm.expectRevert(AgentJob.NotEvaluator.selector); + job.reject(id, REASON, ""); + vm.stopPrank(); + } +} diff --git a/test/foundry/unit/MockRefundProtocol.t.sol b/test/foundry/unit/MockRefundProtocol.t.sol new file mode 100644 index 0000000..e8d4de1 --- /dev/null +++ b/test/foundry/unit/MockRefundProtocol.t.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {MockRefundProtocol} from "../../../contracts/MockRefundProtocol.sol"; + +contract MockRefundProtocolTest is Test { + MockRefundProtocol m; + address arb = makeAddr("arbiter"); + address other = makeAddr("other"); + + event RefundByArbiter(uint256 indexed paymentID, address indexed caller); + event LockupSet(address indexed recipient, uint256 seconds_); + + function setUp() public { + m = new MockRefundProtocol(arb); + } + + function test_constructor() public view { + assertEq(m.arbiter(), arb); + assertEq(m.refundCount(), 0); + } + + function test_setArbiter() public { + vm.prank(arb); + m.setArbiter(other); + assertEq(m.arbiter(), other); + vm.prank(arb); + vm.expectRevert(MockRefundProtocol.CallerNotAllowed.selector); + m.setArbiter(arb); + } + + function test_setArbiter_revert_notArbiter() public { + vm.prank(other); + vm.expectRevert(MockRefundProtocol.CallerNotAllowed.selector); + m.setArbiter(other); + } + + function test_refundByArbiter() public { + vm.expectEmit(true, true, false, false, address(m)); + emit RefundByArbiter(5, arb); + vm.prank(arb); + m.refundByArbiter(5); + assertTrue(m.refunded(5)); + assertFalse(m.refunded(6)); + assertEq(m.refundCount(), 1); + vm.prank(arb); + m.refundByArbiter(5); // mock does not dedupe; it only records calls + assertEq(m.refundCount(), 2); + } + + function test_refundByArbiter_revert_notArbiter() public { + vm.prank(other); + vm.expectRevert(MockRefundProtocol.CallerNotAllowed.selector); + m.refundByArbiter(1); + } + + function test_setLockupSeconds() public { + vm.expectEmit(true, false, false, true, address(m)); + emit LockupSet(other, 3600); + vm.prank(arb); + m.setLockupSeconds(other, 3600); + assertEq(m.lockupSeconds(other), 3600); + } + + function test_setLockupSeconds_revert_notArbiter() public { + vm.prank(other); + vm.expectRevert(MockRefundProtocol.CallerNotAllowed.selector); + m.setLockupSeconds(other, 1); + } + + function testFuzz_onlyArbiter(address caller, uint256 id) public { + vm.assume(caller != arb); + vm.startPrank(caller); + vm.expectRevert(MockRefundProtocol.CallerNotAllowed.selector); + m.refundByArbiter(id); + vm.expectRevert(MockRefundProtocol.CallerNotAllowed.selector); + m.setLockupSeconds(caller, id); + vm.expectRevert(MockRefundProtocol.CallerNotAllowed.selector); + m.setArbiter(caller); + vm.stopPrank(); + } +} diff --git a/test/foundry/unit/PredgeAgentValidator.t.sol b/test/foundry/unit/PredgeAgentValidator.t.sol new file mode 100644 index 0000000..3ddfb17 --- /dev/null +++ b/test/foundry/unit/PredgeAgentValidator.t.sol @@ -0,0 +1,297 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {PredgeAgentValidator} from "../../../contracts/PredgeAgentValidator.sol"; + +contract PredgeAgentValidatorTest is Test { + PredgeAgentValidator reg; + address owner = makeAddr("owner"); + address val = makeAddr("validator"); + address val2 = makeAddr("validator2"); + address stranger = makeAddr("stranger"); + bytes32 constant H = keccak256("acceptance-test"); + bytes32 constant RH = keccak256("attestation"); + + event ValidationRequest(address indexed validatorAddress, uint256 indexed agentId, string requestURI, bytes32 indexed requestHash); + event ValidationResponse(address indexed validatorAddress, uint256 indexed agentId, bytes32 indexed requestHash, uint8 response, string responseURI, bytes32 responseHash, string tag); + event ValidatorUpdated(address indexed previousValidator, address indexed newValidator); + + function setUp() public { + vm.warp(1_700_000_000); + vm.prank(owner); + reg = new PredgeAgentValidator(val); + } + + function _req(bytes32 h, uint256 agentId) internal { + reg.validationRequest(val, agentId, "ipfs://req", h); + } + + function _resp(bytes32 h, uint8 score) internal { + vm.prank(val); + reg.validationResponse(h, score, "ipfs://resp", RH, "predge/commit-before-outcome"); + } + + // ───────────── constructor / admin ───────────── + function test_constructor() public view { + assertEq(reg.owner(), owner); + assertEq(reg.validator(), val); + assertEq(reg.requestCount(), 0); + assertEq(reg.responseCount(), 0); + } + + function test_constructor_revert_zeroValidator() public { + vm.expectRevert(PredgeAgentValidator.ZeroAddress.selector); + new PredgeAgentValidator(address(0)); + } + + function test_setValidator() public { + vm.expectEmit(true, true, false, false, address(reg)); + emit ValidatorUpdated(val, val2); + vm.prank(owner); + reg.setValidator(val2); + assertEq(reg.validator(), val2); + } + + function test_setValidator_revert_notOwner() public { + vm.prank(val); + vm.expectRevert(PredgeAgentValidator.NotOwner.selector); + reg.setValidator(val2); + } + + function test_setValidator_revert_zero() public { + vm.prank(owner); + vm.expectRevert(PredgeAgentValidator.ZeroAddress.selector); + reg.setValidator(address(0)); + } + + // ───────────── validationRequest ───────────── + function test_request_records() public { + vm.expectEmit(true, true, true, true, address(reg)); + emit ValidationRequest(val, 7, "ipfs://req", H); + vm.prank(stranger); + _req(H, 7); + assertEq(reg.requestCount(), 1); + assertEq(reg.requestURIOf(H), "ipfs://req"); + (address v, uint256 a, uint8 r, bytes32 rh, string memory tag, uint256 last) = reg.getValidationStatus(H); + assertEq(v, val); + assertEq(a, 7); + assertEq(r, 0); + assertEq(rh, bytes32(0)); + assertEq(tag, ""); + assertEq(last, block.timestamp, "lastUpdate = requestedAt while pending"); + assertFalse(reg.isValidated(H)); + assertEq(reg.commitLeadTime(H), 0); + } + + function test_request_revert_zeroHash() public { + vm.expectRevert(PredgeAgentValidator.ZeroHash.selector); + _req(bytes32(0), 1); + } + + function test_request_revert_wrongValidator() public { + vm.expectRevert(PredgeAgentValidator.NotValidator.selector); + reg.validationRequest(val2, 1, "", H); + } + + function test_request_revert_duplicate() public { + _req(H, 1); + vm.expectRevert(PredgeAgentValidator.AlreadyRequested.selector); + _req(H, 2); + } + + /// BUG (AV-01): validationRequest is permissionless and first-writer-wins, so anyone who sees + /// a pending request in the mempool can front-run it with the same requestHash and a + /// different agentId / requestURI. The honest request then reverts AlreadyRequested and the + /// validator's eventual verdict is attributed (in events and getSummary) to the squatter's + /// agentId. The honest requester should be able to bind requestHash to its own agentId. + function test_BUG_request_frontRunSquatsRequestHash() public { + address attacker = makeAddr("attacker"); + vm.prank(attacker); + reg.validationRequest(val, 666, "ipfs://attacker", H); // front-run + vm.prank(stranger); + _req(H, 7); // the honest agent's request: should succeed, reverts AlreadyRequested + (, uint256 agentId, , , , ) = reg.getValidationStatus(H); + assertEq(agentId, 7, "verdict bound to squatter's agentId"); + } + + // ───────────── validationResponse ───────────── + function test_response_records() public { + _req(H, 7); + vm.warp(block.timestamp + 100); + vm.expectEmit(true, true, true, true, address(reg)); + emit ValidationResponse(val, 7, H, 100, "ipfs://resp", RH, "predge/commit-before-outcome"); + _resp(H, 100); + assertEq(reg.responseCount(), 1); + assertTrue(reg.isValidated(H)); + assertEq(reg.commitLeadTime(H), 100); + assertEq(reg.responseURIOf(H), "ipfs://resp"); + (, , uint8 r, bytes32 rh, string memory tag, uint256 last) = reg.getValidationStatus(H); + assertEq(r, 100); + assertEq(rh, RH); + assertEq(tag, "predge/commit-before-outcome"); + assertEq(last, block.timestamp, "lastUpdate = respondedAt"); + } + + function test_response_zeroScoreIsRealVerdict() public { + _req(H, 1); + _resp(H, 0); + assertTrue(reg.isValidated(H)); + } + + function test_response_revert_notValidator() public { + _req(H, 1); + vm.prank(stranger); + vm.expectRevert(PredgeAgentValidator.NotValidator.selector); + reg.validationResponse(H, 100, "", RH, ""); + } + + function testFuzz_response_revert_badScore(uint8 score) public { + score = uint8(bound(score, 101, 255)); + _req(H, 1); + vm.prank(val); + vm.expectRevert(PredgeAgentValidator.BadScore.selector); + reg.validationResponse(H, score, "", RH, ""); + } + + function test_response_boundaryScore100ok_101reverts() public { + _req(H, 1); + vm.prank(val); + vm.expectRevert(PredgeAgentValidator.BadScore.selector); + reg.validationResponse(H, 101, "", RH, ""); + _resp(H, 100); + } + + function test_response_revert_zeroResponseHash() public { + _req(H, 1); + vm.prank(val); + vm.expectRevert(PredgeAgentValidator.ZeroHash.selector); + reg.validationResponse(H, 100, "", bytes32(0), ""); + } + + function test_response_revert_notRequested() public { + vm.prank(val); + vm.expectRevert(PredgeAgentValidator.NotRequested.selector); + reg.validationResponse(H, 100, "", RH, ""); + } + + function test_response_revert_alreadyResponded_cannotFlip() public { + _req(H, 1); + _resp(H, 100); + vm.prank(val); + vm.expectRevert(PredgeAgentValidator.AlreadyResponded.selector); + reg.validationResponse(H, 0, "", keccak256("flip"), ""); + (, , uint8 r, , , ) = reg.getValidationStatus(H); + assertEq(r, 100); + } + + function test_rotation_oldValidatorLocked_newCanAnswer_pastVerdictKept() public { + bytes32 h2 = keccak256("second"); + _req(H, 1); + _resp(H, 100); + _req(h2, 1); // requested under old validator + vm.prank(owner); + reg.setValidator(val2); + vm.prank(val); + vm.expectRevert(PredgeAgentValidator.NotValidator.selector); + reg.validationResponse(h2, 0, "", RH, ""); + vm.prank(val2); + reg.validationResponse(h2, 0, "", RH, ""); + (address v, , , , , ) = reg.getValidationStatus(h2); + assertEq(v, val, "stored validator stays the one named at request time"); + (, , uint8 r, , , ) = reg.getValidationStatus(H); + assertEq(r, 100); + // after rotation, a request naming the old validator is rejected + vm.expectRevert(PredgeAgentValidator.NotValidator.selector); + reg.validationRequest(val, 1, "", keccak256("third")); + } + + /// BUG (AV-02, low): commitLeadTime's NatSpec promises "Non-zero on every answered request", + /// but a request and its response in the same block give 0: commit-before-outcome is not + /// enforced as strictly-before by the chain. + function test_BUG_commitLeadTime_zeroWhenSameBlock() public { + _req(H, 1); + _resp(H, 100); + assertGt(reg.commitLeadTime(H), 0, "same-block response gives zero lead time"); + } + + // ───────────── getSummary ───────────── + function test_getSummary_filtersByAgentAndAnswered() public { + bytes32[] memory hs = new bytes32[](5); + for (uint256 i; i < 5; i++) hs[i] = keccak256(abi.encode(i)); + _req(hs[0], 1); + _resp(hs[0], 100); + _req(hs[1], 1); + _resp(hs[1], 50); + _req(hs[2], 1); // pending + _req(hs[3], 2); + _resp(hs[3], 0); // other agent + // hs[4] never requested + (uint64 n, uint8 avg) = reg.getSummary(1, hs); + assertEq(n, 2); + assertEq(avg, 75); + (n, avg) = reg.getSummary(2, hs); + assertEq(n, 1); + assertEq(avg, 0); + (n, avg) = reg.getSummary(3, hs); + assertEq(n, 0); + assertEq(avg, 0); + (n, avg) = reg.getSummary(1, new bytes32[](0)); + assertEq(n, 0); + } + + function test_getSummary_roundsDown() public { + bytes32[] memory hs = new bytes32[](3); + uint8[3] memory s = [uint8(100), 0, 0]; + for (uint256 i; i < 3; i++) { + hs[i] = keccak256(abi.encode(i)); + _req(hs[i], 9); + _resp(hs[i], s[i]); + } + (uint64 n, uint8 avg) = reg.getSummary(9, hs); + assertEq(n, 3); + assertEq(avg, 33); + } + + /// Duplicate hashes in the input are each counted (caller-controlled; informational). + function test_getSummary_countsDuplicates() public { + _req(H, 1); + _resp(H, 100); + bytes32[] memory hs = new bytes32[](3); + hs[0] = H; + hs[1] = H; + hs[2] = H; + (uint64 n, ) = reg.getSummary(1, hs); + assertEq(n, 3); + } + + function testFuzz_getSummary_averageMatches(uint8[8] memory scores, uint8 count) public { + count = uint8(bound(count, 1, 8)); + bytes32[] memory hs = new bytes32[](count); + uint256 sum; + for (uint256 i; i < count; i++) { + uint8 sc = uint8(bound(scores[i], 0, 100)); + hs[i] = keccak256(abi.encode("f", i)); + _req(hs[i], 5); + _resp(hs[i], sc); + sum += sc; + } + (uint64 n, uint8 avg) = reg.getSummary(5, hs); + assertEq(n, count); + assertEq(avg, uint8(sum / count)); + assertLe(avg, 100); + } + + function testFuzz_requestThenResponse_writeOnce(bytes32 h, uint256 agentId, uint8 score, bytes32 rh, uint32 dt) public { + vm.assume(h != bytes32(0) && rh != bytes32(0)); + score = uint8(bound(score, 0, 100)); + reg.validationRequest(val, agentId, "u", h); + vm.warp(block.timestamp + dt); + vm.prank(val); + reg.validationResponse(h, score, "r", rh, "t"); + assertEq(reg.commitLeadTime(h), dt); + vm.prank(val); + vm.expectRevert(PredgeAgentValidator.AlreadyResponded.selector); + reg.validationResponse(h, score, "r", rh, "t"); + } +} diff --git a/test/foundry/unit/PredgeRefundArbiter.t.sol b/test/foundry/unit/PredgeRefundArbiter.t.sol new file mode 100644 index 0000000..c956cdf --- /dev/null +++ b/test/foundry/unit/PredgeRefundArbiter.t.sol @@ -0,0 +1,560 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Test, stdStorage, StdStorage, stdError} from "forge-std/Test.sol"; +import {PredgeRefundArbiter} from "../../../contracts/PredgeRefundArbiter.sol"; +import {MockRefundProtocol} from "../../../contracts/MockRefundProtocol.sol"; +import {ToggleReceiver, Reenterer} from "../utils/Actors.sol"; + +contract PredgeRefundArbiterTest is Test { + using stdStorage for StdStorage; + + PredgeRefundArbiter arb; + MockRefundProtocol rp; + address owner = makeAddr("owner"); + address op = makeAddr("operator"); + address op2 = makeAddr("operator2"); + address payer = makeAddr("payer"); + address merchant = makeAddr("merchant"); + address anyone = makeAddr("anyone"); + uint64 constant WINDOW = 1 days; + uint96 constant MIN = 0.001 ether; + bytes32 constant EV = keccak256("payer evidence"); // keccak, not sha256: a precompile call would consume vm.prank + uint256 constant PID = 42; + + event OperatorUpdated(address indexed previousOperator, address indexed newOperator); + event EvidenceFiled(uint256 indexed paymentID, address indexed party, bytes32 evidenceHash); + event Committed(uint256 indexed paymentID, bytes32 evidenceHash, address evidenceFrom, PredgeRefundArbiter.Direction direction, uint96 bond); + event Ruled(uint256 indexed paymentID, PredgeRefundArbiter.Direction direction, bytes32 evidenceHash, uint64 ruledAt); + event Slashed(uint256 indexed paymentID, address indexed challenger, uint96 bond, bytes32 committed, bytes32 delivered); + event Reclaimed(uint256 indexed paymentID, uint96 bond); + event ParamsUpdated(uint64 challengeWindow, uint96 minBond); + + function setUp() public { + vm.warp(1_700_000_000); + // same construction order as the canonical plan: protocol points at the arbiter's future address + address predicted = vm.computeCreateAddress(owner, vm.getNonce(owner) + 1); + vm.startPrank(owner); + rp = new MockRefundProtocol(predicted); + arb = new PredgeRefundArbiter(address(rp), op, WINDOW, MIN); + vm.stopPrank(); + assertEq(address(arb), predicted); + vm.deal(op, 100 ether); + } + + function _file(address party, uint256 pid, bytes32 h) internal { + vm.prank(party); + arb.fileEvidence(pid, h); + } + + function _rule(uint256 pid, PredgeRefundArbiter.Direction d, uint256 bond) internal { + vm.prank(op); + arb.rule{value: bond}(pid, payer, EV, d); + } + + function _ruled(PredgeRefundArbiter.Direction d) internal { + _file(payer, PID, EV); + _rule(PID, d, MIN); + } + + /// Tamper with the write-once filing via raw storage, to reach the (otherwise unreachable) + /// slash path. See finding RA-01. + function _tamperFiling(uint256 pid, address party, bytes32 h) internal { + stdstore.target(address(arb)).sig("filedEvidence(uint256,address)").with_key(pid).with_key(party).checked_write(h); + } + + // ───────────── constructor / admin ───────────── + function test_constructor() public view { + assertEq(arb.owner(), owner); + assertEq(arb.operator(), op); + assertEq(address(arb.refundProtocol()), address(rp)); + assertEq(arb.challengeWindow(), WINDOW); + assertEq(arb.minBond(), MIN); + assertEq(rp.arbiter(), address(arb)); + } + + function test_constructor_revert_zeroProtocol() public { + vm.expectRevert(PredgeRefundArbiter.ZeroAddress.selector); + new PredgeRefundArbiter(address(0), op, WINDOW, MIN); + } + + function test_constructor_revert_zeroOperator() public { + vm.expectRevert(PredgeRefundArbiter.ZeroAddress.selector); + new PredgeRefundArbiter(address(rp), address(0), WINDOW, MIN); + } + + function test_setOperator() public { + vm.expectEmit(true, true, false, false, address(arb)); + emit OperatorUpdated(op, op2); + vm.prank(owner); + arb.setOperator(op2); + assertEq(arb.operator(), op2); + } + + function test_setOperator_revert_notOwner() public { + vm.prank(op); + vm.expectRevert(PredgeRefundArbiter.NotOwner.selector); + arb.setOperator(op2); + } + + function test_setOperator_revert_zero() public { + vm.prank(owner); + vm.expectRevert(PredgeRefundArbiter.ZeroAddress.selector); + arb.setOperator(address(0)); + } + + function test_setParams() public { + vm.expectEmit(false, false, false, true, address(arb)); + emit ParamsUpdated(7, 9); + vm.prank(owner); + arb.setParams(7, 9); + assertEq(arb.challengeWindow(), 7); + assertEq(arb.minBond(), 9); + } + + function test_setParams_revert_notOwner() public { + vm.prank(op); + vm.expectRevert(PredgeRefundArbiter.NotOwner.selector); + arb.setParams(0, 0); + } + + function test_setLockupSeconds_passThrough() public { + vm.prank(owner); + arb.setLockupSeconds(merchant, 3600); + assertEq(rp.lockupSeconds(merchant), 3600); + } + + function test_setLockupSeconds_revert_notOwner() public { + vm.prank(op); + vm.expectRevert(PredgeRefundArbiter.NotOwner.selector); + arb.setLockupSeconds(merchant, 1); + } + + // ───────────── fileEvidence ───────────── + function test_fileEvidence() public { + vm.expectEmit(true, true, false, true, address(arb)); + emit EvidenceFiled(PID, payer, EV); + _file(payer, PID, EV); + assertEq(arb.filedEvidence(PID, payer), EV); + _file(merchant, PID, keccak256("m")); // both parties may file + _file(payer, PID + 1, EV); // and per payment + } + + function test_fileEvidence_revert_zeroHash() public { + vm.prank(payer); + vm.expectRevert(PredgeRefundArbiter.ZeroHash.selector); + arb.fileEvidence(PID, bytes32(0)); + } + + function test_fileEvidence_revert_operator() public { + vm.prank(op); + vm.expectRevert(PredgeRefundArbiter.ArbiterCannotFile.selector); + arb.fileEvidence(PID, EV); + } + + function test_fileEvidence_revert_owner() public { + vm.prank(owner); + vm.expectRevert(PredgeRefundArbiter.ArbiterCannotFile.selector); + arb.fileEvidence(PID, EV); + } + + function test_fileEvidence_revert_self() public { + vm.prank(address(arb)); + vm.expectRevert(PredgeRefundArbiter.ArbiterCannotFile.selector); + arb.fileEvidence(PID, EV); + } + + function test_fileEvidence_revert_writeOnce() public { + _file(payer, PID, EV); + vm.prank(payer); + vm.expectRevert(abi.encodeWithSelector(PredgeRefundArbiter.EvidenceAlreadyFiled.selector, PID, payer)); + arb.fileEvidence(PID, keccak256("revised")); + } + + // ───────────── rule ───────────── + function test_rule_refund_callsProtocol() public { + _file(payer, PID, EV); + vm.expectEmit(true, false, false, true, address(arb)); + emit Committed(PID, EV, payer, PredgeRefundArbiter.Direction.Refund, MIN); + vm.expectEmit(true, false, false, true, address(arb)); + emit Ruled(PID, PredgeRefundArbiter.Direction.Refund, EV, uint64(block.timestamp)); + _rule(PID, PredgeRefundArbiter.Direction.Refund, MIN); + assertTrue(rp.refunded(PID)); + assertEq(rp.refundCount(), 1); + assertEq(arb.rulingCount(), 1); + assertEq(address(arb).balance, MIN); + (bytes32 h, address from, uint96 bond, uint64 at, PredgeRefundArbiter.Direction d, bool settled) = arb.rulingOf(PID); + assertEq(h, EV); + assertEq(from, payer); + assertEq(bond, MIN); + assertEq(at, block.timestamp); + assertEq(uint8(d), uint8(PredgeRefundArbiter.Direction.Refund)); + assertFalse(settled); + (, , , address decidedBy, , , ) = arb.rulings(PID); + assertEq(decidedBy, op); + } + + function test_rule_release_doesNotRefund() public { + _ruled(PredgeRefundArbiter.Direction.Release); + assertFalse(rp.refunded(PID)); + assertEq(rp.refundCount(), 0); + assertEq(arb.rulingCount(), 1); + } + + function test_rule_revert_notOperator() public { + _file(payer, PID, EV); + vm.deal(anyone, 1 ether); + vm.prank(anyone); + vm.expectRevert(PredgeRefundArbiter.NotOperator.selector); + arb.rule{value: MIN}(PID, payer, EV, PredgeRefundArbiter.Direction.Refund); + } + + function test_rule_revert_zeroHash() public { + vm.prank(op); + vm.expectRevert(PredgeRefundArbiter.ZeroHash.selector); + arb.rule{value: MIN}(PID, payer, bytes32(0), PredgeRefundArbiter.Direction.Refund); + } + + function test_rule_revert_zeroFrom() public { + vm.prank(op); + vm.expectRevert(PredgeRefundArbiter.ZeroAddress.selector); + arb.rule{value: MIN}(PID, address(0), EV, PredgeRefundArbiter.Direction.Refund); + } + + function test_rule_revert_directionNone() public { + vm.prank(op); + vm.expectRevert(PredgeRefundArbiter.BadDirection.selector); + arb.rule{value: MIN}(PID, payer, EV, PredgeRefundArbiter.Direction.None); + } + + function test_rule_revert_directionOutOfRange() public { + _file(payer, PID, EV); + bytes memory data = abi.encodeWithSelector(PredgeRefundArbiter.rule.selector, PID, payer, EV, uint8(3)); + vm.prank(op); + (bool ok, ) = address(arb).call{value: MIN}(data); + assertFalse(ok, "enum out of range must revert"); + } + + function test_rule_revert_bondTooSmall() public { + _file(payer, PID, EV); + vm.prank(op); + vm.expectRevert(PredgeRefundArbiter.BondTooSmall.selector); + arb.rule{value: MIN - 1}(PID, payer, EV, PredgeRefundArbiter.Direction.Refund); + } + + function test_rule_bondExactlyMinOk() public { + _file(payer, PID, EV); + _rule(PID, PredgeRefundArbiter.Direction.Refund, MIN); + } + + function test_rule_revert_noEvidenceFiled() public { + vm.prank(op); + vm.expectRevert(abi.encodeWithSelector(PredgeRefundArbiter.NoEvidenceFiled.selector, PID, payer)); + arb.rule{value: MIN}(PID, payer, EV, PredgeRefundArbiter.Direction.Refund); + } + + function test_rule_revert_citesDifferentVersion() public { + _file(payer, PID, keccak256("what the payer actually filed")); + vm.prank(op); + vm.expectRevert(abi.encodeWithSelector(PredgeRefundArbiter.NoEvidenceFiled.selector, PID, payer)); + arb.rule{value: MIN}(PID, payer, EV, PredgeRefundArbiter.Direction.Refund); + } + + function test_rule_revert_evidenceFiledForOtherPayment() public { + _file(payer, PID + 1, EV); + vm.prank(op); + vm.expectRevert(abi.encodeWithSelector(PredgeRefundArbiter.NoEvidenceFiled.selector, PID, payer)); + arb.rule{value: MIN}(PID, payer, EV, PredgeRefundArbiter.Direction.Refund); + } + + function test_rule_revert_alreadyRuled() public { + _ruled(PredgeRefundArbiter.Direction.Release); + vm.prank(op); + vm.expectRevert(abi.encodeWithSelector(PredgeRefundArbiter.AlreadyRuled.selector, PID)); + arb.rule{value: MIN}(PID, payer, EV, PredgeRefundArbiter.Direction.Refund); + } + + function test_rule_revert_alreadyRuledEvenAfterReclaim() public { + _ruled(PredgeRefundArbiter.Direction.Release); + vm.warp(block.timestamp + WINDOW); + vm.prank(op); + arb.reclaim(PID); + vm.prank(op); + vm.expectRevert(abi.encodeWithSelector(PredgeRefundArbiter.AlreadyRuled.selector, PID)); + arb.rule{value: MIN}(PID, payer, EV, PredgeRefundArbiter.Direction.Refund); + } + + /// BUG (RA-02): bond stored as uint96(msg.value) without a bound check; value >= 2^96 is + /// truncated and the excess is locked in the contract forever (same pattern as AJ-01, VB-02). + function test_BUG_rule_truncatesBondAbove2pow96() public { + _file(payer, PID, EV); + uint256 v = uint256(type(uint96).max) + 1 + MIN; + vm.deal(op, v); + vm.prank(op); + arb.rule{value: v}(PID, payer, EV, PredgeRefundArbiter.Direction.Release); + (, , uint96 bond, , , ) = arb.rulingOf(PID); + assertEq(uint256(bond), v, "bond truncated to uint96"); + } + + // ───────────── challenge ───────────── + function test_challenge_revert_notRuled() public { + vm.expectRevert(abi.encodeWithSelector(PredgeRefundArbiter.NotRuled.selector, PID)); + arb.challenge(PID); + } + + function test_challenge_revert_honestRuling() public { + _ruled(PredgeRefundArbiter.Direction.Refund); + vm.prank(anyone); + vm.expectRevert(abi.encodeWithSelector(PredgeRefundArbiter.RulingHonest.selector, PID)); + arb.challenge(PID); + // other parties filing later does not change the cited filing + _file(merchant, PID, keccak256("merchant")); + vm.expectRevert(abi.encodeWithSelector(PredgeRefundArbiter.RulingHonest.selector, PID)); + arb.challenge(PID); + } + + function test_challenge_revert_alreadySettled() public { + _ruled(PredgeRefundArbiter.Direction.Refund); + vm.warp(block.timestamp + WINDOW); + vm.prank(op); + arb.reclaim(PID); + vm.expectRevert(abi.encodeWithSelector(PredgeRefundArbiter.AlreadySettled.selector, PID)); + arb.challenge(PID); + } + + /// The slash path is only reachable if the write-once filing changes, which the contract + /// itself never allows (finding RA-01). Here storage is forced to exercise the payout logic. + function test_challenge_slashesOnTamperedFiling() public { + _ruled(PredgeRefundArbiter.Direction.Refund); + bytes32 changed = keccak256("changed"); + _tamperFiling(PID, payer, changed); + assertTrue(arb.wouldSlash(PID)); + vm.expectEmit(true, true, false, true, address(arb)); + emit Slashed(PID, anyone, MIN, EV, changed); + vm.prank(anyone); + arb.challenge(PID); + assertEq(anyone.balance, MIN); + assertEq(arb.slashCount(), 1); + (, , uint96 bond, , , bool settled) = arb.rulingOf(PID); + assertEq(bond, 0); + assertTrue(settled); + assertFalse(arb.wouldSlash(PID)); + // no double slash, no reclaim of a slashed bond + vm.expectRevert(abi.encodeWithSelector(PredgeRefundArbiter.AlreadySettled.selector, PID)); + arb.challenge(PID); + vm.warp(block.timestamp + WINDOW); + vm.prank(op); + vm.expectRevert(abi.encodeWithSelector(PredgeRefundArbiter.AlreadySettled.selector, PID)); + arb.reclaim(PID); + } + + function test_challenge_revert_challengerCannotReceive() public { + _ruled(PredgeRefundArbiter.Direction.Refund); + _tamperFiling(PID, payer, keccak256("changed")); + ToggleReceiver c = new ToggleReceiver(); + c.setAccept(false); + vm.expectRevert(PredgeRefundArbiter.TransferFailed.selector); + c.exec(address(arb), 0, abi.encodeCall(PredgeRefundArbiter.challenge, (PID))); + } + + function test_challenge_reentrancyBlocked() public { + _ruled(PredgeRefundArbiter.Direction.Refund); + _tamperFiling(PID, payer, keccak256("changed")); + Reenterer r = new Reenterer(); + r.arm(address(arb), abi.encodeCall(PredgeRefundArbiter.challenge, (PID))); + r.exec(address(arb), 0, abi.encodeCall(PredgeRefundArbiter.challenge, (PID))); + assertTrue(r.reentered()); + assertFalse(r.reentrySucceeded()); + assertEq(address(r).balance, MIN); + } + + // ───────────── reclaim ───────────── + function test_reclaim_afterWindow() public { + _ruled(PredgeRefundArbiter.Direction.Refund); + uint256 before = op.balance; + vm.warp(block.timestamp + WINDOW); + vm.expectEmit(true, false, false, true, address(arb)); + emit Reclaimed(PID, MIN); + vm.prank(op); + arb.reclaim(PID); + assertEq(op.balance, before + MIN); + assertEq(address(arb).balance, 0); + (, , uint96 bond, , , bool settled) = arb.rulingOf(PID); + assertEq(bond, 0); + assertTrue(settled); + } + + function test_reclaim_revert_windowOpen_boundary() public { + _ruled(PredgeRefundArbiter.Direction.Refund); + vm.warp(block.timestamp + WINDOW - 1); + vm.prank(op); + vm.expectRevert(abi.encodeWithSelector(PredgeRefundArbiter.WindowOpen.selector, PID)); + arb.reclaim(PID); + vm.warp(block.timestamp + 1); + vm.prank(op); + arb.reclaim(PID); + } + + function test_reclaim_revert_notOperator() public { + _ruled(PredgeRefundArbiter.Direction.Refund); + vm.warp(block.timestamp + WINDOW); + vm.prank(owner); + vm.expectRevert(PredgeRefundArbiter.NotOperator.selector); + arb.reclaim(PID); + } + + function test_reclaim_revert_notRuled() public { + vm.prank(op); + vm.expectRevert(abi.encodeWithSelector(PredgeRefundArbiter.NotRuled.selector, PID)); + arb.reclaim(PID); + } + + function test_reclaim_revert_twice() public { + _ruled(PredgeRefundArbiter.Direction.Refund); + vm.warp(block.timestamp + WINDOW); + vm.startPrank(op); + arb.reclaim(PID); + vm.expectRevert(abi.encodeWithSelector(PredgeRefundArbiter.AlreadySettled.selector, PID)); + arb.reclaim(PID); + vm.stopPrank(); + } + + function test_reclaim_revert_operatorCannotReceive() public { + ToggleReceiver o = new ToggleReceiver(); + vm.prank(owner); + arb.setOperator(address(o)); + _file(payer, PID, EV); + vm.deal(address(o), 1 ether); + o.exec(address(arb), MIN, abi.encodeCall(PredgeRefundArbiter.rule, (PID, payer, EV, PredgeRefundArbiter.Direction.Refund))); + vm.warp(block.timestamp + WINDOW); + o.setAccept(false); + vm.expectRevert(PredgeRefundArbiter.TransferFailed.selector); + o.exec(address(arb), 0, abi.encodeCall(PredgeRefundArbiter.reclaim, (PID))); + } + + // ───────────── views ───────────── + function test_wouldSlash_branches() public { + assertFalse(arb.wouldSlash(PID), "no ruling"); + _ruled(PredgeRefundArbiter.Direction.Refund); + assertFalse(arb.wouldSlash(PID), "honest"); + vm.warp(block.timestamp + WINDOW); + vm.prank(op); + arb.reclaim(PID); + _tamperFiling(PID, payer, keccak256("x")); + assertFalse(arb.wouldSlash(PID), "settled"); + } + + // ───────────── documented findings (pass = behaviour as described) ───────────── + + /// RA-01 (informational): rule() requires filedEvidence[pid][from] == evidenceHash and + /// filings are write-once, so challenge() can NEVER succeed in practice: slashing is dead + /// code and the bond secures nothing beyond "evidence was filed by some non-operator key". + function testFuzz_finding_honestRulingNeverSlashable(bytes32 h, address party, address challenger, uint32 dt) public { + vm.assume(h != bytes32(0) && party != address(0) && party != op && party != owner && party != address(arb)); + vm.prank(party); + arb.fileEvidence(PID, h); + vm.prank(op); + arb.rule{value: MIN}(PID, party, h, PredgeRefundArbiter.Direction.Refund); + vm.warp(block.timestamp + dt); + vm.prank(challenger); + vm.expectRevert(abi.encodeWithSelector(PredgeRefundArbiter.RulingHonest.selector, PID)); + arb.challenge(PID); + } + + /// RA-03 (design): `evidenceFrom` is not bound to the payment's payer or merchant. An operator + /// can file "evidence" from a sock-puppet key it controls and rule on it; the ruling is + /// unslashable. The constraint "evidence comes from the parties" is not enforced on-chain. + function test_finding_operatorCanCiteSockPuppetEvidence() public { + address puppet = makeAddr("operator-controlled puppet"); + _file(puppet, PID, EV); + vm.prank(op); + arb.rule{value: MIN}(PID, puppet, EV, PredgeRefundArbiter.Direction.Refund); + assertTrue(rp.refunded(PID)); + assertFalse(arb.wouldSlash(PID)); + } + + /// RA-04 (centralisation): setParams applies retroactively to open rulings. The owner can + /// set challengeWindow = 0 and the operator reclaims immediately; or raise it to lock bonds. + function test_finding_setParamsIsRetroactive() public { + _ruled(PredgeRefundArbiter.Direction.Refund); + vm.prank(owner); + arb.setParams(0, MIN); + vm.prank(op); + arb.reclaim(PID); // same block as the ruling + vm.prank(owner); + arb.setParams(type(uint64).max, 0); + _file(payer, PID + 1, EV); + vm.prank(op); + arb.rule(PID + 1, payer, EV, PredgeRefundArbiter.Direction.Refund); // minBond 0: zero bond allowed + vm.warp(block.timestamp + 365 days); + // RA-07: ruledAt + challengeWindow is uint64 arithmetic; a window near type(uint64).max + // overflows and reclaim panics (0x11) instead of returning WindowOpen. Bonds are locked + // until the owner lowers the window again. + vm.prank(op); + vm.expectRevert(stdError.arithmeticError); + arb.reclaim(PID + 1); + vm.prank(owner); + arb.setParams(365 days * 2, 0); + vm.prank(op); + vm.expectRevert(abi.encodeWithSelector(PredgeRefundArbiter.WindowOpen.selector, PID + 1)); + arb.reclaim(PID + 1); + } + + /// RA-05 (operational): after setOperator, bonds posted by the previous operator are paid + /// to the NEW operator on reclaim; the old key can no longer reclaim its own stake. + function test_finding_operatorRotationRedirectsBond() public { + _ruled(PredgeRefundArbiter.Direction.Refund); + vm.prank(owner); + arb.setOperator(op2); + vm.warp(block.timestamp + WINDOW); + vm.prank(op); + vm.expectRevert(PredgeRefundArbiter.NotOperator.selector); + arb.reclaim(PID); + vm.prank(op2); + arb.reclaim(PID); + assertEq(op2.balance, MIN); + } + + /// RA-06 (design): an operator that has been rotated out may then file evidence as a + /// "party" (the ArbiterCannotFile check only looks at current keys). + function test_finding_formerOperatorCanFile() public { + vm.prank(owner); + arb.setOperator(op2); + _file(op, PID, EV); + assertEq(arb.filedEvidence(PID, op), EV); + } + + function testFuzz_onlyOperatorRules(address caller) public { + vm.assume(caller != op); + _file(payer, PID, EV); + vm.deal(caller, 1 ether); + vm.prank(caller); + vm.expectRevert(PredgeRefundArbiter.NotOperator.selector); + arb.rule{value: MIN}(PID, payer, EV, PredgeRefundArbiter.Direction.Refund); + } + + function testFuzz_reclaimWindow(uint64 window, uint32 dt) public { + window = uint64(bound(window, 1, 365 days)); + vm.prank(owner); + arb.setParams(window, MIN); + _ruled(PredgeRefundArbiter.Direction.Release); + vm.warp(block.timestamp + dt); + vm.prank(op); + if (dt < window) { + vm.expectRevert(abi.encodeWithSelector(PredgeRefundArbiter.WindowOpen.selector, PID)); + arb.reclaim(PID); + } else { + arb.reclaim(PID); + assertEq(address(arb).balance, 0); + } + } + + function testFuzz_ruleBond(uint96 bond) public { + bond = uint96(bound(bond, MIN, 50 ether)); + _file(payer, PID, EV); + _rule(PID, PredgeRefundArbiter.Direction.Refund, bond); + (, , uint96 b, , , ) = arb.rulingOf(PID); + assertEq(b, bond); + assertEq(address(arb).balance, bond); + } +} diff --git a/test/foundry/unit/PredgeSettlement.t.sol b/test/foundry/unit/PredgeSettlement.t.sol new file mode 100644 index 0000000..e803b34 --- /dev/null +++ b/test/foundry/unit/PredgeSettlement.t.sol @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {PredgeSettlement} from "../../../contracts/PredgeSettlement.sol"; +import {ToggleReceiver, Reenterer, ForceSend} from "../utils/Actors.sol"; + +contract PredgeSettlementTest is Test { + PredgeSettlement s; + address owner = makeAddr("owner"); + address payer = makeAddr("payer"); + address payable treasury = payable(makeAddr("treasury")); + bytes32 constant ROUTE = keccak256("v1/wallets/{address}"); + + event Paid(address indexed payer, bytes32 indexed route, uint256 amount, uint256 timestamp, string meta); + event Withdrawn(address indexed to, uint256 amount); + + function setUp() public { + vm.prank(owner); + s = new PredgeSettlement(); + vm.deal(payer, 100 ether); + } + + function test_constructor_owner() public view { + assertEq(s.owner(), owner); + } + + function test_payForRoute_emitsReceipt() public { + vm.expectEmit(true, true, false, true, address(s)); + emit Paid(payer, ROUTE, 1 ether, block.timestamp, "0xabc"); + vm.prank(payer); + s.payForRoute{value: 1 ether}(ROUTE, "0xabc"); + assertEq(address(s).balance, 1 ether); + } + + function test_payForRoute_revert_zero() public { + vm.prank(payer); + vm.expectRevert(PredgeSettlement.ZeroPayment.selector); + s.payForRoute(ROUTE, ""); + } + + function test_receive_emitsDirectReceipt() public { + vm.expectEmit(true, true, false, true, address(s)); + emit Paid(payer, bytes32("direct"), 2 ether, block.timestamp, ""); + vm.prank(payer); + (bool ok, ) = address(s).call{value: 2 ether}(""); + assertTrue(ok); + assertEq(address(s).balance, 2 ether); + } + + function test_receive_revert_zero() public { + vm.prank(payer); + (bool ok, bytes memory ret) = address(s).call(""); + assertFalse(ok); + assertEq(bytes4(ret), PredgeSettlement.ZeroPayment.selector); + } + + function test_fallback_unknownSelectorReverts() public { + vm.prank(payer); + (bool ok, ) = address(s).call{value: 1}(hex"deadbeef"); + assertFalse(ok, "no fallback: calldata with value is rejected"); + } + + function test_withdraw_sendsWholeBalance() public { + vm.prank(payer); + s.payForRoute{value: 3 ether}(ROUTE, ""); + vm.expectEmit(true, false, false, true, address(s)); + emit Withdrawn(treasury, 3 ether); + vm.prank(owner); + s.withdraw(treasury); + assertEq(treasury.balance, 3 ether); + assertEq(address(s).balance, 0); + } + + function test_withdraw_includesForceSentValue() public { + new ForceSend{value: 5 ether}(payable(address(s))); + vm.prank(owner); + s.withdraw(treasury); + assertEq(treasury.balance, 5 ether); + } + + function test_withdraw_zeroBalanceOk() public { + vm.expectEmit(true, false, false, true, address(s)); + emit Withdrawn(treasury, 0); + vm.prank(owner); + s.withdraw(treasury); + } + + function test_withdraw_revert_notOwner() public { + vm.prank(payer); + vm.expectRevert(PredgeSettlement.NotOwner.selector); + s.withdraw(payable(payer)); + } + + function test_withdraw_revert_recipientRejects() public { + ToggleReceiver r = new ToggleReceiver(); + r.setAccept(false); + vm.prank(payer); + s.payForRoute{value: 1 ether}(ROUTE, ""); + vm.prank(owner); + vm.expectRevert(PredgeSettlement.WithdrawFailed.selector); + s.withdraw(payable(address(r))); + assertEq(address(s).balance, 1 ether); + } + + function test_withdraw_reentrancyIsHarmless() public { + Reenterer r = new Reenterer(); + r.arm(address(s), abi.encodeCall(PredgeSettlement.withdraw, (payable(address(r))))); + vm.prank(payer); + s.payForRoute{value: 1 ether}(ROUTE, ""); + vm.prank(owner); + s.withdraw(payable(address(r))); + assertFalse(r.reentrySucceeded(), "re-entrant withdraw is NotOwner"); + assertEq(address(r).balance, 1 ether); + } + + /// Finding PS-01 (informational): withdraw accepts address(0); the call succeeds and the + /// balance is burnt. There is also no ownership transfer, so a lost owner key strands funds. + function test_finding_withdrawToZeroBurns() public { + vm.prank(payer); + s.payForRoute{value: 1 ether}(ROUTE, ""); + vm.prank(owner); + s.withdraw(payable(address(0))); + assertEq(address(s).balance, 0); + assertEq(address(0).balance, 1 ether); + } + + function testFuzz_payments_accumulate_thenWithdraw(uint64[5] memory amts) public { + uint256 total; + for (uint256 i; i < 5; i++) { + uint256 a = bound(amts[i], 1, 10 ether); + vm.prank(payer); + if (i % 2 == 0) s.payForRoute{value: a}(bytes32(i), "m"); + else { + (bool ok, ) = address(s).call{value: a}(""); + assertTrue(ok); + } + total += a; + } + assertEq(address(s).balance, total); + vm.prank(owner); + s.withdraw(treasury); + assertEq(treasury.balance, total); + } + + function testFuzz_withdraw_onlyOwner(address caller) public { + vm.assume(caller != owner); + vm.prank(caller); + vm.expectRevert(PredgeSettlement.NotOwner.selector); + s.withdraw(payable(caller)); + } +} diff --git a/test/foundry/unit/PredgeValidatorBond.t.sol b/test/foundry/unit/PredgeValidatorBond.t.sol new file mode 100644 index 0000000..99176c0 --- /dev/null +++ b/test/foundry/unit/PredgeValidatorBond.t.sol @@ -0,0 +1,606 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {PredgeValidatorBond} from "../../../contracts/PredgeValidatorBond.sol"; +import {AgentJob} from "../../../contracts/AgentJob.sol"; +import {MockJob} from "../mocks/MockJob.sol"; +import {ToggleReceiver, Reenterer} from "../utils/Actors.sol"; + +contract PredgeValidatorBondTest is Test { + PredgeValidatorBond bond; + AgentJob job; + address owner = makeAddr("owner"); + address val = makeAddr("validator"); + address val2 = makeAddr("validator2"); + address client = makeAddr("client"); + address provider = makeAddr("provider"); + address anyone = makeAddr("anyone"); + uint64 constant WINDOW = 1 days; + bytes32 constant REQ = keccak256("request"); + bytes32 constant GOOD = keccak256("expected deliverable"); + bytes32 constant BAD = keccak256("garbage"); + uint96 constant B = 1 ether; + uint256 jobId; + + event Committed(bytes32 indexed requestHash, bytes32 expected, uint96 bond, uint64 stakedAt); + event Scored(bytes32 indexed requestHash, uint8 score, uint64 scoredAt); + event Slashed(bytes32 indexed requestHash, address indexed challenger, uint96 bond, uint8 recordedScore, bytes32 deliveredHash); + event Reclaimed(bytes32 indexed requestHash, uint96 bond); + event ValidatorUpdated(address indexed previousValidator, address indexed newValidator); + + function setUp() public { + vm.warp(1_700_000_000); + job = new AgentJob(); + vm.prank(owner); + bond = new PredgeValidatorBond(val, address(job), WINDOW); + vm.deal(val, 1_000 ether); + vm.deal(client, 1_000 ether); + jobId = _newJob(REQ, val); + } + + function _newJob(bytes32 spec, address evaluator) internal returns (uint256 id) { + vm.prank(client); + id = job.createJob{value: 1 ether}(provider, evaluator, spec); + } + + function _stake() internal { + vm.prank(val); + bond.stakeAndCommit{value: B}(REQ, GOOD, jobId); + } + + function _submit(bytes32 d) internal { + vm.prank(provider); + job.submit(jobId, d, ""); + } + + function _score(uint8 s) internal { + vm.prank(val); + bond.recordScore(REQ, s); + } + + function _stakeSubmitScore(bytes32 d, uint8 s) internal { + _stake(); + _submit(d); + _score(s); + } + + function _stake_() internal view returns (PredgeValidatorBond.Stake memory st) { + (st.expected, st.bond, st.stakedAt, st.score, st.scored, st.closed, st.jobId, st.scoredAt) = bond.stakes(REQ); + } + + // ───────────── constructor / admin ───────────── + function test_constructor() public view { + assertEq(bond.owner(), owner); + assertEq(bond.validator(), val); + assertEq(address(bond.job()), address(job)); + assertEq(bond.disputeWindow(), WINDOW); + assertEq(bond.totalBonded(), 0); + } + + function test_constructor_revert_zeroValidator() public { + vm.expectRevert(PredgeValidatorBond.ZeroAddress.selector); + new PredgeValidatorBond(address(0), address(job), WINDOW); + } + + function test_constructor_revert_zeroJob() public { + vm.expectRevert(PredgeValidatorBond.ZeroAddress.selector); + new PredgeValidatorBond(val, address(0), WINDOW); + } + + function test_setValidator() public { + vm.expectEmit(true, true, false, false, address(bond)); + emit ValidatorUpdated(val, val2); + vm.prank(owner); + bond.setValidator(val2); + assertEq(bond.validator(), val2); + } + + function test_setValidator_revert_notOwner() public { + vm.prank(val); + vm.expectRevert(PredgeValidatorBond.NotOwner.selector); + bond.setValidator(val2); + } + + function test_setValidator_revert_zero() public { + vm.prank(owner); + vm.expectRevert(PredgeValidatorBond.ZeroAddress.selector); + bond.setValidator(address(0)); + } + + // ───────────── stakeAndCommit ───────────── + function test_stake_records() public { + vm.expectEmit(true, false, false, true, address(bond)); + emit Committed(REQ, GOOD, B, uint64(block.timestamp)); + _stake(); + PredgeValidatorBond.Stake memory s = _stake_(); + assertEq(s.expected, GOOD); + assertEq(s.bond, B); + assertEq(s.stakedAt, block.timestamp); + assertEq(s.jobId, jobId); + assertFalse(s.scored); + assertFalse(s.closed); + assertEq(bond.totalBonded(), B); + assertEq(address(bond).balance, B); + } + + function test_stake_revert_notValidator() public { + vm.deal(anyone, 1 ether); + vm.prank(anyone); + vm.expectRevert(PredgeValidatorBond.NotValidator.selector); + bond.stakeAndCommit{value: B}(REQ, GOOD, jobId); + } + + function test_stake_revert_zeroExpected() public { + vm.prank(val); + vm.expectRevert(PredgeValidatorBond.ZeroHash.selector); + bond.stakeAndCommit{value: B}(REQ, bytes32(0), jobId); + } + + function test_stake_revert_zeroBond() public { + vm.prank(val); + vm.expectRevert(PredgeValidatorBond.ZeroBond.selector); + bond.stakeAndCommit(REQ, GOOD, jobId); + } + + function test_stake_revert_alreadyCommitted() public { + _stake(); + vm.prank(val); + vm.expectRevert(PredgeValidatorBond.AlreadyCommitted.selector); + bond.stakeAndCommit{value: B}(REQ, GOOD, jobId); + } + + function test_stake_revert_specMismatch() public { + uint256 other = _newJob(keccak256("other spec"), val); + vm.prank(val); + vm.expectRevert(PredgeValidatorBond.JobNotBound.selector); + bond.stakeAndCommit{value: B}(REQ, GOOD, other); + } + + function test_stake_revert_evaluatorMismatch() public { + uint256 other = _newJob(REQ, anyone); + vm.prank(val); + vm.expectRevert(PredgeValidatorBond.JobNotBound.selector); + bond.stakeAndCommit{value: B}(REQ, GOOD, other); + } + + function test_stake_revert_nonexistentJob() public { + vm.prank(val); + vm.expectRevert(PredgeValidatorBond.JobNotBound.selector); + bond.stakeAndCommit{value: B}(REQ, GOOD, 999); + } + + function test_stake_revert_providerIsValidator() public { + vm.prank(client); + uint256 id = job.createJob{value: 1}(val, val, keccak256("self")); + vm.prank(val); + vm.expectRevert(PredgeValidatorBond.ProviderIsValidator.selector); + bond.stakeAndCommit{value: B}(keccak256("self"), GOOD, id); + } + + /// BUG (VB-01): the contract's own model is "commits the acceptance test BEFORE the work + /// exists", but stakeAndCommit never checks the job state. A validator can wait until the + /// provider has submitted, read the deliverable, and set `expected` = that deliverable with + /// score 100: the verdict is then unslashable whatever was delivered, so the bond proves + /// nothing about commit-before-outcome. Expected: stake must revert once the job is Submitted. + function test_BUG_stake_allowedAfterDeliverableIsPublic() public { + _submit(BAD); // provider delivers garbage; it is now on-chain + vm.prank(val); + vm.expectRevert(); // should revert: work already delivered + bond.stakeAndCommit{value: B}(REQ, BAD, jobId); + } + + /// Same as VB-01, end-to-end: the dishonest pass is unslashable and the bond comes back. + function test_finding_postHocCommitMakesFalsePassUnslashable() public { + _submit(BAD); + vm.prank(val); + bond.stakeAndCommit{value: B}(REQ, BAD, jobId); + _score(100); + assertFalse(bond.wouldSlash(REQ)); + vm.expectRevert(PredgeValidatorBond.VerdictHonest.selector); + bond.challenge(REQ); + vm.warp(block.timestamp + WINDOW); + vm.prank(val); + bond.reclaim(REQ); + } + + /// BUG (VB-02): bond stored as uint96(msg.value) and totalBonded += uint96(msg.value): + /// values >= 2^96 are truncated and the excess is locked forever. + function test_BUG_stake_truncatesBondAbove2pow96() public { + uint256 v = uint256(type(uint96).max) + 1 + B; + vm.deal(val, v); + vm.prank(val); + bond.stakeAndCommit{value: v}(REQ, GOOD, jobId); + assertEq(uint256(_stake_().bond), v, "bond truncated"); + } + + // ───────────── recordScore ───────────── + function test_score_records() public { + _stake(); + vm.warp(block.timestamp + 10); + vm.expectEmit(true, false, false, true, address(bond)); + emit Scored(REQ, 100, uint64(block.timestamp)); + _score(100); + PredgeValidatorBond.Stake memory s = _stake_(); + assertTrue(s.scored); + assertEq(s.score, 100); + assertEq(s.scoredAt, block.timestamp); + } + + function test_score_zeroAllowed() public { + _stake(); + _score(0); + assertTrue(_stake_().scored); + } + + function testFuzz_score_revert_badScore(uint8 s) public { + vm.assume(s != 0 && s != 100); + _stake(); + vm.prank(val); + vm.expectRevert(PredgeValidatorBond.BadScore.selector); + bond.recordScore(REQ, s); + } + + function test_score_revert_notValidator() public { + _stake(); + vm.prank(anyone); + vm.expectRevert(PredgeValidatorBond.NotValidator.selector); + bond.recordScore(REQ, 100); + } + + function test_score_revert_notCommitted() public { + vm.prank(val); + vm.expectRevert(PredgeValidatorBond.NotCommitted.selector); + bond.recordScore(REQ, 100); + } + + function test_score_revert_alreadyScored() public { + _stake(); + _score(100); + vm.prank(val); + vm.expectRevert(PredgeValidatorBond.AlreadyScored.selector); + bond.recordScore(REQ, 0); + } + + // ───────────── challenge ───────────── + function test_challenge_revert_notCommitted() public { + vm.expectRevert(PredgeValidatorBond.NotCommitted.selector); + bond.challenge(REQ); + } + + function test_challenge_revert_notScored() public { + _stake(); + _submit(BAD); + vm.expectRevert(PredgeValidatorBond.NotScored.selector); + bond.challenge(REQ); + } + + function test_challenge_revert_notSubmitted() public { + _stake(); + _score(100); + vm.expectRevert(PredgeValidatorBond.NotSubmitted.selector); + bond.challenge(REQ); + } + + function test_challenge_revert_honestPass() public { + _stakeSubmitScore(GOOD, 100); + vm.expectRevert(PredgeValidatorBond.VerdictHonest.selector); + bond.challenge(REQ); + } + + function test_challenge_revert_honestFail() public { + _stakeSubmitScore(BAD, 0); + vm.expectRevert(PredgeValidatorBond.VerdictHonest.selector); + bond.challenge(REQ); + } + + function test_challenge_slashesFalsePass() public { + _stakeSubmitScore(BAD, 100); + assertTrue(bond.wouldSlash(REQ)); + vm.expectEmit(true, true, false, true, address(bond)); + emit Slashed(REQ, anyone, B, 100, BAD); + vm.prank(anyone); + bond.challenge(REQ); + assertEq(anyone.balance, B); + assertEq(bond.totalBonded(), 0); + assertEq(bond.slashCount(), 1); + assertEq(address(bond).balance, 0); + PredgeValidatorBond.Stake memory s = _stake_(); + assertTrue(s.closed); + assertEq(s.bond, 0); + assertFalse(bond.wouldSlash(REQ)); + } + + function test_challenge_slashesFalseFail() public { + _stakeSubmitScore(GOOD, 0); + vm.prank(anyone); + bond.challenge(REQ); + assertEq(anyone.balance, B); + } + + function test_challenge_worksAfterCompleteAndReject() public { + _stakeSubmitScore(BAD, 100); + vm.prank(val); + job.complete(jobId, keccak256("r"), ""); // state 3 + assertTrue(bond.wouldSlash(REQ)); + vm.prank(anyone); + bond.challenge(REQ); + assertEq(anyone.balance, B); + } + + function test_challenge_revert_twice_noDoubleSlash() public { + _stakeSubmitScore(BAD, 100); + vm.prank(anyone); + bond.challenge(REQ); + vm.expectRevert(PredgeValidatorBond.Closed.selector); + bond.challenge(REQ); + assertEq(anyone.balance, B); + } + + function test_challenge_revert_challengerCannotReceive() public { + _stakeSubmitScore(BAD, 100); + ToggleReceiver c = new ToggleReceiver(); + c.setAccept(false); + vm.expectRevert(PredgeValidatorBond.TransferFailed.selector); + c.exec(address(bond), 0, abi.encodeCall(PredgeValidatorBond.challenge, (REQ))); + assertEq(bond.totalBonded(), B, "rolled back"); + } + + function test_challenge_reentrancyBlocked() public { + _stakeSubmitScore(BAD, 100); + Reenterer r = new Reenterer(); + r.arm(address(bond), abi.encodeCall(PredgeValidatorBond.challenge, (REQ))); + r.exec(address(bond), 0, abi.encodeCall(PredgeValidatorBond.challenge, (REQ))); + assertTrue(r.reentered()); + assertFalse(r.reentrySucceeded()); + assertEq(address(r).balance, B); + } + + /// Note: there is no challenge deadline. A lie stays slashable after the window until the + /// validator reclaims; that is a race the challenger can win. (Safe direction; informational.) + function test_challenge_stillPossibleAfterWindowUntilReclaim() public { + _stakeSubmitScore(BAD, 100); + vm.warp(block.timestamp + WINDOW * 10); + vm.prank(anyone); + bond.challenge(REQ); + vm.prank(val); + vm.expectRevert(PredgeValidatorBond.Closed.selector); + bond.reclaim(REQ); + } + + // ───────────── reclaim ───────────── + function test_reclaim_afterWindowFromScore() public { + _stake(); + vm.warp(block.timestamp + WINDOW * 5); // long after stake + _submit(GOOD); + _score(100); + vm.prank(val); + vm.expectRevert(PredgeValidatorBond.WindowOpen.selector); + bond.reclaim(REQ); // window runs from the score, not the stake (audit fix F1) + vm.warp(block.timestamp + WINDOW - 1); + vm.prank(val); + vm.expectRevert(PredgeValidatorBond.WindowOpen.selector); + bond.reclaim(REQ); + vm.warp(block.timestamp + 1); + uint256 before = val.balance; + vm.expectEmit(true, false, false, true, address(bond)); + emit Reclaimed(REQ, B); + vm.prank(val); + bond.reclaim(REQ); + assertEq(val.balance, before + B); + assertEq(bond.totalBonded(), 0); + assertTrue(_stake_().closed); + vm.expectRevert(PredgeValidatorBond.Closed.selector); + bond.challenge(REQ); + } + + function test_reclaim_revert_notValidator() public { + _stakeSubmitScore(GOOD, 100); + vm.warp(block.timestamp + WINDOW); + vm.prank(anyone); + vm.expectRevert(PredgeValidatorBond.NotValidator.selector); + bond.reclaim(REQ); + } + + function test_reclaim_revert_notCommitted() public { + vm.prank(val); + vm.expectRevert(PredgeValidatorBond.NotCommitted.selector); + bond.reclaim(REQ); + } + + function test_reclaim_revert_notScored() public { + _stake(); + vm.warp(block.timestamp + WINDOW * 2); + vm.prank(val); + vm.expectRevert(PredgeValidatorBond.NotScored.selector); + bond.reclaim(REQ); + } + + function test_reclaim_revert_closed() public { + _stakeSubmitScore(GOOD, 100); + vm.warp(block.timestamp + WINDOW); + vm.startPrank(val); + bond.reclaim(REQ); + vm.expectRevert(PredgeValidatorBond.Closed.selector); + bond.reclaim(REQ); + vm.stopPrank(); + } + + function test_reclaim_revert_validatorCannotReceive() public { + ToggleReceiver v = new ToggleReceiver(); + vm.deal(address(v), 10 ether); + vm.prank(owner); + bond.setValidator(address(v)); + uint256 id = _newJob(keccak256("t"), address(v)); + v.exec(address(bond), B, abi.encodeCall(PredgeValidatorBond.stakeAndCommit, (keccak256("t"), GOOD, id))); + v.exec(address(bond), 0, abi.encodeCall(PredgeValidatorBond.recordScore, (keccak256("t"), 0))); + vm.warp(block.timestamp + WINDOW); + v.setAccept(false); + vm.expectRevert(PredgeValidatorBond.TransferFailed.selector); + v.exec(address(bond), 0, abi.encodeCall(PredgeValidatorBond.reclaim, (keccak256("t")))); + } + + // ───────────── wouldSlash ───────────── + function test_wouldSlash_branches() public { + assertFalse(bond.wouldSlash(REQ), "no stake"); + _stake(); + assertFalse(bond.wouldSlash(REQ), "not scored"); + _score(100); + assertFalse(bond.wouldSlash(REQ), "not submitted"); + _submit(GOOD); + assertFalse(bond.wouldSlash(REQ), "honest pass"); + } + + function test_wouldSlash_scoreZeroBranches() public { + _stakeSubmitScore(BAD, 0); + assertFalse(bond.wouldSlash(REQ), "honest fail"); + } + + function test_wouldSlash_scoreZeroLie() public { + _stakeSubmitScore(GOOD, 0); + assertTrue(bond.wouldSlash(REQ)); + } + + // ───────────── MockJob-driven edge cases ───────────── + function test_mockJob_stateBoundaries() public { + MockJob mj = new MockJob(); + vm.prank(owner); + PredgeValidatorBond b2 = new PredgeValidatorBond(val, address(mj), WINDOW); + mj.set(1, provider, val, REQ, BAD, 0); + vm.startPrank(val); + b2.stakeAndCommit{value: B}(REQ, GOOD, 1); + b2.recordScore(REQ, 100); + vm.stopPrank(); + // state 0 and 1 -> nothing to compare yet + assertFalse(b2.wouldSlash(REQ)); + vm.expectRevert(PredgeValidatorBond.NotSubmitted.selector); + b2.challenge(REQ); + mj.set(1, provider, val, REQ, BAD, 1); + vm.expectRevert(PredgeValidatorBond.NotSubmitted.selector); + b2.challenge(REQ); + // state 2 is the first comparable state + mj.set(1, provider, val, REQ, BAD, 2); + assertTrue(b2.wouldSlash(REQ)); + // the job contract is trusted: a mutable job could flip the deliverable after the fact + mj.set(1, provider, val, REQ, GOOD, 4); + assertFalse(b2.wouldSlash(REQ)); + } + + /// Kills Gambit mutant VB#33 (`s.jobId = jobId` -> `s.jobId = 1`): a stake bound to a job + /// other than #1 must read THAT job's deliverable on challenge. + function test_stake_bindsTheGivenJobId() public { + bytes32 req2 = keccak256("request 2"); + uint256 id2 = _newJob(req2, val); + assertEq(id2, 2); + _submit(GOOD); // job #1 delivers GOOD + vm.prank(provider); + job.submit(id2, BAD, ""); // job #2 delivers BAD + vm.startPrank(val); + bond.stakeAndCommit{value: B}(req2, GOOD, id2); + bond.recordScore(req2, 100); + vm.stopPrank(); + (, , , , , , uint256 bound_, ) = bond.stakes(req2); + assertEq(bound_, id2); + assertTrue(bond.wouldSlash(req2), "must compare against job #2, not job #1"); + vm.prank(anyone); + bond.challenge(req2); + assertEq(anyone.balance, B); + } + + // ───────────── documented findings ───────────── + + /// VB-03 (design): recordScore does not require the job to be Submitted. If the validator + /// scores before delivery and the provider never submits (or submits after reclaim), the + /// bond comes back with no challenge ever possible. + function test_finding_scoreBeforeDeliveryThenReclaim() public { + _stake(); + _score(100); + vm.warp(block.timestamp + WINDOW); + vm.expectRevert(PredgeValidatorBond.NotSubmitted.selector); + bond.challenge(REQ); + vm.prank(val); + bond.reclaim(REQ); + _submit(BAD); // later delivery contradicts the verdict, but the bond is gone + assertFalse(bond.wouldSlash(REQ)); + } + + /// VB-04 (operational): setValidator hands every open stake to the new key: it can score + /// stakes the old key committed, and reclaim pays the CURRENT validator. ProviderIsValidator + /// is only checked at stake time, so rotating to the job's provider re-opens the + /// "writes both sides" hole the check was meant to close. + function test_finding_rotationInheritsStakesAndBypassesProviderCheck() public { + _stake(); + vm.prank(owner); + bond.setValidator(provider); + vm.prank(provider); + bond.recordScore(REQ, 100); + _submit(GOOD); // provider now writes both sides + vm.warp(block.timestamp + WINDOW); + uint256 before = provider.balance; + vm.prank(provider); + bond.reclaim(REQ); + assertEq(provider.balance, before + B, "old validator's bond paid to new validator"); + } + + /// VB-05 (doc mismatch): NatSpec says the test is `sha256(deliverable) == expected`, but the + /// code compares the bytes32 `deliverable` directly with `expected`; no sha256 is computed. + function test_finding_comparisonIsRawEquality() public { + _stake(); + _submit(sha256(abi.encodePacked(GOOD))); + _score(100); + assertTrue(bond.wouldSlash(REQ), "sha256(expected) is not accepted; raw equality only"); + } + + // ───────────── fuzz ───────────── + function testFuzz_verdictSlashableIffLie(bytes32 expected, bytes32 delivered, bool pass) public { + vm.assume(expected != bytes32(0)); + vm.prank(val); + bond.stakeAndCommit{value: B}(REQ, expected, jobId); + _submit(delivered); + _score(pass ? 100 : 0); + bool lie = pass ? delivered != expected : delivered == expected; + assertEq(bond.wouldSlash(REQ), lie); + vm.prank(anyone); + if (lie) { + bond.challenge(REQ); + assertEq(anyone.balance, B); + } else { + vm.expectRevert(PredgeValidatorBond.VerdictHonest.selector); + bond.challenge(REQ); + } + } + + function testFuzz_reclaimWindow(uint32 dt) public { + _stakeSubmitScore(GOOD, 100); + vm.warp(block.timestamp + dt); + vm.prank(val); + if (dt < WINDOW) { + vm.expectRevert(PredgeValidatorBond.WindowOpen.selector); + bond.reclaim(REQ); + } else { + bond.reclaim(REQ); + assertEq(bond.totalBonded(), 0); + } + } + + function testFuzz_onlyValidator(address caller) public { + vm.assume(caller != val); + vm.deal(caller, 1 ether); + vm.startPrank(caller); + vm.expectRevert(PredgeValidatorBond.NotValidator.selector); + bond.stakeAndCommit{value: 1}(REQ, GOOD, jobId); + vm.expectRevert(PredgeValidatorBond.NotValidator.selector); + bond.recordScore(REQ, 100); + vm.expectRevert(PredgeValidatorBond.NotValidator.selector); + bond.reclaim(REQ); + vm.stopPrank(); + } + + function testFuzz_stakeBondAccounting(uint96 amt) public { + amt = uint96(bound(amt, 1, 500 ether)); + vm.prank(val); + bond.stakeAndCommit{value: amt}(REQ, GOOD, jobId); + assertEq(bond.totalBonded(), amt); + assertEq(address(bond).balance, amt); + } +} diff --git a/test/foundry/unit/TokenEdgeCases.t.sol b/test/foundry/unit/TokenEdgeCases.t.sol new file mode 100644 index 0000000..e4df1f9 --- /dev/null +++ b/test/foundry/unit/TokenEdgeCases.t.sol @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {AgentJob} from "../../../contracts/AgentJob.sol"; +import {PredgeSettlement} from "../../../contracts/PredgeSettlement.sol"; +import {PredgeValidatorBond} from "../../../contracts/PredgeValidatorBond.sol"; +import {PredgeRefundArbiter} from "../../../contracts/PredgeRefundArbiter.sol"; +import {MockRefundProtocol} from "../../../contracts/MockRefundProtocol.sol"; +import {FeeOnTransferToken, NoReturnToken, BlacklistUSDC, MockERC20} from "../mocks/MockTokens.sol"; + +/// All six contracts move value only as the chain's NATIVE token (USDC on Arc). None of them +/// calls an ERC20, so fee-on-transfer, missing-return and blacklist tokens cannot break their +/// accounting. What these tests pin down is the flip side (finding TK-01): ERC20 tokens sent to +/// any of the contracts by mistake are unrecoverable, because there is no sweep function. +contract TokenEdgeCasesTest is Test { + AgentJob job; + PredgeSettlement settle; + PredgeValidatorBond bond; + PredgeRefundArbiter arb; + FeeOnTransferToken fot; + NoReturnToken nrt; + BlacklistUSDC usdc; + address user = makeAddr("user"); + + function setUp() public { + job = new AgentJob(); + settle = new PredgeSettlement(); + bond = new PredgeValidatorBond(address(this), address(job), 1 days); + arb = new PredgeRefundArbiter(address(new MockRefundProtocol(address(0xA))), address(this), 1 days, 1); + fot = new FeeOnTransferToken(100); + nrt = new NoReturnToken(); + usdc = new BlacklistUSDC(); + fot.mint(user, 1e24); + nrt.mint(user, 1e24); + usdc.mint(user, 1e12); + } + + function _targets() internal view returns (address[4] memory t) { + t = [address(job), address(settle), address(bond), address(arb)]; + } + + function test_feeOnTransferToken_arrivesShort_andIsStuck() public { + address[4] memory t = _targets(); + for (uint256 i; i < 4; i++) { + vm.prank(user); + fot.transfer(t[i], 1000); + assertEq(fot.balanceOf(t[i]), 990, "1% fee burnt in transit"); + } + // native accounting untouched + assertEq(address(job).balance + address(settle).balance + address(bond).balance + address(arb).balance, 0); + assertEq(bond.totalBonded(), 0); + } + + function test_noReturnToken_transferSucceeds_andIsStuck() public { + vm.prank(user); + nrt.transfer(address(settle), 5); + assertEq(nrt.balanceOf(address(settle)), 5); + // the only exit is withdraw(), which moves native value only + settle.withdraw(payable(address(this))); + assertEq(nrt.balanceOf(address(settle)), 5); + } + + function test_blacklistUSDC_isIrrelevantToNativeEscrow() public { + usdc.blacklist(address(job), true); + vm.prank(user); + vm.expectRevert(bytes("Blacklistable: account is blacklisted")); + usdc.transfer(address(job), 1); + // the native escrow keeps working while the ERC20 blacklist bites + vm.deal(user, 1 ether); + vm.prank(user); + uint256 id = job.createJob{value: 1 ether}(makeAddr("p"), makeAddr("e"), bytes32("s")); + assertEq(id, 1); + } + + function test_settlementWithdrawMovesNativeOnly() public { + vm.prank(user); + usdc.transfer(address(settle), 1e6); + vm.deal(address(this), 1 ether); + settle.payForRoute{value: 1 ether}(bytes32("r"), ""); + settle.withdraw(payable(makeAddr("treasury"))); + assertEq(usdc.balanceOf(address(settle)), 1e6, "USDC (ERC20) stuck: no sweep"); + } + + receive() external payable {} +} diff --git a/test/foundry/utils/Actors.sol b/test/foundry/utils/Actors.sol new file mode 100644 index 0000000..44b884d --- /dev/null +++ b/test/foundry/utils/Actors.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +/// @notice A recipient whose native-value receive can be switched off, modelling a +/// blacklisted / non-payable counterparty (the native analogue of a USDC blacklist). +contract ToggleReceiver { + bool public accept = true; + uint256 public received; + + function setAccept(bool a) external { + accept = a; + } + + /// @notice Forward an arbitrary call (lets this contract act as client/provider/operator/validator). + function exec(address target, uint256 value, bytes calldata data) external payable returns (bytes memory) { + (bool ok, bytes memory ret) = target.call{value: value}(data); + if (!ok) { + assembly { + revert(add(ret, 32), mload(ret)) + } + } + return ret; + } + + receive() external payable { + require(accept, "blacklisted"); + received += msg.value; + } +} + +/// @notice A recipient that tries to re-enter `target` with `data` when it receives value. +/// Records whether the re-entrant call succeeded; never reverts itself. +contract Reenterer { + address public target; + bytes public data; + bool public reentered; + bool public reentrySucceeded; + + function arm(address target_, bytes calldata data_) external { + target = target_; + data = data_; + } + + function exec(address t, uint256 value, bytes calldata d) external payable returns (bytes memory) { + (bool ok, bytes memory ret) = t.call{value: value}(d); + if (!ok) { + assembly { + revert(add(ret, 32), mload(ret)) + } + } + return ret; + } + + receive() external payable { + if (target != address(0) && !reentered) { + reentered = true; + (bool ok, ) = target.call(data); + reentrySucceeded = ok; + } + } +} + +/// @notice Force-sends native value via SELFDESTRUCT (still transfers balance under Cancun / EIP-6780). +contract ForceSend { + constructor(address payable to) payable { + selfdestruct(to); + } +}