Files
nutshell/cashu/wallet/wallet.py
callebtc a0ef44dba0 Blind authentication (#675)
* auth server

* cleaning up

* auth ledger class

* class variables -> instance variables

* annotations

* add models and api route

* custom amount and api prefix

* add auth db

* blind auth token working

* jwt working

* clean up

* JWT works

* using openid connect server

* use oauth server with password flow

* new realm

* add keycloak docker

* hopefully not garbage

* auth works

* auth kinda working

* fix cli

* auth works for send and receive

* pass auth_db to Wallet

* auth in info

* refactor

* fix supported

* cache mint info

* fix settings and endpoints

* add description to .env.example

* track changes for openid connect client

* store mint in db

* store credentials

* clean up v1_api.py

* load mint info into auth wallet

* fix first login

* authenticate if refresh token fails

* clear auth also middleware

* use regex

* add cli command

* pw works

* persist keyset amounts

* add errors.py

* do not start auth server if disabled in config

* upadte poetry

* disvoery url

* fix test

* support device code flow

* adopt latest spec changes

* fix code flow

* mint max bat dynamic

* mypy ignore

* fix test

* do not serialize amount in authproof

* all auth flows working

* fix tests

* submodule

* refactor

* test

* dont sleep

* test

* add wallet auth tests

* test differently

* test only keycloak for now

* fix creds

* daemon

* fix test

* install everything

* install jinja

* delete wallet for every test

* auth: use global rate limiter

* test auth rate limit

* keycloak hostname

* move keycloak test data

* reactivate all tests

* add readme

* load proofs

* remove unused code

* remove unused code

* implement change suggestions by ok300

* add error codes

* test errors
2025-01-29 22:48:51 -06:00

1393 lines
56 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import copy
import json
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,
WalletMint,
)
from ..core.crypto import b_dhke
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.mint_info import MintInfo
from ..core.models import (
PostCheckStateResponse,
PostMeltQuoteResponse,
)
from ..core.nuts import nut20
from ..core.p2pk import Secret
from ..core.settings import settings
from . import migrations
from .compat import WalletCompat
from .crud import (
bump_secret_derivation,
get_bolt11_mint_quote,
get_keysets,
get_mint_by_url,
get_proofs,
invalidate_proof,
secret_used,
set_secret_derivation,
store_bolt11_melt_quote,
store_bolt11_mint_quote,
store_keyset,
store_mint,
store_proof,
update_bolt11_melt_quote,
update_bolt11_mint_quote,
update_keyset,
update_mint,
update_proof,
)
from .errors import BalanceTooLowError
from .htlc import WalletHTLC
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,
WalletCompat,
):
"""
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
auth_db: Optional[Database] = None
auth_keyset_id: Optional[str] = None
def __init__(
self,
url: str,
db: str,
name: str = "wallet",
unit: str = "sat",
auth_db: Optional[str] = None,
auth_keyset_id: Optional[str] = None,
):
"""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".
unit (str, optional): Unit of the wallet. Defaults to "sat".
auth_db (Optional[str], optional): Path to the auth database directory. Defaults to None.
auth_keyset_id (Optional[str], optional): Keyset ID of the auth keyset. Defaults to None.
"""
self.db = Database(name, db)
self.proofs: List[Proof] = []
self.name = name
self.unit = Unit[unit]
url = sanitize_url(url)
# if this is an auth wallet
if (auth_db and not auth_keyset_id) or (not auth_db and auth_keyset_id):
raise Exception("Both auth_db and auth_keyset_id must be provided.")
if auth_db and auth_keyset_id:
self.auth_db = Database("auth", auth_db)
self.auth_keyset_id = auth_keyset_id
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",
auth_db: Optional[str] = None,
auth_keyset_id: Optional[str] = None,
load_all_keysets: bool = False,
**kwargs,
):
"""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.
auth_db (Optional[str], optional): Path to the auth database directory. Defaults to None.
auth_keyset_id (Optional[str], optional): Keyset ID of the auth keyset. Defaults to None.
kwargs: Additional keyword arguments.
Returns:
Wallet: Initialized wallet.
"""
logger.trace(f"Initializing wallet with database: {db}")
self = cls(
url=url,
db=db,
name=name,
unit=unit,
auth_db=auth_db,
auth_keyset_id=auth_keyset_id,
)
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}
if self.keysets:
keysets_str = " ".join([f"{i} {k.unit}" for i, k in self.keysets.items()])
logger.debug(f"Loaded keysets: {keysets_str}")
await self.load_mint_info(offline=True)
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, reload=False, offline=False) -> MintInfo | None:
"""Loads the mint info from the mint.
Args:
reload (bool, optional): If True, the mint info is reloaded from the mint. Defaults to False.
offline (bool, optional): If True, the mint info is not loaded from the mint. Defaults to False.
"""
# if self.mint_info and not reload:
# return self.mint_info
# read mint info from db
if reload:
if offline:
raise Exception("Cannot reload mint info offline.")
logger.debug("Forcing reload of mint info.")
mint_info_resp = await self._get_info()
self.mint_info = MintInfo(**mint_info_resp.dict())
wallet_mint_db = await get_mint_by_url(url=self.url, db=self.db)
if not wallet_mint_db:
if self.mint_info:
logger.debug("Storing mint info in db.")
await store_mint(
db=self.db,
mint=WalletMint(
url=self.url, info=json.dumps(self.mint_info.dict())
),
)
else:
if offline:
return None
logger.debug("Loading mint info from mint.")
mint_info_resp = await self._get_info()
self.mint_info = MintInfo(**mint_info_resp.dict())
if not wallet_mint_db:
logger.debug("Storing mint info in db.")
await store_mint(
db=self.db,
mint=WalletMint(
url=self.url, info=json.dumps(self.mint_info.dict())
),
)
return self.mint_info
elif (
self.mint_info
and not json.dumps(self.mint_info.dict()) == wallet_mint_db.info
):
logger.debug("Updating mint info in db.")
await update_mint(
db=self.db,
mint=WalletMint(url=self.url, info=json.dumps(self.mint_info.dict())),
)
return self.mint_info
else:
logger.debug("Loading mint info from db.")
self.mint_info = MintInfo.from_json_str(wallet_mint_db.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 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(f"Loading mint {self.url}")
await self.load_mint_keysets(force_old_keysets)
await self.activate_keyset(keyset_id)
try:
await self.load_mint_info(reload=True)
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
"""
# generate a key for signing the quote request
privkey_hex, pubkey_hex = nut20.generate_keypair()
mint_quote = await super().mint_quote(amount, self.unit, memo, pubkey_hex)
subscriptions = SubscriptionManager(self.url)
threading.Thread(
target=subscriptions.connect, name="SubscriptionManager", daemon=True
).start()
subscriptions.subscribe(
kind=JSONRPCSubscriptionKinds.BOLT11_MINT_QUOTE,
filters=[mint_quote.quote],
callback=callback,
)
quote = MintQuote.from_resp_wallet(mint_quote, self.url, amount, self.unit.name)
# store the private key in the quote
quote.privkey = privkey_hex
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.
keypair (Optional[Tuple[str, str], optional]): NUT-19 private public ephemeral keypair. Defaults to None.
Returns:
MintQuote: Mint Quote
"""
# generate a key for signing the quote request
privkey_hex, pubkey_hex = nut20.generate_keypair()
mint_quote_response = await super().mint_quote(
amount, self.unit, memo, pubkey_hex
)
quote = MintQuote.from_resp_wallet(
mint_quote_response, self.url, amount, self.unit.name
)
quote.privkey = privkey_hex
await store_bolt11_mint_quote(db=self.db, quote=quote)
return quote
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)
# 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)
quote = await get_bolt11_mint_quote(db=self.db, quote=quote_id)
if not quote:
raise Exception("Quote not found.")
signature: str | None = None
if quote.privkey:
signature = nut20.sign_mint_quote(quote_id, outputs, quote.privkey)
# will raise exception if mint is unsuccessful
promises = await super().mint(outputs, quote_id, signature)
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.
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)
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.
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 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 BalanceTooLowError()
# 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 BalanceTooLowError()
# 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