From 1ae628232f960240ecb122195a8e3ce1dee21b7a Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Mon, 13 Jul 2026 22:57:08 +0200 Subject: [PATCH 1/2] itest: cover custom-anchor tapscript timeout script-path spend Lock an asset into an OP_TRUE output whose anchor commits a CSV timeout leaf, then unilaterally sweep it through the script path after the timelock matures. The sweep is caller-funded so tapd broadcasts it directly. Exercises the ScriptPath signing surface, caller-provided OP_TRUE asset witnesses, and non-cooperative proof regeneration without burning the asset. --- itest/custom_anchor_script_path_test.go | 445 ++++++++++++++++++++++++ 1 file changed, 445 insertions(+) create mode 100644 itest/custom_anchor_script_path_test.go diff --git a/itest/custom_anchor_script_path_test.go b/itest/custom_anchor_script_path_test.go new file mode 100644 index 0000000..9540290 --- /dev/null +++ b/itest/custom_anchor_script_path_test.go @@ -0,0 +1,445 @@ +//go:build itest + +package itest + +import ( + "bytes" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + tapsdk "github.com/lightninglabs/tap-sdk" + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/stretchr/testify/require" +) + +// customAnchorCSVDelay is the relative block delay committed by the timeout +// leaf. It must be small enough to mature quickly in the itest yet large +// enough that the spend is only valid after explicit maturation. +const customAnchorCSVDelay = int64(4) + +// TestCustomAnchorBuilderScriptPathTimeoutSweep proves the generic +// custom-anchor capability that Ark-style unilateral exits depend on, without +// any Ark-specific concept: an asset output can commit a tapscript timeout leaf +// as a sibling to the asset commitment, be spent later purely through that +// script path, and yield a valid, non-burned proof. The asset script key is +// OP_TRUE, so the asset witness is regenerated with no counterparty signature; +// the timeout policy lives on the Bitcoin anchor and is satisfied by a single +// key after the relative timelock matures. +// +// It deliberately exercises the ScriptPath signing surface that both existing +// custom-anchor itests assert is empty. +func TestCustomAnchorBuilderScriptPathTimeoutSweep(t *testing.T) { + h, ctx := newFundedHarnessFor(t, TransportGRPC) + + name := uniqueEventLabel("custom-anchor-script-path") + minted, err := h.CreateFungibleAndConfirm(t, ctx, name, 1_000) + require.NoError(t, err) + require.True(t, minted.Ref.IsGroupRef()) + + mintProof, err := h.AliceWallet.ExportProofFile( + ctx, + tapsdk.AssetRefFromAssetID(minted.Asset.Genesis.IssuanceID), + minted.Asset.ScriptKey.PubKey, nil, + ) + require.NoError(t, err) + require.NotEmpty(t, mintProof.RawProofFile) + + mintFile, err := proof.DecodeFile(mintProof.RawProofFile) + require.NoError(t, err) + lastMintProof, err := mintFile.LastProof() + require.NoError(t, err) + + // The timeout leaf is a standard CSV closure keyed to a single spender + // key: OP_CSV OP_DROP OP_CHECKSIG. + csvKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + csvScript := customAnchorCSVLeafScript(t, customAnchorCSVDelay, csvKey) + csvLeafHash := customAnchorTapLeafHash(csvScript) + + // The asset-level internal key for the OP_TRUE script key is unique per + // output and never needs to be wallet-owned. + opTrueKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + opTrueInternalKey := tapsdk.KeyDescriptor{ + RawKeyBytes: mustCompressedPubKey(t, opTrueKey.PubKey()), + } + + // The BTC anchor internal key is NUMS, so the timeout leaf is the only + // spending path. + numsInternalKey := tapsdk.InternalKey{ + PubKey: mustCompressedPubKey(t, asset.NUMSPubKey), + } + + lockAnchorPsbt, lockValue := customAnchorITestPSBT( + t, lastMintProof, numsInternalKey, + ) + mintAnchorSigner, err := tapsdk.ParseTaprootPubKey( + lastMintProof.InclusionProof.InternalKey.SerializeCompressed(), + ) + require.NoError(t, err) + + lockRequest := &tapsdk.CustomAnchorRequest{ + Inputs: []tapsdk.CustomAssetInput{{ + ID: "mint-output", + AssetRef: minted.Ref, + Amount: minted.Asset.Amount, + ProofFile: mintProof.RawProofFile, + Witness: tapsdk.CustomAssetWitnessPlan{ + Mode: tapsdk.CustomAssetWitnessBackendSigner, + }, + }}, + Outputs: []tapsdk.CustomAssetOutput{{ + ID: "timeout-output", + AssetRef: minted.Ref, + Amount: minted.Asset.Amount, + AnchorOutputIndex: 0, + AnchorValueSat: uint64(lockValue), + Script: tapsdk.CustomAssetScriptPlan{ + Mode: tapsdk.CustomAssetScriptOPTrue, + OPTrue: &tapsdk.CustomAssetOPTrueScriptPlan{ + InternalKey: opTrueInternalKey, + }, + }, + Anchor: tapsdk.CustomAnchorOutputPlan{ + InternalKey: numsInternalKey, + Tapscript: tapsdk.CustomAnchorTapscriptPlan{ + TapLeaves: []tapsdk.TapLeaf{{ + Script: csvScript, + }}, + }, + }, + }}, + AnchorPSBT: lockAnchorPsbt, + Funding: tapsdk.CustomAnchorFundingPlan{ + Mode: tapsdk.CustomAnchorFundingCallerFundedExact, + CallerFundedExact: &tapsdk.CustomAnchorCallerFundedExact{}, + }, + PassiveAssets: tapsdk.CustomAnchorPassiveAssets{ + Policy: tapsdk.CustomAnchorPassiveReject, + }, + LossPolicy: tapsdk.CustomAnchorLossPolicy{ + Mode: tapsdk.CustomAnchorLossReject, + }, + SigningPlans: []tapsdk.CustomAnchorInputSigningPlan{{ + InputIndex: 0, + KeyPath: &tapsdk.CustomAnchorKeyPathSigningPlan{ + Signer: mintAnchorSigner.XOnly(), + }, + }}, + } + + lockPlan, err := h.AliceWallet.NewCustomAnchorTxBuilder().Build( + ctx, lockRequest, + ) + require.NoError(t, err) + lockVerification := lockPlan.Verification() + require.True(t, lockVerification.Valid()) + + lockSealed, err := lockPlan.Commit(ctx, tapsdk.CustomAnchorCommitOptions{ + Publish: tapsdk.CustomAnchorPublishMetadata{Label: name}, + }) + require.NoError(t, err) + require.NoError(t, lockSealed.Validate()) + require.Len(t, lockSealed.Outputs, 1) + timeoutOutput := lockSealed.Outputs[0] + require.NotNil(t, timeoutOutput.OPTrueSpend) + require.NoError(t, timeoutOutput.OPTrueSpend.Validate( + timeoutOutput.ScriptKey, + )) + + lockFinal, err := newAliceLndAnchorSigner(t)(ctx, lockSealed.AnchorPsbt) + require.NoError(t, err) + require.NoError(t, lockSealed.VerifyFinalAnchorPSBT(lockFinal)) + + _, err = h.AliceWallet.PublishCustomAnchorTransfer( + ctx, lockSealed, lockFinal, + ) + require.NoError(t, err) + + h.MineBlocks(t, defaultMineBlocks) + h.WaitForSync(t, ctx, h.AliceClient, defaultSyncTimeout) + + timeoutProof := h.WaitForProofFile( + t, ctx, h.AliceWallet, + tapsdk.AssetRefFromAssetID(timeoutOutput.IssuanceID), + timeoutOutput.ScriptKey, &timeoutOutput.AnchorOutpoint, + ) + require.NotEmpty(t, timeoutProof.RawProofFile) + + // Bury the timeout output far enough that the relative timelock is + // satisfied before the sweep is broadcast. + h.MineBlocks(t, int(customAnchorCSVDelay)) + h.WaitForSync(t, ctx, h.AliceClient, defaultSyncTimeout) + + // The sweep spends the timeout output unilaterally: the asset input is + // authorized by the regenerated OP_TRUE witness (no counterparty), and + // the BTC anchor is spent through the timeout leaf. + sweepFile, err := proof.DecodeFile(timeoutProof.RawProofFile) + require.NoError(t, err) + sweepInputProof, err := sweepFile.LastProof() + require.NoError(t, err) + + receiverKeys, err := h.BobWallet.DeriveKeys(ctx) + require.NoError(t, err) + + sweepAnchorPsbt, sweepValue := customAnchorCSVSweepPSBT( + t, sweepInputProof, receiverKeys.InternalKey, csvScript, + timeoutOutput.TaprootAssetRoot, timeoutOutput.TaprootMerkleRoot, + ) + + sweepRequest := &tapsdk.CustomAnchorRequest{ + Inputs: []tapsdk.CustomAssetInput{{ + ID: "timeout-input", + AssetRef: minted.Ref, + Amount: minted.Asset.Amount, + ProofFile: timeoutProof.RawProofFile, + Witness: tapsdk.CustomAssetWitnessPlan{ + Mode: tapsdk.CustomAssetWitnessCallerProvided, + Stack: timeoutOutput.OPTrueSpend.WitnessStack(), + }, + }}, + Outputs: []tapsdk.CustomAssetOutput{{ + ID: "bob-output", + AssetRef: minted.Ref, + Amount: minted.Asset.Amount, + AnchorOutputIndex: 0, + AnchorValueSat: uint64(sweepValue), + Script: tapsdk.CustomAssetScriptPlan{ + Mode: tapsdk.CustomAssetScriptExternal, + External: &tapsdk.CustomAssetExternalScriptPlan{ + ScriptKey: receiverKeys.ScriptKey, + }, + }, + Anchor: tapsdk.CustomAnchorOutputPlan{ + InternalKey: receiverKeys.InternalKey, + }, + }}, + AnchorPSBT: sweepAnchorPsbt, + Funding: tapsdk.CustomAnchorFundingPlan{ + Mode: tapsdk.CustomAnchorFundingCallerFundedExact, + CallerFundedExact: &tapsdk.CustomAnchorCallerFundedExact{}, + }, + PassiveAssets: tapsdk.CustomAnchorPassiveAssets{ + Policy: tapsdk.CustomAnchorPassiveReject, + }, + LossPolicy: tapsdk.CustomAnchorLossPolicy{ + Mode: tapsdk.CustomAnchorLossReject, + }, + SigningPlans: []tapsdk.CustomAnchorInputSigningPlan{{ + InputIndex: 0, + ScriptPath: &tapsdk.CustomAnchorScriptPathSigningPlan{ + LeafHash: csvLeafHash, + RequiredSigners: []tapsdk.XOnlyPubKey{ + mustXOnlyPubKey(t, csvKey.PubKey()), + }, + }, + }}, + } + + sweepPlan, err := h.AliceWallet.NewCustomAnchorTxBuilder().Build( + ctx, sweepRequest, + ) + require.NoError(t, err) + sweepVerification := sweepPlan.Verification() + require.True(t, sweepVerification.Valid()) + + sweepSealed, err := sweepPlan.Commit(ctx, tapsdk.CustomAnchorCommitOptions{ + Publish: tapsdk.CustomAnchorPublishMetadata{ + Label: name + "-sweep", + }, + }) + require.NoError(t, err) + require.NoError(t, sweepSealed.Validate()) + + // This is the assertion the existing custom-anchor itests invert: the + // unilateral sweep produces exactly one script-path signing request and + // no key-path request. + sweepSigning, err := sweepSealed.SigningRequests() + require.NoError(t, err) + require.Empty(t, sweepSigning.KeyPath) + require.Empty(t, sweepSigning.MuSig2) + require.Len(t, sweepSigning.ScriptPath, 1) + scriptRequest := sweepSigning.ScriptPath[0] + require.Equal(t, csvLeafHash, scriptRequest.LeafHash) + + signature, err := schnorr.Sign(csvKey, scriptRequest.Sighash[:]) + require.NoError(t, err) + signatureBytes := signature.Serialize() + if scriptRequest.SighashType != uint32(txscript.SigHashDefault) { + signatureBytes = append( + signatureBytes, byte(scriptRequest.SighashType), + ) + } + + witnessed, err := sweepSealed.ApplyScriptPathWitness( + scriptRequest.ID, [][]byte{signatureBytes}, + ) + require.NoError(t, err) + require.NoError(t, witnessed.VerifyFinalAnchorPSBT(witnessed.AnchorPsbt)) + + sweepFinalPacket := decodeCustomAnchorITestPSBT(t, witnessed.AnchorPsbt) + require.NotEmpty(t, sweepFinalPacket.Inputs[0].FinalScriptWitness) + + // The sweep pays its own fee, so tapd broadcasts it directly with no + // external fee-bump child. + _, err = h.AliceWallet.PublishCustomAnchorTransfer( + ctx, witnessed, witnessed.AnchorPsbt, + ) + require.NoError(t, err) + + h.MineBlocks(t, defaultMineBlocks) + h.WaitForSync(t, ctx, h.AliceClient, defaultSyncTimeout) + h.WaitForSync(t, ctx, h.BobClient, defaultSyncTimeout) + + sweptOutput := sweepSealed.Outputs[0] + sweptProof := h.WaitForProofFile( + t, ctx, h.AliceWallet, + tapsdk.AssetRefFromAssetID(sweptOutput.IssuanceID), + sweptOutput.ScriptKey, &sweptOutput.AnchorOutpoint, + ) + require.NotEmpty(t, sweptProof.RawProofFile) + + // The asset survived the unilateral spend: same issuance and amount, a + // normal transfer rather than a burn. + h.EnableUniverseBootstrap(t, ctx) + registered, err := h.BobWallet.ImportProofFile(ctx, sweptProof) + require.NoError(t, err) + require.Equal(t, sweptOutput.IssuanceID, registered.IssuanceID) + require.Equal(t, sweptOutput.ScriptKey, registered.ScriptKey) + require.Equal(t, minted.Asset.Amount, registered.Amount) + + bobBalance := h.WaitForBalance( + t, ctx, h.BobWallet, minted.Ref, minted.Asset.Amount, + balanceTimeoutFor(minted.Ref), + ) + require.Equal(t, minted.Asset.Amount, bobBalance) +} + +// customAnchorCSVLeafScript builds a standard CSV timeout closure keyed to a +// single spender: OP_CHECKSEQUENCEVERIFY OP_DROP OP_CHECKSIG. +func customAnchorCSVLeafScript(t testing.TB, delay int64, + key *btcec.PrivateKey) []byte { + + t.Helper() + builder := txscript.NewScriptBuilder() + builder.AddInt64(delay) + builder.AddOp(txscript.OP_CHECKSEQUENCEVERIFY) + builder.AddOp(txscript.OP_DROP) + builder.AddData(schnorr.SerializePubKey(key.PubKey())) + builder.AddOp(txscript.OP_CHECKSIG) + script, err := builder.Script() + require.NoError(t, err) + + return script +} + +// customAnchorTapLeafHash returns the base-version TapLeaf hash committed by +// the builder for a sibling script. +func customAnchorTapLeafHash(script []byte) tapsdk.Hash { + leaf := txscript.NewBaseTapLeaf(script) + hash := leaf.TapHash() + + var out tapsdk.Hash + copy(out[:], hash[:]) + + return out +} + +// customAnchorCSVSweepPSBT builds the caller-funded v3 sweep that spends the +// timeout output through its script path and pays its own fee, so tapd can +// broadcast it directly without an external fee-bump child. The input sequence +// encodes the relative timelock, and the spent input carries the reconstructed +// timeout-leaf control block so the builder can derive the script-path signing +// request. The control block is composed from the committed roots exactly as +// an integrating host would. It returns the sweep output value. +func customAnchorCSVSweepPSBT(t testing.TB, inputProof *proof.Proof, + receiverInternalKey tapsdk.InternalKey, csvScript []byte, + taprootAssetRoot, taprootMerkleRoot tapsdk.Hash) ([]byte, int64) { + + t.Helper() + inputOutpoint := inputProof.OutPoint() + inputTxOut := inputProof.AnchorTx.TxOut[inputOutpoint.Index] + require.Greater(t, inputTxOut.Value, customAnchorITestFeeSat) + sweepValue := inputTxOut.Value - customAnchorITestFeeSat + require.GreaterOrEqual(t, sweepValue, int64(330)) + + internalKey, err := btcec.ParsePubKey(receiverInternalKey.PubKey[:]) + require.NoError(t, err) + outputKey := txscript.ComputeTaprootKeyNoScript(internalKey) + placeholderScript, err := txscript.PayToTaprootScript(outputKey) + require.NoError(t, err) + + // Reconstruct the timeout-leaf control block against the NUMS anchor + // internal key. The asset commitment root is the only sibling, so it is + // the sole inclusion-proof element. + anchorOutputKey := txscript.ComputeTaprootOutputKey( + asset.NUMSPubKey, taprootMerkleRoot[:], + ) + controlBlock := txscript.ControlBlock{ + InternalKey: asset.NUMSPubKey, + OutputKeyYIsOdd: anchorOutputKey.SerializeCompressed()[0] == 0x03, + LeafVersion: txscript.BaseLeafVersion, + InclusionProof: taprootAssetRoot[:], + } + require.Equal(t, taprootMerkleRoot[:], controlBlock.RootHash(csvScript)) + require.Equal(t, inputTxOut.PkScript, + mustPayToTaproot(t, anchorOutputKey)) + controlBlockBytes, err := controlBlock.ToBytes() + require.NoError(t, err) + + sweepTx := wire.NewMsgTx(customAnchorITestVersion) + sweepTx.LockTime = 21 + sweepTx.AddTxIn(wire.NewTxIn(&inputOutpoint, nil, nil)) + sweepTx.TxIn[0].Sequence = uint32(customAnchorCSVDelay) + sweepTx.AddTxOut(&wire.TxOut{ + Value: sweepValue, + PkScript: placeholderScript, + }) + + packet, err := psbt.NewFromUnsignedTx(sweepTx) + require.NoError(t, err) + packet.Inputs[0].WitnessUtxo = inputTxOut + packet.Inputs[0].TaprootInternalKey = schnorr.SerializePubKey( + asset.NUMSPubKey, + ) + packet.Inputs[0].TaprootMerkleRoot = taprootMerkleRoot[:] + packet.Inputs[0].TaprootLeafScript = []*psbt.TaprootTapLeafScript{{ + ControlBlock: controlBlockBytes, + Script: csvScript, + LeafVersion: txscript.BaseLeafVersion, + }} + + var buffer bytes.Buffer + require.NoError(t, packet.Serialize(&buffer)) + + return buffer.Bytes(), sweepValue +} + +func mustPayToTaproot(t testing.TB, key *btcec.PublicKey) []byte { + t.Helper() + script, err := txscript.PayToTaprootScript(key) + require.NoError(t, err) + + return script +} + +func mustCompressedPubKey(t testing.TB, key *btcec.PublicKey) tapsdk.PubKey { + t.Helper() + parsed, err := tapsdk.ParsePubKey(key.SerializeCompressed()) + require.NoError(t, err) + + return parsed +} + +func mustXOnlyPubKey(t testing.TB, key *btcec.PublicKey) tapsdk.XOnlyPubKey { + t.Helper() + parsed, err := tapsdk.ParseXOnlyPubKey(schnorr.SerializePubKey(key)) + require.NoError(t, err) + + return parsed +} From 906ab2632af7df91bb22734eb9c5ecedf87eab5c Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Tue, 14 Jul 2026 13:32:30 +0200 Subject: [PATCH 2/2] itest: cover custom-anchor MuSig2 key-path spend Lock an asset into an OP_TRUE output anchored to a MuSig2-aggregated internal key, then cooperatively sweep it with a single aggregate key-path signature produced by an in-process two-party ceremony (BIP-341 taproot tweak). Exercises the MuSig2 signing surface and non-cooperative asset-witness regeneration without burning the asset. --- itest/custom_anchor_musig2_test.go | 432 +++++++++++++++++++++++++++++ 1 file changed, 432 insertions(+) create mode 100644 itest/custom_anchor_musig2_test.go diff --git a/itest/custom_anchor_musig2_test.go b/itest/custom_anchor_musig2_test.go new file mode 100644 index 0000000..c8fe469 --- /dev/null +++ b/itest/custom_anchor_musig2_test.go @@ -0,0 +1,432 @@ +//go:build itest + +package itest + +import ( + "bytes" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + tapsdk "github.com/lightninglabs/tap-sdk" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/stretchr/testify/require" +) + +// TestCustomAnchorBuilderMuSig2KeyPathSweep proves the generic custom-anchor +// capability that Ark-style cooperative exits depend on, without any +// Ark-specific concept: an asset output can be anchored to a MuSig2-aggregated +// internal key and later spent cooperatively through a single aggregate +// key-path signature, yielding a valid, non-burned proof. The asset script key +// is OP_TRUE, so the asset witness is regenerated with no per-party signature; +// the cooperative policy lives on the Bitcoin anchor key. +// +// It exercises the MuSig2 signing surface that both existing custom-anchor +// itests assert is empty. +func TestCustomAnchorBuilderMuSig2KeyPathSweep(t *testing.T) { + h, ctx := newFundedHarnessFor(t, TransportGRPC) + + name := uniqueEventLabel("custom-anchor-musig2") + minted, err := h.CreateFungibleAndConfirm(t, ctx, name, 1_000) + require.NoError(t, err) + require.True(t, minted.Ref.IsGroupRef()) + + mintProof, err := h.AliceWallet.ExportProofFile( + ctx, + tapsdk.AssetRefFromAssetID(minted.Asset.Genesis.IssuanceID), + minted.Asset.ScriptKey.PubKey, nil, + ) + require.NoError(t, err) + + mintFile, err := proof.DecodeFile(mintProof.RawProofFile) + require.NoError(t, err) + lastMintProof, err := mintFile.LastProof() + require.NoError(t, err) + + // Two cooperative signers own the anchor. Their pre-tweaked aggregate + // key is the anchor internal key exactly as the SDK reconstructs it. The + // SDK re-parses participants as x-only keys before aggregating, so the + // participant keys must be even-parity for the local aggregate to match. + aliceKey := customAnchorEvenParityKey(t) + bobKey := customAnchorEvenParityKey(t) + signers := []*btcec.PrivateKey{aliceKey, bobKey} + participants := []*btcec.PublicKey{aliceKey.PubKey(), bobKey.PubKey()} + aggregateKey := customAnchorMuSig2AggregateKey(t, participants) + muSig2InternalKey := tapsdk.InternalKey{ + PubKey: mustCompressedPubKey(t, aggregateKey), + } + + opTrueKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + opTrueInternalKey := tapsdk.KeyDescriptor{ + RawKeyBytes: mustCompressedPubKey(t, opTrueKey.PubKey()), + } + + lockAnchorPsbt, lockValue := customAnchorITestPSBT( + t, lastMintProof, muSig2InternalKey, + ) + mintAnchorSigner, err := tapsdk.ParseTaprootPubKey( + lastMintProof.InclusionProof.InternalKey.SerializeCompressed(), + ) + require.NoError(t, err) + + lockRequest := &tapsdk.CustomAnchorRequest{ + Inputs: []tapsdk.CustomAssetInput{{ + ID: "mint-output", + AssetRef: minted.Ref, + Amount: minted.Asset.Amount, + ProofFile: mintProof.RawProofFile, + Witness: tapsdk.CustomAssetWitnessPlan{ + Mode: tapsdk.CustomAssetWitnessBackendSigner, + }, + }}, + Outputs: []tapsdk.CustomAssetOutput{{ + ID: "musig2-output", + AssetRef: minted.Ref, + Amount: minted.Asset.Amount, + AnchorOutputIndex: 0, + AnchorValueSat: uint64(lockValue), + Script: tapsdk.CustomAssetScriptPlan{ + Mode: tapsdk.CustomAssetScriptOPTrue, + OPTrue: &tapsdk.CustomAssetOPTrueScriptPlan{ + InternalKey: opTrueInternalKey, + }, + }, + Anchor: tapsdk.CustomAnchorOutputPlan{ + InternalKey: muSig2InternalKey, + }, + }}, + AnchorPSBT: lockAnchorPsbt, + Funding: tapsdk.CustomAnchorFundingPlan{ + Mode: tapsdk.CustomAnchorFundingCallerFundedExact, + CallerFundedExact: &tapsdk.CustomAnchorCallerFundedExact{}, + }, + PassiveAssets: tapsdk.CustomAnchorPassiveAssets{ + Policy: tapsdk.CustomAnchorPassiveReject, + }, + LossPolicy: tapsdk.CustomAnchorLossPolicy{ + Mode: tapsdk.CustomAnchorLossReject, + }, + SigningPlans: []tapsdk.CustomAnchorInputSigningPlan{{ + InputIndex: 0, + KeyPath: &tapsdk.CustomAnchorKeyPathSigningPlan{ + Signer: mintAnchorSigner.XOnly(), + }, + }}, + } + + lockPlan, err := h.AliceWallet.NewCustomAnchorTxBuilder().Build( + ctx, lockRequest, + ) + require.NoError(t, err) + lockVerification := lockPlan.Verification() + require.True(t, lockVerification.Valid()) + + lockSealed, err := lockPlan.Commit(ctx, tapsdk.CustomAnchorCommitOptions{ + Publish: tapsdk.CustomAnchorPublishMetadata{Label: name}, + }) + require.NoError(t, err) + require.Len(t, lockSealed.Outputs, 1) + muSig2Output := lockSealed.Outputs[0] + require.NotNil(t, muSig2Output.OPTrueSpend) + + lockFinal, err := newAliceLndAnchorSigner(t)(ctx, lockSealed.AnchorPsbt) + require.NoError(t, err) + require.NoError(t, lockSealed.VerifyFinalAnchorPSBT(lockFinal)) + + _, err = h.AliceWallet.PublishCustomAnchorTransfer( + ctx, lockSealed, lockFinal, + ) + require.NoError(t, err) + + h.MineBlocks(t, defaultMineBlocks) + h.WaitForSync(t, ctx, h.AliceClient, defaultSyncTimeout) + + lockProof := h.WaitForProofFile( + t, ctx, h.AliceWallet, + tapsdk.AssetRefFromAssetID(muSig2Output.IssuanceID), + muSig2Output.ScriptKey, &muSig2Output.AnchorOutpoint, + ) + require.NotEmpty(t, lockProof.RawProofFile) + + // The cooperative spend re-anchors the asset to Bob: the asset input is + // authorized by the regenerated OP_TRUE witness, and the Bitcoin anchor + // is spent by an aggregate MuSig2 key-path signature. + sweepFile, err := proof.DecodeFile(lockProof.RawProofFile) + require.NoError(t, err) + sweepInputProof, err := sweepFile.LastProof() + require.NoError(t, err) + + receiverKeys, err := h.BobWallet.DeriveKeys(ctx) + require.NoError(t, err) + + sweepAnchorPsbt, sweepValue := customAnchorMuSig2SweepPSBT( + t, sweepInputProof, receiverKeys.InternalKey, aggregateKey, + muSig2Output.TaprootMerkleRoot, + ) + + sweepRequest := &tapsdk.CustomAnchorRequest{ + Inputs: []tapsdk.CustomAssetInput{{ + ID: "musig2-input", + AssetRef: minted.Ref, + Amount: minted.Asset.Amount, + ProofFile: lockProof.RawProofFile, + Witness: tapsdk.CustomAssetWitnessPlan{ + Mode: tapsdk.CustomAssetWitnessCallerProvided, + Stack: muSig2Output.OPTrueSpend.WitnessStack(), + }, + }}, + Outputs: []tapsdk.CustomAssetOutput{{ + ID: "bob-output", + AssetRef: minted.Ref, + Amount: minted.Asset.Amount, + AnchorOutputIndex: 0, + AnchorValueSat: uint64(sweepValue), + Script: tapsdk.CustomAssetScriptPlan{ + Mode: tapsdk.CustomAssetScriptExternal, + External: &tapsdk.CustomAssetExternalScriptPlan{ + ScriptKey: receiverKeys.ScriptKey, + }, + }, + Anchor: tapsdk.CustomAnchorOutputPlan{ + InternalKey: receiverKeys.InternalKey, + }, + }}, + AnchorPSBT: sweepAnchorPsbt, + Funding: tapsdk.CustomAnchorFundingPlan{ + Mode: tapsdk.CustomAnchorFundingCallerFundedExact, + CallerFundedExact: &tapsdk.CustomAnchorCallerFundedExact{}, + }, + PassiveAssets: tapsdk.CustomAnchorPassiveAssets{ + Policy: tapsdk.CustomAnchorPassiveReject, + }, + LossPolicy: tapsdk.CustomAnchorLossPolicy{ + Mode: tapsdk.CustomAnchorLossReject, + }, + SigningPlans: []tapsdk.CustomAnchorInputSigningPlan{{ + InputIndex: 0, + MuSig2: &tapsdk.CustomAnchorMuSig2SigningPlan{ + Participants: []tapsdk.XOnlyPubKey{ + mustXOnlyPubKey(t, aliceKey.PubKey()), + mustXOnlyPubKey(t, bobKey.PubKey()), + }, + SessionContext: []byte("musig2-cooperative-sweep"), + }, + }}, + } + + sweepPlan, err := h.AliceWallet.NewCustomAnchorTxBuilder().Build( + ctx, sweepRequest, + ) + require.NoError(t, err) + sweepVerification := sweepPlan.Verification() + require.True(t, sweepVerification.Valid()) + + sweepSealed, err := sweepPlan.Commit(ctx, tapsdk.CustomAnchorCommitOptions{ + Publish: tapsdk.CustomAnchorPublishMetadata{Label: name + "-sweep"}, + }) + require.NoError(t, err) + require.NoError(t, sweepSealed.Validate()) + + // The cooperative sweep produces exactly one MuSig2 signing request and + // no single-signer key-path or script-path request. + sweepSigning, err := sweepSealed.SigningRequests() + require.NoError(t, err) + require.Empty(t, sweepSigning.KeyPath) + require.Empty(t, sweepSigning.ScriptPath) + require.Len(t, sweepSigning.MuSig2, 1) + muSig2Request := sweepSigning.MuSig2[0] + require.Len(t, muSig2Request.Participants, 2) + + signature := customAnchorMuSig2Sign( + t, signers, participants, muSig2Request.Sighash, + muSig2Request.TaprootMerkleRoot, + ) + if muSig2Request.SighashType != uint32(txscript.SigHashDefault) { + signature = append(signature, byte(muSig2Request.SighashType)) + } + + witnessed, err := sweepSealed.ApplyKeyPathSignature( + muSig2Request.ID, signature, + ) + require.NoError(t, err) + + // Finalize the aggregate key-path signature into a broadcastable witness, + // the step an external key-path signer such as lnd performs. + finalAnchorPsbt := customAnchorFinalizeKeyPath(t, witnessed.AnchorPsbt) + require.NoError(t, witnessed.VerifyFinalAnchorPSBT(finalAnchorPsbt)) + + _, err = h.AliceWallet.PublishCustomAnchorTransfer( + ctx, witnessed, finalAnchorPsbt, + ) + require.NoError(t, err) + + h.MineBlocks(t, defaultMineBlocks) + h.WaitForSync(t, ctx, h.AliceClient, defaultSyncTimeout) + h.WaitForSync(t, ctx, h.BobClient, defaultSyncTimeout) + + sweptOutput := sweepSealed.Outputs[0] + sweptProof := h.WaitForProofFile( + t, ctx, h.AliceWallet, + tapsdk.AssetRefFromAssetID(sweptOutput.IssuanceID), + sweptOutput.ScriptKey, &sweptOutput.AnchorOutpoint, + ) + require.NotEmpty(t, sweptProof.RawProofFile) + + h.EnableUniverseBootstrap(t, ctx) + registered, err := h.BobWallet.ImportProofFile(ctx, sweptProof) + require.NoError(t, err) + require.Equal(t, sweptOutput.IssuanceID, registered.IssuanceID) + require.Equal(t, sweptOutput.ScriptKey, registered.ScriptKey) + require.Equal(t, minted.Asset.Amount, registered.Amount) + + bobBalance := h.WaitForBalance( + t, ctx, h.BobWallet, minted.Ref, minted.Asset.Amount, + balanceTimeoutFor(minted.Ref), + ) + require.Equal(t, minted.Asset.Amount, bobBalance) +} + +// customAnchorFinalizeKeyPath finalizes a Taproot key-path input by promoting +// its aggregate key-spend signature to the final witness stack, then returns +// the serialized PSBT ready for broadcast. +func customAnchorFinalizeKeyPath(t testing.TB, rawPsbt []byte) []byte { + t.Helper() + packet := decodeCustomAnchorITestPSBT(t, rawPsbt) + ok, err := psbt.MaybeFinalize(packet, 0) + require.NoError(t, err) + require.True(t, ok) + + var buffer bytes.Buffer + require.NoError(t, packet.Serialize(&buffer)) + + return buffer.Bytes() +} + +// customAnchorEvenParityKey returns a fresh private key whose public key has +// even Y parity, so it equals its own x-only reparse. +func customAnchorEvenParityKey(t testing.TB) *btcec.PrivateKey { + t.Helper() + for { + key, err := btcec.NewPrivateKey() + require.NoError(t, err) + if key.PubKey().SerializeCompressed()[0] == 0x02 { + return key + } + } +} + +// customAnchorMuSig2AggregateKey returns the BIP-327 pre-tweaked aggregate key +// for the given ordered participant set, matching how the SDK reconstructs the +// committed internal key (unsorted). +func customAnchorMuSig2AggregateKey(t testing.TB, + participants []*btcec.PublicKey) *btcec.PublicKey { + + t.Helper() + aggregate, _, _, err := musig2.AggregateKeys(participants, false) + require.NoError(t, err) + + return aggregate.PreTweakedKey +} + +// customAnchorMuSig2Sign runs a complete in-process MuSig2 signing ceremony +// over the message, applying the BIP-341 taproot tweak for the committed +// script root, and returns the 64-byte aggregate Schnorr signature. +func customAnchorMuSig2Sign(t testing.TB, signers []*btcec.PrivateKey, + participants []*btcec.PublicKey, sighash tapsdk.Hash, + scriptRoot []byte) []byte { + + t.Helper() + var message [32]byte + copy(message[:], sighash[:]) + + nonces := make([]*musig2.Nonces, len(signers)) + pubNonces := make([][musig2.PubNonceSize]byte, len(signers)) + for i, signer := range signers { + nonce, err := musig2.GenNonces( + musig2.WithPublicKey(signer.PubKey()), + ) + require.NoError(t, err) + nonces[i] = nonce + pubNonces[i] = nonce.PubNonce + } + + combinedNonce, err := musig2.AggregateNonces(pubNonces) + require.NoError(t, err) + + partials := make([]*musig2.PartialSignature, len(signers)) + for i, signer := range signers { + partial, err := musig2.Sign( + nonces[i].SecNonce, signer, combinedNonce, participants, + message, musig2.WithTaprootSignTweak(scriptRoot), + ) + require.NoError(t, err) + partials[i] = partial + } + + finalSig := musig2.CombineSigs( + partials[0].R, partials, + musig2.WithTaprootTweakedCombine( + message, participants, scriptRoot, false, + ), + ) + + return finalSig.Serialize() +} + +// customAnchorMuSig2SweepPSBT builds the caller-funded v3 cooperative sweep +// that spends the MuSig2 output through the aggregate key path and pays its own +// fee. The spent input declares the aggregate internal key and committed merkle +// root so the builder can derive the MuSig2 signing request. It returns the +// sweep output value. +func customAnchorMuSig2SweepPSBT(t testing.TB, inputProof *proof.Proof, + receiverInternalKey tapsdk.InternalKey, aggregateKey *btcec.PublicKey, + taprootMerkleRoot tapsdk.Hash) ([]byte, int64) { + + t.Helper() + inputOutpoint := inputProof.OutPoint() + inputTxOut := inputProof.AnchorTx.TxOut[inputOutpoint.Index] + require.Greater(t, inputTxOut.Value, customAnchorITestFeeSat) + sweepValue := inputTxOut.Value - customAnchorITestFeeSat + require.GreaterOrEqual(t, sweepValue, int64(330)) + + internalKey, err := btcec.ParsePubKey(receiverInternalKey.PubKey[:]) + require.NoError(t, err) + outputKey := txscript.ComputeTaprootKeyNoScript(internalKey) + placeholderScript, err := txscript.PayToTaprootScript(outputKey) + require.NoError(t, err) + + // Sanity check: the declared aggregate key and committed merkle root must + // reproduce the actual on-chain output key. + anchorOutputKey := txscript.ComputeTaprootOutputKey( + aggregateKey, taprootMerkleRoot[:], + ) + require.Equal(t, inputTxOut.PkScript, + mustPayToTaproot(t, anchorOutputKey)) + + sweepTx := wire.NewMsgTx(customAnchorITestVersion) + sweepTx.LockTime = 21 + sweepTx.AddTxIn(wire.NewTxIn(&inputOutpoint, nil, nil)) + sweepTx.TxIn[0].Sequence = customAnchorITestSequence + sweepTx.AddTxOut(&wire.TxOut{ + Value: sweepValue, + PkScript: placeholderScript, + }) + + packet, err := psbt.NewFromUnsignedTx(sweepTx) + require.NoError(t, err) + packet.Inputs[0].WitnessUtxo = inputTxOut + packet.Inputs[0].TaprootInternalKey = schnorr.SerializePubKey( + aggregateKey, + ) + packet.Inputs[0].TaprootMerkleRoot = taprootMerkleRoot[:] + + var buffer bytes.Buffer + require.NoError(t, packet.Serialize(&buffer)) + + return buffer.Bytes(), sweepValue +}