Payment links, originally proposed for Zcash, are a mechanism that allow sending shielded funds over any secure channel, without requiring the payee to have already installed wallet software.
This mechanism could be useful for a shielded payment app built on top of Penumbra, which already has all of the required protocol features for private payments as a side effect of protocol features for private DeFi.
At a high level, payment links work as follows. The payer creates an ephemeral spending key, moves funds into the control of that key, and sends the key to the payee over a private channel. The payee (or the payer, or anyone who observes the payment) can sweep the funds to a key they control.
Because payment links don’t require the payee to already have wallet software installed (the payee can generate a wallet after onboarding with the payment link), they’re extremely useful for onboarding, as demonstrated by Payy Network. As noted in the Zcash proposal, and productized by Payy, they can also potentially improve scalability, as they can potentially allow recipients to avoid scanning the chain. (This has other system design tradeoffs, however; Penumbra instead chooses to make scanning fast).
This post describes a construction of payment links for Penumbra based around bearer notes, and describes differences from the Zcash proposal.
Shielded Notes
Recall that in Penumbra, value is recorded in notes, consisting of the following data:
- a typed
value
, consisting of a (u128) amount and an asset ID; - a control
address
; - a random seed
rseed
.
Notes have two different cryptographic identifiers: a note commitment, which can be computed from the note contents alone, and a nullifier, which can only be computed with knowledge of the viewing key corresponding to the control address.
Notes are never published on-chain. Instead, when new notes are created, the creator reveals only the note commitment onchain, and encrypts the note to the recipient. The note commitment is then included in the State Commitment Tree (SCT). The recipient learns about the note and tracks its position in a local copy of the SCT. To spend a note, the recipient proves that the note was previously included in the SCT (via a ZK proof of Merkle tree inclusion), proves that they have control over the note (via a randomized signature), and proves that the note has not already been spent (by revealing the note’s nullifier).
Bearer Notes
The core of the payment link construction is the concept of a bearer note: a note that can be spent by anyone who observes it.
To create a bearer note, a value
is given and the random seed rseed
is chosen randomly as normal. However, the control address is chosen to be the default address for the spending key derived from using rseed
as the input to the standard BIP39 key derivation process.
This construction is different from the one originally proposed for Zcash, which started from a randomly selected spending key and used it to derive rseed
. The key advantage of deriving the address
from rseed
rather than the other way around is that it provides an easily checkable criteria to statelessly determine whether any given note is a bearer note: does the address in the note match the one that would be expected if it were a bearer note?
This property simplifies client implementations, by allowing them to determine whether a given note was/is a bearer note without tracking any extra information about its provenance.
Auth Paths
As described above, spending a note requires:
- Proving that the note’s commitment was previously included in the SCT;
- Proving control over the note via a randomized signature;
- Proving that the note was not previously spent, by revealing the note’s nullifier.
Bearer notes make (2) and (3) trivial given knowledge of the note. However, (1) remains a challenge.
The claimer of the bearer note must witness an auth path from that note’s commitment up to some historical root of the SCT. This historical root is called the anchor, as it anchors the (stateless) ZK proofs to the chain state.
However, this poses a challenge. Normally, each Penumbra client’s embedded ultralight node syncs a local, filtered instance of the SCT that forgets all leaves and nodes except the ones necessary to track that wallet’s notes. The claimer of a bearer note will not already have the auth path for a particular note commitment, since the point of this construction is that they aren’t required to have already been running a Penumbra client.
In the Zcash design, the client was supposed to query a server to learn the auth path. But querying the server for an auth path is suboptimal because it means that the claimer reveals precisely which note they are trying to claim. Since note commitments are revealed when they are created, this allows the server to link the sender and the receiver of the payment, defeating the privacy. (I’m not sure what Payy does here, but as an L2 with a centralized sequencer, this choice would be particularly problematic, as the sequencer would have privileged visibility into the complete transaction graph).
This could be avoided if the payer includes the auth path in the data they send to the payee. Penumbra’s SCT is a 3-tiered stack of depth-8 quadtrees, so there are 3 sibling field elements at each position along the auth path, for 3 * 8 * 3 * 32 = 2304 bytes
total. This is sizeable, but probably workable to include in a payment URL (which should not exceed 8KB).
Payment Link Lifecycle
The bearer note contained in a payment link is created by the payer and claimed by the payee. The payer can also (re)claim the bearer note at any point until the payee claims it, but this is not discussed separately since it’s just the payer acting as if they were the payee.
Payer Computation
As mentioned above, Penumbra clients’ SCT instances automatically forget all nodes other than the ones that track notes they control. This process happens automatically, and ideally the payer would be able to obtain auth paths for bearer notes they create without alterations to the core client logic. However, the tiered structure of the SCT does provide a way to do this:
Eternity┃ ╱╲ ◀───────────── Anchor
Tree┃ ╱││╲ = Global Tree Root
┃ * ** * ╮
┃ * * * * │ 8 levels
┃ * * * * ╯
┃ ╱╲ ╱╲ ╱╲ ╱╲
┃ ╱││╲ ╱││╲ ╱││╲ ╱││╲ ◀─── Global Tree Leaf
▲ = Epoch Root
┌──┘
│
│
Epoch┃ ╱╲ ◀───────────── Epoch Root
Tree┃ ╱││╲
┃ * ** * ╮
┃ * * * * │ 8 levels
┃ * * * * ╯
┃ ╱╲ ╱╲ ╱╲ ╱╲
┃ ╱││╲ ╱││╲ ╱││╲ ╱││╲ ◀─── Epoch Leaf
▲ = Block Root
└───┐
│
│
Block┃ ╱╲ ◀───────────── Block Root
Tree┃ ╱││╲
┃ * ** * ╮
┃ * * * * │ 8 levels
┃ * * * * ╯
┃ ╱╲ ╱╲ ╱╲ ╱╲
┃ ╱││╲ ╱││╲ ╱││╲ ╱││╲ ◀─── Block Leaf
= Note Commitment
To create a bearer note, the payer creates a transaction with a bearer note as an output but otherwise as normal, with an encrypted memo describing the purpose of the transaction and the sender’s return address.
As long as this transaction has at least one change note controlled by the payer, the payer’s client will have tracked an auth path for a note in the same block. The first two parts of this note’s auth path will be the same as the desired auth path for the bearer note. The payer (separately from the core sync logic) can then fetch the CompactBlock
containing all of the note commitments created in that block, identify the commitment for the bearer note, and assemble the block-scoped portion of the auth path.
The payer can then transmit the bearer note and its auth path to the payee, along with the memo plaintext consisting of the text memo and sender return address.
Payee Computation
After receiving a bearer note, auth path, and memo plaintext, a payee can claim the payment by sweeping its funds into an address they control. The standard transaction planning logic is intended for use with notes controlled exclusively by the transaction creator, and it’s better not to modify it. Instead, they should use a special-purpose transaction creation method specifically for claiming a bearer note. This method would take as input:
- The bearer note;
- The bearer note’s auth path;
- The (user’s) address to claim funds into;
- The sender-provided memo plaintext;
and manually construct a transaction with:
- The root of the provided auth path as the anchor;
- A single
Spend
action whose spend auth signature is created with the bearer spend key; - A single
Output
action assigning change to the user’s claim address; - The sender-provided memo plaintext;
Data Recovery
The downside of a purely P2P data transmission system is that the P2P nodes may lose data. What happens when either the payer or payee lose their local state and need to recover it? A key design principle of Penumbra is that knowledge of a seed phrase alone should be sufficient to recover all of a user’s funds and also all relevant historical data. This is important not just for backups but also to allow the user to seamlessly synchronize views of the same wallet state on different devices.
On the payer side, a payer who syncs with the chain will detect all of the transactions they created, including the transactions that created any bearer notes used for payment links. Those transactions will have the memo text the payer sent to the payee, allowing them to understand the purpose of the transaction. The payer’s client can also identify that the transaction was creating a bearer note for a payment link (as distinct from being a finalized payment to an external wallet) because bearer notes can be statelessly identified as such. Having identified the transaction outputs as bearer notes, the payer’s client can then query to learn whether they were claimed. This prevents a user from losing funds in unclaimed payment links after they restore from a seed phrase.
On the payee side, a payee who syncs with the chain will detect the sweep transaction they used to claim a payment. This transaction replicates the sender-provided memo plaintext and return address, allowing them to understand the purpose of the transaction and who sent it.
Payment Links
This design doesn’t address the details of what exactly should go into the payment link itself and how that data should be encoded. Since this is off-chain, this seems best to leave up to an individual client, and just specify common notions of bearer notes, transaction flows, etc.
For a complete payload, the size is bounded by:
16 + 32 + 80 + 32 = 160 bytes
for the note plaintext;3 * 8 * 3 * 32 = 2304 bytes
for the auth path;80 + 432 = 512 bytes
for the memo plaintext,
or 2976 bytes
total. This is somewhat large to put in a URL, but at the point that a user is copy-pasting a URL, the exact size may not matter. An alternative would be to encrypt this payload, upload it to some relay service, and have a payment link containing only an encryption key – but this creates the problem that the relay service can then observe the sender and receiver of the payment, which is undesirable.