multi: asset tree wire format, codecs, and flow version V2 (M2.1) - #1091
Conversation
Flow-V2 rounds use an RBF-signalling input sequence on every tree transaction; the sequence is consensus-visible, so nodes carry it and ToTx derives it via TxSequence. Trees pair with their AssetTreeContext directly, and subtree amounts gain input-outpoint fallback keying so extracted and deserialized clones still resolve them.
Built trees now carry their context and asset ref, node templates follow the node sequence, and subtree amounts re-register under the node's input outpoint once materialization assigns it.
TreeNode gains the per-node signing tweak and subtree asset amount, VTXOTree the tree's asset ref. Deserialization recomputes node keys with the per-node tweak, rebuilds the AssetTreeContext, and stamps a flow-version-derived node sequence via WithNodeSequence. FlowVersionV2 is the asset-aware round flow; the ingress guard now accepts it.
New optional TLV records carry the tree's asset ref and node sequence plus per-node signing tweaks, sealed packages, and subtree amounts. Bitcoin-only flow-V1 blobs stay byte-identical.
Validate the flow version before decoding trees and stamp SequenceV2 onto every node of a V2 round, keeping txid-keyed signing consistent with the operator.
tap-sdk PR #173 adds the CallerSigned signing-plan variant needed to classify lnd funding inputs on caller-funded anchors.
BatchAnchorCommitter mirrors the operator's commitment-transaction flow: derive the composed batch output script before funding (preview against a balance-stubbed template), then seal against the final funded transaction with fail-closed reproduction of the derivation. The batch output uses a deterministic OP_TRUE asset script key so the derivation is stable across builds and the tree's root node spends it with a caller-provided witness. The returned root source chains the tree beneath the unconfirmed commitment transition, and the materializer extends child proof paths from the root's recorded steps.
The e2e now mirrors the round lifecycle exactly: derive the batch script pre-funding, fund with an LND wallet UTXO, commit the sealed transition, build and sign the whole tree against the unconfirmed commitment transition, and only then broadcast, confirm, and unroll. Change pays to P2WPKH because tapd requires the taproot internal key of every non-asset P2TR output for its exclusion proofs.
tap-sdk PR #173 (caller-signed anchor inputs) merged.
Round integrations verify operator tranche proofs against the operator's own tapd inventory; the verifier was package-private.
Persistence and round bookkeeping need each sealed commit's asset reference and anchor output position without reparsing the package.
The operator inventory funds batch outputs from ordinary UTXOs; the issuance-level tranche notion stays hidden behind AssetRef and only surfaces where the protocol demands it (proof export by issuance id).
Taproot Asset exclusion proofs require every non-asset P2TR output of the commitment transaction to carry its internal key and BIP-371 tap tree on the PSBT; the batch output builder now exposes both.
The caller's synthetic key-spend appearance satisfies its funding wallet's weight estimator, but tapd skips writing its real derivation material when caller metadata is present — and without it the wallet backing tapd cannot sign the funding input. The commit request now hands tapd a copy with that input cleared.
External broadcast leaves tapd's transfer bookkeeping incomplete: the funding UTXO stays in inventory and no proofs generate. Publishing the sealed package with the finalized anchor PSBT closes the loop.
Round integrations build asset tree leaves from the same policy shape the e2e proves; the projection was test-only.
In this commit, we fix two MuSig2 nonce handling bugs in the mockMuSig2Signer used by the lib/tree signing tests. First, MuSig2CreateSession advertised a freshly generated nonce in the session info but created the underlying musig2 session via ctx.NewSession(), which generates its own internal nonce pair. The nonce peers aggregated was never the nonce the session signed with. We now pass the advertised nonces into the session via musig2.WithPreGeneratedNonce. Second, MuSig2RegisterCombinedNonce registered the pre-aggregated nonce through RegisterPubNonce, which treats the aggregate as a single peer nonce. The session's combined nonce then became ownNonce + aggregate instead of the aggregate, and haveAll only flipped for 2-signer sessions. We now use the RegisterCombinedNonce primitive that btcec v2.5.0 provides for exactly this coordinator-style flow. These bugs were invisible because every ceremony in the suite is 2-of-2 and no test ever combined partial signatures across participants: a partial signature over a wrong combined nonce looks identical to a correct one until the aggregate is verified. We close that gap by extending TestFullSigningFlow to combine both parties' partial signatures per transaction and verify the final Schnorr signature against the node's final taproot key. Both subtests fail against the old mock with ErrFinalSigInvalid and pass with the fix. (cherry picked from commit 62e03d1)
In this commit, we port the lib/tree mock signer fixes to the round test harness's realMuSig2Signer, which was copied from the same broken pattern: MuSig2CreateSession created its musig2 session without WithPreGeneratedNonce, so the advertised nonce was never the one the session signed with, and MuSig2RegisterCombinedNonce fed the aggregate through RegisterPubNonce, corrupting the session's combined nonce and only flipping haveAll for 2-signer sessions. No round test currently drives a full combine through this signer: the error-path test (newUnaggregatedSignerSession) still gets its expected "not all nonces registered" failure since allNoncesKnown starts false, and generateValidTreeSignatures builds raw btcd sessions directly for real combines. The fix is about keeping the harness a correct reference for input.MuSig2Signer behavior rather than changing any current test outcome. (cherry picked from commit 392aa3a)
Operator-built asset trees derive every leaf owner key from the same wallet, so the full MuSig2 ceremony can run locally instead of over the client fan-out. SignTreeLocally spans one session per cosigner, aggregates nonces per transaction, and combines through the first cosigner's sessions, which must cover the whole tree.
fabd327 to
aadc6b7
Compare
1aa2174
into
darioAnongba/taproot-assets-oor-runtime
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: aadc6b71d6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| t, treeErr := roundpb.TreeFromProto( | ||
| pt, e.TreeOpts..., | ||
| pt, treeOpts..., | ||
| ) |
There was a problem hiding this comment.
Pass asset tweaks into the signing sessions
When a decoded tree contains per-node signing_tweak values, TreeFromProto computes each FinalKey with those tweaks and attaches the resulting AssetContext, but the later CreateSignerSessionJob construction in round/transitions.go leaves TweakLookup nil. Consequently, an asset-aware client signs every node using SweepTapscriptRoot instead of the key encoded in that node's output, so its partial signatures cannot complete or verify; populate the job's lookup from clientTree.AssetContext.
AGENTS.md reference: round/AGENTS.md:L164-L166
Useful? React with 👍 / 👎.
| if flowVersion >= roundpb.FlowVersionV2 { | ||
| treeOpts = append( | ||
| append( | ||
| []roundpb.TreeFromProtoOption(nil), treeOpts..., | ||
| ), | ||
| roundpb.WithNodeSequence(tree.SequenceV2), | ||
| ) |
There was a problem hiding this comment.
Apply the V2 sequence when reconstructing connector trees
For a V2 round with forfeit mappings, this stamps only the wire-decoded VTXO trees with SequenceV2; validateConnectorAncestry later rebuilds the connector tree through BuildConnectorTree, whose nodes retain the V1 final sequence. Since the connector transaction sequence changes its txid, the locally reconstructed leaf outpoint cannot match the operator's V2 ConnectorOutpoint, causing otherwise valid V2 rounds to fail ancestry validation before signing. Thread the flow version into connector reconstruction and stamp those nodes too.
AGENTS.md reference: round/AGENTS.md:L231-L241
Useful? React with 👍 / 👎.
| if err != nil { | ||
| return nil, fmt.Errorf("balance anchor template: %w", err) | ||
| } | ||
| copy(balanced.Inputs, template.Inputs) |
There was a problem hiding this comment.
Preserve PSBT output metadata in the balanced preview
When the pre-funding template includes non-asset P2TR outputs such as connectors, their TaprootInternalKey/TaprootTapTree data resides in template.Outputs, but balanceTemplate copies only the input maps into the new packet. buildRequest therefore serializes a preview PSBT without the metadata tapd needs to construct exclusion proofs, so DeriveScript fails on realistic commitment templates even though the caller supplied the required BIP-371 fields; copy the output maps as well.
Useful? React with 👍 / 👎.
Stacked on #1084. Client-side work for M2 of the round integration (execplan in lumos#744): milestone 1 (wire format, codecs, flow version) plus the milestone-2 batch-anchor machinery.
Milestone 1 — wire format, codecs, flow version
TreeNodegainssigning_tweak(the node's combined taproot tweak committing to sweep leaf + asset commitment root) andasset_amount(subtree total);VTXOTreegainsasset_ref. Bitcoin-only trees leave all three empty.TreeFromProtorecomputes each node'sFinalKeywith the per-node tweak when present (sweep-root fallback otherwise) and rebuilds theAssetTreeContext;TreeToProtoemits it. Sealed packages deliberately stay off the wire (operator persistence only).FlowVersionV2added and accepted by the ingress guard. V2 gates the deferredToTx()sequence change: node transactions move to the RBF-signallingMaxTxInSequenceNum - 2(tree.SequenceV2). The sequence is consensus-visible, so it is derived from the round's flow version at decode time (WithNodeSequence), andCommitmentTxBuilt.FromProtovalidates the version before decoding trees.Node.Sequence+TxSequence(),Tree.AssetContextpairing, input-outpoint fallback keying for subtree amounts so client-extracted paths (cloned nodes) still resolve them.Milestone 2 — caller-funded batch anchors (
tapassets.BatchAnchorCommitter)This is the mechanism lumos's
buildCommitmentTxwill call: batch output scripts are fixed beforeFundPsbt, but the asset transition can only commit against the final funded transaction.DeriveScriptpreviews the composed batch output script pre-funding (Build+PreviewOutputCommitmentsagainst a balance-stubbed template — no tapd mutation).Commitseals the transition against the funded transaction and verifies fail-closed: derived script byte-equal on the funded output, committed merkle/asset roots equal to the derivation, composed script reproducible from the committed roots, committed transaction byte-identical to the funded one.TreeRootAssetSourcechains the tree beneath the unconfirmed commitment transition through a compact proof path, with every ancestor step byte-bound.The e2e now runs the exact round lifecycle against real tapd (~40s): mint → derive script → fund with an LND UTXO → sealed commit → build + sign the whole tree on the unconfirmed commitment transition →
VerifySigned→ broadcast → confirm → unroll via package relay.Depends on tap-sdk#173 (
CallerSignedsigning-plan variant, pinned): lnd funding inputs on a caller-funded anchor were previously unclassifiable.Notes for the lumos integration
TaprootInternalKeyPSBT metadata for connector outputs and lnd change (lnd's FundPsbt populates its own change; connectors need explicit metadata), or use non-P2TR outputs.