← All blogs

Demixing Tornado Cash Transactions

11 min readforensics · ethereum · privacy · tooling

Hi, In this blog I will be explaining how on-chain analysts demix Tornado Cash transactions — linking a deposit to the withdrawal it funded — and walking through a tool I built to do it. We will go through the heuristics one by one, look at why the obvious way to combine them is wrong, and then run the whole thing against a real deposit on mainnet.

One thing to get out of the way first, because it is the thing people get wrong about demixing: no cryptography is broken here. Tornado Cash's zk-SNARKs work exactly as advertised. Given a withdrawal, every deposit of the same denomination remains an equally valid explanation, and nothing in this post changes that. What leaks is everything the user did around the proof — when they deposited, how many notes they bought, what they paid for gas, who they had dealt with before, where the money went next. That behaviour is public, and it is enough.

How Tornado Cash works

The protocol is simple, which is part of why it worked so well.

You deposit a fixed amount — 0.1, 1, 10 or 100 ETH, or one of the ERC-20 pools — into a shared contract, along with a commitment: a hash of a secret only you hold. The contract adds your commitment to a Merkle tree. Later, from a completely fresh address, you submit a zk-SNARK proving you know a secret matching some leaf in that tree, plus a nullifier that stops you spending the same note twice. The contract checks the proof, checks the nullifier is unused, and pays out.

At no point does the contract learn which deposit you are. Neither does anyone reading the chain.

Four deposits enter a 1 ETH pool and four withdrawals leave it. The proof confirms a valid deposit exists but never says which one.

Fixed denominations are what make this work. If you could deposit 3.7194 ETH and withdraw 3.7194 ETH, the amount alone would give you away. By forcing everyone into the same sizes, every note in a pool looks identical to every other. That set of indistinguishable notes is the anonymity set, and it is the number that actually matters.

The gap the analysis lives in

Here is the important part. The proof protects the link between one deposit and one withdrawal. It does not protect anything about how you behaved while making them:

  • It does not stop you withdrawing back to the address you deposited from.
  • It does not stop you buying four notes in one sitting and withdrawing four notes to one address.
  • It does not stop you using the same wallet's gas settings on both sides.
  • It does not stop you consolidating five fresh addresses into one a week later.

Every one of those is a choice made by a person, not a weakness in a circuit. Demixing is reading those choices.

A timeline of a depositor buying three 1 ETH notes 96 seconds apart plus one 10 ETH note, then one address collecting four 1 ETH and three 10 ETH withdrawals a day later, with the behavioural signals labelled.

The heuristics

These are the signals my tool scores. None of them is a proof of anything on its own.

1. Address reuse

The withdrawal goes back to the address that made the deposit. This is not a lead, it is a link — and people still do it constantly. It is the first thing to check and the only signal I treat as decisive.

2. Denomination profile

Someone deposits 3 × 1 ETH and 1 × 10 ETH in one sitting. Now look for an address that received exactly that mix. Using two pool sizes together is far more distinctive than using either alone, because you are matching a shape rather than a number. This is the strongest behavioural signal in the set.

3. Deposit count

A burst of deposits of the same denomination, close together in time — someone buying several notes at once. (The literature calls this a "voucher"; the tool calls it a session, which is easier to read on a screen.) Buy four notes and you have four notes to withdraw, and people overwhelmingly withdraw them to one or two addresses. Counts that match are worth a lot; counts that match and are unusual are worth more.

4. Gas fingerprints — with a large caveat

Two transactions bidding an identical, unusual gas price suggests one wallet with one configuration. The academic work on this (Tutela, linked at the bottom) calls it the unique gas price heuristic.

But here is a correctness trap I walked straight into when I first wrote this, and it is worth spelling out: a relayed withdrawal's gas was paid by the relayer, not the user. If someone withdrew through a relayer — which is the normal case, because a fresh address has no ETH to pay for its own gas — then the gas price, the transaction type and the priority fee all fingerprint the relayer's infrastructure. Comparing them to the depositor's wallet is meaningless.

