Skip to content

Conversation

@robwoodgate
Copy link
Contributor

@robwoodgate robwoodgate commented Oct 19, 2025

CLOSES #290
REPLACES: #291

Summary:

Defines P2BK as an ECDH-derived blinding scheme instead of one using random scalars.

Each proof now includes a per-proof ephemeral pubkey p2pk_e, from which both parties can derive the same blinding factor(s) deterministically.

ECDH shared secret works because:

Receiver long-term keypair: (p, P = p·G)
Sender ephemeral keypair: (e, E = e·G)
Shared secret: Zx = x(e·P) = x(p·E)   (32-byte x-coordinate)
Both compute the same point: e·p·G =  e·P = p·E

NOTE: Each receiver pubkey (P) has their own unique shared secret, and can ONLY derive their own.

Importantly, 3rd parties and the mint CANNOT derive the original locking pubkeys. Only the sender and the receiver have the secret keys required to calculate the ECDH shared secret, which can derive both the original pubkeys and the signing secret.

Proofs can be locked to a well known public key, posted in public without compromising privacy, and spent by the recipient without needing any side-channel communication.

This also simplifes Nostr NutZaps. NIP-61 pubkeys no longer need to be advertised and periodically rotated for privacy.

Key points:

  • Adds p2pk_e (33-byte SEC1 pubkey) per proof (stored as pe in token v4 format)
  • Uses deterministic blinding:
    rᵢ = SHA-256( b"Cashu_P2BK_v1" || Zx || keyset_id_bytes || i_byte)
    where Zx is a shared ECDH secret, keyset_id_bytes is the hex_to_bytes of keyset ID, and i_byte is the P2PK locking key "slot" position.
  • No mint or protocol changes required.

Assumptions

Implementations

Live Demo:

Copy link
Contributor

@d4rp4t d4rp4t left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You've added a package.json file, propably by accident

@robwoodgate
Copy link
Contributor Author

You've added a package.json file, propably by accident

Good catch - thanks @d4rp4t. Fixed

@a1denvalu3
Copy link
Contributor

a1denvalu3 commented Oct 22, 2025

@robwoodgate We should derive a Z_i for each locking key listed in the secret, so that the knowledge of one blinding factor doesn't necessarily imply knowledge of every other. This way, we can also throw away the slot indices ($i \in [0, 10]$).

Use case example:

Alice locks to Carol's public key with a locktime and a refund key. Carol can't unblind Alice's refund key.

I've opened a PR here

@robwoodgate
Copy link
Contributor Author

robwoodgate commented Oct 23, 2025

@robwoodgate We should derive a Z_i for each locking key listed in the secret, so that the knowledge of one blinding factor doesn't necessarily imply knowledge of every other. This way, we can also throw away the slot indices ($i \in [0, 10]$).

Use case example:

Alice locks to Carol's public key with a locktime and a refund key. Carol can't unblind Alice's refund key.

I've opened a PR here

We already DO calculate shared secret (Zx) per locking key.

For each receiver key P, compute:
a. Slot index i in [data, ...pubkeys, ...refund]
b. Zx = x(e·P)
c. rᵢ = H("Cashu_P2BK_v1" || Zx || keyset_id || i) mod n
d. P′ = P + rᵢ·G

The slot index is just for ADDITIONAL uniqueness, so that if the same key (P) is added to both pubkeys and refund, it will be uniquely blinded by the slot index.

Only the sender knows the ephemeral secret, so only they can derive Zx per locking key (eP)

The receiver(s) only know the ephemeral pubkey (E) and their own secret key (p), so they can only generate the shared secret for their own key (pE).

EDIT: I've added some clarifying note blocks, because it's a crucial point that is easy to overlook.

@a1denvalu3
Copy link
Contributor

@robwoodgate I think we can avoid the p2pk_e entirely and instead use the nonce inside of secret more cleverly by setting it nonce = e*G.

@robwoodgate
Copy link
Contributor Author

robwoodgate commented Oct 29, 2025

@robwoodgate I think we can avoid the p2pk_e entirely and instead use the nonce inside of secret more cleverly by setting it nonce = e*G.

