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
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ 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';

import {
getWalletDepositNetworks,
getWalletDepositNetworksForToken,
getWalletDepositTokenIcon,
offersWalletDepositEurc,
} from './constants';

const PILL_ICON_STYLE = { width: 20, height: 20, borderRadius: 10 };
Expand Down Expand Up @@ -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 (
<>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
getWalletDepositNetworksForToken,
getWalletDepositTokenIcon,
getWalletDepositTokens,
offersWalletDepositEurc,
resolveWalletDepositChain,
resolveWalletDepositMinimum,
resolveWalletDepositSymbol,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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');
});
});
61 changes: 47 additions & 14 deletions components/DepositOption/WalletDepositAddress/constants.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -60,7 +60,20 @@ const MINIMUM_DEPOSIT_BY_TOKEN: Record<string, number> = {
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.
Expand All @@ -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.
*/
Expand All @@ -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;
Expand All @@ -104,16 +119,24 @@ const TOKEN_ICON_FALLBACKS: Record<string, ImageSourcePropType> = {
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 => {
const icon = BRIDGE_TOKENS[chainId]?.tokens?.[symbol]?.icon;
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),
}));
Expand Down Expand Up @@ -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;
};

Expand Down Expand Up @@ -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,
Expand All @@ -229,7 +253,7 @@ export const getAllWalletDepositTokens = (): WalletDepositToken[] => {
];
const seen = new Set<string>();
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;
Expand All @@ -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;
Expand All @@ -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();
Expand Down
7 changes: 5 additions & 2 deletions components/DepositOption/WalletDepositAddress/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
getDefaultWalletDepositSelection,
getWalletDepositNetworks,
getWalletDepositTokenIcon,
offersWalletDepositEurc,
resolveWalletDepositMinimum,
resolveWalletDepositSymbol,
WALLET_DEPOSIT_LEARN_URL,
Expand Down Expand Up @@ -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 (
Expand Down
2 changes: 1 addition & 1 deletion hooks/useWalletDepositAddress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
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 @@ -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 }),
Expand Down
4 changes: 3 additions & 1 deletion lib/utils/depositFee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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;
}

Expand Down
Loading