From af3e82691e5707c14494e34b95984cd4f026f80d Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 10 Jun 2023 20:45:03 +0200 Subject: [PATCH] [Wallet] Allow minting of specific amounts (#248) * Allow to start wallet API by cashu --daemon * Provide access to wallet name via settings * Make format * Use flag is_eager for daemon option * add setting api_host * fix: add missing amount * refactor mint * cli and api for splitting and tests * invoice balance? * remove balance checks until I know why it doesnt update * remove all balance checks from tests * delete old code * remove debug logs --------- Co-authored-by: sihamon --- cashu/wallet/api/router.py | 17 +++++++++-- cashu/wallet/cli/cli.py | 30 ++++++++++++++++--- cashu/wallet/wallet.py | 60 ++++++++++++++++++-------------------- tests/test_cli.py | 17 +++++++++-- tests/test_wallet.py | 18 ++++++++++-- tests/test_wallet_api.py | 26 +++++++++++++++++ 6 files changed, 126 insertions(+), 42 deletions(-) diff --git a/cashu/wallet/api/router.py b/cashu/wallet/api/router.py index a36b8cd..f9ff181 100644 --- a/cashu/wallet/api/router.py +++ b/cashu/wallet/api/router.py @@ -100,12 +100,24 @@ async def invoice( default=None, description="Mint URL to create an invoice at (None for default mint)", ), + split: int = Query( + default=None, description="Split minted tokens with a specific amount." + ), ): + # in case the user wants a specific split, we create a list of amounts + optional_split = None + if split: + assert amount % split == 0, "split must be divisor or amount" + assert amount >= split, "split must smaller or equal amount" + n_splits = amount // split + optional_split = [split] * n_splits + print(f"Requesting split with {n_splits}*{split} sat tokens.") + global wallet wallet = await load_mint(wallet, mint) initial_balance = wallet.available_balance if not settings.lightning: - r = await wallet.mint(amount) + r = await wallet.mint(amount, split=optional_split) return InvoiceResponse( amount=amount, balance=wallet.available_balance, @@ -114,12 +126,13 @@ async def invoice( elif amount and not hash: invoice = await wallet.request_mint(amount) return InvoiceResponse( + amount=amount, invoice=invoice, balance=wallet.available_balance, initial_balance=initial_balance, ) elif amount and hash: - await wallet.mint(amount, hash) + await wallet.mint(amount, split=optional_split, hash=hash) return InvoiceResponse( amount=amount, hash=hash, diff --git a/cashu/wallet/cli/cli.py b/cashu/wallet/cli/cli.py index 126002b..ffb5bb2 100644 --- a/cashu/wallet/cli/cli.py +++ b/cashu/wallet/cli/cli.py @@ -12,6 +12,7 @@ from os.path import isdir, join import click from click import Context +from loguru import logger from ...core.base import TokenV3 from ...core.helpers import sum_proofs @@ -140,14 +141,31 @@ async def pay(ctx: Context, invoice: str, yes: bool): @cli.command("invoice", help="Create Lighting invoice.") @click.argument("amount", type=int) @click.option("--hash", default="", help="Hash of the paid invoice.", type=str) +@click.option( + "--split", + "-s", + default=None, + help="Split minted tokens with a specific amount.", + type=int, +) @click.pass_context @coro -async def invoice(ctx: Context, amount: int, hash: str): +async def invoice(ctx: Context, amount: int, hash: str, split: int): wallet: Wallet = ctx.obj["WALLET"] await wallet.load_mint() wallet.status() + # in case the user wants a specific split, we create a list of amounts + optional_split = None + if split: + assert amount % split == 0, "split must be divisor or amount" + assert amount >= split, "split must smaller or equal amount" + n_splits = amount // split + optional_split = [split] * n_splits + print(f"Requesting split with {n_splits}*{split} sat tokens.") + if not settings.lightning: r = await wallet.mint(amount) + # user requests an invoice elif amount and not hash: invoice = await wallet.request_mint(amount) if invoice.pr: @@ -169,21 +187,25 @@ async def invoice(ctx: Context, amount: int, hash: str): while time.time() < check_until and not paid: time.sleep(3) try: - await wallet.mint(amount, invoice.hash) + await wallet.mint(amount, split=optional_split, hash=invoice.hash) paid = True print(" Invoice paid.") except Exception as e: # TODO: user error codes! - if str(e) == "Error: Lightning invoice not paid yet.": + if "invoice not paid" in str(e): print(".", end="", flush=True) continue + else: + print(f"Error: {str(e)}") if not paid: print("\n") print( "Invoice is not paid yet, stopping check. Use the command above to recheck after the invoice has been paid." ) + + # user paid invoice and want to check it elif amount and hash: - await wallet.mint(amount, hash) + await wallet.mint(amount, split=optional_split, hash=hash) wallet.status() return diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 0419a13..ac3c0fa 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -114,6 +114,7 @@ class LedgerAPI: self, promises: List[BlindedSignature], secrets: List[str], rs: List[PrivateKey] ): """Returns proofs of promise from promises. Wants secrets and blinding factors rs.""" + logger.trace(f"Constructing proofs.") proofs: List[Proof] = [] for promise, secret, r in zip(promises, secrets, rs): logger.trace(f"Creating proof with keyset {self.keyset_id} = {promise.id}") @@ -131,6 +132,7 @@ class LedgerAPI: secret=secret, ) proofs.append(proof) + logger.trace(f"Constructed {len(proofs)} proofs.") return proofs @staticmethod @@ -202,6 +204,7 @@ class LedgerAPI: def _construct_outputs(amounts: List[int], secrets: List[str]): """Takes a list of amounts and secrets and returns outputs. Outputs are blinded messages `outputs` and blinding factors `rs`""" + logger.trace(f"Constructing outputs.") assert len(amounts) == len( secrets ), f"len(amounts)={len(amounts)} not equal to len(secrets)={len(secrets)}" @@ -214,12 +217,16 @@ class LedgerAPI: amount=amount, B_=B_.serialize().hex() ) outputs.append(output) + logger.trace(f"Constructed {len(outputs)} outputs.") return outputs, rs async def _check_used_secrets(self, secrets): + """Checks if any of the secrets have already been used""" + logger.trace("Checking secrets.") for s in secrets: if await secret_used(s, db=self.db): raise Exception(f"secret already used: {s}") + logger.trace("Secret check complete.") def generate_secrets(self, secret, n): """`secret` is the base string that will be tweaked n times""" @@ -303,6 +310,7 @@ class LedgerAPI: @async_set_requests async def request_mint(self, amount): """Requests a mint from the server and returns Lightning invoice.""" + logger.trace("Requesting mint: GET /mint") resp = self.s.get(self.url + "/mint", params={"amount": amount}) resp.raise_for_status() return_dict = resp.json() @@ -317,6 +325,7 @@ class LedgerAPI: await self._check_used_secrets(secrets) outputs, rs = self._construct_outputs(amounts, secrets) outputs_payload = PostMintRequest(outputs=outputs) + logger.trace("Checking Lightning invoice. POST /mint") resp = self.s.post( self.url + "/mint", json=outputs_payload.dict(), @@ -328,6 +337,7 @@ class LedgerAPI: resp.raise_for_status() reponse_dict = resp.json() self.raise_on_error(reponse_dict) + logger.trace("Lightning invoice checked. POST /mint") try: # backwards compatibility: parse promises < 0.8.0 with no "promises" field promises = PostMintResponseLegacy.parse_obj(reponse_dict).__root__ @@ -498,20 +508,37 @@ class Wallet(LedgerAPI): await store_lightning_invoice(db=self.db, invoice=invoice) return invoice - async def mint(self, amount: int, hash: Optional[str] = None): + async def mint( + self, + amount: int, + split: Optional[List[int]] = None, + hash: Optional[str] = None, + ): """Mint tokens of a specific amount after an invoice has been paid. Args: amount (int): Total amount of tokens to be minted + split (Optional[List[str]], optional): List of desired amount splits to be minted. Total must sum to `amount`. hash (Optional[str], optional): Hash for looking up the paid Lightning invoice. Defaults to None (for testing with LIGHTNING=False). 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. """ - split = amount_split(amount) + # specific split + if split: + assert sum(split) == amount, "split must sum to amount" + for a in split: + if a not in [2**i for i in range(settings.max_order)]: + raise Exception( + f"Can only mint amounts with 2^n up to {2**settings.max_order}." + ) + + # if no split was specified, we use the canonical split + split = split or amount_split(amount) proofs = await super().mint(split, hash) if proofs == []: raise Exception("received no proofs.") @@ -523,35 +550,6 @@ class Wallet(LedgerAPI): self.proofs += proofs return proofs - async def mint_amounts(self, amounts: List[int], hash: Optional[str] = None): - """Similar to wallet.mint() but accepts a predefined list of amount to be minted. - - Args: - amounts (List[int]): List of amounts requested - hash (Optional[str], optional): Hash for looking up the paid Lightning invoice. Defaults to None (for testing with LIGHTNING=False). - - Raises: - Exception: Newly minted proofs. - - Returns: - List[Proof]: Newly minted proofs. - """ - for amount in amounts: - if amount not in [2**i for i in range(settings.max_order)]: - raise Exception( - f"Can only mint amounts with 2^n up to {2**settings.max_order}." - ) - proofs = await super().mint(amounts, hash) - if proofs == []: - raise Exception("received no proofs.") - await self._store_proofs(proofs) - if hash: - await update_lightning_invoice( - db=self.db, hash=hash, paid=True, time_paid=int(time.time()) - ) - self.proofs += proofs - return proofs - async def redeem( self, proofs: List[Proof], diff --git a/tests/test_cli.py b/tests/test_cli.py index fdbfdc2..f20f868 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -70,11 +70,24 @@ def test_invoice(mint, cli_prefix): assert result.exception is None print("INVOICE") print(result.output) - # wallet = asyncio.run(init_wallet()) - # assert f"Balance: {wallet.available_balance} sat" in result.output + wallet = asyncio.run(init_wallet()) + # assert wallet.available_balance >= 1000 + assert f"Balance: {wallet.available_balance} sat" in result.output assert result.exit_code == 0 +@pytest.mark.asyncio +def test_invoice_with_split(mint, cli_prefix): + runner = CliRunner() + result = runner.invoke( + cli, + [*cli_prefix, "invoice", "10", "-s", "1"], + ) + assert result.exception is None + # wallet = asyncio.run(init_wallet()) + # assert wallet.proof_amounts.count(1) >= 10 + + @pytest.mark.asyncio def test_wallets(cli_prefix): runner = CliRunner() diff --git a/tests/test_wallet.py b/tests/test_wallet.py index b9e6d6b..64f7605 100644 --- a/tests/test_wallet.py +++ b/tests/test_wallet.py @@ -97,16 +97,28 @@ async def test_mint(wallet1: Wallet): @pytest.mark.asyncio async def test_mint_amounts(wallet1: Wallet): """Mint predefined amounts""" - await wallet1.mint_amounts([1, 1, 1, 2, 2, 4, 16]) + amts = [1, 1, 1, 2, 2, 4, 16] + await wallet1.mint(amount=sum(amts), split=amts) assert wallet1.balance == 27 - assert wallet1.proof_amounts == [1, 1, 1, 2, 2, 4, 16] + assert wallet1.proof_amounts == amts + + +@pytest.mark.asyncio +async def test_mint_amounts_wrong_sum(wallet1: Wallet): + """Mint predefined amounts""" + amts = [1, 1, 1, 2, 2, 4, 16] + await assert_err( + wallet1.mint(amount=sum(amts) + 1, split=amts), + "split must sum to amount", + ) @pytest.mark.asyncio async def test_mint_amounts_wrong_order(wallet1: Wallet): """Mint amount that is not part in 2^n""" + amts = [1, 2, 3] await assert_err( - wallet1.mint_amounts([1, 2, 3]), + wallet1.mint(amount=sum(amts), split=[1, 2, 3]), f"Can only mint amounts with 2^n up to {2**settings.max_order}.", ) diff --git a/tests/test_wallet_api.py b/tests/test_wallet_api.py index 4df94a9..1c7b287 100644 --- a/tests/test_wallet_api.py +++ b/tests/test_wallet_api.py @@ -1,7 +1,19 @@ +import asyncio + from fastapi.testclient import TestClient +from cashu.core.migrations import migrate_databases from cashu.core.settings import settings +from cashu.wallet import migrations from cashu.wallet.api.app import app +from cashu.wallet.wallet import Wallet + + +async def init_wallet(): + wallet = Wallet(settings.mint_host, "data/wallet", "wallet") + await migrate_databases(wallet.db, migrations) + await wallet.load_proofs() + return wallet def test_invoice(mint): @@ -15,6 +27,20 @@ def test_invoice(mint): assert response.json()["amount"] +def test_invoice_with_split(mint): + with TestClient(app) as client: + response = client.post("/invoice?amount=10&split=1") + assert response.status_code == 200 + if settings.lightning: + assert response.json()["invoice"] + else: + assert response.json()["balance"] + assert response.json()["amount"] + # wallet = asyncio.run(init_wallet()) + # asyncio.run(wallet.load_proofs()) + # assert wallet.proof_amounts.count(1) >= 10 + + def test_balance(): with TestClient(app) as client: response = client.get("/balance")