Skip to content

multi: asset tree wire format, codecs, and flow version V2 (M2.1) - #1091

Merged
darioAnongba merged 19 commits into
darioAnongba/asset-tree-m1from
darioAnongba/asset-tree-m2-client
Aug 11, 2026
Merged

multi: asset tree wire format, codecs, and flow version V2 (M2.1)#1091
darioAnongba merged 19 commits into
darioAnongba/asset-tree-m1from
darioAnongba/asset-tree-m2-client

Conversation

@darioAnongba

@darioAnongba darioAnongba commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

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

  • Wire: TreeNode gains signing_tweak (the node's combined taproot tweak committing to sweep leaf + asset commitment root) and asset_amount (subtree total); VTXOTree gains asset_ref. Bitcoin-only trees leave all three empty.
  • Proto codec: TreeFromProto recomputes each node's FinalKey with the per-node tweak when present (sweep-root fallback otherwise) and rebuilds the AssetTreeContext; TreeToProto emits it. Sealed packages deliberately stay off the wire (operator persistence only).
  • Flow version V2: FlowVersionV2 added and accepted by the ingress guard. V2 gates the deferred ToTx() sequence change: node transactions move to the RBF-signalling MaxTxInSequenceNum - 2 (tree.SequenceV2). The sequence is consensus-visible, so it is derived from the round's flow version at decode time (WithNodeSequence), and CommitmentTxBuilt.FromProto validates the version before decoding trees.
  • lib/tree: Node.Sequence + TxSequence(), Tree.AssetContext pairing, input-outpoint fallback keying for subtree amounts so client-extracted paths (cloned nodes) still resolve them.
  • TLV codec: optional records for per-node tweak/sealed-package/amount and tree-level asset-ref/sequence; Bitcoin-only flow-V1 blobs stay byte-identical.

Milestone 2 — caller-funded batch anchors (tapassets.BatchAnchorCommitter)

This is the mechanism lumos's buildCommitmentTx will call: batch output scripts are fixed before FundPsbt, but the asset transition can only commit against the final funded transaction.

  • DeriveScript previews the composed batch output script pre-funding (Build + PreviewOutputCommitments against a balance-stubbed template — no tapd mutation).
  • Commit seals 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.
  • The batch output uses a deterministic OP_TRUE asset script key (backend-chosen wallet keys are re-derived per build and would diverge the roots), and the returned TreeRootAssetSource chains the tree beneath the unconfirmed commitment transition through a compact proof path, with every ancestor step byte-bound.
  • Full-value single-tranche only, by design: split commitments bind the anchor output index, so only splitless transitions survive output reordering by the funding wallet.

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 transitionVerifySigned → broadcast → confirm → unroll via package relay.

Depends on tap-sdk#173 (CallerSigned signing-plan variant, pinned): lnd funding inputs on a caller-funded anchor were previously unclassifiable.

Notes for the lumos integration

  • tapd requires the taproot internal key of every non-asset P2TR output on the anchor transaction (exclusion proofs). lumos must attach TaprootInternalKey PSBT metadata for connector outputs and lnd change (lnd's FundPsbt populates its own change; connectors need explicit metadata), or use non-P2TR outputs.
  • The batch-anchor digest scoping the OP_TRUE key must be known pre-funding (round ID, not the batch outpoint).

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.
@darioAnongba
darioAnongba force-pushed the darioAnongba/asset-tree-m2-client branch from fabd327 to aadc6b7 Compare August 6, 2026 13:40
@darioAnongba
darioAnongba marked this pull request as ready for review August 11, 2026 15:59
@darioAnongba
darioAnongba merged commit 1aa2174 into darioAnongba/taproot-assets-oor-runtime Aug 11, 2026
1 check passed
@darioAnongba
darioAnongba deleted the darioAnongba/asset-tree-m2-client branch August 11, 2026 16:01

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread round/from_proto.go
Comment on lines 235 to 237
t, treeErr := roundpb.TreeFromProto(
pt, e.TreeOpts...,
pt, treeOpts...,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment thread round/from_proto.go
Comment on lines +214 to +220
if flowVersion >= roundpb.FlowVersionV2 {
treeOpts = append(
append(
[]roundpb.TreeFromProtoOption(nil), treeOpts...,
),
roundpb.WithNodeSequence(tree.SequenceV2),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment thread tapassets/batch_anchor.go
if err != nil {
return nil, fmt.Errorf("balance anchor template: %w", err)
}
copy(balanced.Inputs, template.Inputs)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant