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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
- 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fold into the last commit

- 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)

- added: Changelly swap provider
Expand Down
71 changes: 71 additions & 0 deletions src/__tests__/zcashMigration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, it } from '@jest/globals'
import type { EdgeCurrencyWallet } from 'edge-core-js'

import { getZcashMigrationStatus } from '../util/zcashMigration'

Comment thread
peachbits marked this conversation as resolved.
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()
})
})
133 changes: 133 additions & 0 deletions src/components/cards/ZcashMigrationCard.tsx
Original file line number Diff line number Diff line change
@@ -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'
Comment thread
cursor[bot] marked this conversation as resolved.

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> | 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> = 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<void> => {
await onMigratePress()
})
Comment thread
cursor[bot] marked this conversation as resolved.

const handleLearnMore = useHandler(() => {
const uri = config.zcashMigrationLearnMoreUrl ?? ZCASH_MIGRATION_HELP_URI
openBrowserUri(uri).catch((error: unknown) => {
showError(error)
})
})

return (
<EdgeCard
gradientBackground={theme.cardGradientWarning}
marginRem={[0.5, 0.5, 0, 0.5]}
>
<View style={styles.container}>
<View style={styles.titleContainer}>
<IonIcon
name="warning-outline"
style={styles.icon}
color={theme.primaryText}
size={theme.rem(1.25)}
/>
<EdgeText numberOfLines={0} style={styles.titleText}>
{lstrings.zcash_migration_recommended_title}
</EdgeText>
</View>

{/*
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.
*/}
<EdgeText style={styles.text} numberOfLines={10}>
{sprintf(
lstrings.zcash_migration_recommended_body_1s,
orchardBalanceText
)}{' '}
<EdgeText style={styles.learnMoreLink} onPress={handleLearnMore}>
{lstrings.zcash_migration_learn_more_button}
</EdgeText>
</EdgeText>

<View style={styles.buttonContainer}>
<EdgeButton
label={lstrings.zcash_migration_recommended_button}
layout="solo"
mini
onPress={handleMigrate}
type="primary"
/>
</View>
</View>
</EdgeCard>
)
}

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)
}
}))
8 changes: 7 additions & 1 deletion src/components/common/EdgeCarousel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,13 @@ export function EdgeCarousel<T>(props: Props<T>): React.ReactElement {
<View style={containerStyle}>
{data.map((item, itemIndex) => (
<ItemBox
key={keyExtractor(item, itemIndex)}
// The index is part of the identity on purpose. An item's whole
// position comes from an animated transform, and that transform
// is not re-applied when a surviving item shifts slots: removing
// an item leaves the ones after it parked at their old offsets,
// a full item-width off-screen. Remounting on a slot change
// establishes the transform fresh, which is always correct.
key={`${itemIndex}-${keyExtractor(item, itemIndex)}`}

@j0ntz j0ntz Aug 1, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous issue was a PITA too here. Great catch, thanks.

boxStyle={boxStyle}
itemIndex={itemIndex}
itemWidth={itemWidth}
Expand Down
20 changes: 17 additions & 3 deletions src/components/scenes/SendScene2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1091,8 +1100,13 @@ const SendComponent: React.FC<Props> = props => {

const renderInfoTiles = (): Array<React.ReactElement | null> | null => {
if (infoTiles == null || infoTiles.length === 0) return null
return infoTiles.map(({ label, value }) => (
<EdgeRow key={label} title={label} body={value} />
return infoTiles.map(({ label, value, maximumHeight }) => (
<EdgeRow
key={label}
title={label}
body={value}
maximumHeight={maximumHeight}
/>
))
}

Expand Down
Loading
Loading