diff --git a/backend/devices/bitbox02/keystore.go b/backend/devices/bitbox02/keystore.go index c2e233da67..d011bee75d 100644 --- a/backend/devices/bitbox02/keystore.go +++ b/backend/devices/bitbox02/keystore.go @@ -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)), + } +} diff --git a/backend/handlers/handlers.go b/backend/handlers/handlers.go index 2a8fbdc365..d137bb33f7 100644 --- a/backend/handlers/handlers.go +++ b/backend/handlers/handlers.go @@ -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") @@ -605,6 +606,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() diff --git a/backend/keystore/keystore.go b/backend/keystore/keystore.go index e3b33b5cd0..b41e1207df 100644 --- a/backend/keystore/keystore.go +++ b/backend/keystore/keystore.go @@ -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"` } diff --git a/backend/keystore/mocks/keystore.go b/backend/keystore/mocks/keystore.go index e5a1f04476..86a17d38a3 100644 --- a/backend/keystore/mocks/keystore.go +++ b/backend/keystore/mocks/keystore.go @@ -38,6 +38,9 @@ var _ keystore.Keystore = &KeystoreMock{} // ExtendedPublicKeyFunc: func(coinMoqParam coin.Coin, absoluteKeypath signing.AbsoluteKeypath) (*hdkeychain.ExtendedKey, error) { // panic("mock out the ExtendedPublicKey method") // }, +// FeaturesFunc: func() *keystore.Features { +// panic("mock out the Features method") +// }, // NameFunc: func() (string, error) { // panic("mock out the Name method") // }, @@ -111,6 +114,9 @@ type KeystoreMock struct { // ExtendedPublicKeyFunc mocks the ExtendedPublicKey method. ExtendedPublicKeyFunc func(coinMoqParam coin.Coin, absoluteKeypath signing.AbsoluteKeypath) (*hdkeychain.ExtendedKey, error) + // FeaturesFunc mocks the Features method. + FeaturesFunc func() *keystore.Features + // NameFunc mocks the Name method. NameFunc func() (string, error) @@ -191,6 +197,9 @@ type KeystoreMock struct { // AbsoluteKeypath is the absoluteKeypath argument value. AbsoluteKeypath signing.AbsoluteKeypath } + // Features holds details about calls to the Features method. + Features []struct { + } // Name holds details about calls to the Name method. Name []struct { } @@ -292,6 +301,7 @@ type KeystoreMock struct { lockCanVerifyAddress sync.RWMutex lockCanVerifyExtendedPublicKey sync.RWMutex lockExtendedPublicKey sync.RWMutex + lockFeatures sync.RWMutex lockName sync.RWMutex lockRootFingerprint sync.RWMutex lockSignBTCMessage sync.RWMutex @@ -474,6 +484,33 @@ func (mock *KeystoreMock) ExtendedPublicKeyCalls() []struct { return calls } +// Features calls FeaturesFunc. +func (mock *KeystoreMock) Features() *keystore.Features { + if mock.FeaturesFunc == nil { + panic("KeystoreMock.FeaturesFunc: method is nil but Keystore.Features was just called") + } + callInfo := struct { + }{} + mock.lockFeatures.Lock() + mock.calls.Features = append(mock.calls.Features, callInfo) + mock.lockFeatures.Unlock() + return mock.FeaturesFunc() +} + +// FeaturesCalls gets all the calls that were made to Features. +// Check the length with: +// +// len(mockedKeystore.FeaturesCalls()) +func (mock *KeystoreMock) FeaturesCalls() []struct { +} { + var calls []struct { + } + mock.lockFeatures.RLock() + calls = mock.calls.Features + mock.lockFeatures.RUnlock() + return calls +} + // Name calls NameFunc. func (mock *KeystoreMock) Name() (string, error) { if mock.NameFunc == nil { diff --git a/backend/keystore/software/software.go b/backend/keystore/software/software.go index e4f7508dce..8023173673 100644 --- a/backend/keystore/software/software.go +++ b/backend/keystore/software/software.go @@ -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 diff --git a/frontends/web/src/api/keystores.ts b/frontends/web/src/api/keystores.ts index 3be74c2f71..5bbb587e09 100644 --- a/frontends/web/src/api/keystores.ts +++ b/frontends/web/src/api/keystores.ts @@ -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 ) => { @@ -43,3 +53,7 @@ export const deregisterTest = (): Promise => { export const connectKeystore = (rootFingerprint: string): Promise<{ success: boolean; }> => { return apiPost('connect-keystore', { rootFingerprint }); }; + +export const getKeystoreFeatures = (rootFingerprint: string): Promise => { + return apiGet(`keystore/${rootFingerprint}/features`); +}; diff --git a/frontends/web/src/app.tsx b/frontends/web/src/app.tsx index d634c1019a..320e9cb42f 100644 --- a/frontends/web/src/app.tsx +++ b/frontends/web/src/app.tsx @@ -216,7 +216,6 @@ export const App = () => { diff --git a/frontends/web/src/components/dialog/firmware-upgrade-required-dialog.tsx b/frontends/web/src/components/dialog/firmware-upgrade-required-dialog.tsx new file mode 100644 index 0000000000..3270d145b9 --- /dev/null +++ b/frontends/web/src/components/dialog/firmware-upgrade-required-dialog.tsx @@ -0,0 +1,64 @@ +/** + * 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 { useCallback, useContext } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { Dialog, DialogButtons } from './dialog'; +import { getDeviceList } from '@/api/devices'; +import { syncDeviceList } from '@/api/devicessync'; +import { useSync } from '@/hooks/api'; +import { useDefault } from '@/hooks/default'; +import { Button } from '@/components/forms'; +import { AppContext } from '@/contexts/AppContext'; + +type TFirmwareUpgradeRequiredDialogProps = { + open: boolean; + onClose: () => void; +}; + +export const FirmwareUpgradeRequiredDialog = ({ open, onClose }: TFirmwareUpgradeRequiredDialogProps) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { setFirmwareUpdateDialogOpen } = useContext(AppContext); + const devices = useDefault(useSync(getDeviceList, syncDeviceList), {}); + const deviceIDs = Object.keys(devices); + + const handleUpgrade = useCallback(() => { + setFirmwareUpdateDialogOpen(true); + if (deviceIDs && deviceIDs.length > 0) { + navigate(`/settings/device-settings/${deviceIDs[0] as string}`); + } + onClose(); + }, [setFirmwareUpdateDialogOpen, deviceIDs, navigate, onClose]); + + return ( + +

{t('device.firmwareUpgradeRequired')}

+ + + + +
+ ); +}; diff --git a/frontends/web/src/routes/account/send/components/inputs/receiver-address-wrapper.tsx b/frontends/web/src/routes/account/send/components/inputs/receiver-address-wrapper.tsx index 4485b01d94..c92b202a2c 100644 --- a/frontends/web/src/routes/account/send/components/inputs/receiver-address-wrapper.tsx +++ b/frontends/web/src/routes/account/send/components/inputs/receiver-address-wrapper.tsx @@ -16,15 +16,18 @@ import { ChangeEvent, useCallback, useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; +import { alertUser } from '@/components/alert/Alert'; import { TOption } from '@/components/dropdown/dropdown'; import { InputWithDropdown } from '@/components/forms/input-with-dropdown'; import * as accountApi from '@/api/account'; import { getReceiveAddressList, IAccount } from '@/api/account'; import { statusChanged, syncdone } from '@/api/accountsync'; +import { connectKeystore, getKeystoreFeatures } from '@/api/keystores'; import { unsubscribe } from '@/utils/subscriptions'; import { TUnsubscribe } from '@/utils/transport-common'; import { useMountedRef } from '@/hooks/mount'; import { useMediaQuery } from '@/hooks/mediaquery'; +import { FirmwareUpgradeRequiredDialog } from '@/components/dialog/firmware-upgrade-required-dialog'; import { SpinnerRingAnimated } from '@/components/spinner/SpinnerAnimation'; import { Logo } from '@/components/icon'; import receiverStyles from './receiver-address-input.module.css'; @@ -73,6 +76,7 @@ export const ReceiverAddressWrapper = ({ children, }: TReceiverAddressWrapperProps) => { const { t } = useTranslation(); + const [showFirmwareUpgradeDialog, setShowFirmwareUpgradeDialog] = useState(false); const mounted = useMountedRef(); const isMobile = useMediaQuery('(max-width: 768px)'); const [selectedAccount, setSelectedAccount] = useState | null>(null); @@ -88,11 +92,34 @@ export const ReceiverAddressWrapper = ({ }; }) : []; + const checkFirmwareSupport = useCallback(async (selectedAccount: accountApi.IAccount) => { + const rootFingerprint = selectedAccount.keystore.rootFingerprint; + const connectResult = await connectKeystore(rootFingerprint); + if (!connectResult.success) { + return false; + } + const featuresResult = await getKeystoreFeatures(rootFingerprint); + if (!featuresResult.success) { + alertUser(featuresResult.errorMessage || t('genericError')); + return false; + } + if (!featuresResult.features?.supportsSendToSelf) { + setShowFirmwareUpgradeDialog(true); + return false; + } + return true; + }, [t]); + const handleSendToAccount = useCallback(async (selectedOption: TAccountOption) => { if (selectedOption.value === null || selectedOption.disabled) { return; } const selectedAccountValue = selectedOption.value; + + const supported = await checkFirmwareSupport(selectedAccountValue); + if (!supported) { + return; + } setSelectedAccount(selectedOption); try { const receiveAddresses = await getReceiveAddressList(selectedAccountValue.code)(); @@ -104,7 +131,7 @@ export const ReceiverAddressWrapper = ({ } catch (e) { console.error(e); } - }, [onInputChange, onAccountChange]); + }, [onInputChange, onAccountChange, checkFirmwareSupport]); const handleReset = useCallback(() => { setSelectedAccount(null); @@ -141,34 +168,40 @@ export const ReceiverAddressWrapper = ({ }, [accounts, checkAccountStatus]); return ( - ) => onInputChange(e.target.value)} - value={recipientAddress} - disabled={selectedAccount !== null} - autoFocus={!isMobile} - dropdownOptions={accountOptions} - dropdownValue={selectedAccount} - onDropdownChange={(selected) => { - if (selected && selected.value !== null && !(selected as TAccountOption).disabled) { - handleSendToAccount(selected as TAccountOption); - } - }} - dropdownPlaceholder={t('send.sendToAccount.placeholder')} - dropdownTitle={t('send.sendToAccount.title')} - renderOptions={(e, isSelectedValue) => } - isOptionDisabled={(option) => (option as TAccountOption).disabled || false} - labelSection={selectedAccount ? ( - - {t('generic.reset')} - - ) : undefined} - > - {children} - + <> + ) => onInputChange(e.target.value)} + value={recipientAddress} + disabled={selectedAccount !== null} + autoFocus={!isMobile} + dropdownOptions={accountOptions} + dropdownValue={selectedAccount} + onDropdownChange={(selected) => { + if (selected && selected.value !== null && !(selected as TAccountOption).disabled) { + handleSendToAccount(selected as TAccountOption); + } + }} + dropdownPlaceholder={t('send.sendToAccount.placeholder')} + dropdownTitle={t('send.sendToAccount.title')} + renderOptions={(e, isSelectedValue) => } + isOptionDisabled={(option) => (option as TAccountOption).disabled || false} + labelSection={selectedAccount ? ( + + {t('generic.reset')} + + ) : undefined} + > + {children} + + setShowFirmwareUpgradeDialog(false)} + /> + ); }; diff --git a/frontends/web/src/routes/account/send/send.tsx b/frontends/web/src/routes/account/send/send.tsx index 942096b303..a0e7e21e20 100644 --- a/frontends/web/src/routes/account/send/send.tsx +++ b/frontends/web/src/routes/account/send/send.tsx @@ -78,7 +78,6 @@ export const Send = ({ activeCurrency, }: TProps) => { const { t } = useTranslation(); - const selectedUTXOsRef = useRef({}); const [utxoDialogActive, setUtxoDialogActive] = useState(false); // in case there are multiple parallel tx proposals we can ignore all other but the last one @@ -139,7 +138,6 @@ export const Send = ({ try { const result = await accountApi.sendTx(account.code, note); setSendResult(result); - setIsConfirming(false); } catch (err) { console.error(err); } finally { diff --git a/frontends/web/src/routes/market/pocket.tsx b/frontends/web/src/routes/market/pocket.tsx index 868f97394d..030909a8b5 100644 --- a/frontends/web/src/routes/market/pocket.tsx +++ b/frontends/web/src/routes/market/pocket.tsx @@ -14,13 +14,12 @@ * limitations under the License. */ -import { createRef, useContext, useEffect, useState, useRef } from 'react'; +import { createRef, useEffect, useState, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { RequestAddressV0Message, MessageVersion, parseMessage, serializeMessage, V0MessageType, PaymentRequestV0Message } from 'request-address'; -import { AppContext } from '@/contexts/AppContext'; import { getConfig } from '@/utils/config'; -import { Dialog, DialogButtons } from '@/components/dialog/dialog'; +import { Dialog } from '@/components/dialog/dialog'; import { confirmation } from '@/components/confirm/Confirm'; import { verifyAddress, getPocketURL, TMarketAction } from '@/api/market'; import { AccountCode, getInfo, getTransactionList, hasPaymentRequest, signAddress, proposeTx, sendTx, TTxInput } from '@/api/account'; @@ -33,26 +32,23 @@ import { alertUser } from '@/components/alert/Alert'; import { MarketGuide } from './guide'; import { convertScriptType } from '@/utils/request-addess'; import { parseExternalBtcAmount } from '@/api/coins'; +import { FirmwareUpgradeRequiredDialog } from '@/components/dialog/firmware-upgrade-required-dialog'; import style from './iframe.module.css'; -import { Button } from '@/components/forms'; type TProps = { action: TMarketAction; code: AccountCode; - deviceIDs: string[]; } export const Pocket = ({ action, code, - deviceIDs, }: TProps) => { const { t } = useTranslation(); const navigate = useNavigate(); // Pocket sell only works if the FW supports payment requests const hasPaymentRequestResponse = useLoad(() => hasPaymentRequest(code)); - const { setFirmwareUpdateDialogOpen } = useContext(AppContext); const [fwRequiredDialog, setFwRequiredDialog] = useState(false); const [height, setHeight] = useState(0); @@ -91,7 +87,7 @@ export const Pocket = ({ alertUser(hasPaymentRequestResponse?.errorMessage); } } - }, [action, deviceIDs, hasPaymentRequestResponse, navigate, setFirmwareUpdateDialogOpen, t]); + }, [action, hasPaymentRequestResponse, t]); useEffect(() => { if (config) { @@ -382,27 +378,13 @@ export const Pocket = ({ medium centered>
{t('buy.pocket.verifyBitBox02')}
- - {t('device.firmwareUpgradeRequired')} - - - - - + { + setFwRequiredDialog(false); + navigate(-1); + }} + /> diff --git a/frontends/web/src/routes/router.tsx b/frontends/web/src/routes/router.tsx index 6a36745313..1d2966034e 100644 --- a/frontends/web/src/routes/router.tsx +++ b/frontends/web/src/routes/router.tsx @@ -55,7 +55,6 @@ import { More } from '@/routes/settings/more'; type TAppRouterProps = { devices: TDevices; - deviceIDs: string[]; accounts: IAccount[]; activeAccounts: IAccount[]; devicesKey: ((input: string) => string) @@ -70,7 +69,7 @@ const InjectParams = ({ children }: TInjectParamsProps) => { return React.cloneElement(children as React.ReactElement, params); }; -export const AppRouter = ({ devices, deviceIDs, devicesKey, accounts, activeAccounts }: TAppRouterProps) => { +export const AppRouter = ({ devices, devicesKey, accounts, activeAccounts }: TAppRouterProps) => { const hasAccounts = accounts.length > 0; const Homepage = ( ); @@ -207,7 +205,6 @@ export const AppRouter = ({ devices, deviceIDs, devicesKey, accounts, activeAcco );