Love this idea! That would be the ultimate privacy move because P2BK proofs would be totally indistinguishable from standard P2PK proofs. And making the E the nonce would effectively enforce wallets to ensure uniqueness per proof.

The only downside is the privacy benefit lol... there would be no way to know if a proof was blinded or not, so you would have to try signing EVERY P2PK proof that doesn't have your pubkey with both your secret key (p) and both your derived secret keys (p') to be sure it's not yours.

Overall, I think that's probably a tradeoff worth making for the privacy. And very in line with Bitcoin silent payments.

Anyone disagree?

@robwoodgate
Copy link
Contributor Author

robwoodgate commented Oct 29, 2025

@robwoodgate I think we can avoid the p2pk_e entirely and instead use the nonce inside of secret more cleverly by setting it nonce = e*G.

Thinking about this some more this afternoon.... a possible reason to not do this:

if the Mint knows a P2BK E might be held in the secret nonce, could it possibly discriminate against P2BK proofs in some way by checking if the nonce is a valid curve x-coordinate?

(Though around half of all 32 byte string nonces would naturally be valid x-coordinates in any case...)

@a1denvalu3
Copy link
Contributor

a1denvalu3 commented Oct 29, 2025

@robwoodgate around ~ $\frac{1}{2}$ scalars are the x-coordinate to a public key. If the inputs to a TX are $n$, that gives $n$ independent ecash notes all having a valid public key in the nonce field. That has $\bigl(\frac{1}{2}\bigr)^n$ probability of happening randomly.

Though this could be easily fixed if newer wallets always use EC public keys as nonces, even for normal p2pk. The nonce field as of now isn't used for anything else than guaranteeing uniqueness between secrets locked to the same key.

@robwoodgate
Copy link
Contributor Author

robwoodgate commented Oct 29, 2025

Though this could be easily fixed if newer wallets always use EC public keys as nonces, even for normal p2pk.

That would alleviate the discrimination concern for sure. It would also go some way to alleviating the related concern that using the secret.nonce would reveal ALL E's to the mint, not just those in proofs posted publicly.

@a1denvalu3
Copy link
Contributor

a1denvalu3 commented Oct 30, 2025

Discussed off proposal: SIG_ALL would break under P2BK conditions, because each Proof requires a unique E. SIG_ALL provides that all fields in the structured secret across all inputs/outputs (apart from the nonce) be the same, Therefore we have a conflict and SIG_ALL P2BK proofs wouldn't be spendable.

Two paths to resolution:

  1. Make it so that E can be shared across a batch of SIG_ALL proofs. This option precludes the possibility of setting nonce = E inside the secret, as every nonce must be unique. Using p2pk_e degrades privacy because its possible for third parties (including the Mint) to single out P2BK proofs;
  2. Forbid the use of SIG_ALL for P2BK proofs, and always set nonce=e*G for every NUT-10 secret (whether they are P2BK or not).

I would personally prefer option 2, especially given the Mint can find out who is using silent payments easily through option 1.

@robwoodgate
Copy link
Contributor Author

robwoodgate commented Oct 31, 2025

Discussed off proposal: SIG_ALL would break under P2BK conditions, because each Proof requires a unique E.

To summarize the dilemma:

Option 1: Carry E in the Proof.p2pk_e field

Pros

  • Allows P2BK SIG_ALL because the same E could be used across all the proofs to ensure all proofs carry the same tags as per SIG_ALL spec.
  • Ensures complete privacy for proofs that are NOT posted publicly, as the p2pk_e field is stripped before being sent to the Mint.
  • Signals a proof is using P2BK, so wallets need not try deriving keys for all proofs "just in case".

Cons

  • Signals a proof is using P2BK, allowing Mints to discriminate if the proof is posted in public (as the secret can be tied to P2BK, even if the p2pk_e is later stripped.)
  • Carrying an extra field increases token size

Option 2: Carry E in the Proof.secret.nonce field

Pros

  • Reduces changes to Proof shape and size - no extra p2bk_e field.
  • P2BK proofs posted in public LOOK like regular P2PK proofs, making discrimination harder (see caveat in cons).
  • Avoids the need to strip p2bk_e before sending to the Mint.

