Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support NUT-XX (signatures on quotes) for mint and wallet side #670

Merged
merged 34 commits into from
Dec 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
01590b9
nut-19 sign mint quote
lollerfirst Nov 13, 2024
3b1109b
ephemeral key for quote
lollerfirst Nov 13, 2024
59dc56f
`mint` adjustments + crypto/nut19.py
lollerfirst Nov 14, 2024
369a49e
wip: mint side working
callebtc Nov 15, 2024
af616da
fix import
lollerfirst Nov 15, 2024
0e32c38
Merge remote-tracking branch 'upstream/nut-19-mint-quote-signature' i…
lollerfirst Nov 15, 2024
410d808
post-merge fixups
lollerfirst Nov 15, 2024
3005e2f
more fixes
lollerfirst Nov 15, 2024
432b39e
make format
lollerfirst Nov 15, 2024
219d878
move nut19 to nuts directory
lollerfirst Nov 15, 2024
c4ce689
`key` -> `privkey` and `pubkey`
lollerfirst Nov 15, 2024
c7e6080
make format
lollerfirst Nov 15, 2024
f0e9857
mint_info method for nut-19 support
lollerfirst Nov 17, 2024
b53251c
fix tests imports
lollerfirst Nov 17, 2024
ee43caf
fix signature missing positional argument + fix db migration format n…
lollerfirst Nov 17, 2024
1f3ab34
make format
lollerfirst Nov 20, 2024
3c6b125
fix `get_invoice_status`
lollerfirst Nov 20, 2024
434c460
rename to xx
lollerfirst Dec 4, 2024
0d1e69a
Merge branch 'main' into nut-19
callebtc Dec 4, 2024
10ab918
nutxx -> nut20
callebtc Dec 4, 2024
34aa46c
mypy
callebtc Dec 4, 2024
b43c01a
remove `mint_quote_signature_required` as per spec
lollerfirst Dec 7, 2024
6132866
wip edits
callebtc Dec 12, 2024
3c8609f
clean up
callebtc Dec 13, 2024
b0a5287
fix tests
callebtc Dec 13, 2024
b3a0201
fix deprecated api tests
callebtc Dec 13, 2024
bf53fb2
fix redis tests
callebtc Dec 13, 2024
85f9ab8
fix cache tests
callebtc Dec 13, 2024
807d243
fix regtest mint external
callebtc Dec 13, 2024
f1e470d
fix mint regtest
callebtc Dec 13, 2024
c0d8802
add test without signature
callebtc Dec 14, 2024
7be5f9f
test pubkeys in quotes
callebtc Dec 14, 2024
275a4f3
wip
callebtc Dec 14, 2024
668b071
add compat
callebtc Dec 14, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cashu/core/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,8 @@ class MintQuote(LedgerEvent):
paid_time: Union[int, None] = None
expiry: Optional[int] = None
mint: Optional[str] = None
privkey: Optional[str] = None
pubkey: Optional[str] = None

@classmethod
def from_row(cls, row: Row):
Expand All @@ -436,6 +438,8 @@ def from_row(cls, row: Row):
state=MintQuoteState(row["state"]),
created_time=created_time,
paid_time=paid_time,
pubkey=row["pubkey"] if "pubkey" in row.keys() else None,
privkey=row["privkey"] if "privkey" in row.keys() else None,
)

@classmethod
Expand All @@ -458,6 +462,7 @@ def from_resp_wallet(cls, mint_quote_resp, mint: str, amount: int, unit: str):
mint=mint,
expiry=mint_quote_resp.expiry,
created_time=int(time.time()),
pubkey=mint_quote_resp.pubkey,
)

@property
Expand Down
16 changes: 16 additions & 0 deletions cashu/core/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,19 @@ class QuoteNotPaidError(CashuError):

def __init__(self):
super().__init__(self.detail, code=2001)


class QuoteSignatureInvalidError(CashuError):
detail = "Signature for mint request invalid"
code = 20008

def __init__(self):
super().__init__(self.detail, code=20008)


class QuoteRequiresPubkeyError(CashuError):
detail = "Pubkey required for mint quote"
code = 20009

def __init__(self):
super().__init__(self.detail, code=20009)
15 changes: 11 additions & 4 deletions cashu/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,21 +128,25 @@ class PostMintQuoteRequest(BaseModel):
description: Optional[str] = Field(
default=None, max_length=settings.mint_max_request_length
) # invoice description
pubkey: Optional[str] = Field(
default=None, max_length=settings.mint_max_request_length
) # NUT-20 quote lock pubkey


