NUT28 - ECDH-derived Pay-to-Blinded-Key (P2BK) #300

Merged
robwoodgate merged 7 commits from p2bk-silent into main 2026-02-16 12:53:36 +00:00
robwoodgate commented 2025-10-19 21:27:39 +00:00 (Migrated from github.com)

CLOSES #290
REPLACES: #291

Implementations:

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.

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

  • The explicit 11 slot range (0-10) assumes the limits laid out in #255

Live Demo:

**CLOSES** #290 **REPLACES:** #291 ### Implementations: - [x] Cashu-TS - Original: https://github.com/cashubtc/cashu-ts/pull/377 - Revised: https://github.com/cashubtc/cashu-ts/pull/465 - [x] CDK https://github.com/cashubtc/cdk/pull/1253 - [x] DotNut - Original: https://github.com/Kukks/DotNut/pull/21 - Revised: https://github.com/Kukks/DotNut/pull/28 ## 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: ```blockquote 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. ### 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 - The explicit 11 slot range (0-10) assumes the limits laid out in #255 ### Live Demo: - Create P2BK locked tokens using [Cashu NutLock](https://www.nostrly.com/cashu-nutlock/) - Redeem P2BK using either [Cashu Witness](https://www.nostrly.com/cashu-witness/) or [Cashu Redeem](https://www.nostrly.com/cashu-redeem/)
thesimplekid (Migrated from github.com) reviewed 2025-10-19 21:27:39 +00:00
d4rp4t (Migrated from github.com) reviewed 2025-10-19 22:20:12 +00:00
d4rp4t (Migrated from github.com) commented 2025-10-19 22:20:12 +00:00

this file was probably added by accident

this file was probably added by accident
d4rp4t (Migrated from github.com) requested changes 2025-10-19 22:21:24 +00:00
d4rp4t (Migrated from github.com) left a comment

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

You've added a package.json file, propably by accident
a1denvalu3 (Migrated from github.com) approved these changes 2025-10-20 08:09:58 +00:00
robwoodgate commented 2025-10-20 11:09:18 +00:00 (Migrated from github.com)

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

Good catch - thanks @d4rp4t. Fixed

> You've added a package.json file, propably by accident Good catch - thanks @d4rp4t. Fixed
d4rp4t (Migrated from github.com) approved these changes 2025-10-20 11:25:08 +00:00
a1denvalu3 commented 2025-10-22 23:05:38 +00:00 (Migrated from github.com)

@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 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](https://github.com/robwoodgate/nuts/pull/1)
robwoodgate commented 2025-10-23 04:44:01 +00:00 (Migrated from github.com)

@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.

> @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](https://github.com/robwoodgate/nuts/pull/1) 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 commented 2025-10-29 12:08:41 +00:00 (Migrated from github.com)

@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 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 commented 2025-10-29 13:24:32 +00:00 (Migrated from github.com)

@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 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 commented 2025-10-29 16:17:56 +00:00 (Migrated from github.com)

@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...)

> @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 commented 2025-10-29 16:41:04 +00:00 (Migrated from github.com)

@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 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 commented 2025-10-29 16:52:47 +00:00 (Migrated from github.com)

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.

> 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 commented 2025-10-30 12:24:23 +00:00 (Migrated from github.com)

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.

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.
a1denvalu3 (Migrated from github.com) reviewed 2025-10-30 23:49:17 +00:00
a1denvalu3 (Migrated from github.com) commented 2025-10-30 23:49:17 +00:00

We should be more precise about the format of the stuff that goes in the hash function:

  1. Zx is raw bytes or the utf-8 encoded hex representation of the bytes?
  2. same for keyset_id.
  3. Is i 1 byte or is it a radix-10 utf-8 string
We should be more precise about the format of the stuff that goes in the hash function: 1. `Zx` is raw bytes or the utf-8 encoded hex representation of the bytes? 2. same for `keyset_id`. 3. Is `i` 1 byte or is it a radix-10 utf-8 string
a1denvalu3 (Migrated from github.com) reviewed 2025-10-31 00:24:25 +00:00
a1denvalu3 (Migrated from github.com) commented 2025-10-31 00:20:24 +00:00

Calculating the remainder of the division by n requires switching to a representation on a multiple precision library (bigint) which are unfit to do operations on secret values and could also potentially introduce a new dependency on some implementations that are not already relying on them.

I would suggest rejection sampling instead:

  1. Sample rᵢ = H("Cashu_P2BK_v1" || Zx || keyset_id || i || counter).
  2. Instantiate a scalar. If it fails increase the counter and repeat step 1.
Calculating the remainder of the division by `n` requires switching to a representation on a multiple precision library (bigint) which are unfit to do operations on secret values and could also potentially introduce a new dependency on some implementations that are not already relying on them. I would suggest rejection sampling instead: 1. Sample `rᵢ = H("Cashu_P2BK_v1" || Zx || keyset_id || i || counter)`. 2. Instantiate a scalar. If it fails increase the counter and repeat step 1.
robwoodgate (Migrated from github.com) reviewed 2025-10-31 00:51:56 +00:00
robwoodgate (Migrated from github.com) commented 2025-10-31 00:51:56 +00:00

The Determinism section specifies raw bytes for hash inputs but yes, it could be spelled out here too.

We should also add a full worked example including secret keys to ensure implementations can test against it.

The Determinism section specifies raw bytes for hash inputs but yes, it could be spelled out here too. We should also add a full worked example including secret keys to ensure implementations can test against it.
robwoodgate (Migrated from github.com) reviewed 2025-10-31 00:56:02 +00:00
robwoodgate (Migrated from github.com) commented 2025-10-31 00:56:02 +00:00

Mod n is unavoidable in the derivation step, so not sure this would help much?

That said, there is a retry built in (again in the Determinism section) so can be easily done.

In any case, we should ensure the NUT makes the details laid out in the Cashu-TS reference implementation explicit.

