From d32fdbd56f6134bf02583853f556d571ffbe3418 Mon Sep 17 00:00:00 2001 From: feruz Date: Sat, 13 Jun 2026 09:29:15 +0300 Subject: [PATCH 1/3] fix(wallet): unblock Hive Engine token transfers and harden HE reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hive Engine token transfers (e.g. ARCHON) could not proceed — the NEXT button stayed disabled for every HE token. Token precision was read from the Engine balances table, which has no precision field (it lives on the tokens table), so tokenPrecision was always undefined and the transfer's precision gate never passed. - Source Engine precision from the tokens table (fetchTokens) instead of the balances row; fetch both in parallel. Preserve precision 0 (integer tokens). - Thread token precision through the HiveAuth op-builder path (buildTransferOpsArray -> getEngineActionOpArray -> getEngineActionJSON) so the broadcast quantity is truncated to the token's real precision on every auth type, not the 8-decimal fallback (over-precise quantities are rejected by the Engine sidechain). - Add a request timeout + bounded retry-with-backoff to the HE proxy reads, which previously had neither, so a transient proxy blip no longer surfaces as an empty wallet or a stuck transfer. - Surface wallet-load failures with a Retry instead of a blank list, and cap the portfolio query retry backoff. - Cover the engine precision threading with unit tests. --- src/containers/transferContainer.ts | 17 ++++++-- src/providers/hive-engine/hiveEngine.ts | 43 ++++++++++++++++--- .../hive-engine/hiveEngineActions.ts | 9 +++- .../queries/walletQueries/walletQueries.ts | 4 ++ .../transfer/screen/transferScreen.tsx | 1 + src/screens/wallet/screen/walletScreen.tsx | 29 ++++++++++++- .../wallet/screen/walletScreenStyles.ts | 12 ++++++ src/utils/transactionOpsBuilder.test.ts | 39 +++++++++++++++++ src/utils/transactionOpsBuilder.ts | 28 ++++++++++-- 9 files changed, 165 insertions(+), 17 deletions(-) diff --git a/src/containers/transferContainer.ts b/src/containers/transferContainer.ts index eceb28d6fb..7efc99b4ec 100644 --- a/src/containers/transferContainer.ts +++ b/src/containers/transferContainer.ts @@ -21,7 +21,7 @@ import { getPointsSummary } from '../providers/ecency/ePoint'; // Utils import { getAssetPrecision, toFixedNoExp, formatTokenQuantity } from '../utils/number'; -import { fetchTokenBalances } from '../providers/hive-engine/hiveEngine'; +import { fetchTokenBalances, fetchTokens } from '../providers/hive-engine/hiveEngine'; import TransferTypes from '../constants/transferTypes'; import { fetchSpkMarkets } from '../providers/hive-spk/hiveSpk'; import TokenLayers from '../constants/tokenLayers'; @@ -110,11 +110,22 @@ class TransferContainer extends Component { const assetLayer = this.props.route.params?.assetLayer ?? this.props.route.params?.tokenLayer; if (assetLayer === TokenLayers.ENGINE) { - const tokenBalances = await fetchTokenBalances(username); + // Engine precision lives on the TOKENS table, not the balances row — the + // balances table carries no `precision` field, so reading it from a balance + // returns undefined for every token. That leaves tokenPrecision undefined, + // which permanently disables the NEXT button and risks broadcasting an + // over-precise (sidechain-rejected) quantity. Fetch both and source + // precision from the token definition. Precision can legitimately be 0 + // (integer tokens), so keep it as-is rather than defaulting a falsy 0 away. + const [tokenBalances, tokens] = await Promise.all([ + fetchTokenBalances(username), + fetchTokens([fundType]), + ]); + + enginePrecision = tokens.find((t) => t.symbol === fundType)?.precision; tokenBalances.forEach((tokenBalance) => { if (tokenBalance.symbol === fundType) { - enginePrecision = tokenBalance.precision; switch (transferType) { case TransferTypes.UNDELEGATE: balance = tokenBalance.delegationsOut; diff --git a/src/providers/hive-engine/hiveEngine.ts b/src/providers/hive-engine/hiveEngine.ts index f6746ee721..56357da1e3 100644 --- a/src/providers/hive-engine/hiveEngine.ts +++ b/src/providers/hive-engine/hiveEngine.ts @@ -29,6 +29,39 @@ const PATH_ENGINE_REWARDS = '/private-api/engine-reward-api'; // proxied path for 'https://info-api.tribaldex.com/market/ohlcv'; const PATH_ENGINE_CHART = '/private-api/engine-chart-api'; +// All Hive-Engine reads ride a single Ecency proxy. Unlike hive-engine.com — which +// the web app queries directly and which fails over across nodes — this proxy has no +// built-in retry or request timeout, so a transient timeout/5xx surfaces as an empty +// wallet or a permanently-disabled transfer (NEXT stays greyed because token +// precision never loads). Bound each request with a timeout and retry it a couple of +// times with exponential backoff so a transient blip self-heals instead. +const ENGINE_TIMEOUT_MS = 15000; +const ENGINE_MAX_RETRIES = 2; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const postEngineContract = async (data: EngineRequestPayload): Promise => { + let lastError: unknown; + // Attempts are intentionally sequential — each retry waits for the previous one to + // fail before backing off, so the await-in-loop is by design here. + for (let attempt = 0; attempt <= ENGINE_MAX_RETRIES; attempt += 1) { + try { + // eslint-disable-next-line no-await-in-loop + const response = await ecencyApi.post(PATH_ENGINE_CONTRACTS, data, { + timeout: ENGINE_TIMEOUT_MS, + }); + return response.data.result as T; + } catch (err) { + lastError = err; + if (attempt < ENGINE_MAX_RETRIES) { + // eslint-disable-next-line no-await-in-loop + await sleep(500 * 2 ** attempt); + } + } + } + throw lastError; +}; + export const fetchTokenBalances = (account: string): Promise => { const data: EngineRequestPayload = { jsonrpc: JSON_RPC.RPC_2, @@ -43,9 +76,8 @@ export const fetchTokenBalances = (account: string): Promise => id: EngineIds.ONE, }; - return ecencyApi - .post(PATH_ENGINE_CONTRACTS, data) - .then((r) => r.data.result) + return postEngineContract(data) + .then((result) => result ?? []) .catch(() => { return []; }); @@ -65,9 +97,8 @@ export const fetchTokens = (tokens: string[]): Promise => { id: EngineIds.ONE, }; - return ecencyApi - .post(PATH_ENGINE_CONTRACTS, data) - .then((r) => r.data.result) + return postEngineContract(data) + .then((result) => result ?? []) .catch(() => { return []; }); diff --git a/src/providers/hive-engine/hiveEngineActions.ts b/src/providers/hive-engine/hiveEngineActions.ts index bdee2a4f62..86269a880a 100644 --- a/src/providers/hive-engine/hiveEngineActions.ts +++ b/src/providers/hive-engine/hiveEngineActions.ts @@ -9,6 +9,7 @@ export const getEngineActionJSON = ( amount: string, symbol: string, memo?: string, + precision?: number, ): EngineActionJSON => { return { contractName: EngineContracts.TOKENS, @@ -16,7 +17,10 @@ export const getEngineActionJSON = ( contractPayload: { symbol, to, - quantity: formatTokenQuantity(parseToken(amount)), + // Truncate to the token's on-chain precision; an over-precise quantity is + // silently rejected by the Engine sidechain. precision can be 0 (integer + // tokens), so pass it through as-is rather than defaulting a falsy 0 away. + quantity: formatTokenQuantity(parseToken(amount), precision), memo: action === EngineActions.TRANSFER ? memo : undefined, }, }; @@ -29,8 +33,9 @@ export const getEngineActionOpArray = ( amount: string, symbol: string, memo?: string, + precision?: number, ): Operation[] => { - const json = getEngineActionJSON(action, to, amount, symbol, memo); + const json = getEngineActionJSON(action, to, amount, symbol, memo, precision); const op = { id: 'ssc-mainnet-hive', diff --git a/src/providers/queries/walletQueries/walletQueries.ts b/src/providers/queries/walletQueries/walletQueries.ts index 5e8db7a1fc..847170ccd2 100644 --- a/src/providers/queries/walletQueries/walletQueries.ts +++ b/src/providers/queries/walletQueries/walletQueries.ts @@ -99,6 +99,10 @@ export const useAssetsQuery = ({ onlyEnabled = true }: { onlyEnabled?: boolean } staleTime: 30 * 1000, enabled: !!currentAccount?.name, // Only fetch when logged in retry: 2, + // Cap the backoff so a flaky proxy can't leave the wallet on the skeleton for + // the default ~exponential delay (which climbs toward 30s) before surfacing the + // error/retry state. + retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 4000), }); const selectedData = useMemo(() => { diff --git a/src/screens/transfer/screen/transferScreen.tsx b/src/screens/transfer/screen/transferScreen.tsx index c1bfd83d05..16cdb56964 100644 --- a/src/screens/transfer/screen/transferScreen.tsx +++ b/src/screens/transfer/screen/transferScreen.tsx @@ -481,6 +481,7 @@ const TransferView = ({ fundType, memo, tokenLayer, + precision: tokenPrecision, recurrence: isRecurrentTransfer ? +recurrence : null, executions: isRecurrentTransfer ? +executions : null, }); diff --git a/src/screens/wallet/screen/walletScreen.tsx b/src/screens/wallet/screen/walletScreen.tsx index 006c061770..62940e538c 100644 --- a/src/screens/wallet/screen/walletScreen.tsx +++ b/src/screens/wallet/screen/walletScreen.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, Fragment, useMemo } from 'react'; -import { View, AppState, AppStateStatus } from 'react-native'; +import { View, Text, TouchableOpacity, AppState, AppStateStatus } from 'react-native'; import { isArray } from 'lodash'; // Containers @@ -239,6 +239,31 @@ const WalletScreen = ({ navigation }: { navigation: any }) => { /> ); + // When the portfolio request fails (e.g. the Ecency proxy hiccups) the query + // throws and the list would otherwise render blank with no explanation. Show the + // failure with a Retry instead of an empty wallet. A genuine empty wallet (no + // error) still falls through to `null`. + const _renderEmptyComponent = () => { + if (walletQuery.isFetching) { + return ; + } + if (walletQuery.isError) { + return ( + + + {intl.formatMessage({ id: 'alert.wallet_refresh_failed' })} + + + + {intl.formatMessage({ id: 'alert.try_again' })} + + + + ); + } + return null; + }; + const _renderWalletHeader = () => ( { : null} + ListEmptyComponent={_renderEmptyComponent} ListHeaderComponent={_renderWalletHeader} renderItem={_renderItem} keyExtractor={(item, index) => item.symbol + index} diff --git a/src/screens/wallet/screen/walletScreenStyles.ts b/src/screens/wallet/screen/walletScreenStyles.ts index 601c72df40..755679ab0c 100644 --- a/src/screens/wallet/screen/walletScreenStyles.ts +++ b/src/screens/wallet/screen/walletScreenStyles.ts @@ -40,4 +40,16 @@ export default EStyleSheet.create({ flex: 1, paddingTop: 8, }, + errorWrapper: { + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 32, + paddingVertical: 48, + }, + errorText: { + color: '$primaryDarkText', + fontSize: 14, + textAlign: 'center', + marginBottom: 16, + }, }); diff --git a/src/utils/transactionOpsBuilder.test.ts b/src/utils/transactionOpsBuilder.test.ts index 21da1bdf04..7657ab59b5 100644 --- a/src/utils/transactionOpsBuilder.test.ts +++ b/src/utils/transactionOpsBuilder.test.ts @@ -56,6 +56,45 @@ describe('buildTransferOpsArray', () => { }); }); + // mock shape: ['engine_op', action, from, to, amount, symbol, memo, precision] + describe('engine token precision', () => { + const engineBase = { + from: 'alice', + to: 'bob', + fundType: 'ARCHON', + tokenLayer: TokenLayers.ENGINE, + }; + + it('truncates an over-precise engine amount to the token precision and threads precision through', () => { + const ops = buildTransferOpsArray(TransferTypes.TRANSFER, { + ...engineBase, + amount: '10.123456', + precision: 3, + }); + expect(ops[0][4]).toBe('10.123 ARCHON'); + expect(ops[0][7]).toBe(3); + }); + + it('supports integer (precision 0) tokens without dropping the 0', () => { + const ops = buildTransferOpsArray(TransferTypes.TRANSFER, { + ...engineBase, + amount: '10.9', + precision: 0, + }); + expect(ops[0][4]).toBe('10 ARCHON'); + expect(ops[0][7]).toBe(0); + }); + + it('falls back to 8-decimal formatting when precision is unknown', () => { + const ops = buildTransferOpsArray(TransferTypes.TRANSFER, { + ...engineBase, + amount: '10.123456789', + }); + expect(ops[0][4]).toBe('10.12345678 ARCHON'); + expect(ops[0][7]).toBeUndefined(); + }); + }); + describe('TRANSFER', () => { it('builds single transfer op', () => { const ops = buildTransferOpsArray(TransferTypes.TRANSFER, baseData); diff --git a/src/utils/transactionOpsBuilder.ts b/src/utils/transactionOpsBuilder.ts index c5479b1d6d..a9790e4d4e 100644 --- a/src/utils/transactionOpsBuilder.ts +++ b/src/utils/transactionOpsBuilder.ts @@ -18,18 +18,22 @@ interface TansferData { tokenLayer?: string; recurrence?: number; executions?: number; + // Hive-Engine token precision. Required for ENGINE transfers so the quantity is + // truncated to the token's real precision instead of the 8-decimal fallback — + // an over-precise quantity is silently rejected by the Engine sidechain. + precision?: number; } export const buildTransferOpsArray = ( transferType: string, - { from, to, amount, memo, fundType, recurrence, executions, tokenLayer }: TansferData, + { from, to, amount, memo, fundType, recurrence, executions, tokenLayer, precision }: TansferData, ) => { // Normalize to the asset's on-chain precision before building the op: HIVE/HBD/ // POINTS need exactly 3 decimals, VESTS 6; Hive-Engine tokens use their own // precision (no scientific notation). Over-precise amounts are otherwise rejected. const amountValue = tokenLayer === TokenLayers.ENGINE - ? formatTokenQuantity(amount) + ? formatTokenQuantity(amount, precision) : toFixedNoExp(amount, getAssetPrecision(fundType)); amount = `${amountValue} ${fundType}`; @@ -48,10 +52,26 @@ export const buildTransferOpsArray = ( throw new Error(`Too many recipients (${destinations.length}), max is ${MAX_RECIPIENTS}`); } return destinations.flatMap((dest) => - getEngineActionOpArray(EngineActions.TRANSFER, from, dest, amount, fundType, memo), + getEngineActionOpArray( + EngineActions.TRANSFER, + from, + dest, + amount, + fundType, + memo, + precision, + ), ); } - return getEngineActionOpArray(transferType as EngineActions, from, to, amount, fundType, memo); + return getEngineActionOpArray( + transferType as EngineActions, + from, + to, + amount, + fundType, + memo, + precision, + ); } else if (tokenLayer === TokenLayers.SPK) { return buildActiveCustomJsonOpArr( from, From 3f24933b043b6fd0d3df9822a68cca690bb822f0 Mon Sep 17 00:00:00 2001 From: feruz Date: Sat, 13 Jun 2026 10:03:29 +0300 Subject: [PATCH 2/3] fix(wallet): address review feedback on HE transfer/wallet hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - transferScreen: allow whole-number Engine transfers through when token precision is unknown (precision-safe), so a degraded token-metadata lookup no longer dead-buttons NEXT; fractional amounts still wait for precision. - hiveEngine: stop swallowing engine read failures into [] — fetchTokenBalances/ fetchTokens now reject on a real transport failure (after retries) and resolve [] only for a genuine empty result, so the wallet query and assetsSelect can retry/surface errors instead of showing an outage as an empty wallet. - transferContainer: fetch balances and token metadata with allSettled so one leg failing no longer discards the other. - walletScreen: show the load-failure Retry as a banner above the list so a failed refresh of an already-loaded (stale) wallet is also surfaced, not just the first-load empty case. - hiveEngine: remove dead fetchHiveEngineTokenBalances/fetchMetics/ fetchUnclaimedRewards (no callers) and now-unused imports. --- src/containers/transferContainer.ts | 8 +- src/providers/hive-engine/hiveEngine.ts | 100 ++---------------- .../transfer/screen/transferScreen.tsx | 9 +- src/screens/wallet/screen/walletScreen.tsx | 43 ++++---- .../wallet/screen/walletScreenStyles.ts | 19 ++-- 5 files changed, 55 insertions(+), 124 deletions(-) diff --git a/src/containers/transferContainer.ts b/src/containers/transferContainer.ts index 7efc99b4ec..29fbeaa9d1 100644 --- a/src/containers/transferContainer.ts +++ b/src/containers/transferContainer.ts @@ -117,10 +117,16 @@ class TransferContainer extends Component { // over-precise (sidechain-rejected) quantity. Fetch both and source // precision from the token definition. Precision can legitimately be 0 // (integer tokens), so keep it as-is rather than defaulting a falsy 0 away. - const [tokenBalances, tokens] = await Promise.all([ + // allSettled so a failure in one leg doesn't discard the other: a token- + // metadata (precision) outage shouldn't hide an already-fetched balance, and + // a balance outage shouldn't hide precision. Each read rejects (rather than + // resolving []) on a real proxy failure, so a rejected leg is left unset. + const [balancesResult, tokensResult] = await Promise.allSettled([ fetchTokenBalances(username), fetchTokens([fundType]), ]); + const tokenBalances = balancesResult.status === 'fulfilled' ? balancesResult.value : []; + const tokens = tokensResult.status === 'fulfilled' ? tokensResult.value : []; enginePrecision = tokens.find((t) => t.symbol === fundType)?.precision; diff --git a/src/providers/hive-engine/hiveEngine.ts b/src/providers/hive-engine/hiveEngine.ts index 56357da1e3..3e17b05336 100644 --- a/src/providers/hive-engine/hiveEngine.ts +++ b/src/providers/hive-engine/hiveEngine.ts @@ -8,12 +8,9 @@ import { EngineRequestPayload, Token, TokenBalance, - TokenStatus, - HiveEngineToken, - EngineMetric, MarketData, } from './hiveEngine.types'; -import { convertEngineToken, convertRewardsStatus, convertMarketData } from './converters'; +import { convertMarketData } from './converters'; import ecencyApi from '../../config/ecencyApi'; /** @@ -23,9 +20,6 @@ import ecencyApi from '../../config/ecencyApi'; */ const PATH_ENGINE_CONTRACTS = '/private-api/engine-api'; -// proxied path for 'https://scot-api.hive-engine.com/'; -const PATH_ENGINE_REWARDS = '/private-api/engine-reward-api'; - // proxied path for 'https://info-api.tribaldex.com/market/ohlcv'; const PATH_ENGINE_CHART = '/private-api/engine-chart-api'; @@ -76,11 +70,10 @@ export const fetchTokenBalances = (account: string): Promise => id: EngineIds.ONE, }; - return postEngineContract(data) - .then((result) => result ?? []) - .catch(() => { - return []; - }); + // Resolve [] only for a genuine empty result; a transport failure (after retries) + // rejects so callers — the wallet list query and the transfer balance fetch — can + // surface an error/retry instead of mistaking a proxy outage for an empty wallet. + return postEngineContract(data).then((result) => result ?? []); }; export const fetchTokens = (tokens: string[]): Promise => { @@ -97,85 +90,10 @@ export const fetchTokens = (tokens: string[]): Promise => { id: EngineIds.ONE, }; - return postEngineContract(data) - .then((result) => result ?? []) - .catch(() => { - return []; - }); -}; - -export const fetchHiveEngineTokenBalances = async ( - account: string, -): Promise> => { - try { - const balances = await fetchTokenBalances(account); - const symbols = balances.map((t) => t.symbol); - - const tokens = await fetchTokens(symbols); - const metrices = await fetchMetics(symbols); - const unclaimed = await fetchUnclaimedRewards(account); - - return balances.map((balance) => { - const token = tokens.find((t) => t.symbol == balance.symbol); - const metrics = metrices.find((t) => t.symbol == balance.symbol); - const pendingRewards = unclaimed.find((t) => t.symbol == balance.symbol); - return convertEngineToken(balance, token, metrics, pendingRewards); - }); - } catch (err) { - console.warn('Failed to get engine token balances', err); - Sentry.captureException(err); - throw err; - } -}; - -export const fetchMetics = async (tokens?: string[]) => { - try { - const data = { - jsonrpc: JSON_RPC.RPC_2, - method: Methods.FIND, - params: { - contract: EngineContracts.MARKET, - table: EngineTables.METRICS, - query: { - symbol: { $in: tokens }, - }, - }, - id: EngineIds.ONE, - }; - - const response = await ecencyApi.post(PATH_ENGINE_CONTRACTS, data); - if (!response.data.result) { - throw new Error('No metric data returned'); - } - - return response.data.result as EngineMetric[]; - } catch (err) { - console.warn('Failed to get engine metrices', err); - Sentry.captureException(err); - throw err; - } -}; - -export const fetchUnclaimedRewards = async (account: string): Promise => { - try { - const response = await ecencyApi.get(`${PATH_ENGINE_REWARDS}/${account}`, { - params: { hive: 1 }, - }); - const rawData = Object.values(response.data); - if (!rawData || rawData.length === 0) { - throw new Error('No rewards data returned'); - } - - const data = rawData.map(convertRewardsStatus); - const filteredData = data.filter((item) => item && item.pendingToken > 0); - - console.log('unclaimed engine rewards data', filteredData); - return filteredData; - } catch (err) { - console.warn('failed ot get unclaimed engine rewards', err); - Sentry.captureException(err); - return []; - } + // Resolve [] only for a genuine empty result; a transport failure (after retries) + // rejects so the transfer precision lookup can fall back / retry instead of + // silently leaving precision unset. + return postEngineContract(data).then((result) => result ?? []); }; export const fetchEngineMarketData = async ( diff --git a/src/screens/transfer/screen/transferScreen.tsx b/src/screens/transfer/screen/transferScreen.tsx index 16cdb56964..eac5ef115b 100644 --- a/src/screens/transfer/screen/transferScreen.tsx +++ b/src/screens/transfer/screen/transferScreen.tsx @@ -756,9 +756,12 @@ const TransferView = ({ isUsernameValid && // Don't allow submit until the real balance has loaded (it is '' while fetching). !isBalanceLoading && - // Wait for the Engine token's precision to load so the amount can't be - // broadcast with the fallback 8-decimal precision before it is known. - (!isEngineToken || tokenPrecision !== undefined) && + // For Engine tokens, wait for precision before broadcasting a fractional amount + // so it can't go out with the 8-decimal fallback (which an over-precise sidechain + // quantity is rejected for). A whole-number amount is precision-safe at any + // precision, so allow it through even if the token-metadata lookup degrades — + // that keeps the common case working instead of dead-buttoning NEXT. + (!isEngineToken || tokenPrecision !== undefined || Number.isInteger(amount)) && // A HIVE/HBD send to an exchange must carry a memo or the deposit is lost. !exchangeMemoRequired && (!isRecurrentTransfer || diff --git a/src/screens/wallet/screen/walletScreen.tsx b/src/screens/wallet/screen/walletScreen.tsx index 62940e538c..9df61e0ad1 100644 --- a/src/screens/wallet/screen/walletScreen.tsx +++ b/src/screens/wallet/screen/walletScreen.tsx @@ -239,29 +239,27 @@ const WalletScreen = ({ navigation }: { navigation: any }) => { /> ); - // When the portfolio request fails (e.g. the Ecency proxy hiccups) the query - // throws and the list would otherwise render blank with no explanation. Show the - // failure with a Retry instead of an empty wallet. A genuine empty wallet (no - // error) still falls through to `null`. - const _renderEmptyComponent = () => { - if (walletQuery.isFetching) { - return ; + // When the portfolio request fails (e.g. the Ecency proxy hiccups) surface a Retry + // banner. It is rendered above the list rather than as ListEmptyComponent because + // React Query keeps the last successful data on error — so a failed *refresh* of an + // already-loaded wallet has a non-empty list and would otherwise show no indication + // the data is stale. Shown only once a fetch has actually failed and settled. + const _renderErrorBanner = () => { + if (!walletQuery.isError || walletQuery.isFetching) { + return null; } - if (walletQuery.isError) { - return ( - - - {intl.formatMessage({ id: 'alert.wallet_refresh_failed' })} + return ( + + + {intl.formatMessage({ id: 'alert.wallet_refresh_failed' })} + + + + {intl.formatMessage({ id: 'alert.try_again' })} - - - {intl.formatMessage({ id: 'alert.try_again' })} - - - - ); - } - return null; + + + ); }; const _renderWalletHeader = () => ( @@ -281,10 +279,11 @@ const WalletScreen = ({ navigation }: { navigation: any }) => { {() => ( + {_renderErrorBanner()} : null} ListHeaderComponent={_renderWalletHeader} renderItem={_renderItem} keyExtractor={(item, index) => item.symbol + index} diff --git a/src/screens/wallet/screen/walletScreenStyles.ts b/src/screens/wallet/screen/walletScreenStyles.ts index 755679ab0c..7a1342b744 100644 --- a/src/screens/wallet/screen/walletScreenStyles.ts +++ b/src/screens/wallet/screen/walletScreenStyles.ts @@ -40,16 +40,21 @@ export default EStyleSheet.create({ flex: 1, paddingTop: 8, }, - errorWrapper: { + errorBanner: { + flexDirection: 'row', alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: 32, - paddingVertical: 48, + justifyContent: 'space-between', + marginHorizontal: 16, + marginBottom: 8, + paddingHorizontal: 16, + paddingVertical: 12, + borderRadius: 12, + backgroundColor: '$primaryLightBackground', }, errorText: { color: '$primaryDarkText', - fontSize: 14, - textAlign: 'center', - marginBottom: 16, + fontSize: 13, + flex: 1, + marginRight: 12, }, }); From 452774b2d1cfbaaf337d869d94fc38244c9c0637 Mon Sep 17 00:00:00 2001 From: feruz Date: Sat, 13 Jun 2026 10:33:06 +0300 Subject: [PATCH 3/3] fix(wallet): coerce amount for integer gate and fix retry i18n id - transferScreen: amount is string state, so Number.isInteger(amount) was always false and the whole-number degraded-mode escape never activated (engine integer transfers stayed blocked when precision was unknown). Coerce with Number(amount). - walletScreen: alert.try_again does not exist (try_again lives under another namespace); use the existing alert.something_wrong_reload so the retry label renders translated instead of a raw id. --- src/screens/transfer/screen/transferScreen.tsx | 5 +++-- src/screens/wallet/screen/walletScreen.tsx | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/screens/transfer/screen/transferScreen.tsx b/src/screens/transfer/screen/transferScreen.tsx index eac5ef115b..a292f8dfdc 100644 --- a/src/screens/transfer/screen/transferScreen.tsx +++ b/src/screens/transfer/screen/transferScreen.tsx @@ -760,8 +760,9 @@ const TransferView = ({ // so it can't go out with the 8-decimal fallback (which an over-precise sidechain // quantity is rejected for). A whole-number amount is precision-safe at any // precision, so allow it through even if the token-metadata lookup degrades — - // that keeps the common case working instead of dead-buttoning NEXT. - (!isEngineToken || tokenPrecision !== undefined || Number.isInteger(amount)) && + // that keeps the common case working instead of dead-buttoning NEXT. `amount` is + // string state, so coerce before the integer test (Number.isInteger never coerces). + (!isEngineToken || tokenPrecision !== undefined || Number.isInteger(Number(amount))) && // A HIVE/HBD send to an exchange must carry a memo or the deposit is lost. !exchangeMemoRequired && (!isRecurrentTransfer || diff --git a/src/screens/wallet/screen/walletScreen.tsx b/src/screens/wallet/screen/walletScreen.tsx index 9df61e0ad1..a596d0e1e9 100644 --- a/src/screens/wallet/screen/walletScreen.tsx +++ b/src/screens/wallet/screen/walletScreen.tsx @@ -255,7 +255,7 @@ const WalletScreen = ({ navigation }: { navigation: any }) => { - {intl.formatMessage({ id: 'alert.try_again' })} + {intl.formatMessage({ id: 'alert.something_wrong_reload' })}