Skip to content

Commit

Permalink
mint adjustments + crypto/nut19.py
Browse files Browse the repository at this point in the history
  • Loading branch information
lollerfirst committed Nov 14, 2024
1 parent 3b1109b commit 59dc56f
Show file tree
Hide file tree
Showing 9 changed files with 78 additions and 37 deletions.
34 changes: 34 additions & 0 deletions cashu/core/crypto/nut19.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from hashlib import sha256
from typing import List

from secp import PrivateKey, PublicKey

from ..base import BlindedMessage


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)
8 changes: 4 additions & 4 deletions cashu/core/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,15 +97,15 @@ class QuoteNotPaidError(CashuError):
def __init__(self):
super().__init__(self.detail, code=2001)

class QuoteWitnessNotProvidedError(CashuError):
detail = "Witness not provided for mint quote"
class QuoteInvalidWitnessError(CashuError):
detail = "Witness is invalid for mint quote"
code = 20008

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

class QuoteInvalidWitnessError(CashuError):
detail = "Witness is invalid for mint quote"
class QuoteWitnessNotProvidedError(CashuError):
detail = "Witness not provided for mint quote"
code = 20009

def __init__(self):
Expand Down
2 changes: 1 addition & 1 deletion cashu/mint/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@
MINT_NUT,
MPP_NUT,
P2PK_NUT,
QUOTE_SIGNATURE_NUT,
RESTORE_NUT,
SCRIPT_NUT,
STATE_NUT,
WEBSOCKETS_NUT,
QUOTE_SIGNATURE_NUT,
)
from ..core.settings import settings
from ..mint.protocols import SupportsBackends
Expand Down
2 changes: 1 addition & 1 deletion cashu/mint/ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@
LightningError,
NotAllowedError,
QuoteNotPaidError,
QuoteWitnessNotProvidedError,
TransactionError,
QuoteWitnessNotProvidedError
)
from ..core.helpers import sum_proofs
from ..core.models import (
Expand Down
12 changes: 4 additions & 8 deletions cashu/mint/verification.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,20 @@
BlindedSignature,
Method,
MintKeyset,
MintQuote,
Proof,
Unit,
MintQuote,
)
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 (
NoSecretInProofsError,
NotAllowedError,
QuoteInvalidWitnessError,
SecretTooLongError,
TransactionError,
TransactionUnitError,
QuoteInvalidWitnessError,
)
from ..core.settings import settings
from ..lightning.base import LightningBackend
Expand Down Expand Up @@ -285,9 +285,5 @@ def _verify_quote_signature(
) -> None:
"""Verify signature on quote id and outputs"""
pubkey = PublicKey(bytes.fromhex(quote.key), raw=True) # type: ignore
sigbytes = bytes.fromhex(signature)
serialized_outputs = b"".join([o.json().encode("utf-8") for o in outputs])
msgbytes = quote.quote.encode("utf-8") + serialized_outputs

if not pubkey.schnorr_verify(msgbytes, sigbytes, raw=True):
if not nut19.verify_mint_quote(quote.quote, outputs, pubkey, signature):
raise QuoteInvalidWitnessError()
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
9 changes: 6 additions & 3 deletions cashu/wallet/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ 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, quote_key=mint_quote.key
)
)
# set paid so we won't react to any more callbacks
Expand Down Expand Up @@ -402,7 +402,7 @@ 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, quote_key=mint_quote.key
)
paid = True
else:
Expand All @@ -423,7 +423,10 @@ 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, quote_key=quote.key)

# close open subscriptions so we can exit
try:
Expand Down
10 changes: 7 additions & 3 deletions cashu/wallet/v1_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -333,30 +333,34 @@ async def get_mint_quote(self, quote: str) -> PostMintQuoteResponse:
@async_set_httpx_client
@async_ensure_mint_loaded
async def mint(
self, outputs: List[BlindedMessage], quote: str
self, outputs: List[BlindedMessage], quote: str, witness: Optional[str] = None
) -> List[BlindedSignature]:
"""Mints new coins and returns a proof of promise.
Args:
outputs (List[BlindedMessage]): Outputs to mint new tokens with
quote (str): Quote ID.
witness (Optional[str], optional): NUT-19 signature of the request.
Returns:
list[Proof]: List of proofs.
Raises:
Exception: If the minting fails
"""
outputs_payload = PostMintRequest(outputs=outputs, quote=quote)
outputs_payload = PostMintRequest(outputs=outputs, quote=quote, witness=witness)
logger.trace("Checking Lightning invoice. POST /v1/mint/bolt11")

