@@ -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,39 @@ 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
504+ }
505+
506+ // GetOutPoint returns the outpoint of the zero-value UTXO.
507+ func (m * ManagedUTXO ) GetOutPoint () wire.OutPoint {
508+ return m .OutPoint
509+ }
510+
511+ // GetOutputValue returns the satoshi value of the zero-value UTXO.
512+ func (m * ManagedUTXO ) GetOutputValue () btcutil.Amount {
513+ return m .OutputValue
514+ }
515+
516+ // GetInternalKey returns the internal key descriptor for the zero-value UTXO.
517+ func (m * ManagedUTXO ) GetInternalKey () keychain.KeyDescriptor {
518+ return m .InternalKey
519+ }
520+
521+ // GetMerkleRoot returns the taproot merkle root for the zero-value UTXO.
522+ func (m * ManagedUTXO ) GetMerkleRoot () []byte {
523+ return m .MerkleRoot
524+ }
525+
526+ // GetPkScript returns the pkScript of the anchor output.
527+ func (m * ManagedUTXO ) GetPkScript () []byte {
528+ return m .PkScript
492529}
493530
494531// AssetHumanReadable is a subset of the base asset struct that only includes
@@ -1309,6 +1346,7 @@ func (a *AssetStore) FetchManagedUTXOs(ctx context.Context) (
13091346 MerkleRoot : u .MerkleRoot ,
13101347 TapscriptSibling : u .TapscriptSibling ,
13111348 LeaseOwner : u .LeaseOwner ,
1349+ Swept : u .Swept ,
13121350 }
13131351 if u .LeaseExpiry .Valid {
13141352 utxo .LeaseExpiry = u .LeaseExpiry .Time
@@ -1320,6 +1358,179 @@ func (a *AssetStore) FetchManagedUTXOs(ctx context.Context) (
13201358 return managedUtxos , nil
13211359}
13221360
1361+ // MarkManagedUTXOAsSwept marks a managed UTXO as swept, indicating it has been
1362+ // spent in a Bitcoin transaction.
1363+ func (a * AssetStore ) MarkManagedUTXOAsSwept (ctx context.Context ,
1364+ outpoint wire.OutPoint ) error {
1365+
1366+ outpointBytes , err := encodeOutpoint (outpoint )
1367+ if err != nil {
1368+ return fmt .Errorf ("unable to encode outpoint: %w" , err )
1369+ }
1370+
1371+ var writeTxOpts AssetStoreTxOptions
1372+ return a .db .ExecTx (ctx , & writeTxOpts , func (q ActiveAssetsStore ) error {
1373+ return q .MarkManagedUTXOAsSwept (ctx , outpointBytes )
1374+ })
1375+ }
1376+
1377+ // FetchZeroValueAnchorUTXOs fetches all managed UTXOs that contain only
1378+ // zero-value assets (tombstones and burns).
1379+ func (a * AssetStore ) FetchZeroValueAnchorUTXOs (ctx context.Context ) (
1380+ []tapfreighter.ZeroValueInput , error ) {
1381+
1382+ // Strategy: fetch all managed UTXOs and filter in-memory.
1383+ // A UTXO is a "zero-value anchor" if all assets are either tombstones
1384+ // (NUMS key with amount 0) or burns.
1385+ // We exclude leased and spent UTXOs.
1386+
1387+ var results []tapfreighter.ZeroValueInput
1388+
1389+ readOpts := NewAssetStoreReadTx ()
1390+ now := a .clock .Now ().UTC ()
1391+
1392+ dbErr := a .db .ExecTx (ctx , & readOpts , func (q ActiveAssetsStore ) error {
1393+ utxos , err := q .FetchManagedUTXOs (ctx )
1394+ if err != nil {
1395+ return err
1396+ }
1397+
1398+ for _ , u := range utxos {
1399+ // Skip UTXOs that are leased.
1400+ if len (u .LeaseOwner ) > 0 &&
1401+ u .LeaseExpiry .Valid &&
1402+ u .LeaseExpiry .Time .UTC ().After (now ) {
1403+
1404+ continue
1405+ }
1406+
1407+ var anchorPoint wire.OutPoint
1408+ err := readOutPoint (
1409+ bytes .NewReader (u .Outpoint ), 0 , 0 , & anchorPoint )
1410+ if err != nil {
1411+ return err
1412+ }
1413+
1414+ // Skip UTXOs that have already been swept.
1415+ if u .Swept {
1416+ continue
1417+ }
1418+
1419+ // Query all assets anchored at this outpoint.
1420+ // We include spent assets here because tombstones are
1421+ // marked as spent when created.
1422+ anchorPointBytes , err := encodeOutpoint (anchorPoint )
1423+ if err != nil {
1424+ return err
1425+ }
1426+
1427+ assetsAtAnchor , err := a .queryChainAssets (
1428+ ctx , q , QueryAssetFilters {
1429+ AnchorPoint : anchorPointBytes ,
1430+ Now : sql.NullTime {
1431+ Time : now ,
1432+ Valid : true ,
1433+ },
1434+ },
1435+ )
1436+ if err != nil {
1437+ return err
1438+ }
1439+
1440+ // Skip If we don't have any assets recorded.
1441+ if len (assetsAtAnchor ) == 0 {
1442+ continue
1443+ }
1444+
1445+ // Determine if all assets are tombstones or burns.
1446+ // A tombstone asset is marked as "spent" at the asset
1447+ // level but its anchor UTXO may still be unspent
1448+ // on-chain and available for sweeping.
1449+ allZeroValue := true
1450+ for _ , chainAsset := range assetsAtAnchor {
1451+ aAsset := chainAsset .Asset
1452+
1453+ isTombstone := aAsset .Amount == 0 &&
1454+ aAsset .ScriptKey .PubKey .IsEqual (
1455+ asset .NUMSPubKey ,
1456+ )
1457+
1458+ isBurn := len (aAsset .PrevWitnesses ) > 0 &&
1459+ asset .IsBurnKey (
1460+ aAsset .ScriptKey .PubKey ,
1461+ aAsset .PrevWitnesses [0 ],
1462+ )
1463+
1464+ if ! isTombstone && ! isBurn {
1465+ allZeroValue = false
1466+ break
1467+ }
1468+ }
1469+
1470+ if ! allZeroValue {
1471+ continue
1472+ }
1473+
1474+ log .Debugf ("Anchor %v is zero-value, adding to " +
1475+ "sweep list" , anchorPoint )
1476+
1477+ internalKey , err := btcec .ParsePubKey (u .RawKey )
1478+ if err != nil {
1479+ return err
1480+ }
1481+
1482+ // Fetch the chain transaction to get the actual
1483+ // pkScript.
1484+ chainTx , err := q .FetchChainTx (ctx , anchorPoint .Hash [:])
1485+ if err != nil {
1486+ log .Warnf ("Failed to fetch chain tx for " +
1487+ "%v: %v, skipping" , anchorPoint , err )
1488+ continue
1489+ }
1490+
1491+ // Extract the pkScript from the transaction.
1492+ var tx wire.MsgTx
1493+ if err := tx .Deserialize (
1494+ bytes .NewReader (chainTx .RawTx ),
1495+ ); err != nil {
1496+ log .Warnf ("Failed to deserialize tx for " +
1497+ "%v: %v, skipping" , anchorPoint , err )
1498+ continue
1499+ }
1500+ pkScript := tx .TxOut [anchorPoint .Index ].PkScript
1501+
1502+ mu := & ManagedUTXO {
1503+ OutPoint : anchorPoint ,
1504+ OutputValue : btcutil .Amount (u .AmtSats ),
1505+ InternalKey : keychain.KeyDescriptor {
1506+ PubKey : internalKey ,
1507+ KeyLocator : keychain.KeyLocator {
1508+ Index : uint32 (u .KeyIndex ),
1509+ Family : keychain .KeyFamily (
1510+ u .KeyFamily ,
1511+ ),
1512+ },
1513+ },
1514+ TaprootAssetRoot : u .TaprootAssetRoot ,
1515+ MerkleRoot : u .MerkleRoot ,
1516+ TapscriptSibling : u .TapscriptSibling ,
1517+ LeaseOwner : u .LeaseOwner ,
1518+ PkScript : pkScript ,
1519+ LeaseExpiry : u .LeaseExpiry .Time ,
1520+ }
1521+
1522+ results = append (results , mu )
1523+ }
1524+
1525+ return nil
1526+ })
1527+ if dbErr != nil {
1528+ return nil , dbErr
1529+ }
1530+
1531+ return results , nil
1532+ }
1533+
13231534// FetchAssetProofsSizes fetches the sizes of the proofs in the db.
13241535func (a * AssetStore ) FetchAssetProofsSizes (
13251536 ctx context.Context ) ([]AssetProofSize , error ) {
@@ -3302,9 +3513,25 @@ func (a *AssetStore) LogAnchorTxConfirm(ctx context.Context,
33023513 // Keep the old proofs as a reference for when we list past
33033514 // transfers.
33043515
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.
3516+ // Mark all zero-value UTXOs as swept since they were spent
3517+ // as additional inputs to the Bitcoin transaction.
3518+ for _ , zeroValueInput := range conf .ZeroValueInputs {
3519+ outpoint := zeroValueInput .GetOutPoint ()
3520+ outpointBytes , err := encodeOutpoint (outpoint )
3521+ if err != nil {
3522+ return fmt .Errorf ("failed to encode " +
3523+ "zero-value outpoint: %w" , err )
3524+ }
3525+
3526+ err = q .MarkManagedUTXOAsSwept (ctx , outpointBytes )
3527+ if err != nil {
3528+ return fmt .Errorf ("unable to mark zero-value " +
3529+ "UTXO as swept: %w" , err )
3530+ }
3531+
3532+ log .Infof ("Marked zero-value UTXO %v as swept" ,
3533+ outpoint )
3534+ }
33083535
33093536 // We now insert in the DB any burns that may have been present
33103537 // in the transfer.
0 commit comments