Issue #313: allow checking pending invoices (#493)

* Update deprecated datetime

* Add options to Invoices cli
With these options, we are able to return:
1) all invoices (this is the default);
2) pending invoices (paid False, out False);
3) paid invoices;
4) and unpaid invoices.

* make format

* Fix mypy error with datetime

* sort imports

* Remove unneeded unit when printing out info

* Fix wrong method doc

* Try to mint pending invoices

* make pre-commit

* Refactor --tests flag to --mint
The default will be false, i.e., if the user does not
pass in the --mint flag, it will not try to mint
the pending invoice.

---------

Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
This commit is contained in:
Guilherme Pereira
2024-04-03 16:51:15 +01:00
committed by GitHub
parent b8ad0e0a8f
commit 3b2f1aa6f4
9 changed files with 394 additions and 74 deletions

View File

@@ -255,9 +255,9 @@ class LedgerSpendingConditions:
# check if all secrets are P2PK
# NOTE: This is redundant, because P2PKSecret.from_secret() already checks for the kind
# Leaving it in for explicitness
if not all([
SecretKind(secret.kind) == SecretKind.P2PK for secret in p2pk_secrets
]):
if not all(
[SecretKind(secret.kind) == SecretKind.P2PK for secret in p2pk_secrets]
):
# not all secrets are P2PK
return True

View File