class PostMintQuoteResponse(BaseModel):
quote: str # quote id
request: str # input payment request
paid: Optional[bool] # DEPRECATED as per NUT-04 PR #141
state: Optional[str] # state of the quote
state: Optional[str] # state of the quote (optional for backwards compat)
expiry: Optional[int] # expiry of the quote
pubkey: Optional[str] = None # NUT-20 quote lock pubkey
paid: Optional[bool] = None # DEPRECATED as per NUT-04 PR #141

@classmethod
def from_mint_quote(self, mint_quote: MintQuote) -> "PostMintQuoteResponse":
def from_mint_quote(cls, mint_quote: MintQuote) -> "PostMintQuoteResponse":
to_dict = mint_quote.dict()
# turn state into string
to_dict["state"] = mint_quote.state.value
return PostMintQuoteResponse.parse_obj(to_dict)
return cls.parse_obj(to_dict)


# ------- API: MINT -------
Expand All @@ -153,6 +157,9 @@ class PostMintRequest(BaseModel):
outputs: List[BlindedMessage] = Field(
..., max_items=settings.mint_max_request_length
)
signature: Optional[str] = Field(
default=None, max_length=settings.mint_max_request_length
) # NUT-20 quote signature


class PostMintResponse(BaseModel):
Expand Down
Empty file added cashu/core/nuts/__init__.py
Empty file.
41 changes: 41 additions & 0 deletions cashu/core/nuts/nut20.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from hashlib import sha256
from typing import List

from ..base import BlindedMessage
from ..crypto.secp import PrivateKey, PublicKey


def generate_keypair() -> tuple[str, str]:
privkey = PrivateKey()
assert privkey.pubkey
pubkey = privkey.pubkey
return privkey.serialize(), pubkey.serialize(True).hex()


def construct_message(quote_id: str, outputs: List[BlindedMessage]) -> bytes:
serialized_outputs = b"".join([o.B_.encode("utf-8") for o in outputs])
msgbytes = sha256(quote_id.encode("utf-8") + serialized_outputs).digest()
return msgbytes


def sign_mint_quote(
quote_id: str,
outputs: List[BlindedMessage],
private_key: str,
) -> str:
privkey = PrivateKey(bytes.fromhex(private_key), raw=True)
msgbytes = construct_message(quote_id, outputs)
sig = privkey.schnorr_sign(msgbytes, None, raw=True)
return sig.hex()


