[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:
callebtc
2023-07-02 01:56:05 +02:00
committed by GitHub
parent 4beaf8ff41
commit 01d498309b
15 changed files with 548 additions and 231 deletions

View File

@@ -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"]

View File

@@ -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

View File

@@ -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
View 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)))

View File

@@ -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

View File

@@ -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)

View File

@@ -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
# execute and verify P2SH
txin_p2sh_address, valid = verify_script(
proof.script.script, proof.script.signature
)
if valid:
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_bitcoin_script(
proof.p2shscript.script, proof.p2shscript.signature
)
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]):

View File

@@ -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)

View File

@@ -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
print("---- Pay to script hash (P2SH) ----\n")
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")

View File

@@ -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

View File

@@ -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(
f"From {event.public_key[:3]}..{event.public_key[-3:]}: {decrypted_content}"
)
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}),

View File

@@ -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 ----------

View File

@@ -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",

View File

@@ -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,

View File

@@ -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