Repository navigation
NUT-10: Nutroot secrets (v3 keysets) - #443
robwoodgate wants to merge 102 commits into
Conversation
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.
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.
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.
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.
…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.
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.
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.
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.
# 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.
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.
| - 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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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
Scope
Core only: what every v3 mint and wallet needs. Optional follow-ons:
POST /v1/transaction, any mix of proofs and paid mint quotes in, blinded messages and a melt quote out.templateleaf, for transactions signed now and submitted later.nutspAsigning packages for co-signing wallets.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:
n, where BIP341 rejects a digest at or above the curve order.0x00is a fixed vocabulary (threshold,after,hashlock,commit), and an unknown field of either parity rejects.Cashu_NutrootLeaf,Cashu_NutrootBranchandCashu_NutrootTweak, so no derived value is interchangeable with Bitcoin's.H + u*Gwith a freshuper proof, disclosed so a holder can verify that no key path exists.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.