diff --git a/CHANGELOG.md b/CHANGELOG.md index fc34ebe29e7..2fb4ca06c8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased (develop) +- 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). iOS only until the Android SDK ships Ironwood. + ## 4.50.0 (staging) - added: Changelly swap provider diff --git a/package-lock.json b/package-lock.json index cfa26610c0f..bd0d7eee43e 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-2922143e/edge-currency-accountbased-4.86.1-ironwood-6f579238.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-2922143e/react-native-zcash-0.12.1-ironwood-124ee19.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-6e29ab42/edge-currency-accountbased-4.86.1-ironwood-ac7aa5d5.tgz", + "integrity": "sha512-WP091BDa5XnhcWSMD1gnzQE7nn8IZHTdRGqE7En7pQv8f/L91XjvkJuy1d9VAW3K1G280dT7DIsOZbOX4mOZdA==", "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-2922143e/react-native-zcash-0.12.1-ironwood-124ee19.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-6e29ab42/react-native-zcash-0.12.1-ironwood-91f50eb.tgz", + "integrity": "sha512-QQfeLzjH/5mEqIrdYcZpwYd1FFXODzG0hBq6TS/n6uOvh8xT+2jpJ0FB9RcBzDKrD9lTLc5oZX7eA9W8joPkpg==", "license": "MIT", "dependencies": { "biggystring": "^4.2.3", diff --git a/package.json b/package.json index 7e74850f60f..a0a1fac8080 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-6e29ab42/edge-currency-accountbased-4.86.1-ironwood-ac7aa5d5.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-6e29ab42/react-native-zcash-0.12.1-ironwood-91f50eb.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..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/components/themed/TransactionListTop.tsx b/src/components/themed/TransactionListTop.tsx index d8332b3b2cf..1a14918fdf7 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' @@ -156,6 +161,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 @@ -665,6 +671,96 @@ 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 max + * send-to-self in the ordinary send scene — post-activation, plain + * proposals route outputs and change into Ironwood, so no special + * transaction type exists. 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' + ) + if (unifiedAddress == null) return + 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 + } + ] + }) + } + + return ( + { + handleMigratePress().catch((error: unknown) => { + showError(error) + }) + } + }} + /> + ) + } + function renderButtons(): React.ReactElement { const styles = getStyles(theme) const hideStaking = !isStakingAvailable @@ -795,6 +891,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..238edce4f3c 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -764,6 +764,21 @@ 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). + // 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: 'Move your Orchard funds to Ironwood', + zcash_migration_recommended_body_1s: + "Zcash's Ironwood network upgrade added a new shielded pool, and %s of your ZEC is still in the older Orchard pool. We recommend moving it with a single send 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: 'Move my funds', + zcash_migration_info_tile_label: 'Ironwood migration', + zcash_migration_info_tile_value: + 'This send moves 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', string_first_fantom_wallet_name: 'My Fantom', diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index fe138434cfc..99c4966e2f3 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 your Orchard funds to Ironwood", + "zcash_migration_recommended_body_1s": "Zcash's Ironwood network upgrade added a new shielded pool, and %s of your ZEC is still in the older Orchard pool. We recommend moving it with a single send 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": "Move my funds", + "zcash_migration_info_tile_label": "Ironwood migration", + "zcash_migration_info_tile_value": "This send moves 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", "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..b31df078604 --- /dev/null +++ b/src/util/zcashMigration.ts @@ -0,0 +1,65 @@ +import { + 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. +// +// 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. +// + +/** + * 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 + +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 + } +}