diff --git a/.gitignore b/.gitignore index 1936ea893..b2adfec99 100644 --- a/.gitignore +++ b/.gitignore @@ -145,3 +145,6 @@ tor-data *.pem junit.xml + +# Ralph +.ralph diff --git a/cashu/core/base.py b/cashu/core/base.py index 0d424ef48..287d07150 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -19,15 +19,21 @@ from ..mint.events.event_model import LedgerEvent from .crypto.aes import AESCipher from .crypto.b_dhke import hash_to_curve +from .crypto.bls import PublicKey as BlsPublicKey +from .crypto.bls_dhke import hash_to_curve as bls_hash_to_curve +from .crypto.interfaces import PrivateKey, PublicKey from .crypto.keys import ( derive_keys, derive_keys_deprecated_pre_0_15, + derive_keys_v3, derive_keyset_id, derive_keyset_id_deprecated, derive_keyset_id_v2, + derive_keyset_id_v3, derive_pubkeys, + is_bls_keyset, ) -from .crypto.secp import PrivateKey, PublicKey +from .crypto.secp import SecpPublicKey from .legacy import derive_keys_backwards_compatible_insecure_pre_0_12 from .settings import settings @@ -146,7 +152,13 @@ class Proof(BaseModel): def __init__(self, **data): super().__init__(**data) - self.Y = hash_to_curve(self.secret.encode("utf-8")).format().hex() + # v3 (BLS12-381) keysets compute Y on G1; v0/v1/v2 keep secp256k1 hash-to-curve. + # Y is used purely as a lookup index for proofs_used / checkstate / subscriptions, + # so the wallet must compute the same hex for the same (secret, keyset_version). + if self.id and is_bls_keyset(self.id): + self.Y = bls_hash_to_curve(self.secret.encode("utf-8")).format().hex() + else: + self.Y = hash_to_curve(self.secret.encode("utf-8")).format().hex() @classmethod def from_dict(cls, proof_dict: dict): @@ -266,11 +278,16 @@ class BlindedSignature(BaseModel): @classmethod def from_row(cls, row: Row): + # v3 (BLS) promises store dleq_e / dleq_s as NULL because pairings replace DLEQ. + # Match the model's `dleq: Optional[DLEQ] = None` declaration and skip construction. + dleq_e = row["dleq_e"] + dleq_s = row["dleq_s"] + dleq = DLEQ(e=dleq_e, s=dleq_s) if None not in (dleq_e, dleq_s) else None return cls( id=row["id"], amount=row["amount"], C_=row["c_"], - dleq=DLEQ(e=row["dleq_e"], s=row["dleq_s"]), + dleq=dleq, ) @@ -759,12 +776,17 @@ def serialize(self): ) @classmethod - def from_row(cls, row: Row): + def from_row(cls, row: RowMapping): def deserialize(serialized: str) -> Dict[int, PublicKey]: - return { - int(amount): PublicKey(bytes.fromhex(hex_key)) - for amount, hex_key in dict(json.loads(serialized)).items() - } + + is_v3 = is_bls_keyset(row["id"]) + pub_keys: Dict[int, PublicKey] = {} + for amount, hex_key in dict(json.loads(serialized)).items(): + if is_v3: + pub_keys[int(amount)] = BlsPublicKey(bytes.fromhex(hex_key), group="G2") # type: ignore + else: + pub_keys[int(amount)] = SecpPublicKey(bytes.fromhex(hex_key)) # type: ignore + return pub_keys return cls( id=row["id"], @@ -974,7 +996,7 @@ def generate_keys(self): assert self.public_keys is not None self.id = derive_keyset_id(self.public_keys) logger.info(f"Generated keyset v1 ID: {self.id}") - else: + elif self.version_tuple < (0, 21): self.private_keys = derive_keys( self.seed, self.derivation_path, self.amounts ) @@ -988,6 +1010,19 @@ def generate_keys(self): assert self.public_keys is not None self.id = derive_keyset_id_v2(self.public_keys, self.unit.name, self.final_expiry, self.input_fee_ppk) logger.info(f"Generated keyset v2 ID: {self.id}") + else: + self.private_keys = derive_keys_v3( + self.seed, self.derivation_path, self.amounts + ) # type: ignore + self.public_keys = derive_pubkeys(self.private_keys, self.amounts) # type: ignore + + # KEYSETS V3: BLS12-381 cryptography + if id_in_db: + self.id = id_in_db + else: + assert self.public_keys is not None + self.id = derive_keyset_id_v3(self.public_keys, self.unit.name, self.final_expiry, self.input_fee_ppk) + logger.info(f"Generated keyset v3 (BLS) ID: {self.id}") # ------- TOKEN ------- diff --git a/cashu/core/crypto/b_dhke.py b/cashu/core/crypto/b_dhke.py index bb54637b4..a98122bdc 100644 --- a/cashu/core/crypto/b_dhke.py +++ b/cashu/core/crypto/b_dhke.py @@ -51,14 +51,22 @@ """ import hashlib -from typing import Optional, Tuple +from typing import Optional, Tuple, Union, cast -from .secp import PrivateKey, PublicKey +from .bls import PrivateKey as BlsPrivateKey +from .bls import PublicKey as BlsPublicKey +from .secp import SecpPrivateKey, SecpPublicKey +PublicKey = Union[SecpPublicKey, BlsPublicKey] +PrivateKey = Union[SecpPrivateKey, BlsPrivateKey] + + +# Remove the import from bls_dhke as it's causing redefinition +# from .bls_dhke import hash_to_curve DOMAIN_SEPARATOR = b"Secp256k1_HashToCurve_Cashu_" -def hash_to_curve(message: bytes) -> PublicKey: +def hash_to_curve(message: bytes) -> SecpPublicKey: """Generates a secp256k1 point from a message. The point is generated by hashing the message with a domain separator and then @@ -78,45 +86,51 @@ def hash_to_curve(message: bytes) -> PublicKey: _hash = hashlib.sha256(msg_to_hash + counter.to_bytes(4, "little")).digest() try: # will error if point does not lie on curve - return PublicKey(b"\x02" + _hash) + return SecpPublicKey(b"\x02" + _hash) except Exception: counter += 1 # it should never reach this point raise ValueError("No valid point found") -def step1_alice( - secret_msg: str, blinding_factor: Optional[PrivateKey] = None -) -> tuple[PublicKey, PrivateKey]: - Y: PublicKey = hash_to_curve(secret_msg.encode("utf-8")) - r = blinding_factor or PrivateKey() - B_: PublicKey = Y + r.public_key # type: ignore - return B_, r +def step1_alice(secret_msg: str, blinding_factor: Optional[SecpPrivateKey] = None) -> Tuple[SecpPublicKey, SecpPrivateKey]: + Y: SecpPublicKey = hash_to_curve(secret_msg.encode("utf-8")) # type: ignore + r = blinding_factor or SecpPrivateKey() + B_: SecpPublicKey = Y + r.public_key # type: ignore + return B_, r # type: ignore -def step2_bob(B_: PublicKey, a: PrivateKey) -> Tuple[PublicKey, PrivateKey, PrivateKey]: - C_: PublicKey = B_ * a # type: ignore +def step2_bob(B_: SecpPublicKey, a: SecpPrivateKey) -> Tuple[SecpPublicKey, SecpPrivateKey, SecpPrivateKey]: + C_: SecpPublicKey = B_ * a # type: ignore # produce dleq proof e, s = step2_bob_dleq(B_, a) - return C_, e, s + return C_, SecpPrivateKey(bytes.fromhex(e.to_hex())), SecpPrivateKey(bytes.fromhex(s.to_hex())) -def step3_alice(C_: PublicKey, r: PrivateKey, A: PublicKey) -> PublicKey: - C: PublicKey = C_ - A * r # type: ignore +def step3_alice( + C_: Union[SecpPublicKey, BlsPublicKey], + r: Union[SecpPrivateKey, BlsPrivateKey], + A: Union[SecpPublicKey, BlsPublicKey], +) -> Union[SecpPublicKey, BlsPublicKey]: + C = C_ - A * r return C -def verify(a: PrivateKey, C: PublicKey, secret_msg: str) -> bool: - Y: PublicKey = hash_to_curve(secret_msg.encode("utf-8")) +def verify( + a: Union[SecpPrivateKey, BlsPrivateKey], + C: Union[SecpPublicKey, BlsPublicKey], + secret_msg: str, +) -> bool: + Y: Union[SecpPublicKey, BlsPublicKey] = hash_to_curve(secret_msg.encode("utf-8")) # type: ignore valid = C == Y * a # type: ignore # BEGIN: BACKWARDS COMPATIBILITY < 0.15.1 if not valid: - valid = verify_deprecated(a, C, secret_msg) + valid = verify_deprecated(a, C, secret_msg) # type: ignore # END: BACKWARDS COMPATIBILITY < 0.15.1 return valid -def hash_e(*publickeys: PublicKey) -> bytes: +def hash_e(*publickeys: Union[SecpPublicKey, BlsPublicKey]) -> bytes: e_ = "" for p in publickeys: _p = p.format(compressed=False).hex() @@ -126,35 +140,42 @@ def hash_e(*publickeys: PublicKey) -> bytes: def step2_bob_dleq( - B_: PublicKey, a: PrivateKey, p_bytes: bytes = b"" -) -> Tuple[PrivateKey, PrivateKey]: + B_: SecpPublicKey, + a: SecpPrivateKey, + p_bytes: bytes = b"", +) -> Tuple[Union[SecpPrivateKey, BlsPrivateKey], Union[SecpPrivateKey, BlsPrivateKey]]: + if p_bytes: # deterministic p for testing - p = PrivateKey(p_bytes) + p = SecpPrivateKey(p_bytes) else: - # normally, we generate a random p - p = PrivateKey() + p = SecpPrivateKey() + - R1 = p.public_key # R1 = pG + R1 = SecpPublicKey(p.public_key.format()) # R1 = pG assert R1 - R2: PublicKey = B_ * p # type: ignore - C_: PublicKey = B_ * a # type: ignore - A = a.public_key + R2: PublicKey = cast(PublicKey, B_ * p) # type: ignore + C_: PublicKey = cast(PublicKey, B_ * a) # type: ignore + A = cast(PublicKey, a.public_key) assert A e = hash_e(R1, R2, A, C_) # e = hash(R1, R2, A, C_) s = p.add(bytes.fromhex(a.multiply(e).to_hex())) # s = p + ek - spk = PrivateKey(bytes.fromhex(s.to_hex())) - epk = PrivateKey(e) + spk = SecpPrivateKey(bytes.fromhex(s.to_hex())) + epk = SecpPrivateKey(e) return epk, spk def alice_verify_dleq( B_: PublicKey, C_: PublicKey, e: PrivateKey, s: PrivateKey, A: PublicKey ) -> bool: - R1 = s.public_key - A * e # type: ignore - R2 = B_ * s - C_ * e # type: ignore + s_pub = SecpPublicKey(s.public_key.format()) + A_secp = SecpPublicKey(A.format()) + B_secp = SecpPublicKey(B_.format()) + C_secp = SecpPublicKey(C_.format()) + R1 = s_pub - A_secp * e # type: ignore + R2 = B_secp * s - C_secp * e # type: ignore e_bytes = bytes.fromhex(e.to_hex()) - return e_bytes == hash_e(R1, R2, A, C_) + return e_bytes == hash_e(R1, R2, A_secp, C_secp) def carol_verify_dleq( @@ -179,7 +200,7 @@ def carol_verify_dleq( # -------- Deprecated hash_to_curve before 0.15.0 -------- -def hash_to_curve_deprecated(message: bytes) -> PublicKey: +def hash_to_curve_deprecated(message: bytes) -> SecpPublicKey: """Generates a point from the message hash and checks if the point lies on the curve. If it does not, iteratively tries to compute a new point from the hash.""" point = None @@ -188,7 +209,7 @@ def hash_to_curve_deprecated(message: bytes) -> PublicKey: _hash = hashlib.sha256(msg_to_hash).digest() try: # will error if point does not lie on curve - point = PublicKey(b"\x02" + _hash) + point = SecpPublicKey(b"\x02" + _hash) except Exception: msg_to_hash = _hash return point @@ -198,12 +219,12 @@ def step1_alice_deprecated( secret_msg: str, blinding_factor: Optional[PrivateKey] = None ) -> tuple[PublicKey, PrivateKey]: Y: PublicKey = hash_to_curve_deprecated(secret_msg.encode("utf-8")) - r = blinding_factor or PrivateKey() + r = blinding_factor or SecpPrivateKey() B_: PublicKey = Y + r.public_key # type: ignore return B_, r -def verify_deprecated(a: PrivateKey, C: PublicKey, secret_msg: str) -> bool: +def verify_deprecated(a: SecpPrivateKey, C: SecpPublicKey, secret_msg: str) -> bool: Y: PublicKey = hash_to_curve_deprecated(secret_msg.encode("utf-8")) valid = C == Y * a # type: ignore return valid diff --git a/cashu/core/crypto/bls.py b/cashu/core/crypto/bls.py new file mode 100644 index 000000000..f39fff565 --- /dev/null +++ b/cashu/core/crypto/bls.py @@ -0,0 +1,71 @@ +import os +from typing import Optional + +import pyblst + +from .interfaces import ICashuPrivateKey, ICashuPublicKey + +curve_order = 52435875175126190479447740508185965837690552500527637822603658699938581184513 +_G2_HEX = '93e02b6052719f607dacd3a088274f65596bd0d09920b61ab5da61bbdc7f5049334cf11213945d57e5ac7d055d042b7e024aa2b2f08f0a91260805272dc51051c6e47ad4fa403b02b4510b647ae3d1770bac0326a805bbefd48056c8c121bdb8' + + + +class PrivateKey(ICashuPrivateKey): + def __init__(self, privkey: bytes = b"", scalar: Optional[int] = None): + if scalar is not None: + self.scalar = scalar % curve_order + elif privkey: + self.scalar = int.from_bytes(privkey, "big") % curve_order + else: + self.scalar = int.from_bytes(os.urandom(32), "big") % curve_order + + @property + def private_key(self) -> bytes: + return self.scalar.to_bytes(32, "big") + + def to_hex(self) -> str: + return self.private_key.hex() + + def get_g2_public_key(self) -> "PublicKey": + pt = pyblst.BlstP2Element().uncompress(bytes.fromhex(_G2_HEX)).scalar_mul(self.scalar) + return PublicKey(point=pt, group="G2") + + @property + def public_key(self) -> "PublicKey": + return self.get_g2_public_key() + + +class PublicKey(ICashuPublicKey): + def __init__(self, compressed: bytes = b"", point=None, group="G1"): + self.group = group + try: + if point is not None: + self.point = point + elif compressed: + if self.group == "G1": + self.point = pyblst.BlstP1Element().uncompress(compressed) + else: + self.point = pyblst.BlstP2Element().uncompress(compressed) + else: + raise ValueError("Must provide point or compressed bytes") + except Exception as e: + print(f"Exception parsing BLS public key (group={self.group}, compressed={compressed.hex() if compressed else ''}):", repr(e)) + raise ValueError("The public key could not be parsed or is invalid.") + + def format(self, compressed: bool = True) -> bytes: + return self.point.compress() + + def serialize(self) -> bytes: + return self.format() + + def __eq__(self, other): + if isinstance(other, PublicKey): + return self.point == other.point + return False + + def __mul__(self, scalar): + if isinstance(scalar, PrivateKey): + return PublicKey(point=self.point.scalar_mul(scalar.scalar), group=self.group) + elif isinstance(scalar, int): + return PublicKey(point=self.point.scalar_mul(scalar), group=self.group) + raise TypeError("Can't multiply with non-scalar") diff --git a/cashu/core/crypto/bls_dhke.py b/cashu/core/crypto/bls_dhke.py new file mode 100644 index 000000000..80f7a92d9 --- /dev/null +++ b/cashu/core/crypto/bls_dhke.py @@ -0,0 +1,149 @@ +import hashlib +import os +from typing import Optional, Tuple + +import pyblst + +from .bls import _G2_HEX, PrivateKey, PublicKey, curve_order +from .interfaces import ICashuPrivateKey, ICashuPublicKey + +# Cashu specific domain separation tag for BLS12-381 G1 +DST = b"CASHU_BLS12_381_G1_XMD:SHA-256_SSWU_RO_" + +def ext_euclid(a, b): + if b == 0: + return 1, 0, a + x, y, g = ext_euclid(b, a % b) + return y, x - y * (a // b), g + +def mod_inverse(a, m): + x, y, g = ext_euclid(a, m) + if g != 1: + raise Exception('modular inverse does not exist') + return x % m + +def hash_to_curve(message: bytes) -> PublicKey: + """ + Hash a message to a point on G1 using SSWU. + """ + pt = pyblst.BlstP1Element().hash_to_group(message, DST) + return PublicKey(point=pt, group="G1") + +def step1_alice( + secret_msg: str, blinding_factor: Optional[PrivateKey] = None +) -> tuple[PublicKey, PrivateKey]: + """ + Alice blinds the message: B' = Y * r + where Y = hash_to_curve(secret_msg) + """ + Y: PublicKey = hash_to_curve(secret_msg.encode("utf-8")) + r = blinding_factor or PrivateKey() + B_: PublicKey = Y * r + return B_, r + +def step2_bob(B_: PublicKey, a: PrivateKey) -> Tuple[PublicKey, None, None]: + """ + Bob signs the blinded message: C' = B' * a + Returns C' and dummy DLEQ values since BLS12-381 pairings make DLEQ proofs redundant. + """ + C_: PublicKey = B_ * a + # Return dummy private keys for backwards compatibility with DLEQ logic elsewhere + return C_, None, None + + + +def step3_alice(C_: PublicKey, r: ICashuPrivateKey, A: ICashuPublicKey) -> PublicKey: + """ + Alice unblinds the signature: C = C' * (1/r) + """ + r_inv = mod_inverse(r.scalar, curve_order) # type: ignore + C: PublicKey = C_ * r_inv + return C + +def keyed_verification(a: PrivateKey, C: PublicKey, secret_msg: str) -> bool: + """ + Mint verification: checks C == Y * a + """ + Y: PublicKey = hash_to_curve(secret_msg.encode("utf-8")) + valid = C == Y * a + return valid + +def pairing_verification(K2: PublicKey, C: PublicKey, secret_msg: str) -> bool: + """ + Verify the BLS signature using pairings. + e(C, G2) == e(Y, K2) + """ + Y = hash_to_curve(secret_msg.encode("utf-8")) + + g2_point = pyblst.BlstP2Element().uncompress(bytes.fromhex(_G2_HEX)) + + p1 = pyblst.miller_loop(C.point, g2_point) + p2 = pyblst.miller_loop(Y.point, K2.point) + return pyblst.final_verify(p1, p2) + +def batch_pairing_verification(K2s: list[PublicKey], Cs: list[PublicKey], secret_msgs: list[str]) -> bool: + """ + Batch verifies BLS12-381 signatures using random linear combinations. + This significantly improves performance over checking each signature individually. + """ + n = len(Cs) + if n == 0: + return True + + # Generate random 256-bit scalars + rs = [int.from_bytes(os.urandom(32), "big") for _ in range(n)] + Ys = [hash_to_curve(msg.encode("utf-8")) for msg in secret_msgs] + + # Left side: sum(r_i * C_i) + sum_C = Cs[0].point.scalar_mul(rs[0]) + for i in range(1, n): + sum_C = sum_C + Cs[i].point.scalar_mul(rs[i]) + + g2_point = pyblst.BlstP2Element().uncompress(bytes.fromhex(_G2_HEX)) + left_miller = pyblst.miller_loop(sum_C, g2_point) + + # Right side: prod(e(sum(r_i * Y_i), K2_j)) grouped by unique K2 + # Group the Y points by their corresponding K2 point + grouped_Ys = {} + for i in range(n): + k2_hex = K2s[i].format().hex() + y_r = Ys[i].point.scalar_mul(rs[i]) + + if k2_hex not in grouped_Ys: + grouped_Ys[k2_hex] = {"k2": K2s[i].point, "sum_y": y_r} + else: + grouped_Ys[k2_hex]["sum_y"] = grouped_Ys[k2_hex]["sum_y"] + y_r + + # Now compute the pairings for each unique K2 + right_miller = None + for group in grouped_Ys.values(): + miller = pyblst.miller_loop(group["sum_y"], group["k2"]) + if right_miller is None: + right_miller = miller + else: + right_miller = right_miller * miller + + return pyblst.final_verify(left_miller, right_miller) + +def hash_e(*publickeys: PublicKey) -> bytes: + """Dummy for backwards compatibility""" + e_ = "" + for p in publickeys: + _p = p.format(compressed=True).hex() + e_ += str(_p) + return hashlib.sha256(e_.encode("utf-8")).digest() + +# Deprecated functions (kept to avoid import errors, though they shouldn't be called) +def hash_to_curve_deprecated(message: bytes) -> PublicKey: + return hash_to_curve(message) + +def step1_alice_deprecated( + secret_msg: str, blinding_factor: Optional[PrivateKey] = None +) -> tuple[PublicKey, PrivateKey]: + return step1_alice(secret_msg, blinding_factor) + +def verify_deprecated(a: PrivateKey, C: PublicKey, secret_msg: str) -> bool: + return keyed_verification(a, C, secret_msg) + +def carol_verify_dleq_deprecated(*args, **kwargs): + return True diff --git a/cashu/core/crypto/interfaces.py b/cashu/core/crypto/interfaces.py new file mode 100644 index 000000000..84013e857 --- /dev/null +++ b/cashu/core/crypto/interfaces.py @@ -0,0 +1,15 @@ +from abc import ABC, abstractmethod + + +class ICashuPublicKey(ABC): + @abstractmethod + def format(self, compressed: bool = True) -> bytes: ... + @abstractmethod + def serialize(self) -> bytes: ... + +class ICashuPrivateKey(ABC): + @abstractmethod + def to_hex(self) -> str: ... + +PublicKey = ICashuPublicKey +PrivateKey = ICashuPrivateKey diff --git a/cashu/core/crypto/keys.py b/cashu/core/crypto/keys.py index bee0237d7..7b75e695e 100644 --- a/cashu/core/crypto/keys.py +++ b/cashu/core/crypto/keys.py @@ -1,11 +1,13 @@ import base64 import hashlib import random -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Union from bip32 import BIP32 -from .secp import PrivateKey, PublicKey +from .bls import PrivateKey as BlsPrivateKey +from .interfaces import ICashuPublicKey +from .secp import SecpPrivateKey, SecpPublicKey def derive_keys(mnemonic: str, derivation_path: str, amounts: List[int]): @@ -15,7 +17,7 @@ def derive_keys(mnemonic: str, derivation_path: str, amounts: List[int]): bip32 = BIP32.from_seed(mnemonic.encode()) orders_str = [f"/{a}'" for a in range(len(amounts))] return { - a: PrivateKey( + a: SecpPrivateKey( bip32.get_privkey_from_path(derivation_path + orders_str[i]), ) for i, a in enumerate(amounts) @@ -29,7 +31,7 @@ def derive_keys_deprecated_pre_0_15( Deterministic derivation of keys for 2^n values. """ return { - a: PrivateKey( + a: SecpPrivateKey( hashlib.sha256((seed + derivation_path + str(i)).encode("utf-8")).digest()[ :32 ], @@ -38,19 +40,21 @@ def derive_keys_deprecated_pre_0_15( } -def derive_pubkey(seed: str) -> PublicKey: - pubkey = PrivateKey( +def derive_pubkey(seed: str) -> SecpPublicKey: + pubkey = SecpPrivateKey( hashlib.sha256((seed).encode("utf-8")).digest()[:32], ).public_key assert pubkey - return pubkey + return pubkey # type: ignore -def derive_pubkeys(keys: Dict[int, PrivateKey], amounts: List[int]): +def derive_pubkeys( + keys: Dict[int, Union[SecpPrivateKey, BlsPrivateKey]], amounts: List[int] +): return {amt: keys[amt].public_key for amt in amounts} -def derive_keyset_id(keys: Dict[int, PublicKey]): +def derive_keyset_id(keys: Dict[int, ICashuPublicKey]): """Deterministic derivation keyset_id from set of public keys (version 00).""" # sort public keys by amount sorted_keys = dict(sorted(keys.items())) @@ -59,7 +63,7 @@ def derive_keyset_id(keys: Dict[int, PublicKey]): def derive_keyset_id_v2( - keys: Dict[int, PublicKey], + keys: Dict[int, ICashuPublicKey], unit: str, final_expiry: Optional[int] = None, input_fee_ppk: int = 0, @@ -99,6 +103,7 @@ def derive_keyset_id_v2( return f"01{hash_digest}" + def derive_keyset_short_id(keyset_id: str) -> str: """ Derive the short keyset ID (8 bytes) from a full keyset ID. @@ -113,9 +118,14 @@ def derive_keyset_short_id(keyset_id: str) -> str: if is_base64_keyset_id(keyset_id) or keyset_id.startswith("00"): return keyset_id - # For version 01, return first 16 chars (8 bytes in hex) - if keyset_id.startswith("01"): - return keyset_id[:16] + # For version 01 and onwards, return first 16 chars (8 bytes in hex) + version = get_keyset_id_version(keyset_id) + if version != "base64": + try: + if int(version) >= 1: + return keyset_id[:16] + except ValueError: + pass raise ValueError(f"Unsupported keyset version in ID: {keyset_id}") @@ -136,7 +146,7 @@ def is_base64_keyset_id(keyset_id: str) -> bool: True if the keyset ID is base64 format, False otherwise """ # If it starts with a known version prefix, it's not base64 - if keyset_id.startswith("00") or keyset_id.startswith("01"): + if keyset_id.startswith(("00", "01", "02")): return False # Try to decode as base64 to confirm @@ -166,12 +176,22 @@ def get_keyset_id_version(keyset_id: str) -> str: return keyset_id[:2] +def is_bls_keyset(keyset_id: str) -> bool: + """Check if a keyset ID uses BLS12-381 cryptography (version >= 02).""" + version = get_keyset_id_version(keyset_id) + if version == "base64": + return False + try: + return int(version) >= 2 + except ValueError: + return False + def is_keyset_id_v2(keyset_id: str) -> bool: """Check if a keyset ID is version 2 (starts with '01').""" return get_keyset_id_version(keyset_id) == '01' -def derive_keyset_id_deprecated(keys: Dict[int, PublicKey]): +def derive_keyset_id_deprecated(keys: Dict[int, ICashuPublicKey]): """DEPRECATED 0.15.0: Deterministic derivation keyset_id from set of public keys. DEPRECATION: This method produces base64 keyset ids. Use `derive_keyset_id` instead. """ @@ -183,8 +203,45 @@ def derive_keyset_id_deprecated(keys: Dict[int, PublicKey]): ).decode()[:12] +def derive_keys_v3(mnemonic: str, derivation_path: str, amounts: List[int]) -> Dict[int, BlsPrivateKey]: + """ + Deterministic derivation of BLS12-381 keys for 2^n values. + Since BIP32 doesn't technically cover BLS12-381, we use HKDF or simple hashing on the BIP32 seed. + For simplicity and backwards compatibility of mnemonic/path logic, we hash the BIP32 path output to generate the scalar. + """ + bip32 = BIP32.from_seed(mnemonic.encode()) + orders_str = [f"/{a}'" for a in range(len(amounts))] + return { + a: BlsPrivateKey( + hashlib.sha256(bip32.get_privkey_from_path(derivation_path + orders_str[i])).digest() + ) + for i, a in enumerate(amounts) + } + + +def derive_keyset_id_v3( + keys: Dict[int, ICashuPublicKey], + unit: str, + final_expiry: Optional[int] = None, + input_fee_ppk: int = 0, +) -> str: + """ + Deterministic derivation keyset_id v3 from set of BLS public keys (version 02). + """ + sorted_keys = dict(sorted(keys.items())) + keyset_id_bytes = b",".join([f"{a}:{p.format().hex()}".encode("utf-8") for (a, p) in sorted_keys.items()]) + keyset_id_bytes += f"|unit:{unit}".encode("utf-8") + if input_fee_ppk > 0: + keyset_id_bytes += f"|input_fee_ppk:{input_fee_ppk}".encode("utf-8") + if final_expiry is not None: + keyset_id_bytes += f"|final_expiry:{final_expiry}".encode("utf-8") + hash_digest = hashlib.sha256(keyset_id_bytes).hexdigest() + return f"02{hash_digest}" + + def random_hash() -> str: """Returns a base64-urlsafe encoded random hash.""" return base64.urlsafe_b64encode( bytes([random.getrandbits(8) for i in range(30)]) ).decode() + diff --git a/cashu/core/crypto/secp.py b/cashu/core/crypto/secp.py index ee531bc08..52a145476 100644 --- a/cashu/core/crypto/secp.py +++ b/cashu/core/crypto/secp.py @@ -1,11 +1,12 @@ -from coincurve import PrivateKey, PublicKey +from coincurve import PrivateKey as CoincurvePrivateKey +from coincurve import PublicKey as CoincurvePublicKey +from .interfaces import ICashuPrivateKey, ICashuPublicKey -# We extend the public key to define some operations on points -# Picked from https://github.com/WTRMQDev/secp256k1-zkp-py/blob/master/secp256k1_zkp/__init__.py -class PublicKeyExt(PublicKey): + +class SecpPublicKey(CoincurvePublicKey, ICashuPublicKey): def __add__(self, pubkey2): - if isinstance(pubkey2, PublicKey): + if isinstance(pubkey2, CoincurvePublicKey): return self.combine([pubkey2]) # type: ignore else: raise TypeError(f"Can't add pubkey and {pubkey2.__class__}") @@ -15,22 +16,25 @@ def __neg__(self): first_byte, remainder = serialized[:1], serialized[1:] # flip odd/even byte first_byte = {b"\x03": b"\x02", b"\x02": b"\x03"}[first_byte] - return PublicKey(first_byte + remainder) + return SecpPublicKey(first_byte + remainder) def __sub__(self, pubkey2): - if isinstance(pubkey2, PublicKey): + if isinstance(pubkey2, CoincurvePublicKey): + # Convert to SecpPublicKey if it's just a CoincurvePublicKey + if not isinstance(pubkey2, SecpPublicKey): + pubkey2 = SecpPublicKey(pubkey2.format()) return self + (-pubkey2) # type: ignore else: raise TypeError(f"Can't add pubkey and {pubkey2.__class__}") def __mul__(self, privkey): - if isinstance(privkey, PrivateKey): - return self.multiply(bytes.fromhex(privkey.to_hex())) + if isinstance(privkey, SecpPrivateKey): + return SecpPublicKey(self.multiply(bytes.fromhex(privkey.to_hex())).format()) else: raise TypeError("Can't multiply with non privatekey") def __eq__(self, pubkey2): - if isinstance(pubkey2, PublicKey): + if isinstance(pubkey2, CoincurvePublicKey): seq1 = self.to_data() seq2 = pubkey2.to_data() # type: ignore return seq1 == seq2 @@ -41,11 +45,11 @@ def to_data(self): assert self.public_key return [self.public_key.data[i] for i in range(64)] + def serialize(self) -> bytes: + return self.format() + +class SecpPrivateKey(CoincurvePrivateKey, ICashuPrivateKey): + def to_hex(self) -> str: + return super().to_hex() + -# Horrible monkeypatching -PublicKey.__add__ = PublicKeyExt.__add__ # type: ignore -PublicKey.__neg__ = PublicKeyExt.__neg__ # type: ignore -PublicKey.__sub__ = PublicKeyExt.__sub__ # type: ignore -PublicKey.__mul__ = PublicKeyExt.__mul__ # type: ignore -PublicKey.__eq__ = PublicKeyExt.__eq__ # type: ignore -PublicKey.to_data = PublicKeyExt.to_data # type: ignore diff --git a/cashu/core/crypto/types.py b/cashu/core/crypto/types.py new file mode 100644 index 000000000..01167d4c8 --- /dev/null +++ b/cashu/core/crypto/types.py @@ -0,0 +1,8 @@ +from typing import Union + +from .bls import PrivateKey as BlsPrivateKey +from .bls import PublicKey as BlsPublicKey +from .secp import SecpPrivateKey, SecpPublicKey + +AnyPrivateKey = Union[SecpPrivateKey, BlsPrivateKey] +AnyPublicKey = Union[SecpPublicKey, BlsPublicKey] diff --git a/cashu/core/legacy.py b/cashu/core/legacy.py index f53ef5971..d5ca6da70 100644 --- a/cashu/core/legacy.py +++ b/cashu/core/legacy.py @@ -1,6 +1,6 @@ import hashlib -from ..core.crypto.secp import PrivateKey +from ..core.crypto.secp import SecpPrivateKey from ..core.settings import settings @@ -11,7 +11,7 @@ def derive_keys_backwards_compatible_insecure_pre_0_12( WARNING: Broken key derivation for backwards compatibility with 0.11. """ return { - 2**i: PrivateKey( + 2**i: SecpPrivateKey( hashlib.sha256((seed + derivation_path + str(i)).encode("utf-8")) .hexdigest() .encode("utf-8")[:32] diff --git a/cashu/core/nuts/nut20.py b/cashu/core/nuts/nut20.py index eec40bd2b..67ae80519 100644 --- a/cashu/core/nuts/nut20.py +++ b/cashu/core/nuts/nut20.py @@ -4,11 +4,11 @@ from coincurve import PublicKeyXOnly from ..base import BlindedMessage -from ..crypto.secp import PrivateKey +from ..crypto.secp import SecpPrivateKey def generate_keypair() -> tuple[str, str]: - privkey = PrivateKey() + privkey = SecpPrivateKey() assert privkey.public_key pubkey = privkey.public_key return privkey.to_hex(), pubkey.format().hex() @@ -26,7 +26,7 @@ def sign_mint_quote( private_key: str, ) -> str: - privkey = PrivateKey(bytes.fromhex(private_key)) + privkey = SecpPrivateKey(bytes.fromhex(private_key)) msgbytes = construct_message(quote_id, outputs) sig = privkey.sign_schnorr(msgbytes) return sig.hex() diff --git a/cashu/core/p2pk.py b/cashu/core/p2pk.py index a0f8b15a1..4c9c7c846 100644 --- a/cashu/core/p2pk.py +++ b/cashu/core/p2pk.py @@ -4,7 +4,8 @@ from coincurve import PublicKeyXOnly -from .crypto.secp import PrivateKey, PublicKey +from .crypto.interfaces import PublicKey +from .crypto.secp import SecpPrivateKey from .errors import InvalidProofsError from .secret import Secret, SecretKind @@ -51,7 +52,7 @@ def n_sigs_refund(self) -> Union[None, int]: return n_sigs_refund -def schnorr_sign(message: bytes, private_key: PrivateKey) -> bytes: +def schnorr_sign(message: bytes, private_key: SecpPrivateKey) -> bytes: signature = private_key.sign_schnorr( hashlib.sha256(message).digest(), None, # type: ignore diff --git a/cashu/core/secret.py b/cashu/core/secret.py index a54b1e389..9e6a348b2 100644 --- a/cashu/core/secret.py +++ b/cashu/core/secret.py @@ -5,7 +5,7 @@ from loguru import logger from pydantic import BaseModel, RootModel -from .crypto.secp import PrivateKey +from .crypto.secp import SecpPrivateKey class SecretKind(Enum): @@ -67,7 +67,7 @@ class Secret(BaseModel): def serialize(self) -> str: data_dict: Dict[str, Any] = { "data": self.data, - "nonce": self.nonce or PrivateKey().to_hex()[:32], + "nonce": self.nonce or SecpPrivateKey().to_hex()[:32], } if self.tags.root: logger.debug(f"Serializing tags: {self.tags.root}") diff --git a/cashu/core/settings.py b/cashu/core/settings.py index aa8b95e55..4a3dd800f 100644 --- a/cashu/core/settings.py +++ b/cashu/core/settings.py @@ -9,7 +9,7 @@ env = Env() -VERSION = "0.20.0" +VERSION = "0.21.0" def find_env_file(): diff --git a/cashu/mint/conditions.py b/cashu/mint/conditions.py index da53d2660..e789074d0 100644 --- a/cashu/mint/conditions.py +++ b/cashu/mint/conditions.py @@ -4,7 +4,7 @@ from loguru import logger from ..core.base import BlindedMessage, P2PKWitness, Proof -from ..core.crypto.secp import PublicKey +from ..core.crypto.secp import SecpPublicKey from ..core.errors import ( TransactionError, ) @@ -161,7 +161,7 @@ def _verify_p2pk_signatures( logger.trace(f"Message: {message_to_sign}") if verify_schnorr_signature( message=message_to_sign.encode("utf-8"), - pubkey=PublicKey(bytes.fromhex(pubkey)), + pubkey=SecpPublicKey(bytes.fromhex(pubkey)), signature=bytes.fromhex(input_sig), ): n_pubkeys_with_valid_sigs += 1 @@ -385,7 +385,7 @@ def _verify_sigall_spending_conditions( for i, s in enumerate(signatures): if verify_schnorr_signature( message=message_to_sign.encode("utf-8"), - pubkey=PublicKey(bytes.fromhex(p)), + pubkey=SecpPublicKey(bytes.fromhex(p)), signature=bytes.fromhex(s), ): n_valid_sigs += 1 diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index fcf756a94..ca42b88a4 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -1,1054 +1,1057 @@ -import asyncio -import time -from typing import Dict, List, Mapping, Optional, Tuple - -import bolt11 -from loguru import logger - -from ..core.base import ( - DLEQ, - Amount, - BlindedMessage, - BlindedSignature, - MeltQuote, - MeltQuoteState, - Method, - MintKeyset, - MintQuote, - MintQuoteState, - Proof, - Unit, -) -from ..core.crypto import b_dhke -from ..core.crypto.aes import AESCipher -from ..core.crypto.keys import ( - derive_pubkey, - random_hash, -) -from ..core.crypto.secp import PrivateKey, PublicKey -from ..core.db import Connection, Database -from ..core.errors import ( - CashuError, - LightningError, - LightningPaymentFailedError, - NotAllowedError, - QuoteAlreadyIssuedError, - QuoteNotPaidError, - QuoteSignatureInvalidError, - TransactionAmountExceedsLimitError, - TransactionError, -) -from ..core.helpers import sum_proofs -from ..core.models import ( - PostMeltQuoteRequest, - PostMeltQuoteResponse, - PostMintQuoteRequest, -) -from ..core.settings import settings -from ..core.split import amount_split -from ..lightning.base import ( - InvoiceResponse, - LightningBackend, - PaymentQuoteResponse, - PaymentResponse, - PaymentResult, - PaymentStatus, -) -from ..mint.crud import LedgerCrudSqlite -from .conditions import LedgerSpendingConditions -from .db.read import DbReadHelper -from .db.write import DbWriteHelper -from .events.events import LedgerEventManager -from .features import LedgerFeatures -from .keysets import LedgerKeysets -from .tasks import LedgerTasks -from .verification import LedgerVerification -from .watchdog import LedgerWatchdog - - -class Ledger( - LedgerVerification, - LedgerSpendingConditions, - LedgerTasks, - LedgerFeatures, - LedgerWatchdog, - LedgerKeysets, -): - backends: Mapping[Method, Mapping[Unit, LightningBackend]] = {} - keysets: Dict[str, MintKeyset] = {} - events = LedgerEventManager() - db: Database - db_read: DbReadHelper - db_write: DbWriteHelper - invoice_listener_tasks: List[asyncio.Task] = [] - watchdog_tasks: List[asyncio.Task] = [] - disable_melt: bool = False - pubkey: PublicKey - - def __init__( - self, - *, - db: Database, - seed: str, - derivation_path="", - amounts: Optional[List[int]] = None, - backends: Optional[Mapping[Method, Mapping[Unit, LightningBackend]]] = None, - seed_decryption_key: Optional[str] = None, - crud=LedgerCrudSqlite(), - ) -> None: - self.keysets: Dict[str, MintKeyset] = {} - self.backends: Mapping[Method, Mapping[Unit, LightningBackend]] = {} - self.events = LedgerEventManager() - self.db_read: DbReadHelper - self.locks: Dict[str, asyncio.Lock] = {} # holds multiprocessing locks - self.invoice_listener_tasks: List[asyncio.Task] = [] - self.watchdog_tasks: List[asyncio.Task] = [] - self.regular_tasks: List[asyncio.Task] = [] - - if not seed: - raise Exception("seed not set") - - # decrypt seed if seed_decryption_key is set - try: - self.seed = ( - AESCipher(seed_decryption_key).decrypt(seed) - if seed_decryption_key - else seed - ) - except Exception as e: - raise Exception( - f"Could not decrypt seed. Make sure that the seed is correct and the decryption key is set. {e}" - ) - self.derivation_path = derivation_path - - self.db = db - self.crud = crud - - if backends: - self.backends = backends - - if amounts: - self.amounts = amounts - else: - self.amounts = [2**n for n in range(settings.max_order)] - - self.pubkey = derive_pubkey(self.seed) - self.db_read = DbReadHelper(self.db, self.crud) - self.db_write = DbWriteHelper(self.db, self.crud, self.events, self.db_read) - - LedgerWatchdog.__init__(self) - - # ------- STARTUP ------- - - async def startup_ledger(self) -> None: - await self._startup_keysets() - await self._check_backends() - self.regular_tasks.append(asyncio.create_task(self._run_regular_tasks())) - self.invoice_listener_tasks = await self.dispatch_listeners() - if settings.mint_watchdog_enabled: - self.watchdog_tasks = await self.dispatch_watchdogs() - - async def _startup_keysets(self) -> None: - await self.init_keysets() - for derivation_path in settings.mint_derivation_path_list: - derivation_path = self.maybe_update_derivation_path(derivation_path) - await self.activate_keyset(derivation_path=derivation_path) - - async def _run_regular_tasks(self) -> None: - """ - Runs periodic ledger maintenance tasks forever. - This function intentionally loops forever and is designed to be scheduled as a Task. - """ - logger.info("Starting ledger regular tasks loop") - while True: - try: - await self._check_pending_proofs_and_melt_quotes() - await asyncio.sleep(settings.mint_regular_tasks_interval_seconds) - except Exception as e: - logger.error(f"Ledger regular task failed: {e}") - await asyncio.sleep(60) - - async def _check_backends(self) -> None: - for method in self.backends: - for unit in self.backends[method]: - logger.info( - f"Using {self.backends[method][unit].__class__.__name__} backend for" - f" method: '{method.name}' and unit: '{unit.name}'" - ) - status = await self.backends[method][unit].status() - if status.error_message: - logger.error( - "The backend for" - f" {self.backends[method][unit].__class__.__name__} isn't" - f" working properly: '{status.error_message}'" - ) - exit(1) - logger.info(f"Backend balance: {status.balance}") - - logger.info(f"Data dir: {settings.cashu_dir}") - - async def shutdown_ledger(self) -> None: - logger.debug("Disconnecting from database") - await self.db.engine.dispose() - logger.debug("Shutting down invoice listeners") - for task in self.invoice_listener_tasks: - task.cancel() - for task in self.watchdog_tasks: - task.cancel() - logger.debug("Shutting down regular tasks") - for task in self.regular_tasks: - task.cancel() - - async def _check_pending_proofs_and_melt_quotes(self): - """Startup routine that checks all pending melt quotes and either invalidates - their pending proofs for a successful melt or deletes them if the melt failed. - """ - # get all pending melt quotes - pending_melt_quotes = await self.crud.get_all_melt_quotes_from_pending_proofs( - db=self.db - ) - if not pending_melt_quotes: - return - logger.info(f"Checking {len(pending_melt_quotes)} pending melt quotes") - for quote in pending_melt_quotes: - quote = await self.get_melt_quote(quote_id=quote.quote) - logger.info(f"Melt quote {quote.quote} state: {quote.state}") - - # ------- ECASH ------- - - async def _generate_change_promises( - - self, - fee_provided: int, - fee_paid: int, - outputs: Optional[List[BlindedMessage]], - melt_id: Optional[str] = None, - keyset: Optional[MintKeyset] = None, - ) -> List[BlindedSignature]: - """Generates a set of new promises (blinded signatures) from a set of blank outputs - (outputs with no or ignored amount) by looking at the difference between the Lightning - fee reserve provided by the wallet and the actual Lightning fee paid by the mint. - - If there is a positive difference, produces maximum `n_return_outputs` new outputs - with values close or equal to the fee difference. If the given number of `outputs` matches - the equation defined in NUT-08, we can be sure to return the overpaid fee perfectly. - Otherwise, a smaller amount will be returned. - - Args: - input_amount (int): Amount of the proofs provided by the client. - output_amount (int): Amount of the melt request to be paid. - output_fee_paid (int): Actually paid melt network fees. - outputs (Optional[List[BlindedMessage]]): Outputs to sign for returning the overpaid fees. - - Raises: - Exception: Output validation failed. - - Returns: - List[BlindedSignature]: Signatures on the outputs. - """ - # we make sure that the fee is positive - overpaid_fee = fee_provided - fee_paid - - if overpaid_fee <= 0 or outputs is None: - if overpaid_fee < 0: - logger.error( - f"Overpaid fee is negative ({overpaid_fee}). This should not happen." - ) - return [] - - logger.debug( - f"Lightning fee was: {fee_paid}. User provided: {fee_provided}. " - f"Returning difference: {overpaid_fee}." - ) - - return_amounts = amount_split(overpaid_fee) - - # We return at most as many outputs as were provided or as many as are - # required to pay back the overpaid fee. - n_return_outputs = min(len(outputs), len(return_amounts)) - - # we only need as many outputs as we have change to return - outputs = outputs[:n_return_outputs] - - # we sort the return_amounts in descending order so we only - # take the largest values in the next step - return_amounts_sorted = sorted(return_amounts, reverse=True) - # we need to imprint these amounts into the blanket outputs - for i in range(len(outputs)): - outputs[i].amount = return_amounts_sorted[i] # type: ignore - if not self._verify_no_duplicate_outputs(outputs): - raise TransactionError("duplicate promises.") - return_promises = await self._sign_blinded_messages(outputs) - # delete remaining unsigned blank outputs from db - if melt_id: - await self.crud.delete_blinded_messages_melt_id(melt_id=melt_id, db=self.db) - return return_promises - - # ------- TRANSACTIONS ------- - - async def mint_quote(self, quote_request: PostMintQuoteRequest) -> MintQuote: - """Creates a mint quote and stores it in the database. - - Args: - quote_request (PostMintQuoteRequest): Mint quote request. - - Raises: - Exception: Quote creation failed. - - Returns: - MintQuote: Mint quote object. - """ - logger.trace("called request_mint") - if not quote_request.amount > 0: - raise TransactionError("amount must be positive") - if ( - settings.mint_max_mint_bolt11_sat - and quote_request.amount > settings.mint_max_mint_bolt11_sat - ): - raise TransactionAmountExceedsLimitError( - f"Maximum mint amount is {settings.mint_max_mint_bolt11_sat} sat." - ) - if settings.mint_bolt11_disable_mint: - raise NotAllowedError("Minting with bolt11 is disabled.") - - unit, method = self._verify_and_get_unit_method( - quote_request.unit, Method.bolt11.name - ) - - if ( - quote_request.description - and not self.backends[method][unit].supports_description - ): - raise NotAllowedError("Backend does not support descriptions.") - - # Check maximum balance. - # TODO: Allow setting MINT_MAX_BALANCE per unit - if settings.mint_max_balance: - balance, fees_paid = await self.get_unit_balance_and_fees(unit, db=self.db) - if balance + quote_request.amount > settings.mint_max_balance: - raise NotAllowedError("Mint has reached maximum balance.") - - logger.trace(f"requesting invoice for {unit.str(quote_request.amount)}") - invoice_response: InvoiceResponse = await self.backends[method][ - unit - ].create_invoice( - amount=Amount(unit=unit, amount=quote_request.amount), - memo=quote_request.description, - ) - logger.trace( - f"got invoice {invoice_response.payment_request} with checking id" - f" {invoice_response.checking_id}" - ) - - if not (invoice_response.payment_request and invoice_response.checking_id): - raise LightningError("could not fetch bolt11 payment request from backend") - - # get invoice expiry time - invoice_obj = bolt11.decode(invoice_response.payment_request) - - # NOTE: we normalize the request to lowercase to avoid case sensitivity - # This works with Lightning but might not work with other methods - request = invoice_response.payment_request.lower() - - expiry = None - if invoice_obj.expiry is not None: - expiry = invoice_obj.date + invoice_obj.expiry - - quote = MintQuote( - quote=random_hash(), - method=method.name, - request=request, - checking_id=invoice_response.checking_id, - unit=quote_request.unit, - amount=quote_request.amount, - state=MintQuoteState.unpaid, - created_time=int(time.time()), - expiry=expiry, - pubkey=quote_request.pubkey, - ) - await self.crud.store_mint_quote(quote=quote, db=self.db) - await self.events.submit(quote) - - return quote - - async def get_mint_quote(self, quote_id: str) -> MintQuote: - """Returns a mint quote. If the quote is not paid, checks with the backend if the associated request is paid. - - Args: - quote_id (str): ID of the mint quote. - - Raises: - Exception: Quote not found. - - Returns: - MintQuote: Mint quote object. - """ - quote = await self.crud.get_mint_quote(quote_id=quote_id, db=self.db) - if not quote: - raise Exception("quote not found") - - unit, method = self._verify_and_get_unit_method(quote.unit, quote.method) - - if quote.unpaid: - if not quote.checking_id: - raise CashuError("quote has no checking id") - logger.trace(f"Lightning: checking invoice {quote.checking_id}") - status: PaymentStatus = await self.backends[method][ - unit - ].get_invoice_status(quote.checking_id) - if status.settled: - # change state to paid in one transaction, it could have been marked paid - # by the invoice listener in the mean time +import asyncio +import time +from typing import Any, Dict, List, Mapping, Optional, Tuple + +import bolt11 +from loguru import logger + +from ..core.base import ( + DLEQ, + Amount, + BlindedMessage, + BlindedSignature, + MeltQuote, + MeltQuoteState, + Method, + MintKeyset, + MintQuote, + MintQuoteState, + Proof, + PublicKey, + Unit, +) +from ..core.crypto import b_dhke, bls_dhke +from ..core.crypto.aes import AESCipher +from ..core.crypto.bls import PublicKey as BlsPublicKey +from ..core.crypto.keys import ( + derive_pubkey, + is_bls_keyset, + random_hash, +) +from ..core.crypto.secp import SecpPublicKey +from ..core.db import Connection, Database +from ..core.errors import ( + CashuError, + LightningError, + LightningPaymentFailedError, + NotAllowedError, + QuoteAlreadyIssuedError, + QuoteNotPaidError, + QuoteSignatureInvalidError, + TransactionAmountExceedsLimitError, + TransactionError, +) +from ..core.helpers import sum_proofs +from ..core.models import ( + PostMeltQuoteRequest, + PostMeltQuoteResponse, + PostMintQuoteRequest, +) +from ..core.settings import settings +from ..core.split import amount_split +from ..lightning.base import ( + InvoiceResponse, + LightningBackend, + PaymentQuoteResponse, + PaymentResponse, + PaymentResult, + PaymentStatus, +) +from ..mint.crud import LedgerCrudSqlite +from .conditions import LedgerSpendingConditions +from .db.read import DbReadHelper +from .db.write import DbWriteHelper +from .events.events import LedgerEventManager +from .features import LedgerFeatures +from .keysets import LedgerKeysets +from .tasks import LedgerTasks +from .verification import LedgerVerification +from .watchdog import LedgerWatchdog + + +class Ledger( + LedgerVerification, + LedgerSpendingConditions, + LedgerTasks, + LedgerFeatures, + LedgerWatchdog, + LedgerKeysets, +): + backends: Mapping[Method, Mapping[Unit, LightningBackend]] = {} + keysets: Dict[str, MintKeyset] = {} + events = LedgerEventManager() + db: Database + db_read: DbReadHelper + db_write: DbWriteHelper + invoice_listener_tasks: List[asyncio.Task] = [] + watchdog_tasks: List[asyncio.Task] = [] + disable_melt: bool = False + pubkey: PublicKey # type: ignore + + def __init__( + self, + *, + db: Database, + seed: str, + derivation_path="", + amounts: Optional[List[int]] = None, + backends: Optional[Mapping[Method, Mapping[Unit, LightningBackend]]] = None, + seed_decryption_key: Optional[str] = None, + crud=LedgerCrudSqlite(), + ) -> None: + self.keysets: Dict[str, MintKeyset] = {} + self.backends: Mapping[Method, Mapping[Unit, LightningBackend]] = {} + self.events = LedgerEventManager() + self.db_read: DbReadHelper + self.locks: Dict[str, asyncio.Lock] = {} # holds multiprocessing locks + self.invoice_listener_tasks: List[asyncio.Task] = [] + self.watchdog_tasks: List[asyncio.Task] = [] + self.regular_tasks: List[asyncio.Task] = [] + + if not seed: + raise Exception("seed not set") + + # decrypt seed if seed_decryption_key is set + try: + self.seed = ( + AESCipher(seed_decryption_key).decrypt(seed) + if seed_decryption_key + else seed + ) + except Exception as e: + raise Exception( + f"Could not decrypt seed. Make sure that the seed is correct and the decryption key is set. {e}" + ) + self.derivation_path = derivation_path + + self.db = db + self.crud = crud + + if backends: + self.backends = backends + + if amounts: + self.amounts = amounts + else: + self.amounts = [2**n for n in range(settings.max_order)] + + self.pubkey = derive_pubkey(self.seed) + self.db_read = DbReadHelper(self.db, self.crud) + self.db_write = DbWriteHelper(self.db, self.crud, self.events, self.db_read) + + LedgerWatchdog.__init__(self) + + # ------- STARTUP ------- + + async def startup_ledger(self) -> None: + await self._startup_keysets() + await self._check_backends() + self.regular_tasks.append(asyncio.create_task(self._run_regular_tasks())) + self.invoice_listener_tasks = await self.dispatch_listeners() + if settings.mint_watchdog_enabled: + self.watchdog_tasks = await self.dispatch_watchdogs() + + async def _startup_keysets(self) -> None: + await self.init_keysets() + for derivation_path in settings.mint_derivation_path_list: + derivation_path = self.maybe_update_derivation_path(derivation_path) + await self.activate_keyset(derivation_path=derivation_path) + + async def _run_regular_tasks(self) -> None: + """ + Runs periodic ledger maintenance tasks forever. + This function intentionally loops forever and is designed to be scheduled as a Task. + """ + logger.info("Starting ledger regular tasks loop") + while True: + try: + await self._check_pending_proofs_and_melt_quotes() + await asyncio.sleep(settings.mint_regular_tasks_interval_seconds) + except Exception as e: + logger.error(f"Ledger regular task failed: {e}") + await asyncio.sleep(60) + + async def _check_backends(self) -> None: + for method in self.backends: + for unit in self.backends[method]: + logger.info( + f"Using {self.backends[method][unit].__class__.__name__} backend for" + f" method: '{method.name}' and unit: '{unit.name}'" + ) + status = await self.backends[method][unit].status() + if status.error_message: + logger.error( + "The backend for" + f" {self.backends[method][unit].__class__.__name__} isn't" + f" working properly: '{status.error_message}'" + ) + exit(1) + logger.info(f"Backend balance: {status.balance}") + + logger.info(f"Data dir: {settings.cashu_dir}") + + async def shutdown_ledger(self) -> None: + logger.debug("Disconnecting from database") + await self.db.engine.dispose() + logger.debug("Shutting down invoice listeners") + for task in self.invoice_listener_tasks: + task.cancel() + for task in self.watchdog_tasks: + task.cancel() + logger.debug("Shutting down regular tasks") + for task in self.regular_tasks: + task.cancel() + + async def _check_pending_proofs_and_melt_quotes(self): + """Startup routine that checks all pending melt quotes and either invalidates + their pending proofs for a successful melt or deletes them if the melt failed. + """ + # get all pending melt quotes + pending_melt_quotes = await self.crud.get_all_melt_quotes_from_pending_proofs( + db=self.db + ) + if not pending_melt_quotes: + return + logger.info(f"Checking {len(pending_melt_quotes)} pending melt quotes") + for quote in pending_melt_quotes: + quote = await self.get_melt_quote(quote_id=quote.quote) + logger.info(f"Melt quote {quote.quote} state: {quote.state}") + + # ------- ECASH ------- + + async def _generate_change_promises( + + self, + fee_provided: int, + fee_paid: int, + outputs: Optional[List[BlindedMessage]], + melt_id: Optional[str] = None, + keyset: Optional[MintKeyset] = None, + ) -> List[BlindedSignature]: + """Generates a set of new promises (blinded signatures) from a set of blank outputs + (outputs with no or ignored amount) by looking at the difference between the Lightning + fee reserve provided by the wallet and the actual Lightning fee paid by the mint. + + If there is a positive difference, produces maximum `n_return_outputs` new outputs + with values close or equal to the fee difference. If the given number of `outputs` matches + the equation defined in NUT-08, we can be sure to return the overpaid fee perfectly. + Otherwise, a smaller amount will be returned. + + Args: + input_amount (int): Amount of the proofs provided by the client. + output_amount (int): Amount of the melt request to be paid. + output_fee_paid (int): Actually paid melt network fees. + outputs (Optional[List[BlindedMessage]]): Outputs to sign for returning the overpaid fees. + + Raises: + Exception: Output validation failed. + + Returns: + List[BlindedSignature]: Signatures on the outputs. + """ + # we make sure that the fee is positive + overpaid_fee = fee_provided - fee_paid + + if overpaid_fee <= 0 or outputs is None: + if overpaid_fee < 0: + logger.error( + f"Overpaid fee is negative ({overpaid_fee}). This should not happen." + ) + return [] + + logger.debug( + f"Lightning fee was: {fee_paid}. User provided: {fee_provided}. " + f"Returning difference: {overpaid_fee}." + ) + + return_amounts = amount_split(overpaid_fee) + + # We return at most as many outputs as were provided or as many as are + # required to pay back the overpaid fee. + n_return_outputs = min(len(outputs), len(return_amounts)) + + # we only need as many outputs as we have change to return + outputs = outputs[:n_return_outputs] + + # we sort the return_amounts in descending order so we only + # take the largest values in the next step + return_amounts_sorted = sorted(return_amounts, reverse=True) + # we need to imprint these amounts into the blanket outputs + for i in range(len(outputs)): + outputs[i].amount = return_amounts_sorted[i] # type: ignore + if not self._verify_no_duplicate_outputs(outputs): + raise TransactionError("duplicate promises.") + return_promises = await self._sign_blinded_messages(outputs) + # delete remaining unsigned blank outputs from db + if melt_id: + await self.crud.delete_blinded_messages_melt_id(melt_id=melt_id, db=self.db) + return return_promises + + # ------- TRANSACTIONS ------- + + async def mint_quote(self, quote_request: PostMintQuoteRequest) -> MintQuote: + """Creates a mint quote and stores it in the database. + + Args: + quote_request (PostMintQuoteRequest): Mint quote request. + + Raises: + Exception: Quote creation failed. + + Returns: + MintQuote: Mint quote object. + """ + logger.trace("called request_mint") + if not quote_request.amount > 0: + raise TransactionError("amount must be positive") + if ( + settings.mint_max_mint_bolt11_sat + and quote_request.amount > settings.mint_max_mint_bolt11_sat + ): + raise TransactionAmountExceedsLimitError( + f"Maximum mint amount is {settings.mint_max_mint_bolt11_sat} sat." + ) + if settings.mint_bolt11_disable_mint: + raise NotAllowedError("Minting with bolt11 is disabled.") + + unit, method = self._verify_and_get_unit_method( + quote_request.unit, Method.bolt11.name + ) + + if ( + quote_request.description + and not self.backends[method][unit].supports_description + ): + raise NotAllowedError("Backend does not support descriptions.") + + # Check maximum balance. + # TODO: Allow setting MINT_MAX_BALANCE per unit + if settings.mint_max_balance: + balance, fees_paid = await self.get_unit_balance_and_fees(unit, db=self.db) + if balance + quote_request.amount > settings.mint_max_balance: + raise NotAllowedError("Mint has reached maximum balance.") + + logger.trace(f"requesting invoice for {unit.str(quote_request.amount)}") + invoice_response: InvoiceResponse = await self.backends[method][ + unit + ].create_invoice( + amount=Amount(unit=unit, amount=quote_request.amount), + memo=quote_request.description, + ) + logger.trace( + f"got invoice {invoice_response.payment_request} with checking id" + f" {invoice_response.checking_id}" + ) + + if not (invoice_response.payment_request and invoice_response.checking_id): + raise LightningError("could not fetch bolt11 payment request from backend") + + # get invoice expiry time + invoice_obj = bolt11.decode(invoice_response.payment_request) + + # NOTE: we normalize the request to lowercase to avoid case sensitivity + # This works with Lightning but might not work with other methods + request = invoice_response.payment_request.lower() + + expiry = None + if invoice_obj.expiry is not None: + expiry = invoice_obj.date + invoice_obj.expiry + + quote = MintQuote( + quote=random_hash(), + method=method.name, + request=request, + checking_id=invoice_response.checking_id, + unit=quote_request.unit, + amount=quote_request.amount, + state=MintQuoteState.unpaid, + created_time=int(time.time()), + expiry=expiry, + pubkey=quote_request.pubkey, + ) + await self.crud.store_mint_quote(quote=quote, db=self.db) + await self.events.submit(quote) + + return quote + + async def get_mint_quote(self, quote_id: str) -> MintQuote: + """Returns a mint quote. If the quote is not paid, checks with the backend if the associated request is paid. + + Args: + quote_id (str): ID of the mint quote. + + Raises: + Exception: Quote not found. + + Returns: + MintQuote: Mint quote object. + """ + quote = await self.crud.get_mint_quote(quote_id=quote_id, db=self.db) + if not quote: + raise Exception("quote not found") + + unit, method = self._verify_and_get_unit_method(quote.unit, quote.method) + + if quote.unpaid: + if not quote.checking_id: + raise CashuError("quote has no checking id") + logger.trace(f"Lightning: checking invoice {quote.checking_id}") + status: PaymentStatus = await self.backends[method][ + unit + ].get_invoice_status(quote.checking_id) + if status.settled: + # change state to paid in one transaction, it could have been marked paid + # by the invoice listener in the mean time async with self.db.get_connection( lock_table="mint_quotes", lock_select_statement="quote = :quote", lock_parameters={"quote": quote_id}, ) as conn: - quote = await self.crud.get_mint_quote( - quote_id=quote_id, db=self.db, conn=conn - ) - if not quote: - raise Exception("quote not found") - if quote.unpaid: - logger.trace(f"Setting quote {quote_id} as paid") - quote.state = MintQuoteState.paid - quote.paid_time = int(time.time()) - await self.crud.update_mint_quote( - quote=quote, db=self.db, conn=conn - ) - await self.events.submit(quote) - - return quote - - async def mint( - self, - *, - outputs: List[BlindedMessage], - quote_id: str, - signature: Optional[str] = None, - ) -> List[BlindedSignature]: - """Mints new coins if quote with `quote_id` was paid. Ingest blind messages `outputs` and returns blind signatures `promises`. - - Args: - outputs (List[BlindedMessage]): Outputs (blinded messages) to sign. - quote_id (str): Mint quote id. - witness (Optional[str], optional): NUT-19 witness signature. Defaults to None. - - Raises: - Exception: Validation of outputs failed. - Exception: Quote not paid. - Exception: Quote already issued. - Exception: Quote expired. - Exception: Amount to mint does not match quote amount. - - Returns: - List[BlindedSignature]: Signatures on the outputs. - """ - await self._verify_outputs(outputs) - sum_amount_outputs = sum([b.amount for b in outputs]) - # we already know from _verify_outputs that all outputs have the same unit because they have the same keyset - output_unit = self.keysets[outputs[0].id].unit - - quote = await self.get_mint_quote(quote_id) - if quote.pending: - raise TransactionError("Mint quote already pending.") - if quote.issued: - raise QuoteAlreadyIssuedError() - if quote.state != MintQuoteState.paid: - raise QuoteNotPaidError() - - previous_state = quote.state - await self.db_write._set_mint_quote_pending(quote_id=quote_id) - try: - if not quote.unit == output_unit.name: - raise TransactionError("quote unit does not match output unit") - if not quote.amount == sum_amount_outputs: - raise TransactionError("amount to mint does not match quote amount") - if quote.expiry and quote.expiry < int(time.time()): - raise TransactionError("quote expired") - if not self._verify_mint_quote_witness(quote, outputs, signature): - raise QuoteSignatureInvalidError() - await self._store_blinded_messages(outputs, mint_id=quote_id) - promises = await self._sign_blinded_messages(outputs) - except Exception as e: - await self.db_write._unset_mint_quote_pending( - quote_id=quote_id, state=previous_state - ) - raise e - await self.db_write._unset_mint_quote_pending( - quote_id=quote_id, state=MintQuoteState.issued - ) - - return promises - - def create_internal_melt_quote( - self, mint_quote: MintQuote, melt_quote: PostMeltQuoteRequest - ) -> PaymentQuoteResponse: - unit, method = self._verify_and_get_unit_method( - melt_quote.unit, Method.bolt11.name - ) - # NOTE: we normalize the request to lowercase to avoid case sensitivity - # This works with Lightning but might not work with other methods - request = melt_quote.request.lower() - - if not request == mint_quote.request: - raise TransactionError("bolt11 requests do not match") - if not mint_quote.unit == melt_quote.unit: - raise TransactionError("units do not match") - if not mint_quote.method == method.name: - raise TransactionError("methods do not match") - if mint_quote.paid: - raise TransactionError("mint quote already paid") - if mint_quote.issued: - raise TransactionError("mint quote already issued") - if not mint_quote.unpaid: - raise TransactionError("mint quote is not unpaid") - - if not mint_quote.checking_id: - raise TransactionError("mint quote has no checking id") - if melt_quote.is_mpp: - raise TransactionError("internal payments do not support mpp") - - internal_fee = Amount(unit, 0) # no internal fees - amount = Amount(unit, mint_quote.amount) - - payment_quote = PaymentQuoteResponse( - checking_id=mint_quote.checking_id, - amount=amount, - fee=internal_fee, - ) - logger.info( - f"Issuing internal melt quote: {request} ->" - f" {mint_quote.quote} ({amount.str()} + {internal_fee.str()} fees)" - ) - - return payment_quote - - def validate_payment_quote( - self, melt_quote: PostMeltQuoteRequest, payment_quote: PaymentQuoteResponse - ): - # payment quote validation - unit, method = self._verify_and_get_unit_method( - melt_quote.unit, Method.bolt11.name - ) - if not payment_quote.checking_id: - raise Exception("quote has no checking id") - # verify that payment quote amount is as expected - if ( - melt_quote.is_mpp - and melt_quote.mpp_amount != payment_quote.amount.to(Unit.msat).amount - ): - logger.error( - f"expected {payment_quote.amount.to(Unit.msat).amount} msat but got {melt_quote.mpp_amount}" - ) - raise TransactionError("quote amount not as requested") - # make sure the backend returned the amount with a correct unit - if not payment_quote.amount.unit == unit: - raise TransactionError("payment quote amount units do not match") - # fee from the backend must be in the same unit as the amount - if not payment_quote.fee.unit == unit: - raise TransactionError("payment quote fee units do not match") - - async def melt_quote( - self, melt_quote: PostMeltQuoteRequest - ) -> PostMeltQuoteResponse: - """Creates a melt quote and stores it in the database. - - Args: - melt_quote (PostMeltQuoteRequest): Melt quote request. - - Raises: - Exception: Quote invalid. - Exception: Quote already paid. - Exception: Quote already issued. - - Returns: - PostMeltQuoteResponse: Melt quote response. - """ - if settings.mint_bolt11_disable_melt: - raise NotAllowedError("Melting with bol11 is disabled.") - - unit, method = self._verify_and_get_unit_method( - melt_quote.unit, Method.bolt11.name - ) - - # NOTE: we normalize the request to lowercase to avoid case sensitivity - # This works with Lightning but might not work with other methods - request = melt_quote.request.lower() - - # check if there is a mint quote with the same payment request - # so that we would be able to handle the transaction internally - # and therefore respond with internal transaction fees (0 for now) - mint_quote = await self.crud.get_mint_quote(request=request, db=self.db) - if mint_quote and mint_quote.unit == melt_quote.unit: - # check if the melt quote is partial and error if it is. - # it's just not possible to handle this case - if melt_quote.is_mpp: - raise TransactionError("internal mpp not allowed.") - payment_quote = self.create_internal_melt_quote(mint_quote, melt_quote) - else: - # not internal - # verify that the backend supports mpp if the quote request has an amount - if melt_quote.is_mpp and not self.backends[method][unit].supports_mpp: - raise TransactionError("backend does not support mpp.") - # get payment quote by backend - payment_quote = await self.backends[method][unit].get_payment_quote( - melt_quote=melt_quote - ) - - self.validate_payment_quote(melt_quote, payment_quote) - - # verify that the amount of the proofs is not larger than the maximum allowed - if ( - settings.mint_max_melt_bolt11_sat - and payment_quote.amount.to(unit).amount > settings.mint_max_melt_bolt11_sat - ): - raise NotAllowedError( - f"Maximum melt amount is {settings.mint_max_melt_bolt11_sat} sat." - ) - - # We assume that the request is a bolt11 invoice, this works since we - # support only the bol11 method for now. - invoice_obj = bolt11.decode(melt_quote.request) - if not invoice_obj.amount_msat: - raise TransactionError("invoice has no amount.") - # we set the expiry of this quote to the expiry of the bolt11 invoice - expiry = None - if invoice_obj.expiry is not None: - expiry = invoice_obj.date + invoice_obj.expiry - - quote = MeltQuote( - quote=random_hash(), - method=method.name, - request=request, - checking_id=payment_quote.checking_id, - unit=unit.name, - amount=payment_quote.amount.to(unit).amount, - state=MeltQuoteState.unpaid, - fee_reserve=payment_quote.fee.to(unit).amount, - created_time=int(time.time()), - expiry=expiry, - ) - await self.db_write._store_melt_quote(quote) - await self.events.submit(quote) - - return PostMeltQuoteResponse( - quote=quote.quote, - amount=quote.amount, - unit=quote.unit, - request=quote.request, - fee_reserve=quote.fee_reserve, - paid=quote.paid, # deprecated - state=quote.state.value, - expiry=quote.expiry, - ) - - async def get_melt_quote(self, quote_id: str, rollback_unknown=False) -> MeltQuote: - """Returns a melt quote. - - If the melt quote is pending, checks status of the payment with the backend. - - If settled, sets the quote as paid and invalidates pending proofs (commit). - - If failed, sets the quote as unpaid and unsets pending proofs (rollback). - - If rollback_unknown is set, do the same for unknown states as for failed states. - - Args: - quote_id (str): ID of the melt quote. - rollback_unknown (bool, optional): Rollback unknown payment states to unpaid. Defaults to False. - - Raises: - Exception: Quote not found. - - Returns: - MeltQuote: Melt quote object. - """ - melt_quote = await self.crud.get_melt_quote(quote_id=quote_id, db=self.db) - if not melt_quote: - raise Exception("quote not found") - - unit, method = self._verify_and_get_unit_method( - melt_quote.unit, melt_quote.method - ) - - # we only check the state with the backend if there is no associated internal - # mint quote for this melt quote - is_internal = await self.crud.get_mint_quote( - request=melt_quote.request, db=self.db - ) - - if melt_quote.pending and not is_internal: - logger.debug( - "Lightning: checking outgoing Lightning payment" - f" {melt_quote.checking_id}" - ) - status: PaymentStatus = await self.backends[method][ - unit - ].get_payment_status(melt_quote.checking_id) - logger.debug(f"State: {status.result}") - if status.settled: - logger.debug(f"Setting quote {quote_id} as paid") - melt_quote.state = MeltQuoteState.paid - if status.fee: - melt_quote.fee_paid = status.fee.to(unit).amount - if status.preimage: - melt_quote.payment_preimage = status.preimage - melt_quote.paid_time = int(time.time()) - pending_proofs = await self.crud.get_pending_proofs_for_quote( - quote_id=quote_id, db=self.db - ) - - # change to compensate wallet for overpaid fees - melt_outputs = await self.crud.get_blinded_messages_melt_id( - melt_id=quote_id, db=self.db - ) - if melt_outputs: - total_provided = sum_proofs(pending_proofs) - input_fees = self.get_fees_for_proofs(pending_proofs) - fee_reserve_provided = ( - total_provided - melt_quote.amount - input_fees - ) - return_promises = await self._generate_change_promises( - fee_provided=fee_reserve_provided, - fee_paid=melt_quote.fee_paid, - outputs=melt_outputs, - melt_id=quote_id, - keyset=self.keysets[melt_outputs[0].id], - ) - melt_quote.change = return_promises - - # Calculate fees - proofs_by_keyset: Dict[str, List[Proof]] = {} - for p in pending_proofs: - proofs_by_keyset.setdefault(p.id, []).append(p) - keyset_fees = {} - for keyset_id, keyset_proofs in proofs_by_keyset.items(): - keyset_fees[keyset_id] = self.get_fees_for_proofs(keyset_proofs) - - melt_quote = await self.db_write.set_melt_quote_paid_and_invalidate_proofs( - quote=melt_quote, - proofs=pending_proofs, - keysets=self.keysets, - keyset_fees=keyset_fees, - ) - - if status.failed or (rollback_unknown and status.unknown): - logger.debug(f"Setting quote {quote_id} as unpaid") - pending_proofs = await self.crud.get_pending_proofs_for_quote( - quote_id=quote_id, db=self.db - ) - melt_quote = await self.db_write.unset_melt_quote_pending_and_proofs( - quote=melt_quote, - proofs=pending_proofs, - keysets=self.keysets, - state=MeltQuoteState.unpaid, - ) - - - return melt_quote - - async def melt_mint_settle_internally( - self, melt_quote: MeltQuote, proofs: List[Proof] - ) -> MeltQuote: - """Settles a melt quote internally if there is a mint quote with the same payment request. - - `proofs` are passed to determine the ecash input transaction fees for this melt quote. - - Args: - melt_quote (MeltQuote): Melt quote to settle. - proofs (List[Proof]): Proofs provided for paying the Lightning invoice. - - Raises: - Exception: Melt quote already paid. - Exception: Melt quote already issued. - - Returns: - MeltQuote: Settled melt quote. - """ - # first we check if there is a mint quote with the same payment request - # so that we can handle the transaction internally without the backend - mint_quote = await self.crud.get_mint_quote( - request=melt_quote.request, db=self.db - ) - if not mint_quote: - return melt_quote - - # settle externally if units are different - if mint_quote.unit != melt_quote.unit: - return melt_quote - - # we settle the transaction internally - if melt_quote.paid: - raise TransactionError("melt quote already paid") - - # verify amounts from bolt11 invoice - bolt11_request = melt_quote.request - invoice_obj = bolt11.decode(bolt11_request) - - if not invoice_obj.amount_msat: - raise TransactionError("invoice has no amount.") - if not mint_quote.amount == melt_quote.amount: - raise TransactionError("amounts do not match") - if not bolt11_request == mint_quote.request: - raise TransactionError("bolt11 requests do not match") - if not mint_quote.method == melt_quote.method: - raise TransactionError("methods do not match") - - if mint_quote.paid: - raise TransactionError("mint quote already paid") - if mint_quote.issued: - raise TransactionError("mint quote already issued") - - if mint_quote.state != MintQuoteState.unpaid: - raise TransactionError("mint quote is not unpaid") - - logger.info( - f"Settling bolt11 payment internally: {melt_quote.quote} ->" - f" {mint_quote.quote} ({melt_quote.amount} {melt_quote.unit})" - ) - - melt_quote.fee_paid = 0 # no internal fees - melt_quote.state = MeltQuoteState.paid - melt_quote.paid_time = int(time.time()) - - mint_quote.state = MintQuoteState.paid - mint_quote.paid_time = melt_quote.paid_time - - async with self.db.get_connection() as conn: - await self.crud.update_melt_quote(quote=melt_quote, db=self.db, conn=conn) - await self.crud.update_mint_quote(quote=mint_quote, db=self.db, conn=conn) - - await self.events.submit(melt_quote) - await self.events.submit(mint_quote) - - return melt_quote - - async def melt( - self, - *, - proofs: List[Proof], - quote: str, - outputs: Optional[List[BlindedMessage]] = None, - ) -> PostMeltQuoteResponse: - """Invalidates proofs and pays a Lightning invoice. - - Args: - proofs (List[Proof]): Proofs provided for paying the Lightning invoice - quote (str): ID of the melt quote. - outputs (Optional[List[BlindedMessage]]): Blank outputs for returning overpaid fees to the wallet. - - Raises: - e: Lightning payment unsuccessful - - Returns: - PostMeltQuoteResponse: Melt quote response. - """ - # make sure we're allowed to melt - if self.disable_melt and settings.mint_disable_melt_on_error: - raise NotAllowedError("Melt is disabled. Please contact the operator.") - - # get melt quote and check if it was already paid - melt_quote = await self.get_melt_quote(quote_id=quote) - if not melt_quote.unpaid: - raise TransactionError(f"melt quote is not unpaid: {melt_quote.state}") - - unit, method = self._verify_and_get_unit_method( - melt_quote.unit, melt_quote.method - ) - - # make sure that the proofs are in the same unit as the quote - self._verify_proofs_unit(proofs, expected_unit=unit) - - # make sure that the outputs (for fee return) are in the same unit as the quote - if outputs: - # _verify_outputs checks if all outputs have the same unit - await self._verify_outputs( - outputs, skip_amount_check=True, expected_unit=unit + quote = await self.crud.get_mint_quote( + quote_id=quote_id, db=self.db, conn=conn + ) + if not quote: + raise Exception("quote not found") + if quote.unpaid: + logger.trace(f"Setting quote {quote_id} as paid") + quote.state = MintQuoteState.paid + quote.paid_time = int(time.time()) + await self.crud.update_mint_quote( + quote=quote, db=self.db, conn=conn + ) + await self.events.submit(quote) + + return quote + + async def mint( + self, + *, + outputs: List[BlindedMessage], + quote_id: str, + signature: Optional[str] = None, + ) -> List[BlindedSignature]: + """Mints new coins if quote with `quote_id` was paid. Ingest blind messages `outputs` and returns blind signatures `promises`. + + Args: + outputs (List[BlindedMessage]): Outputs (blinded messages) to sign. + quote_id (str): Mint quote id. + witness (Optional[str], optional): NUT-19 witness signature. Defaults to None. + + Raises: + Exception: Validation of outputs failed. + Exception: Quote not paid. + Exception: Quote already issued. + Exception: Quote expired. + Exception: Amount to mint does not match quote amount. + + Returns: + List[BlindedSignature]: Signatures on the outputs. + """ + await self._verify_outputs(outputs) + sum_amount_outputs = sum([b.amount for b in outputs]) + # we already know from _verify_outputs that all outputs have the same unit because they have the same keyset + output_unit = self.keysets[outputs[0].id].unit + + quote = await self.get_mint_quote(quote_id) + if quote.pending: + raise TransactionError("Mint quote already pending.") + if quote.issued: + raise QuoteAlreadyIssuedError() + if quote.state != MintQuoteState.paid: + raise QuoteNotPaidError() + + previous_state = quote.state + await self.db_write._set_mint_quote_pending(quote_id=quote_id) + try: + if not quote.unit == output_unit.name: + raise TransactionError("quote unit does not match output unit") + if not quote.amount == sum_amount_outputs: + raise TransactionError("amount to mint does not match quote amount") + if quote.expiry and quote.expiry < int(time.time()): + raise TransactionError("quote expired") + if not self._verify_mint_quote_witness(quote, outputs, signature): + raise QuoteSignatureInvalidError() + await self._store_blinded_messages(outputs, mint_id=quote_id) + promises = await self._sign_blinded_messages(outputs) + except Exception as e: + await self.db_write._unset_mint_quote_pending( + quote_id=quote_id, state=previous_state + ) + raise e + await self.db_write._unset_mint_quote_pending( + quote_id=quote_id, state=MintQuoteState.issued + ) + + return promises + + def create_internal_melt_quote( + self, mint_quote: MintQuote, melt_quote: PostMeltQuoteRequest + ) -> PaymentQuoteResponse: + unit, method = self._verify_and_get_unit_method( + melt_quote.unit, Method.bolt11.name + ) + # NOTE: we normalize the request to lowercase to avoid case sensitivity + # This works with Lightning but might not work with other methods + request = melt_quote.request.lower() + + if not request == mint_quote.request: + raise TransactionError("bolt11 requests do not match") + if not mint_quote.unit == melt_quote.unit: + raise TransactionError("units do not match") + if not mint_quote.method == method.name: + raise TransactionError("methods do not match") + if mint_quote.paid: + raise TransactionError("mint quote already paid") + if mint_quote.issued: + raise TransactionError("mint quote already issued") + if not mint_quote.unpaid: + raise TransactionError("mint quote is not unpaid") + + if not mint_quote.checking_id: + raise TransactionError("mint quote has no checking id") + if melt_quote.is_mpp: + raise TransactionError("internal payments do not support mpp") + + internal_fee = Amount(unit, 0) # no internal fees + amount = Amount(unit, mint_quote.amount) + + payment_quote = PaymentQuoteResponse( + checking_id=mint_quote.checking_id, + amount=amount, + fee=internal_fee, + ) + logger.info( + f"Issuing internal melt quote: {request} ->" + f" {mint_quote.quote} ({amount.str()} + {internal_fee.str()} fees)" + ) + + return payment_quote + + def validate_payment_quote( + self, melt_quote: PostMeltQuoteRequest, payment_quote: PaymentQuoteResponse + ): + # payment quote validation + unit, method = self._verify_and_get_unit_method( + melt_quote.unit, Method.bolt11.name + ) + if not payment_quote.checking_id: + raise Exception("quote has no checking id") + # verify that payment quote amount is as expected + if ( + melt_quote.is_mpp + and melt_quote.mpp_amount != payment_quote.amount.to(Unit.msat).amount + ): + logger.error( + f"expected {payment_quote.amount.to(Unit.msat).amount} msat but got {melt_quote.mpp_amount}" + ) + raise TransactionError("quote amount not as requested") + # make sure the backend returned the amount with a correct unit + if not payment_quote.amount.unit == unit: + raise TransactionError("payment quote amount units do not match") + # fee from the backend must be in the same unit as the amount + if not payment_quote.fee.unit == unit: + raise TransactionError("payment quote fee units do not match") + + async def melt_quote( + self, melt_quote: PostMeltQuoteRequest + ) -> PostMeltQuoteResponse: + """Creates a melt quote and stores it in the database. + + Args: + melt_quote (PostMeltQuoteRequest): Melt quote request. + + Raises: + Exception: Quote invalid. + Exception: Quote already paid. + Exception: Quote already issued. + + Returns: + PostMeltQuoteResponse: Melt quote response. + """ + if settings.mint_bolt11_disable_melt: + raise NotAllowedError("Melting with bol11 is disabled.") + + unit, method = self._verify_and_get_unit_method( + melt_quote.unit, Method.bolt11.name + ) + + # NOTE: we normalize the request to lowercase to avoid case sensitivity + # This works with Lightning but might not work with other methods + request = melt_quote.request.lower() + + # check if there is a mint quote with the same payment request + # so that we would be able to handle the transaction internally + # and therefore respond with internal transaction fees (0 for now) + mint_quote = await self.crud.get_mint_quote(request=request, db=self.db) + if mint_quote and mint_quote.unit == melt_quote.unit: + # check if the melt quote is partial and error if it is. + # it's just not possible to handle this case + if melt_quote.is_mpp: + raise TransactionError("internal mpp not allowed.") + payment_quote = self.create_internal_melt_quote(mint_quote, melt_quote) + else: + # not internal + # verify that the backend supports mpp if the quote request has an amount + if melt_quote.is_mpp and not self.backends[method][unit].supports_mpp: + raise TransactionError("backend does not support mpp.") + # get payment quote by backend + payment_quote = await self.backends[method][unit].get_payment_quote( + melt_quote=melt_quote ) - # verify SIG_ALL signatures - message_to_sign = ( - "".join([p.secret for p in proofs] + [o.B_ for o in outputs or []]) + quote - ) - self._verify_sigall_spending_conditions(proofs, outputs or [], message_to_sign) - - # verify that the amount of the input proofs is equal to the amount of the quote - total_provided = sum_proofs(proofs) - input_fees = self.get_fees_for_proofs(proofs) - total_needed = melt_quote.amount + melt_quote.fee_reserve + input_fees - # we need the fees specifically for lightning to return the overpaid fees - fee_reserve_provided = total_provided - melt_quote.amount - input_fees - if total_provided < total_needed: - raise TransactionError( - f"not enough inputs provided for melt. Provided: {total_provided}, needed: {total_needed}" - ) - if fee_reserve_provided < melt_quote.fee_reserve: - raise TransactionError( - f"not enough fee reserve provided for melt. Provided fee reserve: {fee_reserve_provided}, needed: {melt_quote.fee_reserve}" - ) - - # verify inputs and their spending conditions - # note, we do not verify outputs here, as they are only used for returning overpaid fees - # We must have called _verify_outputs here already! (see above) - await self.verify_inputs_and_outputs(proofs=proofs) - - # set quote and proofs to pending to avoid race conditions - melt_quote = await self.db_write.verify_and_set_melt_quote_pending( - quote=melt_quote, proofs=proofs, keysets=self.keysets - ) - - # store the change outputs - if outputs: - await self._store_blinded_messages(outputs, melt_id=melt_quote.quote) - - # if the melt corresponds to an internal mint, mark both as paid - melt_quote = await self.melt_mint_settle_internally(melt_quote, proofs) - # quote not paid yet (not internal), pay it with the backend - if not melt_quote.paid: - logger.debug(f"Lightning: pay invoice {melt_quote.request}") - try: - payment = await self.backends[method][unit].pay_invoice( - melt_quote, melt_quote.fee_reserve * 1000 - ) - logger.debug( - f"Melt – Result: {payment.result.name}: preimage: {payment.preimage}," - f" fee: {payment.fee.str() if payment.fee is not None else 'None'}" - ) - if ( - payment.checking_id - and payment.checking_id != melt_quote.checking_id - ): - logger.warning( - f"pay_invoice returned different checking_id: {payment.checking_id} than melt quote: {melt_quote.checking_id}. Will use it for potentially checking payment status later." - ) - melt_quote.checking_id = payment.checking_id - await self.crud.update_melt_quote(quote=melt_quote, db=self.db) - except Exception as e: - logger.error(f"Exception during pay_invoice: {e}") - payment = PaymentResponse( - result=PaymentResult.UNKNOWN, - error_message=str(e), - ) - - match payment.result: - case PaymentResult.FAILED | PaymentResult.UNKNOWN: - # explicitly check payment status for failed or unknown payment states - checking_id = payment.checking_id or melt_quote.checking_id - logger.debug( - f"Payment state is {payment.result.name}.{' Error: ' + payment.error_message + '.' if payment.error_message else ''} Checking status for {checking_id}." - ) - try: - status = await self.backends[method][unit].get_payment_status( - checking_id - ) - except Exception as e: - # Something went wrong. We might have lost connection to the backend. Keep transaction pending and return. - logger.error( - f"Lightning backend error: could not check payment status. Proofs for melt quote {melt_quote.quote} are stuck as PENDING.\nError: {e}" - ) - self.disable_melt = True - return PostMeltQuoteResponse.from_melt_quote(melt_quote) - - match status.result: - case PaymentResult.FAILED | PaymentResult.UNKNOWN: - # Everything as expected. Payment AND a status check both agree on a failure. We roll back the transaction. - await self.db_write.unset_melt_quote_pending_and_proofs( - quote=melt_quote, - proofs=proofs, - keysets=self.keysets, - state=MeltQuoteState.unpaid, - ) - if status.error_message: - logger.error( - f"Status check error: {status.error_message}" - ) - raise LightningPaymentFailedError( - f"Lightning payment failed{': ' + payment.error_message if payment.error_message else ''}." - ) - case _: - # Something went wrong with our implementation or the backend. Status check returned different result than payment. Keep transaction pending and return. - logger.error( - f"Payment state was {payment.result} but additional payment state check returned {status.result.name}. Proofs for melt quote {melt_quote.quote} are stuck as PENDING." - ) - self.disable_melt = True - return PostMeltQuoteResponse.from_melt_quote(melt_quote) - - case PaymentResult.SETTLED: - # payment successful - if payment.fee: - melt_quote.fee_paid = payment.fee.to( - to_unit=unit, round="up" - ).amount - if payment.preimage: - melt_quote.payment_preimage = payment.preimage - # set quote as paid - melt_quote.state = MeltQuoteState.paid - melt_quote.paid_time = int(time.time()) - # NOTE: This is the only branch for a successful payment - - case PaymentResult.PENDING | _: - logger.debug( - f"Lightning payment is {payment.result.name}: {payment.checking_id}" - ) - return PostMeltQuoteResponse.from_melt_quote(melt_quote) - - # melt was successful (either internal or via backend), invalidate proofs - # prepare change to compensate wallet for overpaid fees - return_promises: List[BlindedSignature] = [] - if outputs: - return_promises = await self._generate_change_promises( - fee_provided=fee_reserve_provided, - fee_paid=melt_quote.fee_paid, - outputs=outputs, - melt_id=melt_quote.quote, - keyset=self.keysets[outputs[0].id], - ) - - melt_quote.change = return_promises - - # Calculate fees - proofs_by_keyset: Dict[str, List[Proof]] = {} - for p in proofs: - proofs_by_keyset.setdefault(p.id, []).append(p) - keyset_fees = {} - for keyset_id, keyset_proofs in proofs_by_keyset.items(): - keyset_fees[keyset_id] = self.get_fees_for_proofs(keyset_proofs) - - melt_quote = await self.db_write.set_melt_quote_paid_and_invalidate_proofs( - quote=melt_quote, - proofs=proofs, - keysets=self.keysets, - keyset_fees=keyset_fees, - ) - - return PostMeltQuoteResponse.from_melt_quote(melt_quote) - - - async def swap( - self, - *, - proofs: List[Proof], - outputs: List[BlindedMessage], - keyset: Optional[MintKeyset] = None, - ): - """Consumes proofs and prepares new promises based on the amount swap. Used for swapping tokens - Before sending or for redeeming tokens for new ones that have been received by another wallet. - - Args: - proofs (List[Proof]): Proofs to be invalidated for the swap. - outputs (List[BlindedMessage]): New outputs that should be signed in return. - keyset (Optional[MintKeyset], optional): Keyset to use. Uses default keyset if not given. Defaults to None. - - Raises: - Exception: Validation of proofs or outputs failed - - Returns: - List[BlindedSignature]: New promises (signatures) for the outputs. - """ - logger.trace("swap called") - # verify spending inputs, outputs, and spending conditions - await self.verify_inputs_and_outputs(proofs=proofs, outputs=outputs) - await self.db_write._verify_spent_proofs_and_set_pending( - proofs, keysets=self.keysets - ) + self.validate_payment_quote(melt_quote, payment_quote) + + # verify that the amount of the proofs is not larger than the maximum allowed + if ( + settings.mint_max_melt_bolt11_sat + and payment_quote.amount.to(unit).amount > settings.mint_max_melt_bolt11_sat + ): + raise NotAllowedError( + f"Maximum melt amount is {settings.mint_max_melt_bolt11_sat} sat." + ) + + # We assume that the request is a bolt11 invoice, this works since we + # support only the bol11 method for now. + invoice_obj = bolt11.decode(melt_quote.request) + if not invoice_obj.amount_msat: + raise TransactionError("invoice has no amount.") + # we set the expiry of this quote to the expiry of the bolt11 invoice + expiry = None + if invoice_obj.expiry is not None: + expiry = invoice_obj.date + invoice_obj.expiry + + quote = MeltQuote( + quote=random_hash(), + method=method.name, + request=request, + checking_id=payment_quote.checking_id, + unit=unit.name, + amount=payment_quote.amount.to(unit).amount, + state=MeltQuoteState.unpaid, + fee_reserve=payment_quote.fee.to(unit).amount, + created_time=int(time.time()), + expiry=expiry, + ) + await self.db_write._store_melt_quote(quote) + await self.events.submit(quote) + + return PostMeltQuoteResponse( + quote=quote.quote, + amount=quote.amount, + unit=quote.unit, + request=quote.request, + fee_reserve=quote.fee_reserve, + paid=quote.paid, # deprecated + state=quote.state.value, + expiry=quote.expiry, + ) + + async def get_melt_quote(self, quote_id: str, rollback_unknown=False) -> MeltQuote: + """Returns a melt quote. + + If the melt quote is pending, checks status of the payment with the backend. + - If settled, sets the quote as paid and invalidates pending proofs (commit). + - If failed, sets the quote as unpaid and unsets pending proofs (rollback). + - If rollback_unknown is set, do the same for unknown states as for failed states. + + Args: + quote_id (str): ID of the melt quote. + rollback_unknown (bool, optional): Rollback unknown payment states to unpaid. Defaults to False. + + Raises: + Exception: Quote not found. + + Returns: + MeltQuote: Melt quote object. + """ + melt_quote = await self.crud.get_melt_quote(quote_id=quote_id, db=self.db) + if not melt_quote: + raise Exception("quote not found") + + unit, method = self._verify_and_get_unit_method( + melt_quote.unit, melt_quote.method + ) + + # we only check the state with the backend if there is no associated internal + # mint quote for this melt quote + is_internal = await self.crud.get_mint_quote( + request=melt_quote.request, db=self.db + ) + + if melt_quote.pending and not is_internal: + logger.debug( + "Lightning: checking outgoing Lightning payment" + f" {melt_quote.checking_id}" + ) + status: PaymentStatus = await self.backends[method][ + unit + ].get_payment_status(melt_quote.checking_id) + logger.debug(f"State: {status.result}") + if status.settled: + logger.debug(f"Setting quote {quote_id} as paid") + melt_quote.state = MeltQuoteState.paid + if status.fee: + melt_quote.fee_paid = status.fee.to(unit).amount + if status.preimage: + melt_quote.payment_preimage = status.preimage + melt_quote.paid_time = int(time.time()) + pending_proofs = await self.crud.get_pending_proofs_for_quote( + quote_id=quote_id, db=self.db + ) + + # change to compensate wallet for overpaid fees + melt_outputs = await self.crud.get_blinded_messages_melt_id( + melt_id=quote_id, db=self.db + ) + if melt_outputs: + total_provided = sum_proofs(pending_proofs) + input_fees = self.get_fees_for_proofs(pending_proofs) + fee_reserve_provided = ( + total_provided - melt_quote.amount - input_fees + ) + return_promises = await self._generate_change_promises( + fee_provided=fee_reserve_provided, + fee_paid=melt_quote.fee_paid, + outputs=melt_outputs, + melt_id=quote_id, + keyset=self.keysets[melt_outputs[0].id], + ) + melt_quote.change = return_promises + + # Calculate fees + proofs_by_keyset: Dict[str, List[Proof]] = {} + for p in pending_proofs: + proofs_by_keyset.setdefault(p.id, []).append(p) + keyset_fees = {} + for keyset_id, keyset_proofs in proofs_by_keyset.items(): + keyset_fees[keyset_id] = self.get_fees_for_proofs(keyset_proofs) + + melt_quote = await self.db_write.set_melt_quote_paid_and_invalidate_proofs( + quote=melt_quote, + proofs=pending_proofs, + keysets=self.keysets, + keyset_fees=keyset_fees, + ) + + if status.failed or (rollback_unknown and status.unknown): + logger.debug(f"Setting quote {quote_id} as unpaid") + pending_proofs = await self.crud.get_pending_proofs_for_quote( + quote_id=quote_id, db=self.db + ) + melt_quote = await self.db_write.unset_melt_quote_pending_and_proofs( + quote=melt_quote, + proofs=pending_proofs, + keysets=self.keysets, + state=MeltQuoteState.unpaid, + ) + + + return melt_quote + + async def melt_mint_settle_internally( + self, melt_quote: MeltQuote, proofs: List[Proof] + ) -> MeltQuote: + """Settles a melt quote internally if there is a mint quote with the same payment request. + + `proofs` are passed to determine the ecash input transaction fees for this melt quote. + + Args: + melt_quote (MeltQuote): Melt quote to settle. + proofs (List[Proof]): Proofs provided for paying the Lightning invoice. + + Raises: + Exception: Melt quote already paid. + Exception: Melt quote already issued. + + Returns: + MeltQuote: Settled melt quote. + """ + # first we check if there is a mint quote with the same payment request + # so that we can handle the transaction internally without the backend + mint_quote = await self.crud.get_mint_quote( + request=melt_quote.request, db=self.db + ) + if not mint_quote: + return melt_quote + + # settle externally if units are different + if mint_quote.unit != melt_quote.unit: + return melt_quote + + # we settle the transaction internally + if melt_quote.paid: + raise TransactionError("melt quote already paid") + + # verify amounts from bolt11 invoice + bolt11_request = melt_quote.request + invoice_obj = bolt11.decode(bolt11_request) + + if not invoice_obj.amount_msat: + raise TransactionError("invoice has no amount.") + if not mint_quote.amount == melt_quote.amount: + raise TransactionError("amounts do not match") + if not bolt11_request == mint_quote.request: + raise TransactionError("bolt11 requests do not match") + if not mint_quote.method == melt_quote.method: + raise TransactionError("methods do not match") + + if mint_quote.paid: + raise TransactionError("mint quote already paid") + if mint_quote.issued: + raise TransactionError("mint quote already issued") + + if mint_quote.state != MintQuoteState.unpaid: + raise TransactionError("mint quote is not unpaid") + + logger.info( + f"Settling bolt11 payment internally: {melt_quote.quote} ->" + f" {mint_quote.quote} ({melt_quote.amount} {melt_quote.unit})" + ) + + melt_quote.fee_paid = 0 # no internal fees + melt_quote.state = MeltQuoteState.paid + melt_quote.paid_time = int(time.time()) + + mint_quote.state = MintQuoteState.paid + mint_quote.paid_time = melt_quote.paid_time + + async with self.db.get_connection() as conn: + await self.crud.update_melt_quote(quote=melt_quote, db=self.db, conn=conn) + await self.crud.update_mint_quote(quote=mint_quote, db=self.db, conn=conn) + + await self.events.submit(melt_quote) + await self.events.submit(mint_quote) + + return melt_quote + + async def melt( + self, + *, + proofs: List[Proof], + quote: str, + outputs: Optional[List[BlindedMessage]] = None, + ) -> PostMeltQuoteResponse: + """Invalidates proofs and pays a Lightning invoice. + + Args: + proofs (List[Proof]): Proofs provided for paying the Lightning invoice + quote (str): ID of the melt quote. + outputs (Optional[List[BlindedMessage]]): Blank outputs for returning overpaid fees to the wallet. + + Raises: + e: Lightning payment unsuccessful + + Returns: + PostMeltQuoteResponse: Melt quote response. + """ + # make sure we're allowed to melt + if self.disable_melt and settings.mint_disable_melt_on_error: + raise NotAllowedError("Melt is disabled. Please contact the operator.") + + # get melt quote and check if it was already paid + melt_quote = await self.get_melt_quote(quote_id=quote) + if not melt_quote.unpaid: + raise TransactionError(f"melt quote is not unpaid: {melt_quote.state}") + + unit, method = self._verify_and_get_unit_method( + melt_quote.unit, melt_quote.method + ) + + # make sure that the proofs are in the same unit as the quote + self._verify_proofs_unit(proofs, expected_unit=unit) + + # make sure that the outputs (for fee return) are in the same unit as the quote + if outputs: + # _verify_outputs checks if all outputs have the same unit + await self._verify_outputs( + outputs, skip_amount_check=True, expected_unit=unit + ) + + # verify SIG_ALL signatures + message_to_sign = ( + "".join([p.secret for p in proofs] + [o.B_ for o in outputs or []]) + quote + ) + self._verify_sigall_spending_conditions(proofs, outputs or [], message_to_sign) + + # verify that the amount of the input proofs is equal to the amount of the quote + total_provided = sum_proofs(proofs) + input_fees = self.get_fees_for_proofs(proofs) + total_needed = melt_quote.amount + melt_quote.fee_reserve + input_fees + # we need the fees specifically for lightning to return the overpaid fees + fee_reserve_provided = total_provided - melt_quote.amount - input_fees + if total_provided < total_needed: + raise TransactionError( + f"not enough inputs provided for melt. Provided: {total_provided}, needed: {total_needed}" + ) + if fee_reserve_provided < melt_quote.fee_reserve: + raise TransactionError( + f"not enough fee reserve provided for melt. Provided fee reserve: {fee_reserve_provided}, needed: {melt_quote.fee_reserve}" + ) + + # verify inputs and their spending conditions + # note, we do not verify outputs here, as they are only used for returning overpaid fees + # We must have called _verify_outputs here already! (see above) + await self.verify_inputs_and_outputs(proofs=proofs) + + # set quote and proofs to pending to avoid race conditions + melt_quote = await self.db_write.verify_and_set_melt_quote_pending( + quote=melt_quote, proofs=proofs, keysets=self.keysets + ) + + # store the change outputs + if outputs: + await self._store_blinded_messages(outputs, melt_id=melt_quote.quote) + + # if the melt corresponds to an internal mint, mark both as paid + melt_quote = await self.melt_mint_settle_internally(melt_quote, proofs) + # quote not paid yet (not internal), pay it with the backend + if not melt_quote.paid: + logger.debug(f"Lightning: pay invoice {melt_quote.request}") + try: + payment = await self.backends[method][unit].pay_invoice( + melt_quote, melt_quote.fee_reserve * 1000 + ) + logger.debug( + f"Melt – Result: {payment.result.name}: preimage: {payment.preimage}," + f" fee: {payment.fee.str() if payment.fee is not None else 'None'}" + ) + if ( + payment.checking_id + and payment.checking_id != melt_quote.checking_id + ): + logger.warning( + f"pay_invoice returned different checking_id: {payment.checking_id} than melt quote: {melt_quote.checking_id}. Will use it for potentially checking payment status later." + ) + melt_quote.checking_id = payment.checking_id + await self.crud.update_melt_quote(quote=melt_quote, db=self.db) + except Exception as e: + logger.error(f"Exception during pay_invoice: {e}") + payment = PaymentResponse( + result=PaymentResult.UNKNOWN, + error_message=str(e), + ) + + match payment.result: + case PaymentResult.FAILED | PaymentResult.UNKNOWN: + # explicitly check payment status for failed or unknown payment states + checking_id = payment.checking_id or melt_quote.checking_id + logger.debug( + f"Payment state is {payment.result.name}.{' Error: ' + payment.error_message + '.' if payment.error_message else ''} Checking status for {checking_id}." + ) + try: + status = await self.backends[method][unit].get_payment_status( + checking_id + ) + except Exception as e: + # Something went wrong. We might have lost connection to the backend. Keep transaction pending and return. + logger.error( + f"Lightning backend error: could not check payment status. Proofs for melt quote {melt_quote.quote} are stuck as PENDING.\nError: {e}" + ) + self.disable_melt = True + return PostMeltQuoteResponse.from_melt_quote(melt_quote) + + match status.result: + case PaymentResult.FAILED | PaymentResult.UNKNOWN: + # Everything as expected. Payment AND a status check both agree on a failure. We roll back the transaction. + await self.db_write.unset_melt_quote_pending_and_proofs( + quote=melt_quote, + proofs=proofs, + keysets=self.keysets, + state=MeltQuoteState.unpaid, + ) + if status.error_message: + logger.error( + f"Status check error: {status.error_message}" + ) + raise LightningPaymentFailedError( + f"Lightning payment failed{': ' + payment.error_message if payment.error_message else ''}." + ) + case _: + # Something went wrong with our implementation or the backend. Status check returned different result than payment. Keep transaction pending and return. + logger.error( + f"Payment state was {payment.result} but additional payment state check returned {status.result.name}. Proofs for melt quote {melt_quote.quote} are stuck as PENDING." + ) + self.disable_melt = True + return PostMeltQuoteResponse.from_melt_quote(melt_quote) + + case PaymentResult.SETTLED: + # payment successful + if payment.fee: + melt_quote.fee_paid = payment.fee.to( + to_unit=unit, round="up" + ).amount + if payment.preimage: + melt_quote.payment_preimage = payment.preimage + # set quote as paid + melt_quote.state = MeltQuoteState.paid + melt_quote.paid_time = int(time.time()) + # NOTE: This is the only branch for a successful payment + + case PaymentResult.PENDING | _: + logger.debug( + f"Lightning payment is {payment.result.name}: {payment.checking_id}" + ) + return PostMeltQuoteResponse.from_melt_quote(melt_quote) + + # melt was successful (either internal or via backend), invalidate proofs + # prepare change to compensate wallet for overpaid fees + return_promises: List[BlindedSignature] = [] + if outputs: + return_promises = await self._generate_change_promises( + fee_provided=fee_reserve_provided, + fee_paid=melt_quote.fee_paid, + outputs=outputs, + melt_id=melt_quote.quote, + keyset=self.keysets[outputs[0].id], + ) + + melt_quote.change = return_promises + + # Calculate fees + proofs_by_keyset: Dict[str, List[Proof]] = {} + for p in proofs: + proofs_by_keyset.setdefault(p.id, []).append(p) + keyset_fees = {} + for keyset_id, keyset_proofs in proofs_by_keyset.items(): + keyset_fees[keyset_id] = self.get_fees_for_proofs(keyset_proofs) + + melt_quote = await self.db_write.set_melt_quote_paid_and_invalidate_proofs( + quote=melt_quote, + proofs=proofs, + keysets=self.keysets, + keyset_fees=keyset_fees, + ) + + return PostMeltQuoteResponse.from_melt_quote(melt_quote) + + + async def swap( + self, + *, + proofs: List[Proof], + outputs: List[BlindedMessage], + keyset: Optional[MintKeyset] = None, + ): + """Consumes proofs and prepares new promises based on the amount swap. Used for swapping tokens + Before sending or for redeeming tokens for new ones that have been received by another wallet. + + Args: + proofs (List[Proof]): Proofs to be invalidated for the swap. + outputs (List[BlindedMessage]): New outputs that should be signed in return. + keyset (Optional[MintKeyset], optional): Keyset to use. Uses default keyset if not given. Defaults to None. + + Raises: + Exception: Validation of proofs or outputs failed + + Returns: + List[BlindedSignature]: New promises (signatures) for the outputs. + """ + logger.trace("swap called") + # verify spending inputs, outputs, and spending conditions + await self.verify_inputs_and_outputs(proofs=proofs, outputs=outputs) + await self.db_write._verify_spent_proofs_and_set_pending( + proofs, keysets=self.keysets + ) try: Ys = [p.Y for p in proofs] lock_parameters = {f"y{i}": y for i, y in enumerate(Ys)} @@ -1058,156 +1061,170 @@ async def swap( lock_select_statement=f"y IN ({ys_list})", lock_parameters=lock_parameters, ) as conn: - await self._store_blinded_messages(outputs, keyset=keyset, conn=conn) - - # Calculate fees - proofs_by_keyset: Dict[str, List[Proof]] = {} - for p in proofs: - proofs_by_keyset.setdefault(p.id, []).append(p) - keyset_fees = {} - for keyset_id, keyset_proofs in proofs_by_keyset.items(): - keyset_fees[keyset_id] = self.get_fees_for_proofs(keyset_proofs) - - await self.db_write.invalidate_proofs( - proofs=proofs, - keysets=self.keysets, - keyset_fees=keyset_fees, - conn=conn, - ) - promises = await self._sign_blinded_messages(outputs, conn) - except Exception as e: - logger.trace(f"swap failed: {e}") - raise e - finally: - # delete proofs from pending list - await self.db_write._unset_proofs_pending(proofs, keysets=self.keysets) - - logger.trace("swap successful") - return promises - - async def restore( - self, outputs: List[BlindedMessage] - ) -> Tuple[List[BlindedMessage], List[BlindedSignature]]: - signatures: List[BlindedSignature] = [] - return_outputs: List[BlindedMessage] = [] - async with self.db.get_connection() as conn: - for output in outputs: - logger.trace(f"looking for promise: {output}") - promise = await self.crud.get_blind_signature( - b_=output.B_, db=self.db, conn=conn - ) - if promise is not None: - signatures.append(promise) - return_outputs.append(output) - logger.trace(f"promise found: {promise}") - return return_outputs, signatures - - # ------- BLIND SIGNATURES ------- - - async def _store_blinded_messages( - self, - outputs: List[BlindedMessage], - keyset: Optional[MintKeyset] = None, - mint_id: Optional[str] = None, - melt_id: Optional[str] = None, - swap_id: Optional[str] = None, - conn: Optional[Connection] = None, - ) -> None: - """Stores a blinded message in the database. - - Args: - outputs (List[BlindedMessage]): Blinded messages to store. - keyset (Optional[MintKeyset], optional): Keyset to use. Uses default keyset if not given. Defaults to None. - conn: (Optional[Connection], optional): Database connection to reuse. Will create a new one if not given. Defaults to None. - """ - async with self.db.get_connection(conn) as conn: - for output in outputs: - keyset = keyset or self.keysets[output.id] - if output.id not in self.keysets: - raise TransactionError(f"keyset {output.id} not found") - if output.id != keyset.id: - raise TransactionError("keyset id does not match output id") - if not keyset.active: - raise TransactionError("keyset is not active") - logger.trace(f"Storing blinded message with keyset {keyset.id}.") - await self.crud.store_blinded_message( - id=keyset.id, - amount=output.amount, - b_=output.B_, - mint_id=mint_id, - melt_id=melt_id, - swap_id=swap_id, - db=self.db, - conn=conn, - ) - logger.trace(f"Stored blinded message for {output.amount}") - - async def _sign_blinded_messages( - self, - outputs: List[BlindedMessage], - conn: Optional[Connection] = None, - ) -> list[BlindedSignature]: - """Generates a promises (Blind signatures) for given amount and returns a pair (amount, C'). - - Important: When a promises is once created it should be considered issued to the user since the user - will always be able to restore promises later through the backup restore endpoint. That means that additional - checks in the code that might decide not to return these promises should be avoided once this function is - called. Only call this function if the transaction is fully validated! - - Args: - B_s (List[BlindedMessage]): Blinded secret (point on curve) - keyset (Optional[MintKeyset], optional): Which keyset to use. Private keys will be taken from this keyset. - If not given will use the keyset of the first output. Defaults to None. - conn: (Optional[Connection], optional): Database connection to reuse. Will create a new one if not given. Defaults to None. - Returns: - list[BlindedSignature]: Generated BlindedSignatures. - """ - promises: List[ - Tuple[str, PublicKey, int, PublicKey, PrivateKey, PrivateKey] - ] = [] - for output in outputs: - B_ = PublicKey(bytes.fromhex(output.B_)) - if output.id not in self.keysets: - raise TransactionError(f"keyset {output.id} not found") - keyset = self.keysets[output.id] - if output.id != keyset.id: - raise TransactionError("keyset id does not match output id") - if not keyset.active: - raise TransactionError("keyset is not active") - keyset_id = output.id - logger.trace(f"Generating promise with keyset {keyset_id}.") - private_key_amount = keyset.private_keys[output.amount] - C_, e, s = b_dhke.step2_bob(B_, private_key_amount) - promises.append((keyset_id, B_, output.amount, C_, e, s)) - - keyset = keyset or self.keyset - - signatures = [] - async with self.db.get_connection(conn) as conn: - for promise in promises: - keyset_id, B_, amount, C_, e, s = promise - logger.trace(f"crud: _generate_promise storing promise for {amount}") - await self.crud.update_blinded_message_signature( - amount=amount, - b_=B_.format().hex(), - c_=C_.format().hex(), - e=e.to_hex(), - s=s.to_hex(), - db=self.db, - conn=conn, - ) - logger.trace(f"crud: _generate_promise stored promise for {amount}") - signature = BlindedSignature( - id=keyset_id, - amount=amount, - C_=C_.format().hex(), - dleq=DLEQ(e=e.to_hex(), s=s.to_hex()), - ) - signatures.append(signature) - - # bump keyset balance - await self.crud.bump_keyset_balance( - db=self.db, keyset=self.keysets[keyset_id], amount=amount, conn=conn - ) - - return signatures + await self._store_blinded_messages(outputs, keyset=keyset, conn=conn) + + # Calculate fees + proofs_by_keyset: Dict[str, List[Proof]] = {} + for p in proofs: + proofs_by_keyset.setdefault(p.id, []).append(p) + keyset_fees = {} + for keyset_id, keyset_proofs in proofs_by_keyset.items(): + keyset_fees[keyset_id] = self.get_fees_for_proofs(keyset_proofs) + + await self.db_write.invalidate_proofs( + proofs=proofs, + keysets=self.keysets, + keyset_fees=keyset_fees, + conn=conn, + ) + promises = await self._sign_blinded_messages(outputs, conn) + except Exception as e: + logger.trace(f"swap failed: {e}") + raise e + finally: + # delete proofs from pending list + await self.db_write._unset_proofs_pending(proofs, keysets=self.keysets) + + logger.trace("swap successful") + return promises + + async def restore( + self, outputs: List[BlindedMessage] + ) -> Tuple[List[BlindedMessage], List[BlindedSignature]]: + signatures: List[BlindedSignature] = [] + return_outputs: List[BlindedMessage] = [] + async with self.db.get_connection() as conn: + for output in outputs: + logger.trace(f"looking for promise: {output}") + promise = await self.crud.get_blind_signature( + b_=output.B_, db=self.db, conn=conn + ) + if promise is not None: + signatures.append(promise) + return_outputs.append(output) + logger.trace(f"promise found: {promise}") + return return_outputs, signatures + + # ------- BLIND SIGNATURES ------- + + async def _store_blinded_messages( + self, + outputs: List[BlindedMessage], + keyset: Optional[MintKeyset] = None, + mint_id: Optional[str] = None, + melt_id: Optional[str] = None, + swap_id: Optional[str] = None, + conn: Optional[Connection] = None, + ) -> None: + """Stores a blinded message in the database. + + Args: + outputs (List[BlindedMessage]): Blinded messages to store. + keyset (Optional[MintKeyset], optional): Keyset to use. Uses default keyset if not given. Defaults to None. + conn: (Optional[Connection], optional): Database connection to reuse. Will create a new one if not given. Defaults to None. + """ + async with self.db.get_connection(conn) as conn: + for output in outputs: + keyset = keyset or self.keysets[output.id] + if output.id not in self.keysets: + raise TransactionError(f"keyset {output.id} not found") + if output.id != keyset.id: + raise TransactionError("keyset id does not match output id") + if not keyset.active: + raise TransactionError("keyset is not active") + logger.trace(f"Storing blinded message with keyset {keyset.id}.") + await self.crud.store_blinded_message( + id=keyset.id, + amount=output.amount, + b_=output.B_, + mint_id=mint_id, + melt_id=melt_id, + swap_id=swap_id, + db=self.db, + conn=conn, + ) + logger.trace(f"Stored blinded message for {output.amount}") + + async def _sign_blinded_messages( + self, + outputs: List[BlindedMessage], + conn: Optional[Connection] = None, + ) -> list[BlindedSignature]: + """Generates a promises (Blind signatures) for given amount and returns a pair (amount, C'). + + Important: When a promises is once created it should be considered issued to the user since the user + will always be able to restore promises later through the backup restore endpoint. That means that additional + checks in the code that might decide not to return these promises should be avoided once this function is + called. Only call this function if the transaction is fully validated! + + Args: + B_s (List[BlindedMessage]): Blinded secret (point on curve) + keyset (Optional[MintKeyset], optional): Which keyset to use. Private keys will be taken from this keyset. + If not given will use the keyset of the first output. Defaults to None. + conn: (Optional[Connection], optional): Database connection to reuse. Will create a new one if not given. Defaults to None. + Returns: + list[BlindedSignature]: Generated BlindedSignatures. + """ + + promises: List[ + Tuple[str, Any, int, Any, Any, Any] + ] = [] + for output in outputs: + if output.id not in self.keysets: + raise TransactionError(f"keyset {output.id} not found") + keyset = self.keysets[output.id] + is_v3 = is_bls_keyset(keyset.id) + B_: PublicKey + if is_v3: + B_ = BlsPublicKey(bytes.fromhex(output.B_)) + else: + B_ = SecpPublicKey(bytes.fromhex(output.B_)) + + if output.id != keyset.id: + raise TransactionError("keyset id does not match output id") + if not keyset.active: + raise TransactionError("keyset is not active") + keyset_id = output.id + logger.trace(f"Generating promise with keyset {keyset_id}.") + private_key_amount = keyset.private_keys[output.amount] + + if is_v3: + C_, e, s = bls_dhke.step2_bob(B_, private_key_amount) # type: ignore + else: + C_, e, s = b_dhke.step2_bob(B_, private_key_amount) # type: ignore + + promises.append((keyset_id, B_, output.amount, C_, e, s)) + + keyset = keyset or self.keyset + + signatures = [] + async with self.db.get_connection(conn) as conn: + for promise in promises: + keyset_id, B_, amount, C_, e, s = promise + logger.trace(f"crud: _generate_promise storing promise for {amount}") + e_hex = e.to_hex() if e else None + s_hex = s.to_hex() if s else None + await self.crud.update_blinded_message_signature( + amount=amount, + b_=B_.format().hex(), + c_=C_.format().hex(), + e=e_hex, + s=s_hex, + db=self.db, + conn=conn, + ) + logger.trace(f"crud: _generate_promise stored promise for {amount}") + signature = BlindedSignature( + id=keyset_id, + amount=amount, + C_=C_.format().hex(), + dleq=DLEQ(e=e_hex, s=s_hex) if e_hex and s_hex else None, + ) + signatures.append(signature) + + # bump keyset balance + await self.crud.bump_keyset_balance( + db=self.db, keyset=self.keysets[keyset_id], amount=amount, conn=conn + ) + + return signatures diff --git a/cashu/mint/protocols.py b/cashu/mint/protocols.py index 1fcd3799f..0b3336d9a 100644 --- a/cashu/mint/protocols.py +++ b/cashu/mint/protocols.py @@ -1,7 +1,7 @@ from typing import Dict, List, Mapping, Protocol from ..core.base import Method, MintKeyset, Unit -from ..core.crypto.secp import PublicKey +from ..core.crypto.interfaces import PublicKey from ..core.db import Database from ..lightning.base import LightningBackend from ..mint.crud import LedgerCrud diff --git a/cashu/mint/verification.py b/cashu/mint/verification.py index 4230a86f4..e0c46c76d 100644 --- a/cashu/mint/verification.py +++ b/cashu/mint/verification.py @@ -8,10 +8,14 @@ Method, MintQuote, Proof, + PublicKey, Unit, ) from ..core.crypto import b_dhke -from ..core.crypto.secp import PublicKey +from ..core.crypto.bls import PublicKey as BlsPublicKey +from ..core.crypto.bls_dhke import keyed_verification +from ..core.crypto.keys import is_bls_keyset +from ..core.crypto.secp import SecpPublicKey from ..core.db import Connection from ..core.errors import ( InvalidProofsError, @@ -227,10 +231,18 @@ def _verify_proof_bdhke(self, proof: Proof) -> bool: f"Validating proof {proof.secret} with keyset {self.keysets[proof.id].id}." ) # use the appropriate active keyset for this proof.id - private_key_amount = self.keysets[proof.id].private_keys[proof.amount] - - C = PublicKey(bytes.fromhex(proof.C)) - valid = b_dhke.verify(private_key_amount, C, proof.secret) + keyset = self.keysets[proof.id] + private_key_amount = keyset.private_keys[proof.amount] + + is_v3 = is_bls_keyset(proof.id) + C: PublicKey + if is_v3: + C = BlsPublicKey(bytes.fromhex(proof.C)) + valid = keyed_verification(private_key_amount, C, proof.secret) # type: ignore + else: + C = SecpPublicKey(bytes.fromhex(proof.C)) + valid = b_dhke.verify(private_key_amount, C, proof.secret) # type: ignore + if valid: logger.trace("Proof verified.") else: diff --git a/cashu/wallet/auth/auth.py b/cashu/wallet/auth/auth.py index 40eb0b00a..5fd6cd98a 100644 --- a/cashu/wallet/auth/auth.py +++ b/cashu/wallet/auth/auth.py @@ -1,15 +1,16 @@ import hashlib import os -from typing import List, Optional +from typing import List, Optional, Union from loguru import logger +from cashu.core.base import Proof +from cashu.core.crypto.bls import PrivateKey as BlsPrivateKey +from cashu.core.crypto.secp import SecpPrivateKey +from cashu.core.db import Database from cashu.core.helpers import sum_proofs from cashu.core.mint_info import MintInfo -from ...core.base import Proof -from ...core.crypto.secp import PrivateKey -from ...core.db import Database from ..crud import get_mint_by_url, update_mint from ..wallet import Wallet from .openid_connect.openid_client import AuthorizationFlow, OpenIDClient @@ -227,12 +228,12 @@ async def mint_blind_auth(self) -> List[Proof]: amounts = self.mint_info.bat_max_mint * [1] # 1 AUTH tokens secrets = [hashlib.sha256(os.urandom(32)).hexdigest() for _ in amounts] - rs = [PrivateKey(os.urandom(32)) for _ in amounts] + rs: List[Union[SecpPrivateKey, BlsPrivateKey]] = [SecpPrivateKey(os.urandom(32)) for _ in amounts] derivation_paths = ["" for _ in amounts] - outputs, rs = self._construct_outputs(amounts, secrets, rs) + outputs, rs = self._construct_outputs(amounts, secrets, rs) # type: ignore promises = await self.blind_mint_blind_auth(clear_auth_token, outputs) new_proofs = await self._construct_proofs( - promises, secrets, rs, derivation_paths + promises, secrets, rs, derivation_paths # type: ignore ) logger.debug( f"Minted {self.unit.str(sum_proofs(new_proofs))} blind auth proofs." diff --git a/cashu/wallet/helpers.py b/cashu/wallet/helpers.py index cc59cb942..1f9433827 100644 --- a/cashu/wallet/helpers.py +++ b/cashu/wallet/helpers.py @@ -45,7 +45,7 @@ async def redeem_TokenV3(wallet: Wallet, token: TokenV3) -> Wallet: # load unit from wallet keyset db proof_keyset_id = token.token[0].proofs[0].id keysets = await get_keysets(id=proof_keyset_id, db=wallet.db) - if not keysets and proof_keyset_id.startswith("01") and len(proof_keyset_id) == 16: + if not keysets and proof_keyset_id.startswith(("01", "02")) and len(proof_keyset_id) == 16: # This might be a v2 short ID, try to find a matching full ID all_keysets = await get_keysets(db=wallet.db) keysets = [k for k in all_keysets if k.id.startswith(proof_keyset_id)] diff --git a/cashu/wallet/p2pk.py b/cashu/wallet/p2pk.py index eb057f0c5..b38815b7e 100644 --- a/cashu/wallet/p2pk.py +++ b/cashu/wallet/p2pk.py @@ -11,7 +11,7 @@ P2PKWitness, Proof, ) -from ..core.crypto.secp import PrivateKey +from ..core.crypto.secp import SecpPrivateKey from ..core.db import Database from ..core.p2pk import ( P2PKSecret, @@ -24,7 +24,7 @@ class WalletP2PK(SupportsPrivateKey, SupportsDb): db: Database - private_key: PrivateKey + private_key: SecpPrivateKey # ---------- P2PK ---------- async def create_p2pk_pubkey(self): diff --git a/cashu/wallet/proofs.py b/cashu/wallet/proofs.py index fd92abd82..b4a432622 100644 --- a/cashu/wallet/proofs.py +++ b/cashu/wallet/proofs.py @@ -89,8 +89,8 @@ async def _expand_short_keyset_ids(self, proofs: List[Proof]) -> None: keysets_dict = {k.id: k for k in self.keysets.values()} for proof in proofs: - # Check if this is a v2 short ID (16 chars starting with '01') - if proof.id.startswith("01") and len(proof.id) == 16: + # Check if this is a short ID (16 chars starting with '01' or '02') + if proof.id.startswith(("01", "02")) and len(proof.id) == 16: full_id = manager.get_full_keyset_id(proof.id, keysets_dict) logger.trace(f"Expanded short keyset ID {proof.id} -> {full_id}") proof.id = full_id diff --git a/cashu/wallet/protocols.py b/cashu/wallet/protocols.py index c8a4af85b..d922ed656 100644 --- a/cashu/wallet/protocols.py +++ b/cashu/wallet/protocols.py @@ -3,7 +3,7 @@ import httpx from ..core.base import Proof, Unit, WalletKeyset -from ..core.crypto.secp import PrivateKey +from ..core.crypto.interfaces import PrivateKey from ..core.db import Database from ..core.mint_info import MintInfo diff --git a/cashu/wallet/secrets.py b/cashu/wallet/secrets.py index fbb34359e..596a0161b 100644 --- a/cashu/wallet/secrets.py +++ b/cashu/wallet/secrets.py @@ -2,14 +2,15 @@ import hashlib import hmac import os -from typing import List, Optional, Tuple +from typing import List, Optional, Tuple, Union from bip32 import BIP32 from loguru import logger from mnemonic import Mnemonic -from ..core.crypto.keys import get_keyset_id_version -from ..core.crypto.secp import PrivateKey +from ..core.crypto.bls import PrivateKey as BlsPrivateKey +from ..core.crypto.keys import get_keyset_id_version, is_bls_keyset +from ..core.crypto.secp import SecpPrivateKey from ..core.db import Database from ..core.secret import Secret from ..core.settings import settings @@ -88,7 +89,7 @@ async def _init_private_key(self, from_mnemonic: Optional[str] = None) -> None: try: self.bip32 = BIP32.from_seed(self.seed) - self.private_key = PrivateKey( + self.private_key = SecpPrivateKey( self.bip32.get_privkey_from_path("m/129372'/0'/0'/0'") ) except ValueError: @@ -127,7 +128,7 @@ async def generate_determinstic_secret( if version == "base64" or version == "00": # BIP32 derivation for base64 (ancient) and version 00 keysets return await self._derive_secret_bip32(counter, keyset_id) - elif version == "01": + elif version in ("01", "02"): # HMAC-SHA256 derivation for version 01 keysets (per NUT-13 test vectors) return await self._derive_secret_hmac_sha256(counter, keyset_id) else: @@ -189,7 +190,7 @@ async def _derive_secret_hmac_sha256( async def generate_n_secrets( self, n: int = 1, skip_bump: bool = False - ) -> Tuple[List[str], List[PrivateKey], List[str]]: + ) -> Tuple[List[str], List[Union[SecpPrivateKey, BlsPrivateKey]], List[str]]: """Generates n secrets and blinding factors and returns a tuple of secrets, blinding factors, and derivation paths. @@ -200,7 +201,7 @@ async def generate_n_secrets( will succeed or not (like a POST /mint request). Defaults to False. Returns: - Tuple[List[str], List[PrivateKey], List[str]]: Secrets, blinding factors, derivation paths + Tuple[List[str], List[ICashuPrivateKey], List[str]]: Secrets, blinding factors, derivation paths """ if n < 1: @@ -222,9 +223,10 @@ async def generate_n_secrets( # secrets are supplied as str secrets = [s[0].hex() for s in secrets_rs_derivationpaths] # rs are supplied as PrivateKey - rs = [ - PrivateKey(s[1]) for s in secrets_rs_derivationpaths - ] + if is_bls_keyset(self.keyset_id): + rs: List[Union[SecpPrivateKey, BlsPrivateKey]] = [BlsPrivateKey(s[1]) for s in secrets_rs_derivationpaths] + else: + rs = [SecpPrivateKey(s[1]) for s in secrets_rs_derivationpaths] derivation_paths = [s[2] for s in secrets_rs_derivationpaths] @@ -232,7 +234,7 @@ async def generate_n_secrets( async def generate_secrets_from_to( self, from_counter: int, to_counter: int, keyset_id: Optional[str] = None - ) -> Tuple[List[str], List[PrivateKey], List[str]]: + ) -> Tuple[List[str], List[Union[SecpPrivateKey, BlsPrivateKey]], List[str]]: """Generates secrets and blinding factors from `from_counter` to `to_counter` Args: @@ -241,7 +243,7 @@ async def generate_secrets_from_to( keyset_id (Optional[str], optional): Keyset id. Defaults to None. Returns: - Tuple[List[str], List[PrivateKey], List[str]]: Secrets, blinding factors, derivation paths + Tuple[List[str], List[ICashuPrivateKey], List[str]]: Secrets, blinding factors, derivation paths Raises: ValueError: If `from_counter` is larger than `to_counter` @@ -257,13 +259,17 @@ async def generate_secrets_from_to( # secrets are supplied as str secrets = [s[0].hex() for s in secrets_rs_derivationpaths] # rs are supplied as PrivateKey - rs = [PrivateKey(s[1]) for s in secrets_rs_derivationpaths] + keyset_id = keyset_id or self.keyset_id + if is_bls_keyset(keyset_id): + rs: List[Union[SecpPrivateKey, BlsPrivateKey]] = [BlsPrivateKey(s[1]) for s in secrets_rs_derivationpaths] + else: + rs = [SecpPrivateKey(s[1]) for s in secrets_rs_derivationpaths] derivation_paths = [s[2] for s in secrets_rs_derivationpaths] return secrets, rs, derivation_paths async def generate_locked_secrets( self, send_outputs: List[int], keep_outputs: List[int], secret_lock: Secret - ) -> Tuple[List[str], List[PrivateKey], List[str]]: + ) -> Tuple[List[str], List[Union[SecpPrivateKey, BlsPrivateKey]], List[str]]: """Generates secrets and blinding factors for a transaction with `send_outputs` and `keep_outputs`. Args: @@ -271,9 +277,9 @@ async def generate_locked_secrets( keep_outputs (List[int]): List of amounts to keep Returns: - Tuple[List[str], List[PrivateKey], List[str]]: Secrets, blinding factors, derivation paths + Tuple[List[str], List[ICashuPrivateKey], List[str]]: Secrets, blinding factors, derivation paths """ - rs: List[PrivateKey] = [] + rs: List[Union[SecpPrivateKey, BlsPrivateKey]] = [] # generate secrets for receiver secret_locks = [secret_lock.serialize() for i in range(len(send_outputs))] logger.debug(f"Creating proofs with custom secrets: {secret_locks}") diff --git a/cashu/wallet/v1_api.py b/cashu/wallet/v1_api.py index 6c7a9f2d5..bcb623e71 100644 --- a/cashu/wallet/v1_api.py +++ b/cashu/wallet/v1_api.py @@ -1,6 +1,6 @@ import json from posixpath import join -from typing import List, Optional, Tuple, Union +from typing import Dict, List, Optional, Tuple, Union import bolt11 import httpx @@ -19,7 +19,10 @@ Unit, WalletKeyset, ) -from ..core.crypto.secp import PublicKey +from ..core.crypto.bls import PublicKey as BlsPublicKey +from ..core.crypto.interfaces import PublicKey +from ..core.crypto.keys import is_bls_keyset +from ..core.crypto.secp import SecpPublicKey from ..core.db import Database from ..core.models import ( GetInfoResponse, @@ -244,18 +247,23 @@ async def _get_keys(self) -> List[WalletKeyset]: keys = KeysResponse.model_validate(keys_dict) keysets_str = " ".join([f"{k.id} ({k.unit})" for k in keys.keysets]) logger.debug(f"Received {len(keys.keysets)} keysets from mint: {keysets_str}.") - ret = [ - WalletKeyset( + + ret = [] + for keyset in keys.keysets: + is_v3 = is_bls_keyset(keyset.id) + pub_keys: Dict[int, PublicKey] = {} + for amt, val in keyset.keys.items(): + if is_v3: + pub_keys[int(amt)] = BlsPublicKey(bytes.fromhex(val), group="G2") + else: + pub_keys[int(amt)] = SecpPublicKey(bytes.fromhex(val)) + + ret.append(WalletKeyset( id=keyset.id, unit=keyset.unit, - public_keys={ - int(amt): PublicKey(bytes.fromhex(val)) - for amt, val in keyset.keys.items() - }, + public_keys=pub_keys, mint_url=self.url, - ) - for keyset in keys.keysets - ] + )) return ret @async_set_httpx_client @@ -280,12 +288,18 @@ async def _get_keyset(self, keyset_id: str) -> WalletKeyset: keys_dict = resp.json() assert len(keys_dict), Exception("did not receive any keys") + keys = KeysResponse.model_validate(keys_dict) this_keyset = keys.keysets[0] - keyset_keys = { - int(amt): PublicKey(bytes.fromhex(val)) - for amt, val in this_keyset.keys.items() - } + is_v3 = is_bls_keyset(this_keyset.id) + + keyset_keys: Dict[int, PublicKey] = {} + for amt, val in this_keyset.keys.items(): + if is_v3: + keyset_keys[int(amt)] = BlsPublicKey(bytes.fromhex(val), group="G2") + else: + keyset_keys[int(amt)] = SecpPublicKey(bytes.fromhex(val)) + keyset = WalletKeyset( id=keyset_id, unit=this_keyset.unit, diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 1b63b8eab..408925b63 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -1,1513 +1,1579 @@ -import copy -import json -import threading -import time -from typing import Callable, Dict, List, Optional, Tuple, Union - -from bip32 import BIP32 -from loguru import logger - -from ..core.base import ( - Amount, - BlindedMessage, - BlindedSignature, - DLEQWallet, - MeltQuote, - MeltQuoteState, - MintQuote, - MintQuoteState, - Proof, - Unit, - WalletKeyset, - WalletMint, -) -from ..core.crypto import b_dhke -from ..core.crypto.secp import PrivateKey, PublicKey -from ..core.db import Database -from ..core.errors import KeysetNotFoundError -from ..core.helpers import ( - amount_summary, - calculate_number_of_blank_outputs, - sum_promises, - sum_proofs, -) -from ..core.json_rpc.base import JSONRPCSubscriptionKinds -from ..core.migrations import migrate_databases -from ..core.mint_info import MintInfo -from ..core.models import ( - PostCheckStateResponse, - PostMeltQuoteResponse, -) -from ..core.nuts import nut20 -from ..core.p2pk import Secret -from ..core.settings import settings -from . import migrations -from .compat import WalletCompat -from .crud import ( - bump_secret_derivation, - get_bolt11_melt_quote, - get_bolt11_mint_quote, - get_keysets, - get_mint_by_url, - get_proofs, - invalidate_proof, - secret_used, - set_secret_derivation, - store_bolt11_melt_quote, - store_bolt11_mint_quote, - store_keyset, - store_mint, - store_proof, - update_bolt11_melt_quote, - update_bolt11_mint_quote, - update_keyset, - update_mint, - update_proof, -) -from .errors import BalanceTooLowError -from .htlc import WalletHTLC -from .p2pk import WalletP2PK -from .proofs import WalletProofs -from .secrets import WalletSecrets -from .subscriptions import SubscriptionManager -from .transactions import WalletTransactions -from .utils import sanitize_url -from .v1_api import LedgerAPI - - -class Wallet( - LedgerAPI, - WalletP2PK, - WalletHTLC, - WalletSecrets, - WalletTransactions, - WalletProofs, - WalletCompat, -): - """ - Nutshell wallet class. - - This class is the main interface to the Nutshell wallet. It is a subclass of the - LedgerAPI class, which provides the API methods to interact with the mint. - - To use `Wallet`, initialize it with the mint URL and the path to the database directory. - - Initialize the wallet with `Wallet.with_db(url, db)`. This will load the private key and - all keysets from the database. - - Use `load_proofs` to load all proofs of the selected mint and unit from the database. - - Use `load_mint` to load the public keys of the mint and fetch those that we don't have. - This will also load the mint info. - - Use `mint_quote` to request a Lightning invoice for minting tokens. - Use `mint` to mint tokens of a specific amount after an invoice has been paid. - Use `melt_quote` to fetch a quote for paying a Lightning invoice. - Use `melt` to pay a Lightning invoice. - """ - - keyset_id: str # holds current keyset id - keysets: Dict[str, WalletKeyset] = {} # holds keysets - # mint_keyset_ids: List[str] # holds active keyset ids of the mint - unit: Unit - mint_info: MintInfo # holds info about mint - mnemonic: str # holds mnemonic of the wallet - seed: bytes # holds private key of the wallet generated from the mnemonic - db: Database - bip32: BIP32 - # private_key: Optional[PrivateKey] = None - auth_db: Optional[Database] = None - auth_keyset_id: Optional[str] = None - - def __init__( - self, - url: str, - db: str, - name: str = "wallet", - unit: str = "sat", - auth_db: Optional[str] = None, - auth_keyset_id: Optional[str] = None, - ): - """A Cashu wallet. - - Args: - url (str): URL of the mint. - db (str): Path to the database directory. - name (str, optional): Name of the wallet database file. Defaults to "wallet". - unit (str, optional): Unit of the wallet. Defaults to "sat". - auth_db (Optional[str], optional): Path to the auth database directory. Defaults to None. - auth_keyset_id (Optional[str], optional): Keyset ID of the auth keyset. Defaults to None. - """ - self.db = Database(name, db) - self.proofs: List[Proof] = [] - self.name = name - self.unit = Unit[unit] - url = sanitize_url(url) - - # if this is an auth wallet - if (auth_db and not auth_keyset_id) or (not auth_db and auth_keyset_id): - raise Exception("Both auth_db and auth_keyset_id must be provided.") - if auth_db and auth_keyset_id: - self.auth_db = Database("auth", auth_db) - self.auth_keyset_id = auth_keyset_id - - super().__init__(url=url, db=self.db) - logger.debug("Wallet initialized") - logger.debug(f"Mint URL: {url}") - logger.debug(f"Database: {db}") - logger.debug(f"Unit: {self.unit.name}") - - @classmethod - async def with_db( - cls, - url: str, - db: str, - name: str = "wallet", - skip_db_read: bool = False, - unit: str = "sat", - auth_db: Optional[str] = None, - auth_keyset_id: Optional[str] = None, - load_all_keysets: bool = False, - **kwargs, - ): - """Initializes a wallet with a database and initializes the private key. - - Args: - url (str): URL of the mint. - db (str): Path to the database. - name (str, optional): Name of the wallet. Defaults to "wallet". - skip_db_read (bool, optional): If true, values from db like private key and - keysets are not loaded. Useful for running only migrations and returning. - Defaults to False. - unit (str, optional): Unit of the wallet. Defaults to "sat". - load_all_keysets (bool, optional): If true, all keysets are loaded from the database. - Defaults to False. - auth_db (Optional[str], optional): Path to the auth database directory. Defaults to None. - auth_keyset_id (Optional[str], optional): Keyset ID of the auth keyset. Defaults to None. - kwargs: Additional keyword arguments. - - Returns: - Wallet: Initialized wallet. - """ - logger.trace(f"Initializing wallet with database: {db}") - self = cls( - url=url, - db=db, - name=name, - unit=unit, - auth_db=auth_db, - auth_keyset_id=auth_keyset_id, - ) - await self._migrate_database() - - if skip_db_read: - return self - - logger.trace("Mint init: loading private key and keysets from db.") - await self._init_private_key() - keysets_list = await get_keysets( - mint_url=url if not load_all_keysets else None, db=self.db - ) - if not load_all_keysets: - keysets_active_unit = [k for k in keysets_list if k.unit == self.unit] - self.keysets = {k.id: k for k in keysets_active_unit} - else: - self.keysets = {k.id: k for k in keysets_list} - - if self.keysets: - keysets_str = " ".join([f"{i} {k.unit}" for i, k in self.keysets.items()]) - logger.debug(f"Loaded keysets: {keysets_str}") - - await self.load_mint_info(offline=True) - - return self - - async def _migrate_database(self): - try: - await migrate_databases(self.db, migrations) - except Exception as e: - logger.error(f"Could not run migrations: {e}") - raise e - - # ---------- API ---------- - - async def load_mint_info(self, reload=False, offline=False) -> MintInfo | None: - """Loads the mint info from the mint. - - Args: - reload (bool, optional): If True, the mint info is reloaded from the mint. Defaults to False. - offline (bool, optional): If True, the mint info is not loaded from the mint. Defaults to False. - """ - # if self.mint_info and not reload: - # return self.mint_info - - # read mint info from db - if reload: - if offline: - raise Exception("Cannot reload mint info offline.") - logger.debug("Forcing reload of mint info.") - mint_info_resp = await self._get_info() - self.mint_info = MintInfo(**mint_info_resp.model_dump()) - - wallet_mint_db = await get_mint_by_url(url=self.url, db=self.db) - if not wallet_mint_db: - if self.mint_info: - logger.debug("Storing mint info in db.") - await store_mint( - db=self.db, - mint=WalletMint( - url=self.url, info=json.dumps(self.mint_info.model_dump()) - ), - ) - else: - if offline: - return None - logger.debug("Loading mint info from mint.") - mint_info_resp = await self._get_info() - self.mint_info = MintInfo(**mint_info_resp.model_dump()) - if not wallet_mint_db: - logger.debug("Storing mint info in db.") - await store_mint( - db=self.db, - mint=WalletMint( - url=self.url, info=json.dumps(self.mint_info.model_dump()) - ), - ) - return self.mint_info - elif ( - self.mint_info - and not json.dumps(self.mint_info.model_dump()) == wallet_mint_db.info - ): - logger.debug("Updating mint info in db.") - await update_mint( - db=self.db, - mint=WalletMint(url=self.url, info=json.dumps(self.mint_info.model_dump())), - ) - return self.mint_info - else: - logger.debug("Loading mint info from db.") - self.mint_info = MintInfo.from_json_str(wallet_mint_db.info) - return self.mint_info - - async def load_mint_keysets(self, force_old_keysets=False): - """Loads all keyset of the mint and makes sure we have them all in the database. - - Then loads all keysets from the database for the active mint and active unit into self.keysets. - """ - logger.trace("Loading mint keysets.") - mint_keysets_resp = await self._get_keysets() - mint_keysets_dict = {k.id: k for k in mint_keysets_resp} - # load all keysets of thisd mint from the db - keysets_in_db = await get_keysets(mint_url=self.url, db=self.db) - - # db is empty, get all keys from the mint and store them - if not keysets_in_db: - all_keysets = await self._get_keys() - for keyset in all_keysets: - keyset.active = mint_keysets_dict[keyset.id].active - keyset.input_fee_ppk = mint_keysets_dict[keyset.id].input_fee_ppk or 0 - await store_keyset(keyset=keyset, db=self.db) - - keysets_in_db = await get_keysets(mint_url=self.url, db=self.db) - keysets_in_db_dict = {k.id: k for k in keysets_in_db} - - # get all new keysets that are not in memory yet and store them in the database - for mint_keyset in mint_keysets_dict.values(): - if mint_keyset.id not in keysets_in_db_dict: - logger.debug( - f"Storing new mint keyset: {mint_keyset.id} ({mint_keyset.unit})" - ) - wallet_keyset = await self._get_keyset(mint_keyset.id) - wallet_keyset.active = mint_keyset.active - wallet_keyset.input_fee_ppk = mint_keyset.input_fee_ppk or 0 - await store_keyset(keyset=wallet_keyset, db=self.db) - - for mint_keyset in mint_keysets_dict.values(): - # if the active flag changes from active to inactive - # or the fee attributes have changed, update them in the database - if mint_keyset.id in keysets_in_db_dict: - changed = False - if ( - not mint_keyset.active - and mint_keyset.active != keysets_in_db_dict[mint_keyset.id].active - ): - keysets_in_db_dict[mint_keyset.id].active = mint_keyset.active - changed = True - if ( - mint_keyset.input_fee_ppk - and mint_keyset.input_fee_ppk - != keysets_in_db_dict[mint_keyset.id].input_fee_ppk - ): - keysets_in_db_dict[ - mint_keyset.id - ].input_fee_ppk = mint_keyset.input_fee_ppk - changed = True - if changed: - logger.debug( - f"Updating mint keyset: {mint_keyset.id} ({mint_keyset.unit}) fee: {mint_keyset.input_fee_ppk} ppk, active: {mint_keyset.active}" - ) - await update_keyset( - keyset=keysets_in_db_dict[mint_keyset.id], db=self.db - ) - - await self.inactivate_base64_keysets(force_old_keysets) - - await self.load_keysets_from_db() - - async def activate_keyset(self, keyset_id: Optional[str] = None) -> None: - """Activates a keyset by setting self.keyset_id. Either activates a specific keyset - of chooses one of the active keysets of the mint with the same unit as the wallet. - """ - - if keyset_id: - if keyset_id not in self.keysets: - await self.load_mint_keysets() - - if keyset_id not in self.keysets: - raise KeysetNotFoundError(keyset_id) - - if self.keysets[keyset_id].unit != self.unit: - raise Exception( - f"Keyset {keyset_id} has unit {self.keysets[keyset_id].unit.name}," - f" but wallet has unit {self.unit.name}." - ) - - if not self.keysets[keyset_id].active: - raise Exception(f"Keyset {keyset_id} is not active.") - - self.keyset_id = keyset_id - else: - # if no keyset_id is given, choose an active keyset with the same unit as the wallet - chosen_keyset = None - for keyset in self.keysets.values(): - if keyset.unit == self.unit and keyset.active: - chosen_keyset = keyset - break - - if not chosen_keyset: - raise Exception(f"No active keyset found for unit {self.unit.name}.") - - self.keyset_id = chosen_keyset.id - - logger.debug( - f"Activated keyset {self.keyset_id} ({self.keysets[self.keyset_id].unit}) fee: {self.keysets[self.keyset_id].input_fee_ppk}" - ) - - async def load_mint(self, keyset_id: str = "", force_old_keysets=False) -> None: - """ - Loads the public keys of the mint. Either gets the keys for the specified - `keyset_id` or gets the keys of the active keyset from the mint. - Gets the active keyset ids of the mint and stores in `self.mint_keyset_ids`. - - Args: - keyset_id (str, optional): Keyset id to load. Defaults to "". - force_old_keysets (bool, optional): If true, old deprecated base64 keysets are not ignored. This is necessary for restoring tokens from old base64 keysets. - Defaults to False. - """ - logger.trace(f"Loading mint {self.url}") - try: - await self.load_mint_keysets(force_old_keysets) - await self.activate_keyset(keyset_id) - await self.load_mint_info(reload=True) - except Exception as e: - logger.error(f"Could not load mint info: {e}") - pass - - async def load_proofs(self, reload: bool = False, all_keysets=False) -> None: - """Load all proofs of the selected mint and unit (i.e. self.keysets) into memory.""" - - if self.proofs and not reload: - logger.debug("Proofs already loaded.") - return - - self.proofs = [] - await self.load_keysets_from_db() - async with self.db.connect() as conn: - if all_keysets: - proofs = await get_proofs(db=self.db, conn=conn) - self.proofs.extend(proofs) - else: - for keyset_id in self.keysets: - proofs = await get_proofs(db=self.db, id=keyset_id, conn=conn) - self.proofs.extend(proofs) - keysets_str = " ".join([f"{k.id} ({k.unit})" for k in self.keysets.values()]) - logger.trace(f"Proofs loaded for keysets: {keysets_str}") - - async def load_keysets_from_db( - self, url: Union[str, None] = "", unit: Union[str, None] = "" - ): - """Load all keysets of the selected mint and unit from the database into self.keysets.""" - # so that the caller can set unit = None, otherwise use defaults - if unit == "": - unit = self.unit.name - if url == "": - url = self.url - keysets = await get_keysets(mint_url=url, unit=unit, db=self.db) - for keyset in keysets: - self.keysets[keyset.id] = keyset - logger.trace( - f"Loaded keysets from db: {[(k.id, k.unit.name, k.input_fee_ppk) for k in self.keysets.values()]}" - ) - - async def _check_used_secrets(self, secrets): - """Checks if any of the secrets have already been used""" - logger.trace("Checking secrets.") - async with self.db.get_connection() as conn: - for s in secrets: - if await secret_used(s, db=self.db, conn=conn): - raise Exception(f"secret already used: {s}") - logger.trace("Secret check complete.") - - async def request_mint_with_callback( - self, - amount: int, - callback: Callable, - memo: Optional[str] = None, - ) -> Tuple[MintQuote, SubscriptionManager]: - """Request a quote invoice for minting tokens. - - Args: - amount (int): Amount for Lightning invoice in satoshis - callback (Callable): Callback function to be called when the invoice is paid. - memo (Optional[str], optional): Memo for the Lightning invoice. Defaults - - Returns: - MintQuote: Mint Quote - """ - # generate a key for signing the quote request - privkey_hex, pubkey_hex = nut20.generate_keypair() - mint_quote = await super().mint_quote(amount, self.unit, memo, pubkey_hex) - subscriptions = SubscriptionManager(self.url) - threading.Thread( - target=subscriptions.connect, name="SubscriptionManager", daemon=True - ).start() - subscriptions.subscribe( - kind=JSONRPCSubscriptionKinds.BOLT11_MINT_QUOTE, - filters=[mint_quote.quote], - callback=callback, - ) - quote = MintQuote.from_resp_wallet(mint_quote, self.url, amount, self.unit.name) - - # store the private key in the quote - quote.privkey = privkey_hex - await store_bolt11_mint_quote(db=self.db, quote=quote) - - return quote, subscriptions - - async def request_mint( - self, - amount: int, - memo: Optional[str] = None, - ) -> MintQuote: - """Request a quote invoice for minting tokens. - - Args: - amount (int): Amount for Lightning invoice in satoshis - callback (Optional[Callable], optional): Callback function to be called when the invoice is paid. Defaults to None. - memo (Optional[str], optional): Memo for the Lightning invoice. Defaults to None. - keypair (Optional[Tuple[str, str], optional]): NUT-19 private public ephemeral keypair. Defaults to None. - - Returns: - MintQuote: Mint Quote - """ - # generate a key for signing the quote request - privkey_hex, pubkey_hex = nut20.generate_keypair() - - mint_quote_response = await super().mint_quote( - amount, self.unit, memo, pubkey_hex - ) - quote = MintQuote.from_resp_wallet( - mint_quote_response, self.url, amount, self.unit.name - ) - - quote.privkey = privkey_hex - await store_bolt11_mint_quote(db=self.db, quote=quote) - return quote - - async def get_mint_quote( - self, - quote_id: str, - ) -> MintQuote: - """Get a mint quote from mint. - - Args: - quote_id (str): Id of the mint quote. - - Returns: - MintQuote: Mint quote. - """ - mint_quote_response = await super().get_mint_quote(quote_id) - mint_quote_local = await get_bolt11_mint_quote(db=self.db, quote=quote_id) - mint_quote = MintQuote.from_resp_wallet( - mint_quote_response, - mint=self.url, - amount=( - mint_quote_response.amount or mint_quote_local.amount - if mint_quote_local - else 0 # BACKWARD COMPATIBILITY mint response < 0.17.0 - ), - unit=( - mint_quote_response.unit or mint_quote_local.unit - if mint_quote_local - else self.unit.name # BACKWARD COMPATIBILITY mint response < 0.17.0 - ), - ) - if mint_quote_local and mint_quote_local.privkey: - mint_quote.privkey = mint_quote_local.privkey - - if not mint_quote_local: - await store_bolt11_mint_quote(db=self.db, quote=mint_quote) - - return mint_quote - - async def mint( - self, - amount: int, - quote_id: str, - split: Optional[List[int]] = None, - ) -> List[Proof]: - """Mint tokens of a specific amount after an invoice has been paid. - - Args: - amount (int): Total amount of tokens to be minted - quote_id (str): Id for looking up the paid Lightning invoice. - split (Optional[List[str]], optional): List of desired amount splits to be minted. Total must sum to `amount`. - - Raises: - Exception: Raises exception if `amounts` does not sum to `amount` or has unsupported value. - Exception: Raises exception if no proofs have been provided - - Returns: - List[Proof]: Newly minted proofs. - """ - # specific split - if split: - logger.trace(f"Mint with split: {split}") - assert sum(split) == amount, "split must sum to amount" - allowed_amounts = ( - self.get_allowed_amounts() - ) # Get allowed amounts from the mint - for a in split: - if a not in allowed_amounts: - raise Exception( - f"Can only mint amounts supported by the mint: {allowed_amounts}" - ) - - # split based on our wallet state - amounts = split or self.split_wallet_state(amount) - - # quirk: we skip bumping the secret counter in the database since we are - # not sure if the minting will succeed. If it succeeds, we will bump it - # in the next step. - secrets, rs, derivation_paths = await self.generate_n_secrets( - len(amounts), skip_bump=True - ) - await self._check_used_secrets(secrets) - outputs, rs = self._construct_outputs(amounts, secrets, rs) - - quote = await get_bolt11_mint_quote(db=self.db, quote=quote_id) - if not quote: - raise Exception("Quote not found.") - signature: str | None = None - if quote.privkey: - signature = nut20.sign_mint_quote(quote_id, outputs, quote.privkey) - - # will raise exception if mint is unsuccessful - promises = await super().mint(outputs, quote_id, signature) - - promises_keyset_id = promises[0].id - await bump_secret_derivation( - db=self.db, keyset_id=promises_keyset_id, by=len(amounts) - ) - proofs = await self._construct_proofs(promises, secrets, rs, derivation_paths) - - await update_bolt11_mint_quote( - db=self.db, - quote=quote_id, - state=MintQuoteState.issued, - paid_time=int(time.time()), - ) - # store the mint_id in proofs - async with self.db.connect() as conn: - for p in proofs: - p.mint_id = quote_id - await update_proof(p, mint_id=quote_id, conn=conn) - return proofs - - async def redeem( - self, - proofs: List[Proof], - ) -> Tuple[List[Proof], List[Proof]]: - """Redeem proofs by sending them to yourself by calling a split. - Args: - proofs (List[Proof]): Proofs to be redeemed. - """ - # verify DLEQ of incoming proofs - self.verify_proofs_dleq(proofs) - return await self.split(proofs=proofs, amount=0) - - async def split( - self, - proofs: List[Proof], - amount: int, - secret_lock: Optional[Secret] = None, - include_fees: bool = False, - ) -> 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. - - If secret_lock is None, random secrets will be generated for the tokens to keep (keep_outputs) - and the promises to send (send_outputs). If secret_lock is provided, the wallet will create - blinded secrets with those to attach a predefined spending condition to the tokens they want to send. - - Calls `sign_proofs_inplace_swap` which parses all proofs and checks whether their - secrets corresponds to any locks that we have the unlock conditions for. If so, - it adds the unlock conditions to the proofs. - - Args: - proofs (List[Proof]): Proofs to be split. - amount (int): Amount to be sent. - secret_lock (Optional[Secret], optional): Secret to lock the tokens to be sent. Defaults to None. - include_fees (bool, optional): If True, the fees are included in the amount to send (output of - this method, to be sent in the future). This is not the fee that is required to swap the - `proofs` (input to this method) which must already be included. Defaults to False. - - Returns: - Tuple[List[Proof], List[Proof]]: Two lists of proofs, one for keeping and one for sending. - """ - assert len(proofs) > 0, "no proofs provided." - assert sum_proofs(proofs) >= amount, "amount too large." - assert amount >= 0, "amount can't be negative." - # make sure we're operating on an independent copy of proofs - proofs = copy.copy(proofs) - - input_fees = self.get_fees_for_proofs(proofs) - logger.trace(f"Input fees: {input_fees}") - # create a suitable amounts to keep and send. - keep_outputs, send_outputs = self.determine_output_amounts( - proofs, - amount, - include_fees=include_fees, - keyset_id_outputs=self.keyset_id, - ) - - amounts = keep_outputs + send_outputs - - # generate secrets for new outputs - if secret_lock is None: - secrets, rs, derivation_paths = await self.generate_n_secrets(len(amounts)) - else: - secrets, rs, derivation_paths = await self.generate_locked_secrets( - send_outputs, keep_outputs, secret_lock - ) - - assert len(secrets) == len( - amounts - ), "number of secrets does not match number of outputs" - # verify that we didn't accidentally reuse a secret - await self._check_used_secrets(secrets) - - # construct outputs - outputs, rs = self._construct_outputs(amounts, secrets, rs, self.keyset_id) - - # potentially add witnesses to outputs based on what requirement the proofs indicate - proofs = self.sign_proofs_inplace_swap(proofs, outputs) - - # sort outputs by amount, remember original order - sorted_outputs_with_indices = sorted( - enumerate(outputs), key=lambda p: p[1].amount - ) - original_indices, sorted_outputs = zip(*sorted_outputs_with_indices) - - # Call swap API - sorted_promises = await super().split(proofs, list(sorted_outputs)) - - # sort promises back to original order - promises = [ - promise - for _, promise in sorted( - zip(original_indices, sorted_promises), key=lambda x: x[0] - ) - ] - - # Construct proofs from returned promises (i.e., unblind the signatures) - new_proofs = await self._construct_proofs( - promises, secrets, rs, derivation_paths - ) - - await self.invalidate(proofs) - - keep_proofs = new_proofs[: len(keep_outputs)] - send_proofs = new_proofs[len(keep_outputs) :] - return keep_proofs, send_proofs - - async def melt_quote( - self, invoice: str, amount_msat: Optional[int] = None - ) -> MeltQuote: - """ - Fetches a melt quote from the mint and either uses the amount in the invoice or the amount provided. - """ - if amount_msat and not self.mint_info.supports_mpp("bolt11", self.unit): - raise Exception("Mint does not support MPP, cannot specify amount.") - melt_quote_resp = await super().melt_quote(invoice, self.unit, amount_msat) - logger.debug( - f"Mint wants {self.unit.str(melt_quote_resp.fee_reserve)} as fee reserve." - ) - melt_quote = MeltQuote.from_resp_wallet( - melt_quote_resp, - self.url, - unit=self.unit.name, - request=invoice, - ) - await store_bolt11_melt_quote(db=self.db, quote=melt_quote) - melt_quote = MeltQuote.from_resp_wallet( - melt_quote_resp, - self.url, - unit=melt_quote_resp.unit - or self.unit.name, # BACKWARD COMPATIBILITY mint response < 0.17.0 - request=melt_quote_resp.request - or invoice, # BACKWARD COMPATIBILITY mint response < 0.17.0 - ) - return melt_quote - - async def get_melt_quote(self, quote: str) -> Optional[MeltQuote]: - """Fetches a melt quote from the mint and updates proofs in the database. - - Args: - quote (str): Quote ID to fetch. - - Returns: - Optional[MeltQuote]: MeltQuote object. - """ - melt_quote_resp = await super().get_melt_quote(quote) - melt_quote_local = await get_bolt11_melt_quote(db=self.db, quote=quote) - melt_quote = MeltQuote.from_resp_wallet( - melt_quote_resp, - self.url, - unit=( - melt_quote_resp.unit or melt_quote_local.unit - if melt_quote_local - else self.unit.name # BACKWARD COMPATIBILITY mint response < 0.17.0 - ), - request=( - melt_quote_resp.request or melt_quote_local.request - if (melt_quote_local and melt_quote_local.request) - else "None" # BACKWARD COMPATIBILITY mint response < 0.17.0 - ), - ) - - # update database - if not melt_quote_local: - await store_bolt11_melt_quote(db=self.db, quote=melt_quote) - else: - proofs = await get_proofs(db=self.db, melt_id=quote) - if ( - melt_quote.state == MeltQuoteState.paid - and melt_quote_local.state != MeltQuoteState.paid - ): - logger.debug("Updating paid status of melt quote.") - await update_bolt11_melt_quote( - db=self.db, - quote=quote, - state=melt_quote.state, - paid_time=int(time.time()), - payment_preimage=melt_quote.payment_preimage or "", - fee_paid=melt_quote.fee_paid, - ) - # invalidate proofs - if sum_proofs(proofs) == melt_quote.amount + melt_quote.fee_reserve: - await self.invalidate(proofs) - - if melt_quote.change: - logger.warning( - "Melt quote contains change but change is not supported yet." - ) - - if melt_quote.state == MeltQuoteState.unpaid: - logger.debug("Updating unpaid status of melt quote.") - await self.set_reserved_for_melt(proofs, reserved=False, quote_id=None) - return melt_quote - - async def melt( - self, proofs: List[Proof], invoice: str, fee_reserve_sat: int, quote_id: str - ) -> PostMeltQuoteResponse: - """Pays a lightning invoice and returns the status of the payment. - - Args: - proofs (List[Proof]): List of proofs to be spent. - invoice (str): Lightning invoice to be paid. - fee_reserve_sat (int): Amount of fees to be reserved for the payment. - - """ - - # Make sure we're operating on an independent copy of proofs - proofs = copy.copy(proofs) - - # Generate a number of blank outputs for any overpaid fees. As described in - # NUT-08, the mint will imprint these outputs with a value depending on the - # amount of fees we overpaid. - n_change_outputs = calculate_number_of_blank_outputs(fee_reserve_sat) - ( - change_secrets, - change_rs, - change_derivation_paths, - ) = await self.generate_n_secrets(n_change_outputs) - change_outputs, change_rs = self._construct_outputs( - n_change_outputs * [1], change_secrets, change_rs - ) - - await self.set_reserved_for_melt(proofs, reserved=True, quote_id=quote_id) - proofs = self.sign_proofs_inplace_melt(proofs, change_outputs, quote_id) - try: - melt_quote_resp = await super().melt(quote_id, proofs, change_outputs) - except Exception as e: - logger.debug(f"Mint error: {e}") - # remove the melt_id in proofs and set reserved to False - await self.set_reserved_for_melt(proofs, reserved=False, quote_id=None) - raise Exception(f"could not pay invoice: {e}") - - melt_quote = MeltQuote.from_resp_wallet( - melt_quote_resp, - self.url, - unit=self.unit.name, - request=invoice, - ) - # if payment fails - if melt_quote.state == MeltQuoteState.unpaid: - # remove the melt_id in proofs and set reserved to False - await self.set_reserved_for_melt(proofs, reserved=False, quote_id=None) - raise Exception("could not pay invoice.") - elif melt_quote.state == MeltQuoteState.pending: - # payment is still pending - logger.debug("Payment is still pending.") - return melt_quote_resp - - # invoice was paid successfully - await self.invalidate(proofs) - - # update paid status in db - logger.trace(f"Settings invoice {quote_id} to paid.") - logger.trace(f"Quote: {melt_quote_resp}") - fee_paid = melt_quote.amount + melt_quote.fee_paid - if melt_quote.change: - fee_paid -= sum_promises(melt_quote.change) - - await update_bolt11_melt_quote( - db=self.db, - quote=quote_id, - state=MeltQuoteState.paid, - paid_time=int(time.time()), - payment_preimage=melt_quote.payment_preimage or "", - fee_paid=fee_paid, - ) - - # handle change and produce proofs - if melt_quote.change: - change_proofs = await self._construct_proofs( - melt_quote.change, - change_secrets[: len(melt_quote.change)], - change_rs[: len(melt_quote.change)], - change_derivation_paths[: len(melt_quote.change)], - ) - logger.debug(f"Received change: {self.unit.str(sum_proofs(change_proofs))}") - return melt_quote_resp - - async def check_proof_state(self, proofs) -> PostCheckStateResponse: - return await super().check_proof_state(proofs) - - async def check_proof_state_with_callback( - self, proofs: List[Proof], callback: Callable - ) -> Tuple[PostCheckStateResponse, SubscriptionManager]: - subscriptions = SubscriptionManager(self.url) - threading.Thread( - target=subscriptions.connect, name="SubscriptionManager", daemon=True - ).start() - subscriptions.subscribe( - kind=JSONRPCSubscriptionKinds.PROOF_STATE, - filters=[proof.Y for proof in proofs], - callback=callback, - ) - return await self.check_proof_state(proofs), subscriptions - - # ---------- TOKEN MECHANICS ---------- - - # ---------- DLEQ PROOFS ---------- - - def verify_proofs_dleq(self, proofs: List[Proof]): - """Verifies DLEQ proofs in proofs.""" - for proof in proofs: - if not proof.dleq: - logger.trace("No DLEQ proof in proof.") - return - logger.trace("Verifying DLEQ proof.") - assert proof.id - assert ( - proof.id in self.keysets - ), f"Keyset {proof.id} not known, can not verify DLEQ." - if not b_dhke.carol_verify_dleq( - secret_msg=proof.secret, - C=PublicKey(bytes.fromhex(proof.C)), - r=PrivateKey(bytes.fromhex(proof.dleq.r)), - e=PrivateKey(bytes.fromhex(proof.dleq.e)), - s=PrivateKey(bytes.fromhex(proof.dleq.s)), - A=self.keysets[proof.id].public_keys[proof.amount], - ): - raise Exception("DLEQ proof invalid.") - else: - logger.trace("DLEQ proof valid.") - logger.debug("Verified incoming DLEQ proofs.") - - async def _construct_proofs( - self, - promises: List[BlindedSignature], - secrets: List[str], - rs: List[PrivateKey], - derivation_paths: List[str], - ) -> List[Proof]: - """Constructs proofs from promises, secrets, rs and derivation paths. - - This method is called after the user has received blind signatures from - the mint. The results are proofs that can be used as ecash. - - Args: - promises (List[BlindedSignature]): blind signatures from mint - secrets (List[str]): secrets that were previously used to create blind messages (that turned into promises) - rs (List[PrivateKey]): blinding factors that were previously used to create blind messages (that turned into promises) - derivation_paths (List[str]): derivation paths that were used to generate secrets and blinding factors - - Returns: - List[Proof]: list of proofs that can be used as ecash - """ - logger.trace("Constructing proofs.") - proofs: List[Proof] = [] - for promise, secret, r, path in zip(promises, secrets, rs, derivation_paths): - 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 - await self.load_mint_keysets() - assert promise.id in self.keysets, "Could not load keyset." - C_ = PublicKey(bytes.fromhex(promise.C_)) - C = b_dhke.step3_alice( - C_, r, self.keysets[promise.id].public_keys[promise.amount] - ) - - if not settings.wallet_use_deprecated_h2c: - B_, r = b_dhke.step1_alice(secret, r) # recompute B_ for dleq proofs - # BEGIN: BACKWARDS COMPATIBILITY < 0.15.1 - else: - B_, r = b_dhke.step1_alice_deprecated( - secret, r - ) # recompute B_ for dleq proofs - # END: BACKWARDS COMPATIBILITY < 0.15.1 - - proof = Proof( - id=promise.id, - amount=promise.amount, - C=C.format().hex(), - secret=secret, - derivation_path=path, - ) - - # if the mint returned a dleq proof, we add it to the proof - if promise.dleq: - proof.dleq = DLEQWallet( - e=promise.dleq.e, s=promise.dleq.s, r=r.to_hex() - ) - - proofs.append(proof) - - logger.trace( - f"Created proof: {proof}, r: {r.to_hex()} out of promise {promise}" - ) - - # DLEQ verify - self.verify_proofs_dleq(proofs) - - logger.trace(f"Constructed {len(proofs)} proofs.") - - # add new proofs to wallet - self.proofs += copy.copy(proofs) - # store new proofs in database - await self._store_proofs(proofs) - - return proofs - - def _construct_outputs( - self, - amounts: List[int], - secrets: List[str], - rs: List[PrivateKey] = [], - keyset_id: Optional[str] = None, - ) -> Tuple[List[BlindedMessage], List[PrivateKey]]: - """Takes a list of amounts and secrets and returns outputs. - Outputs are blinded messages `outputs` and blinding factors `rs` - - Args: - amounts (List[int]): list of amounts - secrets (List[str]): list of secrets - rs (List[PrivateKey], optional): list of blinding factors. If not given, `rs` are generated in step1_alice. Defaults to []. - - Returns: - List[BlindedMessage]: list of blinded messages that can be sent to the mint - List[PrivateKey]: list of blinding factors that can be used to construct proofs after receiving blind signatures from the mint - - Raises: - AssertionError: if len(amounts) != len(secrets) - """ - assert len(amounts) == len( - secrets - ), f"len(amounts)={len(amounts)} not equal to len(secrets)={len(secrets)}" - keyset_id = keyset_id or self.keyset_id - outputs: List[BlindedMessage] = [] - rs_ = [None] * len(amounts) if not rs else rs - rs_return: List[PrivateKey] = [] - for secret, amount, r in zip(secrets, amounts, rs_): - if not settings.wallet_use_deprecated_h2c: - B_, r = b_dhke.step1_alice(secret, r or None) - # BEGIN: BACKWARDS COMPATIBILITY < 0.15.1 - else: - B_, r = b_dhke.step1_alice_deprecated(secret, r or None) - # END: BACKWARDS COMPATIBILITY < 0.15.1 - - assert r - rs_return.append(r) - output = BlindedMessage( - amount=amount, B_=B_.format().hex(), id=keyset_id - ) - outputs.append(output) - logger.trace(f"Constructing output: {output}, r: {r.to_hex()}") - - return outputs, rs_return - - async def construct_outputs(self, amounts: List[int]) -> List[BlindedMessage]: - """Constructs outputs for a list of amounts. - - Args: - amounts (List[int]): List of amounts to construct outputs for. - - Returns: - List[BlindedMessage]: List of blinded messages that can be sent to the mint. - """ - secrets, rs, _ = await self.generate_n_secrets(len(amounts)) - return self._construct_outputs(amounts, secrets, rs)[0] - - async def _store_proofs(self, proofs): - try: - async with self.db.connect() as conn: - for proof in proofs: - await store_proof(proof, db=self.db, conn=conn) - except Exception as e: - logger.error(f"Could not store proofs in database: {e}") - logger.error(proofs) - raise e - - async def get_spent_proofs_check_states_batched( - self, proofs: List[Proof] - ) -> List[Proof]: - """Checks the state of proofs in batches. - - Args: - proofs (List[Proof]): List of proofs to check. - - Returns: - List[Proof]: List of proofs that are spent. - """ - batch_size = settings.proofs_batch_size - spent_proofs = [] - for i in range(0, len(proofs), batch_size): - batch = proofs[i : i + batch_size] - proof_states = await self.check_proof_state(batch) - for j, state in enumerate(proof_states.states): - if state.spent: - spent_proofs.append(batch[j]) - return spent_proofs - - async def invalidate( - self, proofs: List[Proof], check_spendable=False - ) -> List[Proof]: - """Invalidates all unspendable tokens supplied in proofs. - - Args: - proofs (List[Proof]): Which proofs to delete - check_spendable (bool, optional): Asks the mint to check whether proofs are already spent before deleting them. Defaults to False. - - Returns: - List[Proof]: List of proofs that are still spendable. - """ - invalidated_proofs: List[Proof] = [] - if check_spendable: - invalidated_proofs = await self.get_spent_proofs_check_states_batched( - proofs - ) - else: - invalidated_proofs = proofs - - if invalidated_proofs: - logger.trace( - f"Invalidating {len(invalidated_proofs)} proofs worth" - f" {self.unit.str(sum_proofs(invalidated_proofs))}." - ) - - for p in invalidated_proofs: - try: - # mark proof as spent - await invalidate_proof(p, db=self.db) - except Exception as e: - logger.error(f"DB error while invalidating proof: {e}") - - invalidate_secrets = [p.secret for p in invalidated_proofs] - self.proofs = list( - filter(lambda p: p.secret not in invalidate_secrets, self.proofs) - ) - return [p for p in proofs if p not in invalidated_proofs] - - # ---------- TRANSACTION HELPERS ---------- - - async def select_to_send( - self, - proofs: List[Proof], - amount: int, - *, - set_reserved: bool = False, - offline: bool = False, - include_fees: bool = False, - ) -> Tuple[List[Proof], int]: - """ - Selects proofs such that a desired `amount` can be sent. If the offline coin selection is unsuccessful, - and `offline` is set to False (default), we split the available proofs with the mint to get the desired `amount`. - - If `set_reserved` is set to True, the proofs are marked as reserved so they aren't used in other transactions. - - If `include_fees` is set to True, the selection includes the swap fees to receive the selected proofs. - - Args: - proofs (List[Proof]): Proofs to split - amount (int): Amount to split to - set_reserved (bool, optional): If set, the proofs are marked as reserved. Defaults to False. - offline (bool, optional): If set, the coin selection is done offline. Defaults to False. - include_fees (bool, optional): If set, the fees for spending the proofs later are included in the - amount to be selected. Defaults to False. - - Returns: - List[Proof]: Proofs to send - int: Fees for the transaction - """ - # select proofs that are not reserved and are in the active keysets of the mint - proofs = self.active_proofs(proofs) - if sum_proofs(proofs) < amount: - raise BalanceTooLowError() - - # coin selection for potentially offline sending - send_proofs = self.coinselect(proofs, amount, include_fees=include_fees) - fees = self.get_fees_for_proofs(send_proofs) - logger.trace( - f"select_to_send: selected: {self.unit.str(sum_proofs(send_proofs))} (+ {self.unit.str(fees)} fees) – wanted: {self.unit.str(amount)}" - ) - # offline coin selection unsuccessful, we need to swap proofs before we can send - if not send_proofs or sum_proofs(send_proofs) > amount + fees: - if not offline: - logger.debug("Offline coin selection unsuccessful. Splitting proofs.") - # we set the proofs as reserved later - _, send_proofs = await self.swap_to_send( - proofs, - amount, - set_reserved=False, - include_fees=include_fees, - ) - else: - raise Exception( - "Could not select proofs in offline mode. Available amounts:" - + amount_summary(proofs, self.unit) - ) - if set_reserved: - await self.set_reserved_for_send(send_proofs, reserved=True) - return send_proofs, fees - - async def swap_to_send( - self, - proofs: List[Proof], - amount: int, - *, - secret_lock: Optional[Secret] = None, - set_reserved: bool = False, - include_fees: bool = False, - ) -> 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 - proofs are returned to be kept. All newly created proofs will be stored in the database but if `set_reserved` is set - to True, the proofs to be sent (which sum up to `amount`) will be marked as reserved so they aren't used in other - transactions. - - Args: - proofs (List[Proof]): Proofs to split - amount (int): Amount to split to - secret_lock (Optional[str], optional): If set, a custom secret is used to lock new outputs. Defaults to None. - 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. - include_fees (bool, optional): If set, the fees for spending the send_proofs later are included in the amount to be selected. Defaults to True. - - Returns: - Tuple[List[Proof], List[Proof]]: Tuple of proofs to keep and proofs to send - """ - # select proofs that are not reserved and are in the active keysets of the mint - proofs = self.active_proofs(proofs) - if sum_proofs(proofs) < amount: - raise BalanceTooLowError() - - # coin selection for swapping, needs to include fees - swap_proofs = self.coinselect(proofs, amount, include_fees=True) - - # Extra rule: add proofs from inactive keysets to swap_proofs to get rid of them - swap_proofs += [ - p - for p in proofs - if not self.keysets[p.id].active and not p.reserved and p not in swap_proofs - ] - - fees = self.get_fees_for_proofs(swap_proofs) - 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, include_fees=include_fees - ) - if set_reserved: - await self.set_reserved_for_send(send_proofs, reserved=True) - return keep_proofs, send_proofs - - # ---------- BALANCE CHECKS ---------- - - @property - def balance(self) -> Amount: - return Amount(self.unit, sum_proofs(self.proofs)) - - @property - def available_balance(self) -> Amount: - return Amount(self.unit, sum_proofs([p for p in self.proofs if not p.reserved])) - - @property - def proof_amounts(self): - """Returns a sorted list of amounts of all proofs""" - return [p.amount for p in sorted(self.proofs, key=lambda p: p.amount)] - - def active_proofs(self, proofs: List[Proof]): - """Returns a list of proofs that - - have an id that is in the current `self.keysets` which have the unit in `self.unit` - - are not reserved - """ - - def is_active_proof(p: Proof) -> bool: - return ( - p.id in self.keysets - and self.keysets[p.id].unit == self.unit - and not p.reserved - ) - - return [p for p in proofs if is_active_proof(p)] - - def balance_per_keyset(self) -> Dict[str, Dict[str, Union[int, str]]]: - ret: Dict[str, Dict[str, Union[int, str]]] = { - key: { - "balance": sum_proofs(proofs), - "available": sum_proofs([p for p in proofs if not p.reserved]), - } - for key, proofs in self._get_proofs_per_keyset(self.proofs).items() - } - for key in ret.keys(): - if key in self.keysets: - ret[key]["unit"] = self.keysets[key].unit.name - return ret - - def balance_per_unit(self) -> Dict[Unit, Dict[str, Union[int, str]]]: - ret: Dict[Unit, Dict[str, Union[int, str]]] = { - unit: { - "balance": sum_proofs(proofs), - "available": sum_proofs([p for p in proofs if not p.reserved]), - } - for unit, proofs in self._get_proofs_per_unit(self.proofs).items() - } - return ret - - async def balance_per_minturl( - self, unit: Optional[Unit] = None - ) -> Dict[str, Dict[str, Union[int, str]]]: - balances = await self._get_proofs_per_minturl(self.proofs, unit=unit) - balances_return: Dict[str, Dict[str, Union[int, str]]] = { - key: { - "balance": sum_proofs(proofs), - "available": sum_proofs([p for p in proofs if not p.reserved]), - } - for key, proofs in balances.items() - } - for key in balances_return.keys(): - if unit: - balances_return[key]["unit"] = unit.name - return dict(sorted(balances_return.items(), key=lambda item: item[0])) # type: ignore - - # ---------- RESTORE WALLET ---------- - - async def restore_tokens_for_keyset( - self, keyset_id: str, to: int = 2, batch: int = 25 - ) -> None: - """ - Restores tokens for a given keyset_id. - - Args: - keyset_id (str): The keyset_id to restore tokens for. - to (int, optional): The number of consecutive empty responses to stop restoring. Defaults to 2. - batch (int, optional): The number of proofs to restore in one batch. Defaults to 25. - """ - empty_batches = 0 - # we get the current secret counter and restore from there on - spendable_proofs = [] - counter_before = await bump_secret_derivation( - db=self.db, keyset_id=keyset_id, by=0 - ) - if counter_before != 0: - print("Keyset has already been used. Restoring from its last state.") - i = counter_before - last_restore_count = 0 - while empty_batches < to: - print(f"Restoring counter {i} to {i + batch} for keyset {keyset_id} ...") - ( - next_restored_output_index, - restored_proofs, - ) = await self.restore_promises_from_to(keyset_id, i, i + batch - 1) - last_restore_count += next_restored_output_index - i += batch - if len(restored_proofs) == 0: - empty_batches += 1 - continue - spendable_proofs = await self.invalidate( - restored_proofs, check_spendable=True - ) - if len(spendable_proofs): - print( - f"Restored {sum_proofs(spendable_proofs)} sat for keyset {keyset_id}." - ) - else: - logger.debug( - f"None of the {len(restored_proofs)} restored proofs are spendable." - ) - - # restore the secret counter to its previous value for the last round - revert_counter_by = i - last_restore_count - logger.debug(f"Reverting secret counter by {revert_counter_by}") - before = await bump_secret_derivation( - db=self.db, - keyset_id=keyset_id, - by=-revert_counter_by, - ) - logger.debug( - f"Secret counter reverted from {before} to {before - revert_counter_by}" - ) - if last_restore_count == 0: - print(f"No tokens restored for keyset {keyset_id}.") - return - - async def restore_wallet_from_mnemonic( - self, mnemonic: Optional[str], to: int = 2, batch: int = 25 - ) -> None: - """ - Restores the wallet from a mnemonic. - - Args: - mnemonic (Optional[str]): The mnemonic to restore the wallet from. If None, the mnemonic is loaded from the db. - to (int, optional): The number of consecutive empty responses to stop restoring. Defaults to 2. - batch (int, optional): The number of proofs to restore in one batch. Defaults to 25. - """ - await self._init_private_key(mnemonic) - await self.load_mint(force_old_keysets=False) - print("Restoring tokens...") - for keyset_id in self.keysets.keys(): - await self.restore_tokens_for_keyset(keyset_id, to, batch) - - async def restore_promises_from_to( - self, keyset_id: str, from_counter: int, to_counter: int - ) -> Tuple[int, List[Proof]]: - """Restores promises from a given range of counters. This is for restoring a wallet from a mnemonic. - - Args: - from_counter (int): Counter for the secret derivation to start from - to_counter (int): Counter for the secret derivation to end at - - Returns: - Tuple[int, List[Proof]]: Index of the last restored output and list of restored proofs - """ - # we regenerate the secrets and rs for the given range - secrets, rs, derivation_paths = await self.generate_secrets_from_to( - from_counter, to_counter, keyset_id=keyset_id - ) - # we don't know the amount but luckily the mint will tell us so we use a dummy amount here - amounts_dummy = [1] * len(secrets) - # we generate outputs from deterministic secrets and rs - regenerated_outputs, _ = self._construct_outputs( - amounts_dummy, secrets, rs, keyset_id=keyset_id - ) - # we ask the mint to reissue the promises - next_restored_output_index, proofs = await self.restore_promises( - outputs=regenerated_outputs, - secrets=secrets, - rs=rs, - derivation_paths=derivation_paths, - ) - - await set_secret_derivation( - db=self.db, keyset_id=keyset_id, counter=to_counter + 1 - ) - return next_restored_output_index, proofs - - async def restore_promises( - self, - outputs: List[BlindedMessage], - secrets: List[str], - rs: List[PrivateKey], - derivation_paths: List[str], - ) -> Tuple[int, List[Proof]]: - """Restores proofs from a list of outputs, secrets, rs and derivation paths. - - Args: - outputs (List[BlindedMessage]): Outputs for which we request promises - secrets (List[str]): Secrets generated for the outputs - rs (List[PrivateKey]): Random blinding factors generated for the outputs - derivation_paths (List[str]): Derivation paths used for the secrets necessary to unblind the promises - - Returns: - Tuple[int, List[Proof]]: Index of the last restored output and list of restored proofs - """ - # restored_outputs is there so we can match the promises to the secrets and rs - restored_outputs, restored_promises = await super().restore_promises(outputs) - # determine the index in `outputs` of the last restored output from restored_outputs[-1].B_ - if not restored_outputs: - next_restored_output_index = 0 - else: - next_restored_output_index = ( - next( - ( - idx - for idx, val in enumerate(outputs) - if val.B_ == restored_outputs[-1].B_ - ), - 0, - ) - + 1 - ) - logger.trace(f"Last restored output index: {next_restored_output_index}") - # now we need to filter out the secrets, rs and derivation_paths that had a match - matching_indices = [ - idx - for idx, val in enumerate(outputs) - if val.B_ in [o.B_ for o in restored_outputs] - ] - secrets = [secrets[i] for i in matching_indices] - rs = [rs[i] for i in matching_indices] - derivation_paths = [derivation_paths[i] for i in matching_indices] - logger.debug( - f"Restored {len(restored_promises)} promises. Constructing proofs." - ) - # now we can construct the proofs with the secrets and rs - proofs = await self._construct_proofs( - restored_promises, secrets, rs, derivation_paths - ) - logger.debug(f"Restored {len(restored_promises)} promises") - return next_restored_output_index, proofs +import copy +import json +import threading +import time +from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union, cast + +from bip32 import BIP32 +from loguru import logger + +from ..core.base import ( + Amount, + BlindedMessage, + BlindedSignature, + DLEQWallet, + MeltQuote, + MeltQuoteState, + MintQuote, + MintQuoteState, + Proof, + Unit, + WalletKeyset, + WalletMint, +) +from ..core.crypto import b_dhke, bls_dhke +from ..core.crypto.bls import PrivateKey as BlsPrivateKey +from ..core.crypto.bls import PublicKey as BlsPublicKey +from ..core.crypto.interfaces import ( + ICashuPrivateKey, + PrivateKey, + PublicKey, +) +from ..core.crypto.keys import is_bls_keyset +from ..core.crypto.secp import SecpPrivateKey, SecpPublicKey +from ..core.db import Database +from ..core.errors import KeysetNotFoundError +from ..core.helpers import ( + amount_summary, + calculate_number_of_blank_outputs, + sum_promises, + sum_proofs, +) +from ..core.json_rpc.base import JSONRPCSubscriptionKinds +from ..core.migrations import migrate_databases +from ..core.mint_info import MintInfo +from ..core.models import ( + PostCheckStateResponse, + PostMeltQuoteResponse, +) +from ..core.nuts import nut20 +from ..core.p2pk import Secret +from ..core.settings import settings +from . import migrations +from .compat import WalletCompat +from .crud import ( + bump_secret_derivation, + get_bolt11_melt_quote, + get_bolt11_mint_quote, + get_keysets, + get_mint_by_url, + get_proofs, + invalidate_proof, + secret_used, + set_secret_derivation, + store_bolt11_melt_quote, + store_bolt11_mint_quote, + store_keyset, + store_mint, + store_proof, + update_bolt11_melt_quote, + update_bolt11_mint_quote, + update_keyset, + update_mint, + update_proof, +) +from .errors import BalanceTooLowError +from .htlc import WalletHTLC +from .p2pk import WalletP2PK +from .proofs import WalletProofs +from .secrets import WalletSecrets +from .subscriptions import SubscriptionManager +from .transactions import WalletTransactions +from .utils import sanitize_url +from .v1_api import LedgerAPI + + +class Wallet( + LedgerAPI, + WalletP2PK, + WalletHTLC, + WalletSecrets, + WalletTransactions, + WalletProofs, + WalletCompat, +): + """ + Nutshell wallet class. + + This class is the main interface to the Nutshell wallet. It is a subclass of the + LedgerAPI class, which provides the API methods to interact with the mint. + + To use `Wallet`, initialize it with the mint URL and the path to the database directory. + + Initialize the wallet with `Wallet.with_db(url, db)`. This will load the private key and + all keysets from the database. + + Use `load_proofs` to load all proofs of the selected mint and unit from the database. + + Use `load_mint` to load the public keys of the mint and fetch those that we don't have. + This will also load the mint info. + + Use `mint_quote` to request a Lightning invoice for minting tokens. + Use `mint` to mint tokens of a specific amount after an invoice has been paid. + Use `melt_quote` to fetch a quote for paying a Lightning invoice. + Use `melt` to pay a Lightning invoice. + """ + + keyset_id: str # holds current keyset id + keysets: Dict[str, WalletKeyset] = {} # holds keysets + # mint_keyset_ids: List[str] # holds active keyset ids of the mint + unit: Unit + mint_info: MintInfo # holds info about mint + mnemonic: str # holds mnemonic of the wallet + seed: bytes # holds private key of the wallet generated from the mnemonic + db: Database + bip32: BIP32 + private_key: SecpPrivateKey + auth_db: Optional[Database] = None + auth_keyset_id: Optional[str] = None + + def __init__( + self, + url: str, + db: str, + name: str = "wallet", + unit: str = "sat", + auth_db: Optional[str] = None, + auth_keyset_id: Optional[str] = None, + ): + """A Cashu wallet. + + Args: + url (str): URL of the mint. + db (str): Path to the database directory. + name (str, optional): Name of the wallet database file. Defaults to "wallet". + unit (str, optional): Unit of the wallet. Defaults to "sat". + auth_db (Optional[str], optional): Path to the auth database directory. Defaults to None. + auth_keyset_id (Optional[str], optional): Keyset ID of the auth keyset. Defaults to None. + """ + self.db = Database(name, db) + self.proofs: List[Proof] = [] + self.name = name + self.unit = Unit[unit] + url = sanitize_url(url) + + # if this is an auth wallet + if (auth_db and not auth_keyset_id) or (not auth_db and auth_keyset_id): + raise Exception("Both auth_db and auth_keyset_id must be provided.") + if auth_db and auth_keyset_id: + self.auth_db = Database("auth", auth_db) + self.auth_keyset_id = auth_keyset_id + + super().__init__(url=url, db=self.db) + logger.debug("Wallet initialized") + logger.debug(f"Mint URL: {url}") + logger.debug(f"Database: {db}") + logger.debug(f"Unit: {self.unit.name}") + + @classmethod + async def with_db( + cls, + url: str, + db: str, + name: str = "wallet", + skip_db_read: bool = False, + unit: str = "sat", + auth_db: Optional[str] = None, + auth_keyset_id: Optional[str] = None, + load_all_keysets: bool = False, + **kwargs, + ): + """Initializes a wallet with a database and initializes the private key. + + Args: + url (str): URL of the mint. + db (str): Path to the database. + name (str, optional): Name of the wallet. Defaults to "wallet". + skip_db_read (bool, optional): If true, values from db like private key and + keysets are not loaded. Useful for running only migrations and returning. + Defaults to False. + unit (str, optional): Unit of the wallet. Defaults to "sat". + load_all_keysets (bool, optional): If true, all keysets are loaded from the database. + Defaults to False. + auth_db (Optional[str], optional): Path to the auth database directory. Defaults to None. + auth_keyset_id (Optional[str], optional): Keyset ID of the auth keyset. Defaults to None. + kwargs: Additional keyword arguments. + + Returns: + Wallet: Initialized wallet. + """ + logger.trace(f"Initializing wallet with database: {db}") + self = cls( + url=url, + db=db, + name=name, + unit=unit, + auth_db=auth_db, + auth_keyset_id=auth_keyset_id, + ) + await self._migrate_database() + + if skip_db_read: + return self + + logger.trace("Mint init: loading private key and keysets from db.") + await self._init_private_key() + keysets_list = await get_keysets( + mint_url=url if not load_all_keysets else None, db=self.db + ) + if not load_all_keysets: + keysets_active_unit = [k for k in keysets_list if k.unit == self.unit] + self.keysets = {k.id: k for k in keysets_active_unit} + else: + self.keysets = {k.id: k for k in keysets_list} + + if self.keysets: + keysets_str = " ".join([f"{i} {k.unit}" for i, k in self.keysets.items()]) + logger.debug(f"Loaded keysets: {keysets_str}") + + await self.load_mint_info(offline=True) + + return self + + async def _migrate_database(self): + try: + await migrate_databases(self.db, migrations) + except Exception as e: + logger.error(f"Could not run migrations: {e}") + raise e + + # ---------- API ---------- + + async def load_mint_info(self, reload=False, offline=False) -> MintInfo | None: + """Loads the mint info from the mint. + + Args: + reload (bool, optional): If True, the mint info is reloaded from the mint. Defaults to False. + offline (bool, optional): If True, the mint info is not loaded from the mint. Defaults to False. + """ + # if self.mint_info and not reload: + # return self.mint_info + + # read mint info from db + if reload: + if offline: + raise Exception("Cannot reload mint info offline.") + logger.debug("Forcing reload of mint info.") + mint_info_resp = await self._get_info() + self.mint_info = MintInfo(**mint_info_resp.model_dump()) + + wallet_mint_db = await get_mint_by_url(url=self.url, db=self.db) + if not wallet_mint_db: + if self.mint_info: + logger.debug("Storing mint info in db.") + await store_mint( + db=self.db, + mint=WalletMint( + url=self.url, info=json.dumps(self.mint_info.model_dump()) + ), + ) + else: + if offline: + return None + logger.debug("Loading mint info from mint.") + mint_info_resp = await self._get_info() + self.mint_info = MintInfo(**mint_info_resp.model_dump()) + if not wallet_mint_db: + logger.debug("Storing mint info in db.") + await store_mint( + db=self.db, + mint=WalletMint( + url=self.url, info=json.dumps(self.mint_info.model_dump()) + ), + ) + return self.mint_info + elif ( + self.mint_info + and not json.dumps(self.mint_info.model_dump()) == wallet_mint_db.info + ): + logger.debug("Updating mint info in db.") + await update_mint( + db=self.db, + mint=WalletMint(url=self.url, info=json.dumps(self.mint_info.model_dump())), + ) + return self.mint_info + else: + logger.debug("Loading mint info from db.") + self.mint_info = MintInfo.from_json_str(wallet_mint_db.info) + return self.mint_info + + async def load_mint_keysets(self, force_old_keysets=False): + """Loads all keyset of the mint and makes sure we have them all in the database. + + Then loads all keysets from the database for the active mint and active unit into self.keysets. + """ + logger.trace("Loading mint keysets.") + mint_keysets_resp = await self._get_keysets() + mint_keysets_dict = {k.id: k for k in mint_keysets_resp} + # load all keysets of thisd mint from the db + keysets_in_db = await get_keysets(mint_url=self.url, db=self.db) + + # db is empty, get all keys from the mint and store them + if not keysets_in_db: + all_keysets = await self._get_keys() + for keyset in all_keysets: + keyset.active = mint_keysets_dict[keyset.id].active + keyset.input_fee_ppk = mint_keysets_dict[keyset.id].input_fee_ppk or 0 + await store_keyset(keyset=keyset, db=self.db) + + keysets_in_db = await get_keysets(mint_url=self.url, db=self.db) + keysets_in_db_dict = {k.id: k for k in keysets_in_db} + + # get all new keysets that are not in memory yet and store them in the database + for mint_keyset in mint_keysets_dict.values(): + if mint_keyset.id not in keysets_in_db_dict: + logger.debug( + f"Storing new mint keyset: {mint_keyset.id} ({mint_keyset.unit})" + ) + wallet_keyset = await self._get_keyset(mint_keyset.id) + wallet_keyset.active = mint_keyset.active + wallet_keyset.input_fee_ppk = mint_keyset.input_fee_ppk or 0 + await store_keyset(keyset=wallet_keyset, db=self.db) + + for mint_keyset in mint_keysets_dict.values(): + # if the active flag changes from active to inactive + # or the fee attributes have changed, update them in the database + if mint_keyset.id in keysets_in_db_dict: + changed = False + if ( + not mint_keyset.active + and mint_keyset.active != keysets_in_db_dict[mint_keyset.id].active + ): + keysets_in_db_dict[mint_keyset.id].active = mint_keyset.active + changed = True + if ( + mint_keyset.input_fee_ppk + and mint_keyset.input_fee_ppk + != keysets_in_db_dict[mint_keyset.id].input_fee_ppk + ): + keysets_in_db_dict[ + mint_keyset.id + ].input_fee_ppk = mint_keyset.input_fee_ppk + changed = True + if changed: + logger.debug( + f"Updating mint keyset: {mint_keyset.id} ({mint_keyset.unit}) fee: {mint_keyset.input_fee_ppk} ppk, active: {mint_keyset.active}" + ) + await update_keyset( + keyset=keysets_in_db_dict[mint_keyset.id], db=self.db + ) + + await self.inactivate_base64_keysets(force_old_keysets) + + await self.load_keysets_from_db() + + async def activate_keyset(self, keyset_id: Optional[str] = None) -> None: + """Activates a keyset by setting self.keyset_id. Either activates a specific keyset + of chooses one of the active keysets of the mint with the same unit as the wallet. + """ + + if keyset_id: + if keyset_id not in self.keysets: + await self.load_mint_keysets() + + if keyset_id not in self.keysets: + raise KeysetNotFoundError(keyset_id) + + if self.keysets[keyset_id].unit != self.unit: + raise Exception( + f"Keyset {keyset_id} has unit {self.keysets[keyset_id].unit.name}," + f" but wallet has unit {self.unit.name}." + ) + + if not self.keysets[keyset_id].active: + raise Exception(f"Keyset {keyset_id} is not active.") + + self.keyset_id = keyset_id + else: + # if no keyset_id is given, choose an active keyset with the same unit as the wallet + chosen_keyset = None + for keyset in self.keysets.values(): + if keyset.unit == self.unit and keyset.active: + chosen_keyset = keyset + break + + if not chosen_keyset: + raise Exception(f"No active keyset found for unit {self.unit.name}.") + + self.keyset_id = chosen_keyset.id + + logger.debug( + f"Activated keyset {self.keyset_id} ({self.keysets[self.keyset_id].unit}) fee: {self.keysets[self.keyset_id].input_fee_ppk}" + ) + + async def load_mint(self, keyset_id: str = "", force_old_keysets=False) -> None: + """ + Loads the public keys of the mint. Either gets the keys for the specified + `keyset_id` or gets the keys of the active keyset from the mint. + Gets the active keyset ids of the mint and stores in `self.mint_keyset_ids`. + + Args: + keyset_id (str, optional): Keyset id to load. Defaults to "". + force_old_keysets (bool, optional): If true, old deprecated base64 keysets are not ignored. This is necessary for restoring tokens from old base64 keysets. + Defaults to False. + """ + logger.trace(f"Loading mint {self.url}") + try: + await self.load_mint_keysets(force_old_keysets) + await self.activate_keyset(keyset_id) + await self.load_mint_info(reload=True) + except Exception as e: + logger.error(f"Could not load mint info: {e}") + pass + + async def load_proofs(self, reload: bool = False, all_keysets=False) -> None: + """Load all proofs of the selected mint and unit (i.e. self.keysets) into memory.""" + + if self.proofs and not reload: + logger.debug("Proofs already loaded.") + return + + self.proofs = [] + await self.load_keysets_from_db() + async with self.db.connect() as conn: + if all_keysets: + proofs = await get_proofs(db=self.db, conn=conn) + self.proofs.extend(proofs) + else: + for keyset_id in self.keysets: + proofs = await get_proofs(db=self.db, id=keyset_id, conn=conn) + self.proofs.extend(proofs) + keysets_str = " ".join([f"{k.id} ({k.unit})" for k in self.keysets.values()]) + logger.trace(f"Proofs loaded for keysets: {keysets_str}") + + async def load_keysets_from_db( + self, url: Union[str, None] = "", unit: Union[str, None] = "" + ): + """Load all keysets of the selected mint and unit from the database into self.keysets.""" + # so that the caller can set unit = None, otherwise use defaults + if unit == "": + unit = self.unit.name + if url == "": + url = self.url + keysets = await get_keysets(mint_url=url, unit=unit, db=self.db) + for keyset in keysets: + self.keysets[keyset.id] = keyset + logger.trace( + f"Loaded keysets from db: {[(k.id, k.unit.name, k.input_fee_ppk) for k in self.keysets.values()]}" + ) + + async def _check_used_secrets(self, secrets): + """Checks if any of the secrets have already been used""" + logger.trace("Checking secrets.") + async with self.db.get_connection() as conn: + for s in secrets: + if await secret_used(s, db=self.db, conn=conn): + raise Exception(f"secret already used: {s}") + logger.trace("Secret check complete.") + + async def request_mint_with_callback( + self, + amount: int, + callback: Callable, + memo: Optional[str] = None, + ) -> Tuple[MintQuote, SubscriptionManager]: + """Request a quote invoice for minting tokens. + + Args: + amount (int): Amount for Lightning invoice in satoshis + callback (Callable): Callback function to be called when the invoice is paid. + memo (Optional[str], optional): Memo for the Lightning invoice. Defaults + + Returns: + MintQuote: Mint Quote + """ + # generate a key for signing the quote request + privkey_hex, pubkey_hex = nut20.generate_keypair() + mint_quote = await super().mint_quote(amount, self.unit, memo, pubkey_hex) + subscriptions = SubscriptionManager(self.url) + threading.Thread( + target=subscriptions.connect, name="SubscriptionManager", daemon=True + ).start() + subscriptions.subscribe( + kind=JSONRPCSubscriptionKinds.BOLT11_MINT_QUOTE, + filters=[mint_quote.quote], + callback=callback, + ) + quote = MintQuote.from_resp_wallet(mint_quote, self.url, amount, self.unit.name) + + # store the private key in the quote + quote.privkey = privkey_hex + await store_bolt11_mint_quote(db=self.db, quote=quote) + + return quote, subscriptions + + async def request_mint( + self, + amount: int, + memo: Optional[str] = None, + ) -> MintQuote: + """Request a quote invoice for minting tokens. + + Args: + amount (int): Amount for Lightning invoice in satoshis + callback (Optional[Callable], optional): Callback function to be called when the invoice is paid. Defaults to None. + memo (Optional[str], optional): Memo for the Lightning invoice. Defaults to None. + keypair (Optional[Tuple[str, str], optional]): NUT-19 private public ephemeral keypair. Defaults to None. + + Returns: + MintQuote: Mint Quote + """ + # generate a key for signing the quote request + privkey_hex, pubkey_hex = nut20.generate_keypair() + + mint_quote_response = await super().mint_quote( + amount, self.unit, memo, pubkey_hex + ) + quote = MintQuote.from_resp_wallet( + mint_quote_response, self.url, amount, self.unit.name + ) + + quote.privkey = privkey_hex + await store_bolt11_mint_quote(db=self.db, quote=quote) + return quote + + async def get_mint_quote( + self, + quote_id: str, + ) -> MintQuote: + """Get a mint quote from mint. + + Args: + quote_id (str): Id of the mint quote. + + Returns: + MintQuote: Mint quote. + """ + mint_quote_response = await super().get_mint_quote(quote_id) + mint_quote_local = await get_bolt11_mint_quote(db=self.db, quote=quote_id) + mint_quote = MintQuote.from_resp_wallet( + mint_quote_response, + mint=self.url, + amount=( + mint_quote_response.amount or mint_quote_local.amount + if mint_quote_local + else 0 # BACKWARD COMPATIBILITY mint response < 0.17.0 + ), + unit=( + mint_quote_response.unit or mint_quote_local.unit + if mint_quote_local + else self.unit.name # BACKWARD COMPATIBILITY mint response < 0.17.0 + ), + ) + if mint_quote_local and mint_quote_local.privkey: + mint_quote.privkey = mint_quote_local.privkey + + if not mint_quote_local: + await store_bolt11_mint_quote(db=self.db, quote=mint_quote) + + return mint_quote + + async def mint( + self, + amount: int, + quote_id: str, + split: Optional[List[int]] = None, + ) -> List[Proof]: + """Mint tokens of a specific amount after an invoice has been paid. + + Args: + amount (int): Total amount of tokens to be minted + quote_id (str): Id for looking up the paid Lightning invoice. + split (Optional[List[str]], optional): List of desired amount splits to be minted. Total must sum to `amount`. + + Raises: + Exception: Raises exception if `amounts` does not sum to `amount` or has unsupported value. + Exception: Raises exception if no proofs have been provided + + Returns: + List[Proof]: Newly minted proofs. + """ + # specific split + if split: + logger.trace(f"Mint with split: {split}") + assert sum(split) == amount, "split must sum to amount" + allowed_amounts = ( + self.get_allowed_amounts() + ) # Get allowed amounts from the mint + for a in split: + if a not in allowed_amounts: + raise Exception( + f"Can only mint amounts supported by the mint: {allowed_amounts}" + ) + + # split based on our wallet state + amounts = split or self.split_wallet_state(amount) + + # quirk: we skip bumping the secret counter in the database since we are + # not sure if the minting will succeed. If it succeeds, we will bump it + # in the next step. + secrets, rs, derivation_paths = await self.generate_n_secrets( + len(amounts), skip_bump=True + ) + await self._check_used_secrets(secrets) + outputs, rs = self._construct_outputs(amounts, secrets, rs) + + quote = await get_bolt11_mint_quote(db=self.db, quote=quote_id) # type: ignore + if not quote: + raise Exception("Quote not found.") + signature: str | None = None + if quote.privkey: + signature = nut20.sign_mint_quote(quote_id, outputs, quote.privkey) + + # will raise exception if mint is unsuccessful + promises = await super().mint(outputs, quote_id, signature) + + promises_keyset_id = promises[0].id + await bump_secret_derivation( + db=self.db, keyset_id=promises_keyset_id, by=len(amounts) + ) + proofs = await self._construct_proofs(promises, secrets, rs, derivation_paths) + + await update_bolt11_mint_quote( + db=self.db, + quote=quote_id, + state=MintQuoteState.issued, + paid_time=int(time.time()), + ) + # store the mint_id in proofs + async with self.db.connect() as conn: + for p in proofs: + p.mint_id = quote_id + await update_proof(p, mint_id=quote_id, conn=conn) + return proofs + + async def redeem( + self, + proofs: List[Proof], + ) -> Tuple[List[Proof], List[Proof]]: + """Redeem proofs by sending them to yourself by calling a split. + Args: + proofs (List[Proof]): Proofs to be redeemed. + """ + # verify DLEQ of incoming proofs + self.verify_proofs_dleq(proofs) + self.verify_proofs_bls(proofs) + return await self.split(proofs=proofs, amount=0) + + async def split( + self, + proofs: List[Proof], + amount: int, + secret_lock: Optional[Secret] = None, + include_fees: bool = False, + ) -> 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. + + If secret_lock is None, random secrets will be generated for the tokens to keep (keep_outputs) + and the promises to send (send_outputs). If secret_lock is provided, the wallet will create + blinded secrets with those to attach a predefined spending condition to the tokens they want to send. + + Calls `sign_proofs_inplace_swap` which parses all proofs and checks whether their + secrets corresponds to any locks that we have the unlock conditions for. If so, + it adds the unlock conditions to the proofs. + + Args: + proofs (List[Proof]): Proofs to be split. + amount (int): Amount to be sent. + secret_lock (Optional[Secret], optional): Secret to lock the tokens to be sent. Defaults to None. + include_fees (bool, optional): If True, the fees are included in the amount to send (output of + this method, to be sent in the future). This is not the fee that is required to swap the + `proofs` (input to this method) which must already be included. Defaults to False. + + Returns: + Tuple[List[Proof], List[Proof]]: Two lists of proofs, one for keeping and one for sending. + """ + assert len(proofs) > 0, "no proofs provided." + assert sum_proofs(proofs) >= amount, "amount too large." + assert amount >= 0, "amount can't be negative." + # make sure we're operating on an independent copy of proofs + proofs = copy.copy(proofs) + + input_fees = self.get_fees_for_proofs(proofs) + logger.trace(f"Input fees: {input_fees}") + # create a suitable amounts to keep and send. + keep_outputs, send_outputs = self.determine_output_amounts( + proofs, + amount, + include_fees=include_fees, + keyset_id_outputs=self.keyset_id, + ) + + amounts = keep_outputs + send_outputs + + # generate secrets for new outputs + if secret_lock is None: + secrets, rs, derivation_paths = await self.generate_n_secrets(len(amounts)) + else: + secrets, rs, derivation_paths = await self.generate_locked_secrets( + send_outputs, keep_outputs, secret_lock + ) + + assert len(secrets) == len( + amounts + ), "number of secrets does not match number of outputs" + # verify that we didn't accidentally reuse a secret + await self._check_used_secrets(secrets) + + # construct outputs + outputs, rs = self._construct_outputs(amounts, secrets, rs, self.keyset_id) + + # potentially add witnesses to outputs based on what requirement the proofs indicate + proofs = self.sign_proofs_inplace_swap(proofs, outputs) + + # sort outputs by amount, remember original order + sorted_outputs_with_indices = sorted( + enumerate(outputs), key=lambda p: p[1].amount + ) + original_indices, sorted_outputs = zip(*sorted_outputs_with_indices) + + # Call swap API + sorted_promises = await super().split(proofs, list(sorted_outputs)) + + # sort promises back to original order + promises = [ + promise + for _, promise in sorted( + zip(original_indices, sorted_promises), key=lambda x: x[0] + ) + ] + + # Construct proofs from returned promises (i.e., unblind the signatures) + new_proofs = await self._construct_proofs( + promises, secrets, rs, derivation_paths + ) + + await self.invalidate(proofs) + + keep_proofs = new_proofs[: len(keep_outputs)] + send_proofs = new_proofs[len(keep_outputs) :] + return keep_proofs, send_proofs + + async def melt_quote( + self, invoice: str, amount_msat: Optional[int] = None + ) -> MeltQuote: + """ + Fetches a melt quote from the mint and either uses the amount in the invoice or the amount provided. + """ + if amount_msat and not self.mint_info.supports_mpp("bolt11", self.unit): + raise Exception("Mint does not support MPP, cannot specify amount.") + melt_quote_resp = await super().melt_quote(invoice, self.unit, amount_msat) + logger.debug( + f"Mint wants {self.unit.str(melt_quote_resp.fee_reserve)} as fee reserve." + ) + melt_quote = MeltQuote.from_resp_wallet( + melt_quote_resp, + self.url, + unit=self.unit.name, + request=invoice, + ) + await store_bolt11_melt_quote(db=self.db, quote=melt_quote) + melt_quote = MeltQuote.from_resp_wallet( + melt_quote_resp, + self.url, + unit=melt_quote_resp.unit + or self.unit.name, # BACKWARD COMPATIBILITY mint response < 0.17.0 + request=melt_quote_resp.request + or invoice, # BACKWARD COMPATIBILITY mint response < 0.17.0 + ) + return melt_quote + + async def get_melt_quote(self, quote: str) -> Optional[MeltQuote]: + """Fetches a melt quote from the mint and updates proofs in the database. + + Args: + quote (str): Quote ID to fetch. + + Returns: + Optional[MeltQuote]: MeltQuote object. + """ + melt_quote_resp = await super().get_melt_quote(quote) + melt_quote_local = await get_bolt11_melt_quote(db=self.db, quote=quote) + melt_quote = MeltQuote.from_resp_wallet( + melt_quote_resp, + self.url, + unit=( + melt_quote_resp.unit or melt_quote_local.unit + if melt_quote_local + else self.unit.name # BACKWARD COMPATIBILITY mint response < 0.17.0 + ), + request=( + melt_quote_resp.request or melt_quote_local.request + if (melt_quote_local and melt_quote_local.request) + else "None" # BACKWARD COMPATIBILITY mint response < 0.17.0 + ), + ) + + # update database + if not melt_quote_local: + await store_bolt11_melt_quote(db=self.db, quote=melt_quote) + else: + proofs = await get_proofs(db=self.db, melt_id=quote) + if ( + melt_quote.state == MeltQuoteState.paid + and melt_quote_local.state != MeltQuoteState.paid + ): + logger.debug("Updating paid status of melt quote.") + await update_bolt11_melt_quote( + db=self.db, + quote=quote, + state=melt_quote.state, + paid_time=int(time.time()), + payment_preimage=melt_quote.payment_preimage or "", + fee_paid=melt_quote.fee_paid, + ) + # invalidate proofs + if sum_proofs(proofs) == melt_quote.amount + melt_quote.fee_reserve: + await self.invalidate(proofs) + + if melt_quote.change: + logger.warning( + "Melt quote contains change but change is not supported yet." + ) + + if melt_quote.state == MeltQuoteState.unpaid: + logger.debug("Updating unpaid status of melt quote.") + await self.set_reserved_for_melt(proofs, reserved=False, quote_id=None) + return melt_quote + + async def melt( + self, proofs: List[Proof], invoice: str, fee_reserve_sat: int, quote_id: str + ) -> PostMeltQuoteResponse: + """Pays a lightning invoice and returns the status of the payment. + + Args: + proofs (List[Proof]): List of proofs to be spent. + invoice (str): Lightning invoice to be paid. + fee_reserve_sat (int): Amount of fees to be reserved for the payment. + + """ + + # Make sure we're operating on an independent copy of proofs + proofs = copy.copy(proofs) + + # Generate a number of blank outputs for any overpaid fees. As described in + # NUT-08, the mint will imprint these outputs with a value depending on the + # amount of fees we overpaid. + n_change_outputs = calculate_number_of_blank_outputs(fee_reserve_sat) + ( + change_secrets, + change_rs, + change_derivation_paths, + ) = await self.generate_n_secrets(n_change_outputs) + change_outputs, change_rs = self._construct_outputs( + n_change_outputs * [1], change_secrets, change_rs + ) + + await self.set_reserved_for_melt(proofs, reserved=True, quote_id=quote_id) + proofs = self.sign_proofs_inplace_melt(proofs, change_outputs, quote_id) + try: + melt_quote_resp = await super().melt(quote_id, proofs, change_outputs) + except Exception as e: + logger.debug(f"Mint error: {e}") + # remove the melt_id in proofs and set reserved to False + await self.set_reserved_for_melt(proofs, reserved=False, quote_id=None) + raise Exception(f"could not pay invoice: {e}") + + melt_quote = MeltQuote.from_resp_wallet( + melt_quote_resp, + self.url, + unit=self.unit.name, + request=invoice, + ) + # if payment fails + if melt_quote.state == MeltQuoteState.unpaid: + # remove the melt_id in proofs and set reserved to False + await self.set_reserved_for_melt(proofs, reserved=False, quote_id=None) + raise Exception("could not pay invoice.") + elif melt_quote.state == MeltQuoteState.pending: + # payment is still pending + logger.debug("Payment is still pending.") + return melt_quote_resp + + # invoice was paid successfully + await self.invalidate(proofs) + + # update paid status in db + logger.trace(f"Settings invoice {quote_id} to paid.") + logger.trace(f"Quote: {melt_quote_resp}") + fee_paid = melt_quote.amount + melt_quote.fee_paid + if melt_quote.change: + fee_paid -= sum_promises(melt_quote.change) + + await update_bolt11_melt_quote( + db=self.db, + quote=quote_id, + state=MeltQuoteState.paid, + paid_time=int(time.time()), + payment_preimage=melt_quote.payment_preimage or "", + fee_paid=fee_paid, + ) + + # handle change and produce proofs + if melt_quote.change: + change_proofs = await self._construct_proofs( + melt_quote.change, + change_secrets[: len(melt_quote.change)], + change_rs[: len(melt_quote.change)], + change_derivation_paths[: len(melt_quote.change)], + ) + logger.debug(f"Received change: {self.unit.str(sum_proofs(change_proofs))}") + return melt_quote_resp + + async def check_proof_state(self, proofs) -> PostCheckStateResponse: + return await super().check_proof_state(proofs) + + async def check_proof_state_with_callback( + self, proofs: List[Proof], callback: Callable + ) -> Tuple[PostCheckStateResponse, SubscriptionManager]: + subscriptions = SubscriptionManager(self.url) + threading.Thread( + target=subscriptions.connect, name="SubscriptionManager", daemon=True + ).start() + subscriptions.subscribe( + kind=JSONRPCSubscriptionKinds.PROOF_STATE, + filters=[proof.Y for proof in proofs], + callback=callback, + ) + return await self.check_proof_state(proofs), subscriptions + + # ---------- TOKEN MECHANICS ---------- + + # ---------- DLEQ PROOFS ---------- + + def verify_proofs_dleq(self, proofs: List[Proof]): + """Verifies DLEQ proofs in proofs.""" + verified_count = 0 + for proof in proofs: + if not proof.dleq: + logger.trace("No DLEQ proof in proof.") + continue + if is_bls_keyset(proof.id): + logger.trace("BLS keyset, skipping DLEQ proof verification.") + continue + logger.trace("Verifying DLEQ proof.") + assert proof.id + assert ( + proof.id in self.keysets + ), f"Keyset {proof.id} not known, can not verify DLEQ." + if not b_dhke.carol_verify_dleq( + secret_msg=proof.secret, + C=SecpPublicKey(bytes.fromhex(proof.C)), + r=SecpPrivateKey(bytes.fromhex(proof.dleq.r)), + e=SecpPrivateKey(bytes.fromhex(proof.dleq.e)), + s=SecpPrivateKey(bytes.fromhex(proof.dleq.s)), + A=cast(Any, self.keysets[proof.id].public_keys[proof.amount]), + ): + raise Exception("DLEQ proof invalid.") + else: + logger.trace("DLEQ proof valid.") + verified_count += 1 + if verified_count > 0: + logger.debug(f"Verified {verified_count} incoming DLEQ proofs.") + + def verify_proofs_bls(self, proofs: List[Proof]): + """Verifies BLS signatures using pairings.""" + + bls_proofs = [p for p in proofs if is_bls_keyset(p.id)] + if not bls_proofs: + return + + logger.trace(f"Verifying {len(bls_proofs)} BLS signatures using pairings.") + + K2s: List[BlsPublicKey] = [] + Cs: List[BlsPublicKey] = [] + secret_msgs: List[str] = [] + + for proof in bls_proofs: + assert proof.id + assert proof.id in self.keysets, f"Keyset {proof.id} not known." + K2s.append(self.keysets[proof.id].public_keys[proof.amount]) # type: ignore + Cs.append(BlsPublicKey(bytes.fromhex(proof.C))) + secret_msgs.append(proof.secret) + + valid = bls_dhke.batch_pairing_verification(K2s, Cs, secret_msgs) # type: ignore + if not valid: + raise Exception("BLS signature verification failed.") + + logger.debug(f"Verified {len(bls_proofs)} incoming BLS signatures using pairings.") + + async def _construct_proofs( + self, + promises: Sequence[BlindedSignature], + secrets: Sequence[str], + rs: Sequence[Union[SecpPrivateKey, BlsPrivateKey]], + derivation_paths: Sequence[str], + ) -> List[Proof]: + + """Constructs proofs from promises, secrets, rs and derivation paths. + + This method is called after the user has received blind signatures from + the mint. The results are proofs that can be used as ecash. + + Args: + promises (List[BlindedSignature]): blind signatures from mint + secrets (List[str]): secrets that were previously used to create blind messages (that turned into promises) + rs (List[PrivateKey]): blinding factors that were previously used to create blind messages (that turned into promises) + derivation_paths (List[str]): derivation paths that were used to generate secrets and blinding factors + + Returns: + List[Proof]: list of proofs that can be used as ecash + """ + + logger.trace("Constructing proofs.") + proofs: List[Proof] = [] + for promise, secret, r, path in zip(promises, secrets, rs, derivation_paths): + 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 + await self.load_mint_keysets() + assert promise.id in self.keysets, "Could not load keyset." + + is_v3 = is_bls_keyset(promise.id) + if is_v3: + C_ = cast(PublicKey, BlsPublicKey(bytes.fromhex(promise.C_))) + C = bls_dhke.step3_alice( # type: ignore + cast(Any, C_), + cast(Any, r), + cast(Any, self.keysets[promise.id].public_keys[promise.amount]), + ) + else: + C_ = cast(PublicKey, SecpPublicKey(bytes.fromhex(promise.C_))) + C = b_dhke.step3_alice( # type: ignore + cast(Any, C_), + cast(Any, r), + cast(Any, self.keysets[promise.id].public_keys[promise.amount]), + ) + + if is_v3: + B_, r = bls_dhke.step1_alice(cast(Any, secret), cast(Any, r)) + elif not settings.wallet_use_deprecated_h2c: + B_, r = cast(Any, b_dhke.step1_alice(cast(Any, secret), cast(Any, r))) # recompute B_ for dleq proofs + # BEGIN: BACKWARDS COMPATIBILITY < 0.15.1 + else: + B_, r = cast(Any, b_dhke.step1_alice_deprecated( + cast(Any, secret), cast(Any, r) + )) # recompute B_ for dleq proofs + # END: BACKWARDS COMPATIBILITY < 0.15.1 + + proof = Proof( + id=promise.id, + amount=promise.amount, + C=C.format().hex(), + secret=secret, + derivation_path=path, + ) + + # if the mint returned a dleq proof, we add it to the proof + if promise.dleq: + proof.dleq = DLEQWallet( + e=promise.dleq.e, s=promise.dleq.s, r=r.to_hex() + ) + + proofs.append(proof) + + logger.trace( + f"Created proof: {proof}, r: {r.to_hex()} out of promise {promise}" + ) + + # DLEQ verify + self.verify_proofs_dleq(proofs) + self.verify_proofs_bls(proofs) + + logger.trace(f"Constructed {len(proofs)} proofs.") + + # add new proofs to wallet + self.proofs += copy.copy(proofs) + # store new proofs in database + await self._store_proofs(proofs) + + return proofs + + def _construct_outputs( + self, + amounts: Sequence[int], + secrets: Sequence[str], + rs: Sequence[Union[SecpPrivateKey, BlsPrivateKey]] = (), + keyset_id: Optional[str] = None, + ) -> Tuple[List[BlindedMessage], List[Union[SecpPrivateKey, BlsPrivateKey]]]: + """Takes a list of amounts and secrets and returns outputs. + Outputs are blinded messages `outputs` and blinding factors `rs` + + Args: + amounts (List[int]): list of amounts + secrets (List[str]): list of secrets + rs (list[PrivateKey], optional): list of blinding factors. If not given, `rs` are generated in step1_alice. Defaults to []. + + Returns: + List[BlindedMessage]: list of blinded messages that can be sent to the mint + List[PrivateKey]: list of blinding factors that can be used to construct proofs after receiving blind signatures from the mint + + Raises: + AssertionError: if len(amounts) != len(secrets) + """ + assert len(amounts) == len( + secrets + ), f"len(amounts)={len(amounts)} not equal to len(secrets)={len(secrets)}" + keyset_id = keyset_id or self.keyset_id + outputs: List[BlindedMessage] = [] + rs_ = [None] * len(amounts) if not rs else rs + rs_return: List[Union[SecpPrivateKey, BlsPrivateKey]] = [] + + + for secret, amount, r in zip(secrets, amounts, rs_): + is_v3 = is_bls_keyset(keyset_id) + if is_v3: + B_, r = bls_dhke.step1_alice(cast(Any, secret), cast(Any, r or None)) + elif not settings.wallet_use_deprecated_h2c: + B_, r = b_dhke.step1_alice(secret, cast(PrivateKey, r)) # type: ignore + # BEGIN: BACKWARDS COMPATIBILITY < 0.15.1 + else: + B_, r = b_dhke.step1_alice_deprecated(secret, cast(PrivateKey, r)) # type: ignore + # END: BACKWARDS COMPATIBILITY < 0.15.1 + + assert r + rs_return.append(r) + output = BlindedMessage( + amount=amount, B_=B_.format().hex(), id=keyset_id + ) + outputs.append(output) + logger.trace(f"Constructing output: {output}, r: {r.to_hex()}") + + return outputs, rs_return + + async def construct_outputs(self, amounts: List[int]) -> List[BlindedMessage]: + """Constructs outputs for a list of amounts. + + Args: + amounts (List[int]): List of amounts to construct outputs for. + + Returns: + List[BlindedMessage]: List of blinded messages that can be sent to the mint. + """ + secrets, rs, _ = await self.generate_n_secrets(len(amounts)) + return self._construct_outputs(amounts, secrets, rs)[0] + + async def _store_proofs(self, proofs): + try: + async with self.db.connect() as conn: + for proof in proofs: + await store_proof(proof, db=self.db, conn=conn) + except Exception as e: + logger.error(f"Could not store proofs in database: {e}") + logger.error(proofs) + raise e + + async def get_spent_proofs_check_states_batched( + self, proofs: List[Proof] + ) -> List[Proof]: + """Checks the state of proofs in batches. + + Args: + proofs (List[Proof]): List of proofs to check. + + Returns: + List[Proof]: List of proofs that are spent. + """ + batch_size = settings.proofs_batch_size + spent_proofs = [] + for i in range(0, len(proofs), batch_size): + batch = proofs[i : i + batch_size] + proof_states = await self.check_proof_state(batch) + for j, state in enumerate(proof_states.states): + if state.spent: + spent_proofs.append(batch[j]) + return spent_proofs + + async def invalidate( + self, proofs: List[Proof], check_spendable=False + ) -> List[Proof]: + """Invalidates all unspendable tokens supplied in proofs. + + Args: + proofs (List[Proof]): Which proofs to delete + check_spendable (bool, optional): Asks the mint to check whether proofs are already spent before deleting them. Defaults to False. + + Returns: + List[Proof]: List of proofs that are still spendable. + """ + invalidated_proofs: List[Proof] = [] + if check_spendable: + invalidated_proofs = await self.get_spent_proofs_check_states_batched( + proofs + ) + else: + invalidated_proofs = proofs + + if invalidated_proofs: + logger.trace( + f"Invalidating {len(invalidated_proofs)} proofs worth" + f" {self.unit.str(sum_proofs(invalidated_proofs))}." + ) + + for p in invalidated_proofs: + try: + # mark proof as spent + await invalidate_proof(p, db=self.db) + except Exception as e: + logger.error(f"DB error while invalidating proof: {e}") + + invalidate_secrets = [p.secret for p in invalidated_proofs] + self.proofs = list( + filter(lambda p: p.secret not in invalidate_secrets, self.proofs) + ) + return [p for p in proofs if p not in invalidated_proofs] + + # ---------- TRANSACTION HELPERS ---------- + + async def select_to_send( + self, + proofs: List[Proof], + amount: int, + *, + set_reserved: bool = False, + offline: bool = False, + include_fees: bool = False, + ) -> Tuple[List[Proof], int]: + """ + Selects proofs such that a desired `amount` can be sent. If the offline coin selection is unsuccessful, + and `offline` is set to False (default), we split the available proofs with the mint to get the desired `amount`. + + If `set_reserved` is set to True, the proofs are marked as reserved so they aren't used in other transactions. + + If `include_fees` is set to True, the selection includes the swap fees to receive the selected proofs. + + Args: + proofs (List[Proof]): Proofs to split + amount (int): Amount to split to + set_reserved (bool, optional): If set, the proofs are marked as reserved. Defaults to False. + offline (bool, optional): If set, the coin selection is done offline. Defaults to False. + include_fees (bool, optional): If set, the fees for spending the proofs later are included in the + amount to be selected. Defaults to False. + + Returns: + List[Proof]: Proofs to send + int: Fees for the transaction + """ + # select proofs that are not reserved and are in the active keysets of the mint + proofs = self.active_proofs(proofs) + if sum_proofs(proofs) < amount: + raise BalanceTooLowError() + + # coin selection for potentially offline sending + send_proofs = self.coinselect(proofs, amount, include_fees=include_fees) + fees = self.get_fees_for_proofs(send_proofs) + logger.trace( + f"select_to_send: selected: {self.unit.str(sum_proofs(send_proofs))} (+ {self.unit.str(fees)} fees) – wanted: {self.unit.str(amount)}" + ) + # offline coin selection unsuccessful, we need to swap proofs before we can send + if not send_proofs or sum_proofs(send_proofs) > amount + fees: + if not offline: + logger.debug("Offline coin selection unsuccessful. Splitting proofs.") + # we set the proofs as reserved later + _, send_proofs = await self.swap_to_send( + proofs, + amount, + set_reserved=False, + include_fees=include_fees, + ) + else: + raise Exception( + "Could not select proofs in offline mode. Available amounts:" + + amount_summary(proofs, self.unit) + ) + if set_reserved: + await self.set_reserved_for_send(send_proofs, reserved=True) + return send_proofs, fees + + async def swap_to_send( + self, + proofs: List[Proof], + amount: int, + *, + secret_lock: Optional[Secret] = None, + set_reserved: bool = False, + include_fees: bool = False, + ) -> 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 + proofs are returned to be kept. All newly created proofs will be stored in the database but if `set_reserved` is set + to True, the proofs to be sent (which sum up to `amount`) will be marked as reserved so they aren't used in other + transactions. + + Args: + proofs (List[Proof]): Proofs to split + amount (int): Amount to split to + secret_lock (Optional[str], optional): If set, a custom secret is used to lock new outputs. Defaults to None. + 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. + include_fees (bool, optional): If set, the fees for spending the send_proofs later are included in the amount to be selected. Defaults to True. + + Returns: + Tuple[List[Proof], List[Proof]]: Tuple of proofs to keep and proofs to send + """ + # select proofs that are not reserved and are in the active keysets of the mint + proofs = self.active_proofs(proofs) + if sum_proofs(proofs) < amount: + raise BalanceTooLowError() + + # coin selection for swapping, needs to include fees + swap_proofs = self.coinselect(proofs, amount, include_fees=True) + + # Extra rule: add proofs from inactive keysets to swap_proofs to get rid of them + swap_proofs += [ + p + for p in proofs + if not self.keysets[p.id].active and not p.reserved and p not in swap_proofs + ] + + fees = self.get_fees_for_proofs(swap_proofs) + 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, include_fees=include_fees + ) + if set_reserved: + await self.set_reserved_for_send(send_proofs, reserved=True) + return keep_proofs, send_proofs + + # ---------- BALANCE CHECKS ---------- + + @property + def balance(self) -> Amount: + return Amount(self.unit, sum_proofs(self.proofs)) + + @property + def available_balance(self) -> Amount: + return Amount(self.unit, sum_proofs([p for p in self.proofs if not p.reserved])) + + @property + def proof_amounts(self): + """Returns a sorted list of amounts of all proofs""" + return [p.amount for p in sorted(self.proofs, key=lambda p: p.amount)] + + def active_proofs(self, proofs: List[Proof]): + """Returns a list of proofs that + - have an id that is in the current `self.keysets` which have the unit in `self.unit` + - are not reserved + """ + + def is_active_proof(p: Proof) -> bool: + return ( + p.id in self.keysets + and self.keysets[p.id].unit == self.unit + and not p.reserved + ) + + return [p for p in proofs if is_active_proof(p)] + + def balance_per_keyset(self) -> Dict[str, Dict[str, Union[int, str]]]: + ret: Dict[str, Dict[str, Union[int, str]]] = { + key: { + "balance": sum_proofs(proofs), + "available": sum_proofs([p for p in proofs if not p.reserved]), + } + for key, proofs in self._get_proofs_per_keyset(self.proofs).items() + } + for key in ret.keys(): + if key in self.keysets: + ret[key]["unit"] = self.keysets[key].unit.name + return ret + + def balance_per_unit(self) -> Dict[Unit, Dict[str, Union[int, str]]]: + ret: Dict[Unit, Dict[str, Union[int, str]]] = { + unit: { + "balance": sum_proofs(proofs), + "available": sum_proofs([p for p in proofs if not p.reserved]), + } + for unit, proofs in self._get_proofs_per_unit(self.proofs).items() + } + return ret + + async def balance_per_minturl( + self, unit: Optional[Unit] = None + ) -> Dict[str, Dict[str, Union[int, str]]]: + balances = await self._get_proofs_per_minturl(self.proofs, unit=unit) + balances_return: Dict[str, Dict[str, Union[int, str]]] = { + key: { + "balance": sum_proofs(proofs), + "available": sum_proofs([p for p in proofs if not p.reserved]), + } + for key, proofs in balances.items() + } + for key in balances_return.keys(): + if unit: + balances_return[key]["unit"] = unit.name + return dict(sorted(balances_return.items(), key=lambda item: item[0])) # type: ignore + + # ---------- RESTORE WALLET ---------- + + async def restore_tokens_for_keyset( + self, keyset_id: str, to: int = 2, batch: int = 25 + ) -> None: + """ + Restores tokens for a given keyset_id. + + Args: + keyset_id (str): The keyset_id to restore tokens for. + to (int, optional): The number of consecutive empty responses to stop restoring. Defaults to 2. + batch (int, optional): The number of proofs to restore in one batch. Defaults to 25. + """ + empty_batches = 0 + # we get the current secret counter and restore from there on + spendable_proofs = [] + counter_before = await bump_secret_derivation( + db=self.db, keyset_id=keyset_id, by=0 + ) + if counter_before != 0: + print("Keyset has already been used. Restoring from its last state.") + i = counter_before + last_restore_count = 0 + while empty_batches < to: + print(f"Restoring counter {i} to {i + batch} for keyset {keyset_id} ...") + ( + next_restored_output_index, + restored_proofs, + ) = await self.restore_promises_from_to(keyset_id, i, i + batch - 1) + last_restore_count += next_restored_output_index + i += batch + if len(restored_proofs) == 0: + empty_batches += 1 + continue + spendable_proofs = await self.invalidate( + restored_proofs, check_spendable=True + ) + if len(spendable_proofs): + print( + f"Restored {sum_proofs(spendable_proofs)} sat for keyset {keyset_id}." + ) + else: + logger.debug( + f"None of the {len(restored_proofs)} restored proofs are spendable." + ) + + # restore the secret counter to its previous value for the last round + revert_counter_by = i - last_restore_count + logger.debug(f"Reverting secret counter by {revert_counter_by}") + before = await bump_secret_derivation( + db=self.db, + keyset_id=keyset_id, + by=-revert_counter_by, + ) + logger.debug( + f"Secret counter reverted from {before} to {before - revert_counter_by}" + ) + if last_restore_count == 0: + print(f"No tokens restored for keyset {keyset_id}.") + return + + async def restore_wallet_from_mnemonic( + self, mnemonic: Optional[str], to: int = 2, batch: int = 25 + ) -> None: + """ + Restores the wallet from a mnemonic. + + Args: + mnemonic (Optional[str]): The mnemonic to restore the wallet from. If None, the mnemonic is loaded from the db. + to (int, optional): The number of consecutive empty responses to stop restoring. Defaults to 2. + batch (int, optional): The number of proofs to restore in one batch. Defaults to 25. + """ + await self._init_private_key(mnemonic) + await self.load_mint(force_old_keysets=False) + print("Restoring tokens...") + for keyset_id in self.keysets.keys(): + await self.restore_tokens_for_keyset(keyset_id, to, batch) + + async def restore_promises_from_to( + self, keyset_id: str, from_counter: int, to_counter: int + ) -> Tuple[int, List[Proof]]: + """Restores promises from a given range of counters. This is for restoring a wallet from a mnemonic. + + Args: + from_counter (int): Counter for the secret derivation to start from + to_counter (int): Counter for the secret derivation to end at + + Returns: + Tuple[int, List[Proof]]: Index of the last restored output and list of restored proofs + """ + # we regenerate the secrets and rs for the given range + secrets, rs, derivation_paths = await self.generate_secrets_from_to( + from_counter, to_counter, keyset_id=keyset_id + ) + # we don't know the amount but luckily the mint will tell us so we use a dummy amount here + amounts_dummy = [1] * len(secrets) + # we generate outputs from deterministic secrets and rs + regenerated_outputs, _ = self._construct_outputs( + amounts_dummy, secrets, rs, keyset_id=keyset_id + ) + # we ask the mint to reissue the promises + next_restored_output_index, proofs = await self.restore_promises( + outputs=regenerated_outputs, + secrets=secrets, + rs=rs, + derivation_paths=derivation_paths, + ) + + await set_secret_derivation( + db=self.db, keyset_id=keyset_id, counter=to_counter + 1 + ) + return next_restored_output_index, proofs + + async def restore_promises( + self, + outputs: Sequence[BlindedMessage], + secrets: Sequence[str], + rs: Sequence[ICashuPrivateKey], + derivation_paths: Sequence[str], + ) -> Tuple[int, List[Proof]]: + """Restores proofs from a list of outputs, secrets, rs and derivation paths. + + Args: + outputs (List[BlindedMessage]): Outputs for which we request promises + secrets (List[str]): Secrets generated for the outputs + rs (List[PrivateKey]): Random blinding factors generated for the outputs + derivation_paths (List[str]): Derivation paths used for the secrets necessary to unblind the promises + + Returns: + Tuple[int, List[Proof]]: Index of the last restored output and list of restored proofs + """ + # restored_outputs is there so we can match the promises to the secrets and rs + restored_outputs, restored_promises = await super().restore_promises(outputs) + # determine the index in `outputs` of the last restored output from restored_outputs[-1].B_ + if not restored_outputs: + next_restored_output_index = 0 + else: + next_restored_output_index = ( + next( + ( + idx + for idx, val in enumerate(outputs) + if val.B_ == restored_outputs[-1].B_ + ), + 0, + ) + + 1 + ) + logger.trace(f"Last restored output index: {next_restored_output_index}") + # now we need to filter out the secrets, rs and derivation_paths that had a match + matching_indices = [ + idx + for idx, val in enumerate(outputs) + if val.B_ in [o.B_ for o in restored_outputs] + ] + secrets = [secrets[i] for i in matching_indices] + rs = [rs[i] for i in matching_indices] + derivation_paths = [derivation_paths[i] for i in matching_indices] + logger.debug( + f"Restored {len(restored_promises)} promises. Constructing proofs." + ) + # now we can construct the proofs with the secrets and rs + proofs = await self._construct_proofs( + restored_promises, + secrets, + cast(Sequence[Union[SecpPrivateKey, BlsPrivateKey]], rs), + derivation_paths, + ) + logger.debug(f"Restored {len(restored_promises)} promises") + return next_restored_output_index, proofs diff --git a/docs/refactor_key_hierarchy.md b/docs/refactor_key_hierarchy.md new file mode 100644 index 000000000..0d05a3446 --- /dev/null +++ b/docs/refactor_key_hierarchy.md @@ -0,0 +1,66 @@ +# Plan: Cryptographic Key Type Hierarchy Redesign + +This document outlines the strategy for resolving persistent `mypy` invariance errors by refactoring the cryptographic key types (`PublicKey` and `PrivateKey`) from `Union` types into a formal inheritance-based type hierarchy. + +## 1. Problem Statement +Currently, we represent public/private keys as a `Union` of SECP and BLS types. This leads to `mypy` invariance errors because `List[SecpPublicKey]` is **not** a subtype of `List[Union[SecpPublicKey, BlsPublicKey]]`. While `Union` types solve type *definition* issues, they do not resolve *collection* variance issues, necessitating excessive `type: ignore` comments. + +## 2. Objective +Introduce a common abstract base interface for all cryptographic keys in Nutshell. This allows collections to be typed as `List[BasePublicKey]`, which can safely accept instances of both `SecpPublicKey` and `BlsPublicKey`, removing the need for `type: ignore`. + +## 3. Proposed Hierarchy + +We will introduce `ABC`s (Abstract Base Classes) for Public and Private keys. + +```python +# cashu/core/crypto/interfaces.py + +from abc import ABC, abstractmethod + +class ICashuPublicKey(ABC): + @abstractmethod + def format(self, compressed: bool = True) -> bytes: ... + @abstractmethod + def serialize(self) -> bytes: ... + +class ICashuPrivateKey(ABC): + @abstractmethod + def to_hex(self) -> str: ... +``` + +## 4. Implementation Strategy + +### Phase 1: Define Interfaces +* Create `cashu/core/crypto/interfaces.py` defining `ICashuPublicKey` and `ICashuPrivateKey`. +* Ensure these ABCs define all shared methods currently used across the codebase (`format`, `serialize`, `to_hex`, etc.). + +### Phase 2: Refactor Concrete Implementations +* Update `cashu/core/crypto/secp.py` and `cashu/core/crypto/bls.py` so the concrete classes (`SecpPublicKey`, `BlsPublicKey`, etc.) inherit from the new interfaces. +* *Note:* If the underlying library classes (e.g., `coincurve.PublicKey`) cannot directly inherit from the ABC, use an adapter/wrapper class approach. + +### Phase 3: Update Core Base +* Modify `cashu/core/base.py` to remove `AnyPublicKey` and `AnyPrivateKey` union types. +* Update `PublicKey` and `PrivateKey` to refer to the new ABCs. + +### Phase 4: Systematic Component Refactor +Refactor component-by-component to replace `Union` type signatures with the ABCs: +1. **Ledger/Mint Logic:** Update `mint/ledger.py` and `mint/verification.py`. +2. **Wallet Logic:** Update `wallet/wallet.py` and `wallet/secrets.py`. +3. **Auth/Protocols:** Update `wallet/auth/` and any interfaces. + +### Phase 5: Cleanup +* Perform a global find/replace to remove `# type: ignore` comments that were added to handle the invariance issues. +* Run `make mypy` and fix remaining structural type mismatches. + +## 5. Risks and Mitigations + +| Risk | Mitigation | +| :--- | :--- | +| **Breaking changes in concrete key implementations** | Keep the new ABCs thin. Ensure they only define contract-critical methods; do not force logic into the ABC. | +| **Serialization issues** | Ensure the `format()` and `serialize()` methods in the ABC are strictly implemented by concrete classes to match existing hex/bytes formats. | +| **Performance overhead** | If using wrapper classes, measure overhead. If using direct inheritance/mixin, the overhead should be negligible. | + +## 6. Next Steps +1. Review and approve the proposed interface definition in `interfaces.py`. +2. Begin Phase 2 for one cryptographic implementation (`secp.py`) as a prototype. +3. Evaluate the impact on `mypy` errors before proceeding to Phase 4. diff --git a/poetry.lock b/poetry.lock index 16fcfaaf7..4a5d6b51e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1635,6 +1635,51 @@ files = [ {file = "protobuf-5.29.3.tar.gz", hash = "sha256:5da0f41edaf117bde316404bad1a486cb4ededf8e4a54891296f648e8e076620"}, ] +[[package]] +name = "pyblst" +version = "0.3.15" +description = "Python bindings for blst" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pyblst-0.3.15-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7059022585a394e2aa89f165327c6d440e6bb66beeea1624c3739c62ab7b8881"}, + {file = "pyblst-0.3.15-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8908aadaddbf1d7b816832d5d591115109ae2fcd7ee9763ec40299a3484df6b"}, + {file = "pyblst-0.3.15-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:863ab4e88d45e2e5cba1171638aaf96bd9e5cab4ecf87ae1741114f2a2e7f93d"}, + {file = "pyblst-0.3.15-cp310-cp310-win_amd64.whl", hash = "sha256:3a550014c9f8e833a221cebb3e8b5154cf40128c57993eb07f81db7172a20ab4"}, + {file = "pyblst-0.3.15-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:9b7774808bfded98d79f4a1bd38b1ca3e1eea1b0093036db01b18de682a1e322"}, + {file = "pyblst-0.3.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2fbcdef1e4d5adfabeaf156cb116fb406bca74676d631334146847d5d330cbe2"}, + {file = "pyblst-0.3.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80b0c826e05cb615d501d9ad0aa0901477c22df3b5cea5508882f6c97a52687d"}, + {file = "pyblst-0.3.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a3cd76c0bb82088f095878cd789295d97534b750273919df40459aa9b1cbe94"}, + {file = "pyblst-0.3.15-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0d32b85c8cb4ef156ebbaafdef98a279aac8a79afcb3c8d5ffd5dbe9ec96448"}, + {file = "pyblst-0.3.15-cp311-cp311-win_amd64.whl", hash = "sha256:5f97a7e5ef9c2175cd1e5b5ab645220e4999283ebd587872942c920b50389164"}, + {file = "pyblst-0.3.15-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:d174dfc26d5cc8a147216566173636a3285d80cb66feec08135937451d3c141f"}, + {file = "pyblst-0.3.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c2612e97637eadce6cef0ed006681c0ff6e1b4edf3b59c8f5ed590957971693f"}, + {file = "pyblst-0.3.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7408a9a8d3694b4f20668e5c3f966e22e61fe5e84e9511f38b5300b225d19182"}, + {file = "pyblst-0.3.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d9143edc459af7bcc43a1ff73cfcf3227a6f848a4140fde2cb41531ca34db95"}, + {file = "pyblst-0.3.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a476ab90a76a0282b7404f8ebdbf0f14519faaded28cd4f3982d691862907b65"}, + {file = "pyblst-0.3.15-cp312-cp312-win_amd64.whl", hash = "sha256:0c2e1f73a4739e9c5c000f00e362d6abe8cd405ec4b94a7db509ef546033999a"}, + {file = "pyblst-0.3.15-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:4b82f23ade52a751c89049cb114257d55671e10c86cd6d7cf727022f837f060e"}, + {file = "pyblst-0.3.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c8748d1d1ae5459d3832e1f25d1f85d589410e60c8a033f8fc1819962f7d48d1"}, + {file = "pyblst-0.3.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:566fdcb29cf517400846192e7c8b748866f1b7ddc6cac2d118f3b524e1e7c74f"}, + {file = "pyblst-0.3.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc156bbe5fc1544f23055ecde82486fcd036ae6f0ee8951991d1b3da11f61872"}, + {file = "pyblst-0.3.15-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:575a25fe6ed8c7f8f4fa3fc711a99bb5fb7b375dfb3fbadf6504e590e88a3377"}, + {file = "pyblst-0.3.15-cp313-cp313-win_amd64.whl", hash = "sha256:c9e038d9fa4a27f97cb554d316827c92672c1b9c4df06f66bdc8bdbca8825991"}, + {file = "pyblst-0.3.15-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6955cf55c29130f6d6a628a98b0355b79c65d34e3e3daa5fbdf5da566ff6e017"}, + {file = "pyblst-0.3.15-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5862365f3d699760342359d682902709659002046b6ece65c61975def6d999db"}, + {file = "pyblst-0.3.15-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:25aef3945ba479dd5d6436ec9054ef5f0ef9e6bd53ed8a0a49af3fb2d389797b"}, + {file = "pyblst-0.3.15-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b7d7a9ec75ea75578357d76fa6ab55999ecafd803c6c3ae7fb22ef0b2c7517a"}, + {file = "pyblst-0.3.15-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5169f24a7da7d651670a1b3b7a430c3a95b5e1b6ed6cf19d75a11b60b812031e"}, + {file = "pyblst-0.3.15-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:357221ef14fc7b789a36848079eb39d48b10edfb6eca19ac316fe15fd60fd47c"}, + {file = "pyblst-0.3.15-cp39-cp39-win_amd64.whl", hash = "sha256:87a479d3df8e30af504b32f7dbc238075df2c92b2fb1e0e350441246225408dd"}, + {file = "pyblst-0.3.15-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bbcf59f494c3595b5b137b315cbc9922adc4056691ba400e477b5476097d0b6"}, + {file = "pyblst-0.3.15-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9235b00b9c73c0b51db3f2bd074abc8294beef2f4a97843bd249c069b5674f40"}, + {file = "pyblst-0.3.15-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d7e271894ffe338748490862dd0675fbf411b2717f4272e8b0d9061cc1d87e0"}, + {file = "pyblst-0.3.15-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8b6bed2465fe9aee411151ae0724313bdb055efb041561deea8761fb3e1217d4"}, + {file = "pyblst-0.3.15-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b3f7b5a9ee3a1b8bb88bdb3c3ddc256df009cd8da6a04af13d183d3d577d7c9"}, + {file = "pyblst-0.3.15.tar.gz", hash = "sha256:258831210c069ece6d9894bffbe8013834f094d874f30070a4ad8d5a0e317c08"}, +] + [[package]] name = "pycparser" version = "2.22" @@ -2883,4 +2928,4 @@ cffi = ["cffi (>=1.11)"] [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "a1fda6a32a65cf0cb29a81558cc3b573f538a24cfb5a17d1a6f02f1f1f1c2110" +content-hash = "48725b29c973fa5ccef7355641d9238aeefebfd45471ff36e047ab73317aff38" diff --git a/pyproject.toml b/pyproject.toml index 03f39adb1..d03ddcbc6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "cashu" -version = "0.20.0" +version = "0.21.0" description = "Ecash wallet and mint" authors = ["calle "] license = "MIT" @@ -45,6 +45,7 @@ redis = "^5.1.1" brotli = "^1.1.0" zstandard = "^0.23.0" jinja2 = "^3.1.5" +pyblst = "^0.3.1" [tool.poetry.group.dev.dependencies] pytest-asyncio = "^0.24.0" diff --git a/tests/fuzz/test_fuzz_core.py b/tests/fuzz/test_fuzz_core.py index 5cd949f2f..de9139e1d 100644 --- a/tests/fuzz/test_fuzz_core.py +++ b/tests/fuzz/test_fuzz_core.py @@ -19,7 +19,7 @@ Unit, WalletKeyset, ) -from cashu.core.crypto.secp import PrivateKey +from cashu.core.crypto.bls import PrivateKey from cashu.core.secret import Secret, Tags diff --git a/tests/mint/test_mint.py b/tests/mint/test_mint.py index 9cf474bf4..fdf1ccef0 100644 --- a/tests/mint/test_mint.py +++ b/tests/mint/test_mint.py @@ -10,6 +10,8 @@ from cashu.mint.ledger import Ledger from tests.helpers import pay_if_regtest +settings.version = "0.20.0" + async def assert_err(f, msg): """Compute f() and expect an error message 'msg'.""" diff --git a/tests/mint/test_mint_api.py b/tests/mint/test_mint_api.py index 2ff3c4e8d..bea381041 100644 --- a/tests/mint/test_mint_api.py +++ b/tests/mint/test_mint_api.py @@ -22,6 +22,8 @@ from cashu.wallet.wallet import Wallet from tests.helpers import get_real_invoice, is_fake, is_regtest, pay_if_regtest +settings.version = "0.20.0" + BASE_URL = "http://localhost:3337" diff --git a/tests/mint/test_mint_conditions.py b/tests/mint/test_mint_conditions.py index 910706bf5..e930ce549 100644 --- a/tests/mint/test_mint_conditions.py +++ b/tests/mint/test_mint_conditions.py @@ -5,7 +5,7 @@ import pytest from cashu.core.base import BlindedMessage, HTLCWitness, P2PKWitness, Proof -from cashu.core.crypto.secp import PrivateKey +from cashu.core.crypto.secp import SecpPrivateKey as PrivateKey from cashu.core.errors import InvalidProofsError, TransactionError from cashu.core.p2pk import P2PKSecret, SigFlags, schnorr_sign from cashu.core.secret import Secret, SecretKind, Tags diff --git a/tests/mint/test_mint_db_operations.py b/tests/mint/test_mint_db_operations.py index 2e642ea38..055571ecd 100644 --- a/tests/mint/test_mint_db_operations.py +++ b/tests/mint/test_mint_db_operations.py @@ -8,6 +8,7 @@ import pytest_asyncio from cashu.core import db +from cashu.core.crypto.bls import PublicKey from cashu.core.db import Connection from cashu.core.migrations import backup_database from cashu.core.models import PostMeltQuoteRequest @@ -360,7 +361,6 @@ async def test_db_lock_table(wallet: Wallet, ledger: Ledger): async def test_store_and_sign_blinded_message(ledger: Ledger): # Localized imports to avoid polluting module scope from cashu.core.crypto.b_dhke import step1_alice, step2_bob - from cashu.core.crypto.secp import PublicKey # Arrange: prepare a blinded message tied to current active keyset amount = 8 @@ -482,7 +482,6 @@ async def test_get_blinded_messages_by_melt_id_filters_signed( wallet: Wallet, ledger: Ledger ): from cashu.core.crypto.b_dhke import step1_alice, step2_bob - from cashu.core.crypto.secp import PublicKey amount = 2 keyset_id = ledger.keyset.id @@ -560,7 +559,6 @@ async def test_update_blinded_message_signature_before_store_blinded_message_err ledger: Ledger, ): from cashu.core.crypto.b_dhke import step1_alice, step2_bob - from cashu.core.crypto.secp import PublicKey amount = 8 # Generate a blinded message that we will NOT store @@ -605,7 +603,6 @@ async def test_get_blind_signatures_by_melt_id_returns_signed( wallet: Wallet, ledger: Ledger ): from cashu.core.crypto.b_dhke import step1_alice, step2_bob - from cashu.core.crypto.secp import PublicKey amount = 4 keyset_id = ledger.keyset.id @@ -657,7 +654,6 @@ async def test_get_melt_quote_includes_change_signatures( wallet: Wallet, ledger: Ledger ): from cashu.core.crypto.b_dhke import step1_alice, step2_bob - from cashu.core.crypto.secp import PublicKey amount = 8 keyset_id = ledger.keyset.id diff --git a/tests/mint/test_mint_keysets.py b/tests/mint/test_mint_keysets.py index add5225b7..0b31a2539 100644 --- a/tests/mint/test_mint_keysets.py +++ b/tests/mint/test_mint_keysets.py @@ -8,7 +8,7 @@ get_keyset_id_version, is_keyset_id_v2, ) -from cashu.core.crypto.secp import PublicKey +from cashu.core.crypto.secp import SecpPublicKey from cashu.core.settings import settings from cashu.mint.ledger import Ledger from tests.mint.test_mint_init import ( @@ -256,7 +256,7 @@ async def test_keyset_id_v2_error_cases(): get_keyset_id_version("x") # Too short with pytest.raises(ValueError): - derive_keyset_short_id("02invalid") # Invalid version + derive_keyset_short_id("abinvalid") # Invalid version # Test with None keys should work (just empty dict) empty_id = derive_keyset_id_v2({}, Unit.sat) @@ -335,80 +335,80 @@ async def test_keyset_id_v1_test_vectors(): """ # Vector 1: Small keyset keys_v1_vec1 = { - 1: PublicKey(bytes.fromhex("03a40f20667ed53513075dc51e715ff2046cad64eb68960632269ba7f0210e38bc")), - 2: PublicKey(bytes.fromhex("03fd4ce5a16b65576145949e6f99f445f8249fee17c606b688b504a849cdc452de")), - 4: PublicKey(bytes.fromhex("02648eccfa4c026960966276fa5a4cae46ce0fd432211a4f449bf84f13aa5f8303")), - 8: PublicKey(bytes.fromhex("02fdfd6796bfeac490cbee12f778f867f0a2c68f6508d17c649759ea0dc3547528")), + 1: SecpPublicKey(bytes.fromhex("03a40f20667ed53513075dc51e715ff2046cad64eb68960632269ba7f0210e38bc")), + 2: SecpPublicKey(bytes.fromhex("03fd4ce5a16b65576145949e6f99f445f8249fee17c606b688b504a849cdc452de")), + 4: SecpPublicKey(bytes.fromhex("02648eccfa4c026960966276fa5a4cae46ce0fd432211a4f449bf84f13aa5f8303")), + 8: SecpPublicKey(bytes.fromhex("02fdfd6796bfeac490cbee12f778f867f0a2c68f6508d17c649759ea0dc3547528")), } keyset_id_v1_vec1 = derive_keyset_id(keys_v1_vec1) assert keyset_id_v1_vec1 == "00456a94ab4e1c46", "V1 vector 1 keyset ID mismatch" # Vector 2: Large keyset (all max_order amounts) keys_v1_vec2 = { - 1: PublicKey(bytes.fromhex("03ba786a2c0745f8c30e490288acd7a72dd53d65afd292ddefa326a4a3fa14c566")), - 2: PublicKey(bytes.fromhex("03361cd8bd1329fea797a6add1cf1990ffcf2270ceb9fc81eeee0e8e9c1bd0cdf5")), - 4: PublicKey(bytes.fromhex("036e378bcf78738ddf68859293c69778035740e41138ab183c94f8fee7572214c7")), - 8: PublicKey(bytes.fromhex("03909d73beaf28edfb283dbeb8da321afd40651e8902fcf5454ecc7d69788626c0")), - 16: PublicKey(bytes.fromhex("028a36f0e6638ea7466665fe174d958212723019ec08f9ce6898d897f88e68aa5d")), - 32: PublicKey(bytes.fromhex("03a97a40e146adee2687ac60c2ba2586a90f970de92a9d0e6cae5a4b9965f54612")), - 64: PublicKey(bytes.fromhex("03ce86f0c197aab181ddba0cfc5c5576e11dfd5164d9f3d4a3fc3ffbbf2e069664")), - 128: PublicKey(bytes.fromhex("0284f2c06d938a6f78794814c687560a0aabab19fe5e6f30ede38e113b132a3cb9")), - 256: PublicKey(bytes.fromhex("03b99f475b68e5b4c0ba809cdecaae64eade2d9787aa123206f91cd61f76c01459")), - 512: PublicKey(bytes.fromhex("03d4db82ea19a44d35274de51f78af0a710925fe7d9e03620b84e3e9976e3ac2eb")), - 1024: PublicKey(bytes.fromhex("031fbd4ba801870871d46cf62228a1b748905ebc07d3b210daf48de229e683f2dc")), - 2048: PublicKey(bytes.fromhex("0276cedb9a3b160db6a158ad4e468d2437f021293204b3cd4bf6247970d8aff54b")), - 4096: PublicKey(bytes.fromhex("02fc6b89b403ee9eb8a7ed457cd3973638080d6e04ca8af7307c965c166b555ea2")), - 8192: PublicKey(bytes.fromhex("0320265583e916d3a305f0d2687fcf2cd4e3cd03a16ea8261fda309c3ec5721e21")), - 16384: PublicKey(bytes.fromhex("036e41de58fdff3cb1d8d713f48c63bc61fa3b3e1631495a444d178363c0d2ed50")), - 32768: PublicKey(bytes.fromhex("0365438f613f19696264300b069d1dad93f0c60a37536b72a8ab7c7366a5ee6c04")), - 65536: PublicKey(bytes.fromhex("02408426cfb6fc86341bac79624ba8708a4376b2d92debdf4134813f866eb57a8d")), - 131072: PublicKey(bytes.fromhex("031063e9f11c94dc778c473e968966eac0e70b7145213fbaff5f7a007e71c65f41")), - 262144: PublicKey(bytes.fromhex("02f2a3e808f9cd168ec71b7f328258d0c1dda250659c1aced14c7f5cf05aab4328")), - 524288: PublicKey(bytes.fromhex("038ac10de9f1ff9395903bb73077e94dbf91e9ef98fd77d9a2debc5f74c575bc86")), - 1048576: PublicKey(bytes.fromhex("0203eaee4db749b0fc7c49870d082024b2c31d889f9bc3b32473d4f1dfa3625788")), - 2097152: PublicKey(bytes.fromhex("033cdb9d36e1e82ae652b7b6a08e0204569ec7ff9ebf85d80a02786dc7fe00b04c")), - 4194304: PublicKey(bytes.fromhex("02c8b73f4e3a470ae05e5f2fe39984d41e9f6ae7be9f3b09c9ac31292e403ac512")), - 8388608: PublicKey(bytes.fromhex("025bbe0cfce8a1f4fbd7f3a0d4a09cb6badd73ef61829dc827aa8a98c270bc25b0")), - 16777216: PublicKey(bytes.fromhex("037eec3d1651a30a90182d9287a5c51386fe35d4a96839cf7969c6e2a03db1fc21")), - 33554432: PublicKey(bytes.fromhex("03280576b81a04e6abd7197f305506476f5751356b7643988495ca5c3e14e5c262")), - 67108864: PublicKey(bytes.fromhex("03268bfb05be1dbb33ab6e7e00e438373ca2c9b9abc018fdb452d0e1a0935e10d3")), - 134217728: PublicKey(bytes.fromhex("02573b68784ceba9617bbcc7c9487836d296aa7c628c3199173a841e7a19798020")), - 268435456: PublicKey(bytes.fromhex("0234076b6e70f7fbf755d2227ecc8d8169d662518ee3a1401f729e2a12ccb2b276")), - 536870912: PublicKey(bytes.fromhex("03015bd88961e2a466a2163bd4248d1d2b42c7c58a157e594785e7eb34d880efc9")), - 1073741824: PublicKey(bytes.fromhex("02c9b076d08f9020ebee49ac8ba2610b404d4e553a4f800150ceb539e9421aaeee")), - 2147483648: PublicKey(bytes.fromhex("034d592f4c366afddc919a509600af81b489a03caf4f7517c2b3f4f2b558f9a41a")), - 4294967296: PublicKey(bytes.fromhex("037c09ecb66da082981e4cbdb1ac65c0eb631fc75d85bed13efb2c6364148879b5")), - 8589934592: PublicKey(bytes.fromhex("02b4ebb0dda3b9ad83b39e2e31024b777cc0ac205a96b9a6cfab3edea2912ed1b3")), - 17179869184: PublicKey(bytes.fromhex("026cc4dacdced45e63f6e4f62edbc5779ccd802e7fabb82d5123db879b636176e9")), - 34359738368: PublicKey(bytes.fromhex("02b2cee01b7d8e90180254459b8f09bbea9aad34c3a2fd98c85517ecfc9805af75")), - 68719476736: PublicKey(bytes.fromhex("037a0c0d564540fc574b8bfa0253cca987b75466e44b295ed59f6f8bd41aace754")), - 137438953472: PublicKey(bytes.fromhex("021df6585cae9b9ca431318a713fd73dbb76b3ef5667957e8633bca8aaa7214fb6")), - 274877906944: PublicKey(bytes.fromhex("02b8f53dde126f8c85fa5bb6061c0be5aca90984ce9b902966941caf963648d53a")), - 549755813888: PublicKey(bytes.fromhex("029cc8af2840d59f1d8761779b2496623c82c64be8e15f9ab577c657c6dd453785")), - 1099511627776: PublicKey(bytes.fromhex("03e446fdb84fad492ff3a25fc1046fb9a93a5b262ebcd0151caa442ea28959a38a")), - 2199023255552: PublicKey(bytes.fromhex("02d6b25bd4ab599dd0818c55f75702fde603c93f259222001246569018842d3258")), - 4398046511104: PublicKey(bytes.fromhex("03397b522bb4e156ec3952d3f048e5a986c20a00718e5e52cd5718466bf494156a")), - 8796093022208: PublicKey(bytes.fromhex("02d1fb9e78262b5d7d74028073075b80bb5ab281edcfc3191061962c1346340f1e")), - 17592186044416: PublicKey(bytes.fromhex("030d3f2ad7a4ca115712ff7f140434f802b19a4c9b2dd1c76f3e8e80c05c6a9310")), - 35184372088832: PublicKey(bytes.fromhex("03e325b691f292e1dfb151c3fb7cad440b225795583c32e24e10635a80e4221c06")), - 70368744177664: PublicKey(bytes.fromhex("03bee8f64d88de3dee21d61f89efa32933da51152ddbd67466bef815e9f93f8fd1")), - 140737488355328: PublicKey(bytes.fromhex("0327244c9019a4892e1f04ba3bf95fe43b327479e2d57c25979446cc508cd379ed")), - 281474976710656: PublicKey(bytes.fromhex("02fb58522cd662f2f8b042f8161caae6e45de98283f74d4e99f19b0ea85e08a56d")), - 562949953421312: PublicKey(bytes.fromhex("02adde4b466a9d7e59386b6a701a39717c53f30c4810613c1b55e6b6da43b7bc9a")), - 1125899906842624: PublicKey(bytes.fromhex("038eeda11f78ce05c774f30e393cda075192b890d68590813ff46362548528dca9")), - 2251799813685248: PublicKey(bytes.fromhex("02ec13e0058b196db80f7079d329333b330dc30c000dbdd7397cbbc5a37a664c4f")), - 4503599627370496: PublicKey(bytes.fromhex("02d2d162db63675bd04f7d56df04508840f41e2ad87312a3c93041b494efe80a73")), - 9007199254740992: PublicKey(bytes.fromhex("0356969d6aef2bb40121dbd07c68b6102339f4ea8e674a9008bb69506795998f49")), - 18014398509481984: PublicKey(bytes.fromhex("02f4e667567ebb9f4e6e180a4113bb071c48855f657766bb5e9c776a880335d1d6")), - 36028797018963968: PublicKey(bytes.fromhex("0385b4fe35e41703d7a657d957c67bb536629de57b7e6ee6fe2130728ef0fc90b0")), - 72057594037927936: PublicKey(bytes.fromhex("02b2bc1968a6fddbcc78fb9903940524824b5f5bed329c6ad48a19b56068c144fd")), - 144115188075855872: PublicKey(bytes.fromhex("02e0dbb24f1d288a693e8a49bc14264d1276be16972131520cf9e055ae92fba19a")), - 288230376151711744: PublicKey(bytes.fromhex("03efe75c106f931a525dc2d653ebedddc413a2c7d8cb9da410893ae7d2fa7d19cc")), - 576460752303423488: PublicKey(bytes.fromhex("02c7ec2bd9508a7fc03f73c7565dc600b30fd86f3d305f8f139c45c404a52d958a")), - 1152921504606846976: PublicKey(bytes.fromhex("035a6679c6b25e68ff4e29d1c7ef87f21e0a8fc574f6a08c1aa45ff352c1d59f06")), - 2305843009213693952: PublicKey(bytes.fromhex("033cdc225962c052d485f7cfbf55a5b2367d200fe1fe4373a347deb4cc99e9a099")), - 4611686018427387904: PublicKey(bytes.fromhex("024a4b806cf413d14b294719090a9da36ba75209c7657135ad09bc65328fba9e6f")), - 9223372036854775808: PublicKey(bytes.fromhex("0377a6fe114e291a8d8e991627c38001c8305b23b9e98b1c7b1893f5cd0dda6cad")), + 1: SecpPublicKey(bytes.fromhex("03ba786a2c0745f8c30e490288acd7a72dd53d65afd292ddefa326a4a3fa14c566")), + 2: SecpPublicKey(bytes.fromhex("03361cd8bd1329fea797a6add1cf1990ffcf2270ceb9fc81eeee0e8e9c1bd0cdf5")), + 4: SecpPublicKey(bytes.fromhex("036e378bcf78738ddf68859293c69778035740e41138ab183c94f8fee7572214c7")), + 8: SecpPublicKey(bytes.fromhex("03909d73beaf28edfb283dbeb8da321afd40651e8902fcf5454ecc7d69788626c0")), + 16: SecpPublicKey(bytes.fromhex("028a36f0e6638ea7466665fe174d958212723019ec08f9ce6898d897f88e68aa5d")), + 32: SecpPublicKey(bytes.fromhex("03a97a40e146adee2687ac60c2ba2586a90f970de92a9d0e6cae5a4b9965f54612")), + 64: SecpPublicKey(bytes.fromhex("03ce86f0c197aab181ddba0cfc5c5576e11dfd5164d9f3d4a3fc3ffbbf2e069664")), + 128: SecpPublicKey(bytes.fromhex("0284f2c06d938a6f78794814c687560a0aabab19fe5e6f30ede38e113b132a3cb9")), + 256: SecpPublicKey(bytes.fromhex("03b99f475b68e5b4c0ba809cdecaae64eade2d9787aa123206f91cd61f76c01459")), + 512: SecpPublicKey(bytes.fromhex("03d4db82ea19a44d35274de51f78af0a710925fe7d9e03620b84e3e9976e3ac2eb")), + 1024: SecpPublicKey(bytes.fromhex("031fbd4ba801870871d46cf62228a1b748905ebc07d3b210daf48de229e683f2dc")), + 2048: SecpPublicKey(bytes.fromhex("0276cedb9a3b160db6a158ad4e468d2437f021293204b3cd4bf6247970d8aff54b")), + 4096: SecpPublicKey(bytes.fromhex("02fc6b89b403ee9eb8a7ed457cd3973638080d6e04ca8af7307c965c166b555ea2")), + 8192: SecpPublicKey(bytes.fromhex("0320265583e916d3a305f0d2687fcf2cd4e3cd03a16ea8261fda309c3ec5721e21")), + 16384: SecpPublicKey(bytes.fromhex("036e41de58fdff3cb1d8d713f48c63bc61fa3b3e1631495a444d178363c0d2ed50")), + 32768: SecpPublicKey(bytes.fromhex("0365438f613f19696264300b069d1dad93f0c60a37536b72a8ab7c7366a5ee6c04")), + 65536: SecpPublicKey(bytes.fromhex("02408426cfb6fc86341bac79624ba8708a4376b2d92debdf4134813f866eb57a8d")), + 131072: SecpPublicKey(bytes.fromhex("031063e9f11c94dc778c473e968966eac0e70b7145213fbaff5f7a007e71c65f41")), + 262144: SecpPublicKey(bytes.fromhex("02f2a3e808f9cd168ec71b7f328258d0c1dda250659c1aced14c7f5cf05aab4328")), + 524288: SecpPublicKey(bytes.fromhex("038ac10de9f1ff9395903bb73077e94dbf91e9ef98fd77d9a2debc5f74c575bc86")), + 1048576: SecpPublicKey(bytes.fromhex("0203eaee4db749b0fc7c49870d082024b2c31d889f9bc3b32473d4f1dfa3625788")), + 2097152: SecpPublicKey(bytes.fromhex("033cdb9d36e1e82ae652b7b6a08e0204569ec7ff9ebf85d80a02786dc7fe00b04c")), + 4194304: SecpPublicKey(bytes.fromhex("02c8b73f4e3a470ae05e5f2fe39984d41e9f6ae7be9f3b09c9ac31292e403ac512")), + 8388608: SecpPublicKey(bytes.fromhex("025bbe0cfce8a1f4fbd7f3a0d4a09cb6badd73ef61829dc827aa8a98c270bc25b0")), + 16777216: SecpPublicKey(bytes.fromhex("037eec3d1651a30a90182d9287a5c51386fe35d4a96839cf7969c6e2a03db1fc21")), + 33554432: SecpPublicKey(bytes.fromhex("03280576b81a04e6abd7197f305506476f5751356b7643988495ca5c3e14e5c262")), + 67108864: SecpPublicKey(bytes.fromhex("03268bfb05be1dbb33ab6e7e00e438373ca2c9b9abc018fdb452d0e1a0935e10d3")), + 134217728: SecpPublicKey(bytes.fromhex("02573b68784ceba9617bbcc7c9487836d296aa7c628c3199173a841e7a19798020")), + 268435456: SecpPublicKey(bytes.fromhex("0234076b6e70f7fbf755d2227ecc8d8169d662518ee3a1401f729e2a12ccb2b276")), + 536870912: SecpPublicKey(bytes.fromhex("03015bd88961e2a466a2163bd4248d1d2b42c7c58a157e594785e7eb34d880efc9")), + 1073741824: SecpPublicKey(bytes.fromhex("02c9b076d08f9020ebee49ac8ba2610b404d4e553a4f800150ceb539e9421aaeee")), + 2147483648: SecpPublicKey(bytes.fromhex("034d592f4c366afddc919a509600af81b489a03caf4f7517c2b3f4f2b558f9a41a")), + 4294967296: SecpPublicKey(bytes.fromhex("037c09ecb66da082981e4cbdb1ac65c0eb631fc75d85bed13efb2c6364148879b5")), + 8589934592: SecpPublicKey(bytes.fromhex("02b4ebb0dda3b9ad83b39e2e31024b777cc0ac205a96b9a6cfab3edea2912ed1b3")), + 17179869184: SecpPublicKey(bytes.fromhex("026cc4dacdced45e63f6e4f62edbc5779ccd802e7fabb82d5123db879b636176e9")), + 34359738368: SecpPublicKey(bytes.fromhex("02b2cee01b7d8e90180254459b8f09bbea9aad34c3a2fd98c85517ecfc9805af75")), + 68719476736: SecpPublicKey(bytes.fromhex("037a0c0d564540fc574b8bfa0253cca987b75466e44b295ed59f6f8bd41aace754")), + 137438953472: SecpPublicKey(bytes.fromhex("021df6585cae9b9ca431318a713fd73dbb76b3ef5667957e8633bca8aaa7214fb6")), + 274877906944: SecpPublicKey(bytes.fromhex("02b8f53dde126f8c85fa5bb6061c0be5aca90984ce9b902966941caf963648d53a")), + 549755813888: SecpPublicKey(bytes.fromhex("029cc8af2840d59f1d8761779b2496623c82c64be8e15f9ab577c657c6dd453785")), + 1099511627776: SecpPublicKey(bytes.fromhex("03e446fdb84fad492ff3a25fc1046fb9a93a5b262ebcd0151caa442ea28959a38a")), + 2199023255552: SecpPublicKey(bytes.fromhex("02d6b25bd4ab599dd0818c55f75702fde603c93f259222001246569018842d3258")), + 4398046511104: SecpPublicKey(bytes.fromhex("03397b522bb4e156ec3952d3f048e5a986c20a00718e5e52cd5718466bf494156a")), + 8796093022208: SecpPublicKey(bytes.fromhex("02d1fb9e78262b5d7d74028073075b80bb5ab281edcfc3191061962c1346340f1e")), + 17592186044416: SecpPublicKey(bytes.fromhex("030d3f2ad7a4ca115712ff7f140434f802b19a4c9b2dd1c76f3e8e80c05c6a9310")), + 35184372088832: SecpPublicKey(bytes.fromhex("03e325b691f292e1dfb151c3fb7cad440b225795583c32e24e10635a80e4221c06")), + 70368744177664: SecpPublicKey(bytes.fromhex("03bee8f64d88de3dee21d61f89efa32933da51152ddbd67466bef815e9f93f8fd1")), + 140737488355328: SecpPublicKey(bytes.fromhex("0327244c9019a4892e1f04ba3bf95fe43b327479e2d57c25979446cc508cd379ed")), + 281474976710656: SecpPublicKey(bytes.fromhex("02fb58522cd662f2f8b042f8161caae6e45de98283f74d4e99f19b0ea85e08a56d")), + 562949953421312: SecpPublicKey(bytes.fromhex("02adde4b466a9d7e59386b6a701a39717c53f30c4810613c1b55e6b6da43b7bc9a")), + 1125899906842624: SecpPublicKey(bytes.fromhex("038eeda11f78ce05c774f30e393cda075192b890d68590813ff46362548528dca9")), + 2251799813685248: SecpPublicKey(bytes.fromhex("02ec13e0058b196db80f7079d329333b330dc30c000dbdd7397cbbc5a37a664c4f")), + 4503599627370496: SecpPublicKey(bytes.fromhex("02d2d162db63675bd04f7d56df04508840f41e2ad87312a3c93041b494efe80a73")), + 9007199254740992: SecpPublicKey(bytes.fromhex("0356969d6aef2bb40121dbd07c68b6102339f4ea8e674a9008bb69506795998f49")), + 18014398509481984: SecpPublicKey(bytes.fromhex("02f4e667567ebb9f4e6e180a4113bb071c48855f657766bb5e9c776a880335d1d6")), + 36028797018963968: SecpPublicKey(bytes.fromhex("0385b4fe35e41703d7a657d957c67bb536629de57b7e6ee6fe2130728ef0fc90b0")), + 72057594037927936: SecpPublicKey(bytes.fromhex("02b2bc1968a6fddbcc78fb9903940524824b5f5bed329c6ad48a19b56068c144fd")), + 144115188075855872: SecpPublicKey(bytes.fromhex("02e0dbb24f1d288a693e8a49bc14264d1276be16972131520cf9e055ae92fba19a")), + 288230376151711744: SecpPublicKey(bytes.fromhex("03efe75c106f931a525dc2d653ebedddc413a2c7d8cb9da410893ae7d2fa7d19cc")), + 576460752303423488: SecpPublicKey(bytes.fromhex("02c7ec2bd9508a7fc03f73c7565dc600b30fd86f3d305f8f139c45c404a52d958a")), + 1152921504606846976: SecpPublicKey(bytes.fromhex("035a6679c6b25e68ff4e29d1c7ef87f21e0a8fc574f6a08c1aa45ff352c1d59f06")), + 2305843009213693952: SecpPublicKey(bytes.fromhex("033cdc225962c052d485f7cfbf55a5b2367d200fe1fe4373a347deb4cc99e9a099")), + 4611686018427387904: SecpPublicKey(bytes.fromhex("024a4b806cf413d14b294719090a9da36ba75209c7657135ad09bc65328fba9e6f")), + 9223372036854775808: SecpPublicKey(bytes.fromhex("0377a6fe114e291a8d8e991627c38001c8305b23b9e98b1c7b1893f5cd0dda6cad")), } keyset_id_v1_vec2 = derive_keyset_id(keys_v1_vec2) assert keyset_id_v1_vec2 == "000f01df73ea149a", "V1 vector 2 keyset ID mismatch" @@ -424,10 +424,10 @@ async def test_keyset_id_v2_test_vectors(): """ # V2 Vector 1: Small keyset (4 keys) keys_v2_vec1 = { - 1: PublicKey(bytes.fromhex("03a40f20667ed53513075dc51e715ff2046cad64eb68960632269ba7f0210e38bc")), - 2: PublicKey(bytes.fromhex("03fd4ce5a16b65576145949e6f99f445f8249fee17c606b688b504a849cdc452de")), - 4: PublicKey(bytes.fromhex("02648eccfa4c026960966276fa5a4cae46ce0fd432211a4f449bf84f13aa5f8303")), - 8: PublicKey(bytes.fromhex("02fdfd6796bfeac490cbee12f778f867f0a2c68f6508d17c649759ea0dc3547528")), + 1: SecpPublicKey(bytes.fromhex("03a40f20667ed53513075dc51e715ff2046cad64eb68960632269ba7f0210e38bc")), + 2: SecpPublicKey(bytes.fromhex("03fd4ce5a16b65576145949e6f99f445f8249fee17c606b688b504a849cdc452de")), + 4: SecpPublicKey(bytes.fromhex("02648eccfa4c026960966276fa5a4cae46ce0fd432211a4f449bf84f13aa5f8303")), + 8: SecpPublicKey(bytes.fromhex("02fdfd6796bfeac490cbee12f778f867f0a2c68f6508d17c649759ea0dc3547528")), } keyset_id_v2_vec1 = derive_keyset_id_v2(keys_v2_vec1, Unit.sat, 2059210353, 100) assert keyset_id_v2_vec1 == "015ba18a8adcd02e715a58358eb618da4a4b3791151a4bee5e968bb88406ccf76a", \ @@ -435,70 +435,70 @@ async def test_keyset_id_v2_test_vectors(): # V2 Vectors 2 and 3: Large keyset (all max_order amounts) keys_v2_vec23 = { - 1: PublicKey(bytes.fromhex("03ba786a2c0745f8c30e490288acd7a72dd53d65afd292ddefa326a4a3fa14c566")), - 2: PublicKey(bytes.fromhex("03361cd8bd1329fea797a6add1cf1990ffcf2270ceb9fc81eeee0e8e9c1bd0cdf5")), - 4: PublicKey(bytes.fromhex("036e378bcf78738ddf68859293c69778035740e41138ab183c94f8fee7572214c7")), - 8: PublicKey(bytes.fromhex("03909d73beaf28edfb283dbeb8da321afd40651e8902fcf5454ecc7d69788626c0")), - 16: PublicKey(bytes.fromhex("028a36f0e6638ea7466665fe174d958212723019ec08f9ce6898d897f88e68aa5d")), - 32: PublicKey(bytes.fromhex("03a97a40e146adee2687ac60c2ba2586a90f970de92a9d0e6cae5a4b9965f54612")), - 64: PublicKey(bytes.fromhex("03ce86f0c197aab181ddba0cfc5c5576e11dfd5164d9f3d4a3fc3ffbbf2e069664")), - 128: PublicKey(bytes.fromhex("0284f2c06d938a6f78794814c687560a0aabab19fe5e6f30ede38e113b132a3cb9")), - 256: PublicKey(bytes.fromhex("03b99f475b68e5b4c0ba809cdecaae64eade2d9787aa123206f91cd61f76c01459")), - 512: PublicKey(bytes.fromhex("03d4db82ea19a44d35274de51f78af0a710925fe7d9e03620b84e3e9976e3ac2eb")), - 1024: PublicKey(bytes.fromhex("031fbd4ba801870871d46cf62228a1b748905ebc07d3b210daf48de229e683f2dc")), - 2048: PublicKey(bytes.fromhex("0276cedb9a3b160db6a158ad4e468d2437f021293204b3cd4bf6247970d8aff54b")), - 4096: PublicKey(bytes.fromhex("02fc6b89b403ee9eb8a7ed457cd3973638080d6e04ca8af7307c965c166b555ea2")), - 8192: PublicKey(bytes.fromhex("0320265583e916d3a305f0d2687fcf2cd4e3cd03a16ea8261fda309c3ec5721e21")), - 16384: PublicKey(bytes.fromhex("036e41de58fdff3cb1d8d713f48c63bc61fa3b3e1631495a444d178363c0d2ed50")), - 32768: PublicKey(bytes.fromhex("0365438f613f19696264300b069d1dad93f0c60a37536b72a8ab7c7366a5ee6c04")), - 65536: PublicKey(bytes.fromhex("02408426cfb6fc86341bac79624ba8708a4376b2d92debdf4134813f866eb57a8d")), - 131072: PublicKey(bytes.fromhex("031063e9f11c94dc778c473e968966eac0e70b7145213fbaff5f7a007e71c65f41")), - 262144: PublicKey(bytes.fromhex("02f2a3e808f9cd168ec71b7f328258d0c1dda250659c1aced14c7f5cf05aab4328")), - 524288: PublicKey(bytes.fromhex("038ac10de9f1ff9395903bb73077e94dbf91e9ef98fd77d9a2debc5f74c575bc86")), - 1048576: PublicKey(bytes.fromhex("0203eaee4db749b0fc7c49870d082024b2c31d889f9bc3b32473d4f1dfa3625788")), - 2097152: PublicKey(bytes.fromhex("033cdb9d36e1e82ae652b7b6a08e0204569ec7ff9ebf85d80a02786dc7fe00b04c")), - 4194304: PublicKey(bytes.fromhex("02c8b73f4e3a470ae05e5f2fe39984d41e9f6ae7be9f3b09c9ac31292e403ac512")), - 8388608: PublicKey(bytes.fromhex("025bbe0cfce8a1f4fbd7f3a0d4a09cb6badd73ef61829dc827aa8a98c270bc25b0")), - 16777216: PublicKey(bytes.fromhex("037eec3d1651a30a90182d9287a5c51386fe35d4a96839cf7969c6e2a03db1fc21")), - 33554432: PublicKey(bytes.fromhex("03280576b81a04e6abd7197f305506476f5751356b7643988495ca5c3e14e5c262")), - 67108864: PublicKey(bytes.fromhex("03268bfb05be1dbb33ab6e7e00e438373ca2c9b9abc018fdb452d0e1a0935e10d3")), - 134217728: PublicKey(bytes.fromhex("02573b68784ceba9617bbcc7c9487836d296aa7c628c3199173a841e7a19798020")), - 268435456: PublicKey(bytes.fromhex("0234076b6e70f7fbf755d2227ecc8d8169d662518ee3a1401f729e2a12ccb2b276")), - 536870912: PublicKey(bytes.fromhex("03015bd88961e2a466a2163bd4248d1d2b42c7c58a157e594785e7eb34d880efc9")), - 1073741824: PublicKey(bytes.fromhex("02c9b076d08f9020ebee49ac8ba2610b404d4e553a4f800150ceb539e9421aaeee")), - 2147483648: PublicKey(bytes.fromhex("034d592f4c366afddc919a509600af81b489a03caf4f7517c2b3f4f2b558f9a41a")), - 4294967296: PublicKey(bytes.fromhex("037c09ecb66da082981e4cbdb1ac65c0eb631fc75d85bed13efb2c6364148879b5")), - 8589934592: PublicKey(bytes.fromhex("02b4ebb0dda3b9ad83b39e2e31024b777cc0ac205a96b9a6cfab3edea2912ed1b3")), - 17179869184: PublicKey(bytes.fromhex("026cc4dacdced45e63f6e4f62edbc5779ccd802e7fabb82d5123db879b636176e9")), - 34359738368: PublicKey(bytes.fromhex("02b2cee01b7d8e90180254459b8f09bbea9aad34c3a2fd98c85517ecfc9805af75")), - 68719476736: PublicKey(bytes.fromhex("037a0c0d564540fc574b8bfa0253cca987b75466e44b295ed59f6f8bd41aace754")), - 137438953472: PublicKey(bytes.fromhex("021df6585cae9b9ca431318a713fd73dbb76b3ef5667957e8633bca8aaa7214fb6")), - 274877906944: PublicKey(bytes.fromhex("02b8f53dde126f8c85fa5bb6061c0be5aca90984ce9b902966941caf963648d53a")), - 549755813888: PublicKey(bytes.fromhex("029cc8af2840d59f1d8761779b2496623c82c64be8e15f9ab577c657c6dd453785")), - 1099511627776: PublicKey(bytes.fromhex("03e446fdb84fad492ff3a25fc1046fb9a93a5b262ebcd0151caa442ea28959a38a")), - 2199023255552: PublicKey(bytes.fromhex("02d6b25bd4ab599dd0818c55f75702fde603c93f259222001246569018842d3258")), - 4398046511104: PublicKey(bytes.fromhex("03397b522bb4e156ec3952d3f048e5a986c20a00718e5e52cd5718466bf494156a")), - 8796093022208: PublicKey(bytes.fromhex("02d1fb9e78262b5d7d74028073075b80bb5ab281edcfc3191061962c1346340f1e")), - 17592186044416: PublicKey(bytes.fromhex("030d3f2ad7a4ca115712ff7f140434f802b19a4c9b2dd1c76f3e8e80c05c6a9310")), - 35184372088832: PublicKey(bytes.fromhex("03e325b691f292e1dfb151c3fb7cad440b225795583c32e24e10635a80e4221c06")), - 70368744177664: PublicKey(bytes.fromhex("03bee8f64d88de3dee21d61f89efa32933da51152ddbd67466bef815e9f93f8fd1")), - 140737488355328: PublicKey(bytes.fromhex("0327244c9019a4892e1f04ba3bf95fe43b327479e2d57c25979446cc508cd379ed")), - 281474976710656: PublicKey(bytes.fromhex("02fb58522cd662f2f8b042f8161caae6e45de98283f74d4e99f19b0ea85e08a56d")), - 562949953421312: PublicKey(bytes.fromhex("02adde4b466a9d7e59386b6a701a39717c53f30c4810613c1b55e6b6da43b7bc9a")), - 1125899906842624: PublicKey(bytes.fromhex("038eeda11f78ce05c774f30e393cda075192b890d68590813ff46362548528dca9")), - 2251799813685248: PublicKey(bytes.fromhex("02ec13e0058b196db80f7079d329333b330dc30c000dbdd7397cbbc5a37a664c4f")), - 4503599627370496: PublicKey(bytes.fromhex("02d2d162db63675bd04f7d56df04508840f41e2ad87312a3c93041b494efe80a73")), - 9007199254740992: PublicKey(bytes.fromhex("0356969d6aef2bb40121dbd07c68b6102339f4ea8e674a9008bb69506795998f49")), - 18014398509481984: PublicKey(bytes.fromhex("02f4e667567ebb9f4e6e180a4113bb071c48855f657766bb5e9c776a880335d1d6")), - 36028797018963968: PublicKey(bytes.fromhex("0385b4fe35e41703d7a657d957c67bb536629de57b7e6ee6fe2130728ef0fc90b0")), - 72057594037927936: PublicKey(bytes.fromhex("02b2bc1968a6fddbcc78fb9903940524824b5f5bed329c6ad48a19b56068c144fd")), - 144115188075855872: PublicKey(bytes.fromhex("02e0dbb24f1d288a693e8a49bc14264d1276be16972131520cf9e055ae92fba19a")), - 288230376151711744: PublicKey(bytes.fromhex("03efe75c106f931a525dc2d653ebedddc413a2c7d8cb9da410893ae7d2fa7d19cc")), - 576460752303423488: PublicKey(bytes.fromhex("02c7ec2bd9508a7fc03f73c7565dc600b30fd86f3d305f8f139c45c404a52d958a")), - 1152921504606846976: PublicKey(bytes.fromhex("035a6679c6b25e68ff4e29d1c7ef87f21e0a8fc574f6a08c1aa45ff352c1d59f06")), - 2305843009213693952: PublicKey(bytes.fromhex("033cdc225962c052d485f7cfbf55a5b2367d200fe1fe4373a347deb4cc99e9a099")), - 4611686018427387904: PublicKey(bytes.fromhex("024a4b806cf413d14b294719090a9da36ba75209c7657135ad09bc65328fba9e6f")), - 9223372036854775808: PublicKey(bytes.fromhex("0377a6fe114e291a8d8e991627c38001c8305b23b9e98b1c7b1893f5cd0dda6cad")), + 1: SecpPublicKey(bytes.fromhex("03ba786a2c0745f8c30e490288acd7a72dd53d65afd292ddefa326a4a3fa14c566")), + 2: SecpPublicKey(bytes.fromhex("03361cd8bd1329fea797a6add1cf1990ffcf2270ceb9fc81eeee0e8e9c1bd0cdf5")), + 4: SecpPublicKey(bytes.fromhex("036e378bcf78738ddf68859293c69778035740e41138ab183c94f8fee7572214c7")), + 8: SecpPublicKey(bytes.fromhex("03909d73beaf28edfb283dbeb8da321afd40651e8902fcf5454ecc7d69788626c0")), + 16: SecpPublicKey(bytes.fromhex("028a36f0e6638ea7466665fe174d958212723019ec08f9ce6898d897f88e68aa5d")), + 32: SecpPublicKey(bytes.fromhex("03a97a40e146adee2687ac60c2ba2586a90f970de92a9d0e6cae5a4b9965f54612")), + 64: SecpPublicKey(bytes.fromhex("03ce86f0c197aab181ddba0cfc5c5576e11dfd5164d9f3d4a3fc3ffbbf2e069664")), + 128: SecpPublicKey(bytes.fromhex("0284f2c06d938a6f78794814c687560a0aabab19fe5e6f30ede38e113b132a3cb9")), + 256: SecpPublicKey(bytes.fromhex("03b99f475b68e5b4c0ba809cdecaae64eade2d9787aa123206f91cd61f76c01459")), + 512: SecpPublicKey(bytes.fromhex("03d4db82ea19a44d35274de51f78af0a710925fe7d9e03620b84e3e9976e3ac2eb")), + 1024: SecpPublicKey(bytes.fromhex("031fbd4ba801870871d46cf62228a1b748905ebc07d3b210daf48de229e683f2dc")), + 2048: SecpPublicKey(bytes.fromhex("0276cedb9a3b160db6a158ad4e468d2437f021293204b3cd4bf6247970d8aff54b")), + 4096: SecpPublicKey(bytes.fromhex("02fc6b89b403ee9eb8a7ed457cd3973638080d6e04ca8af7307c965c166b555ea2")), + 8192: SecpPublicKey(bytes.fromhex("0320265583e916d3a305f0d2687fcf2cd4e3cd03a16ea8261fda309c3ec5721e21")), + 16384: SecpPublicKey(bytes.fromhex("036e41de58fdff3cb1d8d713f48c63bc61fa3b3e1631495a444d178363c0d2ed50")), + 32768: SecpPublicKey(bytes.fromhex("0365438f613f19696264300b069d1dad93f0c60a37536b72a8ab7c7366a5ee6c04")), + 65536: SecpPublicKey(bytes.fromhex("02408426cfb6fc86341bac79624ba8708a4376b2d92debdf4134813f866eb57a8d")), + 131072: SecpPublicKey(bytes.fromhex("031063e9f11c94dc778c473e968966eac0e70b7145213fbaff5f7a007e71c65f41")), + 262144: SecpPublicKey(bytes.fromhex("02f2a3e808f9cd168ec71b7f328258d0c1dda250659c1aced14c7f5cf05aab4328")), + 524288: SecpPublicKey(bytes.fromhex("038ac10de9f1ff9395903bb73077e94dbf91e9ef98fd77d9a2debc5f74c575bc86")), + 1048576: SecpPublicKey(bytes.fromhex("0203eaee4db749b0fc7c49870d082024b2c31d889f9bc3b32473d4f1dfa3625788")), + 2097152: SecpPublicKey(bytes.fromhex("033cdb9d36e1e82ae652b7b6a08e0204569ec7ff9ebf85d80a02786dc7fe00b04c")), + 4194304: SecpPublicKey(bytes.fromhex("02c8b73f4e3a470ae05e5f2fe39984d41e9f6ae7be9f3b09c9ac31292e403ac512")), + 8388608: SecpPublicKey(bytes.fromhex("025bbe0cfce8a1f4fbd7f3a0d4a09cb6badd73ef61829dc827aa8a98c270bc25b0")), + 16777216: SecpPublicKey(bytes.fromhex("037eec3d1651a30a90182d9287a5c51386fe35d4a96839cf7969c6e2a03db1fc21")), + 33554432: SecpPublicKey(bytes.fromhex("03280576b81a04e6abd7197f305506476f5751356b7643988495ca5c3e14e5c262")), + 67108864: SecpPublicKey(bytes.fromhex("03268bfb05be1dbb33ab6e7e00e438373ca2c9b9abc018fdb452d0e1a0935e10d3")), + 134217728: SecpPublicKey(bytes.fromhex("02573b68784ceba9617bbcc7c9487836d296aa7c628c3199173a841e7a19798020")), + 268435456: SecpPublicKey(bytes.fromhex("0234076b6e70f7fbf755d2227ecc8d8169d662518ee3a1401f729e2a12ccb2b276")), + 536870912: SecpPublicKey(bytes.fromhex("03015bd88961e2a466a2163bd4248d1d2b42c7c58a157e594785e7eb34d880efc9")), + 1073741824: SecpPublicKey(bytes.fromhex("02c9b076d08f9020ebee49ac8ba2610b404d4e553a4f800150ceb539e9421aaeee")), + 2147483648: SecpPublicKey(bytes.fromhex("034d592f4c366afddc919a509600af81b489a03caf4f7517c2b3f4f2b558f9a41a")), + 4294967296: SecpPublicKey(bytes.fromhex("037c09ecb66da082981e4cbdb1ac65c0eb631fc75d85bed13efb2c6364148879b5")), + 8589934592: SecpPublicKey(bytes.fromhex("02b4ebb0dda3b9ad83b39e2e31024b777cc0ac205a96b9a6cfab3edea2912ed1b3")), + 17179869184: SecpPublicKey(bytes.fromhex("026cc4dacdced45e63f6e4f62edbc5779ccd802e7fabb82d5123db879b636176e9")), + 34359738368: SecpPublicKey(bytes.fromhex("02b2cee01b7d8e90180254459b8f09bbea9aad34c3a2fd98c85517ecfc9805af75")), + 68719476736: SecpPublicKey(bytes.fromhex("037a0c0d564540fc574b8bfa0253cca987b75466e44b295ed59f6f8bd41aace754")), + 137438953472: SecpPublicKey(bytes.fromhex("021df6585cae9b9ca431318a713fd73dbb76b3ef5667957e8633bca8aaa7214fb6")), + 274877906944: SecpPublicKey(bytes.fromhex("02b8f53dde126f8c85fa5bb6061c0be5aca90984ce9b902966941caf963648d53a")), + 549755813888: SecpPublicKey(bytes.fromhex("029cc8af2840d59f1d8761779b2496623c82c64be8e15f9ab577c657c6dd453785")), + 1099511627776: SecpPublicKey(bytes.fromhex("03e446fdb84fad492ff3a25fc1046fb9a93a5b262ebcd0151caa442ea28959a38a")), + 2199023255552: SecpPublicKey(bytes.fromhex("02d6b25bd4ab599dd0818c55f75702fde603c93f259222001246569018842d3258")), + 4398046511104: SecpPublicKey(bytes.fromhex("03397b522bb4e156ec3952d3f048e5a986c20a00718e5e52cd5718466bf494156a")), + 8796093022208: SecpPublicKey(bytes.fromhex("02d1fb9e78262b5d7d74028073075b80bb5ab281edcfc3191061962c1346340f1e")), + 17592186044416: SecpPublicKey(bytes.fromhex("030d3f2ad7a4ca115712ff7f140434f802b19a4c9b2dd1c76f3e8e80c05c6a9310")), + 35184372088832: SecpPublicKey(bytes.fromhex("03e325b691f292e1dfb151c3fb7cad440b225795583c32e24e10635a80e4221c06")), + 70368744177664: SecpPublicKey(bytes.fromhex("03bee8f64d88de3dee21d61f89efa32933da51152ddbd67466bef815e9f93f8fd1")), + 140737488355328: SecpPublicKey(bytes.fromhex("0327244c9019a4892e1f04ba3bf95fe43b327479e2d57c25979446cc508cd379ed")), + 281474976710656: SecpPublicKey(bytes.fromhex("02fb58522cd662f2f8b042f8161caae6e45de98283f74d4e99f19b0ea85e08a56d")), + 562949953421312: SecpPublicKey(bytes.fromhex("02adde4b466a9d7e59386b6a701a39717c53f30c4810613c1b55e6b6da43b7bc9a")), + 1125899906842624: SecpPublicKey(bytes.fromhex("038eeda11f78ce05c774f30e393cda075192b890d68590813ff46362548528dca9")), + 2251799813685248: SecpPublicKey(bytes.fromhex("02ec13e0058b196db80f7079d329333b330dc30c000dbdd7397cbbc5a37a664c4f")), + 4503599627370496: SecpPublicKey(bytes.fromhex("02d2d162db63675bd04f7d56df04508840f41e2ad87312a3c93041b494efe80a73")), + 9007199254740992: SecpPublicKey(bytes.fromhex("0356969d6aef2bb40121dbd07c68b6102339f4ea8e674a9008bb69506795998f49")), + 18014398509481984: SecpPublicKey(bytes.fromhex("02f4e667567ebb9f4e6e180a4113bb071c48855f657766bb5e9c776a880335d1d6")), + 36028797018963968: SecpPublicKey(bytes.fromhex("0385b4fe35e41703d7a657d957c67bb536629de57b7e6ee6fe2130728ef0fc90b0")), + 72057594037927936: SecpPublicKey(bytes.fromhex("02b2bc1968a6fddbcc78fb9903940524824b5f5bed329c6ad48a19b56068c144fd")), + 144115188075855872: SecpPublicKey(bytes.fromhex("02e0dbb24f1d288a693e8a49bc14264d1276be16972131520cf9e055ae92fba19a")), + 288230376151711744: SecpPublicKey(bytes.fromhex("03efe75c106f931a525dc2d653ebedddc413a2c7d8cb9da410893ae7d2fa7d19cc")), + 576460752303423488: SecpPublicKey(bytes.fromhex("02c7ec2bd9508a7fc03f73c7565dc600b30fd86f3d305f8f139c45c404a52d958a")), + 1152921504606846976: SecpPublicKey(bytes.fromhex("035a6679c6b25e68ff4e29d1c7ef87f21e0a8fc574f6a08c1aa45ff352c1d59f06")), + 2305843009213693952: SecpPublicKey(bytes.fromhex("033cdc225962c052d485f7cfbf55a5b2367d200fe1fe4373a347deb4cc99e9a099")), + 4611686018427387904: SecpPublicKey(bytes.fromhex("024a4b806cf413d14b294719090a9da36ba75209c7657135ad09bc65328fba9e6f")), + 9223372036854775808: SecpPublicKey(bytes.fromhex("0377a6fe114e291a8d8e991627c38001c8305b23b9e98b1c7b1893f5cd0dda6cad")), } # V2 Vector 2: Unit=sat, final_expiry=2059210353 (with large keyset) diff --git a/tests/mint/test_mint_verification.py b/tests/mint/test_mint_verification.py index 6557f89a1..c3eaccae9 100644 --- a/tests/mint/test_mint_verification.py +++ b/tests/mint/test_mint_verification.py @@ -15,7 +15,7 @@ Unit, ) from cashu.core.crypto.b_dhke import step1_alice -from cashu.core.crypto.secp import PrivateKey +from cashu.core.crypto.bls import PrivateKey from cashu.core.errors import ( InvalidProofsError, NoSecretInProofsError, diff --git a/tests/test_crypto.py b/tests/test_crypto.py index 901ee7898..0b4990aea 100644 --- a/tests/test_crypto.py +++ b/tests/test_crypto.py @@ -11,7 +11,8 @@ step2_bob_dleq, step3_alice, ) -from cashu.core.crypto.secp import PrivateKey, PublicKey +from cashu.core.crypto.secp import SecpPrivateKey as PrivateKey +from cashu.core.crypto.secp import SecpPublicKey as PublicKey def test_hash_to_curve(): diff --git a/tests/test_crypto_bls.py b/tests/test_crypto_bls.py new file mode 100644 index 000000000..79441658e --- /dev/null +++ b/tests/test_crypto_bls.py @@ -0,0 +1,137 @@ +from cashu.core.crypto.bls import PrivateKey, PublicKey +from cashu.core.crypto.bls_dhke import ( + batch_pairing_verification, + hash_to_curve, + keyed_verification, + pairing_verification, + step1_alice, + step2_bob, + step3_alice, +) + + +def test_hash_to_curve(): + result = hash_to_curve( + bytes.fromhex( + "0000000000000000000000000000000000000000000000000000000000000000" + ) + ) + assert isinstance(result, PublicKey) + assert result.group == "G1" + assert ( + result.format().hex() + == "a0687086dadc17db3c73fc63d58d61569ca32752a9b92c4e543692bc6b87b293fdcb4e9c870ab6e6d08127deb9382fb9" + ) + result = hash_to_curve( + bytes.fromhex( + "0000000000000000000000000000000000000000000000000000000000000001" + ) + ) + assert ( + result.format().hex() + == "8dbdd24f1bc6f485fda14721cb1f15ba72ba34c05f89b5ca38c2a222c07158f471011d50a371cdb365da6bc7ef4139f4" + ) + + +def test_bls_steps(): + secret_msg = "test_message" + + # Alice step 1 + B_, r = step1_alice(secret_msg) + assert isinstance(B_, PublicKey) + assert isinstance(r, PrivateKey) + + # Bob step 2 + a = PrivateKey() # Mint private key + C_, dummy_e, dummy_s = step2_bob(B_, a) + assert dummy_e is None + assert dummy_s is None + assert isinstance(C_, PublicKey) + + # Alice step 3 + A = a.public_key # Mint public key (not strictly needed for unblinding in BLS) + C = step3_alice(C_, r, A) + assert isinstance(C, PublicKey) + + # Verification (Mint) + assert keyed_verification(a, C, secret_msg) + + # Verification (Wallet using Pairings) + assert pairing_verification(A, C, secret_msg) + + +def test_batch_pairing_verification(): + secrets = ["msg1", "msg2", "msg3"] + K2s = [] + Cs = [] + + a1 = PrivateKey() + a2 = PrivateKey() + + # msg1 signed by a1 + B1_, r1 = step1_alice(secrets[0]) + C1_, _, _ = step2_bob(B1_, a1) + C1 = step3_alice(C1_, r1, a1.public_key) + K2s.append(a1.public_key) + Cs.append(C1) + + # msg2 signed by a1 + B2_, r2 = step1_alice(secrets[1]) + C2_, _, _ = step2_bob(B2_, a1) + C2 = step3_alice(C2_, r2, a1.public_key) + K2s.append(a1.public_key) + Cs.append(C2) + + # msg3 signed by a2 + B3_, r3 = step1_alice(secrets[2]) + C3_, _, _ = step2_bob(B3_, a2) + C3 = step3_alice(C3_, r3, a2.public_key) + K2s.append(a2.public_key) + Cs.append(C3) + + assert batch_pairing_verification(K2s, Cs, secrets) + + # Test failure + Cs[0] = C2 # wrong signature for msg1 + assert not batch_pairing_verification(K2s, Cs, secrets) + + +def test_deterministic_bls_steps(): + secret_msg = "test_message" + + r = PrivateKey( + bytes.fromhex( + "0000000000000000000000000000000000000000000000000000000000000003" + ) + ) + a = PrivateKey( + bytes.fromhex( + "0000000000000000000000000000000000000000000000000000000000000002" + ) + ) + + B_, _r = step1_alice(secret_msg, r) + assert _r.to_hex() == r.to_hex() + + C_, dummy_e, dummy_s = step2_bob(B_, a) + assert dummy_e is None + + A = a.public_key + C = step3_alice(C_, r, A) + + assert keyed_verification(a, C, secret_msg) + assert pairing_verification(A, C, secret_msg) + + # Just asserting they don't throw and give specific hex values + assert ( + B_.format().hex() + == "8e88c5f6a93f653784a66b033a00e52128499e18b095c2a56f080d1c2a937ffc9ef4600804a48d087bbd1f662f6b068f" + ) + assert ( + C_.format().hex() + == "8d52d7a6cbe5e99858d5c15c092d11a0c387c78917471211082a6e5afc2a79680dfa188fafe5d4a51c5398ce160e7a16" + ) + assert ( + C.format().hex() + == "b7a4881059133fd91a8753600d9a5e524c65d6224f6fe2d5aef9e59f1507fdad90b3b4d48ee46da5c8dfaa0b88e28b69" + ) diff --git a/tests/wallet/test_wallet.py b/tests/wallet/test_wallet.py index 192f6335b..09a23a483 100644 --- a/tests/wallet/test_wallet.py +++ b/tests/wallet/test_wallet.py @@ -103,7 +103,7 @@ async def test_get_keys(wallet1: Wallet): # assert keyset.id_deprecated == "eGnEWtdJ0PIM" assert ( keyset.id - == "01d8a63077d0a51f9855f066409782ffcb322dc8a2265291865221ed06c039f6bc" + == "0200351069db7a17336468dda24c22ce79a0fc1ebaf81b75adff42ecb7db118288" ) assert isinstance(keyset.id, str) assert len(keyset.id) > 0 @@ -550,11 +550,11 @@ async def test_token_state(wallet1: Wallet): async def testactivate_keyset_specific_keyset(wallet1: Wallet): await wallet1.activate_keyset() assert list(wallet1.keysets.keys()) == [ - "01d8a63077d0a51f9855f066409782ffcb322dc8a2265291865221ed06c039f6bc" + "0200351069db7a17336468dda24c22ce79a0fc1ebaf81b75adff42ecb7db118288" ] await wallet1.activate_keyset(keyset_id=wallet1.keyset_id) await wallet1.activate_keyset( - keyset_id="01d8a63077d0a51f9855f066409782ffcb322dc8a2265291865221ed06c039f6bc" + keyset_id="0200351069db7a17336468dda24c22ce79a0fc1ebaf81b75adff42ecb7db118288" ) # expect deprecated keyset id to be present await assert_err( diff --git a/tests/wallet/test_wallet_auth.py b/tests/wallet/test_wallet_auth.py index 8d0c7e2b9..fadeb581d 100644 --- a/tests/wallet/test_wallet_auth.py +++ b/tests/wallet/test_wallet_auth.py @@ -7,8 +7,8 @@ import pytest_asyncio from cashu.core.base import Unit +from cashu.core.crypto.bls import PrivateKey from cashu.core.crypto.keys import random_hash -from cashu.core.crypto.secp import PrivateKey from cashu.core.errors import ( BlindAuthFailedError, BlindAuthRateLimitExceededError, diff --git a/tests/wallet/test_wallet_cli.py b/tests/wallet/test_wallet_cli.py index c71611006..36f36f6c5 100644 --- a/tests/wallet/test_wallet_cli.py +++ b/tests/wallet/test_wallet_cli.py @@ -6,6 +6,7 @@ from click.testing import CliRunner from cashu.core.base import TokenV4 +from cashu.core.crypto.keys import is_bls_keyset from cashu.core.settings import settings from cashu.wallet.cli.cli import cli from cashu.wallet.wallet import Wallet @@ -477,7 +478,8 @@ def test_send_with_dleq(mint, cli_prefix): token_str = result.output.split("\n")[0] assert "cashuB" in token_str, "output does not have a token" token = TokenV4.deserialize(token_str).to_tokenv3() - assert token.token[0].proofs[0].dleq is not None, "no dleq included" + if not is_bls_keyset(token.token[0].proofs[0].id): + assert token.token[0].proofs[0].dleq is not None, "no dleq included" def test_send_legacy(mint, cli_prefix): diff --git a/tests/wallet/test_wallet_crud_unit.py b/tests/wallet/test_wallet_crud_unit.py index 82405c7a4..54ca0f1dd 100644 --- a/tests/wallet/test_wallet_crud_unit.py +++ b/tests/wallet/test_wallet_crud_unit.py @@ -13,7 +13,7 @@ WalletKeyset, WalletMint, ) -from cashu.core.crypto.secp import PrivateKey +from cashu.core.crypto.bls import PrivateKey from cashu.core.db import Database from cashu.core.migrations import migrate_databases from cashu.wallet import migrations as wallet_migrations diff --git a/tests/wallet/test_wallet_htlc.py b/tests/wallet/test_wallet_htlc.py index f7b517c8e..cbc141ba8 100644 --- a/tests/wallet/test_wallet_htlc.py +++ b/tests/wallet/test_wallet_htlc.py @@ -8,7 +8,7 @@ import pytest_asyncio from cashu.core.base import HTLCWitness, Proof -from cashu.core.crypto.secp import PrivateKey +from cashu.core.crypto.bls import PrivateKey from cashu.core.htlc import HTLCSecret from cashu.core.migrations import migrate_databases from cashu.core.p2pk import SigFlags diff --git a/tests/wallet/test_wallet_keysets_v2.py b/tests/wallet/test_wallet_keysets_v2.py index 3fc27d7d5..5f3a11c4d 100644 --- a/tests/wallet/test_wallet_keysets_v2.py +++ b/tests/wallet/test_wallet_keysets_v2.py @@ -13,13 +13,13 @@ from mnemonic import Mnemonic from cashu.core.base import Proof, TokenV4, TokenV4Proof, TokenV4Token, WalletKeyset +from cashu.core.crypto.bls import PrivateKey from cashu.core.crypto.keys import ( derive_keyset_short_id, get_keyset_id_version, is_base64_keyset_id, is_keyset_id_v2, ) -from cashu.core.crypto.secp import PrivateKey from cashu.wallet.keyset_manager import KeysetManager from cashu.wallet.proofs import WalletProofs from cashu.wallet.secrets import WalletSecrets diff --git a/tests/wallet/test_wallet_p2pk.py b/tests/wallet/test_wallet_p2pk.py index 1e12d3fed..bcf13e30f 100644 --- a/tests/wallet/test_wallet_p2pk.py +++ b/tests/wallet/test_wallet_p2pk.py @@ -11,7 +11,8 @@ from coincurve import PublicKeyXOnly from cashu.core.base import P2PKWitness, Proof -from cashu.core.crypto.secp import PrivateKey, PublicKey +from cashu.core.crypto.bls import PrivateKey +from cashu.core.crypto.secp import SecpPublicKey as PublicKey from cashu.core.migrations import migrate_databases from cashu.core.p2pk import P2PKSecret, SigFlags from cashu.core.secret import Secret, SecretKind, Tags diff --git a/tests/wallet/test_wallet_p2pk_methods.py b/tests/wallet/test_wallet_p2pk_methods.py index 346d8e5db..d5c17c52c 100644 --- a/tests/wallet/test_wallet_p2pk_methods.py +++ b/tests/wallet/test_wallet_p2pk_methods.py @@ -7,7 +7,7 @@ from coincurve import PublicKeyXOnly from cashu.core.base import P2PKWitness -from cashu.core.crypto.secp import PrivateKey +from cashu.core.crypto.bls import PrivateKey from cashu.core.migrations import migrate_databases from cashu.core.p2pk import P2PKSecret, SigFlags from cashu.core.secret import SecretKind, Tags diff --git a/tests/wallet/test_wallet_restore.py b/tests/wallet/test_wallet_restore.py index 2b65d17c1..88d49865b 100644 --- a/tests/wallet/test_wallet_restore.py +++ b/tests/wallet/test_wallet_restore.py @@ -6,7 +6,7 @@ import pytest_asyncio from cashu.core.base import Proof -from cashu.core.crypto.secp import PrivateKey +from cashu.core.crypto.bls import PrivateKey from cashu.core.errors import CashuError from cashu.wallet.wallet import Wallet from cashu.wallet.wallet import Wallet as Wallet1 diff --git a/tests/wallet/test_wallet_v1_api.py b/tests/wallet/test_wallet_v1_api.py index 1c3499e33..959fa3a96 100644 --- a/tests/wallet/test_wallet_v1_api.py +++ b/tests/wallet/test_wallet_v1_api.py @@ -5,7 +5,7 @@ import pytest from cashu.core.base import BlindedMessage, MeltQuoteState, Proof, Unit -from cashu.core.crypto.secp import PrivateKey +from cashu.core.crypto.bls import PrivateKey from cashu.core.db import Database from cashu.core.settings import settings from cashu.wallet.v1_api import LedgerAPI