diff --git a/cashu/core/base.py b/cashu/core/base.py index 6ee31b6..b91ee9c 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -8,6 +8,7 @@ class Proof(BaseModel): amount: int secret: str = "" C: str + script: str = "" reserved: bool = False # whether this proof is reserved for sending send_id: str = "" # unique ID of send attempt time_created: str = "" diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index fffee09..ffe506e 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -86,6 +86,24 @@ class Ledger: C = PublicKey(bytes.fromhex(proof.C), raw=True) return b_dhke.verify(secret_key, C, proof.secret) + def _verify_script(self, proof: Proof): + print(f"secret: {proof.secret}") + print(f"script: {proof.script}") + print( + f"script_hash: {hashlib.sha256(proof.script.encode('utf-8')).hexdigest()}" + ) + if len(proof.secret.split("SCRIPT:")) != 2: + return True + if len(proof.script) < 16: + raise Exception("Script error: not long enough.") + if ( + hashlib.sha256(proof.script.encode("utf-8")).hexdigest() + != proof.secret.split("SCRIPT:")[1] + ): + raise Exception("Script error: script hash not valid.") + print(f"Script {proof.script} valid.") + return True + def _verify_outputs( self, total: int, amount: int, output_data: List[BlindedMessage] ): @@ -242,7 +260,7 @@ class Ledger: # verify overspending attempt if amount > total: raise Exception("split amount is higher than the total sum.") - # Verify proofs + # Verify secret criteria if not all([self._verify_secret_criteria(p) for p in proofs]): raise Exception("secrets do not match criteria.") # verify that only unique proofs and outputs were used @@ -254,6 +272,9 @@ class Ledger: # Verify proofs if not all([self._verify_proof(p) for p in proofs]): raise Exception("could not verify proofs.") + # Verify scripts + if not all([self._verify_script(p) for p in proofs]): + raise Exception("could not verify scripts.") # Mark proofs as used and prepare new promises await self._invalidate_proofs(proofs) diff --git a/cashu/wallet/cli.py b/cashu/wallet/cli.py index cabd3dc..00a4a52 100755 --- a/cashu/wallet/cli.py +++ b/cashu/wallet/cli.py @@ -124,14 +124,15 @@ async def send(ctx, amount: int, secret: str): @cli.command("receive", help="Receive tokens.") @click.argument("token", type=str) @click.option("--secret", "-s", default="", help="Token spending condition.", type=str) +@click.option("--script", default=None, help="Token unlock script.", type=str) @click.pass_context @coro -async def receive(ctx, token: str, secret: str): +async def receive(ctx, token: str, secret: str, script: str): wallet: Wallet = ctx.obj["WALLET"] wallet.load_mint() wallet.status() proofs = [Proof.from_dict(p) for p in json.loads(base64.urlsafe_b64decode(token))] - _, _ = await wallet.redeem(proofs, secret) + _, _ = await wallet.redeem(proofs, secret, script) wallet.status() diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 2602ebd..9a98fa8 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -106,7 +106,7 @@ class LedgerAPI: @staticmethod def generate_deterministic_secrets(secret, n): """`secret` is the base string that will be tweaked n times""" - return [f"{secret}_{i}" for i in range(n)] + return [f"{i}:{secret}" for i in range(n)] async def mint(self, amounts, payment_hash=None): """Mints new coins and returns a proof of promise.""" @@ -132,7 +132,9 @@ class LedgerAPI: promises = [BlindedSignature.from_dict(p) for p in promises_list] return self._construct_proofs(promises, secrets, rs) - async def split(self, proofs, amount, snd_secret: str = None): + async def split( + self, proofs, amount, snd_secret: str = None, has_script: bool = False + ): """Consume proofs and create new promises based on amount split. If snd_secret is None, random secrets will be generated for the tokens to keep (fst_outputs) and the promises to send (snd_outputs). @@ -147,7 +149,6 @@ class LedgerAPI: amounts = fst_outputs + snd_outputs if snd_secret is None: - logger.debug("Generating random secrets.") secrets = [self._generate_secret() for _ in range(len(amounts))] else: logger.debug(f"Creating proofs with custom secret: {snd_secret}") @@ -243,7 +244,9 @@ class Wallet(LedgerAPI): self.proofs += proofs return proofs - async def redeem(self, proofs: List[Proof], snd_secret: str = None): + async def redeem( + self, proofs: List[Proof], snd_secret: str = None, snd_script: str = None + ): if snd_secret: logger.debug(f"Redeption secret: {snd_secret}") snd_secrets = self.generate_deterministic_secrets(snd_secret, len(proofs)) @@ -251,11 +254,26 @@ class Wallet(LedgerAPI): # 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)) + if snd_script: + logger.debug(f"Unlock script: {snd_script}") + # overload proofs with unlock script + for p in proofs: + p.script = snd_script + return await self.split( + proofs, sum(p["amount"] for p in proofs), has_script=snd_script is not None + ) - async def split(self, proofs: List[Proof], amount: int, snd_secret: str = None): + async def split( + self, + proofs: List[Proof], + amount: int, + snd_secret: str = None, + has_script: bool = False, + ): assert len(proofs) > 0, ValueError("no proofs provided.") - fst_proofs, snd_proofs = await super().split(proofs, amount, snd_secret) + fst_proofs, snd_proofs = await super().split( + proofs, amount, snd_secret, has_script + ) if len(fst_proofs) == 0 and len(snd_proofs) == 0: raise Exception("received no splits.") used_secrets = [p["secret"] for p in proofs] diff --git a/poetry.lock b/poetry.lock index 7224f57..d03a291 100644 --- a/poetry.lock +++ b/poetry.lock @@ -430,6 +430,14 @@ typing-extensions = {version = ">=3.7.2", markers = "python_version < \"3.8\""} [package.extras] testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] +[[package]] +name = "python-bitcoinlib" +version = "0.11.2" +description = "The Swiss Army Knife of the Bitcoin protocol." +category = "main" +optional = false +python-versions = "*" + [[package]] name = "python-dotenv" version = "0.21.0" @@ -632,7 +640,7 @@ testing = ["func-timeout", "jaraco.itertools", "pytest (>=6)", "pytest-black (>= [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "fbb8f977f71b77cf9b6514134111dc466f7fa1f70f8114e47b858d85c39199e4" +content-hash = "be65b895cb013a28ac6a6b2aacc131d140d6eafcd071aa021dbf2d2fbe311802" [metadata.files] anyio = [ @@ -964,6 +972,10 @@ pytest-asyncio = [ {file = "pytest-asyncio-0.19.0.tar.gz", hash = "sha256:ac4ebf3b6207259750bc32f4c1d8fcd7e79739edbc67ad0c58dd150b1d072fed"}, {file = "pytest_asyncio-0.19.0-py3-none-any.whl", hash = "sha256:7a97e37cfe1ed296e2e84941384bdd37c376453912d397ed39293e0916f521fa"}, ] +python-bitcoinlib = [ + {file = "python-bitcoinlib-0.11.2.tar.gz", hash = "sha256:61ba514e0d232cc84741e49862dcedaf37199b40bba252a17edc654f63d13f39"}, + {file = "python_bitcoinlib-0.11.2-py3-none-any.whl", hash = "sha256:78bd4ee717fe805cd760dfdd08765e77b7c7dbef4627f8596285e84953756508"}, +] python-dotenv = [ {file = "python-dotenv-0.21.0.tar.gz", hash = "sha256:b77d08274639e3d34145dfa6c7008e66df0f04b7be7a75fd0d5292c191d79045"}, {file = "python_dotenv-0.21.0-py3-none-any.whl", hash = "sha256:1684eb44636dd462b66c3ee016599815514527ad99965de77f43e0944634a7e5"}, diff --git a/pyproject.toml b/pyproject.toml index aa3937a..c4b4bc1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ ecdsa = "^0.18.0" bitstring = "^3.1.9" secp256k1 = "^0.14.0" sqlalchemy-aio = "^0.17.0" +python-bitcoinlib = "^0.11.2" [tool.poetry.dev-dependencies] black = {version = "^22.8.0", allow-prereleases = true}