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
71 changes: 49 additions & 22 deletions backend/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ func NewHandlers(
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool { return true },
},
log: logging.Get().WithGroup("handlers"),
log: log,
}

getAPIRouter := func(subrouter *mux.Router) func(string, func(*http.Request) (interface{}, error)) *mux.Route {
Expand Down Expand Up @@ -200,10 +200,10 @@ func NewHandlers(
getAPIRouterNoError(apiRouter)("/qr", handlers.getQRCode).Methods("GET")
getAPIRouterNoError(apiRouter)("/config", handlers.getAppConfig).Methods("GET")
getAPIRouterNoError(apiRouter)("/config/default", handlers.getDefaultConfig).Methods("GET")
getAPIRouter(apiRouter)("/config", handlers.postAppConfig).Methods("POST")
getAPIRouterNoError(apiRouter)("/config", handlers.postAppConfig).Methods("POST")
getAPIRouterNoError(apiRouter)("/native-locale", handlers.getNativeLocale).Methods("GET")
getAPIRouter(apiRouter)("/notify-user", handlers.postNotify).Methods("POST")
getAPIRouter(apiRouter)("/open", handlers.postOpen).Methods("POST")
getAPIRouterNoError(apiRouter)("/open", handlers.postOpen).Methods("POST")
getAPIRouterNoError(apiRouter)("/update", handlers.getUpdate).Methods("GET")
getAPIRouterNoError(apiRouter)("/banners/{key}", handlers.getBanners).Methods("GET")
getAPIRouterNoError(apiRouter)("/using-mobile-data", handlers.getUsingMobileData).Methods("GET")
Expand All @@ -220,7 +220,7 @@ func NewHandlers(
getAPIRouterNoError(apiRouter)("/accounts", handlers.getAccounts).Methods("GET")
getAPIRouterNoError(apiRouter)("/swap/accounts", handlers.getSwapAccounts).Methods("GET")
getAPIRouterNoError(apiRouter)("/swap/status", handlers.getSwapStatus).Methods("GET")
getAPIRouter(apiRouter)("/accounts/balance-summary", handlers.getAccountsBalanceSummary).Methods("GET")
getAPIRouterNoError(apiRouter)("/accounts/balance-summary", handlers.getAccountsBalanceSummary).Methods("GET")
getAPIRouterNoError(apiRouter)("/set-account-active", handlers.postSetAccountActive).Methods("POST")
getAPIRouterNoError(apiRouter)("/set-token-active", handlers.postSetTokenActive).Methods("POST")
getAPIRouterNoError(apiRouter)("/set-account-receive-script-type", handlers.postSetAccountReceiveScriptType).Methods("POST")
Expand Down Expand Up @@ -248,7 +248,7 @@ func NewHandlers(
getAPIRouterNoError(apiRouter)("/market/btcdirect/info/{action}/{code}", handlers.getMarketBtcDirectInfo).Methods("GET")
getAPIRouterNoError(apiRouter)("/swap/quote", handlers.postSwapkitQuote).Methods("POST")
getAPIRouterNoError(apiRouter)("/swap/sign", handlers.postSwapSign).Methods("POST")
getAPIRouter(apiRouter)("/market/moonpay/buy-info/{code}", handlers.getMarketMoonpayBuyInfo).Methods("GET")
getAPIRouterNoError(apiRouter)("/market/moonpay/buy-info/{code}", handlers.getMarketMoonpayBuyInfo).Methods("GET")
getAPIRouterNoError(apiRouter)("/market/pocket/api-url/{action}", handlers.getMarketPocketURL).Methods("GET")
getAPIRouterNoError(apiRouter)("/market/pocket/verify-address", handlers.postPocketWidgetVerifyAddress).Methods("POST")
getAPIRouterNoError(apiRouter)("/market/bitrefill/info/{action}/{code}", handlers.getMarketBitrefillInfo).Methods("GET")
Expand Down Expand Up @@ -541,12 +541,22 @@ func (handlers *Handlers) getDefaultConfig(*http.Request) interface{} {
return handlers.backend.DefaultAppConfig()
}

func (handlers *Handlers) postAppConfig(r *http.Request) (interface{}, error) {
func (handlers *Handlers) postAppConfig(r *http.Request) interface{} {
type response struct {
Success bool `json:"success"`
ErrorMessage string `json:"errorMessage,omitempty"`
}

appConfig := config.AppConfig{}
if err := json.NewDecoder(r.Body).Decode(&appConfig); err != nil {
return nil, errp.WithStack(err)
handlers.log.WithField("handler", "postAppConfig").WithError(err).Error("handler failed")
return response{Success: false, ErrorMessage: err.Error()}
}
if err := handlers.backend.Config().SetAppConfig(appConfig); err != nil {
handlers.log.WithField("handler", "postAppConfig").WithError(err).Error("handler failed")
return response{Success: false, ErrorMessage: err.Error()}
}
return nil, handlers.backend.Config().SetAppConfig(appConfig)
return response{Success: true}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// getNativeLocaleHandler returns user preferred UI language as reported
Expand All @@ -567,12 +577,22 @@ func (handlers *Handlers) postNotify(r *http.Request) (interface{}, error) {
return nil, nil
}

func (handlers *Handlers) postOpen(r *http.Request) (interface{}, error) {
func (handlers *Handlers) postOpen(r *http.Request) interface{} {
type response struct {
Success bool `json:"success"`
ErrorMessage string `json:"errorMessage,omitempty"`
Comment on lines +582 to +583
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] I'm almost thinking -but not sure- we could have a base response type like this one, and then most of the others would just either use this directly, or extend it with other fields

}

var url string
if err := json.NewDecoder(r.Body).Decode(&url); err != nil {
return nil, errp.WithStack(err)
handlers.log.WithField("handler", "postOpen").WithError(err).Error("handler failed")
return response{Success: false, ErrorMessage: err.Error()}
}
return nil, handlers.backend.SystemOpen(url)
if err := handlers.backend.SystemOpen(url); err != nil {
handlers.log.WithField("handler", "postOpen").WithError(err).Error("handler failed")
return response{Success: false, ErrorMessage: err.Error()}
}
return response{Success: true}
}

func (handlers *Handlers) getUpdate(*http.Request) interface{} {
Expand Down Expand Up @@ -870,17 +890,18 @@ func (handlers *Handlers) postBtcFormatUnit(r *http.Request) interface{} {
}

// getAccountsBalanceSummary returns the total balance summary of all coins and accounts.
func (handlers *Handlers) getAccountsBalanceSummary(*http.Request) (interface{}, error) {
func (handlers *Handlers) getAccountsBalanceSummary(*http.Request) interface{} {
type response struct {
Success bool `json:"success"`
TotalBalance *backend.AccountsBalanceSummary `json:"accountsBalanceSummary"`
}

totalBalance, err := handlers.backend.AccountsBalanceSummary()
if err != nil {
return response{Success: false}, nil
handlers.log.WithField("handler", "getAccountsBalanceSummary").WithError(err).Error("handler failed")
return response{Success: false}
}
return response{Success: true, TotalBalance: totalBalance}, nil
return response{Success: true, TotalBalance: totalBalance}
}

func (handlers *Handlers) postSetAccountActive(r *http.Request) interface{} {
Expand Down Expand Up @@ -1452,10 +1473,18 @@ func (handlers *Handlers) getMarketVendors(r *http.Request) interface{} {
return supported
}

func (handlers *Handlers) getMarketMoonpayBuyInfo(r *http.Request) (interface{}, error) {
func (handlers *Handlers) getMarketMoonpayBuyInfo(r *http.Request) interface{} {
type result struct {
Success bool `json:"success"`
ErrorMessage string `json:"errorMessage,omitempty"`
URL string `json:"url,omitempty"`
Address string `json:"address,omitempty"`
}

acct, err := handlers.backend.GetAccountFromCode(accountsTypes.Code(mux.Vars(r)["code"]))
if err != nil {
return nil, err
handlers.log.WithField("handler", "getMarketMoonpayBuyInfo").WithError(err).Error("handler failed")
return result{Success: false, ErrorMessage: err.Error()}
}

lang := handlers.backend.Config().AppConfig().Backend.UserLanguage
Expand All @@ -1470,16 +1499,14 @@ func (handlers *Handlers) getMarketMoonpayBuyInfo(r *http.Request) (interface{},
}
buy, err := market.MoonpayInfo(acct, params)
if err != nil {
return nil, err
handlers.log.WithField("handler", "getMarketMoonpayBuyInfo").WithError(err).Error("handler failed")
return result{Success: false, ErrorMessage: err.Error()}
}
resp := struct {
URL string `json:"url"`
Address string `json:"address"`
}{
return result{
Success: true,
URL: buy.URL,
Address: buy.Address,
}
return resp, nil
}

func (handlers *Handlers) getMarketBtcDirectInfo(r *http.Request) interface{} {
Expand Down
4 changes: 4 additions & 0 deletions frontends/web/src/api/market.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,12 @@ export const getMarketDeals = (
};

export type MoonpayBuyInfo = {
success: true;
url: string;
address: string;
} | {
success: false;
errorMessage?: string;
};
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fine for now, but we should try to do something like

SuccessResponse & {
url: string;
address: string;
} | FailResponse;


export const getMoonpayBuyInfo = (code: AccountCode) => {
Expand Down
9 changes: 8 additions & 1 deletion frontends/web/src/api/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ export const notifyUser = (text: string) => {
return apiPost('notify-user', { text });
};

export const open = (href: string) => {
type TOpenResponse = {
success: true;
} | {
success: false;
errorMessage?: string;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Many types use SuccessResponse and FailResponse, but this has slightly different properties. Instead of "errorMessage" it has just "message". Therer is a comment that this uses // if the backend uses maybeBB02Err.

export type SuccessResponse = {
  success: true;
};

// if the backend uses maybeBB02Err
export type FailResponse = {
  code?: number;
  message?: string;
  success: false;
};

https://github.com/BitBoxSwiss/bitbox-wallet-app/blob/master/frontends/web/src/api/response.ts

I have no strong opinion on which one we should prefer, but it would be nice if we could just use those everywhere.

type TOpenResponse = SuccessResponse | FailResponse;

Would it make sense to refactor maybeBB02Err to use errorMessage etc?

(also some endpoints return "errMsg")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Almost all endpoints outside use errorMessage I think. Makes sense to me to make BitBox02 handlers use the same, but that's for another PR, as it is unrelated to this one.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok then we should change FailResponse to use errorMessage in the future and use and extend from SuccessResponse FailResponse everywhere.

Should we rename SuccessResponse to SuccessResponseLegacy and already start with the new one?

But I'd love if in the end all use the same and we dont end up having a mix again.

};

export const open = (href: string): Promise<TOpenResponse> => {
return apiPost('open', href);
};
14 changes: 13 additions & 1 deletion frontends/web/src/components/anchor/anchor.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
// SPDX-License-Identifier: Apache-2.0

import { ReactNode, SyntheticEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { open } from '@/api/system';
import { alertUser } from '@/components/alert/Alert';
import { runningInIOS } from '@/utils/env';
import style from './anchor.module.css';

Expand Down Expand Up @@ -32,6 +34,8 @@ export const A = ({
children,
...props
}: TProps) => {
const { t } = useTranslation();

return (
<span
className={`
Expand All @@ -41,7 +45,15 @@ export const A = ({
title={props.title || href}
onClick={(e: SyntheticEvent) => {
e.preventDefault();
open(href).catch(console.error);
open(href)
.then(response => {
if (!response.success) {
alertUser(response.errorMessage
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At some point we should stop using alertUser, this function was always a bit a hack but very convenient. Better would be to have an error state and render the error in the component.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can there be a solution that is as easy to use as alertUser? Making ad-hoc ways to display errors everywhere is difficult (for me).

Maybe you could make a PR sometime that removes all uses of alertUser and replaces it with a streamlined way of showing such messages?

Copy link
Copy Markdown
Collaborator

@thisconnect thisconnect Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is pretty easy have a look: https://github.com/BitBoxSwiss/bitbox-wallet-app/pull/4082/changes.

But I agree it would be nice to have a good reusable way.

In the future we may want to have a global notification center / error component. 🤷

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue with that one is that it's a lot more effort - need to think where to place the error message, test if it looks good, etc. alertUser "just works" - would be nice to have something that is as easy to use.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's harder yes but much nicer UI than popup. we also have popup over popup, somewhere in one case 3 popups over each other each with different dimension and visible. I'll find a screenshot once.

also from our friend gpt:

You’re right to call this a “sin” 😄 — a global mutable function that triggers UI from anywhere (including utilities) breaks React’s core model (unidirectional data flow + explicit state). Refactoring it is less about removing alertUser and more about restoring architectural boundaries.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keep as is for this PR, but if you don't mind maybe well change it for smth better and remove alertUser ok?

? t('unknownError', { errorMessage: response.errorMessage })
: t('genericError'));
}
})
.catch(console.error);
Comment thread
benma marked this conversation as resolved.
}}
tabIndex={0}
{...props}>
Expand Down
110 changes: 110 additions & 0 deletions frontends/web/src/routes/market/moonpay.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// SPDX-License-Identifier: Apache-2.0

import '../../../__mocks__/i18n';
import type { ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

vi.mock('@/components/layout', () => ({
Header: ({ title }: { title: ReactNode }) => <div>{title}</div>,
}));
vi.mock('@/components/spinner/Spinner', () => ({
Spinner: ({ text }: { text?: string }) => <div>{text}</div>,
}));
vi.mock('@/hooks/backbutton', () => ({
UseDisableBackButton: () => null,
}));
vi.mock('@/hooks/darkmode', () => ({
useDarkmode: () => ({ isDarkMode: false, toggleDarkmode: vi.fn() }),
}));
vi.mock('@/hooks/vendor-iframe', () => ({
useVendorIframeResizeHeight: () => ({
containerRef: { current: null },
height: 480,
iframeLoaded: false,
onIframeLoad: vi.fn(),
}),
useVendorTerms: () => ({
agreedTerms: true,
setAgreedTerms: vi.fn(),
}),
}));
vi.mock('./guide', () => ({
MarketGuide: () => null,
}));
vi.mock('@/api/market', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/api/market')>();
return {
...actual,
getMoonpayBuyInfo: vi.fn(),
};
});
vi.mock('@/utils/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/utils/config')>();
return {
...actual,
getConfig: vi.fn(),
};
});

import { render, screen } from '@testing-library/react';
import type { TAccount } from '@/api/account';
import * as marketApi from '@/api/market';
import * as config from '@/utils/config';
import { Moonpay } from './moonpay';

const account: TAccount = {
keystore: {
connected: true,
lastConnected: '',
name: 'BitBox02',
rootFingerprint: 'f23ab988',
watchonly: false,
},
active: true,
blockExplorerTxPrefix: '',
code: 'btc-account',
coinCode: 'btc',
coinName: 'Bitcoin',
coinUnit: 'BTC',
isToken: false,
name: 'Bitcoin Account',
};

describe('routes/market/moonpay', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(config.getConfig).mockResolvedValue({
frontend: {
skipMoonpayDisclaimer: true,
},
});
});

it('renders the MoonPay iframe on success', async () => {
vi.mocked(marketApi.getMoonpayBuyInfo).mockReturnValue(() => Promise.resolve({
success: true,
url: 'https://buy.moonpay.com?walletAddress=bc1qexample',
address: 'bc1qexample',
}));

render(<Moonpay accounts={[account]} code={account.code} />);

const iframe = await screen.findByTitle('Moonpay');
expect(iframe).toHaveAttribute(
'src',
'https://buy.moonpay.com?walletAddress=bc1qexample&colorCode=%235E94BF&theme=light',
);
});

it('renders an error message on failure', async () => {
vi.mocked(marketApi.getMoonpayBuyInfo).mockReturnValue(() => Promise.resolve({
success: false,
errorMessage: 'Account is not valid.',
}));

render(<Moonpay accounts={[account]} code={account.code} />);

expect(await screen.findByText('Account is not valid.')).toBeInTheDocument();
expect(screen.queryByTitle('Moonpay')).not.toBeInTheDocument();
});
});
10 changes: 8 additions & 2 deletions frontends/web/src/routes/market/moonpay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { getConfig } from '@/utils/config';
import { getMoonpayBuyInfo } from '@/api/market';
import { MarketGuide } from './guide';
import { Header } from '@/components/layout';
import { Message } from '@/components/message/message';
import { Spinner } from '@/components/spinner/Spinner';
import { findAccount, isBitcoinOnly } from '@/routes/account/utils';
import { MoonpayTerms } from '@/components/terms/moonpay-terms';
Expand Down Expand Up @@ -58,8 +59,8 @@ export const Moonpay = ({ accounts, code }: TProps) => {
) : (
<div style={{ height }}>
<UseDisableBackButton />
{!iframeLoaded && <Spinner text={t('loading')} />}
{ moonpay && (
{(!moonpay || (moonpay.success && !iframeLoaded)) && <Spinner text={t('loading')} />}
{ moonpay?.success && (
<iframe
onLoad={() => {
onIframeLoad();
Expand All @@ -73,6 +74,11 @@ export const Moonpay = ({ accounts, code }: TProps) => {
src={`${moonpay.url}&colorCode=%235E94BF&theme=${isDarkMode ? 'dark' : 'light'}`}>
</iframe>
)}
{ moonpay?.success === false && (
<Message type="error">
{moonpay.errorMessage || t('genericError')}
</Message>
)}
</div>
)}
</div>
Expand Down
13 changes: 12 additions & 1 deletion frontends/web/src/utils/config.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
// SPDX-License-Identifier: Apache-2.0

import { apiGet, apiPost } from '@/utils/request';
import { runningInQtWebEngine, runningOnMobile } from '@/utils/env';

type TConfig = {
backend?: unknown;
frontend?: unknown;
};

type TSetConfigResponse = null | undefined | {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this correct that it can return null | undefined ?

success: true;
} | {
success: false;
errorMessage?: string;
};

let pendingConfig: TConfig = {};

/**
Expand All @@ -32,7 +40,10 @@ export const setConfig = (object: TConfig) => {
});
pendingConfig = nextConfig;
return apiPost('config', nextConfig)
.then(() => {
.then((response: TSetConfigResponse) => {
if (response?.success === false && !runningInQtWebEngine() && !runningOnMobile()) {
throw new Error(response.errorMessage || 'Failed to update configuration');
}
Comment thread
benma marked this conversation as resolved.
pendingConfig = {};
return nextConfig;
Comment thread
benma marked this conversation as resolved.
});
Expand Down