Skip to content

Commit d34762d

Browse files
committed
Merge branch 'feature/pro-3646-set-a-minimum-threshold-for-ln-top-ups'
2 parents 1ba659f + 85ec14d commit d34762d

10 files changed

Lines changed: 140 additions & 93 deletions

File tree

backend/lightning/handlers.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ func (lightning *Lightning) PostPrepareTopUp(r *http.Request) interface{} {
6161
Success bool `json:"success"`
6262
ErrorCode string `json:"errorCode,omitempty"`
6363
FundingLimit *fundingLimit `json:"fundingLimit,omitempty"`
64+
MinAmountSat uint64 `json:"minAmountSat,omitempty"`
6465
*topUpProposal
6566
}
6667

@@ -76,14 +77,24 @@ func (lightning *Lightning) PostPrepareTopUp(r *http.Request) interface{} {
7677
return responseDto{Success: true, Data: result{Success: true, topUpProposal: proposal}}
7778
}
7879

79-
if limitErr, ok := err.(*topUpFundingLimitError); ok {
80+
var limitErr *topUpFundingLimitError
81+
if errors.As(err, &limitErr) {
8082
return responseDto{Success: true, Data: result{
8183
Success: false,
8284
ErrorCode: string(errLightningBalanceLimitExceeded),
8385
FundingLimit: &limitErr.fundingLimit,
8486
}}
8587
}
86-
if validationErr, ok := errp.Cause(err).(accountErrors.TxValidationError); ok {
88+
var amountBelowMinimum *lightningAmountBelowMinimumError
89+
if errors.As(err, &amountBelowMinimum) {
90+
return responseDto{Success: true, Data: result{
91+
Success: false,
92+
ErrorCode: string(errLightningAmountBelowMinimum),
93+
MinAmountSat: amountBelowMinimum.minAmountSat,
94+
}}
95+
}
96+
var validationErr accountErrors.TxValidationError
97+
if errors.As(err, &validationErr) {
8798
return responseDto{Success: true, Data: result{Success: false, ErrorCode: validationErr.Error()}}
8899
}
89100
return errorResponse(err)

backend/lightning/topup.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import (
1212
"github.com/BitBoxSwiss/bitbox-wallet-app/util/errp"
1313
)
1414

15+
const minimumTopUpAmountSat = 1000
16+
1517
const errLightningBalanceLimitExceeded errp.ErrorCode = "lightningBalanceLimitExceeded"
1618

1719
type prepareTopUpRequest struct {
@@ -41,7 +43,15 @@ func parseTopUpAmount(accountCoin coin.Coin, amount string) (coin.Amount, error)
4143
if accountCoin.GetFormatUnit(false) == string(coin.BtcUnitSats) {
4244
unit = big.NewInt(1)
4345
}
44-
return coin.NewSendAmount(amount).Amount(unit, false)
46+
return coin.NewSendAmount(amount).Amount(unit, true)
47+
}
48+
49+
func validateTopUpAmount(amount coin.Amount) error {
50+
minimumTopUpAmount := big.NewInt(minimumTopUpAmountSat)
51+
if amount.BigInt().Cmp(minimumTopUpAmount) < 0 {
52+
return &lightningAmountBelowMinimumError{minAmountSat: minimumTopUpAmountSat}
53+
}
54+
return nil
4555
}
4656

4757
// PrepareTopUp validates the Lightning funding limit and creates the Bitcoin transaction proposal
@@ -59,6 +69,9 @@ func (lightning *Lightning) PrepareTopUp(request prepareTopUpRequest) (*topUpPro
5969
if err != nil {
6070
return nil, err
6171
}
72+
if err := validateTopUpAmount(amount); err != nil {
73+
return nil, err
74+
}
6275
_, limit, err := lightning.balanceWithFundingLimit()
6376
if err != nil {
6477
return nil, err

backend/lightning/topup_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,34 @@ func TestPrepareTopUp(t *testing.T) {
114114
require.Equal(t, coin.NewAmountFromInt64(125_000), parsedAmount)
115115
}
116116

117+
func TestPrepareTopUpRejectsAmountBelowMinimumBeforeCreatingProposal(t *testing.T) {
118+
for _, amount := range []string{"0", "0.00000999"} {
119+
t.Run(amount, func(t *testing.T) {
120+
sdk := &topUpTestSDK{balanceSat: 50_000, incomingSat: 25_000}
121+
lightning := makeActiveLightningWithSDK(t, sdk)
122+
account := testTopUpAccount(t, lightning, func(*accounts.TxProposalArgs) (
123+
coin.Amount, coin.Amount, coin.Amount, error,
124+
) {
125+
t.Fatal("must not create a below-minimum transaction proposal")
126+
return coin.Amount{}, coin.Amount{}, coin.Amount{}, nil
127+
})
128+
129+
proposal, err := lightning.PrepareTopUp(prepareTopUpRequest{
130+
SourceAccountCode: testTopUpSourceAccountCode,
131+
Amount: amount,
132+
FeeTarget: "economy",
133+
})
134+
135+
require.Nil(t, proposal)
136+
var amountBelowMinimum *lightningAmountBelowMinimumError
137+
require.ErrorAs(t, err, &amountBelowMinimum)
138+
require.Equal(t, uint64(minimumTopUpAmountSat), amountBelowMinimum.minAmountSat)
139+
require.Empty(t, account.TxProposalCalls())
140+
require.Zero(t, sdk.receiveCallCount)
141+
})
142+
}
143+
}
144+
117145
func TestPrepareTopUpRejectsAmountAboveFundingLimitBeforeCreatingProposal(t *testing.T) {
118146
sdk := &topUpTestSDK{balanceSat: 50_000, incomingSat: 25_000}
119147
lightning := makeActiveLightningWithSDK(t, sdk)
@@ -137,3 +165,11 @@ func TestPrepareTopUpRejectsAmountAboveFundingLimitBeforeCreatingProposal(t *tes
137165
require.Empty(t, account.TxProposalCalls())
138166
require.Zero(t, sdk.receiveCallCount)
139167
}
168+
169+
func TestValidateTopUpAmount(t *testing.T) {
170+
err := validateTopUpAmount(coin.NewAmountFromInt64(minimumTopUpAmountSat - 1))
171+
var amountBelowMinimum *lightningAmountBelowMinimumError
172+
require.ErrorAs(t, err, &amountBelowMinimum)
173+
require.Equal(t, uint64(minimumTopUpAmountSat), amountBelowMinimum.minAmountSat)
174+
require.NoError(t, validateTopUpAmount(coin.NewAmountFromInt64(minimumTopUpAmountSat)))
175+
}

frontends/web/src/api/lightning.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ export type TPrepareTopUpResult = {
8080
errorCode: typeof lightningBalanceLimitErrorCode;
8181
fundingLimit: TLightningFundingLimit;
8282
success: false;
83+
} | {
84+
errorCode: TLightningErrorCode.AMOUNT_BELOW_MINIMUM;
85+
minAmountSat: number;
86+
success: false;
8387
} | {
8488
errorCode: TTxProposalErrorCode;
8589
success: false;

frontends/web/src/locales/en/app.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1886,7 +1886,6 @@
18861886
},
18871887
"from": "From",
18881888
"noBitcoinAccounts": "No Bitcoin accounts active. Please activate a Bitcoin account.",
1889-
"noFundedBitcoinAccounts": "No Bitcoin accounts with an available balance. Receive Bitcoin before trying to top up again.",
18901889
"note": "Lightning top-up",
18911890
"success": {
18921891
"message": "Top up created!",

frontends/web/src/routes/lightning/topup/topup-form.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ type TProps = {
5454
isSubmitting: boolean;
5555
isUpdatingProposal: boolean;
5656
lightningBalance?: TBalance;
57+
minimumAmountError?: string;
5758
note: string;
5859
onAmountChange: (value: string) => void;
5960
onBack: () => void;
@@ -82,6 +83,7 @@ export const TopUpForm = ({
8283
isSubmitting,
8384
isUpdatingProposal,
8485
lightningBalance,
86+
minimumAmountError,
8587
note,
8688
onAmountChange,
8789
onBack,
@@ -144,7 +146,7 @@ export const TopUpForm = ({
144146
label={sourceAmountUnit}
145147
id="topUpAmount"
146148
onChange={onAmountChange}
147-
error={errorHandling.amountError}
149+
error={minimumAmountError || errorHandling.amountError}
148150
value={amount}
149151
placeholder={t('send.amount.placeholder')}
150152
/>

frontends/web/src/routes/lightning/topup/topup-result.tsx

Lines changed: 0 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import { useTranslation } from 'react-i18next';
44
import { useNavigate } from 'react-router-dom';
5-
import type { TAccount } from '@/api/account';
65
import { DesktopBackButton } from '@/components/backbutton/backbutton';
76
import { Button } from '@/components/forms';
87
import { GuideWrapper, GuidedContent, Header, Main } from '@/components/layout';
@@ -18,10 +17,6 @@ type TTopUpNoBitcoinAccountsProps = {
1817
hasAccounts: boolean;
1918
};
2019

21-
type TTopUpNoFundedBitcoinAccountsProps = {
22-
btcAccounts: TAccount[];
23-
};
24-
2520
export const TopUpSuccess = () => {
2621
const { t } = useTranslation();
2722
const navigate = useNavigate();
@@ -98,45 +93,6 @@ export const TopUpNoBitcoinAccounts = ({ hasAccounts }: TTopUpNoBitcoinAccountsP
9893
);
9994
};
10095

101-
export const TopUpNoFundedBitcoinAccounts = ({ btcAccounts }: TTopUpNoFundedBitcoinAccountsProps) => {
102-
const { t } = useTranslation();
103-
const navigate = useNavigate();
104-
const receiveRoute = btcAccounts.length === 1 && btcAccounts[0]
105-
? `/account/${btcAccounts[0].code}/receive`
106-
: '/accounts/select-receive/bitcoin';
107-
108-
return (
109-
<GuideWrapper>
110-
<GuidedContent>
111-
<Main>
112-
<Header title={
113-
<>
114-
<h2 className="hide-on-small">{t('lightning.topUp.title')}</h2>
115-
<MobileHeader
116-
onClick={() => navigate('/lightning')}
117-
title={t('lightning.topUp.title')}
118-
/>
119-
</>
120-
} />
121-
<View textCenter verticallyCentered>
122-
<ViewContent>
123-
<p>{t('lightning.topUp.noFundedBitcoinAccounts')}</p>
124-
</ViewContent>
125-
<ViewButtons>
126-
<Button primary onClick={() => navigate(receiveRoute)}>
127-
{t('generic.receive', { context: 'bitcoin' })}
128-
</Button>
129-
<DesktopBackButton onClick={() => navigate('/lightning')}>
130-
{t('button.back')}
131-
</DesktopBackButton>
132-
</ViewButtons>
133-
</View>
134-
</Main>
135-
</GuidedContent>
136-
</GuideWrapper>
137-
);
138-
};
139-
14096
export const TopUpAborted = ({ onRetry }: TTopUpAbortedProps) => {
14197
const navigate = useNavigate();
14298

frontends/web/src/routes/lightning/topup/topup.test.tsx

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import * as accountApi from '@/api/account';
99
import * as coinsApi from '@/api/coins';
1010
import * as keystoresApi from '@/api/keystores';
1111
import * as lightningApi from '@/api/lightning';
12+
import { TLightningErrorCode } from '@/api/lightning-errors';
1213
import { RatesContext } from '@/contexts/RatesContext';
1314
import { LightningTopUp } from './topup';
1415

@@ -17,14 +18,20 @@ vi.mock('@/i18n/i18n');
1718
vi.mock('./topup-form', () => ({
1819
TopUpForm: ({
1920
balanceLimitError,
21+
btcAccounts,
2022
canReview,
23+
errorHandling,
24+
minimumAmountError,
2125
onAmountChange,
2226
onFeeTargetChange,
2327
onReview,
2428
sendError,
2529
}: {
2630
balanceLimitError?: string;
31+
btcAccounts: accountApi.TAccount[];
2732
canReview: boolean;
33+
errorHandling: { amountError?: string };
34+
minimumAmountError?: string;
2835
onAmountChange: (amount: string) => void;
2936
onFeeTargetChange: (feeTarget: accountApi.FeeTargetCode) => void;
3037
onReview: () => void;
@@ -34,7 +41,10 @@ vi.mock('./topup-form', () => ({
3441
<button onClick={() => onAmountChange('100000')}>Set amount</button>
3542
<button onClick={() => onFeeTargetChange('economy')}>Set fee target</button>
3643
<button disabled={!canReview} onClick={onReview}>Review</button>
44+
<span data-testid="btc-accounts">{btcAccounts.map(account => account.code).join(',')}</span>
3745
<span data-testid="balance-limit-error">{balanceLimitError}</span>
46+
<span data-testid="amount-error">{minimumAmountError || errorHandling.amountError}</span>
47+
<span data-testid="fiat-amount-error">{errorHandling.amountError}</span>
3848
<span data-testid="send-error">{sendError}</span>
3949
</>
4050
),
@@ -79,7 +89,7 @@ const lightningBalance = (marginSat = 150000): lightningApi.TLightningBalance =>
7989
incoming: amount('0'),
8090
});
8191

82-
const renderTopUp = () => render(
92+
const renderTopUp = (activeAccounts = [account]) => render(
8393
<MemoryRouter>
8494
<RatesContext.Provider value={{
8595
defaultCurrency: 'USD',
@@ -91,18 +101,39 @@ const renderTopUp = () => render(
91101
updateDefaultCurrency: vi.fn(),
92102
removeFromActiveCurrencies: vi.fn(),
93103
}}>
94-
<LightningTopUp activeAccounts={[account]} hasAccounts />
104+
<LightningTopUp activeAccounts={activeAccounts} hasAccounts />
95105
</RatesContext.Provider>
96106
</MemoryRouter>
97107
);
98108

99109
describe('LightningTopUp', () => {
100110
beforeEach(() => {
101111
vi.restoreAllMocks();
102-
vi.spyOn(accountApi, 'getBalance').mockResolvedValue({ success: true, balance: lightningBalance() });
103112
vi.spyOn(lightningApi, 'subscribeLightningBalance').mockReturnValue(vi.fn());
104113
});
105114

115+
it('shows every Bitcoin account without loading its balance', async () => {
116+
const emptyAccount: accountApi.TAccount = {
117+
...account,
118+
code: 'empty-btc-account',
119+
name: 'Empty Bitcoin Account',
120+
keystore: {
121+
...account.keystore,
122+
connected: false,
123+
},
124+
};
125+
const getBalance = vi.spyOn(accountApi, 'getBalance').mockResolvedValue({
126+
success: true,
127+
balance: lightningBalance(200000),
128+
});
129+
vi.spyOn(lightningApi, 'getLightningBalance').mockResolvedValue(lightningBalance());
130+
131+
renderTopUp([account, emptyAccount]);
132+
133+
expect(await screen.findByTestId('btc-accounts')).toHaveTextContent('btc-account,empty-btc-account');
134+
expect(getBalance).not.toHaveBeenCalled();
135+
});
136+
106137
it('prepares and sends a top-up through the dedicated endpoint', async () => {
107138
vi.spyOn(lightningApi, 'getLightningBalance').mockResolvedValue(lightningBalance());
108139
vi.spyOn(coinsApi, 'convertToCurrency').mockResolvedValue({ success: true, fiatAmount: '100' });
@@ -154,4 +185,24 @@ describe('LightningTopUp', () => {
154185
await waitFor(() => expect(screen.getByTestId('balance-limit-error')).toHaveTextContent('Maximum top-up amount'));
155186
expect(screen.getByRole('button', { name: 'Review' })).toBeDisabled();
156187
});
188+
189+
it('shows the minimum-amount error returned by the prepare endpoint', async () => {
190+
vi.spyOn(lightningApi, 'getLightningBalance').mockResolvedValue(lightningBalance());
191+
vi.spyOn(lightningApi, 'postPrepareTopUp').mockResolvedValue({
192+
success: false,
193+
errorCode: TLightningErrorCode.AMOUNT_BELOW_MINIMUM,
194+
minAmountSat: 1000,
195+
});
196+
vi.spyOn(coinsApi, 'convertToCurrency').mockResolvedValue({ success: true, fiatAmount: '0.01' });
197+
renderTopUp();
198+
199+
fireEvent.click(await screen.findByRole('button', { name: 'Set amount' }));
200+
fireEvent.click(screen.getByRole('button', { name: 'Set fee target' }));
201+
202+
await waitFor(() => expect(screen.getByTestId('amount-error')).toHaveTextContent(
203+
'The amount must be at least 1000 sats.'
204+
));
205+
expect(screen.getByTestId('fiat-amount-error')).toBeEmptyDOMElement();
206+
expect(screen.getByRole('button', { name: 'Review' })).toBeDisabled();
207+
});
157208
});

0 commit comments

Comments
 (0)