From d263d8c2c4005a1037277b9202cb2bd28a14fb0c Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Tue, 21 Jul 2026 19:40:57 +0200 Subject: [PATCH] customanchor: preview output commitments Expose locally derived output commitment roots before virtual packets are committed, including split and backend-signer coverage. Closes #165. --- custom_anchor_builder.go | 124 ++++++++++++ custom_anchor_builder_test.go | 58 ++++++ custom_anchor_path_builder_test.go | 183 ++++++++++++++++-- .../advanced-custom-anchor-transactions.md | 25 ++- itest/custom_anchor_script_path_test.go | 19 ++ 5 files changed, 384 insertions(+), 25 deletions(-) diff --git a/custom_anchor_builder.go b/custom_anchor_builder.go index 0d0f662..9ffe0aa 100644 --- a/custom_anchor_builder.go +++ b/custom_anchor_builder.go @@ -174,6 +174,38 @@ type CustomAnchorPlannedOutputSummary struct { OPTrueSpend *CustomAssetOPTrueSpendInfo } +// CustomAnchorOutputCommitmentPreview describes the Taproot commitment roots +// a custom-anchor plan will insert into one Bitcoin output. The preview is +// computed locally and does not sign or commit a virtual transaction or mutate +// the plan. +type CustomAnchorOutputCommitmentPreview struct { + // LogicalOutputID is the stable caller-defined output allocation ID. + LogicalOutputID string + + // LogicalOutputIndex is the output position in the build request. + LogicalOutputIndex uint32 + + // PacketIndex identifies the prepared virtual packet. + PacketIndex uint32 + + // PacketRole identifies the active or passive packet collection. + PacketRole CustomAnchorPacketRole + + // VirtualOutputIndex identifies the output within PacketIndex. + VirtualOutputIndex uint32 + + // AnchorOutputIndex identifies the output in the anchor transaction. + AnchorOutputIndex uint32 + + // TaprootAssetRoot is the root of the Taproot Asset commitment before + // combining it with the host-supplied tapscript sibling. + TaprootAssetRoot Hash + + // TaprootMerkleRoot is the final BIP341 root that combines the Taproot + // Asset commitment with the optional host-supplied tapscript sibling. + TaprootMerkleRoot Hash +} + // NewCustomAnchorTxBuilder creates the advanced proof-selected builder. func (s *Wallet) NewCustomAnchorTxBuilder() *CustomAnchorTxBuilder { params, err := address.Net(s.networkHRP) @@ -274,6 +306,98 @@ func (p *CustomAnchorPlan) Outputs() []CustomAnchorPlannedOutputSummary { return result } +// PreviewOutputCommitments derives the exact output commitment roots without +// signing virtual inputs or calling CommitVirtualPsbts. It is intended for +// hosts that must compose and canonically order their final Bitcoin outputs +// before committing the transition. +// +// Taproot Asset V1 output commitment leaves use witness-stripped asset +// encoding, so the roots are independent of a later backend virtual-input +// signature. Split commitments bind the anchor output index, however, so +// callers that reindex outputs after a preview must rebuild and preview again +// before committing. +func (p *CustomAnchorPlan) PreviewOutputCommitments() ( + []CustomAnchorOutputCommitmentPreview, error) { + + if p == nil || p.client == nil || p.request == nil { + return nil, fmt.Errorf("nil custom anchor plan") + } + if !p.verification.Valid() { + return nil, fmt.Errorf("custom anchor plan verification is not " + + "valid") + } + if len(p.activeVirtualPSBTs) != len(p.backendSigning) { + return nil, fmt.Errorf("custom anchor plan signing classification " + + "is incomplete") + } + active, err := decodeVirtualPackets( + "preview active", p.activeVirtualPSBTs, + ) + if err != nil { + return nil, err + } + passive, err := decodeVirtualPackets( + "preview passive", p.passiveVirtualPSBTs, + ) + if err != nil { + return nil, err + } + allPackets := append( + append([]*tappsbt.VPacket(nil), active...), passive..., + ) + commitments, err := tapsend.CreateOutputCommitments(allPackets) + if err != nil { + return nil, fmt.Errorf("preview output commitments: %w", err) + } + + previews := make( + []CustomAnchorOutputCommitmentPreview, 0, len(p.outputs), + ) + for idx := range p.outputs { + planned := p.outputs[idx] + packets := active + if planned.PacketRole == CustomAnchorPacketRolePassive { + packets = passive + } else if planned.PacketRole != CustomAnchorPacketRoleActive { + return nil, fmt.Errorf("planned output %q has unknown packet "+ + "role %d", planned.LogicalOutputID, + planned.PacketRole) + } + if planned.PacketIndex >= uint32(len(packets)) { + return nil, fmt.Errorf("planned output %q packet index is "+ + "out of range", planned.LogicalOutputID) + } + packet := packets[planned.PacketIndex] + if planned.VirtualOutput >= uint32(len(packet.Outputs)) { + return nil, fmt.Errorf("planned output %q virtual index is "+ + "out of range", planned.LogicalOutputID) + } + virtualOutput := packet.Outputs[planned.VirtualOutput] + assetRoot, merkleRoot, err := deriveCustomAnchorOutputRoots( + virtualOutput, commitments, + ) + if err != nil { + return nil, fmt.Errorf("preview output %q commitment roots: "+ + "%w", planned.LogicalOutputID, err) + } + + previews = append(previews, + CustomAnchorOutputCommitmentPreview{ + LogicalOutputID: planned.LogicalOutputID, + LogicalOutputIndex: planned.RequestIndex, + PacketIndex: planned.PacketIndex, + PacketRole: planned.PacketRole, + VirtualOutputIndex: planned.VirtualOutput, + AnchorOutputIndex: planned.AnchorOutput, + TaprootAssetRoot: assetRoot, + TaprootMerkleRoot: merkleRoot, + }, + ) + } + + return previews, nil +} + // Verification returns a copy of the structured pre-commit verification // report. func (p *CustomAnchorPlan) Verification() CustomAnchorVerificationResult { diff --git a/custom_anchor_builder_test.go b/custom_anchor_builder_test.go index d2068a5..2136b0d 100644 --- a/custom_anchor_builder_test.go +++ b/custom_anchor_builder_test.go @@ -120,6 +120,64 @@ type customAnchorBuilderFixture struct { btcInputKey *btcec.PrivateKey } +// TestCustomAnchorOutputCommitmentPreviewSupportsBackendSigning proves V1 +// output roots do not depend on the delegated virtual-input signature. +func TestCustomAnchorOutputCommitmentPreviewSupportsBackendSigning( + t *testing.T) { + + t.Parallel() + + fixture := newCustomAnchorBuilderFixture(t) + var signCalls int + fixture.client.signVirtual = func(_ context.Context, packet []byte) ( + []byte, error) { + + signCalls++ + return customAnchorSignVirtualPacket( + t, fixture.assetSpendKey, packet, + ), nil + } + fixture.client.commit = func(_ context.Context, + req *CommitVirtualPsbtsRequest) (*CommitVirtualPsbtsResponse, + error) { + + return customAnchorTestCommitResponse(t, req), nil + } + capabilities := DefaultTapdCustomAnchorCapabilities() + client := &capableCustomAnchorBuilderTestClient{ + customAnchorBuilderTestClient: fixture.client, + capabilities: &capabilities, + } + plan, err := NewWallet(client, NetworkRegtest). + NewCustomAnchorTxBuilder().Build( + context.Background(), fixture.request, + ) + require.NoError(t, err) + + previews, err := plan.PreviewOutputCommitments() + require.NoError(t, err) + require.Len(t, previews, 1) + require.Zero(t, signCalls) + + sealed, err := plan.Commit(context.Background()) + require.NoError(t, err) + require.Equal(t, 1, signCalls) + require.Len(t, sealed.Outputs, 1) + require.Equal(t, previews[0].TaprootAssetRoot, + sealed.Outputs[0].TaprootAssetRoot) + require.Equal(t, previews[0].TaprootMerkleRoot, + sealed.Outputs[0].TaprootMerkleRoot) +} + +// TestCustomAnchorOutputCommitmentPreviewRejectsEmptyPlan prevents a manually +// constructed zero-value plan from appearing to have a valid empty preview. +func TestCustomAnchorOutputCommitmentPreviewRejectsEmptyPlan(t *testing.T) { + t.Parallel() + + _, err := (&CustomAnchorPlan{}).PreviewOutputCommitments() + require.ErrorContains(t, err, "nil custom anchor plan") +} + func TestCustomAnchorBuilderRequiresWalletFundingLock(t *testing.T) { t.Parallel() diff --git a/custom_anchor_path_builder_test.go b/custom_anchor_path_builder_test.go index 00c9f69..4741d66 100644 --- a/custom_anchor_path_builder_test.go +++ b/custom_anchor_path_builder_test.go @@ -73,6 +73,135 @@ func TestCustomAnchorPathBuilderConsumesUnconfirmedTip(t *testing.T) { packet.Inputs[0].Proof.OutPoint()) } +// TestCustomAnchorOutputCommitmentPreviewMatchesCommit proves a +// caller-witnessed plan can expose its exact output roots before the backend +// commits the transition. +func TestCustomAnchorOutputCommitmentPreviewMatchesCommit(t *testing.T) { + t.Parallel() + + fixture := newCustomAnchorPathBuilderFixture(t) + request := fixture.request.Clone() + anchor, err := decodeAnchorPSBT(request.AnchorPSBT) + require.NoError(t, err) + equalCarrier := (anchor.UnsignedTx.TxOut[0].Value + + anchor.UnsignedTx.TxOut[1].Value) / 2 + require.Equal(t, int64(6_561), equalCarrier) + anchor.UnsignedTx.TxOut[0].Value = equalCarrier + anchor.UnsignedTx.TxOut[1].Value = equalCarrier + request.AnchorPSBT, err = serializePSBT(anchor) + require.NoError(t, err) + receiver := cloneCustomAssetOutput(request.Outputs[0]) + receiver.ID = "asset-receiver" + receiver.Amount = 60 + receiver.AnchorValueSat = uint64(equalCarrier) + change := cloneCustomAssetOutput(request.Outputs[0]) + change.ID = "asset-change" + change.Amount = 40 + change.AnchorOutputIndex = 0 + change.AnchorValueSat = uint64(equalCarrier) + change.Script.External.ScriptKey = customAnchorExternalScriptKey( + t, testPrivateKey(t, 56), + ) + change.Anchor.InternalKey.PubKey = mustCustomAnchorPubKey( + t, testPrivateKey(t, 57).PubKey(), + ) + request.Outputs = []CustomAssetOutput{receiver, change} + witnessBuilder := NewWallet(fixture.client, NetworkRegtest). + NewCustomAnchorTxBuilder() + request.Inputs[0].Witness.Stack = customAnchorPathSpendWitness( + t, witnessBuilder, fixture.transition, request.Outputs, + fixture.tipSpendKey, + ) + fixture.client.commit = func(_ context.Context, + req *CommitVirtualPsbtsRequest) (*CommitVirtualPsbtsResponse, + error) { + + return customAnchorTestCommitResponse(t, req), nil + } + capabilities := DefaultTapdCustomAnchorCapabilities() + client := &capableCustomAnchorBuilderTestClient{ + customAnchorBuilderTestClient: fixture.client, + capabilities: &capabilities, + } + builder := NewWallet(client, NetworkRegtest). + NewCustomAnchorTxBuilder().SetConfirmedProofVerifier( + fixture.verifier, + ) + + plan, err := builder.Build(context.Background(), request) + require.NoError(t, err) + beforePackets := plan.ActiveVirtualPSBTs() + require.Len(t, beforePackets, 1) + preparedPacket, err := tappsbt.Decode(beforePackets[0]) + require.NoError(t, err) + isSplit, err := preparedPacket.HasSplitCommitment() + require.NoError(t, err) + require.True(t, isSplit) + _, err = preparedPacket.SplitRootOutput() + require.NoError(t, err) + previews, err := plan.PreviewOutputCommitments() + require.NoError(t, err) + require.Len(t, previews, 2) + require.Equal(t, uint32(1), previews[0].AnchorOutputIndex) + require.Equal(t, uint32(0), previews[1].AnchorOutputIndex) + for idx := range previews { + require.NotZero(t, previews[idx].TaprootAssetRoot) + require.NotZero(t, previews[idx].TaprootMerkleRoot) + } + repeated, err := plan.PreviewOutputCommitments() + require.NoError(t, err) + require.Equal(t, previews, repeated) + require.Equal(t, beforePackets, plan.ActiveVirtualPSBTs()) + + // Split locators bind the anchor output index. Rebuilding the same + // equal-value logical allocations with swapped indices must therefore + // produce different previews before a host checks for a stable canonical + // permutation. + swapped := request.Clone() + swapped.Outputs[0].AnchorOutputIndex = 0 + swapped.Outputs[1].AnchorOutputIndex = 1 + swapped.Inputs[0].Witness.Stack = customAnchorPathSpendWitness( + t, witnessBuilder, fixture.transition, swapped.Outputs, + fixture.tipSpendKey, + ) + swappedPlan, err := builder.Build(context.Background(), swapped) + require.NoError(t, err) + swappedPreviews, err := swappedPlan.PreviewOutputCommitments() + require.NoError(t, err) + require.Len(t, swappedPreviews, 2) + require.Equal(t, previews[0].LogicalOutputID, + swappedPreviews[0].LogicalOutputID) + require.Equal(t, previews[1].LogicalOutputID, + swappedPreviews[1].LogicalOutputID) + require.NotEqual(t, previews, swappedPreviews) + require.NotEqual(t, previews[0].TaprootAssetRoot, + swappedPreviews[0].TaprootAssetRoot) + + sealed, err := plan.Commit(context.Background()) + require.NoError(t, err) + require.Len(t, sealed.Outputs, 2) + require.Len(t, sealed.ActiveVirtualPsbts, 1) + committedPacket, err := tappsbt.Decode(sealed.ActiveVirtualPsbts[0]) + require.NoError(t, err) + var alternateLeaves int + for idx := range committedPacket.Outputs { + alternateLeaves += len(committedPacket.Outputs[idx].AltLeaves) + } + require.Positive(t, alternateLeaves) + for idx := range sealed.Outputs { + require.Equal(t, sealed.Outputs[idx].LogicalOutputID, + previews[idx].LogicalOutputID) + require.Equal(t, sealed.Outputs[idx].LogicalOutputIndex, + previews[idx].LogicalOutputIndex) + require.Equal(t, sealed.Outputs[idx].AnchorOutputIndex, + previews[idx].AnchorOutputIndex) + require.Equal(t, sealed.Outputs[idx].TaprootAssetRoot, + previews[idx].TaprootAssetRoot) + require.Equal(t, sealed.Outputs[idx].TaprootMerkleRoot, + previews[idx].TaprootMerkleRoot) + } +} + // TestCustomAnchorPathBuilderRequiresOneProofSource keeps confirmed proofs and // compact paths a disjoint input union. func TestCustomAnchorPathBuilderRequiresOneProofSource(t *testing.T) { @@ -218,11 +347,12 @@ func TestCustomAnchorPathBuilderRejectsDuplicateTip(t *testing.T) { } type customAnchorPathBuilderFixture struct { - request *CustomAnchorRequest - path *AssetProofPath - transition *proof.Proof - verifier *testConfirmedProofVerifier - client *customAnchorBuilderTestClient + request *CustomAnchorRequest + path *AssetProofPath + transition *proof.Proof + tipSpendKey *btcec.PrivateKey + verifier *testConfirmedProofVerifier + client *customAnchorBuilderTestClient } func newCustomAnchorPathBuilderFixture( @@ -338,14 +468,15 @@ func newCustomAnchorPathBuilderFixture( request.Inputs[0].Witness = CustomAssetWitnessPlan{ Mode: CustomAssetWitnessCallerProvided, Stack: customAnchorPathSpendWitness( - t, builder, transition, request.Outputs[0], tipSpendKey, + t, builder, transition, request.Outputs, tipSpendKey, ), } return &customAnchorPathBuilderFixture{ - request: request, - path: path, - transition: transition, + request: request, + path: path, + transition: transition, + tipSpendKey: tipSpendKey, verifier: &testConfirmedProofVerifier{ result: &ConfirmedProofVerification{ AnchorAssetInventoryComplete: true, @@ -357,17 +488,21 @@ func newCustomAnchorPathBuilderFixture( func customAnchorPathSpendWitness(t *testing.T, builder *CustomAnchorTxBuilder, inputProof *proof.Proof, - output CustomAssetOutput, spendKey *btcec.PrivateKey) [][]byte { + outputs []CustomAssetOutput, spendKey *btcec.PrivateKey) [][]byte { t.Helper() - internalKey := mustParseCustomAnchorPubKey( - t, output.Anchor.InternalKey.PubKey, - ) - scriptKey, err := tapAssetScriptKey(output.Script.External.ScriptKey) - require.NoError(t, err) - packets, err := tapsend.DistributeCoins( - []*proof.Proof{inputProof}, []*tapsend.Allocation{{ + allocations := make([]*tapsend.Allocation, len(outputs)) + for idx := range outputs { + output := outputs[idx] + internalKey := mustParseCustomAnchorPubKey( + t, output.Anchor.InternalKey.PubKey, + ) + scriptKey, err := tapAssetScriptKey( + output.Script.External.ScriptKey, + ) + require.NoError(t, err) + allocations[idx] = &tapsend.Allocation{ Type: tapsend.CommitAllocationToRemote, OutputIndex: output.AnchorOutputIndex, InternalKey: internalKey, @@ -375,7 +510,11 @@ func customAnchorPathSpendWitness(t *testing.T, Amount: output.Amount, AssetVersion: asset.V1, BtcAmount: btcutil.Amount(output.AnchorValueSat), - }}, builder.params, true, tappsbt.V1, + } + } + packets, err := tapsend.DistributeCoins( + []*proof.Proof{inputProof}, allocations, builder.params, true, + tappsbt.V1, ) require.NoError(t, err) require.Len(t, packets, 1) @@ -388,8 +527,14 @@ func customAnchorPathSpendWitness(t *testing.T, signed := customAnchorSignVirtualPacket(t, spendKey, encoded) signedPacket, err := tappsbt.Decode(signed) require.NoError(t, err) - require.Len(t, signedPacket.Outputs, 1) newAsset := signedPacket.Outputs[0].Asset + isSplit, err := signedPacket.HasSplitCommitment() + require.NoError(t, err) + if isSplit { + rootOutput, err := signedPacket.SplitRootOutput() + require.NoError(t, err) + newAsset = rootOutput.Asset + } require.NotNil(t, newAsset) require.Len(t, newAsset.PrevWitnesses, 1) require.NotEmpty(t, newAsset.PrevWitnesses[0].TxWitness) diff --git a/docs/design/advanced-custom-anchor-transactions.md b/docs/design/advanced-custom-anchor-transactions.md index 0893d05..beb8285 100644 --- a/docs/design/advanced-custom-anchor-transactions.md +++ b/docs/design/advanced-custom-anchor-transactions.md @@ -264,21 +264,25 @@ The concrete lifecycle is: returns an immutable `CustomAnchorPlan`. 3. The caller inspects `plan.Verification()`, `plan.AnchorPSBT()`, and the prepared virtual packets. No Bitcoin signature has been requested. -4. `Commit` checks required backend capabilities. It asks the backend to sign +4. The caller can use `PreviewOutputCommitments` to derive the exact Taproot + Asset and BIP341 output roots without signing or committing. This lets a + host compose and canonically order final Bitcoin output scripts before it + freezes the transaction. +5. `Commit` checks required backend capabilities. It asks the backend to sign virtual packets whose inputs use the backend-signer mode, then calls `CommitVirtualPsbts`. -5. The SDK validates the backend's funding shape and runs +6. The SDK validates the backend's funding shape and runs `ValidateAnchorInputs` and `ValidateAnchorOutputs` over the complete active and passive packet set. It returns a sealed `CustomAnchorTransferPackage`. -6. The caller persists the package before external signing, broadcasting, or +7. The caller persists the package before external signing, broadcasting, or handing it to another service. -7. The caller derives typed Bitcoin signing requests from the package, applies +8. The caller derives typed Bitcoin signing requests from the package, applies external signatures or script witnesses on returned package clones, and invokes an `AnchorSigner` for wallet-managed Bitcoin inputs when needed. -8. `VerifyFinalAnchorPSBT` compares the complete unsigned transaction and all +9. `VerifyFinalAnchorPSBT` compares the complete unsigned transaction and all non-signature PSBT metadata with the frozen committed transaction. -9. `Wallet.PublishCustomAnchorTransfer` additionally requires a fully +10. `Wallet.PublishCustomAnchorTransfer` additionally requires a fully finalized PSBT, revalidates all asset commitments, checks skip-broadcast capability, and calls tapd's publish-and-log operation. @@ -293,6 +297,15 @@ OP_TRUE path controlled by the integrating protocol. Interactive asset signing after packet assembly requires a separate immutable asset-signing request/apply phase and is follow-up work. +Taproot Asset V1 output commitment leaves use witness-stripped asset encoding, +so `PreviewOutputCommitments` does not need backend virtual-input signatures. +It operates on decoded copies, so deriving the deterministic spent-output +alternate leaves cannot mutate the immutable plan. For split transitions the +commitment root depends on the virtual output's anchor index. A host that uses +the preview to sort equal-value Bitcoin outputs must therefore rebuild and +preview after reindexing, then require the final permutation to be stable +before commit. + ## Durable Transfer Package `CustomAnchorTransferPackage` is the recovery boundary. It stores: diff --git a/itest/custom_anchor_script_path_test.go b/itest/custom_anchor_script_path_test.go index 3c78db1..a11e18d 100644 --- a/itest/custom_anchor_script_path_test.go +++ b/itest/custom_anchor_script_path_test.go @@ -139,6 +139,9 @@ func TestCustomAnchorBuilderScriptPathTimeoutSweep(t *testing.T) { require.NoError(t, err) lockVerification := lockPlan.Verification() require.True(t, lockVerification.Valid()) + lockPreview, err := lockPlan.PreviewOutputCommitments() + require.NoError(t, err) + require.Len(t, lockPreview, 1) lockSealed, err := lockPlan.Commit(ctx, tapsdk.CustomAnchorCommitOptions{ Publish: tapsdk.CustomAnchorPublishMetadata{Label: name}, @@ -147,6 +150,10 @@ func TestCustomAnchorBuilderScriptPathTimeoutSweep(t *testing.T) { require.NoError(t, lockSealed.Validate()) require.Len(t, lockSealed.Outputs, 1) timeoutOutput := lockSealed.Outputs[0] + require.Equal(t, timeoutOutput.TaprootAssetRoot, + lockPreview[0].TaprootAssetRoot) + require.Equal(t, timeoutOutput.TaprootMerkleRoot, + lockPreview[0].TaprootMerkleRoot) require.NotNil(t, timeoutOutput.OPTrueSpend) require.NoError(t, timeoutOutput.OPTrueSpend.Validate( timeoutOutput.ScriptKey, @@ -247,6 +254,9 @@ func TestCustomAnchorBuilderScriptPathTimeoutSweep(t *testing.T) { require.NoError(t, err) sweepVerification := sweepPlan.Verification() require.True(t, sweepVerification.Valid()) + sweepPreview, err := sweepPlan.PreviewOutputCommitments() + require.NoError(t, err) + require.Len(t, sweepPreview, 1) sweepSealed, err := sweepPlan.Commit(ctx, tapsdk.CustomAnchorCommitOptions{ Publish: tapsdk.CustomAnchorPublishMetadata{ @@ -255,6 +265,15 @@ func TestCustomAnchorBuilderScriptPathTimeoutSweep(t *testing.T) { }) require.NoError(t, err) require.NoError(t, sweepSealed.Validate()) + require.Len(t, sweepSealed.Outputs, 1) + require.Equal(t, sweepSealed.Outputs[0].LogicalOutputID, + sweepPreview[0].LogicalOutputID) + require.Equal(t, sweepSealed.Outputs[0].AnchorOutputIndex, + sweepPreview[0].AnchorOutputIndex) + require.Equal(t, sweepSealed.Outputs[0].TaprootAssetRoot, + sweepPreview[0].TaprootAssetRoot) + require.Equal(t, sweepSealed.Outputs[0].TaprootMerkleRoot, + sweepPreview[0].TaprootMerkleRoot) // This is the assertion the existing custom-anchor itests invert: the // unilateral sweep produces exactly one script-path signing request and