An auditor's-eye look at confidential computing on Solana — and a class of bug that the cryptography won't catch for you.
- Arcium lets Solana programs compute over encrypted data using MPC — the plaintext never appears anywhere, on-chain or off. Great for auctions, dark pools, private balances.
- An encrypted input is a bearer token: it's bound to the application's MXE key and nothing else — not to a user, an auction, or a specific computation.
- The bug (cross-context ciphertext replay): copy a victim's public ciphertext and feed it into a different computation in the same app (e.g. a dummy auction you control that resolves early). Same MXE → same key → it decrypts to the victim's real value. Per-MXE key isolation does not help, because you never left the MXE.
- It's worse than early reveal — it's a decryption oracle. Even a circuit that reveals only the winner/clearing price leaks the exact secret: a first-price clearing price is the top bid, and a one-bit "who won?" reveal becomes a binary search under attacker-chosen co-bids.
- It's not fixed by revealing less, or by nonces. Root cause is missing domain separation on confidential inputs. The fix is to bind the input to its context and verify that binding inside the circuit (in-circuit Ed25519 over
(context_id, …)), backed by on-chain signer/PDA binding.- For auditors: any multi-context Arcium app (multiple auctions/markets/rounds, reusable state) is suspect until inputs are provably context-bound. Confidentiality of an input is a property of the whole MXE — audit every
reveal()in every circuit.
Why this article
Arcium brings something genuinely new to Solana: the ability to compute over data that stays encrypted during the computation, not just at rest and in transit. That unlocks dark pools, sealed-bid auctions, private order books, hidden-state games, and confidential balances — applications that are impossible on a transparent ledger.
But "the data is encrypted the whole time" is a property of the cryptographic core, not of your application. The core can be flawless and your app can still leak every secret it was supposed to protect — through ordinary integration logic that an auditor would recognize from any Solana program, plus a few pitfalls unique to confidential compute.
This article walks through one such pitfall end to end. It is a confidentiality break we'll call cross-context ciphertext replay. It is simple to trigger, it escalates into a full decryption oracle, and — importantly — the protocol's strongest guarantee (per-MXE key isolation) does nothing to stop it. First we'll build enough of a mental model of how Arcium works to see the bug clearly. Then we'll exploit it, classify it, and fix it.
How Arcium works (the parts that matter)
The cast
Arcium is a decentralized confidential computing network that uses Solana as its coordination and settlement layer. Four pieces are enough to follow along:
- MXE (Multi-party eXecution Environment) — your application's confidential "VM." It owns the circuits (the confidential programs) and an encryption keypair. Each MXE is tied to a Solana program.
- Arcis — the Rust framework you write circuits in. Circuits compile to a fixed Multi-Party Computation (MPC) structure.
- Arx nodes / clusters — the off-chain workers. A cluster is a set of Arx nodes that jointly run your computation using MPC. They stake collateral and can be slashed.
- The Arcium Solana program — the on-chain orchestrator: a mempool/queue, per-computation accounts, fee handling, and callback dispatch. Your own Anchor program (the "MXE program") talks to it.
The cryptographic engine is MPC: data is split into secret shares spread across the nodes. No single node ever holds a complete value. Nodes compute directly on the shares, so a plaintext input never materializes anywhere — not on an RPC, not on-chain, not in any node's memory, not in any operator's logs.
The lifecycle of one confidential computation
Client MXE program Arcium program Cluster
(browser) (your Anchor) (on-chain) (Arx nodes)
| | | |
1. encrypt locally | | |
|--- tx w/ ciphertext ->| | |
| 2. queue_computation (CPI) -->| |
| | 3. task into mempool |
| | |----- picked up ------>|
| | | 4. compute in MPC
| | |<- BLS-signed result --|
| 5. callback <----------------| |
| | verify_output(...) | |
|<--- result / event ---| | |
- Encrypt locally. The client derives a shared secret with the MXE and encrypts its inputs (more below).
- Queue. Your program builds the encrypted arguments and CPIs into
queue_computation, paying fees/rent and naming a callback. - Mempool. The Arcium program places the task in an on-chain mempool. Tasks can carry a validity window (
valid_after/valid_before). - Compute. A cluster pulls the task and runs the circuit over secret shares.
- Callback + verify. The cluster returns a result with a BLS signature; your callback calls
verify_output(&cluster_account, &computation_account), which checks that signature against the cluster's on-chain key. Only verified results are accepted.
A key consequence for later: the nodes are passive workers. They never read your business rules. The only thing that makes a computation (and any reveal it performs) happen is a task appearing in the mempool — and the only thing that can put it there is a successful queue_computation from your program. "Reveal at midnight" is enforced by your contract and Solana's clock, never by node discretion.
How inputs are encrypted
The client side is small and worth reading literally (this is the documented pattern):
// Fetch the MXE's x25519 public key — bound to the target program const mxePublicKey = await getMXEPublicKeyWithRetry(provider, program.programId); const privateKey = x25519.utils.randomSecretKey(); // ephemeral client key const publicKey = x25519.getPublicKey(privateKey); const nonce = randomBytes(16); const sharedSecret = x25519.getSharedSecret(privateKey, mxePublicKey); // ECDH const cipher = new RescueCipher(sharedSecret); const ciphertext = cipher.encrypt(plaintext, nonce); // goes on-chain
Two facts from this will drive everything:
- The encryption key is the MXE's key, fetched via
program.programId. Its private half is generated by Distributed Key Generation and exists only as shares across the cluster — no node, and no operator, can decrypt alone. - The ciphertext, public key, and nonce are public bytes. They travel on-chain in the clear; only the plaintext is protected.
Inside a circuit, inputs are wrapped in an Enc<Owner, T> type:
| Type | Who can reveal |
|---|---|
Enc<Shared, T> | the client and the MXE |
Enc<Mxe, T> | the MXE cluster only |
input.to_arcis() decodes a ciphertext into secret shares (in MPC — nobody sees plaintext). owner.from_arcis(x) re-encrypts a result. x.reveal() declassifies a value to public plaintext. What a circuit reveals, and to whom, is fixed in the circuit's source at compile time.
The MPC mental model in one paragraph
Because the circuit compiles to a fixed structure before any data flows, it cannot branch on a secret. So when a condition depends on hidden data, both branches execute and the secret condition merely selects the result (out = c*a + (1-c)*b), without any node learning which side "won." Loops must have compile-time-fixed bounds; there's no secret-dependent break. The upshot: the shape of the computation is public and constant; only the values are hidden, and only the explicit reveal()s come out.
The trust boundary: what the protocol gives you, and what it doesn't
It's worth stating the dividing line precisely, because the bug lives exactly on it.
The protocol guarantees (under Cerberus, its dishonest-majority backend): as long as one node in the cluster is honest, your computation is correct and your secrets stay secret. Different MXEs have different keys, so one program cannot decrypt another program's ciphertexts. The callback's verify_output ensures your contract only acts on genuine, cluster-signed results.
The protocol does not give you: any binding between a ciphertext and the computation it was meant for. An encrypted input is a bearer token. It is scoped to the MXE key — and to nothing else. It is not bound to a user, an auction, a market, or a specific circuit invocation. Whoever holds the bytes can present them as input to any computation that the MXE exposes.
That single gap is the whole vulnerability.
Mental reframing: the confidentiality of one input is not a property of the one computation you had in mind. It is a property of the entire MXE — specifically, the union of every
reveal()reachable across every circuit the MXE can be made to run on that input.
The vulnerability: cross-context ciphertext replay
Consider a sealed-bid auction program. To be useful it supports many auctions. The intended flow:
- Bidders encrypt their bids and submit them to an auction.
- At the auction's deadline, a
resolvecomputation runs and reveals the winner and clearing price — never the individual losing bids.
Alice bids 100 on Auction A, which ends in seven days. Her ciphertext sits on-chain as public bytes.
Now the attacker:
- Copies Alice's ciphertext (it's public).
- Opens Auction B — same program, therefore the same MXE and the same key — with a deadline of one day, and submits Alice's copied ciphertext as a bid in B.
- Waits one day. Auction B resolves.

