Skip to content
Draft
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
36 changes: 36 additions & 0 deletions backend/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()),
Expand Down Expand Up @@ -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"`
Expand Down
137 changes: 137 additions & 0 deletions backend/lightning_topup.go
Original file line number Diff line number Diff line change
@@ -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
}
120 changes: 120 additions & 0 deletions backend/lightning_topup_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
21 changes: 20 additions & 1 deletion frontends/web/src/api/lightning.ts
Original file line number Diff line number Diff line change
@@ -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<T> =
Expand All @@ -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;
Expand Down Expand Up @@ -150,6 +165,10 @@ export const getBoardingAddress = async (): Promise<string> => {
return getApiResponse<string>('lightning/boarding-address', 'Error calling getBoardingAddress');
};

export const getTopUpInfo = async (): Promise<TTopUpInfo> => {
return apiGet('lightning/top-up/info');
};

export const postPreparePayment = async (data: TPreparePaymentRequest): Promise<TPreparePaymentResponse> => {
return postApiResponse<TPreparePaymentResponse, TPreparePaymentRequest>(
'lightning/prepare-payment',
Expand Down
9 changes: 9 additions & 0 deletions frontends/web/src/locales/en/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -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…",
Expand Down
Loading
Loading