diff --git a/lavamoat/webpack/policy-override.json b/lavamoat/webpack/policy-override.json index 333a7689..9be9409e 100644 --- a/lavamoat/webpack/policy-override.json +++ b/lavamoat/webpack/policy-override.json @@ -1,5 +1,11 @@ { "resources": { + "cypress>lodash": { + "globals": { + "setTimeout": true, + "clearTimeout": true + } + }, "bootstrap": { "globals": { "document": true, diff --git a/package-lock.json b/package-lock.json index 535740db..99b34498 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "hathor-wallet", - "version": "0.34.0", + "version": "0.35.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hathor-wallet", - "version": "0.34.0", + "version": "0.35.0", "hasInstallScript": true, "dependencies": { "@hathor/hathor-rpc-handler": "5.0.0", diff --git a/package.json b/package.json index 2384cea6..b427ef86 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "productName": "Hathor Wallet", "description": "Light wallet for Hathor Network", "author": "Hathor Labs (https://hathor.network/)", - "version": "0.34.0", + "version": "0.35.0", "engines": { "node": ">=22.0.0", "npm": ">=10.0.0" diff --git a/src/components/ModalTokenImport.js b/src/components/ModalTokenImport.js index eba489ff..9f52d828 100644 --- a/src/components/ModalTokenImport.js +++ b/src/components/ModalTokenImport.js @@ -14,6 +14,7 @@ import { GlobalModalContext, MODAL_TYPES } from './GlobalModal'; import { tokenRegisterRequested } from '../actions/index'; import { getGlobalWallet } from '../modules/wallet'; import walletUtils from '../utils/wallet'; +import helpers from '../utils/helpers'; import { colors } from '../constants'; /** @@ -337,7 +338,10 @@ export default function ModalTokenImport({ onClose, manageDomLifecycle }) { { + e.preventDefault(); + helpers.openExternalURL(explorerLink); + }} rel="noopener noreferrer" title={uid} > diff --git a/src/components/ModalTransactionOverview.js b/src/components/ModalTransactionOverview.js index 847734f2..ecce218a 100644 --- a/src/components/ModalTransactionOverview.js +++ b/src/components/ModalTransactionOverview.js @@ -5,7 +5,7 @@ * LICENSE file in the root directory of this source tree. */ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { t } from 'ttag'; import $ from 'jquery'; import PropTypes from 'prop-types'; @@ -15,6 +15,7 @@ import { useSelector } from 'react-redux'; import helpers from '../utils/helpers'; import { TOKEN_FEE_RFC_URL, colors } from '../constants'; import SendTxHandler from './SendTxHandler'; +import { getGlobalWallet } from '../modules/wallet'; const MODAL_ID = 'transactionOverviewModal'; @@ -39,13 +40,27 @@ function ModalTransactionOverview({ const [pin, setPin] = useState(''); const [phase, setPhase] = useState('review'); const [errorMessage, setErrorMessage] = useState(''); + const [pinError, setPinError] = useState(''); const [preparedTx, setPreparedTx] = useState(null); + const pinInputRef = useRef(null); const tokenMetadata = useSelector((state) => state.tokenMetadata); useEffect(() => { manageDomLifecycle(`#${MODAL_ID}`); }, [manageDomLifecycle]); + // Focus the PIN field after the modal finishes fading in. `autoFocus` alone + // fails on first open: the input mounts while the modal is still `display:none`, + // so the browser drops the focus. `autoFocus` is still kept — it covers the + // error -> "Try again" -> review remount, where the modal is already visible. + useEffect(() => { + const onShown = () => pinInputRef.current?.focus(); + $(`#${MODAL_ID}`).on('shown.bs.modal.focuspin', onShown); + return () => { + $(`#${MODAL_ID}`).off('shown.bs.modal.focuspin', onShown); + }; + }, []); + const fee = typeof totalFee === 'bigint' ? totalFee : BigInt(totalFee || 0); const hasAnyFee = fee > 0n; @@ -95,10 +110,28 @@ function ModalTransactionOverview({ const handleCancel = () => { $(`#${MODAL_ID}`).modal('hide'); setPin(''); + setPinError(''); onCancel(); }; const handleConfirm = async () => { + // Validate the PIN before attempting the send so a wrong PIN surfaces as + // "Invalid PIN" on the review phase instead of a generic send failure. A + // missing wallet or a checkPin failure is a real error, not a wrong PIN, so + // route it to the error phase instead of leaving the rejection unhandled. + try { + const wallet = getGlobalWallet(); + if (!await wallet.checkPin(pin)) { + setPinError(t`Invalid PIN`); + return; + } + } catch (e) { + setErrorMessage(e.message || t`Error validating PIN.`); + setPhase('error'); + return; + } + + setPinError(''); setPhase('sending'); setErrorMessage(''); @@ -129,6 +162,7 @@ function ModalTransactionOverview({ const handleRetry = () => { setPreparedTx(null); setErrorMessage(''); + setPinError(''); setPin(''); setPhase('review'); }; @@ -136,6 +170,7 @@ function ModalTransactionOverview({ const handlePinChange = (e) => { const value = e.target.value.replace(/\D/g, '').slice(0, 6); setPin(value); + setPinError(''); }; // --- Render helpers for the review phase --- @@ -291,6 +326,7 @@ function ModalTransactionOverview({
+ {pinError &&

{pinError}

}
); diff --git a/src/reducers/index.js b/src/reducers/index.js index ac3d5de7..11055065 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -670,18 +670,23 @@ const removeTokenMetadata = (state, action) => { delete newMeta[uid]; } - // If the token has zero balance we should remove the balance data + // Drop a zero-balance unregistered token from allTokens and tokensBalance + // together (fetchUnknownTokens reads tokensBalance[uid].data for every allTokens + // entry): a leftover keeps the "Import Tokens" banner advertising a token the + // modal can't resolve. Tokens with a balance stay, still re-importable. const newBalance = Object.assign({}, state.tokensBalance); - if (uid in newBalance && (!!newBalance[uid].data)) { - const balance = newBalance[uid].data; - if ((balance.available + balance.locked) === 0n) { - delete newBalance[uid]; - } + const newAllTokens = Object.assign({}, state.allTokens); + const balance = newBalance[uid]?.data; + if (balance && (balance.available + balance.locked) === 0n) { + delete newBalance[uid]; + delete newAllTokens[uid]; } return { ...state, tokenMetadata: newMeta, + tokensBalance: newBalance, + allTokens: newAllTokens, }; }; diff --git a/src/sagas/featureToggle.js b/src/sagas/featureToggle.js index ed11c8c7..5c4ce27f 100644 --- a/src/sagas/featureToggle.js +++ b/src/sagas/featureToggle.js @@ -250,7 +250,14 @@ export function mapFeatureToggles(toggles) { }, {}); } -export function* handleToggleUpdate() { +/** + * Re-hydrate state.featureToggles from the Unleash singleton after clean_data + * wipes it, so a post-restart checkForFeatureFlag read isn't stale. + * + * Doesn't dispatch FEATURE_TOGGLE_UPDATED — it can reload the wallet, unsafe + * from inside startWallet. + */ +export function* syncFeatureTogglesFromClient() { const unleashClient = getUnleashClient(); const featureTogglesInitialized = yield select((state) => state.featureTogglesInitialized); @@ -258,10 +265,12 @@ export function* handleToggleUpdate() { return; } - const { toggles } = unleashClient; - const featureToggles = mapFeatureToggles(toggles); - + const featureToggles = mapFeatureToggles(unleashClient.toggles); yield put(setFeatureToggles(featureToggles)); +} + +export function* handleToggleUpdate() { + yield call(syncFeatureTogglesFromClient); yield put({ type: 'FEATURE_TOGGLE_UPDATED' }); } diff --git a/src/sagas/wallet.js b/src/sagas/wallet.js index 54c2f1e6..2ce668e3 100644 --- a/src/sagas/wallet.js +++ b/src/sagas/wallet.js @@ -75,7 +75,7 @@ import { dispatchLedgerTokenSignatureVerification, } from './helpers'; import { fetchTokenData, restoreTokensForNetwork } from './tokens'; -import { updateUnleashClientContext } from './featureToggle'; +import { updateUnleashClientContext, syncFeatureTogglesFromClient } from './featureToggle'; import walletUtils from '../utils/wallet'; import tokensUtils from '../utils/tokens'; import nanoUtils from '../utils/nanoContracts'; @@ -159,6 +159,11 @@ export function* startWallet(action) { yield put(loadingAddresses(true)); + // Refresh the mirror before the checkForFeatureFlag reads below: the passphrase + // flow reaches startWallet right after clean_data wiped it, which would make the + // single-address read fall back to multi. + yield call(syncFeatureTogglesFromClient); + if (hardware) { // We need to ensure that the hardware wallet storage is always generated here since we may be // starting the wallet with a second device and so we cannot trust the xpub saved on storage. diff --git a/src/screens/SendTokens.js b/src/screens/SendTokens.js index df6c44a1..31bb78ec 100644 --- a/src/screens/SendTokens.js +++ b/src/screens/SendTokens.js @@ -522,7 +522,7 @@ function SendTokens() { decimalPlaces, onClose: () => { globalModalContext.hideModal(); - resetForm(); + navigate('/wallet/'); }, onViewDetails: () => { console.log('View tx details:', tx.hash, tx); @@ -596,20 +596,6 @@ function SendTokens() { }); }; - /** - * Reset form to initial state after successful send - */ - const resetForm = () => { - setTxTokens([...getSelectedToken()]); - setDataOutputs([]); - setTokenFees({}); - setTokenChangeOutputs({}); - setFeeError(''); - setErrorMessage(''); - references.current = [React.createRef()]; - dataOutputRefs.current = {}; - }; - /** * Group outputs by token for display in the transaction overview modal. * Returns an array of { token, outputs, total } where total only includes