Shielded transfers
How a private→private shielded transfer flows end-to-end on Paraloom (v0.5.0-rc4) — circuit, consensus, on-chain settlement, encrypted note delivery, and recipient discovery.
Shielded transfers
A shielded transfer moves value from one shielded address to another without touching Solana publicly: the input notes are nullified, two output notes are created, the recipient learns their note off-band (encrypted), and the on-chain footprint is two nullifier PDAs + an advanced Merkle root.
This is the third leg of the privacy lifecycle (after deposit and withdraw) and shipped end-to-end in v0.5.0-rc4.
Shape
Fixed 2 inputs, 2 outputs, matching TransferCircuit::MAX_INPUTS / MAX_OUTPUTS. A single-note spend pads the second input with a real second note (the circuit proves Merkle membership per input — there is no "dummy input" flag), and an unused output can be a value-0 note. Value conservation is in-circuit: sum(input_values) == sum(output_values), range-constrained to [0, 2^64) by bit decomposition.
TransferCircuit (public inputs)
merkle_root — inputs' membership root (pool's current root)
nullifiers[2] — per-input
output_commitments[2] — per-output
TransferCircuit (private witness)
input_values[2], input_randomness[2], input_recipients[2], input_secrets[2]
input_paths[2] — full-depth Merkle paths (one per input)
output_values[2], output_randomness[2], recipient_addresses[2]End-to-end flow
sequenceDiagram
autonumber
participant W as Sender wallet
participant N as Node (transfer ingress)
participant V as Validator cohort (libp2p)
participant L as Leader node (settlement)
participant S as Solana program
participant R as Recipient wallet
W->>W: prove_transfer (WASM, browser)
W->>W: encrypt each output to recipient address
W->>N: POST /transfer/submit { nullifiers, commitments, root, proof, ciphertexts }
N->>V: gossip TransferVerificationRequest
V->>V: verify_transfer_parts (real Groth16 per validator)
V->>L: TransferVerificationResult { Valid }
L->>L: BFT quorum reached → ApprovedTransfer
L->>S: shielded_transfer (nullify inputs, advance root)
Note over N,R: ciphertexts recorded by every node on the gossip; served from GET /transfer/scan
R->>N: GET /transfer/scan
R->>R: trial-decrypt with viewing key → discovered note
R->>R: balance reflects discovered notesOn-chain instruction
shielded_transfer (Anchor, programs/paraloom/src/lib.rs) does not release funds. It:
- Verifies the call is from the bridge authority (
has_one = authority). - Asserts the two input nullifiers differ (defence-in-depth;
initwould also fail if they collide). inits a per-nullifier PDA for each input under the sharedb"nullifier"namespace — so a note can never be spent twice across eitherwithdraworshielded_transfer.- Sets
bridge_state.merkle_rootto the post-state root (leader-computed, accepted at face value; redundant on-chain re-verification ships with #165). - Emits
ShieldedTransferEvent { nullifiers, output_commitments, new_merkle_root, timestamp }.
The proof blob is recorded but not re-verified on-chain — the L2 validator quorum verified it before the leader called this instruction. See Comparison for the trust model.
Encrypted note delivery (#196)
Spend capability in Paraloom is knowledge of a note's contents ({amount, randomness, recipient}) — anyone who learns these can spend the note, regardless of who holds a key (the circuit's secret is just a per-spend nullifier randomizer). So "delivering a note" means encrypting those fields to the recipient.
- Scheme. NaCl
crypto_box(X25519 + XSalsa20-Poly1305) with a fresh ephemeral sender key per output — Sapling-style unlinkable. The recipient's shielded address is their X25519 public key, so no directory lookup is needed. - Wire format. Bundle =
epk(32) || nonce(24) || ct.NotePlaintext=amount(8, LE) || randomness(32) || recipient(32)= 72 bytes. Byte-compatible with the wallet'stweetnacl.box, pinned by a tweetnacl interop test vector insrc/privacy/note_crypto.rs. - Delivery. Carried opaquely through the transfer flow (
ciphertexts: [hex; 2]onPOST /transfer/submit); every node that sees the gossip records(output_commitment, ciphertext)pairs and serves them fromGET /transfer/scan. - Discovery. Recipient polls
/transfer/scan, trial-decrypts each bundle with theirViewingKey(X25519 secret); failures are silent. Decrypted notes feed the wallet's shielded balance directly.
Key + ceremony
The transfer circuit uses its own Groth16 proving/verifying key pair, separate from the withdrawal pair:
cargo run --release --bin setup-transfer-ceremony
# → keys/transfer_proving.key (~11 MB)
# → keys/transfer_verifying.key (~632 B)The current keys are from a dev single-party trusted setup (BGM17 tooling is shipped at rc2; the multi-party ceremony run is #64, the gate to v0.5.0 final). The companion in-browser prover lives in the paraloom-prover-wasm crate (prove_transfer export, wasm-pack --target web --release).
Limitations on devnet (rc4)
Honest scope — these are tracked, gate mainnet, and do not affect fund safety on devnet:
- On-chain re-verification is deferred. The L2 quorum verifies; the Solana program does not re-run Groth16 (#165, Solana SIMD-0388 ~Q3'26). The post-transfer Merkle root is set by the consensus leader and is likewise not re-verified on-chain.
- Delivery store is in-memory. The per-node
/transfer/scanstore does not persist across restart; the ingress is disabled by default and intended for a loopback / management interface. - Per-transfer pool convergence is partial. The settling node appends the output commitments to its shielded pool; recipients use that node — or the on-chain tree — to spend.
- Trusted setup is a dev ceremony. See
Key + ceremonyabove.
Where the code lives
- On-chain instruction + tests:
programs/paraloom/src/lib.rs,programs/paraloom/tests/shielded_transfer_test.rs - L2 consensus:
src/consensus/transfer.rs(TransferVerificationCoordinator, embeds the sharedVoteTally) - HTTP ingress + scan:
src/node/transfer_ingress.rs(POST /transfer/submit,GET /transfer/scan) - Settlement:
src/bridge/solana/submitter.rs::submit_approved_transfer,src/bridge/solana/program.rs::submit_shielded_transfer - Note crypto:
src/privacy/note_crypto.rs(encrypt/decrypt + interop test vector);src/privacy/types.rs::ViewingKey - Canonical proof test (real 2-in/2-out Groth16):
tests/transfer_proof_canonical.rs - Network E2E (libp2p multi-node):
tests/transfer_consensus_network_e2e.rs