Cons

  • Prevents P2BK SIG_ALL because the same E cannot be used across all the proofs (nonce MUST be unique), and by extension, all proofs would not carry the same tags as per SIG_ALL spec.
  • Passes E to the mint in ALL cases (inc for proofs never posted publicly). This could possibly allow discrimination unless ALL NUT-10 secret nonces are required to be valid 33 byte points on the SEC1 curve.

EDIT - if we can specify NUT-10 nonces SHOULD be random 33-byte SEC1 pubkeys, then am also leaning towards Option 2.

@robwoodgate
Copy link
Contributor Author

To summarize the dilemma:

Discussed again off proposal: The general feeling was to go with Proof.p2pk_e because:

  • Specifying NUT-10 nonces SHOULD be SEC1 compressed pubkeys would be a significant change to protocol
  • Precluding P2BK with SIG_ALL would be a big loss
  • Using the nonce field as a critical data carrier feels wrong ("Explicit is better than implicit")

Overall, reason 2 (loss of SIG_ALL compatibility) was seen as the main reason to NOT use the nonce as the carrier.

@a1denvalu3
Copy link
Contributor

a1denvalu3 commented Nov 3, 2025

@robwoodgate We could simplify the parity detection on the receiver side if we compared the x-only of the unblinded public key:
P' - r*G = -/+ p*G + r*G - r*G = -/+ p*G = -/+ P
Compare: x-only(P) == x-only(p*G)

But this is more of an implementation choice. We should however mention in the NUT that this is possible.

@robwoodgate
Copy link
Contributor Author

robwoodgate commented Nov 3, 2025

@robwoodgate We could simplify the parity detection on the receiver side if we compared the x-only of the unblinded public key: P' - r*G = -/+ p*G + r*G - r*G = -/+ p*G = -/+ P Compare: x-only(P) == x-only(p*G)

But this is more of an implementation choice. We should however mention in the NUT that this is possible.

I don't think we need to mention implementation detail in the NUT.

In cashu-ts, the aim was to achieve algorithmic constant time, so both sk candidates are always calculated and the correct one chosen at the end.

1. Derive sk1, compute its pubkey
2. Derive sk2, compute its pubkey
3. Choose whichever sk derived the blinded pubkey P' or return nothing

You are correct the original pubkey P can be obtained once r is derived. I think you are proposing:

1. Derive unblinded P from P' and r
2. Compute the pubkey, see if it matches x(p.G) = x(P)
3. Compute pubkey using negated key, see if it matches x(-p.G) = x(P)
4. Derive sk using either p or -p, depending on the result above, or return nothing

It's not much of a simplifcation, as the blinded private key still needs to be derived in any case.

@robwoodgate
Copy link
Contributor Author

@robwoodgate We could simplify the parity detection on the receiver side if we compared the x-only of the unblinded public key: P' - r*G = -/+ p*G + r*G - r*G = -/+ p*G = -/+ P Compare: x-only(P) == x-only(p*G)

But this is more of an implementation choice. We should however mention in the NUT that this is possible.

Overall, the parity detection issue is nothing to do with Pubkeys, it depends on whether the receiver secret key p is stored normalized for Schnorr or not. BIP-340 doesn't mandate (AFAIK) that a secret key should be normalized to the form that always creates even-Y pubkey, it only specifies that pubkeys be even-Y normalized.

So a wallet/Nostr client etc might allow a negative-Y generating sk to be stored, because it is flipped 'on the fly'.

We therefore will always need to check both for Schnorr derived pubkeys.

@a1denvalu3
Copy link
Contributor

a1denvalu3 commented Nov 3, 2025

It's not much of a simplifcation, as the blinded private key still needs to be derived in any case.

0. Pre-compute the public key: p*G
1. Derive unblinded P from P' and r: P = P' - r*G
2. See if the x-only matches: x(P) == x(p*G).
    2.a If it does, the first byte of the respective SEC1s tell you whether p or -p is to be used.
