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 9 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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,7 @@ LIGHTNING_RESERVE_FEE_MIN=2000
# MINT_GLOBAL_RATE_LIMIT_PER_MINUTE=60
# Determines the number of transactions (mint, melt, swap) allowed per minute per IP
# MINT_TRANSACTION_RATE_LIMIT_PER_MINUTE=20

# --------- MINT FEATURES ---------
# Require NUT-19 signature for mint quotes
MINT_QUOTE_SIGNATURE_REQUIRED=FALSE
6 changes: 5 additions & 1 deletion cashu/core/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -409,10 +409,12 @@ class MintQuote(LedgerEvent):
unit: str
amount: int
state: MintQuoteState
key: Union[str, None] = None
created_time: Union[int, None] = None
paid_time: Union[int, None] = None
expiry: Optional[int] = None
mint: Optional[str] = None
pubkey: Optional[str] = None

@classmethod
def from_row(cls, row: Row):
Expand All @@ -436,10 +438,11 @@ def from_row(cls, row: Row):
state=MintQuoteState(row["state"]),
created_time=created_time,
paid_time=paid_time,
key=row["key"],
)

@classmethod
def from_resp_wallet(cls, mint_quote_resp, mint: str, amount: int, unit: str):
def from_resp_wallet(cls, mint_quote_resp, mint: str, amount: int, unit: str, key: Optional[str] = None):
# BEGIN: BACKWARDS COMPATIBILITY < 0.16.0: "paid" field to "state"
if mint_quote_resp.state is None:
if mint_quote_resp.paid is True:
Expand All @@ -458,6 +461,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()),
key=key,
)

@property
Expand Down
33 changes: 33 additions & 0 deletions cashu/core/crypto/nut19.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from hashlib import sha256
from typing import List

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


def construct_message(quote_id: str, outputs: List[BlindedMessage]) -> bytes:
serialized_outputs = bytes.fromhex("".join([o.B_ 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],
privkey: PrivateKey,
) -> str:
msgbytes = construct_message(quote_id, outputs)
sig = privkey.schnorr_sign(msgbytes)
return sig.hex()

def verify_mint_quote(
quote_id: str,
outputs: List[BlindedMessage],
pubkey: PublicKey,
signature: str,
) -> bool:
msgbytes = construct_message(quote_id, outputs)
sig = bytes.fromhex(signature)
return pubkey.schnorr_verify(msgbytes, sig)
14 changes: 14 additions & 0 deletions cashu/core/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,17 @@ class QuoteNotPaidError(CashuError):

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

class QuoteInvalidWitnessError(CashuError):
detail = "Witness on mint request not provided or invalid"
code = 20008

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

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

def __init__(self):
super().__init__(self.detail, code=20009)
8 changes: 7 additions & 1 deletion cashu/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,14 +129,17 @@ 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
) # 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
expiry: Optional[int] # expiry of the quote
pubkey: Optional[str] # quote lock pubkey

@classmethod
def from_mint_quote(self, mint_quote: MintQuote) -> "PostMintQuoteResponse":
Expand All @@ -154,6 +157,9 @@ class PostMintRequest(BaseModel):
outputs: List[BlindedMessage] = Field(
..., max_items=settings.mint_max_request_length
)
witness: Optional[str] = Field(
default=None, max_length=settings.mint_max_request_length
) # witness signature


class PostMintResponse(BaseModel):
Expand Down
1 change: 1 addition & 0 deletions cashu/core/nuts.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@
HTLC_NUT = 14
MPP_NUT = 15
WEBSOCKETS_NUT = 17
MINT_QUOTE_SIGNATURE_NUT = 19 # TODO: change to actual number
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)))
1 change: 1 addition & 0 deletions cashu/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ class MintSettings(CashuSettings):

mint_input_fee_ppk: int = Field(default=0)
mint_disable_melt_on_error: bool = Field(default=False)
mint_quote_signature_required: bool = Field(default=False)


class MintDeprecationFlags(MintSettings):
Expand Down
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, key)
VALUES (:quote, :method, :request, :checking_id, :unit, :amount, :paid, :issued, :state, :created_time, :paid_time, :key)
""",
{
"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 ""
),
"key": quote.key or ""
},
)

Expand Down
10 changes: 10 additions & 0 deletions cashu/mint/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
HTLC_NUT,
MELT_NUT,
MINT_NUT,
MINT_QUOTE_SIGNATURE_NUT,
MPP_NUT,
P2PK_NUT,
RESTORE_NUT,
Expand Down Expand Up @@ -43,6 +44,7 @@ def mint_features(self) -> Dict[int, Union[List[Any], Dict[str, Any]]]:
melt_method_settings.append(melt_setting)

supported_dict = dict(supported=True)
required_dict = dict(supported=True, required=True)

mint_features: Dict[int, Union[List[Any], Dict[str, Any]]] = {
MINT_NUT: dict(
Expand All @@ -60,8 +62,10 @@ def mint_features(self) -> Dict[int, Union[List[Any], Dict[str, Any]]]:
P2PK_NUT: supported_dict,
DLEQ_NUT: supported_dict,
HTLC_NUT: supported_dict,
MINT_QUOTE_SIGNATURE_NUT: supported_dict,
}

# MPP_NUT
# signal which method-unit pairs support MPP
mpp_features = []
for method, unit_dict in self.backends.items():
Expand All @@ -78,6 +82,7 @@ def mint_features(self) -> Dict[int, Union[List[Any], Dict[str, Any]]]:
if mpp_features:
mint_features[MPP_NUT] = mpp_features

# WEBSOCKETS_NUT
# specify which websocket features are supported
# these two are supported by default
websocket_features: Dict[str, List[Dict[str, Union[str, List[str]]]]] = {
Expand Down Expand Up @@ -105,4 +110,9 @@ def mint_features(self) -> Dict[int, Union[List[Any], Dict[str, Any]]]:
if websocket_features:
mint_features[WEBSOCKETS_NUT] = websocket_features

# MINT_QUOTE_SIGNATURE_NUT
# add "required" field to mint quote signature nut
if settings.mint_quote_signature_required:
mint_features[MINT_QUOTE_SIGNATURE_NUT] = required_dict

return mint_features
14 changes: 12 additions & 2 deletions cashu/mint/ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@
KeysetNotFoundError,
LightningError,
NotAllowedError,
QuoteInvalidWitnessError,
QuoteNotPaidError,
QuoteRequiresPubkeyError,
TransactionError,
)
from ..core.helpers import sum_proofs
Expand Down Expand Up @@ -423,6 +425,9 @@ async def mint_quote(self, quote_request: PostMintQuoteRequest) -> MintQuote:
if balance + quote_request.amount > settings.mint_max_balance:
raise NotAllowedError("Mint has reached maximum balance.")

if settings.mint_quote_signature_required and not quote_request.pubkey:
raise QuoteRequiresPubkeyError()

logger.trace(f"requesting invoice for {unit.str(quote_request.amount)}")
invoice_response: InvoiceResponse = await self.backends[method][
unit
Expand Down Expand Up @@ -459,6 +464,7 @@ async def mint_quote(self, quote_request: PostMintQuoteRequest) -> MintQuote:
state=MintQuoteState.unpaid,
created_time=int(time.time()),
expiry=expiry,
key=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 +524,14 @@ async def mint(
*,
outputs: List[BlindedMessage],
quote_id: str,
witness: 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 +543,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 +555,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 +565,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, witness):
raise QuoteInvalidWitnessError()

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 key 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 @@ -172,6 +172,7 @@ async def mint_quote(
paid=quote.paid, # deprecated
state=quote.state.value,
expiry=quote.expiry,
pubkey=quote.key,
)
logger.trace(f"< POST /v1/mint/quote/bolt11: {resp}")
return resp
Expand All @@ -196,6 +197,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.key,
)
logger.trace(f"< GET /v1/mint/quote/bolt11/{quote}")
return resp
Expand Down Expand Up @@ -242,7 +244,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, witness=payload.witness
)
blinded_signatures = PostMintResponse(signatures=promises)
logger.trace(f"< POST /v1/mint/bolt11: {blinded_signatures}")
return blinded_signatures
Expand Down
14 changes: 13 additions & 1 deletion cashu/mint/verification.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@
BlindedSignature,
Method,
MintKeyset,
MintQuote,
Proof,
Unit,
)
from ..core.crypto import b_dhke
from ..core.crypto import b_dhke, nut19
from ..core.crypto.secp import PublicKey
from ..core.db import Connection, Database
from ..core.errors import (
Expand Down Expand Up @@ -277,3 +278,14 @@ def _verify_and_get_unit_method(
)

return unit, method

def _verify_mint_quote_witness(
self, quote: MintQuote, outputs: List[BlindedMessage], witness: Optional[str],
) -> bool:
"""Verify signature on quote id and outputs"""
if not quote.key:
return True
if not witness:
return False
pubkey = PublicKey(bytes.fromhex(quote.key), raw=True)
return nut19.verify_mint_quote(quote.quote, outputs, pubkey, witness)
2 changes: 1 addition & 1 deletion cashu/wallet/api/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ async def swap(
)

# mint token in incoming mint
await incoming_wallet.mint(amount, quote_id=mint_quote.quote)
await incoming_wallet.mint(amount, quote_id=mint_quote.quote, quote_key=mint_quote.key)
await incoming_wallet.load_proofs(reload=True)
mint_balances = await incoming_wallet.balance_per_minturl()
return SwapResponse(
Expand Down
Loading
Loading