From 849178bb87bf3d25b5be772c8bf8a7cdd9b3e432 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Sat, 8 Oct 2022 11:38:14 +0300 Subject: [PATCH] feat: add split logic --- lnbits/extensions/cashu/core/split.py | 8 +++ lnbits/extensions/cashu/mint.py | 72 +++++++++++++++++++++++--- lnbits/extensions/cashu/mint_helper.py | 57 +++++++++++++++++++- lnbits/extensions/cashu/views_api.py | 67 ++++++++++++------------ 4 files changed, 160 insertions(+), 44 deletions(-) create mode 100644 lnbits/extensions/cashu/core/split.py diff --git a/lnbits/extensions/cashu/core/split.py b/lnbits/extensions/cashu/core/split.py new file mode 100644 index 000000000..44b9cf51d --- /dev/null +++ b/lnbits/extensions/cashu/core/split.py @@ -0,0 +1,8 @@ +def amount_split(amount): + """Given an amount returns a list of amounts returned e.g. 13 is [1, 4, 8].""" + bits_amt = bin(amount)[::-1][:-2] + rv = [] + for (pos, bit) in enumerate(bits_amt): + if bit == "1": + rv.append(2**pos) + return rv diff --git a/lnbits/extensions/cashu/mint.py b/lnbits/extensions/cashu/mint.py index 6be607039..3388b45ab 100644 --- a/lnbits/extensions/cashu/mint.py +++ b/lnbits/extensions/cashu/mint.py @@ -3,14 +3,24 @@ from typing import List, Set from lnbits import bolt11 from lnbits.core.services import check_transaction_status, fee_reserve, pay_invoice -from lnbits.extensions.cashu.models import Cashu from lnbits.wallets.base import PaymentStatus from .core.b_dhke import step2_bob -from .core.base import BlindedSignature, Proof +from .core.base import BlindedMessage, BlindedSignature, Proof from .core.secp import PublicKey +from .core.split import amount_split from .crud import get_proofs_used, invalidate_proof -from .mint_helper import derive_keys, derive_pubkeys, verify_proof +from .mint_helper import ( + derive_keys, + derive_pubkeys, + verify_equation_balanced, + verify_no_duplicates, + verify_outputs, + verify_proof, + verify_secret_criteria, + verify_split_amount, +) +from .models import Cashu # todo: extract const MAX_ORDER = 64 @@ -52,10 +62,8 @@ async def melt(cashu: Cashu, proofs: List[Proof], invoice: str): """Invalidates proofs and pays a Lightning invoice.""" # Verify proofs proofs_used: Set[str] = set(await get_proofs_used(cashu.id)) - # if not all([verify_proof(cashu.prvkey, proofs_used, p) for p in proofs]): - # raise Exception("could not verify proofs.") for p in proofs: - await verify_proof(cashu.prvkey, proofs_used, p) + await verify_proof(cashu.prvkey, proofs_used, p) total_provided = sum([p["amount"] for p in proofs]) invoice_obj = bolt11.decode(invoice) @@ -91,7 +99,57 @@ async def check_fees(wallet_id: str, decoded_invoice): fees_msat = fee_reserve(amount * 1000) if status.paid != True else 0 return fees_msat + +async def split( + cashu: Cashu, proofs: List[Proof], amount: int, outputs: List[BlindedMessage] +): + """Consumes proofs and prepares new promises based on the amount split.""" + total = sum([p.amount for p in proofs]) + + # verify that amount is kosher + verify_split_amount(amount) + # verify overspending attempt + if amount > total: + raise Exception( + f"split amount ({amount}) is higher than the total sum ({total})." + ) + + # Verify secret criteria + if not all([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 verify_no_duplicates(proofs, outputs): + raise Exception("duplicate proofs or promises.") + # verify that outputs have the correct amount + if not verify_outputs(total, amount, outputs): # ? + raise Exception("split of promises is not as expected.") + # Verify proofs + # Verify proofs + proofs_used: Set[str] = set(await get_proofs_used(cashu.id)) + for p in proofs: + await verify_proof(cashu.prvkey, proofs_used, p) + + # Mark proofs as used and prepare new promises + await invalidate_proofs(cashu.id, proofs) + + outs_fst = amount_split(total - amount) + outs_snd = amount_split(amount) + B_fst = [ + PublicKey(bytes.fromhex(od.B_), raw=True) for od in outputs[: len(outs_fst)] + ] + B_snd = [ + PublicKey(bytes.fromhex(od.B_), raw=True) for od in outputs[len(outs_fst) :] + ] + # PublicKey(bytes.fromhex(payload.B_), raw=True) + prom_fst, prom_snd = await generate_promises( + cashu.prvkey, outs_fst, B_fst + ), await generate_promises(cashu.prvkey, outs_snd, B_snd) + # verify amounts in produced proofs + verify_equation_balanced(proofs, prom_fst + prom_snd) + return prom_fst, prom_snd + + async def invalidate_proofs(cashu_id: str, proofs: List[Proof]): """Adds secrets of proofs to the list of knwon secrets and stores them in the db.""" for p in proofs: - await invalidate_proof(cashu_id, p) \ No newline at end of file + await invalidate_proof(cashu_id, p) diff --git a/lnbits/extensions/cashu/mint_helper.py b/lnbits/extensions/cashu/mint_helper.py index 5fc43e49d..5c96d8310 100644 --- a/lnbits/extensions/cashu/mint_helper.py +++ b/lnbits/extensions/cashu/mint_helper.py @@ -2,8 +2,10 @@ import hashlib from typing import List, Set from .core.b_dhke import verify +from .core.base import BlindedSignature from .core.secp import PrivateKey, PublicKey -from .models import Proof +from .core.split import amount_split +from .models import BlindedMessage, Proof # todo: extract const MAX_ORDER = 64 @@ -27,6 +29,7 @@ def derive_pubkeys(keys: List[PrivateKey]): return {amt: keys[amt].pubkey for amt in [2**i for i in range(MAX_ORDER)]} +# async required? async def verify_proof(master_prvkey: str, proofs_used: Set[str], proof: Proof): """Verifies that the proof of promise was issued by this ledger.""" if proof.secret in proofs_used: @@ -38,4 +41,54 @@ async def verify_proof(master_prvkey: str, proofs_used: Set[str], proof: Proof): C = PublicKey(bytes.fromhex(proof.C), raw=True) validMintSig = verify(secret_key, C, proof.secret) if validMintSig != True: - raise Exception(f"tokens not valid. Secret: {proof.secret}") + raise Exception(f"tokens not valid. Secret: {proof.secret}") + + +def verify_split_amount(amount: int): + """Split amount like output amount can't be negative or too big.""" + try: + verify_amount(amount) + except: + # For better error message + raise Exception("invalid split amount: " + str(amount)) + + +def verify_secret_criteria(proof: Proof): + if proof.secret is None or proof.secret == "": + raise Exception("no secret in proof.") + return True + + +def verify_no_duplicates(proofs: List[Proof], outputs: List[BlindedMessage]): + secrets = [p.secret for p in proofs] + if len(secrets) != len(list(set(secrets))): + return False + B_s = [od.B_ for od in outputs] + if len(B_s) != len(list(set(B_s))): + return False + return True + + +def verify_outputs(total: int, amount: int, outputs: List[BlindedMessage]): + """Verifies the expected split was correctly computed""" + frst_amt, scnd_amt = total - amount, amount # we have two amounts to split to + frst_outputs = amount_split(frst_amt) + scnd_outputs = amount_split(scnd_amt) + expected = frst_outputs + scnd_outputs + given = [o.amount for o in outputs] + return given == expected + + +def verify_amount(amount: int): + """Any amount used should be a positive integer not larger than 2^MAX_ORDER.""" + valid = isinstance(amount, int) and amount > 0 and amount < 2**MAX_ORDER + if not valid: + raise Exception("invalid amount: " + str(amount)) + return amount + + +def verify_equation_balanced(proofs: List[Proof], outs: List[BlindedSignature]): + """Verify that Σoutputs - Σinputs = 0.""" + sum_inputs = sum(verify_amount(p.amount) for p in proofs) + sum_outputs = sum(verify_amount(p.amount) for p in outs) + assert sum_outputs - sum_inputs == 0 diff --git a/lnbits/extensions/cashu/views_api.py b/lnbits/extensions/cashu/views_api.py index a53caaa09..3d221a17b 100644 --- a/lnbits/extensions/cashu/views_api.py +++ b/lnbits/extensions/cashu/views_api.py @@ -1,3 +1,4 @@ +import json from http import HTTPStatus from typing import Union @@ -16,7 +17,7 @@ from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key from lnbits.wallets.base import PaymentStatus from . import cashu_ext -from .core.base import CashuError +from .core.base import CashuError, PostSplitResponse, SplitRequest from .crud import ( create_cashu, delete_cashu, @@ -28,7 +29,7 @@ from .crud import ( update_lightning_invoice, ) from .ledger import mint, request_mint -from .mint import generate_promises, get_pubkeys, melt +from .mint import generate_promises, get_pubkeys, melt, split from .models import ( Cashu, CheckPayload, @@ -207,9 +208,7 @@ async def api_cashu_check_invoice(cashu_id: str, payment_hash: str): @cashu_ext.get("/api/v1/cashu/{cashu_id}/keys", status_code=HTTPStatus.OK) -async def keys( - cashu_id: str = Query(False), wallet: WalletTypeInfo = Depends(get_key_type) -): +async def keys(cashu_id: str = Query(False)): """Get the public keys of the mint""" mint = await get_cashu(cashu_id) if mint is None: @@ -220,11 +219,7 @@ async def keys( @cashu_ext.get("/api/v1/cashu/{cashu_id}/mint") -async def mint_pay_request( - amount: int = 0, - cashu_id: str = Query(None), - wallet: WalletTypeInfo = Depends(get_key_type), -): +async def mint_pay_request(amount: int = 0, cashu_id: str = Query(None)): """Request minting of tokens. Server responds with a Lightning invoice.""" cashu = await get_cashu(cashu_id) @@ -246,9 +241,7 @@ async def mint_pay_request( await store_lightning_invoice(cashu_id, invoice) except Exception as e: logger.error(e) - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e) - ) + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)) return {"pr": payment_request, "hash": payment_hash} @@ -258,7 +251,6 @@ async def mint_coins( data: MintPayloads, cashu_id: str = Query(None), payment_hash: Union[str, None] = None, - wallet: WalletTypeInfo = Depends(require_admin_key), ): """ Requests the minting of tokens belonging to a paid payment request. @@ -286,17 +278,17 @@ async def mint_coins( total_requested = sum([bm.amount for bm in data.blinded_messages]) if total_requested > invoice.amount: - # raise CashuError(error = f"Requested amount to high: {total_requested}. Invoice amount: {invoice.amount}") raise HTTPException( - status_code=HTTPStatus.PAYMENT_REQUIRED, detail=f"Requested amount to high: {total_requested}. Invoice amount: {invoice.amount}" + status_code=HTTPStatus.PAYMENT_REQUIRED, + detail=f"Requested amount too high: {total_requested}. Invoice amount: {invoice.amount}", ) status: PaymentStatus = await check_transaction_status(cashu.wallet, payment_hash) # todo: revert to: status.paid != True: - if status.paid != True: - raise HTTPException( - status_code=HTTPStatus.PAYMENT_REQUIRED, detail="Invoice not paid." - ) + # if status.paid != True: + # raise HTTPException( + # status_code=HTTPStatus.PAYMENT_REQUIRED, detail="Invoice not paid." + # ) try: await update_lightning_invoice(cashu_id, payment_hash, True) @@ -313,13 +305,12 @@ async def mint_coins( return promises except Exception as e: logger.error(e) - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e) - ) + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)) @cashu_ext.post("/api/v1/cashu/{cashu_id}/melt") async def melt_coins(payload: MeltPayload, cashu_id: str = Query(None)): + """Invalidates proofs and pays a Lightning invoice.""" cashu: Cashu = await get_cashu(cashu_id) if cashu is None: raise HTTPException( @@ -330,9 +321,7 @@ async def melt_coins(payload: MeltPayload, cashu_id: str = Query(None)): return {"paid": ok, "preimage": preimage} except Exception as e: logger.error(e) - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e) - ) + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)) @cashu_ext.post("/check") @@ -340,21 +329,29 @@ async def check_spendable_coins(payload: CheckPayload, cashu_id: str = Query(Non return await check_spendable(payload.proofs, cashu_id) -@cashu_ext.post("/split") -async def spli_coinst(payload: SplitPayload, cashu_id: str = Query(None)): +@cashu_ext.post("/api/v1/cashu/{cashu_id}/split") +async def split_proofs(payload: SplitRequest, cashu_id: str = Query(None)): """ Requetst a set of tokens with amount "total" to be split into two newly minted sets with amount "split" and "total-split". """ + print("### RECEIVE") + print("payload", json.dumps(payload, default=vars)) + cashu: Cashu = await get_cashu(cashu_id) + if cashu is None: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist." + ) proofs = payload.proofs amount = payload.amount - output_data = payload.output_data.blinded_messages + outputs = payload.outputs.blinded_messages if payload.outputs else None try: - split_return = await split(proofs, amount, output_data) + split_return = await split(cashu, proofs, amount, outputs) except Exception as exc: - return {"error": str(exc)} + raise CashuError(error=str(exc)) if not split_return: - """There was a problem with the split""" - raise Exception("could not split tokens.") - fst_promises, snd_promises = split_return - return {"fst": fst_promises, "snd": snd_promises} + return {"error": "there was a problem with the split."} + frst_promises, scnd_promises = split_return + resp = PostSplitResponse(fst=frst_promises, snd=scnd_promises) + print("### resp", json.dumps(resp, default=vars)) + return resp