NUT-28: Pay-to-Blinded-Key (P2BK)¶
optional
depends on: NUT-11
Summary¶
This NUT describes Pay-to-Blinded-Key (P2BK), which extends the NUT-11 (P2PK) spending conditions. By implication, it also extends NUT-14 (HTLC).
P2BK preserves privacy by blinding each NUT-11 receiver pubkey P with an ECDH-derived scalar rᵢ. Both sides can deterministically derive the same rᵢ from their own keys, but a third party cannot. This improves user privacy by preventing the mint from linking multiple P2PK spends by the same party.
ECDH Shared Secret (Zx)¶
Elliptic-curve Diffie–Hellman (ECDH) allows two parties to create an x-coordinate shared secret (Zx) by combining their private key with the public key of the other party: Zx = x(epG) = x(eP) = x(pE).
For P2BK, the sender creates an ephemeral keypair (private key: e, public key: E), which protects the privacy of their usual long-lived public key. They then calculate the shared secret by combining the ephemeral private key (e) and the receiver's long-lived public key (P).
The receiver calculates the same shared secret Zx using their private key (p) and the ephemeral public key (E), which is supplied by the sender in the proof metadata.
The shared secret Zx is then used to derive the blinded public keys.
Deriving Blinded Public Keys¶
Per NUT-11, there are up to 11 locking 'slots' in the order: [data, ...pubkeys, ...refund].
Slot 0 is the data tag. Slots 1-10 can be any combination of pubkeys and refund keys.
Each public key in the NUT-11 proof is permanently blinded using a deterministic blinding scalar (rᵢ), where i is the slot index.
The blinding scalar for each slot is calculated as:
rᵢ = SHA-256( DOMAIN_SEPARATOR || Zx || i_byte)
Where:
DOMAIN_SEPARATORconstant byte stringb"Cashu_P2BK_v1"Zxis the ECDH shared secret (ePfor sender,pEfor receiver).i_byteis the single unsigned byte representation ofi: (0x00to0x0A)||denotes concatenation
If rᵢ is not in the range 1 ≤ rᵢ ≤ n−1, retry once with an extra 0xff byte appended to the hash input as follows:
rᵢ = SHA-256( b"Cashu_P2BK_v1" || Zx || i_byte || 0xff )
If rᵢ is still not in the range 1 ≤ rᵢ ≤ n−1, abort and discard the ephemeral keypair.
Finally, the public key (P) for slot i is blinded (P') as follows:
P' = P + rᵢG
Example¶
Below is an example implementation in TypeScript.
function deriveP2BKBlindingTweakFromECDH(
point: WeierstrassPoint<bigint>, // E or P
scalar: bigint, // p or e
slotIndex: number, // i
): bigint {
// Calculate x-only ECDH shared point (Zx)
const Zx = point.multiply(scalar).toBytes(true).slice(1);
const iByte = new Uint8Array([slotIndex & 0xff]);
// Derive deterministic blinding factor (r):
// Note: bytesToNumber does NOT reduce modulo n
let r: bigint = bytesToNumber(sha256(Bytes.concat(P2BK_DST, Zx, iByte)));
if (r === 0n || r >= secp256k1.Point.CURVE().n) {
// Very unlikely to get here!
r = bytesToNumber(
sha256(Bytes.concat(P2BK_DST, Zx, iByte, new Uint8Array([0xff]))),
);
if (r === 0n || r >= secp256k1.Point.CURVE().n) {
// Astronomically unlikely to get here!
throw new Error("P2BK: tweak derivation failed");
}
}
return r;
}
For detailed examples of slot blinding, see the test vectors.
[!IMPORTANT] All receiver keys MUST be in compressed SEC1 format (33 bytes) before ECDH and blinding. \ The sender MUST add an '02' prefix to BIP-340 x-only pubkeys (eg Nostr).
Proof Object Extension¶
Each proof adds a single new metadata field:
{
"amount": int,
"id": hex_str,
"secret": str, // still ["P2PK", {...}]
"C": hex_str,
"p2pk_e": hex_str // NEW: 33-byte SEC1 compressed ephemeral public key E
}
p2pk_econtains the sender's ephemeral pubkey (E) used for blinding- All pubkeys inside the
"P2PK"secret are the blinded formsP' - The mint sees standard P2PK data and remains unaware of the blinding
- For Token V4 encoding, the
p2pk_efield is namedpe, andEis encoded as a 33 byte CBOR bstr
Deriving Private Keys¶
With P2BK, the NUT-11 public locking keys are permanently blinded. The mint sees only the blinded public keys, and expects signatures from the corresponding private key.
The receiver must therefore derive the correct blinded private key (k). Because BIP-340 lifts public keys to even-Y parity, there are two possible derivation paths:
- Standard derivation:
k = (p + rᵢ) mod n - Negated derivation:
k = (-p + rᵢ) mod n
Where p is the receiver's long lived private key.
To decide which derivation to use, the receiver calculates their natural pubkey (pG) and compares the parity to their actual pubkey (P).
If the parity matches, use standard derivation, otherwise use negated derivation.
The fastest way to do this in a wallet is to unblind, verify the key is a match, then select derivation by parity:
a. compute Rᵢ = rᵢG \
b. unblind P = P' − Rᵢ \
c. verify x(P) == x(pG) \
d. use standard derivation if parity(P) == parity(pG), otherwise use negated derivation
Sender Workflow¶
- Generate a fresh random scalar
eand computeE = eG - For each receiver key
P, compute: \ a. Unique shared secret for this key:Zx = x(eP)\ b. Slot indexiin[data, ...pubkeys, ...refund]\ c. Blinding scalar:rᵢ = SHA-256(b"Cashu_P2BK_v1" || Zx || i_byte)\ d. Blinded Public Key:P' = P + rᵢG - Build the canonical P2PK secret with the blinded
P'keys in their slots. - Interact with the mint normally; the mint never learns
Porrᵢ - Include
p2pk_e = Ein the final proof
[!IMPORTANT] Use a fresh ephemeral keypair (
e/E) for each new output, so that every proof has unique blinded keys and a uniqueEin theProof.p2pk_efield.In the case of
SIG_ALL, the SAME ephemeral keypair MUST be used for all outputs, as allSIG_ALLproof secrets must have IDENTICALdataandtagsfields.
Receiver Workflow¶
- Read
Efromproof.p2pk_eand the key slot order indexifrom[data, ...pubkeys, ...refund] - Calculate your unique shared secret:
Zx = x(pE) - For each slot
i, compute: \ a. Blinding scalar:rᵢ = SHA-256(b"Cashu_P2BK_v1" || Zx || i_byte)\ b. ComputeRᵢ = rᵢG\ c. UnblindP = P' − Rᵢ\ d. Verifyx(P) == x(pG). If it does not match, thisP'is not for this private key, skip it. \ e. Derive the secret key using: \ • standard derivation ifparity(P) == parity(pG)\ • negative derivation otherwise - Remove the
p2pk_efield from the proof - Sign with the derived private keys and spend as an ordinary P2PK proof
[!NOTE] Each receiver can only calculate their OWN shared secret (
pE), because a shared secret requires either the receiver's private key (pE) or the sender's ephemeral private key (eP).