diff --git a/CHANGELOG.md b/CHANGELOG.md index 55bfb31f649..f27e96bf75a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 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) - 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/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]) 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() + }) +}