The first version of my tool scored those pairs anyway, and quietly filtered out real candidates because a stranger's relayer happened to use a different fee. Now the gas signals report "not applicable" for relayed withdrawals and stay out of the maths entirely. Refusing to answer is a different thing from answering no, and a scoring model has to know the difference.

5. Timing

How long the money sat in the pool, decayed on a three-day half-life, plus whether both transactions fall in the same part of the depositor's day. Weak signals individually — plenty of people withdraw quickly — but they sort a big candidate list usefully.

6. Relayer overlap

Has the depositor dealt with this relayer before, outside Tornado? Modest signal, cheap to compute.

7. Asset round trips

This one comes from watching how stolen funds actually move. Someone holding a token they want to keep cannot mix it directly if there is no pool for it, so the cycle is: sell the position for ETH, push the ETH through Tornado, withdraw to a fresh address, buy the same token back.

Each half is unremarkable on its own — people swap constantly. What is not unremarkable is the pairing: an address moving out of a token in the days before a deposit, and a different address moving back into that same token in the days after a withdrawal. It is a round trip through the mixer, and the token identity survives it even though the amount does not.

The strength depends entirely on what the token is, and this is where the signal earns its weight or loses it:

  • A stablecoin round trip is weak. Selling USDT before and buying USDT after is a pattern shared by an enormous number of addresses. Worth noting, not worth acting on.
  • WBTC or LINK is better. Fewer addresses hold them, and fewer still cycle out and back inside a fortnight.
  • A thin-liquidity or governance token is the interesting case. If an address sells a token with a few hundred holders, mixes, and a fresh address buys that same token back a week later, coincidence is a stretch. This is the shape that shows up after a protocol exploit: the attacker holds the thing they stole, cannot mix it directly because no pool exists for it, and has to route through ETH in both directions.

The tool scores accordingly — a common asset counts for about half of an obscure one.

The same reasoning covers protocols: two addresses using the same uncommon contracts is worth a nudge. Uniswap and 1inch are excluded outright — everyone uses them, and counting them would recreate exactly the false positive described below.

8. Downstream convergence

The split-exit tell. A user withdraws to five fresh addresses to look like five users, then a week later forwards all five to one place. Follow each candidate one hop and look for a destination the depositor already uses.

Combining them is where it gets interesting

I had all the heuristics working and the tool was still producing nonsense. The problem was not the signals, it was how I was adding them up.

My first version took a weighted average of the signals. That is the obvious approach, and it is wrong in a way that is worth understanding.

Most people do not withdraw to the address they deposited from. So address reuse — the heaviest-weighted signal — scores zero for almost every genuine candidate. Average it in and every honest lead gets dragged toward nothing. The tool was confidently rating real matches at 37%.

Absence of the strongest evidence is the normal case, not evidence of absence.

So I threw that out and rebuilt it around likelihood ratios:

  1. Start from the honest prior. If 42 withdrawals of that denomination left the pool during the search window, any one of them is your deposit with probability 1 in 42. That is the anonymity set, and every result is reported against it.
  2. Multiply by what the behaviour says. Each signal that fires multiplies the odds by its likelihood ratio, raised to how strongly it fired. A signal that does not fire multiplies by one and changes nothing at all.
  3. Normalise across the candidate set. This one took me a second pass to get right. Candidates are not independent claims, they are competing ones — only so many of these withdrawals can belong to one depositor. Score each in isolation and you get four addresses at 90%, which is four confident answers to a question that has one. So the weights are scaled to sum to the number of notes the depositor actually bought.
The scoring model: a 2.4 percent prior multiplied by likelihood ratios of 80, 8.5 and 2.8, then normalised across all candidates to give 47 percent.

Two properties fall out of this that I like. With no evidence at all, every candidate lands exactly back on 1/N — the tool tells you it has nothing, instead of inventing a ranking. And because the prior is baked in, the same 5% means different things in different pools, so the labels ("strong lead", "noise") are graded on lift over the prior rather than the raw number. 5% in a pool of 900 is a 45× lift and the best thing you will see that day. 5% in a pool of 12 is below chance.

