Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions .github/workflows/foundry.yml
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "foundry-lib/forge-std"]
path = foundry-lib/forge-std
url = https://github.com/foundry-rs/forge-std
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
1 change: 1 addition & 0 deletions foundry-lib/forge-std
Submodule forge-std added at 8e4051
35 changes: 35 additions & 0 deletions foundry.toml
Original file line number Diff line number Diff line change
@@ -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"
23 changes: 23 additions & 0 deletions test/forge-parity.test.mjs
Original file line number Diff line number Diff line change
@@ -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));
});
}
143 changes: 143 additions & 0 deletions test/foundry/invariant/AgentJob.invariant.t.sol
Original file line number Diff line number Diff line change
@@ -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());
}
}
Loading
Loading