Merge pull request #11 from callebtc/show_pending_tokens

Show pending tokens
This commit is contained in:
calle
2022-09-22 14:24:03 +03:00
committed by GitHub
15 changed files with 207 additions and 58 deletions

View File

@@ -1,4 +1,6 @@
DEBUG = true
DEBUG=FALSE
CASHU_DIR=~/.cashu
# WALLET

View File

@@ -13,7 +13,9 @@ The easiest way to use Cashu is to install the package it via pip:
pip install cashu
```
To update Cashu, use `pip install cashu -U`. You can skip the entire next section about Poetry and jump right to [Using Cashu](#using-cashu).
To update Cashu, use `pip install cashu -U`. If you have problems running the command above on Ubuntu, run `sudo apt install -y pip pkg-config libpq-dev`.
You can skip the entire next section about Poetry and jump right to [Using Cashu](#using-cashu).
### Hard install: Poetry
These steps help you install Python via pyenv and Poetry. If you already have Poetry running on your computer, you can skip this step and jump right to [Install Cashu](#install-cashu).

View File

@@ -6,9 +6,12 @@ from pydantic import BaseModel
class Proof(BaseModel):
amount: int
C: str
secret: str
C: str
reserved: bool = False # whether this proof is reserved for sending
send_id: str = "" # unique ID of send attempt
time_created: str = ""
time_reserved: str = ""
@classmethod
def from_row(cls, row: Row):
@@ -17,17 +20,28 @@ class Proof(BaseModel):
C=row[1],
secret=row[2],
reserved=row[3] or False,
send_id=row[4] or "",
time_created=row[5] or "",
time_reserved=row[6] or "",
)
@classmethod
def from_dict(cls, d: dict):
assert "secret" in d, "no secret in proof"
assert "amount" in d, "no amount in proof"
return cls(
amount=d["amount"],
C=d["C"],
secret=d["secret"],
reserved=d["reserved"] or False,
amount=d.get("amount"),
C=d.get("C"),
secret=d.get("secret"),
reserved=d.get("reserved") or False,
send_id=d.get("send_id") or "",
time_created=d.get("time_created") or "",
time_reserved=d.get("time_reserved") or "",
)
def to_dict(self):
return dict(amount=self.amount, secret=self.secret, C=self.C)
def __getitem__(self, key):
return self.__getattribute__(key)

View File

@@ -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"

View File

@@ -11,7 +11,8 @@ 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 +34,7 @@ def startup(app: FastAPI):
)
logger.info(f"Lightning balance: {balance} sat")
logger.info(f"Data dir: {CASHU_DIR}")
logger.info("Mint started.")

View File

@@ -13,14 +13,9 @@ from core.secp import PrivateKey, PublicKey
from core.settings import LIGHTNING, MAX_ORDER
from core.split import amount_split
from lightning import WALLET
from mint.crud import (
get_lightning_invoice,
get_proofs_used,
invalidate_proof,
store_lightning_invoice,
store_promise,
update_lightning_invoice,
)
from mint.crud import (get_lightning_invoice, get_proofs_used,
invalidate_proof, store_lightning_invoice,
store_promise, update_lightning_invoice)
class Ledger:

46
poetry.lock generated
View File

@@ -323,6 +323,25 @@ docs = ["alabaster (==0.7.12)", "autodocsumm (==0.2.9)", "sphinx (==5.1.1)", "sp
lint = ["flake8 (==5.0.4)", "flake8-bugbear (==22.9.11)", "mypy (==0.971)", "pre-commit (>=2.4,<3.0)"]
tests = ["pytest", "pytz", "simplejson"]
[[package]]
name = "mypy"
version = "0.971"
description = "Optional static typing for Python"
category = "dev"
optional = false
python-versions = ">=3.6"
[package.dependencies]
mypy-extensions = ">=0.4.3"
tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
typed-ast = {version = ">=1.4.0,<2", markers = "python_version < \"3.8\""}
typing-extensions = ">=3.10"
[package.extras]
dmypy = ["psutil (>=4.0)"]
python2 = ["typed-ast (>=1.4.0,<2)"]
reports = ["lxml"]
[[package]]
name = "mypy-extensions"
version = "0.4.3"
@@ -690,7 +709,7 @@ testing = ["func-timeout", "jaraco.itertools", "pytest (>=6)", "pytest-black (>=
[metadata]
lock-version = "1.1"
python-versions = "^3.7"
content-hash = "68f6f6e33100fdd1bd86879f8c146a96a5ee822b0a6e8b9ec27205f6eab1fcd8"
content-hash = "d5ee88384ec1ec1774f9fab845d7e8ac6e8ab1859506bcce178f6783b98e535c"
[metadata.files]
anyio = [
@@ -915,6 +934,31 @@ marshmallow = [
{file = "marshmallow-3.18.0-py3-none-any.whl", hash = "sha256:35e02a3a06899c9119b785c12a22f4cda361745d66a71ab691fd7610202ae104"},
{file = "marshmallow-3.18.0.tar.gz", hash = "sha256:6804c16114f7fce1f5b4dadc31f4674af23317fcc7f075da21e35c1a35d781f7"},
]
mypy = [
{file = "mypy-0.971-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f2899a3cbd394da157194f913a931edfd4be5f274a88041c9dc2d9cdcb1c315c"},
{file = "mypy-0.971-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:98e02d56ebe93981c41211c05adb630d1d26c14195d04d95e49cd97dbc046dc5"},
{file = "mypy-0.971-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:19830b7dba7d5356d3e26e2427a2ec91c994cd92d983142cbd025ebe81d69cf3"},
{file = "mypy-0.971-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:02ef476f6dcb86e6f502ae39a16b93285fef97e7f1ff22932b657d1ef1f28655"},
{file = "mypy-0.971-cp310-cp310-win_amd64.whl", hash = "sha256:25c5750ba5609a0c7550b73a33deb314ecfb559c350bb050b655505e8aed4103"},
{file = "mypy-0.971-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d3348e7eb2eea2472db611486846742d5d52d1290576de99d59edeb7cd4a42ca"},
{file = "mypy-0.971-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3fa7a477b9900be9b7dd4bab30a12759e5abe9586574ceb944bc29cddf8f0417"},
{file = "mypy-0.971-cp36-cp36m-win_amd64.whl", hash = "sha256:2ad53cf9c3adc43cf3bea0a7d01a2f2e86db9fe7596dfecb4496a5dda63cbb09"},
{file = "mypy-0.971-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:855048b6feb6dfe09d3353466004490b1872887150c5bb5caad7838b57328cc8"},
{file = "mypy-0.971-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:23488a14a83bca6e54402c2e6435467a4138785df93ec85aeff64c6170077fb0"},
{file = "mypy-0.971-cp37-cp37m-win_amd64.whl", hash = "sha256:4b21e5b1a70dfb972490035128f305c39bc4bc253f34e96a4adf9127cf943eb2"},
{file = "mypy-0.971-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:9796a2ba7b4b538649caa5cecd398d873f4022ed2333ffde58eaf604c4d2cb27"},
{file = "mypy-0.971-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5a361d92635ad4ada1b1b2d3630fc2f53f2127d51cf2def9db83cba32e47c856"},
{file = "mypy-0.971-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b793b899f7cf563b1e7044a5c97361196b938e92f0a4343a5d27966a53d2ec71"},
{file = "mypy-0.971-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d1ea5d12c8e2d266b5fb8c7a5d2e9c0219fedfeb493b7ed60cd350322384ac27"},
{file = "mypy-0.971-cp38-cp38-win_amd64.whl", hash = "sha256:23c7ff43fff4b0df93a186581885c8512bc50fc4d4910e0f838e35d6bb6b5e58"},
{file = "mypy-0.971-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1f7656b69974a6933e987ee8ffb951d836272d6c0f81d727f1d0e2696074d9e6"},
{file = "mypy-0.971-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d2022bfadb7a5c2ef410d6a7c9763188afdb7f3533f22a0a32be10d571ee4bbe"},
{file = "mypy-0.971-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ef943c72a786b0f8d90fd76e9b39ce81fb7171172daf84bf43eaf937e9f220a9"},
{file = "mypy-0.971-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d744f72eb39f69312bc6c2abf8ff6656973120e2eb3f3ec4f758ed47e414a4bf"},
{file = "mypy-0.971-cp39-cp39-win_amd64.whl", hash = "sha256:77a514ea15d3007d33a9e2157b0ba9c267496acf12a7f2b9b9f8446337aac5b0"},
{file = "mypy-0.971-py3-none-any.whl", hash = "sha256:0d054ef16b071149917085f51f89555a576e2618d5d9dd70bd6eea6410af3ac9"},
{file = "mypy-0.971.tar.gz", hash = "sha256:40b0f21484238269ae6a57200c807d80debc6459d444c0489a102d7c6a75fa56"},
]
mypy-extensions = [
{file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
{file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "cashu"
version = "0.0.1"
version = "0.1.8"
description = "Ecash wallet and mint."
authors = ["calle <callebtc@protonmail.com>"]
license = "MIT"
@@ -36,6 +36,11 @@ secp256k1 = "^0.14.0"
black = {version = "^22.8.0", allow-prereleases = true}
isort = "^5.10.1"
[tool.poetry.group.dev.dependencies]
mypy = "^0.971"
black = {version = "^22.8.0", allow-prereleases = true}
isort = "^5.10.1"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"

View File

@@ -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()
@@ -13,7 +13,7 @@ entry_points = {"console_scripts": ["cashu = wallet.cashu:cli"]}
setuptools.setup(
name="cashu",
version="0.1.7",
version="0.1.8",
description="Ecash wallet and mint with Bitcoin Lightning support",
long_description=long_description,
long_description_content_type="text/markdown",

View File

@@ -4,8 +4,10 @@ import asyncio
import base64
import json
import math
from datetime import datetime
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 +17,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, DEBUG, LIGHTNING, MINT_URL
from wallet import migrations
from wallet.crud import get_reserved_proofs
from wallet.wallet import Wallet as Wallet
@@ -38,17 +40,11 @@ class NaturalOrderGroup(click.Group):
@click.option("--host", "-h", default=MINT_URL, help="Mint address.")
@click.option("--wallet", "-w", "walletname", default="wallet", help="Wallet to use.")
@click.pass_context
def cli(
ctx,
host: str,
walletname: str,
):
def cli(ctx, host: str, walletname: str):
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 +102,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()
@@ -127,17 +123,27 @@ async def receive(ctx, token: str):
@cli.command("burn", help="Burn spent tokens.")
@click.argument("token", required=False, type=str)
@click.option("--all", "-a", default=False, is_flag=True, help="Burn all spent tokens.")
@click.option(
"--force", "-f", default=False, is_flag=True, help="Force check on all tokens."
)
@click.pass_context
@coro
async def burn(ctx, token: str, all: bool):
async def burn(ctx, token: str, all: bool, force: bool):
wallet: Wallet = ctx.obj["WALLET"]
await init_wallet(wallet)
if not (all or token) or (token and all):
print("Error: enter a token or use --all to burn all pending tokens.")
if not (all or token or force) or (token and all):
print(
"Error: enter a token or use --all to burn all pending tokens or --force to check all tokens."
)
return
if all:
# check only those who are flagged as reserved
proofs = await get_reserved_proofs(wallet.db)
if force:
# check all proofs in db
proofs = wallet.proofs
else:
# check only the specified ones
proofs = [
Proof.from_dict(p) for p in json.loads(base64.urlsafe_b64decode(token))
]
@@ -146,6 +152,29 @@ 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)
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)
reserved_date = datetime.utcfromtimestamp(
int(grouped_proofs[0].time_reserved)
).strftime("%Y-%m-%d %H:%M:%S")
print(
f"Amount: {sum([p['amount'] for p in grouped_proofs])} sat Sent: {reserved_date} ID: {key}\n"
)
print(token)
print("")
wallet.status()
@cli.command("pay", help="Pay lightning invoice.")
@click.argument("invoice", type=str)
@click.pass_context
@@ -168,3 +197,16 @@ async def pay(ctx, invoice: str):
_, send_proofs = await wallet.split_to_send(wallet.proofs, amount)
await wallet.pay_lightning(send_proofs, amount, invoice)
wallet.status()
@cli.command("info", help="Information about Cashu wallet.")
@click.pass_context
@coro
async def info(ctx):
wallet: Wallet = ctx.obj["WALLET"]
await init_wallet(wallet)
wallet.status()
print(f"Debug: {DEBUG}")
print(f"Cashu dir: {CASHU_DIR}")
print(f"Mint URL: {MINT_URL}")
return

View File

@@ -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)),
)

View File

@@ -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_and_timestamps(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")

View File

@@ -1,4 +1,8 @@
import base64
import json
import random
import secrets as scrts
import uuid
from typing import List
import requests
@@ -15,6 +19,7 @@ from core.base import (
)
from core.db import Database
from core.secp import PublicKey
from core.settings import DEBUG
from core.split import amount_split
from wallet.crud import get_proofs, invalidate_proof, store_proof, update_proof_reserved
@@ -52,6 +57,10 @@ class LedgerAPI:
proofs.append(proof)
return proofs
def _generate_secret(self, randombits=128):
"""Returns base64 encoded random string."""
return scrts.token_urlsafe(randombits // 8)
def request_mint(self, amount):
"""Requests a mint from the server and returns Lightning invoice."""
r = requests.get(self.url + "/mint", params={"amount": amount})
@@ -63,7 +72,7 @@ class LedgerAPI:
secrets = []
rs = []
for amount in amounts:
secret = str(random.getrandbits(128))
secret = self._generate_secret()
secrets.append(secret)
B_, r = b_dhke.step1_bob(secret)
rs.append(r)
@@ -91,7 +100,7 @@ class LedgerAPI:
secrets = []
payloads: MintPayloads = MintPayloads()
for output_amt in fst_outputs + snd_outputs:
secret = str(random.getrandbits(128))
secret = self._generate_secret()
B_, r = b_dhke.step1_bob(secret)
secrets.append((r, secret))
payload: BlindedMessage = BlindedMessage(
@@ -186,6 +195,14 @@ class Wallet(LedgerAPI):
raise Exception("could not pay invoice.")
return status["paid"]
@staticmethod
async def serialize_proofs(proofs: List[Proof]):
proofs_serialized = [p.to_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 +211,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)