Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 22 additions & 6 deletions ios/ZanoModule.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand All @@ -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
Expand All @@ -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]
};
}

Expand Down
5 changes: 3 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
},
"dependencies": {
"cleaners": "^0.3.17",
"rfc4648": "^1.5.4",
"tweetnacl": "^1.0.3"
}
}
167 changes: 143 additions & 24 deletions src/CppBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,13 @@ export interface NativeZanoModule {
readonly callZano: (name: string, jsonArguments: string[]) => Promise<string>

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 {
Expand All @@ -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
}

Expand All @@ -66,7 +91,7 @@ export class CppBridge {
): Promise<JsonRpc<ReturnCode>> {
const response = await this.module.callZano('init', [
rpcAddress,
this.module.documentDirectory,
this.documentDirectory,
logLevel.toFixed()
])
return JSON.parse(response)
Expand All @@ -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)
Expand Down Expand Up @@ -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<JsonRpc<WalletDetails>> {
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,
Expand All @@ -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
Expand Down Expand Up @@ -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<void> {
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<void> {
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<boolean> {
const response = await this.module.callZano('isWalletExist', [
this.module.documentDirectory + '/wallets/' + path
this.documentDirectory + '/wallets/' + path
])
return response === '1'
}
Expand Down Expand Up @@ -302,10 +398,25 @@ export class CppBridge {
): Promise<WalletDetails> {
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
Expand Down Expand Up @@ -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()
Comment thread
j0ntz marked this conversation as resolved.
const started = async (wallet: WalletDetails): Promise<WalletDetails> => {
await this.runWallet(wallet.wallet_id)
return wallet
}

const openWith = async (password: string): Promise<WalletDetails> => {
const wallet = this.expectWallet(
this.handleRpcResponse(await this.open(storagePath, password))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -501,7 +625,7 @@ export class CppBridge {
}

log('Rebuilding the Zano wallet file')
return await rebuild()
return await started(await rebuild())
}
}

Expand All @@ -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<string> {
Expand Down Expand Up @@ -628,19 +752,6 @@ export class CppBridge {
}

async transfer(walletId: number, opts: TransferParams): Promise<string> {
// 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: {
Expand All @@ -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,
Expand Down
Loading