Merces II: Deep Dive
This post is the technical companion to Merces II: What Onchain Finance Should Look Like. That post argues onchain finance needs private accounts. This is how we built them.
Merces II is a private account system for stablecoins on public blockchains. Balances are encrypted, transfers hide all parties and amounts, and every state transition is verified by a zero-knowledge proof onchain. The system runs as a standard Solidity contract, no protocol-level modifications, no custom chain, deployable wherever the EVM does.
This post walks through the full construction: the smart contract and its action queue, the MPC network that maintains encrypted state, the two families of zero-knowledge proofs, the gateway for gas abstraction and sender privacy, and the compliance model for selective disclosure.
The Architecture
Three components make the system work:
The smart contract is the ground truth. It holds the actual ERC-20 tokens backing all private balances, stores the current commitment (Merkle root) to the encrypted balance tree, and maintains an action queue where users register intents: deposits, transfers, withdrawals. It never sees plaintext balances or counterparty relationships. It only verifies proofs.
The MPC network is a set of three computing nodes that jointly hold secret shares of every user’s balance in a Merkle tree, built on TACEO’s Oblivious Map (OMap) — a private, verifiable key-value store designed for efficient encrypted state updates. No single node knows any balance. When an action appears in the queue, the nodes collaboratively update the relevant balances, compute the new Merkle root, and produce a zero-knowledge proof that the update was done correctly. One node then posts the proof and new root onchain.
The user’s client (wallet or frontend) constructs the cryptographic material needed to register an action: commitments to sender, receiver, and amount, encrypted secret shares for the MPC network, and a ZK proof that everything was constructed honestly.
This separation is the core design insight. Users register intents onchain. The MPC network executes them offchain. The smart contract verifies the result.

