The observer
Our adversary runs a full node or reads a block explorer. For every transaction it sees the block height and time, the transparent inputs and outputs (addresses and values), how many Sapling spends and outputs and how many Orchard and Ironwood actions it has, the fee, and the value balance of each shielded pool: the net amount entering or leaving it. It cannot see shielded senders, recipients, amounts or memos. It has unlimited patience and no cryptographic breakthroughs.
What crosses a boundary
Three moves put an exact amount on the public chain:
- Shielding (transparent → pool): the transparent inputs, their addresses, and the net value entering the pool.
- Deshielding (pool → transparent): each transparent output, its address and its value.
- Pool crossing, the turnstile: value moving between Sapling, Orchard and Ironwood shows up as one pool's balance going down by exactly what another's goes up. Since NU6.3 (block 3,428,143, 28 July 2026) Orchard is frozen and withdraw-only, so every Orchard holder has to cross this turnstile.
Leakscope calls each of these a boundary event. Everything else stays inside the pools and is not analysed.
Detectors
| Leak | Rule | Severity | Found |
|---|---|---|---|
| Round trip | A shielding spends a transparent output created by an earlier deshielding. | critical (deterministic) | 15,668 |
| Address reuse | A shielding comes from an address that earlier received a deshielding. | high (deterministic) | 16,510 |
| Quick round trip | A withdrawal with exactly one candidate deposit, at most 100 blocks earlier. | high | 4,833 |
| Amount match | A withdrawal with 1 to 10 candidate deposits in the window. | 1 → high, ≤3 → medium, ≤10 → low | 406 |
| Pool crossing | Every turnstile move; how many moves of the same amount happened within the window. | unique → medium, ≤10 → low, else info | 31,842 |
| Fee fingerprint | The fee paid differs from the ZIP-317 conventional fee for the transaction's shape. | low | 5,011 |
A candidate deposit for a withdrawal is a boundary event that entered the same pool the withdrawal leaves, earlier and within the window, whose amount equals the withdrawal plus that withdrawal's own fee. The fee is public, so this pins the match to one exact value: it is what you see when someone withdraws a whole note they deposited. The anonymity set is the number of such candidates. A set of 1 means the observer has exactly one guess.
How often is a link wrong?
A unique match can still be a coincidence. To measure that, we ran the exact same rule backwards in time: against deposits made after each withdrawal. Money cannot leave before it arrives, so every backward match is a coincidence, and it occurs at the same rate as coincidences among the forward matches. That gives a false-link rate measured on the chain's real amount distribution, not on a model of it.
| Link | Found in range | Measured precision |
|---|---|---|
| non-round amount, within 100 blocks | 4,813 | ≈99% |
| non-round amount, within a day | 406 | ≈85% |
| round amount, within 10 blocks | 20 | ≈90% |
What we dropped because it failed this test: unique matches more than a day apart (more backward coincidences than forward links) and round amounts (multiples of 0.001 ZEC) more than 10 blocks apart. Round trips and address reuse need no such test: they follow the actual coins. Reproduce it with engine/scripts/evaluate.ts.
The headline, 24.6%, is the share of deshielding transactions that are linked either backwards by a calibrated amount link (5,128) or forwards by a round trip (15,215 more), out of 82,581.
Assumptions
- Window: 30 days (34,560 blocks at 1,152 blocks per day). The observer looks this far back for a matching deposit.
- Fee gap: a deposit matches a withdrawal only if it is larger by exactly the withdrawal's own fee. In the rare case the fee can't be computed, any gap up to 250,000 zatoshis in multiples of 5,000 (the ZIP-317 marginal fee) is accepted. Pre-flight uses this looser rule on purpose: it estimates the crowd an amount hides in, before any fee exists.
- Direction: the deposit must precede the withdrawal and enter the pool the withdrawal leaves.
- Pool crossings are judged by exact amount within the same window, before and after.
Pre-flight scoring
Pre-flight runs the same engine in your browser. It counts recent boundary events with the planned amount (same fee rule) and maps that crowd to a score: 1 → 5, up to 3 → 25, up to 10 → 50, up to 50 → 75, more → 90. Staying in one shielded pool scores 100; a fully transparent transfer scores 0. Reusing an address seen at a pool boundary caps the score at 10. In the browser the event index carries no addresses per event, only the set of all boundary addresses, so both address checks use that set.
Suggestions follow ZIP 318: split the amount into {1, 2, 5} × 10k denominations between 0.01 and 10,000 ZEC (deterministic digit-by-digit, or randomized), shuffle them, start one 144-block bucket after the next boundary, and space them with exponential delays (mean 66 blocks, about 82 minutes, redrawn above 576). Randomness comes from the browser's cryptographic RNG.
Limits & false positives
- Amount matches are probabilistic. A set of 1 means no other deposit fits, not that the link is proven. Two unrelated people can move the same odd amount. Round amounts collect large sets and are downgraded automatically. Round trips and address reuse are deterministic.
- The anonymity set counts plausible sources; it does not weigh them. A real observer would add timing, wallet fingerprints and off-chain knowledge, so these numbers are a lower bound on what can be linked.
- Edges of the range. Withdrawals early in the range see less than 30 days of history, which can make a match look more unique than it is. The report generator supports a warm-up period to remove this; the range shown here is labelled on every page.
- Custodians and batches. Exchanges batch many users into one transaction. A match may link a user to an exchange rather than to a person.
- Fees are only computed when every input value is known.
- Fully shielded transactions are not analysed beyond their existence, timing and shape.
How we validated
Our own testnet mistakes were caught
We made deliberate mistakes with our own testnet wallet and ran the same pipeline over those blocks. Both were found, along with the exact amounts the pool crossings revealed:
- Round trip:
85e9872f65…df46→1d15b95d39…9bb9 - Quick round trip:
4080c7a2fb…b5ab→8225aad033…0a5f
The wallet scan reproduces this from that wallet's public viewing key.
Regtest and unit tests
On a private regtest chain we reproduced each leak class in scripted scenarios, so each detector was checked against transactions whose owners we know. The engine's unit tests cover every detector and the ZIP-318 planner, and the transaction parser is tested against real mainnet transactions.
Mainnet spot checks against an independent explorer
We compared findings with zecblock.com. For example, c6b771a76d…57fdcheckexplorer ↗ (Opens zecblock.com in a new tab. That explorer learns you looked up this transaction.) shielded 2.50121076 ZEC into Sapling at block 3,428,153; seven blocks later 57d7d945f7…fe3dcheckexplorer ↗ (Opens zecblock.com in a new tab. That explorer learns you looked up this transaction.) deshielded 2.50106076 ZEC from Sapling: the same amount minus a 15,000-zatoshi fee, with no other candidate in the window.
Data
Mainnet blocks 3,428,143 to 3,515,250 (75.6 days), 584,497 transactions and 241,314 boundary events, read from public lightwalletd servers and parsed by our own transaction parser. Report generated 12 Oct, 16:47 UTC.
| File | Contents | Size |
|---|---|---|
| summary.json | headline counts | 2 KB |
| days.json | per-day series | 17 KB |
| links.json | linked pairs drawn on the Observatory | 2,509 KB |
| examples.json | the most telling finding of each kind | 38 KB |
| event-index.json | every boundary event (amount, height, direction, pools), for pre-flight | 6,830 KB |
| boundary-addresses.json | every transparent address seen at a boundary | 3,135 KB |
| findings/xx.json | all findings, sharded by the first byte of each txid | 256 files |
Everything is reproducible: the indexer writes one JSON line per transaction, and engine/scripts/report.ts turns them into these files. Source: github.com/AdityaWisnuu/leakscope.
This site's own privacy
- Static files only: no server code, no analytics, no cookies, no third-party scripts or fonts at runtime.
- Check requests one shard named by the first byte of the txid (shared by about 1/256 of all transactions), or every shard in paranoid mode. The txid stays after the
#in the URL, which browsers never send. - Pre-flight downloads the same public index for everyone and computes locally.
- Scan keeps the viewing key inside a WebAssembly module in the tab. Blocks come from a public proxy that learns only the height range and your IP address.
- Explorer links are opt-in and labelled: following one tells that explorer which transaction you care about.