Merge pull request #10296 from hieblmi/estimate-inputs

estimatefee: tx fee estimate for selected inputs
This commit is contained in:
ziggieXXX authored and GitHub committed 2026-01-07 11:45:24 +01:00
commit 39a1421d15
12 files changed
+3660 -3314

No files matched your search

+2 -1
View File
@@ -16,6 +16,7 @@ import (
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
"github.com/lightningnetwork/lnd"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lnwallet/chanfunding"
"github.com/urfave/cli"
@@ -406,7 +407,7 @@ func openChannel(ctx *cli.Context) error {
if ctx.IsSet("utxo") {
utxos := ctx.StringSlice("utxo")
outpoints, err := UtxosToOutpoints(utxos)
outpoints, err := lnd.UtxosToOutpoints(utxos)
if err != nil {
return fmt.Errorf("unable to decode utxos: %w", err)
}
+28 -7
View File
@@ -402,6 +402,14 @@ var estimateFeeCommand = cli.Command{
"transaction *should* confirm in",
},
coinSelectionStrategyFlag,
cli.StringSliceFlag{
Name: "utxo",
Usage: "a utxo specified as outpoint(tx:idx) which " +
"will be used as input for the transaction " +
"to be estimated. This flag can be " +
"repeatedly used to specify multiple utxos " +
"as inputs.",
},
},
Action: actionDecorator(estimateFees),
}
@@ -423,10 +431,21 @@ func estimateFees(ctx *cli.Context) error {
client, cleanUp := getClient(ctx)
defer cleanUp()
var inputs []*lnrpc.OutPoint
if ctx.IsSet("utxo") {
utxos := ctx.StringSlice("utxo")
inputs, err = lnd.UtxosToOutpoints(utxos)
if err != nil {
return fmt.Errorf("unable to decode utxos: %w", err)
}
}
resp, err := client.EstimateFee(ctxc, &lnrpc.EstimateFeeRequest{
AddrToAmount: amountToAddr,
TargetConf: int32(ctx.Int64("conf_target")),
CoinSelectionStrategy: coinSelectionStrategy,
Inputs: inputs,
})
if err != nil {
return err
@@ -607,7 +626,7 @@ func sendCoins(ctx *cli.Context) error {
if ctx.IsSet("utxo") {
utxos := ctx.StringSlice("utxo")
outpoints, err = UtxosToOutpoints(utxos)
outpoints, err = lnd.UtxosToOutpoints(utxos)
if err != nil {
return fmt.Errorf("unable to decode utxos: %w", err)
}
@@ -784,12 +803,12 @@ func listUnspent(ctx *cli.Context) error {
// to stdout. At the moment, this filters out the raw txid bytes from
// each utxo's outpoint and only prints the txid string.
var listUnspentResp = struct {
Utxos []*Utxo `json:"utxos"`
Utxos []*lnd.Utxo `json:"utxos"`
}{
Utxos: make([]*Utxo, 0, len(resp.Utxos)),
Utxos: make([]*lnd.Utxo, 0, len(resp.Utxos)),
}
for _, protoUtxo := range resp.Utxos {
utxo := NewUtxoFromProto(protoUtxo)
utxo := lnd.NewUtxoFromProto(protoUtxo)
listUnspentResp.Utxos = append(listUnspentResp.Utxos, utxo)
}
@@ -2789,12 +2808,14 @@ func updateChannelPolicy(ctx *cli.Context) error {
// to stdout. At the moment, this filters out the raw txid bytes from
// each failed update's outpoint and only prints the txid string.
var listFailedUpdateResp = struct {
FailedUpdates []*FailedUpdate `json:"failed_updates"`
FailedUpdates []*lnd.FailedUpdate `json:"failed_updates"`
}{
FailedUpdates: make([]*FailedUpdate, 0, len(resp.FailedUpdates)),
FailedUpdates: make(
[]*lnd.FailedUpdate, 0, len(resp.FailedUpdates),
),
}
for _, protoUpdate := range resp.FailedUpdates {
failedUpdate := NewFailedUpdateFromProto(protoUpdate)
failedUpdate := lnd.NewFailedUpdateFromProto(protoUpdate)
listFailedUpdateResp.FailedUpdates = append(
listFailedUpdateResp.FailedUpdates, failedUpdate)
}
+11 -10
View File
@@ -20,6 +20,7 @@ import (
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire"
"github.com/lightningnetwork/lnd"
"github.com/lightningnetwork/lnd/lnrpc/walletrpc"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
"github.com/lightningnetwork/lnd/lnwallet/chanfunding"
@@ -330,7 +331,7 @@ func bumpFee(ctx *cli.Context) error {
}
// Validate and parse the relevant arguments/flags.
protoOutPoint, err := NewProtoOutPoint(ctx.Args().Get(0))
protoOutPoint, err := lnd.NewProtoOutPoint(ctx.Args().Get(0))
if err != nil {
return err
}
@@ -812,11 +813,11 @@ func removeTransaction(ctx *cli.Context) error {
// utxoLease contains JSON annotations for a lease on an unspent output.
type utxoLease struct {
ID string `json:"id"`
OutPoint OutPoint `json:"outpoint"`
Expiration uint64 `json:"expiration"`
PkScript []byte `json:"pk_script"`
Value uint64 `json:"value"`
ID string `json:"id"`
OutPoint lnd.OutPoint `json:"outpoint"`
Expiration uint64 `json:"expiration"`
PkScript []byte `json:"pk_script"`
Value uint64 `json:"value"`
}
// fundPsbtResponse is a struct that contains JSON annotations for nice result
@@ -1358,7 +1359,7 @@ func fundPsbt(ctx *cli.Context) error {
}
for idx, input := range inputs {
op, err := NewProtoOutPoint(input)
op, err := lnd.NewProtoOutPoint(input)
if err != nil {
return fmt.Errorf("error parsing "+
"UTXO outpoint %d: %v", idx,
@@ -1447,7 +1448,7 @@ func marshallLocks(lockedUtxos []*walletrpc.UtxoLease) []*utxoLease {
for idx, lock := range lockedUtxos {
jsonLocks[idx] = &utxoLease{
ID: hex.EncodeToString(lock.Id),
OutPoint: NewOutPointFromProto(lock.Outpoint),
OutPoint: lnd.NewOutPointFromProto(lock.Outpoint),
Expiration: lock.Expiration,
PkScript: lock.PkScript,
Value: lock.Value,
@@ -1578,7 +1579,7 @@ func leaseOutput(ctx *cli.Context) error {
}
outpointStr := ctx.String("outpoint")
outpoint, err := NewProtoOutPoint(outpointStr)
outpoint, err := lnd.NewProtoOutPoint(outpointStr)
if err != nil {
return fmt.Errorf("error parsing outpoint: %w", err)
}
@@ -1663,7 +1664,7 @@ func releaseOutput(ctx *cli.Context) error {
return fmt.Errorf("outpoint argument missing")
}
outpoint, err := NewProtoOutPoint(outpointStr)
outpoint, err := lnd.NewProtoOutPoint(outpointStr)
if err != nil {
return fmt.Errorf("error parsing outpoint: %w", err)
}
+17 -12
View File
@@ -1,6 +1,9 @@
package commands
import "github.com/lightningnetwork/lnd/lnrpc/walletrpc"
import (
"github.com/lightningnetwork/lnd"
"github.com/lightningnetwork/lnd/lnrpc/walletrpc"
)
// PendingSweep is a CLI-friendly type of the walletrpc.PendingSweep proto. We
// use this to show more useful string versions of byte slices and enums.
@@ -9,16 +12,16 @@ import "github.com/lightningnetwork/lnd/lnrpc/walletrpc"
// here. Instead, we should rely on the struct defined in the proto
// `PendingSweepsResponse` only.
type PendingSweep struct {
OutPoint OutPoint `json:"outpoint"`
WitnessType string `json:"witness_type"`
AmountSat uint32 `json:"amount_sat"`
SatPerVByte uint32 `json:"sat_per_vbyte"`
BroadcastAttempts uint32 `json:"broadcast_attempts"`
RequestedSatPerVByte uint32 `json:"requested_sat_per_vbyte"`
Immediate bool `json:"immediate"`
Budget uint64 `json:"budget"`
DeadlineHeight uint32 `json:"deadline_height"`
MaturityHeight uint32 `json:"maturity_height"`
OutPoint lnd.OutPoint `json:"outpoint"`
WitnessType string `json:"witness_type"`
AmountSat uint32 `json:"amount_sat"`
SatPerVByte uint32 `json:"sat_per_vbyte"`
BroadcastAttempts uint32 `json:"broadcast_attempts"`
RequestedSatPerVByte uint32 `json:"requested_sat_per_vbyte"`
Immediate bool `json:"immediate"`
Budget uint64 `json:"budget"`
DeadlineHeight uint32 `json:"deadline_height"`
MaturityHeight uint32 `json:"maturity_height"`
NextBroadcastHeight uint32 `json:"next_broadcast_height"`
RequestedConfTarget uint32 `json:"requested_conf_target"`
@@ -29,7 +32,9 @@ type PendingSweep struct {
// its corresponding CLI-friendly type.
func NewPendingSweepFromProto(pendingSweep *walletrpc.PendingSweep) *PendingSweep {
return &PendingSweep{
OutPoint: NewOutPointFromProto(pendingSweep.Outpoint),
OutPoint: lnd.NewOutPointFromProto(
pendingSweep.Outpoint,
),
WitnessType: pendingSweep.WitnessType.String(),
AmountSat: pendingSweep.AmountSat,
SatPerVByte: uint32(pendingSweep.SatPerVbyte),
@@ -64,8 +64,16 @@
it becomes available. These methods provide an alternative to the standard
`MuSig2RegisterNonces` workflow and are only supported in MuSig2 v1.0.0rc2.
* The `EstimateFee` RPC now supports [explicit input
selection](https://github.com/lightningnetwork/lnd/pull/10296). Users can
specify a list of inputs to use as transaction inputs via the new
`inputs` field in `EstimateFeeRequest`.
## lncli Additions
* The `estimatefee` command now supports the `--utxos` flag to specify explicit
inputs for fee estimation.
# Improvements
## Functional Updates
@@ -138,6 +146,7 @@
* Boris Nagaev
* Elle Mouton
* Erick Cestari
* hieblmi
* Mohamed Awnallah
* Nishant Bansal
* Pins
+8
View File
@@ -759,6 +759,14 @@ var allTestCases = []*lntest.TestCase{
Name: "estimate fee",
TestFunc: testEstimateFee,
},
{
Name: "estimate on chain fee with selected inputs",
TestFunc: testEstimateOnChainFeeWithSelectedInputs,
},
{
Name: "estimate on chain fee auto selected inputs",
TestFunc: testEstimateOnChainFeeAutoSelectedInputs,
},
}
// appendPrefixed is used to add a prefix to each test name in the subtests
+232
View File
@@ -0,0 +1,232 @@
package itest
import (
"fmt"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lnrpc/walletrpc"
"github.com/lightningnetwork/lnd/lntest"
"github.com/stretchr/testify/require"
)
// testEstimateOnChainFeeWithSelectedInputs tests that the EstimateFee RPC
// with specific input selection produces an accurate fee estimate that matches
// the actual fee when sending coins with the same inputs.
func testEstimateOnChainFeeWithSelectedInputs(ht *lntest.HarnessTest) {
// Create a new node for this test.
alice := ht.NewNode("Alice", nil)
// Fund Alice with multiple UTXOs of different amounts to give us
// several inputs to choose from.
const (
utxo1Amount = btcutil.Amount(500_000)
utxo2Amount = btcutil.Amount(300_000)
utxo3Amount = btcutil.Amount(200_000)
targetConf = 2
)
ht.FundCoins(utxo1Amount, alice)
ht.FundCoins(utxo2Amount, alice)
ht.FundCoins(utxo3Amount, alice)
// List Alice's UTXOs to get their outpoints.
utxosResp := alice.RPC.ListUnspent(&walletrpc.ListUnspentRequest{
MinConfs: 1,
MaxConfs: 1000,
})
require.GreaterOrEqual(ht, len(utxosResp.Utxos), 3,
"expected at least 3 UTXOs")
// Create a lookup map to find outpoints by amount.
utxoLookup := make(map[int64]*lnrpc.OutPoint)
for _, utxo := range utxosResp.Utxos {
utxoLookup[utxo.AmountSat] = utxo.Outpoint
}
// Select the first two UTXOs for our test.
selectedInputs := []*lnrpc.OutPoint{
utxoLookup[int64(utxo1Amount)],
utxoLookup[int64(utxo2Amount)],
}
require.NotNil(ht, selectedInputs[0], "first UTXO not found")
require.NotNil(ht, selectedInputs[1], "second UTXO not found")
// Generate a destination address for the transaction.
destAddrResp := alice.RPC.NewAddress(&lnrpc.NewAddressRequest{
Type: lnrpc.AddressType_WITNESS_PUBKEY_HASH,
})
// Amount to send (less than the sum of selected UTXOs to allow for
// fees and change).
const sendAmount = int64(400_000)
// Create an address-to-amount map for the transaction.
addrToAmount := map[string]int64{
destAddrResp.Address: sendAmount,
}
// Call EstimateFee with the selected inputs.
estimateReq := &lnrpc.EstimateFeeRequest{
AddrToAmount: addrToAmount,
TargetConf: targetConf,
Inputs: selectedInputs,
}
estimateResp, err := alice.RPC.LN.EstimateFee(ht.Context(), estimateReq)
require.NoError(ht, err, "EstimateFee failed")
ht.Logf("Fee estimate: %d sats, fee rate: %d sat/vbyte",
estimateResp.FeeSat, estimateResp.SatPerVbyte)
// Verify that the estimate response includes the inputs we specified.
require.Len(ht, estimateResp.Inputs, 2,
"expected 2 inputs in estimate response")
// Create a map of input outpoints from the estimate for an easy lookup.
estimateInputs := make(map[string]bool)
for _, input := range estimateResp.Inputs {
key := fmt.Sprintf("%s:%d", input.TxidStr, input.OutputIndex)
estimateInputs[key] = true
}
// Verify each selected outpoint is in the estimate.
for _, outpoint := range selectedInputs {
key := fmt.Sprintf("%s:%d", outpoint.TxidStr,
outpoint.OutputIndex)
require.True(ht, estimateInputs[key],
"outpoint %s not found in estimate inputs", key)
}
// Now actually send coins using the same parameters.
sendReq := &lnrpc.SendCoinsRequest{
Addr: destAddrResp.Address,
Amount: sendAmount,
TargetConf: targetConf,
Outpoints: selectedInputs,
}
sendResp := alice.RPC.SendCoins(sendReq)
txid := sendResp.Txid
ht.Logf("Transaction sent with txid: %s", txid)
// Get the transaction details to extract the actual fee paid.
txDetails := alice.RPC.GetTransactions(
&lnrpc.GetTransactionsRequest{
StartHeight: 0,
EndHeight: -1,
},
)
// Find our transaction in the list.
var actualFee int64
var found bool
for _, tx := range txDetails.Transactions {
if tx.TxHash == txid {
actualFee = tx.TotalFees
found = true
ht.Logf("Actual fee paid: %d sats", actualFee)
break
}
}
require.True(ht, found, "sent transaction not found")
require.EqualValues(
ht, estimateResp.FeeSat, actualFee, "fee estimate does not "+
"match actual fee",
)
// Mine the SendCoinsRequest.
ht.MineBlocksAndAssertNumTxes(1, 1)
}
// testEstimateOnChainFeeAutoSelectedInputs tests that the EstimateFee RPC
// without input selection allows the wallet to auto-select inputs, and that
// using those selected inputs in SendCoins produces a matching fee.
func testEstimateOnChainFeeAutoSelectedInputs(ht *lntest.HarnessTest) {
// Create a new node for this test.
alice := ht.NewNode("Alice", nil)
// Fund Alice with multiple UTXOs.
const (
utxo1Amount = btcutil.Amount(500_000)
utxo2Amount = btcutil.Amount(300_000)
utxo3Amount = btcutil.Amount(200_000)
targetConf = 2
)
ht.FundCoins(utxo1Amount, alice)
ht.FundCoins(utxo2Amount, alice)
ht.FundCoins(utxo3Amount, alice)
// Generate a destination address.
destAddrResp := alice.RPC.NewAddress(
&lnrpc.NewAddressRequest{
Type: lnrpc.AddressType_WITNESS_PUBKEY_HASH,
},
)
const sendAmount = int64(100_000)
addrToAmount := map[string]int64{
destAddrResp.Address: sendAmount,
}
// Estimate fee without specifying inputs (wallet auto-selects).
estimateReq := &lnrpc.EstimateFeeRequest{
AddrToAmount: addrToAmount,
TargetConf: targetConf,
}
estimateResp, err := alice.RPC.LN.EstimateFee(ht.Context(), estimateReq)
require.NoError(ht, err, "EstimateFee failed")
ht.Logf("Fee estimate (auto-select): %d sats, "+
"fee rate: %d sat/vbyte, inputs selected: %d",
estimateResp.FeeSat, estimateResp.SatPerVbyte,
len(estimateResp.Inputs))
// The estimate should have selected some inputs.
require.NotEmpty(ht, estimateResp.Inputs,
"estimate should have selected inputs")
// Send using the inputs that were selected by the estimate.
sendReq := &lnrpc.SendCoinsRequest{
Addr: destAddrResp.Address,
Amount: sendAmount,
TargetConf: targetConf,
Outpoints: estimateResp.Inputs,
}
sendResp := alice.RPC.SendCoins(sendReq)
txid := sendResp.Txid
ht.Logf("Transaction sent with txid: %s", txid)
// Get the actual fee.
txDetails := alice.RPC.GetTransactions(
&lnrpc.GetTransactionsRequest{
StartHeight: 0,
EndHeight: -1,
},
)
var actualFee int64
var found bool
for _, tx := range txDetails.Transactions {
if tx.TxHash == txid {
actualFee = tx.TotalFees
found = true
ht.Logf("Actual fee paid: %d sats", actualFee)
break
}
}
require.True(ht, found, "sent transaction not found")
require.EqualValues(
ht, estimateResp.FeeSat, actualFee, "fee estimate does not "+
"match actual fee",
)
// Mine the SendCoinsRequest.
ht.MineBlocksAndAssertNumTxes(1, 1)
}
+3307 -3281
View File
File diff suppressed because it is too large. Load diff
+6
View File
@@ -1219,6 +1219,9 @@ message EstimateFeeRequest {
// The strategy to use for selecting coins during fees estimation.
CoinSelectionStrategy coin_selection_strategy = 5;
// A list of selected inputs for the transaction.
repeated OutPoint inputs = 6;
}
message EstimateFeeResponse {
@@ -1231,6 +1234,9 @@ message EstimateFeeResponse {
// The fee rate in satoshi/vbyte.
uint64 sat_per_vbyte = 3;
// A list of selected inputs for the transaction the estimate is for.
repeated OutPoint inputs = 4;
}
message SendManyRequest {
+8
View File
@@ -5038,6 +5038,14 @@
"type": "string",
"format": "uint64",
"description": "The fee rate in satoshi/vbyte."
},
"inputs": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/lnrpcOutPoint"
},
"description": "A list of selected inputs for the transaction the estimate is for."
}
}
},
+31 -2
View File
@@ -1275,14 +1275,29 @@ func (r *rpcServer) EstimateFee(ctx context.Context,
return nil, err
}
var selectOutpoints fn.Set[wire.OutPoint]
if len(in.Inputs) != 0 {
wireOutpoints, err := toWireOutpoints(in.Inputs)
if err != nil {
return nil, fmt.Errorf("can't create outpoints %w", err)
}
if fn.HasDuplicates(wireOutpoints) {
return nil, fmt.Errorf("selected outpoints contain " +
"duplicate values")
}
selectOutpoints = fn.NewSet(wireOutpoints...)
}
// We will ask the wallet to create a tx using this fee rate. We set
// dryRun=true to avoid inflating the change addresses in the db.
var tx *txauthor.AuthoredTx
wallet := r.server.cc.Wallet
err = wallet.WithCoinSelectLock(func() error {
tx, err = wallet.CreateSimpleTx(
nil, outputs, feePerKw, minConfs, coinSelectionStrategy,
true,
selectOutpoints, outputs, feePerKw, minConfs,
coinSelectionStrategy, true,
)
return err
})
@@ -1297,12 +1312,26 @@ func (r *rpcServer) EstimateFee(ctx context.Context,
}
totalFee := int64(tx.TotalInput) - totalOutput
// Return the inputs the estimate is for.
outStr := make([]string, 0, len(tx.Tx.TxIn))
for _, txIn := range tx.Tx.TxIn {
outStr = append(
outStr, txIn.PreviousOutPoint.String(),
)
}
inputs, err := UtxosToOutpoints(outStr)
if err != nil {
return nil, fmt.Errorf("can't convert outpoints %w", err)
}
resp := &lnrpc.EstimateFeeResponse{
FeeSat: totalFee,
SatPerVbyte: uint64(feePerKw.FeePerVByte()),
// Deprecated field.
FeerateSatPerByte: int64(feePerKw.FeePerVByte()),
Inputs: inputs,
}
rpcsLog.Debugf("[estimatefee] fee estimate for conf target %d: %v",
+1 -1
View File
@@ -1,4 +1,4 @@
package commands
package lnd
import (
"encoding/hex"