Because B runs on the same MXE, to_arcis() decodes Alice's ciphertext into her real secret shares (100). The per-MXE key isolation that defeats cross-program attacks is useless here — we never left the MXE. Auction B happily computes over Alice's true bid, seven days early, in a context she never consented to.
Note there is no cryptographic break in any of this. The attacker never decrypts anything and never learns a key. They relay the public blob — ciphertext, client public key, and nonce, all of which travel on-chain in the clear — and the honest MXE does exactly what it always does: re-derives the x25519 shared secret from its key shares plus the client's public key, and decodes to secret shares. The plaintext never materializes for the attacker either; it just flows into a computation of the attacker's choosing. That is precisely what makes a bearer token dangerous: no forgery is required, only a copy-paste.
This is cross-context ciphertext replay: a valid confidential input, replayed into a different computation context within the same MXE.
"But my circuit only reveals the winner and price"
Here's why being stingy with reveal() does not save you. The replay turns the auction into a decryption oracle.
First-price, one step. In a first-price auction the clearing price is the top bid. Put Alice's copied ciphertext into Auction B with the attacker's own bid set to 0. Alice wins, and the revealed clearing price is exactly 100. Done.
Even if you only reveal the winner's identity — a single bit — you binary-search the value. The attacker runs a dummy auction with their own, known co-bid T:
- "Alice won" ⟹
bid > T - "Attacker won" ⟹
bid ≤ T
Repeat with T = 128, 64, 192, …. About log₂(range) dummy auctions later, the attacker knows Alice's exact bid. A one-bit reveal plus attacker-chosen co-inputs plus replay equals full plaintext recovery.
The takeaway is uncomfortable: you cannot patch this by revealing less. The leak is the replay, not the verbosity of the output.
One precondition, stated plainly. The full decryption-oracle escalation assumes the MXE exposes some circuit that mixes a victim's ciphertext with attacker-chosen inputs and reveals a comparison over them — which the sealed-bid auction does by construction. An MXE with no such circuit (say, one that only ever reveals aggregates over inputs the attacker can't co-choose) may cap the leak at early reveal rather than full recovery. The replay itself is still a confidentiality break; how far it escalates depends on what the MXE's reveals let an attacker combine with the stolen input. When auditing, that combinable surface is the thing to enumerate.
Classifying the bug
Is this "just a replay attack"? Mechanically, yes — the delivery vector is replaying a captured ciphertext. But that label is misleading on two counts, and the mislabel leads straight to the wrong fix.
- The impact is confidentiality, not duplication. Classic replay is an integrity bug — you make an action happen twice (a replayed payment). Here nothing is duplicated; replay is used as a read primitive to exfiltrate a secret.
- The classic replay fix doesn't work. Replay's textbook cure is freshness — nonces, sequence numbers, dedup. None of that helps here: the attacker can open a fresh dummy auction, can pre-register before the victim, and can win races. Freshness is a speed bump, not a fix.
So the honest taxonomy is an intersection of three known families:
| Family | Role here |
|---|---|
| Replay attack | the mechanism (resubmit a captured ciphertext) |
| Missing domain separation / context binding | the root cause (input bound to the MXE key and nothing else) |
| Chosen-input decryption oracle (CCA-flavored) | the escalated impact (recover the plaintext bit by bit) |
In crypto-protocol terms it's a confused deputy at the value level / a cross-context attack: a credential (the ciphertext) honored in a context it was never scoped to. The name cross-context ciphertext replay captures the mechanism; missing domain separation on confidential inputs — equivalently, missing authenticated input binding — captures the cure. Use both.
This is not a new problem so much as the confidential-compute instance of a familiar one. FHE systems hit exactly the same wall: a ciphertext alone says nothing about who is entitled to feed it into what. fhEVM's answer is a zk input proof (the submitter proves knowledge of the plaintext at submission) plus an access-control list that pins each ciphertext to the party who proved it — so a ciphertext can only be consumed by its rightful owner in its intended context. The in-circuit signature fix below is one instantiation of that same shape, adapted to Arcis.
How to fix it
1. Primary fix — bind the value to its context, verified inside the circuit
The input must be unusable outside the computation it was meant for. Put the context (the auction_id, and the bidder's identity) inside the encrypted payload, and have the circuit reject inputs whose embedded context doesn't match the computation it's actually running.
A first cut, in Arcis-flavored pseudocode (illustrative, not a compilable program):
pub struct Bid { amount: u64, auction_id: u64, // the context this bid is scoped to bidder: u128, // who it belongs to } #[instruction] pub fn resolve( bids: Enc<Mxe, [Bid; N]>, public_auction_id: u64, // the auction we are ACTUALLY resolving (public) ) -> /* winner + clearing price */ { let bids = bids.to_arcis(); // Both branches execute; the secret flag merely selects. for i in 0..N { let counts = bids[i].auction_id == public_auction_id; effective[i] = if counts { bids[i].amount } else { 0 }; // 0 = neutral } // ... compute winner/price over `effective`, then reveal only that ... }
A bid whose embedded auction_id doesn't match the auction being resolved contributes the neutral value and can never influence any revealed output. Replay Alice's bid (embedded auction_id = A) into Auction B and it is silently excluded — the dummy auction reveals nothing about her.
The catch: don't let the attacker forge the context. A bearer ciphertext is malleable, and auction_ids are public values. With the cipher in counter mode, an attacker who knows the layout could flip the embedded auction_id field from A to B without disturbing the bid — re-enabling the attack. So the binding itself must be authenticated. Arcis provides the tool: in-circuit Ed25519 signature verification (ArcisEd25519) and SHA3 hashing. Require the bidder to sign their context, and verify it in MPC:
// Alice signs (auction_id || amount_commitment) with her wallet key off-chain. // commitment = rescue_commit(amount, opening); the `opening` rides inside the // encrypted payload alongside the amount. let msg = sha3_256(concat(signed_auction_id_bytes, commitment_bytes)); let valid = ArcisEd25519::verify(alice_pubkey, msg, alice_signature); // CRITICAL: the signature covers a *commitment*, not the amount the circuit will // use. So the circuit must prove the commitment actually opens to that amount — // otherwise the "authenticated" binding authenticates nothing about the value. let bound = commitment_bytes == rescue_commit(bids[i].amount, bids[i].opening); let counts = valid & bound & (signed_auction_id == public_auction_id);
The bound check is not optional. Without it, the signature and the ciphertext are two independent objects, and an attacker can mix and match: take Alice's real-amount ciphertext, flip its malleable embedded auction_id to B, and attach a signature Alice legitimately produced over some other commitment for auction B. valid passes, the context check passes, and her real bid flows in — the exact attack we were trying to kill. Recomputing the commitment over the amount the circuit is about to use is what pins the signed context to the actual value.
With bound in place, a replayed ciphertext carries Alice's signature over the real auction (A) and over a commitment to her real amount; it validates only against A, and only for the amount it commits to. The attacker can't forge Alice's signature over B, and can't retarget an old signature onto a different value. This is the version that actually closes the oracle.
A few things this still needs to be safe:
- Canonical/strict Ed25519. Ed25519 signatures are malleable (a non-canonical
Syields a second valid signature for the same message).ArcisEd25519::verifymust enforce canonicalS, or "valid signature" is a weaker predicate than it looks. - Freshness within a context. A signature over
(auction_id, commitment)still replays inside the same auction unless something consumes it once — cover a per-submission nonce in the signed message, or let the on-chain PDA (fix #2) enforce one bid per(auction_id, bidder). - Globally unique, domain-separated
auction_id. If two contexts can ever share anauction_id, you are back to cross-context replay. Namespace it (e.g. program-scoped, monotonic, or hashed with a domain tag) so no two computations answer to the same id.
2. On-chain submission binding (necessary, not sufficient)
Require the bidder to be the transaction signer, and store bids in PDAs seeded by (auction_id, bidder). This is good Solana hygiene and you should do it — but on its own it binds the label (who submitted) and not the value. The attacker can still place Alice's copied bytes labeled as themselves; the value still leaks through the clearing price. Combine it with fix #1.
Be precise about what the signer proves: it proves who submitted the transaction, not who encrypted the input — and those two parties differ in exactly the replay case. To make submitter-binding actually bite, you'd have to record the encryptor's identity and assert it in-circuit. That is really just fix #1 by another route (the in-circuit signature is the proof of who encrypted), which is why on-chain binding is defense in depth rather than a standalone control.
3. Shrink the oracle at the reveal
Defense in depth: require a minimum number of distinct, on-chain-verified bidders before any reveal; avoid revealing raw clearing prices that can equal a single bid; reveal results only to authenticated participants. These reduce the attacker's chosen-input leverage even where binding has gaps.
4. Freshness / dedup
Rejecting already-seen ciphertexts raises cost slightly but is defeated by fresh contexts and races. Treat it as a speed bump, never the control.
Takeaways
For developers building on Arcium:
- Treat every encrypted input as a bearer token bound only to the MXE key. If your program has more than one computation context — multiple auctions, markets, rounds, or any reusable state — you must bind inputs to their context yourself.
- The right primitive is in-circuit signature verification over the intended context, not a nonce.
- Remember that confidentiality of an input is a property of the whole MXE: audit every
reveal()in every circuit, not just the one you're thinking about.
For auditors reviewing an Arcium MXE, add these to the checklist:
- Can a confidential input be routed into a computation/context it wasn't intended for? (multiple auctions/markets/rounds, shared state)
- Is the encrypted payload bound to
(context_id, submitter)and is that binding authenticated (not just embedded in malleable ciphertext)? - If the binding is a signature over a commitment, does the circuit recompute the commitment over the value it actually uses (so the signature can't be retargeted onto a different amount)? Is the signature verify canonical/strict, and is it consumed once per context?
- Across all circuits in the MXE, does any reachable
reveal()disclose more than intended for a given input — directly or as an oracle under attacker-chosen co-inputs? - Are bids/inputs bound on-chain to the signer and to per-context PDAs (defense in depth)?
Confidential computing changes where your secrets can leak, not whether they can. Arcium's cryptography is strong precisely where it claims to be — and silent exactly where your application has to take over. Cross-context ciphertext replay lives in that handoff, and it will be a recurring, high-severity finding in real-world Arcium applications.
This is the first in a series examining Arcium security from a smart-contract auditing perspective.