diff --git a/components/DepositOption/WalletDepositAddress/WalletDepositSelectors.tsx b/components/DepositOption/WalletDepositAddress/WalletDepositSelectors.tsx index 091d7eb29..4ce206c7f 100644 --- a/components/DepositOption/WalletDepositAddress/WalletDepositSelectors.tsx +++ b/components/DepositOption/WalletDepositAddress/WalletDepositSelectors.tsx @@ -5,6 +5,7 @@ import { Check, ChevronDown } from 'lucide-react-native'; import { Text } from '@/components/ui/text'; import { DEPOSIT_MODAL } from '@/constants/modals'; +import { useCardProvider } from '@/hooks/useCardProvider'; import { cn } from '@/lib/utils'; import { useDepositStore } from '@/store/useDepositStore'; @@ -12,6 +13,7 @@ import { getWalletDepositNetworks, getWalletDepositNetworksForToken, getWalletDepositTokenIcon, + offersWalletDepositEurc, } from './constants'; const PILL_ICON_STYLE = { width: 20, height: 20, borderRadius: 10 }; @@ -142,7 +144,12 @@ export const WalletDepositNetworkList = ({ onSelect, onDismiss, }: WalletDepositNetworkListProps) => { - const networks = useMemo(() => getWalletDepositNetworksForToken(symbol), [symbol]); + const { provider } = useCardProvider(); + const withEurc = offersWalletDepositEurc(provider); + const networks = useMemo( + () => getWalletDepositNetworksForToken(symbol, withEurc), + [symbol, withEurc], + ); return ( <> diff --git a/components/DepositOption/WalletDepositAddress/WalletDepositTokens.tsx b/components/DepositOption/WalletDepositAddress/WalletDepositTokens.tsx index 21961ea31..db0c0f24a 100644 --- a/components/DepositOption/WalletDepositAddress/WalletDepositTokens.tsx +++ b/components/DepositOption/WalletDepositAddress/WalletDepositTokens.tsx @@ -6,9 +6,14 @@ import { Check } from 'lucide-react-native'; import CardFundGroup from '@/components/Card/CardFund/CardFundGroup'; import CardFundRow from '@/components/Card/CardFund/CardFundRow'; import { DEPOSIT_MODAL } from '@/constants/modals'; +import { useCardProvider } from '@/hooks/useCardProvider'; import { useDepositStore } from '@/store/useDepositStore'; -import { getAllWalletDepositTokens, resolveWalletDepositChain } from './constants'; +import { + getAllWalletDepositTokens, + offersWalletDepositEurc, + resolveWalletDepositChain, +} from './constants'; const TOKEN_ICON_STYLE = { width: 36, height: 36, borderRadius: 18 }; @@ -30,12 +35,14 @@ const WalletDepositTokens = () => { // picked reads as a decision already made for them. const selected = walletDeposit.isChangingToken ? walletDeposit.symbol : undefined; - const tokens = useMemo(() => getAllWalletDepositTokens(), []); + const { provider } = useCardProvider(); + const withEurc = offersWalletDepositEurc(provider); + const tokens = useMemo(() => getAllWalletDepositTokens(withEurc), [withEurc]); const handleSelect = (symbol: string) => { setWalletDeposit({ symbol, - chainId: resolveWalletDepositChain(symbol, walletDeposit.chainId), + chainId: resolveWalletDepositChain(symbol, walletDeposit.chainId, withEurc), isChangingToken: false, }); setModal(DEPOSIT_MODAL.OPEN_PUBLIC_ADDRESS); diff --git a/components/DepositOption/WalletDepositAddress/__tests__/walletDepositConstants.test.ts b/components/DepositOption/WalletDepositAddress/__tests__/walletDepositConstants.test.ts index 962dfb434..1bd537f77 100644 --- a/components/DepositOption/WalletDepositAddress/__tests__/walletDepositConstants.test.ts +++ b/components/DepositOption/WalletDepositAddress/__tests__/walletDepositConstants.test.ts @@ -8,6 +8,7 @@ import { getWalletDepositNetworksForToken, getWalletDepositTokenIcon, getWalletDepositTokens, + offersWalletDepositEurc, resolveWalletDepositChain, resolveWalletDepositMinimum, resolveWalletDepositSymbol, @@ -90,6 +91,7 @@ describe('usesDirectDepositAddress', () => { it('mints for a Wirex cardholder sending a stablecoin', () => { expect(usesDirectDepositAddress('USDC', CardProvider.WIREX)).toBe(true); expect(usesDirectDepositAddress('USDT', CardProvider.WIREX)).toBe(true); + expect(usesDirectDepositAddress('EURC', CardProvider.WIREX)).toBe(true); }); // Minting resolves by issuer: for Rain it would deliver to the card, and with @@ -241,3 +243,48 @@ describe('resolveWalletDepositChain', () => { expect(resolveWalletDepositChain('ETH', polygon.id)).toBe(mainnet.id); }); }); + +/** + * EURC is offered to Wirex cardholders only, on Base only, and through the + * minted address - the pipeline delivers it to their Safe on Base. + */ +describe('EURC', () => { + it('is offered to Wirex cardholders only', () => { + expect(offersWalletDepositEurc(CardProvider.WIREX)).toBe(true); + expect(offersWalletDepositEurc(CardProvider.RAIN)).toBe(false); + expect(offersWalletDepositEurc(null)).toBe(false); + expect(offersWalletDepositEurc(undefined)).toBe(false); + }); + + it('is left out unless asked for', () => { + expect(getAllWalletDepositTokens().map(token => token.symbol)).not.toContain('EURC'); + expect(getWalletDepositTokens(base.id).map(token => token.symbol)).toEqual(['USDC']); + }); + + it('follows the stablecoins in "Select token"', () => { + const symbols = getAllWalletDepositTokens(true).map(token => token.symbol); + expect(symbols.slice(0, 4)).toEqual(['USDC', 'USDT', 'EURC', 'ETH']); + }); + + it('is offered on Base only', () => { + expect(getWalletDepositTokens(base.id, true).map(token => token.symbol)).toEqual([ + 'USDC', + 'EURC', + ]); + expect(getWalletDepositTokens(mainnet.id, true).map(token => token.symbol)).not.toContain( + 'EURC', + ); + expect(getWalletDepositNetworksForToken('EURC', true).map(network => network.chainId)).toEqual([ + base.id, + ]); + }); + + it('opens on Base, whatever chain was showing', () => { + expect(resolveWalletDepositChain('EURC', undefined, true)).toBe(base.id); + expect(resolveWalletDepositChain('EURC', fuse.id, true)).toBe(base.id); + }); + + it('carries its own icon', () => { + expect(getWalletDepositTokenIcon(base.id, 'EURC')).toBe('images/eurc.png'); + }); +}); diff --git a/components/DepositOption/WalletDepositAddress/constants.ts b/components/DepositOption/WalletDepositAddress/constants.ts index ee1465f57..ce3a96dc5 100644 --- a/components/DepositOption/WalletDepositAddress/constants.ts +++ b/components/DepositOption/WalletDepositAddress/constants.ts @@ -1,7 +1,7 @@ import { ImageSourcePropType } from 'react-native'; import { arbitrum, base, bsc, fuse, mainnet, polygon } from 'viem/chains'; -import { BRIDGE_TOKENS } from '@/constants/bridge'; +import { BRIDGE_TOKENS, EURC_TOKENS } from '@/constants/bridge'; import { getAsset } from '@/lib/assets'; import { CardProvider, DepositAsset } from '@/lib/types'; import { getAllowedTokensForChain, getVaultDepositConfig } from '@/lib/vaults'; @@ -60,7 +60,20 @@ const MINIMUM_DEPOSIT_BY_TOKEN: Record = { const DEFAULT_MINIMUM_DEPOSIT = 1; /** The stablecoins the deposit pipeline has a route for. */ -const DIRECT_DEPOSIT_SYMBOLS = new Set(['USDC', 'USDT']); +const DIRECT_DEPOSIT_SYMBOLS = new Set(['USDC', 'USDT', 'EURC']); + +/** EURC, which the screen offers on `EURC_TOKENS`' chains rather than `BRIDGE_TOKENS`'. */ +const EURC_SYMBOL = 'EURC'; + +/** + * Whether the screen offers EURC at all: to Wirex cardholders only. + * + * The pipeline delivers it as EURC to their Safe on Base, the balance the Base + * spend-module instance sells for euro purchases. For anyone else it would be + * a balance nothing spends, so they are not offered it. + */ +export const offersWalletDepositEurc = (provider: CardProvider | null | undefined): boolean => + provider === CardProvider.WIREX; /** * Whether the deposit address is one the pipeline mints, rather than the Safe. @@ -72,7 +85,9 @@ const DIRECT_DEPOSIT_SYMBOLS = new Set(['USDC', 'USDT']); * Fuse, which is the same balance their card settles from. For a Rain * cardholder it would deliver to the card, and for someone with no card there * is no issuer to resolve — so everyone else is shown the Safe, everywhere. - * - A stablecoin. ETH, WETH, FUSE and WFUSE have no route through the pipeline; + * - A stablecoin. EURC counts: for it the pipeline delivers to their Safe on + * Base instead, where the card's euro balance is spent from. + * ETH, WETH, FUSE and WFUSE have no route through the pipeline; * they land in the Safe and stay as the token that was sent, so there the Safe * address is the right answer rather than a fallback. */ @@ -85,7 +100,7 @@ export const usesDirectDepositAddress = ( * The order "Select token" leads with. The stablecoins people actually deposit * come first, then ETH; everything else follows in network order. */ -const TOKEN_DISPLAY_ORDER = ['USDC', 'USDT', 'ETH']; +const TOKEN_DISPLAY_ORDER = ['USDC', 'USDT', 'EURC', 'ETH']; /** * The contract the pipeline credits for a native asset. It lists WETH and WFUSE; @@ -104,6 +119,7 @@ const TOKEN_ICON_FALLBACKS: Record = { WETH: getAsset('images/weth.png'), FUSE: getAsset('images/fuse-4x.png'), WFUSE: getAsset('images/wfuse.png'), + EURC: getAsset('images/eurc.png'), }; export const getWalletDepositTokenIcon = (chainId: number, symbol: string): ImageSourcePropType => { @@ -111,9 +127,16 @@ export const getWalletDepositTokenIcon = (chainId: number, symbol: string): Imag return icon ?? TOKEN_ICON_FALLBACKS[symbol] ?? TOKEN_ICON_FALLBACKS.USDC; }; -/** The currencies that chain accepts, in the order `BRIDGE_TOKENS` lists them. */ -export const getWalletDepositTokens = (chainId: number): WalletDepositToken[] => - getAllowedTokensForChain(chainId).map(symbol => ({ +/** + * The currencies that chain accepts, in the order `BRIDGE_TOKENS` lists them, + * then EURC where `EURC_TOKENS` lists it and `withEurc` (see + * `offersWalletDepositEurc`) says to offer it. + */ +export const getWalletDepositTokens = (chainId: number, withEurc = false): WalletDepositToken[] => + [ + ...getAllowedTokensForChain(chainId), + ...(withEurc && EURC_TOKENS[chainId] ? [EURC_SYMBOL] : []), + ].map(symbol => ({ symbol, icon: getWalletDepositTokenIcon(chainId, symbol), })); @@ -148,8 +171,9 @@ export const getWalletDepositNetworks = (): WalletDepositNetwork[] => { export const resolveWalletDepositSymbol = ( chainId: number, symbol?: string, + withEurc = false, ): string | undefined => { - const tokens = getWalletDepositTokens(chainId); + const tokens = getWalletDepositTokens(chainId, withEurc); return tokens.some(token => token.symbol === symbol) ? symbol : tokens[0]?.symbol; }; @@ -219,7 +243,7 @@ export const getDefaultWalletDepositSelection = (): { chainId: number; symbol: s * is not the order anyone looks for them in. Whatever is not named there follows * in network order. Each is drawn with the icon of the first chain carrying it. */ -export const getAllWalletDepositTokens = (): WalletDepositToken[] => { +export const getAllWalletDepositTokens = (withEurc = false): WalletDepositToken[] => { const { chainId: defaultChainId } = getDefaultWalletDepositSelection(); const chainIds = [ defaultChainId, @@ -229,7 +253,7 @@ export const getAllWalletDepositTokens = (): WalletDepositToken[] => { ]; const seen = new Set(); const tokens = chainIds.flatMap(chainId => - getWalletDepositTokens(chainId).filter(token => { + getWalletDepositTokens(chainId, withEurc).filter(token => { if (seen.has(token.symbol)) return false; seen.add(token.symbol); return true; @@ -249,10 +273,13 @@ export const getAllWalletDepositTokens = (): WalletDepositToken[] => { * carry it, so switching chain can never quietly switch the currency too. All of * them when nothing is chosen yet, or no chain carries it. */ -export const getWalletDepositNetworksForToken = (symbol?: string): WalletDepositNetwork[] => { +export const getWalletDepositNetworksForToken = ( + symbol?: string, + withEurc = false, +): WalletDepositNetwork[] => { const networks = getWalletDepositNetworks(); const carrying = networks.filter(network => - getWalletDepositTokens(network.chainId).some(token => token.symbol === symbol), + getWalletDepositTokens(network.chainId, withEurc).some(token => token.symbol === symbol), ); return carrying.length ? carrying : networks; @@ -265,8 +292,14 @@ export const getWalletDepositNetworksForToken = (symbol?: string): WalletDeposit * from the address screen keeps the chain. Otherwise the default chain (see * `getDefaultWalletDepositSelection`) if it carries it, else the first that does. */ -export const resolveWalletDepositChain = (symbol: string, currentChainId?: number): number => { - const carrying = getWalletDepositNetworksForToken(symbol).map(network => network.chainId); +export const resolveWalletDepositChain = ( + symbol: string, + currentChainId?: number, + withEurc = false, +): number => { + const carrying = getWalletDepositNetworksForToken(symbol, withEurc).map( + network => network.chainId, + ); if (currentChainId !== undefined && carrying.includes(currentChainId)) return currentChainId; const { chainId: defaultChainId } = getDefaultWalletDepositSelection(); diff --git a/components/DepositOption/WalletDepositAddress/index.tsx b/components/DepositOption/WalletDepositAddress/index.tsx index 2c5ac624b..6357344ac 100644 --- a/components/DepositOption/WalletDepositAddress/index.tsx +++ b/components/DepositOption/WalletDepositAddress/index.tsx @@ -22,6 +22,7 @@ import { getDefaultWalletDepositSelection, getWalletDepositNetworks, getWalletDepositTokenIcon, + offersWalletDepositEurc, resolveWalletDepositMinimum, resolveWalletDepositSymbol, WALLET_DEPOSIT_LEARN_URL, @@ -107,11 +108,13 @@ const WalletDepositAddress = () => { (nextChainId: number) => { setWalletDeposit({ chainId: nextChainId, - symbol: resolveWalletDepositSymbol(nextChainId, symbol) ?? symbol, + symbol: + resolveWalletDepositSymbol(nextChainId, symbol, offersWalletDepositEurc(provider)) ?? + symbol, }); setIsNetworkOpen(false); }, - [setWalletDeposit, symbol], + [provider, setWalletDeposit, symbol], ); return ( diff --git a/hooks/useWalletDepositAddress.ts b/hooks/useWalletDepositAddress.ts index caea3cb1a..a1d530fc0 100644 --- a/hooks/useWalletDepositAddress.ts +++ b/hooks/useWalletDepositAddress.ts @@ -19,7 +19,7 @@ export const WALLET_DEPOSIT_ADDRESS_QUERY_KEY = 'wallet-deposit-address'; * * The destination is the card's (`RAIN_CARD`), which is historical naming: the * backend resolves the issuer, and for Wirex that means their Safe on Fuse — - * the balance their card settles from. Which is exactly why the rule is theirs + * the balance their card settles from — or, for EURC, their Safe on Base. Which is exactly why the rule is theirs * alone: the same call for a Rain cardholder would deliver to the card, and for * someone with no card there is no issuer to resolve. * diff --git a/lib/utils/__tests__/depositFee.test.ts b/lib/utils/__tests__/depositFee.test.ts index 83fb31e7e..ee3c03f9f 100644 --- a/lib/utils/__tests__/depositFee.test.ts +++ b/lib/utils/__tests__/depositFee.test.ts @@ -66,6 +66,17 @@ describe('getDepositFeeRatePpm', () => { ); }); + it('charges a Wirex cardholder EURC everywhere but Base, where it is delivered', () => { + expectFreeOnlyOn([base.id], chainId => + getDepositFeeRatePpm({ + provider: CardProvider.WIREX, + product: 'wallet', + chainId, + symbol: 'EURC', + }), + ); + }); + it.each(NON_STABLECOINS)('never charges a Wirex cardholder %s', symbol => { expectFreeOnlyOn(CHAIN_IDS, chainId => getDepositFeeRatePpm({ provider: CardProvider.WIREX, product: 'wallet', chainId, symbol }), diff --git a/lib/utils/depositFee.ts b/lib/utils/depositFee.ts index b6343cc5e..2a6d8d624 100644 --- a/lib/utils/depositFee.ts +++ b/lib/utils/depositFee.ts @@ -14,6 +14,7 @@ import { CardProvider, DepositFeeQuote } from '@/lib/types'; * | -------------- | --------------- | ------------------------------------------------- | * | Fund your card | Rain | Every chain but Base, where the card is funded | * | Fund your card | Either, EURC | Every chain but Base, where EURC is delivered | + * | Wallet | Wirex, EURC | Every chain but Base, where EURC is delivered | * | Wallet | Wirex | Stablecoins, on every chain but Fuse, where the | * | | | Safe the card spends from lives | * | Wallet | No card | Nothing | @@ -86,7 +87,8 @@ export function getDepositFeeRatePpm({ // A EURC card deposit goes to the Safe on Base whoever issued the card, so it // is charged only when it has to be bridged there. - if (product === 'card' && symbol?.toUpperCase() === 'EURC') { + const isEurc = symbol?.toUpperCase() === 'EURC'; + if (isEurc && (product === 'card' || (product === 'wallet' && isWirex))) { return chainId === base.id ? 0 : DEFAULT_DEPOSIT_FEE_RATE_PPM; }