From 0a089ca6325370227df86f55da1ca275f0d44d55 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 17 Jul 2026 17:24:12 -0700 Subject: [PATCH 1/2] Gate balance effects and FIO refresh on engine readiness With the core's wallet cache (wallet cache v2 phase 1), wallet objects exist before their engines load, and waitForAllWallets resolves in that window. Three login-path surfaces consumed engine state immediately: - The action queue's address-balance effect read balanceMap right after awaiting the wallet, which could evaluate a balance effect against cached, possibly stale balances. It now reports not-yet-effective until the engine has fully synced, matching the conservatism the loan flow already applies. - The FIO address refresh called otherMethods on pre-engine wallets, which is {} in that window. Services now waits for each FIO wallet's engine-backed otherMethods (bounded by a generous safety-valve timeout) before refreshing. - FioService's periodic expired-domain check called otherMethods.getFioAddresses the same way (caught live on the sim). It now skips pre-engine wallets and lets the next 30s cycle retry, which also avoids wedging its one-shot expiredChecking latch. --- CHANGELOG.md | 2 + eslint.config.mjs | 1 - src/components/services/FioService.ts | 73 ++++++++++++------- src/components/services/Services.tsx | 21 ++++++ .../action-queue/runtime/checkActionEffect.ts | 12 +++ src/util/waitForWalletOtherMethods.ts | 42 +++++++++++ 6 files changed, 122 insertions(+), 29 deletions(-) create mode 100644 src/util/waitForWalletOtherMethods.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 55bfb31f649..f7910bdb11c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased (develop) +- changed: Gate action-queue balance-effect checks and the login FIO address refresh on engine readiness, so wallets emitted from the core's new wallet cache (before their engines load) cannot mis-evaluate balance effects or crash the FIO refresh. + ## 4.50.0 (staging) - added: Changelly swap provider diff --git a/eslint.config.mjs b/eslint.config.mjs index 988cc271d09..32540e84769 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -330,7 +330,6 @@ export default [ 'src/components/services/DeepLinkingManager.tsx', 'src/components/services/EdgeContextCallbackManager.tsx', - 'src/components/services/FioService.ts', 'src/components/services/LoanManagerService.ts', 'src/components/services/NetworkActivity.ts', 'src/components/services/PasswordReminderService.ts', diff --git a/src/components/services/FioService.ts b/src/components/services/FioService.ts index 33043c899cb..cd919f0ea5c 100644 --- a/src/components/services/FioService.ts +++ b/src/components/services/FioService.ts @@ -28,7 +28,7 @@ interface Props { type NameDates = Record -export const FioService = (props: Props) => { +export const FioService: React.FC = props => { const { account, navigation } = props const dispatch = useDispatch() @@ -65,40 +65,54 @@ export const FioService = (props: Props) => { } if (expiredChecking.current) return - expiredChecking.current = true - const walletsToCheck: EdgeCurrencyWallet[] = [] - for (const fioWallet of fioWallets.current) { - if (!walletsCheckedForExpired.current[fioWallet.id]) { - walletsToCheck.push(fioWallet) + // Wallet objects can exist before their engines do (wallet cache), + // and refreshFioNames calls engine-backed otherMethods. + // Skip pre-engine wallets and let the next cycle retry them: + const readyWallets = fioWallets.current.filter( + fioWallet => fioWallet.otherMethods.getFioAddresses != null + ) + if (readyWallets.length === 0) return + + expiredChecking.current = true + try { + const walletsToCheck: EdgeCurrencyWallet[] = [] + for (const fioWallet of readyWallets) { + if (!walletsCheckedForExpired.current[fioWallet.id]) { + walletsToCheck.push(fioWallet) + } } - } - const namesToCheck: FioDomain[] = [] - const { fioDomains, fioWalletsById } = await refreshFioNames(walletsToCheck) - expiredLastChecks.current ??= await getFioExpiredCheckFromDisklet(disklet) - for (const fioDomain of fioDomains) { - if (needToCheckExpired(expiredLastChecks.current, fioDomain.name)) { - namesToCheck.push(fioDomain) + const namesToCheck: FioDomain[] = [] + const { fioDomains, fioWalletsById } = await refreshFioNames( + walletsToCheck + ) + expiredLastChecks.current ??= await getFioExpiredCheckFromDisklet(disklet) + for (const fioDomain of fioDomains) { + if (needToCheckExpired(expiredLastChecks.current, fioDomain.name)) { + namesToCheck.push(fioDomain) + } } - } - if (namesToCheck.length !== 0) { - const expired: FioDomain[] = getExpiredSoonFioDomains(fioDomains) - if (expired.length > 0) { - const first: FioDomain = expired[0] - const fioWallet: EdgeCurrencyWallet = fioWalletsById[first.walletId] - await showFioExpiredModal(navigation, fioWallet, first) - expireReminderShown.current = true + if (namesToCheck.length !== 0) { + const expired: FioDomain[] = getExpiredSoonFioDomains(fioDomains) + if (expired.length > 0) { + const first: FioDomain = expired[0] + const fioWallet: EdgeCurrencyWallet = fioWalletsById[first.walletId] + await showFioExpiredModal(navigation, fioWallet, first) + expireReminderShown.current = true - expiredLastChecks.current[first.name] = new Date() - await setFioExpiredCheckToDisklet(expiredLastChecks.current, disklet) - } + expiredLastChecks.current[first.name] = new Date() + await setFioExpiredCheckToDisklet(expiredLastChecks.current, disklet) + } - for (const walletId in fioWalletsById) { - walletsCheckedForExpired.current[walletId] = true + for (const walletId in fioWalletsById) { + walletsCheckedForExpired.current[walletId] = true + } } - + } finally { + // Always release the latch, or a cycle with nothing to check + // (or an error) would disable this check for the whole session: expiredChecking.current = false } }) @@ -130,7 +144,10 @@ export const FioService = (props: Props) => { return null } -function arraysEqual(arr1: EdgeCurrencyWallet[], arr2: EdgeCurrencyWallet[]) { +function arraysEqual( + arr1: EdgeCurrencyWallet[], + arr2: EdgeCurrencyWallet[] +): boolean { if (arr1.length !== arr2.length) return false arr1.sort((a, b) => a.id.localeCompare(b.id)) diff --git a/src/components/services/Services.tsx b/src/components/services/Services.tsx index be66975bbb5..641d04e1426 100644 --- a/src/components/services/Services.tsx +++ b/src/components/services/Services.tsx @@ -27,6 +27,7 @@ import { width } from '../../util/scaling' import { snooze } from '../../util/utils' +import { waitForWalletOtherMethods } from '../../util/waitForWalletOtherMethods' import { AlertDropdown } from '../navigation/AlertDropdown' import { AccountCallbackManager } from './AccountCallbackManager' import { ActionQueueService } from './ActionQueueService' @@ -88,6 +89,26 @@ export const Services: React.FC = props => { console.warn('registerNotificationsV2 error:', error) }) + // Wallet objects can exist before their engines do (wallet cache), + // and the FIO refreshes below call engine-backed otherMethods, + // so wait for each FIO wallet's engine first: + const fioWallets = Object.values(account.currencyWallets).filter( + wallet => wallet.currencyInfo.pluginId === 'fio' + ) + await Promise.all( + fioWallets.map(async wallet => { + // Per-wallet, so one broken wallet cannot reject the whole gate + // or hide which wallet timed out: + await waitForWalletOtherMethods(wallet).catch((error: unknown) => { + console.warn('waitForWalletOtherMethods error:', error) + }) + }) + ) + + // Bail out if the account logged out while we waited for engines, + // so the refreshes below never run against the next session: + if (!account.loggedIn) return + await dispatch(refreshConnectedWallets).catch((error: unknown) => { console.warn(error) }) diff --git a/src/controllers/action-queue/runtime/checkActionEffect.ts b/src/controllers/action-queue/runtime/checkActionEffect.ts index e72523b2e48..04c4b04bd34 100644 --- a/src/controllers/action-queue/runtime/checkActionEffect.ts +++ b/src/controllers/action-queue/runtime/checkActionEffect.ts @@ -1,5 +1,6 @@ import { gte, lte } from 'biggystring' +import { DONE_THRESHOLD } from '../../../constants/WalletAndCurrencyConstants' import { filterNull } from '../../../util/safeFilters' import { checkPushEvent } from '../push' import type { @@ -120,6 +121,17 @@ export async function checkActionEffect( // TODO: Use effect.address when we can check address balances const { aboveAmount, belowAmount, tokenId, walletId } = effect const wallet = await account.waitForCurrencyWallet(walletId) + + // The wallet object can exist before its engine loads (wallet cache), + // so don't evaluate the effect against cached, possibly stale + // balances. Report "not yet effective" until the engine has synced: + if (wallet.syncStatus.totalRatio < DONE_THRESHOLD) { + return { + delay: 15000, + isEffective: false + } + } + const walletBalance = wallet.balanceMap.get(tokenId) ?? '0' return { diff --git a/src/util/waitForWalletOtherMethods.ts b/src/util/waitForWalletOtherMethods.ts new file mode 100644 index 00000000000..f010c52cd48 --- /dev/null +++ b/src/util/waitForWalletOtherMethods.ts @@ -0,0 +1,42 @@ +import type { EdgeCurrencyWallet } from 'edge-core-js' + +// Engine creation for a large account can take minutes on login, +// matching how long waitForAllWallets used to take before the wallet +// cache existed, so this is a safety valve rather than a deadline: +const OTHER_METHODS_TIMEOUT_MS = 10 * 60 * 1000 + +/** + * Waits for a wallet's engine-backed `otherMethods` to arrive. + * + * The core's wallet cache emits wallet objects before their engines + * exist, and `wallet.otherMethods` is guaranteed to be `{}` until the + * engine loads. Rejects after a timeout so a wallet whose engine never + * loads cannot hang callers forever. + */ +export async function waitForWalletOtherMethods( + wallet: EdgeCurrencyWallet, + timeoutMs: number = OTHER_METHODS_TIMEOUT_MS +): Promise { + if (Object.keys(wallet.otherMethods).length > 0) return + + await new Promise((resolve, reject) => { + const handleReady = (): void => { + clearTimeout(timeout) + unsubscribe() + resolve() + } + const timeout = setTimeout(() => { + unsubscribe() + reject( + new Error(`Timed out waiting for wallet ${wallet.id} engine methods`) + ) + }, timeoutMs) + const unsubscribe = wallet.watch('otherMethods', otherMethods => { + if (Object.keys(otherMethods).length > 0) handleReady() + }) + + // The methods may have arrived between the caller's check and the + // subscription above, in which case the watcher never fires: + if (Object.keys(wallet.otherMethods).length > 0) handleReady() + }) +} From 99b80e3ae877ebb914a2cbe09d5a78b67d31a4e0 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Sun, 19 Jul 2026 01:49:22 -0700 Subject: [PATCH 2/2] Prioritize an opened wallet's engine startup The core's new post-login queue staggers cached wallets' engine startup. withWallet covers every wallet-scoped scene, so opening one calls waitForCurrencyWallet, which moves that wallet's engine to the front of the queue. --- CHANGELOG.md | 1 + src/components/hoc/withWallet.tsx | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7910bdb11c..f27e96bf75a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased (develop) - changed: Gate action-queue balance-effect checks and the login FIO address refresh on engine readiness, so wallets emitted from the core's new wallet cache (before their engines load) cannot mis-evaluate balance effects or crash the FIO refresh. +- changed: Opening any wallet-scoped scene asks the core to prioritize that wallet's engine startup in the post-login queue. ## 4.50.0 (staging) diff --git a/src/components/hoc/withWallet.tsx b/src/components/hoc/withWallet.tsx index 28e78c44801..31bcb46fc63 100644 --- a/src/components/hoc/withWallet.tsx +++ b/src/components/hoc/withWallet.tsx @@ -26,6 +26,16 @@ export function withWallet( const currencyWallets = useWatch(account, 'currencyWallets') const wallet = currencyWallets[route.params.walletId] + // Opening a wallet-scoped scene is the "the user wants this wallet" + // signal: waitForCurrencyWallet moves the wallet's engine startup to + // the front of the core's post-login queue. Rejections (a deleted or + // broken wallet) are already handled by the effect below: + const { walletId } = route.params + React.useEffect(() => { + if (account.waitForCurrencyWallet == null) return + account.waitForCurrencyWallet(walletId).catch(() => {}) + }, [account, walletId]) + React.useEffect(() => { if (wallet == null) navigation.goBack() }, [navigation, wallet])