diff --git a/CHANGELOG.md b/CHANGELOG.md index a473ac14a..d0d8115fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +- added: (ARRR) A lightwalletd failover pool, with the node list replaceable from the info server +- changed: (ARRR) Pirate Chain runs on the unified `react-native-pirate-wallet` SDK (^0.3.4), replacing `react-native-piratechain` +- changed: (ARRR) Wallets scan and broadcast over TLS against the plugin's own lightwalletd node, not the SDK's default +- changed: (ARRR) The wallet registry credential lives in the iOS Keychain or Android Keystore +- changed: (ARRR) Signing keys stay locked except while a send is in flight +- fixed: (ARRR) A send is refused with a wait-and-retry message until the wallet can spend, instead of failing after confirmation + ## 4.94.1 (2026-09-17) - fixed: (Axelar) Restore the `axelar-archrpc.chainode.tech` archive node. Its replacement, `axelar-rpc.polkachu.com`, stores blocks only from June 2026, so older transactions could not be dated. diff --git a/package-lock.json b/package-lock.json index e81188e08..021005e8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,6 +45,7 @@ "bs58": "4.0.1", "chain-registry": "^2.0.251", "cleaners": "^0.3.14", + "create-hmac": "1.1.7", "ed25519-hd-key": "^1.3.0", "es-aggregate-error": "^1.0.13", "eth-sig-util": "^3.0.1", @@ -73,6 +74,7 @@ "@grpc/proto-loader": "^0.7.3", "@sucrase/webpack-loader": "^2.0.0", "@types/chai": "^4.2.9", + "@types/create-hmac": "1.1.3", "@types/es-aggregate-error": "^1.0.6", "@types/long": "^4.0.2", "@types/mocha": "^7.0.1", @@ -113,7 +115,7 @@ "process": "^0.11.10", "querystring": "^0.2.1", "react-native-monero": "0.5.0", - "react-native-piratechain": "0.5.0", + "react-native-pirate-wallet": "0.3.4", "react-native-zano": "^0.5.1", "react-native-zcash": "0.13.1", "rimraf": "^3.0.2", @@ -131,7 +133,7 @@ }, "peerDependencies": { "react-native-monero": "^0.5.0", - "react-native-piratechain": "v0.5.0", + "react-native-pirate-wallet": "^0.3.4", "react-native-zano": "^0.5.1", "react-native-zcash": "^0.13.1" } @@ -5230,6 +5232,16 @@ "@types/node": "*" } }, + "node_modules/@types/create-hmac": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@types/create-hmac/-/create-hmac-1.1.3.tgz", + "integrity": "sha512-oDDSoBrAfdEPQuBpQp14y/ygUVU1IC6gq8Arm8IPP8TRC11AnLLYmpE3VvNUhPK/Ax3USGDjdE1Sc1LCIpf0NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/es-aggregate-error": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/es-aggregate-error/-/es-aggregate-error-1.0.6.tgz", @@ -15636,17 +15648,88 @@ "react-native": ">=0.47.0 <1.0.0" } }, - "node_modules/react-native-piratechain": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/react-native-piratechain/-/react-native-piratechain-0.5.0.tgz", - "integrity": "sha512-cCYNGll6Zye+2oIABBLMSg6DEuIEUJomZkODXKnEDvG8SSIByz2w00Crt6Ol3UjIaDlA69Y10Ty4SiKvLTy2EQ==", + "node_modules/react-native-pirate-wallet": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/react-native-pirate-wallet/-/react-native-pirate-wallet-0.3.4.tgz", + "integrity": "sha512-834gLYAr4FGDE+AxIWqDdwqr6/9cPEJBUDy8t1TDdHHde+Qd46u5pITj6VirheWBzFe8HrWMH/I/OpwQHXGHBA==", "dev": true, "license": "MIT", - "dependencies": { - "rfc4648": "^1.3.0" + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "react-native-pirate-wallet-android": "0.3.4", + "react-native-pirate-wallet-android-x86_64": "0.3.4", + "react-native-pirate-wallet-ios-device": "0.3.4", + "react-native-pirate-wallet-ios-simulator-arm64": "0.3.4", + "react-native-pirate-wallet-ios-simulator-x86_64": "0.3.4" }, "peerDependencies": { - "react-native": ">=0.47.0 <1.0.0" + "react-native": ">=0.71.0 <1.0.0" + } + }, + "node_modules/react-native-pirate-wallet-android": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/react-native-pirate-wallet-android/-/react-native-pirate-wallet-android-0.3.4.tgz", + "integrity": "sha512-5nr8ruXDc+05ogyJAyUwSKnOUp8YfEwBts+TPwyrXHah22zBE3v4uGgj44ufkEeizsqLdIQvoHbsSu2ZBtfxOQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/react-native-pirate-wallet-android-x86_64": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/react-native-pirate-wallet-android-x86_64/-/react-native-pirate-wallet-android-x86_64-0.3.4.tgz", + "integrity": "sha512-O6XqFLJCtVksAsGyIhbPrM4pPFy42T6n34PRRHrRj/LMhKChUlmRf8I680HOW7pVWfe5DrHZ8ZLpUUOIUwOljQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/react-native-pirate-wallet-ios-device": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/react-native-pirate-wallet-ios-device/-/react-native-pirate-wallet-ios-device-0.3.4.tgz", + "integrity": "sha512-/TjisbqIYgviAp4RHNDm+KAku7zLDu8zhetWbyeLxqlZNLSjVT1H1a5vYEJMddj4/JX8WEMTIhG7TnjYNUGhSg==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/react-native-pirate-wallet-ios-simulator-arm64": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/react-native-pirate-wallet-ios-simulator-arm64/-/react-native-pirate-wallet-ios-simulator-arm64-0.3.4.tgz", + "integrity": "sha512-fELgVk98se8eoL1Wbz8zJ83kx6sKf6W4o9h2XPRcd9flr8H8+5bYctGTZGkPwN+oTrREUEfUG+6+RkDPs791Iw==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/react-native-pirate-wallet-ios-simulator-x86_64": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/react-native-pirate-wallet-ios-simulator-x86_64/-/react-native-pirate-wallet-ios-simulator-x86_64-0.3.4.tgz", + "integrity": "sha512-mUEJujwo1soc9bn0uUfYch/gK1VXdI62/PGuxUiniFsym0BBFe9K/UuxBsLcnyt88KexZYqkkBQDVAokVvJulQ==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" } }, "node_modules/react-native-zano": { diff --git a/package.json b/package.json index c89b3f6d8..49e1047f1 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "bs58": "4.0.1", "chain-registry": "^2.0.251", "cleaners": "^0.3.14", + "create-hmac": "1.1.7", "ed25519-hd-key": "^1.3.0", "es-aggregate-error": "^1.0.13", "eth-sig-util": "^3.0.1", @@ -129,6 +130,7 @@ "@grpc/proto-loader": "^0.7.3", "@sucrase/webpack-loader": "^2.0.0", "@types/chai": "^4.2.9", + "@types/create-hmac": "1.1.3", "@types/es-aggregate-error": "^1.0.6", "@types/long": "^4.0.2", "@types/mocha": "^7.0.1", @@ -169,7 +171,7 @@ "process": "^0.11.10", "querystring": "^0.2.1", "react-native-monero": "0.5.0", - "react-native-piratechain": "0.5.0", + "react-native-pirate-wallet": "0.3.4", "react-native-zano": "^0.5.1", "react-native-zcash": "0.13.1", "rimraf": "^3.0.2", @@ -187,7 +189,7 @@ }, "peerDependencies": { "react-native-monero": "^0.5.0", - "react-native-piratechain": "v0.5.0", + "react-native-pirate-wallet": "^0.3.4", "react-native-zano": "^0.5.1", "react-native-zcash": "^0.13.1" }, diff --git a/src/docs/piratechain-unified-wallet-sdk.md b/src/docs/piratechain-unified-wallet-sdk.md new file mode 100644 index 000000000..d41bc4159 --- /dev/null +++ b/src/docs/piratechain-unified-wallet-sdk.md @@ -0,0 +1,668 @@ +# Pirate Chain on the unified wallet SDK: replace the crashing wallet module with react-native-pirate-wallet + +| | | +|---|---| +| Status | Implemented; a funded end-to-end send verified on the iOS sim on `0.3.4` through the [keychain](#keychain) registry and a signing session, and the shippable build verified green on CI ([section 7](#7-testing)). Human review addressed through the 2026-09-15 re-review | +| Author | Jon Tzeng | +| Reviewer | peachbits | +| Last updated | 2026-09-16 | +| Repos | [edge-currency-accountbased](https://github.com/EdgeApp/edge-currency-accountbased), [edge-react-gui](https://github.com/EdgeApp/edge-react-gui), react-native-pirate-wallet (npm `0.3.4`) | +| Implementation | [edge-currency-accountbased#1055](https://github.com/EdgeApp/edge-currency-accountbased/pull/1055), [edge-react-gui#6021](https://github.com/EdgeApp/edge-react-gui/pull/6021) | +| Supersedes | - | +| Related | [PirateNetwork/Pirate-Unified-Light-Wallet#19](https://github.com/PirateNetwork/Pirate-Unified-Light-Wallet/pull/19), Asana 1216926437132721 | + + + +Branch references point at `agent/1214721783909451` in both Edge repos. Direction came from Asana task 1216926437132721 (reconcile the open rewrite PRs with the released v1.1.5 and confirm it removes the piratechain crash workaround) and the recorded review thread with the Pirate Chain team. + +## Contents +1. [Problem](#1-problem) +2. [Prior art](#2-prior-art) +3. [Goals and non-goals](#3-goals-and-non-goals) +4. [Design overview](#4-design-overview) +5. [Detailed design: edge-currency-accountbased](#5-detailed-design-edge-currency-accountbased) +6. [Detailed design: edge-react-gui and the SDK dependency](#6-detailed-design-edge-react-gui-and-the-sdk-dependency) +7. [Testing](#7-testing) +8. [Phase history](#8-phase-history) +9. [Decisions](#9-decisions) +10. [Glossary](#10-glossary) +11. [References](#11-references) +12. [Post-implementation retrospective](#12-post-implementation-retrospective) + +## 1. Problem + +The shipped `react-native-piratechain` module is a fork of ZcashLightClientKit. Its Swift and Rust layers each open the wallet's SQLite database, and when Swift reads while Rust writes the process crashes. The crash is frequent enough that the sim-testing playbook prescribes a local workaround for every unrelated agent run: set `piratechain: false` in `src/util/corePlugins.ts` so the module never loads. That workaround is the "orch workaround" this task exists to retire. + +The Pirate Chain team replaced that module with a unified SDK whose React Native binding is `react-native-pirate-wallet`, where all database access goes through Rust (Swift and Kotlin only pass JSON). An in-flight rewrite reimplemented the Edge plugin over that binding (see [section 8](#8-phase-history)), but it was built against an unreleased fork (v1.1.4 plus local patches) and left three reviewer concerns open. The team has since shipped **v1.1.5**, which merges the Edge-authored fixes and changes the wire format, and then **v1.1.6** (npm `0.3.0`), which sets the [Ironwood](#ironwood) activation height and ships their [Supernova](#supernova) sync engine. This work reconciles the plugin with those releases and pins v1.1.6. + +## 2. Prior art + +- Old `react-native-piratechain`: the crash source ([section 1](#1-problem)); not fixable without abandoning the double-open architecture, which the unified SDK does. +- Vendored fork v1.1.4 plus patches: made sync and send work by patching a per-call tokio runtime that killed the sync worker and a payload-camelization bug in the [RN](#rn) wrapper. Those patches went upstream as [PR #19](https://github.com/PirateNetwork/Pirate-Unified-Light-Wallet/pull/19) and merged, so carrying a fork is no longer the answer; v1.1.5 is installable directly. + +## 3. Goals and non-goals + +Goals: +- Depend on the published `react-native-pirate-wallet`, native binaries included. The plugin started on a vendored v1.1.4 fork, moved to the v1.1.5 release ([RN](#rn) binding 0.1.1 to 0.2.0), and now pins `0.3.2`, on top of the v1.1.6 release (`0.3.0`) that carries the [Ironwood](#ironwood) activation height ([section 6](#ironwood-activation)). +- Reconcile the plugin to the v1.1.5 wire format: amounts as decimal strings in both directions, sends through the SDK `send()` method, and the `orchard` to `ironwood` key rename in the type surface. +- Resolve the three open review threads: string amounts (precision), registry passphrase secrecy (security), and honest native-error typing. +- Hold every [ARRR](#arrr) wallet in one device-scoped registry so multiple wallets sync at once over a shared block cache ([decision 1](#decision-1-one-device-scoped-registry-namespace)). +- Keep `piratechain: true` in the GUI and confirm on device that the crash is gone, removing the need for the corePlugins workaround. + +Non-goals: +- Isolating registries per Edge account. The SDK's registry selection is device-global, so an account-scoped registry would reintroduce the switching that breaks concurrent sync ([decision 1](#decision-1-one-device-scoped-registry-namespace)). +- Publishing `react-native-pirate-wallet` itself. The Pirate team published it on 2026-08-06, and [phase 5](#phase-5-npm-dependency-and-the-lightwalletd-endpoint) consumes that release; Edge does not own the package. +- Resolving the arm64 Debug link ceiling. Debug simulator builds do not link with all four large Rust/C++ native libraries present ([section 6](#the-arm64-debug-link-ceiling)). Release builds do, so this costs local iteration rather than shipping, and the real fix belongs upstream. +- Closing the spendability window the SDK leaves after `SYNCED`. The plugin surfaces the SDK's `ERR_SYNC_FINALIZING` the same way it surfaces any other native error, which the app renders as a generic failure ([the spendability window after `SYNCED`](#the-spendability-window-after-synced)). Mapping it to retryable copy is a GUI change on top of a plugin signal that does not exist yet, so it is tracked rather than done here. +- Stopping the synchronizers that archived wallets start at login ([section 7](#7-testing) item 21). Engines are started by the core before the account's archived state loads, so the fix is above the plugin and would be an unrelated change to core's wallet lifecycle. + +## 4. Design overview + +| Repo | Deliverable | Scope | +|---|---|---| +| edge-currency-accountbased | [#1055](https://github.com/EdgeApp/edge-currency-accountbased/pull/1055) | Bridge and engine reconciliation ([section 5](#5-detailed-design-edge-currency-accountbased)) | +| edge-react-gui | [#6021](https://github.com/EdgeApp/edge-react-gui/pull/6021) | Depend on the published SDK, keep plugin enabled ([section 6](#6-detailed-design-edge-react-gui-and-the-sdk-dependency)) | +| react-native-pirate-wallet | npm `0.3.4` | Unified wallet SDK, published by the Pirate team ([section 6](#6-detailed-design-edge-react-gui-and-the-sdk-dependency)) | + +The plugin's native [IO bridge](#io-bridge) (`piratechainIo.ts`) runs on the React Native side and talks to the SDK, which forwards JSON to the Rust core. The engine (`PiratechainEngine.ts`) runs inside the edge-core-js plugin context and reaches the bridge over the [yaob](#yaob) object bridge. + +```mermaid +sequenceDiagram + box edge-core-js plugin context + participant Engine as PiratechainEngine + end + box React Native side + participant Bridge as piratechainIo (bridge) + participant SDK as react-native-pirate-wallet + end + box Native + participant Rust as pirate-ffi-native (Rust) + end + Engine->>Bridge: makeSynchronizer({ mnemonic, name, birthdayHeight, signingCredential }) + Bridge->>SDK: configureSecureAccountStorage({ accountId: 'edge-pirate-keychain' }) + SDK->>Rust: configure_wallet_storage (credential minted natively, held in the Keychain or Keystore, once) + Bridge->>SDK: restoreWallet + Bridge->>SDK: enableWalletSigningProtection on first sight, then lockWalletSigning + Bridge->>SDK: createSynchronizer + Engine->>Bridge: start() (after the engine has subscribed) + Engine->>Bridge: send(outputs[amount as string], fee as string) + Bridge->>SDK: unlockWalletSigning, send(walletId, outputs, fee), lockWalletSigning in finally + SDK->>Rust: build_tx -> sign_tx -> broadcast_tx (amounts as strings) + Rust-->>Engine: txid +``` + +## 5. Detailed design: edge-currency-accountbased + +The plugin's public shape and the engine's transaction mapping are unchanged. + +### Registry storage + +The SDK's storage entry points select a registry directory and create or unlock it with a passphrase, and that selection is **device-global**: one registry is active at a time, and switching cancels any running sync and clears the registry and block caches. So the bridge configures exactly one device-scoped registry, `DEVICE_ACCOUNT_ID = 'edge-pirate-keychain'`, and every [ARRR](#arrr) wallet lives inside it keyed by its alias `name` (the `base16(walletId)` the tools layer already passes). Wallet-free reads (`isValidAddress`, the chain-tip probe in `getLatestNetworkHeight`) use that same registry, so no throwaway namespace exists. + +`ensureDeviceStorage()` performs the configuration at most once, memoized on a promise so concurrent wallet starts share one setup. It configures storage and then sets the transport (`set_tunnel` Direct, because the SDK's default Tor tunnel does not reliably bootstrap inside Edge); a failure clears the memo so the next call retries the whole setup rather than proceeding on an unconfigured registry. Registry mutations (restore, and the probe wallet's create/delete) run under a `registryLock` serialization. Syncing does not: each wallet gets its own `PirateWalletSynchronizer`, and with no namespace switching left they run concurrently over a shared block cache. + +The registry passphrase never exists in JavaScript. `configureSecureAccountStorage({ accountId })`, added in SDK 0.3.4, has the native module mint a random credential on first use and keep it in the iOS [Keychain](#keychain) or the Android [Keystore](#keystore): the Kotlin bridge wraps it with [AES-GCM](#aes-gcm) under an `AndroidKeyStore` key and stores the ciphertext in private shared preferences, and the iOS bridge stores a Keychain item. Every later launch reads it back natively and passes it to `configure_wallet_storage` without returning it across the bridge. The registry directory is derived from `accountId` alone on both platforms, so the secure registry takes a fresh id rather than pointing a Keychain credential at a directory whose registry was encrypted under the earlier JS-minted passphrase. Nothing shipped with that passphrase, so the only registries it orphans are on development devices, and their wallets re-restore from seed into the new registry on first run. + +[`src/piratechain/piratechainIo.ts`](https://github.com/EdgeApp/edge-currency-accountbased/blob/6973f77d3b200fe9f9cb9dc0804c77203e2595d3/src/piratechain/piratechainIo.ts) +```ts +const DEVICE_ACCOUNT_ID = 'edge-pirate-keychain' + + const configureDeviceStorage = async (): Promise => { + await getSdk().configureSecureAccountStorage({ + accountId: DEVICE_ACCOUNT_ID + }) + // The default transport tunnels through Tor, which doesn't reliably + // bootstrap inside Edge, and configuring storage clears transport state. + // Reconnect directly, like every other plugin: + await invokeCall('set_tunnel', { mode: 'Direct' }) + } +``` + +Every path that reaches the SDK's storage awaits `ensureDeviceStorage()` first: the tools' `isValidAddress`, `getNewWalletBirthdayBlockheight` and `derivePublicKey`, and the engine's `syncNetwork` before it builds a synchronizer. Nothing runs at construction, so building tools never depends on the native module being linked (the plugin's unit tests build tools against a stub bridge). + +### Wallet signing sessions + +The registry credential unlocks viewing data and the block cache, which concurrent sync needs before any Edge account is open, so it cannot also be what guards signing. SDK 0.3.4 separates the two. `enable_wallet_signing_protection` rewraps a wallet's seed and spending keys under a second key derived from a credential the host supplies, holds that key in memory only, and fails `sign_tx` with `ERR_SIGNING_SESSION_LOCKED` until `unlock_wallet_signing` installs it again. Viewing keys and compact blocks stay readable while locked, so a locked wallet still syncs. + +Edge has no account-session secret to hand a currency plugin; engines receive wallet keys, not account keys. The mnemonic is the closest thing: account-encrypted material that `syncNetwork` receives exactly while the account is unlocked and that leaves the engine when it is killed. So the credential is an [HMAC](#hmac) over the mnemonic, keyed by a fixed domain string and built on [SHA-256](#sha-256), hex encoded, computed in the engine and passed to the bridge inside the synchronizer config, and never written anywhere on either side: + +[`src/piratechain/PiratechainEngine.ts`](https://github.com/EdgeApp/edge-currency-accountbased/blob/6973f77d3b200fe9f9cb9dc0804c77203e2595d3/src/piratechain/PiratechainEngine.ts) +```ts +function deriveSigningCredential(mnemonic: string): string { + return base16.stringify( + createHmac('sha256', SIGNING_CREDENTIAL_DOMAIN).update(mnemonic).digest() + ) +} +const SIGNING_CREDENTIAL_DOMAIN = 'edge-currency-accountbased/piratechain/signing-session/v1' +``` + +The bridge reads `get_wallet_signing_status` after the wallet is restored and enables protection the first time a wallet is seen on this device, so its spending keys are wrapped from the first sync on; it then locks the wallet at once, because nothing before a send needs the key. The key is in native memory only for the span of a `send`: the bridge unlocks immediately before it (another wallet's `lock_wallet_signing` or a `lock_all_wallet_signing` can have cleared it, and a failure surfaces with its real reason) and locks in a `finally`, whether or not the send went through. `stop` locks too, for a send in flight when the engine is killed on account lock or logout, and that lock runs whether or not `close()` threw. Sync never depends on any of this: a signing call that fails logs and the synchronizer starts anyway, and the eventual send then reports the SDK's own reason rather than a softened one. An earlier revision unlocked at every start and relied on `stop` to lock, which kept the key in memory for the life of the engine; the review on 2026-09-11 pointed out that `send` re-established the unlock anyway, so the start-time unlock bought nothing. + +[`src/piratechain/piratechainIo.ts`](https://github.com/EdgeApp/edge-currency-accountbased/blob/6973f77d3b200fe9f9cb9dc0804c77203e2595d3/src/piratechain/piratechainIo.ts) +```ts + try { + const status = await walletSdk.getWalletSigningStatus(walletId) + const enabled = status.protectionEnabled + ? status + : await walletSdk.enableWalletSigningProtection( + walletId, + signingCredential + ) + if (enabled.unlocked) await walletSdk.lockWalletSigning(walletId) + } catch (error: unknown) { + console.warn( + `piratechain: wallet signing protection unavailable: ${String(error)}` + ) + } + // ... + send: async (outputs, fee) => { + await ensureSigningUnlocked() + try { + return await walletSdk.send(walletId, outputs, fee ?? null) + } finally { + await lockSigning() + } + }, +``` + +### Endpoint pool diagnostics + +`get_lightd_endpoint_pool_diagnostics`, also new in 0.3.4, live-probes every configured endpoint over the wallet's transport and reports the configured primary, the endpoint the SDK would select (`activeEndpoint`, null when none passes connectivity, compact-cache readiness and canonical-chain validation), whether automatic failover is on, and each endpoint's health, tip height, latency and rejection reason. The engine logs it once after each synchronizer start, off the sync path, because a wallet that never syncs otherwise looks the same whether every node is down or the pool was refused. It is the observation surface the degraded-endpoint test in [section 7](#7-testing) item 19 lacked. + +### Chain tip without mutating the registry + +`getLatestNetworkHeight` cannot ask the SDK for a chain tip directly, and the obvious workaround (create a throwaway wallet with no birthday, read the height it resolves, delete it) mutates the shared registry. Doing that while other wallets' synchronizers are running **aborts the app**: the native service panics inside `pirate_wallet_service_invoke_json`, and because the panic crosses the [FFI](#ffi) boundary Rust turns it into `SIGABRT` rather than a catchable error. Under the phase-2 per-wallet model this never surfaced, since the probe had its own throwaway namespace and touched nothing live. + +So the height comes from three sources, cheapest and safest first: + +1. A wallet that is already registered: `getSyncStatus(walletId).targetHeight`, which reads state instead of changing it. +2. `test_node` (the typed `testLightdEndpoint`) against the plugin's configured `lightwalletdUrl`. It reports the tip height without registering anything, so it is safe while synchronizers run, and it reads Edge's node rather than the SDK's default. That matters twice over: the default is unreachable, and a tip taken from an unaudited node could be inflated, which would set a new wallet's birthday above notes it then never scans. +3. The create-and-delete probe, gated on the registry being **empty**. Its `create_wallet` with no birthday falls back to the SDK's static checkpoint, which sits below the true tip and is therefore conservative. + +The gate on step 3 is what keeps the probe away from a live registry. An earlier cut ran the probe whenever the step-1 loop fell through, which is not the same as an empty registry: wallets can be registered and syncing while none has resolved a tip yet (the first seconds after launch report `targetHeight: 0`), and that is exactly the state where the probe aborts the app. With wallets present and no reachable node, `getLatestNetworkHeight` now throws instead, failing wallet creation visibly rather than killing the process. + +### Synchronizer lifecycle + +The SDK synchronizer emits its first `statusChanged` (`STOPPED` to `SYNCING`) synchronously inside `start()`, so a listener attached afterwards never sees it and the engine would sit at `STOPPED`, which blocks every spend. The bridge therefore exposes `start` on the handle instead of starting inside `makeSynchronizer`: `syncNetwork` creates the synchronizer, subscribes in `initSubscriptions`, and only then calls `start()`, so no event can fire into the void. An earlier revision started inside `makeSynchronizer` and had `initSubscriptions` read the status once afterwards, adopting it only while the engine still said `STOPPED`; that worked, but it detected the race rather than removing it, and the review on 2026-09-11 asked for the ordering fix. + +The handle is the only way to stop the native poller, so a `start()` that throws closes the synchronizer before rethrowing: without that, a retry stacks a second poller on the same registry wallet. + +The plugin does not choose which wallets get a synchronizer; the core starts engines and the plugin serves them. That has an observable cost the plugin cannot fix: archived [ARRR](#arrr) wallets still start synchronizers at login and scan for tens of seconds before the account's archived state arrives and stops them ([section 7](#7-testing) item 21). On an account carrying several, that is several concurrent scans against one node on every launch. + +### Amounts as strings + +The package's own `index.d.ts` declares `Balance`, `TransactionInfo` and `TransactionOutput` amounts as `AmountString`, and the plugin compiles against those types: `react-native-pirate-wallet` is a devDependency, like the other native SDKs, and the hand-written ambient declaration an earlier revision carried is gone, since an ambient `declare module` shadows an installed package and let a wrong shape (`FeeInfo.defaultFee` as a number) compile clean. The poller's snapshot fields the SDK leaves untyped (`balance`, `syncStatus`, `transactions`) pass through cleaners before the engine reads them. `balance` starts as `null`, so a snapshot that does not clean falls back to the wallet-scoped [RPC](#rpc), which covers the window before the first poll. `transactions` starts as `[]` and the engine only reads it after the poller has filled it, so it has no such window and no RPC fallback: each entry is cleaned on its own and a row that fails the shape is dropped with a warning, which keeps the rest of the history rather than rejecting all of it over one bad row. The engine drops `safeParseInt` on the send path and passes `spendAmount` and `networkFee` as strings straight through; the read path already wrapped values in `String(...)` and biggystring, so it needed only the sign check at `processTransaction` switched from a numeric comparison to `gte(netNativeAmount, '0')`. + +### Sends + +`makeSynchronizer(...).send` previously ran `build_tx` / `sign_tx` / `broadcast_tx` over the raw `invoke` bridge to dodge a camelization bug. v1.1.5's `send()` keeps the opaque pending and signed payloads verbatim (via `_callRaw`) and normalizes amounts to strings, so the bridge calls `walletSdk.send(walletId, outputs, fee)` directly. SDK 0.3.4 made `broadcast_tx` wallet-scoped (`broadcastTransaction(walletId, signed)`; the one-argument form is gone) so endpoint selection and repair state belong to the wallet that built the transaction. `send()` routes through it, so the plugin's call is unchanged. + +### The spendability window after `SYNCED` + +A wallet reporting `SYNCED` at 100 percent is not necessarily able to spend. The SDK builds its spend anchor after the scan finishes, and a send attempted in that interval is refused with `ERR_SYNC_FINALIZING: Wallet spend anchor is not available yet. Let sync complete and retry.` The window was ninety seconds on one measured cold sync ([section 7](#7-testing) item 20) and closed on its own. + +`sync_status` cannot see that window: it reports `SYNCED`, `percent` reads 100, and the app clears its sync banner. `get_spendability_status` can, and the Pirate team pointed at it on 2026-08-27 after this doc reported the gap. It returns `spendable` plus the reason it is false, as `rescanRequired`, `repairQueued`, a `reasonCode`, and the anchor heights the SDK is validating against. Since 0.3.4 `reasonCode` is a closed set, `OK`, `ERR_SYNC_FINALIZING`, `ERR_WITNESS_REPAIR_QUEUED` and `ERR_RESCAN_REQUIRED`, and `spendabilityMessage` picks the copy from it, with the two booleans as the fallback for a status that arrives without one. + +So `makeSpend` gates on it. `checkSpendable` keeps the existing `SYNCED` check, then reads the status and throws `PendingFundsError` naming which wait the wallet is in. Both refusals are the same error type: a wallet still scanning has no spend anchor either, so the pre-sync branch throws `PendingFundsError` with the same syncing text rather than a plain `Error`, which is what keeps the amount modal and the send scene's error card on one message instead of sending an unsynced wallet to the generic network-failure card. Refusing at `makeSpend` rather than at broadcast is what makes this a disabled send instead of a failed one: the send scene keeps its confirm slider disabled while it holds no transaction, so the user never confirms a spend the SDK is going to reject. The GUI turns that error into localized retry copy ([section 6](#6-detailed-design-edge-react-gui-and-the-sdk-dependency)) in place of the generic "check your network connection" card. + +A status the plugin cannot read is not a reason to block a spend: `SYNCED` alone was the whole gate before this [RPC](#rpc) existed, so a failed or unrecognized `get_spendability_status` logs and falls through to the old behavior. Only `spendable: false`, read successfully, stops the send. + +On `0.3.2` the status was necessary and not sufficient, which testing established rather than assumed ([section 7](#7-testing) item 22): a wallet reading `spendable: true, repairQueued: false, reasonCode: OK`, with `anchorHeight` equal to `validatedAnchorHeight`, still had two broadcasts rejected by the node with `ERR_WITNESS_REPAIR_QUEUED: Node rejected transaction with unknown anchor. Witness repair was queued; let sync complete and retry.`, and the status did not change after either failure. SDK 0.3.4 answers that directly: repair state is durable until witness reconstruction and anchor validation both finish, `repairQueued` stays true while a repair is queued or running, and the status cannot return `OK` early. `broadcastTx` keeps restating `ERR_WITNESS_REPAIR_QUEUED` and `ERR_SYNC_FINALIZING` as `PendingFundsError` anyway: the backstop costs nothing, and a status that was wrong once earns a second route. Any other native error keeps its own text, because an error the plugin cannot classify must not be softened into a wait. + +### Native error typing + +`SynchronizerCallbacks.onError` is retyped `(error: unknown)` because bridge errors arrive as serialized objects or strings, not real `Error` instances; the existing `error instanceof Error ? error.message : String(error)` guard already assumes this. + +## 6. Detailed design: edge-react-gui and the SDK dependency + +`react-native-pirate-wallet@0.3.4` comes from npm, replacing the `file:../react-native-pirate-wallet` sibling. Its podspec links the `Security` framework for the [Keychain](#keychain)-backed registry credential ([registry storage](#registry-storage)). The wrapper tarball carries only JS and the ObjC/Swift/Kotlin bridge; the native artifacts ship as five `optionalDependencies` pinned to the exact wrapper version (`-android`, `-android-x86_64`, `-ios-device`, `-ios-simulator-arm64`, `-ios-simulator-x86_64`), and `scripts/assemble-ios-framework.js` assembles them into the `PirateWalletNative.xcframework` the podspec vendors. The three iOS packages are marked `os: ["darwin"]`, so Linux CI skips the hundreds of megabytes it cannot use. + +The simulator slice arrives as two thin archives, one per architecture, which 0.3.2 split apart so a developer pulls only what their machine runs. The assembly script hard-links the device slice, then fuses the two thin simulator archives into the universal `ios-arm64_x86_64-simulator` slice with `xcrun lipo`, checking each input's architectures with `lipo -archs` first. That adds a host requirement the earlier versions did not have: `scripts/prepare.sh` now needs Xcode's command line tools on the machine running it, not just Node. + +Nothing in the package runs that assembly on install. `0.2.1` invoked it from a `postinstall`, which `edge-react-gui` could never fire because `.npmrc` sets `ignore-scripts=true`; `0.3.0` dropped the hook. What runs it is the package's podspec: top-level Ruby in a `:path:` podspec is evaluated on every `pod install`, and `0.3.4`'s calls the assembly script with `--force` and raises if the framework directory is still missing afterwards. `npm run prepare.ios` runs `pod install` right after `prepare.sh`, so the framework is assembled when CocoaPods needs it, on the machine that needs it. Earlier revisions also called the script from `scripts/prepare.sh`; that line was redundant with the podspec and weaker than it (no `--force`, no check that the output exists), and the review on 2026-09-11 had it removed. + +`src/util/corePlugins.ts` keeps `piratechain: true`. The only other GUI change is four `testID`s on the send scene's address-tile actions (Enter, Myself, Scan, Paste in `AddressTile2`): they render as icon buttons whose labels are collapsed into an aggregated parent `accessibilityText`, so an automated send drive cannot select them by text and falls back to coordinate taps. The seam back to the plugin is the bridge in [section 5](#5-detailed-design-edge-currency-accountbased) and its diagram. + +### The not-yet-spendable send error + +`ErrorCard`, which the send scene renders when `makeSpend` throws, shows a specific message only for an `I18nError` and falls back to "An unexpected error occurred. Please check your network connection and try again later." for everything else. That fallback is wrong in both diagnosis and advice for a wallet whose funds are merely not spendable yet, which is what the plugin now reports through `PendingFundsError` ([section 5](#5-detailed-design-edge-currency-accountbased)). + +So `SendScene2` translates that one error, in the same `makeSpend` catch block that already translates `InsufficientFundsError`, into an `I18nError` that carries the plugin's own message when it set one, and `send_funds_not_spendable_error_message` (the funds are not spendable yet, wait for the wallet to finish syncing and try again) only when the error arrived without text. The plugin picks its message from the SDK's `reasonCode`, so a wallet rebuilding its witness data says so rather than telling the user to wait for sync, which the review on 2026-09-11 pointed out an unconditional substitution had flattened. Monero throws the same error for unconfirmed funds with edge-core-js's default text, "Not enough confirmed funds", which the scene now shows as-is; the localized line remains the fallback. + +Ethereum throws the same error type with the sentinel message `Unexpected pending transactions`, which the scene already handles with its own warning card and suppressed error state. That path is matched first and left alone. + +### The lightwalletd endpoint + +The SDK bakes in a default [lightwalletd](#lightwalletd) node and never reads the plugin's `networkInfo`, so a wallet left alone scans against that default rather than Edge's. When that node degrades the failure is silent and total: `test_node` still succeeds, the chain tip still resolves, and `sync_status` still reports `SYNCING`, but the scan sits in the `Headers` stage at zero blocks/sec forever, which the app renders as "Sync in Progress, 0% Complete" with no error anywhere in the stack. `PiratechainEngine` therefore passes its configured node down as `lightwalletdUrl`, and `makeSynchronizer` applies it with `set_lightd_endpoint` before the synchronizer starts. The configured port is the node's plain gRPC port, not 443: `test_node` fails against `https://lightd1.pirate.black:443` and succeeds against `http://lightd1.pirate.black:9067`. + +That default node has since gone away entirely. `64.23.167.130:9067`, the address `get_lightd_endpoint` returns on a wallet nobody configured, does not answer: an in-app `test_node` gives back `deadline has elapsed` after eight seconds, and a host-side TCP connect times out rather than being refused, so the packets are dropped. The Pirate team confirmed the retirement directly on this PR on 2026-08-17 and named the replacements: `lightd1.pirate.black`, `lightwalletd1.cryptoforge.cc` and `lightwalletd2.cryptoforge.cc`. So `set_lightd_endpoint` is no longer an optimization over a degraded default; without it an [ARRR](#arrr) wallet has no reachable node at all. + +The configured endpoint is now `https://lightwalletd2.cryptoforge.cc:443`, and the reasons are transport and throughput together. Both cryptoforge hosts terminate TLS on 443 with a Let's Encrypt certificate whose subject alternative names cover the lightwalletd name, and the SDK reports a `tls_pin` for each, so the plaintext exposure this section used to accept is gone: sync and broadcast no longer travel in the clear where an observer can read which block ranges a device fetches, or an active attacker can serve a forked view. + +The throughput half is the larger surprise, and it is the reason this is a change rather than a nicety. Under `0.3.2` a cold scan of a wallet 1.06 million blocks behind runs at roughly 500 blocks/sec against `lightd1.pirate.black:9067`, projecting past half an hour, and reaches the tip in 95 seconds against `lightwalletd2.cryptoforge.cc:443` ([section 7](#7-testing) items 15 to 17). The same wallet on `0.3.0` reached the tip against `lightd1.pirate.black` in 72 seconds, so the naive reading is an SDK regression; the cryptoforge measurement rules that out and leaves an interaction between `0.3.2`'s fetch pattern and that one node. `test_node` puts `lightd1.pirate.black` at a 2,434 ms round trip against `lightwalletd2`'s 537 ms, which is the shape a more round-trip-bound fetch loop would amplify, but the plugin has no visibility into the engine's request pattern and this is where the evidence stops. + +### The lightwalletd failover pool + +SDK 0.3.2 adds a multi-server mode: instead of one endpoint per wallet, the sync engine takes a pool and spreads block fetches across the servers in it, moving off one that stalls. It is off by default upstream. The plugin calls it through the wrapper's typed `setLightdEndpointPool(walletId, url, failoverEndpoints)`, which sends the [RPC](#rpc) `set_lightd_endpoint_pool` with the wallet id, the primary URL and the alternates. Earlier revisions sent the raw RPC with an extra `automatic_failover: true`; the service's handler (`pirate-wallet-service/src/api.rs`, `set_lightd_endpoint_pool`) takes no such argument and sets `automatic_failover = !failover_endpoints.is_empty()` itself, so the flag was always ignored and a non-empty pool is what turns failover on. The single-endpoint call and the chain-tip probe go through the typed `setLightdEndpoint` and `testLightdEndpoint` for the same reason, which also removes the hand-written snake_case cleaner the raw `test_node` result needed. + +The plugin opts in whenever its config names alternates, and it now ships one: `networkInfo.lightwalletdFailoverUrls` carries `https://lightwalletd1.cryptoforge.cc:443` beside the `lightwalletd2` primary. That reverses the earlier default. The pool shipped empty while Edge knew of exactly one reachable clearnet node, which made a "pool" of one both useless and misleading; the Pirate team's answer on this PR supplied the second and third, and an empty list is no longer the honest setting. The info-server payload can still replace the list without an app release, and an empty list still means the bridge makes the ordinary `set_lightd_endpoint` call. + +The SDK validates a pool before accepting it, and two of its rules bind Edge's configuration directly. Both were observed as literal errors on device rather than read from documentation, since none exists: + +- `Automatic failover requires a recognized Pirate network` rejects a host the SDK does not know as a Pirate node. This is why the pool cannot be exercised against a local proxy: an arbitrary `http://127.0.0.1:9068` primary never reaches the transport at all. +- `Automatic failover endpoint changes the connection security mode` rejects a pool whose members disagree on transport. A TLS primary may not list a plaintext alternate, which is the reason `lightd1.pirate.black:9067` stays out of the shipped list, rather than anything about its reachability. + +Two further rules are visible in the SDK's symbol table but were not triggered here: a cap on the number of alternates, and a refusal to combine automatic failover with a pinned primary. + +A rejected pool falls back rather than failing the wallet, and that path is not hypothetical: both rejections above logged, fell back to `set_lightd_endpoint`, and the wallet still reached `SYNCED` ([section 7](#7-testing) item 18). Since the list arrives from the info server, a payload that trips any of these rules would otherwise leave the wallet with no endpoint at all, so `makeSynchronizer` catches the rejection, logs it, and configures the single endpoint instead. + +### Ironwood activation + +Pirate Chain's [Ironwood](#ironwood) [shielded pool](#shielded-pool) is the successor to [Sapling](#sapling), and unified wallet v1.1.6 is the release that sets its activation height. Nothing in the plugin has to change for it, because every call the plugin makes is pool-agnostic by construction: `get_balance` covers both pools plus internal change, `current_receive_address` returns Sapling before activation and Ironwood after it, and `send` sources notes from whatever pool holds them. The plugin never names a pool. + +What activation does change is which SDK versions can spend at all. Consensus branch ids are opaque and go into a transaction's signature, so an SDK that does not carry the post-activation branch id builds transactions the network rejects. The SDK exposes this directly: `validate_consensus_branch` compares its own `sdk_branch_id` against the server's and answers `is_valid`. On 2026-08-17 both read `76b809bb` with `is_valid: true`, both wallets held their whole balance in `sapling` with `ironwood` at zero, and both receive addresses were `zs1...` Sapling addresses, which is the pre-activation state. When the network crosses the activation height, an app still shipping the old `react-native-piratechain` module (or a pre-v1.1.6 unified SDK) will see `is_valid` go false and will not be able to spend, while a v1.1.6 build follows the new rules. That is what makes shipping this pair of PRs time-sensitive rather than merely desirable. + +The plugin's `deriveViewingKey` deliberately keeps calling `export_sapling_viewing_key` even though `export_ironwood_viewing_key` exists. That key is not used for scanning (the SDK scans from its own registry using the spending key); it only populates the wallet's `EdgePublicKey`, which Edge persists as wallet identity. Switching it post-activation would change a stored identity for no functional gain. + +### The arm64 Debug link ceiling + +**Debug** iOS builds do not link with all four large Rust/C++ native libraries present. Release builds do, so this costs local iteration and never reached shipping. The distinction was established on 2026-08-17 and is easy to get wrong, so both measurements are recorded here: + +| configuration | SDK | `__TEXT` | `__text` | result | +|---|---|---|---|---| +| Debug (`0.3.0`, 2026-08-17) | iphonesimulator | 187.3MB | 97.6MB | fails to link | +| Debug (`0.3.4`, 2026-09-07) | iphonesimulator | 190.0MB | 100.2MB | fails to link | +| Release | iphoneos | 140.7MB | 67.4MB | links, 189.7MB arm64 binary | +| Release | iphonesimulator | | | links (Jenkins `Build Maestro ios`) | + +The Debug failure is `ld: fixup error (kind=arm64_b26) ... B/BL out of range (displacement=175114548, max is +/-128MB)`, from `libmonero-module.a`'s `boost::chrono` reaching `_mdb_mutex_failed`. The two ends are both monero calling itself, and they end up 167MB apart because the target lives in `__TEXT,text_env`, a 12KB section the same link warns about: *"symbols in `__TEXT,text_env` have unwind information, but it's not a code section (missing `regular,pure_instructions` section flag)"*. Not being flagged as code, `text_env` is placed at the very end of `__TEXT` (`0x0AB71B38`, 171.5MB in) rather than near its callers at 4.5MB, and the linker will not route a [branch island](#branch-island) into it. Release codegen shrinks `__text` by 31% and the distance falls back inside the ±128MB range. + +Re-measured on `0.3.4` with every library autolinked and no trim: the same fixup error from the same two symbols, with the displacement at 177,426,652 bytes and `text_env` at `0x0ADA62E0`, 2.2MB further out than on `0.3.0`. `__TEXT` grew from 187.3MB to 190.0MB and `__text` from 97.6MB to 100.2MB, so the branch is moving away from the limit rather than toward it, and `react-native-monero`'s changelog through 0.5.0 carries no change to the `text_env` section. + +Static-library contributions at Debug are roughly: pirate-wallet 369MB, zcash 106MB, monero 51MB, zano 29MB. This is not specific to this branch (the same libraries linked on 2026-08-04), and it reproduces on `0.3.0` (simulator slice about 1MB larger than `0.2.1`) and on `0.3.4`. Local Debug testing gets past it by excluding `react-native-zcash`, `react-native-monero` and `react-native-zano` from iOS autolinking, which is a test-only measure and is never committed. + +The durable fix belongs in `react-native-monero`: flag `text_env` as `regular,pure_instructions`, or fold those symbols into `__text`, so the linker can place them near their callers. Shrinking the Rust staticlibs (`opt-level=z`, `panic=abort`, link-time optimization) would also buy headroom, but it treats the symptom. + +## 7. Testing + +1. Static: in edge-currency-accountbased, `tsc --noEmit` exits 0 and `npm test` (`nyc mocha`) reports 490 passing / 0 failing, re-run after every code change through the `0.3.0` round. No piratechain unit tests exist, so the suite covers the repo's other plugins and the shared engine; eslint runs on every touched file through `lint-commit.sh`. +2. Crash retirement (VERIFIED, iOS sim): the GUI was built for the iOS simulator with `piratechain: true` (no corePlugins disable) against the v1.1.5 native binaries. Old `react-native-piratechain` is absent from the build (zero Podfile.lock references, not autolinked). [ARRR](#arrr) wallets ran the shielded sync with the app stable throughout, the exact background sync that crash-looped the old module. Per-account storage created isolated registries under `Library/Application Support/PirateWallet/accounts//`, which [phase 4](#phase-4-one-device-scoped-registry) replaced with the single `edge-pirate-device` directory. +3. Send (VERIFIED, iOS sim, real broadcast): a self-account ARRR send was driven to the transaction-success scene. Source `My Pirate 2` (14.731 ARRR spendable), destination `My Pirate` (picked via the send scene's "Myself" wallet picker, which derived the recipient shielded z-address `zs1e5v84m2mnhwcxd0h4nx85jz97gd9shcphgx84fhh8v7vw9eztz72scekz8c6pxjrl0a2yurjuyj`), amount 4.754 ARRR, fee 0.0001 ARRR. The app reported "Transaction Success" and the transaction record shows txid `34ba68b0fee76668790ef7dae32f374c7f378da589022a1034f1112e234e49cd`. This confirms the string-amount send path and the SDK `send()` call end to end, and exercises the `txid` transaction-processing fix (see [phase 3](#phase-3-e2e-send-verification)) without the `toLowerCase` crash. + +4. Endpoint fix (VERIFIED, iOS sim, 2026-08-11): with `set_lightd_endpoint` applied, `My Pirate 2` and `My Pirate` scanned from their birthdays to `SYNCED` at roughly 1,800 blocks/sec, `localHeight == targetHeight == 4085959`, wallet DBs growing 1.5MB to 233MB, and the wallet-detail sync banner cleared. Without it both wallets sat at `stage: "Headers"`, `blocksPerSecond: 0`, `localHeight` frozen, for 45 minutes across two full builds. Re-verified on a build carrying the committed fix rather than the diagnostic patch. +5. Broadcast (failed on `0.2.1`, 2026-08-11; SUPERSEDED by item 6): three funded attempts from the synced, spendable `My Pirate 2` (2.19 ARRR to `My Pirate`, fee 0.0001, confirm slider active) each failed inside the SDK with `Broadcast failed: Status error: status: Cancelled, message: "Timeout expired"`. Reproduced against two different [lightwalletd](#lightwalletd) nodes, so it was not endpoint-specific. No principal moved. The transaction built and signed in roughly 5 minutes on the sim and the failure was at the gRPC broadcast. +6. Broadcast on `0.3.0` (VERIFIED, iOS sim, 2026-08-17): the same send, driven the same way (`My Pirate 2` to `My Pirate` via the "Myself" picker, 1.088 ARRR, fee 0.0001), broadcast successfully. Build, sign and broadcast together took **2.083 seconds**, against the roughly 5 minutes that ended in a timeout on `0.2.1`. txid `8d2e25d0e624cf714057b65311347a8a5d21cafbbae8f579e714fb979e94a579`; the app showed "Transaction Success" and `My Pirate 2` reconciled from 9.9769 to 8.8888 ARRR while `My Pirate` rose to 257.13. The item-5 wall does not reproduce. Two host-side probes were run anyway to characterize the endpoint: a malformed `SendTransaction` over `grpcurl` against `lightd1.pirate.black:9067` is rejected in about a second with `errorCode -22 "TX decode failed"`, and the wrapper does expose `build_tx` / `sign_tx` / `broadcast_tx` separately, so the broadcast could have been timed in isolation had it still hung. +7. Sync throughput on `0.3.0` (VERIFIED, iOS sim, 2026-08-17): a wallet-menu resync of `My Pirate 2` rewound to height 3,175,610 and returned to the tip at 4,093,957 in 19.25 seconds of wall clock, or **about 47,700 blocks/sec** (about 67,000 blocks/sec across the sampled scanning phase alone). The 2026-08-11 baseline on `0.2.1` was roughly 1,800 blocks/sec, so the [Supernova](#supernova) engine is roughly 27x faster on the same simulator against the same node. +8. Packaging on `0.3.0` (VERIFIED, 2026-08-17): a fresh clone of each branch plus `npm ci` and `npm run prepare` exits 0. In the GUI clone that reproduces `PirateWalletNative.xcframework` with both slices, `ios-arm64` 181.8MB non-fat arm64 and `ios-arm64_x86_64-simulator` 360.3MB fat, with no install hook anywhere in the package, confirming `scripts/prepare.sh` is carrying the assembly on its own. +9. Shippable build (VERIFIED, 2026-08-17): the question of whether the Debug link failure blocks shipping is settled, and the answer is no. A local `xcodebuild -sdk iphoneos -configuration Release` of the untrimmed branch, with all four native libraries autolinked, reports `BUILD SUCCEEDED` and produces a 189.7MB non-fat arm64 binary (`__TEXT` 140.7MB, `__text` 67.4MB) with no link-range error anywhere in the log. Independently, a cheese build of the branch (Jenkins `test-gouda` #18, `4.50.2-gouda (26081701)`, 9m42s) passed all four stages: `Build ios` (the signed device archive), `Build Maestro ios` (Release simulator), and both Android stages. Only the Debug simulator configuration fails ([section 6](#the-arm64-debug-link-ceiling)). +10. Landing-order coupling (OBSERVED, 2026-08-17): the first Release device attempt linked cleanly and then failed at the `Bundle React Native code and images` phase with `Unable to resolve module react-native-piratechain from node_modules/edge-currency-accountbased/lib/piratechain/piratechainIo.js`. That is the published `edge-currency-accountbased@4.87.0` importing a module this branch removes. It is not a defect in either PR, it is the reason the two have to land in order; linking the plugin branch in (which is exactly what the cheese build's tarball pin does for CI) makes the same build succeed. + +11. New-wallet creation on `0.3.0` (VERIFIED, iOS sim, 2026-08-17): `My Pirate 3` was created through the app with no native abort, then archived. Two wallets were already registered and syncing at the time, so the birthday resolved from source 1 of [the chain tip](#chain-tip-without-mutating-the-registry) and that drive did not exercise the `test_node` branch. `test_node` was instead verified directly at runtime, returning `latest_block_height: 4093998` against the configured node. The empty-registry probe path (source 3) is covered by neither. + +12. Multi-server pool on `0.3.2` (VERIFIED, iOS sim, 2026-08-25): the `set_lightd_endpoint_pool` request shape was inferred from the SDK's symbol table, then confirmed on device. With a failover list configured, the bridge logged `pool:ok` and `get_lightd_endpoint_config` read back `{"automatic_failover":true,"failover_endpoints":["http://lightd1.pirate.black:9067"],"host":"64.23.167.130","is_configured":true,"label":null,"port":9067,"tls_pin":null,"use_tls":false}`, so the SDK accepted and stored every field the plugin sends. With the list empty, the default, the bridge logged `pool:skipped-single-endpoint` and made the ordinary `set_lightd_endpoint` call. +13. Automatic failover (NOT DEMONSTRATED, iOS sim, 2026-08-25): configured with the SDK's own dead default node (`64.23.167.130:9067`, which refuses TCP) as the primary and the live `lightd1.pirate.black:9067` as the only alternate, no wallet made scan progress in five minutes: header height crept about 20 blocks/sec and the wallet that reports 100% on a live primary sat at `local: 0`. `get_lightd_endpoint` kept returning the dead primary. Restoring the live primary produced a wallet at `local: 4094008`, `pct: 100` within a minute. Whether the pool needs longer to trip, or needs a primary that fails differently from a refused connection, is not established. +14. Funded send (BLOCKED, iOS sim, 2026-08-25): the send scene derived the destination z-address cross-wallet through the "Myself" picker, then the plugin refused the spend with `Cannot spend until wallet is synced`. The wallet holds 256.0399 ARRR and was 509,535 blocks behind, scanning at a few blocks per second with brief bursts above 20,000, so it reached 0.66 percent in fourteen minutes. Archiving the two empty ARRR wallets that prior rounds left on the account (playbook account hygiene) raised the burst rate but not the sustained one. The send path itself is unchanged by this round's diff and was driven to the transaction-success scene on `0.3.0` (item 6). + +15. Cold-sync A/B, `0.3.0` against `0.3.2` (VERIFIED, iOS sim, 2026-08-25). One controlled experiment, because the numbers on record were not comparable: `0.2.1` at ~1,800 blocks/sec was a cold scan, `0.3.0` at ~47,700 blocks/sec (item 7) was a warm-cache resync, and the 2026-08-25 `0.3.2` figure of 3 to 5 blocks/sec came from a wallet whose birthday sat 64k blocks below the tip. Protocol: one active [ARRR](#arrr) wallet (`My Pirate`, birthday 3,048,549, 1,056,918 blocks behind), every other ARRR wallet archived, the same simulator and the same session, and the SDK version as the only variable. Each side was forced cold by deleting `Library/Application Support/PirateWallet` and `.../com.Pirate.PirateWallet` and asserting both read zero bytes before launch; each side's block cache then grew from zero, which is what distinguishes a cold scan from a rescan. Versions were swapped by replacing `libpirate_ffi_native.a` inside the assembled [xcframework](#xcframework) and relinking, since the wrapper's JS, ObjC/Swift bridge and podspec are the same bytes in both releases and the headers differ only in comments, so the static archive is genuinely the only variable. The linked binary was checked each time for the `set_lightd_endpoint_pool` symbol, which `0.3.2` carries and `0.3.0` does not. + +| SDK | node | cold time to 100% | rate | +|---|---|---|---| +| `0.3.0` | `http://lightd1.pirate.black:9067` | 72 s | ~14,700 blocks/sec | +| `0.3.2` | `http://lightd1.pirate.black:9067` | ~30 min projected (15.86% in 5.6 min) | 499 blocks/sec | +| `0.3.2` | `https://lightwalletd2.cryptoforge.cc:443` | 95 s | ~11,100 blocks/sec | + +16. Verdict (2026-08-25): ship `0.3.2`, and move the endpoint. The middle row read alone says `0.3.2` regressed 20 to 40x, and that was the working hypothesis until the third row was measured. `0.3.2` reaches the tip in 95 seconds against a different node, so the SDK is not slow; the pair of `0.3.2` and `lightd1.pirate.black` is. Block-cache growth measures the same gap in bytes: about 95 KB/s in the middle row against about 3.5 MB/s in the other two, from the same simulator. CPU contention was ruled out by a rehearsal run under a concurrent `xcodebuild` that measured 355 to 412 blocks/sec, inside the same range as the clean 499. + +17. Node survey by in-app `test_node` (VERIFIED, 2026-08-25). All reachable hosts reported the same tip, 4,105,481: + +| url | result | round trip | +|---|---|---| +| `http://lightd1.pirate.black:9067` | ok, `chain_name: main`, `v1.0.0.0` | 2,434 ms | +| `https://lightwalletd2.cryptoforge.cc:443` | ok, `chain_name: main`, `v1.0.0.0` | 537 ms | +| `https://lightwalletd1.cryptoforge.cc:443` | ok | 3,442 ms | +| `http://lightwalletd1.cryptoforge.cc:9067` | connection refused | | +| `http://64.23.167.130:9067` | `deadline has elapsed` | 8,002 ms | + +18. Failover pool with real nodes (VERIFIED, iOS sim, 2026-08-25). Configured with `https://lightwalletd2.cryptoforge.cc:443` primary and `https://lightwalletd1.cryptoforge.cc:443` alternate, `set_lightd_endpoint_pool` returned ok and `get_lightd_endpoint_config` read back `{"automatic_failover":true,"failover_endpoints":["https://lightwalletd1.cryptoforge.cc:443"],"host":"lightwalletd2.cryptoforge.cc","is_configured":true,"port":443,"use_tls":true}`, with `get_lightd_endpoint` naming the primary. That wallet cold-synced to 100% in 87 seconds. Two rejections were produced deliberately along the way and each fell back to the single endpoint and still reached `SYNCED`, which exercises the degrade path for real: a `http://127.0.0.1:9068` primary gave `Automatic failover requires a recognized Pirate network`, and a TLS primary with an `http://lightd1.pirate.black:9067` alternate gave `Automatic failover endpoint ... changes the connection security mode`. + +19. Automatic failover under a stalling primary (NOT TESTED, 2026-08-25). The prescribed harness is a local TCP proxy that completes the handshake and then stops serving, which is a different failure mode from the refused or dropped connections that did not trip failover in item 13. It cannot be built here: the SDK rejects an unrecognized host outright (item 18), so the proxy can never be the primary, and pointing a recognized hostname at it needs `/etc/hosts` or a loopback alias, neither of which this host grants without an interactive password. Whether automatic failover actually moves a stalled scan therefore remains unestablished, and the feature ships on the strength of the pool being configured correctly rather than on a demonstrated recovery. + +20. Funded sends (VERIFIED, iOS sim, real broadcast, 2026-08-25). Two, one per configuration, both from the fully-synced `My Pirate` to its own shielded address and both reaching the transaction-success scene: + - `0.3.0` against `lightd1.pirate.black`: 0.495 [ARRR](#arrr) plus a 0.0001 fee, txid `d4bc2294a427e9c0f197c5ccc21d4ac2687d28fc5f4f3a718f77c757bc130585`. + - `0.3.2` against `lightwalletd2.cryptoforge.cc`, the shipping configuration: 0.498 ARRR plus a 0.0001 fee, txid `239ab8743afbed4f8047ed4e0b05d2aa3e39ebfa7ab9824ddfa52c72c47c4205`. + + This retires item 14's block. It also surfaced a spendability window the plugin does not model: the first slide on the `0.3.0` send failed with `ERR_SYNC_FINALIZING: Wallet spend anchor is not available yet. Let sync complete and retry.` while the wallet already reported `SYNCED` at 100% and the app showed no sync banner. The retry ninety seconds later succeeded, and the `0.3.2` send succeeded on its first slide. So `SYNCED` precedes spendability by an interval the SDK does not expose, and a user who taps send in that window sees Edge's generic "Unexpected Error" card. + +21. Archived wallets still start synchronizers (OBSERVED, iOS sim, 2026-08-25). On an account whose ARRR wallets were all archived, four `makeSynchronizer` calls fired at login, scanned for about 38 seconds, and then went `STOPPED` once the account's archived state loaded. The work is wasted rather than harmful, but it is four concurrent scans against one node on every launch, and it is the mechanism behind the playbook's account-hygiene rule. + +22. The spendability gate, and what it does not cover (VERIFIED, iOS sim, 2026-08-31). `My Pirate`, restored from the account archive at 257.12770237 ARRR, on the `0.3.2` build with the gate compiled in. The gate reads real data and passes a spendable wallet: instrumenting the shipped bundle's `checkSpendable` logged `{"spendable":true,"rescanRequired":false,"repairQueued":false,"reasonCode":"OK","anchorHeight":4113936,"validatedAnchorHeight":4113936}` on every `makeSpend`, the transaction built, and the confirm slider armed. `reasonCode: "OK"` is the first value of that field anyone has recorded. + + What the gate did NOT prevent: two broadcasts of a 2.6 ARRR self-send were rejected by the node with `ERR_WITNESS_REPAIR_QUEUED: Node rejected transaction with unknown anchor. Witness repair was queued; let sync complete and retry.` No principal moved and no fee was spent. A `get_spendability_status` read taken after the second failure returned the same fields apart from the anchor advancing to 4,113,944, so the status does not reflect a repair the broadcast itself queues. This is what motivates the second route in [section 5](#5-detailed-design-edge-currency-accountbased): the retryable broadcast errors are restated as `PendingFundsError` too, so the wait-and-retry advice reaches the user on the path the status cannot see. That contradiction was raised with the Pirate team and answered in 0.3.4, which keeps the repair state visible until the anchor is accepted (phase 10 in [section 8](#8-phase-history)). + +23. [Keychain](#keychain) registry on `0.3.4` (VERIFIED, iOS sim, 2026-09-02). A fresh Debug build of both branches on `react-native-pirate-wallet@0.3.4`, with the local three-library trim. `My Pirate` restored from the account archive; the app created `Library/Application Support/PirateWallet/accounts/edge-pirate-keychain/` with a `wallet_registry.db` and four wallet databases (the archived wallets start synchronizers too, item 21), 13MB within a minute, with no passphrase file anywhere on the plugin [disklet](#disklet). The wallet scanned 1,068,154 blocks from its 2024 birthday to `SYNCED` in about two minutes against `lightwalletd2.cryptoforge.cc` and reported 257.12770237 ARRR, the chain balance. + +24. Funded send through a signing session on `0.3.4` (VERIFIED, iOS sim, real broadcast, 2026-09-02). Self-send of 0.538 ARRR plus the 0.0001 fee from `My Pirate` to its own shielded address `zs1e5v84m2mnhwcxd0h4nx85jz97gd9shcphgx84fhh8v7vw9eztz72scekz8c6pxjrl0a2yurjuyj`. `makeSpend` passed the spendability gate and armed the slider; the broadcast reached "Transaction Success" with txid `93f42a33af4161d697277f050da1c65c1dbf267c2aafdeaa1329ba063f64fbcb`. The bridge's `send` calls `ensureSigningUnlocked` with no guard, so a successful send is evidence that `get_wallet_signing_status`, `enable_wallet_signing_protection` and the unlock all succeeded against the SDK, and that the wallet-scoped `broadcast_tx` accepted the transaction on the first slide. No `ERR_WITNESS_REPAIR_QUEUED` on a wallet restored minutes earlier, which is the state that produced the item 22 rejections on `0.3.2`. + +25. Endpoint pool diagnostic (VERIFIED, iOS sim, 2026-09-14). On 2026-09-02 the app's persisted log files carried no plugin lines, so the probe went unobserved. This round captured the core's log events through a local `onLog` hook (test scaffolding, not committed) and the line arrived one second after the synchronizer started, through the typed `setLightdEndpointPool`: `automaticFailover: true`, `configuredEndpoint` `lightwalletd2.cryptoforge.cc`, `activeEndpoint` `lightwalletd1.cryptoforge.cc`, with the primary marked unhealthy (`502 Bad Gateway` behind nginx) and the alternate healthy at 1,679 ms with tip 4,134,292. That is the first observation of automatic failover actually moving a wallet, the case item 19 could not construct: the primary was down on its own that evening, and the wallet synced to `SYNCED` over the alternate. +26. Broadcast during a primary outage (OBSERVED, iOS sim, 2026-09-14). With sync running over the alternate, two slides of a 0.044 ARRR self-send both failed inside the SDK with `Broadcast failed: h2 protocol error: error reading a body from connection` from the same nginx front, and a direct gRPC probe from the host confirmed the state: `lightwalletd2.cryptoforge.cc` answered 502 while `lightwalletd1.cryptoforge.cc` and `lightd1.pirate.black` answered gRPC. So `broadcast_tx` goes to the configured primary and does not follow the sync engine's failover; the send scene showed the generic network-error card, which is the right diagnosis here. Raised with the Pirate team below. +27. Funded send on the review-fix build (VERIFIED, iOS sim, real broadcast, 2026-09-14). With the primary and alternate swapped in the installed engine asset (a local edit to the throwaway build, restored afterwards) so the healthy node was primary, the same 0.044 ARRR self-send from `My Pirate` reached "Transaction Success" on the first slide, txid `0fc26e01781020bb994164f31526a4d21c9a8434e6bdafcd6f1ec4bacb9a8abe`. This build carries the send-scoped unlock (the key is unlocked inside `send` and locked in its `finally`), `start()` called by the engine after `initSubscriptions`, and the typed endpoint wrappers, so the send is evidence for all three. +28. Max and the not-spendable copy (VERIFIED, iOS sim, 2026-09-14). On a spendable wallet Max filled 257.12750237 ARRR, the balance less the 0.0001 fee, and armed the slider. The refusing branch was driven by forcing the bridge's `getSpendability` to report `spendable: false` in the throwaway build (reverted afterwards): with `ERR_WITNESS_REPAIR_QUEUED` both Max and a 0.044 ARRR `makeSpend` were refused and the send scene showed the plugin's text, "Cannot spend until the wallet finishes repairing its transaction history"; with `ERR_SYNC_FINALIZING` it showed "Cannot spend until the wallet finishes syncing". The forced frames prove the rendering and the gate; they do not prove the SDK emits those codes on its own, which rests on the real `ERR_WITNESS_REPAIR_QUEUED` rejections of 2026-08-31. +29. Funded send with signing protection enabled from scratch, after the unlock fix (VERIFIED, iOS sim, real broadcast, 2026-09-14). A review bot found that `ensureSigningUnlocked` returned after enabling protection without checking whether the key came back unlocked. With that fix built in, the device registry was moved aside so `My Pirate` was restored into a new registry and protection was enabled fresh on its first start, then a 0.044 ARRR self-send reached "Transaction Success" on the first slide, txid `caab1e3298e6e8f275bc17f249c9d9a9fc4185f5c21bb2a978971035601dcba9`. The branch the fix touches, enabling inside `send` itself, only runs when the start-time enable failed, and that failure was not forced. + +Sync note (superseded): the earlier claim that a clean baked build syncs in roughly 90 seconds at 8000 blocks/sec, and that "sync stuck at 0%" was only a broken-build artifact, was wrong. Item 4 above identifies the real cause: the SDK scans against its own default node unless the plugin sets one, and that default stopped serving blocks. + +## 8. Phase history + +### Phase 1: rewrite over the vendored fork (v1.1.4) +- **Sketched:** reimplement the plugin over `react-native-pirate-wallet`, restoring wallets into the SDK registry under the Edge walletId alias, mapping sync progress from the polling synchronizer, and sending through the registry wallet. +- **Shipped:** as sketched, against a vendored fork of v1.1.4 with two local upstream patches (persistent tokio runtime, no-camelize tx payload) plus a JSON-number amount format. +- **Diverged:** the fork carried a shared registry unlocked by a hardcoded app passphrase and amounts as JS numbers. Both drew reviewer objections, held open pending the upstream release. + +### Phase 2: reconcile to released v1.1.5 +| Diverged in phase 1 | Shipped in phase 2 | +|---|---| +| Hardcoded shared app passphrase | Per-wallet `configureAccountStorage` namespace, passphrase = [HMAC](#hmac) of the wallet seed | +| Amounts as JS numbers (precision loss above 2^53-1) | Decimal strings both directions | +| Manual raw build/sign/broadcast | SDK `send()` (opaque payloads preserved upstream) | +| `onError: (error: Error)` | `onError: (error: unknown)` | +| Vendored fork v1.1.4 | Released v1.1.5, binding 0.2.0 | + +- **Deferred:** a single per-Edge-account registry (rather than per wallet) would let one account's wallets share sync state without re-selecting namespaces; it needed an account-derived secret plumbed to the native IO and was out of scope. Phase 4 settles this differently, at device scope ([decision 1](#decision-1-one-device-scoped-registry-namespace)). + +### Phase 3: e2e send verification +Verifying on device landed the following: +- **Fixed:** v1.1.5's `TransactionInfo.txid` is lowercase, but the engine read `tx.txId`, so `edgeTransaction.txid` was `undefined` and `CurrencyEngine.normalizeAddress(undefined)` threw `undefined is not an object (evaluating 'address.toLowerCase')` in `queryTransactions` on every [ARRR](#arrr) sync poll, before `updateTransactionRatio(1)`. Changed `txId` to `txid` in `PiratechainEngine` and `rnPirateWallet.d.ts`. Watch for other camelCase-vs-lowercase mismatches: the SDK's `camelize` only converts snake_case, so `txid` and `arrrtoshis` (no underscore) stay lowercase. +- **Verified:** a real ARRR send broadcast to another wallet in the account ([section 7](#7-testing)), retiring the crash workaround end to end. +- **Fixed (Bugbot review):** `selectNamespace` marked the namespace active before `set_tunnel` succeeded, so a failed Direct-tunnel call could not be retried (the early return left the namespace on the default Tor transport). Phase 4 removed `selectNamespace` entirely. + +- **Observed (fixed in phase 4):** with more than one ARRR wallet, the SDK's single active namespace meant only the last-selected wallet synced and stayed spendable; the others' background pollers read the wrong namespace. The single-wallet send path was unaffected (the send succeeded), but concurrent multi-wallet sync was broken. + +### Phase 4: one device-scoped registry + +The Pirate team confirmed the intended storage model, which is not the one phase 2 built: `configure_wallet_storage` is global, only one namespace is active at a time, and switching cancels active sync and clears the registry and caches. One namespace per **device**, holding many wallets that share the block cache, is the design; concurrency comes from wallet-scoped synchronizers. + +| Diverged in phase 2 | Shipped in phase 4 | +|---|---| +| One namespace per wallet, switched on every wallet-scoped call | One namespace per device, configured once at first use | +| Passphrase = HMAC of the wallet seed (`piratechainCrypto.ts`) | Random 32-byte per-device secret in local storage (`piratechainDeviceStorage.ts`) | +| Fixed throwaway probe namespace for wallet-free reads | The device registry serves them | +| Only the last-selected wallet synced; others polled the wrong namespace | Every wallet's synchronizer runs concurrently over a shared block cache | +| Initial `SYNCED` could be missed, stranding the engine at `STOPPED` | `getStatus()` backstop read once after subscribing | +| Chain tip probed by creating and deleting a throwaway wallet | Read from a registered wallet's `getSyncStatus().targetHeight`; the probe is the empty-registry fallback only | + +Old per-wallet registries are abandoned rather than migrated: wallets re-restore from their seeds into the device registry on first run, and the stale directories hold no unrecoverable state. + +- **Found while testing:** creating a new ARRR wallet crashed the app to springboard, because the chain-tip probe mutated the now-shared registry while three synchronizers were running against it. The crash report pinned it to a Rust panic in `pirate_wallet_service_invoke_json` reaching `abort` through `panic_cannot_unwind`. The fix is [the chain-tip change above](#chain-tip-without-mutating-the-registry); wallet creation then succeeded with all four wallets coexisting in the one registry. + +### Phase 5: npm dependency and the lightwalletd endpoint +- **Sketched:** swap the GUI off the vendored `file:` sibling onto the published `react-native-pirate-wallet@0.2.1`, merge up with `develop`, and close the e2e send that the phase-4 storage re-key blocked. +- **Shipped:** the npm swap, with the [XCFramework](#xcframework) assembly moved into `scripts/prepare.sh` because the repo disables install scripts ([section 6](#6-detailed-design-edge-react-gui-and-the-sdk-dependency)); and the `set_lightd_endpoint` fix, which is what actually made ARRR sync ([section 6](#the-lightwalletd-endpoint)). A clean clone reproduces every native artifact. +- **Diverged:** + - The e2e send still does not broadcast. It now fails at the SDK's gRPC broadcast rather than at spendability, a later failure than phase 4's. + - On current `develop` the iOS binary no longer links. `__TEXT` reaches 184MB against the arm64 +/-128MB branch range, so `ld` cannot place a [branch island](#branch-island). The Pirate static library is the largest contributor at roughly 187MB of arm64 code, but not the only one: the build linked on 2026-08-04 with the same library, and dropping `zcash`, `monero` and `zano` links it again. Dead-stripping, link reordering and `-ld_classic` were each tried and each failed identically. + +### Phase 6: unified wallet v1.1.6 (npm `0.3.0`) +- **Sketched:** move both repos to `react-native-pirate-wallet@0.3.0`, reconcile the plugin against whatever the release changed, re-drive the funded send to find out whether the phase-5 broadcast timeout survives the new [Supernova](#supernova) sync engine, and diagnose the GUI PR's red Travis. +- **Shipped:** a peer-range bump in the plugin, an exact-pin bump plus regenerated lockfiles in the GUI, and nothing else. + - Diffing the `0.3.0` tarball against `0.2.1` showed `src/index.js`, `src/index.d.ts` and both native bridge sources unchanged down to the byte, so the entire release is native and there was no API or wire surface to reconcile. + - The broadcast timeout is gone: the same send completes in 2.08s ([section 7](#7-testing) item 6), and sync is roughly 27x faster (item 7). + - Travis was red for an unrelated reason. A rebase had dropped the component change that rendered a `createWalletRow.` testID while leaving four snapshot lines expecting it; removing those four lines restores parity with `develop`. +- **Diverged:** + - The release notes suggested a larger simulator sidecar and therefore a worse link ceiling. It grew by about 1MB, and the Debug failure reproduces unchanged. + - The link ceiling is a Debug-configuration problem rather than a shipping blocker, which settles a question two earlier rounds left open. A Release device build links, and CI builds the branch green end to end ([section 6](#the-arm64-debug-link-ceiling)). + - The SDK's own default [lightwalletd](#lightwalletd) node stopped accepting connections entirely, independently of this release. + - `0.3.0` removed the wrapper's `postinstall`, so `scripts/prepare.sh` is now the only assembly path a plain install exercises. + - The dead default node turned two dormant review findings into real defects, both fixed here: the chain-tip probe could still run while synchronizers were live, and it resolved the tip against the SDK's default rather than the configured node ([section 5](#chain-tip-without-mutating-the-registry)). + - A synchronizer that failed to `start()` was left running with no handle to stop it, so a retry could stack a second poller on the same registry wallet. `makeSynchronizer` now closes it before rethrowing. + +### Phase 7: npm `0.3.2`, the split simulator packages and the failover pool +- **Sketched:** move both repos to `0.3.2` and incorporate its three release changes: the per-architecture iOS simulator packages, the sync recovery and stall fix, and the optional multi-server mode. +- **Shipped:** + - The dependency move, with `package-lock.json` and `ios/Podfile.lock` regenerated. Diffing the `0.3.2` tarball against `0.3.0` showed only `package.json`, `README.md` and the two packaging scripts changed, so again there was no API or wire surface to reconcile. + - The simulator split needs no consumer change beyond the version: the assembly script fuses the two thin archives itself. What it does add is a host requirement, since it fuses them with `xcrun lipo` ([section 6](#6-detailed-design-edge-react-gui-and-the-sdk-dependency)). + - Multi-server as an opt-in `lightwalletdFailoverUrls` list, defaulting to empty ([the lightwalletd failover pool](#the-lightwalletd-failover-pool)). +- **Diverged:** + - The wrapper documents no multi-server API, and its `README` [RPC](#rpc) list omits it. The surface was recovered by diffing the native symbol strings between the two sidecars, which produced `set_lightd_endpoint_pool` and a new `get_lightd_endpoint_config`, then confirmed on device rather than from documentation ([section 7](#7-testing) item 12). + - Edge has nowhere to fail over to. `lightd1.piratechain.com`, the second clearnet host the SDK carries, no longer resolves, so the pool ships empty and the feature waits on the info server. + - Automatic failover did not rescue a wallet whose primary was unreachable, at least not within the window measured ([section 7](#7-testing) item 13). + - ARRR sync on this simulator is far slower than the `0.3.0` round measured, which blocked the funded send ([section 7](#7-testing) item 14). + +### Phase 8: the endpoint, not the SDK + +- **Sketched:** run a controlled cold-sync A/B between `0.3.0` and `0.3.2` to decide whether to ship the bump or hold, then finally drive the funded send that phase 7 could not reach, and test automatic failover against a primary that accepts connections and then stops serving. +- **Shipped:** + - The A/B, and the verdict it produced: ship `0.3.2` and move the endpoint to `https://lightwalletd2.cryptoforge.cc:443` ([section 7](#7-testing) items 15 and 16). + - The failover pool flipped on by default with a real second node, and the SDK's own validation rules documented from the errors it returns ([the lightwalletd failover pool](#the-lightwalletd-failover-pool)). + - Both funded sends, on both configurations ([section 7](#7-testing) item 20). +- **Diverged:** + - The premise the A/B was built on turned out to be wrong. Two rows of the matrix say `0.3.2` regressed 20 to 40x; the third says it reaches the tip in 95 seconds against a different node. The regression is an interaction with one endpoint, not the SDK, and the fix is a config change rather than holding the bump. + - The Pirate team had already answered the second-node question, in a review comment on this PR on 2026-08-17, eight days before it was asked again. Reading the PR's own review threads would have supplied `lightwalletd1.cryptoforge.cc` and `lightwalletd2.cryptoforge.cc` at the start of this round instead of the end. + - Automatic failover is still not demonstrated, and now provably cannot be with the prescribed harness: the SDK refuses an unrecognized host as a pool primary ([section 7](#7-testing) item 19). + - Two things surfaced that neither the plugin nor Edge models: a spendability window after `SYNCED` (`ERR_SYNC_FINALIZING`), and synchronizers starting for archived wallets ([section 7](#7-testing) items 20 and 21). + +### Phase 9: the team's answer, and a real spendability gate + +- **Sketched:** integrate the Pirate team's 2026-08-27 reply to the report this doc then carried for them. It carried two items: ship `0.3.2` over `0.3.0`, and use `get_spendability_status` to disable spending rather than surfacing `ERR_SYNC_FINALIZING`. +- **Shipped:** + - `checkSpendable` in the engine, `getSpendability` on the bridged synchronizer, and the RPC's result typed and cleaned at the boundary ([the spendability window after `SYNCED`](#the-spendability-window-after-synced)). + - The GUI's translation of that error into localized retry copy, replacing the generic network-error card ([the not-yet-spendable send error](#the-not-yet-spendable-send-error)). + - Both answered items folded into the report so it reads as a resolved thread rather than an open question. +- **Diverged:** + - The status is not sufficient. A wallet reporting `spendable: true` with a validated anchor still had two broadcasts rejected on anchor grounds, and the status did not move afterwards ([section 7](#7-testing) item 22). The gate shipped as recommended, plus a second route on the broadcast error, plus a follow-on question back to the team. + - The RPC was never missing. `get_spendability_status` shipped in `0.3.2` and is in the wrapper's README; the previous round read the SDK's symbol table for the pool RPCs and the README for the rest, and reported a gap that a fuller read of the same file would have closed. The team answered a question that Edge could have answered itself. + +### Phase 10: npm `0.3.4`, the keychain registry and signing sessions + +- **Sketched:** move both repos to `0.3.4` and take every change the release carries: the typed `reasonCode` set, the wallet-scoped broadcast, the endpoint pool diagnostic, and the opt-in secure storage with its wallet signing sessions. +- **Shipped:** + +| Before | After | +|---|---| +| Registry passphrase minted in JS, plaintext on the plugin [disklet](#disklet) (`piratechainDeviceStorage.ts`), handed over through `setDevicePassphrase` | `configureSecureAccountStorage({ accountId: 'edge-pirate-keychain' })`; the native module mints and keeps the credential ([registry storage](#registry-storage)) | +| Signing keys unlocked whenever the registry is | Wrapped under an HMAC of the mnemonic, unlocked per synchronizer start and before each send, locked on stop ([wallet signing sessions](#wallet-signing-sessions)) | +| `reasonCode` an undocumented string; copy branched on the two booleans | Closed set typed and documented; copy branches on it, booleans as fallback | +| No view into which pool node the SDK uses | `get_lightd_endpoint_pool_diagnostics` logged once per start ([endpoint pool diagnostics](#endpoint-pool-diagnostics)) | +| `PiratechainWalletConfig` carried endpoint config and identity together | Split into the registry identity and `PiratechainSynchronizerConfig`, since `deriveViewingKey` needs only the former | + +- **Diverged:** + - The broadcast change needed no plugin code. `send(walletId, ...)` was already the plugin's call and the wrapper routes it through the new wallet-scoped `broadcastTransaction`. + - The registry could not keep its account id. The directory derives from the id alone, so the same id under a new credential would have pointed the [Keychain](#keychain) passphrase at a registry encrypted under the old one; `edge-pirate-device` is abandoned rather than migrated, which costs development devices a rescan and costs users nothing, since nothing shipped with it. + - The SDK's "Edge account session credential" does not exist on the plugin side, so the credential is derived from the mnemonic ([decision 6](#decision-6-the-signing-session-credential-is-an-hmac-of-the-mnemonic)). + - The pool diagnostic's log line never reached the app's persisted logs during the drive ([section 7](#7-testing) item 25); the level moved to warn. + - The typed endpoint setters (`setLightdEndpoint`, `setLightdEndpointPool`) are not adopted this round; the raw RPC the plugin sends still works and the change is deferred so the round stays on the four items the release announced. +- **Re-read, 2026-09-02:** the team's full note was checked again after the orchestration had served it truncated. Every item it names is in the branch, including the lifecycle it prescribes: protection enabled once, unlock on later account unlocks, lock on account lock or sign-out. The plugin enables protection at a wallet's first synchronizer start rather than immediately after create or restore, which is the one timing difference from their note. In Edge those are the same moment, because a new or restored wallet starts its engine at once, and the enable call needs the registry entry that `ensureWallet` creates on that same start. No code changed. +- **History fold, 2026-09-03:** the branch's 26 commits, which narrated the SDK journey (v1.1.5, then 0.3.0, then 0.3.4, seven revisions of this doc, a registry rewritten by a later keychain commit), are folded into six that each describe the end state. This doc rides in the first commit, and the file is renamed from `piratechain-sdk-v115-reconcile.md` because the reconciliation is no longer what it describes. The tree is unchanged apart from the rename and the doc's code stamp. +- **Trimmed, 2026-09-05:** the report section this doc carried for the Pirate team is removed. Four of its five items had been answered (the 0.3.2 sync spread, the typed endpoint surface, spendability after `SYNCED`, the status that read clean while the node rejected the anchor) and each answer already lives in the design sections above; the still-open asks are listed below. npm's latest plugin is still `0.3.4`, so no upgrade was owed. +- **Link ceiling re-measured, 2026-09-07:** one untrimmed Debug simulator build of the branch on `0.3.4` fails to link with the same `arm64_b26` fixup error as on 2026-08-17, 2.2MB further out of range ([section 6](#the-arm64-debug-link-ceiling)). The local three-library trim stays the way to build Debug for the simulator; Release is unaffected. + +### Phase 11: the first human review + +- **Asked (2026-09-11, nine threads on the plugin PR and three on the GUI PR):** compile against the package's own types instead of a hand-written ambient declaration; declare `create-hmac`; use the typed endpoint wrappers; gate `getMaxSpendable` like `makeSpend`; record a transaction's seen height only after it is processed; stop unlocking the signing key at start; expose `start` on the bridge instead of the status sentinel; revert the dead `rpcNode.defaultHost`; drop the redundant `prepare.sh` assembly line; state the dependency footprint; keep the plugin's spendability message in the send scene. +- **Shipped:** all of it, folded into the commits that own each surface, with one divergence: protection is still enabled at first sight rather than lazily inside `send`, so a wallet that never sends still has its spending keys wrapped at rest ([wallet signing sessions](#wallet-signing-sessions)); the key itself is now unlocked only for the span of a send. `SpendabilityStatus` types its anchor heights as `number`, but a finalizing wallet reports `null` (observed on the simulator, 2026-09-05), so the plugin keeps its own cleaner for that result rather than trusting the declaration. +- **Found on the way:** the `automatic_failover: true` the raw pool call carried since phase 7 never did anything; the service derives the flag from the alternates list ([the failover pool](#the-lightwalletd-failover-pool)). The Debug simulator build also needs the three excluded native modules' io factories stubbed on the GUI side, since `makeMoneroIo` and `makeZanoIo` throw at construction without their native module and take `EdgeCoreManager` down with them; that is test scaffolding, never committed. + +### Phase 12: the re-review + +- **Asked (2026-09-15, two threads on the plugin PR):** throw `PendingFundsError` rather than a plain `Error` for the pre-sync refusal, since the send scene's `ErrorCard` renders a plain one as a network failure worth reporting; clean the poller's transaction snapshot per entry instead of rejecting the whole array and falling back to an uncleaned `listTransactions`. +- **Shipped:** both, folded into the commits that own each surface. The same review corrected its own earlier premise, that `makeSpend` already raised `PendingFundsError` before sync; it does so only once the wallet reports `SYNCED` and still cannot spend. +- **Found on the way:** the array fallback could only ever return what the cleaner had just rejected, and it re-ran the poller's own full-history fetch on every `getTransactions` call for as long as the malformed row existed. + +### Open with the Pirate Chain team + +- The SDK's baked-in default lightwalletd, `64.23.167.130:9067`, drops packets. An unconfigured wallet sits in `Headers` at zero blocks per second with no error: `test_node` against a named node still succeeds, the chain tip still resolves, and `sync_status` reports `SYNCING`. The ask is a default that answers, or an unreachable endpoint surfaced through the synchronizer's `onError` callback. +- `set_tunnel` is absent from the README's [RPC](#rpc) list, so the plugin still reaches it through raw `invoke`. +- `broadcast_tx` does not follow the pool's failover: with the primary returning 502 and the sync engine already on the alternate, every broadcast still went to the primary and failed ([section 7](#7-testing) item 26). A wallet that can sync over the pool should be able to broadcast over it. +- The two Android binary packages carry no `os` or `cpu` metadata, so every Linux runner and every Mac downloads 280MB it cannot use; an `os`/`cpu` gate on them would be a cheap win for every consumer. +- `SpendabilityStatus` types `anchorHeight` and `validatedAnchorHeight` as `number`, but a finalizing wallet reports them as `null`; the declaration should say `number | null`. +- On `0.3.2` a cold resync of the same wallet took 72 seconds against `lightwalletd2.cryptoforge.cc` and a projected 30 minutes against `lightd1.pirate.black` ([section 7](#7-testing)). The team's answer was that the resync is network-bound; why one host produces a 400x spread on the same request pattern is unexplained. + + +## 9. Decisions + +### Decision 1: one device-scoped registry namespace +- **Chosen:** a single registry namespace per device (`edge-pirate-keychain`, selected through `configureSecureAccountStorage`), holding every [ARRR](#arrr) wallet keyed by alias, with one synchronizer per wallet. +- **Evidence:** the Pirate team confirmed `configure_wallet_storage` is global state, that switching cancels active sync and clears the registry and caches, and that one namespace per device sharing a block cache is the intended model. Phase 2's per-wallet namespaces produced exactly the predicted failure on device: with two ARRR wallets, only the last-selected one synced and stayed spendable while the others' pollers read the wrong namespace. +- **Rejected:** per-wallet namespaces (phase 2), which cannot support concurrent sync because every wallet-scoped call would have to re-select and thereby cancel another wallet's sync. +- **Rejected:** per-Edge-account namespaces, which have the same defect one level up (switching accounts still clears the shared block cache) and also need an account secret the bridge does not hold. +- **Rejected:** one SDK context per wallet, which the [RN](#rn) binding does not expose (`createPirateWalletSdk` wraps a single native module instance). +- **Reopen if:** the SDK gains per-wallet or per-context storage selection, making isolation possible without cancelling sync. + +### Decision 2: send through the SDK, not raw invoke +- **Chosen:** `walletSdk.send(walletId, outputs, fee)`. +- **Evidence:** v1.1.5 fixed the camelization bug (merged from [PR #19](https://github.com/PirateNetwork/Pirate-Unified-Light-Wallet/pull/19)) that forced the raw path, and its `send()` both preserves the opaque intermediate payloads and normalizes amounts to strings. +- **Rejected:** keeping the manual `build_tx` / `sign_tx` / `broadcast_tx` over raw `invoke`, which now duplicates SDK logic and, because raw `invoke` skips the SDK's amount normalization, would send unnormalized numeric amounts. +- **Reopen if:** a future SDK release changes `send()` semantics or reintroduces the payload rewrite. + +### Decision 3: the registry credential lives in the OS keychain +- **Chosen:** `configureSecureAccountStorage({ accountId: 'edge-pirate-keychain' })`. The native module mints the passphrase, keeps it in the iOS [Keychain](#keychain) or Android [Keystore](#keystore), and never returns it to JavaScript. +- **Evidence:** the automated security review's finding against the previous design was that a plaintext passphrase on the plugin [disklet](#disklet) let anyone who can read the app sandbox spend ARRR without the Edge password, a weaker bar than the rest of the app, where the mnemonic is protected by account encryption. The 0.3.4 entry point removes the secret from JavaScript and from the sandbox's plain files: on Android it is [AES-GCM](#aes-gcm) ciphertext under an `AndroidKeyStore` key, on iOS a Keychain item. The registry directory derives from `accountId` alone on both platforms, which is why the id changed along with the credential. +- **Rejected:** the random per-device passphrase in the plugin's disklet, the design this replaces. It satisfied the SDK's storage contract and the doc carried it as an accepted risk for two rounds; it was the best available seam before the SDK offered a native one. +- **Rejected:** [HMAC](#hmac) of a wallet seed (phase 2), which cannot key a registry holding many wallets without privileging one wallet's seed. +- **Rejected:** a hardcoded constant, the pattern the security review flagged first. +- **Rejected:** reusing `edge-pirate-device` as the secure account id. Same directory, different credential: a device carrying the old registry would fail to open it rather than start fresh. +- **Reopen if:** the credential needs to survive an app reinstall or move between devices, which neither keychain does for this item (the cost is a rescan from birthday, not funds). + +### Decision 4: a TLS endpoint and a real failover pool, rather than holding at `0.3.0` +- **Chosen:** ship `0.3.2`, move the primary to `https://lightwalletd2.cryptoforge.cc:443`, and enable the pool with `https://lightwalletd1.cryptoforge.cc:443` as its alternate. +- **Evidence:** the three-row matrix in [section 7](#7-testing) item 15. `0.3.2` against the old node projects past half an hour for a wallet 1.06M blocks behind; the same build reaches the tip in 95 seconds against the new one, and a funded send broadcast from that configuration ([item 20](#7-testing)). The pool was accepted and read back correctly with those two nodes ([item 18](#7-testing)). +- **Rejected:** holding at `0.3.0`, which was the working verdict until the third row was measured. It would have kept the plaintext transport, kept a node whose round trip is 4.5x the alternative's, and given up `0.3.2`'s pool support for a problem that is not `0.3.2`'s. +- **Rejected:** keeping `lightd1.pirate.black:9067` as a pool alternate. The SDK refuses a pool that mixes transports, so a TLS primary cannot list it; it stays available through the info server if the cryptoforge hosts ever go dark, at the cost of dropping back to plaintext. +- **Rejected:** shipping the pool empty and letting the info server populate it. That was the right call when Edge knew of one node; it is now just a slower path to the same list, and it leaves every existing install on a single endpoint until a payload lands. +- **Reopen if:** the cryptoforge hosts stop answering, or `lightd1.pirate.black` starts serving `0.3.2` at the rate it serves `0.3.0`, which would make the older node a viable third pool member again. +- **Confirmed 2026-08-27:** the Pirate team recommends `0.3.2` over `0.3.0` on its own merits, a resync correctness change and better handling of the heavy blocks near 2,400,000, and attributes the slow row to a one-time live-blocks resync bounded by network rather than by the engine. The decision stands on the measurement either way; this removes the residual worry that the bump carried an engine regression. + +### Decision 5: gate the spend on `get_spendability_status` +- **Chosen:** `makeSpend` reads `get_spendability_status` after the `SYNCED` check and throws `PendingFundsError` when the wallet is not spendable, which the GUI renders as localized retry copy. +- **Evidence:** the anchor window is real and was measured at ninety seconds ([section 7](#7-testing) item 20). The Pirate team named the [RPC](#rpc) on 2026-08-27 in answer to this doc's report; the wrapper has carried it since `0.3.2` and this doc simply missed it. +- **Rejected:** mapping `ERR_SYNC_FINALIZING` to retryable copy in the GUI, which was the alternative this doc proposed before the RPC was known. It fires only after the user confirms a send, so the spend still fails rather than being unavailable, and it couples the GUI to one SDK's error string. +- **Rejected:** reporting zero spendable balance during the window so the existing insufficient-funds path handles it. The balance is real and the message would be false. +- **Also:** `broadcastTx` restates `ERR_SYNC_FINALIZING` and `ERR_WITNESS_REPAIR_QUEUED` as the same error. On `0.3.2` the status alone caught neither ([section 7](#7-testing) item 22); 0.3.4 keeps a queued repair visible until the node will accept the anchor, and the backstop stays because it costs nothing. Every other native error keeps its own text. +- **Rejected:** treating an unreadable status as not spendable. `SYNCED` alone was the whole gate before this RPC existed, so a native surface that drifts or fails would silently disable every ARRR send; the read falls through to the old behavior instead. +- **Reopen if:** the SDK adds a spendability signal to the synchronizer's update events, which would let the send scene disable its slider before the user reaches it rather than at `makeSpend`. The `reasonCode` values are documented as of 0.3.4 and the copy branches on them. + +### Decision 6: the signing session credential is an HMAC of the mnemonic +- **Chosen:** `deriveSigningCredential(mnemonic)`, an [HMAC](#hmac) keyed by a fixed domain string over [SHA-256](#sha-256), computed in the engine on every synchronizer start and held only in the config object handed to the bridge ([wallet signing sessions](#wallet-signing-sessions)). Protection is enabled the first time a wallet is seen and locked at once; the credential unlocks the key only for the span of a send. +- **Evidence:** the SDK asks for "the Edge account session credential", and edge-core-js exposes no such thing to a currency plugin: engines receive wallet keys through `syncNetwork`'s `privateKeys`, nothing account-scoped. The mnemonic has the lifecycle the SDK wants, present exactly while the account is unlocked and gone when the engine is killed, and it is already the secret the wallet's spending keys derive from, so a one-way function of it adds no new secret to protect. `create-hmac` is declared as a direct dependency at the version the repo's `overrides` already pinned, with `@types/create-hmac` for its types; before the 2026-09-11 review it resolved only through npm hoisting, behind a hand-written declaration. +- **Rejected:** leaving signing protection off. Then the Keychain credential alone unlocks signing, and anything that can call the native module while the device is unlocked can sign, which is the residual [decision 3](#decision-3-the-registry-credential-lives-in-the-os-keychain) leaves open. +- **Rejected:** a random per-wallet secret on the disklet, which reintroduces the plaintext-secret-in-the-sandbox problem this round retires. +- **Rejected:** the raw mnemonic as the credential. The SDK receives it once at restore already, but a credential that is itself the seed would put the seed through every unlock call and into whatever the SDK does with credentials; the HMAC is one-way and domain-separated. +- **Reopen if:** edge-core-js gives plugins an account-scoped session secret, which would let the credential lock on account lock without the plugin holding wallet keys at all. + +## 10. Glossary + +### ARRR +The currency code for Pirate Chain, the coin this plugin holds and spends. Every balance and amount in this doc is quoted in ARRR. See [pirate.black](https://pirate.black). + +### Birthday height +The block height a wallet starts scanning from, recorded when the wallet is created. A birthday above the true tip leaves earlier notes unscanned, which is what makes the source of [the chain tip](#chain-tip-without-mutating-the-registry) matter. See the [SDK repo](https://github.com/PirateNetwork/Pirate-Unified-Light-Wallet). + +### Branch island +A stub the linker inserts when a call's target sits further away than the branch instruction can encode. arm64 `B`/`BL` reach +/-128MB, and the Debug link failure in [section 6](#the-arm64-debug-link-ceiling) happens because `ld` will not route an island into a section it does not treat as code. See [ld64](https://github.com/apple-oss-distributions/ld64). + +### Consensus branch id +An identifier for the network's active consensus rules, mixed into a transaction's signature so a transaction built under one rule set is invalid under another. Ironwood activation changes it, which is what stops an older SDK from spending. See [ZIP 200](https://zips.z.cash/zip-0200). + +### AES-GCM +Authenticated symmetric encryption, AES in Galois/Counter Mode. The Android bridge wraps the registry credential with it under a Keystore key before storing the ciphertext in shared preferences ([decision 3](#decision-3-the-registry-credential-lives-in-the-os-keychain)). See [NIST SP 800-38D](https://csrc.nist.gov/pubs/sp/800/38/d/final). + +### CSPRNG +Cryptographically secure pseudo-random number generator. `io.random` is edge-core-js's, and the tools mint new wallet entropy from it; the registry credential is minted natively instead ([decision 3](#decision-3-the-registry-credential-lives-in-the-os-keychain)). See [NIST SP 800-90A](https://csrc.nist.gov/pubs/sp/800/90/a/r1/final). + +### Disklet +Edge's file-storage interface. `io.disklet` is device-local and never syncs to the Edge account; it held the registry passphrase file until [decision 3](#decision-3-the-registry-credential-lives-in-the-os-keychain) moved the credential into the OS keychain. See [disklet](https://github.com/EdgeApp/disklet). + +### FFI +Foreign function interface, the boundary where the SDK's Swift and Kotlin layers call into its Rust core. A Rust panic cannot unwind across it, so it aborts the process instead of raising a catchable error, which is how the chain-tip probe crash presented. See the [Rust FFI docs](https://doc.rust-lang.org/nomicon/ffi.html). + +### HMAC +Hash-based message authentication code, a keyed hash. Phase 2 derived the registry passphrase as an HMAC of the wallet seed, which phase 4 dropped; [decision 6](#decision-6-the-signing-session-credential-is-an-hmac-of-the-mnemonic) uses one, under a fixed domain, as the wallet signing session credential. See [RFC 2104](https://www.rfc-editor.org/rfc/rfc2104). + +### Ironwood +Pirate Chain's successor to the Sapling shielded pool, and the reason this work pins v1.1.6: that release carries the activation height and the post-activation consensus branch id. See the [SDK repo](https://github.com/PirateNetwork/Pirate-Unified-Light-Wallet). + +### IO bridge +The `nativeIo` object an edge-core-js plugin uses to reach React Native APIs from inside the plugin context. `piratechainIo.ts` is this plugin's, and it is the only file that calls the SDK. See [edge-core-js](https://github.com/EdgeApp/edge-core-js). + +### Keychain +Apple's per-app encrypted credential store, backed by the Secure Enclave on device. The iOS bridge keeps the registry credential in it, so `configureSecureAccountStorage` unlocks the registry without the credential ever reaching JavaScript. See [Apple's Keychain services](https://developer.apple.com/documentation/security/keychain-services). + +### Keystore +The Android system credential store (`AndroidKeyStore`), which holds keys that cannot be exported to the app process. The Android bridge wraps the registry credential with a Keystore key and stores the ciphertext in private shared preferences. See [Android Keystore](https://developer.android.com/privacy-and-security/keystore). + +### lightwalletd +The light-client server a shielded wallet fetches compact blocks from and broadcasts transactions through. Edge points every ARRR wallet at its own node rather than the SDK's default ([the lightwalletd endpoint](#the-lightwalletd-endpoint)). See [lightwalletd](https://github.com/zcash/lightwalletd). + +### RN +React Native. The RN binding in this doc is `react-native-pirate-wallet`, the JS and ObjC/Kotlin layer the Pirate team publishes over their Rust core. See [native modules](https://reactnative.dev/docs/native-modules-intro). + +### RPC +Remote procedure call. Every request the plugin makes of the SDK's Rust core is one JSON envelope naming a method, so this doc names each one by its method string (`get_balance`, `set_lightd_endpoint_pool`). See the [SDK repo](https://github.com/PirateNetwork/Pirate-Unified-Light-Wallet). + +### Sapling +The Zcash shielded pool Pirate Chain inherited, whose addresses start `zs1`. Balances and receive addresses stay Sapling until Ironwood activates. See [ZIP 205](https://zips.z.cash/zip-0205). + +### SHA-256 +The 256-bit member of the SHA-2 hash family. It is the hash inside the HMAC that derives the wallet signing session credential from the mnemonic ([decision 6](#decision-6-the-signing-session-credential-is-an-hmac-of-the-mnemonic)). See [FIPS 180-4](https://csrc.nist.gov/pubs/fips/180-4/upd1/final). + +### Shielded pool +A pool of notes whose amounts and addresses are encrypted on chain, spent by proving knowledge of a note rather than by revealing it. The plugin never names a pool: `get_balance` covers both and `send` sources notes from whichever holds them. See the [Zcash protocol spec](https://zips.z.cash/protocol/protocol.pdf). + +### Supernova +The Pirate team's rewritten sync engine, shipped in unified wallet v1.1.6. Measured here at about 47,700 blocks/sec against roughly 1,800 on the previous engine ([section 7](#7-testing) item 7). See the [SDK repo](https://github.com/PirateNetwork/Pirate-Unified-Light-Wallet). + +### Wallet registry +The SDK's own encrypted store of wallets and their spending keys. `configure_wallet_storage` selects exactly one registry per device, and every ARRR wallet lives inside it keyed by alias ([registry storage](#registry-storage)). See the [SDK repo](https://github.com/PirateNetwork/Pirate-Unified-Light-Wallet). + +### xcframework +Apple's container format holding one binary per platform and architecture slice. `PirateWalletNative.xcframework` is assembled from two npm sidecar packages, one per slice, because the wrapper tarball ships no binaries. See [Apple's docs](https://developer.apple.com/documentation/xcode/creating-a-multi-platform-binary-framework-bundle). + +### yaob +The asynchronous object bridge edge-core-js uses between the plugin context and the React Native side. The engine reaches the [IO bridge](#io-bridge) over it, so every bridge method is a promise and every callback is an event. See [yaob](https://github.com/swansontec/yaob). + +## 11. References +- Asana task 1216926437132721 and its recorded Pirate Chain team thread. +- [PirateNetwork/Pirate-Unified-Light-Wallet#19](https://github.com/PirateNetwork/Pirate-Unified-Light-Wallet/pull/19) (merged): the upstream runtime and payload fixes now in v1.1.5. +- v1.1.5 release artifact `pirate-unified-wallet-react-native-plugin-artifacts-v1.1.5.zip` and its bundled documentation (account-scoped storage contract). +- `react-native-pirate-wallet@0.3.4` on npm: `configureSecureAccountStorage`, the wallet signing session RPCs, the typed `SpendabilityStatus` and endpoint API, `getLightdEndpointPoolDiagnostics`, and the wallet-scoped `broadcastTransaction`. +- `react-native-pirate-wallet@0.3.0` on npm (unified wallet v1.1.6): the [Ironwood](#ironwood) activation height, the [Supernova](#supernova) sync engine, and the four `0.3.0` native sidecar packages. It documents the pre- and post-activation address selection rules and the `validate_consensus_branch` compatibility check. + +## 12. Post-implementation retrospective + +Written from the two branches as they stand, before merge. + +### Estimate vs. actuals + +| Item | Expected | Actual | +|---|---|---| +| The v1.1.5 reconcile | A wire-format pass over a finished rewrite | Three further phases before a funded send worked ([phase history](#8-phase-history)) | +| Registry model | The per-wallet namespaces phase 2 shipped | Namespace selection is device-global, so it was rebuilt at device scope ([decision 1](#decision-1-one-device-scoped-registry-namespace)) | +| The `0.3.0` bump | An API reconcile on the scale of v1.1.5 | A peer-range bump and lockfiles; the release is entirely native | +| The broadcast timeout | An SDK defect needing a second upstream PR | Gone in v1.1.6, with no Edge change: five minutes to a timeout became 2.083s to a txid | +| The arm64 link failure | A shipping blocker wanting a dynamic framework | Debug-only. Release links locally and on CI ([section 6](#the-arm64-debug-link-ceiling)) | +| The `0.3.4` bump | A version bump plus a broadcast-signature fix | The broadcast change cost nothing; the storage change retired an accepted security risk and rewrote [decision 3](#decision-3-the-registry-credential-lives-in-the-os-keychain) | + +### Where this document was wrong or silent + +1. [Registry storage](#registry-storage) described one namespace per wallet through phase 3. That model cannot hold more than one ARRR wallet, because `configure_wallet_storage` selects device-globally and switching cancels the other wallets' sync. Rewritten in phase 4. +2. [Testing](#7-testing) carried a sync note claiming a clean build syncs in about 90 seconds and that a stuck sync was a broken-build artifact. Both were wrong; the scan was running against the SDK's own default node (item 4). +3. The doc was silent on what the chain-tip probe does to a shared registry until it aborted the app on device. [Chain tip without mutating the registry](#chain-tip-without-mutating-the-registry) exists because of that crash. +4. [The arm64 Debug link ceiling](#the-arm64-debug-link-ceiling) was written as an open shipping question for two rounds before anyone linked a Release build, which takes one command and settles it. +5. Every round through phase 7 treated "one reachable clearnet node" as a fact about the world. It was a fact about what had been looked at: the Pirate team named two more in a review comment on the plugin PR on 2026-08-17, and phase 8 found it there eight days later. The doc's [failover pool](#the-lightwalletd-failover-pool) section argued for an empty default from that premise, and [section 7](#7-testing) item 14 blamed sync throughput on the SDK from the same blind spot. +6. Phase 7 recorded "3 to 5 blocks/sec on `0.3.2`" without recording what wallet produced it. The figure came from a wallet whose birthday sat 64k blocks below the tip, which is not comparable to a scan from a 2024 birthday, and it sent phase 8 looking for an SDK regression that turned out to be an endpoint problem. A rate without its block range is not a measurement. +7. [Decision 3](#decision-3-the-registry-credential-lives-in-the-os-keychain) rejected the OS keychain in its earlier form as "a native dependency for a secret that guards device-local data the OS already sandboxes". The security review's point was that the sandbox is exactly the boundary a plaintext file trusts too much, and the SDK's 0.3.4 entry point made the keychain free. The rejection was cost reasoning applied to a security question. + +### What held + +- The plugin's public shape and the engine's transaction mapping are the same as before the rewrite. Every SDK change landed inside `piratechainIo.ts` behind the [IO bridge](#io-bridge), which is why the GUI carries a dependency swap and four `testID`s. +- Amounts as decimal strings, adopted in phase 2, survived every later phase and made the `0.3.0` bump a no-op on the wire. +- The fixes Edge wrote against the vendored fork went upstream in [PR #19](https://github.com/PirateNetwork/Pirate-Unified-Light-Wallet/pull/19) and shipped in v1.1.5, so no fork is carried. + +### Verification highlights + +- Funded send on `0.3.4` through the [keychain](#keychain) registry and a signing session, txid [`93f42a33af4161d697277f050da1c65c1dbf267c2aafdeaa1329ba063f64fbcb`](https://explorer.pirate.black/tx/93f42a33af4161d697277f050da1c65c1dbf267c2aafdeaa1329ba063f64fbcb), first slide, on a wallet restored two minutes earlier ([section 7](#7-testing) item 24). +- Funded send after the first human review, with the key unlocked only for the span of the send, txid [`0fc26e01781020bb994164f31526a4d21c9a8434e6bdafcd6f1ec4bacb9a8abe`](https://explorer.pirate.black/tx/0fc26e01781020bb994164f31526a4d21c9a8434e6bdafcd6f1ec4bacb9a8abe) ([section 7](#7-testing) item 27), and the first observed automatic failover, during a real primary outage (item 25). + +- Funded send on `0.3.0`, txid [`8d2e25d0e624cf714057b65311347a8a5d21cafbbae8f579e714fb979e94a579`](https://explorer.pirate.black/tx/8d2e25d0e624cf714057b65311347a8a5d21cafbbae8f579e714fb979e94a579), build through broadcast in 2.083s ([section 7](#7-testing) item 6). +- Resync of 918,347 blocks in 19.25s, about 47,700 blocks/sec against roughly 1,800 on `0.2.1` (item 7). +- `xcodebuild -sdk iphoneos -configuration Release` reports `BUILD SUCCEEDED` with all four native libraries autolinked, and Jenkins `test-gouda` #18 passed all four stages in 9m42s (item 9). +- A fresh clone of each branch plus `npm ci` and `npm run prepare` exits 0, and the GUI clone reproduces the [xcframework](#xcframework) with both slices (item 8). diff --git a/src/piratechain/PiratechainEngine.ts b/src/piratechain/PiratechainEngine.ts index 3f1769586..77fcc9fe9 100644 --- a/src/piratechain/PiratechainEngine.ts +++ b/src/piratechain/PiratechainEngine.ts @@ -1,4 +1,5 @@ -import { abs, add, eq, gt, lte, mul, sub } from 'biggystring' +import { abs, add, eq, gt, gte, lte, mul, sub } from 'biggystring' +import createHmac from 'create-hmac' import { EdgeCurrencyEngine, EdgeCurrencyEngineOptions, @@ -9,19 +10,20 @@ import { EdgeTransaction, EdgeWalletInfo, InsufficientFundsError, - NoAmountSpecifiedError + NoAmountSpecifiedError, + PendingFundsError } from 'edge-core-js/types' -import type { - ConfirmedTransaction, - SpendInfo, - StatusEvent -} from 'react-native-piratechain' +import type { TransactionInfo } from 'react-native-pirate-wallet' import { base16, base64 } from 'rfc4648' import { CurrencyEngine } from '../common/CurrencyEngine' import { PluginEnvironment } from '../common/innerPlugin' import { cleanTxLogs } from '../common/utils' -import type { PiratechainIo, PiratechainSynchronizer } from './piratechainIo' +import type { + PiratechainIo, + PiratechainSpendability, + PiratechainSynchronizer +} from './piratechainIo' import { makePiratechainSyncTracker, PiratechainSyncTracker @@ -44,11 +46,13 @@ export class PiratechainEngine extends CurrencyEngine< pluginId: string networkInfo: PiratechainNetworkInfo otherData!: PiratechainWalletOtherData - synchronizerStatus!: StatusEvent['name'] + synchronizerStatus!: 'STOPPED' | 'SYNCING' | 'SYNCED' availableZatoshi!: string - initialNumBlocksToDownload!: number birthdayHeight: number queryMutex: boolean + /** Heights at which each txid was last processed, to skip stable + * transactions when reprocessing the SDK's full history list: */ + processedTxHeights: Map makeSynchronizer: PiratechainIo['makeSynchronizer'] // Synchronizer management @@ -57,7 +61,6 @@ export class PiratechainEngine extends CurrencyEngine< synchronizer?: PiratechainSynchronizer synchronizerPromise: Promise synchronizerResolver!: (synchronizer: PiratechainSynchronizer) => void - lastUpdateFromSynchronizer?: number constructor( env: PluginEnvironment, @@ -76,6 +79,7 @@ export class PiratechainEngine extends CurrencyEngine< this.synchronizerResolver = resolve }) this.queryMutex = false + this.processedTxHeights = new Map() this.started = false } @@ -85,23 +89,16 @@ export class PiratechainEngine extends CurrencyEngine< } initData(): void { - // walletLocalData - if (this.otherData.blockRange.first === 0) { - this.otherData.blockRange = { - first: this.birthdayHeight, - last: this.birthdayHeight - } - } - // Engine variables - this.initialNumBlocksToDownload = -1 - this.synchronizerStatus = 'DISCONNECTED' + this.synchronizerStatus = 'STOPPED' this.availableZatoshi = '0' + this.processedTxHeights.clear() } initSubscriptions(): void { if (this.synchronizer == null) return - this.synchronizer.on('update', async payload => { + const { synchronizer } = this + synchronizer.on('update', async payload => { const { lastDownloadedHeight, networkBlockHeight } = payload this.updateBlockHeight(networkBlockHeight) this.syncTracker.updateBlockProgress({ @@ -111,22 +108,17 @@ export class PiratechainEngine extends CurrencyEngine< }) await this.queryAll() }) - this.synchronizer.on('statusChanged', async payload => { + synchronizer.on('statusChanged', async payload => { this.synchronizerStatus = payload.name await this.queryAll() }) - this.synchronizer.on('error', async payload => { + synchronizer.on('error', payload => { + // The polling synchronizer retries transient errors on its own: this.log.warn(`Synchronizer error: ${payload.message}`) - if (payload.level === 'critical') { - await this.killEngine() - this.lastUpdateFromSynchronizer = undefined - await this.startEngine() - } }) } async queryAll(): Promise { - this.lastUpdateFromSynchronizer = Date.now() if (this.queryMutex) return this.queryMutex = true try { @@ -150,10 +142,10 @@ export class PiratechainEngine extends CurrencyEngine< async queryBalance(): Promise { if (!this.isSynced() || this.synchronizer == null) return try { - const balances = await this.synchronizer.getBalance() - if (balances.totalZatoshi === '-1') return - this.availableZatoshi = balances.availableZatoshi - this.updateBalance(null, balances.totalZatoshi) + const balance = await this.synchronizer.getBalance() + // `total` includes pending; `spendable` is the confirmed balance: + this.availableZatoshi = String(balance.spendable) + this.updateBalance(null, String(balance.total)) this.syncTracker.updateBalanceRatio(1) } catch (e: any) { this.warn('Failed to update balances', e) @@ -164,42 +156,33 @@ export class PiratechainEngine extends CurrencyEngine< async queryTransactions(): Promise { if (this.synchronizer == null) return try { - let first = this.otherData.blockRange.first - let last = this.otherData.blockRange.last - const blocksToHeight = - this.walletLocalData.blockHeight - this.birthdayHeight - while (this.isSynced() && last <= this.walletLocalData.blockHeight) { - const transactions = await this.synchronizer.getTransactions({ - first, - last - }) - - for (const tx of transactions) this.processTransaction(tx) - - if (last === this.walletLocalData.blockHeight) { - first = this.walletLocalData.blockHeight - this.walletLocalDataDirty = true - this.syncTracker.updateTransactionRatio(1) - break - } - - first = last + 1 - last = - last + this.networkInfo.transactionQueryLimit < - this.walletLocalData.blockHeight - ? last + this.networkInfo.transactionQueryLimit - : this.walletLocalData.blockHeight - - this.otherData.blockRange = { - first, - last - } - this.walletLocalDataDirty = true - - if (blocksToHeight > 0) { - const historyRatio = (last - this.birthdayHeight) / blocksToHeight - this.syncTracker.updateTransactionRatio(historyRatio) + const transactions = await this.synchronizer.getTransactions() + for (const tx of transactions) { + // The SDK returns the full history each time, so only process + // transactions that are new or whose height moved: + const height = tx.height ?? 0 + const seenHeight = this.processedTxHeights.get(tx.txid) + if (seenHeight === height) continue + + if (seenHeight != null && height < seenHeight) { + // A height that moved BACKWARDS means a reorg unmined the + // transaction or re-mined it lower. `addTransaction` only writes a + // height that moved forward, so this update cannot land; say so + // rather than letting it vanish downstream. The stale height is + // wrong by the reorg depth until the transaction confirms above it. + this.warn( + `Reorged height for ${tx.txid} not applied: ${seenHeight} -> ${height}` + ) + this.processedTxHeights.set(tx.txid, height) + continue } + // Record the height only once the transaction is in, so one that + // throws here is retried on the next poll instead of skipped forever: + this.processTransaction(tx) + this.processedTxHeights.set(tx.txid, height) + } + if (this.isSynced()) { + this.syncTracker.updateTransactionRatio(1) } } catch (e: any) { this.error( @@ -209,41 +192,39 @@ export class PiratechainEngine extends CurrencyEngine< } } - processTransaction(tx: ConfirmedTransaction): void { - let netNativeAmount = tx.value + processTransaction(tx: TransactionInfo): void { + // A negative amount is a send and already includes the network fee: + const netNativeAmount = String(tx.amount) const ourReceiveAddresses = [] - if (tx.toAddress != null) { - // check if tx is a spend - netNativeAmount = `-${add( - netNativeAmount, - this.networkInfo.defaultNetworkFee - )}` - } else { + if (gte(netNativeAmount, '0')) { ourReceiveAddresses.push(this.walletInfo.keys.publicKey) } - const edgeMemos: EdgeMemo[] = tx.memos - .filter(text => text !== '') - .map(text => ({ - memoName: 'memo', - type: 'text', - value: text - })) + const edgeMemos: EdgeMemo[] = + tx.memo != null && tx.memo !== '' + ? [ + { + memoName: 'memo', + type: 'text', + value: tx.memo + } + ] + : [] const edgeTransaction: EdgeTransaction = { - blockHeight: tx.minedHeight, + blockHeight: tx.height ?? 0, currencyCode: this.currencyInfo.currencyCode, - date: tx.blockTimeInSeconds, + date: tx.timestamp, isSend: netNativeAmount.startsWith('-'), memos: edgeMemos, nativeAmount: netNativeAmount, - networkFee: this.networkInfo.defaultNetworkFee, + networkFee: String(tx.fee), networkFees: [], otherParams: {}, ourReceiveAddresses, // blank if you sent money otherwise array of addresses that are yours in this transaction signedTx: '', tokenId: null, - txid: tx.rawTransactionId, + txid: tx.txid, walletId: this.walletId } this.addTransaction(null, edgeTransaction) @@ -256,37 +237,64 @@ export class PiratechainEngine extends CurrencyEngine< this.currencyInfo.pluginId )(opts?.privateKeys) - const { rpcNode } = this.networkInfo this.birthdayHeight = piratechainPrivateKeys.birthdayHeight try { // Replace this.synchronizerPromise with a fresh promise. The old promise might have already been resolved this.synchronizerPromise = this.makeSynchronizer({ - mnemonicSeed: piratechainPrivateKeys.mnemonic, + name: base16.stringify(base64.parse(this.walletId)), + mnemonic: piratechainPrivateKeys.mnemonic, birthdayHeight: piratechainPrivateKeys.birthdayHeight, - alias: base16.stringify(base64.parse(this.walletId)), - ...rpcNode + lightwalletdUrl: this.networkInfo.lightwalletdUrl, + lightwalletdFailoverUrls: this.networkInfo.lightwalletdFailoverUrls, + signingCredential: deriveSigningCredential( + piratechainPrivateKeys.mnemonic + ) }) this.synchronizer = await this.synchronizerPromise // People might be waiting on the old promise, so resolve that this.synchronizerResolver(this.synchronizer) + this.logEndpointDiagnostics(this.synchronizer) } catch (e) { - // The synchronizer cannot start if it isn't present. - if ( - String(e) === - 'Invariant Violation: `new NativeEventEmitter()` requires a non-null argument.' - ) { + // The synchronizer cannot start if the native module isn't present: + if (String(e).includes('native module is not linked')) { this.log.warn('SDK not present') } else throw e } this.initData() this.initSubscriptions() + // Start the poller only now that the listeners exist: its first status + // change fires synchronously inside `start`, and one emitted before the + // subscriptions would leave the engine stranded at STOPPED. + await this.synchronizer?.start() return await new Promise(resolve => { this.stopSyncing = resolve }) } + /** + * Records which lightwalletd the SDK selected from the configured pool and + * why it rejected the others. A wallet that never syncs otherwise looks the + * same whether every node is down or the pool was misconfigured, and this + * is the only surface that tells the two apart. The probe makes a round + * trip per endpoint, so it runs off the sync path and never blocks it. + */ + logEndpointDiagnostics(synchronizer: PiratechainSynchronizer): void { + synchronizer + .getEndpointDiagnostics() + .then(diagnostics => { + // Warn level, so it reaches the app's persisted logs at the default + // log level and ships with a user's log export: + this.log.warn(`Lightwalletd pool: ${JSON.stringify(diagnostics)}`) + }) + .catch((error: unknown) => { + this.log.warn( + `Failed to read the lightwalletd pool diagnostics: ${String(error)}` + ) + }) + } + async killEngine(): Promise { this.synchronizerPromise = new Promise(resolve => { this.synchronizerResolver = resolve @@ -309,8 +317,10 @@ export class PiratechainEngine extends CurrencyEngine< await super.killEngine() await this.clearBlockchainCache() await this.startEngine() - this.synchronizer - ?.rescan() + this.synchronizerPromise + .then(async synchronizer => { + await synchronizer.rescan(this.birthdayHeight) + }) .catch((e: any) => this.warn('resyncBlockchain failed: ', e)) this.initData() this.syncTracker.resetSync() @@ -318,6 +328,9 @@ export class PiratechainEngine extends CurrencyEngine< } async getMaxSpendable(): Promise { + // Before the wallet is synced `availableZatoshi` is still '0', which would + // read as no funds; refuse for the same reason `makeSpend` would instead: + await this.checkSpendable() const spendableBalance = sub( this.availableZatoshi, this.networkInfo.defaultNetworkFee @@ -328,8 +341,47 @@ export class PiratechainEngine extends CurrencyEngine< return spendableBalance } + /** + * The SDK reports `SYNCED` before its spend anchor is usable, and a send in + * that window fails inside the SDK with `ERR_SYNC_FINALIZING` only once the + * user has already confirmed it. `get_spendability_status` reports the + * window directly, so refuse the spend here instead: a `makeSpend` that + * throws leaves the send scene with no transaction, which is what keeps its + * confirm slider disabled until the wallet can actually spend. + */ + async checkSpendable(): Promise { + // Before sync the wallet has no spend anchor at all, which is the same + // wait the SDK reports after `SYNCED`, so it gets the same error type and + // text: the app renders `PendingFundsError` with its message on both the + // amount modal and the send scene's error card. + if (!this.isSynced()) { + throw new PendingFundsError( + 'Cannot spend until the wallet finishes syncing' + ) + } + + let spendability: PiratechainSpendability + try { + const synchronizer = await this.synchronizerPromise + spendability = await synchronizer.getSpendability() + } catch (error: unknown) { + // Reaching `SYNCED` was the entire gate before this RPC existed, so a + // status the plugin cannot read must not be what stops a spend. Bridge + // errors arrive serialized rather than as `Error` instances: + this.warn( + 'Failed to read the spendability status', + error instanceof Error ? error : new Error(String(error)) + ) + return + } + if (spendability.spendable) return + + this.warn(`Spend refused: ${JSON.stringify(spendability)}`) + throw new PendingFundsError(spendabilityMessage(spendability)) + } + async makeSpend(edgeSpendInfoIn: EdgeSpendInfo): Promise { - if (!this.isSynced()) throw new Error('Cannot spend until wallet is synced') + await this.checkSpendable() const { edgeSpendInfo, currencyCode } = this.makeSpendCheck(edgeSpendInfoIn) const { memos = [], tokenId } = edgeSpendInfo const spendTarget = edgeSpendInfo.spendTargets[0] @@ -381,45 +433,45 @@ export class PiratechainEngine extends CurrencyEngine< } async broadcastTx( - edgeTransaction: EdgeTransaction, - opts?: EdgeEnginePrivateKeyOptions + edgeTransaction: EdgeTransaction ): Promise { const { memos } = edgeTransaction - const piratechainPrivateKeys = asPiratechainPrivateKeys(this.pluginId)( - opts?.privateKeys - ) if ( edgeTransaction.spendTargets == null || edgeTransaction.spendTargets.length !== 1 ) throw new Error('Invalid spend targets') - const memo = memos[0]?.type === 'text' ? memos[0].value : '' const spendTarget = edgeTransaction.spendTargets[0] - const txParams: SpendInfo = { - zatoshi: sub( - abs(edgeTransaction.nativeAmount), - edgeTransaction.networkFee - ), - toAddress: spendTarget.publicAddress, - memo, - mnemonicSeed: piratechainPrivateKeys.mnemonic - } + if (spendTarget.publicAddress == null) + throw new Error('Missing publicAddress') + + // The registry wallet holds the spending keys, so the send call + // only needs the outputs. Edge's nativeAmount includes the fee: + const memo = memos[0]?.type === 'text' ? memos[0].value : undefined + const spendAmount = sub( + abs(edgeTransaction.nativeAmount), + edgeTransaction.networkFee + ) try { const synchronizer = await this.synchronizerPromise - const signedTx = await synchronizer.sendToAddress(txParams) - if ('txId' in signedTx) { - edgeTransaction.txid = signedTx.txId - edgeTransaction.signedTx = signedTx.raw - edgeTransaction.date = Date.now() / 1000 - this.warn(`SUCCESS broadcastTx\n${cleanTxLogs(edgeTransaction)}`) - } else { - throw new Error(signedTx.errorMessage) - } + const txid = await synchronizer.send( + [ + { + addr: spendTarget.publicAddress, + amount: spendAmount, + memo + } + ], + edgeTransaction.networkFee + ) + edgeTransaction.txid = txid + edgeTransaction.date = Date.now() / 1000 + this.warn(`SUCCESS broadcastTx\n${cleanTxLogs(edgeTransaction)}`) } catch (e: any) { this.warn('FAILURE broadcastTx failed: ', e) - throw e + throw asRetryableSpendError(e) ?? e } return edgeTransaction } @@ -427,11 +479,11 @@ export class PiratechainEngine extends CurrencyEngine< async getFreshAddress(): Promise { const getSynchronizerAddresses = async (): Promise => { const synchronizer = await this.synchronizerPromise - const { saplingAddress } = await synchronizer.deriveUnifiedAddress() - this.otherData.cachedAddress = saplingAddress + const publicAddress = await synchronizer.getCurrentAddress() + this.otherData.cachedAddress = publicAddress this.walletLocalDataDirty = true return { - publicAddress: saplingAddress + publicAddress } } @@ -448,6 +500,72 @@ export class PiratechainEngine extends CurrencyEngine< } } +/** + * SDK error codes that mean "this spend is not ready yet, retry shortly" + * rather than "this spend is wrong". `get_spendability_status` is checked + * before the transaction is built and, since SDK 0.3.4, keeps reporting a + * queued repair until the node will accept the wallet's anchor, so a build + * that passed the gate should not fail with either of these. The backstop + * stays because the status read `spendable: true, reasonCode: OK` through + * four such rejections on 0.3.2, and a wrong status costs the user a + * misleading network error where a mapped one costs nothing. + */ +const RETRYABLE_SPEND_ERROR_CODES = [ + 'ERR_SYNC_FINALIZING', + 'ERR_WITNESS_REPAIR_QUEUED' +] + +/** + * Restates a retryable spend failure as `PendingFundsError`, so the app can + * tell the user to wait instead of blaming their network connection. Anything + * else is left alone: an error the plugin cannot classify must keep its own + * text rather than be softened into a wait. + */ +function asRetryableSpendError(error: unknown): PendingFundsError | undefined { + const message = error instanceof Error ? error.message : String(error) + const code = RETRYABLE_SPEND_ERROR_CODES.find(code => message.includes(code)) + if (code == null) return + + return new PendingFundsError( + code === 'ERR_WITNESS_REPAIR_QUEUED' + ? 'Cannot spend until the wallet finishes repairing its transaction history' + : 'Cannot spend until the wallet finishes syncing' + ) +} + +/** + * The credential the SDK wraps a wallet's signing keys with. Edge has no + * account-session secret to hand a currency plugin, but the mnemonic is + * account-encrypted material the engine holds exactly while the account is + * unlocked, so a key derived from it locks and unlocks on Edge's schedule. + * The derivation is one-way and domain-separated, so the credential the SDK + * sees cannot recover the seed it protects, and it is never written anywhere. + */ +function deriveSigningCredential(mnemonic: string): string { + return base16.stringify( + createHmac('sha256', SIGNING_CREDENTIAL_DOMAIN).update(mnemonic).digest() + ) +} +const SIGNING_CREDENTIAL_DOMAIN = + 'edge-currency-accountbased/piratechain/signing-session/v1' + +/** + * Why the wallet cannot spend yet. Every case resolves itself by waiting, so + * the wording says which wait it is rather than asking the user to act. The + * `reasonCode` is authoritative; the booleans cover a status that arrives + * without one. + */ +function spendabilityMessage(spendability: PiratechainSpendability): string { + const { reasonCode, rescanRequired, repairQueued } = spendability + if (reasonCode === 'ERR_RESCAN_REQUIRED' || rescanRequired) { + return 'Cannot spend until the wallet finishes rescanning' + } + if (reasonCode === 'ERR_WITNESS_REPAIR_QUEUED' || repairQueued) { + return 'Cannot spend until the wallet finishes repairing its transaction history' + } + return 'Cannot spend until the wallet finishes syncing' +} + export async function makeCurrencyEngine( env: PluginEnvironment, tools: PiratechainTools, diff --git a/src/piratechain/PiratechainTools.ts b/src/piratechain/PiratechainTools.ts index 9d5b44231..9dbc1b6ee 100644 --- a/src/piratechain/PiratechainTools.ts +++ b/src/piratechain/PiratechainTools.ts @@ -12,7 +12,7 @@ import { EdgeWalletInfo, JsonObject } from 'edge-core-js/types' -import { Tools as ToolsType } from 'react-native-piratechain' +import { base16, base64 } from 'rfc4648' import { PluginEnvironment } from '../common/innerPlugin' import { asIntegerString } from '../common/types' @@ -32,7 +32,7 @@ export class PiratechainTools implements EdgeCurrencyTools { currencyInfo: EdgeCurrencyInfo io: EdgeIo networkInfo: PiratechainNetworkInfo - nativeTools: typeof ToolsType + piratechainIo: PiratechainIo constructor(env: PluginEnvironment) { const { builtinTokens, currencyInfo, io, networkInfo } = env @@ -48,7 +48,7 @@ export class PiratechainTools implements EdgeCurrencyTools { throw new Error('Need piratechain native IO') } - this.nativeTools = piratechainIo.Tools + this.piratechainIo = piratechainIo } async getDisplayPrivateKey( @@ -65,14 +65,13 @@ export class PiratechainTools implements EdgeCurrencyTools { } async getNewWalletBirthdayBlockheight(): Promise { - return await this.nativeTools.getBirthdayHeight( - this.networkInfo.rpcNode.defaultHost, - this.networkInfo.rpcNode.defaultPort + return await this.piratechainIo.getLatestNetworkHeight( + this.networkInfo.lightwalletdUrl ) } async isValidAddress(address: string): Promise { - return await this.nativeTools.isValidAddress(address) + return await this.piratechainIo.isValidAddress(address) } // will actually use MNEMONIC version of private key @@ -143,13 +142,17 @@ export class PiratechainTools implements EdgeCurrencyTools { if (typeof mnemonic !== 'string') { throw new Error('InvalidMnemonic') } - const unifiedViewingKey: string = await this.nativeTools.deriveViewingKey( + + // Registers the wallet with the SDK's registry as a side effect, + // using the same alias name the engine looks up later: + const viewingKey = await this.piratechainIo.deriveViewingKey({ + name: base16.stringify(base64.parse(walletInfo.id)), mnemonic, - this.networkInfo.rpcNode.networkName - ) + birthdayHeight: piratechainPrivateKeys.birthdayHeight + }) return { birthdayHeight: piratechainPrivateKeys.birthdayHeight, - publicKey: unifiedViewingKey + publicKey: viewingKey } } diff --git a/src/piratechain/piratechainInfo.ts b/src/piratechain/piratechainInfo.ts index 2cba8a81d..24713b739 100644 --- a/src/piratechain/piratechainInfo.ts +++ b/src/piratechain/piratechainInfo.ts @@ -16,8 +16,25 @@ const networkInfo: PiratechainNetworkInfo = { defaultHost: 'lightd1.pirate.black', defaultPort: 443 }, - defaultNetworkFee: '10000', - transactionQueryLimit: 999 + // gRPC over TLS. The Pirate team named this host and its sibling when they + // retired the SDK's built-in default, and both terminate TLS on 443 with a + // Let's Encrypt certificate whose SAN covers the lightwalletd name. Using + // them replaces two problems at once. The plaintext endpoint this used to + // name left sync and broadcast without TLS integrity or authentication, + // which is a real exposure for a shielded chain: a network observer learns + // which block ranges this device fetches and when it broadcasts, and an + // active attacker can serve a forked view. It was also, separately, slow: + // a cold scan of a 1.06M-block wallet took over half an hour against + // `lightd1.pirate.black:9067` and 95 seconds against this host, on the same + // simulator minutes apart (see the design doc's testing section). + lightwalletdUrl: 'https://lightwalletd2.cryptoforge.cc:443', + // Multi-server sync, on by default now that a second reachable node exists. + // The SDK refuses a pool whose members disagree on transport ("Automatic + // failover endpoint ... changes the connection security mode"), so every + // entry here has to be TLS like the primary; `lightd1.pirate.black:9067` + // stays out for that reason rather than for reachability. + lightwalletdFailoverUrls: ['https://lightwalletd1.cryptoforge.cc:443'], + defaultNetworkFee: '10000' } const currencyInfo: EdgeCurrencyInfo = { diff --git a/src/piratechain/piratechainIo.ts b/src/piratechain/piratechainIo.ts index 625148acf..b2752a25a 100644 --- a/src/piratechain/piratechainIo.ts +++ b/src/piratechain/piratechainIo.ts @@ -1,81 +1,548 @@ +import { + asBoolean, + asEither, + asJSON, + asMaybe, + asNull, + asNumber, + asObject, + asOptional, + asString, + asUnknown +} from 'cleaners' +import type { JsonObject } from 'edge-core-js/types' import type { - Addresses, - BlockRange, - ConfirmedTransaction, - ErrorEvent, - InitializerConfig, - SpendFailure, - SpendInfo, - SpendSuccess, - StatusEvent, - Synchronizer, - UpdateEvent, - WalletBalance -} from 'react-native-piratechain' -import { makeSynchronizer, Tools } from 'react-native-piratechain' + Balance, + EndpointPoolDiagnostics, + PirateWalletSdk, + SynchronizerStatus, + TransactionInfo +} from 'react-native-pirate-wallet' +import { createPirateWalletSdk } from 'react-native-pirate-wallet' import { bridgifyObject, emit, onMethod, Subscriber } from 'yaob' +export interface PiratechainStatusEvent { + name: SynchronizerStatus +} + +export interface PiratechainUpdateEvent { + lastDownloadedHeight: number + networkBlockHeight: number + progressPercent: number +} + +export interface PiratechainErrorEvent { + message: string +} + export interface PiratechainEvents { - error: ErrorEvent - statusChanged: StatusEvent - update: UpdateEvent + error: PiratechainErrorEvent + statusChanged: PiratechainStatusEvent + update: PiratechainUpdateEvent +} + +/** + * Whether the wallet can build a spend right now, and why not when it cannot. + * `spendable` is the only load-bearing field; the rest is diagnosis. + */ +export interface PiratechainSpendability { + spendable: boolean + rescanRequired: boolean + repairQueued: boolean + reasonCode?: string + anchorHeight?: number + validatedAnchorHeight?: number +} + +export interface PiratechainSpendOutput { + addr: string + /** Arrrtoshis as a decimal string to preserve precision above 2^53-1. */ + amount: string + memo?: string +} + +/** What identifies a wallet in the SDK registry. */ +export interface PiratechainWalletConfig { + birthdayHeight: number + mnemonic: string + name: string +} + +/** What a running synchronizer needs on top of the registry identity. */ +export interface PiratechainSynchronizerConfig extends PiratechainWalletConfig { + /** + * Alternate lightwalletd URLs for the SDK's multi-server pool. Empty or + * absent leaves the wallet on `lightwalletdUrl` alone. + */ + lightwalletdFailoverUrls?: string[] + /** + * The lightwalletd node this wallet scans against, as a plain gRPC URL. The + * SDK ships its own default node and never consults the plugin's config, so + * without this the wallet silently scans against whatever the SDK picked. + */ + lightwalletdUrl?: string + /** + * Wraps the wallet's signing keys inside the SDK registry. The engine + * derives it from account-encrypted key material it only holds while the + * Edge account is unlocked, so the SDK's signing lock follows Edge's. Never + * persisted on either side of the bridge. + */ + signingCredential: string } export interface PiratechainSynchronizer { on: Subscriber - deriveUnifiedAddress: () => Promise - getBalance: () => Promise - getTransactions: (range: BlockRange) => Promise - rescan: () => Promise - sendToAddress: (spendInfo: SpendInfo) => Promise - stop: () => Promise + getBalance: () => Promise + getCurrentAddress: () => Promise + /** + * Live-probes every configured lightwalletd over the wallet's transport and + * reports which one the SDK would select. Slow by design (one round trip + * per endpoint), so callers keep it off the sync path. + */ + getEndpointDiagnostics: () => Promise + getSpendability: () => Promise + getTransactions: () => Promise + rescan: (fromHeight?: number) => Promise + send: (outputs: PiratechainSpendOutput[], fee?: string) => Promise + /** + * Starts the native poller. Separate from `makeSynchronizer` so the engine + * can subscribe first: the SDK emits its first status change synchronously + * inside `start`, and a listener attached afterwards would never see it. + */ + start: () => Promise + stop: () => Promise } export interface PiratechainIo { - Tools: typeof Tools + deriveViewingKey: (config: PiratechainWalletConfig) => Promise + /** + * @param lightwalletdUrl The node to read the chain tip from. Without it the + * only sources are a wallet that is already syncing and the SDK's own + * default node. + */ + getLatestNetworkHeight: (lightwalletdUrl?: string) => Promise + isValidAddress: (address: string) => Promise makeSynchronizer: ( - config: InitializerConfig + config: PiratechainSynchronizerConfig ) => Promise } +/** + * The SDK's registry namespace is selected globally: only one is active at a + * time, and switching cancels any running sync and clears the registry and + * block caches. So Edge configures exactly one device-scoped namespace, holds + * every ARRR wallet inside it keyed by alias, and runs a wallet-scoped + * synchronizer per wallet concurrently. Wallet-free reads (address + * validation, chain-tip probe) use the same namespace. + * + * The native module mints the namespace passphrase and keeps it in the iOS + * Keychain or Android Keystore, so no JS side ever holds it. The registry + * directory is derived from this id alone, and the id is distinct from the + * one the plugin used with a JS-supplied passphrase, so a device that carries + * that older registry gets a fresh one rather than a credential mismatch; + * wallets re-restore from their seeds. + */ +const DEVICE_ACCOUNT_ID = 'edge-pirate-keychain' + +const asInvokeEnvelope = asObject({ + ok: asBoolean, + result: asOptional(asUnknown), + error: asOptional(asString) +}) + +/** + * The subset of `get_spendability_status` we read. Only `spendable` is + * required: the SDK types the anchor heights as numbers but a finalizing + * wallet reports them as `null`, and a release that drops or renames a + * diagnosis field must cost a vaguer log line rather than the spend itself. + */ +const asSpendabilityStatus = asObject({ + spendable: asBoolean, + rescanRequired: asOptional(asBoolean, false), + repairQueued: asOptional(asBoolean, false), + reasonCode: asOptional(asString), + anchorHeight: asOptional(asNumber), + validatedAnchorHeight: asOptional(asNumber) +}) + +/** + * The poller's cached snapshot fields are untyped in the SDK, so re-check + * the shapes the engine depends on before trusting them. + */ +const asBalance = asObject({ + total: asString, + spendable: asString, + pending: asString +}) +const asSyncStatus = asObject({ + localHeight: asNumber, + targetHeight: asNumber +}) +const asTransactionInfo = asObject({ + txid: asString, + height: asEither(asNumber, asNull), + timestamp: asNumber, + amount: asString, + fee: asString, + memo: asEither(asString, asNull), + confirmed: asBoolean +}) + export function makePiratechainIo(): PiratechainIo { + // The SDK constructor throws when the native module isn't linked, so + // create it lazily to keep `makePiratechainIo` safe on every platform: + let sdk: PirateWalletSdk | undefined + const getSdk = (): PirateWalletSdk => { + if (sdk == null) sdk = createPirateWalletSdk() + return sdk + } + + /** + * Calls a service method the typed JS wrapper doesn't expose. Only + * `set_tunnel` needs this; everything else goes through the wrappers, which + * validate arguments and camelize results. + */ + const invokeCall = async ( + method: string, + params: JsonObject = {} + ): Promise => { + const response = await getSdk().invoke( + JSON.stringify({ method, ...params }) + ) + const envelope = asJSON(asInvokeEnvelope)(response) + if (!envelope.ok) { + throw new Error(envelope.error ?? `Native request failed for ${method}`) + } + return envelope.result + } + + /** + * Configures the one device namespace, at most once. Every call that touches + * storage awaits this first. + */ + let deviceStoragePromise: Promise | undefined + const ensureDeviceStorage = async (): Promise => { + if (deviceStoragePromise == null) { + deviceStoragePromise = configureDeviceStorage().catch( + (error: unknown) => { + // Don't cache a failure — the next call retries the whole setup + // rather than proceeding on an unconfigured namespace: + deviceStoragePromise = undefined + throw error + } + ) + } + await deviceStoragePromise + } + + const configureDeviceStorage = async (): Promise => { + await getSdk().configureSecureAccountStorage({ + accountId: DEVICE_ACCOUNT_ID + }) + // The default transport tunnels through Tor, which doesn't reliably + // bootstrap inside Edge, and configuring storage clears transport state. + // Reconnect directly, like every other plugin: + await invokeCall('set_tunnel', { mode: 'Direct' }) + } + + /** + * Finds the registry wallet matching the Edge wallet's alias name, restoring + * it from the mnemonic if this device hasn't seen it yet. Registry mutations + * are serialized so two wallets starting at once cannot interleave. Syncing + * itself is wallet-scoped and stays concurrent. + */ + let registryLock: Promise = Promise.resolve() + const ensureWallet = async ( + config: PiratechainWalletConfig + ): Promise => { + const task = registryLock.then(async () => { + const { birthdayHeight, mnemonic, name } = config + const walletSdk = getSdk() + await ensureDeviceStorage() + const registryExists = await walletSdk.walletRegistryExists() + if (registryExists) { + const wallets = await walletSdk.listWallets() + const existingWallet = wallets.find(wallet => wallet.name === name) + if (existingWallet != null) return existingWallet.id + } + return await walletSdk.restoreWallet({ name, mnemonic, birthdayHeight }) + }) + registryLock = task.catch(() => undefined) + return await task + } + return bridgifyObject({ - Tools: bridgifyObject(Tools), + async deriveViewingKey(config) { + const walletId = await ensureWallet(config) + return await getSdk().exportSaplingViewingKey(walletId) + }, + + async getLatestNetworkHeight(lightwalletdUrl) { + // Three sources, cheapest and safest first. The last one mutates the + // registry, and the native service panics (aborting the app) when the + // registry changes underneath a running synchronizer, so it is gated on + // the registry being genuinely empty. + const task = registryLock.then(async () => { + const walletSdk = getSdk() + await ensureDeviceStorage() + + // 1. A wallet that is already syncing carries the tip for free. + const wallets = (await walletSdk.walletRegistryExists()) + ? await walletSdk.listWallets() + : [] + for (const wallet of wallets) { + const syncStatus = await walletSdk + .getSyncStatus(wallet.id) + .catch(() => undefined) + if (syncStatus != null && syncStatus.targetHeight > 0) { + return syncStatus.targetHeight + } + } + + // 2. Ask Edge's own node. `test_node` reports the tip without + // registering anything, so it is safe while synchronizers run, and it + // reads the configured node rather than the SDK's default (which is + // unreachable, and whose tip we would not want to trust anyway: a + // birthday taken too high leaves earlier notes unscanned). + if (lightwalletdUrl != null) { + const result = await walletSdk + .testLightdEndpoint(lightwalletdUrl) + .catch(() => undefined) + const height = result?.latestBlockHeight + if (result?.success === true && height != null && height > 0) { + return height + } + } + + // 3. Nothing registered and no reachable node, so no synchronizer can + // be running and mutating the registry is safe. `create_wallet` with + // no birthday falls back to the SDK's static checkpoint, which is + // below the true tip and therefore conservative. + if (wallets.length > 0) { + throw new Error( + 'Cannot resolve the Pirate Chain height: no wallet reports a tip and the configured node is unreachable' + ) + } + const probeWalletId = await walletSdk.createWallet({ + name: 'edge-birthday-probe' + }) + try { + const probeWallet = await walletSdk.getWallet(probeWalletId) + if (probeWallet == null) { + throw new Error('Missing birthday probe wallet') + } + return probeWallet.birthdayHeight + } finally { + await walletSdk.deleteWallet(probeWalletId).catch(() => undefined) + } + }) + registryLock = task.catch(() => undefined) + return await task + }, + + async isValidAddress(address) { + await ensureDeviceStorage() + const result = await getSdk().validateAddress(address) + return result.isValid + }, async makeSynchronizer(config) { - const realSynchronizer: Synchronizer = await makeSynchronizer(config) + const walletSdk = getSdk() + const walletId = await ensureWallet(config) + + // Point the wallet at Edge's own node. The SDK bakes in a default + // lightwalletd and never reads the plugin's `networkInfo`, so a wallet + // left alone scans against that default. When that node is degraded the + // failure is silent and total: `test_node` still succeeds and the chain + // tip still resolves, but the scan sits in the `Headers` stage at zero + // blocks/sec forever, which surfaces in the app as "Sync in Progress, + // 0% Complete" with no error anywhere. + if (config.lightwalletdUrl != null) { + const failoverUrls = config.lightwalletdFailoverUrls ?? [] + let pooled = false + if (failoverUrls.length > 0) { + // Multi-server mode, added in SDK 0.3.2 and off by default there: + // the sync engine spreads block fetches across compatible light + // servers and moves off one that stalls, which is the networking + // bottleneck on a fresh scan. The plugin opts in only when its + // config names alternates, so the single-endpoint call below stays + // the shipped behavior. + try { + await walletSdk.setLightdEndpointPool( + walletId, + config.lightwalletdUrl, + failoverUrls + ) + pooled = true + } catch (error: unknown) { + // The pool list arrives from the info server, so a payload the SDK + // rejects (an unreachable alternate, a host on another network, a + // list past its cap) must not cost the wallet its sync. Fall back + // to the single endpoint, which is what every wallet ran on before + // 0.3.2: + console.warn( + `piratechain: lightwalletd pool rejected, falling back to a single endpoint: ${String( + error + )}` + ) + } + } + if (!pooled) { + await walletSdk.setLightdEndpoint(walletId, config.lightwalletdUrl) + } + } + + // The SDK keeps the seed and spending keys wrapped under a key derived + // from this credential and holds that key in memory only, so a locked + // registry can still sync (viewing keys and the block cache stay + // readable) but cannot sign. Protection is enabled the first time a + // wallet is seen on this device, so its spending keys are wrapped from + // the first sync on, and the key is locked again at once: nothing + // before a send needs it, and `send` unlocks for its own span. Sync does + // not depend on any of this, so a failure here logs and a send then + // fails with the SDK's own ERR_SIGNING_SESSION_LOCKED rather than being + // refused up front: + const { signingCredential } = config + const ensureSigningUnlocked = async (): Promise => { + let status = await walletSdk.getWalletSigningStatus(walletId) + if (!status.protectionEnabled) { + // Enabling may or may not leave the key unlocked, so read the + // status it returns rather than assuming: + status = await walletSdk.enableWalletSigningProtection( + walletId, + signingCredential + ) + } + if (!status.unlocked) { + await walletSdk.unlockWalletSigning(walletId, signingCredential) + } + } + const lockSigning = async (): Promise => { + await walletSdk.lockWalletSigning(walletId).catch((error: unknown) => { + console.warn( + `piratechain: failed to lock wallet signing: ${String(error)}` + ) + }) + } + try { + const status = await walletSdk.getWalletSigningStatus(walletId) + const enabled = status.protectionEnabled + ? status + : await walletSdk.enableWalletSigningProtection( + walletId, + signingCredential + ) + if (enabled.unlocked) await walletSdk.lockWalletSigning(walletId) + } catch (error: unknown) { + console.warn( + `piratechain: wallet signing protection unavailable: ${String(error)}` + ) + } + + const realSynchronizer = walletSdk.createSynchronizer(walletId, { + transactionLimit: null + }) realSynchronizer.subscribe({ - onError(event): void { - emit(out, 'error', event) + onError(error): void { + emit(out, 'error', { + message: error instanceof Error ? error.message : String(error) + }) }, onStatusChanged(status): void { - emit(out, 'statusChanged', status) + emit(out, 'statusChanged', { name: status.name }) }, - onUpdate(event): void { - emit(out, 'update', event) + onUpdate(snapshot): void { + const { progressPercent } = snapshot + const syncStatus = asMaybe(asSyncStatus)(snapshot.syncStatus) + // The first polls can fire before the backend reports heights; + // skip those so progress trackers never see zero heights: + if (syncStatus == null || syncStatus.targetHeight <= 0) return + emit(out, 'update', { + lastDownloadedHeight: syncStatus.localHeight, + networkBlockHeight: syncStatus.targetHeight, + progressPercent + }) } }) const out: PiratechainSynchronizer = bridgifyObject({ on: onMethod, - deriveUnifiedAddress: async () => { - return await realSynchronizer.deriveUnifiedAddress() - }, getBalance: async () => { - return await realSynchronizer.getBalance() + // The polling synchronizer refreshes this before each update event: + return ( + asMaybe(asBalance)(realSynchronizer.balance) ?? + (await walletSdk.getBalance(walletId)) + ) + }, + getCurrentAddress: async () => { + return await walletSdk.getCurrentReceiveAddress(walletId) + }, + getEndpointDiagnostics: async () => { + return await walletSdk.getLightdEndpointPoolDiagnostics(walletId) + }, + getSpendability: async () => { + return asSpendabilityStatus( + await walletSdk.getSpendabilityStatus(walletId) + ) + }, + getTransactions: async () => { + // Same cache as the balance, refreshed by the poller each round. It + // starts as `[]` and the engine only asks after the poller has + // filled it, so there is no RPC to fall back to; a malformed row is + // dropped on its own rather than rejecting the whole history: + const raw: unknown[] = realSynchronizer.transactions + const transactions = raw.flatMap(entry => { + const tx = asMaybe(asTransactionInfo)(entry) + return tx == null ? [] : [tx] + }) + if (transactions.length < raw.length) { + console.warn( + `piratechain: dropped ${ + raw.length - transactions.length + } malformed transactions` + ) + } + return transactions }, - getTransactions: async blockRange => { - return await realSynchronizer.getTransactions(blockRange) + rescan: async fromHeight => { + await walletSdk.rescan(walletId, fromHeight ?? null) }, - rescan: async () => { - return realSynchronizer.rescan() + send: async (outputs, fee) => { + // The signing key is in native memory only for the span of this + // call: unlock here (a failure surfaces with its real reason), and + // lock again whether or not the send went through, so the key + // never outlives the spend that needed it: + await ensureSigningUnlocked() + try { + // The SDK's send builds, signs, and broadcasts, keeping the opaque + // pending/signed payloads verbatim between steps and serializing + // amounts as strings so large sends keep full precision: + return await walletSdk.send(walletId, outputs, fee ?? null) + } finally { + await lockSigning() + } }, - sendToAddress: async spendInfo => { - return await realSynchronizer.sendToAddress(spendInfo) + start: async () => { + try { + await realSynchronizer.start() + } catch (error: unknown) { + // A poller that failed to start may still hold its native sync + // registration; close it so a retry does not leave a second + // synchronizer running against the same registry wallet: + await realSynchronizer.close().catch(() => undefined) + throw error + } }, stop: async () => { - return await realSynchronizer.stop() + try { + await realSynchronizer.close() + } finally { + // Only `send` holds the key unlocked, but the engine is killed on + // account lock or logout, which can land mid-send, so lock here + // too, whether or not the poller closed cleanly: + await lockSigning() + } } }) diff --git a/src/piratechain/piratechainTypes.ts b/src/piratechain/piratechainTypes.ts index 6f663b056..fcfdd70a7 100644 --- a/src/piratechain/piratechainTypes.ts +++ b/src/piratechain/piratechainTypes.ts @@ -1,4 +1,5 @@ import { + asArray, asCodec, asMaybe, asNumber, @@ -8,33 +9,43 @@ import { asValue, Cleaner } from 'cleaners' -import type { BlockRange } from 'react-native-piratechain' import { asWalletInfo } from '../common/types' type PiratechainNetworkName = 'mainnet' | 'testnet' export interface PiratechainNetworkInfo { + /** Unused by the unified SDK (endpoints live in the native core); kept for + * info-server payload compatibility. */ rpcNode: { networkName: PiratechainNetworkName defaultHost: string defaultPort: number } + /** + * The full lightwalletd URL the SDK scans against, scheme included. This is + * deliberately NOT derived from `rpcNode`: that shape shipped with + * `defaultPort: 443` long before the SDK needed a port at all, so an + * info-server payload carrying the historical value would silently point + * sync at a port the SDK cannot speak. Scheme, host and port travel + * together here so a payload can only ever set a coherent endpoint. + */ + lightwalletdUrl: string + /** + * Alternate lightwalletd URLs the SDK may fail over to, in preference + * order. Empty keeps the wallet on `lightwalletdUrl` alone and skips the + * multi-server pool entirely. The SDK validates a pool before accepting it + * and rejects two shapes outright: a host it does not recognize as a Pirate + * network, and an alternate whose transport differs from the primary's, so + * a TLS primary cannot list a plaintext alternate. A rejected pool costs + * the wallet nothing, since the plugin falls back to the single endpoint. + * The info server can replace this list without an app release. + */ + lightwalletdFailoverUrls: string[] defaultNetworkFee: string - transactionQueryLimit: number } -const asPiratechainBlockRange = asObject({ - first: asNumber, - last: asNumber -}) - export const asPiratechainWalletOtherData = asObject({ - alias: asMaybe(asString), - blockRange: asMaybe(asPiratechainBlockRange, () => ({ - first: 0, - last: 0 - })), cachedAddress: asMaybe(asString) }) @@ -86,6 +97,8 @@ export const asPiratechainPrivateKeys = ( // export const asPiratechainInfoPayload = asObject({ + lightwalletdUrl: asOptional(asString), + lightwalletdFailoverUrls: asOptional(asArray(asString)), rpcNode: asOptional( asObject({ networkName: asValue('mainnet', 'testnet'),