Skip to content

Commit b8624ed

Browse files
committed
itest: add fee estimation + input types itest
In this commit, we add a new itest that exercises the minter and freighter when using multiple input types like P2WKH and various fee rates.
1 parent 2267c60 commit b8624ed

File tree

4 files changed

+327
-0
lines changed

4 files changed

+327
-0
lines changed

itest/assertions.go

Lines changed: 71 additions & 0 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,

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+
}

itest/test_list_on_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,10 @@ var testCases = []*testCase{
183183
name: "universe federation",
184184
test: testUniverseFederation,
185185
},
186+
{
187+
name: "fee estimation",
188+
test: testFeeEstimation,
189+
},
186190
{
187191
name: "get info",
188192
test: testGetInfo,

itest/utils.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,15 @@ import (
1212
"github.com/btcsuite/btcd/chaincfg"
1313
"github.com/btcsuite/btcd/chaincfg/chainhash"
1414
"github.com/btcsuite/btcd/rpcclient"
15+
"github.com/btcsuite/btcd/txscript"
1516
"github.com/btcsuite/btcd/wire"
1617
"github.com/lightninglabs/taproot-assets/asset"
18+
"github.com/lightninglabs/taproot-assets/fn"
1719
"github.com/lightninglabs/taproot-assets/proof"
1820
"github.com/lightninglabs/taproot-assets/taprpc"
1921
"github.com/lightninglabs/taproot-assets/taprpc/mintrpc"
22+
"github.com/lightningnetwork/lnd/lnrpc"
23+
"github.com/lightningnetwork/lnd/lntest/node"
2024
"github.com/stretchr/testify/require"
2125
"google.golang.org/protobuf/proto"
2226
)
@@ -148,6 +152,73 @@ func MineBlocks(t *testing.T, client *rpcclient.Client,
148152
return blocks
149153
}
150154

155+
type UTXORequest struct {
156+
Type lnrpc.AddressType
157+
Amount int64
158+
}
159+
160+
// SetNodeUTXOs sets the wallet state for the given node wallet to a set of
161+
// UTXOs of a specific type and value.
162+
func SetNodeUTXOs(t *harnessTest, wallet *node.HarnessNode,
163+
feeRate btcutil.Amount, reqs []*UTXORequest) {
164+
165+
minerAddr := t.lndHarness.Miner.NewMinerAddress()
166+
167+
// Drain any funds held by the node.
168+
wallet.RPC.SendCoins(&lnrpc.SendCoinsRequest{
169+
Addr: minerAddr.EncodeAddress(),
170+
SendAll: true,
171+
})
172+
t.lndHarness.MineBlocksAndAssertNumTxes(1, 1)
173+
174+
// Build TXOs from the UTXO requests, which will be used by the miner
175+
// to build a TX.
176+
makeOutputs := func(req *UTXORequest) *wire.TxOut {
177+
addrResp := wallet.RPC.NewAddress(
178+
&lnrpc.NewAddressRequest{
179+
Type: req.Type,
180+
},
181+
)
182+
183+
addr, err := btcutil.DecodeAddress(
184+
addrResp.Address, t.lndHarness.Miner.ActiveNet,
185+
)
186+
require.NoError(t.t, err)
187+
188+
addrScript, err := txscript.PayToAddrScript(addr)
189+
require.NoError(t.t, err)
190+
191+
return &wire.TxOut{
192+
PkScript: addrScript,
193+
Value: req.Amount,
194+
}
195+
}
196+
197+
aliceOutputs := fn.Map(reqs, makeOutputs)
198+
199+
_ = t.lndHarness.Miner.SendOutputsWithoutChange(aliceOutputs, feeRate)
200+
t.lndHarness.MineBlocksAndAssertNumTxes(1, 1)
201+
t.lndHarness.WaitForBlockchainSync(wallet)
202+
}
203+
204+
// ResetNodeWallet sets the wallet state of the given node to own 100 P2TR UTXOs
205+
// of BTC, which matches the wallet state when initializing the itest harness.
206+
func ResetNodeWallet(t *harnessTest, wallet *node.HarnessNode) {
207+
const outputCount = 100
208+
const txoType = lnrpc.AddressType_TAPROOT_PUBKEY
209+
const outputValue = 1e8
210+
211+
resetReqs := make([]*UTXORequest, outputCount)
212+
for i := 0; i < outputCount; i++ {
213+
resetReqs[i] = &UTXORequest{
214+
txoType,
215+
outputValue,
216+
}
217+
}
218+
219+
SetNodeUTXOs(t, wallet, btcutil.Amount(1), resetReqs)
220+
}
221+
151222
type MintOption func(*MintOptions)
152223

153224
type MintOptions struct {

0 commit comments

Comments
 (0)