diff --git a/assets/images/eurc.png b/assets/images/eurc.png new file mode 100644 index 000000000..f23fbed3c Binary files /dev/null and b/assets/images/eurc.png differ diff --git a/components/Card/CardFund/CardFundOptions.tsx b/components/Card/CardFund/CardFundOptions.tsx index 0584c74da..04b6b1b86 100644 --- a/components/Card/CardFund/CardFundOptions.tsx +++ b/components/Card/CardFund/CardFundOptions.tsx @@ -13,6 +13,7 @@ import { CARD_FUND_TOKENS, CARD_FUND_USD_ICON, CardFundSections, + CardFundToken, getCardFundNetworkChips, getCardFundRoutesTooltip, RAIN_CARD_FUND_SECTIONS, @@ -28,8 +29,13 @@ import { useTransfiCountryAvailability } from '@/hooks/useTransfiCountryAvailabi const TOKEN_ICON_STYLE = { width: 36, height: 36, borderRadius: 18 }; type CardFundOptionsProps = { - /** Stablecoin picked for the direct-deposit flow (USDC / USDT). */ + /** Stablecoin picked for the direct-deposit flow (USDC / USDT / EURC). */ onTokenPress: (symbol: string) => void; + /** + * The stablecoin rows, in display order. Defaults to the dollar stablecoins + * both issuers take; the Wirex flow adds EURC. + */ + tokens?: CardFundToken[]; onMoveFromSavingsPress?: () => void; onExternalWalletPress?: () => void; /** @@ -73,6 +79,7 @@ type CardFundOptionsProps = { /** Step 1 of the card funding flow — "Fund your card". */ const CardFundOptions = ({ onTokenPress, + tokens = CARD_FUND_TOKENS, onMoveFromSavingsPress, onExternalWalletPress, onUsdPress, @@ -99,10 +106,10 @@ const CardFundOptions = ({ {sections.stablecoins ? ( token.symbol))} labelTooltipContext="card_fund_supported_routes" > - {CARD_FUND_TOKENS.map(token => ( + {tokens.map(token => ( } diff --git a/components/Card/CardFund/constants.ts b/components/Card/CardFund/constants.ts index 5b8625dc9..005a8cc2e 100644 --- a/components/Card/CardFund/constants.ts +++ b/components/Card/CardFund/constants.ts @@ -2,7 +2,11 @@ import { ImageSourcePropType } from 'react-native'; import { BRIDGE_TOKENS } from '@/constants/bridge'; import { getAsset } from '@/lib/assets'; -import { CARD_FUND_TOKEN_SYMBOLS, getCardFundChains } from '@/lib/utils/cardFunding'; +import { + CARD_FUND_EURC_SYMBOL, + CARD_FUND_TOKEN_SYMBOLS, + getCardFundChains, +} from '@/lib/utils/cardFunding'; // What the flow offers and accepts lives in its own leaf module so it is unit // testable (this file's import graph pulls `lib/assets`, which does not load @@ -11,6 +15,7 @@ import { CARD_FUND_TOKEN_SYMBOLS, getCardFundChains } from '@/lib/utils/cardFund export type { CardFundSections } from '@/lib/utils/cardFunding'; export { CARD_FUND_DESTINATION_TYPE, + CARD_FUND_EURC_SYMBOL, CARD_FUND_MOVE_COPY, CARD_FUND_TOKEN_SYMBOLS, getCardFundChains, @@ -38,18 +43,30 @@ export type CardFundNetwork = { * * The symbols come from `CARD_FUND_TOKEN_SYMBOLS` so the rows shown and the * routes offered are the same list — a token with an icon here but no entry - * there would be a row whose deposits nothing accepts. + * there would be a row whose deposits nothing accepts. EURC is the one addition, + * offered on its own terms (see `CARD_FUND_EURC_SYMBOL`). */ export const CARD_FUND_TOKEN_ICONS: Record = { USDC: getAsset('images/deposit-crypto-usdc.png'), USDT: getAsset('images/deposit-crypto-usdt.png'), + [CARD_FUND_EURC_SYMBOL]: getAsset('images/eurc.png'), }; +/** The dollar stablecoin rows, which both issuers offer. */ export const CARD_FUND_TOKENS: CardFundToken[] = CARD_FUND_TOKEN_SYMBOLS.map(symbol => ({ symbol, icon: CARD_FUND_TOKEN_ICONS[symbol], })); +/** + * The Wirex rows: the dollar stablecoins, then EURC. Rain is not offered EURC - + * it would land in the Safe on Base, not on the Rain card, which cannot spend it. + */ +export const WIREX_CARD_FUND_TOKENS: CardFundToken[] = [ + ...CARD_FUND_TOKENS, + { symbol: CARD_FUND_EURC_SYMBOL, icon: CARD_FUND_TOKEN_ICONS[CARD_FUND_EURC_SYMBOL] }, +]; + export const CARD_FUND_USD_ICON = getAsset('images/deposit-cash-us.png'); /** @@ -67,7 +84,7 @@ export const CARD_FUND_LEARN_URL = 'https://support.solid.xyz/en/articles/14431132-supported-networks-and-tokens-on-solid'; export const getCardFundTokenIcon = (symbol: string): ImageSourcePropType => - CARD_FUND_TOKENS.find(token => token.symbol === symbol)?.icon ?? CARD_FUND_TOKENS[0].icon; + CARD_FUND_TOKEN_ICONS[symbol] ?? CARD_FUND_TOKEN_ICONS.USDC; /** * Chains that can receive a direct deposit of `symbol`, in display order, each diff --git a/components/Card/WirexCardFundModal.tsx b/components/Card/WirexCardFundModal.tsx index a8b760b23..82c6a6119 100644 --- a/components/Card/WirexCardFundModal.tsx +++ b/components/Card/WirexCardFundModal.tsx @@ -12,6 +12,7 @@ import { CARD_FUND_MOVE_COPY, getCardFundTokenIcon, WIREX_CARD_FUND_SECTIONS, + WIREX_CARD_FUND_TOKENS, } from '@/components/Card/CardFund/constants'; import { useMovableHoldings, @@ -77,6 +78,11 @@ export interface WirexCardFundModalProps { * funds go (the Rain card on Base, or this cardholder's Safe on Fuse, where the * card spends from). * + * EURC is offered beside the dollar stablecoins, to Wirex cardholders only - a + * Rain card cannot spend from the Safe. The same address takes it, but + * the backend delivers it as EURC to the cardholder's Safe on Base, which is + * where the Base spend-module instance sells it for euro purchases. + * * The local-currency rows are offered too, and for the same reason: TransFi has * no USDC-on-Fuse entry, so the backend delivers the bought USDC to that same * card deposit address and the direct-deposit pipeline carries it to the Safe. @@ -297,6 +303,7 @@ export default function WirexCardFundModal({ return ( 'VirtualAccountApplyDialog', ); jest.mock('@/hooks/useCardProvider', () => ({})); +// Its import graph reaches react-native-mmkv, whose native module does not load +// under jest-expo; the local-currency rows only need its answer. +jest.mock('@/hooks/useTransfiCountryAvailability', () => ({ + useTransfiCountryAvailability: () => ({ isAvailable: true }), +})); jest.mock('@/hooks/useFeatureAccess', () => ({})); jest.mock('@/hooks/useOrchestra', () => ({})); jest.mock('@/hooks/useVirtualAccountEntry', () => ({})); @@ -121,3 +126,23 @@ it('offers Wirex no Cash App, even where it is available, having no USD section' expect(titles).not.toContain('Cash App'); act(() => root.unmount()); }); + +it('lists the stablecoin rows it is given, EURC included', () => { + const root = render({ + sections: WIREX_CARD_FUND_SECTIONS, + // Bundled images are module ids, i.e. numbers. + tokens: [ + { symbol: 'USDC', icon: 1 }, + { symbol: 'USDT', icon: 2 }, + { symbol: 'EURC', icon: 3 }, + ], + }); + expect(titlesOf(root).slice(0, 3)).toEqual(['USDC', 'USDT', 'EURC']); + act(() => root.unmount()); +}); + +it('offers Rain only the dollar stablecoins', () => { + const root = render(RAIN_PROPS); + expect(titlesOf(root)).not.toContain('EURC'); + act(() => root.unmount()); +}); diff --git a/constants/bridge.ts b/constants/bridge.ts index ad2d88ee8..eb04d215f 100644 --- a/constants/bridge.ts +++ b/constants/bridge.ts @@ -201,6 +201,31 @@ export const BRIDGE_TOKENS: BridgeTokens = { }, }; +/** + * EURC, Circle's euro stablecoin, on the chains "Fund your card" offers it on. + * + * Deliberately not in `BRIDGE_TOKENS`: every surface that offers a token on a + * chain reads that map, and EURC belongs to one of them. The backend delivers a + * EURC card deposit as EURC to the user's Safe on Base, where the Base + * spend-module instance sells it for euro purchases. It mints no savings share, + * and EURC sent straight to the Safe on Ethereum would stay on Ethereum. + * + * Base only for now. The backend also accepts EURC sent from Ethereum + * (0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c) and bridges it to Base, but that + * leg is not offered until the backend wallet carries an Ethereum EURC float to + * pay the bridge's exact-output overshoot. Adding the entry back offers it. + */ +export const EURC_TOKENS: Record = { + [base.id]: { + name: 'EURC', + fullName: 'EURC', + address: '0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42', + decimals: 6, + icon: require('@/assets/images/eurc.png'), + isPermit: false, + }, +}; + export const getUsdcAddress = (chainId: number) => { const usdcAddress = BRIDGE_TOKENS[chainId]?.tokens?.USDC?.address; if (!usdcAddress) { diff --git a/lib/assets.ts b/lib/assets.ts index b9223f1df..159286760 100644 --- a/lib/assets.ts +++ b/lib/assets.ts @@ -482,6 +482,7 @@ export const ASSETS = { module: require('@/assets/images/eur-fiat-currency.tsx'), hash: 'db4e5f78', }, + 'images/eurc.png': { module: require('@/assets/images/eurc.png'), hash: '8e00e6b6' }, 'images/exclamation-mark.tsx': { module: require('@/assets/images/exclamation-mark.tsx'), hash: '0c62ca51', diff --git a/lib/getTokenIcon.tsx b/lib/getTokenIcon.tsx index c47c01146..5625c3e4b 100644 --- a/lib/getTokenIcon.tsx +++ b/lib/getTokenIcon.tsx @@ -40,6 +40,11 @@ const getTokenIcon = ({ logoUrl, tokenSymbol, size = 24 }: GetTokenIconProps): T type: 'image', source: getAsset('images/usdt.png'), }; + case 'EURC': + return { + type: 'image', + source: getAsset('images/eurc.png'), + }; case 'WETH': case 'ETH': return { diff --git a/lib/utils/__tests__/cardFunding.test.ts b/lib/utils/__tests__/cardFunding.test.ts index c75ece5b0..867b861ab 100644 --- a/lib/utils/__tests__/cardFunding.test.ts +++ b/lib/utils/__tests__/cardFunding.test.ts @@ -1,12 +1,16 @@ -import { BRIDGE_TOKENS } from '@/constants/bridge'; +import { base, mainnet } from 'viem/chains'; + +import { BRIDGE_TOKENS, EURC_TOKENS } from '@/constants/bridge'; import { CARD_SPENDABLE_ASSETS, describeCardSpendableAssets, } from '@/constants/cardSpendableAssets'; import { TokenBalance, TokenType } from '@/lib/types'; import { + CARD_FUND_EURC_SYMBOL, CARD_FUND_MOVE_COPY, CARD_FUND_TOKEN_SYMBOLS, + getCardFundChains, getCardFundRoutes, getCardFundRoutesTooltip, getCardFundSupportedNetworkNames, @@ -97,6 +101,47 @@ describe('getCardFundRoutesTooltip', () => { // safely in Wallet and cannot be spent. expect(getCardFundRoutesTooltip()).toContain('wallet address'); }); + + it('says nothing about EURC unless it is offered', () => { + expect(getCardFundRoutesTooltip()).not.toContain(CARD_FUND_EURC_SYMBOL); + }); + + it('gives EURC its own, narrower list of networks', () => { + const tooltip = getCardFundRoutesTooltip([...CARD_FUND_TOKEN_SYMBOLS, CARD_FUND_EURC_SYMBOL]); + + expect(tooltip).toContain('USDC or USDT only, on '); + expect(tooltip).toContain('; EURC only on Base.'); + expect(tooltip).toContain('wallet address'); + }); +}); + +describe('EURC', () => { + it('is offered on Base only, Ethereum being held back for now', () => { + expect(getCardFundChains(CARD_FUND_EURC_SYMBOL).map(chain => chain.chainId)).toEqual([base.id]); + }); + + it('stays out of BRIDGE_TOKENS, which every other deposit surface offers from', () => { + for (const chain of Object.values(BRIDGE_TOKENS)) { + expect(chain.tokens?.[CARD_FUND_EURC_SYMBOL]).toBeUndefined(); + } + }); + + it('is not a dollar stablecoin the flow may deliver as another', () => { + expect(CARD_FUND_TOKEN_SYMBOLS).not.toContain(CARD_FUND_EURC_SYMBOL); + }); + + it('is never a move-from-wallet route', () => { + // Base EURC already sits in the Safe the euro balance is spent from. + expect(getCardFundRoutes().map(route => route.symbol)).not.toContain(CARD_FUND_EURC_SYMBOL); + }); + + it('carries the 6-decimal contract the backend accepts', () => { + expect(EURC_TOKENS[mainnet.id]).toBeUndefined(); + expect(EURC_TOKENS[base.id]).toMatchObject({ + address: '0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42', + decimals: 6, + }); + }); }); describe('describeCardSpendableAssets', () => { diff --git a/lib/utils/__tests__/depositFee.test.ts b/lib/utils/__tests__/depositFee.test.ts index db32e6191..83fb31e7e 100644 --- a/lib/utils/__tests__/depositFee.test.ts +++ b/lib/utils/__tests__/depositFee.test.ts @@ -46,6 +46,17 @@ describe('getDepositFeeRatePpm', () => { getDepositFeeRatePpm({ provider: CardProvider.WIREX, product: 'card', chainId, symbol }), ); }); + + // EURC is delivered to the Safe on Base whoever issued the card, so Base is + // its home chain for both - including a Wirex holder, whose dollars go to Fuse. + it.each([CardProvider.RAIN, CardProvider.WIREX, null])( + 'charges a EURC card deposit everywhere but Base (issuer %s)', + provider => { + expectFreeOnlyOn([base.id], chainId => + getDepositFeeRatePpm({ provider, product: 'card', chainId, symbol: 'EURC' }), + ); + }, + ); }); describe('wallet deposit', () => { diff --git a/lib/utils/cardFunding.ts b/lib/utils/cardFunding.ts index 05bad48f7..b35d782e3 100644 --- a/lib/utils/cardFunding.ts +++ b/lib/utils/cardFunding.ts @@ -1,4 +1,4 @@ -import { BRIDGE_TOKENS, getBridgeTokenDecimals } from '@/constants/bridge'; +import { BRIDGE_TOKENS, EURC_TOKENS, getBridgeTokenDecimals } from '@/constants/bridge'; import { CardFundRoute } from '@/lib/utils/cardFundMove'; import { getAllowedTokensForChain, getVaultDepositConfig } from '@/lib/vaults'; @@ -17,6 +17,16 @@ import { getAllowedTokensForChain, getVaultDepositConfig } from '@/lib/vaults'; /** Stablecoins the direct-deposit flow offers, in display order. */ export const CARD_FUND_TOKEN_SYMBOLS = ['USDC', 'USDT'] as const; +/** + * EURC, offered after the dollar stablecoins to Wirex cardholders only. + * + * Not one of `CARD_FUND_TOKEN_SYMBOLS`. Those are delivered to wherever the + * issuer spends from; EURC is delivered as EURC to the Safe on Base, where the + * Base spend-module instance sells it, whoever issued the card - and is offered + * only on the chains `EURC_TOKENS` lists (Base, for now). + */ +export const CARD_FUND_EURC_SYMBOL = 'EURC'; + /** One chain a card deposit can arrive from. */ export interface CardFundChain { chainId: number; @@ -33,11 +43,16 @@ export interface CardFundChain { */ export const getCardFundChains = (symbol: string): CardFundChain[] => { const depositConfig = getVaultDepositConfig(); + // EURC is kept out of BRIDGE_TOKENS (see EURC_TOKENS), so it has its own table. + const carries = (chainId: number) => + symbol === CARD_FUND_EURC_SYMBOL + ? !!EURC_TOKENS[chainId] + : getAllowedTokensForChain(chainId).includes(symbol); return Object.entries(BRIDGE_TOKENS) .map(([id, chain]) => ({ chainId: Number(id), chain })) .filter(({ chainId }) => depositConfig.supportedChains.includes(chainId)) - .filter(({ chainId }) => getAllowedTokensForChain(chainId).includes(symbol)) + .filter(({ chainId }) => carries(chainId)) .sort((a, b) => a.chain.sort - b.chain.sort) .map(({ chainId, chain }) => ({ chainId, @@ -47,8 +62,11 @@ export const getCardFundChains = (symbol: string): CardFundChain[] => { }; /** - * Every (network, stablecoin, contract) the pipeline accepts, which is exactly - * what the token rows offer. + * Every (network, stablecoin, contract) the pipeline accepts in dollars, which + * is exactly what the dollar token rows offer. + * + * EURC is not a route to move along. On Base it already sits in the Safe the + * euro balance is spent from, so a move would only send it out and back. * * The contract comes from `BRIDGE_TOKENS`, the same table the backend resolves a * detected transfer against, so a holding matched here is one it will whitelist @@ -75,15 +93,17 @@ export const getCardFundRoutes = (): CardFundRoute[] => ); /** - * Every network any offered stablecoin can be sent from, in display order. + * Every network any of `symbols` can be sent from, in display order. * * The per-token rows already carry their own chips; this is the union, for copy * that has to describe the whole flow rather than one row of it. */ -export const getCardFundSupportedNetworkNames = (): string[] => { +export const getCardFundSupportedNetworkNames = ( + symbols: readonly string[] = CARD_FUND_TOKEN_SYMBOLS, +): string[] => { const seen = new Map(); - for (const symbol of CARD_FUND_TOKEN_SYMBOLS) { + for (const symbol of symbols) { for (const chain of getCardFundChains(symbol)) { if (!chain.isComingSoon) seen.set(chain.chainId, chain.name); } @@ -102,22 +122,32 @@ export const getCardFundSupportedNetworkNames = (): string[] => { * reads as money lost rather than a mistake with a fix. * * Interpolated from the same tables the flow routes on, so a network added or - * withdrawn cannot leave this sentence claiming otherwise. + * withdrawn cannot leave this sentence claiming otherwise. EURC, when offered, + * gets its own clause: it is accepted on far fewer networks than the dollars. */ -export const getCardFundRoutesTooltip = (): string => { - const tokens = CARD_FUND_TOKEN_SYMBOLS.join(' or '); - const networks = getCardFundSupportedNetworkNames(); - const list = - networks.length > 1 - ? `${networks.slice(0, -1).join(', ')} or ${networks[networks.length - 1]}` - : networks[0]; +export const getCardFundRoutesTooltip = ( + symbols: readonly string[] = CARD_FUND_TOKEN_SYMBOLS, +): string => { + const dollars = symbols.filter(symbol => symbol !== CARD_FUND_EURC_SYMBOL); + const routes = [ + `${dollars.join(' or ')} only, on ${joinWithOr(getCardFundSupportedNetworkNames(dollars))}`, + ]; + if (symbols.includes(CARD_FUND_EURC_SYMBOL)) { + routes.push( + `${CARD_FUND_EURC_SYMBOL} only on ${joinWithOr(getCardFundSupportedNetworkNames([CARD_FUND_EURC_SYMBOL]))}`, + ); + } return ( - `${tokens} only, on ${list}. ` + + `${routes.join('; ')}. ` + 'Send to the address this flow shows — a deposit to your own wallet address lands in Wallet, not on your card.' ); }; +/** "A", "A or B", "A, B or C". */ +const joinWithOr = (names: string[]): string => + names.length > 1 ? `${names.slice(0, -1).join(', ')} or ${names[names.length - 1]}` : names[0]; + /** * The direct-deposit destination that selects the *card* deposit address. * @@ -125,7 +155,8 @@ export const getCardFundRoutesTooltip = (): string => { * derived, and its salt — and so every address already handed out — has to stay * exactly as it was. A Wirex cardholder deposits to the same address; the * backend resolves the issuer and delivers the funds to the Rain card on Base - * or to the cardholder's Safe on Fuse accordingly. + * or to the cardholder's Safe on Fuse accordingly. EURC is the exception: it + * goes to the Safe on Base, as EURC, whichever the issuer. */ export const CARD_FUND_DESTINATION_TYPE = 'RAIN_CARD' as const; diff --git a/lib/utils/depositFee.ts b/lib/utils/depositFee.ts index 41a7ad76f..b6343cc5e 100644 --- a/lib/utils/depositFee.ts +++ b/lib/utils/depositFee.ts @@ -13,6 +13,7 @@ import { CardProvider, DepositFeeQuote } from '@/lib/types'; * | Flow | Who | Charged on | * | -------------- | --------------- | ------------------------------------------------- | * | 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 | Stablecoins, on every chain but Fuse, where the | * | | | Safe the card spends from lives | * | Wallet | No card | Nothing | @@ -83,6 +84,12 @@ export function getDepositFeeRatePpm({ }): number { const isWirex = provider === CardProvider.WIREX; + // 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') { + return chainId === base.id ? 0 : DEFAULT_DEPOSIT_FEE_RATE_PPM; + } + if (product === 'savings') { if (isWirex) return 0; @@ -113,7 +120,7 @@ export function getDepositFeeRatePpm({ * The deposit address the backend prices a flow's deposit on. The card and * wallet flows both mint theirs as `RAIN_CARD`, which the backend delivers to * whichever card the user holds - the Rain card on Base, or a Wirex card's Safe - * on Fuse - and prices on that card's route. + * on Fuse, or the Safe on Base for EURC - and prices on that route. */ export const getDepositFeeDestinationType = ( product: DepositFeeProduct,