diff --git a/cashu/core/base.py b/cashu/core/base.py index a78f7ee..6ee31b6 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -6,7 +6,7 @@ from pydantic import BaseModel class Proof(BaseModel): amount: int - secret: str + secret: str = "" C: str reserved: bool = False # whether this proof is reserved for sending send_id: str = "" # unique ID of send attempt @@ -27,12 +27,11 @@ class Proof(BaseModel): @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.get("amount"), C=d.get("C"), - secret=d.get("secret"), + secret=d.get("secret") or "", reserved=d.get("reserved") or False, send_id=d.get("send_id") or "", time_created=d.get("time_created") or "", @@ -42,6 +41,9 @@ class Proof(BaseModel): def to_dict(self): return dict(amount=self.amount, secret=self.secret, C=self.C) + def to_dict_no_secret(self): + return dict(amount=self.amount, C=self.C) + def __getitem__(self, key): return self.__getattribute__(key) diff --git a/cashu/core/settings.py b/cashu/core/settings.py index 36ec220..7e23474 100644 --- a/cashu/core/settings.py +++ b/cashu/core/settings.py @@ -1,3 +1,4 @@ +import sys from pathlib import Path from environs import Env # type: ignore @@ -6,6 +7,9 @@ env = Env() env.read_env() DEBUG = env.bool("DEBUG", default=False) +if not DEBUG: + sys.tracebacklimit = 0 + CASHU_DIR = env.str("CASHU_DIR", default="~/.cashu") CASHU_DIR = CASHU_DIR.replace("~", str(Path.home())) assert len(CASHU_DIR), "CASHU_DIR not defined" @@ -32,4 +36,4 @@ LNBITS_ENDPOINT = env.str("LNBITS_ENDPOINT", default=None) LNBITS_KEY = env.str("LNBITS_KEY", default=None) MAX_ORDER = 64 -VERSION = "0.1.10" +VERSION = "0.2.0" diff --git a/cashu/mint/app.py b/cashu/mint/app.py index 8b722fd..f90ce95 100644 --- a/cashu/mint/app.py +++ b/cashu/mint/app.py @@ -40,7 +40,7 @@ def create_app(config_object="core.settings") -> FastAPI: logger.log(level, record.getMessage()) logger.remove() - log_level: str = "INFO" + log_level: str = "DEBUG" if DEBUG else "INFO" formatter = Formatter() logger.add(sys.stderr, level=log_level, format=formatter.format) diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 13f6310..6e698b6 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -227,30 +227,33 @@ class Ledger: return {i: self._check_spendable(p) for i, p in enumerate(proofs)} async def split( - self, proofs: List[Proof], amount: int, output_data: List[BlindedMessage] + self, proofs: List[Proof], amount: int, outputs: List[BlindedMessage] ): """Consumes proofs and prepares new promises based on the amount split.""" - self._verify_split_amount(amount) - # Verify proofs are valid - if not all([self._verify_proof(p) for p in proofs]): - return False - total = sum([p.amount for p in proofs]) - if not self._verify_no_duplicates(proofs, output_data): - raise Exception("duplicate proofs or promises") + # verify that amount is kosher + self._verify_split_amount(amount) + # verify overspending attempt if amount > total: - raise Exception("split amount is higher than the total sum") - if not self._verify_outputs(total, amount, output_data): - raise Exception("split of promises is not as expected") + raise Exception("split amount is higher than the total sum.") + # verify that only unique proofs and outputs were used + if not self._verify_no_duplicates(proofs, outputs): + raise Exception("duplicate proofs or promises") + # verify that outputs have the correct amount + if not self._verify_outputs(total, amount, outputs): + raise Exception("split of promises is not as expected.") + # Verify proofs + if not all([self._verify_proof(p) for p in proofs]): + raise Exception("could not verify proofs.") # Mark proofs as used and prepare new promises await self._invalidate_proofs(proofs) outs_fst = amount_split(total - amount) outs_snd = amount_split(amount) - B_fst = [od.B_ for od in output_data[: len(outs_fst)]] - B_snd = [od.B_ for od in output_data[len(outs_fst) :]] + B_fst = [od.B_ for od in outputs[: len(outs_fst)]] + B_snd = [od.B_ for od in outputs[len(outs_fst) :]] prom_fst, prom_snd = await self._generate_promises( outs_fst, B_fst ), await self._generate_promises(outs_snd, B_snd) diff --git a/cashu/mint/router.py b/cashu/mint/router.py index b7ebf05..bad7d94 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -82,7 +82,7 @@ async def split(payload: SplitPayload): except Exception as exc: return {"error": str(exc)} if not split_return: - """There was a problem with the split""" - raise Exception("could not split tokens.") + # tere was a problem with the split + return {"error": "there was a problem with the split."} fst_promises, snd_promises = split_return return {"fst": fst_promises, "snd": snd_promises} diff --git a/cashu/wallet/cli.py b/cashu/wallet/cli.py index 09a3f4a..b73ece4 100755 --- a/cashu/wallet/cli.py +++ b/cashu/wallet/cli.py @@ -4,6 +4,8 @@ import asyncio import base64 import json import math +import sys +from loguru import logger from datetime import datetime from functools import wraps from itertools import groupby @@ -41,10 +43,15 @@ class NaturalOrderGroup(click.Group): @click.option("--wallet", "-w", "walletname", default="wallet", help="Wallet to use.") @click.pass_context def cli(ctx, host: str, walletname: str): + # configure logger + logger.remove() + logger.add(sys.stderr, level="DEBUG" if DEBUG else "INFO") ctx.ensure_object(dict) ctx.obj["HOST"] = host ctx.obj["WALLET_NAME"] = walletname - ctx.obj["WALLET"] = Wallet(ctx.obj["HOST"], f"{CASHU_DIR}/{walletname}", walletname) + wallet = Wallet(ctx.obj["HOST"], f"{CASHU_DIR}/{walletname}", walletname) + ctx.obj["WALLET"] = wallet + asyncio.run(init_wallet(wallet)) pass @@ -64,7 +71,7 @@ def coro(f): @coro async def mint(ctx, amount: int, hash: str): wallet: Wallet = ctx.obj["WALLET"] - await init_wallet(wallet) + wallet.load_mint() wallet.status() if not LIGHTNING: r = await wallet.mint(amount) @@ -88,35 +95,42 @@ async def mint(ctx, amount: int, hash: str): @coro async def balance(ctx): wallet: Wallet = ctx.obj["WALLET"] - await init_wallet(wallet) wallet.status() @cli.command("send", help="Send tokens.") @click.argument("amount", type=int) +@click.option("--secret", "-s", default="", help="Token spending condition.", type=str) @click.pass_context @coro -async def send(ctx, amount: int): +async def send(ctx, amount: int, secret: str): wallet: Wallet = ctx.obj["WALLET"] - await init_wallet(wallet) + wallet.load_mint() wallet.status() - _, send_proofs = await wallet.split_to_send(wallet.proofs, amount) + # TODO: remove this list hack + secrets = [secret] if secret else None + _, send_proofs = await wallet.split_to_send(wallet.proofs, amount, secrets) await wallet.set_reserved(send_proofs, reserved=True) - token = await wallet.serialize_proofs(send_proofs) + token = await wallet.serialize_proofs( + send_proofs, hide_secrets=True if secrets else False + ) print(token) wallet.status() @cli.command("receive", help="Receive tokens.") @click.argument("token", type=str) +@click.option("--secret", "-s", default="", help="Token spending condition.", type=str) @click.pass_context @coro -async def receive(ctx, token: str): +async def receive(ctx, token: str, secret: str): wallet: Wallet = ctx.obj["WALLET"] - await init_wallet(wallet) + wallet.load_mint() wallet.status() + # TODO: remove this list hack + secrets = [secret] if secret else None proofs = [Proof.from_dict(p) for p in json.loads(base64.urlsafe_b64decode(token))] - _, _ = await wallet.redeem(proofs) + _, _ = await wallet.redeem(proofs, secrets) wallet.status() @@ -130,7 +144,7 @@ async def receive(ctx, token: str): @coro async def burn(ctx, token: str, all: bool, force: bool): wallet: Wallet = ctx.obj["WALLET"] - await init_wallet(wallet) + wallet.load_mint() 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." @@ -157,7 +171,7 @@ async def burn(ctx, token: str, all: bool, force: bool): @coro async def pending(ctx): wallet: Wallet = ctx.obj["WALLET"] - await init_wallet(wallet) + wallet.load_mint() reserved_proofs = await get_reserved_proofs(wallet.db) if len(reserved_proofs): sorted_proofs = sorted(reserved_proofs, key=itemgetter("send_id")) @@ -181,7 +195,7 @@ async def pending(ctx): @coro async def pay(ctx, invoice: str): wallet: Wallet = ctx.obj["WALLET"] - await init_wallet(wallet) + wallet.load_mint() wallet.status() decoded_invoice: Invoice = bolt11.decode(invoice) amount = math.ceil( @@ -206,5 +220,6 @@ async def info(ctx): print(f"Version: {VERSION}") print(f"Debug: {DEBUG}") print(f"Cashu dir: {CASHU_DIR}") + print(f"Wallet: {ctx.obj['WALLET_NAME']}") print(f"Mint URL: {MINT_URL}") return diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 27d1ba6..7d77600 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -3,6 +3,7 @@ import json import secrets as scrts import uuid from typing import List +from loguru import logger import requests @@ -31,7 +32,8 @@ from cashu.wallet.crud import ( class LedgerAPI: def __init__(self, url): self.url = url - self.keys = self._get_keys(url) + # self.keys = self._get_keys(self.url) + # self._load_mint() @staticmethod def _get_keys(url): @@ -51,10 +53,12 @@ class LedgerAPI: rv.append(2**pos) return rv - def _construct_proofs(self, promises: List[BlindedSignature], secrets: List[str]): - """Returns proofs of promise from promises.""" + def _construct_proofs( + self, promises: List[BlindedSignature], secrets: List[str], rs: List[str] + ): + """Returns proofs of promise from promises. Wants secrets and blinding factors rs.""" proofs = [] - for promise, (r, secret) in zip(promises, secrets): + for promise, secret, r in zip(promises, secrets, rs): C_ = PublicKey(bytes.fromhex(promise.C_), raw=True) C = b_dhke.step3_alice(C_, r, self.keys[promise.amount]) proof = Proof(amount=promise.amount, C=C.serialize().hex(), secret=secret) @@ -65,65 +69,115 @@ class LedgerAPI: """Returns base64 encoded random string.""" return scrts.token_urlsafe(randombits // 8) + def _load_mint(self): + assert len( + self.url + ), "Ledger not initialized correctly: mint URL not specified yet. " + self.keys = self._get_keys(self.url) + assert len(self.keys) > 0, "did not receive keys from mint." + def request_mint(self, amount): """Requests a mint from the server and returns Lightning invoice.""" r = requests.get(self.url + "/mint", params={"amount": amount}) return r.json() - def mint(self, amounts, payment_hash=None): - """Mints new coins and returns a proof of promise.""" + @staticmethod + def _construct_outputs(amounts: List[int], secrets: List[str]): + """Takes a list of amounts and secrets and returns outputs. + Outputs are blinded messages `payloads` and blinding factors `rs`""" + assert len(amounts) == len( + secrets + ), f"len(amounts)={len(amounts)} not equal to len(secrets)={len(secrets)}" payloads: MintPayloads = MintPayloads() - secrets = [] rs = [] - for amount in amounts: - secret = self._generate_secret() - secrets.append(secret) + for secret, amount in zip(secrets, amounts): B_, r = b_dhke.step1_alice(secret) rs.append(r) payload: BlindedMessage = BlindedMessage( amount=amount, B_=B_.serialize().hex() ) payloads.blinded_messages.append(payload) - promises_list = requests.post( + return payloads, rs + + def mint(self, amounts, payment_hash=None): + """Mints new coins and returns a proof of promise.""" + secrets = [self._generate_secret() for s in range(len(amounts))] + payloads, rs = self._construct_outputs(amounts, secrets) + + resp = requests.post( self.url + "/mint", json=payloads.dict(), params={"payment_hash": payment_hash}, - ).json() + ) + try: + promises_list = resp.json() + except: + if resp.status_code >= 300: + raise Exception(f"Error: {f'mint returned {resp.status_code}'}") + else: + raise Exception("Unkown mint error.") if "error" in promises_list: raise Exception("Error: {}".format(promises_list["error"])) - promises = [BlindedSignature.from_dict(p) for p in promises_list] - return self._construct_proofs(promises, [(r, s) for r, s in zip(rs, secrets)]) - def split(self, proofs, amount): - """Consume proofs and create new promises based on amount split.""" + promises = [BlindedSignature.from_dict(p) for p in promises_list] + return self._construct_proofs(promises, secrets, rs) + + def split(self, proofs, amount, snd_secrets: List[str] = None): + """Consume proofs and create new promises based on amount split. + If snd_secrets is None, random secrets will be generated for the tokens to keep (fst_outputs) + and the promises to send (snd_outputs). + + If snd_secrets is provided, the wallet will create blinded secrets with those to attach a + predefined spending condition to the tokens they want to send.""" + total = sum([p["amount"] for p in proofs]) fst_amt, snd_amt = total - amount, amount fst_outputs = amount_split(fst_amt) snd_outputs = amount_split(snd_amt) - # TODO: Refactor together with the same procedure in self.mint() - secrets = [] - payloads: MintPayloads = MintPayloads() - for output_amt in fst_outputs + snd_outputs: - secret = self._generate_secret() - B_, r = b_dhke.step1_alice(secret) - secrets.append((r, secret)) - payload: BlindedMessage = BlindedMessage( - amount=output_amt, B_=B_.serialize().hex() - ) - payloads.blinded_messages.append(payload) + amounts = fst_outputs + snd_outputs + if snd_secrets is None: + secrets = [self._generate_secret() for s in range(len(amounts))] + else: + logger.debug("Creating proofs with spending condition.") + assert len(snd_secrets) == len( + snd_outputs + ), "number of snd_secrets does not match number of ouptus." + # append predefined secrets (to send) to random secrets (to keep) + secrets = [ + self._generate_secret() for s in range(len(fst_outputs)) + ] + snd_secrets + + assert len(secrets) == len( + amounts + ), "number of secrets does not match number of outputs" + + payloads, rs = self._construct_outputs(amounts, secrets) + split_payload = SplitPayload(proofs=proofs, amount=amount, output_data=payloads) - promises_dict = requests.post( + resp = requests.post( self.url + "/split", json=split_payload.dict(), - ).json() + ) + + try: + promises_dict = resp.json() + except: + if resp.status_code >= 300: + raise Exception(f"Error: {f'mint returned {resp.status_code}'}") + else: + raise Exception("Unkown mint error.") if "error" in promises_dict: raise Exception("Error: {}".format(promises_dict["error"])) promises_fst = [BlindedSignature.from_dict(p) for p in promises_dict["fst"]] promises_snd = [BlindedSignature.from_dict(p) for p in promises_dict["snd"]] - # Obtain proofs from promises - fst_proofs = self._construct_proofs(promises_fst, secrets[: len(promises_fst)]) - snd_proofs = self._construct_proofs(promises_snd, secrets[len(promises_fst) :]) + # Construct proofs from promises (i.e., unblind signatures) + fst_proofs = self._construct_proofs( + promises_fst, secrets[: len(promises_fst)], rs[: len(promises_fst)] + ) + snd_proofs = self._construct_proofs( + promises_snd, secrets[len(promises_fst) :], rs[len(promises_fst) :] + ) return fst_proofs, snd_proofs @@ -154,6 +208,9 @@ class Wallet(LedgerAPI): self.proofs: List[Proof] = [] self.name = name + def load_mint(self): + super()._load_mint() + async def load_proofs(self): self.proofs = await get_proofs(db=self.db) @@ -173,12 +230,20 @@ class Wallet(LedgerAPI): self.proofs += proofs return proofs - async def redeem(self, proofs: List[Proof]): + async def redeem(self, proofs: List[Proof], snd_secrets: List[str] = None): + if snd_secrets: + logger.debug(f"Redeption secrets: {snd_secrets}") + assert len(proofs) == len(snd_secrets) + # overload proofs with custom secrets for redemption + for p, s in zip(proofs, snd_secrets): + p.secret = s return await self.split(proofs, sum(p["amount"] for p in proofs)) - async def split(self, proofs: List[Proof], amount: int): + async def split( + self, proofs: List[Proof], amount: int, snd_secrets: List[str] = None + ): assert len(proofs) > 0, ValueError("no proofs provided.") - fst_proofs, snd_proofs = super().split(proofs, amount) + fst_proofs, snd_proofs = super().split(proofs, amount, snd_secrets) if len(fst_proofs) == 0 and len(snd_proofs) == 0: raise Exception("received no splits.") used_secrets = [p["secret"] for p in proofs] @@ -201,18 +266,27 @@ class Wallet(LedgerAPI): return status["paid"] @staticmethod - async def serialize_proofs(proofs: List[Proof]): - proofs_serialized = [p.to_dict() for p in proofs] + async def serialize_proofs(proofs: List[Proof], hide_secrets=False): + if hide_secrets: + proofs_serialized = [p.to_dict_no_secret() for p in proofs] + else: + 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): + async def split_to_send( + self, proofs: List[Proof], amount, snd_secrets: List[str] = None + ): """Like self.split but only considers non-reserved tokens.""" + if snd_secrets: + logger.debug(f"Spending conditions: {snd_secrets}") if len([p for p in proofs if not p.reserved]) <= 0: raise Exception("balance too low.") - return await self.split([p for p in proofs if not p.reserved], amount) + return await self.split( + [p for p in proofs if not p.reserved], amount, snd_secrets + ) async def set_reserved(self, proofs: List[Proof], reserved: bool): """Mark a proof as reserved to avoid reuse or delete marking.""" diff --git a/pyproject.toml b/pyproject.toml index e16a8d8..aa3937a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "cashu" -version = "0.1.10" +version = "0.2.0" description = "Ecash wallet and mint." authors = ["calle "] license = "MIT" diff --git a/setup.py b/setup.py index b7176cc..8728c77 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ entry_points = {"console_scripts": ["cashu = cahu.wallet.cli:cli"]} setuptools.setup( name="cashu", - version="0.1.10", + version="0.2.0", description="Ecash wallet and mint with Bitcoin Lightning support", long_description=long_description, long_description_content_type="text/markdown", diff --git a/tests/test_wallet.py b/tests/test_wallet.py index a77b141..b8a8b4d 100644 --- a/tests/test_wallet.py +++ b/tests/test_wallet.py @@ -25,10 +25,12 @@ def assert_amt(proofs, expected): async def run_test(): wallet1 = Wallet1(SERVER_ENDPOINT, "data/wallet1", "wallet1") await migrate_databases(wallet1.db, migrations) + wallet1.load_mint() wallet1.status() wallet2 = Wallet2(SERVER_ENDPOINT, "data/wallet2", "wallet2") await migrate_databases(wallet2.db, migrations) + wallet2.load_mint() wallet2.status() proofs = [] @@ -105,6 +107,14 @@ async def run_test(): assert wallet1.proof_amounts() == [1, 2, 4, 4, 32, 64] assert wallet2.proof_amounts() == [4, 16] + # manipulate the proof amount + w1_fst_proofs2[0]["amount"] = 123 + await assert_err( + wallet1.split(w1_fst_proofs2, 20), + "Error: 123", + ) + + # try to split an invalid amount await assert_err( wallet1.split(w1_snd_proofs, -500), "Error: invalid split amount: -500",