From f5516241321c02157b8b9619cbdbc90c8865b2fe Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 25 Aug 2023 23:50:16 +0200 Subject: [PATCH 01/10] [Wallet] Refactor `restore_promises_from_to` (#307) * refactor restore_promises_from_to * fix mypy * black * fix tests --- cashu/wallet/api/router.py | 8 +++---- cashu/wallet/wallet.py | 46 +++++++++++++++++++++++++++++--------- tests/test_wallet.py | 16 ++++++------- 3 files changed, 48 insertions(+), 22 deletions(-) diff --git a/cashu/wallet/api/router.py b/cashu/wallet/api/router.py index 0b96fa81..63820b9e 100644 --- a/cashu/wallet/api/router.py +++ b/cashu/wallet/api/router.py @@ -282,9 +282,9 @@ async def burn( wallet = await mint_wallet(mint) if not (all or token or force or delete) or (token and all): raise Exception( - "enter a token or use --all to burn all pending tokens, --force to check" - " all tokensor --delete with send ID to force-delete pending token from" - " list if mint is unavailable.", + "enter a token or use --all to burn all pending tokens, --force to" + " check all tokensor --delete with send ID to force-delete pending" + " token from list if mint is unavailable.", ) if all: # check only those who are flagged as reserved @@ -414,7 +414,7 @@ async def restore( if to < 0: raise Exception("Counter must be positive") await wallet.load_mint() - await wallet.restore_promises(0, to) + await wallet.restore_promises_from_to(0, to) await wallet.invalidate(wallet.proofs) wallet.status() return RestoreResponse(balance=wallet.available_balance) diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 8926ffa7..69a40b50 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -1655,7 +1655,7 @@ async def restore_wallet_from_mnemonic( n_last_restored_proofs = 0 while stop_counter < to: print(f"Restoring token {i} to {i + batch}...") - restored_proofs = await self.restore_promises(i, i + batch - 1) + restored_proofs = await self.restore_promises_from_to(i, i + batch - 1) if len(restored_proofs) == 0: stop_counter += 1 spendable_proofs = await self.invalidate(restored_proofs) @@ -1679,7 +1679,9 @@ async def restore_wallet_from_mnemonic( print("No tokens restored.") return - async def restore_promises(self, from_counter: int, to_counter: int) -> List[Proof]: + async def restore_promises_from_to( + self, from_counter: int, to_counter: int + ) -> List[Proof]: """Restores promises from a given range of counters. This is for restoring a wallet from a mnemonic. Args: @@ -1698,14 +1700,42 @@ async def restore_promises(self, from_counter: int, to_counter: int) -> List[Pro # we generate outptus from deterministic secrets and rs regenerated_outputs, _ = self._construct_outputs(amounts_dummy, secrets, rs) # we ask the mint to reissue the promises - # restored_outputs is there so we can match the promises to the secrets and rs - restored_outputs, restored_promises = await super().restore_promises( - regenerated_outputs + proofs = await self.restore_promises( + outputs=regenerated_outputs, + secrets=secrets, + rs=rs, + derivation_paths=derivation_paths, + ) + + await set_secret_derivation( + db=self.db, keyset_id=self.keyset_id, counter=to_counter + 1 ) + return proofs + + async def restore_promises( + self, + outputs: List[BlindedMessage], + secrets: List[str], + rs: List[PrivateKey], + derivation_paths: List[str], + ) -> List[Proof]: + """Restores proofs from a list of outputs, secrets, rs and derivation paths. + + Args: + outputs (List[BlindedMessage]): Outputs for which we request promises + secrets (List[str]): Secrets generated for the outputs + rs (List[PrivateKey]): Random blinding factors generated for the outputs + derivation_paths (List[str]): Derivation paths for the secrets + + Returns: + List[Proof]: List of restored proofs + """ + # restored_outputs is there so we can match the promises to the secrets and rs + restored_outputs, restored_promises = await super().restore_promises(outputs) # now we need to filter out the secrets and rs that had a match matching_indices = [ idx - for idx, val in enumerate(regenerated_outputs) + for idx, val in enumerate(outputs) if val.B_ in [o.B_ for o in restored_outputs] ] secrets = [secrets[i] for i in matching_indices] @@ -1721,8 +1751,4 @@ async def restore_promises(self, from_counter: int, to_counter: int) -> List[Pro for proof in proofs: if proof.secret not in [p.secret for p in self.proofs]: self.proofs.append(proof) - - await set_secret_derivation( - db=self.db, keyset_id=self.keyset_id, counter=to_counter + 1 - ) return proofs diff --git a/tests/test_wallet.py b/tests/test_wallet.py index 9396ca52..f4ec78b6 100644 --- a/tests/test_wallet.py +++ b/tests/test_wallet.py @@ -387,7 +387,7 @@ async def test_restore_wallet_after_mint(wallet3: Wallet): await wallet3.load_proofs() wallet3.proofs = [] assert wallet3.balance == 0 - await wallet3.restore_promises(0, 20) + await wallet3.restore_promises_from_to(0, 20) assert wallet3.balance == 64 @@ -419,7 +419,7 @@ async def test_restore_wallet_after_split_to_send(wallet3: Wallet): await wallet3.load_proofs() wallet3.proofs = [] assert wallet3.balance == 0 - await wallet3.restore_promises(0, 100) + await wallet3.restore_promises_from_to(0, 100) assert wallet3.balance == 64 * 2 await wallet3.invalidate(wallet3.proofs) assert wallet3.balance == 64 @@ -443,7 +443,7 @@ async def test_restore_wallet_after_send_and_receive(wallet3: Wallet, wallet2: W await wallet3.load_proofs(reload=True) assert wallet3.proofs == [] assert wallet3.balance == 0 - await wallet3.restore_promises(0, 100) + await wallet3.restore_promises_from_to(0, 100) assert wallet3.balance == 64 + 2 * 32 await wallet3.invalidate(wallet3.proofs) assert wallet3.balance == 32 @@ -482,7 +482,7 @@ async def test_restore_wallet_after_send_and_self_receive(wallet3: Wallet): await wallet3.load_proofs(reload=True) assert wallet3.proofs == [] assert wallet3.balance == 0 - await wallet3.restore_promises(0, 100) + await wallet3.restore_promises_from_to(0, 100) assert wallet3.balance == 64 + 2 * 32 + 32 await wallet3.invalidate(wallet3.proofs) assert wallet3.balance == 64 @@ -512,7 +512,7 @@ async def test_restore_wallet_after_send_twice( await wallet3.load_proofs(reload=True) assert wallet3.proofs == [] assert wallet3.balance == 0 - await wallet3.restore_promises(0, 10) + await wallet3.restore_promises_from_to(0, 10) box.add(wallet3.proofs) assert wallet3.balance == 5 await wallet3.invalidate(wallet3.proofs) @@ -532,7 +532,7 @@ async def test_restore_wallet_after_send_twice( await wallet3.load_proofs(reload=True) assert wallet3.proofs == [] assert wallet3.balance == 0 - await wallet3.restore_promises(0, 15) + await wallet3.restore_promises_from_to(0, 15) box.add(wallet3.proofs) assert wallet3.balance == 7 await wallet3.invalidate(wallet3.proofs) @@ -565,7 +565,7 @@ async def test_restore_wallet_after_send_and_self_receive_nonquadratic_value( await wallet3.load_proofs(reload=True) assert wallet3.proofs == [] assert wallet3.balance == 0 - await wallet3.restore_promises(0, 20) + await wallet3.restore_promises_from_to(0, 20) box.add(wallet3.proofs) assert wallet3.balance == 138 await wallet3.invalidate(wallet3.proofs) @@ -583,7 +583,7 @@ async def test_restore_wallet_after_send_and_self_receive_nonquadratic_value( await wallet3.load_proofs(reload=True) assert wallet3.proofs == [] assert wallet3.balance == 0 - await wallet3.restore_promises(0, 50) + await wallet3.restore_promises_from_to(0, 50) assert wallet3.balance == 182 await wallet3.invalidate(wallet3.proofs) assert wallet3.balance == 64 From 87c0adc60d66357d324b257bc2f7a336e8a102c3 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 25 Aug 2023 23:51:20 +0200 Subject: [PATCH 02/10] update requirements.txt (#308) --- requirements.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index f86f9607..d13ce215 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ cffi==1.15.1 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" charset-normalizer==3.2.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" click==8.1.7 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" coincurve==18.0.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" -colorama==0.4.6 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" and (platform_system == "Windows" or sys_platform == "win32") +colorama==0.4.6 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" and platform_system == "Windows" or python_full_version >= "3.8.1" and python_full_version < "4.0.0" and sys_platform == "win32" cryptography==41.0.3 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" ecdsa==0.18.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" environs==9.5.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" @@ -44,7 +44,7 @@ starlette==0.27.0 ; python_full_version >= "3.8.1" and python_full_version < "4. typing-extensions==4.7.1 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" urllib3==2.0.4 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" uvicorn==0.18.3 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" -websocket-client==1.6.1 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" -wheel==0.41.1 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" +websocket-client==1.6.2 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" +wheel==0.41.2 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" win32-setctime==1.1.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" and sys_platform == "win32" zipp==3.16.2 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" From 75e8428af7af4f10ec01e4a124ff787d6e2f612b Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 8 Sep 2023 15:21:14 +0200 Subject: [PATCH 03/10] Mint/add_cors_to_error_response (#312) * add cors to error response and log validation errors * shorten hash for invoices to avoid base64 escape characters --- cashu/core/crypto/keys.py | 2 +- cashu/mint/app.py | 36 ++++++++++++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/cashu/core/crypto/keys.py b/cashu/core/crypto/keys.py index 9993a91b..fcf8c9f0 100644 --- a/cashu/core/crypto/keys.py +++ b/cashu/core/crypto/keys.py @@ -58,5 +58,5 @@ def derive_keyset_id(keys: Dict[int, PublicKey]): def random_hash() -> str: """Returns a base64-urlsafe encoded random hash.""" return base64.urlsafe_b64encode( - bytes([random.getrandbits(8) for i in range(32)]) + bytes([random.getrandbits(8) for i in range(30)]) ).decode() diff --git a/cashu/mint/app.py b/cashu/mint/app.py index 078a939f..895945e8 100644 --- a/cashu/mint/app.py +++ b/cashu/mint/app.py @@ -3,6 +3,10 @@ from traceback import print_exception from fastapi import FastAPI, status +from fastapi.exception_handlers import ( + request_validation_exception_handler as _request_validation_exception_handler, +) +from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse # from fastapi_profiler import PyInstrumentProfilerMiddleware @@ -114,6 +118,12 @@ def emit(self, record): @app.middleware("http") async def catch_exceptions(request: Request, call_next): + CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "*", + "Access-Control-Allow-Headers": "*", + "Access-Control-Allow-Credentials": "true", + } try: return await call_next(request) except Exception as e: @@ -124,22 +134,44 @@ async def catch_exceptions(request: Request, call_next): if isinstance(e, CashuError): logger.error(f"CashuError: {err_message}") + # return with cors headers return JSONResponse( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + status_code=status.HTTP_400_BAD_REQUEST, content={"detail": err_message, "code": e.code}, + headers=CORS_HEADERS, ) logger.error(f"Exception: {err_message}") if settings.debug: print_exception(*sys.exc_info()) return JSONResponse( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + status_code=status.HTTP_400_BAD_REQUEST, content={"detail": err_message, "code": 0}, + headers=CORS_HEADERS, ) +async def request_validation_exception_handler( + request: Request, exc: RequestValidationError +) -> JSONResponse: + """ + This is a wrapper to the default RequestValidationException handler of FastAPI. + This function will be called when client input is not valid. + """ + query_params = request.query_params._dict + detail = { + "errors": exc.errors(), + "query_params": query_params, + } + # log the error + logger.error(detail) + # pass on + return await _request_validation_exception_handler(request, exc) + + @app.on_event("startup") async def startup_mint(): await start_mint_init() app.include_router(router=router) +app.add_exception_handler(RequestValidationError, request_validation_exception_handler) From 5e001548fe3975df54717e277d8a2f6ad32663a7 Mon Sep 17 00:00:00 2001 From: dyKiU <127620445+dyKiU@users.noreply.github.com> Date: Fri, 8 Sep 2023 15:42:01 +0100 Subject: [PATCH 04/10] load the proofs or we get a zero balance (#310) (cherry picked from commit 0e058fa69f53689c7d9d37f02b4f5a62c58f03e7) --- cashu/wallet/api/router.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cashu/wallet/api/router.py b/cashu/wallet/api/router.py index 63820b9e..1f6fcaee 100644 --- a/cashu/wallet/api/router.py +++ b/cashu/wallet/api/router.py @@ -88,6 +88,7 @@ async def pay( global wallet wallet = await mint_wallet(mint) + await wallet.load_proofs(reload=True) total_amount, fee_reserve_sat = await wallet.get_pay_amount_with_fees(invoice) assert total_amount > 0, "amount has to be larger than zero." From a1802b2d811f5fb5ae8022b371d1f5b8d0b64323 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Thu, 21 Sep 2023 14:58:42 +0200 Subject: [PATCH 05/10] Refactor: P2PKSecret (#323) * port changes from dleq branch * adjust tests * fix tag serialization * refactor p2pk and secrets into a new Protocol class * clean up code * refactor p2pk types from base * test second refund pubkey --- cashu/core/base.py | 144 +----------- cashu/core/p2pk.py | 139 +++++++++++ cashu/mint/ledger.py | 36 +-- cashu/mint/main.py | 2 +- cashu/wallet/p2pk.py | 262 +++++++++++++++++++++ cashu/wallet/protocols.py | 16 ++ cashu/wallet/secrets.py | 198 ++++++++++++++++ cashu/wallet/wallet.py | 476 +++----------------------------------- tests/test_wallet_p2pk.py | 63 +++-- 9 files changed, 727 insertions(+), 609 deletions(-) create mode 100644 cashu/wallet/p2pk.py create mode 100644 cashu/wallet/protocols.py create mode 100644 cashu/wallet/secrets.py diff --git a/cashu/core/base.py b/cashu/core/base.py index 525b4b65..7978d962 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -1,8 +1,7 @@ import base64 import json -import time from sqlite3 import Row -from typing import Any, Dict, List, Optional, Union +from typing import Dict, List, Optional, Union from loguru import logger from pydantic import BaseModel @@ -10,150 +9,11 @@ from .crypto.keys import derive_keys, derive_keyset_id, derive_pubkeys from .crypto.secp import PrivateKey, PublicKey from .legacy import derive_keys_backwards_compatible_insecure_pre_0_12 - -# from .p2pk import sign_p2pk_sign +from .p2pk import P2SHScript # ------- PROOFS ------- -class SecretKind: - P2SH = "P2SH" - P2PK = "P2PK" - - -class SigFlags: - SIG_INPUTS = ( # require signatures only on the inputs (default signature flag) - "SIG_INPUTS" - ) - SIG_ALL = "SIG_ALL" # require signatures on inputs and outputs - - -class Tags(BaseModel): - """ - Tags are used to encode additional information in the Secret of a Proof. - """ - - __root__: List[List[str]] = [] - - def __init__(self, tags: Optional[List[List[str]]] = None, **kwargs): - super().__init__(**kwargs) - self.__root__ = tags or [] - - def __setitem__(self, key: str, value: str) -> None: - self.__root__.append([key, value]) - - def __getitem__(self, key: str) -> Union[str, None]: - return self.get_tag(key) - - def get_tag(self, tag_name: str) -> Union[str, None]: - for tag in self.__root__: - if tag[0] == tag_name: - return tag[1] - return None - - def get_tag_all(self, tag_name: str) -> List[str]: - all_tags = [] - for tag in self.__root__: - if tag[0] == tag_name: - all_tags.append(tag[1]) - return all_tags - - -class Secret(BaseModel): - """Describes spending condition encoded in the secret field of a Proof.""" - - kind: str - data: str - nonce: Union[None, str] = None - tags: Union[None, Tags] = None - - def serialize(self) -> str: - data_dict: Dict[str, Any] = { - "data": self.data, - "nonce": self.nonce or PrivateKey().serialize()[:32], - } - if self.tags and self.tags.__root__: - logger.debug(f"Serializing tags: {self.tags.__root__}") - data_dict["tags"] = self.tags.__root__ - return json.dumps( - [self.kind, data_dict], - ) - - @classmethod - def deserialize(cls, from_proof: str): - kind, kwargs = json.loads(from_proof) - data = kwargs.pop("data") - nonce = kwargs.pop("nonce") - tags_list = kwargs.pop("tags", None) - if tags_list: - tags = Tags(tags=tags_list) - else: - tags = None - logger.debug(f"Deserialized Secret: {kind}, {data}, {nonce}, {tags}") - return cls(kind=kind, data=data, nonce=nonce, tags=tags) - - @property - def locktime(self) -> Union[None, int]: - if self.tags: - locktime = self.tags.get_tag("locktime") - if locktime: - return int(locktime) - return None - - @property - def sigflag(self) -> Union[None, str]: - if self.tags: - sigflag = self.tags.get_tag("sigflag") - if sigflag: - return sigflag - return None - - @property - def n_sigs(self) -> Union[None, int]: - if self.tags: - n_sigs = self.tags.get_tag("n_sigs") - if n_sigs: - return int(n_sigs) - return None - - def get_p2pk_pubkey_from_secret(self) -> List[str]: - """Gets the P2PK pubkey from a Secret depending on the locktime - - Args: - secret (Secret): P2PK Secret in ecash token - - Returns: - str: pubkey to use for P2PK, empty string if anyone can spend (locktime passed) - """ - pubkeys: List[str] = [self.data] # for now we only support one pubkey - # get all additional pubkeys from tags for multisig - if self.tags and self.tags.get_tag("pubkey"): - pubkeys += self.tags.get_tag_all("pubkey") - - now = time.time() - if self.locktime and self.locktime < now: - logger.trace(f"p2pk locktime ran out ({self.locktime}<{now}).") - # check tags if a refund pubkey is present. - # If yes, we demand the signature to be from the refund pubkey - if self.tags: - refund_pubkey = self.tags.get_tag("refund") - if refund_pubkey: - pubkeys = [refund_pubkey] - return pubkeys - return [] - return pubkeys - - -class P2SHScript(BaseModel): - """ - Unlocks P2SH spending condition of a Proof - """ - - script: str - signature: str - address: Union[str, None] = None - - class Proof(BaseModel): """ Value token diff --git a/cashu/core/p2pk.py b/cashu/core/p2pk.py index e48fe030..0d97b345 100644 --- a/cashu/core/p2pk.py +++ b/cashu/core/p2pk.py @@ -1,8 +1,147 @@ import hashlib +import json +import time +from typing import Any, Dict, List, Optional, Union + +from loguru import logger +from pydantic import BaseModel from .crypto.secp import PrivateKey, PublicKey +class SecretKind: + P2SH = "P2SH" + P2PK = "P2PK" + + +class SigFlags: + SIG_INPUTS = ( # require signatures only on the inputs (default signature flag) + "SIG_INPUTS" + ) + SIG_ALL = "SIG_ALL" # require signatures on inputs and outputs + + +class Tags(BaseModel): + """ + Tags are used to encode additional information in the Secret of a Proof. + """ + + __root__: List[List[str]] = [] + + def __init__(self, tags: Optional[List[List[str]]] = None, **kwargs): + super().__init__(**kwargs) + self.__root__ = tags or [] + + def __setitem__(self, key: str, value: str) -> None: + self.__root__.append([key, value]) + + def __getitem__(self, key: str) -> Union[str, None]: + return self.get_tag(key) + + def get_tag(self, tag_name: str) -> Union[str, None]: + for tag in self.__root__: + if tag[0] == tag_name: + return tag[1] + return None + + def get_tag_all(self, tag_name: str) -> List[str]: + all_tags = [] + for tag in self.__root__: + if tag[0] == tag_name: + for t in tag[1:]: + all_tags.append(t) + return all_tags + + +class Secret(BaseModel): + """Describes spending condition encoded in the secret field of a Proof.""" + + kind: str + data: str + tags: Tags + nonce: Union[None, str] = None + + def serialize(self) -> str: + data_dict: Dict[str, Any] = { + "data": self.data, + "nonce": self.nonce or PrivateKey().serialize()[:32], + } + if self.tags.__root__: + logger.debug(f"Serializing tags: {self.tags.__root__}") + data_dict["tags"] = self.tags.__root__ + return json.dumps( + [self.kind, data_dict], + ) + + @classmethod + def deserialize(cls, from_proof: str): + kind, kwargs = json.loads(from_proof) + data = kwargs.pop("data") + nonce = kwargs.pop("nonce") + tags_list: List = kwargs.pop("tags", None) + tags = Tags(tags=tags_list) + logger.debug(f"Deserialized Secret: {kind}, {data}, {nonce}, {tags}") + return cls(kind=kind, data=data, nonce=nonce, tags=tags) + + +class P2PKSecret(Secret): + @classmethod + def from_secret(cls, secret: Secret): + assert secret.kind == SecretKind.P2PK, "Secret is not a P2PK secret" + # NOTE: exclude tags in .dict() because it doesn't deserialize it properly + # need to add it back in manually with tags=secret.tags + return cls(**secret.dict(exclude={"tags"}), tags=secret.tags) + + def get_p2pk_pubkey_from_secret(self) -> List[str]: + """Gets the P2PK pubkey from a Secret depending on the locktime + + Args: + secret (Secret): P2PK Secret in ecash token + + Returns: + str: pubkey to use for P2PK, empty string if anyone can spend (locktime passed) + """ + # the pubkey in the data field is the pubkey to use for P2PK + pubkeys: List[str] = [self.data] + + # get all additional pubkeys from tags for multisig + pubkeys += self.tags.get_tag_all("pubkeys") + + # check if locktime is passed and if so, only return refund pubkeys + now = time.time() + if self.locktime and self.locktime < now: + logger.trace(f"p2pk locktime ran out ({self.locktime}<{now}).") + # check tags if a refund pubkey is present. + # If yes, we demand the signature to be from the refund pubkey + return self.tags.get_tag_all("refund") + + return pubkeys + + @property + def locktime(self) -> Union[None, int]: + locktime = self.tags.get_tag("locktime") + return int(locktime) if locktime else None + + @property + def sigflag(self) -> Union[None, str]: + return self.tags.get_tag("sigflag") + + @property + def n_sigs(self) -> Union[None, int]: + n_sigs = self.tags.get_tag("n_sigs") + return int(n_sigs) if n_sigs else None + + +class P2SHScript(BaseModel): + """ + Unlocks P2SH spending condition of a Proof + """ + + script: str + signature: str + address: Union[str, None] = None + + def sign_p2pk_sign(message: bytes, private_key: PrivateKey): # ecdsa version # signature = private_key.ecdsa_serialize(private_key.ecdsa_sign(message)) diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 8f972d58..d508029b 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -13,9 +13,6 @@ MintKeyset, MintKeysets, Proof, - Secret, - SecretKind, - SigFlags, ) from ..core.crypto import b_dhke from ..core.crypto.keys import derive_pubkey, random_hash @@ -33,7 +30,13 @@ TransactionError, ) from ..core.helpers import fee_reserve, sum_proofs -from ..core.p2pk import verify_p2pk_signature +from ..core.p2pk import ( + P2PKSecret, + Secret, + SecretKind, + SigFlags, + verify_p2pk_signature, +) from ..core.script import verify_bitcoin_script from ..core.settings import settings from ..core.split import amount_split @@ -254,12 +257,13 @@ def _verify_input_spending_conditions(self, proof: Proof) -> bool: # secret is not a spending condition so we treat is a normal secret return True if secret.kind == SecretKind.P2SH: + p2pk_secret = P2PKSecret.from_secret(secret) # check if locktime is in the past now = time.time() - if secret.locktime and secret.locktime < now: - logger.trace(f"p2sh locktime ran out ({secret.locktime}<{now}).") + if p2pk_secret.locktime and p2pk_secret.locktime < now: + logger.trace(f"p2sh locktime ran out ({p2pk_secret.locktime}<{now}).") return True - logger.trace(f"p2sh locktime still active ({secret.locktime}>{now}).") + logger.trace(f"p2sh locktime still active ({p2pk_secret.locktime}>{now}).") if ( proof.p2shscript is None @@ -284,8 +288,9 @@ def _verify_input_spending_conditions(self, proof: Proof) -> bool: # P2PK if secret.kind == SecretKind.P2PK: + p2pk_secret = P2PKSecret.from_secret(secret) # check if locktime is in the past - pubkeys = secret.get_p2pk_pubkey_from_secret() + pubkeys = p2pk_secret.get_p2pk_pubkey_from_secret() assert len(set(pubkeys)) == len(pubkeys), "pubkeys must be unique." logger.trace(f"pubkeys: {pubkeys}") # we will get an empty list if the locktime has passed and no refund pubkey is present @@ -307,7 +312,7 @@ def _verify_input_spending_conditions(self, proof: Proof) -> bool: # INPUTS: check signatures proof.p2pksigs against pubkey # we expect the signature to be on the pubkey (=message) itself - n_sigs_required = secret.n_sigs or 1 + n_sigs_required = p2pk_secret.n_sigs or 1 assert n_sigs_required > 0, "n_sigs must be positive." # check if enough signatures are present @@ -321,9 +326,9 @@ def _verify_input_spending_conditions(self, proof: Proof) -> bool: for input_sig in proof.p2pksigs: for pubkey in pubkeys: logger.trace(f"verifying signature {input_sig} by pubkey {pubkey}.") - logger.trace(f"Message: {secret.serialize().encode('utf-8')}") + logger.trace(f"Message: {p2pk_secret.serialize().encode('utf-8')}") if verify_p2pk_signature( - message=secret.serialize().encode("utf-8"), + message=p2pk_secret.serialize().encode("utf-8"), pubkey=PublicKey(bytes.fromhex(pubkey), raw=True), signature=bytes.fromhex(input_sig), ): @@ -370,7 +375,7 @@ def _verify_output_spending_conditions( n_sigs = [] for proof in proofs: try: - secret = Secret.deserialize(proof.secret) + secret = P2PKSecret.deserialize(proof.secret) # get all p2pk pubkeys from secrets pubkeys_per_proof.append(secret.get_p2pk_pubkey_from_secret()) # get signature threshold from secrets @@ -403,7 +408,10 @@ def _verify_output_spending_conditions( # now we check if any of the secrets has sigflag==SIG_ALL if not any( - [Secret.deserialize(p.secret).sigflag == SigFlags.SIG_ALL for p in proofs] + [ + P2PKSecret.deserialize(p.secret).sigflag == SigFlags.SIG_ALL + for p in proofs + ] ): # no secret has sigflag==SIG_ALL return True @@ -798,7 +806,7 @@ async def _generate_change_promises( return_amounts_sorted = sorted(return_amounts, reverse=True) # we need to imprint these amounts into the blanket outputs for i in range(len(outputs)): - outputs[i].amount = return_amounts_sorted[i] + outputs[i].amount = return_amounts_sorted[i] # type: ignore if not self._verify_no_duplicate_outputs(outputs): raise TransactionError("duplicate promises.") return_promises = await self._generate_promises(outputs, keyset) diff --git a/cashu/mint/main.py b/cashu/mint/main.py index a279d93b..999863d4 100644 --- a/cashu/mint/main.py +++ b/cashu/mint/main.py @@ -47,7 +47,7 @@ def main( host=host, ssl_keyfile=ssl_keyfile, ssl_certfile=ssl_certfile, - **d, + **d, # type: ignore ) server = uvicorn.Server(config) server.run() diff --git a/cashu/wallet/p2pk.py b/cashu/wallet/p2pk.py new file mode 100644 index 00000000..ae95eb31 --- /dev/null +++ b/cashu/wallet/p2pk.py @@ -0,0 +1,262 @@ +import base64 +from datetime import datetime, timedelta +from typing import List, Optional + +from loguru import logger + +from ..core import bolt11 as bolt11 +from ..core.base import ( + BlindedMessage, + Proof, +) +from ..core.crypto.secp import PrivateKey +from ..core.db import Database +from ..core.p2pk import ( + P2PKSecret, + P2SHScript, + Secret, + SecretKind, + SigFlags, + Tags, + sign_p2pk_sign, +) +from ..core.script import ( + step0_carol_checksig_redeemscrip, + step0_carol_privkey, + step1_carol_create_p2sh_address, + step2_carol_sign_tx, +) +from ..wallet.crud import ( + get_unused_locks, + store_p2sh, +) +from .protocols import SupportsDb, SupportsPrivateKey + + +class WalletP2PK(SupportsPrivateKey, SupportsDb): + db: Database + private_key: Optional[PrivateKey] = None + # ---------- P2SH and P2PK ---------- + + async def create_p2sh_address_and_store(self) -> str: + """Creates a P2SH lock script and stores the script and signature in the database.""" + alice_privkey = step0_carol_privkey() + txin_redeemScript = step0_carol_checksig_redeemscrip(alice_privkey.pub) + txin_p2sh_address = step1_carol_create_p2sh_address(txin_redeemScript) + txin_signature = step2_carol_sign_tx(txin_redeemScript, alice_privkey).scriptSig + txin_redeemScript_b64 = base64.urlsafe_b64encode(txin_redeemScript).decode() + txin_signature_b64 = base64.urlsafe_b64encode(txin_signature).decode() + p2shScript = P2SHScript( + script=txin_redeemScript_b64, + signature=txin_signature_b64, + address=str(txin_p2sh_address), + ) + await store_p2sh(p2shScript, db=self.db) + assert p2shScript.address + return p2shScript.address + + async def create_p2pk_pubkey(self): + assert ( + self.private_key + ), "No private key set in settings. Set NOSTR_PRIVATE_KEY in .env" + public_key = self.private_key.pubkey + # logger.debug(f"Private key: {self.private_key.bech32()}") + assert public_key + return public_key.serialize().hex() + + async def create_p2pk_lock( + self, + pubkey: str, + locktime_seconds: Optional[int] = None, + tags: Optional[Tags] = None, + sig_all: bool = False, + n_sigs: int = 1, + ) -> P2PKSecret: + logger.debug(f"Provided tags: {tags}") + if not tags: + tags = Tags() + logger.debug(f"Before tags: {tags}") + if locktime_seconds: + tags["locktime"] = str( + int((datetime.now() + timedelta(seconds=locktime_seconds)).timestamp()) + ) + tags["sigflag"] = SigFlags.SIG_ALL if sig_all else SigFlags.SIG_INPUTS + if n_sigs > 1: + tags["n_sigs"] = str(n_sigs) + logger.debug(f"After tags: {tags}") + return P2PKSecret( + kind=SecretKind.P2PK, + data=pubkey, + tags=tags, + ) + + async def create_p2sh_lock( + self, + address: str, + locktime: Optional[int] = None, + tags: Tags = Tags(), + ) -> Secret: + if locktime: + tags["locktime"] = str( + (datetime.now() + timedelta(seconds=locktime)).timestamp() + ) + + return Secret( + kind=SecretKind.P2SH, + data=address, + tags=tags, + ) + + async def sign_p2pk_proofs(self, proofs: List[Proof]) -> List[str]: + assert ( + self.private_key + ), "No private key set in settings. Set NOSTR_PRIVATE_KEY in .env" + private_key = self.private_key + assert private_key.pubkey + logger.trace( + f"Signing with private key: {private_key.serialize()} public key:" + f" {private_key.pubkey.serialize().hex()}" + ) + for proof in proofs: + logger.trace(f"Signing proof: {proof}") + logger.trace(f"Signing message: {proof.secret}") + + signatures = [ + sign_p2pk_sign( + message=proof.secret.encode("utf-8"), + private_key=private_key, + ) + for proof in proofs + ] + logger.debug(f"Signatures: {signatures}") + return signatures + + async def sign_p2pk_outputs(self, outputs: List[BlindedMessage]) -> List[str]: + assert ( + self.private_key + ), "No private key set in settings. Set NOSTR_PRIVATE_KEY in .env" + private_key = self.private_key + assert private_key.pubkey + return [ + sign_p2pk_sign( + message=output.B_.encode("utf-8"), + private_key=private_key, + ) + for output in outputs + ] + + async def add_p2pk_witnesses_to_outputs( + self, outputs: List[BlindedMessage] + ) -> List[BlindedMessage]: + """Takes a list of outputs and adds a P2PK signatures to each. + Args: + outputs (List[BlindedMessage]): Outputs to add P2PK signatures to + Returns: + List[BlindedMessage]: Outputs with P2PK signatures added + """ + p2pk_signatures = await self.sign_p2pk_outputs(outputs) + for o, s in zip(outputs, p2pk_signatures): + o.p2pksigs = [s] + return outputs + + async def add_witnesses_to_outputs( + self, proofs: List[Proof], outputs: List[BlindedMessage] + ) -> List[BlindedMessage]: + """Adds witnesses to outputs if the inputs (proofs) indicate an appropriate signature flag + + Args: + proofs (List[Proof]): Inputs to the transaction + outputs (List[BlindedMessage]): Outputs to add witnesses to + Returns: + List[BlindedMessage]: Outputs with signatures added + """ + # first we check whether all tokens have serialized secrets as their secret + try: + for p in proofs: + Secret.deserialize(p.secret) + except Exception: + # if not, we do not add witnesses (treat as regular token secret) + return outputs + + # if any of the proofs provided require SIG_ALL, we must provide it + if any( + [ + P2PKSecret.deserialize(p.secret).sigflag == SigFlags.SIG_ALL + for p in proofs + ] + ): + outputs = await self.add_p2pk_witnesses_to_outputs(outputs) + return outputs + + async def add_p2sh_witnesses_to_proofs( + self: SupportsDb, proofs: List[Proof] + ) -> List[Proof]: + # Quirk: we use a single P2SH script and signature pair for all tokens in proofs + address = Secret.deserialize(proofs[0].secret).data + p2shscripts = await get_unused_locks(address, db=self.db) + assert len(p2shscripts) == 1, Exception("lock not found.") + p2sh_script, p2sh_signature = ( + p2shscripts[0].script, + p2shscripts[0].signature, + ) + logger.debug(f"Unlock script: {p2sh_script} signature: {p2sh_signature}") + + # attach unlock scripts to proofs + for p in proofs: + p.p2shscript = P2SHScript(script=p2sh_script, signature=p2sh_signature) + return proofs + + async def add_p2pk_witnesses_to_proofs(self, proofs: List[Proof]) -> List[Proof]: + p2pk_signatures = await self.sign_p2pk_proofs(proofs) + logger.debug(f"Unlock signatures for {len(proofs)} proofs: {p2pk_signatures}") + logger.debug(f"Proofs: {proofs}") + # attach unlock signatures to proofs + assert len(proofs) == len(p2pk_signatures), "wrong number of signatures" + for p, s in zip(proofs, p2pk_signatures): + if p.p2pksigs: + p.p2pksigs.append(s) + else: + p.p2pksigs = [s] + return proofs + + async def add_witnesses_to_proofs(self, proofs: List[Proof]) -> List[Proof]: + """Adds witnesses to proofs for P2SH or P2PK redemption. + + This method parses the secret of each proof and determines the correct + witness type and adds it to the proof if we have it available. + + Note: In order for this method to work, all proofs must have the same secret type. + This is because we use a single P2SH script and signature pair for all tokens in proofs. + + For P2PK, we use an individual signature for each token in proofs. + + Args: + proofs (List[Proof]): List of proofs to add witnesses to + + Returns: + List[Proof]: List of proofs with witnesses added + """ + + # iterate through proofs and produce witnesses for each + + # first we check whether all tokens have serialized secrets as their secret + try: + for p in proofs: + Secret.deserialize(p.secret) + except Exception: + # if not, we do not add witnesses (treat as regular token secret) + return proofs + logger.debug("Spending conditions detected.") + # P2SH scripts + if all([Secret.deserialize(p.secret).kind == SecretKind.P2SH for p in proofs]): + logger.debug("P2SH redemption detected.") + proofs = await self.add_p2sh_witnesses_to_proofs(proofs) + + # P2PK signatures + elif all( + [Secret.deserialize(p.secret).kind == SecretKind.P2PK for p in proofs] + ): + logger.debug("P2PK redemption detected.") + proofs = await self.add_p2pk_witnesses_to_proofs(proofs) + + return proofs diff --git a/cashu/wallet/protocols.py b/cashu/wallet/protocols.py new file mode 100644 index 00000000..effb430b --- /dev/null +++ b/cashu/wallet/protocols.py @@ -0,0 +1,16 @@ +from typing import Protocol + +from ..core.crypto.secp import PrivateKey +from ..core.db import Database + + +class SupportsPrivateKey(Protocol): + private_key: PrivateKey + + +class SupportsDb(Protocol): + db: Database + + +class SupportsKeysets(Protocol): + keyset_id: str diff --git a/cashu/wallet/secrets.py b/cashu/wallet/secrets.py new file mode 100644 index 00000000..7c5342a2 --- /dev/null +++ b/cashu/wallet/secrets.py @@ -0,0 +1,198 @@ +import base64 +import hashlib +from typing import List, Optional, Tuple + +from bip32 import BIP32 +from loguru import logger +from mnemonic import Mnemonic + +from ..core import bolt11 as bolt11 +from ..core.crypto.secp import PrivateKey +from ..core.db import Database +from ..core.settings import settings +from ..wallet.crud import ( + bump_secret_derivation, + get_seed_and_mnemonic, + store_seed_and_mnemonic, +) +from .protocols import SupportsDb, SupportsKeysets + + +class WalletSecrets(SupportsDb, SupportsKeysets): + keyset_id: str + db: Database + + async def _init_private_key(self, from_mnemonic: Optional[str] = None) -> None: + """Initializes the private key of the wallet from the mnemonic. + There are three ways to initialize the private key: + 1. If the database does not contain a seed, and no mnemonic is given, a new seed is generated. + 2. If the database does not contain a seed, and a mnemonic is given, the seed is generated from the mnemonic. + 3. If the database contains a seed, the seed is loaded from the database. + + If the mnemonic was not loaded from the database, the seed and mnemonic are stored in the database. + + Args: + from_mnemonic (Optional[str], optional): Mnemonic to use. Defaults to None. + + Raises: + ValueError: If the mnemonic is not BIP39 compliant. + """ + ret_db = await get_seed_and_mnemonic(self.db) + + mnemo = Mnemonic("english") + + if ret_db is None and from_mnemonic is None: + # if there is no seed in the database, generate a new one + mnemonic_str = mnemo.generate() + wallet_command_prefix_str = ( + f" --wallet {settings.wallet_name}" + if settings.wallet_name != "wallet" + else "" + ) + wallet_name = ( + f' for wallet "{settings.wallet_name}"' + if settings.wallet_name != "wallet" + else "" + ) + print( + f"Generated a new mnemonic{wallet_name}. To view it, run" + f' "cashu{wallet_command_prefix_str} info --mnemonic".' + ) + elif from_mnemonic: + # or use the one provided + mnemonic_str = from_mnemonic.lower().strip() + elif ret_db is not None: + # if there is a seed in the database, use it + _, mnemonic_str = ret_db[0], ret_db[1] + else: + logger.debug("No mnemonic provided") + return + + if not mnemo.check(mnemonic_str): + raise ValueError("Invalid mnemonic") + + self.seed = mnemo.to_seed(mnemonic_str) + self.mnemonic = mnemonic_str + + logger.debug(f"Using seed: {self.seed.hex()}") + logger.debug(f"Using mnemonic: {mnemonic_str}") + + # if no mnemonic was in the database, store the new one + if ret_db is None: + await store_seed_and_mnemonic( + self.db, seed=self.seed.hex(), mnemonic=mnemonic_str + ) + + try: + self.bip32 = BIP32.from_seed(self.seed) + self.private_key = PrivateKey( + self.bip32.get_privkey_from_path("m/129372'/0'/0'/0'") + ) + except ValueError: + raise ValueError("Invalid seed") + except Exception as e: + logger.error(e) + + async def _generate_secret(self, randombits=128) -> str: + """Returns base64 encoded deterministic random string. + + NOTE: This method should probably retire after `deterministic_secrets`. We are + deriving secrets from a counter but don't store the respective blinding factor. + We won't be able to restore any ecash generated with these secrets. + """ + secret_counter = await bump_secret_derivation( + db=self.db, keyset_id=self.keyset_id + ) + logger.trace(f"secret_counter: {secret_counter}") + s, _, _ = await self.generate_determinstic_secret(secret_counter) + # return s.decode("utf-8") + return hashlib.sha256(s).hexdigest() + + async def generate_determinstic_secret( + self, counter: int + ) -> Tuple[bytes, bytes, str]: + """ + Determinstically generates two secrets (one as the secret message, + one as the blinding factor). + """ + assert self.bip32, "BIP32 not initialized yet." + # integer keyset id modulo max number of bip32 child keys + keyest_id = int.from_bytes(base64.b64decode(self.keyset_id), "big") % ( + 2**31 - 1 + ) + logger.trace(f"keyset id: {self.keyset_id} becomes {keyest_id}") + token_derivation_path = f"m/129372'/0'/{keyest_id}'/{counter}'" + # for secret + secret_derivation_path = f"{token_derivation_path}/0" + logger.trace(f"secret derivation path: {secret_derivation_path}") + secret = self.bip32.get_privkey_from_path(secret_derivation_path) + # blinding factor + r_derivation_path = f"{token_derivation_path}/1" + logger.trace(f"r derivation path: {r_derivation_path}") + r = self.bip32.get_privkey_from_path(r_derivation_path) + return secret, r, token_derivation_path + + async def generate_n_secrets( + self, n: int = 1, skip_bump: bool = False + ) -> Tuple[List[str], List[PrivateKey], List[str]]: + """Generates n secrets and blinding factors and returns a tuple of secrets, + blinding factors, and derivation paths. + + Args: + n (int, optional): Number of secrets to generate. Defaults to 1. + skip_bump (bool, optional): Skip increment of secret counter in the database. + You want to set this to false if you don't know whether the following operation + will succeed or not (like a POST /mint request). Defaults to False. + + Returns: + Tuple[List[str], List[PrivateKey], List[str]]: Secrets, blinding factors, derivation paths + + """ + secret_counters_start = await bump_secret_derivation( + db=self.db, keyset_id=self.keyset_id, by=n, skip=skip_bump + ) + logger.trace(f"secret_counters_start: {secret_counters_start}") + secret_counters = list(range(secret_counters_start, secret_counters_start + n)) + logger.trace( + f"Generating secret nr {secret_counters[0]} to {secret_counters[-1]}." + ) + secrets_rs_derivationpaths = [ + await self.generate_determinstic_secret(s) for s in secret_counters + ] + # secrets are supplied as str + secrets = [hashlib.sha256(s[0]).hexdigest() for s in secrets_rs_derivationpaths] + # rs are supplied as PrivateKey + rs = [PrivateKey(privkey=s[1], raw=True) for s in secrets_rs_derivationpaths] + + derivation_paths = [s[2] for s in secrets_rs_derivationpaths] + + return secrets, rs, derivation_paths + + async def generate_secrets_from_to( + self, from_counter: int, to_counter: int + ) -> Tuple[List[str], List[PrivateKey], List[str]]: + """Generates secrets and blinding factors from `from_counter` to `to_counter` + + Args: + from_counter (int): Start counter + to_counter (int): End counter + + Returns: + Tuple[List[str], List[PrivateKey], List[str]]: Secrets, blinding factors, derivation paths + + Raises: + ValueError: If `from_counter` is larger than `to_counter` + """ + assert ( + from_counter <= to_counter + ), "from_counter must be smaller than to_counter" + secret_counters = [c for c in range(from_counter, to_counter + 1)] + secrets_rs_derivationpaths = [ + await self.generate_determinstic_secret(s) for s in secret_counters + ] + # secrets are supplied as str + secrets = [hashlib.sha256(s[0]).hexdigest() for s in secrets_rs_derivationpaths] + # rs are supplied as PrivateKey + rs = [PrivateKey(privkey=s[1], raw=True) for s in secrets_rs_derivationpaths] + derivation_paths = [s[2] for s in secrets_rs_derivationpaths] + return secrets, rs, derivation_paths diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 69a40b50..72650d93 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -1,18 +1,15 @@ import base64 -import hashlib import json import math import secrets as scrts import time import uuid -from datetime import datetime, timedelta from itertools import groupby from typing import Dict, List, Optional, Tuple, Union import requests from bip32 import BIP32 from loguru import logger -from mnemonic import Mnemonic from requests import Response from ..core import bolt11 as bolt11 @@ -27,17 +24,12 @@ GetMintResponse, Invoice, KeysetsResponse, - P2SHScript, PostMeltRequest, PostMintRequest, PostMintResponse, PostRestoreResponse, PostSplitRequest, Proof, - Secret, - SecretKind, - SigFlags, - Tags, TokenV2, TokenV2Mint, TokenV3, @@ -50,13 +42,7 @@ from ..core.db import Database from ..core.helpers import calculate_number_of_blank_outputs, sum_proofs from ..core.migrations import migrate_databases -from ..core.p2pk import sign_p2pk_sign -from ..core.script import ( - step0_carol_checksig_redeemscrip, - step0_carol_privkey, - step1_carol_create_p2sh_address, - step2_carol_sign_tx, -) +from ..core.p2pk import Secret from ..core.settings import settings from ..core.split import amount_split from ..tor.tor import TorProxy @@ -64,20 +50,18 @@ bump_secret_derivation, get_keyset, get_proofs, - get_seed_and_mnemonic, - get_unused_locks, invalidate_proof, secret_used, set_secret_derivation, store_keyset, store_lightning_invoice, - store_p2sh, store_proof, - store_seed_and_mnemonic, update_lightning_invoice, update_proof_reserved, ) from . import migrations +from .p2pk import WalletP2PK +from .secrets import WalletSecrets def async_set_requests(func): @@ -126,13 +110,13 @@ def __init__(self, url: str, db: Database): self.s = requests.Session() self.db = db - async def generate_n_secrets( - self, n: int = 1, skip_bump: bool = False - ) -> Tuple[List[str], List[PrivateKey], List[str]]: - return await self.generate_n_secrets(n, skip_bump) + # async def generate_n_secrets( + # self, n: int = 1, skip_bump: bool = False + # ) -> Tuple[List[str], List[PrivateKey], List[str]]: + # return await self.generate_n_secrets(n, skip_bump) - async def _generate_secret(self, skip_bump: bool = False) -> str: - return await self._generate_secret(skip_bump) + # async def _generate_secret(self, skip_bump: bool = False) -> str: + # return await self._generate_secret(skip_bump) @async_set_requests async def _init_s(self): @@ -455,11 +439,13 @@ async def request_mint(self, amount) -> Invoice: return Invoice(amount=amount, pr=mint_response.pr, hash=mint_response.hash) @async_set_requests - async def mint(self, amounts: List[int], hash: Optional[str] = None) -> List[Proof]: + async def mint( + self, outputs: List[BlindedMessage], hash: Optional[str] = None + ) -> List[BlindedSignature]: """Mints new coins and returns a proof of promise. Args: - amounts (List[int]): Amounts of tokens to mint + outputs (List[BlindedMessage]): Outputs to mint new tokens with hash (str, optional): Hash of the paid invoice. Defaults to None. Returns: @@ -468,14 +454,6 @@ async def mint(self, amounts: List[int], hash: Optional[str] = None) -> List[Pro Raises: Exception: If the minting fails """ - # quirk: we skip bumping the secret counter in the database since we are - # not sure if the minting will succeed. If it succeeds, we will bump it - # in the next step. - secrets, rs, derivation_paths = await self.generate_n_secrets( - len(amounts), skip_bump=True - ) - await self._check_used_secrets(secrets) - outputs, rs = self._construct_outputs(amounts, secrets, rs) outputs_payload = PostMintRequest(outputs=outputs) logger.trace("Checking Lightning invoice. POST /mint") resp = self.s.post( @@ -490,12 +468,7 @@ async def mint(self, amounts: List[int], hash: Optional[str] = None) -> List[Pro reponse_dict = resp.json() logger.trace("Lightning invoice checked. POST /mint") promises = PostMintResponse.parse_obj(reponse_dict).promises - - # bump secret counter in database - await bump_secret_derivation( - db=self.db, keyset_id=self.keyset_id, by=len(amounts) - ) - return self._construct_proofs(promises, secrets, rs, derivation_paths) + return promises @async_set_requests async def split( @@ -609,14 +582,14 @@ async def restore_promises( return returnObj.outputs, returnObj.promises -class Wallet(LedgerAPI): +class Wallet(LedgerAPI, WalletP2PK, WalletSecrets): """Minimal wallet wrapper.""" mnemonic: str # holds mnemonic of the wallet seed: bytes # holds private key of the wallet generated from the mnemonic - db: Database + # db: Database bip32: BIP32 - private_key: Optional[PrivateKey] = None + # private_key: Optional[PrivateKey] = None def __init__( self, @@ -669,184 +642,6 @@ async def _migrate_database(self): except Exception as e: logger.error(f"Could not run migrations: {e}") - async def _init_private_key(self, from_mnemonic: Optional[str] = None) -> None: - """Initializes the private key of the wallet from the mnemonic. - There are three ways to initialize the private key: - 1. If the database does not contain a seed, and no mnemonic is given, a new seed is generated. - 2. If the database does not contain a seed, and a mnemonic is given, the seed is generated from the mnemonic. - 3. If the database contains a seed, the seed is loaded from the database. - - If the mnemonic was not loaded from the database, the seed and mnemonic are stored in the database. - - Args: - from_mnemonic (Optional[str], optional): Mnemonic to use. Defaults to None. - - Raises: - ValueError: If the mnemonic is not BIP39 compliant. - """ - ret_db = await get_seed_and_mnemonic(self.db) - - mnemo = Mnemonic("english") - - if ret_db is None and from_mnemonic is None: - # if there is no seed in the database, generate a new one - mnemonic_str = mnemo.generate() - wallet_command_prefix_str = ( - f" --wallet {settings.wallet_name}" - if settings.wallet_name != "wallet" - else "" - ) - wallet_name = ( - f' for wallet "{settings.wallet_name}"' - if settings.wallet_name != "wallet" - else "" - ) - print( - f"Generated a new mnemonic{wallet_name}. To view it, run" - f' "cashu{wallet_command_prefix_str} info --mnemonic".' - ) - elif from_mnemonic: - # or use the one provided - mnemonic_str = from_mnemonic.lower().strip() - elif ret_db is not None: - # if there is a seed in the database, use it - _, mnemonic_str = ret_db[0], ret_db[1] - else: - logger.debug("No mnemonic provided") - return - - if not mnemo.check(mnemonic_str): - raise ValueError("Invalid mnemonic") - - self.seed = mnemo.to_seed(mnemonic_str) - self.mnemonic = mnemonic_str - - logger.debug(f"Using seed: {self.seed.hex()}") - logger.debug(f"Using mnemonic: {mnemonic_str}") - - # if no mnemonic was in the database, store the new one - if ret_db is None: - await store_seed_and_mnemonic( - self.db, seed=self.seed.hex(), mnemonic=mnemonic_str - ) - - try: - self.bip32 = BIP32.from_seed(self.seed) - self.private_key = PrivateKey( - self.bip32.get_privkey_from_path("m/129372'/0'/0'/0'") - ) - except ValueError: - raise ValueError("Invalid seed") - except Exception as e: - logger.error(e) - - async def _generate_secret(self, randombits=128) -> str: - """Returns base64 encoded deterministic random string. - - NOTE: This method should probably retire after `deterministic_secrets`. We are - deriving secrets from a counter but don't store the respective blinding factor. - We won't be able to restore any ecash generated with these secrets. - """ - secret_counter = await bump_secret_derivation( - db=self.db, keyset_id=self.keyset_id - ) - logger.trace(f"secret_counter: {secret_counter}") - s, _, _ = await self.generate_determinstic_secret(secret_counter) - # return s.decode("utf-8") - return hashlib.sha256(s).hexdigest() - - async def generate_determinstic_secret( - self, counter: int - ) -> Tuple[bytes, bytes, str]: - """ - Determinstically generates two secrets (one as the secret message, - one as the blinding factor). - """ - assert self.bip32, "BIP32 not initialized yet." - # integer keyset id modulo max number of bip32 child keys - keyest_id = int.from_bytes(base64.b64decode(self.keyset_id), "big") % ( - 2**31 - 1 - ) - logger.trace(f"keyset id: {self.keyset_id} becomes {keyest_id}") - token_derivation_path = f"m/129372'/0'/{keyest_id}'/{counter}'" - # for secret - secret_derivation_path = f"{token_derivation_path}/0" - logger.trace(f"secret derivation path: {secret_derivation_path}") - secret = self.bip32.get_privkey_from_path(secret_derivation_path) - # blinding factor - r_derivation_path = f"{token_derivation_path}/1" - logger.trace(f"r derivation path: {r_derivation_path}") - r = self.bip32.get_privkey_from_path(r_derivation_path) - return secret, r, token_derivation_path - - async def generate_n_secrets( - self, n: int = 1, skip_bump: bool = False - ) -> Tuple[List[str], List[PrivateKey], List[str]]: - """Generates n secrets and blinding factors and returns a tuple of secrets, - blinding factors, and derivation paths. - - Args: - n (int, optional): Number of secrets to generate. Defaults to 1. - skip_bump (bool, optional): Skip increment of secret counter in the database. - You want to set this to false if you don't know whether the following operation - will succeed or not (like a POST /mint request). Defaults to False. - - Returns: - Tuple[List[str], List[PrivateKey], List[str]]: Secrets, blinding factors, derivation paths - - """ - secret_counters_start = await bump_secret_derivation( - db=self.db, keyset_id=self.keyset_id, by=n, skip=skip_bump - ) - logger.trace(f"secret_counters_start: {secret_counters_start}") - secret_counters = list(range(secret_counters_start, secret_counters_start + n)) - logger.trace( - f"Generating secret nr {secret_counters[0]} to {secret_counters[-1]}." - ) - secrets_rs_derivationpaths = [ - await self.generate_determinstic_secret(s) for s in secret_counters - ] - # secrets are supplied as str - secrets = [hashlib.sha256(s[0]).hexdigest() for s in secrets_rs_derivationpaths] - # rs are supplied as PrivateKey - rs = [PrivateKey(privkey=s[1], raw=True) for s in secrets_rs_derivationpaths] - - derivation_paths = [s[2] for s in secrets_rs_derivationpaths] - # sanity check to make sure we're not reusing secrets - # NOTE: this step is probably wasting more resources than it helps - await self._check_used_secrets(secrets) - - return secrets, rs, derivation_paths - - async def generate_secrets_from_to( - self, from_counter: int, to_counter: int - ) -> Tuple[List[str], List[PrivateKey], List[str]]: - """Generates secrets and blinding factors from `from_counter` to `to_counter` - - Args: - from_counter (int): Start counter - to_counter (int): End counter - - Returns: - Tuple[List[str], List[PrivateKey], List[str]]: Secrets, blinding factors, derivation paths - - Raises: - ValueError: If `from_counter` is larger than `to_counter` - """ - assert ( - from_counter <= to_counter - ), "from_counter must be smaller than to_counter" - secret_counters = [c for c in range(from_counter, to_counter + 1)] - secrets_rs_derivationpaths = [ - await self.generate_determinstic_secret(s) for s in secret_counters - ] - # secrets are supplied as str - secrets = [hashlib.sha256(s[0]).hexdigest() for s in secrets_rs_derivationpaths] - # rs are supplied as PrivateKey - rs = [PrivateKey(privkey=s[1], raw=True) for s in secrets_rs_derivationpaths] - derivation_paths = [s[2] for s in secrets_rs_derivationpaths] - return secrets, rs, derivation_paths - # ---------- API ---------- async def load_mint(self, keyset_id: str = ""): @@ -914,7 +709,25 @@ async def mint( # if no split was specified, we use the canonical split amounts = split or amount_split(amount) - proofs = await super().mint(amounts, hash) + + # quirk: we skip bumping the secret counter in the database since we are + # not sure if the minting will succeed. If it succeeds, we will bump it + # in the next step. + secrets, rs, derivation_paths = await self.generate_n_secrets( + len(amounts), skip_bump=True + ) + await self._check_used_secrets(secrets) + outputs, rs = self._construct_outputs(amounts, secrets, rs) + + # will raise exception if mint is unsuccessful + promises = await super().mint(outputs, hash) + + # success, bump secret counter in database + await bump_secret_derivation( + db=self.db, keyset_id=self.keyset_id, by=len(amounts) + ) + proofs = self._construct_proofs(promises, secrets, rs, derivation_paths) + if proofs == []: raise Exception("received no proofs.") await self._store_proofs(proofs) @@ -925,112 +738,6 @@ async def mint( self.proofs += proofs return proofs - async def add_p2pk_witnesses_to_outputs( - self, outputs: List[BlindedMessage] - ) -> List[BlindedMessage]: - p2pk_signatures = await self.sign_p2pk_outputs(outputs) - for o, s in zip(outputs, p2pk_signatures): - o.p2pksigs = [s] - return outputs - - async def add_witnesses_to_outputs( - self, proofs: List[Proof], outputs: List[BlindedMessage] - ) -> List[BlindedMessage]: - """Adds witnesses to outputs if the inputs (proofs) indicate an appropriate signature flag - - Args: - proofs (List[Proof]): _description_ - outputs (List[BlindedMessage]): _description_ - """ - # first we check whether all tokens have serialized secrets as their secret - try: - for p in proofs: - Secret.deserialize(p.secret) - except Exception: - # if not, we do not add witnesses (treat as regular token secret) - return outputs - - # if any of the proofs provided require SIG_ALL, we must provide it - if any( - [Secret.deserialize(p.secret).sigflag == SigFlags.SIG_ALL for p in proofs] - ): - # p2pk_signatures = await self.sign_p2pk_outputs(outputs) - # for o, s in zip(outputs, p2pk_signatures): - # o.p2pksigs = [s] - outputs = await self.add_p2pk_witnesses_to_outputs(outputs) - return outputs - - async def add_p2sh_witnesses_to_proofs(self, proofs: List[Proof]) -> List[Proof]: - # Quirk: we use a single P2SH script and signature pair for all tokens in proofs - address = Secret.deserialize(proofs[0].secret).data - p2shscripts = await get_unused_locks(address, db=self.db) - assert len(p2shscripts) == 1, Exception("lock not found.") - p2sh_script, p2sh_signature = ( - p2shscripts[0].script, - p2shscripts[0].signature, - ) - logger.debug(f"Unlock script: {p2sh_script} signature: {p2sh_signature}") - - # attach unlock scripts to proofs - for p in proofs: - p.p2shscript = P2SHScript(script=p2sh_script, signature=p2sh_signature) - return proofs - - async def add_p2pk_witnesses_to_proofs(self, proofs: List[Proof]) -> List[Proof]: - p2pk_signatures = await self.sign_p2pk_proofs(proofs) - logger.debug(f"Unlock signatures for {len(proofs)} proofs: {p2pk_signatures}") - logger.debug(f"Proofs: {proofs}") - # attach unlock signatures to proofs - assert len(proofs) == len(p2pk_signatures), "wrong number of signatures" - for p, s in zip(proofs, p2pk_signatures): - if p.p2pksigs: - p.p2pksigs.append(s) - else: - p.p2pksigs = [s] - return proofs - - async def add_witnesses_to_proofs(self, proofs: List[Proof]) -> List[Proof]: - """Adds witnesses to proofs for P2SH or P2PK redemption. - - This method parses the secret of each proof and determines the correct - witness type and adds it to the proof if we have it available. - - Note: In order for this method to work, all proofs must have the same secret type. - This is because we use a single P2SH script and signature pair for all tokens in proofs. - - For P2PK, we use an individual signature for each token in proofs. - - Args: - proofs (List[Proof]): List of proofs to add witnesses to - - Returns: - List[Proof]: List of proofs with witnesses added - """ - - # iterate through proofs and produce witnesses for each - - # first we check whether all tokens have serialized secrets as their secret - try: - for p in proofs: - Secret.deserialize(p.secret) - except Exception: - # if not, we do not add witnesses (treat as regular token secret) - return proofs - logger.debug("Spending conditions detected.") - # P2SH scripts - if all([Secret.deserialize(p.secret).kind == SecretKind.P2SH for p in proofs]): - logger.debug("P2SH redemption detected.") - proofs = await self.add_p2sh_witnesses_to_proofs(proofs) - - # P2PK signatures - elif all( - [Secret.deserialize(p.secret).kind == SecretKind.P2PK for p in proofs] - ): - logger.debug("P2PK redemption detected.") - proofs = await self.add_p2pk_witnesses_to_proofs(proofs) - - return proofs - async def redeem( self, proofs: List[Proof], @@ -1186,7 +893,7 @@ async def pay_lightning( async def check_proof_state(self, proofs): return await super().check_proof_state(proofs) - # ---------- TOKEN MECHANIS ---------- + # ---------- TOKEN MECHANICS ---------- async def _store_proofs(self, proofs): async with self.db.connect() as conn: @@ -1483,115 +1190,6 @@ async def split_to_send( await self.set_reserved(send_proofs, reserved=True) return keep_proofs, send_proofs - # ---------- P2SH and P2PK ---------- - - async def create_p2sh_address_and_store(self) -> str: - """Creates a P2SH lock script and stores the script and signature in the database.""" - alice_privkey = step0_carol_privkey() - txin_redeemScript = step0_carol_checksig_redeemscrip(alice_privkey.pub) - txin_p2sh_address = step1_carol_create_p2sh_address(txin_redeemScript) - txin_signature = step2_carol_sign_tx(txin_redeemScript, alice_privkey).scriptSig - txin_redeemScript_b64 = base64.urlsafe_b64encode(txin_redeemScript).decode() - txin_signature_b64 = base64.urlsafe_b64encode(txin_signature).decode() - p2shScript = P2SHScript( - script=txin_redeemScript_b64, - signature=txin_signature_b64, - address=str(txin_p2sh_address), - ) - await store_p2sh(p2shScript, db=self.db) - assert p2shScript.address - return p2shScript.address - - async def create_p2pk_pubkey(self): - assert ( - self.private_key - ), "No private key set in settings. Set NOSTR_PRIVATE_KEY in .env" - public_key = self.private_key.pubkey - # logger.debug(f"Private key: {self.private_key.bech32()}") - assert public_key - return public_key.serialize().hex() - - async def create_p2pk_lock( - self, - pubkey: str, - locktime_seconds: Optional[int] = None, - tags: Optional[Tags] = None, - sig_all: bool = False, - n_sigs: int = 1, - ) -> Secret: - logger.debug(f"Provided tags: {tags}") - if not tags: - tags = Tags() - logger.debug(f"Before tags: {tags}") - if locktime_seconds: - tags["locktime"] = str( - int((datetime.now() + timedelta(seconds=locktime_seconds)).timestamp()) - ) - tags["sigflag"] = SigFlags.SIG_ALL if sig_all else SigFlags.SIG_INPUTS - if n_sigs > 1: - tags["n_sigs"] = str(n_sigs) - logger.debug(f"After tags: {tags}") - return Secret( - kind=SecretKind.P2PK, - data=pubkey, - tags=tags, - ) - - async def create_p2sh_lock( - self, - address: str, - locktime: Optional[int] = None, - tags: Tags = Tags(), - ) -> Secret: - if locktime: - tags["locktime"] = str( - (datetime.now() + timedelta(seconds=locktime)).timestamp() - ) - - return Secret( - kind=SecretKind.P2SH, - data=address, - tags=tags, - ) - - async def sign_p2pk_proofs(self, proofs: List[Proof]) -> List[str]: - assert ( - self.private_key - ), "No private key set in settings. Set NOSTR_PRIVATE_KEY in .env" - private_key = self.private_key - assert private_key.pubkey - logger.trace( - f"Signing with private key: {private_key.serialize()} public key:" - f" {private_key.pubkey.serialize().hex()}" - ) - for proof in proofs: - logger.trace(f"Signing proof: {proof}") - logger.trace(f"Signing message: {proof.secret}") - - signatures = [ - sign_p2pk_sign( - message=proof.secret.encode("utf-8"), - private_key=private_key, - ) - for proof in proofs - ] - logger.debug(f"Signatures: {signatures}") - return signatures - - async def sign_p2pk_outputs(self, outputs: List[BlindedMessage]) -> List[str]: - assert ( - self.private_key - ), "No private key set in settings. Set NOSTR_PRIVATE_KEY in .env" - private_key = self.private_key - assert private_key.pubkey - return [ - sign_p2pk_sign( - message=output.B_.encode("utf-8"), - private_key=private_key, - ) - for output in outputs - ] - # ---------- BALANCE CHECKS ---------- @property diff --git a/tests/test_wallet_p2pk.py b/tests/test_wallet_p2pk.py index 50101af9..9a864242 100644 --- a/tests/test_wallet_p2pk.py +++ b/tests/test_wallet_p2pk.py @@ -6,9 +6,10 @@ import pytest import pytest_asyncio -from cashu.core.base import Proof, SigFlags, Tags +from cashu.core.base import Proof from cashu.core.crypto.secp import PrivateKey, PublicKey from cashu.core.migrations import migrate_databases +from cashu.core.p2pk import SigFlags, Tags from cashu.wallet import migrations from cashu.wallet.wallet import Wallet from cashu.wallet.wallet import Wallet as Wallet1 @@ -134,6 +135,7 @@ async def test_p2pk_locktime_with_refund_pubkey(wallet1: Wallet, wallet2: Wallet ) send_proofs_copy = copy.deepcopy(send_proofs) # receiver side: can't redeem since we used a garbage pubkey + # and locktime has not passed await assert_err( wallet2.redeem(send_proofs), "Mint Error: no valid signature provided for input.", @@ -162,6 +164,7 @@ async def test_p2pk_locktime_with_wrong_refund_pubkey(wallet1: Wallet, wallet2: ) send_proofs_copy = copy.deepcopy(send_proofs) # receiver side: can't redeem since we used a garbage pubkey + # and locktime has not passed await assert_err( wallet2.redeem(send_proofs), "Mint Error: no valid signature provided for input.", @@ -174,6 +177,38 @@ async def test_p2pk_locktime_with_wrong_refund_pubkey(wallet1: Wallet, wallet2: ) +@pytest.mark.asyncio +async def test_p2pk_locktime_with_second_refund_pubkey( + wallet1: Wallet, wallet2: Wallet +): + await wallet1.mint(64) + pubkey_wallet1 = await wallet1.create_p2pk_pubkey() # receiver side + pubkey_wallet2 = await wallet2.create_p2pk_pubkey() # receiver side + # sender side + garbage_pubkey = PrivateKey().pubkey + assert garbage_pubkey + secret_lock = await wallet1.create_p2pk_lock( + garbage_pubkey.serialize().hex(), # create lock to unspendable pubkey + locktime_seconds=2, # locktime + tags=Tags( + [["refund", pubkey_wallet2, pubkey_wallet1]] + ), # multiple refund pubkeys + ) # sender side + _, send_proofs = await wallet1.split_to_send( + wallet1.proofs, 8, secret_lock=secret_lock + ) + send_proofs_copy = copy.deepcopy(send_proofs) + # receiver side: can't redeem since we used a garbage pubkey + # and locktime has not passed + await assert_err( + wallet1.redeem(send_proofs), + "Mint Error: no valid signature provided for input.", + ) + await asyncio.sleep(2) + # we can now redeem because of the refund locktime + await wallet1.redeem(send_proofs_copy) + + @pytest.mark.asyncio async def test_p2pk_multisig_2_of_2(wallet1: Wallet, wallet2: Wallet): await wallet1.mint(64) @@ -182,15 +217,15 @@ async def test_p2pk_multisig_2_of_2(wallet1: Wallet, wallet2: Wallet): assert pubkey_wallet1 != pubkey_wallet2 # p2pk test secret_lock = await wallet1.create_p2pk_lock( - pubkey_wallet2, tags=Tags([["pubkey", pubkey_wallet1]]), n_sigs=2 + pubkey_wallet2, tags=Tags([["pubkeys", pubkey_wallet1]]), n_sigs=2 ) _, send_proofs = await wallet1.split_to_send( wallet1.proofs, 8, secret_lock=secret_lock ) - # add signatures of wallet2 + # add signatures of wallet1 send_proofs = await wallet1.add_p2pk_witnesses_to_proofs(send_proofs) - # here we add the signatures of wallet1 + # here we add the signatures of wallet2 await wallet2.redeem(send_proofs) @@ -202,15 +237,15 @@ async def test_p2pk_multisig_duplicate_signature(wallet1: Wallet, wallet2: Walle assert pubkey_wallet1 != pubkey_wallet2 # p2pk test secret_lock = await wallet1.create_p2pk_lock( - pubkey_wallet2, tags=Tags([["pubkey", pubkey_wallet1]]), n_sigs=2 + pubkey_wallet2, tags=Tags([["pubkeys", pubkey_wallet1]]), n_sigs=2 ) _, send_proofs = await wallet1.split_to_send( wallet1.proofs, 8, secret_lock=secret_lock ) - # add signatures of wallet2 + # add signatures of wallet2 – this is a duplicate signature send_proofs = await wallet2.add_p2pk_witnesses_to_proofs(send_proofs) - # here we add the signatures of wallet1 + # here we add the signatures of wallet2 await assert_err( wallet2.redeem(send_proofs), "Mint Error: p2pk signatures must be unique." ) @@ -224,7 +259,7 @@ async def test_p2pk_multisig_quorum_not_met_1_of_2(wallet1: Wallet, wallet2: Wal assert pubkey_wallet1 != pubkey_wallet2 # p2pk test secret_lock = await wallet1.create_p2pk_lock( - pubkey_wallet2, tags=Tags([["pubkey", pubkey_wallet1]]), n_sigs=2 + pubkey_wallet2, tags=Tags([["pubkeys", pubkey_wallet1]]), n_sigs=2 ) _, send_proofs = await wallet1.split_to_send( wallet1.proofs, 8, secret_lock=secret_lock @@ -243,7 +278,7 @@ async def test_p2pk_multisig_quorum_not_met_2_of_3(wallet1: Wallet, wallet2: Wal assert pubkey_wallet1 != pubkey_wallet2 # p2pk test secret_lock = await wallet1.create_p2pk_lock( - pubkey_wallet2, tags=Tags([["pubkey", pubkey_wallet1]]), n_sigs=3 + pubkey_wallet2, tags=Tags([["pubkeys", pubkey_wallet1]]), n_sigs=3 ) _, send_proofs = await wallet1.split_to_send( @@ -264,7 +299,7 @@ async def test_p2pk_multisig_with_duplicate_publickey(wallet1: Wallet, wallet2: pubkey_wallet2 = await wallet2.create_p2pk_pubkey() # p2pk test secret_lock = await wallet1.create_p2pk_lock( - pubkey_wallet2, tags=Tags([["pubkey", pubkey_wallet2]]), n_sigs=2 + pubkey_wallet2, tags=Tags([["pubkeys", pubkey_wallet2]]), n_sigs=2 ) _, send_proofs = await wallet1.split_to_send( wallet1.proofs, 8, secret_lock=secret_lock @@ -287,7 +322,7 @@ async def test_p2pk_multisig_with_wrong_first_private_key( # p2pk test secret_lock = await wallet1.create_p2pk_lock( - pubkey_wallet2, tags=Tags([["pubkey", wrong_public_key_hex]]), n_sigs=2 + pubkey_wallet2, tags=Tags([["pubkeys", wrong_public_key_hex]]), n_sigs=2 ) _, send_proofs = await wallet1.split_to_send( wallet1.proofs, 8, secret_lock=secret_lock @@ -300,14 +335,16 @@ async def test_p2pk_multisig_with_wrong_first_private_key( def test_tags(): - tags = Tags([["key1", "value1"], ["key2", "value2"], ["key2", "value3"]]) + tags = Tags( + [["key1", "value1"], ["key2", "value2", "value2_1"], ["key2", "value3"]] + ) assert tags.get_tag("key1") == "value1" assert tags["key1"] == "value1" assert tags.get_tag("key2") == "value2" assert tags["key2"] == "value2" assert tags.get_tag("key3") is None assert tags["key3"] is None - assert tags.get_tag_all("key2") == ["value2", "value3"] + assert tags.get_tag_all("key2") == ["value2", "value2_1", "value3"] @pytest.mark.asyncio From 6282e0a22a4d0db8c202ab140ebd74d96b9a4c3c Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 23 Sep 2023 19:06:37 +0200 Subject: [PATCH 06/10] [Wallet/Mint] DLEQ proofs (#175) * produce dleq * start working on verification * wip dleq * Use C_ instead of C in verify DLEQ! (#176) * Fix comments (DLEQ sign error) * Fix alice_verify_dleq in d_dhke.py * Fix_generate_promise in ledger.py * Fix verify_proofs_dleq in wallet.py * Fix: invalid public key (#182) * Use C_ instead of C in verify DLEQ! * Fix comments (DLEQ sign error) * Fix alice_verify_dleq in d_dhke.py * Fix_generate_promise in ledger.py * Fix verify_proofs_dleq in wallet.py * Fix: invalid public key * Exception: Mint Error: invalid public key * Update cashu/wallet/wallet.py --------- Co-authored-by: calle <93376500+callebtc@users.noreply.github.com> * Update cashu/core/b_dhke.py * Update tests/test_cli.py * verify all constructed proofs * dleq upon receive * serialize without dleq * all tests passing * make format * remove print * remove debug * option to send with dleq * add tests * fix test * deterministic p in step2_dleq and fix mypy error for hash_to_curve * test crypto/hash_e and crypto/step2_bob_dleq * rename A to K in b_dhke.py and test_alice_verify_dleq * rename tests * make format * store dleq in mint db (and readd balance view) * remove `r` from dleq in tests * add pending output * make format * works with pre-dleq mints * fix comments * make format * fix some tests * fix last test * test serialize dleq fix * flake * flake * keyset.id must be str * fix test decorators * start removing the duplicate fields from the dleq * format * remove print * cleanup * add type anotations to dleq functions * remove unnecessary fields from BlindedSignature * tests not working yet * spelling mistakes * spelling mistakes * fix more spelling mistakes * revert to normal * add comments * bdhke: generalize hash_e * remove P2PKSecret changes * revert tests for P2PKSecret * revert tests * revert test fully * revert p2pksecret changes * refactor proof invalidation * store dleq proofs in wallet db * make mypy happy --------- Co-authored-by: moonsettler --- cashu/core/base.py | 64 ++++++-- cashu/core/crypto/b_dhke.py | 86 ++++++++++- cashu/core/script.py | 6 +- cashu/mint/crud.py | 13 +- cashu/mint/ledger.py | 17 ++- cashu/mint/migrations.py | 12 ++ cashu/wallet/api/router.py | 10 +- cashu/wallet/cli/cli.py | 27 +++- cashu/wallet/crud.py | 48 +++--- cashu/wallet/helpers.py | 13 +- cashu/wallet/migrations.py | 7 + cashu/wallet/nostr.py | 4 +- cashu/wallet/p2pk.py | 4 +- cashu/wallet/wallet.py | 290 ++++++++++++++++++++---------------- tests/test_cli.py | 45 +++++- tests/test_core.py | 68 ++++++++- tests/test_crypto.py | 201 ++++++++++++++++++++++++- tests/test_mint.py | 6 + tests/test_wallet.py | 1 + 19 files changed, 717 insertions(+), 205 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index 7978d962..d0b465e7 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -11,6 +11,26 @@ from .legacy import derive_keys_backwards_compatible_insecure_pre_0_12 from .p2pk import P2SHScript + +class DLEQ(BaseModel): + """ + Discrete Log Equality (DLEQ) Proof + """ + + e: str + s: str + + +class DLEQWallet(BaseModel): + """ + Discrete Log Equality (DLEQ) Proof + """ + + e: str + s: str + r: str # blinding_factor, unknown to mint but sent from wallet to wallet for DLEQ proof + + # ------- PROOFS ------- @@ -24,6 +44,8 @@ class Proof(BaseModel): amount: int = 0 secret: str = "" # secret or message to be blinded and signed C: str = "" # signature on secret, unblinded by wallet + dleq: Union[DLEQWallet, None] = None # DLEQ proof + p2pksigs: Union[List[str], None] = [] # P2PK signature p2shscript: Union[P2SHScript, None] = None # P2SH spending condition # whether this proof is reserved for sending, used for coin management in the wallet @@ -34,7 +56,28 @@ class Proof(BaseModel): time_reserved: Union[None, str] = "" derivation_path: Union[None, str] = "" # derivation path of the proof - def to_dict(self): + @classmethod + def from_dict(cls, proof_dict: dict): + if proof_dict.get("dleq"): + proof_dict["dleq"] = DLEQWallet(**json.loads(proof_dict["dleq"])) + c = cls(**proof_dict) + return c + + def to_dict(self, include_dleq=False): + # dictionary without the fields that don't need to be send to Carol + if not include_dleq: + return dict(id=self.id, amount=self.amount, secret=self.secret, C=self.C) + + assert self.dleq, "DLEQ proof is missing" + return dict( + id=self.id, + amount=self.amount, + secret=self.secret, + C=self.C, + dleq=self.dleq.dict(), + ) + + def to_dict_no_dleq(self): # dictionary without the fields that don't need to be send to Carol return dict(id=self.id, amount=self.amount, secret=self.secret, C=self.C) @@ -69,9 +112,10 @@ class BlindedSignature(BaseModel): Blinded signature or "promise" which is the signature on a `BlindedMessage` """ - id: Union[str, None] = None + id: str amount: int C_: str # Hex-encoded signature + dleq: Optional[DLEQ] = None # DLEQ proof class BlindedMessages(BaseModel): @@ -296,7 +340,7 @@ class MintKeyset: Contains the keyset from the mint's perspective. """ - id: Union[str, None] + id: str derivation_path: str private_keys: Dict[int, PrivateKey] public_keys: Union[Dict[int, PublicKey], None] = None @@ -308,7 +352,7 @@ class MintKeyset: def __init__( self, - id=None, + id="", valid_from=None, valid_to=None, first_seen=None, @@ -411,8 +455,8 @@ class TokenV3Token(BaseModel): mint: Optional[str] = None proofs: List[Proof] - def to_dict(self): - return_dict = dict(proofs=[p.to_dict() for p in self.proofs]) + def to_dict(self, include_dleq=False): + return_dict = dict(proofs=[p.to_dict(include_dleq) for p in self.proofs]) if self.mint: return_dict.update(dict(mint=self.mint)) # type: ignore return return_dict @@ -426,8 +470,8 @@ class TokenV3(BaseModel): token: List[TokenV3Token] = [] memo: Optional[str] = None - def to_dict(self): - return_dict = dict(token=[t.to_dict() for t in self.token]) + def to_dict(self, include_dleq=False): + return_dict = dict(token=[t.to_dict(include_dleq) for t in self.token]) if self.memo: return_dict.update(dict(memo=self.memo)) # type: ignore return return_dict @@ -454,7 +498,7 @@ def deserialize(cls, tokenv3_serialized: str) -> "TokenV3": token = json.loads(base64.urlsafe_b64decode(token_base64)) return cls.parse_obj(token) - def serialize(self) -> str: + def serialize(self, include_dleq=False) -> str: """ Takes a TokenV3 and serializes it as "cashuA. """ @@ -462,6 +506,6 @@ def serialize(self) -> str: tokenv3_serialized = prefix # encode the token as a base64 string tokenv3_serialized += base64.urlsafe_b64encode( - json.dumps(self.to_dict()).encode() + json.dumps(self.to_dict(include_dleq)).encode() ).decode() return tokenv3_serialized diff --git a/cashu/core/crypto/b_dhke.py b/cashu/core/crypto/b_dhke.py index bac6355d..e8706239 100644 --- a/cashu/core/crypto/b_dhke.py +++ b/cashu/core/crypto/b_dhke.py @@ -28,22 +28,43 @@ Y = hash_to_curve(secret_message) C == a*Y If true, C must have originated from Bob + + +# DLEQ Proof + +(These steps occur once Bob returns C') + +Bob: +r = random nonce +R1 = r*G +R2 = r*B' +e = hash(R1,R2,A,C') +s = r + e*a +return e, s + +Alice: +R1 = s*G - e*A +R2 = s*B' - e*C' +e == hash(R1,R2,A,C') + +If true, a in A = a*G must be equal to a in C' = a*B' """ import hashlib -from typing import Optional +from typing import Optional, Tuple from secp256k1 import PrivateKey, PublicKey def hash_to_curve(message: bytes) -> PublicKey: """Generates a point from the message hash and checks if the point lies on the curve. - If it does not, it tries computing a new point from the hash.""" + If it does not, iteratively tries to compute a new point from the hash.""" point = None msg_to_hash = message while point is None: _hash = hashlib.sha256(msg_to_hash).digest() try: + # will error if point does not lie on curve point = PublicKey(b"\x02" + _hash, raw=True) except Exception: msg_to_hash = _hash @@ -59,9 +80,11 @@ def step1_alice( return B_, r -def step2_bob(B_: PublicKey, a: PrivateKey) -> PublicKey: +def step2_bob(B_: PublicKey, a: PrivateKey) -> Tuple[PublicKey, PrivateKey, PrivateKey]: C_: PublicKey = B_.mult(a) # type: ignore - return C_ + # produce dleq proof + e, s = step2_bob_dleq(B_, a) + return C_, e, s def step3_alice(C_: PublicKey, r: PrivateKey, A: PublicKey) -> PublicKey: @@ -74,6 +97,61 @@ def verify(a: PrivateKey, C: PublicKey, secret_msg: str) -> bool: return C == Y.mult(a) # type: ignore +def hash_e(*publickeys: PublicKey) -> bytes: + e_ = "" + for p in publickeys: + _p = p.serialize(compressed=False).hex() + e_ += str(_p) + e = hashlib.sha256(e_.encode("utf-8")).digest() + return e + + +def step2_bob_dleq( + B_: PublicKey, a: PrivateKey, p_bytes: bytes = b"" +) -> Tuple[PrivateKey, PrivateKey]: + if p_bytes: + # deterministic p for testing + p = PrivateKey(privkey=p_bytes, raw=True) + else: + # normally, we generate a random p + p = PrivateKey() + + R1 = p.pubkey # R1 = pG + assert R1 + R2: PublicKey = B_.mult(p) # R2 = pB_ # type: ignore + C_: PublicKey = B_.mult(a) # C_ = aB_ # type: ignore + A = a.pubkey + assert A + e = hash_e(R1, R2, A, C_) # e = hash(R1, R2, A, C_) + s = p.tweak_add(a.tweak_mul(e)) # s = p + ek + spk = PrivateKey(s, raw=True) + epk = PrivateKey(e, raw=True) + return epk, spk + + +def alice_verify_dleq( + B_: PublicKey, C_: PublicKey, e: PrivateKey, s: PrivateKey, A: PublicKey +) -> bool: + R1 = s.pubkey - A.mult(e) # type: ignore + R2 = B_.mult(s) - C_.mult(e) # type: ignore + e_bytes = e.private_key + return e_bytes == hash_e(R1, R2, A, C_) + + +def carol_verify_dleq( + secret_msg: str, + r: PrivateKey, + C: PublicKey, + e: PrivateKey, + s: PrivateKey, + A: PublicKey, +) -> bool: + Y: PublicKey = hash_to_curve(secret_msg.encode("utf-8")) + C_: PublicKey = C + A.mult(r) # type: ignore + B_: PublicKey = Y + r.pubkey # type: ignore + return alice_verify_dleq(B_, C_, e, s, A) + + # Below is a test of a simple positive and negative case # # Alice's keys diff --git a/cashu/core/script.py b/cashu/core/script.py index 0fc2a8ea..19827090 100644 --- a/cashu/core/script.py +++ b/cashu/core/script.py @@ -25,7 +25,7 @@ def step0_carol_privkey(): return seckey -def step0_carol_checksig_redeemscrip(carol_pubkey): +def step0_carol_checksig_redeemscript(carol_pubkey): """Create script""" txin_redeemScript = CScript([carol_pubkey, OP_CHECKSIG]) # txin_redeemScript = CScript([-123, OP_CHECKLOCKTIMEVERIFY]) @@ -111,7 +111,7 @@ def verify_bitcoin_script(txin_redeemScript_b64, txin_signature_b64): # --------- # CAROL defines scripthash and ALICE mints them alice_privkey = step0_carol_privkey() - txin_redeemScript = step0_carol_checksig_redeemscrip(alice_privkey.pub) + txin_redeemScript = step0_carol_checksig_redeemscript(alice_privkey.pub) print("Script:", txin_redeemScript.__repr__()) txin_p2sh_address = step1_carol_create_p2sh_address(txin_redeemScript) print(f"Carol sends Alice secret = P2SH:{txin_p2sh_address}") @@ -128,7 +128,7 @@ def verify_bitcoin_script(txin_redeemScript_b64, txin_signature_b64): # CAROL redeems with MINT # CAROL PRODUCES txin_redeemScript and txin_signature to send to MINT - txin_redeemScript = step0_carol_checksig_redeemscrip(alice_privkey.pub) + txin_redeemScript = step0_carol_checksig_redeemscript(alice_privkey.pub) txin_signature = step2_carol_sign_tx(txin_redeemScript, alice_privkey).scriptSig txin_redeemScript_b64 = base64.urlsafe_b64encode(txin_redeemScript).decode() diff --git a/cashu/mint/crud.py b/cashu/mint/crud.py index d15ab222..5759ccfc 100644 --- a/cashu/mint/crud.py +++ b/cashu/mint/crud.py @@ -49,23 +49,28 @@ async def update_lightning_invoice(*args, **kwags): async def store_promise( + *, db: Database, amount: int, B_: str, C_: str, id: str, + e: str = "", + s: str = "", conn: Optional[Connection] = None, ): await (conn or db).execute( f""" INSERT INTO {table_with_schema(db, 'promises')} - (amount, B_b, C_b, id) - VALUES (?, ?, ?, ?) + (amount, B_b, C_b, e, s, id) + VALUES (?, ?, ?, ?, ?, ?) """, ( amount, - str(B_), - str(C_), + B_, + C_, + e, + s, id, ), ) diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index d508029b..19899568 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -7,6 +7,7 @@ from ..core import bolt11 from ..core.base import ( + DLEQ, BlindedMessage, BlindedSignature, Invoice, @@ -117,8 +118,7 @@ async def load_keyset(self, derivation_path, autosave=True): logger.trace(f"crud: stored new keyset {keyset.id}.") # store the new keyset in the current keysets - if keyset.id: - self.keysets.keysets[keyset.id] = keyset + self.keysets.keysets[keyset.id] = keyset logger.debug(f"Loaded keyset {keyset.id}.") return keyset @@ -188,17 +188,24 @@ async def _generate_promise( keyset = keyset if keyset else self.keyset logger.trace(f"Generating promise with keyset {keyset.id}.") private_key_amount = keyset.private_keys[amount] - C_ = b_dhke.step2_bob(B_, private_key_amount) + C_, e, s = b_dhke.step2_bob(B_, private_key_amount) logger.trace(f"crud: _generate_promise storing promise for {amount}") await self.crud.store_promise( amount=amount, B_=B_.serialize().hex(), C_=C_.serialize().hex(), - id=keyset.id, + e=e.serialize(), + s=s.serialize(), db=self.db, + id=keyset.id, ) logger.trace(f"crud: _generate_promise stored promise for {amount}") - return BlindedSignature(id=keyset.id, amount=amount, C_=C_.serialize().hex()) + return BlindedSignature( + id=keyset.id, + amount=amount, + C_=C_.serialize().hex(), + dleq=DLEQ(e=e.serialize(), s=s.serialize()), + ) def _check_spendable(self, proof: Proof): """Checks whether the proof was already spent.""" diff --git a/cashu/mint/migrations.py b/cashu/mint/migrations.py index 44779e62..0d4df023 100644 --- a/cashu/mint/migrations.py +++ b/cashu/mint/migrations.py @@ -156,3 +156,15 @@ async def m007_proofs_and_promises_store_id(db: Database): await db.execute( f"ALTER TABLE {table_with_schema(db, 'promises')} ADD COLUMN id TEXT" ) + + +async def m008_promises_dleq(db: Database): + """ + Add columns for DLEQ proof to promises table. + """ + await db.execute( + f"ALTER TABLE {table_with_schema(db, 'promises')} ADD COLUMN e TEXT" + ) + await db.execute( + f"ALTER TABLE {table_with_schema(db, 'promises')} ADD COLUMN s TEXT" + ) diff --git a/cashu/wallet/api/router.py b/cashu/wallet/api/router.py index 1f6fcaee..1f02ef0f 100644 --- a/cashu/wallet/api/router.py +++ b/cashu/wallet/api/router.py @@ -225,11 +225,11 @@ async def send_command( global wallet if not nostr: balance, token = await send( - wallet, amount, lock, legacy=False, split=not nosplit + wallet, amount=amount, lock=lock, legacy=False, split=not nosplit ) return SendResponse(balance=balance, token=token) else: - token, pubkey = await send_nostr(wallet, amount, nostr) + token, pubkey = await send_nostr(wallet, amount=amount, pubkey=nostr) return SendResponse(balance=wallet.available_balance, token=token, npub=pubkey) @@ -325,7 +325,7 @@ async def pending( enumerate( groupby( sorted_proofs, - key=itemgetter("send_id"), + key=itemgetter("send_id"), # type: ignore ) ), offset, @@ -334,9 +334,9 @@ async def pending( grouped_proofs = list(value) token = await wallet.serialize_proofs(grouped_proofs) tokenObj = deserialize_token_from_string(token) - mint = [t.mint for t in tokenObj.token][0] + mint = [t.mint for t in tokenObj.token if t.mint][0] reserved_date = datetime.utcfromtimestamp( - int(grouped_proofs[0].time_reserved) + int(grouped_proofs[0].time_reserved) # type: ignore ).strftime("%Y-%m-%d %H:%M:%S") result.update( { diff --git a/cashu/wallet/cli/cli.py b/cashu/wallet/cli/cli.py index 5dec8f1a..bdbe5167 100644 --- a/cashu/wallet/cli/cli.py +++ b/cashu/wallet/cli/cli.py @@ -352,6 +352,14 @@ async def balance(ctx: Context, verbose): type=str, ) @click.option("--lock", "-l", default=None, help="Lock tokens (P2SH).", type=str) +@click.option( + "--dleq", + "-d", + default=False, + is_flag=True, + help="Send with DLEQ proof.", + type=bool, +) @click.option( "--legacy", "-l", @@ -387,6 +395,7 @@ async def send_command( nostr: str, nopt: str, lock: str, + dleq: bool, legacy: bool, verbose: bool, yes: bool, @@ -394,9 +403,18 @@ async def send_command( ): wallet: Wallet = ctx.obj["WALLET"] if not nostr and not nopt: - await send(wallet, amount, lock, legacy, split=not nosplit) + await send( + wallet, + amount=amount, + lock=lock, + legacy=legacy, + split=not nosplit, + include_dleq=dleq, + ) else: - await send_nostr(wallet, amount, nostr or nopt, verbose, yes) + await send_nostr( + wallet, amount=amount, pubkey=nostr or nopt, verbose=verbose, yes=yes + ) @cli.command("receive", help="Receive tokens.") @@ -532,14 +550,15 @@ async def pending(ctx: Context, legacy, number: int, offset: int): enumerate( groupby( sorted_proofs, - key=itemgetter("send_id"), + key=itemgetter("send_id"), # type: ignore ) ), offset, number, ): grouped_proofs = list(value) - token = await wallet.serialize_proofs(grouped_proofs) + # TODO: we can't return DLEQ because we don't store it + token = await wallet.serialize_proofs(grouped_proofs, include_dleq=False) tokenObj = deserialize_token_from_string(token) mint = [t.mint for t in tokenObj.token][0] # token_hidden_secret = await wallet.serialize_proofs(grouped_proofs) diff --git a/cashu/wallet/crud.py b/cashu/wallet/crud.py index e229ccf1..fcdfab1a 100644 --- a/cashu/wallet/crud.py +++ b/cashu/wallet/crud.py @@ -1,3 +1,4 @@ +import json import time from typing import Any, List, Optional, Tuple @@ -9,12 +10,12 @@ async def store_proof( proof: Proof, db: Database, conn: Optional[Connection] = None, -): +) -> None: await (conn or db).execute( """ INSERT INTO proofs - (id, amount, C, secret, time_created, derivation_path) - VALUES (?, ?, ?, ?, ?, ?) + (id, amount, C, secret, time_created, derivation_path, dleq) + VALUES (?, ?, ?, ?, ?, ?, ?) """, ( proof.id, @@ -23,6 +24,7 @@ async def store_proof( str(proof.secret), int(time.time()), proof.derivation_path, + json.dumps(proof.dleq.dict()) if proof.dleq else "", ), ) @@ -30,29 +32,29 @@ async def store_proof( async def get_proofs( db: Database, conn: Optional[Connection] = None, -): +) -> List[Proof]: rows = await (conn or db).fetchall(""" SELECT * from proofs """) - return [Proof(**dict(r)) for r in rows] + return [Proof.from_dict(dict(r)) for r in rows] async def get_reserved_proofs( db: Database, conn: Optional[Connection] = None, -): +) -> List[Proof]: rows = await (conn or db).fetchall(""" SELECT * from proofs WHERE reserved """) - return [Proof(**r) for r in rows] + return [Proof.from_dict(dict(r)) for r in rows] async def invalidate_proof( proof: Proof, db: Database, conn: Optional[Connection] = None, -): +) -> None: await (conn or db).execute( """ DELETE FROM proofs @@ -84,7 +86,7 @@ async def update_proof_reserved( send_id: str = "", db: Optional[Database] = None, conn: Optional[Connection] = None, -): +) -> None: clauses = [] values: List[Any] = [] clauses.append("reserved = ?") @@ -109,7 +111,7 @@ async def secret_used( secret: str, db: Database, conn: Optional[Connection] = None, -): +) -> bool: rows = await (conn or db).fetchone( """ SELECT * from proofs @@ -124,7 +126,7 @@ async def store_p2sh( p2sh: P2SHScript, db: Database, conn: Optional[Connection] = None, -): +) -> None: await (conn or db).execute( """ INSERT INTO p2sh @@ -144,7 +146,7 @@ async def get_unused_locks( address: str = "", db: Optional[Database] = None, conn: Optional[Connection] = None, -): +) -> List[P2SHScript]: clause: List[str] = [] args: List[str] = [] @@ -173,7 +175,7 @@ async def update_p2sh_used( used: bool, db: Optional[Database] = None, conn: Optional[Connection] = None, -): +) -> None: clauses = [] values = [] clauses.append("used = ?") @@ -190,7 +192,7 @@ async def store_keyset( mint_url: str = "", db: Optional[Database] = None, conn: Optional[Connection] = None, -): +) -> None: await (conn or db).execute( # type: ignore """ INSERT INTO keysets @@ -243,7 +245,7 @@ async def store_lightning_invoice( db: Database, invoice: Invoice, conn: Optional[Connection] = None, -): +) -> None: await (conn or db).execute( """ INSERT INTO invoices @@ -266,7 +268,7 @@ async def get_lightning_invoice( db: Database, hash: str = "", conn: Optional[Connection] = None, -): +) -> Invoice: clauses = [] values: List[Any] = [] if hash: @@ -291,7 +293,7 @@ async def get_lightning_invoices( db: Database, paid: Optional[bool] = None, conn: Optional[Connection] = None, -): +) -> List[Invoice]: clauses: List[Any] = [] values: List[Any] = [] @@ -319,7 +321,7 @@ async def update_lightning_invoice( paid: bool, time_paid: Optional[int] = None, conn: Optional[Connection] = None, -): +) -> None: clauses = [] values: List[Any] = [] clauses.append("paid = ?") @@ -344,7 +346,7 @@ async def bump_secret_derivation( by: int = 1, skip: bool = False, conn: Optional[Connection] = None, -): +) -> int: rows = await (conn or db).fetchone( "SELECT counter from keysets WHERE id = ?", (keyset_id,) ) @@ -374,7 +376,7 @@ async def set_secret_derivation( keyset_id: str, counter: int, conn: Optional[Connection] = None, -): +) -> None: await (conn or db).execute( "UPDATE keysets SET counter = ? WHERE id = ?", ( @@ -388,7 +390,7 @@ async def set_nostr_last_check_timestamp( db: Database, timestamp: int, conn: Optional[Connection] = None, -): +) -> None: await (conn or db).execute( "UPDATE nostr SET last = ? WHERE type = ?", (timestamp, "dm"), @@ -398,7 +400,7 @@ async def set_nostr_last_check_timestamp( async def get_nostr_last_check_timestamp( db: Database, conn: Optional[Connection] = None, -): +) -> Optional[int]: row = await (conn or db).fetchone( """ SELECT last from nostr WHERE type = ? @@ -432,7 +434,7 @@ async def store_seed_and_mnemonic( seed: str, mnemonic: str, conn: Optional[Connection] = None, -): +) -> None: await (conn or db).execute( """ INSERT INTO seed diff --git a/cashu/wallet/helpers.py b/cashu/wallet/helpers.py index d6a1d289..418a6b75 100644 --- a/cashu/wallet/helpers.py +++ b/cashu/wallet/helpers.py @@ -160,7 +160,13 @@ async def receive( async def send( - wallet: Wallet, amount: int, lock: str, legacy: bool, split: bool = True + wallet: Wallet, + *, + amount: int, + lock: str, + legacy: bool, + split: bool = True, + include_dleq: bool = False, ): """ Prints token to send to stdout. @@ -207,14 +213,14 @@ async def send( "No proof with this amount found. Available amounts:" f" {set([p.amount for p in wallet.proofs])}" ) - await wallet.set_reserved(send_proofs, reserved=True) token = await wallet.serialize_proofs( send_proofs, include_mints=True, + include_dleq=include_dleq, ) print(token) - + await wallet.set_reserved(send_proofs, reserved=True) if legacy: print("") print("Old token format:") @@ -222,6 +228,7 @@ async def send( token = await wallet.serialize_proofs( send_proofs, legacy=True, + include_dleq=include_dleq, ) print(token) diff --git a/cashu/wallet/migrations.py b/cashu/wallet/migrations.py index 061b31c5..94ce47fa 100644 --- a/cashu/wallet/migrations.py +++ b/cashu/wallet/migrations.py @@ -173,3 +173,10 @@ async def m009_privatekey_and_determinstic_key_derivation(db: Database): ); """) # await db.execute("INSERT INTO secret_derivation (counter) VALUES (0)") + + +async def m010_add_proofs_dleq(db: Database): + """ + Columns to store DLEQ proofs for proofs. + """ + await db.execute("ALTER TABLE proofs ADD COLUMN dleq TEXT") diff --git a/cashu/wallet/nostr.py b/cashu/wallet/nostr.py index 3f508b71..69ac6534 100644 --- a/cashu/wallet/nostr.py +++ b/cashu/wallet/nostr.py @@ -45,10 +45,12 @@ async def nip5_to_pubkey(wallet: Wallet, address: str): async def send_nostr( wallet: Wallet, + *, amount: int, pubkey: str, verbose: bool = False, yes: bool = True, + include_dleq=False, ): """ Sends tokens via nostr. @@ -62,7 +64,7 @@ async def send_nostr( _, send_proofs = await wallet.split_to_send( wallet.proofs, amount, set_reserved=True ) - token = await wallet.serialize_proofs(send_proofs) + token = await wallet.serialize_proofs(send_proofs, include_dleq=include_dleq) if pubkey.startswith("npub"): pubkey_to = PublicKey().from_npub(pubkey) diff --git a/cashu/wallet/p2pk.py b/cashu/wallet/p2pk.py index ae95eb31..b176b2b4 100644 --- a/cashu/wallet/p2pk.py +++ b/cashu/wallet/p2pk.py @@ -21,7 +21,7 @@ sign_p2pk_sign, ) from ..core.script import ( - step0_carol_checksig_redeemscrip, + step0_carol_checksig_redeemscript, step0_carol_privkey, step1_carol_create_p2sh_address, step2_carol_sign_tx, @@ -41,7 +41,7 @@ class WalletP2PK(SupportsPrivateKey, SupportsDb): async def create_p2sh_address_and_store(self) -> str: """Creates a P2SH lock script and stores the script and signature in the database.""" alice_privkey = step0_carol_privkey() - txin_redeemScript = step0_carol_checksig_redeemscrip(alice_privkey.pub) + txin_redeemScript = step0_carol_checksig_redeemscript(alice_privkey.pub) txin_p2sh_address = step1_carol_create_p2sh_address(txin_redeemScript) txin_signature = step2_carol_sign_tx(txin_redeemScript, alice_privkey).scriptSig txin_redeemScript_b64 = base64.urlsafe_b64encode(txin_redeemScript).decode() diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 72650d93..bfbe9efd 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -19,6 +19,7 @@ CheckFeesRequest, CheckSpendableRequest, CheckSpendableResponse, + DLEQWallet, GetInfoResponse, GetMeltResponse, GetMintResponse, @@ -110,101 +111,11 @@ def __init__(self, url: str, db: Database): self.s = requests.Session() self.db = db - # async def generate_n_secrets( - # self, n: int = 1, skip_bump: bool = False - # ) -> Tuple[List[str], List[PrivateKey], List[str]]: - # return await self.generate_n_secrets(n, skip_bump) - - # async def _generate_secret(self, skip_bump: bool = False) -> str: - # return await self._generate_secret(skip_bump) - @async_set_requests async def _init_s(self): """Dummy function that can be called from outside to use LedgerAPI.s""" return - def _construct_proofs( - self, - promises: List[BlindedSignature], - secrets: List[str], - rs: List[PrivateKey], - derivation_paths: List[str], - ) -> List[Proof]: - """Constructs proofs from promises, secrets, rs and derivation paths. - - This method is called after the user has received blind signatures from - the mint. The results are proofs that can be used as ecash. - - Args: - promises (List[BlindedSignature]): blind signatures from mint - secrets (List[str]): secrets that were previously used to create blind messages (that turned into promises) - rs (List[PrivateKey]): blinding factors that were previously used to create blind messages (that turned into promises) - derivation_paths (List[str]): derivation paths that were used to generate secrets and blinding factors - - Returns: - List[Proof]: list of proofs that can be used as ecash - """ - logger.trace("Constructing proofs.") - proofs: List[Proof] = [] - for promise, secret, r, path in zip(promises, secrets, rs, derivation_paths): - logger.trace(f"Creating proof with keyset {self.keyset_id} = {promise.id}") - assert ( - self.keyset_id == promise.id - ), "our keyset id does not match promise id." - - C_ = PublicKey(bytes.fromhex(promise.C_), raw=True) - C = b_dhke.step3_alice(C_, r, self.public_keys[promise.amount]) - - proof = Proof( - id=promise.id, - amount=promise.amount, - C=C.serialize().hex(), - secret=secret, - derivation_path=path, - ) - proofs.append(proof) - logger.trace( - f"Created proof: {proof}, r: {r.serialize()} out of promise {promise}" - ) - - logger.trace(f"Constructed {len(proofs)} proofs.") - return proofs - - @staticmethod - def _construct_outputs( - amounts: List[int], secrets: List[str], rs: List[PrivateKey] = [] - ) -> Tuple[List[BlindedMessage], List[PrivateKey]]: - """Takes a list of amounts and secrets and returns outputs. - Outputs are blinded messages `outputs` and blinding factors `rs` - - Args: - amounts (List[int]): list of amounts - secrets (List[str]): list of secrets - rs (List[PrivateKey], optional): list of blinding factors. If not given, `rs` are generated in step1_alice. Defaults to []. - - Returns: - List[BlindedMessage]: list of blinded messages that can be sent to the mint - List[PrivateKey]: list of blinding factors that can be used to construct proofs after receiving blind signatures from the mint - - Raises: - AssertionError: if len(amounts) != len(secrets) - """ - assert len(amounts) == len( - secrets - ), f"len(amounts)={len(amounts)} not equal to len(secrets)={len(secrets)}" - outputs: List[BlindedMessage] = [] - - rs_ = [None] * len(amounts) if not rs else rs - rs_return: List[PrivateKey] = [] - for secret, amount, r in zip(secrets, amounts, rs_): - B_, r = b_dhke.step1_alice(secret, r or None) - rs_return.append(r) - output = BlindedMessage(amount=amount, B_=B_.serialize().hex()) - outputs.append(output) - logger.trace(f"Constructing output: {output}, r: {r.serialize()}") - - return outputs, rs_return - @staticmethod def raise_on_error(resp: Response) -> None: """Raises an exception if the response from the mint contains an error. @@ -465,9 +376,9 @@ async def mint( }, ) self.raise_on_error(resp) - reponse_dict = resp.json() + response_dict = resp.json() logger.trace("Lightning invoice checked. POST /mint") - promises = PostMintResponse.parse_obj(reponse_dict).promises + promises = PostMintResponse.parse_obj(response_dict).promises return promises @async_set_requests @@ -506,7 +417,7 @@ def _splitrequest_include_fields(proofs: List[Proof]): @async_set_requests async def check_proof_state(self, proofs: List[Proof]): """ - Cheks whether the secrets in proofs are already spent or not and returns a list of booleans. + Checks whether the secrets in proofs are already spent or not and returns a list of booleans. """ payload = CheckSpendableRequest(proofs=proofs) @@ -577,8 +488,8 @@ async def restore_promises( payload = PostMintRequest(outputs=outputs) resp = self.s.post(self.url + "/restore", json=payload.dict()) self.raise_on_error(resp) - reponse_dict = resp.json() - returnObj = PostRestoreResponse.parse_obj(reponse_dict) + response_dict = resp.json() + returnObj = PostRestoreResponse.parse_obj(response_dict) return returnObj.outputs, returnObj.promises @@ -609,7 +520,7 @@ def __init__( self.name = name super().__init__(url=url, db=self.db) - logger.debug(f"Wallet initalized with mint URL {url}") + logger.debug(f"Wallet initialized with mint URL {url}") @classmethod async def with_db( @@ -726,16 +637,12 @@ async def mint( await bump_secret_derivation( db=self.db, keyset_id=self.keyset_id, by=len(amounts) ) - proofs = self._construct_proofs(promises, secrets, rs, derivation_paths) + proofs = await self._construct_proofs(promises, secrets, rs, derivation_paths) - if proofs == []: - raise Exception("received no proofs.") - await self._store_proofs(proofs) if hash: await update_lightning_invoice( db=self.db, hash=hash, paid=True, time_paid=int(time.time()) ) - self.proofs += proofs return proofs async def redeem( @@ -749,6 +656,10 @@ async def redeem( Args: proofs (List[Proof]): Proofs to be redeemed. """ + # verify DLEQ of incoming proofs + logger.debug("Verifying DLEQ of incoming proofs.") + self.verify_proofs_dleq(proofs) + logger.debug("DLEQ verified.") return await self.split(proofs, sum_proofs(proofs)) async def split( @@ -797,9 +708,9 @@ async def split( logger.debug(f"Creating proofs with custom secrets: {secret_locks}") assert len(secret_locks) == len( scnd_outputs - ), "number of secret_locks does not match number of ouptus." + ), "number of secret_locks does not match number of outputs." # append predefined secrets (to send) to random secrets (to keep) - # generate sercets to keep + # generate secrets to keep secrets = [ await self._generate_secret() for s in range(len(frst_outputs)) ] + secret_locks @@ -822,18 +733,11 @@ async def split( promises = await super().split(proofs, outputs) # Construct proofs from returned promises (i.e., unblind the signatures) - new_proofs = self._construct_proofs(promises, secrets, rs, derivation_paths) + new_proofs = await self._construct_proofs( + promises, secrets, rs, derivation_paths + ) - # remove used proofs from wallet and add new ones - used_secrets = [p.secret for p in proofs] - self.proofs = list(filter(lambda p: p.secret not in used_secrets, self.proofs)) - # add new proofs to wallet - self.proofs += new_proofs - # store new proofs in database - await self._store_proofs(new_proofs) - # invalidate used proofs in database - for proof in proofs: - await invalidate_proof(proof, db=self.db) + await self.invalidate(proofs) keep_proofs = new_proofs[: len(frst_outputs)] send_proofs = new_proofs[len(frst_outputs) :] @@ -862,7 +766,6 @@ async def pay_lightning( if status.paid: # the payment was successful - await self.invalidate(proofs) invoice_obj = Invoice( amount=-sum_proofs(proofs), pr=invoice, @@ -877,14 +780,15 @@ async def pay_lightning( # handle change and produce proofs if status.change: - change_proofs = self._construct_proofs( + change_proofs = await self._construct_proofs( status.change, secrets[: len(status.change)], rs[: len(status.change)], derivation_paths[: len(status.change)], ) logger.debug(f"Received change: {sum_proofs(change_proofs)} sat") - await self._store_proofs(change_proofs) + + await self.invalidate(proofs) else: raise Exception("could not pay invoice.") @@ -895,10 +799,137 @@ async def check_proof_state(self, proofs): # ---------- TOKEN MECHANICS ---------- + # ---------- DLEQ PROOFS ---------- + + def verify_proofs_dleq(self, proofs: List[Proof]): + """Verifies DLEQ proofs in proofs.""" + for proof in proofs: + if not proof.dleq: + logger.trace("No DLEQ proof in proof.") + return + logger.trace("Verifying DLEQ proof.") + assert self.keys.public_keys + if not b_dhke.carol_verify_dleq( + secret_msg=proof.secret, + C=PublicKey(bytes.fromhex(proof.C), raw=True), + r=PrivateKey(bytes.fromhex(proof.dleq.r), raw=True), + e=PrivateKey(bytes.fromhex(proof.dleq.e), raw=True), + s=PrivateKey(bytes.fromhex(proof.dleq.s), raw=True), + A=self.keys.public_keys[proof.amount], + ): + raise Exception("DLEQ proof invalid.") + else: + logger.debug("DLEQ proof valid.") + + async def _construct_proofs( + self, + promises: List[BlindedSignature], + secrets: List[str], + rs: List[PrivateKey], + derivation_paths: List[str], + ) -> List[Proof]: + """Constructs proofs from promises, secrets, rs and derivation paths. + + This method is called after the user has received blind signatures from + the mint. The results are proofs that can be used as ecash. + + Args: + promises (List[BlindedSignature]): blind signatures from mint + secrets (List[str]): secrets that were previously used to create blind messages (that turned into promises) + rs (List[PrivateKey]): blinding factors that were previously used to create blind messages (that turned into promises) + derivation_paths (List[str]): derivation paths that were used to generate secrets and blinding factors + + Returns: + List[Proof]: list of proofs that can be used as ecash + """ + logger.trace("Constructing proofs.") + proofs: List[Proof] = [] + for promise, secret, r, path in zip(promises, secrets, rs, derivation_paths): + logger.trace(f"Creating proof with keyset {self.keyset_id} = {promise.id}") + assert ( + self.keyset_id == promise.id + ), "our keyset id does not match promise id." + + C_ = PublicKey(bytes.fromhex(promise.C_), raw=True) + C = b_dhke.step3_alice(C_, r, self.public_keys[promise.amount]) + B_, r = b_dhke.step1_alice(secret, r) # recompute B_ for dleq proofs + + proof = Proof( + id=promise.id, + amount=promise.amount, + C=C.serialize().hex(), + secret=secret, + derivation_path=path, + ) + + # if the mint returned a dleq proof, we add it to the proof + if promise.dleq: + proof.dleq = DLEQWallet( + e=promise.dleq.e, s=promise.dleq.s, r=r.serialize() + ) + + proofs.append(proof) + + logger.trace( + f"Created proof: {proof}, r: {r.serialize()} out of promise {promise}" + ) + + # DLEQ verify + self.verify_proofs_dleq(proofs) + + logger.trace(f"Constructed {len(proofs)} proofs.") + + # add new proofs to wallet + self.proofs += proofs + # store new proofs in database + await self._store_proofs(proofs) + + return proofs + + @staticmethod + def _construct_outputs( + amounts: List[int], secrets: List[str], rs: List[PrivateKey] = [] + ) -> Tuple[List[BlindedMessage], List[PrivateKey]]: + """Takes a list of amounts and secrets and returns outputs. + Outputs are blinded messages `outputs` and blinding factors `rs` + + Args: + amounts (List[int]): list of amounts + secrets (List[str]): list of secrets + rs (List[PrivateKey], optional): list of blinding factors. If not given, `rs` are generated in step1_alice. Defaults to []. + + Returns: + List[BlindedMessage]: list of blinded messages that can be sent to the mint + List[PrivateKey]: list of blinding factors that can be used to construct proofs after receiving blind signatures from the mint + + Raises: + AssertionError: if len(amounts) != len(secrets) + """ + assert len(amounts) == len( + secrets + ), f"len(amounts)={len(amounts)} not equal to len(secrets)={len(secrets)}" + outputs: List[BlindedMessage] = [] + + rs_ = [None] * len(amounts) if not rs else rs + rs_return: List[PrivateKey] = [] + for secret, amount, r in zip(secrets, amounts, rs_): + B_, r = b_dhke.step1_alice(secret, r or None) + rs_return.append(r) + output = BlindedMessage(amount=amount, B_=B_.serialize().hex()) + outputs.append(output) + logger.trace(f"Constructing output: {output}, r: {r.serialize()}") + + return outputs, rs_return + async def _store_proofs(self, proofs): - async with self.db.connect() as conn: - for proof in proofs: - await store_proof(proof, db=self.db, conn=conn) + try: + async with self.db.connect() as conn: # type: ignore + for proof in proofs: + await store_proof(proof, db=self.db, conn=conn) + except Exception as e: + logger.error(f"Could not store proofs in database: {e}") + logger.error(proofs) + raise e @staticmethod def _get_proofs_per_keyset(proofs: List[Proof]): @@ -981,7 +1012,7 @@ async def _make_token(self, proofs: List[Proof], include_mints=True) -> TokenV3: return token async def serialize_proofs( - self, proofs: List[Proof], include_mints=True, legacy=False + self, proofs: List[Proof], include_mints=True, include_dleq=False, legacy=False ) -> str: """Produces sharable token with proofs and mint information. @@ -1007,7 +1038,7 @@ async def serialize_proofs( # V3 tokens token = await self._make_token(proofs, include_mints) - return token.serialize() + return token.serialize(include_dleq) async def _make_token_v2(self, proofs: List[Proof], include_mints=True) -> TokenV2: """ @@ -1016,6 +1047,7 @@ async def _make_token_v2(self, proofs: List[Proof], include_mints=True) -> Token """ # build token token = TokenV2(proofs=proofs) + # add mint information to the token, if requested if include_mints: # dummy object to hold information about the mint @@ -1132,7 +1164,7 @@ async def invalidate( invalidated_proofs = proofs if invalidated_proofs: - logger.debug( + logger.trace( f"Invalidating {len(invalidated_proofs)} proofs worth" f" {sum_proofs(invalidated_proofs)} sat." ) @@ -1235,7 +1267,7 @@ async def restore_wallet_from_mnemonic( Args: mnemonic (Optional[str]): The mnemonic to restore the wallet from. If None, the mnemonic is loaded from the db. - to (int, optional): The number of consecutive empty reponses to stop restoring. Defaults to 2. + to (int, optional): The number of consecutive empty responses to stop restoring. Defaults to 2. batch (int, optional): The number of proofs to restore in one batch. Defaults to 25. """ await self._init_private_key(mnemonic) @@ -1295,7 +1327,7 @@ async def restore_promises_from_to( ) # we don't know the amount but luckily the mint will tell us so we use a dummy amount here amounts_dummy = [1] * len(secrets) - # we generate outptus from deterministic secrets and rs + # we generate outputs from deterministic secrets and rs regenerated_outputs, _ = self._construct_outputs(amounts_dummy, secrets, rs) # we ask the mint to reissue the promises proofs = await self.restore_promises( @@ -1339,14 +1371,8 @@ async def restore_promises( secrets = [secrets[i] for i in matching_indices] rs = [rs[i] for i in matching_indices] # now we can construct the proofs with the secrets and rs - proofs = self._construct_proofs( + proofs = await self._construct_proofs( restored_promises, secrets, rs, derivation_paths ) logger.debug(f"Restored {len(restored_promises)} promises") - await self._store_proofs(proofs) - - # append proofs to proofs in memory so the balance updates - for proof in proofs: - if proof.secret not in [p.secret for p in self.proofs]: - self.proofs.append(proof) return proofs diff --git a/tests/test_cli.py b/tests/test_cli.py index b2d2e659..bc1208fd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,6 +3,7 @@ import pytest from click.testing import CliRunner +from cashu.core.base import TokenV3 from cashu.core.settings import settings from cashu.wallet.cli.cli import cli from cashu.wallet.wallet import Wallet @@ -123,9 +124,38 @@ def test_send(mint, cli_prefix): [*cli_prefix, "send", "10"], ) assert result.exception is None - print("SEND") print(result.output) - assert "cashuA" in result.output, "output does not have a token" + token_str = result.output.split("\n")[0] + assert "cashuA" in token_str, "output does not have a token" + token = TokenV3.deserialize(token_str) + assert token.token[0].proofs[0].dleq is None, "dleq included" + + +def test_send_with_dleq(mint, cli_prefix): + runner = CliRunner() + result = runner.invoke( + cli, + [*cli_prefix, "send", "10", "--dleq"], + ) + assert result.exception is None + print(result.output) + token_str = result.output.split("\n")[0] + assert "cashuA" in token_str, "output does not have a token" + token = TokenV3.deserialize(token_str) + assert token.token[0].proofs[0].dleq is not None, "no dleq included" + + +def test_send_legacy(mint, cli_prefix): + runner = CliRunner() + result = runner.invoke( + cli, + [*cli_prefix, "send", "10", "--legacy"], + ) + assert result.exception is None + print(result.output) + # this is the legacy token in the output + token_str = result.output.split("\n")[4] + assert token_str.startswith("eyJwcm9v"), "output is not as expected" def test_send_without_split(mint, cli_prefix): @@ -243,3 +273,14 @@ def test_nostr_send(mint, cli_prefix): assert result.exception is None print("NOSTR_SEND") print(result.output) + + +def test_pending(cli_prefix): + runner = CliRunner() + result = runner.invoke( + cli, + [*cli_prefix, "pending"], + ) + assert result.exception is None + print(result.output) + assert result.exit_code == 0 diff --git a/tests/test_core.py b/tests/test_core.py index 5a46d16c..e41df38c 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -31,17 +31,79 @@ def test_tokenv3_get_proofs(): assert len(token.get_proofs()) == 2 +def test_tokenv3_deserialize_serialize_with_dleq(): + token_str = ( + "cashuAeyJ0b2tlbiI6IFt7InByb29mcyI6IFt7ImlkIjogIjFjQ05JQVoyWC93M" + "SIsICJhbW91bnQiOiAyLCAic2VjcmV0IjogIjZmZjFiY2VlOGUzMzk2NGE4ZDNjNGQ5NzYwNzdiZ" + "DI4ZGVkZWJkODYyMDU0MDQzNDY4ZjU5ZDFiZjI1OTQzN2QiLCAiQyI6ICIwM2I3ZD" + "lkMzIzYTAxOWJlNTE4NzRlOGE5OGY1NDViOTg3Y2JmNmU5MWUwMDc1YTFhZjQ3MjY2NDMxOGRlZ" + "TQzZTUiLCAiZGxlcSI6IHsiZSI6ICI1ZjkxMGQ4NTc0M2U0OTI0ZjRiNjlkNzhjM" + "jFjYTc1ZjEzNzg3Zjc3OTE1NWRmMjMzMjJmYTA1YjU5ODdhYzNmIiwgInMiOiAiZTc4Y2U0MzNiZ" + "WNlZTNjNGU1NzM4ZDdjMzRlNDQyZWQ0MmJkMzk0MjI0ZTc3MjE4OGFjMmI5MzZmM" + "jA2Y2QxYSIsICJyIjogIjI3MzM3ODNmOTQ4MWZlYzAxNzdlYmM4ZjBhOTI2OWVjOGFkNzU5MDU2ZT" + "k3MTRiMWEwYTEwMDQ3MmY2Y2Y5YzIifX0sIHsiaWQiOiAiMWNDTklBWjJYL3cxIi" + "wgImFtb3VudCI6IDgsICJzZWNyZXQiOiAiMmFkNDMyZDRkNTg2MzJiMmRlMzI0ZmQxYmE5OTcyZmE" + "4MDljNmU3ZGE1ZTkyZWVmYjBiNjYxMmQ5M2Q3ZTAwMCIsICJDIjogIjAzMmFmYjg" + "zOWQwMmRmMWNhOGY5ZGZjNTI1NzUxN2Q0MzY4YjdiMTc0MzgzM2JlYWUzZDQzNmExYmQwYmJkYjVk" + "OCIsICJkbGVxIjogeyJlIjogImY0NjM2MzU5YTUzZGQxNGEyNmUyNTMyMDQxZWIx" + "MDE2OTk1ZTg4NzgwODY0OWFlY2VlNTcwZTA5ZTk2NTU3YzIiLCAicyI6ICJmZWYzMGIzMDcwMDJkMW" + "VjNWZiZjg0ZGZhZmRkMGEwOTdkNDJlMDYxNTZiNzdiMTMzMmNjNGZjNGNjYWEyOD" + "JmIiwgInIiOiAiODQ5MjQxNzBlYzc3ZjhjMDNmZDRlZTkyZTA3MjdlMzYyNTliZjRhYTc4NTBjZTc2" + "NDExMDQ0MmNlNmVlM2FjYyJ9fV0sICJtaW50IjogImh0dHA6Ly9sb2NhbGhvc3Q6MzMzOCJ9XX0=" + ) + token = TokenV3.deserialize(token_str) + assert token.serialize(include_dleq=True) == token_str + + def test_tokenv3_deserialize_serialize(): token_str = ( - "cashuAeyJ0b2tlbiI6IFt7InByb29mcyI6IFt7ImlkIjogIkplaFpMVTZuQ3BSZCIsICJhbW91bnQiOiAyLCAic2VjcmV0IjogIjBFN2lDazRkVmxSZjVQRjFnNFpWMnci" - "LCAiQyI6ICIwM2FiNTgwYWQ5NTc3OGVkNTI5NmY4YmVlNjU1ZGJkN2Q2NDJmNWQzMmRlOGUyNDg0NzdlMGI0ZDZhYTg2M2ZjZDUifSwgeyJpZCI6ICJKZWhaTFU2bkNwUmQiLCAiYW" - "1vdW50IjogOCwgInNlY3JldCI6ICJzNklwZXh3SGNxcXVLZDZYbW9qTDJnIiwgIkMiOiAiMDIyZDAwNGY5ZWMxNmE1OGFkOTAxNGMyNTliNmQ2MTRlZDM2ODgyOWYwMmMzODc3M2M0" + "cashuAeyJ0b2tlbiI6IFt7InByb29mcyI6IFt7ImlkIjogIkplaFpMVTZuQ3BSZCIsICJh" + "bW91bnQiOiAyLCAic2VjcmV0IjogIjBFN2lDazRkVmxSZjVQRjFnNFpWMnci" + "LCAiQyI6ICIwM2FiNTgwYWQ5NTc3OGVkNTI5NmY4YmVlNjU1ZGJkN2Q2NDJmNWQzMmRlOG" + "UyNDg0NzdlMGI0ZDZhYTg2M2ZjZDUifSwgeyJpZCI6ICJKZWhaTFU2bkNwUmQiLCAiYW" + "1vdW50IjogOCwgInNlY3JldCI6ICJzNklwZXh3SGNxcXVLZDZYbW9qTDJnIiwgIkMiOiAiM" + "DIyZDAwNGY5ZWMxNmE1OGFkOTAxNGMyNTliNmQ2MTRlZDM2ODgyOWYwMmMzODc3M2M0" "NzIyMWY0OTYxY2UzZjIzIn1dLCAibWludCI6ICJodHRwOi8vbG9jYWxob3N0OjMzMzgifV19" ) token = TokenV3.deserialize(token_str) assert token.serialize() == token_str +def test_tokenv3_deserialize_serialize_no_dleq(): + token_str = ( + "cashuAeyJ0b2tlbiI6IFt7InByb29mcyI6IFt7ImlkIjogIjFjQ05JQVoyWC93MSIsICJhb" + "W91bnQiOiAyLCAic2VjcmV0IjogIjZmZjFiY2VlOGUzMzk2NGE4ZDNjNGQ5NzYwNzdiZ" + "DI4ZGVkZWJkODYyMDU0MDQzNDY4ZjU5ZDFiZjI1OTQzN2QiLCAiQyI6ICIwM2I3ZDlkMzIzY" + "TAxOWJlNTE4NzRlOGE5OGY1NDViOTg3Y2JmNmU5MWUwMDc1YTFhZjQ3MjY2NDMxOGRlZ" + "TQzZTUiLCAiZGxlcSI6IHsiZSI6ICI1ZjkxMGQ4NTc0M2U0OTI0ZjRiNjlkNzhjMjFjYTc1Z" + "jEzNzg3Zjc3OTE1NWRmMjMzMjJmYTA1YjU5ODdhYzNmIiwgInMiOiAiZTc4Y2U0MzNiZ" + "WNlZTNjNGU1NzM4ZDdjMzRlNDQyZWQ0MmJkMzk0MjI0ZTc3MjE4OGFjMmI5MzZmMjA2Y2QxY" + "SIsICJyIjogIjI3MzM3ODNmOTQ4MWZlYzAxNzdlYmM4ZjBhOTI2OWVjOGFkNzU5MDU2ZT" + "k3MTRiMWEwYTEwMDQ3MmY2Y2Y5YzIifX0sIHsiaWQiOiAiMWNDTklBWjJYL3cxIiwgImFtb3" + "VudCI6IDgsICJzZWNyZXQiOiAiMmFkNDMyZDRkNTg2MzJiMmRlMzI0ZmQxYmE5OTcyZmE" + "4MDljNmU3ZGE1ZTkyZWVmYjBiNjYxMmQ5M2Q3ZTAwMCIsICJDIjogIjAzMmFmYjgzOWQwMmR" + "mMWNhOGY5ZGZjNTI1NzUxN2Q0MzY4YjdiMTc0MzgzM2JlYWUzZDQzNmExYmQwYmJkYjVk" + "OCIsICJkbGVxIjogeyJlIjogImY0NjM2MzU5YTUzZGQxNGEyNmUyNTMyMDQxZWIxMDE2OTk1" + "ZTg4NzgwODY0OWFlY2VlNTcwZTA5ZTk2NTU3YzIiLCAicyI6ICJmZWYzMGIzMDcwMDJkMW" + "VjNWZiZjg0ZGZhZmRkMGEwOTdkNDJlMDYxNTZiNzdiMTMzMmNjNGZjNGNjYWEyODJmIiwgIn" + "IiOiAiODQ5MjQxNzBlYzc3ZjhjMDNmZDRlZTkyZTA3MjdlMzYyNTliZjRhYTc4NTBjZTc2" + "NDExMDQ0MmNlNmVlM2FjYyJ9fV0sICJtaW50IjogImh0dHA6Ly9sb2NhbGhvc3Q6MzMzOCJ9XX0=" + ) + token_str_no_dleq = ( + "cashuAeyJ0b2tlbiI6IFt7InByb29mcyI6IFt7ImlkIjogIjFjQ05JQVoyWC93MSIsICJhbW91bn" + "QiOiAyLCAic2VjcmV0IjogIjZmZjFiY2VlOGUzMzk2NGE4ZDNjNGQ5NzYwNzdiZDI4" + "ZGVkZWJkODYyMDU0MDQzNDY4ZjU5ZDFiZjI1OTQzN2QiLCAiQyI6ICIwM2I3ZDlkMzIzYTAxOWJlN" + "TE4NzRlOGE5OGY1NDViOTg3Y2JmNmU5MWUwMDc1YTFhZjQ3MjY2NDMxOGRlZTQzZTU" + "ifSwgeyJpZCI6ICIxY0NOSUFaMlgvdzEiLCAiYW1vdW50IjogOCwgInNlY3JldCI6ICIyYWQ0MzJkN" + "GQ1ODYzMmIyZGUzMjRmZDFiYTk5NzJmYTgwOWM2ZTdkYTVlOTJlZWZiMGI2NjEyZD" + "kzZDdlMDAwIiwgIkMiOiAiMDMyYWZiODM5ZDAyZGYxY2E4ZjlkZmM1MjU3NTE3ZDQzNjhiN2IxNzQz" + "ODMzYmVhZTNkNDM2YTFiZDBiYmRiNWQ4In1dLCAibWludCI6ICJodHRwOi8vbG9jY" + "Wxob3N0OjMzMzgifV19" + ) + token = TokenV3.deserialize(token_str) + assert token.serialize(include_dleq=False) == token_str_no_dleq + + def test_tokenv3_deserialize_with_memo(): token_str = ( "cashuAeyJ0b2tlbiI6IFt7InByb29mcyI6IFt7ImlkIjogIkplaFpMVTZuQ3BSZCIsICJhbW91bnQiOiAyLCAic2VjcmV0IjogIjBFN2lDazRkVmxSZjV" diff --git a/tests/test_crypto.py b/tests/test_crypto.py index 9afc5bb6..59b94849 100644 --- a/tests/test_crypto.py +++ b/tests/test_crypto.py @@ -1,4 +1,13 @@ -from cashu.core.crypto.b_dhke import hash_to_curve, step1_alice, step2_bob, step3_alice +from cashu.core.crypto.b_dhke import ( + alice_verify_dleq, + carol_verify_dleq, + hash_e, + hash_to_curve, + step1_alice, + step2_bob, + step2_bob_dleq, + step3_alice, +) from cashu.core.crypto.secp import PrivateKey, PublicKey @@ -38,9 +47,9 @@ def test_hash_to_curve_iteration(): def test_step1(): - """""" + secret_msg = "test_message" B_, blinding_factor = step1_alice( - "test_message", + secret_msg, blinding_factor=PrivateKey( privkey=bytes.fromhex( "0000000000000000000000000000000000000000000000000000000000000001" @@ -73,7 +82,7 @@ def test_step2(): ), raw=True, ) - C_ = step2_bob(B_, a) + C_, e, s = step2_bob(B_, a) assert ( C_.serialize().hex() == "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2" @@ -107,3 +116,187 @@ def test_step3(): C.serialize().hex() == "03c724d7e6a5443b39ac8acf11f40420adc4f99a02e7cc1b57703d9391f6d129cd" ) + + +def test_dleq_hash_e(): + C_ = PublicKey( + bytes.fromhex( + "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2" + ), + raw=True, + ) + K = PublicKey( + pubkey=b"\x02" + + bytes.fromhex( + "0000000000000000000000000000000000000000000000000000000000000001", + ), + raw=True, + ) + R1 = PublicKey( + pubkey=b"\x02" + + bytes.fromhex( + "0000000000000000000000000000000000000000000000000000000000000001", + ), + raw=True, + ) + R2 = PublicKey( + pubkey=b"\x02" + + bytes.fromhex( + "0000000000000000000000000000000000000000000000000000000000000001", + ), + raw=True, + ) + e = hash_e(R1, R2, K, C_) + assert e.hex() == "a4dc034b74338c28c6bc3ea49731f2a24440fc7c4affc08b31a93fc9fbe6401e" + + +def test_dleq_step2_bob_dleq(): + B_, _ = step1_alice( + "test_message", + blinding_factor=PrivateKey( + privkey=bytes.fromhex( + "0000000000000000000000000000000000000000000000000000000000000001" + ), + raw=True, + ), + ) + a = PrivateKey( + privkey=bytes.fromhex( + "0000000000000000000000000000000000000000000000000000000000000001" + ), + raw=True, + ) + p_bytes = bytes.fromhex( + "0000000000000000000000000000000000000000000000000000000000000001" + ) # 32 bytes + e, s = step2_bob_dleq(B_, a, p_bytes) + assert ( + e.serialize() + == "9818e061ee51d5c8edc3342369a554998ff7b4381c8652d724cdf46429be73d9" + ) + assert ( + s.serialize() + == "9818e061ee51d5c8edc3342369a554998ff7b4381c8652d724cdf46429be73da" + ) # differs from e only in least significant byte because `a = 0x1` + + # change `a` + a = PrivateKey( + privkey=bytes.fromhex( + "0000000000000000000000000000000000000000000000000000000000001111" + ), + raw=True, + ) + e, s = step2_bob_dleq(B_, a, p_bytes) + assert ( + e.serialize() + == "df1984d5c22f7e17afe33b8669f02f530f286ae3b00a1978edaf900f4721f65e" + ) + assert ( + s.serialize() + == "828404170c86f240c50ae0f5fc17bb6b82612d46b355e046d7cd84b0a3c934a0" + ) + + +def test_dleq_alice_verify_dleq(): + # e from test_step2_bob_dleq for a=0x1 + e = PrivateKey( + bytes.fromhex( + "9818e061ee51d5c8edc3342369a554998ff7b4381c8652d724cdf46429be73d9" + ), + raw=True, + ) + # s from test_step2_bob_dleq for a=0x1 + s = PrivateKey( + bytes.fromhex( + "9818e061ee51d5c8edc3342369a554998ff7b4381c8652d724cdf46429be73da" + ), + raw=True, + ) + + a = PrivateKey( + privkey=bytes.fromhex( + "0000000000000000000000000000000000000000000000000000000000000001" + ), + raw=True, + ) + A = a.pubkey + assert A + # B_ is the same as we did: + # B_, _ = step1_alice( + # "test_message", + # blinding_factor=bytes.fromhex( + # "0000000000000000000000000000000000000000000000000000000000000001" + # ), # 32 bytes + # ) + B_ = PublicKey( + bytes.fromhex( + "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2" + ), + raw=True, + ) + + # # C_ is the same as if we did: + # a = PrivateKey( + # privkey=bytes.fromhex( + # "0000000000000000000000000000000000000000000000000000000000000001" + # ), + # raw=True, + # ) + # C_, e, s = step2_bob(B_, a) + + C_ = PublicKey( + bytes.fromhex( + "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2" + ), + raw=True, + ) + + assert alice_verify_dleq(B_, C_, e, s, A) + + +def test_dleq_alice_direct_verify_dleq(): + # ----- test again with B_ and C_ as per step1 and step2 + a = PrivateKey( + privkey=bytes.fromhex( + "0000000000000000000000000000000000000000000000000000000000000001" + ), + raw=True, + ) + A = a.pubkey + assert A + B_, _ = step1_alice( + "test_message", + blinding_factor=PrivateKey( + privkey=bytes.fromhex( + "0000000000000000000000000000000000000000000000000000000000000001" + ), + raw=True, + ), + ) + C_, e, s = step2_bob(B_, a) + assert alice_verify_dleq(B_, C_, e, s, A) + + +def test_dleq_carol_varify_from_bob(): + a = PrivateKey( + privkey=bytes.fromhex( + "0000000000000000000000000000000000000000000000000000000000000001" + ), + raw=True, + ) + A = a.pubkey + assert A + secret_msg = "test_message" + r = PrivateKey( + privkey=bytes.fromhex( + "0000000000000000000000000000000000000000000000000000000000000001" + ), + raw=True, + ) + B_, _ = step1_alice(secret_msg, r) + C_, e, s = step2_bob(B_, a) + assert alice_verify_dleq(B_, C_, e, s, A) + C = step3_alice(C_, r, A) + + # carol does not know B_ and C_, but she receives C and r from Alice + assert carol_verify_dleq(secret_msg=secret_msg, C=C, r=r, e=e, s=s, A=A) diff --git a/tests/test_mint.py b/tests/test_mint.py index ae9ed4a9..cf95caca 100644 --- a/tests/test_mint.py +++ b/tests/test_mint.py @@ -107,6 +107,12 @@ async def test_generate_promises(ledger: Ledger): promises[0].C_ == "037074c4f53e326ee14ed67125f387d160e0e729351471b69ad41f7d5d21071e15" ) + assert promises[0].amount == 8 + + # DLEQ proof present + assert promises[0].dleq + assert promises[0].dleq.s + assert promises[0].dleq.e @pytest.mark.asyncio diff --git a/tests/test_wallet.py b/tests/test_wallet.py index f4ec78b6..b4e5cb07 100644 --- a/tests/test_wallet.py +++ b/tests/test_wallet.py @@ -172,6 +172,7 @@ async def test_mint_amounts_wrong_order(wallet1: Wallet): @pytest.mark.asyncio async def test_split(wallet1: Wallet): await wallet1.mint(64) + assert wallet1.balance == 64 p1, p2 = await wallet1.split(wallet1.proofs, 20) assert wallet1.balance == 64 assert sum_proofs(p1) == 44 From f1b621fa90e703562e39dc408bbd5b1693c9e167 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 23 Sep 2023 19:08:38 +0200 Subject: [PATCH 07/10] HTLCs (#325) * add htlc files * refactor mint into several components * add hash lock signatures * add refund signature checks * simplify hash lock signature check * clean up --- cashu/core/base.py | 2 + cashu/core/htlc.py | 17 ++ cashu/core/p2pk.py | 72 +------ cashu/core/script.py | 4 +- cashu/core/secret.py | 76 ++++++++ cashu/mint/conditions.py | 285 ++++++++++++++++++++++++++++ cashu/mint/ledger.py | 372 ++----------------------------------- cashu/mint/protocols.py | 8 + cashu/mint/verification.py | 144 ++++++++++++++ cashu/wallet/htlc.py | 56 ++++++ cashu/wallet/p2pk.py | 4 +- cashu/wallet/wallet.py | 14 +- tests/test_wallet_htlc.py | 238 ++++++++++++++++++++++++ tests/test_wallet_p2pk.py | 3 +- 14 files changed, 865 insertions(+), 430 deletions(-) create mode 100644 cashu/core/htlc.py create mode 100644 cashu/core/secret.py create mode 100644 cashu/mint/conditions.py create mode 100644 cashu/mint/protocols.py create mode 100644 cashu/mint/verification.py create mode 100644 cashu/wallet/htlc.py create mode 100644 tests/test_wallet_htlc.py diff --git a/cashu/core/base.py b/cashu/core/base.py index d0b465e7..5cef1499 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -48,6 +48,8 @@ class Proof(BaseModel): p2pksigs: Union[List[str], None] = [] # P2PK signature p2shscript: Union[P2SHScript, None] = None # P2SH spending condition + htlcpreimage: Union[str, None] = None # HTLC unlocking preimage + htlcsignature: Union[str, None] = None # HTLC unlocking signature # whether this proof is reserved for sending, used for coin management in the wallet reserved: Union[None, bool] = False # unique ID of send attempt, used for grouping pending tokens in the wallet diff --git a/cashu/core/htlc.py b/cashu/core/htlc.py new file mode 100644 index 00000000..723227d7 --- /dev/null +++ b/cashu/core/htlc.py @@ -0,0 +1,17 @@ +from typing import Union + +from .secret import Secret, SecretKind + + +class HTLCSecret(Secret): + @classmethod + def from_secret(cls, secret: Secret): + assert secret.kind == SecretKind.HTLC, "Secret is not a HTLC secret" + # NOTE: exclude tags in .dict() because it doesn't deserialize it properly + # need to add it back in manually with tags=secret.tags + return cls(**secret.dict(exclude={"tags"}), tags=secret.tags) + + @property + def locktime(self) -> Union[None, int]: + locktime = self.tags.get_tag("locktime") + return int(locktime) if locktime else None diff --git a/cashu/core/p2pk.py b/cashu/core/p2pk.py index 0d97b345..1364649b 100644 --- a/cashu/core/p2pk.py +++ b/cashu/core/p2pk.py @@ -1,17 +1,12 @@ import hashlib -import json import time -from typing import Any, Dict, List, Optional, Union +from typing import List, Union from loguru import logger from pydantic import BaseModel from .crypto.secp import PrivateKey, PublicKey - - -class SecretKind: - P2SH = "P2SH" - P2PK = "P2PK" +from .secret import Secret, SecretKind class SigFlags: @@ -21,69 +16,6 @@ class SigFlags: SIG_ALL = "SIG_ALL" # require signatures on inputs and outputs -class Tags(BaseModel): - """ - Tags are used to encode additional information in the Secret of a Proof. - """ - - __root__: List[List[str]] = [] - - def __init__(self, tags: Optional[List[List[str]]] = None, **kwargs): - super().__init__(**kwargs) - self.__root__ = tags or [] - - def __setitem__(self, key: str, value: str) -> None: - self.__root__.append([key, value]) - - def __getitem__(self, key: str) -> Union[str, None]: - return self.get_tag(key) - - def get_tag(self, tag_name: str) -> Union[str, None]: - for tag in self.__root__: - if tag[0] == tag_name: - return tag[1] - return None - - def get_tag_all(self, tag_name: str) -> List[str]: - all_tags = [] - for tag in self.__root__: - if tag[0] == tag_name: - for t in tag[1:]: - all_tags.append(t) - return all_tags - - -class Secret(BaseModel): - """Describes spending condition encoded in the secret field of a Proof.""" - - kind: str - data: str - tags: Tags - nonce: Union[None, str] = None - - def serialize(self) -> str: - data_dict: Dict[str, Any] = { - "data": self.data, - "nonce": self.nonce or PrivateKey().serialize()[:32], - } - if self.tags.__root__: - logger.debug(f"Serializing tags: {self.tags.__root__}") - data_dict["tags"] = self.tags.__root__ - return json.dumps( - [self.kind, data_dict], - ) - - @classmethod - def deserialize(cls, from_proof: str): - kind, kwargs = json.loads(from_proof) - data = kwargs.pop("data") - nonce = kwargs.pop("nonce") - tags_list: List = kwargs.pop("tags", None) - tags = Tags(tags=tags_list) - logger.debug(f"Deserialized Secret: {kind}, {data}, {nonce}, {tags}") - return cls(kind=kind, data=data, nonce=nonce, tags=tags) - - class P2PKSecret(Secret): @classmethod def from_secret(cls, secret: Secret): diff --git a/cashu/core/script.py b/cashu/core/script.py index 19827090..3fc682f8 100644 --- a/cashu/core/script.py +++ b/cashu/core/script.py @@ -27,7 +27,7 @@ def step0_carol_privkey(): def step0_carol_checksig_redeemscript(carol_pubkey): """Create script""" - txin_redeemScript = CScript([carol_pubkey, OP_CHECKSIG]) + txin_redeemScript = CScript([carol_pubkey, OP_CHECKSIG]) # type: ignore # txin_redeemScript = CScript([-123, OP_CHECKLOCKTIMEVERIFY]) # txin_redeemScript = CScript([3, 3, OP_LESSTHAN, OP_VERIFY]) return txin_redeemScript @@ -58,7 +58,7 @@ def step2_carol_sign_tx(txin_redeemScript, privatekey): tx, txin = step1_bob_carol_create_tx(txin_p2sh_address) sighash = SignatureHash(txin_redeemScript, tx, 0, SIGHASH_ALL) sig = privatekey.sign(sighash) + bytes([SIGHASH_ALL]) - txin.scriptSig = CScript([sig, txin_redeemScript]) + txin.scriptSig = CScript([sig, txin_redeemScript]) # type: ignore return txin diff --git a/cashu/core/secret.py b/cashu/core/secret.py new file mode 100644 index 00000000..72bbd1f9 --- /dev/null +++ b/cashu/core/secret.py @@ -0,0 +1,76 @@ +import json +from typing import Any, Dict, List, Optional, Union + +from loguru import logger +from pydantic import BaseModel + +from .crypto.secp import PrivateKey + + +class SecretKind: + P2SH = "P2SH" + P2PK = "P2PK" + HTLC = "HTLC" + + +class Tags(BaseModel): + """ + Tags are used to encode additional information in the Secret of a Proof. + """ + + __root__: List[List[str]] = [] + + def __init__(self, tags: Optional[List[List[str]]] = None, **kwargs): + super().__init__(**kwargs) + self.__root__ = tags or [] + + def __setitem__(self, key: str, value: str) -> None: + self.__root__.append([key, value]) + + def __getitem__(self, key: str) -> Union[str, None]: + return self.get_tag(key) + + def get_tag(self, tag_name: str) -> Union[str, None]: + for tag in self.__root__: + if tag[0] == tag_name: + return tag[1] + return None + + def get_tag_all(self, tag_name: str) -> List[str]: + all_tags = [] + for tag in self.__root__: + if tag[0] == tag_name: + for t in tag[1:]: + all_tags.append(t) + return all_tags + + +class Secret(BaseModel): + """Describes spending condition encoded in the secret field of a Proof.""" + + kind: str + data: str + tags: Tags + nonce: Union[None, str] = None + + def serialize(self) -> str: + data_dict: Dict[str, Any] = { + "data": self.data, + "nonce": self.nonce or PrivateKey().serialize()[:32], + } + if self.tags.__root__: + logger.debug(f"Serializing tags: {self.tags.__root__}") + data_dict["tags"] = self.tags.__root__ + return json.dumps( + [self.kind, data_dict], + ) + + @classmethod + def deserialize(cls, from_proof: str): + kind, kwargs = json.loads(from_proof) + data = kwargs.pop("data") + nonce = kwargs.pop("nonce") + tags_list: List = kwargs.pop("tags", None) + tags = Tags(tags=tags_list) + logger.debug(f"Deserialized Secret: {kind}, {data}, {nonce}, {tags}") + return cls(kind=kind, data=data, nonce=nonce, tags=tags) diff --git a/cashu/mint/conditions.py b/cashu/mint/conditions.py new file mode 100644 index 00000000..98843950 --- /dev/null +++ b/cashu/mint/conditions.py @@ -0,0 +1,285 @@ +import hashlib +import time +from typing import List + +from loguru import logger + +from ..core.base import ( + BlindedMessage, + Proof, +) +from ..core.crypto.secp import PublicKey +from ..core.errors import ( + TransactionError, +) +from ..core.htlc import HTLCSecret +from ..core.p2pk import ( + P2PKSecret, + SigFlags, + verify_p2pk_signature, +) +from ..core.script import verify_bitcoin_script +from ..core.secret import Secret, SecretKind + + +class LedgerSpendingConditions: + def _verify_input_spending_conditions(self, proof: Proof) -> bool: + """ + Verify spending conditions: + Condition: P2SH - Witnesses proof.p2shscript + Condition: P2PK - Witness: proof.p2pksigs + Condition: HTLC - Witness: proof.htlcpreimage, proof.htlcsignature + """ + # P2SH + try: + secret = Secret.deserialize(proof.secret) + logger.trace(f"proof.secret: {proof.secret}") + logger.trace(f"secret: {secret}") + except Exception: + # secret is not a spending condition so we treat is a normal secret + return True + if secret.kind == SecretKind.P2SH: + p2pk_secret = P2PKSecret.from_secret(secret) + # check if locktime is in the past + now = time.time() + if p2pk_secret.locktime and p2pk_secret.locktime < now: + logger.trace(f"p2sh locktime ran out ({p2pk_secret.locktime}<{now}).") + return True + logger.trace(f"p2sh locktime still active ({p2pk_secret.locktime}>{now}).") + + if ( + proof.p2shscript is None + or proof.p2shscript.script is None + or proof.p2shscript.signature is None + ): + # no script present although secret indicates one + raise TransactionError("no script in proof.") + + # execute and verify P2SH + txin_p2sh_address, valid = verify_bitcoin_script( + proof.p2shscript.script, proof.p2shscript.signature + ) + if not valid: + raise TransactionError("script invalid.") + # check if secret commits to script address + assert secret.data == str(txin_p2sh_address), ( + f"secret does not contain correct P2SH address: {secret.data} is not" + f" {txin_p2sh_address}." + ) + return True + + # P2PK + if secret.kind == SecretKind.P2PK: + p2pk_secret = P2PKSecret.from_secret(secret) + # check if locktime is in the past + pubkeys = p2pk_secret.get_p2pk_pubkey_from_secret() + assert len(set(pubkeys)) == len(pubkeys), "pubkeys must be unique." + logger.trace(f"pubkeys: {pubkeys}") + # we will get an empty list if the locktime has passed and no refund pubkey is present + if not pubkeys: + return True + + # now we check the signature + if not proof.p2pksigs: + # no signature present although secret indicates one + logger.error(f"no p2pk signatures in proof: {proof.p2pksigs}") + raise TransactionError("no p2pk signatures in proof.") + + # we make sure that there are no duplicate signatures + if len(set(proof.p2pksigs)) != len(proof.p2pksigs): + raise TransactionError("p2pk signatures must be unique.") + + # we parse the secret as a P2PK commitment + # assert len(proof.secret.split(":")) == 5, "p2pk secret format invalid." + + # INPUTS: check signatures proof.p2pksigs against pubkey + # we expect the signature to be on the pubkey (=message) itself + n_sigs_required = p2pk_secret.n_sigs or 1 + assert n_sigs_required > 0, "n_sigs must be positive." + + # check if enough signatures are present + assert len(proof.p2pksigs) >= n_sigs_required, ( + f"not enough signatures provided: {len(proof.p2pksigs)} <" + f" {n_sigs_required}." + ) + + n_valid_sigs_per_output = 0 + # loop over all signatures in output + for input_sig in proof.p2pksigs: + for pubkey in pubkeys: + logger.trace(f"verifying signature {input_sig} by pubkey {pubkey}.") + logger.trace(f"Message: {p2pk_secret.serialize().encode('utf-8')}") + if verify_p2pk_signature( + message=p2pk_secret.serialize().encode("utf-8"), + pubkey=PublicKey(bytes.fromhex(pubkey), raw=True), + signature=bytes.fromhex(input_sig), + ): + n_valid_sigs_per_output += 1 + logger.trace( + f"p2pk signature on input is valid: {input_sig} on" + f" {pubkey}." + ) + continue + else: + logger.trace( + f"p2pk signature on input is invalid: {input_sig} on" + f" {pubkey}." + ) + # check if we have enough valid signatures + assert n_valid_sigs_per_output, "no valid signature provided for input." + assert n_valid_sigs_per_output >= n_sigs_required, ( + f"signature threshold not met. {n_valid_sigs_per_output} <" + f" {n_sigs_required}." + ) + logger.trace( + f"{n_valid_sigs_per_output} of {n_sigs_required} valid signatures" + " found." + ) + + logger.trace(proof.p2pksigs) + logger.trace("p2pk signature on inputs is valid.") + + return True + + # HTLC + if secret.kind == SecretKind.HTLC: + htlc_secret = HTLCSecret.from_secret(secret) + # time lock + # check if locktime is in the past + if htlc_secret.locktime and htlc_secret.locktime < time.time(): + refund_pubkeys = htlc_secret.tags.get_tag_all("refund") + if refund_pubkeys: + assert proof.htlcsignature, TransactionError( + "no HTLC refund signature provided" + ) + for pubkey in refund_pubkeys: + if verify_p2pk_signature( + message=htlc_secret.serialize().encode("utf-8"), + pubkey=PublicKey(bytes.fromhex(pubkey), raw=True), + signature=bytes.fromhex(proof.htlcsignature), + ): + # a signature matches + return True + raise TransactionError("HTLC refund signatures did not match.") + # no pubkeys given in secret, anyone can spend + return True + + # hash lock + assert proof.htlcpreimage, TransactionError("no HTLC preimage provided") + + # first we check whether a correct preimage was included + if not hashlib.sha256( + bytes.fromhex(proof.htlcpreimage) + ).digest() == bytes.fromhex(htlc_secret.data): + raise TransactionError("HTLC preimage does not match.") + + # then we check whether a signature is required + hashlock_pubkeys = htlc_secret.tags.get_tag_all("pubkeys") + if hashlock_pubkeys: + assert proof.htlcsignature, TransactionError( + "HTLC no hash lock signatures provided." + ) + for pubkey in hashlock_pubkeys: + if verify_p2pk_signature( + message=htlc_secret.serialize().encode("utf-8"), + pubkey=PublicKey(bytes.fromhex(pubkey), raw=True), + signature=bytes.fromhex(proof.htlcsignature), + ): + # a signature matches + return True + # none of the pubkeys had a match + raise TransactionError("HTLC hash lock signatures did not match.") + # no pubkeys were included, anyone can spend + return True + + # no spending condition present + return True + + def _verify_output_spending_conditions( + self, proofs: List[Proof], outputs: List[BlindedMessage] + ) -> bool: + """ + Verify spending conditions: + Condition: P2PK - Witness: output.p2pksigs + """ + + # P2PK + pubkeys_per_proof = [] + n_sigs = [] + for proof in proofs: + try: + secret = P2PKSecret.deserialize(proof.secret) + # get all p2pk pubkeys from secrets + pubkeys_per_proof.append(secret.get_p2pk_pubkey_from_secret()) + # get signature threshold from secrets + n_sigs.append(secret.n_sigs) + except Exception: + # secret is not a spending condition so we treat is a normal secret + return True + # for all proofs all pubkeys must be the same + assert ( + len(set([tuple(pubs_output) for pubs_output in pubkeys_per_proof])) == 1 + ), "pubkeys in all proofs must match." + pubkeys = pubkeys_per_proof[0] + if not pubkeys: + # no pubkeys present + return True + + logger.trace(f"pubkeys: {pubkeys}") + # TODO: add limit for maximum number of pubkeys + + # for all proofs all n_sigs must be the same + assert len(set(n_sigs)) == 1, "n_sigs in all proofs must match." + n_sigs_required = n_sigs[0] or 1 + + # first we check if all secrets are P2PK + if not all( + [Secret.deserialize(p.secret).kind == SecretKind.P2PK for p in proofs] + ): + # not all secrets are P2PK + return True + + # now we check if any of the secrets has sigflag==SIG_ALL + if not any( + [ + P2PKSecret.deserialize(p.secret).sigflag == SigFlags.SIG_ALL + for p in proofs + ] + ): + # no secret has sigflag==SIG_ALL + return True + + # loop over all outputs and check if the signatures are valid for pubkeys with a threshold of n_sig + for output in outputs: + # we expect the signature to be on the pubkey (=message) itself + assert output.p2pksigs, "no signatures in output." + # TODO: add limit for maximum number of signatures + + # we check whether any signature is duplicate + assert len(set(output.p2pksigs)) == len( + output.p2pksigs + ), "duplicate signatures in output." + + n_valid_sigs_per_output = 0 + # loop over all signatures in output + for output_sig in output.p2pksigs: + for pubkey in pubkeys: + if verify_p2pk_signature( + message=output.B_.encode("utf-8"), + pubkey=PublicKey(bytes.fromhex(pubkey), raw=True), + signature=bytes.fromhex(output_sig), + ): + n_valid_sigs_per_output += 1 + assert n_valid_sigs_per_output, "no valid signature provided for output." + assert n_valid_sigs_per_output >= n_sigs_required, ( + f"signature threshold not met. {n_valid_sigs_per_output} <" + f" {n_sigs_required}." + ) + logger.trace( + f"{n_valid_sigs_per_output} of {n_sigs_required} valid signatures" + " found." + ) + logger.trace(output.p2pksigs) + logger.trace("p2pk signatures on output is valid.") + + return True diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 19899568..7a3f5bd8 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -1,6 +1,5 @@ import asyncio import math -import time from typing import Dict, List, Literal, Optional, Set, Tuple, Union from loguru import logger @@ -24,28 +23,20 @@ KeysetError, KeysetNotFoundError, LightningError, - NoSecretInProofsError, NotAllowedError, - SecretTooLongError, TokenAlreadySpentError, TransactionError, ) from ..core.helpers import fee_reserve, sum_proofs -from ..core.p2pk import ( - P2PKSecret, - Secret, - SecretKind, - SigFlags, - verify_p2pk_signature, -) -from ..core.script import verify_bitcoin_script from ..core.settings import settings from ..core.split import amount_split from ..lightning.base import Wallet from ..mint.crud import LedgerCrud +from .conditions import LedgerSpendingConditions +from .verification import LedgerVerification -class Ledger: +class Ledger(LedgerVerification, LedgerSpendingConditions): locks: Dict[str, asyncio.Lock] = {} # holds multiprocessing locks proofs_pending_lock: asyncio.Lock = ( asyncio.Lock() @@ -76,7 +67,7 @@ async def load_used_proofs(self): logger.trace(f"crud: loaded {len(proofs_used)} used proofs") self.proofs_used = set(proofs_used) - async def load_keyset(self, derivation_path, autosave=True): + async def load_keyset(self, derivation_path, autosave=True) -> MintKeyset: """Load the keyset for a derivation path if it already exists. If not generate new one and store in the db. Args: @@ -207,6 +198,11 @@ async def _generate_promise( dleq=DLEQ(e=e.serialize(), s=s.serialize()), ) + def _check_proofs_spendable(self, proofs: List[Proof]): + """Checks whether the proofs were already spent.""" + if not all([p.secret not in self.proofs_used for p in proofs]): + raise TokenAlreadySpentError() + def _check_spendable(self, proof: Proof): """Checks whether the proof was already spent.""" return proof.secret not in self.proofs_used @@ -220,288 +216,6 @@ async def _check_pending(self, proofs: List[Proof]): ] return pending_states - def _verify_secret_criteria(self, proof: Proof) -> Literal[True]: - """Verifies that a secret is present and is not too long (DOS prevention).""" - if proof.secret is None or proof.secret == "": - raise NoSecretInProofsError() - if len(proof.secret) > 512: - raise SecretTooLongError() - return True - - def _verify_proof_bdhke(self, proof: Proof): - """Verifies that the proof of promise was issued by this ledger.""" - if not self._check_spendable(proof): - raise TokenAlreadySpentError() - # if no keyset id is given in proof, assume the current one - if not proof.id: - private_key_amount = self.keyset.private_keys[proof.amount] - else: - assert proof.id in self.keysets.keysets, f"keyset {proof.id} unknown" - logger.trace( - f"Validating proof with keyset {self.keysets.keysets[proof.id].id}." - ) - # use the appropriate active keyset for this proof.id - private_key_amount = self.keysets.keysets[proof.id].private_keys[ - proof.amount - ] - - C = PublicKey(bytes.fromhex(proof.C), raw=True) - return b_dhke.verify(private_key_amount, C, proof.secret) - - def _verify_input_spending_conditions(self, proof: Proof) -> bool: - """ - Verify spending conditions: - Condition: P2SH - Witnesses proof.p2shscript - Condition: P2PK - Witness: proof.p2pksigs - - """ - # P2SH - try: - secret = Secret.deserialize(proof.secret) - logger.trace(f"proof.secret: {proof.secret}") - logger.trace(f"secret: {secret}") - except Exception: - # secret is not a spending condition so we treat is a normal secret - return True - if secret.kind == SecretKind.P2SH: - p2pk_secret = P2PKSecret.from_secret(secret) - # check if locktime is in the past - now = time.time() - if p2pk_secret.locktime and p2pk_secret.locktime < now: - logger.trace(f"p2sh locktime ran out ({p2pk_secret.locktime}<{now}).") - return True - logger.trace(f"p2sh locktime still active ({p2pk_secret.locktime}>{now}).") - - if ( - proof.p2shscript is None - or proof.p2shscript.script is None - or proof.p2shscript.signature is None - ): - # no script present although secret indicates one - raise TransactionError("no script in proof.") - - # execute and verify P2SH - txin_p2sh_address, valid = verify_bitcoin_script( - proof.p2shscript.script, proof.p2shscript.signature - ) - if not valid: - raise TransactionError("script invalid.") - # check if secret commits to script address - assert secret.data == str(txin_p2sh_address), ( - f"secret does not contain correct P2SH address: {secret.data} is not" - f" {txin_p2sh_address}." - ) - return True - - # P2PK - if secret.kind == SecretKind.P2PK: - p2pk_secret = P2PKSecret.from_secret(secret) - # check if locktime is in the past - pubkeys = p2pk_secret.get_p2pk_pubkey_from_secret() - assert len(set(pubkeys)) == len(pubkeys), "pubkeys must be unique." - logger.trace(f"pubkeys: {pubkeys}") - # we will get an empty list if the locktime has passed and no refund pubkey is present - if not pubkeys: - return True - - # now we check the signature - if not proof.p2pksigs: - # no signature present although secret indicates one - logger.error(f"no p2pk signatures in proof: {proof.p2pksigs}") - raise TransactionError("no p2pk signatures in proof.") - - # we make sure that there are no duplicate signatures - if len(set(proof.p2pksigs)) != len(proof.p2pksigs): - raise TransactionError("p2pk signatures must be unique.") - - # we parse the secret as a P2PK commitment - # assert len(proof.secret.split(":")) == 5, "p2pk secret format invalid." - - # INPUTS: check signatures proof.p2pksigs against pubkey - # we expect the signature to be on the pubkey (=message) itself - n_sigs_required = p2pk_secret.n_sigs or 1 - assert n_sigs_required > 0, "n_sigs must be positive." - - # check if enough signatures are present - assert len(proof.p2pksigs) >= n_sigs_required, ( - f"not enough signatures provided: {len(proof.p2pksigs)} <" - f" {n_sigs_required}." - ) - - n_valid_sigs_per_output = 0 - # loop over all signatures in output - for input_sig in proof.p2pksigs: - for pubkey in pubkeys: - logger.trace(f"verifying signature {input_sig} by pubkey {pubkey}.") - logger.trace(f"Message: {p2pk_secret.serialize().encode('utf-8')}") - if verify_p2pk_signature( - message=p2pk_secret.serialize().encode("utf-8"), - pubkey=PublicKey(bytes.fromhex(pubkey), raw=True), - signature=bytes.fromhex(input_sig), - ): - n_valid_sigs_per_output += 1 - logger.trace( - f"p2pk signature on input is valid: {input_sig} on" - f" {pubkey}." - ) - continue - else: - logger.trace( - f"p2pk signature on input is invalid: {input_sig} on" - f" {pubkey}." - ) - # check if we have enough valid signatures - assert n_valid_sigs_per_output, "no valid signature provided for input." - assert n_valid_sigs_per_output >= n_sigs_required, ( - f"signature threshold not met. {n_valid_sigs_per_output} <" - f" {n_sigs_required}." - ) - logger.trace( - f"{n_valid_sigs_per_output} of {n_sigs_required} valid signatures" - " found." - ) - - logger.trace(proof.p2pksigs) - logger.trace("p2pk signature on inputs is valid.") - - return True - - # no spending contition - return True - - def _verify_output_spending_conditions( - self, proofs: List[Proof], outputs: List[BlindedMessage] - ) -> bool: - """ - Verify spending conditions: - Condition: P2PK - Witness: output.p2pksigs - - """ - # P2SH - pubkeys_per_proof = [] - n_sigs = [] - for proof in proofs: - try: - secret = P2PKSecret.deserialize(proof.secret) - # get all p2pk pubkeys from secrets - pubkeys_per_proof.append(secret.get_p2pk_pubkey_from_secret()) - # get signature threshold from secrets - n_sigs.append(secret.n_sigs) - except Exception: - # secret is not a spending condition so we treat is a normal secret - return True - # for all proofs all pubkeys must be the same - assert ( - len(set([tuple(pubs_output) for pubs_output in pubkeys_per_proof])) == 1 - ), "pubkeys in all proofs must match." - pubkeys = pubkeys_per_proof[0] - if not pubkeys: - # no pubkeys present - return True - - logger.trace(f"pubkeys: {pubkeys}") - # TODO: add limit for maximum number of pubkeys - - # for all proofs all n_sigs must be the same - assert len(set(n_sigs)) == 1, "n_sigs in all proofs must match." - n_sigs_required = n_sigs[0] or 1 - - # first we check if all secrets are P2PK - if not all( - [Secret.deserialize(p.secret).kind == SecretKind.P2PK for p in proofs] - ): - # not all secrets are P2PK - return True - - # now we check if any of the secrets has sigflag==SIG_ALL - if not any( - [ - P2PKSecret.deserialize(p.secret).sigflag == SigFlags.SIG_ALL - for p in proofs - ] - ): - # no secret has sigflag==SIG_ALL - return True - - # loop over all outputs and check if the signatures are valid for pubkeys with a threshold of n_sig - for output in outputs: - # we expect the signature to be on the pubkey (=message) itself - assert output.p2pksigs, "no signatures in output." - # TODO: add limit for maximum number of signatures - - # we check whether any signature is duplicate - assert len(set(output.p2pksigs)) == len( - output.p2pksigs - ), "duplicate signatures in output." - - n_valid_sigs_per_output = 0 - # loop over all signatures in output - for output_sig in output.p2pksigs: - for pubkey in pubkeys: - if verify_p2pk_signature( - message=output.B_.encode("utf-8"), - pubkey=PublicKey(bytes.fromhex(pubkey), raw=True), - signature=bytes.fromhex(output_sig), - ): - n_valid_sigs_per_output += 1 - assert n_valid_sigs_per_output, "no valid signature provided for output." - assert n_valid_sigs_per_output >= n_sigs_required, ( - f"signature threshold not met. {n_valid_sigs_per_output} <" - f" {n_sigs_required}." - ) - logger.trace( - f"{n_valid_sigs_per_output} of {n_sigs_required} valid signatures" - " found." - ) - logger.trace(output.p2pksigs) - logger.trace("p2pk signatures on output is valid.") - - return True - - def _verify_input_output_amounts( - self, inputs: List[Proof], outputs: List[BlindedMessage] - ) -> bool: - """Verifies that inputs have at least the same amount as outputs""" - input_amount = sum([p.amount for p in inputs]) - output_amount = sum([o.amount for o in outputs]) - return input_amount >= output_amount - - def _verify_no_duplicate_proofs(self, proofs: List[Proof]) -> bool: - secrets = [p.secret for p in proofs] - if len(secrets) != len(list(set(secrets))): - return False - return True - - def _verify_no_duplicate_outputs(self, outputs: List[BlindedMessage]) -> bool: - B_s = [od.B_ for od in outputs] - if len(B_s) != len(list(set(B_s))): - return False - return True - - def _verify_amount(self, amount: int) -> 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**settings.max_order - ) - logger.trace(f"Verifying amount {amount} is valid: {valid}") - if not valid: - raise NotAllowedError("invalid amount: " + str(amount)) - return amount - - def _verify_equation_balanced( - self, - proofs: List[Proof], - outs: Union[List[BlindedSignature], List[BlindedMessage]], - ) -> None: - """Verify that Σinputs - Σoutputs = 0. - Outputs can be BlindedSignature or BlindedMessage. - """ - 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 - ), "inputs do not have same amount as outputs" - async def _request_lightning_invoice(self, amount: int): """Generate a Lightning invoice using the funding source backend. @@ -714,51 +428,6 @@ async def _validate_proofs_pending( if p.secret == pp.secret: raise TransactionError("proofs are pending.") - async def _verify_proofs_and_outputs( - self, proofs: List[Proof], outputs: Optional[List[BlindedMessage]] = None - ): - """Checks all proofs and outputs for validity. - - Args: - proofs (List[Proof]): List of proofs to check. - outputs (Optional[List[BlindedMessage]], optional): List of outputs to check. - Must be provided for /split but not for /melt. Defaults to None. - - Raises: - Exception: Scripts did not validate. - Exception: Criteria for provided secrets not met. - Exception: Duplicate proofs provided. - Exception: BDHKE verification failed. - """ - # Verify inputs - - # Verify secret criteria - if not all([self._verify_secret_criteria(p) for p in proofs]): - raise TransactionError("secrets do not match criteria.") - # verify that only unique proofs were used - if not self._verify_no_duplicate_proofs(proofs): - raise TransactionError("duplicate proofs.") - # Verify input spending conditions - if not all([self._verify_input_spending_conditions(p) for p in proofs]): - raise TransactionError("validation of input spending conditions failed.") - # Verify ecash signatures - if not all([self._verify_proof_bdhke(p) for p in proofs]): - raise TransactionError("could not verify proofs.") - - if not outputs: - return - - # Verify outputs - - # verify that only unique outputs were used - if not self._verify_no_duplicate_outputs(outputs): - raise TransactionError("duplicate promises.") - if not self._verify_input_output_amounts(proofs, outputs): - raise TransactionError("input amounts less than output.") - # Verify output spending conditions - if outputs and not self._verify_output_spending_conditions(proofs, outputs): - raise TransactionError("validation of output spending conditions failed.") - async def _generate_change_promises( self, total_provided: int, @@ -938,9 +607,7 @@ async def melt( await self._set_proofs_pending(proofs) try: - await self._verify_proofs_and_outputs(proofs) - logger.trace("verified proofs") - + # verify amounts total_provided = sum_proofs(proofs) invoice_obj = bolt11.decode(invoice) invoice_amount = math.ceil(invoice_obj.amount_msat / 1000) @@ -949,13 +616,18 @@ async def melt( f"Maximum melt amount is {settings.mint_max_peg_out} sat." ) fees_msat = await self.check_fees(invoice) + # verify overspending attempt assert ( total_provided >= invoice_amount + fees_msat / 1000 ), TransactionError("provided proofs not enough for Lightning payment.") + # verify that proofs have not been spent yet + self._check_proofs_spendable(proofs) + # verify spending inputs, outputs, and spending conditions + await self._verify_proofs_and_outputs(proofs, outputs) + # promises to return for overpaid fees return_promises: List[BlindedSignature] = [] - if settings.lightning: logger.trace("paying lightning invoice") status, preimage, fee_msat = await self._pay_lightning_invoice( @@ -1075,20 +747,17 @@ async def split( total_amount = sum_proofs(proofs) try: - logger.trace("verifying _verify_split_amount") # verify that amount is kosher self._verify_amount(total_amount) - # verify overspending attempt self._verify_equation_balanced(proofs, outputs) - - logger.trace("verifying proofs: _verify_proofs_and_outputs") + # verify that proofs have not been spent yet + self._check_proofs_spendable(proofs) + # verify spending inputs, outputs, and spending conditions await self._verify_proofs_and_outputs(proofs, outputs) - logger.trace("verified proofs and outputs") # Mark proofs as used and prepare new promises - logger.trace("invalidating proofs") await self._invalidate_proofs(proofs) - logger.trace("invalidated proofs") + except Exception as e: logger.trace(f"split failed: {e}") raise e @@ -1123,7 +792,6 @@ async def split( logger.trace("split successful") return promises - return prom_fst, prom_snd async def restore( self, outputs: List[BlindedMessage] diff --git a/cashu/mint/protocols.py b/cashu/mint/protocols.py new file mode 100644 index 00000000..947849e9 --- /dev/null +++ b/cashu/mint/protocols.py @@ -0,0 +1,8 @@ +from typing import Protocol + +from ..core.base import MintKeyset, MintKeysets + + +class SupportsKeysets(Protocol): + keyset: MintKeyset + keysets: MintKeysets diff --git a/cashu/mint/verification.py b/cashu/mint/verification.py new file mode 100644 index 00000000..59a0b2f9 --- /dev/null +++ b/cashu/mint/verification.py @@ -0,0 +1,144 @@ +from typing import List, Literal, Optional, Union + +from loguru import logger + +from ..core.base import ( + BlindedMessage, + BlindedSignature, + MintKeyset, + MintKeysets, + Proof, +) +from ..core.crypto import b_dhke +from ..core.crypto.secp import PublicKey +from ..core.errors import ( + NoSecretInProofsError, + NotAllowedError, + SecretTooLongError, + TransactionError, +) +from ..core.settings import settings +from .conditions import LedgerSpendingConditions +from .protocols import SupportsKeysets + + +class LedgerVerification(LedgerSpendingConditions, SupportsKeysets): + """Verification functions for the ledger.""" + + keyset: MintKeyset + keysets: MintKeysets + + async def _verify_proofs_and_outputs( + self, proofs: List[Proof], outputs: Optional[List[BlindedMessage]] = None + ): + """Checks all proofs and outputs for validity. + + Args: + proofs (List[Proof]): List of proofs to check. + outputs (Optional[List[BlindedMessage]], optional): List of outputs to check. + Must be provided for /split but not for /melt. Defaults to None. + + Raises: + Exception: Scripts did not validate. + Exception: Criteria for provided secrets not met. + Exception: Duplicate proofs provided. + Exception: BDHKE verification failed. + """ + # Verify inputs + + # Verify secret criteria + if not all([self._verify_secret_criteria(p) for p in proofs]): + raise TransactionError("secrets do not match criteria.") + # verify that only unique proofs were used + if not self._verify_no_duplicate_proofs(proofs): + raise TransactionError("duplicate proofs.") + # Verify input spending conditions + if not all([self._verify_input_spending_conditions(p) for p in proofs]): + raise TransactionError("validation of input spending conditions failed.") + # Verify ecash signatures + if not all([self._verify_proof_bdhke(p) for p in proofs]): + raise TransactionError("could not verify proofs.") + + if not outputs: + return + + # Verify outputs + + # verify that only unique outputs were used + if not self._verify_no_duplicate_outputs(outputs): + raise TransactionError("duplicate promises.") + if not self._verify_input_output_amounts(proofs, outputs): + raise TransactionError("input amounts less than output.") + # Verify output spending conditions + if outputs and not self._verify_output_spending_conditions(proofs, outputs): + raise TransactionError("validation of output spending conditions failed.") + + def _verify_secret_criteria(self, proof: Proof) -> Literal[True]: + """Verifies that a secret is present and is not too long (DOS prevention).""" + if proof.secret is None or proof.secret == "": + raise NoSecretInProofsError() + if len(proof.secret) > 512: + raise SecretTooLongError() + return True + + def _verify_proof_bdhke(self, proof: Proof): + """Verifies that the proof of promise was issued by this ledger.""" + # if no keyset id is given in proof, assume the current one + if not proof.id: + private_key_amount = self.keyset.private_keys[proof.amount] + else: + assert proof.id in self.keysets.keysets, f"keyset {proof.id} unknown" + logger.trace( + f"Validating proof with keyset {self.keysets.keysets[proof.id].id}." + ) + # use the appropriate active keyset for this proof.id + private_key_amount = self.keysets.keysets[proof.id].private_keys[ + proof.amount + ] + + C = PublicKey(bytes.fromhex(proof.C), raw=True) + return b_dhke.verify(private_key_amount, C, proof.secret) + + def _verify_input_output_amounts( + self, inputs: List[Proof], outputs: List[BlindedMessage] + ) -> bool: + """Verifies that inputs have at least the same amount as outputs""" + input_amount = sum([p.amount for p in inputs]) + output_amount = sum([o.amount for o in outputs]) + return input_amount >= output_amount + + def _verify_no_duplicate_proofs(self, proofs: List[Proof]) -> bool: + secrets = [p.secret for p in proofs] + if len(secrets) != len(list(set(secrets))): + return False + return True + + def _verify_no_duplicate_outputs(self, outputs: List[BlindedMessage]) -> bool: + B_s = [od.B_ for od in outputs] + if len(B_s) != len(list(set(B_s))): + return False + return True + + def _verify_amount(self, amount: int) -> 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**settings.max_order + ) + logger.trace(f"Verifying amount {amount} is valid: {valid}") + if not valid: + raise NotAllowedError("invalid amount: " + str(amount)) + return amount + + def _verify_equation_balanced( + self, + proofs: List[Proof], + outs: Union[List[BlindedSignature], List[BlindedMessage]], + ) -> None: + """Verify that Σinputs - Σoutputs = 0. + Outputs can be BlindedSignature or BlindedMessage. + """ + 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 + ), "inputs do not have same amount as outputs" diff --git a/cashu/wallet/htlc.py b/cashu/wallet/htlc.py new file mode 100644 index 00000000..9ed5dc68 --- /dev/null +++ b/cashu/wallet/htlc.py @@ -0,0 +1,56 @@ +import hashlib +from datetime import datetime, timedelta +from typing import List, Optional + +from ..core import bolt11 as bolt11 +from ..core.base import ( + Proof, +) +from ..core.db import Database +from ..core.htlc import ( + HTLCSecret, +) +from ..core.secret import SecretKind, Tags +from .protocols import SupportsDb + + +class WalletHTLC(SupportsDb): + db: Database + + async def create_htlc_lock( + self, + *, + preimage: Optional[str] = None, + preimage_hash: Optional[str] = None, + hacklock_pubkey: Optional[str] = None, + locktime_seconds: Optional[int] = None, + locktime_pubkey: Optional[str] = None, + ) -> HTLCSecret: + tags = Tags() + if locktime_seconds: + tags["locktime"] = str( + int((datetime.now() + timedelta(seconds=locktime_seconds)).timestamp()) + ) + if locktime_pubkey: + tags["refund"] = locktime_pubkey + + if not preimage_hash and preimage: + preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest() + + assert preimage_hash, "preimage_hash or preimage must be provided" + + if hacklock_pubkey: + tags["pubkeys"] = hacklock_pubkey + + return HTLCSecret( + kind=SecretKind.HTLC, + data=preimage_hash, + tags=tags, + ) + + async def add_htlc_preimage_to_proofs( + self, proofs: List[Proof], preimage: str + ) -> List[Proof]: + for p, s in zip(proofs, preimage): + p.htlcpreimage = s + return proofs diff --git a/cashu/wallet/p2pk.py b/cashu/wallet/p2pk.py index b176b2b4..79d929a6 100644 --- a/cashu/wallet/p2pk.py +++ b/cashu/wallet/p2pk.py @@ -14,10 +14,7 @@ from ..core.p2pk import ( P2PKSecret, P2SHScript, - Secret, - SecretKind, SigFlags, - Tags, sign_p2pk_sign, ) from ..core.script import ( @@ -26,6 +23,7 @@ step1_carol_create_p2sh_address, step2_carol_sign_tx, ) +from ..core.secret import Secret, SecretKind, Tags from ..wallet.crud import ( get_unused_locks, store_p2sh, diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index bfbe9efd..7fd04e75 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -61,6 +61,7 @@ update_proof_reserved, ) from . import migrations +from .htlc import WalletHTLC from .p2pk import WalletP2PK from .secrets import WalletSecrets @@ -394,7 +395,16 @@ async def split( # construct payload def _splitrequest_include_fields(proofs: List[Proof]): """strips away fields from the model that aren't necessary for the /split""" - proofs_include = {"id", "amount", "secret", "C", "p2shscript", "p2pksigs"} + proofs_include = { + "id", + "amount", + "secret", + "C", + "p2shscript", + "p2pksigs", + "htlcpreimage", + "htlcsignature", + } return { "outputs": ..., "proofs": {i: proofs_include for i in range(len(proofs))}, @@ -493,7 +503,7 @@ async def restore_promises( return returnObj.outputs, returnObj.promises -class Wallet(LedgerAPI, WalletP2PK, WalletSecrets): +class Wallet(LedgerAPI, WalletP2PK, WalletHTLC, WalletSecrets): """Minimal wallet wrapper.""" mnemonic: str # holds mnemonic of the wallet diff --git a/tests/test_wallet_htlc.py b/tests/test_wallet_htlc.py new file mode 100644 index 00000000..3303e099 --- /dev/null +++ b/tests/test_wallet_htlc.py @@ -0,0 +1,238 @@ +import asyncio +import hashlib +import secrets +from typing import List + +import pytest +import pytest_asyncio + +from cashu.core.base import Proof +from cashu.core.crypto.secp import PrivateKey +from cashu.core.htlc import HTLCSecret +from cashu.core.migrations import migrate_databases +from cashu.wallet import migrations +from cashu.wallet.wallet import Wallet +from cashu.wallet.wallet import Wallet as Wallet1 +from cashu.wallet.wallet import Wallet as Wallet2 +from tests.conftest import SERVER_ENDPOINT + + +async def assert_err(f, msg): + """Compute f() and expect an error message 'msg'.""" + try: + await f + except Exception as exc: + if msg not in str(exc.args[0]): + raise Exception(f"Expected error: {msg}, got: {exc.args[0]}") + return + raise Exception(f"Expected error: {msg}, got no error") + + +def assert_amt(proofs: List[Proof], expected: int): + """Assert amounts the proofs contain.""" + assert [p.amount for p in proofs] == expected + + +@pytest_asyncio.fixture(scope="function") +async def wallet1(mint): + wallet1 = await Wallet1.with_db( + SERVER_ENDPOINT, "test_data/wallet_p2pk_1", "wallet1" + ) + await migrate_databases(wallet1.db, migrations) + await wallet1.load_mint() + wallet1.status() + yield wallet1 + + +@pytest_asyncio.fixture(scope="function") +async def wallet2(mint): + wallet2 = await Wallet2.with_db( + SERVER_ENDPOINT, "test_data/wallet_p2pk_2", "wallet2" + ) + await migrate_databases(wallet2.db, migrations) + wallet2.private_key = PrivateKey(secrets.token_bytes(32), raw=True) + await wallet2.load_mint() + wallet2.status() + yield wallet2 + + +@pytest.mark.asyncio +async def test_create_htlc_secret(wallet1: Wallet): + await wallet1.mint(64) + preimage = "00000000000000000000000000000000" + preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest() + secret = await wallet1.create_htlc_lock(preimage=preimage) + assert secret.data == preimage_hash + + +@pytest.mark.asyncio +async def test_htlc_split(wallet1: Wallet, wallet2: Wallet): + await wallet1.mint(64) + preimage = "00000000000000000000000000000000" + preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest() + secret = await wallet1.create_htlc_lock(preimage=preimage) + # p2pk test + _, send_proofs = await wallet1.split_to_send(wallet1.proofs, 8, secret_lock=secret) + for p in send_proofs: + assert HTLCSecret.deserialize(p.secret).data == preimage_hash + + +@pytest.mark.asyncio +async def test_htlc_redeem_with_preimage(wallet1: Wallet, wallet2: Wallet): + await wallet1.mint(64) + preimage = "00000000000000000000000000000000" + # preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest() + secret = await wallet1.create_htlc_lock(preimage=preimage) + # p2pk test + _, send_proofs = await wallet1.split_to_send(wallet1.proofs, 8, secret_lock=secret) + for p in send_proofs: + p.htlcpreimage = preimage + await wallet2.redeem(send_proofs) + + +@pytest.mark.asyncio +async def test_htlc_redeem_with_wrong_preimage(wallet1: Wallet, wallet2: Wallet): + await wallet1.mint(64) + preimage = "00000000000000000000000000000000" + # preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest() + secret = await wallet1.create_htlc_lock(preimage=preimage[:-1] + "1") + # p2pk test + _, send_proofs = await wallet1.split_to_send(wallet1.proofs, 8, secret_lock=secret) + for p in send_proofs: + p.htlcpreimage = preimage + await assert_err( + wallet2.redeem(send_proofs), "Mint Error: HTLC preimage does not match" + ) + + +@pytest.mark.asyncio +async def test_htlc_redeem_with_no_signature(wallet1: Wallet, wallet2: Wallet): + await wallet1.mint(64) + preimage = "00000000000000000000000000000000" + pubkey_wallet1 = await wallet1.create_p2pk_pubkey() + # preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest() + secret = await wallet1.create_htlc_lock( + preimage=preimage, hacklock_pubkey=pubkey_wallet1 + ) + # p2pk test + _, send_proofs = await wallet1.split_to_send(wallet1.proofs, 8, secret_lock=secret) + for p in send_proofs: + p.htlcpreimage = preimage + await assert_err( + wallet2.redeem(send_proofs), + "Mint Error: HTLC no hash lock signatures provided.", + ) + + +@pytest.mark.asyncio +async def test_htlc_redeem_with_wrong_signature(wallet1: Wallet, wallet2: Wallet): + await wallet1.mint(64) + preimage = "00000000000000000000000000000000" + pubkey_wallet1 = await wallet1.create_p2pk_pubkey() + # preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest() + secret = await wallet1.create_htlc_lock( + preimage=preimage, hacklock_pubkey=pubkey_wallet1 + ) + # p2pk test + _, send_proofs = await wallet1.split_to_send(wallet1.proofs, 8, secret_lock=secret) + + signatures = await wallet1.sign_p2pk_proofs(send_proofs) + for p, s in zip(send_proofs, signatures): + p.htlcpreimage = preimage + p.htlcsignature = s[:-1] + "1" # wrong signature + + await assert_err( + wallet2.redeem(send_proofs), + "Mint Error: HTLC hash lock signatures did not match.", + ) + + +@pytest.mark.asyncio +async def test_htlc_redeem_with_correct_signature(wallet1: Wallet, wallet2: Wallet): + await wallet1.mint(64) + preimage = "00000000000000000000000000000000" + pubkey_wallet1 = await wallet1.create_p2pk_pubkey() + # preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest() + secret = await wallet1.create_htlc_lock( + preimage=preimage, hacklock_pubkey=pubkey_wallet1 + ) + # p2pk test + _, send_proofs = await wallet1.split_to_send(wallet1.proofs, 8, secret_lock=secret) + + signatures = await wallet1.sign_p2pk_proofs(send_proofs) + for p, s in zip(send_proofs, signatures): + p.htlcpreimage = preimage + p.htlcsignature = s + + await wallet2.redeem(send_proofs) + + +@pytest.mark.asyncio +async def test_htlc_redeem_hashlock_wrong_signature_timelock_correct_signature( + wallet1: Wallet, wallet2: Wallet +): + await wallet1.mint(64) + preimage = "00000000000000000000000000000000" + pubkey_wallet1 = await wallet1.create_p2pk_pubkey() + pubkey_wallet2 = await wallet2.create_p2pk_pubkey() + # preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest() + secret = await wallet1.create_htlc_lock( + preimage=preimage, + hacklock_pubkey=pubkey_wallet2, + locktime_seconds=5, + locktime_pubkey=pubkey_wallet1, + ) + # p2pk test + _, send_proofs = await wallet1.split_to_send(wallet1.proofs, 8, secret_lock=secret) + + signatures = await wallet1.sign_p2pk_proofs(send_proofs) + for p, s in zip(send_proofs, signatures): + p.htlcpreimage = preimage + p.htlcsignature = s + + # should error because we used wallet2 signatures for the hash lock + await assert_err( + wallet1.redeem(send_proofs), + "Mint Error: HTLC hash lock signatures did not match.", + ) + + await asyncio.sleep(5) + # should succeed since lock time has passed and we provided wallet1 signature for timelock + await wallet1.redeem(send_proofs) + + +@pytest.mark.asyncio +async def test_htlc_redeem_hashlock_wrong_signature_timelock_wrong_signature( + wallet1: Wallet, wallet2: Wallet +): + await wallet1.mint(64) + preimage = "00000000000000000000000000000000" + pubkey_wallet1 = await wallet1.create_p2pk_pubkey() + pubkey_wallet2 = await wallet2.create_p2pk_pubkey() + # preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest() + secret = await wallet1.create_htlc_lock( + preimage=preimage, + hacklock_pubkey=pubkey_wallet2, + locktime_seconds=5, + locktime_pubkey=pubkey_wallet1, + ) + # p2pk test + _, send_proofs = await wallet1.split_to_send(wallet1.proofs, 8, secret_lock=secret) + + signatures = await wallet1.sign_p2pk_proofs(send_proofs) + for p, s in zip(send_proofs, signatures): + p.htlcpreimage = preimage + p.htlcsignature = s[:-1] + "1" # wrong signature + + # should error because we used wallet2 signatures for the hash lock + await assert_err( + wallet1.redeem(send_proofs), + "Mint Error: HTLC hash lock signatures did not match.", + ) + + await asyncio.sleep(5) + # should fail since lock time has passed and we provided a wrong signature for timelock + await assert_err( + wallet1.redeem(send_proofs), + "Mint Error: HTLC refund signatures did not match.", + ) diff --git a/tests/test_wallet_p2pk.py b/tests/test_wallet_p2pk.py index 9a864242..db16f54a 100644 --- a/tests/test_wallet_p2pk.py +++ b/tests/test_wallet_p2pk.py @@ -9,7 +9,8 @@ from cashu.core.base import Proof from cashu.core.crypto.secp import PrivateKey, PublicKey from cashu.core.migrations import migrate_databases -from cashu.core.p2pk import SigFlags, Tags +from cashu.core.p2pk import SigFlags +from cashu.core.secret import Tags from cashu.wallet import migrations from cashu.wallet.wallet import Wallet from cashu.wallet.wallet import Wallet as Wallet1 From 638324940a30d38226a892837c12f7ea37969541 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 24 Sep 2023 14:35:13 +0200 Subject: [PATCH 08/10] Tests: Test with `LIGHTNING=True` and refactor mint (#326) * refactor mint verification * test with lightning=true * rename proofs_used to secrets_used and refactor * test with lightning * spelling fixes --- cashu/mint/crud.py | 6 +- cashu/mint/ledger.py | 487 ++++++++++++++++--------------------- cashu/mint/router.py | 2 +- cashu/mint/verification.py | 37 ++- cashu/wallet/secrets.py | 3 + cashu/wallet/wallet.py | 3 + tests/conftest.py | 2 +- tests/test_mint.py | 7 +- tests/test_wallet.py | 102 +++++--- tests/test_wallet_htlc.py | 27 +- tests/test_wallet_p2pk.py | 39 ++- tests/test_wallet_p2sh.py | 9 +- 12 files changed, 379 insertions(+), 345 deletions(-) diff --git a/cashu/mint/crud.py b/cashu/mint/crud.py index 5759ccfc..7faa1bc0 100644 --- a/cashu/mint/crud.py +++ b/cashu/mint/crud.py @@ -17,8 +17,8 @@ async def get_keyset(*args, **kwags): async def get_lightning_invoice(*args, **kwags): return await get_lightning_invoice(*args, **kwags) # type: ignore - async def get_proofs_used(*args, **kwags): - return await get_proofs_used(*args, **kwags) # type: ignore + async def get_secrets_used(*args, **kwags): + return await get_secrets_used(*args, **kwags) # type: ignore async def invalidate_proof(*args, **kwags): return await invalidate_proof(*args, **kwags) # type: ignore @@ -91,7 +91,7 @@ async def get_promise( return BlindedSignature(amount=row[0], C_=row[2], id=row[3]) if row else None -async def get_proofs_used( +async def get_secrets_used( db: Database, conn: Optional[Connection] = None, ): diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 7a3f5bd8..b324f96f 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -24,7 +24,6 @@ KeysetNotFoundError, LightningError, NotAllowedError, - TokenAlreadySpentError, TransactionError, ) from ..core.helpers import fee_reserve, sum_proofs @@ -50,7 +49,7 @@ def __init__( derivation_path="", crud=LedgerCrud, ): - self.proofs_used: Set[str] = set() + self.secrets_used: Set[str] = set() self.master_key = seed self.derivation_path = derivation_path @@ -60,12 +59,7 @@ def __init__( self.pubkey = derive_pubkey(self.master_key) self.keysets = MintKeysets([]) - async def load_used_proofs(self): - """Load all used proofs from database.""" - logger.trace("crud: loading used proofs") - proofs_used = await self.crud.get_proofs_used(db=self.db) - logger.trace(f"crud: loaded {len(proofs_used)} used proofs") - self.proofs_used = set(proofs_used) + # ------- KEYS ------- async def load_keyset(self, derivation_path, autosave=True) -> MintKeyset: """Load the keyset for a derivation path if it already exists. If not generate new one and store in the db. @@ -144,77 +138,15 @@ async def init_keysets(self, autosave=True): # load the current keyset self.keyset = await self.load_keyset(self.derivation_path, autosave) - async def _generate_promises( - self, B_s: List[BlindedMessage], keyset: Optional[MintKeyset] = None - ) -> list[BlindedSignature]: - """Generates promises that sum to the given amount. - - Args: - B_s (List[BlindedMessage]): _description_ - keyset (Optional[MintKeyset], optional): _description_. Defaults to None. - - Returns: - list[BlindedSignature]: _description_ - """ - return [ - await self._generate_promise( - b.amount, PublicKey(bytes.fromhex(b.B_), raw=True), keyset - ) - for b in B_s - ] - - async def _generate_promise( - self, amount: int, B_: PublicKey, keyset: Optional[MintKeyset] = None - ) -> BlindedSignature: - """Generates a promise (Blind signature) for given amount and returns a pair (amount, C'). - - Args: - amount (int): Amount of the promise. - B_ (PublicKey): Blinded secret (point on curve) - keyset (Optional[MintKeyset], optional): Which keyset to use. Private keys will be taken from this keyset. Defaults to None. - - Returns: - BlindedSignature: Generated promise. - """ - keyset = keyset if keyset else self.keyset - logger.trace(f"Generating promise with keyset {keyset.id}.") - private_key_amount = keyset.private_keys[amount] - C_, e, s = b_dhke.step2_bob(B_, private_key_amount) - logger.trace(f"crud: _generate_promise storing promise for {amount}") - await self.crud.store_promise( - amount=amount, - B_=B_.serialize().hex(), - C_=C_.serialize().hex(), - e=e.serialize(), - s=s.serialize(), - db=self.db, - id=keyset.id, - ) - logger.trace(f"crud: _generate_promise stored promise for {amount}") - return BlindedSignature( - id=keyset.id, - amount=amount, - C_=C_.serialize().hex(), - dleq=DLEQ(e=e.serialize(), s=s.serialize()), - ) - - def _check_proofs_spendable(self, proofs: List[Proof]): - """Checks whether the proofs were already spent.""" - if not all([p.secret not in self.proofs_used for p in proofs]): - raise TokenAlreadySpentError() - - def _check_spendable(self, proof: Proof): - """Checks whether the proof was already spent.""" - return proof.secret not in self.proofs_used + def get_keyset(self, keyset_id: Optional[str] = None): + """Returns a dictionary of hex public keys of a specific keyset for each supported amount""" + if keyset_id and keyset_id not in self.keysets.keysets: + raise KeysetNotFoundError() + keyset = self.keysets.keysets[keyset_id] if keyset_id else self.keyset + assert keyset.public_keys, KeysetError("no public keys for this keyset") + return {a: p.serialize().hex() for a, p in keyset.public_keys.items()} - async def _check_pending(self, proofs: List[Proof]): - """Checks whether the proof is still pending.""" - proofs_pending = await self.crud.get_proofs_pending(db=self.db) - pending_secrets = [pp.secret for pp in proofs_pending] - pending_states = [ - True if p.secret in pending_secrets else False for p in proofs - ] - return pending_states + # ------- LIGHTNING ------- async def _request_lightning_invoice(self, amount: int): """Generate a Lightning invoice using the funding source backend. @@ -266,50 +198,26 @@ async def _check_lightning_invoice( Returns: bool: True if invoice has been paid, else False """ - logger.trace(f"crud: _check_lightning_invoice: checking invoice {hash}") invoice: Union[Invoice, None] = await self.crud.get_lightning_invoice( hash=hash, db=self.db, conn=conn ) - logger.trace(f"crud: _check_lightning_invoice: invoice: {invoice}") if invoice is None: raise LightningError("invoice not found.") if invoice.issued: raise LightningError("tokens already issued for this invoice.") + if amount > invoice.amount: + raise LightningError( + f"requested amount too high: {amount}. Invoice amount: {invoice.amount}" + ) assert invoice.payment_hash, "invoice has no payment hash." - - # set this invoice as issued - logger.trace(f"crud: setting invoice {invoice.payment_hash} as issued") - await self.crud.update_lightning_invoice( - hash=hash, issued=True, db=self.db, conn=conn + status = await self.lightning.get_invoice_status(invoice.payment_hash) + logger.trace( + f"_check_lightning_invoice: invoice {invoice.payment_hash} status: {status}" ) - logger.trace(f"crud: invoice {invoice.payment_hash} set as issued") + if not status.paid: + raise InvoiceNotPaidError() - try: - if amount > invoice.amount: - raise LightningError( - f"requested amount too high: {amount}. Invoice amount:" - f" {invoice.amount}" - ) - logger.trace( - f"_check_lightning_invoice: checking invoice {invoice.payment_hash}" - ) - status = await self.lightning.get_invoice_status(invoice.payment_hash) - logger.trace( - f"_check_lightning_invoice: invoice {invoice.payment_hash} status:" - f" {status}" - ) - if status.paid: - return status.paid - else: - raise InvoiceNotPaidError() - except Exception as e: - # unset issued - logger.trace(f"crud: unsetting invoice {invoice.payment_hash} as issued") - await self.crud.update_lightning_invoice( - hash=hash, issued=False, db=self.db, conn=conn - ) - logger.trace(f"crud: invoice {invoice.payment_hash} unset as issued") - raise e + return status.paid async def _pay_lightning_invoice(self, invoice: str, fee_limit_msat: int): """Pays a Lightning invoice via the funding source backend. @@ -324,9 +232,7 @@ async def _pay_lightning_invoice(self, invoice: str, fee_limit_msat: int): Returns: Tuple[bool, string, int]: Returns payment status, preimage of invoice, paid fees (in Millisatoshi) """ - logger.trace(f"_pay_lightning_invoice: paying Lightning invoice {invoice}") error, balance = await self.lightning.status() - logger.trace(f"_pay_lightning_invoice: Lightning wallet balance: {balance}") if error: raise LightningError(f"Lightning wallet not responding: {error}") ( @@ -341,6 +247,8 @@ async def _pay_lightning_invoice(self, invoice: str, fee_limit_msat: int): fee_msat = abs(fee_msat) if fee_msat else fee_msat return ok, preimage, fee_msat + # ------- ECASH ------- + async def _invalidate_proofs(self, proofs: List[Proof]): """Adds secrets of proofs to the list of known secrets and stores them in the db. Removes proofs from pending table. This is executed if the ecash has been redeemed. @@ -349,84 +257,11 @@ async def _invalidate_proofs(self, proofs: List[Proof]): proofs (List[Proof]): Proofs to add to known secret table. """ # Mark proofs as used and prepare new promises - proof_msgs = set([p.secret for p in proofs]) - self.proofs_used |= proof_msgs + secrets = set([p.secret for p in proofs]) + self.secrets_used |= secrets # store in db - logger.trace("crud: storing proofs") for p in proofs: await self.crud.invalidate_proof(proof=p, db=self.db) - logger.trace("crud: stored proofs") - - async def _set_proofs_pending( - self, proofs: List[Proof], conn: Optional[Connection] = None - ): - """If none of the proofs is in the pending table (_validate_proofs_pending), adds proofs to - the list of pending proofs or removes them. Used as a mutex for proofs. - - Args: - proofs (List[Proof]): Proofs to add to pending table. - - Raises: - Exception: At least one proof already in pending table. - """ - # first we check whether these proofs are pending aready - async with self.proofs_pending_lock: - await self._validate_proofs_pending(proofs, conn) - for p in proofs: - try: - logger.trace( - f"crud: _set_proofs_pending setting proof {p.secret} as pending" - ) - await self.crud.set_proof_pending(proof=p, db=self.db, conn=conn) - logger.trace( - f"crud: _set_proofs_pending proof {p.secret} set as pending" - ) - except Exception: - raise TransactionError("proofs already pending.") - - async def _unset_proofs_pending( - self, proofs: List[Proof], conn: Optional[Connection] = None - ): - """Deletes proofs from pending table. - - Args: - proofs (List[Proof]): Proofs to delete. - """ - # we try: except: this block in order to avoid that any errors here - # could block the _invalidate_proofs() call that happens afterwards. - async with self.proofs_pending_lock: - try: - for p in proofs: - logger.trace( - f"crud: _unset_proofs_pending unsetting proof {p.secret} as" - " pending" - ) - await self.crud.unset_proof_pending(proof=p, db=self.db, conn=conn) - logger.trace( - f"crud: _unset_proofs_pending proof {p.secret} unset as pending" - ) - except Exception as e: - print(e) - pass - - async def _validate_proofs_pending( - self, proofs: List[Proof], conn: Optional[Connection] = None - ): - """Checks if any of the provided proofs is in the pending proofs table. - - Args: - proofs (List[Proof]): Proofs to check. - - Raises: - Exception: At least one of the proofs is in the pending table. - """ - logger.trace("crud: _validate_proofs_pending validating proofs") - proofs_pending = await self.crud.get_proofs_pending(db=self.db, conn=conn) - logger.trace("crud: _validate_proofs_pending got proofs pending") - for p in proofs: - for pp in proofs_pending: - if p.secret == pp.secret: - raise TransactionError("proofs are pending.") async def _generate_change_promises( self, @@ -490,14 +325,7 @@ async def _generate_change_promises( else: return [] - # Public methods - def get_keyset(self, keyset_id: Optional[str] = None): - """Returns a dictionary of hex public keys of a specific keyset for each supported amount""" - if keyset_id and keyset_id not in self.keysets.keysets: - raise KeysetNotFoundError() - keyset = self.keysets.keysets[keyset_id] if keyset_id else self.keyset - assert keyset.public_keys, KeysetError("no public keys for this keyset") - return {a: p.serialize().hex() for a, p in keyset.public_keys.items()} + # ------- TRANSACTIONS ------- async def request_mint(self, amount: int): """Returns Lightning invoice and stores it in the db. @@ -561,8 +389,7 @@ async def mint( List[BlindedSignature]: Signatures on the outputs. """ logger.trace("called mint") - amounts = [b.amount for b in B_s] - amount = sum(amounts) + amount_outputs = sum([b.amount for b in B_s]) if settings.lightning: if not hash: @@ -571,15 +398,17 @@ async def mint( self.locks.get(hash) or asyncio.Lock() ) # create a new lock if it doesn't exist async with self.locks[hash]: - # will raise an exception if the invoice is not paid or tokens are already issued - await self._check_lightning_invoice(amount, hash) - del self.locks[hash] + # will raise an exception if the invoice is not paid or tokens are + # already issued or the requested amount is too high + await self._check_lightning_invoice(amount_outputs, hash) - for amount in amounts: - if amount not in [2**i for i in range(settings.max_order)]: - raise NotAllowedError( - f"Can only mint amounts with 2^n up to {2**settings.max_order}." + logger.trace(f"crud: setting invoice {hash} as issued") + await self.crud.update_lightning_invoice( + hash=hash, issued=True, db=self.db ) + del self.locks[hash] + + self._verify_outputs(B_s) promises = await self._generate_promises(B_s, keyset) logger.trace("generated promises") @@ -615,49 +444,45 @@ async def melt( raise NotAllowedError( f"Maximum melt amount is {settings.mint_max_peg_out} sat." ) - fees_msat = await self.check_fees(invoice) + fees_sat = await self.get_melt_fees(invoice) # verify overspending attempt - assert ( - total_provided >= invoice_amount + fees_msat / 1000 - ), TransactionError("provided proofs not enough for Lightning payment.") + assert total_provided >= invoice_amount + fees_sat, TransactionError( + "provided proofs not enough for Lightning payment. Provided:" + f" {total_provided}, needed: {invoice_amount + fees_sat}" + ) - # verify that proofs have not been spent yet - self._check_proofs_spendable(proofs) # verify spending inputs, outputs, and spending conditions - await self._verify_proofs_and_outputs(proofs, outputs) + await self.verify_inputs_and_outputs(proofs, outputs) - # promises to return for overpaid fees - return_promises: List[BlindedSignature] = [] if settings.lightning: - logger.trace("paying lightning invoice") + logger.trace(f"paying lightning invoice {invoice}") status, preimage, fee_msat = await self._pay_lightning_invoice( - invoice, fees_msat + invoice, fees_sat * 1000 ) logger.trace("paid lightning invoice") else: status, preimage, fee_msat = True, "preimage", 0 - logger.trace( - f"status: {status}, preimage: {preimage}, fee_msat: {fee_msat}" + logger.debug( + f"Melt status: {status}: preimage: {preimage}, fee_msat: {fee_msat}" ) - if status: - logger.trace("invalidating proofs") - await self._invalidate_proofs(proofs) - logger.trace("invalidated proofs") - # prepare change to compensate wallet for overpaid fees - assert fee_msat is not None, TransactionError("fees not valid") - if outputs: - return_promises = await self._generate_change_promises( - total_provided=total_provided, - invoice_amount=invoice_amount, - ln_fee_msat=fee_msat, - outputs=outputs, - ) - else: - logger.trace("lightning payment unsuccessful") + if not status: raise LightningError("Lightning payment unsuccessful.") + # melt successful, invalidate proofs + await self._invalidate_proofs(proofs) + + # prepare change to compensate wallet for overpaid fees + return_promises: List[BlindedSignature] = [] + if outputs and fee_msat: + return_promises = await self._generate_change_promises( + total_provided=total_provided, + invoice_amount=invoice_amount, + ln_fee_msat=fee_msat, + outputs=outputs, + ) + except Exception as e: logger.trace(f"exception: {e}") raise e @@ -667,28 +492,7 @@ async def melt( return status, preimage, return_promises - async def check_proof_state( - self, proofs: List[Proof] - ) -> Tuple[List[bool], List[bool]]: - """Checks if provided proofs are spend or are pending. - Used by wallets to check if their proofs have been redeemed by a receiver or they are still in-flight in a transaction. - - Returns two lists that are in the same order as the provided proofs. Wallet must match the list - to the proofs they have provided in order to figure out which proof is spendable or pending - and which isn't. - - Args: - proofs (List[Proof]): List of proofs to check. - - Returns: - List[bool]: List of which proof is still spendable (True if still spendable, else False) - List[bool]: List of which proof are pending (True if pending, else False) - """ - spendable = [self._check_spendable(p) for p in proofs] - pending = await self._check_pending(proofs) - return spendable, pending - - async def check_fees(self, pr: str): + async def get_melt_fees(self, pr: str) -> int: """Returns the fee reserve (in sat) that a wallet must add to its proofs in order to pay a Lightning invoice. @@ -702,18 +506,18 @@ async def check_fees(self, pr: str): # if id does not exist (not internal), it returns paid = None if settings.lightning: decoded_invoice = bolt11.decode(pr) - amount = math.ceil(decoded_invoice.amount_msat / 1000) + amount_msat = decoded_invoice.amount_msat logger.trace( - "check_fees: checking lightning invoice:" + "get_melt_fees: checking lightning invoice:" f" {decoded_invoice.payment_hash}" ) paid = await self.lightning.get_invoice_status(decoded_invoice.payment_hash) - logger.trace(f"check_fees: paid: {paid}") + logger.trace(f"get_melt_fees: paid: {paid}") internal = paid.paid is False else: - amount = 0 + amount_msat = 0 internal = True - fees_msat = fee_reserve(amount * 1000, internal) + fees_msat = fee_reserve(amount_msat, internal) fee_sat = math.ceil(fees_msat / 1000) return fee_sat @@ -743,21 +547,11 @@ async def split( logger.trace("split called") await self._set_proofs_pending(proofs) - - total_amount = sum_proofs(proofs) - try: - # verify that amount is kosher - self._verify_amount(total_amount) - # verify overspending attempt - self._verify_equation_balanced(proofs, outputs) - # verify that proofs have not been spent yet - self._check_proofs_spendable(proofs) # verify spending inputs, outputs, and spending conditions - await self._verify_proofs_and_outputs(proofs, outputs) + await self.verify_inputs_and_outputs(proofs, outputs) # Mark proofs as used and prepare new promises await self._invalidate_proofs(proofs) - except Exception as e: logger.trace(f"split failed: {e}") raise e @@ -815,3 +609,152 @@ async def restore( return_outputs.append(output) logger.trace(f"promise found: {promise}") return return_outputs, promises + + # ------- BLIND SIGNATURES ------- + + async def _generate_promises( + self, B_s: List[BlindedMessage], keyset: Optional[MintKeyset] = None + ) -> list[BlindedSignature]: + """Generates promises that sum to the given amount. + + Args: + B_s (List[BlindedMessage]): _description_ + keyset (Optional[MintKeyset], optional): _description_. Defaults to None. + + Returns: + list[BlindedSignature]: _description_ + """ + return [ + await self._generate_promise( + b.amount, PublicKey(bytes.fromhex(b.B_), raw=True), keyset + ) + for b in B_s + ] + + async def _generate_promise( + self, amount: int, B_: PublicKey, keyset: Optional[MintKeyset] = None + ) -> BlindedSignature: + """Generates a promise (Blind signature) for given amount and returns a pair (amount, C'). + + Args: + amount (int): Amount of the promise. + B_ (PublicKey): Blinded secret (point on curve) + keyset (Optional[MintKeyset], optional): Which keyset to use. Private keys will be taken from this keyset. Defaults to None. + + Returns: + BlindedSignature: Generated promise. + """ + keyset = keyset if keyset else self.keyset + logger.trace(f"Generating promise with keyset {keyset.id}.") + private_key_amount = keyset.private_keys[amount] + C_, e, s = b_dhke.step2_bob(B_, private_key_amount) + logger.trace(f"crud: _generate_promise storing promise for {amount}") + await self.crud.store_promise( + amount=amount, + B_=B_.serialize().hex(), + C_=C_.serialize().hex(), + e=e.serialize(), + s=s.serialize(), + db=self.db, + id=keyset.id, + ) + logger.trace(f"crud: _generate_promise stored promise for {amount}") + return BlindedSignature( + id=keyset.id, + amount=amount, + C_=C_.serialize().hex(), + dleq=DLEQ(e=e.serialize(), s=s.serialize()), + ) + + # ------- PROOFS ------- + + async def load_used_proofs(self): + """Load all used proofs from database.""" + logger.trace("crud: loading used proofs") + secrets_used = await self.crud.get_secrets_used(db=self.db) + logger.trace(f"crud: loaded {len(secrets_used)} used proofs") + self.secrets_used = set(secrets_used) + + def _check_spendable(self, proof: Proof): + """Checks whether the proof was already spent.""" + return proof.secret not in self.secrets_used + + async def _check_pending(self, proofs: List[Proof]): + """Checks whether the proof is still pending.""" + proofs_pending = await self.crud.get_proofs_pending(db=self.db) + pending_secrets = [pp.secret for pp in proofs_pending] + pending_states = [ + True if p.secret in pending_secrets else False for p in proofs + ] + return pending_states + + async def check_proof_state( + self, proofs: List[Proof] + ) -> Tuple[List[bool], List[bool]]: + """Checks if provided proofs are spend or are pending. + Used by wallets to check if their proofs have been redeemed by a receiver or they are still in-flight in a transaction. + + Returns two lists that are in the same order as the provided proofs. Wallet must match the list + to the proofs they have provided in order to figure out which proof is spendable or pending + and which isn't. + + Args: + proofs (List[Proof]): List of proofs to check. + + Returns: + List[bool]: List of which proof is still spendable (True if still spendable, else False) + List[bool]: List of which proof are pending (True if pending, else False) + """ + spendable = [self._check_spendable(p) for p in proofs] + pending = await self._check_pending(proofs) + return spendable, pending + + async def _set_proofs_pending( + self, proofs: List[Proof], conn: Optional[Connection] = None + ): + """If none of the proofs is in the pending table (_validate_proofs_pending), adds proofs to + the list of pending proofs or removes them. Used as a mutex for proofs. + + Args: + proofs (List[Proof]): Proofs to add to pending table. + + Raises: + Exception: At least one proof already in pending table. + """ + # first we check whether these proofs are pending aready + async with self.proofs_pending_lock: + await self._validate_proofs_pending(proofs, conn) + for p in proofs: + try: + await self.crud.set_proof_pending(proof=p, db=self.db, conn=conn) + except Exception: + raise TransactionError("proofs already pending.") + + async def _unset_proofs_pending( + self, proofs: List[Proof], conn: Optional[Connection] = None + ): + """Deletes proofs from pending table. + + Args: + proofs (List[Proof]): Proofs to delete. + """ + async with self.proofs_pending_lock: + for p in proofs: + await self.crud.unset_proof_pending(proof=p, db=self.db, conn=conn) + + async def _validate_proofs_pending( + self, proofs: List[Proof], conn: Optional[Connection] = None + ): + """Checks if any of the provided proofs is in the pending proofs table. + + Args: + proofs (List[Proof]): Proofs to check. + + Raises: + Exception: At least one of the proofs is in the pending table. + """ + proofs_pending = await self.crud.get_proofs_pending(db=self.db, conn=conn) + for p in proofs: + for pp in proofs_pending: + if p.secret == pp.secret: + raise TransactionError("proofs are pending.") diff --git a/cashu/mint/router.py b/cashu/mint/router.py index 3ede2c6b..8b00d0c1 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -232,7 +232,7 @@ async def check_fees(payload: CheckFeesRequest) -> CheckFeesResponse: This is can be useful for checking whether an invoice is internal (Cashu-to-Cashu). """ logger.trace(f"> POST /checkfees: {payload}") - fees_sat = await ledger.check_fees(payload.pr) + fees_sat = await ledger.get_melt_fees(payload.pr) logger.trace(f"< POST /checkfees: {fees_sat}") return CheckFeesResponse(fee=fees_sat) diff --git a/cashu/mint/verification.py b/cashu/mint/verification.py index 59a0b2f9..434dca78 100644 --- a/cashu/mint/verification.py +++ b/cashu/mint/verification.py @@ -1,4 +1,4 @@ -from typing import List, Literal, Optional, Union +from typing import List, Literal, Optional, Set, Union from loguru import logger @@ -15,6 +15,7 @@ NoSecretInProofsError, NotAllowedError, SecretTooLongError, + TokenAlreadySpentError, TransactionError, ) from ..core.settings import settings @@ -27,8 +28,9 @@ class LedgerVerification(LedgerSpendingConditions, SupportsKeysets): keyset: MintKeyset keysets: MintKeysets + secrets_used: Set[str] - async def _verify_proofs_and_outputs( + async def verify_inputs_and_outputs( self, proofs: List[Proof], outputs: Optional[List[BlindedMessage]] = None ): """Checks all proofs and outputs for validity. @@ -45,34 +47,51 @@ async def _verify_proofs_and_outputs( Exception: BDHKE verification failed. """ # Verify inputs - + # Verify proofs are spendable + self._check_proofs_spendable(proofs) + # Verify amounts of inputs + if not all([self._verify_amount(p.amount) for p in proofs]): + raise TransactionError("invalid amount.") # Verify secret criteria if not all([self._verify_secret_criteria(p) for p in proofs]): raise TransactionError("secrets do not match criteria.") # verify that only unique proofs were used if not self._verify_no_duplicate_proofs(proofs): raise TransactionError("duplicate proofs.") - # Verify input spending conditions - if not all([self._verify_input_spending_conditions(p) for p in proofs]): - raise TransactionError("validation of input spending conditions failed.") # Verify ecash signatures if not all([self._verify_proof_bdhke(p) for p in proofs]): raise TransactionError("could not verify proofs.") + # Verify input spending conditions + if not all([self._verify_input_spending_conditions(p) for p in proofs]): + raise TransactionError("validation of input spending conditions failed.") if not outputs: return # Verify outputs + self._verify_outputs(outputs) - # verify that only unique outputs were used - if not self._verify_no_duplicate_outputs(outputs): - raise TransactionError("duplicate promises.") + # Verify inputs and outputs together if not self._verify_input_output_amounts(proofs, outputs): raise TransactionError("input amounts less than output.") # Verify output spending conditions if outputs and not self._verify_output_spending_conditions(proofs, outputs): raise TransactionError("validation of output spending conditions failed.") + def _verify_outputs(self, outputs: List[BlindedMessage]): + """Verify that the outputs are valid.""" + # Verify amounts of outputs + if not all([self._verify_amount(o.amount) for o in outputs]): + raise TransactionError("invalid amount.") + # verify that only unique outputs were used + if not self._verify_no_duplicate_outputs(outputs): + raise TransactionError("duplicate outputs.") + + def _check_proofs_spendable(self, proofs: List[Proof]): + """Checks whether the proofs were already spent.""" + if not all([p.secret not in self.secrets_used for p in proofs]): + raise TokenAlreadySpentError() + def _verify_secret_criteria(self, proof: Proof) -> Literal[True]: """Verifies that a secret is present and is not too long (DOS prevention).""" if proof.secret is None or proof.secret == "": diff --git a/cashu/wallet/secrets.py b/cashu/wallet/secrets.py index 7c5342a2..07732682 100644 --- a/cashu/wallet/secrets.py +++ b/cashu/wallet/secrets.py @@ -148,6 +148,9 @@ async def generate_n_secrets( Tuple[List[str], List[PrivateKey], List[str]]: Secrets, blinding factors, derivation paths """ + if n < 1: + return [], [], [] + secret_counters_start = await bump_secret_derivation( db=self.db, keyset_id=self.keyset_id, by=n, skip=skip_bump ) diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 7fd04e75..15a7a4bf 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -1220,6 +1220,9 @@ async def split_to_send( set_reserved (bool, optional): If set, the proofs are marked as reserved. Should be set to False if a payment attempt is made with the split that could fail (like a Lightning payment). Should be set to True if the token to be sent is displayed to the user to be then sent to someone else. Defaults to False. + + Returns: + Tuple[List[Proof], List[Proof]]: Tuple of proofs to keep and proofs to send """ if secret_lock: logger.debug(f"Spending conditions: {secret_lock}") diff --git a/tests/conftest.py b/tests/conftest.py index 1404216c..2a7ac40b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,7 +25,7 @@ settings.mint_host = "0.0.0.0" settings.mint_listen_port = SERVER_PORT settings.mint_url = SERVER_ENDPOINT -settings.lightning = False +settings.lightning = True settings.tor = False settings.mint_lightning_backend = "FakeWallet" settings.mint_database = "./test_data/test_mint" diff --git a/tests/test_mint.py b/tests/test_mint.py index cf95caca..07908e86 100644 --- a/tests/test_mint.py +++ b/tests/test_mint.py @@ -66,13 +66,14 @@ async def test_get_keyset(ledger: Ledger): @pytest.mark.asyncio async def test_mint(ledger: Ledger): + invoice, payment_hash = await ledger.request_mint(8) blinded_messages_mock = [ BlindedMessage( amount=8, B_="02634a2c2b34bec9e8a4aba4361f6bf202d7fa2365379b0840afe249a7a9d71239", ) ] - promises = await ledger.mint(blinded_messages_mock) + promises = await ledger.mint(blinded_messages_mock, hash=payment_hash) assert len(promises) assert promises[0].amount == 8 assert ( @@ -83,6 +84,7 @@ async def test_mint(ledger: Ledger): @pytest.mark.asyncio async def test_mint_invalid_blinded_message(ledger: Ledger): + invoice, payment_hash = await ledger.request_mint(8) blinded_messages_mock_invalid_key = [ BlindedMessage( amount=8, @@ -90,7 +92,8 @@ async def test_mint_invalid_blinded_message(ledger: Ledger): ) ] await assert_err( - ledger.mint(blinded_messages_mock_invalid_key), "invalid public key" + ledger.mint(blinded_messages_mock_invalid_key, hash=payment_hash), + "invalid public key", ) diff --git a/tests/test_wallet.py b/tests/test_wallet.py index b4e5cb07..d401be53 100644 --- a/tests/test_wallet.py +++ b/tests/test_wallet.py @@ -136,15 +136,17 @@ async def test_get_keyset_ids(wallet1: Wallet): @pytest.mark.asyncio async def test_mint(wallet1: Wallet): - await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) assert wallet1.balance == 64 @pytest.mark.asyncio async def test_mint_amounts(wallet1: Wallet): """Mint predefined amounts""" + invoice = await wallet1.request_mint(64) amts = [1, 1, 1, 2, 2, 4, 16] - await wallet1.mint(amount=sum(amts), split=amts) + await wallet1.mint(amount=sum(amts), split=amts, hash=invoice.hash) assert wallet1.balance == 27 assert wallet1.proof_amounts == amts @@ -171,7 +173,8 @@ async def test_mint_amounts_wrong_order(wallet1: Wallet): @pytest.mark.asyncio async def test_split(wallet1: Wallet): - await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) assert wallet1.balance == 64 p1, p2 = await wallet1.split(wallet1.proofs, 20) assert wallet1.balance == 64 @@ -185,7 +188,8 @@ async def test_split(wallet1: Wallet): @pytest.mark.asyncio async def test_split_to_send(wallet1: Wallet): - await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) keep_proofs, spendable_proofs = await wallet1.split_to_send( wallet1.proofs, 32, set_reserved=True ) @@ -199,7 +203,8 @@ async def test_split_to_send(wallet1: Wallet): @pytest.mark.asyncio async def test_split_more_than_balance(wallet1: Wallet): - await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) await assert_err( wallet1.split(wallet1.proofs, 128), # "Mint Error: inputs do not have same amount as outputs", @@ -208,9 +213,27 @@ async def test_split_more_than_balance(wallet1: Wallet): assert wallet1.balance == 64 +@pytest.mark.asyncio +async def test_melt(wallet1: Wallet): + # mint twice so we have enough to pay the second invoice back + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) + assert wallet1.balance == 128 + total_amount, fee_reserve_sat = await wallet1.get_pay_amount_with_fees(invoice.pr) + _, send_proofs = await wallet1.split_to_send(wallet1.proofs, total_amount) + + await wallet1.pay_lightning( + send_proofs, invoice=invoice.pr, fee_reserve_sat=fee_reserve_sat + ) + assert wallet1.balance == 128 - total_amount + + @pytest.mark.asyncio async def test_split_to_send_more_than_balance(wallet1: Wallet): - await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) await assert_err( wallet1.split_to_send(wallet1.proofs, 128, set_reserved=True), "balance too low.", @@ -221,7 +244,8 @@ async def test_split_to_send_more_than_balance(wallet1: Wallet): @pytest.mark.asyncio async def test_double_spend(wallet1: Wallet): - doublespend = await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + doublespend = await wallet1.mint(64, hash=invoice.hash) await wallet1.split(wallet1.proofs, 20) await assert_err( wallet1.split(doublespend, 20), @@ -233,7 +257,8 @@ async def test_double_spend(wallet1: Wallet): @pytest.mark.asyncio async def test_duplicate_proofs_double_spent(wallet1: Wallet): - doublespend = await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + doublespend = await wallet1.mint(64, hash=invoice.hash) await assert_err( wallet1.split(wallet1.proofs + doublespend, 20), "Mint Error: proofs already pending.", @@ -244,7 +269,8 @@ async def test_duplicate_proofs_double_spent(wallet1: Wallet): @pytest.mark.asyncio async def test_send_and_redeem(wallet1: Wallet, wallet2: Wallet): - await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) _, spendable_proofs = await wallet1.split_to_send( wallet1.proofs, 32, set_reserved=True ) @@ -261,7 +287,8 @@ async def test_send_and_redeem(wallet1: Wallet, wallet2: Wallet): @pytest.mark.asyncio async def test_invalidate_unspent_proofs(wallet1: Wallet): """Try to invalidate proofs that have not been spent yet. Should not work!""" - await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) await wallet1.invalidate(wallet1.proofs) assert wallet1.balance == 64 @@ -269,14 +296,16 @@ async def test_invalidate_unspent_proofs(wallet1: Wallet): @pytest.mark.asyncio async def test_invalidate_unspent_proofs_without_checking(wallet1: Wallet): """Try to invalidate proofs that have not been spent yet but force no check.""" - await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) await wallet1.invalidate(wallet1.proofs, check_spendable=False) assert wallet1.balance == 0 @pytest.mark.asyncio async def test_split_invalid_amount(wallet1: Wallet): - await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) await assert_err( wallet1.split(wallet1.proofs, -1), "amount must be positive.", @@ -285,14 +314,16 @@ async def test_split_invalid_amount(wallet1: Wallet): @pytest.mark.asyncio async def test_create_p2pk_pubkey(wallet1: Wallet): - await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) pubkey = await wallet1.create_p2pk_pubkey() PublicKey(bytes.fromhex(pubkey), raw=True) @pytest.mark.asyncio async def test_p2sh(wallet1: Wallet, wallet2: Wallet): - await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) _ = await wallet1.create_p2sh_address_and_store() # receiver side _, send_proofs = await wallet1.split_to_send(wallet1.proofs, 8) # sender side @@ -305,7 +336,8 @@ async def test_p2sh(wallet1: Wallet, wallet2: Wallet): @pytest.mark.asyncio async def test_p2sh_receive_with_wrong_wallet(wallet1: Wallet, wallet2: Wallet): - await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) wallet1_address = await wallet1.create_p2sh_address_and_store() # receiver side secret_lock = await wallet1.create_p2sh_lock(wallet1_address) # sender side _, send_proofs = await wallet1.split_to_send( @@ -316,7 +348,8 @@ async def test_p2sh_receive_with_wrong_wallet(wallet1: Wallet, wallet2: Wallet): @pytest.mark.asyncio async def test_token_state(wallet1: Wallet): - await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) assert wallet1.balance == 64 resp = await wallet1.check_proof_state(wallet1.proofs) assert resp.dict()["spendable"] @@ -329,11 +362,11 @@ async def test_bump_secret_derivation(wallet3: Wallet): "half depart obvious quality work element tank gorilla view sugar picture" " humble" ) - secrets1, rs1, derivaion_paths1 = await wallet3.generate_n_secrets(5) - secrets2, rs2, derivaion_paths2 = await wallet3.generate_secrets_from_to(0, 4) + secrets1, rs1, derivation_paths1 = await wallet3.generate_n_secrets(5) + secrets2, rs2, derivation_paths2 = await wallet3.generate_secrets_from_to(0, 4) assert secrets1 == secrets2 assert [r.private_key for r in rs1] == [r.private_key for r in rs2] - assert derivaion_paths1 == derivaion_paths2 + assert derivation_paths1 == derivation_paths2 assert secrets1 == [ "9bfb12704297fe90983907d122838940755fcce370ce51e9e00a4275a347c3fe", "dbc5e05f2b1f24ec0e2ab6e8312d5e13f57ada52594d4caf429a697d9c742490", @@ -341,7 +374,7 @@ async def test_bump_secret_derivation(wallet3: Wallet): "652d08c804bd2c5f2c1f3e3d8895860397df394b30473753227d766affd15e89", "654e5997f8a20402f7487296b6f7e463315dd52fc6f6cc5a4e35c7f6ccac77e0", ] - assert derivaion_paths1 == [ + assert derivation_paths1 == [ "m/129372'/0'/2004500376'/0'", "m/129372'/0'/2004500376'/1'", "m/129372'/0'/2004500376'/2'", @@ -356,11 +389,11 @@ async def test_bump_secret_derivation_two_steps(wallet3: Wallet): "half depart obvious quality work element tank gorilla view sugar picture" " humble" ) - secrets1_1, rs1_1, derivaion_paths1 = await wallet3.generate_n_secrets(2) - secrets1_2, rs1_2, derivaion_paths2 = await wallet3.generate_n_secrets(3) + secrets1_1, rs1_1, derivation_paths1 = await wallet3.generate_n_secrets(2) + secrets1_2, rs1_2, derivation_paths2 = await wallet3.generate_n_secrets(3) secrets1 = secrets1_1 + secrets1_2 rs1 = rs1_1 + rs1_2 - secrets2, rs2, derivaion_paths = await wallet3.generate_secrets_from_to(0, 4) + secrets2, rs2, derivation_paths = await wallet3.generate_secrets_from_to(0, 4) assert secrets1 == secrets2 assert [r.private_key for r in rs1] == [r.private_key for r in rs2] @@ -371,9 +404,9 @@ async def test_generate_secrets_from_to(wallet3: Wallet): "half depart obvious quality work element tank gorilla view sugar picture" " humble" ) - secrets1, rs1, derivaion_paths1 = await wallet3.generate_secrets_from_to(0, 4) + secrets1, rs1, derivation_paths1 = await wallet3.generate_secrets_from_to(0, 4) assert len(secrets1) == 5 - secrets2, rs2, derivaion_paths2 = await wallet3.generate_secrets_from_to(2, 4) + secrets2, rs2, derivation_paths2 = await wallet3.generate_secrets_from_to(2, 4) assert len(secrets2) == 3 assert secrets1[2:] == secrets2 assert [r.private_key for r in rs1[2:]] == [r.private_key for r in rs2] @@ -382,7 +415,8 @@ async def test_generate_secrets_from_to(wallet3: Wallet): @pytest.mark.asyncio async def test_restore_wallet_after_mint(wallet3: Wallet): await reset_wallet_db(wallet3) - await wallet3.mint(64) + invoice = await wallet3.request_mint(64) + await wallet3.mint(64, hash=invoice.hash) assert wallet3.balance == 64 await reset_wallet_db(wallet3) await wallet3.load_proofs() @@ -411,7 +445,8 @@ async def test_restore_wallet_after_split_to_send(wallet3: Wallet): ) await reset_wallet_db(wallet3) - await wallet3.mint(64) + invoice = await wallet3.request_mint(64) + await wallet3.mint(64, hash=invoice.hash) assert wallet3.balance == 64 _, spendable_proofs = await wallet3.split_to_send(wallet3.proofs, 32, set_reserved=True) # type: ignore @@ -432,8 +467,8 @@ async def test_restore_wallet_after_send_and_receive(wallet3: Wallet, wallet2: W "hello rug want adapt talent together lunar method bean expose beef position" ) await reset_wallet_db(wallet3) - - await wallet3.mint(64) + invoice = await wallet3.request_mint(64) + await wallet3.mint(64, hash=invoice.hash) assert wallet3.balance == 64 _, spendable_proofs = await wallet3.split_to_send(wallet3.proofs, 32, set_reserved=True) # type: ignore @@ -472,7 +507,8 @@ async def test_restore_wallet_after_send_and_self_receive(wallet3: Wallet): ) await reset_wallet_db(wallet3) - await wallet3.mint(64) + invoice = await wallet3.request_mint(64) + await wallet3.mint(64, hash=invoice.hash) assert wallet3.balance == 64 _, spendable_proofs = await wallet3.split_to_send(wallet3.proofs, 32, set_reserved=True) # type: ignore @@ -497,7 +533,8 @@ async def test_restore_wallet_after_send_twice( wallet3.private_key = PrivateKey() await reset_wallet_db(wallet3) - await wallet3.mint(2) + invoice = await wallet3.request_mint(2) + await wallet3.mint(2, hash=invoice.hash) box.add(wallet3.proofs) assert wallet3.balance == 2 @@ -550,7 +587,8 @@ async def test_restore_wallet_after_send_and_self_receive_nonquadratic_value( ) await reset_wallet_db(wallet3) - await wallet3.mint(64) + invoice = await wallet3.request_mint(64) + await wallet3.mint(64, hash=invoice.hash) box.add(wallet3.proofs) assert wallet3.balance == 64 diff --git a/tests/test_wallet_htlc.py b/tests/test_wallet_htlc.py index 3303e099..5128b3ab 100644 --- a/tests/test_wallet_htlc.py +++ b/tests/test_wallet_htlc.py @@ -58,7 +58,8 @@ async def wallet2(mint): @pytest.mark.asyncio async def test_create_htlc_secret(wallet1: Wallet): - await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) preimage = "00000000000000000000000000000000" preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest() secret = await wallet1.create_htlc_lock(preimage=preimage) @@ -67,7 +68,8 @@ async def test_create_htlc_secret(wallet1: Wallet): @pytest.mark.asyncio async def test_htlc_split(wallet1: Wallet, wallet2: Wallet): - await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) preimage = "00000000000000000000000000000000" preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest() secret = await wallet1.create_htlc_lock(preimage=preimage) @@ -79,7 +81,8 @@ async def test_htlc_split(wallet1: Wallet, wallet2: Wallet): @pytest.mark.asyncio async def test_htlc_redeem_with_preimage(wallet1: Wallet, wallet2: Wallet): - await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) preimage = "00000000000000000000000000000000" # preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest() secret = await wallet1.create_htlc_lock(preimage=preimage) @@ -92,7 +95,8 @@ async def test_htlc_redeem_with_preimage(wallet1: Wallet, wallet2: Wallet): @pytest.mark.asyncio async def test_htlc_redeem_with_wrong_preimage(wallet1: Wallet, wallet2: Wallet): - await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) preimage = "00000000000000000000000000000000" # preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest() secret = await wallet1.create_htlc_lock(preimage=preimage[:-1] + "1") @@ -107,7 +111,8 @@ async def test_htlc_redeem_with_wrong_preimage(wallet1: Wallet, wallet2: Wallet) @pytest.mark.asyncio async def test_htlc_redeem_with_no_signature(wallet1: Wallet, wallet2: Wallet): - await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) preimage = "00000000000000000000000000000000" pubkey_wallet1 = await wallet1.create_p2pk_pubkey() # preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest() @@ -126,7 +131,8 @@ async def test_htlc_redeem_with_no_signature(wallet1: Wallet, wallet2: Wallet): @pytest.mark.asyncio async def test_htlc_redeem_with_wrong_signature(wallet1: Wallet, wallet2: Wallet): - await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) preimage = "00000000000000000000000000000000" pubkey_wallet1 = await wallet1.create_p2pk_pubkey() # preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest() @@ -149,7 +155,8 @@ async def test_htlc_redeem_with_wrong_signature(wallet1: Wallet, wallet2: Wallet @pytest.mark.asyncio async def test_htlc_redeem_with_correct_signature(wallet1: Wallet, wallet2: Wallet): - await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) preimage = "00000000000000000000000000000000" pubkey_wallet1 = await wallet1.create_p2pk_pubkey() # preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest() @@ -171,7 +178,8 @@ async def test_htlc_redeem_with_correct_signature(wallet1: Wallet, wallet2: Wall async def test_htlc_redeem_hashlock_wrong_signature_timelock_correct_signature( wallet1: Wallet, wallet2: Wallet ): - await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) preimage = "00000000000000000000000000000000" pubkey_wallet1 = await wallet1.create_p2pk_pubkey() pubkey_wallet2 = await wallet2.create_p2pk_pubkey() @@ -205,7 +213,8 @@ async def test_htlc_redeem_hashlock_wrong_signature_timelock_correct_signature( async def test_htlc_redeem_hashlock_wrong_signature_timelock_wrong_signature( wallet1: Wallet, wallet2: Wallet ): - await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) preimage = "00000000000000000000000000000000" pubkey_wallet1 = await wallet1.create_p2pk_pubkey() pubkey_wallet2 = await wallet2.create_p2pk_pubkey() diff --git a/tests/test_wallet_p2pk.py b/tests/test_wallet_p2pk.py index db16f54a..883e64bd 100644 --- a/tests/test_wallet_p2pk.py +++ b/tests/test_wallet_p2pk.py @@ -59,14 +59,16 @@ async def wallet2(mint): @pytest.mark.asyncio async def test_create_p2pk_pubkey(wallet1: Wallet): - await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) pubkey = await wallet1.create_p2pk_pubkey() PublicKey(bytes.fromhex(pubkey), raw=True) @pytest.mark.asyncio async def test_p2pk(wallet1: Wallet, wallet2: Wallet): - await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) pubkey_wallet2 = await wallet2.create_p2pk_pubkey() # p2pk test secret_lock = await wallet1.create_p2pk_lock(pubkey_wallet2) # sender side @@ -78,7 +80,8 @@ async def test_p2pk(wallet1: Wallet, wallet2: Wallet): @pytest.mark.asyncio async def test_p2pk_receive_with_wrong_private_key(wallet1: Wallet, wallet2: Wallet): - await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) pubkey_wallet2 = await wallet2.create_p2pk_pubkey() # receiver side # sender side secret_lock = await wallet1.create_p2pk_lock(pubkey_wallet2) # sender side @@ -97,7 +100,8 @@ async def test_p2pk_receive_with_wrong_private_key(wallet1: Wallet, wallet2: Wal async def test_p2pk_short_locktime_receive_with_wrong_private_key( wallet1: Wallet, wallet2: Wallet ): - await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) pubkey_wallet2 = await wallet2.create_p2pk_pubkey() # receiver side # sender side secret_lock = await wallet1.create_p2pk_lock( @@ -121,7 +125,8 @@ async def test_p2pk_short_locktime_receive_with_wrong_private_key( @pytest.mark.asyncio async def test_p2pk_locktime_with_refund_pubkey(wallet1: Wallet, wallet2: Wallet): - await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) pubkey_wallet2 = await wallet2.create_p2pk_pubkey() # receiver side # sender side garbage_pubkey = PrivateKey().pubkey @@ -148,7 +153,8 @@ async def test_p2pk_locktime_with_refund_pubkey(wallet1: Wallet, wallet2: Wallet @pytest.mark.asyncio async def test_p2pk_locktime_with_wrong_refund_pubkey(wallet1: Wallet, wallet2: Wallet): - await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) await wallet2.create_p2pk_pubkey() # receiver side # sender side garbage_pubkey = PrivateKey().pubkey @@ -182,7 +188,8 @@ async def test_p2pk_locktime_with_wrong_refund_pubkey(wallet1: Wallet, wallet2: async def test_p2pk_locktime_with_second_refund_pubkey( wallet1: Wallet, wallet2: Wallet ): - await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) pubkey_wallet1 = await wallet1.create_p2pk_pubkey() # receiver side pubkey_wallet2 = await wallet2.create_p2pk_pubkey() # receiver side # sender side @@ -212,7 +219,8 @@ async def test_p2pk_locktime_with_second_refund_pubkey( @pytest.mark.asyncio async def test_p2pk_multisig_2_of_2(wallet1: Wallet, wallet2: Wallet): - await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) pubkey_wallet1 = await wallet1.create_p2pk_pubkey() pubkey_wallet2 = await wallet2.create_p2pk_pubkey() assert pubkey_wallet1 != pubkey_wallet2 @@ -232,7 +240,8 @@ async def test_p2pk_multisig_2_of_2(wallet1: Wallet, wallet2: Wallet): @pytest.mark.asyncio async def test_p2pk_multisig_duplicate_signature(wallet1: Wallet, wallet2: Wallet): - await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) pubkey_wallet1 = await wallet1.create_p2pk_pubkey() pubkey_wallet2 = await wallet2.create_p2pk_pubkey() assert pubkey_wallet1 != pubkey_wallet2 @@ -254,7 +263,8 @@ async def test_p2pk_multisig_duplicate_signature(wallet1: Wallet, wallet2: Walle @pytest.mark.asyncio async def test_p2pk_multisig_quorum_not_met_1_of_2(wallet1: Wallet, wallet2: Wallet): - await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) pubkey_wallet1 = await wallet1.create_p2pk_pubkey() pubkey_wallet2 = await wallet2.create_p2pk_pubkey() assert pubkey_wallet1 != pubkey_wallet2 @@ -273,7 +283,8 @@ async def test_p2pk_multisig_quorum_not_met_1_of_2(wallet1: Wallet, wallet2: Wal @pytest.mark.asyncio async def test_p2pk_multisig_quorum_not_met_2_of_3(wallet1: Wallet, wallet2: Wallet): - await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) pubkey_wallet1 = await wallet1.create_p2pk_pubkey() pubkey_wallet2 = await wallet2.create_p2pk_pubkey() assert pubkey_wallet1 != pubkey_wallet2 @@ -296,7 +307,8 @@ async def test_p2pk_multisig_quorum_not_met_2_of_3(wallet1: Wallet, wallet2: Wal @pytest.mark.asyncio async def test_p2pk_multisig_with_duplicate_publickey(wallet1: Wallet, wallet2: Wallet): - await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) pubkey_wallet2 = await wallet2.create_p2pk_pubkey() # p2pk test secret_lock = await wallet1.create_p2pk_lock( @@ -312,7 +324,8 @@ async def test_p2pk_multisig_with_duplicate_publickey(wallet1: Wallet, wallet2: async def test_p2pk_multisig_with_wrong_first_private_key( wallet1: Wallet, wallet2: Wallet ): - await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) await wallet1.create_p2pk_pubkey() pubkey_wallet2 = await wallet2.create_p2pk_pubkey() wrong_pubklic_key = PrivateKey().pubkey diff --git a/tests/test_wallet_p2sh.py b/tests/test_wallet_p2sh.py index 95402047..0ae72185 100644 --- a/tests/test_wallet_p2sh.py +++ b/tests/test_wallet_p2sh.py @@ -56,14 +56,16 @@ async def wallet2(mint): @pytest.mark.asyncio async def test_create_p2pk_pubkey(wallet1: Wallet): - await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) pubkey = await wallet1.create_p2pk_pubkey() PublicKey(bytes.fromhex(pubkey), raw=True) @pytest.mark.asyncio async def test_p2sh(wallet1: Wallet, wallet2: Wallet): - await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) _ = await wallet1.create_p2sh_address_and_store() # receiver side _, send_proofs = await wallet1.split_to_send(wallet1.proofs, 8) # sender side @@ -76,7 +78,8 @@ async def test_p2sh(wallet1: Wallet, wallet2: Wallet): @pytest.mark.asyncio async def test_p2sh_receive_with_wrong_wallet(wallet1: Wallet, wallet2: Wallet): - await wallet1.mint(64) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) wallet1_address = await wallet1.create_p2sh_address_and_store() # receiver side secret_lock = await wallet1.create_p2sh_lock(wallet1_address) # sender side _, send_proofs = await wallet1.split_to_send( From 2d3a9234f5d3c034ec676df0f7f4a3df27445aef Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 24 Sep 2023 18:54:49 +0200 Subject: [PATCH 09/10] Tests: mint operations (#328) * mint operations * fix spelling * add pending test * fix tag * make htlc tests less likelyto fail --- cashu/core/secret.py | 7 ++-- cashu/lightning/fake.py | 3 ++ cashu/wallet/htlc.py | 6 ++-- tests/test_mint_operations.py | 64 +++++++++++++++++++++++++++++++++++ tests/test_wallet_htlc.py | 18 +++++----- tests/test_wallet_p2pk.py | 11 +++--- 6 files changed, 90 insertions(+), 19 deletions(-) create mode 100644 tests/test_mint_operations.py diff --git a/cashu/core/secret.py b/cashu/core/secret.py index 72bbd1f9..783cb72d 100644 --- a/cashu/core/secret.py +++ b/cashu/core/secret.py @@ -24,8 +24,11 @@ def __init__(self, tags: Optional[List[List[str]]] = None, **kwargs): super().__init__(**kwargs) self.__root__ = tags or [] - def __setitem__(self, key: str, value: str) -> None: - self.__root__.append([key, value]) + def __setitem__(self, key: str, value: Union[str, List[str]]) -> None: + if isinstance(value, str): + self.__root__.append([key, value]) + elif isinstance(value, list): + self.__root__.append([key, *value]) def __getitem__(self, key: str) -> Union[str, None]: return self.get_tag(key) diff --git a/cashu/lightning/fake.py b/cashu/lightning/fake.py index dfeed100..d2c617a5 100644 --- a/cashu/lightning/fake.py +++ b/cashu/lightning/fake.py @@ -77,6 +77,7 @@ async def create_invoice( async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: invoice = decode(bolt11) + # await asyncio.sleep(5) if invoice.payment_hash[:6] == self.privkey[:6] or BRR: await self.queue.put(invoice) @@ -88,6 +89,8 @@ async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse ) async def get_invoice_status(self, checking_id: str) -> PaymentStatus: + # paid = random.random() > 0.7 + # return PaymentStatus(paid) paid = checking_id in self.paid_invoices or BRR return PaymentStatus(paid or None) diff --git a/cashu/wallet/htlc.py b/cashu/wallet/htlc.py index 9ed5dc68..dae24571 100644 --- a/cashu/wallet/htlc.py +++ b/cashu/wallet/htlc.py @@ -22,7 +22,7 @@ async def create_htlc_lock( *, preimage: Optional[str] = None, preimage_hash: Optional[str] = None, - hacklock_pubkey: Optional[str] = None, + hashlock_pubkey: Optional[str] = None, locktime_seconds: Optional[int] = None, locktime_pubkey: Optional[str] = None, ) -> HTLCSecret: @@ -39,8 +39,8 @@ async def create_htlc_lock( assert preimage_hash, "preimage_hash or preimage must be provided" - if hacklock_pubkey: - tags["pubkeys"] = hacklock_pubkey + if hashlock_pubkey: + tags["pubkeys"] = hashlock_pubkey return HTLCSecret( kind=SecretKind.HTLC, diff --git a/tests/test_mint_operations.py b/tests/test_mint_operations.py new file mode 100644 index 00000000..916d8f56 --- /dev/null +++ b/tests/test_mint_operations.py @@ -0,0 +1,64 @@ +import pytest +import pytest_asyncio + +from cashu.mint.ledger import Ledger +from cashu.wallet.wallet import Wallet +from cashu.wallet.wallet import Wallet as Wallet1 +from tests.conftest import SERVER_ENDPOINT + + +@pytest_asyncio.fixture(scope="function") +async def wallet1(mint): + wallet1 = await Wallet1.with_db( + url=SERVER_ENDPOINT, + db="test_data/wallet1", + name="wallet1", + ) + await wallet1.load_mint() + wallet1.status() + yield wallet1 + + +@pytest.mark.asyncio +async def test_melt(wallet1: Wallet, ledger: Ledger): + # mint twice so we have enough to pay the second invoice back + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) + assert wallet1.balance == 128 + total_amount, fee_reserve_sat = await wallet1.get_pay_amount_with_fees(invoice.pr) + mint_fees = await ledger.get_melt_fees(invoice.pr) + assert mint_fees == fee_reserve_sat + + keep_proofs, send_proofs = await wallet1.split_to_send(wallet1.proofs, total_amount) + + await ledger.melt(send_proofs, invoice.pr, outputs=None) + + +@pytest.mark.asyncio +async def test_split(wallet1: Wallet, ledger: Ledger): + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) + + keep_proofs, send_proofs = await wallet1.split_to_send(wallet1.proofs, 10) + secrets, rs, derivation_paths = await wallet1.generate_n_secrets(len(send_proofs)) + outputs, rs = wallet1._construct_outputs( + [p.amount for p in send_proofs], secrets, rs + ) + + promises = await ledger.split(proofs=send_proofs, outputs=outputs) + assert len(promises) == len(outputs) + assert [p.amount for p in promises] == [p.amount for p in outputs] + + +@pytest.mark.asyncio +async def test_check_proof_state(wallet1: Wallet, ledger: Ledger): + invoice = await wallet1.request_mint(64) + await wallet1.mint(64, hash=invoice.hash) + + keep_proofs, send_proofs = await wallet1.split_to_send(wallet1.proofs, 10) + + spendable, pending = await ledger.check_proof_state(proofs=send_proofs) + assert sum(spendable) == len(send_proofs) + assert sum(pending) == 0 diff --git a/tests/test_wallet_htlc.py b/tests/test_wallet_htlc.py index 5128b3ab..6171aa56 100644 --- a/tests/test_wallet_htlc.py +++ b/tests/test_wallet_htlc.py @@ -99,7 +99,7 @@ async def test_htlc_redeem_with_wrong_preimage(wallet1: Wallet, wallet2: Wallet) await wallet1.mint(64, hash=invoice.hash) preimage = "00000000000000000000000000000000" # preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest() - secret = await wallet1.create_htlc_lock(preimage=preimage[:-1] + "1") + secret = await wallet1.create_htlc_lock(preimage=preimage[:-5] + "11111") # p2pk test _, send_proofs = await wallet1.split_to_send(wallet1.proofs, 8, secret_lock=secret) for p in send_proofs: @@ -117,7 +117,7 @@ async def test_htlc_redeem_with_no_signature(wallet1: Wallet, wallet2: Wallet): pubkey_wallet1 = await wallet1.create_p2pk_pubkey() # preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest() secret = await wallet1.create_htlc_lock( - preimage=preimage, hacklock_pubkey=pubkey_wallet1 + preimage=preimage, hashlock_pubkey=pubkey_wallet1 ) # p2pk test _, send_proofs = await wallet1.split_to_send(wallet1.proofs, 8, secret_lock=secret) @@ -137,15 +137,15 @@ async def test_htlc_redeem_with_wrong_signature(wallet1: Wallet, wallet2: Wallet pubkey_wallet1 = await wallet1.create_p2pk_pubkey() # preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest() secret = await wallet1.create_htlc_lock( - preimage=preimage, hacklock_pubkey=pubkey_wallet1 + preimage=preimage, hashlock_pubkey=pubkey_wallet1 ) + # p2pk test _, send_proofs = await wallet1.split_to_send(wallet1.proofs, 8, secret_lock=secret) - signatures = await wallet1.sign_p2pk_proofs(send_proofs) for p, s in zip(send_proofs, signatures): p.htlcpreimage = preimage - p.htlcsignature = s[:-1] + "1" # wrong signature + p.htlcsignature = s[:-5] + "11111" # wrong signature await assert_err( wallet2.redeem(send_proofs), @@ -161,7 +161,7 @@ async def test_htlc_redeem_with_correct_signature(wallet1: Wallet, wallet2: Wall pubkey_wallet1 = await wallet1.create_p2pk_pubkey() # preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest() secret = await wallet1.create_htlc_lock( - preimage=preimage, hacklock_pubkey=pubkey_wallet1 + preimage=preimage, hashlock_pubkey=pubkey_wallet1 ) # p2pk test _, send_proofs = await wallet1.split_to_send(wallet1.proofs, 8, secret_lock=secret) @@ -186,7 +186,7 @@ async def test_htlc_redeem_hashlock_wrong_signature_timelock_correct_signature( # preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest() secret = await wallet1.create_htlc_lock( preimage=preimage, - hacklock_pubkey=pubkey_wallet2, + hashlock_pubkey=pubkey_wallet2, locktime_seconds=5, locktime_pubkey=pubkey_wallet1, ) @@ -221,7 +221,7 @@ async def test_htlc_redeem_hashlock_wrong_signature_timelock_wrong_signature( # preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest() secret = await wallet1.create_htlc_lock( preimage=preimage, - hacklock_pubkey=pubkey_wallet2, + hashlock_pubkey=pubkey_wallet2, locktime_seconds=5, locktime_pubkey=pubkey_wallet1, ) @@ -231,7 +231,7 @@ async def test_htlc_redeem_hashlock_wrong_signature_timelock_wrong_signature( signatures = await wallet1.sign_p2pk_proofs(send_proofs) for p, s in zip(send_proofs, signatures): p.htlcpreimage = preimage - p.htlcsignature = s[:-1] + "1" # wrong signature + p.htlcsignature = s[:-5] + "11111" # wrong signature # should error because we used wallet2 signatures for the hash lock await assert_err( diff --git a/tests/test_wallet_p2pk.py b/tests/test_wallet_p2pk.py index 883e64bd..8985f0ad 100644 --- a/tests/test_wallet_p2pk.py +++ b/tests/test_wallet_p2pk.py @@ -360,6 +360,12 @@ def test_tags(): assert tags["key3"] is None assert tags.get_tag_all("key2") == ["value2", "value2_1", "value3"] + # set multiple values of the same key + tags["key3"] = "value3" + assert tags.get_tag_all("key3") == ["value3"] + tags["key4"] = ["value4", "value4_2"] + assert tags.get_tag_all("key4") == ["value4", "value4_2"] + @pytest.mark.asyncio async def test_secret_initialized_with_tags(wallet1: Wallet): @@ -370,11 +376,8 @@ async def test_secret_initialized_with_tags(wallet1: Wallet): pubkey=pubkey.serialize().hex(), tags=tags, ) - assert secret.locktime assert secret.locktime == 100 - assert secret.n_sigs assert secret.n_sigs == 3 - assert secret.sigflag assert secret.sigflag == SigFlags.SIG_ALL @@ -390,7 +393,5 @@ async def test_secret_initialized_with_arguments(wallet1: Wallet): ) assert secret.locktime assert secret.locktime > 1689000000 - assert secret.n_sigs assert secret.n_sigs == 3 - assert secret.sigflag assert secret.sigflag == SigFlags.SIG_ALL From 64805e4a9ad058963c025e52b10cdd3b44cbff88 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 24 Sep 2023 19:10:25 +0200 Subject: [PATCH 10/10] Bump to `0.14.0-rc1` (#329) * bump to 0.14.0 * bump python version * use python 3.10.4 in check workflow --- .github/workflows/checks.yml | 4 ++-- .github/workflows/tests.yml | 2 +- README.md | 6 +++--- cashu/core/settings.py | 2 +- pyproject.toml | 2 +- setup.py | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index e001f35e..429f2ede 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.9"] + python-version: ["3.10.4"] poetry-version: ["1.5.1"] steps: - uses: actions/checkout@v2 @@ -28,7 +28,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.9"] + python-version: ["3.10.4"] poetry-version: ["1.5.1"] steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ecea6135..c99460b2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,7 +8,7 @@ jobs: strategy: matrix: os: [ubuntu-latest] - python-version: ["3.9.13"] + python-version: ["3.10.4"] poetry-version: ["1.5.1"] steps: - name: Checkout repository and submodules diff --git a/README.md b/README.md index f9133897..614e3fbe 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ curl https://pyenv.run | bash pyenv init # restart your shell (or source your .rc file), then install python: -pyenv install 3.9.13 +pyenv install 3.10.4 # install poetry curl -sSL https://install.python-poetry.org | python3 - @@ -72,7 +72,7 @@ source ~/.bashrc # install cashu git clone https://github.com/callebtc/cashu.git --recurse-submodules cd cashu -pyenv local 3.9.13 +pyenv local 3.10.4 poetry install ``` @@ -114,7 +114,7 @@ cashu info Returns: ```bash -Version: 0.13.0 +Version: 0.14.0 Debug: False Cashu dir: /home/user/.cashu Wallet: wallet diff --git a/cashu/core/settings.py b/cashu/core/settings.py index 882288d4..f52c3eba 100644 --- a/cashu/core/settings.py +++ b/cashu/core/settings.py @@ -8,7 +8,7 @@ env = Env() -VERSION = "0.13.0" +VERSION = "0.14.0" def find_env_file(): diff --git a/pyproject.toml b/pyproject.toml index f5e34b2c..7a619ba1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "cashu" -version = "0.13.0" +version = "0.14.0" description = "Ecash wallet and mint" authors = ["calle "] license = "MIT" diff --git a/setup.py b/setup.py index d1d5ea6e..dd56a085 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ setuptools.setup( name="cashu", - version="0.13.0", + version="0.14.0", description="Ecash wallet and mint for Bitcoin Lightning", long_description=long_description, long_description_content_type="text/markdown",