Skip to content
Open
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: 1 addition & 2 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export default [
'src/actions/TransactionExportActions.tsx',
'src/actions/WalletActions.tsx',
'src/actions/WalletListActions.tsx',
'src/actions/WalletListMenuActions.tsx',

'src/app.ts',
'src/components/buttons/ButtonsView.tsx',
'src/components/buttons/EdgeSwitch.tsx',
Expand Down Expand Up @@ -202,7 +202,6 @@ export default [
'src/components/modals/SwapVerifyTermsModal.tsx',
'src/components/modals/TextInputModal.tsx',
'src/components/modals/TransferModal.tsx',
'src/components/modals/WalletListMenuModal.tsx',

'src/components/modals/WalletListSortModal.tsx',
'src/components/modals/WcSmartContractModal.tsx',
Expand Down
26 changes: 16 additions & 10 deletions src/actions/WalletListMenuActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { toggleUserPausedWallet } from './SettingsActions'

export type WalletListMenuKey =
| 'settings'
| 'pocketChange'
| 'rename'
| 'delete'
| 'resync'
Expand Down Expand Up @@ -65,6 +66,11 @@ export function walletListMenuAction(
})
}
}
case 'pocketChange': {

Choose a reason for hiding this comment

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

Warning: Dead code: the case 'pocketChange' handler returns an empty no-op. WalletListMenuModal intercepts and handles pocketChange with an early return before ever calling dispatch(walletListMenuAction(...)), so this branch is never reached.

Recommendation: Remove the case 'pocketChange' block entirely (let it fall through to default) to avoid misleading future readers. If keeping it, add a comment making it explicit that this path is intentionally unreachable.

return async () => {
// Handled directly in WalletListMenuModal via Airship
}
}
case 'manageTokens': {
return async (dispatch, getState) => {
navigation.navigate('manageTokens', {
Expand All @@ -79,7 +85,7 @@ export function walletListMenuAction(
const { account } = state.core
account
.changeWalletStates({ [walletId]: { deleted: true } })
.catch(error => {
.catch((error: unknown) => {
showError(error)
})
}
Expand Down Expand Up @@ -118,8 +124,8 @@ export function walletListMenuAction(
try {
const fioAddresses =
await engine.otherMethods.getFioAddressNames()
fioAddress = fioAddresses.length ? fioAddresses[0] : ''
} catch (e: any) {
fioAddress = fioAddresses.length > 0 ? fioAddresses[0] : ''
} catch (e: unknown) {
fioAddress = ''
}
}
Expand All @@ -129,7 +135,7 @@ export function walletListMenuAction(
let additionalMsg: string | undefined
let tokenCurrencyCode: string | undefined
if (tokenId == null) {
if (fioAddress) {
if (fioAddress !== '') {
additionalMsg =
lstrings.fragmet_wallets_delete_fio_extra_message_mobile
} else if (Object.keys(wallet.currencyConfig.allTokens).length > 0) {
Expand All @@ -155,7 +161,7 @@ export function walletListMenuAction(
)} ${wallet.type} ${wallet.id}`
)
})
.catch(error => {
.catch((error: unknown) => {
showError(error)
})

Expand All @@ -176,7 +182,7 @@ export function walletListMenuAction(
} ${tokenId}`
)
})
.catch(error => {
.catch((error: unknown) => {
showError(error)
})
}
Expand Down Expand Up @@ -297,8 +303,8 @@ export function walletListMenuAction(
)
// Add a copy button only for development
let devButtons = {}
// @ts-expect-error
if (global.__DEV__)
// @ts-expect-error - __DEV__ is a RN global not in TS types
if (global.__DEV__ === true)
devButtons = {
copy: { label: lstrings.fragment_wallets_copy_seed }
}
Expand All @@ -313,8 +319,8 @@ export function walletListMenuAction(
buttons={{ ok: { label: lstrings.string_ok_cap }, ...devButtons }}
/>
)).then(buttonPressed => {
// @ts-expect-error
if (global.__DEV__ && buttonPressed === 'copy') {
// @ts-expect-error - __DEV__ is a RN global not in TS types
if (global.__DEV__ === true && buttonPressed === 'copy') {
Clipboard.setString(privateKey)
showToast(lstrings.fragment_wallets_copied_seed)
}
Expand Down
125 changes: 125 additions & 0 deletions src/components/modals/PocketChangeModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import * as React from 'react'
import { View } from 'react-native'
import type { AirshipBridge } from 'react-native-airship'

import { useHandler } from '../../hooks/useHandler'
import { lstrings } from '../../locales/strings'
import { EdgeButton } from '../buttons/EdgeButton'
import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext'
import { SettingsHeaderRow } from '../settings/SettingsHeaderRow'
import { SettingsSwitchRow } from '../settings/SettingsSwitchRow'
import { EdgeText, Paragraph } from '../themed/EdgeText'
import { EdgeModal } from './EdgeModal'

const POCKET_AMOUNTS_XMR = [0.1, 0.2, 0.3, 0.5, 0.8, 1.3]

Choose a reason for hiding this comment

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

Issue: Floating-point numbers used for crypto amounts: POCKET_AMOUNTS_XMR = [0.1, 0.2, 0.3, 0.5, 0.8, 1.3]. JavaScript IEEE-754 floats are imprecise and should never be used to represent cryptocurrency denominations.

Recommendation: Store amounts as strings in the coin's smallest unit (piconero for XMR), or at minimum as integer multipliers of a fixed base unit. Convert to display strings using the same div/bignum helpers used elsewhere in the codebase (e.g., div(nativeAmount, multiplier, DECIMAL_PRECISION)).


export interface PocketChangeConfig {
enabled: boolean
amountIndex: number
}

interface Props {
bridge: AirshipBridge<PocketChangeConfig | undefined>
initialConfig: PocketChangeConfig
}

export const PocketChangeModal: React.FC<Props> = props => {
const { bridge, initialConfig } = props
const theme = useTheme()
const styles = getStyles(theme)
const [enabled, setEnabled] = React.useState(initialConfig.enabled)
const [amountIndex, setAmountIndex] = React.useState(
initialConfig.amountIndex
)

const handleCancel = useHandler((): void => {
bridge.resolve(undefined)
})

const handleSave = useHandler((): void => {
bridge.resolve({ enabled, amountIndex })
})

const handleToggle = useHandler((): void => {
setEnabled(prev => !prev)
})

const handleDecrease = useHandler((): void => {

Choose a reason for hiding this comment

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

Suggestion: No unit tests cover the stepper bounds logic (handleDecrease, handleIncrease) or the Save/Cancel flow.

Recommendation: Add a test file at src/__tests__/modals/PocketChangeModal.test.tsx covering: initial state, upper bound clamping, lower bound clamping, Save resolving with config, Cancel resolving with undefined.

setAmountIndex(prev => Math.max(0, prev - 1))
})

const handleIncrease = useHandler((): void => {
setAmountIndex(prev => Math.min(POCKET_AMOUNTS_XMR.length - 1, prev + 1))
})

return (
<EdgeModal
bridge={bridge}
onCancel={handleCancel}
title={lstrings.pocketchange_title}
>
<View style={styles.container}>
<Paragraph>{lstrings.pocketchange_description}</Paragraph>

<SettingsSwitchRow
label={lstrings.pocketchange_enable}
value={enabled}
onPress={handleToggle}
/>

{enabled ? (
<>
<SettingsHeaderRow label={lstrings.pocketchange_amount_header} />

<View style={styles.stepperRow}>
<EdgeButton
label="−"

Choose a reason for hiding this comment

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

Suggestion: "−" and "+" button labels are hardcoded strings not sourced from locale strings.

Recommendation: Add pocketchange_decrease / pocketchange_increase keys (or reuse existing generic symbols) in both locale files and reference via lstrings.

type="secondary"
mini
onPress={handleDecrease}
disabled={amountIndex <= 0}
/>
<EdgeText style={styles.amountText}>
{POCKET_AMOUNTS_XMR[amountIndex]} XMR{' '}

Choose a reason for hiding this comment

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

Warning: "XMR" is a hardcoded currency code in user-facing text. This modal is Monero-specific via pluginIds: ['monero'], but hardcoding the ticker makes it brittle if the feature is extended.

Recommendation: Pass currencyCode as a prop (from the wallet's currencyInfo.currencyCode) and replace the hardcoded XMR with {currencyCode}.

{lstrings.pocketchange_per_pocket}
</EdgeText>
<EdgeButton
label="+"
type="secondary"
mini
onPress={handleIncrease}
disabled={amountIndex >= POCKET_AMOUNTS_XMR.length - 1}
/>
</View>

<Paragraph>{lstrings.pocketchange_explainer}</Paragraph>
</>
) : null}

<View style={styles.saveButton}>
<EdgeButton label={lstrings.pocketchange_save} onPress={handleSave} />
</View>
</View>
</EdgeModal>
)
}

const getStyles = cacheStyles((theme: Theme) => ({
container: {
paddingHorizontal: theme.rem(0.5)
},
stepperRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: theme.rem(0.5)
},
amountText: {
fontSize: theme.rem(1.1),
marginHorizontal: theme.rem(1)
},
saveButton: {
marginTop: theme.rem(1),
marginBottom: theme.rem(0.5)
}
}))
41 changes: 38 additions & 3 deletions src/components/modals/WalletListMenuModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,12 @@ import {
import { getWalletName } from '../../util/CurrencyWalletHelpers'
import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity'
import { CryptoIcon } from '../icons/CryptoIcon'
import { showError } from '../services/AirshipInstance'
import { Airship, showError } from '../services/AirshipInstance'
import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext'
import { UnscaledText } from '../text/UnscaledText'
import { ModalTitle } from '../themed/ModalParts'
import { EdgeModal } from './EdgeModal'
import { type PocketChangeConfig, PocketChangeModal } from './PocketChangeModal'

interface Option {
value: WalletListMenuKey
Expand All @@ -53,6 +54,7 @@ const icons: Record<string, string> = {
getSeed: 'key',
goToParent: 'upcircleo',
manageTokens: 'plus',
pocketChange: 'wallet',
rawDelete: 'warning',
rename: 'edit',
resync: 'sync',
Expand All @@ -75,6 +77,11 @@ export const WALLET_LIST_MENU: Array<{
label: lstrings.settings_asset_settings,
value: 'settings'
},
{
pluginIds: ['monero'],
label: lstrings.pocketchange_menu_item,
value: 'pocketChange'
},
{
label: lstrings.string_rename,
value: 'rename'
Expand Down Expand Up @@ -142,7 +149,7 @@ export const WALLET_LIST_MENU: Array<{
}
]

export function WalletListMenuModal(props: Props) {
export function WalletListMenuModal(props: Props): React.ReactElement {

Choose a reason for hiding this comment

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

Warning: WalletListMenuModal is exported as a plain function declaration (export function WalletListMenuModal(props: Props): React.ReactElement) rather than the project-convention React.FC<Props> pattern.

Recommendation: Change to export const WalletListMenuModal: React.FC<Props> = props => { ... }

const { bridge, tokenId, navigation, walletId } = props

const [options, setOptions] = React.useState<Option[]>([])
Expand All @@ -161,13 +168,41 @@ export function WalletListMenuModal(props: Props) {
const theme = useTheme()
const styles = getStyles(theme)

const handleCancel = () => {
const handleCancel = (): void => {

Choose a reason for hiding this comment

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

Warning: handleCancel is a plain arrow function not wrapped in useHandler, inconsistent with all other handlers in this file and the codebase convention.

Recommendation: Wrap with useHandler: const handleCancel = useHandler((): void => { props.bridge.resolve() })

props.bridge.resolve()
}

const optionAction = useHandler(async (option: WalletListMenuKey) => {
if (loadingOption != null) return // Prevent multiple actions

if (option === 'pocketChange' && wallet != null) {
setLoadingOption(option)
try {
let initialConfig: PocketChangeConfig = {

Choose a reason for hiding this comment

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

Warning: The initialConfig default { enabled: false, amountIndex: 2 } is hardcoded inline with no bounds validation on saved.amountIndex. If the plugin returns an out-of-bounds index, POCKET_AMOUNTS_XMR[amountIndex] will be undefined.

Recommendation: Export a DEFAULT_POCKET_CHANGE_CONFIG constant from PocketChangeModal.tsx and validate/clamp saved.amountIndex against POCKET_AMOUNTS_XMR.length - 1 before using it.

enabled: false,
amountIndex: 2
}
if (wallet.otherMethods?.getPocketChangeSetting != null) {

Choose a reason for hiding this comment

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

Warning: wallet.otherMethods is untyped (any). Calls to getPocketChangeSetting, setPocketChangeSetting (here) and getPocketChangeTargetsForSpend (in SendScene2) have no compile-time safety—wrong argument shapes or return types will fail silently at runtime.

Recommendation: Define a local interface MoneroOtherMethods with the expected method signatures and cast wallet.otherMethods to it after the null-check.

const saved = await wallet.otherMethods.getPocketChangeSetting()
if (saved != null) initialConfig = saved
}
const result = await Airship.show<PocketChangeConfig | undefined>(b => (
<PocketChangeModal bridge={b} initialConfig={initialConfig} />
))
if (
result != null &&
wallet.otherMethods?.setPocketChangeSetting != null
) {
await wallet.otherMethods.setPocketChangeSetting(result)

Choose a reason for hiding this comment

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

Warning: In the pocketChange success path, setLoadingOption(null) is never called before bridge.resolve(). If the modal stays mounted briefly (animation, test), the loading spinner remains stuck. The error path correctly resets it.

Recommendation: Add setLoadingOption(null) before bridge.resolve() in the success path, or use a finally block.

}
bridge.resolve()
} catch (error) {

Choose a reason for hiding this comment

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

Warning: catch (error) is missing the unknown type annotation, leaving error implicitly typed as any.

Recommendation: Change to catch (error: unknown).

setLoadingOption(null)
showError(error)
}
return
}

setLoadingOption(option)
try {
await dispatch(
Expand Down
Loading