diff --git a/src/containers/transferContainer.ts b/src/containers/transferContainer.ts index eceb28d6fb..29fbeaa9d1 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,28 @@ 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. + // 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; 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..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,12 +20,42 @@ 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'; +// 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,12 +70,10 @@ export const fetchTokenBalances = (account: string): Promise => id: EngineIds.ONE, }; - return ecencyApi - .post(PATH_ENGINE_CONTRACTS, data) - .then((r) => r.data.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 => { @@ -65,86 +90,10 @@ export const fetchTokens = (tokens: string[]): Promise => { id: EngineIds.ONE, }; - return ecencyApi - .post(PATH_ENGINE_CONTRACTS, data) - .then((r) => r.data.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/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..a292f8dfdc 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, }); @@ -755,9 +756,13 @@ 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. `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 006c061770..a596d0e1e9 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,29 @@ const WalletScreen = ({ navigation }: { navigation: any }) => { /> ); + // 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; + } + return ( + + + {intl.formatMessage({ id: 'alert.wallet_refresh_failed' })} + + + + {intl.formatMessage({ id: 'alert.something_wrong_reload' })} + + + + ); + }; + const _renderWalletHeader = () => ( { {() => ( + {_renderErrorBanner()} { }); }); + // 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,