Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,17 @@ jest.mock('@/features/payments/shared/hooks/usePaymentRecorder', () => ({
reset: jest.fn(),
}),
}))
// address book: react-query backed; these tests pin the charge-completion paths only
jest.mock('@/hooks/useSavedAddresses', () => ({
useSavedAddresses: () => ({
savedAddresses: [],
isLoading: false,
findSaved: () => undefined,
save: { mutate: jest.fn() },
rename: { mutateAsync: jest.fn() },
remove: { mutateAsync: jest.fn() },
}),
}))

import WithdrawCryptoPage from '../page'

Expand Down
53 changes: 49 additions & 4 deletions src/app/(mobile-ui)/withdraw/crypto/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
import { useTranslations } from 'next-intl'
import { resolveSettledTxHash } from '@/utils/settled-tx-hash.utils'
import { toError } from '@/utils/to-error'
import { useSavedAddresses } from '@/hooks/useSavedAddresses'
import SaveAddressPrompt from '@/components/Withdraw/AddressBook/SaveAddressPrompt'
import { savedAddressLabel } from '@/utils/saved-address.utils'

export default function WithdrawCryptoPage() {
const router = useRouter()
Expand Down Expand Up @@ -115,6 +118,14 @@ export default function WithdrawCryptoPage() {

// local state for transaction execution
const [isSendingTx, setIsSendingTx] = useState(false)
// crypto address book: prompt to save the destination on review, label it when already saved
const { findSaved, save: saveAddress } = useSavedAddresses()
const [saveToBook, setSaveToBook] = useState(false)
const [bookNickname, setBookNickname] = useState('')
const existingSaved = withdrawData ? findSaved(withdrawData.chain.chainId, withdrawData.address) : undefined
const trimmedBookNickname = bookNickname.trim()
// success screen: the saved nickname, or the one just chosen at submit
const successNickname = existingSaved?.nickname ?? (saveToBook && trimmedBookNickname ? trimmedBookNickname : null)

// combined processing state
const isProcessing = useMemo(() => isSendingTx || isRecording, [isSendingTx, isRecording])
Expand Down Expand Up @@ -191,6 +202,9 @@ export default function WithdrawCryptoPage() {

const handleSetupReview = useCallback(
async (data: Omit<WithdrawData, 'amount'>) => {
// fresh review → fresh save prompt (a previous destination's nickname must not carry over)
setSaveToBook(false)
setBookNickname('')
if (!amountToWithdraw) {
console.error('Amount to withdraw is not set or not available from context')
setError(t('errors.amountMissing'))
Expand Down Expand Up @@ -343,6 +357,15 @@ export default function WithdrawCryptoPage() {
clearErrors()
setIsSendingTx(true)

// save-at-submit: fire alongside the on-chain leg; a failure here must not block the withdraw
if (!existingSaved && saveToBook && trimmedBookNickname) {
saveAddress.mutate({
address: withdrawData.address,
chainId: withdrawData.chain.chainId,
nickname: trimmedBookNickname,
})
}

posthog.capture(ANALYTICS_EVENTS.WITHDRAW_CONFIRMED, {
amount_usd: usdAmount,
method_type: 'crypto',
Expand Down Expand Up @@ -527,6 +550,10 @@ export default function WithdrawCryptoPage() {
triggerHaptic,
t,
toFriendlyError,
existingSaved,
saveToBook,
trimmedBookNickname,
saveAddress,
])

const handleBackFromConfirm = useCallback(() => {
Expand Down Expand Up @@ -638,6 +665,18 @@ export default function WithdrawCryptoPage() {
insufficientBalance={insufficientForFee}
belowMinimumMessage={belowMinimumMessage}
isFromSendFlow={isFromSendFlow}
toNickname={existingSaved?.nickname}
confirmDisabled={!existingSaved && saveToBook && !trimmedBookNickname}
saveAddressPrompt={
!existingSaved && (
<SaveAddressPrompt
checked={saveToBook}
nickname={bookNickname}
onCheckedChange={setSaveToBook}
onNicknameChange={setBookNickname}
/>
)
}
/>
)}

Expand All @@ -659,10 +698,16 @@ export default function WithdrawCryptoPage() {
paymentDetails={paymentDetails}
usdAmount={usdAmount}
message={
<AddressLink
className="text-sm font-normal text-grey-1 no-underline"
address={withdrawData.address}
/>
successNickname ? (
<span className="text-sm font-normal text-grey-1">
{savedAddressLabel(successNickname, withdrawData.address)}
</span>
) : (
<AddressLink
className="text-sm font-normal text-grey-1 no-underline"
address={withdrawData.address}
/>
)
}
/>
</>
Expand Down
109 changes: 80 additions & 29 deletions src/components/AddWithdraw/AddWithdrawRouterView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
import { useRouter, useSearchParams } from 'next/navigation'
import { useSendFlowOrigin } from '@/hooks/useSendFlowOrigin'
import { addMoneyCountryUrl, withdrawCountryUrl, rewriteMethodPath } from '@/utils/native-routes'
import { type FC, useEffect, useRef, useState, useTransition, useCallback } from 'react'
import { type FC, useEffect, useRef, useState, useTransition, useCallback, useContext } from 'react'
import { useUserStore } from '@/redux/hooks'
import { AccountType, type Account } from '@/interfaces/interfaces'
import { useWithdrawFlow } from '@/context/WithdrawFlowContext'
Expand All @@ -26,6 +26,10 @@ import TokenAndNetworkConfirmationModal from '../Global/TokenAndNetworkConfirmat
import posthog from 'posthog-js'
import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
import { useTranslations } from 'next-intl'
import { useSavedAddresses } from '@/hooks/useSavedAddresses'
import SavedAddressEditDrawer from '@/components/Withdraw/AddressBook/SavedAddressEditDrawer'
import { tokenSelectorContext } from '@/context/tokenSelector.context'
import type { SavedAddress } from '@/interfaces/interfaces'

interface AddWithdrawRouterViewProps {
flow: 'add' | 'withdraw'
Expand Down Expand Up @@ -68,8 +72,23 @@ export const AddWithdrawRouterView: FC<AddWithdrawRouterViewProps> = ({
const t = useTranslations('withdraw')
const tAddMoney = useTranslations('addMoney')
const tCommon = useTranslations('common')
const { setSelectedBankAccount, showAllWithdrawMethods, setShowAllWithdrawMethods, setSelectedMethod } =
useWithdrawFlow()
const {
setSelectedBankAccount,
showAllWithdrawMethods,
setShowAllWithdrawMethods,
setSelectedMethod,
setRecipient,
setIsValidRecipient,
} = useWithdrawFlow()
// crypto address book — only meaningful on the withdraw flow, hook is cheap otherwise
const {
savedAddresses,
isLoading: isLoadingSavedAddresses,
rename: renameSavedAddress,
remove: removeSavedAddress,
} = useSavedAddresses({ enabled: flow === 'withdraw' })
const { setSelectedChainID, setSelectedTokenAddress, supportedChainsAndTokens } = useContext(tokenSelectorContext)
const [editingSavedAddress, setEditingSavedAddress] = useState<SavedAddress | null>(null)
const onrampFlowContext = useOnrampFlow()
const { setFromBankSelected } = onrampFlowContext
const [recentMethodsState, setRecentMethodsState] = useState<RecentMethod[]>([])
Expand Down Expand Up @@ -190,18 +209,32 @@ export const AddWithdrawRouterView: FC<AddWithdrawRouterViewProps> = ({

const defaultBackNavigation = () => router.push('/home')

// address-book tap: preselect chain + USDC on it, prefill the destination, then
// pick the crypto method exactly like the "Crypto" tile (no navigation — the
// withdraw page owns the amount step and pushes /withdraw/crypto after Continue)
const handleSavedAddressClick = (saved: SavedAddress) => {
const tokens = supportedChainsAndTokens?.[saved.chainId]?.tokens ?? []
// USDC where the chain has it; otherwise the chain's only/first token (Tron → USDT)
const token = tokens.find((t) => t.symbol.toUpperCase() === 'USDC') ?? tokens[0]
setSelectedChainID(saved.chainId)

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.

BLOCKING: Preserve the saved destination network

Tapping a saved Base entry sets chain 8453 here, but after the amount step opens /withdraw/crypto, InitialWithdrawView mounts with withdrawData still null and resets the token context to the Peanut wallet chain. Because both networks are EVM, the recipient remains valid and Review can send that address on Arbitrum instead of Base; Tron/Solana entries are cleared by the family-change effect instead. Carry the address-book selection into the crypto screen (or skip its default reset for an explicit prefill) and cover the row-to-review transition in a test.

setSelectedTokenAddress(token?.address ?? '')
setRecipient({ name: undefined, address: saved.address })
setIsValidRecipient(true)
handleMethodSelected({ id: 'crypto', type: 'crypto', title: 'Crypto', path: 'crypto' })
}

// check if we're coming from request fulfillment or similar flow
const fromRequestFulfillment = typeof window !== 'undefined' && getFromLocalStorage('fromRequestFulfillment')

if (isLoadingPreferences) {
if (isLoadingPreferences || (flow === 'withdraw' && isLoadingSavedAddresses)) {
return (
<div className="flex min-h-[inherit] flex-col justify-center gap-8">
<PeanutLoading />
</div>
)
}

if (flow === 'withdraw' && savedAccounts.length === 0 && !shouldShowAllMethods) {
if (flow === 'withdraw' && savedAccounts.length === 0 && savedAddresses.length === 0 && !shouldShowAllMethods) {
return (
<div className="flex min-h-[inherit] flex-col justify-start gap-8">
<NavHeader title={pageTitle} onPrev={onBackClick || defaultBackNavigation} />
Expand All @@ -224,30 +257,41 @@ export const AddWithdrawRouterView: FC<AddWithdrawRouterViewProps> = ({
}

// Render saved accounts for withdraw flow if they exist and we're not in 'showAll' mode
if (flow === 'withdraw' && !shouldShowAllMethods && savedAccounts.length > 0) {
if (flow === 'withdraw' && !shouldShowAllMethods && (savedAccounts.length > 0 || savedAddresses.length > 0)) {
return (
<SavedAccountsView
pageTitle={pageTitle}
onPrev={onBackClick || defaultBackNavigation}
savedAccounts={savedAccounts}
onAccountClick={(account, path) => {
setSelectedBankAccount(account)
const countryPath = account.details?.countryName || path || ''
setSelectedMethod({
type: account.type === AccountType.MANTECA ? 'manteca' : 'bridge',
countryPath,
title: 'To Bank',
})
if (account.type === AccountType.MANTECA) {
// preserve method param if coming from send flow
const additionalParams = isBankFromSend ? `&method=${methodParam}` : ''
router.push(
`/withdraw/manteca?country=${encodeURIComponent(countryPath)}&destination=${encodeURIComponent(account.identifier)}&isSavedAccount=true${additionalParams}`
)
}
}}
onSelectNewMethodClick={() => setShouldShowAllMethods(true)}
/>
<>
<SavedAddressEditDrawer
saved={editingSavedAddress}
onClose={() => setEditingSavedAddress(null)}
onRename={(id, nickname) => renameSavedAddress.mutateAsync({ id, nickname })}
onDelete={(id) => removeSavedAddress.mutateAsync(id)}
/>
<SavedAccountsView
pageTitle={pageTitle}
onPrev={onBackClick || defaultBackNavigation}
savedAccounts={savedAccounts}
onAccountClick={(account, path) => {
setSelectedBankAccount(account)
const countryPath = account.details?.countryName || path || ''
setSelectedMethod({
type: account.type === AccountType.MANTECA ? 'manteca' : 'bridge',
countryPath,
title: 'To Bank',
})
if (account.type === AccountType.MANTECA) {
// preserve method param if coming from send flow
const additionalParams = isBankFromSend ? `&method=${methodParam}` : ''
router.push(
`/withdraw/manteca?country=${encodeURIComponent(countryPath)}&destination=${encodeURIComponent(account.identifier)}&isSavedAccount=true${additionalParams}`
)
}
}}
onSelectNewMethodClick={() => setShouldShowAllMethods(true)}
savedAddresses={savedAddresses}
onSavedAddressClick={handleSavedAddressClick}
onSavedAddressEdit={setEditingSavedAddress}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/>
</>
)
}

Expand Down Expand Up @@ -310,7 +354,10 @@ export const AddWithdrawRouterView: FC<AddWithdrawRouterViewProps> = ({
}

// otherwise, use toggle logic for better ux when user manually navigated to "select new method"
if (shouldShowAllMethods && (recentMethodsState.length > 0 || savedAccounts.length > 0)) {
if (
shouldShowAllMethods &&
(recentMethodsState.length > 0 || savedAccounts.length > 0 || savedAddresses.length > 0)
) {
setShouldShowAllMethods(false)
} else if (onBackClick) {
onBackClick()
Expand Down Expand Up @@ -385,6 +432,10 @@ export const AddWithdrawRouterView: FC<AddWithdrawRouterViewProps> = ({
// to /withdraw/crypto after Continue. navigating here (pre-amount)
// trips the crypto page's "no amount" redirect guard, whose unmount
// cleanup resets the whole flow back to saved accounts.
// the plain tile is a fresh destination — drop anything an
// address-book tap left in the recipient state
setRecipient({ name: undefined, address: '' })
setIsValidRecipient(false)
handleMethodSelected({ id: 'crypto', type: 'crypto', title: 'Crypto', path: 'crypto' })
}
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,20 @@ jest.mock('../../Global/TokenAndNetworkConfirmationModal', () => ({
__esModule: true,
default: () => null,
}))
// address book is react-query backed; this file pins the bank-account paths only
jest.mock('@/hooks/useSavedAddresses', () => ({
useSavedAddresses: () => ({
savedAddresses: [],
isLoading: false,
findSaved: () => undefined,
rename: { mutateAsync: jest.fn() },
remove: { mutateAsync: jest.fn() },
}),
}))
jest.mock('../../Withdraw/AddressBook/SavedAddressEditDrawer', () => ({
__esModule: true,
default: () => null,
}))

import { AddWithdrawRouterView } from '../AddWithdrawRouterView'
import { WithdrawFlowContextProvider, useWithdrawFlow } from '@/context/WithdrawFlowContext'
Expand Down
30 changes: 25 additions & 5 deletions src/components/Common/SavedAccountsView.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
'use client'
import { countryData as ALL_METHODS_DATA, ALL_COUNTRIES_ALPHA3_TO_ALPHA2 } from '@/components/AddMoney/consts'
import { formatIban } from '@/utils/general.utils'
import { AccountType, type Account } from '@/interfaces/interfaces'
import { AccountType, type Account, type SavedAddress } from '@/interfaces/interfaces'
import SavedAddressesList from '@/components/Withdraw/AddressBook/SavedAddressesList'
import Image from 'next/image'
import { useTranslations } from 'next-intl'
import { Icon } from '@/components/Global/Icons/Icon'
Expand All @@ -18,6 +19,10 @@ interface SavedAccountListProps {
savedAccounts: Account[]
onAccountClick: (account: Account, path: string) => void
onSelectNewMethodClick: () => void
/** Crypto address book — rendered as its own list under the bank accounts. */
savedAddresses?: SavedAddress[]
onSavedAddressClick?: (saved: SavedAddress) => void
onSavedAddressEdit?: (saved: SavedAddress) => void
}

/**
Expand All @@ -36,17 +41,32 @@ export default function SavedAccountsView({
savedAccounts,
onAccountClick,
onSelectNewMethodClick,
savedAddresses = [],
onSavedAddressClick,
onSavedAddressEdit,
}: SavedAccountListProps) {
const t = useTranslations('global')
const tCommon = useTranslations('common')
return (
<div className="flex min-h-[inherit] flex-col justify-normal gap-8">
<NavHeader title={pageTitle} onPrev={onPrev} />
<div className="space-y-4">
<div className="flex h-full flex-col justify-center space-y-2">
<h2 className="text-base font-bold">{t('savedAccounts.title')}</h2>
<SavedAccountsMapping accounts={savedAccounts} onItemClick={onAccountClick} />
</div>
{savedAccounts.length > 0 && (
<div className="flex h-full flex-col justify-center space-y-2">
<h2 className="text-base font-bold">{t('savedAccounts.title')}</h2>
<SavedAccountsMapping accounts={savedAccounts} onItemClick={onAccountClick} />
</div>
)}
{savedAddresses.length > 0 && onSavedAddressClick && onSavedAddressEdit && (
<div className="flex h-full flex-col justify-center space-y-2">
<h2 className="text-base font-bold">{t('savedAddresses.title')}</h2>
<SavedAddressesList
savedAddresses={savedAddresses}
onSelect={onSavedAddressClick}
onEdit={onSavedAddressEdit}
/>
</div>
)}
<Divider textClassname="font-bold text-grey-1" dividerClassname="bg-grey-1" text={tCommon('or')} />
<Button icon="plus" onClick={onSelectNewMethodClick} shadowSize="4">
{t('savedAccounts.selectNewMethod')}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { EHistoryUserRole, type HistoryEntry } from '@/hooks/useTransactionHistory'
import { type TransactionStrategy, type TransactionStrategyOutput } from '../types'
import { TRANSACTION_NAME_KEYS } from '@/components/TransactionDetails/transaction-name-keys'
import { savedAddressLabel } from '@/utils/saved-address.utils'

export const cryptoDeposit: TransactionStrategy = (entry: HistoryEntry): TransactionStrategyOutput => ({
direction: 'add',
Expand Down Expand Up @@ -33,7 +34,12 @@ export const cryptoWithdraw: TransactionStrategy = (entry: HistoryEntry): Transa
return {
direction: 'withdraw',
transactionCardType: 'withdraw',
nameForDetails: entry.recipientAccount?.identifier || 'External Account',
// Address-book nickname rides on extraData.savedAddressNickname → "Binance · …aec9"
nameForDetails: entry.recipientAccount?.identifier
? entry.extraData?.savedAddressNickname
? savedAddressLabel(entry.extraData.savedAddressNickname, entry.recipientAccount.identifier)
: entry.recipientAccount.identifier
: 'External Account',
nameKey: entry.recipientAccount?.identifier ? undefined : TRANSACTION_NAME_KEYS.externalAccount,
isPeerActuallyUser: false,
isLinkTx: false,
Expand Down
Loading
Loading