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() + }) +}