feat(taproot): taproot secrets for v3 keysets (NUT-10 POC) - #950
Draft
robwoodgate wants to merge 70 commits into
Draft
feat(taproot): taproot secrets for v3 keysets (NUT-10 POC)#950robwoodgate wants to merge 70 commits into
robwoodgate wants to merge 70 commits into
Conversation
NUT-10 and plain text secrets belong to pre-v3 keysets, so the dev mint serves one next to the v3 keyset.
NUT-10 well-known secrets and plain text secrets belong to v1/v2 keysets. Keyset selection prefers the highest version, so a mint serving both hands the wallet a v3 keyset; these wallets now name the pre-v3 one.
Blinding and proof verification assert a point secret on v3 keysets, and the random output path refuses them: a random secret has no key to sign the spend witness, so the proof would be unspendable. NUT-10 flows stay on pre-v3 keysets.
A v3 secret is a point, so a random one is a random keypair rather than random bytes; the wallet keeps the key for the life of the wallet object so the proof can sign its spend witness. Point secrets are validated on the curve via the existing pubkey checker.
A transaction mixing keysets signs its v3 inputs and leaves v0-v2 inputs to their own rules; keys are recovered per keyset, and the transcript carries a v0-v2 secret verbatim.
A mint quote is a transaction input and inputs sign, so a quote redeemed on a v3 keyset carries a lock key: derived from the seed like any other v3 secret (and re-derivable from the quote's pubkey), or random and held in memory when the wallet has no seed. Minting signs with it automatically.
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.
The two are mutually exclusive: k hands over the key, E says derive your own. Both at once is also the shape a re-gifted receiver-keyed scalar takes.
Spec 3.8 says an aggregated key MUST commit to having no script path by tweaking with itself, and 2.5.1 puts that check between the bare and tweaked branches. The primitive supported it; the cascade had no such branch, so a proof whose secret is K + H(K)*G was refused however it was disclosed. A wallet could not receive from a multi-party signer. Both forms reach it now: a disclosed `K` with no tree, which is how an aggregate arrives since no single party holds its scalar, and a bearer scalar, since a single-party key may use the same form. Key recovery follows so such a proof is spendable rather than merely receivable. One existing assertion changed message. It expected "incomplete: tree and a key source are required" for a disclosed key with an empty tree, and that sentence stopped being true when a tree stopped being required. It now expects the accurate refusal, and still asserts a refusal. Vector added to the shared file; nutshell asserts the same math, having the primitive but no cascade of its own.
The spec's section 6 examples only ever use an `after` leaf, so threshold and hashlock had no printed bytes and neither did a tree with an odd leaf count. Vectors added for all three, plus the NUMS point and a leaf key blinded at slot 1, and nutshell asserts the same file. The odd-count fold is the one that matters: three leaves promote the last unchanged, so its path is a single sibling. Root reconstruction is not shape-agnostic even though path verification is, so two implementations folding differently reject each other's valid proofs.
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 everywhere instead of being reduced, and v3 mint keys derive by rejection sampling on m/0'/0'/0'/i'/attempt', which reproduces the mint's keys and keyset id byte for byte - transcripts choose raw point bytes by the input's keyset rather than by a secret that happens to look like a point - disclosed trees enforce the 255-slot cap when built and when received - signing packages are checked as a whole before use: the leaf must commit to its input's secret, spends must name distinct inputs of this transaction, melt packages must carry a quote, and a leaf's threshold counts signatures that actually verify - random v3 outputs keep their key in spend info so it survives serialization
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. Emitting one hands the next owner a witness that can never verify; keeping one on receive leaves it where the new owner's own signature has to go, so their sweep is refused for a witness someone else chose. 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. The slot cap already made such a tree unbuildable; this states the bound. 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. 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. An input with no key to sign with now fails where the cause is known, naming the proof, rather than travelling to the mint as a witness error that names nothing.
A receiver looked up each leaf key at the slot its position implied. The transmitted order is not committed by the root, since sorted-pair hashing fixes the leaf set and not an arrangement, so a permuted list still reconstructs the secret and still passes the receive cascade while every blinded key sits at a slot its owner never derives. The leaf goes quiet with nothing to see. Match by value instead: derive the candidate for each slot in 1..N and look the tree's keys up in that. What a permutation cannot change is which slots are in use, only the count of enumerated keys can, so the match no longer depends on the order. Cost is unchanged, one derivation per slot either way. The sender still assigns slots by walking the list. Sorting the tree into a canonical order was the obvious alternative and does not work: blinding rewrites the leaf bytes, so any order derived from them is only known after the slots it would decide have been assigned. Spec 2.7 records that, so it is not proposed again. Out-of-band signing follows the same rule, so a package built from a reordered tree no longer makes a signer decline a leaf it can satisfy.
A token entry's witness was kept or dropped on whether the secret looked like a compressed point. A pre-v3 secret is an arbitrary string and may happen to be 33 point-shaped bytes of hex, and that proof's witness is a NUT-11 witness, which does travel. Such a proof lost it on encode and again on decode. Dispatch on the keyset instead, which is what decides the rules (spec 5) and what the transcript already does. Nutshell was gated on the keyset from the start, so this also removes a divergence between them. Also corrects the reasoning on the tree depth cap: 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. That is the reason to refuse the whole tree rather than the over-long paths alone. The check itself is unchanged.
An automatic v3 quote lock trusted the mint's echo. A mint that returned a quote locked to some other key would leave the wallet holding a key that signs nothing, and it would only find out at mint time. Check the returned pubkey against the one sent, and refuse an unlocked answer to a locked request. Around that, the automatic lock now survives the paths it did not: - key recovery runs before the "locked quote needs a key" check rather than after, so a quote this wallet locked for itself no longer trips a guard meant for quotes locked by someone else. The guard still holds for every other case, which is what the builder's own check was duplicating, so that one goes. - batch mint recovers the same keys, so a batch of automatically locked quotes signs rather than reporting it has no key. - recovery lowercases the quote pubkey, so a mint that echoes it in upper case does not strand the quote after a restart. Witness normalization now requires the keyset as well as the point shape, matching the token codec: a pre-v3 secret may look like a point, and that proof's witness is a NUT-11 witness that its own rules govern. Rejects a leaf whose threshold exceeds the keys it lists, matching the mint.
The check that a mint locked a quote to the key it was given existed in five spellings across five methods. It was missing from the sixth, the one this branch turned into a locked path when v3 quotes started locking by default, and it was restored there by copying it a seventh time. Copying is how it went missing in the first place, so this puts the rule in one method and calls it from every path that sends a pubkey. Adding another locked path now inherits the check rather than needing to remember it. No behaviour change: the two one-step call sites reported the same error for an absent pubkey as for a wrong one, and now say which.
Line numbers only: the report records positions in the generated types and upstream's changes shifted them.
…ypes - extract countLeafSigners and reuse slotKeysByBlindedPubkey for package signing, replacing two inline copies - drop the advisory keySlots list from signing packages: slots are not committed, so signers match by value over the whole slot space - give ScriptPath an explicit ScriptPathApi type and export the types the public surface references, clearing the api-extractor warnings - name the seed scan headroom and the point secret shape test
Witness attachment, spend info key recovery, script path planning and the spendOptions body become free functions in wallet/taproot.ts over an explicit state slice (seed, counters, random output keys, logger). Wallet keeps a two-line spendOptions delegate and the quote-lock lifecycle. No public API change.
… taproot Name the creqA field and PaymentRequest property by mechanism rather than keyset version: a later keyset version will still use taproot secrets, so a version label goes stale, and the NUT-18 spec text now calls the option taproot. Wire key, public property, builder state and error strings all follow; no behaviour change.
…6 tag 0x0b NUT-26 now allocates a tag for the option, so the encode-side refusal is no longer needed. Sub-TLVs carry the receiver key, leaves (repeatable, request order preserved) and blind-me keys as raw bytes, hex in JSON. A duplicate option or receiver key rejects, matching the nut10 tag.
… leaf A NUMS base has no scalar for the receiver's half of the ECDH, so a blinded NUMS cannot be verified by the payee; the base now travels verbatim, as the spec's script-only rule requires. Per-payment uniqueness must then come from the tree, so a NUMS request or derivation without a blind-me leaf key is refused at both construction sites. Also renames the builder's leftover _v3 field to _taproot.
parseTaprootLeaf/serializeTaprootLeaf join the public API with hex-accepting wrappers, so consumers can decode a spend_info tree or a requested leaf to its declarative form (and back) without internal helpers; receive-time policy checks and wallet UIs need exactly that. TAPROOT_NUMS_KEY is exported so leaves-only requests can be authored through the public API.
…d vectors Mirrors the standalone nut02 branch off main (same derivation change, deduped on merge): version-02 preimage over raw bytes with len32 framing, v3 unit validated against [a-z0-9_-]+, final_expiry out of the preimage. V3 fixture ids and parity pins updated. The keyset-id-dependent parts of taproot-v3.json are regenerated: nut13 outputs (blinding factors now accept at attempts 3, 4, 1, 1 for counters 0-3), transcripts, digests, swap signature, and both encoders' token strings, in sync with the nutshell copy. A new scripts/generate-taproot-vectors.ts does the cashu-ts side of that regeneration idempotently and derives the keyset id from the NUT-02 vector-1 keys so it cannot go stale.
Mirrors the standalone nut02 branch (Bytes.minimalBE, used by deriveKeysetId and NUT-20's amount encoding) and additionally has taproot's exported minimalBE delegate to it, so one implementation remains. No behavior change; vectors unchanged.
3 tasks
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #950 +/- ##
==========================================
- Coverage 95.70% 92.19% -3.52%
==========================================
Files 55 59 +4
Lines 5800 6753 +953
Branches 1469 1765 +296
==========================================
+ Hits 5551 6226 +675
- Misses 106 270 +164
- Partials 143 257 +114
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
CI runs the integration project against stock nutshell and CDK mints, which serve no 02 keyset; the file now probes /v1/keysets once and skips its suites there instead of failing.
75 tasks
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). Wallet side of the two-implementation pair; the nutshell mint counterpart is cashubtc/nutshell#1111 and both consume the same vector file (test/vectors/taproot-v3.json, canonical copy here).
What it implements:
Cross-implementation vectors live in test/vectors/taproot-v3.json; the taproot integration suite runs 80/80 against the nutshell counterpart mint. Draft until the spec freezes.