Skip to content

Commit 1b52974

Browse files
committed
tapdb: add support for zero-value utxo sweeping
This includes the addition of a "swept" field in the "managed_utxos" table with the corresponding migration and the "MarkManagedUTXOAsSwept" function.
1 parent 83a4116 commit 1b52974

File tree

9 files changed

+254
-11
lines changed

9 files changed

+254
-11
lines changed

tapdb/assets_store.go

Lines changed: 220 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,10 @@ type ActiveAssetsStore interface {
279279
// serialized outpoint.
280280
DeleteManagedUTXO(ctx context.Context, outpoint []byte) error
281281

282+
// MarkManagedUTXOAsSwept marks a managed UTXO as swept, indicating
283+
// it has been spent in a Bitcoin transaction.
284+
MarkManagedUTXOAsSwept(ctx context.Context, outpoint []byte) error
285+
282286
// UpdateUTXOLease leases a managed UTXO identified by the passed
283287
// serialized outpoint.
284288
UpdateUTXOLease(ctx context.Context, arg UpdateUTXOLease) error
@@ -489,6 +493,14 @@ type ManagedUTXO struct {
489493
// LeaseExpiry is the expiry time of the lease on this UTXO. If the
490494
// zero, then this UTXO isn't leased.
491495
LeaseExpiry time.Time
496+
497+
// PkScript is the pkScript of the anchor output. This is populated
498+
// when fetching zero-value anchor UTXOs to enable PSBT creation.
499+
PkScript []byte
500+
501+
// Swept indicates whether this UTXO has been used as input
502+
// in a Bitcoin transaction.
503+
Swept bool
492504
}
493505

494506
// AssetHumanReadable is a subset of the base asset struct that only includes
@@ -1309,6 +1321,7 @@ func (a *AssetStore) FetchManagedUTXOs(ctx context.Context) (
13091321
MerkleRoot: u.MerkleRoot,
13101322
TapscriptSibling: u.TapscriptSibling,
13111323
LeaseOwner: u.LeaseOwner,
1324+
Swept: u.Swept,
13121325
}
13131326
if u.LeaseExpiry.Valid {
13141327
utxo.LeaseExpiry = u.LeaseExpiry.Time
@@ -1320,6 +1333,170 @@ func (a *AssetStore) FetchManagedUTXOs(ctx context.Context) (
13201333
return managedUtxos, nil
13211334
}
13221335

1336+
// MarkManagedUTXOAsSwept marks a managed UTXO as swept, indicating it has been
1337+
// spent in a Bitcoin transaction.
1338+
func (a *AssetStore) MarkManagedUTXOAsSwept(ctx context.Context,
1339+
outpoint wire.OutPoint) error {
1340+
1341+
outpointBytes, err := encodeOutpoint(outpoint)
1342+
if err != nil {
1343+
return fmt.Errorf("unable to encode outpoint: %w", err)
1344+
}
1345+
1346+
var writeTxOpts AssetStoreTxOptions
1347+
return a.db.ExecTx(ctx, &writeTxOpts, func(q ActiveAssetsStore) error {
1348+
return q.MarkManagedUTXOAsSwept(ctx, outpointBytes)
1349+
})
1350+
}
1351+
1352+
// FetchZeroValueAnchorUTXOs fetches all managed UTXOs that contain only
1353+
// zero-value assets (tombstones and burns).
1354+
func (a *AssetStore) FetchZeroValueAnchorUTXOs(ctx context.Context) (
1355+
[]*tapfreighter.ZeroValueInput, error) {
1356+
1357+
// Strategy: fetch all managed UTXOs and filter in-memory.
1358+
// A UTXO is a "zero-value anchor" if all assets are either tombstones
1359+
// (NUMS key with amount 0) or burns.
1360+
// We exclude leased and spent UTXOs.
1361+
1362+
var results []*tapfreighter.ZeroValueInput
1363+
1364+
readOpts := NewAssetStoreReadTx()
1365+
now := a.clock.Now().UTC()
1366+
1367+
dbErr := a.db.ExecTx(ctx, &readOpts, func(q ActiveAssetsStore) error {
1368+
utxos, err := q.FetchManagedUTXOs(ctx)
1369+
if err != nil {
1370+
return err
1371+
}
1372+
1373+
for _, u := range utxos {
1374+
if len(u.LeaseOwner) > 0 &&
1375+
u.LeaseExpiry.Valid &&
1376+
u.LeaseExpiry.Time.UTC().After(now) {
1377+
1378+
continue
1379+
}
1380+
1381+
if u.Swept {
1382+
continue
1383+
}
1384+
1385+
var anchorPoint wire.OutPoint
1386+
err := readOutPoint(
1387+
bytes.NewReader(u.Outpoint), 0, 0, &anchorPoint)
1388+
if err != nil {
1389+
return err
1390+
}
1391+
1392+
// Query all assets anchored at this outpoint.
1393+
// We include spent assets here because tombstones are
1394+
// marked as spent when created.
1395+
assetsAtAnchor, err := a.queryChainAssets(
1396+
ctx, q, QueryAssetFilters{
1397+
AnchorPoint: u.Outpoint,
1398+
Now: sql.NullTime{
1399+
Time: now,
1400+
Valid: true,
1401+
},
1402+
},
1403+
)
1404+
if err != nil {
1405+
return fmt.Errorf("failed to query assets at "+
1406+
"anchor: %w", err)
1407+
}
1408+
1409+
if len(assetsAtAnchor) == 0 {
1410+
continue
1411+
}
1412+
1413+
// Determine if all assets are tombstones or burns.
1414+
// A tombstone asset is marked as "spent" at the asset
1415+
// level but its anchor UTXO may still be unspent
1416+
// on-chain and available for sweeping.
1417+
allZeroValue := true
1418+
for _, chainAsset := range assetsAtAnchor {
1419+
aAsset := chainAsset.Asset
1420+
1421+
isTombstone := aAsset.Amount == 0 &&
1422+
aAsset.ScriptKey.PubKey.IsEqual(
1423+
asset.NUMSPubKey,
1424+
)
1425+
1426+
isBurn := len(aAsset.PrevWitnesses) > 0 &&
1427+
asset.IsBurnKey(
1428+
aAsset.ScriptKey.PubKey,
1429+
aAsset.PrevWitnesses[0],
1430+
)
1431+
1432+
if !isTombstone && !isBurn {
1433+
allZeroValue = false
1434+
break
1435+
}
1436+
}
1437+
1438+
if !allZeroValue {
1439+
continue
1440+
}
1441+
1442+
log.Debugf("Adding zero-value anchor to sweep list "+
1443+
"(outpoint=%s)", anchorPoint.String())
1444+
1445+
internalKey, err := btcec.ParsePubKey(u.RawKey)
1446+
if err != nil {
1447+
return err
1448+
}
1449+
1450+
// Fetch the chain transaction to get the actual
1451+
// pkScript.
1452+
chainTx, err := q.FetchChainTx(ctx, anchorPoint.Hash[:])
1453+
if err != nil {
1454+
log.Warnf("Failed to fetch chain tx for "+
1455+
"%v: %v, skipping", anchorPoint, err)
1456+
continue
1457+
}
1458+
1459+
// Extract the pkScript from the transaction.
1460+
var tx wire.MsgTx
1461+
err = tx.Deserialize(
1462+
bytes.NewReader(chainTx.RawTx),
1463+
)
1464+
if err != nil {
1465+
log.Warnf("Failed to deserialize tx for "+
1466+
"%v: %v, skipping", anchorPoint, err)
1467+
continue
1468+
}
1469+
1470+
pkScript := tx.TxOut[anchorPoint.Index].PkScript
1471+
1472+
mu := &tapfreighter.ZeroValueInput{
1473+
OutPoint: anchorPoint,
1474+
OutputValue: btcutil.Amount(u.AmtSats),
1475+
InternalKey: keychain.KeyDescriptor{
1476+
PubKey: internalKey,
1477+
KeyLocator: keychain.KeyLocator{
1478+
Index: uint32(u.KeyIndex),
1479+
Family: keychain.KeyFamily(
1480+
u.KeyFamily,
1481+
),
1482+
},
1483+
},
1484+
MerkleRoot: u.MerkleRoot,
1485+
PkScript: pkScript,
1486+
}
1487+
1488+
results = append(results, mu)
1489+
}
1490+
1491+
return nil
1492+
})
1493+
if dbErr != nil {
1494+
return nil, dbErr
1495+
}
1496+
1497+
return results, nil
1498+
}
1499+
13231500
// FetchAssetProofsSizes fetches the sizes of the proofs in the db.
13241501
func (a *AssetStore) FetchAssetProofsSizes(
13251502
ctx context.Context) ([]AssetProofSize, error) {
@@ -2476,6 +2653,30 @@ func (a *AssetStore) LogPendingParcel(ctx context.Context,
24762653
}
24772654
}
24782655

2656+
// Also extend leases for any zero-value UTXOs being swept.
2657+
for _, zeroValueInput := range spend.ZeroValueInputs {
2658+
outpointBytes, err := encodeOutpoint(
2659+
zeroValueInput.OutPoint,
2660+
)
2661+
if err != nil {
2662+
return fmt.Errorf("unable to encode "+
2663+
"zero-value outpoint: %w", err)
2664+
}
2665+
2666+
err = q.UpdateUTXOLease(ctx, UpdateUTXOLease{
2667+
LeaseOwner: finalLeaseOwner[:],
2668+
LeaseExpiry: sql.NullTime{
2669+
Time: finalLeaseExpiry.UTC(),
2670+
Valid: true,
2671+
},
2672+
Outpoint: outpointBytes,
2673+
})
2674+
if err != nil {
2675+
return fmt.Errorf("unable to extend "+
2676+
" zero-value UTXO lease: %w", err)
2677+
}
2678+
}
2679+
24792680
// Then the passive assets.
24802681
if len(spend.PassiveAssets) > 0 {
24812682
if spend.PassiveAssetsAnchor == nil {
@@ -3302,9 +3503,25 @@ func (a *AssetStore) LogAnchorTxConfirm(ctx context.Context,
33023503
// Keep the old proofs as a reference for when we list past
33033504
// transfers.
33043505

3305-
// At this point we could delete the managed UTXO since it's no
3306-
// longer an unspent output, however we'll keep it in order to
3307-
// be able to reconstruct transfer history.
3506+
// Mark all zero-value UTXOs as swept since they were spent
3507+
// as additional inputs to the Bitcoin transaction.
3508+
for _, zeroValueInput := range conf.ZeroValueInputs {
3509+
outpoint := zeroValueInput.OutPoint
3510+
outpointBytes, err := encodeOutpoint(outpoint)
3511+
if err != nil {
3512+
return fmt.Errorf("failed to encode "+
3513+
"zero-value outpoint: %w", err)
3514+
}
3515+
3516+
err = q.MarkManagedUTXOAsSwept(ctx, outpointBytes)
3517+
if err != nil {
3518+
return fmt.Errorf("unable to mark zero-value "+
3519+
"UTXO as swept: %w", err)
3520+
}
3521+
3522+
log.Infof("Marked zero-value UTXO %v as swept",
3523+
outpoint)
3524+
}
33083525

33093526
// We now insert in the DB any burns that may have been present
33103527
// in the transfer.

tapdb/migrations.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const (
2424
// daemon.
2525
//
2626
// NOTE: This MUST be updated when a new migration is added.
27-
LatestMigrationVersion = 47
27+
LatestMigrationVersion = 48
2828
)
2929

3030
// DatabaseBackend is an interface that contains all methods our different

tapdb/sqlc/assets.sql.go

Lines changed: 19 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- Remove swept flag from managed_utxos table
2+
ALTER TABLE managed_utxos DROP COLUMN swept;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- Add swept flag to managed_utxos table to track when UTXOs have been swept
2+
ALTER TABLE managed_utxos ADD COLUMN swept BOOLEAN NOT NULL DEFAULT FALSE;

tapdb/sqlc/models.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/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: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -608,9 +608,9 @@ WITH target_key(key_id) AS (
608608
)
609609
INSERT INTO managed_utxos (
610610
outpoint, amt_sats, internal_key_id, tapscript_sibling, merkle_root, txn_id,
611-
taproot_asset_root, root_version
611+
taproot_asset_root, root_version, swept
612612
) VALUES (
613-
$2, $3, (SELECT key_id FROM target_key), $4, $5, $6, $7, $8
613+
$2, $3, (SELECT key_id FROM target_key), $4, $5, $6, $7, $8, FALSE
614614
) ON CONFLICT (outpoint)
615615
-- Not a NOP but instead update any nullable fields that aren't null in the
616616
-- args.
@@ -855,6 +855,11 @@ ORDER BY witness_index;
855855
DELETE FROM managed_utxos
856856
WHERE outpoint = $1;
857857

858+
-- name: MarkManagedUTXOAsSwept :exec
859+
UPDATE managed_utxos
860+
SET swept = TRUE
861+
WHERE outpoint = $1;
862+
858863
-- name: UpdateUTXOLease :exec
859864
UPDATE managed_utxos
860865
SET lease_owner = @lease_owner, lease_expiry = @lease_expiry

0 commit comments

Comments
 (0)