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
7 changes: 7 additions & 0 deletions backend/devices/bitbox02/keystore.go
Original file line number Diff line number Diff line change
Expand Up @@ -556,3 +556,10 @@ func (keystore *keystore) SupportsPaymentRequests() error {
}
return keystorePkg.ErrFirmwareUpgradeRequired
}

// Features reports optional capabilities supported by the BitBox02 keystore.
func (keystore *keystore) Features() *keystorePkg.Features {
return &keystorePkg.Features{
SupportsSendToSelf: keystore.device.Version().AtLeast(semver.NewSemVer(9, 22, 0)),
}
}
50 changes: 50 additions & 0 deletions backend/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ func NewHandlers(
getAPIRouterNoError(apiRouter)("/dev-servers", handlers.getDevServers).Methods("GET")
getAPIRouterNoError(apiRouter)("/account-add", handlers.postAddAccount).Methods("POST")
getAPIRouterNoError(apiRouter)("/keystores", handlers.getKeystores).Methods("GET")
getAPIRouterNoError(apiRouter)("/keystore/{rootFingerprint}/features", handlers.getKeystoreFeatures).Methods("GET")
getAPIRouterNoError(apiRouter)("/accounts", handlers.getAccounts).Methods("GET")
getAPIRouter(apiRouter)("/accounts/balance-summary", handlers.getAccountsBalanceSummary).Methods("GET")
getAPIRouterNoError(apiRouter)("/set-account-active", handlers.postSetAccountActive).Methods("POST")
Expand Down Expand Up @@ -597,6 +598,55 @@ func (handlers *Handlers) getKeystores(*http.Request) interface{} {
return keystores
}

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

rootFingerprintHex := mux.Vars(r)["rootFingerprint"]
rootFingerprint, err := hex.DecodeString(rootFingerprintHex)
if err != nil {
handlers.log.WithError(err).Error("invalid root fingerprint for features request")
return response{
Success: false,
ErrorMessage: err.Error(),
}
}

connectedKeystore := handlers.backend.Keystore()
if connectedKeystore == nil {
handlers.log.Warn("features requested but no keystore connected")
return response{
Success: false,
ErrorMessage: "keystore not connected",
}
}

connectedRootFingerprint, err := connectedKeystore.RootFingerprint()
if err != nil {
handlers.log.WithError(err).Error("could not determine connected keystore root fingerprint")
return response{
Success: false,
ErrorMessage: err.Error(),
}
}

if !bytes.Equal(rootFingerprint, connectedRootFingerprint) {
handlers.log.WithField("requested", rootFingerprintHex).Warn("features requested for non-connected keystore")
return response{
Success: false,
ErrorMessage: "wrong keystore connected",
}
}

return response{
Success: true,
Features: connectedKeystore.Features(),
}
}

