From e2dd8689b7ba04fa2ec51db84c8d1b5c1e1853af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Fri, 10 Jul 2026 23:49:35 +0100 Subject: [PATCH] feat(skills): inspect + bridging skills, arbitrum/robinhood chain support --- CLAUDE.md | 2 +- internal/embed/skills/addresses/SKILL.md | 5 +- .../skills/addresses/references/bridges.md | 44 ++ .../addresses/references/robinhood-chain.md | 83 +++ internal/embed/skills/bridging/SKILL.md | 328 ++++++++++ internal/embed/skills/buy-x402/SKILL.md | 13 + .../skills/ethereum-local-wallet/SKILL.md | 5 +- .../ethereum-local-wallet/scripts/signer.py | 26 + .../embed/skills/ethereum-networks/SKILL.md | 32 +- internal/embed/skills/inspect/SKILL.md | 156 +++++ .../skills/inspect/references/explorers.md | 48 ++ .../embed/skills/inspect/scripts/contract.py | 572 ++++++++++++++++++ .../embed/skills/inspect/scripts/decode.py | 447 ++++++++++++++ internal/embed/skills/l2s/SKILL.md | 13 + internal/embed/skills/swap/SKILL.md | 32 +- 15 files changed, 1791 insertions(+), 15 deletions(-) create mode 100644 internal/embed/skills/addresses/references/robinhood-chain.md create mode 100644 internal/embed/skills/bridging/SKILL.md create mode 100644 internal/embed/skills/inspect/SKILL.md create mode 100644 internal/embed/skills/inspect/references/explorers.md create mode 100644 internal/embed/skills/inspect/scripts/contract.py create mode 100644 internal/embed/skills/inspect/scripts/decode.py diff --git a/CLAUDE.md b/CLAUDE.md index 4118cb4a..d3cbacca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -497,7 +497,7 @@ The Cloudflare tunnel exposes the cluster to the public internet. Only x402-gate | `internal/enclave` | `enclave.go`, `enclave_darwin.go`, `enclave_stub.go` | Secure Enclave keys | | `internal/embed` | `embed.go` | Embedded assets (skills, infrastructure, networks) | -**Embedded assets**: `internal/embed/infrastructure/` (K8s templates), `internal/embed/networks/` (ethereum, aztec), `internal/embed/skills/` (23 skills). +**Embedded assets**: `internal/embed/infrastructure/` (K8s templates), `internal/embed/networks/` (ethereum, aztec), `internal/embed/skills/` (25 skills). **Tests**: `cmd/obol/sell_test.go` (CLI flags), `internal/x402/*_test.go` (verifier, config, matcher, E2E), `internal/erc8004/*_test.go` (ABI, client), `internal/embed/embed_crd_test.go` (CRD+RBAC validation), `internal/openclaw/integration_test.go` (full-cluster inference), `internal/openclaw/overlay_test.go`, `internal/inference/gateway_test.go`, `internal/serviceoffercontroller/*_test.go` (controller, render). diff --git a/internal/embed/skills/addresses/SKILL.md b/internal/embed/skills/addresses/SKILL.md index bdf215ff..29eb7947 100644 --- a/internal/embed/skills/addresses/SKILL.md +++ b/internal/embed/skills/addresses/SKILL.md @@ -1,6 +1,6 @@ --- name: addresses -description: Verified contract addresses for major Ethereum protocols across mainnet and L2s. Use this instead of guessing. SKILL.md is an index — load the appropriate references/*.md file for the addresses you need. Start with references/obol-payments.md for the Stack's own rails (USDC + OBOL + Permit2 + ERC-8004 on the supported chains). Other categories include stablecoins, staking + Obol/Splits, DEXs, lending and DeFi, L2-native protocols, infrastructure (Safe, AA, Chainlink, EigenLayer, ENS, OpenSea), bridges (CCIP, Across), and major token addresses. Always verify on-chain via eth_getCode + eth_call before sending value. +description: Verified contract addresses for major Ethereum protocols across mainnet and L2s. Use this instead of guessing. SKILL.md is an index — load the appropriate references/*.md file for the addresses you need. Start with references/obol-payments.md for the Stack's own rails (USDC + OBOL + Permit2 + ERC-8004 on the supported chains). Other categories include stablecoins, staking + Obol/Splits, DEXs, lending and DeFi, L2-native protocols, infrastructure (Safe, AA, Chainlink, EigenLayer, ENS, OpenSea), bridges (CCIP, Across, Arbitrum One + Robinhood Chain canonical), Robinhood Chain, and major token addresses. Always verify on-chain via eth_getCode + eth_call before sending value. --- # Contract Addresses @@ -26,7 +26,8 @@ Open the file that matches the category you need: | [`references/defi.md`](references/defi.md) | Aave, Compound, MakerDAO/Sky, sDAI, Curve, Balancer, Yearn V3 | | [`references/l2-native.md`](references/l2-native.md) | Aerodrome (Base), Velodrome (OP), GMX, Pendle, Camelot, SyncSwap, Morpho | | [`references/infrastructure.md`](references/infrastructure.md) | Safe, ERC-4337 EntryPoint, Chainlink, EigenLayer, OpenSea Seaport, ENS, deterministic deployer | -| [`references/bridges.md`](references/bridges.md) | Chainlink CCIP Router, Across SpokePool | +| [`references/bridges.md`](references/bridges.md) | Chainlink CCIP Router, Across SpokePool, Arbitrum One + Robinhood Chain canonical bridges | +| [`references/robinhood-chain.md`](references/robinhood-chain.md) | Robinhood Chain (4663) — chain metadata, canonical bridge L1+L2 contracts, tokens (bridged USDC, USDG, WETH), ecosystem notes | | [`references/agents-and-tokens.md`](references/agents-and-tokens.md) | ERC-8004 registries, major tokens (UNI, AAVE, COMP, MKR, LDO, WBTC, stETH, rETH) | ## Verify any address at runtime diff --git a/internal/embed/skills/addresses/references/bridges.md b/internal/embed/skills/addresses/references/bridges.md index 07bc0202..0286740c 100644 --- a/internal/embed/skills/addresses/references/bridges.md +++ b/internal/embed/skills/addresses/references/bridges.md @@ -25,3 +25,47 @@ Cross-chain bridge. All SpokePool contracts are upgradeable proxies. | Optimism | `0x6f26Bf09B1C792e3228e5467807a900A503c0281` | ✅ Verified | Source: + +## Arbitrum One canonical bridge + +Lock-and-mint bridge between Ethereum L1 and Arbitrum One. Deposits +~10-15 min (retryable tickets); withdrawals initiate on L2 → 7-day +challenge window → claim on L1 (L1 gas required). + +**Last verified on-chain (`eth_getCode`): 2026-07-10.** + +| Contract | Chain | Address | Status | +|----------|-------|---------|--------| +| Delayed Inbox | Mainnet | `0x4Dbd4fc535Ac27206064B68FfCf827b0A60BAB3f` | ✅ Verified | +| Sequencer Inbox | Mainnet | `0x1c479675ad559DC151F6Ec7ed3FbF8ceE79582B6` | ✅ Verified | +| Bridge | Mainnet | `0x8315177aB297bA92A06054cE80a67Ed4DBd7ed3a` | ✅ Verified | +| Outbox | Mainnet | `0x0B9857ae2D4A3DBe74ffE1d7DF045bb7F96E4840` | ✅ Verified | +| Rollup (post-BoLD) | Mainnet | `0x4DCeB440657f21083db8aDd07665f8ddBe1DCfc0` | ✅ Verified | +| L1 Gateway Router | Mainnet | `0x72Ce9c846789fdB6fC1f34aC4AD25Dd9ef7031ef` | ✅ Verified | +| L1 ERC20 Gateway | Mainnet | `0xa3A7B6F88361F48403514059F1F16C8E78d60EeC` | ✅ Verified | +| L2 Gateway Router | Arbitrum | `0x5288c571Fd7aD117beA99bF60FE0846C4E84F933` | ✅ Verified | + +Arbitrum One tokens (native USDC `0xaf88d065e77c8cC2239327C5EDb3A432268e5831`, +WETH `0x82aF49447D8a07e3bd95BD0d56f35241523fBab1`) are in +[`stablecoins.md`](stablecoins.md) — both re-verified 2026-07-10. + +Source: + +## Robinhood Chain canonical bridge + +Arbitrum-style bridge between Ethereum L1 and Robinhood Chain (chain ID +4663). Deposits ~10 min (retryable tickets, failed deposits redeemable +within 7 days); withdrawals initiate on L2 → 7-day challenge → claim on +L1. Full L1 + L2 contract tables, tokens, and ecosystem notes: +[`robinhood-chain.md`](robinhood-chain.md). + +Key L1 (Ethereum mainnet) addresses: + +| Contract | Address | Status | +|----------|---------|--------| +| Delayed Inbox | `0x1A07cc4BD17E0118BdB54D70990D2158AbAD7a2D` | ✅ Verified | +| Outbox | `0xf0ce991ea4A0d2400A4AB49b20ae333f6Dce3DE9` | ✅ Verified | +| L1 Gateway Router | `0x6a2E3a1e16FC29f27Ce61429746D558d656975bB` | ✅ Verified | +| L1 ERC20 Gateway | `0x85001CC4867C5e1C22dA4B79BB8852B9e2a06da0` | ✅ Verified | + +Source: diff --git a/internal/embed/skills/addresses/references/robinhood-chain.md b/internal/embed/skills/addresses/references/robinhood-chain.md new file mode 100644 index 00000000..39ec5599 --- /dev/null +++ b/internal/embed/skills/addresses/references/robinhood-chain.md @@ -0,0 +1,83 @@ +# Robinhood Chain + +Robinhood's Arbitrum Nitro L2 (chain ID **4663**), settling to Ethereum +L1. Consumer / tokenized-assets focus — most activity is "Robinhood +Token" tokenized stocks (AAPL, TSLA, SPY, …) and meme tokens. Gas token +is ETH. + +**Last verified on-chain (`eth_getCode`): 2026-07-10.** + +## Chain metadata + +| Item | Value | +|------|-------| +| Chain ID | `4663` | +| RPC | `https://rpc.mainnet.chain.robinhood.com` | +| Explorer | | +| Gas token | ETH | +| Stack | Arbitrum Nitro (canonical Arbitrum-style bridge, settles to Ethereum L1) | +| Block time | ~100ms observed (avg over 100k recent blocks, 2026-07-10; Nitro mints blocks on demand) | +| Deposits (L1→L2) | ~10 min via retryable tickets; failed deposits redeemable within 7 days | +| Withdrawals (L2→L1) | Initiate on L2 → 7-day challenge window → claim on L1 (L1 gas required) | + +> ⚠️ **Bridged ERC-20 addresses on Robinhood Chain DIFFER from their +> Ethereum addresses.** Never reuse an Ethereum token address here — +> derive the L2 address via the L2 Gateway Router's +> `calculateL2TokenAddress(l1Token)` or look it up on the explorer. + +## Ethereum L1 bridge contracts + +| Contract | Address | Status | +|----------|---------|--------| +| Rollup | `0x23A19d23e89166adedbDcB432518AB01e4272D94` | ✅ Verified | +| Sequencer Inbox | `0xBd0D173EEb87D57A09521c24388a12789F33ba96` | ✅ Verified | +| Delayed Inbox | `0x1A07cc4BD17E0118BdB54D70990D2158AbAD7a2D` | ✅ Verified | +| Bridge | `0xDf8755334ce7A73cCF6b581C02eA649AE3E864b3` | ✅ Verified | +| Outbox | `0xf0ce991ea4A0d2400A4AB49b20ae333f6Dce3DE9` | ✅ Verified | +| L1 Gateway Router | `0x6a2E3a1e16FC29f27Ce61429746D558d656975bB` | ✅ Verified | +| L1 ERC20 Gateway | `0x85001CC4867C5e1C22dA4B79BB8852B9e2a06da0` | ✅ Verified | +| L1 Custom Gateway | `0x9368EAEbFe6E063C69dcF8126711A6997E0eCeE1` | ✅ Verified | +| L1 WETH Gateway | `0xF7e12b9614b509C747ab4423bC4ACF923759Cf1B` | ✅ Verified | + +## Robinhood Chain L2 contracts + +| Contract | Address | Status | +|----------|---------|--------| +| L2 Gateway Router | `0x1E324B9316138CA9a73F960213621AD1aaf01B89` | ✅ Verified | +| L2 ERC20 Gateway | `0xfd9b17206278C16DdaacF6AC8f05dBf97EdCb31e` | ✅ Verified | +| L2 Custom Gateway | `0x912285144fC0f6e89d3Ed16F5Ab72f87A1878959` | ✅ Verified | +| L2 WETH Gateway | `0x1D187C3E2dA52D72BC9C41e3AbA0fdFa6a7bF055` | ✅ Verified | +| L2 Multicall | `0x2cAC2D899eCC914d704FeaAE33ac1bF36277DaD1` | ✅ Verified | + +Standard Arbitrum precompiles live at `0x64`–`0x72` (ArbSys = +`0x0000000000000000000000000000000000000064`, etc.). `eth_getCode` on +precompiles returns the stub byte `0xfe` — that is expected, not a +missing contract. + +## Tokens + +| Token | Address | Status | Notes | +|-------|---------|--------|-------| +| WETH | `0x0Bd7D308f8E1639FAb988df18A8011f41EAcAD73` | ✅ Verified | Canonical bridged WETH (via L2 WETH Gateway) | +| USDC (bridged) | `0x80e0e24718dbFcad49ECAA6F1e6C89A190586cA8` | ✅ Verified | Canonical **bridged** USDC (USDC.e-style), NOT native Circle USDC. `l1Address()` → mainnet USDC; matches the L2 Gateway Router derivation. 6 decimals. **Nascent** — <500 USDC total supply, single-digit holders as of 2026-07-10 | +| USDG (Global Dollar) | `0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168` | ✅ Verified | Paxos-issued stablecoin, the chain's **dominant stable** (~9.5k holders, deepest DEX pools). Does NOT match the canonical-bridge derivation — issued independently of the bridge. 6 decimals | +| Permit2 | `0x000000000022D473030F116dDEE9F6B43aC78BA3` | ✅ Verified | Canonical CREATE2 address, same as every EVM chain | + +> ⚠️ **The explorer is full of scam tokens named "USDC"** (18-decimal +> meme deploys). The canonical bridged USDC is the one above — confirm +> via `calculateL2TokenAddress(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48)` +> on the L2 Gateway Router before trusting any USDC address here. + +## DeFi ecosystem (young — as of July 2026) + +Uniswap V2/V3/V4 and PancakeSwap V2/V3 factory deployments are live +(per GeckoTerminal's `robinhood` network). Liquidity is early-stage: +the deepest pool is USDG/WETH on Uniswap V3 (~$4.5M reserves), and most +volume is meme tokens and tokenized-stock "Robinhood Token" ERC-20s +paired against WETH. There are no meaningful USDC pools — quote prices +in USDG or WETH on this chain. No dominant native DEX has emerged yet; +treat any protocol addresses here as unproven until you verify them. + +Sources: , +, + diff --git a/internal/embed/skills/bridging/SKILL.md b/internal/embed/skills/bridging/SKILL.md new file mode 100644 index 00000000..c0b00d69 --- /dev/null +++ b/internal/embed/skills/bridging/SKILL.md @@ -0,0 +1,328 @@ +--- +name: bridging +description: "Move assets between Ethereum L1 and L2s — canonical Arbitrum-style bridges (Arbitrum One, Robinhood Chain), Base's OP-stack bridge, and fast third-party routes (Across, CCIP). Quote the full round trip before moving anything: canonical deposits take ~10-15 min, canonical withdrawals lock capital for 7 DAYS plus an L1 claim tx. Use when the user says 'bridge', 'move funds to Arbitrum/Base/Robinhood', 'withdraw to L1', or when deploying to a new chain. Verify every bridge address, confirm with the user, test small first." +metadata: { "openclaw": { "emoji": "🌉", "requires": { "bins": ["cast", "python3", "curl"] } } } +--- + +# Bridging + +Move value between Ethereum L1 and the L2s the Stack touches: **Arbitrum +One** (42161), **Robinhood Chain** (4663, Arbitrum Nitro), and **Base** +(8453, OP Stack). Bridging is the highest-stakes routine operation an agent +can do — fake bridge contracts are a top drain vector, and canonical +withdrawals lock capital for a week. This skill is about doing it safely +and, just as often, about recognising when NOT to do it at all. + +Signing goes through the agent's remote-signer (`ethereum-local-wallet` +skill); reads go through eRPC (`ethereum-networks` skill). All bridge +addresses come from the `addresses` skill references — every one was +verified on-chain (`eth_getCode` + `eth_call`) on 2026-07-10. + +**Iron rules — no exceptions:** + +1. **Verify every bridge contract address before sending.** It must appear + in `addresses/references/bridges.md` or + `addresses/references/robinhood-chain.md`, AND return real bytecode + on-chain right now (`rpc.sh code ` — `0x` means EOA, abort; the + `inspect` skill's `contract.py check ` gives a fuller verdict: + proxy, verified source, labels). + Fake bridge contracts are a top drain vector. Never use a bridge + address from web search, a DM, a chat log, or your own memory. +2. **Confirm with the user before bridging** — state the amount, the exact + route, the expected time BOTH directions (getting back matters), and + the total cost including destination-side claim gas. Never bridge + autonomously. +3. **First bridge on any new route = small test amount.** Confirm arrival + before sending the real amount. +4. **Never bridge the whole treasury.** Keep gas plus working capital on + the origin chain — you may need to act there while funds are in flight. +5. **Canonical withdrawals are a 7-DAY CAPITAL LOCKUP** plus a separate L1 + claim transaction (L1 gas). Treat withdrawn funds as illiquid until + claimed. Any plan that assumes those funds are usable sooner is wrong. +6. **Bridged ERC-20 addresses DIFFER per chain.** Resolve the destination + token via the gateway router's `calculateL2TokenAddress(l1Token)` — + never reuse the L1 address, never trust an explorer search result by + token name (Robinhood Chain's explorer is full of scam "USDC"). +7. **Ensure destination-chain ETH for gas BEFORE bridging ERC-20s** — or + bridge ETH first. Tokens you can't move are tokens you don't have. + +## Mental model: canonical vs third-party + +**Canonical bridges** are the chain's own escrow, secured by the rollup +itself (trust-minimized — if the bridge is broken, the chain is broken). +Deposits are fast-ish; exits wait out the fraud-proof challenge window. + +**Third-party bridges** (Across, Relay, Stargate/LayerZero, Chainlink +CCIP) are liquidity or messaging networks. Minutes instead of days, for a +fee, with *additional* trust assumptions (relayers, oracle networks, +external validator sets). + +| Route | Time | Cost | Trust | +|-------|------|------|-------| +| Canonical deposit (L1 → Arbitrum/Robinhood/Base) | ~10–15 min | L1 gas (~$0.50–2) | The rollup itself | +| Canonical withdrawal (L2 → L1) | **7 days** + L1 claim tx | L2 gas + L1 claim gas | The rollup itself | +| Across (between supported chains) | ~30 s–2 min | relayer fee ~0.05–0.3% | Across relayers + optimistic oracle | +| Chainlink CCIP | minutes | CCIP fee (paid in LINK or native) | Chainlink DON | + +**Choosing:** large value or no rush → canonical. Small/medium value and +speed matters → Across (verified SpokePool addresses are in +`addresses/references/bridges.md`; note Across does not serve Robinhood +Chain in our verified set — check before promising it). CCIP is for +cross-chain *messages* and CCIP-native tokens, not a general asset bridge. +NEVER a bridge you found via web search or that someone linked you — only +the verified references. + +## Should you bridge at all? + +Usually not. x402 revenue arrives per-chain — **USDC on Base, OBOL on +Ethereum mainnet** — and the cheapest treasury strategy is to keep and +spend balances on the chain where they live, or convert locally with the +`swap` skill. Bridge when: + +- **Deploying to a new chain** — you need gas ETH there. +- **Consolidating treasury on explicit user instruction.** +- **A chain-specific opportunity** genuinely requires funds on that chain. + +**Worked example — bridging $50 of ETH mainnet → Robinhood Chain:** +deposit costs ~$0.50–2 of L1 gas and lands in ~10 min. Fine. But coming +BACK canonically costs an L2 initiate tx, a **7-day wait**, and then an L1 +claim tx that alone can run $1–5 in gas — 2–10% of the whole amount, plus +a week of illiquidity. Bridging small sums onto a young chain is close to +a one-way trip; say so before doing it. If the user needs $50 of ETH on +Robinhood Chain *and* expects it back next week, the honest answer is +"don't". + +## Setup common to all runbooks + +```bash +SKILLS="${OBOL_SKILLS_DIR:-/data/.openclaw/skills}" +RPC="$SKILLS/ethereum-networks/scripts/rpc.sh" +WALLET="$SKILLS/ethereum-local-wallet/scripts/signer.py" +ME=$(python3 "$WALLET" accounts | grep -o '0x[0-9a-fA-F]*' | head -1) +``` + +Networks: `mainnet`, `arbitrum`, `robinhood`, `base` (the L2 aliases need +the operator to have registered eRPC upstreams — `obol network add`; if +`rpc.sh --network robinhood chain-id` fails, ask the operator). + +## Runbook 1 — ETH deposit L1 → Arbitrum One / Robinhood Chain + +Send ETH to the chain's **Delayed Inbox** on mainnet calling `depositEth()` +(payable, calldata `0x439370b1` — confirm anytime with +`cast sig "depositEth()"`). Addresses from +`addresses/references/bridges.md`: + +- Arbitrum One Delayed Inbox: `0x4Dbd4fc535Ac27206064B68FfCf827b0A60BAB3f` +- Robinhood Chain Delayed Inbox: `0x1A07cc4BD17E0118BdB54D70990D2158AbAD7a2D` + +```bash +INBOX=0x4Dbd4fc535Ac27206064B68FfCf827b0A60BAB3f # Arbitrum One (swap for Robinhood's) +sh "$RPC" code $INBOX | head -c 20 # must NOT be 0x — iron rule 1 + +AMOUNT_WEI=10000000000000000 # 0.01 ETH test amount first (iron rule 3) +python3 "$WALLET" send-tx --from $ME --to $INBOX \ + --value $AMOUNT_WEI --data 0x439370b1 --network mainnet +``` + +ETH is credited to the **same address** on the L2 (fine for our EOA-style +remote-signer wallets; do NOT use this from a contract wallet — address +aliasing would credit a different address). Expected arrival ~10 min. +Watch for it: + +```bash +sh "$RPC" --network arbitrum balance $ME # or --network robinhood +``` + +If the balance hasn't moved after ~30 min, check the L1 tx receipt +succeeded (`rpc.sh receipt `) before doing anything else. Do not +re-send on a hunch. + +## Runbook 2 — ERC-20 deposit L1 → L2 (Gateway Router) + +**Honest recommendation first:** for ERC-20 deposits, prefer the official +bridge UI (, or Robinhood's bridge) and reserve +the raw-calldata flow below for when the agent must act autonomously. +Retryable-ticket gas estimation (`maxSubmissionCost`, `maxGas`, +`gasPriceBid`) is genuinely tricky; underpaying strands the deposit as a +failed retryable. If you cannot estimate confidently, say so and use small +amounts. Failed retryables are redeemable for **7 days** via the +`ArbRetryableTx` precompile (`0x000000000000000000000000000000000000006E`) +— after that the deposit is lost. + +The call is `outboundTransfer(address _token, address _to, uint256 +_amount, uint256 _maxGas, uint256 _gasPriceBid, bytes _data)` (payable) on +the **L1 Gateway Router** (`addresses/references/bridges.md`): + +- Arbitrum One: `0x72Ce9c846789fdB6fC1f34aC4AD25Dd9ef7031ef` +- Robinhood Chain: `0x6a2E3a1e16FC29f27Ce61429746D558d656975bB` + +**CRITICAL: the ERC-20 `approve` goes to the token's GATEWAY, not the +router.** Resolve it first: + +```bash +ROUTER=0x72Ce9c846789fdB6fC1f34aC4AD25Dd9ef7031ef +TOKEN=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 # e.g. mainnet USDC + +# 1. Which gateway escrows this token? +GATEWAY=$(sh "$RPC" call $ROUTER "getGateway(address)(address)" $TOKEN) + +# 2. Where will the token live on the L2? (iron rule 6 — record this) +sh "$RPC" call $ROUTER "calculateL2TokenAddress(address)(address)" $TOKEN + +# 3. Approve the GATEWAY for exactly the deposit amount +AMOUNT=10000000 # 10 USDC (6 decimals) — test amount first +DATA=$(cast calldata "approve(address,uint256)" $GATEWAY $AMOUNT) +python3 "$WALLET" send-tx --from $ME --to $TOKEN --data $DATA --network mainnet + +# 4. Deposit. _data = abi.encode(maxSubmissionCost, ""). +# msg.value must cover maxSubmissionCost + maxGas * gasPriceBid. +# Example params (VERIFY against current L2 gas before using): +MAX_GAS=300000 +GAS_PRICE_BID=100000000 # 0.1 gwei +MAX_SUBMISSION=1000000000000000 # 0.001 ETH headroom +EXTRA=$(cast abi-encode "f(uint256,bytes)" $MAX_SUBMISSION 0x) +DATA=$(cast calldata \ + "outboundTransfer(address,address,uint256,uint256,uint256,bytes)" \ + $TOKEN $ME $AMOUNT $MAX_GAS $GAS_PRICE_BID $EXTRA) +VALUE=$((MAX_SUBMISSION + MAX_GAS * GAS_PRICE_BID)) +python3 "$WALLET" send-tx --from $ME --to $ROUTER \ + --value $VALUE --data $DATA --network mainnet +``` + +Then poll the **L2 token address from step 2** (not the L1 address) with +`rpc.sh --network call "balanceOf(address)(uint256)" $ME`. +Remember iron rule 7: the L2 balance is useless without L2 gas ETH. + +## Runbook 3 — Withdrawal L2 → L1 (the slow road) + +Before initiating, restate to the user: **funds are locked ~7 days, then a +separate L1 claim transaction costing L1 gas is required.** Get explicit +confirmation. + +**ETH** — call `withdrawEth(address destination)` (payable, value = amount) +on the **ArbSys precompile**, same address on every Arbitrum-stack chain: +`0x0000000000000000000000000000000000000064` (`eth_getCode` on precompiles +returns the stub byte `0xfe` — expected, not a missing contract): + +```bash +ARBSYS=0x0000000000000000000000000000000000000064 +DATA=$(cast calldata "withdrawEth(address)" $ME) +python3 "$WALLET" send-tx --from $ME --to $ARBSYS \ + --value $AMOUNT_WEI --data $DATA --network arbitrum # or robinhood +``` + +**ERC-20** — `outboundTransfer(address _l1Token, address _to, uint256 +_amount, bytes _data)` on the **L2 Gateway Router** (note: 4 args on L2, +no gas params; `_data` = `0x`; pass the **L1** token address): + +- Arbitrum One L2 Gateway Router: `0x5288c571Fd7aD117beA99bF60FE0846C4E84F933` +- Robinhood Chain L2 Gateway Router: `0x1E324B9316138CA9a73F960213621AD1aaf01B89` + +```bash +L2ROUTER=0x5288c571Fd7aD117beA99bF60FE0846C4E84F933 +DATA=$(cast calldata "outboundTransfer(address,address,uint256,bytes)" \ + $L1_TOKEN $ME $AMOUNT 0x) +python3 "$WALLET" send-tx --from $ME --to $L2ROUTER --data $DATA --network arbitrum +``` + +**Claiming after the 7 days** happens on the L1 **Outbox** +(`executeTransaction` with a merkle proof — Outbox addresses in +`addresses/references/bridges.md`). Honesty over heroics: the proof is +normally constructed from the chain's node APIs (the `NodeInterface` +pseudo-contract's `constructOutboxProof`), and **the official bridge UI is +the practical claim path** — it finds claimable withdrawals for an address +and builds the proof for you. The agent's job is bookkeeping (runbook 6): +track the pending withdrawal, and after the ETA remind the user to claim +via the bridge UI with the withdrawing address. + +## Runbook 4 — Robinhood Chain specifics + +Full reference: `addresses/references/robinhood-chain.md`. + +- **Resolve bridged token addresses** via the L2 Gateway Router + (`0x1E324B9316138CA9a73F960213621AD1aaf01B89`): + `calculateL2TokenAddress(l1Token)`. Never trust a name-match on the + explorer — **it is full of 18-decimal scam tokens named "USDC"**. The + canonical bridged USDC is `0x80e0e24718dbFcad49ECAA6F1e6C89A190586cA8` + (6 decimals) and is nearly empty anyway. +- **USDG, not USDC, is the liquid stable there** + (`0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168`, Paxos-issued, ~9.5k + holders, deepest pools). USDG does NOT come through the canonical + bridge — you cannot get it by bridging USDC from mainnet; you'd bridge + ETH/WETH and swap into USDG on-chain. +- Deposits ~10 min; withdrawals 7-day challenge + L1 claim, same as + Arbitrum One. Gas token is ETH. + +## Runbook 5 — Base (OP Stack, NOT Arbitrum-style) + +Base's canonical bridge is the OP-stack **L1StandardBridge** proxy on +mainnet: `0x3154Cf16ccdb4C6d922629664174b904d80F2C35` — verified on-chain +2026-07-10 (`eth_getCode` returns proxy code; `l2TokenBridge()` → +`0x4200000000000000000000000000000000000010`, `version()` → `2.8.0`). +Re-verify with `rpc.sh code` before use (iron rule 1). + +- **ETH deposit:** an EOA can simply send plain ETH to the L1StandardBridge + (its receive function bridges it), or call + `depositETH(uint32 _minGasLimit, bytes _extraData)` payable + (e.g. `_minGasLimit=200000`, `_extraData=0x`). Credited to the same + address on Base in ~10–15 min. +- **ERC-20 deposit:** approve the L1StandardBridge, then + `depositERC20(address _l1Token, address _l2Token, uint256 _amount, + uint32 _minGasLimit, bytes _extraData)` — note OP-stack makes YOU supply + the L2 token address; get it from `addresses/references/stablecoins.md` + or Base docs, never guess. For USDC prefer moving native Base USDC via a + swap/third-party route rather than creating bridged USDC dust. +- **Withdrawals:** initiate on the L2 standard bridge + (`0x4200000000000000000000000000000000000010`), then a **7-day challenge + window** plus a **two-step prove → finalize** on L1. Use the official + bridge UI for prove/finalize; same bookkeeping duty as runbook 6. + +Keep Base exits rare: x402 revenue on Base is meant to be *spent* there +(gas is sub-cent, services are priced there). Converting is the `swap` +skill; exiting to L1 is almost never worth 7 days + two L1 txs for +agent-scale amounts. + +## Runbook 6 — Pending-withdrawal bookkeeping + +Canonical withdrawals outlive any chat session. On EVERY withdrawal +initiate, record in the agent's notes/memory: + +``` +BRIDGE-WITHDRAWAL pending + initiated: 2026-07-10T14:02Z tx: 0x chain: arbitrum → mainnet + asset/amount: 0.5 ETH + claim-after: 2026-07-17T14:02Z (initiate + 7 days) + claim-path: bridge UI (bridge.arbitrum.io) with address 0x +``` + +Surface it to the user immediately ("this is now locked until ~July 17"), +and when any later conversation crosses the ETA, check claimability and +remind them. An unclaimed withdrawal is money the user has forgotten. + +## Time awareness — how long is my capital in flight? + +| Route | Capital in flight | Notes | +|-------|-------------------|-------| +| Canonical deposit L1 → Arbitrum One / Robinhood / Base | ~10–15 min | Retryable/queued message; failed Arbitrum-style retryables redeemable ≤ 7 days | +| Canonical withdrawal Arbitrum One / Robinhood → L1 | **7 days + until claimed** | Claim tx on L1 required; funds do nothing meanwhile | +| Canonical withdrawal Base → L1 | **7 days + prove + finalize** | Two L1 txs | +| Across (supported chains) | ~30 s–2 min | Relayer fee; capacity limits on big amounts | + +**The rule:** any plan that promises funds on another chain "in a few +minutes" via a canonical *withdrawal* is WRONG — physically, not +optimistically. Only deposits and third-party liquidity bridges are fast. + +## See also + +- `addresses` — `references/bridges.md` (all bridge contracts, CCIP, + Across), `references/robinhood-chain.md` (full Robinhood Chain + reference incl. scam-USDC warning) +- `l2s` — chain landscape, which L2 for which job +- `swap` — convert value on ONE chain; often the right move instead of + bridging +- `inspect` — decode calldata and vet contracts (`contract.py check`) + before sending (iron rule 1) +- `ethereum-networks` — `rpc.sh code` / `call` to verify every contract + before sending (iron rule 1) +- `ethereum-local-wallet` — `signer.py send-tx`, supported networks and + aliases diff --git a/internal/embed/skills/buy-x402/SKILL.md b/internal/embed/skills/buy-x402/SKILL.md index 2d91bb40..ecaf385b 100644 --- a/internal/embed/skills/buy-x402/SKILL.md +++ b/internal/embed/skills/buy-x402/SKILL.md @@ -66,6 +66,14 @@ python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/ethereum-local-wallet/scripts This is one tx, ~46k gas, valid forever (unless the user later revokes). EIP-3009 flows (USDC `TransferWithAuthorization`) do **not** need this approval. Sellers that advertise `eip2612GasSponsoring` in their 402 extensions also bypass it (per-request signed permits). +## Verify Before You Pay + +Before paying an unfamiliar seller or signing payment authorizations for a new +endpoint, vet it with the `inspect` skill: `contract.py check ` runs +due-diligence on the recipient (code? proxy? verified source? labels?), and +`decode.py calldata` explains any calldata you are asked to sign (e.g. the +Permit2 approval above). + ## Pitfalls - **`extra.name` is NOT the EIP-712 signing domain name.** The 402 response @@ -114,6 +122,11 @@ This is one tx, ~46k gas, valid forever (unless the user later revokes). EIP-300 slash separator (e.g. `vendor--model`); buyers signing against a legacy slashed name need the controller to insert an explicit LiteLLM entry for the alias (`addLiteLLMModelEntry`). +- **Interop smoke-testing against a non-Obol seller.** A known third-party + public x402 endpoint is `POST https://swiss-knife.xyz/api/usdc-pay` + (x402 exact scheme, USDC on Base / Base-Sepolia, PayAI facilitator; body + `{"to","amount","network"}`). Probing it with `probe --type http + --method POST` should yield standard 402 requirements. ## When to Use diff --git a/internal/embed/skills/ethereum-local-wallet/SKILL.md b/internal/embed/skills/ethereum-local-wallet/SKILL.md index a2139abf..525d7c3c 100644 --- a/internal/embed/skills/ethereum-local-wallet/SKILL.md +++ b/internal/embed/skills/ethereum-local-wallet/SKILL.md @@ -81,7 +81,9 @@ python3 scripts/signer.py send-tx --network hoodi \ --from 0x... --to 0x... --value 1000000000000000000 ``` -Supported networks: `mainnet`, `base`, `base-sepolia`, `hoodi`, `sepolia` (depends on eRPC configuration). The aliases `ethereum` and `eip155:` resolve to the canonical name. Unknown values fail with an explicit error rather than silently signing against chain id 1. +Supported networks: `mainnet`, `base`, `base-sepolia`, `hoodi`, `sepolia`, `arbitrum`, `robinhood` (depends on eRPC configuration). The aliases `ethereum`, `arb`, `arbitrum-one`, `robinhood-chain`, and `eip155:` resolve to the canonical name. Unknown values fail with an explicit error rather than silently signing against chain id 1. + +Note: `arbitrum` and `robinhood` require the stack operator to have registered an eRPC upstream under that alias (`obol network add` — ChainList has both). If `${ERPC_URL}/arbitrum` or `${ERPC_URL}/robinhood` returns 404, the upstream isn't registered — ask the operator. ## Gas & Tip Selection @@ -114,6 +116,7 @@ If `eth_feeHistory` is unavailable, the oracle falls back to per-chain safe defa ## Important Notes - Always **confirm with the user** before sending transactions +- **Never sign calldata you can't explain** — decode it first with the `inspect` skill's `decode.py` - Values are in **wei** (1 ETH = 1000000000000000000 wei) - Use `ethereum-networks` skill (`rpc.sh balance
`) to check balances before sending - The `send-tx` command will broadcast the transaction immediately after signing diff --git a/internal/embed/skills/ethereum-local-wallet/scripts/signer.py b/internal/embed/skills/ethereum-local-wallet/scripts/signer.py index ca34e4bc..6ebf246b 100644 --- a/internal/embed/skills/ethereum-local-wallet/scripts/signer.py +++ b/internal/embed/skills/ethereum-local-wallet/scripts/signer.py @@ -28,6 +28,8 @@ "base-sepolia": 84532, "sepolia": 11155111, "hoodi": 560048, + "arbitrum": 42161, + "robinhood": 4663, } # Friendly aliases that resolve to canonical eRPC names. @@ -39,6 +41,11 @@ "eip155:84532": "base-sepolia", "eip155:11155111": "sepolia", "eip155:560048": "hoodi", + "arb": "arbitrum", + "arbitrum-one": "arbitrum", + "eip155:42161": "arbitrum", + "robinhood-chain": "robinhood", + "eip155:4663": "robinhood", } _GWEI = 1_000_000_000 @@ -88,6 +95,25 @@ "fallback_max": 20 * _GWEI, "min_max_fee": 5 * _GWEI, }, + # Arbitrum-stack L2s: base fee floor on Arbitrum One is 0.01 gwei and + # tips are effectively unused (first-come-first-served), so keep tips + # near-zero like base. + "arbitrum": { + "min_tip": 1_000_000, # 0.001 gwei + "max_tip": 50_000_000, # 0.05 gwei + "fallback_base": 10_000_000, # 0.01 gwei (chain base-fee floor) + "fallback_tip": 1_000_000, + "fallback_max": 50_000_000, + "min_max_fee": 10_000_000, + }, + "robinhood": { + "min_tip": 1_000_000, # 0.001 gwei + "max_tip": 50_000_000, # 0.05 gwei + "fallback_base": 5_000_000, + "fallback_tip": 1_000_000, + "fallback_max": 50_000_000, + "min_max_fee": 5_000_000, + }, } diff --git a/internal/embed/skills/ethereum-networks/SKILL.md b/internal/embed/skills/ethereum-networks/SKILL.md index 5bcb4793..72a016ce 100644 --- a/internal/embed/skills/ethereum-networks/SKILL.md +++ b/internal/embed/skills/ethereum-networks/SKILL.md @@ -30,7 +30,9 @@ The eRPC gateway routes to whichever Ethereum networks are installed: http://erpc.erpc.svc.cluster.local/rpc/{network} ``` -`mainnet` is always available. Other networks (e.g. `hoodi`) are available if installed. You can also use `evm/{chainId}` (e.g. `evm/560048` for Hoodi). +`mainnet` is always available. Other networks (e.g. `hoodi`, `arbitrum`, `robinhood`) are available if installed. You can also use `evm/{chainId}` (e.g. `evm/560048` for Hoodi, `evm/42161` for Arbitrum One, `evm/4663` for Robinhood Chain). + +`arbitrum` and `robinhood` require the stack operator to have registered an eRPC upstream under that alias (`obol network add` — ChainList has both). If `/rpc/arbitrum` or `/rpc/robinhood` returns 404, the upstream isn't registered — ask the operator. To see which networks are connected: @@ -139,6 +141,34 @@ sh scripts/rpc.sh raw eth_blockNumber | `logs` | `address [topic0] [--from-block N]` | Query event logs | | `raw` | `method [params...]` | Raw JSON-RPC call | +## Cast One-Liner Quick Reference + +`cast` itself covers utilities `rpc.sh` doesn't wrap: + +```bash +# Address derivation (no RPC needed) +cast compute-address --nonce # CREATE +cast create2 --deployer --salt --init-code-hash # CREATE2 + +# Raw storage slot read (keep it routed through eRPC) +cast storage --rpc-url http://erpc.erpc.svc.cluster.local/rpc/mainnet +# EIP-1967 implementation slot (proxy → implementation address): +cast storage 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc \ + --rpc-url http://erpc.erpc.svc.cluster.local/rpc/mainnet + +# Pure conversions and hashing (offline) +cast to-unit 1500000000 gwei # convert between eth units +cast from-wei 1000000000000000000 # wei → ether +cast to-wei 1.5 # ether → wei +cast keccak "some data" # keccak256 hash +cast namehash vitalik.eth # ENS namehash +cast sig 'transfer(address,uint256)' # 4-byte function selector +cast max-uint # 2^256 - 1 +cast to-check-sum-address 0xd8da6bf26964af9d7eed9e03e53415d37aa96045 +``` + +For calldata decoding and contract due-diligence beyond raw RPC (proxy detection, verified source, address labels), use the `inspect` skill. + ## Token Queries With `cast`, contract reads use human-readable function signatures instead of raw selectors: diff --git a/internal/embed/skills/inspect/SKILL.md b/internal/embed/skills/inspect/SKILL.md new file mode 100644 index 00000000..3496e1da --- /dev/null +++ b/internal/embed/skills/inspect/SKILL.md @@ -0,0 +1,156 @@ +--- +name: inspect +description: "Decode calldata and vet contracts before you sign or pay. `decode.py` explains what a blob of calldata or a transaction actually does (selector lookup via openchain/4byte, param decode via cast, Safe MultiSend unpacking, offline ERC-20 fast path). `contract.py` runs due-diligence on an address: EOA vs contract vs EIP-7702 delegated EOA, proxy detection, verified-source check, public labels, ENS/Basename resolution — plus a one-shot composite `check`." +metadata: { "openclaw": { "emoji": "🔎", "requires": { "bins": ["python3", "cast", "curl"] } } } +--- + +# Inspect + +Decode before you sign. Vet before you pay. Two read-only tools for the moment +right before money or authority leaves the wallet: + +- `scripts/decode.py` — what does this calldata / transaction DO? +- `scripts/contract.py` — who or what is this address, really? + +## When to Use + +- About to sign unfamiliar calldata or an EIP-712 payload (e.g. the Permit2 + approval printed by `buy-x402`) — `decode.py calldata` +- Reviewing a transaction someone sent, or auditing one you already sent — + `decode.py tx ` +- About to pay a new or unknown x402 seller — `contract.py check ` +- Vetting a counterparty token or contract before approving/holding it — + `contract.py check ` (source verified? proxy? labeled?) +- Deciding whether an address is a wallet or code — `contract.py code` + (detects EIP-7702 delegated EOAs) +- Turning an ENS/Basename into an address (or back) — `contract.py resolve` + +## When NOT to Use + +- Making the actual payment — use `buy-x402` +- Generic RPC reads (balances, blocks, logs) — use `ethereum-networks` +- Signing or sending transactions — use `ethereum-local-wallet` +- Discovering sellers on the ERC-8004 registry — use `discovery` + +## Quick Start + +```bash +# What am I about to sign? (ERC-20 selectors decode fully offline) +python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/inspect/scripts/decode.py calldata \ + 0xa9059cbb000000000000000000000000ab5801a7d398351b8be11c439e05c5b3259aec9b00000000000000000000000000000000000000000000000000000000000f4240 +# selector: 0xa9059cbb +# function: transfer(address,uint256) +# address: 0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B +# uint256: 1000000 [1e6] + +# Who am I about to pay? Composite due-diligence on the payTo address. +python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/inspect/scripts/contract.py check \ + 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --network mainnet +# -> code / proxy / verified source / labels / ENS+Basename sections + +# What did this transaction do? +python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/inspect/scripts/decode.py tx \ + 0xa2b4273e... --network mainnet +``` + +## Commands — decode.py + +| Command | Description | +|---------|-------------| +| `calldata [--to ] [--network ] [--offline]` | Decode raw calldata: selector lookup (offline table → openchain → 4byte), param decode via `cast calldata-decode` (offline static fallback), Safe MultiSend unpack, UTF-8 text fallback. `--to` is context only. `--offline` skips network signature lookups | +| `tx [--network ] [--rpc-url ] [--offline]` | Fetch a tx by hash via RPC, print from/to/value/status/gas, then decode its input with the same pipeline | + +Behavior notes: + +- Common ERC-20/permit selectors (`transfer`, `approve`, `transferFrom`, + `permit`, ...) decode fully offline — no network, no `cast` needed for + static params. +- Safe MultiSend (`0x8d80ff0a`) is unpacked into its inner transactions, + each decoded recursively (max depth 4). `DELEGATECALL` inner txs get a + loud warning — inner code runs with the Safe's own storage/identity. +- Nested `bytes` params that look like calldata are decoded recursively; + nested decode is conservative (selector lookup only, no aggressive + fallbacks). +- Empty calldata is reported as a plain value transfer. A calldata blob + with no known selector is tried as UTF-8 text (top level only). +- The null selector `0x00000000` attracts spam signatures; lookups are + skipped for it. +- Exit codes: `0` decoded, `1` invalid input / RPC failure, `2` tx not + found, `3` could not decode (selector unknown or no candidate matched). + +## Commands — contract.py + +All address commands take `[--network ] [--rpc-url ]`. + +| Command | Description | +|---------|-------------| +| `code ` | Verdict: EOA (no code) / CONTRACT (N bytes) / EIP-7702 DELEGATED EOA. For 7702 it prints the delegation target and tells you to `check` it | +| `proxy ` | Reads EIP-1967 implementation/admin/beacon slots + matches EIP-1167 minimal-proxy bytecode. Points you at the implementation when found | +| `source [--full]` | Verified-source check: Sourcify v2 first, swiss-knife.xyz Etherscan-v2 proxy as fallback. `--full` also prints the file list + main source file | +| `labels ` | Public address labels via swiss-knife.xyz (eth-labels), e.g. `Circle: USDC Token, circle, stablecoin` | +| `resolve [--rpc-url ] [--base-rpc-url ]` | Forward + reverse ENS (mainnet, via `cast`) and Basename (`.base.eth`, via the Base L2 resolver). No `--network` flag — chains are fixed by the name system | +| `check [--base-rpc-url ]` | Composite report: code + proxy + source + labels + names in one shot. The default "vet this seller" command | + +## Supported Networks + +`--network` accepts the eRPC project aliases: `mainnet` (default), `base`, +`base-sepolia`, `sepolia`, `hoodi`. CAIP-2 strings (`eip155:1`, +`eip155:8453`, ...) and `ethereum`/`eth` are normalized on input; unknown +chains fail loudly with the supported list. + +RPC reads compose as `${ERPC_URL}/`. `--rpc-url` takes a FULL +JSON-RPC URL used verbatim (bypassing that composition) — handy outside the +cluster, e.g. `--rpc-url https://ethereum.publicnode.com`. `resolve` and +`check` additionally take `--base-rpc-url` for the Basename leg (Base chain). + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `ERPC_URL` | `http://erpc.erpc.svc.cluster.local/rpc` | eRPC gateway base URL | +| `ERPC_NETWORK` | `mainnet` | Default `--network` for both scripts | + +## Pitfalls + +- **Selector lookups are ADVISORY.** 4-byte selectors collide; openchain and + 4byte.directory may return multiple candidates or a wrong/spammy one. The + script prints all candidates when there is more than one and decodes the + first that fits — that is a best guess, not proof of intent. +- **External APIs are best-effort.** openchain, 4byte.directory, Sourcify, + and swiss-knife.xyz lookups can be down, rate-limited, or stale; failures + degrade to warnings, not hard errors. Absence of labels or source means + *unknown*, NOT safe. +- **EIP-7702 delegated EOAs carry code.** A "contract" verdict on a payer + address may actually be a delegated EOA (23 bytes starting `0xef0100`), + and vice versa a "wallet" you know may have acquired delegation code. + `code` distinguishes all three — always inspect the delegation target too. +- **Legacy proxies evade the EIP-1967 probe.** Older proxies (e.g. mainnet + USDC's FiatTokenProxy on the ZeppelinOS slot) show "no EIP-1967 slots + set" while still being proxies. Cross-check with `source` — the verified + contract name usually gives it away. +- **Nested MultiSend decode is conservative.** Inner transactions use + selector lookup only (no UTF-8 or aggressive fallbacks), so an inner tx + reported as undecodable still executes whatever it encodes. Review + DELEGATECALL inner txs with maximum suspicion. +- **CCIP-read (offchain) ENS names don't resolve.** `cast` cannot follow + ERC-3668 offchain lookups; `.base.eth` is special-cased via the Base L2 + resolver, other offchain-resolver names exit 3. +- **Treat ALL decoded output as advisory.** On-chain state is canonical. + Before signing or paying, cross-check on a block explorer — + `references/explorers.md` has copy-pasteable URL templates. + +## References + +- `references/explorers.md` — block-explorer URL templates for handing + humans clickable verification links +- See also: `buy-x402` skill ("Verify Before You Pay" section calls into + this skill) + +## Constraints + +- **Read-only** — no private keys, no signing, no state changes +- **Python stdlib only** — no pip install; `cast` (Foundry) is the only + external binary and is required for non-static param decode, keccak/ + namehash, and ENS calls +- **In-cluster RPC by default** — routes through eRPC; use `--rpc-url` + only for explicit overrides diff --git a/internal/embed/skills/inspect/references/explorers.md b/internal/embed/skills/inspect/references/explorers.md new file mode 100644 index 00000000..d51e1335 --- /dev/null +++ b/internal/embed/skills/inspect/references/explorers.md @@ -0,0 +1,48 @@ +# Block Explorer URL Templates + +Copy-pasteable links for handing to a human who needs to verify an address or +transaction in a browser. Substitute `{address}` (0x + 40 hex) or `{tx}` +(0x + 64 hex). URL patterns lifted from swiss-knife.xyz's explorer registry. + +The scripts in this skill are advisory; these explorers are where a human +cross-checks before approving a signature or payment. + +## Address / Contract Explorers + +| Explorer | URL template | Chains | Notes | +|----------|-------------|--------|-------| +| Etherscan | `https://etherscan.io/address/{address}` | mainnet | Canonical: txs, verified source, proxy read/write tabs | +| Etherscan (Sepolia) | `https://sepolia.etherscan.io/address/{address}` | sepolia | | +| Basescan | `https://basescan.org/address/{address}` | base | | +| Basescan (Sepolia) | `https://sepolia.basescan.org/address/{address}` | base-sepolia | | +| Blockscout | `https://eth.blockscout.com/address/{address}` | mainnet (`eth.`), base (`base.`), optimism (`optimism.`) | Subdomain per chain; open-source alternative to Etherscan | +| Sourcify repo | `https://repo.sourcify.dev/{chainId}/{address}` | any EVM chain (numeric chain id: 1, 8453, 84532, 11155111) | Raw verified-source tree; same backend `contract.py source` queries | +| Tenderly | `https://dashboard.tenderly.co/contract/{chain}/{address}` | mainnet, sepolia, optimistic, polygon, ... | Chain labels: `mainnet`, `sepolia` (no Base in this view) | +| Dedaub | `https://library.dedaub.com/{chain}/address/{address}` | ethereum, base, arbitrum, fantom | Decompiler — readable code for UNVERIFIED contracts | +| evm.codes | `https://www.evm.codes/contract?address={address}` | mainnet | Runtime bytecode disassembly view | +| UpgradeHub | `https://upgradehub.xyz/diffs/etherscan/{address}` | mainnet (label `etherscan`; also `arbiscan`, `polygonscan`, `optimistic.etherscan`, ...) | Diffs every proxy implementation upgrade — great after `contract.py proxy` | +| OpenSea | `https://opensea.io/{address}` | mainnet, base, optimism, polygon, ... | NFTs held by the address | +| OpenSea (testnets) | `https://testnets.opensea.io/{address}` | sepolia + other testnets | | + +## Transaction Explorers + +| Explorer | URL template | Chains | Notes | +|----------|-------------|--------|-------| +| Etherscan | `https://etherscan.io/tx/{tx}` | mainnet | | +| Etherscan (Sepolia) | `https://sepolia.etherscan.io/tx/{tx}` | sepolia | | +| Basescan | `https://basescan.org/tx/{tx}` | base | | +| Basescan (Sepolia) | `https://sepolia.basescan.org/tx/{tx}` | base-sepolia | | +| Blockscout | `https://eth.blockscout.com/tx/{tx}` | mainnet (`eth.`), base (`base.`), optimism (`optimism.`) | | +| Tenderly | `https://dashboard.tenderly.co/tx/{chain}/{tx}` | `mainnet`, `sepolia`, `optimistic`, `polygon`, ... | Full execution trace + state diff — best "what did this tx really do" view | +| Phalcon | `https://explorer.phalcon.xyz/tx/{chain}/{tx}` | `eth`, `sepolia`, `optimism`, `polygon`, ... | Security-oriented trace explorer (fund flow, call tree) | +| EigenPhi | `https://tx.eigenphi.io/analyseTransaction?chain=ALL&tx={tx}` | mainnet + majors | MEV / sandwich analysis | + +## Chain quick reference + +| eRPC network alias | Chain id | Primary explorer | +|--------------------|----------|------------------| +| `mainnet` | 1 | etherscan.io | +| `base` | 8453 | basescan.org | +| `base-sepolia` | 84532 | sepolia.basescan.org | +| `sepolia` | 11155111 | sepolia.etherscan.io | +| `hoodi` | 560048 | hoodi.etherscan.io (limited third-party coverage) | diff --git a/internal/embed/skills/inspect/scripts/contract.py b/internal/embed/skills/inspect/scripts/contract.py new file mode 100644 index 00000000..8067f0a3 --- /dev/null +++ b/internal/embed/skills/inspect/scripts/contract.py @@ -0,0 +1,572 @@ +#!/usr/bin/env python3 +"""contract.py — Due-diligence on unfamiliar contracts and addresses before paying them. + +Uses only Python stdlib + the `cast` (Foundry) binary for keccak256, namehash, +and ENS calls. RPC reads go through the in-cluster eRPC gateway; source/label +checks hit Sourcify and swiss-knife.xyz public APIs (best-effort). + +Usage: python3 scripts/contract.py [args...] + +Commands (all take [--network ]): + code is-contract / EOA / EIP-7702 delegation verdict + proxy EIP-1967 implementation/admin/beacon slots + EIP-1167 minimal proxy + source [--full] verified source check (Sourcify, then swiss-knife/Etherscan) + labels public address labels (swiss-knife wrapper around eth-labels) + resolve ENS forward/reverse + Basename (.base.eth) resolution + check composite report: code + proxy + source + labels + names + +Environment: + ERPC_URL Base URL for eRPC gateway (default: http://erpc.erpc.svc.cluster.local/rpc) + ERPC_NETWORK Default network (default: mainnet) + +`--rpc-url` takes a FULL JSON-RPC URL used verbatim (already including the +network path), bypassing the ${ERPC_URL}/${network} composition — handy for +testing outside the cluster, e.g. --rpc-url https://ethereum.publicnode.com +`resolve` additionally accepts --base-rpc-url for the Base chain leg. +""" +import argparse +import json +import os +import re +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request + +ERPC_BASE = os.environ.get("ERPC_URL", "http://erpc.erpc.svc.cluster.local/rpc") +DEFAULT_NETWORK = os.environ.get("ERPC_NETWORK", "mainnet") + +CHAIN_IDS = { + "mainnet": 1, + "base": 8453, + "base-sepolia": 84532, + "sepolia": 11155111, + "hoodi": 560048, +} +CHAIN_ALIASES = { + "ethereum": "mainnet", + "eth": "mainnet", + "eip155:1": "mainnet", + "eip155:8453": "base", + "eip155:84532": "base-sepolia", + "eip155:11155111": "sepolia", + "eip155:560048": "hoodi", +} + +# EIP-1967 well-known storage slots. +EIP1967_IMPLEMENTATION = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc" +EIP1967_ADMIN = "0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103" +EIP1967_BEACON = "0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50" + +# EIP-1167 minimal proxy runtime bytecode pattern. +EIP1167_RE = re.compile( + r"^0x363d3d373d3d3d363d73([0-9a-f]{40})5af43d82803e903d91602b57fd5bf3$" +) + +# Basename (Base ENS) L2 resolver — handles forward addr() and reverse name(). +BASENAME_L2_RESOLVER = "0xC6d566A56A1aFf6508b41f6c90ff131615583BCD" +# coinType for chainId 8453: (0x80000000 | 8453) >>> 0 = 0x80002105 +BASE_REVERSE_DOMAIN = "80002105.reverse" + +SOURCIFY_URL = "https://sourcify.dev/server/v2/contract" +SWISSKNIFE_SOURCE_URL = "https://swiss-knife.xyz/api/source-code" +SWISSKNIFE_LABELS_URL = "https://swiss-knife.xyz/api/labels" + +ADDR_RE = re.compile(r"^0x[0-9a-fA-F]{40}$") + + +def resolve_chain(value): + label = str(value).strip() + if label in CHAIN_IDS: + return label + if label in CHAIN_ALIASES: + return CHAIN_ALIASES[label] + supported = ", ".join(sorted(CHAIN_IDS)) + raise SystemExit("Unknown network %r. Supported: %s" % (value, supported)) + + +def require_address(value): + if not ADDR_RE.match(value or ""): + print("Error: %r is not a 0x-prefixed 20-byte address" % value, file=sys.stderr) + sys.exit(1) + return value + + +def rpc_url_for(network, override=None): + return override or ("%s/%s" % (ERPC_BASE, network)) + + +def http_get_json(url, timeout=20): + req = urllib.request.Request(url, headers={"User-Agent": "obol-inspect/1.0"}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read()) + + +def rpc_call(method, params, network, rpc_url=None): + url = rpc_url_for(network, rpc_url) + payload = json.dumps({"jsonrpc": "2.0", "method": method, "params": params, "id": 1}).encode() + # Explicit User-Agent: public RPCs block Python-urllib's default UA (Cloudflare 1010). + req = urllib.request.Request(url, data=payload, headers={ + "Content-Type": "application/json", "User-Agent": "obol-inspect/1.0"}) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + data = json.loads(resp.read()) + except urllib.error.HTTPError as e: + print("RPC HTTP %d from %s" % (e.code, url), file=sys.stderr) + sys.exit(1) + except urllib.error.URLError as e: + print("RPC connection failed: %s (is %s reachable?)" % (e.reason, url), file=sys.stderr) + sys.exit(1) + if "error" in data: + print("RPC error %s: %s" % (data["error"].get("code"), data["error"].get("message")), file=sys.stderr) + sys.exit(1) + return data.get("result") + + +def cast_run(args, allow_fail=False): + """Run a cast subcommand; return stripped stdout or None on failure.""" + env = dict(os.environ) + env["FOUNDRY_DISABLE_NIGHTLY_WARNING"] = "1" + try: + proc = subprocess.run(["cast"] + args, capture_output=True, text=True, timeout=30, env=env) + except FileNotFoundError: + print("Error: `cast` (Foundry) not found on PATH — required for this command", file=sys.stderr) + sys.exit(1) + except subprocess.TimeoutExpired: + if allow_fail: + return None + print("Error: cast timed out", file=sys.stderr) + sys.exit(1) + if proc.returncode != 0: + if allow_fail: + return None + print("cast %s failed: %s" % (args[0], proc.stderr.strip()), file=sys.stderr) + sys.exit(1) + return proc.stdout.strip() + + +# --------------------------------------------------------------------------- +# code +# --------------------------------------------------------------------------- + +def analyze_code(addr, network, rpc_url=None): + """Return dict: {kind: eoa|contract|eip7702, size, code, delegate}.""" + code = rpc_call("eth_getCode", [addr, "latest"], network, rpc_url) or "0x" + code = code.lower() + size = (len(code) - 2) // 2 + if code == "0x": + return {"kind": "eoa", "size": 0, "code": code, "delegate": None} + if code.startswith("0xef0100") and size == 23: + return {"kind": "eip7702", "size": size, "code": code, "delegate": "0x" + code[8:]} + return {"kind": "contract", "size": size, "code": code, "delegate": None} + + +def print_code_report(addr, info, network): + if info["kind"] == "eoa": + print("%s on %s: EOA (no code) — externally owned account" % (addr, network)) + elif info["kind"] == "eip7702": + print("%s on %s: EIP-7702 DELEGATED EOA (%d bytes of code)" % (addr, network, info["size"])) + print(" delegation target: %s" % info["delegate"]) + print(" !! WARNING: this EOA executes the delegate contract's code when called.") + print(" !! Treat it as BOTH a wallet and a contract; inspect the delegate before trusting it:") + print(" python3 scripts/contract.py check %s --network %s" % (info["delegate"], network)) + else: + print("%s on %s: CONTRACT (%d bytes of code)" % (addr, network, info["size"])) + + +def cmd_code(args): + network = resolve_chain(args.network) + addr = require_address(args.addr) + print_code_report(addr, analyze_code(addr, network, args.rpc_url), network) + + +# --------------------------------------------------------------------------- +# proxy +# --------------------------------------------------------------------------- + +def slot_to_address(value): + """Storage word -> address if nonzero, else None.""" + if not value: + return None + raw = value[2:].rjust(64, "0") + if int(raw, 16) == 0: + return None + return "0x" + raw[24:] + + +def analyze_proxy(addr, network, rpc_url=None, code=None): + out = {"implementation": None, "admin": None, "beacon": None, "minimal": None} + for key, slot in ( + ("implementation", EIP1967_IMPLEMENTATION), + ("admin", EIP1967_ADMIN), + ("beacon", EIP1967_BEACON), + ): + value = rpc_call("eth_getStorageAt", [addr, slot, "latest"], network, rpc_url) + out[key] = slot_to_address(value) + if code is None: + code = (rpc_call("eth_getCode", [addr, "latest"], network, rpc_url) or "0x").lower() + m = EIP1167_RE.match(code) + if m: + out["minimal"] = "0x" + m.group(1) + return out + + +def print_proxy_report(addr, info, network): + found = False + if info["implementation"]: + print("EIP-1967 implementation: %s" % info["implementation"]) + found = True + if info["admin"]: + print("EIP-1967 admin: %s" % info["admin"]) + found = True + if info["beacon"]: + print("EIP-1967 beacon: %s" % info["beacon"]) + found = True + if info["minimal"]: + print("EIP-1167 minimal proxy -> %s" % info["minimal"]) + found = True + if found: + target = info["implementation"] or info["minimal"] or info["beacon"] + if target: + print("NOTE: behavior lives in the target; verify the IMPLEMENTATION too:") + print(" python3 scripts/contract.py source %s --network %s" % (target, network)) + else: + print("%s: no EIP-1967 slots set, not an EIP-1167 minimal proxy" % addr) + print("(could still be a non-standard proxy — check the source)") + return found + + +def cmd_proxy(args): + network = resolve_chain(args.network) + addr = require_address(args.addr) + print_proxy_report(addr, analyze_proxy(addr, network, args.rpc_url), network) + + +# --------------------------------------------------------------------------- +# source +# --------------------------------------------------------------------------- + +def fetch_sourcify(addr, chain_id, want_sources=False): + """Sourcify v2 API. 200 -> verified: {match, compilation:{name, compilerVersion}, ...} + 404 -> not verified on Sourcify. Returns dict or None.""" + fields = "compilation" + (",sources" if want_sources else "") + url = "%s/%s/%s?fields=%s" % (SOURCIFY_URL, chain_id, addr, fields) + try: + return http_get_json(url) + except urllib.error.HTTPError as e: + if e.code == 404: + return None + raise + + +def fetch_swissknife_source(addr, chain_id): + """swiss-knife proxy of Etherscan v2 getsourcecode. + Shape: {"status":"1","result":[{SourceCode, ABI, ContractName, + CompilerVersion, Proxy, Implementation, ...}]}""" + qs = urllib.parse.urlencode({"address": addr, "chainId": chain_id}) + data = http_get_json("%s?%s" % (SWISSKNIFE_SOURCE_URL, qs)) + results = data.get("result") + if not isinstance(results, list) or not results: + return None + return results[0] + + +def parse_etherscan_sources(source_code, contract_name): + """Etherscan SourceCode field: plain source, or '{{...}}'-wrapped multi-file JSON.""" + if source_code.startswith("{"): + blob = source_code + if blob.startswith("{{"): + blob = blob[1:-1] + try: + sources = json.loads(blob).get("sources", {}) + return {path: entry.get("content", "") for path, entry in sources.items()} + except (ValueError, AttributeError): + return {contract_name or "Contract": source_code} + return {contract_name or "Contract": source_code} + + +def report_source(addr, network, full=False): + """Print verification report. Returns dict summary (used by check).""" + chain_id = CHAIN_IDS[network] + summary = {"verified": False, "name": None, "compiler": None, "via": None} + + # 1. Sourcify (decentralized, preferred) + data = None + try: + data = fetch_sourcify(addr, chain_id, want_sources=full) + except Exception as e: + print("warn: Sourcify lookup failed: %s" % e, file=sys.stderr) + if data: + comp = data.get("compilation") or {} + summary.update({ + "verified": True, + "name": comp.get("name"), + "compiler": comp.get("compilerVersion"), + "via": "sourcify (%s)" % data.get("match", "match"), + }) + print("verified: YES via Sourcify (match: %s, verifiedAt: %s)" + % (data.get("match"), data.get("verifiedAt"))) + print("contract: %s" % comp.get("name")) + print("compiler: %s %s" % (comp.get("compiler", "solc"), comp.get("compilerVersion"))) + if full: + sources = data.get("sources") or {} + print("files (%d):" % len(sources)) + for path in sorted(sources): + print(" %s" % path) + main = _pick_main_source(sources, comp.get("name")) + if main: + print("--- %s ---" % main) + print(sources[main].get("content") if isinstance(sources[main], dict) else sources[main]) + return summary + + # 2. swiss-knife (Etherscan v2 proxy) fallback + try: + entry = fetch_swissknife_source(addr, chain_id) + except Exception as e: + print("warn: swiss-knife source lookup failed: %s" % e, file=sys.stderr) + entry = None + if entry and entry.get("SourceCode"): + summary.update({ + "verified": True, + "name": entry.get("ContractName"), + "compiler": entry.get("CompilerVersion"), + "via": "etherscan (swiss-knife)", + }) + print("verified: YES via Etherscan (swiss-knife.xyz proxy)") + print("contract: %s" % entry.get("ContractName")) + print("compiler: %s" % entry.get("CompilerVersion")) + if entry.get("Proxy") == "1" and entry.get("Implementation"): + print("proxy: yes, implementation %s" % entry.get("Implementation")) + if full: + files = parse_etherscan_sources(entry["SourceCode"], entry.get("ContractName")) + print("files (%d):" % len(files)) + for path in sorted(files): + print(" %s" % path) + main = _pick_main_source(files, entry.get("ContractName")) + if main: + print("--- %s ---" % main) + print(files[main]) + return summary + + print("verified: NO — source not found on Sourcify or Etherscan") + print("!! Unverified code. You cannot review what it does. Treat with maximum suspicion.") + return summary + + +def _pick_main_source(files, contract_name): + if not files: + return None + if contract_name: + for path in files: + base = path.rsplit("/", 1)[-1] + if base in (contract_name, contract_name + ".sol"): + return path + return sorted(files)[0] + + +def cmd_source(args): + network = resolve_chain(args.network) + addr = require_address(args.addr) + report_source(addr, network, full=args.full) + + +# --------------------------------------------------------------------------- +# labels +# --------------------------------------------------------------------------- + +def fetch_labels(addr, chain_id): + """swiss-knife labels wrapper (eth-labels). Returns list[str] (may be empty).""" + qs = urllib.parse.urlencode({"chainId": chain_id}) + data = http_get_json("%s/%s?%s" % (SWISSKNIFE_LABELS_URL, addr, qs)) + if isinstance(data, list): + return [str(x) for x in data] + return [] + + +def report_labels(addr, network): + try: + labels = fetch_labels(addr, CHAIN_IDS[network]) + except Exception as e: + print("warn: label lookup failed: %s" % e, file=sys.stderr) + return [] + if labels: + print("labels: %s" % ", ".join(labels)) + else: + print("labels: no labels found") + print("(absence of labels means unknown, NOT safe)") + return labels + + +def cmd_labels(args): + network = resolve_chain(args.network) + addr = require_address(args.addr) + report_labels(addr, network) + + +# --------------------------------------------------------------------------- +# resolve — ENS + Basename +# --------------------------------------------------------------------------- + +def basename_reverse_node(addr): + """Replicates swiss-knife convertReverseNodeToBytes for Base (chainId 8453): + keccak256(namehash("80002105.reverse") ++ keccak256(ascii lowercase addr hex)).""" + addr_hex = addr.lower()[2:] + addr_node = cast_run(["keccak", addr_hex]) # keccak of the ASCII hex chars + base_node = cast_run(["namehash", BASE_REVERSE_DOMAIN]) + return cast_run(["keccak", base_node + addr_node[2:]]) + + +def basename_reverse(addr, base_rpc): + node = basename_reverse_node(addr) + name = cast_run( + ["call", BASENAME_L2_RESOLVER, "name(bytes32)(string)", node, "--rpc-url", base_rpc], + allow_fail=True, + ) + if name: + name = name.strip('"') + return name or None + + +def basename_forward(name, base_rpc): + node = cast_run(["namehash", name]) + addr = cast_run( + ["call", BASENAME_L2_RESOLVER, "addr(bytes32)(address)", node, "--rpc-url", base_rpc], + allow_fail=True, + ) + if addr and int(addr, 16) != 0: + return addr + return None + + +def resolve_names_for_address(addr, mainnet_rpc, base_rpc): + """Reverse-resolve: returns (ens_name, basename), both best-effort.""" + ens = cast_run(["lookup-address", addr, "--rpc-url", mainnet_rpc], allow_fail=True) + basename = basename_reverse(addr, base_rpc) + return (ens or None, basename) + + +def cmd_resolve(args): + mainnet_rpc = rpc_url_for("mainnet", args.rpc_url) + base_rpc = args.base_rpc_url or rpc_url_for("base") + target = args.name_or_addr.strip() + + if ADDR_RE.match(target): + ens, basename = resolve_names_for_address(target, mainnet_rpc, base_rpc) + print("address: %s" % target) + print("ens: %s" % (ens or "(none)")) + print("basename: %s" % (basename or "(none)")) + if not ens and not basename: + sys.exit(3) + return + + if "." not in target: + print("Error: %r is neither an address nor a dotted name" % target, file=sys.stderr) + sys.exit(1) + + # Forward resolution. cast resolve-name cannot follow CCIP-read (offchain + # lookup), so .base.eth names are resolved directly on the Base L2 resolver. + if target.lower().endswith(".base.eth"): + addr = basename_forward(target.lower(), base_rpc) + if addr: + print("%s -> %s (via Base L2 resolver)" % (target, addr)) + return + print("%s did not resolve on the Base L2 resolver" % target, file=sys.stderr) + sys.exit(3) + + addr = cast_run(["resolve-name", target, "--rpc-url", mainnet_rpc], allow_fail=True) + if addr: + print("%s -> %s" % (target, addr)) + return + print("%s did not resolve via mainnet ENS (note: CCIP-read/offchain names " + "are not supported by cast)" % target, file=sys.stderr) + sys.exit(3) + + +# --------------------------------------------------------------------------- +# check — composite due-diligence report +# --------------------------------------------------------------------------- + +def cmd_check(args): + network = resolve_chain(args.network) + addr = require_address(args.addr) + print("== inspect check: %s on %s ==" % (addr, network)) + + print("\n-- code --") + info = analyze_code(addr, network, args.rpc_url) + print_code_report(addr, info, network) + + if info["kind"] != "eoa": + print("\n-- proxy --") + print_proxy_report(addr, analyze_proxy(addr, network, args.rpc_url, code=info["code"]), network) + + print("\n-- source --") + report_source(addr, network, full=False) + + print("\n-- labels --") + report_labels(addr, network) + + print("\n-- names --") + mainnet_rpc = rpc_url_for("mainnet", args.rpc_url if network == "mainnet" else None) + base_rpc = args.base_rpc_url or rpc_url_for("base", args.rpc_url if network == "base" else None) + try: + ens, basename = resolve_names_for_address(addr, mainnet_rpc, base_rpc) + print("ens: %s" % (ens or "(none)")) + print("basename: %s" % (basename or "(none)")) + except SystemExit: + raise + except Exception as e: + print("warn: name resolution failed: %s" % e, file=sys.stderr) + + print("\nVerdict inputs above are ADVISORY. Cross-check on a block explorer") + print("before signing or paying (see references/explorers.md).") + + +def main(): + parser = argparse.ArgumentParser(description="Due-diligence on contracts and addresses") + sub = parser.add_subparsers(dest="cmd", required=True) + + def common(p, addr_arg=True): + if addr_arg: + p.add_argument("addr", help="0x address") + p.add_argument("--network", default=DEFAULT_NETWORK) + p.add_argument("--rpc-url", default=None, + help="full JSON-RPC URL used verbatim (bypasses ERPC_URL/network composition)") + + p = sub.add_parser("code", help="EOA vs contract vs EIP-7702 delegated EOA") + common(p) + p.set_defaults(func=cmd_code) + + p = sub.add_parser("proxy", help="EIP-1967 slots + EIP-1167 minimal proxy detection") + common(p) + p.set_defaults(func=cmd_proxy) + + p = sub.add_parser("source", help="verified source check (Sourcify, then Etherscan)") + common(p) + p.add_argument("--full", action="store_true", help="also print file list + main source") + p.set_defaults(func=cmd_source) + + p = sub.add_parser("labels", help="public address labels") + common(p) + p.set_defaults(func=cmd_labels) + + p = sub.add_parser("resolve", help="ENS/Basename forward + reverse resolution") + p.add_argument("name_or_addr", help="ENS name, .base.eth name, or 0x address") + p.add_argument("--rpc-url", default=None, + help="full mainnet JSON-RPC URL (for the ENS leg)") + p.add_argument("--base-rpc-url", default=None, + help="full Base JSON-RPC URL (for the Basename leg)") + p.set_defaults(func=cmd_resolve) + + p = sub.add_parser("check", help="composite: code + proxy + source + labels + names") + common(p) + p.add_argument("--base-rpc-url", default=None, + help="full Base JSON-RPC URL (for the Basename leg)") + p.set_defaults(func=cmd_check) + + args = parser.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/internal/embed/skills/inspect/scripts/decode.py b/internal/embed/skills/inspect/scripts/decode.py new file mode 100644 index 00000000..d90396d3 --- /dev/null +++ b/internal/embed/skills/inspect/scripts/decode.py @@ -0,0 +1,447 @@ +#!/usr/bin/env python3 +"""decode.py — Decode unknown calldata before signing it. + +Uses only Python stdlib + the `cast` (Foundry) binary for ABI param decoding. +Signature lookups go to openchain.xyz first, 4byte.directory as fallback. +Common ERC-20 selectors and Safe MultiSend are decoded fully offline. + +Usage: python3 scripts/decode.py [args...] + +Commands: + calldata [--to ] [--network ] [--offline] + tx [--network ] [--rpc-url ] + +Environment: + ERPC_URL Base URL for eRPC gateway (default: http://erpc.erpc.svc.cluster.local/rpc) + ERPC_NETWORK Default network (default: mainnet) + +`--rpc-url` takes a FULL JSON-RPC URL used verbatim (already including the +network path), bypassing the ${ERPC_URL}/${network} composition — handy for +testing outside the cluster, e.g. --rpc-url https://ethereum.publicnode.com +""" +import argparse +import json +import os +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request + +ERPC_BASE = os.environ.get("ERPC_URL", "http://erpc.erpc.svc.cluster.local/rpc") +DEFAULT_NETWORK = os.environ.get("ERPC_NETWORK", "mainnet") + +# Canonical chain names match eRPC project aliases (same table as signer.py). +CHAIN_IDS = { + "mainnet": 1, + "base": 8453, + "base-sepolia": 84532, + "sepolia": 11155111, + "hoodi": 560048, +} +CHAIN_ALIASES = { + "ethereum": "mainnet", + "eth": "mainnet", + "eip155:1": "mainnet", + "eip155:8453": "base", + "eip155:84532": "base-sepolia", + "eip155:11155111": "sepolia", + "eip155:560048": "hoodi", +} + +# Selectors decoded offline (no network needed). All static param types. +KNOWN_SELECTORS = { + "0xa9059cbb": "transfer(address,uint256)", + "0x095ea7b3": "approve(address,uint256)", + "0x23b872dd": "transferFrom(address,address,uint256)", + "0xd505accf": "permit(address,address,uint256,uint256,uint8,bytes32,bytes32)", + "0x8fcbaf0c": "permit(address,address,uint256,uint256,bool,uint8,bytes32,bytes32)", + "0x70a08231": "balanceOf(address)", + "0x42842e0e": "safeTransferFrom(address,address,uint256)", +} + +# Safe MultiSend: multiSend(bytes transactions) — packed inner tx format. +MULTISEND_SELECTOR = "0x8d80ff0a" + +OPENCHAIN_URL = "https://api.openchain.xyz/signature-database/v1/lookup" +FOURBYTE_URL = "https://www.4byte.directory/api/v1/signatures/" + +MAX_DEPTH = 4 + + +def resolve_chain(value): + label = str(value).strip() + if label in CHAIN_IDS: + return label + if label in CHAIN_ALIASES: + return CHAIN_ALIASES[label] + supported = ", ".join(sorted(CHAIN_IDS)) + raise SystemExit("Unknown network %r. Supported: %s" % (value, supported)) + + +def http_get_json(url, timeout=15): + req = urllib.request.Request(url, headers={"User-Agent": "obol-inspect/1.0"}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read()) + + +def rpc_call(method, params, network, rpc_url=None): + url = rpc_url or ("%s/%s" % (ERPC_BASE, network)) + payload = json.dumps({"jsonrpc": "2.0", "method": method, "params": params, "id": 1}).encode() + # Explicit User-Agent: public RPCs block Python-urllib's default UA (Cloudflare 1010). + req = urllib.request.Request(url, data=payload, headers={ + "Content-Type": "application/json", "User-Agent": "obol-inspect/1.0"}) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + data = json.loads(resp.read()) + except urllib.error.HTTPError as e: + print("RPC HTTP %d from %s" % (e.code, url), file=sys.stderr) + sys.exit(1) + except urllib.error.URLError as e: + print("RPC connection failed: %s (is %s reachable?)" % (e.reason, url), file=sys.stderr) + sys.exit(1) + if "error" in data: + print("RPC error %s: %s" % (data["error"].get("code"), data["error"].get("message")), file=sys.stderr) + sys.exit(1) + return data.get("result") + + +# --------------------------------------------------------------------------- +# Signature lookup (openchain -> 4byte.directory) +# --------------------------------------------------------------------------- + +def lookup_openchain(selector): + """openchain.xyz lookup. Response shape: + {"ok": true, "result": {"function": {"0x": [{"name": "", ...}]}}} + """ + qs = urllib.parse.urlencode({"function": selector, "filter": "true"}) + data = http_get_json("%s?%s" % (OPENCHAIN_URL, qs)) + if not data.get("ok"): + return [] + entries = (data.get("result") or {}).get("function", {}).get(selector) or [] + return [e["name"] for e in entries if e.get("name")] + + +def lookup_4byte(selector): + """4byte.directory lookup, oldest first (ordering=created_at). Shape: + {"count": N, "results": [{"text_signature": "", ...}]} + """ + qs = urllib.parse.urlencode({"hex_signature": selector, "ordering": "created_at"}) + data = http_get_json("%s?%s" % (FOURBYTE_URL, qs)) + return [r["text_signature"] for r in data.get("results", []) if r.get("text_signature")] + + +def lookup_signatures(selector, offline=False): + """Return candidate signatures for a selector, best-first, deduped.""" + candidates = [] + if selector in KNOWN_SELECTORS: + candidates.append(KNOWN_SELECTORS[selector]) + if selector == "0x00000000" or offline: + return candidates # null selector attracts spam; skip lookups + for name, fn in (("openchain", lookup_openchain), ("4byte.directory", lookup_4byte)): + try: + for sig in fn(selector): + if sig not in candidates: + candidates.append(sig) + if candidates: + break # first database that answers wins (openchain filters spam) + except Exception as e: # network lookups are best-effort + print("warn: %s lookup failed: %s" % (name, e), file=sys.stderr) + return candidates + + +# --------------------------------------------------------------------------- +# Param decoding: cast first, offline static decode as fallback +# --------------------------------------------------------------------------- + +def _cast_env(): + env = dict(os.environ) + env["FOUNDRY_DISABLE_NIGHTLY_WARNING"] = "1" + return env + + +def cast_decode(sig, calldata): + """Decode params via `cast calldata-decode`. Returns list of lines or None.""" + try: + proc = subprocess.run( + ["cast", "calldata-decode", sig, calldata], + capture_output=True, text=True, timeout=30, env=_cast_env(), + ) + except FileNotFoundError: + return None + except subprocess.TimeoutExpired: + return None + if proc.returncode != 0: + return None + lines = [ln for ln in proc.stdout.splitlines() if ln.strip()] + return lines if lines else [] + + +def _split_types(sig): + """Extract flat param type list from 'name(type1,type2,...)'. Static types only.""" + inner = sig[sig.index("(") + 1:sig.rindex(")")] + if not inner: + return [] + if "(" in inner or "[" in inner: + return None # tuples/arrays: not supported offline + return [t.strip() for t in inner.split(",")] + + +def offline_static_decode(sig, calldata): + """Decode static-only params (address/uint*/int*/bool/bytes32) without cast.""" + types = _split_types(sig) + if types is None: + return None + body = calldata[10:] + if len(body) != 64 * len(types): + return None + out = [] + for i, typ in enumerate(types): + word = body[64 * i:64 * i + 64] + if typ == "address": + out.append("0x" + word[24:]) + elif typ.startswith("uint") or typ.startswith("int"): + out.append(str(int(word, 16))) + elif typ == "bool": + out.append("true" if int(word, 16) else "false") + elif typ.startswith("bytes") and typ != "bytes": + out.append("0x" + word) + else: + return None # dynamic type: needs cast + return out + + +def decode_params(sig, calldata): + """Try cast, then the offline static decoder. None = could not decode.""" + lines = cast_decode(sig, calldata) + if lines is not None: + return lines + return offline_static_decode(sig, calldata) + + +# --------------------------------------------------------------------------- +# UTF-8 text fallback (top-level only; mirrors swiss-knife decodeAsUtf8Text) +# --------------------------------------------------------------------------- + +def try_utf8_text(hexdata): + raw = hexdata[2:] if hexdata.startswith("0x") else hexdata + if len(raw) < 2: + return None + try: + text = bytes.fromhex(raw).decode("utf-8") + except (ValueError, UnicodeDecodeError): + return None + if not text or "\x00" in text: + return None + printable = sum( + 1 for ch in text + if 32 <= ord(ch) <= 126 or ord(ch) in (9, 10, 13) or ord(ch) > 127 + ) + if printable / len(text) < 0.8: + return None + return text + + +# --------------------------------------------------------------------------- +# Safe MultiSend unpacking +# --------------------------------------------------------------------------- + +OPERATION_NAMES = {0: "CALL", 1: "DELEGATECALL", 2: "CREATE"} + + +def unpack_multisend(calldata): + """Unpack multiSend(bytes) packed inner transactions. + + ABI head: selector + offset(32) + length(32) + packed bytes. + Packed tx: operation uint8 (1B) | to (20B) | value uint256 (32B) | + dataLength uint256 (32B) | data (dataLength bytes). + Returns list of (operation, to, value, data-hex) or raises ValueError. + """ + body = calldata[10:] + if len(body) < 128: + raise ValueError("multiSend calldata too short") + offset = int(body[0:64], 16) * 2 + length = int(body[offset:offset + 64], 16) * 2 + packed = body[offset + 64:offset + 64 + length] + if len(packed) != length: + raise ValueError("multiSend inner bytes truncated") + + txs = [] + i = 0 + while i < len(packed): + if i + 2 + 40 + 64 + 64 > len(packed): + raise ValueError("truncated inner transaction at byte %d" % (i // 2)) + operation = int(packed[i:i + 2], 16) + i += 2 + to = "0x" + packed[i:i + 40] + i += 40 + value = int(packed[i:i + 64], 16) + i += 64 + data_len = int(packed[i:i + 64], 16) * 2 + i += 64 + data = "0x" + packed[i:i + data_len] + if data_len and len(data) - 2 != data_len: + raise ValueError("truncated inner data at byte %d" % (i // 2)) + i += data_len + txs.append((operation, to, value, data)) + if i != len(packed) or not txs: + raise ValueError("multiSend bytes not fully consumed") + return txs + + +# --------------------------------------------------------------------------- +# Main recursive decode +# --------------------------------------------------------------------------- + +def decode_calldata(calldata, to=None, network=DEFAULT_NETWORK, depth=0, offline=False): + """Decode calldata, printing a human-readable report. Returns True if decoded.""" + pad = " " * depth + calldata = calldata.lower() + if not calldata.startswith("0x"): + calldata = "0x" + calldata + raw = calldata[2:] + if any(c not in "0123456789abcdef" for c in raw): + print("%sError: not valid hex" % pad, file=sys.stderr) + sys.exit(1) + + if raw == "": + print("%s(empty calldata — plain value transfer)" % pad) + return True + if len(raw) < 8: + print("%s(calldata shorter than a 4-byte selector: %s)" % (pad, calldata)) + return False + + selector = calldata[:10] + print("%sselector: %s" % (pad, selector)) + + # --- Safe MultiSend special case (selector-gated: safe at any depth) --- + if selector == MULTISEND_SELECTOR: + try: + txs = unpack_multisend(calldata) + except ValueError as e: + print("%smultiSend(bytes) matched but inner unpack failed: %s" % (pad, e), file=sys.stderr) + return False + print("%sfunction: multiSend(bytes) [Safe MultiSend, %d inner transaction(s)]" % (pad, len(txs))) + if depth + 1 > MAX_DEPTH: + print("%s (max decode depth reached; not descending)" % pad) + return True + for n, (op, inner_to, value, data) in enumerate(txs): + op_name = OPERATION_NAMES.get(op, "UNKNOWN(%d)" % op) + print("%s tx #%d: %s -> %s value: %s wei" % (pad, n, op_name, inner_to, value)) + if op == 1: + print("%s !! DELEGATECALL — inner code runs with the Safe's own storage/identity. Review carefully." % pad) + if data == "0x": + print("%s (no calldata)" % pad) + else: + # Conservative nested decode: selector lookup only, no fallbacks. + decode_calldata(data, to=inner_to, network=network, depth=depth + 2, offline=offline) + return True + + # --- Selector lookup + param decode --- + candidates = lookup_signatures(selector, offline=offline) + if candidates: + if len(candidates) > 1: + print("%scandidate signatures (ADVISORY — selector collisions exist):" % pad) + for sig in candidates: + print("%s - %s" % (pad, sig)) + decoded = False + for sig in candidates: + params = decode_params(sig, calldata) + if params is None: + continue + print("%sfunction: %s" % (pad, sig)) + names = _split_types(sig) or [] + for i, p in enumerate(params): + label = names[i] if i < len(names) else "arg%d" % i + print("%s %s: %s" % (pad, label, p)) + # Recursively decode nested bytes params that look like calldata. + if depth + 1 <= MAX_DEPTH and isinstance(p, str) and p.startswith("0x") and len(p) >= 10 and label == "bytes": + decode_calldata(p, network=network, depth=depth + 1, offline=offline) + if not params: + print("%s (no params)" % pad) + decoded = True + break + if decoded: + return True + print("%sno candidate signature decoded cleanly; candidates were:" % pad) + for sig in candidates: + print("%s - %s" % (pad, sig)) + return False + + print("%sselector unknown to openchain/4byte.directory" % pad) + + # --- Aggressive fallback: UTF-8 text (top-level only) --- + if depth == 0: + text = try_utf8_text(calldata) + if text is not None: + print("%spossible UTF-8 text message: %r" % (pad, text)) + return True + return False + + +# --------------------------------------------------------------------------- +# tx subcommand +# --------------------------------------------------------------------------- + +def cmd_tx(args): + network = resolve_chain(args.network) + tx = rpc_call("eth_getTransactionByHash", [args.hash], network, args.rpc_url) + if tx is None: + print("Transaction %s not found on %s" % (args.hash, network), file=sys.stderr) + sys.exit(2) + receipt = rpc_call("eth_getTransactionReceipt", [args.hash], network, args.rpc_url) + + value_wei = int(tx.get("value", "0x0"), 16) + print("hash: %s" % tx.get("hash")) + print("from: %s" % tx.get("from")) + print("to: %s" % (tx.get("to") or "(contract creation)")) + print("value: %.6f ETH (%d wei)" % (value_wei / 1e18, value_wei)) + if receipt is None: + print("status: pending (no receipt yet)") + else: + status = int(receipt.get("status", "0x0"), 16) + print("status: %s" % ("success" if status == 1 else "FAILED (reverted)")) + print("block: %d gasUsed: %d" % ( + int(receipt.get("blockNumber", "0x0"), 16), + int(receipt.get("gasUsed", "0x0"), 16))) + data = tx.get("input") or "0x" + print("input: %d bytes" % ((len(data) - 2) // 2)) + if data != "0x": + print("--- decoded input ---") + ok = decode_calldata(data, to=tx.get("to"), network=network, offline=args.offline) + if not ok: + sys.exit(3) + + +def cmd_calldata(args): + network = resolve_chain(args.network) + ok = decode_calldata(args.hex, to=args.to, network=network, offline=args.offline) + if not ok: + sys.exit(3) + + +def main(): + parser = argparse.ArgumentParser(description="Decode unknown calldata before signing it") + sub = parser.add_subparsers(dest="cmd", required=True) + + p_cd = sub.add_parser("calldata", help="decode raw calldata") + p_cd.add_argument("hex", help="hex calldata (with or without 0x)") + p_cd.add_argument("--to", default=None, help="target contract address (context only)") + p_cd.add_argument("--network", default=DEFAULT_NETWORK) + p_cd.add_argument("--offline", action="store_true", help="skip network signature lookups") + p_cd.set_defaults(func=cmd_calldata) + + p_tx = sub.add_parser("tx", help="fetch a tx by hash and decode its input") + p_tx.add_argument("hash", help="transaction hash") + p_tx.add_argument("--network", default=DEFAULT_NETWORK) + p_tx.add_argument("--rpc-url", default=None, + help="full JSON-RPC URL used verbatim (bypasses ERPC_URL/network composition)") + p_tx.add_argument("--offline", action="store_true", help="skip network signature lookups") + p_tx.set_defaults(func=cmd_tx) + + args = parser.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/internal/embed/skills/l2s/SKILL.md b/internal/embed/skills/l2s/SKILL.md index 74e215e6..f5fc9322 100644 --- a/internal/embed/skills/l2s/SKILL.md +++ b/internal/embed/skills/l2s/SKILL.md @@ -30,6 +30,7 @@ description: Ethereum Layer 2 landscape — Arbitrum, Optimism, Base, zkSync, Sc | **Optimism** | Optimistic (OP Stack) | $8B+ | $0.001-0.003 | 2s | 7 days | 10 | | **Unichain** | Optimistic (OP Stack) | Growing | $0.001-0.003 | 1s | 7 days | 130 | | **Celo** | Optimistic (OP Stack) | $200M+ | <$0.001 | 5s | 7 days | 42220 | +| **Robinhood Chain** | Optimistic (Arbitrum Nitro) | Growing | <$0.001 | ~100ms | 7 days | 4663 | | **Linea** | ZK | $900M+ | $0.003-0.006 | 2s | 30-60min | 59144 | | **zkSync Era** | ZK | $800M+ | $0.003-0.008 | 1s | 15-60min | 324 | | **Scroll** | ZK | $250M+ | $0.002-0.005 | 3s | 30-120min | 534352 | @@ -119,6 +120,13 @@ Members contribute **15% of sequencer revenue** to the Optimism Collective. Cros - **Stylus:** Write smart contracts in Rust, C, C++ (compiles to WASM, runs alongside EVM, shares state). Use for compute-heavy operations (10-100x gas savings). Contracts must be "activated" via `ARB_WASM_ADDRESS` (0x0000…0071). - **Orbit:** Framework for launching L3 chains on Arbitrum. 47 live on mainnet. +### Robinhood Chain +- **What:** Robinhood's own Arbitrum Nitro stack L2 (chain ID 4663), settling to Ethereum L1. Gas token ETH, ~100ms blocks. +- **Focus:** Consumer + tokenized assets — "Robinhood Token" tokenized stocks (AAPL, TSLA, SPY, …) as ERC-20s. +- **Bridging:** Canonical Arbitrum-style bridge (~10 min deposits, 7-day withdrawal + L1 claim) plus partner routes: LayerZero/Stargate, CCIP, Relay, Across, LiFi. +- **Bridged ERC-20 addresses DIFFER from Ethereum** — derive via the L2 Gateway Router, never reuse L1 addresses. +- **Ecosystem is young (mid-2026):** Uniswap V2/V3/V4 + PancakeSwap deployed; deepest liquidity is USDG/WETH. The dominant stablecoin is Paxos **USDG, not USDC** (canonical bridged USDC exists but is near-empty; the explorer is full of scam "USDC" tokens). Verified addresses: `addresses/references/robinhood-chain.md`. + ## RPCs and Explorers | L2 | RPC | Explorer | @@ -128,12 +136,17 @@ Members contribute **15% of sequencer revenue** to the Optimism Collective. Cros | Optimism | `https://mainnet.optimism.io` | https://optimistic.etherscan.io | | Unichain | `https://mainnet.unichain.org` | https://uniscan.xyz | | Celo | `https://forno.celo.org` | https://celoscan.io | +| Robinhood Chain | `https://rpc.mainnet.chain.robinhood.com` | https://robinhoodchain.blockscout.com | | zkSync | `https://mainnet.era.zksync.io` | https://explorer.zksync.io | | Scroll | `https://rpc.scroll.io` | https://scrollscan.com | | Linea | `https://rpc.linea.build` | https://lineascan.build | ## Bridging +> **Runbooks + safety rules:** the `bridging` skill has verified contract +> addresses, cast/signer.py recipes (deposits, withdrawals, claims), and the +> time/cost/risk framework — use it for any actual bridge transaction. + ### Official Bridges | L2 | Bridge URL | L1→L2 | L2→L1 | diff --git a/internal/embed/skills/swap/SKILL.md b/internal/embed/skills/swap/SKILL.md index c55a6d37..802469c5 100644 --- a/internal/embed/skills/swap/SKILL.md +++ b/internal/embed/skills/swap/SKILL.md @@ -21,24 +21,35 @@ from QuoterV2 and execution through the agent's remote-signer expected amount out, minimum out, fee tier, chain. Never swap autonomously. 3. **Never swap more than the immediate need** (e.g. gas top-ups: swap for ~2–4 weeks of expected gas, not the whole balance). -4. Addresses below were verified on-chain (`eth_getCode`) on 2026-07-06. +4. Addresses below were verified on-chain (`eth_getCode`) on 2026-07-06 + (Arbitrum addresses verified 2026-07-10). Re-verify with `rpc.sh code ` if anything reverts unexpectedly. ## Verified addresses -| Contract | Base (8453) | Ethereum mainnet (1) | -|----------|-------------|----------------------| -| Uniswap V3 SwapRouter02 | `0x2626664c2603336E57B271c5C0b26F421741e481` | `0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45` | -| Uniswap V3 QuoterV2 | `0x3d4e44Eb1374240CE5F1B871ab261CD16335B76a` | `0x61fFE014bA17989E743c5F6cB21bF9697530B21e` | -| USDC (6 decimals) | `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` | `0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48` | -| WETH (18 decimals) | `0x4200000000000000000000000000000000000006` | `0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2` | -| OBOL (18 decimals) | — (Base-Sepolia only, no mainnet-Base deploy) | `0x0B010000b7624eb9B3DfBC279673C76E9D29D5F7` | +| Contract | Base (8453) | Ethereum mainnet (1) | Arbitrum (42161) | +|----------|-------------|----------------------|------------------| +| Uniswap V3 SwapRouter02 | `0x2626664c2603336E57B271c5C0b26F421741e481` | `0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45` | `0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45` | +| Uniswap V3 QuoterV2 | `0x3d4e44Eb1374240CE5F1B871ab261CD16335B76a` | `0x61fFE014bA17989E743c5F6cB21bF9697530B21e` | `0x61fFE014bA17989E743c5F6cB21bF9697530B21e` | +| USDC (6 decimals) | `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` | `0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48` | `0xaf88d065e77c8cC2239327C5EDb3A432268e5831` (native) | +| WETH (18 decimals) | `0x4200000000000000000000000000000000000006` | `0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2` | `0x82aF49447D8a07e3bd95BD0d56f35241523fBab1` | +| OBOL (18 decimals) | — (Base-Sepolia only, no mainnet-Base deploy) | `0x0B010000b7624eb9B3DfBC279673C76E9D29D5F7` | — (no Arbitrum deploy) | Aerodrome (router `0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43`, Base) often has deeper liquidity for long-tail Base pairs — but its ve(3,3) router ABI differs; prefer Uniswap V3 for the pairs above and reach for Aerodrome only when V3 quotes are bad. See `building-blocks` for Aerodrome details. +Before approving or swapping a token contract *not* in this table, run the +`inspect` skill's `contract.py check ` (proxy? verified source? +labels?) — token contracts that are unverified proxies deserve suspicion. + +Camelot is the major Arbitrum-native DEX, but the recipes here use Uniswap V3 +(same router/quoter ABI on every chain in the table). + +Robinhood Chain (4663): no verified DEX deployment in this skill yet — check +addresses skill references before attempting swaps there. + ## Recipe: USDC → ETH for gas (Base example) Environment (same defaults as `ethereum-local-wallet`): @@ -132,6 +143,7 @@ sh "$RPC" --network $NET call $USDC "balanceOf(address)(uint256)" $ME # USDC lef - Paying for an x402 service — that's `buy-x402` (no swap needed; it signs USDC/OBOL authorizations directly). -- Bridging between chains — this skill never bridges. USDC on Base and USDC - on mainnet are different balances; say so if the user conflates them. +- Moving value BETWEEN chains — that's the `bridging` skill, not swap. This + skill never bridges. USDC on Base and USDC on mainnet are different + balances; say so if the user conflates them. - Anything the user hasn't confirmed. Every swap is user-confirmed, every time.