Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
3ef8611
feat(crypto): introduce backward compatible keyset v3 (prefix 02) wit…
a1denvalu3 May 5, 2026
dcb5d0c
fix(crypto): replace keyset v3 prefix checks with general >= 02 logic
a1denvalu3 May 5, 2026
ef7b8f8
perf(crypto): optimize BLS pairing verification
a1denvalu3 May 20, 2026
3c0666e
fix(crypto): include 02 prefix in is_base64_keyset_id check
a1denvalu3 May 20, 2026
2daf2f5
fixes
a1denvalu3 May 20, 2026
bbf996f
test vectors from NUT
a1denvalu3 May 20, 2026
97c6d90
feat(crypto): add BLS12-381 (v3) test vectors and debug tracing
a1denvalu3 May 20, 2026
aed722d
chore: remove accidental junk files
a1denvalu3 May 20, 2026
da70679
feat(bls): Add subgroup checks for public points and deterministic ra…
a1denvalu3 May 21, 2026
73fd87a
test: add BLS12-381 (v3) test vectors
a1denvalu3 May 21, 2026
8268414
fix: update tests for BLS12-381 test vectors
a1denvalu3 May 21, 2026
123686b
refactor(crypto): global G2 generator caching for BLS
a1denvalu3 May 22, 2026
c28c048
refactor(crypto): formally verify BLS point at infinity using pyblst
a1denvalu3 May 22, 2026
c5b78e7
fix: resolve mypy errors for BLS12-381 keysets
a1denvalu3 May 25, 2026
267eaf1
fix: bls12-381-v3-keyset implementation
a1denvalu3 May 26, 2026
f67b807
fix: refactor duck typing to explicit isinstance checks
a1denvalu3 May 26, 2026
9a4c5f9
fix: secure BLS signature verification and prevent Mint server DoS
a1denvalu3 Jun 5, 2026
98460b4
refactor(crypto): improve BLS derivation and error handling
a1denvalu3 Jun 8, 2026
7738924
refactor(crypto): replace custom mod_inverse with built-in pow()
a1denvalu3 Jun 8, 2026
4abbdb7
refactor(crypto): simplify return statement in keyed_verification
a1denvalu3 Jun 8, 2026
aa0fe6a
refactor(crypto): hoist nested imports to module level and update tes…
a1denvalu3 Jun 8, 2026
28328ea
fix(mint): resolve db operations and init failures with BLS keysets
a1denvalu3 Jun 8, 2026
4b5e4be
refactor(tests): hoist dynamic imports to top level in test_mint_db_o…
a1denvalu3 Jun 12, 2026
6ab0438
fix(crypto): ensure unit string is explicitly lowercased in v2 and v3…
a1denvalu3 Jun 12, 2026
a3acbc1
fix(tests): resolve BLS keyset test helper prefix
a1denvalu3 Jul 3, 2026
4721441
refactor(crypto): replace hasattr with isinstance check for keyset unit
a1denvalu3 Jul 3, 2026
8acc220
fix(crypto): raise early TypeError for invalid key in step2_bob_dleq …
a1denvalu3 Jul 4, 2026
537994f
test: add shared taproot v3 vectors from spec examples 6.1 and 6.2
robwoodgate Aug 3, 2026
ee6fcd0
feat(crypto): add taproot secrets crypto core with vector tests
robwoodgate Aug 3, 2026
6e47872
refactor: drop the example melt_to leaf type from the taproot core
robwoodgate Aug 3, 2026
29175ed
test: add nut13 v3 derivation vectors
robwoodgate Aug 3, 2026
555e2e6
feat(wallet): derive v3 secrets as pubkeys per taproot secrets NUT-13…
robwoodgate Aug 3, 2026
d54914e
feat(crypto): hash v3 point secrets as raw bytes with legacy utf8 fal…
robwoodgate Aug 3, 2026
5459ff9
test: add transaction transcript vectors
robwoodgate Aug 3, 2026
9c6fb92
feat(crypto): add transaction transcript builder with vector tests
robwoodgate Aug 3, 2026
72edd89
feat(mint): permit witness data on v3 point-secret inputs
robwoodgate Aug 3, 2026
8c5c95b
feat(mint): verify taproot transaction witnesses on swap inputs
robwoodgate Aug 3, 2026
841d722
feat(mint): verify taproot transaction witnesses on melt inputs
robwoodgate Aug 3, 2026
fe3515c
feat(wallet): attach taproot transaction witnesses on swap and melt
robwoodgate Aug 3, 2026
3ee749e
feat(wallet): carry taproot spend info on tokens and use bearer keys
robwoodgate Aug 3, 2026
8d1d87a
feat(mint): require taproot transaction witnesses on point-secret inputs
robwoodgate Aug 3, 2026
40ab08e
feat(mint): verify script path witnesses with leaf evaluation
robwoodgate Aug 3, 2026
38446c8
feat: v3 locked quotes sign the transaction transcript
robwoodgate Aug 4, 2026
e72df1a
feat(mint): widen the quote signature field for script path witnesses
robwoodgate Aug 4, 2026
910aca2
feat(mint): batch mints verify one transaction digest across quote in…
robwoodgate Aug 4, 2026
71e2f86
fix(core): compute v3 proof Y on BLS G1 with binary secret dispatch
robwoodgate Aug 4, 2026
4effe19
test(mint): sign v3 transactions in api and operations suites
robwoodgate Aug 4, 2026
51acb89
docs(mint): describe the witness requirement as implemented
robwoodgate Aug 4, 2026
e855d0d
feat(mint): optional v2 keyset alongside a v3 keyset
robwoodgate Aug 4, 2026
5ac61fa
test: run NUT-10 flows on a pre-v3 keyset
robwoodgate Aug 4, 2026
d4ee392
feat(crypto): v3 keysets take point secrets only
robwoodgate Aug 4, 2026
5a0f287
test: point secrets in v3 fixtures and keyset-aware assertions
robwoodgate Aug 4, 2026
7a0e68a
fix(crypto): check the v3 secret is on the curve
robwoodgate Aug 4, 2026
673ab83
fix(mint): verify v3 witnesses per input
robwoodgate Aug 4, 2026
206cc2b
feat(mint): a v3 mint quote must be locked
robwoodgate Aug 4, 2026
383f785
fix(crypto): require distinct keys in a leaf
robwoodgate Aug 4, 2026
af8c01a
fix(crypto): v3 point secrets are lowercase hex
robwoodgate Aug 5, 2026
f1adabb
feat(token): spend info carries the internal key in V4 tokens
robwoodgate Aug 5, 2026
3ab3e16
chore(tests): resync the shared taproot vector file
robwoodgate Aug 5, 2026
dc61a5c
fix(mint): report input verification failures consistently
robwoodgate Aug 5, 2026
4a98ac4
test(taproot): pin the empty tweak against the shared vector
robwoodgate Aug 5, 2026
d8896ad
test(taproot): pin the leaf forms the worked examples never show
robwoodgate Aug 5, 2026
beff74a
fix(crypto): pin each leaf type to its own fields and validate points
robwoodgate Aug 5, 2026
96c7f94
fix(crypto): tokens drop v3 witnesses and tree caps bind the tree
robwoodgate Aug 5, 2026
1c958b2
docs(crypto): correct the reasoning on the tree depth cap
robwoodgate Aug 6, 2026
5f1e147
feat(mint): size the witness bound to what a script path needs
robwoodgate Aug 6, 2026
e25e981
keysets: length-framed v3 keyset id derivation (NUT-02)
robwoodgate Aug 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .claude/ralph-loop.local.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
active: true
iteration: 6
session_id: 1e7a9591-fabc-45e1-807a-beda3e17fb88
max_iterations: 10
completion_promise: "TAPROOT V3 ALL MILESTONES GREEN"
started_at: "2026-07-24T19:54:57Z"
---

