From bd6418b69d608073c04dfc03c1a1c238149c80b0 Mon Sep 17 00:00:00 2001 From: peachbits Date: Tue, 21 Jul 2026 12:21:23 -0700 Subject: [PATCH 1/3] Zcash: Add Ironwood migration status util and hook Typed access to the engine's v1 Orchard -> Ironwood detection surface (wallet.otherMethods.getMigrationStatus, cleaner-validated) plus a polling hook safe to call unconditionally from shared scenes. iOS-gated until the Android bridge lands. Strings use a recommended tone by design: the sweep is worthwhile, not mandatory - Orchard stays spendable post-fork and drains passively through ordinary spends, so nothing is framed as "required". Co-Authored-By: Claude Fable 5 --- src/__tests__/zcashMigration.test.ts | 71 ++++++++++++++++++++++++++++ src/hooks/useZcashMigrationStatus.ts | 24 ++++++++++ src/locales/en_US.ts | 12 +++++ src/locales/strings/enUS.json | 6 +++ src/util/zcashMigration.ts | 59 +++++++++++++++++++++++ 5 files changed, 172 insertions(+) create mode 100644 src/__tests__/zcashMigration.test.ts create mode 100644 src/hooks/useZcashMigrationStatus.ts create mode 100644 src/util/zcashMigration.ts diff --git a/src/__tests__/zcashMigration.test.ts b/src/__tests__/zcashMigration.test.ts new file mode 100644 index 00000000000..89c6f276c4c --- /dev/null +++ b/src/__tests__/zcashMigration.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from '@jest/globals' +import type { EdgeCurrencyWallet } from 'edge-core-js' + +import { 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() + }) +}) 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 1c1c8b67e5e..14e774cf73d 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -764,6 +764,18 @@ 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 (v1: recommended sweep via + // the ordinary send scene — not framed as mandatory, because Orchard stays + // spendable post-fork and drains passively through ordinary spends): + zcash_migration_recommended_title: 'Move to the new Ironwood pool', + zcash_migration_recommended_body: + "Zcash's Ironwood network upgrade added a new shielded pool. We recommend moving your shielded ZEC to it now with a single send to yourself. Your funds stay safe and spendable either way.", + zcash_migration_recommended_button: 'Move my funds', + zcash_migration_info_tile_label: 'Ironwood migration', + zcash_migration_info_tile_value: + 'This send moves your shielded ZEC to your own address in the new Ironwood pool. The amount is your spendable balance minus the network fee.', + zcash_migration_tx_notes: 'Ironwood migration', 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 685fd7577ea..57b08dd5708 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -593,6 +593,12 @@ "string_first_pivx_wallet_name": "My PIVX", "string_first_zano_wallet_name": "My Zano", "string_first_zcash_wallet_name": "My Zcash", + "zcash_migration_recommended_title": "Move to the new Ironwood pool", + "zcash_migration_recommended_body": "Zcash's Ironwood network upgrade added a new shielded pool. We recommend moving your shielded ZEC to it now with a single send to yourself. Your funds stay safe and spendable either way.", + "zcash_migration_recommended_button": "Move my funds", + "zcash_migration_info_tile_label": "Ironwood migration", + "zcash_migration_info_tile_value": "This send moves your shielded ZEC to your own address in the new Ironwood pool. The amount is your spendable balance minus the network fee.", + "zcash_migration_tx_notes": "Ironwood migration", "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/util/zcashMigration.ts b/src/util/zcashMigration.ts new file mode 100644 index 00000000000..778d6bc9c23 --- /dev/null +++ b/src/util/zcashMigration.ts @@ -0,0 +1,59 @@ +import { + asBoolean, + asMaybe, + asNumber, + asObject, + asOptional, + asString, + asValue +} from 'cleaners' +import type { EdgeCurrencyWallet } from 'edge-core-js' + +// +// 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. +// +// v1 is detection-only: 'required' means a sweep is worthwhile (recommended, +// not mandatory — Orchard stays spendable post-fork and drains passively +// through ordinary spends). The GUI answers it with a prefilled max +// send-to-self through the regular send scene. The 'scheduled'/'error' states +// belong to the guided (v2) migration lifecycle and are dormant in v1. +// + +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 + +// Both platforms implement the migration surface against their respective +// zcash SDKs, so there is no platform gate: an accountbased build without the +// method is the only case that needs excluding. +const isMigrationCapable = (wallet: EdgeCurrencyWallet): boolean => + wallet.currencyInfo.pluginId === 'zcash' && + 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) 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 + } +} From 998a157f636e18e0f03f3d2f2e2812871a7249c0 Mon Sep 17 00:00:00 2001 From: peachbits Date: Tue, 21 Jul 2026 12:31:44 -0700 Subject: [PATCH 2/3] Zcash: Ironwood migration card -> locked max send-to-self The Zcash wallet scene's migration card (shown when the engine reports a sweep is worthwhile) prefills the ordinary send scene with the wallet's own unified address and its max spendable amount, with the address/amount/wallet tiles locked - the account-activation locked-send pattern. Post-activation, plain proposals route outputs and change into Ironwood, so this is a normal transaction through the normal pipeline: fee and final amount come from the engine's makeSpend, and the tx is labeled via metadata notes. Copy is deliberately recommended-tone, not required: Orchard stays spendable post-fork and drains passively through ordinary spends. The card clears via the status poll once Orchard empties by any means (sweep sent, or drained organically). A max send sweeps every shielded pool into Ironwood - the strings say "shielded ZEC", not just Orchard. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 + src/components/cards/ZcashMigrationCard.tsx | 133 +++++++++++++++++++ src/components/scenes/SendScene2.tsx | 20 ++- src/components/themed/TransactionListTop.tsx | 98 +++++++++++++- src/locales/en_US.ts | 16 ++- src/locales/strings/enUS.json | 9 +- src/theme/edgeConfig.ts | 1 + src/types/types.ts | 5 + 8 files changed, 269 insertions(+), 15 deletions(-) create mode 100644 src/components/cards/ZcashMigrationCard.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f72bf436d7..7ec286e0356 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ - fixed: Native NYM wallet details now label the network as "Nyx Network" (the native chain) instead of "Nym Network", while asset labels remain Nym. - fixed: Tapping Max on the Sell scene no longer briefly shows the entered fiat amount in the crypto field while the max is being calculated. +- added: Zcash: Orchard -> Ironwood (NU6.3) migration card on the wallet scene - when the engine reports a sweep is worthwhile, it prefills a locked max send-to-self through the ordinary send scene (recommended-tone: funds stay spendable either way). Available on both platforms. + ## 4.50.0 (2026-07-21) - added: Changelly swap provider diff --git a/src/components/cards/ZcashMigrationCard.tsx b/src/components/cards/ZcashMigrationCard.tsx new file mode 100644 index 00000000000..a25632e1c2d --- /dev/null +++ b/src/components/cards/ZcashMigrationCard.tsx @@ -0,0 +1,133 @@ +import * as React from 'react' +import { View } from 'react-native' +import IonIcon from 'react-native-vector-icons/Ionicons' +import { sprintf } from 'sprintf-js' + +import { useHandler } from '../../hooks/useHandler' +import { lstrings } from '../../locales/strings' +import { config } from '../../theme/appConfig' +import { openBrowserUri } from '../../util/WebUtils' +import { EdgeButton } from '../buttons/EdgeButton' +import { showError } from '../services/AirshipInstance' +import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' +import { EdgeText } from '../themed/EdgeText' +import { EdgeCard } from './EdgeCard' + +const ZCASH_MIGRATION_HELP_URI = 'https://support.edge.app/articles/16111542' + +interface Props { + /** + * The Orchard-pool balance at risk, pre-formatted with its denomination. + * ZIP 318 requires the entry point to show this specific figure rather than + * the wallet's whole shielded balance, since only Orchard funds cross the + * turnstile. + */ + orchardBalanceText: string + onMigratePress: () => Promise | void +} + +/** + * Orchard -> Ironwood (NU6.3) migration card for the Zcash wallet scene. + * + * Its own component rather than an `AlertCardUi4` because the help link is + * inline in the copy rather than a second button, which that card cannot do. + * + * Not dismissable: the card clears on its own once the Orchard balance empties, + * by the sweep or by ordinary spends draining it passively. + */ +export const ZcashMigrationCard: React.FC = props => { + const { orchardBalanceText, onMigratePress } = props + const theme = useTheme() + const styles = getStyles(theme) + + // Returned, not swallowed: EdgeButton's usePendingPress only shows the spinner + // and blocks re-taps when it receives a thenable, and it reports errors itself. + // Preparing the sweep does real work (getAddresses, getMaxSpendable), so the + // button must not stay tappable through it. + const handleMigrate = useHandler(async (): Promise => { + await onMigratePress() + }) + + const handleLearnMore = useHandler(() => { + const uri = config.zcashMigrationLearnMoreUrl ?? ZCASH_MIGRATION_HELP_URI + openBrowserUri(uri).catch((error: unknown) => { + showError(error) + }) + }) + + return ( + + + + + + {lstrings.zcash_migration_recommended_title} + + + + {/* + The help link is inline at the end of the copy rather than a second + button, so the card keeps a single call to action. Nested EdgeText with + its own onPress, per the Stealth Send treatment. + */} + + {sprintf( + lstrings.zcash_migration_recommended_body_1s, + orchardBalanceText + )}{' '} + + {lstrings.zcash_migration_learn_more_button} + + + + + + + + + ) +} + +const getStyles = cacheStyles((theme: Theme) => ({ + container: { + margin: theme.rem(0.5) + }, + titleContainer: { + flexDirection: 'row', + alignItems: 'center' + }, + titleText: { + marginLeft: theme.rem(0.2), + fontFamily: theme.fontFaceMedium, + flexShrink: 1 + }, + icon: { + marginRight: theme.rem(0.2) + }, + text: { + fontSize: theme.rem(0.75), + marginHorizontal: theme.rem(0.25), + marginTop: theme.rem(0.5) + }, + learnMoreLink: { + fontSize: theme.rem(0.75), + color: theme.textLink + }, + buttonContainer: { + marginTop: theme.rem(1) + } +})) diff --git a/src/components/scenes/SendScene2.tsx b/src/components/scenes/SendScene2.tsx index 58aa9578ff5..050a7e8ddf2 100644 --- a/src/components/scenes/SendScene2.tsx +++ b/src/components/scenes/SendScene2.tsx @@ -138,7 +138,16 @@ export interface SendScene2Params { fioAddressSelect?: boolean scamWarning?: boolean } - infoTiles?: Array<{ label: string; value: string }> + infoTiles?: Array<{ + label: string + value: string + /** + * Row height cap, defaulting to EdgeRow's 3 lines. A value longer than the + * cap is not merely clipped - EdgeText shrinks it to as little as 65% of + * its size to fit - so paragraph-length values want 'large' (unlimited). + */ + maximumHeight?: 'small' | 'medium' | 'large' + }> // Custom React node rendered directly above the slider sliderTopNode?: React.ReactNode fioPendingRequest?: FioRequest @@ -1091,8 +1100,13 @@ const SendComponent: React.FC = props => { const renderInfoTiles = (): Array | null => { if (infoTiles == null || infoTiles.length === 0) return null - return infoTiles.map(({ label, value }) => ( - + return infoTiles.map(({ label, value, maximumHeight }) => ( + )) } diff --git a/src/components/themed/TransactionListTop.tsx b/src/components/themed/TransactionListTop.tsx index 96b08f93a0f..75345a4d29b 100644 --- a/src/components/themed/TransactionListTop.tsx +++ b/src/components/themed/TransactionListTop.tsx @@ -1,5 +1,9 @@ -import { add, gt, mul } from 'biggystring' -import type { EdgeCurrencyWallet, EdgeTokenId } from 'edge-core-js' +import { add, div, gt, mul } from 'biggystring' +import type { + EdgeCurrencyWallet, + EdgeSpendInfo, + EdgeTokenId +} from 'edge-core-js' import * as React from 'react' import { View } from 'react-native' import type { AirshipBridge } from 'react-native-airship' @@ -24,6 +28,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' @@ -64,6 +69,7 @@ import { IconButton } from '../buttons/IconButton' import { AlertCardUi4 } from '../cards/AlertCard' import { EdgeCard } from '../cards/EdgeCard' import { VisaCardCard } from '../cards/VisaCardCard' +import { ZcashMigrationCard } from '../cards/ZcashMigrationCard' import { EdgeAnim } from '../common/EdgeAnim' import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity' import { WalletIcon } from '../icons/WalletIcon' @@ -158,6 +164,7 @@ export const TransactionListTop: React.FC = props => { const walletName = useWalletName(wallet) const balanceMap = useWatch(wallet, 'balanceMap') const syncStatus = useWatch(wallet, 'syncStatus') + const migrationStatus = useZcashMigrationStatus(wallet) // Track sync card visibility with 1-second delay after sync completes: const isSyncing = syncStatus.totalRatio < DONE_THRESHOLD @@ -671,6 +678,92 @@ export const TransactionListTop: React.FC = props => { ) } + /** + * Orchard -> Ironwood migration card (Zcash only; appears when the engine + * reports a sweep is worthwhile). Tapping prefills a locked send-to-self in + * the ordinary send scene, whose amount and fee come from the SDK's + * Orchard-only sweep proposal: it spends every Orchard note with the fee + * chosen so no Orchard change remains, leaving the other pools untouched. + * Recommended, not mandatory: Orchard stays spendable and drains passively + * through ordinary spends, and the card clears via the status poll once + * Orchard empties by any means. + */ + function renderZcashMigrationCard(): React.ReactElement | null { + if (tokenId != null || migrationStatus == null) return null + if (migrationStatus.state !== 'required') return null + + // ZIP 318 requires the entry point to display the Orchard-pool-specific + // balance at risk — not the wallet's whole shielded balance, since only + // the Orchard funds cross the turnstile. + const orchardDisplayAmount = formatNumber( + div( + migrationStatus.remainingOrchardZatoshi, + displayDenomination.multiplier, + DECIMAL_PRECISION + ), + { toFixed: 6 } + ) + const orchardBalanceText = `${orchardDisplayAmount} ${displayDenomination.name}` + + const handleMigratePress = async (): Promise => { + const addresses = await wallet.getAddresses({ tokenId: null }) + const unifiedAddress = addresses.find( + address => address.addressType === 'unifiedAddress' + ) + // Every Zcash wallet has one, so this is a can't-happen guard - but a tap + // that silently does nothing is worse than a surfaced error, and onPress + // routes throws to showError: + if (unifiedAddress == null) { + throw new Error('No unified address found for this Zcash wallet') + } + const spendInfo: EdgeSpendInfo = { + tokenId: null, + spendTargets: [{ publicAddress: unifiedAddress.publicAddress }], + metadata: { notes: lstrings.zcash_migration_tx_notes }, + // Top-level otherParams reaches the engine intact (per-target + // otherParams gets overwritten by the send scene's address handlers). + // The flag makes the engine quote and build an Orchard-ONLY sweep + // instead of an ordinary max send across every shielded pool. + otherParams: { ironwoodMigration: true } + } + // The locked amount tile makes the MAX modal unreachable, so prefill the + // engine's quote and lock it. For a migration spend that quote is the + // Orchard balance minus fee, and re-proposing is deterministic, so the + // amount shown here is the amount the user signs. + const migrationAmount = await wallet.getMaxSpendable(spendInfo) + navigation.push('send2', { + walletId: wallet.id, + tokenId: null, + spendInfo: { + ...spendInfo, + spendTargets: [ + { + publicAddress: unifiedAddress.publicAddress, + nativeAmount: migrationAmount + } + ] + }, + lockTilesMap: { address: true, amount: true, wallet: true }, + infoTiles: [ + { + label: lstrings.zcash_migration_info_tile_label, + value: lstrings.zcash_migration_info_tile_value, + // This value runs to a paragraph, and the default 3-line cap makes + // EdgeText shrink it to 65% to fit - unreadable. Let it wrap. + maximumHeight: 'large' + } + ] + }) + } + + return ( + + ) + } + function renderButtons(): React.ReactElement { const styles = getStyles(theme) const hideStaking = !isStakingAvailable @@ -801,6 +894,7 @@ export const TransactionListTop: React.FC = props => { {!isStakingAvailable ? null : renderStakedBalance()} {renderSyncStatus()} + {renderZcashMigrationCard()} {renderButtons()} )} diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 14e774cf73d..1561efdb799 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -767,14 +767,18 @@ const strings = { // Zcash Orchard -> Ironwood (NU6.3) migration (v1: recommended sweep via // the ordinary send scene — not framed as mandatory, because Orchard stays - // spendable post-fork and drains passively through ordinary spends): - zcash_migration_recommended_title: 'Move to the new Ironwood pool', - zcash_migration_recommended_body: - "Zcash's Ironwood network upgrade added a new shielded pool. We recommend moving your shielded ZEC to it now with a single send to yourself. Your funds stay safe and spendable either way.", - zcash_migration_recommended_button: 'Move my funds', + // spendable post-fork and drains passively through ordinary spends). + // Per ZIP 318 the entry point states the Orchard-pool balance at risk, and + // per ZIP 315 it discloses that the migrated amount becomes public before + // any funds leave the pool. + zcash_migration_recommended_title: 'Ironwood Upgrade', + zcash_migration_recommended_body_1s: + "Zcash's Ironwood network upgrade added a new shielded pool, Ironwood, and %s of your funds is still in the older Orchard pool. Funds will migrate as you create transactions or you can migrate them all in a single transaction to yourself. The amount you move will be publicly visible on the blockchain. Your funds stay safe and spendable either way.", + zcash_migration_recommended_button: 'Migrate', + zcash_migration_learn_more_button: 'Learn more', zcash_migration_info_tile_label: 'Ironwood migration', zcash_migration_info_tile_value: - 'This send moves your shielded ZEC to your own address in the new Ironwood pool. The amount is your spendable balance minus the network fee.', + 'This send migrates your Orchard-pool ZEC to your own address in the new Ironwood pool, leaving your other funds untouched. The amount is your Orchard balance minus the network fee, and it will be publicly visible on the blockchain.', zcash_migration_tx_notes: 'Ironwood migration', string_first_tron_wallet_name: 'My Tron', string_first_doge_wallet_name: 'My Doge', diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index 57b08dd5708..9efdf0978c0 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -593,11 +593,12 @@ "string_first_pivx_wallet_name": "My PIVX", "string_first_zano_wallet_name": "My Zano", "string_first_zcash_wallet_name": "My Zcash", - "zcash_migration_recommended_title": "Move to the new Ironwood pool", - "zcash_migration_recommended_body": "Zcash's Ironwood network upgrade added a new shielded pool. We recommend moving your shielded ZEC to it now with a single send to yourself. Your funds stay safe and spendable either way.", - "zcash_migration_recommended_button": "Move my funds", + "zcash_migration_recommended_title": "Ironwood Upgrade", + "zcash_migration_recommended_body_1s": "Zcash's Ironwood network upgrade added a new shielded pool, Ironwood, and %s of your funds is still in the older Orchard pool. Funds will migrate as you create transactions or you can migrate them all in a single transaction to yourself. The amount you move will be publicly visible on the blockchain. Your funds stay safe and spendable either way.", + "zcash_migration_recommended_button": "Migrate", + "zcash_migration_learn_more_button": "Learn more", "zcash_migration_info_tile_label": "Ironwood migration", - "zcash_migration_info_tile_value": "This send moves your shielded ZEC to your own address in the new Ironwood pool. The amount is your spendable balance minus the network fee.", + "zcash_migration_info_tile_value": "This send migrates your Orchard-pool ZEC to your own address in the new Ironwood pool, leaving your other funds untouched. The amount is your Orchard balance minus the network fee, and it will be publicly visible on the blockchain.", "zcash_migration_tx_notes": "Ironwood migration", "string_first_tron_wallet_name": "My Tron", "string_first_doge_wallet_name": "My Doge", diff --git a/src/theme/edgeConfig.ts b/src/theme/edgeConfig.ts index 2ac9e8d952b..53a748b5fd5 100644 --- a/src/theme/edgeConfig.ts +++ b/src/theme/edgeConfig.ts @@ -13,6 +13,7 @@ export const edgeConfig: AppConfig = { darkTheme: edgeDark, pendingTxLearnMoreUrl: 'https://support.edge.app/hc/en-us/articles/43465958781723', + zcashMigrationLearnMoreUrl: 'https://support.edge.app/articles/16111542', defaultWallets: [ { pluginId: 'bitcoin', tokenId: null }, { pluginId: 'ethereum', tokenId: null }, diff --git a/src/types/types.ts b/src/types/types.ts index 0dc47aae68e..2ec12aa32be 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -388,6 +388,11 @@ export interface AppConfig { * Support article for pending transactions "Learn more" link. */ pendingTxLearnMoreUrl?: string + /** + * Support article for the Zcash Orchard -> Ironwood migration card's + * "Learn more" link. + */ + zcashMigrationLearnMoreUrl?: string /** * Home screen long-press quick action shortcuts. * Omit to disable the shortcuts for a build. From 7ba8593e20ee11d7822054687c37d6348f7665b3 Mon Sep 17 00:00:00 2001 From: peachbits Date: Fri, 31 Jul 2026 17:10:43 -0700 Subject: [PATCH 3/3] Fix carousel card stranded off-screen when the list shrinks An item's whole on-screen position comes from an animated transform derived from its index, while its React key is the item's own id. When the card list shrinks, a surviving item shifts to a lower slot without remounting, and the transform is never re-applied: the view stays parked at its old offset, a full item-width off-screen. The container keeps its fixed height and the pagination dots do not depend on width, so what is left on screen is an empty gap with a lone dot. Make the slot part of the item's identity so a shift remounts it, which establishes the transform fresh. Verified on an Android emulator and an iOS simulator. Not Zcash-specific: the trigger is the info server's `noBalance` card being filtered out once `useIsAccountFunded` resolves, which races the carousel mounting. It surfaces here because the Ironwood migration card changes the wallet scene's height, but it can strand a card on any scene whose carousel list shrinks after mount. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + src/components/common/EdgeCarousel.tsx | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ec286e0356..f2ab0197c14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ - fixed: Tapping Max on the Sell scene no longer briefly shows the entered fiat amount in the crypto field while the max is being calculated. - added: Zcash: Orchard -> Ironwood (NU6.3) migration card on the wallet scene - when the engine reports a sweep is worthwhile, it prefills a locked max send-to-self through the ordinary send scene (recommended-tone: funds stay spendable either way). Available on both platforms. +- fixed: An info card no longer disappears into an empty gap when the carousel's card list shrinks. A card's position comes entirely from an animated transform keyed on its index, and that transform is not re-applied when a surviving card shifts slots, so dropping a card left the ones after it parked a full card-width off-screen. The carousel now remounts a card whose slot changes. Reproduces wherever the list shrinks after mount - most visibly when a `noBalance` card is filtered out as balances finish loading. ## 4.50.0 (2026-07-21) diff --git a/src/components/common/EdgeCarousel.tsx b/src/components/common/EdgeCarousel.tsx index b679ae79ee4..53a8886e14a 100644 --- a/src/components/common/EdgeCarousel.tsx +++ b/src/components/common/EdgeCarousel.tsx @@ -66,7 +66,13 @@ export function EdgeCarousel(props: Props): React.ReactElement { {data.map((item, itemIndex) => (