Skip to content

Postpone the Zano refresh worker and drop the tx-wide payment id - #18

Merged
peachbits merged 4 commits into
mainfrom
postponed-refresh
Aug 26, 2026
Merged

Postpone the Zano refresh worker and drop the tx-wide payment id#18
peachbits merged 4 commits into
mainfrom
postponed-refresh

Conversation

@peachbits

@peachbits peachbits commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

CHANGELOG

Does this branch warrant an entry to the CHANGELOG?

  • Yes
  • No

Dependencies

none — but EdgeApp/edge-currency-accountbased#1092 consumes this and should be
reviewed alongside it. That PR needs a release of this one before it can bump
its react-native-zano pin.

Description

Addresses the performance regression QA found after the HF6 merge, plus the
HF6 payment-id correctness gap.

Related: #17 — this branch carries forward the correct parts of that PR and
takes a different approach where that one was incorrect. Left open for now;
no action taken on it.

Postpone the refresh worker through startWallet and generateSeedPhrase

Opening a Zano wallet auto-starts the SDK refresh worker, and that worker holds
the per-wallet epee::locked_object recursive mutex for the entire first
catch-up scan — minutes for a wallet weeks behind. Because React Native runs
every TurboModule call on one shared dispatch queue, anything that then touches
the wallet blocks the whole app's native calls behind that scan.

This opens with postponed_run_wallet and starts the worker explicitly, once
the wallet being returned is the one that should sync. That gives callers a
window to do their setup — the file re-key in particular — before the scan owns
the lock, and gives the engine an actual stop path.

Remove the transfer payment-id option, never send the tx-wide id

Since HF6 the network rejects the transaction-wide payment id outright; ids
travel inside integrated addresses and the wallet attaches each destination's
embedded id natively. transfer now always sends payment_id: '' and the
per-destination loop is gone. TransferParams.paymentId is deleted rather than
deprecated, so callers get a compile error instead of a silently dropped id.

Also corrects AddressInfo to the real native shape — payment_id is a
presence boolean, not the id, and there is no is_integrated field.

Fail closed when the iOS wallet directory cannot be protected

prepareZanoDirectory now returns a status and constantsToExport withholds
documentDirectory when the wallets directory could not be prepared, rather
than handing out a path with no protection applied. The JS constructor throws on
a missing directory instead of writing wallets somewhere unintended.

Encode the derived file password with rfc4648

Cosmetic — uses the shared encoder rather than a bespoke one.

Testing

Unit tests cover the JS logic against a fake native module. Beyond that, this
was run on device in a three-stage ladder on an account with four mainnet Zano
wallets created April–May 2025, each carrying a ~760k-block catch-up window.
Each stage used the same wallet-file fixture; stages 2 and 3 are the same GUI
commit differing only in which packages are installed, so the comparison
isolates these changes.

Confirmed the mechanism on device — with the published packages, the refresh
worker sits in wallet2::refreshpull_blocks for 2269 of 2269 thread
samples, and nothing is written to the wallet file across 8.5 minutes and
~72,000 blocks scanned.

With this branch (plus EdgeApp/edge-currency-accountbased#1092), all four
wallets persisted
partial progress and resumed forward across a hard kill instead of rescanning.
App responsiveness was unchanged — Zano-attributable mutex waits stayed at 0–1
frames per sample in both stages.

Not covered by that run: the startup freeze path, which needs an account whose
wallets are not already marked in securityCheckedWallets, and the
legacy→HF6 wallet-file migration, which needs a fixture holding real scan
history. Both are being followed up separately.


@j0ntz j0ntz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed alongside EdgeApp/edge-currency-accountbased#1092. Nothing I found blocks the merge; all three notes below are suggestions. I checked the rfc4648 swap against the old hand-rolled encoder specifically: base16.stringify uppercases and the .toLowerCase() restores the previous output, so existing wallet files still open.

Separately, all four commit subjects are over the 50-character Edge limit that Paul enforces in review (54 to 77 characters).

Comment thread src/CppBridge.ts Outdated
Comment thread src/CppBridge.ts Outdated
Comment thread src/CppBridge.ts
@j0ntz j0ntz mentioned this pull request Aug 25, 2026
2 tasks
@j0ntz

j0ntz commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

The design doc for this work is now in #19, rewritten to describe what ships here rather than the first round.

Section 8's phase 8 records where this pair diverges from #17 and #1090: the checkpoint replacing the synced-only store, the mempool sweep moving after the paging loop, the HF6 payment-id handling, and the AddressInfo correction. Sections 5, 6, 9 and 11 are updated to match the shipping code, and the open items from my review are written down there too rather than smoothed over.

It lived only on jon/ios-perf-zano-xmr until now, and that branch goes away with #17.

peachbits and others added 4 commits August 25, 2026 12:17
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 #17.

Co-authored-by: Jonathan Tzeng <jnthntzng@gmail.com>
`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 #17.

Co-authored-by: Jonathan Tzeng <jnthntzng@gmail.com>
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 #17.

Co-authored-by: Jonathan Tzeng <jnthntzng@gmail.com>
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
#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)
@peachbits

Copy link
Copy Markdown
Contributor Author

All four subjects reworded to <=50 characters (39-45).

@j0ntz

j0ntz commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Pushed a979b0f here, the migration design doc. It only ever lived on the branch behind the now-closed #17, so it would have been lost with that branch; this repo is where the postponed-run work it describes lands.

It is current as of today rather than a copy: section 6 gained the block-height conversion from the confirmation-count fix (pushed to EdgeApp/edge-currency-accountbased#1092 as e64142b7), Testing gained the unit and in-app coverage for it, and the phase history gained that phase.

@j0ntz
j0ntz force-pushed the postponed-refresh branch from a979b0f to c9947d5 Compare August 25, 2026 22:28
@j0ntz

j0ntz commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Retracting my previous comment and the commit it described. a979b0f is gone from this branch (force-pushed back to c9947d5, your head), and nothing of yours was touched.

The design doc already has its own PR, #19, carrying a newer revision than the copy I pushed here. My phase goes there instead.

@j0ntz j0ntz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ensure fixups squash correctly, they're not fixup! tagged

@peachbits
peachbits merged commit 7f37eff into main Aug 26, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants