mirror of
https://github.com/muun/recovery.git
synced 2025-11-11 14:30:19 -05:00
1107 lines
28 KiB
Go
1107 lines
28 KiB
Go
package newop
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/btcsuite/btcutil"
|
|
"github.com/muun/libwallet"
|
|
"github.com/muun/libwallet/operation"
|
|
)
|
|
|
|
// Transitions that involve asynchronous work block, so apps should always fire in background and
|
|
// observe results.
|
|
//
|
|
// Each State has a concrete type, that embeds a BaseState. The BaseState holds data shared by
|
|
// all states, usually forwarded with every transition.
|
|
|
|
// State is an interface implemented by every state type, containing flow-level properties.
|
|
type State interface {
|
|
GetUpdate() string
|
|
}
|
|
|
|
// States are emitted in Transitions and contain a certain Update type.
|
|
// UpdateAll is the default. Means the state is meant to update the UI entirely. This is currently
|
|
// handled differently by clients. Falcon takes it as a built UI from scratch, while Apollo
|
|
// only builds from scratch if it has to, otherwise it just updates values.
|
|
// UpdateEmpty means the State machine is coming back to a previous State and UI needs no further
|
|
// updating.
|
|
// UpdateInPlace is a special update type for Falcon, it's used to just update values in place,
|
|
// no building UI from scratch.
|
|
const (
|
|
UpdateAll = ""
|
|
UpdateEmpty = "UpdateEmpty"
|
|
UpdateInPlace = "UpdateInPlace"
|
|
)
|
|
|
|
// TransitionListener allows app-level code to receive state updates asynchronously, preserving
|
|
// concrete types across the bridge and enforcing type safety.
|
|
type TransitionListener interface {
|
|
OnStart(nextState *StartState)
|
|
OnResolve(nextState *ResolveState)
|
|
OnEnterAmount(nextState *EnterAmountState)
|
|
OnEnterDescription(nextState *EnterDescriptionState)
|
|
OnValidate(nextState *ValidateState)
|
|
OnValidateLightning(nextState *ValidateLightningState)
|
|
OnConfirm(nextState *ConfirmState)
|
|
OnConfirmLightning(nextState *ConfirmLightningState)
|
|
OnEditFee(nextState *EditFeeState)
|
|
OnError(nextState *ErrorState)
|
|
OnBalanceError(nextState *BalanceErrorState)
|
|
OnAbort(nextState *AbortState)
|
|
}
|
|
|
|
// NewOperationFlow sets up the StartState, so transitions will be reported to `listener`
|
|
func NewOperationFlow(listener TransitionListener) *StartState {
|
|
initial := &StartState{
|
|
BaseState: BaseState{
|
|
listener: listener,
|
|
},
|
|
}
|
|
|
|
return initial
|
|
}
|
|
|
|
// -------------------------------------------------------------------------------------------------
|
|
|
|
// BaseState contains the shared structure among all states
|
|
type BaseState struct {
|
|
listener TransitionListener
|
|
update string
|
|
}
|
|
|
|
func (s *BaseState) GetUpdate() string {
|
|
return s.update
|
|
}
|
|
|
|
type Resolved struct {
|
|
BaseState
|
|
PaymentIntent *PaymentIntent
|
|
PaymentContext *PaymentContext
|
|
PresetAmount *BitcoinAmount
|
|
PresetNote string
|
|
}
|
|
|
|
func (r Resolved) emitError(error string) error {
|
|
nextState := &ErrorState{
|
|
BaseState: r.BaseState,
|
|
PaymentIntent: r.PaymentIntent,
|
|
Error: error,
|
|
}
|
|
nextState.emit()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r Resolved) emitBalanceError(error string, analysis *operation.PaymentAnalysis, inputCurrency string) error {
|
|
|
|
toMonetaryAmount := func(sats int64) *MonetaryAmount {
|
|
window := r.PaymentContext.ExchangeRateWindow
|
|
return window.convert(NewMonetaryAmountFromSatoshis(sats), inputCurrency)
|
|
}
|
|
|
|
next := &BalanceErrorState{
|
|
BaseState: r.BaseState,
|
|
PaymentIntent: r.PaymentIntent,
|
|
TotalAmount: toMonetaryAmount(analysis.TotalInSat),
|
|
Balance: toMonetaryAmount(r.PaymentContext.totalBalance()),
|
|
Error: error,
|
|
}
|
|
next.emit()
|
|
|
|
return nil
|
|
}
|
|
|
|
type AmountInfo struct {
|
|
Amount *BitcoinAmount
|
|
TotalBalance *BitcoinAmount
|
|
TakeFeeFromAmount bool
|
|
FeeRateInSatsPerVByte float64
|
|
}
|
|
|
|
func (a *AmountInfo) mutating(f func(*AmountInfo)) *AmountInfo {
|
|
// Deref to make a copy before mutating. Otherwise we mutate the original.
|
|
mutated := *a
|
|
f(&mutated)
|
|
return &mutated
|
|
}
|
|
|
|
type Validated struct {
|
|
analysis *operation.PaymentAnalysis
|
|
Fee *BitcoinAmount
|
|
FeeNeedsChange bool
|
|
Total *BitcoinAmount
|
|
SwapInfo *SwapInfo
|
|
}
|
|
|
|
type SwapInfo struct {
|
|
IsOneConf bool
|
|
OnchainFee *BitcoinAmount
|
|
SwapFees *SwapFees
|
|
}
|
|
|
|
// -------------------------------------------------------------------------------------------------
|
|
|
|
type StartState struct {
|
|
BaseState
|
|
}
|
|
|
|
func (s *StartState) Resolve(address string, network *libwallet.Network) error {
|
|
uri, err := libwallet.GetPaymentURI(address, network)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(uri.Bip70Url) > 0 {
|
|
// We resolve BIP70 here to avoid hoping about in the apps with the data
|
|
// However, this has the consequence of making the BIP70 resolution
|
|
// and loading of real time data sequential. While this is a bit worse
|
|
// BIP70 is rarely used, and we should usually have RTD up to date.
|
|
|
|
go s.resolveBip70(uri, network)
|
|
return nil
|
|
}
|
|
|
|
next := &ResolveState{
|
|
BaseState: s.BaseState,
|
|
PaymentIntent: &PaymentIntent{
|
|
URI: uri,
|
|
},
|
|
}
|
|
|
|
next.emit()
|
|
return nil
|
|
}
|
|
|
|
func (s *StartState) resolveBip70(uri *libwallet.MuunPaymentURI, network *libwallet.Network) {
|
|
|
|
intent := &PaymentIntent{
|
|
URI: uri,
|
|
}
|
|
|
|
bip70, err := libwallet.DoPaymentRequestCall(uri.Bip70Url, network)
|
|
if err != nil {
|
|
|
|
fmt.Printf("Error resolving bip70 uri: %s. Error: %v\n", uri.Bip70Url, err)
|
|
|
|
// If we failed to resolve the URI but we had an address set, use that!
|
|
if len(uri.Address) > 0 {
|
|
next := &ResolveState{
|
|
BaseState: s.BaseState,
|
|
PaymentIntent: intent,
|
|
}
|
|
next.emit()
|
|
|
|
// If the error contains the expired string, that means that the invoice has expired
|
|
} else if strings.Contains(err.Error(), "failed to unmarshal payment request") {
|
|
next := &ErrorState{
|
|
BaseState: s.BaseState,
|
|
PaymentIntent: intent,
|
|
Error: OperationErrorInvoiceExpired,
|
|
}
|
|
next.emit()
|
|
|
|
// In any other case we display the invalid address message
|
|
} else {
|
|
next := &ErrorState{
|
|
BaseState: s.BaseState,
|
|
PaymentIntent: intent,
|
|
Error: OperationErrorInvalidAddress,
|
|
}
|
|
next.emit()
|
|
}
|
|
|
|
} else {
|
|
|
|
fmt.Printf("Successfully resolved bip70 uri: %s\n", uri.Bip70Url)
|
|
next := &ResolveState{
|
|
BaseState: s.BaseState,
|
|
PaymentIntent: &PaymentIntent{
|
|
URI: bip70,
|
|
},
|
|
}
|
|
next.emit()
|
|
}
|
|
}
|
|
|
|
func (s *StartState) ResolveInvoice(invoice *libwallet.Invoice, network *libwallet.Network) error {
|
|
next := &ResolveState{
|
|
BaseState: s.BaseState,
|
|
PaymentIntent: &PaymentIntent{
|
|
URI: &libwallet.MuunPaymentURI{
|
|
Invoice: invoice,
|
|
},
|
|
},
|
|
}
|
|
|
|
next.emit()
|
|
return nil
|
|
}
|
|
|
|
// -------------------------------------------------------------------------------------------------
|
|
|
|
// PaymentIntent contains the resolved payment intent and does not change during the flow
|
|
type PaymentIntent struct {
|
|
URI *libwallet.MuunPaymentURI
|
|
}
|
|
|
|
func (p *PaymentIntent) Amount() *MonetaryAmount {
|
|
if p.URI.Invoice != nil && p.URI.Invoice.Sats != 0 {
|
|
return NewMonetaryAmountFromSatoshis(p.URI.Invoice.Sats)
|
|
}
|
|
if p.URI.Amount != "" {
|
|
return NewMonetaryAmountFromFiat(p.URI.Amount, "BTC")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type ResolveState struct {
|
|
BaseState
|
|
PaymentIntent *PaymentIntent
|
|
}
|
|
|
|
func (s *ResolveState) SetContext(context *PaymentContext) error {
|
|
return s.setContextWithTime(context, time.Now())
|
|
}
|
|
|
|
// setContextWithTime is meant only for testing, allows caller to use a fixed time to check invoice expiration
|
|
func (s *ResolveState) setContextWithTime(context *PaymentContext, now time.Time) error {
|
|
|
|
// TODO(newop): add type to PaymentIntent to clarify lightning/onchain distinction
|
|
invoice := s.PaymentIntent.URI.Invoice
|
|
totalBalance := context.toBitcoinAmount(context.totalBalance(), context.PrimaryCurrency)
|
|
|
|
if invoice != nil {
|
|
// check expired and send to error state
|
|
if time.Unix(invoice.Expiry, 0).Before(now) {
|
|
return s.emitError(OperationErrorInvoiceExpired)
|
|
}
|
|
|
|
if invoice.Sats > 0 {
|
|
return s.emitValidateLightning(context, invoice, totalBalance)
|
|
}
|
|
|
|
return s.emitAmount(context, totalBalance)
|
|
}
|
|
|
|
amount := s.PaymentIntent.Amount()
|
|
|
|
if s.PaymentIntent.URI.Amount != "" && amount == nil {
|
|
return s.emitError(OperationErrorInvalidAddress)
|
|
}
|
|
|
|
if amount != nil {
|
|
|
|
if amount.toBtc(context.ExchangeRateWindow) < operation.DustThreshold {
|
|
return s.emitError(OperationErrorAmountTooSmall)
|
|
}
|
|
|
|
amount := amount.toBitcoinAmount(context.ExchangeRateWindow, context.PrimaryCurrency)
|
|
return s.emitValidate(&Resolved{
|
|
BaseState: s.BaseState,
|
|
PaymentIntent: s.PaymentIntent,
|
|
PaymentContext: context,
|
|
PresetAmount: amount,
|
|
}, amount, context)
|
|
}
|
|
|
|
return s.emitAmount(context, totalBalance)
|
|
}
|
|
|
|
func (s *ResolveState) emitAmount(context *PaymentContext, totalBalance *BitcoinAmount) error {
|
|
|
|
amount := NewMonetaryAmountFromFiat("0", context.PrimaryCurrency).
|
|
toBitcoinAmount(context.ExchangeRateWindow, context.PrimaryCurrency)
|
|
|
|
nextState := &EnterAmountState{
|
|
Resolved: &Resolved{
|
|
BaseState: s.BaseState,
|
|
PaymentIntent: s.PaymentIntent,
|
|
PaymentContext: context,
|
|
},
|
|
Amount: amount,
|
|
TotalBalance: totalBalance,
|
|
}
|
|
nextState.emit()
|
|
return nil
|
|
}
|
|
|
|
func (s *ResolveState) emitValidateLightning(context *PaymentContext, invoice *libwallet.Invoice, totalBalance *BitcoinAmount) error {
|
|
presetAmount := context.toBitcoinAmount(invoice.Sats, "BTC")
|
|
presetNote := s.PaymentIntent.URI.Invoice.Description
|
|
nextState := &ValidateLightningState{
|
|
Resolved: &Resolved{
|
|
BaseState: s.BaseState,
|
|
PaymentIntent: s.PaymentIntent,
|
|
PaymentContext: context,
|
|
PresetAmount: presetAmount,
|
|
PresetNote: presetNote,
|
|
},
|
|
AmountInfo: &AmountInfo{
|
|
Amount: presetAmount,
|
|
TotalBalance: totalBalance,
|
|
TakeFeeFromAmount: false,
|
|
},
|
|
Note: presetNote,
|
|
}
|
|
nextState.emit()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *ResolveState) emitValidate(resolved *Resolved, amount *BitcoinAmount, context *PaymentContext) error {
|
|
|
|
nextState := &ValidateState{
|
|
Resolved: resolved,
|
|
AmountInfo: &AmountInfo{
|
|
TotalBalance: context.toBitcoinAmount(context.totalBalance(), "BTC"),
|
|
Amount: amount,
|
|
TakeFeeFromAmount: false,
|
|
FeeRateInSatsPerVByte: context.FeeWindow.toInternalType().FastestFeeRate(),
|
|
},
|
|
}
|
|
nextState.emit()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *ResolveState) emitError(error string) error {
|
|
nextState := &ErrorState{
|
|
BaseState: s.BaseState,
|
|
PaymentIntent: s.PaymentIntent,
|
|
Error: error,
|
|
}
|
|
nextState.emit()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *ResolveState) emit() {
|
|
s.listener.OnResolve(s)
|
|
}
|
|
|
|
// -------------------------------------------------------------------------------------------------
|
|
|
|
type EnterAmountState struct {
|
|
*Resolved
|
|
Amount *BitcoinAmount
|
|
TotalBalance *BitcoinAmount
|
|
}
|
|
|
|
func (s *EnterAmountState) EnterAmount(amount *MonetaryAmount, takeFeeFromAmount bool) error {
|
|
|
|
// Let's enforce this business logic here. Our payment analyzer enforces
|
|
// IF tffa/useAllFunds THEN amount == balance
|
|
// But we also want the contraposition:
|
|
// IF amount == balance THEN tffa/useAllFunds
|
|
if amount.String() == s.TotalBalance.InInputCurrency.String() {
|
|
amount = s.TotalBalance.InInputCurrency
|
|
takeFeeFromAmount = true
|
|
}
|
|
|
|
feeWindow := s.PaymentContext.FeeWindow.toInternalType()
|
|
amountInfo := &AmountInfo{
|
|
Amount: amount.toBitcoinAmount(
|
|
s.PaymentContext.ExchangeRateWindow,
|
|
s.PaymentContext.PrimaryCurrency,
|
|
),
|
|
TakeFeeFromAmount: takeFeeFromAmount,
|
|
TotalBalance: s.TotalBalance,
|
|
FeeRateInSatsPerVByte: feeWindow.FastestFeeRate(),
|
|
}
|
|
|
|
if s.Resolved.PaymentIntent.URI.Invoice != nil {
|
|
|
|
nextState := &ValidateLightningState{
|
|
Resolved: s.Resolved,
|
|
AmountInfo: amountInfo,
|
|
Note: s.PaymentIntent.URI.Invoice.Description,
|
|
}
|
|
nextState.emit()
|
|
|
|
} else {
|
|
|
|
nextState := &ValidateState{
|
|
Resolved: s.Resolved,
|
|
AmountInfo: amountInfo,
|
|
Note: s.Resolved.PaymentIntent.URI.Message,
|
|
}
|
|
nextState.emit()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *EnterAmountState) PartialValidate(inputAmount *MonetaryAmount) (bool, error) {
|
|
|
|
amountInSat := int64(inputAmount.toBtc(s.PaymentContext.ExchangeRateWindow))
|
|
isSwap := s.PaymentContext.SubmarineSwap != nil
|
|
|
|
minAmount := int64(operation.DustThreshold)
|
|
if isSwap {
|
|
minAmount = 0
|
|
}
|
|
|
|
if amountInSat < minAmount || amountInSat > s.TotalBalance.InSat {
|
|
return false, nil
|
|
}
|
|
|
|
return true, nil
|
|
}
|
|
|
|
// ChangeCurrency is deprecated. Prefer the newer, ChangeCurrencyWithAmount(currency, inputAmount)
|
|
func (s *EnterAmountState) ChangeCurrency(currency string) error {
|
|
return s.ChangeCurrencyWithAmount(currency, s.Amount.InInputCurrency)
|
|
}
|
|
|
|
// ChangeCurrencyWithAmount respond to the user action of changing the current input currency to a new one,
|
|
// while also updating the input amount, needed for performing the necessary conversion.
|
|
// Note: this state machine doesn't receive partial updates for the input amount each time the
|
|
// user types or deletes a digit, so ChangeCurrencyWithAmount needs to receive the updates input amount.
|
|
func (s *EnterAmountState) ChangeCurrencyWithAmount(currency string, inputAmount *MonetaryAmount) error {
|
|
exchangeRateWindow := s.PaymentContext.ExchangeRateWindow
|
|
|
|
newTotalBalance := s.PaymentContext.toBitcoinAmount(
|
|
s.PaymentContext.totalBalance(),
|
|
currency,
|
|
)
|
|
|
|
var amount *BitcoinAmount
|
|
// IF amount == balance THEN tffa/useAllFunds
|
|
// See EnterAmount transition.
|
|
if inputAmount.String() == s.TotalBalance.InInputCurrency.String() {
|
|
amount = newTotalBalance
|
|
} else {
|
|
amount = &BitcoinAmount{
|
|
InSat: int64(inputAmount.toBtc(exchangeRateWindow)),
|
|
InInputCurrency: exchangeRateWindow.convert(inputAmount, currency),
|
|
InPrimaryCurrency: exchangeRateWindow.convert(inputAmount, s.PaymentContext.PrimaryCurrency),
|
|
}
|
|
}
|
|
|
|
nextState := &EnterAmountState{
|
|
Resolved: s.Resolved,
|
|
Amount: amount,
|
|
TotalBalance: newTotalBalance,
|
|
}
|
|
nextState.emitUpdate(UpdateInPlace)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *EnterAmountState) Back() error {
|
|
nextState := &AbortState{
|
|
BaseState: BaseState{
|
|
listener: s.listener, // create new BaseState to avoid passing along update string
|
|
},
|
|
PreviousState: s,
|
|
}
|
|
nextState.emit()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *EnterAmountState) emit() {
|
|
s.emitUpdate(UpdateAll)
|
|
}
|
|
|
|
func (s *EnterAmountState) emitUpdate(update string) {
|
|
s.update = update
|
|
s.listener.OnEnterAmount(s)
|
|
}
|
|
|
|
// -------------------------------------------------------------------------------------------------
|
|
|
|
type EnterDescriptionState struct {
|
|
*Resolved
|
|
*AmountInfo
|
|
*Validated
|
|
Note string
|
|
}
|
|
|
|
func (s *EnterDescriptionState) EnterDescription(description string) error {
|
|
|
|
if s.PaymentIntent.URI.Invoice == nil {
|
|
|
|
nextState := &ConfirmState{
|
|
Resolved: s.Resolved,
|
|
AmountInfo: s.AmountInfo,
|
|
Validated: s.Validated,
|
|
Note: description,
|
|
}
|
|
nextState.emit()
|
|
|
|
} else {
|
|
|
|
nextState := &ConfirmLightningState{
|
|
Resolved: s.Resolved,
|
|
AmountInfo: s.AmountInfo,
|
|
Validated: s.Validated,
|
|
Note: description,
|
|
}
|
|
|
|
nextState.emit()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *EnterDescriptionState) Back() error {
|
|
|
|
if s.PaymentIntent.Amount() != nil {
|
|
|
|
nextState := &AbortState{
|
|
BaseState: BaseState{
|
|
listener: s.listener, // create new BaseState to avoid passing along update string
|
|
},
|
|
PreviousState: s,
|
|
}
|
|
nextState.emit()
|
|
|
|
} else {
|
|
|
|
amount := s.Amount
|
|
if s.TakeFeeFromAmount {
|
|
amount = s.TotalBalance
|
|
}
|
|
|
|
nextState := &EnterAmountState{
|
|
Resolved: s.Resolved,
|
|
Amount: amount,
|
|
TotalBalance: s.TotalBalance,
|
|
}
|
|
nextState.emit()
|
|
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *EnterDescriptionState) emit() {
|
|
s.emitUpdate(UpdateAll)
|
|
}
|
|
|
|
func (s *EnterDescriptionState) emitUpdate(update string) {
|
|
s.update = update
|
|
s.listener.OnEnterDescription(s)
|
|
}
|
|
|
|
// -------------------------------------------------------------------------------------------------
|
|
|
|
type ValidateState struct {
|
|
*Resolved
|
|
*AmountInfo
|
|
Note string
|
|
}
|
|
|
|
func (s *ValidateState) Continue() error {
|
|
|
|
amountInSat := s.Amount.toBtc()
|
|
if s.TakeFeeFromAmount {
|
|
amountInSat = btcutil.Amount(s.TotalBalance.InSat)
|
|
}
|
|
|
|
inputCurrency := s.Amount.InInputCurrency.Currency
|
|
|
|
analyzer := newPaymentAnalyzer(s.PaymentContext)
|
|
|
|
var analysis *operation.PaymentAnalysis
|
|
var err error
|
|
|
|
analysis, err = analyzer.ToAddress(&operation.PaymentToAddress{
|
|
TakeFeeFromAmount: s.TakeFeeFromAmount,
|
|
AmountInSat: int64(amountInSat),
|
|
FeeRateInSatsPerVByte: s.FeeRateInSatsPerVByte,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
switch analysis.Status {
|
|
case operation.AnalysisStatusOk:
|
|
return s.emitAnalysisOk(analysis, false)
|
|
|
|
case operation.AnalysisStatusUnpayable:
|
|
|
|
// redo the analysis with min fee rate
|
|
minFeeAnalyzer := newPaymentAnalyzer(s.PaymentContext)
|
|
minFeeAnalysis, err := minFeeAnalyzer.ToAddress(&operation.PaymentToAddress{
|
|
TakeFeeFromAmount: s.TakeFeeFromAmount,
|
|
AmountInSat: int64(amountInSat),
|
|
FeeRateInSatsPerVByte: s.PaymentContext.MinFeeRateInSatsPerVByte,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
switch minFeeAnalysis.Status {
|
|
case operation.AnalysisStatusOk:
|
|
// The UI expects to show the full unpayable fee, so we still
|
|
// use the failed analysis here.
|
|
return s.emitAnalysisOk(analysis, true)
|
|
|
|
// We couldn't even compute the minimum fee, amount must be above balance. We'll
|
|
// show the minimum fee for all funds (which is, of course, a lie):
|
|
case operation.AnalysisStatusUnpayable:
|
|
|
|
// We'll do a "best effort" guess of what minimum balance the user would
|
|
// need to pay for the requested amount, even though that's not real or
|
|
// accurate (its impossible to calculate the fee for an amount greater na
|
|
// than the sum of the utxos, because the fee depends on the number and
|
|
// the type of the utxos used). Our best guess is calculating the fee of a
|
|
// use all funds transaction and adding that to the requested amount.
|
|
|
|
return s.emitBalanceError(OperationErrorUnpayable, minFeeAnalysis, inputCurrency)
|
|
}
|
|
|
|
case operation.AnalysisStatusAmountGreaterThanBalance,
|
|
operation.AnalysisStatusAmountTooSmall:
|
|
|
|
switch analysis.Status {
|
|
case operation.AnalysisStatusUnpayable:
|
|
return s.emitBalanceError(OperationErrorUnpayable, analysis, inputCurrency)
|
|
|
|
case operation.AnalysisStatusAmountGreaterThanBalance:
|
|
return s.emitBalanceError(OperationErrorAmountGreaterThanBalance, analysis, inputCurrency)
|
|
|
|
case operation.AnalysisStatusAmountTooSmall:
|
|
return s.emitError(OperationErrorAmountTooSmall)
|
|
}
|
|
|
|
default:
|
|
return fmt.Errorf("unrecognized analysis status: %v", analysis.Status)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *ValidateState) emitAnalysisOk(analysis *operation.PaymentAnalysis, feeNeedsChange bool) error {
|
|
|
|
amount := s.Amount
|
|
if s.TakeFeeFromAmount {
|
|
amount = s.PaymentContext.toBitcoinAmount(
|
|
analysis.AmountInSat,
|
|
s.Amount.InInputCurrency.Currency,
|
|
)
|
|
}
|
|
fee := s.PaymentContext.toBitcoinAmount(
|
|
analysis.FeeInSat,
|
|
s.Amount.InInputCurrency.Currency,
|
|
)
|
|
validated := &Validated{
|
|
analysis: analysis,
|
|
Fee: fee,
|
|
FeeNeedsChange: feeNeedsChange,
|
|
Total: amount.add(fee),
|
|
}
|
|
|
|
amountInfo := s.AmountInfo.mutating(func(info *AmountInfo) {
|
|
info.Amount = amount
|
|
})
|
|
|
|
if s.PaymentIntent.URI.Message != "" || s.Note != "" {
|
|
|
|
note := s.Note
|
|
if note == "" {
|
|
note = s.PaymentIntent.URI.Message
|
|
}
|
|
|
|
nextState := &ConfirmState{
|
|
Resolved: s.Resolved,
|
|
AmountInfo: amountInfo,
|
|
Validated: validated,
|
|
Note: note,
|
|
}
|
|
nextState.emitUpdate(s.update)
|
|
|
|
} else {
|
|
|
|
nextState := &EnterDescriptionState{
|
|
Resolved: s.Resolved,
|
|
AmountInfo: amountInfo,
|
|
Validated: validated,
|
|
}
|
|
nextState.emit()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *ValidateState) emit() {
|
|
s.emitUpdate(UpdateAll)
|
|
}
|
|
|
|
func (s *ValidateState) emitUpdate(update string) {
|
|
s.update = update
|
|
s.listener.OnValidate(s)
|
|
}
|
|
|
|
// -------------------------------------------------------------------------------------------------
|
|
|
|
type ValidateLightningState struct {
|
|
*Resolved
|
|
*AmountInfo
|
|
Note string
|
|
}
|
|
|
|
func (s *ValidateLightningState) Continue() error {
|
|
|
|
amountInSat := s.Amount.toBtc()
|
|
if s.TakeFeeFromAmount {
|
|
amountInSat = btcutil.Amount(s.TotalBalance.InSat)
|
|
}
|
|
|
|
inputCurrency := s.Amount.InInputCurrency.Currency
|
|
|
|
swap := s.PaymentContext.SubmarineSwap
|
|
|
|
analyzer := newPaymentAnalyzer(s.PaymentContext)
|
|
analysis, err := analyzer.ToInvoice(&operation.PaymentToInvoice{
|
|
TakeFeeFromAmount: s.TakeFeeFromAmount,
|
|
AmountInSat: int64(amountInSat),
|
|
SwapFees: swap.Fees.toInternalType(),
|
|
BestRouteFees: swap.toBestRouteFeesInternalType(),
|
|
FundingOutputPolicies: swap.FundingOutputPolicies.toInternalType(),
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
switch analysis.Status {
|
|
case operation.AnalysisStatusOk:
|
|
|
|
s.emitAnalysisOk(analysis)
|
|
|
|
case operation.AnalysisStatusUnpayable:
|
|
|
|
return s.emitBalanceError(OperationErrorUnpayable, analysis, inputCurrency)
|
|
|
|
case operation.AnalysisStatusAmountGreaterThanBalance:
|
|
|
|
return s.emitBalanceError(OperationErrorAmountGreaterThanBalance, analysis, inputCurrency)
|
|
|
|
case operation.AnalysisStatusAmountTooSmall:
|
|
return s.emitError(OperationErrorAmountTooSmall)
|
|
|
|
default:
|
|
return fmt.Errorf("unrecognized analysis status: %v", analysis.Status)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *ValidateLightningState) emitAnalysisOk(analysis *operation.PaymentAnalysis) {
|
|
note := s.Note
|
|
if note != "" {
|
|
note = s.PaymentIntent.URI.Invoice.Description
|
|
}
|
|
|
|
amount := s.Amount
|
|
if s.TakeFeeFromAmount {
|
|
amount = s.PaymentContext.toBitcoinAmount(
|
|
analysis.AmountInSat,
|
|
s.Amount.InInputCurrency.Currency,
|
|
)
|
|
}
|
|
|
|
onchainFee := s.PaymentContext.toBitcoinAmount(
|
|
analysis.FeeInSat,
|
|
s.Amount.InInputCurrency.Currency,
|
|
)
|
|
|
|
swapFees := newSwapFeesFromInternal(analysis.SwapFees)
|
|
var offchainFee *BitcoinAmount
|
|
var totalFee *BitcoinAmount
|
|
|
|
if swapFees.DebtType == DebtTypeLend {
|
|
offchainFee = s.PaymentContext.toBitcoinAmount(
|
|
swapFees.RoutingFeeInSat,
|
|
s.Amount.InInputCurrency.Currency,
|
|
)
|
|
totalFee = offchainFee
|
|
} else {
|
|
offchainFee = s.PaymentContext.toBitcoinAmount(
|
|
swapFees.RoutingFeeInSat+swapFees.OutputPaddingInSat,
|
|
s.Amount.InInputCurrency.Currency,
|
|
)
|
|
totalFee = onchainFee.add(offchainFee)
|
|
}
|
|
|
|
isOneConf := analysis.SwapFees.ConfirmationsNeeded > 0
|
|
|
|
validated := &Validated{
|
|
Fee: totalFee,
|
|
Total: amount.add(totalFee),
|
|
analysis: analysis,
|
|
SwapInfo: &SwapInfo{
|
|
OnchainFee: onchainFee,
|
|
SwapFees: swapFees,
|
|
IsOneConf: isOneConf,
|
|
},
|
|
}
|
|
|
|
amountInfo := s.AmountInfo.mutating(func(info *AmountInfo) {
|
|
info.Amount = amount
|
|
})
|
|
|
|
if note != "" {
|
|
|
|
nextState := &ConfirmLightningState{
|
|
Resolved: s.Resolved,
|
|
AmountInfo: amountInfo,
|
|
Validated: validated,
|
|
Note: s.Note,
|
|
}
|
|
nextState.emit()
|
|
|
|
} else {
|
|
|
|
nextState := &EnterDescriptionState{
|
|
Resolved: s.Resolved,
|
|
AmountInfo: amountInfo,
|
|
Validated: validated,
|
|
}
|
|
nextState.emit()
|
|
}
|
|
}
|
|
|
|
func (s *ValidateLightningState) emit() {
|
|
s.emitUpdate(UpdateAll)
|
|
}
|
|
|
|
func (s *ValidateLightningState) emitUpdate(update string) {
|
|
s.update = update
|
|
s.listener.OnValidateLightning(s)
|
|
}
|
|
|
|
// -------------------------------------------------------------------------------------------------
|
|
|
|
const (
|
|
OperationErrorUnpayable = "Unpayable"
|
|
OperationErrorAmountGreaterThanBalance = "AmountGreaterThanBalance"
|
|
OperationErrorAmountTooSmall = "AmountTooSmall"
|
|
OperationErrorInvalidAddress = "InvalidAddress"
|
|
OperationErrorInvoiceExpired = "InvoiceExpired"
|
|
)
|
|
|
|
type ErrorState struct {
|
|
BaseState
|
|
PaymentIntent *PaymentIntent
|
|
Error string
|
|
}
|
|
|
|
func (s *ErrorState) emit() {
|
|
s.listener.OnError(s)
|
|
}
|
|
|
|
type BalanceErrorState struct {
|
|
BaseState
|
|
PaymentIntent *PaymentIntent
|
|
TotalAmount *MonetaryAmount
|
|
Balance *MonetaryAmount
|
|
Error string
|
|
}
|
|
|
|
func (s *BalanceErrorState) emit() {
|
|
s.listener.OnBalanceError(s)
|
|
}
|
|
|
|
// -------------------------------------------------------------------------------------------------
|
|
|
|
type ConfirmState struct {
|
|
*Resolved
|
|
*AmountInfo
|
|
*Validated
|
|
Note string
|
|
}
|
|
|
|
func (s *ConfirmState) OpenFeeEditor() error {
|
|
|
|
maxFeeRate := newPaymentAnalyzer(s.PaymentContext).MaxFeeRateToAddress(&operation.PaymentToAddress{
|
|
TakeFeeFromAmount: s.TakeFeeFromAmount,
|
|
AmountInSat: s.Amount.InSat,
|
|
FeeRateInSatsPerVByte: s.FeeRateInSatsPerVByte,
|
|
})
|
|
|
|
next := &EditFeeState{
|
|
Resolved: s.Resolved,
|
|
AmountInfo: s.AmountInfo,
|
|
Validated: s.Validated,
|
|
Note: s.Note,
|
|
MaxFeeRateInSatsPerVByte: maxFeeRate,
|
|
}
|
|
|
|
next.emit()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *ConfirmState) Back() error {
|
|
|
|
nextState := &EnterDescriptionState{
|
|
Resolved: s.Resolved,
|
|
AmountInfo: s.AmountInfo,
|
|
Validated: s.Validated,
|
|
Note: s.Note,
|
|
}
|
|
nextState.emit()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *ConfirmState) emit() {
|
|
s.emitUpdate(UpdateAll)
|
|
}
|
|
|
|
func (s *ConfirmState) emitUpdate(update string) {
|
|
s.update = update
|
|
s.listener.OnConfirm(s)
|
|
}
|
|
|
|
// -------------------------------------------------------------------------------------------------
|
|
|
|
type EditFeeState struct {
|
|
*Resolved
|
|
*AmountInfo
|
|
*Validated
|
|
Note string
|
|
MaxFeeRateInSatsPerVByte float64
|
|
}
|
|
|
|
func (s *EditFeeState) MinFeeRateForTarget(target int) (float64, error) {
|
|
feeWindow := s.PaymentContext.FeeWindow.toInternalType()
|
|
return feeWindow.MinimumFeeRate(uint(target))
|
|
}
|
|
|
|
// TODO this currently ignores and forgets input currency, which is important for amount display
|
|
// logic in Edit Fee screens
|
|
func (s *EditFeeState) CalculateFee(rateInSatsPerVByte float64) (*FeeState, error) {
|
|
amountInSat := s.Amount.InSat
|
|
if s.TakeFeeFromAmount {
|
|
amountInSat = s.TotalBalance.InSat
|
|
}
|
|
|
|
analyzer := newPaymentAnalyzer(s.PaymentContext)
|
|
analysis, err := analyzer.ToAddress(&operation.PaymentToAddress{
|
|
TakeFeeFromAmount: s.TakeFeeFromAmount,
|
|
AmountInSat: amountInSat,
|
|
FeeRateInSatsPerVByte: rateInSatsPerVByte,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// TODO(newop): add targetblock to analysis result
|
|
switch analysis.Status {
|
|
// TODO: this should never happen, right? It should have been detected earlier
|
|
case operation.AnalysisStatusAmountGreaterThanBalance, operation.AnalysisStatusAmountTooSmall:
|
|
return &FeeState{
|
|
State: FeeStateNoPossibleFee,
|
|
}, nil
|
|
case operation.AnalysisStatusUnpayable:
|
|
return &FeeState{
|
|
State: FeeStateNeedsChange,
|
|
Amount: s.toBitcoinAmount(analysis.FeeInSat),
|
|
RateInSatsPerVByte: rateInSatsPerVByte,
|
|
TargetBlocks: s.PaymentContext.FeeWindow.nextHighestBlock(rateInSatsPerVByte),
|
|
}, nil
|
|
case operation.AnalysisStatusOk:
|
|
return &FeeState{
|
|
State: FeeStateFinalFee,
|
|
Amount: s.toBitcoinAmount(analysis.FeeInSat),
|
|
RateInSatsPerVByte: rateInSatsPerVByte,
|
|
TargetBlocks: s.PaymentContext.FeeWindow.nextHighestBlock(rateInSatsPerVByte),
|
|
}, nil
|
|
default:
|
|
return nil, fmt.Errorf("unrecognized analysis status: %v", analysis.Status)
|
|
}
|
|
}
|
|
|
|
func (s *EditFeeState) SetFeeRate(rateInSatsPerVByte float64) error {
|
|
// We deref to copy before mutating
|
|
amountInfo := s.AmountInfo.mutating(func(info *AmountInfo) {
|
|
info.FeeRateInSatsPerVByte = rateInSatsPerVByte
|
|
})
|
|
|
|
nextState := &ValidateState{
|
|
Resolved: s.Resolved,
|
|
AmountInfo: amountInfo,
|
|
Note: s.Note,
|
|
}
|
|
nextState.emitUpdate(UpdateInPlace)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *EditFeeState) CloseEditor() error {
|
|
nextState := &ValidateState{
|
|
Resolved: s.Resolved,
|
|
AmountInfo: s.AmountInfo,
|
|
Note: s.Note,
|
|
}
|
|
nextState.emit()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *EditFeeState) toBitcoinAmount(sats int64) *BitcoinAmount {
|
|
return NewMonetaryAmountFromSatoshis(sats).toBitcoinAmount(
|
|
s.PaymentContext.ExchangeRateWindow,
|
|
s.PaymentContext.PrimaryCurrency,
|
|
)
|
|
}
|
|
|
|
func (s *EditFeeState) emit() {
|
|
s.listener.OnEditFee(s)
|
|
}
|
|
|
|
// -------------------------------------------------------------------------------------------------
|
|
|
|
type ConfirmLightningState struct {
|
|
*Resolved
|
|
*AmountInfo
|
|
*Validated
|
|
Note string
|
|
}
|
|
|
|
func (s *ConfirmLightningState) Back() error {
|
|
|
|
nextState := &EnterDescriptionState{
|
|
Resolved: s.Resolved,
|
|
AmountInfo: s.AmountInfo,
|
|
Validated: s.Validated,
|
|
Note: s.Note,
|
|
}
|
|
nextState.emit()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *ConfirmLightningState) emit() {
|
|
s.emitUpdate(UpdateAll)
|
|
}
|
|
|
|
func (s *ConfirmLightningState) emitUpdate(update string) {
|
|
s.update = update
|
|
s.listener.OnConfirmLightning(s)
|
|
}
|
|
|
|
// -------------------------------------------------------------------------------------------------
|
|
|
|
type AbortState struct {
|
|
BaseState
|
|
PreviousState interface {
|
|
emitUpdate(update string)
|
|
}
|
|
}
|
|
|
|
func (s *AbortState) emit() {
|
|
s.listener.OnAbort(s)
|
|
}
|
|
|
|
func (s *AbortState) Cancel() {
|
|
nextState := s.PreviousState
|
|
nextState.emitUpdate(UpdateEmpty)
|
|
}
|