Skip to content
engineering
·89 min read

What Happens When You Send a Private Swap

A complete walkthrough of a private DeFi transaction — from wallet intent through mixnet routing to on-chain settlement and anonymous reply.

What Happens When You Send a Private Swap

End-to-end: from intent to settlement, against an adversary who is watching everything.


In May 2024, a Dutch court sentenced Alexey Pertsev to 64 months in prison for building Tornado Cash [1]. Two weeks earlier, the DOJ arrested the founders of Samourai Wallet for building a Bitcoin mixing tool [2]. Neither developer stole funds. Neither operated the protocols for personal gain. They wrote software that let people transact privately, and they went to prison for it.

Meanwhile, on-chain investigator ZachXBT routinely traces funds through these same privacy tools in near-real-time. He tracked $282 million from a single wallet compromise through cross-chain bridges and into Tornado Cash, identifying the attacker's methodology within hours [3]. The uncomfortable conclusion: the tools that got developers imprisoned do not actually protect the users they were built for. Tornado Cash's ZK proofs are cryptographically sound, yet 20-35% of its users are deanonymizable through timing and behavioral metadata alone [4].

The problem is not cryptography. The problem is everything around the cryptography: IP addresses, timing patterns, gas price fingerprints, RPC logs. Metadata.

This post walks through exactly how NOX the DeFi-native mixnet built by Xythum Labs addresses the full stack. Not just the ZK proof layer, but the transport, the gas sponsorship, the economics, and the response delivery for a single private swap. Alice wants to swap 1 ETH for USDC on Uniswap V3. She does not want anyone to know it was her. NOX acts as her paymaster: the relayer fronts gas, the mixnet hides her identity, and the ZK-UTXO pool handles anonymous reimbursement. Here is how that works, in excruciating detail.

What follows is a 12-step walkthrough of a private swap, covering every layer from wallet to blockchain and back. At each step, we will be precise about what metadata is exposed, to whom, and what remains hidden. We will enumerate adversary types, quantify their capabilities, trace the economic flow with real dollar figures, analyze failure modes, and compare the privacy properties to the same swap on a standard DEX. If you have read Part 3 on the mixnet architecture, this is where that architecture meets real-world DeFi. If you have not, this post is self-contained -- we explain each component as it appears in the flow.


The Adversary Model

Before we trace Alice's swap, we need to be precise about who is trying to deanonymize her. Privacy systems that hand-wave about "adversaries" tend to collapse the moment someone actually tries to break them. If your threat model is "bad guys," your system will be broken by anyone who applies systematic analysis. The academic literature defines several adversary classes with precisely characterized capabilities, and our design must account for all of them.

We enumerate seven adversary types below. For each, we describe their real-world capabilities, cite evidence of their existence and effectiveness, and preview what they can and cannot learn from Alice's swap. The detailed per-adversary analysis comes at the end of this post, after we have walked through the full transaction flow.

The Global Passive Adversary (GPA)

An entity that can observe all network links simultaneously -- every packet entering and leaving every node. Nation-state intelligence agencies approximate this capability. The NSA's XKeyscore system operates over 700 servers at ~150 locations worldwide, processing 20+ terabytes daily [5]. GCHQ's Tempora program intercepted fiber-optic backbone traffic at ~50 billion events per day [6].

The anonymity trilemma, formally proven by Das et al. at IEEE S&P 2018, establishes that no anonymous communication protocol can simultaneously achieve strong anonymity, low bandwidth overhead, and low latency against a GPA [7]. This is a mathematical impossibility, not an engineering gap. You must pick two of three. We chose strong anonymity and low bandwidth overhead, paying the price in latency (Poisson mixing adds stochastic delays at each hop).

What can a GPA learn about Alice? They can see that Alice sent an encrypted packet to the entry node at time t₁, and that the exit node submitted a transaction at time t₂. The question is whether they can correlate these two observations with high confidence. In Tor, the answer is yes -- DeepCorr achieves 96% flow correlation [16]. In a Loopix-style mixnet with Poisson mixing and cover traffic, the correlation problem is fundamentally harder. We will quantify this precisely later.

The Partial Active Adversary

An entity that controls some fraction of mix nodes. This is not hypothetical: Nusenu documented a single threat actor controlling up to 27.5% of all Tor exit capacity in 2021 [8]. KAX17 operated approximately 1,000 Tor servers across 50+ autonomous systems, assessed by Bruce Schneier as likely a nation-state actor [9].

In a mixnet, a partial active adversary who controls nodes on a user's path can correlate input and output traffic. The worst case is controlling both the entry and exit node, which gives them both Alice's IP address and the decrypted payload. The intermediate mixing layer is the only defense -- if the adversary does not control the mix node, Poisson mixing provides statistical unlinkability.

Our topology uses a 3-layer stratified design: Entry -> Mix -> Exit. For the adversary to fully deanonymize Alice, they need to control her specific entry node AND her specific mix node AND her specific exit node. If they control 20% of nodes in each layer, the probability of controlling all three nodes on a given path is 0.2³ = 0.8%. Cover traffic and path rotation reduce this further, but the fundamental protection comes from the stratified topology ensuring the adversary must control nodes at every layer.

The Network-Level Adversary

ISPs and autonomous systems that can observe IP-to-IP connections. Nithyanand et al. measured that up to 85% of Tor circuits are vulnerable to a single state-level AS adversary, rising to >95% in China and Iran [10]. For blockchain users specifically, a 2025 study demonstrated >95% deanonymization accuracy against RPC users through passive TCP timing analysis alone, without breaking TLS [11].

What makes the network-level adversary particularly dangerous for DeFi is the tight timing correlation between user activity and chain state changes. When Alice swaps 1 ETH for USDC, the Uniswap pool's reserves change. If her ISP sees an outbound connection to an Ethereum RPC node at the same timestamp, the correlation is trivial.

In Xythum, Alice never connects to an RPC node. She sends a Sphinx packet to the entry node, which is indistinguishable from cover traffic, other users' transactions, HTTP browsing through the mixnet, or loop-back monitoring messages. Her ISP sees "Alice sent encrypted traffic to the mixnet" -- identical to what every other mixnet user's ISP sees, regardless of what the user is doing.

The Chain Surveillance Adversary

Firms like Chainalysis, whose address clustering was validated at 99.9146% accuracy in the Bitcoin Fog trial [12]. ZachXBT's work demonstrates that even with imperfect tools, behavioral correlation -- deposit/withdrawal timing, cross-chain patterns, exchange interactions -- suffices to trace funds through privacy protocols [3].

The chain surveillance adversary sees what lands on Ethereum: a transaction from the relayer's address, calling the DarkPool contract with a ZK proof. They can see that someone swapped 1 ETH for USDC. They cannot see who. The anonymity set is the entire pool -- every user who has ever deposited into Xythum is a plausible candidate.

This is qualitatively different from Tornado Cash, where 20-35% of users are linkable through deposit/withdrawal timing patterns [4]. In Tornado Cash, you deposit a fixed denomination (0.1, 1, 10, or 100 ETH), wait some time, and withdraw the same amount. The timing delta, the denomination match, and the behavioral patterns (e.g., depositing from Coinbase, withdrawing to a fresh address that immediately interacts with the same DeFi protocols) create correlation opportunities.

Consider the specific linkage attacks against Tornado Cash that Cristodaro et al. documented [4]:

  1. Unique denomination attack: If only 3 users deposited exactly 100 ETH in a given week, the anonymity set for each withdrawal is 3, not thousands.
  2. Timing correlation: A deposit at 14:07 and a withdrawal at 14:12 from the same pool narrows the candidate set dramatically.
  3. Gas price fingerprinting: Users tend to use similar gas prices for deposits and withdrawals, creating a behavioral fingerprint.
  4. Address reuse patterns: Many users withdraw to addresses that later interact with the same DeFi protocols their deposit addresses used.

In Xythum, none of these attacks apply. The deposit, the swap, and the withdrawal are all separate operations with separate ZK proofs, separate nullifiers, and no amount correlation. There are no fixed denominations -- Alice can deposit any amount. There is no timing correlation between deposit and swap -- Alice deposited weeks ago. The gas price is not a fingerprint because Alice does not pay gas from her wallet. And there is no address to reuse because Alice has no on-chain address.

The MEV Searcher

A specialized adversary unique to DeFi. MEV searchers monitor the mempool for pending transactions and front-run, back-run, or sandwich them. In 2023, Flashbots reported over $686 million in cumulative MEV extraction on Ethereum [23].

Against Alice's swap, an MEV searcher cannot sandwich attack because they cannot see the swap details until the relayer submits the transaction. By then, the transaction includes a minimum output amount (Alice set min_out: 3,850 USDC) that protects against unfavorable execution. The MEV searcher sees the relayer's transaction in the mempool, but the swap parameters are inside the adaptor calldata -- by the time they decode it, the atomic execution of the RelayerMulticall leaves no room for insertion.

More importantly, the MEV searcher does not know who they would be extracting value from. Even if they could somehow front-run the swap (e.g., by running a validator), they would extract value from an anonymous pool member, not from Alice specifically. This is a privacy gain, even if it does not fully eliminate MEV.

The RPC Provider Adversary

MetaMask has been logging IP + wallet address pairs since its Infura privacy policy update in November 2022 [17]. Every eth_sendTransaction call through Infura leaks your IP address and your wallet address to ConsenSys. This is not speculation -- it is their documented policy.

Alice never touches an RPC provider. She never calls eth_sendTransaction. She does not even call eth_call or eth_getBalance directly. All her blockchain interactions go through the mixnet: she sends a Sphinx packet containing an RPC request, the exit node executes it against its local Ethereum node, and the response comes back via SURBs. The exit node sees the RPC request, but does not know who sent it. The RPC provider (if the exit node uses one) sees the exit node's IP, not Alice's.

The Compromised Mix Node Adversary

An adversary who controls one or more mix nodes can observe the packets they process: what came in, what went out, and the timing of each. If they control a mix node on Alice's path, they see one input packet and one output packet that they know are related (because they processed the transformation). But crucially, they do not learn Alice's identity or the payload -- they only learn one hop's routing information.

The most dangerous variant: the adversary controls Alice's mix node AND either her entry or exit node. With entry + mix, they know Alice's IP and the timing of the packet at the mix layer, which narrows the candidate set at the exit node. With mix + exit, they know the payload content and the timing at the mix layer, which narrows the candidate set at the entry node. In either case, one independent layer still separates them from full deanonymization.

What the Research Says About Mixnet Attacks

Even purpose-built mixnets are not immune. Oldenburg et al.'s MixMatch (PoPETs 2024) achieved ~0.6 true positive rate at 1% false positive rate against Nym's live network [13]. Attarian et al.'s MixFlow demonstrated ~90% accuracy correlating chat messages through Loopix-based mixnets even with cover traffic [14]. These are the attacks our system must withstand.

The key insight from MixMatch is that flow-level features (packet count, inter-packet timing, burst patterns) remain partially visible even through mixing. The key insight from MixFlow is that contrastive learning can extract patterns from cover traffic noise. Both attacks are more effective with longer observation windows and less cover traffic.

Our mitigations: short-lived transactions (single Sphinx packet per operation, not a persistent flow), high cover traffic ratio (Type A free traffic provides the noise), and path rotation (each operation uses a fresh random route). These do not eliminate the attacks -- they reduce the adversary's advantage to the point where meaningful confidence requires impractical observation durations.

It is important to be honest about what this means in practice. Against a well-funded adversary (nation-state) with sustained access to a significant fraction of the network, our privacy guarantees degrade over time. An adversary who observes the network for months can accumulate enough statistical evidence to narrow anonymity sets, especially for habitual users with distinctive patterns. The defense is not mathematical certainty -- it is economic and operational: making deanonymization expensive enough that it is reserved for high-priority targets rather than mass surveillance. For most DeFi users, this is sufficient. For a user facing a nation-state adversary specifically targeting them, no software-only solution is sufficient -- operational security (Tor, VPNs, physical separation) becomes necessary in addition to protocol-level protections.

We do not claim invulnerability. What we claim is defense in depth: even if one layer is partially compromised, the other layers maintain meaningful privacy guarantees. The combination of ZK proofs (hide what), mixnet (hide who), relayer (hide where), and SURBs with FEC (hide that you received a response) creates a system where deanonymization requires simultaneous compromise of multiple independent systems.


The ZK-UTXO Model: How Xythum Stores Value

Before Alice can swap anything, she needs to understand how Xythum stores value. This is fundamentally different from account-based models (Ethereum, Railgun) and fixed-denomination models (Tornado Cash).

The Note Structure

Every unit of value in Xythum is a Note -- an unspent transaction output (UTXO) with 6 scalar fields:

Note {
    asset_id:    Field    // ERC-20 token address as a BN254 field element
    value:       Field    // Token amount (in smallest units)
    secret:      Field    // Random blinding factor (owner's secret)
    nullifier:   Field    // Anti-double-spend key (0 for received transfers)
    timelock:    Field    // Unix timestamp lock (0 = no lock)
    hashlock:    Field    // Poseidon2 hash lock for conditional claims (0 = no lock)
}

These 6 fields are identical in Noir (the ZK circuit language), TypeScript (the wallet SDK), and Rust (the NOX client). Cross-language parity is enforced by vector generation scripts that produce test inputs in TypeScript, paste expected outputs into Noir unit tests, and fail loudly if a single byte differs.

When Alice deposited 1 ETH into Xythum (she did this earlier, perhaps weeks ago), the deposit circuit created a Note with asset_id = WETH_ADDRESS, value = 1e18, a random secret, a random nullifier, and zeros for timelock and hashlock. This note was encrypted, packed into 7 BN254 field elements, committed to the Merkle tree, and published on-chain as an event. Only Alice (and the compliance key holder) can decrypt it.

The Commitment Scheme: Why We Commit Over Ciphertext

Here is where Xythum diverges from most UTXO privacy systems. The Merkle leaf commitment is:

commitment = Poseidon2(packed_ciphertext[0], packed_ciphertext[1], ..., packed_ciphertext[6])

That is: a hash of the ciphertext, not the plaintext.

Why does this matter? In most privacy systems, the commitment is computed over plaintext fields: Hash(asset, value, secret, nullifier). This means the commitment is independent of the encryption -- you can verify ownership by demonstrating knowledge of the plaintext fields.

Our approach is different: the commitment is bound to the encryption itself. To spend a note, you must prove (inside the ZK circuit) that you can re-encrypt the plaintext with the correct shared secret and produce the exact same ciphertext that was committed to the tree. This creates a tighter binding between the note's encrypted representation and its Merkle leaf.

The encryption pipeline is:

Note (6 fields, 192 bytes)
  -> AES-128-CBC encrypt with PKCS#7 padding -> 208 bytes ciphertext
  -> Pack into 7 BN254 field elements (31 bytes each, last field 22 bytes)
  -> Poseidon2(packed[0..7]) -> commitment (single field element)

The AES key and IV are derived from an ECDH shared secret via domain-separated Poseidon2 KDF:

AES_key = Poseidon2("xythum.enc_key" || shared_secret)  -> 16 bytes
AES_iv  = Poseidon2("xythum.enc_iv"  || shared_secret)  -> 16 bytes

A critical detail: PKCS#7 padding always adds a full 16-byte block when the plaintext is already block-aligned. 192 bytes = 12 AES blocks, so we get 208 bytes of ciphertext, not 192. Getting this wrong makes notes unspendable -- the commitment hash will not match, and the ZK proof will fail silently. We learned this the hard way. Twice.

The Merkle Tree: Lean IMT

All commitments are stored in a Lean Incremental Merkle Tree with depth 32 (supporting 2³² = ~4.3 billion UTXOs). The tree lives in MerkleTreeLib.sol and has one non-standard optimization:

If right sibling is empty (zero):
    parent = left child (no hash)
Else:
    parent = Poseidon2(left, right)

This "lean" semantics saves ~50% of hashing costs for sparse trees. The contract maintains a ring buffer of the last 100 Merkle roots, so a proof generated against root N is still valid when roots N+1 through N+99 are added. Without this buffer, a new deposit by any user would invalidate all in-flight proofs -- a griefing vector.

Alice's wallet maintains a local copy of this tree with identical lean semantics. If the local and on-chain trees disagree by even a single leaf, every proof Alice generates will fail. A historical mismatch between "lean" (SDK) and "standard" (contract) semantics caused weeks of silent proof failures. Both implementations now use the same test vectors.

Dual-Path Nullifiers

When Alice spends a note, she publishes a nullifier hash -- a unique value derived from the note that prevents double-spending. The derivation has two paths:

Path A (self-owned notes): Notes Alice created herself (deposits, change from swaps, split/join outputs). The note's nullifier field is a random non-zero secret set at creation time.

nullifier_hash = Poseidon2(note.nullifier)

Path B (received notes): Notes Alice received from someone else (private transfers). The sender cannot set a nullifier secret known only to Alice, so the nullifier field is set to zero. Instead, the nullifier is derived from the shared secret:

nullifier_hash = Poseidon2(shared_secret, commitment, leaf_index)

The spend.nr circuit enforces this deterministically: if note.nullifier != 0, use Path A; if note.nullifier == 0, use Path B. An attacker cannot choose which path to take -- the circuit checks.

How Scanning Works (The Discovery Problem)

After the swap, Alice needs to find her new note. But she cannot simply ask the chain "show me all notes belonging to Alice" -- that would reveal her identity. She has to scan every event and try to decrypt them.

For deposits (NewNote events): Alice's wallet checks if the event's ephemeral_pk matches any of her pre-generated keys. This requires scanning all deposit events -- O(N) globally.

For transfers (NewPrivateMemo events): The event includes an indexed recipientP_x field, which is the x-coordinate of b * C (recipient key times compliance key). Alice's KeyRepository pre-calculates expected tags for her active incoming keys. The ScanEngine queries the RPC for logs matching these specific tags, dropping discovery from O(Nglobal) to O(N_user_transactions). This is a deliberate privacy trade-off: the indexed field reveals that _some public key component received a transfer, but it enables mobile wallet performance.

The KeyRepository uses a gap limit of 20 pre-generated keys. When a key at index 5 is consumed, the window expands to index 25. Full wallet recovery from a seed phrase requires scanning from block 0 with this lookahead window.


The Full Picture

Here is the bird's-eye view of what happens:

                          Alice's Wallet
                               |
                    1. Build swap intent
                    2. Generate ZK gas proof (~9s)
                    3. Wrap in 32KB Sphinx packet
                    4. Compute PoW nonce (Blake3)
                               |
                               v
                    +-------------------+
                    |    Entry Node     |  <-- Verify PoW, check replay
                    +-------------------+     (Bloom filter: 10M cap, 0.1% FPR)
                               |
                    Poisson delay (~1ms mean)
                               |
                               v
                    +-------------------+
                    |     Mix Node      |  <-- Poisson delay, mix with cover traffic
                    +-------------------+
                               |
                    Poisson delay (~1ms mean)
                               |
                               v
                    +-------------------+
                    |   Exit / Relayer  |  <-- Unwrap -> intent + proof + SURBs
                    +-------------------+
                               |
                    5. Simulate TX (eth_simulateV1)
                    6. Check profitability (>= 10% margin)
                    7. Submit RelayerMulticall (~9.0M gas)
                               |
                               v
                    +-------------------+
                    |    Ethereum       |  <-- Verify ZK proof, execute swap
                    +-------------------+     Emit events, update Merkle tree
                               |
                    8. TX receipt + events
                               |
                               v
                    +-------------------+
                    |   Exit / Relayer  |  <-- FEC-encode receipt (11+4 shards)
                    +-------------------+
                               |
                    15 SURB responses traverse mixnet
                    independently (different timing,
                    different mixing delays)
                               |
                               v
                    +-------------------+
                    |    Entry Node     |  <-- Recognize SURBReplyCommand
                    +-------------------+     Forward to Alice
                               |
                               v
                          Alice's Wallet
                               |
                    9. Collect >= 11/15 shards
                    10. Reed-Solomon reconstruct
                    11. Decrypt with SurbRecovery keys
                    12. Update local Merkle tree + UTXO set

Let's walk through each piece.


Step 1: Building the Swap Intent

Alice's wallet constructs a DeFi intent -- a structured description of what she wants to do. For a Uniswap V3 swap, this looks like:

Intent {
    adaptor:     UniswapV3Adaptor address
    action:      ExactInput swap
    token_in:    WETH
    token_out:   USDC
    amount_in:   1.0 ETH
    path:        WETH -> USDC (single hop)
    min_out:     3,850 USDC (slippage protection)
    deadline:    current_time + 300 seconds
}

The wallet hashes this intent using Poseidon2 -- our circuit-friendly hash function -- to produce an intent_hash. Why Poseidon2 and not keccak256? Because the hash must be verified inside a ZK circuit. Keccak256 costs ~64,000 constraints in Noir. Poseidon2 costs ~600 constraints. The efficiency difference is two orders of magnitude.

For multi-hop swap paths (like WETH -> USDT -> USDC), the path is variable-length, but Poseidon2 inputs are fixed-size. The solution is a two-step compression:

path_hash = keccak256(encoded_path) % BN254_PRIME
intent_hash = Poseidon2(SwapType, path_hash, amountOutMin, ownerX, ownerY)

The % BN254_PRIME reduction is not optional. BN254's scalar field is ~2^254, and keccak256 produces 256-bit numbers. About 75% of keccak outputs exceed the field modulus. Without the modulo, Field.toField() reverts. We learned this from a production incident, not from foresight.

The ownerX and ownerY coordinates bind the swap to a specific recipient -- a BabyJubJub public key that identifies who can claim the swap output. This prevents parameter hijacking: an attacker who copies a valid proof cannot redirect the swap output to their own key.

For the four Uniswap V3 swap types, the intent hash structure varies:

Swap TypePoseidon2 Input Fields
ExactInputSingle(SwapType=0, assetIn, assetOut, fee, amountOutMin, ownerX, ownerY)
ExactInput(SwapType=1, keccak256(path) % PRIME, amountOutMin, ownerX, ownerY)
ExactOutputSingle(SwapType=2, assetIn, assetOut, fee, amountOut, amountInMax, ownerX, ownerY)
ExactOutput(SwapType=3, keccak256(path) % PRIME, amountOut, amountInMax, ownerX, ownerY)

The SwapType enum value provides domain separation -- a proof for an ExactInput swap cannot be replayed as an ExactOutputSingle swap, even if some fields coincidentally match. This is a standard trick from protocol design: include the message type in the hash to prevent type confusion attacks.

For ExactOutput swaps, Alice must withdraw more than the exact output amount (because the input amount is unknown until execution). The adaptor calculates refund = maxIn - actualIn and creates a second PublicMemo to return the dust to Alice. She claims both the output memo and the refund memo via separate public_claim proofs.

What metadata is exposed here? Nothing. The intent construction happens entirely inside Alice's wallet, on her device. No network traffic, no RPC calls, no external communication. The only artifact is a hash that will later be embedded inside a ZK proof.


Step 2: Generating the Gas Payment Proof

Here is the fundamental problem with private transactions: somebody has to pay gas. Gas payments are on-chain, which means they are public. If Alice pays gas from her personal wallet, she is deanonymized immediately. This is not a theoretical concern -- the TRAP attack demonstrates that passive observers can link RPC user IPs to blockchain pseudonyms with >95% accuracy just from TCP timing [11]. Paying gas from a known address is equivalent to signing your name.

Our solution: Alice generates a ZK proof that she owns a note (UTXO) in the Xythum privacy pool, and that note has enough value to cover the gas cost. The proof reveals nothing about which note she owns -- it just proves one exists.

What the Circuit Proves

The gas_payment circuit is structurally similar to a withdrawal, but instead of sending funds to a recipient, it deposits funds into the NoxRewardPool for a specific relayer. The circuit takes these private inputs (known only to Alice):

  • Her spending key
  • The note she is spending (all 6 fields: asset, value, secret, nullifier, timelock, hashlock)
  • A Merkle proof showing this note exists in the on-chain tree (32 sibling hashes)
  • The shared secret for her note's encryption (from ECDH with the compliance key)
  • The leaf index of her note in the tree
  • The change note she will get back (original value minus gas fee)

And produces these public outputs (visible to everyone, including the chain):

  • A nullifier hash (prevents double-spending this note)
  • The Merkle root (must match one of the last 100 on-chain roots)
  • The payment amount and asset
  • The relayer's Ethereum address (so only this relayer can claim the fee)
  • An execution_hash binding
  • The encrypted change note (7 packed ciphertext fields + ephemeral public key)

The Execution Hash Binding

The execution_hash is critical for security. It binds the gas payment proof to this specific transaction:

execution_hash = keccak256(target_address || calldata || fee_amount) % BN254_PRIME

Without this binding, a malicious relayer could take Alice's gas proof and attach it to a different action -- perhaps a transaction that benefits the relayer rather than Alice. The execution_hash makes the proof cryptographically bound to the exact swap parameters, the exact relayer, and the exact fee amount. Change any of these, and the proof verification fails on-chain.

The Ownership Proof (Inside the Circuit)

How does the circuit prove Alice owns the note without revealing which one? Through re-encryption matching:

  1. Alice provides the plaintext note and the ECDH shared secret as private inputs.
  2. The circuit re-encrypts the note: serialize (192 bytes) -> AES-128-CBC (208 bytes) -> pack (7 fields) -> Poseidon2 hash -> commitment.
  3. The circuit verifies that this commitment matches a leaf in the Merkle tree (via the inclusion proof).
  4. If the commitment matches, Alice must know the decryption key -- proving ownership.

This is the "commitment-over-ciphertext" design paying dividends. The circuit does not just check "do you know the plaintext fields?" -- it checks "can you reproduce the exact ciphertext that was committed to the tree?" This binds ownership to the specific encryption used, not just to the underlying data.

How Long Does This Take?

Benchmarked on an AMD Ryzen 7 9800X3D:

CircuitProof Generation TimeOn-Chain Gas Cost
gas_payment~9.0 seconds~5.0M gas
deposit~7.0 seconds~5.0M gas
transfer~11.0 seconds~6.1M gas
withdraw~8.0 seconds~4.2M gas

The 9-second proof generation is dominated by the UltraHonk backend in @aztec/bb.js v3.0.1. The circuit has ~65,000 constraints, including AES-128-CBC (the most expensive operation), Poseidon2 hashing, BabyJubJub scalar multiplication for ECDH, and the 32-level Merkle inclusion proof.

In the Rust NOX client, this proof generation happens via a Node.js subprocess: NoirProver spawns node prove_cli.mjs, which uses bb.js to generate the proof and returns it via stdout. This roundabout architecture exists because the bb CLI binary (the native Barretenberg prover) produces different verifier bytecode than bb.js (the JavaScript binding). Proofs generated by bb CLI will silently fail verification against Solidity verifiers generated by bb.js, and vice versa. The verifier bytecode is deterministic per tool, but the two tools produce incompatible bytecodes. The rule is simple: always use the same tool for both proof generation and verifier generation. Since our Solidity verifiers come from bb.js (via generate_verifier.js), all proofs must also come from bb.js.

The eventual solution is native Rust proof generation via barretenberg-rs or equivalent bindings. This would eliminate the Node.js subprocess overhead (~200ms startup per proof) and simplify deployment (no Node.js dependency on relayer nodes). The migration will happen when the Rust bindings are mature enough to produce verifier-compatible proofs.

Fee Calculation

Alice's wallet estimates the fee using the FeeManager:

gas_cost_wei   = gas_limit * gas_price
fee_with_premium = gas_cost_wei * (10000 + premium_bps) / 10000
fee_in_asset   = fee_with_premium * eth_price_usd / asset_price_usd

The default premium is 1200 basis points (12%) -- this gives the relayer comfortable margin above their 10% minimum profitability threshold. Alice pays slightly more than the gas cost. The relayer keeps the difference. Everyone is happy.

What metadata is exposed in this step? Still nothing external. Proof generation happens entirely on Alice's device. The proof itself reveals the public outputs listed above, but those will only become visible when the transaction is submitted on-chain -- and by then, the proof is inside a Sphinx packet, submitted by the relayer, with no link to Alice.


Step 3: Wrapping in a Sphinx Packet

Alice now has two things: the DeFi intent (with calldata) and the ZK gas payment proof. She needs to get them to the exit node without anyone along the way knowing what she is sending, where it is going, or that it came from her.

Building the Payload

Alice serializes the intent and proof into a RelayerPayload::SubmitTransaction:

RelayerPayload::SubmitTransaction {
    to:   RelayerMulticall_address,
    data: encode_multicall([
        Call { target: DarkPool, data: payRelayer(proof), requireSuccess: true },
        Call { target: UniswapAdaptor, data: executeSwap(...), requireSuccess: false },
    ]),
}

This payload is the cleartext that the exit node will eventually see. But between Alice and the exit node, it is wrapped in layers of encryption -- one layer per hop.

Creating SURBs

Before constructing the Sphinx packet, Alice creates SURBs (Single Use Reply Blocks) -- pre-built return paths so the exit node can send back the transaction receipt without knowing where Alice is.

Each SURB is a one-time-use encrypted routing structure. Alice picks a return path (Exit -> Mix -> Entry -> Alice), generates ephemeral keys for each hop, and constructs the SURB with encrypted routing commands. The exit node can use the SURB (by stuffing a response into it), but it cannot read the SURB (the routing is encrypted -- it only sees the first hop address).

For FEC redundancy, Alice creates 15 SURBs: 11 for data shards and 4 for parity shards. She saves the corresponding SurbRecovery keys locally -- she will need them to decrypt the responses when they arrive.

Constructing the Sphinx Packet

The complete Sphinx packet is exactly 32,768 bytes (32KB), fixed-size, and structured as:

[Header: 1024 bytes][Nonce: 12 bytes][Ciphertext + Tag: 31,732 bytes]

The header contains the ephemeral public key (32 bytes), encrypted routing info (variable, up to ~400 bytes), MAC (32 bytes), and a nonce (8 bytes). The remaining space is the encrypted payload: Alice's intent, proof, SURBs, and ISO/IEC 7816-4 padding (append 0x80, fill with 0x00) to fill the fixed packet size.

The encryption uses X25519 + ChaCha20Poly1305 for the routing layer (fast, high throughput) and a separate layer structure for the payload. Each hop peels one layer, re-blinds the group element, and discovers its routing command.

The key property of Sphinx [15] is bitwise unlinkability: the packet that enters a node and the packet that leaves are cryptographically unrelated. Different ephemeral key, different routing info, different MAC, different payload ciphertext. Even an adversary who sees both the input and output of a node cannot determine that they are the same packet (without breaking the DH problem).

To understand why this matters, consider what happens without bitwise unlinkability. In a naive onion routing scheme, the packet body might change (decryption), but the size stays constant and the header format is recognizable. An adversary who observes a 32KB packet entering a node and a 31.9KB packet leaving it can correlate them by size. In Sphinx, every field of the packet changes at every hop. The adversary sees 32,768 bytes in and 32,768 bytes out -- both look like uniform random data. The only way to link them is to break the Diffie-Hellman problem on the X25519 curve, which is computationally infeasible.

Computing Proof of Work

Before sending, Alice computes a PoW nonce using Blake3 hashcash. This is anti-spam, not anti-Sybil: it prevents an adversary from flooding the mixnet with cheap packets. The difficulty is calibrated so that legitimate users can compute it in <100ms, while an attacker trying to send millions of packets faces meaningful computational costs.

What metadata is exposed? The Sphinx packet itself reveals nothing about its content. From the outside, it is 32,768 bytes of random-looking data. It is indistinguishable from cover traffic, other users' swap packets, HTTP browsing packets, or loop-back monitoring messages. This is by design.

One subtlety worth noting: the Sphinx packet size (32KB) is a fixed constant (PACKET_SIZE = 32_768 in packet.rs). This is larger than strictly necessary for most payloads -- a gas payment proof + swap intent might only need ~4KB. The remaining ~28KB is ISO 7816-4 padding: 0x80 followed by 0x00 bytes. We pay the bandwidth cost of 32KB per packet regardless of payload size, in exchange for the guarantee that all packets are identical in size. Without fixed-size packets, an adversary could distinguish "small" packets (likely cover traffic) from "large" packets (likely real transactions with proofs and SURBs), breaking the indistinguishability property.

The 32KB size is chosen to accommodate the largest expected payload: a gas payment proof (~2KB) + DeFi intent with calldata (~1KB) + 15 SURBs for FEC response delivery (~15KB, depending on route length) + Sphinx header and MAC overhead. With this sizing, even the most complex transaction fits in a single packet without fragmentation.


Step 4: Into the Mixnet Entry Node

Alice sends her Sphinx packet to the entry node over an encrypted connection (TLS + Noise protocol via libp2p). From this point forward, let's track what each party sees -- and, more importantly, what they do not.

What Alice's ISP Sees

An encrypted connection from Alice's IP to the entry node's IP. They can see the timing and size of the traffic flow, but not its content. They cannot distinguish this connection from cover traffic. Against a GPA who also observes the exit node, the Poisson mixing at the intermediate layer provides statistical unlinkability -- the timing correlation that achieves 96% accuracy against Tor (DeepCorr [16]) is fundamentally harder against a mixnet with stochastic delays and cover traffic.

Specifically: Tor uses a low-latency circuit design where packets traverse the circuit in near-real-time. An adversary who observes the circuit entry and exit can correlate timing patterns with high confidence. In a mixnet, each intermediate node holds the packet for a random delay drawn from an exponential distribution. If the mean delay is μ, the time a packet spends at the mix node is drawn from Exp(1/μ). After multiple independent hops with independent delays, the end-to-end timing becomes a convolution of exponential distributions -- a much noisier signal for correlation attacks.

The P2P Network Layer

Before diving into what each node sees, a note on how nodes communicate. The NOX network uses libp2p (TCP + Noise + Yamux + GossipSub) for inter-node communication. Noise provides authenticated encryption (each node has a persistent identity key). Yamux provides multiplexed streams over a single TCP connection. GossipSub provides pubsub-style message distribution for topology updates and node discovery.

Sphinx packets travel as payloads within this P2P layer. A passive observer sees encrypted libp2p traffic between known mixnet nodes -- they can see that nodes are communicating, but not the content or purpose of individual messages. The traffic volume between any two nodes is a mix of real Sphinx packets, cover traffic, heartbeats, and P2P protocol messages. An observer cannot determine what fraction is real traffic.

What the Entry Node Sees and Does

The entry node receives a Sphinx packet. It performs several checks:

  1. PoW verification: Blake3 hashcash nonce check. Invalid PoW -> packet dropped immediately. This is the first line of defense against spam.

  2. Replay detection: The packet's unique tag is checked against a rotational Bloom filter (10M capacity, 0.1% false positive rate). If the tag was seen before -> packet dropped. This prevents replay attacks where an adversary re-sends a captured packet to observe where it goes.

  3. Sphinx processing: Peel one layer of encryption using the entry node's X25519 secret key. Re-blind the group element (DH re-randomization). Extract the routing command: "Forward to Mix Node M2."

  4. Poisson delay: Hold the packet for a random delay drawn from Exp(1/μ), then forward.

The entry node knows Alice's IP address (she connected directly). This is the same privacy property as a Tor guard node. But the entry node does not know:

  • What Alice is sending (the payload is encrypted under the mix node's and exit node's keys)
  • Where it is going (the routing info only reveals the next hop)
  • Whether this is a real transaction, an HTTP browsing request, or dummy cover traffic

Critical difference from Tor: In Tor, the guard node sees the user's IP and the first cell of the circuit, which contains the circuit identifier. This identifier is constant for the circuit's lifetime, enabling long-term tracking. If Alice sends 100 messages through the same Tor circuit, the guard node sees 100 cells from Alice's IP with the same circuit ID. It knows they are all from the same user, even though it cannot read the content.

In our Sphinx design, every packet is independent -- there is no persistent circuit. The entry node processes each packet in isolation. If Alice sends 100 Sphinx packets, the entry node sees 100 independent packets from Alice's IP, each with a unique ephemeral public key, unique routing info, and unique MAC. It can infer that Alice is an active user (she sent 100 packets), but it cannot determine whether those packets all went to the same destination, contained the same type of content, or were related in any way. Each packet is a standalone, self-contained unit.

This comes at a cost: each Sphinx packet requires a fresh DH key exchange (X25519 scalar multiplication), which is more expensive than Tor's stream cipher over an established circuit. But the privacy benefit -- no linkability between packets from the same user -- is fundamental to our threat model. A relayer cannot build a behavioral profile of "what Alice typically does" because it has no way to attribute multiple requests to the same sender.

What the Mix Node Sees and Does

The mix node receives a Sphinx packet that looks completely different from the one the entry node sent. Different ephemeral public key, different routing info, different MAC, different payload ciphertext. This is Sphinx's bitwise unlinkability at work.