Mod n is unavoidable in the derivation step, so not sure this would help much? That said, there is a retry built in (again in the Determinism section) so can be easily done. In any case, we should ensure the NUT makes the details laid out in the Cashu-TS reference implementation explicit.
a1denvalu3 (Migrated from github.com) reviewed 2025-10-31 07:29:48 +00:00
a1denvalu3 (Migrated from github.com) commented 2025-10-31 07:29:48 +00:00

Mod n is unavoidable in the derivation step, so not sure this would help much?

If you meant when deriving k = p + r (mod n) then yes... BUT. The secp256k1 library (and relative rust bindings, python bindings etc...) have a notion of secret key and scalars, while noble secp (typescript) does not. In cashu-ts you are FORCED to use BigInt for secret key tweaking operations, but that doesn't mean it's the case for all other implementations.

> Mod n is unavoidable in the derivation step, so not sure this would help much? If you meant when deriving `k = p + r (mod n)` then yes... **BUT**. The secp256k1 library (and relative rust bindings, python bindings etc...) have a notion of secret key and scalars, while noble secp (typescript) does not. In cashu-ts you are **FORCED** to use BigInt for secret key tweaking operations, but that doesn't mean it's the case for all other implementations.
a1denvalu3 (Migrated from github.com) reviewed 2025-10-31 07:34:14 +00:00
a1denvalu3 (Migrated from github.com) commented 2025-10-31 07:34:14 +00:00

That said, there is a retry built in (again in the Determinism section) so can be easily done.

I think it should be laid out here in the derivation scheme by adding a counter at the end.

> That said, there is a retry built in (again in the Determinism section) so can be easily done. I think it should be laid out here in the derivation scheme by adding a counter at the end.
robwoodgate (Migrated from github.com) reviewed 2025-10-31 09:20:24 +00:00
robwoodgate (Migrated from github.com) commented 2025-10-31 09:20:24 +00:00

The mod n is to indicate it is a valid point on the curve. In the cashu-ts reference implementation, I allowed for a single retry in case r=0. This is what the Determinism section references:

If `rᵢ = 0`, retry once with an extra `0xff` byte appended to the hash input; abort if still zero.
const Zx = point.multiply(scalar).toBytes(false).slice(1, 33);
const iByte = new Uint8Array([slotIndex & 0xff]);
// Derive deterministic blinding factor (r):
let r = secp256k1.Point.Fn.fromBytes(sha256(Bytes.concat(P2BK_DST, Zx, keysetId, iByte)));
if (r === 0n) {
// Very unlikely to get here (1/n)!
r = secp256k1.Point.Fn.fromBytes(
	sha256(Bytes.concat(P2BK_DST, Zx, keysetId, iByte, new Uint8Array([0xff]))),
);
if (r === 0n) throw new Error('P2BK: tweak derivation failed');
}
return r;
The `mod n` is to indicate it is a valid point on the curve. In the cashu-ts reference implementation, I allowed for a single retry in case `r`=0. This is what the Determinism section references: ``` If `rᵢ = 0`, retry once with an extra `0xff` byte appended to the hash input; abort if still zero. ``` ```ts const Zx = point.multiply(scalar).toBytes(false).slice(1, 33); const iByte = new Uint8Array([slotIndex & 0xff]); // Derive deterministic blinding factor (r): let r = secp256k1.Point.Fn.fromBytes(sha256(Bytes.concat(P2BK_DST, Zx, keysetId, iByte))); if (r === 0n) { // Very unlikely to get here (1/n)! r = secp256k1.Point.Fn.fromBytes( sha256(Bytes.concat(P2BK_DST, Zx, keysetId, iByte, new Uint8Array([0xff]))), ); if (r === 0n) throw new Error('P2BK: tweak derivation failed'); } return r; ```
a1denvalu3 (Migrated from github.com) reviewed 2025-10-31 09:35:13 +00:00
a1denvalu3 (Migrated from github.com) commented 2025-10-31 09:35:13 +00:00

@robwoodgate

An invalid key can be key = 0 or key >= N. So it's not really \frac{1}{N}. I think it makes most sense to add a counter (not just a 0xFF byte) and retry up to 16 times (just like hash-to-curve does)

if canonical_slot > 10 {
   return Err(Error::InvalidCanonicalSlot(canonical_slot));
}

// Compute shared point Z = ephemeral_key * pubkey
// Use SharedSecret if available (produces 32 bytes typically equal to x-coordinate)
let shared = SharedSecret::new(pubkey, secret_key);

// SharedSecret exposes 32 bytes (x-coordinate) — ensure this matches your crate
let z_x: [u8; 32] = shared.as_ref().try_into().expect("shared len == 32");

// Build KDF input: domain tag || x-only(Z) || keyset_id (4 bytes BE) || canonical_slot (1 byte) || counter (1 byte)

let mut engine = Sha256::engine();
engine.input(b"Cashu_P2BK_v1");
engine.input(&z_x);
engine.input(&keyset_id.to_bytes());
engine.input(&[canonical_slot]);


// I have to initialize this with a random key otherwise rustc can't figure out
// that there is no possible way this is left uninitialized.
let mut result: Result<SecretKey, Error> = Ok(SecretKey::generate());
for counter in 0..16 {
    let mut engine_clone = engine.clone();
    engine_clone.input(&[counter as u8]);
    let digest = Sha256::from_engine(engine_clone);

    result = SecretKey::from_slice(digest.as_byte_array()).map_err(Error::from);
    match result {
        Ok(_) => break,
        Err(_) => {},
    }
}

I think this is what makes most sense.

@robwoodgate An invalid key can be `key = 0` or `key >= N`. So it's not really $\frac{1}{N}$. I think it makes most sense to add a counter (not just a 0xFF byte) and retry up to 16 times (just like hash-to-curve does) ```rust if canonical_slot > 10 { return Err(Error::InvalidCanonicalSlot(canonical_slot)); } // Compute shared point Z = ephemeral_key * pubkey // Use SharedSecret if available (produces 32 bytes typically equal to x-coordinate) let shared = SharedSecret::new(pubkey, secret_key); // SharedSecret exposes 32 bytes (x-coordinate) — ensure this matches your crate let z_x: [u8; 32] = shared.as_ref().try_into().expect("shared len == 32"); // Build KDF input: domain tag || x-only(Z) || keyset_id (4 bytes BE) || canonical_slot (1 byte) || counter (1 byte) let mut engine = Sha256::engine(); engine.input(b"Cashu_P2BK_v1"); engine.input(&z_x); engine.input(&keyset_id.to_bytes()); engine.input(&[canonical_slot]); // I have to initialize this with a random key otherwise rustc can't figure out // that there is no possible way this is left uninitialized. let mut result: Result<SecretKey, Error> = Ok(SecretKey::generate()); for counter in 0..16 { let mut engine_clone = engine.clone(); engine_clone.input(&[counter as u8]); let digest = Sha256::from_engine(engine_clone); result = SecretKey::from_slice(digest.as_byte_array()).map_err(Error::from); match result { Ok(_) => break, Err(_) => {}, } } ``` I think this is what makes most sense.
robwoodgate commented 2025-10-31 09:53:47 +00:00 (Migrated from github.com)

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.

> 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 (Migrated from github.com) reviewed 2025-10-31 10:12:02 +00:00
robwoodgate (Migrated from github.com) commented 2025-10-31 10:12:02 +00:00

Cool, yes using the hash-to-curve counter idea works for me. Want to PR some amends to incorporate this and polish the spec?

Cool, yes using the hash-to-curve counter idea works for me. Want to PR some amends to incorporate this and polish the spec?
a1denvalu3 (Migrated from github.com) reviewed 2025-10-31 14:54:24 +00:00
a1denvalu3 (Migrated from github.com) commented 2025-10-31 14:54:24 +00:00

@robwoodgate I've opened a PR here with some small changes I felt necessary.

@robwoodgate I've opened a PR [here](https://github.com/robwoodgate/nuts/pull/2/files) with some small changes I felt necessary.
robwoodgate commented 2025-10-31 17:07:26 +00:00 (Migrated from github.com)

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.

> 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.
robwoodgate (Migrated from github.com) reviewed 2025-10-31 17:10:26 +00:00
robwoodgate (Migrated from github.com) commented 2025-10-31 17:10:25 +00:00

Resolved - PR was merged, and we removed the requirement to mod n the blinding tweak, preferring instead to check the range for broader compatibility.

Resolved - PR was merged, and we removed the requirement to mod n the blinding tweak, preferring instead to check the range for broader compatibility.
a1denvalu3 commented 2025-11-03 07:59:54 +00:00 (Migrated from github.com)

@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 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 commented 2025-11-03 11:09:31 +00:00 (Migrated from github.com)

@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 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 commented 2025-11-03 11:21:27 +00:00 (Migrated from github.com)

@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.

> @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 commented 2025-11-03 12:14:59 +00:00 (Migrated from github.com)

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.

> 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 commented 2025-11-03 15:28:34 +00:00 (Migrated from github.com)

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.

> > 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 commented 2025-11-03 17:05:33 +00:00 (Migrated from github.com)

@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 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 commented 2025-11-04 14:54:26 +00:00 (Migrated from github.com)

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

@lollerfirst - I've aded a comprehensive test vector page which will allow implementors to double-check a concrete example across all slots.
robwoodgate (Migrated from github.com) reviewed 2025-11-04 20:29:23 +00:00
robwoodgate (Migrated from github.com) commented 2025-11-04 20:29:00 +00:00

I wonder if the nut26 field should be defined directly inside NUT-18 (as the NUT-10 field is).

Otherwise it can be missed by implementors, if it is only in this NUT.

I wonder if the `nut26` field should be defined directly inside NUT-18 (as the NUT-10 field is). Otherwise it can be missed by implementors, if it is only in this NUT.
robwoodgate commented 2025-11-06 20:26:27 +00:00 (Migrated from github.com)

@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.

@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.
a1denvalu3 (Migrated from github.com) reviewed 2025-11-07 08:44:11 +00:00
a1denvalu3 (Migrated from github.com) commented 2025-11-07 08:44:11 +00:00

I think it should stay here. Otherwise by the same logic we should modify NUT-00 to include p2pk_e in the Proof and Token objects.

I think it should stay here. Otherwise by the same logic we should modify NUT-00 to include `p2pk_e` in the `Proof` and `Token` objects.
a1denvalu3 (Migrated from github.com) approved these changes 2025-11-07 12:31:14 +00:00
robwoodgate commented 2025-11-11 08:58:33 +00:00 (Migrated from github.com)

Formalized to NUT-26, ready for merge.

Formalized to NUT-26, ready for merge.
robwoodgate commented 2025-11-11 09:52:24 +00:00 (Migrated from github.com)

