From cc4aeb57a173131dfc8c5a9e8f64504eb267c7fd Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Wed, 10 Jul 2024 14:14:55 +0200 Subject: [PATCH 01/68] Models + Merkle function + Test draft --- cashu/core/base.py | 77 ++++++++++++++++++++++++++++++++++++++++ cashu/core/crypto/dlc.py | 34 ++++++++++++++++++ cashu/core/models.py | 41 +++++++++++++++++++++ cashu/core/secret.py | 1 + cashu/mint/conditions.py | 5 +++ cashu/mint/features.py | 13 +++++++ tests/test_mint_dlc.py | 13 +++++++ 7 files changed, 184 insertions(+) create mode 100644 cashu/core/crypto/dlc.py create mode 100644 tests/test_mint_dlc.py diff --git a/cashu/core/base.py b/cashu/core/base.py index 96c7313d..2bad73b0 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -114,6 +114,14 @@ class P2PKWitness(BaseModel): def from_witness(cls, witness: str): return cls(**json.loads(witness)) +class DLCWitness(BaseModel): + leaf_secret: str + merkle_proof: List[str] + + @classmethod + def from_witness(cls, witness: str): + return cls(**json.loads(witness)) + class Proof(BaseModel): """ @@ -189,6 +197,16 @@ def p2pksigs(self) -> List[str]: assert self.witness, "Witness is missing for p2pk signature" return P2PKWitness.from_witness(self.witness).signatures + @property + def dlc_leaf_secret(self) -> str: + assert self.witness, "Witness is missing for dlc leaf secret" + return DLCWitness.from_witness(self.witness).leaf_secret + + @property + def dlc_merkle_proof(self) -> List[str]: + assert self.witness, "Witness is missing for dlc merkle proof" + return DLCWitness.from_witness(self.witness).merkle_proof + @property def htlcpreimage(self) -> Union[str, None]: assert self.witness, "Witness is missing for htlc preimage" @@ -877,6 +895,8 @@ class TokenV4(BaseModel): t: List[TokenV4Token] # memo d: Optional[str] = None + # dlc root + r: Optional[str] = None @property def mint(self) -> str: @@ -920,6 +940,10 @@ def proofs(self) -> List[Proof]: for token in self.t for p in token.p ] + + @property + def dlc_root(self) -> str: + return self.r @classmethod def from_tokenv3(cls, tokenv3: TokenV3): @@ -1043,3 +1067,56 @@ def to_tokenv3(self) -> TokenV3: ) ) return tokenv3 + +# -------- DLC STUFF -------- + +class DiscreteLogContract(BaseModel): + """ + A discrete log contract + """ + settled: bool = False + dlc_root: str + funding_amount: int + inputs: List[Proof] # Need to verify these are indeed SCT proofs + debts: Dict[str, int] = {} # We save who we owe money to here + +class DlcBadInputs(BaseModel): + index: int + detail: str + +class DlcFundingProof(BaseModel): + """ + A dlc merkle root with its signature + """ + dlc_root: str + signature: Optional[str] + bad_inputs: Optional[List[DlcBadInputs]] = None # Used to specify potential errors + +class DlcOutcome(BaseModel): + """ + Describes a DLC outcome + """ + k: Optional[str] # The discrete log revealed by the oracle + t: Optional[int] # The timeout (claim when time is over) + P: str # The payout structure associated with k + +class DlcSettlement(BaseModel): + """ + Data used to settle an outcome of a DLC + """ + dlc_root: str + outcome: DlcOutcome + merkle_proof: List[str] + details: Optional[str] + +class DlcPayoutForm(BaseModel): + dlc_root: str + pubkey: str + outputs: List[BlindedMessage] + witness: P2PKWitness + +class DlcPayout(BaseModel): + dlc_root: str + signatures: Optional[List[BlindedSignature]] + details: Optional[str] # error details + diff --git a/cashu/core/crypto/dlc.py b/cashu/core/crypto/dlc.py new file mode 100644 index 00000000..e412245b --- /dev/null +++ b/cashu/core/crypto/dlc.py @@ -0,0 +1,34 @@ +from hashlib import sha256 +from typing import Optional, Tuple +from secp256k1 import PrivateKey, PublicKey + +def sorted_merkle_hash(left: bytes, right: bytes) -> bytes: + '''Sorts `left` and `right` in non-ascending order and + computes the hash of their concatenation + ''' + if int.from_bytes(left, 'big') < int.from_bytes(right, 'big'): + left, right = right, left + return sha256(left+right).digest() + + +def merkle_root(leaf_hashes: List[bytes]) -> bytes: + '''Computes the root of a list of merkle proofs + ''' + if len(leaf_hashes) == 0: + return b"" + elif len(leaf_hashes) == 1: + return leaf_hashes[0] + else: + split = len(leaf_hashes) // 2 + left = merkle_root(leaf_hashes[:split]) + right = merkle_root(leaf_hashes[split:]) + return sorted_merkle_hash(left, right) + +def merkle_verify(root: bytes, leaf_hash: bytes, proof: List[bytes]) -> bool: + '''Verifies that `leaf_hash` belongs to a merkle tree + that has `root` as root + ''' + h = leaf_hash + for branch_hash in proof: + h = sorted_merkle_hash(h, branch_hash) + return h == root \ No newline at end of file diff --git a/cashu/core/models.py b/cashu/core/models.py index f4cea2b1..2d338677 100644 --- a/cashu/core/models.py +++ b/cashu/core/models.py @@ -10,6 +10,11 @@ MintQuote, Proof, ProofState, + DlcFundingProof, + DiscreteLogContract, + DlcSettlement, + DlcPayoutForm, + DlcPayout ) from .settings import settings @@ -304,3 +309,39 @@ class PostRestoreResponse(BaseModel): def __init__(self, **data): super().__init__(**data) self.promises = self.signatures + + +# ------- API: DLC REGISTRATION ------- + +class PostDlcRegistrationRequest(BaseModel): + atomic: Optional[bool] + registrations: List[DiscreteLogContract] + +class PostDlcRegistrationResponse(BaseModel): + funded: List[DlcFundingProof] = [] + errors: Optional[List[DlcFundingProof]] = None + +# ------- API: DLC SETTLEMENT ------- + +class PostDlcSettleRequest(BaseModel): + settlements: List[DlcSettlement] + +class PostDlcSettleResponse(BaseModel): + settled: List[DlcSettlement] = [] + errors: Optional[List[DlcSettlement]] = None + +# ------- API: DLC PAYOUT ------- +class PostDlcPayoutRequest(BaseModel): + atomic: Optional[bool] + payouts: List[DlcPayoutForm] + +class PostDlcPayoutResponse(BaseModel): + paid: List[DlcPayout] + errors: Optional[List[DlcPayout]] + +# ------- API: DLC STATUS ------- + +class GetDlcStatusResponse(BaseModel): + settled: bool + funding_amount: Optional[int] + debts: Optional[Dict[str, int]] diff --git a/cashu/core/secret.py b/cashu/core/secret.py index 663f2be5..e9674ffd 100644 --- a/cashu/core/secret.py +++ b/cashu/core/secret.py @@ -11,6 +11,7 @@ class SecretKind(Enum): P2PK = "P2PK" HTLC = "HTLC" + SCT = "SCT" class Tags(BaseModel): diff --git a/cashu/mint/conditions.py b/cashu/mint/conditions.py index 983b1935..2240b031 100644 --- a/cashu/mint/conditions.py +++ b/cashu/mint/conditions.py @@ -197,6 +197,7 @@ def _verify_input_spending_conditions(self, proof: Proof) -> bool: Verify spending conditions: Condition: P2PK - Checks if signature in proof.witness is valid for pubkey in proof.secret Condition: HTLC - Checks if preimage in proof.witness is valid for hash in proof.secret + Condition: SCT - Spending Condition Tree means this proof is DLC locked: DO NOT SPEND. """ try: @@ -214,6 +215,10 @@ def _verify_input_spending_conditions(self, proof: Proof) -> bool: # HTLC if SecretKind(secret.kind) == SecretKind.HTLC: return self._verify_htlc_spending_conditions(proof, secret) + + # SCT + if SecretKind(secret.kind) == SecretKind.SCT: + return False # no spending condition present return True diff --git a/cashu/mint/features.py b/cashu/mint/features.py index 494033e8..f265cc57 100644 --- a/cashu/mint/features.py +++ b/cashu/mint/features.py @@ -56,6 +56,19 @@ def mint_features(self) -> Dict[int, Union[List[Any], Dict[str, Any]]]: SCRIPT_NUT: supported_dict, P2PK_NUT: supported_dict, DLEQ_NUT: supported_dict, + # Hard coded values for now + DLC_NUT: dict( + supported=True, + funding_proof_pubkey='XXXXXX', + max_payous=30, + ttl=2629743, # 1 month + fees=dict( + sat=dict( + base=0, + ppk=0 + ) + ) + ) } # signal which method-unit pairs support MPP diff --git a/tests/test_mint_dlc.py b/tests/test_mint_dlc.py new file mode 100644 index 00000000..968fe20e --- /dev/null +++ b/tests/test_mint_dlc.py @@ -0,0 +1,13 @@ +import pytest +import pytest_asyncio + +from cashu.core.crypto.dlc import sorted_merkle_hash + +@pytest.mark.asyncio +async def test_sorted_merkle_hash(): + data = [b'\x01', b'\x02'] + target = '25dfd29c09617dcc9852281c030e5b3037a338a4712a42a21c907f259c6412a0' + h = sorted_merkle_hash(data[1], data[0]) + assert h.hex() == target, f'sorted_merkle_hash test fail: {h.hex() = }' + h = sorted_merkle_hash(data[0], data[1]) + assert h.hex() == target, f'sorted_merkle_hash test fail: {h.hex() = }' \ No newline at end of file From 383e032fd802dd88e399ee98ef10187ea0cd7a01 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Wed, 10 Jul 2024 14:30:09 +0200 Subject: [PATCH 02/68] remove `test_mint_dlc` --- tests/test_mint_dlc.py | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 tests/test_mint_dlc.py diff --git a/tests/test_mint_dlc.py b/tests/test_mint_dlc.py deleted file mode 100644 index 968fe20e..00000000 --- a/tests/test_mint_dlc.py +++ /dev/null @@ -1,13 +0,0 @@ -import pytest -import pytest_asyncio - -from cashu.core.crypto.dlc import sorted_merkle_hash - -@pytest.mark.asyncio -async def test_sorted_merkle_hash(): - data = [b'\x01', b'\x02'] - target = '25dfd29c09617dcc9852281c030e5b3037a338a4712a42a21c907f259c6412a0' - h = sorted_merkle_hash(data[1], data[0]) - assert h.hex() == target, f'sorted_merkle_hash test fail: {h.hex() = }' - h = sorted_merkle_hash(data[0], data[1]) - assert h.hex() == target, f'sorted_merkle_hash test fail: {h.hex() = }' \ No newline at end of file From 3f44d819188366f57051dac0ffbf6e5af252dedd Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Wed, 10 Jul 2024 14:36:23 +0200 Subject: [PATCH 03/68] fix errors --- cashu/core/base.py | 6 +----- cashu/core/crypto/dlc.py | 2 ++ cashu/core/nuts.py | 1 + 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index a527b982..357e8d3b 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -1047,17 +1047,13 @@ def proofs(self) -> List[Proof]: ] @property - def dlc_root(self) -> str: + def dlc_root(self) -> Optional[str]: return self.r @property def keysets(self) -> List[str]: return list(set([p.i.hex() for p in self.t])) - @property - def keysets(self) -> List[str]: - return list(set([p.i.hex() for p in self.t])) - @classmethod def from_tokenv3(cls, tokenv3: TokenV3): if not len(tokenv3.mints) == 1: diff --git a/cashu/core/crypto/dlc.py b/cashu/core/crypto/dlc.py index e412245b..eb9d404c 100644 --- a/cashu/core/crypto/dlc.py +++ b/cashu/core/crypto/dlc.py @@ -2,6 +2,8 @@ from typing import Optional, Tuple from secp256k1 import PrivateKey, PublicKey +from typing import List + def sorted_merkle_hash(left: bytes, right: bytes) -> bytes: '''Sorts `left` and `right` in non-ascending order and computes the hash of their concatenation diff --git a/cashu/core/nuts.py b/cashu/core/nuts.py index 6c0b5287..47a96423 100644 --- a/cashu/core/nuts.py +++ b/cashu/core/nuts.py @@ -11,3 +11,4 @@ DETERMINSTIC_SECRETS_NUT = 13 MPP_NUT = 15 WEBSOCKETS_NUT = 17 +DLC_NUT = 99 From 619c778756330d3999f5ac0b3531faefe209685e Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Wed, 10 Jul 2024 14:40:21 +0200 Subject: [PATCH 04/68] fix more errors --- cashu/mint/features.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cashu/mint/features.py b/cashu/mint/features.py index f265cc57..0e6a665e 100644 --- a/cashu/mint/features.py +++ b/cashu/mint/features.py @@ -15,6 +15,7 @@ SCRIPT_NUT, STATE_NUT, WEBSOCKETS_NUT, + DLC_NUT ) from ..core.settings import settings from ..mint.protocols import SupportsBackends From c22b800d64ca45377301ae2e5c25a36109d07a6a Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Wed, 10 Jul 2024 22:20:17 +0200 Subject: [PATCH 05/68] merkle functions tests --- cashu/core/crypto/dlc.py | 41 +++++++++++++++++++++++++++++++--------- tests/test_mint_dlc.py | 39 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 9 deletions(-) create mode 100644 tests/test_mint_dlc.py diff --git a/cashu/core/crypto/dlc.py b/cashu/core/crypto/dlc.py index eb9d404c..77b3daa9 100644 --- a/cashu/core/crypto/dlc.py +++ b/cashu/core/crypto/dlc.py @@ -13,18 +13,41 @@ def sorted_merkle_hash(left: bytes, right: bytes) -> bytes: return sha256(left+right).digest() -def merkle_root(leaf_hashes: List[bytes]) -> bytes: +def merkle_root( + leaf_hashes: List[bytes], + track_branch: Optional[int] = None + ) -> Tuple[bytes, List[bytes]]: '''Computes the root of a list of merkle proofs + if `track_branch` is set, returns also the hashes for the branch that leads + to `leaf_hashes[track_branch]` ''' - if len(leaf_hashes) == 0: - return b"" - elif len(leaf_hashes) == 1: - return leaf_hashes[0] + if track_branch is not None: + if len(leaf_hashes) == 0: + return b"", [] + elif len(leaf_hashes) == 1: + return leaf_hashes[0], [] + else: + split = len(leaf_hashes) // 2 + left, left_branch_hashes = merkle_root(leaf_hashes[:split], + track_branch if track_branch < split else None) + right, right_branch_hashes = merkle_root(leaf_hashes[split:], + track_branch-split if track_branch >= split else None) + branch_hashes = (left_branch_hashes if + track_branch < split else right_branch_hashes) + hashh = sorted_merkle_hash(left, right) + branch_hashes.append(right if track_branch < split else left) + return hashh, branch_hashes else: - split = len(leaf_hashes) // 2 - left = merkle_root(leaf_hashes[:split]) - right = merkle_root(leaf_hashes[split:]) - return sorted_merkle_hash(left, right) + if len(leaf_hashes) == 0: + return b"", None + elif len(leaf_hashes) == 1: + return leaf_hashes[0], None + else: + split = len(leaf_hashes) // 2 + left, _ = merkle_root(leaf_hashes[:split], None) + right, _ = merkle_root(leaf_hashes[split:], None) + hashh = sorted_merkle_hash(left, right) + return hashh, None def merkle_verify(root: bytes, leaf_hash: bytes, proof: List[bytes]) -> bool: '''Verifies that `leaf_hash` belongs to a merkle tree diff --git a/tests/test_mint_dlc.py b/tests/test_mint_dlc.py new file mode 100644 index 00000000..cf59c3d6 --- /dev/null +++ b/tests/test_mint_dlc.py @@ -0,0 +1,39 @@ +import pytest +import pytest_asyncio +from hashlib import sha256 +from random import shuffle, randint +from tests.conftest import SERVER_ENDPOINT + +from cashu.core.crypto.dlc import sorted_merkle_hash, merkle_root, merkle_verify + +@pytest.mark.asyncio +async def test_merkle_hash(): + data = [b'\x01', b'\x02'] + target = '25dfd29c09617dcc9852281c030e5b3037a338a4712a42a21c907f259c6412a0' + h = sorted_merkle_hash(data[1], data[0]) + assert h.hex() == target, f'sorted_merkle_hash test fail: {h.hex() = }' + h = sorted_merkle_hash(data[0], data[1]) + assert h.hex() == target, f'sorted_merkle_hash reverse test fail: {h.hex() = }' + +@pytest.mark.asyncio +async def test_merkle_root(): + target = '0ee849f3b077380cd2cf5c76c6d63bcaa08bea89c1ef9914e5bc86c174417cb3' + leafs = [sha256(i.to_bytes(32, 'big')).digest() for i in range(16)] + root, _ = merkle_root(leafs) + assert root.hex() == target, f"merkle_root test fail: {root.hex() = }" + +@pytest.mark.asyncio +async def test_merkle_verify(): + leafs = [sha256(i.to_bytes(32, 'big')).digest() for i in range(16)] + root, branch_hashes = merkle_root(leafs, 0) + assert merkle_verify(root, leafs[0], branch_hashes), f"merkle_verify test fail" + + leafs = [sha256(i.to_bytes(32, 'big')).digest() for i in range(53)] + root, branch_hashes = merkle_root(leafs, 0) + assert merkle_verify(root, leafs[0], branch_hashes), f"merkle_verify test fail" + + leafs = [sha256(i.to_bytes(32, 'big')).digest() for i in range(18)] + shuffle(leafs) + l = randint(0, len(leafs)-1) + root, branch_hashes = merkle_root(leafs, l) + assert merkle_verify(root, leafs[l], branch_hashes), "merkle_verify test fail" From d1fd1d75db52d6ae1a7df869e97d87ec0b39dd2a Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Wed, 10 Jul 2024 22:26:34 +0200 Subject: [PATCH 06/68] making mypy happy --- cashu/core/crypto/dlc.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cashu/core/crypto/dlc.py b/cashu/core/crypto/dlc.py index 77b3daa9..a6ab911f 100644 --- a/cashu/core/crypto/dlc.py +++ b/cashu/core/crypto/dlc.py @@ -16,7 +16,7 @@ def sorted_merkle_hash(left: bytes, right: bytes) -> bytes: def merkle_root( leaf_hashes: List[bytes], track_branch: Optional[int] = None - ) -> Tuple[bytes, List[bytes]]: + ) -> Tuple[bytes, Optional[List[bytes]]]: '''Computes the root of a list of merkle proofs if `track_branch` is set, returns also the hashes for the branch that leads to `leaf_hashes[track_branch]` @@ -35,6 +35,8 @@ def merkle_root( branch_hashes = (left_branch_hashes if track_branch < split else right_branch_hashes) hashh = sorted_merkle_hash(left, right) + # Needed to pass mypy checks + assert branch_hashes is not None, "merkle_root fail: branch_hashes == None" branch_hashes.append(right if track_branch < split else left) return hashh, branch_hashes else: From 043556b2091e68c934c6945f59b09d30c0df62fc Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Thu, 11 Jul 2024 17:26:02 +0200 Subject: [PATCH 07/68] SCT spending conditions --- cashu/core/base.py | 1 + cashu/core/crypto/dlc.py | 3 +-- cashu/core/secret.py | 1 + cashu/mint/conditions.py | 51 ++++++++++++++++++++++++++++++++++++++-- tests/test_mint_dlc.py | 6 ++--- 5 files changed, 54 insertions(+), 8 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index 357e8d3b..97e6b199 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -118,6 +118,7 @@ def from_witness(cls, witness: str): class DLCWitness(BaseModel): leaf_secret: str merkle_proof: List[str] + witness: Optional[str] = None @classmethod def from_witness(cls, witness: str): diff --git a/cashu/core/crypto/dlc.py b/cashu/core/crypto/dlc.py index a6ab911f..1100dfc3 100644 --- a/cashu/core/crypto/dlc.py +++ b/cashu/core/crypto/dlc.py @@ -1,6 +1,5 @@ from hashlib import sha256 from typing import Optional, Tuple -from secp256k1 import PrivateKey, PublicKey from typing import List @@ -18,7 +17,7 @@ def merkle_root( track_branch: Optional[int] = None ) -> Tuple[bytes, Optional[List[bytes]]]: '''Computes the root of a list of merkle proofs - if `track_branch` is set, returns also the hashes for the branch that leads + if `track_branch` is set, extracts the hashes for the branch that leads to `leaf_hashes[track_branch]` ''' if track_branch is not None: diff --git a/cashu/core/secret.py b/cashu/core/secret.py index e9674ffd..4b01bee5 100644 --- a/cashu/core/secret.py +++ b/cashu/core/secret.py @@ -12,6 +12,7 @@ class SecretKind(Enum): P2PK = "P2PK" HTLC = "HTLC" SCT = "SCT" + DLC = "DLC" class Tags(BaseModel): diff --git a/cashu/mint/conditions.py b/cashu/mint/conditions.py index 2240b031..f8d7ede6 100644 --- a/cashu/mint/conditions.py +++ b/cashu/mint/conditions.py @@ -4,7 +4,7 @@ from loguru import logger -from ..core.base import BlindedMessage, HTLCWitness, Proof +from ..core.base import BlindedMessage, HTLCWitness, Proof, DLCWitness from ..core.crypto.secp import PublicKey from ..core.errors import ( TransactionError, @@ -16,6 +16,7 @@ verify_p2pk_signature, ) from ..core.secret import Secret, SecretKind +from ..core.crypto.dlc import merkle_verify class LedgerSpendingConditions: @@ -192,12 +193,54 @@ def _verify_htlc_spending_conditions(self, proof: Proof, secret: Secret) -> bool # no pubkeys were included, anyone can spend return True + def _verify_sct_spending_conditions(self, proof: Proof, secret: Secret) -> bool: + """ + Verify SCT spending conditions for a single input + """ + if proof.witness is None: + return False + + witness = DLCWitness.from_witness(proof.witness) + assert witness, TransactionError("No or corrupt DLC witness data provided for a secret kind SCT") + + spending_condition = False + try: + leaf_secret = Secret.deserialize(witness.leaf_secret) + logger.trace(f"proof.secret: {proof.secret}") + logger.trace(f"secret: {secret}") + spending_condition = True + except Exception: + # leaf secret is not a spending condition (we assume it is a backup secret) + pass + + # Merkle Tree verify + merkle_root_bytes = bytes.fromhex(secret.data) + merkle_proof_bytes = [bytes.fromhex(h) for h in witness.merkle_proof] + leaf_secret_bytes = hashlib.sha256(witness.leaf_secret.encode()).digest() + valid = merkle_verify(merkle_root_bytes, leaf_secret_bytes, merkle_proof_bytes) + + if not valid: + return False + + if not spending_condition: # means that it is valid and a normal secret + return True + + # leaf_secret is a secret of another kind: verify that kind + # We only ever need the secret and the witness data + new_proof = Proof( + secret=witness.leaf_secret, + witness=witness.witness + ) + return self._verify_input_spending_conditions(new_proof) + def _verify_input_spending_conditions(self, proof: Proof) -> bool: """ Verify spending conditions: Condition: P2PK - Checks if signature in proof.witness is valid for pubkey in proof.secret Condition: HTLC - Checks if preimage in proof.witness is valid for hash in proof.secret - Condition: SCT - Spending Condition Tree means this proof is DLC locked: DO NOT SPEND. + Condition: SCT - Checks if leaf_secret in proof.witness is a leaf of the Merkle Tree with + root proof.secret.data according to proof.witness.merkle_proof + Condition: DLC - NEVER SPEND (can only be registered) """ try: @@ -218,6 +261,10 @@ def _verify_input_spending_conditions(self, proof: Proof) -> bool: # SCT if SecretKind(secret.kind) == SecretKind.SCT: + return self._verify_sct_spending_conditions(proof, secret) + + # DLC + if SecretKind(secret.kind) == SecretKind.DLC: return False # no spending condition present diff --git a/tests/test_mint_dlc.py b/tests/test_mint_dlc.py index cf59c3d6..ccfebe1b 100644 --- a/tests/test_mint_dlc.py +++ b/tests/test_mint_dlc.py @@ -1,8 +1,6 @@ import pytest -import pytest_asyncio from hashlib import sha256 from random import shuffle, randint -from tests.conftest import SERVER_ENDPOINT from cashu.core.crypto.dlc import sorted_merkle_hash, merkle_root, merkle_verify @@ -26,11 +24,11 @@ async def test_merkle_root(): async def test_merkle_verify(): leafs = [sha256(i.to_bytes(32, 'big')).digest() for i in range(16)] root, branch_hashes = merkle_root(leafs, 0) - assert merkle_verify(root, leafs[0], branch_hashes), f"merkle_verify test fail" + assert merkle_verify(root, leafs[0], branch_hashes), "merkle_verify test fail" leafs = [sha256(i.to_bytes(32, 'big')).digest() for i in range(53)] root, branch_hashes = merkle_root(leafs, 0) - assert merkle_verify(root, leafs[0], branch_hashes), f"merkle_verify test fail" + assert merkle_verify(root, leafs[0], branch_hashes), "merkle_verify test fail" leafs = [sha256(i.to_bytes(32, 'big')).digest() for i in range(18)] shuffle(leafs) From 7d22656f0411ff406806603e20d9ddae616227a2 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Thu, 11 Jul 2024 17:49:23 +0200 Subject: [PATCH 08/68] formatting errors --- cashu/core/crypto/dlc.py | 3 +-- cashu/core/models.py | 10 +++++----- cashu/mint/conditions.py | 6 +++--- cashu/mint/features.py | 2 +- tests/test_mint_dlc.py | 14 ++++++++------ 5 files changed, 18 insertions(+), 17 deletions(-) diff --git a/cashu/core/crypto/dlc.py b/cashu/core/crypto/dlc.py index 1100dfc3..689361b0 100644 --- a/cashu/core/crypto/dlc.py +++ b/cashu/core/crypto/dlc.py @@ -1,7 +1,6 @@ from hashlib import sha256 -from typing import Optional, Tuple +from typing import List, Optional, Tuple -from typing import List def sorted_merkle_hash(left: bytes, right: bytes) -> bytes: '''Sorts `left` and `right` in non-ascending order and diff --git a/cashu/core/models.py b/cashu/core/models.py index 3120d0f2..1c955904 100644 --- a/cashu/core/models.py +++ b/cashu/core/models.py @@ -6,15 +6,15 @@ BlindedMessage, BlindedMessage_Deprecated, BlindedSignature, + DiscreteLogContract, + DlcFundingProof, + DlcPayout, + DlcPayoutForm, + DlcSettlement, MeltQuote, MintQuote, Proof, ProofState, - DlcFundingProof, - DiscreteLogContract, - DlcSettlement, - DlcPayoutForm, - DlcPayout ) from .settings import settings diff --git a/cashu/mint/conditions.py b/cashu/mint/conditions.py index f8d7ede6..ebcdf8fc 100644 --- a/cashu/mint/conditions.py +++ b/cashu/mint/conditions.py @@ -4,7 +4,8 @@ from loguru import logger -from ..core.base import BlindedMessage, HTLCWitness, Proof, DLCWitness +from ..core.base import BlindedMessage, DLCWitness, HTLCWitness, Proof +from ..core.crypto.dlc import merkle_verify from ..core.crypto.secp import PublicKey from ..core.errors import ( TransactionError, @@ -16,7 +17,6 @@ verify_p2pk_signature, ) from ..core.secret import Secret, SecretKind -from ..core.crypto.dlc import merkle_verify class LedgerSpendingConditions: @@ -205,7 +205,7 @@ def _verify_sct_spending_conditions(self, proof: Proof, secret: Secret) -> bool: spending_condition = False try: - leaf_secret = Secret.deserialize(witness.leaf_secret) + _ = Secret.deserialize(witness.leaf_secret) logger.trace(f"proof.secret: {proof.secret}") logger.trace(f"secret: {secret}") spending_condition = True diff --git a/cashu/mint/features.py b/cashu/mint/features.py index 0e6a665e..6f83d6ac 100644 --- a/cashu/mint/features.py +++ b/cashu/mint/features.py @@ -5,6 +5,7 @@ MintMeltMethodSetting, ) from ..core.nuts import ( + DLC_NUT, DLEQ_NUT, FEE_RETURN_NUT, MELT_NUT, @@ -15,7 +16,6 @@ SCRIPT_NUT, STATE_NUT, WEBSOCKETS_NUT, - DLC_NUT ) from ..core.settings import settings from ..mint.protocols import SupportsBackends diff --git a/tests/test_mint_dlc.py b/tests/test_mint_dlc.py index ccfebe1b..ba59f915 100644 --- a/tests/test_mint_dlc.py +++ b/tests/test_mint_dlc.py @@ -1,8 +1,10 @@ -import pytest from hashlib import sha256 -from random import shuffle, randint +from random import randint, shuffle + +import pytest + +from cashu.core.crypto.dlc import merkle_root, merkle_verify, sorted_merkle_hash -from cashu.core.crypto.dlc import sorted_merkle_hash, merkle_root, merkle_verify @pytest.mark.asyncio async def test_merkle_hash(): @@ -32,6 +34,6 @@ async def test_merkle_verify(): leafs = [sha256(i.to_bytes(32, 'big')).digest() for i in range(18)] shuffle(leafs) - l = randint(0, len(leafs)-1) - root, branch_hashes = merkle_root(leafs, l) - assert merkle_verify(root, leafs[l], branch_hashes), "merkle_verify test fail" + index = randint(0, len(leafs)-1) + root, branch_hashes = merkle_root(leafs, index) + assert merkle_verify(root, leafs[index], branch_hashes), "merkle_verify test fail" From 5e11a99e589facba6a7af594801159ad0eba0baa Mon Sep 17 00:00:00 2001 From: lollerfirst <43107113+lollerfirst@users.noreply.github.com> Date: Thu, 11 Jul 2024 21:00:02 +0200 Subject: [PATCH 09/68] Update cashu/core/crypto/dlc.py Co-authored-by: conduition --- cashu/core/crypto/dlc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cashu/core/crypto/dlc.py b/cashu/core/crypto/dlc.py index 689361b0..c23c9595 100644 --- a/cashu/core/crypto/dlc.py +++ b/cashu/core/crypto/dlc.py @@ -6,7 +6,7 @@ def sorted_merkle_hash(left: bytes, right: bytes) -> bytes: '''Sorts `left` and `right` in non-ascending order and computes the hash of their concatenation ''' - if int.from_bytes(left, 'big') < int.from_bytes(right, 'big'): + if left < right: left, right = right, left return sha256(left+right).digest() From a14707bfd4b08627469d25c73cf03120ef5aed94 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Thu, 11 Jul 2024 21:11:02 +0200 Subject: [PATCH 10/68] fix description `merkle_root` --- cashu/core/crypto/dlc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cashu/core/crypto/dlc.py b/cashu/core/crypto/dlc.py index c23c9595..7e79a68c 100644 --- a/cashu/core/crypto/dlc.py +++ b/cashu/core/crypto/dlc.py @@ -15,7 +15,7 @@ def merkle_root( leaf_hashes: List[bytes], track_branch: Optional[int] = None ) -> Tuple[bytes, Optional[List[bytes]]]: - '''Computes the root of a list of merkle proofs + '''Computes the root of a list of leaf hashes if `track_branch` is set, extracts the hashes for the branch that leads to `leaf_hashes[track_branch]` ''' From 2dfdafe180f54af8e55155fd1e7ab8de4ca8f326 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Sun, 14 Jul 2024 17:10:42 +0200 Subject: [PATCH 11/68] secret generation --- cashu/core/crypto/dlc.py | 5 +- cashu/core/dlc.py | 15 ++++++ cashu/wallet/secrets.py | 107 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 cashu/core/dlc.py diff --git a/cashu/core/crypto/dlc.py b/cashu/core/crypto/dlc.py index 7e79a68c..da376456 100644 --- a/cashu/core/crypto/dlc.py +++ b/cashu/core/crypto/dlc.py @@ -56,4 +56,7 @@ def merkle_verify(root: bytes, leaf_hash: bytes, proof: List[bytes]) -> bool: h = leaf_hash for branch_hash in proof: h = sorted_merkle_hash(h, branch_hash) - return h == root \ No newline at end of file + return h == root + +def list_hash(leaves: List[str]) -> List[bytes]: + return [sha256(leaf.encode()).digest() for leaf in leaves] \ No newline at end of file diff --git a/cashu/core/dlc.py b/cashu/core/dlc.py new file mode 100644 index 00000000..855b3615 --- /dev/null +++ b/cashu/core/dlc.py @@ -0,0 +1,15 @@ +from .secret import Secret +from ..base import DLCWitness +from ..core.crypto.secp import PrivateKey + +class DLCSecret: + secret: Secret + witness: DLCWitness + blinding_factor: PrivateKey + derivation_path: str + + def __init__(self, **kwargs): + self.secret = kwargs['secret'] + self.witness = kwargs['witness'] + self.blinding_factor = kwargs['blinding_factor'] + self.derivation_path = kwargs['derivation_path'] diff --git a/cashu/wallet/secrets.py b/cashu/wallet/secrets.py index ef35f02f..b944b5e5 100644 --- a/cashu/wallet/secrets.py +++ b/cashu/wallet/secrets.py @@ -9,7 +9,10 @@ from ..core.crypto.secp import PrivateKey from ..core.db import Database -from ..core.secret import Secret +from ..core.secret import Secret, SecretKind, Tags +from ..core.dlc import DLCSecret +from ..core.crypto.dlc import merkle_root, list_hash +from ..core.base import DLCWitness from ..core.settings import settings from ..wallet.crud import ( bump_secret_derivation, @@ -233,3 +236,105 @@ async def generate_locked_secrets( derivation_paths = ["custom"] * len(secrets) return secrets, rs, derivation_paths + + async def generate_sct_secrets( + self, + n_locks: int, + dlc_root: str, + threshold: int, + skip_bump: bool = False, + conditions: Optional[Tuple[List[str], int]] = None, + ) -> Tuple[List[DLCSecret], int]: + """ + Creates a list of DLC locked secrets and witness data. + + Args: + n_locks (int): number of locks to be created + dlc_root (str): root of the contract the funds will be locked to + threshold (int): funding threshold. the mint will register this proof + only if the contract's funding amount is greater or equal to threshold + conditions (optional List[str]): specify custom spending conditions + for the secret which override the backup secret + + Returns: + List[DLCSecret]: Secrets and associated spending conditions + int: The number of secrets that were generated + """ + n_secrets = 0 + if (conditions is None or + len(conditions[0]) == 0): + secrets_and_witnesses = [] + ss, bf, dpath = await self.generate_n_secrets(3*n_locks, skip_bump=skip_bump) + n_secrets += 3*n_locks + for i in range(0, 3*n_locks, 3): + # Commit to the DLC + dlc_commit = Secret( + kind=SecretKind.DLC.value, + nonce=ss[i], + data=dlc_root, + tags=Tags([["threshold", str(threshold)]]), + ).serialize() + # Commit to the backup secret + backup_commit = ss[i+1] + + # Generate DLCWitness and Secret + leaf_hashes = list_hash([dlc_commit, backup_commit]) + root_bytes, merkle_proof_bytes = merkle_root(leaf_hashes, 1) + assert merkle_proof_bytes is not None + witness = DLCWitness( + leaf_secret=backup_commit, + merkle_proof=[h.hex() for h in merkle_proof_bytes], + ) + secret = Secret( + kind=SecretKind.SCT.value, + nonce=ss[i+2], + data=root_bytes.hex(), + tags=Tags() + ) + secrets_and_witnesses.append(DLCSecret( + secret=secret, + witness=witness, + blinding_factor=bf[i], + derivation_path=dpath[i], + )) + return (secrets_and_witnesses, n_secrets) + else: + secrets_and_witnesses = [] + ss, bf, dpath = await self.generate_n_secrets(2*n_locks, skip_bump=skip_bump) + n_secrets += 2*n_locks + for i in range(0, 2*n_locks, 2): + # Commit to the DLC + dlc_commit = Secret( + kind=SecretKind.DLC.value, + nonce=ss[i], + data=dlc_root, + tags=Tags([["threshold", str(threshold)]]), + ).serialize() + + # Generate DLCWitness and Secret + leaf_hashes = list_hash([dlc_commit] + conditions[0]) + index = conditions[1] + assert 0 <= index < len(leaf_hashes), f"Out of bounds {index = } for {len(leaf_hashes) = }" + # We generate the witness data for the specified index + root_bytes, merkle_proof_bytes = merkle_root( + leaf_hashes, + index, + ) + assert merkle_proof_bytes is not None + witness = DLCWitness( + leaf_secret=conditions[0][index], + merkle_proof=[h.hex() for h in merkle_proof_bytes], + ) + secret = Secret( + kind=SecretKind.SCT.value, + nonce=ss[i+1], + data=root_bytes.hex(), + tags=Tags() + ) + secrets_and_witnesses.append(DLCSecret( + secret=secret, + witness=witness, + blinding_factor=bf[i], + derivation_path=dpath[i], + )) + return (secrets_and_witnesses, n_secrets) From f45e5e56295e230a4412e2daf4192e7692944463 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Sun, 14 Jul 2024 17:14:32 +0200 Subject: [PATCH 12/68] fix broken import --- cashu/core/dlc.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cashu/core/dlc.py b/cashu/core/dlc.py index 855b3615..015ccdc9 100644 --- a/cashu/core/dlc.py +++ b/cashu/core/dlc.py @@ -1,6 +1,6 @@ from .secret import Secret -from ..base import DLCWitness -from ..core.crypto.secp import PrivateKey +from .base import DLCWitness +from .crypto.secp import PrivateKey class DLCSecret: secret: Secret From 258d4ae32ff1129b050856f96e65aa90dd2b2247 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Mon, 15 Jul 2024 17:13:50 +0200 Subject: [PATCH 13/68] db add dlc_root and spending_conditions to proofs tables + related edits --- cashu/core/base.py | 10 +++ cashu/core/dlc.py | 7 ++- cashu/wallet/crud.py | 13 +++- cashu/wallet/migrations.py | 9 ++- cashu/wallet/secrets.py | 126 ++++++++++++------------------------- cashu/wallet/wallet.py | 49 +++++++++++++-- 6 files changed, 117 insertions(+), 97 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index fe900612..7853b8c1 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -151,6 +151,8 @@ class Proof(BaseModel): melt_id: Union[ None, str ] = None # holds the id of the melt operation that destroyed this proof + all_spending_conditions: Optional[List[str]] = None # holds all eventual SCT spending conditions + dlc_root: Optional[str] = None # holds the root hash of a DLC contract def __init__(self, **data): super().__init__(**data) @@ -166,6 +168,14 @@ def from_dict(cls, proof_dict: dict): else: # overwrite the empty string with None proof_dict["dleq"] = None + + if (proof_dict.get("all_spending_conditions") + and isinstance(proof_dict["all_spending_conditions"], str)): + tmp = json.loads(proof_dict["all_spending_conditions"]) + proof_dict["all_spending_conditions"] = [json.dumps(t) for t in tmp] + #assert isinstance(proof_dict["all_spending_conditions"], List[str]) + else: + proof_dict["all_spending_conditions"] = None c = cls(**proof_dict) return c diff --git a/cashu/core/dlc.py b/cashu/core/dlc.py index 015ccdc9..9114b90e 100644 --- a/cashu/core/dlc.py +++ b/cashu/core/dlc.py @@ -1,15 +1,16 @@ from .secret import Secret -from .base import DLCWitness from .crypto.secp import PrivateKey +from typing import List + class DLCSecret: secret: Secret - witness: DLCWitness blinding_factor: PrivateKey derivation_path: str + all_spending_conditions: List[str] def __init__(self, **kwargs): self.secret = kwargs['secret'] - self.witness = kwargs['witness'] self.blinding_factor = kwargs['blinding_factor'] self.derivation_path = kwargs['derivation_path'] + self.all_spending_conditions = kwargs['all_spending_conditions'] diff --git a/cashu/wallet/crud.py b/cashu/wallet/crud.py index febd05d0..a4b42e2a 100644 --- a/cashu/wallet/crud.py +++ b/cashu/wallet/crud.py @@ -14,8 +14,8 @@ async def store_proof( await (conn or db).execute( """ INSERT INTO proofs - (id, amount, C, secret, time_created, derivation_path, dleq, mint_id, melt_id) - VALUES (:id, :amount, :C, :secret, :time_created, :derivation_path, :dleq, :mint_id, :melt_id) + (id, amount, C, secret, time_created, derivation_path, dleq, mint_id, melt_id, all_spending_conditions, dlc_root) + VALUES (:id, :amount, :C, :secret, :time_created, :derivation_path, :dleq, :mint_id, :melt_id, :all_spending_conditions, :dlc_root) """, { "id": proof.id, @@ -27,6 +27,11 @@ async def store_proof( "dleq": json.dumps(proof.dleq.dict()) if proof.dleq else "", "mint_id": proof.mint_id, "melt_id": proof.melt_id, + "all_spending_conditions": (json.dumps(proof.all_spending_conditions) + if proof.all_spending_conditions + else "" + ), + "dlc_root": proof.dlc_root if proof.dlc_root else "" }, ) @@ -37,6 +42,7 @@ async def get_proofs( id: Optional[str] = "", melt_id: str = "", mint_id: str = "", + dlc_root: str = "", table: str = "proofs", conn: Optional[Connection] = None, ): @@ -52,6 +58,9 @@ async def get_proofs( if mint_id: clauses.append("mint_id = :mint_id") values["mint_id"] = mint_id + if dlc_root: + clauses.append("dlc_root = :dlc_root") + values["dlc_root"] = dlc_root where = "" if clauses: where = f"WHERE {' AND '.join(clauses)}" diff --git a/cashu/wallet/migrations.py b/cashu/wallet/migrations.py index 41937318..2e19f8ff 100644 --- a/cashu/wallet/migrations.py +++ b/cashu/wallet/migrations.py @@ -243,7 +243,14 @@ async def m012_add_fee_to_keysets(db: Database): # add column for storing the fee of a keyset await conn.execute("ALTER TABLE keysets ADD COLUMN input_fee_ppk INTEGER") await conn.execute("UPDATE keysets SET input_fee_ppk = 0") - + +async def m013_add_dlc_columns(db: Database): + async with db.connect() as conn: + # add a column for storing the (eventual) spending conditions for a DLC locked secret. + await conn.execute("ALTER TABLE proofs ADD COLUMN all_spending_conditions TEXT") + await conn.execute("ALTER TABLE proofs ADD COLUMN dlc_root TEXT") + await conn.execute("ALTER TABLE proofs_used ADD COLUMN all_spending_conditions TEXT") + await conn.execute("ALTER TABLE proofs_used ADD COLUMN dlc_root TEXT") # # async def m020_add_state_to_mint_and_melt_quotes(db: Database): # # async with db.connect() as conn: diff --git a/cashu/wallet/secrets.py b/cashu/wallet/secrets.py index b944b5e5..1baf0329 100644 --- a/cashu/wallet/secrets.py +++ b/cashu/wallet/secrets.py @@ -12,7 +12,6 @@ from ..core.secret import Secret, SecretKind, Tags from ..core.dlc import DLCSecret from ..core.crypto.dlc import merkle_root, list_hash -from ..core.base import DLCWitness from ..core.settings import settings from ..wallet.crud import ( bump_secret_derivation, @@ -239,102 +238,57 @@ async def generate_locked_secrets( async def generate_sct_secrets( self, - n_locks: int, + n: int, dlc_root: str, threshold: int, skip_bump: bool = False, - conditions: Optional[Tuple[List[str], int]] = None, ) -> Tuple[List[DLCSecret], int]: """ - Creates a list of DLC locked secrets and witness data. + Creates a list of DLC locked secrets and associated metadata. Args: - n_locks (int): number of locks to be created + n (int): number of locked secrets to be created dlc_root (str): root of the contract the funds will be locked to threshold (int): funding threshold. the mint will register this proof only if the contract's funding amount is greater or equal to threshold - conditions (optional List[str]): specify custom spending conditions - for the secret which override the backup secret + skip_bump (int): skip bumping of the secret derivation Returns: - List[DLCSecret]: Secrets and associated spending conditions - int: The number of secrets that were generated + List[DLCSecret]: Secrets and associated metadata + int: The number of secrets that were generated (used to bump derivation) """ n_secrets = 0 - if (conditions is None or - len(conditions[0]) == 0): - secrets_and_witnesses = [] - ss, bf, dpath = await self.generate_n_secrets(3*n_locks, skip_bump=skip_bump) - n_secrets += 3*n_locks - for i in range(0, 3*n_locks, 3): - # Commit to the DLC - dlc_commit = Secret( - kind=SecretKind.DLC.value, - nonce=ss[i], - data=dlc_root, - tags=Tags([["threshold", str(threshold)]]), - ).serialize() - # Commit to the backup secret - backup_commit = ss[i+1] - - # Generate DLCWitness and Secret - leaf_hashes = list_hash([dlc_commit, backup_commit]) - root_bytes, merkle_proof_bytes = merkle_root(leaf_hashes, 1) - assert merkle_proof_bytes is not None - witness = DLCWitness( - leaf_secret=backup_commit, - merkle_proof=[h.hex() for h in merkle_proof_bytes], - ) - secret = Secret( - kind=SecretKind.SCT.value, - nonce=ss[i+2], - data=root_bytes.hex(), - tags=Tags() - ) - secrets_and_witnesses.append(DLCSecret( - secret=secret, - witness=witness, - blinding_factor=bf[i], - derivation_path=dpath[i], - )) - return (secrets_and_witnesses, n_secrets) - else: - secrets_and_witnesses = [] - ss, bf, dpath = await self.generate_n_secrets(2*n_locks, skip_bump=skip_bump) - n_secrets += 2*n_locks - for i in range(0, 2*n_locks, 2): - # Commit to the DLC - dlc_commit = Secret( - kind=SecretKind.DLC.value, - nonce=ss[i], - data=dlc_root, - tags=Tags([["threshold", str(threshold)]]), - ).serialize() - - # Generate DLCWitness and Secret - leaf_hashes = list_hash([dlc_commit] + conditions[0]) - index = conditions[1] - assert 0 <= index < len(leaf_hashes), f"Out of bounds {index = } for {len(leaf_hashes) = }" - # We generate the witness data for the specified index - root_bytes, merkle_proof_bytes = merkle_root( - leaf_hashes, - index, - ) - assert merkle_proof_bytes is not None - witness = DLCWitness( - leaf_secret=conditions[0][index], - merkle_proof=[h.hex() for h in merkle_proof_bytes], - ) - secret = Secret( - kind=SecretKind.SCT.value, - nonce=ss[i+1], - data=root_bytes.hex(), - tags=Tags() - ) - secrets_and_witnesses.append(DLCSecret( - secret=secret, - witness=witness, - blinding_factor=bf[i], - derivation_path=dpath[i], - )) - return (secrets_and_witnesses, n_secrets) + secrets_and_metadata = [] + ss, bf, dpath = await self.generate_n_secrets(3*n, skip_bump=skip_bump) + n_secrets += 3*n + logger.trace(f"Generating {n} DLC locked secrets, NO custom SCs") + logger.trace(f"Locked to {dlc_root}") + for i in range(0, 3*n, 3): + # Commit to the DLC + dlc_commit = Secret( + kind=SecretKind.DLC.value, + nonce=ss[i], + data=dlc_root, + tags=Tags([["threshold", str(threshold)]]), + ).serialize() + + # Commit to the backup secret + backup_commit = ss[i+1] + + # Generate Secret + all_spending_conditions = [dlc_commit, backup_commit] + leaf_hashes = list_hash(all_spending_conditions) + root_bytes, _ = merkle_root(leaf_hashes) + secret = Secret( + kind=SecretKind.SCT.value, + nonce=ss[i+2], + data=root_bytes.hex(), + tags=Tags() + ) + secrets_and_metadata.append(DLCSecret( + secret=secret.serialize(), + blinding_factor=bf[i], + derivation_path=dpath[i], + all_spending_conditions=all_spending_conditions, + )) + return (secrets_and_metadata, n_secrets) diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index e97dc2b8..a25a57b6 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -612,6 +612,7 @@ async def split( proofs: List[Proof], amount: int, secret_lock: Optional[Secret] = None, + dlc_data: Optional[Tuple[str, int]] = None, ) -> Tuple[List[Proof], List[Proof]]: """Calls the swap API to split the proofs into two sets of proofs, one for keeping and one for sending. @@ -622,6 +623,7 @@ async def split( Args: proofs (List[Proof]): Proofs to be split. amount (int): Amount to be sent. + dlc_data (Tuple[str, int]): DLC root hash + funding threshold for the proofs to be locked to secret_lock (Optional[Secret], optional): Secret to lock the tokens to be sent. Defaults to None. Returns: @@ -651,12 +653,32 @@ async def split( return [], [] # generate secrets for new outputs - if secret_lock is None: - secrets, rs, derivation_paths = await self.generate_n_secrets(len(amounts)) - else: + # CODE COMPLEXITY: for now we limit ourselves to DLC proofs with + # vanilla backup secrets. In the future, backup secrets could also be P2PK or HTLC + dlc_root = None + spending_conditions = None + if dlc_data is not None: + dlc_root, threshold = dlc_data[0], dlc_data[1] + # Verify dlc_root is a hex string + try: + _ = bytes.fromhex(dlc_root) + except ValueError as e: + assert False, "CAREFUL: provided dlc root is non-hex!" + dlcsecrets = await self.generate_sct_secrets( + len(amounts), + dlc_root, + threshold, + ) + secrets = [dd.secret for dd in dlcsecrets[0]] + rs = [dd.blinding_factor for dd in dlcsecrets[0]] + derivation_paths = [dd.derivation_path for dd in dlcsecrets[0]] + spending_conditions = [dd.all_spending_conditions for dd in dlcsecrets[0]] + elif secret_lock is not None: secrets, rs, derivation_paths = await self.generate_locked_secrets( send_outputs, keep_outputs, secret_lock ) + else: + secrets, rs, derivation_paths = await self.generate_n_secrets(len(amounts)) assert len(secrets) == len( amounts @@ -675,7 +697,12 @@ async def split( # Construct proofs from returned promises (i.e., unblind the signatures) new_proofs = await self._construct_proofs( - promises, secrets, rs, derivation_paths + promises, + secrets, + rs, + derivation_paths, + spending_conditions, + dlc_root, ) await self.invalidate(proofs) @@ -833,6 +860,8 @@ async def _construct_proofs( secrets: List[str], rs: List[PrivateKey], derivation_paths: List[str], + spending_conditions: Optional[List[List[str]]] = None, + dlc_root: Optional[str] = None, ) -> List[Proof]: """Constructs proofs from promises, secrets, rs and derivation paths. @@ -850,7 +879,15 @@ async def _construct_proofs( """ logger.trace("Constructing proofs.") proofs: List[Proof] = [] - for promise, secret, r, path in zip(promises, secrets, rs, derivation_paths): + flag = (spending_conditions is not None + and len(spending_conditions) == len(secrets) + ) + zipped = zip(promises, secrets, rs, derivation_paths) if not flag else ( + zip(promises, secrets, rs, derivation_paths, spending_conditions or []) + ) + for z in zipped: + promise, secret, r, path = z[:4] + spc = z[4] if flag else None if promise.id not in self.keysets: logger.debug(f"Keyset {promise.id} not found in db. Loading from mint.") # we don't have the keyset for this promise, so we load all keysets from the mint @@ -876,6 +913,8 @@ async def _construct_proofs( C=C.serialize().hex(), secret=secret, derivation_path=path, + all_spending_conditions=spc, + dlc_root=dlc_root, ) # if the mint returned a dleq proof, we add it to the proof From 77b26314f4d2894724a7d03670a5c06a332cde76 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Mon, 15 Jul 2024 17:18:56 +0200 Subject: [PATCH 14/68] fix error --- cashu/core/dlc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cashu/core/dlc.py b/cashu/core/dlc.py index 9114b90e..3187df96 100644 --- a/cashu/core/dlc.py +++ b/cashu/core/dlc.py @@ -4,7 +4,7 @@ from typing import List class DLCSecret: - secret: Secret + secret: str blinding_factor: PrivateKey derivation_path: str all_spending_conditions: List[str] From aa1af77ccb4c5472052a5d5d5dae138fadf1ccc5 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Mon, 15 Jul 2024 20:26:48 +0200 Subject: [PATCH 15/68] move dlc from core to wallet --- cashu/{core => wallet}/dlc.py | 6 +++--- cashu/wallet/secrets.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) rename cashu/{core => wallet}/dlc.py (83%) diff --git a/cashu/core/dlc.py b/cashu/wallet/dlc.py similarity index 83% rename from cashu/core/dlc.py rename to cashu/wallet/dlc.py index 3187df96..a145dd80 100644 --- a/cashu/core/dlc.py +++ b/cashu/wallet/dlc.py @@ -1,5 +1,5 @@ -from .secret import Secret -from .crypto.secp import PrivateKey +from ..core.secret import Secret +from ..core.crypto.secp import PrivateKey from typing import List @@ -13,4 +13,4 @@ def __init__(self, **kwargs): self.secret = kwargs['secret'] self.blinding_factor = kwargs['blinding_factor'] self.derivation_path = kwargs['derivation_path'] - self.all_spending_conditions = kwargs['all_spending_conditions'] + self.all_spending_conditions = kwargs['all_spending_conditions'] \ No newline at end of file diff --git a/cashu/wallet/secrets.py b/cashu/wallet/secrets.py index 1baf0329..6c2c9ef0 100644 --- a/cashu/wallet/secrets.py +++ b/cashu/wallet/secrets.py @@ -10,7 +10,7 @@ from ..core.crypto.secp import PrivateKey from ..core.db import Database from ..core.secret import Secret, SecretKind, Tags -from ..core.dlc import DLCSecret +from .dlc import DLCSecret from ..core.crypto.dlc import merkle_root, list_hash from ..core.settings import settings from ..wallet.crud import ( From 8b134ddb96a43a6c9ce6f82d37443cdda3899b7e Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Mon, 15 Jul 2024 21:50:52 +0200 Subject: [PATCH 16/68] move `add_witnessess_to_proofs` up to `wallet.py` for common use. --- cashu/wallet/dlc.py | 35 +++++++++++++++++++++++++++++++-- cashu/wallet/p2pk.py | 34 -------------------------------- cashu/wallet/wallet.py | 44 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 76 insertions(+), 37 deletions(-) diff --git a/cashu/wallet/dlc.py b/cashu/wallet/dlc.py index a145dd80..57212753 100644 --- a/cashu/wallet/dlc.py +++ b/cashu/wallet/dlc.py @@ -1,6 +1,9 @@ from ..core.secret import Secret from ..core.crypto.secp import PrivateKey - +from ..core.crypto.dlc import list_hash, merkle_root +from ..core.base import Proof, DLCWitness +from .protocols import SupportsDb, SupportsPrivateKey +from loguru import logger from typing import List class DLCSecret: @@ -13,4 +16,32 @@ def __init__(self, **kwargs): self.secret = kwargs['secret'] self.blinding_factor = kwargs['blinding_factor'] self.derivation_path = kwargs['derivation_path'] - self.all_spending_conditions = kwargs['all_spending_conditions'] \ No newline at end of file + self.all_spending_conditions = kwargs['all_spending_conditions'] + +class WalletSCT(SupportsPrivateKey, SupportsDb): + # ---------- SCT ---------- + + async def add_sct_witnesses_to_proofs( + self, + proofs: List[Proof] + ) -> List[Proof]: + """Add SCT witness data to proofs""" + logger.debug(f"Unlocking {len(proofs)} proofs locked to DLC root {proofs[0].dlc_root}") + for p in proofs: + all_spending_conditions = p.all_spending_conditions + assert all_spending_conditions is not None, "add_sct_witnesses_to_proof: What the duck is going on here" + leaf_hashes = list_hash(all_spending_conditions) + # We are assuming the backup secret is the last (and second) entry + merkle_root_bytes, merkle_proof_bytes = merkle_root( + leaf_hashes, + len(leaf_hashes)-1, + ) + # If this check fails we are in deep trouble + assert merkle_proof_bytes is not None, "add_sct_witnesses_to_proof: What the duck is going on here" + assert merkle_root_bytes.hex() == Secret.deserialize(p.secret).data, "add_sct_witnesses_to_proof: What the duck is going on here" + backup_secret = all_spending_conditions[-1] + p.witness = DLCWitness( + leaf_secret=backup_secret, + merkle_proof=[m.hex() for m in merkle_proof_bytes] + ).json() + return proofs \ No newline at end of file diff --git a/cashu/wallet/p2pk.py b/cashu/wallet/p2pk.py index e5d5a7fe..d1be327c 100644 --- a/cashu/wallet/p2pk.py +++ b/cashu/wallet/p2pk.py @@ -157,37 +157,3 @@ async def add_p2pk_witnesses_to_proofs(self, proofs: List[Proof]) -> List[Proof] p.witness = P2PKWitness(signatures=[s]).json() return proofs - async def add_witnesses_to_proofs(self, proofs: List[Proof]) -> List[Proof]: - """Adds witnesses to proofs for 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. - 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.") - # P2PK signatures - if all( - [Secret.deserialize(p.secret).kind == SecretKind.P2PK.value 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/wallet.py b/cashu/wallet/wallet.py index a25a57b6..3054cfc1 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -21,6 +21,7 @@ Unit, WalletKeyset, ) +from ..core.secret import SecretKind from ..core.crypto import b_dhke from ..core.crypto.secp import PrivateKey, PublicKey from ..core.db import Database @@ -52,6 +53,7 @@ from .htlc import WalletHTLC from .mint_info import MintInfo from .p2pk import WalletP2PK +from .dlc import WalletSCT from .proofs import WalletProofs from .secrets import WalletSecrets from .subscriptions import SubscriptionManager @@ -60,7 +62,7 @@ class Wallet( - LedgerAPI, WalletP2PK, WalletHTLC, WalletSecrets, WalletTransactions, WalletProofs + LedgerAPI, WalletP2PK, WalletSCT, WalletHTLC, WalletSecrets, WalletTransactions, WalletProofs ): """ Nutshell wallet class. @@ -607,6 +609,46 @@ def swap_send_and_keep_output_amounts( return keep_outputs, send_outputs + async def add_witnesses_to_proofs(self, proofs: List[Proof]) -> List[Proof]: + """Adds witnesses to proofs. + + 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. + 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.") + # P2PK signatures + if all( + [Secret.deserialize(p.secret).kind == SecretKind.P2PK.value for p in proofs] + ): + logger.debug("P2PK redemption detected.") + proofs = await self.add_p2pk_witnesses_to_proofs(proofs) + elif all( + [Secret.deserialize(p.secret).kind == SecretKind.SCT.value for p in proofs] + ): + logger.debug("DLC backup redemption detected") + proofs = await self.add_sct_witnesses_to_proofs(proofs) + + return proofs + async def split( self, proofs: List[Proof], From e709af8439472c940ad084f7a875a0dc745a1d38 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Tue, 16 Jul 2024 10:41:51 +0200 Subject: [PATCH 17/68] tests: swapping for locked, unlocked. --- cashu/wallet/dlc.py | 6 +- cashu/wallet/secrets.py | 14 ++++- cashu/wallet/wallet.py | 7 ++- tests/test_dlc.py | 134 ++++++++++++++++++++++++++++++++++++++++ tests/test_mint_dlc.py | 39 ------------ 5 files changed, 153 insertions(+), 47 deletions(-) create mode 100644 tests/test_dlc.py delete mode 100644 tests/test_mint_dlc.py diff --git a/cashu/wallet/dlc.py b/cashu/wallet/dlc.py index 57212753..ae9d259f 100644 --- a/cashu/wallet/dlc.py +++ b/cashu/wallet/dlc.py @@ -4,13 +4,13 @@ from ..core.base import Proof, DLCWitness from .protocols import SupportsDb, SupportsPrivateKey from loguru import logger -from typing import List +from typing import List, Optional class DLCSecret: secret: str blinding_factor: PrivateKey derivation_path: str - all_spending_conditions: List[str] + all_spending_conditions: Optional[List[str]] def __init__(self, **kwargs): self.secret = kwargs['secret'] @@ -38,7 +38,7 @@ async def add_sct_witnesses_to_proofs( ) # If this check fails we are in deep trouble assert merkle_proof_bytes is not None, "add_sct_witnesses_to_proof: What the duck is going on here" - assert merkle_root_bytes.hex() == Secret.deserialize(p.secret).data, "add_sct_witnesses_to_proof: What the duck is going on here" + #assert merkle_root_bytes.hex() == Secret.deserialize(p.secret).data, "add_sct_witnesses_to_proof: What the duck is going on here" backup_secret = all_spending_conditions[-1] p.witness = DLCWitness( leaf_secret=backup_secret, diff --git a/cashu/wallet/secrets.py b/cashu/wallet/secrets.py index 6c2c9ef0..f700a15c 100644 --- a/cashu/wallet/secrets.py +++ b/cashu/wallet/secrets.py @@ -239,6 +239,7 @@ async def generate_locked_secrets( async def generate_sct_secrets( self, n: int, + n_keep: int, dlc_root: str, threshold: int, skip_bump: bool = False, @@ -248,6 +249,7 @@ async def generate_sct_secrets( Args: n (int): number of locked secrets to be created + n_keep (int): number of vanilla secrets to be created dlc_root (str): root of the contract the funds will be locked to threshold (int): funding threshold. the mint will register this proof only if the contract's funding amount is greater or equal to threshold @@ -257,11 +259,19 @@ async def generate_sct_secrets( List[DLCSecret]: Secrets and associated metadata int: The number of secrets that were generated (used to bump derivation) """ - n_secrets = 0 secrets_and_metadata = [] + keep_secrets, keep_rs, keep_dpath = await self.generate_n_secrets(n_keep, skip_bump=skip_bump) + for ksc, krs, kdp in zip(keep_secrets, keep_rs, keep_dpath): + secrets_and_metadata.append(DLCSecret( + secret=ksc, + blinding_factor=krs, + derivation_path=kdp, + all_spending_conditions=None, + )) + n_secrets = n_keep ss, bf, dpath = await self.generate_n_secrets(3*n, skip_bump=skip_bump) n_secrets += 3*n - logger.trace(f"Generating {n} DLC locked secrets, NO custom SCs") + logger.trace(f"Generating {n} DLC locked secrets and {n_keep} vanilla secrets") logger.trace(f"Locked to {dlc_root}") for i in range(0, 3*n, 3): # Commit to the DLC diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 3054cfc1..73cc06cb 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -707,7 +707,8 @@ async def split( except ValueError as e: assert False, "CAREFUL: provided dlc root is non-hex!" dlcsecrets = await self.generate_sct_secrets( - len(amounts), + len(send_outputs), + len(keep_outputs), dlc_root, threshold, ) @@ -902,7 +903,7 @@ async def _construct_proofs( secrets: List[str], rs: List[PrivateKey], derivation_paths: List[str], - spending_conditions: Optional[List[List[str]]] = None, + spending_conditions: Optional[List[Optional[List[str]]]] = None, dlc_root: Optional[str] = None, ) -> List[Proof]: """Constructs proofs from promises, secrets, rs and derivation paths. @@ -956,7 +957,7 @@ async def _construct_proofs( secret=secret, derivation_path=path, all_spending_conditions=spc, - dlc_root=dlc_root, + dlc_root=dlc_root if spc is not None else None, ) # if the mint returned a dleq proof, we add it to the proof diff --git a/tests/test_dlc.py b/tests/test_dlc.py new file mode 100644 index 00000000..ef66433c --- /dev/null +++ b/tests/test_dlc.py @@ -0,0 +1,134 @@ +from hashlib import sha256 +from random import randint, shuffle +from cashu.lightning.base import InvoiceResponse, PaymentStatus +from cashu.wallet.wallet import Wallet +from cashu.core.secret import Secret, SecretKind +from cashu.core.errors import CashuError +from tests.conftest import SERVER_ENDPOINT +from hashlib import sha256 +from tests.helpers import ( + pay_if_regtest +) + +import pytest +import pytest_asyncio +from loguru import logger + +from typing import Union +from cashu.core.crypto.dlc import merkle_root, merkle_verify, sorted_merkle_hash + +@pytest_asyncio.fixture(scope="function") +async def wallet(): + wallet = await Wallet.with_db( + url=SERVER_ENDPOINT, + db="test_data/wallet", + name="wallet", + ) + await wallet.load_mint() + yield wallet + +async def assert_err(f, msg: Union[str, CashuError]): + """Compute f() and expect an error message 'msg'.""" + try: + await f + except Exception as exc: + error_message: str = str(exc.args[0]) + if isinstance(msg, CashuError): + if msg.detail not in error_message: + raise Exception( + f"CashuError. Expected error: {msg.detail}, got: {error_message}" + ) + return + if msg not in error_message: + raise Exception(f"Expected error: {msg}, got: {error_message}") + return + raise Exception(f"Expected error: {msg}, got no error") + + +@pytest.mark.asyncio +async def test_merkle_hash(): + data = [b'\x01', b'\x02'] + target = '25dfd29c09617dcc9852281c030e5b3037a338a4712a42a21c907f259c6412a0' + h = sorted_merkle_hash(data[1], data[0]) + assert h.hex() == target, f'sorted_merkle_hash test fail: {h.hex() = }' + h = sorted_merkle_hash(data[0], data[1]) + assert h.hex() == target, f'sorted_merkle_hash reverse test fail: {h.hex() = }' + +@pytest.mark.asyncio +async def test_merkle_root(): + target = '0ee849f3b077380cd2cf5c76c6d63bcaa08bea89c1ef9914e5bc86c174417cb3' + leafs = [sha256(i.to_bytes(32, 'big')).digest() for i in range(16)] + root, _ = merkle_root(leafs) + assert root.hex() == target, f"merkle_root test fail: {root.hex() = }" + +@pytest.mark.asyncio +async def test_merkle_verify(): + leafs = [sha256(i.to_bytes(32, 'big')).digest() for i in range(16)] + root, branch_hashes = merkle_root(leafs, 0) + assert merkle_verify(root, leafs[0], branch_hashes), "merkle_verify test fail" + + leafs = [sha256(i.to_bytes(32, 'big')).digest() for i in range(53)] + root, branch_hashes = merkle_root(leafs, 0) + assert merkle_verify(root, leafs[0], branch_hashes), "merkle_verify test fail" + + leafs = [sha256(i.to_bytes(32, 'big')).digest() for i in range(18)] + shuffle(leafs) + index = randint(0, len(leafs)-1) + root, branch_hashes = merkle_root(leafs, index) + assert merkle_verify(root, leafs[index], branch_hashes), "merkle_verify test fail" + +@pytest.mark.asyncio +async def test_swap_for_dlc_locked(wallet: Wallet): + invoice = await wallet.request_mint(64) + await pay_if_regtest(invoice) + minted = await wallet.mint(64, id=invoice.id) + root_hash = sha256("TESTING".encode()).hexdigest() + threshold = 1000 + _, dlc_locked = await wallet.split(minted, 64, dlc_data=(root_hash, threshold)) + print(f"{dlc_locked = }") + assert wallet.balance == 64 + assert wallet.available_balance == 64 + assert all([Secret.deserialize(p.secret).kind == SecretKind.SCT.value for p in dlc_locked]) + +@pytest.mark.asyncio +async def test_unlock_dlc_locked(wallet: Wallet): + invoice = await wallet.request_mint(64) + await pay_if_regtest(invoice) + minted = await wallet.mint(64, id=invoice.id) + root_hash = sha256("TESTING".encode()).hexdigest() + threshold = 1000 + _, dlc_locked = await wallet.split(minted, 64, dlc_data=(root_hash, threshold)) + _, unlocked = await wallet.split(dlc_locked, 64) + print(f"{unlocked = }") + assert wallet.balance == 64 + assert wallet.available_balance == 64 + assert all([bytes.fromhex(p.secret) for p in unlocked]) + +@pytest.mark.asyncio +async def test_partial_swap_for_dlc_locked(wallet: Wallet): + invoice = await wallet.request_mint(64) + await pay_if_regtest(invoice) + minted = await wallet.mint(64, id=invoice.id) + root_hash = sha256("TESTING".encode()).hexdigest() + threshold = 1000 + kept, dlc_locked = await wallet.split(minted, 15, dlc_data=(root_hash, threshold)) + assert wallet.balance == 64 + assert wallet.available_balance == 64 + assert all([bytes.fromhex(p.secret) for p in kept]) + assert all([Secret.deserialize(p.secret).kind == SecretKind.SCT.value for p in dlc_locked]) + +@pytest.mark.asyncio +async def test_cheat1_spend_locked_proofs(wallet: Wallet): + invoice = await wallet.request_mint(64) + await pay_if_regtest(invoice) + minted = await wallet.mint(64, id=invoice.id) + root_hash = sha256("TESTING".encode()).hexdigest() + threshold = 1000 + _, dlc_locked = await wallet.split(minted, 64, dlc_data=(root_hash, threshold)) + + # We pretend we don't know the backup secret, and try to spend the proofs + # with the DLC leaf secret instead + for p in dlc_locked: + p.all_spending_conditions = [p.all_spending_conditions[0]] + strerror = "Mint Error: validation of input spending conditions failed. (Code: 11000)" + await assert_err(wallet.split(dlc_locked, 64), strerror) \ No newline at end of file diff --git a/tests/test_mint_dlc.py b/tests/test_mint_dlc.py deleted file mode 100644 index ba59f915..00000000 --- a/tests/test_mint_dlc.py +++ /dev/null @@ -1,39 +0,0 @@ -from hashlib import sha256 -from random import randint, shuffle - -import pytest - -from cashu.core.crypto.dlc import merkle_root, merkle_verify, sorted_merkle_hash - - -@pytest.mark.asyncio -async def test_merkle_hash(): - data = [b'\x01', b'\x02'] - target = '25dfd29c09617dcc9852281c030e5b3037a338a4712a42a21c907f259c6412a0' - h = sorted_merkle_hash(data[1], data[0]) - assert h.hex() == target, f'sorted_merkle_hash test fail: {h.hex() = }' - h = sorted_merkle_hash(data[0], data[1]) - assert h.hex() == target, f'sorted_merkle_hash reverse test fail: {h.hex() = }' - -@pytest.mark.asyncio -async def test_merkle_root(): - target = '0ee849f3b077380cd2cf5c76c6d63bcaa08bea89c1ef9914e5bc86c174417cb3' - leafs = [sha256(i.to_bytes(32, 'big')).digest() for i in range(16)] - root, _ = merkle_root(leafs) - assert root.hex() == target, f"merkle_root test fail: {root.hex() = }" - -@pytest.mark.asyncio -async def test_merkle_verify(): - leafs = [sha256(i.to_bytes(32, 'big')).digest() for i in range(16)] - root, branch_hashes = merkle_root(leafs, 0) - assert merkle_verify(root, leafs[0], branch_hashes), "merkle_verify test fail" - - leafs = [sha256(i.to_bytes(32, 'big')).digest() for i in range(53)] - root, branch_hashes = merkle_root(leafs, 0) - assert merkle_verify(root, leafs[0], branch_hashes), "merkle_verify test fail" - - leafs = [sha256(i.to_bytes(32, 'big')).digest() for i in range(18)] - shuffle(leafs) - index = randint(0, len(leafs)-1) - root, branch_hashes = merkle_root(leafs, index) - assert merkle_verify(root, leafs[index], branch_hashes), "merkle_verify test fail" From 08529b577d07e72514d60836da070129356b0112 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Tue, 16 Jul 2024 10:54:21 +0200 Subject: [PATCH 18/68] fix naive mistake --- tests/test_dlc.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_dlc.py b/tests/test_dlc.py index ef66433c..f88bcde1 100644 --- a/tests/test_dlc.py +++ b/tests/test_dlc.py @@ -80,7 +80,7 @@ async def test_merkle_verify(): @pytest.mark.asyncio async def test_swap_for_dlc_locked(wallet: Wallet): invoice = await wallet.request_mint(64) - await pay_if_regtest(invoice) + await pay_if_regtest(invoice.bolt11) minted = await wallet.mint(64, id=invoice.id) root_hash = sha256("TESTING".encode()).hexdigest() threshold = 1000 @@ -93,7 +93,7 @@ async def test_swap_for_dlc_locked(wallet: Wallet): @pytest.mark.asyncio async def test_unlock_dlc_locked(wallet: Wallet): invoice = await wallet.request_mint(64) - await pay_if_regtest(invoice) + await pay_if_regtest(invoice.bolt11) minted = await wallet.mint(64, id=invoice.id) root_hash = sha256("TESTING".encode()).hexdigest() threshold = 1000 @@ -107,7 +107,7 @@ async def test_unlock_dlc_locked(wallet: Wallet): @pytest.mark.asyncio async def test_partial_swap_for_dlc_locked(wallet: Wallet): invoice = await wallet.request_mint(64) - await pay_if_regtest(invoice) + await pay_if_regtest(invoice.bolt11) minted = await wallet.mint(64, id=invoice.id) root_hash = sha256("TESTING".encode()).hexdigest() threshold = 1000 @@ -120,7 +120,7 @@ async def test_partial_swap_for_dlc_locked(wallet: Wallet): @pytest.mark.asyncio async def test_cheat1_spend_locked_proofs(wallet: Wallet): invoice = await wallet.request_mint(64) - await pay_if_regtest(invoice) + await pay_if_regtest(invoice.bolt11) minted = await wallet.mint(64, id=invoice.id) root_hash = sha256("TESTING".encode()).hexdigest() threshold = 1000 From 32cc28345eb7a5b761f0588c8f7919ead52da8c3 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Tue, 16 Jul 2024 11:32:57 +0200 Subject: [PATCH 19/68] Better tests for dlc locked proofs spending validation --- cashu/wallet/dlc.py | 2 +- tests/test_dlc.py | 105 +++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 100 insertions(+), 7 deletions(-) diff --git a/cashu/wallet/dlc.py b/cashu/wallet/dlc.py index ae9d259f..b9da2aba 100644 --- a/cashu/wallet/dlc.py +++ b/cashu/wallet/dlc.py @@ -38,7 +38,7 @@ async def add_sct_witnesses_to_proofs( ) # If this check fails we are in deep trouble assert merkle_proof_bytes is not None, "add_sct_witnesses_to_proof: What the duck is going on here" - #assert merkle_root_bytes.hex() == Secret.deserialize(p.secret).data, "add_sct_witnesses_to_proof: What the duck is going on here" + assert merkle_root_bytes.hex() == Secret.deserialize(p.secret).data, "add_sct_witnesses_to_proof: What the duck is going on here" backup_secret = all_spending_conditions[-1] p.witness = DLCWitness( leaf_secret=backup_secret, diff --git a/tests/test_dlc.py b/tests/test_dlc.py index f88bcde1..09deca8a 100644 --- a/tests/test_dlc.py +++ b/tests/test_dlc.py @@ -4,6 +4,7 @@ from cashu.wallet.wallet import Wallet from cashu.core.secret import Secret, SecretKind from cashu.core.errors import CashuError +from cashu.core.base import DLCWitness, Proof from tests.conftest import SERVER_ENDPOINT from hashlib import sha256 from tests.helpers import ( @@ -14,8 +15,8 @@ import pytest_asyncio from loguru import logger -from typing import Union -from cashu.core.crypto.dlc import merkle_root, merkle_verify, sorted_merkle_hash +from typing import Union, List +from cashu.core.crypto.dlc import merkle_root, merkle_verify, sorted_merkle_hash, list_hash @pytest_asyncio.fixture(scope="function") async def wallet(): @@ -118,7 +119,7 @@ async def test_partial_swap_for_dlc_locked(wallet: Wallet): assert all([Secret.deserialize(p.secret).kind == SecretKind.SCT.value for p in dlc_locked]) @pytest.mark.asyncio -async def test_cheat1_spend_locked_proofs(wallet: Wallet): +async def test_wrong_merkle_proof(wallet: Wallet): invoice = await wallet.request_mint(64) await pay_if_regtest(invoice.bolt11) minted = await wallet.mint(64, id=invoice.id) @@ -126,9 +127,101 @@ async def test_cheat1_spend_locked_proofs(wallet: Wallet): threshold = 1000 _, dlc_locked = await wallet.split(minted, 64, dlc_data=(root_hash, threshold)) - # We pretend we don't know the backup secret, and try to spend the proofs - # with the DLC leaf secret instead + async def add_sct_witnesses_to_proofs( + self, + proofs: List[Proof] + ) -> List[Proof]: + """Add SCT witness data to proofs""" + logger.debug(f"Unlocking {len(proofs)} proofs locked to DLC root {proofs[0].dlc_root}") + for p in proofs: + all_spending_conditions = p.all_spending_conditions + assert all_spending_conditions is not None, "add_sct_witnesses_to_proof: What the duck is going on here" + leaf_hashes = list_hash(all_spending_conditions) + # We are assuming the backup secret is the last (and second) entry + merkle_root_bytes, merkle_proof_bytes = merkle_root( + leaf_hashes, + len(leaf_hashes)-1, + ) + # If this check fails we are in deep trouble + assert merkle_proof_bytes is not None, "add_sct_witnesses_to_proof: What the duck is going on here" + #assert merkle_root_bytes.hex() == Secret.deserialize(p.secret).data, "add_sct_witnesses_to_proof: What the duck is going on here" + backup_secret = all_spending_conditions[-1] + p.witness = DLCWitness( + leaf_secret=backup_secret, + merkle_proof=[m.hex() for m in merkle_proof_bytes] + ).json() + return proofs + # Monkey patching + saved = Wallet.add_sct_witnesses_to_proofs + Wallet.add_sct_witnesses_to_proofs = add_sct_witnesses_to_proofs + for p in dlc_locked: p.all_spending_conditions = [p.all_spending_conditions[0]] strerror = "Mint Error: validation of input spending conditions failed. (Code: 11000)" - await assert_err(wallet.split(dlc_locked, 64), strerror) \ No newline at end of file + await assert_err(wallet.split(dlc_locked, 64), strerror) + Wallet.add_sct_witnesses_to_proofs = saved + +@pytest.mark.asyncio +async def test_no_witness_data(wallet: Wallet): + invoice = await wallet.request_mint(64) + await pay_if_regtest(invoice.bolt11) + minted = await wallet.mint(64, id=invoice.id) + root_hash = sha256("TESTING".encode()).hexdigest() + threshold = 1000 + _, dlc_locked = await wallet.split(minted, 64, dlc_data=(root_hash, threshold)) + + async def add_sct_witnesses_to_proofs( + self, + proofs: List[Proof] + ) -> List[Proof]: + return proofs + # Monkey patching + saved = Wallet.add_sct_witnesses_to_proofs + Wallet.add_sct_witnesses_to_proofs = add_sct_witnesses_to_proofs + + strerror = "Mint Error: validation of input spending conditions failed. (Code: 11000)" + await assert_err(wallet.split(dlc_locked, 64), strerror) + Wallet.add_sct_witnesses_to_proofs = saved + +@pytest.mark.asyncio +async def test_cheating1(wallet: Wallet): + # We pretend we don't know the backup secret + # and try to spend DLC locked proofs with the DLC secret + # and its proof of inclusion in the merkle tree + invoice = await wallet.request_mint(64) + await pay_if_regtest(invoice.bolt11) + minted = await wallet.mint(64, id=invoice.id) + root_hash = sha256("TESTING".encode()).hexdigest() + threshold = 1000 + _, dlc_locked = await wallet.split(minted, 64, dlc_data=(root_hash, threshold)) + + async def add_sct_witnesses_to_proofs( + self, + proofs: List[Proof] + ) -> List[Proof]: + """Add SCT witness data to proofs""" + logger.debug(f"Unlocking {len(proofs)} proofs locked to DLC root {proofs[0].dlc_root}") + for p in proofs: + all_spending_conditions = p.all_spending_conditions + assert all_spending_conditions is not None, "add_sct_witnesses_to_proof: What the duck is going on here" + leaf_hashes = list_hash(all_spending_conditions) + # We are pretending we don't know the backup secret + merkle_root_bytes, merkle_proof_bytes = merkle_root( + leaf_hashes, + 0, + ) + assert merkle_proof_bytes is not None, "add_sct_witnesses_to_proof: What the duck is going on here" + assert merkle_root_bytes.hex() == Secret.deserialize(p.secret).data, "add_sct_witnesses_to_proof: What the duck is going on here" + dlc_secret = all_spending_conditions[0] + p.witness = DLCWitness( + leaf_secret=dlc_secret, + merkle_proof=[m.hex() for m in merkle_proof_bytes] + ).json() + return proofs + # Monkey patching + saved = Wallet.add_sct_witnesses_to_proofs + Wallet.add_sct_witnesses_to_proofs = add_sct_witnesses_to_proofs + + strerror = "Mint Error: validation of input spending conditions failed. (Code: 11000)" + await assert_err(wallet.split(dlc_locked, 64), strerror) + Wallet.add_sct_witnesses_to_proofs = saved \ No newline at end of file From b0bfc0eb49bb2314949640df87eaab099cb37ce7 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Wed, 17 Jul 2024 18:42:39 +0200 Subject: [PATCH 20/68] dlc funding token --- cashu/core/base.py | 4 +++ cashu/wallet/cli/cli.py | 64 ++++++++++++++++++++++++++++++++++++----- cashu/wallet/dlc.py | 56 ++++++++++++++++++++++++++++++------ cashu/wallet/helpers.py | 15 +++++++--- cashu/wallet/p2pk.py | 37 +++++++++++++++++++++++- cashu/wallet/proofs.py | 18 ++++++++---- cashu/wallet/secrets.py | 11 +++---- cashu/wallet/wallet.py | 52 ++++++--------------------------- tests/test_dlc.py | 31 ++++++++++++++++---- 9 files changed, 209 insertions(+), 79 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index 7853b8c1..b7cf7c53 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -1132,6 +1132,9 @@ def serialize_to_dict(self, include_dleq=False): # optional memo if self.d: return_dict.update(dict(d=self.d)) + # optional dlc root + if self.r: + return_dict.update(dict(r=self.r)) # mint return_dict.update(dict(m=self.m)) # unit @@ -1202,6 +1205,7 @@ def parse_obj(cls, token_dict: dict): u=token_dict["u"], t=[TokenV4Token(**t) for t in token_dict["t"]], d=token_dict.get("d", None), + r=token_dict.get("r", None), ) # -------- DLC STUFF -------- diff --git a/cashu/wallet/cli/cli.py b/cashu/wallet/cli/cli.py index c9b81cc3..db9db86d 100644 --- a/cashu/wallet/cli/cli.py +++ b/cashu/wallet/cli/cli.py @@ -556,6 +556,21 @@ async def balance(ctx: Context, verbose): help="Force swap token.", type=bool, ) +@click.option( + "--dlc-root", + default=None, + is_flag=False, + help="root hash of the DLC you intend to fund", + type=str, +) +@click.option( + "--threshold", + "-t", + default=1000, + is_flag=False, + help="fund the DLC only if it's been funded >= threshold", + type=int, +) @click.pass_context @coro async def send_command( @@ -571,6 +586,8 @@ async def send_command( offline: bool, include_fees: bool, force_swap: bool, + dlc_root: str, + threshold: int, ): wallet: Wallet = ctx.obj["WALLET"] amount = int(amount * 100) if wallet.unit in [Unit.usd, Unit.eur] else int(amount) @@ -585,6 +602,7 @@ async def send_command( include_fees=include_fees, memo=memo, force_swap=force_swap, + dlc_data=(dlc_root, threshold) if dlc_root else None, ) else: await send_nostr(wallet, amount=amount, pubkey=nostr, verbose=verbose, yes=yes) @@ -1078,9 +1096,27 @@ async def restore(ctx: Context, to: int, batch: int): @cli.command("selfpay", help="Refresh tokens.") # @click.option("--all", default=False, is_flag=True, help="Execute on all available mints.") +@click.option( + "--dlc-unlock", + default=False, + is_flag=True, + help="Use this option to swap exposed DLC proofs whenever registration takes too long to complete", + type=bool, +) +@click.option( + "--dlc-root", + default=None, + is_flag=False, + help="""root hash of the DLC to unlock own proofs from""", + type=str, +) @click.pass_context @coro -async def selfpay(ctx: Context, all: bool = False): +async def selfpay( + ctx: Context, all: bool = False, + dlc_unlock: bool = False, + dlc_root: str = "", +): wallet = await get_mint_wallet(ctx, force_select=True) await wallet.load_mint() @@ -1097,9 +1133,23 @@ async def selfpay(ctx: Context, all: bool = False): print("No balance on this mint.") return - token = await wallet.serialize_proofs(reserved_proofs) - print(f"Selfpay token for mint {wallet.url}:") - print("") - print(token) - token_obj = TokenV4.deserialize(token) - await receive(wallet, token_obj) + # separate dlc proofs from non-dlc ones + non_dlc_reserved = await wallet.filter_non_dlc_proofs(reserved_proofs) + if len(non_dlc_reserved) > 0: + token = await wallet.serialize_proofs(non_dlc_reserved) + print(f"Selfpay token for mint {wallet.url}:") + print("") + print(token) + token_obj = TokenV4.deserialize(token) + await receive(wallet, token_obj) + # if user asked to unlock dlc proofs, do so: + if dlc_unlock: + # any specific root to be unlocked from? + if dlc_root and dlc_root != "": + dlc_reserved = await wallet.filter_proofs_by_dlc_root(dlc_root, reserved_proofs) + await wallet.redeem(dlc_reserved) + # no specific root, unlock all dlc proofs + else: + dlc_reserved = list(set(reserved_proofs) - set(non_dlc_reserved)) + await wallet.redeem(dlc_reserved) + diff --git a/cashu/wallet/dlc.py b/cashu/wallet/dlc.py index b9da2aba..1f3e8311 100644 --- a/cashu/wallet/dlc.py +++ b/cashu/wallet/dlc.py @@ -1,4 +1,4 @@ -from ..core.secret import Secret +from ..core.secret import Secret, SecretKind from ..core.crypto.secp import PrivateKey from ..core.crypto.dlc import list_hash, merkle_root from ..core.base import Proof, DLCWitness @@ -6,7 +6,7 @@ from loguru import logger from typing import List, Optional -class DLCSecret: +class SecretMetadata: secret: str blinding_factor: PrivateKey derivation_path: str @@ -21,12 +21,44 @@ def __init__(self, **kwargs): class WalletSCT(SupportsPrivateKey, SupportsDb): # ---------- SCT ---------- + async def _add_sct_witnesses_to_proofs(self, proofs: List[Proof], backup: bool = False) -> List[Proof]: + """Adds witnesses to proofs. + + 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. + + Args: + proofs (List[Proof]): List of proofs to add witnesses to + backup (bool): use the backup secret for the leaf secret in the witness + 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.") + if all( + [Secret.deserialize(p.secret).kind == SecretKind.SCT.value for p in proofs] + ): + logger.debug("DLC redemption detected") + proofs = await self.add_sct_witnesses_to_proofs(proofs=proofs, backup=backup) + + return proofs + async def add_sct_witnesses_to_proofs( self, - proofs: List[Proof] + proofs: List[Proof], + backup: bool = False, ) -> List[Proof]: """Add SCT witness data to proofs""" - logger.debug(f"Unlocking {len(proofs)} proofs locked to DLC root {proofs[0].dlc_root}") + logger.trace(f"Unlocking {len(proofs)} proofs locked to DLC root {proofs[0].dlc_root}") for p in proofs: all_spending_conditions = p.all_spending_conditions assert all_spending_conditions is not None, "add_sct_witnesses_to_proof: What the duck is going on here" @@ -34,14 +66,22 @@ async def add_sct_witnesses_to_proofs( # We are assuming the backup secret is the last (and second) entry merkle_root_bytes, merkle_proof_bytes = merkle_root( leaf_hashes, - len(leaf_hashes)-1, + len(leaf_hashes)-1 if backup else 0, ) # If this check fails we are in deep trouble assert merkle_proof_bytes is not None, "add_sct_witnesses_to_proof: What the duck is going on here" assert merkle_root_bytes.hex() == Secret.deserialize(p.secret).data, "add_sct_witnesses_to_proof: What the duck is going on here" - backup_secret = all_spending_conditions[-1] + leaf_secret = all_spending_conditions[-1] if backup else all_spending_conditions[0] p.witness = DLCWitness( - leaf_secret=backup_secret, + leaf_secret=leaf_secret, merkle_proof=[m.hex() for m in merkle_proof_bytes] ).json() - return proofs \ No newline at end of file + return proofs + + async def filter_proofs_by_dlc_root(self, dlc_root: str, proofs: List[Proof]) -> List[Proof]: + """Returns a list of proofs each having DLC root equal to `dlc_root` + """ + return list(filter(lambda p: p.dlc_root == dlc_root, proofs)) + + async def filter_non_dlc_proofs(self, proofs: List[Proof]) -> List[Proof]: + return list(filter(lambda p: p.dlc_root is None or p.dlc_root == "", proofs)) \ No newline at end of file diff --git a/cashu/wallet/helpers.py b/cashu/wallet/helpers.py index 85c73b72..35e5ec87 100644 --- a/cashu/wallet/helpers.py +++ b/cashu/wallet/helpers.py @@ -1,5 +1,5 @@ import os -from typing import Optional +from typing import Optional, Tuple from loguru import logger @@ -117,6 +117,7 @@ async def send( include_fees: bool = False, memo: Optional[str] = None, force_swap: bool = False, + dlc_data: Optional[Tuple[str, int]] = None, ): """ Prints token to send to stdout. @@ -145,12 +146,13 @@ async def send( await wallet.load_proofs() await wallet.load_mint() - if secret_lock or force_swap: + if secret_lock or force_swap or dlc_data: _, send_proofs = await wallet.swap_to_send( wallet.proofs, amount, set_reserved=False, # we set reserved later secret_lock=secret_lock, + dlc_data=dlc_data, ) else: send_proofs, fees = await wallet.select_to_send( @@ -160,9 +162,14 @@ async def send( offline=offline, include_fees=include_fees, ) - + # if we are making a DLC funding token, include DLC witness + send_proofs = await wallet._add_sct_witnesses_to_proofs(send_proofs) token = await wallet.serialize_proofs( - send_proofs, include_dleq=include_dleq, legacy=legacy, memo=memo + send_proofs, + include_dleq=include_dleq, + legacy=legacy, + memo=memo, + dlc_root=dlc_data[0] if dlc_data else None ) print(token) diff --git a/cashu/wallet/p2pk.py b/cashu/wallet/p2pk.py index d1be327c..b71564ec 100644 --- a/cashu/wallet/p2pk.py +++ b/cashu/wallet/p2pk.py @@ -113,7 +113,7 @@ async def add_p2pk_witnesses_to_outputs( o.witness = P2PKWitness(signatures=[s]).json() return outputs - async def add_witnesses_to_outputs( + async def _add_p2pk_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 @@ -141,6 +141,41 @@ async def add_witnesses_to_outputs( ): outputs = await self.add_p2pk_witnesses_to_outputs(outputs) return outputs + + async def _add_p2pk_witnesses_to_proofs(self, proofs: List[Proof]) -> List[Proof]: + """Adds witnesses to proofs. + + 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. + 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.") + # P2PK signatures + if all( + [Secret.deserialize(p.secret).kind == SecretKind.P2PK.value for p in proofs] + ): + logger.debug("P2PK redemption detected.") + proofs = await self.add_p2pk_witnesses_to_proofs(proofs) + + return proofs async def add_p2pk_witnesses_to_proofs(self, proofs: List[Proof]) -> List[Proof]: p2pk_signatures = await self.sign_p2pk_proofs(proofs) diff --git a/cashu/wallet/proofs.py b/cashu/wallet/proofs.py index c0169e18..63f5ea4a 100644 --- a/cashu/wallet/proofs.py +++ b/cashu/wallet/proofs.py @@ -135,6 +135,7 @@ async def serialize_proofs( include_dleq=False, legacy=False, memo: Optional[str] = None, + dlc_root: Optional[str] = None, ) -> str: """Produces sharable token with proofs and mint information. @@ -155,7 +156,7 @@ async def serialize_proofs( tokenv3 = await self._make_tokenv3(proofs, memo) return tokenv3.serialize(include_dleq) else: - tokenv4 = await self._make_token(proofs, include_dleq, memo) + tokenv4 = await self._make_token(proofs, include_dleq, memo, dlc_root) return tokenv4.serialize(include_dleq) async def _make_tokenv3( @@ -197,7 +198,10 @@ async def _make_tokenv3( return token async def _make_tokenv4( - self, proofs: List[Proof], include_dleq=False, memo: Optional[str] = None + self, proofs: List[Proof], + include_dleq=False, + memo: Optional[str] = None, + dlc_root: Optional[str] = None, ) -> TokenV4: """ Takes a list of proofs and returns a TokenV4 @@ -238,10 +242,14 @@ async def _make_tokenv4( tokenv4_token = TokenV4Token(i=bytes.fromhex(keyset_id), p=tokenv4_proofs) tokens.append(tokenv4_token) - return TokenV4(m=mint_url, u=unit_str, t=tokens, d=memo) + return TokenV4(m=mint_url, u=unit_str, t=tokens, d=memo, r=dlc_root) async def _make_token( - self, proofs: List[Proof], include_dleq=False, memo: Optional[str] = None + self, + proofs: List[Proof], + include_dleq=False, + memo: Optional[str] = None, + dlc_root: Optional[str] = None, ) -> TokenV4: """ Takes a list of proofs and returns a TokenV4 @@ -253,4 +261,4 @@ async def _make_token( TokenV4: TokenV4 object """ - return await self._make_tokenv4(proofs, include_dleq, memo) + return await self._make_tokenv4(proofs, include_dleq, memo, dlc_root) diff --git a/cashu/wallet/secrets.py b/cashu/wallet/secrets.py index f700a15c..1d96384a 100644 --- a/cashu/wallet/secrets.py +++ b/cashu/wallet/secrets.py @@ -10,7 +10,7 @@ from ..core.crypto.secp import PrivateKey from ..core.db import Database from ..core.secret import Secret, SecretKind, Tags -from .dlc import DLCSecret +from .dlc import SecretMetadata from ..core.crypto.dlc import merkle_root, list_hash from ..core.settings import settings from ..wallet.crud import ( @@ -243,7 +243,7 @@ async def generate_sct_secrets( dlc_root: str, threshold: int, skip_bump: bool = False, - ) -> Tuple[List[DLCSecret], int]: + ) -> Tuple[List[SecretMetadata], int]: """ Creates a list of DLC locked secrets and associated metadata. @@ -256,13 +256,14 @@ async def generate_sct_secrets( skip_bump (int): skip bumping of the secret derivation Returns: - List[DLCSecret]: Secrets and associated metadata + List[SecretMetadata]: Secrets and associated metadata int: The number of secrets that were generated (used to bump derivation) """ secrets_and_metadata = [] + # We generate `n_keep` normal secrets for the proofs we want to keep keep_secrets, keep_rs, keep_dpath = await self.generate_n_secrets(n_keep, skip_bump=skip_bump) for ksc, krs, kdp in zip(keep_secrets, keep_rs, keep_dpath): - secrets_and_metadata.append(DLCSecret( + secrets_and_metadata.append(SecretMetadata( secret=ksc, blinding_factor=krs, derivation_path=kdp, @@ -295,7 +296,7 @@ async def generate_sct_secrets( data=root_bytes.hex(), tags=Tags() ) - secrets_and_metadata.append(DLCSecret( + secrets_and_metadata.append(SecretMetadata( secret=secret.serialize(), blinding_factor=bf[i], derivation_path=dpath[i], diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 73cc06cb..d309634f 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -609,46 +609,6 @@ def swap_send_and_keep_output_amounts( return keep_outputs, send_outputs - async def add_witnesses_to_proofs(self, proofs: List[Proof]) -> List[Proof]: - """Adds witnesses to proofs. - - 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. - 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.") - # P2PK signatures - if all( - [Secret.deserialize(p.secret).kind == SecretKind.P2PK.value for p in proofs] - ): - logger.debug("P2PK redemption detected.") - proofs = await self.add_p2pk_witnesses_to_proofs(proofs) - elif all( - [Secret.deserialize(p.secret).kind == SecretKind.SCT.value for p in proofs] - ): - logger.debug("DLC backup redemption detected") - proofs = await self.add_sct_witnesses_to_proofs(proofs) - - return proofs - async def split( self, proofs: List[Proof], @@ -678,7 +638,8 @@ async def split( proofs = copy.copy(proofs) # potentially add witnesses to unlock provided proofs (if they indicate one) - proofs = await self.add_witnesses_to_proofs(proofs) + proofs = await self._add_p2pk_witnesses_to_proofs(proofs) + proofs = await self._add_sct_witnesses_to_proofs(proofs, backup=True) input_fees = self.get_fees_for_proofs(proofs) logger.debug(f"Input fees: {input_fees}") @@ -732,8 +693,8 @@ async def split( # construct outputs outputs, rs = self._construct_outputs(amounts, secrets, rs) - # potentially add witnesses to outputs based on what requirement the proofs indicate - outputs = await self.add_witnesses_to_outputs(proofs, outputs) + # potentially add p2pk witnesses to outputs based on what requirement the proofs indicate + outputs = await self._add_p2pk_witnesses_to_outputs(proofs, outputs) # Call swap API promises = await super().split(proofs, outputs) @@ -1157,6 +1118,7 @@ async def swap_to_send( secret_lock: Optional[Secret] = None, set_reserved: bool = False, include_fees: bool = True, + dlc_data: Optional[Tuple[str, int]] = None, ) -> Tuple[List[Proof], List[Proof]]: """ Swaps a set of proofs with the mint to get a set that sums up to a desired amount that can be sent. The remaining @@ -1171,6 +1133,8 @@ async def swap_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. + dlc_data(Tuple[str, int], optional): Specify DLC root hash and funding threshold. If set, the proofs will be locked to + the specified DLC root hash Returns: Tuple[List[Proof], List[Proof]]: Tuple of proofs to keep and proofs to send @@ -1196,7 +1160,7 @@ async def swap_to_send( logger.debug( f"Amount to send: {self.unit.str(amount)} (+ {self.unit.str(fees)} fees)" ) - keep_proofs, send_proofs = await self.split(swap_proofs, amount, secret_lock) + keep_proofs, send_proofs = await self.split(swap_proofs, amount, secret_lock, dlc_data) if set_reserved: await self.set_reserved(send_proofs, reserved=True) return keep_proofs, send_proofs diff --git a/tests/test_dlc.py b/tests/test_dlc.py index 09deca8a..d962284c 100644 --- a/tests/test_dlc.py +++ b/tests/test_dlc.py @@ -4,7 +4,8 @@ from cashu.wallet.wallet import Wallet from cashu.core.secret import Secret, SecretKind from cashu.core.errors import CashuError -from cashu.core.base import DLCWitness, Proof +from cashu.core.base import DLCWitness, Proof, TokenV4 +from cashu.wallet.helpers import send from tests.conftest import SERVER_ENDPOINT from hashlib import sha256 from tests.helpers import ( @@ -129,7 +130,8 @@ async def test_wrong_merkle_proof(wallet: Wallet): async def add_sct_witnesses_to_proofs( self, - proofs: List[Proof] + proofs: List[Proof], + backup: bool = False ) -> List[Proof]: """Add SCT witness data to proofs""" logger.debug(f"Unlocking {len(proofs)} proofs locked to DLC root {proofs[0].dlc_root}") @@ -172,7 +174,8 @@ async def test_no_witness_data(wallet: Wallet): async def add_sct_witnesses_to_proofs( self, - proofs: List[Proof] + proofs: List[Proof], + backup: bool = False ) -> List[Proof]: return proofs # Monkey patching @@ -197,7 +200,8 @@ async def test_cheating1(wallet: Wallet): async def add_sct_witnesses_to_proofs( self, - proofs: List[Proof] + proofs: List[Proof], + backup: bool = False ) -> List[Proof]: """Add SCT witness data to proofs""" logger.debug(f"Unlocking {len(proofs)} proofs locked to DLC root {proofs[0].dlc_root}") @@ -224,4 +228,21 @@ async def add_sct_witnesses_to_proofs( strerror = "Mint Error: validation of input spending conditions failed. (Code: 11000)" await assert_err(wallet.split(dlc_locked, 64), strerror) - Wallet.add_sct_witnesses_to_proofs = saved \ No newline at end of file + Wallet.add_sct_witnesses_to_proofs = saved + +@pytest.mark.asyncio +async def test_send_funding_token(wallet: Wallet): + invoice = await wallet.request_mint(64) + await pay_if_regtest(invoice.bolt11) + minted = await wallet.mint(64, id=invoice.id) + available_before = wallet.available_balance + # Send + root_hash = sha256("TESTING".encode()).hexdigest() + available_now, token = await send(wallet, amount=56, lock=None, legacy=False, dlc_data=(root_hash, 1000)) + assert available_now < available_before + deserialized_token = TokenV4.deserialize(token) + assert deserialized_token.dlc_root == root_hash + proofs = deserialized_token.proofs + assert all([Secret.deserialize(p.secret).kind == SecretKind.SCT.value for p in proofs]) + witnesses = [DLCWitness.from_witness(p.witness) for p in proofs] + assert all([Secret.deserialize(w.leaf_secret).kind == SecretKind.DLC.value for w in witnesses]) \ No newline at end of file From 41ee5e1b774db23ab438c9a8a896d076cc16bd31 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Thu, 18 Jul 2024 09:26:45 +0200 Subject: [PATCH 21/68] fix selfpay --- cashu/core/base.py | 4 +--- cashu/wallet/cli/cli.py | 2 +- cashu/wallet/dlc.py | 12 ++++++++---- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index b7cf7c53..9dc5c44d 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -171,9 +171,7 @@ def from_dict(cls, proof_dict: dict): if (proof_dict.get("all_spending_conditions") and isinstance(proof_dict["all_spending_conditions"], str)): - tmp = json.loads(proof_dict["all_spending_conditions"]) - proof_dict["all_spending_conditions"] = [json.dumps(t) for t in tmp] - #assert isinstance(proof_dict["all_spending_conditions"], List[str]) + proof_dict["all_spending_conditions"] = json.loads(proof_dict["all_spending_conditions"]) else: proof_dict["all_spending_conditions"] = None c = cls(**proof_dict) diff --git a/cashu/wallet/cli/cli.py b/cashu/wallet/cli/cli.py index db9db86d..4eef2b22 100644 --- a/cashu/wallet/cli/cli.py +++ b/cashu/wallet/cli/cli.py @@ -1150,6 +1150,6 @@ async def selfpay( await wallet.redeem(dlc_reserved) # no specific root, unlock all dlc proofs else: - dlc_reserved = list(set(reserved_proofs) - set(non_dlc_reserved)) + dlc_reserved = await wallet.filter_dlc_proofs(reserved_proofs) await wallet.redeem(dlc_reserved) diff --git a/cashu/wallet/dlc.py b/cashu/wallet/dlc.py index 1f3e8311..6c07dc58 100644 --- a/cashu/wallet/dlc.py +++ b/cashu/wallet/dlc.py @@ -61,7 +61,7 @@ async def add_sct_witnesses_to_proofs( logger.trace(f"Unlocking {len(proofs)} proofs locked to DLC root {proofs[0].dlc_root}") for p in proofs: all_spending_conditions = p.all_spending_conditions - assert all_spending_conditions is not None, "add_sct_witnesses_to_proof: What the duck is going on here" + assert all_spending_conditions is not None, "add_sct_witnesses_to_proof: None spending conditions" leaf_hashes = list_hash(all_spending_conditions) # We are assuming the backup secret is the last (and second) entry merkle_root_bytes, merkle_proof_bytes = merkle_root( @@ -69,13 +69,14 @@ async def add_sct_witnesses_to_proofs( len(leaf_hashes)-1 if backup else 0, ) # If this check fails we are in deep trouble - assert merkle_proof_bytes is not None, "add_sct_witnesses_to_proof: What the duck is going on here" - assert merkle_root_bytes.hex() == Secret.deserialize(p.secret).data, "add_sct_witnesses_to_proof: What the duck is going on here" + assert merkle_proof_bytes is not None, "add_sct_witnesses_to_proof: Merkle proof is None" + assert merkle_root_bytes.hex() == Secret.deserialize(p.secret).data, "add_sct_witnesses_to_proof: Merkle root not equal to hash in secret.data" leaf_secret = all_spending_conditions[-1] if backup else all_spending_conditions[0] p.witness = DLCWitness( leaf_secret=leaf_secret, merkle_proof=[m.hex() for m in merkle_proof_bytes] ).json() + logger.trace(f"Added dlc witness: {p.witness}") return proofs async def filter_proofs_by_dlc_root(self, dlc_root: str, proofs: List[Proof]) -> List[Proof]: @@ -84,4 +85,7 @@ async def filter_proofs_by_dlc_root(self, dlc_root: str, proofs: List[Proof]) -> return list(filter(lambda p: p.dlc_root == dlc_root, proofs)) async def filter_non_dlc_proofs(self, proofs: List[Proof]) -> List[Proof]: - return list(filter(lambda p: p.dlc_root is None or p.dlc_root == "", proofs)) \ No newline at end of file + return list(filter(lambda p: p.dlc_root is None or p.dlc_root == "", proofs)) + + async def filter_dlc_proofs(self, proofs: List[Proof]) -> List[Proof]: + return list(filter(lambda p: p.dlc_root is not None and p.dlc_root != "", proofs)) \ No newline at end of file From 6a3e8d39880988621f5190b13301faae8a345c32 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Thu, 18 Jul 2024 20:05:38 +0200 Subject: [PATCH 22/68] * mint DB add dlc table migration * started working on dlc registration --- cashu/core/base.py | 12 ++--- cashu/mint/dlc.py | 98 ++++++++++++++++++++++++++++++++++++++++ cashu/mint/migrations.py | 16 +++++++ cashu/wallet/dlc.py | 4 ++ 4 files changed, 124 insertions(+), 6 deletions(-) create mode 100644 cashu/mint/dlc.py diff --git a/cashu/core/base.py b/cashu/core/base.py index 9dc5c44d..145d8fb7 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -1212,13 +1212,13 @@ class DiscreteLogContract(BaseModel): """ A discrete log contract """ - settled: bool = False + settled: Optional[bool] = False dlc_root: str funding_amount: int - inputs: List[Proof] # Need to verify these are indeed SCT proofs - debts: Dict[str, int] = {} # We save who we owe money to here + inputs: Optional[List[Proof]] # Need to verify these are indeed SCT proofs + debts: Optional[Dict[str, int]] = None # We save who we owe money to here -class DlcBadInputs(BaseModel): +class DlcBadInput(BaseModel): index: int detail: str @@ -1227,8 +1227,8 @@ class DlcFundingProof(BaseModel): A dlc merkle root with its signature """ dlc_root: str - signature: Optional[str] - bad_inputs: Optional[List[DlcBadInputs]] = None # Used to specify potential errors + signature: Optional[str] = None + bad_inputs: Optional[List[DlcBadInput]] = None # Used to specify potential errors class DlcOutcome(BaseModel): """ diff --git a/cashu/mint/dlc.py b/cashu/mint/dlc.py new file mode 100644 index 00000000..8dc2214f --- /dev/null +++ b/cashu/mint/dlc.py @@ -0,0 +1,98 @@ +from .ledger import Ledger +from ..core.models import PostDlcRegistrationRequest, PostDlcRegistrationResponse +from ..core.base import DlcBadInput, DlcFundingProof, Proof, DLCWitness +from ..core.secret import Secret, SecretKind +from ..core.crypto.dlc import list_hash, merkle_verify +from ..core.errors import TransactionError +from ..core.nuts import DLC_NUT + + +from hashlib import sha256 +from loguru import logger +from typing import List, Dict, Optional + +class LedgerDLC(Ledger): + + async def _verify_dlc_input_spending_conditions(self, dlc_root: str, p: Proof) -> bool: + if not p.witness: + return False + witness = DLCWitness.from_witness(p.witness) + leaf_secret = Secret.deserialize(witness.leaf_secret) + secret = Secret.deserialize(p.secret) + # Verify secret is of kind SCT + if secret.kind != SecretKind.SCT.value: + return False + # Verify leaf_secret is of kind DLC + if leaf_secret.kind != SecretKind.DLC.value: + return False + # Verify dlc_root is the one referenced in the secret + if leaf_secret.data != dlc_root: + return False + # (Optional?) Verify inclusion of leaf_secret in the SCT root hash + leaf_hash_bytes = sha256(witness.leaf_secret.encode()).digest() + merkle_proof_bytes = [bytes.fromhex(m) for m in witness.merkle_proof] + sct_root_hash_bytes = bytes.fromhex(secret.data) + if not merkle_verify(sct_root_hash_bytes, leaf_hash_bytes, merkle_proof_bytes): + return False + + return True + + + async def _verify_dlc_inputs(self, dlc_root: str, proofs: Optional[List[Proof]]): + # Verify inputs + if not proofs: + raise TransactionError("no proofs provided.") + # 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 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_dlc_input_spending_conditions(dlc_root, p) for p in proofs]): + raise TransactionError("validation of input spending conditions failed.") + + + async def _verify_dlc_amount_fees_coverage(self, funding_amount: int, proofs: List[Proof]): + # Verify proofs of the same denomination + u = self.keysets[proofs[0].id].unit + if not all([self.keysets[p.id].unit == u for p in proofs]): + raise TransactionError("all the inputs must be of the same denomination") + fees = self.mint_features()[DLC_NUT] + assert isinstance(fees, dict) + fees = fees['fees'] + assert isinstance(fees, dict) + amount_provided = sum([p.amount for p in proofs]) + amount_needed = funding_amount + fees['base'] + (funding_amount * fees['ppk'] // 1000) + if amount_needed < amount_provided: + raise TransactionError("funds provided do not cover the DLC funding amount") + + # UNFINISHED + async def register_dlc(self, request: PostDlcRegistrationRequest): + logger.trace("swap called") + is_atomic = request.atomic + funded: List[DlcFundingProof] = [] + errors: List[DlcFundingProof] = [] + for registration in request.registrations: + try: + logger.trace(f"processing registration {registration.dlc_root}") + await self._verify_dlc_inputs(registration.dlc_root, registration.inputs) + assert registration.inputs is not None + await self._verify_dlc_amount_fees_coverage(registration.funding_amount, registration.inputs) + await self.db_write._verify_spent_proofs_and_set_pending(registration.inputs) + except TransactionError as e: + # I know this is horrificly out of spec -- I want to get it to work + errors.append(DlcFundingProof( + dlc_root=registration.dlc_root, + bad_inputs=[DlcBadInput( + index=-1, + detail=e.detail + )] + )) + diff --git a/cashu/mint/migrations.py b/cashu/mint/migrations.py index 88ec94eb..8bd72dac 100644 --- a/cashu/mint/migrations.py +++ b/cashu/mint/migrations.py @@ -825,3 +825,19 @@ async def m021_add_change_and_expiry_to_melt_quotes(db: Database): await conn.execute( f"ALTER TABLE {db.table_with_schema('melt_quotes')} ADD COLUMN expiry TIMESTAMP" ) + +async def m022_add_dlc_table(db: Database): + async with db.connect() as conn: + await conn.execute( + f""" + CREATE TABLE IF NOT EXISTS {db.table_with_schema('dlc')} ( + dlc_root TEXT NOT NULL, + settled BOOL NOT NULL DEFAULT FALSE, + funding_amount {db.big_int} NOT NULL, + debts MEDIUMTEXT, + + UNIQUE (dlc_root), + CHECK (funding_amount > 0) + ); + """ + ) \ No newline at end of file diff --git a/cashu/wallet/dlc.py b/cashu/wallet/dlc.py index 6c07dc58..4834cc60 100644 --- a/cashu/wallet/dlc.py +++ b/cashu/wallet/dlc.py @@ -85,7 +85,11 @@ async def filter_proofs_by_dlc_root(self, dlc_root: str, proofs: List[Proof]) -> return list(filter(lambda p: p.dlc_root == dlc_root, proofs)) async def filter_non_dlc_proofs(self, proofs: List[Proof]) -> List[Proof]: + """Returns a list of proofs each having None or empty dlc root + """ return list(filter(lambda p: p.dlc_root is None or p.dlc_root == "", proofs)) async def filter_dlc_proofs(self, proofs: List[Proof]) -> List[Proof]: + """Returns a list of proofs each having a non empty dlc root + """ return list(filter(lambda p: p.dlc_root is not None and p.dlc_root != "", proofs)) \ No newline at end of file From 9a741cb72d1bce1072a4b76f560821ca965013bd Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Thu, 18 Jul 2024 20:24:35 +0200 Subject: [PATCH 23/68] fix migration --- cashu/mint/migrations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cashu/mint/migrations.py b/cashu/mint/migrations.py index 8bd72dac..3143690a 100644 --- a/cashu/mint/migrations.py +++ b/cashu/mint/migrations.py @@ -834,7 +834,7 @@ async def m022_add_dlc_table(db: Database): dlc_root TEXT NOT NULL, settled BOOL NOT NULL DEFAULT FALSE, funding_amount {db.big_int} NOT NULL, - debts MEDIUMTEXT, + debts TEXT, UNIQUE (dlc_root), CHECK (funding_amount > 0) From 0b89fb7e04dd845dc5c91d9915644ec0ed449698 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Fri, 19 Jul 2024 11:06:53 +0200 Subject: [PATCH 24/68] verify threshold, verify funding amount and fees coverage. --- cashu/core/base.py | 1 + cashu/mint/dlc.py | 82 +++++++++++++++++++++++++++++++++++----------- 2 files changed, 63 insertions(+), 20 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index 145d8fb7..07a764a0 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -1215,6 +1215,7 @@ class DiscreteLogContract(BaseModel): settled: Optional[bool] = False dlc_root: str funding_amount: int + unit: str inputs: Optional[List[Proof]] # Need to verify these are indeed SCT proofs debts: Optional[Dict[str, int]] = None # We save who we owe money to here diff --git a/cashu/mint/dlc.py b/cashu/mint/dlc.py index 8dc2214f..a7b3cf70 100644 --- a/cashu/mint/dlc.py +++ b/cashu/mint/dlc.py @@ -1,26 +1,31 @@ from .ledger import Ledger from ..core.models import PostDlcRegistrationRequest, PostDlcRegistrationResponse -from ..core.base import DlcBadInput, DlcFundingProof, Proof, DLCWitness +from ..core.base import DlcBadInput, DlcFundingProof, Proof, DLCWitness, Unit from ..core.secret import Secret, SecretKind -from ..core.crypto.dlc import list_hash, merkle_verify +from ..core.crypto.dlc import merkle_verify from ..core.errors import TransactionError from ..core.nuts import DLC_NUT from hashlib import sha256 from loguru import logger -from typing import List, Dict, Optional +from typing import List, Dict, Optional, Tuple class LedgerDLC(Ledger): + async def filter_sct_proofs(self, proofs: List[Proof]) -> Tuple[List[Proof], List[Proof]]: + sct_proofs = list(filter(lambda p: Secret.deserialize(p.secret).kind == SecretKind.SCT.value, proofs)) + non_sct_proofs = list(filter(lambda p: p not in sct_proofs, proofs)) + return (sct_proofs, non_sct_proofs) + async def _verify_dlc_input_spending_conditions(self, dlc_root: str, p: Proof) -> bool: if not p.witness: return False - witness = DLCWitness.from_witness(p.witness) - leaf_secret = Secret.deserialize(witness.leaf_secret) - secret = Secret.deserialize(p.secret) - # Verify secret is of kind SCT - if secret.kind != SecretKind.SCT.value: + try: + witness = DLCWitness.from_witness(p.witness) + leaf_secret = Secret.deserialize(witness.leaf_secret) + secret = Secret.deserialize(p.secret) + except Exception as e: return False # Verify leaf_secret is of kind DLC if leaf_secret.kind != SecretKind.DLC.value: @@ -28,7 +33,7 @@ async def _verify_dlc_input_spending_conditions(self, dlc_root: str, p: Proof) - # Verify dlc_root is the one referenced in the secret if leaf_secret.data != dlc_root: return False - # (Optional?) Verify inclusion of leaf_secret in the SCT root hash + # Verify inclusion of leaf_secret in the SCT root hash leaf_hash_bytes = sha256(witness.leaf_secret.encode()).digest() merkle_proof_bytes = [bytes.fromhex(m) for m in witness.merkle_proof] sct_root_hash_bytes = bytes.fromhex(secret.data) @@ -54,28 +59,60 @@ async def _verify_dlc_inputs(self, dlc_root: str, proofs: Optional[List[Proof]]) # 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_dlc_input_spending_conditions(dlc_root, p) for p in proofs]): - raise TransactionError("validation of input spending conditions failed.") + # Split SCT and non-SCT + # REASONING: the submitter of the registration does not need to dlc lock their proofs + sct_proofs, non_sct_proofs = await self.filter_sct_proofs(proofs) + # Verify spending conditions + if not all([self._verify_dlc_input_spending_conditions(dlc_root, p) for p in sct_proofs]): + raise TransactionError("validation of sct input spending conditions failed.") + if not all([self._verify_input_spending_conditions(p) for p in non_sct_proofs]): + raise TransactionError("validation of non-sct input spending conditions failed.") + async def get_fees(self, fa_unit: str) -> Dict[str, int]: + try: + fees = self.mint_features()[DLC_NUT] + assert isinstance(fees, dict) + fees = fees['fees'] + assert isinstance(fees, dict) + fees = fees[fa_unit] + assert isinstance(fees, dict) + return fees + except Exception as e: + raise TransactionError("could not get fees for the specified funding_amount denomination") - async def _verify_dlc_amount_fees_coverage(self, funding_amount: int, proofs: List[Proof]): + async def _verify_dlc_amount_fees_coverage( + self, + funding_amount: int, + fa_unit: str, + proofs: List[Proof], + ) -> int: # Verify proofs of the same denomination + # REASONING: proofs could be usd, eur. We don't want mixed stuff. u = self.keysets[proofs[0].id].unit if not all([self.keysets[p.id].unit == u for p in proofs]): raise TransactionError("all the inputs must be of the same denomination") - fees = self.mint_features()[DLC_NUT] - assert isinstance(fees, dict) - fees = fees['fees'] - assert isinstance(fees, dict) + # Verify registration's funding_amount unit is the same as the proofs + if Unit[fa_unit] != u: + raise TransactionError("funding amount unit is not the same as the proofs") + fees = await self.get_fees(fa_unit) amount_provided = sum([p.amount for p in proofs]) amount_needed = funding_amount + fees['base'] + (funding_amount * fees['ppk'] // 1000) if amount_needed < amount_provided: raise TransactionError("funds provided do not cover the DLC funding amount") + return amount_provided + + async def _verify_dlc_amount_threshold(self, funding_amount: int, proofs: List[Proof]): + """For every SCT proof verify that secret's threshold is less or equal to + the funding_amount + """ + sct_proofs, _ = await self.filter_sct_proofs(proofs) + sct_secrets = [Secret.deserialize(p.secret) for p in sct_proofs] + if not all([int(s.tags.get_tag('threshold')) <= funding_amount for s in sct_secrets]): + raise TransactionError("Some inputs' funding thresholds were not met") # UNFINISHED async def register_dlc(self, request: PostDlcRegistrationRequest): - logger.trace("swap called") + logger.trace("register called") is_atomic = request.atomic funded: List[DlcFundingProof] = [] errors: List[DlcFundingProof] = [] @@ -83,8 +120,13 @@ async def register_dlc(self, request: PostDlcRegistrationRequest): try: logger.trace(f"processing registration {registration.dlc_root}") await self._verify_dlc_inputs(registration.dlc_root, registration.inputs) - assert registration.inputs is not None - await self._verify_dlc_amount_fees_coverage(registration.funding_amount, registration.inputs) + assert registration.inputs is not None # mypy give me a break + amount_provided = await self._verify_dlc_amount_fees_coverage( + registration.funding_amount, + registration.unit, + registration.inputs + ) + await self._verify_dlc_amount_threshold(amount_provided, registration.inputs) await self.db_write._verify_spent_proofs_and_set_pending(registration.inputs) except TransactionError as e: # I know this is horrificly out of spec -- I want to get it to work From 1bbca5abd4c526c0d3ae057c0c01e25f7690326d Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Fri, 19 Jul 2024 11:09:06 +0200 Subject: [PATCH 25/68] embarassing error fix --- cashu/mint/dlc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cashu/mint/dlc.py b/cashu/mint/dlc.py index a7b3cf70..bde1873f 100644 --- a/cashu/mint/dlc.py +++ b/cashu/mint/dlc.py @@ -97,7 +97,7 @@ async def _verify_dlc_amount_fees_coverage( fees = await self.get_fees(fa_unit) amount_provided = sum([p.amount for p in proofs]) amount_needed = funding_amount + fees['base'] + (funding_amount * fees['ppk'] // 1000) - if amount_needed < amount_provided: + if amount_provided < amount_needed: raise TransactionError("funds provided do not cover the DLC funding amount") return amount_provided From fd12aa3dcb097dd2331bffdd32415ebecdc8d881 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Thu, 25 Jul 2024 19:02:16 +0200 Subject: [PATCH 26/68] report index of proofs that failed verification --- cashu/core/base.py | 1 + cashu/core/errors.py | 15 +++- cashu/mint/dlc.py | 176 +++++++++++++++++++++++++++++++++-------- cashu/wallet/wallet.py | 8 +- 4 files changed, 164 insertions(+), 36 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index 07a764a0..140376c6 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -1226,6 +1226,7 @@ class DlcBadInput(BaseModel): class DlcFundingProof(BaseModel): """ A dlc merkle root with its signature + or a dlc merkle root with bad inputs. """ dlc_root: str signature: Optional[str] = None diff --git a/cashu/core/errors.py b/cashu/core/errors.py index 36700acf..936bbe47 100644 --- a/cashu/core/errors.py +++ b/cashu/core/errors.py @@ -1,5 +1,5 @@ -from typing import Optional - +from typing import Optional, List +from .base import DlcBadInput class CashuError(Exception): code: int @@ -96,3 +96,14 @@ class QuoteNotPaidError(CashuError): def __init__(self): super().__init__(self.detail, code=2001) + + +class DlcVerificationFail(CashuError): + detail = "dlc verification fail" + code = 30000 + bad_inputs = None + + def __init__(self, **kwargs): + super().__init__(self.detail, self.code) + self.bad_inputs = kwargs['bad_inputs'] + \ No newline at end of file diff --git a/cashu/mint/dlc.py b/cashu/mint/dlc.py index bde1873f..7fdd553f 100644 --- a/cashu/mint/dlc.py +++ b/cashu/mint/dlc.py @@ -3,7 +3,14 @@ from ..core.base import DlcBadInput, DlcFundingProof, Proof, DLCWitness, Unit from ..core.secret import Secret, SecretKind from ..core.crypto.dlc import merkle_verify -from ..core.errors import TransactionError +from ..core.errors import ( + TransactionError, + DlcVerificationFail, + NotAllowedError, + NoSecretInProofsError, + SecretTooLongError, + CashuError, +) from ..core.nuts import DLC_NUT @@ -18,7 +25,7 @@ async def filter_sct_proofs(self, proofs: List[Proof]) -> Tuple[List[Proof], Lis non_sct_proofs = list(filter(lambda p: p not in sct_proofs, proofs)) return (sct_proofs, non_sct_proofs) - async def _verify_dlc_input_spending_conditions(self, dlc_root: str, p: Proof) -> bool: + def _verify_dlc_input_spending_conditions(self, dlc_root: str, p: Proof) -> bool: if not p.witness: return False try: @@ -43,32 +50,116 @@ async def _verify_dlc_input_spending_conditions(self, dlc_root: str, p: Proof) - return True - async def _verify_dlc_inputs(self, dlc_root: str, proofs: Optional[List[Proof]]): + async def _verify_dlc_inputs( + self, + dlc_root: str, + proofs: List[Proof], + ): + """ + Verifies all inputs to the DLC + + Args: + dlc_root (hex str): root of the DLC contract + proofs: (List[Proof]): proofs to be verified + + Raises: + DlcVerificationFail + """ + # After we have collected all of the errors + # We use this to raise a DlcVerificationFail + def raise_if_err(err): + if len(err) > 0: + logger.error("Failed to verify DLC inputs") + raise DlcVerificationFail(bad_inputs=err) + + # We cannot just raise an exception if one proof fails and call it a day + # for every proof we need to collect its index and motivation of failure + # and report them + # Verify inputs if not proofs: raise TransactionError("no proofs provided.") + + errors = [] # Verify amounts of inputs - if not all([self._verify_amount(p.amount) for p in proofs]): - raise TransactionError("invalid amount.") + for i, p in enumerate(proofs): + try: + self._verify_amount(p.amount) + except NotAllowedError as e: + errors.append(DlcBadInput( + index=i, + detail=e.detail + )) + raise_if_err(errors) + # Verify secret criteria - if not all([self._verify_secret_criteria(p) for p in proofs]): - raise TransactionError("secrets do not match criteria.") + for i, p in enumerate(proofs): + try: + self._verify_secret_criteria(p) + except (SecretTooLongError, NoSecretInProofsError) as e: + errors.append(DlcBadInput( + index=i, + detail=e.detail + )) + raise_if_err(errors) + # verify that only unique proofs were used if not self._verify_no_duplicate_proofs(proofs): raise TransactionError("duplicate proofs.") + # Verify ecash signatures - if not all([self._verify_proof_bdhke(p) for p in proofs]): - raise TransactionError("could not verify proofs.") + for i, p in enumerate(proofs): + valid = False + exc = None + try: + # _verify_proof_bdhke can also raise an AssertionError... + assert self._verify_proof_bdhke(p), "invalid e-cash signature" + except AssertionError as e: + errors.append(DlcBadInput( + index=i, + detail=str(e) + )) + raise_if_err(errors) + + # Verify proofs of the same denomination + # REASONING: proofs could be usd, eur. We don't want mixed stuff. + u = self.keysets[proofs[0].id].unit + for i, p in enumerate(proofs): + if self.keysets[p.id].unit != u: + errors.append(DlcBadInput( + index=i, + detail="all the inputs must be of the same denomination" + )) + raise_if_err(errors) + # Split SCT and non-SCT # REASONING: the submitter of the registration does not need to dlc lock their proofs sct_proofs, non_sct_proofs = await self.filter_sct_proofs(proofs) # Verify spending conditions - if not all([self._verify_dlc_input_spending_conditions(dlc_root, p) for p in sct_proofs]): - raise TransactionError("validation of sct input spending conditions failed.") - if not all([self._verify_input_spending_conditions(p) for p in non_sct_proofs]): - raise TransactionError("validation of non-sct input spending conditions failed.") - - async def get_fees(self, fa_unit: str) -> Dict[str, int]: + for i, p in enumerate(sct_proofs): + # _verify_dlc_input_spending_conditions does not raise any error + # it handles all of them and return either true or false. ALWAYS. + if not self._verify_dlc_input_spending_conditions(dlc_root, p): + errors.append(DlcBadInput( + index=i, + detail="dlc input spending conditions verification failed" + )) + for i, p in enumerate(non_sct_proofs): + valid = False + exc = None + try: + valid = self._verify_input_spending_conditions(p) + except CashuError as e: + exc = e + if not valid: + errors.append(DlcBadInput( + index=i, + detail=exc.detail if exc else "input spending conditions verification failed" + )) + raise_if_err(errors) + + + async def get_dlc_fees(self, fa_unit: str) -> Dict[str, int]: try: fees = self.mint_features()[DLC_NUT] assert isinstance(fees, dict) @@ -86,15 +177,28 @@ async def _verify_dlc_amount_fees_coverage( fa_unit: str, proofs: List[Proof], ) -> int: - # Verify proofs of the same denomination - # REASONING: proofs could be usd, eur. We don't want mixed stuff. + """ + Verifies the sum of the inputs is enough to cover + the funding amount + fees + + Args: + funding_amount (int): funding amount of the contract + fa_unit (str): ONE OF ('sat', 'msat', 'eur', 'usd', 'btc'). The unit in which funding_amount + should be evaluated. + proofs: (List[Proof]): proofs to be verified + + Returns: + (int): amount provided by the proofs + + Raises: + TransactionError + + """ u = self.keysets[proofs[0].id].unit - if not all([self.keysets[p.id].unit == u for p in proofs]): - raise TransactionError("all the inputs must be of the same denomination") # Verify registration's funding_amount unit is the same as the proofs if Unit[fa_unit] != u: raise TransactionError("funding amount unit is not the same as the proofs") - fees = await self.get_fees(fa_unit) + fees = await self.get_dlc_fees(fa_unit) amount_provided = sum([p.amount for p in proofs]) amount_needed = funding_amount + fees['base'] + (funding_amount * fees['ppk'] // 1000) if amount_provided < amount_needed: @@ -119,22 +223,32 @@ async def register_dlc(self, request: PostDlcRegistrationRequest): for registration in request.registrations: try: logger.trace(f"processing registration {registration.dlc_root}") + assert registration.inputs is not None # mypy give me a break await self._verify_dlc_inputs(registration.dlc_root, registration.inputs) - assert registration.inputs is not None # mypy give me a break amount_provided = await self._verify_dlc_amount_fees_coverage( registration.funding_amount, registration.unit, registration.inputs ) await self._verify_dlc_amount_threshold(amount_provided, registration.inputs) - await self.db_write._verify_spent_proofs_and_set_pending(registration.inputs) - except TransactionError as e: - # I know this is horrificly out of spec -- I want to get it to work - errors.append(DlcFundingProof( - dlc_root=registration.dlc_root, - bad_inputs=[DlcBadInput( - index=-1, - detail=e.detail - )] - )) + # Some flavour of this function: we need to insert a check inside the db lock + # to verify there isn't some other contract with the same dlc root. + # await self.db_write._verify_spent_proofs_and_set_pending(registration.inputs) + except (TransactionError, DlcVerificationFail) as e: + logger.error(f"registration {registration.dlc_root} failed") + # Generic Error + if isinstance(e, TransactionError): + errors.append(DlcFundingProof( + dlc_root=registration.dlc_root, + bad_inputs=[DlcBadInput( + index=-1, + detail=e.detail + )] + )) + # DLC verification fail + else: + errors.append(DlcFundingProof( + dlc_root=registration.dlc_root, + bad_inputs=e.bad_inputs, + )) diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index d309634f..1324790e 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -883,15 +883,17 @@ async def _construct_proofs( """ logger.trace("Constructing proofs.") proofs: List[Proof] = [] + ''' flag = (spending_conditions is not None and len(spending_conditions) == len(secrets) ) + zipped = zip(promises, secrets, rs, derivation_paths) if not flag else ( zip(promises, secrets, rs, derivation_paths, spending_conditions or []) ) - for z in zipped: - promise, secret, r, path = z[:4] - spc = z[4] if flag else None + ''' + for i, (promise, secret, r, path) in enumerate(zip(promises, secrets, rs, derivation_paths)): + spc = spending_conditions[i] if spending_conditions else None if promise.id not in self.keysets: logger.debug(f"Keyset {promise.id} not found in db. Loading from mint.") # we don't have the keyset for this promise, so we load all keysets from the mint From ffa6858875ec2b290147f7c63740d7a70e4e6065 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Thu, 25 Jul 2024 20:41:02 +0200 Subject: [PATCH 27/68] DlcFundingProof signature --- cashu/core/base.py | 2 +- cashu/core/crypto/dlc.py | 11 ++++++++++- cashu/mint/dlc.py | 25 +++++++++++++++++++------ 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index 140376c6..1c0071b9 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -1216,7 +1216,7 @@ class DiscreteLogContract(BaseModel): dlc_root: str funding_amount: int unit: str - inputs: Optional[List[Proof]] # Need to verify these are indeed SCT proofs + inputs: Optional[List[Proof]] = None # Need to verify these are indeed SCT proofs debts: Optional[Dict[str, int]] = None # We save who we owe money to here class DlcBadInput(BaseModel): diff --git a/cashu/core/crypto/dlc.py b/cashu/core/crypto/dlc.py index da376456..6465a473 100644 --- a/cashu/core/crypto/dlc.py +++ b/cashu/core/crypto/dlc.py @@ -1,5 +1,6 @@ from hashlib import sha256 from typing import List, Optional, Tuple +from secp256k1 import PrivateKey, PublicKey def sorted_merkle_hash(left: bytes, right: bytes) -> bytes: @@ -59,4 +60,12 @@ def merkle_verify(root: bytes, leaf_hash: bytes, proof: List[bytes]) -> bool: return h == root def list_hash(leaves: List[str]) -> List[bytes]: - return [sha256(leaf.encode()).digest() for leaf in leaves] \ No newline at end of file + return [sha256(leaf.encode()).digest() for leaf in leaves] + +def sign_dlc(dlc_root: str, privkey: PrivateKey) -> bytes: + dlc_root_hash = sha256(bytes.fromhex(dlc_root)).digest() + return privkey.schnorr_sign(dlc_root_hash, None, raw=True) + +def verify_dlc_signature(dlc_root: str, signature: bytes, pubkey: PublicKey) -> bool: + dlc_root_hash = sha256(bytes.fromhex(dlc_root)).digest() + return pubkey.schnorr_verify(dlc_root_hash, signature, None, raw=True) \ No newline at end of file diff --git a/cashu/mint/dlc.py b/cashu/mint/dlc.py index 7fdd553f..87983025 100644 --- a/cashu/mint/dlc.py +++ b/cashu/mint/dlc.py @@ -1,8 +1,8 @@ from .ledger import Ledger from ..core.models import PostDlcRegistrationRequest, PostDlcRegistrationResponse -from ..core.base import DlcBadInput, DlcFundingProof, Proof, DLCWitness, Unit +from ..core.base import DlcBadInput, DlcFundingProof, Proof, DLCWitness, Unit, DiscreteLogContract from ..core.secret import Secret, SecretKind -from ..core.crypto.dlc import merkle_verify +from ..core.crypto.dlc import merkle_verify, sign_dlc from ..core.errors import ( TransactionError, DlcVerificationFail, @@ -218,7 +218,7 @@ async def _verify_dlc_amount_threshold(self, funding_amount: int, proofs: List[P async def register_dlc(self, request: PostDlcRegistrationRequest): logger.trace("register called") is_atomic = request.atomic - funded: List[DlcFundingProof] = [] + funded: List[Tuple[DiscreteLogContract, DlcFundingProof]] = [] errors: List[DlcFundingProof] = [] for registration in request.registrations: try: @@ -231,9 +231,22 @@ async def register_dlc(self, request: PostDlcRegistrationRequest): registration.inputs ) await self._verify_dlc_amount_threshold(amount_provided, registration.inputs) - # Some flavour of this function: we need to insert a check inside the db lock - # to verify there isn't some other contract with the same dlc root. - # await self.db_write._verify_spent_proofs_and_set_pending(registration.inputs) + # At this point we can put this dlc into the funded list and create a signature for it + # We use the funding proof private key + ''' + signature = sign_dlc(registration.dlc_root, self.funding_proof_private_key) + funding_proof = DlcFundingProof( + dlc_root=registration.dlc_root, + signature=signature.hex() + ) + dlc = DiscreteLogContract( + settled=False, + dlc_root=registration.dlc_root, + funding_amount=amount_provided, + unit=registration.unit, + ) + funded.append((dlc, funding_proof)) + ''' except (TransactionError, DlcVerificationFail) as e: logger.error(f"registration {registration.dlc_root} failed") # Generic Error From ff125d6899df4119d9a30511b0cc4dca8e0b3113 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Mon, 29 Jul 2024 21:48:43 +0200 Subject: [PATCH 28/68] refactor: move dlc verification functions into `verification.py`, registration function into `ledger.py`. --- cashu/mint/dlc.py | 256 +------------------------------------ cashu/mint/ledger.py | 57 +++++++++ cashu/mint/verification.py | 187 ++++++++++++++++++++++++++- 3 files changed, 248 insertions(+), 252 deletions(-) diff --git a/cashu/mint/dlc.py b/cashu/mint/dlc.py index 87983025..21f5d126 100644 --- a/cashu/mint/dlc.py +++ b/cashu/mint/dlc.py @@ -1,164 +1,17 @@ -from .ledger import Ledger -from ..core.models import PostDlcRegistrationRequest, PostDlcRegistrationResponse -from ..core.base import DlcBadInput, DlcFundingProof, Proof, DLCWitness, Unit, DiscreteLogContract -from ..core.secret import Secret, SecretKind -from ..core.crypto.dlc import merkle_verify, sign_dlc -from ..core.errors import ( - TransactionError, - DlcVerificationFail, - NotAllowedError, - NoSecretInProofsError, - SecretTooLongError, - CashuError, -) from ..core.nuts import DLC_NUT +from ..core.base import Proof +from ..core.secret import Secret, SecretKind +from ..core.errors import TransactionError +from .features import LedgerFeatures - -from hashlib import sha256 -from loguru import logger -from typing import List, Dict, Optional, Tuple - -class LedgerDLC(Ledger): +from typing import List, Tuple, Dict +class LedgerDLC(LedgerFeatures): async def filter_sct_proofs(self, proofs: List[Proof]) -> Tuple[List[Proof], List[Proof]]: sct_proofs = list(filter(lambda p: Secret.deserialize(p.secret).kind == SecretKind.SCT.value, proofs)) non_sct_proofs = list(filter(lambda p: p not in sct_proofs, proofs)) return (sct_proofs, non_sct_proofs) - def _verify_dlc_input_spending_conditions(self, dlc_root: str, p: Proof) -> bool: - if not p.witness: - return False - try: - witness = DLCWitness.from_witness(p.witness) - leaf_secret = Secret.deserialize(witness.leaf_secret) - secret = Secret.deserialize(p.secret) - except Exception as e: - return False - # Verify leaf_secret is of kind DLC - if leaf_secret.kind != SecretKind.DLC.value: - return False - # Verify dlc_root is the one referenced in the secret - if leaf_secret.data != dlc_root: - return False - # Verify inclusion of leaf_secret in the SCT root hash - leaf_hash_bytes = sha256(witness.leaf_secret.encode()).digest() - merkle_proof_bytes = [bytes.fromhex(m) for m in witness.merkle_proof] - sct_root_hash_bytes = bytes.fromhex(secret.data) - if not merkle_verify(sct_root_hash_bytes, leaf_hash_bytes, merkle_proof_bytes): - return False - - return True - - - async def _verify_dlc_inputs( - self, - dlc_root: str, - proofs: List[Proof], - ): - """ - Verifies all inputs to the DLC - - Args: - dlc_root (hex str): root of the DLC contract - proofs: (List[Proof]): proofs to be verified - - Raises: - DlcVerificationFail - """ - # After we have collected all of the errors - # We use this to raise a DlcVerificationFail - def raise_if_err(err): - if len(err) > 0: - logger.error("Failed to verify DLC inputs") - raise DlcVerificationFail(bad_inputs=err) - - # We cannot just raise an exception if one proof fails and call it a day - # for every proof we need to collect its index and motivation of failure - # and report them - - # Verify inputs - if not proofs: - raise TransactionError("no proofs provided.") - - errors = [] - # Verify amounts of inputs - for i, p in enumerate(proofs): - try: - self._verify_amount(p.amount) - except NotAllowedError as e: - errors.append(DlcBadInput( - index=i, - detail=e.detail - )) - raise_if_err(errors) - - # Verify secret criteria - for i, p in enumerate(proofs): - try: - self._verify_secret_criteria(p) - except (SecretTooLongError, NoSecretInProofsError) as e: - errors.append(DlcBadInput( - index=i, - detail=e.detail - )) - raise_if_err(errors) - - # verify that only unique proofs were used - if not self._verify_no_duplicate_proofs(proofs): - raise TransactionError("duplicate proofs.") - - # Verify ecash signatures - for i, p in enumerate(proofs): - valid = False - exc = None - try: - # _verify_proof_bdhke can also raise an AssertionError... - assert self._verify_proof_bdhke(p), "invalid e-cash signature" - except AssertionError as e: - errors.append(DlcBadInput( - index=i, - detail=str(e) - )) - raise_if_err(errors) - - # Verify proofs of the same denomination - # REASONING: proofs could be usd, eur. We don't want mixed stuff. - u = self.keysets[proofs[0].id].unit - for i, p in enumerate(proofs): - if self.keysets[p.id].unit != u: - errors.append(DlcBadInput( - index=i, - detail="all the inputs must be of the same denomination" - )) - raise_if_err(errors) - - # Split SCT and non-SCT - # REASONING: the submitter of the registration does not need to dlc lock their proofs - sct_proofs, non_sct_proofs = await self.filter_sct_proofs(proofs) - # Verify spending conditions - for i, p in enumerate(sct_proofs): - # _verify_dlc_input_spending_conditions does not raise any error - # it handles all of them and return either true or false. ALWAYS. - if not self._verify_dlc_input_spending_conditions(dlc_root, p): - errors.append(DlcBadInput( - index=i, - detail="dlc input spending conditions verification failed" - )) - for i, p in enumerate(non_sct_proofs): - valid = False - exc = None - try: - valid = self._verify_input_spending_conditions(p) - except CashuError as e: - exc = e - if not valid: - errors.append(DlcBadInput( - index=i, - detail=exc.detail if exc else "input spending conditions verification failed" - )) - raise_if_err(errors) - - async def get_dlc_fees(self, fa_unit: str) -> Dict[str, int]: try: fees = self.mint_features()[DLC_NUT] @@ -169,99 +22,4 @@ async def get_dlc_fees(self, fa_unit: str) -> Dict[str, int]: assert isinstance(fees, dict) return fees except Exception as e: - raise TransactionError("could not get fees for the specified funding_amount denomination") - - async def _verify_dlc_amount_fees_coverage( - self, - funding_amount: int, - fa_unit: str, - proofs: List[Proof], - ) -> int: - """ - Verifies the sum of the inputs is enough to cover - the funding amount + fees - - Args: - funding_amount (int): funding amount of the contract - fa_unit (str): ONE OF ('sat', 'msat', 'eur', 'usd', 'btc'). The unit in which funding_amount - should be evaluated. - proofs: (List[Proof]): proofs to be verified - - Returns: - (int): amount provided by the proofs - - Raises: - TransactionError - - """ - u = self.keysets[proofs[0].id].unit - # Verify registration's funding_amount unit is the same as the proofs - if Unit[fa_unit] != u: - raise TransactionError("funding amount unit is not the same as the proofs") - fees = await self.get_dlc_fees(fa_unit) - amount_provided = sum([p.amount for p in proofs]) - amount_needed = funding_amount + fees['base'] + (funding_amount * fees['ppk'] // 1000) - if amount_provided < amount_needed: - raise TransactionError("funds provided do not cover the DLC funding amount") - return amount_provided - - async def _verify_dlc_amount_threshold(self, funding_amount: int, proofs: List[Proof]): - """For every SCT proof verify that secret's threshold is less or equal to - the funding_amount - """ - sct_proofs, _ = await self.filter_sct_proofs(proofs) - sct_secrets = [Secret.deserialize(p.secret) for p in sct_proofs] - if not all([int(s.tags.get_tag('threshold')) <= funding_amount for s in sct_secrets]): - raise TransactionError("Some inputs' funding thresholds were not met") - - # UNFINISHED - async def register_dlc(self, request: PostDlcRegistrationRequest): - logger.trace("register called") - is_atomic = request.atomic - funded: List[Tuple[DiscreteLogContract, DlcFundingProof]] = [] - errors: List[DlcFundingProof] = [] - for registration in request.registrations: - try: - logger.trace(f"processing registration {registration.dlc_root}") - assert registration.inputs is not None # mypy give me a break - await self._verify_dlc_inputs(registration.dlc_root, registration.inputs) - amount_provided = await self._verify_dlc_amount_fees_coverage( - registration.funding_amount, - registration.unit, - registration.inputs - ) - await self._verify_dlc_amount_threshold(amount_provided, registration.inputs) - # At this point we can put this dlc into the funded list and create a signature for it - # We use the funding proof private key - ''' - signature = sign_dlc(registration.dlc_root, self.funding_proof_private_key) - funding_proof = DlcFundingProof( - dlc_root=registration.dlc_root, - signature=signature.hex() - ) - dlc = DiscreteLogContract( - settled=False, - dlc_root=registration.dlc_root, - funding_amount=amount_provided, - unit=registration.unit, - ) - funded.append((dlc, funding_proof)) - ''' - except (TransactionError, DlcVerificationFail) as e: - logger.error(f"registration {registration.dlc_root} failed") - # Generic Error - if isinstance(e, TransactionError): - errors.append(DlcFundingProof( - dlc_root=registration.dlc_root, - bad_inputs=[DlcBadInput( - index=-1, - detail=e.detail - )] - )) - # DLC verification fail - else: - errors.append(DlcFundingProof( - dlc_root=registration.dlc_root, - bad_inputs=e.bad_inputs, - )) - + raise TransactionError("could not get fees for the specified funding_amount denomination") \ No newline at end of file diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 434cbb93..94d04145 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -20,8 +20,13 @@ ProofSpentState, ProofState, Unit, + DlcBadInput, + DlcFundingProof, + DLCWitness, + DiscreteLogContract ) from ..core.crypto import b_dhke +from ..core.crypto.dlc import sign_dlc from ..core.crypto.aes import AESCipher from ..core.crypto.keys import ( derive_pubkey, @@ -37,12 +42,14 @@ NotAllowedError, QuoteNotPaidError, TransactionError, + DlcVerificationFail, ) from ..core.helpers import sum_proofs from ..core.models import ( PostMeltQuoteRequest, PostMeltQuoteResponse, PostMintQuoteRequest, + PostDlcRegistrationRequest, ) from ..core.settings import settings from ..core.split import amount_split @@ -1086,3 +1093,53 @@ async def _generate_promises( ) signatures.append(signature) return signatures + + async def register_dlc(self, request: PostDlcRegistrationRequest): + logger.trace("register called") + is_atomic = request.atomic + funded: List[Tuple[DiscreteLogContract, DlcFundingProof]] = [] + errors: List[DlcFundingProof] = [] + for registration in request.registrations: + try: + logger.trace(f"processing registration {registration.dlc_root}") + assert registration.inputs is not None # mypy give me a break + await self._verify_dlc_inputs(registration.dlc_root, registration.inputs) + amount_provided = await self._verify_dlc_amount_fees_coverage( + registration.funding_amount, + registration.unit, + registration.inputs + ) + await self._verify_dlc_amount_threshold(amount_provided, registration.inputs) + # At this point we can put this dlc into the funded list and create a signature for it + # We use the funding proof private key + ''' + signature = dlc.sign_dlc(registration.dlc_root, self.funding_proof_private_key) + funding_proof = DlcFundingProof( + dlc_root=registration.dlc_root, + signature=signature.hex() + ) + dlc = DiscreteLogContract( + settled=False, + dlc_root=registration.dlc_root, + funding_amount=amount_provided, + unit=registration.unit, + ) + funded.append((dlc, funding_proof)) + ''' + except (TransactionError, DlcVerificationFail) as e: + logger.error(f"registration {registration.dlc_root} failed") + # Generic Error + if isinstance(e, TransactionError): + errors.append(DlcFundingProof( + dlc_root=registration.dlc_root, + bad_inputs=[DlcBadInput( + index=-1, + detail=e.detail + )] + )) + # DLC verification fail + else: + errors.append(DlcFundingProof( + dlc_root=registration.dlc_root, + bad_inputs=e.bad_inputs, + )) \ No newline at end of file diff --git a/cashu/mint/verification.py b/cashu/mint/verification.py index 429d2ee3..c24c229d 100644 --- a/cashu/mint/verification.py +++ b/cashu/mint/verification.py @@ -1,6 +1,7 @@ from typing import Dict, List, Literal, Optional, Tuple, Union from loguru import logger +from hashlib import sha256 from ..core.base import ( BlindedMessage, @@ -9,16 +10,21 @@ MintKeyset, Proof, Unit, + DlcBadInput, + DLCWitness, ) from ..core.crypto import b_dhke +from ..core.crypto.dlc import merkle_verify from ..core.crypto.secp import PublicKey from ..core.db import Connection, Database from ..core.errors import ( + CashuError, NoSecretInProofsError, NotAllowedError, SecretTooLongError, TransactionError, TransactionUnitError, + DlcVerificationFail, ) from ..core.settings import settings from ..lightning.base import LightningBackend @@ -27,10 +33,10 @@ from .db.read import DbReadHelper from .db.write import DbWriteHelper from .protocols import SupportsBackends, SupportsDb, SupportsKeysets - - +from .dlc import LedgerDLC +from ..core.secret import Secret, SecretKind class LedgerVerification( - LedgerSpendingConditions, SupportsKeysets, SupportsDb, SupportsBackends + LedgerSpendingConditions, LedgerDLC, SupportsKeysets, SupportsDb, SupportsBackends ): """Verification functions for the ledger.""" @@ -277,3 +283,178 @@ def _verify_and_get_unit_method( ) return unit, method + + def _verify_dlc_input_spending_conditions(self, dlc_root: str, p: Proof) -> bool: + if not p.witness: + return False + try: + witness = DLCWitness.from_witness(p.witness) + leaf_secret = Secret.deserialize(witness.leaf_secret) + secret = Secret.deserialize(p.secret) + except Exception as e: + return False + # Verify leaf_secret is of kind DLC + if leaf_secret.kind != SecretKind.DLC.value: + return False + # Verify dlc_root is the one referenced in the secret + if leaf_secret.data != dlc_root: + return False + # Verify inclusion of leaf_secret in the SCT root hash + leaf_hash_bytes = sha256(witness.leaf_secret.encode()).digest() + merkle_proof_bytes = [bytes.fromhex(m) for m in witness.merkle_proof] + sct_root_hash_bytes = bytes.fromhex(secret.data) + if not merkle_verify(sct_root_hash_bytes, leaf_hash_bytes, merkle_proof_bytes): + return False + + return True + + async def _verify_dlc_amount_fees_coverage( + self, + funding_amount: int, + fa_unit: str, + proofs: List[Proof], + ) -> int: + """ + Verifies the sum of the inputs is enough to cover + the funding amount + fees + + Args: + funding_amount (int): funding amount of the contract + fa_unit (str): ONE OF ('sat', 'msat', 'eur', 'usd', 'btc'). The unit in which funding_amount + should be evaluated. + proofs: (List[Proof]): proofs to be verified + + Returns: + (int): amount provided by the proofs + + Raises: + TransactionError + + """ + u = self.keysets[proofs[0].id].unit + # Verify registration's funding_amount unit is the same as the proofs + if Unit[fa_unit] != u: + raise TransactionError("funding amount unit is not the same as the proofs") + fees = await self.get_dlc_fees(fa_unit) + amount_provided = sum([p.amount for p in proofs]) + amount_needed = funding_amount + fees['base'] + (funding_amount * fees['ppk'] // 1000) + if amount_provided < amount_needed: + raise TransactionError("funds provided do not cover the DLC funding amount") + return amount_provided + + async def _verify_dlc_amount_threshold(self, funding_amount: int, proofs: List[Proof]): + """For every SCT proof verify that secret's threshold is less or equal to + the funding_amount + """ + sct_proofs, _ = await self.filter_sct_proofs(proofs) + sct_secrets = [Secret.deserialize(p.secret) for p in sct_proofs] + if not all([int(s.tags.get_tag('threshold')) <= funding_amount for s in sct_secrets]): + raise TransactionError("Some inputs' funding thresholds were not met") + + async def _verify_dlc_inputs( + self, + dlc_root: str, + proofs: List[Proof], + ): + """ + Verifies all inputs to the DLC + + Args: + dlc_root (hex str): root of the DLC contract + proofs: (List[Proof]): proofs to be verified + + Raises: + DlcVerificationFail + """ + # After we have collected all of the errors + # We use this to raise a DlcVerificationFail + def raise_if_err(err): + if len(err) > 0: + logger.error("Failed to verify DLC inputs") + raise DlcVerificationFail(bad_inputs=err) + + # We cannot just raise an exception if one proof fails and call it a day + # for every proof we need to collect its index and motivation of failure + # and report them + + # Verify inputs + if not proofs: + raise TransactionError("no proofs provided.") + + errors = [] + # Verify amounts of inputs + for i, p in enumerate(proofs): + try: + self._verify_amount(p.amount) + except NotAllowedError as e: + errors.append(DlcBadInput( + index=i, + detail=e.detail + )) + raise_if_err(errors) + + # Verify secret criteria + for i, p in enumerate(proofs): + try: + self._verify_secret_criteria(p) + except (SecretTooLongError, NoSecretInProofsError) as e: + errors.append(DlcBadInput( + index=i, + detail=e.detail + )) + raise_if_err(errors) + + # verify that only unique proofs were used + if not self._verify_no_duplicate_proofs(proofs): + raise TransactionError("duplicate proofs.") + + # Verify ecash signatures + for i, p in enumerate(proofs): + valid = False + exc = None + try: + # _verify_proof_bdhke can also raise an AssertionError... + assert self._verify_proof_bdhke(p), "invalid e-cash signature" + except AssertionError as e: + errors.append(DlcBadInput( + index=i, + detail=str(e) + )) + raise_if_err(errors) + + # Verify proofs of the same denomination + # REASONING: proofs could be usd, eur. We don't want mixed stuff. + u = self.keysets[proofs[0].id].unit + for i, p in enumerate(proofs): + if self.keysets[p.id].unit != u: + errors.append(DlcBadInput( + index=i, + detail="all the inputs must be of the same denomination" + )) + raise_if_err(errors) + + # Split SCT and non-SCT + # REASONING: the submitter of the registration does not need to dlc lock their proofs + sct_proofs, non_sct_proofs = await self.filter_sct_proofs(proofs) + # Verify spending conditions + for i, p in enumerate(sct_proofs): + # _verify_dlc_input_spending_conditions does not raise any error + # it handles all of them and return either true or false. ALWAYS. + if not self._verify_dlc_input_spending_conditions(dlc_root, p): + errors.append(DlcBadInput( + index=i, + detail="dlc input spending conditions verification failed" + )) + for i, p in enumerate(non_sct_proofs): + valid = False + exc = None + try: + valid = self._verify_input_spending_conditions(p) + except CashuError as e: + exc = e + if not valid: + errors.append(DlcBadInput( + index=i, + detail=exc.detail if exc else "input spending conditions verification failed" + )) + raise_if_err(errors) From 4ce0b7bd62ec4232efa102438169b11d8edbca4c Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Mon, 29 Jul 2024 22:34:03 +0200 Subject: [PATCH 29/68] funding proof signature fix --- cashu/core/crypto/dlc.py | 33 +++++++++++++++++++++++++++------ cashu/mint/ledger.py | 7 ++++++- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/cashu/core/crypto/dlc.py b/cashu/core/crypto/dlc.py index 6465a473..4b2b9da4 100644 --- a/cashu/core/crypto/dlc.py +++ b/cashu/core/crypto/dlc.py @@ -62,10 +62,31 @@ def merkle_verify(root: bytes, leaf_hash: bytes, proof: List[bytes]) -> bool: def list_hash(leaves: List[str]) -> List[bytes]: return [sha256(leaf.encode()).digest() for leaf in leaves] -def sign_dlc(dlc_root: str, privkey: PrivateKey) -> bytes: - dlc_root_hash = sha256(bytes.fromhex(dlc_root)).digest() - return privkey.schnorr_sign(dlc_root_hash, None, raw=True) +def sign_dlc( + dlc_root: str, + funding_amount: int, + fa_unit: str, + privkey: PrivateKey, +) -> bytes: + message = ( + bytes.fromhex(dlc_root) + +str(funding_amount).encode("utf-8") + +fa_unit.encode("utf-8") + ) + message_hash = sha256(message).digest() + return privkey.schnorr_sign(message_hash, None, raw=True) -def verify_dlc_signature(dlc_root: str, signature: bytes, pubkey: PublicKey) -> bool: - dlc_root_hash = sha256(bytes.fromhex(dlc_root)).digest() - return pubkey.schnorr_verify(dlc_root_hash, signature, None, raw=True) \ No newline at end of file +def verify_dlc_signature( + dlc_root: str, + funding_amount: int, + fa_unit: str, + signature: bytes, + pubkey: PublicKey, +) -> bool: + message = ( + bytes.fromhex(dlc_root) + +str(funding_amount).encode("utf-8") + +fa_unit.encode("utf-8") + ) + message_hash = sha256(message).digest() + return pubkey.schnorr_verify(message_hash, signature, None, raw=True) \ No newline at end of file diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 94d04145..0ab962da 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -1113,7 +1113,12 @@ async def register_dlc(self, request: PostDlcRegistrationRequest): # At this point we can put this dlc into the funded list and create a signature for it # We use the funding proof private key ''' - signature = dlc.sign_dlc(registration.dlc_root, self.funding_proof_private_key) + signature = sign_dlc( + registration.dlc_root, + registration.funding_amount, + registration.unit, + self.funding_proof_private_key + ) funding_proof = DlcFundingProof( dlc_root=registration.dlc_root, signature=signature.hex() From a5b147bc13dfedda6e21cdf1341d33f04c8090af Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Tue, 30 Jul 2024 12:20:51 +0200 Subject: [PATCH 30/68] Database shenanigans --- cashu/core/errors.py | 7 ++++++ cashu/mint/crud.py | 52 ++++++++++++++++++++++++++++++++++++++++ cashu/mint/db/read.py | 9 ++++++- cashu/mint/db/write.py | 51 ++++++++++++++++++++++++++++++++++++++- cashu/mint/ledger.py | 32 ++++++++++++++++++------- cashu/mint/migrations.py | 1 + 6 files changed, 142 insertions(+), 10 deletions(-) diff --git a/cashu/core/errors.py b/cashu/core/errors.py index 936bbe47..1d4701c4 100644 --- a/cashu/core/errors.py +++ b/cashu/core/errors.py @@ -106,4 +106,11 @@ class DlcVerificationFail(CashuError): def __init__(self, **kwargs): super().__init__(self.detail, self.code) self.bad_inputs = kwargs['bad_inputs'] + +class DlcAlreadyRegisteredError(CashuError): + detail = "dlc already registered" + code = 30001 + + def __init__(self, **kwargs): + super().__init__(self.detail, self.code) \ No newline at end of file diff --git a/cashu/mint/crud.py b/cashu/mint/crud.py index 0ef4af9d..9566b37e 100644 --- a/cashu/mint/crud.py +++ b/cashu/mint/crud.py @@ -1,6 +1,7 @@ import json from abc import ABC, abstractmethod from typing import Any, Dict, List, Optional +from .base import DiscreteLogContract from ..core.base import ( BlindedSignature, @@ -243,6 +244,23 @@ async def update_melt_quote( ) -> None: ... + @abstractmethod + async def get_registered_dlc( + self, + dlc_root: str, + db: Database, + conn: Optional[Connection] = None, + ) -> DiscreteLogContract: + ... + + @abstractmethod + async def store_dlc( + self, + dlc: DiscreteLogContract, + db: Database, + conn: Optional[Connection] = None, + ) -> None: + ... class LedgerCrudSqlite(LedgerCrud): """Implementation of LedgerCrud for sqlite. @@ -741,3 +759,37 @@ async def get_proofs_used( values = {f"y_{i}": Ys[i] for i in range(len(Ys))} rows = await (conn or db).fetchall(query, values) return [Proof(**r) for r in rows] if rows else [] + + async def get_registered_dlc( + self, + dlc_root: str, + db: Database, + conn: Optional[Connection] = None, + ) -> Optional[DiscreteLogContract]: + query = f""" + SELECT * from {db.table_with_schema('dlc')} + WHERE dlc_root = :dlc_root + """ + result = await (conn or db).fetchone(query, {"dlc_root": dlc_root}) + return result + + async def store_dlc( + self, + dlc: DiscreteLogContract, + db: Database, + conn: Optional[Connection] = None, + ) -> None: + query = f""" + INSERT INTO {db.table_with_schema('dlc')} + (dlc_root, settled, funding_amount, unit) + VALUES (:dlc_root, :settled, :funding_amount, :unit) + """ + await (conn or db).execute( + query, + { + "dlc_root": dlc.dlc_root, + "settled": dlc.settled, + "funding_amount": dlc.funding_amount, + "unit": dlc.unit, + }, + ) diff --git a/cashu/mint/db/read.py b/cashu/mint/db/read.py index f3231780..6b77d05e 100644 --- a/cashu/mint/db/read.py +++ b/cashu/mint/db/read.py @@ -2,7 +2,7 @@ from ...core.base import Proof, ProofSpentState, ProofState from ...core.db import Connection, Database -from ...core.errors import TokenAlreadySpentError +from ...core.errors import TokenAlreadySpentError, DlcAlreadyRegisteredError from ..crud import LedgerCrud @@ -91,3 +91,10 @@ async def _verify_proofs_spendable( async with self.db.get_connection(conn) as conn: if not len(await self._get_proofs_spent([p.Y for p in proofs], conn)) == 0: raise TokenAlreadySpentError() + + async def _verify_dlc_registrable( + self, dlc_root: str, conn: Optional[Connection] = None, + ): + async with self.db.get_connection(conn) as conn: + if await self.crud.get_registered_dlc(dlc_root, self.db, conn) is not None: + raise DlcAlreadyRegisteredError() \ No newline at end of file diff --git a/cashu/mint/db/write.py b/cashu/mint/db/write.py index 242e659d..6a1221bc 100644 --- a/cashu/mint/db/write.py +++ b/cashu/mint/db/write.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Union +from typing import List, Optional, Union, Tuple from loguru import logger @@ -10,10 +10,15 @@ Proof, ProofSpentState, ProofState, + DiscreteLogContract, + DlcFundingProof, + DlcBadInput, ) from ...core.db import Connection, Database from ...core.errors import ( TransactionError, + TokenAlreadySpentError, + DlcAlreadyRegisteredError, ) from ..crud import LedgerCrud from ..events.events import LedgerEventManager @@ -223,3 +228,47 @@ async def _unset_melt_quote_pending( await self.events.submit(quote_copy) return quote_copy + + async def _verify_proofs_and_dlc_registrations( + self, + registrations: List[Tuple[DiscreteLogContract, DlcFundingProof]], + is_atomic: bool, + ) -> Tuple[List[Tuple[DiscreteLogContract, DlcFundingProof]], List[DlcFundingProof]]: + ok: List[Tuple[DiscreteLogContract, DlcFundingProof]] = [] + errors: List[DlcFundingProof]= [] + logger.trace("_verify_proofs_and_dlc_registrations acquiring lock") + async with self.db.get_connection(lock_table="proofs_used") as conn: + for registration in registrations: + reg = registration[0] + logger.trace("checking whether proofs are already spent") + try: + assert reg.inputs + await self.db_read._verify_proofs_spendable(reg.inputs, conn) + await self.db_read._verify_dlc_registrable(reg.dlc_root, conn) + ok.append(registration) + except (TokenAlreadySpentError, DlcAlreadyRegisteredError) as e: + logger.trace(f"Proofs already spent for registration {reg.dlc_root}") + errors.append(DlcFundingProof( + dlc_root=reg.dlc_root, + bad_inputs=[DlcBadInput( + index=-1, + detail=e.detail + )] + )) + + # Do not continue if errors on atomic + if is_atomic and len(errors) > 0: + return (ok, errors) + + for registration in ok: + reg = registration[0] + assert reg.inputs + for p in reg.inputs: + logger.trace(f"Invalidating proof {p.Y}") + await self.crud.invalidate_proof( + proof=p, db=self.db, conn=conn + ) + logger.trace(f"Registering DLC {reg.dlc_root}") + await self.crud.store_dlc(reg, self.db, conn) + logger.trace("_verify_proofs_and_dlc_registrations lock released") + return (ok, errors) \ No newline at end of file diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 0ab962da..afa321c5 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -50,6 +50,7 @@ PostMeltQuoteResponse, PostMintQuoteRequest, PostDlcRegistrationRequest, + PostDlcRegistrationResponse, ) from ..core.settings import settings from ..core.split import amount_split @@ -1094,9 +1095,9 @@ async def _generate_promises( signatures.append(signature) return signatures - async def register_dlc(self, request: PostDlcRegistrationRequest): + async def register_dlc(self, request: PostDlcRegistrationRequest) -> PostDlcRegistrationResponse: logger.trace("register called") - is_atomic = request.atomic + is_atomic = request.atomic or False funded: List[Tuple[DiscreteLogContract, DlcFundingProof]] = [] errors: List[DlcFundingProof] = [] for registration in request.registrations: @@ -1114,11 +1115,11 @@ async def register_dlc(self, request: PostDlcRegistrationRequest): # We use the funding proof private key ''' signature = sign_dlc( - registration.dlc_root, - registration.funding_amount, - registration.unit, - self.funding_proof_private_key - ) + registration.dlc_root, + registration.funding_amount, + registration.unit, + self.funding_proof_private_key + ) funding_proof = DlcFundingProof( dlc_root=registration.dlc_root, signature=signature.hex() @@ -1127,6 +1128,7 @@ async def register_dlc(self, request: PostDlcRegistrationRequest): settled=False, dlc_root=registration.dlc_root, funding_amount=amount_provided, + inputs=registration.inputs, unit=registration.unit, ) funded.append((dlc, funding_proof)) @@ -1147,4 +1149,18 @@ async def register_dlc(self, request: PostDlcRegistrationRequest): errors.append(DlcFundingProof( dlc_root=registration.dlc_root, bad_inputs=e.bad_inputs, - )) \ No newline at end of file + )) + # If `atomic` register and there are errors, abort + if is_atomic and len(errors) > 0: + return PostDlcRegistrationResponse(errors=errors) + # Database dance: + funded, db_errors = await self.db_write._verify_proofs_and_dlc_registrations(funded, is_atomic) + errors += db_errors + if is_atomic and len(errors) > 0: + return PostDlcRegistrationResponse(errors=errors) + + # ALL OK + return PostDlcRegistrationResponse( + funded=[f[1] for f in funded], + errors=errors if len(errors) > 0 else None, + ) \ No newline at end of file diff --git a/cashu/mint/migrations.py b/cashu/mint/migrations.py index 3143690a..74cbadfb 100644 --- a/cashu/mint/migrations.py +++ b/cashu/mint/migrations.py @@ -834,6 +834,7 @@ async def m022_add_dlc_table(db: Database): dlc_root TEXT NOT NULL, settled BOOL NOT NULL DEFAULT FALSE, funding_amount {db.big_int} NOT NULL, + unit TEXT NOT NULL, debts TEXT, UNIQUE (dlc_root), From 1dd7abf6faa595461b5b9510a49e4dbbd4f75518 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Tue, 30 Jul 2024 12:25:05 +0200 Subject: [PATCH 31/68] error fix --- cashu/mint/crud.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cashu/mint/crud.py b/cashu/mint/crud.py index 9566b37e..b27245e6 100644 --- a/cashu/mint/crud.py +++ b/cashu/mint/crud.py @@ -1,7 +1,7 @@ import json from abc import ABC, abstractmethod from typing import Any, Dict, List, Optional -from .base import DiscreteLogContract +from ..core.base import DiscreteLogContract from ..core.base import ( BlindedSignature, @@ -250,7 +250,7 @@ async def get_registered_dlc( dlc_root: str, db: Database, conn: Optional[Connection] = None, - ) -> DiscreteLogContract: + ) -> Optional[DiscreteLogContract]: ... @abstractmethod From a01a77f97e731c68384dae23d690ac24f2bf1a48 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Wed, 31 Jul 2024 09:17:04 +0200 Subject: [PATCH 32/68] Removed `is_atomic`, `sign_dlc` with the first key of the active keyset for the relevant unit, `sign_dlc` does not include the unit in the hash, connected `register_dlc` to the router. --- cashu/core/crypto/dlc.py | 4 --- cashu/core/models.py | 1 - cashu/mint/db/write.py | 54 +++++++++++++++++++++++++++------------- cashu/mint/ledger.py | 36 ++++++++++++++++----------- cashu/mint/router.py | 17 +++++++++++++ 5 files changed, 75 insertions(+), 37 deletions(-) diff --git a/cashu/core/crypto/dlc.py b/cashu/core/crypto/dlc.py index 4b2b9da4..20adca77 100644 --- a/cashu/core/crypto/dlc.py +++ b/cashu/core/crypto/dlc.py @@ -65,13 +65,11 @@ def list_hash(leaves: List[str]) -> List[bytes]: def sign_dlc( dlc_root: str, funding_amount: int, - fa_unit: str, privkey: PrivateKey, ) -> bytes: message = ( bytes.fromhex(dlc_root) +str(funding_amount).encode("utf-8") - +fa_unit.encode("utf-8") ) message_hash = sha256(message).digest() return privkey.schnorr_sign(message_hash, None, raw=True) @@ -79,14 +77,12 @@ def sign_dlc( def verify_dlc_signature( dlc_root: str, funding_amount: int, - fa_unit: str, signature: bytes, pubkey: PublicKey, ) -> bool: message = ( bytes.fromhex(dlc_root) +str(funding_amount).encode("utf-8") - +fa_unit.encode("utf-8") ) message_hash = sha256(message).digest() return pubkey.schnorr_verify(message_hash, signature, None, raw=True) \ No newline at end of file diff --git a/cashu/core/models.py b/cashu/core/models.py index ccac6f01..b6b2808f 100644 --- a/cashu/core/models.py +++ b/cashu/core/models.py @@ -334,7 +334,6 @@ def __init__(self, **data): # ------- API: DLC REGISTRATION ------- class PostDlcRegistrationRequest(BaseModel): - atomic: Optional[bool] registrations: List[DiscreteLogContract] class PostDlcRegistrationResponse(BaseModel): diff --git a/cashu/mint/db/write.py b/cashu/mint/db/write.py index 6a1221bc..c8e4a28c 100644 --- a/cashu/mint/db/write.py +++ b/cashu/mint/db/write.py @@ -232,10 +232,22 @@ async def _unset_melt_quote_pending( async def _verify_proofs_and_dlc_registrations( self, registrations: List[Tuple[DiscreteLogContract, DlcFundingProof]], - is_atomic: bool, ) -> Tuple[List[Tuple[DiscreteLogContract, DlcFundingProof]], List[DlcFundingProof]]: - ok: List[Tuple[DiscreteLogContract, DlcFundingProof]] = [] + """ + Method to check if proofs are already spent or registrations already registered. If they are not, we + set them as spent and registered respectively + Args: + registrations (List[Tuple[DiscreteLogContract, DlcFundingProof]]): List of registrations. + Returns: + List[Tuple[DiscreteLogContract, DlcFundingProof]]: a list of registered DLCs + List[DlcFundingProof]: a list of errors + """ + checked: List[Tuple[DiscreteLogContract, DlcFundingProof]] = [] + registered: List[Tuple[DiscreteLogContract, DlcFundingProof]] = [] errors: List[DlcFundingProof]= [] + if len(registrations) == 0: + logger.trace("Received 0 registrations") + return [], [] logger.trace("_verify_proofs_and_dlc_registrations acquiring lock") async with self.db.get_connection(lock_table="proofs_used") as conn: for registration in registrations: @@ -245,7 +257,7 @@ async def _verify_proofs_and_dlc_registrations( assert reg.inputs await self.db_read._verify_proofs_spendable(reg.inputs, conn) await self.db_read._verify_dlc_registrable(reg.dlc_root, conn) - ok.append(registration) + checked.append(registration) except (TokenAlreadySpentError, DlcAlreadyRegisteredError) as e: logger.trace(f"Proofs already spent for registration {reg.dlc_root}") errors.append(DlcFundingProof( @@ -255,20 +267,28 @@ async def _verify_proofs_and_dlc_registrations( detail=e.detail )] )) - - # Do not continue if errors on atomic - if is_atomic and len(errors) > 0: - return (ok, errors) - - for registration in ok: + + for registration in checked: reg = registration[0] assert reg.inputs - for p in reg.inputs: - logger.trace(f"Invalidating proof {p.Y}") - await self.crud.invalidate_proof( - proof=p, db=self.db, conn=conn - ) - logger.trace(f"Registering DLC {reg.dlc_root}") - await self.crud.store_dlc(reg, self.db, conn) + try: + for p in reg.inputs: + logger.trace(f"Invalidating proof {p.Y}") + await self.crud.invalidate_proof( + proof=p, db=self.db, conn=conn + ) + + logger.trace(f"Registering DLC {reg.dlc_root}") + await self.crud.store_dlc(reg, self.db, conn) + registered.append(registration) + except Exception as e: + logger.trace(f"Failed to register {reg.dlc_root}: {str(e)}") + errors.append(DlcFundingProof( + dlc_root=reg.dlc_root, + bad_inputs=[DlcBadInput( + index=-1, + detail=str(e) + )] + )) logger.trace("_verify_proofs_and_dlc_registrations lock released") - return (ok, errors) \ No newline at end of file + return (registered, errors) \ No newline at end of file diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index afa321c5..820d4753 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -1096,8 +1096,13 @@ async def _generate_promises( return signatures async def register_dlc(self, request: PostDlcRegistrationRequest) -> PostDlcRegistrationResponse: + """Validates and registers DiscreteLogContracts + Args: + request (PostDlcRegistrationRequest): a request formatted following NUT-DLC spec + Returns: + PostDlcRegistrationResponse: Indicating the funded and registered DLCs as well as the errors. + """ logger.trace("register called") - is_atomic = request.atomic or False funded: List[Tuple[DiscreteLogContract, DlcFundingProof]] = [] errors: List[DlcFundingProof] = [] for registration in request.registrations: @@ -1111,15 +1116,22 @@ async def register_dlc(self, request: PostDlcRegistrationRequest) -> PostDlcRegi registration.inputs ) await self._verify_dlc_amount_threshold(amount_provided, registration.inputs) + # At this point we can put this dlc into the funded list and create a signature for it - # We use the funding proof private key - ''' + # We use the first key from the active keyset of the unit specified in the contract. + active_keyset_for_unit = next( + filter( + lambda k: k.active and k.unit == Unit[registration.unit], + self.keysets.values() + ) + ) + funding_privkey = next(iter(active_keyset_for_unit.private_keys.values())) signature = sign_dlc( registration.dlc_root, registration.funding_amount, - registration.unit, - self.funding_proof_private_key + funding_privkey, ) + funding_proof = DlcFundingProof( dlc_root=registration.dlc_root, signature=signature.hex() @@ -1132,7 +1144,6 @@ async def register_dlc(self, request: PostDlcRegistrationRequest) -> PostDlcRegi unit=registration.unit, ) funded.append((dlc, funding_proof)) - ''' except (TransactionError, DlcVerificationFail) as e: logger.error(f"registration {registration.dlc_root} failed") # Generic Error @@ -1150,17 +1161,12 @@ async def register_dlc(self, request: PostDlcRegistrationRequest) -> PostDlcRegi dlc_root=registration.dlc_root, bad_inputs=e.bad_inputs, )) - # If `atomic` register and there are errors, abort - if is_atomic and len(errors) > 0: - return PostDlcRegistrationResponse(errors=errors) - # Database dance: - funded, db_errors = await self.db_write._verify_proofs_and_dlc_registrations(funded, is_atomic) + # Database dance + registered, db_errors = await self.db_write._verify_proofs_and_dlc_registrations(funded) errors += db_errors - if is_atomic and len(errors) > 0: - return PostDlcRegistrationResponse(errors=errors) - # ALL OK + # Return funded DLCs and errors return PostDlcRegistrationResponse( - funded=[f[1] for f in funded], + funded=[reg[1] for reg in registered], errors=errors if len(errors) > 0 else None, ) \ No newline at end of file diff --git a/cashu/mint/router.py b/cashu/mint/router.py index 56226f32..f0da8bbe 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -24,6 +24,8 @@ PostRestoreResponse, PostSwapRequest, PostSwapResponse, + PostDlcRegistrationRequest, + PostDlcRegistrationResponse ) from ..core.settings import settings from ..mint.startup import ledger @@ -375,3 +377,18 @@ async def restore(payload: PostRestoreRequest) -> PostRestoreResponse: assert payload.outputs, Exception("no outputs provided.") outputs, signatures = await ledger.restore(payload.outputs) return PostRestoreResponse(outputs=outputs, signatures=signatures) + +@router.post( + "v1/register", + name="Register", + summary="Register a DLC batch", + response_model=PostDlcRegistrationResponse, + response_description=( + "Two lists describing which DLC were registered and which encountered errors respectively." + ) +) +@limiter.limit(f"{settings.mint_transaction_rate_limit_per_minute}/minute") +async def register(request: Request, payload: PostDlcRegistrationRequest) -> PostDlcRegistrationResponse: + logger.trace(f"> POST /v1/register: {payload}") + assert len(payload.registrations) > 0, "No registrations provided" + return await ledger.register_dlc(payload) \ No newline at end of file From 8dc0c71e25d183c9607c8a185f1ece2fe7a28bea Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Wed, 31 Jul 2024 12:38:18 +0200 Subject: [PATCH 33/68] tests on `register_dlc` working --- cashu/mint/dlc.py | 15 +++++- cashu/mint/verification.py | 5 +- tests/test_dlc.py | 96 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 109 insertions(+), 7 deletions(-) diff --git a/cashu/mint/dlc.py b/cashu/mint/dlc.py index 21f5d126..f86dd992 100644 --- a/cashu/mint/dlc.py +++ b/cashu/mint/dlc.py @@ -4,12 +4,23 @@ from ..core.errors import TransactionError from .features import LedgerFeatures +from json.decoder import JSONDecodeError + from typing import List, Tuple, Dict class LedgerDLC(LedgerFeatures): async def filter_sct_proofs(self, proofs: List[Proof]) -> Tuple[List[Proof], List[Proof]]: - sct_proofs = list(filter(lambda p: Secret.deserialize(p.secret).kind == SecretKind.SCT.value, proofs)) - non_sct_proofs = list(filter(lambda p: p not in sct_proofs, proofs)) + deserializable = [] + non_sct_proofs = [] + for p in proofs: + try: + Secret.deserialize(p.secret) + deserializable.append(p) + except JSONDecodeError: + non_sct_proofs.append(p) + + sct_proofs = list(filter(lambda p: Secret.deserialize(p.secret).kind == SecretKind.SCT.value, deserializable)) + non_sct_proofs += list(filter(lambda p: p not in sct_proofs, deserializable)) return (sct_proofs, non_sct_proofs) async def get_dlc_fees(self, fa_unit: str) -> Dict[str, int]: diff --git a/cashu/mint/verification.py b/cashu/mint/verification.py index c24c229d..9f98ced9 100644 --- a/cashu/mint/verification.py +++ b/cashu/mint/verification.py @@ -347,8 +347,9 @@ async def _verify_dlc_amount_threshold(self, funding_amount: int, proofs: List[P the funding_amount """ sct_proofs, _ = await self.filter_sct_proofs(proofs) - sct_secrets = [Secret.deserialize(p.secret) for p in sct_proofs] - if not all([int(s.tags.get_tag('threshold')) <= funding_amount for s in sct_secrets]): + dlc_witnesses = [DLCWitness.from_witness(p.witness or "") for p in sct_proofs] + dlc_secrets = [Secret.deserialize(w.leaf_secret) for w in dlc_witnesses] + if not all([int(s.tags.get_tag('threshold')) <= funding_amount for s in dlc_secrets]): raise TransactionError("Some inputs' funding thresholds were not met") async def _verify_dlc_inputs( diff --git a/tests/test_dlc.py b/tests/test_dlc.py index d962284c..e849cc38 100644 --- a/tests/test_dlc.py +++ b/tests/test_dlc.py @@ -4,7 +4,9 @@ from cashu.wallet.wallet import Wallet from cashu.core.secret import Secret, SecretKind from cashu.core.errors import CashuError -from cashu.core.base import DLCWitness, Proof, TokenV4 +from cashu.core.base import DLCWitness, Proof, TokenV4, Unit, DiscreteLogContract +from cashu.core.models import PostDlcRegistrationRequest, PostDlcRegistrationResponse +from cashu.mint.ledger import Ledger from cashu.wallet.helpers import send from tests.conftest import SERVER_ENDPOINT from hashlib import sha256 @@ -17,7 +19,15 @@ from loguru import logger from typing import Union, List -from cashu.core.crypto.dlc import merkle_root, merkle_verify, sorted_merkle_hash, list_hash +from secp256k1 import PrivateKey +from cashu.core.crypto.dlc import ( + merkle_root, + merkle_verify, + sorted_merkle_hash, + list_hash, + sign_dlc, + verify_dlc_signature, +) @pytest_asyncio.fixture(scope="function") async def wallet(): @@ -79,6 +89,20 @@ async def test_merkle_verify(): root, branch_hashes = merkle_root(leafs, index) assert merkle_verify(root, leafs[index], branch_hashes), "merkle_verify test fail" +@pytest.mark.asyncio +async def test_dlc_signatures(): + dlc_root = sha256("TESTING".encode()).hexdigest() + funding_amount = 1000 + privkey = PrivateKey() + + # sign + signature = sign_dlc(dlc_root, funding_amount, privkey) + # verify + assert( + verify_dlc_signature(dlc_root, funding_amount, signature, privkey.pubkey), + "Could not verify funding proof signature" + ) + @pytest.mark.asyncio async def test_swap_for_dlc_locked(wallet: Wallet): invoice = await wallet.request_mint(64) @@ -245,4 +269,70 @@ async def test_send_funding_token(wallet: Wallet): proofs = deserialized_token.proofs assert all([Secret.deserialize(p.secret).kind == SecretKind.SCT.value for p in proofs]) witnesses = [DLCWitness.from_witness(p.witness) for p in proofs] - assert all([Secret.deserialize(w.leaf_secret).kind == SecretKind.DLC.value for w in witnesses]) \ No newline at end of file + assert all([Secret.deserialize(w.leaf_secret).kind == SecretKind.DLC.value for w in witnesses]) + +@pytest.mark.asyncio +async def test_registration_vanilla_proofs(wallet: Wallet, ledger: Ledger): + invoice = await wallet.request_mint(64) + await pay_if_regtest(invoice.bolt11) + minted = await wallet.mint(64, id=invoice.id) + + # Get public key the mint uses to sign + keysets = await wallet._get_keys() + active_keyset_for_unit = next(filter(lambda k: k.active and k.unit == Unit["sat"], keysets)) + pubkey = next(iter(active_keyset_for_unit.public_keys.values())) + + dlc_root = sha256("TESTING".encode()).hexdigest() + dlc = DiscreteLogContract( + funding_amount=64, + unit="sat", + dlc_root=dlc_root, + inputs=minted, + ) + + request = PostDlcRegistrationRequest(registrations=[dlc]) + response = await ledger.register_dlc(request) + assert len(response.funded) == 1, "Funding proofs len != 1" + + funding_proof = response.funded[0] + assert ( + verify_dlc_signature(dlc_root, 64, bytes.fromhex(funding_proof.signature), pubkey), + "Could not verify funding proof" + ) + +@pytest.mark.asyncio +async def test_registration_dlc_locked_proofs(wallet: Wallet, ledger: Ledger): + invoice = await wallet.request_mint(64) + await pay_if_regtest(invoice.bolt11) + minted = await wallet.mint(64, id=invoice.id) + + # Get locked proofs + dlc_root = sha256("TESTING".encode()).hexdigest() + _, locked = await wallet.split(minted, 64, dlc_data=(dlc_root, 32)) + assert len(_) == 0 + + # Add witnesses to proofs + locked = await wallet.add_sct_witnesses_to_proofs(locked) + + # Get public key the mint uses to sign + keysets = await wallet._get_keys() + active_keyset_for_unit = next(filter(lambda k: k.active and k.unit == Unit["sat"], keysets)) + pubkey = next(iter(active_keyset_for_unit.public_keys.values())) + + dlc = DiscreteLogContract( + funding_amount=64, + unit="sat", + dlc_root=dlc_root, + inputs=locked, + ) + + request = PostDlcRegistrationRequest(registrations=[dlc]) + response = await ledger.register_dlc(request) + assert response.errors is None, f"Funding proofs error: {response.errors[0].bad_inputs}" + assert len(response.funded) == 1, "Funding proofs len != 1" + + funding_proof = response.funded[0] + assert ( + verify_dlc_signature(dlc_root, 64, bytes.fromhex(funding_proof.signature), pubkey), + "Could not verify funding proof" + ) \ No newline at end of file From 10b6e9fa70de8c3e18f9dbfe951fa10918308d24 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Thu, 1 Aug 2024 10:39:39 +0200 Subject: [PATCH 34/68] rename `DiscreteLogContract` to `DiscreetLogContract`, added `status_dlc` --- cashu/core/base.py | 12 ++++++++- cashu/core/errors.py | 8 +++++- cashu/core/models.py | 10 +++---- cashu/mint/crud.py | 16 ++++++----- cashu/mint/db/read.py | 16 +++++++++-- cashu/mint/db/write.py | 14 +++++----- cashu/mint/ledger.py | 32 +++++++++++++++++++--- cashu/mint/router.py | 29 +++++++++++++++----- tests/test_dlc.py | 61 +++++++++++++++++++++++++++++++++++++++--- 9 files changed, 161 insertions(+), 37 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index 5147ddf4..8a68dc9f 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -1212,7 +1212,7 @@ def parse_obj(cls, token_dict: dict): # -------- DLC STUFF -------- -class DiscreteLogContract(BaseModel): +class DiscreetLogContract(BaseModel): """ A discrete log contract """ @@ -1223,6 +1223,16 @@ class DiscreteLogContract(BaseModel): inputs: Optional[List[Proof]] = None # Need to verify these are indeed SCT proofs debts: Optional[Dict[str, int]] = None # We save who we owe money to here + @classmethod + def from_row(cls, row: Row): + return cls( + dlc_root=row["dlc_root"], + settled=bool(row["settled"]), + funding_amount=int(row["funding_amount"]), + unit=row["unit"], + debts=row["debts"] or None, + ) + class DlcBadInput(BaseModel): index: int detail: str diff --git a/cashu/core/errors.py b/cashu/core/errors.py index 1d4701c4..1ec3ddfc 100644 --- a/cashu/core/errors.py +++ b/cashu/core/errors.py @@ -113,4 +113,10 @@ class DlcAlreadyRegisteredError(CashuError): def __init__(self, **kwargs): super().__init__(self.detail, self.code) - \ No newline at end of file + +class DlcNotFoundError(CashuError): + detail = "dlc not found" + code = 30002 + + def __init__(self, **kwargs): + super().__init__(self.detail, self.code) \ No newline at end of file diff --git a/cashu/core/models.py b/cashu/core/models.py index b6b2808f..8acdb71b 100644 --- a/cashu/core/models.py +++ b/cashu/core/models.py @@ -6,7 +6,7 @@ BlindedMessage, BlindedMessage_Deprecated, BlindedSignature, - DiscreteLogContract, + DiscreetLogContract, DlcFundingProof, DlcPayout, DlcPayoutForm, @@ -334,7 +334,7 @@ def __init__(self, **data): # ------- API: DLC REGISTRATION ------- class PostDlcRegistrationRequest(BaseModel): - registrations: List[DiscreteLogContract] + registrations: List[DiscreetLogContract] class PostDlcRegistrationResponse(BaseModel): funded: List[DlcFundingProof] = [] @@ -351,7 +351,6 @@ class PostDlcSettleResponse(BaseModel): # ------- API: DLC PAYOUT ------- class PostDlcPayoutRequest(BaseModel): - atomic: Optional[bool] payouts: List[DlcPayoutForm] class PostDlcPayoutResponse(BaseModel): @@ -362,5 +361,6 @@ class PostDlcPayoutResponse(BaseModel): class GetDlcStatusResponse(BaseModel): settled: bool - funding_amount: Optional[int] - debts: Optional[Dict[str, int]] + unit: Optional[str] = None + funding_amount: Optional[int] = None + debts: Optional[Dict[str, int]] = None diff --git a/cashu/mint/crud.py b/cashu/mint/crud.py index b27245e6..58cf54e2 100644 --- a/cashu/mint/crud.py +++ b/cashu/mint/crud.py @@ -1,7 +1,7 @@ import json from abc import ABC, abstractmethod from typing import Any, Dict, List, Optional -from ..core.base import DiscreteLogContract +from ..core.base import DiscreetLogContract from ..core.base import ( BlindedSignature, @@ -250,13 +250,13 @@ async def get_registered_dlc( dlc_root: str, db: Database, conn: Optional[Connection] = None, - ) -> Optional[DiscreteLogContract]: + ) -> Optional[DiscreetLogContract]: ... @abstractmethod async def store_dlc( self, - dlc: DiscreteLogContract, + dlc: DiscreetLogContract, db: Database, conn: Optional[Connection] = None, ) -> None: @@ -765,17 +765,19 @@ async def get_registered_dlc( dlc_root: str, db: Database, conn: Optional[Connection] = None, - ) -> Optional[DiscreteLogContract]: + ) -> Optional[DiscreetLogContract]: query = f""" SELECT * from {db.table_with_schema('dlc')} WHERE dlc_root = :dlc_root """ - result = await (conn or db).fetchone(query, {"dlc_root": dlc_root}) - return result + row = await (conn or db).fetchone(query, {"dlc_root": dlc_root}) + if not row: + return None + return DiscreetLogContract.from_row(row) async def store_dlc( self, - dlc: DiscreteLogContract, + dlc: DiscreetLogContract, db: Database, conn: Optional[Connection] = None, ) -> None: diff --git a/cashu/mint/db/read.py b/cashu/mint/db/read.py index 6b77d05e..b85cc743 100644 --- a/cashu/mint/db/read.py +++ b/cashu/mint/db/read.py @@ -2,7 +2,11 @@ from ...core.base import Proof, ProofSpentState, ProofState from ...core.db import Connection, Database -from ...core.errors import TokenAlreadySpentError, DlcAlreadyRegisteredError +from ...core.errors import ( + TokenAlreadySpentError, + DlcAlreadyRegisteredError, + DlcNotFoundError, +) from ..crud import LedgerCrud @@ -97,4 +101,12 @@ async def _verify_dlc_registrable( ): async with self.db.get_connection(conn) as conn: if await self.crud.get_registered_dlc(dlc_root, self.db, conn) is not None: - raise DlcAlreadyRegisteredError() \ No newline at end of file + raise DlcAlreadyRegisteredError() + + async def _get_registered_dlc(self, dlc_root: str, conn: Optional[Connection] = None): + async with self.db.get_connection(conn) as conn: + dlc = await self.crud.get_registered_dlc(dlc_root, self.db, conn) + if dlc is None: + raise DlcNotFoundError() + return dlc + \ No newline at end of file diff --git a/cashu/mint/db/write.py b/cashu/mint/db/write.py index c8e4a28c..80b84f92 100644 --- a/cashu/mint/db/write.py +++ b/cashu/mint/db/write.py @@ -10,7 +10,7 @@ Proof, ProofSpentState, ProofState, - DiscreteLogContract, + DiscreetLogContract, DlcFundingProof, DlcBadInput, ) @@ -231,19 +231,19 @@ async def _unset_melt_quote_pending( async def _verify_proofs_and_dlc_registrations( self, - registrations: List[Tuple[DiscreteLogContract, DlcFundingProof]], - ) -> Tuple[List[Tuple[DiscreteLogContract, DlcFundingProof]], List[DlcFundingProof]]: + registrations: List[Tuple[DiscreetLogContract, DlcFundingProof]], + ) -> Tuple[List[Tuple[DiscreetLogContract, DlcFundingProof]], List[DlcFundingProof]]: """ Method to check if proofs are already spent or registrations already registered. If they are not, we set them as spent and registered respectively Args: - registrations (List[Tuple[DiscreteLogContract, DlcFundingProof]]): List of registrations. + registrations (List[Tuple[DiscreetLogContract, DlcFundingProof]]): List of registrations. Returns: - List[Tuple[DiscreteLogContract, DlcFundingProof]]: a list of registered DLCs + List[Tuple[DiscreetLogContract, DlcFundingProof]]: a list of registered DLCs List[DlcFundingProof]: a list of errors """ - checked: List[Tuple[DiscreteLogContract, DlcFundingProof]] = [] - registered: List[Tuple[DiscreteLogContract, DlcFundingProof]] = [] + checked: List[Tuple[DiscreetLogContract, DlcFundingProof]] = [] + registered: List[Tuple[DiscreetLogContract, DlcFundingProof]] = [] errors: List[DlcFundingProof]= [] if len(registrations) == 0: logger.trace("Received 0 registrations") diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 820d4753..6539695a 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -23,7 +23,7 @@ DlcBadInput, DlcFundingProof, DLCWitness, - DiscreteLogContract + DiscreetLogContract ) from ..core.crypto import b_dhke from ..core.crypto.dlc import sign_dlc @@ -51,6 +51,7 @@ PostMintQuoteRequest, PostDlcRegistrationRequest, PostDlcRegistrationResponse, + GetDlcStatusResponse, ) from ..core.settings import settings from ..core.split import amount_split @@ -1095,6 +1096,31 @@ async def _generate_promises( signatures.append(signature) return signatures + async def status_dlc(self, dlc_root: str) -> GetDlcStatusResponse: + """Gets the status of a particular DLC + + Args: + dlc_root (str): the root hash of the contract + Returns: + GetDlcStatusResponse: a response containing the status of the DLC, if it was found. + Raises: + DlcNotFoundError: no DLC with dlc_root was found + """ + logger.trace("status_dlc called") + dlc = await self.db_read._get_registered_dlc(dlc_root) + if not dlc.settled: + return GetDlcStatusResponse( + settled=dlc.settled, + funding_amount=dlc.funding_amount, + unit=dlc.unit, + debts=None + ) + else: + return GetDlcStatusResponse( + settled=dlc.settled, + debts=dlc.debts, + ) + async def register_dlc(self, request: PostDlcRegistrationRequest) -> PostDlcRegistrationResponse: """Validates and registers DiscreteLogContracts Args: @@ -1103,7 +1129,7 @@ async def register_dlc(self, request: PostDlcRegistrationRequest) -> PostDlcRegi PostDlcRegistrationResponse: Indicating the funded and registered DLCs as well as the errors. """ logger.trace("register called") - funded: List[Tuple[DiscreteLogContract, DlcFundingProof]] = [] + funded: List[Tuple[DiscreetLogContract, DlcFundingProof]] = [] errors: List[DlcFundingProof] = [] for registration in request.registrations: try: @@ -1136,7 +1162,7 @@ async def register_dlc(self, request: PostDlcRegistrationRequest) -> PostDlcRegi dlc_root=registration.dlc_root, signature=signature.hex() ) - dlc = DiscreteLogContract( + dlc = DiscreetLogContract( settled=False, dlc_root=registration.dlc_root, funding_amount=amount_provided, diff --git a/cashu/mint/router.py b/cashu/mint/router.py index f0da8bbe..db4434a3 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -25,7 +25,8 @@ PostSwapRequest, PostSwapResponse, PostDlcRegistrationRequest, - PostDlcRegistrationResponse + PostDlcRegistrationResponse, + GetDlcStatusResponse, ) from ..core.settings import settings from ..mint.startup import ledger @@ -379,16 +380,30 @@ async def restore(payload: PostRestoreRequest) -> PostRestoreResponse: return PostRestoreResponse(outputs=outputs, signatures=signatures) @router.post( - "v1/register", - name="Register", - summary="Register a DLC batch", + "v1/dlc/fund", + name="Fund", + summary="Register and fund a DLC batch", response_model=PostDlcRegistrationResponse, response_description=( "Two lists describing which DLC were registered and which encountered errors respectively." ) ) @limiter.limit(f"{settings.mint_transaction_rate_limit_per_minute}/minute") -async def register(request: Request, payload: PostDlcRegistrationRequest) -> PostDlcRegistrationResponse: - logger.trace(f"> POST /v1/register: {payload}") +async def dlc_fund(request: Request, payload: PostDlcRegistrationRequest) -> PostDlcRegistrationResponse: + logger.trace(f"> POST /v1/dlc/fund: {payload}") assert len(payload.registrations) > 0, "No registrations provided" - return await ledger.register_dlc(payload) \ No newline at end of file + return await ledger.register_dlc(payload) + +@router.get( + "v1/dlc/status/{dlc_root}", + name="", + summary="Register a DLC batch", + response_model=GetDlcStatusResponse, + response_description=( + "Two lists describing which DLC were registered and which encountered errors respectively." + ) +) +@limiter.limit(f"{settings.mint_transaction_rate_limit_per_minute}/minute") +async def dlc_status(request: Request, dlc_root: str) -> GetDlcStatusResponse: + logger.trace(f"> GET /v1/dlc/status/{dlc_root}") + return await ledger.status_dlc(dlc_root) \ No newline at end of file diff --git a/tests/test_dlc.py b/tests/test_dlc.py index e849cc38..08b8439a 100644 --- a/tests/test_dlc.py +++ b/tests/test_dlc.py @@ -4,7 +4,7 @@ from cashu.wallet.wallet import Wallet from cashu.core.secret import Secret, SecretKind from cashu.core.errors import CashuError -from cashu.core.base import DLCWitness, Proof, TokenV4, Unit, DiscreteLogContract +from cashu.core.base import DLCWitness, Proof, TokenV4, Unit, DiscreetLogContract from cashu.core.models import PostDlcRegistrationRequest, PostDlcRegistrationResponse from cashu.mint.ledger import Ledger from cashu.wallet.helpers import send @@ -283,7 +283,7 @@ async def test_registration_vanilla_proofs(wallet: Wallet, ledger: Ledger): pubkey = next(iter(active_keyset_for_unit.public_keys.values())) dlc_root = sha256("TESTING".encode()).hexdigest() - dlc = DiscreteLogContract( + dlc = DiscreetLogContract( funding_amount=64, unit="sat", dlc_root=dlc_root, @@ -319,7 +319,7 @@ async def test_registration_dlc_locked_proofs(wallet: Wallet, ledger: Ledger): active_keyset_for_unit = next(filter(lambda k: k.active and k.unit == Unit["sat"], keysets)) pubkey = next(iter(active_keyset_for_unit.public_keys.values())) - dlc = DiscreteLogContract( + dlc = DiscreetLogContract( funding_amount=64, unit="sat", dlc_root=dlc_root, @@ -335,4 +335,57 @@ async def test_registration_dlc_locked_proofs(wallet: Wallet, ledger: Ledger): assert ( verify_dlc_signature(dlc_root, 64, bytes.fromhex(funding_proof.signature), pubkey), "Could not verify funding proof" - ) \ No newline at end of file + ) + +@pytest.mark.asyncio +async def test_fund_same_dlc_twice(wallet: Wallet, ledger: Ledger): + invoice = await wallet.request_mint(128) + await pay_if_regtest(invoice.bolt11) + minted = await wallet.mint(128, id=invoice.id) + + dlc_root = sha256("TESTING".encode()).hexdigest() + proofs2, proofs1 = await wallet.split(minted, 64) + + dlc1 = DiscreetLogContract( + funding_amount=64, + unit="sat", + dlc_root=dlc_root, + inputs=proofs1, + ) + dlc2 = DiscreetLogContract( + funding_amount=64, + unit="sat", + dlc_root=dlc_root, + inputs=proofs2, + ) + request = PostDlcRegistrationRequest(registrations=[dlc1]) + response = await ledger.register_dlc(request) + assert response.errors is None, f"Funding proofs error: {response.errors[0].bad_inputs}" + request = PostDlcRegistrationRequest(registrations=[dlc2]) + response = await ledger.register_dlc(request) + assert response.errors and response.errors[0].bad_inputs[0].detail == "dlc already registered" + +@pytest.mark.asyncio +async def test_fund_same_dlc_twice_same_batch(wallet: Wallet, ledger: Ledger): + invoice = await wallet.request_mint(128) + await pay_if_regtest(invoice.bolt11) + minted = await wallet.mint(128, id=invoice.id) + + dlc_root = sha256("TESTING".encode()).hexdigest() + proofs2, proofs1 = await wallet.split(minted, 64) + + dlc1 = DiscreetLogContract( + funding_amount=64, + unit="sat", + dlc_root=dlc_root, + inputs=proofs1, + ) + dlc2 = DiscreetLogContract( + funding_amount=64, + unit="sat", + dlc_root=dlc_root, + inputs=proofs2, + ) + request = PostDlcRegistrationRequest(registrations=[dlc1, dlc2]) + response = await ledger.register_dlc(request) + assert response.errors and len(response.errors) == 1, f"Funding proofs error: {response.errors[0].bad_inputs}" \ No newline at end of file From 0da2683f3c70d3e260647caedef46c5d1e8bbc65 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Thu, 1 Aug 2024 11:23:08 +0200 Subject: [PATCH 35/68] add test for `status_dlc` --- tests/test_dlc.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/tests/test_dlc.py b/tests/test_dlc.py index 08b8439a..5d4471fd 100644 --- a/tests/test_dlc.py +++ b/tests/test_dlc.py @@ -388,4 +388,29 @@ async def test_fund_same_dlc_twice_same_batch(wallet: Wallet, ledger: Ledger): ) request = PostDlcRegistrationRequest(registrations=[dlc1, dlc2]) response = await ledger.register_dlc(request) - assert response.errors and len(response.errors) == 1, f"Funding proofs error: {response.errors[0].bad_inputs}" \ No newline at end of file + assert response.errors and len(response.errors) == 1, f"Funding proofs error: {response.errors[0].bad_inputs}" + +@pytest.mark.asyncio +async def test_get_dlc_status(wallet: Wallet, ledger: Ledger): + invoice = await wallet.request_mint(128) + await pay_if_regtest(invoice.bolt11) + minted = await wallet.mint(128, id=invoice.id) + + dlc_root = sha256("TESTING".encode()).hexdigest() + dlc1 = DiscreetLogContract( + funding_amount=64, + unit="sat", + dlc_root=dlc_root, + inputs=minted, + ) + request = PostDlcRegistrationRequest(registrations=[dlc1]) + response = await ledger.register_dlc(request) + assert response.errors is None, f"Funding proofs error: {response.errors[0].bad_inputs}" + response = await ledger.status_dlc(dlc_root) + assert ( + response.debts is None and + response.settled == False and + response.funding_amount == 128 and + response.unit == "sat", + f"GetDlcStatusResponse did not respect the format" + ) \ No newline at end of file From cb7eea28d3f5e0ae19aab24f008c1604adfd5ace Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Fri, 2 Aug 2024 15:09:15 +0200 Subject: [PATCH 36/68] better threshold check + test --- cashu/mint/verification.py | 20 ++++++++++++++++++-- tests/test_dlc.py | 27 ++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/cashu/mint/verification.py b/cashu/mint/verification.py index 9f98ced9..fd0f87f2 100644 --- a/cashu/mint/verification.py +++ b/cashu/mint/verification.py @@ -346,11 +346,27 @@ async def _verify_dlc_amount_threshold(self, funding_amount: int, proofs: List[P """For every SCT proof verify that secret's threshold is less or equal to the funding_amount """ + def raise_if_err(err): + if len(err) > 0: + logger.error("Failed to verify DLC inputs") + raise DlcVerificationFail(bad_inputs=err) sct_proofs, _ = await self.filter_sct_proofs(proofs) dlc_witnesses = [DLCWitness.from_witness(p.witness or "") for p in sct_proofs] dlc_secrets = [Secret.deserialize(w.leaf_secret) for w in dlc_witnesses] - if not all([int(s.tags.get_tag('threshold')) <= funding_amount for s in dlc_secrets]): - raise TransactionError("Some inputs' funding thresholds were not met") + errors = [] + for i, s in enumerate(dlc_secrets): + if s.tags.get_tag('threshold') is not None: + threshold = None + try: + threshold = int(s.tags.get_tag('threshold')) + except Exception: + pass + if threshold is not None and funding_amount < threshold: + errors.append(DlcBadInput( + index=i, + detail="Threshold amount not respected" + )) + raise_if_err(errors) async def _verify_dlc_inputs( self, diff --git a/tests/test_dlc.py b/tests/test_dlc.py index 5d4471fd..275acb99 100644 --- a/tests/test_dlc.py +++ b/tests/test_dlc.py @@ -337,6 +337,31 @@ async def test_registration_dlc_locked_proofs(wallet: Wallet, ledger: Ledger): "Could not verify funding proof" ) +@pytest.mark.asyncio +async def test_registration_threshold(wallet: Wallet, ledger: Ledger): + invoice = await wallet.request_mint(64) + await pay_if_regtest(invoice.bolt11) + minted = await wallet.mint(64, id=invoice.id) + + # Get locked proofs + dlc_root = sha256("TESTING".encode()).hexdigest() + _, locked = await wallet.split(minted, 64, dlc_data=(dlc_root, 128)) + assert len(_) == 0 + + # Add witnesses to proofs + locked = await wallet.add_sct_witnesses_to_proofs(locked) + + dlc = DiscreetLogContract( + funding_amount=64, + unit="sat", + dlc_root=dlc_root, + inputs=locked, + ) + + request = PostDlcRegistrationRequest(registrations=[dlc]) + response = await ledger.register_dlc(request) + assert response.errors and response.errors[0].bad_inputs[0].detail == "Threshold amount not respected" + @pytest.mark.asyncio async def test_fund_same_dlc_twice(wallet: Wallet, ledger: Ledger): invoice = await wallet.request_mint(128) @@ -412,5 +437,5 @@ async def test_get_dlc_status(wallet: Wallet, ledger: Ledger): response.settled == False and response.funding_amount == 128 and response.unit == "sat", - f"GetDlcStatusResponse did not respect the format" + f"GetDlcStatusResponse with unexpected fields" ) \ No newline at end of file From c6e06e74e5e3e12df16e9d3de88e7a677311b5e8 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Fri, 2 Aug 2024 18:09:42 +0200 Subject: [PATCH 37/68] settlement --- cashu/core/base.py | 8 +++---- cashu/core/errors.py | 10 +++++++- cashu/mint/ledger.py | 47 +++++++++++++++++++++++++++++++++----- cashu/mint/verification.py | 25 ++++++++++++++++++++ 4 files changed, 79 insertions(+), 11 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index 8a68dc9f..f12adda5 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -1250,17 +1250,17 @@ class DlcOutcome(BaseModel): """ Describes a DLC outcome """ - k: Optional[str] # The discrete log revealed by the oracle + k: Optional[str] # The blinded attestation secret t: Optional[int] # The timeout (claim when time is over) - P: str # The payout structure associated with k + P: str # The payout structure associated with this outcome class DlcSettlement(BaseModel): """ Data used to settle an outcome of a DLC """ dlc_root: str - outcome: DlcOutcome - merkle_proof: List[str] + outcome: Optional[DlcOutcome] + merkle_proof: Optional[List[str]] details: Optional[str] class DlcPayoutForm(BaseModel): diff --git a/cashu/core/errors.py b/cashu/core/errors.py index 1ec3ddfc..a6b50e47 100644 --- a/cashu/core/errors.py +++ b/cashu/core/errors.py @@ -119,4 +119,12 @@ class DlcNotFoundError(CashuError): code = 30002 def __init__(self, **kwargs): - super().__init__(self.detail, self.code) \ No newline at end of file + super().__init__(self.detail, self.code) + +class DlcSettlementFail(CashuError): + detail = "settlement verification failed: " + code = 30003 + + def __init__(self, **kwargs): + super().__init__(self.detail, self.code) + self.detail += kwargs['detail'] \ No newline at end of file diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 6539695a..e6b829de 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -23,7 +23,8 @@ DlcBadInput, DlcFundingProof, DLCWitness, - DiscreetLogContract + DiscreetLogContract, + DlcSettlement, ) from ..core.crypto import b_dhke from ..core.crypto.dlc import sign_dlc @@ -51,6 +52,8 @@ PostMintQuoteRequest, PostDlcRegistrationRequest, PostDlcRegistrationResponse, + PostDlcSettleRequest, + PostDlcSettleResponse, GetDlcStatusResponse, ) from ..core.settings import settings @@ -1148,7 +1151,7 @@ async def register_dlc(self, request: PostDlcRegistrationRequest) -> PostDlcRegi active_keyset_for_unit = next( filter( lambda k: k.active and k.unit == Unit[registration.unit], - self.keysets.values() + self.keysets.values(), ) ) funding_privkey = next(iter(active_keyset_for_unit.private_keys.values())) @@ -1157,10 +1160,9 @@ async def register_dlc(self, request: PostDlcRegistrationRequest) -> PostDlcRegi registration.funding_amount, funding_privkey, ) - funding_proof = DlcFundingProof( dlc_root=registration.dlc_root, - signature=signature.hex() + signature=signature.hex(), ) dlc = DiscreetLogContract( settled=False, @@ -1178,7 +1180,7 @@ async def register_dlc(self, request: PostDlcRegistrationRequest) -> PostDlcRegi dlc_root=registration.dlc_root, bad_inputs=[DlcBadInput( index=-1, - detail=e.detail + detail=e.detail, )] )) # DLC verification fail @@ -1195,4 +1197,37 @@ async def register_dlc(self, request: PostDlcRegistrationRequest) -> PostDlcRegi return PostDlcRegistrationResponse( funded=[reg[1] for reg in registered], errors=errors if len(errors) > 0 else None, - ) \ No newline at end of file + ) + + ''' + # UNFINISHED + async def settle_dlc(self, request: PostDlcSettleRequest) -> PostDlcSettleResponse: + """Settle DLCs once the oracle reveals the attestation secret or the timeout is over. + Args: + request (PostDlcSettleRequest): a request formatted following NUT-DLC spec + Returns: + PostDlcSettleResponse: Indicates which DLCs have been settled and potential errors. + """ + logger.trace("settle called") + verified: List[DlcSettlement] = [] + errors: List[DlcSettlement] = [] + for settlement in request: + try: + # Verify inclusion of payout structure and associated attestation in the DLC + assert settlement.outcome and settlement.merkle_proof, "outcome or merkle proof not provided" + await self.verify_dlc_inclusion(settlement.dlc_root, settlement.outcome, settlement.merkle_proof) + verified.append(settlement) + except DlcSettlementFail, AssertionError as e: + errors.append(DlcSettlement( + dlc_root=settlement.dlc_root, + details=e.details if isinstance(e, DlcSettlementFail) else str(e) + )) + # Database dance: + settled, db_errors = await self.db_write._settle_dlc(verified) + errors += db_errors + + return PostDlcSettleResponse( + settled=settled, + errors=errors if len(errors) > 0 else None, + ) + ''' \ No newline at end of file diff --git a/cashu/mint/verification.py b/cashu/mint/verification.py index fd0f87f2..6db7e63b 100644 --- a/cashu/mint/verification.py +++ b/cashu/mint/verification.py @@ -1,5 +1,7 @@ from typing import Dict, List, Literal, Optional, Tuple, Union +import json + from loguru import logger from hashlib import sha256 @@ -12,6 +14,7 @@ Unit, DlcBadInput, DLCWitness, + DlcOutcome, ) from ..core.crypto import b_dhke from ..core.crypto.dlc import merkle_verify @@ -25,6 +28,7 @@ TransactionError, TransactionUnitError, DlcVerificationFail, + DlcSettlementFail, ) from ..core.settings import settings from ..lightning.base import LightningBackend @@ -475,3 +479,24 @@ def raise_if_err(err): detail=exc.detail if exc else "input spending conditions verification failed" )) raise_if_err(errors) + + async def _verify_dlc_payout(self, P: str): + try: + payout = json.loads(P) + if not isinstance(payout, dict): + raise DlcSettlementFail(detail="Provided payout structure is not a dictionary") + if not all([isinstance(k, str) and isinstance(v, int) for k, v in payout.items()]): + raise DlcSettlementFail(detail="Provided payout structure is not a dictionary mapping strings to integers") + for v in payout.values(): + try: + b = bytes.fromhex(v) + if b[0] != b'\x02': + raise DlcSettlementFail(detail="Provided payout structure contains incorrect public keys") + except ValueError as e: + raise DlcSettlementFail(detail=str(e)) + except json.JSONDecodeError as e: + raise DlcSettlementFail(detail="cannot decode the provided payout structure") + + async def _verify_dlc_inclusion(self, dlc_root: str, outcome: DlcOutcome, merkle_proof: List[str]): + # Verify payout structure + await self._verify_dlc_payout(outcome.P) From 2ba5f5bb4131994cc5c91ef771846386b1fa7f41 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Fri, 2 Aug 2024 20:29:47 +0200 Subject: [PATCH 38/68] secret attestation verification --- cashu/mint/ledger.py | 2 +- cashu/mint/verification.py | 41 ++++++++++++++++++++++++++++++++++---- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index e6b829de..9222b245 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -1215,7 +1215,7 @@ async def settle_dlc(self, request: PostDlcSettleRequest) -> PostDlcSettleRespon try: # Verify inclusion of payout structure and associated attestation in the DLC assert settlement.outcome and settlement.merkle_proof, "outcome or merkle proof not provided" - await self.verify_dlc_inclusion(settlement.dlc_root, settlement.outcome, settlement.merkle_proof) + await self._verify_dlc_inclusion(settlement.dlc_root, settlement.outcome, settlement.merkle_proof) verified.append(settlement) except DlcSettlementFail, AssertionError as e: errors.append(DlcSettlement( diff --git a/cashu/mint/verification.py b/cashu/mint/verification.py index 6db7e63b..b117c5f4 100644 --- a/cashu/mint/verification.py +++ b/cashu/mint/verification.py @@ -1,5 +1,6 @@ from typing import Dict, List, Literal, Optional, Tuple, Union +import time import json from loguru import logger @@ -17,8 +18,8 @@ DlcOutcome, ) from ..core.crypto import b_dhke -from ..core.crypto.dlc import merkle_verify -from ..core.crypto.secp import PublicKey +from ..core.crypto.dlc import merkle_verify, list_hash +from ..core.crypto.secp import PublicKey, PrivateKey from ..core.db import Connection, Database from ..core.errors import ( CashuError, @@ -489,8 +490,8 @@ async def _verify_dlc_payout(self, P: str): raise DlcSettlementFail(detail="Provided payout structure is not a dictionary mapping strings to integers") for v in payout.values(): try: - b = bytes.fromhex(v) - if b[0] != b'\x02': + tmp = bytes.fromhex(v) + if tmp[0] != b'\x02': raise DlcSettlementFail(detail="Provided payout structure contains incorrect public keys") except ValueError as e: raise DlcSettlementFail(detail=str(e)) @@ -500,3 +501,35 @@ async def _verify_dlc_payout(self, P: str): async def _verify_dlc_inclusion(self, dlc_root: str, outcome: DlcOutcome, merkle_proof: List[str]): # Verify payout structure await self._verify_dlc_payout(outcome.P) + + dlc_root_bytes = None + merkle_proof_bytes = None + P = b_dhke.hash_to_curve(outcome.P.encode("utf-8")) + try: + dlc_root_bytes = bytes.fromhex(dlc_root) + merkle_proof_bytes = list_hash(merkle_proof) + except ValueError: + raise DlcSettlementFail(detail="either dlc root or merkle proof are not a hex string") + + # Timeout verification + if outcome.t: + unix_epoch = int(time.time()) + if unix_epoch < outcome.t: + raise DlcSettlementFail(detail="too early for a timeout settlement") + K_t = b_dhke.hash_to_curve(outcome.t.to_bytes(4, "big")) + leaf_hash = sha256((K_t+P).serialize()).digest() + if not merkle_verify(dlc_root_bytes, leaf_hash, merkle_proof_bytes): + raise DlcSettlementFail(detail="could not verify inclusion of timeout + payout structure") + # Blinded Attestation Secret verification + elif outcome.k: + k = None + try: + k = bytes.fromhex(outcome.k) + except ValueError: + raise DlcSettlementFail(detail="blinded attestation secret k is not a hex string") + K = PrivateKey(k, raw=True).pubkey + leaf_hash = sha256((K+P).serialize()).digest() + if not merkle_verify(dlc_root_bytes, leaf_hash, merkle_proof_bytes): + raise DlcSettlementFail(detail="could not verify inclusion of attestation secret + payout structure") + else: + raise DlcSettlementFail(detail="no timeout or attestation secret provided") \ No newline at end of file From 6b1d04cdfa62aecc90aa8d413ffb4a2938dd98c3 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Sat, 3 Aug 2024 11:15:04 +0200 Subject: [PATCH 39/68] settlement database --- cashu/core/base.py | 6 +++--- cashu/mint/crud.py | 32 +++++++++++++++++++++++++++++++- cashu/mint/db/write.py | 37 ++++++++++++++++++++++++++++++++++++- cashu/mint/ledger.py | 12 +++++------- cashu/mint/migrations.py | 2 +- 5 files changed, 76 insertions(+), 13 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index f12adda5..a78907b9 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -1259,9 +1259,9 @@ class DlcSettlement(BaseModel): Data used to settle an outcome of a DLC """ dlc_root: str - outcome: Optional[DlcOutcome] - merkle_proof: Optional[List[str]] - details: Optional[str] + outcome: Optional[DlcOutcome] = None + merkle_proof: Optional[List[str]] = None + details: Optional[str] = None class DlcPayoutForm(BaseModel): dlc_root: str diff --git a/cashu/mint/crud.py b/cashu/mint/crud.py index 58cf54e2..45a1482f 100644 --- a/cashu/mint/crud.py +++ b/cashu/mint/crud.py @@ -262,6 +262,16 @@ async def store_dlc( ) -> None: ... + @abstractmethod + async def set_dlc_settled_and_debts( + self, + dlc_root: str, + debts: str, + db: Database, + conn: Optional[Connection] = None, + ) -> None: + ... + class LedgerCrudSqlite(LedgerCrud): """Implementation of LedgerCrud for sqlite. @@ -790,8 +800,28 @@ async def store_dlc( query, { "dlc_root": dlc.dlc_root, - "settled": dlc.settled, + "settled": 0 if dlc.settled is False else 1, "funding_amount": dlc.funding_amount, "unit": dlc.unit, }, ) + + async def set_dlc_settled_and_debts( + self, + dlc_root: str, + debts: str, + db: Database, + conn: Optional[Connection] = None, + ) -> None: + query = f""" + UPDATE TABLE {db.table_with_schema('dlc')} + SET settled = 1, debts = :debts + WHERE dlc_root = :dlc_root + """ + await (conn or db).execute( + query, + { + "dlc_root": dlc_root, + "debts": debts + }, + ) \ No newline at end of file diff --git a/cashu/mint/db/write.py b/cashu/mint/db/write.py index 80b84f92..d305cd77 100644 --- a/cashu/mint/db/write.py +++ b/cashu/mint/db/write.py @@ -13,12 +13,14 @@ DiscreetLogContract, DlcFundingProof, DlcBadInput, + DlcSettlement, ) from ...core.db import Connection, Database from ...core.errors import ( TransactionError, TokenAlreadySpentError, DlcAlreadyRegisteredError, + DlcSettlementFail, ) from ..crud import LedgerCrud from ..events.events import LedgerEventManager @@ -291,4 +293,37 @@ async def _verify_proofs_and_dlc_registrations( )] )) logger.trace("_verify_proofs_and_dlc_registrations lock released") - return (registered, errors) \ No newline at end of file + return (registered, errors) + + async def _settle_dlc( + self, + settlements: List[DlcSettlement] + ) -> Tuple[List[DlcSettlement], List[DlcSettlement]]: + settled = [] + errors = [] + async with self.db.get_connection(lock_table="dlc") as conn: + for settlement in settlements: + try: + # We verify the dlc_root is in the DB + dlc = await self.crud.get_registered_dlc(settlement.dlc_root, self.db, conn) + if dlc is None: + errors.append(DlcSettlement( + dlc_root=settlement.dlc_root, + details="no DLC with this root hash" + )) + continue + if dlc.settled is True: + errors.append(DlcSettlement( + dlc_root=settlement.dlc_root, + details="DLC already settled" + )) + + assert settlement.outcome + await self.crud.set_dlc_settled_and_debts(settlement.dlc_root, settlement.outcome.P, self.db, conn) + settled.append(settlement) + except Exception as e: + errors.append(DlcSettlement( + dlc_root=settlement.dlc_root, + details=f"error with the DB: {str(e)}" + )) + return (settled, errors) \ No newline at end of file diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 9222b245..c5819779 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -44,6 +44,7 @@ QuoteNotPaidError, TransactionError, DlcVerificationFail, + DlcSettlementFail, ) from ..core.helpers import sum_proofs from ..core.models import ( @@ -1199,8 +1200,6 @@ async def register_dlc(self, request: PostDlcRegistrationRequest) -> PostDlcRegi errors=errors if len(errors) > 0 else None, ) - ''' - # UNFINISHED async def settle_dlc(self, request: PostDlcSettleRequest) -> PostDlcSettleResponse: """Settle DLCs once the oracle reveals the attestation secret or the timeout is over. Args: @@ -1211,16 +1210,16 @@ async def settle_dlc(self, request: PostDlcSettleRequest) -> PostDlcSettleRespon logger.trace("settle called") verified: List[DlcSettlement] = [] errors: List[DlcSettlement] = [] - for settlement in request: + for settlement in request.settlements: try: # Verify inclusion of payout structure and associated attestation in the DLC assert settlement.outcome and settlement.merkle_proof, "outcome or merkle proof not provided" await self._verify_dlc_inclusion(settlement.dlc_root, settlement.outcome, settlement.merkle_proof) verified.append(settlement) - except DlcSettlementFail, AssertionError as e: + except (DlcSettlementFail, AssertionError) as e: errors.append(DlcSettlement( dlc_root=settlement.dlc_root, - details=e.details if isinstance(e, DlcSettlementFail) else str(e) + details=e.detail if isinstance(e, DlcSettlementFail) else str(e) )) # Database dance: settled, db_errors = await self.db_write._settle_dlc(verified) @@ -1229,5 +1228,4 @@ async def settle_dlc(self, request: PostDlcSettleRequest) -> PostDlcSettleRespon return PostDlcSettleResponse( settled=settled, errors=errors if len(errors) > 0 else None, - ) - ''' \ No newline at end of file + ) \ No newline at end of file diff --git a/cashu/mint/migrations.py b/cashu/mint/migrations.py index 74cbadfb..d4d6345d 100644 --- a/cashu/mint/migrations.py +++ b/cashu/mint/migrations.py @@ -832,7 +832,7 @@ async def m022_add_dlc_table(db: Database): f""" CREATE TABLE IF NOT EXISTS {db.table_with_schema('dlc')} ( dlc_root TEXT NOT NULL, - settled BOOL NOT NULL DEFAULT FALSE, + settled BIT NOT NULL DEFAULT 0, funding_amount {db.big_int} NOT NULL, unit TEXT NOT NULL, debts TEXT, From c58cf9b9ba757119d53d5386b42f3d4d7f4d3894 Mon Sep 17 00:00:00 2001 From: conduition Date: Mon, 5 Aug 2024 01:30:42 +0000 Subject: [PATCH 40/68] whitespace formatting --- cashu/core/crypto/dlc.py | 2 +- cashu/core/errors.py | 4 ++-- cashu/mint/conditions.py | 8 ++++---- cashu/mint/crud.py | 2 +- cashu/mint/db/read.py | 3 +-- cashu/mint/db/write.py | 6 +++--- cashu/mint/dlc.py | 2 +- cashu/mint/ledger.py | 6 +++--- cashu/mint/migrations.py | 2 +- cashu/mint/router.py | 2 +- cashu/mint/verification.py | 26 +++++++++++++------------- cashu/wallet/dlc.py | 6 +++--- cashu/wallet/migrations.py | 2 +- cashu/wallet/p2pk.py | 2 +- cashu/wallet/wallet.py | 4 ++-- 15 files changed, 38 insertions(+), 39 deletions(-) diff --git a/cashu/core/crypto/dlc.py b/cashu/core/crypto/dlc.py index 20adca77..48888956 100644 --- a/cashu/core/crypto/dlc.py +++ b/cashu/core/crypto/dlc.py @@ -85,4 +85,4 @@ def verify_dlc_signature( +str(funding_amount).encode("utf-8") ) message_hash = sha256(message).digest() - return pubkey.schnorr_verify(message_hash, signature, None, raw=True) \ No newline at end of file + return pubkey.schnorr_verify(message_hash, signature, None, raw=True) diff --git a/cashu/core/errors.py b/cashu/core/errors.py index a6b50e47..dfe25d3f 100644 --- a/cashu/core/errors.py +++ b/cashu/core/errors.py @@ -110,7 +110,7 @@ def __init__(self, **kwargs): class DlcAlreadyRegisteredError(CashuError): detail = "dlc already registered" code = 30001 - + def __init__(self, **kwargs): super().__init__(self.detail, self.code) @@ -127,4 +127,4 @@ class DlcSettlementFail(CashuError): def __init__(self, **kwargs): super().__init__(self.detail, self.code) - self.detail += kwargs['detail'] \ No newline at end of file + self.detail += kwargs['detail'] diff --git a/cashu/mint/conditions.py b/cashu/mint/conditions.py index ebcdf8fc..c69c8f3a 100644 --- a/cashu/mint/conditions.py +++ b/cashu/mint/conditions.py @@ -221,10 +221,10 @@ def _verify_sct_spending_conditions(self, proof: Proof, secret: Secret) -> bool: if not valid: return False - + if not spending_condition: # means that it is valid and a normal secret return True - + # leaf_secret is a secret of another kind: verify that kind # We only ever need the secret and the witness data new_proof = Proof( @@ -258,11 +258,11 @@ def _verify_input_spending_conditions(self, proof: Proof) -> bool: # HTLC if SecretKind(secret.kind) == SecretKind.HTLC: return self._verify_htlc_spending_conditions(proof, secret) - + # SCT if SecretKind(secret.kind) == SecretKind.SCT: return self._verify_sct_spending_conditions(proof, secret) - + # DLC if SecretKind(secret.kind) == SecretKind.DLC: return False diff --git a/cashu/mint/crud.py b/cashu/mint/crud.py index 45a1482f..5446c7dc 100644 --- a/cashu/mint/crud.py +++ b/cashu/mint/crud.py @@ -824,4 +824,4 @@ async def set_dlc_settled_and_debts( "dlc_root": dlc_root, "debts": debts }, - ) \ No newline at end of file + ) diff --git a/cashu/mint/db/read.py b/cashu/mint/db/read.py index b85cc743..3f304ffe 100644 --- a/cashu/mint/db/read.py +++ b/cashu/mint/db/read.py @@ -97,7 +97,7 @@ async def _verify_proofs_spendable( raise TokenAlreadySpentError() async def _verify_dlc_registrable( - self, dlc_root: str, conn: Optional[Connection] = None, + self, dlc_root: str, conn: Optional[Connection] = None, ): async with self.db.get_connection(conn) as conn: if await self.crud.get_registered_dlc(dlc_root, self.db, conn) is not None: @@ -109,4 +109,3 @@ async def _get_registered_dlc(self, dlc_root: str, conn: Optional[Connection] = if dlc is None: raise DlcNotFoundError() return dlc - \ No newline at end of file diff --git a/cashu/mint/db/write.py b/cashu/mint/db/write.py index d305cd77..8d9b662a 100644 --- a/cashu/mint/db/write.py +++ b/cashu/mint/db/write.py @@ -279,7 +279,7 @@ async def _verify_proofs_and_dlc_registrations( await self.crud.invalidate_proof( proof=p, db=self.db, conn=conn ) - + logger.trace(f"Registering DLC {reg.dlc_root}") await self.crud.store_dlc(reg, self.db, conn) registered.append(registration) @@ -317,7 +317,7 @@ async def _settle_dlc( dlc_root=settlement.dlc_root, details="DLC already settled" )) - + assert settlement.outcome await self.crud.set_dlc_settled_and_debts(settlement.dlc_root, settlement.outcome.P, self.db, conn) settled.append(settlement) @@ -326,4 +326,4 @@ async def _settle_dlc( dlc_root=settlement.dlc_root, details=f"error with the DB: {str(e)}" )) - return (settled, errors) \ No newline at end of file + return (settled, errors) diff --git a/cashu/mint/dlc.py b/cashu/mint/dlc.py index f86dd992..0c6d1e73 100644 --- a/cashu/mint/dlc.py +++ b/cashu/mint/dlc.py @@ -33,4 +33,4 @@ async def get_dlc_fees(self, fa_unit: str) -> Dict[str, int]: assert isinstance(fees, dict) return fees except Exception as e: - raise TransactionError("could not get fees for the specified funding_amount denomination") \ No newline at end of file + raise TransactionError("could not get fees for the specified funding_amount denomination") diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index c5819779..81d9d8a1 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -1193,13 +1193,13 @@ async def register_dlc(self, request: PostDlcRegistrationRequest) -> PostDlcRegi # Database dance registered, db_errors = await self.db_write._verify_proofs_and_dlc_registrations(funded) errors += db_errors - + # Return funded DLCs and errors return PostDlcRegistrationResponse( funded=[reg[1] for reg in registered], errors=errors if len(errors) > 0 else None, ) - + async def settle_dlc(self, request: PostDlcSettleRequest) -> PostDlcSettleResponse: """Settle DLCs once the oracle reveals the attestation secret or the timeout is over. Args: @@ -1228,4 +1228,4 @@ async def settle_dlc(self, request: PostDlcSettleRequest) -> PostDlcSettleRespon return PostDlcSettleResponse( settled=settled, errors=errors if len(errors) > 0 else None, - ) \ No newline at end of file + ) diff --git a/cashu/mint/migrations.py b/cashu/mint/migrations.py index d4d6345d..f038be9d 100644 --- a/cashu/mint/migrations.py +++ b/cashu/mint/migrations.py @@ -841,4 +841,4 @@ async def m022_add_dlc_table(db: Database): CHECK (funding_amount > 0) ); """ - ) \ No newline at end of file + ) diff --git a/cashu/mint/router.py b/cashu/mint/router.py index db4434a3..66f8e1a3 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -406,4 +406,4 @@ async def dlc_fund(request: Request, payload: PostDlcRegistrationRequest) -> Pos @limiter.limit(f"{settings.mint_transaction_rate_limit_per_minute}/minute") async def dlc_status(request: Request, dlc_root: str) -> GetDlcStatusResponse: logger.trace(f"> GET /v1/dlc/status/{dlc_root}") - return await ledger.status_dlc(dlc_root) \ No newline at end of file + return await ledger.status_dlc(dlc_root) diff --git a/cashu/mint/verification.py b/cashu/mint/verification.py index b117c5f4..475337e1 100644 --- a/cashu/mint/verification.py +++ b/cashu/mint/verification.py @@ -288,7 +288,7 @@ def _verify_and_get_unit_method( ) return unit, method - + def _verify_dlc_input_spending_conditions(self, dlc_root: str, p: Proof) -> bool: if not p.witness: return False @@ -312,7 +312,7 @@ def _verify_dlc_input_spending_conditions(self, dlc_root: str, p: Proof) -> bool return False return True - + async def _verify_dlc_amount_fees_coverage( self, funding_amount: int, @@ -322,7 +322,7 @@ async def _verify_dlc_amount_fees_coverage( """ Verifies the sum of the inputs is enough to cover the funding amount + fees - + Args: funding_amount (int): funding amount of the contract fa_unit (str): ONE OF ('sat', 'msat', 'eur', 'usd', 'btc'). The unit in which funding_amount @@ -334,7 +334,7 @@ async def _verify_dlc_amount_fees_coverage( Raises: TransactionError - + """ u = self.keysets[proofs[0].id].unit # Verify registration's funding_amount unit is the same as the proofs @@ -372,7 +372,7 @@ def raise_if_err(err): detail="Threshold amount not respected" )) raise_if_err(errors) - + async def _verify_dlc_inputs( self, dlc_root: str, @@ -380,13 +380,13 @@ async def _verify_dlc_inputs( ): """ Verifies all inputs to the DLC - + Args: dlc_root (hex str): root of the DLC contract proofs: (List[Proof]): proofs to be verified Raises: - DlcVerificationFail + DlcVerificationFail """ # After we have collected all of the errors # We use this to raise a DlcVerificationFail @@ -394,7 +394,7 @@ def raise_if_err(err): if len(err) > 0: logger.error("Failed to verify DLC inputs") raise DlcVerificationFail(bad_inputs=err) - + # We cannot just raise an exception if one proof fails and call it a day # for every proof we need to collect its index and motivation of failure # and report them @@ -441,7 +441,7 @@ def raise_if_err(err): errors.append(DlcBadInput( index=i, detail=str(e) - )) + )) raise_if_err(errors) # Verify proofs of the same denomination @@ -466,7 +466,7 @@ def raise_if_err(err): errors.append(DlcBadInput( index=i, detail="dlc input spending conditions verification failed" - )) + )) for i, p in enumerate(non_sct_proofs): valid = False exc = None @@ -477,7 +477,7 @@ def raise_if_err(err): if not valid: errors.append(DlcBadInput( index=i, - detail=exc.detail if exc else "input spending conditions verification failed" + detail=exc.detail if exc else "input spending conditions verification failed" )) raise_if_err(errors) @@ -511,7 +511,7 @@ async def _verify_dlc_inclusion(self, dlc_root: str, outcome: DlcOutcome, merkle except ValueError: raise DlcSettlementFail(detail="either dlc root or merkle proof are not a hex string") - # Timeout verification + # Timeout verification if outcome.t: unix_epoch = int(time.time()) if unix_epoch < outcome.t: @@ -532,4 +532,4 @@ async def _verify_dlc_inclusion(self, dlc_root: str, outcome: DlcOutcome, merkle if not merkle_verify(dlc_root_bytes, leaf_hash, merkle_proof_bytes): raise DlcSettlementFail(detail="could not verify inclusion of attestation secret + payout structure") else: - raise DlcSettlementFail(detail="no timeout or attestation secret provided") \ No newline at end of file + raise DlcSettlementFail(detail="no timeout or attestation secret provided") diff --git a/cashu/wallet/dlc.py b/cashu/wallet/dlc.py index 4834cc60..583f9d56 100644 --- a/cashu/wallet/dlc.py +++ b/cashu/wallet/dlc.py @@ -78,12 +78,12 @@ async def add_sct_witnesses_to_proofs( ).json() logger.trace(f"Added dlc witness: {p.witness}") return proofs - + async def filter_proofs_by_dlc_root(self, dlc_root: str, proofs: List[Proof]) -> List[Proof]: """Returns a list of proofs each having DLC root equal to `dlc_root` """ return list(filter(lambda p: p.dlc_root == dlc_root, proofs)) - + async def filter_non_dlc_proofs(self, proofs: List[Proof]) -> List[Proof]: """Returns a list of proofs each having None or empty dlc root """ @@ -92,4 +92,4 @@ async def filter_non_dlc_proofs(self, proofs: List[Proof]) -> List[Proof]: async def filter_dlc_proofs(self, proofs: List[Proof]) -> List[Proof]: """Returns a list of proofs each having a non empty dlc root """ - return list(filter(lambda p: p.dlc_root is not None and p.dlc_root != "", proofs)) \ No newline at end of file + return list(filter(lambda p: p.dlc_root is not None and p.dlc_root != "", proofs)) diff --git a/cashu/wallet/migrations.py b/cashu/wallet/migrations.py index 2e19f8ff..27937b81 100644 --- a/cashu/wallet/migrations.py +++ b/cashu/wallet/migrations.py @@ -243,7 +243,7 @@ async def m012_add_fee_to_keysets(db: Database): # add column for storing the fee of a keyset await conn.execute("ALTER TABLE keysets ADD COLUMN input_fee_ppk INTEGER") await conn.execute("UPDATE keysets SET input_fee_ppk = 0") - + async def m013_add_dlc_columns(db: Database): async with db.connect() as conn: # add a column for storing the (eventual) spending conditions for a DLC locked secret. diff --git a/cashu/wallet/p2pk.py b/cashu/wallet/p2pk.py index b71564ec..23f1097d 100644 --- a/cashu/wallet/p2pk.py +++ b/cashu/wallet/p2pk.py @@ -141,7 +141,7 @@ async def _add_p2pk_witnesses_to_outputs( ): outputs = await self.add_p2pk_witnesses_to_outputs(outputs) return outputs - + async def _add_p2pk_witnesses_to_proofs(self, proofs: List[Proof]) -> List[Proof]: """Adds witnesses to proofs. diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index ba585f08..bde6e9cd 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -658,7 +658,7 @@ async def split( # generate secrets for new outputs # CODE COMPLEXITY: for now we limit ourselves to DLC proofs with - # vanilla backup secrets. In the future, backup secrets could also be P2PK or HTLC + # vanilla backup secrets. In the future, backup secrets could also be P2PK or HTLC dlc_root = None spending_conditions = None if dlc_data is not None: @@ -888,7 +888,7 @@ async def _construct_proofs( flag = (spending_conditions is not None and len(spending_conditions) == len(secrets) ) - + zipped = zip(promises, secrets, rs, derivation_paths) if not flag else ( zip(promises, secrets, rs, derivation_paths, spending_conditions or []) ) From 97684a6d4d614b6284e86965ed2300bcb094e1e6 Mon Sep 17 00:00:00 2001 From: conduition Date: Mon, 5 Aug 2024 01:31:29 +0000 Subject: [PATCH 41/68] rename DLCWitness -> SCTWitness --- cashu/core/base.py | 6 +++--- cashu/mint/conditions.py | 4 ++-- cashu/mint/ledger.py | 2 +- cashu/mint/verification.py | 6 +++--- cashu/wallet/dlc.py | 4 ++-- tests/test_dlc.py | 26 +++++++++++++------------- 6 files changed, 24 insertions(+), 24 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index a78907b9..a3202610 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -115,7 +115,7 @@ class P2PKWitness(BaseModel): def from_witness(cls, witness: str): return cls(**json.loads(witness)) -class DLCWitness(BaseModel): +class SCTWitness(BaseModel): leaf_secret: str merkle_proof: List[str] witness: Optional[str] = None @@ -213,12 +213,12 @@ def p2pksigs(self) -> List[str]: @property def dlc_leaf_secret(self) -> str: assert self.witness, "Witness is missing for dlc leaf secret" - return DLCWitness.from_witness(self.witness).leaf_secret + return SCTWitness.from_witness(self.witness).leaf_secret @property def dlc_merkle_proof(self) -> List[str]: assert self.witness, "Witness is missing for dlc merkle proof" - return DLCWitness.from_witness(self.witness).merkle_proof + return SCTWitness.from_witness(self.witness).merkle_proof @property def htlcpreimage(self) -> Union[str, None]: diff --git a/cashu/mint/conditions.py b/cashu/mint/conditions.py index c69c8f3a..2da698d7 100644 --- a/cashu/mint/conditions.py +++ b/cashu/mint/conditions.py @@ -4,7 +4,7 @@ from loguru import logger -from ..core.base import BlindedMessage, DLCWitness, HTLCWitness, Proof +from ..core.base import BlindedMessage, SCTWitness, HTLCWitness, Proof from ..core.crypto.dlc import merkle_verify from ..core.crypto.secp import PublicKey from ..core.errors import ( @@ -200,7 +200,7 @@ def _verify_sct_spending_conditions(self, proof: Proof, secret: Secret) -> bool: if proof.witness is None: return False - witness = DLCWitness.from_witness(proof.witness) + witness = SCTWitness.from_witness(proof.witness) assert witness, TransactionError("No or corrupt DLC witness data provided for a secret kind SCT") spending_condition = False diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 81d9d8a1..f9f3cce3 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -22,7 +22,7 @@ Unit, DlcBadInput, DlcFundingProof, - DLCWitness, + SCTWitness, DiscreetLogContract, DlcSettlement, ) diff --git a/cashu/mint/verification.py b/cashu/mint/verification.py index 475337e1..b6f2cff2 100644 --- a/cashu/mint/verification.py +++ b/cashu/mint/verification.py @@ -14,7 +14,7 @@ Proof, Unit, DlcBadInput, - DLCWitness, + SCTWitness, DlcOutcome, ) from ..core.crypto import b_dhke @@ -293,7 +293,7 @@ def _verify_dlc_input_spending_conditions(self, dlc_root: str, p: Proof) -> bool if not p.witness: return False try: - witness = DLCWitness.from_witness(p.witness) + witness = SCTWitness.from_witness(p.witness) leaf_secret = Secret.deserialize(witness.leaf_secret) secret = Secret.deserialize(p.secret) except Exception as e: @@ -356,7 +356,7 @@ def raise_if_err(err): logger.error("Failed to verify DLC inputs") raise DlcVerificationFail(bad_inputs=err) sct_proofs, _ = await self.filter_sct_proofs(proofs) - dlc_witnesses = [DLCWitness.from_witness(p.witness or "") for p in sct_proofs] + dlc_witnesses = [SCTWitness.from_witness(p.witness or "") for p in sct_proofs] dlc_secrets = [Secret.deserialize(w.leaf_secret) for w in dlc_witnesses] errors = [] for i, s in enumerate(dlc_secrets): diff --git a/cashu/wallet/dlc.py b/cashu/wallet/dlc.py index 583f9d56..e05aee49 100644 --- a/cashu/wallet/dlc.py +++ b/cashu/wallet/dlc.py @@ -1,7 +1,7 @@ from ..core.secret import Secret, SecretKind from ..core.crypto.secp import PrivateKey from ..core.crypto.dlc import list_hash, merkle_root -from ..core.base import Proof, DLCWitness +from ..core.base import Proof, SCTWitness from .protocols import SupportsDb, SupportsPrivateKey from loguru import logger from typing import List, Optional @@ -72,7 +72,7 @@ async def add_sct_witnesses_to_proofs( assert merkle_proof_bytes is not None, "add_sct_witnesses_to_proof: Merkle proof is None" assert merkle_root_bytes.hex() == Secret.deserialize(p.secret).data, "add_sct_witnesses_to_proof: Merkle root not equal to hash in secret.data" leaf_secret = all_spending_conditions[-1] if backup else all_spending_conditions[0] - p.witness = DLCWitness( + p.witness = SCTWitness( leaf_secret=leaf_secret, merkle_proof=[m.hex() for m in merkle_proof_bytes] ).json() diff --git a/tests/test_dlc.py b/tests/test_dlc.py index 275acb99..04d4b5ae 100644 --- a/tests/test_dlc.py +++ b/tests/test_dlc.py @@ -4,7 +4,7 @@ from cashu.wallet.wallet import Wallet from cashu.core.secret import Secret, SecretKind from cashu.core.errors import CashuError -from cashu.core.base import DLCWitness, Proof, TokenV4, Unit, DiscreetLogContract +from cashu.core.base import SCTWitness, Proof, TokenV4, Unit, DiscreetLogContract from cashu.core.models import PostDlcRegistrationRequest, PostDlcRegistrationResponse from cashu.mint.ledger import Ledger from cashu.wallet.helpers import send @@ -151,7 +151,7 @@ async def test_wrong_merkle_proof(wallet: Wallet): root_hash = sha256("TESTING".encode()).hexdigest() threshold = 1000 _, dlc_locked = await wallet.split(minted, 64, dlc_data=(root_hash, threshold)) - + async def add_sct_witnesses_to_proofs( self, proofs: List[Proof], @@ -172,7 +172,7 @@ async def add_sct_witnesses_to_proofs( assert merkle_proof_bytes is not None, "add_sct_witnesses_to_proof: What the duck is going on here" #assert merkle_root_bytes.hex() == Secret.deserialize(p.secret).data, "add_sct_witnesses_to_proof: What the duck is going on here" backup_secret = all_spending_conditions[-1] - p.witness = DLCWitness( + p.witness = SCTWitness( leaf_secret=backup_secret, merkle_proof=[m.hex() for m in merkle_proof_bytes] ).json() @@ -195,7 +195,7 @@ async def test_no_witness_data(wallet: Wallet): root_hash = sha256("TESTING".encode()).hexdigest() threshold = 1000 _, dlc_locked = await wallet.split(minted, 64, dlc_data=(root_hash, threshold)) - + async def add_sct_witnesses_to_proofs( self, proofs: List[Proof], @@ -221,7 +221,7 @@ async def test_cheating1(wallet: Wallet): root_hash = sha256("TESTING".encode()).hexdigest() threshold = 1000 _, dlc_locked = await wallet.split(minted, 64, dlc_data=(root_hash, threshold)) - + async def add_sct_witnesses_to_proofs( self, proofs: List[Proof], @@ -241,7 +241,7 @@ async def add_sct_witnesses_to_proofs( assert merkle_proof_bytes is not None, "add_sct_witnesses_to_proof: What the duck is going on here" assert merkle_root_bytes.hex() == Secret.deserialize(p.secret).data, "add_sct_witnesses_to_proof: What the duck is going on here" dlc_secret = all_spending_conditions[0] - p.witness = DLCWitness( + p.witness = SCTWitness( leaf_secret=dlc_secret, merkle_proof=[m.hex() for m in merkle_proof_bytes] ).json() @@ -268,7 +268,7 @@ async def test_send_funding_token(wallet: Wallet): assert deserialized_token.dlc_root == root_hash proofs = deserialized_token.proofs assert all([Secret.deserialize(p.secret).kind == SecretKind.SCT.value for p in proofs]) - witnesses = [DLCWitness.from_witness(p.witness) for p in proofs] + witnesses = [SCTWitness.from_witness(p.witness) for p in proofs] assert all([Secret.deserialize(w.leaf_secret).kind == SecretKind.DLC.value for w in witnesses]) @pytest.mark.asyncio @@ -293,7 +293,7 @@ async def test_registration_vanilla_proofs(wallet: Wallet, ledger: Ledger): request = PostDlcRegistrationRequest(registrations=[dlc]) response = await ledger.register_dlc(request) assert len(response.funded) == 1, "Funding proofs len != 1" - + funding_proof = response.funded[0] assert ( verify_dlc_signature(dlc_root, 64, bytes.fromhex(funding_proof.signature), pubkey), @@ -310,7 +310,7 @@ async def test_registration_dlc_locked_proofs(wallet: Wallet, ledger: Ledger): dlc_root = sha256("TESTING".encode()).hexdigest() _, locked = await wallet.split(minted, 64, dlc_data=(dlc_root, 32)) assert len(_) == 0 - + # Add witnesses to proofs locked = await wallet.add_sct_witnesses_to_proofs(locked) @@ -330,7 +330,7 @@ async def test_registration_dlc_locked_proofs(wallet: Wallet, ledger: Ledger): response = await ledger.register_dlc(request) assert response.errors is None, f"Funding proofs error: {response.errors[0].bad_inputs}" assert len(response.funded) == 1, "Funding proofs len != 1" - + funding_proof = response.funded[0] assert ( verify_dlc_signature(dlc_root, 64, bytes.fromhex(funding_proof.signature), pubkey), @@ -347,7 +347,7 @@ async def test_registration_threshold(wallet: Wallet, ledger: Ledger): dlc_root = sha256("TESTING".encode()).hexdigest() _, locked = await wallet.split(minted, 64, dlc_data=(dlc_root, 128)) assert len(_) == 0 - + # Add witnesses to proofs locked = await wallet.add_sct_witnesses_to_proofs(locked) @@ -437,5 +437,5 @@ async def test_get_dlc_status(wallet: Wallet, ledger: Ledger): response.settled == False and response.funding_amount == 128 and response.unit == "sat", - f"GetDlcStatusResponse with unexpected fields" - ) \ No newline at end of file + f"GetDlcStatusResponse with unexpected fields" + ) From 42964e79b143f7007e19ec4f8505d6bc44dc6800 Mon Sep 17 00:00:00 2001 From: conduition Date: Mon, 5 Aug 2024 16:04:33 +0000 Subject: [PATCH 42/68] raise errors instead of returning false in _verify_sct_spending_conditions This gives the client an explanation for why the input verification failed. --- cashu/mint/conditions.py | 6 +++--- tests/test_dlc.py | 24 +++++++++++++++--------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/cashu/mint/conditions.py b/cashu/mint/conditions.py index 2da698d7..d707355c 100644 --- a/cashu/mint/conditions.py +++ b/cashu/mint/conditions.py @@ -198,10 +198,10 @@ def _verify_sct_spending_conditions(self, proof: Proof, secret: Secret) -> bool: Verify SCT spending conditions for a single input """ if proof.witness is None: - return False + raise TransactionError('missing SCT secret witness') witness = SCTWitness.from_witness(proof.witness) - assert witness, TransactionError("No or corrupt DLC witness data provided for a secret kind SCT") + assert witness, TransactionError("invalid witness data provided for secret kind SCT") spending_condition = False try: @@ -220,7 +220,7 @@ def _verify_sct_spending_conditions(self, proof: Proof, secret: Secret) -> bool: valid = merkle_verify(merkle_root_bytes, leaf_secret_bytes, merkle_proof_bytes) if not valid: - return False + raise TransactionError('SCT secret merkle proof verification failed') if not spending_condition: # means that it is valid and a normal secret return True diff --git a/tests/test_dlc.py b/tests/test_dlc.py index 04d4b5ae..d4f40e06 100644 --- a/tests/test_dlc.py +++ b/tests/test_dlc.py @@ -183,9 +183,11 @@ async def add_sct_witnesses_to_proofs( for p in dlc_locked: p.all_spending_conditions = [p.all_spending_conditions[0]] - strerror = "Mint Error: validation of input spending conditions failed. (Code: 11000)" - await assert_err(wallet.split(dlc_locked, 64), strerror) - Wallet.add_sct_witnesses_to_proofs = saved + try: + strerror = "Mint Error: SCT secret merkle proof verification failed (Code: 11000)" + await assert_err(wallet.split(dlc_locked, 64), strerror) + finally: + Wallet.add_sct_witnesses_to_proofs = saved @pytest.mark.asyncio async def test_no_witness_data(wallet: Wallet): @@ -206,9 +208,11 @@ async def add_sct_witnesses_to_proofs( saved = Wallet.add_sct_witnesses_to_proofs Wallet.add_sct_witnesses_to_proofs = add_sct_witnesses_to_proofs - strerror = "Mint Error: validation of input spending conditions failed. (Code: 11000)" - await assert_err(wallet.split(dlc_locked, 64), strerror) - Wallet.add_sct_witnesses_to_proofs = saved + try: + strerror = "Mint Error: missing SCT secret witness (Code: 11000)" + await assert_err(wallet.split(dlc_locked, 64), strerror) + finally: + Wallet.add_sct_witnesses_to_proofs = saved @pytest.mark.asyncio async def test_cheating1(wallet: Wallet): @@ -250,9 +254,11 @@ async def add_sct_witnesses_to_proofs( saved = Wallet.add_sct_witnesses_to_proofs Wallet.add_sct_witnesses_to_proofs = add_sct_witnesses_to_proofs - strerror = "Mint Error: validation of input spending conditions failed. (Code: 11000)" - await assert_err(wallet.split(dlc_locked, 64), strerror) - Wallet.add_sct_witnesses_to_proofs = saved + try: + strerror = "Mint Error: validation of input spending conditions failed. (Code: 11000)" + await assert_err(wallet.split(dlc_locked, 64), strerror) + finally: + Wallet.add_sct_witnesses_to_proofs = saved @pytest.mark.asyncio async def test_send_funding_token(wallet: Wallet): From 0b5c1912f7ef2d11a6b83121f3448e7390b63d65 Mon Sep 17 00:00:00 2001 From: conduition Date: Mon, 5 Aug 2024 17:04:54 +0000 Subject: [PATCH 43/68] refactor handling of DLC input validation code This aims to make the input validation more succinct and line up better with existing input validation code. --- cashu/mint/conditions.py | 60 ++++++++++++++++++++++---- cashu/mint/dlc.py | 20 +-------- cashu/mint/ledger.py | 3 +- cashu/mint/verification.py | 88 +++++--------------------------------- tests/test_dlc.py | 4 +- 5 files changed, 68 insertions(+), 107 deletions(-) diff --git a/cashu/mint/conditions.py b/cashu/mint/conditions.py index d707355c..f23a1721 100644 --- a/cashu/mint/conditions.py +++ b/cashu/mint/conditions.py @@ -1,10 +1,16 @@ import hashlib import time -from typing import List +from typing import List, Optional from loguru import logger -from ..core.base import BlindedMessage, SCTWitness, HTLCWitness, Proof +from ..core.base import ( + BlindedMessage, + DiscreetLogContract, + HTLCWitness, + Proof, + SCTWitness, +) from ..core.crypto.dlc import merkle_verify from ..core.crypto.secp import PublicKey from ..core.errors import ( @@ -193,7 +199,39 @@ def _verify_htlc_spending_conditions(self, proof: Proof, secret: Secret) -> bool # no pubkeys were included, anyone can spend return True - def _verify_sct_spending_conditions(self, proof: Proof, secret: Secret) -> bool: + def _verify_dlc_spending_conditions(self, p: Proof, secret: Secret, funding_dlc: DiscreetLogContract) -> bool: + """ + Verify DLC spending conditions for a single input. + """ + # Verify secret is of kind DLC + if secret.kind != SecretKind.DLC.value: + raise TransactionError('expected secret of kind DLC') + + # Verify dlc_root is the one referenced in the secret + if secret.data != funding_dlc.dlc_root: + raise TransactionError('attempted to use kind:DLC secret to fund a DLC with the wrong root hash') + + # Verify the DLC funding amount meets the threshold tag. + # If the threshold tag is invalid or missing, ignore it and allow + # spending with any nonzero funding amount. + try: + tag = secret.tags.get_tag('threshold') + if tag is not None: + threshold = int(tag) + except Exception: + threshold = 0 + + if funding_dlc.funding_amount < threshold: + raise TransactionError('DLC funding_amount does not satisfy DLC secret threshold tag') + + return True + + def _verify_sct_spending_conditions( + self, + proof: Proof, + secret: Secret, + funding_dlc: Optional[DiscreetLogContract] = None, + ) -> bool: """ Verify SCT spending conditions for a single input """ @@ -231,16 +269,20 @@ def _verify_sct_spending_conditions(self, proof: Proof, secret: Secret) -> bool: secret=witness.leaf_secret, witness=witness.witness ) - return self._verify_input_spending_conditions(new_proof) + return self._verify_input_spending_conditions(new_proof, funding_dlc) - def _verify_input_spending_conditions(self, proof: Proof) -> bool: + def _verify_input_spending_conditions( + self, + proof: Proof, + funding_dlc: Optional[DiscreetLogContract] = None, + ) -> bool: """ Verify spending conditions: Condition: P2PK - Checks if signature in proof.witness is valid for pubkey in proof.secret Condition: HTLC - Checks if preimage in proof.witness is valid for hash in proof.secret Condition: SCT - Checks if leaf_secret in proof.witness is a leaf of the Merkle Tree with root proof.secret.data according to proof.witness.merkle_proof - Condition: DLC - NEVER SPEND (can only be registered) + Condition: DLC - NEVER SPEND unless for funding a DLC """ try: @@ -261,11 +303,13 @@ def _verify_input_spending_conditions(self, proof: Proof) -> bool: # SCT if SecretKind(secret.kind) == SecretKind.SCT: - return self._verify_sct_spending_conditions(proof, secret) + return self._verify_sct_spending_conditions(proof, secret, funding_dlc) # DLC if SecretKind(secret.kind) == SecretKind.DLC: - return False + if funding_dlc is None: + raise TransactionError('cannot spend secret kind DLC unless funding a DLC') + return self._verify_dlc_spending_conditions(proof, secret, funding_dlc) # no spending condition present return True diff --git a/cashu/mint/dlc.py b/cashu/mint/dlc.py index 0c6d1e73..af196ab1 100644 --- a/cashu/mint/dlc.py +++ b/cashu/mint/dlc.py @@ -1,28 +1,12 @@ from ..core.nuts import DLC_NUT -from ..core.base import Proof -from ..core.secret import Secret, SecretKind +from typing import Dict + from ..core.errors import TransactionError from .features import LedgerFeatures -from json.decoder import JSONDecodeError -from typing import List, Tuple, Dict class LedgerDLC(LedgerFeatures): - async def filter_sct_proofs(self, proofs: List[Proof]) -> Tuple[List[Proof], List[Proof]]: - deserializable = [] - non_sct_proofs = [] - for p in proofs: - try: - Secret.deserialize(p.secret) - deserializable.append(p) - except JSONDecodeError: - non_sct_proofs.append(p) - - sct_proofs = list(filter(lambda p: Secret.deserialize(p.secret).kind == SecretKind.SCT.value, deserializable)) - non_sct_proofs += list(filter(lambda p: p not in sct_proofs, deserializable)) - return (sct_proofs, non_sct_proofs) - async def get_dlc_fees(self, fa_unit: str) -> Dict[str, int]: try: fees = self.mint_features()[DLC_NUT] diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index f9f3cce3..8c3ced8a 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -1139,13 +1139,12 @@ async def register_dlc(self, request: PostDlcRegistrationRequest) -> PostDlcRegi try: logger.trace(f"processing registration {registration.dlc_root}") assert registration.inputs is not None # mypy give me a break - await self._verify_dlc_inputs(registration.dlc_root, registration.inputs) + await self._verify_dlc_inputs(registration) amount_provided = await self._verify_dlc_amount_fees_coverage( registration.funding_amount, registration.unit, registration.inputs ) - await self._verify_dlc_amount_threshold(amount_provided, registration.inputs) # At this point we can put this dlc into the funded list and create a signature for it # We use the first key from the active keyset of the unit specified in the contract. diff --git a/cashu/mint/verification.py b/cashu/mint/verification.py index b6f2cff2..e6156041 100644 --- a/cashu/mint/verification.py +++ b/cashu/mint/verification.py @@ -289,30 +289,6 @@ def _verify_and_get_unit_method( return unit, method - def _verify_dlc_input_spending_conditions(self, dlc_root: str, p: Proof) -> bool: - if not p.witness: - return False - try: - witness = SCTWitness.from_witness(p.witness) - leaf_secret = Secret.deserialize(witness.leaf_secret) - secret = Secret.deserialize(p.secret) - except Exception as e: - return False - # Verify leaf_secret is of kind DLC - if leaf_secret.kind != SecretKind.DLC.value: - return False - # Verify dlc_root is the one referenced in the secret - if leaf_secret.data != dlc_root: - return False - # Verify inclusion of leaf_secret in the SCT root hash - leaf_hash_bytes = sha256(witness.leaf_secret.encode()).digest() - merkle_proof_bytes = [bytes.fromhex(m) for m in witness.merkle_proof] - sct_root_hash_bytes = bytes.fromhex(secret.data) - if not merkle_verify(sct_root_hash_bytes, leaf_hash_bytes, merkle_proof_bytes): - return False - - return True - async def _verify_dlc_amount_fees_coverage( self, funding_amount: int, @@ -347,42 +323,12 @@ async def _verify_dlc_amount_fees_coverage( raise TransactionError("funds provided do not cover the DLC funding amount") return amount_provided - async def _verify_dlc_amount_threshold(self, funding_amount: int, proofs: List[Proof]): - """For every SCT proof verify that secret's threshold is less or equal to - the funding_amount - """ - def raise_if_err(err): - if len(err) > 0: - logger.error("Failed to verify DLC inputs") - raise DlcVerificationFail(bad_inputs=err) - sct_proofs, _ = await self.filter_sct_proofs(proofs) - dlc_witnesses = [SCTWitness.from_witness(p.witness or "") for p in sct_proofs] - dlc_secrets = [Secret.deserialize(w.leaf_secret) for w in dlc_witnesses] - errors = [] - for i, s in enumerate(dlc_secrets): - if s.tags.get_tag('threshold') is not None: - threshold = None - try: - threshold = int(s.tags.get_tag('threshold')) - except Exception: - pass - if threshold is not None and funding_amount < threshold: - errors.append(DlcBadInput( - index=i, - detail="Threshold amount not respected" - )) - raise_if_err(errors) - - async def _verify_dlc_inputs( - self, - dlc_root: str, - proofs: List[Proof], - ): + async def _verify_dlc_inputs(self, dlc: DiscreetLogContract): """ Verifies all inputs to the DLC Args: - dlc_root (hex str): root of the DLC contract + dlc (DiscreetLogContract): the DLC to be funded proofs: (List[Proof]): proofs to be verified Raises: @@ -400,12 +346,12 @@ def raise_if_err(err): # and report them # Verify inputs - if not proofs: + if not dlc.inputs: raise TransactionError("no proofs provided.") errors = [] # Verify amounts of inputs - for i, p in enumerate(proofs): + for i, p in enumerate(dlc.inputs): try: self._verify_amount(p.amount) except NotAllowedError as e: @@ -416,7 +362,7 @@ def raise_if_err(err): raise_if_err(errors) # Verify secret criteria - for i, p in enumerate(proofs): + for i, p in enumerate(dlc.inputs): try: self._verify_secret_criteria(p) except (SecretTooLongError, NoSecretInProofsError) as e: @@ -427,11 +373,11 @@ def raise_if_err(err): raise_if_err(errors) # verify that only unique proofs were used - if not self._verify_no_duplicate_proofs(proofs): + if not self._verify_no_duplicate_proofs(dlc.inputs): raise TransactionError("duplicate proofs.") # Verify ecash signatures - for i, p in enumerate(proofs): + for i, p in enumerate(dlc.inputs): valid = False exc = None try: @@ -446,8 +392,8 @@ def raise_if_err(err): # Verify proofs of the same denomination # REASONING: proofs could be usd, eur. We don't want mixed stuff. - u = self.keysets[proofs[0].id].unit - for i, p in enumerate(proofs): + u = self.keysets[dlc.inputs[0].id].unit + for i, p in enumerate(dlc.inputs): if self.keysets[p.id].unit != u: errors.append(DlcBadInput( index=i, @@ -455,23 +401,11 @@ def raise_if_err(err): )) raise_if_err(errors) - # Split SCT and non-SCT - # REASONING: the submitter of the registration does not need to dlc lock their proofs - sct_proofs, non_sct_proofs = await self.filter_sct_proofs(proofs) - # Verify spending conditions - for i, p in enumerate(sct_proofs): - # _verify_dlc_input_spending_conditions does not raise any error - # it handles all of them and return either true or false. ALWAYS. - if not self._verify_dlc_input_spending_conditions(dlc_root, p): - errors.append(DlcBadInput( - index=i, - detail="dlc input spending conditions verification failed" - )) - for i, p in enumerate(non_sct_proofs): + for i, p in enumerate(dlc.inputs): valid = False exc = None try: - valid = self._verify_input_spending_conditions(p) + valid = self._verify_input_spending_conditions(p, funding_dlc=dlc) except CashuError as e: exc = e if not valid: diff --git a/tests/test_dlc.py b/tests/test_dlc.py index d4f40e06..0c92d281 100644 --- a/tests/test_dlc.py +++ b/tests/test_dlc.py @@ -255,7 +255,7 @@ async def add_sct_witnesses_to_proofs( Wallet.add_sct_witnesses_to_proofs = add_sct_witnesses_to_proofs try: - strerror = "Mint Error: validation of input spending conditions failed. (Code: 11000)" + strerror = "Mint Error: cannot spend secret kind DLC unless funding a DLC (Code: 11000)" await assert_err(wallet.split(dlc_locked, 64), strerror) finally: Wallet.add_sct_witnesses_to_proofs = saved @@ -366,7 +366,7 @@ async def test_registration_threshold(wallet: Wallet, ledger: Ledger): request = PostDlcRegistrationRequest(registrations=[dlc]) response = await ledger.register_dlc(request) - assert response.errors and response.errors[0].bad_inputs[0].detail == "Threshold amount not respected" + assert response.errors and response.errors[0].bad_inputs[0].detail == "DLC funding_amount does not satisfy DLC secret threshold tag" @pytest.mark.asyncio async def test_fund_same_dlc_twice(wallet: Wallet, ledger: Ledger): From 1f287d3ce4a9e19945f513ba045a025f19e9f9dd Mon Sep 17 00:00:00 2001 From: conduition Date: Mon, 5 Aug 2024 17:08:37 +0000 Subject: [PATCH 44/68] Fix linter errors This commit groups together a bunch of linter error fixes, some automatic and others (in test_dlc.py) manual. --- cashu/core/crypto/dlc.py | 1 + cashu/core/errors.py | 4 +-- cashu/mint/crud.py | 2 +- cashu/mint/db/read.py | 2 +- cashu/mint/db/write.py | 15 +++++----- cashu/mint/dlc.py | 4 +-- cashu/mint/ledger.py | 23 +++++++-------- cashu/mint/router.py | 6 ++-- cashu/mint/verification.py | 28 +++++++++--------- cashu/wallet/dlc.py | 13 +++++---- cashu/wallet/secrets.py | 4 +-- cashu/wallet/wallet.py | 5 ++-- tests/test_dlc.py | 60 +++++++++++++++++--------------------- 13 files changed, 80 insertions(+), 87 deletions(-) diff --git a/cashu/core/crypto/dlc.py b/cashu/core/crypto/dlc.py index 48888956..77616023 100644 --- a/cashu/core/crypto/dlc.py +++ b/cashu/core/crypto/dlc.py @@ -1,5 +1,6 @@ from hashlib import sha256 from typing import List, Optional, Tuple + from secp256k1 import PrivateKey, PublicKey diff --git a/cashu/core/errors.py b/cashu/core/errors.py index dfe25d3f..971afbd8 100644 --- a/cashu/core/errors.py +++ b/cashu/core/errors.py @@ -1,5 +1,5 @@ -from typing import Optional, List -from .base import DlcBadInput +from typing import Optional + class CashuError(Exception): code: int diff --git a/cashu/mint/crud.py b/cashu/mint/crud.py index 5446c7dc..2ab13dfe 100644 --- a/cashu/mint/crud.py +++ b/cashu/mint/crud.py @@ -1,10 +1,10 @@ import json from abc import ABC, abstractmethod from typing import Any, Dict, List, Optional -from ..core.base import DiscreetLogContract from ..core.base import ( BlindedSignature, + DiscreetLogContract, MeltQuote, MintKeyset, MintQuote, diff --git a/cashu/mint/db/read.py b/cashu/mint/db/read.py index 3f304ffe..320391cf 100644 --- a/cashu/mint/db/read.py +++ b/cashu/mint/db/read.py @@ -3,9 +3,9 @@ from ...core.base import Proof, ProofSpentState, ProofState from ...core.db import Connection, Database from ...core.errors import ( - TokenAlreadySpentError, DlcAlreadyRegisteredError, DlcNotFoundError, + TokenAlreadySpentError, ) from ..crud import LedgerCrud diff --git a/cashu/mint/db/write.py b/cashu/mint/db/write.py index 8d9b662a..ee9c9a89 100644 --- a/cashu/mint/db/write.py +++ b/cashu/mint/db/write.py @@ -1,8 +1,12 @@ -from typing import List, Optional, Union, Tuple +from typing import List, Optional, Tuple, Union from loguru import logger from ...core.base import ( + DiscreetLogContract, + DlcBadInput, + DlcFundingProof, + DlcSettlement, MeltQuote, MeltQuoteState, MintQuote, @@ -10,17 +14,12 @@ Proof, ProofSpentState, ProofState, - DiscreetLogContract, - DlcFundingProof, - DlcBadInput, - DlcSettlement, ) from ...core.db import Connection, Database from ...core.errors import ( - TransactionError, - TokenAlreadySpentError, DlcAlreadyRegisteredError, - DlcSettlementFail, + TokenAlreadySpentError, + TransactionError, ) from ..crud import LedgerCrud from ..events.events import LedgerEventManager diff --git a/cashu/mint/dlc.py b/cashu/mint/dlc.py index af196ab1..a5695e43 100644 --- a/cashu/mint/dlc.py +++ b/cashu/mint/dlc.py @@ -1,7 +1,7 @@ -from ..core.nuts import DLC_NUT from typing import Dict from ..core.errors import TransactionError +from ..core.nuts import DLC_NUT from .features import LedgerFeatures @@ -16,5 +16,5 @@ async def get_dlc_fees(self, fa_unit: str) -> Dict[str, int]: fees = fees[fa_unit] assert isinstance(fees, dict) return fees - except Exception as e: + except Exception: raise TransactionError("could not get fees for the specified funding_amount denomination") diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 8c3ced8a..0e5278c3 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -10,6 +10,10 @@ Amount, BlindedMessage, BlindedSignature, + DiscreetLogContract, + DlcBadInput, + DlcFundingProof, + DlcSettlement, MeltQuote, MeltQuoteState, Method, @@ -20,15 +24,10 @@ ProofSpentState, ProofState, Unit, - DlcBadInput, - DlcFundingProof, - SCTWitness, - DiscreetLogContract, - DlcSettlement, ) from ..core.crypto import b_dhke -from ..core.crypto.dlc import sign_dlc from ..core.crypto.aes import AESCipher +from ..core.crypto.dlc import sign_dlc from ..core.crypto.keys import ( derive_pubkey, random_hash, @@ -37,25 +36,25 @@ from ..core.db import Connection, Database from ..core.errors import ( CashuError, + DlcSettlementFail, + DlcVerificationFail, KeysetError, KeysetNotFoundError, LightningError, NotAllowedError, QuoteNotPaidError, TransactionError, - DlcVerificationFail, - DlcSettlementFail, ) from ..core.helpers import sum_proofs from ..core.models import ( - PostMeltQuoteRequest, - PostMeltQuoteResponse, - PostMintQuoteRequest, + GetDlcStatusResponse, PostDlcRegistrationRequest, PostDlcRegistrationResponse, PostDlcSettleRequest, PostDlcSettleResponse, - GetDlcStatusResponse, + PostMeltQuoteRequest, + PostMeltQuoteResponse, + PostMintQuoteRequest, ) from ..core.settings import settings from ..core.split import amount_split diff --git a/cashu/mint/router.py b/cashu/mint/router.py index 66f8e1a3..55c31322 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -5,6 +5,7 @@ from ..core.errors import KeysetNotFoundError from ..core.models import ( + GetDlcStatusResponse, GetInfoResponse, KeysetsResponse, KeysetsResponseKeyset, @@ -13,6 +14,8 @@ MintInfoContact, PostCheckStateRequest, PostCheckStateResponse, + PostDlcRegistrationRequest, + PostDlcRegistrationResponse, PostMeltQuoteRequest, PostMeltQuoteResponse, PostMeltRequest, @@ -24,9 +27,6 @@ PostRestoreResponse, PostSwapRequest, PostSwapResponse, - PostDlcRegistrationRequest, - PostDlcRegistrationResponse, - GetDlcStatusResponse, ) from ..core.settings import settings from ..mint.startup import ledger diff --git a/cashu/mint/verification.py b/cashu/mint/verification.py index e6156041..b0f771a2 100644 --- a/cashu/mint/verification.py +++ b/cashu/mint/verification.py @@ -1,35 +1,34 @@ -from typing import Dict, List, Literal, Optional, Tuple, Union - -import time import json +import time +from hashlib import sha256 +from typing import Dict, List, Literal, Optional, Tuple, Union from loguru import logger -from hashlib import sha256 from ..core.base import ( BlindedMessage, BlindedSignature, + DiscreetLogContract, + DlcBadInput, + DlcOutcome, Method, MintKeyset, Proof, Unit, - DlcBadInput, - SCTWitness, - DlcOutcome, ) from ..core.crypto import b_dhke -from ..core.crypto.dlc import merkle_verify, list_hash -from ..core.crypto.secp import PublicKey, PrivateKey +from ..core.crypto.dlc import list_hash, merkle_verify +from ..core.crypto.secp import PrivateKey, PublicKey from ..core.db import Connection, Database from ..core.errors import ( CashuError, + DlcSettlementFail, + DlcVerificationFail, NoSecretInProofsError, NotAllowedError, SecretTooLongError, TransactionError, TransactionUnitError, - DlcVerificationFail, - DlcSettlementFail, ) from ..core.settings import settings from ..lightning.base import LightningBackend @@ -37,9 +36,10 @@ from .conditions import LedgerSpendingConditions from .db.read import DbReadHelper from .db.write import DbWriteHelper -from .protocols import SupportsBackends, SupportsDb, SupportsKeysets from .dlc import LedgerDLC -from ..core.secret import Secret, SecretKind +from .protocols import SupportsBackends, SupportsDb, SupportsKeysets + + class LedgerVerification( LedgerSpendingConditions, LedgerDLC, SupportsKeysets, SupportsDb, SupportsBackends ): @@ -429,7 +429,7 @@ async def _verify_dlc_payout(self, P: str): raise DlcSettlementFail(detail="Provided payout structure contains incorrect public keys") except ValueError as e: raise DlcSettlementFail(detail=str(e)) - except json.JSONDecodeError as e: + except json.JSONDecodeError: raise DlcSettlementFail(detail="cannot decode the provided payout structure") async def _verify_dlc_inclusion(self, dlc_root: str, outcome: DlcOutcome, merkle_proof: List[str]): diff --git a/cashu/wallet/dlc.py b/cashu/wallet/dlc.py index e05aee49..81f5257e 100644 --- a/cashu/wallet/dlc.py +++ b/cashu/wallet/dlc.py @@ -1,10 +1,13 @@ -from ..core.secret import Secret, SecretKind -from ..core.crypto.secp import PrivateKey -from ..core.crypto.dlc import list_hash, merkle_root +from typing import List, Optional + +from loguru import logger + from ..core.base import Proof, SCTWitness +from ..core.crypto.dlc import list_hash, merkle_root +from ..core.crypto.secp import PrivateKey +from ..core.secret import Secret, SecretKind from .protocols import SupportsDb, SupportsPrivateKey -from loguru import logger -from typing import List, Optional + class SecretMetadata: secret: str diff --git a/cashu/wallet/secrets.py b/cashu/wallet/secrets.py index 1d96384a..9e5c91c5 100644 --- a/cashu/wallet/secrets.py +++ b/cashu/wallet/secrets.py @@ -7,17 +7,17 @@ from loguru import logger from mnemonic import Mnemonic +from ..core.crypto.dlc import list_hash, merkle_root from ..core.crypto.secp import PrivateKey from ..core.db import Database from ..core.secret import Secret, SecretKind, Tags -from .dlc import SecretMetadata -from ..core.crypto.dlc import merkle_root, list_hash from ..core.settings import settings from ..wallet.crud import ( bump_secret_derivation, get_seed_and_mnemonic, store_seed_and_mnemonic, ) +from .dlc import SecretMetadata from .protocols import SupportsDb, SupportsKeysets diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index bde6e9cd..c44d6964 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -21,7 +21,6 @@ Unit, WalletKeyset, ) -from ..core.secret import SecretKind from ..core.crypto import b_dhke from ..core.crypto.secp import PrivateKey, PublicKey from ..core.db import Database @@ -50,10 +49,10 @@ update_proof, ) from . import migrations +from .dlc import WalletSCT from .htlc import WalletHTLC from .mint_info import MintInfo from .p2pk import WalletP2PK -from .dlc import WalletSCT from .proofs import WalletProofs from .secrets import WalletSecrets from .subscriptions import SubscriptionManager @@ -666,7 +665,7 @@ async def split( # Verify dlc_root is a hex string try: _ = bytes.fromhex(dlc_root) - except ValueError as e: + except ValueError: assert False, "CAREFUL: provided dlc root is non-hex!" dlcsecrets = await self.generate_sct_secrets( len(send_outputs), diff --git a/tests/test_dlc.py b/tests/test_dlc.py index 0c92d281..f2a39404 100644 --- a/tests/test_dlc.py +++ b/tests/test_dlc.py @@ -1,33 +1,30 @@ from hashlib import sha256 from random import randint, shuffle -from cashu.lightning.base import InvoiceResponse, PaymentStatus -from cashu.wallet.wallet import Wallet -from cashu.core.secret import Secret, SecretKind -from cashu.core.errors import CashuError -from cashu.core.base import SCTWitness, Proof, TokenV4, Unit, DiscreetLogContract -from cashu.core.models import PostDlcRegistrationRequest, PostDlcRegistrationResponse -from cashu.mint.ledger import Ledger -from cashu.wallet.helpers import send -from tests.conftest import SERVER_ENDPOINT -from hashlib import sha256 -from tests.helpers import ( - pay_if_regtest -) +from typing import List, Union import pytest import pytest_asyncio from loguru import logger - -from typing import Union, List from secp256k1 import PrivateKey + +from cashu.core.base import DiscreetLogContract, Proof, SCTWitness, TokenV4, Unit from cashu.core.crypto.dlc import ( + list_hash, merkle_root, merkle_verify, - sorted_merkle_hash, - list_hash, sign_dlc, + sorted_merkle_hash, verify_dlc_signature, ) +from cashu.core.errors import CashuError +from cashu.core.models import PostDlcRegistrationRequest +from cashu.core.secret import Secret, SecretKind +from cashu.mint.ledger import Ledger +from cashu.wallet.helpers import send +from cashu.wallet.wallet import Wallet +from tests.conftest import SERVER_ENDPOINT +from tests.helpers import pay_if_regtest + @pytest_asyncio.fixture(scope="function") async def wallet(): @@ -98,10 +95,9 @@ async def test_dlc_signatures(): # sign signature = sign_dlc(dlc_root, funding_amount, privkey) # verify - assert( - verify_dlc_signature(dlc_root, funding_amount, signature, privkey.pubkey), + assert verify_dlc_signature(dlc_root, funding_amount, signature, privkey.pubkey), \ "Could not verify funding proof signature" - ) + @pytest.mark.asyncio async def test_swap_for_dlc_locked(wallet: Wallet): @@ -264,7 +260,7 @@ async def add_sct_witnesses_to_proofs( async def test_send_funding_token(wallet: Wallet): invoice = await wallet.request_mint(64) await pay_if_regtest(invoice.bolt11) - minted = await wallet.mint(64, id=invoice.id) + _ = await wallet.mint(64, id=invoice.id) available_before = wallet.available_balance # Send root_hash = sha256("TESTING".encode()).hexdigest() @@ -301,10 +297,8 @@ async def test_registration_vanilla_proofs(wallet: Wallet, ledger: Ledger): assert len(response.funded) == 1, "Funding proofs len != 1" funding_proof = response.funded[0] - assert ( - verify_dlc_signature(dlc_root, 64, bytes.fromhex(funding_proof.signature), pubkey), + assert verify_dlc_signature(dlc_root, 64, bytes.fromhex(funding_proof.signature), pubkey),\ "Could not verify funding proof" - ) @pytest.mark.asyncio async def test_registration_dlc_locked_proofs(wallet: Wallet, ledger: Ledger): @@ -338,10 +332,9 @@ async def test_registration_dlc_locked_proofs(wallet: Wallet, ledger: Ledger): assert len(response.funded) == 1, "Funding proofs len != 1" funding_proof = response.funded[0] - assert ( - verify_dlc_signature(dlc_root, 64, bytes.fromhex(funding_proof.signature), pubkey), + assert verify_dlc_signature(dlc_root, 64, bytes.fromhex(funding_proof.signature), pubkey), \ "Could not verify funding proof" - ) + @pytest.mark.asyncio async def test_registration_threshold(wallet: Wallet, ledger: Ledger): @@ -438,10 +431,9 @@ async def test_get_dlc_status(wallet: Wallet, ledger: Ledger): response = await ledger.register_dlc(request) assert response.errors is None, f"Funding proofs error: {response.errors[0].bad_inputs}" response = await ledger.status_dlc(dlc_root) - assert ( - response.debts is None and - response.settled == False and - response.funding_amount == 128 and - response.unit == "sat", - f"GetDlcStatusResponse with unexpected fields" - ) + assert response.debts is None and \ + response.settled is False and \ + response.funding_amount == 128 and \ + response.unit == "sat", \ + "GetDlcStatusResponse with unexpected fields" + From 0c810faabebe556a03d93a932b0fd48d36ef4b34 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Mon, 5 Aug 2024 21:12:23 +0200 Subject: [PATCH 45/68] fix PostgreSQL's tantrum over BIT datatype --- cashu/mint/migrations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cashu/mint/migrations.py b/cashu/mint/migrations.py index f038be9d..1394bd57 100644 --- a/cashu/mint/migrations.py +++ b/cashu/mint/migrations.py @@ -832,7 +832,7 @@ async def m022_add_dlc_table(db: Database): f""" CREATE TABLE IF NOT EXISTS {db.table_with_schema('dlc')} ( dlc_root TEXT NOT NULL, - settled BIT NOT NULL DEFAULT 0, + settled SMALLINT NOT NULL DEFAULT 0, funding_amount {db.big_int} NOT NULL, unit TEXT NOT NULL, debts TEXT, From 162cd47f1cd9c858589e926f619664daef7c524e Mon Sep 17 00:00:00 2001 From: conduition Date: Fri, 23 Aug 2024 04:27:04 +0000 Subject: [PATCH 46/68] avoid re-settling already settled DLC --- cashu/mint/db/write.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cashu/mint/db/write.py b/cashu/mint/db/write.py index ee9c9a89..2bd6ffd4 100644 --- a/cashu/mint/db/write.py +++ b/cashu/mint/db/write.py @@ -316,6 +316,7 @@ async def _settle_dlc( dlc_root=settlement.dlc_root, details="DLC already settled" )) + continue assert settlement.outcome await self.crud.set_dlc_settled_and_debts(settlement.dlc_root, settlement.outcome.P, self.db, conn) From 2a3049e70470bc7d340be8ef9d003064026973fc Mon Sep 17 00:00:00 2001 From: conduition Date: Fri, 23 Aug 2024 05:02:28 +0000 Subject: [PATCH 47/68] separate error types for registration/settlement responses --- cashu/core/base.py | 25 ++++++++++++++++++++----- cashu/core/models.py | 9 ++++++--- cashu/mint/db/write.py | 21 ++++++++++++--------- cashu/mint/ledger.py | 12 +++++++----- 4 files changed, 45 insertions(+), 22 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index a3202610..41e649d3 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -1243,8 +1243,11 @@ class DlcFundingProof(BaseModel): or a dlc merkle root with bad inputs. """ dlc_root: str - signature: Optional[str] = None - bad_inputs: Optional[List[DlcBadInput]] = None # Used to specify potential errors + signature: str + +class DlcFundingError(BaseModel): + dlc_root: str + bad_inputs: Optional[List[DlcBadInput]] # Used to specify potential errors class DlcOutcome(BaseModel): """ @@ -1259,9 +1262,21 @@ class DlcSettlement(BaseModel): Data used to settle an outcome of a DLC """ dlc_root: str - outcome: Optional[DlcOutcome] = None - merkle_proof: Optional[List[str]] = None - details: Optional[str] = None + outcome: DlcOutcome + merkle_proof: List[str] + +class DlcSettlementAck(BaseModel): + """ + Used by the mint to indicate the success of a DLC's funding, settlement, etc. + """ + dlc_root: str + +class DlcSettlementError(BaseModel): + """ + Indicates to the client that a DLC operation (funding, settlement, etc) failed. + """ + dlc_root: str + details: str class DlcPayoutForm(BaseModel): dlc_root: str diff --git a/cashu/core/models.py b/cashu/core/models.py index 8acdb71b..9fd71810 100644 --- a/cashu/core/models.py +++ b/cashu/core/models.py @@ -7,10 +7,13 @@ BlindedMessage_Deprecated, BlindedSignature, DiscreetLogContract, + DlcFundingError, DlcFundingProof, DlcPayout, DlcPayoutForm, DlcSettlement, + DlcSettlementAck, + DlcSettlementError, MeltQuote, MintQuote, Proof, @@ -338,7 +341,7 @@ class PostDlcRegistrationRequest(BaseModel): class PostDlcRegistrationResponse(BaseModel): funded: List[DlcFundingProof] = [] - errors: Optional[List[DlcFundingProof]] = None + errors: Optional[List[DlcFundingError]] = None # ------- API: DLC SETTLEMENT ------- @@ -346,8 +349,8 @@ class PostDlcSettleRequest(BaseModel): settlements: List[DlcSettlement] class PostDlcSettleResponse(BaseModel): - settled: List[DlcSettlement] = [] - errors: Optional[List[DlcSettlement]] = None + settled: List[DlcSettlementAck] = [] + errors: Optional[List[DlcSettlementError]] = None # ------- API: DLC PAYOUT ------- class PostDlcPayoutRequest(BaseModel): diff --git a/cashu/mint/db/write.py b/cashu/mint/db/write.py index ee9c9a89..e2b9d99b 100644 --- a/cashu/mint/db/write.py +++ b/cashu/mint/db/write.py @@ -5,8 +5,11 @@ from ...core.base import ( DiscreetLogContract, DlcBadInput, + DlcFundingError, DlcFundingProof, DlcSettlement, + DlcSettlementAck, + DlcSettlementError, MeltQuote, MeltQuoteState, MintQuote, @@ -233,7 +236,7 @@ async def _unset_melt_quote_pending( async def _verify_proofs_and_dlc_registrations( self, registrations: List[Tuple[DiscreetLogContract, DlcFundingProof]], - ) -> Tuple[List[Tuple[DiscreetLogContract, DlcFundingProof]], List[DlcFundingProof]]: + ) -> Tuple[List[Tuple[DiscreetLogContract, DlcFundingProof]], List[DlcFundingError]]: """ Method to check if proofs are already spent or registrations already registered. If they are not, we set them as spent and registered respectively @@ -245,7 +248,7 @@ async def _verify_proofs_and_dlc_registrations( """ checked: List[Tuple[DiscreetLogContract, DlcFundingProof]] = [] registered: List[Tuple[DiscreetLogContract, DlcFundingProof]] = [] - errors: List[DlcFundingProof]= [] + errors: List[DlcFundingError]= [] if len(registrations) == 0: logger.trace("Received 0 registrations") return [], [] @@ -261,7 +264,7 @@ async def _verify_proofs_and_dlc_registrations( checked.append(registration) except (TokenAlreadySpentError, DlcAlreadyRegisteredError) as e: logger.trace(f"Proofs already spent for registration {reg.dlc_root}") - errors.append(DlcFundingProof( + errors.append(DlcFundingError( dlc_root=reg.dlc_root, bad_inputs=[DlcBadInput( index=-1, @@ -284,7 +287,7 @@ async def _verify_proofs_and_dlc_registrations( registered.append(registration) except Exception as e: logger.trace(f"Failed to register {reg.dlc_root}: {str(e)}") - errors.append(DlcFundingProof( + errors.append(DlcFundingError( dlc_root=reg.dlc_root, bad_inputs=[DlcBadInput( index=-1, @@ -297,7 +300,7 @@ async def _verify_proofs_and_dlc_registrations( async def _settle_dlc( self, settlements: List[DlcSettlement] - ) -> Tuple[List[DlcSettlement], List[DlcSettlement]]: + ) -> Tuple[List[DlcSettlementAck], List[DlcSettlementError]]: settled = [] errors = [] async with self.db.get_connection(lock_table="dlc") as conn: @@ -306,22 +309,22 @@ async def _settle_dlc( # We verify the dlc_root is in the DB dlc = await self.crud.get_registered_dlc(settlement.dlc_root, self.db, conn) if dlc is None: - errors.append(DlcSettlement( + errors.append(DlcSettlementError( dlc_root=settlement.dlc_root, details="no DLC with this root hash" )) continue if dlc.settled is True: - errors.append(DlcSettlement( + errors.append(DlcSettlementError( dlc_root=settlement.dlc_root, details="DLC already settled" )) assert settlement.outcome await self.crud.set_dlc_settled_and_debts(settlement.dlc_root, settlement.outcome.P, self.db, conn) - settled.append(settlement) + settled.append(DlcSettlementAck(dlc_root=settlement.dlc_root)) except Exception as e: - errors.append(DlcSettlement( + errors.append(DlcSettlementError( dlc_root=settlement.dlc_root, details=f"error with the DB: {str(e)}" )) diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 0e5278c3..d4d2b2c5 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -12,8 +12,10 @@ BlindedSignature, DiscreetLogContract, DlcBadInput, + DlcFundingError, DlcFundingProof, DlcSettlement, + DlcSettlementError, MeltQuote, MeltQuoteState, Method, @@ -1133,7 +1135,7 @@ async def register_dlc(self, request: PostDlcRegistrationRequest) -> PostDlcRegi """ logger.trace("register called") funded: List[Tuple[DiscreetLogContract, DlcFundingProof]] = [] - errors: List[DlcFundingProof] = [] + errors: List[DlcFundingError] = [] for registration in request.registrations: try: logger.trace(f"processing registration {registration.dlc_root}") @@ -1175,7 +1177,7 @@ async def register_dlc(self, request: PostDlcRegistrationRequest) -> PostDlcRegi logger.error(f"registration {registration.dlc_root} failed") # Generic Error if isinstance(e, TransactionError): - errors.append(DlcFundingProof( + errors.append(DlcFundingError( dlc_root=registration.dlc_root, bad_inputs=[DlcBadInput( index=-1, @@ -1184,7 +1186,7 @@ async def register_dlc(self, request: PostDlcRegistrationRequest) -> PostDlcRegi )) # DLC verification fail else: - errors.append(DlcFundingProof( + errors.append(DlcFundingError( dlc_root=registration.dlc_root, bad_inputs=e.bad_inputs, )) @@ -1207,7 +1209,7 @@ async def settle_dlc(self, request: PostDlcSettleRequest) -> PostDlcSettleRespon """ logger.trace("settle called") verified: List[DlcSettlement] = [] - errors: List[DlcSettlement] = [] + errors: List[DlcSettlementError] = [] for settlement in request.settlements: try: # Verify inclusion of payout structure and associated attestation in the DLC @@ -1215,7 +1217,7 @@ async def settle_dlc(self, request: PostDlcSettleRequest) -> PostDlcSettleRespon await self._verify_dlc_inclusion(settlement.dlc_root, settlement.outcome, settlement.merkle_proof) verified.append(settlement) except (DlcSettlementFail, AssertionError) as e: - errors.append(DlcSettlement( + errors.append(DlcSettlementError( dlc_root=settlement.dlc_root, details=e.detail if isinstance(e, DlcSettlementFail) else str(e) )) From 971b43e55cec92f5bd86ba098eee959e66a2196e Mon Sep 17 00:00:00 2001 From: conduition Date: Fri, 23 Aug 2024 05:15:57 +0000 Subject: [PATCH 48/68] update registration response to include funding proof keyset id The spec now includes the `funding_proof` as a sub-object, which includes a reference to the keyset used to create the funding proof. --- cashu/core/base.py | 6 +++++- cashu/core/models.py | 4 ++-- cashu/mint/db/write.py | 16 ++++++++-------- cashu/mint/ledger.py | 12 ++++++++---- 4 files changed, 23 insertions(+), 15 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index 41e649d3..b373da77 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -1242,9 +1242,13 @@ class DlcFundingProof(BaseModel): A dlc merkle root with its signature or a dlc merkle root with bad inputs. """ - dlc_root: str + keyset: str signature: str +class DlcFundingAck(BaseModel): + dlc_root: str + funding_proof: DlcFundingProof + class DlcFundingError(BaseModel): dlc_root: str bad_inputs: Optional[List[DlcBadInput]] # Used to specify potential errors diff --git a/cashu/core/models.py b/cashu/core/models.py index 9fd71810..3c4cae6e 100644 --- a/cashu/core/models.py +++ b/cashu/core/models.py @@ -7,8 +7,8 @@ BlindedMessage_Deprecated, BlindedSignature, DiscreetLogContract, + DlcFundingAck, DlcFundingError, - DlcFundingProof, DlcPayout, DlcPayoutForm, DlcSettlement, @@ -340,7 +340,7 @@ class PostDlcRegistrationRequest(BaseModel): registrations: List[DiscreetLogContract] class PostDlcRegistrationResponse(BaseModel): - funded: List[DlcFundingProof] = [] + funded: List[DlcFundingAck] = [] errors: Optional[List[DlcFundingError]] = None # ------- API: DLC SETTLEMENT ------- diff --git a/cashu/mint/db/write.py b/cashu/mint/db/write.py index e2b9d99b..5914332e 100644 --- a/cashu/mint/db/write.py +++ b/cashu/mint/db/write.py @@ -5,8 +5,8 @@ from ...core.base import ( DiscreetLogContract, DlcBadInput, + DlcFundingAck, DlcFundingError, - DlcFundingProof, DlcSettlement, DlcSettlementAck, DlcSettlementError, @@ -235,19 +235,19 @@ async def _unset_melt_quote_pending( async def _verify_proofs_and_dlc_registrations( self, - registrations: List[Tuple[DiscreetLogContract, DlcFundingProof]], - ) -> Tuple[List[Tuple[DiscreetLogContract, DlcFundingProof]], List[DlcFundingError]]: + registrations: List[Tuple[DiscreetLogContract, DlcFundingAck]], + ) -> Tuple[List[Tuple[DiscreetLogContract, DlcFundingAck]], List[DlcFundingError]]: """ Method to check if proofs are already spent or registrations already registered. If they are not, we set them as spent and registered respectively Args: - registrations (List[Tuple[DiscreetLogContract, DlcFundingProof]]): List of registrations. + registrations (List[Tuple[DiscreetLogContract, DlcFundingAck]]): List of registrations. Returns: - List[Tuple[DiscreetLogContract, DlcFundingProof]]: a list of registered DLCs - List[DlcFundingProof]: a list of errors + List[Tuple[DiscreetLogContract, DlcFundingAck]]: a list of registered DLCs + List[DlcFundingError]: a list of errors """ - checked: List[Tuple[DiscreetLogContract, DlcFundingProof]] = [] - registered: List[Tuple[DiscreetLogContract, DlcFundingProof]] = [] + checked: List[Tuple[DiscreetLogContract, DlcFundingAck]] = [] + registered: List[Tuple[DiscreetLogContract, DlcFundingAck]] = [] errors: List[DlcFundingError]= [] if len(registrations) == 0: logger.trace("Received 0 registrations") diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index d4d2b2c5..d3926370 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -12,6 +12,7 @@ BlindedSignature, DiscreetLogContract, DlcBadInput, + DlcFundingAck, DlcFundingError, DlcFundingProof, DlcSettlement, @@ -1134,7 +1135,7 @@ async def register_dlc(self, request: PostDlcRegistrationRequest) -> PostDlcRegi PostDlcRegistrationResponse: Indicating the funded and registered DLCs as well as the errors. """ logger.trace("register called") - funded: List[Tuple[DiscreetLogContract, DlcFundingProof]] = [] + funded: List[Tuple[DiscreetLogContract, DlcFundingAck]] = [] errors: List[DlcFundingError] = [] for registration in request.registrations: try: @@ -1161,9 +1162,12 @@ async def register_dlc(self, request: PostDlcRegistrationRequest) -> PostDlcRegi registration.funding_amount, funding_privkey, ) - funding_proof = DlcFundingProof( + funding_ack = DlcFundingAck( dlc_root=registration.dlc_root, - signature=signature.hex(), + funding_proof=DlcFundingProof( + keyset=active_keyset_for_unit.id, + signature=signature.hex(), + ), ) dlc = DiscreetLogContract( settled=False, @@ -1172,7 +1176,7 @@ async def register_dlc(self, request: PostDlcRegistrationRequest) -> PostDlcRegi inputs=registration.inputs, unit=registration.unit, ) - funded.append((dlc, funding_proof)) + funded.append((dlc, funding_ack)) except (TransactionError, DlcVerificationFail) as e: logger.error(f"registration {registration.dlc_root} failed") # Generic Error From 47ace2f565c72dfc10b031fe9b719a23c1ae310c Mon Sep 17 00:00:00 2001 From: conduition Date: Fri, 23 Aug 2024 05:22:55 +0000 Subject: [PATCH 49/68] fix funding proof signature to match spec --- cashu/core/crypto/dlc.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/cashu/core/crypto/dlc.py b/cashu/core/crypto/dlc.py index 77616023..78bd7113 100644 --- a/cashu/core/crypto/dlc.py +++ b/cashu/core/crypto/dlc.py @@ -70,10 +70,9 @@ def sign_dlc( ) -> bytes: message = ( bytes.fromhex(dlc_root) - +str(funding_amount).encode("utf-8") + +funding_amount.to_bytes(8, "big") ) - message_hash = sha256(message).digest() - return privkey.schnorr_sign(message_hash, None, raw=True) + return privkey.schnorr_sign(message, None, raw=True) def verify_dlc_signature( dlc_root: str, @@ -83,7 +82,6 @@ def verify_dlc_signature( ) -> bool: message = ( bytes.fromhex(dlc_root) - +str(funding_amount).encode("utf-8") + +funding_amount.to_bytes(8, "big") ) - message_hash = sha256(message).digest() - return pubkey.schnorr_verify(message_hash, signature, None, raw=True) + return pubkey.schnorr_verify(message, signature, None, raw=True) From 18c5a3423489018d9f90192dec428e074c8be1e4 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Sat, 24 Aug 2024 17:28:49 +0200 Subject: [PATCH 50/68] test for dlc settlement and relative error fixes --- cashu/mint/crud.py | 2 +- cashu/mint/verification.py | 32 ++++++--- tests/test_dlc.py | 144 ++++++++++++++++++++++++++++++++++++- 3 files changed, 165 insertions(+), 13 deletions(-) diff --git a/cashu/mint/crud.py b/cashu/mint/crud.py index 2ab13dfe..5c00a6cd 100644 --- a/cashu/mint/crud.py +++ b/cashu/mint/crud.py @@ -814,7 +814,7 @@ async def set_dlc_settled_and_debts( conn: Optional[Connection] = None, ) -> None: query = f""" - UPDATE TABLE {db.table_with_schema('dlc')} + UPDATE {db.table_with_schema('dlc')} SET settled = 1, debts = :debts WHERE dlc_root = :dlc_root """ diff --git a/cashu/mint/verification.py b/cashu/mint/verification.py index b0f771a2..c8a0e60c 100644 --- a/cashu/mint/verification.py +++ b/cashu/mint/verification.py @@ -17,7 +17,7 @@ Unit, ) from ..core.crypto import b_dhke -from ..core.crypto.dlc import list_hash, merkle_verify +from ..core.crypto.dlc import merkle_verify from ..core.crypto.secp import PrivateKey, PublicKey from ..core.db import Connection, Database from ..core.errors import ( @@ -422,13 +422,24 @@ async def _verify_dlc_payout(self, P: str): raise DlcSettlementFail(detail="Provided payout structure is not a dictionary") if not all([isinstance(k, str) and isinstance(v, int) for k, v in payout.items()]): raise DlcSettlementFail(detail="Provided payout structure is not a dictionary mapping strings to integers") - for v in payout.values(): + for k in payout.keys(): try: - tmp = bytes.fromhex(v) - if tmp[0] != b'\x02': + tmp = bytes.fromhex(k) + if tmp[0] != 0x02 and tmp[0] != 0x03: + logger.error(f"Provided incorrect public key: {tmp.hex()}") raise DlcSettlementFail(detail="Provided payout structure contains incorrect public keys") + if len(tmp) != 33: + logger.error(f"Provided incorrect public key: {tmp.hex()}") + raise DlcSettlementFail(detail="Provided payout structure contains incorrect public keys: length != 33") except ValueError as e: raise DlcSettlementFail(detail=str(e)) + for v in payout.values(): + try: + weight = int(v) + if weight < 0: + raise DlcSettlementFail(detail="Provided payout structure contains incorrect payout weights") + except ValueError: + raise DlcSettlementFail(detail="Provided payout structure contains incorrect payout weights") except json.JSONDecodeError: raise DlcSettlementFail(detail="cannot decode the provided payout structure") @@ -438,10 +449,11 @@ async def _verify_dlc_inclusion(self, dlc_root: str, outcome: DlcOutcome, merkle dlc_root_bytes = None merkle_proof_bytes = None - P = b_dhke.hash_to_curve(outcome.P.encode("utf-8")) + P = outcome.P.encode("utf-8") try: dlc_root_bytes = bytes.fromhex(dlc_root) - merkle_proof_bytes = list_hash(merkle_proof) + merkle_proof_bytes = [bytes.fromhex(p) for p in merkle_proof] + logger.debug(f"{merkle_proof = }") except ValueError: raise DlcSettlementFail(detail="either dlc root or merkle proof are not a hex string") @@ -450,9 +462,10 @@ async def _verify_dlc_inclusion(self, dlc_root: str, outcome: DlcOutcome, merkle unix_epoch = int(time.time()) if unix_epoch < outcome.t: raise DlcSettlementFail(detail="too early for a timeout settlement") - K_t = b_dhke.hash_to_curve(outcome.t.to_bytes(4, "big")) - leaf_hash = sha256((K_t+P).serialize()).digest() + K_t = b_dhke.hash_to_curve(outcome.t.to_bytes(8, "big")) + leaf_hash = sha256(K_t.serialize(True) + P).digest() if not merkle_verify(dlc_root_bytes, leaf_hash, merkle_proof_bytes): + logger.error(f"Could not verify timeout attestation and payout structure:\n{leaf_hash.hex() = }\n{dlc_root_bytes.hex()}") raise DlcSettlementFail(detail="could not verify inclusion of timeout + payout structure") # Blinded Attestation Secret verification elif outcome.k: @@ -462,8 +475,9 @@ async def _verify_dlc_inclusion(self, dlc_root: str, outcome: DlcOutcome, merkle except ValueError: raise DlcSettlementFail(detail="blinded attestation secret k is not a hex string") K = PrivateKey(k, raw=True).pubkey - leaf_hash = sha256((K+P).serialize()).digest() + leaf_hash = sha256(K.serialize(True) + P).digest() if not merkle_verify(dlc_root_bytes, leaf_hash, merkle_proof_bytes): + logger.error(f"Could not verify secret attestation and payout structure:\n{leaf_hash.hex() = }\n{dlc_root_bytes.hex()}") raise DlcSettlementFail(detail="could not verify inclusion of attestation secret + payout structure") else: raise DlcSettlementFail(detail="no timeout or attestation secret provided") diff --git a/tests/test_dlc.py b/tests/test_dlc.py index f2a39404..0a7d3321 100644 --- a/tests/test_dlc.py +++ b/tests/test_dlc.py @@ -1,5 +1,7 @@ +import json +import time from hashlib import sha256 -from random import randint, shuffle +from random import getrandbits, randint, shuffle from typing import List, Union import pytest @@ -7,7 +9,16 @@ from loguru import logger from secp256k1 import PrivateKey -from cashu.core.base import DiscreetLogContract, Proof, SCTWitness, TokenV4, Unit +from cashu.core.base import ( + DiscreetLogContract, + DlcOutcome, + DlcSettlement, + Proof, + SCTWitness, + TokenV4, + Unit, +) +from cashu.core.crypto.b_dhke import hash_to_curve from cashu.core.crypto.dlc import ( list_hash, merkle_root, @@ -17,7 +28,10 @@ verify_dlc_signature, ) from cashu.core.errors import CashuError -from cashu.core.models import PostDlcRegistrationRequest +from cashu.core.models import ( + PostDlcRegistrationRequest, + PostDlcSettleRequest, +) from cashu.core.secret import Secret, SecretKind from cashu.mint.ledger import Ledger from cashu.wallet.helpers import send @@ -437,3 +451,127 @@ async def test_get_dlc_status(wallet: Wallet, ledger: Ledger): response.unit == "sat", \ "GetDlcStatusResponse with unexpected fields" + +pubkey1 = '0250863ad64a87ae8a2fe83c1af1a8403cb53f53e486d064e5d9c2f5b75098d9fe' +pubkey2 = '03c6047f9441ed7d6a2626a5b1475a0e4c08ae1f1a8403cb53f53e486d064e5d9c' + +payouts = [ + # pubkey 1 wins + { + pubkey1: 1, + pubkey2: 0 + }, + # pubkey 2 wins + { + pubkey1: 0, + pubkey2: 1 + }, + # timeout + { + pubkey1: 1, + pubkey2: 1 + } +] + +''' +We are not blinding the locking points and attestation secrets, but it's equivalent. +''' +@pytest.mark.asyncio +async def test_settle_dlc(wallet: Wallet, ledger: Ledger): + invoice = await wallet.request_mint(128) + await pay_if_regtest(invoice.bolt11) + minted = await wallet.mint(128, id=invoice.id) + + timeout = int(time.time()) + 3600 # TIMEOUT chosen by the parties + K_t = hash_to_curve(timeout.to_bytes(8, 'big')) # TIMEOUT locking point + secret1 = getrandbits(256).to_bytes(32, 'big') # ORACLE secret attestation + K_1 = PrivateKey(secret1, raw=True).pubkey # ORACLE locking point + secret2 = getrandbits(256).to_bytes(32, 'big') # ORACLE secret attestation + K_2 = PrivateKey(secret2, raw=True).pubkey # ORACLE locking point + + leaves = [ + sha256(K_1.serialize(True)+json.dumps(payouts[0]).encode()).digest(), + sha256(K_2.serialize(True)+json.dumps(payouts[1]).encode()).digest(), + sha256(K_t.serialize(True)+json.dumps(payouts[2]).encode()).digest(), + ] + + # We try to settle the second outcome (pubkey2 wins) + dlc_root, merkle_proof = merkle_root(leaves, 1) + assert merkle_verify(dlc_root, leaves[1], merkle_proof) + + dlc = DiscreetLogContract( + funding_amount=127, + unit="sat", + dlc_root=dlc_root.hex(), + inputs=minted, + ) + + request = PostDlcRegistrationRequest(registrations=[dlc]) + response = await ledger.register_dlc(request) + assert response.errors is None, f"Funding proofs error: {response.errors[0].bad_inputs}" + + outcome = DlcOutcome( + P=json.dumps(payouts[1]), + k=secret2.hex() + ) + merkle_proof_hex = [p.hex() for p in merkle_proof] + settlement = DlcSettlement( + dlc_root=dlc_root.hex(), + outcome=outcome, + merkle_proof=merkle_proof_hex, + ) + request = PostDlcSettleRequest(settlements=[settlement]) + response = await ledger.settle_dlc(request) + + assert response.errors is None, f"Response contains errors: {response.errors}" + assert len(response.settled) > 0, "Response contains zero settlements." + +@pytest.mark.asyncio +async def test_settle_dlc_timeout(wallet: Wallet, ledger: Ledger): + invoice = await wallet.request_mint(128) + await pay_if_regtest(invoice.bolt11) + minted = await wallet.mint(128, id=invoice.id) + + timeout = int(time.time()) # TIMEOUT chosen by the parties + K_t = hash_to_curve(timeout.to_bytes(8, 'big')) # TIMEOUT locking point + secret1 = getrandbits(256).to_bytes(32, 'big') # ORACLE secret attestation + K_1 = PrivateKey(secret1, raw=True).pubkey # ORACLE locking point + secret2 = getrandbits(256).to_bytes(32, 'big') # ORACLE secret attestation + K_2 = PrivateKey(secret2, raw=True).pubkey # ORACLE locking point + + leaves = [ + sha256(K_1.serialize(True)+json.dumps(payouts[0]).encode()).digest(), + sha256(K_2.serialize(True)+json.dumps(payouts[1]).encode()).digest(), + sha256(K_t.serialize(True)+json.dumps(payouts[2]).encode()).digest(), + ] + + # We try the timeout settlement + dlc_root, merkle_proof = merkle_root(leaves, 2) + assert merkle_verify(dlc_root, leaves[2], merkle_proof) + + dlc = DiscreetLogContract( + funding_amount=127, + unit="sat", + dlc_root=dlc_root.hex(), + inputs=minted, + ) + + request = PostDlcRegistrationRequest(registrations=[dlc]) + response = await ledger.register_dlc(request) + assert response.errors is None, f"Funding proofs error: {response.errors[0].bad_inputs}" + + outcome = DlcOutcome( + P=json.dumps(payouts[2]), + t=timeout + ) + merkle_proof_hex = [p.hex() for p in merkle_proof] + settlement = DlcSettlement( + dlc_root=dlc_root.hex(), + outcome=outcome, + merkle_proof=merkle_proof_hex, + ) + request = PostDlcSettleRequest(settlements=[settlement]) + response = await ledger.settle_dlc(request) + + assert response.errors is None, f"Response contains errors: {response.errors}" + assert len(response.settled) > 0, "Response contains zero settlements." \ No newline at end of file From be81d78013fa7d5f019f71a62cb4600d6f233d6a Mon Sep 17 00:00:00 2001 From: conduition Date: Mon, 26 Aug 2024 17:08:41 +0000 Subject: [PATCH 51/68] update tests to reflect new response types --- tests/conftest.py | 2 +- tests/test_dlc.py | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 76d39d30..f985a287 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -139,6 +139,6 @@ def mint(): server = UvicornServer(config=config) server.start() - time.sleep(1) + time.sleep(5) yield server server.stop() diff --git a/tests/test_dlc.py b/tests/test_dlc.py index 0a7d3321..a07b894f 100644 --- a/tests/test_dlc.py +++ b/tests/test_dlc.py @@ -310,8 +310,8 @@ async def test_registration_vanilla_proofs(wallet: Wallet, ledger: Ledger): response = await ledger.register_dlc(request) assert len(response.funded) == 1, "Funding proofs len != 1" - funding_proof = response.funded[0] - assert verify_dlc_signature(dlc_root, 64, bytes.fromhex(funding_proof.signature), pubkey),\ + ack = response.funded[0] + assert verify_dlc_signature(dlc_root, 64, bytes.fromhex(ack.funding_proof.signature), pubkey),\ "Could not verify funding proof" @pytest.mark.asyncio @@ -345,8 +345,8 @@ async def test_registration_dlc_locked_proofs(wallet: Wallet, ledger: Ledger): assert response.errors is None, f"Funding proofs error: {response.errors[0].bad_inputs}" assert len(response.funded) == 1, "Funding proofs len != 1" - funding_proof = response.funded[0] - assert verify_dlc_signature(dlc_root, 64, bytes.fromhex(funding_proof.signature), pubkey), \ + ack = response.funded[0] + assert verify_dlc_signature(dlc_root, 64, bytes.fromhex(ack.funding_proof.signature), pubkey), \ "Could not verify funding proof" @@ -508,7 +508,7 @@ async def test_settle_dlc(wallet: Wallet, ledger: Ledger): request = PostDlcRegistrationRequest(registrations=[dlc]) response = await ledger.register_dlc(request) - assert response.errors is None, f"Funding proofs error: {response.errors[0].bad_inputs}" + assert response.errors is None, f"Funding proofs error: {response.errors[0].bad_inputs}" outcome = DlcOutcome( P=json.dumps(payouts[1]), @@ -558,7 +558,7 @@ async def test_settle_dlc_timeout(wallet: Wallet, ledger: Ledger): request = PostDlcRegistrationRequest(registrations=[dlc]) response = await ledger.register_dlc(request) - assert response.errors is None, f"Funding proofs error: {response.errors[0].bad_inputs}" + assert response.errors is None, f"Funding proofs error: {response.errors[0].bad_inputs}" outcome = DlcOutcome( P=json.dumps(payouts[2]), @@ -574,4 +574,4 @@ async def test_settle_dlc_timeout(wallet: Wallet, ledger: Ledger): response = await ledger.settle_dlc(request) assert response.errors is None, f"Response contains errors: {response.errors}" - assert len(response.settled) > 0, "Response contains zero settlements." \ No newline at end of file + assert len(response.settled) > 0, "Response contains zero settlements." From 62145e2877056fd90238730b1005f21f59f298cc Mon Sep 17 00:00:00 2001 From: gudnuf Date: Sat, 7 Sep 2024 10:48:28 -0700 Subject: [PATCH 52/68] add leading slashes and `POST /v1/dlc/settle` --- cashu/mint/router.py | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/cashu/mint/router.py b/cashu/mint/router.py index 55c31322..fd6d7626 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -16,6 +16,8 @@ PostCheckStateResponse, PostDlcRegistrationRequest, PostDlcRegistrationResponse, + PostDlcSettleRequest, + PostDlcSettleResponse, PostMeltQuoteRequest, PostMeltQuoteResponse, PostMeltRequest, @@ -379,31 +381,50 @@ async def restore(payload: PostRestoreRequest) -> PostRestoreResponse: outputs, signatures = await ledger.restore(payload.outputs) return PostRestoreResponse(outputs=outputs, signatures=signatures) + @router.post( - "v1/dlc/fund", + "/v1/dlc/fund", name="Fund", summary="Register and fund a DLC batch", response_model=PostDlcRegistrationResponse, response_description=( "Two lists describing which DLC were registered and which encountered errors respectively." - ) + ), ) @limiter.limit(f"{settings.mint_transaction_rate_limit_per_minute}/minute") -async def dlc_fund(request: Request, payload: PostDlcRegistrationRequest) -> PostDlcRegistrationResponse: +async def dlc_fund( + request: Request, payload: PostDlcRegistrationRequest +) -> PostDlcRegistrationResponse: logger.trace(f"> POST /v1/dlc/fund: {payload}") assert len(payload.registrations) > 0, "No registrations provided" return await ledger.register_dlc(payload) + @router.get( - "v1/dlc/status/{dlc_root}", + "/v1/dlc/status/{dlc_root}", name="", summary="Register a DLC batch", response_model=GetDlcStatusResponse, response_description=( "Two lists describing which DLC were registered and which encountered errors respectively." - ) + ), ) @limiter.limit(f"{settings.mint_transaction_rate_limit_per_minute}/minute") async def dlc_status(request: Request, dlc_root: str) -> GetDlcStatusResponse: logger.trace(f"> GET /v1/dlc/status/{dlc_root}") return await ledger.status_dlc(dlc_root) + + +@router.post( + "/v1/dlc/settle", + name="Settle", + summary="Prove DLC outcomes", + response_model=PostDlcSettleResponse, + response_description="Two lists describing which DLC were settled and which encountered errors respectively.", +) +@limiter.limit(f"{settings.mint_transaction_rate_limit_per_minute}/minute") +async def dlc_settle( + request: Request, payload: PostDlcSettleRequest +) -> PostDlcSettleResponse: + logger.trace(f"> POST /v1/dlc/settle: {payload}") + return await ledger.settle_dlc(payload) From 0ad83a9870da569a2151459d59b3790cecba2155 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Sat, 14 Sep 2024 00:01:05 +0200 Subject: [PATCH 53/68] initial support for payouts --- cashu/core/base.py | 12 +++++++++--- cashu/core/crypto/dlc.py | 13 +++++++++++++ cashu/core/errors.py | 11 +++++++++++ cashu/mint/ledger.py | 30 ++++++++++++++++++++++++++++++ cashu/mint/router.py | 15 +++++++++++++++ cashu/mint/verification.py | 21 ++++++++++++++++++++- 6 files changed, 98 insertions(+), 4 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index b373da77..5fd9eee6 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -1286,10 +1286,16 @@ class DlcPayoutForm(BaseModel): dlc_root: str pubkey: str outputs: List[BlindedMessage] - witness: P2PKWitness + witness: DlcPayoutWitness class DlcPayout(BaseModel): dlc_root: str - signatures: Optional[List[BlindedSignature]] - details: Optional[str] # error details + outputs: Optional[List[BlindedSignature]] + detail: Optional[str] # error details +class DlcPayoutWitness(BaseModel): + # a BIP-340 signature on the root of the contract + signature: Optional[str] = None + + # the discrete log of the public key (the private key) + secret: Optional[str] = None diff --git a/cashu/core/crypto/dlc.py b/cashu/core/crypto/dlc.py index 78bd7113..bf21694c 100644 --- a/cashu/core/crypto/dlc.py +++ b/cashu/core/crypto/dlc.py @@ -85,3 +85,16 @@ def verify_dlc_signature( +funding_amount.to_bytes(8, "big") ) return pubkey.schnorr_verify(message, signature, None, raw=True) + +def verify_payout_signature( + dlc_root: bytes, + signature: bytes, + pubkey: PublicKey, +) -> bool: + return pubkey.schnorr_verify(dlc_root, signature, None, raw=True) + +def verify_payout_secret( + secret: bytes, + pubkey: PublicKey, +) -> bool: + return pubkey == PrivateKey(secret, raw=True).pubkey diff --git a/cashu/core/errors.py b/cashu/core/errors.py index 971afbd8..62efa099 100644 --- a/cashu/core/errors.py +++ b/cashu/core/errors.py @@ -10,6 +10,9 @@ def __init__(self, detail, code=0): self.code = code self.detail = detail + def __str__(self): + return self.detail + class NotAllowedError(CashuError): detail = "not allowed" @@ -128,3 +131,11 @@ class DlcSettlementFail(CashuError): def __init__(self, **kwargs): super().__init__(self.detail, self.code) self.detail += kwargs['detail'] + +class DlcPayoutFail(CashuError): + detail = "payout verification failed: " + code = 30004 + + def __init__(self, **kwargs): + super().__init__(self.detail, self.code) + self.detail += kwargs['detail'] \ No newline at end of file diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 92a12552..f0382d47 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -17,6 +17,7 @@ DlcFundingProof, DlcSettlement, DlcSettlementError, + DlcPayout, MeltQuote, MeltQuoteState, Method, @@ -47,6 +48,7 @@ NotAllowedError, QuoteNotPaidError, TransactionError, + DlcPayoutFail, ) from ..core.helpers import sum_proofs from ..core.models import ( @@ -58,6 +60,8 @@ PostMeltQuoteRequest, PostMeltQuoteResponse, PostMintQuoteRequest, + PostDlcPayoutRequest, + PostDlcPayoutResponse, ) from ..core.settings import settings from ..core.split import amount_split @@ -1242,3 +1246,29 @@ async def settle_dlc(self, request: PostDlcSettleRequest) -> PostDlcSettleRespon settled=settled, errors=errors if len(errors) > 0 else None, ) + + async def payout_dlc(self, request: PostDlcPayoutRequest) -> PostDlcPayoutResponse: + """Ask for payouts from the relevant DLCs + Args: + request (PostDlcSettleRequest): a request containing a list of DlcPayout, + each containing BlindMessages for the correct amount + Returns: + PostDlcSettleResponse: Indicates which DLCs have been settled and potential errors. + """ + logger.trace("payout called") + verified: List[DlcPayoutForm] = [] + errors: List[DlcPayout] = [] + for i, payout in enumerate(request.payouts): + try: + # First we verify signature on the root, using the provided pubkey + await self._verify_dlc_payout_signature(payout.dlc_root, payout.witness, payout.pubkey) + verified.append(payout) + except (DlcPayoutFail, Exception) as e: + errors.append( + DlcPayout( + dlc_root=payout.dlc_root, + detail=str(e), + ) + ) + # We do all other checks inside a DB lock + # ... diff --git a/cashu/mint/router.py b/cashu/mint/router.py index 3aaa0b80..39194498 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -30,6 +30,8 @@ PostRestoreResponse, PostSwapRequest, PostSwapResponse, + PostDlcPayoutRequest, + PostDlcPayoutResponse, ) from ..core.settings import settings from ..mint.startup import ledger @@ -431,3 +433,16 @@ async def dlc_settle( ) -> PostDlcSettleResponse: logger.trace(f"> POST /v1/dlc/settle: {payload}") return await ledger.settle_dlc(payload) + +@router.post( + "/v1/dlc/payout", + name="Payout", + summary="Get DLC payout", + response_model=PostDlcPayoutResponse, + response_description="Blind signatures for the respective payout amount", +) +@router.limit(f"{settings.mint_transaction_rate_limit_per_minute}/minute") +async def dlc_payout( + request: Request, payload: PostDlcPayoutRequest +) -> PostDlcPayoutRequest: + logger.trace(f"> POST /v1/dlc/payout: {payload}") \ No newline at end of file diff --git a/cashu/mint/verification.py b/cashu/mint/verification.py index c8a0e60c..14b707e2 100644 --- a/cashu/mint/verification.py +++ b/cashu/mint/verification.py @@ -11,13 +11,14 @@ DiscreetLogContract, DlcBadInput, DlcOutcome, + DlcPayoutWitness, Method, MintKeyset, Proof, Unit, ) from ..core.crypto import b_dhke -from ..core.crypto.dlc import merkle_verify +from ..core.crypto.dlc import merkle_verify, verify_payout_secret, verify_payout_signature from ..core.crypto.secp import PrivateKey, PublicKey from ..core.db import Connection, Database from ..core.errors import ( @@ -29,6 +30,7 @@ SecretTooLongError, TransactionError, TransactionUnitError, + DlcPayoutFail, ) from ..core.settings import settings from ..lightning.base import LightningBackend @@ -481,3 +483,20 @@ async def _verify_dlc_inclusion(self, dlc_root: str, outcome: DlcOutcome, merkle raise DlcSettlementFail(detail="could not verify inclusion of attestation secret + payout structure") else: raise DlcSettlementFail(detail="no timeout or attestation secret provided") + + + async def _verify_dlc_payout_signature(self, dlc_root: str, witness: DlcPayoutWitness, pubkey_hex: str): + dlc_root_bytes = bytes.fromhex(dlc_root) + pubkey = PublicKey(bytes.fromhex(pubkey_hex), raw=True) + + if witness.signature: + signature_bytes = bytes.fromhex(witness.signature) + if not verify_payout_signature(dlc_root_bytes, signature_bytes, pubkey): + raise DlcPayoutFail(detail="Could not verify payout signature") + elif witness.secret: + secret_bytes = bytes.fromhex(witness.secret) + if not verify_payout_secret(secret_bytes, pubkey): + raise DlcPayoutFail(detail="Could not verify payout secret") + else: + raise DlcPayoutFail(detail="No witness information that provides payout authentication") + From 9f10d34226db6b844da4f1773957010a94b4a23f Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Sat, 14 Sep 2024 12:32:54 +0200 Subject: [PATCH 54/68] dlc payouts: part 2 --- cashu/core/base.py | 2 +- cashu/core/errors.py | 2 + cashu/mint/db/write.py | 91 +++++++++++++++++++++++++++++++++++++----- cashu/mint/ledger.py | 26 ++++++++++-- 4 files changed, 105 insertions(+), 16 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index 5fd9eee6..0dcb9af5 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -1230,7 +1230,7 @@ def from_row(cls, row: Row): settled=bool(row["settled"]), funding_amount=int(row["funding_amount"]), unit=row["unit"], - debts=row["debts"] or None, + debts=json.loads(row["debts"]) if row["debts"] else None, ) class DlcBadInput(BaseModel): diff --git a/cashu/core/errors.py b/cashu/core/errors.py index 62efa099..be537d17 100644 --- a/cashu/core/errors.py +++ b/cashu/core/errors.py @@ -116,6 +116,7 @@ class DlcAlreadyRegisteredError(CashuError): def __init__(self, **kwargs): super().__init__(self.detail, self.code) + self.detail += kwargs['detail'] class DlcNotFoundError(CashuError): detail = "dlc not found" @@ -123,6 +124,7 @@ class DlcNotFoundError(CashuError): def __init__(self, **kwargs): super().__init__(self.detail, self.code) + self.detail += kwargs['detail'] class DlcSettlementFail(CashuError): detail = "settlement verification failed: " diff --git a/cashu/mint/db/write.py b/cashu/mint/db/write.py index 347b13c0..c2113ef6 100644 --- a/cashu/mint/db/write.py +++ b/cashu/mint/db/write.py @@ -10,10 +10,12 @@ DlcSettlement, DlcSettlementAck, DlcSettlementError, + DlcPayoutForm, MeltQuote, MeltQuoteState, MintQuote, MintQuoteState, + MintKeyset, Proof, ProofSpentState, ProofState, @@ -23,6 +25,9 @@ DlcAlreadyRegisteredError, TokenAlreadySpentError, TransactionError, + DlcSettlementFail, + DlcNotFoundError, + DlcPayoutFail, ) from ..crud import LedgerCrud from ..events.events import LedgerEventManager @@ -309,24 +314,88 @@ async def _settle_dlc( # We verify the dlc_root is in the DB dlc = await self.crud.get_registered_dlc(settlement.dlc_root, self.db, conn) if dlc is None: - errors.append(DlcSettlementError( - dlc_root=settlement.dlc_root, - details="no DLC with this root hash" - )) - continue + raise DlcSettlementFail(detail="No DLC with this root hash") if dlc.settled is True: - errors.append(DlcSettlementError( - dlc_root=settlement.dlc_root, - details="DLC already settled" - )) - continue + raise DlcSettlementFail(detail="DLC already settled") assert settlement.outcome await self.crud.set_dlc_settled_and_debts(settlement.dlc_root, settlement.outcome.P, self.db, conn) settled.append(DlcSettlementAck(dlc_root=settlement.dlc_root)) - except Exception as e: + except (CashuError, Exception) as e: errors.append(DlcSettlementError( dlc_root=settlement.dlc_root, details=f"error with the DB: {str(e)}" )) return (settled, errors) + + + async def _verify_and_update_dlc_payouts( + self, + payouts: List[DlcPayoutForm], + keysets: Dict[str, MintKeyset], + ) -> List[DlcPayoutForm]: + """ + We perform the following checks inside the db lock: + * Verify dlc_root exists and is settled + * Verify the debts map contains the referenced public key + * Verify every blind message from the payout request has keyset ID that + matches the DLC in its funding unit. + * Verify the sum of amounts in blind messages is <= than the respective payout amount + """ + verified = [] + errors = [] + async with self.db.get_connection(lock_table="dlc") as conn: + for payout in payouts: + try: + dlc = await self.crud.get_registered_dlc(payout.dlc_root, self.db, conn) + if dlc is None: + raise DlcPayoutFail(detail="No DLC with this root hash") + if not dlc.settled: + raise DlcPayoutFail(detail="DLC is not settled") + if not all([keysets[b.id].unit == Unit[dlc.unit] for b in payout.outputs]): + raise DlcPayoutFail(detail="DLC funding unit does not match blind messages unit") + if dlc.debts is None: + raise DlcPayoutFail(detail="Debts map is empty") + if payout.pubkey not in dlc.debts: + raise DlcPayoutFail(detail=f"{pubkey}: no such public key in debts map") + + # We have already checked the amounts before, so we just sum them + blind_messages_amount = sum([b.amount for b in payout.outputs]) + denom = sum(dlc.debts.values()) + nom = dlc.debts[payout.pubkey] + eligible_amount = int(nom / denom * dlc.funding_amount) + + # Verify the amount of the blind messages is LEQ than the eligible amount + if blind_messages_amount > eligible_amount: + raise DlcPayoutFail(detail=f"amount requested ({blind_messages_amount}) is bigger than eligible amount ({eligible_amount})") + + # Discriminate what to do next based on whether the requested amount is exact or less + if blind_messages_amount == eligible_amount: + # Simply remove the entry + del dlc.debts[payout.pubkey] + else: + # Get a new weight for dlc.debts[payout.pubkey] + # e == eligible_amount, b = blind_messages_amount + # f == funding_amount + # e - b > 0 + # fx / (x + y + z) == e - b + # fx == (e - b)*(x + y + z) + # fx == (ex + ey + ez - bx - by - bz) + # fx - ex + bx == (ey + ez - by - bz) + # x*(f-e+b) == ey + ez - by - bz + # x == (ey + ez - by - bz) / (f-e+b) + # x == (e*(y+z) - b*(y+z)) / (f-e+b) + w = int((eligible_amount*denom - blind_messages_amount*denom) + / (dlc.funding_amount-eligible_amount+blind_messages_amount)) + if w > 0: + dlc.debts[payout.pubkey] = w + else: + del dlc.debts[payout.pubkey] + + await self.crud.set_dlc_settled_and_debts(dlc.dlc_root, dlc.debts, self.db, conn) + + except (CashuError, Exception) as e: + errors.append(DlcPayout( + dlc_root=payout.dlc_root, + detail=f"DB error: {str(e)}" + )) \ No newline at end of file diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index f0382d47..aee5fa2f 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -1262,13 +1262,31 @@ async def payout_dlc(self, request: PostDlcPayoutRequest) -> PostDlcPayoutRespon try: # First we verify signature on the root, using the provided pubkey await self._verify_dlc_payout_signature(payout.dlc_root, payout.witness, payout.pubkey) - verified.append(payout) - except (DlcPayoutFail, Exception) as e: + # Secondly, we verify the outputs + await self._verify_outputs(payout.outputs) + except (CashuError, Exception) as e: errors.append( DlcPayout( dlc_root=payout.dlc_root, detail=str(e), ) ) - # We do all other checks inside a DB lock - # ... + # We perform the following checks inside the db lock: + # * Verify dlc_root exists and is settled + # * Verify the sum of amounts in blind messages is <= than the respective payout amount + db_verified, db_errors = await self.db_write._verify_and_update_dlc_payouts(verified, self.keysets) + errors += db_errors + + # We generate blind signatures + payouts: List[DlcPayout] = [] + for payout in db_verified: + outputs = await self._generate_promises(payout.outputs) + payouts.append(DlcPayout( + dlc_root=payout.dlc_root, + outputs=outputs, + )) + + return PostDlcPayoutResponse( + paid=payouts, + errors=errors if len(errors) > 0 else None, + ) From 0e35fbff18dccfbd8dfa42d7d1c9e6e6e0e5d537 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Sat, 14 Sep 2024 12:43:57 +0200 Subject: [PATCH 55/68] error fixes --- cashu/core/base.py | 4 ++-- cashu/mint/db/write.py | 19 +++++++++++++------ cashu/mint/ledger.py | 1 + cashu/mint/router.py | 7 ++++--- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index 0dcb9af5..d1ba5160 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -1290,8 +1290,8 @@ class DlcPayoutForm(BaseModel): class DlcPayout(BaseModel): dlc_root: str - outputs: Optional[List[BlindedSignature]] - detail: Optional[str] # error details + outputs: Optional[List[BlindedSignature]] = None + detail: Optional[str] = None # error details class DlcPayoutWitness(BaseModel): # a BIP-340 signature on the root of the contract diff --git a/cashu/mint/db/write.py b/cashu/mint/db/write.py index c2113ef6..467829bf 100644 --- a/cashu/mint/db/write.py +++ b/cashu/mint/db/write.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Tuple, Union +from typing import List, Optional, Tuple, Union, Dict from loguru import logger @@ -11,6 +11,7 @@ DlcSettlementAck, DlcSettlementError, DlcPayoutForm, + DlcPayout, MeltQuote, MeltQuoteState, MintQuote, @@ -19,9 +20,11 @@ Proof, ProofSpentState, ProofState, + Unit, ) from ...core.db import Connection, Database from ...core.errors import ( + CashuError, DlcAlreadyRegisteredError, TokenAlreadySpentError, TransactionError, @@ -33,6 +36,8 @@ from ..events.events import LedgerEventManager from .read import DbReadHelper +import json + class DbWriteHelper: db: Database @@ -333,7 +338,7 @@ async def _verify_and_update_dlc_payouts( self, payouts: List[DlcPayoutForm], keysets: Dict[str, MintKeyset], - ) -> List[DlcPayoutForm]: + ) -> Tuple[List[DlcPayoutForm], List[DlcPayout]]: """ We perform the following checks inside the db lock: * Verify dlc_root exists and is settled @@ -357,7 +362,7 @@ async def _verify_and_update_dlc_payouts( if dlc.debts is None: raise DlcPayoutFail(detail="Debts map is empty") if payout.pubkey not in dlc.debts: - raise DlcPayoutFail(detail=f"{pubkey}: no such public key in debts map") + raise DlcPayoutFail(detail=f"{payout.pubkey}: no such public key in debts map") # We have already checked the amounts before, so we just sum them blind_messages_amount = sum([b.amount for b in payout.outputs]) @@ -392,10 +397,12 @@ async def _verify_and_update_dlc_payouts( else: del dlc.debts[payout.pubkey] - await self.crud.set_dlc_settled_and_debts(dlc.dlc_root, dlc.debts, self.db, conn) - + await self.crud.set_dlc_settled_and_debts(dlc.dlc_root, json.dumps(dlc.debts), self.db, conn) + verified.append(payout) except (CashuError, Exception) as e: errors.append(DlcPayout( dlc_root=payout.dlc_root, detail=f"DB error: {str(e)}" - )) \ No newline at end of file + )) + return (verified, errors) + \ No newline at end of file diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index aee5fa2f..a895fb86 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -18,6 +18,7 @@ DlcSettlement, DlcSettlementError, DlcPayout, + DlcPayoutForm, MeltQuote, MeltQuoteState, Method, diff --git a/cashu/mint/router.py b/cashu/mint/router.py index 39194498..ebbbc42d 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -441,8 +441,9 @@ async def dlc_settle( response_model=PostDlcPayoutResponse, response_description="Blind signatures for the respective payout amount", ) -@router.limit(f"{settings.mint_transaction_rate_limit_per_minute}/minute") +@limiter.limit(f"{settings.mint_transaction_rate_limit_per_minute}/minute") async def dlc_payout( request: Request, payload: PostDlcPayoutRequest -) -> PostDlcPayoutRequest: - logger.trace(f"> POST /v1/dlc/payout: {payload}") \ No newline at end of file +) -> PostDlcPayoutResponse: + logger.trace(f"> POST /v1/dlc/payout: {payload}") + return await ledger.payout_dlc(payload) \ No newline at end of file From ad65326bfbd2aee83d43a5c11a28126426068d10 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Sat, 14 Sep 2024 12:49:09 +0200 Subject: [PATCH 56/68] definition of `DlcPayoutWitness` before `DlcPayout` --- cashu/core/base.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index d1ba5160..1f6de394 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -1282,6 +1282,12 @@ class DlcSettlementError(BaseModel): dlc_root: str details: str +class DlcPayoutWitness(BaseModel): + # a BIP-340 signature on the root of the contract + signature: Optional[str] = None + + # the discrete log of the public key (the private key) + secret: Optional[str] = None class DlcPayoutForm(BaseModel): dlc_root: str pubkey: str @@ -1292,10 +1298,3 @@ class DlcPayout(BaseModel): dlc_root: str outputs: Optional[List[BlindedSignature]] = None detail: Optional[str] = None # error details - -class DlcPayoutWitness(BaseModel): - # a BIP-340 signature on the root of the contract - signature: Optional[str] = None - - # the discrete log of the public key (the private key) - secret: Optional[str] = None From 52729b20cecdd0ef4263a311077836a60a98c6b8 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Sat, 14 Sep 2024 16:28:18 +0200 Subject: [PATCH 57/68] fix more errors --- cashu/core/base.py | 2 +- cashu/core/errors.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index 1f6de394..b0e06530 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -1285,9 +1285,9 @@ class DlcSettlementError(BaseModel): class DlcPayoutWitness(BaseModel): # a BIP-340 signature on the root of the contract signature: Optional[str] = None - # the discrete log of the public key (the private key) secret: Optional[str] = None + class DlcPayoutForm(BaseModel): dlc_root: str pubkey: str diff --git a/cashu/core/errors.py b/cashu/core/errors.py index be537d17..0f807285 100644 --- a/cashu/core/errors.py +++ b/cashu/core/errors.py @@ -108,7 +108,7 @@ class DlcVerificationFail(CashuError): def __init__(self, **kwargs): super().__init__(self.detail, self.code) - self.bad_inputs = kwargs['bad_inputs'] + self.bad_inputs = kwargs.get("bad_inputs", "") class DlcAlreadyRegisteredError(CashuError): detail = "dlc already registered" @@ -116,7 +116,7 @@ class DlcAlreadyRegisteredError(CashuError): def __init__(self, **kwargs): super().__init__(self.detail, self.code) - self.detail += kwargs['detail'] + self.detail += kwargs.get("detail", "") class DlcNotFoundError(CashuError): detail = "dlc not found" @@ -124,7 +124,7 @@ class DlcNotFoundError(CashuError): def __init__(self, **kwargs): super().__init__(self.detail, self.code) - self.detail += kwargs['detail'] + self.detail += kwargs.get('detail', '') class DlcSettlementFail(CashuError): detail = "settlement verification failed: " @@ -132,7 +132,7 @@ class DlcSettlementFail(CashuError): def __init__(self, **kwargs): super().__init__(self.detail, self.code) - self.detail += kwargs['detail'] + self.detail += kwargs.get("detail", "") class DlcPayoutFail(CashuError): detail = "payout verification failed: " @@ -140,4 +140,4 @@ class DlcPayoutFail(CashuError): def __init__(self, **kwargs): super().__init__(self.detail, self.code) - self.detail += kwargs['detail'] \ No newline at end of file + self.detail += kwargs.get("detail", "") \ No newline at end of file From 231eb2537cdb0ff85e664a24192a149b0e5cd530 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Sat, 14 Sep 2024 23:37:49 +0200 Subject: [PATCH 58/68] fix for out of spec debts map --- cashu/mint/db/write.py | 34 ++++++++++------------------------ cashu/mint/dlc.py | 2 ++ cashu/mint/ledger.py | 4 +++- cashu/mint/verification.py | 3 --- 4 files changed, 15 insertions(+), 28 deletions(-) diff --git a/cashu/mint/db/write.py b/cashu/mint/db/write.py index 467829bf..80436ea0 100644 --- a/cashu/mint/db/write.py +++ b/cashu/mint/db/write.py @@ -322,9 +322,14 @@ async def _settle_dlc( raise DlcSettlementFail(detail="No DLC with this root hash") if dlc.settled is True: raise DlcSettlementFail(detail="DLC already settled") - assert settlement.outcome - await self.crud.set_dlc_settled_and_debts(settlement.dlc_root, settlement.outcome.P, self.db, conn) + + # Calculate debts map + weights = json.loads(settlement.outcome.P) + weight_sum = sum(weights.values()) + debts = dict(((pubkey, dlc.funding_amount * weight // weight_sum) for pubkey, weight in weights.items())) + + await self.crud.set_dlc_settled_and_debts(settlement.dlc_root, json.dumps(debts), self.db, conn) settled.append(DlcSettlementAck(dlc_root=settlement.dlc_root)) except (CashuError, Exception) as e: errors.append(DlcSettlementError( @@ -366,36 +371,17 @@ async def _verify_and_update_dlc_payouts( # We have already checked the amounts before, so we just sum them blind_messages_amount = sum([b.amount for b in payout.outputs]) - denom = sum(dlc.debts.values()) - nom = dlc.debts[payout.pubkey] - eligible_amount = int(nom / denom * dlc.funding_amount) + eligible_amount = dlc.debts[payout.pubkey] # Verify the amount of the blind messages is LEQ than the eligible amount if blind_messages_amount > eligible_amount: raise DlcPayoutFail(detail=f"amount requested ({blind_messages_amount}) is bigger than eligible amount ({eligible_amount})") # Discriminate what to do next based on whether the requested amount is exact or less - if blind_messages_amount == eligible_amount: - # Simply remove the entry + if blind_messages_amount == eligible_amount: del dlc.debts[payout.pubkey] else: - # Get a new weight for dlc.debts[payout.pubkey] - # e == eligible_amount, b = blind_messages_amount - # f == funding_amount - # e - b > 0 - # fx / (x + y + z) == e - b - # fx == (e - b)*(x + y + z) - # fx == (ex + ey + ez - bx - by - bz) - # fx - ex + bx == (ey + ez - by - bz) - # x*(f-e+b) == ey + ez - by - bz - # x == (ey + ez - by - bz) / (f-e+b) - # x == (e*(y+z) - b*(y+z)) / (f-e+b) - w = int((eligible_amount*denom - blind_messages_amount*denom) - / (dlc.funding_amount-eligible_amount+blind_messages_amount)) - if w > 0: - dlc.debts[payout.pubkey] = w - else: - del dlc.debts[payout.pubkey] + dlc.debts[payout.pubkey] -= blind_messages_amount await self.crud.set_dlc_settled_and_debts(dlc.dlc_root, json.dumps(dlc.debts), self.db, conn) verified.append(payout) diff --git a/cashu/mint/dlc.py b/cashu/mint/dlc.py index a5695e43..aee3f347 100644 --- a/cashu/mint/dlc.py +++ b/cashu/mint/dlc.py @@ -1,5 +1,7 @@ from typing import Dict +import json +from ..core.base import DlcSettlement from ..core.errors import TransactionError from ..core.nuts import DLC_NUT from .features import LedgerFeatures diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index a895fb86..38d0fe0e 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -1232,12 +1232,14 @@ async def settle_dlc(self, request: PostDlcSettleRequest) -> PostDlcSettleRespon try: # Verify inclusion of payout structure and associated attestation in the DLC assert settlement.outcome and settlement.merkle_proof, "outcome or merkle proof not provided" + await self._verify_dlc_payout(settlement.outcome.P) await self._verify_dlc_inclusion(settlement.dlc_root, settlement.outcome, settlement.merkle_proof) + verified.append(settlement) except (DlcSettlementFail, AssertionError) as e: errors.append(DlcSettlementError( dlc_root=settlement.dlc_root, - details=e.detail if isinstance(e, DlcSettlementFail) else str(e) + details=str(e) )) # Database dance: settled, db_errors = await self.db_write._settle_dlc(verified) diff --git a/cashu/mint/verification.py b/cashu/mint/verification.py index 14b707e2..ef068c80 100644 --- a/cashu/mint/verification.py +++ b/cashu/mint/verification.py @@ -446,9 +446,6 @@ async def _verify_dlc_payout(self, P: str): raise DlcSettlementFail(detail="cannot decode the provided payout structure") async def _verify_dlc_inclusion(self, dlc_root: str, outcome: DlcOutcome, merkle_proof: List[str]): - # Verify payout structure - await self._verify_dlc_payout(outcome.P) - dlc_root_bytes = None merkle_proof_bytes = None P = outcome.P.encode("utf-8") From d561dbe5ed825ac40fd96bfa3ee515e2f802357a Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Sun, 15 Sep 2024 23:12:51 +0200 Subject: [PATCH 59/68] dlc payout tests + bug fixes --- cashu/mint/db/write.py | 2 + cashu/mint/ledger.py | 7 +-- tests/test_dlc.py | 98 ++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 101 insertions(+), 6 deletions(-) diff --git a/cashu/mint/db/write.py b/cashu/mint/db/write.py index 80436ea0..4ed833e1 100644 --- a/cashu/mint/db/write.py +++ b/cashu/mint/db/write.py @@ -329,7 +329,9 @@ async def _settle_dlc( weight_sum = sum(weights.values()) debts = dict(((pubkey, dlc.funding_amount * weight // weight_sum) for pubkey, weight in weights.items())) + # Update DLC in the database await self.crud.set_dlc_settled_and_debts(settlement.dlc_root, json.dumps(debts), self.db, conn) + settled.append(DlcSettlementAck(dlc_root=settlement.dlc_root)) except (CashuError, Exception) as e: errors.append(DlcSettlementError( diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 38d0fe0e..538dbac7 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -1253,20 +1253,21 @@ async def settle_dlc(self, request: PostDlcSettleRequest) -> PostDlcSettleRespon async def payout_dlc(self, request: PostDlcPayoutRequest) -> PostDlcPayoutResponse: """Ask for payouts from the relevant DLCs Args: - request (PostDlcSettleRequest): a request containing a list of DlcPayout, + request (PostDlcPayoutRequest): a request containing a list of DlcPayout, each containing BlindMessages for the correct amount Returns: - PostDlcSettleResponse: Indicates which DLCs have been settled and potential errors. + PostDlcPayoutResponse: Indicates which DLCs have been settled and potential errors. """ logger.trace("payout called") verified: List[DlcPayoutForm] = [] errors: List[DlcPayout] = [] - for i, payout in enumerate(request.payouts): + for payout in request.payouts: try: # First we verify signature on the root, using the provided pubkey await self._verify_dlc_payout_signature(payout.dlc_root, payout.witness, payout.pubkey) # Secondly, we verify the outputs await self._verify_outputs(payout.outputs) + verified.append(payout) except (CashuError, Exception) as e: errors.append( DlcPayout( diff --git a/tests/test_dlc.py b/tests/test_dlc.py index a07b894f..5eda0b67 100644 --- a/tests/test_dlc.py +++ b/tests/test_dlc.py @@ -13,6 +13,8 @@ DiscreetLogContract, DlcOutcome, DlcSettlement, + DlcPayoutForm, + DlcPayoutWitness, Proof, SCTWitness, TokenV4, @@ -31,6 +33,7 @@ from cashu.core.models import ( PostDlcRegistrationRequest, PostDlcSettleRequest, + PostDlcPayoutRequest, ) from cashu.core.secret import Secret, SecretKind from cashu.mint.ledger import Ledger @@ -451,9 +454,10 @@ async def test_get_dlc_status(wallet: Wallet, ledger: Ledger): response.unit == "sat", \ "GetDlcStatusResponse with unexpected fields" - -pubkey1 = '0250863ad64a87ae8a2fe83c1af1a8403cb53f53e486d064e5d9c2f5b75098d9fe' -pubkey2 = '03c6047f9441ed7d6a2626a5b1475a0e4c08ae1f1a8403cb53f53e486d064e5d9c' +privkey1 = PrivateKey() +privkey2 = PrivateKey() +pubkey1 = privkey1.pubkey.serialize(True).hex() +pubkey2 = privkey2.pubkey.serialize(True).hex() payouts = [ # pubkey 1 wins @@ -575,3 +579,91 @@ async def test_settle_dlc_timeout(wallet: Wallet, ledger: Ledger): assert response.errors is None, f"Response contains errors: {response.errors}" assert len(response.settled) > 0, "Response contains zero settlements." + +@pytest.mark.asyncio +async def test_payout_dlc(wallet: Wallet, ledger: Ledger): + invoice = await wallet.request_mint(128) + await pay_if_regtest(invoice.bolt11) + minted = await wallet.mint(128, id=invoice.id) + + # ORACLE GENERATES + k1 = PrivateKey() # ORACLE secret attestation for event `x` (nobody knows this) + k2 = PrivateKey() # ORACLE secret attestation for event `y` (nobody knows this) + + # Choose blinding factor `b` + # This is common knownledge to all parties involved with the DLC (except the mint ofc) + b = PrivateKey() + + timeout = int(time.time()) # TIMEOUT chosen by the parties + K_t = hash_to_curve(timeout.to_bytes(8, 'big')) # TIMEOUT locking point (no blinding) + K_1 = k1.pubkey + b.pubkey # Blinded locking point + K_2 = k2.pubkey + b.pubkey # Blinded locking point + + leaves = [ + sha256(K_1.serialize(True)+json.dumps(payouts[0]).encode()).digest(), + sha256(K_2.serialize(True)+json.dumps(payouts[1]).encode()).digest(), + sha256(K_t.serialize(True)+json.dumps(payouts[2]).encode()).digest(), + ] + + # Funding (Registering) the contract + + # TUPLE: first is the dlc root, second is the merkle proof for the leaf we required + dlc_root, merkle_proof = merkle_root(leaves, 0) + assert merkle_verify(dlc_root, leaves[0], merkle_proof) + dlc = DiscreetLogContract( + funding_amount=127, + unit="sat", + dlc_root=dlc_root.hex(), + inputs=minted, + ) + + request = PostDlcRegistrationRequest(registrations=[dlc]) + response = await ledger.register_dlc(request) + assert response.errors is None, f"Funding proofs error: {response.errors[0].bad_inputs}" + + # NOW suppose event `x` occurs: + # * Oracle reveals `secret1` + # * We blind `secret1` with our blinding factor `b` + # * Anybody can settle the DLC + print(f"{len(b.private_key) = }") + k = k1.tweak_add(b.private_key) + outcome = DlcOutcome( + P=json.dumps(payouts[0]), + k=k.hex(), + ) + merkle_proof_hex = [p.hex() for p in merkle_proof] + settlement = DlcSettlement( + dlc_root=dlc_root.hex(), + outcome=outcome, + merkle_proof=merkle_proof_hex, + ) + request = PostDlcSettleRequest(settlements=[settlement]) + response = await ledger.settle_dlc(request) + + assert response.errors is None, f"Response contains errors: {response.errors}" + assert len(response.settled) > 0, "Response contains zero settlements." + + # CLAIMING our victorious payout + # Generating outputs + amounts = [64,32,16,8,4,2,1] + secrets, rs, _ = await wallet.generate_n_secrets( + len(amounts), skip_bump=True + ) + outputs, rs = wallet._construct_outputs(amounts, secrets, rs) + + payout = DlcPayoutForm( + dlc_root=dlc_root.hex(), + pubkey=pubkey1, + outputs=outputs, + witness=DlcPayoutWitness( + signature=privkey1.schnorr_sign(dlc_root, None, raw=True).hex() + ) + ) + + request = PostDlcPayoutRequest(payouts=[payout]) + response = await ledger.payout_dlc(request) + + assert response.errors is None, f"Payout failed: {response.errors[0].detail}" + assert len(response.paid) > 0, f"Payout failed: paid list is empty" + assert response.paid[0].dlc_root == dlc_root.hex() + assert len(response.paid[0].outputs) == len(amounts) \ No newline at end of file From e35df9bf302aca6b2d718d3bfedce2d0ccdd1a10 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Mon, 16 Sep 2024 08:24:17 +0200 Subject: [PATCH 60/68] make format --- cashu/mint/db/write.py | 16 +++++++--------- cashu/mint/dlc.py | 2 -- cashu/mint/ledger.py | 9 ++++----- cashu/mint/router.py | 4 ++-- cashu/mint/verification.py | 8 ++++++-- tests/test_dlc.py | 8 ++++---- 6 files changed, 23 insertions(+), 24 deletions(-) diff --git a/cashu/mint/db/write.py b/cashu/mint/db/write.py index 4ed833e1..279a2047 100644 --- a/cashu/mint/db/write.py +++ b/cashu/mint/db/write.py @@ -1,4 +1,5 @@ -from typing import List, Optional, Tuple, Union, Dict +import json +from typing import Dict, List, Optional, Tuple, Union from loguru import logger @@ -7,16 +8,16 @@ DlcBadInput, DlcFundingAck, DlcFundingError, + DlcPayout, + DlcPayoutForm, DlcSettlement, DlcSettlementAck, DlcSettlementError, - DlcPayoutForm, - DlcPayout, MeltQuote, MeltQuoteState, + MintKeyset, MintQuote, MintQuoteState, - MintKeyset, Proof, ProofSpentState, ProofState, @@ -26,18 +27,15 @@ from ...core.errors import ( CashuError, DlcAlreadyRegisteredError, + DlcPayoutFail, + DlcSettlementFail, TokenAlreadySpentError, TransactionError, - DlcSettlementFail, - DlcNotFoundError, - DlcPayoutFail, ) from ..crud import LedgerCrud from ..events.events import LedgerEventManager from .read import DbReadHelper -import json - class DbWriteHelper: db: Database diff --git a/cashu/mint/dlc.py b/cashu/mint/dlc.py index aee3f347..a5695e43 100644 --- a/cashu/mint/dlc.py +++ b/cashu/mint/dlc.py @@ -1,7 +1,5 @@ from typing import Dict -import json -from ..core.base import DlcSettlement from ..core.errors import TransactionError from ..core.nuts import DLC_NUT from .features import LedgerFeatures diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 538dbac7..0dcc2b23 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -15,10 +15,10 @@ DlcFundingAck, DlcFundingError, DlcFundingProof, - DlcSettlement, - DlcSettlementError, DlcPayout, DlcPayoutForm, + DlcSettlement, + DlcSettlementError, MeltQuote, MeltQuoteState, Method, @@ -49,11 +49,12 @@ NotAllowedError, QuoteNotPaidError, TransactionError, - DlcPayoutFail, ) from ..core.helpers import sum_proofs from ..core.models import ( GetDlcStatusResponse, + PostDlcPayoutRequest, + PostDlcPayoutResponse, PostDlcRegistrationRequest, PostDlcRegistrationResponse, PostDlcSettleRequest, @@ -61,8 +62,6 @@ PostMeltQuoteRequest, PostMeltQuoteResponse, PostMintQuoteRequest, - PostDlcPayoutRequest, - PostDlcPayoutResponse, ) from ..core.settings import settings from ..core.split import amount_split diff --git a/cashu/mint/router.py b/cashu/mint/router.py index ebbbc42d..f1c7acbc 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -15,6 +15,8 @@ MintInfoContact, PostCheckStateRequest, PostCheckStateResponse, + PostDlcPayoutRequest, + PostDlcPayoutResponse, PostDlcRegistrationRequest, PostDlcRegistrationResponse, PostDlcSettleRequest, @@ -30,8 +32,6 @@ PostRestoreResponse, PostSwapRequest, PostSwapResponse, - PostDlcPayoutRequest, - PostDlcPayoutResponse, ) from ..core.settings import settings from ..mint.startup import ledger diff --git a/cashu/mint/verification.py b/cashu/mint/verification.py index ef068c80..3fcda794 100644 --- a/cashu/mint/verification.py +++ b/cashu/mint/verification.py @@ -18,11 +18,16 @@ Unit, ) from ..core.crypto import b_dhke -from ..core.crypto.dlc import merkle_verify, verify_payout_secret, verify_payout_signature +from ..core.crypto.dlc import ( + merkle_verify, + verify_payout_secret, + verify_payout_signature, +) from ..core.crypto.secp import PrivateKey, PublicKey from ..core.db import Connection, Database from ..core.errors import ( CashuError, + DlcPayoutFail, DlcSettlementFail, DlcVerificationFail, NoSecretInProofsError, @@ -30,7 +35,6 @@ SecretTooLongError, TransactionError, TransactionUnitError, - DlcPayoutFail, ) from ..core.settings import settings from ..lightning.base import LightningBackend diff --git a/tests/test_dlc.py b/tests/test_dlc.py index 5eda0b67..8c4085dd 100644 --- a/tests/test_dlc.py +++ b/tests/test_dlc.py @@ -12,9 +12,9 @@ from cashu.core.base import ( DiscreetLogContract, DlcOutcome, - DlcSettlement, DlcPayoutForm, DlcPayoutWitness, + DlcSettlement, Proof, SCTWitness, TokenV4, @@ -31,9 +31,9 @@ ) from cashu.core.errors import CashuError from cashu.core.models import ( + PostDlcPayoutRequest, PostDlcRegistrationRequest, PostDlcSettleRequest, - PostDlcPayoutRequest, ) from cashu.core.secret import Secret, SecretKind from cashu.mint.ledger import Ledger @@ -645,7 +645,7 @@ async def test_payout_dlc(wallet: Wallet, ledger: Ledger): # CLAIMING our victorious payout # Generating outputs - amounts = [64,32,16,8,4,2,1] + amounts = [128] secrets, rs, _ = await wallet.generate_n_secrets( len(amounts), skip_bump=True ) @@ -664,6 +664,6 @@ async def test_payout_dlc(wallet: Wallet, ledger: Ledger): response = await ledger.payout_dlc(request) assert response.errors is None, f"Payout failed: {response.errors[0].detail}" - assert len(response.paid) > 0, f"Payout failed: paid list is empty" + assert len(response.paid) > 0, "Payout failed: paid list is empty" assert response.paid[0].dlc_root == dlc_root.hex() assert len(response.paid[0].outputs) == len(amounts) \ No newline at end of file From 2b8fa8ba694cd35819a4e8a1c8553092be974d83 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Tue, 17 Sep 2024 13:33:25 +0200 Subject: [PATCH 61/68] test fix --- tests/test_dlc.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_dlc.py b/tests/test_dlc.py index 8c4085dd..12cbb7bd 100644 --- a/tests/test_dlc.py +++ b/tests/test_dlc.py @@ -646,7 +646,7 @@ async def test_payout_dlc(wallet: Wallet, ledger: Ledger): # CLAIMING our victorious payout # Generating outputs amounts = [128] - secrets, rs, _ = await wallet.generate_n_secrets( + secrets, rs, dpaths = await wallet.generate_n_secrets( len(amounts), skip_bump=True ) outputs, rs = wallet._construct_outputs(amounts, secrets, rs) @@ -666,4 +666,6 @@ async def test_payout_dlc(wallet: Wallet, ledger: Ledger): assert response.errors is None, f"Payout failed: {response.errors[0].detail}" assert len(response.paid) > 0, "Payout failed: paid list is empty" assert response.paid[0].dlc_root == dlc_root.hex() - assert len(response.paid[0].outputs) == len(amounts) \ No newline at end of file + assert len(response.paid[0].outputs) == len(amounts) + + proofs = await wallet._construct_proofs(response.paid[0].outputs, secrets, rs, dpaths) \ No newline at end of file From 39aa84987a12a1a8ab66bc9fde6c8d5e44231d7d Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Sat, 21 Sep 2024 21:04:43 +0200 Subject: [PATCH 62/68] fix order of `sorted_merkle_hash` --- cashu/core/crypto/dlc.py | 2 +- cashu/mint/db/write.py | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/cashu/core/crypto/dlc.py b/cashu/core/crypto/dlc.py index bf21694c..49f3c10c 100644 --- a/cashu/core/crypto/dlc.py +++ b/cashu/core/crypto/dlc.py @@ -8,7 +8,7 @@ def sorted_merkle_hash(left: bytes, right: bytes) -> bytes: '''Sorts `left` and `right` in non-ascending order and computes the hash of their concatenation ''' - if left < right: + if right < left: left, right = right, left return sha256(left+right).digest() diff --git a/cashu/mint/db/write.py b/cashu/mint/db/write.py index 279a2047..add0e774 100644 --- a/cashu/mint/db/write.py +++ b/cashu/mint/db/write.py @@ -374,16 +374,16 @@ async def _verify_and_update_dlc_payouts( eligible_amount = dlc.debts[payout.pubkey] # Verify the amount of the blind messages is LEQ than the eligible amount - if blind_messages_amount > eligible_amount: + if blind_messages_amount != eligible_amount: raise DlcPayoutFail(detail=f"amount requested ({blind_messages_amount}) is bigger than eligible amount ({eligible_amount})") - # Discriminate what to do next based on whether the requested amount is exact or less - if blind_messages_amount == eligible_amount: - del dlc.debts[payout.pubkey] - else: - dlc.debts[payout.pubkey] -= blind_messages_amount + # Remove the payout from the map + del dlc.debts[payout.pubkey] + # Update the database await self.crud.set_dlc_settled_and_debts(dlc.dlc_root, json.dumps(dlc.debts), self.db, conn) + + # Append payout to verified results verified.append(payout) except (CashuError, Exception) as e: errors.append(DlcPayout( From cd2bf24eab79ede642c6fd60e342c6fa23658909 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Sat, 21 Sep 2024 21:10:54 +0200 Subject: [PATCH 63/68] fix tests --- tests/test_dlc.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_dlc.py b/tests/test_dlc.py index 12cbb7bd..729a4947 100644 --- a/tests/test_dlc.py +++ b/tests/test_dlc.py @@ -74,7 +74,7 @@ async def assert_err(f, msg: Union[str, CashuError]): @pytest.mark.asyncio async def test_merkle_hash(): data = [b'\x01', b'\x02'] - target = '25dfd29c09617dcc9852281c030e5b3037a338a4712a42a21c907f259c6412a0' + target = 'a12871fee210fb8619291eaea194581cbd2531e4b23759d225f6806923f63222' h = sorted_merkle_hash(data[1], data[0]) assert h.hex() == target, f'sorted_merkle_hash test fail: {h.hex() = }' h = sorted_merkle_hash(data[0], data[1]) @@ -82,7 +82,7 @@ async def test_merkle_hash(): @pytest.mark.asyncio async def test_merkle_root(): - target = '0ee849f3b077380cd2cf5c76c6d63bcaa08bea89c1ef9914e5bc86c174417cb3' + target = '3d8064e48c4983cf426e09706cc1f9f24f3f6d15308569e307e4d16f05e1fb04' leafs = [sha256(i.to_bytes(32, 'big')).digest() for i in range(16)] root, _ = merkle_root(leafs) assert root.hex() == target, f"merkle_root test fail: {root.hex() = }" From 5b35eb798be1762e89c7b791d9a273469d3a830a Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Wed, 30 Oct 2024 17:15:50 +0100 Subject: [PATCH 64/68] corrections --- cashu/core/base.py | 2 +- cashu/core/models.py | 1 - cashu/mint/conditions.py | 2 +- cashu/mint/ledger.py | 5 ++--- tests/test_dlc.py | 5 ++--- 5 files changed, 6 insertions(+), 9 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index 55bf7339..cc11f93d 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -1349,5 +1349,5 @@ class DlcPayoutForm(BaseModel): class DlcPayout(BaseModel): dlc_root: str - outputs: Optional[List[BlindedSignature]] = None + signatures: Optional[List[BlindedSignature]] = None detail: Optional[str] = None # error details diff --git a/cashu/core/models.py b/cashu/core/models.py index a19d4194..3fa140be 100644 --- a/cashu/core/models.py +++ b/cashu/core/models.py @@ -379,6 +379,5 @@ class PostDlcPayoutResponse(BaseModel): class GetDlcStatusResponse(BaseModel): settled: bool - unit: Optional[str] = None funding_amount: Optional[int] = None debts: Optional[Dict[str, int]] = None diff --git a/cashu/mint/conditions.py b/cashu/mint/conditions.py index ac95bef1..113f557b 100644 --- a/cashu/mint/conditions.py +++ b/cashu/mint/conditions.py @@ -167,7 +167,7 @@ def _verify_secret_signatures( if verify_schnorr_signature( message=proof.secret.encode("utf-8"), pubkey=PublicKey(bytes.fromhex(pubkey), raw=True), - signature=bytes.fromhex(signature), + signature=bytes.fromhex(input_sig), ): n_valid_sigs_per_output += 1 logger.trace( diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 046b7478..3db3bfb4 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -1202,7 +1202,6 @@ async def status_dlc(self, dlc_root: str) -> GetDlcStatusResponse: return GetDlcStatusResponse( settled=dlc.settled, funding_amount=dlc.funding_amount, - unit=dlc.unit, debts=None ) else: @@ -1354,10 +1353,10 @@ async def payout_dlc(self, request: PostDlcPayoutRequest) -> PostDlcPayoutRespon # We generate blind signatures payouts: List[DlcPayout] = [] for payout in db_verified: - outputs = await self._generate_promises(payout.outputs) + promises = await self._generate_promises(payout.outputs) payouts.append(DlcPayout( dlc_root=payout.dlc_root, - outputs=outputs, + signatures=promises, )) return PostDlcPayoutResponse( diff --git a/tests/test_dlc.py b/tests/test_dlc.py index 729a4947..208cea9f 100644 --- a/tests/test_dlc.py +++ b/tests/test_dlc.py @@ -451,7 +451,6 @@ async def test_get_dlc_status(wallet: Wallet, ledger: Ledger): assert response.debts is None and \ response.settled is False and \ response.funding_amount == 128 and \ - response.unit == "sat", \ "GetDlcStatusResponse with unexpected fields" privkey1 = PrivateKey() @@ -666,6 +665,6 @@ async def test_payout_dlc(wallet: Wallet, ledger: Ledger): assert response.errors is None, f"Payout failed: {response.errors[0].detail}" assert len(response.paid) > 0, "Payout failed: paid list is empty" assert response.paid[0].dlc_root == dlc_root.hex() - assert len(response.paid[0].outputs) == len(amounts) + assert len(response.paid[0].signatures) == len(amounts) - proofs = await wallet._construct_proofs(response.paid[0].outputs, secrets, rs, dpaths) \ No newline at end of file + proofs = await wallet._construct_proofs(response.paid[0].signatures, secrets, rs, dpaths) \ No newline at end of file From 897141b90048e865357a304a4e96f0b1ab3db16c Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Wed, 30 Oct 2024 17:18:34 +0100 Subject: [PATCH 65/68] make format + remove weird SCTWitness clone --- cashu/core/base.py | 9 --------- cashu/mint/conditions.py | 1 - 2 files changed, 10 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index cc11f93d..f0dc4a05 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -121,15 +121,6 @@ class SCTWitness(BaseModel): def from_witness(cls, witness: str): return cls(**json.loads(witness)) -class SCTWitness(BaseModel): - leaf_secret: str - merkle_proof: List[str] - witness: Optional[str] = None - - @classmethod - def from_witness(cls, witness: str): - return cls(**json.loads(witness)) - class Proof(BaseModel): """ diff --git a/cashu/mint/conditions.py b/cashu/mint/conditions.py index 113f557b..c28547b9 100644 --- a/cashu/mint/conditions.py +++ b/cashu/mint/conditions.py @@ -7,7 +7,6 @@ from ..core.base import ( BlindedMessage, DiscreetLogContract, - Proof, SCTWitness, ) From aa7e38e33204ddbcfccdd97f75d93387a2bcd985 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Wed, 30 Oct 2024 17:20:41 +0100 Subject: [PATCH 66/68] remove more weird duplicates --- cashu/core/base.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index f0dc4a05..cf5b758b 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -217,16 +217,6 @@ def dlc_merkle_proof(self) -> List[str]: assert self.witness, "Witness is missing for dlc merkle proof" return SCTWitness.from_witness(self.witness).merkle_proof - @property - def dlc_leaf_secret(self) -> str: - assert self.witness, "Witness is missing for dlc leaf secret" - return SCTWitness.from_witness(self.witness).leaf_secret - - @property - def dlc_merkle_proof(self) -> List[str]: - assert self.witness, "Witness is missing for dlc merkle proof" - return SCTWitness.from_witness(self.witness).merkle_proof - @property def htlcpreimage(self) -> str | None: assert self.witness, "Witness is missing for htlc preimage" From 6e204bc6d0e46eeb43741e33018f71be5914c9b0 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Wed, 30 Oct 2024 17:48:10 +0100 Subject: [PATCH 67/68] unused `proofs` in tests --- tests/test_dlc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_dlc.py b/tests/test_dlc.py index 208cea9f..a8dbd4be 100644 --- a/tests/test_dlc.py +++ b/tests/test_dlc.py @@ -667,4 +667,4 @@ async def test_payout_dlc(wallet: Wallet, ledger: Ledger): assert response.paid[0].dlc_root == dlc_root.hex() assert len(response.paid[0].signatures) == len(amounts) - proofs = await wallet._construct_proofs(response.paid[0].signatures, secrets, rs, dpaths) \ No newline at end of file + await wallet._construct_proofs(response.paid[0].signatures, secrets, rs, dpaths) \ No newline at end of file From a4084aaa15c7110a0a89e1f85b7b853e37bac1af Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Sat, 30 Nov 2024 21:10:35 +0100 Subject: [PATCH 68/68] fix some minor typos --- cashu/mint/features.py | 2 +- cashu/mint/ledger.py | 3 ++- cashu/mint/verification.py | 1 - 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cashu/mint/features.py b/cashu/mint/features.py index a08d7dc5..d571db7e 100644 --- a/cashu/mint/features.py +++ b/cashu/mint/features.py @@ -63,7 +63,7 @@ def mint_features(self) -> Dict[int, Union[List[Any], Dict[str, Any]]]: DLC_NUT: dict( supported=True, funding_proof_pubkey='XXXXXX', - max_payous=30, + max_payouts=30, ttl=2629743, # 1 month fees=dict( sat=dict( diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 3db3bfb4..51ac2bb4 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -1223,8 +1223,9 @@ async def register_dlc(self, request: PostDlcRegistrationRequest) -> PostDlcRegi for registration in request.registrations: try: logger.trace(f"processing registration {registration.dlc_root}") - assert registration.inputs is not None # mypy give me a break + assert registration.inputs is not None await self._verify_dlc_inputs(registration) + amount_provided = await self._verify_dlc_amount_fees_coverage( registration.funding_amount, registration.unit, diff --git a/cashu/mint/verification.py b/cashu/mint/verification.py index d697da5c..77c665c3 100644 --- a/cashu/mint/verification.py +++ b/cashu/mint/verification.py @@ -335,7 +335,6 @@ async def _verify_dlc_inputs(self, dlc: DiscreetLogContract): Args: dlc (DiscreetLogContract): the DLC to be funded - proofs: (List[Proof]): proofs to be verified Raises: DlcVerificationFail