Skip to content

Commit

Permalink
itest: add fee estimation + input types itest
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
jharveyb committed Dec 12, 2023
1 parent c7999e8 commit e724f68
Show file tree
Hide file tree
Showing 3 changed files with 294 additions and 0 deletions.
58 changes: 58 additions & 0 deletions itest/assertions.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcec/v2/schnorr"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/rpcclient"
"github.com/btcsuite/btcd/wire"
Expand All @@ -24,6 +25,7 @@ import (
"github.com/lightninglabs/taproot-assets/universe"
"github.com/lightningnetwork/lnd/lnrpc/chainrpc"
"github.com/lightningnetwork/lnd/lntest/wait"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
"github.com/stretchr/testify/require"
"golang.org/x/exp/maps"
)
Expand Down Expand Up @@ -219,6 +221,62 @@ func AssertTxInBlock(t *testing.T, block *wire.MsgBlock,
return nil
}

// AssertFeeRate checks that the fee paid for a given TX is close to the
// expected fee for the same TX, at a given fee rate.
func AssertFeeRate(t *testing.T, minerClient *rpcclient.Client, inputAmt int64,
txid *chainhash.Hash, feeRate chainfee.SatPerKWeight, roundFee bool) {

var (
outputValue float64
expectedFee, maxOverpayment btcutil.Amount
maxVsizeDifference = int64(2)
)

verboseTx, err := minerClient.GetRawTransactionVerbose(txid)
require.NoError(t, err)

vsize := verboseTx.Vsize
for _, vout := range verboseTx.Vout {
outputValue += vout.Value
}

t.Logf("TX vsize of %d bytes", vsize)

btcOutputValue, err := btcutil.NewAmount(outputValue)
require.NoError(t, err)

actualFee := inputAmt - int64(btcOutputValue)

switch {
case roundFee:
// Replicate the rounding performed when calling `FundPsbt`.
feeSatPerVbyte := uint64(feeRate.FeePerKVByte()) / 1000
roundedFeeRate := chainfee.SatPerKVByte(
feeSatPerVbyte * 1000,
).FeePerKWeight()
expectedFee = roundedFeeRate.FeePerKVByte().
FeeForVSize(int64(vsize))
maxOverpayment = roundedFeeRate.FeePerKVByte().
FeeForVSize(maxVsizeDifference)

default:
expectedFee = feeRate.FeePerKVByte().
FeeForVSize(int64(vsize))
maxOverpayment = feeRate.FeePerKVByte().
FeeForVSize(maxVsizeDifference)
}

// The actual fee may be higher than the expected fee after
// confirmation, as the freighter makes a worst-case estimate of the TX
// vsize. The gap between these two fees should still be small.
require.GreaterOrEqual(t, actualFee, int64(expectedFee))

overpaidFee := actualFee - int64(expectedFee)
require.LessOrEqual(t, overpaidFee, int64(maxOverpayment))

t.Logf("Correct fee of %d sats", actualFee)
}

