The Base counterpart to runtime-qa.md. Same discipline: top to bottom, each
step depends on the last, record everything in the Results table. But this
pass spends real money, so every step names its budget and where the money
goes. Total budget: ~$25 of ETH on Base, most of it recovered at the end.
Run runtime-qa.md on Sepolia first. It is free, and anything it catches
is a bug you did not pay mainnet gas to find. This pass exists for what Sepolia
cannot prove: OP-stack fee math against the real GasPriceOracle, the canonical
mainnet contracts, and real-value UX honesty.
Stealth features on Base (meta-address, scanning, receive ladder, stealth
sends, sweeps) require an @opaquecash/deployments that knows chain 8453. The
currently pinned releases do (the repo repinned to the Base-aware SDK), but a
future repin or a stale node_modules can regress it silently, so check
before starting:
node --input-type=module -e "import * as d from '@opaquecash/deployments'; console.log(d.getEvmChainIds())"[ 11155111 ]→ NOT repinned. Sections 1–5 still apply in full; in sections 6–8 the expected-failure shape is the test: unlocking on Base must surface the deployments error as readable meta-address / scan error text (not a blank popup or a spinner), while plain per-site wallet sends and dApp signing keep working.- Includes
8453→ full pass. Also re-verify the announcer below.
| Contract | Address | Basescan |
|---|---|---|
| ERC-5564 announcer (canonical singleton) | 0x55649E01B5Df198D18D95b5cc5051630cfD45564 |
link |
| ERC-6538 registry (canonical singleton) | 0x6538E6bf4B0eBd30A8Ea093027Ac2422ce5d6538 |
link |
| GasPriceOracle (OP-stack predeploy) | 0x420000000000000000000000000000000000000F |
link |
announceGas for Base (60,000 in src/lib/config.ts) was measured against the
canonical announcer. If the repinned SDK targets a different announcer or
registry on Base, update this table and re-measure: the 2× headroom claim is
only as good as the contract it was measured on.
- Chrome with MetaMask funded with ~$30 of ETH on Base (bridge via bridge.base.org or withdraw from an exchange directly to Base), plus ~$2 of USDC on Base for the token-detection steps (swap dust for it on any Base DEX).
- An Alchemy/Infura Base key ready in case the default endpoint
(
mainnet.base.org) rate-limits scans (Settings → Advanced → RPC; note this now exercises the runtime permission prompt, step 9). Do not fall back tobase-rpc.publicnode.com: it 403seth_getTransactionReceipt, which is the reason it is no longer the default. npm run release: QA the unzipped release artifact, notdist/, so the pass covers exactly what users get.- The bundled test bench (
npm run demo:dev, or the hosted copy) has a Base mainnet mode: the network picker at the top of the page. Use it as the dApp and the stealth counterparty for the steps below. Its Base defaults are cent-scale on purpose (a 0.00003 ETH stealth send, a 0.01 USDC transfer to yourself, a 0.10 USDC approve at a burn-style spender), so the provider and token-prompt steps can be exercised for well under a dollar even before committing this runbook's full budget. Deployment, minting, the permit battery, and the adversarial payloads are Sepolia-only by design.
The re-bundled IIFE content scripts and the stripped web_accessible_resources
have never executed in a browser. This is the highest-risk step in the
whole pass; nothing after it means anything if injection is broken. $0.
- Unzip the release zip, load unpacked. Extension ID reads
fcdigoihpflbghifhmpecihhfjkhabji(the pinned key works). - The install prompt/details show host access for only the three built-in RPC endpoints, no "read data on all websites".
- Service-worker console clean; popup boots.
- On a real page (any https site), the EIP-6963 provider announces at
document_start: Kivuli appears in a wallet-picker page alongside
MetaMask, and
window.postMessagebridge traffic flows (connect works). - The loaded
manifest.json(extension Details → view source, or the unzipped folder) has noweb_accessible_resources. - From a normal web page's console,
fetch('chrome-extension://fcdigoihpflbghifhmpecihhfjkhabji/assets/…')for a chunk named in the folder fails: no probeable fingerprint.
$0. New since the Sepolia runbook: the backup ceremony now challenges you.
- Passcode → 12 words → the 3-word verification appears; a wrong pick regenerates fresh challenges; passing activates the wallet.
- Close/reopen mid-backup resumes at the words, and finishing still works.
- Chip layout survives the 360px popup with long words.
- Settings → About shows the real version from the manifest (matches the zip name), not a placeholder.
$0 until noted.
- Fresh wallet lands on Base (network surface in the popup says Base; no Sepolia anywhere without the developer opt-in).
- Connect Kivuli to a dApp (or the bench in Base mode):
eth_chainId→0x2105,net_version→8453. - If not repinned: unlock surfaces the deployments-registry failure as readable error text in the meta-address/scan surfaces, not a blank popup, not an eternal spinner. Plain per-site signing still works.
- A dApp
wallet_switchEthereumChainto Sepolia (or anything else) is refused with 4902: switching is user-only. - No fiat amounts appear anywhere on mainnet, and every fee shown is a real quote in ETH.
- Force a failure (RPC blocked, or send more than a source can cover): the message on screen is a plain sentence. No "Version: viem@", no "Request Arguments", no multi-line dumps, and any endpoint URL appears as its host only.
- Settings → Advanced → Developer options → "Show technical error details": with it ON, the send failure screen offers a collapsible "Technical details" block with copy to clipboard; with it OFF, the block renders nowhere.
$0.
- Receive shows a plain
0x…address + QR; "New address" rotates it; meta-address copy available below (once SDK-gated features work). - With the SW console open, minting or showing an address fires one
targeted
eth_getBalanceright away (the address is watched from the moment it exists, not from the next 2-minute tick). - Tokens tab shows a USDC row the moment any account holds USDC, with no manual import: the registry's curated Base tokens are tracked by default. Zero balances stay hidden like any other token.
Budget: $2 (gas dust to the per-site address).
- Connect Kivuli on a real Base dApp (Uniswap on Base, Aerodrome, anything with a connect button). Approval names the origin; address is unique to the origin.
-
personal_sign/ typed-data prompts raise and return. - Affordability banner. While the per-site wallet is still empty, submit any transaction from the dApp/bench: the approval prompt shows the danger banner "This wallet can't pay the network fee", names the wallet's balance and the approximate need in ETH (plain decimals, no exponents), and Approve stays disabled until the "Approve anyway" override is ticked. After funding (next step), the same submission shows no banner.
- Send ~$2 of ETH from MetaMask to the per-site address, then submit a 0-value self-tx through the dApp/bench. The approval prompt's fee is a real quote (execution + L1 data fee), and the tx lands on basescan with an actual fee at or under the quote.
- Receipt polling works on the default config. After the dApp/bench
submits, its own
eth_getTransactionReceiptpolling resolves (the bench or dApp reports the confirmation itself, no endless "pending"), and the wallet's activity row flips to Confirmed, all without a custom RPC. This is what themainnet.base.orgdefault exists for; a 403 here means the endpoint regressed. - Switch networks in the popup while a dApp approval prompt is open: the approval must sign on the network the prompt showed, or fail closed, never sign silently on the new one.
- Connected sites shows the wallet's address. Settings → Connected
sites: the connected origin's row shows its per-site address in
monospace, middle-truncated; tapping it copies the full address with an
inline confirmation, and "View on Basescan" opens
basescan.org/address/<that address>in a new tab (on Sepolia the same control reads "View on Etherscan" and targets sepolia.etherscan.io).
Budget: $12: two $5 sends recovered by sweeping, plus ~$2 of USDC that step 6b sends back out.
- Live arrival, Receive screen open. Keep Receive open and send $5 from MetaMask to the shown address. Within ~10 seconds the screen shows the "Payment received" banner and rotates to a fresh address. No browser restart, no "Check now", no popup reopen. Home's quarantine count and balance update on their own while the popup stays open.
- Arrival while closed. Close the popup, send a small second transfer to another receive address, wait out one scan interval, then just open the popup: the note is there (the alarm found it), or appears within a few seconds of opening (opening the wallet runs a check when the last one is stale). A browser restart must never be the thing that surfaces money.
- USDC auto-detection. Send ~$2 of USDC to a fresh receive address with no token imported. The note appears in quarantine showing the USDC amount with the "Can't move yet" badge (its subtitle says the address needs ETH for the network fee, not that a future version is required), and Home's token list shows the USDC row. Removing the USDC row and re-adding it by address behaves as hide/unhide, per network.
- Honest failure, no wedge. Point the RPC at a dead host (Settings → Advanced) and press "Check for money now": it fails with a readable error within ~20 seconds instead of hanging. Restore the endpoint and check again: it works without reloading the extension.
- Send $5 via the meta-address path (transfer + announce: two
transactions). Verify the announce on basescan against the announcer
contract above, and that its gas used is ≤ 60,000 (the configured
announceGas); record the actual number. - Sweep both ETH notes to a per-site wallet. Fees quoted before, actuals on basescan after: quote ≥ actual, and by a sane margin (not 3×). The USDC note stays visible as "Can't move yet" after the ETH sweeps (a sweep moves native only; the tokens leave in step 6b).
- With the popup open and in use past the auto-lock timeout (set it low first), the wallet does not lock mid-session; closed and idle, it still locks on schedule.
Budget: reuses step 6's ~$2 of USDC plus cents of ETH for fees. The rule under test: any source whose own ETH covers the quoted gas can send its tokens; a source with tokens but no ETH is refused with "add ETH" copy, and the fee is never taken in the token. Run the same four send checks twice: once here on Base mainnet, and once on the free network pass (Sepolia today, Base Sepolia once the repinned SDK lands there): Sepolia's curated USDC/USDT make the free twin a faucet-only exercise.
- Gas rule, refused honestly. While the USDC note from step 6 holds no ETH, open Send → pick its USDC row. The quote shows the fee in ETH and the blocker "This wallet holds no ETH for the network fee. Add ETH to it first.", with no version-limit copy anywhere. The Review button never submits.
- Plain-recipient USDC send. Fund the note's address with a little ETH
(or pick a source that already holds both). Send half the USDC to a
plain
0xaddress you control (MetaMask). Amount entry is in USDC at 6 decimals, the fee row stays in ETH, Review names "USDC" and the amount plainly, and the ERC-20 transfer lands on basescan from the source address. Activity shows the entry as USDC with the right amount. - Meta-address USDC send. Send the rest by meta-address (use the bench wallet as recipient). Two transactions: the token transfer to a fresh one-time address, then the announce against the canonical announcer. The recipient's scan finds the note showing USDC. Verify on basescan that the announce's stealth address equals the transfer's recipient: the two halves must agree.
- MAX means the whole token balance. On any USDC source, tap MAX: the field shows the full balance to the last unit (gas is ETH-side, nothing subtracted), and the send leaves the source's USDC at exactly zero with the ETH reduced only by the fee.
Budget: consumed from step 6's swept funds. This is the check Sepolia structurally cannot do: on Base every transaction also pays an L1 data fee, and a MAX send that ignores it reverts or strands dust.
- MAX-send the entire balance of one address. The quote visibly includes the L1 data fee; the transaction lands (no revert, no insufficient-funds), and the source is left with zero (or only the quoted-vs-actual margin).
- The SW reached the GasPriceOracle over the narrow host permissions (no CORS/offline errors in the SW console during quoting).
- Kill the RPC (airplane mode / block the host) and request a quote: the failure is an honest error, not a silently L1-fee-free (under)quote.
- Stale-quote MAX: open the amount step, tap MAX, then wait two or more minutes on Review before pressing Send. The send still lands: submit re-reads balance and fees, pins them onto the transaction, and re-clamps the MAX amount downward; a pre-broadcast insufficient-funds rejection is retried once with refreshed numbers. The Sent screen echoes the amount that actually moved, never more than the reviewed figure.
- Review shows the "Max send" note under the amount (the final amount is set at broadcast).
- The Choose-what-to-send sheet shows the "N sources" aggregate line, and with more than about 20 rows a filter box appears; scrolling stays smooth however many sources exist (rows are windowed).
$0 (Sepolia is free; Base side reuses existing funds).
- Settings → Advanced → Developer options → show test networks; switch to
Sepolia via the confirm dialog. With a page connected:
chainChangedfires (0x2105↔0xaa36a7),eth_chainIdandnet_versionflip. - History/inbox show only the active network's entries; explorer links go to sepolia.etherscan.io vs basescan.org respectively, never crossed.
- On Sepolia, ladder-derived notes from the earlier Sepolia QA pass re-appear after a scan (re-discovery through the receive ladder). Announced-only notes older than the ~20k-block scan window may not; that is a known limit, not a bug.
- The RPC override field edits per network: set an override on Sepolia, switch to Base, confirm Base still uses its own endpoint.
- The test-networks toggle refuses to turn off while Sepolia is active.
- Connected dApp on the wrong network gets the switch prompt. While on
Sepolia, open a previously connected Base site (basescan.org's connect
flow, or the bench pointed at Base) and let it request Base
(
wallet_switchEthereumChainto0x2105; sites that fall back towallet_addEthereumChainfor a built-in chain must land in the same prompt). The dedicated approval window opens naming the origin and showing Sepolia → Base. Approve: the wallet switches (header pill, inbox, history all follow),chainChangedfires on every connected page, and the site unstalls and carries on on Base. Reject (or close the window): the site gets a clean 4001 user-rejection, no switch, no hang. Asking again for the network the wallet is already on resolves silently with no prompt. - Testnet targets stay gated. With test networks OFF in Settings, a
page requesting Sepolia (
0xaa36a7) gets 4902 with copy pointing at Settings → Advanced → Developer options, and no prompt opens. Unknown chains (e.g. Polygon0x89) get 4902 naming the supported networks. - The connect approval names the active network in the wallet card (e.g. "Base", or "Ethereum Sepolia" while on the testnet), so what the site will see is stated before consenting.
- Switch back to Base: Base notes and history return intact.
- Base Sepolia (84532) appears behind the same toggle. Switch to it:
chainChangedreports0x14a34, explorer links go to sepolia.basescan.org, and plain per-site wallet sends and dApp signing work over sepolia.base.org. Until the Base-Sepolia-aware SDK is repinned, unlocking there must surface the deployments-registry error as readable meta-address / scan error text (the Receive screen and scan status say what broke), never a crash, a blank popup, or a spinner.
$0. chrome.permissions.request requires a user gesture, and this flow has never
run.
- Enter an Alchemy/Infura Base URL in Settings → Advanced → RPC: the Chrome permission prompt appears on save.
- Grant → scans/quotes work over the custom host (no CORS "offline").
- Re-enter and decline → the save is blocked with honest copy and the previous endpoint stays in use.
-
http://localhost/127.0.0.1URLs skip the prompt.
$0. The same wallet in Chrome's side panel (chrome.sidePanel, within the
manifest's 116 floor). This surface has never run in a browser either.
-
Menu → Open in side panel: the panel opens with the full wallet and the popup closes. The panel fills its height and stays usable resized down to ~360px.
-
Settings → Advanced → Open in side panel from the toolbar: ON → the toolbar icon opens the panel instead of the popup; OFF → the popup is back. Check both directions.
-
With the toggle ON, let the service worker die (chrome://extensions → Kivuli; the "service worker" link goes inactive after ~30 s idle), then click the toolbar icon: still the panel. The mode is runtime state the background re-applies at SW startup; Chrome does not keep it.
-
With the toggle ON, trigger a dApp approval on a connected site: it still opens as its own window and resolves normally: approvals ride dedicated windows, never the action popup.
-
Panel + expanded tab + (toggle OFF) popup open at once: one wallet, three views: changes in one show in the others.
-
The release artifact still verifies:
src/ui/sidepanel.htmlin the zip, noweb_accessible_resources, extension ID unchanged. -
Bottom sheets anchor to the panel's bottom edge: open the send flow's Choose-what-to-send sheet and an inbox note sheet in the side panel. The sheet sits on the true bottom of the panel with the scrim covering the full height, no dead space below; repeat once in the popup and the expanded tab, and resize the panel while a sheet is open.
Budget: $2 (one small stealth send; funds recovered).
- Start a stealth send and kill connectivity between the transfer landing and the announce (disconnect at the right moment, or point the RPC at a dead host after the transfer confirms).
- Activity shows the Delivering badge; the detail sheet explains the money has left and must not be re-sent.
- Restore connectivity → Retry now clears it with a successful announce, without a second transfer. Confirm on basescan: one transfer, one announce.
$0 new money. This erases the wallet; that is the point.
- Reveal the phrase; erase; restore from the 12 words.
- The same meta-address returns; ladder addresses re-derive; the Base funds from steps 5–7 re-appear after the first full scan, the USDC note included (curated-token detection needs no import after a restore).
- The restored wallet's scan cache and origin map are fresh (encrypted stores fenced to the new identity: no stale connections, no ghost notes from before the erase).
| # | Step | Result | Notes ($ spent) |
|---|---|---|---|
| 1 | Loads + hardened manifest | ||
| 2 | Onboarding + verified backup | ||
| 3 | Base default + honest failure | ||
| 4 | Receive | ||
| 5 | Per-dApp wallet on real dApp | ||
| 6 | Stealth lifecycle | ||
| 6b | USDC sends (gas rule) | ||
| 7 | MAX send / L1 fee | ||
| 8 | Network switch | ||
| 9 | RPC permission grant | ||
| 10 | Side panel | ||
| 11 | Stuck announcement | ||
| 12 | Recovery |
- The pinned
@opaquecashSDK is Base-aware, but the mainnet stealth flows have not passed this runbook yet; until they do, treat them as unverified rather than broken (and re-run the Gate check after any repin). - Base Sepolia (84532) ships in the network table, but the pinned
@opaquecash/deploymentshas no 84532 row yet: stealth flows there surface the registry error (readable, via metaAddressError / scan errors) until the next SDK release is repinned. Plain sends, dApp signing, and token transfers on 84532 are unaffected. - Token sends require the source to hold ETH for the fee (the gas rule); there is no token-denominated fee and no gasless rail yet. Sweeps remain native-only: a token-only note is sendable only after its address holds ETH.
- The Receive live check and curated-token detection cover the active EVM network's registry tokens; arbitrary imported tokens are read for account balances but do not create inbox notes on their own.
- Scan window is ~20k blocks for announced-only notes; ladder notes are always re-discoverable.
- No Solana, no ENS, no custom networks.
- Sepolia's endpoint keeps an install-time host permission on purpose: the free QA path must work without a grant step.