mirror of
https://github.com/muun/recovery.git
synced 2025-11-12 06:50:18 -05:00
Update project structure and build process
This commit is contained in:
79
libwallet/operation/fee_window.go
Normal file
79
libwallet/operation/fee_window.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package operation
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
const swapV2ConfTarget = 250 // Approx 2 days
|
||||
|
||||
type FeeWindow struct {
|
||||
TargetedFees map[uint]float64
|
||||
}
|
||||
|
||||
// SwapFeeRate gets the appropriate fee rate for a given swap (depends on confirmations needed).
|
||||
// Useful method for when swap doesn't have a fixed amount (e.g AmountLessInvoices + use all funds).
|
||||
func (f *FeeWindow) SwapFeeRate(confirmationsNeeded uint) (float64, error) {
|
||||
if confirmationsNeeded == 0 {
|
||||
return f.MinimumFeeRate(swapV2ConfTarget)
|
||||
}
|
||||
|
||||
return f.FastestFeeRate(), nil
|
||||
}
|
||||
|
||||
// MinimumFeeRate gets the minimum available fee rate that will hit a given confirmation target. We
|
||||
// make no guesses (no averages or interpolations), so we might overshoot the fee if data is too
|
||||
// sparse.
|
||||
// Note: the lower the confirmation target, the faster the tx will confirm, and greater the
|
||||
// fee(rate) will be.
|
||||
func (f *FeeWindow) MinimumFeeRate(confirmationTarget uint) (float64, error) {
|
||||
|
||||
if confirmationTarget <= 0 {
|
||||
return 0, fmt.Errorf("can't get feeRate. Expected positive confirmation target, got %v", confirmationTarget)
|
||||
}
|
||||
|
||||
// Walk the available targets backwards, finding the highest target below the given one:
|
||||
for closestTarget := confirmationTarget; closestTarget > 0; closestTarget-- {
|
||||
if feeRate, containsKey := f.TargetedFees[closestTarget]; containsKey {
|
||||
// Found! This is the lowest fee rate that hits the given target.
|
||||
return feeRate, nil
|
||||
}
|
||||
}
|
||||
|
||||
// No result? This is odd, but not illogical. It means *all* of our available targets
|
||||
// are above the requested one. Let's use the fastest:
|
||||
return f.FastestFeeRate(), nil
|
||||
}
|
||||
|
||||
// FastestFeeRate gets the fastest fee rate, in satoshis per weight unit.
|
||||
func (f *FeeWindow) FastestFeeRate() float64 {
|
||||
|
||||
var lowestTarget uint = math.MaxUint32
|
||||
for k := range f.TargetedFees {
|
||||
if k < lowestTarget {
|
||||
lowestTarget = k
|
||||
}
|
||||
}
|
||||
|
||||
return f.TargetedFees[lowestTarget]
|
||||
}
|
||||
|
||||
// NextHighestBlock finds the next highest confirmation/block target for a certain feeRate. Let me
|
||||
// explain, we have a map that associates a conf-target with a fee rate. Now we want to know
|
||||
// associate a conf-target with a given fee rate. We want the NEXT conf-target as we usually want
|
||||
// this data for predictions or estimations and this makes the predictions for the fee rate to
|
||||
// "fall on the correct side" (e.g when estimating max time to confirmation for a given fee rate).
|
||||
// Note: code is not our best work of art. The target < next comparison is to account for our
|
||||
// TargetedFees map not necessarily being sorted.
|
||||
func (f *FeeWindow) NextHighestBlock(feeRate float64) uint {
|
||||
next := uint(math.MaxUint32)
|
||||
for target, rate := range f.TargetedFees {
|
||||
if rate <= feeRate && target < next {
|
||||
next = target
|
||||
}
|
||||
}
|
||||
if next == math.MaxUint32 {
|
||||
return 0 // 0 is a not valid targetBlock, we use it to signal target not found
|
||||
}
|
||||
return next
|
||||
}
|
||||
172
libwallet/operation/fee_window_test.go
Normal file
172
libwallet/operation/fee_window_test.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package operation
|
||||
|
||||
import "testing"
|
||||
|
||||
var singleFeeWindow = &FeeWindow{
|
||||
TargetedFees: func() map[uint]float64 {
|
||||
fees := make(map[uint]float64)
|
||||
fees[1] = 5.6
|
||||
return fees
|
||||
}(),
|
||||
}
|
||||
|
||||
var someFeeWindow = &FeeWindow{
|
||||
TargetedFees: func() map[uint]float64 {
|
||||
fees := make(map[uint]float64)
|
||||
fees[2] = 2.3
|
||||
fees[5] = 7.2
|
||||
fees[20] = 18.7
|
||||
return fees
|
||||
}(),
|
||||
}
|
||||
|
||||
func TestFastestFeeRate(t *testing.T) {
|
||||
|
||||
testCases := []struct {
|
||||
desc string
|
||||
feewindow *FeeWindow
|
||||
expectedFeeRate float64
|
||||
}{
|
||||
{
|
||||
desc: "returns the fastest fee rate",
|
||||
feewindow: someFeeWindow,
|
||||
expectedFeeRate: 2.3,
|
||||
},
|
||||
{
|
||||
desc: "returns the only fee rate as fastest",
|
||||
feewindow: singleFeeWindow,
|
||||
expectedFeeRate: 5.6,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tC := range testCases {
|
||||
t.Run(tC.desc, func(t *testing.T) {
|
||||
|
||||
feeRate := tC.feewindow.FastestFeeRate()
|
||||
|
||||
if feeRate != tC.expectedFeeRate {
|
||||
t.Fatalf("expected feeRate = %v, got %v", tC.expectedFeeRate, feeRate)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinimumFeeRate(t *testing.T) {
|
||||
testCases := []struct {
|
||||
desc string
|
||||
feewindow *FeeWindow
|
||||
confTarget uint
|
||||
expectedFeeRate float64
|
||||
}{
|
||||
{
|
||||
desc: "returns the exact target as closest, if present (1)",
|
||||
feewindow: someFeeWindow,
|
||||
confTarget: 2,
|
||||
expectedFeeRate: 2.3,
|
||||
},
|
||||
{
|
||||
desc: "returns the exact target as closest, if present (2)",
|
||||
feewindow: someFeeWindow,
|
||||
confTarget: 5,
|
||||
expectedFeeRate: 7.2,
|
||||
},
|
||||
{
|
||||
desc: "returns the exact target as closest, if present (3)",
|
||||
feewindow: someFeeWindow,
|
||||
confTarget: 20,
|
||||
expectedFeeRate: 18.7,
|
||||
},
|
||||
{
|
||||
desc: "returns the closest lower target (1)",
|
||||
feewindow: someFeeWindow,
|
||||
confTarget: 4,
|
||||
expectedFeeRate: 2.3,
|
||||
},
|
||||
{
|
||||
desc: "returns the closest lower target (2)",
|
||||
feewindow: someFeeWindow,
|
||||
confTarget: 15,
|
||||
expectedFeeRate: 7.2,
|
||||
},
|
||||
{
|
||||
desc: "returns the closest lower target (3)",
|
||||
feewindow: someFeeWindow,
|
||||
confTarget: 22,
|
||||
expectedFeeRate: 18.7,
|
||||
},
|
||||
{
|
||||
desc: "returns the lowest target by default",
|
||||
feewindow: someFeeWindow,
|
||||
confTarget: 1,
|
||||
expectedFeeRate: 2.3,
|
||||
},
|
||||
{
|
||||
desc: "returns the only fee rate as closest (1)",
|
||||
feewindow: singleFeeWindow,
|
||||
confTarget: 1,
|
||||
expectedFeeRate: 5.6,
|
||||
},
|
||||
{
|
||||
desc: "returns the only fee rate as closest (2)",
|
||||
feewindow: singleFeeWindow,
|
||||
confTarget: 6,
|
||||
expectedFeeRate: 5.6,
|
||||
}, {
|
||||
desc: "returns the only fee rate as closest (3)",
|
||||
feewindow: singleFeeWindow,
|
||||
confTarget: 18,
|
||||
expectedFeeRate: 5.6,
|
||||
}, {
|
||||
desc: "returns the only fee rate as closest (4)",
|
||||
feewindow: singleFeeWindow,
|
||||
confTarget: 24,
|
||||
expectedFeeRate: 5.6,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tC := range testCases {
|
||||
t.Run(tC.desc, func(t *testing.T) {
|
||||
|
||||
feeRate, err := tC.feewindow.MinimumFeeRate(tC.confTarget)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if feeRate != tC.expectedFeeRate {
|
||||
t.Fatalf("expected feeRate = %v, got %v", tC.expectedFeeRate, feeRate)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidConfirmationTargets(t *testing.T) {
|
||||
testCases := []struct {
|
||||
desc string
|
||||
feewindow *FeeWindow
|
||||
confTarget uint
|
||||
}{
|
||||
{
|
||||
desc: "fails check when confirmation target is 0",
|
||||
feewindow: someFeeWindow,
|
||||
confTarget: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tC := range testCases {
|
||||
t.Run(tC.desc, func(t *testing.T) {
|
||||
|
||||
_, err := tC.feewindow.MinimumFeeRate(tC.confTarget)
|
||||
|
||||
if err == nil {
|
||||
t.Fatalf("expected test to error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextHighestBlock(t *testing.T) {
|
||||
block := someFeeWindow.NextHighestBlock(10.0)
|
||||
if block != 2 {
|
||||
t.Fatalf("expected block to be 2, got %v", block)
|
||||
}
|
||||
}
|
||||
60
libwallet/operation/fees.go
Normal file
60
libwallet/operation/fees.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package operation
|
||||
|
||||
import (
|
||||
"math"
|
||||
)
|
||||
|
||||
type feeCalculator struct {
|
||||
NextTransactionSize *NextTransactionSize
|
||||
}
|
||||
|
||||
// Fee DOES NOT return error when amount > balance. Instead we return the fee it would take to
|
||||
// spend all utxos and delegate to the caller the task of checking if that is spendable with the given
|
||||
// amount. This is to avoid using go error handling.
|
||||
// Consequences of this:
|
||||
// - we don't check balance whatsoever
|
||||
// - fee for COLLECT swap is exactly the same as normal case
|
||||
func (f *feeCalculator) Fee(amountInSat int64, feeRateInSatsPerVByte float64, takeFeeFromAmount bool) int64 {
|
||||
if amountInSat == 0 {
|
||||
return 0
|
||||
}
|
||||
if takeFeeFromAmount {
|
||||
return f.feeFromAmount(amountInSat, feeRateInSatsPerVByte)
|
||||
} else {
|
||||
return f.feeFromRemainingBalance(amountInSat, feeRateInSatsPerVByte)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *feeCalculator) feeFromAmount(amountInSat int64, feeRateInSatsPerVByte float64) int64 {
|
||||
if f.NextTransactionSize == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
var fee int64
|
||||
for _, sizeForAmount := range f.NextTransactionSize.SizeProgression {
|
||||
fee = computeFee(sizeForAmount.SizeInVByte, feeRateInSatsPerVByte)
|
||||
if sizeForAmount.AmountInSat >= amountInSat {
|
||||
break // no more UTXOs needed
|
||||
}
|
||||
}
|
||||
return fee
|
||||
}
|
||||
|
||||
func (f *feeCalculator) feeFromRemainingBalance(amountInSat int64, feeRateInSatsPerVByte float64) int64 {
|
||||
if f.NextTransactionSize == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
var fee int64
|
||||
for _, sizeForAmount := range f.NextTransactionSize.SizeProgression {
|
||||
fee = computeFee(sizeForAmount.SizeInVByte, feeRateInSatsPerVByte)
|
||||
if sizeForAmount.AmountInSat >= amountInSat+fee {
|
||||
break // no more UTXOs needed
|
||||
}
|
||||
}
|
||||
return fee
|
||||
}
|
||||
|
||||
func computeFee(sizeInVByte int64, feeRate float64) int64 {
|
||||
return int64(math.Ceil(float64(sizeInVByte) * feeRate))
|
||||
}
|
||||
301
libwallet/operation/fees_test.go
Normal file
301
libwallet/operation/fees_test.go
Normal file
@@ -0,0 +1,301 @@
|
||||
package operation
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
var emptyNts = &NextTransactionSize{}
|
||||
|
||||
var defaultNts = &NextTransactionSize{
|
||||
SizeProgression: []SizeForAmount{
|
||||
{
|
||||
AmountInSat: 103_456,
|
||||
SizeInVByte: 110,
|
||||
},
|
||||
{
|
||||
AmountInSat: 20_345_678,
|
||||
SizeInVByte: 230,
|
||||
},
|
||||
{
|
||||
AmountInSat: 303_456_789,
|
||||
SizeInVByte: 340,
|
||||
},
|
||||
{
|
||||
AmountInSat: 703_456_789,
|
||||
SizeInVByte: 580,
|
||||
},
|
||||
},
|
||||
ExpectedDebtInSat: 0,
|
||||
}
|
||||
|
||||
var singleNts = &NextTransactionSize{
|
||||
SizeProgression: []SizeForAmount{
|
||||
{
|
||||
AmountInSat: 123_456,
|
||||
SizeInVByte: 400,
|
||||
},
|
||||
},
|
||||
ExpectedDebtInSat: 0,
|
||||
}
|
||||
|
||||
// 2nd utxo is actually more expensive to spend that what its worth
|
||||
var negativeUtxoNts = &NextTransactionSize{
|
||||
SizeProgression: []SizeForAmount{
|
||||
{
|
||||
AmountInSat: 48_216,
|
||||
SizeInVByte: 840,
|
||||
},
|
||||
{
|
||||
AmountInSat: 48_880,
|
||||
SizeInVByte: 1366,
|
||||
},
|
||||
},
|
||||
ExpectedDebtInSat: 0,
|
||||
}
|
||||
|
||||
// Utxo is actually more expensive to spend that what its worth
|
||||
var singleNegativeUtxoNts = &NextTransactionSize{
|
||||
SizeProgression: []SizeForAmount{
|
||||
{
|
||||
AmountInSat: 644,
|
||||
SizeInVByte: 840,
|
||||
},
|
||||
},
|
||||
ExpectedDebtInSat: 0,
|
||||
}
|
||||
|
||||
func TestFeeCalculatorForAmountZero(t *testing.T) {
|
||||
testCases := []struct {
|
||||
desc string
|
||||
feeRateInSatsPerVbyte float64
|
||||
takeFeeFromAmount bool
|
||||
expectedFeeInSat int64
|
||||
}{
|
||||
{
|
||||
desc: "calculate for amount zero",
|
||||
feeRateInSatsPerVbyte: 1,
|
||||
takeFeeFromAmount: false,
|
||||
expectedFeeInSat: 0,
|
||||
},
|
||||
{
|
||||
desc: "calculate for amount zero with TFFA",
|
||||
feeRateInSatsPerVbyte: 1,
|
||||
takeFeeFromAmount: true,
|
||||
expectedFeeInSat: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tC := range testCases {
|
||||
t.Run(tC.desc, func(t *testing.T) {
|
||||
|
||||
allNts := []NextTransactionSize{
|
||||
*emptyNts,
|
||||
*defaultNts,
|
||||
*singleNts,
|
||||
*negativeUtxoNts,
|
||||
*singleNegativeUtxoNts,
|
||||
}
|
||||
|
||||
for _, nts := range allNts {
|
||||
calculator := feeCalculator{&nts}
|
||||
feeInSat := calculator.Fee(0, tC.feeRateInSatsPerVbyte, tC.takeFeeFromAmount)
|
||||
|
||||
if feeInSat != tC.expectedFeeInSat {
|
||||
t.Fatalf("expected fee = %v, got %v", tC.expectedFeeInSat, feeInSat)
|
||||
}
|
||||
}
|
||||
|
||||
calculator := feeCalculator{}
|
||||
feeInSat := calculator.Fee(0, tC.feeRateInSatsPerVbyte, tC.takeFeeFromAmount)
|
||||
|
||||
if feeInSat != tC.expectedFeeInSat {
|
||||
t.Fatalf("expected fee = %v, got %v", tC.expectedFeeInSat, feeInSat)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeeCalculator(t *testing.T) {
|
||||
|
||||
testCases := []struct {
|
||||
desc string
|
||||
amountInSat int64
|
||||
feeCalculator *feeCalculator
|
||||
feeRateInSatsPerVbyte float64
|
||||
takeFeeFromAmount bool
|
||||
expectedFeeInSat int64
|
||||
}{
|
||||
{
|
||||
desc: "empty fee calculator",
|
||||
amountInSat: 1000,
|
||||
feeCalculator: &feeCalculator{},
|
||||
feeRateInSatsPerVbyte: 10,
|
||||
takeFeeFromAmount: false,
|
||||
expectedFeeInSat: 0,
|
||||
},
|
||||
{
|
||||
desc: "empty fee calculator with TFFA",
|
||||
amountInSat: 1000,
|
||||
feeCalculator: &feeCalculator{},
|
||||
feeRateInSatsPerVbyte: 10,
|
||||
takeFeeFromAmount: false,
|
||||
expectedFeeInSat: 0,
|
||||
},
|
||||
{
|
||||
desc: "non empty fee calculator",
|
||||
amountInSat: 1000,
|
||||
feeCalculator: &feeCalculator{&NextTransactionSize{
|
||||
SizeProgression: []SizeForAmount{
|
||||
{
|
||||
AmountInSat: 10_000,
|
||||
SizeInVByte: 240,
|
||||
},
|
||||
},
|
||||
ExpectedDebtInSat: 0,
|
||||
}},
|
||||
feeRateInSatsPerVbyte: 10,
|
||||
takeFeeFromAmount: false,
|
||||
expectedFeeInSat: 2400,
|
||||
},
|
||||
{
|
||||
desc: "fails when balance is zero",
|
||||
amountInSat: 1,
|
||||
feeCalculator: &feeCalculator{emptyNts},
|
||||
feeRateInSatsPerVbyte: 10,
|
||||
takeFeeFromAmount: false,
|
||||
expectedFeeInSat: 0,
|
||||
},
|
||||
{
|
||||
desc: "fails when balance is zero with TFFA",
|
||||
amountInSat: 1,
|
||||
feeCalculator: &feeCalculator{emptyNts},
|
||||
feeRateInSatsPerVbyte: 10,
|
||||
takeFeeFromAmount: true,
|
||||
expectedFeeInSat: 0,
|
||||
},
|
||||
{
|
||||
desc: "fails when amount greater than balance",
|
||||
amountInSat: defaultNts.TotalBalance() + 1,
|
||||
feeCalculator: &feeCalculator{defaultNts},
|
||||
feeRateInSatsPerVbyte: 10,
|
||||
takeFeeFromAmount: false,
|
||||
expectedFeeInSat: 5800,
|
||||
},
|
||||
{
|
||||
desc: "fails when amount greater than balance with TFFA",
|
||||
amountInSat: defaultNts.TotalBalance() + 1,
|
||||
feeCalculator: &feeCalculator{defaultNts},
|
||||
feeRateInSatsPerVbyte: 10,
|
||||
takeFeeFromAmount: true,
|
||||
expectedFeeInSat: 5800,
|
||||
},
|
||||
{
|
||||
desc: "calculates when amount plus fee is greater than balance",
|
||||
amountInSat: defaultNts.TotalBalance() - 1,
|
||||
feeCalculator: &feeCalculator{defaultNts},
|
||||
feeRateInSatsPerVbyte: 10,
|
||||
takeFeeFromAmount: false,
|
||||
expectedFeeInSat: 5800,
|
||||
},
|
||||
{
|
||||
desc: "calculates reduced amount and fee with TFFA",
|
||||
amountInSat: 10_345_678,
|
||||
feeCalculator: &feeCalculator{defaultNts},
|
||||
feeRateInSatsPerVbyte: 10,
|
||||
takeFeeFromAmount: true,
|
||||
expectedFeeInSat: 2300,
|
||||
},
|
||||
{
|
||||
// This case can't really happen since our PaymentAnalyzer enforces amount == totalBalance for TFFA
|
||||
// We don't handle that precondition in FeeCalculator to keep its API simple (no error handling)
|
||||
desc: "calculates when no amount is left after TFFA",
|
||||
amountInSat: 10,
|
||||
feeCalculator: &feeCalculator{defaultNts},
|
||||
feeRateInSatsPerVbyte: 10,
|
||||
takeFeeFromAmount: true,
|
||||
expectedFeeInSat: 1100,
|
||||
},
|
||||
{
|
||||
desc: "calculates use-all-funds fee with TFFA",
|
||||
amountInSat: defaultNTS.TotalBalance(),
|
||||
feeCalculator: &feeCalculator{defaultNts},
|
||||
feeRateInSatsPerVbyte: 10,
|
||||
takeFeeFromAmount: true,
|
||||
expectedFeeInSat: 2300,
|
||||
},
|
||||
{
|
||||
desc: "calculates when paying fee does not require an additional UTXO (1)",
|
||||
amountInSat: defaultNts.SizeProgression[0].AmountInSat / 2,
|
||||
feeCalculator: &feeCalculator{defaultNts},
|
||||
feeRateInSatsPerVbyte: 10,
|
||||
takeFeeFromAmount: false,
|
||||
expectedFeeInSat: defaultNts.SizeProgression[0].SizeInVByte * 10,
|
||||
},
|
||||
{
|
||||
desc: "calculates when paying fee does not require an additional UTXO (2)",
|
||||
amountInSat: defaultNts.SizeProgression[1].AmountInSat / 2,
|
||||
feeCalculator: &feeCalculator{defaultNts},
|
||||
feeRateInSatsPerVbyte: 10,
|
||||
takeFeeFromAmount: false,
|
||||
expectedFeeInSat: defaultNts.SizeProgression[1].SizeInVByte * 10,
|
||||
},
|
||||
{
|
||||
desc: "calculates when paying fee does not require an additional UTXO (3)",
|
||||
amountInSat: defaultNts.SizeProgression[2].AmountInSat / 2,
|
||||
feeCalculator: &feeCalculator{defaultNts},
|
||||
feeRateInSatsPerVbyte: 10,
|
||||
takeFeeFromAmount: false,
|
||||
expectedFeeInSat: defaultNts.SizeProgression[2].SizeInVByte * 10,
|
||||
},
|
||||
{
|
||||
desc: "calculates when paying fee does not require an additional UTXO (4)",
|
||||
amountInSat: defaultNts.SizeProgression[3].AmountInSat / 2,
|
||||
feeCalculator: &feeCalculator{defaultNts},
|
||||
feeRateInSatsPerVbyte: 10,
|
||||
takeFeeFromAmount: false,
|
||||
expectedFeeInSat: defaultNts.SizeProgression[3].SizeInVByte * 10,
|
||||
},
|
||||
{
|
||||
desc: "calculates when paying fee requires an additional UTXO (1)",
|
||||
amountInSat: defaultNts.SizeProgression[0].AmountInSat - 1,
|
||||
feeCalculator: &feeCalculator{defaultNts},
|
||||
feeRateInSatsPerVbyte: 10,
|
||||
takeFeeFromAmount: false,
|
||||
expectedFeeInSat: defaultNts.SizeProgression[1].SizeInVByte * 10,
|
||||
},
|
||||
{
|
||||
desc: "calculates when paying fee requires an additional UTXO (2)",
|
||||
amountInSat: defaultNts.SizeProgression[1].AmountInSat - 1,
|
||||
feeCalculator: &feeCalculator{defaultNts},
|
||||
feeRateInSatsPerVbyte: 10,
|
||||
takeFeeFromAmount: false,
|
||||
expectedFeeInSat: defaultNts.SizeProgression[2].SizeInVByte * 10,
|
||||
},
|
||||
{
|
||||
desc: "calculates when paying fee requires an additional UTXO (3)",
|
||||
amountInSat: defaultNts.SizeProgression[2].AmountInSat - 1,
|
||||
feeCalculator: &feeCalculator{defaultNts},
|
||||
feeRateInSatsPerVbyte: 10,
|
||||
takeFeeFromAmount: false,
|
||||
expectedFeeInSat: defaultNts.SizeProgression[3].SizeInVByte * 10,
|
||||
},
|
||||
{
|
||||
desc: "calculates when negative UTXOs are larger than positive UTXOs",
|
||||
amountInSat: 1,
|
||||
feeCalculator: &feeCalculator{singleNegativeUtxoNts},
|
||||
feeRateInSatsPerVbyte: 10,
|
||||
takeFeeFromAmount: false,
|
||||
expectedFeeInSat: 8400, // which is > 64, aka singleNegativeUtxoNts.TotalBalance()
|
||||
},
|
||||
}
|
||||
for _, tC := range testCases {
|
||||
t.Run(tC.desc, func(t *testing.T) {
|
||||
|
||||
feeInSat := tC.feeCalculator.Fee(tC.amountInSat, tC.feeRateInSatsPerVbyte, tC.takeFeeFromAmount)
|
||||
|
||||
if feeInSat != tC.expectedFeeInSat {
|
||||
t.Fatalf("expected fee = %v, got %v", tC.expectedFeeInSat, feeInSat)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
562
libwallet/operation/payment_analyzer.go
Normal file
562
libwallet/operation/payment_analyzer.go
Normal file
@@ -0,0 +1,562 @@
|
||||
package operation
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/btcsuite/btcutil"
|
||||
"github.com/muun/libwallet/fees"
|
||||
)
|
||||
|
||||
// paymentAnalyzer is the component that decides whether a payment can be made or not, and what
|
||||
// are the amount and fees involved. Here a list of some important design decisions:
|
||||
//
|
||||
// - Delegate to the caller the need to make another call if she wants to analyze with minimumFee
|
||||
// - 2 entry points: toAddress for onchain payments, toInvoice for offchain payments
|
||||
// - PaymentAnalyzer return errors only on programmer errors, not for "payment errors" aka
|
||||
// situations that can happen like amount greater than balance
|
||||
// - Returned PaymentAnalysis contains a status that represent:
|
||||
// - Success → `StatusOk`
|
||||
// - Unrecoverable error -> `StatusAmountGreaterThanBalance`, `StatusAmountTooSmall`
|
||||
// - Maybe recoverable error → `StatusUnpayable`
|
||||
// - A status error is:
|
||||
// - Unrecoverable: if there's no way to pay the specified amount with the current state
|
||||
// - Recoverable: if there's a way to pay the specified amount with the current state
|
||||
// (e.g changing the fee, if it applies)
|
||||
// - `OutputAmount > uxtoBalance` IS NOT A `StatusAmountGreaterThanBalance`, because the problem
|
||||
// is not that the `amount > totalBalance` but that the swapFees (sweep + lightning) and/or
|
||||
// collectAmount make `OutputAmount > uxtoBalance`. Hence it is an `StatusUnpayable`.
|
||||
// - We don't allow TFFA for LEND swaps (don't want to lend money if you're taking it all away)
|
||||
// - FeeCalculator DOES NOT return error when amount > balance
|
||||
// - Instead we return the fee it would take to spend all utxos and delegate to the caller the
|
||||
// task of checking if that is spendable with the given amount
|
||||
// - This is to avoid using go error handling
|
||||
// - We finally renamed sweepFee to outputPadding since that's its only use. Here's how it works:
|
||||
// - Only makes sense for swaps
|
||||
// - If amount ≥ DUST ⇒ No need for padding (outputPadding = 0)
|
||||
// - If amount < DUST & debt < maxDebt ⇒ We use debt as padding (outputPadding = 0)
|
||||
// - If amount < DUST && debt >= maxDebt ⇒ outputPadding = DUST - amount
|
||||
// - If amount < DUST and TFFA
|
||||
// - Can't use debt as padding (don't lend money for TFFA)
|
||||
// - Can't use fee as we are using all funds and they are < DUST
|
||||
// - If there's need for padding ⇒ payment is UNPAYABLE
|
||||
// - Hence, outputPadding = 0 or payment is UNPAYABLE
|
||||
|
||||
const DustThreshold = 546
|
||||
|
||||
type PaymentToAddress struct {
|
||||
TakeFeeFromAmount bool
|
||||
AmountInSat int64
|
||||
FeeRateInSatsPerVByte float64
|
||||
}
|
||||
|
||||
type PaymentToInvoice struct {
|
||||
TakeFeeFromAmount bool
|
||||
AmountInSat int64
|
||||
SwapFees *fees.SwapFees // Nullable before we know the paymentAmount for amountless invoice
|
||||
BestRouteFees []fees.BestRouteFees // Nullable when we know the amount beforehand (invoice with amount)
|
||||
FundingOutputPolicies *fees.FundingOutputPolicies // Nullable when we know the amount beforehand (invoice with amount)
|
||||
}
|
||||
|
||||
type PaymentAnalyzer struct {
|
||||
feeWindow *FeeWindow
|
||||
nextTransactionSize *NextTransactionSize
|
||||
feeCalculator *feeCalculator
|
||||
}
|
||||
|
||||
type SizeForAmount struct {
|
||||
AmountInSat int64
|
||||
SizeInVByte int64
|
||||
}
|
||||
|
||||
type NextTransactionSize struct {
|
||||
SizeProgression []SizeForAmount
|
||||
ExpectedDebtInSat int64
|
||||
}
|
||||
|
||||
func (nts *NextTransactionSize) UtxoBalance() int64 {
|
||||
if len(nts.SizeProgression) == 0 {
|
||||
return 0
|
||||
}
|
||||
return nts.SizeProgression[len(nts.SizeProgression)-1].AmountInSat
|
||||
}
|
||||
|
||||
func (nts *NextTransactionSize) TotalBalance() int64 {
|
||||
return nts.UtxoBalance() - nts.ExpectedDebtInSat
|
||||
}
|
||||
|
||||
type AnalysisStatus string
|
||||
|
||||
const (
|
||||
AnalysisStatusOk AnalysisStatus = "Ok"
|
||||
AnalysisStatusAmountGreaterThanBalance AnalysisStatus = "GreaterThanBalance"
|
||||
AnalysisStatusAmountTooSmall AnalysisStatus = "AmountTooSmall"
|
||||
AnalysisStatusUnpayable AnalysisStatus = "Unpayable"
|
||||
)
|
||||
|
||||
type PaymentAnalysis struct {
|
||||
Status AnalysisStatus
|
||||
AmountInSat int64
|
||||
FeeInSat int64
|
||||
SwapFees *fees.SwapFees
|
||||
TotalInSat int64
|
||||
}
|
||||
|
||||
func NewPaymentAnalyzer(feeWindow *FeeWindow, nts *NextTransactionSize) *PaymentAnalyzer {
|
||||
return &PaymentAnalyzer{
|
||||
feeWindow: feeWindow,
|
||||
nextTransactionSize: nts,
|
||||
feeCalculator: &feeCalculator{nts},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *PaymentAnalyzer) totalBalance() int64 {
|
||||
return a.nextTransactionSize.TotalBalance()
|
||||
}
|
||||
|
||||
func (a *PaymentAnalyzer) utxoBalance() int64 {
|
||||
return a.nextTransactionSize.UtxoBalance()
|
||||
}
|
||||
|
||||
func (a *PaymentAnalyzer) ToAddress(payment *PaymentToAddress) (*PaymentAnalysis, error) {
|
||||
if payment.AmountInSat < DustThreshold {
|
||||
return &PaymentAnalysis{
|
||||
Status: AnalysisStatusAmountTooSmall,
|
||||
}, nil
|
||||
}
|
||||
if payment.AmountInSat > a.totalBalance() {
|
||||
return &PaymentAnalysis{
|
||||
Status: AnalysisStatusAmountGreaterThanBalance,
|
||||
TotalInSat: payment.AmountInSat,
|
||||
}, nil
|
||||
}
|
||||
if payment.TakeFeeFromAmount && payment.AmountInSat != a.totalBalance() {
|
||||
return nil, fmt.Errorf("amount (%v) != userBalance (%v) for TFFA", payment.AmountInSat, a.totalBalance())
|
||||
}
|
||||
|
||||
if payment.TakeFeeFromAmount {
|
||||
return a.analyzeFeeFromAmount(payment)
|
||||
}
|
||||
return a.analyzeFeeFromRemainingBalance(payment)
|
||||
}
|
||||
|
||||
func (a *PaymentAnalyzer) analyzeFeeFromAmount(payment *PaymentToAddress) (*PaymentAnalysis, error) {
|
||||
fee := a.feeCalculator.Fee(payment.AmountInSat, payment.FeeRateInSatsPerVByte, true)
|
||||
|
||||
total := payment.AmountInSat
|
||||
amount := total - fee
|
||||
|
||||
if amount <= DustThreshold {
|
||||
// avoid returning a negative amount
|
||||
if amount < 0 {
|
||||
amount = 0
|
||||
}
|
||||
return &PaymentAnalysis{
|
||||
Status: AnalysisStatusUnpayable,
|
||||
AmountInSat: amount,
|
||||
TotalInSat: payment.AmountInSat,
|
||||
FeeInSat: fee,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &PaymentAnalysis{
|
||||
Status: AnalysisStatusOk,
|
||||
AmountInSat: amount,
|
||||
TotalInSat: payment.AmountInSat,
|
||||
FeeInSat: fee,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *PaymentAnalyzer) analyzeFeeFromRemainingBalance(payment *PaymentToAddress) (*PaymentAnalysis, error) {
|
||||
fee := a.feeCalculator.Fee(payment.AmountInSat, payment.FeeRateInSatsPerVByte, false)
|
||||
total := payment.AmountInSat + fee
|
||||
|
||||
if total > a.totalBalance() {
|
||||
return &PaymentAnalysis{
|
||||
Status: AnalysisStatusUnpayable,
|
||||
AmountInSat: payment.AmountInSat,
|
||||
FeeInSat: fee,
|
||||
TotalInSat: total,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &PaymentAnalysis{
|
||||
Status: AnalysisStatusOk,
|
||||
AmountInSat: payment.AmountInSat,
|
||||
TotalInSat: total,
|
||||
FeeInSat: fee,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *PaymentAnalyzer) ToInvoice(payment *PaymentToInvoice) (*PaymentAnalysis, error) {
|
||||
if payment.AmountInSat <= 0 {
|
||||
return &PaymentAnalysis{
|
||||
Status: AnalysisStatusAmountTooSmall,
|
||||
}, nil
|
||||
}
|
||||
if payment.AmountInSat > a.totalBalance() {
|
||||
return &PaymentAnalysis{
|
||||
Status: AnalysisStatusAmountGreaterThanBalance,
|
||||
TotalInSat: payment.AmountInSat,
|
||||
}, nil
|
||||
}
|
||||
if payment.TakeFeeFromAmount {
|
||||
if payment.BestRouteFees == nil {
|
||||
return nil, errors.New("fixed amount swap can't be TFFA since that would change the amount")
|
||||
}
|
||||
if payment.AmountInSat != a.totalBalance() {
|
||||
return nil, fmt.Errorf("amount (%v) != userBalance (%v) for TFFA", payment.AmountInSat, a.totalBalance())
|
||||
}
|
||||
}
|
||||
|
||||
if payment.BestRouteFees != nil {
|
||||
|
||||
// As users can enter newOp screen with 0 balance, we need to check for amount == 0
|
||||
// because of our rule (if balance == amount then useAllFunds = true)
|
||||
if !payment.TakeFeeFromAmount || payment.AmountInSat == 0 {
|
||||
// User chose a specific amount
|
||||
return a.analyzeFixedAmountSwap(payment, fees.ComputeSwapFees(
|
||||
btcutil.Amount(payment.AmountInSat),
|
||||
payment.BestRouteFees,
|
||||
payment.FundingOutputPolicies,
|
||||
payment.TakeFeeFromAmount,
|
||||
))
|
||||
|
||||
} else {
|
||||
return a.analyzeTFFAAmountlessInvoiceSwap(payment)
|
||||
}
|
||||
}
|
||||
|
||||
if payment.SwapFees == nil {
|
||||
return nil, fmt.Errorf("payment is missing required swap fees data")
|
||||
}
|
||||
|
||||
return a.analyzeFixedAmountSwap(payment, payment.SwapFees)
|
||||
}
|
||||
|
||||
func (a *PaymentAnalyzer) analyzeFixedAmountSwap(payment *PaymentToInvoice, swapFees *fees.SwapFees) (*PaymentAnalysis, error) {
|
||||
switch swapFees.DebtType {
|
||||
case fees.DebtTypeLend:
|
||||
return a.analyzeLendSwap(payment, swapFees)
|
||||
case fees.DebtTypeCollect:
|
||||
fallthrough
|
||||
case fees.DebtTypeNone:
|
||||
return a.analyzeCollectSwap(payment, swapFees) // a non-debt swap is just a collect swap with debtAmount = 0
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported debt type: %v", swapFees.DebtType)
|
||||
}
|
||||
|
||||
func (a *PaymentAnalyzer) analyzeLendSwap(payment *PaymentToInvoice, swapFees *fees.SwapFees) (*PaymentAnalysis, error) {
|
||||
|
||||
amount := payment.AmountInSat
|
||||
total := amount + int64(swapFees.RoutingFee)
|
||||
|
||||
if total > a.totalBalance() {
|
||||
return &PaymentAnalysis{
|
||||
Status: AnalysisStatusUnpayable,
|
||||
AmountInSat: amount,
|
||||
TotalInSat: total,
|
||||
FeeInSat: 0,
|
||||
SwapFees: swapFees,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &PaymentAnalysis{
|
||||
Status: AnalysisStatusOk,
|
||||
AmountInSat: amount,
|
||||
TotalInSat: total,
|
||||
FeeInSat: 0,
|
||||
SwapFees: swapFees,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Analyze non LEND swaps (e.g both COLLECT and NON-DEBT swaps), understanding that both cases warrant
|
||||
// the same analysis. A non-debt swap is just a collect swap with debtAmount = 0.
|
||||
func (a *PaymentAnalyzer) analyzeCollectSwap(payment *PaymentToInvoice, swapFees *fees.SwapFees) (*PaymentAnalysis, error) {
|
||||
|
||||
outputAmount := int64(swapFees.OutputAmount)
|
||||
collectAmount := int64(swapFees.DebtAmount)
|
||||
|
||||
expectedOutputAmount := payment.AmountInSat +
|
||||
int64(swapFees.RoutingFee) +
|
||||
int64(swapFees.OutputPadding) +
|
||||
collectAmount
|
||||
|
||||
if outputAmount != expectedOutputAmount {
|
||||
return nil, fmt.Errorf(
|
||||
"swap integrity check failed (outputAmount=%v, original_amount=%v, routing_fee=%v, output_padding=%v, collect_amount=%v)",
|
||||
outputAmount,
|
||||
payment.AmountInSat,
|
||||
int64(swapFees.RoutingFee),
|
||||
int64(swapFees.OutputPadding),
|
||||
collectAmount,
|
||||
)
|
||||
}
|
||||
|
||||
feeRate, err := a.feeWindow.SwapFeeRate(swapFees.ConfirmationsNeeded)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fee := a.feeCalculator.Fee(outputAmount, feeRate, false)
|
||||
total := outputAmount + fee
|
||||
totalForUser := total - collectAmount
|
||||
|
||||
if total > a.utxoBalance() || totalForUser > a.totalBalance() {
|
||||
return &PaymentAnalysis{
|
||||
Status: AnalysisStatusUnpayable,
|
||||
AmountInSat: payment.AmountInSat,
|
||||
FeeInSat: fee,
|
||||
TotalInSat: totalForUser,
|
||||
SwapFees: swapFees,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &PaymentAnalysis{
|
||||
Status: AnalysisStatusOk,
|
||||
AmountInSat: payment.AmountInSat,
|
||||
FeeInSat: fee,
|
||||
TotalInSat: totalForUser,
|
||||
SwapFees: swapFees,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *PaymentAnalyzer) analyzeTFFAAmountlessInvoiceSwap(payment *PaymentToInvoice) (*PaymentAnalysis, error) {
|
||||
zeroConfFeeRate, err := a.feeWindow.SwapFeeRate(0)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
zeroConfFeeInSat := a.computeFeeForTFFASwap(payment, zeroConfFeeRate)
|
||||
if zeroConfFeeInSat > a.totalBalance() {
|
||||
// We can't even pay the onchain fee
|
||||
return &PaymentAnalysis{
|
||||
Status: AnalysisStatusUnpayable,
|
||||
FeeInSat: zeroConfFeeInSat,
|
||||
TotalInSat: a.totalBalance() + int64(zeroConfFeeRate),
|
||||
}, nil
|
||||
}
|
||||
|
||||
params, err := a.computeParamsForTFFASwap(payment, 0)
|
||||
if err != nil {
|
||||
return &PaymentAnalysis{
|
||||
Status: AnalysisStatusUnpayable,
|
||||
FeeInSat: zeroConfFeeInSat,
|
||||
TotalInSat: a.totalBalance() + int64(zeroConfFeeInSat),
|
||||
}, nil
|
||||
}
|
||||
|
||||
confirmations := payment.FundingOutputPolicies.FundingConfirmations(params.Amount, params.RoutingFee)
|
||||
if confirmations == 1 {
|
||||
params, err = a.computeParamsForTFFASwap(payment, 1)
|
||||
if err != nil {
|
||||
// This LITERALLY can never happen, as only source of error for computeParamsForTFFASwap are:
|
||||
// - negative conf target (we're using 1)
|
||||
// - no route for amount (would be catched by previous call since amount with 1-conf fee would be smaller)
|
||||
return &PaymentAnalysis{
|
||||
Status: AnalysisStatusUnpayable,
|
||||
FeeInSat: zeroConfFeeInSat,
|
||||
TotalInSat: a.totalBalance() + int64(zeroConfFeeInSat),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
amount := params.Amount
|
||||
lightningFee := params.RoutingFee
|
||||
onChainFee := params.OnChainFee
|
||||
|
||||
if amount <= 0 {
|
||||
// We can't pay the combined fee
|
||||
// This can be either cause we can't pay both fees summed or we had to bump to
|
||||
// 1-conf and we can't pay that.
|
||||
return &PaymentAnalysis{
|
||||
Status: AnalysisStatusUnpayable,
|
||||
FeeInSat: zeroConfFeeInSat,
|
||||
TotalInSat: a.totalBalance() + int64(zeroConfFeeInSat),
|
||||
}, nil
|
||||
}
|
||||
|
||||
swapFees := fees.ComputeSwapFees(amount, payment.BestRouteFees, payment.FundingOutputPolicies, true)
|
||||
|
||||
if swapFees.DebtType == fees.DebtTypeLend {
|
||||
return nil, errors.New("TFFA swap should not be a lend operation")
|
||||
}
|
||||
|
||||
// This assumes that debt amount is either 0 (DebtType = NONE) or positive (DebtType = COLLECT)
|
||||
outputAmount := amount + lightningFee + swapFees.OutputPadding + swapFees.DebtAmount
|
||||
|
||||
if lightningFee != swapFees.RoutingFee {
|
||||
return nil, fmt.Errorf(
|
||||
"integrity error: inconsistent lightning fee calculated for TFFA swap (lightning_fee=%v, output_amount=%v, original_amount=%v, routing_fee=%v, output_padding=%v)",
|
||||
int64(lightningFee),
|
||||
int64(outputAmount),
|
||||
payment.AmountInSat,
|
||||
int64(swapFees.RoutingFee),
|
||||
int64(swapFees.OutputPadding),
|
||||
)
|
||||
}
|
||||
|
||||
total := outputAmount + onChainFee
|
||||
totalForDisplay := total - swapFees.DebtAmount // amount + lightningFee + outputPadding + onChainFee
|
||||
|
||||
// We need to ensure we can spend on chain and that we have enough UI visible balance too
|
||||
// That is, the collect doesn't make us spend more than we really can and the amount + fee
|
||||
// doesn't default any debt.
|
||||
canPay := total <= btcutil.Amount(a.utxoBalance()) && totalForDisplay <= btcutil.Amount(a.totalBalance())
|
||||
|
||||
if !canPay {
|
||||
return &PaymentAnalysis{
|
||||
Status: AnalysisStatusUnpayable,
|
||||
AmountInSat: int64(amount),
|
||||
FeeInSat: int64(onChainFee),
|
||||
TotalInSat: payment.AmountInSat,
|
||||
SwapFees: swapFees,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &PaymentAnalysis{
|
||||
Status: AnalysisStatusOk,
|
||||
AmountInSat: int64(amount),
|
||||
FeeInSat: int64(onChainFee),
|
||||
TotalInSat: payment.AmountInSat,
|
||||
SwapFees: swapFees,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type swapParams struct {
|
||||
Amount btcutil.Amount
|
||||
OnChainFee btcutil.Amount
|
||||
RoutingFee btcutil.Amount
|
||||
}
|
||||
|
||||
// computeParamsForTFFASwap takes care of the VERY COMPLEX tasks of calculating
|
||||
// the amount, routing fee and on-chain fee for a TFFA swap. Calculating the
|
||||
// on-chain fee is pretty straightforward but for the other two we use what
|
||||
// we call "the equation". Let's dive into how it works:
|
||||
//
|
||||
// Let:
|
||||
// - x be the payment amount
|
||||
// - l be the lightning/routing fee
|
||||
// - h be the onchain fee for amount x
|
||||
// - y the available user balance (utxoBalance - expectedDebt)
|
||||
//
|
||||
// Also, given a specific route, l is a linear function of x l(x) = a * x + b,
|
||||
// where a and b are known.
|
||||
//
|
||||
// For amountLessInvoices, we need to figure out x and l such that x + l = y - h
|
||||
//
|
||||
// Note:
|
||||
// - we don't care about debt (except to calculate user balance) since it
|
||||
// doesn't affect that payment/offchain amount and thus the routingFee. It may
|
||||
// affect the onchain fee though, which is already calculated considering it.
|
||||
// - we don't care about output padding, since it can either be:
|
||||
// - issued debt, in which case point above still holds
|
||||
// - taken by fee, which would mean we are in a TFFA for a sub-dust amount,
|
||||
// which is unpayable since we don't have balance to add as padding/fee.
|
||||
//
|
||||
// Suppose we have only one route.
|
||||
// Then, x + l(x) = y - h
|
||||
// Then, x + (a * x + b) = y - h
|
||||
// Then, x * (1 + a) = y - h - b
|
||||
// Then, x = (y - h - b) / (a + 1) (*1)
|
||||
//
|
||||
// BUT, we can have different routes for different amounts, aka:
|
||||
// l_1(x) = a_1 * x + b_1
|
||||
// l_2(x) = a_2 * x + b_2
|
||||
// l_3(x) = a_3 * x + b_3
|
||||
// etc...
|
||||
//
|
||||
// What we can do is try out each l(x) corresponding to each route and check
|
||||
// which one gives us a valid amount (e.g amount < route.maxCapacityInSat), in
|
||||
// other words, which route is the "best route" associated with that amount.
|
||||
// BestRouteFees guarantees that for each amount there's only one "best route"
|
||||
// and that there is a route for each amount.
|
||||
//
|
||||
// Final note, our final equation looks a little different since our param a
|
||||
// is given as FeeProportionalMillionth/1_000_000, so in order to not lose
|
||||
// precision we need to massage equation *1 bit more:
|
||||
//
|
||||
// x = (y - h - b) / (FeeProportionalMillionth/1_000_000 + 1)
|
||||
// x = (y - h - b) / (FeeProportionalMillionth + 1_000_000) / 1_000_000
|
||||
// x = ((y - h - b) * 1_000_000) / (FeeProportionalMillionth + 1_000_000)
|
||||
//
|
||||
func (a *PaymentAnalyzer) computeParamsForTFFASwap(payment *PaymentToInvoice, confs uint) (*swapParams, error) {
|
||||
feeRate, err := a.feeWindow.SwapFeeRate(confs)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
onChainFeeInSat := a.computeFeeForTFFASwap(payment, feeRate)
|
||||
|
||||
for _, bestRouteFees := range payment.BestRouteFees {
|
||||
amount := btcutil.Amount(
|
||||
(a.totalBalance() - onChainFeeInSat - int64(bestRouteFees.FeeBase)) * 1_000_000 /
|
||||
(int64(bestRouteFees.FeeProportionalMillionth) + 1_000_000))
|
||||
|
||||
lightningFee := bestRouteFees.ForAmount(amount)
|
||||
|
||||
// Warning, this is an ugly hack because we are not (yet) using millisats as unit of
|
||||
// account thus we might an amount 1 sat smaller than the previous implementation.
|
||||
// What this check does is to see if we can send just one sat more with the safe fee
|
||||
// in sats. It probably has some edge cases in some cases but we have been ignoring
|
||||
// them for now so we can live with it.
|
||||
if bestRouteFees.ForAmount(amount+1) == lightningFee {
|
||||
amount += 1
|
||||
}
|
||||
|
||||
if amount+lightningFee <= bestRouteFees.MaxCapacity {
|
||||
|
||||
// There's a special comment to be made here for VERY edgy case where
|
||||
// bestRouteFees.ForAmount(amount+1) == lightningFee+1.
|
||||
// In this case adding 1 sat to the amount makes you need an extra sat for the
|
||||
// routingFee, and these 2 extra sats make the total go over userBalance, but
|
||||
// there's 1 sat available in our balance. What do we do it? Answer: nothing,
|
||||
// it will be burn as on-chain fee.
|
||||
|
||||
return &swapParams{
|
||||
Amount: amount,
|
||||
RoutingFee: lightningFee,
|
||||
OnChainFee: btcutil.Amount(onChainFeeInSat),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("none of the best route fees have enough capacity")
|
||||
}
|
||||
|
||||
func (a *PaymentAnalyzer) computeFeeForTFFASwap(payment *PaymentToInvoice, feeRate float64) int64 {
|
||||
// Compute tha on-chain fee. As its TFFA, we want to calculate the fee for the total balance
|
||||
// including any sats we want to collect.
|
||||
onChainAmount := a.totalBalance() + int64(payment.FundingOutputPolicies.PotentialCollect)
|
||||
|
||||
return a.feeCalculator.Fee(onChainAmount, feeRate, true)
|
||||
}
|
||||
|
||||
// MaxFeeRateToAddress computes the maximum fee rate that can be used when
|
||||
// paying a given amount. This does not imply that the payment _can be made_.
|
||||
// When given invalid parameters, it's likely to still obtain a value here and
|
||||
// the resulting analysis would be Unpayable. It's up to the caller to first
|
||||
// verify the amount is payable, and only then call this method.
|
||||
func (a *PaymentAnalyzer) MaxFeeRateToAddress(payment *PaymentToAddress) float64 {
|
||||
|
||||
if payment.AmountInSat > a.totalBalance() {
|
||||
return 0
|
||||
}
|
||||
|
||||
var restInSat int64
|
||||
if payment.TakeFeeFromAmount {
|
||||
restInSat = payment.AmountInSat
|
||||
} else {
|
||||
restInSat = a.totalBalance() - payment.AmountInSat
|
||||
}
|
||||
|
||||
for _, sizeForAmount := range a.nextTransactionSize.SizeProgression {
|
||||
if sizeForAmount.AmountInSat >= payment.AmountInSat {
|
||||
return float64(restInSat) / float64(sizeForAmount.SizeInVByte)
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
2408
libwallet/operation/payment_analyzer_test.go
Normal file
2408
libwallet/operation/payment_analyzer_test.go
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user