Skip to content
Merged
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
23 changes: 20 additions & 3 deletions src/containers/transferContainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
135 changes: 42 additions & 93 deletions src/providers/hive-engine/hiveEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -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 <T>(data: EngineRequestPayload): Promise<T | undefined> => {
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;
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export const fetchTokenBalances = (account: string): Promise<TokenBalance[]> => {
const data: EngineRequestPayload = {
jsonrpc: JSON_RPC.RPC_2,
Expand All @@ -43,12 +70,10 @@ export const fetchTokenBalances = (account: string): Promise<TokenBalance[]> =>
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<TokenBalance[]>(data).then((result) => result ?? []);
};

export const fetchTokens = (tokens: string[]): Promise<Token[]> => {
Expand All @@ -65,86 +90,10 @@ export const fetchTokens = (tokens: string[]): Promise<Token[]> => {
id: EngineIds.ONE,
};

return ecencyApi
.post(PATH_ENGINE_CONTRACTS, data)
.then((r) => r.data.result)
.catch(() => {
return [];
});
};

export const fetchHiveEngineTokenBalances = async (
account: string,
): Promise<Array<HiveEngineToken | null>> => {
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<TokenStatus[]> => {
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<Token[]>(data).then((result) => result ?? []);
};

export const fetchEngineMarketData = async (
Expand Down
9 changes: 7 additions & 2 deletions src/providers/hive-engine/hiveEngineActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,18 @@ export const getEngineActionJSON = (
amount: string,
symbol: string,
memo?: string,
precision?: number,
): EngineActionJSON => {
return {
contractName: EngineContracts.TOKENS,
contractAction: action,
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,
},
};
Expand All @@ -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',
Expand Down
4 changes: 4 additions & 0 deletions src/providers/queries/walletQueries/walletQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
11 changes: 8 additions & 3 deletions src/screens/transfer/screen/transferScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,7 @@ const TransferView = ({
fundType,
memo,
tokenLayer,
precision: tokenPrecision,
recurrence: isRecurrentTransfer ? +recurrence : null,
executions: isRecurrentTransfer ? +executions : null,
});
Expand Down Expand Up @@ -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 ||
Expand Down
26 changes: 25 additions & 1 deletion src/screens/wallet/screen/walletScreen.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 (
<View style={styles.errorBanner}>
<Text style={styles.errorText}>
{intl.formatMessage({ id: 'alert.wallet_refresh_failed' })}
</Text>
<TouchableOpacity style={styles.headerActionButton} onPress={_onRefresh}>
<Text style={styles.headerActionButtonText}>
{intl.formatMessage({ id: 'alert.something_wrong_reload' })}
</Text>
Comment on lines +257 to +259

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use an existing locale id for the retry button label.

On Line 258, intl.formatMessage({ id: 'alert.try_again' }) does not match the provided
locale structure (which includes alert.wallet_refresh_failed and a separate try_again key).
This can render fallback text instead of the translated label.

Suggested fix
-            {intl.formatMessage({ id: 'alert.try_again' })}
+            {intl.formatMessage({ id: 'try_again' })}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<Text style={styles.headerActionButtonText}>
{intl.formatMessage({ id: 'alert.try_again' })}
</Text>
<Text style={styles.headerActionButtonText}>
{intl.formatMessage({ id: 'try_again' })}
</Text>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/screens/wallet/screen/walletScreen.tsx` around lines 257 - 259, The retry
button is using a non-existent locale id 'alert.try_again'; update the intl call
to use the existing key by replacing intl.formatMessage({ id: 'alert.try_again'
}) with intl.formatMessage({ id: 'try_again' }) in the Wallet screen where the
retry Text (styled by styles.headerActionButtonText) is rendered.

</TouchableOpacity>
</View>
);
};

const _renderWalletHeader = () => (
<WalletHeader
assets={walletQuery.data}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Expand All @@ -256,6 +279,7 @@ const WalletScreen = ({ navigation }: { navigation: any }) => {
<LoggedInContainer>
{() => (
<View style={styles.listWrapper}>
{_renderErrorBanner()}
<FlatList
data={walletListData}
style={globalStyles.tabBarBottom}
Expand Down
17 changes: 17 additions & 0 deletions src/screens/wallet/screen/walletScreenStyles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,21 @@ export default EStyleSheet.create({
flex: 1,
paddingTop: 8,
},
errorBanner: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginHorizontal: 16,
marginBottom: 8,
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 12,
backgroundColor: '$primaryLightBackground',
},
errorText: {
color: '$primaryDarkText',
fontSize: 13,
flex: 1,
marginRight: 12,
},
});
39 changes: 39 additions & 0 deletions src/utils/transactionOpsBuilder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading