From bea17fbd1a2bffef68c98a3b6c57689584915a79 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 24 Dec 2022 01:53:58 +0100 Subject: [PATCH] feat: early multimint support --- cashu/mint/router.py | 14 ++++- cashu/mint/startup.py | 2 +- cashu/wallet/cli.py | 98 ++++++++++++++++++++++++++------ cashu/wallet/crud.py | 4 +- cashu/wallet/wallet.py | 124 +++++++++++++++++++++++++++++++++-------- 5 files changed, 198 insertions(+), 44 deletions(-) diff --git a/cashu/mint/router.py b/cashu/mint/router.py index 09ca2e2..c7fe9f9 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -23,11 +23,23 @@ router: APIRouter = APIRouter() @router.get("/keys") async def keys() -> dict[int, str]: - """Get the public keys of the mint""" + """Get the public keys of the mint of the newest keyset""" keyset = ledger.get_keyset() return keyset +@router.get("/keys/{idBase64Urlsafe}") +async def keyset_keys(idBase64Urlsafe: str) -> dict[int, str]: + """ + Get the public keys of the mint of a specificy keyset id. + The id is encoded in base64_urlsafe and needs to be converted back to + normal base64 before it can be processed. + """ + id = idBase64Urlsafe.replace("-", "+").replace("_", "/") + keyset = ledger.get_keyset(keyset_id=id) + return keyset + + @router.get("/keysets") async def keysets() -> dict[str, list[str]]: """Get all active keysets of the mint""" diff --git a/cashu/mint/startup.py b/cashu/mint/startup.py index a006e07..279fa3b 100644 --- a/cashu/mint/startup.py +++ b/cashu/mint/startup.py @@ -16,7 +16,7 @@ ledger = Ledger( db=Database("mint", "data/mint"), seed=MINT_PRIVATE_KEY, # seed="asd", - derivation_path="0/0/0/0", + derivation_path="0/0/0/1", lightning=LNbitsWallet() if LIGHTNING else None, ) diff --git a/cashu/wallet/cli.py b/cashu/wallet/cli.py index 09ff7d9..3fdfa30 100644 --- a/cashu/wallet/cli.py +++ b/cashu/wallet/cli.py @@ -36,6 +36,7 @@ from cashu.wallet.crud import ( get_lightning_invoices, get_reserved_proofs, get_unused_locks, + get_keyset, ) from cashu.wallet.wallet import Wallet as Wallet @@ -64,14 +65,6 @@ class NaturalOrderGroup(click.Group): ) @click.pass_context def cli(ctx, host: str, walletname: str): - # configure logger - logger.remove() - logger.add(sys.stderr, level="DEBUG" if DEBUG else "INFO") - ctx.ensure_object(dict) - ctx.obj["HOST"] = host - ctx.obj["WALLET_NAME"] = walletname - wallet = Wallet(ctx.obj["HOST"], os.path.join(CASHU_DIR, walletname)) - if TOR and not TorProxy().check_platform(): error_str = "Your settings say TOR=true but the built-in Tor bundle is not supported on your system. Please install Tor manually and set TOR=false and SOCKS_HOST=localhost and SOCKS_PORT=9050 in your Cashu config (recommended) or turn off Tor by setting TOR=false (not recommended). Cashu will not work until you edit your config file accordingly." error_str += "\n\n" @@ -83,6 +76,13 @@ def cli(ctx, host: str, walletname: str): ) raise Exception(error_str) + # configure logger + logger.remove() + logger.add(sys.stderr, level="DEBUG" if DEBUG else "INFO") + ctx.ensure_object(dict) + ctx.obj["HOST"] = host + ctx.obj["WALLET_NAME"] = walletname + wallet = Wallet(ctx.obj["HOST"], os.path.join(CASHU_DIR, walletname)) ctx.obj["WALLET"] = wallet asyncio.run(init_wallet(wallet)) pass @@ -185,13 +185,22 @@ async def invoice(ctx, amount: int, hash: str): @coro async def balance(ctx, verbose): wallet: Wallet = ctx.obj["WALLET"] - keyset_balances = wallet.balance_per_keyset() - if len(keyset_balances) > 1: - print(f"You have balances in {len(keyset_balances)} keysets:") + # keyset_balances = wallet.balance_per_keyset() + # if len(keyset_balances) > 1: + # print(f"You have balances in {len(keyset_balances)} keysets:") + # print("") + # for k, v in keyset_balances.items(): + # print( + # f"Keyset: {k or 'undefined'} Balance: {v['balance']} sat (available: {v['available']} sat)" + # ) + # print("") + mint_balances = await wallet.balance_per_minturl() + if len(mint_balances) > 1: + print(f"You have balances in {len(mint_balances)} mints:") print("") - for k, v in keyset_balances.items(): + for k, v in mint_balances.items(): print( - f"Keyset: {k or 'undefined'} Balance: {v['balance']} sat (available: {v['available']} sat)" + f"Mint: {k or 'undefined'} Balance: {v['balance']} sat (available: {v['available']} sat)" ) print("") if verbose: @@ -221,9 +230,10 @@ async def send(ctx, amount: int, lock: str): wallet.proofs, amount, lock, set_reserved=True ) token = await wallet.serialize_proofs( - send_proofs, hide_secrets=True if lock and not p2sh else False + send_proofs, + hide_secrets=True if lock and not p2sh else False, + include_mints=True, ) - print(token) wallet.status() @@ -249,8 +259,62 @@ async def receive(ctx, token: str, lock: str): signature = p2shscripts[0].signature else: script, signature = None, None - proofs = [Proof(**p) for p in json.loads(base64.urlsafe_b64decode(token))] - _, _ = await wallet.redeem(proofs, scnd_script=script, scnd_siganture=signature) + + try: + # backwards compatibility < 0.7.0: tokens without mint information + proofs = [Proof(**p) for p in json.loads(base64.urlsafe_b64decode(token))] + _, _ = await wallet.redeem(proofs, scnd_script=script, scnd_siganture=signature) + except: + dtoken = json.loads(base64.urlsafe_b64decode(token)) + assert "tokens" in dtoken, Exception("no proofs in token") + # if there is a `mints` field in the token + # we get the mint information in the token and load the keys of each mint + # we then redeem the tokens for each keyset individually + if "mints" in dtoken: + for mint_id in dtoken.get("mints"): + for keyset in set(dtoken["mints"][mint_id]["ks"]): + mint_url = dtoken["mints"][mint_id]["url"] + # init a temporary wallet object + keyset_wallet = Wallet( + mint_url, os.path.join(CASHU_DIR, ctx.obj["WALLET_NAME"]) + ) + + # first we check whether we know this mint already and ask the user + mint_keysets = await get_keyset(id=keyset, db=keyset_wallet.db) + if mint_keysets is None: + click.confirm( + f"Do you want to receive tokens from mint {mint_url}?", + abort=True, + default=True, + ) + + # make sure that this mint indeed supports this keyset + mint_keysets = await keyset_wallet._get_keysets(mint_url) + if keyset in mint_keysets["keysets"]: + # load the keys + await keyset_wallet.load_mint(keyset_id=keyset) + + # redeem proofs of this keyset + redeem_proofs = [ + Proof(**p) + for p in dtoken["tokens"] + if Proof(**p).id == keyset + ] + _, _ = await keyset_wallet.redeem( + redeem_proofs, scnd_script=script, scnd_siganture=signature + ) + keyset_wallet.db.connect() + + # reload proofs to update the main wallet's balance + await wallet.load_proofs() + + else: + # no mint information present, assume default mint + proofs = [Proof(**p) for p in dtoken["tokens"]] + _, _ = await wallet.redeem( + proofs, scnd_script=script, scnd_siganture=signature + ) + wallet.status() diff --git a/cashu/wallet/crud.py b/cashu/wallet/crud.py index adfe5cb..3c1c883 100644 --- a/cashu/wallet/crud.py +++ b/cashu/wallet/crud.py @@ -207,8 +207,8 @@ async def store_keyset( async def get_keyset( - id: str = None, - mint_url: str = None, + id: str = "", + mint_url: str = "", db: Database = None, conn: Optional[Connection] = None, ): diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 5660dff..68a0d02 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -107,24 +107,22 @@ class LedgerAPI: """Returns base64 encoded random string.""" return scrts.token_urlsafe(randombits // 8) - async def _load_mint(self): + async def _load_mint(self, keyset_id: str = ""): """ - Loads the current keys and the active keyset of the map. + Loads the public keys of the mint. Either gets the keys for the specified + `keyset_id` or loads the most recent one from the mint. + Gets and the active keyset ids of the mint and stores in `self.keysets`. """ assert len( self.url ), "Ledger not initialized correctly: mint URL not specified yet. " - # get current keyset - keyset = await self._get_keys(self.url) - # get all active keysets - mint_keysets = [] - try: - keysets_resp = await self._get_keysets(self.url) - mint_keysets = keysets_resp["keysets"] - # store active keysets - except: - pass - self.keysets = mint_keysets if len(mint_keysets) else [keyset.id] + + if keyset_id: + # get requested keyset + keyset = await self._get_keyset(self.url, keyset_id) + else: + # get current keyset + keyset = await self._get_keys(self.url) # store current keyset assert len(keyset.public_keys) > 0, "did not receive keys from mint." @@ -134,6 +132,16 @@ class LedgerAPI: if keyset_local is None: await store_keyset(keyset=keyset, db=self.db) + # get all active keysets of this mint + mint_keysets = [] + try: + keysets_resp = await self._get_keysets(self.url) + mint_keysets = keysets_resp["keysets"] + # store active keysets + except: + pass + self.keysets = mint_keysets if len(mint_keysets) else [keyset.id] + logger.debug(f"Mint keysets: {self.keysets}") logger.debug(f"Current mint keyset: {keyset.id}") @@ -173,7 +181,7 @@ class LedgerAPI: ENDPOINTS """ - async def _get_keys(self, url): + async def _get_keys(self, url: str): self.s = self._set_requests() resp = self.s.get( url + "/keys", @@ -188,7 +196,26 @@ class LedgerAPI: keyset = WalletKeyset(pubkeys=keyset_keys, mint_url=url) return keyset - async def _get_keysets(self, url): + async def _get_keyset(self, url: str, keyset_id: str): + """ + keyset_id is base64, needs to be urlsafe-encoded. + """ + self.s = self._set_requests() + keyset_id_urlsafe = keyset_id.replace("-", "+").replace("_", "/") + resp = self.s.get( + url + f"/keys/{keyset_id_urlsafe}", + ) + resp.raise_for_status() + keys = resp.json() + assert len(keys), Exception("did not receive any keys") + keyset_keys = { + int(amt): PublicKey(bytes.fromhex(val), raw=True) + for amt, val in keys.items() + } + keyset = WalletKeyset(pubkeys=keyset_keys, mint_url=url) + return keyset + + async def _get_keysets(self, url: str): self.s = self._set_requests() resp = self.s.get( url + "/keysets", @@ -359,8 +386,8 @@ class Wallet(LedgerAPI): self.proofs: List[Proof] = [] self.name = name - async def load_mint(self): - await super()._load_mint() + async def load_mint(self, keyset_id: str = ""): + await super()._load_mint(keyset_id) async def load_proofs(self): self.proofs = await get_proofs(db=self.db) @@ -373,6 +400,16 @@ class Wallet(LedgerAPI): def _get_proofs_per_keyset(proofs: List[Proof]): return {key: list(group) for key, group in groupby(proofs, lambda p: p.id)} + async def _get_proofs_per_minturl(self, proofs: List[Proof]): + ret = {} + for id in set([p.id for p in proofs]): + keyset: WalletKeyset = await get_keyset(id=id, db=self.db) + if keyset.mint_url not in ret: + ret[keyset.mint_url] = [p for p in proofs if p.id == id] + else: + ret[keyset.mint_url].extend([p for p in proofs if p.id == id]) + return ret + async def request_mint(self, amount): invoice = super().request_mint(amount) invoice.time_created = int(time.time()) @@ -442,16 +479,47 @@ class Wallet(LedgerAPI): raise Exception("could not pay invoice.") return status["paid"] - @staticmethod - async def serialize_proofs(proofs: List[Proof], hide_secrets=False): + async def serialize_proofs( + self, proofs: List[Proof], hide_secrets=False, include_mints=False + ): + """ + Produces sharable token with proofs and mint information. + """ + # serialize the list of proofs, either with secrets included or without secrets if hide_secrets: proofs_serialized = [p.to_dict_no_secret() for p in proofs] else: proofs_serialized = [p.to_dict() for p in proofs] - token = base64.urlsafe_b64encode( - json.dumps(proofs_serialized).encode() - ).decode() - return token + + token = dict(tokens=proofs_serialized) + + # add mint information to the token, if requested + if include_mints: + + mints = dict() + + # iterate through all proofs and add their keyset to `mints` + for proof in proofs: + if proof.id: + keyset = await get_keyset(id=proof.id, db=self.db) + if keyset: + # TODO: replace this with a mint pubkey + placeholder_mint_id = keyset.mint_url + if placeholder_mint_id not in mints: + id = dict( + url=keyset.mint_url, + ks=[keyset.id], + ) + mints[placeholder_mint_id] = id + else: + if keyset.id not in mints[placeholder_mint_id]["ks"]: + mints[placeholder_mint_id]["ks"].append(keyset.id) + if mints: + token["mints"] = mints + + # encode the token as a base64 string + token_base64 = base64.urlsafe_b64encode(json.dumps(token).encode()).decode() + return token_base64 async def _get_spendable_proofs(self, proofs: List[Proof]): """ @@ -568,5 +636,15 @@ class Wallet(LedgerAPI): for key, proofs in self._get_proofs_per_keyset(self.proofs).items() } + async def balance_per_minturl(self): + balances = await self._get_proofs_per_minturl(self.proofs) + return { + key: { + "balance": sum_proofs(proofs), + "available": sum_proofs([p for p in proofs if not p.reserved]), + } + for key, proofs in balances.items() + } + def proof_amounts(self): return [p["amount"] for p in sorted(self.proofs, key=lambda p: p["amount"])]