Skip to content

NUT-10: Nutroot secrets (v3 keysets) - #443

Open
robwoodgate wants to merge 102 commits into
bls-protocolfrom
taproot-secrets
Open

robwoodgate wants to merge 102 commits into
bls-protocolfrom
taproot-secrets

Conversation

@robwoodgate

@robwoodgate robwoodgate commented Sep 10, 2026 •

Copy link
Copy Markdown
Collaborator

Continues #421, where the earlier review discussion lives.

Summary

Nutroot is the secret format for v3 (BLS) keysets. A proof's secret is a secp256k1 pubkey, and any spending conditions (multisig, timelocks, hashlocks) are committed into it taproot-style. Every v3 spend signs the whole transaction. Pre-v3 keysets keep today's rules.

The reasoning and trade-offs are in the design notes.

Why

  • Every input signs the transaction, so a mint cannot alter or front-run a request. Fraud proofs, proof of liabilities and federations need this.
  • One transcript and one signing rule for every operation.
  • Locked proofs look like plain ones until a condition is used.

Scope

Core only: what every v3 mint and wallet needs. Optional follow-ons:

Keyset rotation during a melt is #436.

How this differs from BIP341

The scheme is called nutroot because it borrows taproot's commitment structure and little else: a public key that commits to a merkle tree of alternative spending conditions, where a key-path spend is byte-identical to an unconditional one. Bitcoin's validation rules mostly do not carry over:

  • The secret is a 33-byte compressed point. Signatures are BIP340, verified against its x-coordinate.
  • The tweak reduces mod n, where BIP341 rejects a digest at or above the curve order.
  • The tweak preimage takes the full 33-byte internal key. Ported code hashing a 32-byte x-only key derives a different tweak and an unspendable proof.
  • Leaves are declarative TLV records, not script. Leaf version 0x00 is a fixed vocabulary (threshold, after, hashlock, commit), and an unknown field of either parity rejects.
  • The merkle fold is normative and sorts, so the root commits the leaf set and a payer can reproduce a payee's requested tree. A tree holds at most 8 leaves, so a path has at most 3 sibling hashes.
  • The tagged-hash domains are Cashu_NutrootLeaf, Cashu_NutrootBranch and Cashu_NutrootTweak, so no derived value is interchangeable with Bitcoin's.
  • A script-only internal key is H + u*G with a fresh u per proof, disclosed so a holder can verify that no key path exists.
  • The signed digest comes from the NUT-00 transaction transcript, whose inputs are proofs and paid mint quotes and whose outputs are blinded messages and melt quotes. There is no sighash, annex or input index.

Implementations

Ticked once each matches this revision.

Stacking