The mix node processes identically:

  1. PoW check (already verified by the entry node, but the mix node re-checks -- defense in depth against compromised entry nodes).

  2. Replay check (against the mix node's own Bloom filter -- a replay that passes the entry node's filter must also pass the mix node's independent filter).

  3. Sphinx unwrap: Peel one layer, re-blind, extract routing: "Forward to Exit Node E1."

  4. Poisson delay: Hold the packet for a random delay. During this time, dozens of other packets -- real ones, cover traffic, loop messages -- flow through the mix node. When the delay expires, the processed packet is forwarded.

The mix node does not know:

  • Who sent the packet (could be any entry node)
  • Where it is going (it only sees the next hop)
  • What position it holds in the route (it cannot tell if it is hop 2 of 3 or hop 2 of 5)
  • Whether the packet is real or cover traffic

The mix node is the critical defense against the entry-exit colluder. Even if the adversary controls both the entry and exit nodes, the intermediate mixing layer with Poisson delays and cover traffic creates statistical uncertainty about which input packet produced which output packet.

How does Poisson mixing work mechanically? When the mix node receives a processed packet, it does not immediately forward it. Instead, it schedules the forwarding with a delay drawn from an exponential distribution: delay = -μ * ln(random()), where μ is the mean delay parameter and random() produces a uniform value in (0, 1]. This produces delays that are usually short (most values cluster near zero) but occasionally long (the exponential tail). The mix node maintains a priority queue (delay queue) of pending packets, ordered by their scheduled forward time.

During the time a packet waits in the queue, other packets arrive, get processed, get delayed, and get forwarded. The interleaving creates ambiguity: an observer who sees 10 packets enter the mix node and 10 packets leave cannot determine the input-output correspondence without breaking the encryption. With N concurrent packets and mean delay μ, the number of plausible correspondences grows combinatorially.

What the Exit Node Sees

The exit node receives a Sphinx packet, unwraps the final layer, and discovers the cleartext payload: Alice's RelayerPayload::SubmitTransaction containing the DeFi intent, the ZK gas payment proof, and 15 SURBs for the response.

The exit node knows what to do (execute a Uniswap swap) but not who asked for it. The gas payment proof is tied to a nullifier in the pool, not to any real-world identity. The SURBs tell the exit node how to send a reply -- but the SURB's return path is encrypted, so the exit node only sees the first hop of the return route.

This is the fundamental asymmetry we exploit: the exit node has the cleartext (necessary for execution) but not the metadata (sufficient for deanonymization). The mixnet stripped the metadata. The ZK proof stripped the identity.

It is worth dwelling on this point because it is the crux of the entire design. In a traditional relayer system (e.g., GSN, OpenZeppelin Defender), the relayer knows who the user is -- the user signs a meta-transaction with their wallet key, and the relayer forwards it. The relayer is a convenience layer, not a privacy layer. In Xythum, the relayer is a privacy shield: it has no idea who it is serving. The ZK proof proves authorization without revealing the authorizer. The Sphinx packet delivers the proof without revealing the sender. The relayer submits the transaction knowing only that a valid pool member authorized it and paid for it.


Step 5: Transaction Simulation

Before spending real gas on-chain, the exit node simulates the entire transaction using eth_simulateV1 -- a stateless simulation endpoint that returns full nested event logs.

Why eth_simulateV1?

We tried three simulation methods:

  1. eth_call: Returns only the raw return value -- no event logs. Useless for us because the gas payment goes through NoxRewardPool, and we need to see the RewardsDeposited event to know how much the relayer will earn.

  2. debug_traceCall: On mainnet, this works and returns logs. On Anvil (our test harness), it silently ignores the withLog: true flag and returns empty logs. Since our integration tests run on Anvil, this path was unreliable for development.

  3. eth_simulateV1: Stateless simulation with full nested event log extraction. Works on both mainnet and Anvil. Returns the complete receipt structure including all internal call logs.

We use eth_simulateV1 as the primary simulation path, with snapshot/revert as a fallback.

What the Simulation Tells Us

The simulation provides four critical pieces of information:

  1. Will the ZK proof verify on-chain? If the proof is invalid (wrong nullifier, stale Merkle root, bad execution_hash), the simulation reverts at the payRelayer call. The relayer learns this before spending gas. The revert cost would be only ~21k gas for the base transaction if submitted -- but we do not submit at all.

  2. Will the swap succeed? The simulation executes the Uniswap swap with current pool state. If the price has moved beyond Alice's slippage tolerance (below her min_out of 3,850 USDC), the swap call reverts. But because requireSuccess: false on the user action, the overall simulation still succeeds -- the payment sticks, the swap fails. The relayer needs to decide whether to submit a transaction where the user action will fail.

  3. How much gas will it cost? The simulation returns gasUsed, which feeds into the profitability calculation. The exit node adds a 20% buffer to the gas estimate before submission -- without this buffer, transactions intermittently revert with "out of gas" despite passing simulation (a known Ethereum estimation edge case).

  4. What events will be emitted? Specifically, the RewardsDeposited(address asset, address to, uint256 amount) event from NoxRewardPool, which tells the exit node exactly how much revenue it will receive if it submits this transaction.

Simulation as Defense

The simulation is the first line of defense against economic attacks. Without it:

  • A malicious user could craft a transaction that consumes all gas before reverting, costing the relayer real ETH with no payment.
  • A user could submit a gas payment proof with a stale Merkle root (too old to be in the 100-root ring buffer), which would revert on-chain.
  • A user could submit a proof where the execution_hash does not match the actual calldata, causing an on-chain revert.

The simulation catches all of these before any real gas is spent. The only gas the relayer ever "wastes" is on transactions that pass simulation but fail on-chain due to state changes between simulation and submission (e.g., someone else spends the same nullifier in the intervening block). This is a standard MEV/reorg risk that all relayers face, not specific to our system.

What metadata is exposed? The exit node sees the full transaction details: the swap parameters, the token pair, the amounts, the ZK proof. It knows what is happening. It does not know who is making it happen. This is acceptable for our threat model -- the exit node is a service provider, not a trusted party.


Step 6: Profitability Check

The exit node is not a charity. It will not submit a transaction that loses money. The profitability calculator (ProfitabilityCalculator in nox-node) implements a three-step validation:

Step 6a: Event Validation

The calculator parses the simulation logs for RewardsDeposited events. Critically, it validates that the event came from the actual NoxRewardPool contract address -- not from a user-deployed contract that emits the same event signature. This is a real attack vector: without address validation, a malicious user could deploy a contract that emits RewardsDeposited(USDC, relayer, 1000000000000) (a trillion dollars), making a worthless transaction appear infinitely profitable.

The TokenRegistry maps token addresses to their decimals and price oracle IDs:

TokenDecimalsPrice IDExample: 1 USD worth
WETH18"ethereum"~333,333,333,333,333 wei
USDC6"usd-coin"1,000,000 (1e6)
DAI18"dai"1,000,000,000,000,000,000 (1e18)
USDT6"tether"1,000,000 (1e6)
WBTC8"bitcoin"~1,429 satoshis

Getting decimals wrong is catastrophic. Consider: treating USDC (6 decimals) as an 18-decimal token divides the payment by 10^12. A 302paymentwouldappearas302 payment would appear as 0.000000000302. The profitability calculator would reject every USDC-denominated payment as unprofitable. Conversely, treating WETH (18 decimals) as a 6-decimal token would multiply the payment by 10^12, making every payment appear infinitely profitable -- the relayer would accept transactions where the user pays essentially nothing.

The TokenRegistry prevents both failure modes with explicit per-token configuration, validated at initialization. Unknown tokens (not in the registry) are rejected with an error rather than silently mishandled. In testing, custom tokens can be registered via register_token() to match Anvil-deployed mock tokens.

Step 6b: USD Conversion

Both sides of the equation are converted to USD for comparison:

Revenue_USD = (reward_amount / 10^decimals) * asset_price_usd
Cost_USD    = (gas_used * gas_price / 10^18) * eth_price_usd

Price data comes from a PriceClient that fetches from nox-oracle -- an aggregate price server that queries Binance and CoinGecko, takes the median of available feeds, and caches with a 10-second TTL. If all feeds fail, the stale cache is used as a fallback (better to use a slightly-old price than to reject all transactions).

Step 6c: Margin Check

margin = Revenue_USD / Cost_USD
 
if margin >= 1.0 + min_profit_margin:
    submit transaction
else:
    reject (unprofitable)

The default minimum profit margin is 10% (min_profit_margin_percent = 10, so the threshold is 1.10). Alice's 12% fee premium is designed to clear this threshold comfortably.

Real Numbers

From our benchmarks at ETH = $3,000 and 10 gwei gas:

CircuitGas UsedCost (USD)Fee Revenue (USD)Margin
gas_payment5.0M$150.87$168.9712%
deposit5.0M$150.87$168.9712%
transfer6.1M$182.58$204.4812%
withdraw4.2M$124.92$139.9112%

A relayer running on a 50/monthVPSbreaksevenatfewerthan1transactionperdayatthesegasconditions.At100transactionsperday,monthlyrevenueexceeds50/month VPS breaks even at fewer than 1 transaction per day at these gas conditions. At 100 transactions per day, monthly revenue exceeds 53,000. The economics are viable even at modest volumes.

At 50 gwei gas (busy periods), costs scale 5x but Alice's fee also scales 5x (she estimates gas price at submission time), so the margin stays roughly constant. The system is designed to be gas-price-agnostic.

Under dev-node flag: On Anvil, ZK proof verification costs ~10M gas at artificially low gas prices (1 gwei). This makes every paid TX appear unprofitable. The dev-node feature flag skips the profitability check when eth_simulateV1 simulation succeeds. This is only for testing -- it would be catastrophic in production.


Step 7: On-Chain Settlement The Relayer as a Privacy Shield

This is the step where the rubber meets the road. The exit node constructs a RelayerMulticall -- an atomic batch of calls submitted in a single Ethereum transaction.

Why the Relayer Model is Critical for Privacy

Consider the alternative: Alice submits the transaction herself. She calls an RPC provider (Infura, Alchemy) with eth_sendTransaction. Now:

  • The RPC provider has her IP address and her wallet address [17]
  • The wallet address appears as msg.sender in the transaction
  • Blockchain analytics can link msg.sender to all her previous transactions
  • MEV searchers can see her pending transaction and front-run it
  • Her ISP can correlate the RPC call timing with the on-chain transaction

The relayer model eliminates all of these:

  • Alice never touches an RPC provider
  • The relayer's address appears as msg.sender, not Alice's
  • Alice has no on-chain address history (she uses ZK-proven UTXOs)
  • The relayer submits the transaction, so MEV searchers see the relayer, not Alice
  • Alice's ISP sees traffic to the mixnet entry node, not to Ethereum

The relayer is economically incentivized to process transactions honestly (it earns the fee premium), it cannot steal Alice's funds (the ZK proof constrains the execution), and it does not know who Alice is (the mixnet stripped her identity).

The DarkPool contract has no msg.sender ownership checks. This is not a bug -- it is the core design decision. Ownership is proved inside the ZK circuit via the re-encryption match. The contract only verifies proofs, manages the Merkle tree, and tracks nullifiers. Anyone can submit a transaction to the DarkPool, and it will execute if and only if the ZK proof is valid.

The RelayerMulticall Execution

Call 0: Gas Payment (requireSuccess: true)

  • Target: DarkPool.payRelayer(proof, publicInputs)
  • Actions:
    1. Verify the ZK proof against the GasPaymentVerifier contract (auto-generated from Noir circuits)
    2. Check the nullifier has not been used (isNullifierSpent[nullifier] == false)
    3. Validate the Merkle root is in the last-100 ring buffer
    4. Confirm the proof timestamp is within 1 hour of block.timestamp
    5. Deposit funds to NoxRewardPool and credit the relayer
    6. Insert the encrypted change note into the Merkle tree
    7. Emit NullifierSpent, RewardsDeposited, and NewNote events
  • If any step fails: the entire transaction reverts. The relayer pays only ~21k gas for the failed base transaction.

Call 1: User Action (requireSuccess: false)

  • Target: UniswapAdaptor.executeSwap(swapType, encodedParams)
  • The adaptor performs the Pull-Swap-Push flow:
    1. Pull: Calls DarkPool.withdraw(proof, publicInputs) with intent_hash binding. This verifies a second ZK proof (the swap authorization), spends a nullifier, and transfers WETH from the pool to the adaptor.
    2. Swap: Calls the Uniswap V3 Router to execute the swap. WETH -> USDC at the current pool price, with Alice's minimum output protection.
    3. Push: Calls DarkPool.publicTransfer(owner, USDC, amount, ...) to create a PublicMemo with the swap output. Alice will later claim this via a public_claim proof.
  • If the swap fails (slippage, liquidity issues, oracle mismatch): the payment from Call 0 still sticks. Alice's gas fee is spent regardless.

Why the requireSuccess Asymmetry Exists

This asymmetry addresses a specific attack vector. Without it:

  1. Attacker generates a valid gas payment proof.
  2. Attacker crafts a swap with min_out set to an impossibly high value (e.g., 1 billion USDC for 1 ETH).
  3. The swap always reverts.
  4. With requireSuccess: true on both calls, the entire transaction reverts, including the payment.
  5. The relayer pays gas (the transaction was submitted to the mempool), but receives no payment.
  6. Repeat 1,000 times. The relayer goes bankrupt.

With requireSuccess: false on the user action, Alice bears the cost of her own swap failures. A grief attack costs the attacker their gas payment proof (the nullifier is spent, the fee is deducted from their note). This makes grief attacks self-defeating: each attack costs the attacker as much as the relayer.

What the Chain Sees

A transaction from the relayer's address to the RelayerMulticall contract. The relayer's address is public. Alice's address is nowhere. The ZK proof proves authorization without revealing identity. The chain has no idea who Alice is -- it only knows that someone with a valid note in the privacy pool authorized this swap and paid for gas.

The chain sees:

  • A NullifierSpent event (a 32-byte hash, not linkable to Alice without breaking Poseidon2)
  • A RewardsDeposited event (the relayer's payment)
  • A NewNote event (Alice's encrypted change note, unreadable without the decryption key)
  • Standard Uniswap events (swap amounts, pool state changes)
  • A NewPublicMemo event (the swap output, encrypted for Alice's key)

For a paid mixnet swap, the combined gas cost (gas_payment + user action) ranges from 8.6M to 9.8M gas based on the operation type. The total end-to-end latency through the paid mixnet path is 4.7-5.9 seconds -- comparable to the direct proof generation time, because the mixnet transit overlaps with on-chain execution.


Steps 8-9: Events, Receipts, and State Changes

The transaction emits several events, each serving a distinct purpose:

NullifierSpent(nullifier_hash) Marks Alice's gas payment note as consumed. The nullifier hash is a Poseidon2 hash of the note's nullifier secret (Path A). This same hash is now in the isNullifierSpent mapping, preventing Alice from spending the same note again. No one can link this nullifier hash to Alice's identity -- it is derived from a random secret that only she knows.

RewardsDeposited(asset, to, amount) Credits the relayer's balance in NoxRewardPool. The relayer can later withdraw these accumulated rewards. This event is what the profitability calculator parsed during simulation.

NewNote(leafIndex, commitment, epk_x, epk_y, packedCiphertext[7]) Alice's encrypted change note is inserted into the Merkle tree at leafIndex. The commitment is Poseidon2(packedCiphertext). The ephemeral public key (epk_x, epk_y) allows Alice (and compliance) to derive the shared secret and decrypt the note. To anyone else, the 7 packed ciphertext fields are indistinguishable from random.

NewPublicMemo(memoId, ownerX, ownerY, asset, value, timelock, salt) The Uniswap swap output. Unlike private notes, public memos have their amount and asset visible on-chain (the adaptor needs to call publicTransfer with cleartext parameters because the swap output is determined at execution time, not at proof generation time). The ownerX, ownerY identify Alice's BabyJubJub public key, but this key is not linked to her Ethereum address. She will claim this memo later using a public_claim proof.

Standard Uniswap events Swap(sender, recipient, amount0, amount1, sqrtPriceX96, liquidity, tick). These reveal that 1 ETH was swapped for ~3,892 USDC through the WETH/USDC pool. They do not reveal who initiated the swap (the sender is the UniswapAdaptor contract, not Alice).

The Event Privacy Spectrum

It is worth noting the different privacy levels of these events:

EventPrivacy LevelWhat's PublicWhat's Hidden
NullifierSpentHighNullifier hash (random-looking 32 bytes)Which note was spent, who spent it
RewardsDepositedMediumRelayer address, payment amount, payment assetWho paid (identity hidden by ZK proof)
NewNote (change)HighCommitment, encrypted ciphertext, ephemeral PKNote contents, owner identity
NewPublicMemoLowOwner PK, asset, amount, timelockLink to initiating transaction
Uniswap SwapLowToken pair, amounts, pool addressWho initiated (adaptor is msg.sender)

The NewPublicMemo event is the weakest link in the privacy chain for swaps. It reveals the swap output amount, the asset, and the recipient's BabyJubJub public key. This is inherent to the adaptor design: the swap output is determined at execution time by the Uniswap pool, so it cannot be encrypted inside a ZK proof (the proof was generated before execution). The public memo creates a brief window where the swap output is publicly visible. Alice should claim it promptly via public_claim to convert it back into a private note.

A future improvement could use a "shielded callback" pattern where the adaptor directly creates an encrypted note via a ZK proof computed on-chain (using a precompiled circuit verifier). This would eliminate the public memo step entirely, but would require the adaptor to perform ZK proof verification for the output note, adding gas cost and complexity.


Step 10: SURB Response with FEC

The exit node takes the transaction receipt (success/failure, gas used, event logs, new Merkle root) and needs to send it back to Alice. It uses the SURBs Alice included in her original Sphinx packet.

The SURB Problem

Unlike forward Sphinx packets, SURB responses traverse the mixnet in the reverse direction. Each hop adds a layer of encryption (unlike forward packets where each hop removes a layer). The exit node stuffs the response into the SURB, the first relay node encrypts it with its key, the second relay node encrypts it again, and by the time it reaches Alice, the response is wrapped in multiple encryption layers that she must peel off.

This inversion is elegant but fragile. Each SURB can only be used once (single-use reply block). Each SURB response is an independent Sphinx packet traversing the mixnet independently. If any single SURB packet is lost in transit, the corresponding shard of the response is gone.

Why Packet Loss is Not Hypothetical

In a mixnet, packet loss happens for several reasons:

  • Buffer overflow: Mix nodes have finite buffer space. Under high load, packets are dropped (this is intentional -- the alternative is unbounded memory growth).
  • Cover traffic competition: Real packets compete with cover traffic and loop messages for buffer space.
  • Node restarts: Nodes periodically restart for maintenance or updates. In-flight packets are lost.
  • Network partitions: Intermittent connectivity between nodes causes packet drops.
  • Replay filter false positives: The Bloom filter has a 0.1% false positive rate. At high packet rates, legitimate packets are occasionally flagged as replays.

FEC to the Rescue

Forward Error Correction (FEC) using Reed-Solomon codes allows Alice to recover the complete response even if some SURB packets are lost. The exit node encodes the receipt into D data shards and P parity shards. Alice needs only D out of D+P shards to reconstruct the full response.

Our default configuration: D=11 data shards, P=4 parity shards, for a total of 15 SURB responses. Alice can lose up to 4 shards (any 4, not necessarily the last 4) and still recover the complete receipt. The parity ratio of ~0.36 (4/11) was chosen empirically: it provides excellent recovery at realistic loss rates (98.8% at 10%) while keeping the SURB budget manageable. Higher ratios (e.g., 8 parity shards for 11 data) would improve recovery at extreme loss rates but would double the number of SURBs Alice needs to create, increasing the forward Sphinx packet size and the bandwidth cost.

The SURB budget is calculated by SurbBudgetCalculator in the client, which takes the estimated response size, the fragment payload size (30,699 bytes = SURB_PAYLOAD_SIZE - FRAGMENT_OVERHEAD of 21 bytes), and the configured parity ratio to determine the total D+P count. For typical transaction receipts (1-5 KB), D=11 is generous -- most receipts fit in 1-2 data shards. The surplus shards carry redundant copies of the same data, further improving reliability.

The FEC metadata (FecInfo) is carried on every fragment:

FecInfo {
    data_shard_count: u32,    // Number of original data shards (11)
    original_data_len: u64,   // Unpadded data length (for truncation after RS decode)
}

This is 12 bytes per fragment -- 0.04% overhead on a 30,700-byte SURB payload. It is present on every fragment because fragments traverse the mixnet independently. Any fragment could be the one that is dropped, so the reassembler must learn FEC parameters from whichever fragment arrives first.

The Numbers

Our benchmarks (1,000 trials per loss rate, 300KB response payloads) quantify the difference:

Packet Loss RateWithout FEC (11 shards)With FEC (11+4 shards)Improvement
0%100.0%100.0%--
5%54.9%99.9%+45.0pp
10%30.9%98.8%+67.9pp
15%17.1%92.7%+75.6pp
20%8.7%85.4%+76.7pp
25%2.9%66.6%+63.7pp

At 10% packet loss -- a realistic operating condition for a mixnet under moderate load -- FEC transforms a 30.9% delivery rate into 98.8%. The cost is 4 additional parity shards (~36% bandwidth overhead) for a reliability improvement that makes the difference between "works most of the time" and "works reliably."

Why FEC Instead of Retransmission (ARQ)

In a normal network, you would use ARQ (Automatic Repeat Request): if a packet is lost, the receiver asks the sender to retransmit. In a mixnet, this is terrible for several reasons:

  1. Each retry requires a new SURB. SURBs are single-use. Alice would need to pre-create dozens of backup SURBs for the retransmission case, increasing the forward packet size.

  2. Each retry is a full round-trip through the mixnet. Three hops forward (Alice -> Entry -> Mix -> Exit), the exit node sends a "which shards are missing?" message, then three hops back (Exit -> Mix -> Entry -> Alice), followed by new SURBs, followed by the retransmitted shards making three more hops. A single retransmission cycle adds 6-9 more hops of latency.

  3. Retransmission patterns leak timing information. An adversary observing the mixnet can see that a SURB response was followed by a new forward packet from the same entry node, followed by more SURB responses. This timing pattern is a deanonymization signal -- it reveals that the same user sent a request, received a partial response, and sent a retransmission request.

FEC eliminates all of these problems. Single-pass, fixed bandwidth overhead, no timing leakage. The exit node sends 15 SURB responses and never thinks about it again. Alice collects whatever arrives and reconstructs.

Reed-Solomon Under the Hood

For the technically curious: Reed-Solomon codes operate over GF(2^8) -- the Galois field with 256 elements. Each byte of data is treated as an element in this field. The encoder creates a generator polynomial of degree P (4 in our case) and evaluates it at D+P points to produce D data symbols and P parity symbols.

The key property: any D symbols suffice to reconstruct the original data. It does not matter which D -- the Reed-Solomon decoding algorithm (we use the reed-solomon-erasure crate with SIMD acceleration) can work with any combination of data and parity symbols, as long as at least D total symbols are available.

The encoding complexity is O(D _ P), which is negligible for our shard counts (11 _ 4 = 44 operations). Decoding is O(D²) worst case, also negligible. The real cost is the bandwidth: sending 15 packets instead of 11 is a 36% overhead. But for a transaction receipt (a few KB), this overhead is entirely acceptable.

The theoretical maximum shard count is 255 (GF(2^8) limit), but our practical limit is MAX_FRAGMENTS_PER_MESSAGE = 200. For typical transaction receipts (1-5 KB), 15 shards is more than sufficient.

How the Exit Node Encodes

The exit node's ResponsePacker performs these steps:

  1. Serialize the transaction receipt to bytes.
  2. Split into 11 equal-length shards (zero-pad the last shard if needed).
  3. Reed-Solomon encode to produce 4 parity shards (using reed-solomon-erasure over GF(2^8)).
  4. Attach FecInfo to each shard.
  5. Pack each shard into a separate SURB response packet.
  6. Publish 15 NoxEvent::SendPacket events to the event bus.
  7. The relayer service picks up each event and forwards it via the appropriate network path.

Each of the 15 SURB response packets traverses the mixnet independently -- different timing, different mixing delays, potentially different routes (if Alice specified different return paths for different SURBs). At each hop, the packets look like any other Sphinx packet. Mix nodes cannot tell that they are responses, or that they are related to each other.


Step 11: Alice Gets Her Receipt

The response packets arrive at Alice's entry node, which recognizes them as SURB deliveries via the SURBReplyCommand in the routing info. The entry node forwards them to Alice.

Collecting and Reconstructing

Alice's wallet collects arriving shards, tagging each with its sequence number from the FEC metadata. She waits until she has at least 11 out of 15 shards. Once she has enough:

  1. Reed-Solomon reconstruction: Using the shard positions and data, she reconstructs the full response. RS can reconstruct from any 11 shards -- it does not matter which 4 are missing. This is the beauty of erasure coding: lost data shards are reconstructed from parity shards, and lost parity shards are simply ignored.

  2. Truncation: The reconstructed data is truncated to original_data_len (removing zero-padding on the last data shard).

  3. SURB decryption: The response has been encrypted by each relay node during transit (each hop added a layer). Alice undoes these layers by encrypting with each hop's key in reverse order (the SURB construction encodes the keys so that this "encrypt to decrypt" operation recovers the plaintext), then decrypts the inner SURB key layer using the SurbRecovery keys she saved in Step 3.

  4. Deserialization: She now has the raw transaction receipt.

What the Receipt Contains

  • Transaction hash: The on-chain tx hash for verification.
  • Status: Success or failure (did the swap execute?).
  • Gas used: How much gas the relayer spent.
  • Event logs: The full set of events emitted by the transaction.
  • New Merkle root: The updated root after the change note and public memo were inserted.

Alice's receipt shows: swap succeeded, she received 3,892 USDC, gas cost was accounted for in her gas payment proof, and her change note is encrypted in the new Merkle leaf at index N.


Step 12: Updating Local State

Alice's wallet now needs to update its local state to reflect the swap. This involves three operations:

Updating the Merkle Tree

Alice's local LeanIMT must include the new leaves inserted by the transaction: her gas payment change note, the swap output public memo, and potentially notes from other users' transactions that occurred between Alice's last sync and now.

The ScanEngine detects gaps: if the latest on-chain leaf index is higher than Alice's local tree's next index, it fetches the missing commitment events and inserts them. This gap repair is critical -- if Alice's local Merkle root diverges from the on-chain root, every proof she generates will fail (the Merkle root is a public input that must match).

Discovering Her Notes

Alice's wallet scans the new events:

Gas payment change note (NewNote event): Alice's wallet tries to decrypt the packed ciphertext using her viewing key. It derives the shared secret via ECDH between the ephemeral public key in the event and her viewing secret key:

shared_secret = ECDH(viewing_sk, ephemeral_pk)
aes_key, iv = KDF(shared_secret)
plaintext = AES_decrypt(packed_ciphertext, aes_key, iv)

If decryption produces a valid NotePlaintext (6 fields with sensible values -- non-zero asset_id, reasonable value, etc.), this is her note. She adds it to her UTXO set with status UNSPENT.

This change note uses the Path A nullifier scheme (self-owned note): the nullifier field is a random non-zero value set during proof generation. When Alice later spends this change note, she will prove knowledge of this nullifier inside the ZK circuit, and the nullifier hash Poseidon2(note.nullifier) will be published on-chain to prevent double-spending.

Swap output (NewPublicMemo event): Alice checks if the ownerX, ownerY coordinates in the public memo match her BabyJubJub public key. If they do, she has a claimable public memo. She stores it and will later generate a public_claim proof to convert it into a private note.

The public_claim circuit proves that Alice knows the secret key corresponding to (ownerX, ownerY): specifically, BJJ(secret_key) == (ownerX, ownerY). This proves ownership of the public memo without revealing the secret key. The contract marks the memo as spent and inserts a new private note (encrypted for Alice) into the Merkle tree.

Deriving New Keys

Alice's KeyRepository advances its internal nonce counter. The next transaction will use new ephemeral keys, spending keys, and incoming keys -- all deterministically derived from her root secret via domain-separated Poseidon2 KDF:

sk_root (from mnemonic or EOA signature)
  +-- sk_spend = Poseidon2("xythum.spend" || sk_root)
  |     +-- secret_i   = Poseidon2("xythum.secret"    || sk_spend || nonce)
  |     +-- nullifier_i = Poseidon2("xythum.nullifier" || sk_spend || nonce)
  +-- sk_view = Poseidon2("xythum.view" || sk_root)
        +-- vk_master = Poseidon2("xythum.ivkMaster" || sk_view)
              +-- ivk_j = vk_master + tweak_j   (per-tx incoming viewing key)
              +-- esk_j = vk_master + tweak_j   (per-tx ephemeral key)

This ensures:

  • Each note has unique encryption keys (forward secrecy at the note level)
  • No two transactions share key material (nonce advances monotonically)
  • Full wallet recovery is possible from the seed phrase (deterministic derivation)
  • An adversary who compromises one note's keys cannot derive other notes' keys (domain separation)

The domain separation strings ("xythum.spend", "xythum.view", etc.) ensure that even if two derivations use the same root, they produce completely different outputs. This is a standard technique from modern key derivation, but it is worth emphasizing because getting domain separation wrong is a common source of key reuse vulnerabilities.

Her wallet now shows: previous ETH balance minus 1 ETH minus gas fee, plus ~3,892 USDC (claimable via public_claim).


The Economic Flow: Follow the Money

Let's trace the exact money movement through this swap, with specific numbers at ETH = $3,000 and 10 gwei gas:

Alice's Perspective

Before swap:
  Note A: 2.0 ETH (deposited earlier)
 
Step 1: Gas payment proof
  Note A (2.0 ETH) -> Gas fee (0.006 ETH) + Change note (1.994 ETH)
  Gas fee ≈ 5.0M gas * 10 gwei * 1.12 premium = 0.056 ETH ≈ $168
  (Paid in WETH from her note, deposited to NoxRewardPool)
 
Step 2: Swap execution
  Note A' (1.0 ETH from separate note or split) -> Uniswap -> 3,892 USDC
  (The withdraw circuit pulls WETH from the pool, the adaptor swaps on Uniswap)
 
After swap:
  Change note: 1.994 ETH (private, in Merkle tree)
  Public memo: 3,892 USDC (claimable via public_claim)
  Total value: ~$9,874 (at ETH = $3,000)

Relayer's Perspective

Revenue:
  RewardsDeposited event: 0.056 ETH ≈ $168.97
  (Credited to relayer's balance in NoxRewardPool)
 
Cost:
  Gas used: ~9.0M gas (gas_payment + swap execution)
  Gas price: 10 gwei
  ETH cost: 0.09 ETH = $270.00
 
Wait -- that doesn't work. $168.97 < $270.00?

Correction: The numbers above assume the gas payment only covers the payRelayer call (~5.0M gas). In practice, Alice's fee covers the total estimated gas for the entire multicall (gas_payment + swap), not just the payment portion. Her FeeManager estimates the total gas and applies the 12% premium to that:

Total gas estimate: ~9.0M gas
Gas cost: 9.0M * 10 gwei = 0.09 ETH = $270.00
Fee with premium: $270.00 * 1.12 = $302.40
Revenue to relayer: $302.40
Profit margin: $302.40 / $270.00 = 1.12 = 12% margin

At the 10% minimum threshold, this clears comfortably. The relayer earns ~$32 per swap at these gas conditions.

Edge Case: Paying Gas in Non-ETH Tokens

Alice can pay gas in any supported token (WETH, USDC, DAI, USDT, WBTC), not just ETH. The FeeManager converts gas costs to the payment asset using the price oracle:

fee_in_asset = gas_cost_wei * eth_price_usd / asset_price_usd

For example, if Alice wants to pay in USDC:

gas_cost_wei = 9.0M * 10 gwei = 0.09 ETH
fee_in_usdc = 0.09 ETH * $3,000 / $1.00 * 1.12 = 302.40 USDC

The relayer receives USDC in the NoxRewardPool and must later convert to ETH to pay gas for future transactions. This creates a token management problem: the relayer accumulates various tokens in the reward pool but needs ETH for gas. V0 handles this manually (the relayer operator periodically swaps accumulated tokens for ETH). V1 will automate this with a treasury management module.

NoxRewardPool Accumulation

The relayer's rewards accumulate in NoxRewardPool. The relayer can withdraw accumulated rewards at any time. At 100 transactions per day:

Daily revenue:  100 * $32 = $3,200
Monthly revenue: $96,000
Monthly cost:    ~$50 (VPS) + gas for withdrawal TXs
Monthly profit:  ~$95,900

These economics make running a relayer node profitable at surprisingly low volumes. Even at 1 transaction per day, the $32 revenue far exceeds the VPS cost. The economic model is designed to attract relayer operators without requiring high throughput.

The Bootstrap Problem

Every privacy system faces a bootstrap problem: the anonymity set is small when the system is new, which discourages users from joining, which keeps the anonymity set small. Xythum addresses this through the Type A / Type B traffic classification.

Type A traffic is free: HTTP browsing, RPC reads, echo requests, dummy packets, heartbeats. Any user can route their web traffic through the mixnet at zero cost. This creates a large base of cover traffic that is indistinguishable from paid DeFi transactions. If 90% of mixnet traffic is free browsing and 10% is paid swaps, an adversary who observes a Sphinx packet cannot determine which category it belongs to.

The economic incentive for relayers during bootstrap: they earn nothing from Type A traffic (it is free), but Type A traffic improves the anonymity set for Type B (paid) traffic, which makes the system more attractive to DeFi users, which increases Type B volume, which increases relayer revenue. The virtuous cycle is:

More free traffic -> better anonymity -> more paid users -> more revenue -> more relayers -> more capacity -> more free traffic

This is the Loopix design philosophy [20]: free traffic is not a cost -- it is the foundation of the privacy guarantee.

V0 Payout Model

In V0, the reward distribution is straightforward: exit nodes collect gas payments via NoxRewardPool and keep the full amount. Non-exit nodes (entry and mix nodes) do not directly earn from paid traffic. Their compensation comes from a central authority that redistributes epoch rewards: exit nodes get gas cost + ~5% margin, non-exit nodes split the remaining ~5%.

This centralized payout model is a V0 simplification. V1 will implement a decentralized staking + reward model where all nodes earn proportional to their contribution (uptime, bandwidth, packet count). The migration requires on-chain staking contracts and a reputation system, which are designed but not yet implemented.


Comparison: This Swap Without the Mixnet

What if Alice performed the same swap on vanilla Uniswap, without Xythum? Let's trace the metadata exposure at each step.

Standard DEX Swap Flow

1. Alice opens MetaMask
2. MetaMask connects to Infura RPC
3. Alice approves WETH spending (on-chain tx from her address)
4. Alice calls Router.exactInputSingle() (on-chain tx from her address)
5. Swap executes, USDC arrives in her wallet
6. Alice checks her balance on Etherscan

Side-by-Side Metadata Comparison

StepStandard DEXXythum + Mixnet
RPC connectionInfura logs Alice's IP + wallet address [17]Alice never touches an RPC provider
Approval TXAlice's address appears as msg.sender; links her to WETH holdingsNo approval needed (pool already holds WETH)
Swap TXAlice's address is msg.sender; swap amounts, token pair, gas price all publicRelayer's address is msg.sender; Alice's address appears nowhere
Gas paymentAlice's wallet pays gas, linking her ETH balance to the swapZK proof pays gas from private pool; no link to any wallet
MempoolMEV searchers see Alice's pending swap and can sandwich itMEV searchers see the relayer's TX; cannot identify Alice
ISPISP sees Alice connecting to Infura and can correlate with chain eventsISP sees Alice connecting to mixnet entry node; cannot distinguish from cover traffic
Block explorerAnyone can see Alice swapped 1 ETH for USDC on EtherscanAnyone can see "someone" swapped 1 ETH for USDC; anonymity set is entire pool
Chainalysis99.9% clustering accuracy links this swap to Alice's full tx history [12]No on-chain address to cluster; ZK-proven UTXOs with no persistent identity
Price impactLarge swaps are visible; bots front-run future tradesSame swap is visible, but cannot be attributed to a specific trader
Historical analysisAlice's entire trade history is public and permanentEach swap uses different nullifiers; no on-chain link between Alice's operations

The information asymmetry is dramatic. On a standard DEX, Alice's swap creates a permanent, publicly visible record tied to her wallet address, her IP address (via RPC logs), her gas spending patterns, and her entire transaction history. On Xythum, the swap creates a record that "someone in the pool performed a swap" -- with no way to determine who.

The Cumulative Effect

The privacy difference compounds over time. Alice's first swap on a standard DEX links her wallet to WETH and USDC. Her second swap adds another token to her profile. After 50 swaps, Chainalysis has a detailed trading profile: which tokens she prefers, what times she trades, what her position sizes are, whether she follows specific influencers' recommendations, and what her total portfolio looks like. This information is sold to trading firms, used by tax authorities, and available to anyone who queries the blockchain.

After 50 swaps through Xythum, the chain shows 50 anonymous swaps from the pool. Each used a different nullifier. There is no on-chain link between them. Chainalysis cannot determine whether these 50 swaps were made by one person, five people, or fifty people. The trading profile does not exist.

This is not a marginal improvement. It is a qualitative change in the information available to adversaries. A standard DEX user's privacy degrades with every transaction. A Xythum user's privacy remains constant regardless of transaction count -- each operation uses fresh nullifiers, fresh proofs, and fresh Sphinx packets with no linkability to previous operations.


What Happens When Things Go Wrong

Privacy systems must fail gracefully. If a failure mode reveals Alice's identity, the system is broken regardless of how well the happy path works.

Transaction Reverts

Gas payment proof rejected (invalid proof, stale root, spent nullifier): The eth_simulateV1 simulation catches this before submission. The relayer's exit node logs the failure reason and drops the packet. No gas is spent. Alice receives no SURB response (the exit node simply does not generate one). After a timeout, Alice's wallet assumes the transaction failed and can retry with a fresh proof against the current Merkle root.

Privacy impact: None. The exit node saw the invalid proof but does not know who sent it. The failed attempt leaves no on-chain trace.

Swap reverts (slippage, liquidity, oracle mismatch): The requireSuccess: false on the user action means the transaction succeeds on-chain but the swap portion fails. Alice's gas payment is spent (the nullifier is consumed, the relayer is paid), but the swap does not execute. Alice receives a SURB response indicating the swap failed.

Privacy impact: Minimal. On-chain, observers see a payRelayer call succeeded and a swap call failed. They know "someone paid for a swap that did not execute." They do not know who.

Alice's recourse: She retries with updated slippage parameters. Her gas payment for the failed attempt is lost (this is the cost of failure, borne by the user, not the relayer).

SURB Response Lost (All Shards Dropped)

If Alice receives fewer than 11 out of 15 SURB response shards, she cannot reconstruct the receipt. At 25% packet loss, this happens 33.4% of the time (our FEC benchmarks show 66.6% success at 25% loss).

Alice's wallet handles this via timeout: after a configurable period (e.g., 60 seconds), it falls back to chain scanning. She syncs her local Merkle tree with the on-chain state, scans for new notes encrypted with her keys, and discovers the swap outcome directly from chain events. This fallback is slower (requires scanning) and reveals interest in the chain state (if she uses an RPC provider), but it works.

A future improvement: Alice could send the request again through the mixnet as a read-only RPC call (eth_getTransactionReceipt), receiving the result via a new set of SURBs. This avoids direct RPC exposure. This is a Type A (free) traffic flow -- an anonymous RPC read routed through the mixnet -- and has zero additional cost.

Proof Verification Fails On-Chain (But Passed Simulation)

This should not happen -- if the simulation succeeded, the proof was valid against the simulated state. But it can happen due to:

  • State changes between simulation and inclusion: Another transaction in the same block spends the same nullifier (front-running by a different relayer), or the Merkle root ring buffer rotates past Alice's root.
  • Anvil vs mainnet behavior differences: eth_simulateV1 may produce slightly different gas estimates than actual execution, causing marginal transactions to fail.

The relayer absorbs this cost (the transaction reverts, the relayer pays ~21k base gas). This is a known cost of operating a relayer in a concurrent environment. The TransactionManager logs the failure and moves on.

Relayer Goes Offline

The exit node crashes, or the network connection between the exit node and Ethereum drops. Alice's Sphinx packet has already been processed and the payload decrypted, but the transaction is never submitted.

From Alice's perspective: no SURB response arrives. After timeout, she retries -- her gas payment proof is still valid (the nullifier was never spent on-chain) and she can send the same payload through a different route (or wait for the exit node to come back).

From the relayer's perspective: it might have started the submission process. If the transaction was submitted to the mempool but the relayer crashed before confirming, the TransactionManager (backed by SledDB) has the pending transaction persisted. On restart, it resumes monitoring. If the transaction was submitted but not yet mined, the gas bumping mechanism (20% increase every 60 seconds) ensures it eventually gets included.

Network Partition

The mixnet experiences a partial partition -- some nodes cannot reach others.

Sphinx packets that were in transit through the partitioned segment are lost. Alice's wallet detects the failure via SURB timeout and retries. The next retry may take a different path (route selection is randomized) that avoids the partition.

Cover traffic patterns change during partitions (some nodes stop receiving/sending cover traffic), which could theoretically be observed by a GPA. However, network partitions are common events that affect all users equally, so observing "cover traffic patterns changed during a partition" does not help identify Alice specifically.

Gas Price Spikes

Between Alice's fee estimation and the relayer's submission, gas prices might spike. If gas doubles, Alice's 12% premium is no longer sufficient:

Alice estimated:  9.0M gas * 10 gwei = 0.09 ETH → fee = 0.1008 ETH (12% premium)
Actual gas price: 20 gwei → actual cost = 0.18 ETH
Margin: 0.1008 / 0.18 = 0.56 (56% of cost, well below the 110% threshold)

The relayer's profitability calculator rejects the transaction. Alice receives no SURB response, times out, and her wallet re-estimates with the current gas price. She generates a new gas payment proof with a higher fee and retries.

This is a feature, not a bug. The profitability check protects relayers from operating at a loss. The cost is latency: Alice might need to retry once or twice during gas price volatility.

Relayer Nonce Collision

If two exit nodes try to submit transactions with the same relayer wallet and the same nonce, one will succeed and one will fail with "nonce too low." The TransactionManager uses SledDB-persisted nonce tracking to prevent this within a single node. For multi-node setups, nonce coordination is a production concern that requires either dedicated nonce servers or partitioned nonce ranges per exit node. This is a standard problem for any multi-relayer system and is not unique to Xythum.


The Scanning Problem

After the swap settles on-chain, Alice needs to discover her new notes. This is a fundamental tension in UTXO privacy systems: you want notes to be private (only the owner can identify them), but the owner needs to find them without revealing which notes are hers.

Trial Decryption

Alice's NoteProcessor fetches all NewNote and NewPrivateMemo events since her last sync. For each event, she:

  1. Extracts the ephemeral public key and packed ciphertext from the event data.
  2. For each of her active incoming keys (up to 20, from the gap limit): a. Derives a candidate shared secret via ECDH. b. Derives AES key and IV from the shared secret. c. Attempts AES-128-CBC decryption. d. Checks if the decrypted plaintext is a valid 192-byte Note (6 fields of 32 bytes each). e. If valid: this is her note. Add to UTXO set.

The trial decryption produces false negatives (we miss a note) only if a key was not in the gap window -- which can happen if the wallet was offline for a long time and many transactions were received. False positives (we "find" a note that is not ours) are negligible: AES-128-CBC decryption of random data produces valid PKCS#7 padding with probability ~1/256, and then the plaintext must also parse as 6 valid BN254 field elements (each must be < ~2^254). The combined probability of a false positive is astronomically low -- roughly 1 in 2^1530.

The Performance Cost of Trial Decryption

Trial decryption is not free. For each event, Alice performs:

  1. One X25519 scalar multiplication (ECDH shared secret derivation)
  2. Two Poseidon2 hashes (AES key + IV derivation)
  3. One AES-128-CBC decryption (208 bytes)
  4. One PKCS#7 padding validation

On modern hardware, this takes ~50-100 microseconds per event per key. With 20 active keys and 10,000 events, Alice performs 200,000 trial decryptions -- about 10-20 seconds of scanning time. This is acceptable for desktop wallets but challenging for mobile. The transfer tag optimization (indexed recipientP_x) reduces the transfer scanning to O(N_user_transactions), but deposit scanning remains O(N_global).

Future improvements include PIR (Private Information Retrieval) servers that allow Alice to query for her events without revealing which events she is interested in, and TEE-based indexers (AMD SEV-SNP) that can decrypt and index events inside a trusted enclave. Both are V2+ scope.

The O(N) Problem and the Transfer Tag Optimization

For deposits and self-notes (NewNote events), Alice must scan every event globally. If 1 million deposits have been made, she tries decryption against all 1 million events, for each of her 20 active keys. That is 20 million trial decryptions.

For transfers (NewPrivateMemo events), the indexed recipientP_x field reduces this dramatically. Alice's KeyRepository pre-calculates the expected recipientP_x values for her active keys:

For each incoming key pair (ivk_j, esk_j):
    recipient_pubkey = ivk_j * G
    recipientP = ivk_j * CompliancePK
    expected_tag = recipientP.x

She queries the RPC for logs where topic[1] == expected_tag. Only matching events are downloaded and decrypted. If Alice has 50 incoming transactions across 20 active keys, she scans 50 events instead of 1 million. The trade-off: the indexed tag reveals that some public key component received a transfer (a privacy leak), but it makes mobile wallet synchronization feasible.

Post-Swap State

After scanning, Alice's wallet state looks like:

UTXO Set:
  [UNSPENT] Change note from gas payment: 1.994 ETH
  [UNSPENT] Change note from swap: varies
  [SPENT]   Original 2.0 ETH note (nullifier spent)
 
Claimable Public Memos:
  [UNCLAIMED] 3,892 USDC from swap output
 
After claiming the public memo (via public_claim proof):
  [UNSPENT] 3,892 USDC private note (now in Merkle tree, fully private)

Multi-Step DeFi Operations: The Intent Pipeline

Alice's swap was a single-hop operation: WETH -> USDC. But DeFi often requires multi-step operations: swap + provide liquidity + stake. How does the intent system handle this?

Current: Sequential Operations

In V0, complex DeFi operations are decomposed into sequential intents. Each intent generates its own proof, its own gas payment, and its own Sphinx packet. For example, "swap ETH for USDC, then provide USDC/DAI liquidity on Uniswap V3":

  1. Swap: gas_payment proof + swap intent → Sphinx packet → mixnet → settlement
  2. Claim: public_claim proof for swap output → Sphinx packet → mixnet → settlement
  3. LP Position: gas_payment proof + LP intent → Sphinx packet → mixnet → settlement

Each step requires waiting for the previous step to settle on-chain, scanning for the output note, and generating a new proof. Total latency: 3 * (~20-30 seconds) = 60-90 seconds.

Future: Batched Multicall

The RelayerMulticall contract already supports multiple user actions in a single transaction:

multicall([
    Call { DarkPool.payRelayer(...), requireSuccess: true },
    Call { UniswapAdaptor.swap(...), requireSuccess: false },
    Call { UniswapAdaptor.addLiquidity(...), requireSuccess: false },
])

This allows sequential DeFi operations to execute atomically in a single transaction. The challenge is proof generation: the second operation's inputs (LP position parameters) depend on the first operation's outputs (swap amount). This requires either:

  • Optimistic estimation: Generate proofs with estimated swap output, accept slippage risk.
  • Iterative proving: Generate the swap proof, simulate the output, then generate the LP proof. This doubles proof generation time but eliminates estimation risk.

Future: Solver Network

The long-term vision (V2+) includes a solver network: instead of Alice specifying exact execution parameters, she submits an intent ("I want to swap 1 ETH for at least 3,850 USDC"), and competing solvers find the best execution. This is similar to CoW Protocol / 1inch Fusion but with full privacy:

  • Alice submits an encrypted intent through the mixnet.
  • Solvers compete to fill the intent (best price wins).
  • The winning solver submits the execution through the RelayerMulticall.
  • Alice's identity is hidden from solvers (they see the intent but not who submitted it).

This requires recursive ZK proofs (PartialSwapCircuit + SettleCircuit) and a staked solver registry. It is designed but not yet implemented.

Future: Multi-Chain via FROST Threshold Signatures

Beyond single-chain operations, the V2+ roadmap includes cross-chain private transactions via FROST threshold signatures. An "Ozoner" network of threshold signers would collectively sign transactions on destination chains, enabling private cross-chain swaps without bridges that expose the user's identity. For example: private swap on Ethereum -> FROST-signed mint on Arbitrum -> private withdrawal on Arbitrum. The user's identity is hidden at every step, across both chains.

This is speculative infrastructure -- the cryptographic primitives are well-understood, but the engineering challenges of coordinating threshold signers across chains while maintaining latency targets are substantial. The intent system is designed to be extensible enough to accommodate this when the infrastructure matures.


What Each Adversary Learned

Let's tally what each adversary class actually learned from Alice's swap, with citations for why each claim holds.

Alice's ISP

Capability: Observes all traffic from Alice's IP.

Observed: An encrypted connection from Alice's IP to the entry node's IP. The connection transmitted ~32KB of data (one Sphinx packet) and later received ~15 SURB response packets (~480KB total).

Learned about Alice: Alice uses the mixnet. Not what for, not when she transacted versus sent cover traffic, not whether the Sphinx packet contained a swap, an HTTP request, or was a dummy packet.

Why: Sphinx packets are fixed-size (32KB) and indistinguishable from cover traffic [15]. The ISP cannot distinguish "Alice sent a $300,000 swap" from "Alice's wallet sent a heartbeat" from "Alice browsed a website through the mixnet." All three look identical on the wire.

Residual risk: Traffic volume analysis. If Alice sends a Sphinx packet and then receives 15 SURB responses 30 seconds later, the ISP knows she sent a message that generated a response. They cannot determine the content, but they know Alice actively used the mixnet (not just passive cover traffic). Countermeasure: constant-rate cover traffic makes all periods look identical.

Entry Node

Capability: Sees Alice's IP + the first Sphinx layer.

Observed: A valid Sphinx packet from Alice's IP address. After processing, the routing command says "forward to Mix Node M2."

Learned about Alice: Alice sent something through the mixnet. The entry node cannot determine what, to whom, or whether it was a real transaction or cover traffic. It knows the next hop (M2) but not the final destination.

Why: Sphinx bitwise unlinkability [15]. The entry node can only peel its own layer. The remaining layers are encrypted under M2's and E1's keys, which the entry node does not have.

Residual risk: If the entry node is colluding with the exit node, they know Alice's IP and the swap details. But they need to link the two observations -- "Alice sent a packet to M2" and "E1 received a swap intent from M2." The mix node's Poisson delay makes this probabilistic, not deterministic.

Mix Node

Capability: Sees two Sphinx packets (input and output) with different group elements, routing info, MACs, and payload ciphertexts.

Observed: One Sphinx packet arrived from Entry Node N1. After Poisson delay, one Sphinx packet was forwarded to Exit Node E1. During the same time window, the mix node also processed packets from other entry nodes and forwarded packets to other exit nodes.

Learned about Alice: Nothing. The mix node cannot link the input packet to the output packet, even if it sees both. The Sphinx re-blinding makes them cryptographically unrelated.

Why: Poisson mixing + cover traffic. MixMatch achieves only ~0.6 true positive rate (at 1% FPR) even with prolonged observation of Nym's live network [13]. In our system, the mix node processes packets from all entry nodes and forwards to all exit nodes. Without knowing which input corresponds to which output, the mix node's observation is information-theoretically useless.

Exit Node / Relayer

Capability: Sees the fully decrypted payload.

Observed: A swap intent for 1 ETH -> USDC, a valid gas_payment ZK proof, and 15 SURBs for response delivery. The ZK proof contains: a nullifier hash, a Merkle root, a payment amount, the relayer's address, and an execution_hash.

Learned about Alice: Someone wants to swap 1 ETH for USDC. Not who. The nullifier hash is a one-way Poseidon2 hash -- it prevents double-spending but does not reveal the spender. The SURBs hide the return path. The relayer knows the action but not the actor.

Why: ZK proof reveals nullifier, not identity [15]. SURBs hide the return path. The exit node can see what but not who.

Residual risk: If the exit node processes very few transactions, and the swap amount is unusual, the anonymity set shrinks. If only one person has ever deposited exactly 1.0 ETH into Xythum, the exit node can guess with high confidence. This is the standard anonymity set problem -- the system requires sufficient usage to provide meaningful privacy. More precisely: the anonymity set is not "all pool members" in a mathematical sense -- it is "all pool members whose notes could plausibly have been used for this operation." A swap of 1 ETH eliminates pool members who only have USDC notes, and pool members whose largest ETH note is less than 1 ETH. The effective anonymity set depends on the distribution of note values in the pool.

Ethereum Chain

Capability: Full transaction history, public state.

Observed: A transaction from the relayer's address calling RelayerMulticall. The calldata contains ZK proof bytes, public inputs, and swap parameters. Events: NullifierSpent, RewardsDeposited, NewNote, Swap, NewPublicMemo.

Learned about Alice: A valid pool member authorized a swap. Not which member. The nullifier hash appears in isNullifierSpent, but this mapping contains thousands of nullifiers from all users -- it provides double-spend prevention, not identity.

Why: The DarkPool contract has no msg.sender ownership checks. Proof verification is the only access control. The chain sees a valid proof, but the proof reveals nothing about the prover's identity beyond "they know a valid note in the Merkle tree."

Mempool Watchers / MEV Searchers

Capability: See pending transactions in the mempool.

Observed: A pending transaction from the relayer's address with swap calldata. They can see the token pair (WETH/USDC), the approximate amounts, and the gas price.

Learned about Alice: Nothing. They see the relayer submitting a swap. They see the swap parameters. They do not see who initiated it. They might attempt to sandwich the swap (front-run + back-run), but Alice's min_out parameter limits the extractable value.

Chainalysis / ZachXBT

Capability: On-chain analysis + behavioral correlation + cross-chain tracking.

Observed: A flow from the Xythum pool to Uniswap, back to the pool. The pool's balance decreased by 1 ETH, and the USDC balance increased by 3,892 USDC. The relayer's reward pool balance increased by the fee amount.

Learned about Alice: The pool was used. By whom? The anonymity set is the entire pool -- every user who has ever deposited into Xythum is a plausible candidate.

Why: None of the Tornado Cash linkage attacks described earlier in this post apply. No fixed denominations, no timing correlation between deposit and swap, no gas fingerprint, no address reuse.

Remaining attack surface: Cross-protocol correlation. If Alice withdraws USDC from Xythum and immediately deposits it on Aave from a fresh address, and the timing and amount match a Xythum swap, that is a link. This is not a Xythum vulnerability -- it is a user operational security issue. Good privacy hygiene (variable timing, amount splitting, multiple withdrawal addresses) mitigates this.

Global Passive Adversary

Capability: Observes all network links simultaneously.

Observed: Traffic between all nodes, including Alice -> Entry and Exit -> Chain. Timestamps of all packet transmissions. Cover traffic patterns.

Learned about Alice: Timing correlation is possible but degraded by Poisson mixing. The GPA observes Alice sent a packet to the entry node at t₁, and the exit node submitted a transaction at t₂. The question is: can they link these?

In a low-traffic regime (Alice is the only user), yes -- trivially. There is only one candidate.

In a high-traffic regime (thousands of users), the Poisson mixing at the intermediate layer creates a distribution of plausible timing relationships. The GPA must determine which of the entry node's inputs at time ~t₁ corresponds to which of the exit node's outputs at time ~t₂. With mean mixing delay μ and N concurrent packets, the probability of correct correlation decreases with N and μ.

Our privacy analytics benchmarks show: at 0ms mean delay, timing correlation coefficient r ≈ 0.999 (essentially no protection). At 1ms mean delay, r drops to ~0.3-0.5 with 15+ concurrent senders. At 10ms mean delay, Shannon entropy of the timing distribution exceeds 3.0 bits -- meaning there are at least 8 equiprobable candidates for any given output.

Why: Anonymity trilemma [7] constrains all systems. Loopix-style mixing with cover traffic provides stronger guarantees than Tor's 96% DeepCorr rate [16], but cannot achieve perfect anonymity without infinite latency or infinite bandwidth.

Colluding Entry + Exit Nodes

Capability: Controls the first and last hop. Has Alice's IP (from entry) and the swap intent (from exit).

Learned about Alice: Full deanonymization if they can confirm the same message traversed both nodes. The mixing layer prevents this with high probability.

The attack: The entry node sees "Alice sent a packet at t₁ to Mix Node M2." The exit node sees "a swap intent arrived from M2 at t₂." If the adversary controls M2, they can directly confirm the link. If they do not control M2, they must infer the link from timing.

Defense: The 3-layer stratified topology means the adversary must compromise nodes at all three layers to directly confirm the link. If they control 20% of each layer, the probability of controlling Alice's full path is 0.2³ = 0.8%. Path rotation (selecting new routes for each packet) means the adversary gets only one observation per transaction. Building statistical confidence requires many observations of the same user.

Bottom line: No single observer, and no combination of observers short of compromising every node in Alice's path, can determine with certainty that Alice performed this swap. The ZK proof hides which note she spent. The Sphinx packets hide which path the message took. The mixnet delays and cover traffic hide when she sent it. The SURBs hide that she received a response. The FEC encoding ensures she actually gets that response.


Why Every Layer Matters

This system has a lot of moving parts. Each one exists because removing it creates a concrete, documented attack:

  • Without ZK proofs: Chain analysis deanonymizes you. Chainalysis achieves 99.9% clustering accuracy [12].

  • Without the mixnet: Your ISP deanonymizes you. DeepCorr achieves 96% flow correlation on Tor [16]. Passive TCP timing achieves >95% against RPC users [11]. MetaMask/Infura has been logging IP + wallet pairs since 2018 [17].

  • Without economic privacy (relayer-paid gas): Gas payment links your wallet to the transaction. The TRAP attack needs only 3-4 transactions to achieve ~97% deanonymization [11].

  • Without the relayer model: Someone must submit the transaction to Ethereum. Without the relayer, Alice does it herself, revealing her IP to the RPC provider and her wallet address to the chain. The relayer is Alice's proxy -- it submits the transaction on her behalf, with no knowledge of who she is.

  • Without the profitability check: Relayers go bankrupt. Without relayers, there is no one to submit transactions, and the system stops. The 10% minimum margin ensures relayer operations are economically sustainable.

  • Without SURBs: You do not know if your trade executed. You would need to query the chain directly, revealing your interest in specific transactions.

  • Without FEC on SURBs: At 10% packet loss, you get your receipt 30.9% of the time. FEC raises this to 98.8%. Without reliable response delivery, the system is unusable.

  • Without the requireSuccess asymmetry: Users can grief relayers by crafting always-reverting swaps, consuming gas without paying. The asymmetry ensures grief attacks are self-defeating.

  • Without the execution_hash binding: A malicious relayer could take Alice's gas payment proof and attach it to a different transaction -- one that benefits the relayer instead of executing Alice's swap.

  • Without replay detection (Bloom filters): An adversary could re-send captured packets to observe the exit node's behavior, using differential analysis to link packets to transactions.

  • Without cover traffic: Traffic analysis becomes trivial. If the only packets on the network are real transactions, timing correlation approaches 100% accuracy.

  • Without the commitment-over-ciphertext scheme: A commitment-over-plaintext scheme would allow an adversary who learns the plaintext fields (e.g., through a side channel) to identify notes without needing the decryption key. Our scheme requires the full encryption pipeline to be correct.

  • Without the dual-path nullifier scheme: If received notes used the same nullifier derivation as self-owned notes, the sender would need to know the recipient's nullifier secret -- breaking the separation between sender and recipient knowledge. The dual-path scheme (Path A for self-owned, Path B for received) ensures that senders can create notes for recipients without learning the recipient's spending secrets.

  • Without constant-time comparison for secrets: An adversary who can measure response times during Sphinx MAC verification could use timing side-channels to reconstruct the MAC key, breaking packet authentication. All sensitive comparisons use subtle::ConstantTimeEq in the Rust implementation.

As the Flashbots research team stated in February 2026: "Simply using a mixer like Railgun or Privacy Pools does not prevent attackers from tracking your metadata (e.g. IP), connecting your accounts, and tracking your identity and location. The missing property is network anonymity" [18]. Vitalik Buterin's April 2025 L1 privacy roadmap identified the same gap, recommending that "wallets should connect to multiple RPC nodes, optionally through a mixnet" [19].

We built the mixnet.


What We Built and What Remains

We have described a system with transport privacy (mixnet) + transaction privacy (ZK proofs) + economic privacy (relayer-paid gas) + response privacy (SURBs with FEC). It is not perfect -- the anonymity trilemma guarantees that no system can be [7]. What it provides is defense in depth: every layer an adversary might attack is protected by an independent mechanism, and compromising one layer does not compromise the others.

The architecture comprises 11 Rust subcrates (~33,000 lines of code, 548 passing tests), 6 TypeScript packages, 7 Noir circuits, and a Solidity contract suite. The ZK proof pipeline runs from circuit definition (Noir) through witness generation and proof construction (bb.js UltraHonk) to on-chain verification (auto-generated Solidity verifiers). The mixnet runs from client-side Sphinx packet construction through 3-layer stratified mixing to exit node execution and SURB-based response delivery with Reed-Solomon FEC.

What remains to be built:

  • Solver network: Competitive intent execution with recursive ZK proofs (V2+)
  • Cross-chain privacy: FROST threshold signatures for multi-chain operations (V2+)
  • Decentralized node incentives: On-chain staking and reputation for mix nodes (V1)
  • PIR/TEE indexing: Private note discovery without trial decryption overhead (V2+)
  • Shielded callbacks: Eliminate the public memo step in adaptor flows (V1+)

Each of these is designed but not yet coded. The current system handles the complete happy path for private swaps, transfers, deposits, and withdrawals with real ZK proofs, real mixnet routing, and real on-chain settlement.

The end-to-end integration test (micro_mainnet_sim) runs the full flow on a local Anvil chain: deploys all contracts, creates a 5-node topology (2 Entry, 2 Mix, 1 Exit), performs deposits, splits, joins, and withdrawals -- all with real Sphinx packets routed through the real mixnet, real ZK proofs generated by bb.js, and real on-chain settlement with profitability validation. It is 1,322 lines of Rust and takes approximately 3 minutes to run. If you want to see the system working end-to-end, that is the place to start:

cargo run --bin micro_mainnet_sim --features dev-node 2>/tmp/sim.log
grep "SUMMARY\|gas_payment\|Paid\|eth_simulateV1" /tmp/sim.log

Does it actually work? Part 5 has the data and the honest answer.

A Note on Honest Threat Assessment

We have been deliberately transparent about what our system does not protect against. A global passive adversary with infinite observation time will eventually correlate timing patterns. A system with a single user has an anonymity set of one. An adversary who controls all three layers of Alice's path achieves full deanonymization.

The honest assessment: Xythum provides meaningful privacy against realistic adversaries -- ISPs, chain analytics firms, RPC providers, mempool watchers, and partial network adversaries. It provides degraded but still substantial privacy against well-resourced adversaries who control significant portions of the mix network. It does not provide perfect privacy against an omniscient adversary with unlimited resources and infinite observation time. No system can [7].

The question is not whether the system is perfect. The question is whether it is better than the alternatives. A standard DEX swap provides zero metadata privacy. Tornado Cash and Railgun provide on-chain privacy but leak transport metadata (timing patterns, IP addresses). Xythum provides on-chain privacy AND transport privacy AND economic privacy, with quantified residual risks for each adversary class.

We think that is a meaningful improvement. We also think that honest, quantified assessment of what a privacy system does and does not protect against is more valuable than vague claims of "full privacy" or "complete anonymity." Every privacy system has limits. The question is whether you know what they are, and whether they matter for your threat model.

Part 5 shows the measurements: latency distributions, throughput limits, timing correlation coefficients, FEC recovery curves, and a comparison with Katzenpost and Nym's published numbers. If you are evaluating this system for production use, that is the post to read.


This is Part 4 of a 7-part series on metadata privacy for DeFi. Part 5: "We Benchmarked Everything" presents the performance data -- 42 charts, 33 data files, and a transparency problem in the privacy infrastructure space.


References:

[1] Dutch Court. (2024, May 14). Alexey Pertsev convicted and sentenced to 64 months for money laundering related to Tornado Cash.

[2] U.S. Department of Justice. (2024, April 24). Founders and CEO of Samourai Wallet charged with money laundering and unlicensed money transmitting offenses.

[3] ZachXBT. (2026, January). Investigation of $282M wallet compromise. Tracked cross-chain movement through THORChain and Tornado Cash. Reported by AMBCrypto.

[4] Cristodaro, C., Kraner, S., & Tessone, C. J. (2025). Clustering Deposit and Withdrawal Activity in Tornado Cash: A Cross-Chain Analysis. arXiv:2510.09433.

[5] ARD/NDR. (2014). XKeyscore source code analysis; Snowden documents. See also The Guardian (2013, July 31).

[6] European Court of Human Rights. (2021). Grand Chamber judgment on GCHQ Tempora. See also The Guardian (2013, June 21).

[7] Das, D., Meiser, S., Mohammadi, E., & Kate, A. (2018). Anonymity Trilemma: Strong Anonymity, Low Bandwidth Overhead, Low Latency -- Choose Two. IEEE Symposium on Security and Privacy (S&P), 108-126.

[8] nusenu. (2021). Tracking One Year of Malicious Tor Exit Relay Activities (Part II). Medium.

[9] The Record. (2021, December). A Mysterious Threat Actor Is Running Hundreds of Malicious Tor Relays (KAX17). Schneier, B. (2021, December). Someone Is Running Lots of Tor Relays.

[10] Nithyanand, R., et al. (2016). Measuring and Mitigating AS-Level Adversaries Against Tor. NDSS 2016.

[11] Anonymous Authors. (2025). Time Tells All: Deanonymization of Blockchain RPC Users with Zero Transaction Fee. arXiv:2508.21440v1.

[12] United States v. Roman Sterlingov (Bitcoin Fog). (2024). U.S. District Court, D.C. Chainalysis Reactor accuracy validated at 99.9146%.

[13] Oldenburg, L., Juarez, M., Argones Rua, E., & Diaz, C. (2024). MixMatch: Flow Matching for Mixnet Traffic. Proceedings on Privacy Enhancing Technologies (PoPETs), 2024.

[14] Attarian, S., Mohammadi, A., Wang, X., & Heydari Beni, A. (2023). MixFlow: A Novel Contrastive Attack on Loopix-based Mixnets. IACR ePrint Archive, 2023/199.

[15] Danezis, G. & Goldberg, I. (2009). Sphinx: A Compact and Provably Secure Mix Format. IEEE S&P 2009.

[16] Nasr, M., Bahramali, A., & Houmansadr, A. (2018). DeepCorr: Strong Flow Correlation Attacks on Tor Using Deep Learning. ACM CCS 2018, 1962-1976.

[17] Decrypt. (2022, November 24). Infura to Collect MetaMask Users' IP, Ethereum Addresses After Privacy Policy Update.

[18] Flashbots. (2026, February 17). Network Anonymized Mempools. Flashbots Writings.

[19] Buterin, V. (2025, April 11). A Maximally Simple L1 Privacy Roadmap. Ethereum Magicians Forum.

[20] Piotrowska, A. et al. (2017). The Loopix Anonymity System. USENIX Security 2017.

[21] Danezis, G. et al. (2003). Mixminion: Design of a Type III Anonymous Remailer Protocol. IEEE S&P 2003.

[22] Beres, F., Seres, I. A., Benczur, A. A., & Quintyne-Collins, M. (2021). Blockchain is Watching You: Profiling and Deanonymizing Ethereum Users. arXiv.

[23] Flashbots. (2023). MEV-Explore v1: Cumulative Extracted MEV Dashboard. Flashbots Research.

[24] Chainalysis. (2023). The 2023 Crypto Crime Report. Address clustering methodology.

[25] Ethereum Foundation. (2024). EIP-4844: Shard Blob Transactions. Ethereum Improvement Proposals.

0xDenji
0xDenji@XythumL

Building privacy infrastructure for the future of finance.

Related Articles

engineering

We Benchmarked Everything: NOX Performance Analysis

Criterion microbenchmarks, throughput tests, latency distributions, and entropy measurements — every number we quote, we measured.

·146 min read
research

A Brief History of Hiding: From Chaum's Mixes to Loopix

Tracing the evolution of anonymous communication — from David Chaum's 1981 paper through remailers, onion routing, and the Poisson mixing revolution.

·182 min read
research

ZK Proofs Hide What You Did. They Don't Hide That You Did It.

The privacy gap nobody talks about — why zero-knowledge proofs are necessary but not sufficient, and how metadata leaks compromise every current DeFi privacy protocol.

·107 min read