Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
75 changes: 75 additions & 0 deletions src/__tests__/hooks/useRefresher.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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)
})
})
113 changes: 111 additions & 2 deletions src/__tests__/scenes/CreateWalletEditNameScene.test.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<typeof render>
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(
<FakeProviders initialState={splitState}>
<CreateWalletEditNameScene
{...fakeEdgeAppSceneProps('createWalletEditName', {
createWalletList: [
{
type: 'create',
key: `create-wallet:${pluginId}`,
currencyCode: 'ECX',
displayName: 'eCash',
pluginId,
tokenId: null,
walletType: `wallet:${pluginId}`
}
],
splitSourceWalletId: SOURCE_WALLET_ID
})}
/>
</FakeProviders>
)
return { rendered, split }
}

const pressSplit = async (
rendered: ReturnType<typeof render>
): Promise<void> => {
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()
})
})
})
2 changes: 2 additions & 0 deletions src/__tests__/util/corePlugins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ describe('corePlugins', () => {
'dogecoin',
'eboost',
'ecash',
'ecashbitcoin',
'ecashbitcoinbetanet',
'feathercoin',
'groestlcoin',
'litecoin',
Expand Down
166 changes: 166 additions & 0 deletions src/__tests__/util/ecashSplit.test.ts
Original file line number Diff line number Diff line change
@@ -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<Partial<EdgeTransaction>>
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')
})
})
Loading
Loading