Skip to content

Commit bb78e82

Browse files
committed
tapfreighter: add call to FetchZeroValueAnchorUTXOs to retrieve zero-value inputs
1 parent 1b52974 commit bb78e82

File tree

8 files changed

+297
-49
lines changed

8 files changed

+297
-49
lines changed

tapfreighter/chain_porter.go

Lines changed: 46 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -888,6 +888,7 @@ func (p *ChainPorter) storePackageAnchorTxConf(pkg *sendPackage) error {
888888
TxIndex: int32(pkg.TransferTxConfEvent.TxIndex),
889889
FinalProofs: pkg.FinalProofs,
890890
PassiveAssetProofFiles: passiveAssetProofFiles,
891+
ZeroValueInputs: pkg.ZeroValueInputs,
891892
}, burns)
892893
if err != nil {
893894
return fmt.Errorf("unable to log parcel delivery "+
@@ -1248,9 +1249,8 @@ func (p *ChainPorter) importLocalAddresses(ctx context.Context,
12481249
for idx := range parcel.Outputs {
12491250
out := &parcel.Outputs[idx]
12501251

1251-
// Skip non-local outputs, those are going to a receiver outside
1252-
// of this daemon.
1253-
if !out.ScriptKeyLocal {
1252+
// Determine if the output should be imported into the wallet.
1253+
if !out.IsSpendable() {
12541254
continue
12551255
}
12561256

@@ -1267,23 +1267,30 @@ func (p *ChainPorter) importLocalAddresses(ctx context.Context,
12671267
return err
12681268
}
12691269

1270+
log.Infof("Importing anchor output key for output %d "+
1271+
"(isTombstone=%v, isBurn=%v): outpoint=%v, key=%x",
1272+
idx, out.IsTombstone(), out.IsBurn(),
1273+
out.Anchor.OutPoint,
1274+
anchorOutputKey.SerializeCompressed())
1275+
12701276
// Before we broadcast the transaction to the network, we'll
12711277
// import the new anchor output into the wallet so it watches
12721278
// it for spends and also takes account of the BTC we used in
12731279
// the transfer.
12741280
_, err = p.cfg.Wallet.ImportTaprootOutput(ctx, anchorOutputKey)
1275-
switch {
1276-
case err == nil:
1277-
break
1278-
1279-
// On restart, we'll get an error that the output has already
1280-
// been added to the wallet, so we'll catch this now and move
1281-
// along if so.
1282-
case strings.Contains(err.Error(), "already exists"):
1283-
break
1281+
if err != nil {
1282+
// On restart, we'll get an error that the output has
1283+
// already been added to the wallet, so we'll catch this
1284+
// now and move along if so.
1285+
if strings.Contains(err.Error(), "already exists") {
1286+
log.Tracef("Anchor output key already exists "+
1287+
"(outpoint=%v): %w",
1288+
out.Anchor.OutPoint, err)
1289+
continue
1290+
}
12841291

1285-
default:
1286-
return err
1292+
return fmt.Errorf("unable to import anchor output "+
1293+
"key: %w", err)
12871294
}
12881295
}
12891296

@@ -1446,6 +1453,7 @@ func (p *ChainPorter) stateStep(currentPkg sendPackage) (*sendPackage, error) {
14461453

14471454
currentPkg.VirtualPackets = fundSendRes.VPackets
14481455
currentPkg.InputCommitments = fundSendRes.InputCommitments
1456+
currentPkg.ZeroValueInputs = fundSendRes.ZeroValueInputs
14491457

14501458
currentPkg.SendState = SendStateVirtualSign
14511459

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

15921600
anchorTx, err := wallet.AnchorVirtualTransactions(
15931601
ctx, &AnchorVTxnsParams{
1594-
FeeRate: feeRate,
1595-
ActivePackets: currentPkg.VirtualPackets,
1596-
PassivePackets: currentPkg.PassiveAssets,
1602+
FeeRate: feeRate,
1603+
ActivePackets: currentPkg.VirtualPackets,
1604+
PassivePackets: currentPkg.PassiveAssets,
1605+
ZeroValueInputs: currentPkg.ZeroValueInputs,
15971606
},
15981607
)
15991608
if err != nil {
@@ -1695,8 +1704,8 @@ func (p *ChainPorter) stateStep(currentPkg sendPackage) (*sendPackage, error) {
16951704
parcel, err := ConvertToTransfer(
16961705
currentHeight, currentPkg.VirtualPackets,
16971706
currentPkg.AnchorTx, currentPkg.PassiveAssets,
1698-
isLocalKey, currentPkg.Label,
1699-
currentPkg.SkipAnchorTxBroadcast,
1707+
currentPkg.ZeroValueInputs, isLocalKey,
1708+
currentPkg.Label, currentPkg.SkipAnchorTxBroadcast,
17001709
)
17011710
if err != nil {
17021711
p.unlockInputs(ctx, &currentPkg)
@@ -1715,6 +1724,8 @@ func (p *ChainPorter) stateStep(currentPkg sendPackage) (*sendPackage, error) {
17151724
// Write the parcel to disk as a pending parcel. This step also
17161725
// records the transfer details (e.g., reference to the anchor
17171726
// transaction ID, transfer outputs and inputs) to the database.
1727+
// This will also extend the leases for both asset inputs and
1728+
// zero-value UTXOs to prevent them from being used elsewhere.
17181729
err = p.cfg.ExportLog.LogPendingParcel(
17191730
ctx, parcel, defaultWalletLeaseIdentifier,
17201731
time.Now().Add(defaultBroadcastCoinLeaseDuration),
@@ -1883,9 +1894,8 @@ func (p *ChainPorter) unlockInputs(ctx context.Context, pkg *sendPackage) {
18831894
// sanity-check that we have known input commitments to unlock, since
18841895
// that might not always be the case (for example if another party
18851896
// contributes inputs).
1886-
if pkg.SendState < SendStateStorePreBroadcast &&
1887-
len(pkg.InputCommitments) > 0 {
1888-
1897+
// Also unlock any zero-value UTXOs that were leased for this package.
1898+
if pkg.SendState < SendStateStorePreBroadcast {
18891899
for prevID := range pkg.InputCommitments {
18901900
log.Debugf("Unlocking input %v", prevID.OutPoint)
18911901

@@ -1897,6 +1907,20 @@ func (p *ChainPorter) unlockInputs(ctx context.Context, pkg *sendPackage) {
18971907
prevID.OutPoint, err)
18981908
}
18991909
}
1910+
1911+
zeroValueOutpoints := fn.Map(
1912+
pkg.ZeroValueInputs,
1913+
func(z *ZeroValueInput) wire.OutPoint {
1914+
return z.OutPoint
1915+
},
1916+
)
1917+
1918+
err := p.cfg.AssetWallet.ReleaseCoins(
1919+
ctx, zeroValueOutpoints...,
1920+
)
1921+
if err != nil {
1922+
log.Warnf("Unable to unlock zero-value inputs: %v", err)
1923+
}
19001924
}
19011925

19021926
// If we're in another state, the anchor transaction has been created,

tapfreighter/coin_select.go

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -114,20 +114,6 @@ func (s *CoinSelect) SelectCoins(ctx context.Context,
114114
return selectedCoins, nil
115115
}
116116

117-
// LeaseCoins leases/locks/reserves coins for the given lease owner until the
118-
// given expiry. This is used to prevent multiple concurrent coin selection
119-
// attempts from selecting the same coin(s).
120-
func (s *CoinSelect) LeaseCoins(ctx context.Context, leaseOwner [32]byte,
121-
expiry time.Time, utxoOutpoints ...wire.OutPoint) error {
122-
123-
s.coinLock.Lock()
124-
defer s.coinLock.Unlock()
125-
126-
return s.coinLister.LeaseCoins(
127-
ctx, leaseOwner, expiry, utxoOutpoints...,
128-
)
129-
}
130-
131117
// ReleaseCoins releases/unlocks coins that were previously leased and makes
132118
// them available for coin selection again.
133119
func (s *CoinSelect) ReleaseCoins(ctx context.Context,
@@ -199,4 +185,46 @@ func (s *CoinSelect) selectForAmount(minTotalAmount uint64,
199185
return selectedCommitments, nil
200186
}
201187

188+
// SelectZeroValueCoins fetches all managed UTXOs that contain only
189+
// zero-value assets (tombstones and burns). The selected UTXOs are
190+
// leased for the default lease duration.
191+
func (s *CoinSelect) SelectZeroValueCoins(ctx context.Context) (
192+
[]*ZeroValueInput, error) {
193+
194+
s.coinLock.Lock()
195+
defer s.coinLock.Unlock()
196+
197+
// Fetch all zero-value UTXOs that are eligible for sweeping.
198+
zeroValueInputs, err := s.coinLister.FetchZeroValueAnchorUTXOs(ctx)
199+
if err != nil {
200+
return nil, fmt.Errorf("unable to fetch zero-value UTXOs: %w",
201+
err)
202+
}
203+
204+
// We now need to lock/lease/reserve those selected coins so
205+
// that they can't be used by other processes.
206+
if len(zeroValueInputs) > 0 {
207+
expiry := time.Now().Add(defaultCoinLeaseDuration)
208+
zeroValueOutpoints := fn.Map(
209+
zeroValueInputs,
210+
func(z *ZeroValueInput) wire.OutPoint {
211+
return z.OutPoint
212+
},
213+
)
214+
err = s.coinLister.LeaseCoins(
215+
ctx, defaultWalletLeaseIdentifier, expiry,
216+
zeroValueOutpoints...,
217+
)
218+
if err != nil {
219+
return nil, fmt.Errorf("unable to lease zero-value "+
220+
"UTXOs: %w", err)
221+
}
222+
223+
log.Debugf("Selected and leased %d zero-value UTXOs",
224+
len(zeroValueInputs))
225+
}
226+
227+
return zeroValueInputs, nil
228+
}
229+
202230
var _ CoinSelector = (*CoinSelect)(nil)

tapfreighter/coin_select_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ func (m *mockCoinLister) DeleteExpiredLeases(ctx context.Context) error {
6060
return nil
6161
}
6262

63+
func (m *mockCoinLister) FetchZeroValueAnchorUTXOs(
64+
context.Context) ([]*ZeroValueInput, error) {
65+
66+
return nil, nil
67+
}
68+
6369
// TestCoinSelector tests that the coin selector behaves as expected.
6470
func TestCoinSelector(t *testing.T) {
6571
var (

tapfreighter/fund.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ import (
2626
func createFundedPacketWithInputs(ctx context.Context, exporter proof.Exporter,
2727
keyRing KeyRing, addrBook AddrBook, fundDesc *tapsend.FundingDescriptor,
2828
vPktTemplate *tappsbt.VPacket,
29-
selectedCommitments []*AnchoredCommitment) (*FundedVPacket, error) {
29+
selectedCommitments []*AnchoredCommitment,
30+
zeroValueInputs []*ZeroValueInput) (*FundedVPacket, error) {
3031

3132
if vPktTemplate.ChainParams == nil {
3233
return nil, errors.New("chain params not set in virtual packet")
@@ -159,6 +160,7 @@ func createFundedPacketWithInputs(ctx context.Context, exporter proof.Exporter,
159160
return &FundedVPacket{
160161
VPackets: allPackets,
161162
InputCommitments: inputCommitments,
163+
ZeroValueInputs: zeroValueInputs,
162164
}, nil
163165
}
164166

tapfreighter/fund_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,7 @@ func TestFundPacket(t *testing.T) {
321321
vPkt *tappsbt.VPacket
322322
inputProofs []*proof.Proof
323323
selectedCommitments []*AnchoredCommitment
324+
zeroValueInputs []*ZeroValueInput
324325
keysDerived int
325326
expectedErr string
326327
expectedInputCommitments tappsbt.InputCommitments
@@ -350,6 +351,28 @@ func TestFundPacket(t *testing.T) {
350351
Commitment: inputCommitment,
351352
Asset: &inputAsset,
352353
}},
354+
zeroValueInputs: []*ZeroValueInput{
355+
{
356+
OutPoint: wire.OutPoint{
357+
Hash: test.RandHash(),
358+
Index: 1,
359+
},
360+
OutputValue: 1000,
361+
InternalKey: internalKey,
362+
MerkleRoot: test.RandBytes(32),
363+
PkScript: test.RandBytes(34),
364+
},
365+
{
366+
OutPoint: wire.OutPoint{
367+
Hash: test.RandHash(),
368+
Index: 0,
369+
},
370+
OutputValue: 546,
371+
InternalKey: internalKey,
372+
MerkleRoot: test.RandBytes(32),
373+
PkScript: test.RandBytes(34),
374+
},
375+
},
353376
keysDerived: 3,
354377
expectedInputCommitments: tappsbt.InputCommitments{
355378
inputPrevID: inputCommitment,
@@ -765,6 +788,7 @@ func TestFundPacket(t *testing.T) {
765788
result, err := createFundedPacketWithInputs(
766789
ctx, exporter, keyRing, addrBook,
767790
tc.fundDesc, tc.vPkt, tc.selectedCommitments,
791+
tc.zeroValueInputs,
768792
)
769793

770794
keyRing.AssertNumberOfCalls(
@@ -788,6 +812,13 @@ func TestFundPacket(t *testing.T) {
788812
tt, result.VPackets,
789813
tc.expectedOutputs(tt, keyRing),
790814
)
815+
816+
// Verify zero-value inputs are correctly added to the
817+
// result.
818+
require.Len(
819+
tt, result.ZeroValueInputs,
820+
len(tc.zeroValueInputs),
821+
)
791822
})
792823
}
793824
}

tapfreighter/interface.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,11 @@ 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,
175+
error)
171176
}
172177

173178
// MultiCommitmentSelectStrategy is an enum that describes the strategy that
@@ -195,6 +200,29 @@ type CoinSelector interface {
195200
// ReleaseCoins releases/unlocks coins that were previously leased and
196201
// makes them available for coin selection again.
197202
ReleaseCoins(ctx context.Context, utxoOutpoints ...wire.OutPoint) error
203+
204+
// SelectZeroValueCoins selects all managed UTXOs that contain only
205+
// zero-value assets (tombstones and burns). The selected UTXOs are
206+
// leased for the default lease duration.
207+
SelectZeroValueCoins(ctx context.Context) ([]*ZeroValueInput, error)
208+
}
209+
210+
// ZeroValueInput represents a zero-value UTXO that should be swept.
211+
type ZeroValueInput struct {
212+
// OutPoint is the outpoint of the zero-value UTXO.
213+
OutPoint wire.OutPoint
214+
215+
// OutputValue is the satoshi value of the zero-value UTXO.
216+
OutputValue btcutil.Amount
217+
218+
// InternalKey is the internal key descriptor for the zero-value UTXO.
219+
InternalKey keychain.KeyDescriptor
220+
221+
// MerkleRoot is the taproot merkle root for the zero-value UTXO.
222+
MerkleRoot []byte
223+
224+
// PkScript is the pkScript of the anchor output.
225+
PkScript []byte
198226
}
199227

200228
// TransferInput represents the database level input to an asset transfer.
@@ -431,6 +459,29 @@ func (out *TransferOutput) UniqueKey() (OutputIdentifier, error) {
431459
), nil
432460
}
433461

462+
// IsTombstone returns true if the transfer output is a tombstone.
463+
func (out *TransferOutput) IsTombstone() bool {
464+
return out.Amount == 0 && out.ScriptKey.PubKey.IsEqual(asset.NUMSPubKey)
465+
}
466+
467+
// IsBurn returns true if the transfer output is a burn.
468+
func (out *TransferOutput) IsBurn() bool {
469+
return out.Amount == 0 && len(out.WitnessData) > 0 &&
470+
asset.IsBurnKey(
471+
out.ScriptKey.PubKey, out.WitnessData[0],
472+
)
473+
}
474+
475+
// IsLocal returns true if the transfer output is a local script key.
476+
func (out *TransferOutput) IsLocal() bool {
477+
return out.ScriptKeyLocal
478+
}
479+
480+
// IsSpendable returns true if the transfer output is spendable.
481+
func (out *TransferOutput) IsSpendable() bool {
482+
return out.IsLocal() || out.IsTombstone() || out.IsBurn()
483+
}
484+
434485
// OutboundParcel represents the database level delta of an outbound Taproot
435486
// Asset parcel (outbound spend). A spend will destroy a series of assets listed
436487
// as inputs, and re-create them as new outputs. Along the way some assets may
@@ -480,6 +531,10 @@ type OutboundParcel struct {
480531
// transfer.
481532
Outputs []TransferOutput
482533

534+
// ZeroValueInputs is the set of zero-value UTXOs that are being swept
535+
// as additional inputs to the Bitcoin transaction.
536+
ZeroValueInputs []*ZeroValueInput
537+
483538
// Label is a user provided label for the transfer.
484539
Label string
485540

@@ -498,6 +553,7 @@ func (o *OutboundParcel) Copy() *OutboundParcel {
498553
ChainFees: o.ChainFees,
499554
Inputs: fn.CopySlice(o.Inputs),
500555
Outputs: fn.CopySlice(o.Outputs),
556+
ZeroValueInputs: fn.CopySlice(o.ZeroValueInputs),
501557
Label: o.Label,
502558
SkipAnchorTxBroadcast: o.SkipAnchorTxBroadcast,
503559
}
@@ -574,6 +630,10 @@ type AssetConfirmEvent struct {
574630
// PassiveAssetProofFiles is the set of passive asset proof files that
575631
// are re-anchored during the parcel confirmation process.
576632
PassiveAssetProofFiles map[asset.ID][]*proof.AnnotatedProof
633+
634+
// ZeroValueInputs is the set of zero-value UTXOs that were swept as
635+
// additional inputs to the Bitcoin transaction.
636+
ZeroValueInputs []*ZeroValueInput
577637
}
578638

579639
// ExportLog is used to track the state of outbound Taproot Asset parcels

0 commit comments

Comments
 (0)