diff --git a/cashu/core/base.py b/cashu/core/base.py index a78f7ee..00c9a0e 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -4,10 +4,26 @@ from typing import List from pydantic import BaseModel +class P2SHScript(BaseModel): + script: str + signature: str + address: str = None + + @classmethod + def from_row(cls, row: Row): + return cls( + address=row[0], + script=row[1], + signature=row[2], + used=row[3], + ) + + class Proof(BaseModel): amount: int - secret: str + secret: str = "" C: 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 = "" @@ -27,12 +43,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 +57,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/db.py b/cashu/core/db.py index e393aa1..5b05af6 100644 --- a/cashu/core/db.py +++ b/cashu/core/db.py @@ -83,45 +83,45 @@ class Database(Compat): self.name = db_name self.db_location = db_location self.db_location_is_url = "://" in self.db_location - if self.db_location_is_url: - database_uri = self.db_location + raise Exception("Remote databases not supported. Use SQLite.") + # database_uri = self.db_location - if database_uri.startswith("cockroachdb://"): - self.type = COCKROACH - else: - self.type = POSTGRES + # if database_uri.startswith("cockroachdb://"): + # self.type = COCKROACH + # else: + # self.type = POSTGRES - import psycopg2 # type: ignore + # import psycopg2 # type: ignore - def _parse_timestamp(value, _): - f = "%Y-%m-%d %H:%M:%S.%f" - if not "." in value: - f = "%Y-%m-%d %H:%M:%S" - return time.mktime(datetime.datetime.strptime(value, f).timetuple()) + # def _parse_timestamp(value, _): + # f = "%Y-%m-%d %H:%M:%S.%f" + # if not "." in value: + # f = "%Y-%m-%d %H:%M:%S" + # return time.mktime(datetime.datetime.strptime(value, f).timetuple()) - psycopg2.extensions.register_type( - psycopg2.extensions.new_type( - psycopg2.extensions.DECIMAL.values, - "DEC2FLOAT", - lambda value, curs: float(value) if value is not None else None, - ) - ) - psycopg2.extensions.register_type( - psycopg2.extensions.new_type( - (1082, 1083, 1266), - "DATE2INT", - lambda value, curs: time.mktime(value.timetuple()) - if value is not None - else None, - ) - ) + # psycopg2.extensions.register_type( + # psycopg2.extensions.new_type( + # psycopg2.extensions.DECIMAL.values, + # "DEC2FLOAT", + # lambda value, curs: float(value) if value is not None else None, + # ) + # ) + # psycopg2.extensions.register_type( + # psycopg2.extensions.new_type( + # (1082, 1083, 1266), + # "DATE2INT", + # lambda value, curs: time.mktime(value.timetuple()) + # if value is not None + # else None, + # ) + # ) - psycopg2.extensions.register_type( - psycopg2.extensions.new_type( - (1184, 1114), "TIMESTAMP2INT", _parse_timestamp - ) - ) + # psycopg2.extensions.register_type( + # psycopg2.extensions.new_type( + # (1184, 1114), "TIMESTAMP2INT", _parse_timestamp + # ) + # ) else: if not os.path.exists(self.db_location): print(f"Creating database directory: {self.db_location}") 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 new file mode 100644 index 0000000..ef81a7a --- /dev/null +++ b/cashu/core/script.py @@ -0,0 +1,166 @@ +import base64 +import hashlib +import random + +COIN = 100_000_000 +TXID = "bff785da9f8169f49be92fa95e31f0890c385bfb1bd24d6b94d7900057c617ae" +SEED = b"__not__used" + +from bitcoin.core import CMutableTxIn, CMutableTxOut, COutPoint, CTransaction, lx +from bitcoin.core.script import * +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(SEED).digest() + h = hashlib.sha256(str(random.getrandbits(256)).encode()).digest() + seckey = CBitcoinSecret.from_secret_bytes(h) + return seckey + + +def step0_carol_checksig_redeemscrip(carol_pubkey): + """Create script""" + txin_redeemScript = CScript([carol_pubkey, OP_CHECKSIG]) + # 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""" + txin_p2sh_address = P2SHBitcoinAddress.from_redeemScript(txin_redeemScript) + 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), + P2SHBitcoinAddress(str(txin_p2sh_address)).to_scriptPubKey(), + ) + tx = CTransaction([txin], [txout]) + return tx, txin + + +def step2_carol_sign_tx(txin_redeemScript, privatekey): + """Sign transaction with private key""" + 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 = privatekey.sign(sighash) + bytes([SIGHASH_ALL]) + txin.scriptSig = CScript([sig, txin_redeemScript]) + return txin + + +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, flags=[SCRIPT_VERIFY_P2SH] + ) + return True + except VerifyScriptError as e: + raise Exception("Script verification failed:", e) + except EvalScriptError as e: + print(f"Script: {txin_scriptPubKey.__repr__()}") + raise Exception("Script evaluation failed:", e) + except Exception as e: + raise Exception("Script execution failed:", e) + + +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 + 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 P2SH: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_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.__repr__()}\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_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 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.") diff --git a/cashu/core/settings.py b/cashu/core/settings.py index 36ec220..41003ce 100644 --- a/cashu/core/settings.py +++ b/cashu/core/settings.py @@ -1,12 +1,25 @@ +import os +import sys from pathlib import Path - from environs import Env # type: ignore +from loguru import logger env = Env() -env.read_env() + +ENV_FILE = os.path.join(str(Path.home()), ".cashu", ".env") +if not os.path.isfile(ENV_FILE): + ENV_FILE = os.path.join(os.getcwd(), ".env") +if os.path.isfile(ENV_FILE): + env.read_env(ENV_FILE) +else: + ENV_FILE = None + env.read_env() DEBUG = env.bool("DEBUG", default=False) -CASHU_DIR = env.str("CASHU_DIR", default="~/.cashu") +if not DEBUG: + sys.tracebacklimit = 0 + +CASHU_DIR = env.str("CASHU_DIR", default=os.path.join(str(Path.home()), ".cashu")) CASHU_DIR = CASHU_DIR.replace("~", str(Path.home())) assert len(CASHU_DIR), "CASHU_DIR not defined" @@ -32,4 +45,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/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 8b722fd..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 @@ -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/crud.py b/cashu/mint/crud.py index 18a7195..a608967 100644 --- a/cashu/mint/crud.py +++ b/cashu/mint/crud.py @@ -93,7 +93,7 @@ async def get_lightning_invoice( SELECT * from invoices WHERE hash = ? """, - hash, + (hash,), ) return Invoice.from_row(row) @@ -106,5 +106,8 @@ async def update_lightning_invoice( ): await (conn or db).execute( "UPDATE invoices SET issued = ? WHERE hash = ?", - (issued, hash), + ( + issued, + hash, + ), ) diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 13f6310..522e54d 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -3,12 +3,15 @@ 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 @@ -27,9 +30,9 @@ class Ledger: def __init__(self, secret_key: str, db: str): self.proofs_used: Set[str] = set() - self.master_key: str = secret_key - self.keys: List[PrivateKey] = self._derive_keys(self.master_key) - self.pub_keys: List[PublicKey] = self._derive_pubkeys(self.keys) + self.master_key = secret_key + self.keys = self._derive_keys(self.master_key) + self.pub_keys = self._derive_pubkeys(self.keys) self.db: Database = Database("mint", db) async def load_used_proofs(self): @@ -73,6 +76,16 @@ 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.") + 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): @@ -81,6 +94,36 @@ class Ledger: C = PublicKey(bytes.fromhex(proof.C), raw=True) return b_dhke.verify(secret_key, C, proof.secret) + def _verify_script(self, idx: int, proof: Proof): + """ + Verify bitcoin script in proof.script commited to by
in proof.secret. + proof.secret format: P2SH:
: + """ + # if no script is given + if ( + proof.script is None + or proof.script.script is None + or proof.script.signature is None + ): + 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 + # execute and verify P2SH + txin_p2sh_address, valid = verify_script( + proof.script.script, proof.script.signature + ) + if valid: + # 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 correct P2SH address: {proof.secret.split(':')[1]}!={txin_p2sh_address}." + return valid + def _verify_outputs( self, total: int, amount: int, output_data: List[BlindedMessage] ): @@ -150,7 +193,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) @@ -227,32 +270,43 @@ 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 scripts + if not all([self._verify_script(i, p) for i, p in enumerate(proofs)]): + raise Exception("script verification failed.") + # 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 + 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) + # verify amounts in produced proofs self._verify_equation_balanced(proofs, prom_fst + prom_snd) return prom_fst, prom_snd diff --git a/cashu/mint/router.py b/cashu/mint/router.py index b7ebf05..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() @@ -82,7 +83,6 @@ 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.") + 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/mint/startup.py b/cashu/mint/startup.py index a2c4653..1207091 100644 --- a/cashu/mint/startup.py +++ b/cashu/mint/startup.py @@ -2,7 +2,7 @@ import asyncio from loguru import logger -from cashu.core.settings import CASHU_DIR +from cashu.core.settings import CASHU_DIR, LIGHTNING from cashu.lightning import WALLET from cashu.mint.migrations import m001_initial @@ -13,13 +13,14 @@ async def load_ledger(): await asyncio.wait([m001_initial(ledger.db)]) await ledger.load_used_proofs() - error_message, balance = await WALLET.status() - if error_message: - logger.warning( - f"The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'", - RuntimeWarning, - ) + if LIGHTNING: + error_message, balance = await WALLET.status() + if error_message: + logger.warning( + f"The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'", + RuntimeWarning, + ) + logger.info(f"Lightning balance: {balance} sat") - logger.info(f"Lightning balance: {balance} sat") logger.info(f"Data dir: {CASHU_DIR}") logger.info("Mint started.") diff --git a/cashu/wallet/cli.py b/cashu/wallet/cli.py index 09a3f4a..ebc0edf 100755 --- a/cashu/wallet/cli.py +++ b/cashu/wallet/cli.py @@ -4,22 +4,24 @@ import asyncio import base64 import json import math +import os +import sys from datetime import datetime from functools import wraps from itertools import groupby 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.settings import CASHU_DIR, DEBUG, LIGHTNING, MINT_URL, VERSION +from cashu.core.settings import CASHU_DIR, DEBUG, LIGHTNING, MINT_URL, VERSION, ENV_FILE from cashu.wallet import migrations -from cashu.wallet.crud import get_reserved_proofs +from cashu.wallet.crud import get_reserved_proofs, get_unused_locks from cashu.wallet.wallet import Wallet as Wallet @@ -37,14 +39,19 @@ class NaturalOrderGroup(click.Group): @click.group(cls=NaturalOrderGroup) -@click.option("--host", "-h", default=MINT_URL, help="Mint address.") -@click.option("--wallet", "-w", "walletname", default="wallet", help="Wallet to use.") +@click.option("--host", "-h", default=MINT_URL, help="Mint URL.") +@click.option("--wallet", "-w", "walletname", default="wallet", help="Wallet name.") @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"], os.path.join(CASHU_DIR, walletname)) + ctx.obj["WALLET"] = wallet + asyncio.run(init_wallet(wallet)) pass @@ -57,14 +64,14 @@ def coro(f): return wrapper -@cli.command("mint", help="Mint tokens.") +@cli.command("mint", help="Mint.") @click.argument("amount", type=int) @click.option("--hash", default="", help="Hash of the paid invoice.", type=str) @click.pass_context @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) @@ -83,57 +90,79 @@ async def mint(ctx, amount: int, hash: str): return -@cli.command("balance", help="See balance.") +@cli.command("balance", help="Balance.") @click.pass_context @coro async def balance(ctx): wallet: Wallet = ctx.obj["WALLET"] - await init_wallet(wallet) wallet.status() -@cli.command("send", help="Send tokens.") +@cli.command("send", help="Send coins.") @click.argument("amount", type=int) +@click.option("--lock", "-l", default=None, help="Lock coins (P2SH).", type=str) @click.pass_context @coro -async def send(ctx, amount: int): +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 lock and len(lock.split("P2SH:")) == 2: + p2sh = True wallet: Wallet = ctx.obj["WALLET"] - await init_wallet(wallet) + wallet.load_mint() wallet.status() - _, send_proofs = await wallet.split_to_send(wallet.proofs, amount) + _, 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) - print(token) + coin = await wallet.serialize_proofs( + send_proofs, hide_secrets=True if lock and not p2sh else False + ) + print(coin) wallet.status() -@cli.command("receive", help="Receive tokens.") -@click.argument("token", type=str) +@cli.command("receive", help="Receive coins.") +@click.argument("coin", type=str) +@click.option("--lock", "-l", default=None, help="Unlock coins.", type=str) @click.pass_context @coro -async def receive(ctx, token: str): +async def receive(ctx, coin: str, lock: str): wallet: Wallet = ctx.obj["WALLET"] - await init_wallet(wallet) + wallet.load_mint() wallet.status() - proofs = [Proof.from_dict(p) for p in json.loads(base64.urlsafe_b64decode(token))] - _, _ = await wallet.redeem(proofs) + if lock: + # load the script and signature of this address from the database + assert len(lock.split("P2SH:")) == 2, Exception( + "lock has wrong format. Expected P2SH:
." + ) + address_split = lock.split("P2SH:")[1] + + p2shscripts = await get_unused_locks(address_split, db=wallet.db) + assert len(p2shscripts) == 1 + script = p2shscripts[0].script + signature = p2shscripts[0].signature + else: + script, signature = None, None + proofs = [Proof.from_dict(p) for p in json.loads(base64.urlsafe_b64decode(coin))] + _, _ = await wallet.redeem(proofs, snd_script=script, snd_siganture=signature) wallet.status() -@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.") +@cli.command("burn", help="Burn spent coins.") +@click.argument("coin", required=False, type=str) +@click.option("--all", "-a", default=False, is_flag=True, help="Burn all spent coins.") @click.option( - "--force", "-f", default=False, is_flag=True, help="Force check on all tokens." + "--force", "-f", default=False, is_flag=True, help="Force check on all coins." ) @click.pass_context @coro -async def burn(ctx, token: str, all: bool, force: bool): +async def burn(ctx, coin: str, all: bool, force: bool): wallet: Wallet = ctx.obj["WALLET"] - await init_wallet(wallet) - if not (all or token or force) or (token and all): + wallet.load_mint() + if not (all or coin or force) or (coin and all): print( - "Error: enter a token or use --all to burn all pending tokens or --force to check all tokens." + "Error: enter a coin or use --all to burn all pending coins or --force to check all coins." ) return if all: @@ -145,43 +174,49 @@ async def burn(ctx, token: str, all: bool, force: bool): else: # check only the specified ones proofs = [ - Proof.from_dict(p) for p in json.loads(base64.urlsafe_b64decode(token)) + Proof.from_dict(p) for p in json.loads(base64.urlsafe_b64decode(coin)) ] wallet.status() await wallet.invalidate(proofs) wallet.status() -@cli.command("pending", help="Show pending tokens.") +@cli.command("pending", help="Show pending coins.") @click.pass_context @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): + print(f"--------------------------\n") sorted_proofs = sorted(reserved_proofs, key=itemgetter("send_id")) - for key, value in groupby(sorted_proofs, key=itemgetter("send_id")): + for i, (key, value) in enumerate( + groupby(sorted_proofs, key=itemgetter("send_id")) + ): grouped_proofs = list(value) - token = await wallet.serialize_proofs(grouped_proofs) + coin = await wallet.serialize_proofs(grouped_proofs) + coin_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"#{i} Amount: {sum([p['amount'] for p in grouped_proofs])} sat Time: {reserved_date} ID: {key}\n" ) - print(token) - print("") + print(f"With secret: {coin}\n\nSecretless: {coin_hidden_secret}\n") + print(f"--------------------------\n") wallet.status() -@cli.command("pay", help="Pay lightning invoice.") +@cli.command("pay", help="Pay Lightning invoice.") @click.argument("invoice", type=str) @click.pass_context @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( @@ -199,6 +234,47 @@ async def pay(ctx, invoice: str): wallet.status() +@cli.command("lock", help="Generate receiving lock.") +@click.pass_context +@coro +async def lock(ctx): + wallet: Wallet = ctx.obj["WALLET"] + p2shscript = await wallet.create_p2sh_lock() + txin_p2sh_address = p2shscript.address + print("---- Pay to script hash (P2SH) ----\n") + print("Use a lock to receive coins that only you can unlock.") + print("") + print(f"Public receiving lock: P2SH:{txin_p2sh_address}") + print("") + print( + f"Anyone can send coins to this lock:\n\ncashu send --lock P2SH:{txin_p2sh_address}" + ) + print("") + print( + f"Only you can receive coins from this lock:\n\ncashu receive --lock P2SH:{txin_p2sh_address}\n" + ) + + +@cli.command("locks", help="Show unused receiving locks.") +@click.pass_context +@coro +async def locks(ctx): + wallet: Wallet = ctx.obj["WALLET"] + locks = await get_unused_locks(db=wallet.db) + if len(locks): + print("") + print(f"--------------------------\n") + for l in locks: + print(f"Address: {l.address}") + print(f"Script: {l.script}") + print(f"Signature: {l.signature}") + print("") + print(f"Receive: cashu receive --lock P2SH:{l.address}") + print("") + print(f"--------------------------\n") + return True + + @cli.command("info", help="Information about Cashu wallet.") @click.pass_context @coro @@ -206,5 +282,8 @@ async def info(ctx): print(f"Version: {VERSION}") print(f"Debug: {DEBUG}") print(f"Cashu dir: {CASHU_DIR}") + if ENV_FILE: + print(f"Settings: {ENV_FILE}") + print(f"Wallet: {ctx.obj['WALLET_NAME']}") print(f"Mint URL: {MINT_URL}") return diff --git a/cashu/wallet/crud.py b/cashu/wallet/crud.py index c5741b6..bacb968 100644 --- a/cashu/wallet/crud.py +++ b/cashu/wallet/crud.py @@ -1,7 +1,7 @@ import time -from typing import Optional +from typing import Optional, List, Any -from cashu.core.base import Proof +from cashu.core.base import Proof, P2SHScript from cashu.core.db import Connection, Database @@ -59,7 +59,7 @@ async def invalidate_proof( DELETE FROM proofs WHERE secret = ? """, - str(proof["secret"]), + (str(proof["secret"]),), ) await (conn or db).execute( @@ -80,7 +80,7 @@ async def update_proof_reserved( conn: Optional[Connection] = None, ): clauses = [] - values = [] + values: List[Any] = [] clauses.append("reserved = ?") values.append(reserved) @@ -97,3 +97,86 @@ 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 + + +async def store_p2sh( + p2sh: P2SHScript, + db: Database, + conn: Optional[Connection] = None, +): + + await (conn or db).execute( + """ + INSERT INTO p2sh + (address, script, signature, used) + VALUES (?, ?, ?, ?) + """, + ( + p2sh.address, + p2sh.script, + p2sh.signature, + False, + ), + ) + + +async def get_unused_locks( + address: str = None, + db: Database = None, + conn: Optional[Connection] = None, +): + + clause: List[str] = [] + args: List[str] = [] + + clause.append("used = 0") + + if address: + clause.append("address = ?") + args.append(address) + + where = "" + if clause: + where = f"WHERE {' AND '.join(clause)}" + + rows = await (conn or db).fetchall( + f""" + SELECT * from p2sh + {where} + """, + tuple(args), + ) + return [P2SHScript.from_row(r) for r in rows] + + +async def update_p2sh_used( + p2sh: P2SHScript, + used: bool, + db: Database = None, + conn: Optional[Connection] = None, +): + clauses = [] + values = [] + clauses.append("used = ?") + values.append(used) + + await (conn or db).execute( + f"UPDATE proofs SET {', '.join(clauses)} WHERE address = ?", + (*values, str(p2sh.address)), + ) diff --git a/cashu/wallet/migrations.py b/cashu/wallet/migrations.py index c8b3ec0..68b4b64 100644 --- a/cashu/wallet/migrations.py +++ b/cashu/wallet/migrations.py @@ -79,3 +79,22 @@ async def m003_add_proofs_sendid_and_timestamps(db): await db.execute("ALTER TABLE proofs ADD COLUMN time_created TIMESTAMP") await db.execute("ALTER TABLE proofs ADD COLUMN time_reserved TIMESTAMP") await db.execute("ALTER TABLE proofs_used ADD COLUMN time_used TIMESTAMP") + + +async def m004_p2sh_locks(db: Database): + """ + Stores P2SH addresses and unlock scripts. + """ + await db.execute( + """ + CREATE TABLE IF NOT EXISTS p2sh ( + address TEXT NOT NULL, + script TEXT NOT NULL, + signature TEXT NOT NULL, + used BOOL NOT NULL, + + UNIQUE (address, script, signature) + + ); + """ + ) diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 27d1ba6..b941869 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -5,6 +5,7 @@ import uuid from typing import List import requests +from loguru import logger import cashu.core.b_dhke as b_dhke from cashu.core.base import ( @@ -13,9 +14,16 @@ from cashu.core.base import ( CheckPayload, MeltPayload, MintPayloads, + P2SHScript, Proof, SplitPayload, ) +from cashu.core.script import ( + step0_carol_privkey, + step0_carol_checksig_redeemscrip, + step1_carol_create_p2sh_address, + step2_carol_sign_tx, +) from cashu.core.db import Database from cashu.core.secp import PublicKey from cashu.core.settings import DEBUG @@ -23,15 +31,16 @@ from cashu.core.split import amount_split from cashu.wallet.crud import ( get_proofs, invalidate_proof, + secret_used, store_proof, update_proof_reserved, + store_p2sh, ) class LedgerAPI: def __init__(self, url): self.url = url - self.keys = self._get_keys(url) @staticmethod def _get_keys(url): @@ -51,79 +60,144 @@ 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) proofs.append(proof) return proofs - def _generate_secret(self, randombits=128): + @staticmethod + def _generate_secret(randombits=128): """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 + + 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}") + + def generate_secrets(self, secret, n): + """`secret` is the base string that will be tweaked n times""" + if len(secret.split("P2SH:")) == 2: + return [f"{secret}:{self._generate_secret()}" 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.""" + 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( 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) + + 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). + + 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]) 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_secret is None: + secrets = [self._generate_secret() for _ in range(len(amounts))] + else: + snd_secrets = self.generate_secrets(snd_secret, len(snd_outputs)) + logger.debug(f"Creating proofs with custom secrets: {snd_secrets}") + 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" + await self._check_used_secrets(secrets) + 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"])) + 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"]] - # 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 +228,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) @@ -166,19 +243,34 @@ 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]): + async def redeem( + self, + proofs: List[Proof], + snd_script: str = None, + snd_siganture: str = None, + ): + if snd_script and snd_siganture: + logger.debug(f"Unlock script: {snd_script}") + # attach unlock scripts to proofs + for p in proofs: + p.script = P2SHScript(script=snd_script, signature=snd_siganture) 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_secret: str = None, + ): assert len(proofs) > 0, ValueError("no proofs provided.") - fst_proofs, snd_proofs = super().split(proofs, amount) + 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] @@ -201,18 +293,25 @@ 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_secret: str = None): """Like self.split but only considers non-reserved tokens.""" + 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) + return await self.split( + [p for p in proofs if not p.reserved], amount, snd_secret + ) async def set_reserved(self, proofs: List[Proof], reserved: bool): """Mark a proof as reserved to avoid reuse or delete marking.""" @@ -239,6 +338,21 @@ class Wallet(LedgerAPI): filter(lambda p: p["secret"] not in invalidate_secrets, self.proofs) ) + async def create_p2sh_lock(self): + alice_privkey = step0_carol_privkey() + txin_redeemScript = step0_carol_checksig_redeemscrip(alice_privkey.pub) + txin_p2sh_address = step1_carol_create_p2sh_address(txin_redeemScript) + 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() + p2shScript = P2SHScript( + script=txin_redeemScript_b64, + signature=txin_signature_b64, + address=str(txin_p2sh_address), + ) + await store_p2sh(p2shScript, db=self.db) + return p2shScript + @property def balance(self): return sum(p["amount"] for p in self.proofs) @@ -249,7 +363,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/poetry.lock b/poetry.lock index 7224f57..038564d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -344,14 +344,6 @@ importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] -[[package]] -name = "psycopg2-binary" -version = "2.9.3" -description = "psycopg2 - Python-PostgreSQL Database Adapter" -category = "main" -optional = false -python-versions = ">=3.6" - [[package]] name = "py" version = "1.11.0" @@ -430,6 +422,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 +632,7 @@ testing = ["func-timeout", "jaraco.itertools", "pytest (>=6)", "pytest-black (>= [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "fbb8f977f71b77cf9b6514134111dc466f7fa1f70f8114e47b858d85c39199e4" +content-hash = "b4e980ee90226bab07750b1becc8c69df7752f6d168d200a79c782aa1efe61da" [metadata.files] anyio = [ @@ -848,64 +848,6 @@ pluggy = [ {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, ] -psycopg2-binary = [ - {file = "psycopg2-binary-2.9.3.tar.gz", hash = "sha256:761df5313dc15da1502b21453642d7599d26be88bff659382f8f9747c7ebea4e"}, - {file = "psycopg2_binary-2.9.3-cp310-cp310-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:539b28661b71da7c0e428692438efbcd048ca21ea81af618d845e06ebfd29478"}, - {file = "psycopg2_binary-2.9.3-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e82d38390a03da28c7985b394ec3f56873174e2c88130e6966cb1c946508e65"}, - {file = "psycopg2_binary-2.9.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:57804fc02ca3ce0dbfbef35c4b3a4a774da66d66ea20f4bda601294ad2ea6092"}, - {file = "psycopg2_binary-2.9.3-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:083a55275f09a62b8ca4902dd11f4b33075b743cf0d360419e2051a8a5d5ff76"}, - {file = "psycopg2_binary-2.9.3-cp310-cp310-manylinux_2_24_ppc64le.whl", hash = "sha256:0a29729145aaaf1ad8bafe663131890e2111f13416b60e460dae0a96af5905c9"}, - {file = "psycopg2_binary-2.9.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:3a79d622f5206d695d7824cbf609a4f5b88ea6d6dab5f7c147fc6d333a8787e4"}, - {file = "psycopg2_binary-2.9.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:090f3348c0ab2cceb6dfbe6bf721ef61262ddf518cd6cc6ecc7d334996d64efa"}, - {file = "psycopg2_binary-2.9.3-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:a9e1f75f96ea388fbcef36c70640c4efbe4650658f3d6a2967b4cc70e907352e"}, - {file = "psycopg2_binary-2.9.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c3ae8e75eb7160851e59adc77b3a19a976e50622e44fd4fd47b8b18208189d42"}, - {file = "psycopg2_binary-2.9.3-cp310-cp310-win32.whl", hash = "sha256:7b1e9b80afca7b7a386ef087db614faebbf8839b7f4db5eb107d0f1a53225029"}, - {file = "psycopg2_binary-2.9.3-cp310-cp310-win_amd64.whl", hash = "sha256:8b344adbb9a862de0c635f4f0425b7958bf5a4b927c8594e6e8d261775796d53"}, - {file = "psycopg2_binary-2.9.3-cp36-cp36m-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:e847774f8ffd5b398a75bc1c18fbb56564cda3d629fe68fd81971fece2d3c67e"}, - {file = "psycopg2_binary-2.9.3-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:68641a34023d306be959101b345732360fc2ea4938982309b786f7be1b43a4a1"}, - {file = "psycopg2_binary-2.9.3-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3303f8807f342641851578ee7ed1f3efc9802d00a6f83c101d21c608cb864460"}, - {file = "psycopg2_binary-2.9.3-cp36-cp36m-manylinux_2_24_aarch64.whl", hash = "sha256:e3699852e22aa68c10de06524a3721ade969abf382da95884e6a10ff798f9281"}, - {file = "psycopg2_binary-2.9.3-cp36-cp36m-manylinux_2_24_ppc64le.whl", hash = "sha256:526ea0378246d9b080148f2d6681229f4b5964543c170dd10bf4faaab6e0d27f"}, - {file = "psycopg2_binary-2.9.3-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:b1c8068513f5b158cf7e29c43a77eb34b407db29aca749d3eb9293ee0d3103ca"}, - {file = "psycopg2_binary-2.9.3-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:15803fa813ea05bef089fa78835118b5434204f3a17cb9f1e5dbfd0b9deea5af"}, - {file = "psycopg2_binary-2.9.3-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:152f09f57417b831418304c7f30d727dc83a12761627bb826951692cc6491e57"}, - {file = "psycopg2_binary-2.9.3-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:404224e5fef3b193f892abdbf8961ce20e0b6642886cfe1fe1923f41aaa75c9d"}, - {file = "psycopg2_binary-2.9.3-cp36-cp36m-win32.whl", hash = "sha256:1f6b813106a3abdf7b03640d36e24669234120c72e91d5cbaeb87c5f7c36c65b"}, - {file = "psycopg2_binary-2.9.3-cp36-cp36m-win_amd64.whl", hash = "sha256:2d872e3c9d5d075a2e104540965a1cf898b52274a5923936e5bfddb58c59c7c2"}, - {file = "psycopg2_binary-2.9.3-cp37-cp37m-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:10bb90fb4d523a2aa67773d4ff2b833ec00857f5912bafcfd5f5414e45280fb1"}, - {file = "psycopg2_binary-2.9.3-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:874a52ecab70af13e899f7847b3e074eeb16ebac5615665db33bce8a1009cf33"}, - {file = "psycopg2_binary-2.9.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a29b3ca4ec9defec6d42bf5feb36bb5817ba3c0230dd83b4edf4bf02684cd0ae"}, - {file = "psycopg2_binary-2.9.3-cp37-cp37m-manylinux_2_24_aarch64.whl", hash = "sha256:12b11322ea00ad8db8c46f18b7dfc47ae215e4df55b46c67a94b4effbaec7094"}, - {file = "psycopg2_binary-2.9.3-cp37-cp37m-manylinux_2_24_ppc64le.whl", hash = "sha256:53293533fcbb94c202b7c800a12c873cfe24599656b341f56e71dd2b557be063"}, - {file = "psycopg2_binary-2.9.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c381bda330ddf2fccbafab789d83ebc6c53db126e4383e73794c74eedce855ef"}, - {file = "psycopg2_binary-2.9.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:9d29409b625a143649d03d0fd7b57e4b92e0ecad9726ba682244b73be91d2fdb"}, - {file = "psycopg2_binary-2.9.3-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:183a517a3a63503f70f808b58bfbf962f23d73b6dccddae5aa56152ef2bcb232"}, - {file = "psycopg2_binary-2.9.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:15c4e4cfa45f5a60599d9cec5f46cd7b1b29d86a6390ec23e8eebaae84e64554"}, - {file = "psycopg2_binary-2.9.3-cp37-cp37m-win32.whl", hash = "sha256:adf20d9a67e0b6393eac162eb81fb10bc9130a80540f4df7e7355c2dd4af9fba"}, - {file = "psycopg2_binary-2.9.3-cp37-cp37m-win_amd64.whl", hash = "sha256:2f9ffd643bc7349eeb664eba8864d9e01f057880f510e4681ba40a6532f93c71"}, - {file = "psycopg2_binary-2.9.3-cp38-cp38-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:def68d7c21984b0f8218e8a15d514f714d96904265164f75f8d3a70f9c295667"}, - {file = "psycopg2_binary-2.9.3-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dffc08ca91c9ac09008870c9eb77b00a46b3378719584059c034b8945e26b272"}, - {file = "psycopg2_binary-2.9.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:280b0bb5cbfe8039205c7981cceb006156a675362a00fe29b16fbc264e242834"}, - {file = "psycopg2_binary-2.9.3-cp38-cp38-manylinux_2_24_aarch64.whl", hash = "sha256:af9813db73395fb1fc211bac696faea4ca9ef53f32dc0cfa27e4e7cf766dcf24"}, - {file = "psycopg2_binary-2.9.3-cp38-cp38-manylinux_2_24_ppc64le.whl", hash = "sha256:63638d875be8c2784cfc952c9ac34e2b50e43f9f0a0660b65e2a87d656b3116c"}, - {file = "psycopg2_binary-2.9.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ffb7a888a047696e7f8240d649b43fb3644f14f0ee229077e7f6b9f9081635bd"}, - {file = "psycopg2_binary-2.9.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:0c9d5450c566c80c396b7402895c4369a410cab5a82707b11aee1e624da7d004"}, - {file = "psycopg2_binary-2.9.3-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:d1c1b569ecafe3a69380a94e6ae09a4789bbb23666f3d3a08d06bbd2451f5ef1"}, - {file = "psycopg2_binary-2.9.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8fc53f9af09426a61db9ba357865c77f26076d48669f2e1bb24d85a22fb52307"}, - {file = "psycopg2_binary-2.9.3-cp38-cp38-win32.whl", hash = "sha256:6472a178e291b59e7f16ab49ec8b4f3bdada0a879c68d3817ff0963e722a82ce"}, - {file = "psycopg2_binary-2.9.3-cp38-cp38-win_amd64.whl", hash = "sha256:35168209c9d51b145e459e05c31a9eaeffa9a6b0fd61689b48e07464ffd1a83e"}, - {file = "psycopg2_binary-2.9.3-cp39-cp39-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:47133f3f872faf28c1e87d4357220e809dfd3fa7c64295a4a148bcd1e6e34ec9"}, - {file = "psycopg2_binary-2.9.3-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:91920527dea30175cc02a1099f331aa8c1ba39bf8b7762b7b56cbf54bc5cce42"}, - {file = "psycopg2_binary-2.9.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:887dd9aac71765ac0d0bac1d0d4b4f2c99d5f5c1382d8b770404f0f3d0ce8a39"}, - {file = "psycopg2_binary-2.9.3-cp39-cp39-manylinux_2_24_aarch64.whl", hash = "sha256:1f14c8b0942714eb3c74e1e71700cbbcb415acbc311c730370e70c578a44a25c"}, - {file = "psycopg2_binary-2.9.3-cp39-cp39-manylinux_2_24_ppc64le.whl", hash = "sha256:7af0dd86ddb2f8af5da57a976d27cd2cd15510518d582b478fbb2292428710b4"}, - {file = "psycopg2_binary-2.9.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:93cd1967a18aa0edd4b95b1dfd554cf15af657cb606280996d393dadc88c3c35"}, - {file = "psycopg2_binary-2.9.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bda845b664bb6c91446ca9609fc69f7db6c334ec5e4adc87571c34e4f47b7ddb"}, - {file = "psycopg2_binary-2.9.3-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:01310cf4cf26db9aea5158c217caa92d291f0500051a6469ac52166e1a16f5b7"}, - {file = "psycopg2_binary-2.9.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:99485cab9ba0fa9b84f1f9e1fef106f44a46ef6afdeec8885e0b88d0772b49e8"}, - {file = "psycopg2_binary-2.9.3-cp39-cp39-win32.whl", hash = "sha256:46f0e0a6b5fa5851bbd9ab1bc805eef362d3a230fbdfbc209f4a236d0a7a990d"}, - {file = "psycopg2_binary-2.9.3-cp39-cp39-win_amd64.whl", hash = "sha256:accfe7e982411da3178ec690baaceaad3c278652998b2c45828aaac66cd8285f"}, -] py = [ {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, @@ -964,6 +906,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 e16a8d8..c46d31c 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" @@ -13,7 +13,6 @@ SQLAlchemy = "1.3.24" click = "8.0.4" pydantic = "^1.10.2" bech32 = "^1.2.0" -psycopg2-binary = "^2.9.3" fastapi = "^0.83.0" environs = "^9.5.0" uvicorn = "^0.18.3" @@ -22,6 +21,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} diff --git a/requirements.txt b/requirements.txt index 192fb45..8fc17f6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,13 +19,13 @@ marshmallow==3.18.0 ; python_version >= "3.7" and python_version < "4.0" outcome==1.2.0 ; python_version >= "3.7" and python_version < "4.0" packaging==21.3 ; python_version >= "3.7" and python_version < "4.0" pluggy==1.0.0 ; python_version >= "3.7" and python_version < "4.0" -psycopg2-binary==2.9.3 ; python_version >= "3.7" and python_version < "4.0" py==1.11.0 ; python_version >= "3.7" and python_version < "4.0" pycparser==2.21 ; python_version >= "3.7" and python_version < "4.0" 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" diff --git a/setup.py b/setup.py index b7176cc..07d30e3 100644 --- a/setup.py +++ b/setup.py @@ -9,11 +9,11 @@ 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", - 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..5800910 100644 --- a/tests/test_wallet.py +++ b/tests/test_wallet.py @@ -1,3 +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 @@ -25,10 +28,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 = [] @@ -50,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() @@ -67,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 @@ -96,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 @@ -105,9 +110,42 @@ 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_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( wallet1.split(w1_snd_proofs, -500), - "Error: invalid split amount: -500", + "Mint 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 + ) + + # p2sh test + p2shscript = await wallet2.create_p2sh_lock() + txin_p2sh_address = p2shscript.address + lock = f"P2SH:{txin_p2sh_address}" + _, send_proofs = await wallet1.split_to_send(wallet1.proofs, 8, lock) + _, _ = await wallet2.redeem( + send_proofs, snd_script=p2shscript.script, snd_siganture=p2shscript.signature + ) + + # 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), + "Mint Error: no secret in proof.", )