Transfer
Transfers move value between accounts without anything leaving the pool. The chain records that a transaction happened and how many notes went in and out, but the sender, recipient, and amount are not revealed.
How a transfer moves
A transfer spends one or more of your ACTIVE notes and produces new ones: a note for the recipient and a change note back to you. The proof convinces the pool that the inputs are yours, unspent, and descended from attested deposits, without revealing which notes they are. On-chain, the PoolVault marks the input nullifiers spent, inserts the output commitments into the state tree, and emits a Note event for each published output.
You can submit the spend two ways:
- Relayed. You hand the prepared proof to a relayer, which pays the gas and submits the transact, so your wallet never appears on-chain. The recipient receives the full
amount, and the relayer's fee is charged on top and taken from your change. On the deployed relayer, that fee surfaces on-chain as the transact's public output, so a relayed transfer carries a nonzeroamountOutequal to it. The proof is bound to the relayer you prepared for, so only that relayer can submit it, and the payload can't be altered without failing the proof. - Self-submission. You submit from your own wallet, with no relayer and no fee. A pure transfer then has an
amountOutof zero and exposes no public output at all, at the cost of your wallet being visible as the submitter and paying the gas.
Quotes, expiry and retries, processor binding, and the relayer trust model are shared with the other operations and live in Relaying.
How to send
const sendAmount = "0x11c37937e08000"; // 0.005 ETH in wei
const myNotes = (await session.exportAccount()).notes
.filter((n) => n.status === "ACTIVE" && n.tokenId === NATIVE_ETH);
const noteToSpend = myNotes.find((n) => BigInt(n.value) >= BigInt(sendAmount));
if (!noteToSpend) throw new Error("no single ACTIVE note covers the amount");
const prepared = await session.prepareTransfer({
inputCommitments: [noteToSpend.commitment],
amount: sendAmount,
tokenId: NATIVE_ETH,
recipientDiscoveryData: {
evmAddress: recipient,
},
});
const result = await session.relayTransfer(prepared.relayOptions[0]);
console.log("transfer landed:", result.txReceipt.txHash);
const recipientNote = prepared.executeOptions.recipientPendingNotes[0];
prepareTransfer() fetches a fee quote, builds the proof, and returns both relayed-submit options and self-submit options. If the recipient has no registered viewing key, opt into out-of-band delivery with allowOutOfBandFallback: true and deliver the note secret yourself.
Constraints
- Circuit cap: the
transact_NxMfamily tops out at five inputs and five outputs. Beyond that you must split the spend into multiple cycles. - Label-aware allocator: the SDK produces one recipient note and one change note per distinct deposit label spent, so spending three distinct labels would require six outputs, which no circuit supports. Keep it to at most two labels per call.
- Recipient discoverability: if the recipient has registered a viewing key, the SDK uses discoverable mode and embeds an encrypted payload in the Note event. If not, the transfer throws
RecipientViewingKeyUnregisteredrather than downgrading automatically. To allow out-of-band delivery you must opt in withallowOutOfBandFallback: true, and then hand the recipient the noteSecret yourself. - Quote expiry: the relayer signs a fee commitment with a short expiry, and slow proof generation can outlast it. When that happens, re-run
prepareTransferfor a fresh quote and submit again, handling bothFeeCommitmentExpiredand relayer-side rejections. - Local state drift: after a failed or partial relay flow, local note state can lag behind the chain, so always verify spent status on-chain before picking inputs.
How the recipient receives
What the recipient has to do depends on whether they registered on the keystore. A registered recipient does nothing: the note payload travels inside the Note event, encrypted to their published viewing key, and their next discovery sync finds it by trial decryption. For an unregistered recipient nothing is published on-chain at all, so you must opt into out-of-band delivery and hand them the note secret yourself, over a channel you trust. Both receive paths end in Manage notes: discovery for registered recipients, import for out-of-band ones.
Behind the scenes
session.prepareTransfer(...), then session.relayTransfer(...) or session.executeTransfer(...)PoolVault.transact(...). The deployed relayer submits through PrivacyPoolRelay.relay(...), while self-relay calls the pool from your wallet.transact_NxM, picked by the SDK based on input and output countscontext signal binds both the transact params and the note data (keccak256(abi.encode(transactParams, noteData)) reduced into the field), so neither the public parameters nor the encrypted payloads can be altered after you sign without failing PoolVault_ProofContextMismatch.spentNullifiers, output commitments added to the state tree, and one Note event emitted per published noteData[] entry (out-of-band recipient outputs publish no entry, so they emit no Note event).What's next
- Generate a payment receipt for selective disclosure of this transfer to an accountant.
- Manage notes on the recipient side: discovery or import, depending on the delivery mode.
- Verify spent status before the next spend.