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
Binary file added assets/images/eurc.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 10 additions & 3 deletions components/Card/CardFund/CardFundOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
CARD_FUND_TOKENS,
CARD_FUND_USD_ICON,
CardFundSections,
CardFundToken,
getCardFundNetworkChips,
getCardFundRoutesTooltip,
RAIN_CARD_FUND_SECTIONS,
Expand All @@ -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;
/**
Expand Down Expand Up @@ -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,
Expand All @@ -99,10 +106,10 @@ const CardFundOptions = ({
{sections.stablecoins ? (
<CardFundGroup
label="Stablecoins"
labelTooltip={getCardFundRoutesTooltip()}
labelTooltip={getCardFundRoutesTooltip(tokens.map(token => token.symbol))}
labelTooltipContext="card_fund_supported_routes"
>
{CARD_FUND_TOKENS.map(token => (
{tokens.map(token => (
<CardFundRow
key={token.symbol}
icon={<Image source={token.icon} style={TOKEN_ICON_STYLE} contentFit="cover" />}
Expand Down
23 changes: 20 additions & 3 deletions components/Card/CardFund/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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<string, ImageSourcePropType> = {
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');

/**
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions components/Card/WirexCardFundModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -297,6 +303,7 @@ export default function WirexCardFundModal({
return (
<CardFundOptions
onTokenPress={handleTokenPress}
tokens={WIREX_CARD_FUND_TOKENS}
onLocalCurrencyPress={handleLocalCurrencyPress}
onMoveFromSavingsPress={handleMoveFromWalletPress}
sections={WIREX_CARD_FUND_SECTIONS}
Expand Down
25 changes: 25 additions & 0 deletions components/Card/__tests__/CardFundOptions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ jest.mock(
() => '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', () => ({}));
Expand Down Expand Up @@ -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());
});
25 changes: 25 additions & 0 deletions constants/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, BridgeToken> = {
[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) {
Expand Down
1 change: 1 addition & 0 deletions lib/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 5 additions & 0 deletions lib/getTokenIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
47 changes: 46 additions & 1 deletion lib/utils/__tests__/cardFunding.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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', () => {
Expand Down
11 changes: 11 additions & 0 deletions lib/utils/__tests__/depositFee.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading
Loading