pocket
How it works

The proving pipeline

How a witness becomes a proof, why bb.js is vendored rather than bundled, and the deadlines that stop a wedged prover from bricking the wallet.

Proving happens on your machine, in an offscreen document, using vendored artifacts. Nothing is fetched at proving time.

The steps

Load the circuit

The compiled circuit ships inside the extension package. Its bytecode arrives base64-encoded and gzipped inside the Noir artifact, so it is decoded and decompressed before the prover sees it.

Circuits are cached in memory after the first load.

Solve the witness

noir_js turns named inputs into the ordered witness the prover consumes.

Solving is also a free correctness check: the circuit refuses an assignment that does not satisfy its constraints, so a bad witness fails here rather than producing a proof that fails on chain.

Decompress the witness

noir_js returns the witness gzipped, the same form nargo writes.

The low-level bb API takes it decompressed, and handing it the compressed bytes does not error. It traps inside the wasm as RuntimeError: unreachable, which says nothing at all about the cause.

Prove

The solved witness is sent to the offscreen document, which runs acirProveUltraKeccakHonk.

This is the most sensitive message in the system: for a transfer or a withdrawal it contains the spending key, the amount and the blinding. The offscreen document checks its sender for that reason, rather than resting on the fact that nothing else currently listens.

Split and check the output

bb.js returns publicInputs || proof concatenated. The contract takes the two separately and would reject a proof carrying its inputs glued to the front, so they are split at the circuit's known slot count.

The proof is then asserted to be exactly 14,592 bytes.

What the size check does and does not catch

A poseidon2-transcript proof is the same size as a keccak one at bb 0.87.0, so this check does not prove the transcript is right.

What it does catch is an accidental zero-knowledge proof, which is longer (16,224 bytes), and a public-input count that disagrees with the contract. The transcript is pinned separately, by hashing the verification keys at the release gate.

Why bb.js is vendored and never bundled

The 0.87.0 browser build declares a top-level __webpack_exports__ that collides with a bundler's own runtime, and it spawns its worker from an import.meta.url marked webpackIgnore.

Bundled, that URL resolves to a hashed chunk that does not exist. createMainWorker then awaits readiness with no timeout and no reject path, so proving hangs forever with no error.

So bb.js is copied verbatim into the package and loaded as native ESM from an absolute chrome-extension:// URL. That URL is absolute rather than root-relative for a reason: a root-relative specifier resolves against the importing module's own URL, and under the dev server that is http://localhost:3000, so the import went to the dev server and the private pocket could not be set up at all in a development build.

Vendoring is also what makes this legal under Manifest V3's ban on remotely hosted code. Every byte the extension executes is in the package.

What else is vendored

ArtifactWhy it shipsSize
bb.js browser buildremote code is banned, and bundling breaks it7.1 MB
The structured reference stringbb.js would otherwise fetch it from a remote host at proving time4.0 MB
The six compiled circuitsa circuit is code572 KB

The reference string is sized by measurement

The size is driven by the largest circuit's proving subgroup, which is not the gate count bb prints. Measured in a real browser:

CircuitGatesSubgroup
register14,41216,384
withdraw28,86832,768
transfer28,92632,768
spender_transfer28,92632,768
set_spender28,92632,768
revoke_spender28,92632,768

bb needs subgroup + 1 points, so 32,769. The build takes 2¹⁶ for headroom, because a circuit change that pushed past 32,768 would otherwise trap inside the wasm with an opaque failure rather than a useful error.

Both halves are sha256-pinned before anything is written, and the G2 point is additionally compared limb by limb against the value compiled into the on-chain verifier. A substituted reference string fails the build rather than silently producing proofs against a different trusted setup.

Three deadlines

chrome.runtime.sendMessage has no timeout of its own, and a wedged wasm worker is indistinguishable from a slow one. So every step is bounded:

BoundValueGuards
Prover init30 swasm instantiation and reference-string load
One proof120 sthe proving call itself
A whole request165 sthe service worker's own bound, from the outside

The third exists because the offscreen document bounds each job, and a job that wedges the serial queue is never dequeued. A new request queues behind the wedge and its own bound never starts running, so without a bound on the worker side that request hangs forever and the private pocket shows a spinner with nothing scheduled to end it.

165 seconds exceeds init plus prove, so it cannot fire on a proof that is merely slow, and stays under Chrome's five-minute cap on a single request, so Pocket's own error arrives before Chrome's silent kill.

Recovery is to destroy the document. Its state is entirely rebuildable: the wasm re-instantiates and the reference string is bundled, so being wrong costs a few hundred milliseconds on the next proof, against a wallet that otherwise cannot prove anything again until the browser restarts.

Tearing down is itself bounded, because a worker wedged badly enough to blow the prove timeout is exactly the one whose destroy() never resolves. Awaited without a bound, that would hang inside the serial queue and every later proof would queue behind it forever.

The queue

The offscreen document runs proofs serially. bb.js worker memory is the binding constraint, so never two at once.

Each job waits for the previous one regardless of outcome, and the queue depth is reportable so the interface can show backpressure rather than dropping work.

Threading

The document opts into cross-origin isolation through the manifest, which lets bb.js take its multi-threaded path, up to eight threads.

This is not load-bearing. Without isolation bb.js loads the single-threaded wasm and collapses to one thread, which costs speed and not function.

On this page