From ff7312c6d8aec96d340db275b392fb819423a875 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 28 Sep 2022 00:26:25 +0200 Subject: [PATCH 01/11] comment --- mint/app.py | 10 +++++++--- wallet/wallet.py | 1 + 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/mint/app.py b/mint/app.py index ddd5315..377ed1f 100644 --- a/mint/app.py +++ b/mint/app.py @@ -11,8 +11,12 @@ from secp256k1 import PublicKey import core.settings as settings from core.base import CheckPayload, MeltPayload, MintPayloads, SplitPayload -from core.settings import (CASHU_DIR, MINT_PRIVATE_KEY, MINT_SERVER_HOST, - MINT_SERVER_PORT) +from core.settings import ( + CASHU_DIR, + MINT_PRIVATE_KEY, + MINT_SERVER_HOST, + MINT_SERVER_PORT, +) from lightning import WALLET from mint.ledger import Ledger from mint.migrations import m001_initial @@ -78,7 +82,7 @@ def create_app(config_object="core.settings") -> FastAPI: description="Ecash wallet and mint.", license_info={ "name": "MIT License", - "url": "https://raw.githubusercontent.com/callebtc/cashu/main/LICENSE", + "url": "https://raw.githubusercontent.com/callebtc/cashu/main/LICENSE.md", }, ) diff --git a/wallet/wallet.py b/wallet/wallet.py index b3dccfc..17172d5 100644 --- a/wallet/wallet.py +++ b/wallet/wallet.py @@ -97,6 +97,7 @@ class LedgerAPI: 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: From 21dff05f8262dcda27367744201cc711e4856090 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 28 Sep 2022 11:24:15 +0200 Subject: [PATCH 02/11] router stuff --- mint/__init__.py | 6 + mint/__main__.py | 7 ++ mint/api.py | 0 mint/app.py | 310 ++++++++++++++--------------------------------- mint/main.py | 52 ++++++++ mint/router.py | 86 +++++++++++++ 6 files changed, 240 insertions(+), 221 deletions(-) create mode 100644 mint/__main__.py create mode 100644 mint/api.py create mode 100644 mint/main.py create mode 100644 mint/router.py diff --git a/mint/__init__.py b/mint/__init__.py index e69de29..9fa043b 100644 --- a/mint/__init__.py +++ b/mint/__init__.py @@ -0,0 +1,6 @@ +from core.settings import MINT_PRIVATE_KEY +from mint.ledger import Ledger + +print("init") + +ledger = Ledger(MINT_PRIVATE_KEY, "data/mint") diff --git a/mint/__main__.py b/mint/__main__.py new file mode 100644 index 0000000..3681527 --- /dev/null +++ b/mint/__main__.py @@ -0,0 +1,7 @@ +from .app import create_app, main + +print("main") + +app = create_app() + +# main() diff --git a/mint/api.py b/mint/api.py new file mode 100644 index 0000000..e69de29 diff --git a/mint/app.py b/mint/app.py index 377ed1f..e79d433 100644 --- a/mint/app.py +++ b/mint/app.py @@ -1,221 +1,89 @@ -import asyncio -import logging -import sys -from typing import Union - -import click -import uvicorn -from fastapi import FastAPI -from loguru import logger -from secp256k1 import PublicKey - -import core.settings as settings -from core.base import CheckPayload, MeltPayload, MintPayloads, SplitPayload -from core.settings import ( - CASHU_DIR, - MINT_PRIVATE_KEY, - MINT_SERVER_HOST, - MINT_SERVER_PORT, -) -from lightning import WALLET -from mint.ledger import Ledger -from mint.migrations import m001_initial - -ledger = Ledger(MINT_PRIVATE_KEY, "data/mint") - - -def startup(app: FastAPI): - @app.on_event("startup") - 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, - ) - - logger.info(f"Lightning balance: {balance} sat") - logger.info(f"Data dir: {CASHU_DIR}") - logger.info("Mint started.") - - -def create_app(config_object="core.settings") -> FastAPI: - def configure_logger() -> None: - class Formatter: - def __init__(self): - self.padding = 0 - self.minimal_fmt: str = "{time:YYYY-MM-DD HH:mm:ss.SS} | {level} | {message}\n" - if settings.DEBUG: - self.fmt: str = "{time:YYYY-MM-DD HH:mm:ss.SS} | {level: <4} | {name}:{function}:{line} | {message}\n" - else: - self.fmt: str = self.minimal_fmt - - def format(self, record): - function = "{function}".format(**record) - if function == "emit": # uvicorn logs - return self.minimal_fmt - return self.fmt - - class InterceptHandler(logging.Handler): - def emit(self, record): - try: - level = logger.level(record.levelname).name - except ValueError: - level = record.levelno - logger.log(level, record.getMessage()) - - logger.remove() - log_level: str = "INFO" - formatter = Formatter() - logger.add(sys.stderr, level=log_level, format=formatter.format) - - logging.getLogger("uvicorn").handlers = [InterceptHandler()] - logging.getLogger("uvicorn.access").handlers = [InterceptHandler()] - - configure_logger() - - app = FastAPI( - title="Cashu Mint", - description="Ecash wallet and mint.", - license_info={ - "name": "MIT License", - "url": "https://raw.githubusercontent.com/callebtc/cashu/main/LICENSE.md", - }, - ) - - startup(app) - return app - - -app = create_app() - - -@app.get("/keys") -def keys(): - """Get the public keys of the mint""" - return ledger.get_pubkeys() - - -@app.get("/mint") -async def request_mint(amount: int = 0): - """Request minting of tokens. Server responds with a Lightning invoice.""" - payment_request, payment_hash = await ledger.request_mint(amount) - print(f"Lightning invoice: {payment_request}") - return {"pr": payment_request, "hash": payment_hash} - - -@app.post("/mint") -async def mint(payloads: MintPayloads, payment_hash: Union[str, None] = None): - """ - Requests the minting of tokens belonging to a paid payment request. - - Parameters: - pr: payment_request of the Lightning paid invoice. - - Body (JSON): - payloads: contains a list of blinded messages waiting to be signed. - - NOTE: - - This needs to be replaced by the preimage otherwise someone knowing - the payment_request can request the tokens instead of the rightful - owner. - - The blinded message should ideally be provided to the server *before* payment - in the GET /mint endpoint so that the server knows to sign only these tokens - when the invoice is paid. - """ - amounts = [] - B_s = [] - for payload in payloads.blinded_messages: - amounts.append(payload.amount) - B_s.append(PublicKey(bytes.fromhex(payload.B_), raw=True)) - try: - promises = await ledger.mint(B_s, amounts, payment_hash=payment_hash) - return promises - except Exception as exc: - return {"error": str(exc)} - - -@app.post("/melt") -async def melt(payload: MeltPayload): - """ - Requests tokens to be destroyed and sent out via Lightning. - """ - ok, preimage = await ledger.melt(payload.proofs, payload.amount, payload.invoice) - return {"paid": ok, "preimage": preimage} - - -@app.post("/check") -async def check_spendable(payload: CheckPayload): - return await ledger.check_spendable(payload.proofs) - - -@app.post("/split") -async def split(payload: SplitPayload): - """ - Requetst a set of tokens with amount "total" to be split into two - newly minted sets with amount "split" and "total-split". - """ - proofs = payload.proofs - amount = payload.amount - output_data = payload.output_data.blinded_messages - try: - split_return = await ledger.split(proofs, amount, output_data) - 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.") - fst_promises, snd_promises = split_return - return {"fst": fst_promises, "snd": snd_promises} - - -@click.command( - context_settings=dict( - ignore_unknown_options=True, - allow_extra_args=True, - ) -) -@click.option("--port", default=MINT_SERVER_PORT, help="Port to listen on") -@click.option("--host", default=MINT_SERVER_HOST, help="Host to run mint on") -@click.option("--ssl-keyfile", default=None, help="Path to SSL keyfile") -@click.option("--ssl-certfile", default=None, help="Path to SSL certificate") -@click.pass_context -def main( - ctx, - port: int = MINT_SERVER_PORT, - host: str = MINT_SERVER_HOST, - ssl_keyfile: str = None, - ssl_certfile: str = None, -): - """Launched with `poetry run mint` at root level""" - # this beautiful beast parses all command line arguments and passes them to the uvicorn server - d = dict() - for a in ctx.args: - item = a.split("=") - if len(item) > 1: # argument like --key=value - print(a, item) - d[item[0].strip("--").replace("-", "_")] = ( - int(item[1]) # need to convert to int if it's a number - if item[1].isdigit() - else item[1] - ) - else: - d[a.strip("--")] = True # argument like --key - - config = uvicorn.Config( - "mint.app:app", - port=port, - host=host, - ssl_keyfile=ssl_keyfile, - ssl_certfile=ssl_certfile, - **d, - ) - server = uvicorn.Server(config) - server.run() - - -if __name__ == "__main__": - main() +import asyncio +import logging +import sys +from typing import Union + + +from fastapi import FastAPI +from loguru import logger +from secp256k1 import PublicKey + +import core.settings as settings +from core.settings import ( + CASHU_DIR, +) +from lightning import WALLET +from mint.ledger import Ledger +from mint.migrations import m001_initial + +from . import ledger + + +def startup(app: FastAPI): + @app.on_event("startup") + 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, + ) + + logger.info(f"Lightning balance: {balance} sat") + logger.info(f"Data dir: {CASHU_DIR}") + logger.info("Mint started.") + + +def create_app(config_object="core.settings") -> FastAPI: + def configure_logger() -> None: + class Formatter: + def __init__(self): + self.padding = 0 + self.minimal_fmt: str = "{time:YYYY-MM-DD HH:mm:ss.SS} | {level} | {message}\n" + if settings.DEBUG: + self.fmt: str = "{time:YYYY-MM-DD HH:mm:ss.SS} | {level: <4} | {name}:{function}:{line} | {message}\n" + else: + self.fmt: str = self.minimal_fmt + + def format(self, record): + function = "{function}".format(**record) + if function == "emit": # uvicorn logs + return self.minimal_fmt + return self.fmt + + class InterceptHandler(logging.Handler): + def emit(self, record): + try: + level = logger.level(record.levelname).name + except ValueError: + level = record.levelno + logger.log(level, record.getMessage()) + + logger.remove() + log_level: str = "INFO" + formatter = Formatter() + logger.add(sys.stderr, level=log_level, format=formatter.format) + + logging.getLogger("uvicorn").handlers = [InterceptHandler()] + logging.getLogger("uvicorn.access").handlers = [InterceptHandler()] + + configure_logger() + + app = FastAPI( + title="Cashu Mint", + description="Ecash wallet and mint.", + license_info={ + "name": "MIT License", + "url": "https://raw.githubusercontent.com/callebtc/cashu/main/LICENSE", + }, + ) + + startup(app) + return app + + +# if __name__ == "__main__": +# main() diff --git a/mint/main.py b/mint/main.py new file mode 100644 index 0000000..3eafd4c --- /dev/null +++ b/mint/main.py @@ -0,0 +1,52 @@ +import click +import uvicorn + +from core.settings import ( + MINT_SERVER_HOST, + MINT_SERVER_PORT, +) + + +@click.command( + context_settings=dict( + ignore_unknown_options=True, + allow_extra_args=True, + ) +) +@click.option("--port", default=MINT_SERVER_PORT, help="Port to listen on") +@click.option("--host", default=MINT_SERVER_HOST, help="Host to run mint on") +@click.option("--ssl-keyfile", default=None, help="Path to SSL keyfile") +@click.option("--ssl-certfile", default=None, help="Path to SSL certificate") +@click.pass_context +def main( + ctx, + port: int = MINT_SERVER_PORT, + host: str = MINT_SERVER_HOST, + ssl_keyfile: str = None, + ssl_certfile: str = None, +): + """Launched with `poetry run mint` at root level""" + # this beautiful beast parses all command line arguments and passes them to the uvicorn server + d = dict() + for a in ctx.args: + item = a.split("=") + if len(item) > 1: # argument like --key=value + print(a, item) + d[item[0].strip("--").replace("-", "_")] = ( + int(item[1]) # need to convert to int if it's a number + if item[1].isdigit() + else item[1] + ) + else: + d[a.strip("--")] = True # argument like --key + + config = uvicorn.Config( + "mint.__main__:app", + port=port, + host=host, + ssl_keyfile=ssl_keyfile, + ssl_certfile=ssl_certfile, + **d, + ) + server = uvicorn.Server(config) + server.run() diff --git a/mint/router.py b/mint/router.py new file mode 100644 index 0000000..7f5ac35 --- /dev/null +++ b/mint/router.py @@ -0,0 +1,86 @@ +from fastapi import APIRouter +from mint import ledger +from core.base import CheckPayload, MeltPayload, MintPayloads, SplitPayload +from typing import Union +from secp256k1 import PublicKey + +router: APIRouter = APIRouter() + + +@router.get("/keys") +def keys(): + """Get the public keys of the mint""" + return ledger.get_pubkeys() + + +@router.get("/mint") +async def request_mint(amount: int = 0): + """Request minting of tokens. Server responds with a Lightning invoice.""" + payment_request, payment_hash = await ledger.request_mint(amount) + print(f"Lightning invoice: {payment_request}") + return {"pr": payment_request, "hash": payment_hash} + + +@router.post("/mint") +async def mint(payloads: MintPayloads, payment_hash: Union[str, None] = None): + """ + Requests the minting of tokens belonging to a paid payment request. + + Parameters: + pr: payment_request of the Lightning paid invoice. + + Body (JSON): + payloads: contains a list of blinded messages waiting to be signed. + + NOTE: + - This needs to be replaced by the preimage otherwise someone knowing + the payment_request can request the tokens instead of the rightful + owner. + - The blinded message should ideally be provided to the server *before* payment + in the GET /mint endpoint so that the server knows to sign only these tokens + when the invoice is paid. + """ + amounts = [] + B_s = [] + for payload in payloads.blinded_messages: + amounts.append(payload.amount) + B_s.append(PublicKey(bytes.fromhex(payload.B_), raw=True)) + try: + promises = await ledger.mint(B_s, amounts, payment_hash=payment_hash) + return promises + except Exception as exc: + return {"error": str(exc)} + + +@router.post("/melt") +async def melt(payload: MeltPayload): + """ + Requests tokens to be destroyed and sent out via Lightning. + """ + ok, preimage = await ledger.melt(payload.proofs, payload.amount, payload.invoice) + return {"paid": ok, "preimage": preimage} + + +@router.post("/check") +async def check_spendable(payload: CheckPayload): + return await ledger.check_spendable(payload.proofs) + + +@router.post("/split") +async def split(payload: SplitPayload): + """ + Requetst a set of tokens with amount "total" to be split into two + newly minted sets with amount "split" and "total-split". + """ + proofs = payload.proofs + amount = payload.amount + output_data = payload.output_data.blinded_messages + try: + split_return = await ledger.split(proofs, amount, output_data) + 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.") + fst_promises, snd_promises = split_return + return {"fst": fst_promises, "snd": snd_promises} From a723417eef23370af57c0a055c97c21ef733e900 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 28 Sep 2022 15:54:22 +0200 Subject: [PATCH 03/11] refactor mint --- mint/__main__.py | 5 ++--- mint/app.py | 13 +++++-------- mint/main.py | 2 +- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/mint/__main__.py b/mint/__main__.py index 3681527..90e97df 100644 --- a/mint/__main__.py +++ b/mint/__main__.py @@ -1,7 +1,6 @@ -from .app import create_app, main +from .main import main print("main") -app = create_app() -# main() +main() diff --git a/mint/app.py b/mint/app.py index e79d433..e3a9cbd 100644 --- a/mint/app.py +++ b/mint/app.py @@ -1,19 +1,14 @@ import asyncio import logging import sys -from typing import Union from fastapi import FastAPI from loguru import logger -from secp256k1 import PublicKey -import core.settings as settings -from core.settings import ( - CASHU_DIR, -) +from core.settings import CASHU_DIR, DEBUG + from lightning import WALLET -from mint.ledger import Ledger from mint.migrations import m001_initial from . import ledger @@ -43,7 +38,7 @@ def create_app(config_object="core.settings") -> FastAPI: def __init__(self): self.padding = 0 self.minimal_fmt: str = "{time:YYYY-MM-DD HH:mm:ss.SS} | {level} | {message}\n" - if settings.DEBUG: + if DEBUG: self.fmt: str = "{time:YYYY-MM-DD HH:mm:ss.SS} | {level: <4} | {name}:{function}:{line} | {message}\n" else: self.fmt: str = self.minimal_fmt @@ -87,3 +82,5 @@ def create_app(config_object="core.settings") -> FastAPI: # if __name__ == "__main__": # main() + +app = create_app() diff --git a/mint/main.py b/mint/main.py index 3eafd4c..7f70c5b 100644 --- a/mint/main.py +++ b/mint/main.py @@ -41,7 +41,7 @@ def main( d[a.strip("--")] = True # argument like --key config = uvicorn.Config( - "mint.__main__:app", + "mint.app:app", port=port, host=host, ssl_keyfile=ssl_keyfile, From 001b5e24a006db0ace5e8a4f61cae2bebc9d719c Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 28 Sep 2022 17:42:48 +0200 Subject: [PATCH 04/11] refactor --- cashu/core/__init__.py | 0 cashu/core/b_dhke.py | 93 ++++++ cashu/core/base.py | 108 +++++++ cashu/core/bolt11.py | 370 ++++++++++++++++++++++++ cashu/core/db.py | 182 ++++++++++++ cashu/core/helpers.py | 35 +++ cashu/core/migrations.py | 51 ++++ cashu/core/secp.py | 52 ++++ cashu/core/settings.py | 34 +++ cashu/core/split.py | 8 + cashu/lightning/__init__.py | 3 + cashu/lightning/base.py | 88 ++++++ cashu/lightning/lnbits.py | 154 ++++++++++ cashu/mint/__init__.py | 6 + cashu/mint/__main__.py | 6 + cashu/mint/app.py | 73 +++++ cashu/mint/crud.py | 110 +++++++ cashu/mint/ledger.py | 258 +++++++++++++++++ cashu/mint/main.py | 52 ++++ cashu/mint/migrations.py | 87 ++++++ cashu/mint/router.py | 86 ++++++ cashu/mint/startup.py | 25 ++ cashu/wallet/__init__.py | 3 + cashu/wallet/__main__.py | 3 + cashu/wallet/cli.py | 212 ++++++++++++++ cashu/wallet/crud.py | 99 +++++++ cashu/wallet/migrations.py | 81 ++++++ cashu/wallet/wallet.py | 256 ++++++++++++++++ cashu/wallet/wallet_live/.DS_Store | Bin 0 -> 6148 bytes cashu/wallet/wallet_live/.placeholder | 0 cashu/wallet/wallet_live/wallet.sqlite3 | Bin 0 -> 36864 bytes 31 files changed, 2535 insertions(+) create mode 100644 cashu/core/__init__.py create mode 100644 cashu/core/b_dhke.py create mode 100644 cashu/core/base.py create mode 100644 cashu/core/bolt11.py create mode 100644 cashu/core/db.py create mode 100644 cashu/core/helpers.py create mode 100644 cashu/core/migrations.py create mode 100644 cashu/core/secp.py create mode 100644 cashu/core/settings.py create mode 100644 cashu/core/split.py create mode 100644 cashu/lightning/__init__.py create mode 100644 cashu/lightning/base.py create mode 100644 cashu/lightning/lnbits.py create mode 100644 cashu/mint/__init__.py create mode 100644 cashu/mint/__main__.py create mode 100644 cashu/mint/app.py create mode 100644 cashu/mint/crud.py create mode 100644 cashu/mint/ledger.py create mode 100644 cashu/mint/main.py create mode 100644 cashu/mint/migrations.py create mode 100644 cashu/mint/router.py create mode 100644 cashu/mint/startup.py create mode 100644 cashu/wallet/__init__.py create mode 100644 cashu/wallet/__main__.py create mode 100755 cashu/wallet/cli.py create mode 100644 cashu/wallet/crud.py create mode 100644 cashu/wallet/migrations.py create mode 100644 cashu/wallet/wallet.py create mode 100644 cashu/wallet/wallet_live/.DS_Store create mode 100644 cashu/wallet/wallet_live/.placeholder create mode 100644 cashu/wallet/wallet_live/wallet.sqlite3 diff --git a/cashu/core/__init__.py b/cashu/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cashu/core/b_dhke.py b/cashu/core/b_dhke.py new file mode 100644 index 0000000..be9a141 --- /dev/null +++ b/cashu/core/b_dhke.py @@ -0,0 +1,93 @@ +# Don't trust me with cryptography. + +""" +Implementation of https://gist.github.com/RubenSomsen/be7a4760dd4596d06963d67baf140406 +Alice: +A = a*G +return A +Bob: +Y = hash_to_point(secret_message) +r = random blinding factor +B'= Y + r*G +return B' +Alice: +C' = a*B' + (= a*Y + a*r*G) +return C' +Bob: +C = C' - r*A + (= C' - a*r*G) + (= a*Y) +return C, secret_message +Alice: +Y = hash_to_point(secret_message) +C == a*Y +If true, C must have originated from Alice +""" + +import hashlib + +from secp256k1 import PrivateKey, PublicKey + + +def hash_to_point(secret_msg): + """Generates x coordinate from the message hash and checks if the point lies on the curve. + If it does not, it tries computing again a new x coordinate from the hash of the coordinate.""" + point = None + msg = secret_msg + while point is None: + _hash = hashlib.sha256(msg).hexdigest().encode("utf-8") + try: + # We construct compressed pub which has x coordinate encoded with even y + _hash = list(_hash[:33]) # take the 33 bytes and get a list of bytes + _hash[0] = 0x02 # set first byte to represent even y coord + _hash = bytes(_hash) + point = PublicKey(_hash, raw=True) + except: + msg = _hash + + return point + + +def step1_alice(secret_msg): + secret_msg = secret_msg.encode("utf-8") + Y = hash_to_point(secret_msg) + r = PrivateKey() + B_ = Y + r.pubkey + return B_, r + + +def step2_bob(B_, a): + C_ = B_.mult(a) + return C_ + + +def step3_alice(C_, r, A): + C = C_ - A.mult(r) + return C + + +def verify(a, C, secret_msg): + Y = hash_to_point(secret_msg.encode("utf-8")) + return C == Y.mult(a) + + +### Below is a test of a simple positive and negative case + +# # Alice's keys +# a = PrivateKey() +# A = a.pubkey +# secret_msg = "test" +# B_, r = step1_alice(secret_msg) +# C_ = step2_bob(B_, a) +# C = step3_alice(C_, r, A) +# print("C:{}, secret_msg:{}".format(C, secret_msg)) +# assert verify(a, C, secret_msg) +# assert verify(a, C + C, secret_msg) == False # adding C twice shouldn't pass +# assert verify(a, A, secret_msg) == False # A shouldn't pass + +# # Test operations +# b = PrivateKey() +# B = b.pubkey +# assert -A -A + A == -A # neg +# assert B.mult(a) == A.mult(b) # a*B = A*b diff --git a/cashu/core/base.py b/cashu/core/base.py new file mode 100644 index 0000000..a78f7ee --- /dev/null +++ b/cashu/core/base.py @@ -0,0 +1,108 @@ +from sqlite3 import Row +from typing import List + +from pydantic import BaseModel + + +class Proof(BaseModel): + amount: int + secret: str + C: str + reserved: bool = False # whether this proof is reserved for sending + send_id: str = "" # unique ID of send attempt + time_created: str = "" + time_reserved: str = "" + + @classmethod + def from_row(cls, row: Row): + return cls( + amount=row[0], + C=row[1], + secret=row[2], + reserved=row[3] or False, + send_id=row[4] or "", + time_created=row[5] or "", + time_reserved=row[6] or "", + ) + + @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"), + reserved=d.get("reserved") or False, + send_id=d.get("send_id") or "", + time_created=d.get("time_created") or "", + time_reserved=d.get("time_reserved") or "", + ) + + def to_dict(self): + return dict(amount=self.amount, secret=self.secret, C=self.C) + + def __getitem__(self, key): + return self.__getattribute__(key) + + def __setitem__(self, key, val): + self.__setattr__(key, val) + + +class Proofs(BaseModel): + """TODO: Use this model""" + + proofs: List[Proof] + + +class Invoice(BaseModel): + amount: int + pr: str + hash: str + issued: bool = False + + @classmethod + def from_row(cls, row: Row): + return cls( + amount=int(row[0]), + pr=str(row[1]), + hash=str(row[2]), + issued=bool(row[3]), + ) + + +class BlindedMessage(BaseModel): + amount: int + B_: str + + +class BlindedSignature(BaseModel): + amount: int + C_: str + + @classmethod + def from_dict(cls, d: dict): + return cls( + amount=d["amount"], + C_=d["C_"], + ) + + +class MintPayloads(BaseModel): + blinded_messages: List[BlindedMessage] = [] + + +class SplitPayload(BaseModel): + proofs: List[Proof] + amount: int + output_data: MintPayloads + + +class CheckPayload(BaseModel): + proofs: List[Proof] + + +class MeltPayload(BaseModel): + proofs: List[Proof] + amount: int + invoice: str diff --git a/cashu/core/bolt11.py b/cashu/core/bolt11.py new file mode 100644 index 0000000..962581d --- /dev/null +++ b/cashu/core/bolt11.py @@ -0,0 +1,370 @@ +import hashlib +import re +import time +from binascii import unhexlify +from decimal import Decimal +from typing import List, NamedTuple, Optional + +import bitstring # type: ignore +import secp256k1 +from bech32 import CHARSET, bech32_decode, bech32_encode +from ecdsa import SECP256k1, VerifyingKey # type: ignore +from ecdsa.util import sigdecode_string # type: ignore + + +class Route(NamedTuple): + pubkey: str + short_channel_id: str + base_fee_msat: int + ppm_fee: int + cltv: int + + +class Invoice(object): + payment_hash: str + amount_msat: int = 0 + description: Optional[str] = None + description_hash: Optional[str] = None + payee: Optional[str] = None + date: int + expiry: int = 3600 + secret: Optional[str] = None + route_hints: List[Route] = [] + min_final_cltv_expiry: int = 18 + + +def decode(pr: str) -> Invoice: + """bolt11 decoder, + based on https://github.com/rustyrussell/lightning-payencode/blob/master/lnaddr.py + """ + + hrp, decoded_data = bech32_decode(pr) + if hrp is None or decoded_data is None: + raise ValueError("Bad bech32 checksum") + if not hrp.startswith("ln"): + raise ValueError("Does not start with ln") + + bitarray = _u5_to_bitarray(decoded_data) + + # final signature 65 bytes, split it off. + if len(bitarray) < 65 * 8: + raise ValueError("Too short to contain signature") + + # extract the signature + signature = bitarray[-65 * 8 :].tobytes() + + # the tagged fields as a bitstream + data = bitstring.ConstBitStream(bitarray[: -65 * 8]) + + # build the invoice object + invoice = Invoice() + + # decode the amount from the hrp + m = re.search(r"[^\d]+", hrp[2:]) + if m: + amountstr = hrp[2 + m.end() :] + if amountstr != "": + invoice.amount_msat = _unshorten_amount(amountstr) + + # pull out date + invoice.date = data.read(35).uint + + while data.pos != data.len: + tag, tagdata, data = _pull_tagged(data) + data_length = len(tagdata) / 5 + + if tag == "d": + invoice.description = _trim_to_bytes(tagdata).decode("utf-8") + elif tag == "h" and data_length == 52: + invoice.description_hash = _trim_to_bytes(tagdata).hex() + elif tag == "p" and data_length == 52: + invoice.payment_hash = _trim_to_bytes(tagdata).hex() + elif tag == "x": + invoice.expiry = tagdata.uint + elif tag == "n": + invoice.payee = _trim_to_bytes(tagdata).hex() + # this won't work in most cases, we must extract the payee + # from the signature + elif tag == "s": + invoice.secret = _trim_to_bytes(tagdata).hex() + elif tag == "r": + s = bitstring.ConstBitStream(tagdata) + while s.pos + 264 + 64 + 32 + 32 + 16 < s.len: + route = Route( + pubkey=s.read(264).tobytes().hex(), + short_channel_id=_readable_scid(s.read(64).intbe), + base_fee_msat=s.read(32).intbe, + ppm_fee=s.read(32).intbe, + cltv=s.read(16).intbe, + ) + invoice.route_hints.append(route) + + # BOLT #11: + # A reader MUST check that the `signature` is valid (see the `n` tagged + # field specified below). + # A reader MUST use the `n` field to validate the signature instead of + # performing signature recovery if a valid `n` field is provided. + message = bytearray([ord(c) for c in hrp]) + data.tobytes() + sig = signature[0:64] + if invoice.payee: + key = VerifyingKey.from_string(unhexlify(invoice.payee), curve=SECP256k1) + key.verify(sig, message, hashlib.sha256, sigdecode=sigdecode_string) + else: + keys = VerifyingKey.from_public_key_recovery( + sig, message, SECP256k1, hashlib.sha256 + ) + signaling_byte = signature[64] + key = keys[int(signaling_byte)] + invoice.payee = key.to_string("compressed").hex() + + return invoice + + +def encode(options): + """Convert options into LnAddr and pass it to the encoder""" + addr = LnAddr() + addr.currency = options["currency"] + addr.fallback = options["fallback"] if options["fallback"] else None + if options["amount"]: + addr.amount = options["amount"] + if options["timestamp"]: + addr.date = int(options["timestamp"]) + + addr.paymenthash = unhexlify(options["paymenthash"]) + + if options["description"]: + addr.tags.append(("d", options["description"])) + if options["description_hash"]: + addr.tags.append(("h", options["description_hash"])) + if options["expires"]: + addr.tags.append(("x", options["expires"])) + + if options["fallback"]: + addr.tags.append(("f", options["fallback"])) + if options["route"]: + for r in options["route"]: + splits = r.split("/") + route = [] + while len(splits) >= 5: + route.append( + ( + unhexlify(splits[0]), + unhexlify(splits[1]), + int(splits[2]), + int(splits[3]), + int(splits[4]), + ) + ) + splits = splits[5:] + assert len(splits) == 0 + addr.tags.append(("r", route)) + return lnencode(addr, options["privkey"]) + + +def lnencode(addr, privkey): + if addr.amount: + amount = Decimal(str(addr.amount)) + # We can only send down to millisatoshi. + if amount * 10**12 % 10: + raise ValueError( + "Cannot encode {}: too many decimal places".format(addr.amount) + ) + + amount = addr.currency + shorten_amount(amount) + else: + amount = addr.currency if addr.currency else "" + + hrp = "ln" + amount + "0n" + + # Start with the timestamp + data = bitstring.pack("uint:35", addr.date) + + # Payment hash + data += tagged_bytes("p", addr.paymenthash) + tags_set = set() + + for k, v in addr.tags: + + # BOLT #11: + # + # A writer MUST NOT include more than one `d`, `h`, `n` or `x` fields, + if k in ("d", "h", "n", "x"): + if k in tags_set: + raise ValueError("Duplicate '{}' tag".format(k)) + + if k == "r": + route = bitstring.BitArray() + for step in v: + pubkey, channel, feebase, feerate, cltv = step + route.append( + bitstring.BitArray(pubkey) + + bitstring.BitArray(channel) + + bitstring.pack("intbe:32", feebase) + + bitstring.pack("intbe:32", feerate) + + bitstring.pack("intbe:16", cltv) + ) + data += tagged("r", route) + elif k == "f": + data += encode_fallback(v, addr.currency) + elif k == "d": + data += tagged_bytes("d", v.encode()) + elif k == "x": + # Get minimal length by trimming leading 5 bits at a time. + expirybits = bitstring.pack("intbe:64", v)[4:64] + while expirybits.startswith("0b00000"): + expirybits = expirybits[5:] + data += tagged("x", expirybits) + elif k == "h": + data += tagged_bytes("h", v) + elif k == "n": + data += tagged_bytes("n", v) + else: + # FIXME: Support unknown tags? + raise ValueError("Unknown tag {}".format(k)) + + tags_set.add(k) + + # BOLT #11: + # + # A writer MUST include either a `d` or `h` field, and MUST NOT include + # both. + if "d" in tags_set and "h" in tags_set: + raise ValueError("Cannot include both 'd' and 'h'") + if not "d" in tags_set and not "h" in tags_set: + raise ValueError("Must include either 'd' or 'h'") + + # We actually sign the hrp, then data (padded to 8 bits with zeroes). + privkey = secp256k1.PrivateKey(bytes(unhexlify(privkey))) + sig = privkey.ecdsa_sign_recoverable( + bytearray([ord(c) for c in hrp]) + data.tobytes() + ) + # This doesn't actually serialize, but returns a pair of values :( + sig, recid = privkey.ecdsa_recoverable_serialize(sig) + data += bytes(sig) + bytes([recid]) + + return bech32_encode(hrp, bitarray_to_u5(data)) + + +class LnAddr(object): + def __init__( + self, paymenthash=None, amount=None, currency="bc", tags=None, date=None + ): + self.date = int(time.time()) if not date else int(date) + self.tags = [] if not tags else tags + self.unknown_tags = [] + self.paymenthash = paymenthash + self.signature = None + self.pubkey = None + self.currency = currency + self.amount = amount + + def __str__(self): + return "LnAddr[{}, amount={}{} tags=[{}]]".format( + hexlify(self.pubkey.serialize()).decode("utf-8"), + self.amount, + self.currency, + ", ".join([k + "=" + str(v) for k, v in self.tags]), + ) + + +def shorten_amount(amount): + """Given an amount in bitcoin, shorten it""" + # Convert to pico initially + amount = int(amount * 10**12) + units = ["p", "n", "u", "m", ""] + for unit in units: + if amount % 1000 == 0: + amount //= 1000 + else: + break + return str(amount) + unit + + +def _unshorten_amount(amount: str) -> int: + """Given a shortened amount, return millisatoshis""" + # BOLT #11: + # The following `multiplier` letters are defined: + # + # * `m` (milli): multiply by 0.001 + # * `u` (micro): multiply by 0.000001 + # * `n` (nano): multiply by 0.000000001 + # * `p` (pico): multiply by 0.000000000001 + units = {"p": 10**12, "n": 10**9, "u": 10**6, "m": 10**3} + unit = str(amount)[-1] + + # BOLT #11: + # A reader SHOULD fail if `amount` contains a non-digit, or is followed by + # anything except a `multiplier` in the table above. + if not re.fullmatch(r"\d+[pnum]?", str(amount)): + raise ValueError("Invalid amount '{}'".format(amount)) + + if unit in units: + return int(int(amount[:-1]) * 100_000_000_000 / units[unit]) + else: + return int(amount) * 100_000_000_000 + + +def _pull_tagged(stream): + tag = stream.read(5).uint + length = stream.read(5).uint * 32 + stream.read(5).uint + return (CHARSET[tag], stream.read(length * 5), stream) + + +def is_p2pkh(currency, prefix): + return prefix == base58_prefix_map[currency][0] + + +def is_p2sh(currency, prefix): + return prefix == base58_prefix_map[currency][1] + + +# Tagged field containing BitArray +def tagged(char, l): + # Tagged fields need to be zero-padded to 5 bits. + while l.len % 5 != 0: + l.append("0b0") + return ( + bitstring.pack( + "uint:5, uint:5, uint:5", + CHARSET.find(char), + (l.len / 5) / 32, + (l.len / 5) % 32, + ) + + l + ) + + +def tagged_bytes(char, l): + return tagged(char, bitstring.BitArray(l)) + + +def _trim_to_bytes(barr): + # Adds a byte if necessary. + b = barr.tobytes() + if barr.len % 8 != 0: + return b[:-1] + return b + + +def _readable_scid(short_channel_id: int) -> str: + return "{blockheight}x{transactionindex}x{outputindex}".format( + blockheight=((short_channel_id >> 40) & 0xFFFFFF), + transactionindex=((short_channel_id >> 16) & 0xFFFFFF), + outputindex=(short_channel_id & 0xFFFF), + ) + + +def _u5_to_bitarray(arr: List[int]) -> bitstring.BitArray: + ret = bitstring.BitArray() + for a in arr: + ret += bitstring.pack("uint:5", a) + return ret + + +def bitarray_to_u5(barr): + assert barr.len % 5 == 0 + ret = [] + s = bitstring.ConstBitStream(barr) + while s.pos != s.len: + ret.append(s.read(5).uint) + return ret diff --git a/cashu/core/db.py b/cashu/core/db.py new file mode 100644 index 0000000..e393aa1 --- /dev/null +++ b/cashu/core/db.py @@ -0,0 +1,182 @@ +import asyncio +import datetime +import os +import time +from contextlib import asynccontextmanager +from typing import Optional + +from sqlalchemy import create_engine +from sqlalchemy_aio.base import AsyncConnection +from sqlalchemy_aio.strategy import ASYNCIO_STRATEGY # type: ignore + +POSTGRES = "POSTGRES" +COCKROACH = "COCKROACH" +SQLITE = "SQLITE" + + +class Compat: + type: Optional[str] = "" + schema: Optional[str] = "" + + def interval_seconds(self, seconds: int) -> str: + if self.type in {POSTGRES, COCKROACH}: + return f"interval '{seconds} seconds'" + elif self.type == SQLITE: + return f"{seconds}" + return "" + + @property + def timestamp_now(self) -> str: + if self.type in {POSTGRES, COCKROACH}: + return "now()" + elif self.type == SQLITE: + return "(strftime('%s', 'now'))" + return "" + + @property + def serial_primary_key(self) -> str: + if self.type in {POSTGRES, COCKROACH}: + return "SERIAL PRIMARY KEY" + elif self.type == SQLITE: + return "INTEGER PRIMARY KEY AUTOINCREMENT" + return "" + + @property + def references_schema(self) -> str: + if self.type in {POSTGRES, COCKROACH}: + return f"{self.schema}." + elif self.type == SQLITE: + return "" + return "" + + +class Connection(Compat): + def __init__(self, conn: AsyncConnection, txn, typ, name, schema): + self.conn = conn + self.txn = txn + self.type = typ + self.name = name + self.schema = schema + + def rewrite_query(self, query) -> str: + if self.type in {POSTGRES, COCKROACH}: + query = query.replace("%", "%%") + query = query.replace("?", "%s") + return query + + async def fetchall(self, query: str, values: tuple = ()) -> list: + result = await self.conn.execute(self.rewrite_query(query), values) + return await result.fetchall() + + async def fetchone(self, query: str, values: tuple = ()): + result = await self.conn.execute(self.rewrite_query(query), values) + row = await result.fetchone() + await result.close() + return row + + async def execute(self, query: str, values: tuple = ()): + return await self.conn.execute(self.rewrite_query(query), values) + + +class Database(Compat): + def __init__(self, db_name: str, db_location: str): + 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 + + if database_uri.startswith("cockroachdb://"): + self.type = COCKROACH + else: + self.type = POSTGRES + + 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()) + + 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 + ) + ) + else: + if not os.path.exists(self.db_location): + print(f"Creating database directory: {self.db_location}") + os.makedirs(self.db_location) + self.path = os.path.join(self.db_location, f"{self.name}.sqlite3") + database_uri = f"sqlite:///{self.path}" + self.type = SQLITE + + self.schema = self.name + if self.name.startswith("ext_"): + self.schema = self.name[4:] + else: + self.schema = None + + self.engine = create_engine(database_uri, strategy=ASYNCIO_STRATEGY) + self.lock = asyncio.Lock() + + @asynccontextmanager + async def connect(self): + await self.lock.acquire() + try: + async with self.engine.connect() as conn: + async with conn.begin() as txn: + wconn = Connection(conn, txn, self.type, self.name, self.schema) + + if self.schema: + if self.type in {POSTGRES, COCKROACH}: + await wconn.execute( + f"CREATE SCHEMA IF NOT EXISTS {self.schema}" + ) + elif self.type == SQLITE: + await wconn.execute( + f"ATTACH '{self.path}' AS {self.schema}" + ) + + yield wconn + finally: + self.lock.release() + + async def fetchall(self, query: str, values: tuple = ()) -> list: + async with self.connect() as conn: + result = await conn.execute(query, values) + return await result.fetchall() + + async def fetchone(self, query: str, values: tuple = ()): + async with self.connect() as conn: + result = await conn.execute(query, values) + row = await result.fetchone() + await result.close() + return row + + async def execute(self, query: str, values: tuple = ()): + async with self.connect() as conn: + return await conn.execute(query, values) + + @asynccontextmanager + async def reuse_conn(self, conn: Connection): + yield conn diff --git a/cashu/core/helpers.py b/cashu/core/helpers.py new file mode 100644 index 0000000..0f8c885 --- /dev/null +++ b/cashu/core/helpers.py @@ -0,0 +1,35 @@ +import asyncio +from functools import partial, wraps + +from cashu.core.settings import LIGHTNING_FEE_PERCENT, LIGHTNING_RESERVE_FEE_MIN + + +def async_wrap(func): + @wraps(func) + async def run(*args, loop=None, executor=None, **kwargs): + if loop is None: + loop = asyncio.get_event_loop() + partial_func = partial(func, *args, **kwargs) + return await loop.run_in_executor(executor, partial_func) + + return run + + +def async_unwrap(to_await): + async_response = [] + + async def run_and_capture_result(): + r = await to_await + async_response.append(r) + + loop = asyncio.get_event_loop() + coroutine = run_and_capture_result() + loop.run_until_complete(coroutine) + return async_response[0] + + +def fee_reserve(amount_msat: int) -> int: + """Function for calculating the Lightning fee reserve""" + return max( + int(LIGHTNING_RESERVE_FEE_MIN), int(amount_msat * LIGHTNING_FEE_PERCENT / 100.0) + ) diff --git a/cashu/core/migrations.py b/cashu/core/migrations.py new file mode 100644 index 0000000..6beaa91 --- /dev/null +++ b/cashu/core/migrations.py @@ -0,0 +1,51 @@ +import re + +from loguru import logger + +from cashu.core.db import COCKROACH, POSTGRES, SQLITE, Database + + +async def migrate_databases(db: Database, migrations_module): + """Creates the necessary databases if they don't exist already; or migrates them.""" + + async def set_migration_version(conn, db_name, version): + await conn.execute( + """ + INSERT INTO dbversions (db, version) VALUES (?, ?) + ON CONFLICT (db) DO UPDATE SET version = ? + """, + (db_name, version, version), + ) + + async def run_migration(db, migrations_module): + db_name = migrations_module.__name__.split(".")[-2] + for key, migrate in migrations_module.__dict__.items(): + match = match = matcher.match(key) + if match: + version = int(match.group(1)) + if version > current_versions.get(db_name, 0): + await migrate(db) + + if db.schema == None: + await set_migration_version(db, db_name, version) + else: + async with db.connect() as conn: + await set_migration_version(conn, db_name, version) + + async with db.connect() as conn: + if conn.type == SQLITE: + exists = await conn.fetchone( + "SELECT * FROM sqlite_master WHERE type='table' AND name='dbversions'" + ) + elif conn.type in {POSTGRES, COCKROACH}: + exists = await conn.fetchone( + "SELECT * FROM information_schema.tables WHERE table_name = 'dbversions'" + ) + + if not exists: + await migrations_module.m000_create_migrations_table(conn) + + rows = await (await conn.execute("SELECT * FROM dbversions")).fetchall() + current_versions = {row["db"]: row["version"] for row in rows} + matcher = re.compile(r"^m(\d\d\d)_") + await run_migration(conn, migrations_module) diff --git a/cashu/core/secp.py b/cashu/core/secp.py new file mode 100644 index 0000000..3341643 --- /dev/null +++ b/cashu/core/secp.py @@ -0,0 +1,52 @@ +from secp256k1 import PrivateKey, PublicKey + + +# We extend the public key to define some operations on points +# Picked from https://github.com/WTRMQDev/secp256k1-zkp-py/blob/master/secp256k1_zkp/__init__.py +class PublicKeyExt(PublicKey): + def __add__(self, pubkey2): + if isinstance(pubkey2, PublicKey): + new_pub = PublicKey() + new_pub.combine([self.public_key, pubkey2.public_key]) + return new_pub + else: + raise TypeError("Cant add pubkey and %s" % pubkey2.__class__) + + def __neg__(self): + serialized = self.serialize() + first_byte, remainder = serialized[:1], serialized[1:] + # flip odd/even byte + first_byte = {b"\x03": b"\x02", b"\x02": b"\x03"}[first_byte] + return PublicKey(first_byte + remainder, raw=True) + + def __sub__(self, pubkey2): + if isinstance(pubkey2, PublicKey): + return self + (-pubkey2) + else: + raise TypeError("Can't add pubkey and %s" % pubkey2.__class__) + + def mult(self, privkey): + if isinstance(privkey, PrivateKey): + return self.tweak_mul(privkey.private_key) + else: + raise TypeError("Can't multiply with non privatekey") + + def __eq__(self, pubkey2): + if isinstance(pubkey2, PublicKey): + seq1 = self.to_data() + seq2 = pubkey2.to_data() + return seq1 == seq2 + else: + raise TypeError("Can't compare pubkey and %s" % pubkey2.__class__) + + def to_data(self): + return [self.public_key.data[i] for i in range(64)] + + +# Horrible monkeypatching +PublicKey.__add__ = PublicKeyExt.__add__ +PublicKey.__neg__ = PublicKeyExt.__neg__ +PublicKey.__sub__ = PublicKeyExt.__sub__ +PublicKey.mult = PublicKeyExt.mult +PublicKey.__eq__ = PublicKeyExt.__eq__ +PublicKey.to_data = PublicKeyExt.to_data diff --git a/cashu/core/settings.py b/cashu/core/settings.py new file mode 100644 index 0000000..d24c0c4 --- /dev/null +++ b/cashu/core/settings.py @@ -0,0 +1,34 @@ +from pathlib import Path + +from environs import Env # type: ignore + +env = Env() +env.read_env() + +DEBUG = env.bool("DEBUG", default=False) +CASHU_DIR = env.str("CASHU_DIR", default="~/.cashu") +CASHU_DIR = CASHU_DIR.replace("~", str(Path.home())) +assert len(CASHU_DIR), "CASHU_DIR not defined" + +LIGHTNING = env.bool("LIGHTNING", default=True) +LIGHTNING_FEE_PERCENT = env.float("LIGHTNING_FEE_PERCENT", default=1.0) +assert LIGHTNING_FEE_PERCENT >= 0, "LIGHTNING_FEE_PERCENT must be at least 0" +LIGHTNING_RESERVE_FEE_MIN = env.float("LIGHTNING_RESERVE_FEE_MIN", default=4000) + +MINT_PRIVATE_KEY = env.str("MINT_PRIVATE_KEY", default=None) + +MINT_SERVER_HOST = env.str("MINT_SERVER_HOST", default="127.0.0.1") +MINT_SERVER_PORT = env.int("MINT_SERVER_PORT", default=3338) + +MINT_HOST = env.str("MINT_HOST", default="8333.space") +MINT_PORT = env.int("MINT_PORT", default=3338) + +if MINT_HOST in ["localhost", "127.0.0.1"]: + MINT_URL = f"http://{MINT_HOST}:{MINT_PORT}" +else: + MINT_URL = f"https://{MINT_HOST}:{MINT_PORT}" + +LNBITS_ENDPOINT = env.str("LNBITS_ENDPOINT", default=None) +LNBITS_KEY = env.str("LNBITS_KEY", default=None) + +MAX_ORDER = 64 diff --git a/cashu/core/split.py b/cashu/core/split.py new file mode 100644 index 0000000..44b9cf5 --- /dev/null +++ b/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/cashu/lightning/__init__.py b/cashu/lightning/__init__.py new file mode 100644 index 0000000..bc14007 --- /dev/null +++ b/cashu/lightning/__init__.py @@ -0,0 +1,3 @@ +from cashu.lightning.lnbits import LNbitsWallet + +WALLET = LNbitsWallet() diff --git a/cashu/lightning/base.py b/cashu/lightning/base.py new file mode 100644 index 0000000..e38b6d8 --- /dev/null +++ b/cashu/lightning/base.py @@ -0,0 +1,88 @@ +from abc import ABC, abstractmethod +from typing import AsyncGenerator, Coroutine, NamedTuple, Optional + + +class StatusResponse(NamedTuple): + error_message: Optional[str] + balance_msat: int + + +class InvoiceResponse(NamedTuple): + ok: bool + checking_id: Optional[str] = None # payment_hash, rpc_id + payment_request: Optional[str] = None + error_message: Optional[str] = None + + +class PaymentResponse(NamedTuple): + # when ok is None it means we don't know if this succeeded + ok: Optional[bool] = None + checking_id: Optional[str] = None # payment_hash, rcp_id + fee_msat: Optional[int] = None + preimage: Optional[str] = None + error_message: Optional[str] = None + + +class PaymentStatus(NamedTuple): + paid: Optional[bool] = None + fee_msat: Optional[int] = None + preimage: Optional[str] = None + + @property + def pending(self) -> bool: + return self.paid is not True + + @property + def failed(self) -> bool: + return self.paid == False + + def __str__(self) -> str: + if self.paid == True: + return "settled" + elif self.paid == False: + return "failed" + elif self.paid == None: + return "still pending" + else: + return "unknown (should never happen)" + + +class Wallet(ABC): + @abstractmethod + def status(self) -> Coroutine[None, None, StatusResponse]: + pass + + @abstractmethod + def create_invoice( + self, + amount: int, + memo: Optional[str] = None, + description_hash: Optional[bytes] = None, + ) -> Coroutine[None, None, InvoiceResponse]: + pass + + @abstractmethod + def pay_invoice( + self, bolt11: str, fee_limit_msat: int + ) -> Coroutine[None, None, PaymentResponse]: + pass + + @abstractmethod + def get_invoice_status( + self, checking_id: str + ) -> Coroutine[None, None, PaymentStatus]: + pass + + @abstractmethod + def get_payment_status( + self, checking_id: str + ) -> Coroutine[None, None, PaymentStatus]: + pass + + @abstractmethod + def paid_invoices_stream(self) -> AsyncGenerator[str, None]: + pass + + +class Unsupported(Exception): + pass diff --git a/cashu/lightning/lnbits.py b/cashu/lightning/lnbits.py new file mode 100644 index 0000000..f174f2d --- /dev/null +++ b/cashu/lightning/lnbits.py @@ -0,0 +1,154 @@ +import asyncio +import hashlib +import json +from os import getenv +from typing import AsyncGenerator, Dict, Optional + +import requests + +from cashu.core.settings import LNBITS_ENDPOINT, LNBITS_KEY + +from .base import ( + InvoiceResponse, + PaymentResponse, + PaymentStatus, + StatusResponse, + Wallet, +) + + +class LNbitsWallet(Wallet): + """https://github.com/lnbits/lnbits""" + + def __init__(self): + self.endpoint = LNBITS_ENDPOINT + + key = LNBITS_KEY + self.key = {"X-Api-Key": key} + self.s = requests.Session() + self.s.auth = ("user", "pass") + self.s.headers.update({"X-Api-Key": key}) + + async def status(self) -> StatusResponse: + try: + r = self.s.get(url=f"{self.endpoint}/api/v1/wallet", timeout=15) + except Exception as exc: + return StatusResponse( + f"Failed to connect to {self.endpoint} due to: {exc}", 0 + ) + + try: + data = r.json() + except: + return StatusResponse( + f"Failed to connect to {self.endpoint}, got: '{r.text[:200]}...'", 0 + ) + if "detail" in data: + return StatusResponse(f"LNbits error: {data['detail']}", 0) + return StatusResponse(None, data["balance"]) + + async def create_invoice( + self, + amount: int, + memo: Optional[str] = None, + description_hash: Optional[bytes] = None, + unhashed_description: Optional[bytes] = None, + ) -> InvoiceResponse: + data: Dict = {"out": False, "amount": amount} + if description_hash: + data["description_hash"] = description_hash.hex() + if unhashed_description: + data["unhashed_description"] = unhashed_description.hex() + + data["memo"] = memo or "" + try: + r = self.s.post(url=f"{self.endpoint}/api/v1/payments", json=data) + except: + return InvoiceResponse(False, None, None, r.json()["detail"]) + ok, checking_id, payment_request, error_message = ( + True, + None, + None, + None, + ) + + data = r.json() + checking_id, payment_request = data["checking_id"], data["payment_request"] + + return InvoiceResponse(ok, checking_id, payment_request, error_message) + + async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: + try: + r = self.s.post( + url=f"{self.endpoint}/api/v1/payments", + json={"out": True, "bolt11": bolt11}, + timeout=None, + ) + except: + error_message = r.json()["detail"] + return PaymentResponse(None, None, None, None, error_message) + if "detail" in r.json(): + return PaymentResponse(None, None, None, None, r.json()["detail"]) + ok, checking_id, fee_msat, preimage, error_message = ( + True, + None, + None, + None, + None, + ) + + data = r.json() + checking_id = data["payment_hash"] + + # we do this to get the fee and preimage + payment: PaymentStatus = await self.get_payment_status(checking_id) + + return PaymentResponse(ok, checking_id, payment.fee_msat, payment.preimage) + + async def get_invoice_status(self, checking_id: str) -> PaymentStatus: + try: + + r = self.s.get( + url=f"{self.endpoint}/api/v1/payments/{checking_id}", + headers=self.key, + ) + except: + return PaymentStatus(None) + return PaymentStatus(r.json()["paid"]) + + async def get_payment_status(self, checking_id: str) -> PaymentStatus: + try: + r = self.s.get( + url=f"{self.endpoint}/api/v1/payments/{checking_id}", headers=self.key + ) + except: + return PaymentStatus(None) + data = r.json() + if "paid" not in data and "details" not in data: + return PaymentStatus(None) + + return PaymentStatus(data["paid"], data["details"]["fee"], data["preimage"]) + + async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: + url = f"{self.endpoint}/api/v1/payments/sse" + + while True: + try: + async with requests.stream("GET", url) as r: + async for line in r.aiter_lines(): + if line.startswith("data:"): + try: + data = json.loads(line[5:]) + except json.decoder.JSONDecodeError: + continue + + if type(data) is not dict: + continue + + yield data["payment_hash"] # payment_hash + + except: + pass + + print("lost connection to lnbits /payments/sse, retrying in 5 seconds") + await asyncio.sleep(5) diff --git a/cashu/mint/__init__.py b/cashu/mint/__init__.py new file mode 100644 index 0000000..85779d7 --- /dev/null +++ b/cashu/mint/__init__.py @@ -0,0 +1,6 @@ +from cashu.core.settings import MINT_PRIVATE_KEY +from cashu.mint.ledger import Ledger + +print("init") + +ledger = Ledger(MINT_PRIVATE_KEY, "data/mint") diff --git a/cashu/mint/__main__.py b/cashu/mint/__main__.py new file mode 100644 index 0000000..90e97df --- /dev/null +++ b/cashu/mint/__main__.py @@ -0,0 +1,6 @@ +from .main import main + +print("main") + + +main() diff --git a/cashu/mint/app.py b/cashu/mint/app.py new file mode 100644 index 0000000..77b2858 --- /dev/null +++ b/cashu/mint/app.py @@ -0,0 +1,73 @@ +import asyncio +import logging +import sys + + +from fastapi import FastAPI +from loguru import logger + +from cashu.core.settings import CASHU_DIR, DEBUG + +from cashu.lightning import WALLET +from cashu.mint.migrations import m001_initial + +from . import ledger +from .router import router +from .startup import load_ledger + + +def create_app(config_object="core.settings") -> FastAPI: + def configure_logger() -> None: + class Formatter: + def __init__(self): + self.padding = 0 + self.minimal_fmt: str = "{time:YYYY-MM-DD HH:mm:ss.SS} | {level} | {message}\n" + if DEBUG: + self.fmt: str = "{time:YYYY-MM-DD HH:mm:ss.SS} | {level: <4} | {name}:{function}:{line} | {message}\n" + else: + self.fmt: str = self.minimal_fmt + + def format(self, record): + function = "{function}".format(**record) + if function == "emit": # uvicorn logs + return self.minimal_fmt + return self.fmt + + class InterceptHandler(logging.Handler): + def emit(self, record): + try: + level = logger.level(record.levelname).name + except ValueError: + level = record.levelno + logger.log(level, record.getMessage()) + + logger.remove() + log_level: str = "INFO" + formatter = Formatter() + logger.add(sys.stderr, level=log_level, format=formatter.format) + + logging.getLogger("uvicorn").handlers = [InterceptHandler()] + logging.getLogger("uvicorn.access").handlers = [InterceptHandler()] + + configure_logger() + + app = FastAPI( + title="Cashu Mint", + description="Ecash wallet and mint.", + license_info={ + "name": "MIT License", + "url": "https://raw.githubusercontent.com/callebtc/cashu/main/LICENSE", + }, + ) + + return app + + +app = create_app() + +app.include_router(router=router) + + +@app.on_event("startup") +async def startup_load_ledger(): + await load_ledger() diff --git a/cashu/mint/crud.py b/cashu/mint/crud.py new file mode 100644 index 0000000..18a7195 --- /dev/null +++ b/cashu/mint/crud.py @@ -0,0 +1,110 @@ +import secrets +from typing import Optional + +from cashu.core.base import Invoice, Proof +from cashu.core.db import Connection, Database + + +async def store_promise( + amount: int, + B_: str, + C_: str, + db: Database, + conn: Optional[Connection] = None, +): + + await (conn or db).execute( + """ + INSERT INTO promises + (amount, B_b, C_b) + VALUES (?, ?, ?) + """, + ( + amount, + str(B_), + str(C_), + ), + ) + + +async def get_proofs_used( + db: Database, + conn: Optional[Connection] = None, +): + + rows = await (conn or db).fetchall( + """ + SELECT secret from proofs_used + """ + ) + return [row[0] for row in rows] + + +async def invalidate_proof( + proof: Proof, + db: Database, + conn: Optional[Connection] = None, +): + + # we add the proof and secret to the used list + await (conn or db).execute( + """ + INSERT INTO proofs_used + (amount, C, secret) + VALUES (?, ?, ?) + """, + ( + proof.amount, + str(proof.C), + str(proof.secret), + ), + ) + + +async def store_lightning_invoice( + invoice: Invoice, + db: Database, + conn: Optional[Connection] = None, +): + + await (conn or db).execute( + """ + INSERT INTO invoices + (amount, pr, hash, issued) + VALUES (?, ?, ?, ?) + """, + ( + invoice.amount, + invoice.pr, + invoice.hash, + invoice.issued, + ), + ) + + +async def get_lightning_invoice( + hash: str, + db: Database, + conn: Optional[Connection] = None, +): + + row = await (conn or db).fetchone( + """ + SELECT * from invoices + WHERE hash = ? + """, + hash, + ) + return Invoice.from_row(row) + + +async def update_lightning_invoice( + hash: str, + issued: bool, + db: Database, + conn: Optional[Connection] = None, +): + await (conn or db).execute( + "UPDATE invoices SET issued = ? WHERE hash = ?", + (issued, hash), + ) diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py new file mode 100644 index 0000000..13f6310 --- /dev/null +++ b/cashu/mint/ledger.py @@ -0,0 +1,258 @@ +""" +Implementation of https://gist.github.com/phyro/935badc682057f418842c72961cf096c +""" + +import hashlib +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.secp import PrivateKey, PublicKey +from cashu.core.settings import LIGHTNING, MAX_ORDER +from cashu.core.split import amount_split +from cashu.lightning import WALLET +from cashu.mint.crud import ( + get_lightning_invoice, + get_proofs_used, + invalidate_proof, + store_lightning_invoice, + store_promise, + update_lightning_invoice, +) + + +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.db: Database = Database("mint", db) + + async def load_used_proofs(self): + self.proofs_used = set(await get_proofs_used(db=self.db)) + + @staticmethod + def _derive_keys(master_key: str): + """Deterministic derivation of keys for 2^n values.""" + return { + 2 + ** i: PrivateKey( + hashlib.sha256((str(master_key) + str(i)).encode("utf-8")) + .hexdigest() + .encode("utf-8")[:32], + raw=True, + ) + for i in range(MAX_ORDER) + } + + @staticmethod + def _derive_pubkeys(keys: List[PrivateKey]): + return {amt: keys[amt].pubkey for amt in [2**i for i in range(MAX_ORDER)]} + + async def _generate_promises(self, amounts: List[int], B_s: List[str]): + """Generates promises that sum to the given amount.""" + return [ + await self._generate_promise(amount, PublicKey(bytes.fromhex(B_), raw=True)) + for (amount, B_) in zip(amounts, B_s) + ] + + async def _generate_promise(self, amount: int, B_: PublicKey): + """Generates a promise for given amount and returns a pair (amount, C').""" + secret_key = self.keys[amount] # Get the correct key + C_ = b_dhke.step2_bob(B_, secret_key) + await store_promise( + amount, B_=B_.serialize().hex(), C_=C_.serialize().hex(), db=self.db + ) + return BlindedSignature(amount=amount, C_=C_.serialize().hex()) + + def _check_spendable(self, proof: Proof): + """Checks whether the proof was already spent.""" + return not proof.secret in self.proofs_used + + def _verify_proof(self, proof: Proof): + """Verifies that the proof of promise was issued by this ledger.""" + if not self._check_spendable(proof): + raise Exception(f"tokens already spent. Secret: {proof.secret}") + secret_key = self.keys[proof.amount] # Get the correct key to check against + C = PublicKey(bytes.fromhex(proof.C), raw=True) + return b_dhke.verify(secret_key, C, proof.secret) + + def _verify_outputs( + self, total: int, amount: int, output_data: List[BlindedMessage] + ): + """Verifies the expected split was correctly computed""" + fst_amt, snd_amt = total - amount, amount # we have two amounts to split to + fst_outputs = amount_split(fst_amt) + snd_outputs = amount_split(snd_amt) + expected = fst_outputs + snd_outputs + given = [o.amount for o in output_data] + return given == expected + + def _verify_no_duplicates( + self, proofs: List[Proof], output_data: 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 output_data] + if len(B_s) != len(list(set(B_s))): + return False + return True + + def _verify_split_amount(self, amount: int): + """Split amount like output amount can't be negative or too big.""" + try: + self._verify_amount(amount) + except: + # For better error message + raise Exception("invalid split amount: " + str(amount)) + + def _verify_amount(self, 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( + self, proofs: List[Proof], outs: List[BlindedMessage] + ): + """Verify that Σoutputs - Σinputs = 0.""" + sum_inputs = sum(self._verify_amount(p.amount) for p in proofs) + sum_outputs = sum(self._verify_amount(p.amount) for p in outs) + assert sum_outputs - sum_inputs == 0 + + def _get_output_split(self, amount: int): + """Given an amount returns a list of amounts returned e.g. 13 is [1, 4, 8].""" + self._verify_amount(amount) + bits_amt = bin(amount)[::-1][:-2] + rv = [] + for (pos, bit) in enumerate(bits_amt): + if bit == "1": + rv.append(2**pos) + return rv + + async def _request_lightning_invoice(self, amount: int): + """Returns an invoice from the Lightning backend.""" + error, balance = await WALLET.status() + if error: + raise Exception(f"Lightning wallet not responding: {error}") + ok, checking_id, payment_request, error_message = await WALLET.create_invoice( + amount, "cashu deposit" + ) + return payment_request, checking_id + + async def _check_lightning_invoice(self, payment_hash: str): + """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") + status = await WALLET.get_invoice_status(payment_hash) + if status.paid: + await update_lightning_invoice(payment_hash, issued=True, db=self.db) + return status.paid + + async def _pay_lightning_invoice(self, invoice: str, amount: int): + """Returns an invoice from the Lightning backend.""" + error, balance = await WALLET.status() + if error: + raise Exception(f"Lightning wallet not responding: {error}") + ok, checking_id, fee_msat, preimage, error_message = await WALLET.pay_invoice( + invoice, fee_limit_msat=fee_reserve(amount * 1000) + ) + return ok, preimage + + async def _invalidate_proofs(self, proofs: List[Proof]): + """Adds secrets of proofs to the list of knwon secrets and stores them in the db.""" + # Mark proofs as used and prepare new promises + proof_msgs = set([p.secret for p in proofs]) + self.proofs_used |= proof_msgs + # store in db + for p in proofs: + await invalidate_proof(p, db=self.db) + + # Public methods + def get_pubkeys(self): + """Returns public keys for possible amounts.""" + return {a: p.serialize().hex() for a, p in self.pub_keys.items()} + + async def request_mint(self, amount): + """Returns Lightning invoice and stores it in the db.""" + payment_request, checking_id = await self._request_lightning_invoice(amount) + invoice = Invoice( + amount=amount, pr=payment_request, hash=checking_id, issued=False + ) + if not payment_request or not checking_id: + raise Exception(f"Could not create Lightning invoice.") + await store_lightning_invoice(invoice, db=self.db) + return payment_request, checking_id + + async def mint(self, B_s: List[PublicKey], amounts: List[int], payment_hash=None): + """Mints a promise for coins for B_.""" + # check if lightning invoice was paid + if LIGHTNING and ( + payment_hash and not await self._check_lightning_invoice(payment_hash) + ): + raise Exception("Lightning invoice not paid yet.") + + for amount in amounts: + if amount not in [2**i for i in range(MAX_ORDER)]: + raise Exception(f"Can only mint amounts up to {2**MAX_ORDER}.") + + promises = [ + await self._generate_promise(amount, B_) for B_, amount in zip(B_s, amounts) + ] + return promises + + async def melt(self, proofs: List[Proof], amount: int, invoice: str): + """Invalidates proofs and pays a Lightning invoice.""" + # if not LIGHTNING: + total = sum([p["amount"] for p in proofs]) + # check that lightning fees are included + assert total + fee_reserve(amount * 1000) >= amount, Exception( + "provided proofs not enough for Lightning payment." + ) + + status, preimage = await self._pay_lightning_invoice(invoice, amount) + if status == True: + await self._invalidate_proofs(proofs) + return status, preimage + + async def check_spendable(self, proofs: List[Proof]): + """Checks if all provided proofs are valid and still spendable (i.e. have not been spent).""" + 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] + ): + """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") + 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") + + # 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) :]] + prom_fst, prom_snd = await self._generate_promises( + outs_fst, B_fst + ), await self._generate_promises(outs_snd, B_snd) + self._verify_equation_balanced(proofs, prom_fst + prom_snd) + return prom_fst, prom_snd diff --git a/cashu/mint/main.py b/cashu/mint/main.py new file mode 100644 index 0000000..4fff6ba --- /dev/null +++ b/cashu/mint/main.py @@ -0,0 +1,52 @@ +import click +import uvicorn + +from cashu.core.settings import ( + MINT_SERVER_HOST, + MINT_SERVER_PORT, +) + + +@click.command( + context_settings=dict( + ignore_unknown_options=True, + allow_extra_args=True, + ) +) +@click.option("--port", default=MINT_SERVER_PORT, help="Port to listen on") +@click.option("--host", default=MINT_SERVER_HOST, help="Host to run mint on") +@click.option("--ssl-keyfile", default=None, help="Path to SSL keyfile") +@click.option("--ssl-certfile", default=None, help="Path to SSL certificate") +@click.pass_context +def main( + ctx, + port: int = MINT_SERVER_PORT, + host: str = MINT_SERVER_HOST, + ssl_keyfile: str = None, + ssl_certfile: str = None, +): + """Launched with `poetry run mint` at root level""" + # this beautiful beast parses all command line arguments and passes them to the uvicorn server + d = dict() + for a in ctx.args: + item = a.split("=") + if len(item) > 1: # argument like --key=value + print(a, item) + d[item[0].strip("--").replace("-", "_")] = ( + int(item[1]) # need to convert to int if it's a number + if item[1].isdigit() + else item[1] + ) + else: + d[a.strip("--")] = True # argument like --key + + config = uvicorn.Config( + "cashu.mint.app:app", + port=port, + host=host, + ssl_keyfile=ssl_keyfile, + ssl_certfile=ssl_certfile, + **d, + ) + server = uvicorn.Server(config) + server.run() diff --git a/cashu/mint/migrations.py b/cashu/mint/migrations.py new file mode 100644 index 0000000..967b9d3 --- /dev/null +++ b/cashu/mint/migrations.py @@ -0,0 +1,87 @@ +from cashu.core.db import Database + + +async def m000_create_migrations_table(db): + await db.execute( + """ + CREATE TABLE IF NOT EXISTS dbversions ( + db TEXT PRIMARY KEY, + version INT NOT NULL + ) + """ + ) + + +async def m001_initial(db: Database): + await db.execute( + """ + CREATE TABLE IF NOT EXISTS promises ( + amount INTEGER NOT NULL, + B_b TEXT NOT NULL, + C_b TEXT NOT NULL, + + UNIQUE (B_b) + + ); + """ + ) + + await db.execute( + """ + CREATE TABLE IF NOT EXISTS proofs_used ( + amount INTEGER NOT NULL, + C TEXT NOT NULL, + secret TEXT NOT NULL, + + UNIQUE (secret) + + ); + """ + ) + + await db.execute( + """ + CREATE TABLE IF NOT EXISTS invoices ( + amount INTEGER NOT NULL, + pr TEXT NOT NULL, + hash TEXT NOT NULL, + issued BOOL NOT NULL, + + UNIQUE (hash) + + ); + """ + ) + + await db.execute( + """ + CREATE VIEW IF NOT EXISTS balance_issued AS + SELECT COALESCE(SUM(s), 0) AS balance FROM ( + SELECT SUM(amount) AS s + FROM promises + WHERE amount > 0 + ); + """ + ) + + await db.execute( + """ + CREATE VIEW IF NOT EXISTS balance_used AS + SELECT COALESCE(SUM(s), 0) AS balance FROM ( + SELECT SUM(amount) AS s + FROM proofs_used + WHERE amount > 0 + ); + """ + ) + + await db.execute( + """ + CREATE VIEW IF NOT EXISTS balance AS + SELECT s_issued - s_used AS balance FROM ( + SELECT bi.balance AS s_issued, bu.balance AS s_used + FROM balance_issued bi + CROSS JOIN balance_used bu + ); + """ + ) diff --git a/cashu/mint/router.py b/cashu/mint/router.py new file mode 100644 index 0000000..01fca8a --- /dev/null +++ b/cashu/mint/router.py @@ -0,0 +1,86 @@ +from fastapi import APIRouter +from cashu.mint import ledger +from cashu.core.base import CheckPayload, MeltPayload, MintPayloads, SplitPayload +from typing import Union +from secp256k1 import PublicKey + +router: APIRouter = APIRouter() + + +@router.get("/keys") +def keys(): + """Get the public keys of the mint""" + return ledger.get_pubkeys() + + +@router.get("/mint") +async def request_mint(amount: int = 0): + """Request minting of tokens. Server responds with a Lightning invoice.""" + payment_request, payment_hash = await ledger.request_mint(amount) + print(f"Lightning invoice: {payment_request}") + return {"pr": payment_request, "hash": payment_hash} + + +@router.post("/mint") +async def mint(payloads: MintPayloads, payment_hash: Union[str, None] = None): + """ + Requests the minting of tokens belonging to a paid payment request. + + Parameters: + pr: payment_request of the Lightning paid invoice. + + Body (JSON): + payloads: contains a list of blinded messages waiting to be signed. + + NOTE: + - This needs to be replaced by the preimage otherwise someone knowing + the payment_request can request the tokens instead of the rightful + owner. + - The blinded message should ideally be provided to the server *before* payment + in the GET /mint endpoint so that the server knows to sign only these tokens + when the invoice is paid. + """ + amounts = [] + B_s = [] + for payload in payloads.blinded_messages: + amounts.append(payload.amount) + B_s.append(PublicKey(bytes.fromhex(payload.B_), raw=True)) + try: + promises = await ledger.mint(B_s, amounts, payment_hash=payment_hash) + return promises + except Exception as exc: + return {"error": str(exc)} + + +@router.post("/melt") +async def melt(payload: MeltPayload): + """ + Requests tokens to be destroyed and sent out via Lightning. + """ + ok, preimage = await ledger.melt(payload.proofs, payload.amount, payload.invoice) + return {"paid": ok, "preimage": preimage} + + +@router.post("/check") +async def check_spendable(payload: CheckPayload): + return await ledger.check_spendable(payload.proofs) + + +@router.post("/split") +async def split(payload: SplitPayload): + """ + Requetst a set of tokens with amount "total" to be split into two + newly minted sets with amount "split" and "total-split". + """ + proofs = payload.proofs + amount = payload.amount + output_data = payload.output_data.blinded_messages + try: + split_return = await ledger.split(proofs, amount, output_data) + 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.") + 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 new file mode 100644 index 0000000..d263c49 --- /dev/null +++ b/cashu/mint/startup.py @@ -0,0 +1,25 @@ +import asyncio +from loguru import logger + +from cashu.core.settings import CASHU_DIR + +from cashu.lightning import WALLET +from cashu.mint.migrations import m001_initial + +from . import ledger + + +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, + ) + + logger.info(f"Lightning balance: {balance} sat") + logger.info(f"Data dir: {CASHU_DIR}") + logger.info("Mint started.") diff --git a/cashu/wallet/__init__.py b/cashu/wallet/__init__.py new file mode 100644 index 0000000..bf11b51 --- /dev/null +++ b/cashu/wallet/__init__.py @@ -0,0 +1,3 @@ +import sys + +sys.tracebacklimit = None diff --git a/cashu/wallet/__main__.py b/cashu/wallet/__main__.py new file mode 100644 index 0000000..4cafccb --- /dev/null +++ b/cashu/wallet/__main__.py @@ -0,0 +1,3 @@ +from .cli import cli + +cli() diff --git a/cashu/wallet/cli.py b/cashu/wallet/cli.py new file mode 100755 index 0000000..07a0616 --- /dev/null +++ b/cashu/wallet/cli.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python + +import asyncio +import base64 +import json +import math +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 + +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 +from cashu.wallet import migrations +from cashu.wallet.crud import get_reserved_proofs +from cashu.wallet.wallet import Wallet as Wallet + + +async def init_wallet(wallet: Wallet): + """Performs migrations and loads proofs from db.""" + await migrate_databases(wallet.db, migrations) + await wallet.load_proofs() + + +class NaturalOrderGroup(click.Group): + """For listing commands in help in order of definition""" + + def list_commands(self, ctx): + return self.commands.keys() + + +@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.pass_context +def cli(ctx, host: str, walletname: str): + 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) + pass + + +# https://github.com/pallets/click/issues/85#issuecomment-503464628 +def coro(f): + @wraps(f) + def wrapper(*args, **kwargs): + return asyncio.run(f(*args, **kwargs)) + + return wrapper + + +@cli.command("mint", help="Mint tokens.") +@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.status() + if not LIGHTNING: + r = await wallet.mint(amount) + elif amount and not hash: + r = await wallet.request_mint(amount) + if "pr" in r: + print(f"Pay this invoice to mint {amount} sat:") + print(f"Invoice: {r['pr']}") + print("") + print( + f"After paying the invoice, run this command:\ncashu mint {amount} --hash {r['hash']}" + ) + elif amount and hash: + await wallet.mint(amount, hash) + wallet.status() + return + + +@cli.command("balance", help="See 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.") +@click.argument("amount", type=int) +@click.pass_context +@coro +async def send(ctx, amount: int): + wallet: Wallet = ctx.obj["WALLET"] + await init_wallet(wallet) + wallet.status() + _, send_proofs = await wallet.split_to_send(wallet.proofs, amount) + await wallet.set_reserved(send_proofs, reserved=True) + token = await wallet.serialize_proofs(send_proofs) + print(token) + wallet.status() + + +@cli.command("receive", help="Receive tokens.") +@click.argument("token", type=str) +@click.pass_context +@coro +async def receive(ctx, token: str): + wallet: Wallet = ctx.obj["WALLET"] + await init_wallet(wallet) + wallet.status() + proofs = [Proof.from_dict(p) for p in json.loads(base64.urlsafe_b64decode(token))] + _, _ = await wallet.redeem(proofs) + 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.") +@click.option( + "--force", "-f", default=False, is_flag=True, help="Force check on all tokens." +) +@click.pass_context +@coro +async def burn(ctx, token: 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): + print( + "Error: enter a token or use --all to burn all pending tokens or --force to check all tokens." + ) + return + if all: + # check only those who are flagged as reserved + proofs = await get_reserved_proofs(wallet.db) + elif force: + # check all proofs in db + proofs = wallet.proofs + else: + # check only the specified ones + proofs = [ + Proof.from_dict(p) for p in json.loads(base64.urlsafe_b64decode(token)) + ] + wallet.status() + await wallet.invalidate(proofs) + wallet.status() + + +@cli.command("pending", help="Show pending tokens.") +@click.pass_context +@coro +async def pending(ctx): + wallet: Wallet = ctx.obj["WALLET"] + await init_wallet(wallet) + reserved_proofs = await get_reserved_proofs(wallet.db) + if len(reserved_proofs): + sorted_proofs = sorted(reserved_proofs, key=itemgetter("send_id")) + for key, value in groupby(sorted_proofs, key=itemgetter("send_id")): + grouped_proofs = list(value) + token = await wallet.serialize_proofs(grouped_proofs) + 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" + ) + print(token) + print("") + wallet.status() + + +@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.status() + decoded_invoice: Invoice = bolt11.decode(invoice) + amount = math.ceil( + (decoded_invoice.amount_msat + fee_reserve(decoded_invoice.amount_msat)) / 1000 + ) # 1% fee for Lightning + print( + f"Paying Lightning invoice of {decoded_invoice.amount_msat // 1000} sat ({amount} sat incl. fees)" + ) + assert amount > 0, "amount is not positive" + if wallet.available_balance < amount: + print("Error: Balance too low.") + return + _, send_proofs = await wallet.split_to_send(wallet.proofs, amount) + await wallet.pay_lightning(send_proofs, amount, invoice) + wallet.status() + + +@cli.command("info", help="Information about Cashu wallet.") +@click.pass_context +@coro +async def info(ctx): + wallet: Wallet = ctx.obj["WALLET"] + await init_wallet(wallet) + wallet.status() + print(f"Debug: {DEBUG}") + print(f"Cashu dir: {CASHU_DIR}") + print(f"Mint URL: {MINT_URL}") + return diff --git a/cashu/wallet/crud.py b/cashu/wallet/crud.py new file mode 100644 index 0000000..c5741b6 --- /dev/null +++ b/cashu/wallet/crud.py @@ -0,0 +1,99 @@ +import time +from typing import Optional + +from cashu.core.base import Proof +from cashu.core.db import Connection, Database + + +async def store_proof( + proof: Proof, + db: Database, + conn: Optional[Connection] = None, +): + + await (conn or db).execute( + """ + INSERT INTO proofs + (amount, C, secret, time_created) + VALUES (?, ?, ?, ?) + """, + (proof.amount, str(proof.C), str(proof.secret), int(time.time())), + ) + + +async def get_proofs( + db: Database, + conn: Optional[Connection] = None, +): + + rows = await (conn or db).fetchall( + """ + SELECT * from proofs + """ + ) + return [Proof.from_row(r) for r in rows] + + +async def get_reserved_proofs( + db: Database, + conn: Optional[Connection] = None, +): + + rows = await (conn or db).fetchall( + """ + SELECT * from proofs + WHERE reserved + """ + ) + return [Proof.from_row(r) for r in rows] + + +async def invalidate_proof( + proof: Proof, + db: Database, + conn: Optional[Connection] = None, +): + + await (conn or db).execute( + f""" + DELETE FROM proofs + WHERE secret = ? + """, + str(proof["secret"]), + ) + + await (conn or db).execute( + """ + INSERT INTO proofs_used + (amount, C, secret, time_used) + VALUES (?, ?, ?, ?) + """, + (proof.amount, str(proof.C), str(proof.secret), int(time.time())), + ) + + +async def update_proof_reserved( + proof: Proof, + reserved: bool, + send_id: str = None, + db: Database = None, + conn: Optional[Connection] = None, +): + clauses = [] + values = [] + clauses.append("reserved = ?") + values.append(reserved) + + if send_id: + clauses.append("send_id = ?") + values.append(send_id) + + if reserved: + # set the time of reserving + clauses.append("time_reserved = ?") + values.append(int(time.time())) + + await (conn or db).execute( + f"UPDATE proofs SET {', '.join(clauses)} WHERE secret = ?", + (*values, str(proof.secret)), + ) diff --git a/cashu/wallet/migrations.py b/cashu/wallet/migrations.py new file mode 100644 index 0000000..c8b3ec0 --- /dev/null +++ b/cashu/wallet/migrations.py @@ -0,0 +1,81 @@ +from cashu.core.db import Database + + +async def m000_create_migrations_table(db): + await db.execute( + """ + CREATE TABLE IF NOT EXISTS dbversions ( + db TEXT PRIMARY KEY, + version INT NOT NULL + ) + """ + ) + + +async def m001_initial(db: Database): + await db.execute( + """ + CREATE TABLE IF NOT EXISTS proofs ( + amount INTEGER NOT NULL, + C TEXT NOT NULL, + secret TEXT NOT NULL, + + UNIQUE (secret) + + ); + """ + ) + + await db.execute( + """ + CREATE TABLE IF NOT EXISTS proofs_used ( + amount INTEGER NOT NULL, + C TEXT NOT NULL, + secret TEXT NOT NULL, + + UNIQUE (secret) + + ); + """ + ) + + await db.execute( + """ + CREATE VIEW IF NOT EXISTS balance AS + SELECT COALESCE(SUM(s), 0) AS balance FROM ( + SELECT SUM(amount) AS s + FROM proofs + WHERE amount > 0 + ); + """ + ) + + await db.execute( + """ + CREATE VIEW IF NOT EXISTS balance_used AS + SELECT COALESCE(SUM(s), 0) AS used FROM ( + SELECT SUM(amount) AS s + FROM proofs_used + WHERE amount > 0 + ); + """ + ) + + +async def m002_add_proofs_reserved(db): + """ + Column for marking proofs as reserved when they are being sent. + """ + + await db.execute("ALTER TABLE proofs ADD COLUMN reserved BOOL") + + +async def m003_add_proofs_sendid_and_timestamps(db): + """ + Column with unique ID for each initiated send attempt + so proofs can be later grouped together for each send attempt. + """ + await db.execute("ALTER TABLE proofs ADD COLUMN send_id TEXT") + 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") diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py new file mode 100644 index 0000000..27d1ba6 --- /dev/null +++ b/cashu/wallet/wallet.py @@ -0,0 +1,256 @@ +import base64 +import json +import secrets as scrts +import uuid +from typing import List + +import requests + +import cashu.core.b_dhke as b_dhke +from cashu.core.base import ( + BlindedMessage, + BlindedSignature, + CheckPayload, + MeltPayload, + MintPayloads, + Proof, + SplitPayload, +) +from cashu.core.db import Database +from cashu.core.secp import PublicKey +from cashu.core.settings import DEBUG +from cashu.core.split import amount_split +from cashu.wallet.crud import ( + get_proofs, + invalidate_proof, + store_proof, + update_proof_reserved, +) + + +class LedgerAPI: + def __init__(self, url): + self.url = url + self.keys = self._get_keys(url) + + @staticmethod + def _get_keys(url): + resp = requests.get(url + "/keys").json() + return { + int(amt): PublicKey(bytes.fromhex(val), raw=True) + for amt, val in resp.items() + } + + @staticmethod + def _get_output_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 + + def _construct_proofs(self, promises: List[BlindedSignature], secrets: List[str]): + """Returns proofs of promise from promises.""" + proofs = [] + for promise, (r, secret) in zip(promises, secrets): + 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): + """Returns base64 encoded random string.""" + return scrts.token_urlsafe(randombits // 8) + + 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.""" + payloads: MintPayloads = MintPayloads() + secrets = [] + rs = [] + for amount in amounts: + secret = self._generate_secret() + secrets.append(secret) + 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( + self.url + "/mint", + json=payloads.dict(), + params={"payment_hash": payment_hash}, + ).json() + 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.""" + 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) + split_payload = SplitPayload(proofs=proofs, amount=amount, output_data=payloads) + promises_dict = requests.post( + self.url + "/split", + json=split_payload.dict(), + ).json() + 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) :]) + + return fst_proofs, snd_proofs + + async def check_spendable(self, proofs: List[Proof]): + payload = CheckPayload(proofs=proofs) + return_dict = requests.post( + self.url + "/check", + json=payload.dict(), + ).json() + + return return_dict + + async def pay_lightning(self, proofs: List[Proof], amount: int, invoice: str): + payload = MeltPayload(proofs=proofs, amount=amount, invoice=invoice) + return_dict = requests.post( + self.url + "/melt", + json=payload.dict(), + ).json() + return return_dict + + +class Wallet(LedgerAPI): + """Minimal wallet wrapper.""" + + def __init__(self, url: str, db: str, name: str = "no_name"): + super().__init__(url) + self.db = Database("wallet", db) + self.proofs: List[Proof] = [] + self.name = name + + async def load_proofs(self): + self.proofs = await get_proofs(db=self.db) + + async def _store_proofs(self, proofs): + for proof in proofs: + await store_proof(proof, db=self.db) + + async def request_mint(self, amount): + return super().request_mint(amount) + + async def mint(self, amount: int, payment_hash: str = None): + split = amount_split(amount) + proofs = 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]): + return await self.split(proofs, sum(p["amount"] for p in proofs)) + + async def split(self, proofs: List[Proof], amount: int): + assert len(proofs) > 0, ValueError("no proofs provided.") + fst_proofs, snd_proofs = super().split(proofs, amount) + if len(fst_proofs) == 0 and len(snd_proofs) == 0: + raise Exception("received no splits.") + used_secrets = [p["secret"] for p in proofs] + self.proofs = list( + filter(lambda p: p["secret"] not in used_secrets, self.proofs) + ) + self.proofs += fst_proofs + snd_proofs + await self._store_proofs(fst_proofs + snd_proofs) + for proof in proofs: + await invalidate_proof(proof, db=self.db) + return fst_proofs, snd_proofs + + async def pay_lightning(self, proofs: List[Proof], amount: int, invoice: str): + """Pays a lightning invoice""" + status = await super().pay_lightning(proofs, amount, invoice) + if status["paid"] == True: + await self.invalidate(proofs) + else: + raise Exception("could not pay invoice.") + return status["paid"] + + @staticmethod + async def serialize_proofs(proofs: List[Proof]): + 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): + """Like self.split but only considers non-reserved tokens.""" + 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) + + async def set_reserved(self, proofs: List[Proof], reserved: bool): + """Mark a proof as reserved to avoid reuse or delete marking.""" + uuid_str = str(uuid.uuid1()) + for proof in proofs: + proof.reserved = True + await update_proof_reserved( + proof, reserved=reserved, send_id=uuid_str, db=self.db + ) + + async def check_spendable(self, proofs): + return await super().check_spendable(proofs) + + async def invalidate(self, proofs): + """Invalidates all spendable tokens supplied in proofs.""" + spendables = await self.check_spendable(proofs) + invalidated_proofs = [] + for idx, spendable in spendables.items(): + if not spendable: + invalidated_proofs.append(proofs[int(idx)]) + await invalidate_proof(proofs[int(idx)], db=self.db) + invalidate_secrets = [p["secret"] for p in invalidated_proofs] + self.proofs = list( + filter(lambda p: p["secret"] not in invalidate_secrets, self.proofs) + ) + + @property + def balance(self): + return sum(p["amount"] for p in self.proofs) + + @property + def available_balance(self): + return sum(p["amount"] for p in self.proofs if not p.reserved) + + 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)" + ) + + def proof_amounts(self): + return [p["amount"] for p in sorted(self.proofs, key=lambda p: p["amount"])] diff --git a/cashu/wallet/wallet_live/.DS_Store b/cashu/wallet/wallet_live/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..5008ddfcf53c02e82d7eee2e57c38e5672ef89f6 GIT binary patch literal 6148 zcmeH~Jr2S!425mzP>H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T0;FffCTTRJ1}C9*T*r!vwf5R;i$F5R zwVebgit6NoN>EX80TLI8ULYz&kdU|lsp6(cNRdG5MG+TB2&ssR3UL9diYg?YwP)f? z(xlxeVW`qQ$1^@Nv(8@Yd)NDY>zwa>&z$@8ndh&SX*av_V!Afflba@UbCXA>)5&D= zDxb@IzV&g4I|ttShX3dG+<(8juTDOG>G=A_>B-W{8#pv6J@EM1 zt;e3(nm+T`C(mq6cYZc~U}gHQkM!c~rR{6e(@#CK^~BcM=~GWXGkxm(nKKW6-@wPG z&ul%*FW+~-)snB2YwsF0y;820D=(LJ`pKuC=BnR!+4E1G{`C2+=>t2DKK0H4r>;M{ zw0Pvc`<7mN<>lwgD@}SKZRc|7rK_dgdw)05&z#=6Fx~s}>0{?^%y(|<%+}-2Odo&x zu`^re9^ZQ4-1#RTxO(d0sW`=`X}#j~r_Vn9W!;bF#Ct?;M=e4 z9lyNrsjahH(_N51F%>s{dFuKTi;G8&A76Uy`8}cSuKqm&8Xn8;H9mxJZY;Kj`2!;P z?7_vOCr{43KDUR2JrsLTIrJWM?8m$d@jBK2F(=>2u{z-oCZRoa=;-N_bCc({+w#j- zKlcJJ4=<&cuFd-IFYSr(lHBvn;^ucJ->A9y+ADit*%|Pok(>Wp93DDw=;$NIeh3b` z5FGr~xy7R=PRw0@qz}GrUM^RzK0n*O`u4r~cQE4ZpYI_=+w7E+pF4Z{$;ZxKoc`?A z#kUL7?iFk4x>1#S#-7sihmJmR;{6u-_Dq-L+xHi!r%Sb_uJbv!`JWU1jUOWdBLX7= zBLX7=BLX7=BLX7=BLX7=BLX7=BLW{G1Xku2j?KN2UU;Egn_nTWxy|oP_&0ux2#g4f z2#g4f2#g4f2#g4f2#g4f2#g4f2#g4Pq!3tMIJUgg09ePDzp(t~WNm)srbalY>u+ko$ONS+8lInLFq;c({xSBt(Vr9?7g$H^GYkL zoi#FA5u=pWNmgbwk5XO*rF38oLNevj^}`o=8=p|B6s2t|m3Ap=m5YqJ(M@WS?P5n{ zv3TK9(-~1hXHB-rb+T#NV0>qp+8FP>4nYfI3d%@V;{yVtjfz?-8@!rK7JjD&Ir)N6 zHa2Z%Ti_C|Q9ve|%pe^~>x>P5Q=Y6-K1z(%H%T^KX^4%jDurlF(OHO;I-{~mEmxCDwI;XLHf`Io z^F=kHXxTL~5STSyNnu^|B4|y(!0ow3_l+tS8jGIUhA2}0OZfi+B?rPadP zD1??iXyc)1Abf9$))=e33(zXgPSzPOPUhik>r$s&RvqX{4*?x(iZ-xyF{#cuQCw_{ z%vNWqV}@J&Ub;p)FW5C9K~V_FUIxWv3VfMLM&W{i*DOVuwQFxArP@=2)%jyq*y zmm2^zY)%UpR%%%S7i=oxLc}&|?PZs9v9|NkkkMI34;GGZKoHuxz&4Vis_te@A+9W# zaPqBPOf$^a3epFmv+l^y+$tHMQiJl}EnSYVv2`SR<4x&0!c49qPV1Z{6tAp; zt17}rX71~Ov600@=BzW;(la?Abg-(0eBe`()18-%U|(7oX#@M}9o!&FQ4Bm`^1u+` zNC_g7(KnEpDqsu~0cwaWDR?;Q`J$||)(7^|UB0{LO<(NV)IuB|f`A&Pix58mUliHG z4-bz7$v}2+Eh6Y6W~ zwCO}$Gq_TOVx+7@S}wu%lCVl7=Aslvc7n8ls^n;-`VlgO zm1K!kLK$YfW4G4n*cCRSCSaqI=&~%%$Hb;erjTvv$)rL+buh@mr8Q#6IVrtSvGc34gc z?PI1gq*PdB2QUqpBjLSPMp3un40V8`gY6;vm5|KNZjx$3*TUm=xvD~|1tp4dLRnJe zfrf^S_RvOm99W97o+z`y!=YSP?xd&Y2}*(Ml9W^q0=1TMU@N?wC7yka4CIH>kl9M3 z9RGEl@g~+!F(Vy(ww;x};V^JoBu)O8)HLKA2bYJRiG$JU;w{G|WHYMkS|6;Ij+}t6 z)HTivNCHZr zV_ClI;Ax2^YN|~zFq>)!cgy^DP*F?fv;s< zoL7_2^{F9rau(u|4pJQ?NSH19}2+%Esr{|MtCao8R2It$lm#+k2lTjrT_c zMg&F#Mg&F#Mg&F#Mg&F#Mg&F#J|F@cKQnoFVB7R4F9^Rju-|zbW4}DCQHjbqR|a+% zZ)Ggqn?b$7TNt~pgyS{W!vni|_dT@m+}yx^+`SJjT$~%$NjtgmN0Zlw^}z07=(7X6 zS0@E`}})Yp&c0r~hGKTjV%n|72i8;||7t zVOT5T7(>qwY9idu$cKi#{6Bh-v2&Z>d=!1|MB229{8^VzkcB8!dDl<{NK!fX6|3+uFWk> ze!E`ggMQ3T9;|)r{cC{E?m0M5&jsmapSqE=6a9n;FZ2m8l`b6nyhZ{s>w8Q98%#Z<*j!-#~TF|l; zDojkA!#4zv7?k!<*T$sTI>NiD#VRmNo-s|A(4%;XucwchW0(Nq>?+N$u6`3 zt=jY41KA4218tSLMGzi=#vsqJa{BgsncdP)NZxlg6!6F$a#f3_ilmTJK|`+V^4g-L zWYm?2#$qrnw73tP;}3^5$!HXjgl=8sBju5exVA5j{KsU&bv9Y%JoIQdly9gvzE#qHmQG@^<=mSC=|F8o%tE&bX zJg^$gX7H3*u!*z7{e+M1o7dvjx z1uCLNit3~&4Yh_!M6qZ|7phLifkEC-ST&Umy8xOeTqJ#9Yn_fNio9w}^|_$SBEM31 zoSZH76H?nK$E;UTLUT+qW8-MC<^MF_u>0&=2&uTn=34>D$36-Fb|1;_<-Uf*bABsns7Hvr( zGy=nnFr-MpuoSz7-^he%fVfy59wXA1`}M#Ol5dEXP&Le@7`p94bwW4;Nnv5mouPza z>cK4_65a+YGC7@-Ncf(pI+TWtm!VBq1*F2})JVyhh`bGi?3Avj1iVE1R~(4=Vqh(* zIaPsEKx`lE=i{|gg|lU;xsnd~7vDI1p+*Vf!ep9K|ewX9A$h3-zUw_5J_% zFMn~e@rNsavGI+y-(COC@)wu?Y2{mMw`}~%=DD>;*1o>>OKbnW`OS?dSO0bW`1c%~*Ut297v;#0cenenIU_@X< zU_@XzgkgHzJ^gA2d8SYFR~=+_ z-VqOn*_7|@u^nbG{#d`-LDt~APAy!R8>GkGSuZ+Fk2~IP!mu~++|gfl&^vFA)yobP zv~Ta>8YXBT?XNqmS9@DOc37MBNWX?*9nxEOt~;o?dCSh&L4C=a`?15`zIC{c9oP)K zscze_Hr{5xgkcT1jeZHkdS~l(?7+6yTFK2+OWpTp*nO}Pvl@7I;@>>pa*PNCt_h|1w)z$^A9dOxBSCj|7(huuYdplB*OjX zZ*AVa@u#EyzaQ^L8ukAV{jEr&{{P{=4{6l@KlFD$kNW?p|Bw3rsQ-`p|ET|u`v0i^ zkNW?p|Np=0|2JO$FKoQA^ZNgdABRr+7~_b*h`@-zh`@-zh`@-zh`@-zh`@-zh`^6L k0+;Xa-`2E`e^hn*zTW@8Z+~!fc5m Date: Wed, 28 Sep 2022 17:44:43 +0200 Subject: [PATCH 05/11] clean --- README.md | 17 +- core/__init__.py | 0 core/b_dhke.py | 93 ----------- core/base.py | 108 ------------ core/bolt11.py | 370 ------------------------------------------ core/db.py | 182 --------------------- core/helpers.py | 35 ---- core/migrations.py | 51 ------ core/secp.py | 52 ------ core/settings.py | 34 ---- core/split.py | 8 - lightning/__init__.py | 3 - lightning/base.py | 88 ---------- lightning/lnbits.py | 149 ----------------- mint/__init__.py | 6 - mint/__main__.py | 6 - mint/api.py | 0 mint/app.py | 86 ---------- mint/crud.py | 110 ------------- mint/ledger.py | 258 ----------------------------- mint/main.py | 52 ------ mint/migrations.py | 87 ---------- mint/router.py | 86 ---------- poetry.lock | 14 +- pyproject.toml | 4 +- setup.py | 2 +- wallet/__init__.py | 3 - wallet/cashu.py | 212 ------------------------ wallet/crud.py | 99 ----------- wallet/migrations.py | 81 --------- wallet/wallet.py | 252 ---------------------------- 31 files changed, 24 insertions(+), 2524 deletions(-) delete mode 100644 core/__init__.py delete mode 100644 core/b_dhke.py delete mode 100644 core/base.py delete mode 100644 core/bolt11.py delete mode 100644 core/db.py delete mode 100644 core/helpers.py delete mode 100644 core/migrations.py delete mode 100644 core/secp.py delete mode 100644 core/settings.py delete mode 100644 core/split.py delete mode 100644 lightning/__init__.py delete mode 100644 lightning/base.py delete mode 100644 lightning/lnbits.py delete mode 100644 mint/__init__.py delete mode 100644 mint/__main__.py delete mode 100644 mint/api.py delete mode 100644 mint/app.py delete mode 100644 mint/crud.py delete mode 100644 mint/ledger.py delete mode 100644 mint/main.py delete mode 100644 mint/migrations.py delete mode 100644 mint/router.py delete mode 100644 wallet/__init__.py delete mode 100755 wallet/cashu.py delete mode 100644 wallet/crud.py delete mode 100644 wallet/migrations.py delete mode 100644 wallet/wallet.py diff --git a/README.md b/README.md index 2d49e35..bf9d857 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,17 @@ Cashu is an Ecash implementation based on David Wagner's variant of Chaumian blinding. Token logic based on [minicash](https://github.com/phyro/minicash) ([description](https://gist.github.com/phyro/935badc682057f418842c72961cf096c)) which implements a [Blind Diffie-Hellman Key Exchange](https://cypherpunks.venona.com/date/1996/03/msg01848.html) scheme written down by Ruben Somsen [here](https://gist.github.com/RubenSomsen/be7a4760dd4596d06963d67baf140406). The database mechanics and the Lightning backend uses parts from [LNbits](https://github.com/lnbits/lnbits-legend). +

