Skip to content

Commit ed985f4

Browse files
committed
Add deterministic tests for the wallet UI-state cache
The fake currency plugin gains a test-controlled engine gate, so "before the engine exists" is a controlled state instead of a race, and the cache saver throttle drops to 50ms under test. The suite covers cold-start equivalence, cached emission, live-data overwrite, pending engine-gated calls (completion, engine failure, wallet deletion), renames inside the cache window, cancelled post-logout writes, corrupt cache files, saver behavior, and the otherMethods pre-engine guarantee.
1 parent 5105dcc commit ed985f4

3 files changed

Lines changed: 422 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
- added: Per-wallet `walletCache.json` with each wallet's name, fiat code, enabled token IDs, and last-known balances. Currency wallets now emit their API objects as soon as this cache loads, before their engines exist, so the GUI can render the wallet list immediately at login. Engine-backed methods wait for the engine internally and reject if it fails or the wallet is deleted. First login (no cache) behaves exactly as before.
6+
- changed: `waitForCurrencyWallet` and `waitForAllWallets` now resolve when the wallet object exists, which can be before its engine loads.
7+
- changed: `wallet.otherMethods` is `{}` until the wallet's engine loads, then switches to the engine's methods.
8+
59
## 2.47.1 (2026-07-17)
610

711
- fixed: Revert `@nymproject/mix-fetch` to v1 (1.4.4), restoring the pinned gateway and network requester. The v2 stack shipped in 2.47.0 fails to complete small HTTPS JSON-RPC requests through most exit nodes and its exit-node auto-discovery rarely converges, which left wallets with NYM privacy enabled unable to sync or send.
Lines changed: 370 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
1+
import { expect } from 'chai'
2+
import { afterEach, beforeEach, describe, it } from 'mocha'
3+
4+
import { walletCacheSaverConfig } from '../../../../src/core/currency/wallet/wallet-cache-file'
5+
import {
6+
EdgeContext,
7+
EdgeCurrencyWallet,
8+
makeFakeEdgeWorld
9+
} from '../../../../src/index'
10+
import { snooze } from '../../../../src/util/snooze'
11+
import { expectRejection } from '../../../expect-rejection'
12+
import {
13+
createEngineGate,
14+
fakePluginTestConfig
15+
} from '../../../fake/fake-currency-plugin'
16+
import { fakeUser } from '../../../fake/fake-user'
17+
18+
const contextOptions = { apiKey: '', appId: '', deviceDescription: 'iphone12' }
19+
const quiet = { onLog() {} }
20+
21+
// Generous wait for the throttled cache saver (50ms in tests) to write:
22+
const SAVE_WAIT_MS = 300
23+
24+
// Short wait to prove something has *not* happened:
25+
const RACE_WAIT_MS = 150
26+
27+
interface CachedWorld {
28+
context: EdgeContext
29+
walletId: string
30+
}
31+
32+
/**
33+
* Logs in once without any engine gate, decorates the fakecoin wallet
34+
* with recognizable values, waits for the cache saver to persist them,
35+
* and logs out. The returned context has a warm cache on disk.
36+
*/
37+
async function makeCachedWorld(): Promise<CachedWorld> {
38+
const world = await makeFakeEdgeWorld([fakeUser], quiet)
39+
const context = await world.makeEdgeContext({
40+
...contextOptions,
41+
plugins: { fakecoin: true }
42+
})
43+
44+
const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin)
45+
const walletInfo = account.getFirstWalletInfo('wallet:fakecoin')
46+
if (walletInfo == null) throw new Error('Broken test account')
47+
const wallet = await account.waitForCurrencyWallet(walletInfo.id)
48+
49+
await wallet.renameWallet('Cached Name')
50+
await wallet.changeEnabledTokenIds(['badf00d5'])
51+
await account.currencyConfig.fakecoin.changeUserSettings({
52+
balance: 12345,
53+
tokenBalance: 45
54+
})
55+
56+
// Let the callbacks propagate and the throttled saver write:
57+
await snooze(SAVE_WAIT_MS)
58+
await account.logout()
59+
60+
return { context, walletId: walletInfo.id }
61+
}
62+
63+
describe('wallet cache', function () {
64+
beforeEach(function () {
65+
fakePluginTestConfig.engineGate = undefined
66+
walletCacheSaverConfig.throttleMs = 50
67+
})
68+
69+
afterEach(function () {
70+
fakePluginTestConfig.engineGate = undefined
71+
walletCacheSaverConfig.throttleMs = 5000
72+
})
73+
74+
it('cold login without cache files matches master behavior', async function () {
75+
this.timeout(15000)
76+
const world = await makeFakeEdgeWorld([fakeUser], quiet)
77+
const context = await world.makeEdgeContext({
78+
...contextOptions,
79+
plugins: { fakecoin: true }
80+
})
81+
82+
// First-ever login on this device, with engine creation blocked:
83+
const { gate, release } = createEngineGate()
84+
fakePluginTestConfig.engineGate = gate
85+
const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin)
86+
const walletInfo = account.getFirstWalletInfo('wallet:fakecoin')
87+
if (walletInfo == null) throw new Error('Broken test account')
88+
89+
// With no cache, the wallet must not emit while the engine is blocked:
90+
await snooze(RACE_WAIT_MS)
91+
expect(account.currencyWallets[walletInfo.id]).equals(undefined)
92+
93+
// Releasing the engine lets the wallet finish loading as on master:
94+
release()
95+
const wallet = await account.waitForCurrencyWallet(walletInfo.id)
96+
expect(wallet.name).equals('Fake Wallet')
97+
await account.logout()
98+
})
99+
100+
it('warm login emits cached wallet before the engine exists', async function () {
101+
this.timeout(15000)
102+
const { context, walletId } = await makeCachedWorld()
103+
104+
const { gate, release } = createEngineGate()
105+
fakePluginTestConfig.engineGate = gate
106+
const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin)
107+
108+
// The wallet emits from the cache while the engine is still blocked:
109+
const wallet = await account.waitForCurrencyWallet(walletId)
110+
expect(wallet.name).equals('Cached Name')
111+
expect(wallet.fiatCurrencyCode).equals('iso:USD')
112+
expect(wallet.enabledTokenIds).deep.equals(['badf00d5'])
113+
expect(wallet.balanceMap.get(null)).equals('12345')
114+
expect(wallet.balances.FAKE).equals('12345')
115+
expect(wallet.balances.TOKEN).equals('45')
116+
117+
release()
118+
await account.logout()
119+
})
120+
121+
it('live engine data overwrites cached values on the same object', async function () {
122+
this.timeout(15000)
123+
const { context, walletId } = await makeCachedWorld()
124+
125+
const { gate, release } = createEngineGate()
126+
fakePluginTestConfig.engineGate = gate
127+
const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin)
128+
const wallet = await account.waitForCurrencyWallet(walletId)
129+
expect(wallet.balances.FAKE).equals('12345')
130+
131+
release()
132+
await account.currencyConfig.fakecoin.changeUserSettings({ balance: 777 })
133+
await snooze(SAVE_WAIT_MS)
134+
135+
// Live data lands on the very same wallet object:
136+
expect(wallet.balances.FAKE).equals('777')
137+
expect(account.currencyWallets[walletId]).equals(wallet)
138+
await account.logout()
139+
})
140+
141+
it('makeSpend pends during the cache window and then completes', async function () {
142+
this.timeout(15000)
143+
const { context, walletId } = await makeCachedWorld()
144+
145+
const { gate, release } = createEngineGate()
146+
fakePluginTestConfig.engineGate = gate
147+
const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin)
148+
const wallet = await account.waitForCurrencyWallet(walletId)
149+
150+
let settled = false
151+
const spendPromise = wallet
152+
.makeSpend({
153+
tokenId: null,
154+
spendTargets: [{ publicAddress: 'somewhere', nativeAmount: '0' }]
155+
})
156+
.then(tx => {
157+
settled = true
158+
return tx
159+
})
160+
161+
await snooze(RACE_WAIT_MS)
162+
expect(settled).equals(false)
163+
164+
release()
165+
const tx = await spendPromise
166+
expect(tx.txid).equals('spend')
167+
await account.logout()
168+
})
169+
170+
it('engine failure rejects calls pending on the engine', async function () {
171+
this.timeout(15000)
172+
const { context, walletId } = await makeCachedWorld()
173+
174+
const { gate, fail } = createEngineGate()
175+
fakePluginTestConfig.engineGate = gate
176+
const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin)
177+
const wallet = await account.waitForCurrencyWallet(walletId)
178+
179+
const spendPromise = wallet.makeSpend({
180+
tokenId: null,
181+
spendTargets: [{ publicAddress: 'somewhere', nativeAmount: '0' }]
182+
})
183+
184+
fail(new Error('Engine exploded'))
185+
await expectRejection(spendPromise, 'Error: Engine exploded')
186+
await account.logout()
187+
})
188+
189+
it('storage-backed methods survive an engine failure', async function () {
190+
this.timeout(15000)
191+
const { context, walletId } = await makeCachedWorld()
192+
193+
const { gate, fail } = createEngineGate()
194+
fakePluginTestConfig.engineGate = gate
195+
const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin)
196+
const wallet = await account.waitForCurrencyWallet(walletId)
197+
198+
fail(new Error('Engine exploded'))
199+
200+
// Engine-backed methods reject, but the repo is healthy,
201+
// so storage-backed methods keep working:
202+
await expectRejection(
203+
wallet.makeSpend({
204+
tokenId: null,
205+
spendTargets: [{ publicAddress: 'somewhere', nativeAmount: '0' }]
206+
}),
207+
'Error: Engine exploded'
208+
)
209+
await wallet.renameWallet('Renamed After Failure')
210+
expect(wallet.name).equals('Renamed After Failure')
211+
await account.logout()
212+
})
213+
214+
it('deleting the wallet rejects calls pending on the engine', async function () {
215+
this.timeout(15000)
216+
const { context, walletId } = await makeCachedWorld()
217+
218+
const { gate, release } = createEngineGate()
219+
fakePluginTestConfig.engineGate = gate
220+
const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin)
221+
const wallet = await account.waitForCurrencyWallet(walletId)
222+
223+
const spendPromise = wallet.makeSpend({
224+
tokenId: null,
225+
spendTargets: [{ publicAddress: 'somewhere', nativeAmount: '0' }]
226+
})
227+
228+
await account.changeWalletStates({ [walletId]: { deleted: true } })
229+
230+
// The pixie tree tears down, so the pending call must reject,
231+
// not dangle forever:
232+
await expectRejection(spendPromise)
233+
release()
234+
await account.logout()
235+
})
236+
237+
it('renameWallet during the cache window updates Redux and the cache file', async function () {
238+
this.timeout(15000)
239+
const { context, walletId } = await makeCachedWorld()
240+
241+
const { gate, release } = createEngineGate()
242+
fakePluginTestConfig.engineGate = gate
243+
const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin)
244+
const wallet = await account.waitForCurrencyWallet(walletId)
245+
246+
// The repo loads before the engine, so renames work in the window:
247+
await wallet.renameWallet('Renamed In Window')
248+
expect(wallet.name).equals('Renamed In Window')
249+
250+
// The saver picks up the change:
251+
await snooze(SAVE_WAIT_MS)
252+
release()
253+
await account.logout()
254+
255+
// The next gated login sees the new name from the cache:
256+
const { gate: gate2, release: release2 } = createEngineGate()
257+
fakePluginTestConfig.engineGate = gate2
258+
const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin)
259+
const wallet2 = await account2.waitForCurrencyWallet(walletId)
260+
expect(wallet2.name).equals('Renamed In Window')
261+
release2()
262+
await account2.logout()
263+
})
264+
265+
it('logout during a pending throttled save cancels the write', async function () {
266+
this.timeout(15000)
267+
const { context, walletId } = await makeCachedWorld()
268+
269+
// Slow the saver down so its write is still pending at logout:
270+
walletCacheSaverConfig.throttleMs = 3000
271+
const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin)
272+
const wallet = await account.waitForCurrencyWallet(walletId)
273+
await wallet.renameWallet('Ghost Name')
274+
await account.logout()
275+
276+
// The cancelled write must not have touched the cache:
277+
walletCacheSaverConfig.throttleMs = 50
278+
const { gate, release } = createEngineGate()
279+
fakePluginTestConfig.engineGate = gate
280+
const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin)
281+
const wallet2 = await account2.waitForCurrencyWallet(walletId)
282+
expect(wallet2.name).equals('Cached Name')
283+
release()
284+
await account2.logout()
285+
})
286+
287+
it('rejects a corrupt cache file, falls back cold, and re-saves', async function () {
288+
this.timeout(15000)
289+
const { context, walletId } = await makeCachedWorld()
290+
291+
// Corrupt the cache file after the saver has settled:
292+
const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin)
293+
const wallet = await account.waitForCurrencyWallet(walletId)
294+
await snooze(SAVE_WAIT_MS)
295+
await wallet.localDisklet.setText('walletCache.json', '{ "version": 99 }')
296+
await account.logout()
297+
298+
// A corrupt file means the cold path runs:
299+
const { gate, release } = createEngineGate()
300+
fakePluginTestConfig.engineGate = gate
301+
const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin)
302+
await snooze(RACE_WAIT_MS)
303+
expect(account2.currencyWallets[walletId]).equals(undefined)
304+
305+
// Releasing the engine loads the wallet, and the saver rewrites the file:
306+
release()
307+
const wallet2 = await account2.waitForCurrencyWallet(walletId)
308+
expect(wallet2.name).equals('Cached Name')
309+
await snooze(SAVE_WAIT_MS)
310+
await account2.logout()
311+
312+
// The rewritten file feeds the next gated login:
313+
const { gate: gate3, release: release3 } = createEngineGate()
314+
fakePluginTestConfig.engineGate = gate3
315+
const account3 = await context.loginWithPIN(fakeUser.username, fakeUser.pin)
316+
const wallet3 = await account3.waitForCurrencyWallet(walletId)
317+
expect(wallet3.name).equals('Cached Name')
318+
release3()
319+
await account3.logout()
320+
})
321+
322+
it('balance changes reach the cache file within a throttle window', async function () {
323+
this.timeout(15000)
324+
const { context, walletId } = await makeCachedWorld()
325+
326+
const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin)
327+
await account.waitForCurrencyWallet(walletId)
328+
await account.currencyConfig.fakecoin.changeUserSettings({ balance: 999 })
329+
await snooze(SAVE_WAIT_MS)
330+
await account.logout()
331+
332+
const { gate, release } = createEngineGate()
333+
fakePluginTestConfig.engineGate = gate
334+
const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin)
335+
const wallet2 = await account2.waitForCurrencyWallet(walletId)
336+
expect(wallet2.balances.FAKE).equals('999')
337+
release()
338+
await account2.logout()
339+
})
340+
341+
it('otherMethods is {} pre-engine and carries engine methods post-engine', async function () {
342+
this.timeout(15000)
343+
const { context, walletId } = await makeCachedWorld()
344+
345+
const { gate, release } = createEngineGate()
346+
fakePluginTestConfig.engineGate = gate
347+
const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin)
348+
const wallet = await account.waitForCurrencyWallet(walletId)
349+
350+
// Pre-engine, otherMethods is a safe empty object:
351+
expect(wallet.otherMethods).not.equals(undefined)
352+
expect(Object.keys(wallet.otherMethods)).deep.equals([])
353+
354+
release()
355+
await waitForOtherMethods(wallet)
356+
expect(await wallet.otherMethods.testMethod('hello')).equals(
357+
'testMethod called with: hello'
358+
)
359+
await account.logout()
360+
})
361+
})
362+
363+
/** Polls until the engine's otherMethods replace the empty pre-engine ones. */
364+
async function waitForOtherMethods(wallet: EdgeCurrencyWallet): Promise<void> {
365+
for (let i = 0; i < 100; ++i) {
366+
if (wallet.otherMethods.testMethod != null) return
367+
await snooze(50)
368+
}
369+
throw new Error('otherMethods never arrived')
370+
}

0 commit comments

Comments
 (0)