Skip to content

feat(taproot): taproot secrets for v3 keysets (NUT-10 POC) - #1111

Draft
robwoodgate wants to merge 67 commits into
cashubtc:mainfrom
robwoodgate:rnd/taproot-v3
Draft

feat(taproot): taproot secrets for v3 keysets (NUT-10 POC)#1111
robwoodgate wants to merge 67 commits into
cashubtc:mainfrom
robwoodgate:rnd/taproot-v3

Conversation

@robwoodgate

Copy link
Copy Markdown
Contributor

Draft proof of concept for the taproot secrets spec (cashubtc/nuts#421). Mint side of the two-implementation pair; the cashu-ts wallet counterpart is cashubtc/cashu-ts#950 and both consume the same vector file (tests/taproot_v3_vectors.json here, canonical copy in cashu-ts).

Built on the BLS12-381 v3 keyset base as it stood before #999's recent rebase (8acc220). #999 has since been restructured, so this branch needs a re-port onto its new shape before it can stack cleanly; until then this PR is a working snapshot, and GitHub may show it as conflicting with main.

What it implements on top of the BLS base:

  • v3 keysets accept point secrets only; Y hashes the raw 33 secret bytes on G1. Legacy and NUT-10 JSON secrets stay on v1/v2 keysets, and a second v2 keyset can be activated alongside via MINT_V2_KEYSET_DERIVATION_PATH.
  • Leaf grammar and taproot crypto core (TLV leaves, tagged hashes, merkle fold, tweaks) as a functional mirror of the cashu-ts module, driven by the shared vectors.
  • One transaction transcript per swap/mint/melt request that every v3 input signs; key-path and script-path witness verification; locked mint quotes required on v3.
  • Spend info on proofs and the token codec (si CBOR map), with v3 transaction witnesses stripped from tokens.
  • The length-framed NUT-02 v3 keyset id.

The integration suite in cashu-ts runs 80/80 against this mint. Draft until the spec freezes.

a1denvalu3 and others added 30 commits July 3, 2026 18:00
- Use single miller loop accumulation by negating the signature point
- Verify against the identity element (BlstFP12Element)
- Applies to both single and batch pairing verification functions
- Add NUT-00 round-trip test vectors to  for v3 (BLS12-381)
- Add NUT-02 keyset ID test vectors to  for v3 keysets
- Add NUT-13 secret and blinding factor derivation test vector to
- Add TRACE level logging in core BLS operations (bls_dhke.py, keys.py, secrets.py) for tracking blinding factor reduction, derivation, and verification states
- Hoist in-line imports to module level
Moved _G2_HEX string definition and uncompression step to global scope in bls.py to avoid repeated initialization and uncompression in pairing and batch pairing verification functions. Imported the cached G2 point directly into bls_dhke.py.
Added is_infinity method to PublicKey class and updated step2_bob to formally verify the blinded message is not the point at infinity instead of checking the serialized hex string against a hardcoded constant.
Quote signatures now cover the transaction digest and v3 inputs carry
witnesses, so these suites attach witnesses before calling the ledger.
The docstring still described the migration window where absent witnesses
passed; the check has required a witness per point-secret input since the
spend_info work.
Keyset flavour follows the mint's own version, so a mint that never ran a
pre-v3 release cannot serve v1/v2 keysets at all. MINT_V2_KEYSET_DERIVATION_PATH
activates one on its own derivation path, which is what the pre-v3 secret
formats need.
Well-known secrets and plain text secrets belong to v1/v2 keysets. The test
mint now activates a v2 keyset and the P2PK and HTLC suites bind to it;
keyset counts and rotation pick-up adjust for the extra keyset.
A keyset version accepts exactly one secret format. NUT-10 well-known and
plain text secrets stay on legacy/v1/v2 keysets; the v3 hash input refuses
anything that is not a 33-byte compressed point. The BLS primitives keep
their byte-level vectors through a raw bytes input.
Placeholder proofs on v3 keysets carry a compressed point, the BLS wallet
check hashes one, and the keysets endpoint is compared against what the
mint holds now that it also serves a v2 keyset.
Shape alone let a 02-prefixed non-point through. Parsing it as a secp
pubkey rejects those, and the wallet fixture now uses a real point.
Verification follows each proof's keyset, per spec section 5, and the
transcript carries a v0-v2 secret's own bytes so mixed transactions hash.
A quote is a transaction input and inputs sign, so an unlocked quote has no
key to sign with. Unlocked minting stays available on pre-v3 keysets, which is
where the tests covering it now run.
Signatures verify against the x-only key, so entries sharing an x coordinate
count as one signer. Leaves listing both parities of a key now reject at
parse time.
Two spellings of one secret hashed alike here and differently in the wallet,
so a proof could verify at the mint while its owner computed another Y for
it. One canonical spelling, checked where the secret is read.
A script-only proof discloses its tree and its internal key: without `K`
there is no key source, so the receive cascade cannot check the disclosure
is complete and the proof reads as partial. cashu-ts has encoded it as the
`si` field `i` since the tree work; this side dropped it on both hops, so a
token crossing between the two lost it silently.

Adds the cross-implementation token vectors to the shared file: four spend
info shapes, each with both encoders' output. The two differ on what NUT-00
leaves free (cashu-ts writes the short keyset id and a different map order),
so the vectors pin what must agree, which is the decoded spend info, and
each side's own encoding so drift is visible.
Prettier reformats it on the cashu-ts side; the two copies stay byte for
byte identical so a drift is a real drift.
_verify_sigall_spending_conditions reported one failure mode by return value
while the surrounding checks raise, so callers had to handle two conventions.
It now raises, and a secret that is not a NUT-10 secret surfaces as a
transaction error rather than a parser error from inside the comparison.

The conditions test that asserted the falsy return now asserts the exception,
which is the stricter of the two.
Spec 3.8's form for an aggregated key. This side has the tweak primitive but
no receive cascade, so the math is what has to agree across implementations.
Threshold and hashlock leaves serialized, and the odd-count fold: three
leaves means the last is promoted unchanged, so its merkle path is a single
sibling. A builder and a verifier that fold differently reject each other's
valid proofs, so this has to agree across implementations.
Leaf parsing and serialization now reject a known constraint field that
the leaf type does not define, so a leaf carries exactly the fields its
evaluation rule reads. Every leaf key is checked as a compressed point
and parity twins are refused on the build side as well as the parse
side. The 1024-byte cap applies to the leaf body, excluding the version
byte, per 2.6 read against 2.3's wire form.

Also in this set:

- merkle siblings must be 32 bytes, and a zero tweak returns the
  internal key rather than failing on a zero scalar
- BLS scalars are canonical non-zero Fr values instead of being reduced,
  so a private key and its modulo alias no longer name the same scalar,
  and random keys use rejection sampling
- keyset version dispatch matches the id shape exactly, so only a
  well-formed 02 id selects the BLS and taproot rules
- the transaction transcript chooses raw point bytes by the input's
  keyset rather than by a secret that happens to look like a point
- a batch mint witness binds the amounts the request asks for rather
  than the quotes' stored amounts
- random_hash draws from the CSPRNG
A v3 witness signs one transaction's digest, so it means nothing outside
that transaction and a token carries no transaction. Tokens now drop it
in both directions, so a token cannot arrive carrying a witness that
stands where the new owner's own signature has to go. Pre-v3 witnesses
are unaffected.

The depth cap of 2.6 now applies to a disclosed tree, not only to a
witness path: past 2^8 leaves every merkle path is longer than a verifier
accepts, so the fallbacks a holder was told they had do not exist.

An `after` leaf's time is bounded at 2^53 - 1, the point where
implementations built on IEEE-754 integers stop counting exactly, so both
sides read the same leaf rather than one committing what the other
cannot parse.

A keyset id contributes raw bytes when it is hex and utf8 otherwise, the
rule the secret already follows, so a mixed transaction carrying a
pre-v1 base64 id has a transcript instead of an exception.
Past 2^8 leaves the tree is deeper than a verifier accepts, so some
leaves cannot be reached, not all of them: odd-count promotion carries a
leaf up a level without a sibling, so a few short paths survive in any
large tree (at 300 leaves, 44 are within the cap). That is the reason to
refuse the whole tree rather than the over-long paths alone, since which
leaves stay reachable falls out of the fold and not out of anything the
builder chose. The check is unchanged.
A script path witness carries a leaf, a control block and up to a
threshold's worth of signatures, none of which fit the 1024-character
bound that a v0-v2 witness lives inside. v3 inputs get a bound of their
own; pre-v3 keeps its setting exactly.

Also rejects a leaf whose threshold exceeds the keys it lists, in both
parsing and serialization. Such a leaf could never be satisfied, so a
builder now hears about it rather than shipping a dead condition, and a
tree holding one is refused at receive rather than accepted with a leaf
that only looks like a fallback.

The bound's test constructed its proof with a plain secret on a v3
keyset, which has been refused since v3 keysets took point secrets only,
so it raised before reaching the assertion and the bound went untested.
Given a point secret it exercises the boundary both ways.
derive_keyset_id_v3 builds the preimage over raw bytes with len32
framing: framed(keys) || framed(unit) || framed(fee), minimal
big-endian integers, fee empty when zero. The unit must match
[a-z0-9_-]+, and final_expiry is keyset metadata rather than a
preimage input, so the parameter is gone.

NUT-02 vector ids and the shared taproot vectors are regenerated
(nut13 outputs, transcripts, token strings), in sync with the
cashu-ts copy.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

2 participants