Skip to content

Commit

Permalink
wip: mint side working
Browse files Browse the repository at this point in the history
  • Loading branch information
callebtc committed Nov 15, 2024
1 parent 6a4f1bd commit 369a49e
Show file tree
Hide file tree
Showing 12 changed files with 86 additions and 23 deletions.
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
3 changes: 3 additions & 0 deletions cashu/core/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,7 @@ class MintQuote(LedgerEvent):
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,6 +437,7 @@ def from_row(cls, row: Row):
state=MintQuoteState(row["state"]),
created_time=created_time,
paid_time=paid_time,
pubkey=row["pubkey"],
)

@classmethod
Expand All @@ -458,6 +460,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
26 changes: 25 additions & 1 deletion cashu/core/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,28 @@ class QuoteNotPaidError(CashuError):
code = 20001

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


class QuoteRequiresPubkeyError(CashuError):
detail = "NUT-19 mint quote pubkey required"
code = 20009 # TODO: fix number

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


class QuoteInvalidSignatureError(CashuError):
detail = "NUT-19 mint quote invalid signature"
code = 20008

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


class QuoteNoSignatureError(CashuError):
detail = "NUT-19 no signature provided"
code = 20009

def __init__(self):
super().__init__(self.detail, code=self.code)
7 changes: 7 additions & 0 deletions cashu/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ 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):
Expand All @@ -137,6 +140,7 @@ class PostMintQuoteResponse(BaseModel):
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 +158,9 @@ class PostMintRequest(BaseModel):
outputs: List[BlindedMessage] = Field(
..., max_items=settings.mint_max_request_length
)
witness: Optional[str] = Field(
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
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
13 changes: 11 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,
QuoteInvalidSignatureError,
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,
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 +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 @@ -558,6 +564,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_nut19_mint_quote_witness(quote, witness, outputs):
raise QuoteInvalidSignatureError()

promises = await self._generate_promises(outputs)
except Exception as e:
await self.db_write._unset_mint_quote_pending(
Expand Down
7 changes: 7 additions & 0 deletions cashu/mint/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -838,3 +838,10 @@ 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_pubkey_to_mint_quotes(db: Database):
async with db.connect() as conn:
await conn.execute(
f"ALTER TABLE {db.table_with_schema('mint_quotes')} ADD COLUMN pubkey TEXT"
)
4 changes: 3 additions & 1 deletion cashu/mint/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,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: 14 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 Down Expand Up @@ -277,3 +278,16 @@ def _verify_and_get_unit_method(
)

return unit, method

def _verify_nut19_mint_quote_witness(
self, quote: MintQuote, witness: Union[str, None], outputs: List[BlindedMessage]
):
"""Verify that the witness is valid for the mint quote."""
if not quote.pubkey:
return True
if not witness:
return False
serialized_outputs = b"".join([o.B_.encode("utf-8") for o in outputs])
msgbytes = quote.quote.encode("utf-8") + serialized_outputs
pubkey = PublicKey(bytes.fromhex(quote.pubkey), raw=True)
return pubkey.schnorr_verify(msgbytes, bytes.fromhex(witness), None, raw=True)

0 comments on commit 369a49e

Please sign in to comment.