Skip to content
Closed
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: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# react-native-zano

## Unreleased
## 0.4.1 (unreleased)

- 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.
- fixed: A transfer to an integrated address with no explicit payment id now broadcasts the payment id resolved from that address instead of an empty one.
- 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)

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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "react-native-zano",
"version": "0.4.0",
"version": "0.4.1",
"description": "React Native bindings for the Zano blockchain",
"homepage": "https://github.com/EdgeApp/react-native-zano",
"repository": {
Expand Down Expand Up @@ -57,6 +57,7 @@
},
"dependencies": {
"cleaners": "^0.3.17",
"rfc4648": "^1.5.4",
"tweetnacl": "^1.0.3"
}
}
91 changes: 84 additions & 7 deletions src/CppBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,20 @@ export class CppBridge {
private readonly module: NativeZanoModule

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.module = zanoModule
}

Expand Down Expand Up @@ -238,6 +252,37 @@ 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. Requires `init`
* to have run.
*/
private async configurePostponedRun(): Promise<void> {
const response = await this.syncCall(
'configure',
0,
JSON.stringify({ postponed_run_wallet: true })
)
const parsed: { status?: string } = JSON.parse(response)
if (parsed.status !== 'OK') {
throw new Error(`Zano configure returned ${response}`)
}
}

/**
* Starts the refresh worker for an open wallet. Idempotent: the native
* side skips the spawn when the worker is already running.
*/
private async runWallet(walletId: number): Promise<void> {
const response = await this.syncCall('run_wallet', walletId, '')
const parsed: { error_code?: string } = JSON.parse(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
Expand Down Expand Up @@ -302,10 +347,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 +406,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
}
Comment thread
j0ntz marked this conversation as resolved.

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

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

Expand All @@ -522,7 +595,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 @@ -652,7 +725,11 @@ export class CppBridge {

comment: opts.comment,
fee: opts.fee,
payment_id: opts.paymentId ?? '',
// The loop above resolves this from an integrated address when the
// caller did not pass one; sending `opts.paymentId` here would drop
// that and broadcast an integrated-address transfer with no payment
// id, which the receiver needs to credit the deposit.
payment_id: paymentId ?? '',

hide_receiver: true,
mixin: 15,
Expand Down
Loading