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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## 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.
- 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)

- 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.
Expand Down
18 changes: 9 additions & 9 deletions package-lock.json

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

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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": {
Expand Down
88 changes: 71 additions & 17 deletions src/zano/ZanoEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ import {
import type {
CppBridge,
RecentTransaction,
TransferParams
TransferParams,
WalletDetails
} from 'react-native-zano'

import { CurrencyEngine } from '../common/CurrencyEngine'
Expand Down Expand Up @@ -110,8 +111,32 @@ 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
// 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'
)
}

Comment thread
cursor[bot] marked this conversation as resolved.
return response.wallet_id
} catch (error: unknown) {
if (!(error instanceof Error)) throw error
Expand All @@ -121,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) {
Expand All @@ -142,6 +156,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'
)
}
Comment thread
cursor[bot] marked this conversation as resolved.

this.log(
`initializeWallet: found existing wallet with ID ${existingWallet.wallet_id}`
)
Expand All @@ -159,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<WalletDetails[]> {
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)
}
Expand Down Expand Up @@ -404,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))
}
}

Expand Down
77 changes: 68 additions & 9 deletions src/zano/ZanoTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -81,8 +88,12 @@ export class ZanoTools implements EdgeCurrencyTools {
): Promise<JsonObject> {
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
Expand All @@ -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
)

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