Skip to content

Commit cffc720

Browse files
committed
customanchor: expose output commitment roots
Persist the Taproot Asset and final BIP341 roots for each committed output so host protocols can compose policy control blocks without parsing proprietary PSBT fields. Reconstruct both roots from the complete packet set, bind tapd root hints, and cover Ark-like siblings, grouped issuances, passive assets, tampering, and live exact/P2A transfers.
1 parent 1c405cb commit cffc720

8 files changed

Lines changed: 269 additions & 1 deletion

custom_anchor_builder_test.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -639,9 +639,20 @@ func TestCustomAnchorBuilderGroupedMultiIssuance(t *testing.T) {
639639
require.Len(t, packageSnapshot.Outputs, 2)
640640
require.Len(t, packageSnapshot.ProofUpdates, 2)
641641
packageIssuances := make(map[AssetID]struct{})
642-
for _, output := range packageSnapshot.Outputs {
642+
for idx, output := range packageSnapshot.Outputs {
643643
packageIssuances[output.IssuanceID] = struct{}{}
644644
require.Equal(t, "group-receiver", output.LogicalOutputID)
645+
require.NotZero(t, output.TaprootAssetRoot)
646+
if idx > 0 {
647+
require.Equal(t,
648+
packageSnapshot.Outputs[0].TaprootAssetRoot,
649+
output.TaprootAssetRoot,
650+
)
651+
require.Equal(t,
652+
packageSnapshot.Outputs[0].TaprootMerkleRoot,
653+
output.TaprootMerkleRoot,
654+
)
655+
}
645656
}
646657
require.Len(t, packageIssuances, 2)
647658

custom_anchor_lifecycle.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1099,6 +1099,18 @@ func (p *CustomAnchorPlan) committedOutputSummaries(anchor *psbt.Packet,
10991099
[]CustomAnchorProofUpdate, error) {
11001100

11011101
txID := anchor.UnsignedTx.TxHash()
1102+
allPackets := append(
1103+
append([]*tappsbt.VPacket(nil), active...), passive...,
1104+
)
1105+
// The committed packets already contain tapd's deterministic STXO alt
1106+
// leaves. Reconstruct roots from those persisted leaves without adding a
1107+
// second copy.
1108+
commitments, err := tapsend.CreateOutputCommitments(
1109+
allPackets, tapsend.WithNoSTXOProofs(),
1110+
)
1111+
if err != nil {
1112+
return nil, nil, fmt.Errorf("derive committed output roots: %w", err)
1113+
}
11021114
outputs := make([]CustomAnchorAssetOutputSummary, 0, len(p.outputs))
11031115
updates := make([]CustomAnchorProofUpdate, 0, len(p.outputs))
11041116
for idx := range p.outputs {
@@ -1141,6 +1153,12 @@ func (p *CustomAnchorPlan) committedOutputSummaries(anchor *psbt.Packet,
11411153
return nil, nil, fmt.Errorf("committed output %q anchor value "+
11421154
"changed", planned.LogicalOutputID)
11431155
}
1156+
taprootAssetRoot, taprootMerkleRoot, err :=
1157+
deriveCustomAnchorOutputRoots(vOut, commitments)
1158+
if err != nil {
1159+
return nil, nil, fmt.Errorf("derive output %q commitment roots: %w",
1160+
planned.LogicalOutputID, err)
1161+
}
11441162
proofBlob, err := encodeTransitionProof(vOut.ProofSuffix)
11451163
if err != nil {
11461164
return nil, nil, fmt.Errorf("encode output %q proof suffix: %w",
@@ -1159,6 +1177,8 @@ func (p *CustomAnchorPlan) committedOutputSummaries(anchor *psbt.Packet,
11591177
AssetType: planned.AssetType,
11601178
AnchorOutpoint: anchorOutpoint,
11611179
AnchorValueSat: anchorValue,
1180+
TaprootAssetRoot: taprootAssetRoot,
1181+
TaprootMerkleRoot: taprootMerkleRoot,
11621182
ScriptKey: planned.ScriptKey,
11631183
Amount: planned.Amount,
11641184
ScriptMode: planned.ScriptMode,

custom_anchor_lifecycle_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ func TestCustomAnchorLifecycleBackendSigningAndPublish(t *testing.T) {
8686
t.Parallel()
8787

8888
fixture := newCustomAnchorBuilderFixture(t)
89+
policyLeaf := txscript.NewBaseTapLeaf([]byte{txscript.OP_TRUE})
90+
fixture.request.Outputs[0].Anchor.Tapscript = CustomAnchorTapscriptPlan{
91+
TapLeaves: []TapLeaf{{Script: cloneBytes(policyLeaf.Script)}},
92+
}
8993
var (
9094
signCalls int
9195
commitRequest *CommitVirtualPsbtsRequest
@@ -171,6 +175,14 @@ func TestCustomAnchorLifecycleBackendSigningAndPublish(t *testing.T) {
171175
require.Len(t, packageSnapshot.ProofUpdates, 1)
172176
require.NotEmpty(t, packageSnapshot.ProofUpdates[0].ProofBlob)
173177
require.Equal(t, publishMetadata, packageSnapshot.Publish)
178+
outputSummary := packageSnapshot.Outputs[0]
179+
require.NotZero(t, outputSummary.TaprootAssetRoot)
180+
require.NotZero(t, outputSummary.TaprootMerkleRoot)
181+
policyRoot := policyLeaf.TapHash()
182+
expectedRoot := asset.NewTapBranchHash(
183+
chainhash.Hash(outputSummary.TaprootAssetRoot), policyRoot,
184+
)
185+
require.Equal(t, Hash(expectedRoot), outputSummary.TaprootMerkleRoot)
174186

175187
before, err := decodeAnchorPSBT(plan.AnchorPSBT())
176188
require.NoError(t, err)
@@ -192,6 +204,17 @@ func TestCustomAnchorLifecycleBackendSigningAndPublish(t *testing.T) {
192204
committed.UnsignedTx.TxOut[1].PkScript)
193205
require.Equal(t, before.UnsignedTx.TxOut[1].Value,
194206
committed.UnsignedTx.TxOut[1].Value)
207+
internalKey, err := btcec.ParsePubKey(
208+
fixture.request.Outputs[0].Anchor.InternalKey.PubKey[:],
209+
)
210+
require.NoError(t, err)
211+
outputKey := txscript.ComputeTaprootOutputKey(
212+
internalKey, outputSummary.TaprootMerkleRoot[:],
213+
)
214+
expectedPkScript, err := txscript.PayToTaprootScript(outputKey)
215+
require.NoError(t, err)
216+
require.Equal(t, expectedPkScript,
217+
committed.UnsignedTx.TxOut[outputSummary.AnchorOutputIndex].PkScript)
195218

196219
_, err = wallet.PublishCustomAnchorTransfer(
197220
context.Background(), packageSnapshot,

custom_anchor_package.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,16 @@ type CustomAnchorAssetOutputSummary struct {
285285
// AnchorValueSat is the BTC value assigned to the asset-bearing output.
286286
AnchorValueSat int64 `json:"anchor_value_sat"`
287287

288+
// TaprootAssetRoot is the root of the Taproot Asset commitment before
289+
// combining it with the host-supplied tapscript sibling. Hosts such as
290+
// SwapDK use this root to compose their policy tree and control blocks.
291+
TaprootAssetRoot Hash `json:"taproot_asset_root"`
292+
293+
// TaprootMerkleRoot is the final BIP341 root that combines the Taproot
294+
// Asset commitment with the optional host-supplied tapscript sibling.
295+
// It is the tweak committed to by the anchor output key.
296+
TaprootMerkleRoot Hash `json:"taproot_merkle_root"`
297+
288298
// ScriptKey is the asset script key for the new output.
289299
ScriptKey PubKey `json:"script_key"`
290300

@@ -1034,6 +1044,11 @@ func validatePackageOutputMapping(anchor *psbt.Packet, txID chainhash.Hash,
10341044
}
10351045
anchorOutpoint := Outpoint{Txid: [32]byte(txID), Index: anchorIndex}
10361046
anchorValue := anchor.UnsignedTx.TxOut[anchorIndex].Value
1047+
taprootAssetRoot, taprootMerkleRoot, err :=
1048+
deriveCustomAnchorOutputRoots(virtualOutput, commitments)
1049+
if err != nil {
1050+
return fmt.Errorf("derive output commitment roots: %w", err)
1051+
}
10371052
if !customAnchorAssetRefMatchesIssuance(
10381053
summary.AssetRef, actualRef, issuanceID,
10391054
) ||
@@ -1045,6 +1060,16 @@ func validatePackageOutputMapping(anchor *psbt.Packet, txID chainhash.Hash,
10451060

10461061
return fmt.Errorf("output summary does not match virtual output")
10471062
}
1063+
if summary.TaprootAssetRoot != taprootAssetRoot ||
1064+
summary.TaprootMerkleRoot != taprootMerkleRoot {
1065+
1066+
return fmt.Errorf("output commitment roots do not match virtual output")
1067+
}
1068+
if err := validateCustomAnchorOutputRootHints(
1069+
anchor, anchorIndex, taprootAssetRoot, taprootMerkleRoot,
1070+
); err != nil {
1071+
return err
1072+
}
10481073
if customAnchorProofCourierString(
10491074
virtualOutput.ProofDeliveryAddress,
10501075
) != summary.ProofDelivery.CourierAddress {
@@ -1096,6 +1121,69 @@ func validatePackageOutputMapping(anchor *psbt.Packet, txID chainhash.Hash,
10961121
return nil
10971122
}
10981123

1124+
func deriveCustomAnchorOutputRoots(output *tappsbt.VOutput,
1125+
commitments tappsbt.OutputCommitments) (Hash, Hash, error) {
1126+
1127+
if output == nil {
1128+
return Hash{}, Hash{}, fmt.Errorf("virtual output is missing")
1129+
}
1130+
if output.AnchorOutputInternalKey == nil {
1131+
return Hash{}, Hash{}, fmt.Errorf("anchor output internal key is missing")
1132+
}
1133+
anchorCommitment, ok := commitments[output.AnchorOutputIndex]
1134+
if !ok || anchorCommitment == nil {
1135+
return Hash{}, Hash{}, fmt.Errorf(
1136+
"anchor output %d commitment is missing",
1137+
output.AnchorOutputIndex,
1138+
)
1139+
}
1140+
1141+
_, merkleRoot, assetRoot, err := tapsend.AnchorOutputScript(
1142+
output.AnchorOutputInternalKey,
1143+
output.AnchorOutputTapscriptSibling, anchorCommitment,
1144+
)
1145+
if err != nil {
1146+
return Hash{}, Hash{}, err
1147+
}
1148+
1149+
return Hash(assetRoot), Hash(merkleRoot), nil
1150+
}
1151+
1152+
func validateCustomAnchorOutputRootHints(anchor *psbt.Packet,
1153+
anchorIndex uint32, assetRoot, merkleRoot Hash) error {
1154+
1155+
if anchorIndex >= uint32(len(anchor.Outputs)) {
1156+
return fmt.Errorf("anchor output metadata is out of range")
1157+
}
1158+
unknowns := anchor.Outputs[anchorIndex].Unknowns
1159+
assetRootHint := tappsbt.ExtractCustomField(
1160+
unknowns, tappsbt.PsbtKeyTypeOutputAssetRoot,
1161+
)
1162+
if len(assetRootHint) != len(assetRoot) {
1163+
return fmt.Errorf("anchor output %d Taproot Asset root hint must be %d "+
1164+
"bytes, was %d", anchorIndex, len(assetRoot), len(assetRootHint))
1165+
}
1166+
if !bytes.Equal(assetRootHint, assetRoot[:]) {
1167+
return fmt.Errorf("anchor output %d Taproot Asset root hint does not "+
1168+
"match the committed assets", anchorIndex)
1169+
}
1170+
1171+
merkleRootHint := tappsbt.ExtractCustomField(
1172+
unknowns, tappsbt.PsbtKeyTypeOutputTaprootMerkleRoot,
1173+
)
1174+
if len(merkleRootHint) != len(merkleRoot) {
1175+
return fmt.Errorf("anchor output %d Taproot merkle root hint must be %d "+
1176+
"bytes, was %d", anchorIndex, len(merkleRoot),
1177+
len(merkleRootHint))
1178+
}
1179+
if !bytes.Equal(merkleRootHint, merkleRoot[:]) {
1180+
return fmt.Errorf("anchor output %d Taproot merkle root hint does not "+
1181+
"match the committed assets", anchorIndex)
1182+
}
1183+
1184+
return nil
1185+
}
1186+
10991187
// Clone returns a deep copy of the package so callers can snapshot a committed
11001188
// state before external signing or broadcast mutates host-owned buffers.
11011189
func (p *CustomAnchorTransferPackage) Clone() *CustomAnchorTransferPackage {

custom_anchor_package_test.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,24 @@ func TestCustomAnchorTransferPackageRejectsSemanticTamper(t *testing.T) {
604604
},
605605
wantErr: "output proof courier does not match output summary",
606606
},
607+
{
608+
name: "output taproot asset root",
609+
mutate: func(_ *testing.T,
610+
pkg *CustomAnchorTransferPackage) {
611+
612+
pkg.Outputs[0].TaprootAssetRoot[0] ^= 1
613+
},
614+
wantErr: "output commitment roots do not match virtual output",
615+
},
616+
{
617+
name: "output taproot merkle root",
618+
mutate: func(_ *testing.T,
619+
pkg *CustomAnchorTransferPackage) {
620+
621+
pkg.Outputs[0].TaprootMerkleRoot[0] ^= 1
622+
},
623+
wantErr: "output commitment roots do not match virtual output",
624+
},
607625
{
608626
name: "output anchor index",
609627
mutate: func(t *testing.T,
@@ -779,6 +797,75 @@ func TestCustomAnchorTransferPackageRejectsSemanticTamper(t *testing.T) {
779797
}
780798
}
781799

800+
func TestCustomAnchorTransferPackageRejectsRootHintTamper(t *testing.T) {
801+
tests := []struct {
802+
name string
803+
mutate func([]*btcpsbt.Unknown) []*btcpsbt.Unknown
804+
wantErr string
805+
}{
806+
{
807+
name: "missing asset root",
808+
mutate: func(unknowns []*btcpsbt.Unknown) []*btcpsbt.Unknown {
809+
return removeCustomAnchorTestField(
810+
unknowns, tappsbt.PsbtKeyTypeOutputAssetRoot,
811+
)
812+
},
813+
wantErr: "Taproot Asset root hint must be 32 bytes, was 0",
814+
},
815+
{
816+
name: "short merkle root",
817+
mutate: func(unknowns []*btcpsbt.Unknown) []*btcpsbt.Unknown {
818+
return tappsbt.AddCustomField(
819+
unknowns,
820+
tappsbt.PsbtKeyTypeOutputTaprootMerkleRoot,
821+
[]byte{1},
822+
)
823+
},
824+
wantErr: "Taproot merkle root hint must be 32 bytes, was 1",
825+
},
826+
{
827+
name: "wrong asset root",
828+
mutate: func(unknowns []*btcpsbt.Unknown) []*btcpsbt.Unknown {
829+
return tappsbt.AddCustomField(
830+
unknowns, tappsbt.PsbtKeyTypeOutputAssetRoot,
831+
bytes.Repeat([]byte{1}, 32),
832+
)
833+
},
834+
wantErr: "Taproot Asset root hint does not match",
835+
},
836+
{
837+
name: "wrong merkle root",
838+
mutate: func(unknowns []*btcpsbt.Unknown) []*btcpsbt.Unknown {
839+
return tappsbt.AddCustomField(
840+
unknowns,
841+
tappsbt.PsbtKeyTypeOutputTaprootMerkleRoot,
842+
bytes.Repeat([]byte{2}, 32),
843+
)
844+
},
845+
wantErr: "Taproot merkle root hint does not match",
846+
},
847+
}
848+
849+
for _, test := range tests {
850+
t.Run(test.name, func(t *testing.T) {
851+
pkg := newCustomAnchorTestFixture(t).unsealed
852+
for _, current := range []*[]byte{
853+
&pkg.CommittedAnchorPsbt, &pkg.AnchorPsbt,
854+
} {
855+
packet := mustDecodeAnchorPSBT(t, *current)
856+
index := pkg.Outputs[0].AnchorOutputIndex
857+
packet.Outputs[index].Unknowns = test.mutate(
858+
packet.Outputs[index].Unknowns,
859+
)
860+
*current = mustSerializeAnchorPSBT(t, packet)
861+
}
862+
863+
_, err := pkg.Seal()
864+
require.ErrorContains(t, err, test.wantErr)
865+
})
866+
}
867+
}
868+
782869
func TestCustomAnchorTransferPackageRejectsPassiveSemanticTamper(
783870
t *testing.T) {
784871

@@ -1595,6 +1682,20 @@ func mutateCustomAnchorPackageVPacket(t *testing.T,
15951682
require.NoError(t, err)
15961683
}
15971684

1685+
func removeCustomAnchorTestField(unknowns []*btcpsbt.Unknown,
1686+
key []byte) []*btcpsbt.Unknown {
1687+
1688+
filtered := make([]*btcpsbt.Unknown, 0, len(unknowns))
1689+
for _, unknown := range unknowns {
1690+
if unknown != nil && bytes.Equal(unknown.Key, key) {
1691+
continue
1692+
}
1693+
filtered = append(filtered, unknown)
1694+
}
1695+
1696+
return filtered
1697+
}
1698+
15981699
func mustDecodeAnchorPSBT(t *testing.T, raw []byte) *btcpsbt.Packet {
15991700
t.Helper()
16001701
packet, err := decodeAnchorPSBT(raw)

custom_anchor_passive_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,12 @@ func TestCustomAnchorPassiveCallerReanchor(t *testing.T) {
109109
packageSnapshot.Outputs[1].PacketRole)
110110
require.Equal(t, customAnchorPassiveCourier,
111111
packageSnapshot.Outputs[1].ProofDelivery.CourierAddress)
112+
require.Equal(t, packageSnapshot.Outputs[0].AnchorOutputIndex,
113+
packageSnapshot.Outputs[1].AnchorOutputIndex)
114+
require.Equal(t, packageSnapshot.Outputs[0].TaprootAssetRoot,
115+
packageSnapshot.Outputs[1].TaprootAssetRoot)
116+
require.Equal(t, packageSnapshot.Outputs[0].TaprootMerkleRoot,
117+
packageSnapshot.Outputs[1].TaprootMerkleRoot)
112118
}
113119

114120
func TestCustomAnchorPassiveCallerReanchorRejectsMutation(t *testing.T) {

docs/design/advanced-custom-anchor-transactions.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,9 @@ phase and is follow-up work.
306306
- the caller's custom lock ID and requested expiration, when wallet funding
307307
was used;
308308
- logical-to-concrete input and output mappings;
309+
- each output's Taproot Asset commitment root and final BIP341 merkle root,
310+
derived from the complete committed active/passive packet set rather than
311+
trusted proprietary PSBT fields;
309312
- the self-contained confirmed proof file or compact proof path for every
310313
selected input, bound by a domain-separated content ID;
311314
- every output proof suffix and complete proof-delivery metadata;
@@ -332,6 +335,16 @@ Funding metadata is mode-strict. Caller-funded exact and external P2A packages
332335
cannot carry backend change, lock, locked-UTXO, or backend-managed-input
333336
metadata; wallet-funded packages require their deterministic custom lock ID.
334337

338+
For a host-owned policy tree, such as an Ark VTXO, the host combines its known
339+
policy root with `CustomAnchorAssetOutputSummary.TaprootAssetRoot`. The result
340+
must equal the summary's `TaprootMerkleRoot`; that final root is the tweak
341+
committed by the anchor output key. The SDK exposes both values so the host can
342+
build and persist its control blocks without parsing tapd's proprietary PSBT
343+
output fields. The host remains responsible for persisting its complete policy
344+
descriptor, leaves, signers, and internal key. The SDK also requires tapd's
345+
PSBT root hints to be present and equal to these independently reconstructed
346+
values because the downstream transfer logger persists those hints.
347+
335348
The committed PSBT metadata is also sealed. Before finalization, the current
336349
PSBT may differ only in signature and finalization fields; changes to prevout,
337350
sighash, Taproot key/leaf, or other signing metadata are rejected. Standard

0 commit comments

Comments
 (0)