def verify_mint_quote(
quote_id: str,
outputs: List[BlindedMessage],
public_key: str,
signature: str,
) -> bool:
pubkey = PublicKey(bytes.fromhex(public_key), raw=True)
msgbytes = construct_message(quote_id, outputs)
sig = bytes.fromhex(signature)
return pubkey.schnorr_verify(msgbytes, sig, None, raw=True)
1 change: 1 addition & 0 deletions cashu/core/nuts.py → cashu/core/nuts/nuts.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@
MPP_NUT = 15
WEBSOCKETS_NUT = 17
CACHE_NUT = 19
MINT_QUOTE_SIGNATURE_NUT = 20
19 changes: 0 additions & 19 deletions cashu/core/p2pk.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,22 +50,3 @@ def verify_schnorr_signature(
return pubkey.schnorr_verify(
hashlib.sha256(message).digest(), signature, None, raw=True
)


if __name__ == "__main__":
# generate keys
private_key_bytes = b"12300000000000000000000000000123"
private_key = PrivateKey(private_key_bytes, raw=True)
print(private_key.serialize())
public_key = private_key.pubkey
assert public_key
print(public_key.serialize().hex())

# sign message (=pubkey)
message = public_key.serialize()
signature = private_key.ecdsa_serialize(private_key.ecdsa_sign(message))
print(signature.hex())

# verify
pubkey_verify = PublicKey(message, raw=True)
print(public_key.ecdsa_verify(message, pubkey_verify.ecdsa_deserialize(signature)))
5 changes: 3 additions & 2 deletions cashu/mint/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -421,8 +421,8 @@ async def store_mint_quote(
await (conn or db).execute(
f"""
INSERT INTO {db.table_with_schema('mint_quotes')}
(quote, method, request, checking_id, unit, amount, paid, issued, state, created_time, paid_time)
VALUES (:quote, :method, :request, :checking_id, :unit, :amount, :paid, :issued, :state, :created_time, :paid_time)
(quote, method, request, checking_id, unit, amount, paid, issued, state, created_time, paid_time, pubkey)
VALUES (:quote, :method, :request, :checking_id, :unit, :amount, :paid, :issued, :state, :created_time, :paid_time, :pubkey)
""",
{
"quote": quote.quote,
Expand All @@ -440,6 +440,7 @@ async def store_mint_quote(
"paid_time": db.to_timestamp(
db.timestamp_from_seconds(quote.paid_time) or ""
),
"pubkey": quote.pubkey or ""
},
)

Expand Down
4 changes: 3 additions & 1 deletion cashu/mint/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@
MeltMethodSetting,
MintMethodSetting,
)
from ..core.nuts import (
from ..core.nuts.nuts import (
CACHE_NUT,
DLEQ_NUT,
FEE_RETURN_NUT,
HTLC_NUT,
MELT_NUT,
MINT_NUT,
MINT_QUOTE_SIGNATURE_NUT,
MPP_NUT,
P2PK_NUT,
RESTORE_NUT,
Expand Down Expand Up @@ -75,6 +76,7 @@ def add_supported_features(
mint_features[P2PK_NUT] = supported_dict
mint_features[DLEQ_NUT] = supported_dict
mint_features[HTLC_NUT] = supported_dict
mint_features[MINT_QUOTE_SIGNATURE_NUT] = supported_dict
return mint_features

def add_mpp_features(
Expand Down
10 changes: 8 additions & 2 deletions cashu/mint/ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
LightningError,
NotAllowedError,
QuoteNotPaidError,
QuoteSignatureInvalidError,
TransactionError,
)
from ..core.helpers import sum_proofs
Expand Down Expand Up @@ -459,6 +460,7 @@ async def mint_quote(self, quote_request: PostMintQuoteRequest) -> MintQuote:
state=MintQuoteState.unpaid,
created_time=int(time.time()),
expiry=expiry,
pubkey=quote_request.pubkey,
)
await self.crud.store_mint_quote(quote=quote, db=self.db)
await self.events.submit(quote)
Expand Down Expand Up @@ -518,13 +520,14 @@ async def mint(
*,
outputs: List[BlindedMessage],
quote_id: str,
signature: Optional[str] = None,
) -> List[BlindedSignature]:
"""Mints new coins if quote with `quote_id` was paid. Ingest blind messages `outputs` and returns blind signatures `promises`.

Args:
outputs (List[BlindedMessage]): Outputs (blinded messages) to sign.
quote_id (str): Mint quote id.
keyset (Optional[MintKeyset], optional): Keyset to use. If not provided, uses active keyset. Defaults to None.
witness (Optional[str], optional): NUT-19 witness signature. Defaults to None.

Raises:
Exception: Validation of outputs failed.
Expand All @@ -536,7 +539,6 @@ async def mint(
Returns:
List[BlindedSignature]: Signatures on the outputs.
"""

await self._verify_outputs(outputs)
sum_amount_outputs = sum([b.amount for b in outputs])
# we already know from _verify_outputs that all outputs have the same unit because they have the same keyset
Expand All @@ -549,6 +551,7 @@ async def mint(
raise TransactionError("Mint quote already issued.")
if not quote.paid:
raise QuoteNotPaidError()

previous_state = quote.state
await self.db_write._set_mint_quote_pending(quote_id=quote_id)
try:
Expand All @@ -558,6 +561,9 @@ async def mint(
raise TransactionError("amount to mint does not match quote amount")
if quote.expiry and quote.expiry > int(time.time()):
raise TransactionError("quote expired")
if not self._verify_mint_quote_witness(quote, outputs, signature):
raise QuoteSignatureInvalidError()

promises = await self._generate_promises(outputs)
except Exception as e:
await self.db_write._unset_mint_quote_pending(
Expand Down
9 changes: 9 additions & 0 deletions cashu/mint/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -838,3 +838,12 @@ async def m022_quote_set_states_to_values(db: Database):
await conn.execute(
f"UPDATE {db.table_with_schema('mint_quotes')} SET state = '{mint_quote_states.value}' WHERE state = '{mint_quote_states.name}'"
)

async def m023_add_key_to_mint_quote_table(db: Database):
async with db.connect() as conn:
await conn.execute(
f"""
ALTER TABLE {db.table_with_schema('mint_quotes')}
ADD COLUMN pubkey TEXT DEFAULT NULL
"""
)
6 changes: 5 additions & 1 deletion cashu/mint/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ async def mint_quote(
paid=quote.paid, # deprecated
state=quote.state.value,
expiry=quote.expiry,
pubkey=quote.pubkey,
)
logger.trace(f"< POST /v1/mint/quote/bolt11: {resp}")
return resp
Expand All @@ -198,6 +199,7 @@ async def get_mint_quote(request: Request, quote: str) -> PostMintQuoteResponse:
paid=mint_quote.paid, # deprecated
state=mint_quote.state.value,
expiry=mint_quote.expiry,
pubkey=mint_quote.pubkey,
)
logger.trace(f"< GET /v1/mint/quote/bolt11/{quote}")
return resp
Expand Down Expand Up @@ -251,7 +253,9 @@ async def mint(
"""
logger.trace(f"> POST /v1/mint/bolt11: {payload}")

promises = await ledger.mint(outputs=payload.outputs, quote_id=payload.quote)
promises = await ledger.mint(
outputs=payload.outputs, quote_id=payload.quote, signature=payload.signature
)
blinded_signatures = PostMintResponse(signatures=promises)
logger.trace(f"< POST /v1/mint/bolt11: {blinded_signatures}")
return blinded_signatures
Expand Down
15 changes: 15 additions & 0 deletions cashu/mint/verification.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
BlindedSignature,
Method,
MintKeyset,
MintQuote,
Proof,
Unit,
)
Expand All @@ -20,6 +21,7 @@
TransactionError,
TransactionUnitError,
)
from ..core.nuts import nut20
from ..core.settings import settings
from ..lightning.base import LightningBackend
from ..mint.crud import LedgerCrud
Expand Down Expand Up @@ -277,3 +279,16 @@ def _verify_and_get_unit_method(
)

return unit, method

def _verify_mint_quote_witness(
self,
quote: MintQuote,
outputs: List[BlindedMessage],
signature: Optional[str],
) -> bool:
"""Verify signature on quote id and outputs"""
if not quote.pubkey:
return True
if not signature:
return False
return nut20.verify_mint_quote(quote.quote, outputs, quote.pubkey, signature)
25 changes: 19 additions & 6 deletions cashu/wallet/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,9 @@ def mint_invoice_callback(msg: JSONRPCNotficationParams):
try:
asyncio.run(
wallet.mint(
int(amount), split=optional_split, quote_id=mint_quote.quote
int(amount),
split=optional_split,
quote_id=mint_quote.quote,
)
)
# set paid so we won't react to any more callbacks
Expand Down Expand Up @@ -402,7 +404,9 @@ def mint_invoice_callback(msg: JSONRPCNotficationParams):
mint_quote_resp = await wallet.get_mint_quote(mint_quote.quote)
if mint_quote_resp.state == MintQuoteState.paid.value:
await wallet.mint(
amount, split=optional_split, quote_id=mint_quote.quote
amount,
split=optional_split,
quote_id=mint_quote.quote,
)
paid = True
else:
Expand All @@ -423,7 +427,14 @@ def mint_invoice_callback(msg: JSONRPCNotficationParams):

# user paid invoice before and wants to check the quote id
elif amount and id:
await wallet.mint(amount, split=optional_split, quote_id=id)
quote = await get_bolt11_mint_quote(wallet.db, quote=id)
if not quote:
raise Exception("Quote not found")
await wallet.mint(
amount,
split=optional_split,
quote_id=quote.quote,
)

# close open subscriptions so we can exit
try:
Expand Down Expand Up @@ -921,11 +932,13 @@ async def invoices(ctx, paid: bool, unpaid: bool, pending: bool, mint: bool):
print("No invoices found.")
return

async def _try_to_mint_pending_invoice(amount: int, id: str) -> Optional[MintQuote]:
async def _try_to_mint_pending_invoice(
amount: int, quote_id: str
) -> Optional[MintQuote]:
try:
proofs = await wallet.mint(amount, id)
proofs = await wallet.mint(amount, quote_id)
print(f"Received {wallet.unit.str(sum_proofs(proofs))}")
return await get_bolt11_mint_quote(db=wallet.db, quote=id)
return await get_bolt11_mint_quote(db=wallet.db, quote=quote_id)
except Exception as e:
logger.error(f"Could not mint pending invoice: {e}")
return None
Expand Down
Loading
Loading