A private pool for Bitcoin and Arkade assets on Arkade. Inside the pool, value moves as notes: each spend is proved valid, but nobody learns who paid whom or how much. It runs on Mutinynet only.
- The pool is one Arkade VTXO, the head, which holds a supply-1 pool token and a commitment to the pool state. Each listed asset has one reserve VTXO beside it (at most 64).
- A wallet owns notes in a depth-32 Poseidon tree. To spend, the browser proves a spend (one input note) or a join (two input notes), each with two outputs, with Groth16, and reveals only the spent notes' nullifiers.
- The pool forgets in epochs of 128 batches (
SHIELDED_ROLLUP_EPOCH_LEN, baked intoDockerfile.dokploy). Each epoch has its own note tree and spent-note set, and a note spends directly in the epoch it was made in and the one after; a wallet refreshes its notes inside that window. A note that falls out of it is recovered with a one-slot recover batch against the sealed spent-note sets of the epoch it came from and the one after, since it could have been spent directly in either; losing epoch e+1's file therefore also blocks recoveries of epoch e. The operator serves those sets at/api/rollup/archives, and.github/workflows/archive-mirror.ymlmirrors each closed epoch's file to therollup-genesis-4-archivesrelease. - The operator collects up to ten spends, or ten joins, into a batch and proves the state update. A batch closes as soon as a spend is pending and no batch is in flight; spends that arrive meanwhile share the next. The head's covenant checks every client proof and the batch proof with the Arkade emulator's BN254 opcodes before the pool moves.
- Deposits come from the depositor's Arkade coins, which they sign in a short signing step. Withdrawals pay an Arkade address and must be at least the 330-sat dust.
- Each output note is sealed to its recipient's view key (X25519 and AES-GCM) and published with its batch. A wallet restores from its recovery secret by scanning the published batches.
- A payment spends one note, two notes in one join, or up to three notes as one atomic group. A wallet holding more than eight notes of an asset merges its two smallest in the background.
- One 24-word recovery phrase derives a spend key, a nullifier key and a view key. An address (
shrol2…) carries the owner hash and the public view key. Two read-only keys can be shared: a view key (shview2…) shows the notes a wallet received, and a full viewing key (shfvk2…) also shows which of them were spent. Neither can spend or freeze a note. - Anyone can list an Arkade asset by sending one unit and 330 sats to the pool address; the operator turns it into that asset's reserve.
- The operator renews the head and reserves in fresh batch swaps, eight reserves per swap, 48 hours before they expire.
The wallet requests Lightning quotes only after Get quote. That action shares the invoice or Lightning address and amount with its payment service. Sending funds the verified HTLC directly from one pool note, including the swap fee; ordinary Arkade coins are never added. Each send refund and receive invoice uses its own HD child key and payout address. Receive and refund payouts return to the pool one coin at a time, signed with that child's key.
A Lightning address remains a permanent alias. The wallet derives its payout keys locally on a dedicated branch (2/7) and binds the alias to one Arkade address and claim key at a time; the service never receives a descriptor, extended key, fingerprint or derivation path. The wallet binds a new address after the current one receives a payment, and otherwise once a day. Payers between two rotations share one address, so they and the Arkade operator can link those payments, and the service can link every address it served to the alias. Every address ever bound stays watched. Direct swaps use a separate branch (2/8); both branches keep durable counters shared across pools.
Keep browser data while swaps or payouts are pending. The journal saves the exact pool submission before sending it, and keeps uncertain funds reserved across reloads; a timeout or missing status never frees them. Existing swaps retain their original recovery path, with new funding disabled. Rolling back the application must preserve the Lightning journal and swap repositories.
The recovery phrase derives all child keys, but does not reconstruct every direct swap or arbitrarily large unpaid index gap after browser storage is lost. Recover a Lightning address scans the 2/7 branch under the current operator settings until 400 consecutive addresses never held a coin, then watches every address up to the last one that did. Outstanding direct swaps also require their stored recovery records. Alias history recovery contacts the service only after explicit enablement or recovery. Keep the page open to shield received payouts.
The pool accepts deposits of at least 330 sats. Direct receive invoices enforce that floor. Alias or direct Arkade payouts below it remain at their individual HD addresses; the wallet reports them and never combines unrelated payouts automatically. Keep their recovery records too.
| Path | Contents |
|---|---|
src/server.ts |
The service: pool, /api/rollup and web app on one port |
src/rollup/ |
Operator, batcher, covenant, prover, renewal, listings and HTTP API |
src/stock/ |
Arkade network, indexer, journal and signature helpers |
packages/protocol/src/rollup/ |
Notes, state tree, nullifiers, addresses and the wallet account |
app/ |
Home page and wallet (/wallet) |
circuits/rollup/ |
Spend, join and batch circuits |
rollup-keys/ |
genesis-4.json, the pinned manifest of the published proving keys |
tools/vm/ |
Go bridge to the Arkade emulator, and the covenant builder (-rollup-leaves) |
Requires Node 24.2 and Go 1.26.9.
npm ci
npm run vm:build
npm run build
node tools/fetch-rollup-keys.mjs rollup-keys/genesis-4.json https://github.com/arkade-os/shielded/releases/download/rollup-genesis-4-keys .deps/keys
SHIELDED_DATA_DIR=.deps/data SHIELDED_ROLLUP_KEYS=.deps/keys HOST=127.0.0.1 npm run serverfetch-rollup-keys.mjs keeps each downloaded file only if it matches the manifest pinned in the repo. Without SHIELDED_ROLLUP_KEYS the server generates its own development keys instead: that needs the compiled circuits (npm run circom && npm run rollup:compile:epoch, then SHIELDED_ROLLUP_CIRCUITS=circuits/rollup/build), 6 GiB of memory and a 1.2 GB powers-of-tau download. Then fund the fundingAddress from /api/rollup/status with at least 2,000 sats to create the pool. The server listens on port 8792; npm run app serves the web app with hot reload and proxies /api to it.
That same operator wallet keeps paying after genesis. arkd charges each renewal intent 0.2% of every input under a day old — the head, each reserve the intent carries, and the wallet's own float coins — and a batch re-creates the head each time, so a renewal of a 6,000-sat head costs 12 sats. A pool with reserves sends one intent per 8 reserves, and each of those intents pays for the head again. Fund the wallet above the 2,000-sat minimum, sized to the head and the reserve count you mean to run — the change returns as a fresh coin, which the next renewal carries along so arkd cannot sweep it. At most 8 of the wallet's coins ride in one intent, largest first, so coins anyone pays to the published address can neither crowd the float out nor grow the intent past arkd's weight limit. A wallet that cannot cover the fee stops the pool renewing, and says so in the notice on /api/rollup/status.
A deposit is free unless the operator turns a fee on. The fee goes to a separate fee wallet, so the server needs only that wallet's address and view key, never its spend key:
SHIELDED_DEPOSIT_FEE_ADDRESS: the fee wallet'sshrol2…address.SHIELDED_DEPOSIT_FEE_VIEW_KEY: itsshview2…view key, which the pool uses to check each deposit's fee note.SHIELDED_DEPOSIT_FEE_SATSandSHIELDED_DEPOSIT_FEE_BPS: the fee is the larger of the fixed sats and the bps share of the deposit, rounded up. Both default to 0.
A deposit whose coins are all payouts this pool made, the payout outputs of its own recorded batches, pays no fee. An asset deposit pays nothing while its BTC carrier sats total 330 or less, and the full fee on that total above it.
The fee can be turned on once this version of the wallet is deployed. Older pages and older versions of the @arkade-os/shielded package cannot pay it, so the pool refuses their deposits with a message to reload.
With none of these set the fee is off, and SHIELDED_DEPOSIT_FEE_SATS=0 and SHIELDED_DEPOSIT_FEE_BPS=0 alone change nothing. With an address and view key but both numbers 0 it is off too, and the pool logs a warning. Once it is live, /api/rollup/status shows pool.depositFee as {fixedSats, bps, address}; the view key is never served. A fee that is only partly set, or set wrong, blocks the pool instead of running it fee-free, with the reason in /api/rollup/status and the log: a SATS or BPS above 0 with no address and view key, an address without a view key or the reverse, an address or view key that does not parse, a view key that is not the address's, a SATS or BPS that is not plain decimal digits, bps above 10000, or any other variable starting SHIELDED_DEPOSIT_FEE_, which is a misspelling.
Read docs/FEE-WALLET.md before setting any of these: it covers creating the fee wallet, running the keeper that merges its fee notes, and turning the fee on and off.
The proving keys come from a two-phase Groth16 setup.
- Phase 1 is the Hermez Perpetual Powers of Tau transcript for 2^20 constraints,
powersOfTau28_hez_final_20.ptau, pinned by its BLAKE2b-512 intools/rollup-setup.mjs. It is shared with many other projects; nothing here re-runs it. - Phase 2 for the genesis 4 epoch circuits is a small testnet ceremony. Its one contribution per circuit, and the public hash of Bitcoin block 972846 that seals them as a random beacon, are listed in
rollup-keys/genesis-4.json. The keys are sound if at least one contributor discarded their randomness, and with a single contribution per circuit that is the word of whoever made it. The beacon only stops the last contributor from choosing the final key; it adds no secrecy. This is not a production ceremony, so the keys are for Mutinynet tests only. - Genesis 3. Its keys (release
rollup-genesis-3-keys) were a development-only setup with a single automated contribution and no beacon, and the current build does not use them. That release stays published as the record of that setup, and is never deleted.
To check the published keys, compile the circuits (the build is deterministic) and verify each key against its circuit and the phase-1 transcript:
npm run circom && npm run rollup:compile:epoch
node tools/fetch-rollup-keys.mjs rollup-keys/genesis-4.json https://github.com/arkade-os/shielded/releases/download/rollup-genesis-4-keys .deps/keys
for c in epoch-spend epoch-join epoch-batch-spend epoch-batch-join epoch-batch-recover; do npx snarkjs zkey verify circuits/rollup/build/$c.r1cs .deps/rollup-ceremony/powersOfTau28_hez_final_20.ptau .deps/keys/$c.zkey; doneFetch the transcript first from https://circom.info/powersOfTau28_hez_final_20.ptau into .deps/rollup-ceremony/, and check that b2sum matches the PTAU_BLAKE2B512 in tools/rollup-setup.mjs. Check the compiled R1CS hashes against rollup-keys/genesis-4.json (npm run rollup:budget:epoch pins the same hashes); the download tool checks the key-file hashes. snarkjs zkey verify checks each key against its circuit and the phase-1 transcript, and prints every contribution hash and the beacon it finds in the key; they should match the manifest.
The batch leaf accepts any valid batch, whoever proves it. Mirror the pool while it is up, then prove and submit your own withdrawal without it:
npm run fallback -- mirror https://shielded.mutinynet.arkade.sh ./pool-mirror
SHIELDED_PHRASE="your 24 words" npm run fallback -- withdraw ./pool-mirror 5000 tark1…
npm run fallback -- publish ./pool-mirror https://shielded.mutinynet.arkade.sh <batch>withdraw replays the mirrored batches, checks they reach the head the indexer shows, and proves your spend plus nine zero-value spends and the batch. It then submits the batch to the Arkade emulator. publish hands its record to the operator, which checks it against the chain before following. Without the operator nobody can renew the pool head, so the payout expires when the head does; withdraw prints that deadline, and the wallet shows it once the operator stops answering. withdraw spends a note of the two live epochs; a note older than that is brought back first with SHIELDED_PHRASE="your 24 words" npm run fallback -- recover ./pool-mirror <epoch> (BTC notes only, the largest of that epoch per run), using the checked archive of that epoch that mirror saved.
This path covers the pool operator stopping, not Arkade itself: the batch still needs the Arkade emulator and operator to accept it. If they are offline, the pool cannot move at all (see SECURITY.md).
npm test
(cd tools/vm && go test ./...)
npm run test:rollup:epoch-witnessnpm test needs the VM binary from npm run vm:build; the epoch witness test needs the compiled circuits (npm run circom && npm run rollup:compile:epoch).
See docs/DOKPLOY.md. Read SECURITY.md first: this is unaudited test software.