Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/lightning/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,9 @@ func (lightning *Lightning) PostPreparePayment(r *http.Request) interface{} {

fee, err := lightning.PreparePayment(jsonBody.Bolt11, jsonBody.AmountSat)
if err != nil {
if fee != nil && jsonBody.AmountSat != nil && errp.Cause(err) == errLightningInsufficientFunds {
return responseDto{Success: false, ErrorCode: string(errLightningInsufficientFunds), Data: fee}
}
Comment thread
strmci marked this conversation as resolved.
return errorResponse(err)
}

Expand Down
55 changes: 51 additions & 4 deletions backend/lightning/payments.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
package lightning

import (
"errors"
"math/big"
"strconv"
"strings"
"time"

"github.com/BitBoxSwiss/bitbox-wallet-app/backend/accounts"
Expand All @@ -13,7 +15,11 @@ import (
"github.com/breez/breez-sdk-spark-go/breez_sdk_spark"
)

const errPaymentApprovalRequired errp.ErrorCode = "paymentApprovalRequired"
const (
errPaymentApprovalRequired errp.ErrorCode = "paymentApprovalRequired"
errLightningInsufficientFunds errp.ErrorCode = "lightningInsufficientFunds"
errLightningInvoiceAlreadyUsed errp.ErrorCode = "lightningInvoiceAlreadyUsed"
)

type lightningInvoice struct {
Bolt11 string `json:"bolt11"`
Expand Down Expand Up @@ -241,20 +247,53 @@ func checkApprovedPaymentFee(fee uint64, approvedFee uint64) error {
return nil
}

func checkPaymentBalance(fee *paymentFee, balance *accounts.Balance) error {
if new(big.Int).SetUint64(fee.TotalDebitSat).Cmp(balance.Available().BigInt()) > 0 {
return errLightningInsufficientFunds
}
return nil
}

func lightningPaymentError(err error) error {
if err == nil {
return nil
}
if errors.Is(err, breez_sdk_spark.ErrSdkErrorInsufficientFunds) {
return errp.WithMessage(errLightningInsufficientFunds, err.Error())
}
errString := strings.ToLower(err.Error())
if strings.Contains(errString, "preimage request already exists") ||
(strings.Contains(errString, "duplicate_operation") && strings.Contains(errString, "paymenthash")) {
return errp.WithMessage(errLightningInvoiceAlreadyUsed, err.Error())
}
// Spark currently wraps insufficient funds as SdkErrorSparkError with this text.
if strings.Contains(errString, "insufficient funds") {
return errp.WithMessage(errLightningInsufficientFunds, err.Error())
}
return err
}

// PreparePayment computes the fee quote for the provided payment request.
func (lightning *Lightning) PreparePayment(paymentInvoice string, amountSat *uint64) (*paymentFee, error) {
if err := lightning.CheckActive(); err != nil {
return nil, err
}
prepareResponse, err := lightning.sdkService.PrepareSendPayment(prepareSendPaymentRequest(paymentInvoice, amountSat))
if err != nil {
return nil, err
return nil, lightningPaymentError(err)
}

fee, err := preparedPaymentFee(prepareResponse)
if err != nil {
return nil, err
}
balance, err := lightning.Balance()
if err != nil {
return nil, err
}
if err := checkPaymentBalance(fee, balance); err != nil {
return fee, err
}
lightning.log.Printf("Lightning Fee: %v sats", fee.FeeSat)
return fee, nil
}
Expand All @@ -271,13 +310,21 @@ func (lightning *Lightning) SendPayment(paymentInvoice string, amount *uint64, a

prepareResponse, err := lightning.sdkService.PrepareSendPayment(prepareSendPaymentRequest(paymentInvoice, amount))
if err != nil {
return err
return lightningPaymentError(err)
}

fee, err := preparedPaymentFee(prepareResponse)
if err != nil {
return err
}
balance, err := lightning.Balance()
if err != nil {
return err
}
// Re-check the balance because funds or the prepared fee can change between quote approval and send.
if err := checkPaymentBalance(fee, balance); err != nil {
return err
}
if err := checkApprovedPaymentFee(fee.FeeSat, approvedFee); err != nil {
return err
}
Expand All @@ -293,7 +340,7 @@ func (lightning *Lightning) SendPayment(paymentInvoice string, amount *uint64, a
_, err = lightning.sdkService.SendPayment(payRequest)

if err != nil {
return err
return lightningPaymentError(err)
}
return nil
}
Expand Down
87 changes: 87 additions & 0 deletions backend/lightning/payments_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package lightning

import (
"errors"
"math/big"
"testing"

Expand Down Expand Up @@ -283,3 +284,89 @@ func coinAmountWithConversions(amount string) coin.FormattedAmountWithConversion
func stringPointer(value string) *string {
return &value
}

func TestCheckPaymentBalance(t *testing.T) {
t.Parallel()

testCases := []struct {
name string
totalDebitSat uint64
availableSat int64
expectedErr error
}{
{
name: "total debit below available balance",
totalDebitSat: 99,
availableSat: 100,
},
{
name: "total debit equals available balance",
totalDebitSat: 100,
availableSat: 100,
},
{
name: "total debit exceeds available balance",
totalDebitSat: 101,
availableSat: 100,
expectedErr: errLightningInsufficientFunds,
},
}

for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()

balance := accounts.NewBalance(coin.NewAmountFromInt64(testCase.availableSat), coin.NewAmountFromInt64(0))
err := checkPaymentBalance(&paymentFee{TotalDebitSat: testCase.totalDebitSat}, balance)
require.Equal(t, testCase.expectedErr, errp.Cause(err))
})
}
}

func TestLightningPaymentError(t *testing.T) {
t.Parallel()

unrelatedErr := errors.New("network unavailable")
testCases := []struct {
name string
err error
expectedErr error
expectedErrorContains []string
}{
{
name: "typed SDK insufficient funds",
err: breez_sdk_spark.NewSdkErrorInsufficientFunds(),
expectedErr: errLightningInsufficientFunds,
expectedErrorContains: []string{"SdkError: InsufficientFunds", "lightningInsufficientFunds"},
},
{
name: "Spark insufficient funds",
err: breez_sdk_spark.NewSdkErrorSparkError("Tree service error: insufficient funds"),
expectedErr: errLightningInsufficientFunds,
expectedErrorContains: []string{"Tree service error: insufficient funds", "lightningInsufficientFunds"},
},
{
name: "Spark already used invoice",
err: breez_sdk_spark.NewSdkErrorSparkError("Service error: status: AlreadyExists, message: preimage request already exists for paymentHash abc, details: DUPLICATE_OPERATION"),
expectedErr: errLightningInvoiceAlreadyUsed,
expectedErrorContains: []string{"preimage request already exists", "lightningInvoiceAlreadyUsed"},
},
{
name: "unrelated error",
err: unrelatedErr,
expectedErr: unrelatedErr,
},
}

for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()

err := lightningPaymentError(testCase.err)
require.Equal(t, testCase.expectedErr, errp.Cause(err))
for _, expectedText := range testCase.expectedErrorContains {
require.Contains(t, err.Error(), expectedText)
}
})
}
}
18 changes: 11 additions & 7 deletions frontends/web/src/api/lightning.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// SPDX-License-Identifier: Apache-2.0

import { apiGet, apiPost } from '../utils/request';
import { AccountCode, TAmountWithConversions, TBalance, TTransactionStatus } from './account';
import { TSubscriptionCallback, TUnsubscribe, subscribeEndpoint } from './subscribe';
import type { AccountCode, TAmountWithConversions, TBalance, TTransactionStatus } from '@/api/account';
import type { TSubscriptionCallback, TUnsubscribe } from '@/api/subscribe';
import { subscribeEndpoint } from '@/api/subscribe';
import { apiGet, apiPost } from '@/utils/request';

export type TLightningResponse<T> =
| {
Expand All @@ -11,6 +12,7 @@ export type TLightningResponse<T> =
}
| {
success: false;
data?: T;
errorMessage?: string;
errorCode?: string;
};
Expand Down Expand Up @@ -79,12 +81,14 @@ export type TParsePaymentInputRequest = {
s: string;
};

export class TSdkError extends Error {
export class TSdkError<T = unknown> extends Error {
code?: string;
data?: T;

constructor(message: string, code?: string) {
constructor(message: string, code?: string, data?: T) {
super(message);
this.code = code;
this.data = data;

Object.setPrototypeOf(this, TSdkError.prototype);
}
Expand All @@ -103,7 +107,7 @@ const queryString = (params: Record<string, string | number | undefined | null>)
const getApiResponse = async <T>(url: string, defaultError: string = 'Error'): Promise<T> => {
const response: TLightningResponse<T> = await apiGet(url);
if (!response.success) {
throw new TSdkError(response.errorMessage || defaultError, response.errorCode);
throw new TSdkError(response.errorMessage || defaultError, response.errorCode, response.data);
}
if (response.data === undefined) {
throw new TSdkError(defaultError);
Expand All @@ -114,7 +118,7 @@ const getApiResponse = async <T>(url: string, defaultError: string = 'Error'): P
const postApiResponse = async <T, C extends object | undefined>(url: string, data: C, defaultError: string = 'Error'): Promise<T> => {
const response: TLightningResponse<T> = await apiPost(url, data);
if (!response.success) {
throw new TSdkError(response.errorMessage || defaultError, response.errorCode);
throw new TSdkError(response.errorMessage || defaultError, response.errorCode, response.data);
}
if (response.data === undefined) {
return undefined as T;
Expand Down
2 changes: 2 additions & 0 deletions frontends/web/src/locales/en/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -916,6 +916,8 @@
"aoppUnsupportedKeystore": "The connected device cannot sign messages for this asset.",
"aoppVersion": "Unknown version.",
"keystoreTimeout": "Wallet request expired. Please try again.",
"lightningInsufficientFunds": "Insufficient funds in your Lightning wallet.",
"lightningInvoiceAlreadyUsed": "This Lightning invoice was already used. Please request a new invoice.",
"paymentApprovalRequired": "Payment fee increased. Please review and approve again.",
"wrongKeystore": "Wrong wallet connected. Please make sure to insert the correct device matching this account.",
"wrongKeystore2": " If you are using the optional passphrase, make sure you have entered the correct passphrase for the account."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ export const ConfirmStep = () => {
const {
paymentDetails,
paymentQuote,
returnToEditInvoice,
sendPayment,
} = useLightningSendContext();

Expand All @@ -23,10 +22,6 @@ export const ConfirmStep = () => {
}

const handleBack = () => {
if (!paymentDetails.invoice.amountSat) {
returnToEditInvoice();
return;
}
navigate(-1);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,39 @@ import { useTranslation } from 'react-i18next';
import { TPaymentInputTypeVariant } from '@/api/lightning';
import { Button, Input } from '@/components/forms';
import { Column, Grid } from '@/components/layout';
import { Status } from '@/components/status/status';
import { View, ViewButtons, ViewContent } from '@/components/view/view';
import { useLightningSendContext } from '../lightning-send-context';
import { PaymentFeeDetails } from './invoice-details';

export const EditInvoiceStep = () => {
const { t } = useTranslation();
const {
customAmount,
customPrepareState,
paymentDetails,
preparePayment,
resetPayment,
sendPayment,
setCustomAmount,
} = useLightningSendContext();

if (paymentDetails?.type !== TPaymentInputTypeVariant.BOLT11) {
return null;
}

const currentQuote = customPrepareState.status === 'success' && customPrepareState.amountSat === customAmount
? customPrepareState.quote
: undefined;
const errorQuote = customPrepareState.status === 'error' && customPrepareState.amountSat === customAmount
? customPrepareState.quote
: undefined;
const isPreparing = customPrepareState.status === 'loading' && customPrepareState.amountSat === customAmount;
const prepareError = customPrepareState.status === 'error' && customPrepareState.amountSat === customAmount
? customPrepareState.error
: undefined;
const displayedQuote = currentQuote || errorQuote;
const showQuote = isPreparing || displayedQuote;

return (
<View fitContent minHeight="100%">
<ViewContent>
Expand All @@ -33,7 +49,10 @@ export const EditInvoiceStep = () => {
label={t('lightning.receive.amountSats.label')}
placeholder={t('lightning.receive.amountSats.placeholder')}
id="amountSatsInput"
onInput={(event: ChangeEvent<HTMLInputElement>) => setCustomAmount(event.target.valueAsNumber)}
onInput={(event: ChangeEvent<HTMLInputElement>) => {
const amount = event.target.valueAsNumber;
setCustomAmount(Number.isNaN(amount) ? undefined : amount);
}}
value={customAmount ? `${customAmount}` : ''}
autoFocus
/>
Expand All @@ -46,15 +65,19 @@ export const EditInvoiceStep = () => {
disabled
value={paymentDetails.invoice.description || ''}
/>
<Status dismissibleKey="" type="error" hidden={!prepareError}>
{prepareError}
</Status>
{showQuote && <PaymentFeeDetails quote={displayedQuote} />}
</Column>
</Grid>
</ViewContent>
<ViewButtons>
<Button
primary
onClick={preparePayment}
disabled={!customAmount}>
{t('button.continue')}
onClick={sendPayment}
disabled={!currentQuote}>
{t('generic.send')}
</Button>
<Button secondary onClick={resetPayment}>
{t('button.back')}
Expand Down
Loading