feat(taproot): taproot secrets for v3 keysets (NUT-10 POC) - #1111
Draft
robwoodgate wants to merge 67 commits into
Draft
feat(taproot): taproot secrets for v3 keysets (NUT-10 POC)#1111robwoodgate wants to merge 67 commits into
robwoodgate wants to merge 67 commits into
Conversation
…h BLS12-381 cryptography
- 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
…ndom scalars for batch verification
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.
… keyset ID derivation
…and update unsupported keyset filter test
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.
This was referenced Aug 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
The integration suite in cashu-ts runs 80/80 against this mint. Draft until the spec freezes.