diff --git a/cmd/commands/assets.go b/cmd/commands/assets.go index 0b6c8c53f1..e1b97c58d2 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,14 @@ 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 +527,40 @@ 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 != "" { + 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") + } + 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 +569,40 @@ 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) + err = os.WriteFile(path, resp.Batch.BatchPsbt, 0o600) + if 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 +697,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", + }, }, Action: finalizeBatch, } @@ -630,10 +715,18 @@ 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 != "" { + signedPath := tapcfg.CleanAndExpandPath(path) + req.SignedPsbt, err = os.ReadFile(signedPath) + 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/docs/release-notes/release-notes-0.9.0.md b/docs/release-notes/release-notes-0.9.0.md index 4d9d44470a..da05c1eb02 100644 --- a/docs/release-notes/release-notes-0.9.0.md +++ b/docs/release-notes/release-notes-0.9.0.md @@ -164,6 +164,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. + * [PR#2266](https://github.com/lightninglabs/taproot-assets/pull/2266) adds the 'watcher': a daemon-wide service that records each act of staking local state on a chain outcome as a durable diff --git a/itest/assertions.go b/itest/assertions.go index fd92810c07..49e5ea8915 100644 --- a/itest/assertions.go +++ b/itest/assertions.go @@ -135,6 +135,43 @@ 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, + ) + } + + anchorBlockHash := a.ChainAnchor.AnchorBlockHash + if anchorBlockHash != blockHash.String() { + return fmt.Errorf( + "unexpected asset anchor block "+ + "hash, got %v wanted %x", + 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 +2152,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 +2181,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, }, }, ) @@ -2167,7 +2219,7 @@ func AssertAssetsMinted(t *testing.T, tapClient commands.RpcClientsBundle, mintedAsset := AssertAssetState( t, confirmedAssets, assetRequest.Asset.Name, metaHash[:], - AssetAnchorCheck(mintTXID, blockHash), + AssetAnchorOutpointCheck(mintOutpoint, blockHash), scriptKeyLocalCheck, AssetVersionCheck(assetRequest.Asset.AssetVersion), func(a *taprpc.Asset) error { @@ -2178,13 +2230,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, @@ -2195,6 +2243,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..b150620a9d --- /dev/null +++ b/itest/mint_custom_anchor_test.go @@ -0,0 +1,2083 @@ +//nolint:lll +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 := &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, + )) + 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; { + blocks[left], blocks[right] = blocks[right], blocks[left] + left++ + right-- + } + + 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} { + err := writeCustomAnchorLenPrefixed(preimage, []byte(value)) + if 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 7e99343368..6ac66bc5a0 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, diff --git a/lndservices/chain_bridge.go b/lndservices/chain_bridge.go index a8aae9e179..9cff42356f 100644 --- a/lndservices/chain_bridge.go +++ b/lndservices/chain_bridge.go @@ -5,7 +5,9 @@ import ( "errors" "fmt" "sort" + "strings" "time" + "unicode" "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" @@ -17,7 +19,10 @@ import ( "github.com/lightninglabs/taproot-assets/tapnode" "github.com/lightninglabs/taproot-assets/tapreorg" "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" ) const ( @@ -366,6 +371,220 @@ func (l *LndRpcChainBridge) PublishTransaction(ctx context.Context, return err } +// ValidateAndPublishTransaction submits a transaction before tapd persists an +// 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 { + + err := l.lnd.WalletKit.PublishTransaction(ctx, tx, label) + if err == nil { + 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(publishErr) + } + + return publishErr +} + +// 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 { + 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 + } + + // 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 + } + + for idx := range a { + if a[idx] != b[idx] { + return false + } + } + + 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 + }) +} + // EstimateFee returns a fee estimate for the confirmation target. func (l *LndRpcChainBridge) EstimateFee(ctx context.Context, confTarget uint32) (chainfee.SatPerKWeight, error) { @@ -403,6 +622,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) // A compile-time assertion that the chain bridge satisfies the chain // sensing contract the re-org watcher pins in its own package. diff --git a/lndservices/chain_bridge_test.go b/lndservices/chain_bridge_test.go index 068f81fbfc..f441bdbbe9 100644 --- a/lndservices/chain_bridge_test.go +++ b/lndservices/chain_bridge_test.go @@ -8,12 +8,329 @@ import ( "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" + btcwalletchain "github.com/btcsuite/btcwallet/chain" "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/taproot-assets/tapnode" "github.com/lightningnetwork/lnd/chainntnfs" "github.com/lightningnetwork/lnd/lnrpc/chainrpc" + "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: "collapsed missing or spent input is ambiguous", + err: status.Error( + codes.Unknown, lnwallet.ErrDoubleSpend.Error(), + ), + definitive: false, + }, + { + name: "mempool fee with detail", + err: status.Error( + codes.Unknown, lnwallet.ErrMempoolFee.Error()+ + ": min relay fee not met", + ), + definitive: true, + }, + { + name: "non final transaction", + err: status.Error( + codes.Unknown, "non-final", + ), + 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", + ), + definitive: false, + }, + { + name: "transport failure", + err: status.Error( + codes.Unavailable, "connection closed", + ), + 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( + t, testCase.definitive, + isDefinitivePublishError(testCase.err), + ) + }) + } +} + +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)) +} + // fakeChainNotifier is a minimal fake of the lndclient chain notifier // that captures spend registrations and forwards caller-provided // channels. diff --git a/lndservices/custom_anchor_lease_test.go b/lndservices/custom_anchor_lease_test.go new file mode 100644 index 0000000000..de1046d499 --- /dev/null +++ b/lndservices/custom_anchor_lease_test.go @@ -0,0 +1,258 @@ +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" + "github.com/lightninglabs/taproot-assets/tapnode" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/stretchr/testify/require" +) + +type customAnchorWalletKitMock struct { + lndclient.WalletKitClient + + leases []lndclient.LeaseDescriptor + utxos []*lnwallet.Utxo + 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 +} + +func (m *customAnchorWalletKitMock) ListUnspent(context.Context, int32, + int32, ...lndclient.ListUnspentOption) ([]*lnwallet.Utxo, error) { + + m.listUnspentCalled = true + m.listUnspentCalls++ + return m.utxos, nil +} + +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, + 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) { + 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, + }) + + 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") + 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) +} + +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 new 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 b7830f70ce..11f230302e 100644 --- a/lndservices/wallet_anchor.go +++ b/lndservices/wallet_anchor.go @@ -2,8 +2,10 @@ package lndservices import ( "context" + "errors" "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,6 +84,11 @@ 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 ) // FundPsbt attaches enough inputs to the target PSBT packet for it to be @@ -200,6 +208,152 @@ func (l *LndRpcWalletAnchor) ImportTaprootOutput(ctx context.Context, return addr, nil } +// 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) { + + 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 + } + + 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 nil, fmt.Errorf("error listing existing leases: %w", err) + } + leaseByOutpoint := make(map[wire.OutPoint]lndclient.LeaseDescriptor, + len(leases)) + for _, lease := range leases { + leaseByOutpoint[lease.Outpoint] = lease + } + + // 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, + ) + } + } + + needUnspent := false + for _, op := range ops { + if _, ok := leaseByOutpoint[op]; !ok { + needUnspent = true + break + } + } + 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 + } + + _, err := l.lnd.WalletKit.LeaseOutput( + ctx, lockID, op, customAnchorLeaseDuration, + ) + if err != nil { + return locked, fmt.Errorf( + "unable to lease wallet input %v: %w", op, err, + ) + } + locked = append(locked, op) + } + + return locked, nil +} + +// ReleaseInput releases only the calling batch's custom-anchor lease for an +// input. +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.LockID == lockID { + owned[lease.Outpoint] = struct{}{} + } + } + + var releaseErr error + for _, op := range ops { + if _, ok := owned[op]; !ok { + continue + } + 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, + )) + } + } + + return releaseErr +} + // UnlockInput unlocks the set of target inputs after a batch or send // transaction is abandoned. func (l *LndRpcWalletAnchor) UnlockInput(ctx context.Context, @@ -277,5 +431,7 @@ 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 _ tapnode.CustomAnchorBatchLeaser = (*LndRpcWalletAnchor)(nil) var _ tapfreighter.WalletAnchor = (*LndRpcWalletAnchor)(nil) 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 8173d90eab..d57241a163 100644 --- a/rpcserver/rpcserver.go +++ b/rpcserver/rpcserver.go @@ -1,3 +1,4 @@ +//nolint:lll package rpcserver import ( @@ -109,6 +110,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" @@ -947,14 +952,53 @@ 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 { + 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, + ) + 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(), @@ -963,9 +1007,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()) + } verboseBatch, 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) @@ -989,6 +1045,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) { @@ -1074,13 +1147,43 @@ 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 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", + ) + } + 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(), @@ -1093,6 +1196,7 @@ func (r *RPCServer) FinalizeBatch(ctx context.Context, tapgarden.FinalizeParams{ FeeRate: feeRateOpt, SiblingTapTree: tapTreeOpt, + SignedPsbt: signedPsbt, }, ) if err != nil { @@ -6283,11 +6387,15 @@ func marshalMintingBatch(batch *tapgarden.MintingBatch, return nil, err } + batchKey := batch.BatchKey.PubKey.SerializeCompressed() rpcBatch := &mintrpc.MintingBatch{ - BatchKey: batch.BatchKey.PubKey.SerializeCompressed(), - State: rpcBatchState, - CreatedAt: batch.CreationTime.UTC().Unix(), - HeightHint: batch.HeightHint, + BatchKey: batchKey, + 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 f72b72349b..12a6db881f 100644 --- a/tapdb/asset_minting.go +++ b/tapdb/asset_minting.go @@ -1,3 +1,4 @@ +//nolint:lll package tapdb import ( @@ -265,6 +266,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. @@ -1044,9 +1054,10 @@ 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, - rawBatchKey, batchSibling, genScript []byte) (*commitment.TapCommitment, - error) { +func fetchAssetSprouts( + ctx context.Context, q PendingAssetStore, + rawBatchKey, batchSibling, genScript []byte, + mintingInternalKey *btcec.PublicKey) (*commitment.TapCommitment, error) { dbSprout, err := q.FetchAssetsForBatch(ctx, rawBatchKey) if err != nil { @@ -1168,13 +1179,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) @@ -1184,7 +1188,7 @@ func fetchAssetSprouts(ctx context.Context, q PendingAssetStore, } return tapgarden.VerifyOutputScript( - batchKey, tapSibling, genScript, sprouts, + mintingInternalKey, tapSibling, genScript, sprouts, ) } @@ -1438,6 +1442,8 @@ func marshalMintingBatch(ctx context.Context, q PendingAssetStore, batch.GenesisPacket = &tapgarden.FundedMintAnchorPsbt{ FundedPsbt: tapsend.FundedPsbt{ Pkt: genesisPkt, + LockedUTXOs: tapgarden. + CustomAnchorLockedUTXOs(genesisPkt), ChangeOutputIndex: extractSqlInt32[int32]( dbBatch.ChangeOutputIndex, ), @@ -1487,8 +1493,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 @@ -1814,16 +1825,146 @@ func (a *AssetMintingStore) AddSproutsToBatch(ctx context.Context, return nil } +// 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 -// 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, - batch *tapgarden.MintingBatch, genesisPkt *tapsend.FundedPsbt, +func (a *AssetMintingStore) CommitSignedGenesisTxWithKey(ctx context.Context, + batch *tapgarden.MintingBatch, + mintingInternalKey keychain.KeyDescriptor, + genesisPkt *tapsend.FundedPsbt, anchorOutputIndex uint32, merkleRoot, tapTreeRoot []byte, tapSibling []byte) error { @@ -1846,6 +1987,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{ @@ -1865,6 +2010,25 @@ func (a *AssetMintingStore) CommitSignedGenesisTx(ctx context.Context, var writeTxOpts AssetStoreTxOptions 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", + ) + } + + _, 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, + ) + } + // 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) @@ -1897,7 +2061,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, @@ -1947,6 +2111,53 @@ func (a *AssetMintingStore) CommitSignedGenesisTx(ctx context.Context, return nil } +// 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 { + 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, + }) + 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, @@ -2221,7 +2432,9 @@ func (a *AssetMintingStore) DeleteTapscriptTree(ctx context.Context, // cultivator consume separately, as well as the TapscriptTreeManager // used for batch tap siblings. var ( - _ tapgarden.BatchStore = (*AssetMintingStore)(nil) - _ tapgarden.MintingRefReader = (*AssetMintingStore)(nil) - _ asset.TapscriptTreeManager = (*AssetMintingStore)(nil) + _ tapgarden.BatchStore = (*AssetMintingStore)(nil) + _ tapgarden.MintingRefReader = (*AssetMintingStore)(nil) + _ tapgarden.SignedGenesisPsbtStore = (*AssetMintingStore)(nil) + _ tapgarden.MintingInternalKeyStore = (*AssetMintingStore)(nil) + _ asset.TapscriptTreeManager = (*AssetMintingStore)(nil) ) diff --git a/tapdb/asset_minting_test.go b/tapdb/asset_minting_test.go index c6c31742fc..d9784d0795 100644 --- a/tapdb/asset_minting_test.go +++ b/tapdb/asset_minting_test.go @@ -1,3 +1,4 @@ +//nolint:lll package tapdb import ( @@ -27,6 +28,7 @@ import ( "github.com/lightninglabs/taproot-assets/tapdb/sqlc" "github.com/lightninglabs/taproot-assets/tapgarden" "github.com/lightninglabs/taproot-assets/tapnode" + "github.com/lightninglabs/taproot-assets/tappsbt" "github.com/lightninglabs/taproot-assets/tapscript" "github.com/lightninglabs/taproot-assets/tapsend" "github.com/lightningnetwork/lnd/clock" @@ -1056,15 +1058,51 @@ func TestCommitBatchChainActions(t *testing.T) { // // TODO(roasbeef): move the tx extraction up one layer? randAssetCtx.genesisPkt.Pkt.Inputs[0].FinalScriptSig = []byte{} + 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, + ) + 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.mintingBatch, randAssetCtx.genesisPkt, 0, + require.NoError(t, assetStore.CommitSignedGenesisTxWithKey( + ctx, randAssetCtx.mintingBatch, customInternalKey, + randAssetCtx.genesisPkt, 0, randAssetCtx.merkleRoot, randAssetCtx.scriptRoot, randAssetCtx.tapSiblingBytes, )) + require.ErrorContains(t, assetStore.StoreSignedGenesisPsbt( + ctx, randAssetCtx.mintingBatch.BatchKey.PubKey, + randAssetCtx.genesisPkt, + ), "batch in state BatchStateBroadcast") require.Equal( t, tapgarden.BatchStateBroadcast, randAssetCtx.mintingBatch.State(), @@ -1107,6 +1145,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, @@ -1295,6 +1339,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.CommitSignedGenesisTxWithKey( + ctx, randAssetCtx.mintingBatch, 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.mintingBatch.BatchKey.PubKey, + ) + 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. @@ -1343,6 +1424,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 1efef62552..0107d2839d 100644 --- a/tapdb/assets_store_test.go +++ b/tapdb/assets_store_test.go @@ -2654,6 +2654,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 c9e76fb0ae..34ca972616 100644 --- a/tapdb/sqlc/assets.sql.go +++ b/tapdb/sqlc/assets.sql.go @@ -1392,6 +1392,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 @@ -3349,6 +3413,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 @@ -3886,8 +4000,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 ` @@ -4172,3 +4288,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 c9627fad41..6dc3b2e0d0 100644 --- a/tapdb/sqlc/querier.go +++ b/tapdb/sqlc/querier.go @@ -193,6 +193,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) @@ -480,6 +485,7 @@ type Querier interface { ReAnchorPassiveAssets(ctx context.Context, arg ReAnchorPassiveAssetsParams) error RecordReorgDeliveryFailure(ctx context.Context, arg RecordReorgDeliveryFailureParams) error RecordReorgEffectFailure(ctx context.Context, arg RecordReorgEffectFailureParams) error + RepairCustomAnchorInternalKey(ctx context.Context, arg RepairCustomAnchorInternalKeyParams) (int64, error) // The receive-side inverse of event completion: the anchor // transaction the events were keyed to was decided against by the // chain, so the events return to the given (pre-completion) status. @@ -654,6 +660,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 02de659454..5f088ab93e 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 @@ -730,6 +748,57 @@ 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 internal_keys.raw_key = @expected_old_key +), replacement_key AS ( + 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) +WHERE outpoint = @outpoint + AND txn_id = (SELECT txn_id FROM candidate_batch) + AND internal_key_id = (SELECT key_id FROM expected_old_key); + -- name: FetchOrphanManagedUTXOs :many SELECT utxos.*, diff --git a/tapgarden/batch.go b/tapgarden/batch.go index a541f42fa9..083373255a 100644 --- a/tapgarden/batch.go +++ b/tapgarden/batch.go @@ -1,3 +1,4 @@ +//nolint:lll package tapgarden import ( @@ -7,12 +8,14 @@ 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" "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" ) @@ -76,6 +79,21 @@ 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 @@ -146,12 +164,15 @@ func (m *MintingBatch) Copy() (*MintingBatch, error) { } batchCopy := &MintingBatch{ - CreationTime: m.CreationTime, - HeightHint: m.HeightHint, - BatchKey: asset.CopyKeyDescriptor(m.BatchKey), - SupplyCommitments: m.SupplyCommitments, - Seedlings: copySeedlings(m.Seedlings), - AssetMetas: copyAssetMetas(m.AssetMetas), + CreationTime: m.CreationTime, + HeightHint: m.HeightHint, + BatchKey: asset.CopyKeyDescriptor(m.BatchKey), + SupplyCommitments: m.SupplyCommitments, + Seedlings: copySeedlings(m.Seedlings), + AssetMetas: copyAssetMetas(m.AssetMetas), + CustomAnchorLeaseError: m.CustomAnchorLeaseError, + CustomAnchorPublishError: m.CustomAnchorPublishError, + CustomAnchorKeyError: m.CustomAnchorKeyError, } batchCopy.setState(m.State()) @@ -243,13 +264,60 @@ 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") + } + + 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) + } + + 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/cultivator.go b/tapgarden/cultivator.go index cf44e21e29..388231f3f8 100644 --- a/tapgarden/cultivator.go +++ b/tapgarden/cultivator.go @@ -1,3 +1,4 @@ +//nolint:lll package tapgarden import ( @@ -19,9 +20,11 @@ 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/tapreorg" "github.com/lightninglabs/taproot-assets/tapsend" "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lnwallet/chainfee" "golang.org/x/exp/maps" ) @@ -31,6 +34,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 ( @@ -123,6 +132,10 @@ type CultivatorConfig struct { // 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 + // AnchoringWaiters is the planter's waiter set, which its delivery // listener nudges; the cultivator's wait on its anchoring blocks // on it. Required whenever AnchoringWatcher is set. @@ -136,6 +149,12 @@ type Cultivator 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 + customAnchorLeaseError string + customAnchorPublishError string + batchKey BatchKey cfg *CultivatorConfig @@ -146,6 +165,15 @@ type Cultivator struct { // confInfo is used to store a delivered confirmation event. confInfo *chainntnfs.TxConfirmation + // anchorOutputIndex is the index in the anchor output that commits to + // 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 // abandonEvent reports that the anchoring watcher abandoned the // batch's genesis transaction: a conflicting spend of its inputs // was buried, and the mint site's compensation has already @@ -166,6 +194,44 @@ type Cultivator struct { *fn.ContextGuard } +func (b *Cultivator) setCustomAnchorLeaseError(status string) { + b.statusMu.Lock() + defer b.statusMu.Unlock() + + b.customAnchorLeaseError = status +} + +func (b *Cultivator) setCustomAnchorPublishError(status string) { + b.statusMu.Lock() + defer b.statusMu.Unlock() + + b.customAnchorPublishError = status +} + +func (b *Cultivator) customAnchorStatus() (string, string) { + b.statusMu.RLock() + defer b.statusMu.RUnlock() + + return b.customAnchorLeaseError, b.customAnchorPublishError +} + +// batchCopy returns a consistent planter-owned snapshot of the batch and its +// transient custom-anchor health. The cultivator is the sole writer of the +// health fields while it is active. +func (b *Cultivator) batchCopy() (*MintingBatch, error) { + b.statusMu.RLock() + defer b.statusMu.RUnlock() + + batchCopy, err := b.cfg.Batch.Copy() + if err != nil { + return nil, err + } + batchCopy.CustomAnchorLeaseError = b.customAnchorLeaseError + batchCopy.CustomAnchorPublishError = b.customAnchorPublishError + + return batchCopy, nil +} + // augmenter returns the GenesisTxAugmenter from the embedded // GardenKit, or a NoOpAugmenter when none was wired. Call sites // can invoke augmenter methods without nil-checking. @@ -179,17 +245,81 @@ func (b *Cultivator) augmenter() GenesisTxAugmenter { // NewCultivator creates a new Taproot Asset cultivator based on the passed // config. func NewCultivator(cfg *CultivatorConfig) *Cultivator { - return &Cultivator{ - batchKey: asset.ToSerialized(cfg.Batch.BatchKey.PubKey), - cfg: cfg, - confEvent: make(chan *chainntnfs.TxConfirmation, 1), - abandonEvent: make(chan struct{}, 1), - done: make(chan struct{}), + cultivator := &Cultivator{ + batchKey: asset.ToSerialized(cfg.Batch.BatchKey.PubKey), + cfg: cfg, + customAnchorLeaseError: cfg.Batch.CustomAnchorLeaseError, + customAnchorPublishError: cfg.Batch.CustomAnchorPublishError, + confEvent: make(chan *chainntnfs.TxConfirmation, 1), + done: make(chan struct{}), + abandonEvent: make(chan struct{}, 1), ContextGuard: &fn.ContextGuard{ DefaultTimeout: DefaultTimeout, Quit: make(chan struct{}), }, } + if cfg.Batch.GenesisPacket != nil { + cultivator.anchorOutputIndex = cfg.Batch.GenesisPacket. + AssetAnchorOutIdx + } + + return cultivator +} + +// 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 *Cultivator) 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 } // Done returns a channel that is closed once the cultivator's main @@ -273,6 +403,16 @@ func (b *Cultivator) Cancel(respCh chan<- CancelResp) error { cancelResp = CancelResp{true, err} case BatchStateCommitted: + if customAnchorPublicationPending(b.cfg.Batch) { + err := fmt.Errorf( + "Cultivator(%x), custom anchor publication "+ + "status is ambiguous and is not cancellable", + batchKey, + ) + cancelResp = CancelResp{false, err} + break + } + err := b.cfg.BatchStore.UpdateBatchState( ctx, b.cfg.Batch, BatchStateSproutCancelled, @@ -517,6 +657,13 @@ func (b *Cultivator) 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 @@ -526,6 +673,62 @@ func (b *Cultivator) 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 := renewCustomAnchorLeasesReadOnly( + ctx, b.cfg.Wallet, + customAnchorLeaseID(b.cfg.Batch.BatchKey.PubKey), + 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.publishMintErrorEvent(statusErr, BatchStateBroadcast) + 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.publishMintErrorEvent(statusErr, BatchStateBroadcast) + 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.publishMintErrorEvent(statusErr, BatchStateBroadcast) + continue + } + + b.customAnchorWalletAccepted = true + b.setCustomAnchorPublishError("") + b.publishMintEvent(BatchStateBroadcast) + // We've received the confirmation notification, so we can // advance our state machine through the final two phases. case confInfo := <-b.confEvent: @@ -569,6 +772,23 @@ func (b *Cultivator) assetCultivator() { "buried conflicting transaction; batch "+ "cancelled", b.batchKey[:]) + // The watcher has committed abandonment on disk. Release + // only this custom batch's recorded wallet leases; keep + // the shared signed packet immutable for concurrent reads. + if isCustomAnchorPsbt(b.cfg.Batch.GenesisPacket.Pkt) { + ctx, cancel := b.WithCtxQuit() + err := releaseCustomAnchorOutpoints( + ctx, b.cfg.Wallet, + customAnchorLeaseID(b.cfg.Batch.BatchKey.PubKey), + b.cfg.Batch.GenesisPacket.LockedUTXOs, + ) + cancel() + if err != nil { + log.Warnf("Unable to release abandoned custom "+ + "anchor leases: %v", err) + } + } + b.cfg.Batch.setState(BatchStateSproutCancelled) b.publishMintEvent(BatchStateSproutCancelled) @@ -705,6 +925,78 @@ func (b *Cultivator) seedlingsToAssetSprouts(ctx context.Context, ) } +// publishBroadcast retries the persisted transaction after its confirmation +// sensor has been installed. Custom anchors retain their batch-scoped leases +// and remain watched even when wallet acceptance is ambiguous. +func (b *Cultivator) publishBroadcast(signedTx *wire.MsgTx) error { + customAnchor := isCustomAnchorPsbt( + b.cfg.Batch.GenesisPacket.Pkt, + ) + if !customAnchor || !b.customAnchorWalletAccepted { + if customAnchor { + renewCtx, renewCancel := b.WithCtxQuit() + renewErr := renewCustomAnchorLeasesReadOnly( + renewCtx, b.cfg.Wallet, + customAnchorLeaseID(b.cfg.Batch.BatchKey.PubKey), + b.cfg.Batch.GenesisPacket, + ) + renewCancel() + if renewErr != nil { + b.setCustomAnchorLeaseError(renewErr.Error()) + renewStatusErr := fmt.Errorf( + "custom anchor lease renewal "+ + "before publish retry failed: %w", + renewErr, + ) + b.publishMintErrorEvent( + renewStatusErr, BatchStateBroadcast, + ) + } 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 { + return fmt.Errorf("unable to publish "+ + "transaction: %w", err) + } + if customAnchor { + b.customAnchorWalletAccepted = err == nil + if err != nil { + publishErrText := err.Error() + b.setCustomAnchorPublishError( + publishErrText, + ) + publishStatusErr := fmt.Errorf( + "custom anchor publish remains "+ + "ambiguous: %w", err, + ) + b.publishMintErrorEvent( + publishStatusErr, + BatchStateBroadcast, + ) + } 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("") + } + } + } + } + + return nil +} + // stateStep attempts to transition the state machine from one state to // another. Two states are terminal: the broadcast state, and the finalized // state. @@ -833,9 +1125,19 @@ func (b *Cultivator) stateStep(currentState BatchState) (BatchState, error) { "script: %w", err) } - anchorOut := genesisTxPkt.UnsignedTx. - TxOut[genesisPkt.AssetAnchorOutIdx] - anchorOut.PkScript = genesisScript + 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("Cultivator(%x): committing sprouts to disk", b.batchKey[:]) @@ -910,11 +1212,17 @@ func (b *Cultivator) 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 + publishStateAtEntry := getCustomAnchorPublishState(signedPkt) + _, 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. @@ -928,6 +1236,12 @@ func (b *Cultivator) stateStep(currentState BatchState) (BatchState, error) { return 0, fmt.Errorf("genesis TX failed final checks: "+ "%w", err) } + err = validateExclusionProofOutputs( + signedPkt, b.anchorOutputIndex, + ) + if err != nil { + return 0, err + } // Populate how much this tx paid in on-chain fees. chainFees, err := signedPkt.GetTxFee() @@ -943,7 +1257,24 @@ func (b *Cultivator) stateStep(currentState BatchState) (BatchState, error) { log.Infof("Cultivator(%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)) + } + + // 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 import the public @@ -984,29 +1315,19 @@ func (b *Cultivator) stateStep(currentState BatchState) (BatchState, error) { return 0, err } - // 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) - - // Import the minting output key into the backing wallet so it - // recognizes the de minimis amt of sats under our control. - // This MUST happen before the state-transition write below: a - // crash between writing Broadcast and importing the key would - // leave lnd unaware of the output forever, since the Broadcast - // branch on restart never re-runs this step. With the import - // first, a crash anywhere in this branch resumes from Committed - // and the (idempotent) import retries cleanly. - ctx, cancel = b.WithCtxQuit() - defer cancel() - _, err = b.cfg.Wallet.ImportTaprootOutput(ctx, mintingOutputKey) + // 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 - // 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 @@ -1015,24 +1336,176 @@ func (b *Cultivator) stateStep(currentState BatchState) (BatchState, error) { } changeIdx := b.cfg.Batch.GenesisPacket.ChangeOutputIndex - signedFundedPsbt := tapsend.FundedPsbt{ - Pkt: signedPkt, - ChangeOutputIndex: changeIdx, - ChainFees: int64(chainFees), + // Start from the funded packet so custom lease ownership survives + // the signed-genesis commit. Replace only the signed transaction and + // newly known fee fields; copy the slice to avoid aliasing caretaker + // and store state. + signedFundedPsbt := b.cfg.Batch.GenesisPacket.FundedPsbt + signedFundedPsbt.Pkt = signedPkt + signedFundedPsbt.ChangeOutputIndex = changeIdx + signedFundedPsbt.ChainFees = int64(chainFees) + signedFundedPsbt.LockedUTXOs = fn.CopySlice( + b.cfg.Batch.GenesisPacket.LockedUTXOs, + ) + + // 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 := signedFundedPsbt + publishFunded.Pkt = publishPkt + storeCtx, storeCancel := b.WithCtxQuit() + err = storeSignedGenesisPsbt( + storeCtx, b.cfg.BatchStore, 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 + signedFundedPsbt.Pkt = publishPkt + b.cfg.Batch.GenesisPacket.Pkt = publishPkt } - err = b.cfg.BatchStore.CommitSignedGenesisTx( - ctx, b.cfg.Batch, - &signedFundedPsbt, - b.cfg.Batch.GenesisPacket.AssetAnchorOutIdx, - merkleRoot, tapCommitmentRoot[:], siblingBytes, + + // 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 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 + commitPacket = &signedFundedPsbt + acceptedPacket *FundedMintAnchorPsbt + ) + if isCustomAnchorPsbt(signedPkt) { + renewCtx, renewCancel := b.WithCtxQuit() + renewErr := renewCustomAnchorLeasesReadOnly( + renewCtx, b.cfg.Wallet, + customAnchorLeaseID(b.cfg.Batch.BatchKey.PubKey), + 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()) + + // Import-pending and publish-pending both retain fully + // signed bytes the external signer can independently + // relay. Cross into durable Broadcast so the watcher is + // installed, but don't actively republish until the + // recorded local leases are reacquired. + } else { + b.setCustomAnchorLeaseError("") + } + + if renewErr == nil { + publishAttempted = true + publishCtx, publishCancel := b.WithCtxQuit() + publishErr = tapnode.ValidateAndPublishTransaction( + publishCtx, b.cfg.ChainBridge, signedTx, + IssuanceTxLabel, + ) + 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 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, + )) + } + b.customAnchorWalletAccepted = publishAttempted && + publishErr == nil + 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 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, err = b.cfg.Batch.GenesisPacket.Copy() + if err != nil { + return 0, fmt.Errorf("unable to copy accepted custom "+ + "anchor packet: %w", err) + } + // Preserve the deep-copied packet. Assigning the whole + // FundedPsbt here would alias publishPkt and clearing the + // accepted marker below would also make the live Committed + // batch cancellable before the DB commit succeeds. + acceptedPacket.ChangeOutputIndex = + signedFundedPsbt.ChangeOutputIndex + acceptedPacket.ChainFees = signedFundedPsbt.ChainFees + acceptedPacket.LockedUTXOs = fn.CopySlice( + signedFundedPsbt.LockedUTXOs, + ) + setCustomAnchorPublishState( + acceptedPacket.Pkt, + customAnchorPublishNone, + ) + commitPacket = &acceptedPacket.FundedPsbt + } + } + } + + // 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) + + err = commitSignedGenesisTx( + ctx, b.cfg.BatchStore, b.cfg.Batch, mintingInternalKey, + commitPacket, b.anchorOutputIndex, merkleRoot, + tapCommitmentRoot[:], + siblingBytes, ) if err != nil { return 0, fmt.Errorf("unable to commit genesis "+ "tx: %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 + // installed instead of silently leaving the batch unattended. + if publishErr != nil { + log.Warnf("Initial custom anchor publication was ambiguous, "+ + "retrying from Broadcast: %v", publishErr) + } // DB write succeeded; sync in-memory batch with disk. - b.cfg.Batch.GenesisPacket.Pkt = signedPkt + if acceptedPacket != nil { + b.cfg.Batch.GenesisPacket.Pkt = acceptedPacket.Pkt + } else { + b.cfg.Batch.GenesisPacket.Pkt = signedPkt + } b.cfg.Batch.GenesisPacket.ChainFees = int64(chainFees) log.Infof("Cultivator(%x): transition states: %v -> %v", @@ -1055,14 +1528,26 @@ func (b *Cultivator) stateStep(currentState BatchState) (BatchState, error) { log.Infof("Cultivator(%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)) + } // The re-org watcher is the sole sensor: the batch registers // its genesis transaction as a speculative anchoring - // (idempotently) before the transaction leaves this daemon — - // the stake exists before the act it senses — and a goroutine - // waits on the registry's delivered phase instead of a - // cultivator-owned confirmation subscription. + // (idempotently) before the Broadcast publish — the stake + // exists before the act it senses — and a goroutine waits on + // the registry's delivered phase. There is no cultivator-owned + // confirmation subscription. + // + // An earlier ambiguous submission (the Committed-state custom + // publish) may already have relayed the exact transaction, so + // a later publication error must never create a gap in + // confirmation tracking. publishBroadcast keeps a custom + // anchor watched when wallet acceptance is still ambiguous. regCtx, regCancel := b.WithCtxQuitNoTimeout() err = b.registerMintAnchoring(regCtx, signedTx) @@ -1073,17 +1558,10 @@ func (b *Cultivator) stateStep(currentState BatchState) (BatchState, error) { "anchoring: %w", err) } - // With the anchoring staked, broadcast the transaction. - ctx, cancel := b.WithCtxQuit() - defer cancel() - err = b.cfg.ChainBridge.PublishTransaction( - ctx, signedTx, IssuanceTxLabel, - ) - if err != nil { + if err := b.publishBroadcast(signedTx); err != nil { regCancel() - return 0, fmt.Errorf("unable to publish "+ - "transaction: %w", err) + return 0, err } txHash := signedTx.TxHash() @@ -1159,6 +1637,10 @@ func (b *Cultivator) 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( @@ -1174,8 +1656,8 @@ func (b *Cultivator) stateStep(currentState BatchState) (BatchState, error) { BlockHeight: confInfo.BlockHeight, Tx: confInfo.Tx, TxIndex: int(confInfo.TxIndex), - OutputIndex: int(pkt.AssetAnchorOutIdx), - InternalKey: b.cfg.Batch.BatchKey.PubKey, + OutputIndex: int(b.anchorOutputIndex), + InternalKey: mintingInternalKey, TapscriptSibling: batchSibling, TaprootAssetRoot: batchCommitment, }, diff --git a/tapgarden/custom_anchor_repair.go b/tapgarden/custom_anchor_repair.go new file mode 100644 index 0000000000..483caa85ac --- /dev/null +++ b/tapgarden/custom_anchor_repair.go @@ -0,0 +1,425 @@ +//nolint:lll +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..67c65fce17 --- /dev/null +++ b/tapgarden/custom_anchor_repair_test.go @@ -0,0 +1,530 @@ +//nolint:lll +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) + batchCopy, err := batches[0].Copy() + require.NoError(t, err) + require.Equal(t, want, batchCopy.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 new file mode 100644 index 0000000000..691fbd580e --- /dev/null +++ b/tapgarden/custom_anchor_test.go @@ -0,0 +1,889 @@ +//nolint:lll +package tapgarden + +import ( + "bytes" + "context" + "errors" + "fmt" + "sync" + "testing" + "time" + + "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/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" + "github.com/lightningnetwork/lnd/keychain" + "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 +} + +type blockingCustomAnchorWallet struct { + *trackedCustomAnchorWallet + + blockOn wire.OutPoint + 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) { + + 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(), + 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) + + 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") +} + +// TestCancelledBatchFundingLeaseDispatch guards the merge with ordinary +// wallet-funded cancellation: custom inputs keep their batch-scoped lease +// owner and foreign inputs must never reach the wallet unlock fallback. +func TestCancelledBatchFundingLeaseDispatch(t *testing.T) { + for _, owned := range []bool{false, true} { + t.Run(fmt.Sprintf("owned=%v", owned), func(t *testing.T) { + ctx := t.Context() + wallet := newTrackedCustomAnchorWallet() + pkt := testCustomAnchorPacket(t) + markCustomAnchorPsbt(pkt) + op := pkt.UnsignedTx.TxIn[0].PreviousOutPoint + foreign := op + foreign.Index++ + pkt.UnsignedTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: foreign, + }) + pkt.Inputs = append(pkt.Inputs, psbt.PInput{}) + _, key := btcec.PrivKeyFromBytes( + bytes.Repeat([]byte{2}, 32), + ) + leaseID := customAnchorLeaseID(key) + var locked []wire.OutPoint + if owned { + _, err := wallet.LeaseInput(ctx, leaseID, op) + require.NoError(t, err) + locked = []wire.OutPoint{op} + } + SetCustomAnchorLockedUTXOs(pkt, locked) + batch := &MintingBatch{ + BatchKey: keychain.KeyDescriptor{PubKey: key}, + GenesisPacket: &FundedMintAnchorPsbt{ + FundedPsbt: tapsend.FundedPsbt{ + Pkt: pkt, LockedUTXOs: locked, + }, + }, + } + releaseBatchFundingInputs(ctx, wallet, batch) + require.Empty(t, wallet.leases) + require.Empty(t, batch.GenesisPacket.LockedUTXOs) + if owned { + require.Equal(t, []customAnchorLeaseRequest{{ + leaseID: leaseID, op: op, + }}, wallet.releases) + } else { + require.Empty(t, wallet.releases) + } + select { + case unlocked := <-wallet.UnlockInputSignal: + t.Fatalf("custom input used ordinary unlock: %v", + unlocked) + default: + } + }) + } +} + +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}, + } + _, batchKey := btcec.PrivKeyFromBytes(bytes.Repeat([]byte{2}, 32)) + + require.NoError(t, renewCustomAnchorLeases( + context.Background(), wallet, customAnchorLeaseID(batchKey), 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: + } +} + +// 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 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) { + 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() + + _, 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) + 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 +} + +func TestCustomGenesisPsbtValidation(t *testing.T) { + pkt := testCustomAnchorPacket(t) + funded, err := customGenesisPsbt( + context.Background(), address.TestNet3Tap, nil, pkt, 0, -1, + noneUint32(), NoOpAugmenter{}, + ) + require.NoError(t, err) + 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( + context.Background(), address.TestNet3Tap, nil, + missingDerivation, 0, -1, noneUint32(), NoOpAugmenter{}, + ) + 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( + context.Background(), address.TestNet3Tap, nil, forgedMarker, 0, + -1, noneUint32(), NoOpAugmenter{}, + ) + require.NoError(t, err) + require.Equal( + t, customAnchorPublishNone, + getCustomAnchorPublishState(funded.Pkt), + ) + + bad := testCustomAnchorPacket(t) + bad.Inputs = nil + _, err = customGenesisPsbt( + context.Background(), address.TestNet3Tap, nil, bad, 0, -1, + noneUint32(), NoOpAugmenter{}, + ) + require.ErrorContains(t, err, "input maps") + + underfunded := testCustomAnchorPacket(t) + underfunded.Inputs[0].WitnessUtxo.Value = 500 + _, err = customGenesisPsbt( + context.Background(), address.TestNet3Tap, nil, underfunded, 0, + -1, noneUint32(), NoOpAugmenter{}, + ) + 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( + context.Background(), address.TestNet3Tap, nil, dust, 0, -1, + noneUint32(), NoOpAugmenter{}, + ) + require.ErrorContains(t, err, "anchor output is dust") + + for _, tapTree := range [][]byte{{}, {0x00}} { + anchorTapTree := testCustomAnchorPacket(t) + anchorTapTree.Outputs[0].TaprootTapTree = tapTree + _, err = customGenesisPsbt( + context.Background(), address.TestNet3Tap, nil, + anchorTapTree, 0, -1, noneUint32(), NoOpAugmenter{}, + ) + 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 { + 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( + context.Background(), address.TestNet3Tap, nil, + invalid, 0, -1, noneUint32(), NoOpAugmenter{}, + ) + 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( + context.Background(), address.TestNet3Tap, nil, validMetadata, 0, + -1, noneUint32(), NoOpAugmenter{}, + ) + 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, customAnchorImportPending) + require.True(t, customAnchorPublicationPending(batch)) + + setCustomAnchorPublishState(pkt, customAnchorPublishRejected) + require.True(t, customAnchorPublicationPending(batch)) +} + +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 + stripTapdCustomAnchorMarkers(signed) + + 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) + require.True(t, isCustomAnchorPsbt(merged)) + + 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)} + + copyAnchor, err := original.Copy() + require.NoError(t, err) + copyPkt := copyAnchor.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) +} + +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{ + chainParams: address.TestNet3Tap, + } + 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 := mockPreCommitTxOut(*delegationKey.PubKey) + require.NoError(t, err) + pkt.UnsignedTx.AddTxOut(&preCommitOut) + pkt.Outputs = append(pkt.Outputs, psbt.POutput{}) + + _, err = customGenesisPsbt( + ctx, address.TestNet3Tap, batch, clonePsbt(t, pkt), 0, -1, + noneUint32(), augmenter, + ) + require.ErrorContains(t, err, "requires a pre-commitment output index") + + wrong := clonePsbt(t, pkt) + wrong.UnsignedTx.TxOut[1].PkScript[0] ^= 1 + _, err = customGenesisPsbt( + ctx, address.TestNet3Tap, batch, wrong, 0, -1, + fn.Some(uint32(1)), augmenter, + ) + require.ErrorContains(t, err, "unique output matching the augmenter") + + wrongValue := clonePsbt(t, pkt) + wrongValue.UnsignedTx.TxOut[1].Value-- + _, err = customGenesisPsbt( + ctx, address.TestNet3Tap, batch, wrongValue, 0, -1, + fn.Some(uint32(1)), augmenter, + ) + require.ErrorContains(t, err, "unique output matching the augmenter") + + duplicate := clonePsbt(t, pkt) + duplicate.Inputs[0].WitnessUtxo.Value += preCommitOut.Value + duplicate.UnsignedTx.AddTxOut(&wire.TxOut{ + Value: preCommitOut.Value, + PkScript: fn.CopySlice(preCommitOut.PkScript), + }) + duplicate.Outputs = append(duplicate.Outputs, psbt.POutput{}) + _, err = customGenesisPsbt( + ctx, address.TestNet3Tap, batch, duplicate, 0, -1, + fn.Some(uint32(1)), augmenter, + ) + require.ErrorContains(t, err, "unique output matching the augmenter") + + _, err = customGenesisPsbt( + ctx, address.TestNet3Tap, nil, clonePsbt(t, pkt), 0, -1, + fn.Some(uint32(1)), NoOpAugmenter{}, + ) + require.ErrorContains(t, err, "without augmenter output") + + funded, err := customGenesisPsbt( + ctx, address.TestNet3Tap, batch, clonePsbt(t, pkt), 0, -1, + fn.Some(uint32(1)), augmenter, + ) + require.NoError(t, err) + batch.GenesisPacket = &funded + bindData, err := augmenter.BindData(ctx, batch) + require.NoError(t, err) + preCommit, err := bindData.UnwrapOrErr( + errors.New("pre-commitment output missing"), + ) + require.NoError(t, err) + require.Equal(t, uint32(1), preCommit.OutputIndex) + 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/custom_anchor_watcher_test.go b/tapgarden/custom_anchor_watcher_test.go new file mode 100644 index 0000000000..ba74e256db --- /dev/null +++ b/tapgarden/custom_anchor_watcher_test.go @@ -0,0 +1,237 @@ +package tapgarden + +import ( + "bytes" + "context" + "fmt" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/psbt/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/taproot-assets/fn" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightninglabs/taproot-assets/tapnode" + "github.com/lightninglabs/taproot-assets/tapnode/tapnodemock" + "github.com/lightninglabs/taproot-assets/tapreorg" + "github.com/lightninglabs/taproot-assets/tapsend" + "github.com/lightningnetwork/lnd/keychain" + "github.com/stretchr/testify/require" +) + +// customWatcherFixture resumes a signed batch with a wallet input, a foreign +// input, and its asset anchor at output one. Only the wallet input is leased. +func customWatcherFixture(t *testing.T) (*Cultivator, + *tapnodemock.ChainBridge, *trackedCustomAnchorWallet, + *tapreorg.MockRegistrar) { + + t.Helper() + pkt := testCustomAnchorPacket(t) + foreign := wire.OutPoint{Hash: chainhash.Hash{2}, Index: 3} + pkt.UnsignedTx.AddTxIn(wire.NewTxIn(&foreign, nil, nil)) + pkt.Inputs = append(pkt.Inputs, psbt.PInput{ + WitnessUtxo: wire.NewTxOut(2_000, []byte{0x51}), + }) + pkt.UnsignedTx.TxOut = append([]*wire.TxOut{ + wire.NewTxOut(0, []byte{0x6a}), + }, pkt.UnsignedTx.TxOut...) + pkt.Outputs = append([]psbt.POutput{{}}, pkt.Outputs...) + for i := range pkt.Inputs { + pkt.Inputs[i].FinalScriptSig = []byte{} + } + locked := []wire.OutPoint{pkt.UnsignedTx.TxIn[0].PreviousOutPoint} + SetCustomAnchorLockedUTXOs(pkt, locked) + setCustomAnchorPublishState(pkt, customAnchorPublishPending) + + key, err := btcec.NewPrivateKey() + require.NoError(t, err) + batch := &MintingBatch{ + BatchKey: keychain.KeyDescriptor{PubKey: key.PubKey()}, + HeightHint: 100, + GenesisPacket: &FundedMintAnchorPsbt{ + FundedPsbt: tapsend.FundedPsbt{ + Pkt: pkt, LockedUTXOs: locked, + }, + AssetAnchorOutIdx: 1, + }, + } + batch.setState(BatchStateBroadcast) + wallet := newTrackedCustomAnchorWallet() + chain := tapnodemock.NewChainBridge() + chain.PublishReq = make(chan *wire.MsgTx, 10) + registrar := tapreorg.NewMockRegistrar() + cultivator := NewCultivator(&CultivatorConfig{ + Batch: batch, + GardenKit: &GardenKit{ + Wallet: wallet, ChainBridge: chain, + AnchoringWatcher: registrar, AnchoringThreshold: 6, + }, + AnchoringWaiters: tapreorg.NewDeliveryWaiters(), + BroadcastCompleteChan: make(chan struct{}, 1), + BroadcastErrChan: make(chan error, 1), + CancelReqChan: make(chan cancelReq, 1), + ErrChan: make(chan error, 1), + SignalCompletion: func() {}, + PublishMintEvent: func(fn.Event) {}, + }) + t.Cleanup(func() { require.NoError(t, cultivator.Stop()) }) + return cultivator, chain, wallet, registrar +} + +// TestCustomAnchorWatcherConfirmation pins the merge boundary: registration +// precedes retries, failures remain watched, and externally relayed bytes can +// confirm without using a second, legacy confirmation subscription. +func TestCustomAnchorWatcherConfirmation(t *testing.T) { + failures := []string{"publication", "lease", "registration"} + for _, failure := range failures { + t.Run(failure, func(t *testing.T) { + t.Parallel() + b, chain, wallet, registrar := customWatcherFixture(t) + pkt := b.cfg.Batch.GenesisPacket.Pkt + var before bytes.Buffer + require.NoError(t, pkt.Serialize(&before)) + signed, err := psbt.Extract(pkt) + require.NoError(t, err) + chain.FailPublishOnce() + if failure == "registration" { + registrar.FailNextRegister( + fmt.Errorf("registry offline"), + ) + _, err := b.stateStep(BatchStateBroadcast) + require.ErrorContains( + t, err, "registry offline", + ) + require.Empty(t, chain.PublishAttempts) + } + if failure == "lease" { + op := pkt.UnsignedTx.TxIn[0].PreviousOutPoint + owner := tapnode.CustomAnchorLeaseID{1} + wallet.leases[op] = owner + } + state, err := b.stateStep(BatchStateBroadcast) + require.NoError(t, err) + require.Equal(t, BatchStateBroadcast, state) + require.False(t, b.customAnchorWalletAccepted) + if failure == "lease" { + require.Empty(t, chain.PublishAttempts) + } else { + require.Len(t, chain.PublishAttempts, 1) + attempt := <-chain.PublishAttempts + require.Equal(t, watcherTxBytes(t, signed), + watcherTxBytes(t, attempt)) + } + require.Zero(t, chain.ReqCount.Load()) + + ctx := context.Background() + txid := signed.TxHash() + anchoring, err := registrar.LookupByMatchKey( + ctx, MintSiteID, txid.CloneBytes(), + ) + require.NoError(t, err) + require.NotNil(t, anchoring) + points := anchoring.Triggers.OutPoints() + require.Len(t, points, 2) + for _, point := range points { + for i, input := range signed.TxIn { + op := input.PreviousOutPoint + if point.OutPoint != op { + continue + } + prev := pkt.Inputs[i].WitnessUtxo + require.Equal(t, prev.PkScript, + point.PkScript) + } + } + reply := make(chan CancelResp, 1) + require.Error(t, b.Cancel(reply)) + require.False(t, (<-reply).cancelAttempted) + + block := &wire.MsgBlock{ + Header: wire.BlockHeader{Nonce: 7}, + Transactions: []*wire.MsgTx{signed}, + } + chain.SetBlock(block.BlockHash(), block) + _, err = registrar.ConfirmSpend( + signed, block.BlockHash(), 101, 0, + block.Header, proof.TxMerkleProof{}, + ) + require.NoError(t, err) + b.cfg.AnchoringWaiters.Nudge(anchoring.ID) + select { + case conf := <-b.confEvent: + require.Equal(t, watcherTxBytes(t, signed), + watcherTxBytes(t, conf.Tx)) + require.Equal(t, block.BlockHash(), + *conf.BlockHash) + case <-time.After(5 * time.Second): + t.Fatal("custom anchor confirmation was lost") + } + var after bytes.Buffer + require.NoError(t, pkt.Serialize(&after)) + require.Equal(t, before.Bytes(), after.Bytes()) + }) + } +} + +// TestCustomAnchorWatcherRetryAbandonment covers retries after restart and +// terminal conflict delivery, lease ownership and packet immutability. +func TestCustomAnchorWatcherRetryAbandonment(t *testing.T) { + t.Parallel() + b, chain, wallet, registrar := customWatcherFixture(t) + b.cfg.CustomAnchorLeaseRenewalInterval = 10 * time.Millisecond + pkt := b.cfg.Batch.GenesisPacket.Pkt + var before bytes.Buffer + require.NoError(t, pkt.Serialize(&before)) + chain.FailPublishOnce() + require.NoError(t, b.Start()) + var first *wire.MsgTx + select { + case first = <-chain.PublishAttempts: + case <-time.After(5 * time.Second): + t.Fatal("initial custom publication was not attempted") + } + select { + case retry := <-chain.PublishReq: + require.Equal(t, watcherTxBytes(t, first), + watcherTxBytes(t, retry)) + case <-time.After(5 * time.Second): + t.Fatal("custom publication was not retried") + } + txid := first.TxHash() + anchoring, err := registrar.LookupByMatchKey(context.Background(), + MintSiteID, txid.CloneBytes()) + require.NoError(t, err) + require.NotNil(t, anchoring) + foreign := first.Copy() + foreign.TxOut[0].Value++ + require.NoError(t, registrar.Abandon(anchoring.ID, foreign, + chainhash.Hash{9}, 107, 0)) + b.cfg.AnchoringWaiters.Nudge(anchoring.ID) + select { + case <-b.Done(): + case <-time.After(5 * time.Second): + t.Fatal("custom cultivator did not stop after abandonment") + } + require.Equal(t, BatchStateSproutCancelled, b.cfg.Batch.State()) + require.Empty(t, b.cfg.ErrChan) + require.Zero(t, chain.ReqCount.Load()) + wallet.mu.Lock() + defer wallet.mu.Unlock() + require.Empty(t, wallet.leases) + require.Equal(t, []customAnchorLeaseRequest{{ + leaseID: customAnchorLeaseID(b.cfg.Batch.BatchKey.PubKey), + op: pkt.UnsignedTx.TxIn[0].PreviousOutPoint, + }}, wallet.releases) + var after bytes.Buffer + require.NoError(t, pkt.Serialize(&after)) + require.Equal(t, before.Bytes(), after.Bytes()) +} + +func watcherTxBytes(t *testing.T, tx *wire.MsgTx) []byte { + t.Helper() + var buf bytes.Buffer + require.NoError(t, tx.Serialize(&buf)) + return buf.Bytes() +} diff --git a/tapgarden/interface.go b/tapgarden/interface.go index bbb6c8846c..6845080419 100644 --- a/tapgarden/interface.go +++ b/tapgarden/interface.go @@ -1,3 +1,4 @@ +//nolint:lll package tapgarden import ( @@ -307,6 +308,92 @@ type MintingRefReader interface { error) } +// SignedGenesisPsbtStore is an optional BatchStore 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 BatchStore 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 BatchStore, + 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, + ) +} + +func commitSignedGenesisTx(ctx context.Context, store BatchStore, + 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 +// 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/interface_compat_test.go b/tapgarden/interface_compat_test.go new file mode 100644 index 0000000000..5dfb0ffb1c --- /dev/null +++ b/tapgarden/interface_compat_test.go @@ -0,0 +1,152 @@ +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 { + BatchStore + + 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 +} + +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 { + + 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") + + 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, + ) + require.NoError(t, err) + require.True(t, store.stored) + }) +} diff --git a/tapgarden/issue721_stateful_test.go b/tapgarden/issue721_stateful_test.go new file mode 100644 index 0000000000..9b0a3a5a6c --- /dev/null +++ b/tapgarden/issue721_stateful_test.go @@ -0,0 +1,2387 @@ +//nolint:lll +package tapgarden_test + +import ( + "bytes" + "context" + "crypto/sha256" + "fmt" + "sync" + "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" + "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/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 { + testMintingStore + fail bool + failCommit bool + commitAttempted bool + commitLockedOutpoints []wire.OutPoint +} + +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 + s.commitLockedOutpoints = fn.CopySlice(genesisTx.LockedUTXOs) + if s.failCommit { + return fmt.Errorf("injected signed genesis commit failure") + } + + keyStore, ok := s.testMintingStore.(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 { + testMintingStore + fail bool +} + +type issue721TimeoutFundingStore struct { + testMintingStore +} + +func (s *issue721TimeoutFundingStore) CommitBatchFunding(ctx context.Context, + _ *tapgarden.MintingBatch, _ *chainhash.Hash, + _ tapgarden.FundedMintAnchorPsbt, + _ fn.Option[tapgarden.PreCommitBindData]) error { + + <-ctx.Done() + return ctx.Err() +} + +func (s *issue721FailStateStore) UpdateBatchState(ctx context.Context, + batch *tapgarden.MintingBatch, state tapgarden.BatchState) error { + + if s.fail && state == tapgarden.BatchStateSproutCancelled { + return fmt.Errorf("injected cancellation store failure") + } + + return s.testMintingStore.UpdateBatchState(ctx, batch, state) +} + +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") + } + + signedStore, ok := s.testMintingStore.(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.BatchStore) 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 +// 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() + + // 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() + 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) + 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"), + }} + 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() + 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](), + AnchorPsbt: pkt, + AssetAnchorOutIdx: 1, + ChangeOutputIndex: -1, + PreCommitOutputIndex: fn.None[uint32](), + }) + require.NoError(t, err) + require.NotNil(t, resp) + + batch, err := resp.ToMintingBatch() + require.NoError(t, err) + return batch +} + +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{item, witnessScript}, + ) + require.NoError(t, err) + + 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 +// 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) + callerBytes := serializePacket(t, pkt) + ownedInput := pkt.UnsignedTx.TxIn[0].PreviousOutPoint + h.wallet.SetOwnedInput(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) + 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, 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], + 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[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) + 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() + 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.assertNumCultivatorsActive(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, + }) + 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, + ) + require.NoError(t, err) + require.True(t, (*importedKey).IsEqual(expectedOutputKey)) + published, err := psbt.Extract(valid) + require.NoError(t, err) + h.assertAnchoringRegistered(published) + anchorings, err := h.registrar.AllAnchorings( + context.Background(), tapgarden.MintSiteID, + ) + require.NoError(t, err) + require.Len(t, anchorings, 1) + points := anchorings[0].Triggers.OutPoints() + require.Len(t, points, len(published.TxIn)) + require.Equal(t, published.TxIn[0].PreviousOutPoint, points[0].OutPoint) + require.Equal( + t, valid.Inputs[0].WitnessUtxo.PkScript, points[0].PkScript, + ) + 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()) + 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) + require.Equal(t, uint32(1), persisted.GenesisPacket.AssetAnchorOutIdx) + 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 +// retried automatically from Broadcast and installs a confirmation watcher. +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 { + _ = 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, + ) + + // Registration returns only after release, so the lease failure is + // visible to the Broadcast retry that follows it. The anchoring is + // already stored when entered fires. + entered, release := h.registrar.PauseNextRegister() + h.chain.FailPublishOnce() + 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) + firstAttempt, err := fn.RecvOrTimeout( + h.chain.PublishAttempts, defaultTimeout, + ) + require.NoError(t, err) + select { + case <-entered: + case <-time.After(defaultTimeout): + t.Fatal("mint anchoring was not registered before publish retry") + } + + // 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"), + ) + h.assertAnchoringRegistered(*firstAttempt) + release() + 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) +} + +// 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() + 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, + ) + 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, + }) + 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) + attempt, err := fn.RecvOrTimeout( + h.chain.PublishAttempts, defaultTimeout, + ) + require.NoError(t, err) + require.Equal(t, signedTx.WitnessHash(), (*attempt).WitnessHash()) + + // Before the byte-identical retry, the Broadcast anchoring is staked + // and the recorded local input lease is renewed. + h.assertAnchoringRegistered(signedTx) + broadcastLease, err := fn.RecvOrTimeout( + h.wallet.LeaseInputSignal, defaultTimeout, + ) + require.NoError(t, err) + 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()) + + 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.Equal(t, signedTx.WitnessHash(), persistedTx.WitnessHash()) + h.assertNumCultivatorsActive(1) + _, err = h.planter.CancelBatch() + 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() + h.assertAnchoringRegistered(signedTx) + 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.confirmAnchoring(restartedTx, block) + h.assertNumCultivatorsActive(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 +// 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.SetImportError(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.SetImportError(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) + published, err := psbt.Extract(signed) + require.NoError(t, err) + h.assertAnchoringRegistered(published) + 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.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, + ) + + h.wallet.SetImportError(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.SetImportError(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) + resumed, err := psbt.Extract(signed) + require.NoError(t, err) + h.assertAnchoringRegistered(resumed) + h.assertNumCultivatorsActive(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.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, + ) + + // The first import failure durably leaves the signed packet in the + // import-pending state. + h.wallet.SetImportError(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.assertNumCultivatorsActive(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 verify cancellation stays fail-closed: import-pending + // already contains fully signed bytes the external signer can relay. + 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") + + // Finalize returns only after its failed cultivator is removed. This must + // not require polling: callers may retry or query the retained reservation + // 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.ErrorContains(t, err, "publication status is ambiguous") + require.True(t, batchKey.IsEqual(prepared.BatchKey.PubKey)) + select { + case released := <-h.wallet.ReleaseInputSignal: + t.Fatalf("lease released for relayable signed transaction: %v", + released) + default: + } + retained := h.fetchSingleBatch(batchKey) + require.Equal(t, tapgarden.BatchStateCommitted, retained.State()) +} + +// TestIssue721PublishFailureAfterRestartIsAdopted verifies that a recovered +// 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) + 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, + ) + + // 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 = issue721SignedStore(t, 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) + 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) + h.assertAnchoringRegistered(*firstAttempt) + 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) + h.assertNumCultivatorsActive(1) + select { + case op := <-h.wallet.ReleaseInputSignal: + 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, issue721SignedStore(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) + h.assertAnchoringRegistered(expected) + 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, "batch recovery is in progress") + + 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, issue721SignedStore(t, store).StoreSignedGenesisPsbt( + t.Context(), prepared.BatchKey.PubKey, &funded, + )) + + h.refreshChainPlanter() + _, err = fn.RecvOrTimeout(h.wallet.ImportPubKeySignal, defaultTimeout) + require.NoError(t, err) + watched, err := psbt.Extract(signed) + require.NoError(t, err) + h.assertAnchoringRegistered(watched) + 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, "batch recovery is in progress") +} + +// 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 = issue721SignedStore(t, 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, err := psbt.Extract(signed) + require.NoError(t, err) + + // 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.assertNumCultivatorsActive(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{testMintingStore: 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.SetOwnedInput(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) + + newCultivator := func() *tapgarden.Cultivator { + return tapgarden.NewCultivator(&tapgarden.CultivatorConfig{ + Batch: prepared, + GardenKit: &tapgarden.GardenKit{ + Wallet: h.wallet, + BatchStore: store, + }, + PublishMintEvent: func(fn.Event) { + }, + }) + } + + store.fail = true + require.NoError(t, newCultivator().Cancel( + make(chan tapgarden.CancelResp, 1), + )) + 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, newCultivator().Cancel( + make(chan tapgarden.CancelResp, 1), + )) + 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 TestIssue721ImportRestartLeaseFailureWatches(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) + op := pkt.UnsignedTx.TxIn[0].PreviousOutPoint + h.wallet.SetOwnedInput(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.SetImportError(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.SetImportError(nil) + h.wallet.SetLeaseError(op, fmt.Errorf("lease no longer available")) + h.refreshChainPlanter() + _, err = fn.RecvOrTimeout(h.wallet.ImportPubKeySignal, defaultTimeout) + require.NoError(t, err) + watched, err := psbt.Extract(signed) + require.NoError(t, err) + h.assertAnchoringRegistered(watched) + require.Eventually(t, func() bool { + return h.fetchSingleBatch(prepared.BatchKey.PubKey).State() == + tapgarden.BatchStateBroadcast + }, defaultTimeout, 10*time.Millisecond) + 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, + }) + require.NoError(t, err) + require.Len(t, batches, 1) + require.Contains( + t, batches[0].CustomAnchorLeaseError, "lease no longer available", + ) + select { + case attempt := <-h.chain.PublishAttempts: + t.Fatalf("transaction published after lease renewal failure: %v", + attempt.TxHash()) + case published := <-h.chain.PublishReq: + t.Fatalf("transaction published after lease renewal failure: %v", + published.TxHash()) + case released := <-h.wallet.ReleaseInputSignal: + t.Fatalf("import-pending lease released: %v", released) + case <-time.After(2 * h.leaseRenewalInterval): + } + _, err = h.planter.CancelBatch() + require.ErrorContains(t, err, "batch recovery is in progress") +} + +// TestIssue721RetryStoreFailureIsAtomic ensures signed packet and import marker +// updates never alias the prepared packet before the store commits. +func TestIssue721RetryStoreFailureIsAtomic(t *testing.T) { + store := &issue721FailSignedStore{testMintingStore: 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, + ) + + 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, + }) + h.assertFinalizeBatch( + &wg, respChan, "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() + 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{testMintingStore: 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) + require.Equal(t, []wire.OutPoint{ownedInput}, + store.commitLockedOutpoints) + + 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. +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.SetOwnedInput(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.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) + 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.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, + 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.SetOwnedInput(first, true) + h.wallet.SetOwnedInput(second, true) + issue721Fund(t, h, pkt) + <-h.wallet.LeaseInputSignal + <-h.wallet.LeaseInputSignal + h.wallet.SetReleaseError(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()) + }) +} + +// TestIssue721PersistenceTimeoutRollsBackLease proves that a funding-store +// timeout after successful acquisition cannot poison the lease cleanup RPC. +func TestIssue721PersistenceTimeoutRollsBackLease(t *testing.T) { + store := &issue721TimeoutFundingStore{ + testMintingStore: 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") + } + select { + case unlocked := <-h.wallet.UnlockInputSignal: + t.Fatalf("custom input used wallet-funded unlock path: %v", + unlocked) + default: + } + + 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) { + 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.registrar.FailNextRegister( + fmt.Errorf("failed to register confirmation"), + ) + 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 register confirmation", + ) + + 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( + 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, + }) + h.assertAnchoringRegistered(published) + h.assertTxPublished() + minted := h.assertFinalizeBatch(&wg, respChan, "") + require.Equal(t, tapgarden.BatchStateBroadcast, minted.State()) +} + +// TestIssue721RestartConfirmationFailureRetainsReservation verifies that a +// Broadcast batch with relayable publish-pending bytes remains the exclusive +// admission reservation when restart confirmation registration fails. +func TestIssue721RestartConfirmationFailureRetainsReservation(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, + ) + + entered, release := h.registrar.PauseNextRegister() + 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.LeaseInputSignal, defaultTimeout) + require.NoError(t, err) + _, err = fn.RecvOrTimeout(h.wallet.ImportPubKeySignal, defaultTimeout) + require.NoError(t, err) + attempt, err := fn.RecvOrTimeout(h.chain.PublishAttempts, defaultTimeout) + require.NoError(t, err) + select { + case <-entered: + case <-time.After(defaultTimeout): + t.Fatal("mint anchoring was not registered before publish retry") + } + h.wallet.SetLeaseError( + ownedInput, fmt.Errorf("suppress publish retry during watcher test"), + ) + h.assertAnchoringRegistered(*attempt) + release() + minted := h.assertFinalizeBatch(&wg, respChan, "") + require.Equal(t, tapgarden.BatchStateBroadcast, minted.State()) + + // The batch is Broadcast, but its anchoring did not survive into + // the next process. Registration failing there must leave the + // reservation in place. + h.registrar.DropAnchorings() + h.registrar.FailNextRegister( + fmt.Errorf("failed to register confirmation"), + ) + h.refreshChainPlanter() + require.Eventually(t, func() bool { + n, err := h.planter.NumActiveBatches() + return err == nil && n == 0 + }, defaultTimeout, 10*time.Millisecond) + + pending, err := h.planter.PendingBatch() + require.NoError(t, err) + require.NotNil(t, pending) + require.Equal(t, tapgarden.BatchStateBroadcast, pending.State()) + _, err = h.planter.CancelBatch() + require.ErrorContains(t, err, "BatchStateBroadcast is not cancellable") + select { + case released := <-h.wallet.ReleaseInputSignal: + t.Fatalf("publish-pending lease released after watcher failure: %v", + released) + default: + } + + second := issue721Seedling() + second.AssetName = "issue-721-after-watcher-failure" + 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") +} + +// 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()) +} + +// 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.assertNumCultivatorsActive(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. +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, state, + ) + require.NoError(t, err) + } + + h.refreshChainPlanter() + restored, err := h.planter.PendingBatch() + require.NoError(t, err) + require.Equal(t, state, restored.State()) + h.assertNumCultivatorsActive(0) + }) + } + + 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 = issue721SignedStore(t, 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) + resumed, err := psbt.Extract(signed) + require.NoError(t, err) + h.assertAnchoringRegistered(resumed) + h.assertNumCultivatorsActive(1) + }) + + 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.assertNumCultivatorsActive(1) + }) +} + +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, err := batch.GenesisPacket.Copy() + require.NoError(t, err) + legacy.Pkt.Outputs[1].Bip32Derivation = nil + legacy.Pkt.Outputs[1].TaprootBip32Derivation = nil + require.NoError(t, store.CommitBatchFunding( + t.Context(), batch, nil, *legacy, + fn.None[tapgarden.PreCommitBindData](), + )) + if state == tapgarden.BatchStateFrozen { + require.NoError(t, store.UpdateBatchState( + t.Context(), batch, 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, err := prepared.GenesisPacket.Copy() + require.NoError(t, err) + legacy.Pkt.Outputs[1].Bip32Derivation = nil + legacy.Pkt.Outputs[1].TaprootInternalKey = + fn.CopySlice(pkt.Outputs[1].TaprootInternalKey) + require.NoError(t, issue721SignedStore(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, issue721SignedStore(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) + h.assertAnchoringRegistered(*firstAttempt) + 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() + + var buf bytes.Buffer + require.NoError(t, pkt.Serialize(&buf)) + clone, err := psbt.NewFromRawBytes(&buf, false) + require.NoError(t, err) + + 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 5620a85fad..56024fa19d 100644 --- a/tapgarden/planter.go +++ b/tapgarden/planter.go @@ -1,20 +1,28 @@ +//nolint:lll package tapgarden import ( "bytes" "context" + "crypto/sha256" + "encoding/binary" "encoding/hex" "errors" "fmt" + "reflect" "slices" "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" "github.com/btcsuite/btcd/psbt/v2" + "github.com/btcsuite/btcd/txscript/v2" "github.com/btcsuite/btcd/wire/v2" "github.com/davecgh/go-spew/spew" "github.com/lightninglabs/taproot-assets/address" @@ -27,6 +35,8 @@ import ( "github.com/lightninglabs/taproot-assets/tapscript" "github.com/lightninglabs/taproot-assets/tapsend" 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" ) @@ -136,6 +146,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? } @@ -347,6 +362,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 @@ -354,6 +370,606 @@ 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, +} + +var customAnchorPublishMarker = []byte{ + 0xfc, 0x04, 't', 'a', 'p', 'd', 0x02, +} + +type customAnchorPublishState byte + +type customAnchorLeaseMarkerState byte + +const ( + customAnchorPublishNone customAnchorPublishState = iota + customAnchorPublishPending + customAnchorPublishRejected + customAnchorImportPending + + maxFinalScriptWitnessSize = 4_000_000 + maxFinalWitnessItems = 100_000 + + customAnchorLeaseRenewalInterval = 5 * time.Minute +) + +const ( + customAnchorLeaseMarkerMissing customAnchorLeaseMarkerState = iota + customAnchorLeaseMarkerLegacy + customAnchorLeaseMarkerCurrent +) + +func markCustomAnchorPsbt(packet *psbt.Packet) { + if isCustomAnchorPsbt(packet) { + return + } + + packet.Unknowns = append(packet.Unknowns, &psbt.Unknown{ + Key: fn.CopySlice(customAnchorPsbtMarker), + Value: []byte{1}, + }) +} + +// 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 + } + + unknowns := packet.Unknowns[:0] + for _, unknown := range packet.Unknowns { + if !bytes.Equal(unknown.Key, customAnchorPsbtMarker) { + unknowns = append(unknowns, unknown) + } + } + packet.Unknowns = append(unknowns, &psbt.Unknown{ + Key: fn.CopySlice(customAnchorPsbtMarker), Value: value, + }) +} + +// 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, customAnchorLeaseMarkerMissing, nil + } + + var marker *psbt.Unknown + for _, unknown := range packet.Unknowns { + if !bytes.Equal(unknown.Key, customAnchorPsbtMarker) { + continue + } + if marker != nil { + return nil, customAnchorLeaseMarkerCurrent, + fmt.Errorf("duplicate custom anchor lease marker") + } + marker = unknown + } + + 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") + } + + 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{}{} + } + + 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 +} + +// 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 ops +} + +// 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, + 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") + } + 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{}{} + } + + 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( + "unable to renew custom anchor input lease %v", op, + ), releaseErr) + } + + 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( + ctx, wallet, leaseID, 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 := rollbackCustomAnchorOutpoints( + ctx, wallet, leaseID, 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 +} + +// 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 := customAnchorCleanupContext(ctx) + defer cancel() + + return releaseCustomAnchorOutpoints( + cleanupCtx, wallet, leaseID, ops, + ) +} + +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 { + 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, 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 { + 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, + )) + } + } + + return releaseErr +} + +func releaseCustomAnchorLeases(ctx context.Context, + wallet tapnode.WalletAnchor, leaseID tapnode.CustomAnchorLeaseID, + funded *FundedMintAnchorPsbt) error { + + if funded == nil { + return nil + } + if err := releaseCustomAnchorOutpoints( + ctx, wallet, leaseID, funded.LockedUTXOs, + ); err != nil { + return err + } + + funded.LockedUTXOs = nil + SetCustomAnchorLockedUTXOs(funded.Pkt, nil) + return nil +} + +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 + } + if _, ok := wallet.(tapnode.CustomAnchorLeaser); !ok { + return fmt.Errorf("wallet does not support custom anchor leases") + } + + markerOps, markerState, err := parseCustomAnchorLockedUTXOs(funded.Pkt) + 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, + ) + if err != nil { + return fmt.Errorf("unable to upgrade legacy custom anchor "+ + "leases: %w", err) + } + + if updateLegacyMarker { + funded.LockedUTXOs = locked + SetCustomAnchorLockedUTXOs(funded.Pkt, locked) + } + + return nil + } else if markerState == customAnchorLeaseMarkerCurrent { + lockedUTXOs = markerOps + } + 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 +} + +// 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 + } + + for _, unknown := range packet.Unknowns { + if bytes.Equal(unknown.Key, customAnchorPsbtMarker) { + return true + } + } + + 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 { + 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 upgrade cannot expose + // cancellation or release its leases. + return customAnchorPublishPending + + case customAnchorPublishPending, + customAnchorImportPending: + + return state + } + } + + // 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 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 { + if batch == nil || batch.GenesisPacket == nil || + !isCustomAnchorPsbt(batch.GenesisPacket.Pkt) { + + return false + } + + // Both states contain a durably stored, fully signed transaction that + // the external signer can independently relay. Until confirmation (or a + // future definitive invalidation mechanism), cancelling either state + // could release its leases while the transaction is still mineable. + switch getCustomAnchorPublishState(batch.GenesisPacket.Pkt) { + case customAnchorImportPending, customAnchorPublishPending: + return true + + default: + return false + } } // PendingGroupWitness specifies the asset group witness for an asset seedling @@ -395,6 +1011,11 @@ type ChainPlanter struct { // progress the batch through the final phases. cultivators map[BatchKey]*Cultivator + // 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 + // waiters tracks the cultivators waiting on anchoring outcomes, // nudged by the re-org watcher's delivery listener. waiters *tapreorg.DeliveryWaiters @@ -404,6 +1025,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. Each request is a closure that runs // inside the gardener loop with full access to ChainPlanter state. @@ -421,22 +1047,76 @@ type ChainPlanter struct { *fn.ContextGuard } +// startupCaretakerResult is the first publication result from a custom batch +// cultivator that was resumed during startup. +type startupCaretakerResult struct { + batchKey BatchKey + cultivator *Cultivator + 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 + } + + cultivator, ok := c.cultivators[key] + if !ok { + continue + } + + leaseErr, publishErr := cultivator.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, - cultivators: make(map[BatchKey]*Cultivator), - waiters: tapreorg.NewDeliveryWaiters(), + cfg: cfg, + cultivators: make(map[BatchKey]*Cultivator), + customAnchorKeyErrors: make(map[BatchKey]string), + waiters: tapreorg.NewDeliveryWaiters(), // Buffer size 1 is a fast path only: it lets a single // cultivator that finishes while the gardener is inside a // stateReq closure hand off its signal inline. Exit never // depends on buffer space; a full buffer diverts the send // to a goroutine (see SignalCompletion in // newCultivatorForBatch). - completionSignals: make(chan BatchKey, 1), - seedlingReqs: make(chan *Seedling), - stateReqs: make(chan stateReq), - subscribers: make(map[uint64]*fn.EventReceiver[fn.Event]), + completionSignals: make(chan BatchKey, 1), + startupCaretakerResults: make(chan startupCaretakerResult, 1), + seedlingReqs: make(chan *Seedling), + stateReqs: make(chan stateReq), + subscribers: make( + map[uint64]*fn.EventReceiver[fn.Event], + ), ContextGuard: &fn.ContextGuard{ DefaultTimeout: DefaultTimeout, Quit: make(chan struct{}), @@ -510,6 +1190,8 @@ func (c *ChainPlanter) newCultivatorForBatch(batch *MintingBatch, CancelReqChan: make(chan cancelReq, 1), PublishMintEvent: c.publishSubscriberEvent, ErrChan: c.cfg.ErrChan, + CustomAnchorLeaseRenewalInterval: c.cfg. + CustomAnchorLeaseRenewalInterval, AnchoringWaiters: c.waiters, } if feeRate != nil { @@ -529,17 +1211,79 @@ func (c *ChainPlanter) Start() error { c.startOnce.Do(func() { log.Infof("Starting ChainPlanter") - // First, we'll read out any minting batches that aren't yet - // fully finalized (minting transaction well confirmed on - // chain). This includes batches that were still pending before - // our last restart, so were never frozen in the first place. - // The cultivator will handle progressing the batch to the - // frozen state, and beyond. - // - // TODO(roasbeef): instead do RBF here? so only a single - // pending batch at a time? but would end up changing assetIDs. - ctx, cancel := c.WithCtxQuit() - defer cancel() + // First, we'll read out any minting batches that aren't yet + // fully finalized (minting transaction well confirmed on + // chain). This includes batches that were still pending before + // our last restart, so were never frozen in the first place. + // The cultivator will handle progressing the batch to the + // frozen state, and beyond. + // + // TODO(roasbeef): instead do RBF here? so only a single + // 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.BatchStore.(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.BatchStore.FetchNonFinalBatches( ctx, ) @@ -577,13 +1321,110 @@ 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 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 && + isCustomAnchorPsbt(batch.GenesisPacket.Pkt) + publishState := customAnchorPublishNone + if customBatch { + publishState = getCustomAnchorPublishState( + batch.GenesisPacket.Pkt, + ) + } + awaitingExternalSigner := customBatch && + (batchState == BatchStatePending || + batchState == BatchStateFrozen || + (batchState == BatchStateCommitted && + publishState != + customAnchorPublishPending && + publishState != + customAnchorImportPending)) + leaseRenewalFailed := false + if customBatch && (batchState == BatchStatePending || + batchState == BatchStateFrozen || + batchState == BatchStateCommitted) { + + renewErr := renewCustomAnchorLeases( + ctx, c.cfg.Wallet, + customAnchorLeaseID(batch.BatchKey.PubKey), + batch.GenesisPacket, + ) + if renewErr != nil { + batch.CustomAnchorLeaseError = fmt.Sprintf( + "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", renewErr) + leaseRenewalFailed = true + } else { + batch.CustomAnchorLeaseError = "" + } + } + + // A batch without durably retained signed bytes cannot advance + // after a failed lease renewal. Once import-pending or + // publish-pending is stored, the external signer can relay the + // transaction independently, so recovery must resume and install + // a confirmation watcher even while active publication is + // suppressed. + if leaseRenewalFailed && + !customAnchorPublicationPending(batch) { + + awaitingExternalSigner = true + } + if awaitingExternalSigner { + if c.pendingBatch != nil { + startErr = fmt.Errorf("multiple custom batches " + + "awaiting external signatures") + return + } + c.pendingBatch = batch + continue + } + + // A custom batch with a signed transaction awaiting import or + // 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) + startupAdmissionReservation := startupCustomRecovery || + (customBatch && batchState == BatchStateBroadcast && + publishState == customAnchorPublishPending) + if startupAdmissionReservation { + if c.pendingBatch != nil { + startErr = fmt.Errorf("multiple custom batches " + + "require an admission reservation") + return + } + + // The cultivator mutates its batch while crossing import and + // publication boundaries. Reserve an independent copy so API + // reads and the gardener never race those mutations. + batchCopy, err := batch.Copy() + if err != nil { + startErr = fmt.Errorf("unable to copy custom batch for "+ + "startup reservation: %w", err) + return + } + c.pendingBatch = batchCopy + } + // 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 @@ -717,22 +1558,31 @@ func (c *ChainPlanter) Start() error { } } if err := cultivator.Start(); err != nil { + delete(c.cultivators, asset.ToSerialized( + batch.BatchKey.PubKey, + )) startErr = err return } - // The cultivator advances the batch in the background, - // and nothing else reads its broadcast channels on - // this path: BroadcastErrChan is otherwise only - // consumed by the interactive finalize handler. Watch - // for a pre-broadcast failure so a failed resume - // cannot leave the batch occupying the singleton slot - // enforced by the migration 000061 index, which would - // block all further minting. A batch resumed at - // BatchStateConfirmed skips the broadcast phase - // entirely and reports through the completion signal, - // so there is nothing to watch. - if batch.State() != BatchStateConfirmed { + if startupAdmissionReservation { + batchKey := asset.ToSerialized(batch.BatchKey.PubKey) + c.Wg.Add(1) + go c.forwardStartupCultivatorResult( + batchKey, cultivator, + ) + } else if batch.State() != BatchStateConfirmed { + // The cultivator advances the batch in the background, + // and nothing else reads its broadcast channels on + // this path: BroadcastErrChan is otherwise only + // consumed by the interactive finalize handler. Watch + // for a pre-broadcast failure so a failed resume + // cannot leave the batch occupying the singleton slot + // enforced by the migration 000061 index, which would + // block all further minting. A batch resumed at + // BatchStateConfirmed skips the broadcast phase + // entirely and reports through the completion signal, + // so there is nothing to watch. c.Wg.Add(1) go c.watchResumedCultivator(cultivator) } @@ -748,6 +1598,33 @@ func (c *ChainPlanter) Start() error { return startErr } +// forwardStartupCultivatorResult forwards exactly one publication result from +// a custom cultivator resumed during startup. It never mutates planter state; +// the gardener processes the result serially with all API requests. +func (c *ChainPlanter) forwardStartupCultivatorResult(batchKey BatchKey, + cultivator *Cultivator) { + + defer c.Wg.Done() + + var result startupCaretakerResult + result.batchKey = batchKey + result.cultivator = cultivator + + select { + case <-cultivator.cfg.BroadcastCompleteChan: + + case result.err = <-cultivator.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 @@ -1078,6 +1955,333 @@ func fundGenesisPsbt(ctx context.Context, _ 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(ctx context.Context, + chainParams address.ChainParams, + pendingBatch *MintingBatch, packet *psbt.Packet, + assetAnchorOutIdx uint32, + changeOutputIndex int32, + preCommitOutputIndex fn.Option[uint32], + augmenter GenesisTxAugmenter) (FundedMintAnchorPsbt, error) { + + var zero FundedMintAnchorPsbt + + if packet == nil || packet.UnsignedTx == nil { + return zero, fmt.Errorf("custom anchor PSBT is missing its " + + "unsigned transaction") + } + // 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") + } + 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 uint64(assetAnchorOutIdx) >= uint64(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) + } + _, err = customAnchorKeyDesc( + chainParams, packet, assetAnchorOutIdx, + ) + if 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") + } + if augmenter == nil { + augmenter = NoOpAugmenter{} + } + + // A caller-authored packet already contains any output contributed by + // the augmenter. Verify that the explicit RPC index selects the one + // deterministic output the augmenter expects. This prevents PostFund + // and BindData from silently selecting a different duplicate output. + extraOutputs, err := augmenter.ExtraOutputs(ctx, pendingBatch) + if err != nil { + return zero, fmt.Errorf("augmenter ExtraOutputs: %w", err) + } + if len(extraOutputs) > 1 { + return zero, fmt.Errorf("augmenter returned %d extra outputs, only "+ + "zero or one is supported", len(extraOutputs)) + } + + var expectedExtraIdx fn.Option[uint32] + if len(extraOutputs) == 0 { + if preCommitOutputIndex.IsSome() { + return zero, fmt.Errorf("pre-commitment output index " + + "specified for batch without augmenter output") + } + } else { + idx, err := preCommitOutputIndex.UnwrapOrErr(fmt.Errorf( + "custom augmented batch requires a pre-commitment " + + "output index", + )) + if err != nil { + return zero, err + } + if uint64(idx) >= uint64(len(packet.UnsignedTx.TxOut)) || + idx == assetAnchorOutIdx || + (changeOutputIndex >= 0 && idx == uint32(changeOutputIndex)) { + + return zero, fmt.Errorf("invalid pre-commitment output "+ + "index %d", idx) + } + + expected := extraOutputs[0] + matches := 0 + for outputIdx, output := range packet.UnsignedTx.TxOut { + if output.Value != expected.Value || + !bytes.Equal(output.PkScript, expected.PkScript) { + + continue + } + matches++ + if uint32(outputIdx) != idx { + continue + } + expectedExtraIdx = fn.Some(idx) + } + if matches != 1 || expectedExtraIdx.IsNone() { + return zero, fmt.Errorf("pre-commitment output %d must be "+ + "the unique output matching the augmenter", idx) + } + } + + // 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 { + err = validateExclusionProofOutputs( + packet, assetAnchorOutIdx, + ) + if 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, + } + markCustomAnchorPsbt(packet) + if err := augmenter.PostFund(ctx, pendingBatch, &funded); err != nil { + return zero, fmt.Errorf("augmenter PostFund: %w", err) + } + err = validateExclusionProofOutputs( + packet, assetAnchorOutIdx, + ) + if err != nil { + return zero, err + } + indexes := AnchorTxOutputIndexes{ + AssetAnchorOutIdx: assetAnchorOutIdx, + ChangeOutIdx: 0, + } + result, err := NewFundedMintAnchorPsbt(funded, indexes) + if err != nil { + return zero, err + } + + // BindData is the persistence source of truth after the upstream + // 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, err := pendingBatch.Copy() + if err != nil { + return zero, fmt.Errorf("copy pending batch: %w", err) + } + stagedBatch.GenesisPacket = &result + bindData, err := augmenter.BindData(ctx, stagedBatch) + if err != nil { + return zero, fmt.Errorf("augmenter BindData: %w", err) + } + if expectedExtraIdx.IsNone() && bindData.IsSome() { + return zero, fmt.Errorf("augmenter bound unexpected output") + } + if expectedExtraIdx.IsSome() { + declaredIdx, err := expectedExtraIdx.UnwrapOrErr(fmt.Errorf( + "declared augmenter output index missing", + )) + if err != nil { + return zero, err + } + bound, err := bindData.UnwrapOrErr(fmt.Errorf( + "augmenter did not bind the declared output", + )) + if err != nil { + return zero, err + } + if bound.OutputIndex != declaredIdx { + return zero, fmt.Errorf("augmenter bound output %d, "+ + "caller declared %d", bound.OutputIndex, + declaredIdx) + } + } + } + + return result, nil +} + +// 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. +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( @@ -1559,8 +2763,12 @@ func fetchFinalizedBatch(ctx context.Context, refs MintingRefReader, } } + 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 { @@ -1911,10 +3119,32 @@ func (c *ChainPlanter) cancelMintingBatch(ctx context.Context, // If the target batch was not assigned a cultivator, the only // non-cancelled batch in play is c.pendingBatch (canCancelBatch - // guarantees this). Update the batch state on disk and in memory in - // a single atomic call. + // guarantees this). Determine the correct terminal state, then update + // both the disk row and the in-memory batch in a single atomic call. + var cancelState BatchState + switch c.pendingBatch.State() { + case BatchStatePending, BatchStateFrozen: + cancelState = BatchStateSeedlingCancelled + + case BatchStateCommitted: + if customAnchorPublicationPending(c.pendingBatch) { + return fmt.Errorf("custom anchor publication status is " + + "ambiguous and is not cancellable") + } + + cancelState = BatchStateSproutCancelled + + case BatchStateBroadcast, BatchStateConfirmed, BatchStateFinalized, + BatchStateSeedlingCancelled, BatchStateSproutCancelled: + fallthrough + + default: + return fmt.Errorf("batch state %v is not cancellable", + c.pendingBatch.State()) + } + err := c.cfg.BatchStore.UpdateBatchState( - ctx, c.pendingBatch, BatchStateSeedlingCancelled, + ctx, c.pendingBatch, cancelState, ) if err != nil { return fmt.Errorf("unable to cancel minting batch: %w", err) @@ -2019,6 +3249,12 @@ func (c *ChainPlanter) watchResumedCultivator(cultivator *Cultivator) { // 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 cultivators as well. @@ -2028,6 +3264,129 @@ func (c *ChainPlanter) gardener() { for { select { + case result := <-c.startupCaretakerResults: + cultivator, ok := c.cultivators[result.batchKey] + if !ok || cultivator != result.cultivator { + // 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 { + // 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 { + + batchSnapshot, err := cultivator.batchCopy() + if err != nil { + log.Errorf("Unable to copy recovered custom batch: %v", err) + continue + } + if customAnchorPublicationPending(batchSnapshot) { + c.pendingBatch = batchSnapshot + } else { + 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 := cultivator.Stop(); err != nil { + log.Warnf("Unable to stop failed startup cultivator: %v", + err) + } + delete(c.cultivators, result.batchKey) + c.pendingBatch = cultivator.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 || + !isCustomAnchorPsbt(batch.GenesisPacket.Pkt) { + + 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.cultivators[batchKey]; ok { + continue + } + switch batch.State() { + case BatchStatePending, BatchStateFrozen, + BatchStateCommitted: + + ctx, cancel := c.WithCtxQuit() + err := renewCustomAnchorLeases( + ctx, c.cfg.Wallet, + customAnchorLeaseID(batch.BatchKey.PubKey), + batch.GenesisPacket, + ) + 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() { + + leaseErrText := statusErr.Error() + batch.CustomAnchorLeaseError = leaseErrText + event, eventErr := newAssetMintErrorEvent( + statusErr, batch.State(), batch, + ) + if eventErr != nil { + log.Errorf("Unable to snapshot custom anchor "+ + "lease error event: %v", eventErr) + } else { + c.publishSubscriberEvent(event) + } + } + log.Warnf("Unable to renew custom anchor input "+ + "leases: %v", err) + continue + } + + if batch.CustomAnchorLeaseError != "" { + batch.CustomAnchorLeaseError = "" + event, eventErr := newAssetMintEvent( + batch.State(), batch, + ) + if eventErr != nil { + log.Errorf("Unable to snapshot custom anchor "+ + "lease recovery event: %v", eventErr) + } else { + c.publishSubscriberEvent(event) + } + } + + case BatchStateBroadcast, BatchStateConfirmed, + BatchStateFinalized, BatchStateSeedlingCancelled, + BatchStateSproutCancelled: + } + // A request for new asset issuance just arrived, add this to // the pending batch and acknowledge the receipt back to the // caller. @@ -2100,6 +3459,17 @@ func (c *ChainPlanter) gardener() { delete(c.cultivators, 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 or mutate our @@ -2183,9 +3553,20 @@ func releaseBatchFundingInputs(ctx context.Context, return } - err := releaseFundingInputs( - ctx, wallet, &batch.GenesisPacket.FundedPsbt, - ) + // Custom anchors can contain foreign inputs and use batch-scoped + // leases. Never send those inputs through the wallet-funded fallback + // that unlocks every transaction input when LockedUTXOs is empty. + var err error + if isCustomAnchorPsbt(batch.GenesisPacket.Pkt) { + err = releaseCustomAnchorLeases( + ctx, wallet, customAnchorLeaseID(batch.BatchKey.PubKey), + batch.GenesisPacket, + ) + } else { + err = releaseFundingInputs( + ctx, wallet, &batch.GenesisPacket.FundedPsbt, + ) + } if err != nil { batchKeySerial := asset.ToSerialized(batch.BatchKey.PubKey) log.Warnf("Unable to release funding inputs of cancelled "+ @@ -2211,6 +3592,27 @@ func (c *ChainPlanter) fundingError(funded *tapsend.FundedPsbt, return errors.Join(cause, unlockErr) } +// batchFundingError releases the leases held by a computed mint anchor using +// the mechanism that acquired them. Caller-authored anchors use batch-scoped +// custom leases, while wallet-funded anchors use the wallet's ordinary input +// locks. +func (c *ChainPlanter) batchFundingError(ctx context.Context, + batchKey *btcec.PublicKey, funded *FundedMintAnchorPsbt, + cause error) error { + + if funded == nil { + return cause + } + if isCustomAnchorPsbt(funded.Pkt) { + releaseErr := rollbackCustomAnchorLeases( + ctx, c.cfg.Wallet, customAnchorLeaseID(batchKey), funded, + ) + return errors.Join(cause, releaseErr) + } + + return c.fundingError(&funded.FundedPsbt, cause) +} + // prepareFunding stores the optional tapscript sibling and constructs // the funding-computation closure shared by createFundedBatch and // applyFundingToBatch. @@ -2234,8 +3636,45 @@ func (c *ChainPlanter) prepareFunding(ctx context.Context, "for minting batch: %w", err) } - computeFunding := func(batch *MintingBatch) ( - *FundedMintAnchorPsbt, error) { + 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( + ctx, c.cfg.ChainParams, batch, params.AnchorPsbt, + params.AssetAnchorOutIdx, + params.ChangeOutputIndex, + params.PreCommitOutputIndex, c.augmenter(), + ) + if err != nil { + return nil, err + } + + locked, err := acquireCustomAnchorLeases( + ctx, c.cfg.Wallet, + customAnchorLeaseID(batch.BatchKey.PubKey), funded.Pkt, + nil, + ) + if err != nil { + return nil, err + } + funded.LockedUTXOs = locked + SetCustomAnchorLockedUTXOs(funded.Pkt, locked) + + return &funded, nil + } feeRate, err := c.anchorTxFeeRate(ctx, params.FeeRate) if err != nil { @@ -2333,11 +3772,15 @@ func (c *ChainPlanter) createFundedBatch(ctx context.Context, preCommit, err := c.augmenter().BindData(ctx, newBatch) if err != nil { bindErr := fmt.Errorf("augmenter BindData: %w", err) - return nil, c.fundingError(&mintAnchorTx.FundedPsbt, bindErr) + return nil, c.batchFundingError( + ctx, newBatch.BatchKey.PubKey, mintAnchorTx, bindErr, + ) } err = c.cfg.BatchStore.CommitMintingBatch(ctx, newBatch, preCommit) if err != nil { - return nil, c.fundingError(&mintAnchorTx.FundedPsbt, err) + return nil, c.batchFundingError( + ctx, newBatch.BatchKey.PubKey, mintAnchorTx, err, + ) } return newBatch, nil @@ -2379,13 +3822,17 @@ func (c *ChainPlanter) applyFundingToBatch(ctx context.Context, stagingBatch, err := batch.Copy() if err != nil { copyErr := fmt.Errorf("unable to copy batch: %w", err) - return c.fundingError(&mintAnchorTx.FundedPsbt, copyErr) + return c.batchFundingError( + ctx, batch.BatchKey.PubKey, mintAnchorTx, copyErr, + ) } stagingBatch.GenesisPacket = mintAnchorTx preCommit, err := c.augmenter().BindData(ctx, stagingBatch) if err != nil { bindErr := fmt.Errorf("augmenter BindData: %w", err) - return c.fundingError(&mintAnchorTx.FundedPsbt, bindErr) + return c.batchFundingError( + ctx, batch.BatchKey.PubKey, mintAnchorTx, bindErr, + ) } // Persist the sibling, genesis TX, and (when present) the @@ -2399,7 +3846,9 @@ func (c *ChainPlanter) applyFundingToBatch(ctx context.Context, if err != nil { commitErr := fmt.Errorf("unable to commit batch "+ "funding: %w", err) - return c.fundingError(&mintAnchorTx.FundedPsbt, commitErr) + return c.batchFundingError( + ctx, batch.BatchKey.PubKey, mintAnchorTx, commitErr, + ) } // All persistence succeeded; mirror the funding into memory. @@ -2748,6 +4197,332 @@ 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()) + } + + // 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 + } + if sealedBatch != nil { + batch = sealedBatch + } + + if batch.State() == BatchStatePending { + if err := freezeMintingBatch(ctx, c.cfg.BatchStore, batch); err != nil { + return nil, err + } + } + + cultivator := c.newCultivatorForBatch(batch, nil) + nextState, err := cultivator.stateStep(BatchStateFrozen) + delete(c.cultivators, asset.ToSerialized(batch.BatchKey.PubKey)) + if err != nil { + return nil, err + } + if nextState != BatchStateCommitted { + return nil, fmt.Errorf("unexpected prepared batch state: %v", + nextState) + } + + 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 + } + // 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. + stripTapdCustomAnchorMarkers(storedNorm) + stripTapdCustomAnchorMarkers(signedNorm) + 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 +// cultivator 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 + } + + 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 +} + +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. +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 cultivator for the batch and starts it. func (c *ChainPlanter) finalizeBatch(params FinalizeParams) (*Cultivator, error) { @@ -2761,6 +4536,166 @@ func (c *ChainPlanter) finalizeBatch(params FinalizeParams) (*Cultivator, // 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") + } + 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 or broadcast custom batch " + + "available for external finalization") + } + 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) + } + 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", persistedErr) + } + + 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()) { + if state == BatchStateBroadcast { + return nil, fmt.Errorf("broadcast retry changes " + + "finalized transaction") + } + + return nil, fmt.Errorf("committed retry changes " + + "finalized transaction") + } + + // 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 + } + 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) + if err != nil { + cancel() + return nil, fmt.Errorf("externally signed PSBT has an invalid "+ + "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, + ) + locked, err := acquireCustomAnchorLeases( + ctx, c.cfg.Wallet, + customAnchorLeaseID(c.pendingBatch.BatchKey.PubKey), + merged, previousLocks, + ) + cancel() + if err != nil { + 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( + merged, customAnchorImportPending, + ) + + // Store the signed packet before starting the caretaker. If + // 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 + ctx, cancel = c.WithCtxQuit() + err = storeSignedGenesisPsbt( + ctx, c.cfg.BatchStore, + c.pendingBatch.BatchKey.PubKey, + &signedFundedPsbt, + ) + cancel() + if err != nil { + releaseErr := rollbackCustomAnchorOutpoints( + ctx, c.cfg.Wallet, + customAnchorLeaseID( + c.pendingBatch.BatchKey.PubKey, + ), newLocks, + ) + return nil, fmt.Errorf("unable to store externally signed "+ + "PSBT: %w", errors.Join(err, releaseErr)) + } + + c.pendingBatch.GenesisPacket.Pkt = merged + c.pendingBatch.GenesisPacket.LockedUTXOs = locked + } + + cultivator := c.newCultivatorForBatch(c.pendingBatch, nil) + if err := cultivator.Start(); err != nil { + return nil, fmt.Errorf("unable to start new cultivator: %w", + err) + } + + return cultivator, 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") @@ -2790,7 +4725,10 @@ func (c *ChainPlanter) finalizeBatch(params FinalizeParams) (*Cultivator, // dispatch in one place rather than re-checking pending-ness // here. if !c.pendingBatch.IsFunded() { - err = c.fundPendingBatch(ctx, FundParams(params)) + err = c.fundPendingBatch(ctx, FundParams{ + FeeRate: params.FeeRate, + SiblingTapTree: params.SiblingTapTree, + }) if err != nil { return nil, err } @@ -2904,6 +4842,8 @@ func (c *ChainPlanter) ListBatches(params ListBatchesParams) ([]*VerboseBatch, out <- stateErr[[]*VerboseBatch](err) return } + c.attachCustomAnchorKeyErrors(batches) + c.attachCustomAnchorRuntimeStatus(batches) out <- stateOk(batches) }, ) @@ -2914,6 +4854,15 @@ func (c *ChainPlanter) ListBatches(params ListBatchesParams) ([]*VerboseBatch, func (c *ChainPlanter) FundBatch(params FundParams) (*VerboseBatch, error) { return dispatchStateReq( c, func(out chan<- stateResult[*VerboseBatch]) { + if c.pendingBatch != nil && + c.pendingBatch.State() != BatchStatePending { + + out <- stateErr[*VerboseBatch](fmt.Errorf( + "batch in state %v cannot be funded", + c.pendingBatch.State(), + )) + return + } if c.pendingBatch != nil && c.pendingBatch.IsFunded() { @@ -2947,6 +4896,38 @@ func (c *ChainPlanter) FundBatch(params FundParams) (*VerboseBatch, error) { ) } +// PrepareBatch commits a caller-authored batch into its selected anchor +// output and returns the packet for external signing. +func (c *ChainPlanter) PrepareBatch() (*MintingBatch, error) { + return dispatchStateReq( + c, func(out chan<- stateResult[*MintingBatch]) { + if c.pendingBatch == nil { + out <- stateErr[*MintingBatch](fmt.Errorf( + "no pending batch", + )) + return + } + ctx, cancel := c.WithCtxQuit() + preparedBatch, err := c.prepareBatch(ctx, c.pendingBatch) + cancel() + if err != nil { + out <- stateErr[*MintingBatch](fmt.Errorf( + "unable to prepare minting batch: %w", err, + )) + return + } + + c.pendingBatch = preparedBatch + batchCopy, err := preparedBatch.Copy() + if err != nil { + out <- stateErr[*MintingBatch](err) + return + } + out <- stateOk(batchCopy) + }, + ) +} + // 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) { @@ -2958,6 +4939,15 @@ func (c *ChainPlanter) SealBatch(params SealParams) (*MintingBatch, error) { )) return } + if c.pendingBatch.State() != BatchStatePending && + c.pendingBatch.State() != BatchStateFrozen { + + out <- stateErr[*MintingBatch](fmt.Errorf( + "batch in state %v cannot be sealed", + c.pendingBatch.State(), + )) + return + } ctx, cancel := c.WithCtxQuit() sealedBatch, err := c.sealBatch( @@ -3008,6 +4998,14 @@ func (c *ChainPlanter) FinalizeBatch(params FinalizeParams) (*MintingBatch, batchKey := c.pendingBatch.BatchKey.PubKey batchKeySerial := asset.ToSerialized(batchKey) + customBatch := c.pendingBatch.GenesisPacket != nil && + isCustomAnchorPsbt(c.pendingBatch.GenesisPacket.Pkt) + if _, ok := c.cultivators[batchKeySerial]; ok { + out <- stateErr[*MintingBatch](fmt.Errorf( + "batch recovery is in progress", + )) + return + } log.Infof("Finalizing batch %x", batchKeySerial) cultivator, err := c.finalizeBatch(params) @@ -3028,17 +5026,20 @@ func (c *ChainPlanter) FinalizeBatch(params FinalizeParams) (*MintingBatch, // caller does can race the cultivator // goroutine that shares the underlying // batch. - batchCopy, err := cultivator.cfg.Batch.Copy() - - // The batch has been broadcast, so we can - // remove the pending batch regardless of - // whether the snapshot above succeeded. - c.pendingBatch = nil - + batchCopy, err := cultivator.batchCopy() if err != nil { + // Snapshot failure cannot prove a signed custom + // transaction is safe to abandon. Keep the existing + // reservation and fail closed. out <- stateErr[*MintingBatch](err) return } + + if customAnchorPublicationPending(batchCopy) { + c.pendingBatch = batchCopy + } else { + c.pendingBatch = nil + } out <- stateOk(batchCopy) case err := <-cultivator.cfg.BroadcastErrChan: @@ -3073,7 +5074,9 @@ func (c *ChainPlanter) FinalizeBatch(params FinalizeParams) (*MintingBatch, return } - c.pendingBatch = nil + if !customBatch { + c.pendingBatch = nil + } case <-c.Quit: return @@ -3086,6 +5089,15 @@ func (c *ChainPlanter) FinalizeBatch(params FinalizeParams) (*MintingBatch, func (c *ChainPlanter) CancelBatch() (*btcec.PublicKey, error) { return dispatchStateReq( c, func(out chan<- stateResult[*btcec.PublicKey]) { + if c.pendingBatch != nil { + key := asset.ToSerialized(c.pendingBatch.BatchKey.PubKey) + if _, ok := c.cultivators[key]; ok { + out <- stateErr[*btcec.PublicKey](fmt.Errorf( + "batch recovery is in progress", + )) + return + } + } batchKey, err := c.canCancelBatch() if err != nil { out <- stateErr[*btcec.PublicKey](err) @@ -3121,6 +5133,15 @@ func (c *ChainPlanter) CancelBatch() (*btcec.PublicKey, error) { // 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()) + } // Refuse a new batch before the augmenter or planter derives any keys. // This avoids burning key indices when a resumed or orphaned diff --git a/tapgarden/planter_test.go b/tapgarden/planter_test.go index 852333d51d..b952e52167 100644 --- a/tapgarden/planter_test.go +++ b/tapgarden/planter_test.go @@ -1,3 +1,4 @@ +//nolint:lll package tapgarden_test import ( @@ -96,7 +97,7 @@ type mintingTestHarness struct { chain *tapnodemock.ChainBridge - store *tapdb.AssetMintingStore + store testMintingStore treeStore *tapgarden.FallibleTapscriptTreeMgr @@ -133,12 +134,20 @@ type mintingTestHarness struct { testing.TB errChan chan error + + leaseRenewalInterval time.Duration +} + +type testMintingStore interface { + tapgarden.BatchStore + tapgarden.MintingRefReader + asset.TapscriptTreeManager } // newMintingTestHarness creates a new test harness from an active minting // store and an existing testing context. func newMintingTestHarness(t testing.TB, - store *tapdb.AssetMintingStore) *mintingTestHarness { + store testMintingStore) *mintingTestHarness { keyRing := tapnodemock.NewKeyRing() genSigner := tapgarden.NewMockGenSigner(keyRing) @@ -189,9 +198,10 @@ func (t *mintingTestHarness) refreshChainPlanter() { AnchoringWatcher: t.registrar, GenesisTxAugmenter: t.augmenter, }, - 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()) } @@ -517,14 +527,12 @@ func (t *mintingTestHarness) progressCaretaker(isFunded bool, // sign this PSBT packet generated above. t.assertGenesisPsbtFinalized(batchSibling) - // With the PSBT packet finalized for the caretaker, we should now - // receive a request to publish a transaction followed by a - // confirmation request. + // Registration completes before the unbuffered publish, so accepting + // the publish proves the anchoring was staked first. tx := t.assertTxPublished() - // With the transaction published, we should now receive a confirmation - // request. To ensure the file proof is constructed properly, we'll - // also make a "fake" block that includes our transaction. + // Build a block that includes the genesis transaction so the + // confirmation closure can witness the anchoring. merkleTree := blockchain.BuildMerkleTreeStore( []*btcutil.Tx{btcutil.NewTx(tx)}, false, ) @@ -1198,6 +1206,11 @@ func (t *mintingTestHarness) assertAnchoringRegistered(tx *wire.MsgTx) { require.Eventually(t, func() bool { return len(t.mintAnchorings(tx)) > 0 }, defaultTimeout, 10*time.Millisecond) + + // The re-org watcher replaced the cultivator's confirmation + // subscription. A non-zero request count means the legacy + // RegisterConfirmationsNtfn path ran. + require.Zero(t, t.chain.ReqCount.Load()) } // confirmAnchoring flips the anchoring's delivered phase to witnessed @@ -2040,6 +2053,12 @@ func testFundFailureReleasesWalletLeases(t *mintingTestHarness) { ) require.NoError(t, err) require.Equal(t, funded.LockedUTXOs[0], *unlocked) + select { + case released := <-t.wallet.ReleaseInputSignal: + t.Fatalf("wallet-funded input used custom release path: %v", + released) + default: + } } // testCancelFundedBatchReleasesLeases verifies that cancelling a funded diff --git a/tapnode/chain_bridge.go b/tapnode/chain_bridge.go index c58d8eee32..b02f7b536c 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 +// 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 +} + +// 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. @@ -68,6 +103,27 @@ type ChainBridge interface { 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 035364d6a3..083d03c136 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,14 @@ 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 + // 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 cultivator goroutines) hold it for read; all writers @@ -44,6 +53,9 @@ type ChainBridge struct { Blocks map[chainhash.Hash]*wire.MsgBlock failFeeEstimates atomic.Bool + failPublish atomic.Bool + failPublishFinal atomic.Bool + failConfRegister atomic.Bool errConf atomic.Int32 emptyConf atomic.Int32 confErr chan error @@ -54,7 +66,10 @@ 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), + ConfTxIDs: make(map[int]*chainhash.Hash), ConfReqSignal: make(chan int), BlockEpochSignal: make(chan struct{}, 1), NewBlocks: make(chan int32), @@ -67,6 +82,22 @@ 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) +} + +// 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) +} + // 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 +140,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, + txid *chainhash.Hash, pkScript []byte, _, _ uint32, _ bool, _ chan struct{}) (*chainntnfs.ConfirmationEvent, chan error, error) { select { @@ -117,6 +148,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 +164,11 @@ 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): @@ -234,10 +273,36 @@ 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 } +// 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, @@ -291,6 +356,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. diff --git a/tapnode/tapnodemock/wallet_anchor.go b/tapnode/tapnodemock/wallet_anchor.go index ea7e7c2941..5fa6c1a397 100644 --- a/tapnode/tapnodemock/wallet_anchor.go +++ b/tapnode/tapnodemock/wallet_anchor.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "math/rand/v2" + "sync" "sync/atomic" "testing" @@ -28,10 +29,14 @@ import ( // WalletAnchor is an in-memory mock implementation of tapnode.WalletAnchor. type WalletAnchor struct { + mu sync.RWMutex + FundPsbtSignal chan *tapsend.FundedPsbt UnlockInputSignal chan wire.OutPoint 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 @@ -39,10 +44,56 @@ 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 failSignPsbt atomic.Bool } +// 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 +} + // FailSignPsbtOnce arms the next call to SignAndFinalizePsbt to return // an error. The failing call does not send on SignPsbtSignal. func (m *WalletAnchor) FailSignPsbtOnce() { @@ -56,10 +107,15 @@ func NewWalletAnchor() *WalletAnchor { UnlockInputSignal: make(chan wire.OutPoint, 10), 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), } } @@ -194,12 +250,57 @@ func (m *WalletAnchor) ImportTaprootOutput(ctx context.Context, case <-ctx.Done(): return nil, fmt.Errorf("shutting down") } + m.mu.RLock() + importErr := m.ImportErr + m.mu.RUnlock() + if importErr != nil { + return nil, 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(ctx context.Context, + _ tapnode.CustomAnchorLeaseID, op wire.OutPoint) (bool, error) { + + m.mu.RLock() + leaseErr := m.LeaseErrors[op] + owned := m.OwnedInputs[op] + m.mu.RUnlock() + if leaseErr != nil { + return false, leaseErr + } + if !owned { + return false, 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(ctx context.Context, + _ tapnode.CustomAnchorLeaseID, op wire.OutPoint) error { + + select { + case m.ReleaseInputSignal <- op: + + case <-ctx.Done(): + return ctx.Err() + } + m.mu.RLock() + releaseErr := m.ReleaseErrors[op] + m.mu.RUnlock() + return releaseErr +} + // UnlockInput unlocks the set of target inputs after a batch or send // transaction is abandoned. func (m *WalletAnchor) UnlockInput(_ context.Context, @@ -279,3 +380,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 7842da8c94..15d6d86993 100644 --- a/tapnode/wallet_anchor.go +++ b/tapnode/wallet_anchor.go @@ -58,3 +58,40 @@ 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 +} + +// 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 +} diff --git a/tapreorg/registrar.go b/tapreorg/registrar.go index 50fe2d0073..d06cce52b9 100644 --- a/tapreorg/registrar.go +++ b/tapreorg/registrar.go @@ -92,6 +92,15 @@ type MockRegistrar struct { failNext error anchorings map[AnchoringID]*Anchoring phase1Tx RegistryTx + + // pauseEntered is closed once the next registration has been + // stored and before Register returns. pauseRelease is closed by + // the test to let that registration return. Both are nil unless + // a test armed PauseNextRegister. The gap is the synchronization + // point between "anchoring is visible" and "the caller continues + // into publish". + pauseEntered chan struct{} + pauseRelease chan struct{} } // BestHeight returns the mock's chain height. @@ -170,17 +179,44 @@ func (m *MockRegistrar) RegisterBatch(ctx context.Context, phase1 BatchPhase1Func) ([]AnchoringID, error) { m.mu.Lock() - defer m.mu.Unlock() + ids, entered, release, err := m.registerBatchLocked( + ctx, specs, phase1, + ) + m.mu.Unlock() + if err != nil { + return nil, err + } + + // Wait without the lock so a test can observe the stored + // anchoring and inject a failure before the caller publishes. + if release != nil { + close(entered) + select { + case <-release: + case <-ctx.Done(): + return nil, ctx.Err() + } + } + + return ids, nil +} + +// registerBatchLocked is RegisterBatch while m.mu is held. On success +// it may return the pause channels the caller must wait on after +// releasing the lock. +func (m *MockRegistrar) registerBatchLocked(ctx context.Context, + specs []RegistrationSpec, phase1 BatchPhase1Func) ([]AnchoringID, + chan struct{}, chan struct{}, error) { if len(specs) == 0 { - return nil, ErrEmptyRegistrationBatch + return nil, nil, nil, ErrEmptyRegistrationBatch } if m.failNext != nil { err := m.failNext m.failNext = nil - return nil, err + return nil, nil, nil, err } working := make(map[AnchoringID]*Anchoring, len(m.anchorings)) @@ -207,8 +243,9 @@ func (m *MockRegistrar) RegisterBatch(ctx context.Context, if existing != nil { if spec.Phase1OnAttach && anchoringAbandoned(existing) { - return nil, fmt.Errorf("anchoring %d: %w", - existing.ID, ErrAnchoringAbandoned) + return nil, nil, nil, fmt.Errorf( + "anchoring %d: %w", existing.ID, + ErrAnchoringAbandoned) } ids = append(ids, existing.ID) @@ -243,14 +280,40 @@ func (m *MockRegistrar) RegisterBatch(ctx context.Context, if needsPhase1 && phase1 != nil && m.phase1Tx != nil { if err := phase1(ctx, m.phase1Tx, ids); err != nil { - return nil, err + return nil, nil, nil, err } } m.anchorings = working m.nextID = nextID - return ids, nil + entered := m.pauseEntered + release := m.pauseRelease + m.pauseEntered = nil + m.pauseRelease = nil + + return ids, entered, release, nil +} + +// PauseNextRegister makes the next successful registration block after +// the anchoring is stored and before Register returns. The entered +// channel is closed at that point. Calling release lets Register +// return. release is idempotent. +func (m *MockRegistrar) PauseNextRegister() (<-chan struct{}, func()) { + entered := make(chan struct{}) + releaseCh := make(chan struct{}) + var once sync.Once + + m.mu.Lock() + m.pauseEntered = entered + m.pauseRelease = releaseCh + m.mu.Unlock() + + return entered, func() { + once.Do(func() { + close(releaseCh) + }) + } } // anchoringAbandoned reports whether an anchoring is abandoned in its @@ -285,6 +348,16 @@ func (m *MockRegistrar) FailNextRegister(err error) { m.failNext = err } +// DropAnchorings forgets every recorded anchoring. Tests use it to +// simulate a restart on which the batch is already Broadcast but its +// anchoring was never persisted. +func (m *MockRegistrar) DropAnchorings() { + m.mu.Lock() + defer m.mu.Unlock() + + m.anchorings = make(map[AnchoringID]*Anchoring) +} + // snapshot copies an anchoring deeply enough that a caller holding the // result is unaffected by later mutation through the registrar. // diff --git a/taprpc/mintrpc/mint.pb.go b/taprpc/mintrpc/mint.pb.go index bc5a471d7f..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. @@ -788,9 +818,23 @@ 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. 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"` + // 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 +906,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 +1005,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 +1116,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 +1128,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 +1141,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 +1175,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 +1187,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 +1200,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 +1226,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 +1248,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 +1261,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 +1303,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 +1339,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 +1351,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 +1364,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 +1382,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 +1394,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 +1407,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 +1420,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 +1432,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 +1445,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 +1473,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 +1485,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 +1498,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 +1562,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 +1574,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 +1587,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 +1609,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 +1621,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 +1634,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 +1662,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 +1674,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 +1687,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 { @@ -1588,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" + @@ -1600,29 +1780,46 @@ 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\"\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 +1851,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 +1875,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 +1887,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 +1976,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 +1990,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 b197640378..0f493482f8 100644 --- a/taprpc/mintrpc/mint.pb.gw.go +++ b/taprpc/mintrpc/mint.pb.gw.go @@ -116,6 +116,33 @@ func local_request_Mint_SealBatch_0(ctx context.Context, marshaler runtime.Marsh return msg, metadata, err } +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 + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + 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 + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(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 @@ -322,6 +349,26 @@ func RegisterMintHandlerServer(ctx context.Context, mux *runtime.ServeMux, serve } forward_Mint_SealBatch_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) }) + mux.Handle(http.MethodPost, 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) + 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(http.MethodPost, pattern_Mint_FinalizeBatch_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() @@ -480,6 +527,23 @@ func RegisterMintHandlerClient(ctx context.Context, mux *runtime.ServeMux, clien } forward_Mint_SealBatch_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) }) + mux.Handle(http.MethodPost, 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) + 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(http.MethodPost, pattern_Mint_FinalizeBatch_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() @@ -555,6 +619,7 @@ var ( pattern_Mint_MintAsset_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v1", "taproot-assets", "assets"}, "")) pattern_Mint_FundBatch_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4}, []string{"v1", "taproot-assets", "assets", "mint", "fund"}, "")) 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"}, "")) pattern_Mint_ListBatches_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 1, 0, 4, 1, 5, 5}, []string{"v1", "taproot-assets", "assets", "mint", "batches", "batch_key"}, "")) @@ -565,6 +630,7 @@ var ( forward_Mint_MintAsset_0 = runtime.ForwardResponseMessage forward_Mint_FundBatch_0 = runtime.ForwardResponseMessage forward_Mint_SealBatch_0 = runtime.ForwardResponseMessage + forward_Mint_PrepareBatch_0 = runtime.ForwardResponseMessage forward_Mint_FinalizeBatch_0 = runtime.ForwardResponseMessage forward_Mint_CancelBatch_0 = runtime.ForwardResponseMessage forward_Mint_ListBatches_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..b1634178f0 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. */ @@ -285,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 { @@ -333,6 +352,25 @@ 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. 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. + 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 +378,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 +441,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..2d80ee0053 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. 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", + "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." } } }, @@ -525,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." } } }, @@ -580,6 +654,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 700c3a9ca8..b8b18aef97 100644 --- a/taprpc/perms.go +++ b/taprpc/perms.go @@ -187,6 +187,10 @@ var ( Entity: "mint", Action: "write", }}, + "/mintrpc.Mint/PrepareBatch": {{ + Entity: "mint", + Action: "write", + }}, "/mintrpc.Mint/FinalizeBatch": {{ Entity: "mint", Action: "write", diff --git a/universe/supplycommit/mock.go b/universe/supplycommit/mock.go index 86e908f674..e5a184ae4b 100644 --- a/universe/supplycommit/mock.go +++ b/universe/supplycommit/mock.go @@ -209,6 +209,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) {