@@ -3,24 +3,26 @@
import asyncio
import os
import time
from datetime import datetime
from datetime import datetime, timezone
from functools import wraps
from itertools import groupby, islice
from operator import itemgetter
from os import listdir
from os.path import isdir, join
from typing import Optional
import click
from click import Context
from loguru import logger
from ...core.base import TokenV3, Unit
from ...core.base import Invoice, TokenV3, Unit
from ...core.helpers import sum_proofs
from ...core.logging import configure_logger
from ...core.settings import settings
from ...nostr.client.client import NostrClient
from ...tor.tor import TorProxy
from ...wallet.crud import (
get_lightning_invoice,
get_lightning_invoices,
get_reserved_proofs,
get_seed_and_mnemonic,
@@ -124,7 +126,7 @@ async def cli(ctx: Context, host: str, walletname: str, unit: str, tests: bool):
env_path = settings.env_file
else:
error_str += (
"Ceate a new Cashu config file here:"
"Create a new Cashu config file here:"
f" {os.path.join(settings.cashu_dir, '.env')}"
)
env_path = os.path.join(settings.cashu_dir, ".env")
@@ -158,7 +160,6 @@ async def cli(ctx: Context, host: str, walletname: str, unit: str, tests: bool):
assert wallet, "Wallet not found."
ctx.obj["WALLET"] = wallet
# await init_wallet(ctx.obj["WALLET"], load_proofs=False)
# only if a command is one of a subset that needs to specify a mint host
# if a mint host is already specified as an argument `host`, use it
@@ -166,7 +167,7 @@ async def cli(ctx: Context, host: str, walletname: str, unit: str, tests: bool):
return
# ------ MULTIUNIT ------- : Select a unit
ctx.obj["WALLET"] = await get_unit_wallet(ctx)
# ------ MUTLIMINT ------- : Select a wallet
# ------ MULTIMINT ------- : Select a wallet
# else: we ask the user to select one
ctx.obj["WALLET"] = await get_mint_wallet(
ctx
@@ -637,8 +638,8 @@ async def pending(ctx: Context, legacy, number: int, offset: int):
mint = [t.mint for t in tokenObj.token][0]
# token_hidden_secret = await wallet.serialize_proofs(grouped_proofs)
assert grouped_proofs[0].time_reserved
reserved_date = datetime.utcfromtimestamp(
int(grouped_proofs[0].time_reserved)
reserved_date = datetime.fromtimestamp(
int(grouped_proofs[0].time_reserved), timezone.utc
).strftime("%Y-%m-%d %H:%M:%S")
print(
f"#{i} Amount:"
@@ -692,39 +693,120 @@ async def locks(ctx):
return True
@cli.command("invoices", help="List of all pending invoices.")
@cli.command("invoices", help="List of all invoices.")
@click.option(
"-op",
"--only-paid",
"paid",
default=False,
is_flag=True,
help="Show only paid invoices.",
type=bool,
)
@click.option(
"-ou",
"--only-unpaid",
"unpaid",
default=False,
is_flag=True,
help="Show only unpaid invoices.",
type=bool,
)
@click.option(
"-p",
"--pending",
"pending",
default=False,
is_flag=True,
help="Show all pending invoices",
type=bool,
)
@click.option(
"--mint",
"-m",
is_flag=True,
default=False,
help="Try to mint pending invoices",
)
@click.pass_context
@coro
async def invoices(ctx):
async def invoices(ctx, paid: bool, unpaid: bool, pending: bool, mint: bool):
wallet: Wallet = ctx.obj["WALLET"]
invoices = await get_lightning_invoices(db=wallet.db)
if len(invoices):
print("")
print("--------------------------\n")
for invoice in invoices:
if paid and unpaid:
print("You should only choose one option: either --only-paid or --only-unpaid")
return
if mint:
await wallet.load_mint()
paid_arg = None
if unpaid:
paid_arg = False
elif paid:
paid_arg = True
invoices = await get_lightning_invoices(
db=wallet.db,
paid=paid_arg,
pending=pending or None,
)
if len(invoices) == 0:
print("No invoices found.")
return
async def _try_to_mint_pending_invoice(amount: int, id: str) -> Optional[Invoice]:
try:
await wallet.mint(amount, id)
return await get_lightning_invoice(db=wallet.db, id=id)
except Exception as e:
logger.error(f"Could not mint pending invoice [{id}]: {e}")
return None
def _print_invoice_info(invoice: Invoice):
print("\n--------------------------\n")
print(f"Amount: {abs(invoice.amount)}")
print(f"ID: {invoice.id}")
print(f"Paid: {invoice.paid}")
print(f"Incoming: {invoice.amount > 0}")
print(f"Amount: {abs(invoice.amount)}")
if invoice.id:
print(f"ID: {invoice.id}")
if invoice.preimage:
print(f"Preimage: {invoice.preimage}")
if invoice.time_created:
d = datetime.utcfromtimestamp(
int(float(invoice.time_created))
d = datetime.fromtimestamp(
int(float(invoice.time_created)), timezone.utc
).strftime("%Y-%m-%d %H:%M:%S")
print(f"Created: {d}")
print(f"Created at: {d}")
if invoice.time_paid:
d = datetime.utcfromtimestamp(int(float(invoice.time_paid))).strftime(
"%Y-%m-%d %H:%M:%S"
d = datetime.fromtimestamp(
(int(float(invoice.time_paid))), timezone.utc
).strftime("%Y-%m-%d %H:%M:%S")
print(f"Paid at: {d}")
print(f"\nPayment request: {invoice.bolt11}")
invoices_printed_count = 0
for invoice in invoices:
is_pending_invoice = invoice.out is False and invoice.paid is False
if is_pending_invoice and mint:
# Tries to mint pending invoice
updated_invoice = await _try_to_mint_pending_invoice(
invoice.amount, invoice.id
)
print(f"Paid: {d}")
print("")
print(f"Payment request: {invoice.bolt11}")
print("")
print("--------------------------\n")
else:
# If the mint ran successfully and we are querying for pending or unpaid invoices, do not print it
if pending or unpaid:
continue
# Otherwise, print the invoice with updated values
if updated_invoice:
invoice = updated_invoice
_print_invoice_info(invoice)
invoices_printed_count += 1
if invoices_printed_count == 0:
print("No invoices found.")
else:
print("\n--------------------------\n")
@cli.command("wallets", help="List of all available wallets.")

View File

@@ -67,10 +67,12 @@ async def get_reserved_proofs(
db: Database,
conn: Optional[Connection] = None,
) -> List[Proof]:
rows = await (conn or db).fetchall("""
rows = await (conn or db).fetchall(
"""
SELECT * from proofs
WHERE reserved
""")
"""
)
return [Proof.from_dict(dict(r)) for r in rows]
@@ -279,15 +281,22 @@ async def get_lightning_invoice(
async def get_lightning_invoices(
db: Database,
paid: Optional[bool] = None,
pending: Optional[bool] = None,
conn: Optional[Connection] = None,
) -> List[Invoice]:
clauses: List[Any] = []
values: List[Any] = []
if paid is not None:
if paid is not None and not pending:
clauses.append("paid = ?")
values.append(paid)
if pending:
clauses.append("paid = ?")
values.append(False)
clauses.append("out = ?")
values.append(False)
where = ""
if clauses:
where = f"WHERE {' AND '.join(clauses)}"

View File

@@ -2,17 +2,20 @@ from ..core.db import Connection, Database
async def m000_create_migrations_table(conn: Connection):
await conn.execute("""
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS dbversions (
db TEXT PRIMARY KEY,
version INT NOT NULL
)
""")
"""
)
async def m001_initial(db: Database):
async with db.connect() as conn:
await conn.execute(f"""
await conn.execute(
f"""
CREATE TABLE IF NOT EXISTS proofs (
amount {db.big_int} NOT NULL,
C TEXT NOT NULL,
@@ -21,9 +24,11 @@ async def m001_initial(db: Database):
UNIQUE (secret)
);
""")
"""
)
await conn.execute(f"""
await conn.execute(
f"""
CREATE TABLE IF NOT EXISTS proofs_used (
amount {db.big_int} NOT NULL,
C TEXT NOT NULL,
@@ -32,25 +37,30 @@ async def m001_initial(db: Database):
UNIQUE (secret)
);
""")
"""
)
await conn.execute("""
await conn.execute(
"""
CREATE VIEW IF NOT EXISTS balance AS
SELECT COALESCE(SUM(s), 0) AS balance FROM (
SELECT SUM(amount) AS s
FROM proofs
WHERE amount > 0
);
""")
"""
)
await conn.execute("""
await conn.execute(
"""
CREATE VIEW IF NOT EXISTS balance_used AS
SELECT COALESCE(SUM(s), 0) AS used FROM (
SELECT SUM(amount) AS s
FROM proofs_used
WHERE amount > 0
);
""")
"""
)
async def m002_add_proofs_reserved(db: Database):
@@ -96,7 +106,8 @@ async def m005_wallet_keysets(db: Database):
Stores mint keysets from different mints and epochs.
"""
async with db.connect() as conn:
await conn.execute(f"""
await conn.execute(
f"""
CREATE TABLE IF NOT EXISTS keysets (
id TEXT,
mint_url TEXT,
@@ -108,7 +119,8 @@ async def m005_wallet_keysets(db: Database):
UNIQUE (id, mint_url)
);
""")
"""
)
await conn.execute("ALTER TABLE proofs ADD COLUMN id TEXT")
await conn.execute("ALTER TABLE proofs_used ADD COLUMN id TEXT")
@@ -119,7 +131,8 @@ async def m006_invoices(db: Database):
Stores Lightning invoices.
"""
async with db.connect() as conn:
await conn.execute(f"""
await conn.execute(
f"""
CREATE TABLE IF NOT EXISTS invoices (
amount INTEGER NOT NULL,
pr TEXT NOT NULL,
@@ -132,7 +145,8 @@ async def m006_invoices(db: Database):
UNIQUE (hash)
);
""")
"""
)
async def m007_nostr(db: Database):
@@ -140,12 +154,14 @@ async def m007_nostr(db: Database):
Stores timestamps of nostr operations.
"""
async with db.connect() as conn:
await conn.execute("""
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS nostr (
type TEXT NOT NULL,
last TIMESTAMP DEFAULT NULL
)
""")
"""
)
await conn.execute(
"""
INSERT INTO nostr
@@ -172,14 +188,16 @@ async def m009_privatekey_and_determinstic_key_derivation(db: Database):
await conn.execute("ALTER TABLE keysets ADD COLUMN counter INTEGER DEFAULT 0")
await conn.execute("ALTER TABLE proofs ADD COLUMN derivation_path TEXT")
await conn.execute("ALTER TABLE proofs_used ADD COLUMN derivation_path TEXT")
await conn.execute("""
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS seed (
seed TEXT NOT NULL,
mnemonic TEXT NOT NULL,
UNIQUE (seed, mnemonic)
);
""")
"""
)
# await conn.execute("INSERT INTO secret_derivation (counter) VALUES (0)")

View File

@@ -133,9 +133,12 @@ class WalletP2PK(SupportsPrivateKey, SupportsDb):
return outputs
# if any of the proofs provided require SIG_ALL, we must provide it
if any([
P2PKSecret.deserialize(p.secret).sigflag == SigFlags.SIG_ALL for p in proofs
]):
if any(
[
P2PKSecret.deserialize(p.secret).sigflag == SigFlags.SIG_ALL
for p in proofs
]
):
outputs = await self.add_p2pk_witnesses_to_outputs(outputs)
return outputs
@@ -181,9 +184,9 @@ class WalletP2PK(SupportsPrivateKey, SupportsDb):
return proofs
logger.debug("Spending conditions detected.")
# P2PK signatures
if all([
Secret.deserialize(p.secret).kind == SecretKind.P2PK.value for p in proofs
]):
if all(
[Secret.deserialize(p.secret).kind == SecretKind.P2PK.value for p in proofs]
):
logger.debug("P2PK redemption detected.")
proofs = await self.add_p2pk_witnesses_to_proofs(proofs)

View File

@@ -1488,7 +1488,7 @@ class Wallet(LedgerAPI, WalletP2PK, WalletHTLC, WalletSecrets):
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 True.
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.

View File

@@ -4,7 +4,7 @@ from httpx import Response
from cashu.core.base import Amount, MeltQuote, Unit
from cashu.core.settings import settings
from cashu.lightning.blink import MINIMUM_FEE_MSAT, BlinkWallet
from cashu.lightning.blink import MINIMUM_FEE_MSAT, BlinkWallet # type: ignore
settings.mint_blink_key = "123"
blink = BlinkWallet(unit=Unit.sat)

View File

@@ -28,6 +28,23 @@ def get_bolt11_and_invoice_id_from_invoice_command(output: str) -> Tuple[str, st
return invoice, invoice_id
def get_invoice_from_invoices_command(output: str) -> dict[str, str]:
splitted = output.split("\n")
removed_empty_and_hiphens = [
value for value in splitted if value and not value.startswith("-----")
]
dict_output = {
f"{value.split(': ')[0]}": value.split(": ")[1]
for value in removed_empty_and_hiphens
}
return dict_output
async def reset_invoices(wallet: Wallet):
await wallet.db.execute("DELETE FROM invoices")
async def init_wallet():
settings.debug = False
wallet = await Wallet.with_db(
@@ -158,6 +175,197 @@ def test_invoice_with_split(mint, cli_prefix):
wallet = asyncio.run(init_wallet())
assert wallet.proof_amounts.count(1) >= 10
@pytest.mark.skipif(not is_fake, reason="only on fakewallet")
def test_invoices_with_minting(cli_prefix):
# arrange
wallet1 = asyncio.run(init_wallet())
asyncio.run(reset_invoices(wallet=wallet1))
invoice = asyncio.run(wallet1.request_mint(64))
# act
runner = CliRunner()
result = runner.invoke(
cli,
[*cli_prefix, "invoices", "--mint"],
)
# assert
print("INVOICES --mint")
assert result.exception is None
assert result.exit_code == 0
assert "No invoices found." not in result.output
assert "ID" in result.output
assert "Paid" in result.output
assert get_invoice_from_invoices_command(result.output)["ID"] == invoice.id
assert get_invoice_from_invoices_command(result.output)["Paid"] == "True"
def test_invoices_without_minting(cli_prefix):
# arrange
wallet1 = asyncio.run(init_wallet())
asyncio.run(reset_invoices(wallet=wallet1))
invoice = asyncio.run(wallet1.request_mint(64))
# act
runner = CliRunner()
result = runner.invoke(
cli,
[*cli_prefix, "invoices"],
)
# assert
print("INVOICES")
assert result.exception is None
assert result.exit_code == 0
assert "No invoices found." not in result.output
assert "ID" in result.output
assert "Paid" in result.output
assert get_invoice_from_invoices_command(result.output)["ID"] == invoice.id
assert get_invoice_from_invoices_command(result.output)["Paid"] == str(invoice.paid)
@pytest.mark.skipif(not is_fake, reason="only on fakewallet")
def test_invoices_with_onlypaid_option(cli_prefix):
# arrange
wallet1 = asyncio.run(init_wallet())
asyncio.run(reset_invoices(wallet=wallet1))
asyncio.run(wallet1.request_mint(64))
# act
runner = CliRunner()
result = runner.invoke(
cli,
[*cli_prefix, "invoices", "--only-paid", "--mint"],
)
# assert
print("INVOICES --only-paid --mint")
assert result.exception is None
assert result.exit_code == 0
assert "No invoices found." in result.output
def test_invoices_with_onlypaid_option_without_minting(cli_prefix):
# arrange
wallet1 = asyncio.run(init_wallet())
asyncio.run(reset_invoices(wallet=wallet1))
asyncio.run(wallet1.request_mint(64))
# act
runner = CliRunner()
result = runner.invoke(
cli,
[*cli_prefix, "invoices", "--only-paid"],
)
# assert
print("INVOICES --only-paid")
assert result.exception is None
assert result.exit_code == 0
assert "No invoices found." in result.output
@pytest.mark.skipif(not is_fake, reason="only on fakewallet")
def test_invoices_with_onlyunpaid_option(cli_prefix):
# arrange
wallet1 = asyncio.run(init_wallet())
asyncio.run(reset_invoices(wallet=wallet1))
asyncio.run(wallet1.request_mint(64))
# act
runner = CliRunner()
result = runner.invoke(
cli,
[*cli_prefix, "invoices", "--only-unpaid", "--mint"],
)
# assert
print("INVOICES --only-unpaid --mint")
assert result.exception is None
assert result.exit_code == 0
assert "No invoices found." in result.output
def test_invoices_with_onlyunpaid_option_without_minting(cli_prefix):
# arrange
wallet1 = asyncio.run(init_wallet())
asyncio.run(reset_invoices(wallet=wallet1))
invoice = asyncio.run(wallet1.request_mint(64))
# act
runner = CliRunner()
result = runner.invoke(
cli,
[*cli_prefix, "invoices", "--only-unpaid"],
)
# assert
print("INVOICES --only-unpaid")
assert result.exception is None
assert result.exit_code == 0
assert "No invoices found." not in result.output
assert "ID" in result.output
assert "Paid" in result.output
assert get_invoice_from_invoices_command(result.output)["ID"] == invoice.id
assert get_invoice_from_invoices_command(result.output)["Paid"] == str(invoice.paid)
def test_invoices_with_both_onlypaid_and_onlyunpaid_options(cli_prefix):
runner = CliRunner()
result = runner.invoke(
cli,
[*cli_prefix, "invoices", "--only-paid", "--only-unpaid"],
)
assert result.exception is None
print("INVOICES --only-paid --only-unpaid")
assert result.exit_code == 0
assert (
"You should only choose one option: either --only-paid or --only-unpaid"
in result.output
)
@pytest.mark.skipif(not is_fake, reason="only on fakewallet")
def test_invoices_with_pending_option(cli_prefix):
# arrange
wallet1 = asyncio.run(init_wallet())
asyncio.run(reset_invoices(wallet=wallet1))
asyncio.run(wallet1.request_mint(64))
# act
runner = CliRunner()
result = runner.invoke(
cli,
[*cli_prefix, "invoices", "--pending", "--mint"],
)
# assert
print("INVOICES --pending --mint")
assert result.exception is None
assert result.exit_code == 0
assert "No invoices found." in result.output
def test_invoices_with_pending_option_without_minting(cli_prefix):
# arrange
wallet1 = asyncio.run(init_wallet())
asyncio.run(reset_invoices(wallet=wallet1))
invoice = asyncio.run(wallet1.request_mint(64))
# act
runner = CliRunner()
result = runner.invoke(
cli,
[*cli_prefix, "invoices", "--pending"],
)
# assert
print("INVOICES --pending")
assert result.exception is None
assert result.exit_code == 0
assert "No invoices found." not in result.output
assert "ID" in result.output
assert "Paid" in result.output
assert get_invoice_from_invoices_command(result.output)["ID"] == invoice.id
assert get_invoice_from_invoices_command(result.output)["Paid"] == str(invoice.paid)
def test_wallets(cli_prefix):
runner = CliRunner()

View File

@@ -231,9 +231,9 @@ async def test_p2pk_locktime_with_second_refund_pubkey(
secret_lock = await wallet1.create_p2pk_lock(
garbage_pubkey.serialize().hex(), # create lock to unspendable pubkey
locktime_seconds=2, # locktime
tags=Tags([
["refund", pubkey_wallet2, pubkey_wallet1]
]), # multiple refund pubkeys
tags=Tags(
[["refund", pubkey_wallet2, pubkey_wallet1]]
), # multiple refund pubkeys
) # sender side
_, send_proofs = await wallet1.split_to_send(
wallet1.proofs, 8, secret_lock=secret_lock
@@ -388,9 +388,9 @@ async def test_p2pk_multisig_with_wrong_first_private_key(
def test_tags():
tags = Tags([
["key1", "value1"], ["key2", "value2", "value2_1"], ["key2", "value3"]
])
tags = Tags(
[["key1", "value1"], ["key2", "value2", "value2_1"], ["key2", "value3"]]
)
assert tags.get_tag("key1") == "value1"
assert tags["key1"] == "value1"
assert tags.get_tag("key2") == "value2"