Added reference to the DotNut implementation (https://github.com/Kukks/DotNut/pull/21)

Added reference to the DotNut implementation (https://github.com/Kukks/DotNut/pull/21)
thesimplekid (Migrated from github.com) reviewed 2025-11-12 17:38:54 +00:00
thesimplekid (Migrated from github.com) commented 2025-11-12 17:24:26 +00:00

I hate to even suggest this but do we think there will be other use cases in the future that will want to extend the payment request? Would it make sense to add a tags field that is an array of array of strings this would be more consistent with NUT10 where we have the tags in the secret.

I hate to even suggest this but do we think there will be other use cases in the future that will want to extend the payment request? Would it make sense to add a tags field that is an array of array of strings this would be more consistent with NUT10 where we have the tags in the secret.
thesimplekid (Migrated from github.com) commented 2025-11-12 17:31:55 +00:00

I see the comment here. I'm okay if we keep it as it just gonna leave my comment about the future extension path.

I see the comment here. I'm okay if we keep it as it just gonna leave my comment about the future extension path.
thesimplekid (Migrated from github.com) commented 2025-11-12 17:27:54 +00:00

Is there a reason to have both?

Is there a reason to have both?
robwoodgate (Migrated from github.com) reviewed 2025-11-12 18:56:38 +00:00
robwoodgate (Migrated from github.com) commented 2025-11-12 18:56:38 +00:00

Sadly yes... if we don't want NOSTR users hating Cashu.

The problem is that BIP-340 doesn't require secret keys to be normalized. It only requires the pubkey to be lifted to X-Parity. So a Nostr user with a secret key that derives a natural Y-parity pubkey will not be able to derive a working secret key unless that key was normalized for them.

For regular SEC1 pubkey it doesn't matter - the parity is always the natural one.

Sadly yes... if we don't want NOSTR users hating Cashu. The problem is that BIP-340 doesn't require secret keys to be normalized. It only requires the pubkey to be lifted to X-Parity. So a Nostr user with a secret key that derives a natural Y-parity pubkey will not be able to derive a working secret key unless that key was normalized for them. For regular SEC1 pubkey it doesn't matter - the parity is always the natural one.
thesimplekid (Migrated from github.com) approved these changes 2025-11-14 16:51:26 +00:00
robwoodgate commented 2025-11-25 14:42:29 +00:00 (Migrated from github.com)

Added cross references to NUT-14 (HTLC) and noted that the data slot (hash) is not to be blinded for HTLC proofs.

Added cross references to NUT-14 (HTLC) and noted that the `data` slot (hash) is not to be blinded for HTLC proofs.
callebtc (Migrated from github.com) reviewed 2025-12-16 09:08:21 +00:00
callebtc (Migrated from github.com) commented 2025-12-16 09:08:21 +00:00

Agreed, I think this deserves its own PR. @robwoodgate maybe we can edit to NUT-18 after a separate discussion?

Agreed, I think this deserves its own PR. @robwoodgate maybe we can edit to NUT-18 after a separate discussion?
robwoodgate (Migrated from github.com) reviewed 2025-12-16 12:36:39 +00:00
robwoodgate (Migrated from github.com) commented 2025-12-16 12:36:39 +00:00

I have reversed the changes to NUT-18.

The new NUT-26 still talks about extending it, so we will need to be updated in the new PR if the signalling schema changes for payment request.

I have reversed the changes to NUT-18. The new NUT-26 still talks about extending it, so we will need to be updated in the new PR if the signalling schema changes for payment request.
callebtc (Migrated from github.com) reviewed 2025-12-25 11:47:09 +00:00
callebtc (Migrated from github.com) commented 2025-12-25 11:47:09 +00:00
`depends on: NUT-11`
```suggestion `depends on: NUT-11` ```
callebtc (Migrated from github.com) requested changes 2025-12-25 11:51:37 +00:00
callebtc (Migrated from github.com) left a comment

I suggest that for future extensions, the setting in the NUT-18 PR should be a JSON.

I suggest that for future extensions, the setting in the NUT-18 PR should be a JSON.
callebtc (Migrated from github.com) commented 2025-12-25 11:49:32 +00:00
  "nut26": NUT26Option <optional>,
```suggestion "nut26": NUT26Option <optional>, ```
callebtc (Migrated from github.com) commented 2025-12-25 11:49:42 +00:00

something like this has a better upgradeability

something like this has a better upgradeability
callebtc commented 2025-12-25 11:57:48 +00:00 (Migrated from github.com)

I applied changes in f16c9c4 before realizing that the NUT-18 specs have moved from the NUT-18 document into the NUT-26 doc. I assumed we're going to define that in a separate PR. I made suggestions because the way it's suggested here will be hard to upgrade in the future. I think it should be left for another day and deserves more thought.

Large parts of the spec read AI generated. Please remove all AI parts. The spec could probably be compressed by 50% by taking out the slop.

I applied changes in [f16c9c4](https://github.com/cashubtc/nuts/pull/300/commits/f16c9c4a770ff7af938cb92ccfd5b10929dbc744) before realizing that the NUT-18 specs have moved from the NUT-18 document into the NUT-26 doc. I assumed we're going to define that in a separate PR. I made suggestions because the way it's suggested here will be hard to upgrade in the future. I think it should be left for another day and deserves more thought. Large parts of the spec read AI generated. **Please remove all AI parts.** The spec could probably be compressed by 50% by taking out the slop.
callebtc (Migrated from github.com) reviewed 2025-12-25 11:59:04 +00:00
callebtc (Migrated from github.com) commented 2025-12-25 11:59:04 +00:00

redundant

redundant
callebtc (Migrated from github.com) reviewed 2025-12-25 11:59:27 +00:00
callebtc (Migrated from github.com) commented 2025-12-25 11:59:27 +00:00

this should probably be a test vector

this should probably be a test vector
callebtc (Migrated from github.com) reviewed 2025-12-25 12:00:45 +00:00
callebtc (Migrated from github.com) commented 2025-12-25 12:00:46 +00:00

not sure if this fits the spec.

not sure if this fits the spec.
callebtc (Migrated from github.com) reviewed 2025-12-25 12:01:48 +00:00
callebtc (Migrated from github.com) commented 2025-12-25 12:01:48 +00:00

"P2BK-enhanced"?

"P2BK-enhanced"?
robwoodgate (Migrated from github.com) reviewed 2025-12-28 22:27:11 +00:00
robwoodgate (Migrated from github.com) commented 2025-12-28 22:27:11 +00:00

Removed all NUT-18 references for now. Let's rethink that part

Removed all NUT-18 references for now. Let's rethink that part
robwoodgate commented 2025-12-28 22:28:19 +00:00 (Migrated from github.com)

I applied changes in f16c9c4 before realizing that the NUT-18 specs have moved from the NUT-18 document into the NUT-26 doc. I assumed we're going to define that in a separate PR. I made suggestions because the way it's suggested here will be hard to upgrade in the future. I think it should be left for another day and deserves more thought.

Large parts of the spec read AI generated. Please remove all AI parts. The spec could probably be compressed by 50% by taking out the slop.

Have totally re-written from scratch to remove all the cruft and NUT-18 references.

> I applied changes in [f16c9c4](https://github.com/cashubtc/nuts/pull/300/commits/f16c9c4a770ff7af938cb92ccfd5b10929dbc744) before realizing that the NUT-18 specs have moved from the NUT-18 document into the NUT-26 doc. I assumed we're going to define that in a separate PR. I made suggestions because the way it's suggested here will be hard to upgrade in the future. I think it should be left for another day and deserves more thought. > > Large parts of the spec read AI generated. **Please remove all AI parts.** The spec could probably be compressed by 50% by taking out the slop. Have totally re-written from scratch to remove all the cruft and NUT-18 references.
SatsAndSports commented 2026-01-05 15:43:42 +00:00 (Migrated from github.com)

What does 'abort' mean in practice here:

If rᵢ is still not in the range 1 ≤ rᵢ ≤ n−1, abort.

That the sender must abandon this ephemeral key pair and generate a new e and E and try again?

Also, why do we have this two-stage process where we try without the 0xff suffix and once with? Couldn't we just skip the retry and tell the sender to keep generating e until they find a suitable r_i?

(I feel like I might be missing some other justification. So sorry for dumb questions 😀. I'm finally putting blinding into the Spilman channel implementation and am hoping to fully understand this NUT in order to maximize compatibility between this NUT and the channel's blinding system)

What does 'abort' mean in practice here: > If rᵢ is still not in the range 1 ≤ rᵢ ≤ n−1, abort. That the sender must abandon this ephemeral key pair and generate a new `e` and `E` and try again? Also, why do we have this two-stage process where we try without the `0xff` suffix and once with? Couldn't we just skip the retry and tell the sender to keep generating `e` until they find a suitable `r_i`? _(I feel like I might be missing some other justification. So sorry for dumb questions 😀. I'm finally putting blinding into the Spilman channel implementation and am hoping to fully understand this NUT in order to maximize compatibility between this NUT and the channel's blinding system)_
robwoodgate commented 2026-01-05 17:14:21 +00:00 (Migrated from github.com)

What does 'abort' mean in practice here:

If rᵢ is still not in the range 1 ≤ rᵢ ≤ n−1, abort.

That the sender must abandon this ephemeral key pair and generate a new e and E and try again?

Correct!

Also, why do we have this two-stage process where we try without the 0xff suffix and once with? Couldn't we just skip the retry and tell the sender to keep generating e until they find a suitable r_i?

The two step process is to ensure the app doesn't have to worry about starting over in the unlikely event the value is out of range. It keeps this "problem" in the crypto aware scope rather than pushing it further up the app stack.

It's so astronomically unlikely to ever happen (as N ≈ 2²⁵⁶ × 0.9999999999999999992559580114556583) that a simple one-step retry is plenty to mean an abort is practically impossible.

I did originally spec (and would prefer) the r calculation MODULO N, which would have reduced the invalid case to literal 0 (a 1 in 2^256 chance), but this is apparently problematic in RUST (CDK) at the moment.

We have the same discussion around NUT-13.

> What does 'abort' mean in practice here: > > > If rᵢ is still not in the range 1 ≤ rᵢ ≤ n−1, abort. > > That the sender must abandon this ephemeral key pair and generate a new `e` and `E` and try again? Correct! > > Also, why do we have this two-stage process where we try without the `0xff` suffix and once with? Couldn't we just skip the retry and tell the sender to keep generating `e` until they find a suitable `r_i`? The two step process is to ensure the app doesn't have to worry about starting over in the unlikely event the value is out of range. It keeps this "problem" in the crypto aware scope rather than pushing it further up the app stack. It's so astronomically unlikely to ever happen (as N ≈ 2²⁵⁶ × 0.9999999999999999992559580114556583) that a simple one-step retry is plenty to mean an abort is practically impossible. I did originally spec (and would prefer) the `r` calculation `MODULO N`, which would have reduced the invalid case to literal `0` (a 1 in 2^256 chance), but this is apparently problematic in RUST (CDK) at the moment. We have the [same discussion](https://github.com/cashubtc/nuts/issues/321) around NUT-13.
robwoodgate commented 2026-01-05 19:23:33 +00:00 (Migrated from github.com)

Rebased to main, added code example.

Rebased to main, added code example.
robwoodgate commented 2026-01-06 14:22:11 +00:00 (Migrated from github.com)

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

@callebtc - can you re-review when you get some time please.
SatsAndSports (Migrated from github.com) reviewed 2026-01-06 16:09:22 +00:00
SatsAndSports (Migrated from github.com) commented 2026-01-06 16:09:22 +00:00

Shouldn't pe here be p2pk_e?

> - [NUT-26][26]: Pay-to-Blinded-Key - adds `"p2pk_e": bytes` to individual proofs
Shouldn't `pe` here be `p2pk_e`? ```suggestion > - [NUT-26][26]: Pay-to-Blinded-Key - adds `"p2pk_e": bytes` to individual proofs ```
robwoodgate (Migrated from github.com) reviewed 2026-01-06 16:23:13 +00:00
robwoodgate (Migrated from github.com) commented 2026-01-06 16:23:13 +00:00

No, the token v4 format uses abbreviated key names. See NUT-26 - "Proof Object Extension" section:

For Token V4 encoding, the `p2pk_e` field is named `pe`, and `E` is encoded as a 33 byte CBOR bstr
No, the token v4 format uses abbreviated key names. See NUT-26 - "Proof Object Extension" section: ``` For Token V4 encoding, the `p2pk_e` field is named `pe`, and `E` is encoded as a 33 byte CBOR bstr ```
SatsAndSports (Migrated from github.com) reviewed 2026-01-06 16:54:20 +00:00
SatsAndSports (Migrated from github.com) commented 2026-01-06 16:54:20 +00:00

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

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
robwoodgate (Migrated from github.com) reviewed 2026-01-06 17:57:48 +00:00
robwoodgate (Migrated from github.com) commented 2026-01-06 17:57:48 +00:00

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

The keyset ID ensures the blinding factor is unique between mints (and keysets) to avoid privacy leakage in case of ephemeral key reuse.
SatsAndSports (Migrated from github.com) reviewed 2026-01-06 18:33:00 +00:00
SatsAndSports (Migrated from github.com) commented 2026-01-06 18:33:00 +00:00

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

~How about (instead) including the secret's `nonce` in this derivation?~
SatsAndSports (Migrated from github.com) reviewed 2026-01-06 20:10:21 +00:00
SatsAndSports (Migrated from github.com) commented 2026-01-06 20:10:21 +00:00

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

> 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
SatsAndSports (Migrated from github.com) reviewed 2026-01-07 22:48:25 +00:00
SatsAndSports (Migrated from github.com) commented 2026-01-07 22:48:25 +00:00

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

I would still like to remove the keyset_id from the derivation, for the reason described at the start of this thread
robwoodgate (Migrated from github.com) reviewed 2026-01-08 09:15:04 +00:00
robwoodgate (Migrated from github.com) commented 2026-01-08 09:15:04 +00:00

@callebtc, @a1denvalu3, @thesimplekid - thoughts?

@callebtc, @a1denvalu3, @thesimplekid - thoughts?
a1denvalu3 (Migrated from github.com) reviewed 2026-01-11 14:04:09 +00:00
a1denvalu3 (Migrated from github.com) commented 2026-01-11 14:04:09 +00:00

@robwoodgate I think we can make this sacrifice, because -if there is nonce re-use- putting the keyset ID in the derivation only protects in the case the nonce reuse is across different keysets

@robwoodgate I think we can make this sacrifice, because -if there is nonce re-use- putting the keyset ID in the derivation only protects in the case the nonce reuse is across different keysets
a1denvalu3 (Migrated from github.com) reviewed 2026-01-11 14:05:59 +00:00
a1denvalu3 (Migrated from github.com) commented 2026-01-11 14:05:59 +00:00

Realistically we're not going to have nonce re-use.

Realistically we're not going to have nonce re-use.
a1denvalu3 (Migrated from github.com) requested changes 2026-01-11 14:48:57 +00:00
a1denvalu3 (Migrated from github.com) left a comment

I think we will now need to change the number of the NUT because bech got merged first.

I think we will now need to change the number of the NUT because bech got merged first.
robwoodgate commented 2026-01-11 16:18:53 +00:00 (Migrated from github.com)

I think we will now need to change the number of the NUT because bech got merged first.

Yeah 🤦‍♂️

Done.

> I think we will now need to change the number of the NUT because bech got merged first. Yeah 🤦‍♂️ Done.
robwoodgate (Migrated from github.com) reviewed 2026-01-11 16:23:11 +00:00
robwoodgate (Migrated from github.com) commented 2026-01-11 16:23:10 +00:00

If there is nonce reuse, that is "dOInG-iT-WroNG", so yeah, we probably don't need to protect against that.

If there is nonce reuse, that is _"dOInG-iT-WroNG"_, so yeah, we probably don't need to protect against that.
robwoodgate (Migrated from github.com) reviewed 2026-01-11 17:08:41 +00:00
robwoodgate (Migrated from github.com) left a comment

Have removed keyset ID from the blinding factor calculation, re-calculated all test vectors and renamed to NUT-28 (as BECH32 got merged first).

Have removed keyset ID from the blinding factor calculation, re-calculated all test vectors and renamed to NUT-28 (as BECH32 got merged first).
robwoodgate (Migrated from github.com) reviewed 2026-01-11 17:09:27 +00:00
robwoodgate (Migrated from github.com) commented 2026-01-11 17:09:27 +00:00

Have removed keyset id as requested

Have removed keyset id as requested
robwoodgate commented 2026-01-13 10:08:49 +00:00 (Migrated from github.com)

Rebased to main to resolve a merge conflict.

Rebased to main to resolve a merge conflict.
robwoodgate commented 2026-01-13 15:27:23 +00:00 (Migrated from github.com)

Filenames now set for immediate merge as NUT-28

Filenames now set for immediate merge as NUT-28
SatsAndSports (Migrated from github.com) approved these changes 2026-01-13 19:46:31 +00:00
SatsAndSports (Migrated from github.com) left a comment

Thanks for the last small changes. Looks good

Thanks for the last small changes. Looks good
KvngMikey (Migrated from github.com) approved these changes 2026-01-27 21:42:34 +00:00
a1denvalu3 (Migrated from github.com) approved these changes 2026-02-11 17:40:56 +00:00
d4rp4t (Migrated from github.com) approved these changes 2026-02-12 14:33:49 +00:00
callebtc (Migrated from github.com) reviewed 2026-02-12 15:58:36 +00:00
callebtc (Migrated from github.com) commented 2026-02-12 15:58:36 +00:00

I suggest to removing this, or we would have to add all possible extensions here (witness for example)

```suggestion ``` I suggest to removing this, or we would have to add all possible extensions here (witness for example)
callebtc (Migrated from github.com) reviewed 2026-02-12 15:58:50 +00:00
callebtc (Migrated from github.com) commented 2026-02-12 15:58:50 +00:00
```suggestion ```
callebtc (Migrated from github.com) reviewed 2026-02-12 15:59:27 +00:00
callebtc (Migrated from github.com) commented 2026-02-12 15:59:27 +00:00
```suggestion ```
callebtc (Migrated from github.com) reviewed 2026-02-12 16:01:39 +00:00
@ -0,0 +1,176 @@
# NUT-28: Pay-to-Blinded-Key (P2BK)
callebtc (Migrated from github.com) commented 2026-02-12 16:01:39 +00:00
```suggestion ```
callebtc (Migrated from github.com) reviewed 2026-02-12 16:03:34 +00:00
@ -0,0 +1,176 @@
# NUT-28: Pay-to-Blinded-Key (P2BK)
callebtc (Migrated from github.com) commented 2026-02-12 16:03:34 +00:00
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.
```suggestion 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. ```
callebtc (Migrated from github.com) reviewed 2026-02-12 16:04:13 +00:00
@ -0,0 +1,176 @@
# NUT-28: Pay-to-Blinded-Key (P2BK)
callebtc (Migrated from github.com) commented 2026-02-12 16:04:13 +00:00
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)`.
```suggestion 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)`. ```
callebtc (Migrated from github.com) reviewed 2026-02-12 16:04:25 +00:00
@ -0,0 +1,176 @@
# NUT-28: Pay-to-Blinded-Key (P2BK)
callebtc (Migrated from github.com) commented 2026-02-12 16:04:25 +00:00

we should spell it out once before abbreviating

we should spell it out once before abbreviating
callebtc (Migrated from github.com) reviewed 2026-02-12 16:05:31 +00:00
@ -0,0 +1,176 @@
# NUT-28: Pay-to-Blinded-Key (P2BK)
callebtc (Migrated from github.com) commented 2026-02-12 16:05:31 +00:00

This protects the privacy of their own long-lived public key.

we didn't define what the long-lived public key is yet (next sentence). we only speak about the receiver's pubkey but the sentence sounds like it protects the sender's pubkey. suggest to just remove the sentence.

> This protects the privacy of their own long-lived public key. we didn't define what the long-lived public key is yet (next sentence). we only speak about the receiver's pubkey but the sentence sounds like it protects the sender's pubkey. suggest to just remove the sentence.
callebtc (Migrated from github.com) reviewed 2026-02-12 16:06:22 +00:00
@ -0,0 +1,176 @@
# NUT-28: Pay-to-Blinded-Key (P2BK)
callebtc (Migrated from github.com) commented 2026-02-12 16:06:22 +00:00
For P2BK, the sender creates an ephemeral keypair (private key: `e`, public key: `E`). They then calculate the shared secret by combining the ephemeral private key (`e`) and the receiver's long-lived public key (`P`).
```suggestion For P2BK, the sender creates an ephemeral keypair (private key: `e`, public key: `E`). They then calculate the shared secret by combining the ephemeral private key (`e`) and the receiver's long-lived public key (`P`). ```
callebtc (Migrated from github.com) reviewed 2026-02-12 16:07:33 +00:00
@ -0,0 +1,176 @@
# NUT-28: Pay-to-Blinded-Key (P2BK)
callebtc (Migrated from github.com) commented 2026-02-12 16:07:33 +00:00
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](#proof-object-extension).
```suggestion 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](#proof-object-extension). ```
callebtc (Migrated from github.com) reviewed 2026-02-12 16:07:51 +00:00
@ -0,0 +1,176 @@
# NUT-28: Pay-to-Blinded-Key (P2BK)
callebtc (Migrated from github.com) commented 2026-02-12 16:07:51 +00:00
The shared secret `Zx` is then used to derive the blinded public keys.
```suggestion The shared secret `Zx` is then used to derive the blinded public keys. ```
callebtc (Migrated from github.com) reviewed 2026-02-12 16:09:20 +00:00
@ -0,0 +1,176 @@
# NUT-28: Pay-to-Blinded-Key (P2BK)
callebtc (Migrated from github.com) commented 2026-02-12 16:09:20 +00:00
For broader compatibility, `rᵢ` **MUST NOT** be normalised modulo `n`

I know this issue (backwards compat) but why not? Every private key should be normalized.

``` For broader compatibility, `rᵢ` **MUST NOT** be normalised modulo `n` ``` I know this issue (backwards compat) but why not? Every private key should be normalized.
callebtc (Migrated from github.com) reviewed 2026-02-12 16:09:46 +00:00
@ -0,0 +49,4 @@
rᵢ = SHA-256( b"Cashu_P2BK_v1" || Zx || i_byte || 0xff )
```
If `rᵢ` is still not in the range `1 ≤ rᵢ ≤ n1`, abort and discard the ephemeral keypair.
callebtc (Migrated from github.com) commented 2026-02-12 16:09:46 +00:00

It would allow us to delete this part

It would allow us to delete this part
callebtc (Migrated from github.com) reviewed 2026-02-12 16:10:24 +00:00
@ -0,0 +1,176 @@
# NUT-28: Pay-to-Blinded-Key (P2BK)
callebtc (Migrated from github.com) commented 2026-02-12 16:10:24 +00:00
### Example

Below is an example implementation in TypeScript.
```suggestion ### Example Below is an example implementation in TypeScript. ```
callebtc (Migrated from github.com) reviewed 2026-02-12 16:10:59 +00:00
@ -0,0 +82,4 @@
// Astronomically unlikely to get here!
throw new Error("P2BK: tweak derivation failed");
}
}
callebtc (Migrated from github.com) commented 2026-02-12 16:10:59 +00:00

