From 6789a87c1401bc44bd1c6612edd55705a2899b3f Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 30 Sep 2022 03:10:47 +0200 Subject: [PATCH 01/27] pay to secret --- cashu/core/base.py | 8 ++- cashu/core/settings.py | 6 +- cashu/mint/app.py | 2 +- cashu/mint/ledger.py | 29 ++++---- cashu/mint/router.py | 4 +- cashu/wallet/cli.py | 41 +++++++---- cashu/wallet/wallet.py | 152 ++++++++++++++++++++++++++++++----------- pyproject.toml | 2 +- setup.py | 2 +- tests/test_wallet.py | 10 +++ 10 files changed, 182 insertions(+), 74 deletions(-) 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", From 616d05b2ef2bb5e8c77e3b9cf66bdb63611a11a4 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 30 Sep 2022 03:12:30 +0200 Subject: [PATCH 02/27] typo --- cashu/mint/router.py | 1 - 1 file changed, 1 deletion(-) diff --git a/cashu/mint/router.py b/cashu/mint/router.py index bad7d94..aca3adc 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -82,7 +82,6 @@ async def split(payload: SplitPayload): except Exception as exc: return {"error": str(exc)} if not split_return: - # 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} From 572904d0455df59ef888c173a7c38cb17bc0d4c0 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 30 Sep 2022 03:14:02 +0200 Subject: [PATCH 03/27] comments --- cashu/wallet/wallet.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 7d77600..042f051 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -32,8 +32,6 @@ from cashu.wallet.crud import ( class LedgerAPI: def __init__(self, url): self.url = url - # self.keys = self._get_keys(self.url) - # self._load_mint() @staticmethod def _get_keys(url): From 5d640efc75d0e032c42220f2f27ffe81bca89c74 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 1 Oct 2022 02:33:47 +0200 Subject: [PATCH 04/27] determinstic secrets for multiple tokens --- cashu/mint/ledger.py | 4 ++-- cashu/wallet/cli.py | 27 +++++++++++---------- cashu/wallet/crud.py | 16 +++++++++++++ cashu/wallet/wallet.py | 54 ++++++++++++++++++++++++------------------ tests/test_wallet.py | 44 ++++++++++++++++++++++++++++++---- 5 files changed, 103 insertions(+), 42 deletions(-) diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 6e698b6..1c4874f 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -150,7 +150,7 @@ class Ledger: """Checks with the Lightning backend whether an invoice with this payment_hash was paid.""" invoice: Invoice = await get_lightning_invoice(payment_hash, db=self.db) if invoice.issued: - raise Exception("tokens already issued for this invoice") + raise Exception("tokens already issued for this invoice.") status = await WALLET.get_invoice_status(payment_hash) if status.paid: await update_lightning_invoice(payment_hash, issued=True, db=self.db) @@ -239,7 +239,7 @@ class Ledger: 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") + 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.") diff --git a/cashu/wallet/cli.py b/cashu/wallet/cli.py index b73ece4..c9d5b77 100755 --- a/cashu/wallet/cli.py +++ b/cashu/wallet/cli.py @@ -100,19 +100,19 @@ async def balance(ctx): @cli.command("send", help="Send tokens.") @click.argument("amount", type=int) -@click.option("--secret", "-s", default="", help="Token spending condition.", type=str) +@click.option( + "--secret", "-s", default=None, help="Token spending condition.", type=str +) @click.pass_context @coro async def send(ctx, amount: int, secret: str): wallet: Wallet = ctx.obj["WALLET"] wallet.load_mint() wallet.status() - # TODO: remove this list hack - secrets = [secret] if secret else None - _, send_proofs = await wallet.split_to_send(wallet.proofs, amount, secrets) + _, send_proofs = await wallet.split_to_send(wallet.proofs, amount, secret) await wallet.set_reserved(send_proofs, reserved=True) token = await wallet.serialize_proofs( - send_proofs, hide_secrets=True if secrets else False + send_proofs, hide_secrets=True if secret else False ) print(token) wallet.status() @@ -127,10 +127,8 @@ async def receive(ctx, token: str, secret: str): wallet: Wallet = ctx.obj["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, secrets) + _, _ = await wallet.redeem(proofs, secret) wallet.status() @@ -175,17 +173,22 @@ async def pending(ctx): 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 = groupby(sorted_proofs, key=itemgetter("send_id")) + for i, (key, value) in enumerate(grouped_proofs): grouped_proofs = list(value) token = await wallet.serialize_proofs(grouped_proofs) + token_hidden_secret = await wallet.serialize_proofs( + grouped_proofs, hide_secrets=True + ) 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" + f"Amount: {sum([p['amount'] for p in grouped_proofs])} sat Sent: {reserved_date} ID: {key} #{i+1}/{len(grouped_proofs)}\n" ) - print(token) - print("") + print(f"With secret: {token}\n\nSecretless: {token_hidden_secret}\n") + if i < len(grouped_proofs) - 1: + print(f"--------------------------\n") wallet.status() diff --git a/cashu/wallet/crud.py b/cashu/wallet/crud.py index c5741b6..0a0e97b 100644 --- a/cashu/wallet/crud.py +++ b/cashu/wallet/crud.py @@ -97,3 +97,19 @@ async def update_proof_reserved( f"UPDATE proofs SET {', '.join(clauses)} WHERE secret = ?", (*values, str(proof.secret)), ) + + +async def secret_used( + secret: str, + db: Database, + conn: Optional[Connection] = None, +): + + rows = await (conn or db).fetchone( + """ + SELECT * from proofs + WHERE secret = ? + """, + (secret), + ) + return rows is not None diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 042f051..e946855 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -26,6 +26,7 @@ from cashu.wallet.crud import ( invalidate_proof, store_proof, update_proof_reserved, + secret_used, ) @@ -97,9 +98,15 @@ class LedgerAPI: payloads.blinded_messages.append(payload) return payloads, rs - def mint(self, amounts, payment_hash=None): + async def _check_used_secrets(self, secrets): + for s in secrets: + if await secret_used(s, db=self.db): + raise Exception(f"secret already used: {s}") + + async 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))] + await self._check_used_secrets(secrets) payloads, rs = self._construct_outputs(amounts, secrets) resp = requests.post( @@ -120,12 +127,12 @@ class LedgerAPI: 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): + async def split(self, proofs, amount, snd_secret: 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) + If snd_secret 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 + If snd_secret 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]) @@ -134,10 +141,13 @@ class LedgerAPI: snd_outputs = amount_split(snd_amt) amounts = fst_outputs + snd_outputs - if snd_secrets is None: - secrets = [self._generate_secret() for s in range(len(amounts))] + if snd_secret is None: + logger.debug("Generating random secrets.") + secrets = [self._generate_secret() for _ in range(len(amounts))] else: - logger.debug("Creating proofs with spending condition.") + logger.debug(f"Creating proofs with custom secret: {snd_secret}") + # TODO: serialize them here + snd_secrets = [f"{snd_secret}_{i}" for i in range(len(snd_outputs))] assert len(snd_secrets) == len( snd_outputs ), "number of snd_secrets does not match number of ouptus." @@ -149,7 +159,7 @@ class LedgerAPI: assert len(secrets) == len( amounts ), "number of secrets does not match number of outputs" - + await self._check_used_secrets(secrets) payloads, rs = self._construct_outputs(amounts, secrets) split_payload = SplitPayload(proofs=proofs, amount=amount, output_data=payloads) @@ -221,27 +231,27 @@ class Wallet(LedgerAPI): async def mint(self, amount: int, payment_hash: str = None): split = amount_split(amount) - proofs = super().mint(split, payment_hash) + proofs = await super().mint(split, payment_hash) if proofs == []: raise Exception("received no proofs.") await self._store_proofs(proofs) self.proofs += proofs return proofs - async def redeem(self, proofs: List[Proof], snd_secrets: List[str] = None): - if snd_secrets: - logger.debug(f"Redeption secrets: {snd_secrets}") + async def redeem(self, proofs: List[Proof], snd_secret: str = None): + if snd_secret: + logger.debug(f"Redeption secret: {snd_secret}") + # TODO: serialize them here + snd_secrets = [f"{snd_secret}_{i}" for i in range(len(proofs))] 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, snd_secrets: List[str] = None - ): + async def split(self, proofs: List[Proof], amount: int, snd_secret: str = None): assert len(proofs) > 0, ValueError("no proofs provided.") - fst_proofs, snd_proofs = super().split(proofs, amount, snd_secrets) + fst_proofs, snd_proofs = await super().split(proofs, amount, snd_secret) if len(fst_proofs) == 0 and len(snd_proofs) == 0: raise Exception("received no splits.") used_secrets = [p["secret"] for p in proofs] @@ -274,16 +284,14 @@ class Wallet(LedgerAPI): ).decode() return token - async def split_to_send( - self, proofs: List[Proof], amount, snd_secrets: List[str] = None - ): + async def split_to_send(self, proofs: List[Proof], amount, snd_secret: str = None): """Like self.split but only considers non-reserved tokens.""" - if snd_secrets: - logger.debug(f"Spending conditions: {snd_secrets}") + if snd_secret: + logger.debug(f"Spending conditions: {snd_secret}") 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, snd_secrets + [p for p in proofs if not p.reserved], amount, snd_secret ) async def set_reserved(self, proofs: List[Proof], reserved: bool): @@ -321,7 +329,7 @@ class Wallet(LedgerAPI): def status(self): print( - f"Balance: {self.balance} sat (Available: {self.available_balance} sat in {len([p for p in self.proofs if not p.reserved])} tokens)" + f"Balance: {self.balance} sat (available: {self.available_balance} sat in {len([p for p in self.proofs if not p.reserved])} tokens)" ) def proof_amounts(self): diff --git a/tests/test_wallet.py b/tests/test_wallet.py index b8a8b4d..4f147ef 100644 --- a/tests/test_wallet.py +++ b/tests/test_wallet.py @@ -1,3 +1,5 @@ +import time +from re import S from cashu.core.helpers import async_unwrap from cashu.core.migrations import migrate_databases from cashu.wallet import migrations @@ -108,11 +110,12 @@ async def run_test(): 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", - ) + # w1_fst_proofs2_manipulated = w1_fst_proofs2.copy() + # w1_fst_proofs2_manipulated[0]["amount"] = 123 + # await assert_err( + # wallet1.split(w1_fst_proofs2_manipulated, 20), + # "Error: 123", + # ) # try to split an invalid amount await assert_err( @@ -120,6 +123,37 @@ async def run_test(): "Error: invalid split amount: -500", ) + # mint with secrets + secret = f"asdasd_{time.time()}" + w1_fst_proofs, w1_snd_proofs = await wallet1.split( + wallet1.proofs, 65, snd_secret=secret + ) + + # strip away the secrets + w1_snd_proofs_manipulated = w1_snd_proofs.copy() + for p in w1_snd_proofs_manipulated: + p.secret = "" + await assert_err( + wallet2.redeem(w1_snd_proofs_manipulated), + "Error: duplicate proofs or promises.", + ) + + # redeem with wrong secret + await assert_err( + wallet2.redeem(w1_snd_proofs_manipulated, f"{secret}_asd"), + "Error: could not verify proofs.", + ) + + # redeem with correct secret + await wallet2.redeem(w1_snd_proofs_manipulated, secret) + + # try to redeem them again + # NOTE: token indexing suffix _0 + await assert_err( + wallet2.redeem(w1_snd_proofs_manipulated, secret), + f"Error: tokens already spent. Secret: {secret}_0", + ) + if __name__ == "__main__": async_unwrap(run_test()) From b6c4d676f6fba0ff1d8371c59e1501bab1fbb414 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 1 Oct 2022 02:52:48 +0200 Subject: [PATCH 05/27] refactor deterministic key generation --- cashu/mint/ledger.py | 2 +- cashu/wallet/cli.py | 11 ++++++----- cashu/wallet/wallet.py | 13 +++++++++---- tests/test_wallet.py | 2 +- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 1c4874f..5896aa3 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -239,7 +239,7 @@ class Ledger: 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.") + raise Exception("empty or 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.") diff --git a/cashu/wallet/cli.py b/cashu/wallet/cli.py index c9d5b77..a094aba 100755 --- a/cashu/wallet/cli.py +++ b/cashu/wallet/cli.py @@ -172,9 +172,11 @@ async def pending(ctx): wallet.load_mint() reserved_proofs = await get_reserved_proofs(wallet.db) if len(reserved_proofs): + print(f"--------------------------\n") sorted_proofs = sorted(reserved_proofs, key=itemgetter("send_id")) - grouped_proofs = groupby(sorted_proofs, key=itemgetter("send_id")) - for i, (key, value) in enumerate(grouped_proofs): + for i, (key, value) in enumerate( + groupby(sorted_proofs, key=itemgetter("send_id")) + ): grouped_proofs = list(value) token = await wallet.serialize_proofs(grouped_proofs) token_hidden_secret = await wallet.serialize_proofs( @@ -184,11 +186,10 @@ async def pending(ctx): 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} #{i+1}/{len(grouped_proofs)}\n" + f"#{i} Amount: {sum([p['amount'] for p in grouped_proofs])} sat Time: {reserved_date} ID: {key}\n" ) print(f"With secret: {token}\n\nSecretless: {token_hidden_secret}\n") - if i < len(grouped_proofs) - 1: - print(f"--------------------------\n") + print(f"--------------------------\n") wallet.status() diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index e946855..21eb275 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -103,6 +103,11 @@ class LedgerAPI: if await secret_used(s, db=self.db): raise Exception(f"secret already used: {s}") + @staticmethod + def generate_deterministic_secrets(secret, n): + """`secret` is the base string that will be tweaked n times""" + return [f"{secret}_{i}" for i in range(n)] + async 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))] @@ -146,8 +151,9 @@ class LedgerAPI: secrets = [self._generate_secret() for _ in range(len(amounts))] else: logger.debug(f"Creating proofs with custom secret: {snd_secret}") - # TODO: serialize them here - snd_secrets = [f"{snd_secret}_{i}" for i in range(len(snd_outputs))] + snd_secrets = self.generate_deterministic_secrets( + snd_secret, len(snd_outputs) + ) assert len(snd_secrets) == len( snd_outputs ), "number of snd_secrets does not match number of ouptus." @@ -241,8 +247,7 @@ class Wallet(LedgerAPI): async def redeem(self, proofs: List[Proof], snd_secret: str = None): if snd_secret: logger.debug(f"Redeption secret: {snd_secret}") - # TODO: serialize them here - snd_secrets = [f"{snd_secret}_{i}" for i in range(len(proofs))] + snd_secrets = self.generate_deterministic_secrets(snd_secret, len(proofs)) assert len(proofs) == len(snd_secrets) # overload proofs with custom secrets for redemption for p, s in zip(proofs, snd_secrets): diff --git a/tests/test_wallet.py b/tests/test_wallet.py index 4f147ef..e90fdfa 100644 --- a/tests/test_wallet.py +++ b/tests/test_wallet.py @@ -135,7 +135,7 @@ async def run_test(): p.secret = "" await assert_err( wallet2.redeem(w1_snd_proofs_manipulated), - "Error: duplicate proofs or promises.", + "Error: empty or duplicate proofs or promises.", ) # redeem with wrong secret From 50b47aa8308f9305d68468397799fd0e6a598a7d Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 1 Oct 2022 03:10:48 +0200 Subject: [PATCH 06/27] check for secret in mint --- cashu/mint/ledger.py | 12 +++++++++++- tests/test_wallet.py | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 5896aa3..5e3a3b2 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -73,6 +73,13 @@ class Ledger: """Checks whether the proof was already spent.""" return not proof.secret in self.proofs_used + def _verify_secret_criteria(self, proof: Proof): + if proof.secret is None or proof.secret == "": + raise Exception("no secret in proof.") + if len(proof.secret) < 10: + raise Exception("secret too short, must be at least 10 characters.") + return True + def _verify_proof(self, proof: Proof): """Verifies that the proof of promise was issued by this ledger.""" if not self._check_spendable(proof): @@ -237,9 +244,12 @@ class Ledger: # verify overspending attempt if amount > total: raise Exception("split amount is higher than the total sum.") + # Verify proofs + if not all([self._verify_secret_criteria(p) for p in proofs]): + raise Exception("secrets do not match criteria.") # verify that only unique proofs and outputs were used if not self._verify_no_duplicates(proofs, outputs): - raise Exception("empty or duplicate proofs or promises.") + 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.") diff --git a/tests/test_wallet.py b/tests/test_wallet.py index e90fdfa..fa0c5b6 100644 --- a/tests/test_wallet.py +++ b/tests/test_wallet.py @@ -135,7 +135,7 @@ async def run_test(): p.secret = "" await assert_err( wallet2.redeem(w1_snd_proofs_manipulated), - "Error: empty or duplicate proofs or promises.", + "Error: no secret in proof.", ) # redeem with wrong secret From 21622d0adb03a9b73d1fcf2bcb7999afcf2bac40 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 1 Oct 2022 03:26:44 +0200 Subject: [PATCH 07/27] remove length check --- cashu/mint/ledger.py | 2 -- cashu/wallet/wallet.py | 1 - 2 files changed, 3 deletions(-) diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 5e3a3b2..fffee09 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -76,8 +76,6 @@ class Ledger: def _verify_secret_criteria(self, proof: Proof): if proof.secret is None or proof.secret == "": raise Exception("no secret in proof.") - if len(proof.secret) < 10: - raise Exception("secret too short, must be at least 10 characters.") return True def _verify_proof(self, proof: Proof): diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 21eb275..2602ebd 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -167,7 +167,6 @@ class LedgerAPI: ), "number of secrets does not match number of outputs" await self._check_used_secrets(secrets) payloads, rs = self._construct_outputs(amounts, secrets) - split_payload = SplitPayload(proofs=proofs, amount=amount, output_data=payloads) resp = requests.post( self.url + "/split", From c72ea75911130359f29488c4c8e8ba5facd61b30 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 1 Oct 2022 04:00:52 +0200 Subject: [PATCH 08/27] 128 bit == 22 characters --- cashu/wallet/cli.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cashu/wallet/cli.py b/cashu/wallet/cli.py index a094aba..cabd3dc 100755 --- a/cashu/wallet/cli.py +++ b/cashu/wallet/cli.py @@ -106,6 +106,9 @@ async def balance(ctx): @click.pass_context @coro async def send(ctx, amount: int, secret: str): + if secret and len(secret) < 22: + print("Error: secret has to be at least 22 characters long.") + return wallet: Wallet = ctx.obj["WALLET"] wallet.load_mint() wallet.status() From b5e03e4fc7b79637a2e51cb8c7a0f3df647ed70d Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 2 Oct 2022 11:51:47 +0200 Subject: [PATCH 09/27] use python-bitcoinlib instead of python-bitcointx --- cashu/core/base.py | 1 + cashu/mint/ledger.py | 23 ++++++++++++++++++++++- cashu/wallet/cli.py | 5 +++-- cashu/wallet/wallet.py | 32 +++++++++++++++++++++++++------- poetry.lock | 14 +++++++++++++- pyproject.toml | 1 + 6 files changed, 65 insertions(+), 11 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index 6ee31b6..b91ee9c 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -8,6 +8,7 @@ class Proof(BaseModel): amount: int secret: str = "" C: str + script: str = "" reserved: bool = False # whether this proof is reserved for sending send_id: str = "" # unique ID of send attempt time_created: str = "" diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index fffee09..ffe506e 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -86,6 +86,24 @@ class Ledger: C = PublicKey(bytes.fromhex(proof.C), raw=True) return b_dhke.verify(secret_key, C, proof.secret) + def _verify_script(self, proof: Proof): + print(f"secret: {proof.secret}") + print(f"script: {proof.script}") + print( + f"script_hash: {hashlib.sha256(proof.script.encode('utf-8')).hexdigest()}" + ) + if len(proof.secret.split("SCRIPT:")) != 2: + return True + if len(proof.script) < 16: + raise Exception("Script error: not long enough.") + if ( + hashlib.sha256(proof.script.encode("utf-8")).hexdigest() + != proof.secret.split("SCRIPT:")[1] + ): + raise Exception("Script error: script hash not valid.") + print(f"Script {proof.script} valid.") + return True + def _verify_outputs( self, total: int, amount: int, output_data: List[BlindedMessage] ): @@ -242,7 +260,7 @@ class Ledger: # verify overspending attempt if amount > total: raise Exception("split amount is higher than the total sum.") - # Verify proofs + # Verify secret criteria if not all([self._verify_secret_criteria(p) for p in proofs]): raise Exception("secrets do not match criteria.") # verify that only unique proofs and outputs were used @@ -254,6 +272,9 @@ class Ledger: # Verify proofs if not all([self._verify_proof(p) for p in proofs]): raise Exception("could not verify proofs.") + # Verify scripts + if not all([self._verify_script(p) for p in proofs]): + raise Exception("could not verify scripts.") # Mark proofs as used and prepare new promises await self._invalidate_proofs(proofs) diff --git a/cashu/wallet/cli.py b/cashu/wallet/cli.py index cabd3dc..00a4a52 100755 --- a/cashu/wallet/cli.py +++ b/cashu/wallet/cli.py @@ -124,14 +124,15 @@ async def send(ctx, amount: int, secret: str): @cli.command("receive", help="Receive tokens.") @click.argument("token", type=str) @click.option("--secret", "-s", default="", help="Token spending condition.", type=str) +@click.option("--script", default=None, help="Token unlock script.", type=str) @click.pass_context @coro -async def receive(ctx, token: str, secret: str): +async def receive(ctx, token: str, secret: str, script: str): wallet: Wallet = ctx.obj["WALLET"] wallet.load_mint() wallet.status() proofs = [Proof.from_dict(p) for p in json.loads(base64.urlsafe_b64decode(token))] - _, _ = await wallet.redeem(proofs, secret) + _, _ = await wallet.redeem(proofs, secret, script) wallet.status() diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 2602ebd..9a98fa8 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -106,7 +106,7 @@ class LedgerAPI: @staticmethod def generate_deterministic_secrets(secret, n): """`secret` is the base string that will be tweaked n times""" - return [f"{secret}_{i}" for i in range(n)] + return [f"{i}:{secret}" for i in range(n)] async def mint(self, amounts, payment_hash=None): """Mints new coins and returns a proof of promise.""" @@ -132,7 +132,9 @@ class LedgerAPI: promises = [BlindedSignature.from_dict(p) for p in promises_list] return self._construct_proofs(promises, secrets, rs) - async def split(self, proofs, amount, snd_secret: str = None): + async def split( + self, proofs, amount, snd_secret: str = None, has_script: bool = False + ): """Consume proofs and create new promises based on amount split. If snd_secret is None, random secrets will be generated for the tokens to keep (fst_outputs) and the promises to send (snd_outputs). @@ -147,7 +149,6 @@ class LedgerAPI: amounts = fst_outputs + snd_outputs if snd_secret is None: - logger.debug("Generating random secrets.") secrets = [self._generate_secret() for _ in range(len(amounts))] else: logger.debug(f"Creating proofs with custom secret: {snd_secret}") @@ -243,7 +244,9 @@ class Wallet(LedgerAPI): self.proofs += proofs return proofs - async def redeem(self, proofs: List[Proof], snd_secret: str = None): + async def redeem( + self, proofs: List[Proof], snd_secret: str = None, snd_script: str = None + ): if snd_secret: logger.debug(f"Redeption secret: {snd_secret}") snd_secrets = self.generate_deterministic_secrets(snd_secret, len(proofs)) @@ -251,11 +254,26 @@ class Wallet(LedgerAPI): # 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)) + if snd_script: + logger.debug(f"Unlock script: {snd_script}") + # overload proofs with unlock script + for p in proofs: + p.script = snd_script + return await self.split( + proofs, sum(p["amount"] for p in proofs), has_script=snd_script is not None + ) - async def split(self, proofs: List[Proof], amount: int, snd_secret: str = None): + async def split( + self, + proofs: List[Proof], + amount: int, + snd_secret: str = None, + has_script: bool = False, + ): assert len(proofs) > 0, ValueError("no proofs provided.") - fst_proofs, snd_proofs = await super().split(proofs, amount, snd_secret) + fst_proofs, snd_proofs = await super().split( + proofs, amount, snd_secret, has_script + ) if len(fst_proofs) == 0 and len(snd_proofs) == 0: raise Exception("received no splits.") used_secrets = [p["secret"] for p in proofs] diff --git a/poetry.lock b/poetry.lock index 7224f57..d03a291 100644 --- a/poetry.lock +++ b/poetry.lock @@ -430,6 +430,14 @@ typing-extensions = {version = ">=3.7.2", markers = "python_version < \"3.8\""} [package.extras] testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] +[[package]] +name = "python-bitcoinlib" +version = "0.11.2" +description = "The Swiss Army Knife of the Bitcoin protocol." +category = "main" +optional = false +python-versions = "*" + [[package]] name = "python-dotenv" version = "0.21.0" @@ -632,7 +640,7 @@ testing = ["func-timeout", "jaraco.itertools", "pytest (>=6)", "pytest-black (>= [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "fbb8f977f71b77cf9b6514134111dc466f7fa1f70f8114e47b858d85c39199e4" +content-hash = "be65b895cb013a28ac6a6b2aacc131d140d6eafcd071aa021dbf2d2fbe311802" [metadata.files] anyio = [ @@ -964,6 +972,10 @@ pytest-asyncio = [ {file = "pytest-asyncio-0.19.0.tar.gz", hash = "sha256:ac4ebf3b6207259750bc32f4c1d8fcd7e79739edbc67ad0c58dd150b1d072fed"}, {file = "pytest_asyncio-0.19.0-py3-none-any.whl", hash = "sha256:7a97e37cfe1ed296e2e84941384bdd37c376453912d397ed39293e0916f521fa"}, ] +python-bitcoinlib = [ + {file = "python-bitcoinlib-0.11.2.tar.gz", hash = "sha256:61ba514e0d232cc84741e49862dcedaf37199b40bba252a17edc654f63d13f39"}, + {file = "python_bitcoinlib-0.11.2-py3-none-any.whl", hash = "sha256:78bd4ee717fe805cd760dfdd08765e77b7c7dbef4627f8596285e84953756508"}, +] python-dotenv = [ {file = "python-dotenv-0.21.0.tar.gz", hash = "sha256:b77d08274639e3d34145dfa6c7008e66df0f04b7be7a75fd0d5292c191d79045"}, {file = "python_dotenv-0.21.0-py3-none-any.whl", hash = "sha256:1684eb44636dd462b66c3ee016599815514527ad99965de77f43e0944634a7e5"}, diff --git a/pyproject.toml b/pyproject.toml index aa3937a..c4b4bc1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ ecdsa = "^0.18.0" bitstring = "^3.1.9" secp256k1 = "^0.14.0" sqlalchemy-aio = "^0.17.0" +python-bitcoinlib = "^0.11.2" [tool.poetry.dev-dependencies] black = {version = "^22.8.0", allow-prereleases = true} From a6e655e80ad93ee7f71fc77b1d0525174399b55c Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 2 Oct 2022 12:11:37 +0200 Subject: [PATCH 10/27] script --- cashu/core/script.py | 138 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 cashu/core/script.py diff --git a/cashu/core/script.py b/cashu/core/script.py new file mode 100644 index 0000000..bdfd728 --- /dev/null +++ b/cashu/core/script.py @@ -0,0 +1,138 @@ +import hashlib +import base64 + +COIN = 100_000_000 +TXID = "bff785da9f8169f49be92fa95e31f0890c385bfb1bd24d6b94d7900057c617ae" + +from bitcoin.core import ( + lx, + COutPoint, + CMutableTxOut, + CMutableTxIn, + CMutableTransaction, +) +from bitcoin.core.script import * +from bitcoin.core.scripteval import VerifyScript +from bitcoin.wallet import CBitcoinAddress, CBitcoinSecret + + +def step0_carol_privkey(): + """Private key""" + h = hashlib.sha256(b"correct horse battery staple").digest() + seckey = CBitcoinSecret.from_secret_bytes(h) + return seckey + + +def step0_carolt_checksig_redeemscrip(carol_pubkey): + """Create script""" + txin_redeemScript = CScript([carol_pubkey, OP_CHECKSIG]) + # txin_redeemScript = CScript( + # [ + # 3, + # 3, + # OP_LESSTHANOREQUAL, + # OP_VERIFY, + # ] + # ) + return txin_redeemScript + + +def step1_carol_create_p2sh_address(txin_redeemScript): + """Create address (serialized scriptPubKey) to share with Alice""" + # print("Script:", b2x(txin_redeemScript)) + # returns [OP_HASH160, bitcointx.core.Hash160(self), OP_EQUAL] + txin_scriptPubKey = txin_redeemScript.to_p2sh_scriptPubKey() + txin_p2sh_address = CBitcoinAddress.from_scriptPubKey(txin_scriptPubKey) + # print("Pay to:", str(txin_p2sh_address)) + return txin_p2sh_address + + +def step1_bob_carol_create_tx(txin_p2sh_address): + """Create transaction""" + txid = lx(TXID) + vout = 0 + txin = CMutableTxIn(COutPoint(txid, vout)) + txout = CMutableTxOut( + int(0.0005 * COIN), + CBitcoinAddress(str(txin_p2sh_address)).to_scriptPubKey(), + ) + tx = CMutableTransaction([txin], [txout]) + return tx, txin + + +def step2_carol_sign_tx(txin_redeemScript): + """Sign transaction with private key""" + seckey = step0_carol_privkey() + txin_p2sh_address = step1_carol_create_p2sh_address(txin_redeemScript) + tx, txin = step1_bob_carol_create_tx(txin_p2sh_address) + sighash = SignatureHash(txin_redeemScript, tx, 0, SIGHASH_ALL) + sig = seckey.sign(sighash) + bytes([SIGHASH_ALL]) + txin.scriptSig = CScript([sig, txin_redeemScript]) + return txin + + +def step3_bob_verify_script(txin_signature, txin_redeemScript): + txin_scriptPubKey = txin_redeemScript.to_p2sh_scriptPubKey() + + try: + VerifyScript(txin_signature, txin_scriptPubKey, tx, 0) + return True + except Exception as e: + print(e) + return False + + +if __name__ == "__main__": + # https://github.com/romanz/python-bitcointx/blob/master/examples/spend-p2sh-txout.py + # CAROL shares txin_p2sh_address with ALICE: + + # --------- + # CAROL defines scripthash and ALICE mints them + + txin_redeemScript = step0_carolt_checksig_redeemscrip(step0_carol_privkey().pub) + txin_p2sh_address = step1_carol_create_p2sh_address(txin_redeemScript) + print(f"Carol sends Alice secret = P2SH:{txin_p2sh_address}") + print("") + + # --------- + + # ALICE: mint tokens with secret SCRIPT:txin_p2sh_address + print(f"Alice mints tokens with secret = P2SH:{txin_p2sh_address}") + print("") + # ... + + # --------- + # CAROL redeems with MINT + + # CAROL PRODUCES txin_redeemScript and txin_signature to send to MINT + txin_redeemScript = step0_carolt_checksig_redeemscrip(step0_carol_privkey().pub) + txin_signature = step2_carol_sign_tx(txin_redeemScript).scriptSig + + txin_redeemScript_b64 = base64.urlsafe_b64encode(txin_redeemScript).decode() + txin_signature_b64 = base64.urlsafe_b64encode(txin_signature).decode() + print( + f"Carol to Bob:\nscript: {txin_redeemScript_b64}\nsignature: {txin_signature_b64}\n" + ) + print("") + # --------- + # MINT verifies SCRIPT and SIGNATURE and mints tokens + + # MINT receives txin_redeemScript_b64 and txin_signature_b64 fom CAROL: + txin_redeemScript = CScript(base64.urlsafe_b64decode(txin_redeemScript_b64)) + txin_signature = CScript(base64.urlsafe_b64decode(txin_signature_b64)) + + txin_p2sh_address = step1_carol_create_p2sh_address(txin_redeemScript) + print(f"Bob recreates secret: P2SH:{txin_p2sh_address}") + # MINT checks that SCRIPT:txin_p2sh_address has not been spent yet + # ... + tx, _ = step1_bob_carol_create_tx(txin_p2sh_address) + + print( + f"Bob verifies:\nscript: {txin_redeemScript_b64}\nsignature: {txin_signature_b64}\n" + ) + script_valid = step3_bob_verify_script(txin_signature, txin_redeemScript) + # MINT redeems tokens and stores SCRIPT:txin_p2sh_address + # ... + + print("Successfull.") + # print("Transaction:", b2x(tx.serialize())) From af54161cb30205445bf6d565e9819c5ff27ff7c4 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 2 Oct 2022 19:27:14 +0200 Subject: [PATCH 11/27] isort --- cashu/core/base.py | 7 ++- cashu/core/helpers.py | 3 +- cashu/core/script.py | 120 +++++++++++++++++++++++--------------- cashu/lightning/lnbits.py | 9 +-- cashu/mint/app.py | 2 +- cashu/mint/ledger.py | 69 +++++++++++++--------- cashu/mint/router.py | 3 +- cashu/wallet/cli.py | 35 +++++++++-- cashu/wallet/wallet.py | 47 ++++++--------- tests/test_wallet.py | 1 + 10 files changed, 179 insertions(+), 117 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index b91ee9c..7694ff8 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -4,11 +4,16 @@ from typing import List from pydantic import BaseModel +class P2SHScript(BaseModel): + script: str + signature: str + + class Proof(BaseModel): amount: int secret: str = "" C: str - script: str = "" + script: P2SHScript = None reserved: bool = False # whether this proof is reserved for sending send_id: str = "" # unique ID of send attempt time_created: str = "" diff --git a/cashu/core/helpers.py b/cashu/core/helpers.py index 0f8c885..680e773 100644 --- a/cashu/core/helpers.py +++ b/cashu/core/helpers.py @@ -1,7 +1,8 @@ import asyncio from functools import partial, wraps -from cashu.core.settings import LIGHTNING_FEE_PERCENT, LIGHTNING_RESERVE_FEE_MIN +from cashu.core.settings import (LIGHTNING_FEE_PERCENT, + LIGHTNING_RESERVE_FEE_MIN) def async_wrap(func): diff --git a/cashu/core/script.py b/cashu/core/script.py index bdfd728..e4359f0 100644 --- a/cashu/core/script.py +++ b/cashu/core/script.py @@ -1,49 +1,38 @@ -import hashlib import base64 +import hashlib +import random COIN = 100_000_000 TXID = "bff785da9f8169f49be92fa95e31f0890c385bfb1bd24d6b94d7900057c617ae" +SEED = b"__not__used" -from bitcoin.core import ( - lx, - COutPoint, - CMutableTxOut, - CMutableTxIn, - CMutableTransaction, -) +from bitcoin.core import (CMutableTxIn, CMutableTxOut, COutPoint, CTransaction, + lx) from bitcoin.core.script import * -from bitcoin.core.scripteval import VerifyScript -from bitcoin.wallet import CBitcoinAddress, CBitcoinSecret +from bitcoin.core.scripteval import (SCRIPT_VERIFY_P2SH, EvalScriptError, + VerifyScript, VerifyScriptError) +from bitcoin.wallet import CBitcoinSecret, P2SHBitcoinAddress def step0_carol_privkey(): """Private key""" - h = hashlib.sha256(b"correct horse battery staple").digest() + # h = hashlib.sha256(SEED).digest() + h = hashlib.sha256(str(random.getrandbits(256)).encode()).digest() seckey = CBitcoinSecret.from_secret_bytes(h) return seckey -def step0_carolt_checksig_redeemscrip(carol_pubkey): +def step0_carol_checksig_redeemscrip(carol_pubkey): """Create script""" txin_redeemScript = CScript([carol_pubkey, OP_CHECKSIG]) - # txin_redeemScript = CScript( - # [ - # 3, - # 3, - # OP_LESSTHANOREQUAL, - # OP_VERIFY, - # ] - # ) + # txin_redeemScript = CScript([-123, OP_CHECKLOCKTIMEVERIFY]) + # txin_redeemScript = CScript([3, 3, OP_LESSTHAN, OP_VERIFY]) return txin_redeemScript def step1_carol_create_p2sh_address(txin_redeemScript): """Create address (serialized scriptPubKey) to share with Alice""" - # print("Script:", b2x(txin_redeemScript)) - # returns [OP_HASH160, bitcointx.core.Hash160(self), OP_EQUAL] - txin_scriptPubKey = txin_redeemScript.to_p2sh_scriptPubKey() - txin_p2sh_address = CBitcoinAddress.from_scriptPubKey(txin_scriptPubKey) - # print("Pay to:", str(txin_p2sh_address)) + txin_p2sh_address = P2SHBitcoinAddress.from_redeemScript(txin_redeemScript) return txin_p2sh_address @@ -54,49 +43,81 @@ def step1_bob_carol_create_tx(txin_p2sh_address): txin = CMutableTxIn(COutPoint(txid, vout)) txout = CMutableTxOut( int(0.0005 * COIN), - CBitcoinAddress(str(txin_p2sh_address)).to_scriptPubKey(), + P2SHBitcoinAddress(str(txin_p2sh_address)).to_scriptPubKey(), ) - tx = CMutableTransaction([txin], [txout]) + tx = CTransaction([txin], [txout]) return tx, txin -def step2_carol_sign_tx(txin_redeemScript): +def step2_carol_sign_tx(txin_redeemScript, privatekey): """Sign transaction with private key""" - seckey = step0_carol_privkey() txin_p2sh_address = step1_carol_create_p2sh_address(txin_redeemScript) tx, txin = step1_bob_carol_create_tx(txin_p2sh_address) sighash = SignatureHash(txin_redeemScript, tx, 0, SIGHASH_ALL) - sig = seckey.sign(sighash) + bytes([SIGHASH_ALL]) + sig = privatekey.sign(sighash) + bytes([SIGHASH_ALL]) txin.scriptSig = CScript([sig, txin_redeemScript]) return txin -def step3_bob_verify_script(txin_signature, txin_redeemScript): +def step3_bob_verify_script(txin_signature, txin_redeemScript, tx): txin_scriptPubKey = txin_redeemScript.to_p2sh_scriptPubKey() - try: - VerifyScript(txin_signature, txin_scriptPubKey, tx, 0) + VerifyScript( + txin_signature, txin_scriptPubKey, tx, 0, flags=[SCRIPT_VERIFY_P2SH] + ) return True + except VerifyScriptError as e: + print("Could not verify script:", e) + except EvalScriptError as e: + print("Script did not evaluate:", e) + print(f"Script: {txin_scriptPubKey.__repr__()}") except Exception as e: print(e) - return False + return False +def verify_script(txin_redeemScript_b64, txin_signature_b64): + txin_redeemScript = CScript(base64.urlsafe_b64decode(txin_redeemScript_b64)) + print("Redeem script:", txin_redeemScript.__repr__()) + # txin_redeemScript = CScript([2, 3, OP_LESSTHAN, OP_VERIFY]) + txin_signature = CScript(value=base64.urlsafe_b64decode(txin_signature_b64)) + + txin_p2sh_address = step1_carol_create_p2sh_address(txin_redeemScript) + print(f"Bob recreates secret: P2SH:{txin_p2sh_address}") + # MINT checks that P2SH:txin_p2sh_address has not been spent yet + # ... + tx, _ = step1_bob_carol_create_tx(txin_p2sh_address) + + print( + f"Bob verifies:\nscript: {txin_redeemScript_b64}\nsignature: {txin_signature_b64}\n" + ) + script_valid = step3_bob_verify_script(txin_signature, txin_redeemScript, tx) + # MINT redeems tokens and stores P2SH:txin_p2sh_address + # ... + if script_valid: + print("Successfull.") + else: + print("Error.") + return txin_p2sh_address, script_valid + + +# simple test case if __name__ == "__main__": # https://github.com/romanz/python-bitcointx/blob/master/examples/spend-p2sh-txout.py # CAROL shares txin_p2sh_address with ALICE: # --------- # CAROL defines scripthash and ALICE mints them - - txin_redeemScript = step0_carolt_checksig_redeemscrip(step0_carol_privkey().pub) + alice_privkey = step0_carol_privkey() + txin_redeemScript = step0_carol_checksig_redeemscrip(alice_privkey.pub) + print("Script:", txin_redeemScript.__repr__()) txin_p2sh_address = step1_carol_create_p2sh_address(txin_redeemScript) print(f"Carol sends Alice secret = P2SH:{txin_p2sh_address}") print("") # --------- - # ALICE: mint tokens with secret SCRIPT:txin_p2sh_address + # ALICE: mint tokens with secret P2SH:txin_p2sh_address print(f"Alice mints tokens with secret = P2SH:{txin_p2sh_address}") print("") # ... @@ -105,13 +126,13 @@ if __name__ == "__main__": # CAROL redeems with MINT # CAROL PRODUCES txin_redeemScript and txin_signature to send to MINT - txin_redeemScript = step0_carolt_checksig_redeemscrip(step0_carol_privkey().pub) - txin_signature = step2_carol_sign_tx(txin_redeemScript).scriptSig + txin_redeemScript = step0_carol_checksig_redeemscrip(alice_privkey.pub) + txin_signature = step2_carol_sign_tx(txin_redeemScript, alice_privkey).scriptSig txin_redeemScript_b64 = base64.urlsafe_b64encode(txin_redeemScript).decode() txin_signature_b64 = base64.urlsafe_b64encode(txin_signature).decode() print( - f"Carol to Bob:\nscript: {txin_redeemScript_b64}\nsignature: {txin_signature_b64}\n" + f"Carol to Bob:\nscript: {txin_redeemScript.__repr__()}\nscript: {txin_redeemScript_b64}\nsignature: {txin_signature_b64}\n" ) print("") # --------- @@ -119,20 +140,25 @@ if __name__ == "__main__": # MINT receives txin_redeemScript_b64 and txin_signature_b64 fom CAROL: txin_redeemScript = CScript(base64.urlsafe_b64decode(txin_redeemScript_b64)) - txin_signature = CScript(base64.urlsafe_b64decode(txin_signature_b64)) + txin_redeemScript_p2sh = txin_p2sh_address.to_redeemScript() + print("Redeem script:", txin_redeemScript.__repr__()) + print("P2SH:", txin_redeemScript_p2sh.__repr__()) + # txin_redeemScript = CScript([2, 3, OP_LESSTHAN, OP_VERIFY]) + txin_signature = CScript(value=base64.urlsafe_b64decode(txin_signature_b64)) txin_p2sh_address = step1_carol_create_p2sh_address(txin_redeemScript) print(f"Bob recreates secret: P2SH:{txin_p2sh_address}") - # MINT checks that SCRIPT:txin_p2sh_address has not been spent yet + # MINT checks that P2SH:txin_p2sh_address has not been spent yet # ... tx, _ = step1_bob_carol_create_tx(txin_p2sh_address) print( f"Bob verifies:\nscript: {txin_redeemScript_b64}\nsignature: {txin_signature_b64}\n" ) - script_valid = step3_bob_verify_script(txin_signature, txin_redeemScript) - # MINT redeems tokens and stores SCRIPT:txin_p2sh_address + script_valid = step3_bob_verify_script(txin_signature, txin_redeemScript, tx) + # MINT redeems tokens and stores P2SH:txin_p2sh_address # ... - - print("Successfull.") - # print("Transaction:", b2x(tx.serialize())) + if script_valid: + print("Successfull.") + else: + print("Error.") diff --git a/cashu/lightning/lnbits.py b/cashu/lightning/lnbits.py index f174f2d..5a5a6de 100644 --- a/cashu/lightning/lnbits.py +++ b/cashu/lightning/lnbits.py @@ -8,13 +8,8 @@ import requests from cashu.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/cashu/mint/app.py b/cashu/mint/app.py index f90ce95..f0a6b73 100644 --- a/cashu/mint/app.py +++ b/cashu/mint/app.py @@ -5,7 +5,7 @@ import sys from fastapi import FastAPI from loguru import logger -from cashu.core.settings import VERSION, DEBUG +from cashu.core.settings import DEBUG, VERSION from cashu.lightning import WALLET from cashu.mint.migrations import m001_initial diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index ffe506e..452020c 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -3,24 +3,22 @@ Implementation of https://gist.github.com/phyro/935badc682057f418842c72961cf096c """ import hashlib +from inspect import signature +from signal import signal from typing import List, Set import cashu.core.b_dhke as b_dhke from cashu.core.base import BlindedMessage, BlindedSignature, Invoice, Proof from cashu.core.db import Database from cashu.core.helpers import fee_reserve +from cashu.core.script import verify_script from cashu.core.secp import PrivateKey, PublicKey from cashu.core.settings import LIGHTNING, MAX_ORDER from cashu.core.split import amount_split from cashu.lightning import WALLET -from cashu.mint.crud import ( - get_lightning_invoice, - get_proofs_used, - invalidate_proof, - store_lightning_invoice, - store_promise, - update_lightning_invoice, -) +from cashu.mint.crud import (get_lightning_invoice, get_proofs_used, + invalidate_proof, store_lightning_invoice, + store_promise, update_lightning_invoice) class Ledger: @@ -73,6 +71,11 @@ class Ledger: """Checks whether the proof was already spent.""" return not proof.secret in self.proofs_used + def _verify_secret_or_script(self, proof: Proof): + if proof.secret and proof.script: + raise Exception("secret and script present at the same time.") + return True + def _verify_secret_criteria(self, proof: Proof): if proof.secret is None or proof.secret == "": raise Exception("no secret in proof.") @@ -86,23 +89,35 @@ class Ledger: C = PublicKey(bytes.fromhex(proof.C), raw=True) return b_dhke.verify(secret_key, C, proof.secret) - def _verify_script(self, proof: Proof): - print(f"secret: {proof.secret}") - print(f"script: {proof.script}") - print( - f"script_hash: {hashlib.sha256(proof.script.encode('utf-8')).hexdigest()}" - ) - if len(proof.secret.split("SCRIPT:")) != 2: - return True - if len(proof.script) < 16: - raise Exception("Script error: not long enough.") + def _verify_script(self, idx: int, proof: Proof): if ( - hashlib.sha256(proof.script.encode("utf-8")).hexdigest() - != proof.secret.split("SCRIPT:")[1] + proof.script is None + or proof.script.script is None + or proof.script.signature is None ): - raise Exception("Script error: script hash not valid.") - print(f"Script {proof.script} valid.") - return True + if len(proof.secret.split("P2SH:")) == 2: + # secret indicates a script but no script is present + return False + else: + # secret indicates no script, so treat script as valid + return True + txin_p2sh_address, valid = verify_script( + proof.script.script, proof.script.signature + ) + # if len(proof.script) < 16: + # raise Exception("Script error: not long enough.") + # if ( + # hashlib.sha256(proof.script.encode("utf-8")).hexdigest() + # != proof.secret.split("P2SH:")[1] + # ): + # raise Exception("Script error: script hash not valid.") + print( + f"Script {proof.script.script.__repr__()} {'valid' if valid else 'invalid'}." + ) + if valid: + print(f"{idx}:P2SH:{txin_p2sh_address}") + proof.secret = f"{idx}:P2SH:{txin_p2sh_address}" + return valid def _verify_outputs( self, total: int, amount: int, output_data: List[BlindedMessage] @@ -255,6 +270,11 @@ class Ledger: """Consumes proofs and prepares new promises based on the amount split.""" total = sum([p.amount for p in proofs]) + # if not all([self._verify_secret_or_script(p) for p in proofs]): + # raise Exception("can't use secret and script at the same time.") + # Verify scripts + if not all([self._verify_script(i, p) for i, p in enumerate(proofs)]): + raise Exception("could not verify scripts.") # verify that amount is kosher self._verify_split_amount(amount) # verify overspending attempt @@ -272,9 +292,6 @@ class Ledger: # Verify proofs if not all([self._verify_proof(p) for p in proofs]): raise Exception("could not verify proofs.") - # Verify scripts - if not all([self._verify_script(p) for p in proofs]): - raise Exception("could not verify scripts.") # Mark proofs as used and prepare new promises await self._invalidate_proofs(proofs) diff --git a/cashu/mint/router.py b/cashu/mint/router.py index aca3adc..be656df 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -3,7 +3,8 @@ from typing import Union from fastapi import APIRouter from secp256k1 import PublicKey -from cashu.core.base import CheckPayload, MeltPayload, MintPayloads, SplitPayload +from cashu.core.base import (CheckPayload, MeltPayload, MintPayloads, + SplitPayload) from cashu.mint import ledger router: APIRouter = APIRouter() diff --git a/cashu/wallet/cli.py b/cashu/wallet/cli.py index 00a4a52..66979d8 100755 --- a/cashu/wallet/cli.py +++ b/cashu/wallet/cli.py @@ -5,7 +5,6 @@ import base64 import json import math import sys -from loguru import logger from datetime import datetime from functools import wraps from itertools import groupby @@ -13,12 +12,14 @@ from operator import itemgetter import click from bech32 import bech32_decode, bech32_encode, convertbits +from loguru import logger import cashu.core.bolt11 as bolt11 from cashu.core.base import Proof from cashu.core.bolt11 import Invoice from cashu.core.helpers import fee_reserve from cashu.core.migrations import migrate_databases +from cashu.core.script import * from cashu.core.settings import CASHU_DIR, DEBUG, LIGHTNING, MINT_URL, VERSION from cashu.wallet import migrations from cashu.wallet.crud import get_reserved_proofs @@ -123,19 +124,43 @@ async def send(ctx, amount: int, secret: str): @cli.command("receive", help="Receive tokens.") @click.argument("token", type=str) -@click.option("--secret", "-s", default="", help="Token spending condition.", type=str) -@click.option("--script", default=None, help="Token unlock script.", type=str) +@click.option("--secret", "-s", default=None, help="Token secret.", type=str) +@click.option("--script", default=None, help="Unlock script.", type=str) +@click.option("--signature", default=None, help="Script signature.", type=str) @click.pass_context @coro -async def receive(ctx, token: str, secret: str, script: str): +async def receive(ctx, token: str, secret: str, script: str, signature: str): wallet: Wallet = ctx.obj["WALLET"] wallet.load_mint() wallet.status() proofs = [Proof.from_dict(p) for p in json.loads(base64.urlsafe_b64decode(token))] - _, _ = await wallet.redeem(proofs, secret, script) + _, _ = await wallet.redeem( + proofs, snd_secret=secret, snd_script=script, snd_siganture=signature + ) wallet.status() +@cli.command("address", help="Generate receiving address.") +@click.pass_context +@coro +async def address(ctx): + alice_privkey = step0_carol_privkey() + txin_redeemScript = step0_carol_checksig_redeemscrip(alice_privkey.pub) + txin_p2sh_address = step1_carol_create_p2sh_address(txin_redeemScript) + print("Redeem script:", txin_redeemScript.__repr__()) + print(f"Receiving address: P2SH:{txin_p2sh_address}") + print("") + print(f"Send via command:\ncashu send --secret P2SH:{txin_p2sh_address}") + print("") + + txin_signature = step2_carol_sign_tx(txin_redeemScript, alice_privkey).scriptSig + txin_redeemScript_b64 = base64.urlsafe_b64encode(txin_redeemScript).decode() + txin_signature_b64 = base64.urlsafe_b64encode(txin_signature).decode() + print( + f"Receive via command:\ncashu receive --secret P2SH:{txin_p2sh_address} --script {txin_redeemScript_b64} --signature {txin_signature_b64}" + ) + + @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.") diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 9a98fa8..34b09f8 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -3,31 +3,20 @@ import json import secrets as scrts import uuid from typing import List -from loguru import logger import requests +from loguru import logger import cashu.core.b_dhke as b_dhke -from cashu.core.base import ( - BlindedMessage, - BlindedSignature, - CheckPayload, - MeltPayload, - MintPayloads, - Proof, - SplitPayload, -) +from cashu.core.base import (BlindedMessage, BlindedSignature, CheckPayload, + MeltPayload, MintPayloads, P2SHScript, Proof, + SplitPayload) from cashu.core.db import Database from cashu.core.secp import PublicKey from cashu.core.settings import DEBUG from cashu.core.split import amount_split -from cashu.wallet.crud import ( - get_proofs, - invalidate_proof, - store_proof, - update_proof_reserved, - secret_used, -) +from cashu.wallet.crud import (get_proofs, invalidate_proof, secret_used, + store_proof, update_proof_reserved) class LedgerAPI: @@ -132,9 +121,7 @@ class LedgerAPI: promises = [BlindedSignature.from_dict(p) for p in promises_list] return self._construct_proofs(promises, secrets, rs) - async def split( - self, proofs, amount, snd_secret: str = None, has_script: bool = False - ): + async def split(self, proofs, amount, snd_secret: str = None): """Consume proofs and create new promises based on amount split. If snd_secret is None, random secrets will be generated for the tokens to keep (fst_outputs) and the promises to send (snd_outputs). @@ -182,7 +169,7 @@ class LedgerAPI: else: raise Exception("Unkown mint error.") if "error" in promises_dict: - raise Exception("Error: {}".format(promises_dict["error"])) + raise Exception("Mint 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"]] # Construct proofs from promises (i.e., unblind signatures) @@ -245,7 +232,11 @@ class Wallet(LedgerAPI): return proofs async def redeem( - self, proofs: List[Proof], snd_secret: str = None, snd_script: str = None + self, + proofs: List[Proof], + snd_secret: str = None, + snd_script: str = None, + snd_siganture: str = None, ): if snd_secret: logger.debug(f"Redeption secret: {snd_secret}") @@ -254,13 +245,15 @@ class Wallet(LedgerAPI): # overload proofs with custom secrets for redemption for p, s in zip(proofs, snd_secrets): p.secret = s - if snd_script: + has_script = False + if snd_script and snd_siganture: + has_script = True logger.debug(f"Unlock script: {snd_script}") # overload proofs with unlock script for p in proofs: - p.script = snd_script + p.script = P2SHScript(script=snd_script, signature=snd_siganture) return await self.split( - proofs, sum(p["amount"] for p in proofs), has_script=snd_script is not None + proofs, sum(p["amount"] for p in proofs), has_script=has_script ) async def split( @@ -271,9 +264,7 @@ class Wallet(LedgerAPI): has_script: bool = False, ): assert len(proofs) > 0, ValueError("no proofs provided.") - fst_proofs, snd_proofs = await super().split( - proofs, amount, snd_secret, has_script - ) + fst_proofs, snd_proofs = await super().split(proofs, amount, snd_secret) if len(fst_proofs) == 0 and len(snd_proofs) == 0: raise Exception("received no splits.") used_secrets = [p["secret"] for p in proofs] diff --git a/tests/test_wallet.py b/tests/test_wallet.py index fa0c5b6..d6658bd 100644 --- a/tests/test_wallet.py +++ b/tests/test_wallet.py @@ -1,5 +1,6 @@ import time from re import S + from cashu.core.helpers import async_unwrap from cashu.core.migrations import migrate_databases from cashu.wallet import migrations From 50dc5a14ed2832566f738bddb422515cb51e479f Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 2 Oct 2022 19:47:15 +0200 Subject: [PATCH 12/27] requiremnts --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 192fb45..e773874 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,6 +26,7 @@ pydantic==1.10.2 ; python_version >= "3.7" and python_version < "4.0" pyparsing==3.0.9 ; python_version >= "3.7" and python_version < "4.0" pytest-asyncio==0.19.0 ; python_version >= "3.7" and python_version < "4.0" pytest==7.1.3 ; python_version >= "3.7" and python_version < "4.0" +python-bitcoinlib==0.11.2 ; python_version >= "3.7" and python_version < "4.0" python-dotenv==0.21.0 ; python_version >= "3.7" and python_version < "4.0" represent==1.6.0.post0 ; python_version >= "3.7" and python_version < "4.0" requests==2.27.1 ; python_version >= "3.7" and python_version < "4.0" From 68a2fcd691c7deaa8f43ddbe19c625b1ac928a18 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 2 Oct 2022 19:49:17 +0200 Subject: [PATCH 13/27] tests --- tests/test_wallet.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/test_wallet.py b/tests/test_wallet.py index d6658bd..8e61a80 100644 --- a/tests/test_wallet.py +++ b/tests/test_wallet.py @@ -55,7 +55,7 @@ async def run_test(): # Error: We try to double-spend by providing a valid proof twice await assert_err( wallet1.split(wallet1.proofs + proofs, 20), - f"Error: tokens already spent. Secret: {proofs[0]['secret']}", + f"Mint Error: tokens already spent. Secret: {proofs[0]['secret']}", ) assert wallet1.balance == 63 + 64 wallet1.status() @@ -72,7 +72,7 @@ async def run_test(): # Error: We try to double-spend and it fails await assert_err( wallet1.split([proofs[0]], 10), - f"Error: tokens already spent. Secret: {proofs[0]['secret']}", + f"Mint Error: tokens already spent. Secret: {proofs[0]['secret']}", ) assert wallet1.balance == 63 + 64 @@ -101,7 +101,7 @@ async def run_test(): # Error: We try to double-spend and it fails await assert_err( wallet1.split(w1_snd_proofs, 5), - f"Error: tokens already spent. Secret: {w1_snd_proofs[0]['secret']}", + f"Mint Error: tokens already spent. Secret: {w1_snd_proofs[0]['secret']}", ) assert wallet1.balance == 63 + 64 - 20 @@ -121,7 +121,7 @@ async def run_test(): # try to split an invalid amount await assert_err( wallet1.split(w1_snd_proofs, -500), - "Error: invalid split amount: -500", + "Mint Error: invalid split amount: -500", ) # mint with secrets @@ -136,13 +136,13 @@ async def run_test(): p.secret = "" await assert_err( wallet2.redeem(w1_snd_proofs_manipulated), - "Error: no secret in proof.", + "Mint Error: no secret in proof.", ) # redeem with wrong secret await assert_err( wallet2.redeem(w1_snd_proofs_manipulated, f"{secret}_asd"), - "Error: could not verify proofs.", + "Mint Error: could not verify proofs.", ) # redeem with correct secret @@ -152,7 +152,7 @@ async def run_test(): # NOTE: token indexing suffix _0 await assert_err( wallet2.redeem(w1_snd_proofs_manipulated, secret), - f"Error: tokens already spent. Secret: {secret}_0", + f"Mint Error: tokens already spent. Secret: 0:{secret}", ) From 34dfd2458769758264eeae7b77332a4977a58795 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 2 Oct 2022 19:51:34 +0200 Subject: [PATCH 14/27] fix typo --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 8728c77..07d30e3 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ with open(path.join(this_directory, "README.md"), encoding="utf-8") as f: with open("requirements.txt") as f: requirements = f.read().splitlines() -entry_points = {"console_scripts": ["cashu = cahu.wallet.cli:cli"]} +entry_points = {"console_scripts": ["cashu = cashu.wallet.cli:cli"]} setuptools.setup( name="cashu", From 46c5ef298d119fc2c1af32804fad6ec67e49d0b1 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 2 Oct 2022 22:05:09 +0200 Subject: [PATCH 15/27] multiple sends to address through random secret --- cashu/mint/ledger.py | 36 +++++++++++++++----------- cashu/wallet/cli.py | 52 +++++++++++++++++++++++-------------- cashu/wallet/wallet.py | 59 +++++++++++++++++++++++------------------- 3 files changed, 87 insertions(+), 60 deletions(-) diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 452020c..797618d 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -16,9 +16,14 @@ from cashu.core.secp import PrivateKey, PublicKey from cashu.core.settings import LIGHTNING, MAX_ORDER from cashu.core.split import amount_split from cashu.lightning import WALLET -from cashu.mint.crud import (get_lightning_invoice, get_proofs_used, - invalidate_proof, store_lightning_invoice, - store_promise, update_lightning_invoice) +from cashu.mint.crud import ( + get_lightning_invoice, + get_proofs_used, + invalidate_proof, + store_lightning_invoice, + store_promise, + update_lightning_invoice, +) class Ledger: @@ -104,19 +109,20 @@ class Ledger: txin_p2sh_address, valid = verify_script( proof.script.script, proof.script.signature ) - # if len(proof.script) < 16: - # raise Exception("Script error: not long enough.") - # if ( - # hashlib.sha256(proof.script.encode("utf-8")).hexdigest() - # != proof.secret.split("P2SH:")[1] - # ): - # raise Exception("Script error: script hash not valid.") - print( - f"Script {proof.script.script.__repr__()} {'valid' if valid else 'invalid'}." - ) if valid: - print(f"{idx}:P2SH:{txin_p2sh_address}") - proof.secret = f"{idx}:P2SH:{txin_p2sh_address}" + # check if secret commits to script address + # format: P2SH:
: + assert len(proof.secret.split(":")) == 3, "secret format wrong" + assert proof.secret.split(":")[1] == str( + txin_p2sh_address + ), f"secret does not contain P2SH address: {proof.secret.split(':')[1]}!={txin_p2sh_address}" + # print( + # f"Script {proof.script.script.__repr__()} {'valid' if valid else 'invalid'}." + # ) + # if valid: + # print(f"{idx}:P2SH:{txin_p2sh_address}") + # print("proof.secret", proof.secret) + # proof.secret = f"{idx}:P2SH:{txin_p2sh_address}" return valid def _verify_outputs( diff --git a/cashu/wallet/cli.py b/cashu/wallet/cli.py index 66979d8..7ab0468 100755 --- a/cashu/wallet/cli.py +++ b/cashu/wallet/cli.py @@ -101,22 +101,23 @@ async def balance(ctx): @cli.command("send", help="Send tokens.") @click.argument("amount", type=int) -@click.option( - "--secret", "-s", default=None, help="Token spending condition.", type=str -) +@click.option("--lock", "-l", default=None, help="Token lock (P2SH address).", type=str) @click.pass_context @coro -async def send(ctx, amount: int, secret: str): - if secret and len(secret) < 22: - print("Error: secret has to be at least 22 characters long.") +async def send(ctx, amount: int, lock: str): + if lock and len(lock) < 22: + print("Error: lock has to be at least 22 characters long.") return + p2sh = False + if len(lock.split("P2SH:")) == 2: + p2sh = True wallet: Wallet = ctx.obj["WALLET"] wallet.load_mint() wallet.status() - _, send_proofs = await wallet.split_to_send(wallet.proofs, amount, secret) + _, send_proofs = await wallet.split_to_send(wallet.proofs, amount, lock) await wallet.set_reserved(send_proofs, reserved=True) token = await wallet.serialize_proofs( - send_proofs, hide_secrets=True if secret else False + send_proofs, hide_secrets=True if lock and not p2sh else False ) print(token) wallet.status() @@ -124,19 +125,23 @@ async def send(ctx, amount: int, secret: str): @cli.command("receive", help="Receive tokens.") @click.argument("token", type=str) -@click.option("--secret", "-s", default=None, help="Token secret.", type=str) -@click.option("--script", default=None, help="Unlock script.", type=str) -@click.option("--signature", default=None, help="Script signature.", type=str) +# @click.option("--secret", "-s", default=None, help="Token secret.", type=str) +@click.option("--unlock", "-u", default=None, help="Unlock script.", type=str) +# @click.option("--signature", default=None, help="Script signature.", type=str) @click.pass_context @coro -async def receive(ctx, token: str, secret: str, script: str, signature: str): +async def receive(ctx, token: str, unlock: str): wallet: Wallet = ctx.obj["WALLET"] wallet.load_mint() wallet.status() + if unlock: + assert ( + len(unlock.split(":")) == 2 + ), "unlock format wrong, expected