+Quick links: +Cashu client protocol · +Quick Install · +Manual install · +Configuration · +Using Cashu · +Run a mint +

+

+ ## Cashu client protocol There are ongoing efforts to implement alternative Cashu clients that use the same protocol such as a [Cashu Javascript wallet](https://github.com/motorina0/cashu-js-wallet). If you are interested in helping with Cashu development, please see the [docs](docs/) for the notation and conventions used. @@ -20,8 +31,8 @@ To update Cashu, use `pip install cashu -U`. If you have problems running the co You can skip the entire next section about Poetry and jump right to [Using Cashu](#using-cashu). -### Hard install: Poetry -These steps help you install Python via pyenv and Poetry. If you already have Poetry running on your computer, you can skip this step and jump right to [Install Cashu](#install-cashu). +## Hard install: Poetry +These steps help you install Python via pyenv and Poetry. If you already have Poetry running on your computer, you can skip this step and jump right to [Install Cashu](#poetry-install-cashu). #### Poetry: Prerequisites @@ -168,7 +179,7 @@ Balance: 351 sat (Available: 351 sat in 7 tokens) Balance: 339 sat (Available: 339 sat in 8 tokens) ``` -## Run a mint yourself +# Running a mint This command runs the mint on your local computer. Skip this step if you want to use the [public test mint](#test-instance) instead. ```bash mint diff --git a/core/__init__.py b/core/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/core/b_dhke.py b/core/b_dhke.py deleted file mode 100644 index be9a141..0000000 --- a/core/b_dhke.py +++ /dev/null @@ -1,93 +0,0 @@ -# Don't trust me with cryptography. - -""" -Implementation of https://gist.github.com/RubenSomsen/be7a4760dd4596d06963d67baf140406 -Alice: -A = a*G -return A -Bob: -Y = hash_to_point(secret_message) -r = random blinding factor -B'= Y + r*G -return B' -Alice: -C' = a*B' - (= a*Y + a*r*G) -return C' -Bob: -C = C' - r*A - (= C' - a*r*G) - (= a*Y) -return C, secret_message -Alice: -Y = hash_to_point(secret_message) -C == a*Y -If true, C must have originated from Alice -""" - -import hashlib - -from secp256k1 import PrivateKey, PublicKey - - -def hash_to_point(secret_msg): - """Generates x coordinate from the message hash and checks if the point lies on the curve. - If it does not, it tries computing again a new x coordinate from the hash of the coordinate.""" - point = None - msg = secret_msg - while point is None: - _hash = hashlib.sha256(msg).hexdigest().encode("utf-8") - try: - # We construct compressed pub which has x coordinate encoded with even y - _hash = list(_hash[:33]) # take the 33 bytes and get a list of bytes - _hash[0] = 0x02 # set first byte to represent even y coord - _hash = bytes(_hash) - point = PublicKey(_hash, raw=True) - except: - msg = _hash - - return point - - -def step1_alice(secret_msg): - secret_msg = secret_msg.encode("utf-8") - Y = hash_to_point(secret_msg) - r = PrivateKey() - B_ = Y + r.pubkey - return B_, r - - -def step2_bob(B_, a): - C_ = B_.mult(a) - return C_ - - -def step3_alice(C_, r, A): - C = C_ - A.mult(r) - return C - - -def verify(a, C, secret_msg): - Y = hash_to_point(secret_msg.encode("utf-8")) - return C == Y.mult(a) - - -### Below is a test of a simple positive and negative case - -# # Alice's keys -# a = PrivateKey() -# A = a.pubkey -# secret_msg = "test" -# B_, r = step1_alice(secret_msg) -# C_ = step2_bob(B_, a) -# C = step3_alice(C_, r, A) -# print("C:{}, secret_msg:{}".format(C, secret_msg)) -# assert verify(a, C, secret_msg) -# assert verify(a, C + C, secret_msg) == False # adding C twice shouldn't pass -# assert verify(a, A, secret_msg) == False # A shouldn't pass - -# # Test operations -# b = PrivateKey() -# B = b.pubkey -# assert -A -A + A == -A # neg -# assert B.mult(a) == A.mult(b) # a*B = A*b diff --git a/core/base.py b/core/base.py deleted file mode 100644 index a78f7ee..0000000 --- a/core/base.py +++ /dev/null @@ -1,108 +0,0 @@ -from sqlite3 import Row -from typing import List - -from pydantic import BaseModel - - -class Proof(BaseModel): - amount: int - secret: str - C: str - reserved: bool = False # whether this proof is reserved for sending - send_id: str = "" # unique ID of send attempt - time_created: str = "" - time_reserved: str = "" - - @classmethod - def from_row(cls, row: Row): - return cls( - amount=row[0], - C=row[1], - secret=row[2], - reserved=row[3] or False, - send_id=row[4] or "", - time_created=row[5] or "", - time_reserved=row[6] or "", - ) - - @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"), - reserved=d.get("reserved") or False, - send_id=d.get("send_id") or "", - time_created=d.get("time_created") or "", - time_reserved=d.get("time_reserved") or "", - ) - - def to_dict(self): - return dict(amount=self.amount, secret=self.secret, C=self.C) - - def __getitem__(self, key): - return self.__getattribute__(key) - - def __setitem__(self, key, val): - self.__setattr__(key, val) - - -class Proofs(BaseModel): - """TODO: Use this model""" - - proofs: List[Proof] - - -class Invoice(BaseModel): - amount: int - pr: str - hash: str - issued: bool = False - - @classmethod - def from_row(cls, row: Row): - return cls( - amount=int(row[0]), - pr=str(row[1]), - hash=str(row[2]), - issued=bool(row[3]), - ) - - -class BlindedMessage(BaseModel): - amount: int - B_: str - - -class BlindedSignature(BaseModel): - amount: int - C_: str - - @classmethod - def from_dict(cls, d: dict): - return cls( - amount=d["amount"], - C_=d["C_"], - ) - - -class MintPayloads(BaseModel): - blinded_messages: List[BlindedMessage] = [] - - -class SplitPayload(BaseModel): - proofs: List[Proof] - amount: int - output_data: MintPayloads - - -class CheckPayload(BaseModel): - proofs: List[Proof] - - -class MeltPayload(BaseModel): - proofs: List[Proof] - amount: int - invoice: str diff --git a/core/bolt11.py b/core/bolt11.py deleted file mode 100644 index 962581d..0000000 --- a/core/bolt11.py +++ /dev/null @@ -1,370 +0,0 @@ -import hashlib -import re -import time -from binascii import unhexlify -from decimal import Decimal -from typing import List, NamedTuple, Optional - -import bitstring # type: ignore -import secp256k1 -from bech32 import CHARSET, bech32_decode, bech32_encode -from ecdsa import SECP256k1, VerifyingKey # type: ignore -from ecdsa.util import sigdecode_string # type: ignore - - -class Route(NamedTuple): - pubkey: str - short_channel_id: str - base_fee_msat: int - ppm_fee: int - cltv: int - - -class Invoice(object): - payment_hash: str - amount_msat: int = 0 - description: Optional[str] = None - description_hash: Optional[str] = None - payee: Optional[str] = None - date: int - expiry: int = 3600 - secret: Optional[str] = None - route_hints: List[Route] = [] - min_final_cltv_expiry: int = 18 - - -def decode(pr: str) -> Invoice: - """bolt11 decoder, - based on https://github.com/rustyrussell/lightning-payencode/blob/master/lnaddr.py - """ - - hrp, decoded_data = bech32_decode(pr) - if hrp is None or decoded_data is None: - raise ValueError("Bad bech32 checksum") - if not hrp.startswith("ln"): - raise ValueError("Does not start with ln") - - bitarray = _u5_to_bitarray(decoded_data) - - # final signature 65 bytes, split it off. - if len(bitarray) < 65 * 8: - raise ValueError("Too short to contain signature") - - # extract the signature - signature = bitarray[-65 * 8 :].tobytes() - - # the tagged fields as a bitstream - data = bitstring.ConstBitStream(bitarray[: -65 * 8]) - - # build the invoice object - invoice = Invoice() - - # decode the amount from the hrp - m = re.search(r"[^\d]+", hrp[2:]) - if m: - amountstr = hrp[2 + m.end() :] - if amountstr != "": - invoice.amount_msat = _unshorten_amount(amountstr) - - # pull out date - invoice.date = data.read(35).uint - - while data.pos != data.len: - tag, tagdata, data = _pull_tagged(data) - data_length = len(tagdata) / 5 - - if tag == "d": - invoice.description = _trim_to_bytes(tagdata).decode("utf-8") - elif tag == "h" and data_length == 52: - invoice.description_hash = _trim_to_bytes(tagdata).hex() - elif tag == "p" and data_length == 52: - invoice.payment_hash = _trim_to_bytes(tagdata).hex() - elif tag == "x": - invoice.expiry = tagdata.uint - elif tag == "n": - invoice.payee = _trim_to_bytes(tagdata).hex() - # this won't work in most cases, we must extract the payee - # from the signature - elif tag == "s": - invoice.secret = _trim_to_bytes(tagdata).hex() - elif tag == "r": - s = bitstring.ConstBitStream(tagdata) - while s.pos + 264 + 64 + 32 + 32 + 16 < s.len: - route = Route( - pubkey=s.read(264).tobytes().hex(), - short_channel_id=_readable_scid(s.read(64).intbe), - base_fee_msat=s.read(32).intbe, - ppm_fee=s.read(32).intbe, - cltv=s.read(16).intbe, - ) - invoice.route_hints.append(route) - - # BOLT #11: - # A reader MUST check that the `signature` is valid (see the `n` tagged - # field specified below). - # A reader MUST use the `n` field to validate the signature instead of - # performing signature recovery if a valid `n` field is provided. - message = bytearray([ord(c) for c in hrp]) + data.tobytes() - sig = signature[0:64] - if invoice.payee: - key = VerifyingKey.from_string(unhexlify(invoice.payee), curve=SECP256k1) - key.verify(sig, message, hashlib.sha256, sigdecode=sigdecode_string) - else: - keys = VerifyingKey.from_public_key_recovery( - sig, message, SECP256k1, hashlib.sha256 - ) - signaling_byte = signature[64] - key = keys[int(signaling_byte)] - invoice.payee = key.to_string("compressed").hex() - - return invoice - - -def encode(options): - """Convert options into LnAddr and pass it to the encoder""" - addr = LnAddr() - addr.currency = options["currency"] - addr.fallback = options["fallback"] if options["fallback"] else None - if options["amount"]: - addr.amount = options["amount"] - if options["timestamp"]: - addr.date = int(options["timestamp"]) - - addr.paymenthash = unhexlify(options["paymenthash"]) - - if options["description"]: - addr.tags.append(("d", options["description"])) - if options["description_hash"]: - addr.tags.append(("h", options["description_hash"])) - if options["expires"]: - addr.tags.append(("x", options["expires"])) - - if options["fallback"]: - addr.tags.append(("f", options["fallback"])) - if options["route"]: - for r in options["route"]: - splits = r.split("/") - route = [] - while len(splits) >= 5: - route.append( - ( - unhexlify(splits[0]), - unhexlify(splits[1]), - int(splits[2]), - int(splits[3]), - int(splits[4]), - ) - ) - splits = splits[5:] - assert len(splits) == 0 - addr.tags.append(("r", route)) - return lnencode(addr, options["privkey"]) - - -def lnencode(addr, privkey): - if addr.amount: - amount = Decimal(str(addr.amount)) - # We can only send down to millisatoshi. - if amount * 10**12 % 10: - raise ValueError( - "Cannot encode {}: too many decimal places".format(addr.amount) - ) - - amount = addr.currency + shorten_amount(amount) - else: - amount = addr.currency if addr.currency else "" - - hrp = "ln" + amount + "0n" - - # Start with the timestamp - data = bitstring.pack("uint:35", addr.date) - - # Payment hash - data += tagged_bytes("p", addr.paymenthash) - tags_set = set() - - for k, v in addr.tags: - - # BOLT #11: - # - # A writer MUST NOT include more than one `d`, `h`, `n` or `x` fields, - if k in ("d", "h", "n", "x"): - if k in tags_set: - raise ValueError("Duplicate '{}' tag".format(k)) - - if k == "r": - route = bitstring.BitArray() - for step in v: - pubkey, channel, feebase, feerate, cltv = step - route.append( - bitstring.BitArray(pubkey) - + bitstring.BitArray(channel) - + bitstring.pack("intbe:32", feebase) - + bitstring.pack("intbe:32", feerate) - + bitstring.pack("intbe:16", cltv) - ) - data += tagged("r", route) - elif k == "f": - data += encode_fallback(v, addr.currency) - elif k == "d": - data += tagged_bytes("d", v.encode()) - elif k == "x": - # Get minimal length by trimming leading 5 bits at a time. - expirybits = bitstring.pack("intbe:64", v)[4:64] - while expirybits.startswith("0b00000"): - expirybits = expirybits[5:] - data += tagged("x", expirybits) - elif k == "h": - data += tagged_bytes("h", v) - elif k == "n": - data += tagged_bytes("n", v) - else: - # FIXME: Support unknown tags? - raise ValueError("Unknown tag {}".format(k)) - - tags_set.add(k) - - # BOLT #11: - # - # A writer MUST include either a `d` or `h` field, and MUST NOT include - # both. - if "d" in tags_set and "h" in tags_set: - raise ValueError("Cannot include both 'd' and 'h'") - if not "d" in tags_set and not "h" in tags_set: - raise ValueError("Must include either 'd' or 'h'") - - # We actually sign the hrp, then data (padded to 8 bits with zeroes). - privkey = secp256k1.PrivateKey(bytes(unhexlify(privkey))) - sig = privkey.ecdsa_sign_recoverable( - bytearray([ord(c) for c in hrp]) + data.tobytes() - ) - # This doesn't actually serialize, but returns a pair of values :( - sig, recid = privkey.ecdsa_recoverable_serialize(sig) - data += bytes(sig) + bytes([recid]) - - return bech32_encode(hrp, bitarray_to_u5(data)) - - -class LnAddr(object): - def __init__( - self, paymenthash=None, amount=None, currency="bc", tags=None, date=None - ): - self.date = int(time.time()) if not date else int(date) - self.tags = [] if not tags else tags - self.unknown_tags = [] - self.paymenthash = paymenthash - self.signature = None - self.pubkey = None - self.currency = currency - self.amount = amount - - def __str__(self): - return "LnAddr[{}, amount={}{} tags=[{}]]".format( - hexlify(self.pubkey.serialize()).decode("utf-8"), - self.amount, - self.currency, - ", ".join([k + "=" + str(v) for k, v in self.tags]), - ) - - -def shorten_amount(amount): - """Given an amount in bitcoin, shorten it""" - # Convert to pico initially - amount = int(amount * 10**12) - units = ["p", "n", "u", "m", ""] - for unit in units: - if amount % 1000 == 0: - amount //= 1000 - else: - break - return str(amount) + unit - - -def _unshorten_amount(amount: str) -> int: - """Given a shortened amount, return millisatoshis""" - # BOLT #11: - # The following `multiplier` letters are defined: - # - # * `m` (milli): multiply by 0.001 - # * `u` (micro): multiply by 0.000001 - # * `n` (nano): multiply by 0.000000001 - # * `p` (pico): multiply by 0.000000000001 - units = {"p": 10**12, "n": 10**9, "u": 10**6, "m": 10**3} - unit = str(amount)[-1] - - # BOLT #11: - # A reader SHOULD fail if `amount` contains a non-digit, or is followed by - # anything except a `multiplier` in the table above. - if not re.fullmatch(r"\d+[pnum]?", str(amount)): - raise ValueError("Invalid amount '{}'".format(amount)) - - if unit in units: - return int(int(amount[:-1]) * 100_000_000_000 / units[unit]) - else: - return int(amount) * 100_000_000_000 - - -def _pull_tagged(stream): - tag = stream.read(5).uint - length = stream.read(5).uint * 32 + stream.read(5).uint - return (CHARSET[tag], stream.read(length * 5), stream) - - -def is_p2pkh(currency, prefix): - return prefix == base58_prefix_map[currency][0] - - -def is_p2sh(currency, prefix): - return prefix == base58_prefix_map[currency][1] - - -# Tagged field containing BitArray -def tagged(char, l): - # Tagged fields need to be zero-padded to 5 bits. - while l.len % 5 != 0: - l.append("0b0") - return ( - bitstring.pack( - "uint:5, uint:5, uint:5", - CHARSET.find(char), - (l.len / 5) / 32, - (l.len / 5) % 32, - ) - + l - ) - - -def tagged_bytes(char, l): - return tagged(char, bitstring.BitArray(l)) - - -def _trim_to_bytes(barr): - # Adds a byte if necessary. - b = barr.tobytes() - if barr.len % 8 != 0: - return b[:-1] - return b - - -def _readable_scid(short_channel_id: int) -> str: - return "{blockheight}x{transactionindex}x{outputindex}".format( - blockheight=((short_channel_id >> 40) & 0xFFFFFF), - transactionindex=((short_channel_id >> 16) & 0xFFFFFF), - outputindex=(short_channel_id & 0xFFFF), - ) - - -def _u5_to_bitarray(arr: List[int]) -> bitstring.BitArray: - ret = bitstring.BitArray() - for a in arr: - ret += bitstring.pack("uint:5", a) - return ret - - -def bitarray_to_u5(barr): - assert barr.len % 5 == 0 - ret = [] - s = bitstring.ConstBitStream(barr) - while s.pos != s.len: - ret.append(s.read(5).uint) - return ret diff --git a/core/db.py b/core/db.py deleted file mode 100644 index e393aa1..0000000 --- a/core/db.py +++ /dev/null @@ -1,182 +0,0 @@ -import asyncio -import datetime -import os -import time -from contextlib import asynccontextmanager -from typing import Optional - -from sqlalchemy import create_engine -from sqlalchemy_aio.base import AsyncConnection -from sqlalchemy_aio.strategy import ASYNCIO_STRATEGY # type: ignore - -POSTGRES = "POSTGRES" -COCKROACH = "COCKROACH" -SQLITE = "SQLITE" - - -class Compat: - type: Optional[str] = "" - schema: Optional[str] = "" - - def interval_seconds(self, seconds: int) -> str: - if self.type in {POSTGRES, COCKROACH}: - return f"interval '{seconds} seconds'" - elif self.type == SQLITE: - return f"{seconds}" - return "" - - @property - def timestamp_now(self) -> str: - if self.type in {POSTGRES, COCKROACH}: - return "now()" - elif self.type == SQLITE: - return "(strftime('%s', 'now'))" - return "" - - @property - def serial_primary_key(self) -> str: - if self.type in {POSTGRES, COCKROACH}: - return "SERIAL PRIMARY KEY" - elif self.type == SQLITE: - return "INTEGER PRIMARY KEY AUTOINCREMENT" - return "" - - @property - def references_schema(self) -> str: - if self.type in {POSTGRES, COCKROACH}: - return f"{self.schema}." - elif self.type == SQLITE: - return "" - return "" - - -class Connection(Compat): - def __init__(self, conn: AsyncConnection, txn, typ, name, schema): - self.conn = conn - self.txn = txn - self.type = typ - self.name = name - self.schema = schema - - def rewrite_query(self, query) -> str: - if self.type in {POSTGRES, COCKROACH}: - query = query.replace("%", "%%") - query = query.replace("?", "%s") - return query - - async def fetchall(self, query: str, values: tuple = ()) -> list: - result = await self.conn.execute(self.rewrite_query(query), values) - return await result.fetchall() - - async def fetchone(self, query: str, values: tuple = ()): - result = await self.conn.execute(self.rewrite_query(query), values) - row = await result.fetchone() - await result.close() - return row - - async def execute(self, query: str, values: tuple = ()): - return await self.conn.execute(self.rewrite_query(query), values) - - -class Database(Compat): - def __init__(self, db_name: str, db_location: str): - 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 - - if database_uri.startswith("cockroachdb://"): - self.type = COCKROACH - else: - self.type = POSTGRES - - 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()) - - 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 - ) - ) - else: - if not os.path.exists(self.db_location): - print(f"Creating database directory: {self.db_location}") - os.makedirs(self.db_location) - self.path = os.path.join(self.db_location, f"{self.name}.sqlite3") - database_uri = f"sqlite:///{self.path}" - self.type = SQLITE - - self.schema = self.name - if self.name.startswith("ext_"): - self.schema = self.name[4:] - else: - self.schema = None - - self.engine = create_engine(database_uri, strategy=ASYNCIO_STRATEGY) - self.lock = asyncio.Lock() - - @asynccontextmanager - async def connect(self): - await self.lock.acquire() - try: - async with self.engine.connect() as conn: - async with conn.begin() as txn: - wconn = Connection(conn, txn, self.type, self.name, self.schema) - - if self.schema: - if self.type in {POSTGRES, COCKROACH}: - await wconn.execute( - f"CREATE SCHEMA IF NOT EXISTS {self.schema}" - ) - elif self.type == SQLITE: - await wconn.execute( - f"ATTACH '{self.path}' AS {self.schema}" - ) - - yield wconn - finally: - self.lock.release() - - async def fetchall(self, query: str, values: tuple = ()) -> list: - async with self.connect() as conn: - result = await conn.execute(query, values) - return await result.fetchall() - - async def fetchone(self, query: str, values: tuple = ()): - async with self.connect() as conn: - result = await conn.execute(query, values) - row = await result.fetchone() - await result.close() - return row - - async def execute(self, query: str, values: tuple = ()): - async with self.connect() as conn: - return await conn.execute(query, values) - - @asynccontextmanager - async def reuse_conn(self, conn: Connection): - yield conn diff --git a/core/helpers.py b/core/helpers.py deleted file mode 100644 index 75dab61..0000000 --- a/core/helpers.py +++ /dev/null @@ -1,35 +0,0 @@ -import asyncio -from functools import partial, wraps - -from core.settings import LIGHTNING_FEE_PERCENT, LIGHTNING_RESERVE_FEE_MIN - - -def async_wrap(func): - @wraps(func) - async def run(*args, loop=None, executor=None, **kwargs): - if loop is None: - loop = asyncio.get_event_loop() - partial_func = partial(func, *args, **kwargs) - return await loop.run_in_executor(executor, partial_func) - - return run - - -def async_unwrap(to_await): - async_response = [] - - async def run_and_capture_result(): - r = await to_await - async_response.append(r) - - loop = asyncio.get_event_loop() - coroutine = run_and_capture_result() - loop.run_until_complete(coroutine) - return async_response[0] - - -def fee_reserve(amount_msat: int) -> int: - """Function for calculating the Lightning fee reserve""" - return max( - int(LIGHTNING_RESERVE_FEE_MIN), int(amount_msat * LIGHTNING_FEE_PERCENT / 100.0) - ) diff --git a/core/migrations.py b/core/migrations.py deleted file mode 100644 index 1133de6..0000000 --- a/core/migrations.py +++ /dev/null @@ -1,51 +0,0 @@ -import re - -from loguru import logger - -from core.db import COCKROACH, POSTGRES, SQLITE, Database - - -async def migrate_databases(db: Database, migrations_module): - """Creates the necessary databases if they don't exist already; or migrates them.""" - - async def set_migration_version(conn, db_name, version): - await conn.execute( - """ - INSERT INTO dbversions (db, version) VALUES (?, ?) - ON CONFLICT (db) DO UPDATE SET version = ? - """, - (db_name, version, version), - ) - - async def run_migration(db, migrations_module): - db_name = migrations_module.__name__.split(".")[-2] - for key, migrate in migrations_module.__dict__.items(): - match = match = matcher.match(key) - if match: - version = int(match.group(1)) - if version > current_versions.get(db_name, 0): - await migrate(db) - - if db.schema == None: - await set_migration_version(db, db_name, version) - else: - async with db.connect() as conn: - await set_migration_version(conn, db_name, version) - - async with db.connect() as conn: - if conn.type == SQLITE: - exists = await conn.fetchone( - "SELECT * FROM sqlite_master WHERE type='table' AND name='dbversions'" - ) - elif conn.type in {POSTGRES, COCKROACH}: - exists = await conn.fetchone( - "SELECT * FROM information_schema.tables WHERE table_name = 'dbversions'" - ) - - if not exists: - await migrations_module.m000_create_migrations_table(conn) - - rows = await (await conn.execute("SELECT * FROM dbversions")).fetchall() - current_versions = {row["db"]: row["version"] for row in rows} - matcher = re.compile(r"^m(\d\d\d)_") - await run_migration(conn, migrations_module) diff --git a/core/secp.py b/core/secp.py deleted file mode 100644 index 3341643..0000000 --- a/core/secp.py +++ /dev/null @@ -1,52 +0,0 @@ -from secp256k1 import PrivateKey, PublicKey - - -# We extend the public key to define some operations on points -# Picked from https://github.com/WTRMQDev/secp256k1-zkp-py/blob/master/secp256k1_zkp/__init__.py -class PublicKeyExt(PublicKey): - def __add__(self, pubkey2): - if isinstance(pubkey2, PublicKey): - new_pub = PublicKey() - new_pub.combine([self.public_key, pubkey2.public_key]) - return new_pub - else: - raise TypeError("Cant add pubkey and %s" % pubkey2.__class__) - - def __neg__(self): - serialized = self.serialize() - first_byte, remainder = serialized[:1], serialized[1:] - # flip odd/even byte - first_byte = {b"\x03": b"\x02", b"\x02": b"\x03"}[first_byte] - return PublicKey(first_byte + remainder, raw=True) - - def __sub__(self, pubkey2): - if isinstance(pubkey2, PublicKey): - return self + (-pubkey2) - else: - raise TypeError("Can't add pubkey and %s" % pubkey2.__class__) - - def mult(self, privkey): - if isinstance(privkey, PrivateKey): - return self.tweak_mul(privkey.private_key) - else: - raise TypeError("Can't multiply with non privatekey") - - def __eq__(self, pubkey2): - if isinstance(pubkey2, PublicKey): - seq1 = self.to_data() - seq2 = pubkey2.to_data() - return seq1 == seq2 - else: - raise TypeError("Can't compare pubkey and %s" % pubkey2.__class__) - - def to_data(self): - return [self.public_key.data[i] for i in range(64)] - - -# Horrible monkeypatching -PublicKey.__add__ = PublicKeyExt.__add__ -PublicKey.__neg__ = PublicKeyExt.__neg__ -PublicKey.__sub__ = PublicKeyExt.__sub__ -PublicKey.mult = PublicKeyExt.mult -PublicKey.__eq__ = PublicKeyExt.__eq__ -PublicKey.to_data = PublicKeyExt.to_data diff --git a/core/settings.py b/core/settings.py deleted file mode 100644 index d24c0c4..0000000 --- a/core/settings.py +++ /dev/null @@ -1,34 +0,0 @@ -from pathlib import Path - -from environs import Env # type: ignore - -env = Env() -env.read_env() - -DEBUG = env.bool("DEBUG", default=False) -CASHU_DIR = env.str("CASHU_DIR", default="~/.cashu") -CASHU_DIR = CASHU_DIR.replace("~", str(Path.home())) -assert len(CASHU_DIR), "CASHU_DIR not defined" - -LIGHTNING = env.bool("LIGHTNING", default=True) -LIGHTNING_FEE_PERCENT = env.float("LIGHTNING_FEE_PERCENT", default=1.0) -assert LIGHTNING_FEE_PERCENT >= 0, "LIGHTNING_FEE_PERCENT must be at least 0" -LIGHTNING_RESERVE_FEE_MIN = env.float("LIGHTNING_RESERVE_FEE_MIN", default=4000) - -MINT_PRIVATE_KEY = env.str("MINT_PRIVATE_KEY", default=None) - -MINT_SERVER_HOST = env.str("MINT_SERVER_HOST", default="127.0.0.1") -MINT_SERVER_PORT = env.int("MINT_SERVER_PORT", default=3338) - -MINT_HOST = env.str("MINT_HOST", default="8333.space") -MINT_PORT = env.int("MINT_PORT", default=3338) - -if MINT_HOST in ["localhost", "127.0.0.1"]: - MINT_URL = f"http://{MINT_HOST}:{MINT_PORT}" -else: - MINT_URL = f"https://{MINT_HOST}:{MINT_PORT}" - -LNBITS_ENDPOINT = env.str("LNBITS_ENDPOINT", default=None) -LNBITS_KEY = env.str("LNBITS_KEY", default=None) - -MAX_ORDER = 64 diff --git a/core/split.py b/core/split.py deleted file mode 100644 index 44b9cf5..0000000 --- a/core/split.py +++ /dev/null @@ -1,8 +0,0 @@ -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/lightning/__init__.py b/lightning/__init__.py deleted file mode 100644 index baa53c7..0000000 --- a/lightning/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from lightning.lnbits import LNbitsWallet - -WALLET = LNbitsWallet() diff --git a/lightning/base.py b/lightning/base.py deleted file mode 100644 index e38b6d8..0000000 --- a/lightning/base.py +++ /dev/null @@ -1,88 +0,0 @@ -from abc import ABC, abstractmethod -from typing import AsyncGenerator, Coroutine, NamedTuple, Optional - - -class StatusResponse(NamedTuple): - error_message: Optional[str] - balance_msat: int - - -class InvoiceResponse(NamedTuple): - ok: bool - checking_id: Optional[str] = None # payment_hash, rpc_id - payment_request: Optional[str] = None - error_message: Optional[str] = None - - -class PaymentResponse(NamedTuple): - # when ok is None it means we don't know if this succeeded - ok: Optional[bool] = None - checking_id: Optional[str] = None # payment_hash, rcp_id - fee_msat: Optional[int] = None - preimage: Optional[str] = None - error_message: Optional[str] = None - - -class PaymentStatus(NamedTuple): - paid: Optional[bool] = None - fee_msat: Optional[int] = None - preimage: Optional[str] = None - - @property - def pending(self) -> bool: - return self.paid is not True - - @property - def failed(self) -> bool: - return self.paid == False - - def __str__(self) -> str: - if self.paid == True: - return "settled" - elif self.paid == False: - return "failed" - elif self.paid == None: - return "still pending" - else: - return "unknown (should never happen)" - - -class Wallet(ABC): - @abstractmethod - def status(self) -> Coroutine[None, None, StatusResponse]: - pass - - @abstractmethod - def create_invoice( - self, - amount: int, - memo: Optional[str] = None, - description_hash: Optional[bytes] = None, - ) -> Coroutine[None, None, InvoiceResponse]: - pass - - @abstractmethod - def pay_invoice( - self, bolt11: str, fee_limit_msat: int - ) -> Coroutine[None, None, PaymentResponse]: - pass - - @abstractmethod - def get_invoice_status( - self, checking_id: str - ) -> Coroutine[None, None, PaymentStatus]: - pass - - @abstractmethod - def get_payment_status( - self, checking_id: str - ) -> Coroutine[None, None, PaymentStatus]: - pass - - @abstractmethod - def paid_invoices_stream(self) -> AsyncGenerator[str, None]: - pass - - -class Unsupported(Exception): - pass diff --git a/lightning/lnbits.py b/lightning/lnbits.py deleted file mode 100644 index 94a49eb..0000000 --- a/lightning/lnbits.py +++ /dev/null @@ -1,149 +0,0 @@ -import asyncio -import hashlib -import json -from os import getenv -from typing import AsyncGenerator, Dict, Optional - -import requests - -from core.settings import LNBITS_ENDPOINT, LNBITS_KEY - -from .base import (InvoiceResponse, PaymentResponse, PaymentStatus, - StatusResponse, Wallet) - - -class LNbitsWallet(Wallet): - """https://github.com/lnbits/lnbits""" - - def __init__(self): - self.endpoint = LNBITS_ENDPOINT - - key = LNBITS_KEY - self.key = {"X-Api-Key": key} - self.s = requests.Session() - self.s.auth = ("user", "pass") - self.s.headers.update({"X-Api-Key": key}) - - async def status(self) -> StatusResponse: - try: - r = self.s.get(url=f"{self.endpoint}/api/v1/wallet", timeout=15) - except Exception as exc: - return StatusResponse( - f"Failed to connect to {self.endpoint} due to: {exc}", 0 - ) - - try: - data = r.json() - except: - return StatusResponse( - f"Failed to connect to {self.endpoint}, got: '{r.text[:200]}...'", 0 - ) - if "detail" in data: - return StatusResponse(f"LNbits error: {data['detail']}", 0) - return StatusResponse(None, data["balance"]) - - async def create_invoice( - self, - amount: int, - memo: Optional[str] = None, - description_hash: Optional[bytes] = None, - unhashed_description: Optional[bytes] = None, - ) -> InvoiceResponse: - data: Dict = {"out": False, "amount": amount} - if description_hash: - data["description_hash"] = description_hash.hex() - if unhashed_description: - data["unhashed_description"] = unhashed_description.hex() - - data["memo"] = memo or "" - try: - r = self.s.post(url=f"{self.endpoint}/api/v1/payments", json=data) - except: - return InvoiceResponse(False, None, None, r.json()["detail"]) - ok, checking_id, payment_request, error_message = ( - True, - None, - None, - None, - ) - - data = r.json() - checking_id, payment_request = data["checking_id"], data["payment_request"] - - return InvoiceResponse(ok, checking_id, payment_request, error_message) - - async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: - try: - r = self.s.post( - url=f"{self.endpoint}/api/v1/payments", - json={"out": True, "bolt11": bolt11}, - timeout=None, - ) - except: - error_message = r.json()["detail"] - return PaymentResponse(None, None, None, None, error_message) - if "detail" in r.json(): - return PaymentResponse(None, None, None, None, r.json()["detail"]) - ok, checking_id, fee_msat, preimage, error_message = ( - True, - None, - None, - None, - None, - ) - - data = r.json() - checking_id = data["payment_hash"] - - # we do this to get the fee and preimage - payment: PaymentStatus = await self.get_payment_status(checking_id) - - return PaymentResponse(ok, checking_id, payment.fee_msat, payment.preimage) - - async def get_invoice_status(self, checking_id: str) -> PaymentStatus: - try: - - r = self.s.get( - url=f"{self.endpoint}/api/v1/payments/{checking_id}", - headers=self.key, - ) - except: - return PaymentStatus(None) - return PaymentStatus(r.json()["paid"]) - - async def get_payment_status(self, checking_id: str) -> PaymentStatus: - try: - r = self.s.get( - url=f"{self.endpoint}/api/v1/payments/{checking_id}", headers=self.key - ) - except: - return PaymentStatus(None) - data = r.json() - if "paid" not in data and "details" not in data: - return PaymentStatus(None) - - return PaymentStatus(data["paid"], data["details"]["fee"], data["preimage"]) - - async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: - url = f"{self.endpoint}/api/v1/payments/sse" - - while True: - try: - async with requests.stream("GET", url) as r: - async for line in r.aiter_lines(): - if line.startswith("data:"): - try: - data = json.loads(line[5:]) - except json.decoder.JSONDecodeError: - continue - - if type(data) is not dict: - continue - - yield data["payment_hash"] # payment_hash - - except: - pass - - print("lost connection to lnbits /payments/sse, retrying in 5 seconds") - await asyncio.sleep(5) diff --git a/mint/__init__.py b/mint/__init__.py deleted file mode 100644 index 9fa043b..0000000 --- a/mint/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from core.settings import MINT_PRIVATE_KEY -from mint.ledger import Ledger - -print("init") - -ledger = Ledger(MINT_PRIVATE_KEY, "data/mint") diff --git a/mint/__main__.py b/mint/__main__.py deleted file mode 100644 index 90e97df..0000000 --- a/mint/__main__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .main import main - -print("main") - - -main() diff --git a/mint/api.py b/mint/api.py deleted file mode 100644 index e69de29..0000000 diff --git a/mint/app.py b/mint/app.py deleted file mode 100644 index e3a9cbd..0000000 --- a/mint/app.py +++ /dev/null @@ -1,86 +0,0 @@ -import asyncio -import logging -import sys - - -from fastapi import FastAPI -from loguru import logger - -from core.settings import CASHU_DIR, DEBUG - -from lightning import WALLET -from mint.migrations import m001_initial - -from . import ledger - - -def startup(app: FastAPI): - @app.on_event("startup") - 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, - ) - - logger.info(f"Lightning balance: {balance} sat") - logger.info(f"Data dir: {CASHU_DIR}") - logger.info("Mint started.") - - -def create_app(config_object="core.settings") -> FastAPI: - def configure_logger() -> None: - class Formatter: - def __init__(self): - self.padding = 0 - self.minimal_fmt: str = "{time:YYYY-MM-DD HH:mm:ss.SS} | {level} | {message}\n" - if DEBUG: - self.fmt: str = "{time:YYYY-MM-DD HH:mm:ss.SS} | {level: <4} | {name}:{function}:{line} | {message}\n" - else: - self.fmt: str = self.minimal_fmt - - def format(self, record): - function = "{function}".format(**record) - if function == "emit": # uvicorn logs - return self.minimal_fmt - return self.fmt - - class InterceptHandler(logging.Handler): - def emit(self, record): - try: - level = logger.level(record.levelname).name - except ValueError: - level = record.levelno - logger.log(level, record.getMessage()) - - logger.remove() - log_level: str = "INFO" - formatter = Formatter() - logger.add(sys.stderr, level=log_level, format=formatter.format) - - logging.getLogger("uvicorn").handlers = [InterceptHandler()] - logging.getLogger("uvicorn.access").handlers = [InterceptHandler()] - - configure_logger() - - app = FastAPI( - title="Cashu Mint", - description="Ecash wallet and mint.", - license_info={ - "name": "MIT License", - "url": "https://raw.githubusercontent.com/callebtc/cashu/main/LICENSE", - }, - ) - - startup(app) - return app - - -# if __name__ == "__main__": -# main() - -app = create_app() diff --git a/mint/crud.py b/mint/crud.py deleted file mode 100644 index 5b92271..0000000 --- a/mint/crud.py +++ /dev/null @@ -1,110 +0,0 @@ -import secrets -from typing import Optional - -from core.base import Invoice, Proof -from core.db import Connection, Database - - -async def store_promise( - amount: int, - B_: str, - C_: str, - db: Database, - conn: Optional[Connection] = None, -): - - await (conn or db).execute( - """ - INSERT INTO promises - (amount, B_b, C_b) - VALUES (?, ?, ?) - """, - ( - amount, - str(B_), - str(C_), - ), - ) - - -async def get_proofs_used( - db: Database, - conn: Optional[Connection] = None, -): - - rows = await (conn or db).fetchall( - """ - SELECT secret from proofs_used - """ - ) - return [row[0] for row in rows] - - -async def invalidate_proof( - proof: Proof, - db: Database, - conn: Optional[Connection] = None, -): - - # we add the proof and secret to the used list - await (conn or db).execute( - """ - INSERT INTO proofs_used - (amount, C, secret) - VALUES (?, ?, ?) - """, - ( - proof.amount, - str(proof.C), - str(proof.secret), - ), - ) - - -async def store_lightning_invoice( - invoice: Invoice, - db: Database, - conn: Optional[Connection] = None, -): - - await (conn or db).execute( - """ - INSERT INTO invoices - (amount, pr, hash, issued) - VALUES (?, ?, ?, ?) - """, - ( - invoice.amount, - invoice.pr, - invoice.hash, - invoice.issued, - ), - ) - - -async def get_lightning_invoice( - hash: str, - db: Database, - conn: Optional[Connection] = None, -): - - row = await (conn or db).fetchone( - """ - SELECT * from invoices - WHERE hash = ? - """, - hash, - ) - return Invoice.from_row(row) - - -async def update_lightning_invoice( - hash: str, - issued: bool, - db: Database, - conn: Optional[Connection] = None, -): - await (conn or db).execute( - "UPDATE invoices SET issued = ? WHERE hash = ?", - (issued, hash), - ) diff --git a/mint/ledger.py b/mint/ledger.py deleted file mode 100644 index f5ce49f..0000000 --- a/mint/ledger.py +++ /dev/null @@ -1,258 +0,0 @@ -""" -Implementation of https://gist.github.com/phyro/935badc682057f418842c72961cf096c -""" - -import hashlib -from typing import List, Set - -import core.b_dhke as b_dhke -from core.base import BlindedMessage, BlindedSignature, Invoice, Proof -from core.db import Database -from core.helpers import fee_reserve -from core.secp import PrivateKey, PublicKey -from core.settings import LIGHTNING, MAX_ORDER -from core.split import amount_split -from lightning import WALLET -from mint.crud import ( - get_lightning_invoice, - get_proofs_used, - invalidate_proof, - store_lightning_invoice, - store_promise, - update_lightning_invoice, -) - - -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.db: Database = Database("mint", db) - - async def load_used_proofs(self): - self.proofs_used = set(await get_proofs_used(db=self.db)) - - @staticmethod - def _derive_keys(master_key: str): - """Deterministic derivation of keys for 2^n values.""" - return { - 2 - ** i: PrivateKey( - hashlib.sha256((str(master_key) + str(i)).encode("utf-8")) - .hexdigest() - .encode("utf-8")[:32], - raw=True, - ) - for i in range(MAX_ORDER) - } - - @staticmethod - def _derive_pubkeys(keys: List[PrivateKey]): - return {amt: keys[amt].pubkey for amt in [2**i for i in range(MAX_ORDER)]} - - async def _generate_promises(self, amounts: List[int], B_s: List[str]): - """Generates promises that sum to the given amount.""" - return [ - await self._generate_promise(amount, PublicKey(bytes.fromhex(B_), raw=True)) - for (amount, B_) in zip(amounts, B_s) - ] - - async def _generate_promise(self, amount: int, B_: PublicKey): - """Generates a promise for given amount and returns a pair (amount, C').""" - secret_key = self.keys[amount] # Get the correct key - C_ = b_dhke.step2_bob(B_, secret_key) - await store_promise( - amount, B_=B_.serialize().hex(), C_=C_.serialize().hex(), db=self.db - ) - return BlindedSignature(amount=amount, C_=C_.serialize().hex()) - - def _check_spendable(self, proof: Proof): - """Checks whether the proof was already spent.""" - return not proof.secret in self.proofs_used - - def _verify_proof(self, proof: Proof): - """Verifies that the proof of promise was issued by this ledger.""" - if not self._check_spendable(proof): - raise Exception(f"tokens already spent. Secret: {proof.secret}") - secret_key = self.keys[proof.amount] # Get the correct key to check against - C = PublicKey(bytes.fromhex(proof.C), raw=True) - return b_dhke.verify(secret_key, C, proof.secret) - - def _verify_outputs( - self, total: int, amount: int, output_data: List[BlindedMessage] - ): - """Verifies the expected split was correctly computed""" - fst_amt, snd_amt = total - amount, amount # we have two amounts to split to - fst_outputs = amount_split(fst_amt) - snd_outputs = amount_split(snd_amt) - expected = fst_outputs + snd_outputs - given = [o.amount for o in output_data] - return given == expected - - def _verify_no_duplicates( - self, proofs: List[Proof], output_data: 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 output_data] - if len(B_s) != len(list(set(B_s))): - return False - return True - - def _verify_split_amount(self, amount: int): - """Split amount like output amount can't be negative or too big.""" - try: - self._verify_amount(amount) - except: - # For better error message - raise Exception("invalid split amount: " + str(amount)) - - def _verify_amount(self, 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( - self, proofs: List[Proof], outs: List[BlindedMessage] - ): - """Verify that Σoutputs - Σinputs = 0.""" - sum_inputs = sum(self._verify_amount(p.amount) for p in proofs) - sum_outputs = sum(self._verify_amount(p.amount) for p in outs) - assert sum_outputs - sum_inputs == 0 - - def _get_output_split(self, amount: int): - """Given an amount returns a list of amounts returned e.g. 13 is [1, 4, 8].""" - self._verify_amount(amount) - bits_amt = bin(amount)[::-1][:-2] - rv = [] - for (pos, bit) in enumerate(bits_amt): - if bit == "1": - rv.append(2**pos) - return rv - - async def _request_lightning_invoice(self, amount: int): - """Returns an invoice from the Lightning backend.""" - error, balance = await WALLET.status() - if error: - raise Exception(f"Lightning wallet not responding: {error}") - ok, checking_id, payment_request, error_message = await WALLET.create_invoice( - amount, "cashu deposit" - ) - return payment_request, checking_id - - async def _check_lightning_invoice(self, payment_hash: str): - """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") - status = await WALLET.get_invoice_status(payment_hash) - if status.paid: - await update_lightning_invoice(payment_hash, issued=True, db=self.db) - return status.paid - - async def _pay_lightning_invoice(self, invoice: str, amount: int): - """Returns an invoice from the Lightning backend.""" - error, balance = await WALLET.status() - if error: - raise Exception(f"Lightning wallet not responding: {error}") - ok, checking_id, fee_msat, preimage, error_message = await WALLET.pay_invoice( - invoice, fee_limit_msat=fee_reserve(amount * 1000) - ) - return ok, preimage - - async def _invalidate_proofs(self, proofs: List[Proof]): - """Adds secrets of proofs to the list of knwon secrets and stores them in the db.""" - # Mark proofs as used and prepare new promises - proof_msgs = set([p.secret for p in proofs]) - self.proofs_used |= proof_msgs - # store in db - for p in proofs: - await invalidate_proof(p, db=self.db) - - # Public methods - def get_pubkeys(self): - """Returns public keys for possible amounts.""" - return {a: p.serialize().hex() for a, p in self.pub_keys.items()} - - async def request_mint(self, amount): - """Returns Lightning invoice and stores it in the db.""" - payment_request, checking_id = await self._request_lightning_invoice(amount) - invoice = Invoice( - amount=amount, pr=payment_request, hash=checking_id, issued=False - ) - if not payment_request or not checking_id: - raise Exception(f"Could not create Lightning invoice.") - await store_lightning_invoice(invoice, db=self.db) - return payment_request, checking_id - - async def mint(self, B_s: List[PublicKey], amounts: List[int], payment_hash=None): - """Mints a promise for coins for B_.""" - # check if lightning invoice was paid - if LIGHTNING and ( - payment_hash and not await self._check_lightning_invoice(payment_hash) - ): - raise Exception("Lightning invoice not paid yet.") - - for amount in amounts: - if amount not in [2**i for i in range(MAX_ORDER)]: - raise Exception(f"Can only mint amounts up to {2**MAX_ORDER}.") - - promises = [ - await self._generate_promise(amount, B_) for B_, amount in zip(B_s, amounts) - ] - return promises - - async def melt(self, proofs: List[Proof], amount: int, invoice: str): - """Invalidates proofs and pays a Lightning invoice.""" - # if not LIGHTNING: - total = sum([p["amount"] for p in proofs]) - # check that lightning fees are included - assert total + fee_reserve(amount * 1000) >= amount, Exception( - "provided proofs not enough for Lightning payment." - ) - - status, preimage = await self._pay_lightning_invoice(invoice, amount) - if status == True: - await self._invalidate_proofs(proofs) - return status, preimage - - async def check_spendable(self, proofs: List[Proof]): - """Checks if all provided proofs are valid and still spendable (i.e. have not been spent).""" - 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] - ): - """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") - 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") - - # 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) :]] - prom_fst, prom_snd = await self._generate_promises( - outs_fst, B_fst - ), await self._generate_promises(outs_snd, B_snd) - self._verify_equation_balanced(proofs, prom_fst + prom_snd) - return prom_fst, prom_snd diff --git a/mint/main.py b/mint/main.py deleted file mode 100644 index 7f70c5b..0000000 --- a/mint/main.py +++ /dev/null @@ -1,52 +0,0 @@ -import click -import uvicorn - -from core.settings import ( - MINT_SERVER_HOST, - MINT_SERVER_PORT, -) - - -@click.command( - context_settings=dict( - ignore_unknown_options=True, - allow_extra_args=True, - ) -) -@click.option("--port", default=MINT_SERVER_PORT, help="Port to listen on") -@click.option("--host", default=MINT_SERVER_HOST, help="Host to run mint on") -@click.option("--ssl-keyfile", default=None, help="Path to SSL keyfile") -@click.option("--ssl-certfile", default=None, help="Path to SSL certificate") -@click.pass_context -def main( - ctx, - port: int = MINT_SERVER_PORT, - host: str = MINT_SERVER_HOST, - ssl_keyfile: str = None, - ssl_certfile: str = None, -): - """Launched with `poetry run mint` at root level""" - # this beautiful beast parses all command line arguments and passes them to the uvicorn server - d = dict() - for a in ctx.args: - item = a.split("=") - if len(item) > 1: # argument like --key=value - print(a, item) - d[item[0].strip("--").replace("-", "_")] = ( - int(item[1]) # need to convert to int if it's a number - if item[1].isdigit() - else item[1] - ) - else: - d[a.strip("--")] = True # argument like --key - - config = uvicorn.Config( - "mint.app:app", - port=port, - host=host, - ssl_keyfile=ssl_keyfile, - ssl_certfile=ssl_certfile, - **d, - ) - server = uvicorn.Server(config) - server.run() diff --git a/mint/migrations.py b/mint/migrations.py deleted file mode 100644 index 231013c..0000000 --- a/mint/migrations.py +++ /dev/null @@ -1,87 +0,0 @@ -from core.db import Database - - -async def m000_create_migrations_table(db): - await db.execute( - """ - CREATE TABLE IF NOT EXISTS dbversions ( - db TEXT PRIMARY KEY, - version INT NOT NULL - ) - """ - ) - - -async def m001_initial(db: Database): - await db.execute( - """ - CREATE TABLE IF NOT EXISTS promises ( - amount INTEGER NOT NULL, - B_b TEXT NOT NULL, - C_b TEXT NOT NULL, - - UNIQUE (B_b) - - ); - """ - ) - - await db.execute( - """ - CREATE TABLE IF NOT EXISTS proofs_used ( - amount INTEGER NOT NULL, - C TEXT NOT NULL, - secret TEXT NOT NULL, - - UNIQUE (secret) - - ); - """ - ) - - await db.execute( - """ - CREATE TABLE IF NOT EXISTS invoices ( - amount INTEGER NOT NULL, - pr TEXT NOT NULL, - hash TEXT NOT NULL, - issued BOOL NOT NULL, - - UNIQUE (hash) - - ); - """ - ) - - await db.execute( - """ - CREATE VIEW IF NOT EXISTS balance_issued AS - SELECT COALESCE(SUM(s), 0) AS balance FROM ( - SELECT SUM(amount) AS s - FROM promises - WHERE amount > 0 - ); - """ - ) - - await db.execute( - """ - CREATE VIEW IF NOT EXISTS balance_used AS - SELECT COALESCE(SUM(s), 0) AS balance FROM ( - SELECT SUM(amount) AS s - FROM proofs_used - WHERE amount > 0 - ); - """ - ) - - await db.execute( - """ - CREATE VIEW IF NOT EXISTS balance AS - SELECT s_issued - s_used AS balance FROM ( - SELECT bi.balance AS s_issued, bu.balance AS s_used - FROM balance_issued bi - CROSS JOIN balance_used bu - ); - """ - ) diff --git a/mint/router.py b/mint/router.py deleted file mode 100644 index 7f5ac35..0000000 --- a/mint/router.py +++ /dev/null @@ -1,86 +0,0 @@ -from fastapi import APIRouter -from mint import ledger -from core.base import CheckPayload, MeltPayload, MintPayloads, SplitPayload -from typing import Union -from secp256k1 import PublicKey - -router: APIRouter = APIRouter() - - -@router.get("/keys") -def keys(): - """Get the public keys of the mint""" - return ledger.get_pubkeys() - - -@router.get("/mint") -async def request_mint(amount: int = 0): - """Request minting of tokens. Server responds with a Lightning invoice.""" - payment_request, payment_hash = await ledger.request_mint(amount) - print(f"Lightning invoice: {payment_request}") - return {"pr": payment_request, "hash": payment_hash} - - -@router.post("/mint") -async def mint(payloads: MintPayloads, payment_hash: Union[str, None] = None): - """ - Requests the minting of tokens belonging to a paid payment request. - - Parameters: - pr: payment_request of the Lightning paid invoice. - - Body (JSON): - payloads: contains a list of blinded messages waiting to be signed. - - NOTE: - - This needs to be replaced by the preimage otherwise someone knowing - the payment_request can request the tokens instead of the rightful - owner. - - The blinded message should ideally be provided to the server *before* payment - in the GET /mint endpoint so that the server knows to sign only these tokens - when the invoice is paid. - """ - amounts = [] - B_s = [] - for payload in payloads.blinded_messages: - amounts.append(payload.amount) - B_s.append(PublicKey(bytes.fromhex(payload.B_), raw=True)) - try: - promises = await ledger.mint(B_s, amounts, payment_hash=payment_hash) - return promises - except Exception as exc: - return {"error": str(exc)} - - -@router.post("/melt") -async def melt(payload: MeltPayload): - """ - Requests tokens to be destroyed and sent out via Lightning. - """ - ok, preimage = await ledger.melt(payload.proofs, payload.amount, payload.invoice) - return {"paid": ok, "preimage": preimage} - - -@router.post("/check") -async def check_spendable(payload: CheckPayload): - return await ledger.check_spendable(payload.proofs) - - -@router.post("/split") -async def split(payload: SplitPayload): - """ - Requetst a set of tokens with amount "total" to be split into two - newly minted sets with amount "split" and "total-split". - """ - proofs = payload.proofs - amount = payload.amount - output_data = payload.output_data.blinded_messages - try: - split_return = await ledger.split(proofs, amount, output_data) - 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.") - fst_promises, snd_promises = split_return - return {"fst": fst_promises, "snd": snd_promises} diff --git a/poetry.lock b/poetry.lock index 4f84b80..c0dac60 100644 --- a/poetry.lock +++ b/poetry.lock @@ -85,7 +85,7 @@ uvloop = ["uvloop (>=0.15.2)"] [[package]] name = "certifi" -version = "2022.9.14" +version = "2022.9.24" description = "Python package for providing Mozilla's CA Bundle." category = "main" optional = false @@ -205,11 +205,11 @@ dotenv = ["python-dotenv"] [[package]] name = "h11" -version = "0.13.0" +version = "0.14.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] typing-extensions = {version = "*", markers = "python_version < \"3.8\""} @@ -759,8 +759,8 @@ black = [ {file = "black-22.8.0.tar.gz", hash = "sha256:792f7eb540ba9a17e8656538701d3eb1afcb134e3b45b71f20b25c77a8db7e6e"}, ] certifi = [ - {file = "certifi-2022.9.14-py3-none-any.whl", hash = "sha256:e232343de1ab72c2aa521b625c80f699e356830fd0e2c620b465b304b17b0516"}, - {file = "certifi-2022.9.14.tar.gz", hash = "sha256:36973885b9542e6bd01dea287b2b4b3b21236307c56324fcc3f1160f2d655ed5"}, + {file = "certifi-2022.9.24-py3-none-any.whl", hash = "sha256:90c1a32f1d68f940488354e36370f6cca89f0f106db09518524c88d6ed83f382"}, + {file = "certifi-2022.9.24.tar.gz", hash = "sha256:0d9c601124e5a6ba9712dbc60d9c53c21e34f5f641fe83002317394311bdce14"}, ] cffi = [ {file = "cffi-1.15.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2"}, @@ -857,8 +857,8 @@ Flask = [ {file = "Flask-2.2.2.tar.gz", hash = "sha256:642c450d19c4ad482f96729bd2a8f6d32554aa1e231f4f6b4e7e5264b16cca2b"}, ] h11 = [ - {file = "h11-0.13.0-py3-none-any.whl", hash = "sha256:8ddd78563b633ca55346c8cd41ec0af27d3c79931828beffb46ce70a379e7442"}, - {file = "h11-0.13.0.tar.gz", hash = "sha256:70813c1135087a248a4d38cc0e1a0181ffab2188141a93eaf567940c3957ff06"}, + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, ] idna = [ {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, diff --git a/pyproject.toml b/pyproject.toml index 4bf03ad..8588b47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,6 @@ requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" [tool.poetry.scripts] -mint = "mint.app:main" -cashu = "wallet.cashu:cli" +mint = "cashu.mint.app:main" +cashu = "cashu.wallet.cli:cli" wallet-test = "tests.test_wallet:test" \ No newline at end of file diff --git a/setup.py b/setup.py index f80f9ca..01bb877 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ with open(path.join(this_directory, "README.md"), encoding="utf-8") as f: with open("requirements.txt") as f: requirements = f.read().splitlines() -entry_points = {"console_scripts": ["cashu = wallet.cashu:cli"]} +entry_points = {"console_scripts": ["cashu = cahu.wallet.cashu:cli"]} setuptools.setup( name="cashu", diff --git a/wallet/__init__.py b/wallet/__init__.py deleted file mode 100644 index bf11b51..0000000 --- a/wallet/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -import sys - -sys.tracebacklimit = None diff --git a/wallet/cashu.py b/wallet/cashu.py deleted file mode 100755 index c252b46..0000000 --- a/wallet/cashu.py +++ /dev/null @@ -1,212 +0,0 @@ -#!/usr/bin/env python - -import asyncio -import base64 -import json -import math -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 - -import core.bolt11 as bolt11 -from core.base import Proof -from core.bolt11 import Invoice -from core.helpers import fee_reserve -from core.migrations import migrate_databases -from core.settings import CASHU_DIR, DEBUG, LIGHTNING, MINT_URL -from wallet import migrations -from wallet.crud import get_reserved_proofs -from wallet.wallet import Wallet as Wallet - - -async def init_wallet(wallet: Wallet): - """Performs migrations and loads proofs from db.""" - await migrate_databases(wallet.db, migrations) - await wallet.load_proofs() - - -class NaturalOrderGroup(click.Group): - """For listing commands in help in order of definition""" - - def list_commands(self, ctx): - return self.commands.keys() - - -@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.pass_context -def cli(ctx, host: str, walletname: str): - 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) - pass - - -# https://github.com/pallets/click/issues/85#issuecomment-503464628 -def coro(f): - @wraps(f) - def wrapper(*args, **kwargs): - return asyncio.run(f(*args, **kwargs)) - - return wrapper - - -@cli.command("mint", help="Mint tokens.") -@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.status() - if not LIGHTNING: - r = await wallet.mint(amount) - elif amount and not hash: - r = await wallet.request_mint(amount) - if "pr" in r: - print(f"Pay this invoice to mint {amount} sat:") - print(f"Invoice: {r['pr']}") - print("") - print( - f"After paying the invoice, run this command:\ncashu mint {amount} --hash {r['hash']}" - ) - elif amount and hash: - await wallet.mint(amount, hash) - wallet.status() - return - - -@cli.command("balance", help="See 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.") -@click.argument("amount", type=int) -@click.pass_context -@coro -async def send(ctx, amount: int): - wallet: Wallet = ctx.obj["WALLET"] - await init_wallet(wallet) - wallet.status() - _, send_proofs = await wallet.split_to_send(wallet.proofs, amount) - await wallet.set_reserved(send_proofs, reserved=True) - token = await wallet.serialize_proofs(send_proofs) - print(token) - wallet.status() - - -@cli.command("receive", help="Receive tokens.") -@click.argument("token", type=str) -@click.pass_context -@coro -async def receive(ctx, token: str): - wallet: Wallet = ctx.obj["WALLET"] - await init_wallet(wallet) - wallet.status() - proofs = [Proof.from_dict(p) for p in json.loads(base64.urlsafe_b64decode(token))] - _, _ = await wallet.redeem(proofs) - 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.") -@click.option( - "--force", "-f", default=False, is_flag=True, help="Force check on all tokens." -) -@click.pass_context -@coro -async def burn(ctx, token: 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): - print( - "Error: enter a token or use --all to burn all pending tokens or --force to check all tokens." - ) - return - if all: - # check only those who are flagged as reserved - proofs = await get_reserved_proofs(wallet.db) - elif force: - # check all proofs in db - proofs = wallet.proofs - else: - # check only the specified ones - proofs = [ - Proof.from_dict(p) for p in json.loads(base64.urlsafe_b64decode(token)) - ] - wallet.status() - await wallet.invalidate(proofs) - wallet.status() - - -@cli.command("pending", help="Show pending tokens.") -@click.pass_context -@coro -async def pending(ctx): - wallet: Wallet = ctx.obj["WALLET"] - await init_wallet(wallet) - reserved_proofs = await get_reserved_proofs(wallet.db) - if len(reserved_proofs): - sorted_proofs = sorted(reserved_proofs, key=itemgetter("send_id")) - for key, value in groupby(sorted_proofs, key=itemgetter("send_id")): - grouped_proofs = list(value) - token = await wallet.serialize_proofs(grouped_proofs) - 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" - ) - print(token) - print("") - wallet.status() - - -@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.status() - decoded_invoice: Invoice = bolt11.decode(invoice) - amount = math.ceil( - (decoded_invoice.amount_msat + fee_reserve(decoded_invoice.amount_msat)) / 1000 - ) # 1% fee for Lightning - print( - f"Paying Lightning invoice of {decoded_invoice.amount_msat // 1000} sat ({amount} sat incl. fees)" - ) - assert amount > 0, "amount is not positive" - if wallet.available_balance < amount: - print("Error: Balance too low.") - return - _, send_proofs = await wallet.split_to_send(wallet.proofs, amount) - await wallet.pay_lightning(send_proofs, amount, invoice) - wallet.status() - - -@cli.command("info", help="Information about Cashu wallet.") -@click.pass_context -@coro -async def info(ctx): - wallet: Wallet = ctx.obj["WALLET"] - await init_wallet(wallet) - wallet.status() - print(f"Debug: {DEBUG}") - print(f"Cashu dir: {CASHU_DIR}") - print(f"Mint URL: {MINT_URL}") - return diff --git a/wallet/crud.py b/wallet/crud.py deleted file mode 100644 index 55bb293..0000000 --- a/wallet/crud.py +++ /dev/null @@ -1,99 +0,0 @@ -import time -from typing import Optional - -from core.base import Proof -from core.db import Connection, Database - - -async def store_proof( - proof: Proof, - db: Database, - conn: Optional[Connection] = None, -): - - await (conn or db).execute( - """ - INSERT INTO proofs - (amount, C, secret, time_created) - VALUES (?, ?, ?, ?) - """, - (proof.amount, str(proof.C), str(proof.secret), int(time.time())), - ) - - -async def get_proofs( - db: Database, - conn: Optional[Connection] = None, -): - - rows = await (conn or db).fetchall( - """ - SELECT * from proofs - """ - ) - return [Proof.from_row(r) for r in rows] - - -async def get_reserved_proofs( - db: Database, - conn: Optional[Connection] = None, -): - - rows = await (conn or db).fetchall( - """ - SELECT * from proofs - WHERE reserved - """ - ) - return [Proof.from_row(r) for r in rows] - - -async def invalidate_proof( - proof: Proof, - db: Database, - conn: Optional[Connection] = None, -): - - await (conn or db).execute( - f""" - DELETE FROM proofs - WHERE secret = ? - """, - str(proof["secret"]), - ) - - await (conn or db).execute( - """ - INSERT INTO proofs_used - (amount, C, secret, time_used) - VALUES (?, ?, ?, ?) - """, - (proof.amount, str(proof.C), str(proof.secret), int(time.time())), - ) - - -async def update_proof_reserved( - proof: Proof, - reserved: bool, - send_id: str = None, - db: Database = None, - conn: Optional[Connection] = None, -): - clauses = [] - values = [] - clauses.append("reserved = ?") - values.append(reserved) - - if send_id: - clauses.append("send_id = ?") - values.append(send_id) - - if reserved: - # set the time of reserving - clauses.append("time_reserved = ?") - values.append(int(time.time())) - - await (conn or db).execute( - f"UPDATE proofs SET {', '.join(clauses)} WHERE secret = ?", - (*values, str(proof.secret)), - ) diff --git a/wallet/migrations.py b/wallet/migrations.py deleted file mode 100644 index 8745577..0000000 --- a/wallet/migrations.py +++ /dev/null @@ -1,81 +0,0 @@ -from core.db import Database - - -async def m000_create_migrations_table(db): - await db.execute( - """ - CREATE TABLE IF NOT EXISTS dbversions ( - db TEXT PRIMARY KEY, - version INT NOT NULL - ) - """ - ) - - -async def m001_initial(db: Database): - await db.execute( - """ - CREATE TABLE IF NOT EXISTS proofs ( - amount INTEGER NOT NULL, - C TEXT NOT NULL, - secret TEXT NOT NULL, - - UNIQUE (secret) - - ); - """ - ) - - await db.execute( - """ - CREATE TABLE IF NOT EXISTS proofs_used ( - amount INTEGER NOT NULL, - C TEXT NOT NULL, - secret TEXT NOT NULL, - - UNIQUE (secret) - - ); - """ - ) - - await db.execute( - """ - CREATE VIEW IF NOT EXISTS balance AS - SELECT COALESCE(SUM(s), 0) AS balance FROM ( - SELECT SUM(amount) AS s - FROM proofs - WHERE amount > 0 - ); - """ - ) - - await db.execute( - """ - CREATE VIEW IF NOT EXISTS balance_used AS - SELECT COALESCE(SUM(s), 0) AS used FROM ( - SELECT SUM(amount) AS s - FROM proofs_used - WHERE amount > 0 - ); - """ - ) - - -async def m002_add_proofs_reserved(db): - """ - Column for marking proofs as reserved when they are being sent. - """ - - await db.execute("ALTER TABLE proofs ADD COLUMN reserved BOOL") - - -async def m003_add_proofs_sendid_and_timestamps(db): - """ - Column with unique ID for each initiated send attempt - so proofs can be later grouped together for each send attempt. - """ - await db.execute("ALTER TABLE proofs ADD COLUMN send_id TEXT") - 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") diff --git a/wallet/wallet.py b/wallet/wallet.py deleted file mode 100644 index 17172d5..0000000 --- a/wallet/wallet.py +++ /dev/null @@ -1,252 +0,0 @@ -import base64 -import json -import random -import secrets as scrts -import uuid -from typing import List - -import requests - -import core.b_dhke as b_dhke -from core.base import ( - BlindedMessage, - BlindedSignature, - CheckPayload, - MeltPayload, - MintPayloads, - Proof, - SplitPayload, -) -from core.db import Database -from core.secp import PublicKey -from core.settings import DEBUG -from core.split import amount_split -from wallet.crud import get_proofs, invalidate_proof, store_proof, update_proof_reserved - - -class LedgerAPI: - def __init__(self, url): - self.url = url - self.keys = self._get_keys(url) - - @staticmethod - def _get_keys(url): - resp = requests.get(url + "/keys").json() - return { - int(amt): PublicKey(bytes.fromhex(val), raw=True) - for amt, val in resp.items() - } - - @staticmethod - def _get_output_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 - - def _construct_proofs(self, promises: List[BlindedSignature], secrets: List[str]): - """Returns proofs of promise from promises.""" - proofs = [] - for promise, (r, secret) in zip(promises, secrets): - 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): - """Returns base64 encoded random string.""" - return scrts.token_urlsafe(randombits // 8) - - 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.""" - payloads: MintPayloads = MintPayloads() - secrets = [] - rs = [] - for amount in amounts: - secret = self._generate_secret() - secrets.append(secret) - 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( - self.url + "/mint", - json=payloads.dict(), - params={"payment_hash": payment_hash}, - ).json() - 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.""" - 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) - split_payload = SplitPayload(proofs=proofs, amount=amount, output_data=payloads) - promises_dict = requests.post( - self.url + "/split", - json=split_payload.dict(), - ).json() - 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) :]) - - return fst_proofs, snd_proofs - - async def check_spendable(self, proofs: List[Proof]): - payload = CheckPayload(proofs=proofs) - return_dict = requests.post( - self.url + "/check", - json=payload.dict(), - ).json() - - return return_dict - - async def pay_lightning(self, proofs: List[Proof], amount: int, invoice: str): - payload = MeltPayload(proofs=proofs, amount=amount, invoice=invoice) - return_dict = requests.post( - self.url + "/melt", - json=payload.dict(), - ).json() - return return_dict - - -class Wallet(LedgerAPI): - """Minimal wallet wrapper.""" - - def __init__(self, url: str, db: str, name: str = "no_name"): - super().__init__(url) - self.db = Database("wallet", db) - self.proofs: List[Proof] = [] - self.name = name - - async def load_proofs(self): - self.proofs = await get_proofs(db=self.db) - - async def _store_proofs(self, proofs): - for proof in proofs: - await store_proof(proof, db=self.db) - - async def request_mint(self, amount): - return super().request_mint(amount) - - async def mint(self, amount: int, payment_hash: str = None): - split = amount_split(amount) - proofs = 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]): - return await self.split(proofs, sum(p["amount"] for p in proofs)) - - async def split(self, proofs: List[Proof], amount: int): - assert len(proofs) > 0, ValueError("no proofs provided.") - fst_proofs, snd_proofs = super().split(proofs, amount) - if len(fst_proofs) == 0 and len(snd_proofs) == 0: - raise Exception("received no splits.") - used_secrets = [p["secret"] for p in proofs] - self.proofs = list( - filter(lambda p: p["secret"] not in used_secrets, self.proofs) - ) - self.proofs += fst_proofs + snd_proofs - await self._store_proofs(fst_proofs + snd_proofs) - for proof in proofs: - await invalidate_proof(proof, db=self.db) - return fst_proofs, snd_proofs - - async def pay_lightning(self, proofs: List[Proof], amount: int, invoice: str): - """Pays a lightning invoice""" - status = await super().pay_lightning(proofs, amount, invoice) - if status["paid"] == True: - await self.invalidate(proofs) - else: - raise Exception("could not pay invoice.") - return status["paid"] - - @staticmethod - async def serialize_proofs(proofs: List[Proof]): - 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): - """Like self.split but only considers non-reserved tokens.""" - 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) - - async def set_reserved(self, proofs: List[Proof], reserved: bool): - """Mark a proof as reserved to avoid reuse or delete marking.""" - uuid_str = str(uuid.uuid1()) - for proof in proofs: - proof.reserved = True - await update_proof_reserved( - proof, reserved=reserved, send_id=uuid_str, db=self.db - ) - - async def check_spendable(self, proofs): - return await super().check_spendable(proofs) - - async def invalidate(self, proofs): - """Invalidates all spendable tokens supplied in proofs.""" - spendables = await self.check_spendable(proofs) - invalidated_proofs = [] - for idx, spendable in spendables.items(): - if not spendable: - invalidated_proofs.append(proofs[int(idx)]) - await invalidate_proof(proofs[int(idx)], db=self.db) - invalidate_secrets = [p["secret"] for p in invalidated_proofs] - self.proofs = list( - filter(lambda p: p["secret"] not in invalidate_secrets, self.proofs) - ) - - @property - def balance(self): - return sum(p["amount"] for p in self.proofs) - - @property - def available_balance(self): - return sum(p["amount"] for p in self.proofs if not p.reserved) - - 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)" - ) - - def proof_amounts(self): - return [p["amount"] for p in sorted(self.proofs, key=lambda p: p["amount"])] From b5347077c276e47257687c0ab521b1865b57646a Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 28 Sep 2022 17:47:58 +0200 Subject: [PATCH 06/11] bump --- pyproject.toml | 2 +- setup.py | 2 +- tests/test_wallet.py | 14 ++++++-------- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8588b47..b8a2150 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "cashu" -version = "0.1.9" +version = "0.1.10" description = "Ecash wallet and mint." authors = ["calle "] license = "MIT" diff --git a/setup.py b/setup.py index 01bb877..d8a0317 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ entry_points = {"console_scripts": ["cashu = cahu.wallet.cashu:cli"]} setuptools.setup( name="cashu", - version="0.1.9", + version="0.1.10", 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 9def85f..a77b141 100644 --- a/tests/test_wallet.py +++ b/tests/test_wallet.py @@ -1,10 +1,8 @@ -import asyncio - -from core.helpers import async_unwrap -from core.migrations import migrate_databases -from wallet import migrations -from wallet.wallet import Wallet as Wallet1 -from wallet.wallet import Wallet as Wallet2 +from cashu.core.helpers import async_unwrap +from cashu.core.migrations import migrate_databases +from cashu.wallet import migrations +from cashu.wallet.wallet import Wallet as Wallet1 +from cashu.wallet.wallet import Wallet as Wallet2 SERVER_ENDPOINT = "http://localhost:3338" @@ -29,7 +27,7 @@ async def run_test(): await migrate_databases(wallet1.db, migrations) wallet1.status() - wallet2 = Wallet1(SERVER_ENDPOINT, "data/wallet2", "wallet2") + wallet2 = Wallet2(SERVER_ENDPOINT, "data/wallet2", "wallet2") await migrate_databases(wallet2.db, migrations) wallet2.status() From 9a482d28f5bfca11312b28e95d1ee02f6e6e5000 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 28 Sep 2022 17:51:47 +0200 Subject: [PATCH 07/11] show version --- cashu/core/settings.py | 1 + cashu/wallet/cli.py | 6 ++---- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/cashu/core/settings.py b/cashu/core/settings.py index d24c0c4..36ec220 100644 --- a/cashu/core/settings.py +++ b/cashu/core/settings.py @@ -32,3 +32,4 @@ LNBITS_ENDPOINT = env.str("LNBITS_ENDPOINT", default=None) LNBITS_KEY = env.str("LNBITS_KEY", default=None) MAX_ORDER = 64 +VERSION = "0.1.10" diff --git a/cashu/wallet/cli.py b/cashu/wallet/cli.py index 07a0616..09a3f4a 100755 --- a/cashu/wallet/cli.py +++ b/cashu/wallet/cli.py @@ -17,7 +17,7 @@ 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 +from cashu.core.settings import CASHU_DIR, DEBUG, LIGHTNING, MINT_URL, VERSION from cashu.wallet import migrations from cashu.wallet.crud import get_reserved_proofs from cashu.wallet.wallet import Wallet as Wallet @@ -203,9 +203,7 @@ async def pay(ctx, invoice: str): @click.pass_context @coro async def info(ctx): - wallet: Wallet = ctx.obj["WALLET"] - await init_wallet(wallet) - wallet.status() + print(f"Version: {VERSION}") print(f"Debug: {DEBUG}") print(f"Cashu dir: {CASHU_DIR}") print(f"Mint URL: {MINT_URL}") From 68bae0d1cfe3dad8a9df57c3b0d2f5a86298b2d5 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 28 Sep 2022 17:57:45 +0200 Subject: [PATCH 08/11] isort and flask remove --- cashu/mint/app.py | 2 -- cashu/mint/main.py | 5 +---- cashu/mint/router.py | 8 +++++--- cashu/mint/startup.py | 2 +- poetry.lock | 25 +------------------------ pyproject.toml | 1 - 6 files changed, 8 insertions(+), 35 deletions(-) diff --git a/cashu/mint/app.py b/cashu/mint/app.py index 77b2858..428bc05 100644 --- a/cashu/mint/app.py +++ b/cashu/mint/app.py @@ -2,12 +2,10 @@ import asyncio import logging import sys - from fastapi import FastAPI from loguru import logger from cashu.core.settings import CASHU_DIR, DEBUG - from cashu.lightning import WALLET from cashu.mint.migrations import m001_initial diff --git a/cashu/mint/main.py b/cashu/mint/main.py index 4fff6ba..50c4a6a 100644 --- a/cashu/mint/main.py +++ b/cashu/mint/main.py @@ -1,10 +1,7 @@ import click import uvicorn -from cashu.core.settings import ( - MINT_SERVER_HOST, - MINT_SERVER_PORT, -) +from cashu.core.settings import MINT_SERVER_HOST, MINT_SERVER_PORT @click.command( diff --git a/cashu/mint/router.py b/cashu/mint/router.py index 01fca8a..b7ebf05 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -1,9 +1,11 @@ -from fastapi import APIRouter -from cashu.mint import ledger -from cashu.core.base import CheckPayload, MeltPayload, MintPayloads, SplitPayload from typing import Union + +from fastapi import APIRouter from secp256k1 import PublicKey +from cashu.core.base import CheckPayload, MeltPayload, MintPayloads, SplitPayload +from cashu.mint import ledger + router: APIRouter = APIRouter() diff --git a/cashu/mint/startup.py b/cashu/mint/startup.py index d263c49..a2c4653 100644 --- a/cashu/mint/startup.py +++ b/cashu/mint/startup.py @@ -1,8 +1,8 @@ import asyncio + from loguru import logger from cashu.core.settings import CASHU_DIR - from cashu.lightning import WALLET from cashu.mint.migrations import m001_initial diff --git a/poetry.lock b/poetry.lock index c0dac60..f8a5ae0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -184,25 +184,6 @@ dev = ["autoflake (>=1.4.0,<2.0.0)", "flake8 (>=3.8.3,<6.0.0)", "passlib[bcrypt] doc = ["mdx-include (>=1.4.1,<2.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "pyyaml (>=5.3.1,<7.0.0)", "typer (>=0.4.1,<0.5.0)"] test = ["anyio[trio] (>=3.2.1,<4.0.0)", "black (==22.3.0)", "databases[sqlite] (>=0.3.2,<0.6.0)", "email_validator (>=1.1.1,<2.0.0)", "flake8 (>=3.8.3,<6.0.0)", "flask (>=1.1.2,<3.0.0)", "httpx (>=0.14.0,<0.19.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.910)", "orjson (>=3.2.1,<4.0.0)", "peewee (>=3.13.3,<4.0.0)", "pytest (>=6.2.4,<7.0.0)", "pytest-cov (>=2.12.0,<4.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "requests (>=2.24.0,<3.0.0)", "sqlalchemy (>=1.3.18,<1.5.0)", "types-dataclasses (==0.6.5)", "types-orjson (==3.6.2)", "types-ujson (==4.2.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0,<6.0.0)"] -[[package]] -name = "Flask" -version = "2.2.2" -description = "A simple framework for building complex web applications." -category = "main" -optional = false -python-versions = ">=3.7" - -[package.dependencies] -click = ">=8.0" -importlib-metadata = {version = ">=3.6.0", markers = "python_version < \"3.10\""} -itsdangerous = ">=2.0" -Jinja2 = ">=3.0" -Werkzeug = ">=2.2.2" - -[package.extras] -async = ["asgiref (>=3.2)"] -dotenv = ["python-dotenv"] - [[package]] name = "h11" version = "0.14.0" @@ -709,7 +690,7 @@ testing = ["func-timeout", "jaraco.itertools", "pytest (>=6)", "pytest-black (>= [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "d5ee88384ec1ec1774f9fab845d7e8ac6e8ab1859506bcce178f6783b98e535c" +content-hash = "4058e11929b71dee85bceb89e1fb5254411db9bde0e087f65b5cf7c0c7fbae8f" [metadata.files] anyio = [ @@ -852,10 +833,6 @@ fastapi = [ {file = "fastapi-0.83.0-py3-none-any.whl", hash = "sha256:694a2b6c2607a61029a4be1c6613f84d74019cb9f7a41c7a475dca8e715f9368"}, {file = "fastapi-0.83.0.tar.gz", hash = "sha256:96eb692350fe13d7a9843c3c87a874f0d45102975257dd224903efd6c0fde3bd"}, ] -Flask = [ - {file = "Flask-2.2.2-py3-none-any.whl", hash = "sha256:b9c46cc36662a7949f34b52d8ec7bb59c0d74ba08ba6cb9ce9adc1d8676d9526"}, - {file = "Flask-2.2.2.tar.gz", hash = "sha256:642c450d19c4ad482f96729bd2a8f6d32554aa1e231f4f6b4e7e5264b16cca2b"}, -] h11 = [ {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, diff --git a/pyproject.toml b/pyproject.toml index b8a2150..cac9458 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,6 @@ SQLAlchemy = "1.3.24" sqlalchemy-aio = "0.17.0" charset-normalizer = "2.0.12" click = "8.0.4" -Flask = "2.2.2" idna = "3.3" itsdangerous = "2.1.1" Jinja2 = "3.0.3" From ea6ab798cd889f2a50e6d6e51f9ef907306d0dda Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 28 Sep 2022 17:59:30 +0200 Subject: [PATCH 09/11] remove prints --- cashu/mint/__init__.py | 2 -- cashu/mint/__main__.py | 3 --- 2 files changed, 5 deletions(-) diff --git a/cashu/mint/__init__.py b/cashu/mint/__init__.py index 85779d7..cfe9d4c 100644 --- a/cashu/mint/__init__.py +++ b/cashu/mint/__init__.py @@ -1,6 +1,4 @@ from cashu.core.settings import MINT_PRIVATE_KEY from cashu.mint.ledger import Ledger -print("init") - ledger = Ledger(MINT_PRIVATE_KEY, "data/mint") diff --git a/cashu/mint/__main__.py b/cashu/mint/__main__.py index 90e97df..5d6a810 100644 --- a/cashu/mint/__main__.py +++ b/cashu/mint/__main__.py @@ -1,6 +1,3 @@ from .main import main -print("main") - - main() From 63f1c7b93bdb86a07b773e6a9f95378bebe98f33 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 28 Sep 2022 18:00:30 +0200 Subject: [PATCH 10/11] fix mint command --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index cac9458..52c07a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,6 @@ requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" [tool.poetry.scripts] -mint = "cashu.mint.app:main" +mint = "cashu.mint.main:main" cashu = "cashu.wallet.cli:cli" wallet-test = "tests.test_wallet:test" \ No newline at end of file From a044fcdc2cd65568c1e36a63c67b428fbb2cc359 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 28 Sep 2022 18:00:51 +0200 Subject: [PATCH 11/11] fix command in setup.py --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index d8a0317..b7176cc 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ with open(path.join(this_directory, "README.md"), encoding="utf-8") as f: with open("requirements.txt") as f: requirements = f.read().splitlines() -entry_points = {"console_scripts": ["cashu = cahu.wallet.cashu:cli"]} +entry_points = {"console_scripts": ["cashu = cahu.wallet.cli:cli"]} setuptools.setup( name="cashu",