Read taproot-v3/PROMPT.md and execute it: one task per iteration, following its rules exactly.
129 changes: 119 additions & 10 deletions cashu/core/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,28 @@
from ..mint.events.event_model import LedgerEvent
from .crypto.aes import AESCipher
from .crypto.b_dhke import hash_to_curve
from .crypto.bls import PrivateKey as BlsPrivateKey
from .crypto.bls import PublicKey as BlsPublicKey
from .crypto.keys import (
derive_keys,
derive_keys_deprecated_pre_0_15,
derive_keys_v3,
derive_keyset_id,
derive_keyset_id_deprecated,
derive_keyset_id_v2,
derive_keyset_id_v3,
derive_pubkeys,
is_bls_keyset,
)
from .crypto.secp import PrivateKey, PublicKey
from .crypto.secp import PrivateKey as SecpPrivateKey
from .crypto.secp import PublicKey as SecpPublicKey
from .crypto.taproot import is_taproot_point_secret
from .legacy import derive_keys_backwards_compatible_insecure_pre_0_12
from .settings import settings

PrivateKey = Union[SecpPrivateKey, BlsPrivateKey]
PublicKey = Union[SecpPublicKey, BlsPublicKey]


class DLEQ(BaseModel):
"""
Expand Down Expand Up @@ -117,6 +127,22 @@ def from_witness(cls, witness: str):
return cls(**json.loads(witness))


class SpendInfo(BaseModel):
"""Taproot spend info (spec 2.5): a key and, when conditions exist, the leaf tree.

