diff --git a/docs/release-notes/release-notes-0.8.0.md b/docs/release-notes/release-notes-0.8.0.md index ff7402843..cfa3cf666 100644 --- a/docs/release-notes/release-notes-0.8.0.md +++ b/docs/release-notes/release-notes-0.8.0.md @@ -32,6 +32,11 @@ ## Functional Updates +- [Garbage collection of zero-value UTXOs](https://github.com/lightninglabs/taproot-assets/pull/1832) + by sweeping tombstones and burn outputs when executing onchain transactions. + Garbage collection will be executed on every burn, transfer or call to + `AnchorVirtualPsbts`. + ## RPC Updates - [PR#1841](https://github.com/lightninglabs/taproot-assets/pull/1841): Remove diff --git a/itest/burn_test.go b/itest/burn_test.go index e983451d7..bff462aa2 100644 --- a/itest/burn_test.go +++ b/itest/burn_test.go @@ -216,8 +216,8 @@ func testBurnAssets(t *harnessTest) { AssertSendEventsComplete(t.t, fullSendAddr.ScriptKey, sendEvents) AssertBalances( - t.t, t.tapd, burnAmt+simpleCollectible.Amount, - WithNumUtxos(2), WithNumAnchorUtxos(2), + t.t, t.tapd, simpleCollectible.Amount, + WithNumUtxos(1), WithNumAnchorUtxos(1), WithScriptKeyType(asset.ScriptKeyBurn), ) @@ -253,12 +253,6 @@ func testBurnAssets(t *harnessTest) { simpleAsset.Amount-burnAmt-multiBurnAmt, ) - AssertBalances( - t.t, t.tapd, burnAmt+simpleCollectible.Amount+multiBurnAmt, - WithNumUtxos(3), WithNumAnchorUtxos(3), - WithScriptKeyType(asset.ScriptKeyBurn), - ) - resp, err := t.tapd.ListAssets(ctxt, &taprpc.ListAssetRequest{ IncludeSpent: true, }) @@ -295,13 +289,6 @@ func testBurnAssets(t *harnessTest) { t.t, t.tapd, simpleGroupGen.AssetId, simpleGroup.Amount-burnAmt, ) - AssertBalances( - t.t, t.tapd, - burnAmt+simpleCollectible.Amount+multiBurnAmt+burnAmt, - WithNumUtxos(4), WithNumAnchorUtxos(4), - WithScriptKeyType(asset.ScriptKeyBurn), - ) - burns = AssertNumBurns(t.t, t.tapd, 4, nil) var groupBurn *taprpc.AssetBurn for _, b := range burns { @@ -355,13 +342,6 @@ func testBurnAssets(t *harnessTest) { ) AssertBalanceByID(t.t, t.tapd, simpleGroupCollectGen.AssetId, 0) - AssertBalances( - t.t, t.tapd, - burnAmt+simpleCollectible.Amount+multiBurnAmt+burnAmt+1, - WithNumUtxos(5), WithNumAnchorUtxos(5), - WithScriptKeyType(asset.ScriptKeyBurn), - ) - // We now perform some queries to test the filters of the ListBurns // call. diff --git a/itest/full_value_split_test.go b/itest/full_value_split_test.go index 8c3f9517e..4b4874cea 100644 --- a/itest/full_value_split_test.go +++ b/itest/full_value_split_test.go @@ -61,10 +61,11 @@ func testFullValueSend(t *harnessTest) { 1, 2, ) - // Alice should have one more zero-value tombstones in her wallet. + // After the second run, Alice's previous tombstons were swept. She now + // has 1 new tombstone UTXO from the last full-value send. AssertBalances( t.t, t.tapd, 0, WithScriptKeyType(asset.ScriptKeyTombstone), - WithNumUtxos(3), WithNumAnchorUtxos(3), + WithNumUtxos(1), WithNumAnchorUtxos(1), ) AssertBalances( t.t, secondTapd, mintedAsset.Amount+mintedGroupAsset.Amount, diff --git a/itest/test_list_on_test.go b/itest/test_list_on_test.go index 646781e2c..dd35a9a7f 100644 --- a/itest/test_list_on_test.go +++ b/itest/test_list_on_test.go @@ -101,6 +101,10 @@ var allTestCases = []*testCase{ name: "min relay fee bump", test: testMinRelayFeeBump, }, + { + name: "zero value anchor sweep", + test: testZeroValueAnchorSweep, + }, { name: "restart receiver check balance", test: testRestartReceiverCheckBalance, diff --git a/itest/zero_value_anchor_test.go b/itest/zero_value_anchor_test.go new file mode 100644 index 000000000..523d5b427 --- /dev/null +++ b/itest/zero_value_anchor_test.go @@ -0,0 +1,178 @@ +package itest + +import ( + "context" + + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/taprpc" + "github.com/lightninglabs/taproot-assets/taprpc/mintrpc" + "github.com/stretchr/testify/require" +) + +// testZeroValueAnchorSweep tests that zero-value anchor outputs +// are automatically swept when creating new on-chain transactions. +func testZeroValueAnchorSweep(t *harnessTest) { + ctxb := context.Background() + + // First, mint some simple asset. + rpcAssets := MintAssetsConfirmBatch( + t.t, t.lndHarness.Miner().Client, t.tapd, + []*mintrpc.MintAssetRequest{simpleAssets[0]}, + ) + genInfo := rpcAssets[0].AssetGenesis + assetAmount := simpleAssets[0].Asset.Amount + + // Create a second tapd node. + bobLnd := t.lndHarness.NewNodeWithCoins("Bob", nil) + secondTapd := setupTapdHarness(t.t, t, bobLnd, t.universeServer) + defer func() { + require.NoError(t.t, secondTapd.stop(!*noDelete)) + }() + + bobAddr, err := secondTapd.NewAddr(ctxb, &taprpc.NewAddrRequest{ + AssetId: genInfo.AssetId, + Amt: assetAmount, + AssetVersion: rpcAssets[0].Version, + }) + require.NoError(t.t, err) + + // Send ALL assets to Bob, which should create a tombstone. + sendResp, _ := sendAssetsToAddr(t, t.tapd, bobAddr) + + ConfirmAndAssertOutboundTransfer( + t.t, t.lndHarness.Miner().Client, t.tapd, sendResp, + genInfo.AssetId, + []uint64{0, assetAmount}, 0, 1, + ) + AssertNonInteractiveRecvComplete(t.t, secondTapd, 1) + + // Alice should have 1 tombstone UTXO from the full-value send. + AssertBalances( + t.t, t.tapd, 0, WithScriptKeyType(asset.ScriptKeyTombstone), + WithNumUtxos(1), WithNumAnchorUtxos(1), + ) + + // Test 1: Send transaction sweeps tombstones. + rpcAssets2 := MintAssetsConfirmBatch( + t.t, t.lndHarness.Miner().Client, t.tapd, + []*mintrpc.MintAssetRequest{simpleAssets[0]}, + ) + genInfo2 := rpcAssets2[0].AssetGenesis + + // Send full amount of the new asset. This should sweep Alice's + // first tombstone and create a new one. + bobAddr2, err := secondTapd.NewAddr(ctxb, &taprpc.NewAddrRequest{ + AssetId: genInfo2.AssetId, + Amt: assetAmount, + AssetVersion: rpcAssets2[0].Version, + }) + require.NoError(t.t, err) + + sendResp2, _ := sendAssetsToAddr(t, t.tapd, bobAddr2) + + ConfirmAndAssertOutboundTransfer( + t.t, t.lndHarness.Miner().Client, t.tapd, sendResp2, + genInfo2.AssetId, + []uint64{0, assetAmount}, 1, 2, + ) + AssertNonInteractiveRecvComplete(t.t, secondTapd, 2) + + // Check Alice's tombstone balance. The first tombstone should have been + // swept (spent on-chain as an input), and a new one created. We now + // have 1 tombstone UTXO (the new one from the second send). + AssertBalances( + t.t, t.tapd, 0, WithScriptKeyType(asset.ScriptKeyTombstone), + WithNumUtxos(1), WithNumAnchorUtxos(1), + ) + + // Get the new tombstone outpoint. + utxosAfterSend, err := t.tapd.ListUtxos(ctxb, &taprpc.ListUtxosRequest{ + ScriptKeyType: &taprpc.ScriptKeyTypeQuery{ + Type: &taprpc.ScriptKeyTypeQuery_ExplicitType{ + ExplicitType: taprpc. + ScriptKeyType_SCRIPT_KEY_TOMBSTONE, + }, + }, + }) + require.NoError(t.t, err) + require.Len(t.t, utxosAfterSend.ManagedUtxos, 1) + + // Test 2: Burning transaction sweeps tombstones. + rpcAssets3 := MintAssetsConfirmBatch( + t.t, t.lndHarness.Miner().Client, t.tapd, + []*mintrpc.MintAssetRequest{simpleAssets[0]}, + ) + genInfo3 := rpcAssets3[0].AssetGenesis + + // Full burn the asset to create a zero-value burn UTXO + // and sweep the second tombstone. + burnResp, err := t.tapd.BurnAsset(ctxb, &taprpc.BurnAssetRequest{ + Asset: &taprpc.BurnAssetRequest_AssetId{ + AssetId: genInfo3.AssetId, + }, + AmountToBurn: assetAmount, + ConfirmationText: "assets will be destroyed", + }) + require.NoError(t.t, err) + + AssertAssetOutboundTransferWithOutputs( + t.t, t.lndHarness.Miner().Client, t.tapd, burnResp.BurnTransfer, + [][]byte{genInfo3.AssetId}, + []uint64{assetAmount}, 2, 3, 1, true, + ) + + // Alice should have 0 tombstones remaining and 1 burn UTXO. + AssertBalances( + t.t, t.tapd, 0, WithScriptKeyType(asset.ScriptKeyTombstone), + WithNumUtxos(0), WithNumAnchorUtxos(0), + ) + AssertBalances( + t.t, t.tapd, assetAmount, + WithScriptKeyType(asset.ScriptKeyBurn), + WithNumUtxos(1), WithNumAnchorUtxos(1), + ) + + // Get the burn UTXO outpoint for the next test. + burnUtxos, err := t.tapd.ListUtxos(ctxb, &taprpc.ListUtxosRequest{ + ScriptKeyType: &taprpc.ScriptKeyTypeQuery{ + Type: &taprpc.ScriptKeyTypeQuery_ExplicitType{ + ExplicitType: taprpc. + ScriptKeyType_SCRIPT_KEY_BURN, + }, + }, + }) + require.NoError(t.t, err) + require.Len(t.t, burnUtxos.ManagedUtxos, 1) + + // Test 3: Send transactions sweeps zero-value burns. + rpcAssets4 := MintAssetsConfirmBatch( + t.t, t.lndHarness.Miner().Client, t.tapd, + []*mintrpc.MintAssetRequest{simpleAssets[0]}, + ) + genInfo4 := rpcAssets4[0].AssetGenesis + + // Send partial amount. This should NOT create a tombstone output + // and sweep the burn UTXO. + partialAmount := assetAmount / 2 + bobAddr3, err := secondTapd.NewAddr(ctxb, &taprpc.NewAddrRequest{ + AssetId: genInfo4.AssetId, + Amt: partialAmount, + AssetVersion: rpcAssets4[0].Version, + }) + require.NoError(t.t, err) + + sendResp3, _ := sendAssetsToAddr(t, t.tapd, bobAddr3) + + ConfirmAndAssertOutboundTransfer( + t.t, t.lndHarness.Miner().Client, t.tapd, sendResp3, + genInfo4.AssetId, + []uint64{partialAmount, partialAmount}, 3, 4, + ) + AssertNonInteractiveRecvComplete(t.t, secondTapd, 3) + + // The burn UTXO should have been swept. + AssertBalances( + t.t, t.tapd, 0, WithScriptKeyType(asset.ScriptKeyBurn), + WithNumUtxos(0), WithNumAnchorUtxos(0), + ) +} diff --git a/rpcserver.go b/rpcserver.go index 8ece71103..9af5b306e 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -2671,8 +2671,17 @@ func (r *rpcServer) AnchorVirtualPsbts(ctx context.Context, prevID.OutPoint.String()) } + // Fetch zero-value UTXOs that should be swept as additional inputs. + zeroValueInputs, err := r.cfg.AssetStore.FetchZeroValueAnchorUTXOs(ctx) + if err != nil { + return nil, fmt.Errorf("unable to fetch zero-value "+ + "UTXOs: %w", err) + } + resp, err := r.cfg.ChainPorter.RequestShipment( - tapfreighter.NewPreSignedParcel(vPackets, inputCommitments, ""), + tapfreighter.NewPreSignedParcel( + vPackets, inputCommitments, zeroValueInputs, "", + ), ) if err != nil { return nil, fmt.Errorf("error requesting delivery: %w", err) @@ -3785,7 +3794,8 @@ func (r *rpcServer) BurnAsset(ctx context.Context, resp, err := r.cfg.ChainPorter.RequestShipment( tapfreighter.NewPreSignedParcel( - fundResp.VPackets, fundResp.InputCommitments, in.Note, + fundResp.VPackets, fundResp.InputCommitments, + fundResp.ZeroValueInputs, in.Note, ), ) if err != nil { diff --git a/tapdb/assets_store.go b/tapdb/assets_store.go index 8c480cb8c..3ff41fb44 100644 --- a/tapdb/assets_store.go +++ b/tapdb/assets_store.go @@ -279,6 +279,10 @@ type ActiveAssetsStore interface { // serialized outpoint. DeleteManagedUTXO(ctx context.Context, outpoint []byte) error + // MarkManagedUTXOAsSwept marks a managed UTXO as swept, indicating + // it has been spent in a Bitcoin transaction. + MarkManagedUTXOAsSwept(ctx context.Context, outpoint []byte) error + // UpdateUTXOLease leases a managed UTXO identified by the passed // serialized outpoint. UpdateUTXOLease(ctx context.Context, arg UpdateUTXOLease) error @@ -489,6 +493,14 @@ type ManagedUTXO struct { // LeaseExpiry is the expiry time of the lease on this UTXO. If the // zero, then this UTXO isn't leased. LeaseExpiry time.Time + + // PkScript is the pkScript of the anchor output. This is populated + // when fetching zero-value anchor UTXOs to enable PSBT creation. + PkScript []byte + + // Swept indicates whether this UTXO has been used as input + // in a Bitcoin transaction. + Swept bool } // AssetHumanReadable is a subset of the base asset struct that only includes @@ -1309,6 +1321,7 @@ func (a *AssetStore) FetchManagedUTXOs(ctx context.Context) ( MerkleRoot: u.MerkleRoot, TapscriptSibling: u.TapscriptSibling, LeaseOwner: u.LeaseOwner, + Swept: u.Swept, } if u.LeaseExpiry.Valid { utxo.LeaseExpiry = u.LeaseExpiry.Time @@ -1320,6 +1333,170 @@ func (a *AssetStore) FetchManagedUTXOs(ctx context.Context) ( return managedUtxos, nil } +// MarkManagedUTXOAsSwept marks a managed UTXO as swept, indicating it has been +// spent in a Bitcoin transaction. +func (a *AssetStore) MarkManagedUTXOAsSwept(ctx context.Context, + outpoint wire.OutPoint) error { + + outpointBytes, err := encodeOutpoint(outpoint) + if err != nil { + return fmt.Errorf("unable to encode outpoint: %w", err) + } + + var writeTxOpts AssetStoreTxOptions + return a.db.ExecTx(ctx, &writeTxOpts, func(q ActiveAssetsStore) error { + return q.MarkManagedUTXOAsSwept(ctx, outpointBytes) + }) +} + +// FetchZeroValueAnchorUTXOs fetches all managed UTXOs that contain only +// zero-value assets (tombstones and burns). +func (a *AssetStore) FetchZeroValueAnchorUTXOs(ctx context.Context) ( + []*tapfreighter.ZeroValueInput, error) { + + // Strategy: fetch all managed UTXOs and filter in-memory. + // A UTXO is a "zero-value anchor" if all assets are either tombstones + // (NUMS key with amount 0) or burns. + // We exclude leased and spent UTXOs. + + var results []*tapfreighter.ZeroValueInput + + readOpts := NewAssetStoreReadTx() + now := a.clock.Now().UTC() + + dbErr := a.db.ExecTx(ctx, &readOpts, func(q ActiveAssetsStore) error { + utxos, err := q.FetchManagedUTXOs(ctx) + if err != nil { + return err + } + + for _, u := range utxos { + if len(u.LeaseOwner) > 0 && + u.LeaseExpiry.Valid && + u.LeaseExpiry.Time.UTC().After(now) { + + continue + } + + if u.Swept { + continue + } + + var anchorPoint wire.OutPoint + err := readOutPoint( + bytes.NewReader(u.Outpoint), 0, 0, &anchorPoint) + if err != nil { + return err + } + + // Query all assets anchored at this outpoint. + // We include spent assets here because tombstones are + // marked as spent when created. + assetsAtAnchor, err := a.queryChainAssets( + ctx, q, QueryAssetFilters{ + AnchorPoint: u.Outpoint, + Now: sql.NullTime{ + Time: now, + Valid: true, + }, + }, + ) + if err != nil { + return fmt.Errorf("failed to query assets at "+ + "anchor: %w", err) + } + + if len(assetsAtAnchor) == 0 { + continue + } + + // Determine if all assets are tombstones or burns. + // A tombstone asset is marked as "spent" at the asset + // level but its anchor UTXO may still be unspent + // on-chain and available for sweeping. + allZeroValue := true + for _, chainAsset := range assetsAtAnchor { + aAsset := chainAsset.Asset + + isTombstone := aAsset.Amount == 0 && + aAsset.ScriptKey.PubKey.IsEqual( + asset.NUMSPubKey, + ) + + isBurn := len(aAsset.PrevWitnesses) > 0 && + asset.IsBurnKey( + aAsset.ScriptKey.PubKey, + aAsset.PrevWitnesses[0], + ) + + if !isTombstone && !isBurn { + allZeroValue = false + break + } + } + + if !allZeroValue { + continue + } + + log.Debugf("Adding zero-value anchor to sweep list "+ + "(outpoint=%s)", anchorPoint.String()) + + internalKey, err := btcec.ParsePubKey(u.RawKey) + if err != nil { + return err + } + + // Fetch the chain transaction to get the actual + // pkScript. + chainTx, err := q.FetchChainTx(ctx, anchorPoint.Hash[:]) + if err != nil { + log.Warnf("Failed to fetch chain tx for "+ + "%v: %v, skipping", anchorPoint, err) + continue + } + + // Extract the pkScript from the transaction. + var tx wire.MsgTx + err = tx.Deserialize( + bytes.NewReader(chainTx.RawTx), + ) + if err != nil { + log.Warnf("Failed to deserialize tx for "+ + "%v: %v, skipping", anchorPoint, err) + continue + } + + pkScript := tx.TxOut[anchorPoint.Index].PkScript + + mu := &tapfreighter.ZeroValueInput{ + OutPoint: anchorPoint, + OutputValue: btcutil.Amount(u.AmtSats), + InternalKey: keychain.KeyDescriptor{ + PubKey: internalKey, + KeyLocator: keychain.KeyLocator{ + Index: uint32(u.KeyIndex), + Family: keychain.KeyFamily( + u.KeyFamily, + ), + }, + }, + MerkleRoot: u.MerkleRoot, + PkScript: pkScript, + } + + results = append(results, mu) + } + + return nil + }) + if dbErr != nil { + return nil, dbErr + } + + return results, nil +} + // FetchAssetProofsSizes fetches the sizes of the proofs in the db. func (a *AssetStore) FetchAssetProofsSizes( ctx context.Context) ([]AssetProofSize, error) { @@ -2476,6 +2653,30 @@ func (a *AssetStore) LogPendingParcel(ctx context.Context, } } + // Also extend leases for any zero-value UTXOs being swept. + for _, zeroValueInput := range spend.ZeroValueInputs { + outpointBytes, err := encodeOutpoint( + zeroValueInput.OutPoint, + ) + if err != nil { + return fmt.Errorf("unable to encode "+ + "zero-value outpoint: %w", err) + } + + err = q.UpdateUTXOLease(ctx, UpdateUTXOLease{ + LeaseOwner: finalLeaseOwner[:], + LeaseExpiry: sql.NullTime{ + Time: finalLeaseExpiry.UTC(), + Valid: true, + }, + Outpoint: outpointBytes, + }) + if err != nil { + return fmt.Errorf("unable to extend "+ + " zero-value UTXO lease: %w", err) + } + } + // Then the passive assets. if len(spend.PassiveAssets) > 0 { if spend.PassiveAssetsAnchor == nil { @@ -3302,9 +3503,25 @@ func (a *AssetStore) LogAnchorTxConfirm(ctx context.Context, // Keep the old proofs as a reference for when we list past // transfers. - // At this point we could delete the managed UTXO since it's no - // longer an unspent output, however we'll keep it in order to - // be able to reconstruct transfer history. + // Mark all zero-value UTXOs as swept since they were spent + // as additional inputs to the Bitcoin transaction. + for _, zeroValueInput := range conf.ZeroValueInputs { + outpoint := zeroValueInput.OutPoint + outpointBytes, err := encodeOutpoint(outpoint) + if err != nil { + return fmt.Errorf("failed to encode "+ + "zero-value outpoint: %w", err) + } + + err = q.MarkManagedUTXOAsSwept(ctx, outpointBytes) + if err != nil { + return fmt.Errorf("unable to mark zero-value "+ + "UTXO as swept: %w", err) + } + + log.Infof("Marked zero-value UTXO %v as swept", + outpoint) + } // We now insert in the DB any burns that may have been present // in the transfer. diff --git a/tapdb/migrations.go b/tapdb/migrations.go index 8deb9fa65..4ff83484d 100644 --- a/tapdb/migrations.go +++ b/tapdb/migrations.go @@ -24,7 +24,7 @@ const ( // daemon. // // NOTE: This MUST be updated when a new migration is added. - LatestMigrationVersion = 47 + LatestMigrationVersion = 48 ) // DatabaseBackend is an interface that contains all methods our different diff --git a/tapdb/sqlc/assets.sql.go b/tapdb/sqlc/assets.sql.go index 6db3205eb..f202fafc9 100644 --- a/tapdb/sqlc/assets.sql.go +++ b/tapdb/sqlc/assets.sql.go @@ -1480,7 +1480,7 @@ func (q *Queries) FetchInternalKeyLocator(ctx context.Context, rawKey []byte) (F } const FetchManagedUTXO = `-- name: FetchManagedUTXO :one -SELECT utxo_id, outpoint, amt_sats, internal_key_id, taproot_asset_root, tapscript_sibling, merkle_root, txn_id, lease_owner, lease_expiry, root_version, key_id, raw_key, key_family, key_index +SELECT utxo_id, outpoint, amt_sats, internal_key_id, taproot_asset_root, tapscript_sibling, merkle_root, txn_id, lease_owner, lease_expiry, root_version, swept, key_id, raw_key, key_family, key_index FROM managed_utxos utxos JOIN internal_keys keys ON utxos.internal_key_id = keys.key_id @@ -1507,6 +1507,7 @@ type FetchManagedUTXORow struct { LeaseOwner []byte LeaseExpiry sql.NullTime RootVersion sql.NullInt16 + Swept bool KeyID int64 RawKey []byte KeyFamily int32 @@ -1528,6 +1529,7 @@ func (q *Queries) FetchManagedUTXO(ctx context.Context, arg FetchManagedUTXOPara &i.LeaseOwner, &i.LeaseExpiry, &i.RootVersion, + &i.Swept, &i.KeyID, &i.RawKey, &i.KeyFamily, @@ -1537,7 +1539,7 @@ func (q *Queries) FetchManagedUTXO(ctx context.Context, arg FetchManagedUTXOPara } const FetchManagedUTXOs = `-- name: FetchManagedUTXOs :many -SELECT utxo_id, outpoint, amt_sats, internal_key_id, taproot_asset_root, tapscript_sibling, merkle_root, txn_id, lease_owner, lease_expiry, root_version, key_id, raw_key, key_family, key_index +SELECT utxo_id, outpoint, amt_sats, internal_key_id, taproot_asset_root, tapscript_sibling, merkle_root, txn_id, lease_owner, lease_expiry, root_version, swept, key_id, raw_key, key_family, key_index FROM managed_utxos utxos JOIN internal_keys keys ON utxos.internal_key_id = keys.key_id @@ -1555,6 +1557,7 @@ type FetchManagedUTXOsRow struct { LeaseOwner []byte LeaseExpiry sql.NullTime RootVersion sql.NullInt16 + Swept bool KeyID int64 RawKey []byte KeyFamily int32 @@ -1582,6 +1585,7 @@ func (q *Queries) FetchManagedUTXOs(ctx context.Context) ([]FetchManagedUTXOsRow &i.LeaseOwner, &i.LeaseExpiry, &i.RootVersion, + &i.Swept, &i.KeyID, &i.RawKey, &i.KeyFamily, @@ -2324,6 +2328,17 @@ func (q *Queries) InsertAssetSeedlingIntoBatch(ctx context.Context, arg InsertAs return err } +const MarkManagedUTXOAsSwept = `-- name: MarkManagedUTXOAsSwept :exec +UPDATE managed_utxos +SET swept = TRUE +WHERE outpoint = $1 +` + +func (q *Queries) MarkManagedUTXOAsSwept(ctx context.Context, outpoint []byte) error { + _, err := q.db.ExecContext(ctx, MarkManagedUTXOAsSwept, outpoint) + return err +} + const NewMintingBatch = `-- name: NewMintingBatch :exec INSERT INTO asset_minting_batches ( batch_state, batch_id, height_hint, creation_time_unix, universe_commitments @@ -3203,9 +3218,9 @@ WITH target_key(key_id) AS ( ) INSERT INTO managed_utxos ( outpoint, amt_sats, internal_key_id, tapscript_sibling, merkle_root, txn_id, - taproot_asset_root, root_version + taproot_asset_root, root_version, swept ) VALUES ( - $2, $3, (SELECT key_id FROM target_key), $4, $5, $6, $7, $8 + $2, $3, (SELECT key_id FROM target_key), $4, $5, $6, $7, $8, FALSE ) ON CONFLICT (outpoint) -- Not a NOP but instead update any nullable fields that aren't null in the -- args. diff --git a/tapdb/sqlc/migrations/000048_add_swept_flag.down.sql b/tapdb/sqlc/migrations/000048_add_swept_flag.down.sql new file mode 100644 index 000000000..b87b9053b --- /dev/null +++ b/tapdb/sqlc/migrations/000048_add_swept_flag.down.sql @@ -0,0 +1,2 @@ +-- Remove swept flag from managed_utxos table +ALTER TABLE managed_utxos DROP COLUMN swept; diff --git a/tapdb/sqlc/migrations/000048_add_swept_flag.up.sql b/tapdb/sqlc/migrations/000048_add_swept_flag.up.sql new file mode 100644 index 000000000..bf3081498 --- /dev/null +++ b/tapdb/sqlc/migrations/000048_add_swept_flag.up.sql @@ -0,0 +1,2 @@ +-- Add swept flag to managed_utxos table to track when UTXOs have been swept +ALTER TABLE managed_utxos ADD COLUMN swept BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/tapdb/sqlc/models.go b/tapdb/sqlc/models.go index 8c6f374d8..49381d8db 100644 --- a/tapdb/sqlc/models.go +++ b/tapdb/sqlc/models.go @@ -302,6 +302,7 @@ type ManagedUtxo struct { LeaseOwner []byte LeaseExpiry sql.NullTime RootVersion sql.NullInt16 + Swept bool } type MintSupplyPreCommit struct { diff --git a/tapdb/sqlc/querier.go b/tapdb/sqlc/querier.go index 1d7b8a8a2..b012c7f6f 100644 --- a/tapdb/sqlc/querier.go +++ b/tapdb/sqlc/querier.go @@ -155,6 +155,7 @@ type Querier interface { LinkDanglingSupplyUpdateEvents(ctx context.Context, arg LinkDanglingSupplyUpdateEventsParams) error LogProofTransferAttempt(ctx context.Context, arg LogProofTransferAttemptParams) error LogServerSync(ctx context.Context, arg LogServerSyncParams) error + MarkManagedUTXOAsSwept(ctx context.Context, outpoint []byte) error // Mark a supply pre-commitment output as spent by its outpoint. The // pre-commitment corresponds to an asset issuance where the local node acted as // the issuer. diff --git a/tapdb/sqlc/queries/assets.sql b/tapdb/sqlc/queries/assets.sql index dfce2d3ac..89bbad343 100644 --- a/tapdb/sqlc/queries/assets.sql +++ b/tapdb/sqlc/queries/assets.sql @@ -608,9 +608,9 @@ WITH target_key(key_id) AS ( ) INSERT INTO managed_utxos ( outpoint, amt_sats, internal_key_id, tapscript_sibling, merkle_root, txn_id, - taproot_asset_root, root_version + taproot_asset_root, root_version, swept ) VALUES ( - $2, $3, (SELECT key_id FROM target_key), $4, $5, $6, $7, $8 + $2, $3, (SELECT key_id FROM target_key), $4, $5, $6, $7, $8, FALSE ) ON CONFLICT (outpoint) -- Not a NOP but instead update any nullable fields that aren't null in the -- args. @@ -855,6 +855,11 @@ ORDER BY witness_index; DELETE FROM managed_utxos WHERE outpoint = $1; +-- name: MarkManagedUTXOAsSwept :exec +UPDATE managed_utxos +SET swept = TRUE +WHERE outpoint = $1; + -- name: UpdateUTXOLease :exec UPDATE managed_utxos SET lease_owner = @lease_owner, lease_expiry = @lease_expiry diff --git a/tapdb/sqlc/schemas/generated_schema.sql b/tapdb/sqlc/schemas/generated_schema.sql index ff3712df4..cee82eed8 100644 --- a/tapdb/sqlc/schemas/generated_schema.sql +++ b/tapdb/sqlc/schemas/generated_schema.sql @@ -622,7 +622,7 @@ CREATE TABLE managed_utxos ( -- expiry is NULL or the timestamp is in the past, then the lease is not -- valid and the UTXO is available for coin selection. lease_expiry TIMESTAMP -, root_version SMALLINT); +, root_version SMALLINT, swept BOOLEAN NOT NULL DEFAULT FALSE); CREATE INDEX mint_anchor_uni_commitments_outpoint_idx ON mint_supply_pre_commits(outpoint) diff --git a/tapfreighter/chain_porter.go b/tapfreighter/chain_porter.go index 6c6bb8165..7c417ce69 100644 --- a/tapfreighter/chain_porter.go +++ b/tapfreighter/chain_porter.go @@ -888,6 +888,7 @@ func (p *ChainPorter) storePackageAnchorTxConf(pkg *sendPackage) error { TxIndex: int32(pkg.TransferTxConfEvent.TxIndex), FinalProofs: pkg.FinalProofs, PassiveAssetProofFiles: passiveAssetProofFiles, + ZeroValueInputs: pkg.ZeroValueInputs, }, burns) if err != nil { return fmt.Errorf("unable to log parcel delivery "+ @@ -1248,9 +1249,8 @@ func (p *ChainPorter) importLocalAddresses(ctx context.Context, for idx := range parcel.Outputs { out := &parcel.Outputs[idx] - // Skip non-local outputs, those are going to a receiver outside - // of this daemon. - if !out.ScriptKeyLocal { + // Determine if the output should be imported into the wallet. + if !out.IsSpendable() { continue } @@ -1267,23 +1267,30 @@ func (p *ChainPorter) importLocalAddresses(ctx context.Context, return err } + log.Infof("Importing anchor output key for output %d "+ + "(isTombstone=%v, isBurn=%v): outpoint=%v, key=%x", + idx, out.IsTombstone(), out.IsBurn(), + out.Anchor.OutPoint, + anchorOutputKey.SerializeCompressed()) + // Before we broadcast the transaction to the network, we'll // import the new anchor output into the wallet so it watches // it for spends and also takes account of the BTC we used in // the transfer. _, err = p.cfg.Wallet.ImportTaprootOutput(ctx, anchorOutputKey) - 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 + if err != nil { + // 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. + if strings.Contains(err.Error(), "already exists") { + log.Tracef("Anchor output key already exists "+ + "(outpoint=%v): %w", + out.Anchor.OutPoint, err) + continue + } - default: - return err + return fmt.Errorf("unable to import anchor output "+ + "key: %w", err) } } @@ -1446,6 +1453,7 @@ func (p *ChainPorter) stateStep(currentPkg sendPackage) (*sendPackage, error) { currentPkg.VirtualPackets = fundSendRes.VPackets currentPkg.InputCommitments = fundSendRes.InputCommitments + currentPkg.ZeroValueInputs = fundSendRes.ZeroValueInputs currentPkg.SendState = SendStateVirtualSign @@ -1591,9 +1599,10 @@ func (p *ChainPorter) stateStep(currentPkg sendPackage) (*sendPackage, error) { anchorTx, err := wallet.AnchorVirtualTransactions( ctx, &AnchorVTxnsParams{ - FeeRate: feeRate, - ActivePackets: currentPkg.VirtualPackets, - PassivePackets: currentPkg.PassiveAssets, + FeeRate: feeRate, + ActivePackets: currentPkg.VirtualPackets, + PassivePackets: currentPkg.PassiveAssets, + ZeroValueInputs: currentPkg.ZeroValueInputs, }, ) if err != nil { @@ -1695,8 +1704,8 @@ func (p *ChainPorter) stateStep(currentPkg sendPackage) (*sendPackage, error) { parcel, err := ConvertToTransfer( currentHeight, currentPkg.VirtualPackets, currentPkg.AnchorTx, currentPkg.PassiveAssets, - isLocalKey, currentPkg.Label, - currentPkg.SkipAnchorTxBroadcast, + currentPkg.ZeroValueInputs, isLocalKey, + currentPkg.Label, currentPkg.SkipAnchorTxBroadcast, ) if err != nil { p.unlockInputs(ctx, ¤tPkg) @@ -1715,6 +1724,8 @@ func (p *ChainPorter) stateStep(currentPkg sendPackage) (*sendPackage, error) { // Write the parcel to disk as a pending parcel. This step also // records the transfer details (e.g., reference to the anchor // transaction ID, transfer outputs and inputs) to the database. + // This will also extend the leases for both asset inputs and + // zero-value UTXOs to prevent them from being used elsewhere. err = p.cfg.ExportLog.LogPendingParcel( ctx, parcel, defaultWalletLeaseIdentifier, time.Now().Add(defaultBroadcastCoinLeaseDuration), @@ -1883,9 +1894,8 @@ func (p *ChainPorter) unlockInputs(ctx context.Context, pkg *sendPackage) { // sanity-check that we have known input commitments to unlock, since // that might not always be the case (for example if another party // contributes inputs). - if pkg.SendState < SendStateStorePreBroadcast && - len(pkg.InputCommitments) > 0 { - + // Also unlock any zero-value UTXOs that were leased for this package. + if pkg.SendState < SendStateStorePreBroadcast { for prevID := range pkg.InputCommitments { log.Debugf("Unlocking input %v", prevID.OutPoint) @@ -1897,6 +1907,20 @@ func (p *ChainPorter) unlockInputs(ctx context.Context, pkg *sendPackage) { prevID.OutPoint, err) } } + + zeroValueOutpoints := fn.Map( + pkg.ZeroValueInputs, + func(z *ZeroValueInput) wire.OutPoint { + return z.OutPoint + }, + ) + + err := p.cfg.AssetWallet.ReleaseCoins( + ctx, zeroValueOutpoints..., + ) + if err != nil { + log.Warnf("Unable to unlock zero-value inputs: %v", err) + } } // If we're in another state, the anchor transaction has been created, diff --git a/tapfreighter/coin_select.go b/tapfreighter/coin_select.go index 90b239328..feea48167 100644 --- a/tapfreighter/coin_select.go +++ b/tapfreighter/coin_select.go @@ -114,20 +114,6 @@ func (s *CoinSelect) SelectCoins(ctx context.Context, return selectedCoins, nil } -// LeaseCoins leases/locks/reserves coins for the given lease owner until the -// given expiry. This is used to prevent multiple concurrent coin selection -// attempts from selecting the same coin(s). -func (s *CoinSelect) LeaseCoins(ctx context.Context, leaseOwner [32]byte, - expiry time.Time, utxoOutpoints ...wire.OutPoint) error { - - s.coinLock.Lock() - defer s.coinLock.Unlock() - - return s.coinLister.LeaseCoins( - ctx, leaseOwner, expiry, utxoOutpoints..., - ) -} - // ReleaseCoins releases/unlocks coins that were previously leased and makes // them available for coin selection again. func (s *CoinSelect) ReleaseCoins(ctx context.Context, @@ -199,4 +185,46 @@ func (s *CoinSelect) selectForAmount(minTotalAmount uint64, return selectedCommitments, nil } +// SelectZeroValueCoins fetches all managed UTXOs that contain only +// zero-value assets (tombstones and burns). The selected UTXOs are +// leased for the default lease duration. +func (s *CoinSelect) SelectZeroValueCoins(ctx context.Context) ( + []*ZeroValueInput, error) { + + s.coinLock.Lock() + defer s.coinLock.Unlock() + + // Fetch all zero-value UTXOs that are eligible for sweeping. + zeroValueInputs, err := s.coinLister.FetchZeroValueAnchorUTXOs(ctx) + if err != nil { + return nil, fmt.Errorf("unable to fetch zero-value UTXOs: %w", + err) + } + + // We now need to lock/lease/reserve those selected coins so + // that they can't be used by other processes. + if len(zeroValueInputs) > 0 { + expiry := time.Now().Add(defaultCoinLeaseDuration) + zeroValueOutpoints := fn.Map( + zeroValueInputs, + func(z *ZeroValueInput) wire.OutPoint { + return z.OutPoint + }, + ) + err = s.coinLister.LeaseCoins( + ctx, defaultWalletLeaseIdentifier, expiry, + zeroValueOutpoints..., + ) + if err != nil { + return nil, fmt.Errorf("unable to lease zero-value "+ + "UTXOs: %w", err) + } + + log.Debugf("Selected and leased %d zero-value UTXOs", + len(zeroValueInputs)) + } + + return zeroValueInputs, nil +} + var _ CoinSelector = (*CoinSelect)(nil) diff --git a/tapfreighter/coin_select_test.go b/tapfreighter/coin_select_test.go index b30e1d4ee..9fbce852b 100644 --- a/tapfreighter/coin_select_test.go +++ b/tapfreighter/coin_select_test.go @@ -60,6 +60,12 @@ func (m *mockCoinLister) DeleteExpiredLeases(ctx context.Context) error { return nil } +func (m *mockCoinLister) FetchZeroValueAnchorUTXOs( + context.Context) ([]*ZeroValueInput, error) { + + return nil, nil +} + // TestCoinSelector tests that the coin selector behaves as expected. func TestCoinSelector(t *testing.T) { var ( diff --git a/tapfreighter/fund.go b/tapfreighter/fund.go index 8926c0bad..439365d5f 100644 --- a/tapfreighter/fund.go +++ b/tapfreighter/fund.go @@ -26,7 +26,8 @@ import ( func createFundedPacketWithInputs(ctx context.Context, exporter proof.Exporter, keyRing KeyRing, addrBook AddrBook, fundDesc *tapsend.FundingDescriptor, vPktTemplate *tappsbt.VPacket, - selectedCommitments []*AnchoredCommitment) (*FundedVPacket, error) { + selectedCommitments []*AnchoredCommitment, + zeroValueInputs []*ZeroValueInput) (*FundedVPacket, error) { if vPktTemplate.ChainParams == nil { return nil, errors.New("chain params not set in virtual packet") @@ -159,6 +160,7 @@ func createFundedPacketWithInputs(ctx context.Context, exporter proof.Exporter, return &FundedVPacket{ VPackets: allPackets, InputCommitments: inputCommitments, + ZeroValueInputs: zeroValueInputs, }, nil } diff --git a/tapfreighter/fund_test.go b/tapfreighter/fund_test.go index 13d32b26c..df0e9f530 100644 --- a/tapfreighter/fund_test.go +++ b/tapfreighter/fund_test.go @@ -321,6 +321,7 @@ func TestFundPacket(t *testing.T) { vPkt *tappsbt.VPacket inputProofs []*proof.Proof selectedCommitments []*AnchoredCommitment + zeroValueInputs []*ZeroValueInput keysDerived int expectedErr string expectedInputCommitments tappsbt.InputCommitments @@ -350,6 +351,28 @@ func TestFundPacket(t *testing.T) { Commitment: inputCommitment, Asset: &inputAsset, }}, + zeroValueInputs: []*ZeroValueInput{ + { + OutPoint: wire.OutPoint{ + Hash: test.RandHash(), + Index: 1, + }, + OutputValue: 1000, + InternalKey: internalKey, + MerkleRoot: test.RandBytes(32), + PkScript: test.RandBytes(34), + }, + { + OutPoint: wire.OutPoint{ + Hash: test.RandHash(), + Index: 0, + }, + OutputValue: 546, + InternalKey: internalKey, + MerkleRoot: test.RandBytes(32), + PkScript: test.RandBytes(34), + }, + }, keysDerived: 3, expectedInputCommitments: tappsbt.InputCommitments{ inputPrevID: inputCommitment, @@ -765,6 +788,7 @@ func TestFundPacket(t *testing.T) { result, err := createFundedPacketWithInputs( ctx, exporter, keyRing, addrBook, tc.fundDesc, tc.vPkt, tc.selectedCommitments, + tc.zeroValueInputs, ) keyRing.AssertNumberOfCalls( @@ -788,6 +812,13 @@ func TestFundPacket(t *testing.T) { tt, result.VPackets, tc.expectedOutputs(tt, keyRing), ) + + // Verify zero-value inputs are correctly added to the + // result. + require.Len( + tt, result.ZeroValueInputs, + len(tc.zeroValueInputs), + ) }) } } diff --git a/tapfreighter/interface.go b/tapfreighter/interface.go index 70179cb9c..b949788ba 100644 --- a/tapfreighter/interface.go +++ b/tapfreighter/interface.go @@ -168,6 +168,11 @@ type CoinLister interface { // DeleteExpiredLeases deletes all expired leases from the database. DeleteExpiredLeases(ctx context.Context) error + + // FetchZeroValueAnchorUTXOs fetches all managed UTXOs that contain only + // zero-value assets (tombstones and burns). + FetchZeroValueAnchorUTXOs(ctx context.Context) ([]*ZeroValueInput, + error) } // MultiCommitmentSelectStrategy is an enum that describes the strategy that @@ -195,6 +200,29 @@ type CoinSelector interface { // ReleaseCoins releases/unlocks coins that were previously leased and // makes them available for coin selection again. ReleaseCoins(ctx context.Context, utxoOutpoints ...wire.OutPoint) error + + // SelectZeroValueCoins selects all managed UTXOs that contain only + // zero-value assets (tombstones and burns). The selected UTXOs are + // leased for the default lease duration. + SelectZeroValueCoins(ctx context.Context) ([]*ZeroValueInput, error) +} + +// ZeroValueInput represents a zero-value UTXO that should be swept. +type ZeroValueInput struct { + // OutPoint is the outpoint of the zero-value UTXO. + OutPoint wire.OutPoint + + // OutputValue is the satoshi value of the zero-value UTXO. + OutputValue btcutil.Amount + + // InternalKey is the internal key descriptor for the zero-value UTXO. + InternalKey keychain.KeyDescriptor + + // MerkleRoot is the taproot merkle root for the zero-value UTXO. + MerkleRoot []byte + + // PkScript is the pkScript of the anchor output. + PkScript []byte } // TransferInput represents the database level input to an asset transfer. @@ -431,6 +459,29 @@ func (out *TransferOutput) UniqueKey() (OutputIdentifier, error) { ), nil } +// IsTombstone returns true if the transfer output is a tombstone. +func (out *TransferOutput) IsTombstone() bool { + return out.Amount == 0 && out.ScriptKey.PubKey.IsEqual(asset.NUMSPubKey) +} + +// IsBurn returns true if the transfer output is a burn. +func (out *TransferOutput) IsBurn() bool { + return out.Amount == 0 && len(out.WitnessData) > 0 && + asset.IsBurnKey( + out.ScriptKey.PubKey, out.WitnessData[0], + ) +} + +// IsLocal returns true if the transfer output is a local script key. +func (out *TransferOutput) IsLocal() bool { + return out.ScriptKeyLocal +} + +// IsSpendable returns true if the transfer output is spendable. +func (out *TransferOutput) IsSpendable() bool { + return out.IsLocal() || out.IsTombstone() || out.IsBurn() +} + // OutboundParcel represents the database level delta of an outbound Taproot // Asset parcel (outbound spend). A spend will destroy a series of assets listed // as inputs, and re-create them as new outputs. Along the way some assets may @@ -480,6 +531,10 @@ type OutboundParcel struct { // transfer. Outputs []TransferOutput + // ZeroValueInputs is the set of zero-value UTXOs that are being swept + // as additional inputs to the Bitcoin transaction. + ZeroValueInputs []*ZeroValueInput + // Label is a user provided label for the transfer. Label string @@ -498,6 +553,7 @@ func (o *OutboundParcel) Copy() *OutboundParcel { ChainFees: o.ChainFees, Inputs: fn.CopySlice(o.Inputs), Outputs: fn.CopySlice(o.Outputs), + ZeroValueInputs: fn.CopySlice(o.ZeroValueInputs), Label: o.Label, SkipAnchorTxBroadcast: o.SkipAnchorTxBroadcast, } @@ -574,6 +630,10 @@ type AssetConfirmEvent struct { // PassiveAssetProofFiles is the set of passive asset proof files that // are re-anchored during the parcel confirmation process. PassiveAssetProofFiles map[asset.ID][]*proof.AnnotatedProof + + // ZeroValueInputs is the set of zero-value UTXOs that were swept as + // additional inputs to the Bitcoin transaction. + ZeroValueInputs []*ZeroValueInput } // ExportLog is used to track the state of outbound Taproot Asset parcels diff --git a/tapfreighter/parcel.go b/tapfreighter/parcel.go index 8561002f2..dc8cdeefd 100644 --- a/tapfreighter/parcel.go +++ b/tapfreighter/parcel.go @@ -283,6 +283,10 @@ type PreSignedParcel struct { // spent in the virtual transaction. inputCommitments tappsbt.InputCommitments + // zeroValueInputs is the list of zero-value UTXOs that should be swept + // as additional inputs to the transaction. + zeroValueInputs []*ZeroValueInput + // note is a string that provides any user defined description for this // transfer. note string @@ -295,7 +299,7 @@ var _ Parcel = (*PreSignedParcel)(nil) // NewPreSignedParcel creates a new PreSignedParcel. func NewPreSignedParcel(vPackets []*tappsbt.VPacket, inputCommitments tappsbt.InputCommitments, - note string) *PreSignedParcel { + zeroValueInputs []*ZeroValueInput, note string) *PreSignedParcel { return &PreSignedParcel{ parcelKit: &parcelKit{ @@ -304,6 +308,7 @@ func NewPreSignedParcel(vPackets []*tappsbt.VPacket, }, vPackets: vPackets, inputCommitments: inputCommitments, + zeroValueInputs: zeroValueInputs, note: note, } } @@ -320,6 +325,7 @@ func (p *PreSignedParcel) pkg() *sendPackage { SendState: SendStateAnchorSign, VirtualPackets: p.vPackets, InputCommitments: p.inputCommitments, + ZeroValueInputs: p.zeroValueInputs, Note: p.note, } } @@ -486,6 +492,10 @@ type sendPackage struct { // associated Taproot Asset commitment. InputCommitments tappsbt.InputCommitments + // ZeroValueInputs is a list of zero-value UTXOs that should be swept + // as additional inputs to the transaction. + ZeroValueInputs []*ZeroValueInput + // SendManifests is a map of send manifests that need to be sent to the // auth mailbox server to complete an address V2 transfer. It is keyed // by the anchor output index. @@ -546,6 +556,7 @@ type sendPackage struct { // they were already committed at. func ConvertToTransfer(currentHeight uint32, activeTransfers []*tappsbt.VPacket, anchorTx *tapsend.AnchorTransaction, passiveAssets []*tappsbt.VPacket, + zeroValueInputs []*ZeroValueInput, isLocalKey func(asset.ScriptKey) (bool, error), label string, skipAnchorTxBroadcast bool) (*OutboundParcel, error) { @@ -584,6 +595,7 @@ func ConvertToTransfer(currentHeight uint32, activeTransfers []*tappsbt.VPacket, ), PassiveAssets: passiveAssets, PassiveAssetsAnchor: passiveAssetAnchor, + ZeroValueInputs: zeroValueInputs, Label: label, SkipAnchorTxBroadcast: skipAnchorTxBroadcast, } diff --git a/tapfreighter/wallet.go b/tapfreighter/wallet.go index 13da9efdc..91e16c315 100644 --- a/tapfreighter/wallet.go +++ b/tapfreighter/wallet.go @@ -172,6 +172,10 @@ type AnchorVTxnsParams struct { // PassivePackets is a list of all the virtual transactions which // re-anchor passive assets. PassivePackets []*tappsbt.VPacket + + // ZeroValueInputs is a list of zero-value UTXOs that should be swept + // as additional inputs to the transaction. + ZeroValueInputs []*ZeroValueInput } // WalletConfig holds the configuration for a new Wallet. @@ -248,6 +252,10 @@ type FundedVPacket struct { // InputCommitments is a map from virtual package input index to its // associated Taproot Asset commitment. InputCommitments tappsbt.InputCommitments + + // ZeroValueInputs is a list of zero-value UTXOs that should be swept + // as additional inputs to the transaction. + ZeroValueInputs []*ZeroValueInput } // FundAddressSend funds a virtual transaction, selecting assets to spend in @@ -656,7 +664,13 @@ func (f *AssetWallet) FundPacket(ctx context.Context, return nil, err } - // If we return with an error, we want to release the coins we've + zeroValueInputs, err := f.cfg.CoinSelector.SelectZeroValueCoins(ctx) + if err != nil { + return nil, fmt.Errorf("unable to select zero-value "+ + "UTXOs: %w", err) + } + + // If we return with an error, we want to release all the coins we've // selected. success := false defer func() { @@ -667,6 +681,16 @@ func (f *AssetWallet) FundPacket(ctx context.Context, return c.AnchorPoint }, ) + + // Also release any zero-value UTXOs we may have leased. + zeroValueOutpoints := fn.Map( + zeroValueInputs, + func(z *ZeroValueInput) wire.OutPoint { + return z.OutPoint + }, + ) + outpoints = append(outpoints, zeroValueOutpoints...) + err := f.cfg.CoinSelector.ReleaseCoins( ctx, outpoints..., ) @@ -678,7 +702,7 @@ func (f *AssetWallet) FundPacket(ctx context.Context, pkt, err := createFundedPacketWithInputs( ctx, f.cfg.AssetProofs, f.cfg.KeyRing, f.cfg.AddrBook, fundDesc, - vPkt, selectedCommitments, + vPkt, selectedCommitments, zeroValueInputs, ) if err != nil { return nil, err @@ -713,7 +737,13 @@ func (f *AssetWallet) FundBurn(ctx context.Context, return nil, err } - // If we return with an error, we want to release the coins we've + zeroValueInputs, err := f.cfg.CoinSelector.SelectZeroValueCoins(ctx) + if err != nil { + return nil, fmt.Errorf("unable to select zero-value "+ + "UTXOs: %w", err) + } + + // If we return with an error, we want to release all the coins we've // selected. success := false defer func() { @@ -724,6 +754,16 @@ func (f *AssetWallet) FundBurn(ctx context.Context, return c.AnchorPoint }, ) + + // Also release any zero-value UTXOs we may have leased. + zeroValueOutpoints := fn.Map( + zeroValueInputs, + func(z *ZeroValueInput) wire.OutPoint { + return z.OutPoint + }, + ) + outpoints = append(outpoints, zeroValueOutpoints...) + err := f.cfg.CoinSelector.ReleaseCoins( ctx, outpoints..., ) @@ -797,7 +837,7 @@ func (f *AssetWallet) FundBurn(ctx context.Context, // split commitment and other data. fundedPkt, err := createFundedPacketWithInputs( ctx, f.cfg.AssetProofs, f.cfg.KeyRing, f.cfg.AddrBook, fundDesc, - vPkt, selectedCommitments, + vPkt, selectedCommitments, zeroValueInputs, ) if err != nil { return nil, err @@ -1191,6 +1231,15 @@ func (f *AssetWallet) AnchorVirtualTransactions(ctx context.Context, // it itself. addAnchorPsbtInputs(sendPacket, params.ActivePackets) + // Add zero-value inputs that should be swept as additional inputs. + numZeroValueInputs := len(params.ZeroValueInputs) + if numZeroValueInputs > 0 { + log.Infof("Sweeping %d zero-value UTXOs", numZeroValueInputs) + addZeroValuePsbtInputs( + sendPacket, params.ZeroValueInputs, f.cfg.ChainParams, + ) + } + // We now fund the packet, placing the change on the last output. anchorPkt, err := f.cfg.Wallet.FundPsbt( ctx, sendPacket, 1, params.FeeRate, -1, @@ -1198,21 +1247,16 @@ func (f *AssetWallet) AnchorVirtualTransactions(ctx context.Context, if err != nil { return nil, fmt.Errorf("unable to fund psbt: %w", err) } - - log.Infof("Received funded PSBT packet") - log.Tracef("Packet: %v", spew.Sdump(anchorPkt.Pkt)) + log.Tracef("Got funded PSBT packet: %v", spew.Sdump(anchorPkt.Pkt)) // With all the input and output information in the packet, we // can now ask lnd to sign it, and then extract the final // version ourselves. - log.Debugf("Signing PSBT") - log.Tracef("PSBT: %s", spew.Sdump(anchorPkt)) signedPsbt, err := f.cfg.Wallet.SignPsbt(ctx, anchorPkt.Pkt) if err != nil { return nil, fmt.Errorf("unable to sign psbt: %w", err) } - log.Debugf("Got signed PSBT") - log.Tracef("PSBT: %s", spew.Sdump(signedPsbt)) + log.Tracef("Got signed PSBT: %s", spew.Sdump(signedPsbt)) // Before we finalize, we need to calculate the actual, final fees that // we pay. @@ -1380,3 +1424,44 @@ func addAnchorPsbtInputs(btcPkt *psbt.Packet, vPackets []*tappsbt.VPacket) { } } + +// addZeroValuePsbtInputs adds zero-value UTXOs as inputs to the PSBT. +func addZeroValuePsbtInputs(btcPkt *psbt.Packet, + zeroValueInputs []*ZeroValueInput, chainParams *address.ChainParams) { + + for _, utxo := range zeroValueInputs { + // Check if this input is already added to avoid duplicates. + if tapsend.HasInput(btcPkt.UnsignedTx, utxo.OutPoint) { + continue + } + + // Create the BIP32 derivation info for signing. + bip32Derivation, trDerivation := tappsbt. + Bip32DerivationFromKeyDesc( + utxo.InternalKey, chainParams.HDCoinType, + ) + + btcPkt.Inputs = append(btcPkt.Inputs, psbt.PInput{ + WitnessUtxo: &wire.TxOut{ + Value: int64(utxo.OutputValue), + PkScript: utxo.PkScript, + }, + SighashType: txscript.SigHashDefault, + Bip32Derivation: []*psbt.Bip32Derivation{ + bip32Derivation, + }, + TaprootInternalKey: schnorr.SerializePubKey( + utxo.InternalKey.PubKey, + ), + TaprootBip32Derivation: []*psbt.TaprootBip32Derivation{ + trDerivation, + }, + TaprootMerkleRoot: utxo.MerkleRoot, + }) + btcPkt.UnsignedTx.TxIn = append( + btcPkt.UnsignedTx.TxIn, &wire.TxIn{ + PreviousOutPoint: utxo.OutPoint, + }, + ) + } +} diff --git a/tapsend/allocation.go b/tapsend/allocation.go index 1c0be0c98..33c52137e 100644 --- a/tapsend/allocation.go +++ b/tapsend/allocation.go @@ -12,6 +12,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/psbt" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/lightninglabs/taproot-assets/address" @@ -147,6 +148,15 @@ type Allocation struct { // output. InternalKey *btcec.PublicKey + // Bip32Derivation is the BIP32 derivation info for the internal key. + // This is used to preserve the key derivation through the allocation + // flow so that PSBTs can be properly signed. + Bip32Derivation []*psbt.Bip32Derivation + + // TaprootBip32Derivation is the taproot BIP32 derivation info for the + // internal key. + TaprootBip32Derivation []*psbt.TaprootBip32Derivation + // NonAssetLeaves is the full list of TapLeaf nodes that aren't any // asset commitments. This is used to construct the tapscript sibling // for the asset commitment. This is mutually exclusive to the @@ -616,17 +626,19 @@ func allocatePiece(p piece, a Allocation, toFill uint64, deliveryAddr := a.ProofDeliveryAddress vOut := &tappsbt.VOutput{ - AssetVersion: a.AssetVersion, - Interactive: interactive, - AnchorOutputIndex: a.OutputIndex, - AnchorOutputInternalKey: a.InternalKey, - AnchorOutputTapscriptSibling: sibling, - ScriptKey: scriptKey, - ProofDeliveryAddress: deliveryAddr, - LockTime: a.LockTime, - RelativeLockTime: uint64(a.Sequence), - AltLeaves: a.AltLeaves, - Address: a.Address, + AssetVersion: a.AssetVersion, + Interactive: interactive, + AnchorOutputIndex: a.OutputIndex, + AnchorOutputInternalKey: a.InternalKey, + AnchorOutputBip32Derivation: a.Bip32Derivation, + AnchorOutputTaprootBip32Derivation: a.TaprootBip32Derivation, + AnchorOutputTapscriptSibling: sibling, + ScriptKey: scriptKey, + ProofDeliveryAddress: deliveryAddr, + LockTime: a.LockTime, + RelativeLockTime: uint64(a.Sequence), + AltLeaves: a.AltLeaves, + Address: a.Address, } // If we've allocated all pieces, or we don't need to allocate anything @@ -845,6 +857,8 @@ func setAllocationFieldsFromOutput(alloc *Allocation, vOut *tappsbt.VOutput) { alloc.AssetVersion = vOut.AssetVersion alloc.OutputIndex = vOut.AnchorOutputIndex alloc.InternalKey = vOut.AnchorOutputInternalKey + alloc.Bip32Derivation = vOut.AnchorOutputBip32Derivation + alloc.TaprootBip32Derivation = vOut.AnchorOutputTaprootBip32Derivation alloc.GenScriptKey = StaticScriptKeyGen(vOut.ScriptKey) alloc.Sequence = uint32(vOut.RelativeLockTime) alloc.LockTime = vOut.LockTime