3. Compute either k_0 = p + r or k_1 = -p + r based on (2.a)

To me, this sounds like a semplification. You trade in 2 point-scalar multiplications and 1 point addition for 1 point-scalar multiplication and 1 addition.

@robwoodgate
Copy link
Contributor Author

robwoodgate commented Nov 3, 2025

It's not much of a simplifcation, as the blinded private key still needs to be derived in any case.

0. Pre-compute the public key: p*G
1. Derive unblinded P from P' and r: P = P' - r*G
2. See if the x-only matches: x(P) == x(p*G).
    2.a If it does, the first byte of the respective SEC1s tell you whether p or -p is to be used.
3. Compute either k_0 = p + r or k_1 = -p + r based on (2.a)

To me, this sounds like a semplification. You trade in 2 point-scalar multiplications and 1 point addition for 1 point-scalar multiplication and 1 addition.

I understand now - yes, you can save a point multiply, and the approach is sound. I will revisit the cashu-ts reference implementation though for optimization.

@robwoodgate
Copy link
Contributor Author

robwoodgate commented Nov 3, 2025

@robwoodgate We could simplify the parity detection on the receiver side if we compared the x-only of the unblinded public key: P' - r*G = -/+ p*G + r*G - r*G = -/+ p*G = -/+ P Compare: x-only(P) == x-only(p*G)

But this is more of an implementation choice. We should however mention in the NUT that this is possible. If we have one int the spec, it may as well be the optimal one.

@lollerfirst - I've now added this as the primary workflow. As we have one in the spec, it may as well be the optimal one!

@robwoodgate
Copy link
Contributor Author

@lollerfirst - I've aded a comprehensive test vector page which will allow implementors to double-check a concrete example across all slots.

@robwoodgate
Copy link
Contributor Author

@callebtc @thesimplekid - We now have implementations in review for Cashu-TS and CDK, so this PR is ready for review too. There is one question I have about whether we update NUT-18 to show p2pk_e as a default, LMK if I should add that.

@robwoodgate
Copy link
Contributor Author

Rebased to main, added code example.

@robwoodgate
Copy link
Contributor Author

@callebtc - can you re-review when you get some time please.

The blinding scalar for each slot is calculated as:

```
rᵢ = SHA-256( DOMAIN_SEPARATOR || Zx || keyset_id_bytes || i_byte)
Copy link

@SatsAndSports SatsAndSports Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to include the keyset here?

I ask because of a problem in the Spilman Channel, where I think I'd like to remove the keyset_id from this. I have the blinding fully working in the channel today, but not (yet) exactly following the system described in this NUT.

TLDR: If a SIG_ALL swap locks output to a blinded pubkey, and the swap then sends the outputs to an unexpected keyset, then the unblinding won't work because the keyset is wrong. To clarify: I'm not talking about the inputs to this swap, I'm talking about the outputs of the swap, where the outputs are blinded P2BK

If I sign a two-party transaction with SIG_ALL then it commits to the amounts in the outputs, but it does not commit to the keyset in those outputs. Imagine the second party adds their signature and performs the swap. The second party decides what keyset those outputs will be in, and this might not be the keyset that I was expecting. They are free to choose any active keyset with the right unit.

If the outputs commit to blinded pubkeys, then things will be strange. I computed the blinded pubkey on one keyset, because I assumed the swap would create outputs in the same keyset as the inputs to the swap. But then the outputs are in a different keyset, and I would need to write special code to unblind and sign those outputs; special code which uses the 'expected keyset' instead of the actual keyset

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The keyset ID ensures the blinding factor is unique between mints (and keysets) to avoid privacy leakage in case of ephemeral key reuse.

Copy link

@SatsAndSports SatsAndSports Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about (instead) including the secret's nonce in this derivation?

Copy link

@SatsAndSports SatsAndSports Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about (instead) including the secret's nonce in this derivation?

actually, ignore that particular question from me about the nonce. It would break SIG_ALL, which requires all the proofs to have the same (blinded) public key

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would still like to remove the keyset_id from the derivation, for the reason described at the start of this thread

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@callebtc, @a1denvalu3 - thoughts?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants