Update project structure and build process

This commit is contained in:
Juan Pablo Civile
2025-05-13 11:10:08 -03:00
parent 124e9fa1bc
commit d9f3e925a4
277 changed files with 15321 additions and 930 deletions

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

View 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)
}
}

View 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))
}

View 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)
}
})
}
}

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

File diff suppressed because it is too large Load Diff