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
55 changes: 55 additions & 0 deletions examples/credible-block/README.md
Original file line number Diff line number Diff line change
@@ -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.
226 changes: 226 additions & 0 deletions examples/credible-block/script/test-credible-upgrades.sh
Original file line number Diff line number Diff line change
@@ -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
Comment thread
makemake-kbo marked this conversation as resolved.

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 <description> <actual> <expected>
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
Comment thread
makemake-kbo marked this conversation as resolved.
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 ]]
41 changes: 41 additions & 0 deletions examples/credible-block/src/CredibleRegistry.sol
Original file line number Diff line number Diff line change
@@ -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;
}
}
22 changes: 22 additions & 0 deletions examples/credible-block/src/GuardedCounter.sol
Original file line number Diff line number Diff line change
@@ -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++;
}
}
10 changes: 10 additions & 0 deletions foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading