From d9308730aff4c4ede4481ce01a31dd13da1637f2 Mon Sep 17 00:00:00 2001 From: peachbits Date: Fri, 24 Jul 2026 15:57:48 -0700 Subject: [PATCH 1/7] changed: (Zano) Update @zano-project/zano-utils-js to v0.0.4-edge.2 The previous build emitted a 25-word seed phrase with a trailing space instead of 26 words whenever the checksum landed on CHECKSUM_MAX, about one seed in 814. That path is already reachable from `parseUri`, which decodes scanned base58 raw seeds through `seedToMnemonic`. The new build also exports `verifySeedPhrase` and `isSeedPhrasePasswordProtected`, so seed phrases can be validated without calling into the native Zano library. Add a test that guards the pin, since downgrading it would silently hand users malformed backup phrases again. --- CHANGELOG.md | 2 ++ package-lock.json | 8 ++--- package.json | 2 +- test/zano/zanoUtilsChecksum.test.ts | 47 +++++++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 test/zano/zanoUtilsChecksum.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e3f1ea559..39aa74369 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- fixed: (Zano) Roughly one in 814 newly created seed phrases came out with 25 words and a trailing space instead of 26 words, because the mnemonic library was missing a checksum wrap-around case that Zano core handles. + ## 4.87.0 (2026-08-02) - added: (Sui) `rpcNodes`, `rpcNodesArchival`, and `maxRequestsPerSecond` to the info payload, so nodes can be changed without a client release. Transaction sweeps start on an archival node, since the walk begins at the wallet's oldest transaction and a pruned node rejects a cursor older than its retention window. diff --git a/package-lock.json b/package-lock.json index d4fbb346a..83b3a163a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,7 @@ "@ton/core": "^0.59.0", "@ton/crypto": "^3.3.0", "@ton/ton": "^15.1.0", - "@zano-project/zano-utils-js": "https://github.com/EdgeApp/zano-utils-js/releases/download/v0.0.4-edge.1/zano-project-zano-utils-js-0.0.4.tgz", + "@zano-project/zano-utils-js": "https://github.com/EdgeApp/zano-utils-js/releases/download/v0.0.4-edge.2/zano-project-zano-utils-js-0.0.4-edge.2.tgz", "@zondax/izari-filecoin": "^1.2.6", "algosdk": "^2.1.0", "assert-log": "^0.2.2", @@ -6000,9 +6000,9 @@ "license": "BSD-2-Clause" }, "node_modules/@zano-project/zano-utils-js": { - "version": "0.0.4", - "resolved": "https://github.com/EdgeApp/zano-utils-js/releases/download/v0.0.4-edge.1/zano-project-zano-utils-js-0.0.4.tgz", - "integrity": "sha512-abG9HLc40PLODKnwsrnYx2qgHBK0En9j30reJeWJeIAvc0ltXL/YnbA3cr2BK+/ieZ963/WEmIfcIraeeD0h1g==", + "version": "0.0.4-edge.2", + "resolved": "https://github.com/EdgeApp/zano-utils-js/releases/download/v0.0.4-edge.2/zano-project-zano-utils-js-0.0.4-edge.2.tgz", + "integrity": "sha512-SJLmmDTF5A7mf1DmIXdVRbiyCRFmR2cGLEekF5tt/v4ECqLOcYpNTjC1lU3Ul0UdyuJShTMHEYfHgVoCVqKiiQ==", "license": "MIT", "dependencies": { "axios": "^0.21.1", diff --git a/package.json b/package.json index f701ed7d2..becec7d15 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,7 @@ "@ton/core": "^0.59.0", "@ton/crypto": "^3.3.0", "@ton/ton": "^15.1.0", - "@zano-project/zano-utils-js": "https://github.com/EdgeApp/zano-utils-js/releases/download/v0.0.4-edge.1/zano-project-zano-utils-js-0.0.4.tgz", + "@zano-project/zano-utils-js": "https://github.com/EdgeApp/zano-utils-js/releases/download/v0.0.4-edge.2/zano-project-zano-utils-js-0.0.4-edge.2.tgz", "@zondax/izari-filecoin": "^1.2.6", "algosdk": "^2.1.0", "assert-log": "^0.2.2", diff --git a/test/zano/zanoUtilsChecksum.test.ts b/test/zano/zanoUtilsChecksum.test.ts new file mode 100644 index 000000000..f47be8379 --- /dev/null +++ b/test/zano/zanoUtilsChecksum.test.ts @@ -0,0 +1,47 @@ +import { seedToMnemonic, verifySeedPhrase } from '@zano-project/zano-utils-js' +import { assert } from 'chai' + +// This guards the pinned `@zano-project/zano-utils-js` build rather than our +// own code. Builds before v0.0.4-edge.2 emitted a 25-word phrase with a +// trailing space whenever the checksum landed on CHECKSUM_MAX (813) -- about +// one seed in 814 -- because `(813 << 1) | 0` addressed index 1626 of a +// 1626-word dictionary. Zano core clamps that case to zero. Downgrading the +// pin would silently hand users malformed backup phrases again. + +// The checksum is salted with the creation-timestamp word, which only +// advances weekly, so these vectors reproduce with the clock frozen. +const FIXED_TIME = Date.UTC(2025, 0, 1) +const boundarySeeds = [ + '0000000000000000000000000000000000000000000000000000000000000af1', + '0000000000000000000000000000000000000000000000000000000000000afa', + '0000000000000000000000000000000000000000000000000000000000001561' +] + +describe('zano-utils-js checksum clamp', () => { + const realNow = Date.now + + beforeEach(() => { + Date.now = () => FIXED_TIME + }) + + afterEach(() => { + Date.now = realNow + }) + + for (const seedHex of boundarySeeds) { + it(`generates a complete 26-word phrase for ...${seedHex.slice( + -6 + )}`, () => { + const mnemonic = seedToMnemonic(seedHex) + + assert.equal(mnemonic, mnemonic.trim(), 'phrase has trailing whitespace') + assert.lengthOf(mnemonic.split(/\s+/), 26) + }) + } + + it('verifies the phrases it generates at the boundary', () => { + for (const seedHex of boundarySeeds) { + assert.isTrue(verifySeedPhrase(seedToMnemonic(seedHex)), seedHex) + } + }) +}) From 2642eb64ea5609d0b1ddbd1fef6e51f1acbc4d93 Mon Sep 17 00:00:00 2001 From: peachbits Date: Mon, 10 Aug 2026 14:09:10 -0700 Subject: [PATCH 2/7] added: (Zano) Offline seed phrase helpers `deriveAddressFromMnemonic`, `validateMnemonic`, `normalizeMnemonic`, `verifyMnemonicChecksum` and `mnemonicMatchesKeysSeed` derive, validate and self-check Zano seed phrases in pure JS, with no native library involved. Nothing calls them yet. Only phrases without a seed passphrase can be handled this way: the JS library cannot decrypt a protected seed, and Zano core asserts that the phrase's password flag matches the password supplied to it, so a protected phrase decoded without its passphrase yields a wrong address rather than an error. The helpers detect that case and throw. `validateMnemonic` enforces the checksum word on 26-word phrases, matching the native path it replaces: `account_base::restore_from_seed_phrase` returns false on a mismatch and `get_seed_phrase_info` then reports an empty address, which callers threw on. Without it, one word mistyped onto another dictionary word imports silently as a different account. 25-word phrases carry no checksum word and are unaffected, which is what Zano core does too. Every helper here refuses a passphrase-protected phrase, and the module says so. `verifySeedPhrase` throws on one by itself, but `mnemonicToSeed` does not -- it answers with a seed derived without decrypting, so a protected phrase produces a wrong key rather than an error. Guarding `mnemonicMatchesKeysSeed` rather than trusting its callers keeps that from reading as a plain mismatch. Protected phrases go to the native library through `ZanoTools`, which is the only implementation that supports them. --- src/zano/zanoMnemonic.ts | 123 ++++++++++++++++++++++++ test/zano/zanoMnemonic.test.ts | 170 +++++++++++++++++++++++++++++++++ 2 files changed, 293 insertions(+) create mode 100644 src/zano/zanoMnemonic.ts create mode 100644 test/zano/zanoMnemonic.test.ts diff --git a/src/zano/zanoMnemonic.ts b/src/zano/zanoMnemonic.ts new file mode 100644 index 000000000..5d6e3ff9b --- /dev/null +++ b/src/zano/zanoMnemonic.ts @@ -0,0 +1,123 @@ +import { + getAccountBySecretSpendKey, + getMasterAddress, + isSeedPhrasePasswordProtected, + mnemonicToSeed, + verifySeedPhrase +} from '@zano-project/zano-utils-js' + +/** + * Seed-phrase helpers that run without the native Zano library. + * + * These cover unprotected phrases only. `@zano-project/zano-utils-js` + * implements no seed-password support -- decrypting one is part of Zano's + * derivation scheme, and guessing at it would risk deriving a wallet the + * reference implementation cannot restore -- so every helper here refuses a + * passphrase-protected phrase, and callers route those to the native library + * through `ZanoTools`, which is the only implementation that supports them. + */ + +/** + * The canonical form of a seed phrase, used everywhere one is stored, + * compared, or hashed. + * + * Imported phrases arrive with whatever spacing the user or the scanner + * produced, and the wallet-file password is derived from the phrase, so an + * unnormalized phrase would derive a different password for the same wallet. + */ +export function normalizeMnemonic(mnemonic: string): string { + return mnemonic.trim().replace(/\s+/g, ' ') +} + +/** + * Derives the Zano master address from a seed phrase, without calling into + * the native library. + * + * Only valid for phrases that carry no seed passphrase. The pure-JS library + * cannot decrypt a protected seed, and Zano core asserts that the phrase's + * "password used" flag matches the password supplied to it, so a protected + * phrase decoded without its passphrase yields the wrong address rather than + * an error. + */ +export function deriveAddressFromMnemonic(mnemonic: string): string { + const normalized = normalizeMnemonic(mnemonic) + validateMnemonic(normalized) + + const { publicSpendKey, publicViewKey } = getAccountBySecretSpendKey( + mnemonicToSeed(normalized) + ) + return getMasterAddress(publicSpendKey, publicViewKey) +} + +/** Length of a keys seed in hex characters. */ +const KEYS_SEED_HEX_LENGTH = 64 + +/** + * Reports whether a phrase decodes back to the keys seed it was built from. + * + * Used to self-check phrases this plugin generates. A phrase we hand the user + * is their only backup, so it is worth proving that the words carry the + * entropy we meant to encode rather than trusting the encoder. + */ +export function mnemonicMatchesKeysSeed( + mnemonic: string, + keysSeedHex: string +): boolean { + const normalized = normalizeMnemonic(mnemonic) + + // `mnemonicToSeed` does not decrypt, and answers a protected phrase with a + // wrong seed rather than an error, so guard it rather than trusting the + // caller. Only `verifySeedPhrase` refuses these on its own. + if (isSeedPhrasePasswordProtected(normalized)) { + throw new Error('Zano seed phrase requires a passphrase') + } + + // The `full` form is the keys seed followed by the timestamp and checksum + // words, so compare just the seed itself: + const decoded = mnemonicToSeed(normalized, true).slice( + 0, + KEYS_SEED_HEX_LENGTH + ) + return decoded.toLowerCase() === keysSeedHex.toLowerCase() +} + +/** + * Reports whether a phrase's checksum word matches its seed words. + * + * Used to self-check phrases this plugin generates, where a mismatch means a + * bug on our side. It is deliberately not applied to phrases the user + * supplies -- see `validateMnemonic`. + */ +export function verifyMnemonicChecksum(mnemonic: string): boolean { + return verifySeedPhrase(normalizeMnemonic(mnemonic)) +} + +/** + * Validates a seed phrase offline, throwing if it is unusable without the + * native library. + * + * Checks the word count, that every word is in the Zano dictionary, that the + * phrase is not passphrase-protected, and -- for 26-word phrases -- that the + * checksum word matches. + */ +export function validateMnemonic(mnemonic: string): void { + const normalized = normalizeMnemonic(mnemonic) + + // Throws on a bad word count or a word outside the dictionary: + if (isSeedPhrasePasswordProtected(normalized)) { + throw new Error('Zano seed phrase requires a passphrase') + } + + // The native import path this replaced enforced the checksum, by way of + // `account_base::restore_from_seed_phrase` returning false on a mismatch + // and `get_seed_phrase_info` then reporting an empty address. Without this + // a single mistyped word that happens to land on another dictionary word + // imports silently as a different account. + // + // 25-word phrases are unaffected: they carry no checksum word, and + // `verifySeedPhrase` returns true for them before reaching the check -- + // which is also what Zano core does. + if (!verifySeedPhrase(normalized)) { + throw new Error('Zano seed phrase checksum does not match') + } +} diff --git a/test/zano/zanoMnemonic.test.ts b/test/zano/zanoMnemonic.test.ts new file mode 100644 index 000000000..028ecfbba --- /dev/null +++ b/test/zano/zanoMnemonic.test.ts @@ -0,0 +1,170 @@ +import { seedToMnemonic } from '@zano-project/zano-utils-js' +import { assert } from 'chai' +import { randomBytes } from 'crypto' +import { base16 } from 'rfc4648' + +import { + deriveAddressFromMnemonic, + mnemonicMatchesKeysSeed, + normalizeMnemonic, + validateMnemonic +} from '../../src/zano/zanoMnemonic' + +// The all-zero seed. Only the timestamp word (index 24) depends on when the +// phrase was generated, and the address depends only on the 24 seed words, +// so both this literal and the address below are stable. +const mnemonic = + 'like like like like like like like like like like like like like like like like like like like like like like like like shoulder mom' +const address = + 'ZxCErvJMYURjo2LcMJzj5SM7N1YADhuy3hZqiaqvpkBhHWWZBxyTDm1aNpd8ZRfVJVVy3FncPDrGygB4gik9jhJv36cJT4HSg' + +/** Replaces the creation-timestamp word, which carries the passphrase flag. */ +const withTimestampWord = (phrase: string, word: string): string => { + const words = phrase.split(' ') + words[24] = word + return words.join(' ') +} + +describe('normalizeMnemonic', () => { + it('trims and collapses whitespace', () => { + assert.equal(normalizeMnemonic(` ${mnemonic} `), mnemonic) + assert.equal(normalizeMnemonic(mnemonic.replace(/ /g, ' ')), mnemonic) + assert.equal(normalizeMnemonic(`${mnemonic} `), mnemonic) + }) + + it('leaves an already-normal phrase alone', () => { + assert.equal(normalizeMnemonic(mnemonic), mnemonic) + }) +}) + +describe('deriveAddressFromMnemonic', () => { + it('matches its golden vector', () => { + assert.equal(deriveAddressFromMnemonic(mnemonic), address) + }) + + it('normalizes before deriving', () => { + assert.equal(deriveAddressFromMnemonic(` ${mnemonic} `), address) + }) + + it('rejects a passphrase-protected phrase', () => { + // Dictionary index >= 800 sets the "password used" flag. Deriving such a + // phrase without its passphrase would silently produce a wrong address. + assert.throws( + () => deriveAddressFromMnemonic(withTimestampWord(mnemonic, 'among')), + /requires a passphrase/ + ) + }) +}) + +describe('mnemonicMatchesKeysSeed', () => { + const zeroSeed = '00'.repeat(32) + + it('matches the seed its words encode', () => { + assert.isTrue(mnemonicMatchesKeysSeed(mnemonic, zeroSeed)) + }) + + it('ignores hex case and surrounding whitespace', () => { + assert.isTrue(mnemonicMatchesKeysSeed(` ${mnemonic} `, '0'.repeat(64))) + assert.isTrue( + mnemonicMatchesKeysSeed(mnemonic, zeroSeed.toUpperCase()), + 'the plugin generates uppercase hex' + ) + }) + + it('rejects a seed the words do not encode', () => { + assert.isFalse(mnemonicMatchesKeysSeed(mnemonic, `${'00'.repeat(31)}01`)) + }) + + it('refuses a passphrase-protected phrase', () => { + // `mnemonicToSeed` does not decrypt, and answers a protected phrase with + // a wrong seed rather than an error, so this would otherwise report a + // meaningless comparison as a plain false. + assert.throws( + () => + mnemonicMatchesKeysSeed(withTimestampWord(mnemonic, 'among'), zeroSeed), + /requires a passphrase/ + ) + }) + + it('holds for freshly generated phrases', () => { + // The property `createPrivateKey` relies on: what `seedToMnemonic` + // encodes is what the phrase decodes back to. + for (let i = 0; i < 64; ++i) { + const seed = base16.stringify(randomBytes(32)) + assert.isTrue( + mnemonicMatchesKeysSeed(seedToMnemonic(seed), seed), + `failed for seed ${seed}` + ) + } + }) +}) + +describe('validateMnemonic', () => { + it('accepts a well-formed phrase', () => { + assert.doesNotThrow(() => validateMnemonic(mnemonic)) + }) + + it('accepts a 25-word phrase, which carries no checksum', () => { + assert.doesNotThrow(() => + validateMnemonic(mnemonic.split(' ').slice(0, 25).join(' ')) + ) + }) + + it('rejects a 26-word phrase whose checksum does not match', () => { + // The realistic transcription error: one word mistyped into another word + // that is also in the dictionary. The native import path this replaced + // rejected these, so accepting them would import a different account + // under the user's phrase. + const words = mnemonic.split(' ') + words[25] = 'shoulder' + assert.throws(() => validateMnemonic(words.join(' ')), /checksum/) + }) + + it('rejects the wrong word count', () => { + assert.throws(() => validateMnemonic('like like like')) + assert.throws(() => validateMnemonic('')) + }) + + it('rejects a word outside the dictionary', () => { + assert.throws(() => + validateMnemonic(mnemonic.replace('shoulder', 'notazanoword')) + ) + }) + + it('rejects an address rather than a phrase', () => { + assert.throws(() => validateMnemonic(address)) + }) + + it('rejects a passphrase-protected phrase', () => { + assert.throws( + () => validateMnemonic(withTimestampWord(mnemonic, 'among')), + /requires a passphrase/ + ) + }) + + it('treats the last unflagged timestamp word as unprotected', () => { + // The checksum is salted with the timestamp word, so swapping it + // invalidates the checksum and this reaches the later check. Failing + // there rather than on the passphrase check is the point: index 799 is + // still unflagged. + assert.throws( + () => validateMnemonic(withTimestampWord(mnemonic, 'ugly')), + /checksum/ + ) + }) + + it('rejects a phrase whose checksum word does not match', () => { + // The native path this replaced rejected these: + // `account_base::restore_from_seed_phrase` returns false on a checksum + // mismatch, and `get_seed_phrase_info` then reports an empty address, + // which the old `importPrivateKey` threw on. One word mistyped onto + // another dictionary word would otherwise import a different account. + assert.throws( + () => + validateMnemonic( + 'before bring today bleed process melody cruel devil nowhere frozen bit month fur suffocate thigh against volume effort hill worse thick shove world different just love' + ), + /checksum/ + ) + }) +}) From eeff763b40f858bc9ab9eedbd1b0643fed5569de Mon Sep 17 00:00:00 2001 From: peachbits Date: Mon, 10 Aug 2026 12:16:58 -0700 Subject: [PATCH 3/7] changed: (Zano) Generate and validate seed phrases without the native library `createPrivateKey` obtained a seed phrase by calling `generateSeedPhrase`, which writes a wallet file to disk as a side effect and can only encrypt it with the seed passphrase -- the empty string, for every wallet created in the app. Generate the phrase from the plugin's own entropy instead, so no wallet file exists until the engine creates one, and creating a wallet starts nothing native at all. A generated phrase is the user's only backup, so it is self-checked twice before being returned, both times offline: the words must decode back to the entropy they were built from, and the checksum word must match them. The round-trip covers the encoder, which is where the 25-word phrases came from. There is deliberately no native cross-check. `ZanoEngine` compares the native wallet's address against this phrase's derived one every time the wallet starts, so a JS/native disagreement still stops the wallet before it can sync, and it does so against the real wallet rather than instance 0. Starting the SDK -- creating its working directories and log file -- is not something wallet creation should be doing to re-derive an address we already have. `importPrivateKey` and `derivePublicKey` now validate and derive offline when no passphrase is involved, which is the common case and the one `parseUri` hits for every scanned payload. Phrases protected by a seed passphrase still go through the native library, which is the only implementation that supports them: `@zano-project/zano-utils-js` has no decryption path at all, and `mnemonicToSeed` takes no password. Those two branches keep their own `init` calls, which cannot be left to `ZanoEngine` -- both run before an engine for the wallet exists. Imported phrases are stored normalized, since react-native-zano derives the wallet-file password from the stored phrase. `derivePublicKey`'s passphrase branch checks the native response the same way `importPrivateKey` does on the identical call. Without it a wrong passphrase returned `{ publicKey: '' }` and the wallet was created with an empty address instead of failing, while the offline branch alongside it threw. --- CHANGELOG.md | 2 + src/zano/ZanoTools.ts | 77 +++++++-- test/zano/ZanoToolsKeys.test.ts | 268 ++++++++++++++++++++++++++++++++ 3 files changed, 338 insertions(+), 9 deletions(-) create mode 100644 test/zano/ZanoToolsKeys.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 39aa74369..0dabf06b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- changed: (Zano) Generate seed phrases from the plugin's own entropy rather than through the native library. Creating a wallet no longer starts the native library or writes a wallet file to disk, and the generated phrase is self-checked offline: it must decode back to the entropy it was built from, and its checksum word must match. +- changed: (Zano) Derive addresses and validate seed phrases without the native library, so scanning or sweeping a Zano private key no longer needs the native module. Phrases protected by a seed passphrase still use the native library, which is the only implementation that supports them. - fixed: (Zano) Roughly one in 814 newly created seed phrases came out with 25 words and a trailing space instead of 26 words, because the mnemonic library was missing a checksum wrap-around case that Zano core handles. ## 4.87.0 (2026-08-02) diff --git a/src/zano/ZanoTools.ts b/src/zano/ZanoTools.ts index 0430693c4..8e776daba 100644 --- a/src/zano/ZanoTools.ts +++ b/src/zano/ZanoTools.ts @@ -25,6 +25,13 @@ import { asMaybeContractLocation, validateToken } from '../common/tokenHelpers' import { encodeUriCommon, parseUriCommon } from '../common/uriHelpers' import { getLegacyDenomination, mergeDeeply } from '../common/utils' import { parseZanoDeeplink } from './parseZanoDeeplink' +import { + deriveAddressFromMnemonic, + mnemonicMatchesKeysSeed, + normalizeMnemonic, + validateMnemonic, + verifyMnemonicChecksum +} from './zanoMnemonic' import { asZanoAssetDetails, asZanoPrivateKeys, @@ -81,8 +88,12 @@ export class ZanoTools implements EdgeCurrencyTools { ): Promise { const { pluginId } = this.currencyInfo + // Store the normalized form. The wallet-file password is derived from + // the mnemonic, so stray whitespace would otherwise change it. + const mnemonic = normalizeMnemonic(input) + const out = { - [`${pluginId}Mnemonic`]: input + [`${pluginId}Mnemonic`]: mnemonic } const { passphrase } = opts @@ -99,9 +110,21 @@ export class ZanoTools implements EdgeCurrencyTools { } out[`${pluginId}StoragePath`] = storagePath + if (seedPassword === '') { + // Validate offline. `parseUri` calls this for every scanned payload to + // decide whether it is a private key, so it must not touch the native + // library or the network. + validateMnemonic(mnemonic) + return out + } + + // Only the native library can decrypt a passphrase-protected seed, and it + // has to be initialized here rather than left to `ZanoEngine`: importing + // runs before any engine for this wallet exists. `getSeedPhraseInfo` uses + // instance 0, so it opens no wallet and writes no wallet file. await this.zano.init(this.networkInfo.walletRpcAddress, -1) const seedPhraseInfo = await this.zano.getSeedPhraseInfo( - input, + mnemonic, seedPassword ) @@ -122,14 +145,30 @@ export class ZanoTools implements EdgeCurrencyTools { const storagePath = this.createPath() - await this.zano.init(this.networkInfo.walletRpcAddress, -1) - const generatedWallet = await this.zano.generateSeedPhrase( - this.networkInfo.walletRpcAddress, - storagePath, - '' - ) + // Generate from our own entropy rather than through the native library. + // `generateSeedPhrase` writes a wallet file to disk purely as a side + // effect of producing a seed, and it has no way to encrypt that file + // with anything but the seed passphrase. + const keysSeed = base16.stringify(this.io.random(32)) + const mnemonic = seedToMnemonic(keysSeed) + + // A phrase we hand the user is their only backup, and a wallet funded + // against a phrase that does not restore is unrecoverable, so self-check + // both halves of it before returning: the seed words must decode back to + // the entropy above, and the checksum word must match them. + // + // Deliberately no native cross-check: creating a wallet is not a reason to + // start the SDK, and `ZanoEngine` compares the native address against this + // phrase's derived one every time the wallet starts, which catches a + // JS/native disagreement before the wallet can sync. + if (!mnemonicMatchesKeysSeed(mnemonic, keysSeed)) { + throw new Error('Zano seed phrase generation did not round-trip') + } + if (!verifyMnemonicChecksum(mnemonic)) { + throw new Error('Zano seed phrase generation produced a bad checksum') + } - return await this.importPrivateKey(generatedWallet.seed, { storagePath }) + return await this.importPrivateKey(mnemonic, { storagePath }) } async derivePublicKey(walletInfo: EdgeWalletInfo): Promise { @@ -141,12 +180,32 @@ export class ZanoTools implements EdgeCurrencyTools { const zanoPrivateKeys = asZanoPrivateKeys(pluginId)(walletInfo.keys) const { mnemonic, passphrase = '' } = zanoPrivateKeys + if (passphrase === '') { + // Derive offline. `makeMemoryWallet` calls this before an engine + // exists, so it must work without the native library. + return { publicKey: deriveAddressFromMnemonic(mnemonic) } + } + + // Only the native library can decrypt a passphrase-protected seed, and it + // has to be initialized here rather than left to `ZanoEngine`, since + // `makeMemoryWallet` derives before any engine exists. `getSeedPhraseInfo` + // uses instance 0, so it opens no wallet and writes no wallet file. await this.zano.init(this.networkInfo.walletRpcAddress, -1) const seedPhraseInfo = await this.zano.getSeedPhraseInfo( mnemonic, passphrase ) + // The same check `importPrivateKey` makes on this call. Without it a + // wrong passphrase returns `{ publicKey: '' }`, and the wallet is created + // with an empty address rather than failing. + if ( + seedPhraseInfo.error_code !== 'OK' || + seedPhraseInfo.response_data.address === '' + ) { + throw new Error('Unable to derive the Zano address from this mnemonic') + } + return { publicKey: seedPhraseInfo.response_data.address } diff --git a/test/zano/ZanoToolsKeys.test.ts b/test/zano/ZanoToolsKeys.test.ts new file mode 100644 index 000000000..8d23c05e6 --- /dev/null +++ b/test/zano/ZanoToolsKeys.test.ts @@ -0,0 +1,268 @@ +import { assert } from 'chai' + +import { deriveAddressFromMnemonic } from '../../src/zano/zanoMnemonic' +import { ZanoTools } from '../../src/zano/ZanoTools' + +interface BridgeCall { + method: string + args: unknown[] +} + +interface FakeOpts { + /** Overrides the address the native library reports, to force a mismatch. */ + address?: string + errorCode?: string +} + +/** + * Builds `ZanoTools` without running its constructor, which needs a native + * module. `io.random` is counter-based so the storage path and the seed do + * not come out identical. + */ +const makeTools = (calls: BridgeCall[], opts: FakeOpts = {}): ZanoTools => { + let counter = 0 + + const zano = { + init: async (...args: unknown[]) => { + calls.push({ method: 'init', args }) + return {} + }, + getSeedPhraseInfo: async (mnemonic: string, seedPassword: string) => { + calls.push({ + method: 'getSeedPhraseInfo', + args: [mnemonic, seedPassword] + }) + return { + error_code: opts.errorCode ?? 'OK', + response_data: { + address: opts.address ?? deriveAddressFromMnemonic(mnemonic) + } + } + }, + // Anything that would write a wallet file. Reaching these is the bug. + generateSeedPhrase: async (...args: unknown[]) => { + calls.push({ method: 'generateSeedPhrase', args }) + throw new Error('generateSeedPhrase must not be called') + }, + generate: async (...args: unknown[]) => { + calls.push({ method: 'generate', args }) + throw new Error('generate must not be called') + }, + open: async (...args: unknown[]) => { + calls.push({ method: 'open', args }) + throw new Error('open must not be called') + }, + restore: async (...args: unknown[]) => { + calls.push({ method: 'restore', args }) + throw new Error('restore must not be called') + } + } + + return Object.assign(Object.create(ZanoTools.prototype), { + builtinTokens: {}, + currencyInfo: { pluginId: 'zano', walletType: 'wallet:zano' }, + io: { + random: (size: number) => { + const out = new Uint8Array(size) + out[size - 1] = ++counter + return out + } + }, + networkInfo: { walletRpcAddress: 'http://example.invalid' }, + zano + }) as ZanoTools +} + +/** Methods that create or touch a wallet file on disk. */ +const FILE_METHODS = ['generateSeedPhrase', 'generate', 'open', 'restore'] + +describe('ZanoTools.createPrivateKey', () => { + it('never writes a wallet file', async () => { + // The vulnerability: `generateSeedPhrase` used to write a wallet file + // encrypted with the empty string, purely to obtain a seed phrase. + const calls: BridgeCall[] = [] + const tools = makeTools(calls) + + await tools.createPrivateKey('wallet:zano') + + const touched = calls.filter(call => FILE_METHODS.includes(call.method)) + assert.deepEqual(touched, []) + }) + + it('returns a usable key set', async () => { + const calls: BridgeCall[] = [] + const tools = makeTools(calls) + + const keys = await tools.createPrivateKey('wallet:zano') + + assert.lengthOf(String(keys.zanoMnemonic).split(' '), 26) + assert.match(String(keys.zanoStoragePath), /^[0-9A-F]{64}$/) + assert.isUndefined(keys.zanoPassphrase) + // The storage path must not be derived from the same entropy as the seed: + assert.notEqual(keys.zanoStoragePath, keys.zanoMnemonic) + }) + + it('makes no native calls at all', async () => { + // Creating a wallet is not a reason to start the SDK. The phrase is + // self-checked offline, and `ZanoEngine` compares the native address + // against it on every start. + const calls: BridgeCall[] = [] + const tools = makeTools(calls) + + await tools.createPrivateKey('wallet:zano') + + assert.deepEqual(calls, []) + }) + + it('rejects the wrong wallet type', async () => { + const tools = makeTools([]) + await assertRejects( + async () => await tools.createPrivateKey('wallet:monero'), + /InvalidWalletType/ + ) + }) +}) + +describe('ZanoTools.importPrivateKey', () => { + let mnemonic: string + + beforeEach(async () => { + const keys = await makeTools([]).createPrivateKey('wallet:zano') + mnemonic = String(keys.zanoMnemonic) + }) + + it('makes no native calls without a passphrase', async () => { + // `parseUri` runs this against every scanned payload to decide whether it + // is a private key, so it must stay offline. + const calls: BridgeCall[] = [] + const tools = makeTools(calls) + + await tools.importPrivateKey(mnemonic) + + assert.deepEqual(calls, []) + }) + + it('stores the normalized mnemonic', async () => { + const tools = makeTools([]) + + // Scanners and hand-typed input arrive with arbitrary spacing, and the + // wallet-file password is derived from the stored phrase. + const messy = ` ${mnemonic.replace(/ /g, ' ')} ` + const keys = await tools.importPrivateKey(messy) + + assert.equal(keys.zanoMnemonic, mnemonic) + }) + + it('rejects things that are not seed phrases', async () => { + const tools = makeTools([]) + for (const input of [ + '', + 'like like like', + 'ZxDFpn4k7xVYyc9VZ3LphrJbkpc46xfREace5bme1aXiMzKPAHA8jsTWcHSXhv9AdodSaoGXK9Mg7bk3ec4FkQrj357fZPWZX', + 'https://edge.app', + mnemonic.replace('like', 'notazanoword') + ]) { + await assertRejects(async () => await tools.importPrivateKey(input)) + } + }) + + it('uses the native library when a passphrase is supplied', async () => { + const calls: BridgeCall[] = [] + const tools = makeTools(calls, { address: 'ZxAnyAddress' }) + + const keys = await tools.importPrivateKey(mnemonic, { + passphrase: 'hunter2' + }) + + assert.equal(keys.zanoPassphrase, 'hunter2') + const checks = calls.filter(call => call.method === 'getSeedPhraseInfo') + assert.deepEqual(checks[0].args, [mnemonic, 'hunter2']) + }) + + it('rejects a passphrase the native library will not validate', async () => { + const tools = makeTools([], { address: '' }) + await assertRejects( + async () => + await tools.importPrivateKey(mnemonic, { passphrase: 'hunter2' }), + /Unable to validate mnemonic/ + ) + }) + + it('passes the storage path through unchanged', async () => { + const tools = makeTools([]) + const keys = await tools.importPrivateKey(mnemonic, { storagePath: 'AB' }) + assert.equal(keys.zanoStoragePath, 'AB') + }) +}) + +describe('ZanoTools.derivePublicKey', () => { + let mnemonic: string + + beforeEach(async () => { + const keys = await makeTools([]).createPrivateKey('wallet:zano') + mnemonic = String(keys.zanoMnemonic) + }) + + it('derives offline without a passphrase', async () => { + // `makeMemoryWallet` calls this before any engine exists. + const calls: BridgeCall[] = [] + const tools = makeTools(calls) + + const { publicKey } = await tools.derivePublicKey({ + id: 'a', + type: 'wallet:zano', + keys: { zanoMnemonic: mnemonic, zanoStoragePath: 'AB' } + }) + + assert.equal(publicKey, deriveAddressFromMnemonic(mnemonic)) + assert.deepEqual(calls, []) + }) + + it('uses the native library with a passphrase', async () => { + const calls: BridgeCall[] = [] + const tools = makeTools(calls, { address: 'ZxPassphraseAddress' }) + + const { publicKey } = await tools.derivePublicKey({ + id: 'a', + type: 'wallet:zano', + keys: { + zanoMnemonic: mnemonic, + zanoPassphrase: 'hunter2', + zanoStoragePath: 'AB' + } + }) + + assert.equal(publicKey, 'ZxPassphraseAddress') + const checks = calls.filter(call => call.method === 'getSeedPhraseInfo') + assert.deepEqual(checks[0].args, [mnemonic, 'hunter2']) + }) + + it('rejects the wrong wallet type', async () => { + const tools = makeTools([]) + await assertRejects( + async () => + await tools.derivePublicKey({ + id: 'a', + type: 'wallet:monero', + keys: { zanoMnemonic: mnemonic, zanoStoragePath: 'AB' } + }), + /InvalidWalletType/ + ) + }) +}) + +/** Chai's `assert.throws` does not handle promises. */ +async function assertRejects( + fn: () => Promise, + match?: RegExp +): Promise { + try { + await fn() + } catch (error: unknown) { + if (match != null) { + assert.match(String(error), match) + } + return + } + assert.fail('Expected the call to reject') +} From 0d796401283bd0027bf93ba6a5a51bed4369f549 Mon Sep 17 00:00:00 2001 From: peachbits Date: Mon, 10 Aug 2026 11:59:21 -0700 Subject: [PATCH 4/7] added: (Zano) Verify the native wallet address matches the derived public key The public key is now derived from the seed phrase in pure JS for wallets without a passphrase, so check it against the address the native wallet actually reports when it opens. A mismatch fails engine startup loudly instead of syncing a wallet whose address is not the one shown to the user. Two things keep the check from being trivially bypassed. It closes the wallet before throwing, since `startWallet` has already opened it and the lifecycle manager does not run `onStop` for an `onStart` that threw -- left open, the handle leaks and the next start finds it through ALREADY_EXISTS. And the ALREADY_EXISTS recovery path applies the same comparison to the wallet it adopts, so retrying cannot walk around the check. --- CHANGELOG.md | 1 + src/zano/ZanoEngine.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dabf06b5..3a1939e6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- added: (Zano) Verify that the native wallet address matches the address derived from the seed phrase when a wallet starts, failing the start rather than syncing a wallet whose native address is not the one shown to the user. - changed: (Zano) Generate seed phrases from the plugin's own entropy rather than through the native library. Creating a wallet no longer starts the native library or writes a wallet file to disk, and the generated phrase is self-checked offline: it must decode back to the entropy it was built from, and its checksum word must match. - changed: (Zano) Derive addresses and validate seed phrases without the native library, so scanning or sweeping a Zano private key no longer needs the native module. Phrases protected by a seed passphrase still use the native library, which is the only implementation that supports them. - fixed: (Zano) Roughly one in 814 newly created seed phrases came out with 25 words and a trailing space instead of 26 words, because the mnemonic library was missing a checksum wrap-around case that Zano core handles. diff --git a/src/zano/ZanoEngine.ts b/src/zano/ZanoEngine.ts index 74a07ae9a..7f8877c70 100644 --- a/src/zano/ZanoEngine.ts +++ b/src/zano/ZanoEngine.ts @@ -112,6 +112,29 @@ export class ZanoEngine extends CurrencyEngine< keys.passphrase ?? '', keys.storagePath ) + + // The public key is derived from the seed phrase, in pure JS for + // wallets without a passphrase. Fail loudly rather than sync a + // wallet whose native address is not the one we show the user. + if (response.wi.address !== this.walletInfo.keys.publicKey) { + // `startWallet` left the wallet open, and the lifecycle manager + // does not run `onStop` for an `onStart` that threw. Left open, + // the next start gets ALREADY_EXISTS and adopts it below, and + // every restart leaks another handle. + try { + await this.tools.zano.closeWallet(response.wallet_id) + } catch (closeError: unknown) { + this.log.warn( + `initializeWallet: could not close the mismatched wallet: ${String( + closeError + )}` + ) + } + throw new Error( + 'initializeWallet: native wallet address does not match the wallet public key' + ) + } + return response.wallet_id } catch (error: unknown) { if (!(error instanceof Error)) throw error @@ -142,6 +165,14 @@ export class ZanoEngine extends CurrencyEngine< ) } + // Adopting a wallet has to clear the same bar as opening one, or + // the address check above is bypassed by anything that retries. + if (existingWallet.wi?.address !== this.walletInfo.keys.publicKey) { + throw new Error( + 'initializeWallet: existing native wallet address does not match the wallet public key' + ) + } + this.log( `initializeWallet: found existing wallet with ID ${existingWallet.wallet_id}` ) From 9af5b828d4a43a17c233ef99acb21a0d871aaafe Mon Sep 17 00:00:00 2001 From: peachbits Date: Fri, 24 Jul 2026 16:24:43 -0700 Subject: [PATCH 5/7] fixed: (Zano) Align the react-native-zano dependency range with the app The dev and peer ranges were `^0.2.7` while edge-react-gui installs `^0.3.0`. Caret ranges below 1.0 do not widen past the minor version, so those two do not overlap and the peer dependency was unsatisfiable -- visible only because both repos set `legacy-peer-deps`. --- CHANGELOG.md | 1 + package-lock.json | 10 +++++----- package.json | 4 ++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a1939e6d..eccda7716 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - changed: (Zano) Generate seed phrases from the plugin's own entropy rather than through the native library. Creating a wallet no longer starts the native library or writes a wallet file to disk, and the generated phrase is self-checked offline: it must decode back to the entropy it was built from, and its checksum word must match. - changed: (Zano) Derive addresses and validate seed phrases without the native library, so scanning or sweeping a Zano private key no longer needs the native module. Phrases protected by a seed passphrase still use the native library, which is the only implementation that supports them. - fixed: (Zano) Roughly one in 814 newly created seed phrases came out with 25 words and a trailing space instead of 26 words, because the mnemonic library was missing a checksum wrap-around case that Zano core handles. +- fixed: (Zano) Align the `react-native-zano` dependency range with the app. The dev and peer ranges were `^0.2.7` while edge-react-gui installs `^0.3.0`, and caret ranges below 1.0 do not widen past the minor version, so the peer dependency was unsatisfiable. ## 4.87.0 (2026-08-02) diff --git a/package-lock.json b/package-lock.json index 83b3a163a..a092c0131 100644 --- a/package-lock.json +++ b/package-lock.json @@ -114,7 +114,7 @@ "querystring": "^0.2.1", "react-native-monero": "0.4.0", "react-native-piratechain": "0.5.0", - "react-native-zano": "^0.2.7", + "react-native-zano": "^0.3.0", "react-native-zcash": "0.13.1", "rimraf": "^3.0.2", "shell-quote": "^1.8.1", @@ -132,7 +132,7 @@ "peerDependencies": { "react-native-monero": "^0.3.0", "react-native-piratechain": "v0.5.0", - "react-native-zano": "^0.2.7", + "react-native-zano": "^0.3.0", "react-native-zcash": "^0.13.1" } }, @@ -15635,9 +15635,9 @@ } }, "node_modules/react-native-zano": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/react-native-zano/-/react-native-zano-0.2.8.tgz", - "integrity": "sha512-t1TiC32GbZ706r8TxkGOnT3p08gPdhPpcYMJSlccbAp14M0M+YnMlb37ddml+0nPsIFrudJJPIvFYSLkEhcimQ==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/react-native-zano/-/react-native-zano-0.3.0.tgz", + "integrity": "sha512-6LG8Iqnq14pPmIKfHpD4PcCXlvJh7vvR1BafzpX+1MkwHtbugQOuwHD8SCfvWZCfkOhtK/jie2AS2SOVyIosXA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { diff --git a/package.json b/package.json index becec7d15..1f1a2f3b9 100644 --- a/package.json +++ b/package.json @@ -170,7 +170,7 @@ "querystring": "^0.2.1", "react-native-monero": "0.4.0", "react-native-piratechain": "0.5.0", - "react-native-zano": "^0.2.7", + "react-native-zano": "^0.3.0", "react-native-zcash": "0.13.1", "rimraf": "^3.0.2", "shell-quote": "^1.8.1", @@ -188,7 +188,7 @@ "peerDependencies": { "react-native-monero": "^0.3.0", "react-native-piratechain": "v0.5.0", - "react-native-zano": "^0.2.7", + "react-native-zano": "^0.3.0", "react-native-zcash": "^0.13.1" }, "overrides": { From 4dac369451676458d2fd944f8a66dcdb3b4954d0 Mon Sep 17 00:00:00 2001 From: peachbits Date: Thu, 6 Aug 2026 17:07:11 -0700 Subject: [PATCH 6/7] Report Zano wallet-file migration events to the wallet log `react-native-zano`'s `startWallet` takes an optional `log` callback and uses it to report what happened to the wallet file on disk: re-keyed with the derived password, rebuilt because no known password opened it, or recovered by the native library. We passed no callback, so all of that was silent -- a wallet that lost its history to a rebuild looked identical to one that migrated cleanly. Route it to `this.log.warn` so the events land in the wallet log alongside the rest of the engine's output. Requires the unreleased `startWallet` overload from react-native-zano; the dependency pin follows when that publishes. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + src/zano/ZanoEngine.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eccda7716..289b66db2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - added: (Zano) Verify that the native wallet address matches the address derived from the seed phrase when a wallet starts, failing the start rather than syncing a wallet whose native address is not the one shown to the user. +- added: (Zano) Report wallet-file migration and recovery events to the wallet log, so a re-keyed or rebuilt wallet file is visible in support logs rather than silent. - changed: (Zano) Generate seed phrases from the plugin's own entropy rather than through the native library. Creating a wallet no longer starts the native library or writes a wallet file to disk, and the generated phrase is self-checked offline: it must decode back to the entropy it was built from, and its checksum word must match. - changed: (Zano) Derive addresses and validate seed phrases without the native library, so scanning or sweeping a Zano private key no longer needs the native module. Phrases protected by a seed passphrase still use the native library, which is the only implementation that supports them. - fixed: (Zano) Roughly one in 814 newly created seed phrases came out with 25 words and a trailing space instead of 26 words, because the mnemonic library was missing a checksum wrap-around case that Zano core handles. diff --git a/src/zano/ZanoEngine.ts b/src/zano/ZanoEngine.ts index 7f8877c70..8d761a6ad 100644 --- a/src/zano/ZanoEngine.ts +++ b/src/zano/ZanoEngine.ts @@ -110,7 +110,8 @@ export class ZanoEngine extends CurrencyEngine< const response = await this.tools.zano.startWallet( keys.mnemonic, keys.passphrase ?? '', - keys.storagePath + keys.storagePath, + { log: message => this.log.warn(message) } ) // The public key is derived from the seed phrase, in pure JS for From ec25287f0df6a096936b47cec7a9be98c141e126 Mon Sep 17 00:00:00 2001 From: peachbits Date: Mon, 10 Aug 2026 12:16:58 -0700 Subject: [PATCH 7/7] Read the Zano view key without taking the native wallet lock `getDisplayPublicSeed` fetched the private view key through `getWalletInfo`, which calls `wallets_manager::get_wallet_info` -- an unbounded `lock()` on the per-wallet mutex, with no timeout. Its neighbour `get_recent_transfers` uses `try_lock` and returns CORE_BUSY instead, so the blocking behaviour here looks unintentional. That matters because the GUI asks for this key for every wallet a few seconds after login, as part of its compromised-key sweep. The native module runs its calls on one serial queue, so a single wallet that is mid-refresh holds the lock and every other Zano call queues behind it -- taking the app with it. Sampling a frozen app showed 100% of samples parked in `plain_wallet::get_wallet_info` under `__psynch_mutexwait`. `getOpenedWallets` returns the same secret without the lock: it takes only the shared wallet-map lock and fills each entry with `get_wallet_info_unlocked`. `wi.view_sec_key` and `wi_extended.view_private_key` are both `pod_to_hex(view_secret_key)`. Both calls read the same unfiltered `m_wallets` map, so a wallet we hold an id for is always listed -- there is no case where the old call could answer and this one could not. Rather than keep it as a fallback that would silently reintroduce the lock, throw: if that ever fires, the assumption above is wrong and we want to see it. Co-Authored-By: Claude Opus 4.8 The catch reports `String(error)` rather than `JSON.stringify(error)`, which yields `{}` for an Error and made every failure here read the same. The list-and-find is now one `listOpenedWallets` helper, shared with the ALREADY_EXISTS recovery path in `onStart` that was already doing the same shape check. The failure names the wallet id, since reaching it usually means the wallet closed or was still opening between the id being handed out and this snapshot -- a race rather than the bug the old wording suggested. --- CHANGELOG.md | 1 + src/zano/ZanoEngine.ts | 54 +++++++++++++++++++++++++++++------------- 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 289b66db2..bf324640e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - changed: (Zano) Derive addresses and validate seed phrases without the native library, so scanning or sweeping a Zano private key no longer needs the native module. Phrases protected by a seed passphrase still use the native library, which is the only implementation that supports them. - fixed: (Zano) Roughly one in 814 newly created seed phrases came out with 25 words and a trailing space instead of 26 words, because the mnemonic library was missing a checksum wrap-around case that Zano core handles. - fixed: (Zano) Align the `react-native-zano` dependency range with the app. The dev and peer ranges were `^0.2.7` while edge-react-gui installs `^0.3.0`, and caret ranges below 1.0 do not widen past the minor version, so the peer dependency was unsatisfiable. +- fixed: (Zano) Read the private view key without taking the native per-wallet lock. The app requests it for every wallet shortly after login, and the previous call blocks with no timeout, so an account with several Zano wallets could hang while one of them was mid-refresh. ## 4.87.0 (2026-08-02) diff --git a/src/zano/ZanoEngine.ts b/src/zano/ZanoEngine.ts index 8d761a6ad..c930347af 100644 --- a/src/zano/ZanoEngine.ts +++ b/src/zano/ZanoEngine.ts @@ -18,7 +18,8 @@ import { import type { CppBridge, RecentTransaction, - TransferParams + TransferParams, + WalletDetails } from 'react-native-zano' import { CurrencyEngine } from '../common/CurrencyEngine' @@ -145,19 +146,8 @@ export class ZanoEngine extends CurrencyEngine< `initializeWallet: wallet already exists, finding existing wallet` ) - // Get all opened wallets and find ours by storage path - const openedWalletsResponse = await this.tools.zano.getOpenedWallets() - if ( - !('result' in openedWalletsResponse) || - openedWalletsResponse.result == null - ) { - throw new Error( - 'initializeWallet: Failed to retrieve opened wallets' - ) - } - // Find the wallet that matches our storage path - const existingWallet = openedWalletsResponse.result.find( + const existingWallet = (await this.listOpenedWallets()).find( info => info.name === keys.storagePath ) if (existingWallet?.wallet_id == null) { @@ -191,6 +181,21 @@ export class ZanoEngine extends CurrencyEngine< }) } + /** + * Lists the wallets the native library currently has open. + * + * `get_opened_wallets` reads its snapshot under a shared lock on the wallet + * map and reports each wallet through `get_wallet_info_unlocked`, so unlike + * the per-wallet calls it never waits on a wallet that is mid-refresh. + */ + private async listOpenedWallets(): Promise { + const response = await this.tools.zano.getOpenedWallets() + if (!('result' in response) || response.result == null) { + throw new Error('Could not list the opened Zano wallets') + } + return response.result + } + setOtherData(raw: any): void { this.otherData = asZanoWalletOtherData(raw) } @@ -436,10 +441,27 @@ export class ZanoEngine extends CurrencyEngine< throw new Error('Wallet is not running, cannot get view key') } - const walletInfo = await this.tools.zano.getWalletInfo(nativeId) - return walletInfo.wi_extended.view_private_key + // `getOpenedWallets` reads the view key without taking the per-wallet + // lock, while `getWalletInfo` blocks on it with no timeout. The app + // asks for this key for every wallet shortly after login, so the + // locking call can stall the whole native queue behind a wallet that + // is mid-refresh. + const entry = (await this.listOpenedWallets()).find( + wallet => wallet.wallet_id === nativeId + ) + const viewKey = entry?.wi?.view_sec_key + if (viewKey == null || viewKey === '') { + // Not a bug so much as a race: the wallet closed, or was still + // opening, between our id being handed out and this snapshot. + throw new Error( + `Zano wallet ${nativeId} was not in the opened-wallet list` + ) + } + return viewKey } catch (error: unknown) { - throw new Error('Failed to get wallet info: ' + JSON.stringify(error)) + // `JSON.stringify` on an Error yields `{}`, so every failure here + // used to report the same empty cause. + throw new Error('Failed to get the wallet view key: ' + String(error)) } }