@@ -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,159 @@ 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+ if ! aAsset .IsTombstone () && ! aAsset .IsBurn () {
1422+ allZeroValue = false
1423+ break
1424+ }
1425+ }
1426+
1427+ if ! allZeroValue {
1428+ continue
1429+ }
1430+
1431+ log .Debugf ("Adding zero-value anchor to sweep list " +
1432+ "(outpoint=%s)" , anchorPoint .String ())
1433+
1434+ internalKey , err := btcec .ParsePubKey (u .RawKey )
1435+ if err != nil {
1436+ return err
1437+ }
1438+
1439+ // Fetch the chain transaction to get the actual
1440+ // pkScript.
1441+ chainTx , err := q .FetchChainTx (ctx , anchorPoint .Hash [:])
1442+ if err != nil {
1443+ log .Warnf ("Failed to fetch chain tx for " +
1444+ "%v: %v, skipping" , anchorPoint , err )
1445+ continue
1446+ }
1447+
1448+ // Extract the pkScript from the transaction.
1449+ var tx wire.MsgTx
1450+ err = tx .Deserialize (
1451+ bytes .NewReader (chainTx .RawTx ),
1452+ )
1453+ if err != nil {
1454+ log .Warnf ("Failed to deserialize tx for " +
1455+ "%v: %v, skipping" , anchorPoint , err )
1456+ continue
1457+ }
1458+
1459+ pkScript := tx .TxOut [anchorPoint .Index ].PkScript
1460+
1461+ mu := & tapfreighter.ZeroValueInput {
1462+ OutPoint : anchorPoint ,
1463+ OutputValue : btcutil .Amount (u .AmtSats ),
1464+ InternalKey : keychain.KeyDescriptor {
1465+ PubKey : internalKey ,
1466+ KeyLocator : keychain.KeyLocator {
1467+ Index : uint32 (u .KeyIndex ),
1468+ Family : keychain .KeyFamily (
1469+ u .KeyFamily ,
1470+ ),
1471+ },
1472+ },
1473+ MerkleRoot : u .MerkleRoot ,
1474+ PkScript : pkScript ,
1475+ }
1476+
1477+ results = append (results , mu )
1478+ }
1479+
1480+ return nil
1481+ })
1482+ if dbErr != nil {
1483+ return nil , dbErr
1484+ }
1485+
1486+ return results , nil
1487+ }
1488+
13231489// FetchAssetProofsSizes fetches the sizes of the proofs in the db.
13241490func (a * AssetStore ) FetchAssetProofsSizes (
13251491 ctx context.Context ) ([]AssetProofSize , error ) {
@@ -2476,6 +2642,30 @@ func (a *AssetStore) LogPendingParcel(ctx context.Context,
24762642 }
24772643 }
24782644
2645+ // Also extend leases for any zero-value UTXOs being swept.
2646+ for _ , zeroValueInput := range spend .ZeroValueInputs {
2647+ outpointBytes , err := encodeOutpoint (
2648+ zeroValueInput .OutPoint ,
2649+ )
2650+ if err != nil {
2651+ return fmt .Errorf ("unable to encode " +
2652+ "zero-value outpoint: %w" , err )
2653+ }
2654+
2655+ err = q .UpdateUTXOLease (ctx , UpdateUTXOLease {
2656+ LeaseOwner : finalLeaseOwner [:],
2657+ LeaseExpiry : sql.NullTime {
2658+ Time : finalLeaseExpiry .UTC (),
2659+ Valid : true ,
2660+ },
2661+ Outpoint : outpointBytes ,
2662+ })
2663+ if err != nil {
2664+ return fmt .Errorf ("unable to extend " +
2665+ " zero-value UTXO lease: %w" , err )
2666+ }
2667+ }
2668+
24792669 // Then the passive assets.
24802670 if len (spend .PassiveAssets ) > 0 {
24812671 if spend .PassiveAssetsAnchor == nil {
@@ -3302,9 +3492,25 @@ func (a *AssetStore) LogAnchorTxConfirm(ctx context.Context,
33023492 // Keep the old proofs as a reference for when we list past
33033493 // transfers.
33043494
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.
3495+ // Mark all zero-value UTXOs as swept since they were spent
3496+ // as additional inputs to the Bitcoin transaction.
3497+ for _ , zeroValueInput := range conf .ZeroValueInputs {
3498+ outpoint := zeroValueInput .OutPoint
3499+ outpointBytes , err := encodeOutpoint (outpoint )
3500+ if err != nil {
3501+ return fmt .Errorf ("failed to encode " +
3502+ "zero-value outpoint: %w" , err )
3503+ }
3504+
3505+ err = q .MarkManagedUTXOAsSwept (ctx , outpointBytes )
3506+ if err != nil {
3507+ return fmt .Errorf ("unable to mark zero-value " +
3508+ "UTXO as swept: %w" , err )
3509+ }
3510+
3511+ log .Debugf ("Marked zero-value UTXO %v as swept" ,
3512+ outpoint )
3513+ }
33083514
33093515 // We now insert in the DB any burns that may have been present
33103516 // in the transfer.
0 commit comments