From 71b6d1b0ef4e95d05655167975e535e828042ede Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 5 Jun 2026 13:03:49 -0700 Subject: [PATCH 1/8] Reimplement Pirate Chain plugin over react-native-pirate-wallet The piratechain team's orchard upgrade replaces the zcash-cloned react-native-piratechain SDK with a wallet-registry based SDK (react-native-pirate-wallet) whose lightwalletd endpoint, checkpoints, and spending keys live inside the native core. Rebuild the engine, tools, and yaob io bridge on that API: wallets are restored into the SDK registry under the Edge walletId alias, sync progress comes from the SDK's polling synchronizer, transactions map from signed fee-inclusive amounts, and sends go through the registry wallet instead of passing the mnemonic per spend. --- CHANGELOG.md | 2 + package-lock.json | 16 +- package.json | 3 +- src/piratechain/PiratechainEngine.ts | 205 ++++++++------------- src/piratechain/PiratechainTools.ts | 25 +-- src/piratechain/piratechainInfo.ts | 3 +- src/piratechain/piratechainIo.ts | 266 ++++++++++++++++++++++----- src/piratechain/piratechainTypes.ts | 14 +- src/piratechain/rnPirateWallet.d.ts | 191 +++++++++++++++++++ 9 files changed, 514 insertions(+), 211 deletions(-) create mode 100644 src/piratechain/rnPirateWallet.d.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e3f1ea559..b4435c916 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- changed: (ARRR) Reimplement the Pirate Chain plugin over the unified `react-native-pirate-wallet` SDK, replacing `react-native-piratechain`. + ## 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..9a21011eb 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.1.1", "react-native-zano": "^0.2.7", "react-native-zcash": "^0.13.1" }, diff --git a/src/piratechain/PiratechainEngine.ts b/src/piratechain/PiratechainEngine.ts index 3f1769586..baa2be524 100644 --- a/src/piratechain/PiratechainEngine.ts +++ b/src/piratechain/PiratechainEngine.ts @@ -11,16 +11,12 @@ 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' import { PluginEnvironment } from '../common/innerPlugin' -import { cleanTxLogs } from '../common/utils' +import { cleanTxLogs, safeParseInt } from '../common/utils' import type { PiratechainIo, PiratechainSynchronizer } from './piratechainIo' import { makePiratechainSyncTracker, @@ -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,18 +83,10 @@ 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 { @@ -115,18 +105,13 @@ export class PiratechainEngine extends CurrencyEngine< this.synchronizerStatus = payload.name await this.queryAll() }) - this.synchronizer.on('error', async payload => { + this.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 +135,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 +149,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 +169,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 (tx.amount >= 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 +214,21 @@ 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, - birthdayHeight: piratechainPrivateKeys.birthdayHeight, - alias: base16.stringify(base64.parse(this.walletId)), - ...rpcNode + name: base16.stringify(base64.parse(this.walletId)), + mnemonic: piratechainPrivateKeys.mnemonic, + birthdayHeight: piratechainPrivateKeys.birthdayHeight }) 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 +262,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 +336,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: safeParseInt(spendAmount), + memo + } + ], + safeParseInt(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 +382,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..db4c8bf2a 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,11 @@ 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() } 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 +140,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..68131ad36 100644 --- a/src/piratechain/piratechainInfo.ts +++ b/src/piratechain/piratechainInfo.ts @@ -16,8 +16,7 @@ const networkInfo: PiratechainNetworkInfo = { defaultHost: 'lightd1.pirate.black', defaultPort: 443 }, - defaultNetworkFee: '10000', - transactionQueryLimit: 999 + defaultNetworkFee: '10000' } const currencyInfo: EdgeCurrencyInfo = { diff --git a/src/piratechain/piratechainIo.ts b/src/piratechain/piratechainIo.ts index 625148acf..729b66b5e 100644 --- a/src/piratechain/piratechainIo.ts +++ b/src/piratechain/piratechainIo.ts @@ -1,84 +1,264 @@ +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 + amount: number + memo?: string +} + +export interface PiratechainWalletConfig { + birthdayHeight: number + 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 + getTransactions: () => Promise + rescan: (fromHeight?: number) => Promise + send: (outputs: PiratechainSpendOutput[], fee?: number) => 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 } +/** + * The SDK encrypts its on-device databases (SQLCipher) behind an app + * passphrase and rejects every wallet call with "App is locked" until + * `set_app_passphrase`/`unlock_app` runs. Edge already gates wallet access + * behind its own login, so a fixed passphrase keeps at-rest encryption at + * parity with the previous SDK (sandbox-protected files). + */ +const APP_PASSPHRASE = 'edge-pirate-wallet' + +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 + } + + let unlockPromise: Promise | undefined + const ensureUnlocked = async (): Promise => { + if (unlockPromise == null) { + unlockPromise = (async () => { + const hasPassphrase = asBoolean(await invokeCall('has_app_passphrase')) + if (hasPassphrase) { + await invokeCall('unlock_app', { passphrase: APP_PASSPHRASE }) + } else { + await invokeCall('set_app_passphrase', { passphrase: APP_PASSPHRASE }) + } + // The SDK tunnels through Tor by default, which doesn't reliably + // bootstrap inside Edge. Connect directly, like every other plugin: + await invokeCall('set_tunnel', { mode: 'Direct' }) + })().catch((error: unknown) => { + // Allow a retry on the next call instead of caching the failure: + unlockPromise = undefined + throw error + }) + } + await unlockPromise + } + + /** + * Finds the registry wallet matching the Edge wallet's alias name, + * restoring it from the mnemonic if this device hasn't seen it yet. + * Calls are serialized because the registry has no name uniqueness: + * two concurrent restores would create duplicate wallets. + */ + let ensureWalletLock: Promise = Promise.resolve() + const ensureWallet = async ( + config: PiratechainWalletConfig + ): Promise => { + const task = ensureWalletLock.then(async () => { + const { birthdayHeight, mnemonic, name } = config + const walletSdk = getSdk() + await ensureUnlocked() + 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 }) + }) + ensureWalletLock = 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() { + // The SDK has no wallet-free "get chain tip" call, but `create_wallet` + // with no birthday resolves one from the lightwalletd tip (falling back + // to the SDK's static checkpoint), so probe with a throwaway wallet. + // Registry mutations share the serialization lock (see ensureWallet): + const task = ensureWalletLock.then(async () => { + const walletSdk = getSdk() + await ensureUnlocked() + 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) + } + }) + ensureWalletLock = task.catch(() => undefined) + return await task + }, + + async isValidAddress(address) { + await ensureUnlocked() + 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) + 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) }, - 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 wrapper's `send` helper camelizes the build_tx result and + // feeds it back into sign_tx, which rejects it (snake_case + // fields), so run the three steps over the raw invoke bridge: + const pending = await invokeCall('build_tx', { + wallet_id: walletId, + outputs, + fee_opt: fee ?? null + }) + const signed = await invokeCall('sign_tx', { + wallet_id: walletId, + pending + }) + const txid = await invokeCall('broadcast_tx', { signed }) + return asString(txid) }, 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..2a526789e 100644 --- a/src/piratechain/piratechainTypes.ts +++ b/src/piratechain/piratechainTypes.ts @@ -8,33 +8,23 @@ 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 } 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) }) diff --git a/src/piratechain/rnPirateWallet.d.ts b/src/piratechain/rnPirateWallet.d.ts new file mode 100644 index 000000000..da0b39395 --- /dev/null +++ b/src/piratechain/rnPirateWallet.d.ts @@ -0,0 +1,191 @@ +/** + * 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. */ + export interface PirateBalance { + total: number + spendable: number + pending: number + } + + /** Entry of the `list_transactions` RPC result. Amounts are arrrtoshis. */ + export interface PirateTransaction { + txId: string + height: number | null + timestamp: number + amount: number + fee: number + 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 + amount: number + memo?: 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 + onError?: (error: Error) => 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?: number | null + ) => Promise + + exportSaplingViewingKey: (walletId: string) => Promise + exportOrchardViewingKey: (walletId: string) => Promise + } + + export function createPirateWalletSdk(): PirateWalletSdk +} From 12bac7e4d2d7f79a4da97575a8e06ea61ca121df Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Tue, 28 Jul 2026 14:53:50 -0700 Subject: [PATCH 2/8] Reconcile Pirate Chain plugin to released SDK v1.1.5 The Pirate Unified Light Wallet v1.1.5 release ships the merged upstream fixes and finalizes the wire format the plugin targets. Serialize amounts as decimal strings end to end so balances and sends above 2^53-1 arrrtoshi keep full precision, and drive sends through the SDK's send() now that it keeps the opaque build/sign/broadcast payloads verbatim. Replace the removed global app-passphrase flow with per-wallet configureAccountStorage so each local wallet lives in its own registry namespace unlocked by a passphrase derived from that wallet's seed instead of a shared hardcoded one. Type native bridge errors as unknown to match what the RN bridge actually delivers. --- CHANGELOG.md | 2 +- package.json | 2 +- src/piratechain/PiratechainEngine.ts | 22 +++--- src/piratechain/PiratechainTools.ts | 4 +- src/piratechain/piratechainCrypto.ts | 17 ++++ src/piratechain/piratechainIo.ts | 114 +++++++++++++++------------ src/piratechain/rnPirateWallet.d.ts | 48 ++++++++--- 7 files changed, 133 insertions(+), 76 deletions(-) create mode 100644 src/piratechain/piratechainCrypto.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b4435c916..3ea1d47e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- changed: (ARRR) Reimplement the Pirate Chain plugin over the unified `react-native-pirate-wallet` SDK, replacing `react-native-piratechain`. +- changed: (ARRR) Reimplement the Pirate Chain plugin over the unified `react-native-pirate-wallet` SDK, replacing `react-native-piratechain`. Isolate each wallet in its own passphrase-scoped registry and encode amounts as strings for full precision. ## 4.87.0 (2026-08-02) diff --git a/package.json b/package.json index 9a21011eb..5055952c0 100644 --- a/package.json +++ b/package.json @@ -186,7 +186,7 @@ }, "peerDependencies": { "react-native-monero": "^0.3.0", - "react-native-pirate-wallet": "^0.1.1", + "react-native-pirate-wallet": "^0.2.0", "react-native-zano": "^0.2.7", "react-native-zcash": "^0.13.1" }, diff --git a/src/piratechain/PiratechainEngine.ts b/src/piratechain/PiratechainEngine.ts index baa2be524..73f3d4dc9 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, @@ -16,7 +16,8 @@ import { base16, base64 } from 'rfc4648' import { CurrencyEngine } from '../common/CurrencyEngine' import { PluginEnvironment } from '../common/innerPlugin' -import { cleanTxLogs, safeParseInt } from '../common/utils' +import { cleanTxLogs } from '../common/utils' +import { derivePiratechainRegistryPassphrase } from './piratechainCrypto' import type { PiratechainIo, PiratechainSynchronizer } from './piratechainIo' import { makePiratechainSyncTracker, @@ -154,9 +155,9 @@ export class PiratechainEngine extends CurrencyEngine< // 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 + if (this.processedTxHeights.get(tx.txid) === height) continue this.processTransaction(tx) - this.processedTxHeights.set(tx.txId, height) + this.processedTxHeights.set(tx.txid, height) } if (this.isSynced()) { this.syncTracker.updateTransactionRatio(1) @@ -173,7 +174,7 @@ export class PiratechainEngine extends CurrencyEngine< // A negative amount is a send and already includes the network fee: const netNativeAmount = String(tx.amount) const ourReceiveAddresses = [] - if (tx.amount >= 0) { + if (gte(netNativeAmount, '0')) { ourReceiveAddresses.push(this.walletInfo.keys.publicKey) } @@ -201,7 +202,7 @@ export class PiratechainEngine extends CurrencyEngine< ourReceiveAddresses, // blank if you sent money otherwise array of addresses that are yours in this transaction signedTx: '', tokenId: null, - txid: tx.txId, + txid: tx.txid, walletId: this.walletId } this.addTransaction(null, edgeTransaction) @@ -221,7 +222,10 @@ export class PiratechainEngine extends CurrencyEngine< this.synchronizerPromise = this.makeSynchronizer({ name: base16.stringify(base64.parse(this.walletId)), mnemonic: piratechainPrivateKeys.mnemonic, - birthdayHeight: piratechainPrivateKeys.birthdayHeight + birthdayHeight: piratechainPrivateKeys.birthdayHeight, + registryPassphrase: derivePiratechainRegistryPassphrase( + piratechainPrivateKeys.mnemonic + ) }) this.synchronizer = await this.synchronizerPromise // People might be waiting on the old promise, so resolve that @@ -363,11 +367,11 @@ export class PiratechainEngine extends CurrencyEngine< [ { addr: spendTarget.publicAddress, - amount: safeParseInt(spendAmount), + amount: spendAmount, memo } ], - safeParseInt(edgeTransaction.networkFee) + edgeTransaction.networkFee ) edgeTransaction.txid = txid edgeTransaction.date = Date.now() / 1000 diff --git a/src/piratechain/PiratechainTools.ts b/src/piratechain/PiratechainTools.ts index db4c8bf2a..403d1d90d 100644 --- a/src/piratechain/PiratechainTools.ts +++ b/src/piratechain/PiratechainTools.ts @@ -18,6 +18,7 @@ import { PluginEnvironment } from '../common/innerPlugin' import { asIntegerString } from '../common/types' import { encodeUriCommon, parseUriCommon } from '../common/uriHelpers' import { getLegacyDenomination, mergeDeeply } from '../common/utils' +import { derivePiratechainRegistryPassphrase } from './piratechainCrypto' import type { PiratechainIo } from './piratechainIo' import { asArrrPublicKey, @@ -146,7 +147,8 @@ export class PiratechainTools implements EdgeCurrencyTools { const viewingKey = await this.piratechainIo.deriveViewingKey({ name: base16.stringify(base64.parse(walletInfo.id)), mnemonic, - birthdayHeight: piratechainPrivateKeys.birthdayHeight + birthdayHeight: piratechainPrivateKeys.birthdayHeight, + registryPassphrase: derivePiratechainRegistryPassphrase(mnemonic) }) return { birthdayHeight: piratechainPrivateKeys.birthdayHeight, diff --git a/src/piratechain/piratechainCrypto.ts b/src/piratechain/piratechainCrypto.ts new file mode 100644 index 000000000..1a67b4ab7 --- /dev/null +++ b/src/piratechain/piratechainCrypto.ts @@ -0,0 +1,17 @@ +import { createHmac } from 'crypto' + +/** + * Derives the per-wallet registry passphrase from the wallet's seed. The SDK + * requires a unique, high-entropy, secret-derived passphrase per local wallet + * (never a shared or hardcoded one), so HMAC the seed rather than passing the + * raw mnemonic as the passphrase. + * + * This runs on the core (webpack) side, where `crypto` is polyfilled by + * `crypto-browserify`. It must NOT be imported by the native IO bridge, which + * is bundled by Metro where `crypto` does not resolve; the bridge receives the + * already-derived passphrase through its wallet config instead. + */ +const PASSPHRASE_DOMAIN = 'edge-pirate-wallet-registry-v1' + +export const derivePiratechainRegistryPassphrase = (mnemonic: string): string => + createHmac('sha256', mnemonic).update(PASSPHRASE_DOMAIN).digest('hex') diff --git a/src/piratechain/piratechainIo.ts b/src/piratechain/piratechainIo.ts index 729b66b5e..f44fddf36 100644 --- a/src/piratechain/piratechainIo.ts +++ b/src/piratechain/piratechainIo.ts @@ -38,7 +38,8 @@ export interface PiratechainEvents { export interface PiratechainSpendOutput { addr: string - amount: number + /** Arrrtoshis as a decimal string to preserve precision above 2^53-1. */ + amount: string memo?: string } @@ -46,6 +47,12 @@ export interface PiratechainWalletConfig { birthdayHeight: number mnemonic: string name: string + /** + * The registry passphrase, derived from the wallet seed on the core side + * (see piratechainCrypto). The bridge cannot derive it because Metro does + * not resolve `crypto`. + */ + registryPassphrase: string } export interface PiratechainSynchronizer { @@ -54,7 +61,7 @@ export interface PiratechainSynchronizer { getCurrentAddress: () => Promise getTransactions: () => Promise rescan: (fromHeight?: number) => Promise - send: (outputs: PiratechainSpendOutput[], fee?: number) => Promise + send: (outputs: PiratechainSpendOutput[], fee?: string) => Promise stop: () => Promise } @@ -68,13 +75,18 @@ export interface PiratechainIo { } /** - * The SDK encrypts its on-device databases (SQLCipher) behind an app - * passphrase and rejects every wallet call with "App is locked" until - * `set_app_passphrase`/`unlock_app` runs. Edge already gates wallet access - * behind its own login, so a fixed passphrase keeps at-rest encryption at - * parity with the previous SDK (sandbox-protected files). + * The SDK isolates each local wallet in its own encrypted registry namespace, + * selected by `configureAccountStorage` before any wallet call. The registry + * passphrase must be unique per local wallet and derived from high-entropy + * secret material rather than shared or hardcoded; the core side derives it + * from the wallet seed (see piratechainCrypto) and passes it in the config. + * + * A separate throwaway namespace serves wallet-free reads (address validation, + * chain-tip probe). It never holds funds or spending keys, so a fixed + * passphrase is safe here. */ -const APP_PASSPHRASE = 'edge-pirate-wallet' +const PROBE_ACCOUNT_ID = 'edge-arrr-probe' +const PROBE_PASSPHRASE = 'edge-arrr-probe-namespace-v1' const asInvokeEnvelope = asObject({ ok: asBoolean, @@ -106,42 +118,49 @@ export function makePiratechainIo(): PiratechainIo { return envelope.result } - let unlockPromise: Promise | undefined - const ensureUnlocked = async (): Promise => { - if (unlockPromise == null) { - unlockPromise = (async () => { - const hasPassphrase = asBoolean(await invokeCall('has_app_passphrase')) - if (hasPassphrase) { - await invokeCall('unlock_app', { passphrase: APP_PASSPHRASE }) - } else { - await invokeCall('set_app_passphrase', { passphrase: APP_PASSPHRASE }) - } - // The SDK tunnels through Tor by default, which doesn't reliably - // bootstrap inside Edge. Connect directly, like every other plugin: - await invokeCall('set_tunnel', { mode: 'Direct' }) - })().catch((error: unknown) => { - // Allow a retry on the next call instead of caching the failure: - unlockPromise = undefined - throw error - }) - } - await unlockPromise + // Selecting a namespace clears the SDK's active wallet and sync caches, so + // track the active one and only switch when it actually changes: + let activeAccountId: string | undefined + const selectNamespace = async ( + accountId: string, + passphrase: string + ): Promise => { + if (activeAccountId === accountId) return + await getSdk().configureAccountStorage({ accountId, passphrase }) + // The default transport tunnels through Tor, which doesn't reliably + // bootstrap inside Edge, and a namespace switch clears transport state. + // Reconnect directly, like every other plugin. Mark the namespace active + // only after the tunnel is set: if set_tunnel throws, activeAccountId + // stays unchanged so a retry reconfigures fully instead of early-returning + // onto the unreliable default Tor transport. + await invokeCall('set_tunnel', { mode: 'Direct' }) + activeAccountId = accountId } /** - * Finds the registry wallet matching the Edge wallet's alias name, - * restoring it from the mnemonic if this device hasn't seen it yet. - * Calls are serialized because the registry has no name uniqueness: - * two concurrent restores would create duplicate wallets. + * Ensures some namespace is active for a wallet-free read. Reuses the + * currently-selected wallet namespace when one is active so validating an + * address never clears a syncing wallet's caches. + */ + const ensureAnyNamespace = async (): Promise => { + if (activeAccountId != null) return + await selectNamespace(PROBE_ACCOUNT_ID, PROBE_PASSPHRASE) + } + + /** + * Finds the registry wallet matching the Edge wallet's alias name inside its + * own namespace, restoring it from the mnemonic if this device hasn't seen + * it yet. Calls are serialized so a namespace switch never interleaves with + * another wallet's registry mutation. */ let ensureWalletLock: Promise = Promise.resolve() const ensureWallet = async ( config: PiratechainWalletConfig ): Promise => { const task = ensureWalletLock.then(async () => { - const { birthdayHeight, mnemonic, name } = config + const { birthdayHeight, mnemonic, name, registryPassphrase } = config const walletSdk = getSdk() - await ensureUnlocked() + await selectNamespace(name, registryPassphrase) const registryExists = await walletSdk.walletRegistryExists() if (registryExists) { const wallets = await walletSdk.listWallets() @@ -163,11 +182,12 @@ export function makePiratechainIo(): PiratechainIo { async getLatestNetworkHeight() { // The SDK has no wallet-free "get chain tip" call, but `create_wallet` // with no birthday resolves one from the lightwalletd tip (falling back - // to the SDK's static checkpoint), so probe with a throwaway wallet. - // Registry mutations share the serialization lock (see ensureWallet): + // to the SDK's static checkpoint), so probe with a throwaway wallet in + // the throwaway namespace. Shares the serialization lock (see + // ensureWallet) so it never switches namespaces mid-mutation: const task = ensureWalletLock.then(async () => { const walletSdk = getSdk() - await ensureUnlocked() + await selectNamespace(PROBE_ACCOUNT_ID, PROBE_PASSPHRASE) const probeWalletId = await walletSdk.createWallet({ name: 'edge-birthday-probe' }) @@ -186,7 +206,7 @@ export function makePiratechainIo(): PiratechainIo { }, async isValidAddress(address) { - await ensureUnlocked() + await ensureAnyNamespace() const result = await getSdk().validateAddress(address) return result.isValid }, @@ -238,20 +258,10 @@ export function makePiratechainIo(): PiratechainIo { await walletSdk.rescan(walletId, fromHeight ?? null) }, send: async (outputs, fee) => { - // The wrapper's `send` helper camelizes the build_tx result and - // feeds it back into sign_tx, which rejects it (snake_case - // fields), so run the three steps over the raw invoke bridge: - const pending = await invokeCall('build_tx', { - wallet_id: walletId, - outputs, - fee_opt: fee ?? null - }) - const signed = await invokeCall('sign_tx', { - wallet_id: walletId, - pending - }) - const txid = await invokeCall('broadcast_tx', { signed }) - return asString(txid) + // 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 () => { await realSynchronizer.close() diff --git a/src/piratechain/rnPirateWallet.d.ts b/src/piratechain/rnPirateWallet.d.ts index da0b39395..6d2a562ad 100644 --- a/src/piratechain/rnPirateWallet.d.ts +++ b/src/piratechain/rnPirateWallet.d.ts @@ -41,20 +41,26 @@ declare module 'react-native-pirate-wallet' { lastBatchMs: number | null } - /** Result of the `get_balance` RPC. Values are arrrtoshis. */ + /** + * 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: number - spendable: number - pending: number + total: string + spendable: string + pending: string } - /** Entry of the `list_transactions` RPC result. Amounts are arrrtoshis. */ + /** + * Entry of the `list_transactions` RPC result. Amounts are arrrtoshis + * serialized as decimal strings (see PirateBalance). + */ export interface PirateTransaction { - txId: string + txid: string height: number | null timestamp: number - amount: number - fee: number + amount: string + fee: string memo: string | null confirmed: boolean } @@ -82,10 +88,22 @@ declare module 'react-native-pirate-wallet' { export interface PirateTransactionOutput { addr: string - amount: number + /** Arrrtoshis as a decimal string to preserve precision above 2^53-1. */ + amount: string memo?: string | null } + /** + * Per-account storage isolation. Configures a registry with its own path + * and passphrase so each local Edge account gets an isolated wallet + * registry instead of sharing a single device-wide one. + */ + export interface PirateAccountStorageConfig { + accountId: string + passphrase: string + storagePath?: string | null + } + export interface SynchronizerSnapshot { walletId: string alias: string @@ -106,7 +124,9 @@ declare module 'react-native-pirate-wallet' { name: SynchronizerStatus }) => void onUpdate?: (snapshot: SynchronizerSnapshot) => void - onError?: (error: Error) => 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 { @@ -180,11 +200,15 @@ declare module 'react-native-pirate-wallet' { send: ( walletId: string, outputsOrOutput: PirateTransactionOutput | PirateTransactionOutput[], - fee?: number | null + fee?: string | null ) => Promise + configureAccountStorage: ( + config: PirateAccountStorageConfig + ) => Promise + exportSaplingViewingKey: (walletId: string) => Promise - exportOrchardViewingKey: (walletId: string) => Promise + exportIronwoodViewingKey: (walletId: string) => Promise } export function createPirateWalletSdk(): PirateWalletSdk From 11d1edf2548d9e2778f3b997ab2bd0e4295ccb6b Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Tue, 28 Jul 2026 15:48:24 -0700 Subject: [PATCH 3/8] Add Piratechain v1.1.5 reconciliation design doc --- src/docs/piratechain-sdk-v115-reconcile.md | 163 +++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 src/docs/piratechain-sdk-v115-reconcile.md diff --git a/src/docs/piratechain-sdk-v115-reconcile.md b/src/docs/piratechain-sdk-v115-reconcile.md new file mode 100644 index 000000000..1a7b76315 --- /dev/null +++ b/src/docs/piratechain-sdk-v115-reconcile.md @@ -0,0 +1,163 @@ +# Piratechain SDK v1.1.5 reconciliation: replace the crashing wallet module with the released unified SDK + +| | | +|---|---| +| Status | Implemented (pending sim verification) | +| Author | Jon Tzeng | +| Reviewer | peachbits | +| Last updated | 2026-07-28 | +| 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 (vendored) | +| 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 vendored SDK](#6-detailed-design-edge-react-gui-and-the-vendored-sdk) +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), per-wallet registry passphrase (security), and honest native-error typing. +- Keep `piratechain: true` in the GUI and confirm on device that the crash is gone, removing the need for the corePlugins workaround. + +Non-goals: +- Plumbing an Edge-account-derived secret into the plugin's native IO for a single per-Edge-account registry. The bridge only receives per-wallet secret material, so this design scopes the registry per wallet instead ([decision 1](#decision-1-per-wallet-registry-namespaces)). +- Publishing `react-native-pirate-wallet` to npm. It stays a vendored `file:` dependency, unchanged from the prior phase. + +## 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) | Dependency version bump, keep plugin enabled ([section 6](#6-detailed-design-edge-react-gui-and-the-vendored-sdk)) | +| react-native-pirate-wallet | vendored `file:` sibling | Re-vendored to v1.1.5 0.2.0 ([section 6](#6-detailed-design-edge-react-gui-and-the-vendored-sdk)) | + +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 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 }) + Bridge->>SDK: configureAccountStorage({ accountId: name, passphrase: HMAC(mnemonic) }) + SDK->>Rust: configure_wallet_storage (open/create namespace) + 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 + +Three files change; the plugin's public shape and the engine's transaction mapping are unchanged. + +### Registry storage and namespaces + +v1.1.5 removes the global `set_app_passphrase` / `unlock_app` flow entirely; the only storage entry point is `configureAccountStorage`, which selects an account-scoped registry directory, creates or unlocks it with the passphrase, and clears active wallet and sync caches before switching namespaces. The bridge configures one namespace per Edge wallet, keyed by the wallet's alias `name` (already the `base16(walletId)` the tools layer passes), with a passphrase derived from that wallet's seed: + +```ts +// as landed, piratechainIo.ts +const PASSPHRASE_DOMAIN = 'edge-pirate-wallet-registry-v1' +const deriveNamespacePassphrase = (mnemonic: string): string => + createHmac('sha256', mnemonic).update(PASSPHRASE_DOMAIN).digest('hex') +``` + +`selectNamespace(accountId, passphrase)` no-ops when the requested namespace is already active, so a syncing wallet's caches are not cleared by unrelated reads. Wallet-free reads (`isValidAddress`, the chain-tip probe in `getLatestNetworkHeight`) reuse the active namespace when one exists and otherwise fall back to a fixed throwaway probe namespace that never holds funds. All namespace switches and registry mutations run under the existing `ensureWalletLock` serialization. + +### 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 vendored SDK + +The vendored `react-native-pirate-wallet` sibling is re-extracted from the v1.1.5 release artifact (`pirate-unified-wallet-react-native-plugin-artifacts-v1.1.5.zip`), taking the 0.2.0 binding source plus the iOS xcframework (device and simulator slices) and Android jniLibs. The GUI dependency reference stays `file:../react-native-pirate-wallet`; only the resolved version in `yarn.lock` and `ios/Podfile.lock` moves from 0.1.1 to 0.2.0. `src/util/corePlugins.ts` keeps `piratechain: true`; no code change is needed there because the fix is native. The seam back to the plugin is the bridge in [section 5](#5-detailed-design-edge-currency-accountbased) and its diagram. + +## 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: build the GUI for the iOS simulator with `piratechain: true` (no corePlugins disable) against the v1.1.5 native binaries, open an ARRR wallet, and confirm the app does not crash while the wallet syncs. This is the primary acceptance signal. +3. Send: from a funded ARRR wallet, send a small amount to a second address and reach the transaction-success scene, confirming the string-amount send path and the SDK `send()` call end to end. + +## 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 needs an account-derived secret plumbed to the native IO and is out of scope here ([decision 1](#decision-1-per-wallet-registry-namespaces)). + +## 9. Decisions + +### Decision 1: per-wallet registry namespaces +Chosen: one `configureAccountStorage` namespace per Edge wallet, keyed by the wallet alias, passphrase derived from that wallet's seed. +Evidence: the native IO bridge is a single shared instance that receives only per-wallet config (`{ mnemonic, name, birthdayHeight }`); it has no Edge-account handle. v1.1.5's README requires a unique, high-entropy, secret-derived passphrase per local account and forbids hardcoded or public values. The wallet seed is the only secret material the bridge holds. +Rejected: a single shared namespace with one passphrase, which cannot be both unique-per-account and derived-from-secret without plumbing an account secret the bridge does not have, and which is exactly the hardcoded-passphrase pattern the reviewer flagged. Rejected: per-Edge-account namespaces, which would require changing how the plugin's native IO is instantiated to carry account secret material; deferred as a non-goal. +Reopen if: the plugin gains access to an Edge-account-derived secret, or concurrent multi-wallet sync (which forces namespace re-selection and cache clears on switch) becomes a measured problem. + +### 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: derive the passphrase with an HMAC over the seed +Chosen: `createHmac('sha256', mnemonic).update(domain).digest('hex')` from Node `crypto` (shimmed by the `crypto-browserify` dependency in the RN bundle). +Evidence: `crypto-browserify` is a direct dependency and `@types/node` types the import, so it type-checks and bundles. HMAC over the seed yields a stable, high-entropy, per-wallet value without ever using the raw mnemonic as the passphrase. +Rejected: `create-hmac` directly (present transitively but untyped, would introduce `any`); using the raw mnemonic (exposes spending material as the storage key). +Reopen if: the RN bundle stops shimming `crypto`, in which case switch to a typed hashing dependency. + +## 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). From 18dd062f5afd8e6f3c66bce9615ca5e9eb60b313 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 29 Jul 2026 17:27:50 -0700 Subject: [PATCH 4/8] Update Piratechain v1.1.5 reconciliation doc with e2e send verification --- src/docs/piratechain-sdk-v115-reconcile.md | 38 ++++++++++++++-------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/src/docs/piratechain-sdk-v115-reconcile.md b/src/docs/piratechain-sdk-v115-reconcile.md index 1a7b76315..233e84262 100644 --- a/src/docs/piratechain-sdk-v115-reconcile.md +++ b/src/docs/piratechain-sdk-v115-reconcile.md @@ -2,10 +2,10 @@ | | | |---|---| -| Status | Implemented (pending sim verification) | +| Status | Implemented and verified (iOS sim, e2e send broadcast) | | Author | Jon Tzeng | | Reviewer | peachbits | -| Last updated | 2026-07-28 | +| Last updated | 2026-07-29 | | 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 (vendored) | | 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 | - | @@ -86,16 +86,18 @@ Three files change; the plugin's public shape and the engine's transaction mappi ### Registry storage and namespaces -v1.1.5 removes the global `set_app_passphrase` / `unlock_app` flow entirely; the only storage entry point is `configureAccountStorage`, which selects an account-scoped registry directory, creates or unlocks it with the passphrase, and clears active wallet and sync caches before switching namespaces. The bridge configures one namespace per Edge wallet, keyed by the wallet's alias `name` (already the `base16(walletId)` the tools layer passes), with a passphrase derived from that wallet's seed: +v1.1.5 removes the global `set_app_passphrase` / `unlock_app` flow entirely; the only storage entry point is `configureAccountStorage`, which selects an account-scoped registry directory, creates or unlocks it with the passphrase, and clears active wallet and sync caches before switching namespaces. The bridge configures one namespace per Edge wallet, keyed by the wallet's alias `name` (already the `base16(walletId)` the tools layer passes), with a passphrase derived from that wallet's seed. + +The passphrase is derived on the **core side**, not in the bridge. Metro does not resolve Node's `crypto` module (the bridge redboxes `Unable to resolve module crypto`), while the accountbased webpack bundle aliases `crypto` to `crypto-browserify`. So the HMAC lives in `piratechainCrypto.ts`, imported only by the engine and tools (which run in the core webview), and the derived value is passed to the bridge as `registryPassphrase` on the wallet config: ```ts -// as landed, piratechainIo.ts +// as landed, piratechainCrypto.ts (core side) const PASSPHRASE_DOMAIN = 'edge-pirate-wallet-registry-v1' -const deriveNamespacePassphrase = (mnemonic: string): string => +export const derivePiratechainRegistryPassphrase = (mnemonic: string): string => createHmac('sha256', mnemonic).update(PASSPHRASE_DOMAIN).digest('hex') ``` -`selectNamespace(accountId, passphrase)` no-ops when the requested namespace is already active, so a syncing wallet's caches are not cleared by unrelated reads. Wallet-free reads (`isValidAddress`, the chain-tip probe in `getLatestNetworkHeight`) reuse the active namespace when one exists and otherwise fall back to a fixed throwaway probe namespace that never holds funds. All namespace switches and registry mutations run under the existing `ensureWalletLock` serialization. +`selectNamespace(accountId, passphrase)` no-ops when the requested namespace is already active, so a syncing wallet's caches are not cleared by unrelated reads. It configures the storage, then sets the transport (`set_tunnel` Direct), and only then marks the namespace active: if `set_tunnel` throws, `activeAccountId` stays unchanged so a retry reconfigures fully instead of early-returning onto the SDK's unreliable default Tor transport. Wallet-free reads (`isValidAddress`, the chain-tip probe in `getLatestNetworkHeight`) reuse the active namespace when one exists and otherwise fall back to a fixed throwaway probe namespace that never holds funds. All namespace switches and registry mutations run under the existing `ensureWalletLock` serialization. ### Amounts as strings @@ -116,8 +118,10 @@ The vendored `react-native-pirate-wallet` sibling is re-extracted from the v1.1. ## 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: build the GUI for the iOS simulator with `piratechain: true` (no corePlugins disable) against the v1.1.5 native binaries, open an ARRR wallet, and confirm the app does not crash while the wallet syncs. This is the primary acceptance signal. -3. Send: from a funded ARRR wallet, send a small amount to a second address and reach the transaction-success scene, confirming the string-amount send path and the SDK `send()` call end to end. +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. + +Sync note: on a clean baked build the shielded sync completes fast on the sim (roughly 90 seconds from wallet birthday to `SYNCED`, `localHeight == targetHeight`, at roughly 8000 blocks/sec), and `getSpendabilityStatus` then reports `spendable: true` / `reason_code: OK`. The earlier "sync stuck at 0%" observation did not reproduce; it was an artifact of a broken build where the reconciled engine was not correctly loaded, not a native scan stall. ## 8. Phase history @@ -137,6 +141,14 @@ Diverged: the fork carried a shared registry unlocked by a hardcoded app passphr 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 needs an account-derived secret plumbed to the native IO and is out of scope here ([decision 1](#decision-1-per-wallet-registry-namespaces)). +### Phase 3: e2e send verification +Two things landed while verifying on device: +- 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). Moved the `activeAccountId` assignment to after `set_tunnel` ([section 5](#registry-storage-and-namespaces)). + +Observed (not fixed, [decision 1](#decision-1-per-wallet-registry-namespaces) reopen trigger): with more than one ARRR wallet, the SDK's single active namespace means only the last-selected wallet syncs and stays spendable; the others' background pollers read the wrong namespace. The single-wallet send path is unaffected (the send succeeded), but concurrent multi-wallet sync is the "measured problem" decision 1 anticipated. A fix would re-select the wallet's namespace per SDK operation, or instantiate one SDK context per wallet. + ## 9. Decisions ### Decision 1: per-wallet registry namespaces @@ -151,11 +163,11 @@ Evidence: v1.1.5 fixed the camelization bug (merged from [PR #19](https://github 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: derive the passphrase with an HMAC over the seed -Chosen: `createHmac('sha256', mnemonic).update(domain).digest('hex')` from Node `crypto` (shimmed by the `crypto-browserify` dependency in the RN bundle). -Evidence: `crypto-browserify` is a direct dependency and `@types/node` types the import, so it type-checks and bundles. HMAC over the seed yields a stable, high-entropy, per-wallet value without ever using the raw mnemonic as the passphrase. -Rejected: `create-hmac` directly (present transitively but untyped, would introduce `any`); using the raw mnemonic (exposes spending material as the storage key). -Reopen if: the RN bundle stops shimming `crypto`, in which case switch to a typed hashing dependency. +### Decision 3: derive the passphrase with an HMAC over the seed, on the core side +Chosen: `createHmac('sha256', mnemonic).update(domain).digest('hex')` in `piratechainCrypto.ts`, imported by the engine and tools (core webview context) and passed to the bridge as `registryPassphrase`. +Evidence: HMAC over the seed yields a stable, high-entropy, per-wallet value without ever using the raw mnemonic as the passphrase. The derivation cannot live in the bridge: Metro does not resolve Node's `crypto` (the bridge redboxes `Unable to resolve module crypto`), whereas the accountbased webpack bundle aliases `crypto` to `crypto-browserify` and `@types/node` types the import, so it type-checks and bundles core-side. This was found on the sim and moved before landing. +Rejected: deriving in the bridge with `crypto` (fails to resolve under Metro); `create-hmac` directly (present transitively but untyped, would introduce `any`); using the raw mnemonic (exposes spending material as the storage key). +Reopen if: the core bundle stops shimming `crypto`, in which case switch to a typed hashing dependency. ## 10. References - Asana task 1216926437132721 and its recorded Pirate Chain team thread. From 934c90e68bd37964cbd2176e9e8a08b0796a2f9f Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 3 Aug 2026 17:20:40 -0700 Subject: [PATCH 5/8] Hold every Pirate Chain wallet in one device-scoped registry The SDK's configureAccountStorage selects the wallet registry globally: one namespace is active at a time, and switching cancels any running sync and clears the registry and block caches. Per-wallet namespaces therefore left only the last-selected ARRR wallet syncing, with the others' pollers reading the wrong namespace, and gave up the shared block cache. Configure a single device-scoped registry once, key every wallet by alias inside it, and let each wallet's synchronizer run concurrently. Its passphrase is a random per-device secret minted from io.random and kept in the plugin's local storage, replacing the seed-derived HMAC, which cannot key a registry that holds many wallets. Wallet-free reads use the device registry, so the throwaway probe namespace is gone. Also expose the synchronizer status from the bridge and read it once after the engine subscribes, so a SYNCED that fires before subscription cannot strand the engine at STOPPED. --- CHANGELOG.md | 2 +- src/piratechain/PiratechainEngine.ts | 30 ++-- src/piratechain/PiratechainTools.ts | 30 +++- src/piratechain/piratechainCrypto.ts | 17 --- src/piratechain/piratechainDeviceStorage.ts | 68 +++++++++ src/piratechain/piratechainIo.ts | 144 ++++++++++++-------- src/piratechain/rnPirateWallet.d.ts | 7 +- 7 files changed, 208 insertions(+), 90 deletions(-) delete mode 100644 src/piratechain/piratechainCrypto.ts create mode 100644 src/piratechain/piratechainDeviceStorage.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ea1d47e1..fa416ba52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- changed: (ARRR) Reimplement the Pirate Chain plugin over the unified `react-native-pirate-wallet` SDK, replacing `react-native-piratechain`. Isolate each wallet in its own passphrase-scoped registry and encode amounts as strings for full precision. +- 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. ## 4.87.0 (2026-08-02) diff --git a/src/piratechain/PiratechainEngine.ts b/src/piratechain/PiratechainEngine.ts index 73f3d4dc9..c25af2f70 100644 --- a/src/piratechain/PiratechainEngine.ts +++ b/src/piratechain/PiratechainEngine.ts @@ -17,7 +17,6 @@ import { base16, base64 } from 'rfc4648' import { CurrencyEngine } from '../common/CurrencyEngine' import { PluginEnvironment } from '../common/innerPlugin' import { cleanTxLogs } from '../common/utils' -import { derivePiratechainRegistryPassphrase } from './piratechainCrypto' import type { PiratechainIo, PiratechainSynchronizer } from './piratechainIo' import { makePiratechainSyncTracker, @@ -92,7 +91,8 @@ export class PiratechainEngine extends CurrencyEngine< 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({ @@ -102,14 +102,30 @@ 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', payload => { + synchronizer.on('error', payload => { // The polling synchronizer retries transient errors on its own: this.log.warn(`Synchronizer error: ${payload.message}`) }) + + // 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 { @@ -218,14 +234,12 @@ export class PiratechainEngine extends CurrencyEngine< 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({ name: base16.stringify(base64.parse(this.walletId)), mnemonic: piratechainPrivateKeys.mnemonic, - birthdayHeight: piratechainPrivateKeys.birthdayHeight, - registryPassphrase: derivePiratechainRegistryPassphrase( - piratechainPrivateKeys.mnemonic - ) + birthdayHeight: piratechainPrivateKeys.birthdayHeight }) this.synchronizer = await this.synchronizerPromise // People might be waiting on the old promise, so resolve that diff --git a/src/piratechain/PiratechainTools.ts b/src/piratechain/PiratechainTools.ts index 403d1d90d..fd6fb7ae4 100644 --- a/src/piratechain/PiratechainTools.ts +++ b/src/piratechain/PiratechainTools.ts @@ -18,7 +18,7 @@ import { PluginEnvironment } from '../common/innerPlugin' import { asIntegerString } from '../common/types' import { encodeUriCommon, parseUriCommon } from '../common/uriHelpers' import { getLegacyDenomination, mergeDeeply } from '../common/utils' -import { derivePiratechainRegistryPassphrase } from './piratechainCrypto' +import { getPiratechainDevicePassphrase } from './piratechainDeviceStorage' import type { PiratechainIo } from './piratechainIo' import { asArrrPublicKey, @@ -34,6 +34,7 @@ export class PiratechainTools implements EdgeCurrencyTools { io: EdgeIo networkInfo: PiratechainNetworkInfo piratechainIo: PiratechainIo + devicePassphrasePromise?: Promise constructor(env: PluginEnvironment) { const { builtinTokens, currencyInfo, io, networkInfo } = env @@ -52,6 +53,27 @@ export class PiratechainTools implements EdgeCurrencyTools { 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( privateWalletInfo: EdgeWalletInfo ): Promise { @@ -66,10 +88,12 @@ export class PiratechainTools implements EdgeCurrencyTools { } async getNewWalletBirthdayBlockheight(): Promise { + await this.ensureDevicePassphrase() return await this.piratechainIo.getLatestNetworkHeight() } async isValidAddress(address: string): Promise { + await this.ensureDevicePassphrase() return await this.piratechainIo.isValidAddress(address) } @@ -144,11 +168,11 @@ export class PiratechainTools implements EdgeCurrencyTools { // 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, - birthdayHeight: piratechainPrivateKeys.birthdayHeight, - registryPassphrase: derivePiratechainRegistryPassphrase(mnemonic) + birthdayHeight: piratechainPrivateKeys.birthdayHeight }) return { birthdayHeight: piratechainPrivateKeys.birthdayHeight, diff --git a/src/piratechain/piratechainCrypto.ts b/src/piratechain/piratechainCrypto.ts deleted file mode 100644 index 1a67b4ab7..000000000 --- a/src/piratechain/piratechainCrypto.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { createHmac } from 'crypto' - -/** - * Derives the per-wallet registry passphrase from the wallet's seed. The SDK - * requires a unique, high-entropy, secret-derived passphrase per local wallet - * (never a shared or hardcoded one), so HMAC the seed rather than passing the - * raw mnemonic as the passphrase. - * - * This runs on the core (webpack) side, where `crypto` is polyfilled by - * `crypto-browserify`. It must NOT be imported by the native IO bridge, which - * is bundled by Metro where `crypto` does not resolve; the bridge receives the - * already-derived passphrase through its wallet config instead. - */ -const PASSPHRASE_DOMAIN = 'edge-pirate-wallet-registry-v1' - -export const derivePiratechainRegistryPassphrase = (mnemonic: string): string => - createHmac('sha256', mnemonic).update(PASSPHRASE_DOMAIN).digest('hex') 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/piratechainIo.ts b/src/piratechain/piratechainIo.ts index f44fddf36..09a4e5a55 100644 --- a/src/piratechain/piratechainIo.ts +++ b/src/piratechain/piratechainIo.ts @@ -47,18 +47,13 @@ export interface PiratechainWalletConfig { birthdayHeight: number mnemonic: string name: string - /** - * The registry passphrase, derived from the wallet seed on the core side - * (see piratechainCrypto). The bridge cannot derive it because Metro does - * not resolve `crypto`. - */ - registryPassphrase: string } export interface PiratechainSynchronizer { on: Subscriber getBalance: () => Promise getCurrentAddress: () => Promise + getStatus: () => Promise getTransactions: () => Promise rescan: (fromHeight?: number) => Promise send: (outputs: PiratechainSpendOutput[], fee?: string) => Promise @@ -72,21 +67,22 @@ export interface PiratechainIo { makeSynchronizer: ( config: PiratechainWalletConfig ) => Promise + setDevicePassphrase: (passphrase: string) => Promise } /** - * The SDK isolates each local wallet in its own encrypted registry namespace, - * selected by `configureAccountStorage` before any wallet call. The registry - * passphrase must be unique per local wallet and derived from high-entropy - * secret material rather than shared or hardcoded; the core side derives it - * from the wallet seed (see piratechainCrypto) and passes it in the config. + * `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. * - * A separate throwaway namespace serves wallet-free reads (address validation, - * chain-tip probe). It never holds funds or spending keys, so a fixed - * passphrase is safe here. + * 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 PROBE_ACCOUNT_ID = 'edge-arrr-probe' -const PROBE_PASSPHRASE = 'edge-arrr-probe-namespace-v1' +const DEVICE_ACCOUNT_ID = 'edge-pirate-device' const asInvokeEnvelope = asObject({ ok: asBoolean, @@ -118,49 +114,57 @@ export function makePiratechainIo(): PiratechainIo { return envelope.result } - // Selecting a namespace clears the SDK's active wallet and sync caches, so - // track the active one and only switch when it actually changes: - let activeAccountId: string | undefined - const selectNamespace = async ( - accountId: string, - passphrase: string - ): Promise => { - if (activeAccountId === accountId) return - await getSdk().configureAccountStorage({ accountId, passphrase }) - // The default transport tunnels through Tor, which doesn't reliably - // bootstrap inside Edge, and a namespace switch clears transport state. - // Reconnect directly, like every other plugin. Mark the namespace active - // only after the tunnel is set: if set_tunnel throws, activeAccountId - // stays unchanged so a retry reconfigures fully instead of early-returning - // onto the unreliable default Tor transport. - await invokeCall('set_tunnel', { mode: 'Direct' }) - activeAccountId = accountId - } + // Supplied by the core side, which reads it from local storage: + let devicePassphrase: string | undefined /** - * Ensures some namespace is active for a wallet-free read. Reuses the - * currently-selected wallet namespace when one is active so validating an - * address never clears a syncing wallet's caches. + * Configures the one device namespace, at most once. Every call that touches + * storage awaits this first. */ - const ensureAnyNamespace = async (): Promise => { - if (activeAccountId != null) return - await selectNamespace(PROBE_ACCOUNT_ID, PROBE_PASSPHRASE) + 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 inside its - * own namespace, restoring it from the mnemonic if this device hasn't seen - * it yet. Calls are serialized so a namespace switch never interleaves with - * another wallet's registry mutation. + * 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 ensureWalletLock: Promise = Promise.resolve() + let registryLock: Promise = Promise.resolve() const ensureWallet = async ( config: PiratechainWalletConfig ): Promise => { - const task = ensureWalletLock.then(async () => { - const { birthdayHeight, mnemonic, name, registryPassphrase } = config + const task = registryLock.then(async () => { + const { birthdayHeight, mnemonic, name } = config const walletSdk = getSdk() - await selectNamespace(name, registryPassphrase) + await ensureDeviceStorage() const registryExists = await walletSdk.walletRegistryExists() if (registryExists) { const wallets = await walletSdk.listWallets() @@ -169,25 +173,46 @@ export function makePiratechainIo(): PiratechainIo { } return await walletSdk.restoreWallet({ name, mnemonic, birthdayHeight }) }) - ensureWalletLock = task.catch(() => undefined) + registryLock = task.catch(() => undefined) return await task } return bridgifyObject({ + 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, but `create_wallet` - // with no birthday resolves one from the lightwalletd tip (falling back - // to the SDK's static checkpoint), so probe with a throwaway wallet in - // the throwaway namespace. Shares the serialization lock (see - // ensureWallet) so it never switches namespaces mid-mutation: - const task = ensureWalletLock.then(async () => { + // 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 selectNamespace(PROBE_ACCOUNT_ID, PROBE_PASSPHRASE) + 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' }) @@ -201,12 +226,12 @@ export function makePiratechainIo(): PiratechainIo { await walletSdk.deleteWallet(probeWalletId).catch(() => undefined) } }) - ensureWalletLock = task.catch(() => undefined) + registryLock = task.catch(() => undefined) return await task }, async isValidAddress(address) { - await ensureAnyNamespace() + await ensureDeviceStorage() const result = await getSdk().validateAddress(address) return result.isValid }, @@ -251,6 +276,9 @@ export function makePiratechainIo(): PiratechainIo { getCurrentAddress: async () => { return await walletSdk.getCurrentReceiveAddress(walletId) }, + getStatus: async () => { + return realSynchronizer.status + }, getTransactions: async () => { return realSynchronizer.transactions }, diff --git a/src/piratechain/rnPirateWallet.d.ts b/src/piratechain/rnPirateWallet.d.ts index 6d2a562ad..de1b6411f 100644 --- a/src/piratechain/rnPirateWallet.d.ts +++ b/src/piratechain/rnPirateWallet.d.ts @@ -94,9 +94,10 @@ declare module 'react-native-pirate-wallet' { } /** - * Per-account storage isolation. Configures a registry with its own path - * and passphrase so each local Edge account gets an isolated wallet - * registry instead of sharing a single device-wide one. + * 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 From 76bb601ae6949bdcf60ddde8d1f810b03a6321cd Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 3 Aug 2026 17:20:45 -0700 Subject: [PATCH 6/8] Update the Piratechain design doc for the device-scoped registry Rewrite the storage section and decisions 1 and 3 to the shipped model, and record the phase-4 divergence from the per-wallet namespaces phase 2 built. --- src/docs/piratechain-sdk-v115-reconcile.md | 107 +++++++++++++++------ 1 file changed, 78 insertions(+), 29 deletions(-) diff --git a/src/docs/piratechain-sdk-v115-reconcile.md b/src/docs/piratechain-sdk-v115-reconcile.md index 233e84262..d1fce3209 100644 --- a/src/docs/piratechain-sdk-v115-reconcile.md +++ b/src/docs/piratechain-sdk-v115-reconcile.md @@ -5,7 +5,7 @@ | Status | Implemented and verified (iOS sim, e2e send broadcast) | | Author | Jon Tzeng | | Reviewer | peachbits | -| Last updated | 2026-07-29 | +| Last updated | 2026-08-04 | | 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 (vendored) | | 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 | - | @@ -41,12 +41,14 @@ The Pirate Chain team replaced that module with a unified SDK whose React Native 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), per-wallet registry passphrase (security), and honest native-error typing. +- 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: -- Plumbing an Edge-account-derived secret into the plugin's native IO for a single per-Edge-account registry. The bridge only receives per-wallet secret material, so this design scopes the registry per wallet instead ([decision 1](#decision-1-per-wallet-registry-namespaces)). -- Publishing `react-native-pirate-wallet` to npm. It stays a vendored `file:` dependency, unchanged from the prior phase. +- 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` to npm. It stays a vendored `file:` dependency, unchanged from the prior phase; the Pirate team's npm publish is the trigger to revisit. +- 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 @@ -61,6 +63,7 @@ The plugin's native IO bridge (`piratechainIo.ts`) runs on the React Native side ```mermaid sequenceDiagram box edge-core-js plugin context + participant Tools as PiratechainTools participant Engine as PiratechainEngine end box React Native side @@ -70,9 +73,10 @@ sequenceDiagram 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: name, passphrase: HMAC(mnemonic) }) - SDK->>Rust: configure_wallet_storage (open/create namespace) + 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) @@ -82,22 +86,50 @@ sequenceDiagram ## 5. Detailed design: edge-currency-accountbased -Three files change; the plugin's public shape and the engine's transaction mapping are unchanged. +The plugin's public shape and the engine's transaction mapping are unchanged. -### Registry storage and namespaces +### Registry storage -v1.1.5 removes the global `set_app_passphrase` / `unlock_app` flow entirely; the only storage entry point is `configureAccountStorage`, which selects an account-scoped registry directory, creates or unlocks it with the passphrase, and clears active wallet and sync caches before switching namespaces. The bridge configures one namespace per Edge wallet, keyed by the wallet's alias `name` (already the `base16(walletId)` the tools layer passes), with a passphrase derived from that wallet's seed. +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. -The passphrase is derived on the **core side**, not in the bridge. Metro does not resolve Node's `crypto` module (the bridge redboxes `Unable to resolve module crypto`), while the accountbased webpack bundle aliases `crypto` to `crypto-browserify`. So the HMAC lives in `piratechainCrypto.ts`, imported only by the engine and tools (which run in the core webview), and the derived value is passed to the bridge as `registryPassphrase` on the wallet config: +`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, piratechainCrypto.ts (core side) -const PASSPHRASE_DOMAIN = 'edge-pirate-wallet-registry-v1' -export const derivePiratechainRegistryPassphrase = (mnemonic: string): string => - createHmac('sha256', mnemonic).update(PASSPHRASE_DOMAIN).digest('hex') +// 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 +} ``` -`selectNamespace(accountId, passphrase)` no-ops when the requested namespace is already active, so a syncing wallet's caches are not cleared by unrelated reads. It configures the storage, then sets the transport (`set_tunnel` Direct), and only then marks the namespace active: if `set_tunnel` throws, `activeAccountId` stays unchanged so a retry reconfigures fully instead of early-returning onto the SDK's unreliable default Tor transport. Wallet-free reads (`isValidAddress`, the chain-tip probe in `getLatestNetworkHeight`) reuse the active namespace when one exists and otherwise fall back to a fixed throwaway probe namespace that never holds funds. All namespace switches and registry mutations run under the existing `ensureWalletLock` serialization. +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 @@ -139,23 +171,40 @@ Diverged: the fork carried a shared registry unlocked by a hardcoded app passphr | `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 needs an account-derived secret plumbed to the native IO and is out of scope here ([decision 1](#decision-1-per-wallet-registry-namespaces)). +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 -Two things landed while verifying on device: +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). Moved the `activeAccountId` assignment to after `set_tunnel` ([section 5](#registry-storage-and-namespaces)). +- 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. -Observed (not fixed, [decision 1](#decision-1-per-wallet-registry-namespaces) reopen trigger): with more than one ARRR wallet, the SDK's single active namespace means only the last-selected wallet syncs and stays spendable; the others' background pollers read the wrong namespace. The single-wallet send path is unaffected (the send succeeded), but concurrent multi-wallet sync is the "measured problem" decision 1 anticipated. A fix would re-select the wallet's namespace per SDK operation, or instantiate one SDK context per wallet. +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. ## 9. Decisions -### Decision 1: per-wallet registry namespaces -Chosen: one `configureAccountStorage` namespace per Edge wallet, keyed by the wallet alias, passphrase derived from that wallet's seed. -Evidence: the native IO bridge is a single shared instance that receives only per-wallet config (`{ mnemonic, name, birthdayHeight }`); it has no Edge-account handle. v1.1.5's README requires a unique, high-entropy, secret-derived passphrase per local account and forbids hardcoded or public values. The wallet seed is the only secret material the bridge holds. -Rejected: a single shared namespace with one passphrase, which cannot be both unique-per-account and derived-from-secret without plumbing an account secret the bridge does not have, and which is exactly the hardcoded-passphrase pattern the reviewer flagged. Rejected: per-Edge-account namespaces, which would require changing how the plugin's native IO is instantiated to carry account secret material; deferred as a non-goal. -Reopen if: the plugin gains access to an Edge-account-derived secret, or concurrent multi-wallet sync (which forces namespace re-selection and cache clears on switch) becomes a measured problem. +### 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)`. @@ -163,11 +212,11 @@ Evidence: v1.1.5 fixed the camelization bug (merged from [PR #19](https://github 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: derive the passphrase with an HMAC over the seed, on the core side -Chosen: `createHmac('sha256', mnemonic).update(domain).digest('hex')` in `piratechainCrypto.ts`, imported by the engine and tools (core webview context) and passed to the bridge as `registryPassphrase`. -Evidence: HMAC over the seed yields a stable, high-entropy, per-wallet value without ever using the raw mnemonic as the passphrase. The derivation cannot live in the bridge: Metro does not resolve Node's `crypto` (the bridge redboxes `Unable to resolve module crypto`), whereas the accountbased webpack bundle aliases `crypto` to `crypto-browserify` and `@types/node` types the import, so it type-checks and bundles core-side. This was found on the sim and moved before landing. -Rejected: deriving in the bridge with `crypto` (fails to resolve under Metro); `create-hmac` directly (present transitively but untyped, would introduce `any`); using the raw mnemonic (exposes spending material as the storage key). -Reopen if: the core bundle stops shimming `crypto`, in which case switch to a typed hashing dependency. +### 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. From 022a27157d36fda5be4815eb5c59c267303a23ec Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Tue, 11 Aug 2026 19:46:39 -0700 Subject: [PATCH 7/8] Point Pirate Chain wallets at the plugin's own lightwalletd node The SDK bakes in a default lightwalletd and never consults the plugin's networkInfo, so every wallet scanned against that default. When that node stops serving blocks the failure is silent and total: test_node still succeeds and the chain tip still resolves, so the app shows 'Sync in Progress, 0% Complete' forever with no error, while the scan sits in the Headers stage at zero blocks per second. Pass the configured node down to makeSynchronizer and apply it with set_lightd_endpoint before the synchronizer starts. The configured port moves from 443 to the node's plain gRPC port, which is what the SDK speaks; test_node fails against https on 443 and succeeds on 9067. --- CHANGELOG.md | 1 + src/piratechain/PiratechainEngine.ts | 3 ++- src/piratechain/piratechainInfo.ts | 11 +++++++++++ src/piratechain/piratechainIo.ts | 21 +++++++++++++++++++++ src/piratechain/piratechainTypes.ts | 10 ++++++++++ 5 files changed, 45 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa416ba52..0bf0cf987 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 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) diff --git a/src/piratechain/PiratechainEngine.ts b/src/piratechain/PiratechainEngine.ts index c25af2f70..9a612bdba 100644 --- a/src/piratechain/PiratechainEngine.ts +++ b/src/piratechain/PiratechainEngine.ts @@ -239,7 +239,8 @@ export class PiratechainEngine extends CurrencyEngine< this.synchronizerPromise = this.makeSynchronizer({ name: base16.stringify(base64.parse(this.walletId)), mnemonic: piratechainPrivateKeys.mnemonic, - birthdayHeight: piratechainPrivateKeys.birthdayHeight + birthdayHeight: piratechainPrivateKeys.birthdayHeight, + lightwalletdUrl: this.networkInfo.lightwalletdUrl }) this.synchronizer = await this.synchronizerPromise // People might be waiting on the old promise, so resolve that diff --git a/src/piratechain/piratechainInfo.ts b/src/piratechain/piratechainInfo.ts index 68131ad36..7cecb406e 100644 --- a/src/piratechain/piratechainInfo.ts +++ b/src/piratechain/piratechainInfo.ts @@ -16,6 +16,17 @@ const networkInfo: PiratechainNetworkInfo = { defaultHost: 'lightd1.pirate.black', defaultPort: 443 }, + // 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' } diff --git a/src/piratechain/piratechainIo.ts b/src/piratechain/piratechainIo.ts index 09a4e5a55..54f236c90 100644 --- a/src/piratechain/piratechainIo.ts +++ b/src/piratechain/piratechainIo.ts @@ -45,6 +45,12 @@ export interface PiratechainSpendOutput { 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 } @@ -239,6 +245,21 @@ export function makePiratechainIo(): PiratechainIo { async 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 }) diff --git a/src/piratechain/piratechainTypes.ts b/src/piratechain/piratechainTypes.ts index 2a526789e..908b90051 100644 --- a/src/piratechain/piratechainTypes.ts +++ b/src/piratechain/piratechainTypes.ts @@ -21,6 +21,15 @@ export interface PiratechainNetworkInfo { 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 } @@ -76,6 +85,7 @@ export const asPiratechainPrivateKeys = ( // export const asPiratechainInfoPayload = asObject({ + lightwalletdUrl: asOptional(asString), rpcNode: asOptional( asObject({ networkName: asValue('mainnet', 'testnet'), From a58a7ed38b1a48de73461a96e59e72dfe4d18085 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Tue, 11 Aug 2026 20:25:46 -0700 Subject: [PATCH 8/8] Update the Piratechain design doc for the npm dependency and endpoint fix --- src/docs/piratechain-sdk-v115-reconcile.md | 36 ++++++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/src/docs/piratechain-sdk-v115-reconcile.md b/src/docs/piratechain-sdk-v115-reconcile.md index d1fce3209..43c25f3be 100644 --- a/src/docs/piratechain-sdk-v115-reconcile.md +++ b/src/docs/piratechain-sdk-v115-reconcile.md @@ -2,11 +2,11 @@ | | | |---|---| -| Status | Implemented and verified (iOS sim, e2e send broadcast) | +| Status | Implemented; sync verified on the iOS sim, broadcast blocked upstream ([section 7](#7-testing)) | | Author | Jon Tzeng | | Reviewer | peachbits | -| Last updated | 2026-08-04 | -| 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 (vendored) | +| 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 | @@ -19,7 +19,7 @@ Branch references point at `agent/1214721783909451` in both Edge repos. Directio 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 vendored SDK](#6-detailed-design-edge-react-gui-and-the-vendored-sdk) +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) @@ -47,7 +47,7 @@ Goals: 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` to npm. It stays a vendored `file:` dependency, unchanged from the prior phase; the Pirate team's npm publish is the trigger to revisit. +- 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 @@ -55,8 +55,8 @@ Non-goals: | 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) | Dependency version bump, keep plugin enabled ([section 6](#6-detailed-design-edge-react-gui-and-the-vendored-sdk)) | -| react-native-pirate-wallet | vendored `file:` sibling | Re-vendored to v1.1.5 0.2.0 ([section 6](#6-detailed-design-edge-react-gui-and-the-vendored-sdk)) | +| 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. @@ -143,9 +143,17 @@ The engine subscribes to the synchronizer after `start()`, so a `statusChanged` `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 vendored SDK +## 6. Detailed design: edge-react-gui and the SDK dependency -The vendored `react-native-pirate-wallet` sibling is re-extracted from the v1.1.5 release artifact (`pirate-unified-wallet-react-native-plugin-artifacts-v1.1.5.zip`), taking the 0.2.0 binding source plus the iOS xcframework (device and simulator slices) and Android jniLibs. The GUI dependency reference stays `file:../react-native-pirate-wallet`; only the resolved version in `yarn.lock` and `ios/Podfile.lock` moves from 0.1.1 to 0.2.0. `src/util/corePlugins.ts` keeps `piratechain: true`; no code change is needed there because the fix is native. The seam back to the plugin is the bridge in [section 5](#5-detailed-design-edge-currency-accountbased) and its diagram. +`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 @@ -153,7 +161,10 @@ The vendored `react-native-pirate-wallet` sibling is re-extracted from the v1.1. 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. -Sync note: on a clean baked build the shielded sync completes fast on the sim (roughly 90 seconds from wallet birthday to `SYNCED`, `localHeight == targetHeight`, at roughly 8000 blocks/sec), and `getSpendabilityStatus` then reports `spendable: true` / `reason_code: OK`. The earlier "sync stuck at 0%" observation did not reproduce; it was an artifact of a broken build where the reconciled engine was not correctly loaded, not a native scan stall. +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 @@ -198,6 +209,11 @@ Old per-wallet registries are abandoned rather than migrated: wallets re-restore 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