`k` (32-byte scalar hex, bearer) and `E` (33-byte point hex, receiver-keyed)
are mutually exclusive. `K` (33-byte point hex) is the internal key, needed
when neither yields one: a script-only proof discloses its tree and `K` so
the tree can be checked complete. `tree` lists serialized leaves (hex) in
slot-map order. Local-only: never sent to the mint.
"""

k: Optional[str] = None
E: Optional[str] = None
K: Optional[str] = None
tree: Optional[List[str]] = None


class Proof(BaseModel):
"""
Value token
Expand All @@ -129,6 +155,7 @@ class Proof(BaseModel):
C: str = "" # signature on secret, unblinded by wallet
dleq: Optional[DLEQWallet] = None # DLEQ proof
witness: Union[None, str] = None # witness for spending condition
spend_info: Optional[SpendInfo] = None # taproot spend info (local-only)

# whether this proof is reserved for sending, used for coin management in the wallet
reserved: Union[None, bool] = False
Expand All @@ -146,7 +173,15 @@ class Proof(BaseModel):

def __init__(self, **data):
super().__init__(**data)
self.Y = hash_to_curve(self.secret.encode("utf-8")).format().hex()
if is_bls_keyset(self.id):
# V3: Y lives on BLS G1, hashed over the secret's raw bytes for
# point secrets (taproot) with utf8 fallback for legacy secrets.
from .crypto.bls_dhke import hash_to_curve as bls_hash_to_curve
from .crypto.bls_dhke import secret_to_hash_input

self.Y = bls_hash_to_curve(secret_to_hash_input(self.secret)).format().hex()
else:
self.Y = hash_to_curve(self.secret.encode("utf-8")).format().hex()

@classmethod
def from_dict(cls, proof_dict: dict):
Expand Down Expand Up @@ -941,12 +976,16 @@ def serialize(self):
)

@classmethod
def from_row(cls, row: Row):
def from_row(cls, row: RowMapping):
def deserialize(serialized: str) -> Dict[int, PublicKey]:
return {
int(amount): PublicKey(bytes.fromhex(hex_key))
for amount, hex_key in dict(json.loads(serialized)).items()
}
is_v3 = is_bls_keyset(row["id"])
pub_keys: Dict[int, PublicKey] = {}
for amount, hex_key in dict(json.loads(serialized)).items():
if is_v3:
pub_keys[int(amount)] = BlsPublicKey(bytes.fromhex(hex_key), group="G2")
else:
pub_keys[int(amount)] = SecpPublicKey(bytes.fromhex(hex_key))
return pub_keys

return cls(
id=row["id"],
Expand Down Expand Up @@ -1156,7 +1195,7 @@ def generate_keys(self):
assert self.public_keys is not None
self.id = derive_keyset_id(self.public_keys)
logger.info(f"Generated keyset v1 ID: {self.id}")
else:
elif self.version_tuple < (0, 21):
self.private_keys = derive_keys(
self.seed, self.derivation_path, self.amounts
)
Expand All @@ -1175,6 +1214,19 @@ def generate_keys(self):
self.input_fee_ppk,
)
logger.info(f"Generated keyset v2 ID: {self.id}")
else:
self.private_keys = derive_keys_v3(
self.seed, self.derivation_path, self.amounts
) # type: ignore[assignment]
self.public_keys = derive_pubkeys(self.private_keys, self.amounts) # type: ignore

