Skip to content

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_SEPARATOR constant byte string b"Cashu_P2BK_v1"
  • Zx is the ECDH shared secret (eP for sender, pE for receiver).
  • i_byte is the single unsigned byte representation of i: (0x00 to 0x0A)
  • || 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_e contains the sender's ephemeral pubkey (E) used for blinding
  • All pubkeys inside the "P2PK" secret are the blinded forms P'
  • The mint sees standard P2PK data and remains unaware of the blinding
  • For Token V4 encoding, the p2pk_e field is named pe, and E is 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

  1. Generate a fresh random scalar e and compute E = eG
  2. For each receiver key P, compute: \ a. Unique shared secret for this key: Zx = x(eP) \ b. Slot index i in [data, ...pubkeys, ...refund] \ c. Blinding scalar: rᵢ = SHA-256(b"Cashu_P2BK_v1" || Zx || i_byte) \ d. Blinded Public Key: P' = P + rᵢG
  3. Build the canonical P2PK secret with the blinded P' keys in their slots.
  4. Interact with the mint normally; the mint never learns P or rᵢ
  5. Include p2pk_e = E in 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 unique E in the Proof.p2pk_e field.

In the case of SIG_ALL, the SAME ephemeral keypair MUST be used for all outputs, as all SIG_ALL proof secrets must have IDENTICAL data and tags fields.

Receiver Workflow

  1. Read E from proof.p2pk_e and the key slot order index i from [data, ...pubkeys, ...refund]
  2. Calculate your unique shared secret: Zx = x(pE)
  3. For each slot i, compute: \ a. Blinding scalar: rᵢ = SHA-256(b"Cashu_P2BK_v1" || Zx || i_byte) \ b. Compute Rᵢ = rᵢG \ c. Unblind P = P' − Rᵢ \ d. Verify x(P) == x(pG). If it does not match, this P' is not for this private key, skip it. \ e. Derive the secret key using: \ • standard derivation if parity(P) == parity(pG) \ • negative derivation otherwise
  4. Remove the p2pk_e field from the proof
  5. 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).