Skip to content

Commit 6fe11a5

Browse files
committed
feat: implement zero-value UTXO sweeping mechanism
- Add database query to fetch managed UTXOs containing only zero-value assets (tombstones/burns) - Implement ZeroValueInput interface for accessing UTXO details - Modify PSBT creation to include zero-value inputs with proper BIP32 derivation - Update integration test to verify sweeping functionality - Add sweeping logic to automatically include tombstone inputs in new on-chain transactions Note: LND limitation prevents imported Taproot keys from being signed, so sweeping requires LND changes to fully work.
1 parent c2b0a37 commit 6fe11a5

File tree

11 files changed

+330
-11
lines changed

11 files changed

+330
-11
lines changed

itest/test_list_on_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ var allTestCases = []*testCase{
101101
name: "min relay fee bump",
102102
test: testMinRelayFeeBump,
103103
},
104+
{
105+
name: "zero value anchor sweep",
106+
test: testZeroValueAnchorSweep,
107+
},
104108
{
105109
name: "restart receiver check balance",
106110
test: testRestartReceiverCheckBalance,

rpcserver.go

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1419,14 +1419,6 @@ func (r *rpcServer) ListUtxos(ctx context.Context,
14191419
utxos[op] = utxo
14201420
}
14211421

1422-
// As a final pass, we'll prune out any UTXOs that don't have any
1423-
// assets, as these may be in the DB just for record keeping.
1424-
for _, utxo := range utxos {
1425-
if len(utxo.Assets) == 0 {
1426-
delete(utxos, utxo.OutPoint)
1427-
}
1428-
}
1429-
14301422
return &taprpc.ListUtxosResponse{
14311423
ManagedUtxos: utxos,
14321424
}, nil

tapdb/assets_store.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,26 @@ type ManagedUTXO struct {
491491
LeaseExpiry time.Time
492492
}
493493

494+
// GetOutPoint returns the outpoint of the zero-value UTXO.
495+
func (m *ManagedUTXO) GetOutPoint() wire.OutPoint {
496+
return m.OutPoint
497+
}
498+
499+
// GetOutputValue returns the satoshi value of the zero-value UTXO.
500+
func (m *ManagedUTXO) GetOutputValue() btcutil.Amount {
501+
return m.OutputValue
502+
}
503+
504+
// GetInternalKey returns the internal key descriptor for the zero-value UTXO.
505+
func (m *ManagedUTXO) GetInternalKey() keychain.KeyDescriptor {
506+
return m.InternalKey
507+
}
508+
509+
// GetMerkleRoot returns the taproot merkle root for the zero-value UTXO.
510+
func (m *ManagedUTXO) GetMerkleRoot() []byte {
511+
return m.MerkleRoot
512+
}
513+
494514
// AssetHumanReadable is a subset of the base asset struct that only includes
495515
// human-readable asset fields.
496516
type AssetHumanReadable struct {
@@ -1320,6 +1340,68 @@ func (a *AssetStore) FetchManagedUTXOs(ctx context.Context) (
13201340
return managedUtxos, nil
13211341
}
13221342

1343+
// FetchZeroValueAnchorUTXOs fetches all managed UTXOs that contain only
1344+
// zero-value assets (tombstones and burns).
1345+
func (a *AssetStore) FetchZeroValueAnchorUTXOs(ctx context.Context) (
1346+
[]tapfreighter.ZeroValueInput, error) {
1347+
1348+
var (
1349+
utxos []sqlc.FetchZeroValueAnchorUTXOsRow
1350+
err error
1351+
)
1352+
1353+
readOpts := NewAssetStoreReadTx()
1354+
dbErr := a.db.ExecTx(ctx, &readOpts, func(q ActiveAssetsStore) error {
1355+
// Cast to sqlc.Querier to access FetchZeroValueAnchorUTXOs
1356+
utxos, err = q.(sqlc.Querier).FetchZeroValueAnchorUTXOs(ctx)
1357+
return err
1358+
})
1359+
if dbErr != nil {
1360+
return nil, dbErr
1361+
}
1362+
1363+
managedUtxos := make([]tapfreighter.ZeroValueInput, len(utxos))
1364+
for i, u := range utxos {
1365+
var anchorPoint wire.OutPoint
1366+
err := readOutPoint(
1367+
bytes.NewReader(u.Outpoint), 0, 0, &anchorPoint,
1368+
)
1369+
if err != nil {
1370+
return nil, err
1371+
}
1372+
1373+
internalKey, err := btcec.ParsePubKey(u.RawKey)
1374+
if err != nil {
1375+
return nil, err
1376+
}
1377+
1378+
utxo := &ManagedUTXO{
1379+
OutPoint: anchorPoint,
1380+
OutputValue: btcutil.Amount(u.AmtSats),
1381+
InternalKey: keychain.KeyDescriptor{
1382+
PubKey: internalKey,
1383+
KeyLocator: keychain.KeyLocator{
1384+
Index: uint32(u.KeyIndex),
1385+
Family: keychain.KeyFamily(
1386+
u.KeyFamily,
1387+
),
1388+
},
1389+
},
1390+
TaprootAssetRoot: u.TaprootAssetRoot,
1391+
MerkleRoot: u.MerkleRoot,
1392+
TapscriptSibling: u.TapscriptSibling,
1393+
LeaseOwner: u.LeaseOwner,
1394+
}
1395+
if u.LeaseExpiry.Valid {
1396+
utxo.LeaseExpiry = u.LeaseExpiry.Time
1397+
}
1398+
1399+
managedUtxos[i] = utxo
1400+
}
1401+
1402+
return managedUtxos, nil
1403+
}
1404+
13231405
// FetchAssetProofsSizes fetches the sizes of the proofs in the db.
13241406
func (a *AssetStore) FetchAssetProofsSizes(
13251407
ctx context.Context) ([]AssetProofSize, error) {

tapdb/sqlc/assets.sql.go

Lines changed: 89 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tapdb/sqlc/querier.go

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tapdb/sqlc/queries/assets.sql

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,37 @@ FROM managed_utxos utxos
633633
JOIN internal_keys keys
634634
ON utxos.internal_key_id = keys.key_id;
635635

636+
-- name: FetchZeroValueAnchorUTXOs :many
637+
WITH zero_value_assets AS (
638+
SELECT DISTINCT anchor_utxo_id
639+
FROM assets a
640+
JOIN script_keys sk ON a.script_key_id = sk.script_key_id
641+
JOIN internal_keys ik ON sk.internal_key_id = ik.key_id
642+
LEFT JOIN asset_witnesses aw ON a.asset_id = aw.asset_id
643+
WHERE (
644+
-- Tombstones: zero amount with NUMS key
645+
(a.amount = 0 AND ik.raw_key = '\x02\x7c\x79\xb9\xb2\x6e\x46\x38\x95\xee\xf5\x67\x9d\x85\x58\x94\x2c\x86\xc4\xad\x22\x33\xad\xef\x01\xbc\x3e\x6d\x54\x0b\x36\x53\xfe') OR
646+
-- Burns: assets with burn witnesses
647+
(aw.witness_id IS NOT NULL AND aw.witness_stack IS NOT NULL)
648+
)
649+
AND a.spent = FALSE
650+
),
651+
anchor_asset_counts AS (
652+
SELECT
653+
anchor_utxo_id,
654+
COUNT(*) as total_assets,
655+
COUNT(CASE WHEN anchor_utxo_id IN (SELECT anchor_utxo_id FROM zero_value_assets) THEN 1 END) as zero_value_assets
656+
FROM assets
657+
WHERE spent = FALSE
658+
GROUP BY anchor_utxo_id
659+
)
660+
SELECT utxos.*, keys.*
661+
FROM managed_utxos utxos
662+
JOIN internal_keys keys ON utxos.internal_key_id = keys.key_id
663+
JOIN anchor_asset_counts aac ON utxos.utxo_id = aac.anchor_utxo_id
664+
WHERE aac.total_assets = aac.zero_value_assets
665+
AND utxos.lease_owner IS NULL;
666+
636667
-- name: AnchorPendingAssets :exec
637668
WITH assets_to_update AS (
638669
SELECT script_key_id

tapfreighter/chain_porter.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1446,6 +1446,7 @@ func (p *ChainPorter) stateStep(currentPkg sendPackage) (*sendPackage, error) {
14461446

14471447
currentPkg.VirtualPackets = fundSendRes.VPackets
14481448
currentPkg.InputCommitments = fundSendRes.InputCommitments
1449+
currentPkg.ZeroValueInputs = fundSendRes.ZeroValueInputs
14491450

14501451
currentPkg.SendState = SendStateVirtualSign
14511452

@@ -1591,9 +1592,10 @@ func (p *ChainPorter) stateStep(currentPkg sendPackage) (*sendPackage, error) {
15911592

15921593
anchorTx, err := wallet.AnchorVirtualTransactions(
15931594
ctx, &AnchorVTxnsParams{
1594-
FeeRate: feeRate,
1595-
ActivePackets: currentPkg.VirtualPackets,
1596-
PassivePackets: currentPkg.PassiveAssets,
1595+
FeeRate: feeRate,
1596+
ActivePackets: currentPkg.VirtualPackets,
1597+
PassivePackets: currentPkg.PassiveAssets,
1598+
ZeroValueInputs: currentPkg.ZeroValueInputs,
15971599
},
15981600
)
15991601
if err != nil {

tapfreighter/coin_select.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,4 +199,15 @@ func (s *CoinSelect) selectForAmount(minTotalAmount uint64,
199199
return selectedCommitments, nil
200200
}
201201

202+
// FetchZeroValueAnchorUTXOs fetches all managed UTXOs that contain only
203+
// zero-value assets (tombstones and burns).
204+
func (s *CoinSelect) FetchZeroValueAnchorUTXOs(ctx context.Context) (
205+
[]ZeroValueInput, error) {
206+
207+
s.coinLock.Lock()
208+
defer s.coinLock.Unlock()
209+
210+
return s.coinLister.FetchZeroValueAnchorUTXOs(ctx)
211+
}
212+
202213
var _ CoinSelector = (*CoinSelect)(nil)

tapfreighter/interface.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,10 @@ type CoinLister interface {
168168

169169
// DeleteExpiredLeases deletes all expired leases from the database.
170170
DeleteExpiredLeases(ctx context.Context) error
171+
172+
// FetchZeroValueAnchorUTXOs fetches all managed UTXOs that contain only
173+
// zero-value assets (tombstones and burns).
174+
FetchZeroValueAnchorUTXOs(ctx context.Context) ([]ZeroValueInput, error)
171175
}
172176

173177
// MultiCommitmentSelectStrategy is an enum that describes the strategy that
@@ -195,6 +199,25 @@ type CoinSelector interface {
195199
// ReleaseCoins releases/unlocks coins that were previously leased and
196200
// makes them available for coin selection again.
197201
ReleaseCoins(ctx context.Context, utxoOutpoints ...wire.OutPoint) error
202+
203+
// FetchZeroValueAnchorUTXOs fetches all managed UTXOs that contain only
204+
// zero-value assets (tombstones and burns).
205+
FetchZeroValueAnchorUTXOs(ctx context.Context) ([]ZeroValueInput, error)
206+
}
207+
208+
// ZeroValueInput represents a zero-value UTXO that should be swept.
209+
type ZeroValueInput interface {
210+
// GetOutPoint returns the outpoint of the zero-value UTXO.
211+
GetOutPoint() wire.OutPoint
212+
213+
// GetOutputValue returns the satoshi value of the zero-value UTXO.
214+
GetOutputValue() btcutil.Amount
215+
216+
// GetInternalKey returns the internal key descriptor for the zero-value UTXO.
217+
GetInternalKey() keychain.KeyDescriptor
218+
219+
// GetMerkleRoot returns the taproot merkle root for the zero-value UTXO.
220+
GetMerkleRoot() []byte
198221
}
199222

200223
// TransferInput represents the database level input to an asset transfer.

tapfreighter/parcel.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,10 @@ type sendPackage struct {
486486
// associated Taproot Asset commitment.
487487
InputCommitments tappsbt.InputCommitments
488488

489+
// ZeroValueInputs is a list of zero-value UTXOs that should be swept
490+
// as additional inputs to the transaction.
491+
ZeroValueInputs []ZeroValueInput
492+
489493
// SendManifests is a map of send manifests that need to be sent to the
490494
// auth mailbox server to complete an address V2 transfer. It is keyed
491495
// by the anchor output index.

0 commit comments

Comments
 (0)