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())
+}