From ca196cdc69bba80eaab846d4448102addbaa1e45 Mon Sep 17 00:00:00 2001 From: Sadiq Jaffer Date: Thu, 13 Aug 2026 15:56:10 +0100 Subject: [PATCH 01/24] tapgarden: support externally signed mint anchors --- cmd/commands/assets.go | 88 +++- rpcserver/rpcserver.go | 108 ++++- tapdb/asset_minting.go | 18 +- tapgarden/batch.go | 36 +- tapgarden/caretaker.go | 21 +- tapgarden/custom_anchor_test.go | 175 ++++++++ tapgarden/interface.go | 5 + tapgarden/issue721_stateful_test.go | 292 +++++++++++++ tapgarden/planter.go | 625 +++++++++++++++++++++++++++- taprpc/mintrpc/mint.pb.go | 386 ++++++++++++----- taprpc/mintrpc/mint.pb.gw.go | 77 ++++ taprpc/mintrpc/mint.pb.json.go | 25 ++ taprpc/mintrpc/mint.proto | 40 ++ taprpc/mintrpc/mint.swagger.json | 80 ++++ taprpc/mintrpc/mint.yaml | 4 + taprpc/mintrpc/mint_grpc.pb.go | 44 ++ taprpc/perms.go | 4 + 17 files changed, 1868 insertions(+), 160 deletions(-) create mode 100644 tapgarden/custom_anchor_test.go create mode 100644 tapgarden/issue721_stateful_test.go diff --git a/cmd/commands/assets.go b/cmd/commands/assets.go index 0b6c8c53f1..6b8140d836 100644 --- a/cmd/commands/assets.go +++ b/cmd/commands/assets.go @@ -10,6 +10,7 @@ import ( "github.com/btcsuite/btcd/wire/v2" "github.com/lightninglabs/taproot-assets/address" + "github.com/lightninglabs/taproot-assets/fn" "github.com/lightninglabs/taproot-assets/rpcserver" "github.com/lightninglabs/taproot-assets/tapcfg" "github.com/lightninglabs/taproot-assets/taprpc" @@ -126,6 +127,13 @@ const ( shortResponseName = "short" enableSupplyCommitmentsName = "enable_supply_commitments" feeRateName = "sat_per_vbyte" + anchorPsbtName = "anchor_psbt" + assetAnchorOutputIndexName = "asset_anchor_output_index" + changeOutputIndexName = "change_output_index" + noChangeOutputName = "no_change_output" + preCommitOutputIndexName = "pre_commit_output_index" + signedPsbtName = "signed_psbt" + outputPsbtName = "output_psbt" skipProofCourierPingCheckName = "skip-proof-courier-ping-check" assetAmountName = "amount" burnOverrideConfirmationName = "override_confirmation_destroy_assets" @@ -238,6 +246,7 @@ var mintAssetCommand = cli.Command{ listBatchesCommand, fundBatchCommand, sealBatchCommand, + prepareBatchCommand, finalizeBatchCommand, cancelBatchCommand, }, @@ -496,6 +505,11 @@ var fundBatchCommand = cli.Command{ Usage: "if set, the fee rate in sat/vB to use for " + "the minting transaction", }, + cli.StringFlag{Name: anchorPsbtName, Usage: "caller-funded PSBT file"}, + cli.Uint64Flag{Name: assetAnchorOutputIndexName}, + cli.Int64Flag{Name: changeOutputIndexName}, + cli.BoolFlag{Name: noChangeOutputName}, + cli.Uint64Flag{Name: preCommitOutputIndexName}, }, Action: fundBatch, } @@ -510,10 +524,36 @@ func fundBatch(ctx *cli.Context) error { return err } - resp, err := client.FundBatch(ctxc, &mintrpc.FundBatchRequest{ + req := &mintrpc.FundBatchRequest{ ShortResponse: ctx.Bool(shortResponseName), FeeRate: feeRate, - }) + } + if path := ctx.String(anchorPsbtName); path != "" { + req.AnchorPsbt, err = os.ReadFile(tapcfg.CleanAndExpandPath(path)) + if err != nil { + return err + } + assetIdx := ctx.Uint64(assetAnchorOutputIndexName) + if assetIdx > math.MaxUint32 { + return fmt.Errorf("asset anchor output index out of range") + } + changeIdx := ctx.Int64(changeOutputIndexName) + if changeIdx < 0 || changeIdx > math.MaxInt32 { + return fmt.Errorf("change output index out of range") + } + req.AssetAnchorOutputIndex = uint32(assetIdx) + req.ChangeOutputIndex = int32(changeIdx) + req.NoChangeOutput = ctx.Bool(noChangeOutputName) + if ctx.IsSet(preCommitOutputIndexName) { + preCommitIdx := ctx.Uint64(preCommitOutputIndexName) + if preCommitIdx > math.MaxUint32 { + return fmt.Errorf("pre-commitment output index out of " + + "range") + } + req.PreCommitOutputIndex = fn.Ptr(uint32(preCommitIdx)) + } + } + resp, err := client.FundBatch(ctxc, req) if err != nil { return fmt.Errorf("unable to fund batch: %w", err) } @@ -522,6 +562,38 @@ func fundBatch(ctx *cli.Context) error { return nil } +var prepareBatchCommand = cli.Command{ + Name: "prepare", + Usage: "prepare a custom anchor batch for external signing", + Flags: []cli.Flag{cli.StringFlag{ + Name: outputPsbtName, + Usage: "write the raw prepared PSBT to this file", + }}, + Action: prepareBatch, +} + +func prepareBatch(ctx *cli.Context) error { + client, cleanUp := getMintClient(ctx) + defer cleanUp() + resp, err := client.PrepareBatch( + getContext(), &mintrpc.PrepareBatchRequest{}, + ) + if err != nil { + return fmt.Errorf("unable to prepare batch: %w", err) + } + if path := ctx.String(outputPsbtName); path != "" { + if resp.Batch == nil { + return fmt.Errorf("prepare response has no batch") + } + path = tapcfg.CleanAndExpandPath(path) + if err := os.WriteFile(path, resp.Batch.BatchPsbt, 0o600); err != nil { + return fmt.Errorf("unable to write prepared PSBT: %w", err) + } + } + printRespJSON(resp) + return nil +} + var sealBatchCommand = cli.Command{ Name: "seal", Usage: "seal a batch", @@ -616,6 +688,7 @@ var finalizeBatchCommand = cli.Command{ Usage: "if set, the fee rate in sat/vB to use for " + "the minting transaction", }, + cli.StringFlag{Name: signedPsbtName, Usage: "externally signed PSBT file"}, }, Action: finalizeBatch, } @@ -630,10 +703,17 @@ func finalizeBatch(ctx *cli.Context) error { return err } - resp, err := client.FinalizeBatch(ctxc, &mintrpc.FinalizeBatchRequest{ + req := &mintrpc.FinalizeBatchRequest{ ShortResponse: ctx.Bool(shortResponseName), FeeRate: feeRate, - }) + } + if path := ctx.String(signedPsbtName); path != "" { + req.SignedPsbt, err = os.ReadFile(tapcfg.CleanAndExpandPath(path)) + if err != nil { + return err + } + } + resp, err := client.FinalizeBatch(ctxc, req) if err != nil { return fmt.Errorf("unable to finalize batch: %w", err) } diff --git a/rpcserver/rpcserver.go b/rpcserver/rpcserver.go index 79c5e1bf72..125c86aa0f 100644 --- a/rpcserver/rpcserver.go +++ b/rpcserver/rpcserver.go @@ -945,14 +945,44 @@ func checkFeeRateSanity(ctx context.Context, rpcFeeRate chainfee.SatPerKWeight, // FundBatch attempts to fund the current pending batch. func (r *RPCServer) FundBatch(ctx context.Context, req *mintrpc.FundBatchRequest) (*mintrpc.FundBatchResponse, error) { + if len(req.AnchorPsbt) != 0 && req.FeeRate != 0 { + return nil, fmt.Errorf("fee rate cannot be combined with a " + + "caller-funded anchor PSBT") + } + if len(req.AnchorPsbt) == 0 && (req.AssetAnchorOutputIndex != 0 || + req.ChangeOutputIndex != 0 || req.NoChangeOutput || + req.PreCommitOutputIndex != nil) { - feeRate, err := checkFeeRateSanity( - ctx, chainfee.SatPerKWeight(req.FeeRate), r.cfg.Lnd.WalletKit, - ) - if err != nil { - return nil, err + return nil, fmt.Errorf("custom anchor output controls require " + + "anchor_psbt") + } + if req.NoChangeOutput && req.ChangeOutputIndex != 0 { + return nil, fmt.Errorf("no_change_output conflicts with " + + "change_output_index") + } + + var anchorPsbt *psbt.Packet + if len(req.AnchorPsbt) != 0 { + var err error + anchorPsbt, err = psbt.NewFromRawBytes( + bytes.NewReader(req.AnchorPsbt), false, + ) + if err != nil { + return nil, fmt.Errorf("unable to parse anchor PSBT: %w", err) + } + } + + var feeRateOpt fn.Option[chainfee.SatPerKWeight] + if anchorPsbt == nil { + feeRate, err := checkFeeRateSanity( + ctx, chainfee.SatPerKWeight(req.FeeRate), + r.cfg.Lnd.WalletKit, + ) + if err != nil { + return nil, err + } + feeRateOpt = fn.MaybeSome(feeRate) } - feeRateOpt := fn.MaybeSome(feeRate) tapTreeOpt, err := rpcutils.UnmarshalTapscriptSibling( req.GetFullTree(), req.GetBranch(), @@ -961,9 +991,21 @@ func (r *RPCServer) FundBatch(ctx context.Context, return nil, err } + changeOutputIndex := req.ChangeOutputIndex + if req.NoChangeOutput { + changeOutputIndex = -1 + } + preCommitIdx := fn.None[uint32]() + if req.PreCommitOutputIndex != nil { + preCommitIdx = fn.Some(req.GetPreCommitOutputIndex()) + } fundBatchResp, err := r.cfg.AssetMinter.FundBatch(tapgarden.FundParams{ - FeeRate: feeRateOpt, - SiblingTapTree: tapTreeOpt, + FeeRate: feeRateOpt, + SiblingTapTree: tapTreeOpt, + AnchorPsbt: anchorPsbt, + AssetAnchorOutIdx: req.AssetAnchorOutputIndex, + ChangeOutputIndex: changeOutputIndex, + PreCommitOutputIndex: preCommitIdx, }) if err != nil { return nil, fmt.Errorf("unable to fund batch: %w", err) @@ -987,6 +1029,23 @@ func (r *RPCServer) FundBatch(ctx context.Context, }, nil } +// PrepareBatch commits the current caller-authored batch into its selected +// anchor output and returns the packet for external signing. +func (r *RPCServer) PrepareBatch(_ context.Context, + req *mintrpc.PrepareBatchRequest) (*mintrpc.PrepareBatchResponse, error) { + + batch, err := r.cfg.AssetMinter.PrepareBatch() + if err != nil { + return nil, fmt.Errorf("unable to prepare batch: %w", err) + } + rpcBatch, err := marshalMintingBatch(batch, req.ShortResponse) + if err != nil { + return nil, err + } + + return &mintrpc.PrepareBatchResponse{Batch: rpcBatch}, nil +} + // UnmarshalGroupWitness parses an asset group witness from the RPC variant. func UnmarshalGroupWitness( wit *taprpc.GroupWitness) (*tapgarden.PendingGroupWitness, error) { @@ -1072,13 +1131,33 @@ func (r *RPCServer) FinalizeBatch(ctx context.Context, req *mintrpc.FinalizeBatchRequest) (*mintrpc.FinalizeBatchResponse, error) { - feeRate, err := checkFeeRateSanity( - ctx, chainfee.SatPerKWeight(req.FeeRate), r.cfg.Lnd.WalletKit, - ) - if err != nil { - return nil, err + var signedPsbt *psbt.Packet + if len(req.SignedPsbt) != 0 { + if req.FeeRate != 0 || req.BatchSibling != nil { + return nil, fmt.Errorf("signed_psbt cannot be combined with " + + "fee rate or tapscript sibling") + } + var err error + signedPsbt, err = psbt.NewFromRawBytes( + bytes.NewReader(req.SignedPsbt), false, + ) + if err != nil { + return nil, fmt.Errorf("unable to parse signed anchor PSBT: %w", + err) + } + } + + var feeRateOpt fn.Option[chainfee.SatPerKWeight] + if signedPsbt == nil { + feeRate, err := checkFeeRateSanity( + ctx, chainfee.SatPerKWeight(req.FeeRate), + r.cfg.Lnd.WalletKit, + ) + if err != nil { + return nil, err + } + feeRateOpt = fn.MaybeSome(feeRate) } - feeRateOpt := fn.MaybeSome(feeRate) tapTreeOpt, err := rpcutils.UnmarshalTapscriptSibling( req.GetFullTree(), req.GetBranch(), @@ -1091,6 +1170,7 @@ func (r *RPCServer) FinalizeBatch(ctx context.Context, tapgarden.FinalizeParams{ FeeRate: feeRateOpt, SiblingTapTree: tapTreeOpt, + SignedPsbt: signedPsbt, }, ) if err != nil { diff --git a/tapdb/asset_minting.go b/tapdb/asset_minting.go index 424f98e490..b7e02d8b88 100644 --- a/tapdb/asset_minting.go +++ b/tapdb/asset_minting.go @@ -959,8 +959,8 @@ func fetchAssetSeedlings(ctx context.Context, q PendingAssetStore, // generation, the GroupKeyFamily and GroupKeyIndex fields of the // FetchAssetsForBatchRow need to be manually modified to be sql.NullInt32. func fetchAssetSprouts(ctx context.Context, q PendingAssetStore, - rawBatchKey, batchSibling, genScript []byte) (*commitment.TapCommitment, - error) { + rawBatchKey, batchSibling, genScript []byte, + mintingInternalKey *btcec.PublicKey) (*commitment.TapCommitment, error) { dbSprout, err := q.FetchAssetsForBatch(ctx, rawBatchKey) if err != nil { @@ -1082,13 +1082,6 @@ func fetchAssetSprouts(ctx context.Context, q PendingAssetStore, sprouts[i] = assetSprout } - // Verify that we can reconstruct the genesis output script used in the - // anchor TX. - batchKey, err := btcec.ParsePubKey(rawBatchKey) - if err != nil { - return nil, err - } - var tapSibling *chainhash.Hash if len(batchSibling) != 0 { tapSibling, err = chainhash.NewHash(batchSibling) @@ -1098,7 +1091,7 @@ func fetchAssetSprouts(ctx context.Context, q PendingAssetStore, } return tapgarden.VerifyOutputScript( - batchKey, tapSibling, genScript, sprouts, + mintingInternalKey, tapSibling, genScript, sprouts, ) } @@ -1447,8 +1440,13 @@ func marshalMintingBatch(ctx context.Context, q PendingAssetStore, genesisTx := batch.GenesisPacket.Pkt.UnsignedTx genesisScript := genesisTx.TxOut[assetAnchorOutIdx].PkScript tapscriptSibling := batch.TapSibling() + mintingInternalKey, err := batch.MintingInternalKey() + if err != nil { + return nil, err + } batch.RootAssetCommitment, err = fetchAssetSprouts( ctx, q, dbBatch.RawKey, tapscriptSibling, genesisScript, + mintingInternalKey, ) if err != nil { return nil, err diff --git a/tapgarden/batch.go b/tapgarden/batch.go index 11bfc15cf1..5805ec82aa 100644 --- a/tapgarden/batch.go +++ b/tapgarden/batch.go @@ -7,6 +7,7 @@ import ( "time" "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/txscript/v2" "github.com/lightninglabs/taproot-assets/asset" @@ -190,13 +191,46 @@ func (m *MintingBatch) MintingOutputKey(sibling *commitment.TapscriptPreimage) ( siblingHash, ) + internalKey, err := m.MintingInternalKey() + if err != nil { + return nil, nil, err + } + mintingPubKey := txscript.ComputeTaprootOutputKey( - m.BatchKey.PubKey, taprootAssetScriptRoot[:], + internalKey, taprootAssetScriptRoot[:], ) return mintingPubKey, taprootAssetScriptRoot[:], nil } +// MintingInternalKey returns the internal key used for the asset anchor. A +// caller-authored anchor carries this key in the selected PSBT output; legacy +// batches continue to use the batch key. +func (m *MintingBatch) MintingInternalKey() (*btcec.PublicKey, error) { + if m.GenesisPacket == nil || + !isCustomAnchorPsbt(m.GenesisPacket.Pkt) { + + return m.BatchKey.PubKey, nil + } + + pkt := m.GenesisPacket.Pkt + anchorIdx := m.GenesisPacket.AssetAnchorOutIdx + if pkt == nil || int(anchorIdx) >= len(pkt.Outputs) { + return nil, fmt.Errorf("custom anchor PSBT is missing the asset " + + "anchor output map") + } + + internalKey, err := schnorr.ParsePubKey( + pkt.Outputs[anchorIdx].TaprootInternalKey, + ) + if err != nil { + return nil, fmt.Errorf("invalid custom anchor internal key: %w", + err) + } + + return internalKey, nil +} + // VerifyOutputScript recomputes a batch genesis output script from a batch key, // tapscript sibling, and set of assets. It checks multiple tap commitment // versions to account for legacy batches. diff --git a/tapgarden/caretaker.go b/tapgarden/caretaker.go index 495cdd31bb..7adde34bee 100644 --- a/tapgarden/caretaker.go +++ b/tapgarden/caretaker.go @@ -711,11 +711,16 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) // TODO(roasbeef): only execute if finalized? or missing sig ctx, cancel := b.WithCtxQuit() defer cancel() - signedPkt, err := b.cfg.Wallet.SignAndFinalizePsbt( - ctx, b.cfg.Batch.GenesisPacket.Pkt, - ) - if err != nil { - return 0, fmt.Errorf("unable to sign psbt: %w", err) + signedPkt := b.cfg.Batch.GenesisPacket.Pkt + _, extractErr := psbt.Extract(signedPkt) + if extractErr != nil { + signedPkt, extractErr = b.cfg.Wallet.SignAndFinalizePsbt( + ctx, signedPkt, + ) + if extractErr != nil { + return 0, fmt.Errorf("unable to sign psbt: %w", + extractErr) + } } // Final TX sanity check. @@ -1007,6 +1012,10 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) // output we need to create an exclusion proof for it (and for // all other P2TR outputs, we just assume BIP-0086 here). batchCommitment := b.cfg.Batch.RootAssetCommitment + mintingInternalKey, err := b.cfg.Batch.MintingInternalKey() + if err != nil { + return 0, err + } pkt := b.cfg.Batch.GenesisPacket genesisOutPoint, err := pkt.GenesisOutpoint().UnwrapOrErr( @@ -1023,7 +1032,7 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) Tx: confInfo.Tx, TxIndex: int(confInfo.TxIndex), OutputIndex: int(b.anchorOutputIndex), - InternalKey: b.cfg.Batch.BatchKey.PubKey, + InternalKey: mintingInternalKey, TapscriptSibling: batchSibling, TaprootAssetRoot: batchCommitment, }, diff --git a/tapgarden/custom_anchor_test.go b/tapgarden/custom_anchor_test.go new file mode 100644 index 0000000000..e46567e975 --- /dev/null +++ b/tapgarden/custom_anchor_test.go @@ -0,0 +1,175 @@ +package tapgarden + +import ( + "bytes" + "errors" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/psbt/v2" + "github.com/btcsuite/btcd/txscript/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/taproot-assets/address" + "github.com/lightninglabs/taproot-assets/fn" + "github.com/lightninglabs/taproot-assets/tapsend" + "github.com/stretchr/testify/require" +) + +func testCustomAnchorPacket(t *testing.T) *psbt.Packet { + t.Helper() + + _, pub := btcec.PrivKeyFromBytes(bytes.Repeat([]byte{1}, 32)) + pkScript, err := txscript.PayToTaprootScript(pub) + require.NoError(t, err) + var prevHash chainhash.Hash + prevHash[0] = 1 + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{PreviousOutPoint: wire.OutPoint{ + Hash: prevHash, Index: 1, + }}) + tx.AddTxOut(&wire.TxOut{Value: 1_000, PkScript: pkScript}) + pkt, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + pkt.Inputs[0].WitnessUtxo = &wire.TxOut{ + Value: 2_000, PkScript: pkScript, + } + pkt.Outputs[0].TaprootInternalKey = schnorr.SerializePubKey(pub) + pkt.Unknowns = []*psbt.Unknown{{Key: []byte{0x50}, Value: []byte("x")}} + + return pkt +} + +func TestCustomGenesisPsbtValidation(t *testing.T) { + pkt := testCustomAnchorPacket(t) + funded, err := customGenesisPsbt( + address.TestNet3Tap, nil, pkt, 0, -1, noneUint32(), + ) + require.NoError(t, err) + require.True(t, isCustomAnchorPsbt(funded.Pkt)) + require.True(t, funded.GenesisOutpoint().IsSome()) + + bad := testCustomAnchorPacket(t) + bad.Inputs = nil + _, err = customGenesisPsbt( + address.TestNet3Tap, nil, bad, 0, -1, noneUint32(), + ) + require.ErrorContains(t, err, "input maps") + + underfunded := testCustomAnchorPacket(t) + underfunded.Inputs[0].WitnessUtxo.Value = 500 + _, err = customGenesisPsbt( + address.TestNet3Tap, nil, underfunded, 0, -1, noneUint32(), + ) + require.ErrorContains(t, err, "outputs exceed") + + dust := testCustomAnchorPacket(t) + dust.UnsignedTx.TxOut[0].Value = 1 + dust.UnsignedTx.TxOut[0].PkScript = []byte{txscript.OP_RETURN} + _, err = customGenesisPsbt( + address.TestNet3Tap, nil, dust, 0, -1, noneUint32(), + ) + require.ErrorContains(t, err, "anchor output is dust") +} + +func TestMergeSignedCustomPsbt(t *testing.T) { + stored := testCustomAnchorPacket(t) + markCustomAnchorPsbt(stored) + signed := clonePsbt(t, stored) + signed.Inputs[0].FinalScriptSig = []byte{} + signed.Inputs[0].FinalScriptWitness = []byte{1, 0} + signed.Inputs[0].TaprootInternalKey = nil + + merged, err := mergeSignedCustomPsbt(stored, signed) + require.NoError(t, err) + require.NotNil(t, merged.Inputs[0].FinalScriptSig) + require.NotEmpty(t, merged.Inputs[0].FinalScriptWitness) + require.Equal(t, stored.Inputs[0].WitnessUtxo, + merged.Inputs[0].WitnessUtxo) + + mutated := clonePsbt(t, signed) + mutated.Inputs[0].WitnessUtxo.Value++ + _, err = mergeSignedCustomPsbt(stored, mutated) + require.ErrorContains(t, err, "changes input UTXO") + require.Nil(t, stored.Inputs[0].FinalScriptSig) +} + +func TestFundedMintAnchorPsbtCopyPreservesMetadata(t *testing.T) { + pkt := testCustomAnchorPacket(t) + pkt.Outputs[0].Unknowns = []*psbt.Unknown{{ + Key: []byte{0x51}, Value: []byte("output"), + }} + pkt.Inputs[0].SighashType = txscript.SigHashSingle + original := &FundedMintAnchorPsbt{FundedPsbt: fundedPsbt(pkt)} + + copyPkt := original.Copy().Pkt + require.Equal(t, pkt.Unknowns, copyPkt.Unknowns) + require.Equal(t, pkt.Outputs, copyPkt.Outputs) + require.Equal(t, pkt.Inputs, copyPkt.Inputs) + copyPkt.Unknowns[0].Value[0] ^= 1 + require.NotEqual(t, pkt.Unknowns, copyPkt.Unknowns) +} + +func TestCustomGenesisPsbtSupplyPreCommitment(t *testing.T) { + seedling := RandGroupAnchorSeedling(t, "supply-anchor", true) + batch := &MintingBatch{ + Seedlings: map[string]*Seedling{ + seedling.AssetName: &seedling, + }, + SupplyCommitments: true, + } + + pkt := testCustomAnchorPacket(t) + delegationKey, err := seedling.DelegationKey.UnwrapOrErr( + errors.New("delegation key missing"), + ) + require.NoError(t, err) + preCommitOut, err := PreCommitTxOut(*delegationKey.PubKey) + require.NoError(t, err) + pkt.UnsignedTx.AddTxOut(&preCommitOut) + pkt.Outputs = append(pkt.Outputs, psbt.POutput{}) + + _, err = customGenesisPsbt( + address.TestNet3Tap, batch, clonePsbt(t, pkt), 0, -1, + noneUint32(), + ) + require.ErrorContains(t, err, "requires a pre-commitment output index") + + wrong := clonePsbt(t, pkt) + wrong.UnsignedTx.TxOut[1].PkScript[0] ^= 1 + _, err = customGenesisPsbt( + address.TestNet3Tap, batch, wrong, 0, -1, fn.Some(uint32(1)), + ) + require.ErrorContains(t, err, "doesn't match the batch delegation key") + + funded, err := customGenesisPsbt( + address.TestNet3Tap, batch, clonePsbt(t, pkt), 0, -1, + fn.Some(uint32(1)), + ) + require.NoError(t, err) + preCommit, err := funded.PreCommitmentOutput.UnwrapOrErr( + errors.New("pre-commitment output missing"), + ) + require.NoError(t, err) + require.Equal(t, uint32(1), preCommit.OutIdx) + require.Equal(t, schnorr.SerializePubKey(delegationKey.PubKey), + funded.Pkt.Outputs[1].TaprootInternalKey) + require.Len(t, funded.Pkt.Outputs[1].Bip32Derivation, 1) + require.Len(t, funded.Pkt.Outputs[1].TaprootBip32Derivation, 1) +} + +func noneUint32() fn.Option[uint32] { return fn.None[uint32]() } + +func fundedPsbt(pkt *psbt.Packet) tapsend.FundedPsbt { + return tapsend.FundedPsbt{Pkt: pkt, ChangeOutputIndex: -1} +} + +func clonePsbt(t *testing.T, pkt *psbt.Packet) *psbt.Packet { + t.Helper() + var buf bytes.Buffer + require.NoError(t, pkt.Serialize(&buf)) + clone, err := psbt.NewFromRawBytes(&buf, false) + require.NoError(t, err) + return clone +} diff --git a/tapgarden/interface.go b/tapgarden/interface.go index cf9fa4f3cf..c296eaff27 100644 --- a/tapgarden/interface.go +++ b/tapgarden/interface.go @@ -54,6 +54,11 @@ type Planter interface { // deriving all witnesses necessary to create the final genesis TX. SealBatch(params SealParams) (*MintingBatch, error) + // PrepareBatch freezes a caller-funded batch and commits the asset tree + // into its selected anchor output. The returned PSBT is then ready for + // external signing. + PrepareBatch() (*MintingBatch, error) + // FinalizeBatch signals that the asset minter should finalize // the current batch, if one exists. FinalizeBatch(params FinalizeParams) (*MintingBatch, error) diff --git a/tapgarden/issue721_stateful_test.go b/tapgarden/issue721_stateful_test.go new file mode 100644 index 0000000000..ead2565ee8 --- /dev/null +++ b/tapgarden/issue721_stateful_test.go @@ -0,0 +1,292 @@ +package tapgarden_test + +import ( + "bytes" + "crypto/sha256" + "sync" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/psbt/v2" + "github.com/btcsuite/btcd/txscript/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/fn" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightninglabs/taproot-assets/tapgarden" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/stretchr/testify/require" +) + +// issue721Anchor constructs a caller-owned PSBT whose first output and all +// input metadata must survive mint preparation unchanged. Output one is the +// caller-selected asset anchor. +func issue721Anchor(t *testing.T) (*psbt.Packet, *btcec.PublicKey, []byte) { + t.Helper() + + witnessScript := []byte{txscript.OP_TRUE} + witnessHash := sha256.Sum256(witnessScript) + prevScript, err := txscript.NewScriptBuilder(). + AddOp(txscript.OP_0).AddData(witnessHash[:]).Script() + require.NoError(t, err) + + priv, internalKey := btcec.PrivKeyFromBytes(bytes.Repeat([]byte{7}, 32)) + require.NotNil(t, priv) + anchorKey := txscript.ComputeTaprootKeyNoScript(internalKey) + anchorScript, err := txscript.PayToTaprootScript(anchorKey) + require.NoError(t, err) + + var prevHash chainhash.Hash + copy(prevHash[:], bytes.Repeat([]byte{3}, 32)) + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{Hash: prevHash, Index: 9}, + Sequence: 12345, + }) + tx.AddTxOut(&wire.TxOut{ + Value: 2_000, + PkScript: []byte{txscript.OP_RETURN, 0x01, 0x42}, + }) + tx.AddTxOut(&wire.TxOut{Value: 10_000, PkScript: anchorScript}) + + pkt, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + pkt.Inputs[0].WitnessUtxo = &wire.TxOut{ + Value: 20_000, + PkScript: prevScript, + } + pkt.Inputs[0].WitnessScript = witnessScript + pkt.Inputs[0].SighashType = txscript.SigHashAll + pkt.Inputs[0].Unknowns = []*psbt.Unknown{{ + Key: []byte{0x50}, Value: []byte("input-metadata"), + }} + pkt.Outputs[0].Unknowns = []*psbt.Unknown{{ + Key: []byte{0x51}, Value: []byte("output-metadata"), + }} + pkt.Outputs[1].TaprootInternalKey = schnorr.SerializePubKey(internalKey) + pkt.Outputs[1].Unknowns = []*psbt.Unknown{{ + Key: []byte{0x52}, Value: []byte("anchor-metadata"), + }} + pkt.Unknowns = []*psbt.Unknown{{ + Key: []byte{0x53}, Value: []byte("global-metadata"), + }} + + return pkt, internalKey, witnessScript +} + +func issue721Seedling() *tapgarden.Seedling { + return &tapgarden.Seedling{ + AssetVersion: asset.V0, + AssetType: asset.Normal, + AssetName: "issue-721-custom-anchor", + Meta: &proof.MetaReveal{Data: []byte("issue-721")}, + Amount: 100, + } +} + +func issue721Fund(t *testing.T, h *mintingTestHarness, + pkt *psbt.Packet) *tapgarden.MintingBatch { + + t.Helper() + resp, err := h.planter.FundBatch(tapgarden.FundParams{ + FeeRate: fn.None[chainfee.SatPerKWeight](), + SiblingTapTree: fn.None[asset.TapscriptTreeNodes](), + AnchorPsbt: pkt, + AssetAnchorOutIdx: 1, + ChangeOutputIndex: -1, + PreCommitOutputIndex: fn.None[uint32](), + }) + require.NoError(t, err) + require.NotNil(t, resp) + + return resp.Batch.ToMintingBatch() +} + +func issue721FinalWitness(t *testing.T, witnessScript []byte) []byte { + t.Helper() + + var buf bytes.Buffer + err := psbt.WriteTxWitness(&buf, wire.TxWitness{witnessScript}) + require.NoError(t, err) + + return buf.Bytes() +} + +// TestIssue721PrepareSignResume exercises the complete caller-owned anchor +// boundary: custom funding, synchronous commitment, restart pause, failed +// external witness retry, and successful broadcast with the original input and +// non-anchor output intact. +func TestIssue721PrepareSignResume(t *testing.T) { + store := newMintingStore(t) + h := newMintingTestHarness(t, store) + h.refreshChainPlanter() + t.Cleanup(func() { + if h.planter != nil { + _ = h.planter.Stop() + } + }) + + seedling := issue721Seedling() + h.queueSeedlingsInBatch(false, seedling) + pkt, customInternalKey, witnessScript := issue721Anchor(t) + original := clonePacket(t, pkt) + + funded := issue721Fund(t, h, pkt) + require.Equal(t, tapgarden.BatchStatePending, funded.State()) + require.Equal(t, original.UnsignedTx.TxIn, + funded.GenesisPacket.Pkt.UnsignedTx.TxIn) + require.Equal(t, original.UnsignedTx.TxOut, + funded.GenesisPacket.Pkt.UnsignedTx.TxOut) + require.Equal(t, original.Inputs, funded.GenesisPacket.Pkt.Inputs) + require.Equal(t, original.Outputs, funded.GenesisPacket.Pkt.Outputs) + + prepared, err := h.planter.PrepareBatch() + require.NoError(t, err) + require.Equal(t, tapgarden.BatchStateCommitted, prepared.State()) + require.Equal(t, uint32(1), prepared.GenesisPacket.AssetAnchorOutIdx) + + preparedPkt := prepared.GenesisPacket.Pkt + require.Equal(t, original.UnsignedTx.TxIn, preparedPkt.UnsignedTx.TxIn) + require.Equal(t, original.UnsignedTx.TxOut[0], + preparedPkt.UnsignedTx.TxOut[0]) + require.Equal(t, original.UnsignedTx.TxOut[1].Value, + preparedPkt.UnsignedTx.TxOut[1].Value) + require.NotEqual(t, original.UnsignedTx.TxOut[1].PkScript, + preparedPkt.UnsignedTx.TxOut[1].PkScript) + require.Equal(t, original.Inputs, preparedPkt.Inputs) + require.Equal(t, original.Outputs, preparedPkt.Outputs) + + gotInternalKey, err := prepared.MintingInternalKey() + require.NoError(t, err) + require.True(t, gotInternalKey.IsEqual(customInternalKey)) + root := prepared.RootAssetCommitment.TapscriptRoot(nil) + expectedOutputKey := txscript.ComputeTaprootOutputKey( + customInternalKey, root[:], + ) + outputKey, _, err := prepared.MintingOutputKey(nil) + require.NoError(t, err) + require.True(t, outputKey.IsEqual(expectedOutputKey)) + + // A committed custom batch must remain pending for its external signer + // across restart, with no caretaker attempting wallet signing. + h.refreshChainPlanter() + restored, err := h.planter.PendingBatch() + require.NoError(t, err) + require.Equal(t, tapgarden.BatchStateCommitted, restored.State()) + h.assertNumCaretakersActive(0) + restoredInternalKey, err := restored.MintingInternalKey() + require.NoError(t, err) + require.True(t, restoredInternalKey.IsEqual(customInternalKey)) + + // A corrupt witness must fail before the persisted prepared packet is + // mutated, so a corrected external signature can be retried. + corrupt := clonePacket(t, restored.GenesisPacket.Pkt) + corrupt.Inputs[0].FinalScriptWitness = issue721FinalWitness( + t, []byte{txscript.OP_FALSE}, + ) + _, err = h.planter.FinalizeBatch(tapgarden.FinalizeParams{ + SignedPsbt: corrupt, + }) + require.ErrorContains(t, err, "externally signed PSBT is not fully valid") + afterFailure, err := h.planter.PendingBatch() + require.NoError(t, err) + require.Equal(t, tapgarden.BatchStateCommitted, afterFailure.State()) + require.Empty(t, + afterFailure.GenesisPacket.Pkt.Inputs[0].FinalScriptWitness) + + valid := clonePacket(t, afterFailure.GenesisPacket.Pkt) + valid.Inputs[0].FinalScriptWitness = issue721FinalWitness( + t, witnessScript, + ) + var wg sync.WaitGroup + respChan := make(chan *FinalizeBatchResp, 1) + h.finalizeBatch(&wg, respChan, &tapgarden.FinalizeParams{ + SignedPsbt: valid, + }) + importedKey, err := fn.RecvOrTimeout( + h.wallet.ImportPubKeySignal, defaultTimeout, + ) + require.NoError(t, err) + require.True(t, (*importedKey).IsEqual(expectedOutputKey)) + published := h.assertTxPublished() + _, err = fn.RecvOrTimeout(h.chain.ConfReqSignal, defaultTimeout) + require.NoError(t, err) + minted := h.assertFinalizeBatch(&wg, respChan, "") + require.Equal(t, original.UnsignedTx.TxIn[0].PreviousOutPoint, + published.TxIn[0].PreviousOutPoint) + require.Equal(t, original.UnsignedTx.TxIn[0].Sequence, + published.TxIn[0].Sequence) + require.Equal(t, original.UnsignedTx.TxOut[0], published.TxOut[0]) + require.Equal(t, original.UnsignedTx.TxOut[1].Value, + published.TxOut[1].Value) + require.Equal(t, tapgarden.BatchStateBroadcast, minted.State()) +} + +// TestIssue721CustomRestartStates pins the startup distinction introduced by +// the external-signing flow: custom pending/frozen batches pause, while a +// legacy pending batch still resumes through the existing caretaker path. +func TestIssue721CustomRestartStates(t *testing.T) { + for _, state := range []tapgarden.BatchState{ + tapgarden.BatchStatePending, + tapgarden.BatchStateFrozen, + } { + state := state + t.Run(state.String(), func(t *testing.T) { + store := newMintingStore(t) + h := newMintingTestHarness(t, store) + h.refreshChainPlanter() + t.Cleanup(func() { + if h.planter != nil { + _ = h.planter.Stop() + } + }) + + h.queueSeedlingsInBatch(false, issue721Seedling()) + pkt, _, _ := issue721Anchor(t) + batch := issue721Fund(t, h, pkt) + if state == tapgarden.BatchStateFrozen { + err := store.UpdateBatchState( + t.Context(), batch.BatchKey.PubKey, state, + ) + require.NoError(t, err) + } + + h.refreshChainPlanter() + restored, err := h.planter.PendingBatch() + require.NoError(t, err) + require.Equal(t, state, restored.State()) + h.assertNumCaretakersActive(0) + }) + } + + t.Run("legacy pending resumes", func(t *testing.T) { + store := newMintingStore(t) + h := newMintingTestHarness(t, store) + h.refreshChainPlanter() + t.Cleanup(func() { + if h.planter != nil { + _ = h.planter.Stop() + } + }) + + h.queueSeedlingsInBatch(false, issue721Seedling()) + var wg sync.WaitGroup + h.assertBatchResumedBackground(&wg, true, true) + h.refreshChainPlanter() + wg.Wait() + h.assertNumCaretakersActive(1) + }) +} + +func clonePacket(t *testing.T, pkt *psbt.Packet) *psbt.Packet { + t.Helper() + + var buf bytes.Buffer + require.NoError(t, pkt.Serialize(&buf)) + clone, err := psbt.NewFromRawBytes(&buf, false) + require.NoError(t, err) + + return clone +} diff --git a/tapgarden/planter.go b/tapgarden/planter.go index e453626b99..ee54122181 100644 --- a/tapgarden/planter.go +++ b/tapgarden/planter.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "reflect" "slices" "sync" "time" @@ -13,6 +14,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/chaincfg/v2" "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/mempool" "github.com/btcsuite/btcd/psbt/v2" "github.com/btcsuite/btcd/txscript/v2" "github.com/btcsuite/btcd/wire/v2" @@ -314,6 +316,7 @@ type UnsealedSeedling struct { type FinalizeParams struct { FeeRate fn.Option[chainfee.SatPerKWeight] SiblingTapTree fn.Option[asset.TapscriptTreeNodes] + SignedPsbt *psbt.Packet } // FundParams are the options available to change how a batch is funded, and how @@ -321,6 +324,54 @@ type FinalizeParams struct { type FundParams struct { FeeRate fn.Option[chainfee.SatPerKWeight] SiblingTapTree fn.Option[asset.TapscriptTreeNodes] + + // AnchorPsbt is an optional caller-authored anchor transaction. When + // present, the wallet funding step is skipped and the caller-selected + // inputs, outputs and PSBT metadata are preserved. + AnchorPsbt *psbt.Packet + + // AssetAnchorOutIdx selects the output whose script will be replaced by + // the final Taproot Asset commitment during batch preparation. + AssetAnchorOutIdx uint32 + + // ChangeOutputIndex identifies a pre-existing change output in the + // caller-authored PSBT. A value of -1 means that there is no change + // output. + ChangeOutputIndex int32 + + // PreCommitOutputIndex selects the caller-provided supply commitment + // pre-commitment output. It must be set when the pending batch enables + // supply commitments. + PreCommitOutputIndex fn.Option[uint32] +} + +var customAnchorPsbtMarker = []byte{ + 0xfc, 0x04, 't', 'a', 'p', 'd', 0x01, +} + +func markCustomAnchorPsbt(packet *psbt.Packet) { + if isCustomAnchorPsbt(packet) { + return + } + + packet.Unknowns = append(packet.Unknowns, &psbt.Unknown{ + Key: fn.CopySlice(customAnchorPsbtMarker), + Value: []byte{1}, + }) +} + +func isCustomAnchorPsbt(packet *psbt.Packet) bool { + if packet == nil { + return false + } + + for _, unknown := range packet.Unknowns { + if bytes.Equal(unknown.Key, customAnchorPsbtMarker) { + return true + } + } + + return false } // PendingGroupWitness specifies the asset group witness for an asset seedling @@ -391,6 +442,7 @@ const ( reqTypeCancelBatch reqTypeFundBatch reqTypeSealBatch + reqTypePrepareBatch ) // ChainPlanter is responsible for accepting new incoming requests to create @@ -523,13 +575,32 @@ func (c *ChainPlanter) Start() error { continue } - // For batches before the actual assets have been - // committed, we'll need to populate this field - // manually. + // All restored batches need a writable metadata map before any + // custom batch can be resumed through preparation or proof work. if batch.AssetMetas == nil { batch.AssetMetas = make(AssetMetas) } + // A committed custom batch is deliberately paused for an + // external signer. Restore it as the pending batch instead of + // launching a caretaker that would immediately ask lnd to sign. + customBatch := batch.GenesisPacket != nil && + isCustomAnchorPsbt(batch.GenesisPacket.Pkt) + awaitingExternalSigner := customBatch && + (batchState == BatchStatePending || + batchState == BatchStateFrozen || + batchState == BatchStateCommitted) + if awaitingExternalSigner { + + if c.pendingBatch != nil { + startErr = fmt.Errorf("multiple custom batches " + + "awaiting external signatures") + return + } + c.pendingBatch = batch + continue + } + // If batch funding or sealing fail during startup, the // batch will be marked as cancelled. The batch can // still be displayed by the planter, and can be @@ -1171,6 +1242,172 @@ func fundGenesisPsbt(ctx context.Context, chainParams address.ChainParams, return fundedMintAnchorPsbt, nil } +// customGenesisPsbt validates and packages a caller-authored mint anchor +// PSBT. Unlike fundGenesisPsbt, this path doesn't ask the backing wallet to +// add or reorder anything in the packet. +func customGenesisPsbt(chainParams address.ChainParams, + pendingBatch *MintingBatch, packet *psbt.Packet, + assetAnchorOutIdx uint32, + changeOutputIndex int32, + preCommitOutputIndex fn.Option[uint32]) (FundedMintAnchorPsbt, error) { + + var zero FundedMintAnchorPsbt + + if packet == nil || packet.UnsignedTx == nil { + return zero, fmt.Errorf("custom anchor PSBT is missing its " + + "unsigned transaction") + } + if len(packet.UnsignedTx.TxIn) == 0 { + return zero, fmt.Errorf("custom anchor PSBT must have at least " + + "one input") + } + if len(packet.Inputs) != len(packet.UnsignedTx.TxIn) { + return zero, fmt.Errorf("custom anchor PSBT input maps don't " + + "match unsigned transaction inputs") + } + if len(packet.Outputs) != len(packet.UnsignedTx.TxOut) { + return zero, fmt.Errorf("custom anchor PSBT output maps don't " + + "match unsigned transaction outputs") + } + if int(assetAnchorOutIdx) >= len(packet.UnsignedTx.TxOut) { + return zero, fmt.Errorf("asset anchor output index %d out of "+ + "range", assetAnchorOutIdx) + } + if changeOutputIndex < -1 || + changeOutputIndex >= int32(len(packet.UnsignedTx.TxOut)) { + + return zero, fmt.Errorf("change output index %d out of range", + changeOutputIndex) + } + if changeOutputIndex == int32(assetAnchorOutIdx) { + return zero, fmt.Errorf("asset anchor and change output indexes " + + "must be distinct") + } + if err := packet.SanityCheck(); err != nil { + return zero, fmt.Errorf("invalid custom anchor PSBT: %w", err) + } + fee, err := packet.GetTxFee() + if err != nil { + return zero, fmt.Errorf("custom anchor PSBT has incomplete or "+ + "invalid input values: %w", err) + } + if fee < 0 { + return zero, fmt.Errorf("custom anchor PSBT outputs exceed " + + "known input value") + } + anchorOutput := packet.UnsignedTx.TxOut[assetAnchorOutIdx] + eventualAnchorOutput := tapsend.CreateDummyOutput() + eventualAnchorOutput.Value = anchorOutput.Value + if eventualAnchorOutput.Value < + mempool.GetDustThreshold(eventualAnchorOutput) { + + return zero, fmt.Errorf("custom asset anchor output is dust") + } + + for idx := range packet.Inputs { + pIn := &packet.Inputs[idx] + if len(pIn.PartialSigs) != 0 || len(pIn.FinalScriptSig) != 0 || + len(pIn.FinalScriptWitness) != 0 || + len(pIn.TaprootKeySpendSig) != 0 || + len(pIn.TaprootScriptSpendSig) != 0 { + + return zero, fmt.Errorf("custom anchor PSBT must be " + + "unsigned before batch preparation") + } + } + + // A custom anchor's internal key is carried in the standard PSBT output + // field. This lets the caretaker derive the final output script without + // conflating the externally controlled key with the batch key. + pOut := packet.Outputs[assetAnchorOutIdx] + if len(pOut.TaprootInternalKey) != schnorr.PubKeyBytesLen { + return zero, fmt.Errorf("custom asset anchor output must specify " + + "a taproot internal key") + } + if _, err := schnorr.ParsePubKey(pOut.TaprootInternalKey); err != nil { + return zero, fmt.Errorf("invalid custom asset anchor internal "+ + "key: %w", err) + } + + funded := tapsend.FundedPsbt{ + Pkt: packet, + ChangeOutputIndex: changeOutputIndex, + } + markCustomAnchorPsbt(packet) + + var preCommitOut fn.Option[PreCommitmentOutput] + var preCommitIdx fn.Option[uint32] + if pendingBatch != nil && pendingBatch.SupplyCommitments { + idx, err := preCommitOutputIndex.UnwrapOrErr(fmt.Errorf( + "custom supply commitment batch requires a pre-commitment " + + "output index", + )) + if err != nil { + return zero, err + } + if int(idx) >= len(packet.UnsignedTx.TxOut) || + idx == assetAnchorOutIdx || idx == uint32(changeOutputIndex) { + + return zero, fmt.Errorf("invalid pre-commitment output "+ + "index %d", idx) + } + + delegationKey, err := fetchDelegationKey(pendingBatch) + if err != nil { + return zero, err + } + dKey, err := delegationKey.UnwrapOrErr(fmt.Errorf( + "missing supply commitment delegation key", + )) + if err != nil { + return zero, err + } + expectedOutput, err := PreCommitTxOut(*dKey.PubKey) + if err != nil { + return zero, err + } + actualOutput := packet.UnsignedTx.TxOut[idx] + if actualOutput.Value != expectedOutput.Value || + !bytes.Equal(actualOutput.PkScript, expectedOutput.PkScript) { + + return zero, fmt.Errorf("pre-commitment output %d doesn't "+ + "match the batch delegation key", idx) + } + + bip32Derivation, trBip32Derivation := + tappsbt.Bip32DerivationFromKeyDesc( + dKey, chainParams.HDCoinType, + ) + pOut := &packet.Outputs[idx] + pOut.Bip32Derivation = []*psbt.Bip32Derivation{bip32Derivation} + pOut.TaprootBip32Derivation = []*psbt.TaprootBip32Derivation{ + trBip32Derivation, + } + pOut.TaprootInternalKey = trBip32Derivation.XOnlyPubKey + + groupKey, err := fetchPreCommitGroupKey(pendingBatch) + if err != nil { + return zero, err + } + preCommitOut = fn.Some(NewPreCommitmentOutput( + idx, dKey, groupKey, + )) + preCommitIdx = fn.Some(idx) + } else if preCommitOutputIndex.IsSome() { + return zero, fmt.Errorf("pre-commitment output index specified " + + "for batch without supply commitments") + } + indexes := AnchorTxOutputIndexes{ + AssetAnchorOutIdx: assetAnchorOutIdx, + ChangeOutIdx: 0, + PreCommitOutIdx: preCommitIdx, + } + + return NewFundedMintAnchorPsbt( + funded, indexes, preCommitOut, + ) +} + // filterSeedlingsWithGroup separates a set of seedlings into two sets based on // their relation to an asset group, which has not been constructed yet. func filterSeedlingsWithGroup( @@ -1569,8 +1806,12 @@ func fetchFinalizedBatch(ctx context.Context, batchStore MintingStore, } } + mintingInternalKey, err := batch.MintingInternalKey() + if err != nil { + return nil, err + } tapCommitment, err := VerifyOutputScript( - batch.BatchKey.PubKey, tapSibling, genScript, batchAssets, + mintingInternalKey, tapSibling, genScript, batchAssets, ) if err != nil { @@ -2065,6 +2306,26 @@ func (c *ChainPlanter) gardener() { req.Resolve(c.pendingBatch.Copy()) } + case reqTypePrepareBatch: + if c.pendingBatch == nil { + req.Error(fmt.Errorf("no pending batch")) + break + } + + ctx, cancel := c.WithCtxQuit() + preparedBatch, err := c.prepareBatch( + ctx, c.pendingBatch, + ) + cancel() + if err != nil { + req.Error(fmt.Errorf("unable to prepare minting "+ + "batch: %w", err)) + break + } + + c.pendingBatch = preparedBatch + req.Resolve(preparedBatch.Copy()) + case reqTypeFinalizeBatch: if c.pendingBatch == nil { req.Error(fmt.Errorf("no pending " + @@ -2096,10 +2357,17 @@ func (c *ChainPlanter) gardener() { break } + customBatch := c.pendingBatch.GenesisPacket != nil && + isCustomAnchorPsbt( + c.pendingBatch.GenesisPacket.Pkt, + ) + broadcastSucceeded := false + // We now wait for the caretaker to either // broadcast the batch or fail to do so. select { case <-caretaker.cfg.BroadcastCompleteChan: + broadcastSucceeded = true req.Resolve(caretaker.cfg.Batch) case err := <-caretaker.cfg.BroadcastErrChan: @@ -2123,7 +2391,9 @@ func (c *ChainPlanter) gardener() { // Now that we have a caretaker launched for // this batch and broadcast its minting // transaction, we can remove the pending batch. - c.pendingBatch = nil + if broadcastSucceeded || !customBatch { + c.pendingBatch = nil + } case reqTypeCancelBatch: batchKey, err := c.canCancelBatch() @@ -2182,6 +2452,19 @@ func (c *ChainPlanter) fundBatch(ctx context.Context, params FundParams, // succeeded, so a failure leaves the batch unchanged. computeFunding := func(batch *MintingBatch) ( *FundedMintAnchorPsbt, error) { + if params.AnchorPsbt != nil { + funded, err := customGenesisPsbt( + c.cfg.ChainParams, batch, params.AnchorPsbt, + params.AssetAnchorOutIdx, + params.ChangeOutputIndex, + params.PreCommitOutputIndex, + ) + if err != nil { + return nil, err + } + + return &funded, nil + } feeRate, err := c.anchorTxFeeRate(ctx, params.FeeRate) if err != nil { @@ -2645,6 +2928,233 @@ func (c *ChainPlanter) sealBatch(ctx context.Context, params SealParams, return batchWithGroupInfo, nil } +// prepareBatch commits the asset tree into a caller-authored anchor PSBT and +// then pauses at the committed state so the packet can be signed externally. +func (c *ChainPlanter) prepareBatch(ctx context.Context, + batch *MintingBatch) (*MintingBatch, error) { + + if !batch.IsFunded() || !isCustomAnchorPsbt(batch.GenesisPacket.Pkt) { + return nil, fmt.Errorf("batch does not use a custom anchor PSBT") + } + if batch.State() != BatchStatePending && + batch.State() != BatchStateFrozen { + + return nil, fmt.Errorf("batch is not ready for preparation: %v", + batch.State()) + } + + sealedBatch, err := c.sealBatch(ctx, SealParams{}, batch) + if err != nil && !errors.Is(err, ErrBatchAlreadySealed) { + return nil, err + } + if sealedBatch != nil { + batch = sealedBatch + } + + if batch.State() == BatchStatePending { + if err := freezeMintingBatch(ctx, c.cfg.Log, batch); err != nil { + return nil, err + } + batch.UpdateState(BatchStateFrozen) + } + + caretaker := c.newCaretakerForBatch(batch, nil) + nextState, err := caretaker.stateStep(BatchStateFrozen) + delete(c.caretakers, asset.ToSerialized(batch.BatchKey.PubKey)) + if err != nil { + return nil, err + } + if nextState != BatchStateCommitted { + return nil, fmt.Errorf("unexpected prepared batch state: %v", + nextState) + } + + batch.UpdateState(BatchStateCommitted) + return batch, nil +} + +// mergeSignedCustomPsbt accepts only signing/finalization fields from an +// externally signed packet. All transaction-defining and descriptive fields +// remain those that were persisted during preparation. +func mergeSignedCustomPsbt(stored, + signed *psbt.Packet) (*psbt.Packet, error) { + + if stored == nil || signed == nil { + return nil, fmt.Errorf("signed custom anchor PSBT is missing") + } + if err := signed.SanityCheck(); err != nil { + return nil, fmt.Errorf("invalid signed custom anchor PSBT: %w", err) + } + normalizePacket := func(pkt *psbt.Packet) (*psbt.Packet, error) { + var buf bytes.Buffer + if err := pkt.Serialize(&buf); err != nil { + return nil, err + } + return psbt.NewFromRawBytes(&buf, false) + } + storedNorm, err := normalizePacket(stored) + if err != nil { + return nil, err + } + signedNorm, err := normalizePacket(signed) + if err != nil { + return nil, err + } + if !reflect.DeepEqual(storedNorm.UnsignedTx, signedNorm.UnsignedTx) || + !reflect.DeepEqual(storedNorm.Outputs, signedNorm.Outputs) || + !reflect.DeepEqual(storedNorm.XPubs, signedNorm.XPubs) || + !reflect.DeepEqual(storedNorm.Unknowns, signedNorm.Unknowns) || + len(stored.Inputs) != len(signed.Inputs) { + + return nil, fmt.Errorf("signed custom anchor PSBT changes prepared " + + "transaction or metadata") + } + + hasSignature := false + for idx := range stored.Inputs { + storedCmp := stored.Inputs[idx] + signedCmp := signed.Inputs[idx] + isFinal := signedCmp.FinalScriptSig != nil || + signedCmp.FinalScriptWitness != nil + if isFinal { + // Standard PSBT finalizers intentionally prune all input + // fields except the UTXO and final scripts. The stored packet + // remains authoritative for those omitted fields. + if signedCmp.WitnessUtxo != nil && + !reflect.DeepEqual( + storedCmp.WitnessUtxo, signedCmp.WitnessUtxo, + ) { + + return nil, fmt.Errorf("signed custom anchor PSBT " + + "changes input UTXO") + } + if signedCmp.NonWitnessUtxo != nil && + !reflect.DeepEqual( + storedCmp.NonWitnessUtxo, + signedCmp.NonWitnessUtxo, + ) { + + return nil, fmt.Errorf("signed custom anchor PSBT " + + "changes input UTXO") + } + if signedCmp.WitnessUtxo == nil && + signedCmp.NonWitnessUtxo == nil { + + return nil, fmt.Errorf("finalized custom anchor PSBT " + + "drops input UTXO") + } + + hasSignature = true + continue + } + + storedCmp.PartialSigs = nil + storedCmp.FinalScriptSig = nil + storedCmp.FinalScriptWitness = nil + storedCmp.TaprootKeySpendSig = nil + storedCmp.TaprootScriptSpendSig = nil + signedCmp.PartialSigs = nil + signedCmp.FinalScriptSig = nil + signedCmp.FinalScriptWitness = nil + signedCmp.TaprootKeySpendSig = nil + signedCmp.TaprootScriptSpendSig = nil + + if !reflect.DeepEqual(storedCmp, signedCmp) { + return nil, fmt.Errorf("signed custom anchor PSBT changes input "+ + "%d metadata", idx) + } + + src := &signed.Inputs[idx] + if len(src.PartialSigs) != 0 || src.FinalScriptSig != nil || + src.FinalScriptWitness != nil || + len(src.TaprootKeySpendSig) != 0 || + len(src.TaprootScriptSpendSig) != 0 { + + hasSignature = true + } + } + + if !hasSignature { + return nil, fmt.Errorf("signed custom anchor PSBT has no signatures") + } + + var buf bytes.Buffer + if err := stored.Serialize(&buf); err != nil { + return nil, err + } + merged, err := psbt.NewFromRawBytes(&buf, false) + if err != nil { + return nil, err + } + for idx := range merged.Inputs { + src := &signed.Inputs[idx] + dst := &merged.Inputs[idx] + dst.PartialSigs = src.PartialSigs + dst.FinalScriptSig = src.FinalScriptSig + dst.FinalScriptWitness = src.FinalScriptWitness + dst.TaprootKeySpendSig = src.TaprootKeySpendSig + dst.TaprootScriptSpendSig = src.TaprootScriptSpendSig + } + + return merged, nil +} + +// validateFinalizedAnchorPsbt executes every input script locally before the +// caretaker persists a broadcast state. This keeps a malformed external +// witness retryable at the prepared state. +func validateFinalizedAnchorPsbt(packet *psbt.Packet) error { + tx, err := psbt.Extract(packet) + if err != nil { + return err + } + + prevOuts := txscript.NewMultiPrevOutFetcher(nil) + for idx := range packet.Inputs { + pIn := &packet.Inputs[idx] + var prevOut *wire.TxOut + switch { + case pIn.WitnessUtxo != nil: + prevOut = pIn.WitnessUtxo + + case pIn.NonWitnessUtxo != nil: + outpoint := tx.TxIn[idx].PreviousOutPoint + if int(outpoint.Index) >= len(pIn.NonWitnessUtxo.TxOut) || + pIn.NonWitnessUtxo.TxHash() != outpoint.Hash { + + return fmt.Errorf("invalid non-witness UTXO for input %d", + idx) + } + prevOut = pIn.NonWitnessUtxo.TxOut[outpoint.Index] + + default: + return fmt.Errorf("missing UTXO for input %d", idx) + } + + prevOuts.AddPrevOut(tx.TxIn[idx].PreviousOutPoint, prevOut) + } + + sigHashes := txscript.NewTxSigHashes(tx, prevOuts) + for idx := range tx.TxIn { + prevOut := prevOuts.FetchPrevOutput( + tx.TxIn[idx].PreviousOutPoint, + ) + vm, err := txscript.NewEngine( + prevOut.PkScript, tx, idx, txscript.StandardVerifyFlags, + nil, sigHashes, prevOut.Value, prevOuts, + ) + if err != nil { + return fmt.Errorf("unable to validate input %d: %w", idx, + err) + } + if err := vm.Execute(); err != nil { + return fmt.Errorf("invalid witness for input %d: %w", idx, + err) + } + } + + return nil +} + // finalizeBatch creates a new caretaker for the batch and starts it. func (c *ChainPlanter) finalizeBatch(params FinalizeParams) (*BatchCaretaker, error) { @@ -2658,6 +3168,43 @@ func (c *ChainPlanter) finalizeBatch(params FinalizeParams) (*BatchCaretaker, // funded. If so, reject any provided parameters, as they would conflict // with those previously used for batch funding. haveParams := params.FeeRate.IsSome() || params.SiblingTapTree.IsSome() + if params.SignedPsbt != nil { + if haveParams { + return nil, fmt.Errorf("signed PSBT cannot be combined with " + + "fee rate or tapscript sibling") + } + if c.pendingBatch.State() != BatchStateCommitted || + !isCustomAnchorPsbt(c.pendingBatch.GenesisPacket.Pkt) { + + return nil, fmt.Errorf("no prepared custom batch awaiting " + + "an external signature") + } + merged, err := mergeSignedCustomPsbt( + c.pendingBatch.GenesisPacket.Pkt, params.SignedPsbt, + ) + if err != nil { + return nil, err + } + if err := validateFinalizedAnchorPsbt(merged); err != nil { + return nil, fmt.Errorf("externally signed PSBT is not fully "+ + "valid: %w", err) + } + c.pendingBatch.GenesisPacket.Pkt = merged + + caretaker := c.newCaretakerForBatch(c.pendingBatch, nil) + if err := caretaker.Start(); err != nil { + return nil, fmt.Errorf("unable to start new caretaker: %w", + err) + } + + return caretaker, nil + } + if c.pendingBatch.IsFunded() && c.pendingBatch.GenesisPacket != nil && + isCustomAnchorPsbt(c.pendingBatch.GenesisPacket.Pkt) { + + return nil, fmt.Errorf("custom anchor batch must be prepared and " + + "finalized with an externally signed PSBT") + } if haveParams && c.pendingBatch.IsFunded() { return nil, fmt.Errorf("cannot provide finalize parameters " + "if batch already funded") @@ -2681,7 +3228,10 @@ func (c *ChainPlanter) finalizeBatch(params FinalizeParams) (*BatchCaretaker, // fails, the batch stays pending so the user can retry. if !c.pendingBatch.IsFunded() { err = c.fundBatch( - ctx, FundParams(params), c.pendingBatch, + ctx, FundParams{ + FeeRate: params.FeeRate, + SiblingTapTree: params.SiblingTapTree, + }, c.pendingBatch, ) if err != nil { return nil, err @@ -2773,6 +3323,18 @@ func (c *ChainPlanter) FundBatch(params FundParams) (*FundBatchResp, error) { return <-req.resp, <-req.err } +// PrepareBatch commits a caller-authored batch into its selected anchor +// output and returns the packet for external signing. +func (c *ChainPlanter) PrepareBatch() (*MintingBatch, error) { + req := newStateReq[*MintingBatch](reqTypePrepareBatch) + + if !fn.SendOrQuit[stateRequest](c.stateReqs, req, c.Quit) { + return nil, fmt.Errorf("chain planter shutting down") + } + + return <-req.resp, <-req.err +} + // SealBatch attempts to seal the current batch, by providing or deriving all // witnesses necessary to create the final genesis TX. func (c *ChainPlanter) SealBatch(params SealParams) (*MintingBatch, error) { @@ -3419,18 +3981,51 @@ func (f *FundedMintAnchorPsbt) Copy() *FundedMintAnchorPsbt { } if f.Pkt != nil { - var unsignedTx *wire.MsgTx - if f.Pkt.UnsignedTx != nil { - unsignedTx = f.Pkt.UnsignedTx.Copy() + if f.Pkt.UnsignedTx == nil { + newMintAnchorPsbt.Pkt = copyMalformedPsbt(f.Pkt) + } else { + var buf bytes.Buffer + if err := f.Pkt.Serialize(&buf); err == nil { + newMintAnchorPsbt.Pkt, _ = psbt.NewFromRawBytes( + &buf, false, + ) + } + if newMintAnchorPsbt.Pkt == nil { + newMintAnchorPsbt.Pkt = copyMalformedPsbt(f.Pkt) + } } + } - newMintAnchorPsbt.Pkt = &psbt.Packet{ - UnsignedTx: unsignedTx, - Inputs: fn.CopySlice(f.Pkt.Inputs), - Outputs: fn.CopySlice(f.Pkt.Outputs), - Unknowns: fn.CopySlice(f.Pkt.Unknowns), + return newMintAnchorPsbt +} + +func copyMalformedPsbt(pkt *psbt.Packet) *psbt.Packet { + copyUnknowns := func(src []*psbt.Unknown) []*psbt.Unknown { + if src == nil { + return nil } + dst := make([]*psbt.Unknown, len(src)) + for idx, unknown := range src { + if unknown == nil { + continue + } + dst[idx] = &psbt.Unknown{ + Key: fn.CopySlice(unknown.Key), + Value: fn.CopySlice(unknown.Value), + } + } + return dst } - return newMintAnchorPsbt + var txCopy *wire.MsgTx + if pkt.UnsignedTx != nil { + txCopy = pkt.UnsignedTx.Copy() + } + return &psbt.Packet{ + UnsignedTx: txCopy, + Inputs: fn.CopySlice(pkt.Inputs), + Outputs: fn.CopySlice(pkt.Outputs), + XPubs: fn.CopySlice(pkt.XPubs), + Unknowns: copyUnknowns(pkt.Unknowns), + } } diff --git a/taprpc/mintrpc/mint.pb.go b/taprpc/mintrpc/mint.pb.go index 3a81891f03..ec5c1fdb1a 100644 --- a/taprpc/mintrpc/mint.pb.go +++ b/taprpc/mintrpc/mint.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 +// protoc-gen-go v1.36.11 // protoc v3.21.12 // source: mintrpc/mint.proto @@ -788,9 +788,20 @@ type FundBatchRequest struct { // // *FundBatchRequest_FullTree // *FundBatchRequest_Branch - BatchSibling isFundBatchRequest_BatchSibling `protobuf_oneof:"batch_sibling"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + BatchSibling isFundBatchRequest_BatchSibling `protobuf_oneof:"batch_sibling"` + // An optional caller-funded anchor PSBT. Input zero remains the asset + // genesis point and the packet must not contain signatures yet. + AnchorPsbt []byte `protobuf:"bytes,5,opt,name=anchor_psbt,json=anchorPsbt,proto3" json:"anchor_psbt,omitempty"` + // The output that will receive the Taproot Asset commitment. + AssetAnchorOutputIndex uint32 `protobuf:"varint,6,opt,name=asset_anchor_output_index,json=assetAnchorOutputIndex,proto3" json:"asset_anchor_output_index,omitempty"` + // The index of an existing change output in anchor_psbt. + ChangeOutputIndex int32 `protobuf:"varint,7,opt,name=change_output_index,json=changeOutputIndex,proto3" json:"change_output_index,omitempty"` + // Set when anchor_psbt has no change output. + NoChangeOutput bool `protobuf:"varint,8,opt,name=no_change_output,json=noChangeOutput,proto3" json:"no_change_output,omitempty"` + // The supply commitment output in anchor_psbt, when applicable. + PreCommitOutputIndex *uint32 `protobuf:"varint,9,opt,name=pre_commit_output_index,json=preCommitOutputIndex,proto3,oneof" json:"pre_commit_output_index,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *FundBatchRequest) Reset() { @@ -862,6 +873,41 @@ func (x *FundBatchRequest) GetBranch() *taprpc.TapBranch { return nil } +func (x *FundBatchRequest) GetAnchorPsbt() []byte { + if x != nil { + return x.AnchorPsbt + } + return nil +} + +func (x *FundBatchRequest) GetAssetAnchorOutputIndex() uint32 { + if x != nil { + return x.AssetAnchorOutputIndex + } + return 0 +} + +func (x *FundBatchRequest) GetChangeOutputIndex() int32 { + if x != nil { + return x.ChangeOutputIndex + } + return 0 +} + +func (x *FundBatchRequest) GetNoChangeOutput() bool { + if x != nil { + return x.NoChangeOutput + } + return false +} + +func (x *FundBatchRequest) GetPreCommitOutputIndex() uint32 { + if x != nil && x.PreCommitOutputIndex != nil { + return *x.PreCommitOutputIndex + } + return 0 +} + type isFundBatchRequest_BatchSibling interface { isFundBatchRequest_BatchSibling() } @@ -926,6 +972,97 @@ func (x *FundBatchResponse) GetBatch() *VerboseBatch { return nil } +type PrepareBatchRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // If true, then the assets currently in the batch won't be returned in the + // response. + ShortResponse bool `protobuf:"varint,1,opt,name=short_response,json=shortResponse,proto3" json:"short_response,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PrepareBatchRequest) Reset() { + *x = PrepareBatchRequest{} + mi := &file_mintrpc_mint_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PrepareBatchRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PrepareBatchRequest) ProtoMessage() {} + +func (x *PrepareBatchRequest) ProtoReflect() protoreflect.Message { + mi := &file_mintrpc_mint_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PrepareBatchRequest.ProtoReflect.Descriptor instead. +func (*PrepareBatchRequest) Descriptor() ([]byte, []int) { + return file_mintrpc_mint_proto_rawDescGZIP(), []int{9} +} + +func (x *PrepareBatchRequest) GetShortResponse() bool { + if x != nil { + return x.ShortResponse + } + return false +} + +type PrepareBatchResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The prepared batch, including the commitment-mutated anchor PSBT. + Batch *MintingBatch `protobuf:"bytes,1,opt,name=batch,proto3" json:"batch,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PrepareBatchResponse) Reset() { + *x = PrepareBatchResponse{} + mi := &file_mintrpc_mint_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PrepareBatchResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PrepareBatchResponse) ProtoMessage() {} + +func (x *PrepareBatchResponse) ProtoReflect() protoreflect.Message { + mi := &file_mintrpc_mint_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PrepareBatchResponse.ProtoReflect.Descriptor instead. +func (*PrepareBatchResponse) Descriptor() ([]byte, []int) { + return file_mintrpc_mint_proto_rawDescGZIP(), []int{10} +} + +func (x *PrepareBatchResponse) GetBatch() *MintingBatch { + if x != nil { + return x.Batch + } + return nil +} + type SealBatchRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // If true, then the assets currently in the batch won't be returned in the @@ -946,7 +1083,7 @@ type SealBatchRequest struct { func (x *SealBatchRequest) Reset() { *x = SealBatchRequest{} - mi := &file_mintrpc_mint_proto_msgTypes[9] + mi := &file_mintrpc_mint_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -958,7 +1095,7 @@ func (x *SealBatchRequest) String() string { func (*SealBatchRequest) ProtoMessage() {} func (x *SealBatchRequest) ProtoReflect() protoreflect.Message { - mi := &file_mintrpc_mint_proto_msgTypes[9] + mi := &file_mintrpc_mint_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -971,7 +1108,7 @@ func (x *SealBatchRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SealBatchRequest.ProtoReflect.Descriptor instead. func (*SealBatchRequest) Descriptor() ([]byte, []int) { - return file_mintrpc_mint_proto_rawDescGZIP(), []int{9} + return file_mintrpc_mint_proto_rawDescGZIP(), []int{11} } func (x *SealBatchRequest) GetShortResponse() bool { @@ -1005,7 +1142,7 @@ type SealBatchResponse struct { func (x *SealBatchResponse) Reset() { *x = SealBatchResponse{} - mi := &file_mintrpc_mint_proto_msgTypes[10] + mi := &file_mintrpc_mint_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1017,7 +1154,7 @@ func (x *SealBatchResponse) String() string { func (*SealBatchResponse) ProtoMessage() {} func (x *SealBatchResponse) ProtoReflect() protoreflect.Message { - mi := &file_mintrpc_mint_proto_msgTypes[10] + mi := &file_mintrpc_mint_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1030,7 +1167,7 @@ func (x *SealBatchResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SealBatchResponse.ProtoReflect.Descriptor instead. func (*SealBatchResponse) Descriptor() ([]byte, []int) { - return file_mintrpc_mint_proto_rawDescGZIP(), []int{10} + return file_mintrpc_mint_proto_rawDescGZIP(), []int{12} } func (x *SealBatchResponse) GetBatch() *MintingBatch { @@ -1056,14 +1193,17 @@ type FinalizeBatchRequest struct { // // *FinalizeBatchRequest_FullTree // *FinalizeBatchRequest_Branch - BatchSibling isFinalizeBatchRequest_BatchSibling `protobuf_oneof:"batch_sibling"` + BatchSibling isFinalizeBatchRequest_BatchSibling `protobuf_oneof:"batch_sibling"` + // The externally signed packet returned by PrepareBatch. All non-signing + // fields must be identical to the prepared packet. + SignedPsbt []byte `protobuf:"bytes,5,opt,name=signed_psbt,json=signedPsbt,proto3" json:"signed_psbt,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *FinalizeBatchRequest) Reset() { *x = FinalizeBatchRequest{} - mi := &file_mintrpc_mint_proto_msgTypes[11] + mi := &file_mintrpc_mint_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1075,7 +1215,7 @@ func (x *FinalizeBatchRequest) String() string { func (*FinalizeBatchRequest) ProtoMessage() {} func (x *FinalizeBatchRequest) ProtoReflect() protoreflect.Message { - mi := &file_mintrpc_mint_proto_msgTypes[11] + mi := &file_mintrpc_mint_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1088,7 +1228,7 @@ func (x *FinalizeBatchRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FinalizeBatchRequest.ProtoReflect.Descriptor instead. func (*FinalizeBatchRequest) Descriptor() ([]byte, []int) { - return file_mintrpc_mint_proto_rawDescGZIP(), []int{11} + return file_mintrpc_mint_proto_rawDescGZIP(), []int{13} } func (x *FinalizeBatchRequest) GetShortResponse() bool { @@ -1130,6 +1270,13 @@ func (x *FinalizeBatchRequest) GetBranch() *taprpc.TapBranch { return nil } +func (x *FinalizeBatchRequest) GetSignedPsbt() []byte { + if x != nil { + return x.SignedPsbt + } + return nil +} + type isFinalizeBatchRequest_BatchSibling interface { isFinalizeBatchRequest_BatchSibling() } @@ -1159,7 +1306,7 @@ type FinalizeBatchResponse struct { func (x *FinalizeBatchResponse) Reset() { *x = FinalizeBatchResponse{} - mi := &file_mintrpc_mint_proto_msgTypes[12] + mi := &file_mintrpc_mint_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1171,7 +1318,7 @@ func (x *FinalizeBatchResponse) String() string { func (*FinalizeBatchResponse) ProtoMessage() {} func (x *FinalizeBatchResponse) ProtoReflect() protoreflect.Message { - mi := &file_mintrpc_mint_proto_msgTypes[12] + mi := &file_mintrpc_mint_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1184,7 +1331,7 @@ func (x *FinalizeBatchResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FinalizeBatchResponse.ProtoReflect.Descriptor instead. func (*FinalizeBatchResponse) Descriptor() ([]byte, []int) { - return file_mintrpc_mint_proto_rawDescGZIP(), []int{12} + return file_mintrpc_mint_proto_rawDescGZIP(), []int{14} } func (x *FinalizeBatchResponse) GetBatch() *MintingBatch { @@ -1202,7 +1349,7 @@ type CancelBatchRequest struct { func (x *CancelBatchRequest) Reset() { *x = CancelBatchRequest{} - mi := &file_mintrpc_mint_proto_msgTypes[13] + mi := &file_mintrpc_mint_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1214,7 +1361,7 @@ func (x *CancelBatchRequest) String() string { func (*CancelBatchRequest) ProtoMessage() {} func (x *CancelBatchRequest) ProtoReflect() protoreflect.Message { - mi := &file_mintrpc_mint_proto_msgTypes[13] + mi := &file_mintrpc_mint_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1227,7 +1374,7 @@ func (x *CancelBatchRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CancelBatchRequest.ProtoReflect.Descriptor instead. func (*CancelBatchRequest) Descriptor() ([]byte, []int) { - return file_mintrpc_mint_proto_rawDescGZIP(), []int{13} + return file_mintrpc_mint_proto_rawDescGZIP(), []int{15} } type CancelBatchResponse struct { @@ -1240,7 +1387,7 @@ type CancelBatchResponse struct { func (x *CancelBatchResponse) Reset() { *x = CancelBatchResponse{} - mi := &file_mintrpc_mint_proto_msgTypes[14] + mi := &file_mintrpc_mint_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1252,7 +1399,7 @@ func (x *CancelBatchResponse) String() string { func (*CancelBatchResponse) ProtoMessage() {} func (x *CancelBatchResponse) ProtoReflect() protoreflect.Message { - mi := &file_mintrpc_mint_proto_msgTypes[14] + mi := &file_mintrpc_mint_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1265,7 +1412,7 @@ func (x *CancelBatchResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CancelBatchResponse.ProtoReflect.Descriptor instead. func (*CancelBatchResponse) Descriptor() ([]byte, []int) { - return file_mintrpc_mint_proto_rawDescGZIP(), []int{14} + return file_mintrpc_mint_proto_rawDescGZIP(), []int{16} } func (x *CancelBatchResponse) GetBatchKey() []byte { @@ -1293,7 +1440,7 @@ type ListBatchRequest struct { func (x *ListBatchRequest) Reset() { *x = ListBatchRequest{} - mi := &file_mintrpc_mint_proto_msgTypes[15] + mi := &file_mintrpc_mint_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1305,7 +1452,7 @@ func (x *ListBatchRequest) String() string { func (*ListBatchRequest) ProtoMessage() {} func (x *ListBatchRequest) ProtoReflect() protoreflect.Message { - mi := &file_mintrpc_mint_proto_msgTypes[15] + mi := &file_mintrpc_mint_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1318,7 +1465,7 @@ func (x *ListBatchRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListBatchRequest.ProtoReflect.Descriptor instead. func (*ListBatchRequest) Descriptor() ([]byte, []int) { - return file_mintrpc_mint_proto_rawDescGZIP(), []int{15} + return file_mintrpc_mint_proto_rawDescGZIP(), []int{17} } func (x *ListBatchRequest) GetFilter() isListBatchRequest_Filter { @@ -1382,7 +1529,7 @@ type ListBatchResponse struct { func (x *ListBatchResponse) Reset() { *x = ListBatchResponse{} - mi := &file_mintrpc_mint_proto_msgTypes[16] + mi := &file_mintrpc_mint_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1394,7 +1541,7 @@ func (x *ListBatchResponse) String() string { func (*ListBatchResponse) ProtoMessage() {} func (x *ListBatchResponse) ProtoReflect() protoreflect.Message { - mi := &file_mintrpc_mint_proto_msgTypes[16] + mi := &file_mintrpc_mint_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1407,7 +1554,7 @@ func (x *ListBatchResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListBatchResponse.ProtoReflect.Descriptor instead. func (*ListBatchResponse) Descriptor() ([]byte, []int) { - return file_mintrpc_mint_proto_rawDescGZIP(), []int{16} + return file_mintrpc_mint_proto_rawDescGZIP(), []int{18} } func (x *ListBatchResponse) GetBatches() []*VerboseBatch { @@ -1429,7 +1576,7 @@ type SubscribeMintEventsRequest struct { func (x *SubscribeMintEventsRequest) Reset() { *x = SubscribeMintEventsRequest{} - mi := &file_mintrpc_mint_proto_msgTypes[17] + mi := &file_mintrpc_mint_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1441,7 +1588,7 @@ func (x *SubscribeMintEventsRequest) String() string { func (*SubscribeMintEventsRequest) ProtoMessage() {} func (x *SubscribeMintEventsRequest) ProtoReflect() protoreflect.Message { - mi := &file_mintrpc_mint_proto_msgTypes[17] + mi := &file_mintrpc_mint_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1454,7 +1601,7 @@ func (x *SubscribeMintEventsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SubscribeMintEventsRequest.ProtoReflect.Descriptor instead. func (*SubscribeMintEventsRequest) Descriptor() ([]byte, []int) { - return file_mintrpc_mint_proto_rawDescGZIP(), []int{17} + return file_mintrpc_mint_proto_rawDescGZIP(), []int{19} } func (x *SubscribeMintEventsRequest) GetShortResponse() bool { @@ -1482,7 +1629,7 @@ type MintEvent struct { func (x *MintEvent) Reset() { *x = MintEvent{} - mi := &file_mintrpc_mint_proto_msgTypes[18] + mi := &file_mintrpc_mint_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1494,7 +1641,7 @@ func (x *MintEvent) String() string { func (*MintEvent) ProtoMessage() {} func (x *MintEvent) ProtoReflect() protoreflect.Message { - mi := &file_mintrpc_mint_proto_msgTypes[18] + mi := &file_mintrpc_mint_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1507,7 +1654,7 @@ func (x *MintEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use MintEvent.ProtoReflect.Descriptor instead. func (*MintEvent) Descriptor() ([]byte, []int) { - return file_mintrpc_mint_proto_rawDescGZIP(), []int{18} + return file_mintrpc_mint_proto_rawDescGZIP(), []int{20} } func (x *MintEvent) GetTimestamp() int64 { @@ -1603,26 +1750,39 @@ const file_mintrpc_mint_proto_rawDesc = "" + "batch_psbt\x18\a \x01(\fR\tbatchPsbt\"|\n" + "\fVerboseBatch\x12+\n" + "\x05batch\x18\x01 \x01(\v2\x15.mintrpc.MintingBatchR\x05batch\x12?\n" + - "\x0funsealed_assets\x18\x02 \x03(\v2\x16.mintrpc.UnsealedAssetR\x0eunsealedAssets\"\xcc\x01\n" + + "\x0funsealed_assets\x18\x02 \x03(\v2\x16.mintrpc.UnsealedAssetR\x0eunsealedAssets\"\xda\x03\n" + "\x10FundBatchRequest\x12%\n" + "\x0eshort_response\x18\x01 \x01(\bR\rshortResponse\x12\x19\n" + "\bfee_rate\x18\x02 \x01(\rR\afeeRate\x128\n" + "\tfull_tree\x18\x03 \x01(\v2\x19.taprpc.TapscriptFullTreeH\x00R\bfullTree\x12+\n" + - "\x06branch\x18\x04 \x01(\v2\x11.taprpc.TapBranchH\x00R\x06branchB\x0f\n" + - "\rbatch_sibling\"@\n" + + "\x06branch\x18\x04 \x01(\v2\x11.taprpc.TapBranchH\x00R\x06branch\x12\x1f\n" + + "\vanchor_psbt\x18\x05 \x01(\fR\n" + + "anchorPsbt\x129\n" + + "\x19asset_anchor_output_index\x18\x06 \x01(\rR\x16assetAnchorOutputIndex\x12.\n" + + "\x13change_output_index\x18\a \x01(\x05R\x11changeOutputIndex\x12(\n" + + "\x10no_change_output\x18\b \x01(\bR\x0enoChangeOutput\x12:\n" + + "\x17pre_commit_output_index\x18\t \x01(\rH\x01R\x14preCommitOutputIndex\x88\x01\x01B\x0f\n" + + "\rbatch_siblingB\x1a\n" + + "\x18_pre_commit_output_index\"@\n" + "\x11FundBatchResponse\x12+\n" + - "\x05batch\x18\x01 \x01(\v2\x15.mintrpc.VerboseBatchR\x05batch\"\xb5\x01\n" + + "\x05batch\x18\x01 \x01(\v2\x15.mintrpc.VerboseBatchR\x05batch\"<\n" + + "\x13PrepareBatchRequest\x12%\n" + + "\x0eshort_response\x18\x01 \x01(\bR\rshortResponse\"C\n" + + "\x14PrepareBatchResponse\x12+\n" + + "\x05batch\x18\x01 \x01(\v2\x15.mintrpc.MintingBatchR\x05batch\"\xb5\x01\n" + "\x10SealBatchRequest\x12%\n" + "\x0eshort_response\x18\x01 \x01(\bR\rshortResponse\x12=\n" + "\x0fgroup_witnesses\x18\x02 \x03(\v2\x14.taprpc.GroupWitnessR\x0egroupWitnesses\x12;\n" + "\x1asigned_group_virtual_psbts\x18\x03 \x03(\tR\x17signedGroupVirtualPsbts\"@\n" + "\x11SealBatchResponse\x12+\n" + - "\x05batch\x18\x01 \x01(\v2\x15.mintrpc.MintingBatchR\x05batch\"\xd0\x01\n" + + "\x05batch\x18\x01 \x01(\v2\x15.mintrpc.MintingBatchR\x05batch\"\xf1\x01\n" + "\x14FinalizeBatchRequest\x12%\n" + "\x0eshort_response\x18\x01 \x01(\bR\rshortResponse\x12\x19\n" + "\bfee_rate\x18\x02 \x01(\rR\afeeRate\x128\n" + "\tfull_tree\x18\x03 \x01(\v2\x19.taprpc.TapscriptFullTreeH\x00R\bfullTree\x12+\n" + - "\x06branch\x18\x04 \x01(\v2\x11.taprpc.TapBranchH\x00R\x06branchB\x0f\n" + + "\x06branch\x18\x04 \x01(\v2\x11.taprpc.TapBranchH\x00R\x06branch\x12\x1f\n" + + "\vsigned_psbt\x18\x05 \x01(\fR\n" + + "signedPsbtB\x0f\n" + "\rbatch_sibling\"D\n" + "\x15FinalizeBatchResponse\x12+\n" + "\x05batch\x18\x01 \x01(\v2\x15.mintrpc.MintingBatchR\x05batch\"\x14\n" + @@ -1654,11 +1814,12 @@ const file_mintrpc_mint_proto_rawDesc = "" + "\x15BATCH_STATE_CONFIRMED\x10\x05\x12\x19\n" + "\x15BATCH_STATE_FINALIZED\x10\x06\x12\"\n" + "\x1eBATCH_STATE_SEEDLING_CANCELLED\x10\a\x12 \n" + - "\x1cBATCH_STATE_SPROUT_CANCELLED\x10\b2\x84\x04\n" + + "\x1cBATCH_STATE_SPROUT_CANCELLED\x10\b2\xd1\x04\n" + "\x04Mint\x12B\n" + "\tMintAsset\x12\x19.mintrpc.MintAssetRequest\x1a\x1a.mintrpc.MintAssetResponse\x12B\n" + "\tFundBatch\x12\x19.mintrpc.FundBatchRequest\x1a\x1a.mintrpc.FundBatchResponse\x12B\n" + - "\tSealBatch\x12\x19.mintrpc.SealBatchRequest\x1a\x1a.mintrpc.SealBatchResponse\x12N\n" + + "\tSealBatch\x12\x19.mintrpc.SealBatchRequest\x1a\x1a.mintrpc.SealBatchResponse\x12K\n" + + "\fPrepareBatch\x12\x1c.mintrpc.PrepareBatchRequest\x1a\x1d.mintrpc.PrepareBatchResponse\x12N\n" + "\rFinalizeBatch\x12\x1d.mintrpc.FinalizeBatchRequest\x1a\x1e.mintrpc.FinalizeBatchResponse\x12H\n" + "\vCancelBatch\x12\x1b.mintrpc.CancelBatchRequest\x1a\x1c.mintrpc.CancelBatchResponse\x12D\n" + "\vListBatches\x12\x19.mintrpc.ListBatchRequest\x1a\x1a.mintrpc.ListBatchResponse\x12P\n" + @@ -1677,7 +1838,7 @@ func file_mintrpc_mint_proto_rawDescGZIP() []byte { } var file_mintrpc_mint_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_mintrpc_mint_proto_msgTypes = make([]protoimpl.MessageInfo, 19) +var file_mintrpc_mint_proto_msgTypes = make([]protoimpl.MessageInfo, 21) var file_mintrpc_mint_proto_goTypes = []any{ (BatchState)(0), // 0: mintrpc.BatchState (*PendingAsset)(nil), // 1: mintrpc.PendingAsset @@ -1689,79 +1850,84 @@ var file_mintrpc_mint_proto_goTypes = []any{ (*VerboseBatch)(nil), // 7: mintrpc.VerboseBatch (*FundBatchRequest)(nil), // 8: mintrpc.FundBatchRequest (*FundBatchResponse)(nil), // 9: mintrpc.FundBatchResponse - (*SealBatchRequest)(nil), // 10: mintrpc.SealBatchRequest - (*SealBatchResponse)(nil), // 11: mintrpc.SealBatchResponse - (*FinalizeBatchRequest)(nil), // 12: mintrpc.FinalizeBatchRequest - (*FinalizeBatchResponse)(nil), // 13: mintrpc.FinalizeBatchResponse - (*CancelBatchRequest)(nil), // 14: mintrpc.CancelBatchRequest - (*CancelBatchResponse)(nil), // 15: mintrpc.CancelBatchResponse - (*ListBatchRequest)(nil), // 16: mintrpc.ListBatchRequest - (*ListBatchResponse)(nil), // 17: mintrpc.ListBatchResponse - (*SubscribeMintEventsRequest)(nil), // 18: mintrpc.SubscribeMintEventsRequest - (*MintEvent)(nil), // 19: mintrpc.MintEvent - (taprpc.AssetVersion)(0), // 20: taprpc.AssetVersion - (taprpc.AssetType)(0), // 21: taprpc.AssetType - (*taprpc.AssetMeta)(nil), // 22: taprpc.AssetMeta - (*taprpc.KeyDescriptor)(nil), // 23: taprpc.KeyDescriptor - (*taprpc.ScriptKey)(nil), // 24: taprpc.ScriptKey - (*taprpc.GroupKeyRequest)(nil), // 25: taprpc.GroupKeyRequest - (*taprpc.GroupVirtualTx)(nil), // 26: taprpc.GroupVirtualTx - (*taprpc.ExternalKey)(nil), // 27: taprpc.ExternalKey - (*taprpc.TapscriptFullTree)(nil), // 28: taprpc.TapscriptFullTree - (*taprpc.TapBranch)(nil), // 29: taprpc.TapBranch - (*taprpc.GroupWitness)(nil), // 30: taprpc.GroupWitness + (*PrepareBatchRequest)(nil), // 10: mintrpc.PrepareBatchRequest + (*PrepareBatchResponse)(nil), // 11: mintrpc.PrepareBatchResponse + (*SealBatchRequest)(nil), // 12: mintrpc.SealBatchRequest + (*SealBatchResponse)(nil), // 13: mintrpc.SealBatchResponse + (*FinalizeBatchRequest)(nil), // 14: mintrpc.FinalizeBatchRequest + (*FinalizeBatchResponse)(nil), // 15: mintrpc.FinalizeBatchResponse + (*CancelBatchRequest)(nil), // 16: mintrpc.CancelBatchRequest + (*CancelBatchResponse)(nil), // 17: mintrpc.CancelBatchResponse + (*ListBatchRequest)(nil), // 18: mintrpc.ListBatchRequest + (*ListBatchResponse)(nil), // 19: mintrpc.ListBatchResponse + (*SubscribeMintEventsRequest)(nil), // 20: mintrpc.SubscribeMintEventsRequest + (*MintEvent)(nil), // 21: mintrpc.MintEvent + (taprpc.AssetVersion)(0), // 22: taprpc.AssetVersion + (taprpc.AssetType)(0), // 23: taprpc.AssetType + (*taprpc.AssetMeta)(nil), // 24: taprpc.AssetMeta + (*taprpc.KeyDescriptor)(nil), // 25: taprpc.KeyDescriptor + (*taprpc.ScriptKey)(nil), // 26: taprpc.ScriptKey + (*taprpc.GroupKeyRequest)(nil), // 27: taprpc.GroupKeyRequest + (*taprpc.GroupVirtualTx)(nil), // 28: taprpc.GroupVirtualTx + (*taprpc.ExternalKey)(nil), // 29: taprpc.ExternalKey + (*taprpc.TapscriptFullTree)(nil), // 30: taprpc.TapscriptFullTree + (*taprpc.TapBranch)(nil), // 31: taprpc.TapBranch + (*taprpc.GroupWitness)(nil), // 32: taprpc.GroupWitness } var file_mintrpc_mint_proto_depIdxs = []int32{ - 20, // 0: mintrpc.PendingAsset.asset_version:type_name -> taprpc.AssetVersion - 21, // 1: mintrpc.PendingAsset.asset_type:type_name -> taprpc.AssetType - 22, // 2: mintrpc.PendingAsset.asset_meta:type_name -> taprpc.AssetMeta - 23, // 3: mintrpc.PendingAsset.group_internal_key:type_name -> taprpc.KeyDescriptor - 24, // 4: mintrpc.PendingAsset.script_key:type_name -> taprpc.ScriptKey + 22, // 0: mintrpc.PendingAsset.asset_version:type_name -> taprpc.AssetVersion + 23, // 1: mintrpc.PendingAsset.asset_type:type_name -> taprpc.AssetType + 24, // 2: mintrpc.PendingAsset.asset_meta:type_name -> taprpc.AssetMeta + 25, // 3: mintrpc.PendingAsset.group_internal_key:type_name -> taprpc.KeyDescriptor + 26, // 4: mintrpc.PendingAsset.script_key:type_name -> taprpc.ScriptKey 1, // 5: mintrpc.UnsealedAsset.asset:type_name -> mintrpc.PendingAsset - 25, // 6: mintrpc.UnsealedAsset.group_key_request:type_name -> taprpc.GroupKeyRequest - 26, // 7: mintrpc.UnsealedAsset.group_virtual_tx:type_name -> taprpc.GroupVirtualTx - 20, // 8: mintrpc.MintAsset.asset_version:type_name -> taprpc.AssetVersion - 21, // 9: mintrpc.MintAsset.asset_type:type_name -> taprpc.AssetType - 22, // 10: mintrpc.MintAsset.asset_meta:type_name -> taprpc.AssetMeta - 23, // 11: mintrpc.MintAsset.group_internal_key:type_name -> taprpc.KeyDescriptor - 24, // 12: mintrpc.MintAsset.script_key:type_name -> taprpc.ScriptKey - 27, // 13: mintrpc.MintAsset.external_group_key:type_name -> taprpc.ExternalKey + 27, // 6: mintrpc.UnsealedAsset.group_key_request:type_name -> taprpc.GroupKeyRequest + 28, // 7: mintrpc.UnsealedAsset.group_virtual_tx:type_name -> taprpc.GroupVirtualTx + 22, // 8: mintrpc.MintAsset.asset_version:type_name -> taprpc.AssetVersion + 23, // 9: mintrpc.MintAsset.asset_type:type_name -> taprpc.AssetType + 24, // 10: mintrpc.MintAsset.asset_meta:type_name -> taprpc.AssetMeta + 25, // 11: mintrpc.MintAsset.group_internal_key:type_name -> taprpc.KeyDescriptor + 26, // 12: mintrpc.MintAsset.script_key:type_name -> taprpc.ScriptKey + 29, // 13: mintrpc.MintAsset.external_group_key:type_name -> taprpc.ExternalKey 3, // 14: mintrpc.MintAssetRequest.asset:type_name -> mintrpc.MintAsset 6, // 15: mintrpc.MintAssetResponse.pending_batch:type_name -> mintrpc.MintingBatch 0, // 16: mintrpc.MintingBatch.state:type_name -> mintrpc.BatchState 1, // 17: mintrpc.MintingBatch.assets:type_name -> mintrpc.PendingAsset 6, // 18: mintrpc.VerboseBatch.batch:type_name -> mintrpc.MintingBatch 2, // 19: mintrpc.VerboseBatch.unsealed_assets:type_name -> mintrpc.UnsealedAsset - 28, // 20: mintrpc.FundBatchRequest.full_tree:type_name -> taprpc.TapscriptFullTree - 29, // 21: mintrpc.FundBatchRequest.branch:type_name -> taprpc.TapBranch + 30, // 20: mintrpc.FundBatchRequest.full_tree:type_name -> taprpc.TapscriptFullTree + 31, // 21: mintrpc.FundBatchRequest.branch:type_name -> taprpc.TapBranch 7, // 22: mintrpc.FundBatchResponse.batch:type_name -> mintrpc.VerboseBatch - 30, // 23: mintrpc.SealBatchRequest.group_witnesses:type_name -> taprpc.GroupWitness - 6, // 24: mintrpc.SealBatchResponse.batch:type_name -> mintrpc.MintingBatch - 28, // 25: mintrpc.FinalizeBatchRequest.full_tree:type_name -> taprpc.TapscriptFullTree - 29, // 26: mintrpc.FinalizeBatchRequest.branch:type_name -> taprpc.TapBranch - 6, // 27: mintrpc.FinalizeBatchResponse.batch:type_name -> mintrpc.MintingBatch - 7, // 28: mintrpc.ListBatchResponse.batches:type_name -> mintrpc.VerboseBatch - 0, // 29: mintrpc.MintEvent.batch_state:type_name -> mintrpc.BatchState - 6, // 30: mintrpc.MintEvent.batch:type_name -> mintrpc.MintingBatch - 4, // 31: mintrpc.Mint.MintAsset:input_type -> mintrpc.MintAssetRequest - 8, // 32: mintrpc.Mint.FundBatch:input_type -> mintrpc.FundBatchRequest - 10, // 33: mintrpc.Mint.SealBatch:input_type -> mintrpc.SealBatchRequest - 12, // 34: mintrpc.Mint.FinalizeBatch:input_type -> mintrpc.FinalizeBatchRequest - 14, // 35: mintrpc.Mint.CancelBatch:input_type -> mintrpc.CancelBatchRequest - 16, // 36: mintrpc.Mint.ListBatches:input_type -> mintrpc.ListBatchRequest - 18, // 37: mintrpc.Mint.SubscribeMintEvents:input_type -> mintrpc.SubscribeMintEventsRequest - 5, // 38: mintrpc.Mint.MintAsset:output_type -> mintrpc.MintAssetResponse - 9, // 39: mintrpc.Mint.FundBatch:output_type -> mintrpc.FundBatchResponse - 11, // 40: mintrpc.Mint.SealBatch:output_type -> mintrpc.SealBatchResponse - 13, // 41: mintrpc.Mint.FinalizeBatch:output_type -> mintrpc.FinalizeBatchResponse - 15, // 42: mintrpc.Mint.CancelBatch:output_type -> mintrpc.CancelBatchResponse - 17, // 43: mintrpc.Mint.ListBatches:output_type -> mintrpc.ListBatchResponse - 19, // 44: mintrpc.Mint.SubscribeMintEvents:output_type -> mintrpc.MintEvent - 38, // [38:45] is the sub-list for method output_type - 31, // [31:38] is the sub-list for method input_type - 31, // [31:31] is the sub-list for extension type_name - 31, // [31:31] is the sub-list for extension extendee - 0, // [0:31] is the sub-list for field type_name + 6, // 23: mintrpc.PrepareBatchResponse.batch:type_name -> mintrpc.MintingBatch + 32, // 24: mintrpc.SealBatchRequest.group_witnesses:type_name -> taprpc.GroupWitness + 6, // 25: mintrpc.SealBatchResponse.batch:type_name -> mintrpc.MintingBatch + 30, // 26: mintrpc.FinalizeBatchRequest.full_tree:type_name -> taprpc.TapscriptFullTree + 31, // 27: mintrpc.FinalizeBatchRequest.branch:type_name -> taprpc.TapBranch + 6, // 28: mintrpc.FinalizeBatchResponse.batch:type_name -> mintrpc.MintingBatch + 7, // 29: mintrpc.ListBatchResponse.batches:type_name -> mintrpc.VerboseBatch + 0, // 30: mintrpc.MintEvent.batch_state:type_name -> mintrpc.BatchState + 6, // 31: mintrpc.MintEvent.batch:type_name -> mintrpc.MintingBatch + 4, // 32: mintrpc.Mint.MintAsset:input_type -> mintrpc.MintAssetRequest + 8, // 33: mintrpc.Mint.FundBatch:input_type -> mintrpc.FundBatchRequest + 12, // 34: mintrpc.Mint.SealBatch:input_type -> mintrpc.SealBatchRequest + 10, // 35: mintrpc.Mint.PrepareBatch:input_type -> mintrpc.PrepareBatchRequest + 14, // 36: mintrpc.Mint.FinalizeBatch:input_type -> mintrpc.FinalizeBatchRequest + 16, // 37: mintrpc.Mint.CancelBatch:input_type -> mintrpc.CancelBatchRequest + 18, // 38: mintrpc.Mint.ListBatches:input_type -> mintrpc.ListBatchRequest + 20, // 39: mintrpc.Mint.SubscribeMintEvents:input_type -> mintrpc.SubscribeMintEventsRequest + 5, // 40: mintrpc.Mint.MintAsset:output_type -> mintrpc.MintAssetResponse + 9, // 41: mintrpc.Mint.FundBatch:output_type -> mintrpc.FundBatchResponse + 13, // 42: mintrpc.Mint.SealBatch:output_type -> mintrpc.SealBatchResponse + 11, // 43: mintrpc.Mint.PrepareBatch:output_type -> mintrpc.PrepareBatchResponse + 15, // 44: mintrpc.Mint.FinalizeBatch:output_type -> mintrpc.FinalizeBatchResponse + 17, // 45: mintrpc.Mint.CancelBatch:output_type -> mintrpc.CancelBatchResponse + 19, // 46: mintrpc.Mint.ListBatches:output_type -> mintrpc.ListBatchResponse + 21, // 47: mintrpc.Mint.SubscribeMintEvents:output_type -> mintrpc.MintEvent + 40, // [40:48] is the sub-list for method output_type + 32, // [32:40] is the sub-list for method input_type + 32, // [32:32] is the sub-list for extension type_name + 32, // [32:32] is the sub-list for extension extendee + 0, // [0:32] is the sub-list for field type_name } func init() { file_mintrpc_mint_proto_init() } @@ -1773,11 +1939,11 @@ func file_mintrpc_mint_proto_init() { (*FundBatchRequest_FullTree)(nil), (*FundBatchRequest_Branch)(nil), } - file_mintrpc_mint_proto_msgTypes[11].OneofWrappers = []any{ + file_mintrpc_mint_proto_msgTypes[13].OneofWrappers = []any{ (*FinalizeBatchRequest_FullTree)(nil), (*FinalizeBatchRequest_Branch)(nil), } - file_mintrpc_mint_proto_msgTypes[15].OneofWrappers = []any{ + file_mintrpc_mint_proto_msgTypes[17].OneofWrappers = []any{ (*ListBatchRequest_BatchKey)(nil), (*ListBatchRequest_BatchKeyStr)(nil), } @@ -1787,7 +1953,7 @@ func file_mintrpc_mint_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_mintrpc_mint_proto_rawDesc), len(file_mintrpc_mint_proto_rawDesc)), NumEnums: 1, - NumMessages: 19, + NumMessages: 21, NumExtensions: 0, NumServices: 1, }, diff --git a/taprpc/mintrpc/mint.pb.gw.go b/taprpc/mintrpc/mint.pb.gw.go index 9cca70af22..eb2f051cfb 100644 --- a/taprpc/mintrpc/mint.pb.gw.go +++ b/taprpc/mintrpc/mint.pb.gw.go @@ -109,6 +109,32 @@ func local_request_Mint_SealBatch_0(ctx context.Context, marshaler runtime.Marsh } +func request_Mint_PrepareBatch_0(ctx context.Context, marshaler runtime.Marshaler, client MintClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq PrepareBatchRequest + var metadata runtime.ServerMetadata + + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && err != io.EOF { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := client.PrepareBatch(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err + +} + +func local_request_Mint_PrepareBatch_0(ctx context.Context, marshaler runtime.Marshaler, server MintServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq PrepareBatchRequest + var metadata runtime.ServerMetadata + + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && err != io.EOF { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := server.PrepareBatch(ctx, &protoReq) + return msg, metadata, err + +} + func request_Mint_FinalizeBatch_0(ctx context.Context, marshaler runtime.Marshaler, client MintClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { var protoReq FinalizeBatchRequest var metadata runtime.ServerMetadata @@ -343,6 +369,31 @@ func RegisterMintHandlerServer(ctx context.Context, mux *runtime.ServeMux, serve }) + mux.Handle("POST", pattern_Mint_PrepareBatch_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/mintrpc.Mint/PrepareBatch", runtime.WithHTTPPathPattern("/v1/taproot-assets/assets/mint/prepare")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_Mint_PrepareBatch_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_Mint_PrepareBatch_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + mux.Handle("POST", pattern_Mint_FinalizeBatch_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() @@ -532,6 +583,28 @@ func RegisterMintHandlerClient(ctx context.Context, mux *runtime.ServeMux, clien }) + mux.Handle("POST", pattern_Mint_PrepareBatch_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/mintrpc.Mint/PrepareBatch", runtime.WithHTTPPathPattern("/v1/taproot-assets/assets/mint/prepare")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_Mint_PrepareBatch_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_Mint_PrepareBatch_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + mux.Handle("POST", pattern_Mint_FinalizeBatch_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() @@ -630,6 +703,8 @@ var ( pattern_Mint_SealBatch_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4}, []string{"v1", "taproot-assets", "assets", "mint", "seal"}, "")) + pattern_Mint_PrepareBatch_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4}, []string{"v1", "taproot-assets", "assets", "mint", "prepare"}, "")) + pattern_Mint_FinalizeBatch_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4}, []string{"v1", "taproot-assets", "assets", "mint", "finalize"}, "")) pattern_Mint_CancelBatch_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4}, []string{"v1", "taproot-assets", "assets", "mint", "cancel"}, "")) @@ -646,6 +721,8 @@ var ( forward_Mint_SealBatch_0 = runtime.ForwardResponseMessage + forward_Mint_PrepareBatch_0 = runtime.ForwardResponseMessage + forward_Mint_FinalizeBatch_0 = runtime.ForwardResponseMessage forward_Mint_CancelBatch_0 = runtime.ForwardResponseMessage diff --git a/taprpc/mintrpc/mint.pb.json.go b/taprpc/mintrpc/mint.pb.json.go index aa8e2b8245..7eaac8efaa 100644 --- a/taprpc/mintrpc/mint.pb.json.go +++ b/taprpc/mintrpc/mint.pb.json.go @@ -96,6 +96,31 @@ func RegisterMintJSONCallbacks(registry map[string]func(ctx context.Context, callback(string(respBytes), nil) } + registry["mintrpc.Mint.PrepareBatch"] = func(ctx context.Context, + conn *grpc.ClientConn, reqJSON string, callback func(string, error)) { + + req := &PrepareBatchRequest{} + err := marshaler.Unmarshal([]byte(reqJSON), req) + if err != nil { + callback("", err) + return + } + + client := NewMintClient(conn) + resp, err := client.PrepareBatch(ctx, req) + if err != nil { + callback("", err) + return + } + + respBytes, err := marshaler.Marshal(resp) + if err != nil { + callback("", err) + return + } + callback(string(respBytes), nil) + } + registry["mintrpc.Mint.FinalizeBatch"] = func(ctx context.Context, conn *grpc.ClientConn, reqJSON string, callback func(string, error)) { diff --git a/taprpc/mintrpc/mint.proto b/taprpc/mintrpc/mint.proto index 3268e93ef1..f7244f24c0 100644 --- a/taprpc/mintrpc/mint.proto +++ b/taprpc/mintrpc/mint.proto @@ -34,6 +34,13 @@ service Mint { */ rpc SealBatch (SealBatchRequest) returns (SealBatchResponse); + /* tapcli `assets mint prepare` + PrepareBatch commits the assets into a caller-authored anchor PSBT and + returns the packet for external signing. The batch remains paused until + FinalizeBatch is called with the signed packet. + */ + rpc PrepareBatch (PrepareBatchRequest) returns (PrepareBatchResponse); + /* tapcli: `assets mint finalize` FinalizeBatch will attempt to finalize the current pending batch. */ @@ -333,6 +340,22 @@ message FundBatchRequest { // A TapBranch that represents a Tapscript tree managed externally. taprpc.TapBranch branch = 4; } + + // An optional caller-funded anchor PSBT. Input zero remains the asset + // genesis point and the packet must not contain signatures yet. + bytes anchor_psbt = 5; + + // The output that will receive the Taproot Asset commitment. + uint32 asset_anchor_output_index = 6; + + // The index of an existing change output in anchor_psbt. + int32 change_output_index = 7; + + // Set when anchor_psbt has no change output. + bool no_change_output = 8; + + // The supply commitment output in anchor_psbt, when applicable. + optional uint32 pre_commit_output_index = 9; } message FundBatchResponse { @@ -340,6 +363,19 @@ message FundBatchResponse { VerboseBatch batch = 1; } +message PrepareBatchRequest { + /* + If true, then the assets currently in the batch won't be returned in the + response. + */ + bool short_response = 1; +} + +message PrepareBatchResponse { + // The prepared batch, including the commitment-mutated anchor PSBT. + MintingBatch batch = 1; +} + message SealBatchRequest { /* If true, then the assets currently in the batch won't be returned in the @@ -390,6 +426,10 @@ message FinalizeBatchRequest { // A TapBranch that represents a Tapscript tree managed externally. taprpc.TapBranch branch = 4; } + + // The externally signed packet returned by PrepareBatch. All non-signing + // fields must be identical to the prepared packet. + bytes signed_psbt = 5; } message FinalizeBatchResponse { diff --git a/taprpc/mintrpc/mint.swagger.json b/taprpc/mintrpc/mint.swagger.json index 6d76db5ed0..0308133fea 100644 --- a/taprpc/mintrpc/mint.swagger.json +++ b/taprpc/mintrpc/mint.swagger.json @@ -195,6 +195,39 @@ ] } }, + "/v1/taproot-assets/assets/mint/prepare": { + "post": { + "summary": "tapcli `assets mint prepare`\nPrepareBatch commits the assets into a caller-authored anchor PSBT and\nreturns the packet for external signing. The batch remains paused until\nFinalizeBatch is called with the signed packet.", + "operationId": "Mint_PrepareBatch", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/mintrpcPrepareBatchResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/mintrpcPrepareBatchRequest" + } + } + ], + "tags": [ + "Mint" + ] + } + }, "/v1/taproot-assets/assets/mint/seal": { "post": { "summary": "tapcli `assets mint seal`\nSealBatch will attempt to seal the current pending batch by creating and\nvalidating asset group witness for all assets in the batch. If a witness\nis not provided, a signature will be derived to serve as the witness. This\nRPC is only needed if any assets in the batch have a custom asset group key\nthat require an external signer. Otherwise, FinalizeBatch can be called\ndirectly.", @@ -319,6 +352,11 @@ "branch": { "$ref": "#/definitions/taprpcTapBranch", "description": "A TapBranch that represents a Tapscript tree managed externally." + }, + "signed_psbt": { + "type": "string", + "format": "byte", + "description": "The externally signed packet returned by PrepareBatch. All non-signing\nfields must be identical to the prepared packet." } } }, @@ -350,6 +388,30 @@ "branch": { "$ref": "#/definitions/taprpcTapBranch", "description": "A TapBranch that represents a Tapscript tree managed externally." + }, + "anchor_psbt": { + "type": "string", + "format": "byte", + "description": "An optional caller-funded anchor PSBT. Input zero remains the asset\ngenesis point and the packet must not contain signatures yet." + }, + "asset_anchor_output_index": { + "type": "integer", + "format": "int64", + "description": "The output that will receive the Taproot Asset commitment." + }, + "change_output_index": { + "type": "integer", + "format": "int32", + "description": "The index of an existing change output in anchor_psbt." + }, + "no_change_output": { + "type": "boolean", + "description": "Set when anchor_psbt has no change output." + }, + "pre_commit_output_index": { + "type": "integer", + "format": "int64", + "description": "The supply commitment output in anchor_psbt, when applicable." } } }, @@ -580,6 +642,24 @@ } } }, + "mintrpcPrepareBatchRequest": { + "type": "object", + "properties": { + "short_response": { + "type": "boolean", + "description": "If true, then the assets currently in the batch won't be returned in the\nresponse." + } + } + }, + "mintrpcPrepareBatchResponse": { + "type": "object", + "properties": { + "batch": { + "$ref": "#/definitions/mintrpcMintingBatch", + "description": "The prepared batch, including the commitment-mutated anchor PSBT." + } + } + }, "mintrpcSealBatchRequest": { "type": "object", "properties": { diff --git a/taprpc/mintrpc/mint.yaml b/taprpc/mintrpc/mint.yaml index 0ec2db4a97..09848e8f5f 100644 --- a/taprpc/mintrpc/mint.yaml +++ b/taprpc/mintrpc/mint.yaml @@ -15,6 +15,10 @@ http: post: "/v1/taproot-assets/assets/mint/seal" body: "*" + - selector: mintrpc.Mint.PrepareBatch + post: "/v1/taproot-assets/assets/mint/prepare" + body: "*" + - selector: mintrpc.Mint.FinalizeBatch post: "/v1/taproot-assets/assets/mint/finalize" body: "*" diff --git a/taprpc/mintrpc/mint_grpc.pb.go b/taprpc/mintrpc/mint_grpc.pb.go index b3ee711ab1..a02e4858f9 100644 --- a/taprpc/mintrpc/mint_grpc.pb.go +++ b/taprpc/mintrpc/mint_grpc.pb.go @@ -39,6 +39,11 @@ type MintClient interface { // that require an external signer. Otherwise, FinalizeBatch can be called // directly. SealBatch(ctx context.Context, in *SealBatchRequest, opts ...grpc.CallOption) (*SealBatchResponse, error) + // tapcli `assets mint prepare` + // PrepareBatch commits the assets into a caller-authored anchor PSBT and + // returns the packet for external signing. The batch remains paused until + // FinalizeBatch is called with the signed packet. + PrepareBatch(ctx context.Context, in *PrepareBatchRequest, opts ...grpc.CallOption) (*PrepareBatchResponse, error) // tapcli: `assets mint finalize` // FinalizeBatch will attempt to finalize the current pending batch. FinalizeBatch(ctx context.Context, in *FinalizeBatchRequest, opts ...grpc.CallOption) (*FinalizeBatchResponse, error) @@ -90,6 +95,15 @@ func (c *mintClient) SealBatch(ctx context.Context, in *SealBatchRequest, opts . return out, nil } +func (c *mintClient) PrepareBatch(ctx context.Context, in *PrepareBatchRequest, opts ...grpc.CallOption) (*PrepareBatchResponse, error) { + out := new(PrepareBatchResponse) + err := c.cc.Invoke(ctx, "/mintrpc.Mint/PrepareBatch", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *mintClient) FinalizeBatch(ctx context.Context, in *FinalizeBatchRequest, opts ...grpc.CallOption) (*FinalizeBatchResponse, error) { out := new(FinalizeBatchResponse) err := c.cc.Invoke(ctx, "/mintrpc.Mint/FinalizeBatch", in, out, opts...) @@ -174,6 +188,11 @@ type MintServer interface { // that require an external signer. Otherwise, FinalizeBatch can be called // directly. SealBatch(context.Context, *SealBatchRequest) (*SealBatchResponse, error) + // tapcli `assets mint prepare` + // PrepareBatch commits the assets into a caller-authored anchor PSBT and + // returns the packet for external signing. The batch remains paused until + // FinalizeBatch is called with the signed packet. + PrepareBatch(context.Context, *PrepareBatchRequest) (*PrepareBatchResponse, error) // tapcli: `assets mint finalize` // FinalizeBatch will attempt to finalize the current pending batch. FinalizeBatch(context.Context, *FinalizeBatchRequest) (*FinalizeBatchResponse, error) @@ -204,6 +223,9 @@ func (UnimplementedMintServer) FundBatch(context.Context, *FundBatchRequest) (*F func (UnimplementedMintServer) SealBatch(context.Context, *SealBatchRequest) (*SealBatchResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method SealBatch not implemented") } +func (UnimplementedMintServer) PrepareBatch(context.Context, *PrepareBatchRequest) (*PrepareBatchResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method PrepareBatch not implemented") +} func (UnimplementedMintServer) FinalizeBatch(context.Context, *FinalizeBatchRequest) (*FinalizeBatchResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method FinalizeBatch not implemented") } @@ -283,6 +305,24 @@ func _Mint_SealBatch_Handler(srv interface{}, ctx context.Context, dec func(inte return interceptor(ctx, in, info, handler) } +func _Mint_PrepareBatch_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PrepareBatchRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(MintServer).PrepareBatch(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/mintrpc.Mint/PrepareBatch", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(MintServer).PrepareBatch(ctx, req.(*PrepareBatchRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _Mint_FinalizeBatch_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(FinalizeBatchRequest) if err := dec(in); err != nil { @@ -377,6 +417,10 @@ var Mint_ServiceDesc = grpc.ServiceDesc{ MethodName: "SealBatch", Handler: _Mint_SealBatch_Handler, }, + { + MethodName: "PrepareBatch", + Handler: _Mint_PrepareBatch_Handler, + }, { MethodName: "FinalizeBatch", Handler: _Mint_FinalizeBatch_Handler, diff --git a/taprpc/perms.go b/taprpc/perms.go index b9e30bc4c9..3fc3ea2163 100644 --- a/taprpc/perms.go +++ b/taprpc/perms.go @@ -183,6 +183,10 @@ var ( Entity: "mint", Action: "write", }}, + "/mintrpc.Mint/PrepareBatch": {{ + Entity: "mint", + Action: "write", + }}, "/mintrpc.Mint/FinalizeBatch": {{ Entity: "mint", Action: "write", From 840ac26b8887b71516179467c919c44bfbb32c46 Mon Sep 17 00:00:00 2001 From: Sadiq Jaffer Date: Thu, 13 Aug 2026 16:52:02 +0100 Subject: [PATCH 02/24] tapgarden: harden custom genesis publication retries --- tapgarden/caretaker.go | 20 +++- tapgarden/custom_anchor_test.go | 79 ++++++++++++++ tapgarden/issue721_stateful_test.go | 164 +++++++++++++++++++++++++++- tapgarden/planter.go | 71 ++++++++++-- tapnode/tapnodemock/chain_bridge.go | 29 ++++- 5 files changed, 346 insertions(+), 17 deletions(-) diff --git a/tapgarden/caretaker.go b/tapgarden/caretaker.go index 7adde34bee..0b6014a68f 100644 --- a/tapgarden/caretaker.go +++ b/tapgarden/caretaker.go @@ -142,7 +142,7 @@ type BatchCaretaker struct { // // TODO(roasbeef): rename to Cultivator? func NewBatchCaretaker(cfg *BatchCaretakerConfig) *BatchCaretaker { - return &BatchCaretaker{ + caretaker := &BatchCaretaker{ batchKey: asset.ToSerialized(cfg.Batch.BatchKey.PubKey), cfg: cfg, confEvent: make(chan *chainntnfs.TxConfirmation, 1), @@ -151,6 +151,12 @@ func NewBatchCaretaker(cfg *BatchCaretakerConfig) *BatchCaretaker { Quit: make(chan struct{}), }, } + if cfg.Batch.GenesisPacket != nil { + caretaker.anchorOutputIndex = cfg.Batch.GenesisPacket. + AssetAnchorOutIdx + } + + return caretaker } // Start attempts to start a new batch caretaker. @@ -734,6 +740,12 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) return 0, fmt.Errorf("genesis TX failed final checks: "+ "%w", err) } + if err := validateExclusionProofOutputs( + signedPkt, b.anchorOutputIndex, + ); err != nil { + + return 0, err + } b.cfg.Batch.GenesisPacket.Pkt = signedPkt @@ -861,8 +873,7 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) ctx, signedTx, IssuanceTxLabel, ) if err != nil { - return 0, fmt.Errorf("unable to publish "+ - "transaction: %w", err) + return 0, fmt.Errorf("unable to publish transaction: %w", err) } // Now we'll wait for a confirmation as we reach our terminal @@ -876,7 +887,8 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) txHash := signedTx.TxHash() confCtx, confCancel := b.WithCtxQuitNoTimeout() confNtfn, errChan, err := b.cfg.ChainBridge.RegisterConfirmationsNtfn( - confCtx, &txHash, signedTx.TxOut[0].PkScript, 1, + confCtx, &txHash, + signedTx.TxOut[b.anchorOutputIndex].PkScript, 1, heightHint, true, nil, ) if err != nil { diff --git a/tapgarden/custom_anchor_test.go b/tapgarden/custom_anchor_test.go index e46567e975..36e4f74237 100644 --- a/tapgarden/custom_anchor_test.go +++ b/tapgarden/custom_anchor_test.go @@ -71,6 +71,85 @@ func TestCustomGenesisPsbtValidation(t *testing.T) { address.TestNet3Tap, nil, dust, 0, -1, noneUint32(), ) require.ErrorContains(t, err, "anchor output is dust") + + // Every non-anchor P2TR output needs enough metadata to construct an + // exclusion proof after the mint confirms. + for _, testCase := range []struct { + name string + output psbt.POutput + errContains string + }{ + { + name: "missing internal key", + errContains: "output 1 is a P2TR output but is missing", + }, + { + name: "invalid internal key", + output: psbt.POutput{ + TaprootInternalKey: bytes.Repeat([]byte{0xff}, 32), + }, + errContains: "internal key is invalid", + }, + { + name: "metadata mismatch", + output: psbt.POutput{ + TaprootInternalKey: fn.CopySlice( + testCustomAnchorPacket(t).Outputs[0]. + TaprootInternalKey, + ), + }, + errContains: "metadata does not match", + }, + { + name: "malformed tap tree", + output: psbt.POutput{ + TaprootInternalKey: fn.CopySlice( + testCustomAnchorPacket(t).Outputs[0]. + TaprootInternalKey, + ), + TaprootTapTree: []byte{0x00}, + }, + errContains: "invalid PSBT tap tree", + }, + } { + t.Run(testCase.name, func(t *testing.T) { + invalid := testCustomAnchorPacket(t) + invalid.UnsignedTx.AddTxOut(&wire.TxOut{ + Value: 1_000, + PkScript: fn.CopySlice( + invalid.UnsignedTx.TxOut[0].PkScript, + ), + }) + invalid.Outputs = append(invalid.Outputs, testCase.output) + _, err := customGenesisPsbt( + address.TestNet3Tap, nil, invalid, 0, -1, + noneUint32(), + ) + require.ErrorContains(t, err, testCase.errContains) + }) + } + + validMetadata := testCustomAnchorPacket(t) + validInternalKey, err := schnorr.ParsePubKey( + validMetadata.Outputs[0].TaprootInternalKey, + ) + require.NoError(t, err) + validOutputKey := txscript.ComputeTaprootKeyNoScript(validInternalKey) + validOutputScript, err := txscript.PayToTaprootScript(validOutputKey) + require.NoError(t, err) + validMetadata.UnsignedTx.AddTxOut(&wire.TxOut{ + Value: 1_000, + PkScript: validOutputScript, + }) + validMetadata.Outputs = append(validMetadata.Outputs, psbt.POutput{ + TaprootInternalKey: fn.CopySlice( + validMetadata.Outputs[0].TaprootInternalKey, + ), + }) + _, err = customGenesisPsbt( + address.TestNet3Tap, nil, validMetadata, 0, -1, noneUint32(), + ) + require.NoError(t, err) } func TestMergeSignedCustomPsbt(t *testing.T) { diff --git a/tapgarden/issue721_stateful_test.go b/tapgarden/issue721_stateful_test.go index ead2565ee8..16c8ea427f 100644 --- a/tapgarden/issue721_stateful_test.go +++ b/tapgarden/issue721_stateful_test.go @@ -211,8 +211,11 @@ func TestIssue721PrepareSignResume(t *testing.T) { require.NoError(t, err) require.True(t, (*importedKey).IsEqual(expectedOutputKey)) published := h.assertTxPublished() - _, err = fn.RecvOrTimeout(h.chain.ConfReqSignal, defaultTimeout) + confReq, err := fn.RecvOrTimeout(h.chain.ConfReqSignal, defaultTimeout) require.NoError(t, err) + require.Equal( + t, published.TxOut[1].PkScript, h.chain.ConfPkScripts[*confReq], + ) minted := h.assertFinalizeBatch(&wg, respChan, "") require.Equal(t, original.UnsignedTx.TxIn[0].PreviousOutPoint, published.TxIn[0].PreviousOutPoint) @@ -224,6 +227,165 @@ func TestIssue721PrepareSignResume(t *testing.T) { require.Equal(t, tapgarden.BatchStateBroadcast, minted.State()) } +// TestIssue721PublishRetry verifies a backend publication error leaves a +// caller-authored batch durably available for retry without re-preparation. +func TestIssue721PublishRetry(t *testing.T) { + t.Run("same process", func(t *testing.T) { + testIssue721PublishRetry(t, false) + }) + t.Run("after restart", func(t *testing.T) { + testIssue721PublishRetry(t, true) + }) +} + +func testIssue721PublishRetry(t *testing.T, restartBeforeRetry bool) { + store := newMintingStore(t) + h := newMintingTestHarness(t, store) + h.refreshChainPlanter() + t.Cleanup(func() { + if h.planter != nil { + _ = h.planter.Stop() + } + }) + + h.queueSeedlingsInBatch(false, issue721Seedling()) + pkt, _, witnessScript := issue721Anchor(t) + issue721Fund(t, h, pkt) + prepared, err := h.planter.PrepareBatch() + require.NoError(t, err) + + signed := clonePacket(t, prepared.GenesisPacket.Pkt) + signed.Inputs[0].FinalScriptWitness = issue721FinalWitness( + t, witnessScript, + ) + + h.chain.FailPublishOnce() + var wg sync.WaitGroup + respChan := make(chan *FinalizeBatchResp, 1) + h.finalizeBatch(&wg, respChan, &tapgarden.FinalizeParams{ + SignedPsbt: signed, + }) + _, err = fn.RecvOrTimeout( + h.wallet.ImportPubKeySignal, defaultTimeout, + ) + require.NoError(t, err) + h.assertFinalizeBatch(&wg, respChan, "failed to publish transaction") + firstAttempt, err := fn.RecvOrTimeout( + h.chain.PublishAttempts, defaultTimeout, + ) + require.NoError(t, err) + + pending, err := h.planter.PendingBatch() + require.NoError(t, err) + require.Equal(t, tapgarden.BatchStateBroadcast, pending.State()) + persisted := h.fetchSingleBatch(pending.BatchKey.PubKey) + require.Equal(t, tapgarden.BatchStateBroadcast, persisted.State()) + _, err = h.planter.CancelBatch() + require.ErrorContains(t, err, "not cancellable") + + if restartBeforeRetry { + // A broadcast batch resumes automatically after restart without + // returning to an externally cancellable pre-broadcast state. + h.refreshChainPlanter() + published := h.assertTxPublished() + require.Equal(t, (*firstAttempt).TxHash(), published.TxHash()) + _, err = fn.RecvOrTimeout(h.chain.ConfReqSignal, defaultTimeout) + require.NoError(t, err) + h.assertNumCaretakersActive(1) + persisted = h.fetchSingleBatch(pending.BatchKey.PubKey) + require.Equal(t, tapgarden.BatchStateBroadcast, persisted.State()) + + return + } + + retry := clonePacket(t, pending.GenesisPacket.Pkt) + h.finalizeBatch(&wg, respChan, &tapgarden.FinalizeParams{ + SignedPsbt: retry, + }) + published := h.assertTxPublished() + require.Equal(t, (*firstAttempt).TxHash(), published.TxHash()) + _, err = fn.RecvOrTimeout(h.chain.ConfReqSignal, defaultTimeout) + require.NoError(t, err) + minted := h.assertFinalizeBatch(&wg, respChan, "") + require.Equal(t, tapgarden.BatchStateBroadcast, minted.State()) +} + +// TestIssue721ConfirmationRegistrationRetry ensures a successful publish +// followed by a transient notifier error remains retryable in the same process. +func TestIssue721ConfirmationRegistrationRetry(t *testing.T) { + store := newMintingStore(t) + h := newMintingTestHarness(t, store) + h.refreshChainPlanter() + t.Cleanup(func() { + if h.planter != nil { + _ = h.planter.Stop() + } + }) + + h.queueSeedlingsInBatch(false, issue721Seedling()) + pkt, _, witnessScript := issue721Anchor(t) + issue721Fund(t, h, pkt) + prepared, err := h.planter.PrepareBatch() + require.NoError(t, err) + signed := clonePacket(t, prepared.GenesisPacket.Pkt) + signed.Inputs[0].FinalScriptWitness = issue721FinalWitness( + t, witnessScript, + ) + + h.chain.FailConfRegistrationOnce() + var wg sync.WaitGroup + respChan := make(chan *FinalizeBatchResp, 1) + h.finalizeBatch(&wg, respChan, &tapgarden.FinalizeParams{ + SignedPsbt: signed, + }) + _, err = fn.RecvOrTimeout( + h.wallet.ImportPubKeySignal, defaultTimeout, + ) + require.NoError(t, err) + h.assertTxPublished() + h.assertFinalizeBatch( + &wg, respChan, "failed to register confirmation", + ) + + pending, err := h.planter.PendingBatch() + require.NoError(t, err) + require.Equal(t, tapgarden.BatchStateBroadcast, pending.State()) + retry := clonePacket(t, pending.GenesisPacket.Pkt) + h.finalizeBatch(&wg, respChan, &tapgarden.FinalizeParams{ + SignedPsbt: retry, + }) + h.assertTxPublished() + _, err = fn.RecvOrTimeout(h.chain.ConfReqSignal, defaultTimeout) + require.NoError(t, err) + minted := h.assertFinalizeBatch(&wg, respChan, "") + require.Equal(t, tapgarden.BatchStateBroadcast, minted.State()) +} + +// TestIssue721CancelPreparedBatch verifies a paused batch that already contains +// sprouts is cancelled using the sprout terminal state. +func TestIssue721CancelPreparedBatch(t *testing.T) { + store := newMintingStore(t) + h := newMintingTestHarness(t, store) + h.refreshChainPlanter() + t.Cleanup(func() { + if h.planter != nil { + _ = h.planter.Stop() + } + }) + + h.queueSeedlingsInBatch(false, issue721Seedling()) + pkt, _, _ := issue721Anchor(t) + issue721Fund(t, h, pkt) + prepared, err := h.planter.PrepareBatch() + require.NoError(t, err) + + batchKey, err := h.planter.CancelBatch() + require.NoError(t, err) + require.True(t, batchKey.IsEqual(prepared.BatchKey.PubKey)) + cancelled := h.fetchSingleBatch(batchKey) + require.Equal(t, tapgarden.BatchStateSproutCancelled, cancelled.State()) +} + // TestIssue721CustomRestartStates pins the startup distinction introduced by // the external-signing flow: custom pending/frozen batches pause, while a // legacy pending batch still resumes through the existing caretaker path. diff --git a/tapgarden/planter.go b/tapgarden/planter.go index ee54122181..7f7f6f1b1c 100644 --- a/tapgarden/planter.go +++ b/tapgarden/planter.go @@ -1328,7 +1328,6 @@ func customGenesisPsbt(chainParams address.ChainParams, return zero, fmt.Errorf("invalid custom asset anchor internal "+ "key: %w", err) } - funded := tapsend.FundedPsbt{ Pkt: packet, ChangeOutputIndex: changeOutputIndex, @@ -1397,6 +1396,12 @@ func customGenesisPsbt(chainParams address.ChainParams, return zero, fmt.Errorf("pre-commitment output index specified " + "for batch without supply commitments") } + if err := validateExclusionProofOutputs( + packet, assetAnchorOutIdx, + ); err != nil { + + return zero, err + } indexes := AnchorTxOutputIndexes{ AssetAnchorOutIdx: assetAnchorOutIdx, ChangeOutIdx: 0, @@ -1408,6 +1413,32 @@ func customGenesisPsbt(chainParams address.ChainParams, ) } +// validateExclusionProofOutputs ensures every non-anchor P2TR output can be +// represented in the minting proofs. Doing this before broadcast prevents an +// otherwise valid mint from becoming unprovable only after confirmation. +func validateExclusionProofOutputs(packet *psbt.Packet, + assetAnchorOutIdx uint32) error { + + if packet == nil || packet.UnsignedTx == nil { + return fmt.Errorf("cannot validate exclusion proof outputs without " + + "an unsigned transaction") + } + + baseProof := &proof.BaseProofParams{} + err := proof.AddExclusionProofs( + baseProof, packet.UnsignedTx, packet.Outputs, + func(outputIndex uint32) bool { + return outputIndex == assetAnchorOutIdx + }, + ) + if err != nil { + return fmt.Errorf("anchor PSBT cannot produce exclusion "+ + "proofs: %w", err) + } + + return nil +} + // filterSeedlingsWithGroup separates a set of seedlings into two sets based on // their relation to an asset group, which has not been constructed yet. func filterSeedlingsWithGroup( @@ -2095,9 +2126,20 @@ func (c *ChainPlanter) cancelMintingBatch(ctx context.Context, // If the target batch was not assigned a caretaker, we only need to // update the batch state on disk to cancel it. - err := c.cfg.Log.UpdateBatchState( - ctx, batchKey, BatchStateSeedlingCancelled, - ) + var cancelState BatchState + switch c.pendingBatch.State() { + case BatchStatePending, BatchStateFrozen: + cancelState = BatchStateSeedlingCancelled + + case BatchStateCommitted: + cancelState = BatchStateSproutCancelled + + default: + return fmt.Errorf("batch state %v is not cancellable", + c.pendingBatch.State()) + } + + err := c.cfg.Log.UpdateBatchState(ctx, batchKey, cancelState) if err != nil { return fmt.Errorf("unable to cancel minting batch: %w", err) } @@ -2372,9 +2414,10 @@ func (c *ChainPlanter) gardener() { case err := <-caretaker.cfg.BroadcastErrChan: req.Error(err) - // Unrecoverable error, stop caretaker - // directly. The pending batch will not - // be saved. + // Stop the failed caretaker directly. Custom + // batches remain pending so the same signed + // transaction can be retried; legacy batches + // retain their existing terminal behavior. stopErr := caretaker.Stop() if stopErr != nil { log.Warnf("Unable to stop "+ @@ -2407,7 +2450,9 @@ func (c *ChainPlanter) gardener() { ctx, cancel := c.WithCtxQuit() err = c.cancelMintingBatch(ctx, batchKey) cancel() - c.pendingBatch = nil + if err == nil { + c.pendingBatch = nil + } // Always return the key of the batch we tried // to cancel. @@ -3173,11 +3218,15 @@ func (c *ChainPlanter) finalizeBatch(params FinalizeParams) (*BatchCaretaker, return nil, fmt.Errorf("signed PSBT cannot be combined with " + "fee rate or tapscript sibling") } - if c.pendingBatch.State() != BatchStateCommitted || + state := c.pendingBatch.State() + // A broadcast custom packet can be resubmitted after an ambiguous + // publication or confirmation-registration error. The merge below + // still requires the prepared transaction and metadata to be identical. + if (state != BatchStateCommitted && state != BatchStateBroadcast) || !isCustomAnchorPsbt(c.pendingBatch.GenesisPacket.Pkt) { - return nil, fmt.Errorf("no prepared custom batch awaiting " + - "an external signature") + return nil, fmt.Errorf("no prepared or broadcast custom batch " + + "available for external finalization") } merged, err := mergeSignedCustomPsbt( c.pendingBatch.GenesisPacket.Pkt, params.SignedPsbt, diff --git a/tapnode/tapnodemock/chain_bridge.go b/tapnode/tapnodemock/chain_bridge.go index b91106aeea..f40a569bb0 100644 --- a/tapnode/tapnodemock/chain_bridge.go +++ b/tapnode/tapnodemock/chain_bridge.go @@ -21,6 +21,7 @@ import ( type ChainBridge struct { FeeEstimateSignal chan struct{} PublishReq chan *wire.MsgTx + PublishAttempts chan *wire.MsgTx ConfReqSignal chan int BlockEpochSignal chan struct{} @@ -35,6 +36,9 @@ type ChainBridge struct { // ConfReqSignal, so any receive from that channel happens-after // the corresponding store and a read of ConfReqs[reqNo] is safe. ConfReqs map[int]*chainntnfs.ConfirmationEvent + // ConfPkScripts records the confirmation script hint for each request. + // It follows the same ConfReqSignal synchronization contract as ConfReqs. + ConfPkScripts map[int][]byte // BlocksMu protects concurrent access to Blocks. Readers (GetBlock, // called from caretaker goroutines) hold it for read; all writers @@ -44,6 +48,8 @@ type ChainBridge struct { Blocks map[chainhash.Hash]*wire.MsgBlock failFeeEstimates atomic.Bool + failPublish atomic.Bool + failConfRegister atomic.Bool errConf atomic.Int32 emptyConf atomic.Int32 confErr chan error @@ -54,7 +60,9 @@ func NewChainBridge() *ChainBridge { return &ChainBridge{ FeeEstimateSignal: make(chan struct{}), PublishReq: make(chan *wire.MsgTx), + PublishAttempts: make(chan *wire.MsgTx, 10), ConfReqs: make(map[int]*chainntnfs.ConfirmationEvent), + ConfPkScripts: make(map[int][]byte), ConfReqSignal: make(chan int), BlockEpochSignal: make(chan struct{}, 1), NewBlocks: make(chan int32), @@ -67,6 +75,16 @@ func (m *ChainBridge) FailFeeEstimatesOnce() { m.failFeeEstimates.Store(true) } +// FailPublishOnce arms the next call to PublishTransaction to return an error. +func (m *ChainBridge) FailPublishOnce() { + m.failPublish.Store(true) +} + +// FailConfRegistrationOnce arms the next confirmation registration to fail. +func (m *ChainBridge) FailConfRegistrationOnce() { + m.failConfRegister.Store(true) +} + // FailConfOnce updates the ChainBridge such that the next call to // RegisterConfirmationNtfn will fail by returning an error on the error channel // returned from RegisterConfirmationNtfn. @@ -109,7 +127,7 @@ func (m *ChainBridge) SendConfNtfn(reqNo int, blockHash *chainhash.Hash, // RegisterConfirmationsNtfn records a confirmation subscription and signals // the caller via ConfReqSignal. func (m *ChainBridge) RegisterConfirmationsNtfn(ctx context.Context, - _ *chainhash.Hash, _ []byte, _, _ uint32, _ bool, + _ *chainhash.Hash, pkScript []byte, _, _ uint32, _ bool, _ chan struct{}) (*chainntnfs.ConfirmationEvent, chan error, error) { select { @@ -117,6 +135,9 @@ func (m *ChainBridge) RegisterConfirmationsNtfn(ctx context.Context, return nil, nil, fmt.Errorf("shutting down") default: } + if m.failConfRegister.CompareAndSwap(true, false) { + return nil, nil, fmt.Errorf("failed to register confirmation") + } defer func() { m.ReqCount.Add(1) @@ -130,6 +151,7 @@ func (m *ChainBridge) RegisterConfirmationsNtfn(ctx context.Context, currentReqCount := m.ReqCount.Load() m.ConfReqs[int(currentReqCount)] = req + m.ConfPkScripts[int(currentReqCount)] = append([]byte(nil), pkScript...) select { case m.ConfReqSignal <- int(currentReqCount): @@ -234,6 +256,11 @@ func (m *ChainBridge) GetBlockTimestamp(_ context.Context, _ uint32) (int64, func (m *ChainBridge) PublishTransaction(_ context.Context, tx *wire.MsgTx, _ string) error { + if m.failPublish.CompareAndSwap(true, false) { + m.PublishAttempts <- tx.Copy() + return fmt.Errorf("failed to publish transaction") + } + m.PublishReq <- tx return nil } From 6b74f67effd046aad1412bb7510cc5b244a93e44 Mon Sep 17 00:00:00 2001 From: Sadiq Jaffer Date: Thu, 13 Aug 2026 17:13:57 +0100 Subject: [PATCH 03/24] tapgarden: validate custom anchor publication policy --- tapgarden/custom_anchor_test.go | 7 ++++ tapgarden/issue721_stateful_test.go | 41 +++++++++++++++++++++++ tapgarden/planter.go | 50 +++++++++++++++++++++++++++++ taprpc/mintrpc/mint.pb.go | 5 ++- taprpc/mintrpc/mint.proto | 5 ++- taprpc/mintrpc/mint.swagger.json | 2 +- 6 files changed, 107 insertions(+), 3 deletions(-) diff --git a/tapgarden/custom_anchor_test.go b/tapgarden/custom_anchor_test.go index 36e4f74237..617ac348f2 100644 --- a/tapgarden/custom_anchor_test.go +++ b/tapgarden/custom_anchor_test.go @@ -72,6 +72,13 @@ func TestCustomGenesisPsbtValidation(t *testing.T) { ) require.ErrorContains(t, err, "anchor output is dust") + anchorTapTree := testCustomAnchorPacket(t) + anchorTapTree.Outputs[0].TaprootTapTree = []byte{0x00} + _, err = customGenesisPsbt( + address.TestNet3Tap, nil, anchorTapTree, 0, -1, noneUint32(), + ) + require.ErrorContains(t, err, "must not specify a PSBT tap tree") + // Every non-anchor P2TR output needs enough metadata to construct an // exclusion proof after the mint confirms. for _, testCase := range []struct { diff --git a/tapgarden/issue721_stateful_test.go b/tapgarden/issue721_stateful_test.go index 16c8ea427f..8278a477d8 100644 --- a/tapgarden/issue721_stateful_test.go +++ b/tapgarden/issue721_stateful_test.go @@ -386,6 +386,47 @@ func TestIssue721CancelPreparedBatch(t *testing.T) { require.Equal(t, tapgarden.BatchStateSproutCancelled, cancelled.State()) } +// TestIssue721LowFeeRejectsBeforeBroadcast verifies a caller-authored +// transaction that cannot meet the node's minimum relay fee remains in the +// prepared state, where it can still be cancelled and rebuilt. +func TestIssue721LowFeeRejectsBeforeBroadcast(t *testing.T) { + store := newMintingStore(t) + h := newMintingTestHarness(t, store) + h.refreshChainPlanter() + t.Cleanup(func() { + if h.planter != nil { + _ = h.planter.Stop() + } + }) + + h.queueSeedlingsInBatch(false, issue721Seedling()) + pkt, _, witnessScript := issue721Anchor(t) + pkt.Inputs[0].WitnessUtxo.Value = 12_000 + issue721Fund(t, h, pkt) + prepared, err := h.planter.PrepareBatch() + require.NoError(t, err) + + signed := clonePacket(t, prepared.GenesisPacket.Pkt) + signed.Inputs[0].FinalScriptWitness = issue721FinalWitness( + t, witnessScript, + ) + _, err = h.planter.FinalizeBatch(tapgarden.FinalizeParams{ + SignedPsbt: signed, + }) + require.ErrorContains(t, err, "fee does not meet minrelayfee") + + pending, err := h.planter.PendingBatch() + require.NoError(t, err) + require.Equal(t, tapgarden.BatchStateCommitted, pending.State()) + h.assertNumCaretakersActive(0) + + batchKey, err := h.planter.CancelBatch() + require.NoError(t, err) + require.True(t, batchKey.IsEqual(prepared.BatchKey.PubKey)) + cancelled := h.fetchSingleBatch(batchKey) + require.Equal(t, tapgarden.BatchStateSproutCancelled, cancelled.State()) +} + // TestIssue721CustomRestartStates pins the startup distinction introduced by // the external-signing flow: custom pending/frozen batches pause, while a // legacy pending batch still resumes through the existing caretaker path. diff --git a/tapgarden/planter.go b/tapgarden/planter.go index 7f7f6f1b1c..8f58bfde1b 100644 --- a/tapgarden/planter.go +++ b/tapgarden/planter.go @@ -10,8 +10,10 @@ import ( "sync" "time" + "github.com/btcsuite/btcd/blockchain" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil/v2" "github.com/btcsuite/btcd/chaincfg/v2" "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/mempool" @@ -30,6 +32,7 @@ import ( "github.com/lightninglabs/taproot-assets/universe" lfn "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/lnwallet/chainfee" "golang.org/x/exp/maps" ) @@ -1328,6 +1331,10 @@ func customGenesisPsbt(chainParams address.ChainParams, return zero, fmt.Errorf("invalid custom asset anchor internal "+ "key: %w", err) } + if len(pOut.TaprootTapTree) != 0 { + return zero, fmt.Errorf("custom asset anchor output must not " + + "specify a PSBT tap tree; use the batch sibling fields") + } funded := tapsend.FundedPsbt{ Pkt: packet, ChangeOutputIndex: changeOutputIndex, @@ -3200,6 +3207,40 @@ func validateFinalizedAnchorPsbt(packet *psbt.Packet) error { return nil } +// validateCustomAnchorFeeRate ensures an externally signed anchor transaction +// meets the backing node's current minimum relay fee before the batch is moved +// into its irreversible broadcast state. +func (c *ChainPlanter) validateCustomAnchorFeeRate(ctx context.Context, + packet *psbt.Packet) error { + + fee, err := packet.GetTxFee() + if err != nil { + return fmt.Errorf("unable to determine custom anchor fee: %w", err) + } + + signedTx, err := psbt.Extract(packet) + if err != nil { + return fmt.Errorf("unable to extract custom anchor transaction: %w", + err) + } + weight := lntypes.WeightUnit( + blockchain.GetTransactionWeight(btcutil.NewTx(signedTx)), + ) + + minRelayFee, err := c.cfg.Wallet.MinRelayFee(ctx) + if err != nil { + return fmt.Errorf("unable to obtain minimum relay fee: %w", err) + } + minFee := minRelayFee.FeeForWeightRoundUp(weight) + if fee < minFee { + return fmt.Errorf("custom anchor fee does not meet minrelayfee: "+ + "(fee=%d, minimum_fee=%d, minrelayfee=%s)", fee, minFee, + minRelayFee.String()) + } + + return nil +} + // finalizeBatch creates a new caretaker for the batch and starts it. func (c *ChainPlanter) finalizeBatch(params FinalizeParams) (*BatchCaretaker, error) { @@ -3238,6 +3279,15 @@ func (c *ChainPlanter) finalizeBatch(params FinalizeParams) (*BatchCaretaker, return nil, fmt.Errorf("externally signed PSBT is not fully "+ "valid: %w", err) } + if state == BatchStateCommitted { + ctx, cancel := c.WithCtxQuit() + err = c.validateCustomAnchorFeeRate(ctx, merged) + cancel() + if err != nil { + return nil, fmt.Errorf("externally signed PSBT has an invalid "+ + "fee rate: %w", err) + } + } c.pendingBatch.GenesisPacket.Pkt = merged caretaker := c.newCaretakerForBatch(c.pendingBatch, nil) diff --git a/taprpc/mintrpc/mint.pb.go b/taprpc/mintrpc/mint.pb.go index ec5c1fdb1a..be23cc0f28 100644 --- a/taprpc/mintrpc/mint.pb.go +++ b/taprpc/mintrpc/mint.pb.go @@ -790,7 +790,10 @@ type FundBatchRequest struct { // *FundBatchRequest_Branch BatchSibling isFundBatchRequest_BatchSibling `protobuf_oneof:"batch_sibling"` // An optional caller-funded anchor PSBT. Input zero remains the asset - // genesis point and the packet must not contain signatures yet. + // genesis point and the packet must not contain signatures yet. The + // selected asset anchor output must not contain PSBT_OUT_TAP_TREE because + // PrepareBatch replaces its script tree with the asset commitment and the + // optional batch_sibling. AnchorPsbt []byte `protobuf:"bytes,5,opt,name=anchor_psbt,json=anchorPsbt,proto3" json:"anchor_psbt,omitempty"` // The output that will receive the Taproot Asset commitment. AssetAnchorOutputIndex uint32 `protobuf:"varint,6,opt,name=asset_anchor_output_index,json=assetAnchorOutputIndex,proto3" json:"asset_anchor_output_index,omitempty"` diff --git a/taprpc/mintrpc/mint.proto b/taprpc/mintrpc/mint.proto index f7244f24c0..4e97a538cf 100644 --- a/taprpc/mintrpc/mint.proto +++ b/taprpc/mintrpc/mint.proto @@ -342,7 +342,10 @@ message FundBatchRequest { } // An optional caller-funded anchor PSBT. Input zero remains the asset - // genesis point and the packet must not contain signatures yet. + // genesis point and the packet must not contain signatures yet. The + // selected asset anchor output must not contain PSBT_OUT_TAP_TREE because + // PrepareBatch replaces its script tree with the asset commitment and the + // optional batch_sibling. bytes anchor_psbt = 5; // The output that will receive the Taproot Asset commitment. diff --git a/taprpc/mintrpc/mint.swagger.json b/taprpc/mintrpc/mint.swagger.json index 0308133fea..f86353db1b 100644 --- a/taprpc/mintrpc/mint.swagger.json +++ b/taprpc/mintrpc/mint.swagger.json @@ -392,7 +392,7 @@ "anchor_psbt": { "type": "string", "format": "byte", - "description": "An optional caller-funded anchor PSBT. Input zero remains the asset\ngenesis point and the packet must not contain signatures yet." + "description": "An optional caller-funded anchor PSBT. Input zero remains the asset\ngenesis point and the packet must not contain signatures yet. The selected\nasset anchor output must not contain PSBT_OUT_TAP_TREE because PrepareBatch\nreplaces its script tree with the asset commitment and the optional\nbatch_sibling." }, "asset_anchor_output_index": { "type": "integer", From bc6531c0ba12be5bed03c2640191bc08278d7d17 Mon Sep 17 00:00:00 2001 From: Sadiq Jaffer Date: Thu, 13 Aug 2026 18:07:50 +0100 Subject: [PATCH 04/24] tapgarden: preserve custom anchor on broadcast retry --- tapgarden/issue721_stateful_test.go | 54 ++++++++++++++++++++++++++--- tapgarden/planter.go | 35 ++++++++++++++++++- 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/tapgarden/issue721_stateful_test.go b/tapgarden/issue721_stateful_test.go index 8278a477d8..c75d6c57ae 100644 --- a/tapgarden/issue721_stateful_test.go +++ b/tapgarden/issue721_stateful_test.go @@ -26,7 +26,9 @@ import ( func issue721Anchor(t *testing.T) (*psbt.Packet, *btcec.PublicKey, []byte) { t.Helper() - witnessScript := []byte{txscript.OP_TRUE} + // A dropped witness element lets retry tests construct distinct, valid + // witnesses for the same unsigned transaction. + witnessScript := []byte{txscript.OP_DROP, txscript.OP_TRUE} witnessHash := sha256.Sum256(witnessScript) prevScript, err := txscript.NewScriptBuilder(). AddOp(txscript.OP_0).AddData(witnessHash[:]).Script() @@ -107,8 +109,20 @@ func issue721Fund(t *testing.T, h *mintingTestHarness, func issue721FinalWitness(t *testing.T, witnessScript []byte) []byte { t.Helper() + return issue721FinalWitnessWithItem( + t, []byte{0x01}, witnessScript, + ) +} + +func issue721FinalWitnessWithItem(t *testing.T, item, + witnessScript []byte) []byte { + + t.Helper() + var buf bytes.Buffer - err := psbt.WriteTxWitness(&buf, wire.TxWitness{witnessScript}) + err := psbt.WriteTxWitness( + &buf, wire.TxWitness{item, witnessScript}, + ) require.NoError(t, err) return buf.Bytes() @@ -288,7 +302,9 @@ func testIssue721PublishRetry(t *testing.T, restartBeforeRetry bool) { // returning to an externally cancellable pre-broadcast state. h.refreshChainPlanter() published := h.assertTxPublished() - require.Equal(t, (*firstAttempt).TxHash(), published.TxHash()) + require.Equal( + t, (*firstAttempt).WitnessHash(), published.WitnessHash(), + ) _, err = fn.RecvOrTimeout(h.chain.ConfReqSignal, defaultTimeout) require.NoError(t, err) h.assertNumCaretakersActive(1) @@ -298,12 +314,42 @@ func testIssue721PublishRetry(t *testing.T, restartBeforeRetry bool) { return } + // A Broadcast retry must not replace the transaction that was already + // persisted before the ambiguous publication failure. Both witnesses are + // valid and have the same txid, but the larger witness has a different + // wtxid and weight. + changedRetry := clonePacket(t, pending.GenesisPacket.Pkt) + changedRetry.Inputs[0].FinalScriptWitness = + issue721FinalWitnessWithItem( + t, bytes.Repeat([]byte{0x02}, 500), witnessScript, + ) + changedTx, err := psbt.Extract(changedRetry) + require.NoError(t, err) + require.Equal(t, (*firstAttempt).TxHash(), changedTx.TxHash()) + require.NotEqual(t, (*firstAttempt).WitnessHash(), changedTx.WitnessHash()) + + _, err = h.planter.FinalizeBatch(tapgarden.FinalizeParams{ + SignedPsbt: changedRetry, + }) + require.ErrorContains( + t, err, "broadcast retry changes finalized transaction", + ) + unchanged, err := h.planter.PendingBatch() + require.NoError(t, err) + unchangedTx, err := psbt.Extract(unchanged.GenesisPacket.Pkt) + require.NoError(t, err) + require.Equal( + t, (*firstAttempt).WitnessHash(), unchangedTx.WitnessHash(), + ) + retry := clonePacket(t, pending.GenesisPacket.Pkt) h.finalizeBatch(&wg, respChan, &tapgarden.FinalizeParams{ SignedPsbt: retry, }) published := h.assertTxPublished() - require.Equal(t, (*firstAttempt).TxHash(), published.TxHash()) + require.Equal( + t, (*firstAttempt).WitnessHash(), published.WitnessHash(), + ) _, err = fn.RecvOrTimeout(h.chain.ConfReqSignal, defaultTimeout) require.NoError(t, err) minted := h.assertFinalizeBatch(&wg, respChan, "") diff --git a/tapgarden/planter.go b/tapgarden/planter.go index 8f58bfde1b..8586ac06cd 100644 --- a/tapgarden/planter.go +++ b/tapgarden/planter.go @@ -3279,6 +3279,39 @@ func (c *ChainPlanter) finalizeBatch(params FinalizeParams) (*BatchCaretaker, return nil, fmt.Errorf("externally signed PSBT is not fully "+ "valid: %w", err) } + if state == BatchStateBroadcast { + persistedTx, err := psbt.Extract( + c.pendingBatch.GenesisPacket.Pkt, + ) + if err != nil { + return nil, fmt.Errorf("unable to extract persisted custom "+ + "anchor transaction: %w", err) + } + retryTx, err := psbt.Extract(merged) + if err != nil { + return nil, fmt.Errorf("unable to extract custom anchor "+ + "retry transaction: %w", err) + } + + var persistedBytes, retryBytes bytes.Buffer + if err := persistedTx.Serialize(&persistedBytes); err != nil { + return nil, fmt.Errorf("unable to serialize persisted custom "+ + "anchor transaction: %w", err) + } + if err := retryTx.Serialize(&retryBytes); err != nil { + return nil, fmt.Errorf("unable to serialize custom anchor "+ + "retry transaction: %w", err) + } + if !bytes.Equal(persistedBytes.Bytes(), retryBytes.Bytes()) { + return nil, fmt.Errorf("broadcast retry changes finalized " + + "transaction") + } + + // The transaction was already committed before the original + // publication attempt. Reuse that exact persisted packet instead + // of replacing it with caller-supplied retry data. + merged = c.pendingBatch.GenesisPacket.Pkt + } if state == BatchStateCommitted { ctx, cancel := c.WithCtxQuit() err = c.validateCustomAnchorFeeRate(ctx, merged) @@ -3287,8 +3320,8 @@ func (c *ChainPlanter) finalizeBatch(params FinalizeParams) (*BatchCaretaker, return nil, fmt.Errorf("externally signed PSBT has an invalid "+ "fee rate: %w", err) } + c.pendingBatch.GenesisPacket.Pkt = merged } - c.pendingBatch.GenesisPacket.Pkt = merged caretaker := c.newCaretakerForBatch(c.pendingBatch, nil) if err := caretaker.Start(); err != nil { From 1df51f290f97a83c8d03d3a017db73953fdae652 Mon Sep 17 00:00:00 2001 From: Sadiq Jaffer Date: Thu, 13 Aug 2026 18:47:18 +0100 Subject: [PATCH 05/24] tapgarden: recover rejected custom anchor publishes --- lndservices/chain_bridge.go | 34 +++++++ lndservices/chain_bridge_test.go | 61 ++++++++++++ tapdb/asset_minting.go | 27 ++++++ tapgarden/caretaker.go | 45 +++++++++ tapgarden/custom_anchor_test.go | 15 +-- tapgarden/interface.go | 7 ++ tapgarden/issue721_stateful_test.go | 115 +++++++++++++++++++++++ tapgarden/planter.go | 141 +++++++++++++++++++++++++--- tapnode/chain_bridge.go | 41 ++++++++ tapnode/tapnodemock/chain_bridge.go | 27 ++++++ universe/supplycommit/mock.go | 6 ++ 11 files changed, 499 insertions(+), 20 deletions(-) create mode 100644 lndservices/chain_bridge_test.go diff --git a/lndservices/chain_bridge.go b/lndservices/chain_bridge.go index 29ea5c9916..3564f64fcb 100644 --- a/lndservices/chain_bridge.go +++ b/lndservices/chain_bridge.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "sort" + "strings" "time" "github.com/btcsuite/btcd/chainhash/v2" @@ -16,7 +17,9 @@ import ( "github.com/lightninglabs/taproot-assets/tapdb" "github.com/lightninglabs/taproot-assets/tapnode" "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "google.golang.org/grpc/status" ) const ( @@ -336,6 +339,37 @@ func (l *LndRpcChainBridge) PublishTransaction(ctx context.Context, return err } +// ValidateAndPublishTransaction submits a transaction before tapd persists an +// irreversible broadcast state. Errors returned by the remote application are +// definitive because lnd performs TestMempoolAccept before publishing, while +// transport and context failures remain ambiguous. +func (l *LndRpcChainBridge) ValidateAndPublishTransaction(ctx context.Context, + tx *wire.MsgTx, label string) error { + + err := l.lnd.WalletKit.PublishTransaction(ctx, tx, label) + if err == nil { + return nil + } + + if isDefinitivePublishError(err) { + return tapnode.NewDefinitivePublishError(err) + } + + return err +} + +// isDefinitivePublishError recognizes the typed lnd publication errors that +// are emitted only after TestMempoolAccept conclusively rejects a transaction. +// Other application and transport errors remain ambiguous: a backend can fail +// after relaying the transaction, so they must not restore a cancellable +// pre-broadcast state. +func isDefinitivePublishError(err error) bool { + errMsg := status.Convert(err).Message() + + return strings.HasPrefix(errMsg, lnwallet.ErrDoubleSpend.Error()) || + strings.HasPrefix(errMsg, lnwallet.ErrMempoolFee.Error()) +} + // EstimateFee returns a fee estimate for the confirmation target. func (l *LndRpcChainBridge) EstimateFee(ctx context.Context, confTarget uint32) (chainfee.SatPerKWeight, error) { diff --git a/lndservices/chain_bridge_test.go b/lndservices/chain_bridge_test.go new file mode 100644 index 0000000000..f79b957503 --- /dev/null +++ b/lndservices/chain_bridge_test.go @@ -0,0 +1,61 @@ +package lndservices + +import ( + "errors" + "testing" + + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestDefinitivePublishError(t *testing.T) { + t.Parallel() + + for _, testCase := range []struct { + name string + err error + definitive bool + }{ + { + name: "spent input", + err: status.Error( + codes.Unknown, lnwallet.ErrDoubleSpend.Error(), + ), + definitive: true, + }, + { + name: "mempool fee with detail", + err: status.Error( + codes.Unknown, lnwallet.ErrMempoolFee.Error()+ + ": min relay fee not met", + ), + definitive: true, + }, + { + name: "generic application error", + err: status.Error(codes.Unknown, "backend failure"), + definitive: false, + }, + { + name: "transport failure", + err: status.Error( + codes.Unavailable, "connection closed", + ), + definitive: false, + }, + { + name: "plain error", + err: errors.New("local failure"), + definitive: false, + }, + } { + t.Run(testCase.name, func(t *testing.T) { + require.Equal( + t, testCase.definitive, + isDefinitivePublishError(testCase.err), + ) + }) + } +} diff --git a/tapdb/asset_minting.go b/tapdb/asset_minting.go index b7e02d8b88..27066d1750 100644 --- a/tapdb/asset_minting.go +++ b/tapdb/asset_minting.go @@ -1977,6 +1977,33 @@ func (a *AssetMintingStore) CommitSignedGenesisTx(ctx context.Context, }) } +// StoreSignedGenesisPsbt durably records a signed custom genesis packet while +// leaving its batch committed. The transaction is only anchored into the +// asset store after publication validation succeeds. +func (a *AssetMintingStore) StoreSignedGenesisPsbt(ctx context.Context, + batchKey *btcec.PublicKey, genesisPkt *tapsend.FundedPsbt) error { + + pktBytes, err := fn.Serialize(genesisPkt.Pkt) + if err != nil { + return fmt.Errorf("unable to serialize signed genesis packet: %w", err) + } + + rawBatchKey := batchKey.SerializeCompressed() + var writeTxOpts AssetStoreTxOptions + return a.db.ExecTx(ctx, &writeTxOpts, func(q PendingAssetStore) error { + err := q.UpdateBatchGenesisTx(ctx, GenesisTxUpdate{ + RawKey: rawBatchKey, + MintingTxPsbt: pktBytes, + }) + if err != nil { + return fmt.Errorf("unable to store signed genesis packet: %w", + err) + } + + return nil + }) +} + // MarkBatchConfirmed stores final confirmation information for a batch on // disk. func (a *AssetMintingStore) MarkBatchConfirmed(ctx context.Context, diff --git a/tapgarden/caretaker.go b/tapgarden/caretaker.go index 0b6014a68f..e5001f420c 100644 --- a/tapgarden/caretaker.go +++ b/tapgarden/caretaker.go @@ -804,6 +804,40 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) return 0, err } + // Custom anchor transactions can contain inputs that aren't owned + // by lnd. Ask the chain backend to validate and submit the fully + // signed transaction before we persist the irreversible Broadcast + // state. A conclusive application rejection leaves the batch in + // Committed, while an ambiguous transport failure must be treated as + // potentially published. + var publishErr error + if isCustomAnchorPsbt(signedPkt) { + publishCtx, publishCancel := b.WithCtxQuit() + publishErr = b.cfg.ChainBridge. + ValidateAndPublishTransaction( + publishCtx, signedTx, IssuanceTxLabel, + ) + publishCancel() + if tapnode.IsDefinitivePublishError(publishErr) { + setCustomAnchorPublishState( + signedPkt, customAnchorPublishRejected, + ) + storeCtx, storeCancel := b.WithCtxQuit() + storeErr := b.cfg.Log.StoreSignedGenesisPsbt( + storeCtx, b.cfg.Batch.BatchKey.PubKey, + &b.cfg.Batch.GenesisPacket.FundedPsbt, + ) + storeCancel() + if storeErr != nil { + return 0, fmt.Errorf("unable to store definitive "+ + "publication rejection: %w", storeErr) + } + + return 0, fmt.Errorf("transaction rejected before "+ + "broadcast state: %w", publishErr) + } + } + // To spend this output in the future, we must also commit the // Taproot Asset commitment root and batch tapscript sibling. tapCommitmentRoot := b.cfg.Batch.RootAssetCommitment. @@ -843,6 +877,17 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) return 0, fmt.Errorf("unable to import key: %w", err) } + // If validation failed ambiguously, the transaction might still + // have reached the backend. The durable signed transaction and + // Broadcast state make an exact retry safe after this caretaker + // exits. + if publishErr != nil { + b.cfg.Batch.UpdateState(BatchStateBroadcast) + + return 0, fmt.Errorf("unable to publish transaction: %w", + publishErr) + } + log.Infof("BatchCaretaker(%x): transition states: %v -> %v", b.batchKey[:], BatchStateCommitted, BatchStateBroadcast) diff --git a/tapgarden/custom_anchor_test.go b/tapgarden/custom_anchor_test.go index 617ac348f2..6185b439fb 100644 --- a/tapgarden/custom_anchor_test.go +++ b/tapgarden/custom_anchor_test.go @@ -72,12 +72,15 @@ func TestCustomGenesisPsbtValidation(t *testing.T) { ) require.ErrorContains(t, err, "anchor output is dust") - anchorTapTree := testCustomAnchorPacket(t) - anchorTapTree.Outputs[0].TaprootTapTree = []byte{0x00} - _, err = customGenesisPsbt( - address.TestNet3Tap, nil, anchorTapTree, 0, -1, noneUint32(), - ) - require.ErrorContains(t, err, "must not specify a PSBT tap tree") + for _, tapTree := range [][]byte{{}, {0x00}} { + anchorTapTree := testCustomAnchorPacket(t) + anchorTapTree.Outputs[0].TaprootTapTree = tapTree + _, err = customGenesisPsbt( + address.TestNet3Tap, nil, anchorTapTree, 0, -1, + noneUint32(), + ) + require.ErrorContains(t, err, "must not specify a PSBT tap tree") + } // Every non-anchor P2TR output needs enough metadata to construct an // exclusion proof after the mint confirms. diff --git a/tapgarden/interface.go b/tapgarden/interface.go index c296eaff27..d5454ba34e 100644 --- a/tapgarden/interface.go +++ b/tapgarden/interface.go @@ -241,6 +241,13 @@ type MintingStore interface { genesisPacket *FundedMintAnchorPsbt, assets *commitment.TapCommitment) error + // StoreSignedGenesisPsbt durably records a fully signed custom genesis + // packet while leaving the batch in BatchStateCommitted. This allows + // publication acceptance to be checked before the irreversible broadcast + // records are created. + StoreSignedGenesisPsbt(ctx context.Context, batchKey *btcec.PublicKey, + genesisTx *tapsend.FundedPsbt) error + // CommitSignedGenesisTx adds a fully signed genesis transaction to the // batch, along with the Taproot Asset script root, which is the // left/right sibling for the Taproot Asset tapscript commitment in the diff --git a/tapgarden/issue721_stateful_test.go b/tapgarden/issue721_stateful_test.go index c75d6c57ae..80487fcb88 100644 --- a/tapgarden/issue721_stateful_test.go +++ b/tapgarden/issue721_stateful_test.go @@ -356,6 +356,76 @@ func testIssue721PublishRetry(t *testing.T, restartBeforeRetry bool) { require.Equal(t, tapgarden.BatchStateBroadcast, minted.State()) } +// TestIssue721DefinitivePublishFailureCancellable verifies a conclusive +// backend rejection never advances a custom batch to Broadcast. The signed +// packet is retained durably in Committed across restart, where the caller can +// still cancel and rebuild the transaction with a different input. +func TestIssue721DefinitivePublishFailureCancellable(t *testing.T) { + store := newMintingStore(t) + h := newMintingTestHarness(t, store) + h.refreshChainPlanter() + t.Cleanup(func() { + if h.planter != nil { + _ = h.planter.Stop() + } + }) + + h.queueSeedlingsInBatch(false, issue721Seedling()) + pkt, _, witnessScript := issue721Anchor(t) + issue721Fund(t, h, pkt) + prepared, err := h.planter.PrepareBatch() + require.NoError(t, err) + + signed := clonePacket(t, prepared.GenesisPacket.Pkt) + signed.Inputs[0].FinalScriptWitness = issue721FinalWitness( + t, witnessScript, + ) + signedTx, err := psbt.Extract(signed) + require.NoError(t, err) + + h.chain.FailPublishDefinitively() + var wg sync.WaitGroup + respChan := make(chan *FinalizeBatchResp, 1) + h.finalizeBatch(&wg, respChan, &tapgarden.FinalizeParams{ + SignedPsbt: signed, + }) + attempt, err := fn.RecvOrTimeout( + h.chain.PublishAttempts, defaultTimeout, + ) + require.NoError(t, err) + require.Equal(t, signedTx.WitnessHash(), (*attempt).WitnessHash()) + h.assertFinalizeBatch( + &wg, respChan, "transaction rejected before broadcast state", + ) + + pending, err := h.planter.PendingBatch() + require.NoError(t, err) + require.Equal(t, tapgarden.BatchStateCommitted, pending.State()) + persisted := h.fetchSingleBatch(pending.BatchKey.PubKey) + require.Equal(t, tapgarden.BatchStateCommitted, persisted.State()) + persistedTx, err := psbt.Extract(persisted.GenesisPacket.Pkt) + require.NoError(t, err) + require.Equal(t, signedTx.WitnessHash(), persistedTx.WitnessHash()) + h.assertNumCaretakersActive(0) + + // A restored custom Committed batch remains paused and retains the exact + // finalized transaction rather than attempting to publish it again. + h.refreshChainPlanter() + restored, err := h.planter.PendingBatch() + require.NoError(t, err) + require.Equal(t, tapgarden.BatchStateCommitted, restored.State()) + restoredTx, err := psbt.Extract(restored.GenesisPacket.Pkt) + require.NoError(t, err) + require.Equal(t, signedTx.WitnessHash(), restoredTx.WitnessHash()) + h.assertNumCaretakersActive(0) + + batchKey, err := h.planter.CancelBatch() + require.NoError(t, err) + require.True(t, batchKey.IsEqual(restored.BatchKey.PubKey)) + cancelled := h.fetchSingleBatch(batchKey) + require.Equal(t, tapgarden.BatchStateSproutCancelled, cancelled.State()) +} + // TestIssue721ConfirmationRegistrationRetry ensures a successful publish // followed by a transient notifier error remains retryable in the same process. func TestIssue721ConfirmationRegistrationRetry(t *testing.T) { @@ -510,6 +580,51 @@ func TestIssue721CustomRestartStates(t *testing.T) { }) } + t.Run("committed publication attempt resumes", func(t *testing.T) { + store := newMintingStore(t) + h := newMintingTestHarness(t, store) + h.refreshChainPlanter() + t.Cleanup(func() { + if h.planter != nil { + _ = h.planter.Stop() + } + }) + + h.queueSeedlingsInBatch(false, issue721Seedling()) + pkt, _, witnessScript := issue721Anchor(t) + issue721Fund(t, h, pkt) + prepared, err := h.planter.PrepareBatch() + require.NoError(t, err) + + signed := clonePacket(t, prepared.GenesisPacket.Pkt) + signed.Inputs[0].FinalScriptWitness = issue721FinalWitness( + t, witnessScript, + ) + // This proprietary marker is the durable boundary written before + // the publication RPC. A crash at that point must resume rather + // than expose a cancellable signed transaction. + signed.Unknowns = append(signed.Unknowns, &psbt.Unknown{ + Key: []byte{0xfc, 0x04, 't', 'a', 'p', 'd', 0x02}, + Value: []byte{1}, + }) + funded := prepared.GenesisPacket.FundedPsbt + funded.Pkt = signed + err = store.StoreSignedGenesisPsbt( + t.Context(), prepared.BatchKey.PubKey, &funded, + ) + require.NoError(t, err) + + h.refreshChainPlanter() + _, err = fn.RecvOrTimeout( + h.wallet.ImportPubKeySignal, defaultTimeout, + ) + require.NoError(t, err) + h.assertTxPublished() + _, err = fn.RecvOrTimeout(h.chain.ConfReqSignal, defaultTimeout) + require.NoError(t, err) + h.assertNumCaretakersActive(1) + }) + t.Run("legacy pending resumes", func(t *testing.T) { store := newMintingStore(t) h := newMintingTestHarness(t, store) diff --git a/tapgarden/planter.go b/tapgarden/planter.go index 8586ac06cd..7d0d082854 100644 --- a/tapgarden/planter.go +++ b/tapgarden/planter.go @@ -352,6 +352,18 @@ var customAnchorPsbtMarker = []byte{ 0xfc, 0x04, 't', 'a', 'p', 'd', 0x01, } +var customAnchorPublishMarker = []byte{ + 0xfc, 0x04, 't', 'a', 'p', 'd', 0x02, +} + +type customAnchorPublishState byte + +const ( + customAnchorPublishNone customAnchorPublishState = iota + customAnchorPublishPending + customAnchorPublishRejected +) + func markCustomAnchorPsbt(packet *psbt.Packet) { if isCustomAnchorPsbt(packet) { return @@ -377,6 +389,57 @@ func isCustomAnchorPsbt(packet *psbt.Packet) bool { return false } +func setCustomAnchorPublishState(packet *psbt.Packet, + state customAnchorPublishState) { + + unknowns := packet.Unknowns[:0] + for _, unknown := range packet.Unknowns { + if !bytes.Equal(unknown.Key, customAnchorPublishMarker) { + unknowns = append(unknowns, unknown) + } + } + packet.Unknowns = unknowns + if state == customAnchorPublishNone { + return + } + + packet.Unknowns = append(packet.Unknowns, &psbt.Unknown{ + Key: fn.CopySlice(customAnchorPublishMarker), + Value: []byte{byte(state)}, + }) +} + +func getCustomAnchorPublishState( + packet *psbt.Packet) customAnchorPublishState { + + if packet == nil { + return customAnchorPublishNone + } + + for _, unknown := range packet.Unknowns { + if !bytes.Equal(unknown.Key, customAnchorPublishMarker) { + continue + } + if len(unknown.Value) == 1 && + customAnchorPublishState(unknown.Value[0]) == + customAnchorPublishRejected { + + return customAnchorPublishRejected + } + + // An unknown or malformed persisted value is conservative: a + // publication attempt might have happened, so don't make the batch + // cancellable. + return customAnchorPublishPending + } + + return customAnchorPublishNone +} + +func stripCustomAnchorPublishState(packet *psbt.Packet) { + setCustomAnchorPublishState(packet, customAnchorPublishNone) +} + // PendingGroupWitness specifies the asset group witness for an asset seedling // in an unsealed minting batch. type PendingGroupWitness struct { @@ -584,15 +647,24 @@ func (c *ChainPlanter) Start() error { batch.AssetMetas = make(AssetMetas) } - // A committed custom batch is deliberately paused for an - // external signer. Restore it as the pending batch instead of - // launching a caretaker that would immediately ask lnd to sign. + // A custom batch is paused while awaiting an external signer or + // after a conclusive publication rejection. A finalized + // Committed packet with a pending publication marker instead + // resumes automatically: it may already have reached the backend + // before a crash and therefore must not become cancellable. customBatch := batch.GenesisPacket != nil && isCustomAnchorPsbt(batch.GenesisPacket.Pkt) + publishState := customAnchorPublishNone + if customBatch { + publishState = getCustomAnchorPublishState( + batch.GenesisPacket.Pkt, + ) + } awaitingExternalSigner := customBatch && (batchState == BatchStatePending || batchState == BatchStateFrozen || - batchState == BatchStateCommitted) + (batchState == BatchStateCommitted && + publishState != customAnchorPublishPending)) if awaitingExternalSigner { if c.pendingBatch != nil { @@ -1331,7 +1403,7 @@ func customGenesisPsbt(chainParams address.ChainParams, return zero, fmt.Errorf("invalid custom asset anchor internal "+ "key: %w", err) } - if len(pOut.TaprootTapTree) != 0 { + if pOut.TaprootTapTree != nil { return zero, fmt.Errorf("custom asset anchor output must not " + "specify a PSBT tap tree; use the batch sibling fields") } @@ -2139,6 +2211,15 @@ func (c *ChainPlanter) cancelMintingBatch(ctx context.Context, cancelState = BatchStateSeedlingCancelled case BatchStateCommitted: + if isCustomAnchorPsbt(c.pendingBatch.GenesisPacket.Pkt) && + getCustomAnchorPublishState( + c.pendingBatch.GenesisPacket.Pkt, + ) == customAnchorPublishPending { + + return fmt.Errorf("custom anchor publication status is " + + "ambiguous and is not cancellable") + } + cancelState = BatchStateSproutCancelled default: @@ -3052,6 +3133,11 @@ func mergeSignedCustomPsbt(stored, if err != nil { return nil, err } + // Publication state is internal recovery metadata added after the + // caller signs. It isn't part of the prepared transaction contract and + // therefore isn't required in a resubmitted external packet. + stripCustomAnchorPublishState(storedNorm) + stripCustomAnchorPublishState(signedNorm) if !reflect.DeepEqual(storedNorm.UnsignedTx, signedNorm.UnsignedTx) || !reflect.DeepEqual(storedNorm.Outputs, signedNorm.Outputs) || !reflect.DeepEqual(storedNorm.XPubs, signedNorm.XPubs) || @@ -3279,14 +3365,15 @@ func (c *ChainPlanter) finalizeBatch(params FinalizeParams) (*BatchCaretaker, return nil, fmt.Errorf("externally signed PSBT is not fully "+ "valid: %w", err) } - if state == BatchStateBroadcast { - persistedTx, err := psbt.Extract( - c.pendingBatch.GenesisPacket.Pkt, - ) - if err != nil { + persistedTx, persistedErr := psbt.Extract( + c.pendingBatch.GenesisPacket.Pkt, + ) + if state == BatchStateBroadcast || persistedErr == nil { + if persistedErr != nil { return nil, fmt.Errorf("unable to extract persisted custom "+ - "anchor transaction: %w", err) + "anchor transaction: %w", persistedErr) } + retryTx, err := psbt.Extract(merged) if err != nil { return nil, fmt.Errorf("unable to extract custom anchor "+ @@ -3303,11 +3390,16 @@ func (c *ChainPlanter) finalizeBatch(params FinalizeParams) (*BatchCaretaker, "retry transaction: %w", err) } if !bytes.Equal(persistedBytes.Bytes(), retryBytes.Bytes()) { - return nil, fmt.Errorf("broadcast retry changes finalized " + - "transaction") + if state == BatchStateBroadcast { + return nil, fmt.Errorf("broadcast retry changes " + + "finalized transaction") + } + + return nil, fmt.Errorf("committed retry changes " + + "finalized transaction") } - // The transaction was already committed before the original + // The signed transaction was already recorded before an earlier // publication attempt. Reuse that exact persisted packet instead // of replacing it with caller-supplied retry data. merged = c.pendingBatch.GenesisPacket.Pkt @@ -3320,6 +3412,27 @@ func (c *ChainPlanter) finalizeBatch(params FinalizeParams) (*BatchCaretaker, return nil, fmt.Errorf("externally signed PSBT has an invalid "+ "fee rate: %w", err) } + setCustomAnchorPublishState( + merged, customAnchorPublishPending, + ) + + // Store the signed packet before starting the caretaker. If + // publication is definitively rejected, the batch remains in + // Committed and the exact signed transaction survives restart + // for inspection, cancellation, or idempotent resubmission. + signedFundedPsbt := c.pendingBatch.GenesisPacket.FundedPsbt + signedFundedPsbt.Pkt = merged + ctx, cancel = c.WithCtxQuit() + err = c.cfg.Log.StoreSignedGenesisPsbt( + ctx, c.pendingBatch.BatchKey.PubKey, + &signedFundedPsbt, + ) + cancel() + if err != nil { + return nil, fmt.Errorf("unable to store externally signed "+ + "PSBT: %w", err) + } + c.pendingBatch.GenesisPacket.Pkt = merged } diff --git a/tapnode/chain_bridge.go b/tapnode/chain_bridge.go index c58d8eee32..2cb8003d43 100644 --- a/tapnode/chain_bridge.go +++ b/tapnode/chain_bridge.go @@ -2,6 +2,7 @@ package tapnode import ( "context" + "errors" "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" @@ -10,6 +11,40 @@ import ( "github.com/lightningnetwork/lnd/lnwallet/chainfee" ) +// DefinitivePublishError marks a transaction publication error that proves the +// transaction was rejected before it could enter the mempool. Callers may +// safely keep the transaction in a pre-broadcast state when this error is +// returned. +type DefinitivePublishError struct { + err error +} + +// NewDefinitivePublishError wraps a conclusive transaction rejection. +func NewDefinitivePublishError(err error) error { + if err == nil { + return nil + } + + return &DefinitivePublishError{err: err} +} + +// Error returns the underlying publication error. +func (e *DefinitivePublishError) Error() string { + return e.err.Error() +} + +// Unwrap returns the underlying publication error. +func (e *DefinitivePublishError) Unwrap() error { + return e.err +} + +// IsDefinitivePublishError reports whether publication conclusively rejected +// the transaction rather than failing with an ambiguous transport error. +func IsDefinitivePublishError(err error) bool { + var definitiveErr *DefinitivePublishError + return errors.As(err, &definitiveErr) +} + // ChainBridge is our bridge to the target chain. It's used to get // confirmation notifications, the current height, publish // transactions, and also estimate fees. @@ -62,6 +97,12 @@ type ChainBridge interface { // the network. PublishTransaction(context.Context, *wire.MsgTx, string) error + // ValidateAndPublishTransaction submits a transaction before callers + // persist an irreversible broadcast state. A definitive rejection is + // returned as DefinitivePublishError; all other errors are ambiguous and + // callers must assume the transaction may have been relayed. + ValidateAndPublishTransaction(context.Context, *wire.MsgTx, string) error + // EstimateFee returns a fee estimate for the confirmation // target. EstimateFee(ctx context.Context, diff --git a/tapnode/tapnodemock/chain_bridge.go b/tapnode/tapnodemock/chain_bridge.go index f40a569bb0..5b68a49fa7 100644 --- a/tapnode/tapnodemock/chain_bridge.go +++ b/tapnode/tapnodemock/chain_bridge.go @@ -49,6 +49,7 @@ type ChainBridge struct { failFeeEstimates atomic.Bool failPublish atomic.Bool + failPublishFinal atomic.Bool failConfRegister atomic.Bool errConf atomic.Int32 emptyConf atomic.Int32 @@ -80,6 +81,12 @@ func (m *ChainBridge) FailPublishOnce() { m.failPublish.Store(true) } +// FailPublishDefinitively arms transaction validation to return a conclusive +// rejection until the caller replaces the mock. +func (m *ChainBridge) FailPublishDefinitively() { + m.failPublishFinal.Store(true) +} + // FailConfRegistrationOnce arms the next confirmation registration to fail. func (m *ChainBridge) FailConfRegistrationOnce() { m.failConfRegister.Store(true) @@ -265,6 +272,26 @@ func (m *ChainBridge) PublishTransaction(_ context.Context, return nil } +// ValidateAndPublishTransaction models the validation performed by lnd before +// publication without sending a second mock publication notification on the +// successful path. +func (m *ChainBridge) ValidateAndPublishTransaction(_ context.Context, + tx *wire.MsgTx, _ string) error { + + if m.failPublishFinal.Load() { + m.PublishAttempts <- tx.Copy() + return tapnode.NewDefinitivePublishError( + fmt.Errorf("transaction rejected: output already spent"), + ) + } + if m.failPublish.CompareAndSwap(true, false) { + m.PublishAttempts <- tx.Copy() + return fmt.Errorf("failed to publish transaction") + } + + return nil +} + // EstimateFee returns chainfee.FeePerKwFloor unless FailFeeEstimatesOnce was // armed, in which case it returns an error once. func (m *ChainBridge) EstimateFee(ctx context.Context, diff --git a/universe/supplycommit/mock.go b/universe/supplycommit/mock.go index ed1d28ebcd..b8216951ef 100644 --- a/universe/supplycommit/mock.go +++ b/universe/supplycommit/mock.go @@ -208,6 +208,12 @@ func (m *mockChainBridge) PublishTransaction(ctx context.Context, return args.Error(0) } +func (m *mockChainBridge) ValidateAndPublishTransaction(_ context.Context, + _ *wire.MsgTx, _ string) error { + + return nil +} + func (m *mockChainBridge) EstimateFee(ctx context.Context, confTarget uint32) (chainfee.SatPerKWeight, error) { From b18a9aa83a8948285c4fb7d9eacea742a3d49264 Mon Sep 17 00:00:00 2001 From: Sadiq Jaffer Date: Fri, 14 Aug 2026 08:24:11 +0100 Subject: [PATCH 06/24] tapgarden: harden custom anchor lifecycle --- lndservices/chain_bridge.go | 36 +++++- lndservices/chain_bridge_test.go | 11 +- rpcserver/rpcserver.go | 12 ++ tapdb/asset_minting.go | 34 +++++- tapdb/asset_minting_test.go | 35 +++++- tapgarden/batch.go | 20 +++- tapgarden/caretaker.go | 66 +++++++++-- tapgarden/custom_anchor_test.go | 70 +++++++++++ tapgarden/interface.go | 2 + tapgarden/issue721_stateful_test.go | 154 ++++++++++++------------ tapgarden/planter.go | 178 ++++++++++++++++++++++++++-- 11 files changed, 512 insertions(+), 106 deletions(-) diff --git a/lndservices/chain_bridge.go b/lndservices/chain_bridge.go index 3564f64fcb..af7e57f52c 100644 --- a/lndservices/chain_bridge.go +++ b/lndservices/chain_bridge.go @@ -364,10 +364,40 @@ func (l *LndRpcChainBridge) ValidateAndPublishTransaction(ctx context.Context, // after relaying the transaction, so they must not restore a cancellable // pre-broadcast state. func isDefinitivePublishError(err error) bool { - errMsg := status.Convert(err).Message() + errMsg := strings.ToLower(status.Convert(err).Message()) + if strings.HasPrefix( + errMsg, strings.ToLower(lnwallet.ErrMempoolFee.Error()), + ) { - return strings.HasPrefix(errMsg, lnwallet.ErrDoubleSpend.Error()) || - strings.HasPrefix(errMsg, lnwallet.ErrMempoolFee.Error()) + return true + } + + // ErrDoubleSpend also represents missing-inputs. A missing parent can + // exist in another peer's mempool, so that collapsed lnd error is not a + // safe basis for making a signed transaction cancellable. + if strings.HasPrefix( + errMsg, strings.ToLower(lnwallet.ErrDoubleSpend.Error()), + ) { + + return false + } + + // These are stable families of policy/consensus reject reasons returned + // directly by TestMempoolAccept. They establish that the backend did not + // relay the transaction. Conflict/missing-input reasons are deliberately + // excluded because their view may differ across mempools. + definitiveRejectMarkers := []string{ + "non-final", "non-bip68-final", "dust", "nonstandard", + "non-mandatory-script-verify-flag", + "mandatory-script-verify-flag", "bad-txns-", "tx-size", + "too many sigops", "too-long-mempool-chain", + "too many ancestors", "too many descendants", + "insufficient fee", "min relay fee", "mempool min fee", + } + + return fn.Any(definitiveRejectMarkers, func(marker string) bool { + return strings.Contains(errMsg, marker) + }) } // EstimateFee returns a fee estimate for the confirmation target. diff --git a/lndservices/chain_bridge_test.go b/lndservices/chain_bridge_test.go index f79b957503..773d5b6dfe 100644 --- a/lndservices/chain_bridge_test.go +++ b/lndservices/chain_bridge_test.go @@ -19,11 +19,11 @@ func TestDefinitivePublishError(t *testing.T) { definitive bool }{ { - name: "spent input", + name: "collapsed missing or spent input is ambiguous", err: status.Error( codes.Unknown, lnwallet.ErrDoubleSpend.Error(), ), - definitive: true, + definitive: false, }, { name: "mempool fee with detail", @@ -33,6 +33,13 @@ func TestDefinitivePublishError(t *testing.T) { ), definitive: true, }, + { + name: "non final transaction", + err: status.Error( + codes.Unknown, "non-final", + ), + definitive: true, + }, { name: "generic application error", err: status.Error(codes.Unknown, "backend failure"), diff --git a/rpcserver/rpcserver.go b/rpcserver/rpcserver.go index 125c86aa0f..4d3c35cbe6 100644 --- a/rpcserver/rpcserver.go +++ b/rpcserver/rpcserver.go @@ -107,6 +107,10 @@ var ( ) const ( + // maxCustomAnchorPsbtSize bounds caller-controlled PSBT work performed + // synchronously by the mint gardener. + maxCustomAnchorPsbtSize = 4 * 1024 * 1024 + // AssetBurnConfirmationText is the text that needs to be set on the // RPC to confirm an asset burn. AssetBurnConfirmationText = "assets will be destroyed" @@ -963,6 +967,10 @@ func (r *RPCServer) FundBatch(ctx context.Context, var anchorPsbt *psbt.Packet if len(req.AnchorPsbt) != 0 { + if len(req.AnchorPsbt) > maxCustomAnchorPsbtSize { + return nil, fmt.Errorf("anchor PSBT exceeds maximum size of %d "+ + "bytes", maxCustomAnchorPsbtSize) + } var err error anchorPsbt, err = psbt.NewFromRawBytes( bytes.NewReader(req.AnchorPsbt), false, @@ -1133,6 +1141,10 @@ func (r *RPCServer) FinalizeBatch(ctx context.Context, var signedPsbt *psbt.Packet if len(req.SignedPsbt) != 0 { + if len(req.SignedPsbt) > maxCustomAnchorPsbtSize { + return nil, fmt.Errorf("signed anchor PSBT exceeds maximum size "+ + "of %d bytes", maxCustomAnchorPsbtSize) + } if req.FeeRate != 0 || req.BatchSibling != nil { return nil, fmt.Errorf("signed_psbt cannot be combined with " + "fee rate or tapscript sibling") diff --git a/tapdb/asset_minting.go b/tapdb/asset_minting.go index 27066d1750..ac74758f44 100644 --- a/tapdb/asset_minting.go +++ b/tapdb/asset_minting.go @@ -1861,7 +1861,8 @@ func (a *AssetMintingStore) AddSproutsToBatch(ctx context.Context, // TODO(roasbeef): or could just re-read assets from disk and set the script // root manually? func (a *AssetMintingStore) CommitSignedGenesisTx(ctx context.Context, - batchKey *btcec.PublicKey, genesisPkt *tapsend.FundedPsbt, + batchKey *btcec.PublicKey, mintingInternalKey keychain.KeyDescriptor, + genesisPkt *tapsend.FundedPsbt, anchorOutputIndex uint32, merkleRoot, tapTreeRoot []byte, tapSibling []byte) error { @@ -1882,6 +1883,10 @@ func (a *AssetMintingStore) CommitSignedGenesisTx(ctx context.Context, genTXID := rawGenTx.TxHash() rawBatchKey := batchKey.SerializeCompressed() + if mintingInternalKey.PubKey == nil { + return fmt.Errorf("minting internal key is missing") + } + rawMintingInternalKey := mintingInternalKey.PubKey.SerializeCompressed() anchorOutput := rawGenTx.TxOut[anchorOutputIndex] anchorPoint := wire.OutPoint{ @@ -1901,6 +1906,16 @@ func (a *AssetMintingStore) CommitSignedGenesisTx(ctx context.Context, var writeTxOpts AssetStoreTxOptions return a.db.ExecTx(ctx, &writeTxOpts, func(q PendingAssetStore) error { + _, err := q.UpsertInternalKey(ctx, InternalKey{ + RawKey: rawMintingInternalKey, + KeyFamily: int32(mintingInternalKey.Family), + KeyIndex: int32(mintingInternalKey.Index), + }) + if err != nil { + return fmt.Errorf("unable to store minting internal key: %w", + err) + } + // First, we'll update the genesis packet stored as part of the // batch, as this packet is now fully signed. pktBytes, err := fn.Serialize(genesisPkt.Pkt) @@ -1933,7 +1948,7 @@ func (a *AssetMintingStore) CommitSignedGenesisTx(ctx context.Context, // this is where all the assets will be anchored within. rootVersion := uint8(commitment.TapCommitmentV2) utxoID, err := q.UpsertManagedUTXO(ctx, RawManagedUTXO{ - RawKey: rawBatchKey, + RawKey: rawMintingInternalKey, Outpoint: anchorOutpoint, AmtSats: anchorOutput.Value, TaprootAssetRoot: tapTreeRoot, @@ -1991,7 +2006,20 @@ func (a *AssetMintingStore) StoreSignedGenesisPsbt(ctx context.Context, rawBatchKey := batchKey.SerializeCompressed() var writeTxOpts AssetStoreTxOptions return a.db.ExecTx(ctx, &writeTxOpts, func(q PendingAssetStore) error { - err := q.UpdateBatchGenesisTx(ctx, GenesisTxUpdate{ + batch, err := q.FetchMintingBatch(ctx, rawBatchKey) + if err != nil { + return fmt.Errorf("unable to fetch batch before storing signed "+ + "packet: %w", err) + } + if tapgarden.BatchState(batch.BatchState) != + tapgarden.BatchStateCommitted { + + return fmt.Errorf("cannot store signed genesis packet for "+ + "batch in state %v", + tapgarden.BatchState(batch.BatchState)) + } + + err = q.UpdateBatchGenesisTx(ctx, GenesisTxUpdate{ RawKey: rawBatchKey, MintingTxPsbt: pktBytes, }) diff --git a/tapdb/asset_minting_test.go b/tapdb/asset_minting_test.go index 925c6ac41e..7c7d54fb1f 100644 --- a/tapdb/asset_minting_test.go +++ b/tapdb/asset_minting_test.go @@ -26,6 +26,7 @@ import ( "github.com/lightninglabs/taproot-assets/proof" "github.com/lightninglabs/taproot-assets/tapdb/sqlc" "github.com/lightninglabs/taproot-assets/tapgarden" + "github.com/lightninglabs/taproot-assets/tappsbt" "github.com/lightninglabs/taproot-assets/tapscript" "github.com/lightninglabs/taproot-assets/tapsend" "github.com/lightningnetwork/lnd/clock" @@ -1037,15 +1038,41 @@ func TestCommitBatchChainActions(t *testing.T) { // // TODO(roasbeef): move the tx extraction up one layer? randAssetCtx.genesisPkt.Pkt.Inputs[0].FinalScriptSig = []byte{} + customInternalKey, _ := test.RandKeyDesc(t) + bip32Derivation, taprootDerivation := + tappsbt.Bip32DerivationFromKeyDesc( + customInternalKey, address.TestNet3Tap.HDCoinType, + ) + randAssetCtx.genesisPkt.Pkt.Outputs[0].Bip32Derivation = + []*psbt.Bip32Derivation{bip32Derivation} + randAssetCtx.genesisPkt.Pkt.Outputs[0].TaprootBip32Derivation = + []*psbt.TaprootBip32Derivation{taprootDerivation} + randAssetCtx.genesisPkt.Pkt.Unknowns = append( + randAssetCtx.genesisPkt.Pkt.Unknowns, &psbt.Unknown{ + Key: []byte{0xfc, 0x04, 't', 'a', 'p', 'd', 0x01}, + Value: []byte{1}, + }, + ) + customOutputKey := txscript.ComputeTaprootOutputKey( + customInternalKey.PubKey, randAssetCtx.merkleRoot, + ) + customOutputScript, err := txscript.PayToTaprootScript(customOutputKey) + require.NoError(t, err) + randAssetCtx.genesisPkt.Pkt.UnsignedTx.TxOut[0].PkScript = + customOutputScript // With our assets inserted, we'll now commit the signed genesis packet // to disk, along with the Taproot Asset script root that's stored // alongside any managed UTXOs. require.NoError(t, assetStore.CommitSignedGenesisTx( - ctx, randAssetCtx.batchKey, randAssetCtx.genesisPkt, 0, + ctx, randAssetCtx.batchKey, customInternalKey, + randAssetCtx.genesisPkt, 0, randAssetCtx.merkleRoot, randAssetCtx.scriptRoot, randAssetCtx.tapSiblingBytes, )) + require.ErrorContains(t, assetStore.StoreSignedGenesisPsbt( + ctx, randAssetCtx.batchKey, randAssetCtx.genesisPkt, + ), "batch in state BatchStateBroadcast") // The batch updated above should be found, with the batch state // updated, and also the genesis transaction updated to match what we @@ -1084,6 +1111,12 @@ func TestCommitBatchChainActions(t *testing.T) { }) require.NoError(t, err) require.Equal(t, randAssetCtx.merkleRoot, managedUTXO.MerkleRoot) + require.Equal( + t, customInternalKey.PubKey.SerializeCompressed(), + managedUTXO.RawKey, + ) + require.Equal(t, int32(customInternalKey.Family), managedUTXO.KeyFamily) + require.Equal(t, int32(customInternalKey.Index), managedUTXO.KeyIndex) require.Equal(t, randAssetCtx.scriptRoot, managedUTXO.TaprootAssetRoot) require.Equal( t, randAssetCtx.tapSiblingBytes, managedUTXO.TapscriptSibling, diff --git a/tapgarden/batch.go b/tapgarden/batch.go index 5805ec82aa..7b2ffd170b 100644 --- a/tapgarden/batch.go +++ b/tapgarden/batch.go @@ -14,6 +14,7 @@ import ( "github.com/lightninglabs/taproot-assets/commitment" "github.com/lightninglabs/taproot-assets/fn" "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightninglabs/taproot-assets/tappsbt" "github.com/lightninglabs/taproot-assets/tapscript" "github.com/lightningnetwork/lnd/keychain" ) @@ -220,9 +221,22 @@ func (m *MintingBatch) MintingInternalKey() (*btcec.PublicKey, error) { "anchor output map") } - internalKey, err := schnorr.ParsePubKey( - pkt.Outputs[anchorIdx].TaprootInternalKey, - ) + pOut := pkt.Outputs[anchorIdx] + if len(pOut.Bip32Derivation) == 1 { + keyDesc, err := tappsbt.KeyDescFromBip32Derivation( + pOut.Bip32Derivation[0], + ) + if err != nil { + return nil, fmt.Errorf("invalid custom anchor key derivation: %w", + err) + } + + return keyDesc.PubKey, nil + } + + // Retain compatibility with custom batches prepared by earlier builds, + // which persisted only the BIP-371 internal-key field. + internalKey, err := schnorr.ParsePubKey(pOut.TaprootInternalKey) if err != nil { return nil, fmt.Errorf("invalid custom anchor internal key: %w", err) diff --git a/tapgarden/caretaker.go b/tapgarden/caretaker.go index e5001f420c..7a86587677 100644 --- a/tapgarden/caretaker.go +++ b/tapgarden/caretaker.go @@ -22,6 +22,7 @@ import ( "github.com/lightninglabs/taproot-assets/fn" "github.com/lightninglabs/taproot-assets/proof" "github.com/lightninglabs/taproot-assets/tapnode" + "github.com/lightninglabs/taproot-assets/tappsbt" "github.com/lightninglabs/taproot-assets/tapsend" "github.com/lightninglabs/taproot-assets/universe" "github.com/lightningnetwork/lnd/chainntnfs" @@ -224,6 +225,15 @@ func (b *BatchCaretaker) Cancel() error { cancelResp = CancelResp{true, err} case BatchStateCommitted: + if customAnchorPublicationPending(b.cfg.Batch) { + + err := fmt.Errorf("BatchCaretaker(%x), custom anchor "+ + "publication status is ambiguous and is not cancellable", + batchKey) + cancelResp = CancelResp{false, err} + break + } + err := b.cfg.Log.UpdateBatchState( ctx, b.cfg.Batch.BatchKey.PubKey, BatchStateSproutCancelled, @@ -656,6 +666,16 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) genesisTxPkt.UnsignedTx. TxOut[b.anchorOutputIndex].PkScript = genesisScript + if isCustomAnchorPsbt(genesisTxPkt) { + // Once the asset root changes the output script, an internal + // key without a matching PSBT_OUT_TAP_TREE would describe a + // contradictory BIP-86 output. Keep the independently validated + // BIP32 locator for wallet bookkeeping, but remove the stale + // BIP-371 output declarations before external signing. + pOut := &genesisTxPkt.Outputs[b.anchorOutputIndex] + pOut.TaprootInternalKey = nil + pOut.TaprootBip32Derivation = nil + } log.Infof("BatchCaretaker(%x): committing sprouts to disk", b.batchKey[:]) @@ -759,7 +779,13 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) log.Infof("BatchCaretaker(%x): GenesisPacket finalized "+ "(absolute_fee_sats: %d)", b.batchKey[:], chainFees) - log.Tracef("GenesisPacket: %v", spew.Sdump(signedPkt)) + if isCustomAnchorPsbt(signedPkt) { + log.Tracef("Custom GenesisPacket finalized: txid=%v, "+ + "inputs=%d, outputs=%d", signedTx.TxHash(), + len(signedTx.TxIn), len(signedTx.TxOut)) + } else { + log.Tracef("GenesisPacket: %v", spew.Sdump(signedPkt)) + } // At this point we have a fully signed PSBT packet which'll // create our set of assets once mined. We'll write this to @@ -842,9 +868,25 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) // Taproot Asset commitment root and batch tapscript sibling. tapCommitmentRoot := b.cfg.Batch.RootAssetCommitment. TapscriptRoot(nil) + mintingInternalKey := b.cfg.Batch.BatchKey + if isCustomAnchorPsbt(signedPkt) { + pOut := signedPkt.Outputs[b.anchorOutputIndex] + if len(pOut.Bip32Derivation) != 1 { + return 0, fmt.Errorf("custom anchor output must have " + + "exactly one BIP32 derivation") + } + mintingInternalKey, err = tappsbt.KeyDescFromBip32Derivation( + pOut.Bip32Derivation[0], + ) + if err != nil { + return 0, fmt.Errorf("unable to derive custom anchor "+ + "internal key: %w", err) + } + } err = b.cfg.Log.CommitSignedGenesisTx( ctx, b.cfg.Batch.BatchKey.PubKey, + mintingInternalKey, &b.cfg.Batch.GenesisPacket.FundedPsbt, b.anchorOutputIndex, merkleRoot, tapCommitmentRoot[:], siblingBytes, @@ -877,15 +919,13 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) return 0, fmt.Errorf("unable to import key: %w", err) } - // If validation failed ambiguously, the transaction might still - // have reached the backend. The durable signed transaction and - // Broadcast state make an exact retry safe after this caretaker - // exits. + // If the initial submission failed ambiguously, the transaction + // might still have reached the backend. Continue into Broadcast so + // the normal retry policy runs and a confirmation watcher is + // installed instead of silently leaving the batch unattended. if publishErr != nil { - b.cfg.Batch.UpdateState(BatchStateBroadcast) - - return 0, fmt.Errorf("unable to publish transaction: %w", - publishErr) + log.Warnf("Initial custom anchor publication was ambiguous, "+ + "retrying from Broadcast: %v", publishErr) } log.Infof("BatchCaretaker(%x): transition states: %v -> %v", @@ -908,7 +948,13 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) log.Infof("BatchCaretaker(%x): extracted finalized GenesisTx", b.batchKey[:]) - log.Tracef("GenesisTx: %v", spew.Sdump(signedTx)) + if isCustomAnchorPsbt(b.cfg.Batch.GenesisPacket.Pkt) { + log.Tracef("Custom GenesisTx: txid=%v, inputs=%d, "+ + "outputs=%d", signedTx.TxHash(), len(signedTx.TxIn), + len(signedTx.TxOut)) + } else { + log.Tracef("GenesisTx: %v", spew.Sdump(signedTx)) + } // With the final transaction extracted, we'll broadcast the // transaction, then request a confirmation notification. diff --git a/tapgarden/custom_anchor_test.go b/tapgarden/custom_anchor_test.go index 6185b439fb..f1a0b77196 100644 --- a/tapgarden/custom_anchor_test.go +++ b/tapgarden/custom_anchor_test.go @@ -12,8 +12,11 @@ import ( "github.com/btcsuite/btcd/txscript/v2" "github.com/btcsuite/btcd/wire/v2" "github.com/lightninglabs/taproot-assets/address" + "github.com/lightninglabs/taproot-assets/asset" "github.com/lightninglabs/taproot-assets/fn" + "github.com/lightninglabs/taproot-assets/tappsbt" "github.com/lightninglabs/taproot-assets/tapsend" + "github.com/lightningnetwork/lnd/keychain" "github.com/stretchr/testify/require" ) @@ -36,6 +39,22 @@ func testCustomAnchorPacket(t *testing.T) *psbt.Packet { Value: 2_000, PkScript: pkScript, } pkt.Outputs[0].TaprootInternalKey = schnorr.SerializePubKey(pub) + keyDesc := keychain.KeyDescriptor{ + KeyLocator: keychain.KeyLocator{ + Family: asset.TaprootAssetsKeyFamily, + Index: 1, + }, + PubKey: pub, + } + bip32Derivation, taprootDerivation := + tappsbt.Bip32DerivationFromKeyDesc( + keyDesc, address.TestNet3Tap.HDCoinType, + ) + pkt.Outputs[0].Bip32Derivation = []*psbt.Bip32Derivation{ + bip32Derivation, + } + pkt.Outputs[0].TaprootBip32Derivation = + []*psbt.TaprootBip32Derivation{taprootDerivation} pkt.Unknowns = []*psbt.Unknown{{Key: []byte{0x50}, Value: []byte("x")}} return pkt @@ -50,6 +69,29 @@ func TestCustomGenesisPsbtValidation(t *testing.T) { require.True(t, isCustomAnchorPsbt(funded.Pkt)) require.True(t, funded.GenesisOutpoint().IsSome()) + missingDerivation := testCustomAnchorPacket(t) + missingDerivation.Outputs[0].Bip32Derivation = nil + missingDerivation.Outputs[0].TaprootBip32Derivation = nil + _, err = customGenesisPsbt( + address.TestNet3Tap, nil, missingDerivation, 0, -1, + noneUint32(), + ) + require.ErrorContains(t, err, "exactly one BIP32") + + forgedMarker := testCustomAnchorPacket(t) + forgedMarker.Unknowns = append(forgedMarker.Unknowns, &psbt.Unknown{ + Key: fn.CopySlice(customAnchorPublishMarker), + Value: []byte{1}, + }) + funded, err = customGenesisPsbt( + address.TestNet3Tap, nil, forgedMarker, 0, -1, noneUint32(), + ) + require.NoError(t, err) + require.Equal( + t, customAnchorPublishNone, + getCustomAnchorPublishState(funded.Pkt), + ) + bad := testCustomAnchorPacket(t) bad.Inputs = nil _, err = customGenesisPsbt( @@ -162,6 +204,32 @@ func TestCustomGenesisPsbtValidation(t *testing.T) { require.NoError(t, err) } +func TestFinalizedAnchorPsbtBoundsWitnessCount(t *testing.T) { + pkt := testCustomAnchorPacket(t) + pkt.Inputs[0].FinalScriptWitness = append( + []byte{0xff}, bytes.Repeat([]byte{0xff}, 8)..., + ) + + require.ErrorContains( + t, validateFinalizedAnchorPsbt(pkt), "witness item count", + ) +} + +func TestCustomAnchorPublicationPending(t *testing.T) { + pkt := testCustomAnchorPacket(t) + markCustomAnchorPsbt(pkt) + setCustomAnchorPublishState(pkt, customAnchorPublishPending) + batch := &MintingBatch{ + GenesisPacket: &FundedMintAnchorPsbt{ + FundedPsbt: tapsend.FundedPsbt{Pkt: pkt}, + }, + } + require.True(t, customAnchorPublicationPending(batch)) + + setCustomAnchorPublishState(pkt, customAnchorPublishRejected) + require.False(t, customAnchorPublicationPending(batch)) +} + func TestMergeSignedCustomPsbt(t *testing.T) { stored := testCustomAnchorPacket(t) markCustomAnchorPsbt(stored) @@ -169,6 +237,7 @@ func TestMergeSignedCustomPsbt(t *testing.T) { signed.Inputs[0].FinalScriptSig = []byte{} signed.Inputs[0].FinalScriptWitness = []byte{1, 0} signed.Inputs[0].TaprootInternalKey = nil + stripTapdCustomAnchorMarkers(signed) merged, err := mergeSignedCustomPsbt(stored, signed) require.NoError(t, err) @@ -176,6 +245,7 @@ func TestMergeSignedCustomPsbt(t *testing.T) { require.NotEmpty(t, merged.Inputs[0].FinalScriptWitness) require.Equal(t, stored.Inputs[0].WitnessUtxo, merged.Inputs[0].WitnessUtxo) + require.True(t, isCustomAnchorPsbt(merged)) mutated := clonePsbt(t, signed) mutated.Inputs[0].WitnessUtxo.Value++ diff --git a/tapgarden/interface.go b/tapgarden/interface.go index d5454ba34e..a6f6e921d1 100644 --- a/tapgarden/interface.go +++ b/tapgarden/interface.go @@ -13,6 +13,7 @@ import ( "github.com/lightninglabs/taproot-assets/fn" "github.com/lightninglabs/taproot-assets/proof" "github.com/lightninglabs/taproot-assets/tapsend" + "github.com/lightningnetwork/lnd/keychain" ) const ( @@ -256,6 +257,7 @@ type MintingStore interface { // NOTE: The BatchState should transition to the BatchStateBroadcast // state upon a successful call. CommitSignedGenesisTx(ctx context.Context, batchKey *btcec.PublicKey, + mintingInternalKey keychain.KeyDescriptor, genesisTx *tapsend.FundedPsbt, anchorOutputIndex uint32, merkleRoot, tapTreeRoot, tapSibling []byte) error diff --git a/tapgarden/issue721_stateful_test.go b/tapgarden/issue721_stateful_test.go index 80487fcb88..934e621307 100644 --- a/tapgarden/issue721_stateful_test.go +++ b/tapgarden/issue721_stateful_test.go @@ -12,11 +12,15 @@ import ( "github.com/btcsuite/btcd/psbt/v2" "github.com/btcsuite/btcd/txscript/v2" "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/taproot-assets/address" "github.com/lightninglabs/taproot-assets/asset" "github.com/lightninglabs/taproot-assets/fn" "github.com/lightninglabs/taproot-assets/proof" "github.com/lightninglabs/taproot-assets/tapgarden" + "github.com/lightninglabs/taproot-assets/tappsbt" + "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -68,6 +72,22 @@ func issue721Anchor(t *testing.T) (*psbt.Packet, *btcec.PublicKey, []byte) { Key: []byte{0x51}, Value: []byte("output-metadata"), }} pkt.Outputs[1].TaprootInternalKey = schnorr.SerializePubKey(internalKey) + keyDesc := keychain.KeyDescriptor{ + KeyLocator: keychain.KeyLocator{ + Family: asset.TaprootAssetsKeyFamily, + Index: 721, + }, + PubKey: internalKey, + } + bip32Derivation, taprootDerivation := + tappsbt.Bip32DerivationFromKeyDesc( + keyDesc, address.TestNet3Tap.HDCoinType, + ) + pkt.Outputs[1].Bip32Derivation = []*psbt.Bip32Derivation{ + bip32Derivation, + } + pkt.Outputs[1].TaprootBip32Derivation = + []*psbt.TaprootBip32Derivation{taprootDerivation} pkt.Outputs[1].Unknowns = []*psbt.Unknown{{ Key: []byte{0x52}, Value: []byte("anchor-metadata"), }} @@ -92,6 +112,14 @@ func issue721Fund(t *testing.T, h *mintingTestHarness, pkt *psbt.Packet) *tapgarden.MintingBatch { t.Helper() + anchorPriv, _ := btcec.PrivKeyFromBytes(bytes.Repeat([]byte{7}, 32)) + h.keyRing.Keys[keychain.KeyLocator{ + Family: asset.TaprootAssetsKeyFamily, + Index: 721, + }] = anchorPriv + h.keyRing.On( + "IsLocalKey", mock.Anything, mock.Anything, + ).Maybe() resp, err := h.planter.FundBatch(tapgarden.FundParams{ FeeRate: fn.None[chainfee.SatPerKWeight](), SiblingTapTree: fn.None[asset.TapscriptTreeNodes](), @@ -128,6 +156,30 @@ func issue721FinalWitnessWithItem(t *testing.T, item, return buf.Bytes() } +func TestIssue721RejectsNonLocalAnchorKey(t *testing.T) { + store := newMintingStore(t) + h := newMintingTestHarness(t, store) + h.refreshChainPlanter() + t.Cleanup(func() { + _ = h.planter.Stop() + }) + + h.queueSeedlingsInBatch(false, issue721Seedling()) + pkt, _, _ := issue721Anchor(t) + h.keyRing.On( + "IsLocalKey", mock.Anything, mock.Anything, + ).Maybe() + _, err := h.planter.FundBatch(tapgarden.FundParams{ + FeeRate: fn.None[chainfee.SatPerKWeight](), + SiblingTapTree: fn.None[asset.TapscriptTreeNodes](), + AnchorPsbt: pkt, + AssetAnchorOutIdx: 1, + ChangeOutputIndex: -1, + PreCommitOutputIndex: fn.None[uint32](), + }) + require.ErrorContains(t, err, "not controlled by the backing wallet") +} + // TestIssue721PrepareSignResume exercises the complete caller-owned anchor // boundary: custom funding, synchronous commitment, restart pause, failed // external witness retry, and successful broadcast with the original input and @@ -170,7 +222,13 @@ func TestIssue721PrepareSignResume(t *testing.T) { require.NotEqual(t, original.UnsignedTx.TxOut[1].PkScript, preparedPkt.UnsignedTx.TxOut[1].PkScript) require.Equal(t, original.Inputs, preparedPkt.Inputs) - require.Equal(t, original.Outputs, preparedPkt.Outputs) + require.Equal(t, original.Outputs[0], preparedPkt.Outputs[0]) + require.Equal(t, original.Outputs[1].Bip32Derivation, + preparedPkt.Outputs[1].Bip32Derivation) + require.Equal(t, original.Outputs[1].Unknowns, + preparedPkt.Outputs[1].Unknowns) + require.Nil(t, preparedPkt.Outputs[1].TaprootInternalKey) + require.Nil(t, preparedPkt.Outputs[1].TaprootBip32Derivation) gotInternalKey, err := prepared.MintingInternalKey() require.NoError(t, err) @@ -241,18 +299,9 @@ func TestIssue721PrepareSignResume(t *testing.T) { require.Equal(t, tapgarden.BatchStateBroadcast, minted.State()) } -// TestIssue721PublishRetry verifies a backend publication error leaves a -// caller-authored batch durably available for retry without re-preparation. +// TestIssue721PublishRetry verifies a one-shot ambiguous initial submission is +// retried automatically from Broadcast and installs a confirmation watcher. func TestIssue721PublishRetry(t *testing.T) { - t.Run("same process", func(t *testing.T) { - testIssue721PublishRetry(t, false) - }) - t.Run("after restart", func(t *testing.T) { - testIssue721PublishRetry(t, true) - }) -} - -func testIssue721PublishRetry(t *testing.T, restartBeforeRetry bool) { store := newMintingStore(t) h := newMintingTestHarness(t, store) h.refreshChainPlanter() @@ -279,73 +328,14 @@ func testIssue721PublishRetry(t *testing.T, restartBeforeRetry bool) { h.finalizeBatch(&wg, respChan, &tapgarden.FinalizeParams{ SignedPsbt: signed, }) - _, err = fn.RecvOrTimeout( - h.wallet.ImportPubKeySignal, defaultTimeout, - ) - require.NoError(t, err) - h.assertFinalizeBatch(&wg, respChan, "failed to publish transaction") firstAttempt, err := fn.RecvOrTimeout( h.chain.PublishAttempts, defaultTimeout, ) require.NoError(t, err) - - pending, err := h.planter.PendingBatch() - require.NoError(t, err) - require.Equal(t, tapgarden.BatchStateBroadcast, pending.State()) - persisted := h.fetchSingleBatch(pending.BatchKey.PubKey) - require.Equal(t, tapgarden.BatchStateBroadcast, persisted.State()) - _, err = h.planter.CancelBatch() - require.ErrorContains(t, err, "not cancellable") - - if restartBeforeRetry { - // A broadcast batch resumes automatically after restart without - // returning to an externally cancellable pre-broadcast state. - h.refreshChainPlanter() - published := h.assertTxPublished() - require.Equal( - t, (*firstAttempt).WitnessHash(), published.WitnessHash(), - ) - _, err = fn.RecvOrTimeout(h.chain.ConfReqSignal, defaultTimeout) - require.NoError(t, err) - h.assertNumCaretakersActive(1) - persisted = h.fetchSingleBatch(pending.BatchKey.PubKey) - require.Equal(t, tapgarden.BatchStateBroadcast, persisted.State()) - - return - } - - // A Broadcast retry must not replace the transaction that was already - // persisted before the ambiguous publication failure. Both witnesses are - // valid and have the same txid, but the larger witness has a different - // wtxid and weight. - changedRetry := clonePacket(t, pending.GenesisPacket.Pkt) - changedRetry.Inputs[0].FinalScriptWitness = - issue721FinalWitnessWithItem( - t, bytes.Repeat([]byte{0x02}, 500), witnessScript, - ) - changedTx, err := psbt.Extract(changedRetry) - require.NoError(t, err) - require.Equal(t, (*firstAttempt).TxHash(), changedTx.TxHash()) - require.NotEqual(t, (*firstAttempt).WitnessHash(), changedTx.WitnessHash()) - - _, err = h.planter.FinalizeBatch(tapgarden.FinalizeParams{ - SignedPsbt: changedRetry, - }) - require.ErrorContains( - t, err, "broadcast retry changes finalized transaction", + _, err = fn.RecvOrTimeout( + h.wallet.ImportPubKeySignal, defaultTimeout, ) - unchanged, err := h.planter.PendingBatch() - require.NoError(t, err) - unchangedTx, err := psbt.Extract(unchanged.GenesisPacket.Pkt) require.NoError(t, err) - require.Equal( - t, (*firstAttempt).WitnessHash(), unchangedTx.WitnessHash(), - ) - - retry := clonePacket(t, pending.GenesisPacket.Pkt) - h.finalizeBatch(&wg, respChan, &tapgarden.FinalizeParams{ - SignedPsbt: retry, - }) published := h.assertTxPublished() require.Equal( t, (*firstAttempt).WitnessHash(), published.WitnessHash(), @@ -458,7 +448,7 @@ func TestIssue721ConfirmationRegistrationRetry(t *testing.T) { h.wallet.ImportPubKeySignal, defaultTimeout, ) require.NoError(t, err) - h.assertTxPublished() + published := h.assertTxPublished() h.assertFinalizeBatch( &wg, respChan, "failed to register confirmation", ) @@ -466,6 +456,22 @@ func TestIssue721ConfirmationRegistrationRetry(t *testing.T) { pending, err := h.planter.PendingBatch() require.NoError(t, err) require.Equal(t, tapgarden.BatchStateBroadcast, pending.State()) + changedRetry := clonePacket(t, pending.GenesisPacket.Pkt) + changedRetry.Inputs[0].FinalScriptWitness = + issue721FinalWitnessWithItem( + t, bytes.Repeat([]byte{0x02}, 500), witnessScript, + ) + changedTx, err := psbt.Extract(changedRetry) + require.NoError(t, err) + require.Equal(t, published.TxHash(), changedTx.TxHash()) + require.NotEqual(t, published.WitnessHash(), changedTx.WitnessHash()) + _, err = h.planter.FinalizeBatch(tapgarden.FinalizeParams{ + SignedPsbt: changedRetry, + }) + require.ErrorContains( + t, err, "broadcast retry changes finalized transaction", + ) + retry := clonePacket(t, pending.GenesisPacket.Pkt) h.finalizeBatch(&wg, respChan, &tapgarden.FinalizeParams{ SignedPsbt: retry, diff --git a/tapgarden/planter.go b/tapgarden/planter.go index 7d0d082854..43658b54da 100644 --- a/tapgarden/planter.go +++ b/tapgarden/planter.go @@ -362,6 +362,9 @@ const ( customAnchorPublishNone customAnchorPublishState = iota customAnchorPublishPending customAnchorPublishRejected + + maxFinalScriptWitnessSize = 4_000_000 + maxFinalWitnessItems = 100_000 ) func markCustomAnchorPsbt(packet *psbt.Packet) { @@ -440,6 +443,30 @@ func stripCustomAnchorPublishState(packet *psbt.Packet) { setCustomAnchorPublishState(packet, customAnchorPublishNone) } +func stripTapdCustomAnchorMarkers(packet *psbt.Packet) { + if packet == nil { + return + } + + unknowns := packet.Unknowns[:0] + for _, unknown := range packet.Unknowns { + if bytes.Equal(unknown.Key, customAnchorPsbtMarker) || + bytes.Equal(unknown.Key, customAnchorPublishMarker) { + + continue + } + unknowns = append(unknowns, unknown) + } + packet.Unknowns = unknowns +} + +func customAnchorPublicationPending(batch *MintingBatch) bool { + return batch != nil && batch.GenesisPacket != nil && + isCustomAnchorPsbt(batch.GenesisPacket.Pkt) && + getCustomAnchorPublishState(batch.GenesisPacket.Pkt) == + customAnchorPublishPending +} + // PendingGroupWitness specifies the asset group witness for an asset seedling // in an unsealed minting batch. type PendingGroupWitness struct { @@ -1332,6 +1359,10 @@ func customGenesisPsbt(chainParams address.ChainParams, return zero, fmt.Errorf("custom anchor PSBT is missing its " + "unsigned transaction") } + // Caller-supplied packets can't set tapd's private lifecycle markers. + // They are added only after the packet passes validation and enters the + // corresponding durable state. + stripTapdCustomAnchorMarkers(packet) if len(packet.UnsignedTx.TxIn) == 0 { return zero, fmt.Errorf("custom anchor PSBT must have at least " + "one input") @@ -1344,7 +1375,7 @@ func customGenesisPsbt(chainParams address.ChainParams, return zero, fmt.Errorf("custom anchor PSBT output maps don't " + "match unsigned transaction outputs") } - if int(assetAnchorOutIdx) >= len(packet.UnsignedTx.TxOut) { + if uint64(assetAnchorOutIdx) >= uint64(len(packet.UnsignedTx.TxOut)) { return zero, fmt.Errorf("asset anchor output index %d out of "+ "range", assetAnchorOutIdx) } @@ -1403,6 +1434,12 @@ func customGenesisPsbt(chainParams address.ChainParams, return zero, fmt.Errorf("invalid custom asset anchor internal "+ "key: %w", err) } + if _, err := customAnchorKeyDesc( + chainParams, packet, assetAnchorOutIdx, + ); err != nil { + + return zero, err + } if pOut.TaprootTapTree != nil { return zero, fmt.Errorf("custom asset anchor output must not " + "specify a PSBT tap tree; use the batch sibling fields") @@ -1423,7 +1460,7 @@ func customGenesisPsbt(chainParams address.ChainParams, if err != nil { return zero, err } - if int(idx) >= len(packet.UnsignedTx.TxOut) || + if uint64(idx) >= uint64(len(packet.UnsignedTx.TxOut)) || idx == assetAnchorOutIdx || idx == uint32(changeOutputIndex) { return zero, fmt.Errorf("invalid pre-commitment output "+ @@ -1492,6 +1529,59 @@ func customGenesisPsbt(chainParams address.ChainParams, ) } +// customAnchorKeyDesc extracts the lnd key locator that controls a caller's +// selected anchor internal key. Both BIP32 output records are required and +// cross-checked so a random point or forged locator can't be persisted as a +// wallet-managed output. +func customAnchorKeyDesc(chainParams address.ChainParams, packet *psbt.Packet, + assetAnchorOutIdx uint32) (keychain.KeyDescriptor, error) { + + var zero keychain.KeyDescriptor + if packet == nil || uint64(assetAnchorOutIdx) >= + uint64(len(packet.Outputs)) { + + return zero, fmt.Errorf("custom anchor output metadata is missing") + } + + pOut := packet.Outputs[assetAnchorOutIdx] + if len(pOut.Bip32Derivation) != 1 || + len(pOut.TaprootBip32Derivation) != 1 { + + return zero, fmt.Errorf("custom asset anchor output must specify " + + "exactly one BIP32 and Taproot BIP32 derivation") + } + + bip32Derivation := pOut.Bip32Derivation[0] + taprootDerivation := pOut.TaprootBip32Derivation[0] + desc, err := tappsbt.KeyDescFromBip32Derivation(bip32Derivation) + if err != nil { + return zero, fmt.Errorf("invalid custom anchor key derivation: %w", + err) + } + + expectedBip32, expectedTaproot := tappsbt.Bip32DerivationFromKeyDesc( + desc, chainParams.HDCoinType, + ) + if !bytes.Equal(expectedBip32.PubKey, bip32Derivation.PubKey) || + !slices.Equal(expectedBip32.Bip32Path, bip32Derivation.Bip32Path) || + !bytes.Equal( + expectedTaproot.XOnlyPubKey, + taprootDerivation.XOnlyPubKey, + ) || !slices.Equal( + expectedTaproot.Bip32Path, taprootDerivation.Bip32Path, + ) || len(taprootDerivation.LeafHashes) != 0 || + !bytes.Equal( + pOut.TaprootInternalKey, + taprootDerivation.XOnlyPubKey, + ) { + + return zero, fmt.Errorf("custom anchor key derivation doesn't " + + "match its internal key or network path") + } + + return desc, nil +} + // validateExclusionProofOutputs ensures every non-anchor P2TR output can be // represented in the minting proofs. Doing this before broadcast prevents an // otherwise valid mint from becoming unprovable only after confirmation. @@ -2211,10 +2301,7 @@ func (c *ChainPlanter) cancelMintingBatch(ctx context.Context, cancelState = BatchStateSeedlingCancelled case BatchStateCommitted: - if isCustomAnchorPsbt(c.pendingBatch.GenesisPacket.Pkt) && - getCustomAnchorPublishState( - c.pendingBatch.GenesisPacket.Pkt, - ) == customAnchorPublishPending { + if customAnchorPublicationPending(c.pendingBatch) { return fmt.Errorf("custom anchor publication status is " + "ambiguous and is not cancellable") @@ -2586,6 +2673,18 @@ func (c *ChainPlanter) fundBatch(ctx context.Context, params FundParams, computeFunding := func(batch *MintingBatch) ( *FundedMintAnchorPsbt, error) { if params.AnchorPsbt != nil { + anchorKeyDesc, err := customAnchorKeyDesc( + c.cfg.ChainParams, params.AnchorPsbt, + params.AssetAnchorOutIdx, + ) + if err != nil { + return nil, err + } + if !c.cfg.KeyRing.IsLocalKey(ctx, anchorKeyDesc) { + return nil, fmt.Errorf("custom asset anchor internal " + + "key is not controlled by the backing wallet") + } + funded, err := customGenesisPsbt( c.cfg.ChainParams, batch, params.AnchorPsbt, params.AssetAnchorOutIdx, @@ -3136,8 +3235,8 @@ func mergeSignedCustomPsbt(stored, // Publication state is internal recovery metadata added after the // caller signs. It isn't part of the prepared transaction contract and // therefore isn't required in a resubmitted external packet. - stripCustomAnchorPublishState(storedNorm) - stripCustomAnchorPublishState(signedNorm) + stripTapdCustomAnchorMarkers(storedNorm) + stripTapdCustomAnchorMarkers(signedNorm) if !reflect.DeepEqual(storedNorm.UnsignedTx, signedNorm.UnsignedTx) || !reflect.DeepEqual(storedNorm.Outputs, signedNorm.Outputs) || !reflect.DeepEqual(storedNorm.XPubs, signedNorm.XPubs) || @@ -3241,6 +3340,17 @@ func mergeSignedCustomPsbt(stored, // caretaker persists a broadcast state. This keeps a malformed external // witness retryable at the prepared state. func validateFinalizedAnchorPsbt(packet *psbt.Packet) error { + for idx := range packet.Inputs { + witness := packet.Inputs[idx].FinalScriptWitness + if witness == nil { + continue + } + if err := validateFinalScriptWitness(witness); err != nil { + return fmt.Errorf("invalid final witness for input %d: %w", + idx, err) + } + } + tx, err := psbt.Extract(packet) if err != nil { return err @@ -3293,6 +3403,45 @@ func validateFinalizedAnchorPsbt(packet *psbt.Packet) error { return nil } +func validateFinalScriptWitness(serialized []byte) error { + if len(serialized) > maxFinalScriptWitnessSize { + return fmt.Errorf("serialized witness exceeds %d bytes", + maxFinalScriptWitnessSize) + } + + reader := bytes.NewReader(serialized) + itemCount, err := wire.ReadVarInt(reader, 0) + if err != nil { + return fmt.Errorf("unable to read witness item count: %w", err) + } + if itemCount > maxFinalWitnessItems { + return fmt.Errorf("witness item count %d exceeds limit %d", + itemCount, maxFinalWitnessItems) + } + + for itemIdx := uint64(0); itemIdx < itemCount; itemIdx++ { + itemSize, err := wire.ReadVarInt(reader, 0) + if err != nil { + return fmt.Errorf("unable to read witness item %d size: %w", + itemIdx, err) + } + if itemSize > uint64(reader.Len()) { + return fmt.Errorf("witness item %d exceeds serialized data", + itemIdx) + } + if _, err := reader.Seek(int64(itemSize), 1); err != nil { + return fmt.Errorf("unable to skip witness item %d: %w", + itemIdx, err) + } + } + if reader.Len() != 0 { + return fmt.Errorf("serialized witness has %d trailing bytes", + reader.Len()) + } + + return nil +} + // validateCustomAnchorFeeRate ensures an externally signed anchor transaction // meets the backing node's current minimum relay fee before the batch is moved // into its irreversible broadcast state. @@ -4230,10 +4379,19 @@ func (f *FundedMintAnchorPsbt) Copy() *FundedMintAnchorPsbt { newMintAnchorPsbt.Pkt = copyMalformedPsbt(f.Pkt) } else { var buf bytes.Buffer - if err := f.Pkt.Serialize(&buf); err == nil { - newMintAnchorPsbt.Pkt, _ = psbt.NewFromRawBytes( + serializeErr := f.Pkt.Serialize(&buf) + if serializeErr == nil { + var parseErr error + newMintAnchorPsbt.Pkt, parseErr = psbt.NewFromRawBytes( &buf, false, ) + if parseErr != nil { + log.Warnf("Unable to parse serialized mint anchor "+ + "PSBT while copying: %v", parseErr) + } + } else { + log.Warnf("Unable to serialize mint anchor PSBT while "+ + "copying: %v", serializeErr) } if newMintAnchorPsbt.Pkt == nil { newMintAnchorPsbt.Pkt = copyMalformedPsbt(f.Pkt) From 263d28a1d1ddd70e235f5d89dd30b1212dc4e347 Mon Sep 17 00:00:00 2001 From: Sadiq Jaffer Date: Fri, 14 Aug 2026 10:33:34 +0100 Subject: [PATCH 07/24] tapgarden: close custom anchor lifecycle gaps --- lndservices/custom_anchor_lease_test.go | 53 +++ lndservices/wallet_anchor.go | 86 ++++ tapdb/asset_minting.go | 3 +- tapgarden/caretaker.go | 95 +++-- tapgarden/issue721_stateful_test.go | 523 +++++++++++++++++++++++- tapgarden/planter.go | 410 ++++++++++++++++++- tapgarden/planter_test.go | 9 +- tapnode/tapnodemock/wallet_anchor.go | 34 ++ tapnode/wallet_anchor.go | 8 + 9 files changed, 1174 insertions(+), 47 deletions(-) create mode 100644 lndservices/custom_anchor_lease_test.go diff --git a/lndservices/custom_anchor_lease_test.go b/lndservices/custom_anchor_lease_test.go new file mode 100644 index 0000000000..fa44d4ca75 --- /dev/null +++ b/lndservices/custom_anchor_lease_test.go @@ -0,0 +1,53 @@ +package lndservices + +import ( + "context" + "crypto/sha256" + "testing" + "time" + + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btcwallet/wtxmgr" + "github.com/lightninglabs/lndclient" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/stretchr/testify/require" +) + +type customAnchorWalletKitMock struct { + lndclient.WalletKitClient + + leases []lndclient.LeaseDescriptor + listUnspentCalled bool +} + +func (m *customAnchorWalletKitMock) ListLeases( + context.Context) ([]lndclient.LeaseDescriptor, error) { + + return m.leases, nil +} + +func (m *customAnchorWalletKitMock) ListUnspent(context.Context, int32, + int32, ...lndclient.ListUnspentOption) ([]*lnwallet.Utxo, error) { + + m.listUnspentCalled = true + return nil, nil +} + +func TestCustomAnchorLeaseRejectsForeignLock(t *testing.T) { + var op wire.OutPoint + op.Index = 7 + foreignID := wtxmgr.LockID(sha256.Sum256([]byte("another-subsystem"))) + walletKit := &customAnchorWalletKitMock{ + leases: []lndclient.LeaseDescriptor{{ + LockID: foreignID, Outpoint: op, Expiration: time.Now().Add(time.Hour), + }}, + } + wallet := NewLndRpcWalletAnchor(&lndclient.LndServices{ + WalletKit: walletKit, + }) + + owned, err := wallet.LeaseInput(t.Context(), op) + require.False(t, owned) + require.ErrorContains(t, err, "already leased by another subsystem") + require.False(t, walletKit.listUnspentCalled) +} diff --git a/lndservices/wallet_anchor.go b/lndservices/wallet_anchor.go index b7830f70ce..3fc6a016fb 100644 --- a/lndservices/wallet_anchor.go +++ b/lndservices/wallet_anchor.go @@ -2,8 +2,10 @@ package lndservices import ( "context" + "crypto/sha256" "fmt" "math" + "time" btcaddr "github.com/btcsuite/btcd/address/v2" "github.com/btcsuite/btcd/btcec/v2" @@ -11,6 +13,7 @@ import ( "github.com/btcsuite/btcd/psbt/v2" "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/wtxmgr" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/taproot-assets/fn" "github.com/lightninglabs/taproot-assets/tapfreighter" @@ -81,8 +84,17 @@ const ( // defaultChangeType is the default change type we'll use when using the // PSBT APIs. defaultChangeType = walletrpc.ChangeAddressType_CHANGE_ADDRESS_TYPE_P2TR + + // External signing may involve offline or multi-party coordination. Keep + // the lease long enough for that workflow, and renew it on restart and + // immediately before finalization. + customAnchorLeaseDuration = 24 * time.Hour ) +var customAnchorLeaseID = wtxmgr.LockID(sha256.Sum256( + []byte("tapd-custom-anchor-psbt"), +)) + // FundPsbt attaches enough inputs to the target PSBT packet for it to be // valid. func (l *LndRpcWalletAnchor) FundPsbt(ctx context.Context, packet *psbt.Packet, @@ -200,6 +212,80 @@ func (l *LndRpcWalletAnchor) ImportTaprootOutput(ctx context.Context, return addr, nil } +// LeaseInput leases an input with tapd's custom-anchor lock ID if the input +// belongs to lnd. Inputs owned by another wallet are intentionally ignored. +func (l *LndRpcWalletAnchor) LeaseInput(ctx context.Context, + op wire.OutPoint) (bool, error) { + + // Renew our own lease without depending on whether ListUnspent includes + // locked outputs. + leases, err := l.lnd.WalletKit.ListLeases(ctx) + if err != nil { + return false, fmt.Errorf("error listing existing leases: %w", err) + } + for _, lease := range leases { + if lease.Outpoint != op { + continue + } + if lease.LockID != customAnchorLeaseID { + return false, fmt.Errorf("wallet input is already leased by " + + "another subsystem") + } + + if lease.LockID == customAnchorLeaseID { + _, err := l.lnd.WalletKit.LeaseOutput( + ctx, customAnchorLeaseID, op, customAnchorLeaseDuration, + ) + return err == nil, err + } + } + + utxos, err := l.lnd.WalletKit.ListUnspent(ctx, 0, math.MaxInt32) + if err != nil { + return false, fmt.Errorf("error listing wallet inputs: %w", err) + } + for _, utxo := range utxos { + if utxo.OutPoint != op { + continue + } + + _, err := l.lnd.WalletKit.LeaseOutput( + ctx, customAnchorLeaseID, op, customAnchorLeaseDuration, + ) + if err != nil { + return false, fmt.Errorf("unable to lease wallet input: %w", err) + } + + return true, nil + } + + return false, nil +} + +// ReleaseInput releases only tapd's custom-anchor lease for an input. +func (l *LndRpcWalletAnchor) ReleaseInput(ctx context.Context, + op wire.OutPoint) error { + + leases, err := l.lnd.WalletKit.ListLeases(ctx) + if err != nil { + return fmt.Errorf("error listing existing leases: %w", err) + } + for _, lease := range leases { + if lease.Outpoint != op || lease.LockID != customAnchorLeaseID { + continue + } + + if err := l.lnd.WalletKit.ReleaseOutput( + ctx, customAnchorLeaseID, op, + ); err != nil { + return fmt.Errorf("error releasing custom anchor lease: %w", + err) + } + } + + return nil +} + // UnlockInput unlocks the set of target inputs after a batch or send // transaction is abandoned. func (l *LndRpcWalletAnchor) UnlockInput(ctx context.Context, diff --git a/tapdb/asset_minting.go b/tapdb/asset_minting.go index ac74758f44..f79f95a5dc 100644 --- a/tapdb/asset_minting.go +++ b/tapdb/asset_minting.go @@ -1389,7 +1389,8 @@ func marshalMintingBatch(ctx context.Context, q PendingAssetStore, batch.GenesisPacket = &tapgarden.FundedMintAnchorPsbt{ FundedPsbt: tapsend.FundedPsbt{ - Pkt: genesisPkt, + Pkt: genesisPkt, + LockedUTXOs: tapgarden.CustomAnchorLockedUTXOs(genesisPkt), ChangeOutputIndex: extractSqlInt32[int32]( dbBatch.ChangeOutputIndex, ), diff --git a/tapgarden/caretaker.go b/tapgarden/caretaker.go index 7a86587677..52c4c5f6a4 100644 --- a/tapgarden/caretaker.go +++ b/tapgarden/caretaker.go @@ -830,6 +830,59 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) return 0, err } + // Import the minting output before any custom transaction is sent + // to the chain backend or the durable state advances to Broadcast. + // A transient import failure therefore leaves both memory and disk + // at Committed and is safe to retry. + importCtx, importCancel := b.WithCtxQuit() + _, err = b.cfg.Wallet.ImportTaprootOutput( + importCtx, mintingOutputKey, + ) + importCancel() + switch { + case err == nil: + break + + case strings.Contains(err.Error(), "already exists"): + break + + default: + return 0, fmt.Errorf("unable to import key: %w", err) + } + + // Only after import succeeds do we cross the durable publication + // boundary. Work on a copy so a failed store leaves both memory and + // disk at the signed-but-not-publishing Committed state. + if isCustomAnchorPsbt(signedPkt) { + var publishBytes bytes.Buffer + if err := signedPkt.Serialize(&publishBytes); err != nil { + return 0, fmt.Errorf("unable to copy custom anchor PSBT: %w", + err) + } + publishPkt, err := psbt.NewFromRawBytes(&publishBytes, false) + if err != nil { + return 0, fmt.Errorf("unable to copy custom anchor PSBT: %w", + err) + } + setCustomAnchorPublishState( + publishPkt, customAnchorPublishPending, + ) + publishFunded := b.cfg.Batch.GenesisPacket.FundedPsbt + publishFunded.Pkt = publishPkt + storeCtx, storeCancel := b.WithCtxQuit() + err = b.cfg.Log.StoreSignedGenesisPsbt( + storeCtx, b.cfg.Batch.BatchKey.PubKey, &publishFunded, + ) + storeCancel() + if err != nil { + return 0, fmt.Errorf("unable to store custom anchor "+ + "publication state: %w", err) + } + + signedPkt = publishPkt + b.cfg.Batch.GenesisPacket.Pkt = publishPkt + } + // Custom anchor transactions can contain inputs that aren't owned // by lnd. Ask the chain backend to validate and submit the fully // signed transaction before we persist the irreversible Broadcast @@ -859,8 +912,25 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) "publication rejection: %w", storeErr) } + releaseCtx, releaseCancel := b.WithCtxQuit() + releaseErr := releaseCustomAnchorLeases( + releaseCtx, b.cfg.Wallet, + b.cfg.Batch.GenesisPacket, + ) + releaseCancel() + if releaseErr == nil { + storeCtx, storeCancel = b.WithCtxQuit() + storeErr = b.cfg.Log.StoreSignedGenesisPsbt( + storeCtx, b.cfg.Batch.BatchKey.PubKey, + &b.cfg.Batch.GenesisPacket.FundedPsbt, + ) + storeCancel() + } + return 0, fmt.Errorf("transaction rejected before "+ - "broadcast state: %w", publishErr) + "broadcast state: %w", errors.Join( + publishErr, releaseErr, storeErr, + )) } } @@ -896,29 +966,6 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) "tx: %w", err) } - // With the genesis transaction committed to disk, we'll also - // import this public key into the backing wallet, so it - // recognizes the de minimis amt sats under out control. - // - // TODO(roasbeef): should be idempotent along w/ all other - // operations above - ctx, cancel = b.WithCtxQuit() - defer cancel() - _, err = b.cfg.Wallet.ImportTaprootOutput(ctx, mintingOutputKey) - switch { - case err == nil: - break - - // On restart, we'll get an error that the output has already - // been added to the wallet, so we'll catch this now and move - // along if so. - case strings.Contains(err.Error(), "already exists"): - break - - default: - return 0, fmt.Errorf("unable to import key: %w", err) - } - // If the initial submission failed ambiguously, the transaction // might still have reached the backend. Continue into Broadcast so // the normal retry policy runs and a confirmation watcher is diff --git a/tapgarden/issue721_stateful_test.go b/tapgarden/issue721_stateful_test.go index 934e621307..e6a7618cf5 100644 --- a/tapgarden/issue721_stateful_test.go +++ b/tapgarden/issue721_stateful_test.go @@ -2,9 +2,12 @@ package tapgarden_test import ( "bytes" + "context" "crypto/sha256" + "fmt" "sync" "testing" + "time" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/schnorr" @@ -18,12 +21,28 @@ import ( "github.com/lightninglabs/taproot-assets/proof" "github.com/lightninglabs/taproot-assets/tapgarden" "github.com/lightninglabs/taproot-assets/tappsbt" + "github.com/lightninglabs/taproot-assets/tapsend" "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) +type issue721FailSignedStore struct { + tapgarden.MintingStore + fail bool +} + +func (s *issue721FailSignedStore) StoreSignedGenesisPsbt(ctx context.Context, + batchKey *btcec.PublicKey, funded *tapsend.FundedPsbt) error { + + if s.fail { + return fmt.Errorf("injected signed PSBT store failure") + } + + return s.MintingStore.StoreSignedGenesisPsbt(ctx, batchKey, funded) +} + // issue721Anchor constructs a caller-owned PSBT whose first output and all // input metadata must survive mint preparation unchanged. Output one is the // caller-selected asset anchor. @@ -198,8 +217,16 @@ func TestIssue721PrepareSignResume(t *testing.T) { h.queueSeedlingsInBatch(false, seedling) pkt, customInternalKey, witnessScript := issue721Anchor(t) original := clonePacket(t, pkt) + callerBytes := serializePacket(t, pkt) + ownedInput := pkt.UnsignedTx.TxIn[0].PreviousOutPoint + h.wallet.OwnedInputs[ownedInput] = true funded := issue721Fund(t, h, pkt) + initialLease, err := fn.RecvOrTimeout( + h.wallet.LeaseInputSignal, defaultTimeout, + ) + require.NoError(t, err) + require.Equal(t, ownedInput, *initialLease) require.Equal(t, tapgarden.BatchStatePending, funded.State()) require.Equal(t, original.UnsignedTx.TxIn, funded.GenesisPacket.Pkt.UnsignedTx.TxIn) @@ -210,9 +237,47 @@ func TestIssue721PrepareSignResume(t *testing.T) { prepared, err := h.planter.PrepareBatch() require.NoError(t, err) + require.Equal(t, callerBytes, serializePacket(t, pkt)) + pkt.Unknowns[0].Value[0] ^= 1 + pendingAfterCallerMutation, err := h.planter.PendingBatch() + require.NoError(t, err) + require.Equal(t, original.Unknowns[0], + pendingAfterCallerMutation.GenesisPacket.Pkt.Unknowns[0]) require.Equal(t, tapgarden.BatchStateCommitted, prepared.State()) require.Equal(t, uint32(1), prepared.GenesisPacket.AssetAnchorOutIdx) + // Once the asset root is committed, no content-mutating entry point can + // change the batch. The original batch must remain usable afterward. + second := issue721Seedling() + second.AssetName = "issue-721-too-late" + updates, err := h.planter.QueueNewSeedling(second) + require.NoError(t, err) + update, err := fn.RecvOrTimeout(updates, defaultTimeout) + require.NoError(t, err) + require.ErrorContains(t, update.Error, "cannot accept new seedlings") + _, err = h.planter.SealBatch(tapgarden.SealParams{}) + require.ErrorContains(t, err, "cannot be sealed") + _, err = h.planter.PrepareBatch() + require.ErrorContains(t, err, "not ready for preparation") + _, err = h.planter.FundBatch(tapgarden.FundParams{}) + require.ErrorContains(t, err, "cannot be funded") + preparedBytes := serializePacket(t, prepared.GenesisPacket.Pkt) + preparedRoot := prepared.RootAssetCommitment.TapscriptRoot(nil) + liveAfterMutations, err := h.planter.PendingBatch() + require.NoError(t, err) + require.Len(t, liveAfterMutations.Seedlings, 1) + require.Equal(t, preparedBytes, + serializePacket(t, liveAfterMutations.GenesisPacket.Pkt)) + require.Equal(t, preparedRoot, + liveAfterMutations.RootAssetCommitment.TapscriptRoot(nil)) + persistedAfterMutations := h.fetchSingleBatch(prepared.BatchKey.PubKey) + require.Equal(t, tapgarden.BatchStateCommitted, + persistedAfterMutations.State()) + require.Equal(t, preparedBytes, + serializePacket(t, persistedAfterMutations.GenesisPacket.Pkt)) + require.Equal(t, preparedRoot, + persistedAfterMutations.RootAssetCommitment.TapscriptRoot(nil)) + preparedPkt := prepared.GenesisPacket.Pkt require.Equal(t, original.UnsignedTx.TxIn, preparedPkt.UnsignedTx.TxIn) require.Equal(t, original.UnsignedTx.TxOut[0], @@ -244,9 +309,18 @@ func TestIssue721PrepareSignResume(t *testing.T) { // A committed custom batch must remain pending for its external signer // across restart, with no caretaker attempting wallet signing. h.refreshChainPlanter() + restartLease, err := fn.RecvOrTimeout( + h.wallet.LeaseInputSignal, defaultTimeout, + ) + require.NoError(t, err) + require.Equal(t, ownedInput, *restartLease) restored, err := h.planter.PendingBatch() require.NoError(t, err) require.Equal(t, tapgarden.BatchStateCommitted, restored.State()) + require.Equal(t, preparedBytes, + serializePacket(t, restored.GenesisPacket.Pkt)) + require.Equal(t, preparedRoot, + restored.RootAssetCommitment.TapscriptRoot(nil)) h.assertNumCaretakersActive(0) restoredInternalKey, err := restored.MintingInternalKey() require.NoError(t, err) @@ -277,6 +351,11 @@ func TestIssue721PrepareSignResume(t *testing.T) { h.finalizeBatch(&wg, respChan, &tapgarden.FinalizeParams{ SignedPsbt: valid, }) + finalizeLease, err := fn.RecvOrTimeout( + h.wallet.LeaseInputSignal, defaultTimeout, + ) + require.NoError(t, err) + require.Equal(t, ownedInput, *finalizeLease) importedKey, err := fn.RecvOrTimeout( h.wallet.ImportPubKeySignal, defaultTimeout, ) @@ -328,14 +407,14 @@ func TestIssue721PublishRetry(t *testing.T) { h.finalizeBatch(&wg, respChan, &tapgarden.FinalizeParams{ SignedPsbt: signed, }) - firstAttempt, err := fn.RecvOrTimeout( - h.chain.PublishAttempts, defaultTimeout, - ) - require.NoError(t, err) _, err = fn.RecvOrTimeout( h.wallet.ImportPubKeySignal, defaultTimeout, ) require.NoError(t, err) + firstAttempt, err := fn.RecvOrTimeout( + h.chain.PublishAttempts, defaultTimeout, + ) + require.NoError(t, err) published := h.assertTxPublished() require.Equal( t, (*firstAttempt).WitnessHash(), published.WitnessHash(), @@ -362,7 +441,14 @@ func TestIssue721DefinitivePublishFailureCancellable(t *testing.T) { h.queueSeedlingsInBatch(false, issue721Seedling()) pkt, _, witnessScript := issue721Anchor(t) + ownedInput := pkt.UnsignedTx.TxIn[0].PreviousOutPoint + h.wallet.OwnedInputs[ownedInput] = true issue721Fund(t, h, pkt) + initialLease, err := fn.RecvOrTimeout( + h.wallet.LeaseInputSignal, defaultTimeout, + ) + require.NoError(t, err) + require.Equal(t, ownedInput, *initialLease) prepared, err := h.planter.PrepareBatch() require.NoError(t, err) @@ -379,6 +465,10 @@ func TestIssue721DefinitivePublishFailureCancellable(t *testing.T) { h.finalizeBatch(&wg, respChan, &tapgarden.FinalizeParams{ SignedPsbt: signed, }) + _, err = fn.RecvOrTimeout( + h.wallet.ImportPubKeySignal, defaultTimeout, + ) + require.NoError(t, err) attempt, err := fn.RecvOrTimeout( h.chain.PublishAttempts, defaultTimeout, ) @@ -416,6 +506,423 @@ func TestIssue721DefinitivePublishFailureCancellable(t *testing.T) { require.Equal(t, tapgarden.BatchStateSproutCancelled, cancelled.State()) } +// TestIssue721ImportRetry verifies that a transient wallet import failure is +// observed before publication and before the durable Broadcast transition. +func TestIssue721ImportRetry(t *testing.T) { + store := newMintingStore(t) + h := newMintingTestHarness(t, store) + h.refreshChainPlanter() + t.Cleanup(func() { + if h.planter != nil { + _ = h.planter.Stop() + } + }) + + h.queueSeedlingsInBatch(false, issue721Seedling()) + pkt, _, witnessScript := issue721Anchor(t) + issue721Fund(t, h, pkt) + prepared, err := h.planter.PrepareBatch() + require.NoError(t, err) + signed := clonePacket(t, prepared.GenesisPacket.Pkt) + signed.Inputs[0].FinalScriptWitness = issue721FinalWitness( + t, witnessScript, + ) + + h.wallet.ImportErr = fmt.Errorf("temporary import failure") + var wg sync.WaitGroup + respChan := make(chan *FinalizeBatchResp, 1) + h.finalizeBatch(&wg, respChan, &tapgarden.FinalizeParams{ + SignedPsbt: signed, + }) + _, err = fn.RecvOrTimeout( + h.wallet.ImportPubKeySignal, defaultTimeout, + ) + require.NoError(t, err) + h.assertFinalizeBatch(&wg, respChan, "temporary import failure") + select { + case <-h.chain.PublishAttempts: + t.Fatal("transaction published before wallet import succeeded") + default: + } + pending, err := h.planter.PendingBatch() + require.NoError(t, err) + require.Equal(t, tapgarden.BatchStateCommitted, pending.State()) + persisted := h.fetchSingleBatch(pending.BatchKey.PubKey) + require.Equal(t, tapgarden.BatchStateCommitted, persisted.State()) + + h.wallet.ImportErr = nil + retry := clonePacket(t, pending.GenesisPacket.Pkt) + h.finalizeBatch(&wg, respChan, &tapgarden.FinalizeParams{ + SignedPsbt: retry, + }) + _, err = fn.RecvOrTimeout( + h.wallet.ImportPubKeySignal, defaultTimeout, + ) + require.NoError(t, err) + h.assertTxPublished() + _, err = fn.RecvOrTimeout(h.chain.ConfReqSignal, defaultTimeout) + require.NoError(t, err) + minted := h.assertFinalizeBatch(&wg, respChan, "") + require.Equal(t, tapgarden.BatchStateBroadcast, minted.State()) +} + +func TestIssue721ImportRetryAfterRestart(t *testing.T) { + store := newMintingStore(t) + h := newMintingTestHarness(t, store) + h.refreshChainPlanter() + t.Cleanup(func() { + if h.planter != nil { + _ = h.planter.Stop() + } + }) + + h.queueSeedlingsInBatch(false, issue721Seedling()) + pkt, _, witnessScript := issue721Anchor(t) + ownedInput := pkt.UnsignedTx.TxIn[0].PreviousOutPoint + h.wallet.OwnedInputs[ownedInput] = true + issue721Fund(t, h, pkt) + initialLease, err := fn.RecvOrTimeout( + h.wallet.LeaseInputSignal, defaultTimeout, + ) + require.NoError(t, err) + require.Equal(t, ownedInput, *initialLease) + prepared, err := h.planter.PrepareBatch() + require.NoError(t, err) + signed := clonePacket(t, prepared.GenesisPacket.Pkt) + signed.Inputs[0].FinalScriptWitness = issue721FinalWitness( + t, witnessScript, + ) + + h.wallet.ImportErr = fmt.Errorf("temporary import failure") + var wg sync.WaitGroup + respChan := make(chan *FinalizeBatchResp, 1) + h.finalizeBatch(&wg, respChan, &tapgarden.FinalizeParams{ + SignedPsbt: signed, + }) + finalizeLease, err := fn.RecvOrTimeout( + h.wallet.LeaseInputSignal, defaultTimeout, + ) + require.NoError(t, err) + require.Equal(t, ownedInput, *finalizeLease) + _, err = fn.RecvOrTimeout(h.wallet.ImportPubKeySignal, defaultTimeout) + require.NoError(t, err) + h.assertFinalizeBatch(&wg, respChan, "temporary import failure") + select { + case <-h.chain.PublishAttempts: + t.Fatal("transaction published before wallet import succeeded") + default: + } + require.Equal(t, tapgarden.BatchStateCommitted, + h.fetchSingleBatch(prepared.BatchKey.PubKey).State()) + + // The signed, pre-publication packet resumes automatically. Import is + // retried before the first publication attempt after restart. + h.wallet.ImportErr = nil + h.refreshChainPlanter() + restartLease, err := fn.RecvOrTimeout( + h.wallet.LeaseInputSignal, defaultTimeout, + ) + require.NoError(t, err) + require.Equal(t, ownedInput, *restartLease) + _, err = fn.RecvOrTimeout(h.wallet.ImportPubKeySignal, defaultTimeout) + require.NoError(t, err) + h.assertTxPublished() + _, err = fn.RecvOrTimeout(h.chain.ConfReqSignal, defaultTimeout) + require.NoError(t, err) + h.assertNumCaretakersActive(1) +} + +func TestIssue721ImportRestartLeaseFailurePauses(t *testing.T) { + store := newMintingStore(t) + h := newMintingTestHarness(t, store) + h.refreshChainPlanter() + t.Cleanup(func() { + if h.planter != nil { + _ = h.planter.Stop() + } + }) + + h.queueSeedlingsInBatch(false, issue721Seedling()) + pkt, _, witnessScript := issue721Anchor(t) + op := pkt.UnsignedTx.TxIn[0].PreviousOutPoint + h.wallet.OwnedInputs[op] = true + issue721Fund(t, h, pkt) + <-h.wallet.LeaseInputSignal + prepared, err := h.planter.PrepareBatch() + require.NoError(t, err) + signed := clonePacket(t, prepared.GenesisPacket.Pkt) + signed.Inputs[0].FinalScriptWitness = issue721FinalWitness( + t, witnessScript, + ) + + h.wallet.ImportErr = fmt.Errorf("temporary import failure") + var wg sync.WaitGroup + respChan := make(chan *FinalizeBatchResp, 1) + h.finalizeBatch(&wg, respChan, &tapgarden.FinalizeParams{ + SignedPsbt: signed, + }) + <-h.wallet.LeaseInputSignal + _, err = fn.RecvOrTimeout(h.wallet.ImportPubKeySignal, defaultTimeout) + require.NoError(t, err) + h.assertFinalizeBatch(&wg, respChan, "temporary import failure") + + h.wallet.ImportErr = nil + h.wallet.LeaseErrors[op] = fmt.Errorf("lease no longer available") + h.refreshChainPlanter() + pending, err := h.planter.PendingBatch() + require.NoError(t, err) + require.Equal(t, tapgarden.BatchStateCommitted, pending.State()) + h.assertNumCaretakersActive(0) + select { + case <-h.wallet.ImportPubKeySignal: + t.Fatal("wallet output imported after lease renewal failure") + case <-h.chain.PublishAttempts: + t.Fatal("transaction published after lease renewal failure") + default: + } + + delete(h.wallet.LeaseErrors, op) + _, err = h.planter.CancelBatch() + require.NoError(t, err) +} + +// TestIssue721RetryStoreFailureIsAtomic ensures marker updates during a retry +// never alias the persisted/in-memory rejected packet before the store commits. +func TestIssue721RetryStoreFailureIsAtomic(t *testing.T) { + store := &issue721FailSignedStore{MintingStore: newMintingStore(t)} + h := newMintingTestHarness(t, store) + h.refreshChainPlanter() + t.Cleanup(func() { + if h.planter != nil { + _ = h.planter.Stop() + } + }) + + h.queueSeedlingsInBatch(false, issue721Seedling()) + pkt, _, witnessScript := issue721Anchor(t) + issue721Fund(t, h, pkt) + prepared, err := h.planter.PrepareBatch() + require.NoError(t, err) + signed := clonePacket(t, prepared.GenesisPacket.Pkt) + signed.Inputs[0].FinalScriptWitness = issue721FinalWitness( + t, witnessScript, + ) + + h.chain.FailPublishDefinitively() + var wg sync.WaitGroup + respChan := make(chan *FinalizeBatchResp, 1) + h.finalizeBatch(&wg, respChan, &tapgarden.FinalizeParams{ + SignedPsbt: signed, + }) + _, err = fn.RecvOrTimeout(h.wallet.ImportPubKeySignal, defaultTimeout) + require.NoError(t, err) + _, err = fn.RecvOrTimeout(h.chain.PublishAttempts, defaultTimeout) + require.NoError(t, err) + h.assertFinalizeBatch( + &wg, respChan, "transaction rejected before broadcast state", + ) + + before, err := h.planter.PendingBatch() + require.NoError(t, err) + beforeBytes := serializePacket(t, before.GenesisPacket.Pkt) + store.fail = true + retry := clonePacket(t, before.GenesisPacket.Pkt) + _, err = h.planter.FinalizeBatch(tapgarden.FinalizeParams{ + SignedPsbt: retry, + }) + require.ErrorContains(t, err, "injected signed PSBT store failure") + after, err := h.planter.PendingBatch() + require.NoError(t, err) + require.Equal(t, beforeBytes, serializePacket(t, after.GenesisPacket.Pkt)) + require.Equal(t, tapgarden.BatchStateCommitted, after.State()) + + store.fail = false + _, err = h.planter.CancelBatch() + require.NoError(t, err) +} + +// TestIssue721MixedInputLeaseLifecycle pins ownership-aware leasing: local +// inputs are leased, persisted and released, while external inputs and all +// caller-authored transaction/PSBT fields remain untouched. +func TestIssue721MixedInputLeaseLifecycle(t *testing.T) { + store := newMintingStore(t) + h := newMintingTestHarness(t, store) + h.refreshChainPlanter() + t.Cleanup(func() { + if h.planter != nil { + _ = h.planter.Stop() + } + }) + + h.queueSeedlingsInBatch(false, issue721Seedling()) + pkt, _, _ := issue721Anchor(t) + local := pkt.UnsignedTx.TxIn[0].PreviousOutPoint + var externalHash chainhash.Hash + copy(externalHash[:], bytes.Repeat([]byte{8}, 32)) + external := wire.OutPoint{Hash: externalHash, Index: 3} + pkt.UnsignedTx.AddTxIn(&wire.TxIn{PreviousOutPoint: external}) + pkt.Inputs = append(pkt.Inputs, psbt.PInput{ + WitnessUtxo: &wire.TxOut{ + Value: 5_000, PkScript: pkt.Inputs[0].WitnessUtxo.PkScript, + }, + WitnessScript: pkt.Inputs[0].WitnessScript, + SighashType: txscript.SigHashAll, + Unknowns: []*psbt.Unknown{{ + Key: []byte{0x54}, Value: []byte("external-input"), + }}, + }) + original := clonePacket(t, pkt) + h.wallet.OwnedInputs[local] = true + + funded := issue721Fund(t, h, pkt) + leased, err := fn.RecvOrTimeout( + h.wallet.LeaseInputSignal, defaultTimeout, + ) + require.NoError(t, err) + require.Equal(t, local, *leased) + require.Equal(t, []wire.OutPoint{local}, + funded.GenesisPacket.LockedUTXOs) + require.Equal(t, original.UnsignedTx, funded.GenesisPacket.Pkt.UnsignedTx) + require.Equal(t, original.Inputs, funded.GenesisPacket.Pkt.Inputs) + require.Equal(t, original.Outputs, funded.GenesisPacket.Pkt.Outputs) + require.Equal(t, original.Unknowns[0], funded.GenesisPacket.Pkt.Unknowns[0]) + + h.refreshChainPlanter() + renewed, err := fn.RecvOrTimeout( + h.wallet.LeaseInputSignal, defaultTimeout, + ) + require.NoError(t, err) + require.Equal(t, local, *renewed) + restored, err := h.planter.PendingBatch() + require.NoError(t, err) + require.Equal(t, []wire.OutPoint{local}, + restored.GenesisPacket.LockedUTXOs) + + _, err = h.planter.CancelBatch() + require.NoError(t, err) + released, err := fn.RecvOrTimeout( + h.wallet.ReleaseInputSignal, defaultTimeout, + ) + require.NoError(t, err) + require.Equal(t, local, *released) + select { + case op := <-h.wallet.ReleaseInputSignal: + t.Fatalf("external input unexpectedly released: %v", op) + default: + } +} + +func TestIssue721PeriodicLeaseRenewal(t *testing.T) { + store := newMintingStore(t) + h := newMintingTestHarness(t, store) + h.leaseRenewalInterval = 20 * time.Millisecond + h.refreshChainPlanter() + t.Cleanup(func() { _ = h.planter.Stop() }) + + h.queueSeedlingsInBatch(false, issue721Seedling()) + pkt, _, _ := issue721Anchor(t) + op := pkt.UnsignedTx.TxIn[0].PreviousOutPoint + h.wallet.OwnedInputs[op] = true + issue721Fund(t, h, pkt) + initial, err := fn.RecvOrTimeout( + h.wallet.LeaseInputSignal, defaultTimeout, + ) + require.NoError(t, err) + require.Equal(t, op, *initial) + renewed, err := fn.RecvOrTimeout( + h.wallet.LeaseInputSignal, defaultTimeout, + ) + require.NoError(t, err) + require.Equal(t, op, *renewed) + + _, err = h.planter.CancelBatch() + require.NoError(t, err) +} + +func TestIssue721LeaseFailureAndCancelReleaseOrdering(t *testing.T) { + t.Run("lease conflict rolls back funding", func(t *testing.T) { + store := newMintingStore(t) + h := newMintingTestHarness(t, store) + h.refreshChainPlanter() + t.Cleanup(func() { _ = h.planter.Stop() }) + + h.queueSeedlingsInBatch(false, issue721Seedling()) + pkt, _, _ := issue721Anchor(t) + anchorPriv, _ := btcec.PrivKeyFromBytes( + bytes.Repeat([]byte{7}, 32), + ) + h.keyRing.Keys[keychain.KeyLocator{ + Family: asset.TaprootAssetsKeyFamily, Index: 721, + }] = anchorPriv + h.keyRing.On( + "IsLocalKey", mock.Anything, mock.Anything, + ).Maybe() + first := pkt.UnsignedTx.TxIn[0].PreviousOutPoint + var secondHash chainhash.Hash + copy(secondHash[:], bytes.Repeat([]byte{6}, 32)) + second := wire.OutPoint{Hash: secondHash, Index: 6} + pkt.UnsignedTx.AddTxIn(&wire.TxIn{PreviousOutPoint: second}) + pkt.Inputs = append(pkt.Inputs, psbt.PInput{ + WitnessUtxo: &wire.TxOut{ + Value: 5_000, PkScript: pkt.Inputs[0].WitnessUtxo.PkScript, + }, + WitnessScript: pkt.Inputs[0].WitnessScript, + }) + h.wallet.OwnedInputs[first] = true + h.wallet.OwnedInputs[second] = true + h.wallet.LeaseErrors[second] = fmt.Errorf("foreign lease conflict") + original := serializePacket(t, pkt) + _, err := h.planter.FundBatch(tapgarden.FundParams{ + AnchorPsbt: pkt, AssetAnchorOutIdx: 1, + ChangeOutputIndex: -1, + FeeRate: fn.None[chainfee.SatPerKWeight](), + SiblingTapTree: fn.None[asset.TapscriptTreeNodes](), + }) + require.ErrorContains(t, err, "foreign lease conflict") + require.Equal(t, first, <-h.wallet.LeaseInputSignal) + require.Equal(t, first, <-h.wallet.ReleaseInputSignal) + require.Equal(t, original, serializePacket(t, pkt)) + pending, err := h.planter.PendingBatch() + require.NoError(t, err) + require.Nil(t, pending.GenesisPacket) + }) + + t.Run("durable cancel attempts every local release", func(t *testing.T) { + store := newMintingStore(t) + h := newMintingTestHarness(t, store) + h.refreshChainPlanter() + t.Cleanup(func() { _ = h.planter.Stop() }) + + h.queueSeedlingsInBatch(false, issue721Seedling()) + pkt, _, _ := issue721Anchor(t) + first := pkt.UnsignedTx.TxIn[0].PreviousOutPoint + var secondHash chainhash.Hash + copy(secondHash[:], bytes.Repeat([]byte{9}, 32)) + second := wire.OutPoint{Hash: secondHash, Index: 4} + pkt.UnsignedTx.AddTxIn(&wire.TxIn{PreviousOutPoint: second}) + pkt.Inputs = append(pkt.Inputs, psbt.PInput{ + WitnessUtxo: &wire.TxOut{ + Value: 5_000, PkScript: pkt.Inputs[0].WitnessUtxo.PkScript, + }, + WitnessScript: pkt.Inputs[0].WitnessScript, + }) + h.wallet.OwnedInputs[first] = true + h.wallet.OwnedInputs[second] = true + issue721Fund(t, h, pkt) + <-h.wallet.LeaseInputSignal + <-h.wallet.LeaseInputSignal + h.wallet.ReleaseErrors[second] = fmt.Errorf("release failed") + + batchKey, err := h.planter.CancelBatch() + require.NoError(t, err) + require.Equal(t, first, <-h.wallet.ReleaseInputSignal) + require.Equal(t, second, <-h.wallet.ReleaseInputSignal) + cancelled := h.fetchSingleBatch(batchKey) + require.Equal(t, tapgarden.BatchStateSeedlingCancelled, + cancelled.State()) + }) +} + // TestIssue721ConfirmationRegistrationRetry ensures a successful publish // followed by a transient notifier error remains retryable in the same process. func TestIssue721ConfirmationRegistrationRetry(t *testing.T) { @@ -660,3 +1167,11 @@ func clonePacket(t *testing.T, pkt *psbt.Packet) *psbt.Packet { return clone } + +func serializePacket(t *testing.T, pkt *psbt.Packet) []byte { + t.Helper() + + var buf bytes.Buffer + require.NoError(t, pkt.Serialize(&buf)) + return buf.Bytes() +} diff --git a/tapgarden/planter.go b/tapgarden/planter.go index 43658b54da..8e92ff9808 100644 --- a/tapgarden/planter.go +++ b/tapgarden/planter.go @@ -3,6 +3,7 @@ package tapgarden import ( "bytes" "context" + "encoding/binary" "errors" "fmt" "reflect" @@ -128,6 +129,11 @@ type PlanterConfig struct { // critical errors to the main server. ErrChan chan<- error + // CustomAnchorLeaseRenewalInterval controls how often wallet-owned + // inputs in a caller-funded batch are re-leased while awaiting an + // external signer. Zero uses the production default. + CustomAnchorLeaseRenewalInterval time.Duration + // TODO(roasbeef): something notification related? } @@ -362,9 +368,12 @@ const ( customAnchorPublishNone customAnchorPublishState = iota customAnchorPublishPending customAnchorPublishRejected + customAnchorImportPending maxFinalScriptWitnessSize = 4_000_000 maxFinalWitnessItems = 100_000 + + customAnchorLeaseRenewalInterval = 5 * time.Minute ) func markCustomAnchorPsbt(packet *psbt.Packet) { @@ -378,6 +387,203 @@ func markCustomAnchorPsbt(packet *psbt.Packet) { }) } +// SetCustomAnchorLockedUTXOs records the wallet-owned inputs leased for a +// custom anchor transaction in tapd's existing proprietary PSBT marker. Caller +// metadata is left untouched, and the marker is persisted with the packet. +func SetCustomAnchorLockedUTXOs(packet *psbt.Packet, ops []wire.OutPoint) { + if packet == nil { + return + } + + value := make([]byte, 5+36*len(ops)) + value[0] = 1 + binary.LittleEndian.PutUint32(value[1:5], uint32(len(ops))) + offset := 5 + for _, op := range ops { + copy(value[offset:offset+32], op.Hash[:]) + binary.LittleEndian.PutUint32(value[offset+32:offset+36], op.Index) + offset += 36 + } + + for _, unknown := range packet.Unknowns { + if bytes.Equal(unknown.Key, customAnchorPsbtMarker) { + unknown.Value = value + return + } + } + + packet.Unknowns = append(packet.Unknowns, &psbt.Unknown{ + Key: fn.CopySlice(customAnchorPsbtMarker), Value: value, + }) +} + +// CustomAnchorLockedUTXOs returns the wallet-owned custom-anchor inputs stored +// in tapd's PSBT marker. Old marker versions simply return no leases. +func CustomAnchorLockedUTXOs(packet *psbt.Packet) []wire.OutPoint { + if packet == nil { + return nil + } + + for _, unknown := range packet.Unknowns { + if !bytes.Equal(unknown.Key, customAnchorPsbtMarker) || + len(unknown.Value) < 5 || unknown.Value[0] != 1 { + + continue + } + + count := binary.LittleEndian.Uint32(unknown.Value[1:5]) + if uint64(count) > uint64((len(unknown.Value)-5)/36) || + len(unknown.Value) != 5+int(count)*36 { + + return nil + } + + ops := make([]wire.OutPoint, count) + offset := 5 + for idx := range ops { + copy(ops[idx].Hash[:], unknown.Value[offset:offset+32]) + ops[idx].Index = binary.LittleEndian.Uint32( + unknown.Value[offset+32 : offset+36], + ) + offset += 36 + } + + return ops + } + + return nil +} + +// acquireCustomAnchorLeases leases all inputs controlled by the backing +// wallet while allowing foreign inputs to remain under an external signer's +// control. Only leases acquired during this call are rolled back on failure. +func acquireCustomAnchorLeases(ctx context.Context, wallet tapnode.WalletAnchor, + packet *psbt.Packet, previouslyLocked []wire.OutPoint) ([]wire.OutPoint, + error) { + + if packet == nil || packet.UnsignedTx == nil { + return nil, fmt.Errorf("custom anchor PSBT is missing a transaction") + } + + previous := make(map[wire.OutPoint]struct{}, len(previouslyLocked)) + for _, op := range previouslyLocked { + previous[op] = struct{}{} + } + + locked := make([]wire.OutPoint, 0, len(packet.UnsignedTx.TxIn)) + newlyLocked := make([]wire.OutPoint, 0, len(packet.UnsignedTx.TxIn)) + seen := make(map[wire.OutPoint]struct{}, len(packet.UnsignedTx.TxIn)) + for _, txIn := range packet.UnsignedTx.TxIn { + op := txIn.PreviousOutPoint + if _, ok := seen[op]; ok { + releaseErr := releaseCustomAnchorOutpoints( + ctx, wallet, newlyLocked, + ) + return nil, errors.Join(fmt.Errorf( + "custom anchor PSBT repeats input %v", op, + ), releaseErr) + } + seen[op] = struct{}{} + owned, err := wallet.LeaseInput(ctx, op) + if err != nil { + releaseErr := releaseCustomAnchorOutpoints( + ctx, wallet, newlyLocked, + ) + return nil, errors.Join( + fmt.Errorf("unable to lease custom anchor input %v: %w", + op, err), releaseErr, + ) + } + if !owned { + if _, wasLocked := previous[op]; wasLocked { + releaseErr := releaseCustomAnchorOutpoints( + ctx, wallet, newlyLocked, + ) + return nil, errors.Join(fmt.Errorf( + "unable to renew custom anchor input lease %v", op, + ), releaseErr) + } + continue + } + + locked = append(locked, op) + if _, wasLocked := previous[op]; !wasLocked { + newlyLocked = append(newlyLocked, op) + } + } + + return locked, nil +} + +func newlyAcquiredLeases(locked, previous []wire.OutPoint) []wire.OutPoint { + previousSet := make(map[wire.OutPoint]struct{}, len(previous)) + for _, op := range previous { + previousSet[op] = struct{}{} + } + + var newlyLocked []wire.OutPoint + for _, op := range locked { + if _, ok := previousSet[op]; !ok { + newlyLocked = append(newlyLocked, op) + } + } + + return newlyLocked +} + +func releaseCustomAnchorOutpoints(ctx context.Context, + wallet tapnode.WalletAnchor, ops []wire.OutPoint) error { + + var releaseErr error + for _, op := range ops { + if err := wallet.ReleaseInput(ctx, op); err != nil { + releaseErr = errors.Join(releaseErr, fmt.Errorf( + "unable to release custom anchor input %v: %w", op, err, + )) + } + } + + return releaseErr +} + +func releaseCustomAnchorLeases(ctx context.Context, + wallet tapnode.WalletAnchor, funded *FundedMintAnchorPsbt) error { + + if funded == nil { + return nil + } + if err := releaseCustomAnchorOutpoints( + ctx, wallet, funded.LockedUTXOs, + ); err != nil { + return err + } + + funded.LockedUTXOs = nil + SetCustomAnchorLockedUTXOs(funded.Pkt, nil) + return nil +} + +func renewCustomAnchorLeases(ctx context.Context, wallet tapnode.WalletAnchor, + funded *FundedMintAnchorPsbt) error { + + if funded == nil { + return nil + } + for _, op := range funded.LockedUTXOs { + owned, err := wallet.LeaseInput(ctx, op) + if err != nil { + return fmt.Errorf("unable to renew custom anchor input %v: %w", + op, err) + } + if !owned { + return fmt.Errorf("custom anchor input %v is no longer available "+ + "to the backing wallet", op) + } + } + + return nil +} + func isCustomAnchorPsbt(packet *psbt.Packet) bool { if packet == nil { return false @@ -423,11 +629,15 @@ func getCustomAnchorPublishState( if !bytes.Equal(unknown.Key, customAnchorPublishMarker) { continue } - if len(unknown.Value) == 1 && - customAnchorPublishState(unknown.Value[0]) == - customAnchorPublishRejected { + if len(unknown.Value) == 1 { + state := customAnchorPublishState(unknown.Value[0]) + switch state { + case customAnchorPublishPending, + customAnchorPublishRejected, + customAnchorImportPending: - return customAnchorPublishRejected + return state + } } // An unknown or malformed persisted value is conservative: a @@ -691,9 +901,32 @@ func (c *ChainPlanter) Start() error { (batchState == BatchStatePending || batchState == BatchStateFrozen || (batchState == BatchStateCommitted && - publishState != customAnchorPublishPending)) - if awaitingExternalSigner { + publishState != customAnchorPublishPending && + publishState != customAnchorImportPending)) + leaseRenewalFailed := false + if customBatch && (batchState == BatchStatePending || + batchState == BatchStateFrozen || + batchState == BatchStateCommitted) { + + if err := renewCustomAnchorLeases( + ctx, c.cfg.Wallet, batch.GenesisPacket, + ); err != nil { + log.Warnf("Unable to renew custom anchor input "+ + "leases during startup: %v", err) + leaseRenewalFailed = true + } + } + + // A pre-publication batch cannot advance after a failed lease + // renewal. Keep it available for Cancel or a Finalize retry. A + // publish-pending batch may already be on chain and must resume + // monitoring regardless. + if leaseRenewalFailed && + publishState != customAnchorPublishPending { + awaitingExternalSigner = true + } + if awaitingExternalSigner { if c.pendingBatch != nil { startErr = fmt.Errorf("multiple custom batches " + "awaiting external signatures") @@ -1359,10 +1592,9 @@ func customGenesisPsbt(chainParams address.ChainParams, return zero, fmt.Errorf("custom anchor PSBT is missing its " + "unsigned transaction") } - // Caller-supplied packets can't set tapd's private lifecycle markers. - // They are added only after the packet passes validation and enters the - // corresponding durable state. - stripTapdCustomAnchorMarkers(packet) + // Run pure structural checks before serialization so malformed callers + // retain the established, actionable validation errors. None of these + // checks mutate the caller's packet. if len(packet.UnsignedTx.TxIn) == 0 { return zero, fmt.Errorf("custom anchor PSBT must have at least " + "one input") @@ -1389,6 +1621,7 @@ func customGenesisPsbt(chainParams address.ChainParams, return zero, fmt.Errorf("asset anchor and change output indexes " + "must be distinct") } + if err := packet.SanityCheck(); err != nil { return zero, fmt.Errorf("invalid custom anchor PSBT: %w", err) } @@ -1444,6 +1677,31 @@ func customGenesisPsbt(chainParams address.ChainParams, return zero, fmt.Errorf("custom asset anchor output must not " + "specify a PSBT tap tree; use the batch sibling fields") } + + // For ordinary batches, exclusion-proof validation is also entirely + // read-only and must run before serialization so malformed metadata gets + // its established, precise error without touching the caller's packet. + if pendingBatch == nil || !pendingBatch.SupplyCommitments { + if err := validateExclusionProofOutputs( + packet, assetAnchorOutIdx, + ); err != nil { + + return zero, err + } + } + + // All caller-authored validation above is pure. Only now make the deep + // copy that tapd will retain and mutate with lifecycle/precommit metadata. + var packetBytes bytes.Buffer + if err := packet.Serialize(&packetBytes); err != nil { + return zero, fmt.Errorf("unable to copy custom anchor PSBT: %w", err) + } + packet, err = psbt.NewFromRawBytes(&packetBytes, false) + if err != nil { + return zero, fmt.Errorf("unable to copy custom anchor PSBT: %w", err) + } + stripTapdCustomAnchorMarkers(packet) + funded := tapsend.FundedPsbt{ Pkt: packet, ChangeOutputIndex: changeOutputIndex, @@ -2319,6 +2577,21 @@ func (c *ChainPlanter) cancelMintingBatch(ctx context.Context, return fmt.Errorf("unable to cancel minting batch: %w", err) } + // Only release leases after cancellation is durable. A partial wallet + // RPC failure can then at worst leave a short-lived stale lease; it can + // never leave an active batch partially unprotected. Cancellation itself + // remains successful and the release helper attempts every recorded input. + if c.pendingBatch.GenesisPacket != nil && + isCustomAnchorPsbt(c.pendingBatch.GenesisPacket.Pkt) { + + if err := releaseCustomAnchorLeases( + ctx, c.cfg.Wallet, c.pendingBatch.GenesisPacket, + ); err != nil { + log.Warnf("Unable to release one or more cancelled custom "+ + "anchor input leases: %v", err) + } + } + return nil } @@ -2328,6 +2601,12 @@ func (c *ChainPlanter) cancelMintingBatch(ctx context.Context, // assets. func (c *ChainPlanter) gardener() { defer c.Wg.Done() + leaseRenewalInterval := c.cfg.CustomAnchorLeaseRenewalInterval + if leaseRenewalInterval <= 0 { + leaseRenewalInterval = customAnchorLeaseRenewalInterval + } + leaseRenewalTicker := time.NewTicker(leaseRenewalInterval) + defer leaseRenewalTicker.Stop() // When this exits due to the quit signal, we also want to stop all the // active caretakers as well. @@ -2337,6 +2616,28 @@ func (c *ChainPlanter) gardener() { for { select { + case <-leaseRenewalTicker.C: + batch := c.pendingBatch + if batch == nil || batch.GenesisPacket == nil || + !isCustomAnchorPsbt(batch.GenesisPacket.Pkt) { + + continue + } + switch batch.State() { + case BatchStatePending, BatchStateFrozen, + BatchStateCommitted: + + ctx, cancel := c.WithCtxQuit() + err := renewCustomAnchorLeases( + ctx, c.cfg.Wallet, batch.GenesisPacket, + ) + cancel() + if err != nil { + log.Warnf("Unable to renew custom anchor input "+ + "leases: %v", err) + } + } + // A request for new asset issuance just arrived, add this to // the pending batch and acknowledge the receipt back to the // caller. @@ -2441,6 +2742,13 @@ func (c *ChainPlanter) gardener() { req.Resolve(batches) case reqTypeFundBatch: + if c.pendingBatch != nil && + c.pendingBatch.State() != BatchStatePending { + + req.Error(fmt.Errorf("batch in state %v cannot be "+ + "funded", c.pendingBatch.State())) + break + } if c.pendingBatch != nil && c.pendingBatch.IsFunded() { @@ -2488,6 +2796,12 @@ func (c *ChainPlanter) gardener() { "batch")) break } + if c.pendingBatch.State() != BatchStatePending && + c.pendingBatch.State() != BatchStateFrozen { + req.Error(fmt.Errorf("batch in state %v cannot be "+ + "sealed", c.pendingBatch.State())) + break + } sealReqParams, err := typedParam[SealParams](req) @@ -2648,6 +2962,10 @@ func (c *ChainPlanter) gardener() { // saved to disk and updated in memory. func (c *ChainPlanter) fundBatch(ctx context.Context, params FundParams, workingBatch *MintingBatch) error { + if workingBatch != nil && workingBatch.State() != BatchStatePending { + return fmt.Errorf("batch in state %v cannot be funded", + workingBatch.State()) + } var ( rootHash *chainhash.Hash @@ -2695,6 +3013,15 @@ func (c *ChainPlanter) fundBatch(ctx context.Context, params FundParams, return nil, err } + locked, err := acquireCustomAnchorLeases( + ctx, c.cfg.Wallet, funded.Pkt, nil, + ) + if err != nil { + return nil, err + } + funded.LockedUTXOs = locked + SetCustomAnchorLockedUTXOs(funded.Pkt, locked) + return &funded, nil } @@ -2759,7 +3086,10 @@ func (c *ChainPlanter) fundBatch(ctx context.Context, params FundParams, err = c.cfg.Log.CommitMintingBatch(ctx, newBatch) if err != nil { - return err + releaseErr := releaseCustomAnchorLeases( + ctx, c.cfg.Wallet, mintAnchorTx, + ) + return errors.Join(err, releaseErr) } c.pendingBatch = newBatch @@ -2781,7 +3111,13 @@ func (c *ChainPlanter) fundBatch(ctx context.Context, params FundParams, ctx, workingBatch.BatchKey.PubKey, rootHash, *mintAnchorTx, ) if err != nil { - return fmt.Errorf("unable to commit batch funding: %w", err) + releaseErr := releaseCustomAnchorLeases( + ctx, c.cfg.Wallet, mintAnchorTx, + ) + return errors.Join( + fmt.Errorf("unable to commit batch funding: %w", err), + releaseErr, + ) } // All persistence succeeded; commit the funding to memory. @@ -3554,15 +3890,43 @@ func (c *ChainPlanter) finalizeBatch(params FinalizeParams) (*BatchCaretaker, merged = c.pendingBatch.GenesisPacket.Pkt } if state == BatchStateCommitted { + // A retry can select the already persisted packet above. Work on + // an independent copy so marker changes cannot split in-memory + // state from disk if the subsequent store fails. + var mergedBytes bytes.Buffer + if err := merged.Serialize(&mergedBytes); err != nil { + return nil, fmt.Errorf("unable to copy custom anchor "+ + "PSBT before finalization: %w", err) + } + merged, err = psbt.NewFromRawBytes(&mergedBytes, false) + if err != nil { + return nil, fmt.Errorf("unable to copy custom anchor "+ + "PSBT before finalization: %w", err) + } + ctx, cancel := c.WithCtxQuit() err = c.validateCustomAnchorFeeRate(ctx, merged) - cancel() if err != nil { + cancel() return nil, fmt.Errorf("externally signed PSBT has an invalid "+ "fee rate: %w", err) } + + previousLocks := fn.CopySlice( + c.pendingBatch.GenesisPacket.LockedUTXOs, + ) + locked, err := acquireCustomAnchorLeases( + ctx, c.cfg.Wallet, merged, previousLocks, + ) + cancel() + if err != nil { + return nil, fmt.Errorf("unable to lease custom anchor "+ + "inputs before finalization: %w", err) + } + newLocks := newlyAcquiredLeases(locked, previousLocks) + SetCustomAnchorLockedUTXOs(merged, locked) setCustomAnchorPublishState( - merged, customAnchorPublishPending, + merged, customAnchorImportPending, ) // Store the signed packet before starting the caretaker. If @@ -3571,6 +3935,7 @@ func (c *ChainPlanter) finalizeBatch(params FinalizeParams) (*BatchCaretaker, // for inspection, cancellation, or idempotent resubmission. signedFundedPsbt := c.pendingBatch.GenesisPacket.FundedPsbt signedFundedPsbt.Pkt = merged + signedFundedPsbt.LockedUTXOs = locked ctx, cancel = c.WithCtxQuit() err = c.cfg.Log.StoreSignedGenesisPsbt( ctx, c.pendingBatch.BatchKey.PubKey, @@ -3578,11 +3943,17 @@ func (c *ChainPlanter) finalizeBatch(params FinalizeParams) (*BatchCaretaker, ) cancel() if err != nil { + ctx, cancel = c.WithCtxQuit() + releaseErr := releaseCustomAnchorOutpoints( + ctx, c.cfg.Wallet, newLocks, + ) + cancel() return nil, fmt.Errorf("unable to store externally signed "+ - "PSBT: %w", err) + "PSBT: %w", errors.Join(err, releaseErr)) } c.pendingBatch.GenesisPacket.Pkt = merged + c.pendingBatch.GenesisPacket.LockedUTXOs = locked } caretaker := c.newCaretakerForBatch(c.pendingBatch, nil) @@ -3851,6 +4222,15 @@ func (c *ChainPlanter) prepSeedlingDelegationKey(ctx context.Context, // either adds it to an existing pending batch or creates a new batch for it. func (c *ChainPlanter) prepAssetSeedling(ctx context.Context, req *Seedling) error { + // This must be the first operation. Once preparation freezes or commits + // the asset root, no request validation, key derivation or metadata + // assignment may run for an additional seedling. + if c.pendingBatch != nil && + c.pendingBatch.State() != BatchStatePending { + + return fmt.Errorf("batch in state %v cannot accept new seedlings", + c.pendingBatch.State()) + } // If the seedling has the universe/supply commitment feature enabled, // finalize the delegation key. diff --git a/tapgarden/planter_test.go b/tapgarden/planter_test.go index bbc71808e1..f6cb8aa063 100644 --- a/tapgarden/planter_test.go +++ b/tapgarden/planter_test.go @@ -112,6 +112,8 @@ type mintingTestHarness struct { *testing.T errChan chan error + + leaseRenewalInterval time.Duration } // newMintingTestHarness creates a new test harness from an active minting @@ -161,9 +163,10 @@ func (t *mintingTestHarness) refreshChainPlanter() { ProofFiles: t.proofFiles, ProofWatcher: t.proofWatcher, }, - ChainParams: *chainParams, - ProofUpdates: t.proofFiles, - ErrChan: t.errChan, + ChainParams: *chainParams, + ProofUpdates: t.proofFiles, + ErrChan: t.errChan, + CustomAnchorLeaseRenewalInterval: t.leaseRenewalInterval, }) require.NoError(t, t.planter.Start()) } diff --git a/tapnode/tapnodemock/wallet_anchor.go b/tapnode/tapnodemock/wallet_anchor.go index d481b604de..cd2e770b4f 100644 --- a/tapnode/tapnodemock/wallet_anchor.go +++ b/tapnode/tapnodemock/wallet_anchor.go @@ -30,6 +30,8 @@ type WalletAnchor struct { FundPsbtSignal chan *tapsend.FundedPsbt SignPsbtSignal chan struct{} ImportPubKeySignal chan *btcec.PublicKey + LeaseInputSignal chan wire.OutPoint + ReleaseInputSignal chan wire.OutPoint ListUnspentSignal chan struct{} SubscribeTxSignal chan struct{} SubscribeTx chan lndclient.Transaction @@ -37,6 +39,10 @@ type WalletAnchor struct { Transactions []lndclient.Transaction ImportedUtxos []*lnwallet.Utxo + OwnedInputs map[wire.OutPoint]bool + LeaseErrors map[wire.OutPoint]error + ReleaseErrors map[wire.OutPoint]error + ImportErr error } // NewWalletAnchor returns a freshly-initialised mock WalletAnchor. @@ -45,10 +51,15 @@ func NewWalletAnchor() *WalletAnchor { FundPsbtSignal: make(chan *tapsend.FundedPsbt), SignPsbtSignal: make(chan struct{}), ImportPubKeySignal: make(chan *btcec.PublicKey), + LeaseInputSignal: make(chan wire.OutPoint, 20), + ReleaseInputSignal: make(chan wire.OutPoint, 20), ListUnspentSignal: make(chan struct{}), SubscribeTxSignal: make(chan struct{}), SubscribeTx: make(chan lndclient.Transaction), ListTxnsSignal: make(chan struct{}), + OwnedInputs: make(map[wire.OutPoint]bool), + LeaseErrors: make(map[wire.OutPoint]error), + ReleaseErrors: make(map[wire.OutPoint]error), } } @@ -175,12 +186,35 @@ func (m *WalletAnchor) ImportTaprootOutput(ctx context.Context, case <-ctx.Done(): return nil, fmt.Errorf("shutting down") } + if m.ImportErr != nil { + return nil, m.ImportErr + } return btcaddr.NewAddressTaproot( schnorr.SerializePubKey(pub), &chaincfg.RegressionNetParams, ) } +// LeaseInput leases the input if the mock marks it as wallet-owned. +func (m *WalletAnchor) LeaseInput(_ context.Context, + op wire.OutPoint) (bool, error) { + + if err := m.LeaseErrors[op]; err != nil { + return false, err + } + if !m.OwnedInputs[op] { + return false, nil + } + m.LeaseInputSignal <- op + return true, nil +} + +// ReleaseInput releases a custom-anchor lease held by the mock wallet. +func (m *WalletAnchor) ReleaseInput(_ context.Context, op wire.OutPoint) error { + m.ReleaseInputSignal <- op + return m.ReleaseErrors[op] +} + // UnlockInput unlocks the set of target inputs after a batch or send // transaction is abandoned. func (m *WalletAnchor) UnlockInput(context.Context, wire.OutPoint) error { diff --git a/tapnode/wallet_anchor.go b/tapnode/wallet_anchor.go index 7842da8c94..2c77e1c1b6 100644 --- a/tapnode/wallet_anchor.go +++ b/tapnode/wallet_anchor.go @@ -31,6 +31,14 @@ type WalletAnchor interface { ImportTaprootOutput(context.Context, *btcec.PublicKey) (btcaddr.Address, error) + // LeaseInput leases the input if it belongs to the backing wallet. The + // returned boolean is false for inputs owned by an external signer. + LeaseInput(context.Context, wire.OutPoint) (bool, error) + + // ReleaseInput releases only a lease previously acquired through + // LeaseInput. It must not release leases owned by other subsystems. + ReleaseInput(context.Context, wire.OutPoint) error + // UnlockInput unlocks the set of target inputs after a batch or // send transaction is abandoned. UnlockInput(context.Context, wire.OutPoint) error From 33b262cc9c0a41d92c27be55dec73d82db0e8c37 Mon Sep 17 00:00:00 2001 From: Sadiq Jaffer Date: Fri, 14 Aug 2026 11:07:48 +0100 Subject: [PATCH 08/24] tapgarden: adopt recovered custom caretakers --- tapgarden/caretaker.go | 17 ++ tapgarden/issue721_stateful_test.go | 350 ++++++++++++++++++++++++++++ tapgarden/planter.go | 166 ++++++++++++- 3 files changed, 527 insertions(+), 6 deletions(-) diff --git a/tapgarden/caretaker.go b/tapgarden/caretaker.go index 52c4c5f6a4..84eaacf105 100644 --- a/tapgarden/caretaker.go +++ b/tapgarden/caretaker.go @@ -248,6 +248,23 @@ func (b *BatchCaretaker) Cancel() error { )) } + // A custom batch can hold wallet input leases while it waits for + // external signatures. Release those leases only after cancellation + // is durable. A wallet RPC failure is best effort because retaining + // the active planter slot after the durable state transition would + // leave memory and disk inconsistent. + if err == nil && b.cfg.Batch.GenesisPacket != nil && + isCustomAnchorPsbt(b.cfg.Batch.GenesisPacket.Pkt) { + + releaseErr := releaseCustomAnchorLeases( + ctx, b.cfg.Wallet, b.cfg.Batch.GenesisPacket, + ) + if releaseErr != nil { + log.Warnf("Unable to release one or more cancelled "+ + "custom anchor input leases: %v", releaseErr) + } + } + b.cfg.PublishMintEvent(newAssetMintEvent( BatchStateSproutCancelled, b.cfg.Batch, )) diff --git a/tapgarden/issue721_stateful_test.go b/tapgarden/issue721_stateful_test.go index e6a7618cf5..e3b44cbf37 100644 --- a/tapgarden/issue721_stateful_test.go +++ b/tapgarden/issue721_stateful_test.go @@ -33,6 +33,21 @@ type issue721FailSignedStore struct { fail bool } +type issue721FailStateStore struct { + tapgarden.MintingStore + fail bool +} + +func (s *issue721FailStateStore) UpdateBatchState(ctx context.Context, + batchKey *btcec.PublicKey, state tapgarden.BatchState) error { + + if s.fail && state == tapgarden.BatchStateSproutCancelled { + return fmt.Errorf("injected cancellation store failure") + } + + return s.MintingStore.UpdateBatchState(ctx, batchKey, state) +} + func (s *issue721FailSignedStore) StoreSignedGenesisPsbt(ctx context.Context, batchKey *btcec.PublicKey, funded *tapsend.FundedPsbt) error { @@ -632,6 +647,341 @@ func TestIssue721ImportRetryAfterRestart(t *testing.T) { h.assertNumCaretakersActive(1) } +// TestIssue721ImportFailureAfterRestartIsAdopted verifies that a recovered +// import-pending caretaker owns the exclusive planter slot and that a second +// import failure returns the batch to a usable pending state instead of +// leaving a dead caretaker registered. +func TestIssue721ImportFailureAfterRestartIsAdopted(t *testing.T) { + store := newMintingStore(t) + h := newMintingTestHarness(t, store) + h.refreshChainPlanter() + t.Cleanup(func() { + if h.planter != nil { + _ = h.planter.Stop() + } + }) + + h.queueSeedlingsInBatch(false, issue721Seedling()) + pkt, _, witnessScript := issue721Anchor(t) + ownedInput := pkt.UnsignedTx.TxIn[0].PreviousOutPoint + h.wallet.OwnedInputs[ownedInput] = true + issue721Fund(t, h, pkt) + initialLease, err := fn.RecvOrTimeout( + h.wallet.LeaseInputSignal, defaultTimeout, + ) + require.NoError(t, err) + require.Equal(t, ownedInput, *initialLease) + prepared, err := h.planter.PrepareBatch() + require.NoError(t, err) + signed := clonePacket(t, prepared.GenesisPacket.Pkt) + signed.Inputs[0].FinalScriptWitness = issue721FinalWitness( + t, witnessScript, + ) + + // The first import failure durably leaves the signed packet in the + // import-pending state. + h.wallet.ImportErr = fmt.Errorf("temporary import failure") + var wg sync.WaitGroup + respChan := make(chan *FinalizeBatchResp, 1) + h.finalizeBatch(&wg, respChan, &tapgarden.FinalizeParams{ + SignedPsbt: signed, + }) + finalizeLease, err := fn.RecvOrTimeout( + h.wallet.LeaseInputSignal, defaultTimeout, + ) + require.NoError(t, err) + require.Equal(t, ownedInput, *finalizeLease) + _, err = fn.RecvOrTimeout(h.wallet.ImportPubKeySignal, defaultTimeout) + require.NoError(t, err) + h.assertFinalizeBatch(&wg, respChan, "temporary import failure") + + // Restart with the import failure still armed. The owned input is renewed + // before the resumed caretaker attempts import. + h.leaseRenewalInterval = 20 * time.Millisecond + h.refreshChainPlanter() + restartLease, err := fn.RecvOrTimeout( + h.wallet.LeaseInputSignal, defaultTimeout, + ) + require.NoError(t, err) + require.Equal(t, ownedInput, *restartLease) + + // The pending reservation is independent from the caretaker's mutable + // batch. It remains safe to query, and the periodic renewal loop must not + // compete with the active recovery caretaker. + for i := 0; i < 10; i++ { + reserved, err := h.planter.PendingBatch() + require.NoError(t, err) + require.Equal(t, tapgarden.BatchStateCommitted, reserved.State()) + require.Equal(t, prepared.BatchKey.PubKey.SerializeCompressed(), + reserved.BatchKey.PubKey.SerializeCompressed()) + } + select { + case op := <-h.wallet.LeaseInputSignal: + t.Fatalf("active startup recovery lease renewed by gardener: %v", op) + case <-time.After(3 * h.leaseRenewalInterval): + } + + // While the resumed caretaker is blocked on import, the recovered batch + // occupies the exclusive slot. Mutations are rejected, and Cancel and + // Finalize return promptly instead of targeting the running caretaker. + second := issue721Seedling() + second.AssetName = "issue-721-recovery-second" + updates, err := h.planter.QueueNewSeedling(second) + require.NoError(t, err) + update, err := fn.RecvOrTimeout(updates, defaultTimeout) + require.NoError(t, err) + require.ErrorContains(t, update.Error, "cannot accept new seedlings") + _, err = h.planter.FinalizeBatch(tapgarden.FinalizeParams{ + SignedPsbt: signed, + }) + require.ErrorContains(t, err, "batch recovery is in progress") + _, err = h.planter.CancelBatch() + require.ErrorContains(t, err, "batch recovery is in progress") + + // Let the repeated import fail. The result monitor removes the exited + // caretaker and retains the same Committed batch for retry. + _, err = fn.RecvOrTimeout(h.wallet.ImportPubKeySignal, defaultTimeout) + require.NoError(t, err) + h.assertNumCaretakersActive(0) + pending, err := h.planter.PendingBatch() + require.NoError(t, err) + require.Equal(t, tapgarden.BatchStateCommitted, pending.State()) + require.Equal(t, prepared.BatchKey.PubKey.SerializeCompressed(), + pending.BatchKey.PubKey.SerializeCompressed()) + + // Finalize remains usable after adoption cleanup. Exercise a further + // failed retry, then cancel and verify the local lease is released. + h.finalizeBatch(&wg, respChan, &tapgarden.FinalizeParams{ + SignedPsbt: clonePacket(t, pending.GenesisPacket.Pkt), + }) + retryLease, err := fn.RecvOrTimeout( + h.wallet.LeaseInputSignal, defaultTimeout, + ) + require.NoError(t, err) + require.Equal(t, ownedInput, *retryLease) + _, err = fn.RecvOrTimeout(h.wallet.ImportPubKeySignal, defaultTimeout) + require.NoError(t, err) + h.assertFinalizeBatch(&wg, respChan, "temporary import failure") + h.assertNumCaretakersActive(0) + + batchKey, err := h.planter.CancelBatch() + require.NoError(t, err) + require.True(t, batchKey.IsEqual(prepared.BatchKey.PubKey)) + released, err := fn.RecvOrTimeout( + h.wallet.ReleaseInputSignal, defaultTimeout, + ) + require.NoError(t, err) + require.Equal(t, ownedInput, *released) + cancelled := h.fetchSingleBatch(batchKey) + require.Equal(t, tapgarden.BatchStateSproutCancelled, cancelled.State()) +} + +// TestIssue721PublishFailureAfterRestartIsAdopted verifies that a recovered +// publish-pending caretaker is cleaned up after a definitive backend rejection +// and leaves the durably rejected batch cancellable without a stale caretaker +// or wallet lease. +func TestIssue721PublishFailureAfterRestartIsAdopted(t *testing.T) { + store := newMintingStore(t) + h := newMintingTestHarness(t, store) + h.refreshChainPlanter() + t.Cleanup(func() { + if h.planter != nil { + _ = h.planter.Stop() + } + }) + + h.queueSeedlingsInBatch(false, issue721Seedling()) + pkt, _, witnessScript := issue721Anchor(t) + ownedInput := pkt.UnsignedTx.TxIn[0].PreviousOutPoint + h.wallet.OwnedInputs[ownedInput] = true + issue721Fund(t, h, pkt) + initialLease, err := fn.RecvOrTimeout( + h.wallet.LeaseInputSignal, defaultTimeout, + ) + require.NoError(t, err) + require.Equal(t, ownedInput, *initialLease) + prepared, err := h.planter.PrepareBatch() + require.NoError(t, err) + signed := clonePacket(t, prepared.GenesisPacket.Pkt) + signed.Inputs[0].FinalScriptWitness = issue721FinalWitness( + t, witnessScript, + ) + + // Simulate a crash after import succeeded and the signed PSBT crossed the + // durable publication boundary. + signed.Unknowns = append(signed.Unknowns, &psbt.Unknown{ + Key: []byte{0xfc, 0x04, 't', 'a', 'p', 'd', 0x02}, + Value: []byte{1}, + }) + funded := prepared.GenesisPacket.FundedPsbt + funded.Pkt = signed + err = store.StoreSignedGenesisPsbt( + t.Context(), prepared.BatchKey.PubKey, &funded, + ) + require.NoError(t, err) + + h.chain.FailPublishDefinitively() + h.refreshChainPlanter() + restartLease, err := fn.RecvOrTimeout( + h.wallet.LeaseInputSignal, defaultTimeout, + ) + require.NoError(t, err) + require.Equal(t, ownedInput, *restartLease) + + _, err = h.planter.CancelBatch() + require.ErrorContains(t, err, "batch recovery is in progress") + _, err = fn.RecvOrTimeout(h.wallet.ImportPubKeySignal, defaultTimeout) + require.NoError(t, err) + _, err = fn.RecvOrTimeout(h.chain.PublishAttempts, defaultTimeout) + require.NoError(t, err) + released, err := fn.RecvOrTimeout( + h.wallet.ReleaseInputSignal, defaultTimeout, + ) + require.NoError(t, err) + require.Equal(t, ownedInput, *released) + + h.assertNumCaretakersActive(0) + pending, err := h.planter.PendingBatch() + require.NoError(t, err) + require.Equal(t, tapgarden.BatchStateCommitted, pending.State()) + require.Empty(t, pending.GenesisPacket.LockedUTXOs) + persisted := h.fetchSingleBatch(pending.BatchKey.PubKey) + require.Empty(t, persisted.GenesisPacket.LockedUTXOs) + + batchKey, err := h.planter.CancelBatch() + require.NoError(t, err) + require.True(t, batchKey.IsEqual(prepared.BatchKey.PubKey)) + cancelled := h.fetchSingleBatch(batchKey) + require.Equal(t, tapgarden.BatchStateSproutCancelled, cancelled.State()) + select { + case op := <-h.wallet.ReleaseInputSignal: + t.Fatalf("lease released twice after definitive rejection: %v", op) + default: + } +} + +// TestIssue721RecoveredCaretakerFastConfirmation verifies that confirmation +// completion and the forwarded publication result can arrive in either order +// without leaving the startup recovery reservation behind. +func TestIssue721RecoveredCaretakerFastConfirmation(t *testing.T) { + store := newMintingStore(t) + h := newMintingTestHarness(t, store) + h.refreshChainPlanter() + t.Cleanup(func() { + if h.planter != nil { + _ = h.planter.Stop() + } + }) + + h.queueSeedlingsInBatch(false, issue721Seedling()) + pkt, _, witnessScript := issue721Anchor(t) + issue721Fund(t, h, pkt) + prepared, err := h.planter.PrepareBatch() + require.NoError(t, err) + signed := clonePacket(t, prepared.GenesisPacket.Pkt) + signed.Inputs[0].FinalScriptWitness = issue721FinalWitness( + t, witnessScript, + ) + signed.Unknowns = append(signed.Unknowns, &psbt.Unknown{ + Key: []byte{0xfc, 0x04, 't', 'a', 'p', 'd', 0x02}, + Value: []byte{1}, + }) + funded := prepared.GenesisPacket.FundedPsbt + funded.Pkt = signed + err = store.StoreSignedGenesisPsbt( + t.Context(), prepared.BatchKey.PubKey, &funded, + ) + require.NoError(t, err) + + h.refreshChainPlanter() + _, err = fn.RecvOrTimeout(h.wallet.ImportPubKeySignal, defaultTimeout) + require.NoError(t, err) + published := h.assertTxPublished() + + // A single-transaction block has the transaction ID as its merkle root. + merkleRoot := published.TxHash() + blockHeader := wire.NewBlockHeader( + 0, &chainhash.Hash{}, &merkleRoot, 0, 0, + ) + block := &wire.MsgBlock{ + Header: *blockHeader, + Transactions: []*wire.MsgTx{published}, + } + notify := h.assertConfReqSent(published, block) + notify() + + h.assertNumCaretakersActive(0) + pending, err := h.planter.PendingBatch() + require.NoError(t, err) + require.Nil(t, pending) + + // The cleared recovery reservation permits the next batch immediately. + second := issue721Seedling() + second.AssetName = "issue-721-after-fast-confirmation" + h.queueSeedlingsInBatch(false, second) +} + +// TestIssue721CaretakerCancelReleasesAfterDurableState verifies the caretaker +// path never releases a custom input lease until the sprout cancellation has +// been committed. Once durable, lease release is best effort and cancellation +// remains terminal. +func TestIssue721CaretakerCancelReleasesAfterDurableState(t *testing.T) { + store := &issue721FailStateStore{MintingStore: newMintingStore(t)} + h := newMintingTestHarness(t, store) + h.refreshChainPlanter() + t.Cleanup(func() { + if h.planter != nil { + _ = h.planter.Stop() + } + }) + + h.queueSeedlingsInBatch(false, issue721Seedling()) + pkt, _, _ := issue721Anchor(t) + ownedInput := pkt.UnsignedTx.TxIn[0].PreviousOutPoint + h.wallet.OwnedInputs[ownedInput] = true + issue721Fund(t, h, pkt) + leased, err := fn.RecvOrTimeout( + h.wallet.LeaseInputSignal, defaultTimeout, + ) + require.NoError(t, err) + require.Equal(t, ownedInput, *leased) + prepared, err := h.planter.PrepareBatch() + require.NoError(t, err) + + newCaretaker := func() *tapgarden.BatchCaretaker { + return tapgarden.NewBatchCaretaker(&tapgarden.BatchCaretakerConfig{ + Batch: prepared, + GardenKit: tapgarden.GardenKit{ + Wallet: h.wallet, + Log: store, + }, + CancelRespChan: make(chan tapgarden.CancelResp, 1), + PublishMintEvent: func(fn.Event) { + }, + }) + } + + store.fail = true + require.NoError(t, newCaretaker().Cancel()) + select { + case op := <-h.wallet.ReleaseInputSignal: + t.Fatalf("lease released before durable cancellation: %v", op) + default: + } + persisted := h.fetchSingleBatch(prepared.BatchKey.PubKey) + require.Equal(t, tapgarden.BatchStateCommitted, persisted.State()) + + store.fail = false + require.NoError(t, newCaretaker().Cancel()) + released, err := fn.RecvOrTimeout( + h.wallet.ReleaseInputSignal, defaultTimeout, + ) + require.NoError(t, err) + require.Equal(t, ownedInput, *released) + persisted = h.fetchSingleBatch(prepared.BatchKey.PubKey) + require.Equal(t, tapgarden.BatchStateSproutCancelled, persisted.State()) +} + func TestIssue721ImportRestartLeaseFailurePauses(t *testing.T) { store := newMintingStore(t) h := newMintingTestHarness(t, store) diff --git a/tapgarden/planter.go b/tapgarden/planter.go index 8e92ff9808..a68a11f20f 100644 --- a/tapgarden/planter.go +++ b/tapgarden/planter.go @@ -776,6 +776,11 @@ type ChainPlanter struct { // any relevant resources. completionSignals chan BatchKey + // startupCaretakerResults carries the first publication result from a + // custom caretaker that was resumed during startup. The gardener is the + // sole owner of the corresponding caretaker and pending batch mutations. + startupCaretakerResults chan startupCaretakerResult + // stateReqs is the channel that any outside requests for the state of // the planter will come across. stateReqs chan stateRequest @@ -792,15 +797,26 @@ type ChainPlanter struct { *fn.ContextGuard } +// startupCaretakerResult is the first publication result from a custom batch +// caretaker that was resumed during startup. +type startupCaretakerResult struct { + batchKey BatchKey + caretaker *BatchCaretaker + err error +} + // NewChainPlanter creates a new ChainPlanter instance given the passed config. func NewChainPlanter(cfg PlanterConfig) *ChainPlanter { return &ChainPlanter{ - cfg: cfg, - caretakers: make(map[BatchKey]*BatchCaretaker), - completionSignals: make(chan BatchKey), - seedlingReqs: make(chan *Seedling), - stateReqs: make(chan stateRequest), - subscribers: make(map[uint64]*fn.EventReceiver[fn.Event]), + cfg: cfg, + caretakers: make(map[BatchKey]*BatchCaretaker), + completionSignals: make(chan BatchKey), + startupCaretakerResults: make(chan startupCaretakerResult, 1), + seedlingReqs: make(chan *Seedling), + stateReqs: make(chan stateRequest), + subscribers: make( + map[uint64]*fn.EventReceiver[fn.Event], + ), ContextGuard: &fn.ContextGuard{ DefaultTimeout: DefaultTimeout, Quit: make(chan struct{}), @@ -936,6 +952,27 @@ func (c *ChainPlanter) Start() error { continue } + // A custom batch with a signed transaction awaiting import or + // publication is resumed automatically. Adopt it as the exclusive + // pending batch before launching its caretaker so no new batch can + // be admitted while recovery is in flight. + startupCustomRecovery := customBatch && + batchState == BatchStateCommitted && + (publishState == customAnchorImportPending || + publishState == customAnchorPublishPending) + if startupCustomRecovery { + if c.pendingBatch != nil { + startErr = fmt.Errorf("multiple custom batches " + + "require startup recovery") + return + } + + // The caretaker mutates its batch while crossing import and + // publication boundaries. Reserve an independent copy so API + // reads and the gardener never race those mutations. + c.pendingBatch = batch.Copy() + } + // If batch funding or sealing fail during startup, the // batch will be marked as cancelled. The batch can // still be displayed by the planter, and can be @@ -1034,9 +1071,20 @@ func (c *ChainPlanter) Start() error { log.Infof("Launching ChainCaretaker(%x)", batchKey) caretaker := c.newCaretakerForBatch(batch, nil) if err := caretaker.Start(); err != nil { + delete(c.caretakers, asset.ToSerialized( + batch.BatchKey.PubKey, + )) startErr = err return } + + if startupCustomRecovery { + batchKey := asset.ToSerialized(batch.BatchKey.PubKey) + c.Wg.Add(1) + go c.forwardStartupCaretakerResult( + batchKey, caretaker, + ) + } } // With all the caretakers for each minting batch launched, @@ -1049,6 +1097,33 @@ func (c *ChainPlanter) Start() error { return startErr } +// forwardStartupCaretakerResult forwards exactly one publication result from +// a custom caretaker resumed during startup. It never mutates planter state; +// the gardener processes the result serially with all API requests. +func (c *ChainPlanter) forwardStartupCaretakerResult(batchKey BatchKey, + caretaker *BatchCaretaker) { + + defer c.Wg.Done() + + var result startupCaretakerResult + result.batchKey = batchKey + result.caretaker = caretaker + + select { + case <-caretaker.cfg.BroadcastCompleteChan: + + case result.err = <-caretaker.cfg.BroadcastErrChan: + + case <-c.Quit: + return + } + + select { + case c.startupCaretakerResults <- result: + case <-c.Quit: + } +} + // Stop signals the ChainPlanter to halt all operations gracefully. func (c *ChainPlanter) Stop() error { var stopErr error @@ -2616,6 +2691,52 @@ func (c *ChainPlanter) gardener() { for { select { + case result := <-c.startupCaretakerResults: + caretaker, ok := c.caretakers[result.batchKey] + if !ok || caretaker != result.caretaker { + // A fast confirmation can remove the caretaker before the + // gardener consumes its forwarded publication success. The + // matching reservation is still safe to release. + if result.err == nil && c.pendingBatch != nil && + asset.ToSerialized( + c.pendingBatch.BatchKey.PubKey, + ) == result.batchKey { + + c.pendingBatch = nil + } + + log.Warnf("Ignoring stale startup caretaker result for %x", + result.batchKey[:]) + continue + } + + if result.err == nil { + // Publication succeeded. Release the exclusive recovery + // slot, but retain the caretaker while it waits for + // confirmation. + if c.pendingBatch != nil && + asset.ToSerialized( + c.pendingBatch.BatchKey.PubKey, + ) == result.batchKey { + + c.pendingBatch = nil + } + + continue + } + + // The caretaker has exited before publication completed. Remove + // it before returning the same batch to the pending slot so + // Finalize and Cancel cannot target a dead caretaker. + if err := caretaker.Stop(); err != nil { + log.Warnf("Unable to stop failed startup caretaker: %v", + err) + } + delete(c.caretakers, result.batchKey) + c.pendingBatch = caretaker.cfg.Batch + log.Warnf("Startup recovery failed for batch %x: %v", + result.batchKey[:], result.err) + case <-leaseRenewalTicker.C: batch := c.pendingBatch if batch == nil || batch.GenesisPacket == nil || @@ -2623,6 +2744,14 @@ func (c *ChainPlanter) gardener() { continue } + + // A startup recovery caretaker owns lease renewal until its first + // publication result. The pending batch is only an immutable + // reservation while that caretaker is active. + batchKey := asset.ToSerialized(batch.BatchKey.PubKey) + if _, ok := c.caretakers[batchKey]; ok { + continue + } switch batch.State() { case BatchStatePending, BatchStateFrozen, BatchStateCommitted: @@ -2702,6 +2831,17 @@ func (c *ChainPlanter) gardener() { delete(c.caretakers, batchKey) + // A recovered custom caretaker can confirm before its forwarded + // publication success is selected. Release its exact reservation + // here as well so event ordering cannot strand a Committed batch. + if c.pendingBatch != nil && + asset.ToSerialized( + c.pendingBatch.BatchKey.PubKey, + ) == batchKey { + + c.pendingBatch = nil + } + // TODO(roasbeef): send completion signal? // A new request just came along to query our internal state. @@ -2866,6 +3006,10 @@ func (c *ChainPlanter) gardener() { batchKey := c.pendingBatch.BatchKey.PubKey batchKeySerial := asset.ToSerialized(batchKey) + if _, ok := c.caretakers[batchKeySerial]; ok { + req.Error(fmt.Errorf("batch recovery is in progress")) + break + } log.Infof("Finalizing batch %x", batchKeySerial) finalizeReqParams, err := @@ -2928,6 +3072,16 @@ func (c *ChainPlanter) gardener() { } case reqTypeCancelBatch: + if c.pendingBatch != nil { + batchKey := asset.ToSerialized( + c.pendingBatch.BatchKey.PubKey, + ) + if _, ok := c.caretakers[batchKey]; ok { + req.Error(fmt.Errorf("batch recovery is in progress")) + break + } + } + batchKey, err := c.canCancelBatch() if err != nil { req.Error(err) From f8fd925126b766ca29146f3348a943562cf8d764 Mon Sep 17 00:00:00 2001 From: Sadiq Jaffer Date: Sat, 15 Aug 2026 12:30:24 +0100 Subject: [PATCH 09/24] tapgarden: harden custom genesis anchor lifecycle --- lndservices/chain_bridge.go | 210 +++++++- lndservices/chain_bridge_test.go | 254 +++++++++ rpcserver/mint_status_test.go | 39 ++ rpcserver/rpcserver.go | 11 +- tapdb/asset_minting.go | 122 ++++- tapdb/asset_minting_test.go | 204 +++++++- tapdb/assets_store_test.go | 55 ++ tapdb/sqlc/assets.sql.go | 135 ++++- tapdb/sqlc/querier.go | 3 + tapdb/sqlc/queries/assets.sql | 69 ++- tapgarden/batch.go | 25 +- tapgarden/caretaker.go | 346 ++++++++++--- tapgarden/custom_anchor_repair.go | 422 +++++++++++++++ tapgarden/custom_anchor_repair_test.go | 527 +++++++++++++++++++ tapgarden/custom_anchor_test.go | 88 +++- tapgarden/interface.go | 26 + tapgarden/issue721_stateful_test.go | 685 +++++++++++++++++++++---- tapgarden/planter.go | 327 ++++++++++-- tapgarden/planter_test.go | 34 +- tapnode/tapnodemock/wallet_anchor.go | 78 ++- taprpc/mintrpc/mint.pb.go | 44 +- taprpc/mintrpc/mint.proto | 12 + taprpc/mintrpc/mint.swagger.json | 14 +- 23 files changed, 3457 insertions(+), 273 deletions(-) create mode 100644 rpcserver/mint_status_test.go create mode 100644 tapgarden/custom_anchor_repair.go create mode 100644 tapgarden/custom_anchor_repair_test.go diff --git a/lndservices/chain_bridge.go b/lndservices/chain_bridge.go index af7e57f52c..7c1c8ab459 100644 --- a/lndservices/chain_bridge.go +++ b/lndservices/chain_bridge.go @@ -7,6 +7,7 @@ import ( "sort" "strings" "time" + "unicode" "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" @@ -19,6 +20,7 @@ import ( "github.com/lightningnetwork/lnd/chainntnfs" "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) @@ -340,9 +342,9 @@ func (l *LndRpcChainBridge) PublishTransaction(ctx context.Context, } // ValidateAndPublishTransaction submits a transaction before tapd persists an -// irreversible broadcast state. Errors returned by the remote application are -// definitive because lnd performs TestMempoolAccept before publishing, while -// transport and context failures remain ambiguous. +// irreversible broadcast state. Only explicit, allowlisted TestMempoolAccept +// rejections are definitive. Transport, context and unrecognized application +// failures remain ambiguous. func (l *LndRpcChainBridge) ValidateAndPublishTransaction(ctx context.Context, tx *wire.MsgTx, label string) error { @@ -351,11 +353,19 @@ func (l *LndRpcChainBridge) ValidateAndPublishTransaction(ctx context.Context, return nil } + return wrapValidateAndPublishError(err) +} + +// wrapValidateAndPublishError adds operation context while retaining the +// definitive marker, gRPC status and original error in the unwrap chain. +func wrapValidateAndPublishError(err error) error { + publishErr := fmt.Errorf("unable to validate and publish transaction: %w", + err) if isDefinitivePublishError(err) { - return tapnode.NewDefinitivePublishError(err) + return tapnode.NewDefinitivePublishError(publishErr) } - return err + return publishErr } // isDefinitivePublishError recognizes the typed lnd publication errors that @@ -364,39 +374,183 @@ func (l *LndRpcChainBridge) ValidateAndPublishTransaction(ctx context.Context, // after relaying the transaction, so they must not restore a cancellable // pre-broadcast state. func isDefinitivePublishError(err error) bool { - errMsg := strings.ToLower(status.Convert(err).Message()) - if strings.HasPrefix( - errMsg, strings.ToLower(lnwallet.ErrMempoolFee.Error()), - ) { + rpcStatus, ok := status.FromError(err) + if !ok { + return false + } + + // The pinned WalletKit implementation returns mapped backend publication + // errors as Unknown. Do not infer equivalent semantics for any other gRPC + // code: even policy-looking text is ambiguous there. + if rpcStatus.Code() != codes.Unknown { + return false + } + + // Reject reasons use both hyphenated and space-separated spellings. + // Tokenizing them gives us delimiter-insensitive matching while keeping + // every match bounded to complete words. + tokens := publishErrorTokens(rpcStatus.Message()) + if len(tokens) == 0 { + return false + } + + // Missing-input, conflict and already-known responses are ambiguous. + // The backend may have a different mempool view, or may already have + // accepted the transaction. Keep these exclusions ahead of the policy + // allowlist so a secondary phrase such as "insufficient fee" can never + // make them definitive. + ambiguousTokens := map[string]struct{}{ + "already": {}, "alreadyexists": {}, "alreadyknown": {}, + "conflict": {}, "conflicting": {}, "conflicts": {}, + "doublespend": {}, "exists": {}, "known": {}, + "missing": {}, "missingorspent": {}, "nonexistent": {}, + "orphan": {}, "replacement": {}, "spend": {}, + "spending": {}, "spent": {}, "unavailable": {}, + } + if fn.Any(tokens, func(token string) bool { + _, ambiguous := ambiguousTokens[token] + return ambiguous + }) { + + return false + } + + ambiguousPhrases := [][]string{ + {"double", "spend"}, {"not", "found"}, + {"in", "mempool"}, {"in", "chain"}, + {"in", "block", "chain"}, {"in", "blockchain"}, + {"unknown", "input"}, {"unknown", "inputs"}, + {"unknown", "outpoint"}, {"unknown", "parent"}, + } + if containsAnyTokenPhrase(tokens, ambiguousPhrases) { + return false + } + + return isDefinitiveRejectReason(tokens) +} +// isDefinitiveRejectReason matches only complete TestMempoolAccept reject +// reasons or reasons behind explicit reject framing. This avoids treating +// incidental text such as "backend dust cache unavailable" as a rejection. +func isDefinitiveRejectReason(tokens []string) bool { + mempoolFeePrefix := publishErrorTokens(lnwallet.ErrMempoolFee.Error()) + if hasTokenPrefix(tokens, mempoolFeePrefix) { return true } - // ErrDoubleSpend also represents missing-inputs. A missing parent can - // exist in another peer's mempool, so that collapsed lnd error is not a - // safe basis for making a signed transaction cancellable. - if strings.HasPrefix( - errMsg, strings.ToLower(lnwallet.ErrDoubleSpend.Error()), - ) { + // Some backends add a small amount of explicit framing around the raw + // reject reason. Strip only known frames; arbitrary leading text must not + // turn a policy word into a definitive result. + rejectFrames := [][]string{ + {"transaction", "rejected", "by", "the", "mempool"}, + {"transaction", "rejected", "by", "mempool"}, + {"transaction", "rejected"}, + {"mempool", "reject", "reason"}, + {"mempool", "rejection"}, + {"reject", "reason"}, + } + for _, frame := range rejectFrames { + if hasTokenPrefix(tokens, frame) && len(tokens) > len(frame) { + tokens = tokens[len(frame):] + break + } + } + + // These are stable policy and consensus reject reasons produced by + // TestMempoolAccept. The bad-txns entries are intentionally enumerated; + // a broad bad-txns prefix would also accept inputs-missingorspent. + definitiveRejectReasons := [][]string{ + {"non", "final"}, {"nonfinal"}, + {"non", "bip68", "final"}, + {"dust"}, {"transaction", "output", "is", "dust"}, + {"nonstandard"}, {"non", "standard"}, + {"non", "mandatory", "script", "verify", "flag"}, + {"non", "mandatory", "script", "verify", "flag", "failed"}, + {"mandatory", "script", "verify", "flag"}, + {"mandatory", "script", "verify", "flag", "failed"}, + {"tx", "size"}, {"tx", "size", "small"}, + {"too", "many", "sigops"}, + {"bad", "txns", "too", "many", "sigops"}, + {"scriptpubkey"}, + {"too", "long", "mempool", "chain"}, + {"too", "many", "ancestors"}, + {"too", "many", "descendants"}, + {"insufficient", "fee"}, {"min", "relay", "fee"}, + {"min", "relay", "fee", "not", "met"}, + {"mempool", "min", "fee"}, + {"mempool", "min", "fee", "not", "met"}, + {"bad", "txns", "vin", "empty"}, + {"bad", "txns", "vout", "empty"}, + {"bad", "txns", "oversize"}, + {"bad", "txns", "vout", "negative"}, + {"bad", "txns", "vout", "toolarge"}, + {"bad", "txns", "txouttotal", "toolarge"}, + {"bad", "txns", "prevout", "null"}, + {"bad", "txns", "inputvalues", "outofrange"}, + {"bad", "txns", "in", "belowout"}, + {"bad", "txns", "fee", "outofrange"}, + {"bad", "txns", "inputs", "duplicate"}, + } + + return fn.Any(definitiveRejectReasons, func(reason []string) bool { + return tokenSlicesEqual(tokens, reason) + }) +} + +// publishErrorTokens splits a reject reason into lowercase alphanumeric words. +// This normalizes punctuation (including hyphens) without permitting substring +// matches inside unrelated words. +func publishErrorTokens(errMsg string) []string { + return strings.FieldsFunc(strings.ToLower(errMsg), func(r rune) bool { + return !unicode.IsLetter(r) && !unicode.IsDigit(r) + }) +} + +// hasTokenPrefix reports whether prefix occurs at the start of tokens. +func hasTokenPrefix(tokens, prefix []string) bool { + return len(prefix) <= len(tokens) && tokenSlicesEqual( + tokens[:len(prefix)], prefix, + ) +} +// tokenSlicesEqual reports whether two token slices are equal. +func tokenSlicesEqual(a, b []string) bool { + if len(a) != len(b) { return false } - // These are stable families of policy/consensus reject reasons returned - // directly by TestMempoolAccept. They establish that the backend did not - // relay the transaction. Conflict/missing-input reasons are deliberately - // excluded because their view may differ across mempools. - definitiveRejectMarkers := []string{ - "non-final", "non-bip68-final", "dust", "nonstandard", - "non-mandatory-script-verify-flag", - "mandatory-script-verify-flag", "bad-txns-", "tx-size", - "too many sigops", "too-long-mempool-chain", - "too many ancestors", "too many descendants", - "insufficient fee", "min relay fee", "mempool min fee", + for idx := range a { + if a[idx] != b[idx] { + return false + } } - return fn.Any(definitiveRejectMarkers, func(marker string) bool { - return strings.Contains(errMsg, marker) + return true +} + +// containsAnyTokenPhrase reports whether any phrase occurs as a contiguous, +// word-bounded sequence within tokens. +func containsAnyTokenPhrase(tokens []string, phrases [][]string) bool { + return fn.Any(phrases, func(phrase []string) bool { + if len(phrase) == 0 || len(phrase) > len(tokens) { + return false + } + + for start := 0; start <= len(tokens)-len(phrase); start++ { + matches := true + for offset := range phrase { + if tokens[start+offset] != phrase[offset] { + matches = false + break + } + } + + if matches { + return true + } + } + + return false }) } diff --git a/lndservices/chain_bridge_test.go b/lndservices/chain_bridge_test.go index 773d5b6dfe..498b9f3a9b 100644 --- a/lndservices/chain_bridge_test.go +++ b/lndservices/chain_bridge_test.go @@ -4,6 +4,8 @@ import ( "errors" "testing" + btcwalletchain "github.com/btcsuite/btcwallet/chain" + "github.com/lightninglabs/taproot-assets/tapnode" "github.com/lightningnetwork/lnd/lnwallet" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" @@ -40,6 +42,135 @@ func TestDefinitivePublishError(t *testing.T) { ), definitive: true, }, + { + name: "invalid argument is not the pinned wallet path", + err: status.Error( + codes.InvalidArgument, + "transaction rejected: non final", + ), + definitive: false, + }, + { + name: "failed precondition is not pinned wallet path", + err: status.Error( + codes.FailedPrecondition, + "mandatory-script-verify-flag failed", + ), + definitive: false, + }, + { + name: "bounded dust marker", + err: status.Error( + codes.Unknown, "dusting service failed", + ), + definitive: false, + }, + { + name: "incidental standalone dust marker", + err: status.Error( + codes.Unknown, "backend dust cache unavailable", + ), + definitive: false, + }, + { + name: "explicitly framed dust rejection", + err: status.Error( + codes.Unknown, "transaction rejected: dust", + ), + definitive: true, + }, + { + name: "incidental nonstandard marker", + err: status.Error( + codes.Unknown, "backend nonstandard cache failure", + ), + definitive: false, + }, + { + name: "bounded fee marker", + err: status.Error( + codes.Unknown, "insufficient feedback", + ), + definitive: false, + }, + { + name: "missing inputs override policy marker", + err: status.Error( + codes.Unknown, + "bad-txns-inputs-missingorspent: "+ + "insufficient fee", + ), + definitive: false, + }, + { + name: "mempool conflict overrides dust marker", + err: status.Error( + codes.Unknown, "txn-mempool-conflict: dust", + ), + definitive: false, + }, + { + name: "plural conflict overrides dust marker", + err: status.Error( + codes.Unknown, + "transaction conflicts with mempool: dust", + ), + definitive: false, + }, + { + name: "double spend overrides fee marker", + err: status.Error( + codes.Unknown, "double-spend: insufficient fee", + ), + definitive: false, + }, + { + name: "replacement conflict overrides fee marker", + err: status.Error( + codes.Unknown, + "replacement transaction underpriced: "+ + "min relay fee not met", + ), + definitive: false, + }, + { + name: "already known overrides policy marker", + err: status.Error( + codes.Unknown, + "txn-already-known: mempool min fee", + ), + definitive: false, + }, + { + name: "known transaction overrides policy marker", + err: status.Error( + codes.Unknown, + "transaction known: mempool min fee", + ), + definitive: false, + }, + { + name: "already in block chain", + err: status.Error( + codes.Unknown, + "transaction already in block chain: dust", + ), + definitive: false, + }, + { + name: "bad txns allowlist entry", + err: status.Error( + codes.Unknown, "bad-txns-vout-negative", + ), + definitive: true, + }, + { + name: "unrecognized bad txns reason", + err: status.Error( + codes.Unknown, "bad-txns-future-new-reason", + ), + definitive: false, + }, { name: "generic application error", err: status.Error(codes.Unknown, "backend failure"), @@ -52,11 +183,56 @@ func TestDefinitivePublishError(t *testing.T) { ), definitive: false, }, + { + name: "transport failure with policy text", + err: status.Error( + codes.Unavailable, "dust", + ), + definitive: false, + }, + { + name: "internal failure with policy text", + err: status.Error( + codes.Internal, "insufficient fee", + ), + definitive: false, + }, + { + name: "canceled with policy text", + err: status.Error( + codes.Canceled, "non-final", + ), + definitive: false, + }, + { + name: "deadline exceeded with policy text", + err: status.Error( + codes.DeadlineExceeded, "dust", + ), + definitive: false, + }, + { + name: "undefined application error with policy text", + err: status.Error( + codes.Unknown, "undefined: non-final", + ), + definitive: false, + }, { name: "plain error", err: errors.New("local failure"), definitive: false, }, + { + name: "plain error with policy text", + err: errors.New("non-final"), + definitive: false, + }, + { + name: "raw undefined error with policy text", + err: errors.New("undefined: non-final"), + definitive: false, + }, } { t.Run(testCase.name, func(t *testing.T) { require.Equal( @@ -66,3 +242,81 @@ func TestDefinitivePublishError(t *testing.T) { }) } } + +func TestDefinitivePublishRPCErrReasons(t *testing.T) { + t.Parallel() + + definitive := []btcwalletchain.RPCErr{ + btcwalletchain.ErrInsufficientFee, + btcwalletchain.ErrMempoolMinFeeNotMet, + btcwalletchain.ErrMinRelayFeeNotMet, + btcwalletchain.ErrMempoolChainTooLong, + btcwalletchain.ErrEmptyOutput, + btcwalletchain.ErrEmptyInput, + btcwalletchain.ErrTxTooSmall, + btcwalletchain.ErrDuplicateInput, + btcwalletchain.ErrEmptyPrevOut, + btcwalletchain.ErrBelowOutValue, + btcwalletchain.ErrNegativeOutput, + btcwalletchain.ErrLargeOutput, + btcwalletchain.ErrLargeTotalOutput, + btcwalletchain.ErrScriptVerifyFlag, + btcwalletchain.ErrTooManySigOps, + btcwalletchain.ErrOversizeTx, + btcwalletchain.ErrNonStandardScript, + btcwalletchain.ErrTxTooLarge, + btcwalletchain.ErrDust, + btcwalletchain.ErrNonFinal, + btcwalletchain.ErrNonBIP68Final, + btcwalletchain.ErrNonMandatoryScriptVerifyFlag, + } + for _, reason := range definitive { + reason := reason + t.Run(reason.Error(), func(t *testing.T) { + err := status.Error(codes.Unknown, reason.Error()) + require.True(t, isDefinitivePublishError(err)) + }) + } + + ambiguous := []btcwalletchain.RPCErr{ + btcwalletchain.ErrMissingInputsOrSpent, + btcwalletchain.ErrTxAlreadyKnown, + btcwalletchain.ErrTxAlreadyConfirmed, + btcwalletchain.ErrMempoolConflict, + btcwalletchain.ErrReplacementAddsUnconfirmed, + btcwalletchain.ErrTooManyReplacements, + btcwalletchain.ErrConflictingTx, + btcwalletchain.ErrTxAlreadyInMempool, + btcwalletchain.ErrMissingInputs, + btcwalletchain.ErrSameNonWitnessData, + } + for _, reason := range ambiguous { + reason := reason + t.Run(reason.Error(), func(t *testing.T) { + err := status.Error(codes.Unknown, reason.Error()) + require.False(t, isDefinitivePublishError(err)) + }) + } +} + +func TestWrapValidateAndPublishError(t *testing.T) { + t.Parallel() + + definitiveSource := status.Error(codes.Unknown, "non-final") + definitiveErr := wrapValidateAndPublishError(definitiveSource) + require.ErrorContains( + t, definitiveErr, "unable to validate and publish transaction", + ) + require.True(t, tapnode.IsDefinitivePublishError(definitiveErr)) + require.ErrorIs(t, definitiveErr, definitiveSource) + require.Equal(t, codes.Unknown, status.Code(definitiveErr)) + + ambiguousSource := status.Error(codes.Unavailable, "connection closed") + ambiguousErr := wrapValidateAndPublishError(ambiguousSource) + require.ErrorContains( + t, ambiguousErr, "unable to validate and publish transaction", + ) + require.False(t, tapnode.IsDefinitivePublishError(ambiguousErr)) + require.ErrorIs(t, ambiguousErr, ambiguousSource) + require.Equal(t, codes.Unavailable, status.Code(ambiguousErr)) +} diff --git a/rpcserver/mint_status_test.go b/rpcserver/mint_status_test.go new file mode 100644 index 0000000000..d8f3846496 --- /dev/null +++ b/rpcserver/mint_status_test.go @@ -0,0 +1,39 @@ +package rpcserver + +import ( + "testing" + + "github.com/lightninglabs/taproot-assets/internal/test" + "github.com/lightninglabs/taproot-assets/tapgarden" + "github.com/stretchr/testify/require" +) + +func TestMarshalCustomAnchorStatus(t *testing.T) { + t.Parallel() + + batchKey, _ := test.RandKeyDesc(t) + batch := &tapgarden.MintingBatch{ + BatchKey: batchKey, + CustomAnchorLeaseError: "lease renewal degraded", + CustomAnchorPublishError: "wallet publication ambiguous", + CustomAnchorKeyError: "wallet locator required", + } + + rpcBatch, err := marshalMintingBatch(batch, true) + require.NoError(t, err) + require.Equal(t, batch.CustomAnchorLeaseError, + rpcBatch.CustomAnchorLeaseError) + require.Equal(t, batch.CustomAnchorPublishError, + rpcBatch.CustomAnchorPublishError) + require.Equal(t, batch.CustomAnchorKeyError, + rpcBatch.CustomAnchorKeyError) + + batch.CustomAnchorLeaseError = "" + batch.CustomAnchorPublishError = "" + batch.CustomAnchorKeyError = "" + rpcBatch, err = marshalMintingBatch(batch, true) + require.NoError(t, err) + require.Empty(t, rpcBatch.CustomAnchorLeaseError) + require.Empty(t, rpcBatch.CustomAnchorPublishError) + require.Empty(t, rpcBatch.CustomAnchorKeyError) +} diff --git a/rpcserver/rpcserver.go b/rpcserver/rpcserver.go index 4d3c35cbe6..6272954581 100644 --- a/rpcserver/rpcserver.go +++ b/rpcserver/rpcserver.go @@ -6275,10 +6275,13 @@ func marshalMintingBatch(batch *tapgarden.MintingBatch, } rpcBatch := &mintrpc.MintingBatch{ - BatchKey: batch.BatchKey.PubKey.SerializeCompressed(), - State: rpcBatchState, - CreatedAt: batch.CreationTime.UTC().Unix(), - HeightHint: batch.HeightHint, + BatchKey: batch.BatchKey.PubKey.SerializeCompressed(), + State: rpcBatchState, + CreatedAt: batch.CreationTime.UTC().Unix(), + HeightHint: batch.HeightHint, + CustomAnchorLeaseError: batch.CustomAnchorLeaseError, + CustomAnchorPublishError: batch.CustomAnchorPublishError, + CustomAnchorKeyError: batch.CustomAnchorKeyError, } // If we have the genesis packet available (funded+signed), then we'll diff --git a/tapdb/asset_minting.go b/tapdb/asset_minting.go index f79f95a5dc..f6cbf651c0 100644 --- a/tapdb/asset_minting.go +++ b/tapdb/asset_minting.go @@ -260,6 +260,15 @@ type PendingAssetStore interface { arg UpsertBatchPreCommitParams) (int64, error) } +type customAnchorKeyRepairQueries interface { + FetchCustomAnchorKeyRepairCandidates(context.Context) ( + []sqlc.FetchCustomAnchorKeyRepairCandidatesRow, error) + RepairCustomAnchorInternalKey(context.Context, + sqlc.RepairCustomAnchorInternalKeyParams) (int64, error) + UpsertWalletVerifiedInternalKey(context.Context, + sqlc.UpsertWalletVerifiedInternalKeyParams) (int64, error) +} + var ( // ErrFetchMintingBatches is returned when fetching multiple minting // batches fails. @@ -1853,6 +1862,103 @@ func (a *AssetMintingStore) AddSproutsToBatch(ctx context.Context, }) } +// FetchCustomAnchorKeyRepairCandidates returns historical mint rows with the +// retained data needed for the wallet-aware repair audit. +func (a *AssetMintingStore) FetchCustomAnchorKeyRepairCandidates( + ctx context.Context) ([]tapgarden.CustomAnchorKeyRepairCandidate, error) { + + var candidates []tapgarden.CustomAnchorKeyRepairCandidate + readOpts := NewAssetStoreReadTx() + err := a.db.ExecTx(ctx, &readOpts, func(q PendingAssetStore) error { + repairQueries, ok := q.(customAnchorKeyRepairQueries) + if !ok { + return fmt.Errorf("custom anchor key repair queries unavailable") + } + + rows, err := repairQueries.FetchCustomAnchorKeyRepairCandidates(ctx) + if err != nil { + return err + } + + for _, row := range rows { + assetOutputIndex := ^uint32(0) + if row.AssetsOutputIndex.Valid && + row.AssetsOutputIndex.Int32 >= 0 { + + assetOutputIndex = uint32(row.AssetsOutputIndex.Int32) + } + + candidates = append(candidates, + tapgarden.CustomAnchorKeyRepairCandidate{ + BatchKey: row.BatchKey, + MintingTxPsbt: row.MintingTxPsbt, + AssetOutputIndex: assetOutputIndex, + Outpoint: row.Outpoint, + ManagedInternalKey: row.ManagedInternalKey, + MerkleRoot: row.MerkleRoot, + }, + ) + } + + return nil + }) + + return candidates, err +} + +// RepairCustomAnchorInternalKey compare-and-swaps the exact poisoned managed +// UTXO to a descriptor that the caller has already proven belongs to the +// wallet and binds the candidate's committed output. +func (a *AssetMintingStore) RepairCustomAnchorInternalKey(ctx context.Context, + candidate tapgarden.CustomAnchorKeyRepairCandidate, + desc keychain.KeyDescriptor) error { + + if desc.PubKey == nil { + return fmt.Errorf("verified custom anchor key is missing") + } + + var writeOpts AssetStoreTxOptions + return a.db.ExecTx(ctx, &writeOpts, func(q PendingAssetStore) error { + repairQueries, ok := q.(customAnchorKeyRepairQueries) + if !ok { + return fmt.Errorf("custom anchor key repair queries unavailable") + } + + rawKey := desc.PubKey.SerializeCompressed() + _, err := repairQueries.UpsertWalletVerifiedInternalKey( + ctx, sqlc.UpsertWalletVerifiedInternalKeyParams{ + RawKey: rawKey, + KeyFamily: int32(desc.Family), + KeyIndex: int32(desc.Index), + }, + ) + if err != nil { + return fmt.Errorf("unable to store verified custom anchor key: %w", err) + } + + updated, err := repairQueries.RepairCustomAnchorInternalKey( + ctx, sqlc.RepairCustomAnchorInternalKeyParams{ + BatchKey: candidate.BatchKey, + AssetOutputIndex: sql.NullInt32{ + Int32: int32(candidate.AssetOutputIndex), + Valid: true, + }, + ExpectedOldKey: candidate.BatchKey, + ReplacementKey: rawKey, + Outpoint: candidate.Outpoint, + }, + ) + if err != nil { + return err + } + if updated != 1 { + return fmt.Errorf("custom anchor key repair compare-and-swap updated %d rows", updated) + } + + return nil + }) +} + // CommitSignedGenesisTx binds a fully signed genesis transaction to a pending // batch on disk. The anchor output index and script root are also stored to // ensure we can reconstruct the private key needed to sign for the batch. The @@ -1907,11 +2013,17 @@ func (a *AssetMintingStore) CommitSignedGenesisTx(ctx context.Context, var writeTxOpts AssetStoreTxOptions return a.db.ExecTx(ctx, &writeTxOpts, func(q PendingAssetStore) error { - _, err := q.UpsertInternalKey(ctx, InternalKey{ - RawKey: rawMintingInternalKey, - KeyFamily: int32(mintingInternalKey.Family), - KeyIndex: int32(mintingInternalKey.Index), - }) + repairQueries, ok := q.(customAnchorKeyRepairQueries) + if !ok { + return fmt.Errorf("wallet-verified internal key query unavailable") + } + + _, err := repairQueries.UpsertWalletVerifiedInternalKey( + ctx, sqlc.UpsertWalletVerifiedInternalKeyParams{ + RawKey: rawMintingInternalKey, + KeyFamily: int32(mintingInternalKey.Family), + KeyIndex: int32(mintingInternalKey.Index), + }) if err != nil { return fmt.Errorf("unable to store minting internal key: %w", err) diff --git a/tapdb/asset_minting_test.go b/tapdb/asset_minting_test.go index 7c7d54fb1f..516cf192ea 100644 --- a/tapdb/asset_minting_test.go +++ b/tapdb/asset_minting_test.go @@ -1038,7 +1038,16 @@ func TestCommitBatchChainActions(t *testing.T) { // // TODO(roasbeef): move the tx extraction up one layer? randAssetCtx.genesisPkt.Pkt.Inputs[0].FinalScriptSig = []byte{} - customInternalKey, _ := test.RandKeyDesc(t) + customInternalKey, customPriv := test.RandKeyDesc(t) + customInternalKey.KeyLocator = keychain.KeyLocator{ + Family: asset.TaprootAssetsKeyFamily, + Index: 721, + } + customInternalKey.PubKey = customPriv.PubKey() + _, err := db.UpsertInternalKey(ctx, InternalKey{ + RawKey: customInternalKey.PubKey.SerializeCompressed(), + }) + require.NoError(t, err) bip32Derivation, taprootDerivation := tappsbt.Bip32DerivationFromKeyDesc( customInternalKey, address.TestNet3Tap.HDCoinType, @@ -1294,6 +1303,43 @@ func TestCommitBatchChainActions(t *testing.T) { } } +func TestCommitSignedGenesisTxConflictingLocatorRollback(t *testing.T) { + t.Parallel() + + ctx := context.Background() + assetStore, _, db := newAssetStore(t) + randAssetCtx := addRandAssets(t, ctx, assetStore, 1) + randAssetCtx.genesisPkt.Pkt.Inputs[0].FinalScriptSig = []byte{} + + customInternalKey, _ := test.RandKeyDesc(t) + rawKey := customInternalKey.PubKey.SerializeCompressed() + conflicting := InternalKey{ + RawKey: rawKey, KeyFamily: int32(customInternalKey.Family), + KeyIndex: int32(customInternalKey.Index) + 1, + } + _, err := db.UpsertInternalKey(ctx, conflicting) + require.NoError(t, err) + + err = assetStore.CommitSignedGenesisTx( + ctx, randAssetCtx.batchKey, customInternalKey, + randAssetCtx.genesisPkt, 0, randAssetCtx.merkleRoot, + randAssetCtx.scriptRoot, randAssetCtx.tapSiblingBytes, + ) + require.ErrorIs(t, err, sql.ErrNoRows) + + // The locator and minting batch stay unchanged because the descriptor + // upsert and all Broadcast bookkeeping share one transaction. + locator, err := db.FetchInternalKeyLocator(ctx, rawKey) + require.NoError(t, err) + require.Equal(t, conflicting.KeyFamily, locator.KeyFamily) + require.Equal(t, conflicting.KeyIndex, locator.KeyIndex) + persisted, err := assetStore.FetchMintingBatch( + ctx, randAssetCtx.batchKey, + ) + require.NoError(t, err) + require.Equal(t, tapgarden.BatchStateCommitted, persisted.State()) +} + // TestDuplicateGroupKey tests that if we attempt to insert a group key with // the exact same tweaked key blob, then the noop UPSERT logic triggers, and we // get the ID of that same key. @@ -1342,6 +1388,162 @@ func TestDuplicateGroupKey(t *testing.T) { require.Equal(t, groupID, groupID2) } +func TestInternalKeyLocatorUpsertMatrix(t *testing.T) { + t.Parallel() + + ctx := context.Background() + _, _, db := newAssetStore(t) + rawKey := test.RandPubKey(t).SerializeCompressed() + + placeholder := sqlc.UpsertInternalKeyParams{RawKey: rawKey} + keyID, err := db.UpsertInternalKey(ctx, placeholder) + require.NoError(t, err) + + verified := sqlc.UpsertWalletVerifiedInternalKeyParams{ + RawKey: rawKey, KeyFamily: 212, KeyIndex: 721, + } + + // A generic, unverified caller cannot promote the placeholder, but must + // still receive its existing ID. Default import-then-use paths insert a + // raw placeholder before later encountering the full descriptor and need + // this ID to create dependent rows. + defaultCallerID, err := db.UpsertInternalKey(ctx, + sqlc.UpsertInternalKeyParams{ + RawKey: rawKey, KeyFamily: verified.KeyFamily, + KeyIndex: verified.KeyIndex, + }) + require.NoError(t, err) + require.Equal(t, keyID, defaultCallerID) + locator, err := db.FetchInternalKeyLocator(ctx, rawKey) + require.NoError(t, err) + require.Zero(t, locator.KeyFamily) + require.Zero(t, locator.KeyIndex) + + // The wallet-verified capability promotes exactly the placeholder and is + // idempotent for the same locator. + verifiedID, err := db.UpsertWalletVerifiedInternalKey(ctx, verified) + require.NoError(t, err) + require.Equal(t, keyID, verifiedID) + verifiedID2, err := db.UpsertWalletVerifiedInternalKey(ctx, verified) + require.NoError(t, err) + require.Equal(t, keyID, verifiedID2) + locator, err = db.FetchInternalKeyLocator(ctx, rawKey) + require.NoError(t, err) + require.Equal(t, verified.KeyFamily, locator.KeyFamily) + require.Equal(t, verified.KeyIndex, locator.KeyIndex) + + // A later generic placeholder import preserves and returns the known + // locator instead of erasing it. + preservedID, err := db.UpsertInternalKey(ctx, placeholder) + require.NoError(t, err) + require.Equal(t, keyID, preservedID) + locator, err = db.FetchInternalKeyLocator(ctx, rawKey) + require.NoError(t, err) + require.Equal(t, verified.KeyFamily, locator.KeyFamily) + require.Equal(t, verified.KeyIndex, locator.KeyIndex) + + // Neither generic nor verified paths overwrite a conflicting known + // locator. The generic path remains a successful ID lookup for backwards + // compatibility, while the verified path reports the conflict. + conflictingGenericID, err := db.UpsertInternalKey( + ctx, sqlc.UpsertInternalKeyParams{ + RawKey: rawKey, KeyFamily: verified.KeyFamily, + KeyIndex: verified.KeyIndex + 1, + }, + ) + require.NoError(t, err) + require.Equal(t, keyID, conflictingGenericID) + _, err = db.UpsertWalletVerifiedInternalKey( + ctx, sqlc.UpsertWalletVerifiedInternalKeyParams{ + RawKey: rawKey, KeyFamily: verified.KeyFamily, + KeyIndex: verified.KeyIndex + 1, + }, + ) + require.ErrorIs(t, err, sql.ErrNoRows) + locator, err = db.FetchInternalKeyLocator(ctx, rawKey) + require.NoError(t, err) + require.Equal(t, verified.KeyFamily, locator.KeyFamily) + require.Equal(t, verified.KeyIndex, locator.KeyIndex) + + for _, known := range []sqlc.UpsertInternalKeyParams{ + {RawKey: test.RandPubKey(t).SerializeCompressed(), KeyIndex: 7}, + {RawKey: test.RandPubKey(t).SerializeCompressed(), KeyFamily: 7}, + } { + known := known + knownID, err := db.UpsertInternalKey(ctx, known) + require.NoError(t, err) + genericConflictID, err := db.UpsertInternalKey( + ctx, sqlc.UpsertInternalKeyParams{ + RawKey: known.RawKey, KeyFamily: 9, KeyIndex: 9, + }, + ) + require.NoError(t, err) + require.Equal(t, knownID, genericConflictID) + + _, err = db.UpsertWalletVerifiedInternalKey( + ctx, sqlc.UpsertWalletVerifiedInternalKeyParams{ + RawKey: known.RawKey, KeyFamily: 9, KeyIndex: 9, + }, + ) + require.ErrorIs(t, err, sql.ErrNoRows) + partial, err := db.FetchInternalKeyLocator(ctx, known.RawKey) + require.NoError(t, err) + require.Equal(t, known.KeyFamily, partial.KeyFamily) + require.Equal(t, known.KeyIndex, partial.KeyIndex) + } +} + +// TestGenericInternalKeyImportThenUse verifies the default script-key storage +// path can reuse an earlier proof-import placeholder without either failing or +// treating the later, unverified descriptor as authority to promote it. +func TestGenericInternalKeyImportThenUse(t *testing.T) { + t.Parallel() + + ctx := context.Background() + _, _, db := newAssetStore(t) + rawKey, _ := test.RandKeyDesc(t) + rawKey.Family = 212 + rawKey.Index = 721 + rawKeyBytes := rawKey.PubKey.SerializeCompressed() + + placeholderID, err := db.UpsertInternalKey( + ctx, sqlc.UpsertInternalKeyParams{RawKey: rawKeyBytes}, + ) + require.NoError(t, err) + + tweak := test.RandBytes(32) + tweakedKey := txscript.ComputeTaprootOutputKey(rawKey.PubKey, tweak) + scriptKey := asset.ScriptKey{ + PubKey: tweakedKey, + TweakedScriptKey: &asset.TweakedScriptKey{ + RawKey: rawKey, + Tweak: tweak, + }, + } + + scriptKeyID, err := upsertScriptKey(ctx, scriptKey, db) + require.NoError(t, err) + require.NotZero(t, scriptKeyID) + storedScriptKeyID, err := db.FetchScriptKeyIDByTweakedKey( + ctx, tweakedKey.SerializeCompressed(), + ) + require.NoError(t, err) + require.Equal(t, scriptKeyID, storedScriptKeyID) + + reusedID, err := db.UpsertInternalKey( + ctx, sqlc.UpsertInternalKeyParams{ + RawKey: rawKeyBytes, KeyFamily: int32(rawKey.Family), + KeyIndex: int32(rawKey.Index), + }, + ) + require.NoError(t, err) + require.Equal(t, placeholderID, reusedID) + locator, err := db.FetchInternalKeyLocator(ctx, rawKeyBytes) + require.NoError(t, err) + require.Zero(t, locator.KeyFamily) + require.Zero(t, locator.KeyIndex) +} + // TestGroupStore tests all the queries exposed via the GroupStore interface, // including fetching asset groups via genesis ID or group key. func TestGroupStore(t *testing.T) { diff --git a/tapdb/assets_store_test.go b/tapdb/assets_store_test.go index 84a41d72a0..e5a3e04290 100644 --- a/tapdb/assets_store_test.go +++ b/tapdb/assets_store_test.go @@ -2227,6 +2227,61 @@ func TestAssetGroupComplexWitness(t *testing.T) { require.True(t, groupKey.IsEqual(storedGroup.GroupKey)) } +// TestGroupKeyUpsertAfterPlaceholderImport verifies the default proof and +// universe import order: a raw-key-only placeholder can be inserted first, +// then a grouped mint can resolve the same stable key ID without granting the +// generic upsert path authority to change its locator. +func TestGroupKeyUpsertAfterPlaceholderImport(t *testing.T) { + t.Parallel() + + _, assetStore, db := newAssetStore(t) + ctx := context.Background() + internalKey := test.RandPubKey(t) + rawKey := internalKey.SerializeCompressed() + + placeholderID, err := db.UpsertInternalKey(ctx, InternalKey{ + RawKey: rawKey, + }) + require.NoError(t, err) + + groupAnchorGen := asset.RandGenesis(t, asset.Normal) + groupAnchorGen.MetaHash = [32]byte{} + genesisPointID, err := upsertGenesisPoint( + ctx, db, groupAnchorGen.FirstPrevOut, + ) + require.NoError(t, err) + genAssetID, err := upsertGenesis( + ctx, db, genesisPointID, groupAnchorGen, + ) + require.NoError(t, err) + + groupKey := asset.GroupKey{ + RawKey: keychain.KeyDescriptor{ + KeyLocator: keychain.KeyLocator{ + Family: 212, + Index: 721, + }, + PubKey: internalKey, + }, + GroupPubKey: *internalKey, + } + _, err = upsertGroupKey( + ctx, &groupKey, assetStore.db, genesisPointID, genAssetID, + ) + require.NoError(t, err) + + resolvedID, err := db.UpsertInternalKey(ctx, InternalKey{ + RawKey: rawKey, KeyFamily: int32(groupKey.RawKey.Family), + KeyIndex: int32(groupKey.RawKey.Index), + }) + require.NoError(t, err) + require.Equal(t, placeholderID, resolvedID) + locator, err := db.FetchInternalKeyLocator(ctx, rawKey) + require.NoError(t, err) + require.Zero(t, locator.KeyFamily) + require.Zero(t, locator.KeyIndex) +} + // TestStoreFetchAssetGroupV1 tests that we can store and fetch an asset group // version 1. func TestStoreFetchAssetGroupV1(t *testing.T) { diff --git a/tapdb/sqlc/assets.sql.go b/tapdb/sqlc/assets.sql.go index b02dcb0c2c..be5d1aff3e 100644 --- a/tapdb/sqlc/assets.sql.go +++ b/tapdb/sqlc/assets.sql.go @@ -1603,6 +1603,105 @@ type FetchManagedUTXOsRow struct { KeyIndex int32 } +const FetchCustomAnchorKeyRepairCandidates = `-- name: FetchCustomAnchorKeyRepairCandidates :many +SELECT batch_keys.raw_key AS batch_key, + batches.minting_tx_psbt, + batches.assets_output_index, + utxos.outpoint, + utxo_keys.raw_key AS managed_internal_key, + utxos.merkle_root +FROM asset_minting_batches batches +JOIN internal_keys batch_keys + ON batches.batch_id = batch_keys.key_id +JOIN genesis_points genesis + ON batches.genesis_id = genesis.genesis_id +JOIN chain_txns chain_tx + ON genesis.anchor_tx_id = chain_tx.txn_id +JOIN managed_utxos utxos + ON utxos.txn_id = chain_tx.txn_id +JOIN internal_keys utxo_keys + ON utxos.internal_key_id = utxo_keys.key_id +WHERE batches.minting_tx_psbt IS NOT NULL + +` + +type FetchCustomAnchorKeyRepairCandidatesRow struct { + BatchKey []byte + MintingTxPsbt []byte + AssetsOutputIndex sql.NullInt32 + Outpoint []byte + ManagedInternalKey []byte + MerkleRoot []byte +} + +func (q *Queries) FetchCustomAnchorKeyRepairCandidates(ctx context.Context) ([]FetchCustomAnchorKeyRepairCandidatesRow, error) { + rows, err := q.db.QueryContext(ctx, FetchCustomAnchorKeyRepairCandidates) + if err != nil { + return nil, err + } + defer rows.Close() + + var items []FetchCustomAnchorKeyRepairCandidatesRow + for rows.Next() { + var i FetchCustomAnchorKeyRepairCandidatesRow + if err := rows.Scan( + &i.BatchKey, &i.MintingTxPsbt, &i.AssetsOutputIndex, + &i.Outpoint, &i.ManagedInternalKey, &i.MerkleRoot, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const RepairCustomAnchorInternalKey = `-- name: RepairCustomAnchorInternalKey :execrows +WITH candidate_batch AS ( + SELECT chain_tx.txn_id + FROM asset_minting_batches batches + JOIN internal_keys batch_keys + ON batches.batch_id = batch_keys.key_id + JOIN genesis_points genesis + ON batches.genesis_id = genesis.genesis_id + JOIN chain_txns chain_tx + ON genesis.anchor_tx_id = chain_tx.txn_id + WHERE batch_keys.raw_key = $1 + AND batches.assets_output_index = $2 +), expected_old_key AS ( + SELECT key_id FROM internal_keys WHERE raw_key = $3 +), replacement_key AS ( + SELECT key_id FROM internal_keys WHERE raw_key = $4 +) +UPDATE managed_utxos +SET internal_key_id = (SELECT key_id FROM replacement_key) +WHERE outpoint = $5 + AND txn_id = (SELECT txn_id FROM candidate_batch) + AND internal_key_id = (SELECT key_id FROM expected_old_key) +` + +type RepairCustomAnchorInternalKeyParams struct { + BatchKey []byte + AssetOutputIndex sql.NullInt32 + ExpectedOldKey []byte + ReplacementKey []byte + Outpoint []byte +} + +func (q *Queries) RepairCustomAnchorInternalKey(ctx context.Context, arg RepairCustomAnchorInternalKeyParams) (int64, error) { + result, err := q.db.ExecContext( + ctx, RepairCustomAnchorInternalKey, arg.BatchKey, + arg.AssetOutputIndex, arg.ExpectedOldKey, + arg.ReplacementKey, arg.Outpoint, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + func (q *Queries) FetchManagedUTXOs(ctx context.Context) ([]FetchManagedUTXOsRow, error) { rows, err := q.db.QueryContext(ctx, FetchManagedUTXOs) if err != nil { @@ -3259,8 +3358,10 @@ INSERT INTO internal_keys ( ) VALUES ( $1, $2, $3 ) ON CONFLICT (raw_key) - -- This is a NOP, raw_key is the unique field that caused the conflict. - DO UPDATE SET raw_key = EXCLUDED.raw_key + -- Generic imports must never mutate a locator already associated with a + -- raw key. They only need the stable key ID, while the wallet-verified + -- query below owns locator promotion and conflict validation. + DO UPDATE SET raw_key = internal_keys.raw_key RETURNING key_id ` @@ -3277,6 +3378,36 @@ func (q *Queries) UpsertInternalKey(ctx context.Context, arg UpsertInternalKeyPa return key_id, err } +const UpsertWalletVerifiedInternalKey = `-- name: UpsertWalletVerifiedInternalKey :one +INSERT INTO internal_keys ( + raw_key, key_family, key_index +) VALUES ( + $1, $2, $3 +) ON CONFLICT (raw_key) + DO UPDATE SET key_family = EXCLUDED.key_family, + key_index = EXCLUDED.key_index + WHERE (internal_keys.key_family = 0 AND internal_keys.key_index = 0) + OR (internal_keys.key_family = EXCLUDED.key_family AND + internal_keys.key_index = EXCLUDED.key_index) +RETURNING key_id +` + +type UpsertWalletVerifiedInternalKeyParams struct { + RawKey []byte + KeyFamily int32 + KeyIndex int32 +} + +func (q *Queries) UpsertWalletVerifiedInternalKey(ctx context.Context, arg UpsertWalletVerifiedInternalKeyParams) (int64, error) { + row := q.db.QueryRowContext( + ctx, UpsertWalletVerifiedInternalKey, arg.RawKey, + arg.KeyFamily, arg.KeyIndex, + ) + var keyID int64 + err := row.Scan(&keyID) + return keyID, err +} + const UpsertManagedUTXO = `-- name: UpsertManagedUTXO :one WITH target_key(key_id) AS ( SELECT key_id diff --git a/tapdb/sqlc/querier.go b/tapdb/sqlc/querier.go index 74cf768b21..f8d017bb27 100644 --- a/tapdb/sqlc/querier.go +++ b/tapdb/sqlc/querier.go @@ -99,6 +99,7 @@ type Querier interface { FetchInternalKeyLocator(ctx context.Context, rawKey []byte) (FetchInternalKeyLocatorRow, error) FetchManagedUTXO(ctx context.Context, arg FetchManagedUTXOParams) (FetchManagedUTXORow, error) FetchManagedUTXOs(ctx context.Context) ([]FetchManagedUTXOsRow, error) + FetchCustomAnchorKeyRepairCandidates(ctx context.Context) ([]FetchCustomAnchorKeyRepairCandidatesRow, error) // Fetch records from the supply_pre_commits table with optional // filtering. FetchMintSupplyPreCommits(ctx context.Context, arg FetchMintSupplyPreCommitsParams) ([]FetchMintSupplyPreCommitsRow, error) @@ -274,6 +275,8 @@ type Querier interface { UpsertGenesisAsset(ctx context.Context, arg UpsertGenesisAssetParams) (int64, error) UpsertGenesisPoint(ctx context.Context, prevOut []byte) (int64, error) UpsertInternalKey(ctx context.Context, arg UpsertInternalKeyParams) (int64, error) + UpsertWalletVerifiedInternalKey(ctx context.Context, arg UpsertWalletVerifiedInternalKeyParams) (int64, error) + RepairCustomAnchorInternalKey(ctx context.Context, arg RepairCustomAnchorInternalKeyParams) (int64, error) UpsertManagedUTXO(ctx context.Context, arg UpsertManagedUTXOParams) (int64, error) // Upsert a supply pre-commit that is tied to a minting batch. // The batch is resolved from @batch_key diff --git a/tapdb/sqlc/queries/assets.sql b/tapdb/sqlc/queries/assets.sql index 562a9e94b6..b303109b72 100644 --- a/tapdb/sqlc/queries/assets.sql +++ b/tapdb/sqlc/queries/assets.sql @@ -4,8 +4,26 @@ INSERT INTO internal_keys ( ) VALUES ( $1, $2, $3 ) ON CONFLICT (raw_key) - -- This is a NOP, raw_key is the unique field that caused the conflict. - DO UPDATE SET raw_key = EXCLUDED.raw_key + -- Generic imports must never mutate a locator already associated with a + -- raw key. They only need the stable key ID, while the wallet-verified + -- query below owns locator promotion and conflict validation. + DO UPDATE SET raw_key = internal_keys.raw_key +RETURNING key_id; + +-- name: UpsertWalletVerifiedInternalKey :one +INSERT INTO internal_keys ( + raw_key, key_family, key_index +) VALUES ( + $1, $2, $3 +) ON CONFLICT (raw_key) + -- This query is restricted to a wallet-verified descriptor. It may + -- upgrade the historical 0/0 placeholder, but never overwrite a known, + -- conflicting locator. + DO UPDATE SET key_family = EXCLUDED.key_family, + key_index = EXCLUDED.key_index + WHERE (internal_keys.key_family = 0 AND internal_keys.key_index = 0) + OR (internal_keys.key_family = EXCLUDED.key_family AND + internal_keys.key_index = EXCLUDED.key_index) RETURNING key_id; -- name: NewMintingBatch :exec @@ -660,6 +678,53 @@ FROM managed_utxos utxos JOIN internal_keys keys ON utxos.internal_key_id = keys.key_id; +-- name: FetchCustomAnchorKeyRepairCandidates :many +-- Return historical mint rows with enough retained information for the +-- wallet-aware startup audit. The caller must still prove that the packet is a +-- tapd custom anchor, that the locator derives the committed internal key and +-- that the backing wallet controls it before attempting a repair. +SELECT batch_keys.raw_key AS batch_key, + batches.minting_tx_psbt, + batches.assets_output_index, + utxos.outpoint, + utxo_keys.raw_key AS managed_internal_key, + utxos.merkle_root +FROM asset_minting_batches batches +JOIN internal_keys batch_keys + ON batches.batch_id = batch_keys.key_id +JOIN genesis_points genesis + ON batches.genesis_id = genesis.genesis_id +JOIN chain_txns chain_tx + ON genesis.anchor_tx_id = chain_tx.txn_id +JOIN managed_utxos utxos + ON utxos.txn_id = chain_tx.txn_id +JOIN internal_keys utxo_keys + ON utxos.internal_key_id = utxo_keys.key_id +WHERE batches.minting_tx_psbt IS NOT NULL; + +-- name: RepairCustomAnchorInternalKey :execrows +WITH candidate_batch AS ( + SELECT chain_tx.txn_id + FROM asset_minting_batches batches + JOIN internal_keys batch_keys + ON batches.batch_id = batch_keys.key_id + JOIN genesis_points genesis + ON batches.genesis_id = genesis.genesis_id + JOIN chain_txns chain_tx + ON genesis.anchor_tx_id = chain_tx.txn_id + WHERE batch_keys.raw_key = @batch_key + AND batches.assets_output_index = @asset_output_index +), expected_old_key AS ( + SELECT key_id FROM internal_keys WHERE raw_key = @expected_old_key +), replacement_key AS ( + SELECT key_id FROM internal_keys WHERE raw_key = @replacement_key +) +UPDATE managed_utxos +SET internal_key_id = (SELECT key_id FROM replacement_key) +WHERE outpoint = @outpoint + AND txn_id = (SELECT txn_id FROM candidate_batch) + AND internal_key_id = (SELECT key_id FROM expected_old_key); + -- name: AnchorPendingAssets :exec WITH assets_to_update AS ( SELECT script_key_id diff --git a/tapgarden/batch.go b/tapgarden/batch.go index 7b2ffd170b..08622241fe 100644 --- a/tapgarden/batch.go +++ b/tapgarden/batch.go @@ -78,6 +78,20 @@ type MintingBatch struct { // combined with the Taproot Asset commitment to derive the // MintingOutputKey. tapSibling *chainhash.Hash + + // CustomAnchorLeaseError is the latest prepared-phase lease renewal + // degradation. It is transient status: Finalize still reacquires every + // recorded wallet-owned input and fails closed if that cannot succeed. + CustomAnchorLeaseError string + + // CustomAnchorPublishError is the latest ambiguous wallet publication + // error for a byte-identical transaction that remains watched and retried. + CustomAnchorPublishError string + + // CustomAnchorKeyError describes an historical custom-anchor managed + // UTXO whose internal key couldn't be repaired automatically. This is + // startup audit health, not a minting state transition. + CustomAnchorKeyError string } // VerboseBatch is a MintingBatch that includes seedlings with their pending @@ -104,10 +118,13 @@ func (m *MintingBatch) Copy() *MintingBatch { HeightHint: m.HeightHint, // The following values are expected to not change once they are // set, so a shallow copy is sufficient. - BatchKey: m.BatchKey, - RootAssetCommitment: m.RootAssetCommitment, - SupplyCommitments: m.SupplyCommitments, - tapSibling: m.tapSibling, + BatchKey: m.BatchKey, + RootAssetCommitment: m.RootAssetCommitment, + SupplyCommitments: m.SupplyCommitments, + tapSibling: m.tapSibling, + CustomAnchorLeaseError: m.CustomAnchorLeaseError, + CustomAnchorPublishError: m.CustomAnchorPublishError, + CustomAnchorKeyError: m.CustomAnchorKeyError, } batchCopy.UpdateState(m.State()) diff --git a/tapgarden/caretaker.go b/tapgarden/caretaker.go index 84eaacf105..6605ad7b07 100644 --- a/tapgarden/caretaker.go +++ b/tapgarden/caretaker.go @@ -26,6 +26,7 @@ import ( "github.com/lightninglabs/taproot-assets/tapsend" "github.com/lightninglabs/taproot-assets/universe" "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lnwallet/chainfee" "golang.org/x/exp/maps" "golang.org/x/sync/errgroup" @@ -45,6 +46,12 @@ var ( // the genesis outpoint is missing from a funded anchor PSBT. ErrFundedAnchorPsbtMissingOutpoint = errors.New("genesis outpoint " + "missing from funded anchor PSBT") + + // ErrLegacyCustomAnchorKeyLocator identifies a prepublication custom + // batch whose retained packet does not contain a wallet key locator. Such + // a raw-only key cannot be safely guessed or upgraded. + ErrLegacyCustomAnchorKeyLocator = errors.New("legacy custom anchor is " + + "missing a wallet key locator") ) const ( @@ -110,6 +117,10 @@ type BatchCaretakerConfig struct { // ErrChan is the main error channel the caretaker will report back // critical errors to the main server. ErrChan chan<- error + + // CustomAnchorLeaseRenewalInterval controls publication retry and lease + // renewal while wallet acceptance of a custom anchor remains ambiguous. + CustomAnchorLeaseRenewalInterval time.Duration } // BatchCaretaker is the caretaker for a MintingBatch. It'll handle validating @@ -119,6 +130,10 @@ type BatchCaretaker struct { startOnce sync.Once stopOnce sync.Once + // statusMu protects the transient custom anchor health fields that are + // written by the caretaker and queried by the planter. + statusMu sync.RWMutex + batchKey BatchKey cfg *BatchCaretakerConfig @@ -133,11 +148,39 @@ type BatchCaretaker struct { // the Taproot Asset commitment. anchorOutputIndex uint32 + // customAnchorWalletAccepted is true once WalletKit has returned success + // for the exact custom transaction. Until then the caretaker keeps a + // confirmation watcher installed, renews local leases and retries the same + // bytes. + customAnchorWalletAccepted bool + // ContextGuard provides a wait group and main quit channel that can be // used to create guarded contexts. *fn.ContextGuard } +func (b *BatchCaretaker) setCustomAnchorLeaseError(status string) { + b.statusMu.Lock() + defer b.statusMu.Unlock() + + b.cfg.Batch.CustomAnchorLeaseError = status +} + +func (b *BatchCaretaker) setCustomAnchorPublishError(status string) { + b.statusMu.Lock() + defer b.statusMu.Unlock() + + b.cfg.Batch.CustomAnchorPublishError = status +} + +func (b *BatchCaretaker) customAnchorStatus() (string, string) { + b.statusMu.RLock() + defer b.statusMu.RUnlock() + + return b.cfg.Batch.CustomAnchorLeaseError, + b.cfg.Batch.CustomAnchorPublishError +} + // NewBatchCaretaker creates a new Taproot Asset caretaker based on the passed // config. // @@ -160,6 +203,56 @@ func NewBatchCaretaker(cfg *BatchCaretakerConfig) *BatchCaretaker { return caretaker } +// mintingInternalKeyDescriptor resolves and proves the wallet-owned descriptor +// that will be persisted for the managed mint output. This preflight must run +// before importing or publishing a custom transaction. +func (b *BatchCaretaker) mintingInternalKeyDescriptor(ctx context.Context, + pkt *psbt.Packet) (keychain.KeyDescriptor, error) { + if !isCustomAnchorPsbt(pkt) { + return b.cfg.Batch.BatchKey, nil + } + + return customAnchorMintingKeyDescriptor( + ctx, b.cfg.KeyRing, pkt, b.anchorOutputIndex, + ) +} + +func customAnchorMintingKeyDescriptor(ctx context.Context, + keyRing tapnode.KeyRing, pkt *psbt.Packet, + anchorOutputIndex uint32) (keychain.KeyDescriptor, error) { + + if !isCustomAnchorPsbt(pkt) { + return keychain.KeyDescriptor{}, fmt.Errorf("packet is not a custom " + + "anchor PSBT") + } + + if pkt == nil || int(anchorOutputIndex) >= len(pkt.Outputs) { + return keychain.KeyDescriptor{}, fmt.Errorf("custom anchor PSBT is "+ + "missing output map %d", anchorOutputIndex) + } + + pOut := pkt.Outputs[anchorOutputIndex] + if len(pOut.Bip32Derivation) != 1 { + return keychain.KeyDescriptor{}, fmt.Errorf("%w; cancel this "+ + "prepublication batch and recreate it with a wallet-owned "+ + "BIP32 descriptor", ErrLegacyCustomAnchorKeyLocator) + } + + desc, err := tappsbt.KeyDescFromBip32Derivation( + pOut.Bip32Derivation[0], + ) + if err != nil { + return keychain.KeyDescriptor{}, fmt.Errorf("invalid custom anchor "+ + "wallet key locator: %w", err) + } + if !keyRing.IsLocalKey(ctx, desc) { + return keychain.KeyDescriptor{}, fmt.Errorf("custom anchor wallet " + + "key locator is not controlled by this wallet") + } + + return desc, nil +} + // Start attempts to start a new batch caretaker. func (b *BatchCaretaker) Start() error { var startErr error @@ -403,6 +496,13 @@ func (b *BatchCaretaker) assetCultivator() { // long-running, asynchronous part. b.cfg.BroadcastCompleteChan <- struct{}{} + retryInterval := b.cfg.CustomAnchorLeaseRenewalInterval + if retryInterval <= 0 { + retryInterval = customAnchorLeaseRenewalInterval + } + publishRetryTicker := time.NewTicker(retryInterval) + defer publishRetryTicker.Stop() + // TODO(roasbeef): proper restart logic? // At this point, we've advanced all the way to broadcasting the @@ -412,6 +512,66 @@ func (b *BatchCaretaker) assetCultivator() { // TODO(roasbeef): eventually should attempt to RBF if needed? for { select { + case <-publishRetryTicker.C: + if b.customAnchorWalletAccepted || + b.cfg.Batch.GenesisPacket == nil || + !isCustomAnchorPsbt(b.cfg.Batch.GenesisPacket.Pkt) { + + continue + } + + // Wallet acceptance remains ambiguous. Protect every recorded + // local input before retrying the exact persisted transaction. + ctx, cancel := b.WithCtxQuit() + err := renewCustomAnchorLeases( + ctx, b.cfg.Wallet, b.cfg.Batch.GenesisPacket, + ) + cancel() + if err != nil { + statusErr := fmt.Errorf("custom anchor lease renewal "+ + "during publish retry failed: %w", err) + b.setCustomAnchorLeaseError(statusErr.Error()) + b.cfg.PublishMintEvent(newAssetMintErrorEvent( + statusErr, BatchStateBroadcast, b.cfg.Batch, + )) + continue + } + b.setCustomAnchorLeaseError("") + + signedTx, err := psbt.Extract( + b.cfg.Batch.GenesisPacket.Pkt, + ) + if err != nil { + statusErr := fmt.Errorf("unable to extract persisted custom "+ + "anchor retry transaction: %w", err) + b.setCustomAnchorPublishError(statusErr.Error()) + b.cfg.PublishMintEvent(newAssetMintErrorEvent( + statusErr, BatchStateBroadcast, b.cfg.Batch, + )) + continue + } + + ctx, cancel = b.WithCtxQuit() + err = b.cfg.ChainBridge.PublishTransaction( + ctx, signedTx, IssuanceTxLabel, + ) + cancel() + if err != nil { + statusErr := fmt.Errorf("custom anchor publish remains "+ + "ambiguous: %w", err) + b.setCustomAnchorPublishError(statusErr.Error()) + b.cfg.PublishMintEvent(newAssetMintErrorEvent( + statusErr, BatchStateBroadcast, b.cfg.Batch, + )) + continue + } + + b.customAnchorWalletAccepted = true + b.setCustomAnchorPublishError("") + b.cfg.PublishMintEvent(newAssetMintEvent( + BatchStateBroadcast, b.cfg.Batch, + )) + // We've received the confirmation notification, so we can // advance our state machine through the final two phases. case confInfo := <-b.confEvent: @@ -755,6 +915,7 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) ctx, cancel := b.WithCtxQuit() defer cancel() signedPkt := b.cfg.Batch.GenesisPacket.Pkt + publishStateAtEntry := getCustomAnchorPublishState(signedPkt) _, extractErr := psbt.Extract(signedPkt) if extractErr != nil { signedPkt, extractErr = b.cfg.Wallet.SignAndFinalizePsbt( @@ -804,6 +965,16 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) log.Tracef("GenesisPacket: %v", spew.Sdump(signedPkt)) } + // Resolve the wallet-owned key descriptor before any wallet import or + // chain publication. Historical raw-only descriptors remain safely + // prepublication and receive an actionable cancellation/recreate error. + mintingInternalKey, err := b.mintingInternalKeyDescriptor( + ctx, signedPkt, + ) + if err != nil { + return 0, err + } + // At this point we have a fully signed PSBT packet which'll // create our set of assets once mined. We'll write this to // disk, then import the public key into the wallet. To do so @@ -902,75 +1073,67 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) // Custom anchor transactions can contain inputs that aren't owned // by lnd. Ask the chain backend to validate and submit the fully - // signed transaction before we persist the irreversible Broadcast - // state. A conclusive application rejection leaves the batch in - // Committed, while an ambiguous transport failure must be treated as - // potentially published. - var publishErr error + // signed transaction before we persist Broadcast. Every result is + // treated as potentially published because the authenticated caller + // also holds the signed bytes; classification is diagnostic only. + var ( + publishErr error + publishAttempted bool + ) if isCustomAnchorPsbt(signedPkt) { - publishCtx, publishCancel := b.WithCtxQuit() - publishErr = b.cfg.ChainBridge. - ValidateAndPublishTransaction( - publishCtx, signedTx, IssuanceTxLabel, - ) - publishCancel() - if tapnode.IsDefinitivePublishError(publishErr) { - setCustomAnchorPublishState( - signedPkt, customAnchorPublishRejected, - ) - storeCtx, storeCancel := b.WithCtxQuit() - storeErr := b.cfg.Log.StoreSignedGenesisPsbt( - storeCtx, b.cfg.Batch.BatchKey.PubKey, - &b.cfg.Batch.GenesisPacket.FundedPsbt, - ) - storeCancel() - if storeErr != nil { - return 0, fmt.Errorf("unable to store definitive "+ - "publication rejection: %w", storeErr) + renewCtx, renewCancel := b.WithCtxQuit() + renewErr := renewCustomAnchorLeases( + renewCtx, b.cfg.Wallet, b.cfg.Batch.GenesisPacket, + ) + renewCancel() + if renewErr != nil { + statusErr := fmt.Errorf("unable to renew custom anchor "+ + "leases before publication: %w", renewErr) + b.setCustomAnchorLeaseError(statusErr.Error()) + + // A retained publish-pending marker means an earlier + // process may already have handed these exact bytes to + // WalletKit. Cross into durable Broadcast so the watcher is + // installed, but don't actively republish until the recorded + // local leases are reacquired. + if publishStateAtEntry != customAnchorPublishPending { + return 0, statusErr } + } else { + b.setCustomAnchorLeaseError("") + } - releaseCtx, releaseCancel := b.WithCtxQuit() - releaseErr := releaseCustomAnchorLeases( - releaseCtx, b.cfg.Wallet, - b.cfg.Batch.GenesisPacket, - ) - releaseCancel() - if releaseErr == nil { - storeCtx, storeCancel = b.WithCtxQuit() - storeErr = b.cfg.Log.StoreSignedGenesisPsbt( - storeCtx, b.cfg.Batch.BatchKey.PubKey, - &b.cfg.Batch.GenesisPacket.FundedPsbt, + if renewErr == nil { + publishAttempted = true + publishCtx, publishCancel := b.WithCtxQuit() + publishErr = b.cfg.ChainBridge. + ValidateAndPublishTransaction( + publishCtx, signedTx, IssuanceTxLabel, ) - storeCancel() - } - - return 0, fmt.Errorf("transaction rejected before "+ - "broadcast state: %w", errors.Join( - publishErr, releaseErr, storeErr, + publishCancel() + } + if tapnode.IsDefinitivePublishError(publishErr) { + // Classification is diagnostic only. The authenticated caller + // holds the fully signed bytes and an external relay cannot be + // disproved, so every WalletKit attempt crosses the durable + // Broadcast boundary and retains confirmation tracking. + b.setCustomAnchorPublishError(fmt.Sprintf( + "wallet policy rejected publication; transaction is "+ + "still treated as potentially relayed: %v", publishErr, )) } + b.customAnchorWalletAccepted = publishAttempted && + publishErr == nil + if b.customAnchorWalletAccepted { + b.setCustomAnchorLeaseError("") + b.setCustomAnchorPublishError("") + } } // To spend this output in the future, we must also commit the // Taproot Asset commitment root and batch tapscript sibling. tapCommitmentRoot := b.cfg.Batch.RootAssetCommitment. TapscriptRoot(nil) - mintingInternalKey := b.cfg.Batch.BatchKey - if isCustomAnchorPsbt(signedPkt) { - pOut := signedPkt.Outputs[b.anchorOutputIndex] - if len(pOut.Bip32Derivation) != 1 { - return 0, fmt.Errorf("custom anchor output must have " + - "exactly one BIP32 derivation") - } - mintingInternalKey, err = tappsbt.KeyDescFromBip32Derivation( - pOut.Bip32Derivation[0], - ) - if err != nil { - return 0, fmt.Errorf("unable to derive custom anchor "+ - "internal key: %w", err) - } - } - err = b.cfg.Log.CommitSignedGenesisTx( ctx, b.cfg.Batch.BatchKey.PubKey, mintingInternalKey, @@ -1020,17 +1183,10 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) log.Tracef("GenesisTx: %v", spew.Sdump(signedTx)) } - // With the final transaction extracted, we'll broadcast the - // transaction, then request a confirmation notification. - ctx, cancel := b.WithCtxQuit() - defer cancel() - err = b.cfg.ChainBridge.PublishTransaction( - ctx, signedTx, IssuanceTxLabel, - ) - if err != nil { - return 0, fmt.Errorf("unable to publish transaction: %w", err) - } - + // Install the confirmation watcher before any retry publication. An + // earlier ambiguous submission may already have relayed the exact + // transaction, so a later publication error must never create a gap in + // confirmation tracking or return the batch to a mutable state. // Now we'll wait for a confirmation as we reach our terminal // state that requires an on-chain event to shift from. We make // sure to request that the block is included as well, since we @@ -1051,6 +1207,60 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) "minting tx conf: %w", err) } + customAnchor := isCustomAnchorPsbt( + b.cfg.Batch.GenesisPacket.Pkt, + ) + if !customAnchor || !b.customAnchorWalletAccepted { + if customAnchor { + renewCtx, renewCancel := b.WithCtxQuit() + renewErr := renewCustomAnchorLeases( + renewCtx, b.cfg.Wallet, + b.cfg.Batch.GenesisPacket, + ) + renewCancel() + if renewErr != nil { + b.setCustomAnchorLeaseError(renewErr.Error()) + b.cfg.PublishMintEvent(newAssetMintErrorEvent( + fmt.Errorf("custom anchor lease renewal "+ + "before publish retry failed: %w", + renewErr), + BatchStateBroadcast, b.cfg.Batch, + )) + } else { + b.setCustomAnchorLeaseError("") + } + } + + leaseErr, _ := b.customAnchorStatus() + if !customAnchor || leaseErr == "" { + publishCtx, publishCancel := b.WithCtxQuit() + err = b.cfg.ChainBridge.PublishTransaction( + publishCtx, signedTx, IssuanceTxLabel, + ) + publishCancel() + if err != nil && !customAnchor { + confNtfn.Cancel() + return 0, fmt.Errorf("unable to publish "+ + "transaction: %w", err) + } + if customAnchor { + b.customAnchorWalletAccepted = err == nil + if err != nil { + b.setCustomAnchorPublishError(err.Error()) + b.cfg.PublishMintEvent( + newAssetMintErrorEvent( + fmt.Errorf("custom anchor publish "+ + "remains ambiguous: %w", err), + BatchStateBroadcast, b.cfg.Batch, + ), + ) + } else { + b.setCustomAnchorPublishError("") + } + } + } + } + // Launch a goroutine that'll notify us when the transaction // confirms. // diff --git a/tapgarden/custom_anchor_repair.go b/tapgarden/custom_anchor_repair.go new file mode 100644 index 0000000000..c485582df4 --- /dev/null +++ b/tapgarden/custom_anchor_repair.go @@ -0,0 +1,422 @@ +package tapgarden + +import ( + "bytes" + "context" + "encoding/binary" + "fmt" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/psbt/v2" + "github.com/btcsuite/btcd/txscript/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/taproot-assets/address" + "github.com/lightninglabs/taproot-assets/tapnode" + "github.com/lightninglabs/taproot-assets/tappsbt" + "github.com/lightningnetwork/lnd/keychain" +) + +// CustomAnchorKeyRepairStatus describes the result of auditing one historical +// custom mint anchor. +type CustomAnchorKeyRepairStatus uint8 + +const ( + // CustomAnchorKeyRepaired means the poisoned managed UTXO was repaired + // with a descriptor proven to belong to the backing wallet. + CustomAnchorKeyRepaired CustomAnchorKeyRepairStatus = iota + + // CustomAnchorKeyHealthy means the managed UTXO no longer points at the + // incorrect batch key and already uses the retained anchor key. + CustomAnchorKeyHealthy + + // CustomAnchorKeyLocatorRequired means the retained packet proves the + // raw anchor key, but doesn't retain a key locator that can be verified. + CustomAnchorKeyLocatorRequired + + // CustomAnchorKeyNotLocal means the retained descriptor doesn't derive + // to a key controlled by the backing wallet. + CustomAnchorKeyNotLocal + + // CustomAnchorKeyConflict means persisted state changed concurrently or + // points at a third key, so the repair cannot safely choose a winner. + CustomAnchorKeyConflict + + // CustomAnchorKeyInvalid means the retained custom-anchor state doesn't + // satisfy the structural or on-chain binding checks required for repair. + CustomAnchorKeyInvalid +) + +// String returns a stable operator-facing name for a repair status. +func (s CustomAnchorKeyRepairStatus) String() string { + switch s { + case CustomAnchorKeyRepaired: + return "repaired" + case CustomAnchorKeyHealthy: + return "healthy" + case CustomAnchorKeyLocatorRequired: + return "key_locator_required" + case CustomAnchorKeyNotLocal: + return "key_not_local" + case CustomAnchorKeyConflict: + return "persisted_key_conflict" + case CustomAnchorKeyInvalid: + return "invalid_retained_state" + default: + return "unknown" + } +} + +// CustomAnchorKeyRepairHealth is the typed, actionable result for one +// historical custom mint anchor. Detail is safe to surface to an operator and +// never contains private key material. +type CustomAnchorKeyRepairHealth struct { + Status CustomAnchorKeyRepairStatus + BatchKey []byte + Outpoint wire.OutPoint + AnchorInternalKey []byte + Detail string +} + +// RequiresIntervention reports whether an operator must supply or reconcile +// key information before tapd can safely spend the historical output. +func (h CustomAnchorKeyRepairHealth) RequiresIntervention() bool { + return h.Status != CustomAnchorKeyRepaired && + h.Status != CustomAnchorKeyHealthy +} + +// CustomAnchorKeyHealthError lets readiness and future operator-facing APIs +// promote typed unhealthy audit results to an error without losing detail. +type CustomAnchorKeyHealthError struct { + Results []CustomAnchorKeyRepairHealth +} + +// Error implements error. +func (e *CustomAnchorKeyHealthError) Error() string { + if len(e.Results) == 0 { + return "historical custom anchor key health check failed" + } + + first := e.Results[0] + return fmt.Sprintf("historical custom anchor key requires operator "+ + "action (status=%s, batch_key=%x, outpoint=%v): %s", + first.Status, first.BatchKey, first.Outpoint, first.Detail) +} + +// CustomAnchorKeyInterventionError returns a typed aggregate error when any +// audit result requires operator action. +func CustomAnchorKeyInterventionError( + results []CustomAnchorKeyRepairHealth) error { + issues := make([]CustomAnchorKeyRepairHealth, 0, len(results)) + for _, result := range results { + if result.RequiresIntervention() { + issues = append(issues, result) + } + } + if len(issues) == 0 { + return nil + } + + return &CustomAnchorKeyHealthError{Results: issues} +} + +// AuditAndRepairCustomAnchorKeys audits historical custom-anchor managed UTXO +// rows and repairs only rows whose retained wallet descriptor, transaction and +// output script all agree. Rows lacking a provably local descriptor are +// returned as typed health results and are never mutated. +func AuditAndRepairCustomAnchorKeys(ctx context.Context, + store CustomAnchorKeyRepairStore, keyRing tapnode.KeyRing, + chainParams address.ChainParams) ([]CustomAnchorKeyRepairHealth, error) { + + candidates, err := store.FetchCustomAnchorKeyRepairCandidates(ctx) + if err != nil { + return nil, fmt.Errorf("unable to fetch historical custom anchor "+ + "key candidates: %w", err) + } + + results := make([]CustomAnchorKeyRepairHealth, 0, len(candidates)) + for _, candidate := range candidates { + result, desc, relevant := auditCustomAnchorKeyCandidate( + ctx, candidate, keyRing, chainParams, + ) + if !relevant { + continue + } + + if result.Status == CustomAnchorKeyHealthy || + result.RequiresIntervention() { + + results = append(results, result) + continue + } + + err := store.RepairCustomAnchorInternalKey( + ctx, candidate, desc, + ) + if err != nil { + result.Status = CustomAnchorKeyConflict + result.Detail = fmt.Sprintf("verified repair compare-and-swap "+ + "failed: %v", err) + results = append(results, result) + continue + } + + result.Status = CustomAnchorKeyRepaired + result.Detail = "managed UTXO now references the wallet-verified " + + "custom anchor key" + results = append(results, result) + } + + return results, nil +} + +// auditCustomAnchorKeyCandidate proves whether a candidate is relevant and +// safe to repair. A zero status with a non-nil descriptor is the internal +// repairable state; it isn't exposed to callers. +func auditCustomAnchorKeyCandidate(ctx context.Context, + candidate CustomAnchorKeyRepairCandidate, keyRing tapnode.KeyRing, + chainParams address.ChainParams) ( + CustomAnchorKeyRepairHealth, keychain.KeyDescriptor, bool) { + + var ( + zeroDesc keychain.KeyDescriptor + result = CustomAnchorKeyRepairHealth{ + BatchKey: append([]byte(nil), candidate.BatchKey...), + } + ) + + packet, err := psbt.NewFromRawBytes( + bytes.NewReader(candidate.MintingTxPsbt), false, + ) + if err != nil { + // A non-custom row is expected in the broad store query. Without a + // parseable exact marker, it isn't a historical repair candidate. + return result, zeroDesc, false + } + if !hasExactHistoricalCustomAnchorMarker(packet) { + return result, zeroDesc, false + } + + invalid := func(detail string) (CustomAnchorKeyRepairHealth, + keychain.KeyDescriptor, bool) { + + result.Status = CustomAnchorKeyInvalid + result.Detail = detail + return result, zeroDesc, true + } + + outpoint, err := decodeCustomAnchorRepairOutpoint(candidate.Outpoint) + if err != nil { + return invalid(fmt.Sprintf("invalid retained outpoint: %v", err)) + } + result.Outpoint = outpoint + + finalTx, err := psbt.Extract(packet) + if err != nil { + return invalid(fmt.Sprintf("unable to extract retained signed mint "+ + "transaction: %v", err)) + } + if finalTx == nil || + uint64(candidate.AssetOutputIndex) >= + uint64(len(finalTx.TxOut)) || + uint64(candidate.AssetOutputIndex) >= uint64(len(packet.Outputs)) { + + return invalid("retained asset output index is out of range") + } + + // The store query deliberately returns every managed output linked to + // the mint transaction. Only the asset anchor index is the historical + // repair target; another managed output in the same transaction is an + // expected sibling, not unhealthy retained state. + if outpoint.Index != candidate.AssetOutputIndex { + return result, zeroDesc, false + } + if outpoint.Hash != finalTx.TxHash() { + + return invalid("retained asset outpoint doesn't match the mint " + + "transaction txid") + } + + if len(candidate.MerkleRoot) != 32 { + return invalid("retained taproot merkle root must be 32 bytes") + } + + batchKey, err := btcec.ParsePubKey(candidate.BatchKey) + if err != nil { + return invalid(fmt.Sprintf("invalid retained batch key: %v", err)) + } + managedKey, err := btcec.ParsePubKey(candidate.ManagedInternalKey) + if err != nil { + return invalid(fmt.Sprintf("invalid managed UTXO internal key: %v", err)) + } + + pOut := packet.Outputs[candidate.AssetOutputIndex] + var rawInternalKey *btcec.PublicKey + if len(pOut.TaprootInternalKey) != 0 { + rawInternalKey, err = schnorr.ParsePubKey(pOut.TaprootInternalKey) + if err != nil { + return invalid(fmt.Sprintf("invalid retained raw anchor key: %v", + err)) + } + result.AnchorInternalKey = rawInternalKey.SerializeCompressed() + } + + if len(pOut.Bip32Derivation) == 0 { + if rawInternalKey == nil { + return invalid("retained anchor output has no internal key") + } + + result.Status = CustomAnchorKeyLocatorRequired + result.Detail = "retained packet proves only the raw anchor key; " + + "automatic repair is unavailable and wallet-validated operator " + + "recovery is required" + return result, zeroDesc, true + } + if len(pOut.Bip32Derivation) != 1 { + return invalid("retained anchor output has conflicting wallet key " + + "locators") + } + + desc, err := tappsbt.KeyDescFromBip32Derivation( + pOut.Bip32Derivation[0], + ) + if err != nil { + return invalid(fmt.Sprintf("invalid retained anchor key locator: %v", + err)) + } + result.AnchorInternalKey = desc.PubKey.SerializeCompressed() + + expectedBip32, expectedTaproot := tappsbt.Bip32DerivationFromKeyDesc( + desc, chainParams.HDCoinType, + ) + if !bytes.Equal(expectedBip32.PubKey, + pOut.Bip32Derivation[0].PubKey) || + !equalUint32s(expectedBip32.Bip32Path, + pOut.Bip32Derivation[0].Bip32Path) { + + return invalid("retained anchor key locator has the wrong network " + + "or derivation path") + } + if rawInternalKey != nil && !bytes.Equal( + schnorr.SerializePubKey(rawInternalKey), + schnorr.SerializePubKey(desc.PubKey), + ) { + + return invalid("retained anchor key locator conflicts with the raw " + + "anchor key") + } + if len(pOut.TaprootBip32Derivation) > 1 { + return invalid("retained anchor output has conflicting taproot key " + + "locators") + } + if len(pOut.TaprootBip32Derivation) == 1 { + actual := pOut.TaprootBip32Derivation[0] + if !bytes.Equal(expectedTaproot.XOnlyPubKey, actual.XOnlyPubKey) || + !equalUint32s(expectedTaproot.Bip32Path, actual.Bip32Path) || + len(actual.LeafHashes) != 0 { + + return invalid("retained taproot key locator conflicts with the " + + "wallet descriptor") + } + } + + actualOutput := finalTx.TxOut[candidate.AssetOutputIndex] + expectedOutputKey := txscript.ComputeTaprootOutputKey( + desc.PubKey, candidate.MerkleRoot, + ) + expectedScript, err := txscript.PayToTaprootScript(expectedOutputKey) + if err != nil { + return invalid(fmt.Sprintf("unable to derive retained anchor script: %v", + err)) + } + if !bytes.Equal(expectedScript, actualOutput.PkScript) { + return invalid("wallet descriptor doesn't bind the committed anchor " + + "output script") + } + + managedIsBatchKey := managedKey.IsEqual(batchKey) + managedIsAnchorKey := managedKey.IsEqual(desc.PubKey) + if !managedIsBatchKey && !managedIsAnchorKey { + result.Status = CustomAnchorKeyConflict + result.Detail = "managed UTXO references neither the historical batch " + + "key nor the retained custom anchor key" + return result, desc, true + } + + if managedIsBatchKey { + batchOutputKey := txscript.ComputeTaprootOutputKey( + batchKey, candidate.MerkleRoot, + ) + batchScript, err := txscript.PayToTaprootScript(batchOutputKey) + if err != nil { + return invalid(fmt.Sprintf("unable to derive batch-key anchor "+ + "script: %v", err)) + } + if bytes.Equal(batchScript, actualOutput.PkScript) { + result.Status = CustomAnchorKeyHealthy + result.Detail = "historical batch key already binds the " + + "committed anchor output" + return result, desc, true + } + } + + if !keyRing.IsLocalKey(ctx, desc) { + result.Status = CustomAnchorKeyNotLocal + result.Detail = "retained key locator doesn't derive to the anchor key " + + "in the backing wallet" + return result, desc, true + } + if managedIsAnchorKey { + result.Status = CustomAnchorKeyHealthy + result.Detail = "managed UTXO already references the retained custom " + + "anchor key with a wallet-verified locator" + return result, desc, true + } + + // CustomAnchorKeyRepaired is zero and is used internally to signal that + // every proof passed and the caller may execute the store CAS. + result.Status = CustomAnchorKeyRepaired + return result, desc, true +} + +func hasExactHistoricalCustomAnchorMarker(packet *psbt.Packet) bool { + markerCount := 0 + for _, unknown := range packet.Unknowns { + if !bytes.Equal(unknown.Key, customAnchorPsbtMarker) { + continue + } + + markerCount++ + if !bytes.Equal(unknown.Value, []byte{1}) { + return false + } + } + + return markerCount == 1 +} + +func decodeCustomAnchorRepairOutpoint(raw []byte) (wire.OutPoint, error) { + var outpoint wire.OutPoint + if len(raw) != chainhashSize+4 { + return outpoint, fmt.Errorf("expected 36 bytes, got %d", len(raw)) + } + + copy(outpoint.Hash[:], raw[:chainhashSize]) + outpoint.Index = binary.LittleEndian.Uint32(raw[chainhashSize:]) + return outpoint, nil +} + +func equalUint32s(a, b []uint32) bool { + if len(a) != len(b) { + return false + } + for idx := range a { + if a[idx] != b[idx] { + return false + } + } + + return true +} + +const chainhashSize = 32 diff --git a/tapgarden/custom_anchor_repair_test.go b/tapgarden/custom_anchor_repair_test.go new file mode 100644 index 0000000000..3e675bed9c --- /dev/null +++ b/tapgarden/custom_anchor_repair_test.go @@ -0,0 +1,527 @@ +package tapgarden + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/psbt/v2" + "github.com/btcsuite/btcd/txscript/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/taproot-assets/address" + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/internal/test" + "github.com/lightninglabs/taproot-assets/tappsbt" + "github.com/lightningnetwork/lnd/keychain" + "github.com/stretchr/testify/require" +) + +type customAnchorRepairTestStore struct { + candidates []CustomAnchorKeyRepairCandidate + repairErr error + repairs int +} + +func (s *customAnchorRepairTestStore) FetchCustomAnchorKeyRepairCandidates( + context.Context) ([]CustomAnchorKeyRepairCandidate, error) { + + return append([]CustomAnchorKeyRepairCandidate(nil), s.candidates...), nil +} + +func (s *customAnchorRepairTestStore) RepairCustomAnchorInternalKey( + _ context.Context, candidate CustomAnchorKeyRepairCandidate, + desc keychain.KeyDescriptor) error { + + if s.repairErr != nil { + return s.repairErr + } + + for idx := range s.candidates { + stored := &s.candidates[idx] + if !bytes.Equal(stored.BatchKey, candidate.BatchKey) || + !bytes.Equal(stored.Outpoint, candidate.Outpoint) || + stored.AssetOutputIndex != candidate.AssetOutputIndex || + !bytes.Equal(stored.ManagedInternalKey, candidate.BatchKey) { + + continue + } + + stored.ManagedInternalKey = desc.PubKey.SerializeCompressed() + s.repairs++ + return nil + } + + return errors.New("repair compare-and-swap failed") +} + +type customAnchorRepairTestKeyRing struct { + keys map[keychain.KeyLocator]*btcec.PublicKey +} + +func (k *customAnchorRepairTestKeyRing) DeriveNextKey(context.Context, + keychain.KeyFamily) (keychain.KeyDescriptor, error) { + + return keychain.KeyDescriptor{}, errors.New("not implemented") +} + +func (k *customAnchorRepairTestKeyRing) IsLocalKey(_ context.Context, + desc keychain.KeyDescriptor) bool { + + pubKey, ok := k.keys[desc.KeyLocator] + return ok && pubKey.IsEqual(desc.PubKey) +} + +func (k *customAnchorRepairTestKeyRing) DeriveSharedKey(context.Context, + *btcec.PublicKey, *keychain.KeyLocator) ([sha256.Size]byte, error) { + + return [sha256.Size]byte{}, errors.New("not implemented") +} + +func customAnchorRepairCandidate(t *testing.T) ( + CustomAnchorKeyRepairCandidate, keychain.KeyDescriptor) { + + t.Helper() + + batchKey, _ := test.RandKeyDesc(t) + anchorKey, _ := test.RandKeyDesc(t) + anchorKey.Family = asset.TaprootAssetsKeyFamily + anchorKey.Index = 721 + merkleRoot := test.RandBytes(32) + + outputKey := txscript.ComputeTaprootOutputKey( + anchorKey.PubKey, merkleRoot, + ) + pkScript, err := txscript.PayToTaprootScript(outputKey) + require.NoError(t, err) + + prevHash := chainhash.Hash(test.RandBytes(32)) + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{PreviousOutPoint: wire.OutPoint{ + Hash: prevHash, Index: 3, + }}) + tx.AddTxOut(&wire.TxOut{Value: 10_000, PkScript: pkScript}) + + packet, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + // Historical managed UTXO rows exist only after the signed packet was + // committed. A zero-item witness is sufficient for PSBT extraction in + // this storage-level fixture; script validity isn't part of this audit. + packet.Inputs[0].FinalScriptWitness = []byte{0} + packet.Outputs[0].TaprootInternalKey = schnorr.SerializePubKey( + anchorKey.PubKey, + ) + bip32, taprootBip32 := tappsbt.Bip32DerivationFromKeyDesc( + anchorKey, address.TestNet3Tap.HDCoinType, + ) + packet.Outputs[0].Bip32Derivation = []*psbt.Bip32Derivation{bip32} + packet.Outputs[0].TaprootBip32Derivation = + []*psbt.TaprootBip32Derivation{taprootBip32} + packet.Unknowns = []*psbt.Unknown{{ + Key: append([]byte(nil), customAnchorPsbtMarker...), + Value: []byte{1}, + }} + + var packetBytes bytes.Buffer + require.NoError(t, packet.Serialize(&packetBytes)) + + outpoint := wire.OutPoint{Hash: tx.TxHash(), Index: 0} + rawOutpoint := make([]byte, 36) + copy(rawOutpoint[:32], outpoint.Hash[:]) + binary.LittleEndian.PutUint32(rawOutpoint[32:], outpoint.Index) + + return CustomAnchorKeyRepairCandidate{ + BatchKey: batchKey.PubKey.SerializeCompressed(), + MintingTxPsbt: packetBytes.Bytes(), + AssetOutputIndex: 0, + Outpoint: rawOutpoint, + ManagedInternalKey: batchKey.PubKey.SerializeCompressed(), + MerkleRoot: merkleRoot, + }, anchorKey +} + +func mutateRepairPacket(t *testing.T, + candidate *CustomAnchorKeyRepairCandidate, mutate func(*psbt.Packet)) { + + t.Helper() + + packet, err := psbt.NewFromRawBytes( + bytes.NewReader(candidate.MintingTxPsbt), false, + ) + require.NoError(t, err) + mutate(packet) + + var packetBytes bytes.Buffer + require.NoError(t, packet.Serialize(&packetBytes)) + candidate.MintingTxPsbt = packetBytes.Bytes() +} + +func TestCustomAnchorHistoricalKeyRepair(t *testing.T) { + candidate, anchorKey := customAnchorRepairCandidate(t) + store := &customAnchorRepairTestStore{ + candidates: []CustomAnchorKeyRepairCandidate{candidate}, + } + keyRing := &customAnchorRepairTestKeyRing{ + keys: map[keychain.KeyLocator]*btcec.PublicKey{ + anchorKey.KeyLocator: anchorKey.PubKey, + }, + } + + results, err := AuditAndRepairCustomAnchorKeys( + t.Context(), store, keyRing, address.TestNet3Tap, + ) + require.NoError(t, err) + require.Len(t, results, 1) + require.Equal(t, CustomAnchorKeyRepaired, results[0].Status) + require.False(t, results[0].RequiresIntervention()) + require.Equal(t, 1, store.repairs) + require.Equal( + t, anchorKey.PubKey.SerializeCompressed(), + store.candidates[0].ManagedInternalKey, + ) + + // Replaying the startup audit is a no-op after the successful repair. + results, err = AuditAndRepairCustomAnchorKeys( + t.Context(), store, keyRing, address.TestNet3Tap, + ) + require.NoError(t, err) + require.Len(t, results, 1) + require.Equal(t, CustomAnchorKeyHealthy, results[0].Status) + require.Equal(t, 1, store.repairs) + require.NoError(t, CustomAnchorKeyInterventionError(results)) +} + +func TestCustomAnchorHistoricalKeyRepairIgnoresSiblingManagedOutput( + t *testing.T) { + + target, anchorKey := customAnchorRepairCandidate(t) + mutateRepairPacket(t, &target, func(packet *psbt.Packet) { + packet.UnsignedTx.TxOut = append(packet.UnsignedTx.TxOut, &wire.TxOut{ + Value: 330, + PkScript: []byte{txscript.OP_TRUE}, + }) + packet.Outputs = append(packet.Outputs, psbt.POutput{}) + }) + packet, err := psbt.NewFromRawBytes( + bytes.NewReader(target.MintingTxPsbt), false, + ) + require.NoError(t, err) + finalTx, err := psbt.Extract(packet) + require.NoError(t, err) + hash := finalTx.TxHash() + copy(target.Outpoint[:32], hash[:]) + + sibling := target + sibling.Outpoint = append([]byte(nil), target.Outpoint...) + binary.LittleEndian.PutUint32(sibling.Outpoint[32:], 1) + thirdKey, _ := test.RandKeyDesc(t) + sibling.ManagedInternalKey = thirdKey.PubKey.SerializeCompressed() + + store := &customAnchorRepairTestStore{ + candidates: []CustomAnchorKeyRepairCandidate{sibling, target}, + } + results, err := AuditAndRepairCustomAnchorKeys( + t.Context(), store, localRepairKeyRing(anchorKey), + address.TestNet3Tap, + ) + require.NoError(t, err) + require.Len(t, results, 1) + require.Equal(t, CustomAnchorKeyRepaired, results[0].Status) + require.Equal(t, 1, store.repairs) + require.Equal( + t, thirdKey.PubKey.SerializeCompressed(), + store.candidates[0].ManagedInternalKey, + ) +} + +func TestCustomAnchorHistoricalKeyRepairHealthAttachedToList(t *testing.T) { + affectedKey, _ := test.RandKeyDesc(t) + healthyKey, _ := test.RandKeyDesc(t) + want := "status=key_locator_required: wallet-validated recovery required" + + planter := NewChainPlanter(PlanterConfig{}) + planter.customAnchorKeyErrors[asset.ToSerialized(affectedKey.PubKey)] = want + batches := []*VerboseBatch{ + { + MintingBatch: &MintingBatch{BatchKey: affectedKey}, + }, + { + MintingBatch: &MintingBatch{BatchKey: healthyKey}, + }, + } + + planter.attachCustomAnchorKeyErrors(batches) + require.Equal(t, want, batches[0].CustomAnchorKeyError) + require.Empty(t, batches[1].CustomAnchorKeyError) + require.Equal(t, want, batches[0].Copy().CustomAnchorKeyError) +} + +func TestCustomAnchorHistoricalKeyRepairUsesFinalTxID(t *testing.T) { + candidate, anchorKey := customAnchorRepairCandidate(t) + mutateRepairPacket(t, &candidate, func(packet *psbt.Packet) { + // A finalized legacy input can change txid through scriptSig. The + // repair must bind the stored outpoint to the extracted transaction, + // not the PSBT's unsigned-transaction hash. + packet.Inputs[0].FinalScriptWitness = nil + packet.Inputs[0].FinalScriptSig = []byte{txscript.OP_TRUE} + }) + packet, err := psbt.NewFromRawBytes( + bytes.NewReader(candidate.MintingTxPsbt), false, + ) + require.NoError(t, err) + finalTx, err := psbt.Extract(packet) + require.NoError(t, err) + finalHash := finalTx.TxHash() + require.NotEqual(t, packet.UnsignedTx.TxHash(), finalHash) + copy(candidate.Outpoint[:32], finalHash[:]) + + result, store := auditRepairCandidate( + t, candidate, anchorKey, true, + ) + require.Equal(t, CustomAnchorKeyRepaired, result.Status) + require.Equal(t, 1, store.repairs) +} + +func TestCustomAnchorHistoricalKeyRepairRefusals(t *testing.T) { + t.Run("raw key only", func(t *testing.T) { + candidate, anchorKey := customAnchorRepairCandidate(t) + mutateRepairPacket(t, &candidate, func(packet *psbt.Packet) { + packet.Outputs[0].Bip32Derivation = nil + packet.Outputs[0].TaprootBip32Derivation = nil + }) + + result, store := auditRepairCandidate( + t, candidate, anchorKey, true, + ) + require.Equal(t, CustomAnchorKeyLocatorRequired, result.Status) + require.Contains(t, result.Detail, "automatic repair is unavailable") + require.NotContains(t, result.Detail, "supply") + require.Zero(t, store.repairs) + assertRepairHealthError(t, result) + }) + + t.Run("non local locator", func(t *testing.T) { + candidate, anchorKey := customAnchorRepairCandidate(t) + result, store := auditRepairCandidate( + t, candidate, anchorKey, false, + ) + require.Equal(t, CustomAnchorKeyNotLocal, result.Status) + require.Zero(t, store.repairs) + assertRepairHealthError(t, result) + }) + + t.Run("already rebound but locator is non local", func(t *testing.T) { + candidate, anchorKey := customAnchorRepairCandidate(t) + candidate.ManagedInternalKey = anchorKey.PubKey.SerializeCompressed() + + result, store := auditRepairCandidate( + t, candidate, anchorKey, false, + ) + require.Equal(t, CustomAnchorKeyNotLocal, result.Status) + require.Zero(t, store.repairs) + }) + + t.Run("third persisted key", func(t *testing.T) { + candidate, anchorKey := customAnchorRepairCandidate(t) + thirdKey, _ := test.RandKeyDesc(t) + candidate.ManagedInternalKey = thirdKey.PubKey.SerializeCompressed() + + result, store := auditRepairCandidate( + t, candidate, anchorKey, true, + ) + require.Equal(t, CustomAnchorKeyConflict, result.Status) + require.Zero(t, store.repairs) + }) + + t.Run("compare and swap conflict", func(t *testing.T) { + candidate, anchorKey := customAnchorRepairCandidate(t) + store := &customAnchorRepairTestStore{ + candidates: []CustomAnchorKeyRepairCandidate{candidate}, + repairErr: errors.New("injected CAS conflict"), + } + keyRing := localRepairKeyRing(anchorKey) + + results, err := AuditAndRepairCustomAnchorKeys( + t.Context(), store, keyRing, address.TestNet3Tap, + ) + require.NoError(t, err) + require.Len(t, results, 1) + require.Equal(t, CustomAnchorKeyConflict, results[0].Status) + require.Contains(t, results[0].Detail, "injected CAS conflict") + require.Zero(t, store.repairs) + }) +} + +func TestCustomAnchorHistoricalKeyRepairBindingChecks(t *testing.T) { + tests := []struct { + name string + mutate func(*testing.T, *CustomAnchorKeyRepairCandidate) + relevant bool + wantStatus CustomAnchorKeyRepairStatus + }{ + { + name: "wrong marker value", + mutate: func(t *testing.T, + candidate *CustomAnchorKeyRepairCandidate) { + + mutateRepairPacket(t, candidate, func(packet *psbt.Packet) { + packet.Unknowns[0].Value = []byte{2} + }) + }, + relevant: false, + }, + { + name: "wrong output index", + mutate: func(_ *testing.T, + candidate *CustomAnchorKeyRepairCandidate) { + + candidate.AssetOutputIndex = 1 + }, + relevant: true, + wantStatus: CustomAnchorKeyInvalid, + }, + { + name: "wrong outpoint txid", + mutate: func(_ *testing.T, + candidate *CustomAnchorKeyRepairCandidate) { + + candidate.Outpoint[0] ^= 1 + }, + relevant: true, + wantStatus: CustomAnchorKeyInvalid, + }, + { + name: "wrong outpoint index", + mutate: func(_ *testing.T, + candidate *CustomAnchorKeyRepairCandidate) { + + binary.LittleEndian.PutUint32(candidate.Outpoint[32:], 1) + }, + relevant: false, + }, + { + name: "wrong output script", + mutate: func(t *testing.T, + candidate *CustomAnchorKeyRepairCandidate) { + + mutateRepairPacket(t, candidate, func(packet *psbt.Packet) { + packet.UnsignedTx.TxOut[0].PkScript[2] ^= 1 + }) + packet, err := psbt.NewFromRawBytes( + bytes.NewReader(candidate.MintingTxPsbt), false, + ) + require.NoError(t, err) + hash := packet.UnsignedTx.TxHash() + copy(candidate.Outpoint[:32], hash[:]) + }, + relevant: true, + wantStatus: CustomAnchorKeyInvalid, + }, + { + name: "wrong network locator", + mutate: func(t *testing.T, + candidate *CustomAnchorKeyRepairCandidate) { + + mutateRepairPacket(t, candidate, func(packet *psbt.Packet) { + packet.Outputs[0].Bip32Derivation[0].Bip32Path[1] = 0 + }) + }, + relevant: true, + wantStatus: CustomAnchorKeyInvalid, + }, + { + name: "multiple wallet locators", + mutate: func(t *testing.T, + candidate *CustomAnchorKeyRepairCandidate) { + + otherKey, _ := test.RandKeyDesc(t) + mutateRepairPacket(t, candidate, func(packet *psbt.Packet) { + derivation := *packet.Outputs[0].Bip32Derivation[0] + derivation.PubKey = otherKey.PubKey.SerializeCompressed() + packet.Outputs[0].Bip32Derivation = append( + packet.Outputs[0].Bip32Derivation, &derivation, + ) + }) + }, + relevant: true, + wantStatus: CustomAnchorKeyInvalid, + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + candidate, anchorKey := customAnchorRepairCandidate(t) + testCase.mutate(t, &candidate) + store := &customAnchorRepairTestStore{ + candidates: []CustomAnchorKeyRepairCandidate{candidate}, + } + results, err := AuditAndRepairCustomAnchorKeys( + t.Context(), store, localRepairKeyRing(anchorKey), + address.TestNet3Tap, + ) + require.NoError(t, err) + if !testCase.relevant { + require.Empty(t, results) + } else { + require.Len(t, results, 1) + require.Equal(t, testCase.wantStatus, + results[0].Status) + } + require.Zero(t, store.repairs) + }) + } +} + +func auditRepairCandidate(t *testing.T, + candidate CustomAnchorKeyRepairCandidate, anchorKey keychain.KeyDescriptor, + local bool) (CustomAnchorKeyRepairHealth, *customAnchorRepairTestStore) { + + t.Helper() + + store := &customAnchorRepairTestStore{ + candidates: []CustomAnchorKeyRepairCandidate{candidate}, + } + keyRing := &customAnchorRepairTestKeyRing{ + keys: make(map[keychain.KeyLocator]*btcec.PublicKey), + } + if local { + keyRing.keys[anchorKey.KeyLocator] = anchorKey.PubKey + } + + results, err := AuditAndRepairCustomAnchorKeys( + t.Context(), store, keyRing, address.TestNet3Tap, + ) + require.NoError(t, err) + require.Len(t, results, 1) + return results[0], store +} + +func localRepairKeyRing( + desc keychain.KeyDescriptor) *customAnchorRepairTestKeyRing { + + return &customAnchorRepairTestKeyRing{ + keys: map[keychain.KeyLocator]*btcec.PublicKey{ + desc.KeyLocator: desc.PubKey, + }, + } +} + +func assertRepairHealthError(t *testing.T, + result CustomAnchorKeyRepairHealth) { + + t.Helper() + + err := CustomAnchorKeyInterventionError( + []CustomAnchorKeyRepairHealth{result}, + ) + var healthErr *CustomAnchorKeyHealthError + require.ErrorAs(t, err, &healthErr) + require.Len(t, healthErr.Results, 1) + require.Contains(t, fmt.Sprint(err), result.Status.String()) +} diff --git a/tapgarden/custom_anchor_test.go b/tapgarden/custom_anchor_test.go index f1a0b77196..95cd75e541 100644 --- a/tapgarden/custom_anchor_test.go +++ b/tapgarden/custom_anchor_test.go @@ -2,6 +2,7 @@ package tapgarden import ( "bytes" + "context" "errors" "testing" @@ -14,12 +15,97 @@ import ( "github.com/lightninglabs/taproot-assets/address" "github.com/lightninglabs/taproot-assets/asset" "github.com/lightninglabs/taproot-assets/fn" + "github.com/lightninglabs/taproot-assets/tapnode/tapnodemock" "github.com/lightninglabs/taproot-assets/tappsbt" "github.com/lightninglabs/taproot-assets/tapsend" "github.com/lightningnetwork/lnd/keychain" "github.com/stretchr/testify/require" ) +func TestCustomAnchorLeaseMarkerValidation(t *testing.T) { + pkt := testCustomAnchorPacket(t) + markCustomAnchorPsbt(pkt) + + ops, state, err := parseCustomAnchorLockedUTXOs(pkt) + require.NoError(t, err) + require.Nil(t, ops) + require.Equal(t, customAnchorLeaseMarkerLegacy, state) + + op := pkt.UnsignedTx.TxIn[0].PreviousOutPoint + SetCustomAnchorLockedUTXOs(pkt, []wire.OutPoint{op}) + ops, state, err = parseCustomAnchorLockedUTXOs(pkt) + require.NoError(t, err) + require.Equal(t, customAnchorLeaseMarkerCurrent, state) + require.Equal(t, []wire.OutPoint{op}, ops) + + var marker *psbt.Unknown + for _, unknown := range pkt.Unknowns { + if bytes.Equal(unknown.Key, customAnchorPsbtMarker) { + marker = unknown + } + } + require.NotNil(t, marker) + marker.Value = []byte{1, 1, 0, 0, 0} + _, _, err = parseCustomAnchorLockedUTXOs(pkt) + require.ErrorContains(t, err, "marker length") + require.Nil(t, CustomAnchorLockedUTXOs(pkt)) + + marker.Value = []byte{1} + pkt.Unknowns = append(pkt.Unknowns, &psbt.Unknown{ + Key: fn.CopySlice(customAnchorPsbtMarker), Value: []byte{1}, + }) + _, _, err = parseCustomAnchorLockedUTXOs(pkt) + require.ErrorContains(t, err, "duplicate custom anchor lease marker") + + // The setter canonicalizes any retained duplicates into one current + // marker before the packet is stored again. + SetCustomAnchorLockedUTXOs(pkt, []wire.OutPoint{op}) + markerCount := 0 + for _, unknown := range pkt.Unknowns { + if bytes.Equal(unknown.Key, customAnchorPsbtMarker) { + markerCount++ + } + } + require.Equal(t, 1, markerCount) + ops, state, err = parseCustomAnchorLockedUTXOs(pkt) + require.NoError(t, err) + require.Equal(t, customAnchorLeaseMarkerCurrent, state) + require.Equal(t, []wire.OutPoint{op}, ops) + + foreign := op + foreign.Index++ + SetCustomAnchorLockedUTXOs(pkt, []wire.OutPoint{foreign}) + _, _, err = parseCustomAnchorLockedUTXOs(pkt) + require.ErrorContains(t, err, "is not in the transaction") +} + +func TestLegacyCustomAnchorLeaseMarkerReacquiresOwnedInputs(t *testing.T) { + pkt := testCustomAnchorPacket(t) + markCustomAnchorPsbt(pkt) + op := pkt.UnsignedTx.TxIn[0].PreviousOutPoint + wallet := tapnodemock.NewWalletAnchor() + wallet.SetOwnedInput(op, true) + funded := &FundedMintAnchorPsbt{ + FundedPsbt: tapsend.FundedPsbt{Pkt: pkt}, + } + + require.NoError(t, renewCustomAnchorLeases( + context.Background(), wallet, funded, + )) + require.Equal(t, op, <-wallet.LeaseInputSignal) + require.Equal(t, []wire.OutPoint{op}, funded.LockedUTXOs) + ops, state, err := parseCustomAnchorLockedUTXOs(pkt) + require.NoError(t, err) + require.Equal(t, customAnchorLeaseMarkerCurrent, state) + require.Equal(t, []wire.OutPoint{op}, ops) + select { + case duplicate := <-wallet.LeaseInputSignal: + t.Fatalf("legacy input leased twice during marker upgrade: %v", + duplicate) + default: + } +} + func testCustomAnchorPacket(t *testing.T) *psbt.Packet { t.Helper() @@ -227,7 +313,7 @@ func TestCustomAnchorPublicationPending(t *testing.T) { require.True(t, customAnchorPublicationPending(batch)) setCustomAnchorPublishState(pkt, customAnchorPublishRejected) - require.False(t, customAnchorPublicationPending(batch)) + require.True(t, customAnchorPublicationPending(batch)) } func TestMergeSignedCustomPsbt(t *testing.T) { diff --git a/tapgarden/interface.go b/tapgarden/interface.go index a6f6e921d1..d2bd782058 100644 --- a/tapgarden/interface.go +++ b/tapgarden/interface.go @@ -308,6 +308,32 @@ type MintingStore interface { groupKey btcec.PublicKey) (fn.Option[DelegationKey], error) } +// CustomAnchorKeyRepairCandidate contains the retained database state needed +// for a wallet-aware audit of a historical custom mint anchor. The store does +// not decide whether the key is local; that proof is performed by the planter +// after its KeyRing is available. +type CustomAnchorKeyRepairCandidate struct { + BatchKey []byte + MintingTxPsbt []byte + AssetOutputIndex uint32 + Outpoint []byte + ManagedInternalKey []byte + MerkleRoot []byte +} + +// CustomAnchorKeyRepairStore is an optional extension implemented by stores +// that can enumerate and compare-and-swap historical custom anchor key rows. +// The repair method must only be called after the descriptor has been proven +// local and shown to bind the candidate's committed output. +type CustomAnchorKeyRepairStore interface { + FetchCustomAnchorKeyRepairCandidates( + ctx context.Context) ([]CustomAnchorKeyRepairCandidate, error) + + RepairCustomAnchorInternalKey(ctx context.Context, + candidate CustomAnchorKeyRepairCandidate, + desc keychain.KeyDescriptor) error +} + var ( // ErrNoGenesis is returned when fetching an asset genesis fails. ErrNoGenesis = errors.New("unable to fetch genesis asset") diff --git a/tapgarden/issue721_stateful_test.go b/tapgarden/issue721_stateful_test.go index e3b44cbf37..a3e5e999d0 100644 --- a/tapgarden/issue721_stateful_test.go +++ b/tapgarden/issue721_stateful_test.go @@ -234,7 +234,7 @@ func TestIssue721PrepareSignResume(t *testing.T) { original := clonePacket(t, pkt) callerBytes := serializePacket(t, pkt) ownedInput := pkt.UnsignedTx.TxIn[0].PreviousOutPoint - h.wallet.OwnedInputs[ownedInput] = true + h.wallet.SetOwnedInput(ownedInput, true) funded := issue721Fund(t, h, pkt) initialLease, err := fn.RecvOrTimeout( @@ -376,9 +376,10 @@ func TestIssue721PrepareSignResume(t *testing.T) { ) require.NoError(t, err) require.True(t, (*importedKey).IsEqual(expectedOutputKey)) - published := h.assertTxPublished() confReq, err := fn.RecvOrTimeout(h.chain.ConfReqSignal, defaultTimeout) require.NoError(t, err) + published, err := psbt.Extract(valid) + require.NoError(t, err) require.Equal( t, published.TxOut[1].PkScript, h.chain.ConfPkScripts[*confReq], ) @@ -398,6 +399,7 @@ func TestIssue721PrepareSignResume(t *testing.T) { func TestIssue721PublishRetry(t *testing.T) { store := newMintingStore(t) h := newMintingTestHarness(t, store) + h.leaseRenewalInterval = 100 * time.Millisecond h.refreshChainPlanter() t.Cleanup(func() { if h.planter != nil { @@ -407,7 +409,14 @@ func TestIssue721PublishRetry(t *testing.T) { h.queueSeedlingsInBatch(false, issue721Seedling()) pkt, _, witnessScript := issue721Anchor(t) + ownedInput := pkt.UnsignedTx.TxIn[0].PreviousOutPoint + h.wallet.SetOwnedInput(ownedInput, true) issue721Fund(t, h, pkt) + initialLease, err := fn.RecvOrTimeout( + h.wallet.LeaseInputSignal, defaultTimeout, + ) + require.NoError(t, err) + require.Equal(t, ownedInput, *initialLease) prepared, err := h.planter.PrepareBatch() require.NoError(t, err) @@ -422,6 +431,11 @@ func TestIssue721PublishRetry(t *testing.T) { h.finalizeBatch(&wg, respChan, &tapgarden.FinalizeParams{ SignedPsbt: signed, }) + finalizeLease, err := fn.RecvOrTimeout( + h.wallet.LeaseInputSignal, defaultTimeout, + ) + require.NoError(t, err) + require.Equal(t, ownedInput, *finalizeLease) _, err = fn.RecvOrTimeout( h.wallet.ImportPubKeySignal, defaultTimeout, ) @@ -430,21 +444,63 @@ func TestIssue721PublishRetry(t *testing.T) { h.chain.PublishAttempts, defaultTimeout, ) require.NoError(t, err) - published := h.assertTxPublished() - require.Equal( - t, (*firstAttempt).WitnessHash(), published.WitnessHash(), + + // Degrade lease renewal before the first Broadcast retry. The watcher + // must still be installed, while active publication is suppressed until + // the recorded local input is protected again. + h.wallet.SetLeaseError( + ownedInput, fmt.Errorf("temporary broadcast renewal failure"), ) _, err = fn.RecvOrTimeout(h.chain.ConfReqSignal, defaultTimeout) require.NoError(t, err) minted := h.assertFinalizeBatch(&wg, respChan, "") require.Equal(t, tapgarden.BatchStateBroadcast, minted.State()) + select { + case attempt := <-h.chain.PublishAttempts: + t.Fatalf("published without a renewed lease: %v", attempt.TxHash()) + case published := <-h.chain.PublishReq: + t.Fatalf("published without a renewed lease: %v", published.TxHash()) + case <-time.After(2 * h.leaseRenewalInterval): + } + + batches, err := h.planter.ListBatches(tapgarden.ListBatchesParams{ + BatchKey: prepared.BatchKey.PubKey, + }) + require.NoError(t, err) + require.Len(t, batches, 1) + require.Contains( + t, batches[0].CustomAnchorLeaseError, + "temporary broadcast renewal failure", + ) + + // Recovery first renews the local lease, then retries the exact persisted + // transaction and clears the queryable degradation. + h.wallet.SetLeaseError(ownedInput, nil) + tickerLease, err := fn.RecvOrTimeout( + h.wallet.LeaseInputSignal, defaultTimeout, + ) + require.NoError(t, err) + require.Equal(t, ownedInput, *tickerLease) + published := h.assertTxPublished() + require.Equal( + t, (*firstAttempt).WitnessHash(), published.WitnessHash(), + ) + require.Eventually(t, func() bool { + batches, err := h.planter.ListBatches( + tapgarden.ListBatchesParams{ + BatchKey: prepared.BatchKey.PubKey, + }, + ) + return err == nil && len(batches) == 1 && + batches[0].CustomAnchorLeaseError == "" + }, defaultTimeout, 10*time.Millisecond) } -// TestIssue721DefinitivePublishFailureCancellable verifies a conclusive -// backend rejection never advances a custom batch to Broadcast. The signed -// packet is retained durably in Committed across restart, where the caller can -// still cancel and rebuild the transaction with a different input. -func TestIssue721DefinitivePublishFailureCancellable(t *testing.T) { +// TestIssue721ClassifiedPublishFailureRemainsBroadcast verifies that reject +// classification is diagnostic only after WalletKit sees fully signed bytes. +// The caller may have relayed the transaction independently, so the exact tx +// remains watched/retried in Broadcast and its leases aren't released. +func TestIssue721ClassifiedPublishFailureRemainsBroadcast(t *testing.T) { store := newMintingStore(t) h := newMintingTestHarness(t, store) h.refreshChainPlanter() @@ -457,7 +513,7 @@ func TestIssue721DefinitivePublishFailureCancellable(t *testing.T) { h.queueSeedlingsInBatch(false, issue721Seedling()) pkt, _, witnessScript := issue721Anchor(t) ownedInput := pkt.UnsignedTx.TxIn[0].PreviousOutPoint - h.wallet.OwnedInputs[ownedInput] = true + h.wallet.SetOwnedInput(ownedInput, true) issue721Fund(t, h, pkt) initialLease, err := fn.RecvOrTimeout( h.wallet.LeaseInputSignal, defaultTimeout, @@ -480,6 +536,11 @@ func TestIssue721DefinitivePublishFailureCancellable(t *testing.T) { h.finalizeBatch(&wg, respChan, &tapgarden.FinalizeParams{ SignedPsbt: signed, }) + finalizeLease, err := fn.RecvOrTimeout( + h.wallet.LeaseInputSignal, defaultTimeout, + ) + require.NoError(t, err) + require.Equal(t, ownedInput, *finalizeLease) _, err = fn.RecvOrTimeout( h.wallet.ImportPubKeySignal, defaultTimeout, ) @@ -489,36 +550,34 @@ func TestIssue721DefinitivePublishFailureCancellable(t *testing.T) { ) require.NoError(t, err) require.Equal(t, signedTx.WitnessHash(), (*attempt).WitnessHash()) - h.assertFinalizeBatch( - &wg, respChan, "transaction rejected before broadcast state", - ) - pending, err := h.planter.PendingBatch() - require.NoError(t, err) - require.Equal(t, tapgarden.BatchStateCommitted, pending.State()) - persisted := h.fetchSingleBatch(pending.BatchKey.PubKey) - require.Equal(t, tapgarden.BatchStateCommitted, persisted.State()) - persistedTx, err := psbt.Extract(persisted.GenesisPacket.Pkt) - require.NoError(t, err) - require.Equal(t, signedTx.WitnessHash(), persistedTx.WitnessHash()) - h.assertNumCaretakersActive(0) - - // A restored custom Committed batch remains paused and retains the exact - // finalized transaction rather than attempting to publish it again. - h.refreshChainPlanter() - restored, err := h.planter.PendingBatch() + // Before the byte-identical retry, the Broadcast watcher is installed + // and the recorded local input lease is renewed. + _, err = fn.RecvOrTimeout(h.chain.ConfReqSignal, defaultTimeout) require.NoError(t, err) - require.Equal(t, tapgarden.BatchStateCommitted, restored.State()) - restoredTx, err := psbt.Extract(restored.GenesisPacket.Pkt) + broadcastLease, err := fn.RecvOrTimeout( + h.wallet.LeaseInputSignal, defaultTimeout, + ) require.NoError(t, err) - require.Equal(t, signedTx.WitnessHash(), restoredTx.WitnessHash()) - h.assertNumCaretakersActive(0) + require.Equal(t, ownedInput, *broadcastLease) + published := h.assertTxPublished() + require.Equal(t, signedTx.WitnessHash(), published.WitnessHash()) + minted := h.assertFinalizeBatch(&wg, respChan, "") + require.Equal(t, tapgarden.BatchStateBroadcast, minted.State()) - batchKey, err := h.planter.CancelBatch() + persisted := h.fetchSingleBatch(prepared.BatchKey.PubKey) + require.Equal(t, tapgarden.BatchStateBroadcast, persisted.State()) + persistedTx, err := psbt.Extract(persisted.GenesisPacket.Pkt) require.NoError(t, err) - require.True(t, batchKey.IsEqual(restored.BatchKey.PubKey)) - cancelled := h.fetchSingleBatch(batchKey) - require.Equal(t, tapgarden.BatchStateSproutCancelled, cancelled.State()) + require.Equal(t, signedTx.WitnessHash(), persistedTx.WitnessHash()) + h.assertNumCaretakersActive(1) + _, err = h.planter.CancelBatch() + require.ErrorContains(t, err, "not cancellable") + select { + case released := <-h.wallet.ReleaseInputSignal: + t.Fatalf("broadcast input lease unexpectedly released: %v", released) + default: + } } // TestIssue721ImportRetry verifies that a transient wallet import failure is @@ -543,7 +602,7 @@ func TestIssue721ImportRetry(t *testing.T) { t, witnessScript, ) - h.wallet.ImportErr = fmt.Errorf("temporary import failure") + h.wallet.SetImportError(fmt.Errorf("temporary import failure")) var wg sync.WaitGroup respChan := make(chan *FinalizeBatchResp, 1) h.finalizeBatch(&wg, respChan, &tapgarden.FinalizeParams{ @@ -565,7 +624,7 @@ func TestIssue721ImportRetry(t *testing.T) { persisted := h.fetchSingleBatch(pending.BatchKey.PubKey) require.Equal(t, tapgarden.BatchStateCommitted, persisted.State()) - h.wallet.ImportErr = nil + h.wallet.SetImportError(nil) retry := clonePacket(t, pending.GenesisPacket.Pkt) h.finalizeBatch(&wg, respChan, &tapgarden.FinalizeParams{ SignedPsbt: retry, @@ -574,7 +633,6 @@ func TestIssue721ImportRetry(t *testing.T) { h.wallet.ImportPubKeySignal, defaultTimeout, ) require.NoError(t, err) - h.assertTxPublished() _, err = fn.RecvOrTimeout(h.chain.ConfReqSignal, defaultTimeout) require.NoError(t, err) minted := h.assertFinalizeBatch(&wg, respChan, "") @@ -594,7 +652,7 @@ func TestIssue721ImportRetryAfterRestart(t *testing.T) { h.queueSeedlingsInBatch(false, issue721Seedling()) pkt, _, witnessScript := issue721Anchor(t) ownedInput := pkt.UnsignedTx.TxIn[0].PreviousOutPoint - h.wallet.OwnedInputs[ownedInput] = true + h.wallet.SetOwnedInput(ownedInput, true) issue721Fund(t, h, pkt) initialLease, err := fn.RecvOrTimeout( h.wallet.LeaseInputSignal, defaultTimeout, @@ -608,7 +666,7 @@ func TestIssue721ImportRetryAfterRestart(t *testing.T) { t, witnessScript, ) - h.wallet.ImportErr = fmt.Errorf("temporary import failure") + h.wallet.SetImportError(fmt.Errorf("temporary import failure")) var wg sync.WaitGroup respChan := make(chan *FinalizeBatchResp, 1) h.finalizeBatch(&wg, respChan, &tapgarden.FinalizeParams{ @@ -632,7 +690,7 @@ func TestIssue721ImportRetryAfterRestart(t *testing.T) { // The signed, pre-publication packet resumes automatically. Import is // retried before the first publication attempt after restart. - h.wallet.ImportErr = nil + h.wallet.SetImportError(nil) h.refreshChainPlanter() restartLease, err := fn.RecvOrTimeout( h.wallet.LeaseInputSignal, defaultTimeout, @@ -641,7 +699,6 @@ func TestIssue721ImportRetryAfterRestart(t *testing.T) { require.Equal(t, ownedInput, *restartLease) _, err = fn.RecvOrTimeout(h.wallet.ImportPubKeySignal, defaultTimeout) require.NoError(t, err) - h.assertTxPublished() _, err = fn.RecvOrTimeout(h.chain.ConfReqSignal, defaultTimeout) require.NoError(t, err) h.assertNumCaretakersActive(1) @@ -664,7 +721,7 @@ func TestIssue721ImportFailureAfterRestartIsAdopted(t *testing.T) { h.queueSeedlingsInBatch(false, issue721Seedling()) pkt, _, witnessScript := issue721Anchor(t) ownedInput := pkt.UnsignedTx.TxIn[0].PreviousOutPoint - h.wallet.OwnedInputs[ownedInput] = true + h.wallet.SetOwnedInput(ownedInput, true) issue721Fund(t, h, pkt) initialLease, err := fn.RecvOrTimeout( h.wallet.LeaseInputSignal, defaultTimeout, @@ -680,7 +737,7 @@ func TestIssue721ImportFailureAfterRestartIsAdopted(t *testing.T) { // The first import failure durably leaves the signed packet in the // import-pending state. - h.wallet.ImportErr = fmt.Errorf("temporary import failure") + h.wallet.SetImportError(fmt.Errorf("temporary import failure")) var wg sync.WaitGroup respChan := make(chan *FinalizeBatchResp, 1) h.finalizeBatch(&wg, respChan, &tapgarden.FinalizeParams{ @@ -777,9 +834,8 @@ func TestIssue721ImportFailureAfterRestartIsAdopted(t *testing.T) { } // TestIssue721PublishFailureAfterRestartIsAdopted verifies that a recovered -// publish-pending caretaker is cleaned up after a definitive backend rejection -// and leaves the durably rejected batch cancellable without a stale caretaker -// or wallet lease. +// publish-pending caretaker remains adopted after a classified backend reject, +// installs its watcher and retries without exposing cancellation or leases. func TestIssue721PublishFailureAfterRestartIsAdopted(t *testing.T) { store := newMintingStore(t) h := newMintingTestHarness(t, store) @@ -793,7 +849,7 @@ func TestIssue721PublishFailureAfterRestartIsAdopted(t *testing.T) { h.queueSeedlingsInBatch(false, issue721Seedling()) pkt, _, witnessScript := issue721Anchor(t) ownedInput := pkt.UnsignedTx.TxIn[0].PreviousOutPoint - h.wallet.OwnedInputs[ownedInput] = true + h.wallet.SetOwnedInput(ownedInput, true) issue721Fund(t, h, pkt) initialLease, err := fn.RecvOrTimeout( h.wallet.LeaseInputSignal, defaultTimeout, @@ -832,34 +888,207 @@ func TestIssue721PublishFailureAfterRestartIsAdopted(t *testing.T) { require.ErrorContains(t, err, "batch recovery is in progress") _, err = fn.RecvOrTimeout(h.wallet.ImportPubKeySignal, defaultTimeout) require.NoError(t, err) - _, err = fn.RecvOrTimeout(h.chain.PublishAttempts, defaultTimeout) + preflightLease, err := fn.RecvOrTimeout( + h.wallet.LeaseInputSignal, defaultTimeout, + ) require.NoError(t, err) - released, err := fn.RecvOrTimeout( - h.wallet.ReleaseInputSignal, defaultTimeout, + require.Equal(t, ownedInput, *preflightLease) + firstAttempt, err := fn.RecvOrTimeout( + h.chain.PublishAttempts, defaultTimeout, ) require.NoError(t, err) - require.Equal(t, ownedInput, *released) - - h.assertNumCaretakersActive(0) - pending, err := h.planter.PendingBatch() + _, err = fn.RecvOrTimeout(h.chain.ConfReqSignal, defaultTimeout) require.NoError(t, err) - require.Equal(t, tapgarden.BatchStateCommitted, pending.State()) - require.Empty(t, pending.GenesisPacket.LockedUTXOs) - persisted := h.fetchSingleBatch(pending.BatchKey.PubKey) - require.Empty(t, persisted.GenesisPacket.LockedUTXOs) - - batchKey, err := h.planter.CancelBatch() + retryLease, err := fn.RecvOrTimeout( + h.wallet.LeaseInputSignal, defaultTimeout, + ) require.NoError(t, err) - require.True(t, batchKey.IsEqual(prepared.BatchKey.PubKey)) - cancelled := h.fetchSingleBatch(batchKey) - require.Equal(t, tapgarden.BatchStateSproutCancelled, cancelled.State()) + require.Equal(t, ownedInput, *retryLease) + published := h.assertTxPublished() + require.Equal(t, (*firstAttempt).WitnessHash(), published.WitnessHash()) + require.Eventually(t, func() bool { + return h.fetchSingleBatch(prepared.BatchKey.PubKey).State() == + tapgarden.BatchStateBroadcast + }, defaultTimeout, 10*time.Millisecond) + h.assertNumCaretakersActive(1) select { case op := <-h.wallet.ReleaseInputSignal: - t.Fatalf("lease released twice after definitive rejection: %v", op) + t.Fatalf("lease released after classified rejection: %v", op) default: } } +// TestIssue721PublishPendingRestartLeaseFailure verifies that a retained +// publication marker crosses into durable Broadcast and installs its watcher +// even if the local lease can't be reacquired after restart. Active +// publication remains suppressed until renewal later succeeds. +func TestIssue721PublishPendingRestartLeaseFailure(t *testing.T) { + store := newMintingStore(t) + h := newMintingTestHarness(t, store) + h.leaseRenewalInterval = 20 * time.Millisecond + h.refreshChainPlanter() + t.Cleanup(func() { + if h.planter != nil { + _ = h.planter.Stop() + } + }) + + h.queueSeedlingsInBatch(false, issue721Seedling()) + pkt, _, witnessScript := issue721Anchor(t) + ownedInput := pkt.UnsignedTx.TxIn[0].PreviousOutPoint + h.wallet.SetOwnedInput(ownedInput, true) + issue721Fund(t, h, pkt) + _, err := fn.RecvOrTimeout(h.wallet.LeaseInputSignal, defaultTimeout) + require.NoError(t, err) + prepared, err := h.planter.PrepareBatch() + require.NoError(t, err) + + signed := clonePacket(t, prepared.GenesisPacket.Pkt) + signed.Inputs[0].FinalScriptWitness = issue721FinalWitness( + t, witnessScript, + ) + signed.Unknowns = append(signed.Unknowns, &psbt.Unknown{ + Key: []byte{0xfc, 0x04, 't', 'a', 'p', 'd', 0x02}, + Value: []byte{1}, + }) + funded := prepared.GenesisPacket.FundedPsbt + funded.Pkt = signed + require.NoError(t, store.StoreSignedGenesisPsbt( + t.Context(), prepared.BatchKey.PubKey, &funded, + )) + expected, err := psbt.Extract(signed) + require.NoError(t, err) + + h.wallet.SetLeaseError( + ownedInput, fmt.Errorf("restart lease unavailable"), + ) + h.refreshChainPlanter() + _, err = fn.RecvOrTimeout(h.wallet.ImportPubKeySignal, defaultTimeout) + require.NoError(t, err) + _, err = fn.RecvOrTimeout(h.chain.ConfReqSignal, defaultTimeout) + require.NoError(t, err) + require.Eventually(t, func() bool { + return h.fetchSingleBatch(prepared.BatchKey.PubKey).State() == + tapgarden.BatchStateBroadcast + }, defaultTimeout, 10*time.Millisecond) + + select { + case attempt := <-h.chain.PublishAttempts: + t.Fatalf("publish attempted without restart lease: %v", attempt.TxHash()) + case published := <-h.chain.PublishReq: + t.Fatalf("published without restart lease: %v", published.TxHash()) + case released := <-h.wallet.ReleaseInputSignal: + t.Fatalf("publication-pending lease released: %v", released) + case <-time.After(2 * h.leaseRenewalInterval): + } + _, err = h.planter.CancelBatch() + require.ErrorContains(t, err, "not cancellable") + + batches, err := h.planter.ListBatches(tapgarden.ListBatchesParams{ + BatchKey: prepared.BatchKey.PubKey, + }) + require.NoError(t, err) + require.Len(t, batches, 1) + require.Contains(t, batches[0].CustomAnchorLeaseError, + "restart lease unavailable") + + h.wallet.SetLeaseError(ownedInput, nil) + renewed, err := fn.RecvOrTimeout( + h.wallet.LeaseInputSignal, defaultTimeout, + ) + require.NoError(t, err) + require.Equal(t, ownedInput, *renewed) + published := h.assertTxPublished() + require.Equal(t, expected.WitnessHash(), published.WitnessHash()) + require.Eventually(t, func() bool { + batches, err := h.planter.ListBatches( + tapgarden.ListBatchesParams{ + BatchKey: prepared.BatchKey.PubKey, + }, + ) + return err == nil && len(batches) == 1 && + batches[0].CustomAnchorLeaseError == "" + }, defaultTimeout, 10*time.Millisecond) +} + +// TestIssue721CorruptLeaseMarkerWatchesWithoutPublishing verifies malformed +// retained lease metadata never becomes an external-only fail-open. A prior +// publication boundary still reaches Broadcast and installs its watcher, but +// no active publication or cancellation is allowed until operator recovery. +func TestIssue721CorruptLeaseMarkerWatchesWithoutPublishing(t *testing.T) { + store := newMintingStore(t) + h := newMintingTestHarness(t, store) + h.refreshChainPlanter() + t.Cleanup(func() { + if h.planter != nil { + _ = h.planter.Stop() + } + }) + + h.queueSeedlingsInBatch(false, issue721Seedling()) + pkt, _, witnessScript := issue721Anchor(t) + ownedInput := pkt.UnsignedTx.TxIn[0].PreviousOutPoint + h.wallet.SetOwnedInput(ownedInput, true) + issue721Fund(t, h, pkt) + _, err := fn.RecvOrTimeout(h.wallet.LeaseInputSignal, defaultTimeout) + require.NoError(t, err) + prepared, err := h.planter.PrepareBatch() + require.NoError(t, err) + + signed := clonePacket(t, prepared.GenesisPacket.Pkt) + signed.Inputs[0].FinalScriptWitness = issue721FinalWitness( + t, witnessScript, + ) + for _, unknown := range signed.Unknowns { + if bytes.Equal(unknown.Key, + []byte{0xfc, 0x04, 't', 'a', 'p', 'd', 0x01}) { + + unknown.Value = []byte{1, 1, 0, 0, 0} + } + } + signed.Unknowns = append(signed.Unknowns, &psbt.Unknown{ + Key: []byte{0xfc, 0x04, 't', 'a', 'p', 'd', 0x02}, + Value: []byte{1}, + }) + funded := prepared.GenesisPacket.FundedPsbt + funded.Pkt = signed + require.NoError(t, store.StoreSignedGenesisPsbt( + t.Context(), prepared.BatchKey.PubKey, &funded, + )) + + h.refreshChainPlanter() + _, err = fn.RecvOrTimeout(h.wallet.ImportPubKeySignal, defaultTimeout) + require.NoError(t, err) + _, err = fn.RecvOrTimeout(h.chain.ConfReqSignal, defaultTimeout) + require.NoError(t, err) + require.Eventually(t, func() bool { + return h.fetchSingleBatch(prepared.BatchKey.PubKey).State() == + tapgarden.BatchStateBroadcast + }, defaultTimeout, 10*time.Millisecond) + + select { + case leased := <-h.wallet.LeaseInputSignal: + t.Fatalf("corrupt marker unexpectedly leased input: %v", leased) + case attempt := <-h.chain.PublishAttempts: + t.Fatalf("corrupt marker unexpectedly published: %v", attempt.TxHash()) + case published := <-h.chain.PublishReq: + t.Fatalf("corrupt marker unexpectedly published: %v", published.TxHash()) + case released := <-h.wallet.ReleaseInputSignal: + t.Fatalf("corrupt marker unexpectedly released input: %v", released) + case <-time.After(100 * time.Millisecond): + } + + batches, err := h.planter.ListBatches(tapgarden.ListBatchesParams{ + BatchKey: prepared.BatchKey.PubKey, + }) + require.NoError(t, err) + require.Len(t, batches, 1) + require.Contains(t, batches[0].CustomAnchorLeaseError, + "invalid custom anchor lease marker") + _, err = h.planter.CancelBatch() + require.ErrorContains(t, err, "not cancellable") +} + // TestIssue721RecoveredCaretakerFastConfirmation verifies that confirmation // completion and the forwarded publication result can arrive in either order // without leaving the startup recovery reservation behind. @@ -896,7 +1125,8 @@ func TestIssue721RecoveredCaretakerFastConfirmation(t *testing.T) { h.refreshChainPlanter() _, err = fn.RecvOrTimeout(h.wallet.ImportPubKeySignal, defaultTimeout) require.NoError(t, err) - published := h.assertTxPublished() + published, err := psbt.Extract(signed) + require.NoError(t, err) // A single-transaction block has the transaction ID as its merkle root. merkleRoot := published.TxHash() @@ -938,7 +1168,7 @@ func TestIssue721CaretakerCancelReleasesAfterDurableState(t *testing.T) { h.queueSeedlingsInBatch(false, issue721Seedling()) pkt, _, _ := issue721Anchor(t) ownedInput := pkt.UnsignedTx.TxIn[0].PreviousOutPoint - h.wallet.OwnedInputs[ownedInput] = true + h.wallet.SetOwnedInput(ownedInput, true) issue721Fund(t, h, pkt) leased, err := fn.RecvOrTimeout( h.wallet.LeaseInputSignal, defaultTimeout, @@ -985,6 +1215,7 @@ func TestIssue721CaretakerCancelReleasesAfterDurableState(t *testing.T) { func TestIssue721ImportRestartLeaseFailurePauses(t *testing.T) { store := newMintingStore(t) h := newMintingTestHarness(t, store) + h.leaseRenewalInterval = 20 * time.Millisecond h.refreshChainPlanter() t.Cleanup(func() { if h.planter != nil { @@ -995,7 +1226,7 @@ func TestIssue721ImportRestartLeaseFailurePauses(t *testing.T) { h.queueSeedlingsInBatch(false, issue721Seedling()) pkt, _, witnessScript := issue721Anchor(t) op := pkt.UnsignedTx.TxIn[0].PreviousOutPoint - h.wallet.OwnedInputs[op] = true + h.wallet.SetOwnedInput(op, true) issue721Fund(t, h, pkt) <-h.wallet.LeaseInputSignal prepared, err := h.planter.PrepareBatch() @@ -1005,7 +1236,7 @@ func TestIssue721ImportRestartLeaseFailurePauses(t *testing.T) { t, witnessScript, ) - h.wallet.ImportErr = fmt.Errorf("temporary import failure") + h.wallet.SetImportError(fmt.Errorf("temporary import failure")) var wg sync.WaitGroup respChan := make(chan *FinalizeBatchResp, 1) h.finalizeBatch(&wg, respChan, &tapgarden.FinalizeParams{ @@ -1016,13 +1247,21 @@ func TestIssue721ImportRestartLeaseFailurePauses(t *testing.T) { require.NoError(t, err) h.assertFinalizeBatch(&wg, respChan, "temporary import failure") - h.wallet.ImportErr = nil - h.wallet.LeaseErrors[op] = fmt.Errorf("lease no longer available") + h.wallet.SetImportError(nil) + h.wallet.SetLeaseError(op, fmt.Errorf("lease no longer available")) h.refreshChainPlanter() pending, err := h.planter.PendingBatch() require.NoError(t, err) require.Equal(t, tapgarden.BatchStateCommitted, pending.State()) h.assertNumCaretakersActive(0) + batches, err := h.planter.ListBatches(tapgarden.ListBatchesParams{ + BatchKey: prepared.BatchKey.PubKey, + }) + require.NoError(t, err) + require.Len(t, batches, 1) + require.Contains( + t, batches[0].CustomAnchorLeaseError, "lease no longer available", + ) select { case <-h.wallet.ImportPubKeySignal: t.Fatal("wallet output imported after lease renewal failure") @@ -1031,13 +1270,27 @@ func TestIssue721ImportRestartLeaseFailurePauses(t *testing.T) { default: } - delete(h.wallet.LeaseErrors, op) + h.wallet.SetLeaseError(op, nil) + renewed, err := fn.RecvOrTimeout( + h.wallet.LeaseInputSignal, defaultTimeout, + ) + require.NoError(t, err) + require.Equal(t, op, *renewed) + require.Eventually(t, func() bool { + batches, err := h.planter.ListBatches( + tapgarden.ListBatchesParams{ + BatchKey: prepared.BatchKey.PubKey, + }, + ) + return err == nil && len(batches) == 1 && + batches[0].CustomAnchorLeaseError == "" + }, defaultTimeout, 10*time.Millisecond) _, err = h.planter.CancelBatch() require.NoError(t, err) } -// TestIssue721RetryStoreFailureIsAtomic ensures marker updates during a retry -// never alias the persisted/in-memory rejected packet before the store commits. +// TestIssue721RetryStoreFailureIsAtomic ensures signed packet and import marker +// updates never alias the prepared packet before the store commits. func TestIssue721RetryStoreFailureIsAtomic(t *testing.T) { store := &issue721FailSignedStore{MintingStore: newMintingStore(t)} h := newMintingTestHarness(t, store) @@ -1058,33 +1311,29 @@ func TestIssue721RetryStoreFailureIsAtomic(t *testing.T) { t, witnessScript, ) - h.chain.FailPublishDefinitively() + before, err := h.planter.PendingBatch() + require.NoError(t, err) + beforeBytes := serializePacket(t, before.GenesisPacket.Pkt) + store.fail = true var wg sync.WaitGroup respChan := make(chan *FinalizeBatchResp, 1) h.finalizeBatch(&wg, respChan, &tapgarden.FinalizeParams{ SignedPsbt: signed, }) - _, err = fn.RecvOrTimeout(h.wallet.ImportPubKeySignal, defaultTimeout) - require.NoError(t, err) - _, err = fn.RecvOrTimeout(h.chain.PublishAttempts, defaultTimeout) - require.NoError(t, err) h.assertFinalizeBatch( - &wg, respChan, "transaction rejected before broadcast state", + &wg, respChan, "injected signed PSBT store failure", ) - - before, err := h.planter.PendingBatch() - require.NoError(t, err) - beforeBytes := serializePacket(t, before.GenesisPacket.Pkt) - store.fail = true - retry := clonePacket(t, before.GenesisPacket.Pkt) - _, err = h.planter.FinalizeBatch(tapgarden.FinalizeParams{ - SignedPsbt: retry, - }) - require.ErrorContains(t, err, "injected signed PSBT store failure") after, err := h.planter.PendingBatch() require.NoError(t, err) require.Equal(t, beforeBytes, serializePacket(t, after.GenesisPacket.Pkt)) require.Equal(t, tapgarden.BatchStateCommitted, after.State()) + select { + case <-h.wallet.ImportPubKeySignal: + t.Fatal("wallet import attempted after signed packet store failure") + case <-h.chain.PublishAttempts: + t.Fatal("publication attempted after signed packet store failure") + default: + } store.fail = false _, err = h.planter.CancelBatch() @@ -1122,7 +1371,7 @@ func TestIssue721MixedInputLeaseLifecycle(t *testing.T) { }}, }) original := clonePacket(t, pkt) - h.wallet.OwnedInputs[local] = true + h.wallet.SetOwnedInput(local, true) funded := issue721Fund(t, h, pkt) leased, err := fn.RecvOrTimeout( @@ -1172,23 +1421,73 @@ func TestIssue721PeriodicLeaseRenewal(t *testing.T) { h.queueSeedlingsInBatch(false, issue721Seedling()) pkt, _, _ := issue721Anchor(t) op := pkt.UnsignedTx.TxIn[0].PreviousOutPoint - h.wallet.OwnedInputs[op] = true + h.wallet.SetOwnedInput(op, true) issue721Fund(t, h, pkt) initial, err := fn.RecvOrTimeout( h.wallet.LeaseInputSignal, defaultTimeout, ) require.NoError(t, err) require.Equal(t, op, *initial) + h.wallet.SetLeaseError(op, fmt.Errorf("temporary renewal degradation")) + require.Eventually(t, func() bool { + pending, err := h.planter.PendingBatch() + return err == nil && pending.CustomAnchorLeaseError != "" + }, defaultTimeout, 10*time.Millisecond) + + // Status is queryable after the one-shot event and clears after a later + // successful renewal, so late RPC clients don't depend on event replay. + h.wallet.SetLeaseError(op, nil) renewed, err := fn.RecvOrTimeout( h.wallet.LeaseInputSignal, defaultTimeout, ) require.NoError(t, err) require.Equal(t, op, *renewed) + require.Eventually(t, func() bool { + pending, err := h.planter.PendingBatch() + return err == nil && pending.CustomAnchorLeaseError == "" + }, defaultTimeout, 10*time.Millisecond) _, err = h.planter.CancelBatch() require.NoError(t, err) } +// TestIssue721LeaseRenewalDoesNotBlockShutdown verifies that a slow lease +// renewal cannot wedge the gardener during shutdown. The wallet mock's signal +// channel models an RPC whose result consumer is temporarily unavailable. +func TestIssue721LeaseRenewalDoesNotBlockShutdown(t *testing.T) { + store := newMintingStore(t) + h := newMintingTestHarness(t, store) + h.leaseRenewalInterval = time.Millisecond + h.refreshChainPlanter() + + h.queueSeedlingsInBatch(false, issue721Seedling()) + pkt, _, _ := issue721Anchor(t) + op := pkt.UnsignedTx.TxIn[0].PreviousOutPoint + h.wallet.SetOwnedInput(op, true) + issue721Fund(t, h, pkt) + + // Leave the lease notifications unread until the buffered mock RPC + // blocks. Stop closes the planter context, which must release the call + // and let the gardener exit promptly. + require.Eventually(t, func() bool { + return len(h.wallet.LeaseInputSignal) == + cap(h.wallet.LeaseInputSignal) + }, defaultTimeout, time.Millisecond) + + stopResult := make(chan error, 1) + go func() { + stopResult <- h.planter.Stop() + }() + + select { + case err := <-stopResult: + require.NoError(t, err) + + case <-time.After(defaultTimeout): + t.Fatal("planter shutdown blocked on custom anchor lease renewal") + } +} + func TestIssue721LeaseFailureAndCancelReleaseOrdering(t *testing.T) { t.Run("lease conflict rolls back funding", func(t *testing.T) { store := newMintingStore(t) @@ -1218,9 +1517,9 @@ func TestIssue721LeaseFailureAndCancelReleaseOrdering(t *testing.T) { }, WitnessScript: pkt.Inputs[0].WitnessScript, }) - h.wallet.OwnedInputs[first] = true - h.wallet.OwnedInputs[second] = true - h.wallet.LeaseErrors[second] = fmt.Errorf("foreign lease conflict") + h.wallet.SetOwnedInput(first, true) + h.wallet.SetOwnedInput(second, true) + h.wallet.SetLeaseError(second, fmt.Errorf("foreign lease conflict")) original := serializePacket(t, pkt) _, err := h.planter.FundBatch(tapgarden.FundParams{ AnchorPsbt: pkt, AssetAnchorOutIdx: 1, @@ -1256,12 +1555,12 @@ func TestIssue721LeaseFailureAndCancelReleaseOrdering(t *testing.T) { }, WitnessScript: pkt.Inputs[0].WitnessScript, }) - h.wallet.OwnedInputs[first] = true - h.wallet.OwnedInputs[second] = true + h.wallet.SetOwnedInput(first, true) + h.wallet.SetOwnedInput(second, true) issue721Fund(t, h, pkt) <-h.wallet.LeaseInputSignal <-h.wallet.LeaseInputSignal - h.wallet.ReleaseErrors[second] = fmt.Errorf("release failed") + h.wallet.SetReleaseError(second, fmt.Errorf("release failed")) batchKey, err := h.planter.CancelBatch() require.NoError(t, err) @@ -1305,7 +1604,6 @@ func TestIssue721ConfirmationRegistrationRetry(t *testing.T) { h.wallet.ImportPubKeySignal, defaultTimeout, ) require.NoError(t, err) - published := h.assertTxPublished() h.assertFinalizeBatch( &wg, respChan, "failed to register confirmation", ) @@ -1313,6 +1611,8 @@ func TestIssue721ConfirmationRegistrationRetry(t *testing.T) { pending, err := h.planter.PendingBatch() require.NoError(t, err) require.Equal(t, tapgarden.BatchStateBroadcast, pending.State()) + published, err := psbt.Extract(pending.GenesisPacket.Pkt) + require.NoError(t, err) changedRetry := clonePacket(t, pending.GenesisPacket.Pkt) changedRetry.Inputs[0].FinalScriptWitness = issue721FinalWitnessWithItem( @@ -1333,9 +1633,9 @@ func TestIssue721ConfirmationRegistrationRetry(t *testing.T) { h.finalizeBatch(&wg, respChan, &tapgarden.FinalizeParams{ SignedPsbt: retry, }) - h.assertTxPublished() _, err = fn.RecvOrTimeout(h.chain.ConfReqSignal, defaultTimeout) require.NoError(t, err) + h.assertTxPublished() minted := h.assertFinalizeBatch(&wg, respChan, "") require.Equal(t, tapgarden.BatchStateBroadcast, minted.State()) } @@ -1482,7 +1782,6 @@ func TestIssue721CustomRestartStates(t *testing.T) { h.wallet.ImportPubKeySignal, defaultTimeout, ) require.NoError(t, err) - h.assertTxPublished() _, err = fn.RecvOrTimeout(h.chain.ConfReqSignal, defaultTimeout) require.NoError(t, err) h.assertNumCaretakersActive(1) @@ -1507,6 +1806,188 @@ func TestIssue721CustomRestartStates(t *testing.T) { }) } +func TestIssue721LegacyRawOnlyPreflightIsNonMutating(t *testing.T) { + for _, state := range []tapgarden.BatchState{ + tapgarden.BatchStatePending, + tapgarden.BatchStateFrozen, + } { + state := state + t.Run(state.String(), func(t *testing.T) { + store := newMintingStore(t) + h := newMintingTestHarness(t, store) + h.refreshChainPlanter() + t.Cleanup(func() { + if h.planter != nil { + _ = h.planter.Stop() + } + }) + + h.queueSeedlingsInBatch(false, issue721Seedling()) + pkt, _, _ := issue721Anchor(t) + batch := issue721Fund(t, h, pkt) + legacy := batch.GenesisPacket.Copy() + legacy.Pkt.Outputs[1].Bip32Derivation = nil + legacy.Pkt.Outputs[1].TaprootBip32Derivation = nil + require.NoError(t, store.CommitBatchFunding( + t.Context(), batch.BatchKey.PubKey, nil, *legacy, + )) + if state == tapgarden.BatchStateFrozen { + require.NoError(t, store.UpdateBatchState( + t.Context(), batch.BatchKey.PubKey, state, + )) + } + + h.refreshChainPlanter() + before, err := h.planter.PendingBatch() + require.NoError(t, err) + beforeBytes := serializePacket(t, before.GenesisPacket.Pkt) + _, err = h.planter.PrepareBatch() + require.ErrorIs( + t, err, tapgarden.ErrLegacyCustomAnchorKeyLocator, + ) + + after, err := h.planter.PendingBatch() + require.NoError(t, err) + require.Equal(t, state, after.State()) + require.Equal(t, beforeBytes, + serializePacket(t, after.GenesisPacket.Pkt)) + persisted := h.fetchSingleBatch(batch.BatchKey.PubKey) + require.Equal(t, state, persisted.State()) + require.Equal(t, beforeBytes, + serializePacket(t, persisted.GenesisPacket.Pkt)) + }) + } +} + +func TestIssue721LegacyRawOnlyFinalizeRemainsCancellable(t *testing.T) { + store := newMintingStore(t) + h := newMintingTestHarness(t, store) + h.refreshChainPlanter() + t.Cleanup(func() { + if h.planter != nil { + _ = h.planter.Stop() + } + }) + + h.queueSeedlingsInBatch(false, issue721Seedling()) + pkt, _, witnessScript := issue721Anchor(t) + issue721Fund(t, h, pkt) + prepared, err := h.planter.PrepareBatch() + require.NoError(t, err) + + legacy := prepared.GenesisPacket.Copy() + legacy.Pkt.Outputs[1].Bip32Derivation = nil + legacy.Pkt.Outputs[1].TaprootInternalKey = + fn.CopySlice(pkt.Outputs[1].TaprootInternalKey) + require.NoError(t, store.StoreSignedGenesisPsbt( + t.Context(), prepared.BatchKey.PubKey, &legacy.FundedPsbt, + )) + h.refreshChainPlanter() + + pending, err := h.planter.PendingBatch() + require.NoError(t, err) + before := serializePacket(t, pending.GenesisPacket.Pkt) + signed := clonePacket(t, pending.GenesisPacket.Pkt) + signed.Inputs[0].FinalScriptWitness = issue721FinalWitness( + t, witnessScript, + ) + _, err = h.planter.FinalizeBatch(tapgarden.FinalizeParams{ + SignedPsbt: signed, + }) + require.ErrorIs(t, err, tapgarden.ErrLegacyCustomAnchorKeyLocator) + + after, err := h.planter.PendingBatch() + require.NoError(t, err) + require.Equal(t, tapgarden.BatchStateCommitted, after.State()) + require.Equal(t, before, serializePacket(t, after.GenesisPacket.Pkt)) + select { + case <-h.wallet.ImportPubKeySignal: + t.Fatal("legacy raw-only batch imported before key preflight") + case <-h.chain.PublishAttempts: + t.Fatal("legacy raw-only batch published before key preflight") + default: + } + _, err = h.planter.CancelBatch() + require.NoError(t, err) +} + +func TestIssue721LegacyRejectedMarkerIsPublicationPending(t *testing.T) { + store := newMintingStore(t) + h := newMintingTestHarness(t, store) + h.refreshChainPlanter() + t.Cleanup(func() { + if h.planter != nil { + _ = h.planter.Stop() + } + }) + + h.queueSeedlingsInBatch(false, issue721Seedling()) + pkt, _, witnessScript := issue721Anchor(t) + ownedInput := pkt.UnsignedTx.TxIn[0].PreviousOutPoint + h.wallet.SetOwnedInput(ownedInput, true) + issue721Fund(t, h, pkt) + _, err := fn.RecvOrTimeout(h.wallet.LeaseInputSignal, defaultTimeout) + require.NoError(t, err) + prepared, err := h.planter.PrepareBatch() + require.NoError(t, err) + + signed := clonePacket(t, prepared.GenesisPacket.Pkt) + signed.Inputs[0].FinalScriptWitness = issue721FinalWitness( + t, witnessScript, + ) + signed.Unknowns = append(signed.Unknowns, &psbt.Unknown{ + Key: []byte{0xfc, 0x04, 't', 'a', 'p', 'd', 0x02}, + Value: []byte{2}, // Historical customAnchorPublishRejected. + }) + funded := prepared.GenesisPacket.FundedPsbt + funded.Pkt = signed + require.NoError(t, store.StoreSignedGenesisPsbt( + t.Context(), prepared.BatchKey.PubKey, &funded, + )) + + // The historical build already attempted these fully signed bytes. On + // restart, treat them as ambiguous: retain the lease, deny cancellation, + // install a watcher and retry the identical transaction. + h.chain.FailPublishOnce() + h.refreshChainPlanter() + startupLease, err := fn.RecvOrTimeout( + h.wallet.LeaseInputSignal, defaultTimeout, + ) + require.NoError(t, err) + require.Equal(t, ownedInput, *startupLease) + _, err = h.planter.CancelBatch() + require.ErrorContains(t, err, "batch recovery is in progress") + _, err = fn.RecvOrTimeout(h.wallet.ImportPubKeySignal, defaultTimeout) + require.NoError(t, err) + preflightLease, err := fn.RecvOrTimeout( + h.wallet.LeaseInputSignal, defaultTimeout, + ) + require.NoError(t, err) + require.Equal(t, ownedInput, *preflightLease) + firstAttempt, err := fn.RecvOrTimeout( + h.chain.PublishAttempts, defaultTimeout, + ) + require.NoError(t, err) + _, err = fn.RecvOrTimeout(h.chain.ConfReqSignal, defaultTimeout) + require.NoError(t, err) + retryLease, err := fn.RecvOrTimeout( + h.wallet.LeaseInputSignal, defaultTimeout, + ) + require.NoError(t, err) + require.Equal(t, ownedInput, *retryLease) + published := h.assertTxPublished() + require.Equal(t, (*firstAttempt).WitnessHash(), published.WitnessHash()) + require.Eventually(t, func() bool { + return h.fetchSingleBatch(prepared.BatchKey.PubKey).State() == + tapgarden.BatchStateBroadcast + }, defaultTimeout, 10*time.Millisecond) + select { + case released := <-h.wallet.ReleaseInputSignal: + t.Fatalf("legacy rejected-marker lease released: %v", released) + default: + } +} + func clonePacket(t *testing.T, pkt *psbt.Packet) *psbt.Packet { t.Helper() diff --git a/tapgarden/planter.go b/tapgarden/planter.go index a68a11f20f..4c0610f2c7 100644 --- a/tapgarden/planter.go +++ b/tapgarden/planter.go @@ -364,6 +364,8 @@ var customAnchorPublishMarker = []byte{ type customAnchorPublishState byte +type customAnchorLeaseMarkerState byte + const ( customAnchorPublishNone customAnchorPublishState = iota customAnchorPublishPending @@ -376,6 +378,12 @@ const ( customAnchorLeaseRenewalInterval = 5 * time.Minute ) +const ( + customAnchorLeaseMarkerMissing customAnchorLeaseMarkerState = iota + customAnchorLeaseMarkerLegacy + customAnchorLeaseMarkerCurrent +) + func markCustomAnchorPsbt(packet *psbt.Packet) { if isCustomAnchorPsbt(packet) { return @@ -405,53 +413,109 @@ func SetCustomAnchorLockedUTXOs(packet *psbt.Packet, ops []wire.OutPoint) { offset += 36 } + unknowns := packet.Unknowns[:0] for _, unknown := range packet.Unknowns { - if bytes.Equal(unknown.Key, customAnchorPsbtMarker) { - unknown.Value = value - return + if !bytes.Equal(unknown.Key, customAnchorPsbtMarker) { + unknowns = append(unknowns, unknown) } } - - packet.Unknowns = append(packet.Unknowns, &psbt.Unknown{ + packet.Unknowns = append(unknowns, &psbt.Unknown{ Key: fn.CopySlice(customAnchorPsbtMarker), Value: value, }) } -// CustomAnchorLockedUTXOs returns the wallet-owned custom-anchor inputs stored -// in tapd's PSBT marker. Old marker versions simply return no leases. -func CustomAnchorLockedUTXOs(packet *psbt.Packet) []wire.OutPoint { +// parseCustomAnchorLockedUTXOs validates and returns the wallet-owned custom +// anchor inputs stored in tapd's PSBT marker. The original one-byte marker is +// reported as legacy so callers can rediscover and lease wallet-owned inputs +// before publication. Malformed current markers must never be interpreted as +// an external-only transaction. +func parseCustomAnchorLockedUTXOs(packet *psbt.Packet) ([]wire.OutPoint, + customAnchorLeaseMarkerState, error) { + if packet == nil { - return nil + return nil, customAnchorLeaseMarkerMissing, nil } + var marker *psbt.Unknown for _, unknown := range packet.Unknowns { - if !bytes.Equal(unknown.Key, customAnchorPsbtMarker) || - len(unknown.Value) < 5 || unknown.Value[0] != 1 { - + if !bytes.Equal(unknown.Key, customAnchorPsbtMarker) { continue } + if marker != nil { + return nil, customAnchorLeaseMarkerCurrent, + fmt.Errorf("duplicate custom anchor lease marker") + } + marker = unknown + } - count := binary.LittleEndian.Uint32(unknown.Value[1:5]) - if uint64(count) > uint64((len(unknown.Value)-5)/36) || - len(unknown.Value) != 5+int(count)*36 { + if marker == nil { + return nil, customAnchorLeaseMarkerMissing, nil + } + if len(marker.Value) == 1 && marker.Value[0] == 1 { + return nil, customAnchorLeaseMarkerLegacy, nil + } + if len(marker.Value) < 5 || marker.Value[0] != 1 { + return nil, customAnchorLeaseMarkerCurrent, + fmt.Errorf("malformed custom anchor lease marker") + } - return nil + count := binary.LittleEndian.Uint32(marker.Value[1:5]) + if uint64(count) > uint64((len(marker.Value)-5)/36) || + len(marker.Value) != 5+int(count)*36 { + + return nil, customAnchorLeaseMarkerCurrent, + fmt.Errorf("malformed custom anchor lease marker length") + } + + ops := make([]wire.OutPoint, count) + seen := make(map[wire.OutPoint]struct{}, count) + offset := 5 + for idx := range ops { + copy(ops[idx].Hash[:], marker.Value[offset:offset+32]) + ops[idx].Index = binary.LittleEndian.Uint32( + marker.Value[offset+32 : offset+36], + ) + offset += 36 + if _, ok := seen[ops[idx]]; ok { + return nil, customAnchorLeaseMarkerCurrent, + fmt.Errorf("duplicate custom anchor leased input %v", ops[idx]) } + seen[ops[idx]] = struct{}{} + } - ops := make([]wire.OutPoint, count) - offset := 5 - for idx := range ops { - copy(ops[idx].Hash[:], unknown.Value[offset:offset+32]) - ops[idx].Index = binary.LittleEndian.Uint32( - unknown.Value[offset+32 : offset+36], - ) - offset += 36 + if len(ops) > 0 && packet.UnsignedTx == nil { + return nil, customAnchorLeaseMarkerCurrent, + fmt.Errorf("custom anchor lease marker has no transaction") + } + for _, op := range ops { + matched := false + for _, txIn := range packet.UnsignedTx.TxIn { + if txIn.PreviousOutPoint == op { + matched = true + break + } + } + if !matched { + return nil, customAnchorLeaseMarkerCurrent, + fmt.Errorf("custom anchor leased input %v is not in the "+ + "transaction", op) } + } + + return ops, customAnchorLeaseMarkerCurrent, nil +} - return ops +// CustomAnchorLockedUTXOs returns the wallet-owned custom-anchor inputs stored +// in tapd's PSBT marker. Strict publication paths use the parser above and +// fail closed on malformed data; this compatibility accessor returns no leases +// for legacy or malformed markers. +func CustomAnchorLockedUTXOs(packet *psbt.Packet) []wire.OutPoint { + ops, _, err := parseCustomAnchorLockedUTXOs(packet) + if err != nil { + return nil } - return nil + return ops } // acquireCustomAnchorLeases leases all inputs controlled by the backing @@ -569,6 +633,27 @@ func renewCustomAnchorLeases(ctx context.Context, wallet tapnode.WalletAnchor, if funded == nil { return nil } + + markerOps, markerState, err := parseCustomAnchorLockedUTXOs(funded.Pkt) + if err != nil { + return fmt.Errorf("invalid custom anchor lease marker: %w", err) + } + if markerState == customAnchorLeaseMarkerLegacy { + locked, err := acquireCustomAnchorLeases( + ctx, wallet, funded.Pkt, nil, + ) + if err != nil { + return fmt.Errorf("unable to upgrade legacy custom anchor "+ + "leases: %w", err) + } + + funded.LockedUTXOs = locked + SetCustomAnchorLockedUTXOs(funded.Pkt, locked) + return nil + } else if markerState == customAnchorLeaseMarkerCurrent { + funded.LockedUTXOs = markerOps + } + for _, op := range funded.LockedUTXOs { owned, err := wallet.LeaseInput(ctx, op) if err != nil { @@ -632,8 +717,13 @@ func getCustomAnchorPublishState( if len(unknown.Value) == 1 { state := customAnchorPublishState(unknown.Value[0]) switch state { + case customAnchorPublishRejected: + // Older builds wrote this marker only after submitting the + // fully signed bytes. Treat it as publication-ambiguous so an + // upgrade cannot expose cancellation or release its leases. + return customAnchorPublishPending + case customAnchorPublishPending, - customAnchorPublishRejected, customAnchorImportPending: return state @@ -771,6 +861,11 @@ type ChainPlanter struct { // progress the batch through the final phases. caretakers map[BatchKey]*BatchCaretaker + // customAnchorKeyErrors maps historical custom-anchor batches to the + // actionable health result produced by the startup key audit. The map is + // owned by the gardener after startup and attached to ListBatches results. + customAnchorKeyErrors map[BatchKey]string + // completionSignals is a channel used to allow the caretakers to // signal that the batch is fully final, allowing garbage collection of // any relevant resources. @@ -805,11 +900,54 @@ type startupCaretakerResult struct { err error } +// attachCustomAnchorKeyErrors adds startup audit health to freshly queried +// batches. The health is intentionally in-memory: it is deterministically +// rebuilt from retained state before the planter serves ListBatches. +func (c *ChainPlanter) attachCustomAnchorKeyErrors(batches []*VerboseBatch) { + for _, batch := range batches { + key := asset.ToSerialized(batch.BatchKey.PubKey) + batch.CustomAnchorKeyError = c.customAnchorKeyErrors[key] + } +} + +// attachCustomAnchorRuntimeStatus overlays transient custom anchor health from +// the gardener-owned pending batch or an active caretaker onto fresh store +// results. This makes degradation queryable by clients that subscribe after +// the corresponding event was emitted. +func (c *ChainPlanter) attachCustomAnchorRuntimeStatus( + batches []*VerboseBatch) { + + var pendingKey BatchKey + if c.pendingBatch != nil { + pendingKey = asset.ToSerialized(c.pendingBatch.BatchKey.PubKey) + } + + for _, batch := range batches { + key := asset.ToSerialized(batch.BatchKey.PubKey) + if c.pendingBatch != nil && key == pendingKey { + batch.CustomAnchorLeaseError = + c.pendingBatch.CustomAnchorLeaseError + batch.CustomAnchorPublishError = + c.pendingBatch.CustomAnchorPublishError + } + + caretaker, ok := c.caretakers[key] + if !ok { + continue + } + + leaseErr, publishErr := caretaker.customAnchorStatus() + batch.CustomAnchorLeaseError = leaseErr + batch.CustomAnchorPublishError = publishErr + } +} + // NewChainPlanter creates a new ChainPlanter instance given the passed config. func NewChainPlanter(cfg PlanterConfig) *ChainPlanter { return &ChainPlanter{ cfg: cfg, caretakers: make(map[BatchKey]*BatchCaretaker), + customAnchorKeyErrors: make(map[BatchKey]string), completionSignals: make(chan BatchKey), startupCaretakerResults: make(chan startupCaretakerResult, 1), seedlingReqs: make(chan *Seedling), @@ -843,6 +981,8 @@ func (c *ChainPlanter) newCaretakerForBatch(batch *MintingBatch, UpdateMintingProofs: c.updateMintingProofs, PublishMintEvent: c.publishSubscriberEvent, ErrChan: c.cfg.ErrChan, + CustomAnchorLeaseRenewalInterval: c.cfg. + CustomAnchorLeaseRenewalInterval, } if feeRate != nil { batchConfig.BatchFeeRate = feeRate @@ -872,6 +1012,62 @@ func (c *ChainPlanter) Start() error { // pending batch at a time? but would end up changing assetIDs. ctx, cancel := c.WithCtxQuit() defer cancel() + + // Historical custom-anchor builds could persist the batch key as + // the managed UTXO internal key. Audit those retained rows only + // after the wallet key ring is available, and automatically repair + // only descriptors proven to be local. Unverifiable historical + // rows require wallet-validated operator recovery and are surfaced + // loudly instead of being guessed or silently ignored. + if repairStore, ok := c.cfg.Log.(CustomAnchorKeyRepairStore); ok { + repairHealth, err := AuditAndRepairCustomAnchorKeys( + ctx, repairStore, c.cfg.KeyRing, c.cfg.ChainParams, + ) + if err != nil { + startErr = err + return + } + + for _, health := range repairHealth { + if health.RequiresIntervention() { + batchKey, err := btcec.ParsePubKey(health.BatchKey) + if err != nil { + log.Errorf("Unable to index historical custom "+ + "anchor key health for batch_key=%x: %v", + health.BatchKey, err) + } else { + key := asset.ToSerialized(batchKey) + c.customAnchorKeyErrors[key] = fmt.Sprintf( + "status=%s, outpoint=%v: %s", + health.Status, health.Outpoint, + health.Detail, + ) + } + + log.Errorf("Historical custom anchor key requires "+ + "operator action: status=%s, batch_key=%x, "+ + "outpoint=%v, detail=%s", health.Status, + health.BatchKey, health.Outpoint, + health.Detail) + continue + } + + batchKey, err := btcec.ParsePubKey(health.BatchKey) + if err == nil { + delete( + c.customAnchorKeyErrors, + asset.ToSerialized(batchKey), + ) + } + + if health.Status == CustomAnchorKeyRepaired { + log.Infof("Repaired historical custom anchor key: "+ + "batch_key=%x, outpoint=%v", health.BatchKey, + health.Outpoint) + } + } + } + nonFinalBatches, err := c.cfg.Log.FetchNonFinalBatches(ctx) if err != nil { startErr = err @@ -900,9 +1096,9 @@ func (c *ChainPlanter) Start() error { batch.AssetMetas = make(AssetMetas) } - // A custom batch is paused while awaiting an external signer or - // after a conclusive publication rejection. A finalized - // Committed packet with a pending publication marker instead + // A custom batch is paused only while awaiting an external signer. + // A finalized Committed packet with any historical publication + // marker instead // resumes automatically: it may already have reached the backend // before a crash and therefore must not become cancellable. customBatch := batch.GenesisPacket != nil && @@ -927,9 +1123,16 @@ func (c *ChainPlanter) Start() error { if err := renewCustomAnchorLeases( ctx, c.cfg.Wallet, batch.GenesisPacket, ); err != nil { + batch.CustomAnchorLeaseError = fmt.Sprintf( + "custom anchor input lease renewal degraded during "+ + "startup; Finalize will retry and fail closed: %v", + err, + ) log.Warnf("Unable to renew custom anchor input "+ "leases during startup: %v", err) leaseRenewalFailed = true + } else { + batch.CustomAnchorLeaseError = "" } } @@ -2762,8 +2965,29 @@ func (c *ChainPlanter) gardener() { ) cancel() if err != nil { + statusErr := fmt.Errorf("custom anchor input lease "+ + "renewal degraded; Finalize will retry and "+ + "fail closed: %w", err) + if batch.CustomAnchorLeaseError != + statusErr.Error() { + + batch.CustomAnchorLeaseError = statusErr.Error() + c.publishSubscriberEvent( + newAssetMintErrorEvent( + statusErr, batch.State(), batch, + ), + ) + } log.Warnf("Unable to renew custom anchor input "+ "leases: %v", err) + continue + } + + if batch.CustomAnchorLeaseError != "" { + batch.CustomAnchorLeaseError = "" + c.publishSubscriberEvent(newAssetMintEvent( + batch.State(), batch, + )) } } @@ -2879,6 +3103,9 @@ func (c *ChainPlanter) gardener() { break } + c.attachCustomAnchorKeyErrors(batches) + c.attachCustomAnchorRuntimeStatus(batches) + req.Resolve(batches) case reqTypeFundBatch: @@ -3006,6 +3233,13 @@ func (c *ChainPlanter) gardener() { batchKey := c.pendingBatch.BatchKey.PubKey batchKeySerial := asset.ToSerialized(batchKey) + // Determine the batch kind before starting the caretaker. The + // caretaker owns and mutates the batch once Start returns, so the + // gardener must not inspect that shared packet afterwards. + customBatch := c.pendingBatch.GenesisPacket != nil && + isCustomAnchorPsbt( + c.pendingBatch.GenesisPacket.Pkt, + ) if _, ok := c.caretakers[batchKeySerial]; ok { req.Error(fmt.Errorf("batch recovery is in progress")) break @@ -3032,10 +3266,6 @@ func (c *ChainPlanter) gardener() { break } - customBatch := c.pendingBatch.GenesisPacket != nil && - isCustomAnchorPsbt( - c.pendingBatch.GenesisPacket.Pkt, - ) broadcastSucceeded := false // We now wait for the caretaker to either @@ -3665,6 +3895,18 @@ func (c *ChainPlanter) prepareBatch(ctx context.Context, batch.State()) } + // Legacy raw-only custom packets must fail before sealing, freezing or + // clearing BIP-371 fields. Without an exact wallet-owned locator we cannot + // safely persist a spendable managed output descriptor. + _, err := customAnchorMintingKeyDescriptor( + ctx, c.cfg.KeyRing, batch.GenesisPacket.Pkt, + batch.GenesisPacket.AssetAnchorOutIdx, + ) + if err != nil { + return nil, fmt.Errorf("custom anchor wallet key preflight failed: %w", + err) + } + sealedBatch, err := c.sealBatch(ctx, SealParams{}, batch) if err != nil && !errors.Is(err, ErrBatchAlreadySealed) { return nil, err @@ -4066,6 +4308,16 @@ func (c *ChainPlanter) finalizeBatch(params FinalizeParams) (*BatchCaretaker, "fee rate: %w", err) } + _, err = customAnchorMintingKeyDescriptor( + ctx, c.cfg.KeyRing, merged, + c.pendingBatch.GenesisPacket.AssetAnchorOutIdx, + ) + if err != nil { + cancel() + return nil, fmt.Errorf("custom anchor wallet key "+ + "preflight failed: %w", err) + } + previousLocks := fn.CopySlice( c.pendingBatch.GenesisPacket.LockedUTXOs, ) @@ -4077,6 +4329,7 @@ func (c *ChainPlanter) finalizeBatch(params FinalizeParams) (*BatchCaretaker, return nil, fmt.Errorf("unable to lease custom anchor "+ "inputs before finalization: %w", err) } + c.pendingBatch.CustomAnchorLeaseError = "" newLocks := newlyAcquiredLeases(locked, previousLocks) SetCustomAnchorLockedUTXOs(merged, locked) setCustomAnchorPublishState( @@ -4084,9 +4337,9 @@ func (c *ChainPlanter) finalizeBatch(params FinalizeParams) (*BatchCaretaker, ) // Store the signed packet before starting the caretaker. If - // publication is definitively rejected, the batch remains in - // Committed and the exact signed transaction survives restart - // for inspection, cancellation, or idempotent resubmission. + // any WalletKit publication attempt is made, the exact signed + // transaction survives restart and must progress into watched, + // immutable Broadcast handling regardless of the returned error. signedFundedPsbt := c.pendingBatch.GenesisPacket.FundedPsbt signedFundedPsbt.Pkt = merged signedFundedPsbt.LockedUTXOs = locked diff --git a/tapgarden/planter_test.go b/tapgarden/planter_test.go index f6cb8aa063..6cc0581a8b 100644 --- a/tapgarden/planter_test.go +++ b/tapgarden/planter_test.go @@ -114,6 +114,12 @@ type mintingTestHarness struct { errChan chan error leaseRenewalInterval time.Duration + + // pendingConfReq records the watcher that is installed before the + // transaction is published. The production caretaker deliberately uses + // watcher-first ordering so an ambiguous earlier publication cannot race + // confirmation tracking. + pendingConfReq *int } // newMintingTestHarness creates a new test harness from an active minting @@ -1133,6 +1139,21 @@ func (t *mintingTestHarness) assertGenesisPsbtFinalized( func (t *mintingTestHarness) assertTxPublished() *wire.MsgTx { t.Helper() + // Most callers haven't consumed the watcher request yet, so accepting it + // here unblocks the production watcher-first ordering. A few security tests + // intentionally consume the watcher themselves before allowing a later + // retry; in that case the publication can arrive directly. + select { + case tx := <-t.chain.PublishReq: + return tx + + case reqNo := <-t.chain.ConfReqSignal: + t.pendingConfReq = &reqNo + + case <-time.After(defaultTimeout): + t.Fatal("transaction publication request not sent") + } + tx, err := fn.RecvOrTimeout(t.chain.PublishReq, defaultTimeout) require.NoError(t, err) @@ -1145,10 +1166,15 @@ func (t *mintingTestHarness) assertTxPublished() *wire.MsgTx { func (t *mintingTestHarness) assertConfReqSent(tx *wire.MsgTx, block *wire.MsgBlock) func() { - reqNo, err := fn.RecvOrTimeout( - t.chain.ConfReqSignal, defaultTimeout, - ) - require.NoError(t, err) + reqNo := t.pendingConfReq + if reqNo == nil { + var err error + reqNo, err = fn.RecvOrTimeout( + t.chain.ConfReqSignal, defaultTimeout, + ) + require.NoError(t, err) + } + t.pendingConfReq = nil return func() { t.chain.SendConfNtfn(*reqNo, &chainhash.Hash{}, 1, 0, block, tx) diff --git a/tapnode/tapnodemock/wallet_anchor.go b/tapnode/tapnodemock/wallet_anchor.go index cd2e770b4f..5c3591a32f 100644 --- a/tapnode/tapnodemock/wallet_anchor.go +++ b/tapnode/tapnodemock/wallet_anchor.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "math/rand" + "sync" "testing" btcaddr "github.com/btcsuite/btcd/address/v2" @@ -27,6 +28,8 @@ import ( // WalletAnchor is an in-memory mock implementation of tapnode.WalletAnchor. type WalletAnchor struct { + mu sync.RWMutex + FundPsbtSignal chan *tapsend.FundedPsbt SignPsbtSignal chan struct{} ImportPubKeySignal chan *btcec.PublicKey @@ -45,6 +48,48 @@ type WalletAnchor struct { ImportErr error } +// SetOwnedInput updates whether an input is controlled by the mock wallet. +func (m *WalletAnchor) SetOwnedInput(op wire.OutPoint, owned bool) { + m.mu.Lock() + defer m.mu.Unlock() + + m.OwnedInputs[op] = owned +} + +// SetLeaseError updates the error returned when an input is leased. +func (m *WalletAnchor) SetLeaseError(op wire.OutPoint, err error) { + m.mu.Lock() + defer m.mu.Unlock() + + if err == nil { + delete(m.LeaseErrors, op) + return + } + + m.LeaseErrors[op] = err +} + +// SetReleaseError updates the error returned when an input is released. +func (m *WalletAnchor) SetReleaseError(op wire.OutPoint, err error) { + m.mu.Lock() + defer m.mu.Unlock() + + if err == nil { + delete(m.ReleaseErrors, op) + return + } + + m.ReleaseErrors[op] = err +} + +// SetImportError updates the error returned when a Taproot output is imported. +func (m *WalletAnchor) SetImportError(err error) { + m.mu.Lock() + defer m.mu.Unlock() + + m.ImportErr = err +} + // NewWalletAnchor returns a freshly-initialised mock WalletAnchor. func NewWalletAnchor() *WalletAnchor { return &WalletAnchor{ @@ -186,8 +231,11 @@ func (m *WalletAnchor) ImportTaprootOutput(ctx context.Context, case <-ctx.Done(): return nil, fmt.Errorf("shutting down") } - if m.ImportErr != nil { - return nil, m.ImportErr + m.mu.RLock() + importErr := m.ImportErr + m.mu.RUnlock() + if importErr != nil { + return nil, importErr } return btcaddr.NewAddressTaproot( @@ -196,23 +244,35 @@ func (m *WalletAnchor) ImportTaprootOutput(ctx context.Context, } // LeaseInput leases the input if the mock marks it as wallet-owned. -func (m *WalletAnchor) LeaseInput(_ context.Context, +func (m *WalletAnchor) LeaseInput(ctx context.Context, op wire.OutPoint) (bool, error) { - if err := m.LeaseErrors[op]; err != nil { - return false, err + m.mu.RLock() + leaseErr := m.LeaseErrors[op] + owned := m.OwnedInputs[op] + m.mu.RUnlock() + if leaseErr != nil { + return false, leaseErr } - if !m.OwnedInputs[op] { + if !owned { return false, nil } - m.LeaseInputSignal <- op - return true, nil + select { + case m.LeaseInputSignal <- op: + return true, nil + + case <-ctx.Done(): + return false, ctx.Err() + } } // ReleaseInput releases a custom-anchor lease held by the mock wallet. func (m *WalletAnchor) ReleaseInput(_ context.Context, op wire.OutPoint) error { m.ReleaseInputSignal <- op - return m.ReleaseErrors[op] + m.mu.RLock() + releaseErr := m.ReleaseErrors[op] + m.mu.RUnlock() + return releaseErr } // UnlockInput unlocks the set of target inputs after a batch or send diff --git a/taprpc/mintrpc/mint.pb.go b/taprpc/mintrpc/mint.pb.go index be23cc0f28..6cffa9a39f 100644 --- a/taprpc/mintrpc/mint.pb.go +++ b/taprpc/mintrpc/mint.pb.go @@ -634,9 +634,18 @@ type MintingBatch struct { HeightHint uint32 `protobuf:"varint,6,opt,name=height_hint,json=heightHint,proto3" json:"height_hint,omitempty"` // The genesis transaction as a PSBT packet. Only populated if the batch has // been committed. - BatchPsbt []byte `protobuf:"bytes,7,opt,name=batch_psbt,json=batchPsbt,proto3" json:"batch_psbt,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + BatchPsbt []byte `protobuf:"bytes,7,opt,name=batch_psbt,json=batchPsbt,proto3" json:"batch_psbt,omitempty"` + // The latest prepared or broadcast custom-anchor wallet lease renewal + // degradation. Empty after a successful renewal or wallet acceptance. + CustomAnchorLeaseError string `protobuf:"bytes,8,opt,name=custom_anchor_lease_error,json=customAnchorLeaseError,proto3" json:"custom_anchor_lease_error,omitempty"` + // The latest ambiguous publication error for a custom-anchor transaction + // that remains watched and retried byte-identically. + CustomAnchorPublishError string `protobuf:"bytes,9,opt,name=custom_anchor_publish_error,json=customAnchorPublishError,proto3" json:"custom_anchor_publish_error,omitempty"` + // Historical custom-anchor key health requiring operator intervention. + // Empty when the managed anchor key is healthy or was repaired. + CustomAnchorKeyError string `protobuf:"bytes,10,opt,name=custom_anchor_key_error,json=customAnchorKeyError,proto3" json:"custom_anchor_key_error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *MintingBatch) Reset() { @@ -718,6 +727,27 @@ func (x *MintingBatch) GetBatchPsbt() []byte { return nil } +func (x *MintingBatch) GetCustomAnchorLeaseError() string { + if x != nil { + return x.CustomAnchorLeaseError + } + return "" +} + +func (x *MintingBatch) GetCustomAnchorPublishError() string { + if x != nil { + return x.CustomAnchorPublishError + } + return "" +} + +func (x *MintingBatch) GetCustomAnchorKeyError() string { + if x != nil { + return x.CustomAnchorKeyError + } + return "" +} + type VerboseBatch struct { state protoimpl.MessageState `protogen:"open.v1"` // The minting batch, without any assets. @@ -1738,7 +1768,7 @@ const file_mintrpc_mint_proto_rawDesc = "" + "\x05asset\x18\x01 \x01(\v2\x12.mintrpc.MintAssetR\x05asset\x12%\n" + "\x0eshort_response\x18\x02 \x01(\bR\rshortResponse\"O\n" + "\x11MintAssetResponse\x12:\n" + - "\rpending_batch\x18\x01 \x01(\v2\x15.mintrpc.MintingBatchR\fpendingBatch\"\x83\x02\n" + + "\rpending_batch\x18\x01 \x01(\v2\x15.mintrpc.MintingBatchR\fpendingBatch\"\xb4\x03\n" + "\fMintingBatch\x12\x1b\n" + "\tbatch_key\x18\x01 \x01(\fR\bbatchKey\x12\x1d\n" + "\n" + @@ -1750,7 +1780,11 @@ const file_mintrpc_mint_proto_rawDesc = "" + "\vheight_hint\x18\x06 \x01(\rR\n" + "heightHint\x12\x1d\n" + "\n" + - "batch_psbt\x18\a \x01(\fR\tbatchPsbt\"|\n" + + "batch_psbt\x18\a \x01(\fR\tbatchPsbt\x129\n" + + "\x19custom_anchor_lease_error\x18\b \x01(\tR\x16customAnchorLeaseError\x12=\n" + + "\x1bcustom_anchor_publish_error\x18\t \x01(\tR\x18customAnchorPublishError\x125\n" + + "\x17custom_anchor_key_error\x18\n" + + " \x01(\tR\x14customAnchorKeyError\"|\n" + "\fVerboseBatch\x12+\n" + "\x05batch\x18\x01 \x01(\v2\x15.mintrpc.MintingBatchR\x05batch\x12?\n" + "\x0funsealed_assets\x18\x02 \x03(\v2\x16.mintrpc.UnsealedAssetR\x0eunsealedAssets\"\xda\x03\n" + diff --git a/taprpc/mintrpc/mint.proto b/taprpc/mintrpc/mint.proto index 4e97a538cf..b1634178f0 100644 --- a/taprpc/mintrpc/mint.proto +++ b/taprpc/mintrpc/mint.proto @@ -292,6 +292,18 @@ message MintingBatch { // The genesis transaction as a PSBT packet. Only populated if the batch has // been committed. bytes batch_psbt = 7; + + // The latest prepared or broadcast custom-anchor wallet lease renewal + // degradation. Empty after a successful renewal or wallet acceptance. + string custom_anchor_lease_error = 8; + + // The latest ambiguous publication error for a custom-anchor transaction + // that remains watched and retried byte-identically. + string custom_anchor_publish_error = 9; + + // Historical custom-anchor key health requiring operator intervention. + // Empty when the managed anchor key is healthy or was repaired. + string custom_anchor_key_error = 10; } message VerboseBatch { diff --git a/taprpc/mintrpc/mint.swagger.json b/taprpc/mintrpc/mint.swagger.json index f86353db1b..2d80ee0053 100644 --- a/taprpc/mintrpc/mint.swagger.json +++ b/taprpc/mintrpc/mint.swagger.json @@ -392,7 +392,7 @@ "anchor_psbt": { "type": "string", "format": "byte", - "description": "An optional caller-funded anchor PSBT. Input zero remains the asset\ngenesis point and the packet must not contain signatures yet. The selected\nasset anchor output must not contain PSBT_OUT_TAP_TREE because PrepareBatch\nreplaces its script tree with the asset commitment and the optional\nbatch_sibling." + "description": "An optional caller-funded anchor PSBT. Input zero remains the asset\ngenesis point and the packet must not contain signatures yet. The\nselected asset anchor output must not contain PSBT_OUT_TAP_TREE because\nPrepareBatch replaces its script tree with the asset commitment and the\noptional batch_sibling." }, "asset_anchor_output_index": { "type": "integer", @@ -587,6 +587,18 @@ "type": "string", "format": "byte", "description": "The genesis transaction as a PSBT packet. Only populated if the batch has\nbeen committed." + }, + "custom_anchor_lease_error": { + "type": "string", + "description": "The latest prepared or broadcast custom-anchor wallet lease renewal\ndegradation. Empty after a successful renewal or wallet acceptance." + }, + "custom_anchor_publish_error": { + "type": "string", + "description": "The latest ambiguous publication error for a custom-anchor transaction\nthat remains watched and retried byte-identically." + }, + "custom_anchor_key_error": { + "type": "string", + "description": "Historical custom-anchor key health requiring operator intervention.\nEmpty when the managed anchor key is healthy or was repaired." } } }, From f238d7bf9974f8d7ccd9a19a8aff90010e90617c Mon Sep 17 00:00:00 2001 From: Sadiq Jaffer Date: Sat, 15 Aug 2026 12:30:36 +0100 Subject: [PATCH 10/24] itest: cover foreign MuSig2 custom mint anchors --- itest/assertions.go | 61 +- itest/mint_custom_anchor_test.go | 2079 ++++++++++++++++++++++++++++++ itest/test_list_on_test.go | 8 + 3 files changed, 2140 insertions(+), 8 deletions(-) create mode 100644 itest/mint_custom_anchor_test.go diff --git a/itest/assertions.go b/itest/assertions.go index e21165c811..a4a8f4b4d8 100644 --- a/itest/assertions.go +++ b/itest/assertions.go @@ -135,6 +135,37 @@ func AssetAnchorCheck(txid, blockHash chainhash.Hash) AssetCheck { } } +// AssetAnchorOutpointCheck returns a check function that tests an asset's full +// anchor outpoint and block hash. +func AssetAnchorOutpointCheck(outpoint wire.OutPoint, + blockHash chainhash.Hash) AssetCheck { + + return func(a *taprpc.Asset) error { + if a.ChainAnchor == nil { + return fmt.Errorf("asset is missing chain anchor field") + } + + anchorOutpoint, err := wire.NewOutPointFromString( + a.ChainAnchor.AnchorOutpoint, + ) + if err != nil { + return fmt.Errorf("unable to parse outpoint: %w", err) + } + if *anchorOutpoint != outpoint { + return fmt.Errorf("unexpected asset anchor outpoint, got %v "+ + "wanted %v", anchorOutpoint, outpoint) + } + + if a.ChainAnchor.AnchorBlockHash != blockHash.String() { + return fmt.Errorf("unexpected asset anchor block hash, got "+ + "%v wanted %x", a.ChainAnchor.AnchorBlockHash, + blockHash[:]) + } + + return nil + } +} + // AssetScriptKeyIsLocalCheck returns a check function that tests an asset's // script key for being a local key. func AssetScriptKeyIsLocalCheck(isLocal bool) AssetCheck { @@ -2115,6 +2146,20 @@ func AssertAssetsMinted(t *testing.T, tapClient commands.RpcClientsBundle, assetRequests []*mintrpc.MintAssetRequest, mintTXID, blockHash chainhash.Hash) []*taprpc.Asset { + return AssertAssetsMintedAtOutpoint( + t, tapClient, assetRequests, wire.OutPoint{Hash: mintTXID}, + blockHash, + ) +} + +// AssertAssetsMintedAtOutpoint makes sure all assets in the minting request +// were minted at the exact anchor outpoint and block. The function returns the +// list of minted assets. +func AssertAssetsMintedAtOutpoint(t *testing.T, + tapClient commands.RpcClientsBundle, + assetRequests []*mintrpc.MintAssetRequest, mintOutpoint wire.OutPoint, + blockHash chainhash.Hash) []*taprpc.Asset { + ctx := context.Background() // The rest of the anchor information should now be populated as well. @@ -2130,7 +2175,8 @@ func AssertAssetsMinted(t *testing.T, tapClient commands.RpcClientsBundle, ctx, &taprpc.ListAssetRequest{ ScriptKeyType: allScriptKeysQuery, AnchorOutpoint: &taprpc.OutPoint{ - Txid: mintTXID[:], + Txid: mintOutpoint.Hash[:], + OutputIndex: mintOutpoint.Index, }, }, ) @@ -2157,7 +2203,7 @@ func AssertAssetsMinted(t *testing.T, tapClient commands.RpcClientsBundle, mintedAsset := AssertAssetState( t, confirmedAssets, assetRequest.Asset.Name, metaHash[:], - AssetAnchorCheck(mintTXID, blockHash), + AssetAnchorOutpointCheck(mintOutpoint, blockHash), AssetScriptKeyIsLocalCheck(true), AssetVersionCheck(assetRequest.Asset.AssetVersion), func(a *taprpc.Asset) error { @@ -2168,13 +2214,9 @@ func AssertAssetsMinted(t *testing.T, tapClient commands.RpcClientsBundle, "outpoint") } - if firstOutpoint == "" { - firstOutpoint = anchor.AnchorOutpoint - - return nil - } + if firstOutpoint != "" && + anchor.AnchorOutpoint != firstOutpoint { - if anchor.AnchorOutpoint != firstOutpoint { return fmt.Errorf("unexpected anchor "+ "outpoint, got %v wanted %v", anchor.AnchorOutpoint, @@ -2185,6 +2227,9 @@ func AssertAssetsMinted(t *testing.T, tapClient commands.RpcClientsBundle, return fmt.Errorf("missing block " + "height") } + if firstOutpoint == "" { + firstOutpoint = anchor.AnchorOutpoint + } return nil }, diff --git a/itest/mint_custom_anchor_test.go b/itest/mint_custom_anchor_test.go new file mode 100644 index 0000000000..2c1996cd55 --- /dev/null +++ b/itest/mint_custom_anchor_test.go @@ -0,0 +1,2079 @@ +package itest + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "math" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "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/v2" + "github.com/btcsuite/btcd/chaincfg/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/psbt/v2" + "github.com/btcsuite/btcd/txscript/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/fn" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightninglabs/taproot-assets/tappsbt" + "github.com/lightninglabs/taproot-assets/taprpc" + "github.com/lightninglabs/taproot-assets/taprpc/mintrpc" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnrpc/signrpc" + "github.com/lightningnetwork/lnd/lnrpc/walletrpc" + "github.com/lightningnetwork/lnd/lntest/rpc" + "github.com/stretchr/testify/require" +) + +type customAnchorMuSig2SigningEvidence struct { + ParticipantPubKeys []string `json:"participant_pubkeys"` + PublicNonces []string `json:"public_nonces"` + PartialSignatures []string `json:"partial_signatures"` + FinalSignature string `json:"final_signature"` +} + +const ( + customAnchorUTXOKeyDomain = "M10-ISSUE721-UTXO-KEY-V1\x00" + customAnchorUTXOLeafDomain = "M10-ISSUE721-UTXO-LEAF-V1\x00" + customAnchorUTXOEmptyDomain = "M10-ISSUE721-UTXO-EMPTY-V1\x00" + customAnchorUTXONodeDomain = "M10-ISSUE721-UTXO-NODE-V1\x00" + customAnchorAuthorityCommitmentDomain = "M10-ISSUE721-AUTHORITY-COMMITMENT-V1\x00" + customAnchorAuthorityTag = "M10-ISSUE721-AUTHORITY-V1" +) + +type customAnchorOutpointEvidence struct { + TxID string `json:"txid"` + OutputIndex uint32 `json:"output_index"` +} + +type customAnchorWalletLeafEvidence struct { + Outpoint customAnchorOutpointEvidence `json:"outpoint"` + Value uint64 `json:"value_sat"` + Script string `json:"script_pubkey"` +} + +type customAnchorSparseProofEvidence struct { + Key string `json:"key"` + Leaf *customAnchorWalletLeafEvidence `json:"leaf,omitempty"` + Siblings []string `json:"siblings_leaf_to_root"` +} + +type customAnchorWalletQueryEvidence struct { + MinConfirmations int32 `json:"min_confirmations"` + MaxConfirmations int32 `json:"max_confirmations"` + Account string `json:"account"` + UnconfirmedOnly bool `json:"unconfirmed_only"` +} + +type customAnchorWalletCommitmentEvidence struct { + Algorithm string `json:"algorithm"` + KeyEncoding string `json:"key_encoding"` + LeafEncoding string `json:"leaf_encoding"` + TreeEncoding string `json:"tree_encoding"` + Query customAnchorWalletQueryEvidence `json:"query"` + Count uint64 `json:"count"` + Root string `json:"root"` + FeeInputMembership []customAnchorSparseProofEvidence `json:"fee_input_membership"` + ForeignInputNonMembership customAnchorSparseProofEvidence `json:"foreign_input_non_membership"` +} + +type customAnchorKeyLocatorEvidence struct { + Family int32 `json:"family"` + Index int32 `json:"index"` +} + +type customAnchorAuthorityPoPEvidence struct { + Algorithm string `json:"algorithm"` + Tag string `json:"tag"` + CommitmentEncoding string `json:"commitment_encoding"` + Locator customAnchorKeyLocatorEvidence `json:"locator"` + PublicKey string `json:"public_key_xonly"` + Commitment string `json:"commitment"` + Signature string `json:"signature"` +} + +type customAnchorChainBlockEvidence struct { + Height uint32 `json:"height"` + Hash string `json:"hash"` + RawBlock string `json:"raw_block"` +} + +type customAnchorChainEvidence struct { + Network string `json:"network"` + StartHeight uint32 `json:"start_height"` + TipHeight uint32 `json:"tip_height"` + TipHash string `json:"tip_hash"` + Blocks []customAnchorChainBlockEvidence `json:"blocks"` +} + +type customAnchorAuthorityEvidence struct { + AliceIdentityPubKey string `json:"alice_identity_pubkey"` + WalletUTXO customAnchorWalletCommitmentEvidence `json:"wallet_utxo_commitment"` + AnchorKeyPoP customAnchorAuthorityPoPEvidence `json:"anchor_key_pop"` + Chain customAnchorChainEvidence `json:"chain"` +} + +type customAnchorSparseTree struct { + leaves map[[32]byte]customAnchorWalletLeafEvidence + levels [257]map[[32]byte][32]byte + defaults [257][32]byte +} + +type customAnchorMuSig2Evidence struct { + Schema string `json:"schema"` + CampaignID string `json:"campaign_id"` + CohortID string `json:"cohort_id,omitempty"` + ProducerBinarySHA256 string `json:"producer_binary_sha256"` + Version uint32 `json:"version"` + ForeignFundingTx string `json:"foreign_funding_tx"` + ForeignFundingBlock string `json:"foreign_funding_block"` + ForeignFundingBlockHash string `json:"foreign_funding_block_hash"` + ForeignFundingTxIndex uint32 `json:"foreign_funding_tx_index"` + ForeignGenesisOutpoint string `json:"foreign_genesis_outpoint"` + ForeignPrevoutValue int64 `json:"foreign_prevout_value"` + ForeignPrevoutScript string `json:"foreign_prevout_script"` + SuccessorOutputIndex uint32 `json:"successor_output_index"` + SuccessorValue int64 `json:"successor_value"` + SuccessorScript string `json:"successor_script"` + AssetAnchorOutputIndex uint32 `json:"asset_anchor_output_index"` + AssetAnchorValue int64 `json:"asset_anchor_value"` + AssetAnchorInternalKey string `json:"asset_anchor_internal_key"` + AssetAnchorKeyFamily int32 `json:"asset_anchor_key_family"` + AssetAnchorKeyIndex int32 `json:"asset_anchor_key_index"` + FundBatchAnchorPSBT string `json:"fund_batch_anchor_psbt"` + FundBatchAnchorIndex uint32 `json:"fund_batch_anchor_index"` + FundBatchChangeIndex int32 `json:"fund_batch_change_index"` + ChangeOutputIndex int32 `json:"change_output_index"` + TemplatePSBT string `json:"template_psbt"` + FundedPSBT string `json:"funded_psbt"` + PreparedPSBT string `json:"prepared_psbt"` + ExternalFinalizedPSBT string `json:"external_finalized_psbt"` + WalletSignedPSBT string `json:"wallet_signed_psbt"` + WalletSignedInputIndexes []uint32 `json:"wallet_signed_input_indexes"` + FinalizedPSBT string `json:"finalized_psbt"` + FinalTransaction string `json:"final_transaction"` + MintTransactionID string `json:"mint_transaction_id"` + MintBlockHash string `json:"mint_block_hash"` + MintAssetID string `json:"mint_asset_id"` + MintProof string `json:"mint_proof"` + PostSpendTransactionID string `json:"post_spend_transaction_id"` + PostSpendTransaction string `json:"post_spend_transaction"` + PostSpendProof string `json:"post_spend_proof"` + MuSig2Signing customAnchorMuSig2SigningEvidence `json:"musig2_signing"` + Authority customAnchorAuthorityEvidence `json:"authority"` +} + +// testMintCustomAnchorMuSig2Psbt proves the full MINT-10 transaction shape: +// genesis input zero is a genuinely foreign, leafless two-party MuSig2 spend; +// a wallet input pays fees; output zero preserves the caller's collateral +// successor; and a distinct wallet-owned output carries the asset commitment. +func testMintCustomAnchorMuSig2Psbt(t *harnessTest) { + var ( + ctx = context.Background() + aliceTapd = t.tapd + aliceLnd = t.tapd.cfg.LndNode + miner = t.lndHarness.Miner() + ) + + bobLnd := t.lndHarness.NewNodeWithCoins("custom-musig-bob", nil) + carolLnd := t.lndHarness.NewNodeWithCoins("custom-musig-carol", nil) + bobTapd := setupTapdHarness(t.t, t, bobLnd, t.universeServer) + defer func() { + require.NoError(t.t, bobTapd.stop(!*noDelete)) + }() + + bobKey := deriveMuSig2Key(t.t, bobLnd.RPC) + carolKey := deriveMuSig2Key(t.t, carolLnd.RPC) + aggregateKey, err := input.MuSig2CombineKeys( + input.MuSig2Version100RC2, + []*btcec.PublicKey{bobKey.PubKey, carolKey.PubKey}, true, + &input.MuSig2Tweaks{TaprootBIP0086Tweak: true}, + ) + require.NoError(t.t, err) + foreignScript, err := txscript.PayToTaprootScript(aggregateKey.FinalKey) + require.NoError(t.t, err) + + const foreignValue = int64(100_000) + foreignTxID := miner.SendOutput(&wire.TxOut{ + Value: foreignValue, PkScript: foreignScript, + }, btcutil.Amount(1_000)) + foreignFundingBlock := MineBlocks(t.t, miner, 1, 1)[0] + foreignFundingBestHash, foreignFundingHeight := miner.GetBestBlock() + require.Equal(t.t, foreignFundingBlock.BlockHash(), + *foreignFundingBestHash) + foreignFundingTxIndex := -1 + for idx, blockTx := range foreignFundingBlock.Transactions { + if blockTx.TxHash() == *foreignTxID { + foreignFundingTxIndex = idx + break + } + } + require.NotEqual(t.t, -1, foreignFundingTxIndex) + foreignFundingTx := miner.GetRawTransaction(*foreignTxID).MsgTx() + foreignOutputIndex := -1 + for idx, txOut := range foreignFundingTx.TxOut { + if txOut.Value == foreignValue && + bytes.Equal(txOut.PkScript, foreignScript) { + + foreignOutputIndex = idx + break + } + } + require.NotEqual(t.t, -1, foreignOutputIndex) + foreignOutpoint := wire.OutPoint{ + Hash: *foreignTxID, Index: uint32(foreignOutputIndex), + } + foreignPrevOut := foreignFundingTx.TxOut[foreignOutputIndex] + + mintReq := CopyRequest(simpleAssets[0]) + mintReq.Asset.Name = "issue-721-foreign-musig2-itest" + mintReq.Asset.Amount = 100 + mintReqs := []*mintrpc.MintAssetRequest{mintReq} + BuildMintingBatch(t.t, aliceTapd, mintReqs) + + anchorKeyResp := aliceLnd.RPC.DeriveNextKey(&walletrpc.KeyReq{ + KeyFamily: int32(asset.TaprootAssetsKeyFamily), + }) + anchorInternalKey, err := btcec.ParsePubKey(anchorKeyResp.RawKeyBytes) + require.NoError(t.t, err) + anchorPlaceholderKey := txscript.ComputeTaprootKeyNoScript( + anchorInternalKey, + ) + anchorPlaceholderScript, err := txscript.PayToTaprootScript( + anchorPlaceholderKey, + ) + require.NoError(t.t, err) + anchorKeyDesc := keychain.KeyDescriptor{ + PubKey: anchorInternalKey, + KeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamily(anchorKeyResp.KeyLoc.KeyFamily), + Index: uint32(anchorKeyResp.KeyLoc.KeyIndex), + }, + } + anchorDerivation, anchorTaprootDerivation := + tappsbt.Bip32DerivationFromKeyDesc( + anchorKeyDesc, harnessNetParams.HDCoinType, + ) + + const ( + successorValue = foreignValue + anchorValue = int64(10_000) + successorIndex = 0 + anchorIndex = 1 + ) + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{PreviousOutPoint: foreignOutpoint}) + tx.AddTxOut(&wire.TxOut{ + Value: successorValue, PkScript: foreignScript, + }) + tx.AddTxOut(&wire.TxOut{ + Value: anchorValue, PkScript: anchorPlaceholderScript, + }) + template, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t.t, err) + template.Inputs[0].WitnessUtxo = foreignPrevOut + template.Inputs[0].TaprootInternalKey = schnorr.SerializePubKey( + aggregateKey.PreTweakedKey, + ) + template.Inputs[0].TaprootBip32Derivation = + []*psbt.TaprootBip32Derivation{{ + XOnlyPubKey: schnorr.SerializePubKey( + aggregateKey.PreTweakedKey, + ), + }} + template.Inputs[0].Unknowns = []*psbt.Unknown{{ + Key: []byte{0x50}, Value: []byte("foreign-two-party-musig2"), + }} + template.Outputs[successorIndex].TaprootInternalKey = + schnorr.SerializePubKey(aggregateKey.PreTweakedKey) + template.Outputs[successorIndex].Unknowns = []*psbt.Unknown{{ + Key: []byte{0x51}, Value: []byte("preserved-collateral-successor"), + }} + template.Outputs[anchorIndex].Bip32Derivation = + []*psbt.Bip32Derivation{anchorDerivation} + template.Outputs[anchorIndex].TaprootBip32Derivation = + []*psbt.TaprootBip32Derivation{anchorTaprootDerivation} + template.Outputs[anchorIndex].TaprootInternalKey = + anchorTaprootDerivation.XOnlyPubKey + template.Outputs[anchorIndex].Unknowns = []*psbt.Unknown{{ + Key: []byte{0x52}, Value: []byte("wallet-owned-asset-anchor"), + }} + + templateBytes, err := fn.Serialize(template) + require.NoError(t.t, err) + normalizedTemplate, err := psbt.NewFromRawBytes( + bytes.NewReader(templateBytes), false, + ) + require.NoError(t.t, err) + + // Commit to Alice's complete, non-paginated WalletKit UTXO view before + // funding. The evidence only discloses the selected fee leaves and the + // foreign empty leaf; it never exports addresses or the full inventory. + walletQuery := customAnchorWalletQueryEvidence{ + MinConfirmations: 0, + MaxConfirmations: math.MaxInt32, + Account: "", + UnconfirmedOnly: false, + } + walletSnapshot := aliceLnd.RPC.ListUnspent( + &walletrpc.ListUnspentRequest{ + MinConfs: walletQuery.MinConfirmations, + MaxConfs: walletQuery.MaxConfirmations, + Account: walletQuery.Account, + UnconfirmedOnly: walletQuery.UnconfirmedOnly, + }, + ) + walletTree, err := newCustomAnchorSparseTree(walletSnapshot.Utxos) + require.NoError(t.t, err) + + fundResp := aliceLnd.RPC.FundPsbt(&walletrpc.FundPsbtRequest{ + Template: &walletrpc.FundPsbtRequest_CoinSelect{ + CoinSelect: &walletrpc.PsbtCoinSelect{ + Psbt: templateBytes, + ChangeOutput: &walletrpc.PsbtCoinSelect_Add{ + Add: true, + }, + }, + }, + Fees: &walletrpc.FundPsbtRequest_SatPerVbyte{ + SatPerVbyte: 2, + }, + MinConfs: 1, + ChangeType: walletrpc.ChangeAddressType_CHANGE_ADDRESS_TYPE_P2TR, + MaxFeeRatio: 1, + }) + require.NotEmpty(t.t, fundResp.LockedUtxos) + require.GreaterOrEqual(t.t, fundResp.ChangeOutputIndex, int32(2)) + fundedPacket, err := psbt.NewFromRawBytes( + bytes.NewReader(fundResp.FundedPsbt), false, + ) + require.NoError(t.t, err) + require.Greater(t.t, len(fundedPacket.Inputs), 1) + require.Equal(t.t, foreignOutpoint, + fundedPacket.UnsignedTx.TxIn[0].PreviousOutPoint) + require.Equal(t.t, normalizedTemplate.Inputs[0], fundedPacket.Inputs[0]) + require.Equal(t.t, normalizedTemplate.UnsignedTx.TxOut[successorIndex], + fundedPacket.UnsignedTx.TxOut[successorIndex]) + require.Equal(t.t, normalizedTemplate.Outputs[successorIndex], + fundedPacket.Outputs[successorIndex]) + require.Equal(t.t, normalizedTemplate.UnsignedTx.TxOut[anchorIndex], + fundedPacket.UnsignedTx.TxOut[anchorIndex]) + fee, err := fundedPacket.GetTxFee() + require.NoError(t.t, err) + var walletInputValue int64 + for idx := 1; idx < len(fundedPacket.Inputs); idx++ { + require.NotNil(t.t, fundedPacket.Inputs[idx].WitnessUtxo) + walletInputValue += fundedPacket.Inputs[idx].WitnessUtxo.Value + } + var walletChangeValue int64 + for idx := 2; idx < len(fundedPacket.UnsignedTx.TxOut); idx++ { + walletChangeValue += fundedPacket.UnsignedTx.TxOut[idx].Value + } + require.Equal(t.t, anchorValue+walletChangeValue+int64(fee), + walletInputValue) + + feeMembership := make([]customAnchorSparseProofEvidence, 0, + len(fundedPacket.Inputs)-1) + for idx := 1; idx < len(fundedPacket.Inputs); idx++ { + proof, err := walletTree.proof( + fundedPacket.UnsignedTx.TxIn[idx].PreviousOutPoint, true, + ) + require.NoError(t.t, err) + require.NotNil(t.t, proof.Leaf) + require.Equal(t.t, + customAnchorOutpointEvidenceFromWire( + fundedPacket.UnsignedTx.TxIn[idx].PreviousOutPoint, + ), proof.Leaf.Outpoint, + ) + require.Equal(t.t, + uint64(fundedPacket.Inputs[idx].WitnessUtxo.Value), + proof.Leaf.Value, + ) + require.Equal(t.t, hex.EncodeToString( + fundedPacket.Inputs[idx].WitnessUtxo.PkScript, + ), proof.Leaf.Script) + feeMembership = append(feeMembership, proof) + } + foreignNonMembership, err := walletTree.proof(foreignOutpoint, false) + require.NoError(t.t, err) + foreignKey, err := customAnchorSparseKey( + customAnchorOutpointEvidenceFromWire(foreignOutpoint), + ) + require.NoError(t.t, err) + require.Equal(t.t, hex.EncodeToString(foreignKey[:]), + foreignNonMembership.Key) + walletRoot := walletTree.root() + walletEvidence := customAnchorWalletCommitmentEvidence{ + Algorithm: "sha256-sparse-merkle-256-v1", + KeyEncoding: "SHA256(M10-ISSUE721-UTXO-KEY-V1\\0 || " + + "txid_display_bytes_be[32] || vout_u32be)", + LeafEncoding: "SHA256(M10-ISSUE721-UTXO-LEAF-V1\\0 || " + + "key[32] || value_u64be || script_len_u32be || script)", + TreeEncoding: "256 levels, key bits MSB-first from root; " + + "empty=SHA256(M10-ISSUE721-UTXO-EMPTY-V1\\0); " + + "node=SHA256(M10-ISSUE721-UTXO-NODE-V1\\0 || left || " + + "right); proof siblings are leaf-to-root and proof[i] " + + "uses key bit 255-i", + Query: walletQuery, + Count: uint64(len(walletSnapshot.Utxos)), + Root: hex.EncodeToString(walletRoot[:]), + FeeInputMembership: feeMembership, + ForeignInputNonMembership: foreignNonMembership, + } + + for _, lease := range fundResp.LockedUtxos { + _, err := aliceLnd.RPC.WalletKit.ReleaseOutput( + ctx, &walletrpc.ReleaseOutputRequest{ + Id: lease.Id, Outpoint: lease.Outpoint, + }, + ) + require.NoError(t.t, err) + } + + fundBatchResp, err := aliceTapd.FundBatch( + ctx, &mintrpc.FundBatchRequest{ + AnchorPsbt: fundResp.FundedPsbt, + AssetAnchorOutputIndex: anchorIndex, + ChangeOutputIndex: fundResp.ChangeOutputIndex, + }, + ) + require.NoError(t.t, err) + require.Equal(t.t, mintrpc.BatchState_BATCH_STATE_PENDING, + fundBatchResp.Batch.Batch.State) + prepareResp, err := aliceTapd.PrepareBatch( + ctx, &mintrpc.PrepareBatchRequest{}, + ) + require.NoError(t.t, err) + require.Equal(t.t, mintrpc.BatchState_BATCH_STATE_COMMITTED, + prepareResp.Batch.State) + preparedPacket, err := psbt.NewFromRawBytes( + bytes.NewReader(prepareResp.Batch.BatchPsbt), false, + ) + require.NoError(t.t, err) + + // Input zero and the successor output remain byte-for-byte semantic + // equivalents. Only the selected anchor's script is replaced. + require.Equal(t.t, fundedPacket.UnsignedTx.TxIn[0], + preparedPacket.UnsignedTx.TxIn[0]) + require.Equal(t.t, fundedPacket.Inputs[0], preparedPacket.Inputs[0]) + require.Equal(t.t, fundedPacket.UnsignedTx.TxOut[successorIndex], + preparedPacket.UnsignedTx.TxOut[successorIndex]) + require.Equal(t.t, fundedPacket.Outputs[successorIndex], + preparedPacket.Outputs[successorIndex]) + require.Equal(t.t, anchorValue, + preparedPacket.UnsignedTx.TxOut[anchorIndex].Value) + require.NotEqual(t.t, anchorPlaceholderScript, + preparedPacket.UnsignedTx.TxOut[anchorIndex].PkScript) + for idx := 2; idx < len(fundedPacket.UnsignedTx.TxOut); idx++ { + require.Equal(t.t, fundedPacket.UnsignedTx.TxOut[idx], + preparedPacket.UnsignedTx.TxOut[idx]) + require.Equal(t.t, fundedPacket.Outputs[idx], + preparedPacket.Outputs[idx]) + } + + // Exchange independent nonces and partial signatures between the two + // foreign wallets, then install their combined key-spend witness before + // Alice signs any wallet-owned fee input. + muSigWitness, signingEvidence := signForeignMuSig2Input( + t.t, bobLnd.RPC, carolLnd.RPC, bobKey, carolKey, + aggregateKey.FinalKey, preparedPacket, + ) + var witnessBuf bytes.Buffer + require.NoError(t.t, psbt.WriteTxWitness( + &witnessBuf, wire.TxWitness{muSigWitness}, + )) + preparedPacket.Inputs[0].FinalScriptWitness = witnessBuf.Bytes() + externalInput := preparedPacket.Inputs[0] + preparedWithMuSig, err := fn.Serialize(preparedPacket) + require.NoError(t.t, err) + walletSignResp := aliceLnd.RPC.SignPsbt(&walletrpc.SignPsbtRequest{ + FundedPsbt: preparedWithMuSig, + }) + require.NotContains(t.t, walletSignResp.SignedInputs, uint32(0)) + require.NotEmpty(t.t, walletSignResp.SignedInputs) + walletSignedPacket, err := psbt.NewFromRawBytes( + bytes.NewReader(walletSignResp.SignedPsbt), false, + ) + require.NoError(t.t, err) + require.True(t.t, customAnchorForeignInputPreserved( + preparedPacket.UnsignedTx.TxIn[0], + walletSignedPacket.UnsignedTx.TxIn[0], externalInput, + walletSignedPacket.Inputs[0], + )) + require.NoError(t.t, psbt.MaybeFinalizeAll(walletSignedPacket)) + finalTx, err := psbt.Extract(walletSignedPacket) + require.NoError(t.t, err) + require.Equal(t.t, foreignOutpoint, + finalTx.TxIn[0].PreviousOutPoint) + require.Equal(t.t, fundedPacket.UnsignedTx.TxOut[successorIndex], + finalTx.TxOut[successorIndex]) + assertAllInputsScriptValid(t.t, walletSignedPacket, finalTx) + + signedBytes, err := fn.Serialize(walletSignedPacket) + require.NoError(t.t, err) + ctxFinalize, cancelFinalize := context.WithTimeout( + ctx, defaultWaitTimeout, + ) + finalizeResp, err := aliceTapd.FinalizeBatch( + ctxFinalize, &mintrpc.FinalizeBatchRequest{ + SignedPsbt: signedBytes, + }, + ) + cancelFinalize() + require.NoError(t.t, err) + require.Equal(t.t, mintrpc.BatchState_BATCH_STATE_BROADCAST, + finalizeResp.Batch.State) + + hashes, err := WaitForNTxsInMempool(miner, 1, defaultWaitTimeout) + require.NoError(t.t, err) + require.Len(t.t, hashes, 1) + require.Equal(t.t, finalTx.TxHash(), *hashes[0]) + block := MineBlocks(t.t, miner, 1, 1)[0] + ctxWait, cancelWait := context.WithTimeout(ctx, defaultWaitTimeout) + defer cancelWait() + WaitForBatchState( + t.t, ctxWait, aliceTapd, defaultWaitTimeout, + finalizeResp.Batch.BatchKey, + mintrpc.BatchState_BATCH_STATE_FINALIZED, + ) + mintedAssets := AssertAssetsMintedAtOutpoint( + t.t, aliceTapd, mintReqs, wire.OutPoint{ + Hash: finalTx.TxHash(), Index: anchorIndex, + }, block.BlockHash(), + ) + require.Len(t.t, mintedAssets, 1) + mintedAsset := mintedAssets[0] + require.Equal(t.t, foreignOutpoint.String(), + mintedAsset.AssetGenesis.GenesisPoint) + proofBlob := AssertAssetProofs( + t.t, aliceTapd, aliceLnd.RPC.ChainKit, mintedAsset, + ) + proofFile := &proof.File{} + require.NoError(t.t, proofFile.Decode(bytes.NewReader(proofBlob))) + mintProof, err := proofFile.LastProof() + require.NoError(t.t, err) + require.Equal(t.t, foreignOutpoint, mintProof.PrevOut) + require.Equal(t.t, uint32(anchorIndex), + mintProof.InclusionProof.OutputIndex) + require.True(t.t, + mintProof.InclusionProof.InternalKey.IsEqual(anchorInternalKey)) + require.Equal(t.t, finalTx.TxOut[successorIndex], + mintProof.AnchorTx.TxOut[successorIndex]) + + const sendAmount = uint64(40) + bobAddr, err := bobTapd.NewAddr(ctx, &taprpc.NewAddrRequest{ + AssetId: mintedAsset.AssetGenesis.AssetId, + Amt: sendAmount, + }) + require.NoError(t.t, err) + AssertAddrCreated(t.t, bobTapd, mintedAsset, bobAddr) + sendResp, sendEvents := sendAssetsToAddr(t, aliceTapd, bobAddr) + ConfirmAndAssertOutboundTransfer( + t.t, miner, aliceTapd, sendResp, + mintedAsset.AssetGenesis.AssetId, + []uint64{mintedAsset.Amount - sendAmount, sendAmount}, 0, 1, + ) + var postSpendTx wire.MsgTx + require.NoError(t.t, postSpendTx.Deserialize( + bytes.NewReader(sendResp.Transfer.AnchorTx), + )) + successorOutpoint := wire.OutPoint{ + Hash: finalTx.TxHash(), Index: successorIndex, + } + for _, txIn := range postSpendTx.TxIn { + require.NotEqual(t.t, successorOutpoint, txIn.PreviousOutPoint, + "asset send spent the independent collateral successor") + } + AssertNonInteractiveRecvComplete(t.t, bobTapd, 1) + AssertSendEventsComplete(t.t, bobAddr.ScriptKey, sendEvents) + AssertBalanceByID( + t.t, bobTapd, mintedAsset.AssetGenesis.AssetId, sendAmount, + ) + postSpendProofResp, err := bobTapd.ExportProof( + ctx, &taprpc.ExportProofRequest{ + AssetId: mintedAsset.AssetGenesis.AssetId, + ScriptKey: bobAddr.ScriptKey, + }, + ) + require.NoError(t.t, err) + postSpendVerify, err := bobTapd.VerifyProof( + ctx, &taprpc.ProofFile{RawProofFile: postSpendProofResp.RawProofFile}, + ) + require.NoError(t.t, err) + require.True(t.t, postSpendVerify.Valid) + postSpendTxID, err := chainhash.NewHash(sendResp.Transfer.AnchorTxHash) + require.NoError(t.t, err) + + campaignID := customAnchorEvidenceBinding(t.t, + "TAPD_ITEST_CAMPAIGN_ID", false) + cohortID := customAnchorEvidenceBinding(t.t, + "TAPD_ITEST_COHORT_ID", true) + producerDigest := customAnchorProducerBinarySHA256(t.t) + aliceInfo := aliceLnd.RPC.GetInfo() + identityBytes, err := hex.DecodeString(aliceInfo.IdentityPubkey) + require.NoError(t.t, err) + require.Len(t.t, identityBytes, btcec.PubKeyBytesLenCompressed) + _, err = btcec.ParsePubKey(identityBytes) + require.NoError(t.t, err) + + // Derive the exact locator again rather than treating the earlier public + // key as proof that Alice controls it. + anchorLocator := &signrpc.KeyLocator{ + KeyFamily: anchorKeyResp.KeyLoc.KeyFamily, + KeyIndex: anchorKeyResp.KeyLoc.KeyIndex, + } + require.Equal(t.t, int32(asset.TaprootAssetsKeyFamily), + anchorLocator.KeyFamily) + derivedAnchorKey := aliceLnd.RPC.DeriveKey(anchorLocator) + require.Equal(t.t, anchorKeyResp.RawKeyBytes, + derivedAnchorKey.RawKeyBytes) + require.Equal(t.t, anchorKeyResp.KeyLoc, derivedAnchorKey.KeyLoc) + + chainEvidence, err := collectCustomAnchorChainEvidence( + miner, uint32(foreignFundingHeight), + foreignFundingBlock.BlockHash(), + ) + require.NoError(t.t, err) + for _, expectedTx := range []chainhash.Hash{ + *foreignTxID, finalTx.TxHash(), *postSpendTxID, + } { + txHeight, ok := customAnchorChainTxHeight( + chainEvidence, expectedTx, + ) + require.True(t.t, ok, "transaction %s absent from chain evidence", + expectedTx) + require.GreaterOrEqual(t.t, + chainEvidence.TipHeight-txHeight+1, uint32(1), + ) + } + feeOutpoints := make([]wire.OutPoint, 0, + len(fundedPacket.UnsignedTx.TxIn)-1) + for idx := 1; idx < len(fundedPacket.UnsignedTx.TxIn); idx++ { + feeOutpoints = append(feeOutpoints, + fundedPacket.UnsignedTx.TxIn[idx].PreviousOutPoint) + } + commitment, err := customAnchorAuthorityCommitment( + campaignID, cohortID, producerDigest, identityBytes, + walletEvidence, foreignOutpoint, feeOutpoints, + anchorLocator, schnorr.SerializePubKey(anchorInternalKey), + chainEvidence, + ) + require.NoError(t.t, err) + popResp := aliceLnd.RPC.SignMessageSigner(&signrpc.SignMessageReq{ + Msg: commitment[:], KeyLoc: anchorLocator, SchnorrSig: true, + Tag: []byte(customAnchorAuthorityTag), + }) + require.Len(t.t, popResp.Signature, schnorr.SignatureSize) + popSig, err := schnorr.ParseSignature(popResp.Signature) + require.NoError(t.t, err) + popDigest := chainhash.TaggedHash( + []byte(customAnchorAuthorityTag), commitment[:], + ) + require.True(t.t, popSig.Verify( + popDigest[:], anchorInternalKey, + )) + authorityEvidence := customAnchorAuthorityEvidence{ + AliceIdentityPubKey: aliceInfo.IdentityPubkey, + WalletUTXO: walletEvidence, + AnchorKeyPoP: customAnchorAuthorityPoPEvidence{ + Algorithm: "BIP340 over TaggedHash(tag, commitment)", + Tag: customAnchorAuthorityTag, + CommitmentEncoding: "SHA256(M10-ISSUE721-AUTHORITY-" + + "COMMITMENT-V1\\0 || lenpref(campaign) || " + + "lenpref(cohort) || lenpref(producer_sha256_hex) || " + + "alice_identity_compressed[33] || query_i32be_i32be_" + + "lenpref(account)_bool || wallet_root[32] || " + + "wallet_count_u64be || foreign_outpoint[36] || " + + "fee_count_u32be || sorted_fee_outpoints[36]* || " + + "locator_family_i32be || locator_index_i32be || " + + "anchor_xonly[32] || tip_hash_display_bytes_be[32] || " + + "tip_height_u32be)", + Locator: customAnchorKeyLocatorEvidence{ + Family: anchorLocator.KeyFamily, + Index: anchorLocator.KeyIndex, + }, + PublicKey: hex.EncodeToString( + schnorr.SerializePubKey(anchorInternalKey), + ), + Commitment: hex.EncodeToString(commitment[:]), + Signature: hex.EncodeToString(popResp.Signature), + }, + Chain: chainEvidence, + } + var ( + finalTxBuf bytes.Buffer + foreignFundingBuf bytes.Buffer + foreignBlockBuf bytes.Buffer + ) + require.NoError(t.t, finalTx.Serialize(&finalTxBuf)) + require.NoError(t.t, foreignFundingTx.Serialize(&foreignFundingBuf)) + require.NoError(t.t, foreignFundingBlock.Serialize(&foreignBlockBuf)) + + writeCustomAnchorMuSig2Evidence(t.t, customAnchorMuSig2Evidence{ + Schema: "taproot_assets_custom_anchor_musig2_evidence_v2", + CampaignID: campaignID, + CohortID: cohortID, + ProducerBinarySHA256: producerDigest, + Version: 2, + ForeignFundingTx: hex.EncodeToString( + foreignFundingBuf.Bytes(), + ), + ForeignFundingBlock: hex.EncodeToString( + foreignBlockBuf.Bytes(), + ), + ForeignFundingBlockHash: foreignFundingBlock.BlockHash().String(), + ForeignFundingTxIndex: uint32(foreignFundingTxIndex), + ForeignGenesisOutpoint: foreignOutpoint.String(), + ForeignPrevoutValue: foreignPrevOut.Value, + ForeignPrevoutScript: hex.EncodeToString( + foreignPrevOut.PkScript, + ), + SuccessorOutputIndex: successorIndex, + SuccessorValue: successorValue, + SuccessorScript: hex.EncodeToString(foreignScript), + AssetAnchorOutputIndex: anchorIndex, + AssetAnchorValue: anchorValue, + AssetAnchorInternalKey: hex.EncodeToString( + schnorr.SerializePubKey(anchorInternalKey), + ), + AssetAnchorKeyFamily: anchorKeyResp.KeyLoc.KeyFamily, + AssetAnchorKeyIndex: anchorKeyResp.KeyLoc.KeyIndex, + FundBatchAnchorPSBT: hex.EncodeToString(fundResp.FundedPsbt), + FundBatchAnchorIndex: anchorIndex, + FundBatchChangeIndex: fundResp.ChangeOutputIndex, + ChangeOutputIndex: fundResp.ChangeOutputIndex, + TemplatePSBT: hex.EncodeToString(templateBytes), + FundedPSBT: hex.EncodeToString(fundResp.FundedPsbt), + PreparedPSBT: hex.EncodeToString( + prepareResp.Batch.BatchPsbt, + ), + ExternalFinalizedPSBT: hex.EncodeToString(preparedWithMuSig), + WalletSignedPSBT: hex.EncodeToString( + walletSignResp.SignedPsbt, + ), + WalletSignedInputIndexes: walletSignResp.SignedInputs, + FinalizedPSBT: hex.EncodeToString(signedBytes), + FinalTransaction: hex.EncodeToString(finalTxBuf.Bytes()), + MintTransactionID: finalTx.TxHash().String(), + MintBlockHash: block.BlockHash().String(), + MintAssetID: hex.EncodeToString( + mintedAsset.AssetGenesis.AssetId, + ), + MintProof: hex.EncodeToString(proofBlob), + PostSpendTransactionID: postSpendTxID.String(), + PostSpendTransaction: hex.EncodeToString(sendResp.Transfer.AnchorTx), + PostSpendProof: hex.EncodeToString( + postSpendProofResp.RawProofFile, + ), + MuSig2Signing: signingEvidence, + Authority: authorityEvidence, + }) +} + +func customAnchorForeignInputPreserved(expectedTxIn, actualTxIn *wire.TxIn, + expectedInput, actualInput psbt.PInput) bool { + + if !reflect.DeepEqual(expectedTxIn, actualTxIn) { + return false + } + + hintsAbsent := actualInput.TaprootInternalKey == nil && + actualInput.TaprootBip32Derivation == nil + hintsEqual := bytes.Equal( + expectedInput.TaprootInternalKey, + actualInput.TaprootInternalKey, + ) && reflect.DeepEqual( + expectedInput.TaprootBip32Derivation, + actualInput.TaprootBip32Derivation, + ) + if !hintsAbsent && !hintsEqual { + return false + } + + expectedInput.TaprootInternalKey = nil + expectedInput.TaprootBip32Derivation = nil + actualInput.TaprootInternalKey = nil + actualInput.TaprootBip32Derivation = nil + + return reflect.DeepEqual(expectedInput, actualInput) +} + +func TestCustomAnchorForeignInputPreserved(t *testing.T) { + txIn := &wire.TxIn{PreviousOutPoint: wire.OutPoint{Index: 7}} + expected := psbt.PInput{ + WitnessUtxo: &wire.TxOut{ + Value: 10_000, PkScript: []byte{txscript.OP_TRUE}, + }, + SighashType: txscript.SigHashDefault, + FinalScriptWitness: []byte{1, 2, 3}, + TaprootInternalKey: bytes.Repeat([]byte{2}, 32), + TaprootMerkleRoot: bytes.Repeat([]byte{3}, 32), + TaprootBip32Derivation: []*psbt.TaprootBip32Derivation{{ + XOnlyPubKey: bytes.Repeat([]byte{2}, 32), + }}, + Unknowns: []*psbt.Unknown{{ + Key: []byte{0x50}, Value: []byte("foreign"), + }}, + } + + require.True(t, customAnchorForeignInputPreserved( + txIn, txIn, expected, expected, + )) + pruned := expected + pruned.TaprootInternalKey = nil + pruned.TaprootBip32Derivation = nil + require.True(t, customAnchorForeignInputPreserved( + txIn, txIn, expected, pruned, + )) + + testCases := []struct { + name string + mutate func(*wire.TxIn, *psbt.PInput) + }{ + { + name: "outpoint", + mutate: func(txIn *wire.TxIn, _ *psbt.PInput) { + txIn.PreviousOutPoint.Index++ + }, + }, + { + name: "witness", + mutate: func(_ *wire.TxIn, input *psbt.PInput) { + input.FinalScriptWitness = []byte{4, 5, 6} + }, + }, + { + name: "witness utxo value", + mutate: func(_ *wire.TxIn, input *psbt.PInput) { + input.WitnessUtxo = &wire.TxOut{ + Value: 9_999, + PkScript: input.WitnessUtxo.PkScript, + } + }, + }, + { + name: "witness utxo script", + mutate: func(_ *wire.TxIn, input *psbt.PInput) { + input.WitnessUtxo = &wire.TxOut{ + Value: input.WitnessUtxo.Value, + PkScript: []byte{ + txscript.OP_FALSE, + }, + } + }, + }, + { + name: "unknown metadata", + mutate: func(_ *wire.TxIn, input *psbt.PInput) { + input.Unknowns = []*psbt.Unknown{{ + Key: []byte{0x50}, Value: []byte("changed"), + }} + }, + }, + { + name: "sighash", + mutate: func(_ *wire.TxIn, input *psbt.PInput) { + input.SighashType = txscript.SigHashAll + }, + }, + { + name: "script signature", + mutate: func(_ *wire.TxIn, input *psbt.PInput) { + input.FinalScriptSig = []byte{txscript.OP_TRUE} + }, + }, + { + name: "taproot signature", + mutate: func(_ *wire.TxIn, input *psbt.PInput) { + input.TaprootKeySpendSig = []byte{1} + }, + }, + { + name: "taproot leaf script", + mutate: func(_ *wire.TxIn, input *psbt.PInput) { + input.TaprootLeafScript = + []*psbt.TaprootTapLeafScript{{ + ControlBlock: []byte{1}, + Script: []byte{txscript.OP_TRUE}, + }} + }, + }, + { + name: "merkle root", + mutate: func(_ *wire.TxIn, input *psbt.PInput) { + input.TaprootMerkleRoot = bytes.Repeat([]byte{4}, 32) + }, + }, + { + name: "internal key hint", + mutate: func(_ *wire.TxIn, input *psbt.PInput) { + input.TaprootInternalKey = bytes.Repeat([]byte{5}, 32) + }, + }, + { + name: "derivation hint", + mutate: func(_ *wire.TxIn, input *psbt.PInput) { + input.TaprootBip32Derivation = + []*psbt.TaprootBip32Derivation{{ + XOnlyPubKey: bytes.Repeat([]byte{6}, 32), + }} + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + actualTxIn := *txIn + actualInput := pruned + testCase.mutate(&actualTxIn, &actualInput) + require.False(t, customAnchorForeignInputPreserved( + txIn, &actualTxIn, expected, actualInput, + )) + }) + } +} + +func TestCustomAnchorSparseWalletCommitment(t *testing.T) { + hashA := chainhash.Hash{1} + hashB := chainhash.Hash{2} + utxos := []*lnrpc.Utxo{ + { + AmountSat: 40_000, PkScript: "5120" + + "1111111111111111111111111111111111111111111111111111111111111111", + Outpoint: &lnrpc.OutPoint{ + TxidStr: hashA.String(), OutputIndex: 3, + }, + }, + { + AmountSat: 90_000, PkScript: "0014" + + "2222222222222222222222222222222222222222", + Outpoint: &lnrpc.OutPoint{ + TxidStr: hashB.String(), OutputIndex: 7, + }, + }, + } + tree, err := newCustomAnchorSparseTree(utxos) + require.NoError(t, err) + root := tree.root() + + // Inventory ordering is irrelevant, while every disclosed field is bound. + reversed, err := newCustomAnchorSparseTree([]*lnrpc.Utxo{ + utxos[1], utxos[0], + }) + require.NoError(t, err) + require.Equal(t, root, reversed.root()) + + membership, err := tree.proof(wire.OutPoint{ + Hash: hashA, Index: 3, + }, true) + require.NoError(t, err) + require.NoError(t, verifyCustomAnchorSparseProof( + membership, root, true, + )) + + foreignHash := chainhash.Hash{9} + nonMembership, err := tree.proof(wire.OutPoint{ + Hash: foreignHash, Index: 1, + }, false) + require.NoError(t, err) + require.NoError(t, verifyCustomAnchorSparseProof( + nonMembership, root, false, + )) + + mutatedSibling := membership + mutatedSibling.Siblings = append([]string(nil), membership.Siblings...) + mutatedSibling.Siblings[0] = hex.EncodeToString(make([]byte, 32)) + require.Error(t, verifyCustomAnchorSparseProof( + mutatedSibling, root, true, + )) + mutatedLeaf := membership + leafCopy := *membership.Leaf + leafCopy.Value++ + mutatedLeaf.Leaf = &leafCopy + require.Error(t, verifyCustomAnchorSparseProof( + mutatedLeaf, root, true, + )) + mutatedKey := membership + mutatedKey.Key = hex.EncodeToString(make([]byte, 32)) + require.Error(t, verifyCustomAnchorSparseProof( + mutatedKey, root, true, + )) + + _, err = newCustomAnchorSparseTree([]*lnrpc.Utxo{ + utxos[0], utxos[0], + }) + require.ErrorContains(t, err, "duplicate UTXO") +} + +func TestCustomAnchorAuthorityCommitmentBindsFields(t *testing.T) { + identity := append([]byte{2}, bytes.Repeat([]byte{3}, 32)...) + anchorKey := bytes.Repeat([]byte{4}, 32) + foreign := wire.OutPoint{Hash: chainhash.Hash{5}, Index: 6} + fees := []wire.OutPoint{ + {Hash: chainhash.Hash{7}, Index: 8}, + {Hash: chainhash.Hash{9}, Index: 10}, + } + locator := &signrpc.KeyLocator{KeyFamily: 212, KeyIndex: 11} + wallet := customAnchorWalletCommitmentEvidence{ + Query: customAnchorWalletQueryEvidence{ + MinConfirmations: 0, MaxConfirmations: math.MaxInt32, + Account: "", UnconfirmedOnly: false, + }, + Root: hex.EncodeToString(bytes.Repeat([]byte{12}, 32)), + Count: 13, + } + chain := customAnchorChainEvidence{ + TipHash: chainhash.Hash{14}.String(), TipHeight: 15, + } + commit := func(campaign, cohort, producer string, id []byte, + walletValue customAnchorWalletCommitmentEvidence, + foreignValue wire.OutPoint, feeValues []wire.OutPoint, + locatorValue *signrpc.KeyLocator, key []byte, + chainValue customAnchorChainEvidence) [32]byte { + + result, err := customAnchorAuthorityCommitment( + campaign, cohort, producer, id, walletValue, + foreignValue, feeValues, locatorValue, key, chainValue, + ) + require.NoError(t, err) + return result + } + baseline := commit( + "00112233445566778899aabbccddeeff", + strings.Repeat("1", 64), strings.Repeat("2", 64), identity, + wallet, foreign, fees, locator, anchorKey, chain, + ) + reversedFees := []wire.OutPoint{fees[1], fees[0]} + require.Equal(t, baseline, commit( + "00112233445566778899aabbccddeeff", + strings.Repeat("1", 64), strings.Repeat("2", 64), identity, + wallet, foreign, reversedFees, locator, anchorKey, chain, + )) + + mutations := make([][32]byte, 0, 13) + mutations = append(mutations, commit( + "10112233445566778899aabbccddeeff", + strings.Repeat("1", 64), strings.Repeat("2", 64), identity, + wallet, foreign, fees, locator, anchorKey, chain, + )) + mutations = append(mutations, commit( + "00112233445566778899aabbccddeeff", + strings.Repeat("3", 64), strings.Repeat("2", 64), identity, + wallet, foreign, fees, locator, anchorKey, chain, + )) + mutations = append(mutations, commit( + "00112233445566778899aabbccddeeff", + strings.Repeat("1", 64), strings.Repeat("3", 64), identity, + wallet, foreign, fees, locator, anchorKey, chain, + )) + mutatedIdentity := append([]byte(nil), identity...) + mutatedIdentity[1] ^= 1 + mutations = append(mutations, commit( + "00112233445566778899aabbccddeeff", + strings.Repeat("1", 64), strings.Repeat("2", 64), + mutatedIdentity, wallet, foreign, fees, locator, anchorKey, chain, + )) + mutatedWallet := wallet + mutatedWallet.Query.MaxConfirmations-- + mutations = append(mutations, commit( + "00112233445566778899aabbccddeeff", + strings.Repeat("1", 64), strings.Repeat("2", 64), identity, + mutatedWallet, foreign, fees, locator, anchorKey, chain, + )) + mutatedWallet = wallet + mutatedWallet.Count++ + mutations = append(mutations, commit( + "00112233445566778899aabbccddeeff", + strings.Repeat("1", 64), strings.Repeat("2", 64), identity, + mutatedWallet, foreign, fees, locator, anchorKey, chain, + )) + mutatedWallet = wallet + mutatedWallet.Root = hex.EncodeToString(bytes.Repeat([]byte{13}, 32)) + mutations = append(mutations, commit( + "00112233445566778899aabbccddeeff", + strings.Repeat("1", 64), strings.Repeat("2", 64), identity, + mutatedWallet, foreign, fees, locator, anchorKey, chain, + )) + mutatedForeign := foreign + mutatedForeign.Index++ + mutations = append(mutations, commit( + "00112233445566778899aabbccddeeff", + strings.Repeat("1", 64), strings.Repeat("2", 64), identity, + wallet, mutatedForeign, fees, locator, anchorKey, chain, + )) + mutatedFees := append([]wire.OutPoint(nil), fees...) + mutatedFees[0].Index++ + mutations = append(mutations, commit( + "00112233445566778899aabbccddeeff", + strings.Repeat("1", 64), strings.Repeat("2", 64), identity, + wallet, foreign, mutatedFees, locator, anchorKey, chain, + )) + mutatedLocator := *locator + mutatedLocator.KeyIndex++ + mutations = append(mutations, commit( + "00112233445566778899aabbccddeeff", + strings.Repeat("1", 64), strings.Repeat("2", 64), identity, + wallet, foreign, fees, &mutatedLocator, anchorKey, chain, + )) + mutatedAnchor := append([]byte(nil), anchorKey...) + mutatedAnchor[0] ^= 1 + mutations = append(mutations, commit( + "00112233445566778899aabbccddeeff", + strings.Repeat("1", 64), strings.Repeat("2", 64), identity, + wallet, foreign, fees, locator, mutatedAnchor, chain, + )) + mutatedChain := chain + mutatedChain.TipHeight++ + mutations = append(mutations, commit( + "00112233445566778899aabbccddeeff", + strings.Repeat("1", 64), strings.Repeat("2", 64), identity, + wallet, foreign, fees, locator, anchorKey, mutatedChain, + )) + mutatedChain = chain + mutatedChain.TipHash = chainhash.Hash{15}.String() + mutations = append(mutations, commit( + "00112233445566778899aabbccddeeff", + strings.Repeat("1", 64), strings.Repeat("2", 64), identity, + wallet, foreign, fees, locator, anchorKey, mutatedChain, + )) + for index, mutation := range mutations { + require.NotEqual(t, baseline, mutation, "mutation %d", index) + } +} + +func TestCustomAnchorRegtestBits(t *testing.T) { + block := &wire.MsgBlock{Header: wire.BlockHeader{ + Bits: chaincfg.RegressionNetParams.PowLimitBits, + }} + require.NoError(t, validateCustomAnchorRegtestBits(block)) + + block.Header.Bits-- + require.ErrorContains(t, validateCustomAnchorRegtestBits(block), + "unexpected regtest bits") + require.ErrorContains(t, validateCustomAnchorRegtestBits(nil), + "nil block") +} + +func TestAssetAnchorOutpointCheck(t *testing.T) { + anchorHash := chainhash.Hash{1} + blockHash := chainhash.Hash{2} + anchorOutpoint := wire.OutPoint{Hash: anchorHash, Index: 1} + rpcAsset := &taprpc.Asset{ChainAnchor: &taprpc.AnchorInfo{ + AnchorOutpoint: anchorOutpoint.String(), + AnchorBlockHash: blockHash.String(), + }} + + require.NoError(t, AssetAnchorOutpointCheck( + anchorOutpoint, blockHash, + )(rpcAsset)) + require.Error(t, AssetAnchorOutpointCheck( + wire.OutPoint{Hash: anchorHash, Index: 0}, blockHash, + )(rpcAsset)) +} + +func customAnchorEvidenceBinding(t *testing.T, name string, + requireSHA256 bool) string { + + value := os.Getenv(name) + if os.Getenv("TAPD_ITEST_EVIDENCE_DIR") == "" { + return value + } + require.NotEmpty(t, value, "%s is required in evidence mode", name) + decoded, err := hex.DecodeString(value) + require.NoError(t, err, "%s must be lowercase hex", name) + require.Equal(t, value, hex.EncodeToString(decoded), + "%s must be canonical lowercase hex", name) + if requireSHA256 { + require.Len(t, decoded, sha256.Size, "%s must be SHA256", name) + } else { + require.Len(t, decoded, 16, + "%s must decode to exactly 16 bytes", name) + } + + return value +} + +func customAnchorOutpointEvidenceFromWire( + outpoint wire.OutPoint) customAnchorOutpointEvidence { + + return customAnchorOutpointEvidence{ + TxID: outpoint.Hash.String(), OutputIndex: outpoint.Index, + } +} + +func customAnchorOutpointBytes( + outpoint customAnchorOutpointEvidence) ([]byte, error) { + + txid, err := hex.DecodeString(outpoint.TxID) + if err != nil { + return nil, fmt.Errorf("decode display txid: %w", err) + } + if len(txid) != chainhash.HashSize { + return nil, fmt.Errorf("display txid must be 32 bytes") + } + result := make([]byte, chainhash.HashSize+4) + copy(result, txid) + binary.BigEndian.PutUint32(result[chainhash.HashSize:], + outpoint.OutputIndex) + + return result, nil +} + +func customAnchorWalletLeaf(utxo *lnrpc.Utxo) ( + customAnchorWalletLeafEvidence, error) { + + if utxo == nil || utxo.Outpoint == nil { + return customAnchorWalletLeafEvidence{}, fmt.Errorf("nil UTXO") + } + if utxo.AmountSat < 0 { + return customAnchorWalletLeafEvidence{}, fmt.Errorf( + "negative UTXO amount") + } + txid, err := chainhash.NewHashFromStr(utxo.Outpoint.TxidStr) + if err != nil { + return customAnchorWalletLeafEvidence{}, fmt.Errorf( + "invalid UTXO txid: %w", err) + } + script, err := hex.DecodeString(utxo.PkScript) + if err != nil { + return customAnchorWalletLeafEvidence{}, fmt.Errorf( + "invalid UTXO script: %w", err) + } + + return customAnchorWalletLeafEvidence{ + Outpoint: customAnchorOutpointEvidence{ + TxID: txid.String(), OutputIndex: utxo.Outpoint.OutputIndex, + }, + Value: uint64(utxo.AmountSat), + Script: hex.EncodeToString(script), + }, nil +} + +func customAnchorSparseKey(outpoint customAnchorOutpointEvidence) ( + [32]byte, error) { + + outpointBytes, err := customAnchorOutpointBytes(outpoint) + if err != nil { + return [32]byte{}, err + } + preimage := append([]byte(customAnchorUTXOKeyDomain), outpointBytes...) + + return sha256.Sum256(preimage), nil +} + +func customAnchorSparseLeafHash(key [32]byte, + leaf customAnchorWalletLeafEvidence) ([32]byte, error) { + + script, err := hex.DecodeString(leaf.Script) + if err != nil { + return [32]byte{}, fmt.Errorf("decode script: %w", err) + } + if len(script) > math.MaxUint32 { + return [32]byte{}, fmt.Errorf("script too large") + } + preimage := bytes.NewBufferString(customAnchorUTXOLeafDomain) + _, _ = preimage.Write(key[:]) + requireWriteUint64(preimage, leaf.Value) + requireWriteUint32(preimage, uint32(len(script))) + _, _ = preimage.Write(script) + + return sha256.Sum256(preimage.Bytes()), nil +} + +func customAnchorSparseNode(left, right [32]byte) [32]byte { + preimage := make([]byte, 0, len(customAnchorUTXONodeDomain)+64) + preimage = append(preimage, customAnchorUTXONodeDomain...) + preimage = append(preimage, left[:]...) + preimage = append(preimage, right[:]...) + + return sha256.Sum256(preimage) +} + +func customAnchorPrefix(key [32]byte, bitCount int) [32]byte { + if bitCount == 256 { + return key + } + result := key + byteIndex := bitCount / 8 + remaining := bitCount % 8 + if remaining == 0 { + for idx := byteIndex; idx < len(result); idx++ { + result[idx] = 0 + } + return result + } + result[byteIndex] &= byte(0xff << (8 - remaining)) + for idx := byteIndex + 1; idx < len(result); idx++ { + result[idx] = 0 + } + + return result +} + +func customAnchorSetBit(key [32]byte, bitIndex int, value bool) [32]byte { + mask := byte(1 << (7 - bitIndex%8)) + if value { + key[bitIndex/8] |= mask + } else { + key[bitIndex/8] &^= mask + } + + return key +} + +func newCustomAnchorSparseTree(utxos []*lnrpc.Utxo) ( + *customAnchorSparseTree, error) { + + tree := &customAnchorSparseTree{ + leaves: make(map[[32]byte]customAnchorWalletLeafEvidence), + } + tree.defaults[256] = sha256.Sum256( + []byte(customAnchorUTXOEmptyDomain), + ) + for depth := 255; depth >= 0; depth-- { + tree.defaults[depth] = customAnchorSparseNode( + tree.defaults[depth+1], tree.defaults[depth+1], + ) + } + tree.levels[256] = make(map[[32]byte][32]byte) + outpoints := make(map[string]struct{}, len(utxos)) + for _, utxo := range utxos { + leaf, err := customAnchorWalletLeaf(utxo) + if err != nil { + return nil, err + } + outpointID := fmt.Sprintf("%s:%d", leaf.Outpoint.TxID, + leaf.Outpoint.OutputIndex) + if _, ok := outpoints[outpointID]; ok { + return nil, fmt.Errorf("duplicate UTXO %s", outpointID) + } + outpoints[outpointID] = struct{}{} + key, err := customAnchorSparseKey(leaf.Outpoint) + if err != nil { + return nil, err + } + if _, ok := tree.leaves[key]; ok { + return nil, fmt.Errorf("sparse key collision for %s", outpointID) + } + leafHash, err := customAnchorSparseLeafHash(key, leaf) + if err != nil { + return nil, err + } + tree.leaves[key] = leaf + tree.levels[256][key] = leafHash + } + for depth := 255; depth >= 0; depth-- { + parents := make(map[[32]byte]struct{}) + for child := range tree.levels[depth+1] { + parents[customAnchorPrefix(child, depth)] = struct{}{} + } + tree.levels[depth] = make(map[[32]byte][32]byte, len(parents)) + for parent := range parents { + leftKey := customAnchorSetBit(parent, depth, false) + rightKey := customAnchorSetBit(parent, depth, true) + left, ok := tree.levels[depth+1][leftKey] + if !ok { + left = tree.defaults[depth+1] + } + right, ok := tree.levels[depth+1][rightKey] + if !ok { + right = tree.defaults[depth+1] + } + tree.levels[depth][parent] = customAnchorSparseNode( + left, right, + ) + } + } + + return tree, nil +} + +func (t *customAnchorSparseTree) root() [32]byte { + root, ok := t.levels[0][[32]byte{}] + if !ok { + return t.defaults[0] + } + + return root +} + +func (t *customAnchorSparseTree) proof(outpoint wire.OutPoint, + present bool) (customAnchorSparseProofEvidence, error) { + + key, err := customAnchorSparseKey( + customAnchorOutpointEvidenceFromWire(outpoint), + ) + if err != nil { + return customAnchorSparseProofEvidence{}, err + } + leaf, exists := t.leaves[key] + if exists != present { + return customAnchorSparseProofEvidence{}, fmt.Errorf( + "outpoint %s presence=%v, expected=%v", outpoint, exists, + present) + } + siblings := make([]string, 0, 256) + for proofIndex := 0; proofIndex < 256; proofIndex++ { + depth := 255 - proofIndex + siblingKey := customAnchorPrefix(key, depth+1) + bitSet := key[depth/8]&(1<<(7-depth%8)) != 0 + siblingKey = customAnchorSetBit(siblingKey, depth, !bitSet) + sibling, ok := t.levels[depth+1][siblingKey] + if !ok { + sibling = t.defaults[depth+1] + } + siblings = append(siblings, hex.EncodeToString(sibling[:])) + } + proof := customAnchorSparseProofEvidence{ + Key: hex.EncodeToString(key[:]), Siblings: siblings, + } + if present { + leafCopy := leaf + proof.Leaf = &leafCopy + } + + return proof, nil +} + +func verifyCustomAnchorSparseProof(proof customAnchorSparseProofEvidence, + root [32]byte, present bool) error { + + keyBytes, err := hex.DecodeString(proof.Key) + if err != nil || len(keyBytes) != sha256.Size { + return fmt.Errorf("invalid proof key") + } + var key [32]byte + copy(key[:], keyBytes) + if len(proof.Siblings) != 256 { + return fmt.Errorf("proof must have 256 siblings") + } + var current [32]byte + if present { + if proof.Leaf == nil { + return fmt.Errorf("membership proof missing leaf") + } + expectedKey, err := customAnchorSparseKey(proof.Leaf.Outpoint) + if err != nil || expectedKey != key { + return fmt.Errorf("leaf key mismatch") + } + current, err = customAnchorSparseLeafHash(key, *proof.Leaf) + if err != nil { + return err + } + } else { + if proof.Leaf != nil { + return fmt.Errorf("non-membership proof contains leaf") + } + current = sha256.Sum256([]byte(customAnchorUTXOEmptyDomain)) + } + for proofIndex, siblingHex := range proof.Siblings { + siblingBytes, err := hex.DecodeString(siblingHex) + if err != nil || len(siblingBytes) != sha256.Size { + return fmt.Errorf("invalid sibling %d", proofIndex) + } + var sibling [32]byte + copy(sibling[:], siblingBytes) + bitIndex := 255 - proofIndex + bitSet := key[bitIndex/8]&(1<<(7-bitIndex%8)) != 0 + if bitSet { + current = customAnchorSparseNode(sibling, current) + } else { + current = customAnchorSparseNode(current, sibling) + } + } + if current != root { + return fmt.Errorf("root mismatch") + } + + return nil +} + +type customAnchorChainSource interface { + GetBestBlock() (*chainhash.Hash, int32) + GetBlock(*chainhash.Hash) *wire.MsgBlock +} + +func collectCustomAnchorChainEvidence(source customAnchorChainSource, + startHeight uint32, startHash chainhash.Hash) ( + customAnchorChainEvidence, error) { + + tipHash, tipHeightSigned := source.GetBestBlock() + if tipHeightSigned < 0 || uint32(tipHeightSigned) < startHeight { + return customAnchorChainEvidence{}, fmt.Errorf("invalid chain height") + } + tipHeight := uint32(tipHeightSigned) + blocks := make([]customAnchorChainBlockEvidence, 0, + tipHeight-startHeight+1) + currentHash := *tipHash + for height := tipHeight; ; height-- { + block := source.GetBlock(¤tHash) + if block.BlockHash() != currentHash { + return customAnchorChainEvidence{}, fmt.Errorf( + "block hash mismatch at height %d", height) + } + if err := validateCustomAnchorRegtestBits(block); err != nil { + return customAnchorChainEvidence{}, fmt.Errorf( + "block %d: %w", height, err) + } + var raw bytes.Buffer + if err := block.Serialize(&raw); err != nil { + return customAnchorChainEvidence{}, err + } + blocks = append(blocks, customAnchorChainBlockEvidence{ + Height: height, Hash: currentHash.String(), + RawBlock: hex.EncodeToString(raw.Bytes()), + }) + if height == startHeight { + if currentHash != startHash { + return customAnchorChainEvidence{}, fmt.Errorf( + "chain does not reach funding block") + } + break + } + currentHash = block.Header.PrevBlock + } + for left, right := 0, len(blocks)-1; left < right; left, right = + left+1, right-1 { + + blocks[left], blocks[right] = blocks[right], blocks[left] + } + + return customAnchorChainEvidence{ + Network: "regtest", StartHeight: startHeight, + TipHeight: tipHeight, TipHash: tipHash.String(), Blocks: blocks, + }, nil +} + +func validateCustomAnchorRegtestBits(block *wire.MsgBlock) error { + if block == nil { + return fmt.Errorf("nil block") + } + expected := chaincfg.RegressionNetParams.PowLimitBits + if block.Header.Bits != expected { + return fmt.Errorf("unexpected regtest bits %08x, want %08x", + block.Header.Bits, expected) + } + + return nil +} + +func customAnchorChainTxHeight(chain customAnchorChainEvidence, + txID chainhash.Hash) (uint32, bool) { + + for _, blockEvidence := range chain.Blocks { + raw, err := hex.DecodeString(blockEvidence.RawBlock) + if err != nil { + return 0, false + } + var block wire.MsgBlock + if err := block.Deserialize(bytes.NewReader(raw)); err != nil { + return 0, false + } + for _, tx := range block.Transactions { + if tx.TxHash() == txID { + return blockEvidence.Height, true + } + } + } + + return 0, false +} + +func customAnchorAuthorityCommitment(campaignID, cohortID, + producerDigest string, identity []byte, + wallet customAnchorWalletCommitmentEvidence, + foreign wire.OutPoint, feeOutpoints []wire.OutPoint, + locator *signrpc.KeyLocator, anchorXOnly []byte, + chain customAnchorChainEvidence) ([32]byte, error) { + + if len(identity) != btcec.PubKeyBytesLenCompressed { + return [32]byte{}, fmt.Errorf("invalid identity length") + } + if len(anchorXOnly) != schnorr.PubKeyBytesLen { + return [32]byte{}, fmt.Errorf("invalid anchor key length") + } + root, err := hex.DecodeString(wallet.Root) + if err != nil || len(root) != sha256.Size { + return [32]byte{}, fmt.Errorf("invalid wallet root") + } + tipHash, err := hex.DecodeString(chain.TipHash) + if err != nil || len(tipHash) != chainhash.HashSize { + return [32]byte{}, fmt.Errorf("invalid tip hash") + } + foreignBytes, err := customAnchorOutpointBytes( + customAnchorOutpointEvidenceFromWire(foreign), + ) + if err != nil { + return [32]byte{}, err + } + feeBytes := make([][]byte, 0, len(feeOutpoints)) + seenFees := make(map[string]struct{}, len(feeOutpoints)) + for _, outpoint := range feeOutpoints { + encoded, err := customAnchorOutpointBytes( + customAnchorOutpointEvidenceFromWire(outpoint), + ) + if err != nil { + return [32]byte{}, err + } + id := string(encoded) + if _, ok := seenFees[id]; ok { + return [32]byte{}, fmt.Errorf("duplicate fee outpoint") + } + seenFees[id] = struct{}{} + feeBytes = append(feeBytes, encoded) + } + sort.Slice(feeBytes, func(i, j int) bool { + return bytes.Compare(feeBytes[i], feeBytes[j]) < 0 + }) + + preimage := bytes.NewBufferString(customAnchorAuthorityCommitmentDomain) + for _, value := range []string{campaignID, cohortID, producerDigest} { + if err := writeCustomAnchorLenPrefixed(preimage, []byte(value)); err != nil { + + return [32]byte{}, err + } + } + _, _ = preimage.Write(identity) + requireWriteUint32(preimage, uint32(wallet.Query.MinConfirmations)) + requireWriteUint32(preimage, uint32(wallet.Query.MaxConfirmations)) + if err := writeCustomAnchorLenPrefixed( + preimage, []byte(wallet.Query.Account), + ); err != nil { + return [32]byte{}, err + } + if wallet.Query.UnconfirmedOnly { + _ = preimage.WriteByte(1) + } else { + _ = preimage.WriteByte(0) + } + _, _ = preimage.Write(root) + requireWriteUint64(preimage, wallet.Count) + _, _ = preimage.Write(foreignBytes) + if len(feeBytes) > math.MaxUint32 { + return [32]byte{}, fmt.Errorf("too many fee outpoints") + } + requireWriteUint32(preimage, uint32(len(feeBytes))) + for _, encoded := range feeBytes { + _, _ = preimage.Write(encoded) + } + requireWriteUint32(preimage, uint32(locator.KeyFamily)) + requireWriteUint32(preimage, uint32(locator.KeyIndex)) + _, _ = preimage.Write(anchorXOnly) + _, _ = preimage.Write(tipHash) + requireWriteUint32(preimage, chain.TipHeight) + + return sha256.Sum256(preimage.Bytes()), nil +} + +func writeCustomAnchorLenPrefixed(buffer *bytes.Buffer, value []byte) error { + if len(value) > math.MaxUint32 { + return fmt.Errorf("value too large") + } + requireWriteUint32(buffer, uint32(len(value))) + _, _ = buffer.Write(value) + + return nil +} + +func requireWriteUint32(buffer *bytes.Buffer, value uint32) { + var encoded [4]byte + binary.BigEndian.PutUint32(encoded[:], value) + _, _ = buffer.Write(encoded[:]) +} + +func requireWriteUint64(buffer *bytes.Buffer, value uint64) { + var encoded [8]byte + binary.BigEndian.PutUint64(encoded[:], value) + _, _ = buffer.Write(encoded[:]) +} + +func customAnchorProducerBinarySHA256(t *testing.T) string { + digest := os.Getenv("TAPD_ITEST_PRODUCER_SHA256") + if os.Getenv("TAPD_ITEST_EVIDENCE_DIR") == "" { + return digest + } + + require.Regexp(t, "^[0-9a-f]{64}$", digest, + "TAPD_ITEST_PRODUCER_SHA256 must be lowercase SHA256 hex") + + return digest +} + +func writeCustomAnchorMuSig2Evidence(t *testing.T, + evidence customAnchorMuSig2Evidence) { + + evidenceDir := os.Getenv("TAPD_ITEST_EVIDENCE_DIR") + if evidenceDir == "" { + return + } + require.NoError(t, os.MkdirAll(evidenceDir, 0o700)) + evidenceBytes, err := json.MarshalIndent(evidence, "", " ") + require.NoError(t, err) + evidenceBytes = append(evidenceBytes, '\n') + evidencePath := filepath.Join( + evidenceDir, "custom_anchor_foreign_musig2.json", + ) + require.NoError(t, os.WriteFile(evidencePath, evidenceBytes, 0o600)) + t.Logf("Wrote custom anchor MuSig2 evidence: %s", evidencePath) +} + +func deriveMuSig2Key(t *testing.T, + lnd *rpc.HarnessRPC) keychain.KeyDescriptor { + + keyResp := lnd.DeriveNextKey(&walletrpc.KeyReq{ + KeyFamily: int32(asset.TaprootAssetsKeyFamily), + }) + pubKey, err := btcec.ParsePubKey(keyResp.RawKeyBytes) + require.NoError(t, err) + + return keychain.KeyDescriptor{ + PubKey: pubKey, + KeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamily(keyResp.KeyLoc.KeyFamily), + Index: uint32(keyResp.KeyLoc.KeyIndex), + }, + } +} + +func signForeignMuSig2Input(t *testing.T, signerA, signerB *rpc.HarnessRPC, + keyA, keyB keychain.KeyDescriptor, finalKey *btcec.PublicKey, + pkt *psbt.Packet) ([]byte, customAnchorMuSig2SigningEvidence) { + + noncesA, err := musig2.GenNonces(musig2.WithPublicKey(keyA.PubKey)) + require.NoError(t, err) + noncesB, err := musig2.GenNonces(musig2.WithPublicKey(keyB.PubKey)) + require.NoError(t, err) + sessionA := tapMuSig2Session( + t, signerA, keyA, keyB.PubKey.SerializeCompressed(), *noncesA, + [][]byte{noncesB.PubNonce[:]}, + ) + sessionB := tapMuSig2Session( + t, signerB, keyB, keyA.PubKey.SerializeCompressed(), *noncesB, + [][]byte{noncesA.PubNonce[:]}, + ) + + prevOuts := make(map[wire.OutPoint]*wire.TxOut, len(pkt.Inputs)) + for idx, txIn := range pkt.UnsignedTx.TxIn { + require.NotNil(t, pkt.Inputs[idx].WitnessUtxo) + prevOuts[txIn.PreviousOutPoint] = pkt.Inputs[idx].WitnessUtxo + } + prevOutFetcher := txscript.NewMultiPrevOutFetcher(prevOuts) + sigHashes := txscript.NewTxSigHashes(pkt.UnsignedTx, prevOutFetcher) + sigHash, err := txscript.CalcTaprootSignatureHash( + sigHashes, txscript.SigHashDefault, pkt.UnsignedTx, 0, + prevOutFetcher, + ) + require.NoError(t, err) + + partialA, err := signerA.Signer.MuSig2Sign( + context.Background(), &signrpc.MuSig2SignRequest{ + SessionId: sessionA, MessageDigest: sigHash, + }, + ) + require.NoError(t, err) + partialB, err := signerB.Signer.MuSig2Sign( + context.Background(), &signrpc.MuSig2SignRequest{ + SessionId: sessionB, MessageDigest: sigHash, + }, + ) + require.NoError(t, err) + require.Len(t, partialA.LocalPartialSignature, 32) + require.Len(t, partialB.LocalPartialSignature, 32) + require.NotEqual(t, partialA.LocalPartialSignature, + partialB.LocalPartialSignature) + + combinedA, err := signerA.Signer.MuSig2CombineSig( + context.Background(), &signrpc.MuSig2CombineSigRequest{ + SessionId: sessionA, + OtherPartialSignatures: [][]byte{ + partialB.LocalPartialSignature, + }, + }, + ) + require.NoError(t, err) + require.True(t, combinedA.HaveAllSignatures) + combinedB, err := signerB.Signer.MuSig2CombineSig( + context.Background(), &signrpc.MuSig2CombineSigRequest{ + SessionId: sessionB, + OtherPartialSignatures: [][]byte{ + partialA.LocalPartialSignature, + }, + }, + ) + require.NoError(t, err) + require.True(t, combinedB.HaveAllSignatures) + require.Equal(t, combinedA.FinalSignature, combinedB.FinalSignature) + finalSig, err := schnorr.ParseSignature(combinedA.FinalSignature) + require.NoError(t, err) + require.True(t, finalSig.Verify(sigHash, finalKey)) + + return combinedA.FinalSignature, customAnchorMuSig2SigningEvidence{ + ParticipantPubKeys: []string{ + hex.EncodeToString(keyA.PubKey.SerializeCompressed()), + hex.EncodeToString(keyB.PubKey.SerializeCompressed()), + }, + PublicNonces: []string{ + hex.EncodeToString(noncesA.PubNonce[:]), + hex.EncodeToString(noncesB.PubNonce[:]), + }, + PartialSignatures: []string{ + hex.EncodeToString(partialA.LocalPartialSignature), + hex.EncodeToString(partialB.LocalPartialSignature), + }, + FinalSignature: hex.EncodeToString(combinedA.FinalSignature), + } +} + +func assertAllInputsScriptValid(t *testing.T, pkt *psbt.Packet, + tx *wire.MsgTx) { + + prevOuts := make(map[wire.OutPoint]*wire.TxOut, len(pkt.Inputs)) + for idx, txIn := range tx.TxIn { + require.NotNil(t, pkt.Inputs[idx].WitnessUtxo) + prevOuts[txIn.PreviousOutPoint] = pkt.Inputs[idx].WitnessUtxo + } + prevOutFetcher := txscript.NewMultiPrevOutFetcher(prevOuts) + sigHashes := txscript.NewTxSigHashes(tx, prevOutFetcher) + for idx, txIn := range tx.TxIn { + prevOut := prevOuts[txIn.PreviousOutPoint] + vm, err := txscript.NewEngine( + prevOut.PkScript, tx, idx, txscript.StandardVerifyFlags, + nil, sigHashes, prevOut.Value, prevOutFetcher, + ) + require.NoError(t, err, "input %d script engine", idx) + require.NoError(t, vm.Execute(), "input %d script", idx) + } +} + +// testMintCustomAnchorPsbt proves the complete issue #721 lifecycle with a +// wallet-owned, caller-authored anchor: fund, prepare, externally sign, +// finalize, confirm, and spend the minted asset to a second tapd node. +func testMintCustomAnchorPsbt(t *harnessTest) { + var ( + ctx = context.Background() + aliceTapd = t.tapd + aliceLnd = t.tapd.cfg.LndNode + miner = t.lndHarness.Miner() + ) + + bobLnd := t.lndHarness.NewNodeWithCoins("custom-anchor-bob", nil) + bobTapd := setupTapdHarness(t.t, t, bobLnd, t.universeServer) + defer func() { + require.NoError(t.t, bobTapd.stop(!*noDelete)) + }() + + mintReq := CopyRequest(simpleAssets[0]) + mintReq.Asset.Name = "issue-721-custom-anchor-itest" + mintReq.Asset.Amount = 100 + mintReqs := []*mintrpc.MintAssetRequest{mintReq} + + BuildMintingBatch(t.t, aliceTapd, mintReqs) + + // Use an lnd-owned internal key as the caller-selected asset anchor. The + // output derivation fields use lnd's BIP-0043 purpose (1017') so tapd can + // prove the locator belongs to the same wallet before committing it. + anchorKeyResp := aliceLnd.RPC.DeriveNextKey(&walletrpc.KeyReq{ + KeyFamily: int32(asset.TaprootAssetsKeyFamily), + }) + anchorInternalKey, err := btcec.ParsePubKey(anchorKeyResp.RawKeyBytes) + require.NoError(t.t, err) + anchorOutputKey := txscript.ComputeTaprootKeyNoScript(anchorInternalKey) + anchorScript, err := txscript.PayToTaprootScript(anchorOutputKey) + require.NoError(t.t, err) + + const anchorValue = int64(10_000) + tx := wire.NewMsgTx(2) + tx.AddTxOut(&wire.TxOut{ + Value: anchorValue, + PkScript: anchorScript, + }) + template, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t.t, err) + anchorKeyDesc := keychain.KeyDescriptor{ + PubKey: anchorInternalKey, + KeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamily(anchorKeyResp.KeyLoc.KeyFamily), + Index: uint32(anchorKeyResp.KeyLoc.KeyIndex), + }, + } + bip32Derivation, taprootDerivation := + tappsbt.Bip32DerivationFromKeyDesc( + anchorKeyDesc, harnessNetParams.HDCoinType, + ) + template.Outputs[0].Bip32Derivation = []*psbt.Bip32Derivation{ + bip32Derivation, + } + template.Outputs[0].TaprootBip32Derivation = + []*psbt.TaprootBip32Derivation{taprootDerivation} + template.Outputs[0].TaprootInternalKey = + taprootDerivation.XOnlyPubKey + template.Outputs[0].Unknowns = []*psbt.Unknown{{ + Key: []byte{0x50}, Value: []byte("issue-721-anchor"), + }} + internalKey, err := schnorr.ParsePubKey(taprootDerivation.XOnlyPubKey) + require.NoError(t.t, err) + bip86OutputKey := txscript.ComputeTaprootKeyNoScript(internalKey) + require.Equal(t.t, schnorr.SerializePubKey(bip86OutputKey), + anchorScript[2:]) + + templateBytes, err := fn.Serialize(template) + require.NoError(t.t, err) + fundResp := aliceLnd.RPC.FundPsbt(&walletrpc.FundPsbtRequest{ + Template: &walletrpc.FundPsbtRequest_CoinSelect{ + CoinSelect: &walletrpc.PsbtCoinSelect{ + Psbt: templateBytes, + ChangeOutput: &walletrpc.PsbtCoinSelect_Add{ + Add: true, + }, + }, + }, + Fees: &walletrpc.FundPsbtRequest_SatPerVbyte{ + SatPerVbyte: 2, + }, + MinConfs: 1, + ChangeType: walletrpc.ChangeAddressType_CHANGE_ADDRESS_TYPE_P2TR, + MaxFeeRatio: 1, + }) + require.NotEmpty(t.t, fundResp.LockedUtxos) + require.GreaterOrEqual(t.t, fundResp.ChangeOutputIndex, int32(1)) + + fundedPacket, err := psbt.NewFromRawBytes( + bytes.NewReader(fundResp.FundedPsbt), false, + ) + require.NoError(t.t, err) + require.NotEmpty(t.t, fundedPacket.Inputs) + require.Equal(t.t, anchorValue, + fundedPacket.UnsignedTx.TxOut[0].Value) + require.Equal(t.t, anchorScript, + fundedPacket.UnsignedTx.TxOut[0].PkScript) + + // FundPsbt initially leases every selected wallet input under its own + // lock ID. Release those leases before FundBatch asks tapd to acquire + // the same inputs under the custom-anchor lease ID. + for _, lease := range fundResp.LockedUtxos { + _, err := aliceLnd.RPC.WalletKit.ReleaseOutput( + ctx, &walletrpc.ReleaseOutputRequest{ + Id: lease.Id, + Outpoint: lease.Outpoint, + }, + ) + require.NoError(t.t, err) + } + + fundBatchResp, err := aliceTapd.FundBatch( + ctx, &mintrpc.FundBatchRequest{ + AnchorPsbt: fundResp.FundedPsbt, + AssetAnchorOutputIndex: 0, + ChangeOutputIndex: fundResp.ChangeOutputIndex, + }, + ) + require.NoError(t.t, err) + require.Equal( + t.t, mintrpc.BatchState_BATCH_STATE_PENDING, + fundBatchResp.Batch.Batch.State, + ) + + prepareResp, err := aliceTapd.PrepareBatch( + ctx, &mintrpc.PrepareBatchRequest{}, + ) + require.NoError(t.t, err) + require.Equal( + t.t, mintrpc.BatchState_BATCH_STATE_COMMITTED, + prepareResp.Batch.State, + ) + preparedPacket, err := psbt.NewFromRawBytes( + bytes.NewReader(prepareResp.Batch.BatchPsbt), false, + ) + require.NoError(t.t, err) + + // Preparation is allowed to replace only the selected anchor script. + require.Equal(t.t, fundedPacket.UnsignedTx.TxIn, + preparedPacket.UnsignedTx.TxIn) + require.Equal(t.t, fundedPacket.Inputs, preparedPacket.Inputs) + require.Equal(t.t, anchorValue, + preparedPacket.UnsignedTx.TxOut[0].Value) + require.NotEqual(t.t, anchorScript, + preparedPacket.UnsignedTx.TxOut[0].PkScript) + require.Equal(t.t, fundedPacket.Outputs[0].Bip32Derivation, + preparedPacket.Outputs[0].Bip32Derivation) + require.Equal(t.t, fundedPacket.Outputs[0].Unknowns, + preparedPacket.Outputs[0].Unknowns) + require.Nil(t.t, preparedPacket.Outputs[0].TaprootInternalKey) + require.Nil(t.t, preparedPacket.Outputs[0].TaprootBip32Derivation) + for idx := 1; idx < len(fundedPacket.UnsignedTx.TxOut); idx++ { + require.Equal(t.t, fundedPacket.UnsignedTx.TxOut[idx], + preparedPacket.UnsignedTx.TxOut[idx]) + require.Equal(t.t, fundedPacket.Outputs[idx], + preparedPacket.Outputs[idx]) + } + + // WalletKit signs the wallet input and local PSBT finalization is used + // for compatibility with the remote-signing itest mode. + signedPacket := FinalizePacket(t.t, aliceLnd.RPC, preparedPacket) + signedBytes, err := fn.Serialize(signedPacket) + require.NoError(t.t, err) + ctxFinalize, cancelFinalize := context.WithTimeout(ctx, defaultWaitTimeout) + finalizeResp, err := aliceTapd.FinalizeBatch( + ctxFinalize, &mintrpc.FinalizeBatchRequest{ + SignedPsbt: signedBytes, + }, + ) + cancelFinalize() + require.NoError(t.t, err) + require.Equal( + t.t, mintrpc.BatchState_BATCH_STATE_BROADCAST, + finalizeResp.Batch.State, + ) + + hashes, err := WaitForNTxsInMempool(miner, 1, defaultWaitTimeout) + require.NoError(t.t, err) + require.Len(t.t, hashes, 1) + block := MineBlocks(t.t, miner, 1, 1)[0] + ctxWait, cancelWait := context.WithTimeout(ctx, defaultWaitTimeout) + defer cancelWait() + WaitForBatchState( + t.t, ctxWait, aliceTapd, defaultWaitTimeout, + finalizeResp.Batch.BatchKey, + mintrpc.BatchState_BATCH_STATE_FINALIZED, + ) + mintedAssets := AssertAssetsMinted( + t.t, aliceTapd, mintReqs, *hashes[0], block.BlockHash(), + ) + require.Len(t.t, mintedAssets, 1) + mintedAsset := mintedAssets[0] + + const sendAmount = uint64(40) + bobAddr, err := bobTapd.NewAddr(ctx, &taprpc.NewAddrRequest{ + AssetId: mintedAsset.AssetGenesis.AssetId, + Amt: sendAmount, + }) + require.NoError(t.t, err) + AssertAddrCreated(t.t, bobTapd, mintedAsset, bobAddr) + + sendResp, sendEvents := sendAssetsToAddr(t, aliceTapd, bobAddr) + ConfirmAndAssertOutboundTransfer( + t.t, miner, aliceTapd, sendResp, + mintedAsset.AssetGenesis.AssetId, + []uint64{mintedAsset.Amount - sendAmount, sendAmount}, 0, 1, + ) + AssertNonInteractiveRecvComplete(t.t, bobTapd, 1) + AssertSendEventsComplete(t.t, bobAddr.ScriptKey, sendEvents) + AssertBalanceByID( + t.t, bobTapd, mintedAsset.AssetGenesis.AssetId, sendAmount, + ) +} diff --git a/itest/test_list_on_test.go b/itest/test_list_on_test.go index 070dcd8e92..5c03b9b14e 100644 --- a/itest/test_list_on_test.go +++ b/itest/test_list_on_test.go @@ -49,6 +49,14 @@ var allTestCases = []*testCase{ name: "mint fund seal assets", test: testMintFundSealAssets, }, + { + name: "mint custom anchor psbt", + test: testMintCustomAnchorPsbt, + }, + { + name: "mint custom anchor foreign musig2 psbt", + test: testMintCustomAnchorMuSig2Psbt, + }, { name: "mint external group key chantools", test: testMintExternalGroupKeyChantools, From 7b072890ea271adcc0e4c20b14c1611ac7fbfa8f Mon Sep 17 00:00:00 2001 From: Sadiq Jaffer Date: Fri, 21 Aug 2026 09:14:16 +0100 Subject: [PATCH 11/24] tapgarden: scope custom anchor leases per batch --- lndservices/custom_anchor_lease_test.go | 89 ++++++++++++++++- lndservices/wallet_anchor.go | 36 +++---- tapgarden/caretaker.go | 13 ++- tapgarden/custom_anchor_test.go | 121 +++++++++++++++++++++++- tapgarden/planter.go | 85 +++++++++++++---- tapnode/tapnodemock/wallet_anchor.go | 7 +- tapnode/wallet_anchor.go | 28 ++++-- 7 files changed, 324 insertions(+), 55 deletions(-) diff --git a/lndservices/custom_anchor_lease_test.go b/lndservices/custom_anchor_lease_test.go index fa44d4ca75..cf1b061113 100644 --- a/lndservices/custom_anchor_lease_test.go +++ b/lndservices/custom_anchor_lease_test.go @@ -9,6 +9,7 @@ import ( "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btcwallet/wtxmgr" "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/taproot-assets/tapnode" "github.com/lightningnetwork/lnd/lnwallet" "github.com/stretchr/testify/require" ) @@ -17,6 +18,9 @@ type customAnchorWalletKitMock struct { lndclient.WalletKitClient leases []lndclient.LeaseDescriptor + utxos []*lnwallet.Utxo + leaseCalls []lndclient.LeaseDescriptor + releaseCalls []lndclient.LeaseDescriptor listUnspentCalled bool } @@ -30,7 +34,47 @@ func (m *customAnchorWalletKitMock) ListUnspent(context.Context, int32, int32, ...lndclient.ListUnspentOption) ([]*lnwallet.Utxo, error) { m.listUnspentCalled = true - return nil, nil + return m.utxos, nil +} + +func (m *customAnchorWalletKitMock) LeaseOutput(_ context.Context, + lockID wtxmgr.LockID, op wire.OutPoint, + duration time.Duration) (time.Time, error) { + + expiration := time.Now().Add(duration) + lease := lndclient.LeaseDescriptor{ + LockID: lockID, + Outpoint: op, + Expiration: expiration, + } + for idx := range m.leases { + if m.leases[idx].Outpoint == op { + m.leases[idx] = lease + m.leaseCalls = append(m.leaseCalls, lease) + return expiration, nil + } + } + + m.leases = append(m.leases, lease) + m.leaseCalls = append(m.leaseCalls, lease) + return expiration, nil +} + +func (m *customAnchorWalletKitMock) ReleaseOutput(_ context.Context, + lockID wtxmgr.LockID, op wire.OutPoint) error { + + for idx := range m.leases { + lease := m.leases[idx] + if lease.Outpoint != op || lease.LockID != lockID { + continue + } + + m.releaseCalls = append(m.releaseCalls, lease) + m.leases = append(m.leases[:idx], m.leases[idx+1:]...) + return nil + } + + return nil } func TestCustomAnchorLeaseRejectsForeignLock(t *testing.T) { @@ -46,8 +90,47 @@ func TestCustomAnchorLeaseRejectsForeignLock(t *testing.T) { WalletKit: walletKit, }) - owned, err := wallet.LeaseInput(t.Context(), op) + leaseID := tapnode.CustomAnchorLeaseID(sha256.Sum256([]byte("batch-a"))) + owned, err := wallet.LeaseInput(t.Context(), leaseID, op) require.False(t, owned) - require.ErrorContains(t, err, "already leased by another subsystem") + require.ErrorContains(t, err, "already leased by another") require.False(t, walletKit.listUnspentCalled) } + +func TestCustomAnchorLeaseIsScopedToBatch(t *testing.T) { + var op wire.OutPoint + op.Index = 8 + walletKit := &customAnchorWalletKitMock{ + utxos: []*lnwallet.Utxo{{OutPoint: op}}, + } + wallet := NewLndRpcWalletAnchor(&lndclient.LndServices{ + WalletKit: walletKit, + }) + leaseIDA := tapnode.CustomAnchorLeaseID(sha256.Sum256([]byte("batch-a"))) + leaseIDB := tapnode.CustomAnchorLeaseID(sha256.Sum256([]byte("batch-b"))) + + owned, err := wallet.LeaseInput(t.Context(), leaseIDA, op) + require.NoError(t, err) + require.True(t, owned) + require.Len(t, walletKit.leaseCalls, 1) + require.Equal(t, wtxmgr.LockID(leaseIDA), walletKit.leaseCalls[0].LockID) + + // A second batch cannot renew A's lease or release it by presenting its + // own owner ID. + owned, err = wallet.LeaseInput(t.Context(), leaseIDB, op) + require.False(t, owned) + require.ErrorContains(t, err, "another batch or subsystem") + require.NoError(t, wallet.ReleaseInput(t.Context(), leaseIDB, op)) + require.Empty(t, walletKit.releaseCalls) + require.Len(t, walletKit.leases, 1) + require.Equal(t, wtxmgr.LockID(leaseIDA), walletKit.leases[0].LockID) + + // The rightful owner can renew and release the same lease. + owned, err = wallet.LeaseInput(t.Context(), leaseIDA, op) + require.NoError(t, err) + require.True(t, owned) + require.Len(t, walletKit.leaseCalls, 2) + require.NoError(t, wallet.ReleaseInput(t.Context(), leaseIDA, op)) + require.Len(t, walletKit.releaseCalls, 1) + require.Empty(t, walletKit.leases) +} diff --git a/lndservices/wallet_anchor.go b/lndservices/wallet_anchor.go index 3fc6a016fb..d1f1588144 100644 --- a/lndservices/wallet_anchor.go +++ b/lndservices/wallet_anchor.go @@ -2,7 +2,6 @@ package lndservices import ( "context" - "crypto/sha256" "fmt" "math" "time" @@ -91,10 +90,6 @@ const ( customAnchorLeaseDuration = 24 * time.Hour ) -var customAnchorLeaseID = wtxmgr.LockID(sha256.Sum256( - []byte("tapd-custom-anchor-psbt"), -)) - // FundPsbt attaches enough inputs to the target PSBT packet for it to be // valid. func (l *LndRpcWalletAnchor) FundPsbt(ctx context.Context, packet *psbt.Packet, @@ -212,10 +207,13 @@ func (l *LndRpcWalletAnchor) ImportTaprootOutput(ctx context.Context, return addr, nil } -// LeaseInput leases an input with tapd's custom-anchor lock ID if the input -// belongs to lnd. Inputs owned by another wallet are intentionally ignored. +// LeaseInput leases an input with the calling batch's custom-anchor lock ID if +// the input belongs to lnd. Inputs owned by another wallet are intentionally +// ignored. func (l *LndRpcWalletAnchor) LeaseInput(ctx context.Context, - op wire.OutPoint) (bool, error) { + leaseID tapnode.CustomAnchorLeaseID, op wire.OutPoint) (bool, error) { + + lockID := wtxmgr.LockID(leaseID) // Renew our own lease without depending on whether ListUnspent includes // locked outputs. @@ -227,14 +225,14 @@ func (l *LndRpcWalletAnchor) LeaseInput(ctx context.Context, if lease.Outpoint != op { continue } - if lease.LockID != customAnchorLeaseID { + if lease.LockID != lockID { return false, fmt.Errorf("wallet input is already leased by " + - "another subsystem") + "another batch or subsystem") } - if lease.LockID == customAnchorLeaseID { + if lease.LockID == lockID { _, err := l.lnd.WalletKit.LeaseOutput( - ctx, customAnchorLeaseID, op, customAnchorLeaseDuration, + ctx, lockID, op, customAnchorLeaseDuration, ) return err == nil, err } @@ -250,7 +248,7 @@ func (l *LndRpcWalletAnchor) LeaseInput(ctx context.Context, } _, err := l.lnd.WalletKit.LeaseOutput( - ctx, customAnchorLeaseID, op, customAnchorLeaseDuration, + ctx, lockID, op, customAnchorLeaseDuration, ) if err != nil { return false, fmt.Errorf("unable to lease wallet input: %w", err) @@ -262,21 +260,24 @@ func (l *LndRpcWalletAnchor) LeaseInput(ctx context.Context, return false, nil } -// ReleaseInput releases only tapd's custom-anchor lease for an input. +// ReleaseInput releases only the calling batch's custom-anchor lease for an +// input. func (l *LndRpcWalletAnchor) ReleaseInput(ctx context.Context, - op wire.OutPoint) error { + leaseID tapnode.CustomAnchorLeaseID, op wire.OutPoint) error { + + lockID := wtxmgr.LockID(leaseID) leases, err := l.lnd.WalletKit.ListLeases(ctx) if err != nil { return fmt.Errorf("error listing existing leases: %w", err) } for _, lease := range leases { - if lease.Outpoint != op || lease.LockID != customAnchorLeaseID { + if lease.Outpoint != op || lease.LockID != lockID { continue } if err := l.lnd.WalletKit.ReleaseOutput( - ctx, customAnchorLeaseID, op, + ctx, lockID, op, ); err != nil { return fmt.Errorf("error releasing custom anchor lease: %w", err) @@ -363,5 +364,6 @@ func (l *LndRpcWalletAnchor) MinRelayFee( // A compile time assertion to ensure LndRpcWalletAnchor meets the // tapnode.WalletAnchor interface. var _ tapnode.WalletAnchor = (*LndRpcWalletAnchor)(nil) +var _ tapnode.CustomAnchorLeaser = (*LndRpcWalletAnchor)(nil) var _ tapfreighter.WalletAnchor = (*LndRpcWalletAnchor)(nil) diff --git a/tapgarden/caretaker.go b/tapgarden/caretaker.go index 23000787c2..382a807688 100644 --- a/tapgarden/caretaker.go +++ b/tapgarden/caretaker.go @@ -350,7 +350,9 @@ func (b *BatchCaretaker) Cancel() error { isCustomAnchorPsbt(b.cfg.Batch.GenesisPacket.Pkt) { releaseErr := releaseCustomAnchorLeases( - ctx, b.cfg.Wallet, b.cfg.Batch.GenesisPacket, + ctx, b.cfg.Wallet, + customAnchorLeaseID(b.cfg.Batch.BatchKey.PubKey), + b.cfg.Batch.GenesisPacket, ) if releaseErr != nil { log.Warnf("Unable to release one or more cancelled "+ @@ -529,7 +531,9 @@ func (b *BatchCaretaker) assetCultivator() { // local input before retrying the exact persisted transaction. ctx, cancel := b.WithCtxQuit() err := renewCustomAnchorLeases( - ctx, b.cfg.Wallet, b.cfg.Batch.GenesisPacket, + ctx, b.cfg.Wallet, + customAnchorLeaseID(b.cfg.Batch.BatchKey.PubKey), + b.cfg.Batch.GenesisPacket, ) cancel() if err != nil { @@ -1093,7 +1097,9 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) if isCustomAnchorPsbt(signedPkt) { renewCtx, renewCancel := b.WithCtxQuit() renewErr := renewCustomAnchorLeases( - renewCtx, b.cfg.Wallet, b.cfg.Batch.GenesisPacket, + renewCtx, b.cfg.Wallet, + customAnchorLeaseID(b.cfg.Batch.BatchKey.PubKey), + b.cfg.Batch.GenesisPacket, ) renewCancel() if renewErr != nil { @@ -1224,6 +1230,7 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) renewCtx, renewCancel := b.WithCtxQuit() renewErr := renewCustomAnchorLeases( renewCtx, b.cfg.Wallet, + customAnchorLeaseID(b.cfg.Batch.BatchKey.PubKey), b.cfg.Batch.GenesisPacket, ) renewCancel() diff --git a/tapgarden/custom_anchor_test.go b/tapgarden/custom_anchor_test.go index 95cd75e541..75f9497679 100644 --- a/tapgarden/custom_anchor_test.go +++ b/tapgarden/custom_anchor_test.go @@ -4,6 +4,8 @@ import ( "bytes" "context" "errors" + "fmt" + "sync" "testing" "github.com/btcsuite/btcd/btcec/v2" @@ -15,6 +17,7 @@ import ( "github.com/lightninglabs/taproot-assets/address" "github.com/lightninglabs/taproot-assets/asset" "github.com/lightninglabs/taproot-assets/fn" + "github.com/lightninglabs/taproot-assets/tapnode" "github.com/lightninglabs/taproot-assets/tapnode/tapnodemock" "github.com/lightninglabs/taproot-assets/tappsbt" "github.com/lightninglabs/taproot-assets/tapsend" @@ -22,6 +25,60 @@ import ( "github.com/stretchr/testify/require" ) +type trackedCustomAnchorWallet struct { + *tapnodemock.WalletAnchor + + mu sync.Mutex + leases map[wire.OutPoint]tapnode.CustomAnchorLeaseID + releases []customAnchorLeaseRequest +} + +type customAnchorLeaseRequest struct { + leaseID tapnode.CustomAnchorLeaseID + op wire.OutPoint +} + +func newTrackedCustomAnchorWallet() *trackedCustomAnchorWallet { + return &trackedCustomAnchorWallet{ + WalletAnchor: tapnodemock.NewWalletAnchor(), + leases: make(map[wire.OutPoint]tapnode.CustomAnchorLeaseID), + } +} + +func (w *trackedCustomAnchorWallet) LeaseInput(_ context.Context, + leaseID tapnode.CustomAnchorLeaseID, op wire.OutPoint) (bool, error) { + + w.mu.Lock() + defer w.mu.Unlock() + + owner, ok := w.leases[op] + if ok && owner != leaseID { + return false, fmt.Errorf("input leased by another batch") + } + + w.leases[op] = leaseID + return true, nil +} + +func (w *trackedCustomAnchorWallet) ReleaseInput(_ context.Context, + leaseID tapnode.CustomAnchorLeaseID, op wire.OutPoint) error { + + w.mu.Lock() + defer w.mu.Unlock() + + if w.leases[op] != leaseID { + return nil + } + + delete(w.leases, op) + w.releases = append(w.releases, customAnchorLeaseRequest{ + leaseID: leaseID, + op: op, + }) + + return nil +} + func TestCustomAnchorLeaseMarkerValidation(t *testing.T) { pkt := testCustomAnchorPacket(t) markCustomAnchorPsbt(pkt) @@ -88,9 +145,10 @@ func TestLegacyCustomAnchorLeaseMarkerReacquiresOwnedInputs(t *testing.T) { funded := &FundedMintAnchorPsbt{ FundedPsbt: tapsend.FundedPsbt{Pkt: pkt}, } + _, batchKey := btcec.PrivKeyFromBytes(bytes.Repeat([]byte{2}, 32)) require.NoError(t, renewCustomAnchorLeases( - context.Background(), wallet, funded, + context.Background(), wallet, customAnchorLeaseID(batchKey), funded, )) require.Equal(t, op, <-wallet.LeaseInputSignal) require.Equal(t, []wire.OutPoint{op}, funded.LockedUTXOs) @@ -106,6 +164,67 @@ func TestLegacyCustomAnchorLeaseMarkerReacquiresOwnedInputs(t *testing.T) { } } +// TestCustomAnchorLeasesAreBatchScoped proves that a failing second batch +// cannot mistake the first batch's input lease for its own and release it +// during rollback. +func TestCustomAnchorLeasesAreBatchScoped(t *testing.T) { + wallet := newTrackedCustomAnchorWallet() + ctx := context.Background() + + _, batchKeyA := btcec.PrivKeyFromBytes(bytes.Repeat([]byte{2}, 32)) + _, batchKeyB := btcec.PrivKeyFromBytes(bytes.Repeat([]byte{3}, 32)) + leaseIDA := customAnchorLeaseID(batchKeyA) + leaseIDB := customAnchorLeaseID(batchKeyB) + require.NotEqual(t, leaseIDA, leaseIDB) + + packetA := testCustomAnchorPacket(t) + inputX := packetA.UnsignedTx.TxIn[0].PreviousOutPoint + lockedA, err := acquireCustomAnchorLeases( + ctx, wallet, leaseIDA, packetA, nil, + ) + require.NoError(t, err) + require.Equal(t, []wire.OutPoint{inputX}, lockedA) + require.Equal(t, leaseIDA, wallet.leases[inputX]) + + // Batch B acquires Y first, then collides with A's X. Its rollback must + // release only Y using B's owner ID, leaving A protected. + packetB := testCustomAnchorPacket(t) + inputY := packetB.UnsignedTx.TxIn[0].PreviousOutPoint + inputY.Index++ + packetB.UnsignedTx.TxIn[0].PreviousOutPoint = inputY + packetB.UnsignedTx.AddTxIn(&wire.TxIn{PreviousOutPoint: inputX}) + packetB.Inputs = append(packetB.Inputs, packetB.Inputs[0]) + + _, err = acquireCustomAnchorLeases( + ctx, wallet, leaseIDB, packetB, nil, + ) + require.ErrorContains(t, err, "another batch") + require.Equal(t, leaseIDA, wallet.leases[inputX]) + _, yStillLeased := wallet.leases[inputY] + require.False(t, yStillLeased) + require.Equal(t, []customAnchorLeaseRequest{{ + leaseID: leaseIDB, + op: inputY, + }}, wallet.releases) + + // Even an explicit B release request for X is ownership-scoped and + // cannot disturb A. A can still renew the recorded lease afterwards. + require.NoError(t, releaseCustomAnchorOutpoints( + ctx, wallet, leaseIDB, []wire.OutPoint{inputX}, + )) + require.Equal(t, leaseIDA, wallet.leases[inputX]) + SetCustomAnchorLockedUTXOs(packetA, lockedA) + require.NoError(t, renewCustomAnchorLeases( + ctx, wallet, leaseIDA, &FundedMintAnchorPsbt{ + FundedPsbt: tapsend.FundedPsbt{ + Pkt: packetA, + LockedUTXOs: lockedA, + }, + }, + )) + require.Equal(t, leaseIDA, wallet.leases[inputX]) +} + func testCustomAnchorPacket(t *testing.T) *psbt.Packet { t.Helper() diff --git a/tapgarden/planter.go b/tapgarden/planter.go index 695574a683..aa48238eaa 100644 --- a/tapgarden/planter.go +++ b/tapgarden/planter.go @@ -3,6 +3,7 @@ package tapgarden import ( "bytes" "context" + "crypto/sha256" "encoding/binary" "encoding/hex" "errors" @@ -523,12 +524,16 @@ func CustomAnchorLockedUTXOs(packet *psbt.Packet) []wire.OutPoint { // wallet while allowing foreign inputs to remain under an external signer's // control. Only leases acquired during this call are rolled back on failure. func acquireCustomAnchorLeases(ctx context.Context, wallet tapnode.WalletAnchor, - packet *psbt.Packet, previouslyLocked []wire.OutPoint) ([]wire.OutPoint, - error) { + leaseID tapnode.CustomAnchorLeaseID, packet *psbt.Packet, + previouslyLocked []wire.OutPoint) ([]wire.OutPoint, error) { if packet == nil || packet.UnsignedTx == nil { return nil, fmt.Errorf("custom anchor PSBT is missing a transaction") } + leaser, ok := wallet.(tapnode.CustomAnchorLeaser) + if !ok { + return nil, fmt.Errorf("wallet does not support custom anchor leases") + } previous := make(map[wire.OutPoint]struct{}, len(previouslyLocked)) for _, op := range previouslyLocked { @@ -542,17 +547,17 @@ func acquireCustomAnchorLeases(ctx context.Context, wallet tapnode.WalletAnchor, op := txIn.PreviousOutPoint if _, ok := seen[op]; ok { releaseErr := releaseCustomAnchorOutpoints( - ctx, wallet, newlyLocked, + ctx, wallet, leaseID, newlyLocked, ) return nil, errors.Join(fmt.Errorf( "custom anchor PSBT repeats input %v", op, ), releaseErr) } seen[op] = struct{}{} - owned, err := wallet.LeaseInput(ctx, op) + owned, err := leaser.LeaseInput(ctx, leaseID, op) if err != nil { releaseErr := releaseCustomAnchorOutpoints( - ctx, wallet, newlyLocked, + ctx, wallet, leaseID, newlyLocked, ) return nil, errors.Join( fmt.Errorf("unable to lease custom anchor input %v: %w", @@ -562,7 +567,7 @@ func acquireCustomAnchorLeases(ctx context.Context, wallet tapnode.WalletAnchor, if !owned { if _, wasLocked := previous[op]; wasLocked { releaseErr := releaseCustomAnchorOutpoints( - ctx, wallet, newlyLocked, + ctx, wallet, leaseID, newlyLocked, ) return nil, errors.Join(fmt.Errorf( "unable to renew custom anchor input lease %v", op, @@ -597,11 +602,17 @@ func newlyAcquiredLeases(locked, previous []wire.OutPoint) []wire.OutPoint { } func releaseCustomAnchorOutpoints(ctx context.Context, - wallet tapnode.WalletAnchor, ops []wire.OutPoint) error { + wallet tapnode.WalletAnchor, leaseID tapnode.CustomAnchorLeaseID, + ops []wire.OutPoint) error { + + leaser, ok := wallet.(tapnode.CustomAnchorLeaser) + if !ok { + return fmt.Errorf("wallet does not support custom anchor leases") + } var releaseErr error for _, op := range ops { - if err := wallet.ReleaseInput(ctx, op); err != nil { + if err := leaser.ReleaseInput(ctx, leaseID, op); err != nil { releaseErr = errors.Join(releaseErr, fmt.Errorf( "unable to release custom anchor input %v: %w", op, err, )) @@ -612,13 +623,14 @@ func releaseCustomAnchorOutpoints(ctx context.Context, } func releaseCustomAnchorLeases(ctx context.Context, - wallet tapnode.WalletAnchor, funded *FundedMintAnchorPsbt) error { + wallet tapnode.WalletAnchor, leaseID tapnode.CustomAnchorLeaseID, + funded *FundedMintAnchorPsbt) error { if funded == nil { return nil } if err := releaseCustomAnchorOutpoints( - ctx, wallet, funded.LockedUTXOs, + ctx, wallet, leaseID, funded.LockedUTXOs, ); err != nil { return err } @@ -629,11 +641,15 @@ func releaseCustomAnchorLeases(ctx context.Context, } func renewCustomAnchorLeases(ctx context.Context, wallet tapnode.WalletAnchor, - funded *FundedMintAnchorPsbt) error { + leaseID tapnode.CustomAnchorLeaseID, funded *FundedMintAnchorPsbt) error { if funded == nil { return nil } + leaser, ok := wallet.(tapnode.CustomAnchorLeaser) + if !ok { + return fmt.Errorf("wallet does not support custom anchor leases") + } markerOps, markerState, err := parseCustomAnchorLockedUTXOs(funded.Pkt) if err != nil { @@ -641,7 +657,7 @@ func renewCustomAnchorLeases(ctx context.Context, wallet tapnode.WalletAnchor, } if markerState == customAnchorLeaseMarkerLegacy { locked, err := acquireCustomAnchorLeases( - ctx, wallet, funded.Pkt, nil, + ctx, wallet, leaseID, funded.Pkt, nil, ) if err != nil { return fmt.Errorf("unable to upgrade legacy custom anchor "+ @@ -656,7 +672,7 @@ func renewCustomAnchorLeases(ctx context.Context, wallet tapnode.WalletAnchor, } for _, op := range funded.LockedUTXOs { - owned, err := wallet.LeaseInput(ctx, op) + owned, err := leaser.LeaseInput(ctx, leaseID, op) if err != nil { return fmt.Errorf("unable to renew custom anchor input %v: %w", op, err) @@ -670,6 +686,18 @@ func renewCustomAnchorLeases(ctx context.Context, wallet tapnode.WalletAnchor, return nil } +// customAnchorLeaseID derives a restart-stable, batch-specific lease owner. +// The domain separator prevents the ID from colliding with unrelated uses of +// the batch key. +func customAnchorLeaseID(batchKey *btcec.PublicKey) tapnode.CustomAnchorLeaseID { + preimage := append( + []byte("tapd-custom-anchor-psbt-lease-v1:"), + batchKey.SerializeCompressed()..., + ) + + return tapnode.CustomAnchorLeaseID(sha256.Sum256(preimage)) +} + func isCustomAnchorPsbt(packet *psbt.Packet) bool { if packet == nil { return false @@ -1135,7 +1163,9 @@ func (c *ChainPlanter) Start() error { batchState == BatchStateCommitted) { if err := renewCustomAnchorLeases( - ctx, c.cfg.Wallet, batch.GenesisPacket, + ctx, c.cfg.Wallet, + customAnchorLeaseID(batch.BatchKey.PubKey), + batch.GenesisPacket, ); err != nil { batch.CustomAnchorLeaseError = fmt.Sprintf( "custom anchor input lease renewal degraded during "+ @@ -2947,7 +2977,9 @@ func (c *ChainPlanter) cancelMintingBatch(ctx context.Context, isCustomAnchorPsbt(c.pendingBatch.GenesisPacket.Pkt) { if err := releaseCustomAnchorLeases( - ctx, c.cfg.Wallet, c.pendingBatch.GenesisPacket, + ctx, c.cfg.Wallet, + customAnchorLeaseID(c.pendingBatch.BatchKey.PubKey), + c.pendingBatch.GenesisPacket, ); err != nil { log.Warnf("Unable to release one or more cancelled custom "+ "anchor input leases: %v", err) @@ -3127,7 +3159,9 @@ func (c *ChainPlanter) gardener() { ctx, cancel := c.WithCtxQuit() err := renewCustomAnchorLeases( - ctx, c.cfg.Wallet, batch.GenesisPacket, + ctx, c.cfg.Wallet, + customAnchorLeaseID(batch.BatchKey.PubKey), + batch.GenesisPacket, ) cancel() if err != nil { @@ -3595,7 +3629,9 @@ func (c *ChainPlanter) prepareFunding(ctx context.Context, } locked, err := acquireCustomAnchorLeases( - ctx, c.cfg.Wallet, funded.Pkt, nil, + ctx, c.cfg.Wallet, + customAnchorLeaseID(batch.BatchKey.PubKey), funded.Pkt, + nil, ) if err != nil { return nil, err @@ -3689,7 +3725,8 @@ func (c *ChainPlanter) createFundedBatch(ctx context.Context, if err := c.cfg.Log.CommitMintingBatch(ctx, newBatch); err != nil { releaseErr := releaseCustomAnchorLeases( - ctx, c.cfg.Wallet, mintAnchorTx, + ctx, c.cfg.Wallet, + customAnchorLeaseID(newBatch.BatchKey.PubKey), mintAnchorTx, ) return nil, errors.Join(err, releaseErr) } @@ -3732,7 +3769,8 @@ func (c *ChainPlanter) applyFundingToBatch(ctx context.Context, ) if err != nil { releaseErr := releaseCustomAnchorLeases( - ctx, c.cfg.Wallet, mintAnchorTx, + ctx, c.cfg.Wallet, + customAnchorLeaseID(batch.BatchKey.PubKey), mintAnchorTx, ) return errors.Join( fmt.Errorf("unable to commit batch funding: %w", err), @@ -4579,7 +4617,9 @@ func (c *ChainPlanter) finalizeBatch(params FinalizeParams) (*BatchCaretaker, c.pendingBatch.GenesisPacket.LockedUTXOs, ) locked, err := acquireCustomAnchorLeases( - ctx, c.cfg.Wallet, merged, previousLocks, + ctx, c.cfg.Wallet, + customAnchorLeaseID(c.pendingBatch.BatchKey.PubKey), + merged, previousLocks, ) cancel() if err != nil { @@ -4609,7 +4649,10 @@ func (c *ChainPlanter) finalizeBatch(params FinalizeParams) (*BatchCaretaker, if err != nil { ctx, cancel = c.WithCtxQuit() releaseErr := releaseCustomAnchorOutpoints( - ctx, c.cfg.Wallet, newLocks, + ctx, c.cfg.Wallet, + customAnchorLeaseID( + c.pendingBatch.BatchKey.PubKey, + ), newLocks, ) cancel() return nil, fmt.Errorf("unable to store externally signed "+ diff --git a/tapnode/tapnodemock/wallet_anchor.go b/tapnode/tapnodemock/wallet_anchor.go index d16b0b18a7..71c575ec0b 100644 --- a/tapnode/tapnodemock/wallet_anchor.go +++ b/tapnode/tapnodemock/wallet_anchor.go @@ -258,7 +258,7 @@ func (m *WalletAnchor) ImportTaprootOutput(ctx context.Context, // LeaseInput leases the input if the mock marks it as wallet-owned. func (m *WalletAnchor) LeaseInput(ctx context.Context, - op wire.OutPoint) (bool, error) { + _ tapnode.CustomAnchorLeaseID, op wire.OutPoint) (bool, error) { m.mu.RLock() leaseErr := m.LeaseErrors[op] @@ -280,7 +280,9 @@ func (m *WalletAnchor) LeaseInput(ctx context.Context, } // ReleaseInput releases a custom-anchor lease held by the mock wallet. -func (m *WalletAnchor) ReleaseInput(_ context.Context, op wire.OutPoint) error { +func (m *WalletAnchor) ReleaseInput(_ context.Context, + _ tapnode.CustomAnchorLeaseID, op wire.OutPoint) error { + m.ReleaseInputSignal <- op m.mu.RLock() releaseErr := m.ReleaseErrors[op] @@ -364,3 +366,4 @@ func (m *WalletAnchor) MinRelayFee( } var _ tapnode.WalletAnchor = (*WalletAnchor)(nil) +var _ tapnode.CustomAnchorLeaser = (*WalletAnchor)(nil) diff --git a/tapnode/wallet_anchor.go b/tapnode/wallet_anchor.go index 2c77e1c1b6..5d79967de4 100644 --- a/tapnode/wallet_anchor.go +++ b/tapnode/wallet_anchor.go @@ -31,14 +31,6 @@ type WalletAnchor interface { ImportTaprootOutput(context.Context, *btcec.PublicKey) (btcaddr.Address, error) - // LeaseInput leases the input if it belongs to the backing wallet. The - // returned boolean is false for inputs owned by an external signer. - LeaseInput(context.Context, wire.OutPoint) (bool, error) - - // ReleaseInput releases only a lease previously acquired through - // LeaseInput. It must not release leases owned by other subsystems. - ReleaseInput(context.Context, wire.OutPoint) error - // UnlockInput unlocks the set of target inputs after a batch or // send transaction is abandoned. UnlockInput(context.Context, wire.OutPoint) error @@ -66,3 +58,23 @@ type WalletAnchor interface { // chain backend in sat/kw. MinRelayFee(ctx context.Context) (chainfee.SatPerKWeight, error) } + +// CustomAnchorLeaseID identifies the minting batch that owns a custom anchor +// input lease. Lease IDs must be stable across restarts and unique per batch. +type CustomAnchorLeaseID [32]byte + +// CustomAnchorLeaser is the optional wallet capability required by custom +// anchor minting. Keeping this separate from WalletAnchor allows existing +// wallet implementations that don't support custom anchors to remain source +// compatible. +type CustomAnchorLeaser interface { + // LeaseInput leases the input for the specified batch if it belongs to + // the backing wallet. The returned boolean is false for inputs owned by + // an external signer. A lease held under another ID must be rejected. + LeaseInput(context.Context, CustomAnchorLeaseID, wire.OutPoint) (bool, + error) + + // ReleaseInput releases only a lease owned by the specified batch. It + // must not release leases owned by another batch or subsystem. + ReleaseInput(context.Context, CustomAnchorLeaseID, wire.OutPoint) error +} From 2197aa200cb47f1e0b80d462e9e98ec906c65263 Mon Sep 17 00:00:00 2001 From: Sadiq Jaffer Date: Fri, 21 Aug 2026 09:14:30 +0100 Subject: [PATCH 12/24] tapgarden: make mint failure cleanup deterministic --- bench/fixture/mint_driver.go | 60 +++++++++++++++++------------ tapgarden/issue721_stateful_test.go | 8 +++- tapgarden/planter.go | 8 +++- tapnode/chain_bridge.go | 14 +++---- tapnode/tapnodemock/chain_bridge.go | 10 ++++- 5 files changed, 65 insertions(+), 35 deletions(-) diff --git a/bench/fixture/mint_driver.go b/bench/fixture/mint_driver.go index 7375fc0a14..119fd59fc3 100644 --- a/bench/fixture/mint_driver.go +++ b/bench/fixture/mint_driver.go @@ -11,6 +11,7 @@ import ( "github.com/btcsuite/btcd/blockchain" "github.com/btcsuite/btcd/btcutil/v2" "github.com/btcsuite/btcd/chaincfg/v2" + "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" "github.com/lightninglabs/taproot-assets/asset" "github.com/lightninglabs/taproot-assets/proof" @@ -60,17 +61,30 @@ func NewMintDriver(tb testing.TB) *MintDriver { // pump drains every signal the planter caretaker is known to emit and // synthesises the chain side of the conversation: confirmations are // fabricated, blocks are stored, and signal acks are sent in the -// background so SendConfNtfn never blocks the pump. +// background so SendConfNtfn never blocks the pump. Confirmation registration +// and publication may arrive in either order, so the pump correlates them by +// transaction ID instead of relying on caretaker state ordering. func (d *MintDriver) pump(ctx context.Context) { defer close(d.pumpDone) - // lastTx carries the broadcast transaction between the publish step - // and the conf-registration step. The caretaker publishes before it - // registers, so by the time we see ConfReqSignal the broadcast has - // already happened. The select cases below execute serially in this - // one goroutine, so no synchronisation on lastTx is needed; the - // goroutine spawned for SendConfNtfn captures tx by value. - var lastTx *wire.MsgTx + published := make(map[chainhash.Hash]*wire.MsgTx) + pending := make(map[chainhash.Hash][]int) + + confirm := func(reqNo int, tx *wire.MsgTx) { + // Build a one-tx block and register it under its hash so the + // caretaker's later GetBlock call finds it. SetBlock serialises + // this write against any concurrent caretaker reads. + block := buildBlockForTx(tx) + blockHash := block.BlockHash() + d.ChainBridge.SetBlock(blockHash, block) + + // SendConfNtfn writes to req.Confirmed, which blocks until the + // caretaker reads it. Run it in its own goroutine so the pump + // stays responsive. + go d.ChainBridge.SendConfNtfn( + reqNo, &blockHash, 1, 0, block, tx, + ) + } for { select { @@ -87,28 +101,24 @@ func (d *MintDriver) pump(ctx context.Context) { case <-d.ChainBridge.BlockEpochSignal: case tx := <-d.ChainBridge.PublishReq: - lastTx = tx + txid := tx.TxHash() + published[txid] = tx + for _, reqNo := range pending[txid] { + confirm(reqNo, tx) + } + delete(pending, txid) case reqNo := <-d.ChainBridge.ConfReqSignal: - tx := lastTx - if tx == nil { + txid := d.ChainBridge.ConfTxIDs[reqNo] + if txid == nil { + continue + } + if tx := published[*txid]; tx != nil { + confirm(reqNo, tx) continue } - // Build a one-tx block and register it under its hash - // so the caretaker's later GetBlock call finds it. - // SetBlock serialises this write against any - // concurrent caretaker reads. - block := buildBlockForTx(tx) - blockHash := block.BlockHash() - d.ChainBridge.SetBlock(blockHash, block) - - // SendConfNtfn writes to req.Confirmed, which blocks - // until the caretaker reads it. Run it in its own - // goroutine so the pump stays responsive. - go d.ChainBridge.SendConfNtfn( - reqNo, &blockHash, 1, 0, block, tx, - ) + pending[*txid] = append(pending[*txid], reqNo) } } } diff --git a/tapgarden/issue721_stateful_test.go b/tapgarden/issue721_stateful_test.go index 9e240d573a..77779b3577 100644 --- a/tapgarden/issue721_stateful_test.go +++ b/tapgarden/issue721_stateful_test.go @@ -819,7 +819,13 @@ func TestIssue721ImportFailureAfterRestartIsAdopted(t *testing.T) { _, err = fn.RecvOrTimeout(h.wallet.ImportPubKeySignal, defaultTimeout) require.NoError(t, err) h.assertFinalizeBatch(&wg, respChan, "temporary import failure") - h.assertNumCaretakersActive(0) + + // Finalize returns only after its failed caretaker is removed. This must + // not require polling: callers are allowed to retry or cancel as soon as + // they receive the error. + numActive, err := h.planter.NumActiveBatches() + require.NoError(t, err) + require.Zero(t, numActive) batchKey, err := h.planter.CancelBatch() require.NoError(t, err) diff --git a/tapgarden/planter.go b/tapgarden/planter.go index aa48238eaa..b43ad2ef68 100644 --- a/tapgarden/planter.go +++ b/tapgarden/planter.go @@ -3476,7 +3476,6 @@ func (c *ChainPlanter) gardener() { c.pendingBatch = nil case err := <-caretaker.cfg.BroadcastErrChan: - req.Error(err) // Stop the failed caretaker directly. Custom // batches remain pending so the same signed // transaction can be retried; legacy batches @@ -3515,6 +3514,13 @@ func (c *ChainPlanter) gardener() { c.pendingBatch = nil } + // Only release the synchronous caller after the + // failed caretaker is stopped and removed. Callers + // may immediately retry or cancel after an error, + // so returning earlier exposes a dead caretaker in + // the exclusive slot. + req.Error(err) + case <-c.Quit: return } diff --git a/tapnode/chain_bridge.go b/tapnode/chain_bridge.go index 2cb8003d43..5a0278bfe9 100644 --- a/tapnode/chain_bridge.go +++ b/tapnode/chain_bridge.go @@ -12,9 +12,9 @@ import ( ) // DefinitivePublishError marks a transaction publication error that proves the -// transaction was rejected before it could enter the mempool. Callers may -// safely keep the transaction in a pre-broadcast state when this error is -// returned. +// attempted backend rejected the transaction before it could enter that +// backend's mempool. Callers that have exposed the signed transaction through +// another boundary must still account for an independent relay. type DefinitivePublishError struct { err error } @@ -97,10 +97,10 @@ type ChainBridge interface { // the network. PublishTransaction(context.Context, *wire.MsgTx, string) error - // ValidateAndPublishTransaction submits a transaction before callers - // persist an irreversible broadcast state. A definitive rejection is - // returned as DefinitivePublishError; all other errors are ambiguous and - // callers must assume the transaction may have been relayed. + // ValidateAndPublishTransaction validates and submits a transaction. A + // rejection by this backend is returned as DefinitivePublishError; all + // other errors are ambiguous. Whether a caller may remain pre-broadcast + // also depends on whether the signed transaction was exposed elsewhere. ValidateAndPublishTransaction(context.Context, *wire.MsgTx, string) error // EstimateFee returns a fee estimate for the confirmation diff --git a/tapnode/tapnodemock/chain_bridge.go b/tapnode/tapnodemock/chain_bridge.go index 5b68a49fa7..db0b94552e 100644 --- a/tapnode/tapnodemock/chain_bridge.go +++ b/tapnode/tapnodemock/chain_bridge.go @@ -39,6 +39,9 @@ type ChainBridge struct { // ConfPkScripts records the confirmation script hint for each request. // It follows the same ConfReqSignal synchronization contract as ConfReqs. ConfPkScripts map[int][]byte + // ConfTxIDs records the transaction ID for each confirmation request. It + // follows the same ConfReqSignal synchronization contract as ConfReqs. + ConfTxIDs map[int]*chainhash.Hash // BlocksMu protects concurrent access to Blocks. Readers (GetBlock, // called from caretaker goroutines) hold it for read; all writers @@ -64,6 +67,7 @@ func NewChainBridge() *ChainBridge { PublishAttempts: make(chan *wire.MsgTx, 10), ConfReqs: make(map[int]*chainntnfs.ConfirmationEvent), ConfPkScripts: make(map[int][]byte), + ConfTxIDs: make(map[int]*chainhash.Hash), ConfReqSignal: make(chan int), BlockEpochSignal: make(chan struct{}, 1), NewBlocks: make(chan int32), @@ -134,7 +138,7 @@ func (m *ChainBridge) SendConfNtfn(reqNo int, blockHash *chainhash.Hash, // RegisterConfirmationsNtfn records a confirmation subscription and signals // the caller via ConfReqSignal. func (m *ChainBridge) RegisterConfirmationsNtfn(ctx context.Context, - _ *chainhash.Hash, pkScript []byte, _, _ uint32, _ bool, + txid *chainhash.Hash, pkScript []byte, _, _ uint32, _ bool, _ chan struct{}) (*chainntnfs.ConfirmationEvent, chan error, error) { select { @@ -159,6 +163,10 @@ func (m *ChainBridge) RegisterConfirmationsNtfn(ctx context.Context, currentReqCount := m.ReqCount.Load() m.ConfReqs[int(currentReqCount)] = req m.ConfPkScripts[int(currentReqCount)] = append([]byte(nil), pkScript...) + if txid != nil { + txidCopy := *txid + m.ConfTxIDs[int(currentReqCount)] = &txidCopy + } select { case m.ConfReqSignal <- int(currentReqCount): From 7595b000a202d24dde7e05f41745f2e55993dbcf Mon Sep 17 00:00:00 2001 From: Sadiq Jaffer Date: Fri, 21 Aug 2026 09:29:10 +0100 Subject: [PATCH 13/24] tapgarden: detach lease rollback context --- tapgarden/custom_anchor_test.go | 66 +++++++++++++++++++++++++++++++++ tapgarden/planter.go | 28 ++++++++++++-- 2 files changed, 90 insertions(+), 4 deletions(-) diff --git a/tapgarden/custom_anchor_test.go b/tapgarden/custom_anchor_test.go index 75f9497679..c7d60328e8 100644 --- a/tapgarden/custom_anchor_test.go +++ b/tapgarden/custom_anchor_test.go @@ -7,6 +7,7 @@ import ( "fmt" "sync" "testing" + "time" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/schnorr" @@ -38,6 +39,31 @@ type customAnchorLeaseRequest struct { op wire.OutPoint } +type blockingCustomAnchorWallet struct { + *trackedCustomAnchorWallet + + blockOn wire.OutPoint + releaseCtxErr chan error +} + +func (w *blockingCustomAnchorWallet) LeaseInput(ctx context.Context, + leaseID tapnode.CustomAnchorLeaseID, op wire.OutPoint) (bool, error) { + + if op == w.blockOn { + <-ctx.Done() + return false, ctx.Err() + } + + return w.trackedCustomAnchorWallet.LeaseInput(ctx, leaseID, op) +} + +func (w *blockingCustomAnchorWallet) ReleaseInput(ctx context.Context, + leaseID tapnode.CustomAnchorLeaseID, op wire.OutPoint) error { + + w.releaseCtxErr <- ctx.Err() + return w.trackedCustomAnchorWallet.ReleaseInput(ctx, leaseID, op) +} + func newTrackedCustomAnchorWallet() *trackedCustomAnchorWallet { return &trackedCustomAnchorWallet{ WalletAnchor: tapnodemock.NewWalletAnchor(), @@ -225,6 +251,46 @@ func TestCustomAnchorLeasesAreBatchScoped(t *testing.T) { require.Equal(t, leaseIDA, wallet.leases[inputX]) } +// TestCustomAnchorLeaseRollbackUsesLiveContext proves that an acquisition +// timeout doesn't poison cleanup and leave earlier leases untracked. +func TestCustomAnchorLeaseRollbackUsesLiveContext(t *testing.T) { + trackedWallet := newTrackedCustomAnchorWallet() + wallet := &blockingCustomAnchorWallet{ + trackedCustomAnchorWallet: trackedWallet, + releaseCtxErr: make(chan error, 1), + } + _, batchKey := btcec.PrivKeyFromBytes(bytes.Repeat([]byte{4}, 32)) + leaseID := customAnchorLeaseID(batchKey) + + packet := testCustomAnchorPacket(t) + inputX := packet.UnsignedTx.TxIn[0].PreviousOutPoint + inputY := inputX + inputY.Index++ + wallet.blockOn = inputY + packet.UnsignedTx.AddTxIn(&wire.TxIn{PreviousOutPoint: inputY}) + packet.Inputs = append(packet.Inputs, packet.Inputs[0]) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + _, err := acquireCustomAnchorLeases( + ctx, wallet, leaseID, packet, nil, + ) + require.ErrorIs(t, err, context.DeadlineExceeded) + select { + case releaseCtxErr := <-wallet.releaseCtxErr: + require.NoError(t, releaseCtxErr) + + case <-time.After(time.Second): + t.Fatal("rollback did not release the earlier lease") + } + _, xStillLeased := trackedWallet.leases[inputX] + require.False(t, xStillLeased) + require.Equal(t, []customAnchorLeaseRequest{{ + leaseID: leaseID, + op: inputX, + }}, trackedWallet.releases) +} + func testCustomAnchorPacket(t *testing.T) *psbt.Packet { t.Helper() diff --git a/tapgarden/planter.go b/tapgarden/planter.go index b43ad2ef68..c270592654 100644 --- a/tapgarden/planter.go +++ b/tapgarden/planter.go @@ -546,7 +546,7 @@ func acquireCustomAnchorLeases(ctx context.Context, wallet tapnode.WalletAnchor, for _, txIn := range packet.UnsignedTx.TxIn { op := txIn.PreviousOutPoint if _, ok := seen[op]; ok { - releaseErr := releaseCustomAnchorOutpoints( + releaseErr := rollbackCustomAnchorOutpoints( ctx, wallet, leaseID, newlyLocked, ) return nil, errors.Join(fmt.Errorf( @@ -556,7 +556,7 @@ func acquireCustomAnchorLeases(ctx context.Context, wallet tapnode.WalletAnchor, seen[op] = struct{}{} owned, err := leaser.LeaseInput(ctx, leaseID, op) if err != nil { - releaseErr := releaseCustomAnchorOutpoints( + releaseErr := rollbackCustomAnchorOutpoints( ctx, wallet, leaseID, newlyLocked, ) return nil, errors.Join( @@ -566,7 +566,7 @@ func acquireCustomAnchorLeases(ctx context.Context, wallet tapnode.WalletAnchor, } if !owned { if _, wasLocked := previous[op]; wasLocked { - releaseErr := releaseCustomAnchorOutpoints( + releaseErr := rollbackCustomAnchorOutpoints( ctx, wallet, leaseID, newlyLocked, ) return nil, errors.Join(fmt.Errorf( @@ -585,6 +585,24 @@ func acquireCustomAnchorLeases(ctx context.Context, wallet tapnode.WalletAnchor, return locked, nil } +// rollbackCustomAnchorOutpoints releases leases acquired by an operation that +// failed. The acquisition context may itself be the reason for the failure, so +// cleanup must not inherit its cancellation or deadline. The bounded timeout +// keeps shutdown from waiting indefinitely on an unresponsive wallet RPC. +func rollbackCustomAnchorOutpoints(ctx context.Context, + wallet tapnode.WalletAnchor, leaseID tapnode.CustomAnchorLeaseID, + ops []wire.OutPoint) error { + + cleanupCtx, cancel := context.WithTimeout( + context.WithoutCancel(ctx), DefaultTimeout, + ) + defer cancel() + + return releaseCustomAnchorOutpoints( + cleanupCtx, wallet, leaseID, ops, + ) +} + func newlyAcquiredLeases(locked, previous []wire.OutPoint) []wire.OutPoint { previousSet := make(map[wire.OutPoint]struct{}, len(previous)) for _, op := range previous { @@ -689,7 +707,9 @@ func renewCustomAnchorLeases(ctx context.Context, wallet tapnode.WalletAnchor, // customAnchorLeaseID derives a restart-stable, batch-specific lease owner. // The domain separator prevents the ID from colliding with unrelated uses of // the batch key. -func customAnchorLeaseID(batchKey *btcec.PublicKey) tapnode.CustomAnchorLeaseID { +func customAnchorLeaseID( + batchKey *btcec.PublicKey) tapnode.CustomAnchorLeaseID { + preimage := append( []byte("tapd-custom-anchor-psbt-lease-v1:"), batchKey.SerializeCompressed()..., From 8cd7e11fb3d9dcacee292740290d273954f4a9cf Mon Sep 17 00:00:00 2001 From: Sadiq Jaffer Date: Fri, 21 Aug 2026 09:28:59 +0100 Subject: [PATCH 14/24] taproot-assets: preserve optional custom anchor interfaces --- lndservices/chain_bridge.go | 1 + rpcserver/rpcserver.go | 8 +- tapdb/asset_minting.go | 28 +++++-- tapdb/asset_minting_test.go | 4 +- tapgarden/caretaker.go | 17 ++-- tapgarden/interface.go | 77 ++++++++++++++--- tapgarden/interface_compat_test.go | 126 ++++++++++++++++++++++++++++ tapgarden/issue721_stateful_test.go | 31 +++++-- tapgarden/planter.go | 7 +- tapnode/chain_bridge.go | 25 ++++-- tapnode/chain_bridge_compat_test.go | 75 +++++++++++++++++ tapnode/tapnodemock/chain_bridge.go | 1 + 12 files changed, 354 insertions(+), 46 deletions(-) create mode 100644 tapgarden/interface_compat_test.go create mode 100644 tapnode/chain_bridge_compat_test.go diff --git a/lndservices/chain_bridge.go b/lndservices/chain_bridge.go index 7c1c8ab459..85567e1087 100644 --- a/lndservices/chain_bridge.go +++ b/lndservices/chain_bridge.go @@ -591,6 +591,7 @@ func (l *LndRpcChainBridge) GenProofChainLookup( // A compile time assertion to ensure LndRpcChainBridge meets the // tapnode.ChainBridge interface. var _ tapnode.ChainBridge = (*LndRpcChainBridge)(nil) +var _ tapnode.DefinitivePublisher = (*LndRpcChainBridge)(nil) // ProofChainLookup is an implementation of the asset.ChainLookup interface // that uses a proof file to look up block height information of previous inputs diff --git a/rpcserver/rpcserver.go b/rpcserver/rpcserver.go index 406b953174..83fbaea0f5 100644 --- a/rpcserver/rpcserver.go +++ b/rpcserver/rpcserver.go @@ -1042,7 +1042,13 @@ func (r *RPCServer) FundBatch(ctx context.Context, func (r *RPCServer) PrepareBatch(_ context.Context, req *mintrpc.PrepareBatchRequest) (*mintrpc.PrepareBatchResponse, error) { - batch, err := r.cfg.AssetMinter.PrepareBatch() + preparer, ok := r.cfg.AssetMinter.(tapgarden.BatchPreparer) + if !ok { + return nil, fmt.Errorf("asset minter does not support caller-funded " + + "batch preparation") + } + + batch, err := preparer.PrepareBatch() if err != nil { return nil, fmt.Errorf("unable to prepare batch: %w", err) } diff --git a/tapdb/asset_minting.go b/tapdb/asset_minting.go index a6af906899..b7da90dd46 100644 --- a/tapdb/asset_minting.go +++ b/tapdb/asset_minting.go @@ -1998,14 +1998,30 @@ func (a *AssetMintingStore) RepairCustomAnchorInternalKey(ctx context.Context, } // CommitSignedGenesisTx binds a fully signed genesis transaction to a pending -// batch on disk. The anchor output index and script root are also stored to -// ensure we can reconstruct the private key needed to sign for the batch. The -// genesis transaction itself is inserted as a new chain transaction, which all -// other components then reference. +// batch on disk using the batch key as the minting internal key. This preserves +// the original MintingStore contract for callers that don't use a custom +// anchor. +func (a *AssetMintingStore) CommitSignedGenesisTx(ctx context.Context, + batch *tapgarden.MintingBatch, genesisPkt *tapsend.FundedPsbt, + anchorOutputIndex uint32, merkleRoot, tapTreeRoot []byte, + tapSibling []byte) error { + + return a.CommitSignedGenesisTxWithKey( + ctx, batch, batch.BatchKey, genesisPkt, anchorOutputIndex, + merkleRoot, tapTreeRoot, tapSibling, + ) +} + +// CommitSignedGenesisTxWithKey binds a fully signed genesis transaction to a +// pending batch on disk using the specified minting internal key. The anchor +// output index and script root are also stored to ensure we can reconstruct the +// private key needed to sign for the batch. The genesis transaction itself is +// inserted as a new chain transaction, which all other components then +// reference. // // TODO(roasbeef): or could just re-read assets from disk and set the script // root manually? -func (a *AssetMintingStore) CommitSignedGenesisTx(ctx context.Context, +func (a *AssetMintingStore) CommitSignedGenesisTxWithKey(ctx context.Context, batch *tapgarden.MintingBatch, mintingInternalKey keychain.KeyDescriptor, genesisPkt *tapsend.FundedPsbt, @@ -2457,4 +2473,6 @@ func (a *AssetMintingStore) DeleteTapscriptTree(ctx context.Context, // A compile-time assertion to ensure that AssetMintingStore meets the // tapgarden.MintingStore interface. var _ tapgarden.MintingStore = (*AssetMintingStore)(nil) +var _ tapgarden.SignedGenesisPsbtStore = (*AssetMintingStore)(nil) +var _ tapgarden.MintingInternalKeyStore = (*AssetMintingStore)(nil) var _ asset.TapscriptTreeManager = (*AssetMintingStore)(nil) diff --git a/tapdb/asset_minting_test.go b/tapdb/asset_minting_test.go index 630aed7c49..fbc35796d8 100644 --- a/tapdb/asset_minting_test.go +++ b/tapdb/asset_minting_test.go @@ -1078,7 +1078,7 @@ func TestCommitBatchChainActions(t *testing.T) { // With our assets inserted, we'll now commit the signed genesis packet // to disk, along with the Taproot Asset script root that's stored // alongside any managed UTXOs. - require.NoError(t, assetStore.CommitSignedGenesisTx( + require.NoError(t, assetStore.CommitSignedGenesisTxWithKey( ctx, randAssetCtx.mintingBatch, customInternalKey, randAssetCtx.genesisPkt, 0, randAssetCtx.merkleRoot, randAssetCtx.scriptRoot, @@ -1334,7 +1334,7 @@ func TestCommitSignedGenesisTxConflictingLocatorRollback(t *testing.T) { _, err := db.UpsertInternalKey(ctx, conflicting) require.NoError(t, err) - err = assetStore.CommitSignedGenesisTx( + err = assetStore.CommitSignedGenesisTxWithKey( ctx, randAssetCtx.mintingBatch, customInternalKey, randAssetCtx.genesisPkt, 0, randAssetCtx.merkleRoot, randAssetCtx.scriptRoot, randAssetCtx.tapSiblingBytes, diff --git a/tapgarden/caretaker.go b/tapgarden/caretaker.go index 382a807688..579eea3849 100644 --- a/tapgarden/caretaker.go +++ b/tapgarden/caretaker.go @@ -1072,8 +1072,9 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) publishFunded := b.cfg.Batch.GenesisPacket.FundedPsbt publishFunded.Pkt = publishPkt storeCtx, storeCancel := b.WithCtxQuit() - err = b.cfg.Log.StoreSignedGenesisPsbt( - storeCtx, b.cfg.Batch.BatchKey.PubKey, &publishFunded, + err = storeSignedGenesisPsbt( + storeCtx, b.cfg.Log, b.cfg.Batch.BatchKey.PubKey, + &publishFunded, ) storeCancel() if err != nil { @@ -1122,10 +1123,10 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) if renewErr == nil { publishAttempted = true publishCtx, publishCancel := b.WithCtxQuit() - publishErr = b.cfg.ChainBridge. - ValidateAndPublishTransaction( - publishCtx, signedTx, IssuanceTxLabel, - ) + publishErr = tapnode.ValidateAndPublishTransaction( + publishCtx, b.cfg.ChainBridge, signedTx, + IssuanceTxLabel, + ) publishCancel() } if tapnode.IsDefinitivePublishError(publishErr) { @@ -1150,8 +1151,8 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) // Taproot Asset commitment root and batch tapscript sibling. tapCommitmentRoot := b.cfg.Batch.RootAssetCommitment. TapscriptRoot(nil) - err = b.cfg.Log.CommitSignedGenesisTx( - ctx, b.cfg.Batch, mintingInternalKey, + err = commitSignedGenesisTx( + ctx, b.cfg.Log, b.cfg.Batch, mintingInternalKey, &b.cfg.Batch.GenesisPacket.FundedPsbt, b.anchorOutputIndex, merkleRoot, tapCommitmentRoot[:], siblingBytes, diff --git a/tapgarden/interface.go b/tapgarden/interface.go index 678e352cf7..1735bc305b 100644 --- a/tapgarden/interface.go +++ b/tapgarden/interface.go @@ -55,11 +55,6 @@ type Planter interface { // deriving all witnesses necessary to create the final genesis TX. SealBatch(params SealParams) (*MintingBatch, error) - // PrepareBatch freezes a caller-funded batch and commits the asset tree - // into its selected anchor output. The returned PSBT is then ready for - // external signing. - PrepareBatch() (*MintingBatch, error) - // FinalizeBatch signals that the asset minter should finalize // the current batch, if one exists. FinalizeBatch(params FinalizeParams) (*MintingBatch, error) @@ -80,6 +75,16 @@ type Planter interface { fn.EventPublisher[fn.Event, bool] } +// BatchPreparer is an optional Planter extension for caller-funded batches. +// Keeping this operation separate allows existing Planter implementations to +// remain source compatible when custom anchor support isn't required. +type BatchPreparer interface { + // PrepareBatch freezes a caller-funded batch and commits the asset tree + // into its selected anchor output. The returned PSBT is ready for external + // signing. + PrepareBatch() (*MintingBatch, error) +} + // BatchState an enum that represents the various stages of a minting batch. type BatchState uint8 @@ -251,13 +256,6 @@ type MintingStore interface { genesisPacket *FundedMintAnchorPsbt, assets *commitment.TapCommitment) error - // StoreSignedGenesisPsbt durably records a fully signed custom genesis - // packet while leaving the batch in BatchStateCommitted. This allows - // publication acceptance to be checked before the irreversible broadcast - // records are created. - StoreSignedGenesisPsbt(ctx context.Context, batchKey *btcec.PublicKey, - genesisTx *tapsend.FundedPsbt) error - // CommitSignedGenesisTx adds a fully signed genesis transaction to the // batch, along with the Taproot Asset script root, which is the // left/right sibling for the Taproot Asset tapscript commitment in the @@ -267,7 +265,6 @@ type MintingStore interface { // disk and the in-memory state of the supplied batch is advanced to // match. On failure neither moves. CommitSignedGenesisTx(ctx context.Context, batch *MintingBatch, - mintingInternalKey keychain.KeyDescriptor, genesisTx *tapsend.FundedPsbt, anchorOutputIndex uint32, merkleRoot, tapTreeRoot, tapSibling []byte) error @@ -319,6 +316,60 @@ type MintingStore interface { groupKey btcec.PublicKey) (fn.Option[DelegationKey], error) } +// SignedGenesisPsbtStore is an optional MintingStore extension that durably +// records a signed custom genesis packet before publication is attempted. +type SignedGenesisPsbtStore interface { + StoreSignedGenesisPsbt(ctx context.Context, batchKey *btcec.PublicKey, + genesisTx *tapsend.FundedPsbt) error +} + +// MintingInternalKeyStore is an optional MintingStore extension that persists +// the wallet-proven internal key for a mint anchor. Custom anchors require this +// capability because their internal key can differ from the batch key. +type MintingInternalKeyStore interface { + CommitSignedGenesisTxWithKey(ctx context.Context, batch *MintingBatch, + mintingInternalKey keychain.KeyDescriptor, + genesisTx *tapsend.FundedPsbt, anchorOutputIndex uint32, + merkleRoot, tapTreeRoot, tapSibling []byte) error +} + +func storeSignedGenesisPsbt(ctx context.Context, store MintingStore, + batchKey *btcec.PublicKey, genesisTx *tapsend.FundedPsbt) error { + + signedStore, ok := store.(SignedGenesisPsbtStore) + if !ok { + return fmt.Errorf("minting store does not support signed custom " + + "genesis persistence") + } + + return signedStore.StoreSignedGenesisPsbt( + ctx, batchKey, genesisTx, + ) +} + +func commitSignedGenesisTx(ctx context.Context, store MintingStore, + batch *MintingBatch, mintingInternalKey keychain.KeyDescriptor, + genesisTx *tapsend.FundedPsbt, anchorOutputIndex uint32, + merkleRoot, tapTreeRoot, tapSibling []byte) error { + + if keyStore, ok := store.(MintingInternalKeyStore); ok { + return keyStore.CommitSignedGenesisTxWithKey( + ctx, batch, mintingInternalKey, genesisTx, + anchorOutputIndex, merkleRoot, tapTreeRoot, tapSibling, + ) + } + + if genesisTx != nil && isCustomAnchorPsbt(genesisTx.Pkt) { + return fmt.Errorf("minting store does not support custom anchor " + + "internal keys") + } + + return store.CommitSignedGenesisTx( + ctx, batch, genesisTx, anchorOutputIndex, merkleRoot, + tapTreeRoot, tapSibling, + ) +} + // CustomAnchorKeyRepairCandidate contains the retained database state needed // for a wallet-aware audit of a historical custom mint anchor. The store does // not decide whether the key is local; that proof is performed by the planter diff --git a/tapgarden/interface_compat_test.go b/tapgarden/interface_compat_test.go new file mode 100644 index 0000000000..ca42b84139 --- /dev/null +++ b/tapgarden/interface_compat_test.go @@ -0,0 +1,126 @@ +package tapgarden + +import ( + "context" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/psbt/v2" + "github.com/lightninglabs/taproot-assets/fn" + "github.com/lightninglabs/taproot-assets/tapsend" + "github.com/lightningnetwork/lnd/keychain" + "github.com/stretchr/testify/require" +) + +type legacyMintingStore struct { + MintingStore + + commits int +} + +func (l *legacyMintingStore) CommitSignedGenesisTx(_ context.Context, + _ *MintingBatch, _ *tapsend.FundedPsbt, _ uint32, _, _, _ []byte) error { + + l.commits++ + return nil +} + +type signedPsbtStore struct { + *legacyMintingStore + + stored bool +} + +func (s *signedPsbtStore) StoreSignedGenesisPsbt(_ context.Context, + _ *btcec.PublicKey, _ *tapsend.FundedPsbt) error { + + s.stored = true + return nil +} + +type internalKeyStore struct { + *legacyMintingStore + + committedKey keychain.KeyDescriptor +} + +func (s *internalKeyStore) CommitSignedGenesisTxWithKey(_ context.Context, + _ *MintingBatch, key keychain.KeyDescriptor, _ *tapsend.FundedPsbt, + _ uint32, _, _, _ []byte) error { + + s.committedKey = key + return nil +} + +func TestMintingStoreCompatibilityAdapters(t *testing.T) { + t.Parallel() + + standardPacket := &tapsend.FundedPsbt{Pkt: &psbt.Packet{}} + customPacket := &tapsend.FundedPsbt{Pkt: &psbt.Packet{ + Unknowns: []*psbt.Unknown{{ + Key: fn.CopySlice(customAnchorPsbtMarker), Value: []byte{1}, + }}, + }} + key := keychain.KeyDescriptor{KeyLocator: keychain.KeyLocator{ + Family: 91, + Index: 17, + }} + + t.Run("legacy standard commit", func(t *testing.T) { + t.Parallel() + + store := &legacyMintingStore{} + err := commitSignedGenesisTx( + t.Context(), store, &MintingBatch{}, key, + standardPacket, 0, nil, nil, nil, + ) + + require.NoError(t, err) + require.Equal(t, 1, store.commits) + }) + + t.Run("legacy custom commit fails closed", func(t *testing.T) { + t.Parallel() + + store := &legacyMintingStore{} + err := commitSignedGenesisTx( + t.Context(), store, &MintingBatch{}, key, + customPacket, 0, nil, nil, nil, + ) + + require.ErrorContains(t, err, "custom anchor internal keys") + require.Zero(t, store.commits) + }) + + t.Run("optional internal key capability", func(t *testing.T) { + t.Parallel() + + base := &legacyMintingStore{} + store := &internalKeyStore{legacyMintingStore: base} + err := commitSignedGenesisTx( + t.Context(), store, &MintingBatch{}, key, + customPacket, 0, nil, nil, nil, + ) + + require.NoError(t, err) + require.Equal(t, key.KeyLocator, store.committedKey.KeyLocator) + require.Zero(t, base.commits) + }) + + t.Run("signed PSBT capability", func(t *testing.T) { + t.Parallel() + + legacy := &legacyMintingStore{} + err := storeSignedGenesisPsbt( + t.Context(), legacy, nil, customPacket, + ) + require.ErrorContains(t, err, "signed custom genesis") + + store := &signedPsbtStore{legacyMintingStore: legacy} + err = storeSignedGenesisPsbt( + t.Context(), store, nil, customPacket, + ) + require.NoError(t, err) + require.True(t, store.stored) + }) +} diff --git a/tapgarden/issue721_stateful_test.go b/tapgarden/issue721_stateful_test.go index 77779b3577..2d10e49864 100644 --- a/tapgarden/issue721_stateful_test.go +++ b/tapgarden/issue721_stateful_test.go @@ -55,7 +55,22 @@ func (s *issue721FailSignedStore) StoreSignedGenesisPsbt(ctx context.Context, return fmt.Errorf("injected signed PSBT store failure") } - return s.MintingStore.StoreSignedGenesisPsbt(ctx, batchKey, funded) + signedStore, ok := s.MintingStore.(tapgarden.SignedGenesisPsbtStore) + if !ok { + return fmt.Errorf("minting store does not support signed PSBT persistence") + } + + return signedStore.StoreSignedGenesisPsbt(ctx, batchKey, funded) +} + +func issue721SignedStore(t *testing.T, + store tapgarden.MintingStore) tapgarden.SignedGenesisPsbtStore { + + t.Helper() + signedStore, ok := store.(tapgarden.SignedGenesisPsbtStore) + require.True(t, ok, "test minting store must persist signed PSBTs") + + return signedStore } // issue721Anchor constructs a caller-owned PSBT whose first output and all @@ -877,7 +892,7 @@ func TestIssue721PublishFailureAfterRestartIsAdopted(t *testing.T) { }) funded := prepared.GenesisPacket.FundedPsbt funded.Pkt = signed - err = store.StoreSignedGenesisPsbt( + err = issue721SignedStore(t, store).StoreSignedGenesisPsbt( t.Context(), prepared.BatchKey.PubKey, &funded, ) require.NoError(t, err) @@ -959,7 +974,7 @@ func TestIssue721PublishPendingRestartLeaseFailure(t *testing.T) { }) funded := prepared.GenesisPacket.FundedPsbt funded.Pkt = signed - require.NoError(t, store.StoreSignedGenesisPsbt( + require.NoError(t, issue721SignedStore(t, store).StoreSignedGenesisPsbt( t.Context(), prepared.BatchKey.PubKey, &funded, )) expected, err := psbt.Extract(signed) @@ -1058,7 +1073,7 @@ func TestIssue721CorruptLeaseMarkerWatchesWithoutPublishing(t *testing.T) { }) funded := prepared.GenesisPacket.FundedPsbt funded.Pkt = signed - require.NoError(t, store.StoreSignedGenesisPsbt( + require.NoError(t, issue721SignedStore(t, store).StoreSignedGenesisPsbt( t.Context(), prepared.BatchKey.PubKey, &funded, )) @@ -1123,7 +1138,7 @@ func TestIssue721RecoveredCaretakerFastConfirmation(t *testing.T) { }) funded := prepared.GenesisPacket.FundedPsbt funded.Pkt = signed - err = store.StoreSignedGenesisPsbt( + err = issue721SignedStore(t, store).StoreSignedGenesisPsbt( t.Context(), prepared.BatchKey.PubKey, &funded, ) require.NoError(t, err) @@ -1778,7 +1793,7 @@ func TestIssue721CustomRestartStates(t *testing.T) { }) funded := prepared.GenesisPacket.FundedPsbt funded.Pkt = signed - err = store.StoreSignedGenesisPsbt( + err = issue721SignedStore(t, store).StoreSignedGenesisPsbt( t.Context(), prepared.BatchKey.PubKey, &funded, ) require.NoError(t, err) @@ -1885,7 +1900,7 @@ func TestIssue721LegacyRawOnlyFinalizeRemainsCancellable(t *testing.T) { legacy.Pkt.Outputs[1].Bip32Derivation = nil legacy.Pkt.Outputs[1].TaprootInternalKey = fn.CopySlice(pkt.Outputs[1].TaprootInternalKey) - require.NoError(t, store.StoreSignedGenesisPsbt( + require.NoError(t, issue721SignedStore(t, store).StoreSignedGenesisPsbt( t.Context(), prepared.BatchKey.PubKey, &legacy.FundedPsbt, )) h.refreshChainPlanter() @@ -1947,7 +1962,7 @@ func TestIssue721LegacyRejectedMarkerIsPublicationPending(t *testing.T) { }) funded := prepared.GenesisPacket.FundedPsbt funded.Pkt = signed - require.NoError(t, store.StoreSignedGenesisPsbt( + require.NoError(t, issue721SignedStore(t, store).StoreSignedGenesisPsbt( t.Context(), prepared.BatchKey.PubKey, &funded, )) diff --git a/tapgarden/planter.go b/tapgarden/planter.go index c270592654..5ca8e6365e 100644 --- a/tapgarden/planter.go +++ b/tapgarden/planter.go @@ -688,7 +688,6 @@ func renewCustomAnchorLeases(ctx context.Context, wallet tapnode.WalletAnchor, } else if markerState == customAnchorLeaseMarkerCurrent { funded.LockedUTXOs = markerOps } - for _, op := range funded.LockedUTXOs { owned, err := leaser.LeaseInput(ctx, leaseID, op) if err != nil { @@ -4667,8 +4666,9 @@ func (c *ChainPlanter) finalizeBatch(params FinalizeParams) (*BatchCaretaker, signedFundedPsbt.Pkt = merged signedFundedPsbt.LockedUTXOs = locked ctx, cancel = c.WithCtxQuit() - err = c.cfg.Log.StoreSignedGenesisPsbt( - ctx, c.pendingBatch.BatchKey.PubKey, + err = storeSignedGenesisPsbt( + ctx, c.cfg.Log, + c.pendingBatch.BatchKey.PubKey, &signedFundedPsbt, ) cancel() @@ -5372,6 +5372,7 @@ func (c *ChainPlanter) verifierCtx(ctx context.Context) proof.VerifierCtx { // A compile-time assertion to make sure that ChainPlanter implements the // tapgarden.Planter interface. var _ Planter = (*ChainPlanter)(nil) +var _ BatchPreparer = (*ChainPlanter)(nil) // A compile-time assertion to make sure BatchCaretaker satisfies the // fn.EventPublisher interface. diff --git a/tapnode/chain_bridge.go b/tapnode/chain_bridge.go index 5a0278bfe9..8a2f5901c8 100644 --- a/tapnode/chain_bridge.go +++ b/tapnode/chain_bridge.go @@ -97,18 +97,31 @@ type ChainBridge interface { // the network. PublishTransaction(context.Context, *wire.MsgTx, string) error - // ValidateAndPublishTransaction validates and submits a transaction. A - // rejection by this backend is returned as DefinitivePublishError; all - // other errors are ambiguous. Whether a caller may remain pre-broadcast - // also depends on whether the signed transaction was exposed elsewhere. - ValidateAndPublishTransaction(context.Context, *wire.MsgTx, string) error - // EstimateFee returns a fee estimate for the confirmation // target. EstimateFee(ctx context.Context, confTarget uint32) (chainfee.SatPerKWeight, error) } +// DefinitivePublisher is an optional ChainBridge extension that can +// distinguish conclusive policy rejection from ambiguous publication errors. +type DefinitivePublisher interface { + ValidateAndPublishTransaction(context.Context, *wire.MsgTx, string) error +} + +// ValidateAndPublishTransaction uses definitive publication when available. +// Older ChainBridge implementations retain their publication behavior, with +// every returned error conservatively treated as ambiguous. +func ValidateAndPublishTransaction(ctx context.Context, bridge ChainBridge, + tx *wire.MsgTx, label string) error { + + if publisher, ok := bridge.(DefinitivePublisher); ok { + return publisher.ValidateAndPublishTransaction(ctx, tx, label) + } + + return bridge.PublishTransaction(ctx, tx, label) +} + // GenHeaderVerifier generates a block header on-chain verification callback // function given a chain bridge. func GenHeaderVerifier(ctx context.Context, diff --git a/tapnode/chain_bridge_compat_test.go b/tapnode/chain_bridge_compat_test.go new file mode 100644 index 0000000000..c7c240df65 --- /dev/null +++ b/tapnode/chain_bridge_compat_test.go @@ -0,0 +1,75 @@ +package tapnode + +import ( + "context" + "errors" + "testing" + + "github.com/btcsuite/btcd/wire/v2" + "github.com/stretchr/testify/require" +) + +type legacyPublisher struct { + ChainBridge + + calls int + err error +} + +func (l *legacyPublisher) PublishTransaction(_ context.Context, + _ *wire.MsgTx, _ string) error { + + l.calls++ + return l.err +} + +type capabilityPublisher struct { + *legacyPublisher + + definitiveCalls int + definitiveErr error +} + +func (c *capabilityPublisher) ValidateAndPublishTransaction(_ context.Context, + _ *wire.MsgTx, _ string) error { + + c.definitiveCalls++ + return c.definitiveErr +} + +func TestValidateAndPublishTransactionCompatibility(t *testing.T) { + t.Parallel() + + t.Run("legacy fallback", func(t *testing.T) { + t.Parallel() + + expectedErr := errors.New("ambiguous publish error") + bridge := &legacyPublisher{err: expectedErr} + err := ValidateAndPublishTransaction( + t.Context(), bridge, &wire.MsgTx{}, "compat", + ) + + require.ErrorIs(t, err, expectedErr) + require.Equal(t, 1, bridge.calls) + }) + + t.Run("optional capability", func(t *testing.T) { + t.Parallel() + + expectedErr := NewDefinitivePublishError( + errors.New("policy rejection"), + ) + base := &legacyPublisher{} + bridge := &capabilityPublisher{ + legacyPublisher: base, + definitiveErr: expectedErr, + } + err := ValidateAndPublishTransaction( + t.Context(), bridge, &wire.MsgTx{}, "compat", + ) + + require.ErrorIs(t, err, expectedErr) + require.Equal(t, 1, bridge.definitiveCalls) + require.Zero(t, base.calls) + }) +} diff --git a/tapnode/tapnodemock/chain_bridge.go b/tapnode/tapnodemock/chain_bridge.go index db0b94552e..d71cdb82f0 100644 --- a/tapnode/tapnodemock/chain_bridge.go +++ b/tapnode/tapnodemock/chain_bridge.go @@ -353,6 +353,7 @@ func (m *ChainBridge) GenProofChainLookup(*proof.Proof) (asset.ChainLookup, var _ asset.ChainLookup = (*ChainBridge)(nil) var _ tapnode.ChainBridge = (*ChainBridge)(nil) +var _ tapnode.DefinitivePublisher = (*ChainBridge)(nil) // GenGroupVerifier returns a no-op group verifier suitable for tests that // don't care about group-key authenticity. From c41efb5e299156b30eed1e2f739281ea1bdaa0cb Mon Sep 17 00:00:00 2001 From: Sadiq Jaffer Date: Fri, 21 Aug 2026 09:24:26 +0100 Subject: [PATCH 15/24] build: fix custom anchor CI checks --- cmd/commands/assets.go | 29 ++- docs/release-notes/release-notes-0.9.0.md | 5 + itest/assertions.go | 10 +- itest/mint_custom_anchor_test.go | 14 +- tapdb/sqlc/assets.sql.go | 273 ++++++++++++---------- tapdb/sqlc/querier.go | 10 +- tapdb/sqlc/queries/assets.sql | 8 +- tapdb/sqlc/schemas/generated_schema.sql | 4 + tapgarden/caretaker.go | 8 +- tapgarden/custom_anchor_repair.go | 1 + tapgarden/planter.go | 12 +- 11 files changed, 213 insertions(+), 161 deletions(-) diff --git a/cmd/commands/assets.go b/cmd/commands/assets.go index 6b8140d836..8e59156809 100644 --- a/cmd/commands/assets.go +++ b/cmd/commands/assets.go @@ -505,7 +505,10 @@ var fundBatchCommand = cli.Command{ Usage: "if set, the fee rate in sat/vB to use for " + "the minting transaction", }, - cli.StringFlag{Name: anchorPsbtName, Usage: "caller-funded PSBT file"}, + cli.StringFlag{ + Name: anchorPsbtName, + Usage: "caller-funded PSBT file", + }, cli.Uint64Flag{Name: assetAnchorOutputIndexName}, cli.Int64Flag{Name: changeOutputIndexName}, cli.BoolFlag{Name: noChangeOutputName}, @@ -529,13 +532,15 @@ func fundBatch(ctx *cli.Context) error { FeeRate: feeRate, } if path := ctx.String(anchorPsbtName); path != "" { - req.AnchorPsbt, err = os.ReadFile(tapcfg.CleanAndExpandPath(path)) + anchorPath := tapcfg.CleanAndExpandPath(path) + req.AnchorPsbt, err = os.ReadFile(anchorPath) if err != nil { return err } assetIdx := ctx.Uint64(assetAnchorOutputIndexName) if assetIdx > math.MaxUint32 { - return fmt.Errorf("asset anchor output index out of range") + return fmt.Errorf("asset anchor output index out of " + + "range") } changeIdx := ctx.Int64(changeOutputIndexName) if changeIdx < 0 || changeIdx > math.MaxInt32 { @@ -547,8 +552,8 @@ func fundBatch(ctx *cli.Context) error { if ctx.IsSet(preCommitOutputIndexName) { preCommitIdx := ctx.Uint64(preCommitOutputIndexName) if preCommitIdx > math.MaxUint32 { - return fmt.Errorf("pre-commitment output index out of " + - "range") + return fmt.Errorf("pre-commitment output index " + + "out of range") } req.PreCommitOutputIndex = fn.Ptr(uint32(preCommitIdx)) } @@ -586,8 +591,10 @@ func prepareBatch(ctx *cli.Context) error { return fmt.Errorf("prepare response has no batch") } path = tapcfg.CleanAndExpandPath(path) - if err := os.WriteFile(path, resp.Batch.BatchPsbt, 0o600); err != nil { - return fmt.Errorf("unable to write prepared PSBT: %w", err) + err = os.WriteFile(path, resp.Batch.BatchPsbt, 0o600) + if err != nil { + return fmt.Errorf("unable to write prepared PSBT: %w", + err) } } printRespJSON(resp) @@ -688,7 +695,10 @@ var finalizeBatchCommand = cli.Command{ Usage: "if set, the fee rate in sat/vB to use for " + "the minting transaction", }, - cli.StringFlag{Name: signedPsbtName, Usage: "externally signed PSBT file"}, + cli.StringFlag{ + Name: signedPsbtName, + Usage: "externally signed PSBT file", + }, }, Action: finalizeBatch, } @@ -708,7 +718,8 @@ func finalizeBatch(ctx *cli.Context) error { FeeRate: feeRate, } if path := ctx.String(signedPsbtName); path != "" { - req.SignedPsbt, err = os.ReadFile(tapcfg.CleanAndExpandPath(path)) + signedPath := tapcfg.CleanAndExpandPath(path) + req.SignedPsbt, err = os.ReadFile(signedPath) if err != nil { return err } diff --git a/docs/release-notes/release-notes-0.9.0.md b/docs/release-notes/release-notes-0.9.0.md index 8bb839e720..d906b284f2 100644 --- a/docs/release-notes/release-notes-0.9.0.md +++ b/docs/release-notes/release-notes-0.9.0.md @@ -62,6 +62,11 @@ ## Functional Enhancements +* [PR#2238](https://github.com/lightninglabs/taproot-assets/pull/2238) + adds a caller-controlled Bitcoin anchor PSBT flow for minting batches, + including a prepare/finalize signing boundary, durable restart recovery, + and wallet-input lease management. + ## RPC Additions ## tapcli Additions diff --git a/itest/assertions.go b/itest/assertions.go index a4a8f4b4d8..5f43169410 100644 --- a/itest/assertions.go +++ b/itest/assertions.go @@ -137,8 +137,8 @@ func AssetAnchorCheck(txid, blockHash chainhash.Hash) AssetCheck { // AssetAnchorOutpointCheck returns a check function that tests an asset's full // anchor outpoint and block hash. -func AssetAnchorOutpointCheck(outpoint wire.OutPoint, - blockHash chainhash.Hash) AssetCheck { +func AssetAnchorOutpointCheck( + outpoint wire.OutPoint, blockHash chainhash.Hash) AssetCheck { return func(a *taprpc.Asset) error { if a.ChainAnchor == nil { @@ -156,10 +156,10 @@ func AssetAnchorOutpointCheck(outpoint wire.OutPoint, "wanted %v", anchorOutpoint, outpoint) } - if a.ChainAnchor.AnchorBlockHash != blockHash.String() { + anchorBlockHash := a.ChainAnchor.AnchorBlockHash + if anchorBlockHash != blockHash.String() { return fmt.Errorf("unexpected asset anchor block hash, got "+ - "%v wanted %x", a.ChainAnchor.AnchorBlockHash, - blockHash[:]) + "%v wanted %x", anchorBlockHash, blockHash[:]) } return nil diff --git a/itest/mint_custom_anchor_test.go b/itest/mint_custom_anchor_test.go index 2c1996cd55..fd2c8113f5 100644 --- a/itest/mint_custom_anchor_test.go +++ b/itest/mint_custom_anchor_test.go @@ -1,3 +1,4 @@ +//nolint:lll package itest import ( @@ -1123,12 +1124,15 @@ func TestCustomAnchorAuthorityCommitmentBindsFields(t *testing.T) { strings.Repeat("1", 64), strings.Repeat("2", 64), identity, wallet, foreign, mutatedFees, locator, anchorKey, chain, )) - mutatedLocator := *locator + mutatedLocator := &signrpc.KeyLocator{ + KeyFamily: locator.KeyFamily, + KeyIndex: locator.KeyIndex, + } mutatedLocator.KeyIndex++ mutations = append(mutations, commit( "00112233445566778899aabbccddeeff", strings.Repeat("1", 64), strings.Repeat("2", 64), identity, - wallet, foreign, fees, &mutatedLocator, anchorKey, chain, + wallet, foreign, fees, mutatedLocator, anchorKey, chain, )) mutatedAnchor := append([]byte(nil), anchorKey...) mutatedAnchor[0] ^= 1 @@ -1547,10 +1551,10 @@ func collectCustomAnchorChainEvidence(source customAnchorChainSource, } currentHash = block.Header.PrevBlock } - for left, right := 0, len(blocks)-1; left < right; left, right = - left+1, right-1 { - + for left, right := 0, len(blocks)-1; left < right; { blocks[left], blocks[right] = blocks[right], blocks[left] + left++ + right-- } return customAnchorChainEvidence{ diff --git a/tapdb/sqlc/assets.sql.go b/tapdb/sqlc/assets.sql.go index be5d1aff3e..9873a4ede9 100644 --- a/tapdb/sqlc/assets.sql.go +++ b/tapdb/sqlc/assets.sql.go @@ -1192,6 +1192,70 @@ func (q *Queries) FetchChainTx(ctx context.Context, txid []byte) (ChainTxn, erro return i, err } +const FetchCustomAnchorKeyRepairCandidates = `-- name: FetchCustomAnchorKeyRepairCandidates :many +SELECT batch_keys.raw_key AS batch_key, + batches.minting_tx_psbt, + batches.assets_output_index, + utxos.outpoint, + utxo_keys.raw_key AS managed_internal_key, + utxos.merkle_root +FROM asset_minting_batches batches +JOIN internal_keys batch_keys + ON batches.batch_id = batch_keys.key_id +JOIN genesis_points genesis + ON batches.genesis_id = genesis.genesis_id +JOIN chain_txns chain_tx + ON genesis.anchor_tx_id = chain_tx.txn_id +JOIN managed_utxos utxos + ON utxos.txn_id = chain_tx.txn_id +JOIN internal_keys utxo_keys + ON utxos.internal_key_id = utxo_keys.key_id +WHERE batches.minting_tx_psbt IS NOT NULL +` + +type FetchCustomAnchorKeyRepairCandidatesRow struct { + BatchKey []byte + MintingTxPsbt []byte + AssetsOutputIndex sql.NullInt32 + Outpoint []byte + ManagedInternalKey []byte + MerkleRoot []byte +} + +// Return historical mint rows with enough retained information for the +// wallet-aware startup audit. The caller must still prove that the packet is a +// tapd custom anchor, that the locator derives the committed internal key and +// that the backing wallet controls it before attempting a repair. +func (q *Queries) FetchCustomAnchorKeyRepairCandidates(ctx context.Context) ([]FetchCustomAnchorKeyRepairCandidatesRow, error) { + rows, err := q.db.QueryContext(ctx, FetchCustomAnchorKeyRepairCandidates) + if err != nil { + return nil, err + } + defer rows.Close() + var items []FetchCustomAnchorKeyRepairCandidatesRow + for rows.Next() { + var i FetchCustomAnchorKeyRepairCandidatesRow + if err := rows.Scan( + &i.BatchKey, + &i.MintingTxPsbt, + &i.AssetsOutputIndex, + &i.Outpoint, + &i.ManagedInternalKey, + &i.MerkleRoot, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const FetchGenesisByAssetID = `-- name: FetchGenesisByAssetID :one SELECT gen_asset_id, asset_id, asset_tag, meta_hash, output_index, asset_type, prev_out, anchor_txid, block_height FROM genesis_info_view @@ -1603,105 +1667,6 @@ type FetchManagedUTXOsRow struct { KeyIndex int32 } -const FetchCustomAnchorKeyRepairCandidates = `-- name: FetchCustomAnchorKeyRepairCandidates :many -SELECT batch_keys.raw_key AS batch_key, - batches.minting_tx_psbt, - batches.assets_output_index, - utxos.outpoint, - utxo_keys.raw_key AS managed_internal_key, - utxos.merkle_root -FROM asset_minting_batches batches -JOIN internal_keys batch_keys - ON batches.batch_id = batch_keys.key_id -JOIN genesis_points genesis - ON batches.genesis_id = genesis.genesis_id -JOIN chain_txns chain_tx - ON genesis.anchor_tx_id = chain_tx.txn_id -JOIN managed_utxos utxos - ON utxos.txn_id = chain_tx.txn_id -JOIN internal_keys utxo_keys - ON utxos.internal_key_id = utxo_keys.key_id -WHERE batches.minting_tx_psbt IS NOT NULL - -` - -type FetchCustomAnchorKeyRepairCandidatesRow struct { - BatchKey []byte - MintingTxPsbt []byte - AssetsOutputIndex sql.NullInt32 - Outpoint []byte - ManagedInternalKey []byte - MerkleRoot []byte -} - -func (q *Queries) FetchCustomAnchorKeyRepairCandidates(ctx context.Context) ([]FetchCustomAnchorKeyRepairCandidatesRow, error) { - rows, err := q.db.QueryContext(ctx, FetchCustomAnchorKeyRepairCandidates) - if err != nil { - return nil, err - } - defer rows.Close() - - var items []FetchCustomAnchorKeyRepairCandidatesRow - for rows.Next() { - var i FetchCustomAnchorKeyRepairCandidatesRow - if err := rows.Scan( - &i.BatchKey, &i.MintingTxPsbt, &i.AssetsOutputIndex, - &i.Outpoint, &i.ManagedInternalKey, &i.MerkleRoot, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const RepairCustomAnchorInternalKey = `-- name: RepairCustomAnchorInternalKey :execrows -WITH candidate_batch AS ( - SELECT chain_tx.txn_id - FROM asset_minting_batches batches - JOIN internal_keys batch_keys - ON batches.batch_id = batch_keys.key_id - JOIN genesis_points genesis - ON batches.genesis_id = genesis.genesis_id - JOIN chain_txns chain_tx - ON genesis.anchor_tx_id = chain_tx.txn_id - WHERE batch_keys.raw_key = $1 - AND batches.assets_output_index = $2 -), expected_old_key AS ( - SELECT key_id FROM internal_keys WHERE raw_key = $3 -), replacement_key AS ( - SELECT key_id FROM internal_keys WHERE raw_key = $4 -) -UPDATE managed_utxos -SET internal_key_id = (SELECT key_id FROM replacement_key) -WHERE outpoint = $5 - AND txn_id = (SELECT txn_id FROM candidate_batch) - AND internal_key_id = (SELECT key_id FROM expected_old_key) -` - -type RepairCustomAnchorInternalKeyParams struct { - BatchKey []byte - AssetOutputIndex sql.NullInt32 - ExpectedOldKey []byte - ReplacementKey []byte - Outpoint []byte -} - -func (q *Queries) RepairCustomAnchorInternalKey(ctx context.Context, arg RepairCustomAnchorInternalKeyParams) (int64, error) { - result, err := q.db.ExecContext( - ctx, RepairCustomAnchorInternalKey, arg.BatchKey, - arg.AssetOutputIndex, arg.ExpectedOldKey, - arg.ReplacementKey, arg.Outpoint, - ) - if err != nil { - return 0, err - } - return result.RowsAffected() -} - func (q *Queries) FetchManagedUTXOs(ctx context.Context) ([]FetchManagedUTXOsRow, error) { rows, err := q.db.QueryContext(ctx, FetchManagedUTXOs) if err != nil { @@ -2943,6 +2908,56 @@ func (q *Queries) QueryAssets(ctx context.Context, arg QueryAssetsParams) ([]Que return items, nil } +const RepairCustomAnchorInternalKey = `-- name: RepairCustomAnchorInternalKey :execrows +WITH candidate_batch AS ( + SELECT chain_tx.txn_id + FROM asset_minting_batches batches + JOIN internal_keys batch_keys + ON batches.batch_id = batch_keys.key_id + JOIN genesis_points genesis + ON batches.genesis_id = genesis.genesis_id + JOIN chain_txns chain_tx + ON genesis.anchor_tx_id = chain_tx.txn_id + WHERE batch_keys.raw_key = $2 + AND batches.assets_output_index = $3 +), expected_old_key AS ( + SELECT key_id + FROM internal_keys + WHERE internal_keys.raw_key = $4 +), replacement_key AS ( + SELECT key_id + FROM internal_keys + WHERE internal_keys.raw_key = $5 +) +UPDATE managed_utxos +SET internal_key_id = (SELECT key_id FROM replacement_key) +WHERE outpoint = $1 + AND txn_id = (SELECT txn_id FROM candidate_batch) + AND internal_key_id = (SELECT key_id FROM expected_old_key) +` + +type RepairCustomAnchorInternalKeyParams struct { + Outpoint []byte + BatchKey []byte + AssetOutputIndex sql.NullInt32 + ExpectedOldKey []byte + ReplacementKey []byte +} + +func (q *Queries) RepairCustomAnchorInternalKey(ctx context.Context, arg RepairCustomAnchorInternalKeyParams) (int64, error) { + result, err := q.db.ExecContext(ctx, RepairCustomAnchorInternalKey, + arg.Outpoint, + arg.BatchKey, + arg.AssetOutputIndex, + arg.ExpectedOldKey, + arg.ReplacementKey, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const SetAssetSpent = `-- name: SetAssetSpent :one WITH target_asset(asset_id) AS ( SELECT assets.asset_id @@ -3378,36 +3393,6 @@ func (q *Queries) UpsertInternalKey(ctx context.Context, arg UpsertInternalKeyPa return key_id, err } -const UpsertWalletVerifiedInternalKey = `-- name: UpsertWalletVerifiedInternalKey :one -INSERT INTO internal_keys ( - raw_key, key_family, key_index -) VALUES ( - $1, $2, $3 -) ON CONFLICT (raw_key) - DO UPDATE SET key_family = EXCLUDED.key_family, - key_index = EXCLUDED.key_index - WHERE (internal_keys.key_family = 0 AND internal_keys.key_index = 0) - OR (internal_keys.key_family = EXCLUDED.key_family AND - internal_keys.key_index = EXCLUDED.key_index) -RETURNING key_id -` - -type UpsertWalletVerifiedInternalKeyParams struct { - RawKey []byte - KeyFamily int32 - KeyIndex int32 -} - -func (q *Queries) UpsertWalletVerifiedInternalKey(ctx context.Context, arg UpsertWalletVerifiedInternalKeyParams) (int64, error) { - row := q.db.QueryRowContext( - ctx, UpsertWalletVerifiedInternalKey, arg.RawKey, - arg.KeyFamily, arg.KeyIndex, - ) - var keyID int64 - err := row.Scan(&keyID) - return keyID, err -} - const UpsertManagedUTXO = `-- name: UpsertManagedUTXO :one WITH target_key(key_id) AS ( SELECT key_id @@ -3671,3 +3656,33 @@ func (q *Queries) UpsertTapscriptTreeRootHash(ctx context.Context, arg UpsertTap err := row.Scan(&root_id) return root_id, err } + +const UpsertWalletVerifiedInternalKey = `-- name: UpsertWalletVerifiedInternalKey :one +INSERT INTO internal_keys ( + raw_key, key_family, key_index +) VALUES ( + $1, $2, $3 +) ON CONFLICT (raw_key) + -- This query is restricted to a wallet-verified descriptor. It may + -- upgrade the historical 0/0 placeholder, but never overwrite a known, + -- conflicting locator. + DO UPDATE SET key_family = EXCLUDED.key_family, + key_index = EXCLUDED.key_index + WHERE (internal_keys.key_family = 0 AND internal_keys.key_index = 0) + OR (internal_keys.key_family = EXCLUDED.key_family AND + internal_keys.key_index = EXCLUDED.key_index) +RETURNING key_id +` + +type UpsertWalletVerifiedInternalKeyParams struct { + RawKey []byte + KeyFamily int32 + KeyIndex int32 +} + +func (q *Queries) UpsertWalletVerifiedInternalKey(ctx context.Context, arg UpsertWalletVerifiedInternalKeyParams) (int64, error) { + row := q.db.QueryRowContext(ctx, UpsertWalletVerifiedInternalKey, arg.RawKey, arg.KeyFamily, arg.KeyIndex) + var key_id int64 + err := row.Scan(&key_id) + return key_id, err +} diff --git a/tapdb/sqlc/querier.go b/tapdb/sqlc/querier.go index 86940b02f8..e5b33588b0 100644 --- a/tapdb/sqlc/querier.go +++ b/tapdb/sqlc/querier.go @@ -85,6 +85,11 @@ type Querier interface { FetchChainTxByID(ctx context.Context, txnID int64) (FetchChainTxByIDRow, error) FetchChildren(ctx context.Context, arg FetchChildrenParams) ([]FetchChildrenRow, error) FetchChildrenSelfJoin(ctx context.Context, arg FetchChildrenSelfJoinParams) ([]FetchChildrenSelfJoinRow, error) + // Return historical mint rows with enough retained information for the + // wallet-aware startup audit. The caller must still prove that the packet is a + // tapd custom anchor, that the locator derives the committed internal key and + // that the backing wallet controls it before attempting a repair. + FetchCustomAnchorKeyRepairCandidates(ctx context.Context) ([]FetchCustomAnchorKeyRepairCandidatesRow, error) FetchGenesisByAssetID(ctx context.Context, assetID []byte) (GenesisInfoView, error) FetchGenesisByGroupKey(ctx context.Context, tweakedGroupKey []byte) (GenesisInfoView, error) FetchGenesisByID(ctx context.Context, genAssetID int64) (FetchGenesisByIDRow, error) @@ -99,7 +104,6 @@ type Querier interface { FetchInternalKeyLocator(ctx context.Context, rawKey []byte) (FetchInternalKeyLocatorRow, error) FetchManagedUTXO(ctx context.Context, arg FetchManagedUTXOParams) (FetchManagedUTXORow, error) FetchManagedUTXOs(ctx context.Context) ([]FetchManagedUTXOsRow, error) - FetchCustomAnchorKeyRepairCandidates(ctx context.Context) ([]FetchCustomAnchorKeyRepairCandidatesRow, error) // Fetch records from the supply_pre_commits table with optional // filtering. FetchMintSupplyPreCommits(ctx context.Context, arg FetchMintSupplyPreCommitsParams) ([]FetchMintSupplyPreCommitsRow, error) @@ -240,6 +244,7 @@ type Querier interface { QueryUniverseStats(ctx context.Context) (QueryUniverseStatsRow, error) QueryUniverseSupplyLeaves(ctx context.Context, arg QueryUniverseSupplyLeavesParams) ([]QueryUniverseSupplyLeavesRow, error) ReAnchorPassiveAssets(ctx context.Context, arg ReAnchorPassiveAssetsParams) error + RepairCustomAnchorInternalKey(ctx context.Context, arg RepairCustomAnchorInternalKeyParams) (int64, error) SetAddrManaged(ctx context.Context, arg SetAddrManagedParams) error SetAssetSpent(ctx context.Context, arg SetAssetSpentParams) (int64, error) SetTransferOutputProofDeliveryStatus(ctx context.Context, arg SetTransferOutputProofDeliveryStatusParams) error @@ -276,8 +281,6 @@ type Querier interface { UpsertGenesisAsset(ctx context.Context, arg UpsertGenesisAssetParams) (int64, error) UpsertGenesisPoint(ctx context.Context, prevOut []byte) (int64, error) UpsertInternalKey(ctx context.Context, arg UpsertInternalKeyParams) (int64, error) - UpsertWalletVerifiedInternalKey(ctx context.Context, arg UpsertWalletVerifiedInternalKeyParams) (int64, error) - RepairCustomAnchorInternalKey(ctx context.Context, arg RepairCustomAnchorInternalKeyParams) (int64, error) UpsertManagedUTXO(ctx context.Context, arg UpsertManagedUTXOParams) (int64, error) // Upsert a supply pre-commit that is tied to a minting batch. // The batch is resolved from @batch_key @@ -304,6 +307,7 @@ type Querier interface { UpsertUniverseRoot(ctx context.Context, arg UpsertUniverseRootParams) (int64, error) UpsertUniverseSupplyLeaf(ctx context.Context, arg UpsertUniverseSupplyLeafParams) (int64, error) UpsertUniverseSupplyRoot(ctx context.Context, arg UpsertUniverseSupplyRootParams) (int64, error) + UpsertWalletVerifiedInternalKey(ctx context.Context, arg UpsertWalletVerifiedInternalKeyParams) (int64, error) } var _ Querier = (*Queries)(nil) diff --git a/tapdb/sqlc/queries/assets.sql b/tapdb/sqlc/queries/assets.sql index b303109b72..05ae6f1147 100644 --- a/tapdb/sqlc/queries/assets.sql +++ b/tapdb/sqlc/queries/assets.sql @@ -715,9 +715,13 @@ WITH candidate_batch AS ( WHERE batch_keys.raw_key = @batch_key AND batches.assets_output_index = @asset_output_index ), expected_old_key AS ( - SELECT key_id FROM internal_keys WHERE raw_key = @expected_old_key + SELECT key_id + FROM internal_keys + WHERE internal_keys.raw_key = @expected_old_key ), replacement_key AS ( - SELECT key_id FROM internal_keys WHERE raw_key = @replacement_key + SELECT key_id + FROM internal_keys + WHERE internal_keys.raw_key = @replacement_key ) UPDATE managed_utxos SET internal_key_id = (SELECT key_id FROM replacement_key) diff --git a/tapdb/sqlc/schemas/generated_schema.sql b/tapdb/sqlc/schemas/generated_schema.sql index 1991d0dc7d..e9b0b7d6e3 100644 --- a/tapdb/sqlc/schemas/generated_schema.sql +++ b/tapdb/sqlc/schemas/generated_schema.sql @@ -206,6 +206,10 @@ CREATE TABLE asset_minting_batches ( creation_time_unix TIMESTAMP NOT NULL , tapscript_sibling BLOB, assets_output_index INTEGER, universe_commitments BOOLEAN NOT NULL DEFAULT FALSE); +CREATE UNIQUE INDEX asset_minting_batches_unique_pending_or_frozen + ON asset_minting_batches ((1)) + WHERE batch_state IN (0, 1); + CREATE TABLE asset_proofs ( proof_id INTEGER PRIMARY KEY, diff --git a/tapgarden/caretaker.go b/tapgarden/caretaker.go index 579eea3849..a03ab8ca7f 100644 --- a/tapgarden/caretaker.go +++ b/tapgarden/caretaker.go @@ -208,6 +208,7 @@ func NewBatchCaretaker(cfg *BatchCaretakerConfig) *BatchCaretaker { // before importing or publishing a custom transaction. func (b *BatchCaretaker) mintingInternalKeyDescriptor(ctx context.Context, pkt *psbt.Packet) (keychain.KeyDescriptor, error) { + if !isCustomAnchorPsbt(pkt) { return b.cfg.Batch.BatchKey, nil } @@ -319,7 +320,6 @@ func (b *BatchCaretaker) Cancel() error { case BatchStateCommitted: if customAnchorPublicationPending(b.cfg.Batch) { - err := fmt.Errorf("BatchCaretaker(%x), custom anchor "+ "publication status is ambiguous and is not cancellable", batchKey) @@ -952,10 +952,10 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) return 0, fmt.Errorf("genesis TX failed final checks: "+ "%w", err) } - if err := validateExclusionProofOutputs( + err = validateExclusionProofOutputs( signedPkt, b.anchorOutputIndex, - ); err != nil { - + ) + if err != nil { return 0, err } diff --git a/tapgarden/custom_anchor_repair.go b/tapgarden/custom_anchor_repair.go index c485582df4..600dd6ba00 100644 --- a/tapgarden/custom_anchor_repair.go +++ b/tapgarden/custom_anchor_repair.go @@ -107,6 +107,7 @@ func (e *CustomAnchorKeyHealthError) Error() string { // audit result requires operator action. func CustomAnchorKeyInterventionError( results []CustomAnchorKeyRepairHealth) error { + issues := make([]CustomAnchorKeyRepairHealth, 0, len(results)) for _, result := range results { if result.RequiresIntervention() { diff --git a/tapgarden/planter.go b/tapgarden/planter.go index 5ca8e6365e..b5afaf9cff 100644 --- a/tapgarden/planter.go +++ b/tapgarden/planter.go @@ -765,6 +765,9 @@ func getCustomAnchorPublishState( if len(unknown.Value) == 1 { state := customAnchorPublishState(unknown.Value[0]) switch state { + case customAnchorPublishNone: + return customAnchorPublishNone + case customAnchorPublishRejected: // Older builds wrote this marker only after submitting the // fully signed bytes. Treat it as publication-ambiguous so an @@ -787,10 +790,6 @@ func getCustomAnchorPublishState( return customAnchorPublishNone } -func stripCustomAnchorPublishState(packet *psbt.Packet) { - setCustomAnchorPublishState(packet, customAnchorPublishNone) -} - func stripTapdCustomAnchorMarkers(packet *psbt.Packet) { if packet == nil { return @@ -2976,6 +2975,10 @@ func (c *ChainPlanter) cancelMintingBatch(ctx context.Context, cancelState = BatchStateSproutCancelled + case BatchStateBroadcast, BatchStateConfirmed, BatchStateFinalized, + BatchStateSeedlingCancelled, BatchStateSproutCancelled: + fallthrough + default: return fmt.Errorf("batch state %v is not cancellable", c.pendingBatch.State()) @@ -3432,6 +3435,7 @@ func (c *ChainPlanter) gardener() { preparedBatch, err := c.prepareBatch( ctx, c.pendingBatch, ) + cancel() if err != nil { req.Error(fmt.Errorf("unable to prepare minting "+ From 609b8b54462ac9cb1894d3aa6675842549ffc84d Mon Sep 17 00:00:00 2001 From: Sadiq Jaffer Date: Fri, 21 Aug 2026 09:41:06 +0100 Subject: [PATCH 16/24] style: clean up custom anchor lint --- cmd/commands/assets.go | 6 +- itest/assertions.go | 14 ++-- itest/mint_custom_anchor_test.go | 4 +- lndservices/chain_bridge.go | 15 ++-- lndservices/chain_bridge_test.go | 9 ++- rpcserver/rpcserver.go | 37 ++++++---- tapdb/asset_minting.go | 80 ++++++++++++++------- tapdb/asset_minting_test.go | 1 + tapgarden/batch.go | 9 ++- tapgarden/caretaker.go | 99 ++++++++++++++++---------- tapgarden/custom_anchor_repair.go | 16 +++-- tapgarden/custom_anchor_repair_test.go | 1 + tapgarden/custom_anchor_test.go | 1 + tapgarden/interface.go | 1 + tapgarden/issue721_stateful_test.go | 1 + tapgarden/planter.go | 98 +++++++++++++++---------- tapgarden/planter_test.go | 1 + tapnode/tapnodemock/chain_bridge.go | 6 +- 18 files changed, 258 insertions(+), 141 deletions(-) diff --git a/cmd/commands/assets.go b/cmd/commands/assets.go index 8e59156809..e1b97c58d2 100644 --- a/cmd/commands/assets.go +++ b/cmd/commands/assets.go @@ -552,8 +552,10 @@ func fundBatch(ctx *cli.Context) error { if ctx.IsSet(preCommitOutputIndexName) { preCommitIdx := ctx.Uint64(preCommitOutputIndexName) if preCommitIdx > math.MaxUint32 { - return fmt.Errorf("pre-commitment output index " + - "out of range") + return fmt.Errorf( + "pre-commitment output " + + "index out of range", + ) } req.PreCommitOutputIndex = fn.Ptr(uint32(preCommitIdx)) } diff --git a/itest/assertions.go b/itest/assertions.go index 5f43169410..658eafcb64 100644 --- a/itest/assertions.go +++ b/itest/assertions.go @@ -152,14 +152,20 @@ func AssetAnchorOutpointCheck( return fmt.Errorf("unable to parse outpoint: %w", err) } if *anchorOutpoint != outpoint { - return fmt.Errorf("unexpected asset anchor outpoint, got %v "+ - "wanted %v", anchorOutpoint, outpoint) + return fmt.Errorf( + "unexpected asset anchor "+ + "outpoint, got %v wanted %v", + anchorOutpoint, outpoint, + ) } anchorBlockHash := a.ChainAnchor.AnchorBlockHash if anchorBlockHash != blockHash.String() { - return fmt.Errorf("unexpected asset anchor block hash, got "+ - "%v wanted %x", anchorBlockHash, blockHash[:]) + return fmt.Errorf( + "unexpected asset anchor block "+ + "hash, got %v wanted %x", + anchorBlockHash, blockHash[:], + ) } return nil diff --git a/itest/mint_custom_anchor_test.go b/itest/mint_custom_anchor_test.go index fd2c8113f5..b150620a9d 100644 --- a/itest/mint_custom_anchor_test.go +++ b/itest/mint_custom_anchor_test.go @@ -1647,8 +1647,8 @@ func customAnchorAuthorityCommitment(campaignID, cohortID, preimage := bytes.NewBufferString(customAnchorAuthorityCommitmentDomain) for _, value := range []string{campaignID, cohortID, producerDigest} { - if err := writeCustomAnchorLenPrefixed(preimage, []byte(value)); err != nil { - + err := writeCustomAnchorLenPrefixed(preimage, []byte(value)) + if err != nil { return [32]byte{}, err } } diff --git a/lndservices/chain_bridge.go b/lndservices/chain_bridge.go index 85567e1087..3b4708d6b4 100644 --- a/lndservices/chain_bridge.go +++ b/lndservices/chain_bridge.go @@ -359,8 +359,9 @@ func (l *LndRpcChainBridge) ValidateAndPublishTransaction(ctx context.Context, // wrapValidateAndPublishError adds operation context while retaining the // definitive marker, gRPC status and original error in the unwrap chain. func wrapValidateAndPublishError(err error) error { - publishErr := fmt.Errorf("unable to validate and publish transaction: %w", - err) + publishErr := fmt.Errorf( + "unable to validate and publish transaction: %w", err, + ) if isDefinitivePublishError(err) { return tapnode.NewDefinitivePublishError(publishErr) } @@ -379,9 +380,9 @@ func isDefinitivePublishError(err error) bool { return false } - // The pinned WalletKit implementation returns mapped backend publication - // errors as Unknown. Do not infer equivalent semantics for any other gRPC - // code: even policy-looking text is ambiguous there. + // The pinned WalletKit implementation returns mapped backend + // publication errors as Unknown. Do not infer equivalent semantics for + // any other gRPC code: even policy-looking text is ambiguous there. if rpcStatus.Code() != codes.Unknown { return false } @@ -439,8 +440,8 @@ func isDefinitiveRejectReason(tokens []string) bool { } // Some backends add a small amount of explicit framing around the raw - // reject reason. Strip only known frames; arbitrary leading text must not - // turn a policy word into a definitive result. + // reject reason. Strip only known frames; arbitrary leading text must + // not turn a policy word into a definitive result. rejectFrames := [][]string{ {"transaction", "rejected", "by", "the", "mempool"}, {"transaction", "rejected", "by", "mempool"}, diff --git a/lndservices/chain_bridge_test.go b/lndservices/chain_bridge_test.go index 498b9f3a9b..b6606afbc3 100644 --- a/lndservices/chain_bridge_test.go +++ b/lndservices/chain_bridge_test.go @@ -82,7 +82,8 @@ func TestDefinitivePublishError(t *testing.T) { { name: "incidental nonstandard marker", err: status.Error( - codes.Unknown, "backend nonstandard cache failure", + codes.Unknown, + "backend nonstandard cache failure", ), definitive: false, }, @@ -172,8 +173,10 @@ func TestDefinitivePublishError(t *testing.T) { definitive: false, }, { - name: "generic application error", - err: status.Error(codes.Unknown, "backend failure"), + name: "generic application error", + err: status.Error( + codes.Unknown, "backend failure", + ), definitive: false, }, { diff --git a/rpcserver/rpcserver.go b/rpcserver/rpcserver.go index 83fbaea0f5..191d156641 100644 --- a/rpcserver/rpcserver.go +++ b/rpcserver/rpcserver.go @@ -1,3 +1,4 @@ +//nolint:lll package rpcserver import ( @@ -957,8 +958,9 @@ func (r *RPCServer) FundBatch(ctx context.Context, req.ChangeOutputIndex != 0 || req.NoChangeOutput || req.PreCommitOutputIndex != nil) { - return nil, fmt.Errorf("custom anchor output controls require " + - "anchor_psbt") + return nil, fmt.Errorf( + "custom anchor output controls require anchor_psbt", + ) } if req.NoChangeOutput && req.ChangeOutputIndex != 0 { return nil, fmt.Errorf("no_change_output conflicts with " + @@ -968,15 +970,19 @@ func (r *RPCServer) FundBatch(ctx context.Context, var anchorPsbt *psbt.Packet if len(req.AnchorPsbt) != 0 { if len(req.AnchorPsbt) > maxCustomAnchorPsbtSize { - return nil, fmt.Errorf("anchor PSBT exceeds maximum size of %d "+ - "bytes", maxCustomAnchorPsbtSize) + return nil, fmt.Errorf( + "anchor PSBT exceeds maximum size of %d bytes", + maxCustomAnchorPsbtSize, + ) } var err error anchorPsbt, err = psbt.NewFromRawBytes( bytes.NewReader(req.AnchorPsbt), false, ) if err != nil { - return nil, fmt.Errorf("unable to parse anchor PSBT: %w", err) + return nil, fmt.Errorf( + "unable to parse anchor PSBT: %w", err, + ) } } @@ -1148,20 +1154,26 @@ func (r *RPCServer) FinalizeBatch(ctx context.Context, var signedPsbt *psbt.Packet if len(req.SignedPsbt) != 0 { if len(req.SignedPsbt) > maxCustomAnchorPsbtSize { - return nil, fmt.Errorf("signed anchor PSBT exceeds maximum size "+ - "of %d bytes", maxCustomAnchorPsbtSize) + return nil, fmt.Errorf( + "signed anchor PSBT exceeds maximum "+ + "size of %d bytes", + maxCustomAnchorPsbtSize, + ) } if req.FeeRate != 0 || req.BatchSibling != nil { - return nil, fmt.Errorf("signed_psbt cannot be combined with " + - "fee rate or tapscript sibling") + return nil, fmt.Errorf( + "signed_psbt cannot be combined " + + "with fee rate or tapscript sibling", + ) } var err error signedPsbt, err = psbt.NewFromRawBytes( bytes.NewReader(req.SignedPsbt), false, ) if err != nil { - return nil, fmt.Errorf("unable to parse signed anchor PSBT: %w", - err) + return nil, fmt.Errorf( + "unable to parse signed anchor PSBT: %w", err, + ) } } @@ -6280,8 +6292,9 @@ func marshalMintingBatch(batch *tapgarden.MintingBatch, return nil, err } + batchKey := batch.BatchKey.PubKey.SerializeCompressed() rpcBatch := &mintrpc.MintingBatch{ - BatchKey: batch.BatchKey.PubKey.SerializeCompressed(), + BatchKey: batchKey, State: rpcBatchState, CreatedAt: batch.CreationTime.UTC().Unix(), HeightHint: batch.HeightHint, diff --git a/tapdb/asset_minting.go b/tapdb/asset_minting.go index b7da90dd46..05095c6812 100644 --- a/tapdb/asset_minting.go +++ b/tapdb/asset_minting.go @@ -1,3 +1,4 @@ +//nolint:lll package tapdb import ( @@ -991,7 +992,8 @@ func fetchAssetSeedlings(ctx context.Context, q PendingAssetStore, // https://github.com/kyleconroy/sqlc/issues/1334 is fixed in sqlc, after code // generation, the GroupKeyFamily and GroupKeyIndex fields of the // FetchAssetsForBatchRow need to be manually modified to be sql.NullInt32. -func fetchAssetSprouts(ctx context.Context, q PendingAssetStore, +func fetchAssetSprouts( + ctx context.Context, q PendingAssetStore, rawBatchKey, batchSibling, genScript []byte, mintingInternalKey *btcec.PublicKey) (*commitment.TapCommitment, error) { @@ -1422,8 +1424,9 @@ func marshalMintingBatch(ctx context.Context, q PendingAssetStore, batch.GenesisPacket = &tapgarden.FundedMintAnchorPsbt{ FundedPsbt: tapsend.FundedPsbt{ - Pkt: genesisPkt, - LockedUTXOs: tapgarden.CustomAnchorLockedUTXOs(genesisPkt), + Pkt: genesisPkt, + LockedUTXOs: tapgarden. + CustomAnchorLockedUTXOs(genesisPkt), ChangeOutputIndex: extractSqlInt32[int32]( dbBatch.ChangeOutputIndex, ), @@ -1910,10 +1913,13 @@ func (a *AssetMintingStore) FetchCustomAnchorKeyRepairCandidates( err := a.db.ExecTx(ctx, &readOpts, func(q PendingAssetStore) error { repairQueries, ok := q.(customAnchorKeyRepairQueries) if !ok { - return fmt.Errorf("custom anchor key repair queries unavailable") + return fmt.Errorf( + "custom anchor key repair queries unavailable", + ) } - rows, err := repairQueries.FetchCustomAnchorKeyRepairCandidates(ctx) + rows, err := repairQueries. + FetchCustomAnchorKeyRepairCandidates(ctx) if err != nil { return err } @@ -1923,17 +1929,20 @@ func (a *AssetMintingStore) FetchCustomAnchorKeyRepairCandidates( if row.AssetsOutputIndex.Valid && row.AssetsOutputIndex.Int32 >= 0 { - assetOutputIndex = uint32(row.AssetsOutputIndex.Int32) + assetOutputIndex = uint32( + row.AssetsOutputIndex.Int32, + ) } candidates = append(candidates, tapgarden.CustomAnchorKeyRepairCandidate{ - BatchKey: row.BatchKey, - MintingTxPsbt: row.MintingTxPsbt, - AssetOutputIndex: assetOutputIndex, - Outpoint: row.Outpoint, - ManagedInternalKey: row.ManagedInternalKey, - MerkleRoot: row.MerkleRoot, + BatchKey: row.BatchKey, + MintingTxPsbt: row.MintingTxPsbt, + AssetOutputIndex: assetOutputIndex, + Outpoint: row.Outpoint, + ManagedInternalKey: row. + ManagedInternalKey, + MerkleRoot: row.MerkleRoot, }, ) } @@ -1959,7 +1968,9 @@ func (a *AssetMintingStore) RepairCustomAnchorInternalKey(ctx context.Context, return a.db.ExecTx(ctx, &writeOpts, func(q PendingAssetStore) error { repairQueries, ok := q.(customAnchorKeyRepairQueries) if !ok { - return fmt.Errorf("custom anchor key repair queries unavailable") + return fmt.Errorf( + "custom anchor key repair queries unavailable", + ) } rawKey := desc.PubKey.SerializeCompressed() @@ -1971,7 +1982,10 @@ func (a *AssetMintingStore) RepairCustomAnchorInternalKey(ctx context.Context, }, ) if err != nil { - return fmt.Errorf("unable to store verified custom anchor key: %w", err) + return fmt.Errorf( + "unable to store verified custom anchor key: %w", + err, + ) } updated, err := repairQueries.RepairCustomAnchorInternalKey( @@ -1990,7 +2004,11 @@ func (a *AssetMintingStore) RepairCustomAnchorInternalKey(ctx context.Context, return err } if updated != 1 { - return fmt.Errorf("custom anchor key repair compare-and-swap updated %d rows", updated) + return fmt.Errorf( + "custom anchor key repair "+ + "compare-and-swap updated %d rows", + updated, + ) } return nil @@ -2072,7 +2090,9 @@ func (a *AssetMintingStore) CommitSignedGenesisTxWithKey(ctx context.Context, err = a.db.ExecTx(ctx, &writeTxOpts, func(q PendingAssetStore) error { repairQueries, ok := q.(customAnchorKeyRepairQueries) if !ok { - return fmt.Errorf("wallet-verified internal key query unavailable") + return fmt.Errorf( + "wallet-verified internal key query unavailable", + ) } _, err := repairQueries.UpsertWalletVerifiedInternalKey( @@ -2082,8 +2102,9 @@ func (a *AssetMintingStore) CommitSignedGenesisTxWithKey(ctx context.Context, KeyIndex: int32(mintingInternalKey.Index), }) if err != nil { - return fmt.Errorf("unable to store minting internal key: %w", - err) + return fmt.Errorf( + "unable to store minting internal key: %w", err, + ) } // First, we'll update the genesis packet stored as part of the @@ -2176,7 +2197,9 @@ func (a *AssetMintingStore) StoreSignedGenesisPsbt(ctx context.Context, pktBytes, err := fn.Serialize(genesisPkt.Pkt) if err != nil { - return fmt.Errorf("unable to serialize signed genesis packet: %w", err) + return fmt.Errorf( + "unable to serialize signed genesis packet: %w", err, + ) } rawBatchKey := batchKey.SerializeCompressed() @@ -2184,15 +2207,19 @@ func (a *AssetMintingStore) StoreSignedGenesisPsbt(ctx context.Context, return a.db.ExecTx(ctx, &writeTxOpts, func(q PendingAssetStore) error { batch, err := q.FetchMintingBatch(ctx, rawBatchKey) if err != nil { - return fmt.Errorf("unable to fetch batch before storing signed "+ - "packet: %w", err) + return fmt.Errorf( + "unable to fetch batch before "+ + "storing signed packet: %w", err, + ) } if tapgarden.BatchState(batch.BatchState) != tapgarden.BatchStateCommitted { - return fmt.Errorf("cannot store signed genesis packet for "+ - "batch in state %v", - tapgarden.BatchState(batch.BatchState)) + return fmt.Errorf( + "cannot store signed genesis "+ + "packet for batch in state %v", + tapgarden.BatchState(batch.BatchState), + ) } err = q.UpdateBatchGenesisTx(ctx, GenesisTxUpdate{ @@ -2200,8 +2227,9 @@ func (a *AssetMintingStore) StoreSignedGenesisPsbt(ctx context.Context, MintingTxPsbt: pktBytes, }) if err != nil { - return fmt.Errorf("unable to store signed genesis packet: %w", - err) + return fmt.Errorf( + "unable to store signed genesis packet: %w", err, + ) } return nil diff --git a/tapdb/asset_minting_test.go b/tapdb/asset_minting_test.go index fbc35796d8..1c93471720 100644 --- a/tapdb/asset_minting_test.go +++ b/tapdb/asset_minting_test.go @@ -1,3 +1,4 @@ +//nolint:lll package tapdb import ( diff --git a/tapgarden/batch.go b/tapgarden/batch.go index e758bf1bf4..024610d3d0 100644 --- a/tapgarden/batch.go +++ b/tapgarden/batch.go @@ -1,3 +1,4 @@ +//nolint:lll package tapgarden import ( @@ -85,7 +86,8 @@ type MintingBatch struct { CustomAnchorLeaseError string // CustomAnchorPublishError is the latest ambiguous wallet publication - // error for a byte-identical transaction that remains watched and retried. + // error for a byte-identical transaction that remains watched and + // retried. CustomAnchorPublishError string // CustomAnchorKeyError describes an historical custom-anchor managed @@ -244,8 +246,9 @@ func (m *MintingBatch) MintingInternalKey() (*btcec.PublicKey, error) { pOut.Bip32Derivation[0], ) if err != nil { - return nil, fmt.Errorf("invalid custom anchor key derivation: %w", - err) + return nil, fmt.Errorf( + "invalid custom anchor key derivation: %w", err, + ) } return keyDesc.PubKey, nil diff --git a/tapgarden/caretaker.go b/tapgarden/caretaker.go index a03ab8ca7f..ebfd032e5f 100644 --- a/tapgarden/caretaker.go +++ b/tapgarden/caretaker.go @@ -1,3 +1,4 @@ +//nolint:lll package tapgarden import ( @@ -223,19 +224,23 @@ func customAnchorMintingKeyDescriptor(ctx context.Context, anchorOutputIndex uint32) (keychain.KeyDescriptor, error) { if !isCustomAnchorPsbt(pkt) { - return keychain.KeyDescriptor{}, fmt.Errorf("packet is not a custom " + - "anchor PSBT") + return keychain.KeyDescriptor{}, fmt.Errorf( + "packet is not a custom anchor PSBT", + ) } if pkt == nil || int(anchorOutputIndex) >= len(pkt.Outputs) { - return keychain.KeyDescriptor{}, fmt.Errorf("custom anchor PSBT is "+ - "missing output map %d", anchorOutputIndex) + return keychain.KeyDescriptor{}, fmt.Errorf( + "custom anchor PSBT is missing output map %d", + anchorOutputIndex, + ) } pOut := pkt.Outputs[anchorOutputIndex] if len(pOut.Bip32Derivation) != 1 { return keychain.KeyDescriptor{}, fmt.Errorf("%w; cancel this "+ - "prepublication batch and recreate it with a wallet-owned "+ + "prepublication batch and recreate it with a "+ + "wallet-owned "+ "BIP32 descriptor", ErrLegacyCustomAnchorKeyLocator) } @@ -243,8 +248,9 @@ func customAnchorMintingKeyDescriptor(ctx context.Context, pOut.Bip32Derivation[0], ) if err != nil { - return keychain.KeyDescriptor{}, fmt.Errorf("invalid custom anchor "+ - "wallet key locator: %w", err) + return keychain.KeyDescriptor{}, fmt.Errorf( + "invalid custom anchor wallet key locator: %w", err, + ) } if !keyRing.IsLocalKey(ctx, desc) { return keychain.KeyDescriptor{}, fmt.Errorf("custom anchor wallet " + @@ -320,9 +326,11 @@ func (b *BatchCaretaker) Cancel() error { case BatchStateCommitted: if customAnchorPublicationPending(b.cfg.Batch) { - err := fmt.Errorf("BatchCaretaker(%x), custom anchor "+ - "publication status is ambiguous and is not cancellable", - batchKey) + err := fmt.Errorf( + "BatchCaretaker(%x), custom anchor publication "+ + "status is ambiguous and is not cancellable", + batchKey, + ) cancelResp = CancelResp{false, err} break } @@ -551,8 +559,10 @@ func (b *BatchCaretaker) assetCultivator() { b.cfg.Batch.GenesisPacket.Pkt, ) if err != nil { - statusErr := fmt.Errorf("unable to extract persisted custom "+ - "anchor retry transaction: %w", err) + statusErr := fmt.Errorf( + "unable to extract persisted custom anchor "+ + "retry transaction: %w", err, + ) b.setCustomAnchorPublishError(statusErr.Error()) b.cfg.PublishMintEvent(newAssetMintErrorEvent( statusErr, BatchStateBroadcast, b.cfg.Batch, @@ -860,9 +870,10 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) if isCustomAnchorPsbt(genesisTxPkt) { // Once the asset root changes the output script, an internal // key without a matching PSBT_OUT_TAP_TREE would describe a - // contradictory BIP-86 output. Keep the independently validated - // BIP32 locator for wallet bookkeeping, but remove the stale - // BIP-371 output declarations before external signing. + // contradictory BIP-86 output. Keep the independently + // validated BIP32 locator for wallet bookkeeping, but remove + // the stale BIP-371 output declarations before external + // signing. pOut := &genesisTxPkt.Outputs[b.anchorOutputIndex] pOut.TaprootInternalKey = nil pOut.TaprootBip32Derivation = nil @@ -981,7 +992,8 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) // Resolve the wallet-owned key descriptor before any wallet import or // chain publication. Historical raw-only descriptors remain safely - // prepublication and receive an actionable cancellation/recreate error. + // prepublication and receive an actionable cancellation/recreate + // error. mintingInternalKey, err := b.mintingInternalKeyDescriptor( ctx, signedPkt, ) @@ -1058,13 +1070,15 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) if isCustomAnchorPsbt(signedPkt) { var publishBytes bytes.Buffer if err := signedPkt.Serialize(&publishBytes); err != nil { - return 0, fmt.Errorf("unable to copy custom anchor PSBT: %w", - err) + return 0, fmt.Errorf( + "unable to copy custom anchor PSBT: %w", err, + ) } publishPkt, err := psbt.NewFromRawBytes(&publishBytes, false) if err != nil { - return 0, fmt.Errorf("unable to copy custom anchor PSBT: %w", - err) + return 0, fmt.Errorf( + "unable to copy custom anchor PSBT: %w", err, + ) } setCustomAnchorPublishState( publishPkt, customAnchorPublishPending, @@ -1110,9 +1124,9 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) // A retained publish-pending marker means an earlier // process may already have handed these exact bytes to - // WalletKit. Cross into durable Broadcast so the watcher is - // installed, but don't actively republish until the recorded - // local leases are reacquired. + // WalletKit. Cross into durable Broadcast so the watcher + // is installed, but don't actively republish until the + // recorded local leases are reacquired. if publishStateAtEntry != customAnchorPublishPending { return 0, statusErr } @@ -1130,13 +1144,16 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) publishCancel() } if tapnode.IsDefinitivePublishError(publishErr) { - // Classification is diagnostic only. The authenticated caller - // holds the fully signed bytes and an external relay cannot be - // disproved, so every WalletKit attempt crosses the durable - // Broadcast boundary and retains confirmation tracking. + // Classification is diagnostic only. The authenticated + // caller holds the fully signed bytes and an external + // relay cannot be disproved, so each WalletKit attempt + // crosses the durable Broadcast boundary and retains + // confirmation tracking. b.setCustomAnchorPublishError(fmt.Sprintf( - "wallet policy rejected publication; transaction is "+ - "still treated as potentially relayed: %v", publishErr, + "wallet policy rejected publication; "+ + "transaction is still treated as "+ + "potentially relayed: %v", + publishErr, )) } b.customAnchorWalletAccepted = publishAttempted && @@ -1237,10 +1254,13 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) renewCancel() if renewErr != nil { b.setCustomAnchorLeaseError(renewErr.Error()) - b.cfg.PublishMintEvent(newAssetMintErrorEvent( - fmt.Errorf("custom anchor lease renewal "+ + renewStatusErr := fmt.Errorf( + "custom anchor lease renewal "+ "before publish retry failed: %w", - renewErr), + renewErr, + ) + b.cfg.PublishMintEvent(newAssetMintErrorEvent( + renewStatusErr, BatchStateBroadcast, b.cfg.Batch, )) } else { @@ -1263,12 +1283,19 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) if customAnchor { b.customAnchorWalletAccepted = err == nil if err != nil { - b.setCustomAnchorPublishError(err.Error()) + publishErrText := err.Error() + b.setCustomAnchorPublishError( + publishErrText, + ) + publishStatusErr := fmt.Errorf( + "custom anchor publish remains "+ + "ambiguous: %w", err, + ) b.cfg.PublishMintEvent( newAssetMintErrorEvent( - fmt.Errorf("custom anchor publish "+ - "remains ambiguous: %w", err), - BatchStateBroadcast, b.cfg.Batch, + publishStatusErr, + BatchStateBroadcast, + b.cfg.Batch, ), ) } else { diff --git a/tapgarden/custom_anchor_repair.go b/tapgarden/custom_anchor_repair.go index 600dd6ba00..483caa85ac 100644 --- a/tapgarden/custom_anchor_repair.go +++ b/tapgarden/custom_anchor_repair.go @@ -1,3 +1,4 @@ +//nolint:lll package tapgarden import ( @@ -233,7 +234,6 @@ func auditCustomAnchorKeyCandidate(ctx context.Context, return result, zeroDesc, false } if outpoint.Hash != finalTx.TxHash() { - return invalid("retained asset outpoint doesn't match the mint " + "transaction txid") } @@ -269,8 +269,8 @@ func auditCustomAnchorKeyCandidate(ctx context.Context, result.Status = CustomAnchorKeyLocatorRequired result.Detail = "retained packet proves only the raw anchor key; " + - "automatic repair is unavailable and wallet-validated operator " + - "recovery is required" + "automatic repair is unavailable and wallet-validated " + + "operator recovery is required" return result, zeroDesc, true } if len(pOut.Bip32Derivation) != 1 { @@ -316,8 +316,10 @@ func auditCustomAnchorKeyCandidate(ctx context.Context, !equalUint32s(expectedTaproot.Bip32Path, actual.Bip32Path) || len(actual.LeafHashes) != 0 { - return invalid("retained taproot key locator conflicts with the " + - "wallet descriptor") + return invalid( + "retained taproot key locator conflicts with the " + + "wallet descriptor", + ) } } @@ -363,8 +365,8 @@ func auditCustomAnchorKeyCandidate(ctx context.Context, if !keyRing.IsLocalKey(ctx, desc) { result.Status = CustomAnchorKeyNotLocal - result.Detail = "retained key locator doesn't derive to the anchor key " + - "in the backing wallet" + result.Detail = "retained key locator doesn't derive to the " + + "anchor key in the backing wallet" return result, desc, true } if managedIsAnchorKey { diff --git a/tapgarden/custom_anchor_repair_test.go b/tapgarden/custom_anchor_repair_test.go index 3e675bed9c..ca8da3585b 100644 --- a/tapgarden/custom_anchor_repair_test.go +++ b/tapgarden/custom_anchor_repair_test.go @@ -1,3 +1,4 @@ +//nolint:lll package tapgarden import ( diff --git a/tapgarden/custom_anchor_test.go b/tapgarden/custom_anchor_test.go index c7d60328e8..4353f0c2bc 100644 --- a/tapgarden/custom_anchor_test.go +++ b/tapgarden/custom_anchor_test.go @@ -1,3 +1,4 @@ +//nolint:lll package tapgarden import ( diff --git a/tapgarden/interface.go b/tapgarden/interface.go index 1735bc305b..15e639faf1 100644 --- a/tapgarden/interface.go +++ b/tapgarden/interface.go @@ -1,3 +1,4 @@ +//nolint:lll package tapgarden import ( diff --git a/tapgarden/issue721_stateful_test.go b/tapgarden/issue721_stateful_test.go index 2d10e49864..69c72c717b 100644 --- a/tapgarden/issue721_stateful_test.go +++ b/tapgarden/issue721_stateful_test.go @@ -1,3 +1,4 @@ +//nolint:lll package tapgarden_test import ( diff --git a/tapgarden/planter.go b/tapgarden/planter.go index b5afaf9cff..21d3aedce4 100644 --- a/tapgarden/planter.go +++ b/tapgarden/planter.go @@ -1,3 +1,4 @@ +//nolint:lll package tapgarden import ( @@ -480,7 +481,8 @@ func parseCustomAnchorLockedUTXOs(packet *psbt.Packet) ([]wire.OutPoint, offset += 36 if _, ok := seen[ops[idx]]; ok { return nil, customAnchorLeaseMarkerCurrent, - fmt.Errorf("duplicate custom anchor leased input %v", ops[idx]) + fmt.Errorf("duplicate custom anchor leased input %v", + ops[idx]) } seen[ops[idx]] = struct{}{} } @@ -570,7 +572,8 @@ func acquireCustomAnchorLeases(ctx context.Context, wallet tapnode.WalletAnchor, ctx, wallet, leaseID, newlyLocked, ) return nil, errors.Join(fmt.Errorf( - "unable to renew custom anchor input lease %v", op, + "unable to renew custom anchor "+ + "input lease %v", op, ), releaseErr) } continue @@ -695,8 +698,10 @@ func renewCustomAnchorLeases(ctx context.Context, wallet tapnode.WalletAnchor, op, err) } if !owned { - return fmt.Errorf("custom anchor input %v is no longer available "+ - "to the backing wallet", op) + return fmt.Errorf( + "custom anchor input %v is no longer "+ + "available to the backing wallet", op, + ) } } @@ -769,9 +774,10 @@ func getCustomAnchorPublishState( return customAnchorPublishNone case customAnchorPublishRejected: - // Older builds wrote this marker only after submitting the - // fully signed bytes. Treat it as publication-ambiguous so an - // upgrade cannot expose cancellation or release its leases. + // Older builds wrote this marker only after + // submitting the fully signed bytes. Treat it as + // publication-ambiguous so an upgrade cannot expose + // cancellation or release its leases. return customAnchorPublishPending case customAnchorPublishPending, @@ -1079,8 +1085,10 @@ func (c *ChainPlanter) Start() error { if health.RequiresIntervention() { batchKey, err := btcec.ParsePubKey(health.BatchKey) if err != nil { - log.Errorf("Unable to index historical custom "+ - "anchor key health for batch_key=%x: %v", + log.Errorf( + "Unable to index historical custom "+ + "anchor key health for "+ + "batch_key=%x: %v", health.BatchKey, err) } else { key := asset.ToSerialized(batchKey) @@ -1091,9 +1099,11 @@ func (c *ChainPlanter) Start() error { ) } - log.Errorf("Historical custom anchor key requires "+ - "operator action: status=%s, batch_key=%x, "+ - "outpoint=%v, detail=%s", health.Status, + log.Errorf( + "Historical custom anchor key "+ + "requires operator action: status=%s, "+ + "batch_key=%x, "+ + "outpoint=%v, detail=%s", health.Status, health.BatchKey, health.Outpoint, health.Detail) continue @@ -1108,8 +1118,10 @@ func (c *ChainPlanter) Start() error { } if health.Status == CustomAnchorKeyRepaired { - log.Infof("Repaired historical custom anchor key: "+ - "batch_key=%x, outpoint=%v", health.BatchKey, + log.Infof( + "Repaired historical custom anchor key: "+ + "batch_key=%x, outpoint=%v", + health.BatchKey, health.Outpoint) } } @@ -1173,25 +1185,29 @@ func (c *ChainPlanter) Start() error { (batchState == BatchStatePending || batchState == BatchStateFrozen || (batchState == BatchStateCommitted && - publishState != customAnchorPublishPending && - publishState != customAnchorImportPending)) + publishState != + customAnchorPublishPending && + publishState != + customAnchorImportPending)) leaseRenewalFailed := false if customBatch && (batchState == BatchStatePending || batchState == BatchStateFrozen || batchState == BatchStateCommitted) { - if err := renewCustomAnchorLeases( + renewErr := renewCustomAnchorLeases( ctx, c.cfg.Wallet, customAnchorLeaseID(batch.BatchKey.PubKey), batch.GenesisPacket, - ); err != nil { + ) + if renewErr != nil { batch.CustomAnchorLeaseError = fmt.Sprintf( - "custom anchor input lease renewal degraded during "+ - "startup; Finalize will retry and fail closed: %v", - err, + "custom anchor input lease renewal "+ + "degraded during startup; Finalize will "+ + "retry and fail closed: %v", + renewErr, ) log.Warnf("Unable to renew custom anchor input "+ - "leases during startup: %v", err) + "leases during startup: %v", renewErr) leaseRenewalFailed = true } else { batch.CustomAnchorLeaseError = "" @@ -2006,10 +2022,10 @@ func customGenesisPsbt(chainParams address.ChainParams, return zero, fmt.Errorf("invalid custom asset anchor internal "+ "key: %w", err) } - if _, err := customAnchorKeyDesc( + _, err = customAnchorKeyDesc( chainParams, packet, assetAnchorOutIdx, - ); err != nil { - + ) + if err != nil { return zero, err } if pOut.TaprootTapTree != nil { @@ -2021,10 +2037,10 @@ func customGenesisPsbt(chainParams address.ChainParams, // read-only and must run before serialization so malformed metadata gets // its established, precise error without touching the caller's packet. if pendingBatch == nil || !pendingBatch.SupplyCommitments { - if err := validateExclusionProofOutputs( + err = validateExclusionProofOutputs( packet, assetAnchorOutIdx, - ); err != nil { - + ) + if err != nil { return zero, err } } @@ -2109,10 +2125,10 @@ func customGenesisPsbt(chainParams address.ChainParams, return zero, fmt.Errorf("pre-commitment output index specified " + "for batch without supply commitments") } - if err := validateExclusionProofOutputs( + err = validateExclusionProofOutputs( packet, assetAnchorOutIdx, - ); err != nil { - + ) + if err != nil { return zero, err } indexes := AnchorTxOutputIndexes{ @@ -2968,7 +2984,6 @@ func (c *ChainPlanter) cancelMintingBatch(ctx context.Context, case BatchStateCommitted: if customAnchorPublicationPending(c.pendingBatch) { - return fmt.Errorf("custom anchor publication status is " + "ambiguous and is not cancellable") } @@ -3193,10 +3208,12 @@ func (c *ChainPlanter) gardener() { if batch.CustomAnchorLeaseError != statusErr.Error() { - batch.CustomAnchorLeaseError = statusErr.Error() + leaseErrText := statusErr.Error() + batch.CustomAnchorLeaseError = leaseErrText c.publishSubscriberEvent( newAssetMintErrorEvent( - statusErr, batch.State(), batch, + statusErr, + batch.State(), batch, ), ) } @@ -3211,6 +3228,10 @@ func (c *ChainPlanter) gardener() { batch.State(), batch, )) } + + case BatchStateBroadcast, BatchStateConfirmed, + BatchStateFinalized, BatchStateSeedlingCancelled, + BatchStateSproutCancelled: } // A request for new asset issuance just arrived, add this to @@ -3386,6 +3407,7 @@ func (c *ChainPlanter) gardener() { } if c.pendingBatch.State() != BatchStatePending && c.pendingBatch.State() != BatchStateFrozen { + req.Error(fmt.Errorf("batch in state %v cannot be "+ "sealed", c.pendingBatch.State())) break @@ -3455,9 +3477,10 @@ func (c *ChainPlanter) gardener() { batchKey := c.pendingBatch.BatchKey.PubKey batchKeySerial := asset.ToSerialized(batchKey) - // Determine the batch kind before starting the caretaker. The - // caretaker owns and mutates the batch once Start returns, so the - // gardener must not inspect that shared packet afterwards. + // Determine the batch kind before starting the + // caretaker. The caretaker owns and mutates the batch + // once Start returns, so the gardener must not inspect + // that shared packet afterwards. customBatch := c.pendingBatch.GenesisPacket != nil && isCustomAnchorPsbt( c.pendingBatch.GenesisPacket.Pkt, @@ -3634,6 +3657,7 @@ func (c *ChainPlanter) prepareFunding(ctx context.Context, computeFunding := func(batch *MintingBatch) ( *FundedMintAnchorPsbt, error) { + if params.AnchorPsbt != nil { anchorKeyDesc, err := customAnchorKeyDesc( c.cfg.ChainParams, params.AnchorPsbt, diff --git a/tapgarden/planter_test.go b/tapgarden/planter_test.go index ea941d4ccc..02e319bf27 100644 --- a/tapgarden/planter_test.go +++ b/tapgarden/planter_test.go @@ -1,3 +1,4 @@ +//nolint:lll package tapgarden_test import ( diff --git a/tapnode/tapnodemock/chain_bridge.go b/tapnode/tapnodemock/chain_bridge.go index d71cdb82f0..8d14f5b412 100644 --- a/tapnode/tapnodemock/chain_bridge.go +++ b/tapnode/tapnodemock/chain_bridge.go @@ -37,7 +37,8 @@ type ChainBridge struct { // the corresponding store and a read of ConfReqs[reqNo] is safe. ConfReqs map[int]*chainntnfs.ConfirmationEvent // ConfPkScripts records the confirmation script hint for each request. - // It follows the same ConfReqSignal synchronization contract as ConfReqs. + // It follows the same ConfReqSignal synchronization contract as + // ConfReqs. ConfPkScripts map[int][]byte // ConfTxIDs records the transaction ID for each confirmation request. It // follows the same ConfReqSignal synchronization contract as ConfReqs. @@ -289,7 +290,8 @@ func (m *ChainBridge) ValidateAndPublishTransaction(_ context.Context, if m.failPublishFinal.Load() { m.PublishAttempts <- tx.Copy() return tapnode.NewDefinitivePublishError( - fmt.Errorf("transaction rejected: output already spent"), + fmt.Errorf("transaction rejected: " + + "output already spent"), ) } if m.failPublish.CompareAndSwap(true, false) { From db41bb4faf6fdce01915f008359f340252cde7b2 Mon Sep 17 00:00:00 2001 From: Sadiq Jaffer Date: Fri, 21 Aug 2026 09:41:54 +0100 Subject: [PATCH 17/24] tapgarden: reserve admission after publish failure --- tapgarden/caretaker.go | 13 +++++ tapgarden/issue721_stateful_test.go | 90 ++++++++++++++++++++++++++++- tapgarden/planter.go | 47 ++++++++++----- 3 files changed, 134 insertions(+), 16 deletions(-) diff --git a/tapgarden/caretaker.go b/tapgarden/caretaker.go index ebfd032e5f..68611bbd83 100644 --- a/tapgarden/caretaker.go +++ b/tapgarden/caretaker.go @@ -1161,6 +1161,14 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) if b.customAnchorWalletAccepted { b.setCustomAnchorLeaseError("") b.setCustomAnchorPublishError("") + + // A clean WalletKit acceptance distinguishes this batch + // from one whose signed bytes might only have been relayed + // externally. Clear the durable admission marker before + // CommitSignedGenesisTx stores the Broadcast packet. + if publishStateAtEntry != customAnchorPublishPending { + stripCustomAnchorPublishState(signedPkt) + } } } @@ -1299,6 +1307,11 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) ), ) } else { + // Keep any initial-failure admission marker until + // confirmation. A later retry success cannot prove + // whether the caller independently relayed the first + // attempt, so releasing the slot here would re-open + // amplification before an on-chain outcome. b.setCustomAnchorPublishError("") } } diff --git a/tapgarden/issue721_stateful_test.go b/tapgarden/issue721_stateful_test.go index 69c72c717b..05e9d44b44 100644 --- a/tapgarden/issue721_stateful_test.go +++ b/tapgarden/issue721_stateful_test.go @@ -10,8 +10,11 @@ import ( "testing" "time" + "github.com/btcsuite/btcd/blockchain" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chaincfg/v2" "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/psbt/v2" "github.com/btcsuite/btcd/txscript/v2" @@ -408,6 +411,19 @@ func TestIssue721PrepareSignResume(t *testing.T) { require.Equal(t, original.UnsignedTx.TxOut[1].Value, published.TxOut[1].Value) require.Equal(t, tapgarden.BatchStateBroadcast, minted.State()) + available, err := h.planter.PendingBatch() + require.NoError(t, err) + require.Nil(t, available) + + // A clean WalletKit acceptance clears the internal publication marker + // before the signed packet is committed in Broadcast. + persisted := h.fetchSingleBatch(prepared.BatchKey.PubKey) + for _, unknown := range persisted.GenesisPacket.Pkt.Unknowns { + require.NotEqual( + t, []byte{0xfc, 0x04, 't', 'a', 'p', 'd', 0x02}, + unknown.Key, + ) + } } // TestIssue721PublishRetry verifies a one-shot ambiguous initial submission is @@ -588,12 +604,80 @@ func TestIssue721ClassifiedPublishFailureRemainsBroadcast(t *testing.T) { require.Equal(t, signedTx.WitnessHash(), persistedTx.WitnessHash()) h.assertNumCaretakersActive(1) _, err = h.planter.CancelBatch() - require.ErrorContains(t, err, "not cancellable") + require.ErrorContains(t, err, "batch recovery is in progress") + + // A backend reject keeps the exclusive admission reservation even after + // the watch-only Broadcast retry succeeds. The transaction is still + // tracked, but the caller cannot amplify it through another mint. + reserved, err := h.planter.PendingBatch() + require.NoError(t, err) + require.Equal(t, tapgarden.BatchStateBroadcast, reserved.State()) + second := issue721Seedling() + second.AssetName = "issue-721-rejected-second" + updates, err := h.planter.QueueNewSeedling(second) + require.NoError(t, err) + update, err := fn.RecvOrTimeout(updates, defaultTimeout) + require.NoError(t, err) + require.ErrorContains(t, update.Error, "cannot accept new seedlings") select { case released := <-h.wallet.ReleaseInputSignal: t.Fatalf("broadcast input lease unexpectedly released: %v", released) default: } + + // Restarting a durable Broadcast packet with the publication marker must + // reconstruct the same reservation before accepting API requests. + h.refreshChainPlanter() + restartConfReq, err := fn.RecvOrTimeout( + h.chain.ConfReqSignal, defaultTimeout, + ) + require.NoError(t, err) + restartLease, err := fn.RecvOrTimeout( + h.wallet.LeaseInputSignal, defaultTimeout, + ) + require.NoError(t, err) + require.Equal(t, ownedInput, *restartLease) + restartedTx := h.assertTxPublished() + require.Equal(t, signedTx.WitnessHash(), restartedTx.WitnessHash()) + + restartedReservation, err := h.planter.PendingBatch() + require.NoError(t, err) + require.Equal( + t, tapgarden.BatchStateBroadcast, restartedReservation.State(), + ) + second.AssetName = "issue-721-rejected-after-restart" + updates, err = h.planter.QueueNewSeedling(second) + require.NoError(t, err) + update, err = fn.RecvOrTimeout(updates, defaultTimeout) + require.NoError(t, err) + require.ErrorContains(t, update.Error, "cannot accept new seedlings") + + // Confirmation is the release boundary. Once the watched transaction + // completes, the reservation is cleared and a fresh mint is admitted. + merkleTree := blockchain.BuildMerkleTreeStore( + []*btcutil.Tx{btcutil.NewTx(restartedTx)}, false, + ) + merkleRoot := merkleTree[len(merkleTree)-1] + blockHeader := wire.NewBlockHeader( + 0, chaincfg.MainNetParams.GenesisHash, merkleRoot, 0, 0, + ) + block := &wire.MsgBlock{ + Header: *blockHeader, + Transactions: []*wire.MsgTx{restartedTx}, + } + blockHash := block.BlockHash() + h.chain.SetBlock(blockHash, block) + h.chain.SendConfNtfn( + *restartConfReq, &blockHash, 1, 0, block, restartedTx, + ) + h.assertNumCaretakersActive(0) + available, err := h.planter.PendingBatch() + require.NoError(t, err) + require.Nil(t, available) + + third := issue721Seedling() + third.AssetName = "issue-721-after-confirmation" + h.queueSeedlingsInBatch(false, third) } // TestIssue721ImportRetry verifies that a transient wallet import failure is @@ -1004,7 +1088,7 @@ func TestIssue721PublishPendingRestartLeaseFailure(t *testing.T) { case <-time.After(2 * h.leaseRenewalInterval): } _, err = h.planter.CancelBatch() - require.ErrorContains(t, err, "not cancellable") + require.ErrorContains(t, err, "batch recovery is in progress") batches, err := h.planter.ListBatches(tapgarden.ListBatchesParams{ BatchKey: prepared.BatchKey.PubKey, @@ -1108,7 +1192,7 @@ func TestIssue721CorruptLeaseMarkerWatchesWithoutPublishing(t *testing.T) { require.Contains(t, batches[0].CustomAnchorLeaseError, "invalid custom anchor lease marker") _, err = h.planter.CancelBatch() - require.ErrorContains(t, err, "not cancellable") + require.ErrorContains(t, err, "batch recovery is in progress") } // TestIssue721RecoveredCaretakerFastConfirmation verifies that confirmation diff --git a/tapgarden/planter.go b/tapgarden/planter.go index 21d3aedce4..4de57e2bdb 100644 --- a/tapgarden/planter.go +++ b/tapgarden/planter.go @@ -1234,17 +1234,21 @@ func (c *ChainPlanter) Start() error { } // A custom batch with a signed transaction awaiting import or - // publication is resumed automatically. Adopt it as the exclusive - // pending batch before launching its caretaker so no new batch can - // be admitted while recovery is in flight. + // publication is resumed automatically. A Broadcast batch whose + // initial WalletKit submission failed keeps the same reservation: + // it remains watched and immutable, but cannot be used to amplify + // caller-relayed transactions through additional concurrent mints. startupCustomRecovery := customBatch && batchState == BatchStateCommitted && (publishState == customAnchorImportPending || publishState == customAnchorPublishPending) - if startupCustomRecovery { + startupAdmissionReservation := startupCustomRecovery || + (customBatch && batchState == BatchStateBroadcast && + publishState == customAnchorPublishPending) + if startupAdmissionReservation { if c.pendingBatch != nil { startErr = fmt.Errorf("multiple custom batches " + - "require startup recovery") + "require an admission reservation") return } @@ -3149,15 +3153,22 @@ func (c *ChainPlanter) gardener() { } if result.err == nil { - // Publication succeeded. Release the exclusive recovery - // slot, but retain the caretaker while it waits for - // confirmation. + // A clean first publication releases the exclusive recovery + // slot. A retained initial-failure marker keeps it until + // confirmation even if this recovery attempt succeeded. if c.pendingBatch != nil && asset.ToSerialized( c.pendingBatch.BatchKey.PubKey, ) == result.batchKey { - c.pendingBatch = nil + if customAnchorPublicationPending( + caretaker.cfg.Batch, + ) { + + c.pendingBatch = caretaker.cfg.Batch.Copy() + } else { + c.pendingBatch = nil + } } continue @@ -3515,11 +3526,21 @@ func (c *ChainPlanter) gardener() { // broadcast the batch or fail to do so. select { case <-caretaker.cfg.BroadcastCompleteChan: - req.Resolve(caretaker.cfg.Batch) + // A failed initial custom publication remains + // watched in Broadcast, but retains the exclusive + // admission slot until confirmation. A successful + // WalletKit acceptance cleared the marker before the + // durable Broadcast transition and remains nonblocking. + if customAnchorPublicationPending( + caretaker.cfg.Batch, + ) { - // The batch has been broadcast, so we - // can remove the pending batch. - c.pendingBatch = nil + c.pendingBatch = caretaker.cfg.Batch.Copy() + } else { + c.pendingBatch = nil + } + + req.Resolve(caretaker.cfg.Batch) case err := <-caretaker.cfg.BroadcastErrChan: // Stop the failed caretaker directly. Custom From fa77c1f70ce331fd7242c0c05b3c21644228f7fb Mon Sep 17 00:00:00 2001 From: Sadiq Jaffer Date: Fri, 21 Aug 2026 09:46:31 +0100 Subject: [PATCH 18/24] tapgarden: clear custom publish state with active helper --- tapgarden/caretaker.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tapgarden/caretaker.go b/tapgarden/caretaker.go index 68611bbd83..698ec0ac9b 100644 --- a/tapgarden/caretaker.go +++ b/tapgarden/caretaker.go @@ -1167,7 +1167,9 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) // externally. Clear the durable admission marker before // CommitSignedGenesisTx stores the Broadcast packet. if publishStateAtEntry != customAnchorPublishPending { - stripCustomAnchorPublishState(signedPkt) + setCustomAnchorPublishState( + signedPkt, customAnchorPublishNone, + ) } } } From f55bfdbbfaafa48848df9afc22b40ddff1cdbd08 Mon Sep 17 00:00:00 2001 From: Sadiq Jaffer Date: Fri, 21 Aug 2026 09:56:12 +0100 Subject: [PATCH 19/24] tapgarden: retain publish marker until commit --- tapgarden/caretaker.go | 17 ++++-- tapgarden/interface.go | 7 ++- tapgarden/interface_compat_test.go | 24 +++++++- tapgarden/issue721_stateful_test.go | 95 ++++++++++++++++++++++++++++- 4 files changed, 136 insertions(+), 7 deletions(-) diff --git a/tapgarden/caretaker.go b/tapgarden/caretaker.go index 698ec0ac9b..bc63780b46 100644 --- a/tapgarden/caretaker.go +++ b/tapgarden/caretaker.go @@ -1108,6 +1108,8 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) var ( publishErr error publishAttempted bool + commitPacket = &b.cfg.Batch.GenesisPacket.FundedPsbt + acceptedPacket *FundedMintAnchorPsbt ) if isCustomAnchorPsbt(signedPkt) { renewCtx, renewCancel := b.WithCtxQuit() @@ -1164,12 +1166,16 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) // A clean WalletKit acceptance distinguishes this batch // from one whose signed bytes might only have been relayed - // externally. Clear the durable admission marker before - // CommitSignedGenesisTx stores the Broadcast packet. + // externally. Clear the marker only on the copy passed to + // the atomic Broadcast commit. The live Committed packet + // must retain publish-pending until that commit succeeds. if publishStateAtEntry != customAnchorPublishPending { + acceptedPacket = b.cfg.Batch.GenesisPacket.Copy() setCustomAnchorPublishState( - signedPkt, customAnchorPublishNone, + acceptedPacket.Pkt, + customAnchorPublishNone, ) + commitPacket = &acceptedPacket.FundedPsbt } } } @@ -1180,7 +1186,7 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) TapscriptRoot(nil) err = commitSignedGenesisTx( ctx, b.cfg.Log, b.cfg.Batch, mintingInternalKey, - &b.cfg.Batch.GenesisPacket.FundedPsbt, + commitPacket, b.anchorOutputIndex, merkleRoot, tapCommitmentRoot[:], siblingBytes, ) @@ -1188,6 +1194,9 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) return 0, fmt.Errorf("unable to commit genesis "+ "tx: %w", err) } + if acceptedPacket != nil { + b.cfg.Batch.GenesisPacket.Pkt = acceptedPacket.Pkt + } // If the initial submission failed ambiguously, the transaction // might still have reached the backend. Continue into Broadcast so diff --git a/tapgarden/interface.go b/tapgarden/interface.go index 15e639faf1..0673cc3cf9 100644 --- a/tapgarden/interface.go +++ b/tapgarden/interface.go @@ -336,12 +336,17 @@ type MintingInternalKeyStore interface { func storeSignedGenesisPsbt(ctx context.Context, store MintingStore, batchKey *btcec.PublicKey, genesisTx *tapsend.FundedPsbt) error { - signedStore, ok := store.(SignedGenesisPsbtStore) if !ok { return fmt.Errorf("minting store does not support signed custom " + "genesis persistence") } + if genesisTx != nil && isCustomAnchorPsbt(genesisTx.Pkt) { + if _, ok := store.(MintingInternalKeyStore); !ok { + return fmt.Errorf("minting store does not support custom anchor " + + "internal keys") + } + } return signedStore.StoreSignedGenesisPsbt( ctx, batchKey, genesisTx, diff --git a/tapgarden/interface_compat_test.go b/tapgarden/interface_compat_test.go index ca42b84139..dd60628726 100644 --- a/tapgarden/interface_compat_test.go +++ b/tapgarden/interface_compat_test.go @@ -44,6 +44,19 @@ type internalKeyStore struct { committedKey keychain.KeyDescriptor } +type customMintingStore struct { + *internalKeyStore + + stored bool +} + +func (s *customMintingStore) StoreSignedGenesisPsbt(_ context.Context, + _ *btcec.PublicKey, _ *tapsend.FundedPsbt) error { + + s.stored = true + return nil +} + func (s *internalKeyStore) CommitSignedGenesisTxWithKey(_ context.Context, _ *MintingBatch, key keychain.KeyDescriptor, _ *tapsend.FundedPsbt, _ uint32, _, _, _ []byte) error { @@ -116,7 +129,16 @@ func TestMintingStoreCompatibilityAdapters(t *testing.T) { ) require.ErrorContains(t, err, "signed custom genesis") - store := &signedPsbtStore{legacyMintingStore: legacy} + partial := &signedPsbtStore{legacyMintingStore: legacy} + err = storeSignedGenesisPsbt( + t.Context(), partial, nil, customPacket, + ) + require.ErrorContains(t, err, "custom anchor internal keys") + require.False(t, partial.stored) + + store := &customMintingStore{internalKeyStore: &internalKeyStore{ + legacyMintingStore: legacy, + }} err = storeSignedGenesisPsbt( t.Context(), store, nil, customPacket, ) diff --git a/tapgarden/issue721_stateful_test.go b/tapgarden/issue721_stateful_test.go index 05e9d44b44..f65930dbe2 100644 --- a/tapgarden/issue721_stateful_test.go +++ b/tapgarden/issue721_stateful_test.go @@ -34,7 +34,31 @@ import ( type issue721FailSignedStore struct { tapgarden.MintingStore - fail bool + fail bool + failCommit bool + commitAttempted bool +} + +func (s *issue721FailSignedStore) CommitSignedGenesisTxWithKey( + ctx context.Context, batch *tapgarden.MintingBatch, + mintingInternalKey keychain.KeyDescriptor, + genesisTx *tapsend.FundedPsbt, anchorOutputIndex uint32, + merkleRoot, tapTreeRoot, tapSibling []byte) error { + + s.commitAttempted = true + if s.failCommit { + return fmt.Errorf("injected signed genesis commit failure") + } + + keyStore, ok := s.MintingStore.(tapgarden.MintingInternalKeyStore) + if !ok { + return fmt.Errorf("minting store does not support internal keys") + } + + return keyStore.CommitSignedGenesisTxWithKey( + ctx, batch, mintingInternalKey, genesisTx, anchorOutputIndex, + merkleRoot, tapTreeRoot, tapSibling, + ) } type issue721FailStateStore struct { @@ -1446,6 +1470,75 @@ func TestIssue721RetryStoreFailureIsAtomic(t *testing.T) { require.NoError(t, err) } +// TestIssue721PublishSuccessCommitFailureRetainsReservation verifies that a +// successful WalletKit submission cannot make the live Committed packet appear +// cancellable before its Broadcast transition is durable. +func TestIssue721PublishSuccessCommitFailureRetainsReservation(t *testing.T) { + store := &issue721FailSignedStore{MintingStore: newMintingStore(t)} + h := newMintingTestHarness(t, store) + h.refreshChainPlanter() + t.Cleanup(func() { + if h.planter != nil { + _ = h.planter.Stop() + } + }) + + h.queueSeedlingsInBatch(false, issue721Seedling()) + pkt, _, witnessScript := issue721Anchor(t) + ownedInput := pkt.UnsignedTx.TxIn[0].PreviousOutPoint + h.wallet.SetOwnedInput(ownedInput, true) + issue721Fund(t, h, pkt) + initialLease, err := fn.RecvOrTimeout( + h.wallet.LeaseInputSignal, defaultTimeout, + ) + require.NoError(t, err) + require.Equal(t, ownedInput, *initialLease) + prepared, err := h.planter.PrepareBatch() + require.NoError(t, err) + + signed := clonePacket(t, prepared.GenesisPacket.Pkt) + signed.Inputs[0].FinalScriptWitness = issue721FinalWitness( + t, witnessScript, + ) + store.failCommit = true + var wg sync.WaitGroup + respChan := make(chan *FinalizeBatchResp, 1) + h.finalizeBatch(&wg, respChan, &tapgarden.FinalizeParams{ + SignedPsbt: signed, + }) + finalizeLease, err := fn.RecvOrTimeout( + h.wallet.LeaseInputSignal, defaultTimeout, + ) + require.NoError(t, err) + require.Equal(t, ownedInput, *finalizeLease) + _, err = fn.RecvOrTimeout( + h.wallet.ImportPubKeySignal, defaultTimeout, + ) + require.NoError(t, err) + h.assertFinalizeBatch( + &wg, respChan, "injected signed genesis commit failure", + ) + require.True(t, store.commitAttempted) + + pending, err := h.planter.PendingBatch() + require.NoError(t, err) + require.Equal(t, tapgarden.BatchStateCommitted, pending.State()) + _, err = h.planter.CancelBatch() + require.ErrorContains(t, err, "publication status is ambiguous") + + persisted := h.fetchSingleBatch(prepared.BatchKey.PubKey) + require.Equal(t, tapgarden.BatchStateCommitted, persisted.State()) + select { + case released := <-h.wallet.ReleaseInputSignal: + t.Fatalf("published input lease released after commit failure: %v", + released) + case published := <-h.chain.PublishReq: + t.Fatalf("Broadcast retry started before a durable commit: %v", + published.TxHash()) + default: + } +} + // TestIssue721MixedInputLeaseLifecycle pins ownership-aware leasing: local // inputs are leased, persisted and released, while external inputs and all // caller-authored transaction/PSBT fields remain untouched. From ed3e5bc6eccb92969ddfd5fe340ad5a0510c242e Mon Sep 17 00:00:00 2001 From: Sadiq Jaffer Date: Fri, 21 Aug 2026 09:59:15 +0100 Subject: [PATCH 20/24] tapgarden: detach pre-persistence lease cleanup --- tapgarden/issue721_stateful_test.go | 59 ++++++++++++++++++++++++++++ tapgarden/planter.go | 32 +++++++++++---- tapnode/tapnodemock/wallet_anchor.go | 9 ++++- 3 files changed, 90 insertions(+), 10 deletions(-) diff --git a/tapgarden/issue721_stateful_test.go b/tapgarden/issue721_stateful_test.go index f65930dbe2..fc2caf6cfa 100644 --- a/tapgarden/issue721_stateful_test.go +++ b/tapgarden/issue721_stateful_test.go @@ -66,6 +66,18 @@ type issue721FailStateStore struct { fail bool } +type issue721TimeoutFundingStore struct { + tapgarden.MintingStore +} + +func (s *issue721TimeoutFundingStore) CommitBatchFunding(ctx context.Context, + _ *btcec.PublicKey, _ *chainhash.Hash, + _ tapgarden.FundedMintAnchorPsbt) error { + + <-ctx.Done() + return ctx.Err() +} + func (s *issue721FailStateStore) UpdateBatchState(ctx context.Context, batch *tapgarden.MintingBatch, state tapgarden.BatchState) error { @@ -1771,6 +1783,53 @@ func TestIssue721LeaseFailureAndCancelReleaseOrdering(t *testing.T) { }) } +// TestIssue721PersistenceTimeoutRollsBackLease proves that a funding-store +// timeout after successful acquisition cannot poison the lease cleanup RPC. +func TestIssue721PersistenceTimeoutRollsBackLease(t *testing.T) { + store := &issue721TimeoutFundingStore{ + MintingStore: newMintingStore(t), + } + h := newMintingTestHarness(t, store) + h.refreshChainPlanter() + t.Cleanup(func() { _ = h.planter.Stop() }) + + h.queueSeedlingsInBatch(false, issue721Seedling()) + h.planter.DefaultTimeout = 50 * time.Millisecond + pkt, _, _ := issue721Anchor(t) + op := pkt.UnsignedTx.TxIn[0].PreviousOutPoint + h.wallet.SetOwnedInput(op, true) + anchorPriv, _ := btcec.PrivKeyFromBytes(bytes.Repeat([]byte{7}, 32)) + h.keyRing.Keys[keychain.KeyLocator{ + Family: asset.TaprootAssetsKeyFamily, + Index: 721, + }] = anchorPriv + h.keyRing.On( + "IsLocalKey", mock.Anything, mock.Anything, + ).Maybe() + + _, err := h.planter.FundBatch(tapgarden.FundParams{ + FeeRate: fn.None[chainfee.SatPerKWeight](), + SiblingTapTree: fn.None[asset.TapscriptTreeNodes](), + AnchorPsbt: pkt, + AssetAnchorOutIdx: 1, + ChangeOutputIndex: -1, + PreCommitOutputIndex: fn.None[uint32](), + }) + require.ErrorIs(t, err, context.Canceled) + require.Equal(t, op, <-h.wallet.LeaseInputSignal) + select { + case released := <-h.wallet.ReleaseInputSignal: + require.Equal(t, op, released) + + case <-time.After(time.Second): + t.Fatal("persistence timeout did not roll back acquired lease") + } + + pending, err := h.planter.PendingBatch() + require.NoError(t, err) + require.Nil(t, pending.GenesisPacket) +} + // TestIssue721ConfirmationRegistrationRetry ensures a successful publish // followed by a transient notifier error remains retryable in the same process. func TestIssue721ConfirmationRegistrationRetry(t *testing.T) { diff --git a/tapgarden/planter.go b/tapgarden/planter.go index 4de57e2bdb..a46f924adc 100644 --- a/tapgarden/planter.go +++ b/tapgarden/planter.go @@ -596,9 +596,7 @@ func rollbackCustomAnchorOutpoints(ctx context.Context, wallet tapnode.WalletAnchor, leaseID tapnode.CustomAnchorLeaseID, ops []wire.OutPoint) error { - cleanupCtx, cancel := context.WithTimeout( - context.WithoutCancel(ctx), DefaultTimeout, - ) + cleanupCtx, cancel := customAnchorCleanupContext(ctx) defer cancel() return releaseCustomAnchorOutpoints( @@ -606,6 +604,26 @@ func rollbackCustomAnchorOutpoints(ctx context.Context, ) } +func rollbackCustomAnchorLeases(ctx context.Context, + wallet tapnode.WalletAnchor, leaseID tapnode.CustomAnchorLeaseID, + funded *FundedMintAnchorPsbt) error { + + cleanupCtx, cancel := customAnchorCleanupContext(ctx) + defer cancel() + + return releaseCustomAnchorLeases( + cleanupCtx, wallet, leaseID, funded, + ) +} + +func customAnchorCleanupContext( + ctx context.Context) (context.Context, context.CancelFunc) { + + return context.WithTimeout( + context.WithoutCancel(ctx), DefaultTimeout, + ) +} + func newlyAcquiredLeases(locked, previous []wire.OutPoint) []wire.OutPoint { previousSet := make(map[wire.OutPoint]struct{}, len(previous)) for _, op := range previous { @@ -3798,7 +3816,7 @@ func (c *ChainPlanter) createFundedBatch(ctx context.Context, } if err := c.cfg.Log.CommitMintingBatch(ctx, newBatch); err != nil { - releaseErr := releaseCustomAnchorLeases( + releaseErr := rollbackCustomAnchorLeases( ctx, c.cfg.Wallet, customAnchorLeaseID(newBatch.BatchKey.PubKey), mintAnchorTx, ) @@ -3842,7 +3860,7 @@ func (c *ChainPlanter) applyFundingToBatch(ctx context.Context, ctx, batch.BatchKey.PubKey, prep.rootHash, *mintAnchorTx, ) if err != nil { - releaseErr := releaseCustomAnchorLeases( + releaseErr := rollbackCustomAnchorLeases( ctx, c.cfg.Wallet, customAnchorLeaseID(batch.BatchKey.PubKey), mintAnchorTx, ) @@ -4722,14 +4740,12 @@ func (c *ChainPlanter) finalizeBatch(params FinalizeParams) (*BatchCaretaker, ) cancel() if err != nil { - ctx, cancel = c.WithCtxQuit() - releaseErr := releaseCustomAnchorOutpoints( + releaseErr := rollbackCustomAnchorOutpoints( ctx, c.cfg.Wallet, customAnchorLeaseID( c.pendingBatch.BatchKey.PubKey, ), newLocks, ) - cancel() return nil, fmt.Errorf("unable to store externally signed "+ "PSBT: %w", errors.Join(err, releaseErr)) } diff --git a/tapnode/tapnodemock/wallet_anchor.go b/tapnode/tapnodemock/wallet_anchor.go index 71c575ec0b..d5328d072a 100644 --- a/tapnode/tapnodemock/wallet_anchor.go +++ b/tapnode/tapnodemock/wallet_anchor.go @@ -280,10 +280,15 @@ func (m *WalletAnchor) LeaseInput(ctx context.Context, } // ReleaseInput releases a custom-anchor lease held by the mock wallet. -func (m *WalletAnchor) ReleaseInput(_ context.Context, +func (m *WalletAnchor) ReleaseInput(ctx context.Context, _ tapnode.CustomAnchorLeaseID, op wire.OutPoint) error { - m.ReleaseInputSignal <- op + select { + case m.ReleaseInputSignal <- op: + + case <-ctx.Done(): + return ctx.Err() + } m.mu.RLock() releaseErr := m.ReleaseErrors[op] m.mu.RUnlock() From e648a343b804cf5550da4f4f299f227e7567a108 Mon Sep 17 00:00:00 2001 From: Sadiq Jaffer Date: Fri, 21 Aug 2026 14:13:34 +0100 Subject: [PATCH 21/24] tapgarden: fix custom anchor race and lint --- lndservices/custom_anchor_lease_test.go | 12 ++++-- lndservices/wallet_anchor.go | 25 ++++++++----- tapgarden/caretaker.go | 39 ++++++++++++++------ tapgarden/interface.go | 1 + tapgarden/interface_compat_test.go | 3 +- tapgarden/planter.go | 49 ++++++++++++++++++------- 6 files changed, 92 insertions(+), 37 deletions(-) diff --git a/lndservices/custom_anchor_lease_test.go b/lndservices/custom_anchor_lease_test.go index cf1b061113..d893dde3cd 100644 --- a/lndservices/custom_anchor_lease_test.go +++ b/lndservices/custom_anchor_lease_test.go @@ -83,7 +83,9 @@ func TestCustomAnchorLeaseRejectsForeignLock(t *testing.T) { foreignID := wtxmgr.LockID(sha256.Sum256([]byte("another-subsystem"))) walletKit := &customAnchorWalletKitMock{ leases: []lndclient.LeaseDescriptor{{ - LockID: foreignID, Outpoint: op, Expiration: time.Now().Add(time.Hour), + LockID: foreignID, + Outpoint: op, + Expiration: time.Now().Add(time.Hour), }}, } wallet := NewLndRpcWalletAnchor(&lndclient.LndServices{ @@ -106,8 +108,12 @@ func TestCustomAnchorLeaseIsScopedToBatch(t *testing.T) { wallet := NewLndRpcWalletAnchor(&lndclient.LndServices{ WalletKit: walletKit, }) - leaseIDA := tapnode.CustomAnchorLeaseID(sha256.Sum256([]byte("batch-a"))) - leaseIDB := tapnode.CustomAnchorLeaseID(sha256.Sum256([]byte("batch-b"))) + leaseIDA := tapnode.CustomAnchorLeaseID( + sha256.Sum256([]byte("batch-a")), + ) + leaseIDB := tapnode.CustomAnchorLeaseID( + sha256.Sum256([]byte("batch-b")), + ) owned, err := wallet.LeaseInput(t.Context(), leaseIDA, op) require.NoError(t, err) diff --git a/lndservices/wallet_anchor.go b/lndservices/wallet_anchor.go index d1f1588144..22c9909f71 100644 --- a/lndservices/wallet_anchor.go +++ b/lndservices/wallet_anchor.go @@ -84,9 +84,9 @@ const ( // PSBT APIs. defaultChangeType = walletrpc.ChangeAddressType_CHANGE_ADDRESS_TYPE_P2TR - // External signing may involve offline or multi-party coordination. Keep - // the lease long enough for that workflow, and renew it on restart and - // immediately before finalization. + // External signing may involve offline or multi-party coordination. + // Keep the lease long enough for that workflow, and renew it on restart + // and immediately before finalization. customAnchorLeaseDuration = 24 * time.Hour ) @@ -219,15 +219,19 @@ func (l *LndRpcWalletAnchor) LeaseInput(ctx context.Context, // locked outputs. leases, err := l.lnd.WalletKit.ListLeases(ctx) if err != nil { - return false, fmt.Errorf("error listing existing leases: %w", err) + return false, fmt.Errorf( + "error listing existing leases: %w", err, + ) } for _, lease := range leases { if lease.Outpoint != op { continue } if lease.LockID != lockID { - return false, fmt.Errorf("wallet input is already leased by " + - "another batch or subsystem") + return false, fmt.Errorf( + "wallet input is already leased by another batch or " + + "subsystem", + ) } if lease.LockID == lockID { @@ -251,7 +255,9 @@ func (l *LndRpcWalletAnchor) LeaseInput(ctx context.Context, ctx, lockID, op, customAnchorLeaseDuration, ) if err != nil { - return false, fmt.Errorf("unable to lease wallet input: %w", err) + return false, fmt.Errorf( + "unable to lease wallet input: %w", err, + ) } return true, nil @@ -279,8 +285,9 @@ func (l *LndRpcWalletAnchor) ReleaseInput(ctx context.Context, if err := l.lnd.WalletKit.ReleaseOutput( ctx, lockID, op, ); err != nil { - return fmt.Errorf("error releasing custom anchor lease: %w", - err) + return fmt.Errorf( + "error releasing custom anchor lease: %w", err, + ) } } diff --git a/tapgarden/caretaker.go b/tapgarden/caretaker.go index bc63780b46..8801745610 100644 --- a/tapgarden/caretaker.go +++ b/tapgarden/caretaker.go @@ -133,7 +133,9 @@ type BatchCaretaker struct { // statusMu protects the transient custom anchor health fields that are // written by the caretaker and queried by the planter. - statusMu sync.RWMutex + statusMu sync.RWMutex + customAnchorLeaseError string + customAnchorPublishError string batchKey BatchKey @@ -164,22 +166,35 @@ func (b *BatchCaretaker) setCustomAnchorLeaseError(status string) { b.statusMu.Lock() defer b.statusMu.Unlock() - b.cfg.Batch.CustomAnchorLeaseError = status + b.customAnchorLeaseError = status } func (b *BatchCaretaker) setCustomAnchorPublishError(status string) { b.statusMu.Lock() defer b.statusMu.Unlock() - b.cfg.Batch.CustomAnchorPublishError = status + b.customAnchorPublishError = status } func (b *BatchCaretaker) customAnchorStatus() (string, string) { b.statusMu.RLock() defer b.statusMu.RUnlock() - return b.cfg.Batch.CustomAnchorLeaseError, - b.cfg.Batch.CustomAnchorPublishError + return b.customAnchorLeaseError, b.customAnchorPublishError +} + +// batchCopy returns a consistent planter-owned snapshot of the batch and its +// transient custom-anchor health. The caretaker is the sole writer of the +// health fields while it is active. +func (b *BatchCaretaker) batchCopy() *MintingBatch { + b.statusMu.RLock() + defer b.statusMu.RUnlock() + + batchCopy := b.cfg.Batch.Copy() + batchCopy.CustomAnchorLeaseError = b.customAnchorLeaseError + batchCopy.CustomAnchorPublishError = b.customAnchorPublishError + + return batchCopy } // NewBatchCaretaker creates a new Taproot Asset caretaker based on the passed @@ -188,9 +203,11 @@ func (b *BatchCaretaker) customAnchorStatus() (string, string) { // TODO(roasbeef): rename to Cultivator? func NewBatchCaretaker(cfg *BatchCaretakerConfig) *BatchCaretaker { caretaker := &BatchCaretaker{ - batchKey: asset.ToSerialized(cfg.Batch.BatchKey.PubKey), - cfg: cfg, - confEvent: make(chan *chainntnfs.TxConfirmation, 1), + batchKey: asset.ToSerialized(cfg.Batch.BatchKey.PubKey), + cfg: cfg, + customAnchorLeaseError: cfg.Batch.CustomAnchorLeaseError, + customAnchorPublishError: cfg.Batch.CustomAnchorPublishError, + confEvent: make(chan *chainntnfs.TxConfirmation, 1), ContextGuard: &fn.ContextGuard{ DefaultTimeout: DefaultTimeout, Quit: make(chan struct{}), @@ -538,7 +555,7 @@ func (b *BatchCaretaker) assetCultivator() { // Wallet acceptance remains ambiguous. Protect every recorded // local input before retrying the exact persisted transaction. ctx, cancel := b.WithCtxQuit() - err := renewCustomAnchorLeases( + err := renewCustomAnchorLeasesReadOnly( ctx, b.cfg.Wallet, customAnchorLeaseID(b.cfg.Batch.BatchKey.PubKey), b.cfg.Batch.GenesisPacket, @@ -1113,7 +1130,7 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) ) if isCustomAnchorPsbt(signedPkt) { renewCtx, renewCancel := b.WithCtxQuit() - renewErr := renewCustomAnchorLeases( + renewErr := renewCustomAnchorLeasesReadOnly( renewCtx, b.cfg.Wallet, customAnchorLeaseID(b.cfg.Batch.BatchKey.PubKey), b.cfg.Batch.GenesisPacket, @@ -1265,7 +1282,7 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) if !customAnchor || !b.customAnchorWalletAccepted { if customAnchor { renewCtx, renewCancel := b.WithCtxQuit() - renewErr := renewCustomAnchorLeases( + renewErr := renewCustomAnchorLeasesReadOnly( renewCtx, b.cfg.Wallet, customAnchorLeaseID(b.cfg.Batch.BatchKey.PubKey), b.cfg.Batch.GenesisPacket, diff --git a/tapgarden/interface.go b/tapgarden/interface.go index 0673cc3cf9..81480f98a5 100644 --- a/tapgarden/interface.go +++ b/tapgarden/interface.go @@ -336,6 +336,7 @@ type MintingInternalKeyStore interface { func storeSignedGenesisPsbt(ctx context.Context, store MintingStore, batchKey *btcec.PublicKey, genesisTx *tapsend.FundedPsbt) error { + signedStore, ok := store.(SignedGenesisPsbtStore) if !ok { return fmt.Errorf("minting store does not support signed custom " + diff --git a/tapgarden/interface_compat_test.go b/tapgarden/interface_compat_test.go index dd60628726..17448a3097 100644 --- a/tapgarden/interface_compat_test.go +++ b/tapgarden/interface_compat_test.go @@ -71,7 +71,8 @@ func TestMintingStoreCompatibilityAdapters(t *testing.T) { standardPacket := &tapsend.FundedPsbt{Pkt: &psbt.Packet{}} customPacket := &tapsend.FundedPsbt{Pkt: &psbt.Packet{ Unknowns: []*psbt.Unknown{{ - Key: fn.CopySlice(customAnchorPsbtMarker), Value: []byte{1}, + Key: fn.CopySlice(customAnchorPsbtMarker), + Value: []byte{1}, }}, }} key := keychain.KeyDescriptor{KeyLocator: keychain.KeyLocator{ diff --git a/tapgarden/planter.go b/tapgarden/planter.go index a46f924adc..d6c50605cd 100644 --- a/tapgarden/planter.go +++ b/tapgarden/planter.go @@ -682,6 +682,27 @@ func releaseCustomAnchorLeases(ctx context.Context, func renewCustomAnchorLeases(ctx context.Context, wallet tapnode.WalletAnchor, leaseID tapnode.CustomAnchorLeaseID, funded *FundedMintAnchorPsbt) error { + return renewCustomAnchorLeasesWithMarkerUpdate( + ctx, wallet, leaseID, funded, true, + ) +} + +// renewCustomAnchorLeasesReadOnly renews leases without changing the shared +// funded packet. Active caretakers use this form because the planter may copy +// the packet concurrently after a publication result. +func renewCustomAnchorLeasesReadOnly(ctx context.Context, + wallet tapnode.WalletAnchor, leaseID tapnode.CustomAnchorLeaseID, + funded *FundedMintAnchorPsbt) error { + + return renewCustomAnchorLeasesWithMarkerUpdate( + ctx, wallet, leaseID, funded, false, + ) +} + +func renewCustomAnchorLeasesWithMarkerUpdate(ctx context.Context, + wallet tapnode.WalletAnchor, leaseID tapnode.CustomAnchorLeaseID, + funded *FundedMintAnchorPsbt, updateLegacyMarker bool) error { + if funded == nil { return nil } @@ -694,6 +715,7 @@ func renewCustomAnchorLeases(ctx context.Context, wallet tapnode.WalletAnchor, if err != nil { return fmt.Errorf("invalid custom anchor lease marker: %w", err) } + lockedUTXOs := funded.LockedUTXOs if markerState == customAnchorLeaseMarkerLegacy { locked, err := acquireCustomAnchorLeases( ctx, wallet, leaseID, funded.Pkt, nil, @@ -703,13 +725,16 @@ func renewCustomAnchorLeases(ctx context.Context, wallet tapnode.WalletAnchor, "leases: %w", err) } - funded.LockedUTXOs = locked - SetCustomAnchorLockedUTXOs(funded.Pkt, locked) + if updateLegacyMarker { + funded.LockedUTXOs = locked + SetCustomAnchorLockedUTXOs(funded.Pkt, locked) + } + return nil } else if markerState == customAnchorLeaseMarkerCurrent { - funded.LockedUTXOs = markerOps + lockedUTXOs = markerOps } - for _, op := range funded.LockedUTXOs { + for _, op := range lockedUTXOs { owned, err := leaser.LeaseInput(ctx, leaseID, op) if err != nil { return fmt.Errorf("unable to renew custom anchor input %v: %w", @@ -3179,11 +3204,10 @@ func (c *ChainPlanter) gardener() { c.pendingBatch.BatchKey.PubKey, ) == result.batchKey { - if customAnchorPublicationPending( - caretaker.cfg.Batch, - ) { + batchSnapshot := caretaker.batchCopy() + if customAnchorPublicationPending(batchSnapshot) { - c.pendingBatch = caretaker.cfg.Batch.Copy() + c.pendingBatch = batchSnapshot } else { c.pendingBatch = nil } @@ -3549,16 +3573,15 @@ func (c *ChainPlanter) gardener() { // admission slot until confirmation. A successful // WalletKit acceptance cleared the marker before the // durable Broadcast transition and remains nonblocking. - if customAnchorPublicationPending( - caretaker.cfg.Batch, - ) { + batchSnapshot := caretaker.batchCopy() + if customAnchorPublicationPending(batchSnapshot) { - c.pendingBatch = caretaker.cfg.Batch.Copy() + c.pendingBatch = batchSnapshot } else { c.pendingBatch = nil } - req.Resolve(caretaker.cfg.Batch) + req.Resolve(batchSnapshot) case err := <-caretaker.cfg.BroadcastErrChan: // Stop the failed caretaker directly. Custom From 27fd632c6425e69739eaed0b64d06ea42aa3a7d9 Mon Sep 17 00:00:00 2001 From: Sadiq Jaffer Date: Thu, 27 Aug 2026 10:10:37 +0100 Subject: [PATCH 22/24] tapgarden: fix custom anchor CI regressions --- lndservices/custom_anchor_lease_test.go | 4 +++- lndservices/wallet_anchor.go | 4 ++-- tapgarden/interface_compat_test.go | 11 +++++---- tapgarden/issue721_stateful_test.go | 8 ++++--- tapgarden/planter.go | 32 ------------------------- tapnode/chain_bridge.go | 4 +++- tapnode/tapnodemock/chain_bridge.go | 5 ++-- 7 files changed, 23 insertions(+), 45 deletions(-) diff --git a/lndservices/custom_anchor_lease_test.go b/lndservices/custom_anchor_lease_test.go index d893dde3cd..89689eeb62 100644 --- a/lndservices/custom_anchor_lease_test.go +++ b/lndservices/custom_anchor_lease_test.go @@ -119,7 +119,9 @@ func TestCustomAnchorLeaseIsScopedToBatch(t *testing.T) { require.NoError(t, err) require.True(t, owned) require.Len(t, walletKit.leaseCalls, 1) - require.Equal(t, wtxmgr.LockID(leaseIDA), walletKit.leaseCalls[0].LockID) + require.Equal( + t, wtxmgr.LockID(leaseIDA), walletKit.leaseCalls[0].LockID, + ) // A second batch cannot renew A's lease or release it by presenting its // own owner ID. diff --git a/lndservices/wallet_anchor.go b/lndservices/wallet_anchor.go index 22c9909f71..233ceed24d 100644 --- a/lndservices/wallet_anchor.go +++ b/lndservices/wallet_anchor.go @@ -229,8 +229,8 @@ func (l *LndRpcWalletAnchor) LeaseInput(ctx context.Context, } if lease.LockID != lockID { return false, fmt.Errorf( - "wallet input is already leased by another batch or " + - "subsystem", + "wallet input is already leased by another " + + "batch or subsystem", ) } diff --git a/tapgarden/interface_compat_test.go b/tapgarden/interface_compat_test.go index 6e22bb955e..5dfb0ffb1c 100644 --- a/tapgarden/interface_compat_test.go +++ b/tapgarden/interface_compat_test.go @@ -19,7 +19,8 @@ type legacyMintingStore struct { } func (l *legacyMintingStore) CommitSignedGenesisTx(_ context.Context, - _ *MintingBatch, _ *tapsend.FundedPsbt, _ uint32, _, _, _ []byte) error { + _ *MintingBatch, _ *tapsend.FundedPsbt, _ uint32, _, _, _ []byte, +) error { l.commits++ return nil @@ -137,9 +138,11 @@ func TestMintingStoreCompatibilityAdapters(t *testing.T) { require.ErrorContains(t, err, "custom anchor internal keys") require.False(t, partial.stored) - store := &customMintingStore{internalKeyStore: &internalKeyStore{ - legacyMintingStore: legacy, - }} + store := &customMintingStore{ + internalKeyStore: &internalKeyStore{ + legacyMintingStore: legacy, + }, + } err = storeSignedGenesisPsbt( t.Context(), store, nil, customPacket, ) diff --git a/tapgarden/issue721_stateful_test.go b/tapgarden/issue721_stateful_test.go index b4aef73d4b..fd0fb7c24d 100644 --- a/tapgarden/issue721_stateful_test.go +++ b/tapgarden/issue721_stateful_test.go @@ -1407,9 +1407,11 @@ func TestIssue721ImportRestartLeaseFailureWatches(t *testing.T) { return h.fetchSingleBatch(prepared.BatchKey.PubKey).State() == tapgarden.BatchStateBroadcast }, defaultTimeout, 10*time.Millisecond) - pending, err := h.planter.PendingBatch() - require.NoError(t, err) - require.Equal(t, tapgarden.BatchStateBroadcast, pending.State()) + require.Eventually(t, func() bool { + pending, err := h.planter.PendingBatch() + return err == nil && + pending.State() == tapgarden.BatchStateBroadcast + }, defaultTimeout, 10*time.Millisecond) h.assertNumCultivatorsActive(1) batches, err := h.planter.ListBatches(tapgarden.ListBatchesParams{ BatchKey: prepared.BatchKey.PubKey, diff --git a/tapgarden/planter.go b/tapgarden/planter.go index 6f4eb8ece1..dba7354956 100644 --- a/tapgarden/planter.go +++ b/tapgarden/planter.go @@ -3229,7 +3229,6 @@ func (c *ChainPlanter) gardener() { continue } if customAnchorPublicationPending(batchSnapshot) { - c.pendingBatch = batchSnapshot } else { c.pendingBatch = nil @@ -5654,34 +5653,3 @@ func (p PreCommitmentOutput) Copy() PreCommitmentOutput { GroupPubKey: p.GroupPubKey, } } - -func copyMalformedPsbt(pkt *psbt.Packet) *psbt.Packet { - copyUnknowns := func(src []*psbt.Unknown) []*psbt.Unknown { - if src == nil { - return nil - } - dst := make([]*psbt.Unknown, len(src)) - for idx, unknown := range src { - if unknown == nil { - continue - } - dst[idx] = &psbt.Unknown{ - Key: fn.CopySlice(unknown.Key), - Value: fn.CopySlice(unknown.Value), - } - } - return dst - } - - var txCopy *wire.MsgTx - if pkt.UnsignedTx != nil { - txCopy = pkt.UnsignedTx.Copy() - } - return &psbt.Packet{ - UnsignedTx: txCopy, - Inputs: fn.CopySlice(pkt.Inputs), - Outputs: fn.CopySlice(pkt.Outputs), - XPubs: fn.CopySlice(pkt.XPubs), - Unknowns: copyUnknowns(pkt.Unknowns), - } -} diff --git a/tapnode/chain_bridge.go b/tapnode/chain_bridge.go index 8a2f5901c8..b02f7b536c 100644 --- a/tapnode/chain_bridge.go +++ b/tapnode/chain_bridge.go @@ -106,7 +106,9 @@ type ChainBridge interface { // DefinitivePublisher is an optional ChainBridge extension that can // distinguish conclusive policy rejection from ambiguous publication errors. type DefinitivePublisher interface { - ValidateAndPublishTransaction(context.Context, *wire.MsgTx, string) error + ValidateAndPublishTransaction( + context.Context, *wire.MsgTx, string, + ) error } // ValidateAndPublishTransaction uses definitive publication when available. diff --git a/tapnode/tapnodemock/chain_bridge.go b/tapnode/tapnodemock/chain_bridge.go index 7252eb1ac9..083d03c136 100644 --- a/tapnode/tapnodemock/chain_bridge.go +++ b/tapnode/tapnodemock/chain_bridge.go @@ -40,8 +40,9 @@ type ChainBridge struct { // It follows the same ConfReqSignal synchronization contract as // ConfReqs. ConfPkScripts map[int][]byte - // ConfTxIDs records the transaction ID for each confirmation request. It - // follows the same ConfReqSignal synchronization contract as ConfReqs. + // ConfTxIDs records the transaction ID for each confirmation request. + // It follows the same ConfReqSignal synchronization contract as + // ConfReqs. ConfTxIDs map[int]*chainhash.Hash // BlocksMu protects concurrent access to Blocks. Readers (GetBlock, From b5b089ae4a4157dd53afb3f04d50fd79480fcfe4 Mon Sep 17 00:00:00 2001 From: Sadiq Jaffer Date: Wed, 2 Sep 2026 21:07:34 +0100 Subject: [PATCH 23/24] lndservices: batch custom anchor lease discovery --- lndservices/custom_anchor_lease_test.go | 114 ++++++++++++++++++++ lndservices/wallet_anchor.go | 137 ++++++++++++++++-------- tapgarden/custom_anchor_test.go | 97 +++++++++++++++++ tapgarden/planter.go | 106 ++++++++++++++---- tapnode/wallet_anchor.go | 17 +++ 5 files changed, 407 insertions(+), 64 deletions(-) diff --git a/lndservices/custom_anchor_lease_test.go b/lndservices/custom_anchor_lease_test.go index 89689eeb62..d61fa1fc71 100644 --- a/lndservices/custom_anchor_lease_test.go +++ b/lndservices/custom_anchor_lease_test.go @@ -3,9 +3,11 @@ package lndservices import ( "context" "crypto/sha256" + "errors" "testing" "time" + "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btcwallet/wtxmgr" "github.com/lightninglabs/lndclient" @@ -22,11 +24,15 @@ type customAnchorWalletKitMock struct { leaseCalls []lndclient.LeaseDescriptor releaseCalls []lndclient.LeaseDescriptor listUnspentCalled bool + listLeasesCalls int + listUnspentCalls int + leaseErrors map[wire.OutPoint]error } func (m *customAnchorWalletKitMock) ListLeases( context.Context) ([]lndclient.LeaseDescriptor, error) { + m.listLeasesCalls++ return m.leases, nil } @@ -34,6 +40,7 @@ func (m *customAnchorWalletKitMock) ListUnspent(context.Context, int32, int32, ...lndclient.ListUnspentOption) ([]*lnwallet.Utxo, error) { m.listUnspentCalled = true + m.listUnspentCalls++ return m.utxos, nil } @@ -41,6 +48,10 @@ func (m *customAnchorWalletKitMock) LeaseOutput(_ context.Context, lockID wtxmgr.LockID, op wire.OutPoint, duration time.Duration) (time.Time, error) { + if err := m.leaseErrors[op]; err != nil { + return time.Time{}, err + } + expiration := time.Now().Add(duration) lease := lndclient.LeaseDescriptor{ LockID: lockID, @@ -142,3 +153,106 @@ func TestCustomAnchorLeaseIsScopedToBatch(t *testing.T) { require.Len(t, walletKit.releaseCalls, 1) require.Empty(t, walletKit.leases) } + +func TestCustomAnchorBatchLeaseSnapshotsOnce(t *testing.T) { + leaseID := tapnode.CustomAnchorLeaseID(sha256.Sum256([]byte("batch-a"))) + lockID := wtxmgr.LockID(leaseID) + var hash chainhash.Hash + hash[0] = 1 + + ops := make([]wire.OutPoint, 0, 1002) + for idx := uint32(0); idx < 1000; idx++ { + ops = append(ops, wire.OutPoint{Hash: hash, Index: idx}) + } + current := wire.OutPoint{Hash: hash, Index: 1000} + owned := wire.OutPoint{Hash: hash, Index: 1001} + ops = append(ops, current, owned) + + walletKit := &customAnchorWalletKitMock{ + leases: []lndclient.LeaseDescriptor{{ + LockID: lockID, Outpoint: current, + Expiration: time.Now().Add(time.Hour), + }}, + utxos: []*lnwallet.Utxo{{OutPoint: owned}}, + } + wallet := NewLndRpcWalletAnchor(&lndclient.LndServices{ + WalletKit: walletKit, + }) + + locked, err := wallet.LeaseInputs(t.Context(), leaseID, ops) + require.NoError(t, err) + require.Equal(t, []wire.OutPoint{current, owned}, locked) + require.Equal(t, 1, walletKit.listLeasesCalls) + require.Equal(t, 1, walletKit.listUnspentCalls) + require.Len(t, walletKit.leaseCalls, 2) + require.Equal(t, current, walletKit.leaseCalls[0].Outpoint) + require.Equal(t, owned, walletKit.leaseCalls[1].Outpoint) +} + +func TestCustomAnchorBatchLeasePreflightsConflict(t *testing.T) { + leaseID := tapnode.CustomAnchorLeaseID(sha256.Sum256([]byte("batch-a"))) + foreignID := wtxmgr.LockID(sha256.Sum256([]byte("another-subsystem"))) + owned := wire.OutPoint{Index: 1} + conflict := wire.OutPoint{Index: 2} + walletKit := &customAnchorWalletKitMock{ + leases: []lndclient.LeaseDescriptor{{ + LockID: foreignID, Outpoint: conflict, + Expiration: time.Now().Add(time.Hour), + }}, + utxos: []*lnwallet.Utxo{{OutPoint: owned}}, + } + wallet := NewLndRpcWalletAnchor(&lndclient.LndServices{ + WalletKit: walletKit, + }) + + locked, err := wallet.LeaseInputs( + t.Context(), leaseID, []wire.OutPoint{owned, conflict}, + ) + require.Nil(t, locked) + require.ErrorContains(t, err, "already leased by another") + require.Equal(t, 1, walletKit.listLeasesCalls) + require.Zero(t, walletKit.listUnspentCalls) + require.Empty(t, walletKit.leaseCalls) +} + +func TestCustomAnchorBatchLeasePartialRollback(t *testing.T) { + leaseID := tapnode.CustomAnchorLeaseID(sha256.Sum256([]byte("batch-a"))) + lockID := wtxmgr.LockID(leaseID) + current := wire.OutPoint{Index: 1} + newInput := wire.OutPoint{Index: 2} + failing := wire.OutPoint{Index: 3} + walletKit := &customAnchorWalletKitMock{ + leases: []lndclient.LeaseDescriptor{{ + LockID: lockID, Outpoint: current, + Expiration: time.Now().Add(time.Hour), + }}, + utxos: []*lnwallet.Utxo{ + {OutPoint: newInput}, {OutPoint: failing}, + }, + leaseErrors: map[wire.OutPoint]error{ + failing: errors.New("injected lease failure"), + }, + } + wallet := NewLndRpcWalletAnchor(&lndclient.LndServices{ + WalletKit: walletKit, + }) + + locked, err := wallet.LeaseInputs( + t.Context(), leaseID, + []wire.OutPoint{current, newInput, failing}, + ) + require.ErrorContains(t, err, "injected lease failure") + require.Equal(t, []wire.OutPoint{current, newInput}, locked) + + // This mirrors the planter's owner-aware rollback: the existing lease is + // excluded, while the newly acquired lease is released in one snapshot. + require.NoError(t, wallet.ReleaseInputs( + t.Context(), leaseID, []wire.OutPoint{newInput}, + )) + require.Len(t, walletKit.leases, 1) + require.Equal(t, current, walletKit.leases[0].Outpoint) + require.Len(t, walletKit.releaseCalls, 1) + require.Equal(t, newInput, walletKit.releaseCalls[0].Outpoint) + require.Equal(t, 2, walletKit.listLeasesCalls) + require.Equal(t, 1, walletKit.listUnspentCalls) +} diff --git a/lndservices/wallet_anchor.go b/lndservices/wallet_anchor.go index 233ceed24d..1a4c18791e 100644 --- a/lndservices/wallet_anchor.go +++ b/lndservices/wallet_anchor.go @@ -2,6 +2,7 @@ package lndservices import ( "context" + "errors" "fmt" "math" "time" @@ -207,47 +208,82 @@ func (l *LndRpcWalletAnchor) ImportTaprootOutput(ctx context.Context, return addr, nil } -// LeaseInput leases an input with the calling batch's custom-anchor lock ID if -// the input belongs to lnd. Inputs owned by another wallet are intentionally -// ignored. +// LeaseInput leases one input with the calling batch's custom-anchor lock ID. +// It delegates to the batch operation so ownership discovery has one source of +// truth. func (l *LndRpcWalletAnchor) LeaseInput(ctx context.Context, leaseID tapnode.CustomAnchorLeaseID, op wire.OutPoint) (bool, error) { - lockID := wtxmgr.LockID(leaseID) + locked, err := l.LeaseInputs(ctx, leaseID, []wire.OutPoint{op}) + return len(locked) == 1, err +} + +// LeaseInputs snapshots leases and wallet UTXOs once, then leases only the +// wallet-owned members of ops. Foreign inputs don't increase ownership-query +// traffic. A partial result lets the caller roll back only newly acquired +// leases if a later LeaseOutput call fails. +func (l *LndRpcWalletAnchor) LeaseInputs(ctx context.Context, + leaseID tapnode.CustomAnchorLeaseID, + ops []wire.OutPoint) ([]wire.OutPoint, error) { + + if len(ops) == 0 { + return nil, nil + } - // Renew our own lease without depending on whether ListUnspent includes - // locked outputs. + seen := make(map[wire.OutPoint]struct{}, len(ops)) + for _, op := range ops { + if _, ok := seen[op]; ok { + return nil, fmt.Errorf("custom anchor input is repeated: %v", op) + } + seen[op] = struct{}{} + } + + lockID := wtxmgr.LockID(leaseID) leases, err := l.lnd.WalletKit.ListLeases(ctx) if err != nil { - return false, fmt.Errorf( - "error listing existing leases: %w", err, - ) + return nil, fmt.Errorf("error listing existing leases: %w", err) } + leaseByOutpoint := make(map[wire.OutPoint]lndclient.LeaseDescriptor, + len(leases)) for _, lease := range leases { - if lease.Outpoint != op { - continue - } - if lease.LockID != lockID { - return false, fmt.Errorf( - "wallet input is already leased by another " + - "batch or subsystem", - ) - } + leaseByOutpoint[lease.Outpoint] = lease + } - if lease.LockID == lockID { - _, err := l.lnd.WalletKit.LeaseOutput( - ctx, lockID, op, customAnchorLeaseDuration, - ) - return err == nil, err + // Reject all ownership conflicts before renewing or creating any lease. + for _, op := range ops { + lease, ok := leaseByOutpoint[op] + if ok && lease.LockID != lockID { + return nil, fmt.Errorf("wallet input %v is already leased by "+ + "another batch or subsystem", op) } } - utxos, err := l.lnd.WalletKit.ListUnspent(ctx, 0, math.MaxInt32) - if err != nil { - return false, fmt.Errorf("error listing wallet inputs: %w", err) + needUnspent := false + for _, op := range ops { + if _, ok := leaseByOutpoint[op]; !ok { + needUnspent = true + break + } } - for _, utxo := range utxos { - if utxo.OutPoint != op { + walletInputs := make(map[wire.OutPoint]struct{}) + if needUnspent { + utxos, err := l.lnd.WalletKit.ListUnspent( + ctx, 0, math.MaxInt32, + ) + if err != nil { + return nil, fmt.Errorf("error listing wallet inputs: %w", err) + } + walletInputs = make(map[wire.OutPoint]struct{}, len(utxos)) + for _, utxo := range utxos { + walletInputs[utxo.OutPoint] = struct{}{} + } + } + + locked := make([]wire.OutPoint, 0, len(ops)) + for _, op := range ops { + _, alreadyLeased := leaseByOutpoint[op] + _, walletOwned := walletInputs[op] + if !alreadyLeased && !walletOwned { continue } @@ -255,15 +291,14 @@ func (l *LndRpcWalletAnchor) LeaseInput(ctx context.Context, ctx, lockID, op, customAnchorLeaseDuration, ) if err != nil { - return false, fmt.Errorf( - "unable to lease wallet input: %w", err, + return locked, fmt.Errorf( + "unable to lease wallet input %v: %w", op, err, ) } - - return true, nil + locked = append(locked, op) } - return false, nil + return locked, nil } // ReleaseInput releases only the calling batch's custom-anchor lease for an @@ -271,27 +306,44 @@ func (l *LndRpcWalletAnchor) LeaseInput(ctx context.Context, func (l *LndRpcWalletAnchor) ReleaseInput(ctx context.Context, leaseID tapnode.CustomAnchorLeaseID, op wire.OutPoint) error { + return l.ReleaseInputs(ctx, leaseID, []wire.OutPoint{op}) +} + +// ReleaseInputs snapshots leases once and releases every requested lease owned +// by leaseID. It never releases another subsystem's lease. +func (l *LndRpcWalletAnchor) ReleaseInputs(ctx context.Context, + leaseID tapnode.CustomAnchorLeaseID, ops []wire.OutPoint) error { + + if len(ops) == 0 { + return nil + } + lockID := wtxmgr.LockID(leaseID) leases, err := l.lnd.WalletKit.ListLeases(ctx) if err != nil { return fmt.Errorf("error listing existing leases: %w", err) } + owned := make(map[wire.OutPoint]struct{}, len(leases)) for _, lease := range leases { - if lease.Outpoint != op || lease.LockID != lockID { - continue + if lease.LockID == lockID { + owned[lease.Outpoint] = struct{}{} } + } - if err := l.lnd.WalletKit.ReleaseOutput( - ctx, lockID, op, - ); err != nil { - return fmt.Errorf( - "error releasing custom anchor lease: %w", err, - ) + var releaseErr error + for _, op := range ops { + if _, ok := owned[op]; !ok { + continue + } + if err := l.lnd.WalletKit.ReleaseOutput(ctx, lockID, op); err != nil { + releaseErr = errors.Join(releaseErr, fmt.Errorf( + "error releasing custom anchor lease %v: %w", op, err, + )) } } - return nil + return releaseErr } // UnlockInput unlocks the set of target inputs after a batch or send @@ -372,5 +424,6 @@ func (l *LndRpcWalletAnchor) MinRelayFee( // tapnode.WalletAnchor interface. var _ tapnode.WalletAnchor = (*LndRpcWalletAnchor)(nil) var _ tapnode.CustomAnchorLeaser = (*LndRpcWalletAnchor)(nil) +var _ tapnode.CustomAnchorBatchLeaser = (*LndRpcWalletAnchor)(nil) var _ tapfreighter.WalletAnchor = (*LndRpcWalletAnchor)(nil) diff --git a/tapgarden/custom_anchor_test.go b/tapgarden/custom_anchor_test.go index c7af754853..cba116b1a3 100644 --- a/tapgarden/custom_anchor_test.go +++ b/tapgarden/custom_anchor_test.go @@ -47,6 +47,38 @@ type blockingCustomAnchorWallet struct { releaseCtxErr chan error } +type batchCustomAnchorWallet struct { + *tapnodemock.WalletAnchor + + leaseInputCalls int + batchLeaseCalls int + batchLocked []wire.OutPoint + batchLeaseErr error + batchReleases [][]wire.OutPoint +} + +func (w *batchCustomAnchorWallet) LeaseInput(context.Context, + tapnode.CustomAnchorLeaseID, wire.OutPoint) (bool, error) { + + w.leaseInputCalls++ + return false, nil +} + +func (w *batchCustomAnchorWallet) LeaseInputs(context.Context, + tapnode.CustomAnchorLeaseID, + []wire.OutPoint) ([]wire.OutPoint, error) { + + w.batchLeaseCalls++ + return fn.CopySlice(w.batchLocked), w.batchLeaseErr +} + +func (w *batchCustomAnchorWallet) ReleaseInputs(_ context.Context, + _ tapnode.CustomAnchorLeaseID, ops []wire.OutPoint) error { + + w.batchReleases = append(w.batchReleases, fn.CopySlice(ops)) + return nil +} + func (w *blockingCustomAnchorWallet) LeaseInput(ctx context.Context, leaseID tapnode.CustomAnchorLeaseID, op wire.OutPoint) (bool, error) { @@ -252,6 +284,71 @@ func TestCustomAnchorLeasesAreBatchScoped(t *testing.T) { require.Equal(t, leaseIDA, wallet.leases[inputX]) } +func TestCustomAnchorBatchLeasePath(t *testing.T) { + packet := testCustomAnchorPacket(t) + first := packet.UnsignedTx.TxIn[0].PreviousOutPoint + for idx := uint32(1); idx < 1000; idx++ { + op := first + op.Index += idx + packet.UnsignedTx.AddTxIn(&wire.TxIn{PreviousOutPoint: op}) + packet.Inputs = append(packet.Inputs, packet.Inputs[0]) + } + + wallet := &batchCustomAnchorWallet{ + WalletAnchor: tapnodemock.NewWalletAnchor(), + batchLocked: []wire.OutPoint{first}, + } + var leaseID tapnode.CustomAnchorLeaseID + locked, err := acquireCustomAnchorLeases( + t.Context(), wallet, leaseID, packet, nil, + ) + require.NoError(t, err) + require.Equal(t, []wire.OutPoint{first}, locked) + require.Equal(t, 1, wallet.batchLeaseCalls) + require.Zero(t, wallet.leaseInputCalls) +} + +func TestCustomAnchorBatchLeaseDuplicatePreflight(t *testing.T) { + packet := testCustomAnchorPacket(t) + packet.UnsignedTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: packet.UnsignedTx.TxIn[0].PreviousOutPoint, + }) + packet.Inputs = append(packet.Inputs, packet.Inputs[0]) + wallet := &batchCustomAnchorWallet{ + WalletAnchor: tapnodemock.NewWalletAnchor(), + } + + _, err := acquireCustomAnchorLeases( + t.Context(), wallet, tapnode.CustomAnchorLeaseID{}, packet, nil, + ) + require.ErrorContains(t, err, "repeats input") + require.Zero(t, wallet.batchLeaseCalls) + require.Zero(t, wallet.leaseInputCalls) +} + +func TestCustomAnchorBatchLeaseRollback(t *testing.T) { + packet := testCustomAnchorPacket(t) + current := packet.UnsignedTx.TxIn[0].PreviousOutPoint + newInput := current + newInput.Index++ + packet.UnsignedTx.AddTxIn(&wire.TxIn{PreviousOutPoint: newInput}) + packet.Inputs = append(packet.Inputs, packet.Inputs[0]) + wallet := &batchCustomAnchorWallet{ + WalletAnchor: tapnodemock.NewWalletAnchor(), + batchLocked: []wire.OutPoint{current, newInput}, + batchLeaseErr: errors.New("injected batch lease failure"), + } + + _, err := acquireCustomAnchorLeases( + t.Context(), wallet, tapnode.CustomAnchorLeaseID{}, packet, + []wire.OutPoint{current}, + ) + require.ErrorContains(t, err, "injected batch lease failure") + require.Equal(t, 1, wallet.batchLeaseCalls) + require.Zero(t, wallet.leaseInputCalls) + require.Equal(t, [][]wire.OutPoint{{newInput}}, wallet.batchReleases) +} + // TestCustomAnchorLeaseRollbackUsesLiveContext proves that an acquisition // timeout doesn't poison cleanup and leave earlier leases untracked. func TestCustomAnchorLeaseRollbackUsesLiveContext(t *testing.T) { diff --git a/tapgarden/planter.go b/tapgarden/planter.go index 29b268eff4..c8c9212ac3 100644 --- a/tapgarden/planter.go +++ b/tapgarden/planter.go @@ -546,30 +546,89 @@ func acquireCustomAnchorLeases(ctx context.Context, wallet tapnode.WalletAnchor, if packet == nil || packet.UnsignedTx == nil { return nil, fmt.Errorf("custom anchor PSBT is missing a transaction") } + ops := make([]wire.OutPoint, 0, len(packet.UnsignedTx.TxIn)) + for _, txIn := range packet.UnsignedTx.TxIn { + ops = append(ops, txIn.PreviousOutPoint) + } + + return leaseCustomAnchorOutpoints( + ctx, wallet, leaseID, ops, previouslyLocked, + ) +} + +// leaseCustomAnchorOutpoints leases the requested wallet-owned outpoints. The +// optimized wallet extension snapshots ownership once; older implementations +// retain the compatible per-input behavior. +func leaseCustomAnchorOutpoints(ctx context.Context, + wallet tapnode.WalletAnchor, leaseID tapnode.CustomAnchorLeaseID, + ops, previouslyLocked []wire.OutPoint) ([]wire.OutPoint, error) { + leaser, ok := wallet.(tapnode.CustomAnchorLeaser) if !ok { return nil, fmt.Errorf("wallet does not support custom anchor leases") } + seen := make(map[wire.OutPoint]struct{}, len(ops)) + for _, op := range ops { + if _, ok := seen[op]; ok { + return nil, fmt.Errorf("custom anchor PSBT repeats input %v", op) + } + seen[op] = struct{}{} + } + previous := make(map[wire.OutPoint]struct{}, len(previouslyLocked)) for _, op := range previouslyLocked { previous[op] = struct{}{} } - locked := make([]wire.OutPoint, 0, len(packet.UnsignedTx.TxIn)) - newlyLocked := make([]wire.OutPoint, 0, len(packet.UnsignedTx.TxIn)) - seen := make(map[wire.OutPoint]struct{}, len(packet.UnsignedTx.TxIn)) - for _, txIn := range packet.UnsignedTx.TxIn { - op := txIn.PreviousOutPoint - if _, ok := seen[op]; ok { + if batchLeaser, ok := wallet.(tapnode.CustomAnchorBatchLeaser); ok { + locked, err := batchLeaser.LeaseInputs(ctx, leaseID, ops) + newlyLocked := newlyAcquiredLeases(locked, previouslyLocked) + if err != nil { + releaseErr := rollbackCustomAnchorOutpoints( + ctx, wallet, leaseID, newlyLocked, + ) + return nil, errors.Join(err, releaseErr) + } + + lockedSet := make(map[wire.OutPoint]struct{}, len(locked)) + for _, op := range locked { + if _, requested := seen[op]; !requested { + releaseErr := rollbackCustomAnchorOutpoints( + ctx, wallet, leaseID, newlyLocked, + ) + return nil, errors.Join(fmt.Errorf( + "wallet returned unrequested custom anchor lease %v", op, + ), releaseErr) + } + if _, duplicate := lockedSet[op]; duplicate { + releaseErr := rollbackCustomAnchorOutpoints( + ctx, wallet, leaseID, newlyLocked, + ) + return nil, errors.Join(fmt.Errorf( + "wallet returned duplicate custom anchor lease %v", op, + ), releaseErr) + } + lockedSet[op] = struct{}{} + } + for _, op := range previouslyLocked { + if _, renewed := lockedSet[op]; renewed { + continue + } releaseErr := rollbackCustomAnchorOutpoints( ctx, wallet, leaseID, newlyLocked, ) return nil, errors.Join(fmt.Errorf( - "custom anchor PSBT repeats input %v", op, + "unable to renew custom anchor input lease %v", op, ), releaseErr) } - seen[op] = struct{}{} + + return locked, nil + } + + locked := make([]wire.OutPoint, 0, len(ops)) + newlyLocked := make([]wire.OutPoint, 0, len(ops)) + for _, op := range ops { owned, err := leaser.LeaseInput(ctx, leaseID, op) if err != nil { releaseErr := rollbackCustomAnchorOutpoints( @@ -658,10 +717,17 @@ func releaseCustomAnchorOutpoints(ctx context.Context, wallet tapnode.WalletAnchor, leaseID tapnode.CustomAnchorLeaseID, ops []wire.OutPoint) error { + if len(ops) == 0 { + return nil + } + leaser, ok := wallet.(tapnode.CustomAnchorLeaser) if !ok { return fmt.Errorf("wallet does not support custom anchor leases") } + if batchLeaser, ok := wallet.(tapnode.CustomAnchorBatchLeaser); ok { + return batchLeaser.ReleaseInputs(ctx, leaseID, ops) + } var releaseErr error for _, op := range ops { @@ -720,8 +786,7 @@ func renewCustomAnchorLeasesWithMarkerUpdate(ctx context.Context, if funded == nil { return nil } - leaser, ok := wallet.(tapnode.CustomAnchorLeaser) - if !ok { + if _, ok := wallet.(tapnode.CustomAnchorLeaser); !ok { return fmt.Errorf("wallet does not support custom anchor leases") } @@ -748,18 +813,15 @@ func renewCustomAnchorLeasesWithMarkerUpdate(ctx context.Context, } else if markerState == customAnchorLeaseMarkerCurrent { lockedUTXOs = markerOps } - for _, op := range lockedUTXOs { - owned, err := leaser.LeaseInput(ctx, leaseID, op) - if err != nil { - return fmt.Errorf("unable to renew custom anchor input %v: %w", - op, err) - } - if !owned { - return fmt.Errorf( - "custom anchor input %v is no longer "+ - "available to the backing wallet", op, - ) - } + locked, err := leaseCustomAnchorOutpoints( + ctx, wallet, leaseID, lockedUTXOs, lockedUTXOs, + ) + if err != nil { + return fmt.Errorf("unable to renew custom anchor inputs: %w", err) + } + if len(locked) != len(lockedUTXOs) { + return fmt.Errorf("renewed %d of %d custom anchor input leases", + len(locked), len(lockedUTXOs)) } return nil diff --git a/tapnode/wallet_anchor.go b/tapnode/wallet_anchor.go index 5d79967de4..305504da76 100644 --- a/tapnode/wallet_anchor.go +++ b/tapnode/wallet_anchor.go @@ -78,3 +78,20 @@ type CustomAnchorLeaser interface { // must not release leases owned by another batch or subsystem. ReleaseInput(context.Context, CustomAnchorLeaseID, wire.OutPoint) error } + +// CustomAnchorBatchLeaser is an optional optimized extension that snapshots +// wallet ownership once for a complete custom-anchor input set. Implementations +// must return successfully leased wallet inputs in request order, including a +// partial result when a later lease fails. A lease owned by another subsystem +// must be rejected before any requested input is mutated. +type CustomAnchorBatchLeaser interface { + // LeaseInputs leases every wallet-owned input in ops and ignores inputs + // controlled by external signers. + LeaseInputs(context.Context, CustomAnchorLeaseID, + []wire.OutPoint) ([]wire.OutPoint, error) + + // ReleaseInputs releases only leases owned by leaseID and attempts every + // requested outpoint before returning any joined errors. + ReleaseInputs(context.Context, CustomAnchorLeaseID, + []wire.OutPoint) error +} From 7cc9a174a4c0244165e0dc5e6ba4b89521715917 Mon Sep 17 00:00:00 2001 From: Sadiq Jaffer Date: Sat, 5 Sep 2026 10:52:10 +0100 Subject: [PATCH 24/24] tapgarden: safely copy custom anchor staging batch Use MintingBatch.Copy to avoid copying atomic state, cover staging isolation and error paths, and wrap wallet-anchor lines to satisfy CI lint. --- lndservices/custom_anchor_lease_test.go | 4 +- lndservices/wallet_anchor.go | 20 ++++-- tapgarden/custom_anchor_test.go | 84 +++++++++++++++++++++++++ tapgarden/planter.go | 9 ++- tapnode/wallet_anchor.go | 4 +- 5 files changed, 108 insertions(+), 13 deletions(-) diff --git a/lndservices/custom_anchor_lease_test.go b/lndservices/custom_anchor_lease_test.go index d61fa1fc71..de1046d499 100644 --- a/lndservices/custom_anchor_lease_test.go +++ b/lndservices/custom_anchor_lease_test.go @@ -244,8 +244,8 @@ func TestCustomAnchorBatchLeasePartialRollback(t *testing.T) { require.ErrorContains(t, err, "injected lease failure") require.Equal(t, []wire.OutPoint{current, newInput}, locked) - // This mirrors the planter's owner-aware rollback: the existing lease is - // excluded, while the newly acquired lease is released in one snapshot. + // This mirrors the planter's owner-aware rollback: the existing lease + // is excluded, while the new lease is released in one snapshot. require.NoError(t, wallet.ReleaseInputs( t.Context(), leaseID, []wire.OutPoint{newInput}, )) diff --git a/lndservices/wallet_anchor.go b/lndservices/wallet_anchor.go index 1a4c18791e..11f230302e 100644 --- a/lndservices/wallet_anchor.go +++ b/lndservices/wallet_anchor.go @@ -233,7 +233,9 @@ func (l *LndRpcWalletAnchor) LeaseInputs(ctx context.Context, seen := make(map[wire.OutPoint]struct{}, len(ops)) for _, op := range ops { if _, ok := seen[op]; ok { - return nil, fmt.Errorf("custom anchor input is repeated: %v", op) + return nil, fmt.Errorf( + "custom anchor input is repeated: %v", op, + ) } seen[op] = struct{}{} } @@ -253,8 +255,10 @@ func (l *LndRpcWalletAnchor) LeaseInputs(ctx context.Context, for _, op := range ops { lease, ok := leaseByOutpoint[op] if ok && lease.LockID != lockID { - return nil, fmt.Errorf("wallet input %v is already leased by "+ - "another batch or subsystem", op) + return nil, fmt.Errorf( + "wallet input %v is already leased by "+ + "another batch or subsystem", op, + ) } } @@ -271,7 +275,9 @@ func (l *LndRpcWalletAnchor) LeaseInputs(ctx context.Context, ctx, 0, math.MaxInt32, ) if err != nil { - return nil, fmt.Errorf("error listing wallet inputs: %w", err) + return nil, fmt.Errorf( + "error listing wallet inputs: %w", err, + ) } walletInputs = make(map[wire.OutPoint]struct{}, len(utxos)) for _, utxo := range utxos { @@ -336,9 +342,11 @@ func (l *LndRpcWalletAnchor) ReleaseInputs(ctx context.Context, if _, ok := owned[op]; !ok { continue } - if err := l.lnd.WalletKit.ReleaseOutput(ctx, lockID, op); err != nil { + err := l.lnd.WalletKit.ReleaseOutput(ctx, lockID, op) + if err != nil { releaseErr = errors.Join(releaseErr, fmt.Errorf( - "error releasing custom anchor lease %v: %w", op, err, + "error releasing custom anchor lease %v: %w", + op, err, )) } } diff --git a/tapgarden/custom_anchor_test.go b/tapgarden/custom_anchor_test.go index cba116b1a3..e3c1595ddf 100644 --- a/tapgarden/custom_anchor_test.go +++ b/tapgarden/custom_anchor_test.go @@ -650,6 +650,90 @@ func TestFundedMintAnchorPsbtCopyPreservesMetadata(t *testing.T) { require.NotEqual(t, pkt.Unknowns, copyPkt.Unknowns) } +type customAnchorBindAugmenter struct { + NoOpAugmenter + bind func(*MintingBatch) (fn.Option[PreCommitBindData], error) +} + +func (a customAnchorBindAugmenter) BindData(_ context.Context, + batch *MintingBatch) (fn.Option[PreCommitBindData], error) { + + return a.bind(batch) +} + +// TestCustomGenesisPsbtStagedBatch verifies that binding uses an independent +// batch and that neither successful nor failed binding changes its source. +func TestCustomGenesisPsbtStagedBatch(t *testing.T) { + bindErr := errors.New("injected bind failure") + for _, test := range []struct { + name string + bindErr error + copyError bool + }{ + {name: "success"}, + {name: "bind error", bindErr: bindErr}, + {name: "copy error", copyError: true}, + } { + t.Run(test.name, func(t *testing.T) { + original := &FundedMintAnchorPsbt{ + FundedPsbt: fundedPsbt(testCustomAnchorPacket(t)), + } + if test.copyError { + original.Pkt.UnsignedTx = nil + } + batch := &MintingBatch{ + GenesisPacket: original, + Seedlings: map[string]*Seedling{ + "seed": {AssetName: "original"}, + }, + } + batch.setState(BatchStateFrozen) + pkt := testCustomAnchorPacket(t) + var before bytes.Buffer + require.NoError(t, pkt.Serialize(&before)) + bindCalled := false + augmenter := customAnchorBindAugmenter{ + bind: func(staged *MintingBatch) ( + fn.Option[PreCommitBindData], error) { + + bindCalled = true + require.NotSame(t, batch, staged) + require.Equal(t, batch.State(), staged.State()) + require.NotSame(t, original, staged.GenesisPacket) + require.NotSame(t, pkt, staged.GenesisPacket.Pkt) + require.True(t, isCustomAnchorPsbt( + staged.GenesisPacket.Pkt, + )) + staged.setState(BatchStatePending) + staged.Seedlings["seed"].AssetName = "staged" + return fn.None[PreCommitBindData](), test.bindErr + }, + } + _, err := customGenesisPsbt( + t.Context(), address.TestNet3Tap, batch, pkt, 0, + -1, noneUint32(), augmenter, + ) + switch { + case test.copyError: + require.ErrorContains(t, err, "copy pending batch") + require.False(t, bindCalled) + case test.bindErr != nil: + require.ErrorIs(t, err, test.bindErr) + require.True(t, bindCalled) + default: + require.NoError(t, err) + require.True(t, bindCalled) + } + require.Same(t, original, batch.GenesisPacket) + require.Equal(t, BatchStateFrozen, batch.State()) + require.Equal(t, "original", batch.Seedlings["seed"].AssetName) + var after bytes.Buffer + require.NoError(t, pkt.Serialize(&after)) + require.Equal(t, before.Bytes(), after.Bytes()) + }) + } +} + func TestCustomGenesisPsbtSupplyPreCommitment(t *testing.T) { ctx := context.Background() augmenter := mockSupplyCommitAugmenter{ diff --git a/tapgarden/planter.go b/tapgarden/planter.go index c8c9212ac3..a36d54b825 100644 --- a/tapgarden/planter.go +++ b/tapgarden/planter.go @@ -2104,12 +2104,15 @@ func customGenesisPsbt(ctx context.Context, } // BindData is the persistence source of truth after the upstream - // augmenter refactor. Stage the funded packet on a shallow batch copy + // augmenter refactor. Stage the funded packet on an independent copy // and ensure it resolves to the same output the caller declared. if pendingBatch != nil { - stagedBatch := *pendingBatch + stagedBatch, err := pendingBatch.Copy() + if err != nil { + return zero, fmt.Errorf("copy pending batch: %w", err) + } stagedBatch.GenesisPacket = &result - bindData, err := augmenter.BindData(ctx, &stagedBatch) + bindData, err := augmenter.BindData(ctx, stagedBatch) if err != nil { return zero, fmt.Errorf("augmenter BindData: %w", err) } diff --git a/tapnode/wallet_anchor.go b/tapnode/wallet_anchor.go index 305504da76..15d6d86993 100644 --- a/tapnode/wallet_anchor.go +++ b/tapnode/wallet_anchor.go @@ -90,8 +90,8 @@ type CustomAnchorBatchLeaser interface { LeaseInputs(context.Context, CustomAnchorLeaseID, []wire.OutPoint) ([]wire.OutPoint, error) - // ReleaseInputs releases only leases owned by leaseID and attempts every - // requested outpoint before returning any joined errors. + // ReleaseInputs releases only leases owned by leaseID and attempts + // every requested outpoint before returning any joined errors. ReleaseInputs(context.Context, CustomAnchorLeaseID, []wire.OutPoint) error }