Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #950 +/- ##
===========================================
+ Coverage 61.51% 75.32% +13.80%
===========================================
Files 93 101 +8
Lines 11051 11868 +817
===========================================
+ Hits 6798 8939 +2141
+ Misses 4253 2929 -1324 ☔ View full report in Codecov by Sentry. |
There was a problem hiding this comment.
Pull request overview
Implements NUT-28 Pay-to-Blinded-Key (P2BK) support end-to-end by introducing core blinding/unblinding primitives, carrying the ephemeral pubkey through proofs/tokens, and updating the wallet to create P2BK locks and sign spends with derived blinded keys.
Changes:
- Add core P2BK cryptographic helpers (ECDH shared secret, scalar derivation, pubkey blinding, blinded key derivation).
- Extend Proof/TokenV4 serialization to carry P2BK ephemeral pubkey metadata (
p2pk_e/pe) and update wallet signing flow to strip it before mint calls. - Add wallet/CLI support for creating P2BK locks and integration/unit tests covering primitives + wallet redemption.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
cashu/core/p2bk.py |
New core primitives for blinding pubkeys and deriving blinded private keys. |
cashu/core/base.py |
Adds Proof.p2pk_e and TokenV4 pe encoding/decoding for P2BK metadata. |
cashu/wallet/p2bk.py |
New wallet mixin to create P2BK locks and derive P2BK signing keys. |
cashu/wallet/p2pk.py |
Integrates P2BK signing-key derivation into existing P2PK witness/signing flows and strips p2pk_e before mint calls. |
cashu/wallet/wallet.py |
Propagates p2pk_e through swap_to_send/split and attaches it to outgoing proofs. |
cashu/wallet/helpers.py |
Extends CLI send helper to accept P2BK: / P2BK-SIGALL: locks. |
tests/wallet/test_wallet_p2bk.py |
Adds unit + integration tests for P2BK primitives and wallet/token roundtrips. |
Comments suppressed due to low confidence (2)
cashu/wallet/wallet.py:676
splitgained thep2pk_eparameter but the docstring/Args section wasn’t updated to describe it. Please document whatp2pk_erepresents (ephemeral pubkey E), when it should be provided, and that it is attached only tosend_proofsfor P2BK.
async def split(
self,
proofs: List[Proof],
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.
If secret_lock is None, random secrets will be generated for the tokens to keep (keep_outputs)
and the promises to send (send_outputs). If secret_lock is provided, the wallet will create
blinded secrets with those to attach a predefined spending condition to the tokens they want to send.
Calls `sign_proofs_inplace_swap` which parses all proofs and checks whether their
secrets corresponds to any locks that we have the unlock conditions for. If so,
it adds the unlock conditions to the proofs.
Args:
proofs (List[Proof]): Proofs to be split.
amount (int): Amount to be sent.
secret_lock (Optional[Secret], optional): Secret to lock the tokens to be sent. Defaults to None.
include_fees (bool, optional): If True, the fees are included in the amount to send (output of
this method, to be sent in the future). This is not the fee that is required to swap the
`proofs` (input to this method) which must already be included. Defaults to False.
Returns:
Tuple[List[Proof], List[Proof]]: Two lists of proofs, one for keeping and one for sending.
cashu/wallet/wallet.py:1256
swap_to_sendnow acceptsp2pk_ebut the docstring/Args list doesn’t mention it. Please document that this is the NUT-28 ephemeral pubkey E to be attached to the resultingsend_proofs(and that it should only be used together with a P2BK-blindedsecret_lock).
async def swap_to_send(
self,
proofs: List[Proof],
amount: int,
*,
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
proofs are returned to be kept. All newly created proofs will be stored in the database but if `set_reserved` is set
to True, the proofs to be sent (which sum up to `amount`) will be marked as reserved so they aren't used in other
transactions.
Args:
proofs (List[Proof]): Proofs to split
amount (int): Amount to split to
secret_lock (Optional[str], optional): If set, a custom secret is used to lock new outputs. Defaults to None.
set_reserved (bool, optional): If set, the proofs are marked as reserved. Should be set to False if a payment attempt
is made with the split that could fail (like a Lightning payment). Should be set to True if the token to be sent is
displayed to the user to be then sent to someone else. Defaults to False.
include_fees (bool, optional): If set, the fees for spending the send_proofs later are included in the amount to be selected. Defaults to True.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Vulnerability: Permanent Loss of Funds in P2BK Refund Path The NUT-28 Pay-to-Blinded-Key (P2BK) implementation introduces a critical vulnerability where However, in Because the sender's refund pubkeys are blinded using the receiver's shared secret, the sender cannot unblind them later. When the sender attempts to refund an unclaimed token, This permanently locks the funds in the refund path. If the receiver does not claim the token, the sender's funds are burned permanently. Working Exploit PoC: import asyncio
from cashu.core.base import Proof
from cashu.core.crypto.secp import PrivateKey
from cashu.wallet.p2bk import WalletP2BK
from cashu.core.secret import Tags
async def main():
# Setup Sender Wallet
sender_wallet = WalletP2BK()
sender_wallet.private_key = PrivateKey()
sender_pub = sender_wallet.private_key.public_key.format(compressed=True).hex()
# Setup Receiver Wallet
receiver_wallet = WalletP2BK()
receiver_wallet.private_key = PrivateKey()
receiver_pub = receiver_wallet.private_key.public_key.format(compressed=True).hex()
# Sender creates a P2BK lock to send to the receiver, but adds a refund condition for themselves.
# This automatically happens in CLI via locktime_delta_seconds.
tags = Tags()
tags["refund"] = [sender_pub]
# Bug: create_p2bk_lock blinds all keys (including refund) using receiver_pub
secret_lock, ephemeral_pub = await sender_wallet.create_p2bk_lock(
data=receiver_pub,
tags=tags,
)
# Create a dummy proof with this lock representing the token in transit
proof = Proof(id="test", amount=1, secret=secret_lock.serialize(), C="test", p2pk_e=ephemeral_pub)
# The Receiver CAN unblind their main slot (slot 0) using their private key
receiver_unblinded = receiver_wallet._derive_p2bk_signing_key(proof)
print(f"Receiver unblind success (should be True): {receiver_unblinded is not None}")
# The Sender CANNOT unblind their own refund slot (slot 1) using their private key
sender_unblinded = sender_wallet._derive_p2bk_signing_key(proof)
print(f"Sender unblind refund success (should be False): {sender_unblinded is not None}")
if sender_unblinded is None:
print("VULNERABILITY CONFIRMED: Sender cannot refund their own P2BK token!")
asyncio.run(main()) |
…dening, negative test coverage
Closes #856
Summary
Implements NUT-28 (Pay-to-Blinded-Key) across Nutshell's core and wallet layers. P2BK extends NUT-11 (P2PK) spending conditions by ECDH-blinding each locking pubkey with an ECDH-derived scalar before the secret is written. The mint sees a standard P2PK secret, enforces standard BIP-340 signatures, and learns nothing about the real receiver pubkey.
What P2BK enables
Silent payments for Cashuas sender locks ecash to a receiver pubkey without that pubkey ever appearing at the mintPayment unlinkabilityas each proof carries a fresh ephemeral pubkey E, making proofs from the same sender to the same receiver cryptographically unlinkableTrue multi-party slotsas per-key ECDH means each pubkey in a multisig proof is independently blinded; slot owners cannot be linked or identified by positionChanges
p2pk_eon Proof,peon TokenV4Proof, V3/V4 serializationcreate_p2bk_lock,_derive_p2bk_signing_keywith HTLC-safe try/except