Merge pull request #19 from callebtc/pay_to_scripthash

Pay to scripthash
This commit is contained in:
calle
2022-10-03 21:36:35 +02:00
committed by GitHub
20 changed files with 773 additions and 243 deletions

View File

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

View File

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

View File

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

166
cashu/core/script.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 <address> in proof.secret.
proof.secret format: P2SH:<address>:<secret>
"""
# 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:<address>:<secret>
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

View File

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

View File

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

View File

@@ -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>."
)
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 <amount> --lock P2SH:{txin_p2sh_address}"
)
print("")
print(
f"Only you can receive coins from this lock:\n\ncashu receive <coin> --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 <coin> --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

View File

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

View File

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

View File

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

80
poetry.lock generated
View File

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

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "cashu"
version = "0.1.10"
version = "0.2.0"
description = "Ecash wallet and mint."
authors = ["calle <callebtc@protonmail.com>"]
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}

View File

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

View File

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

View File

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