From 1a84f24423042b447124c0ac8fb23916720d6b19 Mon Sep 17 00:00:00 2001 From: peachbits Date: Mon, 24 Aug 2026 14:23:02 -0700 Subject: [PATCH 1/4] fixed: Postpone the Zano refresh worker Opening a wallet auto-starts its native refresh worker, and the worker holds the per-wallet recursive mutex for the entire first catch-up scan (plain_wallet_api.cpp:508 auto-runs on open; worker_func holds the locked_object proxy across refresh()). The 0.4.0 re-key migration's resetWalletPassword takes that same mutex, so on a wallet weeks behind it blocked for the whole scan while sitting on React Native's shared native-module dispatch queue -- every native call in the app queued behind it, and the app usually died before the migration finished, so the file was never re-keyed and every launch repeated the block. startWallet now configures the native library's postponed_run_wallet mode before its first open, so no probe or migration open starts a worker, and starts the worker explicitly for exactly the wallet it returns. Every throw path leaves either nothing open, or a wallet that reaches its next caller as ALREADY_EXISTS -- and under postponed mode an adopted wallet is not running, so the adopter owns issuing run_wallet for it, which edge-currency-accountbased's adoption path does. The flag is process-wide and sticky; the raw open/restore/generate docs carry the caveat, and any open made after this must be followed by run_wallet once the wallet should sync. generateSeedPhrase had the same shape: its temporary wallet opened with the worker auto-running, so the closeWallet that follows waited on the same lock. It postpones too, and a close that does not report OK now fails the call rather than deleting a file this process still holds open. Both native methods (configure, run_wallet) exist in the shipped 0.4.0 dispatch, so this is JS-only, and runWallet treats the one bare-string native failure answer as a failure report rather than a parse surprise. The fake models the postponed-run contract (runningWallets, an open-wallet table tests can clear to simulate the process dying), and every startWallet success path asserts exactly-the-returned-wallet- running -- verified by mutation: dropping any started() call now fails the suite. Ported from EdgeApp/react-native-zano#17. Co-authored-by: Jonathan Tzeng --- CHANGELOG.md | 4 ++ src/CppBridge.ts | 114 +++++++++++++++++++++++++++++-- test/fakeZanoModule.ts | 58 ++++++++++++++++ test/startWallet.test.ts | 141 +++++++++++++++++++++++++++++++++++---- 4 files changed, 299 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ab4a9d..ed5df94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- added: `runWallet`, which starts the refresh worker for an open wallet. `startWallet` rethrows `ALREADY_EXISTS` for its caller to adopt the already-open wallet, and an adopted wallet does not sync until it is run, so adopting callers need this without reimplementing the raw `run_wallet` response contract. +- fixed: `startWallet` no longer freezes the app while a wallet catches up on blocks. Opening a wallet auto-started its refresh worker, which holds the per-wallet lock for the entire first scan, so the 0.4.0 re-key migration's `resetWalletPassword` blocked on that lock for the whole catch-up (minutes to hours) while sitting on React Native's shared native-module queue, and on iOS every native call in the app queued behind it. Wallets now open with the refresh worker postponed, the migration completes in milliseconds, and the worker is started explicitly for the one wallet `startWallet` returns. +- fixed: Creating a wallet no longer freezes the app. `generateSeedPhrase` opened its temporary wallet without postponing the refresh worker, so the `closeWallet` that follows waited on the per-wallet lock, on the shared native-module queue, for the length of a refresh. A close that does not report OK now fails the call rather than deleting a file this process still holds open. + ## 0.4.0 (2026-08-14) - added: `startWallet` accepts an optional `log` callback that reports wallet-file recovery and migration events. diff --git a/src/CppBridge.ts b/src/CppBridge.ts index 3a0ab4a..9acb8d3 100644 --- a/src/CppBridge.ts +++ b/src/CppBridge.ts @@ -51,6 +51,9 @@ function isAlreadyExists(error: unknown): boolean { export class CppBridge { private readonly module: NativeZanoModule + // Whether `configurePostponedRun` has succeeded. The native flag it sets + // is process-wide and sticky, so one success covers every later call: + private postponedRunConfigured: boolean = false constructor(zanoModule: NativeZanoModule) { this.module = zanoModule @@ -155,11 +158,22 @@ export class CppBridge { return JSON.parse(response) } + /** + * Raw native open. Note that once `startWallet` or `generateSeedPhrase` + * has run, the process-wide postponed-run mode is configured and stays on: + * a wallet opened here will not sync until `run_wallet` is issued for it + * (via `syncCall`). Prefer `startWallet`. + */ async open(path: string, password: string): Promise> { const response = await this.module.callZano('open', [path, password]) return JSON.parse(response) } + /** + * Raw native restore. Subject to the same postponed-run caveat as `open`: + * under postponed mode the restored wallet will not sync until + * `run_wallet` is issued for it. + */ async restore( seed: string, path: string, @@ -175,6 +189,11 @@ export class CppBridge { return JSON.parse(response) } + /** + * Raw native generate. Subject to the same postponed-run caveat as `open`: + * under postponed mode the generated wallet will not sync until + * `run_wallet` is issued for it. + */ async generate( path: string, password: string @@ -238,6 +257,61 @@ export class CppBridge { ]) } + /** + * Tells the native library not to start a wallet's refresh worker as part + * of `open`/`restore`/`generate`; `runWallet` starts it explicitly. The + * flag is process-wide and sticky, so every open made after this call must + * be followed by `runWallet` once the wallet should sync -- and one + * success is enough, so this short-circuits instead of paying a native + * round trip per wallet start. Requires `init` to have run. + */ + private async configurePostponedRun(): Promise { + if (this.postponedRunConfigured) return + const response = await this.syncCall( + 'configure', + 0, + JSON.stringify({ postponed_run_wallet: true }) + ) + // Same `syncCall` primitive as `runWallet`, same quirk: one native + // failure path answers with a bare return-code string rather than JSON, + // so a parse failure is a failure report, not a protocol surprise: + let parsed: { status?: string } + try { + parsed = JSON.parse(response) + } catch (error: unknown) { + throw new Error(`Zano configure returned ${response}`) + } + if (parsed.status !== 'OK') { + throw new Error(`Zano configure returned ${response}`) + } + this.postponedRunConfigured = true + } + + /** + * Starts the refresh worker for an open wallet. Idempotent: the native + * side skips the spawn when the worker is already running. + * + * Public because adopting a wallet is public behavior: `startWallet` + * rethrows ALREADY_EXISTS for its caller to recover from, and the wallet + * the caller then adopts was opened with the refresh worker postponed, so + * it does not sync until this runs. + */ + async runWallet(walletId: number): Promise { + const response = await this.syncCall('run_wallet', walletId, '') + // One native failure path answers with a bare return-code string rather + // than JSON (the postponed main worker failing to start), so a parse + // failure is a failure report, not a protocol surprise: + let parsed: { error_code?: string } + try { + parsed = JSON.parse(response) + } catch (error: unknown) { + throw new Error(`Zano run_wallet returned ${response}`) + } + if (parsed.error_code !== 'OK') { + throw new Error(`Zano run_wallet returned ${response}`) + } + } + async isWalletExist(path: string): Promise { const response = await this.module.callZano('isWalletExist', [ this.module.documentDirectory + '/wallets/' + path @@ -302,10 +376,25 @@ export class CppBridge { ): Promise { await this.init(rpcAddress, logLevel) + // Native `generate` auto-starts the wallet's refresh worker unless + // postponed-run is configured first, and the worker holds the per-wallet + // mutex for the whole of each refresh. The `closeWallet` below takes that + // same mutex on React Native's shared native-module queue, so without + // this the create-wallet path stalls every native call in the app for as + // long as the refresh runs. The flag is process-wide, so this matters + // whenever `generateSeedPhrase` is the first call to configure it. + await this.configurePostponedRun() + const response = await this.generate(storagePath, seedPassword) const result = this.expectWallet(this.handleRpcResponse(response)) - await this.closeWallet(result.wallet_id) + const { response: closeResponse } = await this.closeWallet(result.wallet_id) + if (closeResponse !== 'OK') { + // The file is still open in this process, so deleting it would leave a + // dangling handle. Leaving it is safe: `startWallet` re-keys or + // rebuilds whatever it finds. + throw new Error(`closeWallet returned ${closeResponse}`) + } // `generate` writes a wallet file as a side effect, encrypted with // `seedPassword` -- typically the empty string. The caller only wants @@ -346,6 +435,19 @@ export class CppBridge { const log = opts.log ?? (() => {}) const filePassword = deriveWalletFilePassword(mnemonicSeed) + // An auto-run open starts the refresh worker, which takes the per-wallet + // lock for the entire first catch-up scan -- minutes for a wallet that is + // weeks behind. The migration's `resetWalletPassword` then blocks on that + // lock, and since it runs on React Native's shared native-module queue, + // every native call in the app queues behind it for the whole scan. + // Open without running instead, and start the worker explicitly once the + // wallet this method returns is the one that should sync. + await this.configurePostponedRun() + const started = async (wallet: WalletDetails): Promise => { + await this.runWallet(wallet.wallet_id) + return wallet + } + const openWith = async (password: string): Promise => { const wallet = this.expectWallet( this.handleRpcResponse(await this.open(storagePath, password)) @@ -392,11 +494,11 @@ export class CppBridge { const files = await this.getWalletFiles() const exists = 'items' in files && files.items.includes(storagePath) if (!exists) { - return await restoreFresh() + return await started(await restoreFresh()) } try { - return await openWith(filePassword) + return await started(await openWith(filePassword)) } catch (error: unknown) { // Anything other than a bad password -- including ALREADY_EXISTS, // which callers recover from by adopting the open wallet -- is not @@ -450,7 +552,7 @@ export class CppBridge { // Only believe the migration once the file really opens with the // new password: - const migrated = await openWith(filePassword) + const migrated = await started(await openWith(filePassword)) log('Zano wallet file re-keyed with a derived password') return migrated } catch (error: unknown) { @@ -501,7 +603,7 @@ export class CppBridge { } log('Rebuilding the Zano wallet file') - return await rebuild() + return await started(await rebuild()) } } @@ -522,7 +624,7 @@ export class CppBridge { } log('Zano wallet file opens with no known password, rebuilding it') - return await rebuild() + return await started(await rebuild()) } async stopWallet(walletId: number): Promise { diff --git a/test/fakeZanoModule.ts b/test/fakeZanoModule.ts index c16e08a..a8e321d 100644 --- a/test/fakeZanoModule.ts +++ b/test/fakeZanoModule.ts @@ -22,11 +22,27 @@ export interface FakeState { openResults?: Array /** Forces `resetWalletPassword` to report this raw string. */ resetResult?: string + /** + * Forces `configure` to answer with this raw string, modeling the native + * failure path that reports a bare return code instead of JSON. + */ + configureResult?: string /** * Makes `deleteWallet` leave the file in place while still reporting OK, * which is what the native layer does when the delete fails. */ deleteFails?: boolean + /** + * Filled in by the fake: wallet ids whose refresh worker was started via + * `run_wallet`, so tests can assert which wallets actually sync. + */ + runningWallets?: Set + /** + * Filled in by the fake: storage paths of wallets currently open, keyed by + * wallet id. Clearing it models a fresh process, since the native library + * loses its open-wallet table when the app dies. + */ + openWallets?: Map } export const FAKE_ADDRESS = 'ZxFakeAddress' @@ -63,6 +79,18 @@ export function makeFakeZanoModule(state: FakeState): NativeZanoModule { const open = new Map() let nextId = 1 + // Wallet ids whose refresh worker has been started with `run_wallet`. + // Mirrors the native `postponed_run_wallet` mode the bridge configures: + // opening no longer runs the wallet, so a wallet that should sync must + // appear here. + const running = new Set() + state.runningWallets = running + + // Exposed so a test can model a new process (native loses this table when + // the app dies) and assert which paths are still held open. + const openPaths = new Map() + state.openWallets = openPaths + const methods: { [name: string]: (args: string[]) => string } = { @@ -78,10 +106,17 @@ export function makeFakeZanoModule(state: FakeState): NativeZanoModule { if (queued != null) return queued const file = state.files.get(storagePath) if (file == null) return rpcError('FILE_NOT_FOUND') + // Native refuses a second open of a file this process already holds. + // Driven off the exposed table so a test can clear it to model the + // process dying, which is what a new launch really is. + for (const path of openPaths.values()) { + if (path === storagePath) return rpcError('ALREADY_EXISTS') + } if (file.filePassword !== password) return rpcError('WRONG_PASSWORD') const walletId = nextId++ open.set(walletId, { storagePath, password }) + openPaths.set(walletId, storagePath) return walletResult(walletId, file) }, @@ -92,6 +127,7 @@ export function makeFakeZanoModule(state: FakeState): NativeZanoModule { const walletId = nextId++ open.set(walletId, { storagePath, password: filePassword }) + openPaths.set(walletId, storagePath) return walletResult(walletId, file) }, @@ -105,6 +141,7 @@ export function makeFakeZanoModule(state: FakeState): NativeZanoModule { const walletId = nextId++ open.set(walletId, { storagePath, password }) + openPaths.set(walletId, storagePath) return walletResult(walletId, file) }, @@ -128,6 +165,8 @@ export function makeFakeZanoModule(state: FakeState): NativeZanoModule { const file = state.files.get(entry.storagePath) if (file != null) file.filePassword = entry.password open.delete(Number(walletIdText)) + openPaths.delete(Number(walletIdText)) + running.delete(Number(walletIdText)) return JSON.stringify({ response: 'OK' }) }, @@ -141,6 +180,25 @@ export function makeFakeZanoModule(state: FakeState): NativeZanoModule { // The bridge prefixes the documents directory and `wallets/`: const storagePath = fullPath.replace(/^.*\/wallets\//, '') return state.files.has(storagePath) ? '1' : '0' + }, + + syncCall: ([methodName, instanceIdText]) => { + if (methodName === 'configure') { + if (state.configureResult != null) return state.configureResult + // The bridge only ever posts `postponed_run_wallet: true`, which this + // fake's `open`/`restore`/`generate` already model (nothing runs + // until `run_wallet`). + return JSON.stringify({ status: 'OK' }) + } + if (methodName === 'run_wallet') { + const walletId = Number(instanceIdText) + if (!open.has(walletId)) { + return JSON.stringify({ error_code: 'WALLET_WRONG_ID' }) + } + running.add(walletId) + return JSON.stringify({ error_code: 'OK' }) + } + throw new Error(`No fake for syncCall method ${methodName}`) } } diff --git a/test/startWallet.test.ts b/test/startWallet.test.ts index 675612b..80dca45 100644 --- a/test/startWallet.test.ts +++ b/test/startWallet.test.ts @@ -37,18 +37,24 @@ describe('startWallet', () => { ), `restore call not found in: ${state.calls.join('\n')}` ) + // The restore opens postponed; the returned wallet must be running: + assert.deepEqual([...(state.runningWallets ?? [])], [wallet.wallet_id]) }) it('opens a file already on the derived password without touching it', async () => { const state = makeState({ filePassword: DERIVED, mnemonic: MNEMONIC }) const bridge = makeBridge(state) - await bridge.startWallet(MNEMONIC, '', STORAGE_PATH) + const wallet = await bridge.startWallet(MNEMONIC, '', STORAGE_PATH) const joined = state.calls.join('\n') assert.ok(!joined.includes('resetWalletPassword')) assert.ok(!joined.includes('restore')) assert.ok(!joined.includes('deleteWallet')) + // The postponed-run contract holds on the plain-open path too: dropping + // `started()` here would otherwise leave the wallet open but not syncing. + assert.ok(joined.includes('syncCall(configure,')) + assert.deepEqual([...(state.runningWallets ?? [])], [wallet.wallet_id]) }) it('re-keys a file encrypted with the empty string', async () => { @@ -59,36 +65,104 @@ describe('startWallet', () => { assert.equal(typeof wallet.wallet_id, 'number') assert.equal(state.files.get(STORAGE_PATH)?.filePassword, DERIVED) - // The exact sequence: probe with the new password, open legacy, re-key - // in memory, close to persist, then verify by reopening: + // The exact sequence: postpone the refresh worker so no open below can + // start a scan that would block the re-key, probe with the new password, + // open legacy, re-key in memory, close to persist, verify by reopening, + // and only then start the worker: assert.deepEqual(state.calls, [ + 'syncCall(configure,0,{"postponed_run_wallet":true})', 'getWalletFiles()', `open(${STORAGE_PATH},${DERIVED})`, `open(${STORAGE_PATH},)`, `resetWalletPassword(1,${DERIVED})`, 'closeWallet(1)', - `open(${STORAGE_PATH},${DERIVED})` + `open(${STORAGE_PATH},${DERIVED})`, + 'syncCall(run_wallet,2,)' ]) + assert.deepEqual([...(state.runningWallets ?? [])], [wallet.wallet_id]) }) it('re-keys a file encrypted with a real seed passphrase', async () => { const state = makeState({ filePassword: 'hunter2', mnemonic: MNEMONIC }) const bridge = makeBridge(state) - await bridge.startWallet(MNEMONIC, 'hunter2', STORAGE_PATH) + const wallet = await bridge.startWallet(MNEMONIC, 'hunter2', STORAGE_PATH) assert.equal(state.files.get(STORAGE_PATH)?.filePassword, DERIVED) + assert.deepEqual([...(state.runningWallets ?? [])], [wallet.wallet_id]) }) it('is idempotent across launches', async () => { const state = makeState({ filePassword: '', mnemonic: MNEMONIC }) const bridge = makeBridge(state) - await bridge.startWallet(MNEMONIC, '', STORAGE_PATH) + const first = await bridge.startWallet(MNEMONIC, '', STORAGE_PATH) state.calls.length = 0 - await bridge.startWallet(MNEMONIC, '', STORAGE_PATH) + // A new launch is a new process: native loses its open-wallet table and + // its running workers, so the second start opens the file fresh rather + // than re-using a handle. + state.openWallets?.clear() + state.runningWallets?.clear() + const second = await bridge.startWallet(MNEMONIC, '', STORAGE_PATH) assert.ok(!state.calls.join('\n').includes('resetWalletPassword')) + assert.equal(typeof second.wallet_id, 'number') + assert.notEqual(first.wallet_id, second.wallet_id) + assert.deepEqual([...(state.runningWallets ?? [])], [second.wallet_id]) + }) + + it('configures postponed run once per bridge', async () => { + const state = makeState({ filePassword: DERIVED, mnemonic: MNEMONIC }) + const bridge = makeBridge(state) + + await bridge.startWallet(MNEMONIC, '', STORAGE_PATH) + // The same process starts another wallet: the native flag is sticky, so + // the bridge must not pay a second configure round trip. + state.openWallets?.clear() + state.runningWallets?.clear() + await bridge.startWallet(MNEMONIC, '', STORAGE_PATH) + + const configures = state.calls.filter(call => + call.startsWith('syncCall(configure,') + ) + assert.equal(configures.length, 1, state.calls.join('\n')) + }) + + it('retries configure after a failure, and reports it readably', async () => { + const state = makeState({ filePassword: DERIVED, mnemonic: MNEMONIC }) + // The native failure path answers with a bare return-code string, not + // JSON; the bridge must report it, not throw an opaque SyntaxError: + state.configureResult = 'UNINITIALIZED' + const bridge = makeBridge(state) + + await assert.rejects( + bridge.startWallet(MNEMONIC, '', STORAGE_PATH), + /Zano configure returned UNINITIALIZED/ + ) + + // A failure must not latch the short-circuit: the next start retries. + delete state.configureResult + const wallet = await bridge.startWallet(MNEMONIC, '', STORAGE_PATH) + assert.deepEqual([...(state.runningWallets ?? [])], [wallet.wallet_id]) + const configures = state.calls.filter(call => + call.startsWith('syncCall(configure,') + ) + assert.equal(configures.length, 2, state.calls.join('\n')) + }) + + it('refuses a second start while the first is still open', async () => { + const state = makeState({ filePassword: DERIVED, mnemonic: MNEMONIC }) + const bridge = makeBridge(state) + + const first = await bridge.startWallet(MNEMONIC, '', STORAGE_PATH) + state.calls.length = 0 + + await assert.rejects( + bridge.startWallet(MNEMONIC, '', STORAGE_PATH), + /ALREADY_EXISTS/ + ) + assert.ok(!state.calls.join('\n').includes('deleteWallet')) + assert.deepEqual([...(state.runningWallets ?? [])], [first.wallet_id]) }) it('rebuilds the file when resetWalletPassword does not report OK', async () => { @@ -103,6 +177,8 @@ describe('startWallet', () => { assert.equal(typeof wallet.wallet_id, 'number') assert.ok(state.calls.join('\n').includes('deleteWallet')) assert.equal(state.files.get(STORAGE_PATH)?.filePassword, DERIVED) + // Only the rebuilt wallet syncs; the discarded legacy open never ran: + assert.deepEqual([...(state.runningWallets ?? [])], [wallet.wallet_id]) }) it('leaves the file alone when it cannot release the wallet', async () => { @@ -157,10 +233,11 @@ describe('startWallet', () => { ] const bridge = makeBridge(state) - await bridge.startWallet(MNEMONIC, '', STORAGE_PATH) + const wallet = await bridge.startWallet(MNEMONIC, '', STORAGE_PATH) assert.ok(state.calls.join('\n').includes('deleteWallet')) assert.equal(state.files.get(STORAGE_PATH)?.filePassword, DERIVED) + assert.deepEqual([...(state.runningWallets ?? [])], [wallet.wallet_id]) }) it('fails loudly when the delete leaves the file behind', async () => { @@ -221,10 +298,11 @@ describe('startWallet', () => { const state = makeState({ filePassword: '', mnemonic: MNEMONIC }) const bridge = makeBridge(state) - await bridge.startWallet(MNEMONIC, 'hunter2', STORAGE_PATH) + const wallet = await bridge.startWallet(MNEMONIC, 'hunter2', STORAGE_PATH) assert.equal(state.files.get(STORAGE_PATH)?.filePassword, DERIVED) assert.ok(!state.calls.join('\n').includes('deleteWallet')) + assert.deepEqual([...(state.runningWallets ?? [])], [wallet.wallet_id]) // Both candidates were tried, in order: const opens = state.calls.filter(call => call.startsWith('open(')) assert.deepEqual(opens, [ @@ -260,10 +338,11 @@ describe('startWallet', () => { state.resetResult = '{"error":{"code":"INTERNAL_ERROR"}}' const bridge = makeBridge(state) - await bridge.startWallet(MNEMONIC, 'hunter2', STORAGE_PATH) + const wallet = await bridge.startWallet(MNEMONIC, 'hunter2', STORAGE_PATH) assert.ok(state.calls.join('\n').includes('deleteWallet')) assert.equal(state.files.get(STORAGE_PATH)?.filePassword, DERIVED) + assert.deepEqual([...(state.runningWallets ?? [])], [wallet.wallet_id]) }) it('throws rather than rebuilding when the passphrase is wrong', async () => { @@ -289,10 +368,13 @@ describe('startWallet', () => { }) const bridge = makeBridge(state) - await bridge.startWallet(MNEMONIC, '', STORAGE_PATH) + const wallet = await bridge.startWallet(MNEMONIC, '', STORAGE_PATH) assert.ok(state.calls.join('\n').includes('deleteWallet')) assert.equal(state.files.get(STORAGE_PATH)?.filePassword, DERIVED) + // The rebuilt wallet is the one that must sync -- dropping `started()` + // on this path previously survived the whole suite: + assert.deepEqual([...(state.runningWallets ?? [])], [wallet.wallet_id]) }) it('rethrows ALREADY_EXISTS with the historical message shape', async () => { @@ -337,11 +419,12 @@ describe('startWallet', () => { const bridge = makeBridge(state) const logged: string[] = [] - await bridge.startWallet(MNEMONIC, '', STORAGE_PATH, { + const wallet = await bridge.startWallet(MNEMONIC, '', STORAGE_PATH, { log: message => logged.push(message) }) assert.ok(logged.some(line => line.includes('re-keyed'))) + assert.deepEqual([...(state.runningWallets ?? [])], [wallet.wallet_id]) }) }) @@ -359,4 +442,38 @@ describe('generateSeedPhrase', () => { assert.equal(typeof wallet.seed, 'string') assert.equal(state.files.size, 0) }) + + it('postpones the refresh worker before generating', async () => { + const state = makeState() + const bridge = makeBridge(state) + + await bridge.generateSeedPhrase('http://example.invalid', STORAGE_PATH, '') + + // `generate` auto-starts the refresh worker unless postponed-run is + // configured first, and the `closeWallet` below waits on the mutex that + // worker holds, on the shared native-module queue. + const configureIndex = state.calls.findIndex(call => + call.startsWith('syncCall(configure,') + ) + const generateIndex = state.calls.findIndex(call => + call.startsWith('generate(') + ) + assert.notEqual(configureIndex, -1, state.calls.join('\n')) + assert.ok( + configureIndex < generateIndex, + `configure must precede generate: ${state.calls.join('\n')}` + ) + }) + + it('keeps the file when the close fails, rather than deleting it open', async () => { + const state = makeState() + state.closeResult = 'BUSY' + const bridge = makeBridge(state) + + await assert.rejects( + bridge.generateSeedPhrase('http://example.invalid', STORAGE_PATH, ''), + /BUSY/ + ) + assert.ok(!state.calls.join('\n').includes('deleteWallet')) + }) }) From 56972ef3dace505e4e7ce543d22ac4e8989e0526 Mon Sep 17 00:00:00 2001 From: peachbits Date: Mon, 24 Aug 2026 14:24:12 -0700 Subject: [PATCH 2/4] fixed: Fail closed on unprotected iOS storage `prepareZanoDirectory` failures were log-and-continue: if the `wallets` directory could not be created or could not be marked excluded from device backups, the module still handed out `documentDirectory` and the SDK went on to write the seed and spend keys into a directory an unencrypted Finder backup would capture -- the exact exposure the 0.4.0 backup exclusion exists to close. A nil documents directory was worse: `[docsDir path]` fed nil into an NSDictionary literal, which throws at module init. The module now withholds `documentDirectory` when `wallets` cannot be prepared, and the `CppBridge` constructor turns that into a hard failure with a clear message, so Zano is disabled for the session rather than silently unprotected. `NativeZanoModule.documentDirectory` becomes optional to match the contract this creates, the bridge keeps a guard-validated copy for its own paths, and a test pins the refusal for both the missing and the empty value. `logs` and `app_config` carry no key material, so their preparation stays best-effort. This trades availability for key protection: a transient exclusion failure now disables Zano until the next launch instead of degrading silently. Deliberate, and worth revisiting only if such failures show up in the field. Ported from EdgeApp/react-native-zano#17. Co-authored-by: Jonathan Tzeng --- CHANGELOG.md | 1 + ios/ZanoModule.mm | 28 ++++++++++++++++++++++------ src/CppBridge.ts | 30 ++++++++++++++++++++++++++---- test/startWallet.test.ts | 15 +++++++++++++++ 4 files changed, 64 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed5df94..8d61871 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - added: `runWallet`, which starts the refresh worker for an open wallet. `startWallet` rethrows `ALREADY_EXISTS` for its caller to adopt the already-open wallet, and an adopted wallet does not sync until it is run, so adopting callers need this without reimplementing the raw `run_wallet` response contract. - fixed: `startWallet` no longer freezes the app while a wallet catches up on blocks. Opening a wallet auto-started its refresh worker, which holds the per-wallet lock for the entire first scan, so the 0.4.0 re-key migration's `resetWalletPassword` blocked on that lock for the whole catch-up (minutes to hours) while sitting on React Native's shared native-module queue, and on iOS every native call in the app queued behind it. Wallets now open with the refresh worker postponed, the migration completes in milliseconds, and the worker is started explicitly for the one wallet `startWallet` returns. - fixed: Creating a wallet no longer freezes the app. `generateSeedPhrase` opened its temporary wallet without postponing the refresh worker, so the `closeWallet` that follows waited on the per-wallet lock, on the shared native-module queue, for the length of a refresh. A close that does not report OK now fails the call rather than deleting a file this process still holds open. +- fixed: The iOS module now withholds its document directory when the wallet directory cannot be created or excluded from device backups, so the bridge fails at construction instead of letting the SDK write seed and spend keys somewhere a backup would capture. ## 0.4.0 (2026-08-14) diff --git a/ios/ZanoModule.mm b/ios/ZanoModule.mm index bec14cf..6365384 100644 --- a/ios/ZanoModule.mm +++ b/ios/ZanoModule.mm @@ -69,7 +69,7 @@ + (BOOL)requiresMainQueueSetup { return NO; } * The SDK creates these itself on first use, but only we can set the * backup flag, and that requires the directory to already exist. */ -static void prepareZanoDirectory(NSURL *parent, NSString *name) +static BOOL prepareZanoDirectory(NSURL *parent, NSString *name) { NSURL *url = [parent URLByAppendingPathComponent:name isDirectory:YES]; @@ -79,14 +79,17 @@ static void prepareZanoDirectory(NSURL *parent, NSString *name) attributes:nil error:&error]) { RCTLogWarn(@"zano could not create %@: %@", name, error); - return; + return NO; } if (![url setResourceValue:@YES forKey:NSURLIsExcludedFromBackupKey error:&error]) { RCTLogWarn(@"zano could not exclude %@ from backups: %@", name, error); + return NO; } + + return YES; } - (NSDictionary *)constantsToExport @@ -99,23 +102,36 @@ - (NSDictionary *)constantsToExport } NSFileManager *fileManager = [NSFileManager defaultManager]; + NSError *docsError = nil; NSURL *docsDir = [fileManager URLForDirectory:NSDocumentDirectory inDomain:NSUserDomainMask appropriateForURL:nil create:YES - error:nil]; - NSString *docsPath = [docsDir path]; + error:&docsError]; + if (docsDir == nil) { + RCTLogWarn(@"zano could not resolve the documents directory: %@", docsError); + return @{ @"methodNames": out }; + } // Every directory the SDK derives from the working directory we hand it. // `scripts/update-sources.ts` fails the build if this list drifts from the // folder names declared in the SDK's `plain_wallet_api.cpp`. - prepareZanoDirectory(docsDir, @"wallets"); + // + // `wallets` holds the seed and spend keys, so failing to create it or to + // mark it excluded from backups is fatal: withholding + // `documentDirectory` stops the bridge in its constructor instead of + // letting the SDK write keys somewhere an unencrypted Finder backup would + // pick up. `logs` and `app_config` carry no key material. + BOOL walletsReady = prepareZanoDirectory(docsDir, @"wallets"); prepareZanoDirectory(docsDir, @"logs"); prepareZanoDirectory(docsDir, @"app_config"); + if (!walletsReady) { + return @{ @"methodNames": out }; + } return @{ @"methodNames": out, - @"documentDirectory": docsPath + @"documentDirectory": [docsDir path] }; } diff --git a/src/CppBridge.ts b/src/CppBridge.ts index 9acb8d3..cacabd0 100644 --- a/src/CppBridge.ts +++ b/src/CppBridge.ts @@ -38,7 +38,13 @@ export interface NativeZanoModule { readonly callZano: (name: string, jsonArguments: string[]) => Promise readonly methodNames: string[] - readonly documentDirectory: string + + /** + * Absent when the iOS module could not create the wallet directory or + * exclude it from device backups; the `CppBridge` constructor refuses to + * run without it. + */ + readonly documentDirectory?: string } function isWrongPassword(error: unknown): boolean { @@ -51,11 +57,27 @@ function isAlreadyExists(error: unknown): boolean { export class CppBridge { private readonly module: NativeZanoModule + private readonly documentDirectory: string // Whether `configurePostponedRun` has succeeded. The native flag it sets // is process-wide and sticky, so one success covers every later call: private postponedRunConfigured: boolean = false constructor(zanoModule: NativeZanoModule) { + // The native side omits `documentDirectory` when it could not create the + // wallet directory or exclude it from device backups. That directory + // holds the seed and spend keys, so a missing value must stop the bridge + // here rather than let every path below concatenate `undefined` into a + // storage path the SDK would happily create somewhere unprotected. + if ( + zanoModule.documentDirectory == null || + zanoModule.documentDirectory === '' + ) { + throw new ZanoError( + 'INTERNAL_ERROR', + 'Zano native module reported no document directory' + ) + } + this.documentDirectory = zanoModule.documentDirectory this.module = zanoModule } @@ -69,7 +91,7 @@ export class CppBridge { ): Promise> { const response = await this.module.callZano('init', [ rpcAddress, - this.module.documentDirectory, + this.documentDirectory, logLevel.toFixed() ]) return JSON.parse(response) @@ -83,7 +105,7 @@ export class CppBridge { const response = await this.module.callZano('initWithIpPort', [ ip, port, - this.module.documentDirectory, + this.documentDirectory, logLevel.toFixed() ]) return JSON.parse(response) @@ -314,7 +336,7 @@ export class CppBridge { async isWalletExist(path: string): Promise { const response = await this.module.callZano('isWalletExist', [ - this.module.documentDirectory + '/wallets/' + path + this.documentDirectory + '/wallets/' + path ]) return response === '1' } diff --git a/test/startWallet.test.ts b/test/startWallet.test.ts index 80dca45..56aaf3f 100644 --- a/test/startWallet.test.ts +++ b/test/startWallet.test.ts @@ -19,6 +19,21 @@ const makeState = (file?: FakeWalletFile): FakeState => { const makeBridge = (state: FakeState): CppBridge => new CppBridge(makeFakeZanoModule(state)) +describe('CppBridge construction', () => { + it('refuses a module that reports no document directory', () => { + // The iOS module omits `documentDirectory` when the wallet directory + // could not be created or excluded from device backups. Constructing a + // bridge anyway would concatenate `undefined` into every storage path. + const base = makeFakeZanoModule(makeState()) + for (const documentDirectory of [undefined, '']) { + assert.throws( + () => new CppBridge({ ...base, documentDirectory }), + /no document directory/ + ) + } + }) +}) + describe('startWallet', () => { it('creates a missing file with the derived password, not the seed passphrase', async () => { const state = makeState() From 639234aba39879e0158b424183b754aa90bec277 Mon Sep 17 00:00:00 2001 From: peachbits Date: Mon, 24 Aug 2026 12:34:06 -0700 Subject: [PATCH 3/4] changed: Encode file passwords with rfc4648 Replaces the hand-rolled hex encoder in `deriveWalletFilePassword` with `base16.stringify(...).toLowerCase()` from rfc4648, which is the encoding library the rest of the stack standardizes on. Output is byte-identical for every input -- both produce the lowercase hex of the same 16 digest bytes -- and the unchanged golden-vector test enforces that: any drift here would orphan every wallet file the 0.4.0 migration re-keyed. Strictly cosmetic; no changelog entry. The `utf8Bytes` docstring also grows a paragraph on why the encoder avoids host globals. rfc4648 joins dependencies and the lockfile is regenerated with it, so `npm ci` stays reproducible. Ported from EdgeApp/react-native-zano#17. Co-authored-by: Jonathan Tzeng --- package-lock.json | 5 +++-- package.json | 1 + src/walletFilePassword.ts | 23 +++++++++++------------ 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index c153f46..3e015e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "BSD-3-Clause", "dependencies": { "cleaners": "^0.3.17", + "rfc4648": "^1.5.4", "tweetnacl": "^1.0.3" }, "devDependencies": { @@ -4056,8 +4057,8 @@ "node_modules/rfc4648": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/rfc4648/-/rfc4648-1.5.4.tgz", - "integrity": "sha1-EXTAr7pyQjoLcMOG7P64CqYbBco= sha512-rRg/6Lb+IGfJqO05HZkN50UtY7K/JhxJag1kP23+zyMfrvoB0B7RWv06MbOzoc79RgCdNTiUaNsTT1AJZ7Z+cg==", - "dev": true + "integrity": "sha512-rRg/6Lb+IGfJqO05HZkN50UtY7K/JhxJag1kP23+zyMfrvoB0B7RWv06MbOzoc79RgCdNTiUaNsTT1AJZ7Z+cg==", + "license": "MIT" }, "node_modules/rfdc": { "version": "1.4.1", diff --git a/package.json b/package.json index f0c5718..8f731a4 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ }, "dependencies": { "cleaners": "^0.3.17", + "rfc4648": "^1.5.4", "tweetnacl": "^1.0.3" } } diff --git a/src/walletFilePassword.ts b/src/walletFilePassword.ts index 7bf932c..f59efd5 100644 --- a/src/walletFilePassword.ts +++ b/src/walletFilePassword.ts @@ -1,3 +1,4 @@ +import { base16 } from 'rfc4648' import nacl from 'tweetnacl' /** @@ -17,17 +18,15 @@ const DOMAIN = 'react-native-zano:file-password:v1' */ const PASSWORD_BYTES = 16 -const HEX = '0123456789abcdef' - -function toHex(data: Uint8Array): string { - let out = '' - for (let i = 0; i < data.length; ++i) { - out += HEX[data[i] >> 4] + HEX[data[i] & 0x0f] - } - return out -} - -/** Encodes a string as UTF-8 without relying on Buffer or TextEncoder. */ +/** + * Encodes a string as UTF-8 without relying on Buffer or TextEncoder. + * + * This module runs both on the React Native JS thread and inside + * edge-currency-accountbased's plugin WebView, and `TextEncoder` is not + * guaranteed in every engine and polyfill combination those present. The + * derivation must produce identical bytes everywhere, forever, so it does + * not depend on a host global that may be absent. + */ function utf8Bytes(text: string): Uint8Array { const out: number[] = [] for (let i = 0; i < text.length; ++i) { @@ -74,5 +73,5 @@ function utf8Bytes(text: string): Uint8Array { export function deriveWalletFilePassword(mnemonic: string): string { const normalized = mnemonic.trim().replace(/\s+/g, ' ') const digest = nacl.hash(utf8Bytes(`${DOMAIN}|${normalized}`)) - return toHex(digest.subarray(0, PASSWORD_BYTES)) + return base16.stringify(digest.subarray(0, PASSWORD_BYTES)).toLowerCase() } From c9947d52ae79304153d48f9f702f17cbcebd816b Mon Sep 17 00:00:00 2001 From: peachbits Date: Mon, 24 Aug 2026 14:25:05 -0700 Subject: [PATCH 4/4] changed: Remove the transfer paymentId option Zano HF6 moved payment ids into the transaction outputs: each integrated destination's embedded id is attached natively, one per output, and the wallet RPC rejects any non-empty request-level `payment_id` outright (WALLET_RPC_ERROR_CODE_WRONG_PAYMENT_ID, "tx-wide payment id you provided is now deprecated"). The shipped 0.4.0 SDK enforces this whether or not the fork has activated. The one real defect was forwarding: a caller-supplied `opts.paymentId` went out on the wire and the node refused the send with the deprecation error. The per-destination validation loop next to it, meanwhile, was inert: it gated on `addressInfo.is_integrated`, a field the native `get_address_info` has never returned -- the real response carries `payment_id` only as a boolean presence flag (plain_wallet_api.cpp:484, "lazy to make struct for it") -- so the loop never resolved an id, never threw its one-id-per-transaction error, and only cost a native round-trip per recipient on every send. There is no longer anything a request-level id can express, so the option is removed rather than kept as decoration: the field is always sent empty, and a caller holding a separate payment id folds it into an integrated destination address before calling -- which is what edge-currency-accountbased now does on the user's behalf, offline via zano-utils-js. The dead loop goes with it, along with the fictional `is_integrated`/string-`payment_id` declarations in `AddressInfo` and the test fake, which now match the real native shape. The fake also models the transfer path (`asyncCall`/`tryPullResult`, `invoke`), which is the gap that let the forwarding go untested: tests pin that nothing non-empty ever reaches the wire, that multi-destination sends pass, and that the send path makes no address lookups. Replaces the `payment_id: paymentId ?? ''` change proposed in EdgeApp/react-native-zano#17 -- which was itself a no-op, since the loop feeding `paymentId` never assigned it -- with the removal the HF6 contract actually calls for. References: - Zano HF6 migration guide, "What is intrinsic payment id": https://docs.zano.org/docs/build/exchange-guidelines/HF6-migration-guide/ - wallet_rpc_server.cpp on_transfer (rejection at the pinned SDK) --- CHANGELOG.md | 2 ++ src/CppBridge.ts | 23 ++++++-------- src/types.ts | 10 +++++-- test/fakeZanoModule.ts | 38 +++++++++++++++++++++++ test/transfer.test.ts | 68 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 124 insertions(+), 17 deletions(-) create mode 100644 test/transfer.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d61871..5c0b8f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## Unreleased - added: `runWallet`, which starts the refresh worker for an open wallet. `startWallet` rethrows `ALREADY_EXISTS` for its caller to adopt the already-open wallet, and an adopted wallet does not sync until it is run, so adopting callers need this without reimplementing the raw `run_wallet` response contract. +- changed: `transfer` no longer takes a `paymentId`, and never sends the request-level payment id. Zano HF6 deprecated the transaction-wide payment id -- the node rejects any non-empty value -- and instead delivers payment ids per destination, embedded in integrated addresses, which the wallet attaches natively. Forwarding the option therefore failed every send that carried one. A caller holding a separate payment id must fold it into an integrated destination address before calling. The per-destination address-info loop is also gone: it read fields the native `get_address_info` has never returned, so it never did anything except spend a native round-trip per recipient. +- changed: The `AddressInfo` type now matches the native response: `payment_id` is a boolean presence flag, and `is_integrated` does not exist. Both were previously declared with shapes no native version ever produced. - fixed: `startWallet` no longer freezes the app while a wallet catches up on blocks. Opening a wallet auto-started its refresh worker, which holds the per-wallet lock for the entire first scan, so the 0.4.0 re-key migration's `resetWalletPassword` blocked on that lock for the whole catch-up (minutes to hours) while sitting on React Native's shared native-module queue, and on iOS every native call in the app queued behind it. Wallets now open with the refresh worker postponed, the migration completes in milliseconds, and the worker is started explicitly for the one wallet `startWallet` returns. - fixed: Creating a wallet no longer freezes the app. `generateSeedPhrase` opened its temporary wallet without postponing the refresh worker, so the `closeWallet` that follows waited on the per-wallet lock, on the shared native-module queue, for the length of a refresh. A close that does not report OK now fails the call rather than deleting a file this process still holds open. - fixed: The iOS module now withholds its document directory when the wallet directory cannot be created or excluded from device backups, so the bridge fails at construction instead of letting the SDK write seed and spend keys somewhere a backup would capture. diff --git a/src/CppBridge.ts b/src/CppBridge.ts index cacabd0..b5a5bd1 100644 --- a/src/CppBridge.ts +++ b/src/CppBridge.ts @@ -752,19 +752,6 @@ export class CppBridge { } async transfer(walletId: number, opts: TransferParams): Promise { - // Transaction can only have one payment ID - let paymentId = opts.paymentId - for (const transfer of opts.transfers) { - const addressInfo = await this.getAddressInfo(transfer.recipient) - if (!addressInfo.is_integrated) continue - - if (paymentId == null) { - paymentId = addressInfo.payment_id - } else if (paymentId !== addressInfo.payment_id) { - throw new Error('Transaction can only have one payment ID') - } - } - const params = { method: 'transfer', params: { @@ -776,7 +763,15 @@ export class CppBridge { comment: opts.comment, fee: opts.fee, - payment_id: opts.paymentId ?? '', + + // Since HF6, payment ids travel inside integrated addresses, one + // per destination, and the wallet attaches each embedded id + // natively -- a single transaction may pay several integrated + // addresses carrying different ids. This request-level field is the + // old transaction-wide mechanism, and the node rejects any + // non-empty value outright. A caller with a separate payment id + // must fold it into an integrated destination address first. + payment_id: '', hide_receiver: true, mixin: 15, diff --git a/src/types.ts b/src/types.ts index d3e68a1..ec396dd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -43,11 +43,16 @@ export interface WalletFiles { items: string[] } +/** + * The shape `plain_wallet::get_address_info` actually returns. Note that + * `payment_id` is a boolean presence flag, not the id itself, and there is + * no `is_integrated` field -- the earlier declaration of both was fiction + * that no native version ever produced. + */ export interface AddressInfo { valid: boolean auditable: boolean - is_integrated: boolean - payment_id?: string + payment_id: boolean wrap: boolean } @@ -270,7 +275,6 @@ export interface TransferParams { comment?: string fee: number - paymentId?: string } export interface TransferResponse { diff --git a/test/fakeZanoModule.ts b/test/fakeZanoModule.ts index a8e321d..31d735e 100644 --- a/test/fakeZanoModule.ts +++ b/test/fakeZanoModule.ts @@ -43,6 +43,12 @@ export interface FakeState { * loses its open-wallet table when the app dies. */ openWallets?: Map + /** + * Filled in by the fake: the `params` object of every `transfer` request + * that reached the wallet RPC, so tests can assert what would have gone + * out on the wire. + */ + transferRequests?: any[] } export const FAKE_ADDRESS = 'ZxFakeAddress' @@ -91,6 +97,14 @@ export function makeFakeZanoModule(state: FakeState): NativeZanoModule { const openPaths = new Map() state.openWallets = openPaths + const transferRequests: any[] = [] + state.transferRequests = transferRequests + + // Results for the async job protocol: `asyncCall` runs the work up front + // and parks the payload here; `tryPullResult` delivers it on first pull. + const jobResults = new Map() + let nextJobId = 1 + const methods: { [name: string]: (args: string[]) => string } = { @@ -182,6 +196,30 @@ export function makeFakeZanoModule(state: FakeState): NativeZanoModule { return state.files.has(storagePath) ? '1' : '0' }, + asyncCall: ([methodName, , params]) => { + if (methodName !== 'invoke') { + throw new Error(`No fake for asyncCall method ${methodName}`) + } + const request = JSON.parse(params) + if (request.method !== 'transfer') { + throw new Error(`No fake for invoke method ${String(request.method)}`) + } + transferRequests.push(request.params) + + const jobId = nextJobId++ + jobResults.set( + jobId, + JSON.parse(ok({ tx_hash: 'FAKE_TX_HASH', tx_size: 1 })) + ) + return JSON.stringify({ job_id: jobId }) + }, + + tryPullResult: ([jobIdText]) => { + const result = jobResults.get(Number(jobIdText)) + if (result == null) return JSON.stringify({ status: 'canceled' }) + return JSON.stringify({ status: 'delivered', result }) + }, + syncCall: ([methodName, instanceIdText]) => { if (methodName === 'configure') { if (state.configureResult != null) return state.configureResult diff --git a/test/transfer.test.ts b/test/transfer.test.ts new file mode 100644 index 0000000..33642ed --- /dev/null +++ b/test/transfer.test.ts @@ -0,0 +1,68 @@ +import { strict as assert } from 'assert' + +import { CppBridge } from '../src/CppBridge' +import { FakeState, makeFakeZanoModule } from './fakeZanoModule' + +// Destination addresses are opaque strings to this layer: since HF6 the +// native wallet resolves any embedded payment id itself, so the bridge +// neither parses nor looks up addresses. +const INTEGRATED_A = 'iZFakeIntegratedA' +const INTEGRATED_B = 'iZFakeIntegratedB' + +const makeState = (): FakeState => ({ + calls: [], + files: new Map() +}) + +const makeBridge = (state: FakeState): CppBridge => + new CppBridge(makeFakeZanoModule(state)) + +const sendTo = ( + recipients: string[] +): { + transfers: Array<{ assetId: string; nativeAmount: number; recipient: string }> + fee: number +} => ({ + transfers: recipients.map(recipient => ({ + assetId: 'ASSET', + nativeAmount: 100, + recipient + })), + fee: 10 +}) + +describe('transfer', () => { + it('never sends a request-level payment id', async () => { + // Since HF6 the node rejects any non-empty tx-wide `payment_id`; the id + // rides inside the integrated address and native attaches it. + const state = makeState() + const bridge = makeBridge(state) + + const txHash = await bridge.transfer(1, sendTo([INTEGRATED_A])) + + assert.equal(txHash, 'FAKE_TX_HASH') + assert.equal(state.transferRequests?.length, 1) + assert.equal(state.transferRequests?.[0].payment_id, '') + assert.equal( + state.transferRequests?.[0].destinations[0].address, + INTEGRATED_A + ) + // The id needs no resolution here at all; the send path makes no + // per-destination getAddressInfo round-trips: + assert.ok(!state.calls.join('\n').includes('getAddressInfo')) + }) + + it('allows several integrated destinations with different ids', async () => { + // Legal since HF6: one transaction may pay multiple integrated + // addresses, each output carrying its own intrinsic id. The old + // one-id-per-transaction rule predates that. + const state = makeState() + const bridge = makeBridge(state) + + await bridge.transfer(1, sendTo([INTEGRATED_A, INTEGRATED_B])) + + assert.equal(state.transferRequests?.length, 1) + assert.equal(state.transferRequests?.[0].payment_id, '') + assert.equal(state.transferRequests?.[0].destinations.length, 2) + }) +})