func (handlers *Handlers) getAccounts(*http.Request) interface{} {
persistedAccounts := handlers.backend.Config().AccountsConfig()

Expand Down
10 changes: 10 additions & 0 deletions backend/keystore/keystore.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,4 +150,14 @@ type Keystore interface {

// SupportsPaymentRequests returns nil if the device supports silent payments, or an error indicating why it is not supported.
SupportsPaymentRequests() error

// Features reports optional capabilities supported by this keystore.
Features() *Features
}

// Features enumerates optional capabilities that can differ per keystore implementation.
type Features struct {
// SupportsSendToSelf indicates whether the keystore can explicitly verify outputs that belong to
// the same keystore (used for the send-to-self recipient dropdown flow).
SupportsSendToSelf bool `json:"supportsSendToSelf"`
}
37 changes: 37 additions & 0 deletions backend/keystore/mocks/keystore.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions backend/keystore/software/software.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,13 @@ func (keystore *Keystore) BTCXPubs(
return xpubs, nil
}

// Features reports optional capabilities supported by the software keystore.
func (keystore *Keystore) Features() *keystorePkg.Features {
return &keystorePkg.Features{
SupportsSendToSelf: true,
}
}

func (keystore *Keystore) signBTCTransaction(btcProposedTx *btc.ProposedTransaction) error {
keystore.log.Info("Sign transaction.")
transaction := btcProposedTx.TXProposal.Psbt.UnsignedTx
Expand Down
14 changes: 14 additions & 0 deletions frontends/web/src/api/keystores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ export type { TUnsubscribe };
type TKeystore = { type: 'hardware' | 'software' };
export type TKeystores = TKeystore[];

export type TKeystoreFeatures = {
supportsSendToSelf: boolean;
};

export type TKeystoreFeaturesResponse = {
success: boolean;
features?: TKeystoreFeatures | null;
errorMessage?: string;
};

export const subscribeKeystores = (
cb: (keystores: TKeystores) => void
) => {
Expand All @@ -43,3 +53,7 @@ export const deregisterTest = (): Promise<null> => {
export const connectKeystore = (rootFingerprint: string): Promise<{ success: boolean; }> => {
return apiPost('connect-keystore', { rootFingerprint });
};

export const getKeystoreFeatures = (rootFingerprint: string): Promise<TKeystoreFeaturesResponse> => {
return apiGet(`keystore/${rootFingerprint}/features`);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* Copyright 2025 Shift Crypto AG
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { useContext } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Dialog, DialogButtons } from './dialog';
import { Button } from '@/components/forms';
import { AppContext } from '@/contexts/AppContext';

type TFirmwareUpgradeRequiredDialogProps = {
open: boolean;
onClose: () => void;
deviceIDs?: string[];
};

export const FirmwareUpgradeRequiredDialog = ({ open, onClose, deviceIDs }: TFirmwareUpgradeRequiredDialogProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { setFirmwareUpdateDialogOpen } = useContext(AppContext);

const handleUpgrade = () => {
setFirmwareUpdateDialogOpen(true);
if (deviceIDs && deviceIDs.length > 0) {
navigate(`/settings/device-settings/${deviceIDs[0] as string}`);
}
onClose();
};

return (
<Dialog
title={t('upgradeFirmware.title')}
open={open}
onClose={onClose}>
<p>{t('device.firmwareUpgradeRequired')}</p>
<DialogButtons>
<Button primary onClick={handleUpgrade}>
{t('upgradeFirmware.button')}
</Button>
<Button secondary onClick={onClose}>
{t('dialog.cancel')}
</Button>
</DialogButtons>
</Dialog>
);
};
4 changes: 3 additions & 1 deletion frontends/web/src/routes/account/send/send-wrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@ import { Send } from './send';

type TSendProps = {
activeAccounts: IAccount[];
deviceIDs: string[];
code: AccountCode;
}

export const SendWrapper = ({ activeAccounts, code }: TSendProps) => {
export const SendWrapper = ({ activeAccounts, deviceIDs, code }: TSendProps) => {
const { defaultCurrency } = useContext(RatesContext);
const account = findAccount(activeAccounts, code);

Expand All @@ -35,6 +36,7 @@ export const SendWrapper = ({ activeAccounts, code }: TSendProps) => {
account={account}
activeAccounts={activeAccounts}
activeCurrency={defaultCurrency}
deviceIDs={deviceIDs}
/>
) : (null)
);
Expand Down
25 changes: 22 additions & 3 deletions frontends/web/src/routes/account/send/send.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,15 @@ import { NoteInput } from './components/inputs/note-input';
import { FiatValue } from './components/fiat-value';
import { TProposalError, txProposalErrorHandling } from './services';
import { CoinControl } from './coin-control';
import { connectKeystore } from '@/api/keystores';
import { connectKeystore, getKeystoreFeatures } from '@/api/keystores';
import { FirmwareUpgradeRequiredDialog } from '@/components/dialog/firmware-upgrade-required-dialog';
import style from './send.module.css';

type TProps = {
account: accountApi.IAccount;
activeAccounts?: accountApi.IAccount[];
activeCurrency: accountApi.Fiat;
deviceIDs: string[];
}

const useAccountBalance = (accountCode: accountApi.AccountCode) => {
Expand Down Expand Up @@ -76,8 +78,10 @@ export const Send = ({
account,
activeAccounts,
activeCurrency,
deviceIDs,
}: TProps) => {
const { t } = useTranslation();
const [showFirmwareUpgradeDialog, setShowFirmwareUpgradeDialog] = useState(false);

const selectedUTXOsRef = useRef<TSelectedUTXOs>({});
const [utxoDialogActive, setUtxoDialogActive] = useState(false);
Expand Down Expand Up @@ -135,18 +139,28 @@ export const Send = ({
if (!connectResult.success) {
return;
}
if (selectedReceiverAccount) {
const featuresResult = await getKeystoreFeatures(rootFingerprint);
if (!featuresResult.success) {
alertUser(featuresResult.errorMessage || t('genericError'));
return;
}
if (!featuresResult.features?.supportsSendToSelf) {
setShowFirmwareUpgradeDialog(true);
return;
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

in Pocket we use this

<Dialog title={t('upgradeFirmware.title')} open={fwRequiredDialog}>
{t('device.firmwareUpgradeRequired')}
<DialogButtons>
<Button
primary
onClick={() => {
setFirmwareUpdateDialogOpen(true);
navigate(`/settings/device-settings/${deviceIDs[0] as string}`);
}}>
{t('upgradeFirmware.button')}
</Button>
<Button
secondary
onClick={() => {
setFwRequiredDialog(false);
navigate(-1);
}}>
{t('dialog.cancel')}
</Button>
</DialogButtons>
</Dialog>

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Nice. Will ask my agent to extract this into a resuable component later 😁

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@thisconnect done, PTAL.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Tested with 9.18.0 and tried to find why it didn't show. But reading the code the FW upgrade dialog should only shows when handleSend is called. Could you change to it shows earlier?

I suggest either when the user clicks the dropdown or after choosing an option from the dropdown.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Agree - I think it'd be nice to show it earlier, before the user presses the send btn

setIsConfirming(true);
try {
const result = await accountApi.sendTx(account.code, note);
setSendResult(result);
setIsConfirming(false);
} catch (err) {
console.error(err);
} finally {
// The following method allows pressing escape again.
setIsConfirming(false);
}
}, [account.code, account.keystore.rootFingerprint, note]);
}, [account.code, account.keystore.rootFingerprint, note, selectedReceiverAccount, setShowFirmwareUpgradeDialog, t]);

const getValidTxInputData = useCallback((): Required<accountApi.TTxInput> | false => {
if (
Expand Down Expand Up @@ -513,6 +527,11 @@ export const Send = ({
</Main>
</GuidedContent>
<SendGuide coinCode={account.coinCode} />
<FirmwareUpgradeRequiredDialog
open={showFirmwareUpgradeDialog}
onClose={() => setShowFirmwareUpgradeDialog(false)}
deviceIDs={deviceIDs}
/>
</GuideWrapper>
);
};
Loading