Skip to content

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

Draft
robwoodgate wants to merge 70 commits into
mainfrom
rnd/taproot-v3
Draft

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

Conversation

@robwoodgate

@robwoodgate robwoodgate commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

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:

  • Point secrets on v3 keysets: a secret is a tweaked compressed point, with conditions committed into it as a BIP341-style tree of declarative TLV leaves (threshold, after, hashlock; even fields fail closed, odd fields are annotations).
  • One transaction transcript that every v3 input signs, with key-path and script-path witnesses, and locked mint quotes as transaction inputs.
  • Spend info transfer (bearer k, receiver-keyed E, explicit K with a disclosed tree) and the receive-time verification cascade, including leaf-key blinding with slot recovery by value.
  • NUT-13 v3 derivation: the 0x00 branch derives a secret key, the secret is its compressed pubkey.
  • The taproot payment request option in creqA and creqB (NUT-26 tag 0x0b), with verbatim NUMS request keys requiring a blinded leaf key.
  • The NUT-02 framed v3 keyset id, also opened standalone as feat(nut02): length-frame the v3 keyset id preimage #949; the shared history dedupes on merge.

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.

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

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 71.77734% with 289 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.19%. Comparing base (9189f36) to head (de7d418).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/wallet/taproot.ts 35.13% 88 Missing and 8 partials ⚠️
src/crypto/taproot.ts 80.17% 35 Missing and 33 partials ⚠️
src/model/ScriptPath.ts 53.50% 25 Missing and 28 partials ⚠️
src/wallet/Wallet.ts 78.57% 6 Missing and 15 partials ⚠️
src/utils/tlv.ts 72.00% 7 Missing and 7 partials ⚠️
src/crypto/transcript.ts 82.97% 4 Missing and 4 partials ⚠️
src/crypto/NUT01.ts 65.00% 2 Missing and 5 partials ⚠️
src/crypto/NUT13.ts 76.19% 2 Missing and 3 partials ⚠️
src/utils/core.ts 86.84% 4 Missing and 1 partial ⚠️
src/wallet/_internal.ts 76.47% 4 Missing ⚠️
... and 4 more
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     
Flag Coverage Δ
integration 33.50% <8.90%> (-4.45%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

nut hold This PR is held pending the merge of a new or updated NUT prototype

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

1 participant