Shielded payments enable anonymous funding of credit balances without revealing the payment graph. Using zero-knowledge proofs, agents can prove ownership of funds and spend them without disclosing:
Which specific note is being spent
The total balance of the spender
The connection between different payments by the same user
This privacy layer is powered by Noir circuits compiled to Groth16 proofs, verified either on-chain or off-chain depending on the integration path.
A shielded note represents a claim to a specific amount of value, hidden in a commitment:
interface ShieldedNote { amount: bigint; // Value in base units commitment: string; // keccak256(amount || rho || pkHash) rho: string; // Randomness (prevents linking) pkHash: string; // Hash of owner's public key leafIndex: number; // Position in Merkle tree nullifierSecret: string; // Secret for computing nullifier}
Key properties:
Commitment - Cryptographic binding to amount and owner
Randomness (rho) - Makes commitments unlinkable
Public key hash - Proves ownership without revealing public key
Secret - Only the note owner knows the nullifier secret
Binding - Commitment + secret deterministically produce the nullifier
Public - Once spent, nullifier is revealed (but doesn’t reveal the note)
Critical: The nullifier secret must remain private. If leaked, an attacker could compute the nullifier for your unspent notes and frontrun your transactions.
The circuit proves the following statements without revealing private inputs:
1
Note commitment reconstruction
Prove the spent note’s commitment is correctly formed:
let input_commitment = keccak256([ note_amount, note_rho, note_pk_hash]);
2
Merkle path validity
Prove the input commitment exists in the tree:
let mut current = input_commitment;for i in 0..24 { if path_indices[i] == 0 { current = keccak256([current, path_elements[i]]); } else { current = keccak256([path_elements[i], current]); }}assert(current == root);
3
Nullifier computation
Prove the nullifier is correctly derived:
let computed_nullifier = keccak256([ NULLIFIER_DOMAIN, nullifier_secret, input_commitment]);assert(computed_nullifier == nullifier);
let computed_challenge = keccak256([ CHALLENGE_DOMAIN, merchantCommitment, amount, // ... other payment details]);assert(computed_challenge == challengeHash);
Output unlinkability: The circuit uses independent randomness (merchant_rho, change_rho) for outputs, preventing linkage to the input note’s rho. This means observers cannot tell which outputs came from the same input.
Performance consideration: Proving time for spend_change circuit is typically 1-3 seconds on modern hardware. For mobile or browser environments, consider using a remote proving service.
// Simplified gateway instructionpub fn pay_authorized( ctx: Context<PayAuthorized>, proof: Vec<u8>, public_inputs: Vec<u8>, amount_lamports: u64) -> Result<()> { // 1. CPI to Sunspot verifier sunspot_verifier::verify( ctx.accounts.verifier_program, &proof, &public_inputs )?; // 2. Native SOL transfer let ix = system_instruction::transfer( &ctx.accounts.payer.key(), &ctx.accounts.recipient.key(), amount_lamports ); invoke(&ix, &[ctx.accounts.payer, ctx.accounts.recipient])?; Ok(())}
Gas optimization: Groth16 proofs have constant verification time (~10-15ms) regardless of circuit complexity. This makes on-chain verification practical even on resource-constrained chains.