From 6ce15da527f11b5f21705415d214f6d94161ab87 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 8 Oct 2022 13:56:01 +0200 Subject: [PATCH] keyset working --- cashu/core/base.py | 68 +++++++++++++++++++++++++++++------ cashu/core/crud.py | 74 ++++++++++++++++++++++++++++++++++++++ cashu/core/crypto.py | 31 ++++++++++++++++ cashu/core/errors.py | 21 +++++++++++ cashu/core/migrations.py | 2 -- cashu/mint/crud.py | 1 - cashu/mint/ledger.py | 48 +++++++------------------ cashu/mint/migrations.py | 33 +++++++++++++++++ cashu/mint/startup.py | 6 ++-- cashu/wallet/crud.py | 63 +++++--------------------------- cashu/wallet/migrations.py | 18 +++++++++- cashu/wallet/wallet.py | 72 ++++++++++++++++++++----------------- 12 files changed, 299 insertions(+), 138 deletions(-) create mode 100644 cashu/core/crud.py create mode 100644 cashu/core/crypto.py create mode 100644 cashu/core/errors.py diff --git a/cashu/core/base.py b/cashu/core/base.py index 428432b..75d6a61 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -1,5 +1,9 @@ from sqlite3 import Row -from typing import List, Union, Dict +from typing import List, Union, Dict, Any +from cashu.core.crypto import derive_keyset_id, derive_keys, derive_pubkeys +from cashu.core.secp import PrivateKey, PublicKey + +from loguru import logger from pydantic import BaseModel @@ -9,12 +13,10 @@ class CashuError(BaseModel): error = "CashuError" -class Keyset(BaseModel): +class KeyBase(BaseModel): id: str - keys: Dict - mint_url: Union[str, None] = None - first_seen: Union[str, None] = None - active: bool = True + amount: int + pubkey: str @classmethod def from_row(cls, row: Row): @@ -22,13 +24,55 @@ class Keyset(BaseModel): return cls return cls( id=row[0], - keys=row[1], - mint_url=row[2], - first_seen=row[3], - active=row[4], + amount=int(row[1]), + pubkey=row[2], ) +class Keyset: + id: str + private_keys: Dict[int, PrivateKey] + public_keys: Dict[int, PublicKey] + mint_url: Union[str, None] = None + valid_from: Union[str, None] = None + valid_to: Union[str, None] = None + first_seen: Union[str, None] = None + active: bool = True + + def __init__( + self, + seed: Union[None, str] = None, + derivation_path: str = "0", + pubkeys: Union[None, Dict[int, PublicKey]] = None, + ): + if seed: + self.private_keys = derive_keys(seed, derivation_path) + self.public_keys = derive_pubkeys(self.private_keys) + if pubkeys: + self.public_keys = pubkeys + self.id = derive_keyset_id(self.public_keys) + logger.debug(f"Mint keyset id: {self.id}") + + @classmethod + def from_row(cls, row: Row): + if row is None: + return cls + return cls( + id=row[0], + mint_url=row[1], + valid_from=row[2], + valid_to=row[3], + first_seen=row[4], + active=row[5], + ) + + def get_keybase(self): + return { + k: KeyBase(id=self.id, amount=k, pubkey=v.serialize().hex()) + for k, v in self.public_keys.items() + } + + class P2SHScript(BaseModel): script: str signature: str @@ -65,6 +109,7 @@ class Proof(BaseModel): send_id=row[4] or "", time_created=row[5] or "", time_reserved=row[6] or "", + id=row[7] or "", ) @classmethod @@ -116,17 +161,20 @@ class Invoice(BaseModel): class BlindedMessage(BaseModel): + id: str = "" amount: int B_: str class BlindedSignature(BaseModel): + id: str = "" amount: int C_: str @classmethod def from_dict(cls, d: dict): return cls( + id=d["id"], amount=d["amount"], C_=d["C_"], ) diff --git a/cashu/core/crud.py b/cashu/core/crud.py new file mode 100644 index 0000000..6c107ea --- /dev/null +++ b/cashu/core/crud.py @@ -0,0 +1,74 @@ +from typing import Optional + +from cashu.core.base import Keyset, KeyBase +from cashu.core.db import Connection, Database + + +async def store_keyset( + keyset: Keyset, + mint_url: str, + db: Database, + conn: Optional[Connection] = None, +): + + await (conn or db).execute( + """ + INSERT INTO keysets + (id, mint_url, valid_from, valid_to, first_seen, active) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + keyset.id, + mint_url, + keyset.valid_from, + keyset.valid_to, + keyset.first_seen, + True, + ), + ) + + +async def get_keyset( + id: str = None, + mint_url: str = None, + db: Database = None, + conn: Optional[Connection] = None, +): + clauses = [] + values = [] + clauses.append("active = ?") + values.append(True) + if id: + clauses.append("id = ?") + values.append(id) + if mint_url: + clauses.append("mint_url = ?") + values.append(mint_url) + where = "" + if clauses: + where = f"WHERE {' AND '.join(clauses)}" + + row = await (conn or db).fetchone( + f""" + SELECT * from keysets + {where} + """, + tuple(values), + ) + return Keyset.from_row(row) + + +async def store_mint_pubkey( + key: KeyBase, + db: Database, + conn: Optional[Connection] = None, +): + + await (conn or db).execute( + """ + INSERT INTO mint_pubkeys + (id, amount, pubkey) + VALUES (?, ?, ?) + """, + (key.id, key.amount, key.pubkey), + ) diff --git a/cashu/core/crypto.py b/cashu/core/crypto.py new file mode 100644 index 0000000..c57aa4b --- /dev/null +++ b/cashu/core/crypto.py @@ -0,0 +1,31 @@ +import hashlib +from typing import Dict, List +from cashu.core.secp import PrivateKey, PublicKey +from cashu.core.settings import MAX_ORDER + + +def derive_keys(master_key: str, derivation_path: str = ""): + """ + Deterministic derivation of keys for 2^n values. + TODO: Implement BIP32. + """ + return { + 2 + ** i: PrivateKey( + hashlib.sha256((str(master_key) + derivation_path + str(i)).encode("utf-8")) + .hexdigest() + .encode("utf-8")[:32], + raw=True, + ) + for i in range(MAX_ORDER) + } + + +def derive_pubkeys(keys: Dict[int, PrivateKey]): + return {amt: keys[amt].pubkey for amt in [2**i for i in range(MAX_ORDER)]} + + +def derive_keyset_id(keys: Dict[str, PublicKey]): + """Deterministic derivation keyset_id from set of public keys.""" + pubkeys_concat = "".join([p.serialize().hex() for _, p in keys.items()]) + return hashlib.sha256((pubkeys_concat).encode("utf-8")).hexdigest()[:16] diff --git a/cashu/core/errors.py b/cashu/core/errors.py new file mode 100644 index 0000000..a2ea6c3 --- /dev/null +++ b/cashu/core/errors.py @@ -0,0 +1,21 @@ +from pydantic import BaseModel + + +class CashuError(Exception, BaseModel): + code = "000" + error = "CashuError" + + +class MintException(CashuError): + code = 100 + error = "Mint" + + +class LightningException(MintException): + code = 200 + error = "Lightning" + + +class InvoiceNotPaidException(LightningException): + code = 201 + error = "invoice not paid." diff --git a/cashu/core/migrations.py b/cashu/core/migrations.py index 6beaa91..cc5041f 100644 --- a/cashu/core/migrations.py +++ b/cashu/core/migrations.py @@ -1,7 +1,5 @@ import re -from loguru import logger - from cashu.core.db import COCKROACH, POSTGRES, SQLITE, Database diff --git a/cashu/mint/crud.py b/cashu/mint/crud.py index a608967..0afc285 100644 --- a/cashu/mint/crud.py +++ b/cashu/mint/crud.py @@ -1,4 +1,3 @@ -import secrets from typing import Optional from cashu.core.base import Invoice, Proof diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 1779743..bb689e8 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -2,20 +2,17 @@ Implementation of https://gist.github.com/phyro/935badc682057f418842c72961cf096c """ -import hashlib import math -from inspect import signature -from signal import signal from typing import List, Set import cashu.core.b_dhke as b_dhke import cashu.core.bolt11 as bolt11 -from cashu.core.base import BlindedMessage, BlindedSignature, Invoice, Proof -from cashu.core.crypto import derive_keyset_id +from cashu.core.base import BlindedMessage, BlindedSignature, Invoice, Proof, Keyset +from cashu.core.crypto import derive_keyset_id, derive_keys, derive_pubkeys from cashu.core.db import Database from cashu.core.helpers import fee_reserve from cashu.core.script import verify_script -from cashu.core.secp import PrivateKey, PublicKey +from cashu.core.secp import PublicKey from cashu.core.settings import LIGHTNING, MAX_ORDER from cashu.core.split import amount_split from cashu.lightning import WALLET @@ -32,34 +29,13 @@ from cashu.mint.crud import ( class Ledger: def __init__(self, secret_key: str, db: str): self.proofs_used: Set[str] = set() - self.master_key = secret_key - self.keys = self._derive_keys(self.master_key) - self.keyset_id = derive_keyset_id(self.keys) - self.pub_keys = self._derive_pubkeys(self.keys) + self.keyset = Keyset(self.master_key) self.db: Database = Database("mint", db) async def load_used_proofs(self): self.proofs_used = set(await get_proofs_used(db=self.db)) - @staticmethod - def _derive_keys(master_key: str, keyset_id: str = ""): - """Deterministic derivation of keys for 2^n values.""" - return { - 2 - ** i: PrivateKey( - hashlib.sha256((str(master_key) + str(i) + keyset_id).encode("utf-8")) - .hexdigest() - .encode("utf-8")[:32], - raw=True, - ) - for i in range(MAX_ORDER) - } - - @staticmethod - def _derive_pubkeys(keys: List[PrivateKey]): - return {amt: keys[amt].pubkey for amt in [2**i for i in range(MAX_ORDER)]} - async def _generate_promises(self, amounts: List[int], B_s: List[str]): """Generates promises that sum to the given amount.""" return [ @@ -69,7 +45,7 @@ class Ledger: async def _generate_promise(self, amount: int, B_: PublicKey): """Generates a promise for given amount and returns a pair (amount, C').""" - secret_key = self.keys[amount] # Get the correct key + secret_key = self.keyset.private_keys[amount] # Get the correct key C_ = b_dhke.step2_bob(B_, secret_key) await store_promise( amount, B_=B_.serialize().hex(), C_=C_.serialize().hex(), db=self.db @@ -94,7 +70,9 @@ class Ledger: """Verifies that the proof of promise was issued by this ledger.""" if not self._check_spendable(proof): raise Exception(f"tokens already spent. Secret: {proof.secret}") - secret_key = self.keys[proof.amount] # Get the correct key to check against + secret_key = self.keyset.private_keys[ + proof.amount + ] # Get the correct key to check against C = PublicKey(bytes.fromhex(proof.C), raw=True) return b_dhke.verify(secret_key, C, proof.secret) @@ -125,7 +103,7 @@ class Ledger: assert len(proof.secret.split(":")) == 3, "secret format wrong." assert proof.secret.split(":")[1] == str( txin_p2sh_address - ), f"secret does not contain correct P2SH address: {proof.secret.split(':')[1]}!={txin_p2sh_address}." + ), f"secret does not contain correct P2SH address: {proof.secret.split(':')[1]} is not {txin_p2sh_address}." return valid def _verify_outputs(self, total: int, amount: int, outputs: List[BlindedMessage]): @@ -223,13 +201,13 @@ class Ledger: for p in proofs: await invalidate_proof(p, db=self.db) - # Public methods - def get_pubkeys(self): + def _serialize_pubkeys(self): """Returns public keys for possible amounts.""" - return {a: p.serialize().hex() for a, p in self.pub_keys.items()} + return {a: p.serialize().hex() for a, p in self.keyset.public_keys.items()} + # Public methods def get_keyset(self): - return {"id": self.keyset_id, "keys": self.get_pubkeys()} + return {"id": self.keyset.id, "keys": self._serialize_pubkeys()} async def request_mint(self, amount): """Returns Lightning invoice and stores it in the db.""" diff --git a/cashu/mint/migrations.py b/cashu/mint/migrations.py index 967b9d3..b1d7329 100644 --- a/cashu/mint/migrations.py +++ b/cashu/mint/migrations.py @@ -85,3 +85,36 @@ async def m001_initial(db: Database): ); """ ) + + +async def m003_mint_keysets(db: Database): + """ + Stores mint keysets from different mints and epochs. + """ + await db.execute( + f""" + CREATE TABLE IF NOT EXISTS keysets ( + id TEXT NOT NULL, + mint_url TEXT NOT NULL, + valid_from TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, + valid_to TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, + first_seen TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, + active BOOL NOT NULL DEFAULT TRUE, + + UNIQUE (id, mint_url) + + ); + """ + ) + await db.execute( + f""" + CREATE TABLE IF NOT EXISTS mint_pubkeys ( + id TEXT NOT NULL, + amount INTEGER NOT NULL, + pubkey TEXT NOT NULL, + + UNIQUE (id, pubkey) + + ); + """ + ) diff --git a/cashu/mint/startup.py b/cashu/mint/startup.py index 1207091..043e9a0 100644 --- a/cashu/mint/startup.py +++ b/cashu/mint/startup.py @@ -4,13 +4,15 @@ from loguru import logger from cashu.core.settings import CASHU_DIR, LIGHTNING from cashu.lightning import WALLET -from cashu.mint.migrations import m001_initial +from cashu.mint import migrations +from cashu.core.migrations import migrate_databases from . import ledger async def load_ledger(): - await asyncio.wait([m001_initial(ledger.db)]) + await migrate_databases(ledger.db, migrations) + # await asyncio.wait([m001_initial(ledger.db)]) await ledger.load_used_proofs() if LIGHTNING: diff --git a/cashu/wallet/crud.py b/cashu/wallet/crud.py index 91c917d..4486023 100644 --- a/cashu/wallet/crud.py +++ b/cashu/wallet/crud.py @@ -1,7 +1,7 @@ import time -from typing import Any, List, Optional, Dict +from typing import Any, List, Optional -from cashu.core.base import P2SHScript, Proof, Keyset +from cashu.core.base import P2SHScript, Proof from cashu.core.db import Connection, Database @@ -14,10 +14,10 @@ async def store_proof( await (conn or db).execute( """ INSERT INTO proofs - (amount, C, secret, time_created) - VALUES (?, ?, ?, ?) + (id, amount, C, secret, time_created) + VALUES (?, ?, ?, ?, ?) """, - (proof.amount, str(proof.C), str(proof.secret), int(time.time())), + (proof.id, proof.amount, str(proof.C), str(proof.secret), int(time.time())), ) @@ -65,10 +65,10 @@ async def invalidate_proof( await (conn or db).execute( """ INSERT INTO proofs_used - (amount, C, secret, time_used) - VALUES (?, ?, ?, ?) + (amount, C, secret, time_used, id) + VALUES (?, ?, ?, ?, ?) """, - (proof.amount, str(proof.C), str(proof.secret), int(time.time())), + (proof.amount, str(proof.C), str(proof.secret), int(time.time()), proof.id), ) @@ -180,50 +180,3 @@ async def update_p2sh_used( f"UPDATE proofs SET {', '.join(clauses)} WHERE address = ?", (*values, str(p2sh.address)), ) - - -async def store_keyset( - keyset: Keyset, - mint_url: str, - db: Database, - conn: Optional[Connection] = None, -): - - await (conn or db).execute( - """ - INSERT INTO keysets - (id, mint_url, keys, first_seen, active) - VALUES (?, ?, ?, ?, ?) - """, - (keyset.id, mint_url, keyset.keys, keyset.first_seen, True), - ) - - -async def get_keyset( - id: str = None, - mint_url: str = None, - db: Database = None, - conn: Optional[Connection] = None, -): - clauses = [] - values = [] - clauses.append("active = ?") - values.append(True) - if id: - clauses.append("id = ?") - values.append(id) - if mint_url: - clauses.append("mint_url = ?") - values.append(mint_url) - where = "" - if clauses: - where = f"WHERE {' AND '.join(clauses)}" - - row = await (conn or db).fetchone( - f""" - SELECT * from keysets - {where} - """, - tuple(values), - ) - return Keyset.from_row(row) diff --git a/cashu/wallet/migrations.py b/cashu/wallet/migrations.py index 34f19f2..6cddadf 100644 --- a/cashu/wallet/migrations.py +++ b/cashu/wallet/migrations.py @@ -108,8 +108,9 @@ async def m005_mint_keysets(db: Database): f""" CREATE TABLE IF NOT EXISTS keysets ( id TEXT NOT NULL, - keys TEXT NOT NULL, mint_url TEXT NOT NULL, + valid_from TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, + valid_to TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, first_seen TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, active BOOL NOT NULL DEFAULT TRUE, @@ -118,3 +119,18 @@ async def m005_mint_keysets(db: Database): ); """ ) + await db.execute( + f""" + CREATE TABLE IF NOT EXISTS mint_pubkeys ( + id TEXT NOT NULL, + amount INTEGER NOT NULL, + pubkey TEXT NOT NULL, + + UNIQUE (id, pubkey) + + ); + """ + ) + + await db.execute("ALTER TABLE proofs ADD COLUMN id TEXT") + await db.execute("ALTER TABLE proofs_used ADD COLUMN id TEXT") diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 6f0cf91..e257abf 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -37,9 +37,8 @@ from cashu.wallet.crud import ( store_p2sh, store_proof, update_proof_reserved, - store_keyset, - get_keyset, ) +from cashu.core.crud import store_keyset, get_keyset class LedgerAPI: @@ -49,37 +48,18 @@ class LedgerAPI: def __init__(self, url): self.url = url -<<<<<<< HEAD def _get_keys(self, url): resp = requests.get(url + "/keys").json() keyset_id = resp["id"] keys = resp["keys"] - # return { - # "id": keyset_id, - # "keys": { - # int(amt): PublicKey(bytes.fromhex(val), raw=True) - # for amt, val in keys.items() - # }, - # } - keyset_keys = ( - { - int(amt): PublicKey(bytes.fromhex(val), raw=True) - for amt, val in keys.items() - }, - ) - print(resp) - return Keyset(id=keyset_id, keys=keyset_keys, mint_url=self.url) -======= - @staticmethod - def _get_keys(url): - resp = requests.get(url + "/keys") - resp.raise_for_status() - data = resp.json() - return { + assert len(keys), Exception("did not receive any keys") + keyset_keys = { int(amt): PublicKey(bytes.fromhex(val), raw=True) - for amt, val in data.items() + for amt, val in keys.items() } ->>>>>>> main + keyset = Keyset(pubkeys=keyset_keys) + assert keyset_id == keyset.id, Exception("mint keyset id not valid.") + return keyset @staticmethod def _get_output_split(amount): @@ -121,7 +101,7 @@ class LedgerAPI: keyset_local: Keyset = await get_keyset(keyset.id, self.url, db=self.db) if keyset_local is None: await store_keyset(keyset=keyset, db=self.db) - self.keys = keyset.keys + self.keys = keyset.public_keys self.keyset_id = keyset.id assert len(self.keys) > 0, "did not receive keys from mint." @@ -215,9 +195,19 @@ class LedgerAPI: await self._check_used_secrets(secrets) payloads, rs = self._construct_outputs(amounts, secrets) split_payload = SplitRequest(proofs=proofs, amount=amount, outputs=payloads) + + def _splitrequest_include_fields(proofs): + """strips away fields from the model that aren't necessary for the /split""" + proofs_include = {"id", "amount", "secret", "C", "script"} + return { + "amount": ..., + "outputs": ..., + "proofs": {i: proofs_include for i in range(len(proofs))}, + } + resp = requests.post( self.url + "/split", - json=split_payload.dict(), + json=split_payload.dict(include=_splitrequest_include_fields(proofs)), ) resp.raise_for_status() try: @@ -263,9 +253,19 @@ class LedgerAPI: async def pay_lightning(self, proofs: List[Proof], invoice: str): payload = MeltRequest(proofs=proofs, invoice=invoice) + + def _meltequest_include_fields(proofs): + """strips away fields from the model that aren't necessary for the /melt""" + proofs_include = {"id", "amount", "secret", "C", "script"} + return { + "amount": ..., + "invoice": ..., + "proofs": {i: proofs_include for i in range(len(proofs))}, + } + resp = requests.post( self.url + "/melt", - json=payload.dict(), + json=payload.dict(include=_meltequest_include_fields(proofs)), ) resp.raise_for_status() @@ -357,14 +357,22 @@ class Wallet(LedgerAPI): ).decode() return token + async def _get_spendable_proofs(self, proofs: List[Proof]): + print(f"Debug: only loading proofs with id: {self.keyset_id}") + proofs = [p for p in proofs if p.id == self.keyset_id or not p.id] + proofs = [p for p in proofs if not p.reserved] + return proofs + async def split_to_send(self, proofs: List[Proof], amount, scnd_secret: str = None): """Like self.split but only considers non-reserved tokens.""" if scnd_secret: logger.debug(f"Spending conditions: {scnd_secret}") - if len([p for p in proofs if not p.reserved]) <= 0: + spendable_proofs = await self._get_spendable_proofs(proofs) + print(f"Balance: {sum([p.amount for p in spendable_proofs])}") + if sum([p.amount for p in spendable_proofs]) < amount: raise Exception("balance too low.") return await self.split( - [p for p in proofs if not p.reserved], amount, scnd_secret + [p for p in spendable_proofs if not p.reserved], amount, scnd_secret ) async def set_reserved(self, proofs: List[Proof], reserved: bool):