diff --git a/backend/handlers/handlers.go b/backend/handlers/handlers.go
index 50ec52b3ac..b408053eda 100644
--- a/backend/handlers/handlers.go
+++ b/backend/handlers/handlers.go
@@ -70,6 +70,7 @@ type Backend interface {
PrepareSwap(buyAccountCode, sellAccountCode accountsTypes.Code, routeID, sellAmount string) (*backend.SwapPreparation, error)
SwapAccounts() (backend.SwapAccounts, error)
SwapStatus() backend.SwapStatus
+ LightningTopUpInfo() (backend.LightningTopUpInfo, error)
AccountsByKeystore() (backend.KeystoresAccountsListMap, error)
AccountsFiatAndCoinBalance(backend.AccountsList, string) (*big.Rat, map[coinpkg.Code]*big.Int, error)
Keystore() keystore.Keystore
@@ -277,6 +278,7 @@ func NewHandlers(
getAPIRouterNoError(apiRouter)("/online", handlers.getOnline).Methods("GET")
getAPIRouterNoError(apiRouter)("/keystore/show-backup-banner/{rootFingerprint}", handlers.getKeystoreShowBackupBanner).Methods("GET")
+ getAPIRouterNoError(apiRouter)("/lightning/top-up/info", handlers.getLightningTopUpInfo).Methods("GET")
lightning.NewHandlers(
getAPIRouterNoError(apiRouter.PathPrefix("/lightning").Subrouter()),
@@ -840,6 +842,40 @@ func (handlers *Handlers) getSwapStatus(*http.Request) interface{} {
return handlers.backend.SwapStatus()
}
+func (handlers *Handlers) getLightningTopUpInfo(*http.Request) interface{} {
+ type response struct {
+ Success bool `json:"success"`
+ ErrorMessage string `json:"errorMessage,omitempty"`
+ SourceAccounts []accountBaseJSON `json:"sourceAccounts"`
+ DefaultSourceAccountCode *accountsTypes.Code `json:"defaultSourceAccountCode,omitempty"`
+ AccountToConnectRootFingerprint jsonp.HexBytes `json:"accountToConnectRootFingerprint,omitempty"`
+ }
+
+ topUpInfo, err := handlers.backend.LightningTopUpInfo()
+ if err != nil {
+ return response{
+ Success: false,
+ ErrorMessage: err.Error(),
+ }
+ }
+
+ result := response{
+ Success: true,
+ SourceAccounts: make([]accountBaseJSON, len(topUpInfo.SourceAccounts)),
+ DefaultSourceAccountCode: topUpInfo.DefaultSourceAccountCode,
+ AccountToConnectRootFingerprint: topUpInfo.AccountToConnectRootFingerprint,
+ }
+ for i, account := range topUpInfo.SourceAccounts {
+ result.SourceAccounts[i] = newAccountBaseJSON(
+ account.Keystore,
+ account.AccountConfig,
+ account.AccountCoin,
+ account.KeystoreConnected,
+ )
+ }
+ return result
+}
+
func (handlers *Handlers) lookupEthAccountCode(r *http.Request) interface{} {
var args struct {
Address string `json:"address"`
diff --git a/backend/lightning_topup.go b/backend/lightning_topup.go
new file mode 100644
index 0000000000..3e2842a2e8
--- /dev/null
+++ b/backend/lightning_topup.go
@@ -0,0 +1,137 @@
+// SPDX-License-Identifier: Apache-2.0
+
+package backend
+
+import (
+ "bytes"
+
+ accountsTypes "github.com/BitBoxSwiss/bitbox-wallet-app/backend/accounts/types"
+ coinpkg "github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/coin"
+ "github.com/BitBoxSwiss/bitbox-wallet-app/backend/config"
+)
+
+// LightningTopUpInfo contains the source account choices needed by the Lightning top-up screen.
+type LightningTopUpInfo struct {
+ // SourceAccounts are active, loaded BTC accounts that can fund a top-up.
+ SourceAccounts []LightningTopUpSourceAccount
+ DefaultSourceAccountCode *accountsTypes.Code
+ // AccountToConnectRootFingerprint is set when a matching BTC account exists in the config,
+ // but is not loaded because its BitBox is not connected.
+ AccountToConnectRootFingerprint []byte
+}
+
+// LightningTopUpSourceAccount contains the base account data needed by the frontend account selector.
+type LightningTopUpSourceAccount struct {
+ Keystore config.Keystore
+ KeystoreConnected bool
+ AccountConfig *config.Account
+ AccountCoin coinpkg.Coin
+}
+
+// LightningTopUpInfo returns active BTC accounts that can source a Lightning top-up.
+func (backend *Backend) LightningTopUpInfo() (LightningTopUpInfo, error) {
+ sourceAccounts, err := backend.lightningTopUpSourceAccounts()
+ if err != nil {
+ return LightningTopUpInfo{}, err
+ }
+
+ lightningAccount := backend.lightning.Account()
+ result := LightningTopUpInfo{
+ SourceAccounts: sourceAccounts,
+ DefaultSourceAccountCode: lightningTopUpDefaultSourceAccount(sourceAccounts, lightningAccount),
+ }
+
+ if len(sourceAccounts) == 0 && backend.Keystore() == nil && lightningAccount != nil &&
+ backend.hasConfiguredLightningTopUpSourceAccount(lightningAccount.RootFingerprint) {
+ // Let the frontend open the shared connect prompt immediately. If no matching
+ // configured BTC account exists, the user needs account management instead.
+ result.AccountToConnectRootFingerprint = append([]byte(nil), lightningAccount.RootFingerprint...)
+ }
+
+ return result, nil
+}
+
+func (backend *Backend) lightningTopUpSourceAccounts() ([]LightningTopUpSourceAccount, error) {
+ connectedKeystore, err := backend.connectedKeystoreConfig()
+ if err != nil {
+ return nil, err
+ }
+
+ persistedAccounts := backend.config.AccountsConfig()
+ sourceAccounts := []LightningTopUpSourceAccount{}
+ for _, account := range backend.Accounts() {
+ // Use loaded accounts for the actual selector. Disconnected non-watch accounts are not
+ // loaded, while inactive accounts should stay unavailable until enabled by the user.
+ accountConfig := account.Config().Config
+ if !isLightningTopUpSourceAccount(accountConfig) {
+ continue
+ }
+
+ rootFingerprint, err := accountConfig.SigningConfigurations.RootFingerprint()
+ if err != nil {
+ backend.log.WithField("code", accountConfig.Code).Error("could not identify root fingerprint")
+ continue
+ }
+ keystore, err := persistedAccounts.LookupKeystore(rootFingerprint)
+ if err != nil {
+ backend.log.WithField("code", accountConfig.Code).Error("could not find keystore of account")
+ continue
+ }
+
+ keystoreConnected := connectedKeystore != nil &&
+ bytes.Equal(rootFingerprint, connectedKeystore.RootFingerprint)
+ sourceAccounts = append(sourceAccounts, LightningTopUpSourceAccount{
+ Keystore: *keystore,
+ KeystoreConnected: keystoreConnected,
+ AccountConfig: accountConfig,
+ AccountCoin: account.Coin(),
+ })
+ }
+ return sourceAccounts, nil
+}
+
+func isLightningTopUpSourceAccount(account *config.Account) bool {
+ // Top-up is a regular on-chain BTC send to the Lightning boarding address.
+ // Inactive accounts are omitted so the UI can direct users to Manage accounts.
+ return account.CoinCode == coinpkg.CodeBTC &&
+ !account.HiddenBecauseUnused &&
+ !account.Inactive
+}
+
+func lightningTopUpDefaultSourceAccount(
+ sourceAccounts []LightningTopUpSourceAccount,
+ lightningAccount *config.LightningAccountConfig,
+) *accountsTypes.Code {
+ if len(sourceAccounts) == 0 {
+ return nil
+ }
+ if lightningAccount != nil {
+ // Prefer the BTC account from the BitBox that created the Lightning account.
+ for _, account := range sourceAccounts {
+ rootFingerprint, err := account.AccountConfig.SigningConfigurations.RootFingerprint()
+ if err != nil {
+ continue
+ }
+ if bytes.Equal(rootFingerprint, lightningAccount.RootFingerprint) {
+ code := account.AccountConfig.Code
+ return &code
+ }
+ }
+ }
+ code := sourceAccounts[0].AccountConfig.Code
+ return &code
+}
+
+func (backend *Backend) hasConfiguredLightningTopUpSourceAccount(rootFingerprint []byte) bool {
+ // This checks persisted config, not loaded accounts, so an unplugged BitBox can still
+ // produce a connect prompt when it has an active BTC account configured.
+ for _, account := range backend.config.AccountsConfig().Accounts {
+ if !isLightningTopUpSourceAccount(account) {
+ continue
+ }
+ if account.SigningConfigurations.ContainsRootFingerprint(rootFingerprint) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/backend/lightning_topup_test.go b/backend/lightning_topup_test.go
new file mode 100644
index 0000000000..8bdfaabebc
--- /dev/null
+++ b/backend/lightning_topup_test.go
@@ -0,0 +1,120 @@
+// SPDX-License-Identifier: Apache-2.0
+
+package backend
+
+import (
+ "testing"
+
+ accountsTypes "github.com/BitBoxSwiss/bitbox-wallet-app/backend/accounts/types"
+ "github.com/BitBoxSwiss/bitbox-wallet-app/backend/config"
+ "github.com/stretchr/testify/require"
+)
+
+func setTestLightningAccount(t *testing.T, b *Backend, rootFingerprint []byte) {
+ t.Helper()
+ require.NoError(t, b.Lightning().SetAccount(&config.LightningAccountConfig{
+ Mnemonic: "test mnemonic",
+ RootFingerprint: append([]byte(nil), rootFingerprint...),
+ Code: accountsTypes.Code("v0-lightning-ln-0"),
+ Number: 0,
+ }))
+}
+
+func lightningTopUpSourceAccountCodes(accounts []LightningTopUpSourceAccount) []accountsTypes.Code {
+ codes := make([]accountsTypes.Code, 0, len(accounts))
+ for _, account := range accounts {
+ codes = append(codes, account.AccountConfig.Code)
+ }
+ return codes
+}
+
+func TestLightningTopUpInfoDisconnectedBitBoxReturnsConnectFingerprint(t *testing.T) {
+ b := newBackend(t, testnetDisabled, regtestDisabled)
+ defer b.Close()
+
+ ks := makeBitBox02Multi()
+ ks.RootFingerprintFunc = func() ([]byte, error) {
+ return rootFingerprint1, nil
+ }
+ b.registerKeystore(ks)
+ setTestLightningAccount(t, b, rootFingerprint1)
+ b.DeregisterKeystore()
+
+ topUpInfo, err := b.LightningTopUpInfo()
+ require.NoError(t, err)
+ require.Empty(t, topUpInfo.SourceAccounts)
+ require.Nil(t, topUpInfo.DefaultSourceAccountCode)
+ require.Equal(t, rootFingerprint1, topUpInfo.AccountToConnectRootFingerprint)
+}
+
+func TestLightningTopUpInfoConnectedBitBoxReturnsActiveBTCSourceAccount(t *testing.T) {
+ b := newBackend(t, testnetDisabled, regtestDisabled)
+ defer b.Close()
+
+ ks := makeBitBox02Multi()
+ ks.RootFingerprintFunc = func() ([]byte, error) {
+ return rootFingerprint1, nil
+ }
+ b.registerKeystore(ks)
+ setTestLightningAccount(t, b, rootFingerprint1)
+
+ topUpInfo, err := b.LightningTopUpInfo()
+ require.NoError(t, err)
+ require.Equal(t, []accountsTypes.Code{"v0-55555555-btc-0"}, lightningTopUpSourceAccountCodes(topUpInfo.SourceAccounts))
+ require.NotNil(t, topUpInfo.DefaultSourceAccountCode)
+ require.Equal(t, accountsTypes.Code("v0-55555555-btc-0"), *topUpInfo.DefaultSourceAccountCode)
+ require.Empty(t, topUpInfo.AccountToConnectRootFingerprint)
+ require.True(t, topUpInfo.SourceAccounts[0].KeystoreConnected)
+}
+
+func TestLightningTopUpInfoOmitsInactiveBTCAccountWithoutConnectTargetWhenConnected(t *testing.T) {
+ b := newBackend(t, testnetDisabled, regtestDisabled)
+ defer b.Close()
+
+ ks := makeBitBox02Multi()
+ ks.RootFingerprintFunc = func() ([]byte, error) {
+ return rootFingerprint1, nil
+ }
+ b.registerKeystore(ks)
+ setTestLightningAccount(t, b, rootFingerprint1)
+
+ btcAccountCode := accountsTypes.Code("v0-55555555-btc-0")
+ require.NoError(t, b.SetAccountActive(btcAccountCode, false))
+
+ topUpInfo, err := b.LightningTopUpInfo()
+ require.NoError(t, err)
+ require.Empty(t, topUpInfo.SourceAccounts)
+ require.Nil(t, topUpInfo.DefaultSourceAccountCode)
+ require.Empty(t, topUpInfo.AccountToConnectRootFingerprint)
+}
+
+func TestLightningTopUpInfoDefaultPrefersLightningRootFingerprint(t *testing.T) {
+ b := newBackend(t, testnetDisabled, regtestDisabled)
+ defer b.Close()
+
+ ks1 := makeBitBox02Multi()
+ ks1.RootFingerprintFunc = func() ([]byte, error) {
+ return rootFingerprint1, nil
+ }
+ b.registerKeystore(ks1)
+ setTestLightningAccount(t, b, rootFingerprint1)
+ require.NoError(t, b.SetWatchonly(rootFingerprint1, true))
+ b.DeregisterKeystore()
+
+ ks2Helper := keystoreHelper2()
+ ks2 := makeBitBox02Multi()
+ ks2.RootFingerprintFunc = func() ([]byte, error) {
+ return rootFingerprint2, nil
+ }
+ ks2.ExtendedPublicKeyFunc = ks2Helper.ExtendedPublicKey
+ ks2.BTCXPubsFunc = ks2Helper.BTCXPubs
+ b.registerKeystore(ks2)
+
+ topUpInfo, err := b.LightningTopUpInfo()
+ require.NoError(t, err)
+ require.Contains(t, lightningTopUpSourceAccountCodes(topUpInfo.SourceAccounts), accountsTypes.Code("v0-55555555-btc-0"))
+ require.Contains(t, lightningTopUpSourceAccountCodes(topUpInfo.SourceAccounts), accountsTypes.Code("v0-66666666-btc-0"))
+ require.NotNil(t, topUpInfo.DefaultSourceAccountCode)
+ require.Equal(t, accountsTypes.Code("v0-55555555-btc-0"), *topUpInfo.DefaultSourceAccountCode)
+ require.Empty(t, topUpInfo.AccountToConnectRootFingerprint)
+}
diff --git a/frontends/web/src/api/lightning.ts b/frontends/web/src/api/lightning.ts
index c73379ec41..0b49ad226f 100644
--- a/frontends/web/src/api/lightning.ts
+++ b/frontends/web/src/api/lightning.ts
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: Apache-2.0
import { apiGet, apiPost } from '../utils/request';
-import { AccountCode, TAmountWithConversions, TBalance, TTransactionStatus } from './account';
+import { AccountCode, TAccountBase, TAmountWithConversions, TBalance, TTransactionStatus } from './account';
import { TSubscriptionCallback, TUnsubscribe, subscribeEndpoint } from './subscribe';
export type TLightningResponse =
@@ -21,6 +21,21 @@ export type TLightningAccount = {
num: number;
};
+export type TTopUpSourceAccount = TAccountBase & {
+ coinCode: 'btc';
+ isToken: false;
+};
+
+export type TTopUpInfo = {
+ success: true;
+ sourceAccounts: TTopUpSourceAccount[];
+ defaultSourceAccountCode?: AccountCode;
+ accountToConnectRootFingerprint?: string;
+} | {
+ success: false;
+ errorMessage: string;
+};
+
export type TLightningInvoice = {
bolt11: string;
description?: string;
@@ -150,6 +165,10 @@ export const getBoardingAddress = async (): Promise => {
return getApiResponse('lightning/boarding-address', 'Error calling getBoardingAddress');
};
+export const getTopUpInfo = async (): Promise => {
+ return apiGet('lightning/top-up/info');
+};
+
export const postPreparePayment = async (data: TPreparePaymentRequest): Promise => {
return postApiResponse(
'lightning/prepare-payment',
diff --git a/frontends/web/src/locales/en/app.json b/frontends/web/src/locales/en/app.json
index 5837bc5fbe..c6f64ae169 100644
--- a/frontends/web/src/locales/en/app.json
+++ b/frontends/web/src/locales/en/app.json
@@ -1644,6 +1644,15 @@
"message": "Transaction confirmed and sent!"
},
"title": "Send Lightning"
+ },
+ "topUp": {
+ "button": "Top up",
+ "from": "From",
+ "noActiveSourceAccount": "You need at least one active Bitcoin account to top up your lightning wallet.",
+ "success": {
+ "message": "Top up created!"
+ },
+ "title": "Top up lightning wallet"
}
},
"loading": "loading…",
diff --git a/frontends/web/src/routes/account/send/components/result.tsx b/frontends/web/src/routes/account/send/components/result.tsx
index 9879677170..e3bdbbb5af 100644
--- a/frontends/web/src/routes/account/send/components/result.tsx
+++ b/frontends/web/src/routes/account/send/components/result.tsx
@@ -12,9 +12,12 @@ import { CopyableInput } from '@/components/copy/Copy';
type TProps = {
children?: ReactNode;
code: AccountCode;
- onContinue: () => void;
+ doneRoute?: string;
+ onContinue?: () => void;
onRetry: () => void;
result: TSendTx | undefined;
+ showSuccessActions?: boolean;
+ successMessage?: string;
};
/**
@@ -25,12 +28,16 @@ type TProps = {
export const SendResult = ({
children,
code,
- result,
+ doneRoute,
onContinue,
+ result,
onRetry,
+ showSuccessActions = true,
+ successMessage,
}: TProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
+ const donePath = doneRoute || `/account/${code}`;
if (!result) {
return null;
@@ -47,7 +54,7 @@ export const SendResult = ({
-