# KEYSETS V3: BLS12-381 cryptography
if id_in_db:
self.id = id_in_db
else:
assert self.public_keys is not None
self.id = derive_keyset_id_v3(self.public_keys, self.unit.name, self.input_fee_ppk) # type: ignore[arg-type]
logger.info(f"Generated keyset v3 (BLS) ID: {self.id}")


# ------- TOKEN -------
Expand Down Expand Up @@ -1344,6 +1396,15 @@ class TokenV4DLEQ(BaseModel):
r: bytes


class TokenV4SpendInfo(BaseModel):
"""Taproot spend info in a V4 token: bearer key, DH ephemeral, internal key, leaf tree."""

k: Optional[bytes] = None
e: Optional[bytes] = None
i: Optional[bytes] = None
t: Optional[List[bytes]] = None


class TokenV4Proof(BaseModel):
"""
Value token
Expand All @@ -1354,6 +1415,7 @@ class TokenV4Proof(BaseModel):
c: bytes # signature
d: Optional[TokenV4DLEQ] = None # DLEQ proof
w: Optional[str] = None # witness
si: Optional[TokenV4SpendInfo] = None # taproot spend info

@classmethod
def from_proof(cls, proof: Proof, include_dleq=False):
Expand All @@ -1370,7 +1432,25 @@ def from_proof(cls, proof: Proof, include_dleq=False):
if proof.dleq
else None
),
w=proof.witness,
# A v3 witness signs one transaction's digest, so it means nothing
# outside that transaction and a token carries no transaction.
# Emitting one would hand the next owner a witness that can never
# verify, in place of the signature they have to produce.
w=None if is_taproot_point_secret(proof.secret, proof.id) else proof.witness,
si=(
TokenV4SpendInfo(
k=bytes.fromhex(proof.spend_info.k) if proof.spend_info.k else None,
e=bytes.fromhex(proof.spend_info.E) if proof.spend_info.E else None,
i=bytes.fromhex(proof.spend_info.K) if proof.spend_info.K else None,
t=(
[bytes.fromhex(leaf) for leaf in proof.spend_info.tree]
if proof.spend_info.tree
else None
),
)
if proof.spend_info
else None
),
)


Expand Down Expand Up @@ -1440,7 +1520,25 @@ def proofs(self) -> List[Proof]:
if p.d
else None
),
witness=p.w,
# A v3 witness signs one transaction's digest, so a token
# cannot carry a usable one. Keeping it would leave a stranger's
# witness in place of the signature the new owner must produce,
# and their sweep would be refused for it.
witness=(
None
if is_taproot_point_secret(p.s, token.i.hex())
else p.w
),
spend_info=(
SpendInfo(
k=p.si.k.hex() if p.si.k else None,
E=p.si.e.hex() if p.si.e else None,
K=p.si.i.hex() if p.si.i else None,
tree=[leaf.hex() for leaf in p.si.t] if p.si.t else None,
)
if p.si
else None
),
)
for token in self.t
for p in token.p
Expand Down Expand Up @@ -1507,6 +1605,17 @@ def serialize_to_dict(self, include_dleq=False):
for proof in token["p"]:
if not proof.get("w"):
del proof["w"]
# strip absent spend info; drop None subfields from present ones
for token in return_dict["t"]:
for proof in token["p"]:
if not proof.get("si"):
proof.pop("si", None)
else:
proof["si"] = {
key: value
for key, value in proof["si"].items()
if value is not None
}
# optional memo
if self.d:
return_dict.update(dict(d=self.d))
Expand Down
3 changes: 2 additions & 1 deletion cashu/core/constants.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Maximum lengths for Pydantic string fields
MAX_UNIT_LEN = 64
MAX_PUBKEY_LEN = 66
MAX_PUBKEY_LEN = 96
MAX_SIG_LEN = 130
MAX_WITNESS_LEN = 4096
MAX_QUOTE_ID_LEN = 256
MAX_INVOICE_DESC_LEN = 1024
MAX_PAYMENT_REQUEST_LEN = 10000
7 changes: 6 additions & 1 deletion cashu/core/crypto/b_dhke.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ def derive_dleq_nonce(
def step2_bob_dleq(
B_: PublicKey, a: PrivateKey, p_bytes: bytes = b""
) -> Tuple[PrivateKey, PrivateKey]:
if not isinstance(a, PrivateKey):
raise TypeError(f"Expected SecpPrivateKey, got {type(a)}")
C_: PublicKey = B_ * a # type: ignore
A = a.public_key
assert A
Expand All @@ -179,7 +181,10 @@ def step2_bob_dleq(
assert R1
R2: PublicKey = B_ * p # type: ignore
e = hash_e(R1, R2, A, C_) # e = hash(R1, R2, A, C_)
s = p.add(bytes.fromhex(a.multiply(e).to_hex())) # s = p + ek
if isinstance(a, PrivateKey):
s = p.add(bytes.fromhex(a.multiply(e).to_hex())) # s = p + ek
else:
raise TypeError(f"Expected SecpPrivateKey, got {type(a)}")
spk = PrivateKey(bytes.fromhex(s.to_hex()))
epk = PrivateKey(e)
return epk, spk
Expand Down
81 changes: 81 additions & 0 deletions cashu/core/crypto/bls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import os
from typing import Optional

import pyblst

curve_order = 52435875175126190479447740508185965837690552500527637822603658699938581184513
_G2_HEX = '93e02b6052719f607dacd3a088274f65596bd0d09920b61ab5da61bbdc7f5049334cf11213945d57e5ac7d055d042b7e024aa2b2f08f0a91260805272dc51051c6e47ad4fa403b02b4510b647ae3d1770bac0326a805bbefd48056c8c121bdb8'
G2 = pyblst.BlstP2Element().uncompress(bytes.fromhex(_G2_HEX))


class PrivateKey:
def __init__(self, privkey: bytes = b"", scalar: Optional[int] = None):
if scalar is not None:
if not 0 < scalar < curve_order:
raise ValueError("BLS scalar must be in Fr*")
self.scalar = scalar
elif privkey:
self.scalar = int.from_bytes(privkey, "big")
if len(privkey) != 32 or not 0 < self.scalar < curve_order:
raise ValueError("BLS private key must be 32 bytes in Fr*")
else:
self.scalar = 0
while not 0 < self.scalar < curve_order:
self.scalar = int.from_bytes(os.urandom(32), "big")

@property
def private_key(self) -> bytes:
return self.scalar.to_bytes(32, "big")

def to_hex(self) -> str:
return self.private_key.hex()

def get_g2_public_key(self) -> "PublicKey":
pt = G2.scalar_mul(self.scalar)
return PublicKey(point=pt, group="G2")

@property
def public_key(self) -> "PublicKey":
return self.get_g2_public_key()


class PublicKey:
def __init__(self, compressed: bytes = b"", point=None, group="G1"):
self.group = group
try:
if point is not None:
self.point = point
elif compressed:
if self.group == "G1":
self.point = pyblst.BlstP1Element().uncompress(compressed)
else:
self.point = pyblst.BlstP2Element().uncompress(compressed)
else:
raise ValueError("Must provide point or compressed bytes")
except Exception:
raise ValueError("The public key could not be parsed or is invalid.")

def format(self, compressed: bool = True) -> bytes:
return self.point.compress()

def serialize(self) -> bytes:
return self.format()

def is_infinity(self) -> bool:
"""Check if the point is the point at infinity (additive identity)."""
if self.group == "G1":
return self.point == pyblst.BlstP1Element()
else:
return self.point == pyblst.BlstP2Element()

def __eq__(self, other):
if isinstance(other, PublicKey):
return self.point == other.point
return False

def __mul__(self, scalar):
if isinstance(scalar, PrivateKey):
return PublicKey(point=self.point.scalar_mul(scalar.scalar), group=self.group)
elif isinstance(scalar, int):
return PublicKey(point=self.point.scalar_mul(scalar), group=self.group)
raise TypeError("Can't multiply with non-scalar")
Loading