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
66 changes: 66 additions & 0 deletions cmd/obol/network.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"slices"
"sort"
"strings"
"time"

"github.com/ObolNetwork/obol-stack/internal/config"
"github.com/ObolNetwork/obol-stack/internal/embed"
Expand Down Expand Up @@ -493,6 +494,17 @@ func networkStatusCommand(cfg *config.Config) *cli.Command {
return &cli.Command{
Name: "status",
Usage: "Show eRPC gateway health and upstream counts",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "no-probe",
Usage: "Skip the eth_chainId reachability probe against each upstream.",
},
&cli.DurationFlag{
Name: "probe-timeout",
Value: 2 * time.Second,
Usage: "Per-upstream probe timeout. Probes run in parallel.",
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
u := getUI(cmd)

Expand Down Expand Up @@ -528,11 +540,65 @@ func networkStatusCommand(cfg *config.Config) *cli.Command {
}
}

if cmd.Bool("no-probe") {
return nil
}

renderUpstreamProbes(ctx, cfg, u, cmd.Duration("probe-timeout"))
return nil
},
}
}

// renderUpstreamProbes runs eth_chainId against every upstream and prints a
// warning block when any upstream is unreachable or returns a chain id that
// disagrees with the chain it's pinned to in the eRPC config. The most common
// trigger is a custom pin (`obol network add <name> --endpoint <local-anvil>`)
// left over from a flow run whose Anvil has since been killed or recreated for
// a different chain.
func renderUpstreamProbes(ctx context.Context, cfg *config.Config, u uiPrinter, timeout time.Duration) {
results, err := network.ProbeAllUpstreams(ctx, cfg, timeout)
if err != nil {
u.Warnf("upstream probe skipped: %v", err)
return
}

var dead, mismatched []network.UpstreamProbeResult
for _, r := range results {
switch {
case !r.Reachable:
dead = append(dead, r)
case r.Mismatch():
mismatched = append(mismatched, r)
}
}

if len(dead) == 0 && len(mismatched) == 0 {
u.Printf("\nReachability: all %d upstream(s) responded with the expected chain id.\n", len(results))
return
}

u.Printf("\nReachability warnings:\n")
for _, r := range dead {
u.Warnf(" upstream %q (chain %d) at %s is unreachable: %s",
r.ID, r.DeclaredChain, r.Endpoint, r.Err)
}
for _, r := range mismatched {
u.Warnf(" upstream %q is pinned to chain %d but answered eth_chainId with %d (%s)",
r.ID, r.DeclaredChain, r.ObservedChain, r.Endpoint)
}
u.Printf("\nIf any of these are stale custom pins from a previous test run, drop them with:\n")
u.Printf(" obol network remove <chain-name> # e.g. obol network remove base-sepolia\n")
}

// uiPrinter is the subset of *ui.UI used by renderUpstreamProbes. Defined
// locally so tests can pass a buffer-backed printer without dragging the full
// ui.UI type into the test scope.
type uiPrinter interface {
Printf(format string, args ...any)
Warnf(format string, args ...any)
}

