diff --git a/examples/credible-block/README.md b/examples/credible-block/README.md new file mode 100644 index 0000000..cd24acc --- /dev/null +++ b/examples/credible-block/README.md @@ -0,0 +1,55 @@ +# Credible Block guard — upgrade tests + +Integration scripts that exercise [`CredibleBlockGuard`](../../src/protection/credible_block/CredibleBlockGuard.sol)'s +`onlyCredibleBlock` modifier against a **live anvil node**, so we can validate credible-layer +contract upgrades end to end. + +The forge unit tests in +[`test/protection/credible_block/`](../../test/protection/credible_block/CredibleBlockGuard.t.sol) +fake block state with `vm.roll` / `vm.prank`. That covers the pure decision logic, but it cannot +reproduce the one thing that only exists on a real chain: a builder's *credible block marker* +transaction and a *guarded* transaction landing in the **same block** (a bundle). These scripts seed +a real node and drive mining manually so we can test exactly that. + +## Contents + +| File | Role | +| ---- | ---- | +| [`src/CredibleRegistry.sol`](./src/CredibleRegistry.sol) | Minimal deployable registry: a single immutable builder (set at construction) can mark the current block credible; implements [`ICredibleRegistry`](../../src/protection/credible_block/ICredibleRegistry.sol). The production registry ([`phylaxsystems/credible-registry`](https://github.com/phylaxsystems/credible-registry)) additionally has a timelocked admin, a builder whitelist, and timestamp slot-binding — none needed to exercise the guard. | +| [`src/GuardedCounter.sol`](./src/GuardedCounter.sol) | A concrete `CredibleBlockGuard` standing in for an upgraded credible-layer contract; its `bump()` entrypoint is `onlyCredibleBlock`. | +| [`script/test-credible-upgrades.sh`](./script/test-credible-upgrades.sh) | Orchestrator that boots anvil, deploys, and runs the three cases. | + +## Cases + +| Case | Scenario | Expectation | +| ---- | -------- | ----------- | +| **1. Credible block** | Bundle `[markCurrentBlockCredible, bump]` into one block via manual mining | Both txs succeed; counter increments | +| **2. Non-credible block** | Send only `bump()` with no marker | Reverts with `NonCredibleBlock()`; counter unchanged | +| **3. Fail-open** | Builder stops marking for `> failOpenBlockThreshold` blocks | Still reverts at the boundary (gap == threshold); passes once gap > threshold | + +## How the bundle is simulated + +Anvil auto-mines each tx into its own block by default, which would put the marker and the guarded +call in different blocks. The script turns automine **off** (`evm_setAutomine false`), submits both +txs with `cast send --async` (returns immediately without waiting for a receipt), then seals exactly +one block with `evm_mine`. Both queued txs land together; `--order fifo` guarantees the marker +executes first, so the guarded call sees the block already marked credible. + +## Running + +From the repo root: + +```shell +./examples/credible-block/script/test-credible-upgrades.sh +``` + +Requires `anvil`, `cast`, `forge`, and `jq` on `PATH`. Exits non-zero if any check fails. Env +overrides: `RPC_PORT` (default `8545`), `FAIL_OPEN_THRESHOLD` (default `10`, kept small so the +fail-open case runs quickly). + +The script uses the `credible-block` foundry profile (see `foundry.toml`); it sets +`FOUNDRY_PROFILE=credible-block` itself. + +> On macOS, `cast`/`forge` read system proxy configuration at startup, which the Claude Code Bash +> sandbox blocks (the process aborts with a NULL-object panic). Run this script with the sandbox +> disabled. diff --git a/examples/credible-block/script/test-credible-upgrades.sh b/examples/credible-block/script/test-credible-upgrades.sh new file mode 100755 index 0000000..4e9353f --- /dev/null +++ b/examples/credible-block/script/test-credible-upgrades.sh @@ -0,0 +1,226 @@ +#!/usr/bin/env bash +# +# Integration test for the Credible Layer block guard against a live anvil node. +# +# Unlike the forge unit tests (which fake block state with vm.roll/vm.prank), this drives a real +# node so we can exercise the one thing that only matters on a real chain: whether a builder's +# "credible block marker" tx and a guarded tx land in the SAME block (a bundle). +# +# It seeds anvil with: +# - a CredibleRegistry (examples/credible-block/src/CredibleRegistry.sol) with a single immutable +# builder baked in at construction (the production registry additionally gates builders behind a +# timelocked admin and a whitelist; none of that is needed to exercise the guard), +# - a "builder" account we control, passed in as that immutable builder, +# - a GuardedCounter (a concrete CredibleBlockGuard) standing in for an upgraded credible contract. +# +# Then it verifies three cases: +# 1. Credible block — bundle [marker tx, guarded tx] into one block; both must succeed. +# 2. Non-credible — send only the guarded tx (no marker); it must revert (NonCredibleBlock). +# 3. Fail-open — after the builder stops marking for > failOpenBlockThreshold blocks, the +# guarded tx must start passing again (and must still revert at the boundary). +# +# Usage: ./examples/credible-block/script/test-credible-upgrades.sh (run from the repo root) +# Requires: anvil, cast, forge, jq on PATH. + +set -euo pipefail + +# -------------------------------------------------------------------------------------------------- +# Config +# -------------------------------------------------------------------------------------------------- +RPC_PORT="${RPC_PORT:-8545}" +RPC_URL="http://127.0.0.1:${RPC_PORT}" +FAIL_OPEN_THRESHOLD="${FAIL_OPEN_THRESHOLD:-10}" # kept small so the fail-open case runs quickly + +export FOUNDRY_PROFILE=credible-block + +# Deterministic anvil dev accounts (default mnemonic). +ADMIN_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 # account 0 (deployer/admin) +BUILDER_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d # account 1 (credible builder) +USER_KEY=0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a # account 2 (unprivileged caller) +BUILDER_ADDR=$(cast wallet address "$BUILDER_KEY") +USER_ADDR=$(cast wallet address "$USER_KEY") + +GAS_LIMIT=2000000 # explicit so `cast send` skips eth_estimateGas (which would fail on reverting txs) + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; BLUE=$'\033[0;34m'; BOLD=$'\033[1m'; NC=$'\033[0m' +PASS_COUNT=0; FAIL_COUNT=0 +ANVIL_PID="" + +cleanup() { [[ -n "$ANVIL_PID" ]] && kill "$ANVIL_PID" 2>/dev/null || true; } +trap cleanup EXIT + +info() { echo "${BLUE}==>${NC} $*"; } +section() { echo; echo "${BOLD}$*${NC}"; } + +# check +check() { + local desc="$1" actual="$2" expected="$3" + if [[ "$actual" == "$expected" ]]; then + echo " ${GREEN}PASS${NC} $desc" + PASS_COUNT=$((PASS_COUNT + 1)) + else + echo " ${RED}FAIL${NC} $desc (expected '$expected', got '$actual')" + FAIL_COUNT=$((FAIL_COUNT + 1)) + fi +} + +rpc() { cast rpc --rpc-url "$RPC_URL" "$@" >/dev/null; } +mine_blocks() { rpc anvil_mine "$(cast to-hex "$1")"; } # mine N blocks instantly +receipt_status() { cast receipt --rpc-url "$RPC_URL" --async "$1" --json 2>/dev/null | jq -r '.status'; } +receipt_block() { cast to-dec "$(cast receipt --rpc-url "$RPC_URL" --async "$1" --json | jq -r '.blockNumber')"; } +counter() { cast call --rpc-url "$RPC_URL" "$GUARD" "count()(uint256)"; } + +# Submit a tx WITHOUT waiting for it to be mined; echoes the tx hash. +send_async() { + local key="$1"; shift + cast send --rpc-url "$RPC_URL" --private-key "$key" --gas-limit "$GAS_LIMIT" --async "$@" +} + +# Reset chain state to the post-deployment snapshot and re-arm the snapshot for the next case. +reset_state() { + rpc evm_revert "$SNAPSHOT" + SNAPSHOT=$(cast rpc --rpc-url "$RPC_URL" evm_snapshot | tr -d '"') + rpc evm_setAutomine false # manual mining for deterministic block composition +} + +# -------------------------------------------------------------------------------------------------- +# Boot anvil + deploy +# -------------------------------------------------------------------------------------------------- +section "Booting anvil (fifo mempool ordering) and deploying contracts" + +# Refuse to run against a pre-existing RPC listener: if the port is taken, our anvil would exit and +# the readiness loop below would happily adopt (and snapshot/revert/disable-automine) a foreign node. +if cast block-number --rpc-url "$RPC_URL" >/dev/null 2>&1; then + echo "${RED}ERROR${NC} something is already listening on $RPC_URL; refusing to run. Set RPC_PORT to a free port." >&2 + exit 1 +fi + +# fifo ordering guarantees the marker tx (submitted first) is included before the guarded tx when +# both share a block, so the guarded call sees the block already marked credible. +anvil --port "$RPC_PORT" --order fifo --silent & +ANVIL_PID=$! + +# Poll for readiness, but bail if our anvil child died (e.g. the port was grabbed between the check +# above and launch) rather than proceeding against whatever else may be answering on the port. +for _ in $(seq 1 50); do + if ! kill -0 "$ANVIL_PID" 2>/dev/null; then + echo "${RED}ERROR${NC} anvil (pid $ANVIL_PID) exited before becoming ready; check the port is free." >&2 + exit 1 + fi + cast block-number --rpc-url "$RPC_URL" >/dev/null 2>&1 && break + sleep 0.1 +done + +if ! cast block-number --rpc-url "$RPC_URL" >/dev/null 2>&1; then + echo "${RED}ERROR${NC} anvil did not become ready on $RPC_URL within the timeout." >&2 + exit 1 +fi + +pushd "$REPO_ROOT" >/dev/null + +# Deploy the registry with the builder account we control baked in as its sole marker. +REGISTRY=$(forge create examples/credible-block/src/CredibleRegistry.sol:CredibleRegistry \ + --rpc-url "$RPC_URL" --private-key "$ADMIN_KEY" --broadcast --json \ + --constructor-args "$BUILDER_ADDR" | jq -r '.deployedTo') +info "CredibleRegistry: $REGISTRY (builder=$BUILDER_ADDR)" + +GUARD=$(forge create examples/credible-block/src/GuardedCounter.sol:GuardedCounter \ + --rpc-url "$RPC_URL" --private-key "$ADMIN_KEY" --broadcast --json \ + --constructor-args "$REGISTRY" "$FAIL_OPEN_THRESHOLD" | jq -r '.deployedTo') +info "GuardedCounter: $GUARD (failOpenBlockThreshold=$FAIL_OPEN_THRESHOLD)" + +popd >/dev/null + +check "registry builder is the account we control" \ + "$(cast call --rpc-url "$RPC_URL" "$REGISTRY" "builder()(address)")" \ + "$BUILDER_ADDR" + +SNAPSHOT=$(cast rpc --rpc-url "$RPC_URL" evm_snapshot | tr -d '"') + +# -------------------------------------------------------------------------------------------------- +# Case 1: credible block — bundle [marker, guarded] into one block; both succeed. +# -------------------------------------------------------------------------------------------------- +section "Case 1: credible block (marker + guarded tx bundled in one block)" +reset_state + +# The marker must be submitted first (fifo) so it executes before the guarded call in the block. +MARKER_TX=$(send_async "$BUILDER_KEY" "$REGISTRY" "markCurrentBlockCredible()") +GUARDED_TX=$(send_async "$USER_KEY" "$GUARD" "bump()") +rpc evm_mine # seal ONE block containing both queued txs + +info "marker tx: $MARKER_TX" +info "guarded tx: $GUARDED_TX" +check "marker tx succeeded" "$(receipt_status "$MARKER_TX")" "0x1" +check "guarded tx succeeded" "$(receipt_status "$GUARDED_TX")" "0x1" +check "both txs in the same block" "$(receipt_block "$MARKER_TX")" "$(receipt_block "$GUARDED_TX")" +check "counter incremented to 1" "$(counter)" "1" + +# -------------------------------------------------------------------------------------------------- +# Case 2: non-credible block — guarded tx alone must revert. +# -------------------------------------------------------------------------------------------------- +section "Case 2: non-credible block (guarded tx with no marker reverts)" +reset_state + +# Seed one credible block first. Without it lastCredibleBlock stays 0, and the blocks already mined +# by deployment would put block.number - 0 past a small FAIL_OPEN_THRESHOLD override — tripping +# fail-open, so the guarded tx would wrongly pass and this case would report a false failure. With a +# recent credible block M seeded, the guarded tx below lands at M+1 (gap 1); since the guard rejects +# a zero threshold, every deployable threshold is >= 1, so gap 1 is never > threshold and fail-open +# stays inactive — leaving the block correctly non-credible. +SEED_TX=$(send_async "$BUILDER_KEY" "$REGISTRY" "markCurrentBlockCredible()") +rpc evm_mine +info "seeded credible block at $(receipt_block "$SEED_TX")" + +# The guarded tx's own block carries no marker, so it must revert. +GUARDED_TX=$(send_async "$USER_KEY" "$GUARD" "bump()") +rpc evm_mine +check "guarded tx reverted on-chain" "$(receipt_status "$GUARDED_TX")" "0x0" +check "counter still 0" "$(counter)" "0" + +# Static call at the now-current (non-credible) tip surfaces the revert reason. cast can't decode the +# custom error without the ABI, so we match its 4-byte selector directly. +NON_CREDIBLE_SIG=$(cast sig "NonCredibleBlock()") # 0x95ad9b59 +CALL_OUT=$(cast call --rpc-url "$RPC_URL" --from "$USER_ADDR" "$GUARD" "bump()" 2>&1 || true) +if echo "$CALL_OUT" | grep -qi "$NON_CREDIBLE_SIG"; then + echo " ${GREEN}PASS${NC} static call reverts with NonCredibleBlock()"; PASS_COUNT=$((PASS_COUNT + 1)) +else + echo " ${RED}FAIL${NC} static call did not revert with NonCredibleBlock() (got: $CALL_OUT)"; FAIL_COUNT=$((FAIL_COUNT + 1)) +fi + +# -------------------------------------------------------------------------------------------------- +# Case 3: fail-open — after the builder stops marking for > threshold blocks, guarded tx passes. +# -------------------------------------------------------------------------------------------------- +section "Case 3: fail-open once no credible block for > failOpenBlockThreshold blocks" +reset_state + +THRESHOLD=$(cast call --rpc-url "$RPC_URL" "$GUARD" "failOpenBlockThreshold()(uint256)") +info "failOpenBlockThreshold = $THRESHOLD" + +# Mark one block credible so lastCredibleBlock is set to a concrete block M. +MARKER_TX=$(send_async "$BUILDER_KEY" "$REGISTRY" "markCurrentBlockCredible()") +rpc evm_mine +M=$(receipt_block "$MARKER_TX") +info "last credible block M = $M" + +# Advance so the NEXT sealed block is M+THRESHOLD (gap == threshold, NOT > threshold => still guarded). +mine_blocks $(( THRESHOLD - 1 )) +BOUNDARY_TX=$(send_async "$USER_KEY" "$GUARD" "bump()") +rpc evm_mine +check "at gap == threshold (block $(receipt_block "$BOUNDARY_TX")), guarded tx still reverts" \ + "$(receipt_status "$BOUNDARY_TX")" "0x0" + +# One more block: gap == threshold+1 (> threshold) => fail-open active, guarded tx passes. +OPEN_TX=$(send_async "$USER_KEY" "$GUARD" "bump()") +rpc evm_mine +check "at gap > threshold (block $(receipt_block "$OPEN_TX")), fail-open lets guarded tx pass" \ + "$(receipt_status "$OPEN_TX")" "0x1" +check "counter incremented to 1 via fail-open" "$(counter)" "1" + +# -------------------------------------------------------------------------------------------------- +# Summary +# -------------------------------------------------------------------------------------------------- +section "Summary: ${GREEN}${PASS_COUNT} passed${NC}, ${RED}${FAIL_COUNT} failed${NC}" +[[ "$FAIL_COUNT" -eq 0 ]] diff --git a/examples/credible-block/src/CredibleRegistry.sol b/examples/credible-block/src/CredibleRegistry.sol new file mode 100644 index 0000000..3e2df25 --- /dev/null +++ b/examples/credible-block/src/CredibleRegistry.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {ICredibleRegistry} from "credible-std/protection/credible_block/ICredibleRegistry.sol"; + +/// @notice Minimal deployable Credible Registry for the upgrade-test scripts: just enough to seed a +/// live anvil node with "a builder we control" and let the {CredibleBlockGuard} read block +/// credibility through {ICredibleRegistry}. +/// @dev Trimmed to the essentials — a single immutable builder set at construction, one marker +/// entrypoint, and the two interface reads. The production registry +/// (`phylaxsystems/credible-registry`) adds a timelocked admin, a whitelist of builders, and +/// slot-binding by timestamp; none of that is needed to exercise the guard. +contract CredibleRegistry is ICredibleRegistry { + /// @notice The only account allowed to mark blocks credible. + address public immutable builder; + + mapping(uint256 blockNumber => bool credible) internal _credible; + uint256 internal _lastCredibleBlock; + + error NotBuilder(); + + constructor(address builder_) { + builder = builder_; + } + + /// @notice Marks the current block credible. Callable only by the builder. + function markCurrentBlockCredible() external { + if (msg.sender != builder) revert NotBuilder(); + + _credible[block.number] = true; + _lastCredibleBlock = block.number; + } + + function isCredibleBlock(uint256 blockNumber) external view returns (bool) { + return _credible[blockNumber]; + } + + function lastCredibleBlock() external view returns (uint256) { + return _lastCredibleBlock; + } +} diff --git a/examples/credible-block/src/GuardedCounter.sol b/examples/credible-block/src/GuardedCounter.sol new file mode 100644 index 0000000..6617b50 --- /dev/null +++ b/examples/credible-block/src/GuardedCounter.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {CredibleBlockGuard} from "credible-std/protection/credible_block/CredibleBlockGuard.sol"; +import {ICredibleRegistry} from "credible-std/protection/credible_block/ICredibleRegistry.sol"; + +/// @notice A minimal contract that adopts the {CredibleBlockGuard} `onlyCredibleBlock` modifier, +/// standing in for a real credible-layer contract upgrade. Its guarded entrypoint (`bump`) +/// only executes inside a block a whitelisted builder marked credible, unless the guard is +/// failing open because the builder set has gone silent past `failOpenBlockThreshold`. +contract GuardedCounter is CredibleBlockGuard { + uint256 public count; + + constructor(ICredibleRegistry credibleRegistry_, uint256 failOpenBlockThreshold_) + CredibleBlockGuard(credibleRegistry_, failOpenBlockThreshold_) + {} + + /// @notice Guarded state mutation: reverts with `NonCredibleBlock` outside a credible block. + function bump() external onlyCredibleBlock { + count++; + } +} diff --git a/foundry.toml b/foundry.toml index 9df9847..7c6a123 100644 --- a/foundry.toml +++ b/foundry.toml @@ -192,6 +192,16 @@ remappings = ["credible-std/=src/"] optimizer = true via_ir = false +[profile.credible-block] +src = "examples/credible-block/src" +test = "examples/credible-block/test" +out = "examples/credible-block/out" +cache_path = "examples/credible-block/cache" +libs = ["lib"] +remappings = ["credible-std/=src/"] +optimizer = true +via_ir = false + [profile.spark] src = "examples/spark/src" test = "examples/spark/test"