One more honest note about what the number means. Because notes of one denomination are interchangeable, the score answers "does this withdrawal belong to the depositor", not "does it belong to this exact deposit". The depositor is what an investigation is after anyway.

Running it on a real deposit

Let us take a real one. This deposit put 1 ETH into the pool in March 2025:

0xa62f69b29b866307afd4ea5b930df39c44009c842c4b61da0e80bbb7e33fa105
node src/cli.js 0xa62f69b2…fa105 --window 3d --top 5

The first thing the tool does is read the depositor's history, and that alone is interesting:

Deposit
  1 ETH from 0x43e04e72fb81d81157b89d7bd8c1a8f200aba596
  block 22072719, 2025-03-18T08:57:35.000Z
  session: 3 × 1 ETH deposited within 2 min
  denomination profile: 1× eth-10, 3× eth-1

Three 1 ETH notes bought 96 seconds apart, plus a 10 ETH note in the same sitting. Before looking at a single withdrawal we already know the shape of what we are hunting: three 1 ETH notes and one 10 ETH note that have to come out somewhere.

In the three days that followed, 42 withdrawals of 1 ETH left that pool. That is the anonymity set — a 2.4% prior on each.

Ranked recipients   (share of the depositor's notes, prior = 2.4%)

   1.  41.3%  Strong lead    0xbde4971debabf1846170c32840e89a620020577d  2× 1 ETH
   2.  21.3%  Moderate lead  0xaa5bda9c85a5446f08b613fc767c0443a1f367d1  2× 1 ETH
   3.  20.9%  Moderate lead  0xc35b8540a136c54d6670be25c4fe2f00719b7045  2× 1 ETH
   4.  19.9%  Moderate lead  0xf126b8d11596321b615e690a15af9f18a6e1d3e9  4× 1 ETH
   5.  10.2%  Moderate lead  0x204f75f7232e1bca54b264c822dc567e869cab3d  1× 1 ETH

And the evidence behind the top one:

Why 0xbde4971debabf1846170c32840e89a620020577d is first
  ×26.7   Denomination profile     Partial denomination overlap, nothing extra
                                   (eth-10: deposited 1, received 1; eth-1: deposited 3, received 2).
  ×8.5    Deposit count match      Depositor made 3 deposits of 1 ETH; this address received 2.
                                   The rest may have gone elsewhere.
  ×2.1    Timing proximity         Withdrawn 2.7 days after the deposit.
  ×1.0    Unaccounted withdrawals  Everything this address received is accounted for.
  —       Gas price fingerprint    Not applicable.
  —       Priority fee fingerprint Not applicable.

This address took one 10 ETH note and two 1 ETH notes — a subset of what the depositor put in, with nothing extra. 41% against a 2.4% prior is a 17× lift, and the remaining note plausibly went to a second wallet, which is exactly what split exits look like.

Notice the two "not applicable" lines. Every withdrawal in this window went through a relayer, so there is no user gas to fingerprint. The tool says so instead of scoring them zero.

The address that used to be top of this list

When I first ran this case, the answer was a different address: 0x8b03f94c…, which had collected 4 × 1 ETH and 3 × 10 ETH in the window. It matched both halves of the fingerprint and scored 47%. I wrote it up as the find.

Then look at the counts again. The depositor put in 3 × 1 ETH and 1 × 10 ETH. That address holds four 1 ETH notes and three 10 ETH notes — three more notes than went in. Whoever they are, at least three of those notes were funded by somebody else's deposits.

That is not a better match. It is a worse one. An address holding more than the depositor put in is a shared address — a consolidation point, an exchange deposit address, a service — and as an answer to "where did this depositor's money go", a shared sink is close to useless. My scoring had it backwards: it took min(received, deposited) per pool, which silently caps excess at a perfect score and then printed the words "full denomination fingerprint" over a set that was nothing of the kind.

The fix has two halves. The denomination signal now only reaches full strength on an exact match, and a separate signal — unaccounted withdrawals — multiplies the odds down, once per note the depositor cannot account for:

  ×0.12   Unaccounted withdrawals  3 withdrawals the depositor never deposited
                                   (2 extra eth-10; 1 extra eth-1). Something else funded them.

0x8b03f94c… drops from 1st to 7th, from 47% to 4%. The address that replaces it holds a clean subset with nothing extra.

This is also the first signal in the tool that argues against a candidate, which the scoring had no way to express before — every likelihood ratio was above 1, so evidence could only ever push a candidate up.

One caveat matters enough to be built in. "The depositor never deposited that" is only as good as the history I can read, and I cap that at 300 transactions. If the cap is hit, the depositor may well have made the extra deposit somewhere I cannot see, so the penalty is halved and the run says so in its warnings. There is also a strict mode that removes such addresses from the candidate list entirely — off by default, because a filter that hides the right answer is worse than a score that ranks it second.

The browser version of the tool showing the ranked recipients and the per-signal evidence for the top result.

Now the part where I was nearly fooled

While testing this exact case I got a completely different answer. A candidate that should have been mid-table came out on top at 99%, on the back of this:

  ×60.0   Downstream convergence   Funds moved on to 0xdac17f958d2ee523a2206206994597c13d831ec7,
                                   an address the depositor also uses.

That address is USDT. It is the Tether contract. Half of Ethereum "also uses" it.

The bug was in how I built the depositor's list of known counterparties: I was taking every address they had ever sent a transaction to, which sweeps up token contracts, DEX routers and every other bit of infrastructure. Two addresses both touching USDT is not a relationship.

The fix is a one-line test with a good reason behind it — only count plain value transfers, transactions with no calldata. A person paying a person is a relationship; a wallet poking a contract is not. Nothing real is lost either, because an ERC-20 transfer between two of your own addresses shows the token contract as the destination in a transaction list anyway.

With that in, the spurious candidate drops from 1st to 5th and the genuine denomination match goes back to the top. That is the whole reason the tool prints its evidence line by line: a score you cannot audit is a score you cannot catch out.

Manual analysis: what to check once you have a shortlist

A ranking is a place to start, and the gap between "this address is 41%" and "this address belongs to the person I am investigating" is entirely manual. So the tool now prints, for every candidate, the facts it observed rather than only the score it derived from them. Each line is something you can take to an explorer and check yourself.

What arrived. The counts per pool. The question to ask is not "does this overlap" but "does this account for what went in, and nothing more". An address holding notes the depositor never deposited is shared, and a shared address is a worse answer than a clean one.

Who paid the gas. This decides which questions are even askable. A self-relayed withdrawal was signed by the user's own wallet, so gas price, priority fee, transaction type and nonce all fingerprint them — compare those against the depositor directly. A relayed withdrawal fingerprints the relayer's infrastructure, and reading anything into it is a mistake. The tool separates the two and tells you which you are looking at.

The relayer and the fee. Relayer services charge characteristic rates. A candidate using a relayer the depositor has dealt with before is worth a second look; an unusual fee ratio can identify the service.

How long the money sat. Minutes is careless. A long, irregular wait is discipline, and discipline is why most pools stay anonymous.

What was bought afterwards. The other half of a round trip. If the depositor sold WBTC before depositing and this address bought WBTC after withdrawing, that is the pattern — and if the asset is obscure, it is close to a signature.

Where the money went next. Follow every destination. Several candidates converging on one address is one user pretending to be several, and that reconvergence is usually more decisive than anything at the withdrawal itself.

Which contracts they use. Habits travel between addresses: a niche protocol, a particular NFT marketplace, a specific bridge. Ignore Uniswap, 1inch and the other ubiquitous routers — everybody uses those, and counting them makes every address look related to every other.

Three things the tool cannot see and you should check yourself:

  1. How old the address is, and what funded its first transaction. A self-relayed withdrawal needs gas before it can withdraw, which means somebody sent that ETH. That funding transaction is frequently the loose thread the whole thing unravels from.
  2. Attribution and sanctions listings. A candidate already attributed elsewhere is either strong confirmation or a sign you have found a service rather than a person.
  3. Anything off-chain. ENS names, socials, forum posts, marketplace profiles, previous incident write-ups.

And then the discipline that separates an analyst from someone with a list: write down what would disprove the link, and go looking for it. If nothing could change your mind, the ranking has not told you anything.

The tool

Two front ends, one engine.

In your browsersm4rty.xyz/tools/tornado-demixer. No install, no account, no API key. It reads the chain from a public block explorer directly from your browser; nothing passes through my server, because there is no server involved.

On the command linegithub.com/Sm4rty-1/tornado-demixer. Same engine, for wider windows, CSV and JSON output, and scans too long to sit through in a browser tab. Node 20.9 and nothing else — there is nothing to install:

git clone https://github.com/Sm4rty-1/tornado-demixer
cd tornado-demixer

node src/cli.js <deposit-tx-hash> --window 30d --csv candidates.csv
node src/cli.js --check --source rpc --rpc-url https://…   # test a data source

A few things I would call out about the build:

  • Zero dependencies. The analysis engine uses nothing but fetch, which is what lets the exact same files run in Node and in a browser with no build step. The website vendors that directory verbatim rather than reimplementing it, so the two cannot drift apart in their scoring.
  • All 19 pools, ETH and ERC-20, each address verified on-chain rather than copied from a list.
  • A cheap pass and a deep pass. One log query gets recipients, relayers, fees, timing and gas prices for a whole window — the explorer returns those inline. Only the top candidates get per-transaction lookups. A three-day window costs about 24 requests.
  • The likelihood ratios are constants in one file, so if you think my number for deposit counts is too generous you can change it and re-run.
  • Every signal can be switched off before a scan, split into the main indicators that decide a ranking and the optional ones that only nudge it. If a result rests entirely on one signal, turn it off and see whether the result survives — that is the cheapest sanity check there is.

What it will not do

I want to be straight about the limits, because tools like this attract more confidence than they deserve.

These are leads. A strong lead is somewhere to start looking. Before attaching a name to an address, corroborate with something outside these heuristics entirely.

Disciplined users are invisible, correctly. Wait a random and long time, never reuse an address, always use a relayer, stick to one denomination, do not consolidate — and there is nothing here to find. The tool will tell you so.

Coincidence is real. In a busy pool, some address will match your deposit count by chance. The tool reports how many signals could actually speak, and a confident number resting on two of them deserves suspicion.

The window is a judgement call. A withdrawal outside your window is invisible; widening it enlarges the anonymity set and lowers every score. That tension does not have a right answer, it is the analysis.

On ethics

Tornado Cash was sanctioned by OFAC in 2022 and delisted in March 2025, and the legal picture has kept moving since. Privacy tooling has entirely legitimate users, and nothing in this tool can tell them apart from anyone else — it ranks addresses, it does not judge them.

I wrote it for compliance work, incident response, stolen-fund tracing and research, which is most of what on-chain forensics actually is. If you are building a case against a person with it, the burden of corroboration is yours.

The broader point is the one worth taking away: mixers are an arms race between protocol design and user behaviour, and it is nearly always the behaviour that loses. The cryptography did its job perfectly in every example in this post.

Credits & References

Papers Tutela: An Open-Source Tool for Assessing User-Privacy on Ethereum and Tornado Cash — the address-match and unique gas price heuristics Clustering Deposit and Withdrawal Activity in Tornado Cash: A Cross-Chain Analysis — the same problem at scale, across chains

Tools worth knowing pareto-xyz/tutela-app — the reference implementation tav-r/tornado_cash_heuristics lambdaclass/tornado_cash_anonymity_tool pcaversaccio/tornado-cash-ether-withdrawal-decipherer retrace by 0xKoda — withdrawal-side intelligence, useful for the "what happened after the mixer" half Chainalysis Reactor, Elliptic Investigator, TRM Forensics — the commercial equivalents

Reading Unmasking the Mixer: How On-Chain Sleuths Demix Tornado Cash Transactions by OfficerCia — a good survey of the wider tooling, and where I first saw the voucher and split-exit framing laid out clearly Awesome On-Chain Investigations HandBook by OfficerCia De-mixing TornadoCash by Flipside, AMLBot and PureFi

Thanks for Reading. Any Suggestions are always welcomed!!

Connect with me:

Twitter LinkedIn Github Telegram