// chainIDToName returns a human-readable name for a chain ID.
func chainIDToName(chainID int) string {
names := map[int]string{
Expand Down
3 changes: 3 additions & 0 deletions contracts/fork-obol/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
cache/
out/
broadcast/
66 changes: 66 additions & 0 deletions contracts/fork-obol/PARITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# ForkObolToken parity vs canonical OBOL

`ForkObolToken.sol` is a deliberately minimal ERC-20 + ERC-2612 (`permit`) implementation used by the live Base Sepolia OBOL flow (`flows/flow-14-live-obol-base-sepolia.sh`) when an official OBOL deployment is not available on the target chain. It does **not** try to be drop-in compatible with the canonical OBOL token in every way — it only asserts the bits that affect x402 Permit2 signing and settlement.

This document records the parity guarantees and the deltas, so reviewers (and future contributors) can audit each one independently.

## Canonical reference

- Address (Ethereum mainnet): [`0x0B010000b7624eb9B3DfBC279673C76E9D29D5F7`](https://etherscan.io/address/0x0B010000b7624eb9B3DfBC279673C76E9D29D5F7)
- Source (verified): [Sourcify full-match](https://repo.sourcify.dev/contracts/full_match/1/0x0B010000b7624eb9B3DfBC279673C76E9D29D5F7/sources/src/ObolToken.sol) — `src/ObolToken.sol`
- Compiler: `solc 0.8.17`, optimizer enabled, 200 runs, EVM `london`
- Inheritance: OpenZeppelin `ERC20` + `ERC20Permit` + `ERC20Votes` + `AccessControl`

## What MUST match (and is asserted in tests)

The test `internal/testutil/forkobol_parity_test.go::TestForkObolToken_ParityWithCanonicalOBOL` enforces these at every `go test ./...` run.

| Invariant | Canonical value | Why it matters |
|---|---|---|
| EIP-712 domain typehash | `keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")` = `0x8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f` | The buyer signs `\x19\x01 ‖ domainSeparator ‖ structHash`. Drift here invalidates every signature. |
| Permit struct typehash | `keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)")` = `0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9` | The `structHash` the buyer signs is `keccak256(abi.encode(typehash, owner, spender, value, nonce, deadline))`. Same drift consequence. |
| Name hash | `keccak256("Obol Network")` = `0xc272cbc85e9267f7a7104c8745c6b9edcd2dcf6627beaed25edd4cf95159d5fc` | Goes into the EIP-712 domain. The ServiceOffer's `eip712Name` must equal this string verbatim. |
| Version hash | `keccak256("1")` = `0xc89efdaa54c0f20c7adf612882df0950f5a951637e0307cdcb4c672f298b8bc6` | Goes into the EIP-712 domain. The ServiceOffer's `eip712Version` must equal `"1"` verbatim. |
| `decimals` | `18` | The buyer multiplies the human-readable price by `10^decimals` before signing. Drift here means buyer signs the wrong amount. |
| Domain separator formula | `keccak256(abi.encode(typeHash, nameHash, versionHash, block.chainid, address(this)))` | This formula, applied to mainnet (`chainid=1`, address `0x0B01…`), reproduces the exact `DOMAIN_SEPARATOR()` returned by a live `cast call`: `0x5a3cd81e467dcdfe5d4ed4383d31f23bd6ce41b7be43812c5554ba9f7d949432`. |

The parity test independently:

1. Greps the `.sol` source for the keccak256 string literals (catches accidental constant edits).
2. Computes their keccak256 in Go and compares to the canonical bytes (catches typo drift).
3. Reproduces the mainnet domain separator from the formula (catches encoding drift).

## What is intentionally different

These deltas are listed so reviewers don't waste time spotting them as bugs.

| Feature | ForkObolToken | Canonical OBOL | Reason |
|---|---|---|---|
| Voting / checkpoints | absent | `ERC20Votes` (governance) | Not used by x402 Permit2; keeps the test contract small. |
| Minter access control | unrestricted `mint()` | `MINTER_ROLE`-gated | Test convenience. The contract is only deployed on testnet and a labelled fork. |
| ENS reverse registrar | absent | sets `obol.eth` reverse record | Cosmetic. |
| `burn` / `burnFrom` | absent | present | Not used by Permit2 settlement. |
| Transfer-to-self block | absent | `_to != address(this)` | Defensive only. |
| Domain separator caching | caches `_initialChainId` + `_initialDomainSeparator`, recomputes on chainId change | OpenZeppelin caches the same plus `_CACHED_THIS` | OZ caches `address(this)` to handle proxy delegatecall. ForkObolToken is not proxiable, so caching `address(this)` is unnecessary. |

## Verification recipe

For an external auditor wanting to reproduce the parity claim from scratch:

```sh
# 1. Confirm the canonical contract on mainnet still returns the expected bytes:
cast call 0x0B010000b7624eb9B3DfBC279673C76E9D29D5F7 \
'DOMAIN_SEPARATOR()(bytes32)' \
--rpc-url https://ethereum-rpc.publicnode.com
# expect: 0x5a3cd81e467dcdfe5d4ed4383d31f23bd6ce41b7be43812c5554ba9f7d949432

# 2. Run the in-tree parity test:
go test ./internal/testutil/ -run TestForkObolToken_ParityWithCanonicalOBOL -v

# 3. After deploying to your target chain (here, Base Sepolia):
cast call <YOUR_FORK_OBOL_ADDR> 'DOMAIN_SEPARATOR()(bytes32)' \
--rpc-url https://sepolia.base.org
# Plug into the formula:
# keccak256(abi.encode(0x8b73c3..., 0xc272cb..., 0xc89efd..., chainid=84532, YOUR_ADDR))
# and confirm equality.
```
14 changes: 13 additions & 1 deletion docs/guides/monetize-inference.md
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,8 @@ cat > config-sepolia.json << EOF
},
"schemes": [
{"id": "v1-eip155-exact", "chains": "eip155:*"},
{"id": "v2-eip155-exact", "chains": "eip155:*"}
{"id": "v2-eip155-exact", "chains": "eip155:*",
"config": {"eip2612_gas_sponsoring": true}}
]
}
EOF
Expand All @@ -509,6 +510,17 @@ EOF
> "rpc": [{"http": "http://127.0.0.1:8545", "rate_limit": 50}]
> ```

> [!IMPORTANT]
> **`eip2612_gas_sponsoring: true` shifts gas to the facilitator signer.**
> The OBOL Permit2 path settles `permit + transferFrom` against an ERC20Permit token in a single outer transaction; the facilitator pays gas for the permit step so the buyer never has to hold the chain's native asset. In practice the facilitator's signer wallet (`$FACILITATOR_PRIVATE_KEY`) bears that cost. If the signer balance drops below the gas needed for the next settlement, all OBOL settlements fail and paying buyers see opaque facilitator errors with no on-chain trace.
>
> Operators promoting from RC to production must:
> 1. Monitor the facilitator signer's native-asset balance on every chain it advertises (`eip155:1`, `eip155:8453`, `eip155:84532` for the OBOL chart).
> 2. Alarm well above empty — at least `100 × max_settlement_gas_price × max_settlement_gas` per chain, refilled before it trips.
> 3. Have a runbook for refilling without taking the facilitator down.
>
> The chart-side change to expose this metric to Prometheus is tracked separately in `obol-infrastructure`. Until it lands, monitor by polling `eth_getBalance` against the signer address.

Verify it's running:

```bash
Expand Down
40 changes: 40 additions & 0 deletions flows/flow-04-agent.sh
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,46 @@ else
fail "Agent inference failed — ${out:0:200}"
fi

# Regression check from the model-rank fix (PR #388): a 1B-parameter local
# model was being chosen as default, producing system-prompt parroting on a
# bare "hello" prompt (response listed Hermes/Skills/Terminal/Todo as a
# numbered tool catalogue). Send "hello", parse the message content, and
# assert it neither leaks tool-catalogue language nor blows past the byte
# budget a coherent greeting would respect.
step "Agent answers 'hello' without parroting tool catalogue (model rank regression)"
hello_out=$(curl -sf --max-time 120 -X POST "http://localhost:${AGENT_PF_PORT}/v1/chat/completions" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{\"model\":\"$model_name\",\"messages\":[{\"role\":\"user\",\"content\":\"hello\"}],\"max_tokens\":150,\"stream\":false}" 2>&1) || true
hello_content=$(echo "$hello_out" | python3 -c "
import json, sys
try:
d = json.loads(sys.stdin.read())
print(d['choices'][0]['message']['content'])
except Exception:
pass
" 2>/dev/null)

if [ -z "$hello_content" ]; then
fail "agent did not produce content for 'hello' — raw: ${hello_out:0:200}"
elif echo "$hello_content" | grep -qiE "\\*\\*(Services|Tools|Skills|Functionality)\\*\\*|^[[:space:]]*[1-9]\\..*\\*\\*(Hermes|Skills|Terminal|Todo|Vision)"; then
fail "agent parroted tool catalogue on 'hello' (model too small / prompt leak): ${hello_content:0:300}"
elif [ "${#hello_content}" -gt 600 ]; then
fail "agent reply to 'hello' is suspiciously long (${#hello_content} chars): ${hello_content:0:300}"
else
pass "agent reply to 'hello' is coherent (${#hello_content} chars)"
fi

# Belt-and-braces: the configured default must not be a 1B / 0.5B model. Any
# model whose tag declares a ≤1B parameter count is too small to handle the
# agent's system prompt and shouldn't reach this far.
step "Default model is ≥ 2B parameters"
if echo "$model_name" | grep -qiE ":(0\\.5|0\\.6|1)b\\b|:(0\\.5|0\\.6|1)b-"; then
fail "default model $model_name is too small for agent workloads"
else
pass "default model $model_name passes size floor"
fi

cleanup_pid "$PF_PID"

# §4: Ethereum signing wallet created by obol agent init (getting-started §4)
Expand Down
18 changes: 18 additions & 0 deletions flows/flow-11-dual-stack.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1112,6 +1112,24 @@ else
emit_metrics; exit 1
fi

# Correctness, not just liveness. The paid path returned 200, but the
# response body must also be a coherent answer — same regression class as
# the colleague's free-inference screenshot, except now we're validating it
# happens correctly against a paid endpoint. The prompt was a one-sentence
# question; the answer should be a single short sentence and must not parrot
# the agent's tool catalogue.
step "Paid inference: response content is a coherent answer"
PAID_CONTENT=$(echo "$inference_response" | sed -n 's/^CONTENT=//p')
if [ -z "$PAID_CONTENT" ]; then
fail "Paid inference response had no CONTENT line: ${inference_response:0:300}"
elif echo "$PAID_CONTENT" | grep -qiE "\\*\\*(Services|Tools|Skills|Functionality)\\*\\*|^[[:space:]]*[1-9]\\..*\\*\\*(Hermes|Skills|Terminal|Todo|Vision)"; then
fail "Paid inference reply parroted tool catalogue: ${PAID_CONTENT:0:300}"
elif [ "${#PAID_CONTENT}" -lt 5 ]; then
fail "Paid inference reply is suspiciously short (${#PAID_CONTENT} chars): $PAID_CONTENT"
else
pass "Paid inference reply is coherent (${#PAID_CONTENT} chars)"
fi

cleanup_pid $PF_AGENT
rm -f "$PF_AGENT_LOG"

Expand Down
24 changes: 24 additions & 0 deletions flows/flow-13-dual-stack-obol.sh
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,16 @@ flow13_cleanup() {
set +e
[ -n "$PF_AGENT" ] && cleanup_pid "$PF_AGENT" 2>/dev/null
[ -n "$PF_AGENT_LOG" ] && rm -f "$PF_AGENT_LOG" 2>/dev/null
# Drop the base-sepolia eRPC pin we added on each cluster so the next flow
# (especially flow-11 / flow-14 against live RPC) doesn't inherit a route to
# a dead Anvil fork. Safe if the cluster is already gone; the obol CLI just
# fails and we ignore the exit code.
if [ -d "$ALICE_DIR/config" ]; then
alice network remove base-sepolia >/dev/null 2>&1 || true
fi
if [ -d "$BOB_DIR/config" ]; then
bob network remove base-sepolia >/dev/null 2>&1 || true
fi
if [ -n "$FACILITATOR_PID" ] && kill -0 "$FACILITATOR_PID" 2>/dev/null; then
kill "$FACILITATOR_PID" 2>/dev/null || true
wait "$FACILITATOR_PID" 2>/dev/null || true
Expand Down Expand Up @@ -675,6 +685,20 @@ fi
export USDC_ADDRESS_BASE_SEPOLIA="$OBOL_TOKEN"
pass "OBOL token deployed at $OBOL_TOKEN"

# EIP-712 early-fail probe: the ServiceOffer below pins `eip712Name: "Obol Network"`
# / `eip712Version: "1"`. If the deployed contract's name()/version don't match,
# the buyer will sign Permit2 payloads against a different EIP-712 domain than
# the contract's permit() expects, and settlement will fail at /verify with an
# unhelpful error. Catch the mismatch here, before any signing happens.
EXPECTED_EIP712_NAME="Obol Network"
TOKEN_NAME=$(env -u CHAIN cast call "$OBOL_TOKEN" "name()(string)" \
--rpc-url "$ANVIL_RPC_HOST" 2>/dev/null | tr -d '"')
if [ "$TOKEN_NAME" != "$EXPECTED_EIP712_NAME" ]; then
fail "EIP-712 name mismatch: token reports '$TOKEN_NAME', ServiceOffer pins '$EXPECTED_EIP712_NAME'"
emit_metrics; exit 1
fi
pass "EIP-712 domain probe: token name() = '$TOKEN_NAME' matches eip712Name"

# ═════════════════════════════════════════════════════════════════
# 12. MINT 10 OBOL TO ALICE + BOB SIGNER
# (Bob signer address is unknown until his stack is up — we mint to the
Expand Down
Loading