Original authors: Barry WhiteHat, Kobi Gurkan; full spec fleshed out by Koh Wei Jie; developed with assistance from the members of the Semaphore Society Telegram group.
Since Ethereum transfers are fully visible on-chain, anyone who knows a user's address can trace the source of their funds, determine even how much they own, and analyse their on-chain activity. As such, users do not enjoy much financial privacy beyond pseudonymity. Some workarounds to obscure value flows, like using a centralised exchange wallet or a custodial mixing service, however, introduce a high degree of counterparty and surveillance risk. What the Ethereum ecosystem needs is a noncustodial mixer which works through strong cryptography, rather than on trust.
This is a specification for a minimal Ethereum mixer based on zero-knowledge proofs. It improves transaction privacy by breaking the on-chain link between recipient and destination addresses. It allows a set of users to deposit a fixed amount of ETH or ERC20 tokens each (e.g. 0.1 ETH or 100 tokens), and pay a third-party relayer to trustlessly withdraw the ETH or tokens respectively to a fresh address. As the mixer is noncustodial, they can permissionlessly withdraw their own ETH at any time.
Under the hood, the mixer uses the Semaphore zero-knowledge signalling smart contracts and circuits. At this stage, it relies on a single relayer node, and will only support fixed amounts. Future versions of this mixer will employ a burn relay registry to safely reduce centralisation.
Assume that there are three users who want to mix their funds: Alice, Bob, and Charlie, and a relayer (also known as an operator), Oscar.
Alice, Bob, and Charlie each deposit 1 ETH into the mixer's smart contract. This deposit transaction carries a piece of data which commits to their identity.
To increase her anonymity, Alice's user interface waits till after midnight UTC time to expose a button, which when clicked, generates a zk-SNARK proof and sends it to Oscar. Public inputs to this proof include the recipient's address and a fee for Oscar. The proof demonstrates that Alice had previously deposited ETH and that she had not already withdrawn it. This step, notably, does not reveal Alice's original identity.
Oscar invokes the contract's mix function, which verifies the proof and transfers ETH to the recipient's address. It also sends the a small fee to Oscar.
If Alice wishes to withdraw her ETH by herself, she can do so herself, but this will not give her privacy as this transaction will link her depositing and receiving addresses.
Figure: Overview of system components.
The mixer comprises of a user-facing web3 dApp and the relayer's backend server. The dApp allows users to deposit ETH or tokens and submit withdrawal proofs at a predetermined time (specifically midnight UTC). Even though the user relies on the relayer to perform withdrawals transactions, they can opt to use the dApp to withdraw their funds at any time at the cost of some anonymity.
This system is more complex than other dApps which share this pattern web3-client-server pattern. As will be described below, the UI includes an EdDSA keystore and proof generation capabilities, and the relayer's server should have internal daemons to handle a hot wallet and transaction nonces.
TODO: In-depth spec of Semaphore. Alternatively, add it as an appendix. Note that the work to be done on the mixer will entail major changes to Semaphore, so its readme on Github will be obsolete in regards to the mixer.
Name | Description |
---|---|
signal |
The hash of recipientAddress , broadcasterAddress , and fee |
externalNullifier |
See below |
root |
See below |
nullifiersHash |
See below |
Semaphore uses externalNullifier
and nullifiersHash
to prevent double-withdrawals from the same user. Semaphore also uses root
(along with other private inputs below) to ensure that the user is indeed a depositer.
All the private inputs to the circuit are the same as those described in the Semaphore documentation. At the time of writing, these are:
identityPk
identityNullifier
identityPathElements
identityPathIndex
authSigR
authSigS
TODO: Figure out how to perform a Powers of Tau ceremony for a circom
trusted setup. Also look into the Gnosis team's trusted setup, if they did one.
TODO: There is a case to be made about replacing the MiMC hash function in Semaphore with Pedersen hashes for reasons of cryptographic security. As the gas costs for Pedersen hashes before EIP-1829 and EIP-1962 are very high, there should also be a way for anyone (such as the relayer) to batch deposits and add them to the Semaphore tree. These two changes are major overhauls to Semaphore. Note that while batched deposits can be done without using Pedersen hashes, if we use expensive Pedersen hashes, we must use batched deposits. This can be done after the minimal mixer.
Storage variable | Type | Description |
---|---|---|
mixAmt |
address |
The constant amount of ETH or tokens that a user can mix per deposit |
semaphore |
Semaphore |
The address of the deployed Semaphore contract, represented as a Semaphore type in Solidity |
token |
address |
If the contract is meant to mix ERC20 tokens, this should be the token contract's address. If it is meant to mix ETH only, the address should be the zero address 0x0000... . |
Function | For ETH or ERC20 | Description |
---|---|---|
deposit |
ETH | Payble function for ETH deposits |
mix |
ETH | Verify a withdrawal proof and transfer ETH to the recipient |
depositERC20 |
ERC20 | Transfer mixAmt tokens from the caller into the contract |
mixERC20 |
ERC20 | Verify a withdrawal proof and transfer tokens to the recipient |
deposit(uint256 _identityCommitment)
This function will invoke the Semaphore contract's insertIdentity()
function, passing on the identityCommitment
value as the leaf. In turn, insertIdentity()
updates the identity Merkle tree in the contract. identityCommitment
should be the Pedersen hash of identityPk
(the user's EdDSA public key) and identityNullifier
identityNullifier
should be a random 31-byte (253 bits, to be precise) value for each deposit.
depositERC20(uint256 _identityCommitment)
Does the same as deposit()
as described above, but instead of accepting ETH, this function invokes the transferFrom()
ERC20 function of the token
contract to transfer tokens from the sender to itself.
mix(DepositProof _proof, address payable _relayerAddress)
mix
lets anyone with a valid and unspent zk-SNARK proof of a prior deposit release funds to a recipient. The operator pays the gas for this transaction, and receives _proof.fee
in return. We intend for third party relayers to make these transactions via a burn relay registry. An economically rational operator will only act on mix requests where the fee is high enough to compensate for the service they provide and the gas they pay.
The function does the following:
_proof.fee
is less than mixAmt
.signal
(from the proof input) into the Semaphore contract's signal hash Merkle tree._proof.fee
to the relayer at _relayerAddress
.funds - _proof.fee
to the recipient.mixERC20(DepositProof _proof, address payable _relayerAddress)
Does the same as mix()
but transfers tokens instead of ETH. Note that the fee is paid to the relayer in tokens.
DepositProof
data structureThe mixer contract will use code from circom
's Solidity zk-SNARK verifier. As circom
's contract internally defines a Proof
struct, the mixer's mix
function uses a struct named DepositProof
instead:
struct DepositProof {
bytes32 signal;
uint[2] a;
uint[2][2] b;
uint[2] c;
uint[4] input;
address recipientAddress;
uint256 fee;
}
The values in input
are:
root
nullifiersHash
signalHash
externalNullifier
The signal
input to the zk-SNARK, also referred to as computedSignal
, is the Keccak256 hash of the recipientAddress
, broadcasterAddress
, and fee
.
The UI requires a browser which natively supports the Javascript BigInt
, such as Firefox 68 (yet to be released at the time of writing) or Chrome 67 and above.
The browser should be web3-enabled — that is, it should have a way to sign and send Ethereum transactions, such as the MetaMask extension. The UI should also intelligently keep track and display the user's selected wallet via an on-screen widget. Good examples include those from Uniswap and Radar Relay.
Figure: The wallet widget from Radar Relay
To increase the anonymity set, the UI will force the user to wait till after midnight, UTC time, before they can perform a withdrawal. The UI will only show a button to let the user withdraw funds after midnight. This restriction is only at the UI level.
This approach is a response to an initial technique of waiting for a certain number of deposits or a random time interval, whichever is longer. Said technique, however, would make the mixer vulerable to spam attacks where a user can be deanonymised by an attacker which fills up the deposit limit such that the effective anonymity set of the victim is only 1. Downsides to this technique, however, include the fact that this technique does not strongly guarantee a large anonymity set, especially when usage of the mixer is low.
Additionally, wait times can be adjusted such as once every 6 hours or once an hour.
Olivier from Argent contributed this idea and the above analysis.
The UI will create an EdDSA key each time a user makes a deposit. A user's EdDSA key is essential for them to generate a withdrawal proof. Without this key, they cannot mix or withdraw their funds. As such, if they lose their key, they will lose any funds deposited using it.
The UI ensures availability of keys by storing them in the brower's localStorage. To keep the MVP minimal, which only handles small amounts of ETH, we do not encrypt the key.
UIs which handle larger amounts, should encrypt it with a password that only the user knows. TODO: The internal mechanism for key encryption and storage. Check out Hedgehog, which stores an entropy key in the user's localStorage and uses AES-256-CBC along with a username and password to encrypt a BIP-32 seed. Their approach should be examined and adopted if it makes sense. Another good approach is to use NaCl, which offers safe defaults. Also figure out how to isolate EdDSA key management code from the rest of the UI as we need to prevent NPM supply chain attacks. This may entail implementing enhancements to wallets to create a standard EdDSA signing API.
Also see the App Keys standard, currently in progress.
TODO: Create a security model around key reuse, backup, and loss. Map out risks and potential recovery mechanisms if a user loses their EdDSA key, or if it is stolen.
TODO: check whether it's okay to reuse keys
There is a risk that if a user's EdDSA key is stolen by an attacker, such as if malicious code finds its way into the frontend via an NPM security hole and extracts it from localStorage
.
Once a user makes a deposit, the UI will force them to wait a random amount of time (e.g. between 10 and 30 mins), and then generate the proof required to perform the withdrawal. The proof is then sent to the broadcaster, but it must have enough proofs to batch together to provide a strong enough anonymity set.
Note that this differs from the MVP proposal of sequentially processing an array of multiple proofs.
HaRold Batching proofs together does three things:
- Aggregate gas saving, would only require 1 zkSNARK proof per batch
- Obscures ordering of withdraws from on-chain contract, makes chronological window more fuzzy by bucketing payments into same timeframe/epoch
- Batch must support 1-N transactions (with N being the maximum), otherwise low transaction volume will block withdraws if a full batch of N is required.
HaRold Problem with batching: the anonymity relies upon the user submitting the zkSNARK proof to the chain. Using Groth16BatchVerifier you can get a slight cost reduction when verifying multiple proofs, but without recursive SNARKs (or snark-inside-snark verification), or verifying ring-signatures inside a zkSNARK, you would lose all anonymity between the user and the transaction relayer.
Let the safe minimum anonymity set be n
. In practice, this could be around 6 to 8. The user may also opt for extra privacy, which entails a larger anonymity set k
. Since the Semaphore contract stores a history of up to 100 Merkle tree roots (currently an arbitary number), that is the maximum value of k
.
HaRold I don't think 6-8 is a safe number for an anonymity set, but it's the cross-over point where a ring linkable ring-signature of 6-8 participants becomes equivalent in gas price to a zkSNARK proof.
An anonymity set of hundreds to thousands is much better, and is achievable using both linkable ring signatures (with multiple rounds of mixing) or a zero-knowledge proof (e.g. a zkSNARK) of set membership (where the anonymity set is continuously growing the longer you delay your withdraw).
There needs to be further research to decide on the exact values n
and k
, where there are acceptable tradeoffs between privacy and waiting time for the minimal mixer.
Given the above context, consider the process flow from the broadcaster's point of view. We assume that there are no other anonymity set sizes besides n
and k
.
The broadcaster keeps track of two lists of proofs b[]
and c[]
. Each proof list has a UUID. It also has a threshold value t
between 1 and 100 whose use will be described below.
Whenever the user submits a proof via a submitProof(proof, minAnonSet)
API endpiont, the broadcaster follows these steps:
If minAnonSet == n
:
1.1. Generate a random value r
between 0 and 100.
1.2. If r > t
, append proof
to c
. Next, if len(c) == k
, asynchronously dispatch c
to the mix()
contract function.
1.3. If r <= t
, append proof
to b
. Next, if len(b) == n
, asynchronously dispatch b
to the mix()
contract function.
Otherwise:
2.1. Append proof
to c
.
2.2. If len(c) == k
, asynchronously dispatch c
to the mix()
contract function.
Respond with a success code, and if the mix()
contract function was invoked, its transaction hash.
In effect, this algorithm randomly funnels 1 out of 5 users who do not opt for extra privacy to the higher anonymity set of size k
. This means that the users who do opt for extra privacy do not have to wait for up to k-1
other users who want extra privacy, and 100 - t
% of users who go with the default option can enjoy extra privacy. The downside is that t
% of users who go with the default have to wait slightly longer to get their funds mixed.
Note that t
is an arbitary value. To determine an optimum t
, one should consider the estimated percentage of users who want extra privacy and the ratio n : k
.
deposit(uint256 identity_commitment)
register(address pubkey, string tor_address, uint256 fee)
There is a smart contract where anyone can register to be an operator by posting a mapping for their public key to tor address as well as the fee they charge in wei, which they can update.
User can select any of these operators to broadcast their transactions. The operator is selected via burnedFee
weighted by their fee requirement and some randomness.
struct proof {
bytes memory signal,
uint[2] a,
uint[2][2] b,
uint[2] c,
uint[5] input // (root, nullifiers_hash, signal_hash, external_nullifier, broadcaster_address)
}
withdraw(proof[] proofs)
input = hash(recipient, broadcaster, fee)
For each proof in proofs we check
burnedFee[address] -> uint256
mapping.There is a possibility that a user sends a transaction to multiple broadcasters. The first one to withdraw gets the fee. The remainder get nothing and lose some of their gas payment.
We mitigate this 5 ways
Think about how to let mixer interact nicely with smart contracts on eth.
circom
's Pedersen circuits.We should do a test to see nodes will propogate 0 value transactions