diff --git a/CHANGELOG.md b/CHANGELOG.md index fc34ebe29e7..020cad20a61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased (develop) +- added: (Zcash) Guided Orchard → Ironwood (NU6.3) pool migration: privacy (scheduled transfers) and immediate paths, wallet-scene banner, notification-center card, and wallet menu entry (iOS; Android follows its SDK). + ## 4.50.0 (staging) - added: Changelly swap provider diff --git a/package-lock.json b/package-lock.json index cfa26610c0f..13377e562ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,7 +48,7 @@ "detect-bundler": "^1.1.0", "disklet": "^0.6.0", "edge-core-js": "^2.47.0", - "edge-currency-accountbased": "^4.86.0", + "edge-currency-accountbased": "https://github.com/EdgeApp/react-native-zcash/releases/download/ironwood-ffi-a9637998/edge-currency-accountbased-4.86.1-ironwood-6b67fcfa.tgz", "edge-currency-plugins": "^3.11.0", "edge-exchange-plugins": "^2.51.0", "edge-info-server": "^3.12.0", @@ -113,7 +113,7 @@ "react-native-wheel-picker-android": "^2.0.6", "react-native-worklets": "^0.6.1", "react-native-zano": "^0.3.0", - "react-native-zcash": "^0.12.2", + "react-native-zcash": "https://github.com/EdgeApp/react-native-zcash/releases/download/ironwood-ffi-a9637998/react-native-zcash-0.12.1-ironwood-a416a51.tgz", "react-redux": "^8.1.1", "redux": "^4.2.1", "redux-thunk": "^2.3.0", @@ -15407,9 +15407,9 @@ "license": "MIT" }, "node_modules/edge-currency-accountbased": { - "version": "4.86.0", - "resolved": "https://registry.npmjs.org/edge-currency-accountbased/-/edge-currency-accountbased-4.86.0.tgz", - "integrity": "sha512-/I9/XjcnBSS2c48uOmWrXVO4XLftwNPMuxwHzHB+ngE98KyVgRzh8VtMGYf1U+CLLBF+OGEQHGP/pPItol0XMw==", + "version": "4.86.1", + "resolved": "https://github.com/EdgeApp/react-native-zcash/releases/download/ironwood-ffi-a9637998/edge-currency-accountbased-4.86.1-ironwood-6b67fcfa.tgz", + "integrity": "sha512-xbA5KrXEdIq1EBTV68Lf83hWtHIlzDOM4h4XoHokQrfAztq42m9LqSXjSS9m0T1Y4KV5SLtDaRzq9b9eqpYVCA==", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@bitcoinerlab/secp256k1": "^1.2.0", @@ -15456,6 +15456,7 @@ "ethereumjs-wallet": "^0.6.5", "ethers": "^5.6.0", "isomorphic-ws": "^5.0.0", + "react-native-zcash": "https://github.com/EdgeApp/react-native-zcash/releases/download/ironwood-ffi-a9637998/react-native-zcash-0.12.1-ironwood-a416a51.tgz", "rfc4648": "^1.5.0", "stellar-sdk": "^13.3.0", "tezos-uri": "^1.0.3", @@ -15473,7 +15474,7 @@ "react-native-monero": "^0.3.0", "react-native-piratechain": "v0.5.0", "react-native-zano": "^0.2.7", - "react-native-zcash": "^0.10.1" + "react-native-zcash": "^0.12.1" } }, "node_modules/edge-currency-accountbased/node_modules/isomorphic-ws": { @@ -24546,9 +24547,9 @@ } }, "node_modules/react-native-zcash": { - "version": "0.12.2", - "resolved": "https://registry.npmjs.org/react-native-zcash/-/react-native-zcash-0.12.2.tgz", - "integrity": "sha512-Fl99JoR7d9AYX/45UxDIjHbCT0K6SefxWKLV9z3J0X3UBGQImaguC8Zd1qLDJ9Ae5pBdD6t1Oj+o5BYBp+hT7w==", + "version": "0.12.1", + "resolved": "https://github.com/EdgeApp/react-native-zcash/releases/download/ironwood-ffi-a9637998/react-native-zcash-0.12.1-ironwood-a416a51.tgz", + "integrity": "sha512-SkpPJCGQRLp8yaDysvJDL6LKezrp9wfVkZw6bZvzYGLFWC4q5vXwRI9hyrIi2y0Ng7i68dQJN8EOXXGxVkKdWw==", "license": "MIT", "dependencies": { "biggystring": "^4.2.3", diff --git a/package.json b/package.json index 7e74850f60f..27846542225 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ "detect-bundler": "^1.1.0", "disklet": "^0.6.0", "edge-core-js": "^2.47.0", - "edge-currency-accountbased": "^4.86.0", + "edge-currency-accountbased": "https://github.com/EdgeApp/react-native-zcash/releases/download/ironwood-ffi-a9637998/edge-currency-accountbased-4.86.1-ironwood-6b67fcfa.tgz", "edge-currency-plugins": "^3.11.0", "edge-exchange-plugins": "^2.51.0", "edge-info-server": "^3.12.0", @@ -170,7 +170,7 @@ "react-native-wheel-picker-android": "^2.0.6", "react-native-worklets": "^0.6.1", "react-native-zano": "^0.3.0", - "react-native-zcash": "^0.12.2", + "react-native-zcash": "https://github.com/EdgeApp/react-native-zcash/releases/download/ironwood-ffi-a9637998/react-native-zcash-0.12.1-ironwood-a416a51.tgz", "react-redux": "^8.1.1", "redux": "^4.2.1", "redux-thunk": "^2.3.0", diff --git a/src/__tests__/zcashMigration.test.ts b/src/__tests__/zcashMigration.test.ts new file mode 100644 index 00000000000..01676b67b5e --- /dev/null +++ b/src/__tests__/zcashMigration.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from '@jest/globals' +import type { EdgeCurrencyWallet } from 'edge-core-js' + +import { + asZcashMigrationPlan, + getZcashMigrationStatus +} from '../util/zcashMigration' + +const goodStatus = { + state: 'required', + completedTransfers: 0, + totalTransfers: 0, + remainingOrchardZatoshi: '123', + hasOverdueTransfers: false, + isSynced: true +} + +const makeFakeWallet = (opts: { + pluginId: string + otherMethods?: object +}): EdgeCurrencyWallet => + ({ + currencyInfo: { pluginId: opts.pluginId }, + otherMethods: opts.otherMethods ?? {} + } as any) + +describe('zcashMigration util', () => { + it('returns status for a migration-capable zcash wallet', async () => { + const wallet = makeFakeWallet({ + pluginId: 'zcash', + otherMethods: { + getMigrationStatus: async () => goodStatus + } + }) + const status = await getZcashMigrationStatus(wallet) + expect(status?.state).toBe('required') + expect(status?.remainingOrchardZatoshi).toBe('123') + }) + + it('returns undefined for non-zcash wallets', async () => { + const wallet = makeFakeWallet({ + pluginId: 'bitcoin', + otherMethods: { getMigrationStatus: async () => goodStatus } + }) + expect(await getZcashMigrationStatus(wallet)).toBeUndefined() + }) + + it('returns undefined when the engine lacks the method (old accountbased)', async () => { + const wallet = makeFakeWallet({ pluginId: 'zcash' }) + expect(await getZcashMigrationStatus(wallet)).toBeUndefined() + }) + + it('returns undefined when the engine call throws', async () => { + const wallet = makeFakeWallet({ + pluginId: 'zcash', + otherMethods: { + getMigrationStatus: async () => { + throw new Error('engine broke') + } + } + }) + expect(await getZcashMigrationStatus(wallet)).toBeUndefined() + }) + + it('returns undefined on malformed status shapes', async () => { + const wallet = makeFakeWallet({ + pluginId: 'zcash', + otherMethods: { + getMigrationStatus: async () => ({ state: 'bogus' }) + } + }) + expect(await getZcashMigrationStatus(wallet)).toBeUndefined() + }) + + it('round-trips a migration plan through its cleaner', () => { + const plan = { + strategy: 'privacy' as const, + transfers: [ + { + id: 't1', + amountZatoshi: '350000000', + nextExecutableAfterHeight: 2600100, + expiryHeight: 2610000 + } + ], + estimatedDurationHours: 24, + totalAmountZatoshi: '350000000', + noteSplitFeeZatoshi: '15000', + noteSplitRequired: true, + noteSplitProposalBase64: 'cHJvcG9zYWw=', + scheduleBase64: 'c2NoZWR1bGU=' + } + expect(asZcashMigrationPlan(plan)).toEqual(plan) + }) +}) diff --git a/src/actions/WalletListMenuActions.tsx b/src/actions/WalletListMenuActions.tsx index 41347254263..5889cdb081e 100644 --- a/src/actions/WalletListMenuActions.tsx +++ b/src/actions/WalletListMenuActions.tsx @@ -45,6 +45,7 @@ export type WalletListMenuKey = | 'getRawKeys' | 'rawDelete' | 'togglePause' + | 'ironwoodMigration' | string // for split keys like splitbitcoincash, splitethereum, etc. export function walletListMenuAction( @@ -76,6 +77,14 @@ export function walletListMenuAction( } } + case 'ironwoodMigration': { + return async (dispatch, getState) => { + navigation.navigate('zcashMigrationOptions' as any, { + walletId + }) + } + } + case 'rawDelete': { return async (dispatch, getState) => { const state = getState() diff --git a/src/components/Main.tsx b/src/components/Main.tsx index 1d67d8131e3..41c463548a5 100644 --- a/src/components/Main.tsx +++ b/src/components/Main.tsx @@ -170,6 +170,10 @@ import { WcConnectionsScene as WcConnectionsSceneComponent } from './scenes/WcCo import { WcConnectScene as WcConnectSceneComponent } from './scenes/WcConnectScene' import { WcDisconnectScene as WcDisconnectSceneComponent } from './scenes/WcDisconnectScene' import { WebViewScene as WebViewSceneComponent } from './scenes/WebViewScene' +import { ZcashMigrationInfoScene as ZcashMigrationInfoSceneComponent } from './scenes/ZcashMigrationInfoScene' +import { ZcashMigrationOptionsScene as ZcashMigrationOptionsSceneComponent } from './scenes/ZcashMigrationOptionsScene' +import { ZcashMigrationPlanConfirmScene as ZcashMigrationPlanConfirmSceneComponent } from './scenes/ZcashMigrationPlanConfirmScene' +import { ZcashMigrationStatusScene as ZcashMigrationStatusSceneComponent } from './scenes/ZcashMigrationStatusScene' import { DeepLinkingManager } from './services/DeepLinkingManager' import { useTheme } from './services/ThemeContext' import { MenuTabs } from './themed/MenuTabs' @@ -265,6 +269,14 @@ const MigrateWalletCompletionScene = ifLoggedIn( const MigrateWalletSelectCryptoScene = ifLoggedIn( MigrateWalletSelectCryptoSceneComponent ) +const ZcashMigrationInfoScene = ifLoggedIn(ZcashMigrationInfoSceneComponent) +const ZcashMigrationOptionsScene = ifLoggedIn( + ZcashMigrationOptionsSceneComponent +) +const ZcashMigrationPlanConfirmScene = ifLoggedIn( + ZcashMigrationPlanConfirmSceneComponent +) +const ZcashMigrationStatusScene = ifLoggedIn(ZcashMigrationStatusSceneComponent) const NotificationCenterScene = ifLoggedIn(NotificationCenterSceneComponent) const NotificationScene = ifLoggedIn(NotificationSceneComponent) const OtpRepairScene = ifLoggedIn(OtpRepairSceneComponent) @@ -1012,6 +1024,22 @@ const EdgeAppStack: React.FC = () => { name="migrateWalletSelectCrypto" component={MigrateWalletSelectCryptoScene} /> + + + + = props => { } }) + // Zcash Ironwood migration cards per wallet + Object.keys(wallets).forEach(walletId => { + const migrationKey = `ironwoodMigration-${walletId}` + if ( + notifState[migrationKey] != null && + !notifState[migrationKey].isBannerHidden && + !notifState[migrationKey].isCompleted + ) { + visibleIds.push(migrationKey) + } + }) + if (!otpReminder.isBannerHidden && !account.isDuressAccount) visibleIds.push('otpReminder') @@ -327,9 +340,40 @@ export const NotificationView: React.FC = props => { ) } + if (id.startsWith('ironwoodMigration-')) { + const walletId = id.slice('ironwoodMigration-'.length) + const wallet = wallets[walletId] + if (wallet == null) return null + + const handleCloseMigration = async (): Promise => { + await writeAccountNotifInfo(account, id, { isBannerHidden: true }) + } + const handlePressMigration = async (): Promise => { + navigationDebounced.navigate('zcashMigrationOptions' as any, { + walletId + }) + } + + return ( + + ) + } + return null }, [ + account, dispatch, detectedTokensRedux, handle2FaEnabledClose, diff --git a/src/components/scenes/NotificationCenterScene.tsx b/src/components/scenes/NotificationCenterScene.tsx index 43d94516533..2383c76db9f 100644 --- a/src/components/scenes/NotificationCenterScene.tsx +++ b/src/components/scenes/NotificationCenterScene.tsx @@ -15,6 +15,7 @@ import { config } from '../../theme/appConfig' import { useDispatch, useSelector } from '../../types/reactRedux' import type { EdgeAppSceneProps, NavigationBase } from '../../types/routerTypes' import { getThemedIconUri } from '../../util/CdnUris.ts' +import { getWalletName } from '../../util/CurrencyWalletHelpers' import { showOtpReminderModal } from '../../util/otpReminder.tsx' import { openBrowserUri } from '../../util/WebUtils.ts' import { SceneWrapper } from '../common/SceneWrapper' @@ -242,6 +243,31 @@ export const NotificationCenterScene = (props: Props) => { onClose={completeNotif(key)} /> ) + } else if (key.includes('ironwoodMigration')) { + const { params } = notifState[key] + if (params == null) return null + const { walletId } = params + if (walletId == null || wallets[walletId] == null) return null + const wallet = wallets[walletId] + + const handlePressMigration = async () => { + navigation.navigate('zcashMigrationOptions' as any, { walletId }) + } + + return ( + + ) } else if (key.includes('promoCard-')) { // Handle promo card notifications const { promoCard } = notifState[key].params ?? {} diff --git a/src/components/scenes/ZcashMigrationInfoScene.tsx b/src/components/scenes/ZcashMigrationInfoScene.tsx new file mode 100644 index 00000000000..03cdc9454a4 --- /dev/null +++ b/src/components/scenes/ZcashMigrationInfoScene.tsx @@ -0,0 +1,63 @@ +import * as React from 'react' + +import { useHandler } from '../../hooks/useHandler' +import { lstrings } from '../../locales/strings' +import type { EdgeAppSceneProps } from '../../types/routerTypes' +import { SceneButtons } from '../buttons/SceneButtons' +import { EdgeCard } from '../cards/EdgeCard' +import { SceneWrapper } from '../common/SceneWrapper' +import { useTheme } from '../services/ThemeContext' +import { EdgeText } from '../themed/EdgeText' +import { SceneHeader } from '../themed/SceneHeader' + +export interface ZcashMigrationInfoParams { + walletId: string +} + +interface Props extends EdgeAppSceneProps<'zcashMigrationInfo'> {} + +/** + * "How This Works" explainer for the privacy (scheduled) migration path. + */ +export const ZcashMigrationInfoScene: React.FC = props => { + const { navigation, route } = props + const { walletId } = route.params + const theme = useTheme() + + const handleNext = useHandler(() => { + navigation.navigate('zcashMigrationPlanConfirm', { + walletId, + strategy: 'privacy' + }) + }) + + const paragraphs = [ + lstrings.zcash_migration_how_split, + lstrings.zcash_migration_how_presign, + lstrings.zcash_migration_how_execute, + lstrings.zcash_migration_how_failures + ] + + return ( + + + {paragraphs.map(text => ( + + + {text} + + + ))} + + + ) +} diff --git a/src/components/scenes/ZcashMigrationOptionsScene.tsx b/src/components/scenes/ZcashMigrationOptionsScene.tsx new file mode 100644 index 00000000000..f5b239856d5 --- /dev/null +++ b/src/components/scenes/ZcashMigrationOptionsScene.tsx @@ -0,0 +1,131 @@ +import * as React from 'react' +import { View } from 'react-native' +import IonIcon from 'react-native-vector-icons/Ionicons' + +import { useHandler } from '../../hooks/useHandler' +import { useZcashMigrationStatus } from '../../hooks/useZcashMigrationStatus' +import { lstrings } from '../../locales/strings' +import { useSelector } from '../../types/reactRedux' +import type { EdgeAppSceneProps } from '../../types/routerTypes' +import type { ZcashMigrationStrategy } from '../../util/zcashMigration' +import { SceneButtons } from '../buttons/SceneButtons' +import { AlertCardUi4 } from '../cards/AlertCard' +import { EdgeCard } from '../cards/EdgeCard' +import { SceneWrapper } from '../common/SceneWrapper' +import { useTheme } from '../services/ThemeContext' +import { EdgeText } from '../themed/EdgeText' +import { SceneHeader } from '../themed/SceneHeader' + +export interface ZcashMigrationOptionsParams { + walletId: string +} + +interface Props extends EdgeAppSceneProps<'zcashMigrationOptions'> {} + +/** + * Entry scene for the Orchard -> Ironwood migration: pick between the + * privacy (scheduled) path and the immediate (single-transfer) path. + */ +export const ZcashMigrationOptionsScene: React.FC = props => { + const { navigation, route } = props + const { walletId } = route.params + const theme = useTheme() + + const account = useSelector(state => state.core.account) + const wallet = account.currencyWallets[walletId] + const status = useZcashMigrationStatus(wallet, 5000) + + const [strategy, setStrategy] = + React.useState('privacy') + + // A migration is already in motion (or broken): show progress instead. + React.useEffect(() => { + if (status?.state === 'scheduled' || status?.state === 'error') { + navigation.replace('zcashMigrationStatus', { walletId }) + } + }, [navigation, status?.state, walletId]) + + const handleNext = useHandler(() => { + if (strategy === 'privacy') { + navigation.navigate('zcashMigrationInfo', { walletId }) + } else { + navigation.navigate('zcashMigrationPlanConfirm', { + walletId, + strategy: 'immediate' + }) + } + }) + + const renderOption = ( + optionStrategy: ZcashMigrationStrategy, + title: string, + subtitle: string + ): React.ReactElement => { + const isSelected = strategy === optionStrategy + return ( + { + setStrategy(optionStrategy) + }} + > + + + + {title} + + {subtitle} + + + + + ) + } + + return ( + + + + {lstrings.zcash_migration_required_body} + + {renderOption( + 'privacy', + lstrings.zcash_migration_option_privacy_title, + lstrings.zcash_migration_option_privacy_subtitle + )} + {renderOption( + 'immediate', + lstrings.zcash_migration_option_immediate_title, + lstrings.zcash_migration_option_immediate_subtitle + )} + {strategy === 'immediate' ? ( + + ) : null} + + + ) +} diff --git a/src/components/scenes/ZcashMigrationPlanConfirmScene.tsx b/src/components/scenes/ZcashMigrationPlanConfirmScene.tsx new file mode 100644 index 00000000000..4b7821163a5 --- /dev/null +++ b/src/components/scenes/ZcashMigrationPlanConfirmScene.tsx @@ -0,0 +1,162 @@ +import { div } from 'biggystring' +import * as React from 'react' +import { ActivityIndicator, View } from 'react-native' +import { sprintf } from 'sprintf-js' + +import { useAsyncEffect } from '../../hooks/useAsyncEffect' +import { useHandler } from '../../hooks/useHandler' +import { useWatch } from '../../hooks/useWatch' +import { lstrings } from '../../locales/strings' +import { useSelector } from '../../types/reactRedux' +import type { EdgeAppSceneProps } from '../../types/routerTypes' +import { + confirmZcashMigrationPlan, + prepareZcashMigrationPlan, + type ZcashMigrationPlan, + type ZcashMigrationStrategy +} from '../../util/zcashMigration' +import { SceneButtons } from '../buttons/SceneButtons' +import { AlertCardUi4 } from '../cards/AlertCard' +import { EdgeCard } from '../cards/EdgeCard' +import { SceneWrapper } from '../common/SceneWrapper' +import { EdgeRow } from '../rows/EdgeRow' +import { showError } from '../services/AirshipInstance' +import { useTheme } from '../services/ThemeContext' +import { EdgeText } from '../themed/EdgeText' +import { SceneHeader } from '../themed/SceneHeader' + +export interface ZcashMigrationPlanConfirmParams { + walletId: string + strategy: ZcashMigrationStrategy +} + +interface Props extends EdgeAppSceneProps<'zcashMigrationPlanConfirm'> {} + +/** Zcash always uses 8 decimals (1 ZEC = 1e8 zatoshi). */ +const ZEC_MULTIPLIER = '100000000' +const AVERAGE_BLOCK_SECONDS = 75 + +const displayZec = (zatoshi: string): string => + `${div(zatoshi, ZEC_MULTIPLIER, 8)} ZEC` + +/** + * Shows the proposed migration plan (transfer list, totals, fees, duration) + * for a one-time confirmation, then submits it: note split (when required), + * pre-sign + store the schedule, and for the immediate path broadcast now. + */ +export const ZcashMigrationPlanConfirmScene: React.FC = props => { + const { navigation, route } = props + const { walletId, strategy } = route.params + const theme = useTheme() + + const account = useSelector(state => state.core.account) + const wallet = account.currencyWallets[walletId] + const blockHeight = useWatch(wallet, 'blockHeight') + + const [plan, setPlan] = React.useState() + const [confirming, setConfirming] = React.useState(false) + + useAsyncEffect( + async () => { + const newPlan = await prepareZcashMigrationPlan(wallet, strategy) + setPlan(newPlan) + }, + [wallet, strategy], + 'ZcashMigrationPlanConfirmScene' + ) + + const handleConfirm = useHandler(async () => { + if (plan == null) return + setConfirming(true) + try { + await confirmZcashMigrationPlan(wallet, plan) + navigation.replace('zcashMigrationStatus', { walletId }) + } catch (error) { + showError(error) + } finally { + setConfirming(false) + } + }) + + const renderReadyTime = (nextExecutableAfterHeight: number): string => { + const blocks = nextExecutableAfterHeight - blockHeight + if (blocks <= 0) return lstrings.zcash_migration_plan_ready_now + const hours = Math.max( + 1, + Math.round((blocks * AVERAGE_BLOCK_SECONDS) / 3600) + ) + return sprintf(lstrings.zcash_migration_plan_ready_hours_1s, hours) + } + + return ( + + + {plan == null ? ( + + ) : ( + + + {plan.transfers.map((transfer, index) => ( + + ))} + + + + {plan.noteSplitRequired ? ( + + ) : null} + + + {sprintf( + lstrings.zcash_migration_plan_duration_1s, + plan.estimatedDurationHours + )} + {'\n'} + {lstrings.zcash_migration_plan_fee_note} + + {strategy === 'immediate' ? ( + + ) : null} + + + )} + + ) +} diff --git a/src/components/scenes/ZcashMigrationStatusScene.tsx b/src/components/scenes/ZcashMigrationStatusScene.tsx new file mode 100644 index 00000000000..8f5fcdd6965 --- /dev/null +++ b/src/components/scenes/ZcashMigrationStatusScene.tsx @@ -0,0 +1,164 @@ +import * as React from 'react' +import { ActivityIndicator, View } from 'react-native' +import IonIcon from 'react-native-vector-icons/Ionicons' +import { sprintf } from 'sprintf-js' + +import { useHandler } from '../../hooks/useHandler' +import { useZcashMigrationStatus } from '../../hooks/useZcashMigrationStatus' +import { lstrings } from '../../locales/strings' +import { useSelector } from '../../types/reactRedux' +import type { EdgeAppSceneProps } from '../../types/routerTypes' +import { retryZcashMigration } from '../../util/zcashMigration' +import { SceneButtons } from '../buttons/SceneButtons' +import { AlertCardUi4 } from '../cards/AlertCard' +import { EdgeCard } from '../cards/EdgeCard' +import { SceneWrapper } from '../common/SceneWrapper' +import { showError } from '../services/AirshipInstance' +import { useTheme } from '../services/ThemeContext' +import { EdgeText } from '../themed/EdgeText' +import { SceneHeader } from '../themed/SceneHeader' + +export interface ZcashMigrationStatusParams { + walletId: string +} + +interface Props extends EdgeAppSceneProps<'zcashMigrationStatus'> {} + +/** + * Live N-of-M progress for a scheduled Orchard -> Ironwood migration, with + * error surfacing + retry (refresh stale transfers). + */ +export const ZcashMigrationStatusScene: React.FC = props => { + const { navigation, route } = props + const { walletId } = route.params + const theme = useTheme() + + const account = useSelector(state => state.core.account) + const wallet = account.currencyWallets[walletId] + const status = useZcashMigrationStatus(wallet, 5000) + const [retrying, setRetrying] = React.useState(false) + + const handleRetry = useHandler(async () => { + setRetrying(true) + try { + await retryZcashMigration(wallet) + } catch (error) { + showError(error) + } finally { + setRetrying(false) + } + }) + + const handleDone = useHandler(() => { + navigation.goBack() + }) + + const renderTransferRows = (): React.ReactNode => { + if (status == null || status.totalTransfers === 0) return null + const rows: React.ReactNode[] = [] + for (let i = 0; i < status.totalTransfers; i++) { + const isComplete = i < status.completedTransfers + const isNext = i === status.completedTransfers + rows.push( + + {isComplete ? ( + + ) : isNext && status.state === 'scheduled' ? ( + + ) : ( + + )} + + {sprintf(lstrings.zcash_migration_plan_transfer_1s, i + 1)} + + + ) + } + return {rows} + } + + return ( + + + {status == null ? ( + + ) : ( + + {status.state === 'error' ? ( + + ) : null} + + {status.state === 'complete' + ? lstrings.zcash_migration_status_complete + : sprintf( + lstrings.zcash_migration_status_progress_2s, + status.completedTransfers, + status.totalTransfers + )} + + {renderTransferRows()} + {status.state !== 'complete' ? ( + + {lstrings.zcash_migration_status_body} + + ) : null} + {status.state === 'error' ? ( + + ) : ( + + )} + + )} + + ) +} diff --git a/src/components/services/Services.tsx b/src/components/services/Services.tsx index be66975bbb5..8bbb7a78ddd 100644 --- a/src/components/services/Services.tsx +++ b/src/components/services/Services.tsx @@ -44,6 +44,7 @@ import { SortedWalletList } from './SortedWalletList' import { WalletConnectService } from './WalletConnectService' import { WalletLifecycle } from './WalletLifecycle' import { WipeLogsService } from './WipeLogsService' +import { ZcashMigrationService } from './ZcashMigrationService' interface Props { navigation: NavigationBase @@ -160,6 +161,7 @@ export const Services: React.FC = props => { <> {ENV.BETA_FEATURES ? : null} {account == null ? null : } + {account == null ? null : } {account == null ? null : ( diff --git a/src/components/services/ZcashMigrationService.tsx b/src/components/services/ZcashMigrationService.tsx new file mode 100644 index 00000000000..1396c239bcf --- /dev/null +++ b/src/components/services/ZcashMigrationService.tsx @@ -0,0 +1,57 @@ +import type { EdgeAccount } from 'edge-core-js' +import * as React from 'react' + +import { useWatch } from '../../hooks/useWatch' +import { getZcashMigrationStatus } from '../../util/zcashMigration' +import { updateNotificationInfo } from './NotificationService' + +interface Props { + account: EdgeAccount +} + +const POLL_DELAY_MS = 60000 + +/** + * Watches Zcash wallets for the Orchard -> Ironwood migration condition and + * arms the per-wallet notification-center card (`ironwoodMigration-`). + * The condition mechanism in updateNotificationInfo re-activates the card + * automatically if new Orchard funds arrive after a completed migration. + */ +export const ZcashMigrationService: React.FC = props => { + const { account } = props + const currencyWallets = useWatch(account, 'currencyWallets') + + React.useEffect(() => { + let stopped = false + + const check = async (): Promise => { + for (const walletId of Object.keys(currencyWallets)) { + const wallet = currencyWallets[walletId] + if (wallet.currencyInfo.pluginId !== 'zcash') continue + const status = await getZcashMigrationStatus(wallet) + if (stopped || status == null) continue + const isActive = status.state === 'required' || status.state === 'error' + // 'scheduled' is deliberately not a notification: progress lives on + // the wallet scene banner; the center card is the call to action. + await updateNotificationInfo( + account, + `ironwoodMigration-${walletId}`, + isActive, + { walletId } + ).catch(() => {}) + } + } + + const timerId = setInterval(() => { + check().catch(() => {}) + }, POLL_DELAY_MS) + check().catch(() => {}) + + return () => { + stopped = true + clearInterval(timerId) + } + }, [account, currencyWallets]) + + return null +} diff --git a/src/components/themed/TransactionListTop.tsx b/src/components/themed/TransactionListTop.tsx index d8332b3b2cf..c242a9d4b14 100644 --- a/src/components/themed/TransactionListTop.tsx +++ b/src/components/themed/TransactionListTop.tsx @@ -24,6 +24,7 @@ import { useAsyncValue } from '../../hooks/useAsyncValue' import { useHandler } from '../../hooks/useHandler' import { useWalletName } from '../../hooks/useWalletName' import { useWatch } from '../../hooks/useWatch' +import { useZcashMigrationStatus } from '../../hooks/useZcashMigrationStatus' import { formatNumber, toPercentString } from '../../locales/intl' import { lstrings } from '../../locales/strings' import { getStakePlugins } from '../../plugins/stake-plugins/stakePlugins' @@ -106,6 +107,7 @@ interface Props { export const TransactionListTop: React.FC = props => { const { isEmpty, searching, tokenId, wallet } = props const navigation = useAsyncNavigation(props.navigation) + const migrationStatus = useZcashMigrationStatus(wallet) const dispatch = useDispatch() const account = useSelector(state => state.core.account) @@ -665,6 +667,47 @@ export const TransactionListTop: React.FC = props => { ) } + /** + * Orchard -> Ironwood migration banner (Zcash only; dormant until the + * engine reports the wallet needs, is running, or erred on the migration). + */ + function renderZcashMigrationCard(): React.ReactElement | null { + if (tokenId != null || migrationStatus == null) return null + const { state, completedTransfers, totalTransfers } = migrationStatus + if (state !== 'required' && state !== 'scheduled' && state !== 'error') + return null + + const body = + state === 'required' + ? lstrings.zcash_migration_required_body + : state === 'error' + ? lstrings.zcash_migration_error_body + : sprintf( + lstrings.zcash_migration_scheduled_body_2s, + completedTransfers, + totalTransfers + ) + return ( + { + navigation.navigate('zcashMigrationOptions' as any, { + walletId: wallet.id + }) + } + }} + /> + ) + } + function renderButtons(): React.ReactElement { const styles = getStyles(theme) const hideStaking = !isStakingAvailable @@ -795,6 +838,7 @@ export const TransactionListTop: React.FC = props => { {!isStakingAvailable ? null : renderStakedBalance()} {renderSyncStatus()} + {renderZcashMigrationCard()} {renderButtons()} )} diff --git a/src/hooks/useZcashMigrationStatus.ts b/src/hooks/useZcashMigrationStatus.ts new file mode 100644 index 00000000000..60a350cf47a --- /dev/null +++ b/src/hooks/useZcashMigrationStatus.ts @@ -0,0 +1,24 @@ +import type { EdgeCurrencyWallet } from 'edge-core-js' +import * as React from 'react' + +import { + getZcashMigrationStatus, + type ZcashMigrationStatus +} from '../util/zcashMigration' +import { useRefresher } from './useRefresher' + +/** + * Polls the Zcash Orchard -> Ironwood migration status for a wallet. + * Returns undefined (forever) for anything that is not a migration-capable + * Zcash wallet, so it is safe to call unconditionally from shared scenes. + */ +export function useZcashMigrationStatus( + wallet: EdgeCurrencyWallet, + delay: number = 10000 +): ZcashMigrationStatus | undefined { + const refresher = React.useCallback( + async () => await getZcashMigrationStatus(wallet), + [wallet] + ) + return useRefresher(refresher, undefined, delay) +} diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 49926310b8e..4bea82b45e1 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -764,6 +764,59 @@ const strings = { string_first_pivx_wallet_name: 'My PIVX', string_first_zano_wallet_name: 'My Zano', string_first_zcash_wallet_name: 'My Zcash', + + // Zcash Orchard -> Ironwood (NU6.3) migration: + zcash_migration_title: 'Move to Ironwood', + zcash_migration_required_title: 'Migration Required', + zcash_migration_required_body: + 'The latest Zcash network upgrade requires moving your ZEC from the Orchard pool to the new Ironwood pool. Your funds are safe.', + zcash_migration_scheduled_body_2s: + 'Migration in progress: %1$s of %2$s transfers complete', + zcash_migration_error_body: + 'Your migration needs attention. Tap to review and retry.', + zcash_migration_option_privacy_title: 'Migrate with Privacy', + zcash_migration_option_privacy_subtitle: + 'Split transfers over time · Executes as you use the app · Maximum privacy', + zcash_migration_option_immediate_title: 'Migrate Immediately', + zcash_migration_option_immediate_subtitle: + 'Single transfer · Sends now · Less privacy', + zcash_migration_option_immediate_warning: + 'Your full balance will be revealed on-chain. Crossing the pool boundary exposes the transaction amount. We recommend Migrate with Privacy instead.', + zcash_migration_how_title: 'How This Works', + zcash_migration_how_split: + 'Split: Your balance is divided into several smaller amounts to make them harder to link together.', + zcash_migration_how_presign: + 'Approve once: The transfer plan is signed up front. No further approvals are needed.', + zcash_migration_how_execute: + 'Transfers happen over time: Each transfer is sent in its scheduled window while you use the app. Keep opening Edge over the next day or so to keep the migration moving.', + zcash_migration_how_failures: + 'If something needs attention: The wallet will show a notice so you can retry.', + zcash_migration_plan_title: 'Confirm Transfer Plan', + zcash_migration_plan_transfer_1s: 'Transfer %1$s', + zcash_migration_plan_ready_hours_1s: 'executable in ~%1$s hr', + zcash_migration_plan_ready_now: 'executable now', + zcash_migration_plan_total: 'Total to migrate', + zcash_migration_plan_split_fee: 'Preparation fee', + zcash_migration_plan_duration_1s: 'Estimated duration: ~%1$s hours', + zcash_migration_plan_fee_note: + 'Each transfer pays a standard network fee when it is sent.', + zcash_migration_plan_cannot_cancel_warning: + 'Your full Orchard balance will be transferred to Ironwood in a single on-chain transfer. Once confirmed, this transfer cannot be cancelled.', + zcash_migration_status_title: 'Migration Progress', + zcash_migration_status_progress_2s: '%1$s of %2$s transfers complete', + zcash_migration_status_body: + 'Transfers continue while you use the app. You can safely leave this screen.', + zcash_migration_status_complete: + 'Your ZEC was migrated to the Ironwood pool.', + zcash_migration_status_error_title: 'Migration Needs Attention', + zcash_migration_status_error_body: + 'Some scheduled transfers went stale and need to be refreshed.', + zcash_migration_retry_button: 'Refresh Transfers', + zcash_migration_menu_item: 'Move to Ironwood', + zcash_migration_view_progress: 'View Progress', + zcash_migration_notif_title: 'Zcash: Migration Required', + zcash_migration_notif_body_1s: + 'Move your ZEC in %1$s to the new Ironwood pool', string_first_tron_wallet_name: 'My Tron', string_first_doge_wallet_name: 'My Doge', string_first_fantom_wallet_name: 'My Fantom', diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index fe138434cfc..6a6a17bc8cf 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -593,6 +593,41 @@ "string_first_pivx_wallet_name": "My PIVX", "string_first_zano_wallet_name": "My Zano", "string_first_zcash_wallet_name": "My Zcash", + "zcash_migration_title": "Move to Ironwood", + "zcash_migration_required_title": "Migration Required", + "zcash_migration_required_body": "The latest Zcash network upgrade requires moving your ZEC from the Orchard pool to the new Ironwood pool. Your funds are safe.", + "zcash_migration_scheduled_body_2s": "Migration in progress: %1$s of %2$s transfers complete", + "zcash_migration_error_body": "Your migration needs attention. Tap to review and retry.", + "zcash_migration_option_privacy_title": "Migrate with Privacy", + "zcash_migration_option_privacy_subtitle": "Split transfers over time · Executes as you use the app · Maximum privacy", + "zcash_migration_option_immediate_title": "Migrate Immediately", + "zcash_migration_option_immediate_subtitle": "Single transfer · Sends now · Less privacy", + "zcash_migration_option_immediate_warning": "Your full balance will be revealed on-chain. Crossing the pool boundary exposes the transaction amount. We recommend Migrate with Privacy instead.", + "zcash_migration_how_title": "How This Works", + "zcash_migration_how_split": "Split: Your balance is divided into several smaller amounts to make them harder to link together.", + "zcash_migration_how_presign": "Approve once: The transfer plan is signed up front. No further approvals are needed.", + "zcash_migration_how_execute": "Transfers happen over time: Each transfer is sent in its scheduled window while you use the app. Keep opening Edge over the next day or so to keep the migration moving.", + "zcash_migration_how_failures": "If something needs attention: The wallet will show a notice so you can retry.", + "zcash_migration_plan_title": "Confirm Transfer Plan", + "zcash_migration_plan_transfer_1s": "Transfer %1$s", + "zcash_migration_plan_ready_hours_1s": "executable in ~%1$s hr", + "zcash_migration_plan_ready_now": "executable now", + "zcash_migration_plan_total": "Total to migrate", + "zcash_migration_plan_split_fee": "Preparation fee", + "zcash_migration_plan_duration_1s": "Estimated duration: ~%1$s hours", + "zcash_migration_plan_fee_note": "Each transfer pays a standard network fee when it is sent.", + "zcash_migration_plan_cannot_cancel_warning": "Your full Orchard balance will be transferred to Ironwood in a single on-chain transfer. Once confirmed, this transfer cannot be cancelled.", + "zcash_migration_status_title": "Migration Progress", + "zcash_migration_status_progress_2s": "%1$s of %2$s transfers complete", + "zcash_migration_status_body": "Transfers continue while you use the app. You can safely leave this screen.", + "zcash_migration_status_complete": "Your ZEC was migrated to the Ironwood pool.", + "zcash_migration_status_error_title": "Migration Needs Attention", + "zcash_migration_status_error_body": "Some scheduled transfers went stale and need to be refreshed.", + "zcash_migration_retry_button": "Refresh Transfers", + "zcash_migration_menu_item": "Move to Ironwood", + "zcash_migration_view_progress": "View Progress", + "zcash_migration_notif_title": "Zcash: Migration Required", + "zcash_migration_notif_body_1s": "Move your ZEC in %1$s to the new Ironwood pool", "string_first_tron_wallet_name": "My Tron", "string_first_doge_wallet_name": "My Doge", "string_first_fantom_wallet_name": "My Fantom", diff --git a/src/types/routerTypes.tsx b/src/types/routerTypes.tsx index d7d8ec55964..a452b5b3049 100644 --- a/src/types/routerTypes.tsx +++ b/src/types/routerTypes.tsx @@ -78,6 +78,10 @@ import type { WcConnectionsParams } from '../components/scenes/WcConnectionsScen import type { WcConnectParams } from '../components/scenes/WcConnectScene' import type { WcDisconnectParams } from '../components/scenes/WcDisconnectScene' import type { WebViewSceneParams } from '../components/scenes/WebViewScene' +import type { ZcashMigrationInfoParams } from '../components/scenes/ZcashMigrationInfoScene' +import type { ZcashMigrationOptionsParams } from '../components/scenes/ZcashMigrationOptionsScene' +import type { ZcashMigrationPlanConfirmParams } from '../components/scenes/ZcashMigrationPlanConfirmScene' +import type { ZcashMigrationStatusParams } from '../components/scenes/ZcashMigrationStatusScene' import type { FiatPluginAddressFormParams } from '../plugins/gui/scenes/AddressFormScene' import type { FiatPluginConfirmationParams } from '../plugins/gui/scenes/ConfirmationScene' import type { FiatPluginEmailFormParams } from '../plugins/gui/scenes/ContactFormScene' @@ -256,6 +260,10 @@ export type EdgeAppStackParamList = {} & { wcConnections: WcConnectionsParams wcDisconnect: WcDisconnectParams webView: WebViewSceneParams + zcashMigrationInfo: ZcashMigrationInfoParams + zcashMigrationOptions: ZcashMigrationOptionsParams + zcashMigrationPlanConfirm: ZcashMigrationPlanConfirmParams + zcashMigrationStatus: ZcashMigrationStatusParams } // A drawer router that contains the main `edgeAppStack` diff --git a/src/util/zcashMigration.ts b/src/util/zcashMigration.ts new file mode 100644 index 00000000000..17187af349a --- /dev/null +++ b/src/util/zcashMigration.ts @@ -0,0 +1,104 @@ +import { + asArray, + asBoolean, + asMaybe, + asNumber, + asObject, + asOptional, + asString, + asValue +} from 'cleaners' +import type { EdgeCurrencyWallet } from 'edge-core-js' +import { Platform } from 'react-native' + +// +// Typed access to the Zcash engine's Orchard -> Ironwood migration surface +// (edge-currency-accountbased exposes it through wallet.otherMethods, which is +// untyped). Cleaners mirror the engine's shapes. +// + +/** + * The react-native-zcash Android bridge stubs reject until the Android SDK + * ships Ironwood; hide the feature there entirely. Delete this gate when the + * Android bridge lands. + */ +export const ZCASH_MIGRATION_PLATFORMS = ['ios'] + +export const asZcashMigrationStatus = asObject({ + state: asValue('notNeeded', 'required', 'scheduled', 'complete', 'error'), + completedTransfers: asMaybe(asNumber, 0), + totalTransfers: asMaybe(asNumber, 0), + remainingOrchardZatoshi: asMaybe(asString, '0'), + hasOverdueTransfers: asMaybe(asBoolean, false), + isSynced: asMaybe(asBoolean, false), + nextTransferReadyAtHeight: asOptional(asNumber) +}) +export type ZcashMigrationStatus = ReturnType + +export const asZcashMigrationPlan = asObject({ + strategy: asValue('privacy', 'immediate'), + transfers: asArray( + asObject({ + id: asString, + amountZatoshi: asString, + nextExecutableAfterHeight: asNumber, + expiryHeight: asNumber + }) + ), + estimatedDurationHours: asNumber, + totalAmountZatoshi: asString, + noteSplitFeeZatoshi: asString, + noteSplitRequired: asBoolean, + noteSplitProposalBase64: asOptional(asString), + scheduleBase64: asString +}) +export type ZcashMigrationPlan = ReturnType + +export type ZcashMigrationStrategy = 'privacy' | 'immediate' + +const isMigrationCapable = (wallet: EdgeCurrencyWallet): boolean => + wallet.currencyInfo.pluginId === 'zcash' && + ZCASH_MIGRATION_PLATFORMS.includes(Platform.OS) && + typeof wallet.otherMethods.getMigrationStatus === 'function' + +/** + * The wallet's migration status, or undefined when the wallet is not a + * migration-capable Zcash wallet (wrong plugin, old accountbased build, + * gated platform) or the engine call fails. + */ +export async function getZcashMigrationStatus( + wallet: EdgeCurrencyWallet +): Promise { + if (!isMigrationCapable(wallet)) return undefined + try { + return asZcashMigrationStatus( + await wallet.otherMethods.getMigrationStatus() + ) + } catch (error) { + console.warn('getZcashMigrationStatus failed', error) + return undefined + } +} + +export async function prepareZcashMigrationPlan( + wallet: EdgeCurrencyWallet, + strategy: ZcashMigrationStrategy +): Promise { + return asZcashMigrationPlan( + await wallet.otherMethods.prepareMigrationPlan({ strategy }) + ) +} + +export async function confirmZcashMigrationPlan( + wallet: EdgeCurrencyWallet, + plan: ZcashMigrationPlan +): Promise<{ txid?: string }> { + const out = await wallet.otherMethods.confirmMigrationPlan(plan) + return asObject({ txid: asOptional(asString) })(out) +} + +export async function retryZcashMigration( + wallet: EdgeCurrencyWallet +): Promise { + return asNumber(await wallet.otherMethods.retryMigration()) +}