diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ab4a9d..5c0b8f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## 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. + ## 0.4.0 (2026-08-14) - added: `startWallet` accepts an optional `log` callback that reports wallet-file recovery and migration events. 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/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/CppBridge.ts b/src/CppBridge.ts index 3a0ab4a..b5a5bd1 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,8 +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 } @@ -66,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) @@ -80,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) @@ -155,11 +180,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 +211,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,9 +279,64 @@ 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 + this.documentDirectory + '/wallets/' + path ]) return response === '1' } @@ -302,10 +398,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 +457,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 +516,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 +574,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 +625,7 @@ export class CppBridge { } log('Rebuilding the Zano wallet file') - return await rebuild() + return await started(await rebuild()) } } @@ -522,7 +646,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 { @@ -628,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: { @@ -652,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/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() } diff --git a/test/fakeZanoModule.ts b/test/fakeZanoModule.ts index c16e08a..31d735e 100644 --- a/test/fakeZanoModule.ts +++ b/test/fakeZanoModule.ts @@ -22,11 +22,33 @@ 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 + /** + * 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' @@ -63,6 +85,26 @@ 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 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 } = { @@ -78,10 +120,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 +141,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 +155,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 +179,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 +194,49 @@ 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' + }, + + 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 + // 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..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() @@ -37,18 +52,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 +80,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 +192,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 +248,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 +313,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 +353,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 +383,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 +434,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 +457,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')) + }) }) 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) + }) +})