diff --git a/CHANGELOG.md b/CHANGELOG.md
index 55bfb31f649..923ffe51eec 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@
## Unreleased (develop)
+- added: The receive scene shows a wallet's cached address immediately on a warm login. On rotating-address chains it marks the address provisional with an inline "checking for your latest address" row and swaps in the confirmed address once the engine loads.
+- 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/__tests__/scenes/RequestScene.test.tsx b/src/__tests__/scenes/RequestScene.test.tsx
index 9bc119979ab..575126327cd 100644
--- a/src/__tests__/scenes/RequestScene.test.tsx
+++ b/src/__tests__/scenes/RequestScene.test.tsx
@@ -1,9 +1,10 @@
import { describe, expect, it } from '@jest/globals'
-import { render } from '@testing-library/react-native'
+import { act, render } from '@testing-library/react-native'
import * as React from 'react'
import { RequestSceneComponent } from '../../components/scenes/RequestScene'
import { getTheme } from '../../components/services/ThemeContext'
+import { lstrings } from '../../locales/strings'
import { btcCurrencyInfo } from '../../util/fake/fakeBtcInfo'
import { makeFakeCurrencyConfig } from '../../util/fake/fakeCurrencyConfig'
import { FakeProviders } from '../../util/fake/FakeProviders'
@@ -97,4 +98,63 @@ describe('Request', () => {
expect(rendered.toJSON()).toMatchSnapshot()
rendered.unmount()
})
+
+ it('shows the provisional affordance on a rotating chain while the engine has not confirmed', async () => {
+ jest.useRealTimers()
+ // Rotating chain (btcCurrencyInfo has no hasStableAddresses). The
+ // allowCached query returns the cached address at once; the plain
+ // engine-confirmed query never resolves, so the affordance should
+ // appear after the grace window and stay.
+ const fakeWallet: any = {
+ id: 'w-rotating',
+ currencyInfo: btcCurrencyInfo,
+ currencyConfig: makeFakeCurrencyConfig(btcCurrencyInfo),
+ balanceMap: new Map([[null, '1234']]),
+ on: () => () => {},
+ getAddresses: async (opts: any) => {
+ if (opts.allowCached === true) {
+ return [{ addressType: 'publicAddress', publicAddress: 'cachedaddr' }]
+ }
+ return await new Promise(() => {})
+ },
+ encodeUri: async () => 'bitcoin:cachedaddr'
+ }
+
+ const rendered = render(
+
+ {}}
+ onSelectWallet={async (walletId, currencyCode) => {}}
+ toggleAccountBalanceVisibility={async () => {}}
+ showBalance
+ />
+
+ )
+
+ // Flush getAddressItems' awaited provisional query so the cached
+ // address is on screen, then let the grace window elapse (the
+ // engine-confirmed query never resolves), all inside act() so the
+ // timer-driven setState is applied:
+ await act(async () => {
+ await Promise.resolve()
+ await new Promise(resolve => setTimeout(resolve, 600))
+ })
+
+ expect(
+ rendered.getByText(lstrings.request_provisional_address)
+ ).toBeTruthy()
+ rendered.unmount()
+ })
})
diff --git a/src/__tests__/scenes/__snapshots__/RequestScene.test.tsx.snap b/src/__tests__/scenes/__snapshots__/RequestScene.test.tsx.snap
index 4ff78549a01..7f9296bfb47 100644
--- a/src/__tests__/scenes/__snapshots__/RequestScene.test.tsx.snap
+++ b/src/__tests__/scenes/__snapshots__/RequestScene.test.tsx.snap
@@ -2082,6 +2082,16 @@ exports[`Request should render with loaded props 1`] = `
+
(
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/scenes/RequestScene.tsx b/src/components/scenes/RequestScene.tsx
index d09bb24a1e3..359286d81e5 100644
--- a/src/components/scenes/RequestScene.tsx
+++ b/src/components/scenes/RequestScene.tsx
@@ -2,6 +2,7 @@ import Clipboard from '@react-native-clipboard/clipboard'
import { lt } from 'biggystring'
import type {
EdgeAccount,
+ EdgeAddress,
EdgeCurrencyWallet,
EdgeDenomination,
EdgeEncodeUri,
@@ -59,7 +60,11 @@ import {
import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity'
import { SceneWrapper } from '../common/SceneWrapper'
import { withWallet } from '../hoc/withWallet'
-import { ChatBubblesIcon, ChevronRightIcon } from '../icons/ThemedIcons'
+import {
+ ChatBubblesIcon,
+ ChevronRightIcon,
+ InformationCircleIcon
+} from '../icons/ThemedIcons'
import { AddressModal } from '../modals/AddressModal'
import { ButtonsModal } from '../modals/ButtonsModal'
import {
@@ -128,8 +133,23 @@ interface State {
minimumPopupModalState: CurrencyMinimumPopupState
isFioMode: boolean
errorMessage?: string
+
+ // True while a rotating-address chain is showing its cached address
+ // provisionally, before the engine has confirmed the fresh one:
+ isProvisional: boolean
}
+// Only show the "checking" affordance if the engine has not confirmed
+// within this grace window, so a warm login whose engine answers right
+// away never flashes the row:
+const PROVISIONAL_GRACE_MS = 350
+
+// Stop the "confirming" spinner after this long even if the engine has
+// not answered, so the affordance never spins forever. The cached
+// address stays shown; the engine scheduler's own watchdog is 30s, so
+// a genuinely wedged engine has already given up its slot by now:
+const RECONCILE_TIMEOUT_MS = 30000
+
interface AddressInfo {
addressString: string
label: string
@@ -142,6 +162,18 @@ export class RequestSceneComponent extends React.Component<
flipInputRef: React.RefObject
unsubscribeAddressChanged: (() => void) | undefined
+ // Once the engine has returned a confirmed address, no later refresh
+ // shows the provisional affordance again:
+ engineConfirmed: boolean = false
+ reconcileTimeout: ReturnType | undefined
+ graceTimeout: ReturnType | undefined
+ mounted: boolean = false
+ // Bumped on every address refresh so a slow reconcile from a prior
+ // refresh (or a prior wallet, since withWallet reuses this instance
+ // across wallet switches) is ignored instead of overwriting the
+ // current wallet's address or poisoning `engineConfirmed`:
+ reconcileToken: number = 0
+
constructor(props: Props) {
super(props)
this.flipInputRef = React.createRef()
@@ -154,7 +186,8 @@ export class RequestSceneComponent extends React.Component<
this.state = {
addresses: [],
minimumPopupModalState,
- isFioMode: false
+ isFioMode: false,
+ isProvisional: false
}
if (this.shouldShowMinimumModal(props)) {
const { wallet } = props
@@ -168,6 +201,7 @@ export class RequestSceneComponent extends React.Component<
}
componentDidMount(): void {
+ this.mounted = true
this.getAddressItems().catch((err: unknown) => {
showError(err)
})
@@ -185,15 +219,16 @@ export class RequestSceneComponent extends React.Component<
}
componentWillUnmount(): void {
+ this.mounted = false
+ // Invalidate any in-flight reconcile so its late resolution is a
+ // no-op instead of a setState on an unmounted component:
+ this.reconcileToken += 1
if (this.unsubscribeAddressChanged != null) this.unsubscribeAddressChanged()
+ if (this.reconcileTimeout != null) clearTimeout(this.reconcileTimeout)
+ if (this.graceTimeout != null) clearTimeout(this.graceTimeout)
}
- async getAddressItems(): Promise {
- const { wallet, currencyCode } = this.props
- if (currencyCode == null) return
- if (wallet == null) return
- if (isKeysOnlyPlugin(wallet.currencyInfo.pluginId)) return
-
+ toAddressInfos(allAddresses: EdgeAddress[]): AddressInfo[] {
const addressTypeLabelMap: StringMap = {
segwitAddress: lstrings.request_qr_your_segwit_address,
legacyAddress: lstrings.request_qr_your_legacy_address,
@@ -204,11 +239,10 @@ export class RequestSceneComponent extends React.Component<
unifiedAddress: lstrings.request_qr_your_unified_address
}
- const allAddresses = await wallet.getAddresses({ tokenId: null })
const hasSegwitAddress = allAddresses.some(
address => address.addressType === 'segwitAddress'
)
- const addresses: AddressInfo[] = allAddresses.map(edgeAddress => {
+ return allAddresses.map(edgeAddress => {
let label: string = lstrings.request_qr_your_wallet_address
if (hasSegwitAddress && edgeAddress.addressType === 'publicAddress') {
@@ -222,8 +256,139 @@ export class RequestSceneComponent extends React.Component<
label
}
})
+ }
+
+ async getAddressItems(): Promise {
+ const { wallet, currencyCode } = this.props
+ if (currencyCode == null) return
+ if (wallet == null) return
+ if (isKeysOnlyPlugin(wallet.currencyInfo.pluginId)) return
+
+ // Each refresh gets a token, so a slow reconcile from an earlier
+ // refresh or a prior wallet is ignored when it finally resolves:
+ const token = ++this.reconcileToken
+ const walletId = wallet.id
+
+ // A rotating chain does not serve a cached address to programmatic
+ // callers, so the receive scene opts in with `allowCached` and shows
+ // the result provisionally until the engine confirms the fresh one.
+ // A stable-address chain already serves cached, and the confirmed
+ // address is identical, so it never shows the affordance:
+ const isRotating = wallet.currencyInfo.hasStableAddresses !== true
+ const showProvisional = isRotating && !this.engineConfirmed
+
+ if (!showProvisional) {
+ // No `allowCached` here: `allowCached` exists only to serve the
+ // first pre-engine provisional address on a rotating chain. Once
+ // the engine has confirmed (or on a stable chain, which serves
+ // cached via its own hint), the plain query returns the engine's
+ // current address, so a later refresh (e.g. an `addressChanged`
+ // rotation) never shows or shares a stale address.
+ const allAddresses = await wallet.getAddresses({ tokenId: null })
+ const addresses = this.toAddressInfos(allAddresses)
+ if (this.isStale(token, walletId)) return
+ this.setState({ addresses, selectedAddress: addresses[0] })
+ return
+ }
+
+ // Kick off the engine-confirmed query first (it blocks until the
+ // engine loads on a rotating chain), then serve the provisional
+ // answer right away. If the engine is already up the confirmed
+ // query wins the grace window and no affordance shows:
+ const confirmedPromise = wallet.getAddresses({ tokenId: null })
+ confirmedPromise.catch(() => undefined)
+
+ const provisionalAddresses = this.toAddressInfos(
+ await wallet.getAddresses({ tokenId: null, allowCached: true })
+ )
+ if (this.isStale(token, walletId)) return
+ // Show the address now; the affordance only appears if the engine
+ // stays quiet past the grace window (see reconcileAddress):
+ this.setState({
+ addresses: provisionalAddresses,
+ selectedAddress: provisionalAddresses[0],
+ isProvisional: false
+ })
+
+ this.reconcileAddress(confirmedPromise, token, walletId)
+ }
+
+ // True once a newer refresh has started, this wallet is no longer the
+ // one on screen, or the scene unmounted, so a stale async result must
+ // not touch state:
+ isStale(token: number, walletId: string): boolean {
+ const currentWallet = this.props.wallet
+ return (
+ !this.mounted ||
+ this.reconcileToken !== token ||
+ currentWallet == null ||
+ currentWallet.id !== walletId
+ )
+ }
+
+ reconcileAddress(
+ confirmedPromise: Promise,
+ token: number,
+ walletId: string
+ ): void {
+ let settled = false
+
+ // Private timers per cycle, so a later cycle clearing the instance
+ // fields cannot cancel this cycle's timers:
+ const graceTimer = setTimeout(() => {
+ if (settled || this.isStale(token, walletId)) return
+ this.setState({ isProvisional: true })
+ }, PROVISIONAL_GRACE_MS)
+ const safetyTimer = setTimeout(() => {
+ if (this.isStale(token, walletId)) return
+ this.setState({ isProvisional: false })
+ }, RECONCILE_TIMEOUT_MS)
+ this.graceTimeout = graceTimer
+ this.reconcileTimeout = safetyTimer
+
+ const stopTimers = (): void => {
+ settled = true
+ clearTimeout(graceTimer)
+ clearTimeout(safetyTimer)
+ }
- this.setState({ addresses, selectedAddress: addresses[0] })
+ confirmedPromise
+ .then(allAddresses => {
+ stopTimers()
+ if (this.isStale(token, walletId)) return
+ this.engineConfirmed = true
+ const confirmedAddresses = this.toAddressInfos(allAddresses)
+ this.setState(state => {
+ const unchanged =
+ confirmedAddresses.length === state.addresses.length &&
+ confirmedAddresses.every(
+ (address, index) =>
+ address.addressString === state.addresses[index]?.addressString
+ )
+ // Only swap the address and QR when the engine's answer
+ // differs from the cached one, so the common (identical)
+ // case just drops the affordance with no visual jump:
+ return unchanged
+ ? {
+ addresses: state.addresses,
+ selectedAddress: state.selectedAddress,
+ isProvisional: false
+ }
+ : {
+ addresses: confirmedAddresses,
+ selectedAddress: confirmedAddresses[0],
+ isProvisional: false
+ }
+ })
+ })
+ .catch(() => {
+ // The engine failed or the wallet went away; the wallet's own
+ // error surface reports it. Stop the spinner and keep showing
+ // the cached address:
+ stopTimers()
+ if (this.isStale(token, walletId)) return
+ this.setState({ isProvisional: false })
+ })
}
async getEncodedUri(): Promise {
@@ -252,6 +417,8 @@ export class RequestSceneComponent extends React.Component<
prevProps.wallet != null && wallet.id !== prevProps.wallet.id
if (didWalletChange) {
+ // A different wallet gets its own provisional/confirm cycle:
+ this.engineConfirmed = false
this.getAddressItems().catch((err: unknown) => {
showError(err)
})
@@ -473,6 +640,7 @@ export class RequestSceneComponent extends React.Component<
)
const keysOnlyMode = isKeysOnlyPlugin(wallet.currencyInfo.pluginId)
const addressExplorerDisabled = wallet.currencyInfo.addressExplorer === ''
+ const isRotating = wallet.currencyInfo.hasStableAddresses !== true
// Balance
const nativeBalance = getAvailableBalance(wallet, tokenId)
@@ -609,6 +777,27 @@ export class RequestSceneComponent extends React.Component<
+
+ {/* Rotating chains keep this fixed-height slot whether or not
+ the affordance shows, so the QR above never reflows: */}
+ {isRotating ? (
+
+ {this.state.isProvisional ? (
+ <>
+
+
+ {lstrings.request_provisional_address}
+
+
+ >
+ ) : null}
+
+ ) : null}
@@ -828,6 +1017,20 @@ const getStyles = cacheStyles((theme: Theme) => ({
publicAddressText: {
fontSize: theme.rem(0.75)
},
+ provisionalRow: {
+ height: theme.rem(1.75),
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginTop: theme.rem(0.5)
+ },
+ provisionalIcon: {
+ marginRight: theme.rem(0.5)
+ },
+ provisionalText: {
+ flex: 1,
+ fontSize: theme.rem(0.75),
+ color: theme.secondaryText
+ },
loader: {
flex: 1,
alignSelf: 'center'
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/locales/en_US.ts b/src/locales/en_US.ts
index a6006046045..813b85f7814 100644
--- a/src/locales/en_US.ts
+++ b/src/locales/en_US.ts
@@ -463,6 +463,7 @@ const strings = {
request_qr_email_title: 'Pay with %1$s:',
request_email_subject: '%1$s %2$s Request',
request_qr_your_wallet_address: 'Your Wallet Address',
+ request_provisional_address: 'Checking for your latest address',
request_qr_your_wrapped_segwit_address: 'Your Wrapped-Segwit Address',
request_qr_your_legacy_address: 'Your Legacy Address',
request_qr_your_segwit_address: 'Your Segwit Address',
diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json
index 39c1368be78..7debc51930b 100644
--- a/src/locales/strings/enUS.json
+++ b/src/locales/strings/enUS.json
@@ -324,6 +324,7 @@
"request_qr_email_title": "Pay with %1$s:",
"request_email_subject": "%1$s %2$s Request",
"request_qr_your_wallet_address": "Your Wallet Address",
+ "request_provisional_address": "Checking for your latest address",
"request_qr_your_wrapped_segwit_address": "Your Wrapped-Segwit Address",
"request_qr_your_legacy_address": "Your Legacy Address",
"request_qr_your_segwit_address": "Your Segwit Address",
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()
+ })
+}