From 5d640efc75d0e032c42220f2f27ffe81bca89c74 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 1 Oct 2022 02:33:47 +0200 Subject: [PATCH] determinstic secrets for multiple tokens --- cashu/mint/ledger.py | 4 ++-- cashu/wallet/cli.py | 27 +++++++++++---------- cashu/wallet/crud.py | 16 +++++++++++++ cashu/wallet/wallet.py | 54 ++++++++++++++++++++++++------------------ tests/test_wallet.py | 44 ++++++++++++++++++++++++++++++---- 5 files changed, 103 insertions(+), 42 deletions(-) diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 6e698b6..1c4874f 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -150,7 +150,7 @@ class Ledger: """Checks with the Lightning backend whether an invoice with this payment_hash was paid.""" invoice: Invoice = await get_lightning_invoice(payment_hash, db=self.db) if invoice.issued: - raise Exception("tokens already issued for this invoice") + raise Exception("tokens already issued for this invoice.") status = await WALLET.get_invoice_status(payment_hash) if status.paid: await update_lightning_invoice(payment_hash, issued=True, db=self.db) @@ -239,7 +239,7 @@ class Ledger: raise Exception("split amount is higher than the total sum.") # verify that only unique proofs and outputs were used if not self._verify_no_duplicates(proofs, outputs): - raise Exception("duplicate proofs or promises") + raise Exception("duplicate proofs or promises.") # verify that outputs have the correct amount if not self._verify_outputs(total, amount, outputs): raise Exception("split of promises is not as expected.") diff --git a/cashu/wallet/cli.py b/cashu/wallet/cli.py index b73ece4..c9d5b77 100755 --- a/cashu/wallet/cli.py +++ b/cashu/wallet/cli.py @@ -100,19 +100,19 @@ async def balance(ctx): @cli.command("send", help="Send tokens.") @click.argument("amount", type=int) -@click.option("--secret", "-s", default="", help="Token spending condition.", type=str) +@click.option( + "--secret", "-s", default=None, help="Token spending condition.", type=str +) @click.pass_context @coro async def send(ctx, amount: int, secret: str): wallet: Wallet = ctx.obj["WALLET"] wallet.load_mint() wallet.status() - # TODO: remove this list hack - secrets = [secret] if secret else None - _, send_proofs = await wallet.split_to_send(wallet.proofs, amount, secrets) + _, send_proofs = await wallet.split_to_send(wallet.proofs, amount, secret) await wallet.set_reserved(send_proofs, reserved=True) token = await wallet.serialize_proofs( - send_proofs, hide_secrets=True if secrets else False + send_proofs, hide_secrets=True if secret else False ) print(token) wallet.status() @@ -127,10 +127,8 @@ async def receive(ctx, token: str, secret: str): wallet: Wallet = ctx.obj["WALLET"] wallet.load_mint() wallet.status() - # TODO: remove this list hack - secrets = [secret] if secret else None proofs = [Proof.from_dict(p) for p in json.loads(base64.urlsafe_b64decode(token))] - _, _ = await wallet.redeem(proofs, secrets) + _, _ = await wallet.redeem(proofs, secret) wallet.status() @@ -175,17 +173,22 @@ async def pending(ctx): reserved_proofs = await get_reserved_proofs(wallet.db) if len(reserved_proofs): sorted_proofs = sorted(reserved_proofs, key=itemgetter("send_id")) - for key, value in groupby(sorted_proofs, key=itemgetter("send_id")): + grouped_proofs = groupby(sorted_proofs, key=itemgetter("send_id")) + for i, (key, value) in enumerate(grouped_proofs): grouped_proofs = list(value) token = await wallet.serialize_proofs(grouped_proofs) + token_hidden_secret = await wallet.serialize_proofs( + grouped_proofs, hide_secrets=True + ) reserved_date = datetime.utcfromtimestamp( int(grouped_proofs[0].time_reserved) ).strftime("%Y-%m-%d %H:%M:%S") print( - f"Amount: {sum([p['amount'] for p in grouped_proofs])} sat Sent: {reserved_date} ID: {key}\n" + f"Amount: {sum([p['amount'] for p in grouped_proofs])} sat Sent: {reserved_date} ID: {key} #{i+1}/{len(grouped_proofs)}\n" ) - print(token) - print("") + print(f"With secret: {token}\n\nSecretless: {token_hidden_secret}\n") + if i < len(grouped_proofs) - 1: + print(f"--------------------------\n") wallet.status() diff --git a/cashu/wallet/crud.py b/cashu/wallet/crud.py index c5741b6..0a0e97b 100644 --- a/cashu/wallet/crud.py +++ b/cashu/wallet/crud.py @@ -97,3 +97,19 @@ async def update_proof_reserved( f"UPDATE proofs SET {', '.join(clauses)} WHERE secret = ?", (*values, str(proof.secret)), ) + + +async def secret_used( + secret: str, + db: Database, + conn: Optional[Connection] = None, +): + + rows = await (conn or db).fetchone( + """ + SELECT * from proofs + WHERE secret = ? + """, + (secret), + ) + return rows is not None diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 042f051..e946855 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -26,6 +26,7 @@ from cashu.wallet.crud import ( invalidate_proof, store_proof, update_proof_reserved, + secret_used, ) @@ -97,9 +98,15 @@ class LedgerAPI: payloads.blinded_messages.append(payload) return payloads, rs - def mint(self, amounts, payment_hash=None): + async def _check_used_secrets(self, secrets): + for s in secrets: + if await secret_used(s, db=self.db): + raise Exception(f"secret already used: {s}") + + async def mint(self, amounts, payment_hash=None): """Mints new coins and returns a proof of promise.""" secrets = [self._generate_secret() for s in range(len(amounts))] + await self._check_used_secrets(secrets) payloads, rs = self._construct_outputs(amounts, secrets) resp = requests.post( @@ -120,12 +127,12 @@ class LedgerAPI: promises = [BlindedSignature.from_dict(p) for p in promises_list] return self._construct_proofs(promises, secrets, rs) - def split(self, proofs, amount, snd_secrets: List[str] = None): + async def split(self, proofs, amount, snd_secret: str = None): """Consume proofs and create new promises based on amount split. - If snd_secrets is None, random secrets will be generated for the tokens to keep (fst_outputs) + If snd_secret is None, random secrets will be generated for the tokens to keep (fst_outputs) and the promises to send (snd_outputs). - If snd_secrets is provided, the wallet will create blinded secrets with those to attach a + If snd_secret is provided, the wallet will create blinded secrets with those to attach a predefined spending condition to the tokens they want to send.""" total = sum([p["amount"] for p in proofs]) @@ -134,10 +141,13 @@ class LedgerAPI: snd_outputs = amount_split(snd_amt) amounts = fst_outputs + snd_outputs - if snd_secrets is None: - secrets = [self._generate_secret() for s in range(len(amounts))] + if snd_secret is None: + logger.debug("Generating random secrets.") + secrets = [self._generate_secret() for _ in range(len(amounts))] else: - logger.debug("Creating proofs with spending condition.") + logger.debug(f"Creating proofs with custom secret: {snd_secret}") + # TODO: serialize them here + snd_secrets = [f"{snd_secret}_{i}" for i in range(len(snd_outputs))] assert len(snd_secrets) == len( snd_outputs ), "number of snd_secrets does not match number of ouptus." @@ -149,7 +159,7 @@ class LedgerAPI: assert len(secrets) == len( amounts ), "number of secrets does not match number of outputs" - + await self._check_used_secrets(secrets) payloads, rs = self._construct_outputs(amounts, secrets) split_payload = SplitPayload(proofs=proofs, amount=amount, output_data=payloads) @@ -221,27 +231,27 @@ class Wallet(LedgerAPI): async def mint(self, amount: int, payment_hash: str = None): split = amount_split(amount) - proofs = super().mint(split, payment_hash) + proofs = await super().mint(split, payment_hash) if proofs == []: raise Exception("received no proofs.") await self._store_proofs(proofs) self.proofs += proofs return proofs - async def redeem(self, proofs: List[Proof], snd_secrets: List[str] = None): - if snd_secrets: - logger.debug(f"Redeption secrets: {snd_secrets}") + async def redeem(self, proofs: List[Proof], snd_secret: str = None): + if snd_secret: + logger.debug(f"Redeption secret: {snd_secret}") + # TODO: serialize them here + snd_secrets = [f"{snd_secret}_{i}" for i in range(len(proofs))] assert len(proofs) == len(snd_secrets) # overload proofs with custom secrets for redemption for p, s in zip(proofs, snd_secrets): p.secret = s return await self.split(proofs, sum(p["amount"] for p in proofs)) - async def split( - self, proofs: List[Proof], amount: int, snd_secrets: List[str] = None - ): + async def split(self, proofs: List[Proof], amount: int, snd_secret: str = None): assert len(proofs) > 0, ValueError("no proofs provided.") - fst_proofs, snd_proofs = super().split(proofs, amount, snd_secrets) + fst_proofs, snd_proofs = await super().split(proofs, amount, snd_secret) if len(fst_proofs) == 0 and len(snd_proofs) == 0: raise Exception("received no splits.") used_secrets = [p["secret"] for p in proofs] @@ -274,16 +284,14 @@ class Wallet(LedgerAPI): ).decode() return token - async def split_to_send( - self, proofs: List[Proof], amount, snd_secrets: List[str] = None - ): + async def split_to_send(self, proofs: List[Proof], amount, snd_secret: str = None): """Like self.split but only considers non-reserved tokens.""" - if snd_secrets: - logger.debug(f"Spending conditions: {snd_secrets}") + if snd_secret: + logger.debug(f"Spending conditions: {snd_secret}") if len([p for p in proofs if not p.reserved]) <= 0: raise Exception("balance too low.") return await self.split( - [p for p in proofs if not p.reserved], amount, snd_secrets + [p for p in proofs if not p.reserved], amount, snd_secret ) async def set_reserved(self, proofs: List[Proof], reserved: bool): @@ -321,7 +329,7 @@ class Wallet(LedgerAPI): def status(self): print( - f"Balance: {self.balance} sat (Available: {self.available_balance} sat in {len([p for p in self.proofs if not p.reserved])} tokens)" + f"Balance: {self.balance} sat (available: {self.available_balance} sat in {len([p for p in self.proofs if not p.reserved])} tokens)" ) def proof_amounts(self): diff --git a/tests/test_wallet.py b/tests/test_wallet.py index b8a8b4d..4f147ef 100644 --- a/tests/test_wallet.py +++ b/tests/test_wallet.py @@ -1,3 +1,5 @@ +import time +from re import S from cashu.core.helpers import async_unwrap from cashu.core.migrations import migrate_databases from cashu.wallet import migrations @@ -108,11 +110,12 @@ async def run_test(): assert wallet2.proof_amounts() == [4, 16] # manipulate the proof amount - w1_fst_proofs2[0]["amount"] = 123 - await assert_err( - wallet1.split(w1_fst_proofs2, 20), - "Error: 123", - ) + # w1_fst_proofs2_manipulated = w1_fst_proofs2.copy() + # w1_fst_proofs2_manipulated[0]["amount"] = 123 + # await assert_err( + # wallet1.split(w1_fst_proofs2_manipulated, 20), + # "Error: 123", + # ) # try to split an invalid amount await assert_err( @@ -120,6 +123,37 @@ async def run_test(): "Error: invalid split amount: -500", ) + # mint with secrets + secret = f"asdasd_{time.time()}" + w1_fst_proofs, w1_snd_proofs = await wallet1.split( + wallet1.proofs, 65, snd_secret=secret + ) + + # strip away the secrets + w1_snd_proofs_manipulated = w1_snd_proofs.copy() + for p in w1_snd_proofs_manipulated: + p.secret = "" + await assert_err( + wallet2.redeem(w1_snd_proofs_manipulated), + "Error: duplicate proofs or promises.", + ) + + # redeem with wrong secret + await assert_err( + wallet2.redeem(w1_snd_proofs_manipulated, f"{secret}_asd"), + "Error: could not verify proofs.", + ) + + # redeem with correct secret + await wallet2.redeem(w1_snd_proofs_manipulated, secret) + + # try to redeem them again + # NOTE: token indexing suffix _0 + await assert_err( + wallet2.redeem(w1_snd_proofs_manipulated, secret), + f"Error: tokens already spent. Secret: {secret}_0", + ) + if __name__ == "__main__": async_unwrap(run_test())