Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

- fixed: Android runs native Zano calls on a dedicated thread instead of React Native's shared native-modules thread. On the legacy architecture that shared thread also executes UIManager's view commands, so a Zano call that blocked in C++ froze every view update in the app -- taps dead, screens frozen -- while native scrolling kept working. A blocked call now stalls only Zano.
- fixed: Closing a wallet while it is catching up on blocks no longer deadlocks the native wallet manager permanently on Android. The SDK's `close_wallet` held the wallet-manager lock while waiting out the wallet's refresh worker, which needed that same lock to reach its stop flags, so the close, the worker, and every Zano call after them hung forever; the app's periodic mid-sync saves tripped this within minutes of importing a wallet. The Android build now rewrites `close_wallet` to detach the wallet from the manager before waiting on it. iOS links the prebuilt Zano framework and runs the same cycle without wedging, so it is unchanged.
- fixed: A Zano wallet file the SDK cannot read -- typically zero bytes, left by a crash during the file's very first write -- no longer traps the wallet in a permanent open-retry loop. Such a file fails with `INVALID_FILE` before any password is consulted, which bypassed the password-recovery ladder entirely, so the engine retried the same doomed open every second forever. The file is now deleted and rebuilt from the mnemonic, costing one re-scan, under the same policy as a file no password opens: a wallet with a seed passphrase still refuses, since an unreadable file cannot corroborate the passphrase and a wrong one would rebuild a different wallet.

## 0.5.0 (2026-08-25)

- 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.
Expand Down
32 changes: 27 additions & 5 deletions android/src/main/java/app/edge/rnzano/RnZanoModule.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,25 @@
import com.facebook.react.bridge.ReactMethod;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class RnZanoModule extends ReactContextBaseJavaModule {
// Zano calls run on their own thread, never on the caller's. On the
// legacy architecture every @ReactMethod runs on the shared
// mqt_native_modules thread, which also executes UIManager's view
// commands -- so a Zano call that blocks in C++ (a close waiting out a
// scan chunk, or the SDK's close-during-scan lock inversion) froze every
// view update in the app. Static, because the Zano SDK is one instance
// per process while React module instances are not: a JS reload builds a
// new module while a call from the old one may still be draining, and
// per-instance threads would let both drive the SDK at once. One
// process-wide thread keeps the strict global call ordering, across
// reloads too. Named, because this freeze was diagnosed from thread
// dumps, and "pool-1-thread-1" will not identify itself in the next one.
private static final ExecutorService executor =
Executors.newSingleThreadExecutor(r -> new Thread(r, "zano"));

private native String callZanoJNI(String method, String[] arguments);

private native String[] getMethodNames();
Expand Down Expand Up @@ -42,10 +59,15 @@ public void callZano(String method, ReadableArray arguments, Promise promise) {
strings[i] = arguments.getString(i);
}

try {
promise.resolve(callZanoJNI(method, strings));
} catch (Exception e) {
promise.reject("ZanoError", e);
}
executor.execute(() -> {
try {
promise.resolve(callZanoJNI(method, strings));
} catch (Throwable e) {
// Throwable, not Exception: off the bridge thread an escaping Error
// reaches the default uncaught handler and kills the process with
// the promise unsettled; RN's own dispatcher used to catch it.
promise.reject("ZanoError", e);
}
});
}
}
12 changes: 12 additions & 0 deletions scripts/update-sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { cpus } from 'os'
import { join } from 'path'