def _mintrequest_include_fields(outputs: List[BlindedMessage]):
"""strips away fields from the model that aren't necessary for the /mint"""
outputs_include = {"id", "amount", "B_"}
return {
res = {
"quote": ...,
"outputs": {i: outputs_include for i in range(len(outputs))},
}
if witness:
res["witness"] = ...
return res

payload = outputs_payload.dict(include=_mintrequest_include_fields(outputs)) # type: ignore
resp = await self.httpx.post(
Expand Down
36 changes: 20 additions & 16 deletions cashu/wallet/wallet.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
Unit,
WalletKeyset,
)
from ..core.crypto import b_dhke
from ..core.crypto import b_dhke, nut19
from ..core.crypto.keys import derive_keyset_id
from ..core.crypto.secp import PrivateKey, PublicKey
from ..core.db import Database
Expand All @@ -30,13 +30,13 @@
sum_promises,
sum_proofs,
)
from ..core.nuts import QUOTE_SIGNATURE_NUT
from ..core.json_rpc.base import JSONRPCSubscriptionKinds
from ..core.migrations import migrate_databases
from ..core.models import (
PostCheckStateResponse,
PostMeltQuoteResponse,
)
from ..core.nuts import QUOTE_SIGNATURE_NUT
from ..core.p2pk import Secret
from ..core.settings import settings
from ..core.split import amount_split
Expand Down Expand Up @@ -399,8 +399,7 @@ async def request_mint_with_callback(
Returns:
MintQuote: Mint Quote
"""
privkey = await self.get_quote_ephemeral_key()
pubkey = (privkey.pubkey.serialize(True).hex() if privkey else None)
privkey, pubkey = await self.get_quote_ephemeral_keypair()
mint_qoute = await super().mint_quote(amount, self.unit, memo, pubkey)
subscriptions = SubscriptionManager(self.url)
threading.Thread(
Expand All @@ -411,9 +410,7 @@ async def request_mint_with_callback(
filters=[mint_qoute.quote],
callback=callback,
)
quote = MintQuote.from_resp_wallet(mint_qoute, self.url, amount, self.unit.name,
privkey.serialize() if privkey else None
)
quote = MintQuote.from_resp_wallet(mint_qoute, self.url, amount, self.unit.name, privkey)
await store_bolt11_mint_quote(db=self.db, quote=quote)

return quote, subscriptions
Expand All @@ -432,28 +429,28 @@ async def request_mint(self,
Returns:
MintQuote: Mint Quote
"""
privkey = await self.get_quote_ephemeral_key()
pubkey = (privkey.pubkey.serialize(True).hex() if privkey else None)
privkey, pubkey = await self.get_quote_ephemeral_keypair()
mint_quote_response = await super().mint_quote(amount, self.unit, memo, pubkey)
quote = MintQuote.from_resp_wallet(
mint_quote_response, self.url, amount, self.unit.name,
privkey.serialize() if privkey else None,
mint_quote_response, self.url, amount, self.unit.name, privkey,
)
await store_bolt11_mint_quote(db=self.db, quote=quote)
return quote

# TODO: generate secret with BIP39 (seed and specific derivation + counter)
async def get_quote_ephemeral_key(self) -> Union[PrivateKey, None]:
"""Creates a secret key for a quote
async def get_quote_ephemeral_keypair(self) -> Tuple[Optional[str], Optional[str]]:
"""Creates a keypair for a quote IF the mint supports NUT-19
"""
if not self.mint_info:
await self.load_mint_info()
assert self.mint_info.nuts
nut19 = self.mint_info.nuts.get(QUOTE_SIGNATURE_NUT, None)
if nut19 and nut19["supported"]:
return PrivateKey()
privkey = PrivateKey()
pubkey = privkey.pubkey.serialize(True).hex()
return privkey.serialize(), pubkey
else:
return None
return None, None

def split_wallet_state(self, amount: int) -> List[int]:
"""This function produces an amount split for outputs based on the current state of the wallet.
Expand Down Expand Up @@ -505,13 +502,15 @@ async def mint(
amount: int,
quote_id: str,
split: Optional[List[int]] = None,
quote_key: Optional[str] = None,
) -> List[Proof]:
"""Mint tokens of a specific amount after an invoice has been paid.
Args:
amount (int): Total amount of tokens to be minted
id (str): Id for looking up the paid Lightning invoice.
split (Optional[List[str]], optional): List of desired amount splits to be minted. Total must sum to `amount`.
quote_key (Optional[str], optional): NUT-19 quote key for signing the request.
Raises:
Exception: Raises exception if `amounts` does not sum to `amount` or has unsupported value.
Expand Down Expand Up @@ -545,8 +544,13 @@ async def mint(
await self._check_used_secrets(secrets)
outputs, rs = self._construct_outputs(amounts, secrets, rs)

witness: Optional[str] = None
if quote_key:
privkey = PrivateKey(bytes.fromhex(quote_key), raw=True)
witness = nut19.sign_mint_quote(quote_id, outputs, privkey)

# will raise exception if mint is unsuccessful
promises = await super().mint(outputs, quote_id)
promises = await super().mint(outputs, quote_id, witness)

promises_keyset_id = promises[0].id
await bump_secret_derivation(
Expand Down

0 comments on commit 59dc56f

Please sign in to comment.