Based on bls-protocol (#371's content plus current main), so the diff is nutroot alone. Retargets to main once #371 merges.

Defines the v3 secret family: point secrets with conditions committed
into the key (a BIP341-style tree of declarative leaves), one
transaction transcript that every input signs, key and script path
witnesses, spend info for transfer, and receive-time verification.

Existing NUTs gain version-scoped deltas: NUT-00 (secret bytes, secret
uniqueness, si in the V4 format), NUT-03/05 (v3 inputs witness the
transcript), NUT-04 (v3 quotes are locked transaction inputs), NUT-29
(a batch is one transaction), NUT-11/14/20 (scoped to pre-v3 keysets),
NUT-13 (v3 secret key branch), NUT-18/26 (the taproot payment request
option, TLV tag 0x0b), NUT-28 (the blinding core one layer down: slot
map, value matching, verbatim NUMS bases).

Adds tests/10-tests.md and extends the NUT-13/18/26/28 vectors; all
values are produced and cross-verified by two implementations.
The NUT-13 V3 vectors are re-derived under the framed keyset id,
keeping the secret-key semantics, and the NUT-10 transcript and token
vectors are realigned with the regenerated shared vector file.
- NUT-10: the tweak scalar reduces mod n, never rejected; NUMS provenance
  names the uncompressed encoding of G; the witness merkle path cap is
  explicit; the SIG_ALL mixing rationale cites NUT-11's actual rule
- NUT-00: wallets must preserve si when re-serializing proofs
- NUT-05: change outputs precede the melt quote in the transcript, with a
  melt-with-change vector exercising the ordering and zero amounts
A quote input commits the quote's face amount, never the draw: the
output section already binds what a transaction takes, and partial
mints re-sign the same face amount per request. Quotes with no fixed
amount (eg onchain) commit 0. NUT-04 gets a pointer bullet, and the
quote and output binding rules become a list.
One index byte admits slot 0 plus 255 leaf keys; the wrap the cap
guards against happens at the 256th key, not the 255th.
Two named checks: the spend info reconstructs the secret (base key
precedence, with the key derived from E authoritative over a disclosed
K), and the wallet can spend it (pinning the no-spend-info and lone-K
cases). The pairing check and immediate sweeping are gathered under
recommended checks.
The disclosed tree must match l one-to-one, order-insensitive, byte
identical apart from in-place blind-me substitutions. A tree with any
other leaf is refused: an extra leaf is spend power the payee never
requested.
A request or batch with any v3 output follows the taproot section and
all outputs share that keyset, so which message the signature covers is
never in question. Matches deployed mint behavior.
NUT-10 already defines K as the internal key, but the receive-time
cascade and the v3 slot map called the same point the "base key", to the
point of needing both names in one sentence to read ("with the base key
as internal key"). Use the one name everywhere.

No normative change: the same key, the same checks, the same vectors.
Script-only secrets used BIP341's H verbatim, so the internal key repeated by
design and uniqueness had to come from tree content: at least one leaf differing
per proof, in practice a blinded leaf key. That makes a fatal property depend on
data no verifier checks, and it forced NUT-18 to demand a blind-me key on every
NUMS request.

Take the other half of BIP341's construction instead: K = H + r*G with a fresh r
per proof. Knowing dl(K) would give dl(H) = dl(K) - r, so the key stays
unspendable, and it is unique per proof without touching the tree. r travels in
spend info and holders verify K - r*G == H.

This costs the ability to recognise a script-only proof from K alone, which
BIP341 treats as a leak to avoid rather than a feature; anyone who needs to know
has the spend info. It lets NUT-18 drop the blind-me requirement, so a NUMS
request now reproduces the requested tree unchanged.
Uniqueness is stated as a MUST with no indication of who enforces it, which
reads as though something checks. Nothing does: outputs are blinded, so the mint
never sees the secret, and two outputs sharing a secret under different blinding
factors have different B_, so both get signed. It surfaces only when the first
spend burns the shared Y and every other proof carrying it is refused as already
spent, mint-wide and permanently.

Say so, and give wallets the check that follows from it.
The only sweep guidance was scoped to bearer receivers, so a wallet
implementing this NUT faithfully would hold a receiver-keyed proof
indefinitely. Nothing about receiver-keyed sends makes holding unsafe, which
is exactly the problem: the ephemeral is wallet data and not seed-derivable,
so an unswept proof cannot be restored from the seed alone. The user has their
twelve words and the money is gone.

Widen it to all three shapes and give each its own reason: a bearer sender
holds the same scalar and can conceal a tweaked tree; a receiver-keyed
ephemeral is unrecoverable until swept; a disclosed tree with no key hands the
key-path holder an open-ended claim unless K is a NUMS offset.
Quote lock keys and self-owned leaf keys derived exactly like proof secret
keys, sharing one counter on derivation type 0x00. Collide across purposes and
the result is not two colliding proofs but one key wearing two hats: a quote
lock key may be handed over for delegated minting, a leaf key is published in
the tree, and a proof secret key must stay secret. NUT-20 had named this
isolation before v3 collapsed it.

Give each purpose its own type: 0x02 NUMS offset, 0x03 leaf key, 0x04 quote
lock. Types 0x00-0x03 are components of one proof allocation and share its
counter, which makes uniqueness self-enforcing: reuse a counter and both k and
r repeat, so B_ repeats and mints refuse it. Quote locks keep the independent
counter NUT-20 already specifies.

Frame the v3 message's one variable-length field, matching NUT-02 and the
NUT-00 transcript. The v2 message keeps its unframed form, dispatched by
keyset version, because v2 is deployed. Framing is what lets a type carry its
own suffix without the encoding becoming ambiguous; attempt is hoisted ahead
of the suffix since every type rejection samples.
The headings grouped by KDF, but V2 and V3 both use HMAC-SHA256, so the KDF was
never the distinguishing axis: the keyset version is. That left the V3 sections
and the superseded P2PK path reading as peers, legacy content split either side
of the code examples, and #### headings nested directly under ##.

Reorder to lead with V3 and mark the older ones deprecated, matching NUT-00 and
NUT-10. Two content fixes fall out:

- Counter was filed under Legacy Derivation, but it is the counter rule for
  every version, and it matters more now that proof and quote counters differ.
  Promoted alongside the version sections.
- "Versioned Secret Derivation" was used twice, once for the dispatch and once
  for the code, so the two collided on anchors. The code one is renamed.

Code examples promoted to a section of their own, since they cover all
versions. The one-line HMAC-SHA256 Derivation body said only that the KDF
depends on the keyset version, which is what the new order expresses.
The heading became "Pairing-based BLS blind signatures" in 6e56a68, which
changed its anchor. Ten inbound links across seven files still named the
old slug and resolved to nothing.

Fragment only, no text changes.
… keyset

The NUMS offset was `r`, which NUT-00 and NUT-13 already use for the
blinding factor and NUT-28 for the slot tweak. NUT-13's type table derived
`r` on 0x01 and `r` on 0x02 two rows apart. It is now `u`, in the spend
info field and the V4 CBOR key as well as the prose.

Quote lock keys (type 0x04) framed the keyset id, but a quote is requested
before its keyset is chosen, so the key was bound to whichever keyset
happened to be current at the time. A rotation between quote and mint left
the wallet unable to sign for its own paid quote, and left seed-only
recovery impossible, since a restoring wallet has the quote's pubkey and no
record of the guess. Type 0x04 now frames an empty keyset id, and its
derivation moves to NUT-20 beside the pre-v3 path it supersedes, vectors
with it.

Framing was said to permit at most one variable-length field. That is V2's
constraint, whose unframed message parses from the right; a framed message
admits length-framed suffixes too, so the rule is now that the message
parses left to right without knowing its length.

The v3 vectors are regenerated throughout, since the framed message moved
every derived value and took the transcripts, digests and swap signature
with them.
The NUT-00 heading gained an Optional: prefix in review, and GitHub
lowercases slugs.
deriveSecretAndRHmacV3 built its message with baseMessage, the V2 helper,
which concatenates the keyset id unframed. The Python example and the V3
message definition both frame it, so the two examples derived different
values. Adds baseMessageV3 beside the V2 helper, as the Python has.
Both links resolved to tests/13.md.
Taproot invites assumptions the construction does not keep: the secret is
a compressed point rather than an x-only output key, the tweak reduces
mod n instead of being rejected, leaves are declarative TLV records with
no interpreter, and the fold and depth cap are our own.

Prose, headings, anchors and the NUT-18 payment request field rename. The
three tagged-hash domains keep their Tap names, with a line saying why:
the hash construction is BIP341's, and the Cashu_ prefix already
separates them from Bitcoin's. No derived value moves, so the vectors
stand.
The formula named K without its encoding, and BIP341 hashes a 32-byte
x-only key there, so ported code derives a different tweak.
The fold now sorts leaf hashes ascending before pairing, so the root
commits the leaf set and any transmitted order reconstructs it. The
receive-time rule that a permuted list is equivalent previously held
only for permutations the sorted-pair fold happened to absorb (8 of a
4-leaf tree's 24); it now holds by construction. Slot assignment still
walks the transmitted order and receivers still match by value
(NUT-28). Verifiers are unchanged: merkle paths validate by sorted
pairs whatever built them.

The tagged-hash domains Cashu_TapTweak/TapLeaf/TapBranch become
Cashu_NutrootTweak/NutrootLeaf/NutrootBranch, completing the nutroot
rename in the one pass that regenerates vectors anyway. The Cashu_
prefix stays (every domain tag in the protocol carries it); no version
suffix, since the leaf version byte and the keyset version already
provide widening channels.

Vectors regenerated: every tweak-derived value moves. Unchanged, as
expected: transcripts, digests, the NUT-13 derivations, P2BK blinding,
and the bearer and receiver-keyed-only tokens.
A NUMS request with no blind-me keys blinds nothing: the offset u
supplies uniqueness, so the payer picks no ephemeral and none travels,
and the payee rejects a presence mismatch in either direction. A
receiver-keyed request always blinds slot 0, so its E is unchanged.
NUT-28's fresh-ephemeral rule names the scope.

Also fixed in passing: step 1 still called the NUMS offset r, step 3
omitted u from the spend info list, and the NUMS payment vector
predated the offset design entirely (verbatim NUMS internal key, no u,
and a mandatory-blind claim the offset obsoleted). Regenerated with
deterministic offsets, plus a no-b companion vector carrying no E.
The presence half of the u rule binds the sender only: without the
offset in hand, H + u*G is indistinguishable from any other internal
key, so there is no enforcement point to implement. The proof simply
loses its script-only evidence, and a NUT-18 NUMS payment rejects it.
The absence half stays enforced (K - u*G must reduce to H).
The rejection-sampling order was the one per-type variable the table
did not carry, and it lived only in per-type prose: 0x01 samples
against BLS_FR_ORDER, every other type against SECP256K1_N. A future
type now cannot be added with an unstated order. The NUT-10/NUT-20
links move out of the cells; the per-type sections already carry them.
A pre-v3 witness signs over the secret, so the stored witness plus the
secret is self-verifying. A v3 witness signs the transaction digest,
so served alone it verifies nothing: the response gains a digest field
and a mint returning a version 02 witness must return its digest with
it. The witness bullet is scoped by keyset version while there, since
on v3 every input signs and the field is no longer conditional on a
spending condition.
A version 02 BAT's secret is a fresh random point and its AuthProof
carries a required witness: one BIP-340 signature by the secret's key
over the ordinary transaction digest of a request transcript, a single
authorized-request container (0x05: method, target, body hash) now
registered in NUT-10's table. No BAT-specific message construction
exists; the mint's check is the key-path witness check over a
differently populated transcript. The BAT stays out of the transcript,
bound by which key the signature verifies under. Intermediaries relay
the body byte for byte. The DLEQ sentences are version-scoped while
there (v3 verifies C by pairing), and a worked vector lands in
tests/22-tests.md.
The JSON above it already said nutroot; the base64 beneath still
encoded taproot, left behind by the rename. Re-encoded, byte-identical
otherwise, and now pinned by a cashu-ts test in both directions.
Leaf version 0x00 allocates only even field types, and an unknown
field of either parity now rejects. Odd numbers stay reserved for a
possible future ignorable-annotation class rather than being allocated
as ordinary fields, so that class can come back without a version bump.

The witness signatures array is bounded: exactly one entry on the key
path, at most the leaf's key count on the script path. Thresholds still
count distinct satisfied keys.

Rejection vectors added; no existing vector carries an odd field or an
oversized witness, so no derived value moves.
a1denvalu3 added a commit to cashubtc/nutshell that referenced this pull request Oct 20, 2026
Complete the scoped mint and wallet support for cashubtc/nuts#371 and
cashubtc/nuts#443, including payment requests and signing transport.
Remove unrelated changes.

Publish exact stored Nutroot witnesses and input digests in proof-state
queries and events, following the requested status-reporting behavior.

Validation: 123 targeted tests passed (4 skipped), 3 live WebSocket
subscription tests passed, make format, make check, and git diff --check.
Define Proof.witness in NUT-00 and move the x-only note to the key-path
witness. Fold the NUT-10 key uniqueness and spend info sections, which
NUT-00 covers, and name what a v3 mint must implement. Generalise the
0xFn rule to its section. NUT-22 tests gain a mint-under-a-path target
case. Wording elsewhere.
robwoodgate added a commit to robwoodgate/nutshell that referenced this pull request Oct 21, 2026
Adds the shared vectors for the simplified core revision of
cashubtc/nuts#443 and the signing package NUT (cashubtc/nuts#464): the
nutspA package on a script-only quote lock, leaf type 0xff, and a commit
leaf with an extra field. Additive: vectors for code still on this
branch stay until it goes.
thesimplekid pushed a commit to cashubtc/cdk that referenced this pull request Oct 21, 2026
The 0x12 transcript container gains field 04, issued: the quote's
amount_issued before this request (cashubtc/nuts#443), present even
when zero. The mint fills it from its own quote state for NUT-04 and
NUT-29 mints; the wallet signs with its stored quote's value.

nutroot_v3_vectors.json is refreshed from cashu-ts verbatim.
thesimplekid pushed a commit to cashubtc/cdk that referenced this pull request Oct 21, 2026
…receipts

Aligns Nutroot with the simplified core revision of cashubtc/nuts#443.
Check-state and WebSocket events publish a v3 proof's witness and input
digest from PENDING on, so the spend commitment, the leaf disclosure
field and spend receipts (nutrcA) go. Field 0x0a is unknown again.
Derived quote ids and the 0x05 quote id preimage leave core for the
transactions NUT.

The shared vectors are copied from cashu-ts; the five stale entries in
test_vectors.json now carry the current spec values.
thesimplekid pushed a commit to cashubtc/cdk that referenced this pull request Oct 21, 2026
A v3 mint lists its leaf types in the NUT-06 "10" setting as
leaf_version || leaf_type hex, the core leaves always included
(cashubtc/nuts#443). Nut10Settings carries them; an empty list is
omitted, so existing JSON round-trips.
robwoodgate added a commit to robwoodgate/cdk that referenced this pull request Oct 21, 2026
The test mints through an unlocked quote with no signature, which a
version 02 keyset refuses: v3 quotes are locked and their witness signs
the transaction (cashubtc/nuts#443). The other RPC tests keep the
default keyset.
robwoodgate added a commit to cashubtc/cashu-ts that referenced this pull request Oct 23, 2026
Aligns nutroot with the simplified core revision of cashubtc/nuts#443.

- Check-state returns a v3 proof's witness and input digest from
`PENDING` on (NUT-07), so spend commitments and spend receipts go:
`spendCommitment`, `SpendReceipt`, `verifySpendReceipt`,
`encodeSpendReceipt`/`decodeSpendReceipt` (`nutrcA`), `receipts` on send
and melt results, and `ProofState.commitment`.
- The leaf `disclosure` field is gone, with `LockBuilder.disclose()`,
`LockOptions.disclosure` and the auditable lock helpers. Field `0x0a` is
unknown again and a leaf carrying it fails to parse.
- Derived quote ids (`derivedQuoteId`, `deriveQuoteIdPreimage`, NUT-13
`0x05`) leave core; #1290 brings them back with mint quote outputs.
- The unknown leaf type vector uses `0xff`, which NUT-10 never
allocates. The generator now builds the commit leaf and `nutspA` vectors
on a plain threshold leaf, and they match the spec's values.
- `MintInfo.nutrootLeafTypes` reads the leaf types a mint lists in its
NUT-06 `"10"` setting (NUT-10). A lock with no key path is refused if it
uses a type the mint does not list; a mint with no list, or an empty
one, counts as listing the core leaves.
- The signing package API (`ScriptPath`) is marked experimental: it
follows the draft signing package NUT, cashubtc/nuts#464.
- Signing packages also cover locked mint quotes (NUT-04):
`ScriptPath.extractMintPackage` and `mergeMintPackage` carry a quote's
script path spend, with the quote's tree and `K` supplied in the plan,
and write the witness as the quote's `signature` in a mint or batch mint
preview. `MintProofsConfig.signingPackage` opts `prepareMint` and
`prepareBatchMint` into leaving such a quote unsigned instead of failing
fast. A package with no spends is refused, and a v3 batch preview
carries no legacy signatures.

Breaking, for v5. Integration tested against a cdk mint built from the
matching core trim (61 passed, 19 skipped).
A key-path spend shows only a signature on P; a script-path spend shows
K and the exercised leaf, which suits a publicly verifiable lock such as
a Nutzap. NUT-18 notes the request shape for one.
The quote's message is picked by the outputs' version, so only pre-v3
outputs need excluding. Several v3 keysets in one request are fine, as
CDK already accepts.
robwoodgate added a commit to cashubtc/cashu-ts that referenced this pull request Oct 26, 2026
# NUTS:

- cashubtc/nuts#443

A v3 mint or batch mint request may now put its outputs on several v3
keysets; only mixing in pre-v3 outputs is refused.

## Why


[NUT-04](https://github.com/cashubtc/nuts/blob/taproot-secrets/04.md#nutroot-transactions-v3-keysets)
and NUT-29 dropped the one-keyset rule: the outputs' version picks the
message the quote signs, so only pre-v3 outputs need excluding.
@ye0man ye0man moved this from Backlog to In progress in nuts Oct 28, 2026
The witness is one JSON object, with NUT-10 owning the script-path
fields and `leaf` selecting the path. A proof's witness is always the
object; a mint request's `signature` also takes a bare key-path
signature, and NUT-04 now carries that rule with the field it governs.
NUT-04 marks `pubkey` optional only on pre-v3 keysets and lists
`signature` in the mint request.
@ye0man ye0man added the breaking change This issue/PR entails breaking changes label Nov 3, 2026
@ye0man ye0man moved this from In progress to Needs Review in nuts Nov 3, 2026

@ye0man ye0man left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ACK just wondering if it makes sense to include a line from #445 into 00.md's signing rule

Comment thread 00.md
- each proof input signs with its secret's key,
- each mint quote input signs with its quote lock key ([NUT-04][04]).

Verification is per input, and pre-v3 inputs keep their own rules (the [well-known Secret](10.md#well-known-secret), [NUT-11][11], [NUT-14][14]), so keyset versions mix in one transaction. NUT-11's `SIG_ALL` and `sigflag` apply only to pre-v3 inputs.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed #445 already says "a pre-v3 proof with SIG_ALL MUST be rejected", but that's scoped to /v1/transaction, so swap/melt still allow it today. NUT-11 requires all SIG_ALL inputs to share Secret.data/Secret.tags and signs secret‖C plus amount‖B_; a v3 input has no Secret.data/tags, and the v3 transcript instead binds keyset id, Y, SHA256(B_), amount_issued and fee_index. In a migration swap (pre-v3 SIG_ALL inputs → v3 outputs), what is the signature meant to cover?

Assuming we want the same behavior everywhere, would it make sense to lift #445's line into 00.md's signing rule so it also covers the fixed-shape endpoints — e.g. "a pre-v3 proof with SIG_ALL in a transaction containing any v3 input or output MUST be rejected"? That keeps swap/melt consistent with /v1/transaction and avoids each implementation deciding, without needing a new NUT, and #445 can simply reference it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. I think this is covered already, in two halves.

A pre-v3 SIG_ALL input next to a v3 input is rejected by NUT-11 itself: all inputs must share kind, data and tags, and a v3 input has none to match. Nothing to add.

Pre-v3 SIG_ALL inputs with v3 outputs is the migration swap and it works as-is: the NUT-11 swap message takes amount||B_ from every output, and a v3 output is still a BlindedMessage with both. We need that swap to stay valid, since once a pre-v3 keyset goes inactive it is the only way to move a SIG_ALL-locked proof.

#445 rejects SIG_ALL for a different reason: NUT-11 defines its message only for the swap and melt request shapes, and /v1/transaction has quote inputs and outputs. That is endpoint-specific, so it stays there.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking change This issue/PR entails breaking changes existing nut needs review

Projects

Status: Needs Review

Development

Successfully merging this pull request may close these issues.

3 participants