mirror of
https://github.com/aljazceru/nutshell.git
synced 2025-12-20 18:44:20 +01:00
[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 <sihamon@proton.me>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}.",
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user