// WaitForBatchState polls until the planter has reached the desired state with
// the given batch.
func WaitForBatchState(t *testing.T, ctx context.Context,
Expand Down
232 changes: 232 additions & 0 deletions itest/fee_estimation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
package itest

import (
"context"

"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/taproot-assets/fn"
"github.com/lightninglabs/taproot-assets/taprpc"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
"github.com/stretchr/testify/require"
)

type UTXORequest struct {
Type lnrpc.AddressType
Amount int64
}

// SetAnchorUTXOs sets the wallet state for the sending node to a set of UTXOs
// of a specific type and value.
func SetAnchorUTXO(t *harnessTest, reqs []*UTXORequest) {
// Burn funds that were sent to the first node.
minerAddr := t.lndHarness.Miner.NewMinerAddress()
primaryWallet := t.lndHarness.Alice
// Initial state of Alice wallet is 100 UTXOs of 1 BTC each.

primaryWallet.RPC.SendCoins(&lnrpc.SendCoinsRequest{
Addr: minerAddr.EncodeAddress(),
SendAll: true,
})

t.lndHarness.MineBlocksAndAssertNumTxes(1, 1)

// Request a specific number of sats.
makeOutputs := func(req *UTXORequest) *wire.TxOut {
aliceAddrResp := primaryWallet.RPC.NewAddress(
&lnrpc.NewAddressRequest{
Type: req.Type,
},
)

addr, err := btcutil.DecodeAddress(
aliceAddrResp.Address, t.lndHarness.Miner.ActiveNet,
)
require.NoError(t.t, err)

addrScript, err := txscript.PayToAddrScript(addr)
require.NoError(t.t, err)

return &wire.TxOut{
PkScript: addrScript,
Value: req.Amount,
}
}

aliceOutputs := fn.Map(reqs, makeOutputs)
feeRate := btcutil.Amount(2)

_ = t.lndHarness.Miner.SendOutputsWithoutChange(aliceOutputs, feeRate)

t.lndHarness.MineBlocksAndAssertNumTxes(6, 1)
t.lndHarness.WaitForBlockchainSync(primaryWallet)
}

// testFeeEstimation tests that we're able to spend outputs of various script
// types, and that the fee estimator and TX size estimator used during asset
// transfers are accurate.
func testFeeEstimation(t *harnessTest) {
var (
// Make a ladder of UTXO values so use order is deterministic.
anchorAmounts = []int64{10000, 9990, 9980, 9970}

// The default feerate in the itests is 12.5 sat/vB, but we
// define it here explicitly to use for assertions.
defaultFeeRate = chainfee.SatPerKWeight(3125)
higherFeeRate = defaultFeeRate * 2
excessiveFeeRate = defaultFeeRate * 8
lowFeeRate = chainfee.SatPerKWeight(500)

// We will mint assets using the larget NP2WKH output, and then
// use all three output types for transfers.
initialUTXOs = []*UTXORequest{
{
Type: lnrpc.AddressType_NESTED_PUBKEY_HASH,
Amount: anchorAmounts[0],
},
{
Type: lnrpc.AddressType_NESTED_PUBKEY_HASH,
Amount: anchorAmounts[1],
},
{
Type: lnrpc.AddressType_WITNESS_PUBKEY_HASH,
Amount: anchorAmounts[2],
},
{
Type: lnrpc.AddressType_TAPROOT_PUBKEY,
Amount: anchorAmounts[3],
},
}
)

ctxb := context.Background()
ctxt, cancel := context.WithTimeout(ctxb, defaultWaitTimeout)
defer cancel()

// Reset the on-chain funds of the first node.
SetAnchorUTXO(t, initialUTXOs)

// Mint some assets with a NP2WPKH input, which will give us an anchor
// output to spend for a transfer.
rpcAssets := MintAssetsConfirmBatch(
t.t, t.lndHarness.Miner.Client, t.tapd, simpleAssets,
)

// Check the final fee rate of the mint TX.
rpcMintOutpoint := rpcAssets[0].ChainAnchor.AnchorOutpoint
mintOutpoint, err := wire.NewOutPointFromString(rpcMintOutpoint)
require.NoError(t.t, err)

// We check the minting TX with a rounded fee rate as the minter does
// not adjust the fee rate of the TX after it was funded by our backing
// wallet.
AssertFeeRate(
t.t, t.lndHarness.Miner.Client, anchorAmounts[0],
&mintOutpoint.Hash, defaultFeeRate, true,
)

// Split the normal asset to create a transfer with two anchor outputs.
normalAssetId := rpcAssets[0].AssetGenesis.AssetId
splitAmount := rpcAssets[0].Amount / 2
addr, err := t.tapd.NewAddr(
ctxt, &taprpc.NewAddrRequest{
AssetId: normalAssetId,
Amt: splitAmount,
},
)
require.NoError(t.t, err)
sendResp := sendAssetsToAddr(t, t.tapd, addr)

transferIdx := 0
ConfirmAndAssertOutboundTransfer(
t.t, t.lndHarness.Miner.Client, t.tapd, sendResp, normalAssetId,
[]uint64{splitAmount, splitAmount}, transferIdx, transferIdx+1,
)
transferIdx += 1
AssertNonInteractiveRecvComplete(t.t, t.tapd, transferIdx)

sendTxid, err := chainhash.NewHash(sendResp.Transfer.AnchorTxHash)
require.NoError(t.t, err)

sendInputAmt := anchorAmounts[1] + 1000
AssertFeeRate(
t.t, t.lndHarness.Miner.Client, sendInputAmt, sendTxid,
defaultFeeRate, false,
)

// Double the fee rate to 25 sat/vB before performing another transfer.
t.lndHarness.SetFeeEstimateWithConf(higherFeeRate, 6)

secondSplitAmount := splitAmount / 2
addr2, err := t.tapd.NewAddr(
ctxt, &taprpc.NewAddrRequest{
AssetId: normalAssetId,
Amt: secondSplitAmount,
},
)
require.NoError(t.t, err)
sendResp = sendAssetsToAddr(t, t.tapd, addr2)

ConfirmAndAssertOutboundTransfer(
t.t, t.lndHarness.Miner.Client, t.tapd, sendResp, normalAssetId,
[]uint64{secondSplitAmount, secondSplitAmount},
transferIdx, transferIdx+1,
)
transferIdx += 1
AssertNonInteractiveRecvComplete(t.t, t.tapd, transferIdx)

sendTxid, err = chainhash.NewHash(sendResp.Transfer.AnchorTxHash)
require.NoError(t.t, err)

sendInputAmt = anchorAmounts[2] + 1000
AssertFeeRate(
t.t, t.lndHarness.Miner.Client, sendInputAmt, sendTxid,
higherFeeRate, false,
)

// TODO(jhb): unit test for hitting dust limit?
// If we quadruple the fee rate, the freighter should fail during input
// input selection.
t.lndHarness.SetFeeEstimateWithConf(excessiveFeeRate, 6)

thirdSplitAmount := splitAmount / 4
addr3, err := t.tapd.NewAddr(
ctxt, &taprpc.NewAddrRequest{
AssetId: normalAssetId,
Amt: thirdSplitAmount,
},
)
require.NoError(t.t, err)

_, err = t.tapd.SendAsset(ctxt, &taprpc.SendAssetRequest{
TapAddrs: []string{addr3.Encoded},
})
require.ErrorContains(t.t, err, "insufficient funds available")

// After failure at the high feerate, we should still be able to make a
// transfer at a very low feerate.
t.lndHarness.SetFeeEstimateWithConf(lowFeeRate, 6)
sendResp = sendAssetsToAddr(t, t.tapd, addr3)

ConfirmAndAssertOutboundTransfer(
t.t, t.lndHarness.Miner.Client, t.tapd, sendResp, normalAssetId,
[]uint64{thirdSplitAmount, thirdSplitAmount},
transferIdx, transferIdx+1,
)
transferIdx += 1
AssertNonInteractiveRecvComplete(t.t, t.tapd, transferIdx)

sendTxid, err = chainhash.NewHash(sendResp.Transfer.AnchorTxHash)
require.NoError(t.t, err)

sendInputAmt = anchorAmounts[3] + 1000
AssertFeeRate(
t.t, t.lndHarness.Miner.Client, sendInputAmt, sendTxid,
lowFeeRate, false,
)

// TODO(jhb): Reset node balance to not crash later itests
}
4 changes: 4 additions & 0 deletions itest/test_list_on_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import (
)

var testCases = []*testCase{
{
name: "fee estimation",
test: testFeeEstimation,
},
{
name: "mint assets",
test: testMintAssets,
Expand Down

0 comments on commit e724f68

Please sign in to comment.