Nothing in this extension has ever run in a browser. This is the pass that finds out what that costs. Work top to bottom; each step depends on the last actually working, and stopping to fix is cheaper than pushing through with a broken layer underneath.
Record what you find in the "Results" table at the bottom, including the things that worked, because a green line is evidence and an empty line is not.
- Chrome with MetaMask installed and funded with Sepolia ETH (sepoliafaucet.com, or the Alchemy/Infura faucets). You need a second wallet because a fresh install has no money at all, and because paying Kivuli from an ordinary wallet is the main thing under test.
- An Alchemy or Infura Sepolia key. The public default (publicnode) rate-limits
under real scan traffic and rejects
eth_getLogsover old ranges. Expect to need this; do not treat the resulting failures as bugs until you have swapped it in (Kivuli → Settings → Advanced → RPC, and the bench's RPC field). - Two terminals:
npm run buildoutput loaded unpacked, andnpm run demo:dev.
npm run build # → dist/
npm run demo:dev # → the test bench, usually :5173Load dist/ via chrome://extensions → Developer mode → Load unpacked.
The riskiest moment: a throw during service-worker registration means the extension never starts, and Chrome reports it only in the SW console.
- The card appears with no red "Errors" button.
- Click service worker → the console is clean. WASM init and top-level imports run here; a failure shows up as a red trace, not as a broken UI.
- Open the popup. Boot completes (no permanent spinner, no "Couldn't start").
If the SW console shows a WASM error, that is the single most likely failure in
this build: the wasm-pack glue is statically imported and initialized against
chrome.runtime.getURL("pkg/cryptography_bg.wasm"), and that path has never
executed.
- Fresh install shows Welcome, not "Sign-in isn't wired in this build yet".
(That string means
capabilities.localVaultis not reaching the shell.) - Get started → terms → passcode. A passcode under 8 characters is refused inline.
- The 12 words appear. Write them down. You need them in step 7.
- Continue is disabled until the confirmation box is ticked.
- After confirming you land on the wallet, not back at Welcome.
- Close the popup mid-backup and reopen it. You should return to the backup step with the same 12 words, and finishing should still reach the success screen rather than jumping straight to Home.
- Lock (Settings → Lock wallet) and unlock. Wrong passcode gives an inline error; five wrong ones start a countdown.
- Receive shows a plain Ethereum address (
0x…, 42 characters) and a QR and not a meta-address. This is the change that makes Kivuli payable by ordinary wallets, so if you see a 196-character string here, stop. - "New address" hands out a different one; the old one keeps working.
- "Copy your meta-address" below it copies the long form (
0x+ 196 hex) for wallets that speak the stealth protocol. - Copy works. Keep the receive address on your clipboard.
- It does not claim Solana anywhere in the UI (v1 is EVM-only).
Open the bench. This is where EIP-6963 gets its first real test.
- Kivuli appears in the wallet list, with its icon and name. If it does not, provider injection is broken and steps 5–8 cannot pass. Check the page console and the content-script injection order against MetaMask.
- MetaMask also appears. Both coexisting is the point: one wallet must not erase the other.
- Connect MetaMask in the bench; it reports Sepolia (switch if not).
- Paste the receive address from step 3 (not the meta-address), amount
0.01, Send. This is the case that used to be invisible: an ordinary transfer, from an ordinary wallet, with no announcement at all. - Approve the setup signature, then the transfer, then the announce (two transactions on Ethereum: this is expected, not a double-spend).
- The bench prints a one-time address and both tx hashes, and Etherscan shows the transfer landing at that address.
- In Kivuli, the payment shows within a scan cycle (~2 min), or immediately on Settings → Advanced → Developer options → Check for money now. It is found by watching the address, so no announcement was needed.
- Repeat with the bench's meta-address path (it announces) and confirm that route still works too.
- The amount matches what you sent.
- Sweep it. Note: a fresh wallet has no per-dApp account to sweep into until you have connected to a site. If the destination list is empty, do step 7 first and come back. Pasting an address works either way.
- The sweep tx confirms on Etherscan and the balance moves.
This is the whole product thesis in one line: money reached you at an address nobody can link to you, and you moved it without ever revealing the link.
- In the bench, connect Kivuli this time. The approval prompt names the origin.
- The address it reports is not any address you have used elsewhere.
-
eth_accountsreturns that same address. -
personal_signraises the approval UI and returns a signature. -
signTypedData_v4likewise. -
0-value self tx: needs gas at the per-dApp address, so sweep to it first (step 6). Confirms on Etherscan. - Now the real test: open a genuine Sepolia dApp (Uniswap on Sepolia, an ENS testnet app, anything with a connect button) and connect Kivuli. It should behave like MetaMask, and the address should differ from the bench's. Two origins, two unlinkable wallets, one recovery phrase.
Do this last: it erases the wallet.
- Settings → Security → Recovery phrase → passcode → the same 12 words.
- Lock → Forgot passcode? → tick "I have my 12 words" → Enter my recovery phrase. Nothing is erased at this point: type a phrase with one word wrong and it is refused on the spot, with the wallet still intact.
- Restore with the phrase from step 2 and set a new passcode. The same meta-address comes back, and the swept funds are still there.
- The restored wallet starts clean otherwise: no dApp connections, imported tokens, or contacts carried over from before.
- The receive addresses come back too. They are derived from the phrase, so the restored wallet re-derives them, re-adopts the ones the chain has seen, and the money sent to them in step 5 reappears. If it does not, the ladder recovery is broken and every payment ever received is at risk.
If the meta-address differs after restore, stop: key derivation is not deterministic across a restore and nothing else matters until it is.
| # | Step | Result | Notes |
|---|---|---|---|
| 1 | Loads / SW clean | ||
| 2 | Onboarding + resume | ||
| 3 | Receive | ||
| 4 | EIP-6963 discovery | ||
| 5 | Bench send | ||
| 6 | Scan + sweep | ||
| 7 | Per-dApp wallet + real dApp | ||
| 8 | Reveal + restore | ||
| 9 | Send / settings / expanded / side panel | ||
| 10 | Token playground |
A static adversarial review ran before anyone loaded this, on the theory that a first QA session is too expensive to spend on things a careful read would find. It returned 28 findings; these were the ones that would have ended the session, and they are fixed:
- The bench never booted.
demo/main.tswas the one SDK entry point missingsrc/polyfills, so it threwBuffer is not definedat module evaluation, so step 5 would have failed with a blank page. - A fresh wallet could not reach its inbox. With no dApp accounts yet, Home rendered its empty state, and the only control that opened the inbox lived in the non-empty branch. The first stealth payment would have arrived and been unreachable: the whole demo, invisible.
- personal_sign could sign something other than what it displayed for plain UTF-8 messages (most SIWE flows). Display and signer now agree by construction, with a test pinning it.
- The connect prompt showed a fabricated address: an FNV hash of the origin, not the key the background derives. You would have approved one address and received another.
- A failed scan wedged the scanner permanently. The cursor only advanced after the balance pass, so one rate-limited call meant every later tick re-scanned the whole first window and failed identically, forever.
- A scan that overlapped a sweep undid it, resurrecting the swept note.
- Scan failures were invisible: the header read "Connected" over a dead inbox.
A second adversarial pass ran over that work and found fourteen more, all fixed here. The ones that would have shaped this session:
- Erasing the wallet didn't always erase it. A scan running at the time wrote its results back afterwards, so the next wallet opened onto the previous one's notes and scan cursor, and started scanning from that cursor, silently skipping its own first-scan window.
- Forgot-passcode erased before asking for the phrase. One mistyped backup word and the wallet was gone before anything checked. The phrase is now entered and validated first; the restore replaces the wallet in one step.
- Already-swept money came back as "Money is waiting" after a restore, with a Move button that could only ever fail. Step 8 walked straight into it.
- The spending-cap prompt was decorative. It offered a cap, said unlimited had
been capped at your balance, and signed the site's original unlimited approval.
The cap is now encoded into the
approvetransaction, and permits, whose amount is fixed by a signature the site keeps, no longer pretend to offer one: they state the amount and let you reject it. personal_signbroke on address-shaped messages, returning "address does not belong to this origin" to a connected site.- The bench's
0-value self txwas live on any chain, so a tester still on MetaMask's default mainnet would have paid real gas for a QA no-op.
Two known-unfixed items to expect: the manifest declares no host_permissions,
so a custom RPC on a host without permissive CORS (a local node) will fail as
"offline": the field accepts http://localhost now, but the request itself may
still be blocked; and the built manifest exposes the inpage provider as a
web-accessible resource, which contradicts the anti-fingerprinting note in
manifest.config.ts.
- Home shows Send and Receive only (Swap and Buy are gone).
- Send → pick a source. Received payments and per-site wallets are both listed; picking is deliberate, so that one transaction never merges two of your addresses without you choosing it.
- Send to a saved contact, and to a pasted address. Both go through.
- Send to a meta-address: this becomes a stealth payment (transfer + announce, two transactions), and the fee quote covers both.
- Activity lists what you sent and moved.
- Settings → Security → Change passcode: the old one is required, five wrong tries throttle, and the new one unlocks after a lock.
- Settings → Security → Clear activity data empties the list and nothing else.
- Settings → Advanced → State logs: counts and timings, no addresses. "Copy for support" produces JSON you would be comfortable pasting.
- Settings → Advanced → Developer options → Check for money now.
- Menu → Expand view opens a full browser tab and the popup closes.
- Menu → Open in side panel opens the full wallet in Chrome's side panel and the popup closes. The panel fills its height and survives being resized narrow (~360px).
- Settings → Advanced → Open in side panel from the toolbar: ON → the toolbar icon opens the panel, not the popup; OFF → the popup is back. Check both directions: a set popup silently overriding panel mode is the failure this toggle's plumbing exists to prevent.
- With the toggle ON, stop the service worker (chrome://extensions → Kivuli → the "service worker" link goes inactive after ~30 s idle) and click the toolbar icon: still the panel. The mode is re-applied at SW startup, not remembered by Chrome.
- With the toggle ON, trigger a dApp request from the bench: the approval still opens as its own window and resolves normally: approvals ride dedicated windows, never the action popup.
- Open the side panel, the expanded tab, and (toggle OFF) the popup at once: one wallet, three views: a send or settings change made in one shows up in the others.
-
npm run build/npm run releasestill pass with the new entry:src/ui/sidepanel.htmlin dist and the zip, noweb_accessible_resources, extension ID unchanged. - The toolbar icon is the Kivuli mark, filling its frame at every size.
Real contract interaction, so the approval prompts can be checked against what the chain actually recorded rather than taken on trust.
- Deploy demo token with Kivuli connected. The prompt says it cannot preview the call (there is no decoder for a deployment); the address appears once mined.
- Mint 1,000, then check the balance line.
- Approve 100 → the prompt offers an editable cap. Change it to 25, allow, and confirm the allowance line reads 25, not 100. This is the single most important check on this page: the cap is either enforced or the prompt is decoration.
- Approve unlimited → the prompt must refuse to proceed until you enter a cap. Enter one; the allowance must be that number, never 2^256−1.
- Approve 2^160−1 → must ALSO be called unlimited. This is Permit2's infinite value; a wallet checking only for a uint256 maximum shows it as an ordinary large number.
- Sign permit (500) → the prompt is read-only and states 500. It must not offer a cap: the site holds the struct the signature covers, so a changed amount would simply fail to verify. Then Submit last permit and confirm the allowance reads exactly 500.
- Sign permit (unlimited) → red, "no limit", no cap field.
- Permit2-style → must read as unlimited (uint160 sentinel).
- Transfer 1 → the prompt decodes a transfer with the amount and recipient; the balance drops by 1.
- Revoke → allowance returns to 0.
- Adversarial payloads → Decoy-field permit must display unlimited. If it shows 1 DUSD, the wallet is reading fields the signature never covered. Undeclared types must say it cannot verify the payload (or refuse outright), never present "1" as the amount.
- Testnet only; Sepolia has no real value.
- ERC-20 payments cannot be moved yet (they need the gasless rail); native only. They show as "Nothing to move" rather than being hidden.
- No Solana:
ENABLED_CHAINSinsrc/lib/config.ts. - No ENS resolution or custom networks.
- Sends are ETH only; a token send is refused with a clear message rather than quietly sending ETH instead.
- Activity records only what this wallet itself did. A payment arriving, or a dApp spending from its own wallet, is not in the list.
- The scan cache is plaintext payment history: a security-gate item, tracked in the scanner's TODO, and a reason not to distribute beyond friendly testers yet.