@@ -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.
13241501func (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.
0 commit comments