diff --git a/README.md b/README.md index ac873ac..df43619 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Consolidated skills that cover the most common use cases. Each uses progressive | ----- | ------- | ----------- | | [build-on-base](./skills/build-on-base/SKILL.md) | `npx skills add base/skills --skill build-on-base` | Complete Base development playbook: network, contracts, wallet auth, payments, attribution, and migrations. Consolidates all individual skills into one. | | [base-mcp](./skills/base-mcp/SKILL.md) | `npx skills add base/skills --skill base-mcp` | Base MCP server — gives your AI assistant a wallet via mcp.base.org. Sending, swapping, signing, batched calls, balances, and partner plugins for lending, swaps, and more. | -| [vibenet](./skills/vibenet/SKILL.md) | `npx skills add base/skills --skill vibenet` | Build on [vibenet](https://chain.base.org/vibenet), the Base Vibes devnet for native account abstraction (EIP-8130) with viem: smart accounts, batched calls, session keys and policies, and ERC-8168 payer gas sponsorship. | +| [vibenet](./skills/vibenet/SKILL.md) | `npx skills add base/skills --skill vibenet` | Build on [vibenet](https://chain.base.org/vibenet), the Base Vibes devnet for native account abstraction (EIP-8130) and Cobalt 200ms native blocks, with viem: smart accounts, batched calls, session keys and policies, ERC-8168 payer gas sponsorship, and millisecond block timestamps. | ## Installation @@ -75,6 +75,10 @@ Create an EIP-8130 smart account on vibenet and fund it from the faucet Deploy a smart account on vibenet with sponsored gas, so the user needs no ETH ``` +```text +Send a transaction on vibenet and show me which 200ms block it landed in +``` + ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. diff --git a/skills/vibenet/SKILL.md b/skills/vibenet/SKILL.md index f5c91bc..c199df2 100644 --- a/skills/vibenet/SKILL.md +++ b/skills/vibenet/SKILL.md @@ -1,14 +1,17 @@ --- name: vibenet description: >- - Build on vibenet — Base's devnet for native account abstraction (EIP-8130) - and payer gas sponsorship (ERC-8168) using viem's eip8130 module. Use + Build on vibenet — Base's devnet for native account abstraction (EIP-8130), + payer gas sponsorship (ERC-8168), and Cobalt 200ms native blocks. Use whenever the user mentions vibenet, EIP-8130, ERC-8168, 8130 accounts, native - account abstraction, session keys, actors, policies, payers, or gas - sponsorship on Base — or is writing code that creates or operates 8130 smart - accounts, authorizes session-key actors, sends batched calls, sponsors gas - with a payer, or wires a frontend/script against the vibenet devnet or Base - Sepolia. + account abstraction, session keys, actors, policies, payers, gas sponsorship + on Base, 200ms blocks, Cobalt, BaseTime, timestampMs / blockTimestampMs, + sub-second or millisecond block timestamps, migrating from Flashblocks, or + newHeads / WebSocket streaming on vibenet — or is writing code that creates + or operates 8130 smart accounts, authorizes session-key actors, sends batched + calls, sponsors gas with a payer, queries or streams vibenet blocks, times a + transaction's inclusion, or wires a frontend/script against the vibenet + devnet or Base Sepolia. --- # Vibenet @@ -18,7 +21,11 @@ abstraction in the protocol itself. Accounts are portable across EVM chains, support multiple signer types (secp256k1, P-256, WebAuthn), key rotation without changing address, scoped session-key actors, on-chain policies, and native **ERC-8168** gas sponsorship. The tooling lives in viem's `eip8130` -module (fork branch — not yet in npm `viem`). +module (fork branch — not yet in npm `viem`). Vibenet is also the first Base +network running **Cobalt 200ms native blocks** (five canonical blocks per +second, with a millisecond `timestampMs` on every block) — that part needs no +fork, stock `viem` works. See +[references/200ms-blocks.md](references/200ms-blocks.md). ## Network @@ -26,11 +33,13 @@ module (fork branch — not yet in npm `viem`). |----------|-------| | Chain ID | `84538453` | | Public execution RPC | `https://rpc.vibes.base.org` — 8130-capable (`AA_TX_TYPE` / `0x79`), serves `access-control-allow-origin: *` | +| WebSocket RPC | `wss://rpc.vibes.base.org/ws` — `newHeads`, `logs`, `transactionReceipts` subscriptions (the `/ws` path is required) | +| Block time | **200ms** (Cobalt); blocks carry `timestampMs`, BaseTime predeploy at `0x4200000000000000000000000000000000000030` | | Browser RPC proxy | `https://api.vibes.base.org/api/vibenet/account/rpc` — passes through all `eth_*`, including `0x79` broadcasts and receipt polling | | Hosted payer (ERC-8168) | `https://api.vibes.base.org/api/vibenet/account/payer` | | Faucet | `POST https://api.vibes.base.org/api/vibenet/faucet/drip` with `{ "address": "0x…" }` | | Faucet status | `GET https://api.vibes.base.org/api/vibenet/faucet/status` — drip size, cooldowns, USDV/NFV token addresses | -| Chain health | `GET https://api.vibes.base.org/api/vibenet/chain-health` — `{ healthy, head, headAgeSecs, … }` | +| Chain health | `GET https://api.vibes.base.org/api/vibenet/chain-health` — key off `healthy` + `headAgeSecs`; `stuckSecs` is not actionable on its own (seen at 19000 on a healthy chain) | | Landing page / explorer | `https://chain.base.org/vibenet`, `https://chain.base.org/vibenet/explorer` | | Base Sepolia (also 8130-enabled) | `https://sepolia.base.org`, chain id `84532` | @@ -42,20 +51,34 @@ reads like a code bug rather than a wrong URL. All `api.vibes.base.org` endpoints (RPC proxy, payer, faucet) send permissive CORS headers, and so does `rpc.vibes.base.org` — so browser apps can talk to either. Prefer `rpc.vibes.base.org` for execution and reserve the `account/rpc` -proxy for when you specifically want the hosted path. +proxy for when you specifically want the hosted path. The public RPC is +method-allowlisted: `eth_getHeaderBy*`, `eth_getBlockReceipts`, +`eth_sendRawTransactionSync`, `eth_simulateV1` and `txpool_*` answer +`rpc method is not whitelisted`. + +**Only 8130 / 8168 code needs the fork below.** Querying blocks, streaming +`newHeads`, and sending plain EOA transactions on vibenet work with stock +`npm install viem`. The 8130 modules are additive to viem itself, proposed upstream in -[wevm/viem#5004](https://github.com/wevm/viem/pull/5004) (still an open draft — -not yet released to npm). Until it ships, they have to be built from the fork +[wevm/viem#5004](https://github.com/wevm/viem/pull/5004) (open, out of draft — +not yet merged or released to npm). Until it ships, they have to be built from the fork branch the PR is opened from: `chunter-cb/viem` `feat/eip-8130-production`. **Use the bundled installer** — it does the whole clone→build→link dance, which is error-prone by hand: ```bash -scripts/setup-viem-8130.sh [APP_DIR] # defaults to the current directory +scripts/setup-viem-8130.sh [APP_DIR] [BUILD_DIR] # APP_DIR defaults to the current directory ``` +`BUILD_DIR` defaults to `/.viem-8130-src` — a full viem monorepo +checkout with its own `node_modules` (~500 MB). The script appends +`.viem-8130-src/` to the app's `.gitignore` so a `git add .` can't commit it; +for several apps, pass one shared `BUILD_DIR` outside them (npm records the +`file:` path in `package.json`). Node 22 works despite the fork's +`node >=24.5` engine warning. + When PR #5004 merges and a viem release ships the modules, this collapses to `npm install viem@latest` — the imports (`viem/eip8130`, `viem/eip8168`) and APIs are unchanged, so no code moves. @@ -96,8 +119,9 @@ Creating an account derives a CREATE2 address locally — synchronous, zero RPC, `eth_getCode` still `0x`. It becomes real as a **side effect of its first transaction**, which carries `account.createChange` alongside your actual calls. There is nothing else to call. The shortest path from nothing to a deployed -account is a *sponsored* first tx (no faucet, no funding); the self-paid route -needs the address funded first. Read deployment state from `eth_getCode`, never +account is a *sponsored* first tx (no faucet, no funding — for a zero-value +first tx; sponsorship covers gas, never value); the self-paid route needs the +address funded first. Read deployment state from `eth_getCode`, never from optimistic local state — it decides whether the next tx carries `createChange`. Full lifecycle: [references/eip8130-accounts.md](references/eip8130-accounts.md). @@ -124,16 +148,21 @@ Read the reference for your task: | **Accounts & transactions** | Create an 8130 smart account, the counterfactual→deployed lifecycle, send batched calls, attribution metadata, gas estimation, reading account state, locking, gotchas | [references/eip8130-accounts.md](references/eip8130-accounts.md) | | **Session keys & policies** | Authorize/revoke actors, scopes, SessionPolicy spend limits, config sequences, verifying "silent" changes | [references/session-keys-and-policies.md](references/session-keys-and-policies.md) | | **Gas sponsorship** | Sponsor gas with a payer (ERC-8168), gasless onboarding, `send` vs `sign` modes | [references/payer-sponsorship.md](references/payer-sponsorship.md) | +| **200ms blocks & timing** | Read a block's millisecond timestamp (`timestampMs`), decode the BaseTime deposit / predeploy, stream `newHeads` over WebSocket, send a tx and see which 200ms block it landed in, fix viem polling, migrate from Flashblocks | [references/200ms-blocks.md](references/200ms-blocks.md) | ## Operating Procedure 1. **Classify the task** using the table above and read the relevant reference before implementing. 2. **Pick the right RPC**: `rpc.vibes.base.org` works from both Node and the - browser; `api.vibes.base.org/api/vibenet/account/rpc` is the hosted proxy to - the same chain. Never `vibes.base.org` — that host is not an API. + browser; `wss://rpc.vibes.base.org/ws` for subscriptions; + `api.vibes.base.org/api/vibenet/account/rpc` is the hosted proxy to the same + chain. Never `vibes.base.org` — that host is not an API. 3. **Implement** with explicit chain id, the `scripts/setup-viem-8130.sh` - install, and read-back verification for any account-config change. + install (8130/8168 only), read-back verification for any account-config + change, and an explicit `pollingInterval` (≈100ms) on every receipt wait or + block watch — viem's defaults (4000ms, or a 500ms floor) hide the 200ms + cadence. 4. **Deliver** runnable code, install commands, and any manual steps (env vars, faucet funding). @@ -145,8 +174,15 @@ Read the reference for your task: (upstream PR: [wevm/viem#5004](https://github.com/wevm/viem/pull/5004); API surface: `src/eip8130/index.ts`; docs: `site/pages/eip8130`) - **Deep guide (chaptered)**: `github.com/chunter-cb/eip-8130-web` (`/guide/*`) -- **Session-key walkthrough**: +- **Session-key walkthrough** (names are stale — map `to8130Account` → + `toAccount`, `isActor8130` → `isActor`, `sendCalls` → `sendTransaction`; its + `managerActor` step is the PolicyManager-as-operator authorize in the session + reference): [gist.github.com/chunter-cb/bf70c53a5ab6d8361ce7f4215b776114](https://gist.github.com/chunter-cb/bf70c53a5ab6d8361ce7f4215b776114) +- **200ms blocks (Cobalt)**: + [docs.base.org/upgrades/cobalt/200ms-blocks](https://docs.base.org/upgrades/cobalt/200ms-blocks), + [migrate-from-flashblocks](https://docs.base.org/upgrades/cobalt/migrate-from-flashblocks), + [test-on-vibenet](https://docs.base.org/build-on-base/test-on-vibenet) ## Installation diff --git a/skills/vibenet/references/200ms-blocks.md b/skills/vibenet/references/200ms-blocks.md new file mode 100644 index 0000000..d803ba4 --- /dev/null +++ b/skills/vibenet/references/200ms-blocks.md @@ -0,0 +1,319 @@ +# 200ms native blocks (Cobalt) on vibenet + +Querying, streaming, and timing transactions against vibenet's 200ms canonical +blocks. For network endpoints and install, see the [skill root](../SKILL.md). +**None of this needs the viem fork** — stock `npm install viem` is enough for +everything on this page except the 8130 smart-account send at the end. + +## What changed + +Cobalt moves Base block production from one block every 2s to **five complete +canonical blocks per second**. Each 200ms block has its own number, hash, state +root, receipts, and unsafe → safe → finalized lifecycle. It **replaces +Flashblocks** (the pre-Cobalt preconfirmation stream); Flashblocks production +stops and the `"pending"` tag has no preconfirmation meaning any more. + +| Network | Status | +|---|---| +| vibenet (`84538453`) | **Live** — experimental, "may change, don't base production decisions on it" | +| Base Sepolia / Mainnet | Not active; activation and client versions TBD | + +The block header keeps its **seconds** `timestamp`, and so do `eth_call`, +transaction validity windows, and EVM `block.timestamp`. The sub-second part +comes from a **BaseTime** metadata deposit at `tx[1]` of every block: + +``` +full_ms(block) = 1000 * block.timestamp + millisPart millisPart ∈ {0, 200, 400, 600, 800} +full_ms(child) = full_ms(parent) + 200 (no skipped slots) +``` + +RPC responses expose the full millisecond value as extra **optional** fields +(omitted for pre-Cobalt or pruned history, so always treat them as possibly +missing): + +| Where | Field | Live on `rpc.vibes.base.org`? | +|---|---|---| +| `eth_getBlockByNumber` / `eth_getBlockByHash` | `timestampMs` (hex quantity) | yes | +| `eth_subscribe("newHeads")` | `timestampMs` | yes (`wss://rpc.vibes.base.org/ws`) | +| `eth_getTransactionByHash` / `…ByBlock*AndIndex` (mined tx) | `blockTimestamp` + `blockTimestampMs` | yes | +| `eth_getLogs`, `eth_getFilterLogs/Changes`, `eth_subscribe("logs")` | `blockTimestampMs` on each log | yes | +| `eth_getTransactionReceipt` | `blockTimestampMs` on **`receipt.logs[i]` only** — nothing at the receipt top level | yes | +| `eth_getHeaderByNumber/Hash`, `eth_getBlockReceipts` | `timestampMs` per spec | **no** — `rpc method is not whitelisted` (-32601) on the public RPC | + +Worked example from the docs: a block at 42.200s has `timestamp: 0x2a`, +`timestampMs: 0xa4d8`. + +### The BaseTime deposit (`tx[1]`) and predeploy + +| | | +|---|---| +| Position | `tx[1]` in every block, right after the L1-info deposit at `tx[0]`, before user txs | +| Type / from | Deposit `0x7e` from `0xDeaDDEaDDeAdDeAdDEAdDEaddeAddEAdDEAd0001` | +| To | BaseTime predeploy `0x4200000000000000000000000000000000000030` | +| Calldata | `setTimestampMillisPart(uint16)` — selector `0x86bdf394` + 32-byte part | +| Predeploy getters | `timestampMillisPart()` → `uint16` (selector `0x7b2fea99`), `timestampMs()` → `uint256` (selector `0x5745a677`) | +| Implementation | `0xc0D3C0d3C0d3C0D3c0d3C0d3c0D3C0d3c0d30030` behind the proxy | + +Because the deposit executes before every user transaction, a contract can read +the current block's millisecond time from the predeploy during execution: + +```solidity +interface IBaseTime { + function timestampMillisPart() external view returns (uint16); // 0 | 200 | 400 | 600 | 800 + function timestampMs() external view returns (uint256); // 1000 * block.timestamp + part +} +IBaseTime constant BASE_TIME = IBaseTime(0x4200000000000000000000000000000000000030); +// block.timestamp is still whole seconds. +``` + +## Query a block + +Stock viem keeps unknown RPC fields, so `getBlock` returns `timestampMs` — but +as the **raw hex string**, untyped (the `Block` type doesn't know about it). +Convert it yourself and treat it as optional: + +```ts +import { createPublicClient, http, hexToBigInt, decodeFunctionData, parseAbi, type Hex } from "viem"; + +const vibenet = { + id: 84538453, + name: "vibenet", + nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, + rpcUrls: { default: { http: ["https://rpc.vibes.base.org"], webSocket: ["wss://rpc.vibes.base.org/ws"] } }, + blockTime: 200, // lets viem pick sane defaults, but see "Polling" below +} as const; +const client = createPublicClient({ chain: vibenet, transport: http() }); + +// Cobalt fields ride along untyped — declare them once. +type CobaltBlock = { timestampMs?: Hex }; +type CobaltTx = { blockTimestampMs?: Hex }; + +const BASE_TIME = "0x4200000000000000000000000000000000000030"; +const baseTimeAbi = parseAbi([ + "function timestampMillisPart() view returns (uint16)", + "function timestampMs() view returns (uint256)", + "function setTimestampMillisPart(uint16)", +]); + +const block = await client.getBlock({ includeTransactions: true }); // latest +const raw = (block as unknown as CobaltBlock).timestampMs; +const timestampMs = raw ? hexToBigInt(raw) : undefined; // undefined = pre-Cobalt/pruned +const millisPart = timestampMs !== undefined ? timestampMs - block.timestamp * 1000n : undefined; + +// tx[1] is the BaseTime deposit; decode the part it wrote. +const baseTimeTx = block.transactions[1]; +const { args: [partFromTx] } = decodeFunctionData({ abi: baseTimeAbi, data: baseTimeTx.input }); + +// The predeploy agrees, as long as you pin the same block. +const partFromChain = await client.readContract({ + address: BASE_TIME, abi: baseTimeAbi, functionName: "timestampMillisPart", blockNumber: block.number, +}); + +console.log({ number: block.number, timestamp: block.timestamp, timestampMs, millisPart, partFromTx, partFromChain }); +// e.g. { number: 2845536n, timestamp: 1788960994n, timestampMs: 1788960994200n, millisPart: 200n, partFromTx: 200, partFromChain: 200 } +``` + +Raw JSON-RPC, if you'd rather `curl`: + +```bash +RPC=https://rpc.vibes.base.org +# block → timestamp (s) + timestampMs +curl -s $RPC -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"eth_getBlockByNumber","params":["latest",false]}' \ + | jq '.result | {number, timestamp, timestampMs}' +# logs → blockTimestampMs on every log +curl -s $RPC -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"eth_getLogs","params":[{"fromBlock":"latest","toBlock":"latest"}]}' \ + | jq '.result[0] | {blockNumber, blockTimestamp, blockTimestampMs}' +# predeploy → current millisecond part +curl -s $RPC -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{"to":"0x4200000000000000000000000000000000000030","data":"0x7b2fea99"},"latest"]}' +``` + +Anything mined carries the block's ms time too: `getLogs` / +`getTransactionReceipt(...).logs[i].blockTimestampMs` and +`getTransaction(...).blockTimestampMs` (stock viem; the 8130 fork's +`getTransaction` is the exception — see Gotchas). + +## Stream blocks + +Use the WebSocket endpoint — note the **`/ws` path**; the bare host refuses the +upgrade. Every block arrives (~5/s), each with `timestampMs`: + +```ts +import { createPublicClient, webSocket, hexToBigInt } from "viem"; + +const ws = createPublicClient({ transport: webSocket("wss://rpc.vibes.base.org/ws") }); +const unwatch = ws.watchBlocks({ + onBlock: (block) => { + const ms = hexToBigInt((block as any).timestampMs); + console.log(block.number, ms, `.${ms % 1000n}`); // 2845550n 1788960997000n .0 → .200 → .400 … + }, +}); +``` + +Live-confirmed: 10 consecutive `watchBlocks` events were contiguous block +numbers exactly 200ms apart in `timestampMs`. + +Over **HTTP**, `watchBlocks` polls at the client's `pollingInterval` (see +Polling) and therefore *skips* blocks at 200ms cadence; pass +`emitMissed: true` if you need every block, or use the WebSocket transport. + +Raw subscription, if you're not using viem: + +```json +{"jsonrpc":"2.0","id":1,"method":"eth_subscribe","params":["newHeads"]} +``` + +`eth_subscribe("logs", …)` and `eth_subscribe("transactionReceipts")` also +work on `/ws`. `eth_subscribe("newFlashblocks")` still *returns a subscription +id* on vibenet but **never emits** — a silent no-op, not an error. + +### Migrating from Flashblocks + +| Flashblocks | Cobalt | +|---|---| +| `eth_subscribe("newFlashblocks")` | `eth_subscribe("newHeads")` | +| `eth_subscribe("pendingLogs")` | `eth_subscribe("logs")` | +| `eth_subscribe("newFlashblockTransactions")` | `newHeads`, then fetch each block's txs | +| `eth_getBlockByNumber("pending")`, `eth_getBalance/eth_call/eth_estimateGas/eth_getTransactionCount(…, "pending")`, `eth_getLogs` to `"pending"` | the same call with `"latest"` — canonical 200ms state | + +`"pending"` still *answers* on vibenet (it returns the block being built) but it +is not a preconfirmation stream and there is no replacement for one: the +canonical block simply lands 200ms later. `safe` and `finalized` lag the head by +minutes (~1000 and ~2000 blocks when measured), so don't wait on them in demos. + +## Polling: make viem keep up + +viem's client `pollingInterval` defaults to **4000ms** on a chain object without +`blockTime`, and to `max(blockTime / 2, 500)` — i.e. a **500ms floor** — with +`blockTime: 200`. Every `waitForTransactionReceipt` / `watchBlocks` / +`watchBlockNumber` over HTTP inherits it, so a 200ms chain looks like a 4s (or +0.5s) chain unless you say otherwise. Pass `pollingInterval` explicitly on the +call (or the client) when you're timing anything: + +```ts +const receipt = await client.waitForTransactionReceipt({ hash, pollingInterval: 100 }); +``` + +The 8130 fork's `waitForTransactionReceipt` (from `viem/eip8130`) has its own +default of 500ms, independent of the client — same fix, `pollingInterval: 100`. +It also makes **2–3 RPC round-trips per iteration** (receipt, then a +`getTransaction` probe for the tx's expiry, then a block-timestamp read), so +from a browser at ~150ms RTT each iteration costs ~0.5s and a tx that landed in +one 200ms block still reports ~1.4s (live-observed). When the number matters, +poll `getTransactionReceipt` from `viem/eip8130` yourself every 100ms — one +call per iteration — and keep `allPhasesSucceeded(receipt.eip8130)` for the +verdict. + +## Send a transaction and see which 200ms block it landed in + +### Plain EOA (stock viem) + +```ts +import { createPublicClient, createWalletClient, http, hexToBigInt, parseEther, type Hex } from "viem"; +import { privateKeyToAccount, generatePrivateKey } from "viem/accounts"; +// `vibenet` chain object as above (with webSocket + blockTime: 200) + +const account = privateKeyToAccount(generatePrivateKey()); // throwaway devnet key +const client = createPublicClient({ chain: vibenet, transport: http() }); +const wallet = createWalletClient({ account, chain: vibenet, transport: http() }); + +// Fund it (0.1 ETH per drip, 10s cooldown per address and per IP), then poll the balance. +await fetch("https://api.vibes.base.org/api/vibenet/faucet/drip", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ address: account.address }), +}); +while ((await client.getBalance({ address: account.address })) === 0n) await new Promise((r) => setTimeout(r, 100)); + +const sentAt = Date.now(); +const hash = await wallet.sendTransaction({ to: "0x000000000000000000000000000000000000dEaD", value: parseEther("0.0001") }); +const receipt = await client.waitForTransactionReceipt({ hash, pollingInterval: 100 }); +const minedAt = Date.now(); + +const block = await client.getBlock({ blockNumber: receipt.blockNumber }); +const blockMs = hexToBigInt((block as unknown as { timestampMs: Hex }).timestampMs); +console.log({ + hash, block: receipt.blockNumber, blockTimestampMs: blockMs, millisPart: blockMs % 1000n, + receiptAfterBroadcastMs: minedAt - sentAt, +}); +``` + +Measured on 2026-09-09 (three sends, from a client ~150ms RTT from the RPC): + +| Metric | Observed | +|---|---| +| Faucet drip → balance visible | 0.4–0.7s | +| `sendTransaction` broadcast → receipt (HTTP, 100ms polling) | 0.50–0.53s | +| Same over the WebSocket client (`newHeads`-driven wait) | 0.44–0.69s | +| Inclusion block's `timestampMs` − broadcast wall-clock | +0.26–0.36s (i.e. the next or second slot) | + +Most of the wall-clock in a naive script is *before* broadcast: viem's +`sendTransaction` does chain-id, nonce, gas and fee round-trips first +(~0.7–1.1s at that RTT). Pass `nonce`, `gas`, `maxFeePerGas` and +`maxPriorityFeePerGas` yourself if you want the send to be one round-trip. + +### 8130 smart account (viem fork) + +Same shape as the create-and-send example in +[eip8130-accounts.md](eip8130-accounts.md), with two 200ms-specific details: +poll fast, and read the millisecond time **from the block**, because the 8130 +`getTransaction` doesn't carry it and a `0x79` tx object inside a block body has +no `hash` field to look up. + +```ts +import { hexToBigInt, parseEther, type Hex } from "viem"; +import { sendTransaction, waitForTransactionReceipt, allPhasesSucceeded } from "viem/eip8130"; + +const hash = await sendTransaction(client, { account, calls, gas /* from estimateGas, +20% */ }); +const receipt = await waitForTransactionReceipt(client, { hash, pollingInterval: 100 }); +if (!allPhasesSucceeded(receipt.eip8130)) throw new Error("a phase reverted"); + +const block = await client.getBlock({ blockNumber: BigInt(receipt.blockNumber) }); +const blockMs = hexToBigInt((block as unknown as { timestampMs: Hex }).timestampMs); +console.log(receipt.blockNumber, blockMs, blockMs % 1000n); +``` + +Measured on 2026-09-09 with a fresh account (deploy + first batch in one tx): + +| Metric | Observed | +|---|---| +| `sendTransaction` (prepare + sign + broadcast) | 0.75s | +| Broadcast → 8130 receipt (100ms polling) | 0.47s | +| `eth_getCode` non-empty after the create receipt | first poll, <0.1s (the old "~1 block" lag is now ~200ms at most) | +| Hosted payer accepts a sponsored tx right after the self-paid deploy | first attempt, no `actor is not bound` retry needed | +| `sendTransactionSync` (`eth_sendRawTransactionSync`) | **not allowlisted** on `rpc.vibes.base.org` — use `sendTransaction` + `waitForTransactionReceipt` | + +## Gotchas (all live-confirmed on vibenet) + +- **`timestampMs` / `blockTimestampMs` arrive as hex strings through viem**, not + `bigint` like `timestamp`. `hexToBigInt` them before doing arithmetic or + comparing to `timestamp * 1000n`. They are also absent from viem's types — + cast, as above. +- **The receipt top level has no ms field.** It's on `receipt.logs[i]`. A tx + that emits no logs → fetch its block. +- **The 8130 fork's `getTransaction` drops `blockTimestampMs`** (it rebuilds the + object from the nested `tx` body), and **`0x79` tx objects returned inside a + block or via `eth_getTransactionByBlockNumberAndIndex` have no `hash` key**. + Keep the hash `sendTransaction` returned and read the block's `timestampMs`. +- **`eth_getHeaderByNumber/Hash` and `eth_getBlockReceipts` are not allowlisted** + on the public RPC even though the spec adds `timestampMs` to them. Use + `eth_getBlockByNumber` and per-tx receipts. `eth_sendRawTransactionSync` + isn't either, so the fork's `sendTransactionSync` fails with + `rpc method is not whitelisted` — it is not a 200ms feature you can use here. +- **WebSocket is `wss://rpc.vibes.base.org/ws`.** Without `/ws` (or on + `ws.vibes.base.org`) the upgrade fails with a non-101 status. +- **Default polling hides the speed-up** — 4000ms without `blockTime`, 500ms + floor with it. Always pass `pollingInterval` on waits and watches you time. +- **`"pending"` answers but isn't preconfirmation**; `safe`/`finalized` are + minutes behind the head. +- **Devnet state resets** (block numbers and tx hashes above are from one + session and will not resolve later). Don't hardcode them in tests. +- Everything on this page is vibenet-only today; Base Sepolia and Mainnet still + produce 2s blocks without `timestampMs`. + +## Reference + +- Spec + RPC behaviour: https://docs.base.org/upgrades/cobalt/200ms-blocks +- Flashblocks migration table: https://docs.base.org/upgrades/cobalt/migrate-from-flashblocks +- Vibenet overview: https://docs.base.org/build-on-base/test-on-vibenet diff --git a/skills/vibenet/references/eip8130-accounts.md b/skills/vibenet/references/eip8130-accounts.md index 1f1aecb..54c1727 100644 --- a/skills/vibenet/references/eip8130-accounts.md +++ b/skills/vibenet/references/eip8130-accounts.md @@ -19,8 +19,9 @@ the [skill root](../SKILL.md). `initialActors`, `authorizeActor`, `revokeActor` — **not** as the `signer` passed to `newSmartAccount`. - **Scope** (`actorScope`) — `scopeUnrestricted` (0x00) is admin. Bits: - `sender` `policy` `nonce` `selfPayer` `sponsorPayer`. A policy-bearing actor - must be restricted (non-zero scope), or `authorizeActor` throws. + `operator` (1) `selfPayer` (2) `sponsorPayer` (4) `policy` (8) `nonce` (16). + (`sender` was renamed `operator` on the fork.) A policy-bearing actor must + be restricted (non-zero scope), or `authorizeActor` throws. - **Nonce mode** — admin (`0x00`) or an actor with the `nonce` bit (`SCOPE_NONCE`) may use **ordered** (sequenced, expiry-free) *or* nonce-free (expiring) nonces; sends default to ordered. Only a restricted actor @@ -58,10 +59,19 @@ That leaves exactly two routes from counterfactual to deployed: | Route | Needs funding first? | How | |---|---|---| | **Sponsored** (shortest path) | No | `sendSponsoredCalls` with `accountChanges: [account.createChange]`. The payer pays gas, so this works at a zero balance — no faucet, no cooldown. See [payer-sponsorship.md](payer-sponsorship.md). | -| **Self-paid** | Yes | Faucet-fund `account.address`, then `sendCalls` with `accountChanges: [account.createChange]`. The account pays its own deploy+batch gas. | +| **Self-paid** | Yes | Faucet-fund `account.address`, then `sendTransaction` with `accountChanges: [account.createChange]`. The account pays its own deploy+batch gas. | Reach for the sponsored route when onboarding a user or writing a first -example — it removes the faucet from the critical path entirely. +example — it removes the faucet from the critical path entirely. Sponsorship +covers **gas only**: a first transaction that *transfers value* still needs the +account to hold that value, so "no funding needed" applies to zero-value +onboarding transactions. + +**Persist the salt, not just the key.** `newSmartAccount` picks a random +`salt` when you omit it, so re-creating the account from a stored private key +alone yields a *different address every time*. Any frontend or script that +restores an account must store `salt` next to the key and pass both: +`newSmartAccount({ signer, salt, proxy: "erc1167" })`. ### Checking whether an account is deployed @@ -74,11 +84,13 @@ const code = await client.getCode({ address: account.address }); const deployed = Boolean(code && code !== "0x"); ``` -`getCode` lags ~1 block (~2s) behind the receipt, so immediately after a -successful create it can still return `0x` on a transaction whose every phase -succeeded. **Poll it** — don't conclude the deploy failed from a single read. -The same lag hits the payer: sponsoring right after a self-paid deploy fails -with `actor is not bound` until the config propagates. +`getCode` can lag ~1 block behind the receipt (on 2s blocks that was ~2s; on +today's 200ms blocks it was already non-empty on the first read ~100ms after +the receipt), so immediately after a successful create it *can* still return +`0x` on a transaction whose every phase succeeded. **Poll it** — don't conclude +the deploy failed from a single read. The same lag can hit the payer: +sponsoring right after a self-paid deploy may fail with `actor is not bound` +until the config propagates. Once deployed, **omit `accountChanges` on every subsequent transaction.** @@ -89,7 +101,7 @@ Once deployed, **omit `accountChanges` on every subsequent transaction.** import { createPublicClient, http, parseEther, toHex } from "viem"; import { privateKeyToAccount, generatePrivateKey } from "viem/accounts"; import { - newSmartAccount, sendCalls, estimateGas, encodeWalletCalls, + newSmartAccount, sendTransaction, estimateGas, canonicalAuthenticators, waitForTransactionReceipt, allPhasesSucceeded, } from "viem/eip8130"; @@ -99,7 +111,8 @@ const chain = { id: chainId, name: "vibenet", nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, - rpcUrls: { default: { http: [RPC_URL] } }, + rpcUrls: { default: { http: [RPC_URL], webSocket: ["wss://rpc.vibes.base.org/ws"] } }, + blockTime: 200, // vibenet runs 200ms blocks — see references/200ms-blocks.md }; const client = createPublicClient({ chain, transport: http(RPC_URL) }); // This RPC is CORS-enabled, so it works from the browser too. The hosted @@ -117,76 +130,118 @@ const signer = privateKeyToAccount(generatePrivateKey()); // key.k1(signer.address) is what newSmartAccount uses internally for // the primary actor — you only call key.* when authorizing extra actors. // `proxy: "erc1167"` is required in practice: the default ("upgradeable") -// throws `No canonical UpgradeableAccount is enshrined yet` unless you pass -// an explicit implementation. "erc1167" gives an immutable +// throws `No canonical \`CoinbaseSmartWalletV2\` is deployed against the +// Keystore yet, so \`proxy: "upgradeable"\` requires an explicit +// \`implementation\`` unless you pass one. "erc1167" gives an immutable // DefaultAccount-backed account. const account = newSmartAccount({ signer, proxy: "erc1167" }); // synchronous — no await // (The fork's TS types may require casting a K1 LocalAccount when passing // it as `signer`.) // 3) Fund account.address (faucet), then estimate + send. The drip responds -// with { tx_hash, amount_wei, to } and grants 0.1 ETH, usually landing in -// ~2s — but treat the shape as unstable: confirm funding by polling -// eth_getBalance until non-zero (allow ~60s). Cooldown is ~10s per address -// and per IP; GET /api/vibenet/faucet/status returns the live values. +// with { tx_hash, amount_wei, to } and grants 0.1 ETH, usually visible in +// the balance within ~1s — but treat the shape as unstable: confirm funding +// by polling eth_getBalance until non-zero (allow ~60s). Cooldown is 10s +// per address and per IP; GET /api/vibenet/faucet/status returns the live +// values. // The endpoint is CORS-enabled, so a browser can call it directly. -await fetch("https://api.vibes.base.org/api/vibenet/faucet/drip", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ address: account.address }), -}); +// The cooldown is per address AND per IP, so dripping two addresses +// back-to-back (recipient + account) gets HTTP 429 — retry after ~11s. +async function drip(address: `0x${string}`) { + for (;;) { + const res = await fetch("https://api.vibes.base.org/api/vibenet/faucet/drip", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ address }), + }); + if (res.status !== 429) return res; + await new Promise((r) => setTimeout(r, 11_000)); + } +} +await drip(account.address); const calls = [{ to: "0x…recipient", value: parseEther("0.001") }]; -const wire = encodeWalletCalls({ account: account.address, calls: [calls] }); const gas = await estimateGas(client, { sender: account.address, accountChanges: [account.createChange], - calls: wire, + calls: [calls], // phased: each inner array is one atomic batch + senderAuthAuthenticator: canonicalAuthenticators.k1, // .p256 / .passkey for other signers }); -const hash = await sendCalls(client, { +const hash = await sendTransaction(client, { account, accountChanges: [account.createChange], // omit on subsequent txs calls, - dataSuffix: toHex("invoice #4242"), // maps to signed metadata + dataSuffix: toHex("invoice #4242"), // written to the signed top-level `metadata` gas: (gas * 120n) / 100n, }); -// 4) Wait and check every phase succeeded. -const receipt = await waitForTransactionReceipt(client, { hash }); -if (!allPhasesSucceeded(receipt)) throw new Error("a phase reverted"); +// 4) Wait and check every phase succeeded. Poll fast — 200ms blocks; the +// default is 500ms. +const receipt = await waitForTransactionReceipt(client, { hash, pollingInterval: 100 }); +if (!allPhasesSucceeded(receipt.eip8130)) throw new Error("a phase reverted"); +// receipt.eip8130.metadata echoes the dataSuffix; receipt.eip8130.payer is who paid. ``` -An 8130 receipt executes in **phases** (one per call batch). `phaseStatuses` -on the receipt reports per-phase success for CALL phases only — +An 8130 receipt executes in **phases** (one per call batch). The fork's +`getTransactionReceipt` / `waitForTransactionReceipt` return the raw receipt +plus a parsed `receipt.eip8130 = { payer, phaseStatuses, metadata }`. +`phaseStatuses` reports per-phase success for CALL phases only — account-change application is not covered (see [session-keys-and-policies.md](session-keys-and-policies.md) for why config changes need a read-back instead). UIs that display per-phase results should -render `phaseStatuses` and treat `allPhasesSucceeded(receipt)` as the overall -verdict; don't rely on `receipt.status` alone. For the exact field shape, -check `src/eip8130` on the fork branch — it is experimental and -may shift. +render `phaseStatuses` and treat `allPhasesSucceeded(receipt.eip8130)` as the +overall verdict; don't rely on `receipt.status` alone. For the exact field +shape, check `src/eip8130/actions/getTransactionReceipt.ts` on the fork branch +— it is experimental and may shift. + +**`sendTransaction` used to be called `sendCalls`** (renamed on the fork on +2026-08-26, together with `prepareTransaction` → `prepareTransactionRequest`); +`encodeWalletCalls` is no longer needed for `sendTransaction` / `estimateGas` +(both take the same `calls` shape and route value through the wallet +themselves) — it is still required for value-bearing `sendSponsoredCalls`, see +[payer-sponsorship.md](payer-sponsorship.md). `sendTransactionSync` (EIP-7966 +`eth_sendRawTransactionSync`) also exists but the public vibenet RPC does not +allowlist that method. ## Canonical deployment -Canonical contract addresses per chain come from `getEip8130Deployment(chainId)` -(or `canonicalEip8130Deployment`): `accountConfiguration`, `accounts.*`, -`authenticators.*`, `policies.{manager,sessionPolicy}`. - -The current canonical deployment uses AccountConfiguration -`0x81305d4f4976220D2af17E5Dc246848E235600AC`, DefaultAccount -`0x813078f98b3eb214046C8Dc93A771ac9de5AaDEf`, PolicyManager -`0x813077055d1110F92191ccE13018f51820B40ac1`, and SessionPolicy -`0x813070914C530d030f4Efd8Fa99C18e836435e55`. +The **Keystore** (formerly "AccountConfiguration") is enshrined at the same +address on every 8130 chain and is exported as the `keystoreAddress` constant — +it is no longer a per-chain parameter, and the read actions (`getConfigSequence`, +`isActor`, `getActorConfig`, …) no longer take an `accountConfiguration` +argument. The remaining per-chain addresses come from +`getEip8130Deployment(chainId)` (or `canonicalEip8130Deployment`): +`accounts.{default,defaultHighRate}`, `authenticators.*`, +`policies.{manager,sessionPolicy}`. + +Live on vibenet as of 2026-09-09 (regenerated on 2026-08-26 — the pre-August +`0x81305d4f…` / `0x813078f9…` / `0x81307705…` / `0x81307091…` set has **no +code** any more; an old build fails with +`The contract function "getActorConfig" returned no data ("0x")`): + +| Contract | Address | +|---|---| +| Keystore (`keystoreAddress`) | `0x813012Bd8D971928475235BBac6F0488c4A100AC` | +| DefaultAccount (`accounts.default`) | `0x81309c54D6Bc190FbBc0FA9f296ea4C6A539ADEf` | +| PolicyManager (`policies.manager`) | `0x8130E47Bc12CfDD6d2d2178B35Def9A51cae0aC1` | +| SessionPolicy (`policies.sessionPolicy`) | `0x8130A0D85473CeF9e888B4228F729b48F0c45E55` | + +Don't hardcode these — read them from the module you built, and re-run the +installer when they move. ## Account creation modes -- `newSmartAccount({ signer, proxy: "erc1167" })` — new CREATE2 smart account - (most common). `signer` must be a signing account (`privateKeyToAccount(pk)`, - `toP256Signer`, or `toWebAuthnSigner`) — **not** `key.k1(...)`. The `proxy` - option defaults to `"upgradeable"`, which throws until a canonical - UpgradeableAccount is enshrined unless you pass an explicit implementation — - pass `proxy: "erc1167"` for an immutable DefaultAccount-backed account. +- `newSmartAccount({ signer, proxy: "erc1167", salt? })` — new CREATE2 smart + account (most common). `signer` must be a signing account + (`privateKeyToAccount(pk)`, `toP256Signer`, or `toWebAuthnSigner`) — **not** + `key.k1(...)`. The `proxy` option defaults to `"upgradeable"`, which throws + until a canonical `CoinbaseSmartWalletV2` is deployed unless you pass an + explicit `implementation` — pass `proxy: "erc1167"` for an immutable + DefaultAccount-backed account. The salt is random per call; pass a fixed + `salt` to get the same address across sessions (the account object does not + expose the salt it used). `admins` / `extraActors` register extra actors at + creation. - `toAccount({ signer, userSalt, code, initialActors, authenticator, accountConfigAddress })` — full control over salt / initial actors. - `toAccount({ signer, address, authenticator })` — a configured (non-default) @@ -196,11 +251,14 @@ The current canonical deployment uses AccountConfiguration ## Reading account state (viem actions) -All take `(client, params)` and read the on-chain `AccountConfiguration`: -`getActorConfig`, `isActor`, `getPolicy`, `getSessionSpend`, -`getLockStatus` / `isLocked`, `getConfigSequence`, -`getTransactionCount`, `getTransaction`, `getTransactionReceipt`, -`waitForTransactionReceipt`. +All take `(client, params)` — `params` is `{ account }` (plus `actorId` where +relevant); the Keystore address is built in — and read the on-chain Keystore: +`getActorConfig`, `isActor`, `getPolicy`, `getLockStatus` / `isLocked`, +`getConfigSequence`, `getTransactionCount`, `getTransaction`, +`getTransactionReceipt`, `waitForTransactionReceipt`. The exception is +`getSessionSpend`, which is keyed by policy **commitment** + the exact +committed `tokenLimit`, not by account — see +[session-keys-and-policies.md](session-keys-and-policies.md). ## Locking @@ -211,10 +269,11 @@ change), no separate hash step. `lockChange` requires `unlockDelay >= 1` ## Gotchas -- **Pass `proxy: "erc1167"` to `newSmartAccount` / `toAccount`.** The `proxy` - option defaults to `"upgradeable"`, which throws - `BaseError: No canonical UpgradeableAccount is enshrined yet (pending final - implementation)` unless you supply an explicit implementation. `"erc1167"` +- **Pass `proxy: "erc1167"` to `newSmartAccount`.** The `proxy` option + defaults to `"upgradeable"`, which throws ``No canonical `CoinbaseSmartWalletV2` + is deployed against the Keystore yet, so `proxy: "upgradeable"` requires an + explicit `implementation` `` (live-confirmed on the current branch; older + builds said `No canonical UpgradeableAccount is enshrined yet`). `"erc1167"` creates an immutable DefaultAccount-backed account and is the mode every example in this skill uses. - **`key.k1` ≠ signer.** `key.k1(address)` builds an actor id for authorize / @@ -225,11 +284,11 @@ change), no separate hash step. `lockChange` requires `unlockDelay >= 1` gas from the account (unless a payer sponsors it). - Only the **first** tx includes `account.createChange`; later txs omit it. - After a successful create (with `proxy: "erc1167"`), `eth_getCode` on the - account returns an EIP-1167 minimal proxy delegating to the canonical - DefaultAccount — but the read - lags ~1 block behind the receipt, so an immediate getCode can return `0x` - on a tx that succeeded. Poll before concluding the deploy failed (same lag - as config-change read-backs). + account returns a 45-byte EIP-1167 minimal proxy delegating to the canonical + DefaultAccount — but the read can lag ~1 block behind the receipt (a + 200ms block today), so an immediate getCode *can* return `0x` on a tx that + succeeded. Poll before concluding the deploy failed (same lag as + config-change read-backs). - **A value-bearing call to a brand-new address reverts.** Sending `value` to an address that has never held a balance comes back `status: 0x0` with `phaseStatuses: ["0x0"]` (reproduced 4/4 on vibenet). The same call to an @@ -238,12 +297,20 @@ change), no separate hash step. `lockChange` requires `unlockDelay >= 1` untouched address, not to `value` in general. Cause unconfirmed; if you are onboarding a fresh recipient, fund it from the faucet first or expect the revert. -- Attribution goes in `dataSuffix` on `sendCalls` (maps to signed `metadata`). -- **Always pass explicit `gas`** to `sendCalls` (estimate with - `estimateGas`, add ~20% headroom, as in the example above). Omitting it - gets the tx rejected with the misleading error - `transaction type not supported` — live-confirmed, and easy to misread as - an RPC capability problem. +- Attribution goes in `dataSuffix` on the standalone `sendTransaction` (written + to the signed top-level `metadata`; the core `client.sendTransaction` path + takes `metadata` directly). +- **Always pass explicit `gas`** to the standalone `sendTransaction` (estimate + with `estimateGas`, add ~20% headroom, as in the example above) — the + parameter is required by its type, and historically omitting it got the tx + rejected with the misleading error `transaction type not supported`, easy to + misread as an RPC capability problem. (Only the core `client.sendTransaction` + path, enabled by spreading `eip8130ChainConfig` into the chain definition, + auto-estimates.) +- **Poll receipts at ~100ms.** vibenet produces a block every 200ms; the + fork's `waitForTransactionReceipt` defaults to 500ms and the generic viem + client to 4000ms. See [200ms-blocks.md](200ms-blocks.md) for reading the + block's millisecond timestamp. - `rpc.vibes.base.org` **is** fine for `0x79` broadcasts, from Node and from the browser (it serves `access-control-allow-origin: *`). The `api.vibes.base.org/api/vibenet/account/rpc` proxy is an alternative route to @@ -258,10 +325,13 @@ change), no separate hash step. `lockChange` requires `unlockDelay >= 1` from the last block while `eth_sendRawTransaction` fails, which makes it look like a transaction-shaped problem. Confirm with `GET https://api.vibes.base.org/api/vibenet/chain-health`, which reports - `{ healthy, reason, detail, head, headAgeSecs, stuckSecs }` — a halted chain - returns `healthy: false, reason: "halted"` with a rising `headAgeSecs`. Wait - for it to recover; there is nothing to fix client-side. Worth surfacing in any - UI or script that sends transactions. + `{ healthy, reason, detail, head, headAgeSecs, stuckSecs, faucetBacklog }` — + a halted chain returns `healthy: false, reason: "halted"` with a rising + `headAgeSecs`. Key UIs off **`healthy` + `headAgeSecs`** only: `stuckSecs` + has been observed at 1382 and 19000 on a chain that was `healthy: true` with + `headAgeSecs: 1`, so it is not actionable on its own. Wait for recovery; + there is nothing to fix client-side. Worth surfacing in any UI or script + that sends transactions. - **Nonce-free (expiring) sends historically had a timing bug** — the node could intermittently reject a valid `0x79` with a misleading `transaction type not supported` when the short `expiry` lapsed before diff --git a/skills/vibenet/references/payer-sponsorship.md b/skills/vibenet/references/payer-sponsorship.md index c0279a8..0efbcb3 100644 --- a/skills/vibenet/references/payer-sponsorship.md +++ b/skills/vibenet/references/payer-sponsorship.md @@ -39,6 +39,29 @@ const receipt = await waitForTransactionReceipt(client, { hash }); No faucet call and no user ETH is needed anywhere in this flow — the payer's `payer_auth` makes the protocol debit gas from the payer, not the sender. +**Gas only**: if the sponsored calls *transfer value*, the account still has +to hold that value — a sponsored 0.001 ETH tip from a zero-balance account +reverts, a sponsored zero-value call does not. + +**Sponsored value transfers must be wallet-wrapped.** `sendTransaction` +auto-routes value-bearing calls through the account, but `sendSponsoredCalls` +does not: `calls: [{ to, value, data }]` throws `EIP-8130 calls cannot carry +\`value\` on the wire` (live-confirmed on the current branch). Wrap the phase +with `encodeWalletCalls` first — and remember the account must actually hold +the value: + +```ts +import { encodeWalletCalls } from "viem/eip8130"; + +const { transactionHash } = (await sendSponsoredCalls(client, { + account, + payerClient, + calls: encodeWalletCalls({ + account: account.address, + calls: [[{ to: recipient, value: parseEther("0.001"), data: "0x" }]], + })[0], // one phase → one wallet-routed executeBatch call +})) as unknown as { transactionHash: Hex }; +``` For self-submit (e.g. custom RPC / retry control), pass `mode: "sign"` — the call then resolves with the signed raw transaction, which you broadcast yourself @@ -70,15 +93,25 @@ the fork branch. - Subsequent sponsored txs omit `account.createChange` — only the first tx carries it. -- **Don't sponsor immediately after a self-paid deploy.** Account config - propagates ~1 block behind the receipt (the same lag as `eth_getCode` and - config read-backs), and the payer validates against the lagging state — so a - sponsored tx sent right after a successful deploy is rejected with +- **Retry `actor is not bound` right after a self-paid deploy.** Account + config can propagate ~1 block behind the receipt (the same lag as + `eth_getCode` and config read-backs), and the payer validates against the + lagging state — a sponsored tx sent in that window is rejected with `EIP-8130 validation failed: actor is not bound`, surfaced as viem's `InvalidInputRpcError: Missing or invalid parameters`. Neither message points - at timing. Retry on `actor is not bound` (a few seconds is enough) or wait for - the account's code read-back before sponsoring. Live-confirmed: the same call - fails immediately after deploy and succeeds ~6s later. + at timing. On 2s blocks this was live-reproduced (fails immediately, succeeds + ~6s later); on today's 200ms vibenet a sponsored send ~0.8s after the deploy + receipt was accepted first try. Either way: retry on `actor is not bound` + (sub-second gaps suffice now) or wait for the account's code read-back. +- **The payer itself can run out of ETH**, and it looks nothing like a budget + rejection: `InvalidInputRpcError` wrapping `Broadcast failed … insufficient + funds for gas * price + value: have want ` from an internal + `http://base-client:8545` URL (live-hit 2026-08-21). The `have` figure is the + **payer wallet's** balance. Diagnose with `payer_getTerms` → the `payer` + address → `eth_getBalance`; on the devnet the fix is to faucet-drip the payer + address itself. Also: the terms may advertise only a `token` (USDV) offer + while sponsored `payer_sendTransaction` still works — don't gate on the offer + list. - `context.flow` lets the hosted payer budget free grants per `(sender, flow)` pair; pick a stable string per product surface (e.g. `"onboarding"`, `"transact"`). diff --git a/skills/vibenet/references/session-keys-and-policies.md b/skills/vibenet/references/session-keys-and-policies.md index 96af80e..d95f19f 100644 --- a/skills/vibenet/references/session-keys-and-policies.md +++ b/skills/vibenet/references/session-keys-and-policies.md @@ -9,41 +9,152 @@ config changes correctly. For account creation and core concepts, read ```ts import { key, authorizeActor, actorScope, - defineSessionPolicy, encodeSessionPolicyConfig, getEip8130Deployment, + defineSessionPolicy, encodeSessionPolicyConfig, encodeSessionPolicyAction, } from "viem/eip8130"; -const dep = getEip8130Deployment(chainId); const policyConfig = encodeSessionPolicyConfig({ tokenLimits: [{ token: usdv, limit: 100_000_000n, period: 604_800n }], // 100 USDV / week callScopes: [{ target: usdv, selectorRules: [{ selector: "0xa9059cbb" }] }], // transfer only }); +// `manager` / `policy` default to the canonical PolicyManager / SessionPolicy +// (same addresses on every chain — verified equal to +// getEip8130Deployment(84538453).policies on vibenet). Override only for your +// own contracts. const session = defineSessionPolicy({ - account: account.address, policy: dep.policies.sessionPolicy, - policyConfig, manager: dep.policies.manager, validUntil: 1_900_000_000n, + account: account.address, + policyConfig, + validUntil: 1_900_000_000n, }); // There is no install step. Every execute carries the full PolicyBinding and // the manager recomputes its authorized commitment. -const call = session.executeCall({ target, value: 0n, data }); +const call = session.executeCall(encodeSessionPolicyAction({ target, value: 0n, data })); // Actor identity for authorize (not a LocalAccount). SCOPE_POLICY is set // automatically when `policy` is present. -const sessionActor = key.p256({ x: "0x…", y: "0x…" }); +const sessionActor = key.p256({ x: "0x…", y: "0x…" }); // or key.p256(p256Signer.publicKey) const change = authorizeActor(sessionActor, { - scope: actorScope.sender, + // POLICY gates every call to the manager; SELF_PAYER lets the key pay gas + // from the account (needed for self-paid session sends — see "Use the + // session key"). Adding `operator` would bypass the gate. + scope: actorScope.policy | actorScope.selfPayer, expiry: 1_900_000_000n, policy: session.actorPolicy, }); // `change` is an unsigned change object. Apply it via account.change([change], // { chainId, sequence }) with a LIVE-read sequence — see Sequence correctness -// below — then include the result in a sendCalls signed by an admin actor. -// Don't hand-build accountChanges with a hardcoded sequence; that is the #1 -// cause of a silently skipped authorize. +// below — then include the result in a sendTransaction signed by an admin +// actor. Don't hand-build accountChanges with a hardcoded sequence; that is +// the #1 cause of a silently skipped authorize. ``` A policy actor must have a non-zero scope; admin (scope 0) + policy is rejected by `authorizeActor`. +**Scope names changed on the fork (Aug 2026).** `actorScope` is now +`{ operator: 1, selfPayer: 2, sponsorPayer: 4, policy: 8, nonce: 16 }` — +there is no `actorScope.sender` any more (it is `operator`), so old examples +that pass `scope: actorScope.sender` send `undefined` and fail. OR the bits +you need: + +| Bit | Grants | Add it when | +|---|---|---| +| `policy` (8) | initiation gated to the PolicyManager | always, for a session key — it is set for you when `policy` is passed | +| `selfPayer` (2) | the key may pay gas from the account | the session key sends self-paid txs (live-confirmed required) | +| `sponsorPayer` (4) | the key's txs may be payer-sponsored | the session key sends through an ERC-8168 payer | +| `nonce` (16) | ordered (expiry-free) nonces | you don't want to be confined to nonce-free (expiring) sends | +| `operator` (1) | ungated initiation | never on a policy key — it bypasses the gate | + +**Native ETH is fail-closed.** A session key may attach `value` only if the +config has a `tokenLimits` entry for the zero address; without one every +value-bearing call reverts. ERC-20s differ: an absent token limit is unbounded, +because the `callScopes` allowlist is the gate. + +**Register the PolicyManager as an operator too** (`authorizeActor(key.k1(session.manager), +{ scope: actorScope.operator })`) so its forwarded `executeBatch` can land on the +account — the upstream `fulfillGrantPermissions` helper folds this in +automatically; when hand-rolling, ride it in the same change batch as the +session-key authorize (live-confirmed: both bind in one tx). + +## Use the session key (live-confirmed on vibenet, 2026-09-09) + +Two things the upstream docs don't spell out, both learned from +`EIP-8130 validation failed: actor scope insufficient`: + +- **A self-paying session key needs `selfPayer` as well.** Scope + `actorScope.policy` alone (`0x8`) authorizes fine but the node rejects the + key's own sends; `actorScope.policy | actorScope.selfPayer` (`0xa`) works. + (Sponsored session sends would use `sponsorPayer` instead.) +- **A restricted actor without the `nonce` bit must send nonce-free** + (`nonceKey: nonceKeyMax`); the default ordered nonce is rejected. + +```ts +import { toAccount, toP256Signer, sendTransaction, estimateGas, waitForTransactionReceipt, + canonicalAuthenticators, nonceKeyMax, encodeSessionPolicyAction } from "viem/eip8130"; + +// A second handle on the SAME address, driven by the session signer. Don't +// pass `scope` here — the handle reads it on-chain, and a declared value that +// disagrees fails with `Declared signing scope 0x8 does not match on-chain +// actor scope 0xa`. +const sessionAccount = toAccount({ + signer: toP256Signer({ privateKey: sessionPrivateKey }), + address: account.address, + authenticator: canonicalAuthenticators.p256, +}); + +const action = session.executeCall( + encodeSessionPolicyAction({ target: recipient, value: parseEther("0.001"), data: "0x" }), +); // → a call to the PolicyManager; the manager enforces the committed policy + +const gas = await estimateGas(client, { + sender: account.address, + calls: [[action]], + senderAuthAuthenticator: canonicalAuthenticators.p256, + senderActorId: sessionKey.actorId, +}); +const { timestamp } = await client.getBlock(); +const hash = await sendTransaction(client, { + account: sessionAccount, + calls: [action], + gas: (gas * 120n) / 100n, + nonceKey: nonceKeyMax, // nonce-free: required without the `nonce` scope bit + now: timestamp * 1000n, // anchor the 20s expiry to chain time, not the laptop clock +}); +const receipt = await waitForTransactionReceipt(client, { hash, pollingInterval: 100 }); +// status 0x1, phaseStatuses ['0x1'], and the recipient's balance moved by exactly 0.001 ETH. +``` + +### The binding must be byte-identical at use time + +The commitment stored on the actor is +`keccak256(abi.encode(account, policy, keccak256(policyConfig), validAfter, +validUntil, salt))`, and `session.executeCall` passes the full binding on-chain +for the manager to recompute and compare. So the `defineSessionPolicy` inputs +at **use** time must equal the ones at **authorize** time to the byte — a +wall-clock `validUntil` computed at authorize time and recomputed later +silently produces a different commitment and the manager rejects the call. +Either persist the whole `session.binding`, or pin `validAfter` / `validUntil` +/ `salt` to their `0n` defaults (deterministic binding) and put the time bound +on the actor's `expiry` instead. + +### Failure signature: the session tx lands but its phase reverts + +If a session-key transaction broadcasts fine but comes back `status: 0x0`, +`phaseStatuses: ["0x0"]` with no error text, check (in this order) that the +PolicyManager is bound as an operator actor +(`isActor(client, { account, actorId: key.k1(session.manager).actorId })`), +that the binding is byte-identical (above), and that a value-bearing call has +a zero-address `tokenLimits` entry. `EIP-8130 validation failed: actor scope +insufficient` at broadcast means the key lacks `selfPayer` (self-paid) or +`sponsorPayer` (sponsored). + +### Reading the spend meter + +`getSessionSpend` is keyed by the **commitment and the exact committed limit**, +not by account: `getSessionSpend(client, { commitment: session.commitment, +tokenLimit: { token, limit, period }, sessionPolicy? })` — re-supply the very +`{ token, limit, period }` you encoded (zero address = native ETH). + ## Verifying a config change (and why it can look "silent") Account changes (authorize/revoke) do **not** surface success the way calls do. @@ -71,7 +182,8 @@ Check them by **reading back on-chain state**, not by the receipt: - **The only reliable check is a read-back:** `isActor`, `getActorConfig`, or a bumped `getConfigSequence` — after a failed tx as much as a successful one. -- **Reads lag ~1 block (~2s)** behind the receipt — poll the read-back. +- **Reads can lag ~1 block** behind the receipt (200ms on vibenet today, 2s + on Base Sepolia) — poll the read-back. ## Sequence correctness @@ -90,21 +202,27 @@ channel (`chainId = chain.id`); owner changes use the **multichain** channel ```ts import { getConfigSequence, isActor } from "viem/eip8130"; -const { local } = await getConfigSequence(client, { - accountConfiguration: dep.accountConfiguration, - account: account.address, -}); // read live — do not assume 0 or 1 +// The Keystore address is built in (`keystoreAddress`) — no per-chain +// `accountConfiguration` argument any more. +const { local } = await getConfigSequence(client, { account: account.address }); +// `local` is the NEXT local-channel sequence word (epoch<<32 | seq), read +// live — do not assume 0 or 1. Live-observed on vibenet: 0 before the create +// tx, 1 right after it, 2 after one authorize. const change = await account.change([authorizeActor(/* … */)], { chainId, // local channel for session keys - sequence: Number(local), + sequence: local, }); // … send the tx, then verify by read-back (polled for ~1 block of lag): -const bound = await isActor(client, { - account: account.address, actorId, accountConfiguration: dep.accountConfiguration, -}); +const bound = await isActor(client, { account: account.address, actorId }); if (!bound) throw new Error("authorize was skipped — check sequence/channel"); +// getActorConfig(client, { account, actorId }) → { authenticator, scope, expiry, hasPolicy } ``` +To authorize a session key **in the same transaction that creates the +account**, use `sequence: 1n` (the create bumps local 0→1 before the change +applies) and pass `accountChanges: [account.createChange, change]` — +live-confirmed on vibenet. + ## Reference - Session-key end-to-end walkthrough (create → register PolicyManager + diff --git a/skills/vibenet/scripts/setup-viem-8130.sh b/skills/vibenet/scripts/setup-viem-8130.sh index e97a220..4c9a374 100755 --- a/skills/vibenet/scripts/setup-viem-8130.sh +++ b/skills/vibenet/scripts/setup-viem-8130.sh @@ -18,7 +18,9 @@ # scripts/setup-viem-8130.sh [APP_DIR] [BUILD_DIR] # # APP_DIR project to install viem into (default: current directory) -# BUILD_DIR where to clone+build the fork (default: /.viem-8130-src) +# BUILD_DIR where to clone+build the fork (default: /.viem-8130-src; +# ~500 MB with node_modules — the script gitignores it in APP_DIR; +# share one BUILD_DIR outside the app when you have several apps) # # Env overrides: # VIEM_FORK_REPO (default: https://github.com/chunter-cb/viem) @@ -61,6 +63,18 @@ echo "==> installing build deps (pnpm, via npx — no global install)" echo "==> building viem" ( cd "$BUILD_DIR" && npx --yes pnpm run build ) +# 2b) Git hygiene: the build dir is a ~500 MB monorepo checkout. If it lives +# inside the app, make sure the app's .gitignore excludes it (idempotent). +case "$BUILD_DIR" in + "$APP_DIR"/*) + REL="${BUILD_DIR#"$APP_DIR"/}/" + if ! grep -qxF "$REL" "$APP_DIR/.gitignore" 2>/dev/null; then + echo "==> adding $REL to $APP_DIR/.gitignore" + printf '\n# viem 8130 fork build (scripts/setup-viem-8130.sh)\n%s\n' "$REL" >> "$APP_DIR/.gitignore" + fi + ;; +esac + # 3) Link into the app. --install-links is REQUIRED: without it npm symlinks # node_modules/viem to a path outside the project root, and bundlers # (Turbopack/Next.js) then fail with "Can't resolve 'viem'" even though tsc