diff --git a/CHANGELOG.md b/CHANGELOG.md index 14a0acb68a8..d9721ee351e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased (develop) +- added: eCash (ECX) currency info, off unless the plugin config enables it, and a warning on a Bitcoin wallet until the eCash wallet split from it confirms its replay protection transaction +- added: Confirmation before splitting a Bitcoin wallet into eCash (ECX), explaining the replay protection transaction and its network fee +- fixed: Exchange info, gift card info, Houdini chain, and Zcash migration status refreshes running twice per interval + ## 4.53.0 (staging) - added: `AGENT_TEST_MODE` config setting and iOS debug-build `EdgeTestAnimations` switch that quiet the app for automated UI tests diff --git a/eslint.config.mjs b/eslint.config.mjs index 6fa92c0107c..19b66d5a57a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -411,7 +411,6 @@ export default [ 'src/hooks/useLayoutOnce.ts', 'src/hooks/useMount.ts', - 'src/hooks/useRefresher.ts', 'src/hooks/useScrollToEnd.ts', 'src/hooks/useSharedEvent.ts', 'src/hooks/useTokenDisplayData.ts', diff --git a/src/__tests__/hooks/useRefresher.test.ts b/src/__tests__/hooks/useRefresher.test.ts new file mode 100644 index 00000000000..1431a1826eb --- /dev/null +++ b/src/__tests__/hooks/useRefresher.test.ts @@ -0,0 +1,75 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest +} from '@jest/globals' +import { act, renderHook } from '@testing-library/react-native' + +import { useRefresher } from '../../hooks/useRefresher' + +describe('useRefresher', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + + afterEach(() => { + jest.useRealTimers() + }) + + const advance = async (ms: number): Promise => { + await act(async () => { + await jest.advanceTimersByTimeAsync(ms) + }) + } + + it('refreshes right away and then once per delay without a default value', async () => { + const refresh = jest.fn(async () => 1) + const rendered = renderHook(() => useRefresher(refresh, undefined, 1000)) + await advance(0) + expect(refresh).toHaveBeenCalledTimes(1) + + await advance(1000) + expect(refresh).toHaveBeenCalledTimes(2) + await advance(1000) + expect(refresh).toHaveBeenCalledTimes(3) + rendered.unmount() + }) + + it('waits one delay before refreshing with a default value', async () => { + const refresh = jest.fn(async () => 1) + const rendered = renderHook(() => useRefresher(refresh, 0, 1000)) + await advance(999) + expect(refresh).not.toHaveBeenCalled() + + await advance(1) + expect(refresh).toHaveBeenCalledTimes(1) + expect(rendered.result.current).toBe(1) + rendered.unmount() + }) + + it('stops refreshing once a value is final', async () => { + let count = 0 + const refresh = jest.fn(async () => ++count) + const isFinal = (value: number | undefined): boolean => value === 2 + const rendered = renderHook(() => + useRefresher(refresh, undefined, 1000, isFinal) + ) + await advance(5000) + expect(refresh).toHaveBeenCalledTimes(2) + expect(rendered.result.current).toBe(2) + rendered.unmount() + }) + + it('stops refreshing once unmounted', async () => { + const refresh = jest.fn(async () => 1) + const rendered = renderHook(() => useRefresher(refresh, undefined, 1000)) + await advance(0) + rendered.unmount() + + await advance(5000) + expect(refresh).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/__tests__/scenes/CreateWalletEditNameScene.test.tsx b/src/__tests__/scenes/CreateWalletEditNameScene.test.tsx index 10e90e1dcd2..a94c034498f 100644 --- a/src/__tests__/scenes/CreateWalletEditNameScene.test.tsx +++ b/src/__tests__/scenes/CreateWalletEditNameScene.test.tsx @@ -1,8 +1,11 @@ -import { describe, expect, it } from '@jest/globals' -import { render } from '@testing-library/react-native' +import { afterEach, describe, expect, it, jest } from '@jest/globals' +import { act, fireEvent, render } from '@testing-library/react-native' import * as React from 'react' import { CreateWalletEditNameScene } from '../../components/scenes/CreateWalletEditNameScene' +import { Airship } from '../../components/services/AirshipInstance' +import { lstrings } from '../../locales/strings' +import { fakeAirshipBridge } from '../../util/fake/fakeAirshipBridge' import { btcCurrencyInfo } from '../../util/fake/fakeBtcInfo' import { makeFakeCurrencyConfig } from '../../util/fake/fakeCurrencyConfig' import { ethCurrencyInfo } from '../../util/fake/fakeEthInfo' @@ -86,4 +89,110 @@ describe('CreateWalletEditNameComponent', () => { expect(rendered.toJSON()).toMatchSnapshot() rendered.unmount() }) + + describe('splitting', () => { + const SOURCE_WALLET_ID = 'btc-wallet-id' + + const renderSplit = ( + pluginId: string + ): { + rendered: ReturnType + split: jest.Mock<() => Promise<[]>> + } => { + const split = jest.fn(async (): Promise<[]> => []) + const splitState: FakeState = { + ui: { settings: { defaultIsoFiat: 'USD' } }, + core: { + account: { + currencyConfig: { + [pluginId]: makeFakeCurrencyConfig({ + ...btcCurrencyInfo, + pluginId, + walletType: `wallet:${pluginId}` + }) + }, + currencyWallets: { + [SOURCE_WALLET_ID]: { + name: 'My Bitcoin', + currencyInfo: btcCurrencyInfo, + fiatCurrencyCode: 'iso:USD', + split + } + }, + watch: () => () => {} + } + } + } + const rendered = render( + + + + ) + return { rendered, split } + } + + const pressSplit = async ( + rendered: ReturnType + ): Promise => { + await act(async () => { + fireEvent.press(rendered.getByTestId('splitWalletConfirmButton')) + }) + } + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('splits into eCash once the user confirms', async () => { + const showModal = jest.spyOn(Airship, 'show').mockResolvedValue('split') + const { rendered, split } = renderSplit('ecashbitcoin') + + await pressSplit(rendered) + + const [renderModal] = showModal.mock.calls[0] + const modal = renderModal(fakeAirshipBridge) + if (!React.isValidElement<{ title: string }>(modal)) { + throw new Error('Expected the eCash split modal') + } + expect(modal.props.title).toBe(lstrings.ecash_split_confirm_title) + expect(split).toHaveBeenCalledTimes(1) + rendered.unmount() + }) + + it('does not split into eCash when the user cancels', async () => { + jest.spyOn(Airship, 'show').mockResolvedValue(undefined) + const { rendered, split } = renderSplit('ecashbitcoin') + + await pressSplit(rendered) + + expect(split).not.toHaveBeenCalled() + rendered.unmount() + }) + + it('splits into other assets without asking', async () => { + const showModal = jest.spyOn(Airship, 'show') + const { rendered, split } = renderSplit('bitcoincash') + + await pressSplit(rendered) + + expect(showModal).not.toHaveBeenCalled() + expect(split).toHaveBeenCalledTimes(1) + rendered.unmount() + }) + }) }) diff --git a/src/__tests__/util/corePlugins.test.ts b/src/__tests__/util/corePlugins.test.ts index 45830d07e6c..4ed87185570 100644 --- a/src/__tests__/util/corePlugins.test.ts +++ b/src/__tests__/util/corePlugins.test.ts @@ -21,6 +21,8 @@ describe('corePlugins', () => { 'dogecoin', 'eboost', 'ecash', + 'ecashbitcoin', + 'ecashbitcoinbetanet', 'feathercoin', 'groestlcoin', 'litecoin', diff --git a/src/__tests__/util/ecashSplit.test.ts b/src/__tests__/util/ecashSplit.test.ts new file mode 100644 index 00000000000..ed7a0bd9903 --- /dev/null +++ b/src/__tests__/util/ecashSplit.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from '@jest/globals' +import type { + EdgeAccount, + EdgeCurrencyWallet, + EdgeTransaction +} from 'edge-core-js' + +import { getEcashSplitStatus } from '../../util/ecashSplit' + +const sharedKeys = { publicKeys: { bip49: 'ypubShared', bip84: 'zpubShared' } } +const otherKeys = { publicKeys: { bip49: 'ypubOther', bip84: 'zpubOther' } } + +interface FakeWalletOptions { + keys?: object + // The EcxProtection.json edge-core-js saved, if any: + protection?: object + transactions?: Array> + onStreamClosed?: () => void +} + +const makeWallet = ( + pluginId: string, + opts: FakeWalletOptions = {} +): EdgeCurrencyWallet => { + const { + keys = sharedKeys, + protection, + transactions = [], + onStreamClosed + } = opts + return { + currencyInfo: { pluginId }, + disklet: { + getText: async (path: string) => { + if (path !== 'EcxProtection.json' || protection == null) { + throw new Error(`Cannot load "${path}"`) + } + return JSON.stringify(protection) + } + }, + id: pluginId, + publicWalletInfo: { id: pluginId, type: `wallet:${pluginId}`, keys }, + streamTransactions: async function* () { + try { + yield transactions + } finally { + onStreamClosed?.() + } + } + } as unknown as EdgeCurrencyWallet +} + +const makeAccount = (wallets: EdgeCurrencyWallet[]): EdgeAccount => + ({ + currencyWallets: Object.fromEntries( + wallets.map(wallet => [wallet.id, wallet]) + ) + } as unknown as EdgeAccount) + +const sent = { status: 'sent', txid: 'sweep' } + +describe('getEcashSplitStatus', () => { + const bitcoinWallet = makeWallet('bitcoin') + + it('keeps checking Bitcoin wallets without an eCash split', async () => { + const account = makeAccount([bitcoinWallet]) + expect(await getEcashSplitStatus(account, bitcoinWallet)).toBe('clear') + }) + + it('keeps checking eCash wallets core never protected', async () => { + const account = makeAccount([bitcoinWallet, makeWallet('ecashbitcoin')]) + expect(await getEcashSplitStatus(account, bitcoinWallet)).toBe('clear') + }) + + it('warns while the sweep has not gone out', async () => { + // Right after a split, before the new wallet syncs its balance: + const ecashWallet = makeWallet('ecashbitcoin', { + protection: { status: 'pending' } + }) + const account = makeAccount([bitcoinWallet, ecashWallet]) + expect(await getEcashSplitStatus(account, bitcoinWallet)).toBe('unsent') + }) + + it('warns while the sweep is unconfirmed', async () => { + const ecashWallet = makeWallet('ecashbitcoinbetanet', { + protection: sent, + transactions: [{ txid: 'sweep', confirmations: 'unconfirmed' }] + }) + const account = makeAccount([bitcoinWallet, ecashWallet]) + expect(await getEcashSplitStatus(account, bitcoinWallet)).toBe( + 'unconfirmed' + ) + }) + + it('finds the sweep by its txid', async () => { + const ecashWallet = makeWallet('ecashbitcoin', { + protection: sent, + transactions: [{ txid: 'other', confirmations: 'confirmed' }] + }) + const account = makeAccount([bitcoinWallet, ecashWallet]) + expect(await getEcashSplitStatus(account, bitcoinWallet)).toBe( + 'unconfirmed' + ) + }) + + it('settles once the sweep confirms', async () => { + let isStreamClosed = false + const ecashWallet = makeWallet('ecashbitcoin', { + protection: sent, + transactions: [{ txid: 'sweep', confirmations: 'confirmed' }], + onStreamClosed: () => { + isStreamClosed = true + } + }) + const account = makeAccount([bitcoinWallet, ecashWallet]) + expect(await getEcashSplitStatus(account, bitcoinWallet)).toBe('settled') + expect(isStreamClosed).toBe(true) + }) + + it('settles wallets that had nothing to sweep', async () => { + const ecashWallet = makeWallet('ecashbitcoin', { + protection: { status: 'empty' } + }) + const account = makeAccount([bitcoinWallet, ecashWallet]) + expect(await getEcashSplitStatus(account, bitcoinWallet)).toBe('settled') + }) + + it('warns about an unsent sweep ahead of an unconfirmed one', async () => { + const ecashWallet = makeWallet('ecashbitcoin', { + protection: sent, + transactions: [{ txid: 'sweep', confirmations: 'unconfirmed' }] + }) + const betanetWallet = makeWallet('ecashbitcoinbetanet', { + protection: { status: 'pending' } + }) + const account = makeAccount([bitcoinWallet, ecashWallet, betanetWallet]) + expect(await getEcashSplitStatus(account, bitcoinWallet)).toBe('unsent') + }) + + it('keeps checking until every eCash wallet is protected', async () => { + const ecashWallet = makeWallet('ecashbitcoin', { + protection: { status: 'empty' } + }) + const betanetWallet = makeWallet('ecashbitcoinbetanet') + const account = makeAccount([bitcoinWallet, ecashWallet, betanetWallet]) + expect(await getEcashSplitStatus(account, bitcoinWallet)).toBe('clear') + }) + + it('ignores eCash wallets from another seed', async () => { + const ecashWallet = makeWallet('ecashbitcoin', { + keys: otherKeys, + protection: { status: 'pending' } + }) + const account = makeAccount([bitcoinWallet, ecashWallet]) + expect(await getEcashSplitStatus(account, bitcoinWallet)).toBe('clear') + }) + + it('settles wallets other than Bitcoin', async () => { + const litecoinWallet = makeWallet('litecoin') + const ecashWallet = makeWallet('ecashbitcoin', { + protection: { status: 'pending' } + }) + const account = makeAccount([litecoinWallet, ecashWallet]) + expect(await getEcashSplitStatus(account, litecoinWallet)).toBe('settled') + }) +}) diff --git a/src/components/cards/EcashSplitWarningCard.tsx b/src/components/cards/EcashSplitWarningCard.tsx new file mode 100644 index 00000000000..5ee06d76d18 --- /dev/null +++ b/src/components/cards/EcashSplitWarningCard.tsx @@ -0,0 +1,38 @@ +import type { EdgeCurrencyWallet } from 'edge-core-js' +import * as React from 'react' + +import { useEcashSplitPending } from '../../hooks/useEcashSplitPending' +import { lstrings } from '../../locales/strings' +import { useSelector } from '../../types/reactRedux' +import { AlertCardUi4 } from './AlertCard' + +interface Props { + wallet: EdgeCurrencyWallet +} + +/** + * Warns on a Bitcoin wallet while an eCash (ECX) wallet split from it has not + * confirmed its replay protection sweep. Until then, a Bitcoin send can be + * replayed on ECX and move the matching ECX too. + * + * Renders nothing for other wallets, so shared scenes can mount it as-is. + */ +export const EcashSplitWarningCard: React.FC = props => { + const { wallet } = props + const account = useSelector(state => state.core.account) + const pending = useEcashSplitPending(account, wallet) + + if (pending == null) return null + return ( + + ) +} diff --git a/src/components/modals/WalletListMenuModal.tsx b/src/components/modals/WalletListMenuModal.tsx index 622bf2c9ad6..de5f0fa4c3d 100644 --- a/src/components/modals/WalletListMenuModal.tsx +++ b/src/components/modals/WalletListMenuModal.tsx @@ -2,7 +2,6 @@ import type { EdgeTokenId } from 'edge-core-js' import React from 'react' import { ActivityIndicator, View } from 'react-native' import type { AirshipBridge } from 'react-native-airship' -import AntDesignIcon from 'react-native-vector-icons/AntDesign' import { walletListMenuAction, @@ -27,6 +26,7 @@ import { showError } from '../services/AirshipInstance' import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' import { UnscaledText } from '../text/UnscaledText' import { ModalTitle } from '../themed/ModalParts' +import { VectorIcon } from '../themed/VectorIcon' import { EdgeModal } from './EdgeModal' interface Option { @@ -111,7 +111,9 @@ export const WALLET_LIST_MENU: Array<{ 'telos', 'ufo', 'vertcoin', - 'wax' + 'wax', + 'ecashbitcoin', + 'ecashbitcoinbetanet' ], label: lstrings.fragment_wallets_view_xpub, value: 'viewXPub' @@ -132,6 +134,8 @@ export const WALLET_LIST_MENU: Array<{ 'dogecoin', 'eboost', 'ecash', + 'ecashbitcoin', + 'ecashbitcoinbetanet', 'feathercoin', 'groestlcoin', 'litecoin', @@ -377,7 +381,9 @@ export const WalletListMenuModal: React.FC = props => { size={theme.rem(1)} /> ) : ( - = props => { const sourceWallet = account.currencyWallets[splitSourceWalletId] if (sourceWallet == null) return + // An eCash (ECX) wallet sweeps its funds to itself after the split, which + // costs a fee and leaves the Bitcoin wallet unsafe to send from until it + // confirms: + const isEcashSplit = newWalletItems.some(item => + ECASH_BITCOIN_PLUGIN_IDS.includes(item.pluginId) + ) + if (isEcashSplit) { + const choice = await Airship.show<'split' | 'cancel' | undefined>( + bridge => ( + + ) + ) + if (choice !== 'split') return + } + const splitItems = newWalletItems.map(item => ({ fiatCurrencyCode: sourceWallet.fiatCurrencyCode, name: walletNames[item.key], diff --git a/src/components/themed/TransactionListTop.tsx b/src/components/themed/TransactionListTop.tsx index 0b4fe31c5f4..529e7220ef0 100644 --- a/src/components/themed/TransactionListTop.tsx +++ b/src/components/themed/TransactionListTop.tsx @@ -69,6 +69,7 @@ import { } from '../../util/utils' import { IconButton } from '../buttons/IconButton' import { AlertCardUi4 } from '../cards/AlertCard' +import { EcashSplitWarningCard } from '../cards/EcashSplitWarningCard' import { EdgeCard } from '../cards/EdgeCard' import { LargeUtxoWalletCard } from '../cards/LargeUtxoWalletCard' import { VisaCardCard } from '../cards/VisaCardCard' @@ -927,6 +928,7 @@ export const TransactionListTop: React.FC = props => { ) : null} + {renderZcashMigrationCard()} {renderButtons()} diff --git a/src/constants/WalletAndCurrencyConstants.ts b/src/constants/WalletAndCurrencyConstants.ts index a320f966bd3..3e672a7c3cc 100644 --- a/src/constants/WalletAndCurrencyConstants.ts +++ b/src/constants/WalletAndCurrencyConstants.ts @@ -84,6 +84,8 @@ export const CURRENCY_SETTINGS_KEYS = [ 'digibyte', 'dogecoin', 'eboost', + 'ecashbitcoin', + 'ecashbitcoinbetanet', 'ethDev', 'ethereum', 'ethereumclassic', @@ -179,7 +181,9 @@ export const WALLET_TYPE_ORDER = [ 'wallet:opbnb', 'wallet:nym', 'wallet:robinhood', - 'wallet:arc' + 'wallet:arc', + 'wallet:ecashbitcoin', + 'wallet:ecashbitcoinbetanet' ] export interface WalletSettingOption { @@ -559,6 +563,18 @@ export const SPECIAL_CURRENCY_INFO: Record = { isStakingSupported: false, unstoppableDomainsTicker: 'ECASH' }, + ecashbitcoin: { + hasSegwit: true, + maxSpendTargets: UTXO_MAX_SPEND_TARGETS, + initWalletName: lstrings.string_first_ecash_bitcoin_wallet_name, + isImportKeySupported: true + }, + ecashbitcoinbetanet: { + hasSegwit: true, + maxSpendTargets: UTXO_MAX_SPEND_TARGETS, + initWalletName: lstrings.string_first_ecash_bitcoin_betanet_wallet_name, + isImportKeySupported: true + }, ethereum: { initWalletName: lstrings.string_first_ethereum_wallet_name, dummyPublicAddress: '0x0d73358506663d484945ba85d0cd435ad610b0a0', diff --git a/src/hooks/useEcashSplitPending.ts b/src/hooks/useEcashSplitPending.ts new file mode 100644 index 00000000000..aa4f630f8d9 --- /dev/null +++ b/src/hooks/useEcashSplitPending.ts @@ -0,0 +1,32 @@ +import type { EdgeAccount, EdgeCurrencyWallet } from 'edge-core-js' +import * as React from 'react' + +import { type EcashSplitStatus, getEcashSplitStatus } from '../util/ecashSplit' +import { useRefresher } from './useRefresher' + +/** + * Polls whether an eCash (ECX) wallet split from a Bitcoin wallet is still + * waiting on its protection sweep, until the status can no longer change. + * Returns undefined for anything that is not a Bitcoin wallet, so it is safe + * to call unconditionally from shared scenes. + */ +export function useEcashSplitPending( + account: EdgeAccount, + wallet: EdgeCurrencyWallet, + delay: number = 15000 +): 'unsent' | 'unconfirmed' | undefined { + const refresher = React.useCallback( + async () => await getEcashSplitStatus(account, wallet), + [account, wallet] + ) + const status = useRefresher( + refresher, + undefined, + delay, + isSettled + ) + return status === 'unsent' || status === 'unconfirmed' ? status : undefined +} + +const isSettled = (status: EcashSplitStatus | undefined): boolean => + status === 'settled' diff --git a/src/hooks/useRefresher.ts b/src/hooks/useRefresher.ts index b4137730ae1..2bd068688cb 100644 --- a/src/hooks/useRefresher.ts +++ b/src/hooks/useRefresher.ts @@ -5,32 +5,36 @@ import { showError } from '../components/services/AirshipInstance' export function useRefresher( cb: () => Promise, defaultValue: T, - delay: number + delay: number, + /** Stops refreshing once this returns true for a value. */ + isFinal?: (value: T) => boolean ): T { const [value, setValue] = React.useState(defaultValue) React.useEffect(() => { let abort = false - const handleTimeout = () => { + let timerId: ReturnType | undefined + const handleTimeout = (): void => { cb() .then(value => { if (abort) return setValue(value) + if (isFinal?.(value) === true) return timerId = setTimeout(handleTimeout, delay) }) - .catch(err => { + .catch((err: unknown) => { showError(err) }) } - let timerId = setTimeout(handleTimeout, delay) - + // Without a default value to show, the first refresh runs right away: if (defaultValue == null) handleTimeout() + else timerId = setTimeout(handleTimeout, delay) return () => { abort = true clearTimeout(timerId) } - }, [cb, delay, defaultValue]) + }, [cb, delay, defaultValue, isFinal]) return value } diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 25bc915c768..f42a645459a 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -489,6 +489,14 @@ const strings = { percent_complete_1s: '%1$s Complete', sync_status_title: 'Sync in Progress', sync_status_blocks: 'Syncing blocks %1$s/%2$s', + ecash_split_confirm_title: 'Split to eCash', + ecash_split_confirm_message: + "This creates an eCash (ECX) wallet from this Bitcoin wallet's keys. Once it syncs, Edge sends any ECX to itself in a replay protection transaction, so your BTC and ECX can be spent separately. This costs a small ECX network fee. Until that transaction confirms, sending from this Bitcoin wallet can also move your ECX.", + ecash_split_pending_title: 'eCash Split Pending', + ecash_split_pending_body: + 'An eCash (ECX) wallet split from this wallet is still separating its coins. Until its replay protection transaction confirms, sending from this wallet can also move your ECX. Wait for it to confirm before sending.', + ecash_split_unsent_body: + 'An eCash (ECX) wallet split from this wallet has not sent its replay protection transaction yet. Edge sends it once that wallet finishes syncing. Until it confirms, sending from this wallet can also move your ECX.', large_utxo_wallet_title: 'Large Tx History', large_utxo_wallet_body: 'Syncing may take a while. Keep this wallet open, as the balance may be inaccurate until it finishes.', @@ -779,6 +787,8 @@ const strings = { string_first_dash_wallet_name: 'My Dash', string_first_digibyte_wallet_name: 'My DigiByte', string_first_ecash_wallet_name: 'My eCash', + string_first_ecash_bitcoin_wallet_name: 'My eCash (ECX)', + string_first_ecash_bitcoin_betanet_wallet_name: 'My eCash Betanet', string_first_eos_wallet_name: 'My EOS', string_first_holesky_wallet_name: 'My Holesky', string_first_hyperevm_wallet_name: 'My HyperEVM', diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index 5a77203dfe7..910bf57a7a3 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -338,6 +338,11 @@ "percent_complete_1s": "%1$s Complete", "sync_status_title": "Sync in Progress", "sync_status_blocks": "Syncing blocks %1$s/%2$s", + "ecash_split_confirm_title": "Split to eCash", + "ecash_split_confirm_message": "This creates an eCash (ECX) wallet from this Bitcoin wallet's keys. Once it syncs, Edge sends any ECX to itself in a replay protection transaction, so your BTC and ECX can be spent separately. This costs a small ECX network fee. Until that transaction confirms, sending from this Bitcoin wallet can also move your ECX.", + "ecash_split_pending_title": "eCash Split Pending", + "ecash_split_pending_body": "An eCash (ECX) wallet split from this wallet is still separating its coins. Until its replay protection transaction confirms, sending from this wallet can also move your ECX. Wait for it to confirm before sending.", + "ecash_split_unsent_body": "An eCash (ECX) wallet split from this wallet has not sent its replay protection transaction yet. Edge sends it once that wallet finishes syncing. Until it confirms, sending from this wallet can also move your ECX.", "large_utxo_wallet_title": "Large Tx History", "large_utxo_wallet_body": "Syncing may take a while. Keep this wallet open, as the balance may be inaccurate until it finishes.", "validating": "Validating…", @@ -589,6 +594,8 @@ "string_first_dash_wallet_name": "My Dash", "string_first_digibyte_wallet_name": "My DigiByte", "string_first_ecash_wallet_name": "My eCash", + "string_first_ecash_bitcoin_wallet_name": "My eCash (ECX)", + "string_first_ecash_bitcoin_betanet_wallet_name": "My eCash Betanet", "string_first_eos_wallet_name": "My EOS", "string_first_holesky_wallet_name": "My Holesky", "string_first_hyperevm_wallet_name": "My HyperEVM", diff --git a/src/selectors/getCreateWalletList.ts b/src/selectors/getCreateWalletList.ts index bb8255d44b1..2dc3c34dc6e 100644 --- a/src/selectors/getCreateWalletList.ts +++ b/src/selectors/getCreateWalletList.ts @@ -78,7 +78,12 @@ export const getCreateWalletList = ( excludeAssets = [], disableLegacy = false } = opts - const segwitSpecialCases = new Set(['bitcoin', 'litecoin', 'digibyte']) + const segwitSpecialCases = new Set([ + 'bitcoin', + 'litecoin', + 'digibyte', + 'ecashbitcoin' + ]) const existingWalletsMap = new Map>() for (const item of filteredWalletList) { if (item.type !== 'asset') continue diff --git a/src/util/corePlugins.ts b/src/util/corePlugins.ts index eb49fa65748..68147194788 100644 --- a/src/util/corePlugins.ts +++ b/src/util/corePlugins.ts @@ -1,5 +1,6 @@ import type { EdgeCorePluginsInit, JsonObject } from 'edge-core-js' +import { CONFIG } from '../config' import { pluginMaps } from '../pluginMaps' // Read a plugin init from the pluginMaps map, falling back to a default @@ -95,6 +96,11 @@ function buildUtxoPlugins(): EdgeCorePluginsInit { dogecoin: coreInit('dogecoin'), eboost: true, ecash: coreInit('ecash'), + ecashbitcoin: coreInit('ecashbitcoin'), + // A test network, so only beta builds offer it, even as a Bitcoin split: + ecashbitcoinbetanet: CONFIG.BETA_FEATURES + ? coreInit('ecashbitcoinbetanet') + : false, feathercoin: true, groestlcoin: coreInit('groestlcoin'), litecoin: coreInit('litecoin'), diff --git a/src/util/ecashSplit.ts b/src/util/ecashSplit.ts new file mode 100644 index 00000000000..a8cd858bdc7 --- /dev/null +++ b/src/util/ecashSplit.ts @@ -0,0 +1,119 @@ +import { + asJSON, + asMaybe, + asObject, + asOptional, + asString, + asValue +} from 'cleaners' +import type { EdgeAccount, EdgeCurrencyWallet } from 'edge-core-js' + +// +// Splitting a Bitcoin wallet into eCash (ECX) makes edge-core-js sweep the new +// ECX wallet to itself. Until that sweep confirms, both wallets still share +// UTXOs, so a Bitcoin send can be replayed on ECX and move the ECX as well. +// + +/** The eCash (ECX) plugins whose wallets split from Bitcoin wallets. */ +export const ECASH_BITCOIN_PLUGIN_IDS = ['ecashbitcoin', 'ecashbitcoinbetanet'] + +/** + * The file edge-core-js keeps in a split ECX wallet's synced storage: + * `pending` until the sweep goes out, then `sent` with its txid, or `empty` + * when the wallet had nothing to sweep. + */ +const PROTECTION_FILE = 'EcxProtection.json' +const asProtectionFile = asJSON( + asObject({ + status: asValue('pending', 'sent', 'empty'), + txid: asOptional(asString) + }) +) + +const asUtxoPublicKeys = asObject({ + publicKeys: asObject(asOptional(asString)) +}) + +/** + * Whether a Bitcoin send from a wallet can be replayed on an eCash (ECX) + * wallet split from it: + * - `unsent`: a split wallet has not sent its protection sweep yet. + * - `unconfirmed`: a split wallet's protection sweep has not confirmed. + * - `clear`: nothing is pending, though a new split can change that. + * - `settled`: nothing can become pending until the wallet splits again, + * because it is not a Bitcoin wallet or every split wallet is protected. + */ +export type EcashSplitStatus = 'unsent' | 'unconfirmed' | 'clear' | 'settled' + +export async function getEcashSplitStatus( + account: EdgeAccount, + wallet: EdgeCurrencyWallet +): Promise { + if (wallet.currencyInfo.pluginId !== 'bitcoin') return 'settled' + const xpubs = getXpubs(wallet) + if (xpubs.length === 0) return 'settled' + + const statuses: EcashSplitStatus[] = [] + for (const ecashWallet of Object.values(account.currencyWallets)) { + const { pluginId } = ecashWallet.currencyInfo + if (!ECASH_BITCOIN_PLUGIN_IDS.includes(pluginId)) continue + + // A split wallet derives the same xpubs as the wallet it came from: + if (!getXpubs(ecashWallet).some(xpub => xpubs.includes(xpub))) continue + statuses.push(await getSweepStatus(ecashWallet)) + } + + if (statuses.includes('unsent')) return 'unsent' + if (statuses.includes('unconfirmed')) return 'unconfirmed' + return statuses.length > 0 && statuses.every(status => status === 'settled') + ? 'settled' + : 'clear' +} + +async function getSweepStatus( + wallet: EdgeCurrencyWallet +): Promise { + const protection = await loadProtectionFile(wallet) + + // An ECX wallet that did not split from Bitcoin has nothing to sweep: + if (protection == null) return 'clear' + if (protection.status === 'empty') return 'settled' + if (protection.status === 'pending' || protection.txid == null) { + return 'unsent' + } + + const { txid } = protection + const stream = wallet.streamTransactions({ + tokenId: null, + searchString: txid + }) + // Leaving the loop early closes the stream over the core bridge: + for await (const transactions of stream) { + const sweep = transactions.find(transaction => transaction.txid === txid) + if (sweep != null) { + return sweep.confirmations === 'confirmed' ? 'settled' : 'unconfirmed' + } + } + return 'unconfirmed' +} + +async function loadProtectionFile( + wallet: EdgeCurrencyWallet +): Promise | undefined> { + try { + return asMaybe(asProtectionFile)( + await wallet.disklet.getText(PROTECTION_FILE) + ) + } catch (error: unknown) { + // A wallet edge-core-js never protected has no file: + return undefined + } +} + +function getXpubs(wallet: EdgeCurrencyWallet): string[] { + const keys = asMaybe(asUtxoPublicKeys)(wallet.publicWalletInfo.keys) + if (keys == null) return [] + return Object.values(keys.publicKeys).filter( + (xpub): xpub is string => xpub != null + ) +}