suggest: do not use own normalization, let the crypto lib handle it?

suggest: do not use own normalization, let the crypto lib handle it?
callebtc (Migrated from github.com) reviewed 2026-02-12 16:11:24 +00:00
@ -0,0 +1,176 @@
# NUT-28: Pay-to-Blinded-Key (P2BK)
callebtc (Migrated from github.com) commented 2026-02-12 16:11:24 +00:00
  "p2pk_e": hex_str       // NEW: 33-byte SEC1 compressed ephemeral public key E
```suggestion "p2pk_e": hex_str // NEW: 33-byte SEC1 compressed ephemeral public key E ```
callebtc (Migrated from github.com) reviewed 2026-02-12 16:13:21 +00:00
@ -0,0 +119,4 @@
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`
callebtc (Migrated from github.com) commented 2026-02-12 16:13:22 +00:00

k is undefined

`k` is undefined
robwoodgate (Migrated from github.com) reviewed 2026-02-12 16:48:16 +00:00
@ -0,0 +1,176 @@
# NUT-28: Pay-to-Blinded-Key (P2BK)
robwoodgate (Migrated from github.com) commented 2026-02-12 16:48:16 +00:00

I agree. I specced it modulo n in the intial draft, but received feedback that RUST (CDK) would have problems doing modulo n calculations.

So that's why it was re-drafted as "tweak-and-single-retry, abandon ephemeral key if still out of range"

Modulo n is the far simpler implementation, if it can be supported consistently.

That's the backwards compatibility issue. Happy to go either way - just make the call.

I agree. I specced it modulo n in the intial draft, but received feedback that RUST (CDK) would have problems doing modulo n calculations. So that's why it was re-drafted as _"tweak-and-single-retry, abandon ephemeral key if still out of range"_ Modulo n is the far simpler implementation, if it can be supported consistently. That's the backwards compatibility issue. Happy to go either way - just make the call.
robwoodgate (Migrated from github.com) reviewed 2026-02-12 16:50:53 +00:00
robwoodgate (Migrated from github.com) commented 2026-02-12 16:50:53 +00:00

My concern was that a reader has to know all the NUTS to understand the possible shape of a proof. But happy to remove.

My concern was that a reader has to know all the NUTS to understand the possible shape of a proof. But happy to remove.
robwoodgate (Migrated from github.com) reviewed 2026-02-12 16:58:07 +00:00
@ -0,0 +1,176 @@
# NUT-28: Pay-to-Blinded-Key (P2BK)
robwoodgate (Migrated from github.com) commented 2026-02-12 16:58:07 +00:00
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:
```suggestion 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: ```
robwoodgate (Migrated from github.com) reviewed 2026-02-12 17:01:04 +00:00
@ -0,0 +82,4 @@
// Astronomically unlikely to get here!
throw new Error("P2BK: tweak derivation failed");
}
}
robwoodgate (Migrated from github.com) commented 2026-02-12 17:01:04 +00:00

If RUST can handle the modulo n, it's a thumbs up from me. (@thesimplekid )

If RUST can handle the `modulo n`, it's a thumbs up from me. (@thesimplekid )
robwoodgate (Migrated from github.com) reviewed 2026-02-12 17:05:52 +00:00
@ -0,0 +1,176 @@
# NUT-28: Pay-to-Blinded-Key (P2BK)
robwoodgate (Migrated from github.com) commented 2026-02-12 17:05:52 +00:00

The idea here was to highlight to sender that using an ephemeral keypair protects their own (long lived) public key from being linked to the proofs.

We can remove the sentence, but I think it's an important feature of P2BK... it provides privacy for both sender AND receiver.

The idea here was to highlight to sender that using an ephemeral keypair protects their own (long lived) public key from being linked to the proofs. We can remove the sentence, but I think it's an important feature of P2BK... it provides privacy for both sender AND receiver.
robwoodgate (Migrated from github.com) reviewed 2026-02-12 17:09:36 +00:00
@ -0,0 +1,176 @@
# NUT-28: Pay-to-Blinded-Key (P2BK)
robwoodgate (Migrated from github.com) commented 2026-02-12 17:09:36 +00:00

That's exactly right - using an ephemeral keypair does protect the senders pubkey :)

That's exactly right - using an ephemeral keypair does protect the senders pubkey :)
SatsAndSports (Migrated from github.com) reviewed 2026-02-13 17:28:27 +00:00
@ -0,0 +1,176 @@
# NUT-28: Pay-to-Blinded-Key (P2BK)
SatsAndSports (Migrated from github.com) commented 2026-02-13 17:28:27 +00:00

modulo n could technically give us 0 right?

It's so unlikely, I guess we could ignore it

But if we want to avoid that unlikely event, we could do modulo (n-1) and then add one

i.e. r = 1 + sha256(...) % (n-1) , where % is modulo, in order to ensure that we get a number between 1 and n-1 (inclusive). Although hopefully the cryptography library does something like this already

modulo `n` could technically give us `0` right? It's so unlikely, I guess we could ignore it But if we want to avoid that unlikely event, we could do modulo (n-1) and then add one i.e. `r = 1 + sha256(...) % (n-1)` , where `%` is modulo, in order to ensure that we get a number between `1` and `n-1` (inclusive). Although hopefully the cryptography library does something like this already
robwoodgate (Migrated from github.com) reviewed 2026-02-14 00:42:33 +00:00
@ -0,0 +1,176 @@
# NUT-28: Pay-to-Blinded-Key (P2BK)
robwoodgate (Migrated from github.com) commented 2026-02-14 00:42:33 +00:00

I believe the usual practice is to do standard modulo n and discard/retry if zero.

I believe the usual practice is to do standard modulo n and discard/retry if zero.
robwoodgate (Migrated from github.com) reviewed 2026-02-16 12:38:13 +00:00
@ -0,0 +82,4 @@
// Astronomically unlikely to get here!
throw new Error("P2BK: tweak derivation failed");
}
}
robwoodgate (Migrated from github.com) commented 2026-02-16 12:38:13 +00:00

We agreed offline to keep tweak and retry, as not all implementations can handle modulo n reduction

We agreed offline to keep tweak and retry, as not all implementations can handle modulo n reduction
robwoodgate (Migrated from github.com) reviewed 2026-02-16 12:38:25 +00:00
@ -0,0 +49,4 @@
rᵢ = SHA-256( b"Cashu_P2BK_v1" || Zx || i_byte || 0xff )
```
If `rᵢ` is still not in the range `1 ≤ rᵢ ≤ n1`, abort and discard the ephemeral keypair.
robwoodgate (Migrated from github.com) commented 2026-02-16 12:38:25 +00:00

We agreed offline to keep tweak and retry, as not all implementations can handle modulo n reduction

We agreed offline to keep tweak and retry, as not all implementations can handle modulo n reduction
robwoodgate (Migrated from github.com) reviewed 2026-02-16 12:39:16 +00:00
@ -0,0 +1,176 @@
# NUT-28: Pay-to-Blinded-Key (P2BK)
robwoodgate (Migrated from github.com) commented 2026-02-16 12:39:16 +00:00

clarified

clarified
robwoodgate (Migrated from github.com) reviewed 2026-02-16 12:43:22 +00:00
@ -0,0 +1,176 @@
# NUT-28: Pay-to-Blinded-Key (P2BK)
robwoodgate (Migrated from github.com) commented 2026-02-16 12:43:22 +00:00

We agreed offline to keep tweak and retry, as not all implementations can handle modulo n reduction

We agreed offline to keep tweak and retry, as not all implementations can handle modulo n reduction
callebtc (Migrated from github.com) approved these changes 2026-02-16 12:53:07 +00:00
callebtc (Migrated from github.com) left a comment

LGTM

LGTM
Sign in to join this conversation.
No description provided.