The Merces Smart Contract
The smart contract essentially has the following roles assigned:
- Serving as the ground truth of the current state. This includes holding the actual tokens which correspond to the hidden account balances, as well as the current commitment to the balances.
- Allowing the users to register actions
- Allowing the MPC network to process actions
To this end, the smart contract holds the commitment of the current balance Merkle-tree (which is hold by the MPC nodes) and an action queue for which users can register deposits, wihtdraws and transfers. The core functions are the following:
The Action Queue Pattern
Rather than processing transfers synchronously (which would require the contract to know plaintext values), the contract uses an asynchronous queue. A user registers an intent, such as a private transfer, by posting commitments and encrypted data onchain. The MPC network reads the queue, processes the intent, and calls back with a proof and updated state.
This pattern decouples registration (which anyone can verify is well-formed via client ZK proofs) from execution (which requires the MPC network’s private state). It also means the contract never needs to touch plaintext amounts, senders, or receivers during a transfer.
Key Interfaces (Fully Private)
The contract exposes four core functions. The signatures reveal what’s hidden and what’s public in each operation.
Deposit function
function deposit(
uint256 amount,
uint256 receiverCommitment,
uint256 nullifier,
uint256 merkleRoot,
uint256 beta,
Ciphertext calldata ciphertext,
uint256[4] calldata proof
) public payable validAmount(amount) returns (uint256);
A user can deposit tokens by calling the deposit function. Internally, this function triggers a ERC-20 payment from the user to the on-chain contract, and upon success, registers a deposit in the action queue.
Note, since we have to transfer the actual amount of tokens to the smart contract, the transfer amount must be public here. The actual receiver is secret-shared and the shares are encrypted for the nodes of the MPC network. A ZK proof is attached which proves correct encryption of the secret shares and also proves that the shares match the commitment to the receiver of the private tokens.
The function also has the beta and merkleRoot parameters, which are artifacts of the ZK proof generation, as well as a nullifier which prevents replaying the same proof again.
As a result, the deposit function returns the position of the registered action in the action queue. To be able to act on this return value programmatically, we also emit the position as an event.
Withdraw function
function withdraw(
uint256 senderCommitment,
uint256 amount,
uint256 merkleRoot,
uint256 nullifier,
uint256 beta,
address receiver, // The one receiving the withdrawn funds
Ciphertext calldata ciphertext,
uint256[4] calldata proof
) public validAmount(amount) validRoot(merkleRoot) returns(uint256);
A withdraw only registers the intent in the action queue since we are only allowed to pay out the actual tokens if the user has enough balance.
This only gets verified by the MPC network once it processes the request. Similar to the deposit step, if the withdraw request is valid (and verified by the MPC network), an actual ERC-20 token transfer is triggered to the specified receiver address. Thus, the transfer amount must be public here as well. The actual sender is secret-shared and the ciphertexts are encrypted for the nodes of the MPC network. A ZK proof is attached which proves correct encryption of the secret shares, that the shares match the commitment to the sender of the private tokens, and that the proof creator knows the secret key corresponding to the senders account.
The function also has the beta, nullifier and merkleRoot parameters. While beta is an artifact of the ZK proof generation, the nullifier prevents replaying the same proof again and merkleRoot represent the root of the ID-registry which is internally checked to be valid.
The withdraw function also returns the position of the registered intent in the action queue and emits an event with the same value.
Transfer function
function transfer(
uint256 senderCommitment,
uint256 receiverCommitment,
uint256 amountCommitment,
uint256 merkleRoot,
uint256 nullifier,
uint256 beta,
Ciphertext calldata ciphertext,
uint256[4] calldata proof
) public validRoot(merkleRoot) returns (uint256);
The transfer function registers the intent of transmitting some tokens to a receiver. Since the sender, receiver and actual token amount is hidden, they are only given as a commitment, which later will be used to verify the ZK proof produced by the MPC network.
A nullifier is given to prevent proof replaying, merkleRoot is the root of the ID-registry, ciphertext contains the secret shares of the sender, receiver, amount and randomness for the commitments, and beta is a proof artifact.
The proof itself attests to correct encryption of the secret shares, that the shares match the provided commitments, as well as proving that the creator of the proof knows the secret key corresponding to the sender account.
Similar to deposit and withdraw, the transfer function returns the position of the registered intent in the action queue and emits it also as an event.
ProcessMPC function
function processMPC(
uint256 newStateCommitment,
bool valid,
uint256[4] calldata proof
) public onlyMpc returns (uint256);
The MPC network produces a ZK proof attesting for correct evaluation of the next item in the action queue. Once done, it calls processMPC with the new commitment to the Merkle-tree holding all balances, as well as a flag indicating if a proof is valid. Should a sender not have enough balance to fulfill the request during a withdraw or transfer, this flag will be invalid. Consequently, this flag is used to verifiably proof that a transaction is faulty such that it can be removed from the action queue without raising concerns of censorship.
The actual proof verification in this function requires the old Merkle-tree root, the newly provided one, as well as the commitments provided by the user when registering the transaction to proof that correct values have been used to update the MPC Merkle-tree. The valid flag is also part of the proof verification.
Similar to the client functions, processMPC returns the position of the processed intent in the action queue and emits it also as an event.
The ID Registry
The purpose of the ID-registry is to register users to be able to use Merces, as well as translating the public key of the user to a incrementing index which makes the MPC computations way more efficient. The registry itself is implemented as an incrementing Merkle-tree, where a new user gets the index of the next free leaf.
The registry is also represented in an ID-Registry smart contract, such that the Merces contract can check whether the Merkle roots provided by a client to proof it is part of the registry are actually valid ID-registry roots.
Confidential vs. Fully Private Mode
Merces II supports two privacy modes, selectable per transaction. The client can choose to register transactions in confidential mode, which reveals the sender/receiver addresses of a transaction (while the transferred amount stays private) instead of fully private mode. The design choice to support both modes reflects a practical reality: different use cases have different privacy requirements.
The confidential interfaces to these functions are the following:
function depositConfidential(uint256 amount, uint256 receiver)
public
payable
validAmount(amount)
returns (uint256);
function withdrawConfidential(
uint256 sender,
uint256 amount,
uint256 merkleRoot,
uint256 nullifier,
uint256 beta,
address receiver, // The one receiving the withdrawn funds
Ciphertext calldata ciphertext,
uint256[4] calldata proof
) public validAmount(amount) validRoot(merkleRoot) returns (uint256);
function transferConfidential(
uint256 sender,
uint256 receiver,
uint256 amountCommitment,
uint256 merkleRoot,
uint256 nullifier,
uint256 beta,
Ciphertext calldata ciphertext,
uint256[4] calldata proof
) public validRoot(merkleRoot) returns (uint256);
Compare depositConfidential to its fully private counterpart: two parameters instead of eight, and no ZK proof at all. Since the receiver is a plaintext index (not a commitment), there’s no need to prove correct encryption of secret shares or knowledge of a secret key. The transferConfidential signature tells the same story — sender and receiver are plaintext indices, only the amount remains committed.
The MPC Network
The MPC network in this setup consists of the 3 computing nodes. They constantly poll the smart contract to read the action queue and retrieve the intent, as well as the encrypted secret-shares it requires for computation.
Each party of the MPC network holds secret shares of the full Merkle tree which hold the balances of all users. For each transaction it updates the Merkle-tree, computes the new commitment to the whole tree (i.e., the root of the Merkle-tree), and creates the ZK proof of correct update of the tree, which also includes verifying that the committed to values (sender, receiver, amount) of the client have been used to compute the Merkle-tree updates.
Once done, one of the 3 MPC parties posts the proof alongside the new commitment on-chain.
Reading balance
To read the current balance, a user directly queries the MPC network which responds with the (encrypted) secret-shares of the balance, which can be combined by the users. Note that for a real system, the user should provide a ZK proof of owning the correct secret-key alongside the secret-shares of its id to the network, such that the MPC network is not leaking the balance to someone else. In confidential mode (i.e., the user leaks its address to the MPC network while querying the balance), a signature is sufficient.
Compliance
To showcase how compliance can be incorporated into the Merces design, we give “regulators” the possibility to request revealing transactions which belong to specific user addresses. This is implemented by the MPC network keeping the history of the transactions (in secret-shared form) such that it can be searched on request. When a regulator provides an address, the MPC network will engage in an MPC protocol where the history is searched (using linear scan). Whenever a match is found, the corresponding shares of the transaction details are opened (i.e., revealed) to the regulator.
We want to note that in a real setup, some form of authentication (e.g., a signed warrant from the regulator) must be provided to the MPC network, such that no-one can misuse this compliance service.
Transaction History
Similar to the compliance service, users can request their transaction history from the MPC network. The MPC network, on request, will thus perform a linear scan of the transactions to be able to reveal the right transactions to the user. Similar to reading balances, a real setup requires authentication for this step.
The Zero-Knowledge Proofs
Client Proofs
The client proofs are purely written in Circom and evaluated using SNARKjs. To register a transfer, the client has to proof the following statements:
- The client knows the sender secret key, for which the corresponding public key is stored at the sender index in the ID-registry. This is a Merkle-tree proof for which the merkleRoot of the ID-registry is required to verify.
- The commitments to the sender, receiver, and amount are correctly computed.
- The sender, receiver and amount is correctly secret shared (alongside the randomness used to compute the commitments), and the shares are encrypted using the MPC networks public keys.
- The amount is positive.
Encryption is done using symmetric encryption (using Poseidon2 in duplex mode for ZK friendliness) where the encryption key is derived from the receiving MPC nodes public key. Concretely, the smart contract holds a public key for each MPC party, and the user samples a fresh keypair for each transfer. Notice, that by knowing one secret key, and the other public key, you can derive the shared secret as where is a generator of the used group. The actual encryption key is then the -coordinate of . For this to work, the user needs to send the sampled public key to the smart contract as well.
Since each Groth16 proof requires a large proving key, we embed the proofs for registering a deposit or a withdraw into the transfer proof, where during a deposit we only have a hidden receiver, during a withdraw we only have a hidden sender, and in both cases the amount is actually public.
MPC Proofs
To create the ZK proofs in MPC, we use our CoCircom pipeline. More concretely, we define the proofs in Circom and evaluate them in MPC using CoCircom. For a transfer, we essentially prove the following relation:
- The sender/receiver/amount commitments are recomputed to prove that the correct inputs are used
- We deduct the balance of the sender and add them to the receiver
- We update the Merkle-tree correctly. Verification requires the old commitment as well as the new one.
- Verify that the new sender balance ≥ 0. If not, set the valid flag to false.
- Verify that the transaction amount is positive.
A deposit/withdraw proof is essentially a subset of the transfer proof, where a deposit only has a receiver and a withdraw only has a sender.
The Gateway
In this deployment we offer a gateway, which submits the transaction on behalf of a user in order to pay for the gas. Additionally, it also offers additional privacy protections, since it hides which wallet initiates the on-chain transactions.
During a transfer, the ZK proof fully authenticates the authenticity of the transaction. Thus, a user can just create the ZK proof and any party (such as our gateway) can post the transaction on chain without issues. The nullifier, thereby prevents replaying the proof.
A deposit/withdraw using the gateway requires taking into consideration:
- During a Withdraw, we let users specify the address of who should actually receive the tokens in the end. Without extra steps, a gateway could just overwrite this address in favor of a different beneficiary. Consequently, we designed the ZK proof, which is created for registering a withdraw, to require the receiver address as public input during verification, preventing this issue.
- During a deposit-registration, an actual token transfer from the source address is happening. Consequently, a gateway would be the party actually paying the tokens. Thus, we offer the gateway only for tokens which implement EIP-3009. Thereby the user provides a signature allowing the gateway to post the transaction on-chain whereas the tokens are deducted from the user instead. Care has to be taken here as well, such that the gateway cannot rewrite the
receiverCommitment(especially in the confidential version where no ZK proof is present). Consequently, we use thereceiverCommitmentas the nonce during signing of the request. Note that the nonce is internally used as nullifier in EIP-3009 to prevent replaying the signature for a second request.
EIP-3009 Interface
For ERC-20 tokens, which additionally implement EIP-3009, we offer additional functions to deposit tokens into the Merces contract:
function depositEip3009(
address from,
uint256 amount,
uint256 receiverCommitment,
uint256 nullifier,
uint256 merkleRoot,
uint256 beta,
// For the EIP-3009 authorization
uint8 v,
bytes32 r,
bytes32 s,
Ciphertext calldata ciphertext,
uint256[4] calldata proof
) public validAmount(amount) returns (uint256);
function depositConfidentialEip3009(
address from,
uint256 amount,
uint256 receiver,
// For the EIP-3009 authorization
bytes32 nonce,
uint8 v,
bytes32 r,
bytes32 s
) public payable validAmount(amount) returns (uint256)
These functions allow the (non-private) token holder to sign a token transfer.
Notice, that the interface is the same as for deposit and depositConfidential, except for the addition of the signature which authenticates the token transfer from the from-address.
Looking Forward
Merces II demonstrates private transfers, the foundation of a private account system. Extending this to the full range of onchain finance operations (swaps, lending, yield) means solving a set of concrete technical problems: supporting multiple assets within the same encrypted Merkle tree, enabling private interactions with external DeFi contracts while preserving the proof model, and scaling the MPC proof generation to handle higher-complexity operations without degrading throughput.
The system is deployed on the Plasma testnet. Reach out to us to integrate or build on top of the construction.
Try the app: Merces II
Read the paper: A Private Verifiable Sparse Merkle Tree
Talk to us: [email protected]