mirror of
https://github.com/aljazceru/nutshell.git
synced 2025-12-20 10:34: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,
|
default=None,
|
||||||
description="Mint URL to create an invoice at (None for default mint)",
|
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
|
global wallet
|
||||||
wallet = await load_mint(wallet, mint)
|
wallet = await load_mint(wallet, mint)
|
||||||
initial_balance = wallet.available_balance
|
initial_balance = wallet.available_balance
|
||||||
if not settings.lightning:
|
if not settings.lightning:
|
||||||
r = await wallet.mint(amount)
|
r = await wallet.mint(amount, split=optional_split)
|
||||||
return InvoiceResponse(
|
return InvoiceResponse(
|
||||||
amount=amount,
|
amount=amount,
|
||||||
balance=wallet.available_balance,
|
balance=wallet.available_balance,
|
||||||
@@ -114,12 +126,13 @@ async def invoice(
|
|||||||
elif amount and not hash:
|
elif amount and not hash:
|
||||||
invoice = await wallet.request_mint(amount)
|
invoice = await wallet.request_mint(amount)
|
||||||
return InvoiceResponse(
|
return InvoiceResponse(
|
||||||
|
amount=amount,
|
||||||
invoice=invoice,
|
invoice=invoice,
|
||||||
balance=wallet.available_balance,
|
balance=wallet.available_balance,
|
||||||
initial_balance=initial_balance,
|
initial_balance=initial_balance,
|
||||||
)
|
)
|
||||||
elif amount and hash:
|
elif amount and hash:
|
||||||
await wallet.mint(amount, hash)
|
await wallet.mint(amount, split=optional_split, hash=hash)
|
||||||
return InvoiceResponse(
|
return InvoiceResponse(
|
||||||
amount=amount,
|
amount=amount,
|
||||||
hash=hash,
|
hash=hash,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from os.path import isdir, join
|
|||||||
|
|
||||||
import click
|
import click
|
||||||
from click import Context
|
from click import Context
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from ...core.base import TokenV3
|
from ...core.base import TokenV3
|
||||||
from ...core.helpers import sum_proofs
|
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.")
|
@cli.command("invoice", help="Create Lighting invoice.")
|
||||||
@click.argument("amount", type=int)
|
@click.argument("amount", type=int)
|
||||||
@click.option("--hash", default="", help="Hash of the paid invoice.", type=str)
|
@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
|
@click.pass_context
|
||||||
@coro
|
@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"]
|
wallet: Wallet = ctx.obj["WALLET"]
|
||||||
await wallet.load_mint()
|
await wallet.load_mint()
|
||||||
wallet.status()
|
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:
|
if not settings.lightning:
|
||||||
r = await wallet.mint(amount)
|
r = await wallet.mint(amount)
|
||||||
|
# user requests an invoice
|
||||||
elif amount and not hash:
|
elif amount and not hash:
|
||||||
invoice = await wallet.request_mint(amount)
|
invoice = await wallet.request_mint(amount)
|
||||||
if invoice.pr:
|
if invoice.pr:
|
||||||
@@ -169,21 +187,25 @@ async def invoice(ctx: Context, amount: int, hash: str):
|
|||||||
while time.time() < check_until and not paid:
|
while time.time() < check_until and not paid:
|
||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
try:
|
try:
|
||||||
await wallet.mint(amount, invoice.hash)
|
await wallet.mint(amount, split=optional_split, hash=invoice.hash)
|
||||||
paid = True
|
paid = True
|
||||||
print(" Invoice paid.")
|
print(" Invoice paid.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# TODO: user error codes!
|
# TODO: user error codes!
|
||||||
if str(e) == "Error: Lightning invoice not paid yet.":
|
if "invoice not paid" in str(e):
|
||||||
print(".", end="", flush=True)
|
print(".", end="", flush=True)
|
||||||
continue
|
continue
|
||||||
|
else:
|
||||||
|
print(f"Error: {str(e)}")
|
||||||
if not paid:
|
if not paid:
|
||||||
print("\n")
|
print("\n")
|
||||||
print(
|
print(
|
||||||
"Invoice is not paid yet, stopping check. Use the command above to recheck after the invoice has been paid."
|
"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:
|
elif amount and hash:
|
||||||
await wallet.mint(amount, hash)
|
await wallet.mint(amount, split=optional_split, hash=hash)
|
||||||
wallet.status()
|
wallet.status()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ class LedgerAPI:
|
|||||||
self, promises: List[BlindedSignature], secrets: List[str], rs: List[PrivateKey]
|
self, promises: List[BlindedSignature], secrets: List[str], rs: List[PrivateKey]
|
||||||
):
|
):
|
||||||
"""Returns proofs of promise from promises. Wants secrets and blinding factors rs."""
|
"""Returns proofs of promise from promises. Wants secrets and blinding factors rs."""
|
||||||
|
logger.trace(f"Constructing proofs.")
|
||||||
proofs: List[Proof] = []
|
proofs: List[Proof] = []
|
||||||
for promise, secret, r in zip(promises, secrets, rs):
|
for promise, secret, r in zip(promises, secrets, rs):
|
||||||
logger.trace(f"Creating proof with keyset {self.keyset_id} = {promise.id}")
|
logger.trace(f"Creating proof with keyset {self.keyset_id} = {promise.id}")
|
||||||
@@ -131,6 +132,7 @@ class LedgerAPI:
|
|||||||
secret=secret,
|
secret=secret,
|
||||||
)
|
)
|
||||||
proofs.append(proof)
|
proofs.append(proof)
|
||||||
|
logger.trace(f"Constructed {len(proofs)} proofs.")
|
||||||
return proofs
|
return proofs
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -202,6 +204,7 @@ class LedgerAPI:
|
|||||||
def _construct_outputs(amounts: List[int], secrets: List[str]):
|
def _construct_outputs(amounts: List[int], secrets: List[str]):
|
||||||
"""Takes a list of amounts and secrets and returns outputs.
|
"""Takes a list of amounts and secrets and returns outputs.
|
||||||
Outputs are blinded messages `outputs` and blinding factors `rs`"""
|
Outputs are blinded messages `outputs` and blinding factors `rs`"""
|
||||||
|
logger.trace(f"Constructing outputs.")
|
||||||
assert len(amounts) == len(
|
assert len(amounts) == len(
|
||||||
secrets
|
secrets
|
||||||
), f"len(amounts)={len(amounts)} not equal to len(secrets)={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()
|
amount=amount, B_=B_.serialize().hex()
|
||||||
)
|
)
|
||||||
outputs.append(output)
|
outputs.append(output)
|
||||||
|
logger.trace(f"Constructed {len(outputs)} outputs.")
|
||||||
return outputs, rs
|
return outputs, rs
|
||||||
|
|
||||||
async def _check_used_secrets(self, secrets):
|
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:
|
for s in secrets:
|
||||||
if await secret_used(s, db=self.db):
|
if await secret_used(s, db=self.db):
|
||||||
raise Exception(f"secret already used: {s}")
|
raise Exception(f"secret already used: {s}")
|
||||||
|
logger.trace("Secret check complete.")
|
||||||
|
|
||||||
def generate_secrets(self, secret, n):
|
def generate_secrets(self, secret, n):
|
||||||
"""`secret` is the base string that will be tweaked n times"""
|
"""`secret` is the base string that will be tweaked n times"""
|
||||||
@@ -303,6 +310,7 @@ class LedgerAPI:
|
|||||||
@async_set_requests
|
@async_set_requests
|
||||||
async def request_mint(self, amount):
|
async def request_mint(self, amount):
|
||||||
"""Requests a mint from the server and returns Lightning invoice."""
|
"""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 = self.s.get(self.url + "/mint", params={"amount": amount})
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return_dict = resp.json()
|
return_dict = resp.json()
|
||||||
@@ -317,6 +325,7 @@ class LedgerAPI:
|
|||||||
await self._check_used_secrets(secrets)
|
await self._check_used_secrets(secrets)
|
||||||
outputs, rs = self._construct_outputs(amounts, secrets)
|
outputs, rs = self._construct_outputs(amounts, secrets)
|
||||||
outputs_payload = PostMintRequest(outputs=outputs)
|
outputs_payload = PostMintRequest(outputs=outputs)
|
||||||
|
logger.trace("Checking Lightning invoice. POST /mint")
|
||||||
resp = self.s.post(
|
resp = self.s.post(
|
||||||
self.url + "/mint",
|
self.url + "/mint",
|
||||||
json=outputs_payload.dict(),
|
json=outputs_payload.dict(),
|
||||||
@@ -328,6 +337,7 @@ class LedgerAPI:
|
|||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
reponse_dict = resp.json()
|
reponse_dict = resp.json()
|
||||||
self.raise_on_error(reponse_dict)
|
self.raise_on_error(reponse_dict)
|
||||||
|
logger.trace("Lightning invoice checked. POST /mint")
|
||||||
try:
|
try:
|
||||||
# backwards compatibility: parse promises < 0.8.0 with no "promises" field
|
# backwards compatibility: parse promises < 0.8.0 with no "promises" field
|
||||||
promises = PostMintResponseLegacy.parse_obj(reponse_dict).__root__
|
promises = PostMintResponseLegacy.parse_obj(reponse_dict).__root__
|
||||||
@@ -498,20 +508,37 @@ class Wallet(LedgerAPI):
|
|||||||
await store_lightning_invoice(db=self.db, invoice=invoice)
|
await store_lightning_invoice(db=self.db, invoice=invoice)
|
||||||
return 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.
|
"""Mint tokens of a specific amount after an invoice has been paid.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
amount (int): Total amount of tokens to be minted
|
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).
|
hash (Optional[str], optional): Hash for looking up the paid Lightning invoice. Defaults to None (for testing with LIGHTNING=False).
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
|
Exception: Raises exception if `amounts` does not sum to `amount` or has unsupported value.
|
||||||
Exception: Raises exception if no proofs have been provided
|
Exception: Raises exception if no proofs have been provided
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List[Proof]: Newly minted proofs.
|
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)
|
proofs = await super().mint(split, hash)
|
||||||
if proofs == []:
|
if proofs == []:
|
||||||
raise Exception("received no proofs.")
|
raise Exception("received no proofs.")
|
||||||
@@ -523,35 +550,6 @@ class Wallet(LedgerAPI):
|
|||||||
self.proofs += proofs
|
self.proofs += proofs
|
||||||
return 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(
|
async def redeem(
|
||||||
self,
|
self,
|
||||||
proofs: List[Proof],
|
proofs: List[Proof],
|
||||||
|
|||||||
@@ -70,11 +70,24 @@ def test_invoice(mint, cli_prefix):
|
|||||||
assert result.exception is None
|
assert result.exception is None
|
||||||
print("INVOICE")
|
print("INVOICE")
|
||||||
print(result.output)
|
print(result.output)
|
||||||
# wallet = asyncio.run(init_wallet())
|
wallet = asyncio.run(init_wallet())
|
||||||
# assert f"Balance: {wallet.available_balance} sat" in result.output
|
# assert wallet.available_balance >= 1000
|
||||||
|
assert f"Balance: {wallet.available_balance} sat" in result.output
|
||||||
assert result.exit_code == 0
|
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
|
@pytest.mark.asyncio
|
||||||
def test_wallets(cli_prefix):
|
def test_wallets(cli_prefix):
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
|
|||||||
@@ -97,16 +97,28 @@ async def test_mint(wallet1: Wallet):
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mint_amounts(wallet1: Wallet):
|
async def test_mint_amounts(wallet1: Wallet):
|
||||||
"""Mint predefined amounts"""
|
"""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.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
|
@pytest.mark.asyncio
|
||||||
async def test_mint_amounts_wrong_order(wallet1: Wallet):
|
async def test_mint_amounts_wrong_order(wallet1: Wallet):
|
||||||
"""Mint amount that is not part in 2^n"""
|
"""Mint amount that is not part in 2^n"""
|
||||||
|
amts = [1, 2, 3]
|
||||||
await assert_err(
|
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}.",
|
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 fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from cashu.core.migrations import migrate_databases
|
||||||
from cashu.core.settings import settings
|
from cashu.core.settings import settings
|
||||||
|
from cashu.wallet import migrations
|
||||||
from cashu.wallet.api.app import app
|
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):
|
def test_invoice(mint):
|
||||||
@@ -15,6 +27,20 @@ def test_invoice(mint):
|
|||||||
assert response.json()["amount"]
|
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():
|
def test_balance():
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
response = client.get("/balance")
|
response = client.get("/balance")
|
||||||
|
|||||||
Reference in New Issue
Block a user