diff --git a/CHANGELOG.md b/CHANGELOG.md index e3f1ea559..0bf0cf987 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- changed: (ARRR) Reimplement the Pirate Chain plugin over the unified `react-native-pirate-wallet` SDK, replacing `react-native-piratechain`. Every wallet lives in one device-scoped encrypted registry, so multiple ARRR wallets sync at once over a shared block cache, and amounts are encoded as strings for full precision. +- fixed: (ARRR) Point each Pirate Chain wallet at the plugin's own lightwalletd node. The SDK ships a default node and never reads the plugin's configuration, so wallets scanned against that default instead; when it stopped serving blocks the wallet sat at "Sync in Progress, 0% Complete" indefinitely with no error, since the chain tip still resolved. The configured port also moves to the node's plain gRPC port. + ## 4.87.0 (2026-08-02) - added: (Sui) `rpcNodes`, `rpcNodesArchival`, and `maxRequestsPerSecond` to the info payload, so nodes can be changed without a client release. Transaction sweeps start on an archival node, since the walk begins at the wallet's oldest transaction and a pruned node rejects a cursor older than its retention window. diff --git a/package-lock.json b/package-lock.json index d4fbb346a..a31f3a367 100644 --- a/package-lock.json +++ b/package-lock.json @@ -113,7 +113,6 @@ "process": "^0.11.10", "querystring": "^0.2.1", "react-native-monero": "0.4.0", - "react-native-piratechain": "0.5.0", "react-native-zano": "^0.2.7", "react-native-zcash": "0.13.1", "rimraf": "^3.0.2", @@ -131,7 +130,7 @@ }, "peerDependencies": { "react-native-monero": "^0.3.0", - "react-native-piratechain": "v0.5.0", + "react-native-pirate-wallet": "^0.2.0", "react-native-zano": "^0.2.7", "react-native-zcash": "^0.13.1" } @@ -15621,19 +15620,6 @@ "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "rfc4648": "^1.3.0" - }, - "peerDependencies": { - "react-native": ">=0.47.0 <1.0.0" - } - }, "node_modules/react-native-zano": { "version": "0.2.8", "resolved": "https://registry.npmjs.org/react-native-zano/-/react-native-zano-0.2.8.tgz", diff --git a/package.json b/package.json index f701ed7d2..5055952c0 100644 --- a/package.json +++ b/package.json @@ -169,7 +169,6 @@ "process": "^0.11.10", "querystring": "^0.2.1", "react-native-monero": "0.4.0", - "react-native-piratechain": "0.5.0", "react-native-zano": "^0.2.7", "react-native-zcash": "0.13.1", "rimraf": "^3.0.2", @@ -187,7 +186,7 @@ }, "peerDependencies": { "react-native-monero": "^0.3.0", - "react-native-piratechain": "v0.5.0", + "react-native-pirate-wallet": "^0.2.0", "react-native-zano": "^0.2.7", "react-native-zcash": "^0.13.1" }, diff --git a/src/docs/piratechain-sdk-v115-reconcile.md b/src/docs/piratechain-sdk-v115-reconcile.md new file mode 100644 index 000000000..43c25f3be --- /dev/null +++ b/src/docs/piratechain-sdk-v115-reconcile.md @@ -0,0 +1,240 @@ +# Piratechain SDK v1.1.5 reconciliation: replace the crashing wallet module with the released unified SDK + +| | | +|---|---| +| Status | Implemented; sync verified on the iOS sim, broadcast blocked upstream ([section 7](#7-testing)) | +| Author | Jon Tzeng | +| Reviewer | peachbits | +| Last updated | 2026-08-11 | +| 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.2.1`) | +| 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. [References](#10-references) + +## 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. This work reconciles the plugin with that release. + +## 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 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: +- Re-vendor `react-native-pirate-wallet` from the v1.1.5 release (RN binding 0.1.1 to 0.2.0), native binaries included. +- 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 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. +- Bumping to v1.1.6. That release is v1.1.5 plus the Ironwood mainnet activation height, which the Pirate team sets only once partners confirm readiness. A v1.1.5 build does not survive that activation, so one more bump is owed before it happens. + +## 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.2.1` | Published by the Pirate team 2026-08-06 ([section 6](#6-detailed-design-edge-react-gui-and-the-sdk-dependency)) | + +The plugin's native 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 object bridge. + +```mermaid +sequenceDiagram + box edge-core-js plugin context + participant Tools as PiratechainTools + 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 + Tools->>Bridge: setDevicePassphrase(random device secret, once) + Engine->>Bridge: makeSynchronizer({ mnemonic, name, birthdayHeight }) + Bridge->>SDK: configureAccountStorage({ accountId: 'edge-pirate-device', passphrase }) + SDK->>Rust: configure_wallet_storage (open/create the device registry, once) + Bridge->>SDK: restoreWallet / createSynchronizer / start + Engine->>Bridge: send(outputs[amount as string], fee as string) + Bridge->>SDK: send(walletId, outputs, fee) + 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 + +v1.1.5 removes the global `set_app_passphrase` / `unlock_app` flow entirely; the only storage entry point is `configureAccountStorage`, which selects a registry directory and creates or unlocks it with a passphrase. 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-device'`, and every 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 passphrase is a random 32-byte secret minted per device on first use and persisted in the plugin's local storage. It is generated on the **core side**, in `piratechainDeviceStorage.ts`: the bridge cannot do it because Metro resolves neither Node's `crypto` (the bridge redboxes `Unable to resolve module crypto`) nor a disklet. The core hands it to the bridge through `setDevicePassphrase` before any wallet call: + +```ts +// as landed, piratechainDeviceStorage.ts (core side) +const DEVICE_PASSPHRASE_FILE = 'piratechain/devicePassphrase.json' + +const loadOrCreateDevicePassphrase = async (io: EdgeIo): Promise => { + const { disklet } = io + const listing = await disklet.list(DEVICE_PASSPHRASE_FILE) + if (listing[DEVICE_PASSPHRASE_FILE] === 'file') { + const text = await disklet.getText(DEVICE_PASSPHRASE_FILE) + try { + return asDevicePassphraseFile(text).passphrase + } catch (error: unknown) { + // Unreadable contents: fall through and re-mint. + } + } + const passphrase = base16.stringify(io.random(32)) + await disklet.setText(DEVICE_PASSPHRASE_FILE, JSON.stringify({ passphrase })) + return passphrase +} +``` + +Existence is checked before reading so a transient read failure surfaces as an error rather than silently minting a new secret and orphaning the registry, which would force every wallet to re-scan from its birthday. + +`PiratechainTools.ensureDevicePassphrase()` performs the handoff once, memoized on a promise, and every path that reaches the SDK's storage awaits it first: the tools' own `isValidAddress`, `getNewWalletBirthdayBlockheight` and `derivePublicKey`, plus the engine's `syncNetwork` before it builds a synchronizer. It is deliberately lazy rather than part of `makeCurrencyTools`, so constructing tools never depends on the native module being linked (the plugin's unit tests build tools against a stub bridge). + +### 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 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 a wallet that is already registered: `getSyncStatus(walletId).targetHeight`, which reads state instead of changing it. The create-and-delete probe survives only as the empty-registry fallback, where no wallet exists to ask and therefore no synchronizer can be running. + +### Synchronizer status backstop + +The engine subscribes to the synchronizer after `start()`, so a `statusChanged` that fires in between is lost and the engine stays at `STOPPED`, which blocks every spend. `PiratechainSynchronizer.getStatus()` exposes the SDK synchronizer's current `status`, and `initSubscriptions` reads it once after subscribing, adopting it only when no event has arrived yet (`synchronizerStatus === 'STOPPED'`) so a live event is never clobbered. + +### Amounts as strings + +`rnPirateWallet.d.ts` retypes `PirateBalance`, `PirateTransaction`, and `PirateTransactionOutput` amount fields from `number` to `string`, matching v1.1.5's `AmountString`. 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 now calls `walletSdk.send(walletId, outputs, fee)` directly. + +### 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.2.1` comes from npm, replacing the `file:../react-native-pirate-wallet` sibling. The wrapper tarball carries only JS and the ObjC/Swift/Kotlin bridge; the native artifacts ship as four `optionalDependencies` pinned to the exact wrapper version (`-android`, `-android-x86_64`, `-ios-device`, `-ios-simulator`), and a `postinstall` hard-links the two iOS slices into the `PirateWalletNative.xcframework` the podspec vendors. The iOS pair is marked `os: ["darwin"]`, so Linux CI skips 560MB it cannot use. + +`edge-react-gui` sets `ignore-scripts=true` in `.npmrc`, so that `postinstall` can never fire and the podspec would vendor a framework that does not exist. The assembly therefore runs from `scripts/prepare.sh`, which is where the repo already keeps `patch-package`, `jetify` and the native-header copy, and which must run before `pod install`. The script no-ops off macOS. + +`src/util/corePlugins.ts` keeps `piratechain: true`; nothing else on the GUI side changes. The seam back to the plugin is the bridge in [section 5](#5-detailed-design-edge-currency-accountbased) and its diagram. + +### The lightwalletd endpoint + +The SDK bakes in a default 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`. + +## 7. Testing + +1. Static: `tsc --noEmit` and `verify-repo.sh` (eslint plus jest) pass in edge-currency-accountbased. No piratechain unit tests exist. +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 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//`. +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 (NOT VERIFIED, 2026-08-11): 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 nodes, so it is not endpoint-specific. No principal moved. The transaction builds and signs (roughly 5 minutes on the sim) and the failure is at the gRPC broadcast. This is the one remaining gap before the app is ready for activation, and it is upstream of Edge's code. + +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 (this work) +| Diverged in phase 1 | Shipped in phase 2 | +|---|---| +| Hardcoded shared app passphrase | Per-wallet `configureAccountStorage` namespace, passphrase = 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 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 this phase: 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 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: two walls the phase did not anticipate. The e2e send still does not broadcast — it now fails at the SDK's gRPC broadcast rather than at spendability, which is a different and later failure than phase 4's. And 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. The Pirate static library is the largest contributor at roughly 187MB of arm64 code, but it is not solely responsible; the build linked on 2026-08-04 with the same library, and dropping the three other large Rust/C++ libraries (`zcash`, `monero`, `zano`) links it again. Dead-stripping, link reordering and `-ld_classic` were each tried and each failed identically. + +## 9. Decisions + +### Decision 1: one device-scoped registry namespace +Chosen: a single `configureAccountStorage` namespace per device (`edge-pirate-device`), holding every 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 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: a random per-device passphrase in the plugin's local storage +Chosen: `base16.stringify(io.random(32))`, minted on first use and persisted to `piratechain/devicePassphrase.json` on the core `EdgeIo` disklet, handed to the bridge via `setDevicePassphrase`. +Evidence: v1.1.5's README requires a unique, high-entropy, secret-derived passphrase and forbids hardcoded or public values; a device-random secret satisfies all three and, unlike a seed-derived one, does not tie a device-scoped registry to any single wallet's key material. `io.random` is the core's CSPRNG and `io.disklet` is device-local storage that never syncs, so the secret stays on the device. Generation cannot live in the bridge: Metro resolves neither `crypto` nor a disklet. +Rejected: HMAC of a wallet seed (phase 2's answer), which cannot key a registry holding many wallets without arbitrarily privileging one wallet's seed, and which leaks a deterministic function of spending material into a storage key. Rejected: a hardcoded constant, the exact pattern the security review flagged. Rejected: the OS keychain, which would add a native dependency for a secret that guards device-local data the OS already sandboxes; the disklet is the plugin's existing storage seam. +Reopen if: the secret needs to survive an app reinstall or migrate between devices, which local storage does not do (today the cost is a re-scan from birthday, not a loss of funds). + +## 10. 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 README (account-scoped storage contract). diff --git a/src/piratechain/PiratechainEngine.ts b/src/piratechain/PiratechainEngine.ts index 3f1769586..9a612bdba 100644 --- a/src/piratechain/PiratechainEngine.ts +++ b/src/piratechain/PiratechainEngine.ts @@ -1,4 +1,4 @@ -import { abs, add, eq, gt, lte, mul, sub } from 'biggystring' +import { abs, add, eq, gt, gte, lte, mul, sub } from 'biggystring' import { EdgeCurrencyEngine, EdgeCurrencyEngineOptions, @@ -11,11 +11,7 @@ import { InsufficientFundsError, NoAmountSpecifiedError } from 'edge-core-js/types' -import type { - ConfirmedTransaction, - SpendInfo, - StatusEvent -} from 'react-native-piratechain' +import type { PirateTransaction } from 'react-native-pirate-wallet' import { base16, base64 } from 'rfc4648' import { CurrencyEngine } from '../common/CurrencyEngine' @@ -44,11 +40,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 +55,6 @@ export class PiratechainEngine extends CurrencyEngine< synchronizer?: PiratechainSynchronizer synchronizerPromise: Promise synchronizerResolver!: (synchronizer: PiratechainSynchronizer) => void - lastUpdateFromSynchronizer?: number constructor( env: PluginEnvironment, @@ -76,6 +73,7 @@ export class PiratechainEngine extends CurrencyEngine< this.synchronizerResolver = resolve }) this.queryMutex = false + this.processedTxHeights = new Map() this.started = false } @@ -85,23 +83,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 +102,33 @@ 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() - } }) + + // A status change that fired before these subscriptions existed is lost, + // which would strand the engine at STOPPED and block every spend. Read the + // status once and adopt it if no event has arrived yet: + synchronizer + .getStatus() + .then(async status => { + if (this.synchronizerStatus !== 'STOPPED') return + this.synchronizerStatus = status + await this.queryAll() + }) + .catch((error: unknown) => { + this.log.warn( + `Failed to read the initial synchronizer status: ${String(error)}` + ) + }) } async queryAll(): Promise { - this.lastUpdateFromSynchronizer = Date.now() if (this.queryMutex) return this.queryMutex = true try { @@ -150,10 +152,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 +166,17 @@ 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 have moved (confirmed/reorged): + const height = tx.height ?? 0 + if (this.processedTxHeights.get(tx.txid) === height) continue + this.processTransaction(tx) + this.processedTxHeights.set(tx.txid, height) + } + if (this.isSynced()) { + this.syncTracker.updateTransactionRatio(1) } } catch (e: any) { this.error( @@ -209,41 +186,39 @@ export class PiratechainEngine extends CurrencyEngine< } } - processTransaction(tx: ConfirmedTransaction): void { - let netNativeAmount = tx.value + processTransaction(tx: PirateTransaction): 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,26 +231,23 @@ export class PiratechainEngine extends CurrencyEngine< this.currencyInfo.pluginId )(opts?.privateKeys) - const { rpcNode } = this.networkInfo this.birthdayHeight = piratechainPrivateKeys.birthdayHeight try { + await this.tools.ensureDevicePassphrase() // 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 }) this.synchronizer = await this.synchronizerPromise // People might be waiting on the old promise, so resolve that this.synchronizerResolver(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 } @@ -309,8 +281,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() @@ -381,42 +355,42 @@ 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 @@ -427,11 +401,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 } } diff --git a/src/piratechain/PiratechainTools.ts b/src/piratechain/PiratechainTools.ts index 9d5b44231..fd6fb7ae4 100644 --- a/src/piratechain/PiratechainTools.ts +++ b/src/piratechain/PiratechainTools.ts @@ -12,12 +12,13 @@ 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' import { encodeUriCommon, parseUriCommon } from '../common/uriHelpers' import { getLegacyDenomination, mergeDeeply } from '../common/utils' +import { getPiratechainDevicePassphrase } from './piratechainDeviceStorage' import type { PiratechainIo } from './piratechainIo' import { asArrrPublicKey, @@ -32,7 +33,8 @@ export class PiratechainTools implements EdgeCurrencyTools { currencyInfo: EdgeCurrencyInfo io: EdgeIo networkInfo: PiratechainNetworkInfo - nativeTools: typeof ToolsType + piratechainIo: PiratechainIo + devicePassphrasePromise?: Promise constructor(env: PluginEnvironment) { const { builtinTokens, currencyInfo, io, networkInfo } = env @@ -48,7 +50,28 @@ export class PiratechainTools implements EdgeCurrencyTools { throw new Error('Need piratechain native IO') } - this.nativeTools = piratechainIo.Tools + this.piratechainIo = piratechainIo + } + + /** + * Hands the bridge the device registry passphrase, once. Every call that + * reaches the SDK's storage goes through here first. This is lazy rather + * than part of construction so that building tools never depends on the + * native module being present. + */ + async ensureDevicePassphrase(): Promise { + if (this.devicePassphrasePromise == null) { + this.devicePassphrasePromise = getPiratechainDevicePassphrase(this.io) + .then(async passphrase => { + await this.piratechainIo.setDevicePassphrase(passphrase) + }) + .catch((error: unknown) => { + // Don't cache a failure — let the next call retry: + this.devicePassphrasePromise = undefined + throw error + }) + } + await this.devicePassphrasePromise } async getDisplayPrivateKey( @@ -65,14 +88,13 @@ export class PiratechainTools implements EdgeCurrencyTools { } async getNewWalletBirthdayBlockheight(): Promise { - return await this.nativeTools.getBirthdayHeight( - this.networkInfo.rpcNode.defaultHost, - this.networkInfo.rpcNode.defaultPort - ) + await this.ensureDevicePassphrase() + return await this.piratechainIo.getLatestNetworkHeight() } async isValidAddress(address: string): Promise { - return await this.nativeTools.isValidAddress(address) + await this.ensureDevicePassphrase() + return await this.piratechainIo.isValidAddress(address) } // will actually use MNEMONIC version of private key @@ -143,13 +165,18 @@ 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: + await this.ensureDevicePassphrase() + 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/piratechainDeviceStorage.ts b/src/piratechain/piratechainDeviceStorage.ts new file mode 100644 index 000000000..045795625 --- /dev/null +++ b/src/piratechain/piratechainDeviceStorage.ts @@ -0,0 +1,68 @@ +import { asJSON, asObject, asString } from 'cleaners' +import { EdgeIo } from 'edge-core-js/types' +import { base16 } from 'rfc4648' + +/** + * The SDK keeps exactly one wallet registry namespace active per device: + * `configureAccountStorage` switches the global namespace, cancelling any + * running sync and clearing the registry and block caches. Edge therefore uses + * a single device-scoped namespace holding every ARRR wallet, keyed by alias + * (see src/docs/piratechain-sdk-v115-reconcile.md). + * + * That namespace still needs a passphrase, and it must be a high-entropy + * secret that is unique per device rather than a hardcoded or seed-derived + * value. We mint one from `io.random` the first time the plugin runs and keep + * it in the plugin's local storage, which never leaves the device. + * + * This runs on the core side. The native IO bridge cannot do it: Metro + * resolves neither `crypto` nor a disklet, so the bridge receives the + * passphrase through `setDevicePassphrase` instead. + */ +const DEVICE_PASSPHRASE_FILE = 'piratechain/devicePassphrase.json' + +const asDevicePassphraseFile = asJSON( + asObject({ + passphrase: asString + }) +) + +let devicePassphrasePromise: Promise | undefined + +export const getPiratechainDevicePassphrase = async ( + io: EdgeIo +): Promise => { + if (devicePassphrasePromise == null) { + // Serialize, so two wallets starting at once cannot each mint a secret + // and race to overwrite the other's registry: + devicePassphrasePromise = loadOrCreateDevicePassphrase(io).catch( + (error: unknown) => { + // Don't cache a failure — let the next wallet retry: + devicePassphrasePromise = undefined + throw error + } + ) + } + return await devicePassphrasePromise +} + +const loadOrCreateDevicePassphrase = async (io: EdgeIo): Promise => { + const { disklet } = io + + // Check existence before reading, so a transient read failure surfaces as an + // error instead of silently minting a new secret and orphaning the registry + // (which would force every wallet to re-scan from its birthday): + const listing = await disklet.list(DEVICE_PASSPHRASE_FILE) + if (listing[DEVICE_PASSPHRASE_FILE] === 'file') { + const text = await disklet.getText(DEVICE_PASSPHRASE_FILE) + try { + return asDevicePassphraseFile(text).passphrase + } catch (error: unknown) { + // Unreadable contents: fall through and re-mint. The wallets in the + // abandoned registry restore themselves from their seeds. + } + } + + const passphrase = base16.stringify(io.random(32)) + await disklet.setText(DEVICE_PASSPHRASE_FILE, JSON.stringify({ passphrase })) + return passphrase +} diff --git a/src/piratechain/piratechainInfo.ts b/src/piratechain/piratechainInfo.ts index 2cba8a81d..7cecb406e 100644 --- a/src/piratechain/piratechainInfo.ts +++ b/src/piratechain/piratechainInfo.ts @@ -16,8 +16,18 @@ const networkInfo: PiratechainNetworkInfo = { defaultHost: 'lightd1.pirate.black', defaultPort: 443 }, - defaultNetworkFee: '10000', - transactionQueryLimit: 999 + // Plain gRPC, not gRPC-over-TLS. The SDK's own `test_node` succeeds against + // `http://lightd1.pirate.black:9067` and fails against + // `https://lightd1.pirate.black:443`, so this is the transport the node + // actually serves; the SDK's built-in default node is plaintext too. That + // leaves sync and broadcast traffic 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. Move to an `https://` endpoint + // as soon as the Pirate team publishes a TLS-terminating lightwalletd this + // SDK can complete a gRPC handshake against. + lightwalletdUrl: 'http://lightd1.pirate.black:9067', + defaultNetworkFee: '10000' } const currencyInfo: EdgeCurrencyInfo = { diff --git a/src/piratechain/piratechainIo.ts b/src/piratechain/piratechainIo.ts index 625148acf..54f236c90 100644 --- a/src/piratechain/piratechainIo.ts +++ b/src/piratechain/piratechainIo.ts @@ -1,84 +1,323 @@ +import { + asBoolean, + asJSON, + 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' + PirateBalance, + PirateTransaction, + PirateWalletSdk, + SynchronizerStatus +} 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 +} + +export interface PiratechainSpendOutput { + addr: string + /** Arrrtoshis as a decimal string to preserve precision above 2^53-1. */ + amount: string + memo?: string +} + +export interface PiratechainWalletConfig { + birthdayHeight: number + /** + * 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 + mnemonic: string + name: 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 + getStatus: () => Promise + getTransactions: () => Promise + rescan: (fromHeight?: number) => Promise + send: (outputs: PiratechainSpendOutput[], fee?: string) => Promise + stop: () => Promise } export interface PiratechainIo { - Tools: typeof Tools + deriveViewingKey: (config: PiratechainWalletConfig) => Promise + getLatestNetworkHeight: () => Promise + isValidAddress: (address: string) => Promise makeSynchronizer: ( - config: InitializerConfig + config: PiratechainWalletConfig ) => Promise + setDevicePassphrase: (passphrase: string) => Promise } +/** + * `configureAccountStorage` selects the SDK's registry namespace 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 core side owns the namespace passphrase — a per-device random secret + * kept in local storage (see piratechainDeviceStorage) — and hands it over + * through `setDevicePassphrase` before the first wallet call. + */ +const DEVICE_ACCOUNT_ID = 'edge-pirate-device' + +const asInvokeEnvelope = asObject({ + ok: asBoolean, + result: asOptional(asUnknown), + error: asOptional(asString) +}) + 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. */ + 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 + } + + // Supplied by the core side, which reads it from local storage: + let devicePassphrase: string | undefined + + /** + * 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 => { + const passphrase = devicePassphrase + if (passphrase == null) { + throw new Error('Piratechain device storage passphrase is not set') + } + await getSdk().configureAccountStorage({ + accountId: DEVICE_ACCOUNT_ID, + passphrase + }) + // 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 setDevicePassphrase(passphrase) { + devicePassphrase = passphrase + }, + + async deriveViewingKey(config) { + const walletId = await ensureWallet(config) + return await getSdk().exportSaplingViewingKey(walletId) + }, + + async getLatestNetworkHeight() { + // The SDK has no wallet-free "get chain tip" call. Any wallet already in + // the registry carries it on its sync status, so ask one of those first: + // adding and removing a throwaway wallet mutates the shared registry, + // and the native service panics (aborting the app) when the registry + // changes underneath a running synchronizer. + const task = registryLock.then(async () => { + const walletSdk = getSdk() + await ensureDeviceStorage() + + if (await walletSdk.walletRegistryExists()) { + const wallets = 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 + } + } + } + + // Nothing in the registry to ask, so no synchronizer can be running + // either, and mutating it is safe. `create_wallet` with no birthday + // resolves the height from the lightwalletd tip, falling back to the + // SDK's static checkpoint: + 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) { + await invokeCall('set_lightd_endpoint', { + wallet_id: walletId, + url: config.lightwalletdUrl + }) + } + + 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, syncStatus } = snapshot + // 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 ( + realSynchronizer.balance ?? (await walletSdk.getBalance(walletId)) + ) + }, + getCurrentAddress: async () => { + return await walletSdk.getCurrentReceiveAddress(walletId) + }, + getStatus: async () => { + return realSynchronizer.status }, - getTransactions: async blockRange => { - return await realSynchronizer.getTransactions(blockRange) + getTransactions: async () => { + return realSynchronizer.transactions }, - rescan: async () => { - return realSynchronizer.rescan() + rescan: async fromHeight => { + await walletSdk.rescan(walletId, fromHeight ?? null) }, - sendToAddress: async spendInfo => { - return await realSynchronizer.sendToAddress(spendInfo) + send: async (outputs, fee) => { + // 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) }, stop: async () => { - return await realSynchronizer.stop() + await realSynchronizer.close() } }) + await realSynchronizer.start() return out } }) diff --git a/src/piratechain/piratechainTypes.ts b/src/piratechain/piratechainTypes.ts index 6f663b056..908b90051 100644 --- a/src/piratechain/piratechainTypes.ts +++ b/src/piratechain/piratechainTypes.ts @@ -8,33 +8,32 @@ 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 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 +85,7 @@ export const asPiratechainPrivateKeys = ( // export const asPiratechainInfoPayload = asObject({ + lightwalletdUrl: asOptional(asString), rpcNode: asOptional( asObject({ networkName: asValue('mainnet', 'testnet'), diff --git a/src/piratechain/rnPirateWallet.d.ts b/src/piratechain/rnPirateWallet.d.ts new file mode 100644 index 000000000..de1b6411f --- /dev/null +++ b/src/piratechain/rnPirateWallet.d.ts @@ -0,0 +1,216 @@ +/** + * Type declarations for `react-native-pirate-wallet`. + * + * The package lives in the PirateNetwork/Pirate-Unified-Light-Wallet monorepo + * (bindings/react-native-pirate-wallet) and is not published to npm, so the + * GUI installs it from a hosted tarball and this repo carries the typings + * needed to compile against it. Only the surface consumed by the piratechain + * plugin is declared here. + */ +declare module 'react-native-pirate-wallet' { + export type SyncMode = 'Compact' | 'Deep' + export type SynchronizerStatus = 'STOPPED' | 'SYNCING' | 'SYNCED' + + export interface WalletMeta { + id: string + name: string + createdAt: number + watchOnly: boolean + birthdayHeight: number + networkType?: 'mainnet' | 'testnet' | 'regtest' | null + } + + export interface SynchronizerConfig { + syncMode?: SyncMode + syncingPollIntervalMs?: number + syncedPollIntervalMs?: number + errorPollIntervalMs?: number + transactionLimit?: number | null + } + + /** Result of the `sync_status` RPC. Heights are absolute block heights. */ + export interface PirateSyncStatus { + localHeight: number + targetHeight: number + percent: number + eta: number | null + stage: string | null + lastCheckpoint: number | null + blocksPerSecond: number | null + notesDecrypted: number | null + lastBatchMs: number | null + } + + /** + * Result of the `get_balance` RPC. Values are arrrtoshis serialized as + * decimal strings so balances above 2^53-1 keep full precision. + */ + export interface PirateBalance { + total: string + spendable: string + pending: string + } + + /** + * Entry of the `list_transactions` RPC result. Amounts are arrrtoshis + * serialized as decimal strings (see PirateBalance). + */ + export interface PirateTransaction { + txid: string + height: number | null + timestamp: number + amount: string + fee: string + memo: string | null + confirmed: boolean + } + + export interface PirateNetworkInfo { + name: string + coinType: number + rpcPort: number + defaultBirthday: number + } + + export interface PirateFeeInfo { + defaultFee: number + minFee: number + maxFee: number + feePerOutput: number + memoFeeMultiplier: number + } + + export interface PirateAddressValidation { + isValid: boolean + addressType: string | null + reason: string | null + } + + export interface PirateTransactionOutput { + addr: string + /** Arrrtoshis as a decimal string to preserve precision above 2^53-1. */ + amount: string + memo?: string | null + } + + /** + * Selects the encrypted wallet registry, by path and passphrase. This is + * global state: one registry is active at a time, and switching cancels any + * running sync and clears the registry and block caches. Edge configures a + * single device-scoped registry once and keeps every wallet inside it. + */ + export interface PirateAccountStorageConfig { + accountId: string + passphrase: string + storagePath?: string | null + } + + export interface SynchronizerSnapshot { + walletId: string + alias: string + status: SynchronizerStatus + progressPercent: number + syncStatus: PirateSyncStatus | null + latestBirthdayHeight: number | null + balance: PirateBalance | null + transactions: PirateTransaction[] + updatedAtMillis: number | null + lastError: Error | null + } + + export interface SynchronizerCallbacks { + onStatusChanged?: (event: { + walletId: string + alias: string + name: SynchronizerStatus + }) => void + onUpdate?: (snapshot: SynchronizerSnapshot) => void + // Native errors are serialized across the RN bridge and arrive as plain + // objects or strings, not real Error instances, so consumers must narrow: + onError?: (error: unknown) => void + } + + export class PirateWalletSynchronizer { + constructor( + sdk: PirateWalletSdk, + walletId: string, + config?: SynchronizerConfig + ) + walletId: string + config: SynchronizerConfig + status: SynchronizerStatus + progress: number + syncStatus: PirateSyncStatus | null + latestBirthdayHeight: number | null + balance: PirateBalance | null + transactions: PirateTransaction[] + lastError: Error | null + currentSnapshot: () => SynchronizerSnapshot + isRunning: () => boolean + isSyncing: () => boolean + isComplete: () => boolean + start: () => Promise + stop: () => Promise + refresh: () => Promise + close: () => Promise + subscribe: (callbacks?: SynchronizerCallbacks) => () => void + } + + export class PirateWalletSdk { + invoke: (requestJson: string, pretty?: boolean) => Promise + createSynchronizer: ( + walletId: string, + config?: SynchronizerConfig + ) => PirateWalletSynchronizer + + walletRegistryExists: () => Promise + listWallets: () => Promise + getWallet: (walletId: string) => Promise + createWallet: ( + requestOrName: string | { name: string; birthdayHeight?: number | null }, + birthdayHeight?: number | null + ) => Promise + + restoreWallet: ( + requestOrName: + | string + | { name: string; mnemonic: string; birthdayHeight?: number | null }, + mnemonic?: string, + birthdayHeight?: number | null + ) => Promise + + deleteWallet: (walletId: string) => Promise + getLatestBirthdayHeight: (walletId: string) => Promise + validateMnemonic: (mnemonic: string) => Promise + getNetworkInfo: () => Promise + isValidShieldedAddr: (address: string) => Promise + validateAddress: (address: string) => Promise + getCurrentReceiveAddress: (walletId: string) => Promise + getNextReceiveAddress: (walletId: string) => Promise + getBalance: (walletId: string) => Promise + listTransactions: ( + walletId: string, + limit?: number | null + ) => Promise + + getFeeInfo: () => Promise + startSync: (walletId: string, mode?: SyncMode) => Promise + getSyncStatus: (walletId: string) => Promise + cancelSync: (walletId: string) => Promise + rescan: (walletId: string, fromHeight?: number | null) => Promise + send: ( + walletId: string, + outputsOrOutput: PirateTransactionOutput | PirateTransactionOutput[], + fee?: string | null + ) => Promise + + configureAccountStorage: ( + config: PirateAccountStorageConfig + ) => Promise + + exportSaplingViewingKey: (walletId: string) => Promise + exportIronwoodViewingKey: (walletId: string) => Promise + } + + export function createPirateWalletSdk(): PirateWalletSdk +}