diff --git a/CHANGELOG.md b/CHANGELOG.md index 733995016..53c7daa20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,9 @@ - added: `EdgeCurrencyEngineCallbacks.onSyncStatusChanged` callback. - added: `EdgeCurrencyInfo.syncDisplayPrecision` option. +- added: `EdgeCurrencyWallet.split` method. - added: `EdgeCurrencyWallet.syncStatus` property and matching `EdgeSyncStatus` type. +- deprecated: `EdgeAccount.splitWalletInfo` method. - deprecated: `EdgeCurrencyEngineCallbacks.onAddressesChecked`. Use `onSyncStatusChanged` instead. - deprecated: `EdgeCurrencyWallet.syncRatio`. Use `syncStatus` instead. diff --git a/src/core/account/account-api.ts b/src/core/account/account-api.ts index ce562515e..496bb3d4e 100644 --- a/src/core/account/account-api.ts +++ b/src/core/account/account-api.ts @@ -28,6 +28,7 @@ import { EdgeWalletInfoFull, EdgeWalletStates } from '../../types/types' +import { makeEdgeResult } from '../../util/edgeResult' import { base58 } from '../../util/encoding' import { getPublicWalletInfo } from '../currency/wallet/currency-wallet-pixie' import { @@ -502,7 +503,42 @@ export function makeAccountApi(ai: ApiInput, accountId: string): EdgeAccount { walletId: string, newWalletType: string ): Promise { - return await splitWalletInfo(ai, accountId, walletId, newWalletType) + const { allWalletInfosFull } = accountState() + const walletInfo = allWalletInfosFull.find( + walletInfo => walletInfo.id === walletId + ) + if (walletInfo == null) throw new Error(`Invalid wallet id ${walletId}`) + const existingWallet = + ai.props.output?.currency?.wallets[walletInfo.id]?.walletApi + + // The following check has not been needed since about 2021, + // when the currency plugins became responsible for listing + // their own splittable types, but keep it for safety: + if ( + walletInfo.type === 'wallet:bitcoin' && + walletInfo.keys.format === 'bip49' && + newWalletType === 'wallet:bitcoincash' + ) { + throw new Error( + 'Cannot split segwit-format Bitcoin wallets to Bitcoin Cash' + ) + } + + const [result] = await splitWalletInfo( + ai, + accountId, + walletInfo, + [ + { + walletType: newWalletType, + name: existingWallet?.name ?? undefined, + fiatCurrencyCode: existingWallet?.fiatCurrencyCode + } + ], + true + ) + if (result.ok) return result.result.id + throw result.error }, async listSplittableWalletTypes(walletId: string): Promise { @@ -828,11 +864,3 @@ function getRawPrivateKey( } return info } - -async function makeEdgeResult(promise: Promise): Promise> { - try { - return { ok: true, result: await promise } - } catch (error) { - return { ok: false, error } - } -} diff --git a/src/core/currency/wallet/currency-wallet-api.ts b/src/core/currency/wallet/currency-wallet-api.ts index ca55fe092..a3da9d3f3 100644 --- a/src/core/currency/wallet/currency-wallet-api.ts +++ b/src/core/currency/wallet/currency-wallet-api.ts @@ -28,10 +28,12 @@ import { EdgeParsedUri, EdgePaymentProtocolInfo, EdgeReceiveAddress, + EdgeResult, EdgeSaveTxMetadataOptions, EdgeSignMessageOptions, EdgeSpendInfo, EdgeSpendTarget, + EdgeSplitCurrencyWallet, EdgeStakingStatus, EdgeStreamTransactionOptions, EdgeSyncStatus, @@ -41,6 +43,7 @@ import { EdgeWalletInfo } from '../../../types/types' import { makeMetaTokens } from '../../account/custom-tokens' +import { splitWalletInfo } from '../../login/splitting' import { toApiInput } from '../../root-pixie' import { makeStorageWalletApi } from '../../storage/storage-api' import { getCurrencyMultiplier } from '../currency-selectors' @@ -731,6 +734,18 @@ export function makeCurrencyWalletApi( emit(out, 'transactionsRemoved', undefined) }, + async split( + splitWallets: EdgeSplitCurrencyWallet[] + ): Promise>> { + return await splitWalletInfo( + ai, + accountId, + walletInfo, + splitWallets, + false + ) + }, + // URI handling: async encodeUri(options: EdgeEncodeUri): Promise { return await tools.encodeUri( diff --git a/src/core/login/splitting.ts b/src/core/login/splitting.ts index aefc7a3a0..1a0591372 100644 --- a/src/core/login/splitting.ts +++ b/src/core/login/splitting.ts @@ -3,11 +3,15 @@ import { base64 } from 'rfc4648' import { EdgeCurrencyWallet, + EdgeResult, EdgeSpendInfo, + EdgeSplitCurrencyWallet, EdgeWalletInfo, + EdgeWalletInfoFull, EdgeWalletStates } from '../../types/types' import { hmacSha256 } from '../../util/crypto/hashes' +import { makeEdgeResult } from '../../util/edgeResult' import { utf8 } from '../../util/encoding' import { changeWalletStates } from '../account/account-files' import { waitForCurrencyWallet } from '../currency/currency-selectors' @@ -102,80 +106,135 @@ export function makeSplitWalletInfo( export async function splitWalletInfo( ai: ApiInput, accountId: string, - walletId: string, - newWalletType: string -): Promise { + walletInfo: EdgeWalletInfoFull, + splitWallets: EdgeSplitCurrencyWallet[], + rejectDupes: boolean +): Promise>> { const accountState = ai.props.state.accounts[accountId] const { allWalletInfosFull, sessionKey } = accountState - // Find the wallet we are going to split: - const walletInfo = allWalletInfosFull.find( - walletInfo => walletInfo.id === walletId - ) - if (walletInfo == null) throw new Error(`Invalid wallet id ${walletId}`) - - // Handle BCH / BTC+segwit special case: - if ( - newWalletType === 'wallet:bitcoincash' && - walletInfo.type === 'wallet:bitcoin' && - walletInfo.keys.format === 'bip49' - ) { - throw new Error( - 'Cannot split segwit-format Bitcoin wallets to Bitcoin Cash' - ) + // Validate the wallet types: + const plugins = ai.props.state.plugins.currency + const splitInfos = new Map() + for (const item of splitWallets) { + const { walletType } = item + const pluginId = maybeFindCurrencyPluginId(plugins, item.walletType) + if (pluginId == null) { + throw new Error(`Cannot find plugin for wallet type "${walletType}"`) + } + if (splitInfos.has(walletType)) { + throw new Error(`Duplicate wallet type "${walletType}"`) + } + splitInfos.set(walletType, makeSplitWalletInfo(walletInfo, walletType)) } - // Handle BitcoinABC/SV replay protection: + // Do we need BitcoinABC/SV replay protection? const needsProtection = - newWalletType === 'wallet:bitcoinsv' && - walletInfo.type === 'wallet:bitcoincash' + walletInfo.type === 'wallet:bitcoincash' && + // We can re-protect a wallet by doing a repeated split, + // so don't check if the wallet already exists: + splitInfos.has('wallet:bitcoinsv') if (needsProtection) { - const oldWallet = ai.props.output.currency.wallets[walletId].walletApi - if (oldWallet == null) throw new Error('Missing Wallet') - await protectBchWallet(oldWallet) + const existingWallet = + ai.props.output?.currency?.wallets[walletInfo.id]?.walletApi + if (existingWallet == null) { + throw new Error(`Cannot find wallet ${walletInfo.id}`) + } + await protectBchWallet(existingWallet) } - // See if the wallet has already been split: - const newWalletInfo = makeSplitWalletInfo(walletInfo, newWalletType) - const existingWalletInfo = allWalletInfosFull.find( - walletInfo => walletInfo.id === newWalletInfo.id - ) - if (existingWalletInfo != null) { - if (existingWalletInfo.archived || existingWalletInfo.deleted) { - // Simply undelete the existing wallet: - const walletInfos: EdgeWalletStates = {} - walletInfos[newWalletInfo.id] = { - archived: false, - deleted: false, - migratedFromWalletId: existingWalletInfo.migratedFromWalletId + // Sort the wallet infos into two categories: + const toRestore: EdgeWalletInfoFull[] = [] + const toCreate: EdgeWalletInfo[] = [] + for (const newWalletInfo of splitInfos.values()) { + const existingWalletInfo = allWalletInfosFull.find( + info => info.id === newWalletInfo.id + ) + if (existingWalletInfo == null) { + toCreate.push(newWalletInfo) + } else { + if (existingWalletInfo.archived || existingWalletInfo.deleted) { + toRestore.push(existingWalletInfo) + } else if (rejectDupes) { + if ( + // It's OK to re-split if we are adding protection: + walletInfo.type !== 'wallet:bitcoincash' || + newWalletInfo.type !== 'wallet:bitcoinsv' + ) { + throw new Error( + `This wallet has already been split (${newWalletInfo.type})` + ) + } + } + } + } + + // Restore anything that has simply been deleted: + if (toRestore.length > 0) { + const newStates: EdgeWalletStates = {} + let hasChanges = false + for (const existingWalletInfo of toRestore) { + if (existingWalletInfo.archived || existingWalletInfo.deleted) { + hasChanges = true + newStates[existingWalletInfo.id] = { + archived: false, + deleted: false, + migratedFromWalletId: existingWalletInfo.migratedFromWalletId + } } - await changeWalletStates(ai, accountId, walletInfos) - return newWalletInfo.id } - if (needsProtection) return newWalletInfo.id - throw new Error('This wallet has already been split') + if (hasChanges) await changeWalletStates(ai, accountId, newStates) } // Add the keys to the login: - const kit = makeKeysKit(ai, sessionKey, [newWalletInfo], true) - await applyKit(ai, sessionKey, kit) + if (toCreate.length > 0) { + const kit = makeKeysKit(ai, sessionKey, toCreate, true) + await applyKit(ai, sessionKey, kit) + } + + // Wait for the new wallets to load: + const out = await Promise.all( + splitWallets.map(async splitInfo => { + const walletInfo = splitInfos.get(splitInfo.walletType) + if (walletInfo == null) { + throw new Error(`Missing wallet info for ${splitInfo.walletType}`) + } + return await makeEdgeResult( + finishWalletSplitting( + ai, + walletInfo.id, + toCreate.find(info => info.type === splitInfo.walletType) != null + ? splitInfo + : undefined + ) + ) + }) + ) + + return out +} + +async function finishWalletSplitting( + ai: ApiInput, + walletId: string, + item?: EdgeSplitCurrencyWallet +): Promise { + const wallet = await waitForCurrencyWallet(ai, walletId) // Try to copy metadata on a best-effort basis. // In the future we should clone the repo instead: - try { - const wallet = await waitForCurrencyWallet(ai, newWalletInfo.id) - const oldWallet = ai.props.output.currency.wallets[walletId].walletApi - if (oldWallet != null) { - if (oldWallet.name != null) await wallet.renameWallet(oldWallet.name) - if (oldWallet.fiatCurrencyCode != null) { - await wallet.setFiatCurrencyCode(oldWallet.fiatCurrencyCode) - } - } - } catch (error: unknown) { - ai.props.onError(error) + if (item?.name != null) { + await wallet + .renameWallet(item.name) + .catch((error: unknown) => ai.props.onError(error)) + } + if (item?.fiatCurrencyCode != null) { + await wallet + .setFiatCurrencyCode(item.fiatCurrencyCode) + .catch((error: unknown) => ai.props.onError(error)) } - return newWalletInfo.id + return wallet } async function protectBchWallet(wallet: EdgeCurrencyWallet): Promise { diff --git a/src/types/types.ts b/src/types/types.ts index a329e4611..998426c9f 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -1408,6 +1408,9 @@ export interface EdgeCurrencyWallet { // Wallet management: readonly dumpData: () => Promise readonly resyncBlockchain: () => Promise + readonly split: ( + splitWallets: EdgeSplitCurrencyWallet[] + ) => Promise>> // URI handling: readonly encodeUri: (obj: EdgeEncodeUri) => Promise @@ -1664,6 +1667,12 @@ export type EdgeCreateCurrencyWallet = EdgeCreateCurrencyWalletOptions & { walletType: string } +export interface EdgeSplitCurrencyWallet { + fiatCurrencyCode?: string + name?: string + walletType: string +} + export interface EdgeCurrencyConfig { readonly watch: Subscriber @@ -1867,10 +1876,6 @@ export interface EdgeAccount { readonly getWalletInfo: (id: string) => EdgeWalletInfoFull | undefined readonly listWalletIds: () => string[] readonly listSplittableWalletTypes: (walletId: string) => Promise - readonly splitWalletInfo: ( - walletId: string, - newWalletType: string - ) => Promise // Key access: readonly getDisplayPrivateKey: (walletId: string) => Promise @@ -1917,6 +1922,12 @@ export interface EdgeAccount { request: EdgeSwapRequest, opts?: EdgeSwapRequestOptions ) => Promise + + /** @deprecated Use `EdgeCurrencyWallet.split` instead */ + readonly splitWalletInfo: ( + walletId: string, + newWalletType: string + ) => Promise } // --------------------------------------------------------------------- diff --git a/src/util/edgeResult.ts b/src/util/edgeResult.ts new file mode 100644 index 000000000..fe1410c6c --- /dev/null +++ b/src/util/edgeResult.ts @@ -0,0 +1,11 @@ +import { EdgeResult } from '../types/types' + +export async function makeEdgeResult( + promise: Promise +): Promise> { + try { + return { ok: true, result: await promise } + } catch (error) { + return { ok: false, error } + } +} diff --git a/test/core/account/account.test.ts b/test/core/account/account.test.ts index cc56cc02d..e393fbd70 100644 --- a/test/core/account/account.test.ts +++ b/test/core/account/account.test.ts @@ -10,7 +10,7 @@ import { import { expectRejection } from '../../expect-rejection' import { fakeUser } from '../../fake/fake-user' -const plugins = { fakecoin: true } +const plugins = { fakecoin: true, tulipcoin: true } const contextOptions = { apiKey: '', appId: '', plugins } const quiet = { onLog() {} } @@ -311,7 +311,7 @@ describe('account', function () { // Splitting back should not work: await expectRejection( account.splitWalletInfo(tulipWallet.id, 'wallet:fakecoin'), - 'Error: This wallet has already been split' + 'Error: This wallet has already been split (wallet:fakecoin)' ) }) diff --git a/test/fake/fake-currency-plugin.ts b/test/fake/fake-currency-plugin.ts index 882436d84..bffa847ec 100644 --- a/test/fake/fake-currency-plugin.ts +++ b/test/fake/fake-currency-plugin.ts @@ -88,11 +88,17 @@ class FakeCurrencyEngine implements EdgeCurrencyEngine { private running: boolean private readonly state: State private allTokens: EdgeTokenMap = fakeTokens + private readonly currencyInfo: EdgeCurrencyInfo - constructor(walletInfo: EdgeWalletInfo, opts: EdgeCurrencyEngineOptions) { + constructor( + walletInfo: EdgeWalletInfo, + opts: EdgeCurrencyEngineOptions, + currencyInfo: EdgeCurrencyInfo + ) { this.walletId = walletInfo.id this.callbacks = opts.callbacks this.running = false + this.currencyInfo = currencyInfo this.state = { balance: 0, stakedBalance: 0, @@ -154,7 +160,7 @@ class FakeCurrencyEngine implements EdgeCurrencyEngine { const { tokenId = null } = upgradeCurrencyCode({ allTokens: this.allTokens, currencyCode: incoming.currencyCode, - currencyInfo: fakeCurrencyInfo + currencyInfo: this.currencyInfo }) const newTx: EdgeTransaction = { blockHeight: 0, @@ -204,7 +210,7 @@ class FakeCurrencyEngine implements EdgeCurrencyEngine { async dumpData(): Promise { return { walletId: 'xxx', - walletType: fakeCurrencyInfo.walletType, + walletType: this.currencyInfo.walletType, data: { fakeEngine: { running: this.running } } } } @@ -273,7 +279,7 @@ class FakeCurrencyEngine implements EdgeCurrencyEngine { makeSpend(spendInfo: EdgeSpendInfo): Promise { const { memos = [], spendTargets, tokenId = null } = spendInfo const { currencyCode } = - tokenId == null ? fakeCurrencyInfo : this.allTokens[tokenId] + tokenId == null ? this.currencyInfo : this.allTokens[tokenId] // Check the spend targets: let total = '0' @@ -333,9 +339,11 @@ class FakeCurrencyEngine implements EdgeCurrencyEngine { * Currency plugin setup object. */ class FakeCurrencyTools implements EdgeCurrencyTools { + constructor(private readonly currencyInfo: EdgeCurrencyInfo) {} + // Keys: createPrivateKey(walletType: string, opts?: object): Promise { - if (walletType !== fakeCurrencyInfo.walletType) { + if (walletType !== this.currencyInfo.walletType) { throw new Error('Unsupported key type') } return Promise.resolve({ fakeKey: 'FakePrivateKey' }) @@ -363,7 +371,9 @@ class FakeCurrencyTools implements EdgeCurrencyTools { } getSplittableTypes(publicWalletInfo: EdgeWalletInfo): string[] { - return ['wallet:tulipcoin'] + return this.currencyInfo.walletType === 'wallet:fakecoin' + ? ['wallet:tulipcoin'] + : [] } // URI parsing: @@ -376,25 +386,35 @@ class FakeCurrencyTools implements EdgeCurrencyTools { } } -export const fakeCurrencyPlugin: EdgeCurrencyPlugin = { - currencyInfo: fakeCurrencyInfo, - - getBuiltinTokens(): Promise { - return Promise.resolve(fakeTokens) - }, - - makeCurrencyEngine( - walletInfo: EdgeWalletInfo, - opts: EdgeCurrencyEngineOptions - ): Promise { - return Promise.resolve(new FakeCurrencyEngine(walletInfo, opts)) - }, - - makeCurrencyTools(): Promise { - return Promise.resolve(new FakeCurrencyTools()) +export function makeFakeCurrencyPlugin( + overrides: Partial = {} +): EdgeCurrencyPlugin { + const currencyInfo: EdgeCurrencyInfo = { ...fakeCurrencyInfo, ...overrides } + + return { + currencyInfo, + + getBuiltinTokens(): Promise { + return Promise.resolve(fakeTokens) + }, + + makeCurrencyEngine( + walletInfo: EdgeWalletInfo, + opts: EdgeCurrencyEngineOptions + ): Promise { + return Promise.resolve( + new FakeCurrencyEngine(walletInfo, opts, currencyInfo) + ) + }, + + makeCurrencyTools(): Promise { + return Promise.resolve(new FakeCurrencyTools(currencyInfo)) + } } } +export const fakeCurrencyPlugin = makeFakeCurrencyPlugin() + const asNetworkLocation = asObject({ contractAddress: asString }) diff --git a/test/fake/fake-plugins.ts b/test/fake/fake-plugins.ts index fd28cb2eb..76b55f418 100644 --- a/test/fake/fake-plugins.ts +++ b/test/fake/fake-plugins.ts @@ -1,5 +1,8 @@ import { brokenEnginePlugin } from './fake-broken-engine' -import { fakeCurrencyPlugin } from './fake-currency-plugin' +import { + fakeCurrencyPlugin, + makeFakeCurrencyPlugin +} from './fake-currency-plugin' import { fakeSwapPlugin } from './fake-swap-plugin' export const allPlugins = { @@ -8,5 +11,13 @@ export const allPlugins = { }, 'broken-engine': brokenEnginePlugin, fakecoin: fakeCurrencyPlugin, + tulipcoin: makeFakeCurrencyPlugin({ + assetDisplayName: 'Tulip Coin', + chainDisplayName: 'Tulip Chain', + currencyCode: 'TULIP', + displayName: 'Tulip Coin', + pluginId: 'tulipcoin', + walletType: 'wallet:tulipcoin' + }), fakeswap: fakeSwapPlugin }