mirror of
https://github.com/aljazceru/nutshell.git
synced 2025-12-20 02:24:20 +01:00
[Wallet/mint] P2PK with timelocks (#270)
* p2pk with nostr privatekey and timelocks * add p2pk * fix test * fix test with custom secret * sign whole split transaction * p2pk signature now commits to entire secret and thus to a nonce * use schnorr signatures * revamp P2SH and P2PK with new Secret model * test p2pk * add comments * add nostr private key to tests * fix nostr receive * make format * test redemption after timelock * refactor Server.serialize() * sign sha256(secret) * add optional refund pubkey that triggers after timelock * use nostr private key for now (including nsec parser) * use nostr private key and fix tests * bump version to 0.12.2
This commit is contained in:
@@ -17,7 +17,7 @@ TOR=TRUE
|
||||
|
||||
# NOSTR
|
||||
# nostr private key to which to receive tokens to
|
||||
NOSTR_PRIVATE_KEY=nostr_privatekey_here_hex_or_bech32
|
||||
NOSTR_PRIVATE_KEY=nostr_privatekey_here_hex_or_bech32_nsec
|
||||
# nostr relays (comma separated list)
|
||||
NOSTR_RELAYS=["wss://nostr-pub.wellorder.net"]
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ cashu info
|
||||
|
||||
Returns:
|
||||
```bash
|
||||
Version: 0.12.1
|
||||
Version: 0.12.2
|
||||
Debug: False
|
||||
Cashu dir: /home/user/.cashu
|
||||
Wallet: wallet
|
||||
|
||||
@@ -9,13 +9,62 @@ from pydantic import BaseModel
|
||||
from .crypto.keys import derive_keys, derive_keyset_id, derive_pubkeys
|
||||
from .crypto.secp import PrivateKey, PublicKey
|
||||
from .legacy import derive_keys_backwards_compatible_insecure_pre_0_12
|
||||
from .p2pk import sign_p2pk_sign
|
||||
|
||||
# ------- PROOFS -------
|
||||
|
||||
|
||||
class SecretKind:
|
||||
P2SH = "P2SH"
|
||||
P2PK = "P2PK"
|
||||
|
||||
|
||||
class Tags(BaseModel):
|
||||
__root__: List[List[str]]
|
||||
|
||||
def get_tag(self, tag_name: str) -> Union[str, None]:
|
||||
for tag in self.__root__:
|
||||
if tag[0] == tag_name:
|
||||
return tag[1]
|
||||
return None
|
||||
|
||||
|
||||
class Secret(BaseModel):
|
||||
"""Describes spending condition encoded in the secret field of a Proof."""
|
||||
|
||||
kind: str
|
||||
data: str
|
||||
nonce: Union[None, str] = None
|
||||
timelock: Union[None, int] = None
|
||||
tags: Union[None, Tags] = None
|
||||
|
||||
def serialize(self) -> str:
|
||||
data_dict: Dict[str, Any] = {
|
||||
"data": self.data,
|
||||
"nonce": self.nonce or PrivateKey().serialize()[:32],
|
||||
}
|
||||
if self.timelock:
|
||||
data_dict["timelock"] = self.timelock
|
||||
if self.tags:
|
||||
data_dict["tags"] = self.tags.__root__
|
||||
logger.debug(
|
||||
json.dumps(
|
||||
[self.kind, data_dict],
|
||||
)
|
||||
)
|
||||
return json.dumps(
|
||||
[self.kind, data_dict],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def deserialize(cls, data: str):
|
||||
kind, kwargs = json.loads(data)
|
||||
return cls(kind=kind, **kwargs)
|
||||
|
||||
|
||||
class P2SHScript(BaseModel):
|
||||
"""
|
||||
Describes spending condition of a Proof
|
||||
Unlocks P2SH spending condition of a Proof
|
||||
"""
|
||||
|
||||
script: str
|
||||
@@ -34,7 +83,8 @@ class Proof(BaseModel):
|
||||
amount: int = 0
|
||||
secret: str = "" # secret or message to be blinded and signed
|
||||
C: str = "" # signature on secret, unblinded by wallet
|
||||
script: Union[P2SHScript, None] = None # P2SH spending condition
|
||||
p2pksig: Optional[str] = None # P2PK signature
|
||||
p2shscript: Union[P2SHScript, None] = None # P2SH spending condition
|
||||
reserved: Union[
|
||||
None, bool
|
||||
] = False # whether this proof is reserved for sending, used for coin management in the wallet
|
||||
@@ -174,6 +224,19 @@ class PostSplitRequest(BaseModel):
|
||||
proofs: List[Proof]
|
||||
amount: int
|
||||
outputs: List[BlindedMessage]
|
||||
# signature: Optional[str] = None
|
||||
|
||||
# def sign(self, private_key: PrivateKey):
|
||||
# """
|
||||
# Create a signed split request. The signature is over the `proofs` and `outputs` fields.
|
||||
# """
|
||||
# # message = json.dumps(self.proofs).encode("utf-8") + json.dumps(
|
||||
# # self.outputs
|
||||
# # ).encode("utf-8")
|
||||
# message = json.dumps(self.dict(include={"proofs": ..., "outputs": ...})).encode(
|
||||
# "utf-8"
|
||||
# )
|
||||
# self.signature = sign_p2pk_sign(message, private_key)
|
||||
|
||||
|
||||
class PostSplitResponse(BaseModel):
|
||||
|
||||
39
cashu/core/p2pk.py
Normal file
39
cashu/core/p2pk.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import hashlib
|
||||
|
||||
from cashu.core.crypto.secp import PrivateKey, PublicKey
|
||||
|
||||
|
||||
def sign_p2pk_sign(message: bytes, private_key: PrivateKey):
|
||||
# ecdsa version
|
||||
# signature = private_key.ecdsa_serialize(private_key.ecdsa_sign(message))
|
||||
signature = private_key.schnorr_sign(
|
||||
hashlib.sha256(message).digest(), None, raw=True
|
||||
)
|
||||
return signature.hex()
|
||||
|
||||
|
||||
def verify_p2pk_signature(message: bytes, pubkey: PublicKey, signature: bytes):
|
||||
# ecdsa version
|
||||
# return pubkey.ecdsa_verify(message, pubkey.ecdsa_deserialize(signature))
|
||||
return pubkey.schnorr_verify(
|
||||
hashlib.sha256(message).digest(), signature, None, raw=True
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# generate keys
|
||||
private_key_bytes = b"12300000000000000000000000000123"
|
||||
private_key = PrivateKey(private_key_bytes, raw=True)
|
||||
print(private_key.serialize())
|
||||
public_key = private_key.pubkey
|
||||
assert public_key
|
||||
print(public_key.serialize().hex())
|
||||
|
||||
# sign message (=pubkey)
|
||||
message = public_key.serialize()
|
||||
signature = private_key.ecdsa_serialize(private_key.ecdsa_sign(message))
|
||||
print(signature.hex())
|
||||
|
||||
# verify
|
||||
pubkey_verify = PublicKey(message, raw=True)
|
||||
print(public_key.ecdsa_verify(message, pubkey_verify.ecdsa_deserialize(signature)))
|
||||
@@ -79,28 +79,28 @@ def step3_bob_verify_script(txin_signature, txin_redeemScript, tx):
|
||||
raise Exception("Script execution failed:", e)
|
||||
|
||||
|
||||
def verify_script(txin_redeemScript_b64, txin_signature_b64):
|
||||
def verify_bitcoin_script(txin_redeemScript_b64, txin_signature_b64):
|
||||
txin_redeemScript = CScript(base64.urlsafe_b64decode(txin_redeemScript_b64))
|
||||
print("Redeem script:", txin_redeemScript.__repr__())
|
||||
# print("Redeem script:", txin_redeemScript.__repr__())
|
||||
# txin_redeemScript = CScript([2, 3, OP_LESSTHAN, OP_VERIFY])
|
||||
txin_signature = CScript(value=base64.urlsafe_b64decode(txin_signature_b64))
|
||||
|
||||
txin_p2sh_address = step1_carol_create_p2sh_address(txin_redeemScript)
|
||||
print(f"Bob recreates secret: P2SH:{txin_p2sh_address}")
|
||||
# print(f"Bob recreates secret: P2SH:{txin_p2sh_address}")
|
||||
# MINT checks that P2SH:txin_p2sh_address has not been spent yet
|
||||
# ...
|
||||
tx, _ = step1_bob_carol_create_tx(txin_p2sh_address)
|
||||
|
||||
print(
|
||||
f"Bob verifies:\nscript: {txin_redeemScript_b64}\nsignature: {txin_signature_b64}\n"
|
||||
)
|
||||
# print(
|
||||
# f"Bob verifies:\nscript: {txin_redeemScript_b64}\nsignature: {txin_signature_b64}\n"
|
||||
# )
|
||||
script_valid = step3_bob_verify_script(txin_signature, txin_redeemScript, tx)
|
||||
# MINT redeems tokens and stores P2SH:txin_p2sh_address
|
||||
# ...
|
||||
if script_valid:
|
||||
print("Successfull.")
|
||||
else:
|
||||
print("Error.")
|
||||
# if script_valid:
|
||||
# print("Successfull.")
|
||||
# else:
|
||||
# print("Error.")
|
||||
return txin_p2sh_address, script_valid
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from pydantic import BaseSettings, Extra, Field
|
||||
|
||||
env = Env()
|
||||
|
||||
VERSION = "0.12.1"
|
||||
VERSION = "0.12.2"
|
||||
|
||||
|
||||
def find_env_file():
|
||||
@@ -98,9 +98,15 @@ class WalletSettings(CashuSettings):
|
||||
]
|
||||
)
|
||||
|
||||
timelock_delta_seconds: int = Field(default=86400) # 1 day
|
||||
|
||||
|
||||
class Settings(
|
||||
EnvSettings, MintSettings, MintInformation, WalletSettings, CashuSettings
|
||||
EnvSettings,
|
||||
MintSettings,
|
||||
MintInformation,
|
||||
WalletSettings,
|
||||
CashuSettings,
|
||||
):
|
||||
version: str = Field(default=VERSION)
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import time
|
||||
from typing import Dict, List, Literal, Optional, Set, Union
|
||||
|
||||
from loguru import logger
|
||||
@@ -12,13 +14,16 @@ from ..core.base import (
|
||||
MintKeyset,
|
||||
MintKeysets,
|
||||
Proof,
|
||||
Secret,
|
||||
SecretKind,
|
||||
)
|
||||
from ..core.crypto import b_dhke
|
||||
from ..core.crypto.keys import derive_pubkey, random_hash
|
||||
from ..core.crypto.secp import PublicKey
|
||||
from ..core.db import Connection, Database
|
||||
from ..core.helpers import fee_reserve, sum_proofs
|
||||
from ..core.script import verify_script
|
||||
from ..core.p2pk import verify_p2pk_signature
|
||||
from ..core.script import verify_bitcoin_script
|
||||
from ..core.settings import settings
|
||||
from ..core.split import amount_split
|
||||
from ..lightning.base import Wallet
|
||||
@@ -185,7 +190,7 @@ class Ledger:
|
||||
"""Verifies that a secret is present and is not too long (DOS prevention)."""
|
||||
if proof.secret is None or proof.secret == "":
|
||||
raise Exception("no secret in proof.")
|
||||
if len(proof.secret) > 64:
|
||||
if len(proof.secret) > 512:
|
||||
raise Exception("secret too long.")
|
||||
return True
|
||||
|
||||
@@ -209,35 +214,90 @@ class Ledger:
|
||||
C = PublicKey(bytes.fromhex(proof.C), raw=True)
|
||||
return b_dhke.verify(private_key_amount, C, proof.secret)
|
||||
|
||||
def _verify_script(self, idx: int, proof: Proof) -> bool:
|
||||
def _verify_spending_conditions(self, proof: Proof) -> bool:
|
||||
"""
|
||||
Verify bitcoin script in proof.script commited to by <address> in proof.secret.
|
||||
proof.secret format: P2SH:<address>:<secret>
|
||||
Verify spending conditions:
|
||||
Condition: P2SH - Witnesses proof.p2shscript
|
||||
Condition: P2PK - Witness: proof.p2pksig
|
||||
|
||||
"""
|
||||
# if no script is given
|
||||
if (
|
||||
proof.script is None
|
||||
or proof.script.script is None
|
||||
or proof.script.signature is None
|
||||
):
|
||||
if len(proof.secret.split("P2SH:")) == 2:
|
||||
# secret indicates a script but no script is present
|
||||
return False
|
||||
else:
|
||||
# secret indicates no script, so treat script as valid
|
||||
# P2SH
|
||||
try:
|
||||
secret = Secret.deserialize(proof.secret)
|
||||
except Exception as e:
|
||||
# secret is not a spending condition so we treat is a normal secret
|
||||
return True
|
||||
if secret.kind == SecretKind.P2SH:
|
||||
# check if timelock is in the past
|
||||
now = time.time()
|
||||
if secret.timelock and secret.timelock < now:
|
||||
logger.trace(f"p2sh timelock ran out ({secret.timelock}<{now}).")
|
||||
return True
|
||||
logger.trace(f"p2sh timelock still active ({secret.timelock}>{now}).")
|
||||
|
||||
if (
|
||||
proof.p2shscript is None
|
||||
or proof.p2shscript.script is None
|
||||
or proof.p2shscript.signature is None
|
||||
):
|
||||
# no script present although secret indicates one
|
||||
raise Exception("no script in proof.")
|
||||
|
||||
# execute and verify P2SH
|
||||
txin_p2sh_address, valid = verify_script(
|
||||
proof.script.script, proof.script.signature
|
||||
txin_p2sh_address, valid = verify_bitcoin_script(
|
||||
proof.p2shscript.script, proof.p2shscript.signature
|
||||
)
|
||||
if valid:
|
||||
if not valid:
|
||||
raise Exception("script invalid.")
|
||||
# check if secret commits to script address
|
||||
# format: P2SH:<address>:<secret>
|
||||
assert len(proof.secret.split(":")) == 3, "secret format wrong."
|
||||
assert proof.secret.split(":")[1] == str(
|
||||
assert secret.data == str(
|
||||
txin_p2sh_address
|
||||
), f"secret does not contain correct P2SH address: {proof.secret.split(':')[1]} is not {txin_p2sh_address}."
|
||||
return valid
|
||||
), f"secret does not contain correct P2SH address: {secret.data} is not {txin_p2sh_address}."
|
||||
return True
|
||||
|
||||
# P2PK
|
||||
if secret.kind == SecretKind.P2PK:
|
||||
# check if timelock is in the past
|
||||
now = time.time()
|
||||
if secret.timelock and secret.timelock < now:
|
||||
logger.trace(f"p2pk timelock ran out ({secret.timelock}<{now}).")
|
||||
# check tags if a refund pubkey is present.
|
||||
# If yes, we demand the signature to be from the refund pubkey
|
||||
if secret.tags and secret.tags.get_tag("refund"):
|
||||
signature_pubkey = secret.tags.get_tag("refund")
|
||||
else:
|
||||
# if no refund pubkey is present and the timelock has expired
|
||||
# the token can be spent by anyone
|
||||
return True
|
||||
else:
|
||||
# the timelock is still active, therefore we demand the signature
|
||||
# to be from the pubkey in the data field
|
||||
signature_pubkey = secret.data
|
||||
logger.trace(f"p2pk timelock still active ({secret.timelock}>{now}).")
|
||||
|
||||
# now we check the signature
|
||||
if not proof.p2pksig:
|
||||
# no signature present although secret indicates one
|
||||
raise Exception("no p2pk signature in proof.")
|
||||
|
||||
# we parse the secret as a P2PK commitment
|
||||
# assert len(proof.secret.split(":")) == 5, "p2pk secret format invalid."
|
||||
|
||||
# check signature proof.p2pksig against pubkey
|
||||
# we expect the signature to be on the pubkey (=message) itself
|
||||
assert signature_pubkey, "no signature pubkey present."
|
||||
assert verify_p2pk_signature(
|
||||
message=secret.serialize().encode("utf-8"),
|
||||
pubkey=PublicKey(bytes.fromhex(signature_pubkey), raw=True),
|
||||
signature=bytes.fromhex(proof.p2pksig),
|
||||
), "p2pk signature invalid."
|
||||
logger.trace(proof.p2pksig)
|
||||
logger.trace("p2pk signature valid.")
|
||||
|
||||
return True
|
||||
|
||||
# no spending contition
|
||||
return True
|
||||
|
||||
def _verify_outputs(
|
||||
self, total: int, amount: int, outputs: List[BlindedMessage]
|
||||
@@ -509,7 +569,7 @@ class Ledger:
|
||||
Exception: BDHKE verification failed.
|
||||
"""
|
||||
# Verify scripts
|
||||
if not all([self._verify_script(i, p) for i, p in enumerate(proofs)]):
|
||||
if not all([self._verify_spending_conditions(p) for p in proofs]):
|
||||
raise Exception("script validation failed.")
|
||||
# Verify secret criteria
|
||||
if not all([self._verify_secret_criteria(p) for p in proofs]):
|
||||
|
||||
@@ -220,7 +220,6 @@ async def send_command(
|
||||
@router.post("/receive", name="Receive tokens", response_model=ReceiveResponse)
|
||||
async def receive_command(
|
||||
token: str = Query(default=None, description="Token to receive"),
|
||||
lock: str = Query(default=None, description="Unlock tokens"),
|
||||
nostr: bool = Query(default=False, description="Receive tokens via nostr"),
|
||||
all: bool = Query(default=False, description="Receive all pending tokens"),
|
||||
):
|
||||
@@ -228,7 +227,7 @@ async def receive_command(
|
||||
if token:
|
||||
tokenObj: TokenV3 = deserialize_token_from_string(token)
|
||||
await verify_mints(wallet, tokenObj)
|
||||
balance = await receive(wallet, tokenObj, lock)
|
||||
balance = await receive(wallet, tokenObj)
|
||||
elif nostr:
|
||||
await receive_nostr(wallet)
|
||||
balance = wallet.available_balance
|
||||
@@ -241,7 +240,7 @@ async def receive_command(
|
||||
token = await wallet.serialize_proofs(proofs)
|
||||
tokenObj = deserialize_token_from_string(token)
|
||||
await verify_mints(wallet, tokenObj)
|
||||
balance = await receive(wallet, tokenObj, lock)
|
||||
balance = await receive(wallet, tokenObj)
|
||||
else:
|
||||
raise Exception("enter token or use either flag --nostr or --all.")
|
||||
assert balance
|
||||
@@ -337,9 +336,8 @@ async def pending(
|
||||
|
||||
@router.get("/lock", name="Generate receiving lock", response_model=LockResponse)
|
||||
async def lock():
|
||||
p2shscript = await wallet.create_p2sh_lock()
|
||||
txin_p2sh_address = p2shscript.address
|
||||
return LockResponse(P2SH=txin_p2sh_address)
|
||||
address = await wallet.create_p2sh_address_and_store()
|
||||
return LockResponse(P2SH=address)
|
||||
|
||||
|
||||
@router.get("/locks", name="Show unused receiving locks", response_model=LocksResponse)
|
||||
|
||||
@@ -347,33 +347,25 @@ async def send_command(
|
||||
|
||||
@cli.command("receive", help="Receive tokens.")
|
||||
@click.argument("token", type=str, default="")
|
||||
@click.option("--lock", "-l", default=None, help="Unlock tokens.", type=str)
|
||||
@click.option(
|
||||
"--nostr", "-n", default=False, is_flag=True, help="Receive tokens via nostr."
|
||||
"--nostr",
|
||||
"-n",
|
||||
default=False,
|
||||
is_flag=True,
|
||||
help="Receive tokens via nostr." "receive",
|
||||
)
|
||||
@click.option(
|
||||
"--all", "-a", default=False, is_flag=True, help="Receive all pending tokens."
|
||||
)
|
||||
@click.option(
|
||||
"--verbose",
|
||||
"-v",
|
||||
help="Display more information.",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
type=bool,
|
||||
)
|
||||
@click.pass_context
|
||||
@coro
|
||||
async def receive_cli(
|
||||
ctx: Context,
|
||||
token: str,
|
||||
lock: str,
|
||||
nostr: bool,
|
||||
all: bool,
|
||||
verbose: bool,
|
||||
):
|
||||
wallet: Wallet = ctx.obj["WALLET"]
|
||||
wallet.status()
|
||||
|
||||
if token:
|
||||
tokenObj = deserialize_token_from_string(token)
|
||||
@@ -385,9 +377,9 @@ async def receive_cli(
|
||||
)
|
||||
await verify_mint(mint_wallet, mint_url)
|
||||
|
||||
await receive(wallet, tokenObj, lock)
|
||||
await receive(wallet, tokenObj)
|
||||
elif nostr:
|
||||
await receive_nostr(wallet, verbose)
|
||||
await receive_nostr(wallet)
|
||||
elif all:
|
||||
reserved_proofs = await get_reserved_proofs(wallet.db)
|
||||
if len(reserved_proofs):
|
||||
@@ -402,7 +394,7 @@ async def receive_cli(
|
||||
mint_url, os.path.join(settings.cashu_dir, wallet.name)
|
||||
)
|
||||
await verify_mint(mint_wallet, mint_url)
|
||||
await receive(wallet, tokenObj, lock)
|
||||
await receive(wallet, tokenObj)
|
||||
else:
|
||||
print("Error: enter token or use either flag --nostr or --all.")
|
||||
|
||||
@@ -515,24 +507,36 @@ async def pending(ctx: Context, legacy, number: int, offset: int):
|
||||
|
||||
|
||||
@cli.command("lock", help="Generate receiving lock.")
|
||||
@click.option(
|
||||
"--p2sh",
|
||||
"-p",
|
||||
default=False,
|
||||
is_flag=True,
|
||||
help="Create P2SH lock.",
|
||||
type=bool,
|
||||
)
|
||||
@click.pass_context
|
||||
@coro
|
||||
async def lock(ctx):
|
||||
async def lock(ctx, p2sh):
|
||||
wallet: Wallet = ctx.obj["WALLET"]
|
||||
p2shscript = await wallet.create_p2sh_lock()
|
||||
txin_p2sh_address = p2shscript.address
|
||||
if p2sh:
|
||||
address = await wallet.create_p2sh_address_and_store()
|
||||
lock_str = f"P2SH:{address}"
|
||||
print("---- Pay to script hash (P2SH) ----\n")
|
||||
else:
|
||||
pubkey = await wallet.create_p2pk_pubkey()
|
||||
lock_str = f"P2PK:{pubkey}"
|
||||
print("---- Pay to public key (P2PK) ----\n")
|
||||
|
||||
print("Use a lock to receive tokens that only you can unlock.")
|
||||
print("")
|
||||
print(f"Public receiving lock: P2SH:{txin_p2sh_address}")
|
||||
print(f"Public receiving lock: {lock_str}")
|
||||
print("")
|
||||
print(
|
||||
f"Anyone can send tokens to this lock:\n\ncashu send <amount> --lock P2SH:{txin_p2sh_address}"
|
||||
f"Anyone can send tokens to this lock:\n\ncashu send <amount> --lock {lock_str}"
|
||||
)
|
||||
print("")
|
||||
print(
|
||||
f"Only you can receive tokens from this lock:\n\ncashu receive <token> --lock P2SH:{txin_p2sh_address}\n"
|
||||
)
|
||||
print(f"Only you can receive tokens from this lock: cashu receive <token>")
|
||||
|
||||
|
||||
@cli.command("locks", help="Show unused receiving locks.")
|
||||
@@ -540,17 +544,21 @@ async def lock(ctx):
|
||||
@coro
|
||||
async def locks(ctx):
|
||||
wallet: Wallet = ctx.obj["WALLET"]
|
||||
# P2PK lock
|
||||
pubkey = await wallet.create_p2pk_pubkey()
|
||||
lock_str = f"P2PK:{pubkey}"
|
||||
print("---- Pay to public key (P2PK) lock ----\n")
|
||||
print(f"Lock: {lock_str}")
|
||||
# P2SH locks
|
||||
locks = await get_unused_locks(db=wallet.db)
|
||||
if len(locks):
|
||||
print("")
|
||||
print(f"--------------------------\n")
|
||||
print("---- Pay to script hash (P2SH) locks ----\n")
|
||||
for l in locks:
|
||||
print(f"Address: {l.address}")
|
||||
print(f"Lock: P2SH:{l.address}")
|
||||
print(f"Script: {l.script}")
|
||||
print(f"Signature: {l.signature}")
|
||||
print("")
|
||||
print(f"Receive: cashu receive <token> --lock P2SH:{l.address}")
|
||||
print("")
|
||||
print(f"--------------------------\n")
|
||||
else:
|
||||
print("No locks found. Create one using: cashu lock")
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
|
||||
import click
|
||||
from loguru import logger
|
||||
|
||||
from ..core.base import TokenV1, TokenV2, TokenV3, TokenV3Token
|
||||
from ..core.base import Secret, SecretKind, TokenV1, TokenV2, TokenV3, TokenV3Token
|
||||
from ..core.helpers import sum_proofs
|
||||
from ..core.migrations import migrate_databases
|
||||
from ..core.settings import settings
|
||||
@@ -21,12 +23,7 @@ async def init_wallet(wallet: Wallet, load_proofs: bool = True):
|
||||
await wallet.load_proofs(reload=True)
|
||||
|
||||
|
||||
async def redeem_TokenV3_multimint(
|
||||
wallet: Wallet,
|
||||
token: TokenV3,
|
||||
script,
|
||||
signature,
|
||||
):
|
||||
async def redeem_TokenV3_multimint(wallet: Wallet, token: TokenV3):
|
||||
"""
|
||||
Helper function to iterate thruogh a token with multiple mints and redeem them from
|
||||
these mints one keyset at a time.
|
||||
@@ -43,9 +40,7 @@ async def redeem_TokenV3_multimint(
|
||||
await mint_wallet.load_mint()
|
||||
# redeem proofs of this keyset
|
||||
redeem_proofs = [p for p in t.proofs if p.id == keyset]
|
||||
_, _ = await mint_wallet.redeem(
|
||||
redeem_proofs, scnd_script=script, scnd_siganture=signature
|
||||
)
|
||||
_, _ = await mint_wallet.redeem(redeem_proofs)
|
||||
print(f"Received {sum_proofs(redeem_proofs)} sats")
|
||||
|
||||
|
||||
@@ -113,20 +108,9 @@ def deserialize_token_from_string(token: str) -> TokenV3:
|
||||
async def receive(
|
||||
wallet: Wallet,
|
||||
tokenObj: TokenV3,
|
||||
lock: str,
|
||||
):
|
||||
# check for P2SH locks
|
||||
if lock:
|
||||
# load the script and signature of this address from the database
|
||||
assert len(lock.split("P2SH:")) == 2, Exception(
|
||||
"lock has wrong format. Expected P2SH:<address>."
|
||||
)
|
||||
address_split = lock.split("P2SH:")[1]
|
||||
p2shscripts = await get_unused_locks(address_split, db=wallet.db)
|
||||
assert len(p2shscripts) == 1, Exception("lock not found.")
|
||||
script, signature = p2shscripts[0].script, p2shscripts[0].signature
|
||||
else:
|
||||
script, signature = None, None
|
||||
logger.debug(f"receive: {tokenObj}")
|
||||
proofs = [p for t in tokenObj.token for p in t.proofs]
|
||||
|
||||
includes_mint_info: bool = any([t.mint for t in tokenObj.token])
|
||||
|
||||
@@ -135,13 +119,10 @@ async def receive(
|
||||
await redeem_TokenV3_multimint(
|
||||
wallet,
|
||||
tokenObj,
|
||||
script,
|
||||
signature,
|
||||
)
|
||||
else:
|
||||
# this is very legacy code, virtually any token should have mint information
|
||||
# no mint information present, we extract the proofs and use wallet's default mint
|
||||
|
||||
proofs = [p for t in tokenObj.token for p in t.proofs]
|
||||
# first we load the mint URL from the DB
|
||||
keyset_in_token = proofs[0].id
|
||||
assert keyset_in_token
|
||||
@@ -155,7 +136,7 @@ async def receive(
|
||||
os.path.join(settings.cashu_dir, wallet.name),
|
||||
)
|
||||
await mint_wallet.load_mint(keyset_in_token)
|
||||
_, _ = await mint_wallet.redeem(proofs, script, signature)
|
||||
_, _ = await mint_wallet.redeem(proofs)
|
||||
print(f"Received {sum_proofs(proofs)} sats")
|
||||
|
||||
# reload main wallet so the balance updates
|
||||
@@ -170,19 +151,34 @@ async def send(
|
||||
"""
|
||||
Prints token to send to stdout.
|
||||
"""
|
||||
secret_lock = None
|
||||
if lock:
|
||||
assert len(lock) > 21, Exception(
|
||||
"Error: lock has to be at least 22 characters long."
|
||||
)
|
||||
p2sh = False
|
||||
if lock and len(lock.split("P2SH:")) == 2:
|
||||
p2sh = True
|
||||
if not lock.startswith("P2SH:") and not lock.startswith("P2PK:"):
|
||||
raise Exception("Error: lock has to start with P2SH: or P2PK:")
|
||||
# we add a time lock to the P2PK lock by appending the current unix time + 14 days
|
||||
# we use datetime because it's easier to read
|
||||
if lock.startswith("P2PK:") or lock.startswith("P2SH:"):
|
||||
logger.debug(f"Locking token to: {lock}")
|
||||
logger.debug(
|
||||
f"Adding a time lock of {settings.timelock_delta_seconds} seconds."
|
||||
)
|
||||
if lock.startswith("P2SH:"):
|
||||
secret_lock = await wallet.create_p2sh_lock(
|
||||
lock.split(":")[1], timelock=settings.timelock_delta_seconds
|
||||
)
|
||||
elif lock.startswith("P2PK:"):
|
||||
secret_lock = await wallet.create_p2pk_lock(
|
||||
lock.split(":")[1], timelock=settings.timelock_delta_seconds
|
||||
)
|
||||
|
||||
await wallet.load_proofs()
|
||||
if split:
|
||||
await wallet.load_mint()
|
||||
_, send_proofs = await wallet.split_to_send(
|
||||
wallet.proofs, amount, lock, set_reserved=True
|
||||
wallet.proofs, amount, secret_lock, set_reserved=True
|
||||
)
|
||||
else:
|
||||
# get a proof with specific amount
|
||||
|
||||
@@ -3,6 +3,7 @@ import threading
|
||||
import time
|
||||
|
||||
import click
|
||||
from loguru import logger
|
||||
from requests.exceptions import ConnectionError
|
||||
|
||||
from ..core.settings import settings
|
||||
@@ -10,7 +11,7 @@ from ..nostr.nostr.client.client import NostrClient
|
||||
from ..nostr.nostr.event import Event
|
||||
from ..nostr.nostr.key import PublicKey
|
||||
from .crud import get_nostr_last_check_timestamp, set_nostr_last_check_timestamp
|
||||
from .helpers import receive
|
||||
from .helpers import deserialize_token_from_string, receive
|
||||
from .wallet import Wallet
|
||||
|
||||
|
||||
@@ -95,7 +96,6 @@ async def send_nostr(
|
||||
|
||||
async def receive_nostr(
|
||||
wallet: Wallet,
|
||||
verbose: bool = False,
|
||||
):
|
||||
if settings.nostr_private_key is None:
|
||||
print(
|
||||
@@ -107,34 +107,34 @@ async def receive_nostr(
|
||||
private_key=settings.nostr_private_key, relays=settings.nostr_relays
|
||||
)
|
||||
print(f"Your nostr public key: {client.public_key.bech32()}")
|
||||
if verbose:
|
||||
print(f"Your nostr private key (do not share!): {client.private_key.bech32()}")
|
||||
# print(f"Your nostr private key (do not share!): {client.private_key.bech32()}")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
def get_token_callback(event: Event, decrypted_content):
|
||||
if verbose:
|
||||
print(
|
||||
logger.debug(
|
||||
f"From {event.public_key[:3]}..{event.public_key[-3:]}: {decrypted_content}"
|
||||
)
|
||||
try:
|
||||
# call the receive method
|
||||
|
||||
tokenObj = deserialize_token_from_string(decrypted_content)
|
||||
asyncio.run(
|
||||
receive(
|
||||
wallet,
|
||||
decrypted_content,
|
||||
"",
|
||||
tokenObj,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
pass
|
||||
|
||||
# determine timestamp of last check so we don't scan all historical DMs
|
||||
last_check = await get_nostr_last_check_timestamp(db=wallet.db)
|
||||
logger.debug(f"Last check: {last_check}")
|
||||
if last_check:
|
||||
last_check -= 60 * 60 # 1 hour tolerance
|
||||
await set_nostr_last_check_timestamp(timestamp=int(time.time()), db=wallet.db)
|
||||
|
||||
await set_nostr_last_check_timestamp(timestamp=int(time.time()), db=wallet.db)
|
||||
logger.debug("Starting Nostr DM thread")
|
||||
t = threading.Thread(
|
||||
target=client.get_dm,
|
||||
args=(client.public_key, get_token_callback, {"since": last_check}),
|
||||
|
||||
@@ -5,6 +5,7 @@ import math
|
||||
import secrets as scrts
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from itertools import groupby
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
|
||||
@@ -30,6 +31,9 @@ from ..core.base import (
|
||||
PostMintResponseLegacy,
|
||||
PostSplitRequest,
|
||||
Proof,
|
||||
Secret,
|
||||
SecretKind,
|
||||
Tags,
|
||||
TokenV2,
|
||||
TokenV2Mint,
|
||||
TokenV3,
|
||||
@@ -41,6 +45,7 @@ from ..core.crypto import b_dhke
|
||||
from ..core.crypto.secp import PrivateKey, PublicKey
|
||||
from ..core.db import Database
|
||||
from ..core.helpers import calculate_number_of_blank_outputs, sum_proofs
|
||||
from ..core.p2pk import sign_p2pk_sign
|
||||
from ..core.script import (
|
||||
step0_carol_checksig_redeemscrip,
|
||||
step0_carol_privkey,
|
||||
@@ -49,10 +54,12 @@ from ..core.script import (
|
||||
)
|
||||
from ..core.settings import settings
|
||||
from ..core.split import amount_split
|
||||
from ..nostr.nostr.client.client import NostrClient
|
||||
from ..tor.tor import TorProxy
|
||||
from ..wallet.crud import (
|
||||
get_keyset,
|
||||
get_proofs,
|
||||
get_unused_locks,
|
||||
invalidate_proof,
|
||||
secret_used,
|
||||
store_keyset,
|
||||
@@ -259,20 +266,6 @@ class LedgerAPI:
|
||||
raise Exception(f"secret already used: {s}")
|
||||
logger.trace("Secret check complete.")
|
||||
|
||||
def generate_secrets(self, secret, n) -> List[str]:
|
||||
"""`secret` is the base string that will be tweaked n times
|
||||
|
||||
Args:
|
||||
secret (str): Base secret
|
||||
n (int): Number of secrets to generate
|
||||
|
||||
Returns:
|
||||
List[str]: List of secrets
|
||||
"""
|
||||
if len(secret.split("P2SH:")) == 2:
|
||||
return [f"{secret}:{self._generate_secret()}" for i in range(n)]
|
||||
return [f"{i}:{secret}" for i in range(n)]
|
||||
|
||||
"""
|
||||
ENDPOINTS
|
||||
"""
|
||||
@@ -438,14 +431,14 @@ class LedgerAPI:
|
||||
|
||||
@async_set_requests
|
||||
async def split(
|
||||
self, proofs, amount, scnd_secret: Optional[str] = None
|
||||
self, proofs, amount, secret_lock: Optional[Secret] = None
|
||||
) -> Tuple[List[Proof], List[Proof]]:
|
||||
"""Consume proofs and create new promises based on amount split.
|
||||
|
||||
If scnd_secret is None, random secrets will be generated for the tokens to keep (frst_outputs)
|
||||
If secret_lock is None, random secrets will be generated for the tokens to keep (frst_outputs)
|
||||
and the promises to send (scnd_outputs).
|
||||
|
||||
If scnd_secret is provided, the wallet will create blinded secrets with those to attach a
|
||||
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."""
|
||||
logger.debug("Calling split. POST /split")
|
||||
total = sum_proofs(proofs)
|
||||
@@ -454,18 +447,18 @@ class LedgerAPI:
|
||||
scnd_outputs = amount_split(scnd_amt)
|
||||
|
||||
amounts = frst_outputs + scnd_outputs
|
||||
if scnd_secret is None:
|
||||
if secret_lock is None:
|
||||
secrets = [self._generate_secret() for _ in range(len(amounts))]
|
||||
else:
|
||||
scnd_secrets = self.generate_secrets(scnd_secret, len(scnd_outputs))
|
||||
logger.debug(f"Creating proofs with custom secrets: {scnd_secrets}")
|
||||
assert len(scnd_secrets) == len(
|
||||
secret_locks = [secret_lock.serialize() for i in range(len(scnd_outputs))]
|
||||
logger.debug(f"Creating proofs with custom secrets: {secret_locks}")
|
||||
assert len(secret_locks) == len(
|
||||
scnd_outputs
|
||||
), "number of scnd_secrets does not match number of ouptus."
|
||||
), "number of secret_locks does not match number of ouptus."
|
||||
# append predefined secrets (to send) to random secrets (to keep)
|
||||
secrets = [
|
||||
self._generate_secret() for s in range(len(frst_outputs))
|
||||
] + scnd_secrets
|
||||
] + secret_locks
|
||||
|
||||
assert len(secrets) == len(
|
||||
amounts
|
||||
@@ -477,7 +470,7 @@ class LedgerAPI:
|
||||
# construct payload
|
||||
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"}
|
||||
proofs_include = {"id", "amount", "secret", "C", "p2shscript", "p2pksig"}
|
||||
return {
|
||||
"amount": ...,
|
||||
"outputs": ...,
|
||||
@@ -572,6 +565,8 @@ class LedgerAPI:
|
||||
class Wallet(LedgerAPI):
|
||||
"""Minimal wallet wrapper."""
|
||||
|
||||
private_key: Optional[PrivateKey] = None
|
||||
|
||||
def __init__(self, url: str, db: str, name: str = "no_name"):
|
||||
super().__init__(url)
|
||||
self.db = Database("wallet", db)
|
||||
@@ -579,6 +574,17 @@ class Wallet(LedgerAPI):
|
||||
self.name = name
|
||||
logger.debug(f"Wallet initalized with mint URL {url}")
|
||||
|
||||
# temporarily, we use the NostrClient to generate keys
|
||||
try:
|
||||
nostr_pk = NostrClient(
|
||||
private_key=settings.nostr_private_key, connect=False
|
||||
).private_key
|
||||
self.private_key = (
|
||||
PrivateKey(bytes.fromhex(nostr_pk.hex()), raw=True) or None
|
||||
)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
# ---------- API ----------
|
||||
|
||||
async def load_mint(self, keyset_id: str = ""):
|
||||
@@ -648,27 +654,67 @@ class Wallet(LedgerAPI):
|
||||
self.proofs += proofs
|
||||
return proofs
|
||||
|
||||
async def add_witnesses_to_proofs(self, proofs: List[Proof]):
|
||||
"""Adds witnesses to proofs for P2SH or P2PK redemption."""
|
||||
|
||||
p2sh_script, p2sh_signature = None, None
|
||||
p2pk_signatures = None
|
||||
|
||||
# iterate through proofs and produce witnesses for each
|
||||
|
||||
# first we check whether all tokens have serialized secrets as their secret
|
||||
try:
|
||||
for p in proofs:
|
||||
Secret.deserialize(p.secret)
|
||||
except:
|
||||
# if not, we do not add witnesses (treat as regular token secret)
|
||||
return proofs
|
||||
# P2SH scripts
|
||||
if all([Secret.deserialize(p.secret).kind == SecretKind.P2SH for p in proofs]):
|
||||
# Quirk: we use a single P2SH script and signature pair for all tokens in proofs
|
||||
address = Secret.deserialize(proofs[0].secret).data
|
||||
p2shscripts = await get_unused_locks(address, db=self.db)
|
||||
assert len(p2shscripts) == 1, Exception("lock not found.")
|
||||
p2sh_script, p2sh_signature = (
|
||||
p2shscripts[0].script,
|
||||
p2shscripts[0].signature,
|
||||
)
|
||||
logger.debug(f"Unlock script: {p2sh_script} signature: {p2sh_signature}")
|
||||
|
||||
# attach unlock scripts to proofs
|
||||
for p in proofs:
|
||||
p.p2shscript = P2SHScript(script=p2sh_script, signature=p2sh_signature)
|
||||
|
||||
# P2PK signatures
|
||||
elif all(
|
||||
[Secret.deserialize(p.secret).kind == SecretKind.P2PK for p in proofs]
|
||||
):
|
||||
p2pk_signatures = await self.sign_p2pk_with_privatekey(proofs)
|
||||
logger.debug(f"Unlock signature: {p2pk_signatures}")
|
||||
|
||||
# attach unlock signatures to proofs
|
||||
assert len(proofs) == len(p2pk_signatures), "wrong number of signatures"
|
||||
for p, s in zip(proofs, p2pk_signatures):
|
||||
p.p2pksig = s
|
||||
|
||||
return proofs
|
||||
|
||||
async def redeem(
|
||||
self,
|
||||
proofs: List[Proof],
|
||||
scnd_script: Optional[str] = None,
|
||||
scnd_siganture: Optional[str] = None,
|
||||
):
|
||||
if scnd_script and scnd_siganture:
|
||||
logger.debug(f"Unlock script: {scnd_script}")
|
||||
# attach unlock scripts to proofs
|
||||
for p in proofs:
|
||||
p.script = P2SHScript(script=scnd_script, signature=scnd_siganture)
|
||||
proofs = await self.add_witnesses_to_proofs(proofs)
|
||||
return await self.split(proofs, sum_proofs(proofs))
|
||||
|
||||
async def split(
|
||||
self,
|
||||
proofs: List[Proof],
|
||||
amount: int,
|
||||
scnd_secret: Optional[str] = None,
|
||||
secret_lock: Optional[Secret] = None,
|
||||
):
|
||||
assert len(proofs) > 0, ValueError("no proofs provided.")
|
||||
frst_proofs, scnd_proofs = await super().split(proofs, amount, scnd_secret)
|
||||
frst_proofs, scnd_proofs = await super().split(proofs, amount, secret_lock)
|
||||
|
||||
if len(frst_proofs) == 0 and len(scnd_proofs) == 0:
|
||||
raise Exception("received no splits.")
|
||||
|
||||
@@ -957,7 +1003,7 @@ class Wallet(LedgerAPI):
|
||||
self,
|
||||
proofs: List[Proof],
|
||||
amount: int,
|
||||
scnd_secret: Optional[str] = None,
|
||||
secret_lock: Optional[Secret] = None,
|
||||
set_reserved: bool = False,
|
||||
):
|
||||
"""
|
||||
@@ -966,25 +1012,26 @@ class Wallet(LedgerAPI):
|
||||
Args:
|
||||
proofs (List[Proof]): Proofs to split
|
||||
amount (int): Amount to split to
|
||||
scnd_secret (Optional[str], optional): If set, a custom secret is used to lock new outputs. Defaults to None.
|
||||
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.
|
||||
"""
|
||||
if scnd_secret:
|
||||
logger.debug(f"Spending conditions: {scnd_secret}")
|
||||
if secret_lock:
|
||||
logger.debug(f"Spending conditions: {secret_lock}")
|
||||
spendable_proofs = await self._select_proofs_to_send(proofs, amount)
|
||||
|
||||
keep_proofs, send_proofs = await self.split(
|
||||
spendable_proofs, amount, scnd_secret
|
||||
spendable_proofs, amount, secret_lock
|
||||
)
|
||||
if set_reserved:
|
||||
await self.set_reserved(send_proofs, reserved=True)
|
||||
return keep_proofs, send_proofs
|
||||
|
||||
# ---------- P2SH ----------
|
||||
# ---------- P2SH and P2PK ----------
|
||||
|
||||
async def create_p2sh_lock(self):
|
||||
async def create_p2sh_address_and_store(self) -> str:
|
||||
"""Creates a P2SH lock script and stores the script and signature in the database."""
|
||||
alice_privkey = step0_carol_privkey()
|
||||
txin_redeemScript = step0_carol_checksig_redeemscrip(alice_privkey.pub)
|
||||
txin_p2sh_address = step1_carol_create_p2sh_address(txin_redeemScript)
|
||||
@@ -997,7 +1044,61 @@ class Wallet(LedgerAPI):
|
||||
address=str(txin_p2sh_address),
|
||||
)
|
||||
await store_p2sh(p2shScript, db=self.db)
|
||||
return p2shScript
|
||||
assert p2shScript.address
|
||||
return p2shScript.address
|
||||
|
||||
async def create_p2pk_pubkey(self):
|
||||
assert (
|
||||
self.private_key
|
||||
), "No private key set in settings. Set NOSTR_PRIVATE_KEY in .env"
|
||||
public_key = self.private_key.pubkey
|
||||
# logger.debug(f"Private key: {self.private_key.bech32()}")
|
||||
assert public_key
|
||||
return public_key.serialize().hex()
|
||||
|
||||
async def create_p2pk_lock(
|
||||
self,
|
||||
pubkey: str,
|
||||
timelock: Optional[int] = None,
|
||||
tags: Optional[Tags] = None,
|
||||
):
|
||||
return Secret(
|
||||
kind=SecretKind.P2PK,
|
||||
data=pubkey,
|
||||
timelock=int((datetime.now() + timedelta(seconds=timelock)).timestamp())
|
||||
if timelock
|
||||
else None,
|
||||
tags=tags,
|
||||
)
|
||||
|
||||
async def create_p2sh_lock(
|
||||
self,
|
||||
address: str,
|
||||
timelock: Optional[int] = None,
|
||||
tags: Optional[Tags] = None,
|
||||
):
|
||||
return Secret(
|
||||
kind=SecretKind.P2SH,
|
||||
data=address,
|
||||
timelock=int((datetime.now() + timedelta(seconds=timelock)).timestamp())
|
||||
if timelock
|
||||
else None,
|
||||
tags=tags,
|
||||
)
|
||||
|
||||
async def sign_p2pk_with_privatekey(self, proofs: List[Proof]) -> List[str]:
|
||||
assert (
|
||||
self.private_key
|
||||
), "No private key set in settings. Set NOSTR_PRIVATE_KEY in .env"
|
||||
private_key = self.private_key
|
||||
assert private_key.pubkey
|
||||
return [
|
||||
sign_p2pk_sign(
|
||||
message=proof.secret.encode("utf-8"),
|
||||
private_key=private_key,
|
||||
)
|
||||
for proof in proofs
|
||||
]
|
||||
|
||||
# ---------- BALANCE CHECKS ----------
|
||||
|
||||
|
||||
2
setup.py
2
setup.py
@@ -13,7 +13,7 @@ entry_points = {"console_scripts": ["cashu = cashu.wallet.cli.cli:cli"]}
|
||||
|
||||
setuptools.setup(
|
||||
name="cashu",
|
||||
version="0.12.1",
|
||||
version="0.12.2",
|
||||
description="Ecash wallet and mint for Bitcoin Lightning",
|
||||
long_description=long_description,
|
||||
long_description_content_type="text/markdown",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import multiprocessing
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -58,6 +59,7 @@ def mint():
|
||||
settings.port = 3337
|
||||
settings.mint_url = "http://localhost:3337"
|
||||
settings.port = settings.mint_listen_port
|
||||
settings.nostr_private_key = secrets.token_hex(32) # wrong private key
|
||||
config = uvicorn.Config(
|
||||
"cashu.mint.app:app",
|
||||
port=settings.mint_listen_port,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import time
|
||||
import asyncio
|
||||
import secrets
|
||||
from typing import List
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from cashu.core.base import Proof
|
||||
from cashu.core.base import Proof, Secret, SecretKind, Tags
|
||||
from cashu.core.crypto.secp import PrivateKey, PublicKey
|
||||
from cashu.core.helpers import async_unwrap, sum_proofs
|
||||
from cashu.core.migrations import migrate_databases
|
||||
from cashu.core.settings import settings
|
||||
@@ -20,9 +22,10 @@ async def assert_err(f, msg):
|
||||
try:
|
||||
await f
|
||||
except Exception as exc:
|
||||
assert exc.args[0] == msg, Exception(
|
||||
f"Expected error: {msg}, got: {exc.args[0]}"
|
||||
)
|
||||
if str(exc.args[0]) != msg:
|
||||
raise Exception(f"Expected error: {msg}, got: {exc.args[0]}")
|
||||
return
|
||||
raise Exception(f"Expected error: {msg}, got no error")
|
||||
|
||||
|
||||
def assert_amt(proofs: List[Proof], expected: int):
|
||||
@@ -242,43 +245,113 @@ async def test_split_invalid_amount(wallet1: Wallet):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_split_with_secret(wallet1: Wallet):
|
||||
async def test_create_p2pk_pubkey(wallet1: Wallet):
|
||||
await wallet1.mint(64)
|
||||
secret = f"asdasd_{time.time()}"
|
||||
w1_frst_proofs, w1_scnd_proofs = await wallet1.split(
|
||||
wallet1.proofs, 32, scnd_secret=secret
|
||||
)
|
||||
# check if index prefix is in secret
|
||||
assert w1_scnd_proofs[0].secret == "0:" + secret
|
||||
pubkey = await wallet1.create_p2pk_pubkey()
|
||||
PublicKey(bytes.fromhex(pubkey), raw=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redeem_without_secret(wallet1: Wallet):
|
||||
async def test_p2pk(wallet1: Wallet, wallet2: Wallet):
|
||||
await wallet1.mint(64)
|
||||
# strip away the secrets
|
||||
w1_scnd_proofs_manipulated = wallet1.proofs.copy()
|
||||
for p in w1_scnd_proofs_manipulated:
|
||||
p.secret = ""
|
||||
await assert_err(
|
||||
wallet1.redeem(w1_scnd_proofs_manipulated),
|
||||
"Mint Error: no secret in proof.",
|
||||
pubkey_wallet2 = await wallet2.create_p2pk_pubkey()
|
||||
# p2pk test
|
||||
secret_lock = await wallet1.create_p2pk_lock(pubkey_wallet2) # sender side
|
||||
_, send_proofs = await wallet1.split_to_send(
|
||||
wallet1.proofs, 8, secret_lock=secret_lock
|
||||
)
|
||||
await wallet2.redeem(send_proofs)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def no_test_p2sh(wallet1: Wallet, wallet2: Wallet):
|
||||
async def test_p2pk_receive_with_wrong_private_key(wallet1: Wallet, wallet2: Wallet):
|
||||
await wallet1.mint(64)
|
||||
# p2sh test
|
||||
p2shscript = await wallet1.create_p2sh_lock()
|
||||
txin_p2sh_address = p2shscript.address
|
||||
lock = f"P2SH:{txin_p2sh_address}"
|
||||
_, send_proofs = await wallet1.split_to_send(wallet1.proofs, 8, lock)
|
||||
|
||||
assert send_proofs[0].secret.startswith("P2SH:")
|
||||
|
||||
frst_proofs, scnd_proofs = await wallet2.redeem(
|
||||
send_proofs, scnd_script=p2shscript.script, scnd_siganture=p2shscript.signature
|
||||
pubkey_wallet2 = await wallet2.create_p2pk_pubkey() # receiver side
|
||||
# sender side
|
||||
secret_lock = await wallet1.create_p2pk_lock(pubkey_wallet2) # sender side
|
||||
_, send_proofs = await wallet1.split_to_send(
|
||||
wallet1.proofs, 8, secret_lock=secret_lock
|
||||
)
|
||||
# receiver side: wrong private key
|
||||
wallet1.private_key = PrivateKey() # wrong private key
|
||||
await assert_err(wallet1.redeem(send_proofs), "Mint Error: p2pk signature invalid.")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_p2pk_short_timelock_receive_with_wrong_private_key(
|
||||
wallet1: Wallet, wallet2: Wallet
|
||||
):
|
||||
await wallet1.mint(64)
|
||||
pubkey_wallet2 = await wallet2.create_p2pk_pubkey() # receiver side
|
||||
# sender side
|
||||
secret_lock = await wallet1.create_p2pk_lock(
|
||||
pubkey_wallet2, timelock=4
|
||||
) # sender side
|
||||
_, send_proofs = await wallet1.split_to_send(
|
||||
wallet1.proofs, 8, secret_lock=secret_lock
|
||||
)
|
||||
# receiver side: wrong private key
|
||||
wallet1.private_key = PrivateKey() # wrong private key
|
||||
await assert_err(wallet1.redeem(send_proofs), "Mint Error: p2pk signature invalid.")
|
||||
await asyncio.sleep(6)
|
||||
await wallet1.redeem(send_proofs)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_p2pk_timelock_with_refund_pubkey(wallet1: Wallet, wallet2: Wallet):
|
||||
await wallet1.mint(64)
|
||||
pubkey_wallet2 = await wallet2.create_p2pk_pubkey() # receiver side
|
||||
# sender side
|
||||
garbage_pubkey = PrivateKey().pubkey
|
||||
assert garbage_pubkey
|
||||
secret_lock = await wallet1.create_p2pk_lock(
|
||||
garbage_pubkey.serialize().hex(), # create lock to unspendable pubkey
|
||||
timelock=4, # timelock
|
||||
tags=Tags(__root__=[["refund", pubkey_wallet2]]), # refund pubkey
|
||||
) # sender side
|
||||
_, send_proofs = await wallet1.split_to_send(
|
||||
wallet1.proofs, 8, secret_lock=secret_lock
|
||||
)
|
||||
# receiver side: can't redeem since we used a garbage pubkey
|
||||
await assert_err(wallet2.redeem(send_proofs), "Mint Error: p2pk signature invalid.")
|
||||
await asyncio.sleep(6)
|
||||
# we can now redeem because of the refund timelock
|
||||
await wallet2.redeem(send_proofs)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_p2pk_timelock_with_wrong_refund_pubkey(wallet1: Wallet, wallet2: Wallet):
|
||||
await wallet1.mint(64)
|
||||
pubkey_wallet2 = await wallet2.create_p2pk_pubkey() # receiver side
|
||||
# sender side
|
||||
garbage_pubkey = PrivateKey().pubkey
|
||||
garbage_pubkey_2 = PrivateKey().pubkey
|
||||
assert garbage_pubkey
|
||||
assert garbage_pubkey_2
|
||||
secret_lock = await wallet1.create_p2pk_lock(
|
||||
garbage_pubkey.serialize().hex(), # create lock to unspendable pubkey
|
||||
timelock=4, # timelock
|
||||
tags=Tags(
|
||||
__root__=[["refund", garbage_pubkey_2.serialize().hex()]]
|
||||
), # refund pubkey
|
||||
) # sender side
|
||||
_, send_proofs = await wallet1.split_to_send(
|
||||
wallet1.proofs, 8, secret_lock=secret_lock
|
||||
)
|
||||
# receiver side: can't redeem since we used a garbage pubkey
|
||||
await assert_err(wallet2.redeem(send_proofs), "Mint Error: p2pk signature invalid.")
|
||||
await asyncio.sleep(6)
|
||||
# we still can't redeem it because we used garbage_pubkey_2 as a refund pubkey
|
||||
await assert_err(wallet2.redeem(send_proofs), "Mint Error: p2pk signature invalid.")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_p2sh(wallet1: Wallet, wallet2: Wallet):
|
||||
await wallet1.mint(64)
|
||||
_ = await wallet1.create_p2sh_address_and_store() # receiver side
|
||||
_, send_proofs = await wallet1.split_to_send(wallet1.proofs, 8) # sender side
|
||||
|
||||
frst_proofs, scnd_proofs = await wallet2.redeem(send_proofs) # receiver side
|
||||
assert len(frst_proofs) == 0
|
||||
assert len(scnd_proofs) == 1
|
||||
assert sum_proofs(scnd_proofs) == 8
|
||||
@@ -286,40 +359,11 @@ async def no_test_p2sh(wallet1: Wallet, wallet2: Wallet):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_p2sh_receive_wrong_script(wallet1: Wallet, wallet2: Wallet):
|
||||
async def test_p2sh_receive_with_wrong_wallet(wallet1: Wallet, wallet2: Wallet):
|
||||
await wallet1.mint(64)
|
||||
# p2sh test
|
||||
p2shscript = await wallet1.create_p2sh_lock()
|
||||
txin_p2sh_address = p2shscript.address
|
||||
lock = f"P2SH:{txin_p2sh_address}"
|
||||
_, send_proofs = await wallet1.split_to_send(wallet1.proofs, 8, lock) # type: ignore
|
||||
|
||||
wrong_script = "asad" + p2shscript.script
|
||||
|
||||
await assert_err(
|
||||
wallet2.redeem(
|
||||
send_proofs, scnd_script=wrong_script, scnd_siganture=p2shscript.signature
|
||||
),
|
||||
"Mint Error: ('Script verification failed:', VerifyScriptError('scriptPubKey returned false'))",
|
||||
)
|
||||
assert wallet2.balance == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_p2sh_receive_wrong_signature(wallet1: Wallet, wallet2: Wallet):
|
||||
await wallet1.mint(64)
|
||||
# p2sh test
|
||||
p2shscript = await wallet1.create_p2sh_lock()
|
||||
txin_p2sh_address = p2shscript.address
|
||||
lock = f"P2SH:{txin_p2sh_address}"
|
||||
_, send_proofs = await wallet1.split_to_send(wallet1.proofs, 8, lock) # type: ignore
|
||||
|
||||
wrong_signature = "asda" + p2shscript.signature
|
||||
|
||||
await assert_err(
|
||||
wallet2.redeem(
|
||||
send_proofs, scnd_script=p2shscript.script, scnd_siganture=wrong_signature
|
||||
),
|
||||
"Mint Error: ('Script evaluation failed:', EvalScriptError('EvalScript: OP_RETURN called'))",
|
||||
)
|
||||
assert wallet2.balance == 0
|
||||
wallet1_address = await wallet1.create_p2sh_address_and_store() # receiver side
|
||||
secret_lock = await wallet1.create_p2sh_lock(wallet1_address) # sender side
|
||||
_, send_proofs = await wallet1.split_to_send(
|
||||
wallet1.proofs, 8, secret_lock
|
||||
) # sender side
|
||||
await assert_err(wallet2.redeem(send_proofs), "lock not found.") # wrong receiver
|
||||
|
||||
Reference in New Issue
Block a user