import base64 import copy import threading import time from typing import Callable, Dict, List, Optional, Tuple, Union from bip32 import BIP32 from loguru import logger from ..core.base import ( BlindedMessage, BlindedSignature, DLEQWallet, MeltQuote, MeltQuoteState, MintQuote, MintQuoteState, Proof, Unit, WalletKeyset, ) from ..core.crypto import b_dhke from ..core.crypto.keys import derive_keyset_id from ..core.crypto.secp import PrivateKey, PublicKey from ..core.db import Database from ..core.errors import KeysetNotFoundError from ..core.helpers import ( amount_summary, calculate_number_of_blank_outputs, sum_promises, sum_proofs, ) from ..core.json_rpc.base import JSONRPCSubscriptionKinds from ..core.migrations import migrate_databases from ..core.models import ( PostCheckStateResponse, PostMeltQuoteResponse, ) from ..core.p2pk import Secret from ..core.settings import settings from ..core.split import amount_split from . import migrations from .crud import ( bump_secret_derivation, get_keysets, get_proofs, invalidate_proof, secret_used, set_secret_derivation, store_bolt11_melt_quote, store_bolt11_mint_quote, store_keyset, store_proof, update_bolt11_melt_quote, update_bolt11_mint_quote, update_keyset, update_proof, ) from .htlc import WalletHTLC from .mint_info import MintInfo from .p2pk import WalletP2PK from .proofs import WalletProofs from .secrets import WalletSecrets from .subscriptions import SubscriptionManager from .transactions import WalletTransactions from .utils import sanitize_url from .v1_api import LedgerAPI class Wallet( LedgerAPI, WalletP2PK, WalletHTLC, WalletSecrets, WalletTransactions, WalletProofs ): """ Nutshell wallet class. This class is the main interface to the Nutshell wallet. It is a subclass of the LedgerAPI class, which provides the API methods to interact with the mint. To use `Wallet`, initialize it with the mint URL and the path to the database directory. Initialize the wallet with `Wallet.with_db(url, db)`. This will load the private key and all keysets from the database. Use `load_proofs` to load all proofs of the selected mint and unit from the database. Use `load_mint` to load the public keys of the mint and fetch those that we don't have. This will also load the mint info. Use `mint_quote` to request a Lightning invoice for minting tokens. Use `mint` to mint tokens of a specific amount after an invoice has been paid. Use `melt_quote` to fetch a quote for paying a Lightning invoice. Use `melt` to pay a Lightning invoice. """ keyset_id: str # holds current keyset id keysets: Dict[str, WalletKeyset] = {} # holds keysets # mint_keyset_ids: List[str] # holds active keyset ids of the mint unit: Unit mint_info: MintInfo # holds info about mint mnemonic: str # holds mnemonic of the wallet seed: bytes # holds private key of the wallet generated from the mnemonic db: Database bip32: BIP32 # private_key: Optional[PrivateKey] = None def __init__(self, url: str, db: str, name: str = "wallet", unit: str = "sat"): """A Cashu wallet. Args: url (str): URL of the mint. db (str): Path to the database directory. name (str, optional): Name of the wallet database file. Defaults to "wallet". """ self.db = Database("wallet", db) self.proofs: List[Proof] = [] self.name = name self.unit = Unit[unit] url = sanitize_url(url) super().__init__(url=url, db=self.db) logger.debug("Wallet initialized") logger.debug(f"Mint URL: {url}") logger.debug(f"Database: {db}") logger.debug(f"Unit: {self.unit.name}") @classmethod async def with_db( cls, url: str, db: str, name: str = "wallet", skip_db_read: bool = False, unit: str = "sat", load_all_keysets: bool = False, ): """Initializes a wallet with a database and initializes the private key. Args: url (str): URL of the mint. db (str): Path to the database. name (str, optional): Name of the wallet. Defaults to "wallet". skip_db_read (bool, optional): If true, values from db like private key and keysets are not loaded. Useful for running only migrations and returning. Defaults to False. unit (str, optional): Unit of the wallet. Defaults to "sat". load_all_keysets (bool, optional): If true, all keysets are loaded from the database. Defaults to False. Returns: Wallet: Initialized wallet. """ logger.trace(f"Initializing wallet with database: {db}") self = cls(url=url, db=db, name=name, unit=unit) await self._migrate_database() if skip_db_read: return self logger.trace("Mint init: loading private key and keysets from db.") await self._init_private_key() keysets_list = await get_keysets( mint_url=url if not load_all_keysets else None, db=self.db ) if not load_all_keysets: keysets_active_unit = [k for k in keysets_list if k.unit == self.unit] self.keysets = {k.id: k for k in keysets_active_unit} else: self.keysets = {k.id: k for k in keysets_list} keysets_str = " ".join([f"{i} {k.unit}" for i, k in self.keysets.items()]) logger.debug(f"Loaded keysets: {keysets_str}") return self async def _migrate_database(self): try: await migrate_databases(self.db, migrations) except Exception as e: logger.error(f"Could not run migrations: {e}") raise e # ---------- API ---------- async def load_mint_info(self) -> MintInfo: """Loads the mint info from the mint.""" mint_info_resp = await self._get_info() self.mint_info = MintInfo(**mint_info_resp.dict()) logger.debug(f"Mint info: {self.mint_info}") return self.mint_info async def load_mint_keysets(self, force_old_keysets=False): """Loads all keyset of the mint and makes sure we have them all in the database. Then loads all keysets from the database for the active mint and active unit into self.keysets. """ logger.trace("Loading mint keysets.") mint_keysets_resp = await self._get_keysets() mint_keysets_dict = {k.id: k for k in mint_keysets_resp} # load all keysets of thisd mint from the db keysets_in_db = await get_keysets(mint_url=self.url, db=self.db) # db is empty, get all keys from the mint and store them if not keysets_in_db: all_keysets = await self._get_keys() for keyset in all_keysets: keyset.active = mint_keysets_dict[keyset.id].active keyset.input_fee_ppk = mint_keysets_dict[keyset.id].input_fee_ppk or 0 await store_keyset(keyset=keyset, db=self.db) keysets_in_db = await get_keysets(mint_url=self.url, db=self.db) keysets_in_db_dict = {k.id: k for k in keysets_in_db} # get all new keysets that are not in memory yet and store them in the database for mint_keyset in mint_keysets_dict.values(): if mint_keyset.id not in keysets_in_db_dict: logger.debug( f"Storing new mint keyset: {mint_keyset.id} ({mint_keyset.unit})" ) wallet_keyset = await self._get_keyset(mint_keyset.id) wallet_keyset.active = mint_keyset.active wallet_keyset.input_fee_ppk = mint_keyset.input_fee_ppk or 0 await store_keyset(keyset=wallet_keyset, db=self.db) for mint_keyset in mint_keysets_dict.values(): # if the active flag changes from active to inactive # or the fee attributes have changed, update them in the database if mint_keyset.id in keysets_in_db_dict: changed = False if ( not mint_keyset.active and mint_keyset.active != keysets_in_db_dict[mint_keyset.id].active ): keysets_in_db_dict[mint_keyset.id].active = mint_keyset.active changed = True if ( mint_keyset.input_fee_ppk and mint_keyset.input_fee_ppk != keysets_in_db_dict[mint_keyset.id].input_fee_ppk ): keysets_in_db_dict[ mint_keyset.id ].input_fee_ppk = mint_keyset.input_fee_ppk changed = True if changed: await update_keyset( keyset=keysets_in_db_dict[mint_keyset.id], db=self.db ) await self.inactivate_base64_keysets(force_old_keysets) await self.load_keysets_from_db() async def inactivate_base64_keysets(self, force_old_keysets: bool) -> None: # BEGIN backwards compatibility: phase out keysets with base64 ID by treating them as inactive if settings.wallet_inactivate_base64_keysets and not force_old_keysets: keysets_in_db = await get_keysets(mint_url=self.url, db=self.db) for keyset in keysets_in_db: if not keyset.active: continue # test if the keyset id is a hex string, if not it's base64 try: int(keyset.id, 16) except ValueError: # verify that it's base64 try: _ = base64.b64decode(keyset.id) except ValueError: logger.error("Unexpected: keyset id is neither hex nor base64.") continue # verify that we have a hex version of the same keyset by comparing public keys hex_keyset_id = derive_keyset_id(keys=keyset.public_keys) if hex_keyset_id not in [k.id for k in keysets_in_db]: logger.warning( f"Keyset {keyset.id} is base64 but we don't have a hex version. Ignoring." ) continue logger.warning( f"Keyset {keyset.id} is base64 and has a hex counterpart, setting inactive." ) keyset.active = False await update_keyset(keyset=keyset, db=self.db) # END backwards compatibility async def activate_keyset(self, keyset_id: Optional[str] = None) -> None: """Activates a keyset by setting self.keyset_id. Either activates a specific keyset of chooses one of the active keysets of the mint with the same unit as the wallet. """ if keyset_id: if keyset_id not in self.keysets: await self.load_mint_keysets() if keyset_id not in self.keysets: raise KeysetNotFoundError(keyset_id) if self.keysets[keyset_id].unit != self.unit: raise Exception( f"Keyset {keyset_id} has unit {self.keysets[keyset_id].unit.name}," f" but wallet has unit {self.unit.name}." ) if not self.keysets[keyset_id].active: raise Exception(f"Keyset {keyset_id} is not active.") self.keyset_id = keyset_id else: # if no keyset_id is given, choose an active keyset with the same unit as the wallet chosen_keyset = None for keyset in self.keysets.values(): if keyset.unit == self.unit and keyset.active: chosen_keyset = keyset break if not chosen_keyset: raise Exception(f"No active keyset found for unit {self.unit.name}.") self.keyset_id = chosen_keyset.id logger.debug( f"Activated keyset {self.keyset_id} ({self.keysets[self.keyset_id].unit}) fee: {self.keysets[self.keyset_id].input_fee_ppk}" ) async def load_mint(self, keyset_id: str = "", force_old_keysets=False) -> None: """ Loads the public keys of the mint. Either gets the keys for the specified `keyset_id` or gets the keys of the active keyset from the mint. Gets the active keyset ids of the mint and stores in `self.mint_keyset_ids`. Args: keyset_id (str, optional): Keyset id to load. Defaults to "". force_old_keysets (bool, optional): If true, old deprecated base64 keysets are not ignored. This is necessary for restoring tokens from old base64 keysets. Defaults to False. """ logger.trace("Loading mint.") await self.load_mint_keysets(force_old_keysets) await self.activate_keyset(keyset_id) try: await self.load_mint_info() except Exception as e: logger.debug(f"Could not load mint info: {e}") pass async def load_proofs(self, reload: bool = False, all_keysets=False) -> None: """Load all proofs of the selected mint and unit (i.e. self.keysets) into memory.""" if self.proofs and not reload: logger.debug("Proofs already loaded.") return self.proofs = [] await self.load_keysets_from_db() async with self.db.connect() as conn: if all_keysets: proofs = await get_proofs(db=self.db, conn=conn) self.proofs.extend(proofs) else: for keyset_id in self.keysets: proofs = await get_proofs(db=self.db, id=keyset_id, conn=conn) self.proofs.extend(proofs) keysets_str = " ".join([f"{k.id} ({k.unit})" for k in self.keysets.values()]) logger.trace(f"Proofs loaded for keysets: {keysets_str}") async def load_keysets_from_db( self, url: Union[str, None] = "", unit: Union[str, None] = "" ): """Load all keysets of the selected mint and unit from the database into self.keysets.""" # so that the caller can set unit = None, otherwise use defaults if unit == "": unit = self.unit.name if url == "": url = self.url keysets = await get_keysets(mint_url=url, unit=unit, db=self.db) for keyset in keysets: self.keysets[keyset.id] = keyset logger.trace( f"Loaded keysets from db: {[(k.id, k.unit.name, k.input_fee_ppk) for k in self.keysets.values()]}" ) async def _check_used_secrets(self, secrets): """Checks if any of the secrets have already been used""" logger.trace("Checking secrets.") async with self.db.get_connection() as conn: for s in secrets: if await secret_used(s, db=self.db, conn=conn): raise Exception(f"secret already used: {s}") logger.trace("Secret check complete.") async def request_mint_with_callback( self, amount: int, callback: Callable, memo: Optional[str] = None ) -> Tuple[MintQuote, SubscriptionManager]: """Request a quote invoice for minting tokens. Args: amount (int): Amount for Lightning invoice in satoshis callback (Callable): Callback function to be called when the invoice is paid. memo (Optional[str], optional): Memo for the Lightning invoice. Defaults Returns: MintQuote: Mint Quote """ mint_qoute = await super().mint_quote(amount, self.unit, memo) subscriptions = SubscriptionManager(self.url) threading.Thread( target=subscriptions.connect, name="SubscriptionManager", daemon=True ).start() subscriptions.subscribe( kind=JSONRPCSubscriptionKinds.BOLT11_MINT_QUOTE, filters=[mint_qoute.quote], callback=callback, ) quote = MintQuote.from_resp_wallet(mint_qoute, self.url, amount, self.unit.name) await store_bolt11_mint_quote(db=self.db, quote=quote) return quote, subscriptions async def request_mint(self, amount: int, memo: Optional[str] = None) -> MintQuote: """Request a quote invoice for minting tokens. Args: amount (int): Amount for Lightning invoice in satoshis callback (Optional[Callable], optional): Callback function to be called when the invoice is paid. Defaults to None. memo (Optional[str], optional): Memo for the Lightning invoice. Defaults to None. Returns: MintQuote: Mint Quote """ mint_quote_response = await super().mint_quote(amount, self.unit, memo) quote = MintQuote.from_resp_wallet( mint_quote_response, self.url, amount, self.unit.name ) await store_bolt11_mint_quote(db=self.db, quote=quote) return quote 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. Its objective is to fill up the wallet so that it reaches `n_target` coins of each amount. Args: amount (int): Amount to split Returns: List[int]: List of amounts to mint """ # read the target count for each amount from settings n_target = settings.wallet_target_amount_count amounts_we_have = [p.amount for p in self.proofs if p.reserved is not True] amounts_we_have.sort() # NOTE: Do not assume 2^n here all_possible_amounts: list[int] = [2**i for i in range(settings.max_order)] amounts_we_want_ll = [ [a] * max(0, n_target - amounts_we_have.count(a)) for a in all_possible_amounts ] # flatten list of lists to list amounts_we_want = [item for sublist in amounts_we_want_ll for item in sublist] # sort by increasing amount amounts_we_want.sort() logger.trace( f"Amounts we have: {[(a, amounts_we_have.count(a)) for a in set(amounts_we_have)]}" ) amounts: list[int] = [] while sum(amounts) < amount and amounts_we_want: if sum(amounts) + amounts_we_want[0] > amount: break amounts.append(amounts_we_want.pop(0)) remaining_amount = amount - sum(amounts) if remaining_amount > 0: amounts += amount_split(remaining_amount) amounts.sort() logger.trace(f"Amounts we want: {amounts}") if sum(amounts) != amount: raise Exception(f"Amounts do not sum to {amount}.") return amounts async def mint( self, amount: int, quote_id: str, split: Optional[List[int]] = 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`. Raises: Exception: Raises exception if `amounts` does not sum to `amount` or has unsupported value. Exception: Raises exception if no proofs have been provided Returns: List[Proof]: Newly minted proofs. """ # specific split if split: logger.trace(f"Mint with split: {split}") assert sum(split) == amount, "split must sum to amount" allowed_amounts = [2**i for i in range(settings.max_order)] for a in split: if a not in allowed_amounts: raise Exception( f"Can only mint amounts with 2^n up to {2**settings.max_order}." ) # split based on our wallet state amounts = split or self.split_wallet_state(amount) # if no split was specified, we use the canonical split # amounts = split or amount_split(amount) # quirk: we skip bumping the secret counter in the database since we are # not sure if the minting will succeed. If it succeeds, we will bump it # in the next step. secrets, rs, derivation_paths = await self.generate_n_secrets( len(amounts), skip_bump=True ) await self._check_used_secrets(secrets) outputs, rs = self._construct_outputs(amounts, secrets, rs) # will raise exception if mint is unsuccessful promises = await super().mint(outputs, quote_id) promises_keyset_id = promises[0].id await bump_secret_derivation( db=self.db, keyset_id=promises_keyset_id, by=len(amounts) ) proofs = await self._construct_proofs(promises, secrets, rs, derivation_paths) await update_bolt11_mint_quote( db=self.db, quote=quote_id, state=MintQuoteState.paid, paid_time=int(time.time()), ) # store the mint_id in proofs async with self.db.connect() as conn: for p in proofs: p.mint_id = quote_id await update_proof(p, mint_id=quote_id, conn=conn) return proofs async def redeem( self, proofs: List[Proof], ) -> Tuple[List[Proof], List[Proof]]: """Redeem proofs by sending them to yourself (by calling a split).) Calls `add_witnesses_to_proofs` 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 redeemed. """ # verify DLEQ of incoming proofs self.verify_proofs_dleq(proofs) return await self.split(proofs=proofs, amount=0) def determine_output_amounts( self, proofs: List[Proof], amount: int, include_fees: bool = False, keyset_id_outputs: Optional[str] = None, ) -> Tuple[List[int], List[int]]: """This function generates a suitable amount split for the outputs to keep and the outputs to send. It calculates the amount to keep based on the wallet state and the amount to send based on the amount provided. Amount to keep is based on the proofs we have in the wallet Amount to send is optimally split based on the amount provided plus optionally the fees required to receive them. Args: proofs (List[Proof]): Proofs to be split. amount (int): Amount to be sent. 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). Defaults to False. keyset_id_outputs (str, optional): The keyset ID of the outputs to be produced, used to determine the fee if `include_fees` is set. Returns: Tuple[List[int], List[int]]: Two lists of amounts, one for keeping and one for sending. """ # create a suitable amount split based on the proofs provided total = sum_proofs(proofs) keep_amt, send_amt = total - amount, amount if include_fees: keyset_id = keyset_id_outputs or self.keyset_id tmp_proofs = [Proof(id=keyset_id) for _ in amount_split(send_amt)] fee = self.get_fees_for_proofs(tmp_proofs) keep_amt -= fee send_amt += fee logger.trace(f"Keep amount: {keep_amt}, send amount: {send_amt}") logger.trace(f"Total input: {sum_proofs(proofs)}") # generate optimal split for outputs to send send_amounts = amount_split(send_amt) # we subtract the input fee for the entire transaction from the amount to keep keep_amt -= self.get_fees_for_proofs(proofs) logger.trace(f"Keep amount: {keep_amt}") # we determine the amounts to keep based on the wallet state keep_amounts = self.split_wallet_state(keep_amt) return keep_amounts, send_amounts async def split( self, proofs: List[Proof], amount: int, secret_lock: Optional[Secret] = None, include_fees: bool = False, ) -> 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. 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. """ assert len(proofs) > 0, "no proofs provided." assert sum_proofs(proofs) >= amount, "amount too large." assert amount >= 0, "amount can't be negative." # make sure we're operating on an independent copy of proofs proofs = copy.copy(proofs) # potentially add witnesses to unlock provided proofs (if they indicate one) proofs = self.add_witnesses_to_proofs(proofs) input_fees = self.get_fees_for_proofs(proofs) logger.trace(f"Input fees: {input_fees}") # create a suitable amounts to keep and send. keep_outputs, send_outputs = self.determine_output_amounts( proofs, amount, include_fees=include_fees, keyset_id_outputs=self.keyset_id, ) amounts = keep_outputs + send_outputs # generate secrets for new outputs if secret_lock is None: secrets, rs, derivation_paths = await self.generate_n_secrets(len(amounts)) else: secrets, rs, derivation_paths = await self.generate_locked_secrets( send_outputs, keep_outputs, secret_lock ) assert len(secrets) == len( amounts ), "number of secrets does not match number of outputs" # verify that we didn't accidentally reuse a secret await self._check_used_secrets(secrets) # construct outputs outputs, rs = self._construct_outputs(amounts, secrets, rs, self.keyset_id) # potentially add witnesses to outputs based on what requirement the proofs indicate outputs = self.add_witnesses_to_outputs(proofs, outputs) # sort outputs by amount, remember original order sorted_outputs_with_indices = sorted( enumerate(outputs), key=lambda p: p[1].amount ) original_indices, sorted_outputs = zip(*sorted_outputs_with_indices) # Call swap API sorted_promises = await super().split(proofs, sorted_outputs) # sort promises back to original order promises = [ promise for _, promise in sorted( zip(original_indices, sorted_promises), key=lambda x: x[0] ) ] # Construct proofs from returned promises (i.e., unblind the signatures) new_proofs = await self._construct_proofs( promises, secrets, rs, derivation_paths ) await self.invalidate(proofs) keep_proofs = new_proofs[: len(keep_outputs)] send_proofs = new_proofs[len(keep_outputs) :] return keep_proofs, send_proofs async def melt_quote( self, invoice: str, amount: Optional[int] = None ) -> PostMeltQuoteResponse: """ Fetches a melt quote from the mint and either uses the amount in the invoice or the amount provided. """ if amount and not self.mint_info.supports_mpp("bolt11", self.unit): raise Exception("Mint does not support MPP, cannot specify amount.") melt_quote_resp = await super().melt_quote(invoice, self.unit, amount) logger.debug( f"Mint wants {self.unit.str(melt_quote_resp.fee_reserve)} as fee reserve." ) melt_quote = MeltQuote.from_resp_wallet( melt_quote_resp, self.url, amount=melt_quote_resp.amount, unit=self.unit.name, request=invoice, ) await store_bolt11_melt_quote(db=self.db, quote=melt_quote) return melt_quote_resp async def melt( self, proofs: List[Proof], invoice: str, fee_reserve_sat: int, quote_id: str ) -> PostMeltQuoteResponse: """Pays a lightning invoice and returns the status of the payment. Args: proofs (List[Proof]): List of proofs to be spent. invoice (str): Lightning invoice to be paid. fee_reserve_sat (int): Amount of fees to be reserved for the payment. """ # Make sure we're operating on an independent copy of proofs proofs = copy.copy(proofs) amount = sum_proofs(proofs) # Generate a number of blank outputs for any overpaid fees. As described in # NUT-08, the mint will imprint these outputs with a value depending on the # amount of fees we overpaid. n_change_outputs = calculate_number_of_blank_outputs(fee_reserve_sat) ( change_secrets, change_rs, change_derivation_paths, ) = await self.generate_n_secrets(n_change_outputs) change_outputs, change_rs = self._construct_outputs( n_change_outputs * [1], change_secrets, change_rs ) # store the melt_id in proofs db async with self.db.connect() as conn: for p in proofs: p.melt_id = quote_id await update_proof(p, melt_id=quote_id, conn=conn) melt_quote_resp = await super().melt(quote_id, proofs, change_outputs) melt_quote = MeltQuote.from_resp_wallet( melt_quote_resp, self.url, amount=amount, unit=self.unit.name, request=invoice, ) # if payment fails if melt_quote.state == MeltQuoteState.unpaid: # remove the melt_id in proofs and set reserved to False for p in proofs: p.melt_id = None p.reserved = False await update_proof(p, melt_id="", db=self.db) raise Exception("could not pay invoice.") elif melt_quote.state == MeltQuoteState.pending: # payment is still pending logger.debug("Payment is still pending.") return melt_quote_resp # invoice was paid successfully await self.invalidate(proofs) # update paid status in db logger.trace(f"Settings invoice {quote_id} to paid.") logger.trace(f"Quote: {melt_quote_resp}") fee_paid = melt_quote.amount + melt_quote.fee_paid if melt_quote.change: fee_paid -= sum_promises(melt_quote.change) await update_bolt11_melt_quote( db=self.db, quote=quote_id, state=MeltQuoteState.paid, paid_time=int(time.time()), payment_preimage=melt_quote.payment_preimage or "", fee_paid=fee_paid, ) # handle change and produce proofs if melt_quote.change: change_proofs = await self._construct_proofs( melt_quote.change, change_secrets[: len(melt_quote.change)], change_rs[: len(melt_quote.change)], change_derivation_paths[: len(melt_quote.change)], ) logger.debug(f"Received change: {self.unit.str(sum_proofs(change_proofs))}") return melt_quote_resp async def check_proof_state(self, proofs) -> PostCheckStateResponse: return await super().check_proof_state(proofs) async def check_proof_state_with_callback( self, proofs: List[Proof], callback: Callable ) -> Tuple[PostCheckStateResponse, SubscriptionManager]: subscriptions = SubscriptionManager(self.url) threading.Thread( target=subscriptions.connect, name="SubscriptionManager", daemon=True ).start() subscriptions.subscribe( kind=JSONRPCSubscriptionKinds.PROOF_STATE, filters=[proof.Y for proof in proofs], callback=callback, ) return await self.check_proof_state(proofs), subscriptions # ---------- TOKEN MECHANICS ---------- # ---------- DLEQ PROOFS ---------- def verify_proofs_dleq(self, proofs: List[Proof]): """Verifies DLEQ proofs in proofs.""" for proof in proofs: if not proof.dleq: logger.trace("No DLEQ proof in proof.") return logger.trace("Verifying DLEQ proof.") assert proof.id assert ( proof.id in self.keysets ), f"Keyset {proof.id} not known, can not verify DLEQ." if not b_dhke.carol_verify_dleq( secret_msg=proof.secret, C=PublicKey(bytes.fromhex(proof.C), raw=True), r=PrivateKey(bytes.fromhex(proof.dleq.r), raw=True), e=PrivateKey(bytes.fromhex(proof.dleq.e), raw=True), s=PrivateKey(bytes.fromhex(proof.dleq.s), raw=True), A=self.keysets[proof.id].public_keys[proof.amount], ): raise Exception("DLEQ proof invalid.") else: logger.trace("DLEQ proof valid.") logger.debug("Verified incoming DLEQ proofs.") async def _construct_proofs( self, promises: List[BlindedSignature], secrets: List[str], rs: List[PrivateKey], derivation_paths: List[str], ) -> List[Proof]: """Constructs proofs from promises, secrets, rs and derivation paths. This method is called after the user has received blind signatures from the mint. The results are proofs that can be used as ecash. Args: promises (List[BlindedSignature]): blind signatures from mint secrets (List[str]): secrets that were previously used to create blind messages (that turned into promises) rs (List[PrivateKey]): blinding factors that were previously used to create blind messages (that turned into promises) derivation_paths (List[str]): derivation paths that were used to generate secrets and blinding factors Returns: List[Proof]: list of proofs that can be used as ecash """ logger.trace("Constructing proofs.") proofs: List[Proof] = [] for promise, secret, r, path in zip(promises, secrets, rs, derivation_paths): if promise.id not in self.keysets: logger.debug(f"Keyset {promise.id} not found in db. Loading from mint.") # we don't have the keyset for this promise, so we load all keysets from the mint await self.load_mint_keysets() assert promise.id in self.keysets, "Could not load keyset." C_ = PublicKey(bytes.fromhex(promise.C_), raw=True) C = b_dhke.step3_alice( C_, r, self.keysets[promise.id].public_keys[promise.amount] ) if not settings.wallet_use_deprecated_h2c: B_, r = b_dhke.step1_alice(secret, r) # recompute B_ for dleq proofs # BEGIN: BACKWARDS COMPATIBILITY < 0.15.1 else: B_, r = b_dhke.step1_alice_deprecated( secret, r ) # recompute B_ for dleq proofs # END: BACKWARDS COMPATIBILITY < 0.15.1 proof = Proof( id=promise.id, amount=promise.amount, C=C.serialize().hex(), secret=secret, derivation_path=path, ) # if the mint returned a dleq proof, we add it to the proof if promise.dleq: proof.dleq = DLEQWallet( e=promise.dleq.e, s=promise.dleq.s, r=r.serialize() ) proofs.append(proof) logger.trace( f"Created proof: {proof}, r: {r.serialize()} out of promise {promise}" ) # DLEQ verify self.verify_proofs_dleq(proofs) logger.trace(f"Constructed {len(proofs)} proofs.") # add new proofs to wallet self.proofs += copy.copy(proofs) # store new proofs in database await self._store_proofs(proofs) return proofs def _construct_outputs( self, amounts: List[int], secrets: List[str], rs: List[PrivateKey] = [], keyset_id: Optional[str] = None, ) -> Tuple[List[BlindedMessage], List[PrivateKey]]: """Takes a list of amounts and secrets and returns outputs. Outputs are blinded messages `outputs` and blinding factors `rs` Args: amounts (List[int]): list of amounts secrets (List[str]): list of secrets rs (List[PrivateKey], optional): list of blinding factors. If not given, `rs` are generated in step1_alice. Defaults to []. Returns: List[BlindedMessage]: list of blinded messages that can be sent to the mint List[PrivateKey]: list of blinding factors that can be used to construct proofs after receiving blind signatures from the mint Raises: AssertionError: if len(amounts) != len(secrets) """ assert len(amounts) == len( secrets ), f"len(amounts)={len(amounts)} not equal to len(secrets)={len(secrets)}" keyset_id = keyset_id or self.keyset_id outputs: List[BlindedMessage] = [] rs_ = [None] * len(amounts) if not rs else rs rs_return: List[PrivateKey] = [] for secret, amount, r in zip(secrets, amounts, rs_): if not settings.wallet_use_deprecated_h2c: B_, r = b_dhke.step1_alice(secret, r or None) # BEGIN: BACKWARDS COMPATIBILITY < 0.15.1 else: B_, r = b_dhke.step1_alice_deprecated(secret, r or None) # END: BACKWARDS COMPATIBILITY < 0.15.1 rs_return.append(r) output = BlindedMessage( amount=amount, B_=B_.serialize().hex(), id=keyset_id ) outputs.append(output) logger.trace(f"Constructing output: {output}, r: {r.serialize()}") return outputs, rs_return async def construct_outputs(self, amounts: List[int]) -> List[BlindedMessage]: """Constructs outputs for a list of amounts. Args: amounts (List[int]): List of amounts to construct outputs for. Returns: List[BlindedMessage]: List of blinded messages that can be sent to the mint. """ secrets, rs, _ = await self.generate_n_secrets(len(amounts)) return self._construct_outputs(amounts, secrets, rs)[0] async def _store_proofs(self, proofs): try: async with self.db.connect() as conn: for proof in proofs: await store_proof(proof, db=self.db, conn=conn) except Exception as e: logger.error(f"Could not store proofs in database: {e}") logger.error(proofs) raise e async def invalidate( self, proofs: List[Proof], check_spendable=False ) -> List[Proof]: """Invalidates all unspendable tokens supplied in proofs. Args: proofs (List[Proof]): Which proofs to delete check_spendable (bool, optional): Asks the mint to check whether proofs are already spent before deleting them. Defaults to False. Returns: List[Proof]: List of proofs that are still spendable. """ invalidated_proofs: List[Proof] = [] if check_spendable: # checks proofs in batches for _proofs in [ proofs[i : i + settings.proofs_batch_size] for i in range(0, len(proofs), settings.proofs_batch_size) ]: proof_states = await self.check_proof_state(proofs) for i, state in enumerate(proof_states.states): if state.spent: invalidated_proofs.append(proofs[i]) else: invalidated_proofs = proofs if invalidated_proofs: logger.trace( f"Invalidating {len(invalidated_proofs)} proofs worth" f" {self.unit.str(sum_proofs(invalidated_proofs))}." ) for p in invalidated_proofs: try: # mark proof as spent await invalidate_proof(p, db=self.db) except Exception as e: logger.error(f"DB error while invalidating proof: {e}") invalidate_secrets = [p.secret for p in invalidated_proofs] self.proofs = list( filter(lambda p: p.secret not in invalidate_secrets, self.proofs) ) return [p for p in proofs if p not in invalidated_proofs] # ---------- TRANSACTION HELPERS ---------- async def select_to_send( self, proofs: List[Proof], amount: int, *, set_reserved: bool = False, offline: bool = False, include_fees: bool = False, ) -> Tuple[List[Proof], int]: """ Selects proofs such that a desired `amount` can be sent. If the offline coin selection is unsuccessful, and `offline` is set to False (default), we split the available proofs with the mint to get the desired `amount`. If `set_reserved` is set to True, the proofs are marked as reserved so they aren't used in other transactions. If `include_fees` is set to True, the selection includes the swap fees to receive the selected proofs. Args: proofs (List[Proof]): Proofs to split amount (int): Amount to split to set_reserved (bool, optional): If set, the proofs are marked as reserved. Defaults to False. offline (bool, optional): If set, the coin selection is done offline. Defaults to False. include_fees (bool, optional): If set, the fees for spending the proofs later are included in the amount to be selected. Defaults to False. Returns: List[Proof]: Proofs to send int: Fees for the transaction """ # select proofs that are not reserved and are in the active keysets of the mint proofs = self.active_proofs(proofs) if sum_proofs(proofs) < amount: raise Exception("balance too low.") # coin selection for potentially offline sending send_proofs = self.coinselect(proofs, amount, include_fees=include_fees) fees = self.get_fees_for_proofs(send_proofs) logger.trace( f"select_to_send: selected: {self.unit.str(sum_proofs(send_proofs))} (+ {self.unit.str(fees)} fees) – wanted: {self.unit.str(amount)}" ) # offline coin selection unsuccessful, we need to swap proofs before we can send if not send_proofs or sum_proofs(send_proofs) > amount + fees: if not offline: logger.debug("Offline coin selection unsuccessful. Splitting proofs.") # we set the proofs as reserved later _, send_proofs = await self.swap_to_send( proofs, amount, set_reserved=False, include_fees=include_fees, ) else: raise Exception( "Could not select proofs in offline mode. Available amounts:" + amount_summary(proofs, self.unit) ) if set_reserved: await self.set_reserved(send_proofs, reserved=True) return send_proofs, fees async def swap_to_send( self, proofs: List[Proof], amount: int, *, secret_lock: Optional[Secret] = None, set_reserved: bool = False, include_fees: bool = False, ) -> 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. Returns: Tuple[List[Proof], List[Proof]]: Tuple of proofs to keep and proofs to send """ # select proofs that are not reserved and are in the active keysets of the mint proofs = self.active_proofs(proofs) if sum_proofs(proofs) < amount: raise Exception("balance too low.") # coin selection for swapping, needs to include fees swap_proofs = self.coinselect(proofs, amount, include_fees=True) # Extra rule: add proofs from inactive keysets to swap_proofs to get rid of them swap_proofs += [ p for p in proofs if not self.keysets[p.id].active and not p.reserved and p not in swap_proofs ] fees = self.get_fees_for_proofs(swap_proofs) logger.debug( f"Amount to send: {self.unit.str(amount)} (+ {self.unit.str(fees)} fees)" ) keep_proofs, send_proofs = await self.split( swap_proofs, amount, secret_lock, include_fees=include_fees ) if set_reserved: await self.set_reserved(send_proofs, reserved=True) return keep_proofs, send_proofs # ---------- BALANCE CHECKS ---------- @property def balance(self): return sum_proofs(self.proofs) @property def available_balance(self): return sum_proofs([p for p in self.proofs if not p.reserved]) @property def proof_amounts(self): """Returns a sorted list of amounts of all proofs""" return [p.amount for p in sorted(self.proofs, key=lambda p: p.amount)] def active_proofs(self, proofs: List[Proof]): """Returns a list of proofs that - have an id that is in the current `self.keysets` which have the unit in `self.unit` - are not reserved """ def is_active_proof(p: Proof) -> bool: return ( p.id in self.keysets and self.keysets[p.id].unit == self.unit and not p.reserved ) return [p for p in proofs if is_active_proof(p)] def balance_per_keyset(self) -> Dict[str, Dict[str, Union[int, str]]]: ret: Dict[str, Dict[str, Union[int, str]]] = { key: { "balance": sum_proofs(proofs), "available": sum_proofs([p for p in proofs if not p.reserved]), } for key, proofs in self._get_proofs_per_keyset(self.proofs).items() } for key in ret.keys(): if key in self.keysets: ret[key]["unit"] = self.keysets[key].unit.name return ret def balance_per_unit(self) -> Dict[Unit, Dict[str, Union[int, str]]]: ret: Dict[Unit, Dict[str, Union[int, str]]] = { unit: { "balance": sum_proofs(proofs), "available": sum_proofs([p for p in proofs if not p.reserved]), } for unit, proofs in self._get_proofs_per_unit(self.proofs).items() } return ret async def balance_per_minturl( self, unit: Optional[Unit] = None ) -> Dict[str, Dict[str, Union[int, str]]]: balances = await self._get_proofs_per_minturl(self.proofs, unit=unit) balances_return: Dict[str, Dict[str, Union[int, str]]] = { key: { "balance": sum_proofs(proofs), "available": sum_proofs([p for p in proofs if not p.reserved]), } for key, proofs in balances.items() } for key in balances_return.keys(): if unit: balances_return[key]["unit"] = unit.name return dict(sorted(balances_return.items(), key=lambda item: item[0])) # type: ignore # ---------- RESTORE WALLET ---------- async def restore_tokens_for_keyset( self, keyset_id: str, to: int = 2, batch: int = 25 ) -> None: """ Restores tokens for a given keyset_id. Args: keyset_id (str): The keyset_id to restore tokens for. to (int, optional): The number of consecutive empty responses to stop restoring. Defaults to 2. batch (int, optional): The number of proofs to restore in one batch. Defaults to 25. """ empty_batches = 0 # we get the current secret counter and restore from there on spendable_proofs = [] counter_before = await bump_secret_derivation( db=self.db, keyset_id=keyset_id, by=0 ) if counter_before != 0: print("Keyset has already been used. Restoring from its last state.") i = counter_before last_restore_count = 0 while empty_batches < to: print(f"Restoring counter {i} to {i + batch} for keyset {keyset_id} ...") ( next_restored_output_index, restored_proofs, ) = await self.restore_promises_from_to(keyset_id, i, i + batch - 1) last_restore_count += next_restored_output_index i += batch if len(restored_proofs) == 0: empty_batches += 1 continue spendable_proofs = await self.invalidate( restored_proofs, check_spendable=True ) if len(spendable_proofs): print( f"Restored {sum_proofs(spendable_proofs)} sat for keyset {keyset_id}." ) else: logger.debug( f"None of the {len(restored_proofs)} restored proofs are spendable." ) # restore the secret counter to its previous value for the last round revert_counter_by = i - last_restore_count logger.debug(f"Reverting secret counter by {revert_counter_by}") before = await bump_secret_derivation( db=self.db, keyset_id=keyset_id, by=-revert_counter_by, ) logger.debug( f"Secret counter reverted from {before} to {before - revert_counter_by}" ) if last_restore_count == 0: print(f"No tokens restored for keyset {keyset_id}.") return async def restore_wallet_from_mnemonic( self, mnemonic: Optional[str], to: int = 2, batch: int = 25 ) -> None: """ Restores the wallet from a mnemonic. Args: mnemonic (Optional[str]): The mnemonic to restore the wallet from. If None, the mnemonic is loaded from the db. to (int, optional): The number of consecutive empty responses to stop restoring. Defaults to 2. batch (int, optional): The number of proofs to restore in one batch. Defaults to 25. """ await self._init_private_key(mnemonic) await self.load_mint(force_old_keysets=False) print("Restoring tokens...") for keyset_id in self.keysets.keys(): await self.restore_tokens_for_keyset(keyset_id, to, batch) async def restore_promises_from_to( self, keyset_id: str, from_counter: int, to_counter: int ) -> Tuple[int, List[Proof]]: """Restores promises from a given range of counters. This is for restoring a wallet from a mnemonic. Args: from_counter (int): Counter for the secret derivation to start from to_counter (int): Counter for the secret derivation to end at Returns: Tuple[int, List[Proof]]: Index of the last restored output and list of restored proofs """ # we regenerate the secrets and rs for the given range secrets, rs, derivation_paths = await self.generate_secrets_from_to( from_counter, to_counter, keyset_id=keyset_id ) # we don't know the amount but luckily the mint will tell us so we use a dummy amount here amounts_dummy = [1] * len(secrets) # we generate outputs from deterministic secrets and rs regenerated_outputs, _ = self._construct_outputs( amounts_dummy, secrets, rs, keyset_id=keyset_id ) # we ask the mint to reissue the promises next_restored_output_index, proofs = await self.restore_promises( outputs=regenerated_outputs, secrets=secrets, rs=rs, derivation_paths=derivation_paths, ) await set_secret_derivation( db=self.db, keyset_id=keyset_id, counter=to_counter + 1 ) return next_restored_output_index, proofs async def restore_promises( self, outputs: List[BlindedMessage], secrets: List[str], rs: List[PrivateKey], derivation_paths: List[str], ) -> Tuple[int, List[Proof]]: """Restores proofs from a list of outputs, secrets, rs and derivation paths. Args: outputs (List[BlindedMessage]): Outputs for which we request promises secrets (List[str]): Secrets generated for the outputs rs (List[PrivateKey]): Random blinding factors generated for the outputs derivation_paths (List[str]): Derivation paths used for the secrets necessary to unblind the promises Returns: Tuple[int, List[Proof]]: Index of the last restored output and list of restored proofs """ # restored_outputs is there so we can match the promises to the secrets and rs restored_outputs, restored_promises = await super().restore_promises(outputs) # determine the index in `outputs` of the last restored output from restored_outputs[-1].B_ if not restored_outputs: next_restored_output_index = 0 else: next_restored_output_index = ( next( ( idx for idx, val in enumerate(outputs) if val.B_ == restored_outputs[-1].B_ ), 0, ) + 1 ) logger.trace(f"Last restored output index: {next_restored_output_index}") # now we need to filter out the secrets and rs that had a match matching_indices = [ idx for idx, val in enumerate(outputs) if val.B_ in [o.B_ for o in restored_outputs] ] secrets = [secrets[i] for i in matching_indices] rs = [rs[i] for i in matching_indices] logger.debug( f"Restored {len(restored_promises)} promises. Constructing proofs." ) # now we can construct the proofs with the secrets and rs proofs = await self._construct_proofs( restored_promises, secrets, rs, derivation_paths ) logger.debug(f"Restored {len(restored_promises)} promises") return next_restored_output_index, proofs