How a shielded payment can return to its sender, without an escrow contract, an operator, or any change to how notes are stored.
The problem
Paying a Solana address is the pool's most useful trick and its sharpest edge. The recipient has to learn the note's opening — amount and blinding — and a browser wallet cannot decrypt it from the chain, so the sender passes a claim code. Lose the code, mistype the address, pay someone who never shows up: the value sits in the tree forever. The pool cannot tell a lost note from an unhurried recipient.
The construction
A note's owner field, pk, is just a field element. The pool already fills it
two ways:
pk = Poseidon1(s) a spending key
pk = Poseidon2(hi, lo) a Solana address
A returnable note fills it a third way — with a condition:
pk = Poseidon4(claimant_hi, claimant_lo, refund_pk, not_before)
Two circuits open it.
redeem is the claimant's exit. The claimant's address is a public input,
taken by the program from an account that signed the transaction — exactly
as in claim. refund_pk and not_before are private. From outside, a redeem
is indistinguishable from a claim of an ordinary address-owned note.
reclaim is the sender's exit. The prover shows knowledge of refund_s
with refund_pk = Poseidon1(refund_s). not_before is a public input, and the
program requires Clock.unix_timestamp >= not_before before it even looks at
the proof. The claimant stays private.
Commitments, the tree, shield and transfer are untouched. A returnable
note is created by an ordinary transfer that has no idea it made one.
One nullifier
A note with two exits invites the obvious attack: leave through both. The defence is that both circuits derive the same nullifier,
nullifier = Poseidon3(commitment, leaf_index, blinding)
keyed by the blinding — which both parties know — rather than by a spending key, which only one does. Whichever transaction lands first creates the nullifier record; the other fails to create it again. The on-chain test does exactly this, in both orders.
Nothing to keep
The sender needs three things to reclaim: the opening, the claimant, and
refund_s. None are stored.
- The note's ciphertext slot carries a memo encrypted to the sender's own
key: asset id, amount, claimant address, blinding,
not_before— 184 bytes. The wallet finds it by trial decryption, like any incoming note. - The refund key is derived:
refund_s = Poseidon3(s, blinding, "refund").
So the Sent, not yet claimed list survives a wiped browser, and the refund key differs for every note. Two recipients comparing their claim codes cannot tell they were paid by the same person.
The cost is that the claimant cannot discover the note by scanning, even with
a raw keypair: the ciphertext slot is taken. A returnable note always travels
by claim code — 80 bytes: amount, blinding, refund_pk, not_before.
What a reclaim shows
not_before becomes public at reclaim, and only then. Wallets round it up to
a UTC midnight so that it names every returnable note sent that day rather
than one. A redeem publishes nothing a claim would not.
The deadline
Time comes from the validator clock, which can drift by seconds to minutes: a
deadline for days, not for races. From not_before on, both exits are open
and the first to land wins — by design, since the alternative is a window in
which a payment belongs to nobody.
Beyond refunds
The same shape — this signer now, or that key later — is an escrow with a timeout, an inheritance with a dead-man's delay, a subscription that lapses. The circuits do not care what the condition is for.
Wire format: Protocol. In the app: Payments that come back.