diff --git a/package.json b/package.json index 82e8c83d7..88694670c 100644 --- a/package.json +++ b/package.json @@ -141,7 +141,7 @@ "prettier": "^2.2.0", "process": "^0.11.10", "react-native-piratechain": "^0.3.2", - "react-native-zcash": "^0.3.2", + "react-native-zcash": "0.4.0", "rimraf": "^3.0.2", "stream-browserify": "^2.0.2", "stream-http": "^3.2.0", @@ -155,6 +155,6 @@ }, "peerDependencies": { "react-native-piratechain": "^0.3.2", - "react-native-zcash": "^0.3.2" + "react-native-zcash": "^0.4.0" } } diff --git a/src/index.ts b/src/index.ts index d47d1bf3a..17a603a4e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,13 +9,13 @@ import { fio } from './fio/fioInfo' import { hedera } from './hedera/hederaInfo' import { liberland } from './polkadot/info/liberlandInfo' import { liberlandtestnet } from './polkadot/info/liberlandTestnetInfo' +import { piratechain } from './piratechain/piratechainInfo' import { polkadot } from './polkadot/info/polkadotInfo' import { ripple } from './ripple/rippleInfo' import { solana } from './solana/solanaInfo' import { stellar } from './stellar/stellarInfo' import { tezos } from './tezos/tezosInfo' import { tron } from './tron/tronInfo' -import { piratechain } from './zcash/piratechainInfo' import { zcash } from './zcash/zcashInfo' const plugins = { diff --git a/src/piratechain/PiratechainEngine.ts b/src/piratechain/PiratechainEngine.ts new file mode 100644 index 000000000..098dad2b3 --- /dev/null +++ b/src/piratechain/PiratechainEngine.ts @@ -0,0 +1,449 @@ +import { abs, add, eq, gt, lte, sub } from 'biggystring' +import { + EdgeCurrencyEngine, + EdgeCurrencyEngineOptions, + EdgeEnginePrivateKeyOptions, + EdgeSpendInfo, + EdgeTransaction, + EdgeWalletInfo, + InsufficientFundsError, + JsonObject, + NoAmountSpecifiedError +} from 'edge-core-js/types' + +import { CurrencyEngine } from '../common/CurrencyEngine' +import { PluginEnvironment } from '../common/innerPlugin' +import { cleanTxLogs } from '../common/utils' +import { PiratechainTools } from './PiratechainTools' +import { + asPiratechainPrivateKeys, + asPiratechainWalletOtherData, + asSafePiratechainWalletInfo, + PiratechainInitializerConfig, + PiratechainNetworkInfo, + PiratechainSpendInfo, + PiratechainSynchronizer, + PiratechainSynchronizerStatus, + PiratechainTransaction, + PiratechainWalletOtherData, + SafePiratechainWalletInfo +} from './piratechainTypes' + +export class PiratechainEngine extends CurrencyEngine< + PiratechainTools, + SafePiratechainWalletInfo +> { + pluginId: string + networkInfo: PiratechainNetworkInfo + otherData!: PiratechainWalletOtherData + synchronizer!: PiratechainSynchronizer + synchronizerStatus!: PiratechainSynchronizerStatus + availableZatoshi!: string + initialNumBlocksToDownload!: number + initializer!: PiratechainInitializerConfig + alias!: string + progressRatio!: number + queryMutex: boolean + makeSynchronizer: ( + config: PiratechainInitializerConfig + ) => Promise + + constructor( + env: PluginEnvironment, + tools: PiratechainTools, + walletInfo: SafePiratechainWalletInfo, + opts: EdgeCurrencyEngineOptions, + makeSynchronizer: any + ) { + super(env, tools, walletInfo, opts) + const { networkInfo } = env + this.pluginId = this.currencyInfo.pluginId + this.networkInfo = networkInfo + this.makeSynchronizer = makeSynchronizer + this.queryMutex = false + } + + setOtherData(raw: any): void { + this.otherData = asPiratechainWalletOtherData(raw) + } + + initData(): void { + const { birthdayHeight, alias } = this.initializer + + // walletLocalData + if (this.otherData.blockRange.first === 0) { + this.otherData.blockRange = { + first: birthdayHeight, + last: birthdayHeight + } + } + + // Engine variables + this.alias = alias + this.initialNumBlocksToDownload = -1 + this.synchronizerStatus = 'DISCONNECTED' + this.availableZatoshi = '0' + this.progressRatio = 0 + } + + initSubscriptions(): void { + this.synchronizer.on('update', async payload => { + const { lastDownloadedHeight, scanProgress, networkBlockHeight } = payload + this.onUpdateBlockHeight(networkBlockHeight) + this.onUpdateProgress( + lastDownloadedHeight, + scanProgress, + networkBlockHeight + ) + await this.queryAll() + }) + this.synchronizer.on('statusChanged', async payload => { + this.synchronizerStatus = payload.name + await this.queryAll() + }) + } + + async queryAll(): Promise { + if (this.queryMutex) return + this.queryMutex = true + try { + await this.queryBalance() + await this.queryTransactions() + this.onUpdateTransactions() + } catch (e: any) {} + this.queryMutex = false + } + + onUpdateBlockHeight(networkBlockHeight: number): void { + if (this.walletLocalData.blockHeight !== networkBlockHeight) { + this.walletLocalData.blockHeight = networkBlockHeight + this.walletLocalDataDirty = true + this.currencyEngineCallbacks.onBlockHeightChanged( + this.walletLocalData.blockHeight + ) + } + } + + onUpdateTransactions(): void { + if (this.transactionsChangedArray.length > 0) { + this.currencyEngineCallbacks.onTransactionsChanged( + this.transactionsChangedArray + ) + this.transactionsChangedArray = [] + } + } + + onUpdateProgress( + lastDownloadedHeight: number, + scanProgress: number, + networkBlockHeight: number + ): void { + if (!this.addressesChecked && !this.isSynced()) { + // Sync status is split up between downloading blocks (40%), scanning blocks (49.5%), + // getting balance (0.5%), and querying transactions (10%). + this.tokenCheckBalanceStatus[this.currencyInfo.currencyCode] = + (scanProgress * 0.99) / 100 + + let downloadProgress = 0 + if (lastDownloadedHeight > 0) { + // Initial lastDownloadedHeight value is -1 + const currentNumBlocksToDownload = + networkBlockHeight - lastDownloadedHeight + if (this.initialNumBlocksToDownload < 0) { + this.initialNumBlocksToDownload = currentNumBlocksToDownload + } + + downloadProgress = + currentNumBlocksToDownload === 0 || + this.initialNumBlocksToDownload === 0 + ? 1 + : 1 - currentNumBlocksToDownload / this.initialNumBlocksToDownload + } + this.tokenCheckTransactionsStatus[this.currencyInfo.currencyCode] = + downloadProgress * 0.8 + + const percent = + (this.tokenCheckTransactionsStatus[this.currencyInfo.currencyCode] + + this.tokenCheckBalanceStatus[this.currencyInfo.currencyCode]) / + 2 + if (percent !== this.progressRatio) { + if (Math.abs(percent - this.progressRatio) > 0.1 || percent === 1) { + this.progressRatio = percent + this.updateOnAddressesChecked() + } + } + } + } + + async startEngine(): Promise { + this.initData() + this.synchronizer = await this.makeSynchronizer(this.initializer) + await this.synchronizer.start() + this.initSubscriptions() + await super.startEngine() + } + + isSynced(): boolean { + // Synchronizer status is updated regularly and should be checked before accessing the db to avoid errors + return this.synchronizerStatus === 'SYNCED' + } + + async queryBalance(): Promise { + if (!this.isSynced()) return + try { + const balances = await this.synchronizer.getShieldedBalance() + if (balances.totalZatoshi === '-1') return + this.availableZatoshi = balances.availableZatoshi + this.updateBalance(this.currencyInfo.currencyCode, balances.totalZatoshi) + } catch (e: any) { + this.warn('Failed to update balances', e) + this.updateBalance(this.currencyInfo.currencyCode, '0') + } + } + + async queryTransactions(): Promise { + try { + let first = this.otherData.blockRange.first + let last = this.otherData.blockRange.last + while (this.isSynced() && last <= this.walletLocalData.blockHeight) { + const transactions = await this.synchronizer.getTransactions({ + first, + last + }) + + transactions.forEach(tx => this.processTransaction(tx)) + + if (last === this.walletLocalData.blockHeight) { + first = this.walletLocalData.blockHeight + this.walletLocalDataDirty = true + this.tokenCheckTransactionsStatus[this.currencyInfo.currencyCode] = 1 + this.updateOnAddressesChecked() + 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 + } + } catch (e: any) { + this.error( + `Error querying ${this.currencyInfo.currencyCode} transactions `, + e + ) + } + } + + processTransaction(tx: PiratechainTransaction): void { + let netNativeAmount = tx.value + const ourReceiveAddresses = [] + if (tx.toAddress != null) { + // check if tx is a spend + netNativeAmount = `-${add( + netNativeAmount, + this.networkInfo.defaultNetworkFee + )}` + } else { + ourReceiveAddresses.push(this.walletInfo.keys.publicKey) + } + + const edgeTransaction: EdgeTransaction = { + txid: tx.rawTransactionId, + date: tx.blockTimeInSeconds, + currencyCode: this.currencyInfo.currencyCode, + blockHeight: tx.minedHeight, + nativeAmount: netNativeAmount, + isSend: netNativeAmount.startsWith('-'), + networkFee: this.networkInfo.defaultNetworkFee, + ourReceiveAddresses, // blank if you sent money otherwise array of addresses that are yours in this transaction + signedTx: '', + otherParams: {}, + walletId: this.walletId + } + this.addTransaction(this.currencyInfo.currencyCode, edgeTransaction) + } + + async killEngine(): Promise { + await this.synchronizer.stop() + await super.killEngine() + } + + async clearBlockchainCache(): Promise { + await super.clearBlockchainCache() + } + + async resyncBlockchain(): Promise { + // Don't bother stopping and restarting the synchronizer for a resync + await super.killEngine() + await this.clearBlockchainCache() + await this.startEngine() + this.synchronizer + .rescan(this.walletInfo.keys.birthdayHeight) + .catch((e: any) => this.warn('resyncBlockchain failed: ', e)) + } + + async getMaxSpendable(): Promise { + const spendableBalance = sub( + this.availableZatoshi, + this.networkInfo.defaultNetworkFee + ) + if (lte(spendableBalance, '0')) throw new InsufficientFundsError() + + return spendableBalance + } + + async makeSpend(edgeSpendInfoIn: EdgeSpendInfo): Promise { + if (!this.isSynced()) throw new Error('Cannot spend until wallet is synced') + const { edgeSpendInfo, currencyCode } = this.makeSpendCheck(edgeSpendInfoIn) + const spendTarget = edgeSpendInfo.spendTargets[0] + const { publicAddress, nativeAmount } = spendTarget + + if (publicAddress == null) + throw new Error('makeSpend Missing publicAddress') + if (nativeAmount == null) throw new NoAmountSpecifiedError() + + if (eq(nativeAmount, '0')) throw new NoAmountSpecifiedError() + + const totalTxAmount = add(nativeAmount, this.networkInfo.defaultNetworkFee) + + if ( + gt( + totalTxAmount, + this.walletLocalData.totalBalances[this.currencyInfo.currencyCode] ?? + '0' + ) + ) { + throw new InsufficientFundsError() + } + + if (gt(totalTxAmount, this.availableZatoshi)) { + throw new InsufficientFundsError('Amount exceeds available balance') + } + + // ********************************** + // Create the unsigned EdgeTransaction + + const spendTargets = edgeSpendInfo.spendTargets.map(si => ({ + uniqueIdentifier: si.uniqueIdentifier, + memo: si.memo, + nativeAmount: si.nativeAmount ?? '0', + currencyCode, + publicAddress + })) + + const edgeTransaction: EdgeTransaction = { + txid: '', // txid + date: 0, // date + currencyCode, // currencyCode + blockHeight: 0, // blockHeight + nativeAmount: `-${totalTxAmount}`, // nativeAmount + isSend: nativeAmount.startsWith('-'), + networkFee: this.networkInfo.defaultNetworkFee, // networkFee + ourReceiveAddresses: [], // ourReceiveAddresses + signedTx: '', // signedTx + spendTargets, + walletId: this.walletId + } + + return edgeTransaction + } + + async signTx(edgeTransaction: EdgeTransaction): Promise { + // Transaction is signed and broadcast at the same time + return edgeTransaction + } + + async broadcastTx( + edgeTransaction: EdgeTransaction, + opts?: EdgeEnginePrivateKeyOptions + ): Promise { + const piratechainPrivateKeys = asPiratechainPrivateKeys(this.pluginId)( + opts?.privateKeys + ) + if ( + edgeTransaction.spendTargets == null || + edgeTransaction.spendTargets.length !== 1 + ) + throw new Error('Invalid spend targets') + + const spendTarget = edgeTransaction.spendTargets[0] + const txParams: PiratechainSpendInfo = { + zatoshi: sub( + abs(edgeTransaction.nativeAmount), + edgeTransaction.networkFee + ), + toAddress: spendTarget.publicAddress, + memo: spendTarget.memo ?? spendTarget.uniqueIdentifier ?? '', + fromAccountIndex: 0, + spendingKey: piratechainPrivateKeys.spendKey + } + + try { + const signedTx = await this.synchronizer.sendToAddress(txParams) + edgeTransaction.txid = signedTx.txId + edgeTransaction.signedTx = signedTx.raw + edgeTransaction.date = Date.now() / 1000 + this.warn(`SUCCESS broadcastTx\n${cleanTxLogs(edgeTransaction)}`) + } catch (e: any) { + this.warn('FAILURE broadcastTx failed: ', e) + throw e + } + return edgeTransaction + } + + getDisplayPrivateSeed(privateKeys: JsonObject): string { + const piratechainPrivateKeys = asPiratechainPrivateKeys(this.pluginId)( + privateKeys + ) + return piratechainPrivateKeys.mnemonic + } + + getDisplayPublicSeed(): string { + return this.walletInfo.keys.unifiedViewingKeys?.extfvk ?? '' + } + + async loadEngine(): Promise { + const { walletInfo } = this + await super.loadEngine() + this.engineOn = true + + const { rpcNode } = this.networkInfo + this.initializer = { + fullViewingKey: walletInfo.keys.unifiedViewingKeys, + birthdayHeight: walletInfo.keys.birthdayHeight, + alias: walletInfo.keys.publicKey, + ...rpcNode + } + } +} +export async function makeCurrencyEngine( + env: PluginEnvironment, + tools: PiratechainTools, + walletInfo: EdgeWalletInfo, + opts: EdgeCurrencyEngineOptions +): Promise { + const safeWalletInfo = asSafePiratechainWalletInfo(walletInfo) + const { makeSynchronizer } = + env.nativeIo['edge-currency-accountbased'].piratechain + + const engine = new PiratechainEngine( + env, + tools, + safeWalletInfo, + opts, + makeSynchronizer + ) + + // Do any async initialization necessary for the engine + await engine.loadEngine() + + return engine +} diff --git a/src/piratechain/PiratechainTools.ts b/src/piratechain/PiratechainTools.ts new file mode 100644 index 000000000..daa818261 --- /dev/null +++ b/src/piratechain/PiratechainTools.ts @@ -0,0 +1,226 @@ +import { div } from 'biggystring' +import { entropyToMnemonic, mnemonicToSeed, validateMnemonic } from 'bip39' +import { Buffer } from 'buffer' +import { + EdgeCurrencyInfo, + EdgeCurrencyTools, + EdgeEncodeUri, + EdgeIo, + EdgeMetaToken, + EdgeParsedUri, + EdgeTokenMap, + EdgeWalletInfo, + JsonObject +} from 'edge-core-js/types' +import { + AddressTool as AddressToolType, + KeyTool as KeyToolType +} from 'react-native-piratechain' + +import { PluginEnvironment } from '../common/innerPlugin' +import { asIntegerString } from '../common/types' +import { encodeUriCommon, parseUriCommon } from '../common/uriHelpers' +import { getLegacyDenomination } from '../common/utils' +import { + asArrrPublicKey, + asPiratechainPrivateKeys, + PiratechainNetworkInfo, + UnifiedViewingKey +} from './piratechainTypes' + +export class PiratechainTools implements EdgeCurrencyTools { + builtinTokens: EdgeTokenMap + currencyInfo: EdgeCurrencyInfo + io: EdgeIo + networkInfo: PiratechainNetworkInfo + + KeyTool: typeof KeyToolType + AddressTool: typeof AddressToolType + + constructor(env: PluginEnvironment) { + const { builtinTokens, currencyInfo, io, networkInfo } = env + this.builtinTokens = builtinTokens + this.currencyInfo = currencyInfo + this.io = io + this.networkInfo = networkInfo + + const RNAccountbased = env.nativeIo['edge-currency-accountbased'] + if (RNAccountbased == null) { + throw new Error('Need opts') + } + const { KeyTool, AddressTool } = RNAccountbased.piratechain + + this.KeyTool = KeyTool + this.AddressTool = AddressTool + } + + async getNewWalletBirthdayBlockheight(): Promise { + try { + return await this.KeyTool.getBirthdayHeight( + this.networkInfo.rpcNode.defaultHost, + this.networkInfo.rpcNode.defaultPort + ) + } catch (e: any) { + return this.networkInfo.defaultBirthday + } + } + + async isValidAddress(address: string): Promise { + return ( + (await this.AddressTool.isValidShieldedAddress(address)) || + (await this.AddressTool.isValidTransparentAddress(address)) + ) + } + + // will actually use MNEMONIC version of private key + async importPrivateKey( + userInput: string, + opts: JsonObject = {} + ): Promise { + const { pluginId } = this.currencyInfo + const isValid = validateMnemonic(userInput) + if (!isValid) + throw new Error(`Invalid ${this.currencyInfo.currencyCode} mnemonic`) + const hexBuffer = await mnemonicToSeed(userInput) + const hex = hexBuffer.toString('hex') + const spendKey = await this.KeyTool.deriveSpendingKey( + hex, + this.networkInfo.rpcNode.networkName + ) + if (typeof spendKey !== 'string') throw new Error('Invalid spendKey type') + + // Get current network height for the birthday height + const currentNetworkHeight = await this.getNewWalletBirthdayBlockheight() + + let height = currentNetworkHeight + + const { birthdayHeight } = opts + if (birthdayHeight != null) { + asIntegerString(birthdayHeight) + + const birthdayHeightInt = parseInt(birthdayHeight) + + if (birthdayHeightInt > currentNetworkHeight) { + throw new Error('InvalidBirthdayHeight') // must be less than current block height (assuming query was successful) + } + height = birthdayHeightInt + } + + return { + [`${pluginId}Mnemonic`]: userInput, + [`${pluginId}SpendKey`]: spendKey, + [`${pluginId}BirthdayHeight`]: height + } + } + + async createPrivateKey(walletType: string): Promise { + if (walletType !== this.currencyInfo.walletType) { + throw new Error('InvalidWalletType') + } + + const entropy = Buffer.from(this.io.random(32)).toString('hex') + const mnemonic = entropyToMnemonic(entropy) + return await this.importPrivateKey(mnemonic) + } + + async checkPublicKey(publicKey: JsonObject): Promise { + try { + asArrrPublicKey(publicKey) + return true + } catch (err) { + return false + } + } + + async derivePublicKey(walletInfo: EdgeWalletInfo): Promise { + const { pluginId } = this.currencyInfo + const piratechainPrivateKeys = asPiratechainPrivateKeys(pluginId)( + walletInfo.keys + ) + if (walletInfo.type !== this.currencyInfo.walletType) { + throw new Error('InvalidWalletType') + } + + const mnemonic = piratechainPrivateKeys.mnemonic + if (typeof mnemonic !== 'string') { + throw new Error('InvalidMnemonic') + } + const hexBuffer = await mnemonicToSeed(mnemonic) + const hex = hexBuffer.toString('hex') + const unifiedViewingKeys: UnifiedViewingKey = + await this.KeyTool.deriveViewingKey( + hex, + this.networkInfo.rpcNode.networkName + ) + const shieldedAddress = await this.AddressTool.deriveShieldedAddress( + unifiedViewingKeys.extfvk, + this.networkInfo.rpcNode.networkName + ) + return { + birthdayHeight: piratechainPrivateKeys.birthdayHeight, + publicKey: shieldedAddress, + unifiedViewingKeys + } + } + + async parseUri( + uri: string, + currencyCode?: string, + customTokens?: EdgeMetaToken[] + ): Promise { + const { pluginId } = this.currencyInfo + const networks = { [pluginId]: true } + + const { + edgeParsedUri, + edgeParsedUri: { publicAddress } + } = parseUriCommon( + this.currencyInfo, + uri, + networks, + currencyCode ?? this.currencyInfo.currencyCode, + customTokens + ) + + if (publicAddress == null || !(await this.isValidAddress(publicAddress))) { + throw new Error('InvalidPublicAddressError') + } + + return edgeParsedUri + } + + async encodeUri( + obj: EdgeEncodeUri, + customTokens: EdgeMetaToken[] = [] + ): Promise { + const { pluginId } = this.currencyInfo + const { nativeAmount, currencyCode, publicAddress } = obj + + if (!(await this.isValidAddress(publicAddress))) { + throw new Error('InvalidPublicAddressError') + } + + let amount + if (nativeAmount != null) { + const denom = getLegacyDenomination( + currencyCode ?? this.currencyInfo.currencyCode, + this.currencyInfo, + customTokens + ) + if (denom == null) { + throw new Error('InternalErrorInvalidCurrencyCode') + } + amount = div(nativeAmount, denom.multiplier, 18) + } + const encodedUri = encodeUriCommon(obj, `${pluginId}`, amount) + return encodedUri + } +} + +export async function makeCurrencyTools( + env: PluginEnvironment +): Promise { + return new PiratechainTools(env) +} + +export { makeCurrencyEngine } from './PiratechainEngine' diff --git a/src/zcash/piratechainInfo.ts b/src/piratechain/piratechainInfo.ts similarity index 76% rename from src/zcash/piratechainInfo.ts rename to src/piratechain/piratechainInfo.ts index e3c47a88b..83690e6d3 100644 --- a/src/zcash/piratechainInfo.ts +++ b/src/piratechain/piratechainInfo.ts @@ -3,10 +3,10 @@ import { EdgeCurrencyInfo } from 'edge-core-js/types' import { makeOuterPlugin } from '../common/innerPlugin' -import { ZcashTools } from './ZcashTools' -import { ZcashNetworkInfo } from './zcashTypes' +import { PiratechainTools } from './PiratechainTools' +import { PiratechainNetworkInfo } from './piratechainTypes' -const networkInfo: ZcashNetworkInfo = { +const networkInfo: PiratechainNetworkInfo = { rpcNode: { networkName: 'mainnet', defaultHost: 'lightd1.pirate.black', @@ -14,7 +14,6 @@ const networkInfo: ZcashNetworkInfo = { }, defaultBirthday: 2040000, defaultNetworkFee: '10000', - nativeSdk: 'piratechain', transactionQueryLimit: 999 } @@ -44,11 +43,14 @@ const currencyInfo: EdgeCurrencyInfo = { unsafeBroadcastTx: true } -export const piratechain = makeOuterPlugin({ +export const piratechain = makeOuterPlugin< + PiratechainNetworkInfo, + PiratechainTools +>({ currencyInfo, networkInfo, async getInnerPlugin() { - return await import('./ZcashTools') + return await import('./PiratechainTools') } }) diff --git a/src/piratechain/piratechainTypes.ts b/src/piratechain/piratechainTypes.ts new file mode 100644 index 000000000..6eacc7bc7 --- /dev/null +++ b/src/piratechain/piratechainTypes.ts @@ -0,0 +1,177 @@ +import { + asCodec, + asMaybe, + asNumber, + asObject, + asString, + Cleaner +} from 'cleaners' +import { Subscriber } from 'yaob' + +import { asWalletInfo } from '../common/types' + +type PiratechainNetworkName = 'mainnet' | 'testnet' + +export interface PiratechainNetworkInfo { + rpcNode: { + networkName: PiratechainNetworkName + defaultHost: string + defaultPort: number + } + defaultNetworkFee: string + defaultBirthday: number + transactionQueryLimit: number +} + +export interface PiratechainSpendInfo { + zatoshi: string + toAddress: string + memo: string + fromAccountIndex: number + spendingKey: string +} + +export interface PiratechainTransaction { + rawTransactionId: string + blockTimeInSeconds: number + minedHeight: number + value: string + toAddress?: string + memo?: string +} + +export interface PiratechainPendingTransaction { + txId: string + raw: string +} + +export interface PiratechainWalletBalance { + availableZatoshi: string + totalZatoshi: string +} + +export interface UnifiedViewingKey { + extfvk: string + extpub: string +} + +export interface PiratechainInitializerConfig { + networkName: PiratechainNetworkName + defaultHost: string + defaultPort: number + fullViewingKey: UnifiedViewingKey + alias: string + birthdayHeight: number +} + +export type PiratechainSynchronizerStatus = + | 'STOPPED' + | 'DISCONNECTED' + | 'DOWNLOADING' + | 'VALIDATING' + | 'SCANNING' + | 'ENHANCING' + | 'SYNCED' + +export interface PiratechainStatusEvent { + alias: string + name: PiratechainSynchronizerStatus +} + +export interface PiratechainUpdateEvent { + alias: string + isDownloading: boolean + isScanning: boolean + lastDownloadedHeight: number + lastScannedHeight: number + scanProgress: number // 0 - 100 + networkBlockHeight: number +} + +// Block range is inclusive +export const asPiratechainBlockRange = asObject({ + first: asNumber, + last: asNumber +}) + +export type PiratechainBlockRange = ReturnType + +export const asPiratechainWalletOtherData = asObject({ + alias: asMaybe(asString), + blockRange: asMaybe(asPiratechainBlockRange, { + first: 0, + last: 0 + }) +}) + +export type PiratechainWalletOtherData = ReturnType< + typeof asPiratechainWalletOtherData +> + +export interface PiratechainSynchronizer { + on: Subscriber<{ + statusChanged: PiratechainStatusEvent + update: PiratechainUpdateEvent + }> + start: () => Promise + stop: () => Promise + getTransactions: ( + arg: PiratechainBlockRange + ) => Promise + rescan: (arg: number) => Promise + sendToAddress: ( + arg: PiratechainSpendInfo + ) => Promise + getShieldedBalance: () => Promise +} + +export type PiratechainMakeSynchronizer = () => ( + config: PiratechainInitializerConfig +) => Promise + +export const asArrrPublicKey = asObject({ + birthdayHeight: asNumber, + publicKey: asString, + unifiedViewingKeys: asObject({ + extfvk: asString, + extpub: asString + }) +}) + +export type SafePiratechainWalletInfo = ReturnType< + typeof asSafePiratechainWalletInfo +> +export const asSafePiratechainWalletInfo = asWalletInfo(asArrrPublicKey) + +export interface PiratechainPrivateKeys { + mnemonic: string + spendKey: string + birthdayHeight: number +} +export const asPiratechainPrivateKeys = ( + pluginId: string +): Cleaner => { + const asKeys = asObject({ + [`${pluginId}Mnemonic`]: asString, + [`${pluginId}SpendKey`]: asString, + [`${pluginId}BirthdayHeight`]: asNumber + }) + + return asCodec( + raw => { + const clean = asKeys(raw) + return { + mnemonic: clean[`${pluginId}Mnemonic`] as string, + spendKey: clean[`${pluginId}SpendKey`] as string, + birthdayHeight: clean[`${pluginId}BirthdayHeight`] as number + } + }, + clean => { + return { + [`${pluginId}Mnemonic`]: clean.mnemonic, + [`${pluginId}SpendKey`]: clean.spendKey, + [`${pluginId}BirthdayHeight`]: clean.birthdayHeight + } + } + ) +} diff --git a/src/react-native.ts b/src/react-native.ts index f421a7013..b5a38bc3b 100644 --- a/src/react-native.ts +++ b/src/react-native.ts @@ -7,13 +7,13 @@ import { Synchronizer as PirateSynchronizer } from 'react-native-piratechain' import { - AddressTool as ZcashAddressTool, - KeyTool as ZcashKeyTool, makeSynchronizer as ZcashMakeSynchronizer, - Synchronizer as ZcashSynchronizer + Synchronizer as ZcashSynchronizer, + Tools as ZcashNativeTools } from 'react-native-zcash' import { bridgifyObject, emit, onMethod } from 'yaob' +import { PiratechainInitializerConfig } from './piratechain/piratechainTypes' import { ZcashInitializerConfig } from './zcash/zcashTypes' const { EdgeCurrencyAccountbasedModule } = NativeModules @@ -22,63 +22,88 @@ const { sourceUri } = EdgeCurrencyAccountbasedModule.getConstants() export const pluginUri = sourceUri export const debugUri = 'http://localhost:8082/edge-currency-accountbased.js' -type Synchronizer = ZcashSynchronizer | PirateSynchronizer +const makePiratechainSynchronizer = async ( + config: PiratechainInitializerConfig +): Promise => { + const realSynchronizer = await PiratechainMakeSynchronizer(config) -const makePluginSynchronizer = (pluginId: string) => { - return async (config: ZcashInitializerConfig) => { - let realSynchronizer: Synchronizer + realSynchronizer.subscribe({ + onStatusChanged(status): void { + emit(out, 'statusChanged', status) + }, + onUpdate(event): void { + emit(out, 'update', event) + } + }) - switch (pluginId) { - case 'piratechain': - realSynchronizer = await PiratechainMakeSynchronizer(config) - break - case 'zcash': - realSynchronizer = await ZcashMakeSynchronizer(config) - break - default: - throw new Error(`${pluginId} makeSynchronizer does not exist`) + const out: PirateSynchronizer = bridgifyObject({ + // @ts-expect-error + on: onMethod, + start: async () => { + return await realSynchronizer.start() + }, + getTransactions: async blockRange => { + return await realSynchronizer.getTransactions(blockRange) + }, + rescan: height => { + return realSynchronizer.rescan(height) + }, + sendToAddress: async spendInfo => { + return await realSynchronizer.sendToAddress(spendInfo) + }, + getShieldedBalance: async () => { + return await realSynchronizer.getShieldedBalance() + }, + stop: async () => { + return await realSynchronizer.stop() } + }) + return out +} - realSynchronizer.subscribe({ - onStatusChanged(status): void { - emit(out, 'statusChanged', status) - }, - onUpdate(event): void { - emit(out, 'update', event) - } - }) +const makeZcashSynchronizer = async ( + config: ZcashInitializerConfig +): Promise => { + const realSynchronizer = await ZcashMakeSynchronizer(config) - const out: Synchronizer = bridgifyObject({ - // @ts-expect-error - on: onMethod, - start: async () => { - return await realSynchronizer.start() - }, - getTransactions: async blockRange => { - return await realSynchronizer.getTransactions(blockRange) - }, - rescan: height => { - return realSynchronizer.rescan(height) - }, - sendToAddress: async spendInfo => { - return await realSynchronizer.sendToAddress(spendInfo) - }, - getShieldedBalance: async () => { - return await realSynchronizer.getShieldedBalance() - }, - stop: async () => { - return await realSynchronizer.stop() - } - }) - return out - } + realSynchronizer.subscribe({ + onStatusChanged(status): void { + emit(out, 'statusChanged', status) + }, + onUpdate(event): void { + emit(out, 'update', event) + } + }) + + const out: ZcashSynchronizer = bridgifyObject({ + // @ts-expect-error + on: onMethod, + deriveUnifiedAddress: async () => { + return await realSynchronizer.deriveUnifiedAddress() + }, + getTransactions: async blockRange => { + return await realSynchronizer.getTransactions(blockRange) + }, + rescan: () => { + return realSynchronizer.rescan() + }, + sendToAddress: async spendInfo => { + return await realSynchronizer.sendToAddress(spendInfo) + }, + getBalance: async () => { + return await realSynchronizer.getBalance() + }, + stop: async () => { + return await realSynchronizer.stop() + } + }) + return out } export function makePluginIo(): EdgeOtherMethods { bridgifyObject(PiratechainKeyTool) bridgifyObject(PiratechainAddressTool) - bridgifyObject(ZcashKeyTool) - bridgifyObject(ZcashAddressTool) + bridgifyObject(ZcashNativeTools) return { async fetchText(uri: string, opts: Object) { @@ -96,15 +121,14 @@ export function makePluginIo(): EdgeOtherMethods { piratechain: bridgifyObject({ KeyTool: PiratechainKeyTool, AddressTool: PiratechainAddressTool, - async makeSynchronizer(config: ZcashInitializerConfig) { - return await makePluginSynchronizer('piratechain')(config) + async makeSynchronizer(config: PiratechainInitializerConfig) { + return await makePiratechainSynchronizer(config) } }), zcash: bridgifyObject({ - KeyTool: ZcashKeyTool, - AddressTool: ZcashAddressTool, + Tools: ZcashNativeTools, async makeSynchronizer(config: ZcashInitializerConfig) { - return await makePluginSynchronizer('zcash')(config) + return await makeZcashSynchronizer(config) } }) } diff --git a/src/zcash/ZcashEngine.ts b/src/zcash/ZcashEngine.ts index 6c36f6986..0579d08c6 100644 --- a/src/zcash/ZcashEngine.ts +++ b/src/zcash/ZcashEngine.ts @@ -3,6 +3,7 @@ import { EdgeCurrencyEngine, EdgeCurrencyEngineOptions, EdgeEnginePrivateKeyOptions, + EdgeFreshAddress, EdgeSpendInfo, EdgeTransaction, EdgeWalletInfo, @@ -35,18 +36,21 @@ export class ZcashEngine extends CurrencyEngine< pluginId: string networkInfo: ZcashNetworkInfo otherData!: ZcashWalletOtherData - synchronizer!: ZcashSynchronizer synchronizerStatus!: ZcashSynchronizerStatus availableZatoshi!: string initialNumBlocksToDownload!: number initializer!: ZcashInitializerConfig - alias!: string progressRatio!: number queryMutex: boolean makeSynchronizer: ( config: ZcashInitializerConfig ) => Promise + // Synchronizer management + started: boolean + stopSyncing?: (value: number | PromiseLike) => void + synchronizer?: ZcashSynchronizer + constructor( env: PluginEnvironment, tools: ZcashTools, @@ -60,6 +64,8 @@ export class ZcashEngine extends CurrencyEngine< this.networkInfo = networkInfo this.makeSynchronizer = makeSynchronizer this.queryMutex = false + + this.started = false } setOtherData(raw: any): void { @@ -67,7 +73,7 @@ export class ZcashEngine extends CurrencyEngine< } initData(): void { - const { birthdayHeight, alias } = this.initializer + const { birthdayHeight } = this.initializer // walletLocalData if (this.otherData.blockRange.first === 0) { @@ -78,7 +84,6 @@ export class ZcashEngine extends CurrencyEngine< } // Engine variables - this.alias = alias this.initialNumBlocksToDownload = -1 this.synchronizerStatus = 'DISCONNECTED' this.availableZatoshi = '0' @@ -86,6 +91,7 @@ export class ZcashEngine extends CurrencyEngine< } initSubscriptions(): void { + if (this.synchronizer == null) return this.synchronizer.on('update', async payload => { const { lastDownloadedHeight, scanProgress, networkBlockHeight } = payload this.onUpdateBlockHeight(networkBlockHeight) @@ -175,10 +181,8 @@ export class ZcashEngine extends CurrencyEngine< } async startEngine(): Promise { - this.initData() - this.synchronizer = await this.makeSynchronizer(this.initializer) - await this.synchronizer.start() - this.initSubscriptions() + this.engineOn = true + this.started = true await super.startEngine() } @@ -188,9 +192,9 @@ export class ZcashEngine extends CurrencyEngine< } async queryBalance(): Promise { - if (!this.isSynced()) return + if (!this.isSynced() || this.synchronizer == null) return try { - const balances = await this.synchronizer.getShieldedBalance() + const balances = await this.synchronizer.getBalance() if (balances.totalZatoshi === '-1') return this.availableZatoshi = balances.availableZatoshi this.updateBalance(this.currencyInfo.currencyCode, balances.totalZatoshi) @@ -201,6 +205,7 @@ export class ZcashEngine extends CurrencyEngine< } async queryTransactions(): Promise { + if (this.synchronizer == null) return try { let first = this.otherData.blockRange.first let last = this.otherData.blockRange.last @@ -270,8 +275,37 @@ export class ZcashEngine extends CurrencyEngine< this.addTransaction(this.currencyInfo.currencyCode, edgeTransaction) } + async syncNetwork(opts: EdgeEnginePrivateKeyOptions): Promise { + if (!this.started) return 1000 + + const zcashPrivateKeys = asZcashPrivateKeys(this.currencyInfo.pluginId)( + opts?.privateKeys + ) + + const { rpcNode } = this.networkInfo + this.initializer = { + mnemonicSeed: zcashPrivateKeys.mnemonic, + birthdayHeight: zcashPrivateKeys.birthdayHeight, + alias: this.walletInfo.keys.publicKey.slice(0, 99), + ...rpcNode + } + + this.synchronizer = await this.makeSynchronizer(this.initializer) + this.initData() + this.initSubscriptions() + + return await new Promise(resolve => { + this.stopSyncing = resolve + }) + } + async killEngine(): Promise { - await this.synchronizer.stop() + this.started = false + if (this.stopSyncing != null) { + await this.stopSyncing(1000) + this.stopSyncing = undefined + } + await this.synchronizer?.stop() await super.killEngine() } @@ -285,8 +319,10 @@ export class ZcashEngine extends CurrencyEngine< await this.clearBlockchainCache() await this.startEngine() this.synchronizer - .rescan(this.walletInfo.keys.birthdayHeight) + ?.rescan() .catch((e: any) => this.warn('resyncBlockchain failed: ', e)) + this.initData() + this.synchronizerStatus = 'SYNCING' } async getMaxSpendable(): Promise { @@ -364,6 +400,7 @@ export class ZcashEngine extends CurrencyEngine< edgeTransaction: EdgeTransaction, opts?: EdgeEnginePrivateKeyOptions ): Promise { + if (this.synchronizer == null) throw new Error('Synchronizer undefined') const zcashPrivateKeys = asZcashPrivateKeys(this.pluginId)( opts?.privateKeys ) @@ -382,7 +419,7 @@ export class ZcashEngine extends CurrencyEngine< toAddress: spendTarget.publicAddress, memo: spendTarget.memo ?? spendTarget.uniqueIdentifier ?? '', fromAccountIndex: 0, - spendingKey: zcashPrivateKeys.spendKey + mnemonicSeed: zcashPrivateKeys.mnemonic } try { @@ -398,20 +435,15 @@ export class ZcashEngine extends CurrencyEngine< return edgeTransaction } - async loadEngine(): Promise { - const { walletInfo } = this - await super.loadEngine() - this.engineOn = true - - const { rpcNode } = this.networkInfo - this.initializer = { - fullViewingKey: walletInfo.keys.unifiedViewingKeys, - birthdayHeight: walletInfo.keys.birthdayHeight, - alias: walletInfo.keys.publicKey, - ...rpcNode + async getFreshAddress(): Promise { + if (this.synchronizer == null) throw new Error('Synchronizer undefined') + const unifiedAddress = await this.synchronizer.deriveUnifiedAddress() + return { + publicAddress: unifiedAddress } } } + export async function makeCurrencyEngine( env: PluginEnvironment, tools: ZcashTools, @@ -419,8 +451,7 @@ export async function makeCurrencyEngine( opts: EdgeCurrencyEngineOptions ): Promise { const safeWalletInfo = asSafeZcashWalletInfo(walletInfo) - const { makeSynchronizer } = - env.nativeIo['edge-currency-accountbased'][env.networkInfo.nativeSdk] + const { makeSynchronizer } = env.nativeIo['edge-currency-accountbased'].zcash const engine = new ZcashEngine( env, diff --git a/src/zcash/ZcashTools.ts b/src/zcash/ZcashTools.ts index e8ddb3d7d..589753eae 100644 --- a/src/zcash/ZcashTools.ts +++ b/src/zcash/ZcashTools.ts @@ -1,5 +1,5 @@ import { div } from 'biggystring' -import { entropyToMnemonic, mnemonicToSeed, validateMnemonic } from 'bip39' +import { entropyToMnemonic, validateMnemonic } from 'bip39' import { Buffer } from 'buffer' import { EdgeCurrencyInfo, @@ -12,10 +12,7 @@ import { EdgeWalletInfo, JsonObject } from 'edge-core-js/types' -import { - AddressTool as AddressToolType, - KeyTool as KeyToolType -} from 'react-native-zcash' +import { Tools as ToolsType } from 'react-native-zcash' import { PluginEnvironment } from '../common/innerPlugin' import { asIntegerString } from '../common/types' @@ -34,9 +31,7 @@ export class ZcashTools implements EdgeCurrencyTools { currencyInfo: EdgeCurrencyInfo io: EdgeIo networkInfo: ZcashNetworkInfo - - KeyTool: typeof KeyToolType - AddressTool: typeof AddressToolType + nativeTools: typeof ToolsType constructor(env: PluginEnvironment) { const { builtinTokens, currencyInfo, io, networkInfo } = env @@ -49,10 +44,9 @@ export class ZcashTools implements EdgeCurrencyTools { if (RNAccountbased == null) { throw new Error('Need opts') } - const { KeyTool, AddressTool } = RNAccountbased[this.networkInfo.nativeSdk] + const { Tools } = RNAccountbased.zcash - this.KeyTool = KeyTool - this.AddressTool = AddressTool + this.nativeTools = Tools } async getDisplayPrivateKey( @@ -65,12 +59,12 @@ export class ZcashTools implements EdgeCurrencyTools { async getDisplayPublicKey(publicWalletInfo: EdgeWalletInfo): Promise { const { keys } = asSafeZcashWalletInfo(publicWalletInfo) - return keys.unifiedViewingKeys?.extfvk + return keys.publicKey } async getNewWalletBirthdayBlockheight(): Promise { try { - return await this.KeyTool.getBirthdayHeight( + return await this.nativeTools.getBirthdayHeight( this.networkInfo.rpcNode.defaultHost, this.networkInfo.rpcNode.defaultPort ) @@ -80,10 +74,7 @@ export class ZcashTools implements EdgeCurrencyTools { } async isValidAddress(address: string): Promise { - return ( - (await this.AddressTool.isValidShieldedAddress(address)) || - (await this.AddressTool.isValidTransparentAddress(address)) - ) + return await this.nativeTools.isValidAddress(address) } // will actually use MNEMONIC version of private key @@ -95,13 +86,6 @@ export class ZcashTools implements EdgeCurrencyTools { const isValid = validateMnemonic(userInput) if (!isValid) throw new Error(`Invalid ${this.currencyInfo.currencyCode} mnemonic`) - const hexBuffer = await mnemonicToSeed(userInput) - const hex = hexBuffer.toString('hex') - const spendKey = await this.KeyTool.deriveSpendingKey( - hex, - this.networkInfo.rpcNode.networkName - ) - if (typeof spendKey !== 'string') throw new Error('Invalid spendKey type') // Get current network height for the birthday height const currentNetworkHeight = await this.getNewWalletBirthdayBlockheight() @@ -122,7 +106,6 @@ export class ZcashTools implements EdgeCurrencyTools { return { [`${pluginId}Mnemonic`]: userInput, - [`${pluginId}SpendKey`]: spendKey, [`${pluginId}BirthdayHeight`]: height } } @@ -157,21 +140,14 @@ export class ZcashTools implements EdgeCurrencyTools { if (typeof mnemonic !== 'string') { throw new Error('InvalidMnemonic') } - const hexBuffer = await mnemonicToSeed(mnemonic) - const hex = hexBuffer.toString('hex') - const unifiedViewingKeys: UnifiedViewingKey = - await this.KeyTool.deriveViewingKey( - hex, + const unifiedViewingKey: UnifiedViewingKey = + await this.nativeTools.deriveViewingKey( + mnemonic, this.networkInfo.rpcNode.networkName ) - const shieldedAddress = await this.AddressTool.deriveShieldedAddress( - unifiedViewingKeys.extfvk, - this.networkInfo.rpcNode.networkName - ) return { birthdayHeight: zcashPrivateKeys.birthdayHeight, - publicKey: shieldedAddress, - unifiedViewingKeys + publicKey: unifiedViewingKey } } diff --git a/src/zcash/zcashInfo.ts b/src/zcash/zcashInfo.ts index 15e731e6f..45039aca4 100644 --- a/src/zcash/zcashInfo.ts +++ b/src/zcash/zcashInfo.ts @@ -12,7 +12,6 @@ const networkInfo: ZcashNetworkInfo = { }, defaultBirthday: 1310000, defaultNetworkFee: '1000', // hardcoded default ZEC fee - nativeSdk: 'zcash', transactionQueryLimit: 999 } @@ -41,6 +40,7 @@ export const currencyInfo: EdgeCurrencyInfo = { metaTokens: [], // Deprecated + unsafeSyncNetwork: true, unsafeBroadcastTx: true } diff --git a/src/zcash/zcashTypes.ts b/src/zcash/zcashTypes.ts index 4b590e660..26b4a353a 100644 --- a/src/zcash/zcashTypes.ts +++ b/src/zcash/zcashTypes.ts @@ -20,7 +20,6 @@ export interface ZcashNetworkInfo { } defaultNetworkFee: string defaultBirthday: number - nativeSdk: 'zcash' | 'piratechain' transactionQueryLimit: number } @@ -29,7 +28,7 @@ export interface ZcashSpendInfo { toAddress: string memo: string fromAccountIndex: number - spendingKey: string + mnemonicSeed: string } export interface ZcashTransaction { @@ -60,7 +59,7 @@ export interface ZcashInitializerConfig { networkName: ZcashNetworkName defaultHost: string defaultPort: number - fullViewingKey: UnifiedViewingKey + mnemonicSeed: string alias: string birthdayHeight: number } @@ -68,10 +67,7 @@ export interface ZcashInitializerConfig { export type ZcashSynchronizerStatus = | 'STOPPED' | 'DISCONNECTED' - | 'DOWNLOADING' - | 'VALIDATING' - | 'SCANNING' - | 'ENHANCING' + | 'SYNCING' | 'SYNCED' export interface ZcashStatusEvent { @@ -114,10 +110,11 @@ export interface ZcashSynchronizer { }> start: () => Promise stop: () => Promise + deriveUnifiedAddress: () => Promise getTransactions: (arg: ZcashBlockRange) => Promise - rescan: (arg: number) => Promise + rescan: () => Promise sendToAddress: (arg: ZcashSpendInfo) => Promise - getShieldedBalance: () => Promise + getBalance: () => Promise } export type ZcashMakeSynchronizer = () => ( @@ -126,11 +123,7 @@ export type ZcashMakeSynchronizer = () => ( export const asZecPublicKey = asObject({ birthdayHeight: asNumber, - publicKey: asString, - unifiedViewingKeys: asObject({ - extfvk: asString, - extpub: asString - }) + publicKey: asString }) export type SafeZcashWalletInfo = ReturnType @@ -138,7 +131,6 @@ export const asSafeZcashWalletInfo = asWalletInfo(asZecPublicKey) export interface ZcashPrivateKeys { mnemonic: string - spendKey: string birthdayHeight: number } export const asZcashPrivateKeys = ( @@ -146,7 +138,6 @@ export const asZcashPrivateKeys = ( ): Cleaner => { const asKeys = asObject({ [`${pluginId}Mnemonic`]: asString, - [`${pluginId}SpendKey`]: asString, [`${pluginId}BirthdayHeight`]: asNumber }) @@ -155,14 +146,12 @@ export const asZcashPrivateKeys = ( const clean = asKeys(raw) return { mnemonic: clean[`${pluginId}Mnemonic`] as string, - spendKey: clean[`${pluginId}SpendKey`] as string, birthdayHeight: clean[`${pluginId}BirthdayHeight`] as number } }, clean => { return { [`${pluginId}Mnemonic`]: clean.mnemonic, - [`${pluginId}SpendKey`]: clean.spendKey, [`${pluginId}BirthdayHeight`]: clean.birthdayHeight } } diff --git a/yarn.lock b/yarn.lock index cef35f7d7..7ce9960e7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6681,10 +6681,10 @@ react-native-piratechain@^0.3.2: dependencies: rfc4648 "^1.3.0" -react-native-zcash@^0.3.2: - version "0.3.2" - resolved "https://registry.yarnpkg.com/react-native-zcash/-/react-native-zcash-0.3.2.tgz#f2b77630ab104771e844b07e43269a0fb3d33a63" - integrity sha512-6qukZUUDzEeuVL7h9jq1wek2UDo2KT3QvDuw7FISeoXUug1LAAgPvLE36v51smcdNQamLMBqJWAZM1tS3B5xTw== +react-native-zcash@0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/react-native-zcash/-/react-native-zcash-0.4.0.tgz#cd6cc045673a5bf843a2c5e342b66ee243239555" + integrity sha512-xL87jBLiCqqpKJ4Mir/cC4M+poJYvFu6z9Mad0rGO+zOxfBkVuvunm4kYBtx96bqVHDH3jX9CukE888MBZzFAQ== dependencies: rfc4648 "^1.3.0"