Skip to content

Commit 5a25a46

Browse files
authored
Merge pull request #605 from lightninglabs/fee_estimation_testing
multi: anchor fee test coverage
2 parents 99c3e79 + 9a47eb8 commit 5a25a46

15 files changed

+484
-134
lines changed

cmd/tapcli/assets.go

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/lightninglabs/taproot-assets/tapcfg"
1111
"github.com/lightninglabs/taproot-assets/taprpc"
1212
"github.com/lightninglabs/taproot-assets/taprpc/mintrpc"
13+
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
1314
"github.com/urfave/cli"
1415
)
1516

@@ -50,7 +51,7 @@ var (
5051
groupByGroupName = "by_group"
5152
assetIDName = "asset_id"
5253
shortResponseName = "short"
53-
feeRateName = "fee_rate"
54+
feeRateName = "sat_per_vbyte"
5455
assetAmountName = "amount"
5556
burnOverrideConfirmationName = "override_confirmation_destroy_assets"
5657
)
@@ -261,7 +262,7 @@ var finalizeBatchCommand = cli.Command{
261262
},
262263
cli.Uint64Flag{
263264
Name: feeRateName,
264-
Usage: "if set, the fee rate in sat/kw to use for " +
265+
Usage: "if set, the fee rate in sat/vB to use for " +
265266
"the minting transaction",
266267
},
267268
},
@@ -270,11 +271,20 @@ var finalizeBatchCommand = cli.Command{
270271

271272
func parseFeeRate(ctx *cli.Context) (uint32, error) {
272273
if ctx.IsSet(feeRateName) {
273-
feeRate := ctx.Uint64(feeRateName)
274-
if feeRate > math.MaxUint32 {
274+
userFeeRate := ctx.Uint64(feeRateName)
275+
if userFeeRate > math.MaxUint32 {
275276
return 0, fmt.Errorf("fee rate exceeds 2^32")
276277
}
277278

279+
// Convert from sat/vB to sat/kw. Round up to the fee floor if
280+
// the specified feerate is too low.
281+
feeRate := chainfee.SatPerKVByte(userFeeRate * 1000).
282+
FeePerKWeight()
283+
284+
if feeRate < chainfee.FeePerKwFloor {
285+
feeRate = chainfee.FeePerKwFloor
286+
}
287+
278288
return uint32(feeRate), nil
279289
}
280290

@@ -531,7 +541,7 @@ var sendAssetsCommand = cli.Command{
531541
},
532542
cli.Uint64Flag{
533543
Name: feeRateName,
534-
Usage: "if set, the fee rate in sat/kw to use for " +
544+
Usage: "if set, the fee rate in sat/vB to use for " +
535545
"the anchor transaction",
536546
},
537547
// TODO(roasbeef): add arg for file name to write sender proof

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ require (
1010
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.2
1111
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f
1212
github.com/btcsuite/btcwallet v0.16.10-0.20231017144732-e3ff37491e9c
13+
github.com/btcsuite/btcwallet/wallet/txrules v1.2.0
14+
github.com/btcsuite/btcwallet/wallet/txsizes v1.2.3
1315
github.com/caddyserver/certmagic v0.17.2
1416
github.com/davecgh/go-spew v1.1.1
1517
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1
@@ -60,8 +62,6 @@ require (
6062
github.com/andybalholm/brotli v1.0.3 // indirect
6163
github.com/beorn7/perks v1.0.1 // indirect
6264
github.com/btcsuite/btcwallet/wallet/txauthor v1.3.2 // indirect
63-
github.com/btcsuite/btcwallet/wallet/txrules v1.2.0 // indirect
64-
github.com/btcsuite/btcwallet/wallet/txsizes v1.2.3 // indirect
6565
github.com/btcsuite/btcwallet/walletdb v1.4.0 // indirect
6666
github.com/btcsuite/btcwallet/wtxmgr v1.5.0 // indirect
6767
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd // indirect

itest/assertions.go

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212

1313
"github.com/btcsuite/btcd/btcec/v2"
1414
"github.com/btcsuite/btcd/btcec/v2/schnorr"
15+
"github.com/btcsuite/btcd/btcutil"
1516
"github.com/btcsuite/btcd/chaincfg/chainhash"
1617
"github.com/btcsuite/btcd/rpcclient"
1718
"github.com/btcsuite/btcd/wire"
@@ -24,6 +25,7 @@ import (
2425
"github.com/lightninglabs/taproot-assets/universe"
2526
"github.com/lightningnetwork/lnd/lnrpc/chainrpc"
2627
"github.com/lightningnetwork/lnd/lntest/wait"
28+
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
2729
"github.com/stretchr/testify/require"
2830
"golang.org/x/exp/maps"
2931
)
@@ -219,6 +221,75 @@ func AssertTxInBlock(t *testing.T, block *wire.MsgBlock,
219221
return nil
220222
}
221223

224+
// AssertTransferFeeRate checks that fee paid for the TX anchoring an asset
225+
// transfer is close to the expected fee for that TX, at a given fee rate.
226+
func AssertTransferFeeRate(t *testing.T, minerClient *rpcclient.Client,
227+
transferResp *taprpc.SendAssetResponse, inputAmt int64,
228+
feeRate chainfee.SatPerKWeight, roundFee bool) {
229+
230+
txid, err := chainhash.NewHash(transferResp.Transfer.AnchorTxHash)
231+
require.NoError(t, err)
232+
233+
AssertFeeRate(t, minerClient, inputAmt, txid, feeRate, roundFee)
234+
}
235+
236+
// AssertFeeRate checks that the fee paid for a given TX is close to the
237+
// expected fee for the same TX, at a given fee rate.
238+
func AssertFeeRate(t *testing.T, minerClient *rpcclient.Client, inputAmt int64,
239+
txid *chainhash.Hash, feeRate chainfee.SatPerKWeight, roundFee bool) {
240+
241+
var (
242+
outputValue float64
243+
expectedFee, maxOverpayment btcutil.Amount
244+
maxVsizeDifference = int64(2)
245+
)
246+
247+
verboseTx, err := minerClient.GetRawTransactionVerbose(txid)
248+
require.NoError(t, err)
249+
250+
vsize := verboseTx.Vsize
251+
for _, vout := range verboseTx.Vout {
252+
outputValue += vout.Value
253+
}
254+
255+
t.Logf("TX vsize of %d bytes", vsize)
256+
257+
btcOutputValue, err := btcutil.NewAmount(outputValue)
258+
require.NoError(t, err)
259+
260+
actualFee := inputAmt - int64(btcOutputValue)
261+
262+
switch {
263+
case roundFee:
264+
// Replicate the rounding performed when calling `FundPsbt`.
265+
feeSatPerVbyte := uint64(feeRate.FeePerKVByte()) / 1000
266+
roundedFeeRate := chainfee.SatPerKVByte(
267+
feeSatPerVbyte * 1000,
268+
).FeePerKWeight()
269+
270+
expectedFee = roundedFeeRate.FeePerKVByte().
271+
FeeForVSize(int64(vsize))
272+
maxOverpayment = roundedFeeRate.FeePerKVByte().
273+
FeeForVSize(maxVsizeDifference)
274+
275+
default:
276+
expectedFee = feeRate.FeePerKVByte().
277+
FeeForVSize(int64(vsize))
278+
maxOverpayment = feeRate.FeePerKVByte().
279+
FeeForVSize(maxVsizeDifference)
280+
}
281+
282+
// The actual fee may be higher than the expected fee after
283+
// confirmation, as the freighter makes a worst-case estimate of the TX
284+
// vsize. The gap between these two fees should still be small.
285+
require.GreaterOrEqual(t, actualFee, int64(expectedFee))
286+
287+
overpaidFee := actualFee - int64(expectedFee)
288+
require.LessOrEqual(t, overpaidFee, int64(maxOverpayment))
289+
290+
t.Logf("Correct fee of %d sats", actualFee)
291+
}
292+
222293
// WaitForBatchState polls until the planter has reached the desired state with
223294
// the given batch.
224295
func WaitForBatchState(t *testing.T, ctx context.Context,
@@ -640,16 +711,16 @@ func ConfirmAndAssertOutboundTransfer(t *testing.T,
640711
expectedAmounts []uint64, currentTransferIdx,
641712
numTransfers int) *wire.MsgBlock {
642713

643-
return ConfirmAndAssetOutboundTransferWithOutputs(
714+
return ConfirmAndAssertOutboundTransferWithOutputs(
644715
t, minerClient, sender, sendResp, assetID, expectedAmounts,
645716
currentTransferIdx, numTransfers, 2,
646717
)
647718
}
648719

649-
// ConfirmAndAssetOutboundTransferWithOutputs makes sure the given outbound
720+
// ConfirmAndAssertOutboundTransferWithOutputs makes sure the given outbound
650721
// transfer has the correct state and number of outputs before confirming it and
651722
// then asserting the confirmed state with the node.
652-
func ConfirmAndAssetOutboundTransferWithOutputs(t *testing.T,
723+
func ConfirmAndAssertOutboundTransferWithOutputs(t *testing.T,
653724
minerClient *rpcclient.Client, sender TapdClient,
654725
sendResp *taprpc.SendAssetResponse, assetID []byte,
655726
expectedAmounts []uint64, currentTransferIdx,

itest/burn_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ func testBurnAssets(t *harnessTest) {
9999
},
100100
)
101101
require.NoError(t.t, err)
102-
ConfirmAndAssetOutboundTransferWithOutputs(
102+
ConfirmAndAssertOutboundTransferWithOutputs(
103103
t.t, minerClient, t.tapd, sendResp, simpleAssetGen.AssetId,
104104
outputAmounts, 0, 1, numOutputs,
105105
)

itest/fee_estimation_test.go

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
package itest
2+
3+
import (
4+
"context"
5+
6+
"github.com/btcsuite/btcd/btcutil"
7+
"github.com/btcsuite/btcd/wire"
8+
"github.com/lightninglabs/taproot-assets/taprpc"
9+
"github.com/lightningnetwork/lnd/lnrpc"
10+
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
// testFeeEstimation tests that we're able to spend outputs of various script
15+
// types, and that the fee estimator and TX size estimator used during asset
16+
// transfers are accurate.
17+
func testFeeEstimation(t *harnessTest) {
18+
var (
19+
// Make a ladder of UTXO values so use order is deterministic.
20+
anchorAmounts = []int64{10000, 9990, 9980, 9970}
21+
22+
// The default feerate in the itests is 12.5 sat/vB, but we
23+
// define it here explicitly to use for assertions.
24+
defaultFeeRate = chainfee.SatPerKWeight(3125)
25+
higherFeeRate = defaultFeeRate * 2
26+
excessiveFeeRate = defaultFeeRate * 8
27+
lowFeeRate = chainfee.SatPerKWeight(500)
28+
29+
// We will mint assets using the largest NP2WKH output, and then
30+
// use all three output types for transfers.
31+
initialUTXOs = []*UTXORequest{
32+
{
33+
Type: lnrpc.AddressType_NESTED_PUBKEY_HASH,
34+
Amount: anchorAmounts[0],
35+
},
36+
{
37+
Type: lnrpc.AddressType_NESTED_PUBKEY_HASH,
38+
Amount: anchorAmounts[1],
39+
},
40+
{
41+
Type: lnrpc.AddressType_WITNESS_PUBKEY_HASH,
42+
Amount: anchorAmounts[2],
43+
},
44+
{
45+
Type: lnrpc.AddressType_TAPROOT_PUBKEY,
46+
Amount: anchorAmounts[3],
47+
},
48+
}
49+
)
50+
51+
ctxb := context.Background()
52+
ctxt, cancel := context.WithTimeout(ctxb, defaultWaitTimeout)
53+
defer cancel()
54+
55+
// Set the initial state of the wallet of the first node. The wallet
56+
// state will reset at the end of this test.
57+
SetNodeUTXOs(t, t.lndHarness.Alice, btcutil.Amount(1), initialUTXOs)
58+
defer ResetNodeWallet(t, t.lndHarness.Alice)
59+
60+
// Mint some assets with a NP2WPKH input, which will give us an anchor
61+
// output to spend for a transfer.
62+
rpcAssets := MintAssetsConfirmBatch(
63+
t.t, t.lndHarness.Miner.Client, t.tapd, simpleAssets,
64+
)
65+
66+
// Check the final fee rate of the mint TX.
67+
rpcMintOutpoint := rpcAssets[0].ChainAnchor.AnchorOutpoint
68+
mintOutpoint, err := wire.NewOutPointFromString(rpcMintOutpoint)
69+
require.NoError(t.t, err)
70+
71+
// We check the minting TX with a rounded fee rate as the minter does
72+
// not adjust the fee rate of the TX after it was funded by our backing
73+
// wallet.
74+
AssertFeeRate(
75+
t.t, t.lndHarness.Miner.Client, anchorAmounts[0],
76+
&mintOutpoint.Hash, defaultFeeRate, true,
77+
)
78+
79+
// Split the normal asset to create a transfer with two anchor outputs.
80+
normalAssetId := rpcAssets[0].AssetGenesis.AssetId
81+
splitAmount := rpcAssets[0].Amount / 2
82+
addr, err := t.tapd.NewAddr(
83+
ctxt, &taprpc.NewAddrRequest{
84+
AssetId: normalAssetId,
85+
Amt: splitAmount,
86+
},
87+
)
88+
require.NoError(t.t, err)
89+
90+
AssertAddrCreated(t.t, t.tapd, rpcAssets[0], addr)
91+
sendResp := sendAssetsToAddr(t, t.tapd, addr)
92+
93+
transferIdx := 0
94+
ConfirmAndAssertOutboundTransfer(
95+
t.t, t.lndHarness.Miner.Client, t.tapd, sendResp, normalAssetId,
96+
[]uint64{splitAmount, splitAmount}, transferIdx, transferIdx+1,
97+
)
98+
transferIdx += 1
99+
AssertNonInteractiveRecvComplete(t.t, t.tapd, transferIdx)
100+
101+
sendInputAmt := anchorAmounts[1] + 1000
102+
AssertTransferFeeRate(
103+
t.t, t.lndHarness.Miner.Client, sendResp, sendInputAmt,
104+
defaultFeeRate, false,
105+
)
106+
107+
// Double the fee rate to 25 sat/vB before performing another transfer.
108+
t.lndHarness.SetFeeEstimateWithConf(higherFeeRate, 6)
109+
110+
secondSplitAmount := splitAmount / 2
111+
addr2, err := t.tapd.NewAddr(
112+
ctxt, &taprpc.NewAddrRequest{
113+
AssetId: normalAssetId,
114+
Amt: secondSplitAmount,
115+
},
116+
)
117+
require.NoError(t.t, err)
118+
119+
AssertAddrCreated(t.t, t.tapd, rpcAssets[0], addr2)
120+
sendResp = sendAssetsToAddr(t, t.tapd, addr2)
121+
122+
ConfirmAndAssertOutboundTransfer(
123+
t.t, t.lndHarness.Miner.Client, t.tapd, sendResp, normalAssetId,
124+
[]uint64{secondSplitAmount, secondSplitAmount},
125+
transferIdx, transferIdx+1,
126+
)
127+
transferIdx += 1
128+
AssertNonInteractiveRecvComplete(t.t, t.tapd, transferIdx)
129+
130+
sendInputAmt = anchorAmounts[2] + 1000
131+
AssertTransferFeeRate(
132+
t.t, t.lndHarness.Miner.Client, sendResp, sendInputAmt,
133+
higherFeeRate, false,
134+
)
135+
136+
// If we quadruple the fee rate, the freighter should fail during input
137+
// selection.
138+
t.lndHarness.SetFeeEstimateWithConf(excessiveFeeRate, 6)
139+
140+
thirdSplitAmount := splitAmount / 4
141+
addr3, err := t.tapd.NewAddr(
142+
ctxt, &taprpc.NewAddrRequest{
143+
AssetId: normalAssetId,
144+
Amt: thirdSplitAmount,
145+
},
146+
)
147+
require.NoError(t.t, err)
148+
149+
AssertAddrCreated(t.t, t.tapd, rpcAssets[0], addr3)
150+
_, err = t.tapd.SendAsset(ctxt, &taprpc.SendAssetRequest{
151+
TapAddrs: []string{addr3.Encoded},
152+
})
153+
require.ErrorContains(t.t, err, "insufficient funds available")
154+
155+
// The transfer should also be rejected if the manually-specified
156+
// feerate fails the sanity check against the fee estimator's fee floor
157+
// of 253 sat/kw, or 1.012 sat/vB.
158+
_, err = t.tapd.SendAsset(ctxt, &taprpc.SendAssetRequest{
159+
TapAddrs: []string{addr3.Encoded},
160+
FeeRate: uint32(chainfee.FeePerKwFloor) - 1,
161+
})
162+
require.ErrorContains(t.t, err, "manual fee rate below floor")
163+
// After failure at the high feerate, we should still be able to make a
164+
// transfer at a very low feerate.
165+
t.lndHarness.SetFeeEstimateWithConf(lowFeeRate, 6)
166+
sendResp = sendAssetsToAddr(t, t.tapd, addr3)
167+
168+
ConfirmAndAssertOutboundTransfer(
169+
t.t, t.lndHarness.Miner.Client, t.tapd, sendResp, normalAssetId,
170+
[]uint64{thirdSplitAmount, thirdSplitAmount},
171+
transferIdx, transferIdx+1,
172+
)
173+
transferIdx += 1
174+
AssertNonInteractiveRecvComplete(t.t, t.tapd, transferIdx)
175+
176+
sendInputAmt = anchorAmounts[3] + 1000
177+
AssertTransferFeeRate(
178+
t.t, t.lndHarness.Miner.Client, sendResp, sendInputAmt,
179+
lowFeeRate, false,
180+
)
181+
}

0 commit comments

Comments
 (0)