diff --git a/cashu/core/base.py b/cashu/core/base.py index 75f622010..ff3e4c50e 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -129,6 +129,7 @@ class Proof(BaseModel): C: str = "" # signature on secret, unblinded by wallet dleq: Optional[DLEQWallet] = None # DLEQ proof witness: Union[None, str] = None # witness for spending condition + p2pk_e: Union[None, str] = None # NUT-28 P2BK ephemeral pubkey E (33-byte SEC1 hex) # whether this proof is reserved for sending, used for coin management in the wallet reserved: Union[None, bool] = False @@ -173,6 +174,9 @@ def to_dict(self, include_dleq=False): if self.witness: return_dict["witness"] = self.witness + if self.p2pk_e: + return_dict["p2pk_e"] = self.p2pk_e + return return_dict def to_base64(self): @@ -1168,6 +1172,7 @@ class TokenV4Proof(BaseModel): c: bytes # signature d: Optional[TokenV4DLEQ] = None # DLEQ proof w: Optional[str] = None # witness + pe: Optional[bytes] = None # NUT-28 P2BK ephemeral pubkey E (33-byte SEC1) @classmethod def from_proof(cls, proof: Proof, include_dleq=False): @@ -1185,6 +1190,7 @@ def from_proof(cls, proof: Proof, include_dleq=False): else None ), w=proof.witness, + pe=bytes.fromhex(proof.p2pk_e) if proof.p2pk_e else None, ) @@ -1255,6 +1261,7 @@ def proofs(self) -> List[Proof]: else None ), witness=p.w, + p2pk_e=p.pe.hex() if p.pe else None, ) for token in self.t for p in token.p @@ -1294,6 +1301,7 @@ def from_tokenv3(cls, tokenv3: TokenV3): else None ), w=p.witness, + pe=bytes.fromhex(p.p2pk_e) if p.p2pk_e else None, ) for p in proofs ], @@ -1321,6 +1329,10 @@ def serialize_to_dict(self, include_dleq=False): for proof in token["p"]: if not proof.get("w"): del proof["w"] + # strip pe if not present + if not proof.get("pe"): + if "pe" in proof: + del proof["pe"] # optional memo if self.d: return_dict.update(dict(d=self.d)) @@ -1382,6 +1394,7 @@ def to_tokenv3(self) -> TokenV3: else None ), witness=p.w, + p2pk_e=p.pe.hex() if p.pe else None, ) for p in token.p ], diff --git a/cashu/core/p2bk.py b/cashu/core/p2bk.py new file mode 100644 index 000000000..a5a39a02d --- /dev/null +++ b/cashu/core/p2bk.py @@ -0,0 +1,154 @@ +import hashlib +import os +from typing import List, Optional, Tuple + +from .crypto.secp import PrivateKey, PublicKey + +# Domain separator for P2BK blinding scalar derivation +P2BK_DOMAIN_SEPARATOR = b"Cashu_P2BK_v1" + +# secp256k1 curve order +SECP256K1_ORDER = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 + +def _compressed_pubkey(pubkey_hex: str) -> str: + """Ensure a pubkey is in compressed SEC1 format (33 bytes / 66 hex chars). + Silently adds '02' prefix to bare 32-byte (BIP-340 / Nostr) x-only keys. + """ + raw = bytes.fromhex(pubkey_hex) + if len(raw) == 32: + # x-only key, add 02 prefix + return "02" + pubkey_hex + if len(raw) == 33 and raw[0] in (0x02, 0x03): + return pubkey_hex + raise ValueError(f"Invalid pubkey length: {len(raw)} bytes") + +def ecdh_shared_secret(point: PublicKey, scalar: PrivateKey) -> bytes: + """Compute x-only ECDH shared secret Zx = x(scalar * point)""" + shared_point = point.multiply(bytes.fromhex(scalar.to_hex())) + # compressed format is prefix (1 byte) + x-coordinate (32 bytes) + compressed = shared_point.format(compressed=True) + return compressed[1:] # strip the 02/03 prefix to get Zx + +def derive_blinding_scalar(zx: bytes, slot_index: int) -> int: + """Derive a deterministic blinding scalar r_i from ECDH shared secret and slot index.""" + i_byte = bytes([slot_index & 0xFF]) + data = P2BK_DOMAIN_SEPARATOR + zx + i_byte + r = int.from_bytes(hashlib.sha256(data).digest(), "big") + if r == 0 or r >= SECP256K1_ORDER: + # retry with 0xff appended + data_retry = data + b"\xff" + r = int.from_bytes(hashlib.sha256(data_retry).digest(), "big") + if r == 0 or r >= SECP256K1_ORDER: + raise ValueError("P2BK: blinding scalar derivation failed") + return r + +def _scalar_to_privkey(scalar: int) -> PrivateKey: + """Convert an integer scalar to a PrivateKey.""" + return PrivateKey(scalar.to_bytes(32, "big")) + +def _pubkey_x(pubkey: PublicKey) -> bytes: + """Get the x-coordinate (32 bytes) from a compressed public key.""" + return pubkey.format(compressed=True)[1:] + +def blind_pubkeys( + data_pubkey: str, + additional_pubkeys: List[str], + refund_pubkeys: List[str], + ephemeral_privkey: Optional[PrivateKey] = None, +) -> Tuple[str, List[str], List[str], str]: + """Blind all pubkeys in a P2PK secret using per-key ECDH. + + Each pubkey P_i gets its own shared secret Zx_i = x(e * P_i), as required + by NUT-28: "For each receiver key P, compute: Unique shared secret Zx = x(eP)." + + Args: + data_pubkey: The main locking pubkey. + additional_pubkeys: Additional pubkeys from the "pubkeys" tag. + refund_pubkeys: Refund pubkeys from the "refund" tag. + ephemeral_privkey: Optional ephemeral private key. Generated if None. + + Returns: + Tuple of (blinded_data_pubkey, blinded_additional, blinded_refund, ephemeral_pubkey_hex) + """ + if ephemeral_privkey is None: + ephemeral_privkey = PrivateKey(os.urandom(32)) + + assert ephemeral_privkey.public_key + ephemeral_pubkey_hex = ephemeral_privkey.public_key.format(compressed=True).hex() + + # collect all pubkeys in slot order: [data, ...pubkeys, ...refund] + all_pubkeys = [data_pubkey] + additional_pubkeys + refund_pubkeys + blinded = [] + for i, pk_hex in enumerate(all_pubkeys): + pk_hex = _compressed_pubkey(pk_hex) + pk = PublicKey(bytes.fromhex(pk_hex)) + # Per-key ECDH: Zx_i = x(e * P_i) + zx_i = ecdh_shared_secret(pk, ephemeral_privkey) + r_i = derive_blinding_scalar(zx_i, i) + blinding_point = _scalar_to_privkey(r_i).public_key + assert blinding_point + blinded_pk = pk + blinding_point # type: ignore[operator] # P' = P + r_i*G + blinded.append(blinded_pk.format(compressed=True).hex()) + + # split back into data, pubkeys, refund + blinded_data = blinded[0] + blinded_additional = blinded[1 : 1 + len(additional_pubkeys)] + blinded_refund = blinded[1 + len(additional_pubkeys) :] + + return blinded_data, blinded_additional, blinded_refund, ephemeral_pubkey_hex + + +def derive_blinded_private_key( + privkey: PrivateKey, + ephemeral_pubkey_hex: str, + blinded_pubkey_hex: str, + slot_index: int, +) -> Optional[PrivateKey]: + """derive the blinded private key for a given slot. + + Args: + privkey: Receiver's long-lived private key p. + ephemeral_pubkey_hex: Sender's ephemeral public key E (hex, 33 bytes). + blinded_pubkey_hex: The blinded public key P' from the secret. + slot_index: The slot index i. + + Returns: + The blinded PrivateKey k, or None if this slot is not for this key. + """ + ephemeral_pubkey_hex = _compressed_pubkey(ephemeral_pubkey_hex) + E = PublicKey(bytes.fromhex(ephemeral_pubkey_hex)) + + # Zx = x(p * E) + zx = ecdh_shared_secret(E, privkey) + + r_i = derive_blinding_scalar(zx, slot_index) + + # R_i = r_i * G + r_i_key = _scalar_to_privkey(r_i) + R_i = r_i_key.public_key + assert R_i + + # Unblind: P = P' - R_i + blinded_pubkey_hex = _compressed_pubkey(blinded_pubkey_hex) + P_prime = PublicKey(bytes.fromhex(blinded_pubkey_hex)) + P = P_prime - R_i # type: ignore + + # Verify x(P) == x(p*G) + assert privkey.public_key + pG = privkey.public_key + if _pubkey_x(P) != _pubkey_x(pG): + return None # this slot is not for this key + + # Parity check + p_int = int.from_bytes(bytes.fromhex(privkey.to_hex()), "big") + P_prefix = P.format(compressed=True)[0] + pG_prefix = pG.format(compressed=True)[0] + + if P_prefix == pG_prefix: + # standard derivation: k = (p + r_i) mod n + k = (p_int + r_i) % SECP256K1_ORDER + else: + # negated derivation: k = (-p + r_i) mod n + k = ((-p_int) + r_i) % SECP256K1_ORDER + + return _scalar_to_privkey(k) \ No newline at end of file diff --git a/cashu/wallet/crud.py b/cashu/wallet/crud.py index 77ecb4b6c..d434a4a39 100644 --- a/cashu/wallet/crud.py +++ b/cashu/wallet/crud.py @@ -29,8 +29,8 @@ async def store_proof( await (conn or db).execute( """ INSERT INTO proofs - (id, amount, C, secret, time_created, derivation_path, dleq, mint_id, melt_id) - VALUES (:id, :amount, :C, :secret, :time_created, :derivation_path, :dleq, :mint_id, :melt_id) + (id, amount, C, secret, time_created, derivation_path, dleq, mint_id, melt_id, p2pk_e) + VALUES (:id, :amount, :C, :secret, :time_created, :derivation_path, :dleq, :mint_id, :melt_id, :p2pk_e) """, { "id": proof.id, @@ -42,6 +42,7 @@ async def store_proof( "dleq": json.dumps(proof.dleq.model_dump()) if proof.dleq else "", "mint_id": proof.mint_id, "melt_id": proof.melt_id, + "p2pk_e": proof.p2pk_e, }, ) diff --git a/cashu/wallet/helpers.py b/cashu/wallet/helpers.py index cc59cb942..444542658 100644 --- a/cashu/wallet/helpers.py +++ b/cashu/wallet/helpers.py @@ -146,6 +146,7 @@ async def send( Prints token to send to stdout. """ secret_lock = None + p2pk_e = None if lock: assert len(lock) > 21, Exception( "Error: lock has to be at least 22 characters long." @@ -169,8 +170,26 @@ async def send( tags=tags, ) logger.debug(f"Secret lock: {secret_lock}") + elif lock.startswith("P2BK:") or lock.startswith("P2BK-SIGALL:"): + sigall = lock.startswith("P2BK-SIGALL:") + logger.debug(f"Locking token with P2BK to: {lock}") + logger.debug( + f"Adding a time lock of {settings.locktime_delta_seconds} seconds." + ) + tags = None + if refund_pubkeys: + tags = Tags() + tags["refund"] = refund_pubkeys + secret_lock, p2pk_e = await wallet.create_p2bk_lock( + lock.split(":")[1], + locktime_seconds=settings.locktime_delta_seconds, + sig_all=sigall, + n_sigs=1, + tags=tags, + ) + logger.debug(f"P2BK secret lock: {secret_lock}, ephemeral pubkey: {p2pk_e}") else: - raise Exception("Error: lock has to start with P2PK: or P2PK-SIGALL:") + raise Exception("Error: lock has to start with P2PK:, P2PK-SIGALL:, P2BK:, or P2BK-SIGALL:") await wallet.load_proofs() @@ -181,6 +200,7 @@ async def send( amount, set_reserved=False, # we set reserved later secret_lock=secret_lock, + p2pk_e=p2pk_e, ) else: send_proofs, fees = await wallet.select_to_send( diff --git a/cashu/wallet/migrations.py b/cashu/wallet/migrations.py index 6f979d4a6..3ab7e3d4d 100644 --- a/cashu/wallet/migrations.py +++ b/cashu/wallet/migrations.py @@ -334,3 +334,13 @@ async def m016_remove_nostr_table(db: Database): DROP TABLE IF EXISTS nostr; """ ) + + +async def m017_add_p2pk_e_to_proofs(db: Database): + """ + Column to store the NUT-28 P2BK ephemeral pubkey (p2pk_e / E) on proofs. + Without this, P2BK tokens become unspendable after a wallet restart. + """ + async with db.connect() as conn: + await conn.execute("ALTER TABLE proofs ADD COLUMN p2pk_e TEXT") + await conn.execute("ALTER TABLE proofs_used ADD COLUMN p2pk_e TEXT") diff --git a/cashu/wallet/p2bk.py b/cashu/wallet/p2bk.py new file mode 100644 index 000000000..ea041a702 --- /dev/null +++ b/cashu/wallet/p2bk.py @@ -0,0 +1,126 @@ +from datetime import datetime, timedelta +from typing import List, Optional, Tuple + +from ..core.base import Proof +from ..core.crypto.secp import PrivateKey +from ..core.db import Database +from ..core.p2bk import ( + blind_pubkeys, + derive_blinded_private_key, +) +from ..core.p2pk import ( + P2PKSecret, + SigFlags, +) +from ..core.secret import SecretKind, Tags +from .protocols import SupportsDb, SupportsPrivateKey + + +class WalletP2BK(SupportsPrivateKey, SupportsDb): + db: Database + private_key: PrivateKey + + async def create_p2bk_lock( + self, + data: str, + locktime_seconds: Optional[int] = None, + tags: Optional[Tags] = None, + sig_all: bool = False, + n_sigs: int = 1, + ephemeral_privkey: Optional[PrivateKey] = None, + ) -> Tuple[P2PKSecret, str]: + """Generate a P2BK-blinded P2PK secret. + Blinds pubkeys in [data, ...pubkeys, ...refund] slot order using ECDH. + + Args: + data: Receiver's public key to lock to. + locktime_seconds: Locktime in seconds. + tags: Tags (may contain pubkeys, refund keys). + sig_all: Whether to use SIG_ALL spending condition. + n_sigs: Number of signatures required. + ephemeral_privkey: Ephemeral private key (reuse for SIG_ALL across outputs). + + Returns: + Tuple of (P2PKSecret with blinded keys, ephemeral pubkey E hex). + """ + + if not tags: + tags = Tags() + + if locktime_seconds: + tags["locktime"] = str( + int((datetime.now() + timedelta(seconds=locktime_seconds)).timestamp()) + ) + tags["sigflag"] = ( + SigFlags.SIG_ALL.value if sig_all else SigFlags.SIG_INPUTS.value + ) + if n_sigs > 1: + tags["n_sigs"] = str(n_sigs) + + # Collect pubkeys from tags before blinding + additional_pubkeys = tags.get_tag_all("pubkeys") + refund_pubkeys = tags.get_tag_all("refund") + + # Per-key ECDH: each pubkey P_i gets its own Zx_i = x(e * P_i). + # Multi-party refund keys are handled correctly by this approach. + blinded_data, blinded_additional, blinded_refund, ephemeral_pubkey_hex = ( + blind_pubkeys( + data_pubkey=data, + additional_pubkeys=additional_pubkeys, + refund_pubkeys=refund_pubkeys, + ephemeral_privkey=ephemeral_privkey, + ) + ) + + # Rebuild tags with blinded keys. + # Tags stores multi-value as [key, v1, v2, ...]; list assignment is safe. + blinded_tags = Tags() + for tag in tags.root: + if tag[0] == "pubkeys": + blinded_tags["pubkeys"] = blinded_additional + elif tag[0] == "refund": + blinded_tags["refund"] = blinded_refund + else: + blinded_tags.root.append(tag) + + return P2PKSecret( + kind=SecretKind.P2PK.value, + data=blinded_data, + tags=blinded_tags, + ), ephemeral_pubkey_hex + + def _derive_p2bk_signing_key(self, proof: Proof) -> Optional[PrivateKey]: + """If proof has p2pk_e, derive the blinded private key for our slot. + + Returns the blinded private key if we can unblind a slot, else None. + """ + if not proof.p2pk_e: + return None + secret = P2PKSecret.deserialize(proof.secret) + + all_blinded_pubkeys = ( + [secret.data] + + secret.tags.get_tag_all("pubkeys") + + secret.tags.get_tag_all("refund") + ) + for i, blinded_pk in enumerate(all_blinded_pubkeys): + try: + derived = derive_blinded_private_key( + privkey=self.private_key, + ephemeral_pubkey_hex=proof.p2pk_e, + blinded_pubkey_hex=blinded_pk, + slot_index=i, + ) + except Exception: + # Slot value is not a valid pubkey (e.g. HTLC preimage hash) + continue + if derived is not None: + return derived + return None + + def filter_p2bk_proofs(self, proofs: List[Proof]) -> List[Proof]: + """Filter P2BK proofs (those with p2pk_e) that we can unblind.""" + return [ + p for p in proofs + if p.p2pk_e and self._derive_p2bk_signing_key(p) is not None + ] diff --git a/cashu/wallet/p2pk.py b/cashu/wallet/p2pk.py index eb057f0c5..96cba7c05 100644 --- a/cashu/wallet/p2pk.py +++ b/cashu/wallet/p2pk.py @@ -19,10 +19,11 @@ schnorr_sign, ) from ..core.secret import Secret, SecretKind, Tags +from .p2bk import WalletP2BK from .protocols import SupportsDb, SupportsPrivateKey -class WalletP2PK(SupportsPrivateKey, SupportsDb): +class WalletP2PK(WalletP2BK, SupportsPrivateKey, SupportsDb): db: Database private_key: PrivateKey # ---------- P2PK ---------- @@ -77,6 +78,7 @@ async def create_p2pk_lock( def signatures_proofs_sig_inputs(self, proofs: List[Proof]) -> List[str]: """Signs proof secrets with the private key of the wallet. This method is used to sign P2PK SIG_INPUTS proofs. + For P2BK proofs (with p2pk_e), derives the blinded private key first. Args: proofs (List[Proof]): Proofs to sign @@ -94,23 +96,25 @@ def signatures_proofs_sig_inputs(self, proofs: List[Proof]) -> List[str]: logger.trace(f"Signing proof: {proof}") logger.trace(f"Signing message: {proof.secret}") - signatures = [ - schnorr_sign( - message=proof.secret.encode("utf-8"), - private_key=private_key, - ).hex() - for proof in proofs - ] + signatures = [] + for proof in proofs: + signing_key = self._derive_p2bk_signing_key(proof) or private_key + signatures.append( + schnorr_sign( + message=proof.secret.encode("utf-8"), + private_key=signing_key, + ).hex() + ) logger.debug(f"Signatures: {signatures}") return signatures - def schnorr_sign_message(self, message: str) -> str: - """Sign a message with the private key of the wallet.""" - private_key = self.private_key - assert private_key.public_key + def schnorr_sign_message(self, message: str, signing_key: Optional[PrivateKey] = None) -> str: + """Sign a message with the given key or the wallet's private key.""" + key = signing_key or self.private_key + assert key.public_key return schnorr_sign( message=message.encode("utf-8"), - private_key=private_key, + private_key=key, ).hex() def _inputs_require_sigall(self, proofs: List[Proof]) -> bool: @@ -157,7 +161,9 @@ def add_witness_swap_sig_all( message_to_sign = message_to_sign or "".join( [p.secret for p in proofs] + [o.B_ for o in outputs] ) - signature = self.schnorr_sign_message(message_to_sign) + # For P2BK proofs, use the derived blinded signing key + signing_key = self._derive_p2bk_signing_key(proofs[0]) + signature = self.schnorr_sign_message(message_to_sign, signing_key) # add witness to only the first proof signed_proofs = self.add_signatures_to_proofs([proofs[0]], [signature]) proofs[0].witness = signed_proofs[0].witness @@ -185,6 +191,11 @@ def sign_proofs_inplace_swap( # sign first proof if swap is SIG_ALL proofs = self.add_witness_swap_sig_all(proofs, outputs) + # p2pk_e stripped AFTER signing: add_witnesses_sig_inputs derives the + # blinded key via _derive_p2bk_signing_key before we clear the field. + for p in proofs: + p.p2pk_e = None + return proofs def sign_proofs_inplace_melt( @@ -196,7 +207,14 @@ def sign_proofs_inplace_melt( "".join([p.secret for p in proofs] + [o.B_ for o in outputs]) + quote_id ) # sign first proof if swap is SIG_ALL - return self.add_witness_swap_sig_all(proofs, outputs, message_to_sign) + proofs = self.add_witness_swap_sig_all(proofs, outputs, message_to_sign) + + # p2pk_e stripped AFTER signing: add_witnesses_sig_inputs derives the + # blinded key via _derive_p2bk_signing_key before we clear the field. + for p in proofs: + p.p2pk_e = None + + return proofs def add_signatures_to_proofs( self, proofs: List[Proof], signatures: List[str] @@ -251,12 +269,17 @@ def add_signatures_to_proofs( return proofs def filter_proofs_locked_to_our_pubkey(self, proofs: List[Proof]) -> List[Proof]: - """This method assumes that secrets are all P2PK!""" - # filter proofs that require our pubkey + """Filter proofs locked to our pubkey. Handles both plain P2PK and P2BK proofs.""" assert self.private_key.public_key our_pubkey = self.private_key.public_key.format().hex().lower() our_pubkey_proofs = [] for p in proofs: + # P2BK: if p2pk_e is present, try to unblind and check + if p.p2pk_e: + if self._derive_p2bk_signing_key(p) is not None: + our_pubkey_proofs.append(p) + continue + # Plain P2PK: check if our pubkey is in the secret secret = P2PKSecret.deserialize(p.secret) pubkeys = ( [secret.data] @@ -290,7 +313,9 @@ def sign_p2pk_sig_inputs(self, proofs: List[Proof]) -> List[Proof]: if secret.kind == SecretKind.HTLC.value and ( secret.tags.get_tag("pubkeys") or secret.tags.get_tag("refund") ): - # HTLC secret with pubkeys tag is a P2PK secret + # NUT-28: P2BK is inherited here — HTLC reuses the P2PK + # signature path which calls _derive_p2bk_signing_key. + # No HTLC-specific P2BK handling is needed. p2pk_proofs.append(p) except Exception: pass diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 62bb5a057..cd4921c6e 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -621,12 +621,12 @@ async def mint( ) proofs = await self._construct_proofs(promises, secrets, rs, derivation_paths) - await update_bolt11_mint_quote( - db=self.db, - quote=quote_id, - state=MintQuoteState.issued, - paid_time=int(time.time()), - ) + await update_bolt11_mint_quote( + db=self.db, + quote=quote_id, + state=MintQuoteState.issued, + paid_time=int(time.time()), + ) # store the mint_id in proofs async with self.db.connect() as conn: for p in proofs: @@ -652,6 +652,7 @@ async def split( amount: int, secret_lock: Optional[Secret] = None, include_fees: bool = False, + p2pk_e: Optional[str] = None, ) -> Tuple[List[Proof], List[Proof]]: """Calls the swap API to split the proofs into two sets of proofs, one for keeping and one for sending. @@ -738,6 +739,12 @@ async def split( keep_proofs = new_proofs[: len(keep_outputs)] send_proofs = new_proofs[len(keep_outputs) :] + + # P2BK: attach ephemeral pubkey to send proofs + if p2pk_e: + for p in send_proofs: + p.p2pk_e = p2pk_e + return keep_proofs, send_proofs async def melt_quote( @@ -1230,6 +1237,7 @@ async def swap_to_send( secret_lock: Optional[Secret] = None, set_reserved: bool = False, include_fees: bool = False, + p2pk_e: Optional[str] = None, ) -> Tuple[List[Proof], List[Proof]]: """ Swaps a set of proofs with the mint to get a set that sums up to a desired amount that can be sent. The remaining @@ -1269,7 +1277,7 @@ async def swap_to_send( f"Amount to send: {self.unit.str(amount)} (+ {self.unit.str(fees)} fees)" ) keep_proofs, send_proofs = await self.split( - swap_proofs, amount, secret_lock, include_fees=include_fees + swap_proofs, amount, secret_lock, include_fees=include_fees, p2pk_e=p2pk_e ) if set_reserved: await self.set_reserved_for_send(send_proofs, reserved=True) diff --git a/tests/wallet/test_wallet_p2bk.py b/tests/wallet/test_wallet_p2bk.py new file mode 100644 index 000000000..f0b714ded --- /dev/null +++ b/tests/wallet/test_wallet_p2bk.py @@ -0,0 +1,690 @@ +"""Tests for NUT-28: Pay-to-Blinded-Key (P2BK)""" + +import copy +import secrets +from typing import List + +import pytest +import pytest_asyncio + +from cashu.core.base import BlindedMessage, Proof +from cashu.core.crypto.secp import PrivateKey, PublicKey +from cashu.core.migrations import migrate_databases +from cashu.core.p2bk import ( + SECP256K1_ORDER, + _compressed_pubkey, + _pubkey_x, + blind_pubkeys, + derive_blinded_private_key, + derive_blinding_scalar, + ecdh_shared_secret, +) +from cashu.core.p2pk import P2PKSecret, SigFlags, schnorr_sign, verify_schnorr_signature +from cashu.core.secret import Secret, SecretKind, Tags +from cashu.wallet import migrations +from cashu.wallet.wallet import Wallet +from tests.conftest import SERVER_ENDPOINT +from tests.helpers import pay_if_regtest + + +async def assert_err(f, msg): + """Compute f() and expect an error message 'msg'.""" + try: + await f + except Exception as exc: + exc_msg = str(exc) + if msg and msg not in exc_msg: + raise Exception(f"Expected error: {msg}, got: {exc_msg}") + return + raise Exception(f"Expected error: {msg}, got no error") + + +@pytest_asyncio.fixture(scope="function") +async def wallet1(): + wallet1 = await Wallet.with_db( + SERVER_ENDPOINT, "test_data/wallet_p2bk_1", "wallet1" + ) + await migrate_databases(wallet1.db, migrations) + await wallet1.load_mint() + yield wallet1 + + +@pytest_asyncio.fixture(scope="function") +async def wallet2(): + wallet2 = await Wallet.with_db( + SERVER_ENDPOINT, "test_data/wallet_p2bk_2", "wallet2" + ) + await migrate_databases(wallet2.db, migrations) + wallet2.private_key = PrivateKey(secrets.token_bytes(32)) + await wallet2.load_mint() + yield wallet2 + + +# ────────────────────────────────────────────── +# Unit tests for core P2BK primitives +# ────────────────────────────────────────────── + + +def test_compressed_pubkey_32_bytes(): + """x-only (32 byte) key gets an 02 prefix.""" + priv = PrivateKey() + assert priv.public_key + x_only = priv.public_key.format(compressed=True)[1:].hex() + assert len(bytes.fromhex(x_only)) == 32 + result = _compressed_pubkey(x_only) + assert result.startswith("02") + assert len(bytes.fromhex(result)) == 33 + + +def test_compressed_pubkey_33_bytes(): + """Already compressed key passes through.""" + priv = PrivateKey() + assert priv.public_key + compressed = priv.public_key.format(compressed=True).hex() + assert _compressed_pubkey(compressed) == compressed + + +def test_compressed_pubkey_invalid(): + """Invalid length raises ValueError.""" + with pytest.raises(ValueError): + _compressed_pubkey("aabbccdd") # 4 bytes + + +def test_ecdh_shared_secret_commutative(): + """Zx = x(e*P) == x(p*E).""" + e = PrivateKey() + p = PrivateKey() + assert e.public_key and p.public_key + E = e.public_key + P = p.public_key + zx_sender = ecdh_shared_secret(P, e) # sender computes e*P + zx_receiver = ecdh_shared_secret(E, p) # receiver computes p*E + assert zx_sender == zx_receiver + assert len(zx_sender) == 32 + + +def test_derive_blinding_scalar_deterministic(): + """Same inputs always produce the same scalar.""" + zx = secrets.token_bytes(32) + r1 = derive_blinding_scalar(zx, 0) + r2 = derive_blinding_scalar(zx, 0) + assert r1 == r2 + + +def test_derive_blinding_scalar_different_slots(): + """Different slot indices produce different scalars.""" + zx = secrets.token_bytes(32) + r0 = derive_blinding_scalar(zx, 0) + r1 = derive_blinding_scalar(zx, 1) + assert r0 != r1 + + +def test_derive_blinding_scalar_range(): + """Scalar must be 1 <= r < n.""" + zx = secrets.token_bytes(32) + for i in range(11): # slots 0-10 + r = derive_blinding_scalar(zx, i) + assert 1 <= r < SECP256K1_ORDER + + +def test_blind_pubkeys_roundtrip(): + """Blinded keys can be unblinded by the receiver.""" + receiver_priv = PrivateKey() + assert receiver_priv.public_key + receiver_pub_hex = receiver_priv.public_key.format(compressed=True).hex() + + blinded_data, blinded_add, blinded_refund, ephemeral_pub = blind_pubkeys( + data_pubkey=receiver_pub_hex, + additional_pubkeys=[], + refund_pubkeys=[], + ) + + # Receiver should be able to derive the private key for slot 0 + derived_key = derive_blinded_private_key( + privkey=receiver_priv, + ephemeral_pubkey_hex=ephemeral_pub, + blinded_pubkey_hex=blinded_data, + slot_index=0, + ) + assert derived_key is not None + # The derived key's public key should match the blinded pubkey + assert derived_key.public_key + # The x-coordinates must match (BIP-340 schnorr uses x-only) + assert _pubkey_x(derived_key.public_key) == _pubkey_x( + PublicKey(bytes.fromhex(_compressed_pubkey(blinded_data))) + ) + + +def test_blind_pubkeys_with_additional_and_refund(): + """Blinding works with multiple pubkeys in different slots.""" + receiver_priv = PrivateKey() + assert receiver_priv.public_key + receiver_pub = receiver_priv.public_key.format(compressed=True).hex() + + # Additional pubkey (same receiver for simplicity) + add_pub = receiver_pub + refund_pub = receiver_pub + + blinded_data, blinded_add, blinded_refund, E = blind_pubkeys( + data_pubkey=receiver_pub, + additional_pubkeys=[add_pub], + refund_pubkeys=[refund_pub], + ) + + assert len(blinded_add) == 1 + assert len(blinded_refund) == 1 + + # All three slots should produce valid derived keys + for slot_idx, blinded_pk in enumerate( + [blinded_data] + blinded_add + blinded_refund + ): + derived = derive_blinded_private_key( + privkey=receiver_priv, + ephemeral_pubkey_hex=E, + blinded_pubkey_hex=blinded_pk, + slot_index=slot_idx, + ) + assert derived is not None, f"Failed to derive key for slot {slot_idx}" + + +def test_wrong_receiver_cannot_unblind(): + """A different private key cannot unblind.""" + receiver_priv = PrivateKey() + wrong_priv = PrivateKey() + assert receiver_priv.public_key + receiver_pub = receiver_priv.public_key.format(compressed=True).hex() + + blinded_data, _, _, E = blind_pubkeys( + data_pubkey=receiver_pub, + additional_pubkeys=[], + refund_pubkeys=[], + ) + + derived = derive_blinded_private_key( + privkey=wrong_priv, + ephemeral_pubkey_hex=E, + blinded_pubkey_hex=blinded_data, + slot_index=0, + ) + assert derived is None + + +def test_blinded_pubkey_differs_from_original(): + """Blinded pubkey P' != original pubkey P.""" + priv = PrivateKey() + assert priv.public_key + pub = priv.public_key.format(compressed=True).hex() + + blinded_data, _, _, _ = blind_pubkeys( + data_pubkey=pub, + additional_pubkeys=[], + refund_pubkeys=[], + ) + assert blinded_data.lower() != pub.lower() + + +def test_different_ephemeral_keys_produce_different_blinding(): + """Each fresh ephemeral key produces unique blinding.""" + priv = PrivateKey() + assert priv.public_key + pub = priv.public_key.format(compressed=True).hex() + + blinded1, _, _, E1 = blind_pubkeys( + data_pubkey=pub, + additional_pubkeys=[], + refund_pubkeys=[], + ) + blinded2, _, _, E2 = blind_pubkeys( + data_pubkey=pub, + additional_pubkeys=[], + refund_pubkeys=[], + ) + assert E1 != E2 # random ephemeral keys + assert blinded1 != blinded2 # different blinding + + +def test_derived_key_can_sign_and_verify(): + """Derived blinded private key can produce valid schnorr signatures.""" + receiver_priv = PrivateKey() + assert receiver_priv.public_key + receiver_pub = receiver_priv.public_key.format(compressed=True).hex() + + blinded_data, _, _, E = blind_pubkeys( + data_pubkey=receiver_pub, + additional_pubkeys=[], + refund_pubkeys=[], + ) + + derived_key = derive_blinded_private_key( + privkey=receiver_priv, + ephemeral_pubkey_hex=E, + blinded_pubkey_hex=blinded_data, + slot_index=0, + ) + assert derived_key is not None + + # Sign a message + message = b"test message" + sig = schnorr_sign(message, derived_key) + + # Verify against the blinded pubkey + from cashu.core.p2pk import verify_schnorr_signature + + blinded_pk = PublicKey(bytes.fromhex(_compressed_pubkey(blinded_data))) + assert verify_schnorr_signature(message, blinded_pk, sig) + + +def test_compressed_pubkey_x_only_nostr(): + """Simulating a Nostr-style 32-byte hex key getting 02 prefix.""" + # Use a real private key to derive an x-only pubkey (like Nostr npub) + priv = PrivateKey(secrets.token_bytes(32)) + x_only_hex = priv.public_key.format(compressed=True).hex()[2:] # strip 02/03 + assert len(x_only_hex) == 64 + result = _compressed_pubkey(x_only_hex) + assert result.startswith("02") + # Verify it's a valid point + PublicKey(bytes.fromhex(result)) + + +# ────────────────────────────────────────────── +# Integration tests: P2BK with wallet + mint +# ────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_p2bk_basic(wallet1: Wallet, wallet2: Wallet): + """Basic P2BK: sender blinds, receiver unblinds and redeems.""" + mint_quote = await wallet1.request_mint(64) + await pay_if_regtest(mint_quote.request) + await wallet1.mint(64, quote_id=mint_quote.quote) + + pubkey_wallet2 = await wallet2.create_p2pk_pubkey() + + # Sender creates a P2BK lock + secret_lock, ephemeral_pub = await wallet1.create_p2bk_lock(pubkey_wallet2) + + _, send_proofs = await wallet1.swap_to_send( + wallet1.proofs, 8, secret_lock=secret_lock, p2pk_e=ephemeral_pub + ) + + # Verify proofs have p2pk_e + for p in send_proofs: + assert p.p2pk_e == ephemeral_pub + + # Verify the secret contains blinded pubkeys (not the original) + for p in send_proofs: + secret = P2PKSecret.deserialize(p.secret) + assert secret.data.lower() != pubkey_wallet2.lower() + + # Receiver redeems (the wallet should unblind via p2pk_e) + await wallet2.redeem(send_proofs) + + +@pytest.mark.asyncio +async def test_p2bk_wrong_receiver(wallet1: Wallet, wallet2: Wallet): + """P2BK: wrong private key cannot redeem.""" + mint_quote = await wallet1.request_mint(64) + await pay_if_regtest(mint_quote.request) + await wallet1.mint(64, quote_id=mint_quote.quote) + + pubkey_wallet2 = await wallet2.create_p2pk_pubkey() + + secret_lock, ephemeral_pub = await wallet1.create_p2bk_lock(pubkey_wallet2) + _, send_proofs = await wallet1.swap_to_send( + wallet1.proofs, 8, secret_lock=secret_lock, p2pk_e=ephemeral_pub + ) + + # Set wrong private key on wallet2 + wallet2.private_key = PrivateKey() + await assert_err(wallet2.redeem(send_proofs), "") + + +@pytest.mark.asyncio +async def test_p2bk_sig_all(wallet1: Wallet, wallet2: Wallet): + """P2BK with SIG_ALL spending condition.""" + mint_quote = await wallet1.request_mint(64) + await pay_if_regtest(mint_quote.request) + await wallet1.mint(64, quote_id=mint_quote.quote) + + pubkey_wallet2 = await wallet2.create_p2pk_pubkey() + + secret_lock, ephemeral_pub = await wallet1.create_p2bk_lock( + pubkey_wallet2, sig_all=True + ) + _, send_proofs = await wallet1.swap_to_send( + wallet1.proofs, 8, secret_lock=secret_lock, p2pk_e=ephemeral_pub + ) + + # Verify SIG_ALL flag + for p in send_proofs: + secret = P2PKSecret.deserialize(p.secret) + p2pk = P2PKSecret.from_secret(secret) + assert p2pk.sigflag == SigFlags.SIG_ALL + + # All SIG_ALL proofs must carry the *same* ephemeral key + e_values = [p.p2pk_e for p in send_proofs] + assert all(e == ephemeral_pub for e in e_values), ( + f"Expected identical p2pk_e across SIG_ALL proofs, got {e_values}" + ) + + await wallet2.redeem(send_proofs) + + +@pytest.mark.asyncio +async def test_p2bk_mint_sees_normal_p2pk(wallet1: Wallet, wallet2: Wallet): + """The mint sees a standard P2PK secret, not P2BK metadata.""" + mint_quote = await wallet1.request_mint(64) + await pay_if_regtest(mint_quote.request) + await wallet1.mint(64, quote_id=mint_quote.quote) + + pubkey_wallet2 = await wallet2.create_p2pk_pubkey() + + secret_lock, ephemeral_pub = await wallet1.create_p2bk_lock(pubkey_wallet2) + _, send_proofs = await wallet1.swap_to_send( + wallet1.proofs, 8, secret_lock=secret_lock, p2pk_e=ephemeral_pub + ) + + # The secret kind is still P2PK + for p in send_proofs: + secret = Secret.deserialize(p.secret) + assert secret.kind == SecretKind.P2PK.value + + # p2pk_e is stripped during signing/sending to mint + # (sign_proofs_inplace_swap strips it) + proofs_copy = copy.deepcopy(send_proofs) + outputs = await _create_outputs(wallet2, 8) + signed = wallet2.sign_proofs_inplace_swap(proofs_copy, outputs) + for p in signed: + assert p.p2pk_e is None + + +@pytest.mark.asyncio +async def test_p2bk_unique_ephemeral_per_output(wallet1: Wallet, wallet2: Wallet): + """Each output gets a unique ephemeral keypair when not SIG_ALL.""" + mint_quote = await wallet1.request_mint(128) + await pay_if_regtest(mint_quote.request) + await wallet1.mint(128, quote_id=mint_quote.quote) + + pubkey_wallet2 = await wallet2.create_p2pk_pubkey() + + # Create two separate P2BK locks (each gets a fresh E) + lock1, E1 = await wallet1.create_p2bk_lock(pubkey_wallet2) + lock2, E2 = await wallet1.create_p2bk_lock(pubkey_wallet2) + assert E1 != E2 # unique ephemeral keys + + +@pytest.mark.asyncio +async def test_p2bk_token_v4_roundtrip(wallet1: Wallet, wallet2: Wallet): + """P2BK proofs survive Token V4 (CBOR) serialize/deserialize.""" + from cashu.core.base import TokenV4 + + mint_quote = await wallet1.request_mint(64) + await pay_if_regtest(mint_quote.request) + await wallet1.mint(64, quote_id=mint_quote.quote) + + pubkey_wallet2 = await wallet2.create_p2pk_pubkey() + secret_lock, ephemeral_pub = await wallet1.create_p2bk_lock(pubkey_wallet2) + _, send_proofs = await wallet1.swap_to_send( + wallet1.proofs, 8, secret_lock=secret_lock, p2pk_e=ephemeral_pub + ) + + # Serialize as Token V4 + token_str = await wallet1.serialize_proofs(send_proofs) + assert token_str.startswith("cashuB") or token_str.startswith("cashuA") + + # Deserialize + if token_str.startswith("cashuB"): + token = TokenV4.deserialize(token_str) + deserialized_proofs = token.proofs + else: + from cashu.core.base import TokenV3 + + token_v3 = TokenV3.deserialize(token_str) + deserialized_proofs = token_v3.proofs + + # p2pk_e should survive roundtrip + for p in deserialized_proofs: + assert p.p2pk_e == ephemeral_pub + + +@pytest.mark.asyncio +async def test_p2bk_proof_dict_roundtrip(wallet1: Wallet, wallet2: Wallet): + """p2pk_e survives Proof.to_dict / Proof.from_dict roundtrip.""" + mint_quote = await wallet1.request_mint(64) + await pay_if_regtest(mint_quote.request) + await wallet1.mint(64, quote_id=mint_quote.quote) + + pubkey_wallet2 = await wallet2.create_p2pk_pubkey() + secret_lock, ephemeral_pub = await wallet1.create_p2bk_lock(pubkey_wallet2) + _, send_proofs = await wallet1.swap_to_send( + wallet1.proofs, 8, secret_lock=secret_lock, p2pk_e=ephemeral_pub + ) + + for p in send_proofs: + d = p.to_dict() + assert "p2pk_e" in d + assert d["p2pk_e"] == ephemeral_pub + restored = Proof.from_dict(d) + assert restored.p2pk_e == ephemeral_pub + + +@pytest.mark.asyncio +async def test_v4_roundtrip_without_pe(wallet1: Wallet, wallet2: Wallet): + """A non-P2BK V4 token must roundtrip cleanly with pe absent.""" + from cashu.core.base import TokenV4 + + mint_quote = await wallet1.request_mint(64) + await pay_if_regtest(mint_quote.request) + await wallet1.mint(64, quote_id=mint_quote.quote) + + # Plain P2PK (no P2BK), so no pe field + pubkey_wallet2 = await wallet2.create_p2pk_pubkey() + secret_lock = await wallet1.create_p2pk_lock(pubkey_wallet2) + _, send_proofs = await wallet1.swap_to_send( + wallet1.proofs, 8, secret_lock=secret_lock + ) + + # Sanity: no p2pk_e on plain P2PK proofs + for p in send_proofs: + assert p.p2pk_e is None + + # Serialize → deserialize as V4 + token_str = await wallet1.serialize_proofs(send_proofs) + if token_str.startswith("cashuB"): + token = TokenV4.deserialize(token_str) + deserialized_proofs = token.proofs + else: + from cashu.core.base import TokenV3 + token_v3 = TokenV3.deserialize(token_str) + deserialized_proofs = token_v3.proofs + + # pe must remain absent — not an empty string, not zero-bytes + for p in deserialized_proofs: + assert p.p2pk_e is None + + +def test_p2bk_refund_key_unblind(): + """Sender's refund key can be unblinded independently of receiver's key. + + This verifies per-key ECDH: the sender uses their own private key + against the ephemeral public key E to derive the blinded refund + private key, without needing the receiver's secret. + """ + receiver_priv = PrivateKey() + sender_priv = PrivateKey() + assert receiver_priv.public_key and sender_priv.public_key + receiver_pub = receiver_priv.public_key.format(compressed=True).hex() + sender_pub = sender_priv.public_key.format(compressed=True).hex() + + # Blind with receiver as data, sender as refund + blinded_data, _, blinded_refund, E = blind_pubkeys( + data_pubkey=receiver_pub, + additional_pubkeys=[], + refund_pubkeys=[sender_pub], + ) + assert len(blinded_refund) == 1 + + # Receiver can unblind the data slot (slot 0) + receiver_derived = derive_blinded_private_key( + privkey=receiver_priv, + ephemeral_pubkey_hex=E, + blinded_pubkey_hex=blinded_data, + slot_index=0, + ) + assert receiver_derived is not None + + # Sender can unblind the refund slot (slot 1) + sender_derived = derive_blinded_private_key( + privkey=sender_priv, + ephemeral_pubkey_hex=E, + blinded_pubkey_hex=blinded_refund[0], + slot_index=1, + ) + assert sender_derived is not None + + # Cross-check: sender CANNOT unblind data slot, receiver CANNOT unblind refund slot + assert derive_blinded_private_key( + privkey=sender_priv, + ephemeral_pubkey_hex=E, + blinded_pubkey_hex=blinded_data, + slot_index=0, + ) is None + assert derive_blinded_private_key( + privkey=receiver_priv, + ephemeral_pubkey_hex=E, + blinded_pubkey_hex=blinded_refund[0], + slot_index=1, + ) is None + + # Both derived keys can sign and verify + msg = b"refund path works" + sig_r = schnorr_sign(msg, receiver_derived) + sig_s = schnorr_sign(msg, sender_derived) + + assert receiver_derived.public_key and sender_derived.public_key + assert verify_schnorr_signature(msg, receiver_derived.public_key, sig_r) + assert verify_schnorr_signature(msg, sender_derived.public_key, sig_s) + + +def test_p2bk_multisig_slot_independence(): + """Each receiver unblind only their own slot, not the other's.""" + receiver1_priv = PrivateKey() + receiver2_priv = PrivateKey() + assert receiver1_priv.public_key and receiver2_priv.public_key + + r1_pub = receiver1_priv.public_key.format(compressed=True).hex() + r2_pub = receiver2_priv.public_key.format(compressed=True).hex() + + blinded_data, blinded_add, _, E = blind_pubkeys( + data_pubkey=r1_pub, + additional_pubkeys=[r2_pub], + refund_pubkeys=[], + ) + + # receiver1 unblind slot 0 only + assert derive_blinded_private_key(receiver1_priv, E, blinded_data, 0) is not None + assert derive_blinded_private_key(receiver1_priv, E, blinded_add[0], 1) is None + + # receiver2 unblind slot 1 only + assert derive_blinded_private_key(receiver2_priv, E, blinded_add[0], 1) is not None + assert derive_blinded_private_key(receiver2_priv, E, blinded_data, 0) is None + + +def test_p2bk_tampered_ephemeral_pubkey_fails(): + """Tampered p2pk_e changes Zx, wrong scalars, signature fails.""" + receiver_priv = PrivateKey() + assert receiver_priv.public_key + receiver_pub = receiver_priv.public_key.format(compressed=True).hex() + + blinded_data, _, _, E = blind_pubkeys( + data_pubkey=receiver_pub, + additional_pubkeys=[], + refund_pubkeys=[], + ) + # Generate a different ephemeral pubkey (tampered) + tampered_E = PrivateKey().public_key.format(compressed=True).hex() + assert tampered_E != E + + derived = derive_blinded_private_key( + privkey=receiver_priv, + ephemeral_pubkey_hex=tampered_E, + blinded_pubkey_hex=blinded_data, + slot_index=0, + ) + assert derived is None # tampered E → wrong Zx → unblind fails + + +def test_p2bk_non_sigall_outputs_have_distinct_ephemeral_keys(): + """Two independent non-SIG_ALL outputs must carry distinct E values.""" + priv = PrivateKey() + assert priv.public_key + pub = priv.public_key.format(compressed=True).hex() + + _, _, _, E1 = blind_pubkeys( + data_pubkey=pub, + additional_pubkeys=[], + refund_pubkeys=[], + ) + _, _, _, E2 = blind_pubkeys( + data_pubkey=pub, + additional_pubkeys=[], + refund_pubkeys=[], + ) + assert E1 != E2 + + +@pytest.mark.asyncio +async def test_p2bk_htlc_inheritance(wallet1: Wallet, wallet2: Wallet): + """HTLC proof with P2BK: blinded pubkey, preimage spend works.""" + import hashlib as hl + + from cashu.core.base import HTLCWitness + from cashu.core.htlc import HTLCSecret + + mint_quote = await wallet1.request_mint(64) + await pay_if_regtest(mint_quote.request) + await wallet1.mint(64, quote_id=mint_quote.quote) + + pubkey_wallet2 = await wallet2.create_p2pk_pubkey() + preimage = "00" * 32 + preimage_hash = hl.sha256(bytes.fromhex(preimage)).hexdigest() + + # Blind wallet2's pubkey at slot 1 (HTLC: slot 0 = data = preimage_hash) + dummy_pub = PrivateKey().public_key.format(compressed=True).hex() + _, blinded_hashlock, _, ephemeral_pub = blind_pubkeys( + data_pubkey=dummy_pub, + additional_pubkeys=[pubkey_wallet2], + refund_pubkeys=[], + ) + + # Build HTLC secret with the P2BK-blinded hashlock pubkey + tags = Tags() + tags["pubkeys"] = [blinded_hashlock[0]] + htlc_secret = HTLCSecret( + kind=SecretKind.HTLC.value, + data=preimage_hash, + tags=tags, + ) + _, send_proofs = await wallet1.swap_to_send( + wallet1.proofs, 8, secret_lock=htlc_secret, p2pk_e=ephemeral_pub + ) + + # Verify wallet2 can derive the P2BK signing key for the HTLC proof + for p in send_proofs: + assert p.p2pk_e == ephemeral_pub + assert wallet2._derive_p2bk_signing_key(p) is not None + + # Set preimage on witness before redemption + for p in send_proofs: + p.witness = HTLCWitness(preimage=preimage).model_dump_json() + + # Redeem — sign_proofs_inplace_swap adds P2BK-derived signature alongside preimage + await wallet2.redeem(send_proofs) + + +async def _create_outputs(wallet: Wallet, amount: int) -> List[BlindedMessage]: + """Helper to create blinded outputs.""" + output_amounts = [amount] + secrets, rs, _ = await wallet.generate_n_secrets(len(output_amounts)) + outputs, _ = wallet._construct_outputs(output_amounts, secrets, rs) + return outputs