ParaloomPARALOOM

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 notes

On-chain instruction

shielded_transfer (Anchor, programs/paraloom/src/lib.rs) does not release funds. It:

  1. Verifies the call is from the bridge authority (has_one = authority).
  2. Asserts the two input nullifiers differ (defence-in-depth; init would also fail if they collide).
  3. inits a per-nullifier PDA for each input under the shared b"nullifier" namespace — so a note can never be spent twice across either withdraw or shielded_transfer.
  4. Sets bridge_state.merkle_root to the post-state root (leader-computed, accepted at face value; redundant on-chain re-verification ships with #165).
  5. 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's tweetnacl.box, pinned by a tweetnacl interop test vector in src/privacy/note_crypto.rs.
  • Delivery. Carried opaquely through the transfer flow (ciphertexts: [hex; 2] on POST /transfer/submit); every node that sees the gossip records (output_commitment, ciphertext) pairs and serves them from GET /transfer/scan.
  • Discovery. Recipient polls /transfer/scan, trial-decrypts each bundle with their ViewingKey (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/scan store 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 + ceremony above.

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 shared VoteTally)
  • 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

On this page