diff --git a/.env.example b/.env.example index b10432b..bf04238 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,7 @@ DEBUG = true +CASHU_DIR=~/.cashu + # WALLET MINT_HOST=127.0.0.1 diff --git a/core/base.py b/core/base.py index cf391b4..f1b4ebd 100644 --- a/core/base.py +++ b/core/base.py @@ -9,6 +9,7 @@ class Proof(BaseModel): C: str secret: str reserved: bool = False # whether this proof is reserved for sending + send_id: str = "" # unique ID of send attempt @classmethod def from_row(cls, row: Row): @@ -17,6 +18,7 @@ class Proof(BaseModel): C=row[1], secret=row[2], reserved=row[3] or False, + send_id=row[4] or "", ) @classmethod @@ -26,6 +28,7 @@ class Proof(BaseModel): C=d["C"], secret=d["secret"], reserved=d["reserved"] or False, + send_id=d["send_id"] or "", ) def __getitem__(self, key): diff --git a/core/settings.py b/core/settings.py index b8159aa..d24c0c4 100644 --- a/core/settings.py +++ b/core/settings.py @@ -1,9 +1,15 @@ +from pathlib import Path + from environs import Env # type: ignore env = Env() env.read_env() DEBUG = env.bool("DEBUG", default=False) +CASHU_DIR = env.str("CASHU_DIR", default="~/.cashu") +CASHU_DIR = CASHU_DIR.replace("~", str(Path.home())) +assert len(CASHU_DIR), "CASHU_DIR not defined" + LIGHTNING = env.bool("LIGHTNING", default=True) LIGHTNING_FEE_PERCENT = env.float("LIGHTNING_FEE_PERCENT", default=1.0) assert LIGHTNING_FEE_PERCENT >= 0, "LIGHTNING_FEE_PERCENT must be at least 0" diff --git a/lightning/lnbits.py b/lightning/lnbits.py index 94a49eb..5623b4f 100644 --- a/lightning/lnbits.py +++ b/lightning/lnbits.py @@ -8,8 +8,13 @@ import requests from core.settings import LNBITS_ENDPOINT, LNBITS_KEY -from .base import (InvoiceResponse, PaymentResponse, PaymentStatus, - StatusResponse, Wallet) +from .base import ( + InvoiceResponse, + PaymentResponse, + PaymentStatus, + StatusResponse, + Wallet, +) class LNbitsWallet(Wallet): diff --git a/mint/app.py b/mint/app.py index 360f318..c115c6c 100644 --- a/mint/app.py +++ b/mint/app.py @@ -11,7 +11,12 @@ from secp256k1 import PublicKey import core.settings as settings from core.base import CheckPayload, MeltPayload, MintPayloads, SplitPayload -from core.settings import MINT_PRIVATE_KEY, MINT_SERVER_HOST, MINT_SERVER_PORT +from core.settings import ( + CASHU_DIR, + MINT_PRIVATE_KEY, + MINT_SERVER_HOST, + MINT_SERVER_PORT, +) from lightning import WALLET from mint.ledger import Ledger from mint.migrations import m001_initial @@ -33,7 +38,7 @@ def startup(app: FastAPI): ) logger.info(f"Lightning balance: {balance} sat") - + logger.info(f"Data dir: {CASHU_DIR}") logger.info("Mint started.") diff --git a/setup.py b/setup.py index f9069e7..6563f5c 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ -import setuptools - from os import path +import setuptools + this_directory = path.abspath(path.dirname(__file__)) with open(path.join(this_directory, "README.md"), encoding="utf-8") as f: long_description = f.read() diff --git a/wallet/cashu.py b/wallet/cashu.py index 37bf8bf..4590f56 100755 --- a/wallet/cashu.py +++ b/wallet/cashu.py @@ -5,7 +5,8 @@ import base64 import json import math from functools import wraps -from pathlib import Path +from itertools import groupby +from operator import itemgetter import click from bech32 import bech32_decode, bech32_encode, convertbits @@ -15,7 +16,7 @@ from core.base import Proof from core.bolt11 import Invoice from core.helpers import fee_reserve from core.migrations import migrate_databases -from core.settings import LIGHTNING, MINT_URL +from core.settings import CASHU_DIR, LIGHTNING, MINT_URL from wallet import migrations from wallet.crud import get_reserved_proofs from wallet.wallet import Wallet as Wallet @@ -46,9 +47,7 @@ def cli( ctx.ensure_object(dict) ctx.obj["HOST"] = host ctx.obj["WALLET_NAME"] = walletname - ctx.obj["WALLET"] = Wallet( - ctx.obj["HOST"], f"{str(Path.home())}/.cashu/{walletname}", walletname - ) + ctx.obj["WALLET"] = Wallet(ctx.obj["HOST"], f"{CASHU_DIR}/{walletname}", walletname) pass @@ -106,8 +105,8 @@ async def send(ctx, amount: int): wallet.status() _, send_proofs = await wallet.split_to_send(wallet.proofs, amount) await wallet.set_reserved(send_proofs, reserved=True) - proofs_serialized = [p.dict() for p in send_proofs] - print(base64.urlsafe_b64encode(json.dumps(proofs_serialized).encode()).decode()) + token = await wallet.serialize_proofs(send_proofs) + print(token) wallet.status() @@ -146,6 +145,25 @@ async def burn(ctx, token: str, all: bool): wallet.status() +@cli.command("pending", help="Show pending tokens.") +@click.pass_context +@coro +async def pending(ctx): + wallet: Wallet = ctx.obj["WALLET"] + await init_wallet(wallet) + wallet.status() + reserved_proofs = await get_reserved_proofs(wallet.db) + if len(reserved_proofs): + sorted_proofs = sorted(reserved_proofs, key=itemgetter("send_id")) + for key, value in groupby(sorted_proofs, key=itemgetter("send_id")): + grouped_proofs = list(value) + token = await wallet.serialize_proofs(grouped_proofs) + print( + f"Amount: {sum([p['amount'] for p in grouped_proofs])} sat. ID: {key}" + ) + print(token) + + @cli.command("pay", help="Pay lightning invoice.") @click.argument("invoice", type=str) @click.pass_context diff --git a/wallet/crud.py b/wallet/crud.py index 555715f..55bb293 100644 --- a/wallet/crud.py +++ b/wallet/crud.py @@ -1,4 +1,4 @@ -import secrets +import time from typing import Optional from core.base import Proof @@ -14,14 +14,10 @@ async def store_proof( await (conn or db).execute( """ INSERT INTO proofs - (amount, C, secret) - VALUES (?, ?, ?) + (amount, C, secret, time_created) + VALUES (?, ?, ?, ?) """, - ( - proof.amount, - str(proof.C), - str(proof.secret), - ), + (proof.amount, str(proof.C), str(proof.secret), int(time.time())), ) @@ -69,24 +65,35 @@ async def invalidate_proof( await (conn or db).execute( """ INSERT INTO proofs_used - (amount, C, secret) - VALUES (?, ?, ?) + (amount, C, secret, time_used) + VALUES (?, ?, ?, ?) """, - ( - proof.amount, - str(proof.C), - str(proof.secret), - ), + (proof.amount, str(proof.C), str(proof.secret), int(time.time())), ) async def update_proof_reserved( proof: Proof, reserved: bool, - db: Database, + send_id: str = None, + db: Database = None, conn: Optional[Connection] = None, ): + clauses = [] + values = [] + clauses.append("reserved = ?") + values.append(reserved) + + if send_id: + clauses.append("send_id = ?") + values.append(send_id) + + if reserved: + # set the time of reserving + clauses.append("time_reserved = ?") + values.append(int(time.time())) + await (conn or db).execute( - "UPDATE proofs SET reserved = ? WHERE secret = ?", - (reserved, str(proof.secret)), + f"UPDATE proofs SET {', '.join(clauses)} WHERE secret = ?", + (*values, str(proof.secret)), ) diff --git a/wallet/migrations.py b/wallet/migrations.py index 8dc0229..269f495 100644 --- a/wallet/migrations.py +++ b/wallet/migrations.py @@ -68,3 +68,14 @@ async def m002_add_proofs_reserved(db): """ await db.execute("ALTER TABLE proofs ADD COLUMN reserved BOOL") + + +async def m003_add_proofs_sendid(db): + """ + Column with unique ID for each initiated send attempt + so proofs can be later grouped together for each send attempt. + """ + await db.execute("ALTER TABLE proofs ADD COLUMN send_id TEXT") + await db.execute("ALTER TABLE proofs ADD COLUMN time_created TIMESTAMP") + await db.execute("ALTER TABLE proofs ADD COLUMN time_reserved TIMESTAMP") + await db.execute("ALTER TABLE proofs_used ADD COLUMN time_used TIMESTAMP") diff --git a/wallet/wallet.py b/wallet/wallet.py index c34a946..58929c5 100644 --- a/wallet/wallet.py +++ b/wallet/wallet.py @@ -1,4 +1,7 @@ +import base64 +import json import random +import uuid from typing import List import requests @@ -186,6 +189,14 @@ class Wallet(LedgerAPI): raise Exception("could not pay invoice.") return status["paid"] + @staticmethod + async def serialize_proofs(proofs: List[Proof]): + proofs_serialized = [p.dict() for p in proofs] + token = base64.urlsafe_b64encode( + json.dumps(proofs_serialized).encode() + ).decode() + return token + async def split_to_send(self, proofs: List[Proof], amount): """Like self.split but only considers non-reserved tokens.""" if len([p for p in proofs if not p.reserved]) <= 0: @@ -194,9 +205,12 @@ class Wallet(LedgerAPI): async def set_reserved(self, proofs: List[Proof], reserved: bool): """Mark a proof as reserved to avoid reuse or delete marking.""" + uuid_str = str(uuid.uuid1()) for proof in proofs: proof.reserved = True - await update_proof_reserved(proof, reserved=reserved, db=self.db) + await update_proof_reserved( + proof, reserved=reserved, send_id=uuid_str, db=self.db + ) async def check_spendable(self, proofs): return await super().check_spendable(proofs)