import { getNdkPath } from './utils/android-tools'
import { patchCloseWallet } from './utils/closeWalletPatch'
import {
captureExec,
fileExists,
Expand Down Expand Up @@ -81,6 +82,17 @@ async function downloadSources(): Promise<void> {
const mdPath = join(tmpPath, 'zano_native_lib/Zano/src/crypto/RIPEMD160.h')
const mdText = await readFile(mdPath, 'utf8')
await writeFile(mdPath, '#define compress md_compress\n' + mdText)

// Rework close_wallet so it does not hold the wallet-manager lock while
// it waits on the wallet, which permanently deadlocks the manager when a
// wallet is closed mid-scan (see closeWalletPatch.ts). Only the Android
// libraries build from these sources; iOS links the prebuilt framework:
const wmPath = join(
tmpPath,
'zano_native_lib/Zano/src/wallet/wallets_manager.cpp'
)
const wmText = await readFile(wmPath, 'utf8')
await writeFile(wmPath, patchCloseWallet(wmText))
}

/**
Expand Down
225 changes: 225 additions & 0 deletions scripts/utils/closeWalletPatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
const patchMarker = 'Edge patch: close-during-refresh deadlock'

const hint =
'The SDK sources changed at this pin. If upstream has fixed the ' +
'close-during-refresh lock inversion, delete this patch; otherwise port ' +
'it to the new body.'

/**
* `close_wallet` as pinned at zano_native_lib 91085c0, modulo invisible
* trailing whitespace. The transform refuses to run unless the function it
* found matches this byte-for-byte after trailing whitespace is stripped,
* so ANY upstream drift -- not just drift through the lines the deadlock
* analysis rests on -- fails the build for a human to re-evaluate.
*/
const original = `std::string wallets_manager::close_wallet(size_t wallet_id)
{
EXCLUSIVE_CRITICAL_REGION_LOCAL(m_wallets_lock);

auto it = m_wallets.find(wallet_id);
if (it == m_wallets.end())
return API_RETURN_CODE_WALLET_WRONG_ID;


try
{
it->second.major_stop = true;
it->second.stop_for_refresh = true;
it->second.w.unlocked_get()->stop();

it->second.w->get()->store();
m_wallets.erase(it);
{
CRITICAL_REGION_LOCAL(m_wallet_log_prefixes_lock);
m_wallet_log_prefixes[wallet_id] = std::string("[") + epee::string_tools::num_to_string_fast(wallet_id) + ":CLOSED] ";
}
}

catch (const std::exception& e)
{
return std::string(API_RETURN_CODE_FAIL) + ":" + e.what();
}
catch (...)
{
return API_RETURN_CODE_INTERNAL_ERROR;
}
//m_pview->hide_wallet();
return API_RETURN_CODE_OK;
}`

/**
* The replacement `close_wallet`. The pinned original holds
* `m_wallets_lock` exclusively across two waits on the wallet being
* closed: the `store()` call, which needs the per-wallet lock the refresh
* worker holds for the whole of a scan chunk, and the map erase, whose
* destructor joins the worker thread. The refresh path re-enters the
* manager through wallet callbacks (`on_transfer2`, `on_transfer_canceled`)
* that take `m_wallets_lock` shared, and every other API call takes it
* shared up front -- so a close issued while the wallet is catching up
* wedges the worker, the close, and then every Zano call in the process,
* permanently. Thread dumps of the frozen app show the close parked inside
* the native call, and no checkpoint ever lands again.
*
* The rewrite detaches the map node while holding the lock, then does the
* slow parts -- the store and the implicit thread join when the node dies
* -- with no manager lock held at all. Node extraction keeps the element
* at its address, so the worker thread's references stay valid; wallet ids
* are never reused, so late callbacks cannot hit a recycled id. The
* log-prefix write gains a bounds check, because the manager lock no
* longer serializes it against `reset()`'s vector clear.
*
* Deliberate semantics changes, all confined to the window after the node
* leaves the map:
*
* - "Absent from `m_wallets`" no longer implies "closed and stored".
* During the store/join window the wallet is invisible to
Comment thread
j0ntz marked this conversation as resolved.
* `open_wallet`'s ALREADY_EXISTS check and to status calls while its
* `wallet2` still writes the file. Safe for this bridge, which strictly
* sequences close-before-reopen per wallet on one executor thread; a
* caller without that discipline could double-open the file. Say so in
* any upstream submission.
* - The refresh worker's own callbacks miss where they used to hit. Both
* `on_transfer2` and `on_transfer_canceled` look the wallet up by id
* under a shared `m_wallets_lock` while the store runs, and the entry is
* no longer there. Both lookups are checked -- `on_transfer2` through
* `GET_WALLET_OPTIONS_BY_ID_VOID_RET`, which returns on a miss, and
* `on_transfer_canceled` with an explicit `end()` test that logs and
* returns -- so the cost is a dropped view notification for a wallet
* that is closing anyway, not an unchecked dereference. Every
* `m_wallets.find` in the file is checked this way; the three bare
* `m_wallets[...]` sites all insert a freshly counted id in the open,
* restore and generate paths, and ids are never reused.
* - When `store()` throws, the original left the entry in the map as a
* zombie (its stop flags already set, so it could never sync again);
* the rewrite reports the same error but the wallet is gone. A
* `closeWallet` retry therefore reports WALLET_WRONG_ID instead of
* retrying the store, so `CppBridge`'s re-key migration takes its
* "leave the file alone" branch with the wallet already released; the
* next launch retries the migration.
*
* iOS links the prebuilt `libzano-plain-wallet` framework and keeps the
* original blocking semantics throughout.
*/
const replacement = `std::string wallets_manager::close_wallet(size_t wallet_id)
{
// ${patchMarker}.
//
// The original held m_wallets_lock exclusively across store() and across
// the map erase, whose destructor joins the refresh worker. Both wait on
// a wallet that may be mid-refresh, and the refresh path re-enters the
// manager through wallet callbacks that take m_wallets_lock shared -- so
// a close issued during a long refresh held the very lock the worker
// needed to reach its stop flags, and neither side could ever proceed.
// Detach the map node under the lock instead, then store and join with
// no manager lock held. The node is declared outside the try so its
// destructor -- which joins the worker thread the stop flags told to
// finish -- runs at function exit rather than during unwinding, while
// everything that can throw stays inside the try, so failures keep
// reporting as return codes exactly as they did before this patch.
decltype(m_wallets)::node_type wallet_node;
try
{
{
EXCLUSIVE_CRITICAL_REGION_LOCAL(m_wallets_lock);

auto it = m_wallets.find(wallet_id);
if (it == m_wallets.end())
return API_RETURN_CODE_WALLET_WRONG_ID;

it->second.major_stop = true;
it->second.stop_for_refresh = true;
it->second.w.unlocked_get()->stop();
wallet_node = m_wallets.extract(it);
}

wallet_node.mapped().w->get()->store();
{
CRITICAL_REGION_LOCAL(m_wallet_log_prefixes_lock);
// The manager lock no longer serializes this write against reset()'s
// clear of the vector, so respect its current size:
if (wallet_id < m_wallet_log_prefixes.size())
m_wallet_log_prefixes[wallet_id] = std::string("[") + epee::string_tools::num_to_string_fast(wallet_id) + ":CLOSED] ";
}
}
catch (const std::exception& e)
{
return std::string(API_RETURN_CODE_FAIL) + ":" + e.what();
}
catch (...)
{
return API_RETURN_CODE_INTERNAL_ERROR;
}
//m_pview->hide_wallet();
return API_RETURN_CODE_OK;
}`

/** Strips trailing whitespace per line, the one formatting freedom the
* comparison allows -- the pinned file carries an invisible trailing
* space that transcriptions of it should not have to reproduce. `\r` is
* in the class so a CRLF checkout reports the drift it has, rather than
* one carriage return per line. */
function normalize(code: string): string {
return code
.split('\n')
.map(line => line.replace(/[ \t\r]+$/, ''))
.join('\n')
}

/**
* Rewrites the SDK's `wallets_manager::close_wallet` so it does not hold
* the wallet-manager lock while waiting on the wallet being closed. See
* the comment on `replacement` above for the deadlock this removes and
* the semantics it deliberately changes.
*
* Only the Android libraries pick this up: iOS links the prebuilt
* `libzano-plain-wallet` xcframework rather than building these sources,
* and iOS runs the same close-during-catch-up cycle without wedging.
*
* The function is located by its unique signature, delimited by brace
* counting, and then required to match the pinned original exactly
* (modulo trailing whitespace), so a pin bump that changes `close_wallet`
* in any way fails the build here instead of silently keeping (or
* dropping) a stale patch. The brace counter would be fooled by a brace
* inside a string literal, but the full-body comparison catches that case
* too: a mis-delimited body cannot match the original.
*
* @param text - The contents of the SDK's `wallets_manager.cpp`.
* @returns The patched contents. Already-patched input comes back
* unchanged, so the caller does not need to track whether it ran.
*/
export function patchCloseWallet(text: string): string {
if (text.includes(patchMarker)) return text

const anchor = 'std::string wallets_manager::close_wallet(size_t wallet_id)'
const start = text.indexOf(anchor)
if (start < 0 || text.includes(anchor, start + 1)) {
throw new Error(
`Cannot find a unique wallets_manager::close_wallet to patch. ${hint}`
)
}

// Take the whole function by brace balance:
let depth = 0
let end = -1
for (let i = text.indexOf('{', start); i >= 0 && i < text.length; ++i) {
if (text[i] === '{') ++depth
if (text[i] === '}' && --depth === 0) {
end = i + 1
break
}
}
if (end < 0) {
throw new Error(
`Cannot delimit the body of wallets_manager::close_wallet. ${hint}`
)
}

if (normalize(text.slice(start, end)) !== normalize(original)) {
throw new Error(
'wallets_manager::close_wallet does not match the pinned original ' +
`this patch was written against. ${hint}`
)
}

return text.slice(0, start) + replacement + text.slice(end)
}
37 changes: 32 additions & 5 deletions src/CppBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ function isWrongPassword(error: unknown): boolean {
return error instanceof ZanoError && error.code === 'WRONG_PASSWORD'
}

function isInvalidFile(error: unknown): boolean {
return error instanceof ZanoError && error.code === 'INVALID_FILE'
}

function isAlreadyExists(error: unknown): boolean {
return error instanceof ZanoError && error.code === 'ALREADY_EXISTS'
}
Expand Down Expand Up @@ -438,11 +442,13 @@ export class CppBridge {
* for both roles, so files written by them were keyed with the seed
* passphrase -- the empty string for most wallets. A file still encrypted
* that way is re-keyed in place the first time it opens. A file that no
* known password opens is deleted and rebuilt from the mnemonic, costing
* one re-scan -- but only for a wallet with no seed passphrase. With one
* set, the passphrase is far and away the likeliest thing to be wrong, and
* rebuilding would restore a different wallet over a file that was intact,
* so that case throws instead.
* known password opens, or that the SDK cannot parse a wallet out of at
* all -- the leavings of a crash during the file's first write -- is
* deleted and rebuilt from the mnemonic, costing one re-scan -- but only
* for a wallet with no seed passphrase. With one set, the passphrase is
* far and away the likeliest thing to be wrong, and rebuilding would
* restore a different wallet over a file that was intact, so that case
* throws instead.
*
* The migration is decided entirely by what the file does, so it is
* idempotent and self-healing: an interrupted re-key leaves the file on
Expand Down Expand Up @@ -522,6 +528,27 @@ export class CppBridge {
try {
return await started(await openWith(filePassword))
} catch (error: unknown) {
// A file the SDK cannot parse a wallet header out of -- zero bytes
// after a crash during its first write, or other corruption -- fails
// with INVALID_FILE before any password is consulted, so the password
// ladder below has nothing to probe. Without this branch the error
// propagated as-is and the engine retried the same doomed open
// forever. Route it to the same policy as a file no password opens:
// without a passphrase, the rebuild recreates the identical wallet at
// the cost of a re-scan; with one, an unreadable file cannot
// corroborate the passphrase, and a wrong one would rebuild a
// different wallet, so refuse.
if (isInvalidFile(error)) {
if (seedPassword !== '') {
throw new Error(
'The Zano wallet file is unreadable, and cannot be rebuilt ' +
'because a seed passphrase is set'
)
}
log('Zano wallet file is unreadable, rebuilding it')
return await started(await rebuild())
}

// Anything other than a bad password -- including ALREADY_EXISTS,
// which callers recover from by adopting the open wallet -- is not
// ours to handle.
Expand Down
Loading