From 0a089ca6325370227df86f55da1ca275f0d44d55 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 17 Jul 2026 17:24:12 -0700 Subject: [PATCH 1/3] 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/3] 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]) From 5d1a87985195c1dde07f764a74f2fc2b09c418d3 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Thu, 23 Jul 2026 03:04:42 -0700 Subject: [PATCH 3/3] Show a provisional receive address on warm login The receive scene waited on the wallet engine for every address, so a warm login showed a spinner until the engine loaded. It now opts into the core's cached address (getAddresses allowCached) and renders it at once. On a rotating-address chain the cached answer is provisional: a muted inline row with an info glyph, 'Checking for your latest address', and a spinner sits below the address, the QR is capped to reserve that row's height so it never reflows, and the confirmed address swaps in only if it differs once the engine loads. Stable chains show the cached address full-size with no row. --- CHANGELOG.md | 1 + src/__tests__/scenes/RequestScene.test.tsx | 62 ++++- .../__snapshots__/RequestScene.test.tsx.snap | 10 + src/components/scenes/RequestScene.tsx | 225 +++++++++++++++++- src/locales/en_US.ts | 1 + src/locales/strings/enUS.json | 1 + 6 files changed, 288 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f27e96bf75a..923ffe51eec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 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. 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`] = ` + 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/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",