pay to secret

This commit is contained in:
callebtc
2022-09-30 03:10:47 +02:00
parent 92052a7ed9
commit 6789a87c14
10 changed files with 182 additions and 74 deletions

View File

@@ -6,7 +6,7 @@ from pydantic import BaseModel
class Proof(BaseModel):
amount: int
secret: str
secret: str = ""
C: str
reserved: bool = False # whether this proof is reserved for sending
send_id: str = "" # unique ID of send attempt
@@ -27,12 +27,11 @@ class Proof(BaseModel):
@classmethod
def from_dict(cls, d: dict):
assert "secret" in d, "no secret in proof"
assert "amount" in d, "no amount in proof"
return cls(
amount=d.get("amount"),
C=d.get("C"),
secret=d.get("secret"),
secret=d.get("secret") or "",
reserved=d.get("reserved") or False,
send_id=d.get("send_id") or "",
time_created=d.get("time_created") or "",
@@ -42,6 +41,9 @@ class Proof(BaseModel):
def to_dict(self):
return dict(amount=self.amount, secret=self.secret, C=self.C)
def to_dict_no_secret(self):
return dict(amount=self.amount, C=self.C)
def __getitem__(self, key):
return self.__getattribute__(key)

View File

@@ -1,3 +1,4 @@
import sys
from pathlib import Path
from environs import Env # type: ignore
@@ -6,6 +7,9 @@ env = Env()
env.read_env()
DEBUG = env.bool("DEBUG", default=False)
if not DEBUG:
sys.tracebacklimit = 0
CASHU_DIR = env.str("CASHU_DIR", default="~/.cashu")
CASHU_DIR = CASHU_DIR.replace("~", str(Path.home()))
assert len(CASHU_DIR), "CASHU_DIR not defined"
@@ -32,4 +36,4 @@ LNBITS_ENDPOINT = env.str("LNBITS_ENDPOINT", default=None)
LNBITS_KEY = env.str("LNBITS_KEY", default=None)
MAX_ORDER = 64
VERSION = "0.1.10"
VERSION = "0.2.0"

View File

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

@@ -227,30 +227,33 @@ class Ledger:
return {i: self._check_spendable(p) for i, p in enumerate(proofs)}
async def split(
self, proofs: List[Proof], amount: int, output_data: List[BlindedMessage]
self, proofs: List[Proof], amount: int, outputs: List[BlindedMessage]
):
"""Consumes proofs and prepares new promises based on the amount split."""
self._verify_split_amount(amount)
# Verify proofs are valid
if not all([self._verify_proof(p) for p in proofs]):
return False
total = sum([p.amount for p in proofs])
if not self._verify_no_duplicates(proofs, output_data):
raise Exception("duplicate proofs or promises")
# verify that amount is kosher
self._verify_split_amount(amount)
# verify overspending attempt
if amount > total:
raise Exception("split amount is higher than the total sum")
if not self._verify_outputs(total, amount, output_data):
raise Exception("split of promises is not as expected")
raise Exception("split amount is higher than the total sum.")
# verify that only unique proofs and outputs were used
if not self._verify_no_duplicates(proofs, outputs):
raise Exception("duplicate proofs or promises")
# verify that outputs have the correct amount
if not self._verify_outputs(total, amount, outputs):
raise Exception("split of promises is not as expected.")
# Verify proofs
if not all([self._verify_proof(p) for p in proofs]):
raise Exception("could not verify proofs.")
# Mark proofs as used and prepare new promises
await self._invalidate_proofs(proofs)
outs_fst = amount_split(total - amount)
outs_snd = amount_split(amount)
B_fst = [od.B_ for od in output_data[: len(outs_fst)]]
B_snd = [od.B_ for od in output_data[len(outs_fst) :]]
B_fst = [od.B_ for od in outputs[: len(outs_fst)]]
B_snd = [od.B_ for od in outputs[len(outs_fst) :]]
prom_fst, prom_snd = await self._generate_promises(
outs_fst, B_fst
), await self._generate_promises(outs_snd, B_snd)

View File

@@ -82,7 +82,7 @@ async def split(payload: SplitPayload):
except Exception as exc:
return {"error": str(exc)}
if not split_return:
"""There was a problem with the split"""
raise Exception("could not split tokens.")
# tere was a problem with the split
return {"error": "there was a problem with the split."}
fst_promises, snd_promises = split_return
return {"fst": fst_promises, "snd": snd_promises}

View File

@@ -4,6 +4,8 @@ import asyncio
import base64
import json
import math
import sys
from loguru import logger
from datetime import datetime
from functools import wraps
from itertools import groupby
@@ -41,10 +43,15 @@ class NaturalOrderGroup(click.Group):
@click.option("--wallet", "-w", "walletname", default="wallet", help="Wallet to use.")
@click.pass_context
def cli(ctx, host: str, walletname: str):
# configure logger
logger.remove()
logger.add(sys.stderr, level="DEBUG" if DEBUG else "INFO")
ctx.ensure_object(dict)
ctx.obj["HOST"] = host
ctx.obj["WALLET_NAME"] = walletname
ctx.obj["WALLET"] = Wallet(ctx.obj["HOST"], f"{CASHU_DIR}/{walletname}", walletname)
wallet = Wallet(ctx.obj["HOST"], f"{CASHU_DIR}/{walletname}", walletname)
ctx.obj["WALLET"] = wallet
asyncio.run(init_wallet(wallet))
pass
@@ -64,7 +71,7 @@ def coro(f):
@coro
async def mint(ctx, amount: int, hash: str):
wallet: Wallet = ctx.obj["WALLET"]
await init_wallet(wallet)
wallet.load_mint()
wallet.status()
if not LIGHTNING:
r = await wallet.mint(amount)
@@ -88,35 +95,42 @@ async def mint(ctx, amount: int, hash: str):
@coro
async def balance(ctx):
wallet: Wallet = ctx.obj["WALLET"]
await init_wallet(wallet)
wallet.status()
@cli.command("send", help="Send tokens.")
@click.argument("amount", type=int)
@click.option("--secret", "-s", default="", help="Token spending condition.", type=str)
@click.pass_context
@coro
async def send(ctx, amount: int):
async def send(ctx, amount: int, secret: str):
wallet: Wallet = ctx.obj["WALLET"]
await init_wallet(wallet)
wallet.load_mint()
wallet.status()
_, send_proofs = await wallet.split_to_send(wallet.proofs, amount)
# TODO: remove this list hack
secrets = [secret] if secret else None
_, send_proofs = await wallet.split_to_send(wallet.proofs, amount, secrets)
await wallet.set_reserved(send_proofs, reserved=True)
token = await wallet.serialize_proofs(send_proofs)
token = await wallet.serialize_proofs(
send_proofs, hide_secrets=True if secrets else False
)
print(token)
wallet.status()
@cli.command("receive", help="Receive tokens.")
@click.argument("token", type=str)
@click.option("--secret", "-s", default="", help="Token spending condition.", type=str)
@click.pass_context
@coro
async def receive(ctx, token: str):
async def receive(ctx, token: str, secret: str):
wallet: Wallet = ctx.obj["WALLET"]
await init_wallet(wallet)
wallet.load_mint()
wallet.status()
# TODO: remove this list hack
secrets = [secret] if secret else None
proofs = [Proof.from_dict(p) for p in json.loads(base64.urlsafe_b64decode(token))]
_, _ = await wallet.redeem(proofs)
_, _ = await wallet.redeem(proofs, secrets)
wallet.status()
@@ -130,7 +144,7 @@ async def receive(ctx, token: str):
@coro
async def burn(ctx, token: str, all: bool, force: bool):
wallet: Wallet = ctx.obj["WALLET"]
await init_wallet(wallet)
wallet.load_mint()
if not (all or token or force) or (token and all):
print(
"Error: enter a token or use --all to burn all pending tokens or --force to check all tokens."
@@ -157,7 +171,7 @@ async def burn(ctx, token: str, all: bool, force: bool):
@coro
async def pending(ctx):
wallet: Wallet = ctx.obj["WALLET"]
await init_wallet(wallet)
wallet.load_mint()
reserved_proofs = await get_reserved_proofs(wallet.db)
if len(reserved_proofs):
sorted_proofs = sorted(reserved_proofs, key=itemgetter("send_id"))
@@ -181,7 +195,7 @@ async def pending(ctx):
@coro
async def pay(ctx, invoice: str):
wallet: Wallet = ctx.obj["WALLET"]
await init_wallet(wallet)
wallet.load_mint()
wallet.status()
decoded_invoice: Invoice = bolt11.decode(invoice)
amount = math.ceil(
@@ -206,5 +220,6 @@ async def info(ctx):
print(f"Version: {VERSION}")
print(f"Debug: {DEBUG}")
print(f"Cashu dir: {CASHU_DIR}")
print(f"Wallet: {ctx.obj['WALLET_NAME']}")
print(f"Mint URL: {MINT_URL}")
return

View File

@@ -3,6 +3,7 @@ import json
import secrets as scrts
import uuid
from typing import List
from loguru import logger
import requests
@@ -31,7 +32,8 @@ from cashu.wallet.crud import (
class LedgerAPI:
def __init__(self, url):
self.url = url
self.keys = self._get_keys(url)
# self.keys = self._get_keys(self.url)
# self._load_mint()
@staticmethod
def _get_keys(url):
@@ -51,10 +53,12 @@ class LedgerAPI:
rv.append(2**pos)
return rv
def _construct_proofs(self, promises: List[BlindedSignature], secrets: List[str]):
"""Returns proofs of promise from promises."""
def _construct_proofs(
self, promises: List[BlindedSignature], secrets: List[str], rs: List[str]
):
"""Returns proofs of promise from promises. Wants secrets and blinding factors rs."""
proofs = []
for promise, (r, secret) in zip(promises, secrets):
for promise, secret, r in zip(promises, secrets, rs):
C_ = PublicKey(bytes.fromhex(promise.C_), raw=True)
C = b_dhke.step3_alice(C_, r, self.keys[promise.amount])
proof = Proof(amount=promise.amount, C=C.serialize().hex(), secret=secret)
@@ -65,65 +69,115 @@ class LedgerAPI:
"""Returns base64 encoded random string."""
return scrts.token_urlsafe(randombits // 8)
def _load_mint(self):
assert len(
self.url
), "Ledger not initialized correctly: mint URL not specified yet. "
self.keys = self._get_keys(self.url)
assert len(self.keys) > 0, "did not receive keys from mint."
def request_mint(self, amount):
"""Requests a mint from the server and returns Lightning invoice."""
r = requests.get(self.url + "/mint", params={"amount": amount})
return r.json()
def mint(self, amounts, payment_hash=None):
"""Mints new coins and returns a proof of promise."""
@staticmethod
def _construct_outputs(amounts: List[int], secrets: List[str]):
"""Takes a list of amounts and secrets and returns outputs.
Outputs are blinded messages `payloads` and blinding factors `rs`"""
assert len(amounts) == len(
secrets
), f"len(amounts)={len(amounts)} not equal to len(secrets)={len(secrets)}"
payloads: MintPayloads = MintPayloads()
secrets = []
rs = []
for amount in amounts:
secret = self._generate_secret()
secrets.append(secret)
for secret, amount in zip(secrets, amounts):
B_, r = b_dhke.step1_alice(secret)
rs.append(r)
payload: BlindedMessage = BlindedMessage(
amount=amount, B_=B_.serialize().hex()
)
payloads.blinded_messages.append(payload)
promises_list = requests.post(
return payloads, rs
def mint(self, amounts, payment_hash=None):
"""Mints new coins and returns a proof of promise."""
secrets = [self._generate_secret() for s in range(len(amounts))]
payloads, rs = self._construct_outputs(amounts, secrets)
resp = requests.post(
self.url + "/mint",
json=payloads.dict(),
params={"payment_hash": payment_hash},
).json()
)
try:
promises_list = resp.json()
except:
if resp.status_code >= 300:
raise Exception(f"Error: {f'mint returned {resp.status_code}'}")
else:
raise Exception("Unkown mint error.")
if "error" in promises_list:
raise Exception("Error: {}".format(promises_list["error"]))
promises = [BlindedSignature.from_dict(p) for p in promises_list]
return self._construct_proofs(promises, [(r, s) for r, s in zip(rs, secrets)])
def split(self, proofs, amount):
"""Consume proofs and create new promises based on amount split."""
promises = [BlindedSignature.from_dict(p) for p in promises_list]
return self._construct_proofs(promises, secrets, rs)
def split(self, proofs, amount, snd_secrets: List[str] = None):
"""Consume proofs and create new promises based on amount split.
If snd_secrets is None, random secrets will be generated for the tokens to keep (fst_outputs)
and the promises to send (snd_outputs).
If snd_secrets is provided, the wallet will create blinded secrets with those to attach a
predefined spending condition to the tokens they want to send."""
total = sum([p["amount"] for p in proofs])
fst_amt, snd_amt = total - amount, amount
fst_outputs = amount_split(fst_amt)
snd_outputs = amount_split(snd_amt)
# TODO: Refactor together with the same procedure in self.mint()
secrets = []
payloads: MintPayloads = MintPayloads()
for output_amt in fst_outputs + snd_outputs:
secret = self._generate_secret()
B_, r = b_dhke.step1_alice(secret)
secrets.append((r, secret))
payload: BlindedMessage = BlindedMessage(
amount=output_amt, B_=B_.serialize().hex()
)
payloads.blinded_messages.append(payload)
amounts = fst_outputs + snd_outputs
if snd_secrets is None:
secrets = [self._generate_secret() for s in range(len(amounts))]
else:
logger.debug("Creating proofs with spending condition.")
assert len(snd_secrets) == len(
snd_outputs
), "number of snd_secrets does not match number of ouptus."
# append predefined secrets (to send) to random secrets (to keep)
secrets = [
self._generate_secret() for s in range(len(fst_outputs))
] + snd_secrets
assert len(secrets) == len(
amounts
), "number of secrets does not match number of outputs"
payloads, rs = self._construct_outputs(amounts, secrets)
split_payload = SplitPayload(proofs=proofs, amount=amount, output_data=payloads)
promises_dict = requests.post(
resp = requests.post(
self.url + "/split",
json=split_payload.dict(),
).json()
)
try:
promises_dict = resp.json()
except:
if resp.status_code >= 300:
raise Exception(f"Error: {f'mint returned {resp.status_code}'}")
else:
raise Exception("Unkown mint error.")
if "error" in promises_dict:
raise Exception("Error: {}".format(promises_dict["error"]))
promises_fst = [BlindedSignature.from_dict(p) for p in promises_dict["fst"]]
promises_snd = [BlindedSignature.from_dict(p) for p in promises_dict["snd"]]
# Obtain proofs from promises
fst_proofs = self._construct_proofs(promises_fst, secrets[: len(promises_fst)])
snd_proofs = self._construct_proofs(promises_snd, secrets[len(promises_fst) :])
# Construct proofs from promises (i.e., unblind signatures)
fst_proofs = self._construct_proofs(
promises_fst, secrets[: len(promises_fst)], rs[: len(promises_fst)]
)
snd_proofs = self._construct_proofs(
promises_snd, secrets[len(promises_fst) :], rs[len(promises_fst) :]
)
return fst_proofs, snd_proofs
@@ -154,6 +208,9 @@ class Wallet(LedgerAPI):
self.proofs: List[Proof] = []
self.name = name
def load_mint(self):
super()._load_mint()
async def load_proofs(self):
self.proofs = await get_proofs(db=self.db)
@@ -173,12 +230,20 @@ class Wallet(LedgerAPI):
self.proofs += proofs
return proofs
async def redeem(self, proofs: List[Proof]):
async def redeem(self, proofs: List[Proof], snd_secrets: List[str] = None):
if snd_secrets:
logger.debug(f"Redeption secrets: {snd_secrets}")
assert len(proofs) == len(snd_secrets)
# overload proofs with custom secrets for redemption
for p, s in zip(proofs, snd_secrets):
p.secret = s
return await self.split(proofs, sum(p["amount"] for p in proofs))
async def split(self, proofs: List[Proof], amount: int):
async def split(
self, proofs: List[Proof], amount: int, snd_secrets: List[str] = None
):
assert len(proofs) > 0, ValueError("no proofs provided.")
fst_proofs, snd_proofs = super().split(proofs, amount)
fst_proofs, snd_proofs = super().split(proofs, amount, snd_secrets)
if len(fst_proofs) == 0 and len(snd_proofs) == 0:
raise Exception("received no splits.")
used_secrets = [p["secret"] for p in proofs]
@@ -201,18 +266,27 @@ class Wallet(LedgerAPI):
return status["paid"]
@staticmethod
async def serialize_proofs(proofs: List[Proof]):
async def serialize_proofs(proofs: List[Proof], hide_secrets=False):
if hide_secrets:
proofs_serialized = [p.to_dict_no_secret() for p in proofs]
else:
proofs_serialized = [p.to_dict() for p in proofs]
token = base64.urlsafe_b64encode(
json.dumps(proofs_serialized).encode()
).decode()
return token
async def split_to_send(self, proofs: List[Proof], amount):
async def split_to_send(
self, proofs: List[Proof], amount, snd_secrets: List[str] = None
):
"""Like self.split but only considers non-reserved tokens."""
if snd_secrets:
logger.debug(f"Spending conditions: {snd_secrets}")
if len([p for p in proofs if not p.reserved]) <= 0:
raise Exception("balance too low.")
return await self.split([p for p in proofs if not p.reserved], amount)
return await self.split(
[p for p in proofs if not p.reserved], amount, snd_secrets
)
async def set_reserved(self, proofs: List[Proof], reserved: bool):
"""Mark a proof as reserved to avoid reuse or delete marking."""

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"

View File

@@ -13,7 +13,7 @@ entry_points = {"console_scripts": ["cashu = cahu.wallet.cli:cli"]}
setuptools.setup(
name="cashu",
version="0.1.10",
version="0.2.0",
description="Ecash wallet and mint with Bitcoin Lightning support",
long_description=long_description,
long_description_content_type="text/markdown",

View File

@@ -25,10 +25,12 @@ def assert_amt(proofs, expected):
async def run_test():
wallet1 = Wallet1(SERVER_ENDPOINT, "data/wallet1", "wallet1")
await migrate_databases(wallet1.db, migrations)
wallet1.load_mint()
wallet1.status()
wallet2 = Wallet2(SERVER_ENDPOINT, "data/wallet2", "wallet2")
await migrate_databases(wallet2.db, migrations)
wallet2.load_mint()
wallet2.status()
proofs = []
@@ -105,6 +107,14 @@ async def run_test():
assert wallet1.proof_amounts() == [1, 2, 4, 4, 32, 64]
assert wallet2.proof_amounts() == [4, 16]
# manipulate the proof amount
w1_fst_proofs2[0]["amount"] = 123
await assert_err(
wallet1.split(w1_fst_proofs2, 20),
"Error: 123",
)
# try to split an invalid amount
await assert_err(
wallet1.split(w1_snd_proofs, -500),
"Error: invalid split amount: -500",