diff --git a/.changeset/brave-otters-hide.md b/.changeset/brave-otters-hide.md new file mode 100644 index 0000000000..3bb6c885fa --- /dev/null +++ b/.changeset/brave-otters-hide.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +💄 hide defi section diff --git a/.changeset/brave-otters-wave.md b/.changeset/brave-otters-wave.md new file mode 100644 index 0000000000..bc5b479d5f --- /dev/null +++ b/.changeset/brave-otters-wave.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +✨ restructure add funds root menu diff --git a/.changeset/brisk-terns-guide.md b/.changeset/brisk-terns-guide.md new file mode 100644 index 0000000000..ce3d5bea2c --- /dev/null +++ b/.changeset/brisk-terns-guide.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +✨ add swap and bridge swap guide sheets diff --git a/.changeset/calm-badgers-greet.md b/.changeset/calm-badgers-greet.md new file mode 100644 index 0000000000..fdde20c744 --- /dev/null +++ b/.changeset/calm-badgers-greet.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +✨ resolve ens name for owner address diff --git a/.changeset/calm-herons-store.md b/.changeset/calm-herons-store.md new file mode 100644 index 0000000000..0b983ef8a1 --- /dev/null +++ b/.changeset/calm-herons-store.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +⚡️ cache deployed bytecode indefinitely diff --git a/.changeset/deep-owls-emit.md b/.changeset/deep-owls-emit.md new file mode 100644 index 0000000000..0947e46cb2 --- /dev/null +++ b/.changeset/deep-owls-emit.md @@ -0,0 +1,5 @@ +--- +"@exactly/common": patch +--- + +🔨 codegen allowlists for all chains diff --git a/.changeset/eager-owls-list.md b/.changeset/eager-owls-list.md new file mode 100644 index 0000000000..facdf7f085 --- /dev/null +++ b/.changeset/eager-owls-list.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +✨ add cryptocurrencies asset list diff --git a/.changeset/glad-finches-map.md b/.changeset/glad-finches-map.md new file mode 100644 index 0000000000..d443d3cd3b --- /dev/null +++ b/.changeset/glad-finches-map.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +✨ display allowlisted assets per network diff --git a/.changeset/keen-otters-match.md b/.changeset/keen-otters-match.md new file mode 100644 index 0000000000..1af9afaec4 --- /dev/null +++ b/.changeset/keen-otters-match.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +✨ add supported asset match sheet diff --git a/.changeset/neat-cranes-copy.md b/.changeset/neat-cranes-copy.md new file mode 100644 index 0000000000..d29a5967eb --- /dev/null +++ b/.changeset/neat-cranes-copy.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +💄 redesign copy address sheet diff --git a/.changeset/plush-swans-send.md b/.changeset/plush-swans-send.md new file mode 100644 index 0000000000..8532de4d93 --- /dev/null +++ b/.changeset/plush-swans-send.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +✨ add request sent screen diff --git a/.changeset/proud-lions-glow.md b/.changeset/proud-lions-glow.md new file mode 100644 index 0000000000..4e9fe236c8 --- /dev/null +++ b/.changeset/proud-lions-glow.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +✨ redesign crypto receive screen diff --git a/.changeset/quiet-cranes-teach.md b/.changeset/quiet-cranes-teach.md new file mode 100644 index 0000000000..7a935bad0a --- /dev/null +++ b/.changeset/quiet-cranes-teach.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +✨ add bridge education sheet diff --git a/.changeset/round-moles-count.md b/.changeset/round-moles-count.md new file mode 100644 index 0000000000..a4e059f959 --- /dev/null +++ b/.changeset/round-moles-count.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +💄 redesign bridge quote screen diff --git a/.changeset/sly-foxes-sift.md b/.changeset/sly-foxes-sift.md new file mode 100644 index 0000000000..755c7dc91a --- /dev/null +++ b/.changeset/sly-foxes-sift.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +✨ add network filter to asset select sheet diff --git a/.changeset/soft-doves-teach.md b/.changeset/soft-doves-teach.md new file mode 100644 index 0000000000..f4120f1d30 --- /dev/null +++ b/.changeset/soft-doves-teach.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +✨ add network section info sheets diff --git a/.changeset/warm-swifts-flip.md b/.changeset/warm-swifts-flip.md new file mode 100644 index 0000000000..5ed8e42447 --- /dev/null +++ b/.changeset/warm-swifts-flip.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +💄 toggle qr inline on receive screen diff --git a/.changeset/wise-crows-allow.md b/.changeset/wise-crows-allow.md new file mode 100644 index 0000000000..cf615b064c --- /dev/null +++ b/.changeset/wise-crows-allow.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +✨ list allowlisted assets on add funds diff --git a/.changeset/witty-geese-roam.md b/.changeset/witty-geese-roam.md new file mode 100644 index 0000000000..cefbfac03c --- /dev/null +++ b/.changeset/witty-geese-roam.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +✨ add network selection to receive flow diff --git a/.maestro/subflows/borrow.yaml b/.maestro/subflows/borrow.yaml index d6433ebfdc..33bdc8ee51 100644 --- a/.maestro/subflows/borrow.yaml +++ b/.maestro/subflows/borrow.yaml @@ -4,14 +4,7 @@ appId: ${APP_ID ?? "app.exactly"} - assertTrue: ${installments && !Number.isNaN(Number(installments))} - runFlow: ../subflows/readPortfolio.yaml - evalScript: ${output.portfolioBefore = output.portfolio} -- tapOn: DeFi -- runFlow: - when: { visible: Explore decentralized services } - commands: [{ tapOn: 'Explore decentralized services' }] -- tapOn: USDC funding -- runFlow: - when: { visible: Connect wallet to Exactly Protocol } - commands: [{ tapOn: Connect wallet to Exactly Protocol }] +- tapOn: Borrow - tapOn: Explore funding options - assertVisible: Select amount - runFlow: { file: tapAria.yaml, env: { aria: Amount } } diff --git a/common/wagmi.config.ts b/common/wagmi.config.ts index fee9b40b14..b703fcc341 100644 --- a/common/wagmi.config.ts +++ b/common/wagmi.config.ts @@ -1,7 +1,7 @@ import { defineConfig, type Plugin } from "@wagmi/cli"; import { foundry, react } from "@wagmi/cli/plugins"; import { execSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { readdirSync, readFileSync } from "node:fs"; import { env } from "node:process"; import { getAddress, type Abi } from "viem"; import { anvil, base, baseSepolia, optimism, optimismSepolia } from "viem/chains"; @@ -35,9 +35,26 @@ const [proposalManager] = loadBroadcast("ProposalManager").transactions; const [refunder] = loadBroadcast("Refunder").transactions; const [exaPreviewer] = loadBroadcast("ExaPreviewer").transactions; const swapper = (deploy.accounts.swapper as Record)[chainId] ?? deploy.accounts.swapper.default; -const allowlist = Object.keys( - (deploy.proposalManager.allowlist as Record>)[chainId] ?? {}, -).map((address) => getAddress(address)); +const allowlists = Object.fromEntries( + Object.entries(deploy.proposalManager.allowlist as Record>).map(([id, tokens]) => { + const network = { [String(optimism.id)]: "optimism", [String(base.id)]: "base" }[id]; + const underlying = network + ? readdirSync(`node_modules/@exactly/protocol/deployments/${network}`) + .flatMap((file) => /^Market([A-Za-z.]+)\.json$/.exec(file)?.[1] ?? []) + .filter((asset) => asset !== "ETHRouter") + .map((asset) => + getAddress( + ( + JSON.parse( + readFileSync(`node_modules/@exactly/protocol/deployments/${network}/${asset}.json`, "utf8"), + ) as Deployment + ).address, + ), + ) + : []; + return [id, [...underlying, ...Object.keys(tokens).map((address) => getAddress(address))]]; + }), +); if (!exaPlugin || !issuerChecker || !proposalManager || !exaPreviewer || !refunder || !swapper) { throw new Error("missing contracts"); } @@ -113,7 +130,12 @@ export default defineConfig([ }, }, ), - { name: "Allowlist", run: () => ({ content: `export const allowlist = ${JSON.stringify(allowlist)} as const` }) }, + { + name: "Allowlist", + run: () => ({ + content: `export const allowlists: Record = ${JSON.stringify(allowlists)}`, + }), + }, foundry({ forge: { build: false }, project: "../contracts", diff --git a/src/app/(main)/(home)/_layout.tsx b/src/app/(main)/(home)/_layout.tsx index e749dc178f..8ba94afe9e 100644 --- a/src/app/(main)/(home)/_layout.tsx +++ b/src/app/(main)/(home)/_layout.tsx @@ -6,7 +6,7 @@ import { selectionAsync } from "expo-haptics"; import Head from "expo-router/head"; import { TabList, Tabs, TabSlot, TabTrigger, useTabTrigger } from "expo-router/ui"; -import { Boxes, CalendarCheck, CreditCard, History, Home } from "@tamagui/lucide-icons"; +import { CalendarCheck, CreditCard, History, Home } from "@tamagui/lucide-icons"; import { YStack } from "tamagui"; import SafeView from "../../../components/shared/SafeView"; @@ -20,7 +20,6 @@ const tabs = [ { name: "index", title: "Home", href: "/", Icon: Home }, { name: "card", title: "Card", href: "/card", Icon: CreditCard }, { name: "pay-mode", title: "Payments", href: "/pay-mode", Icon: CalendarCheck }, - { name: "defi", title: "DeFi", href: "/defi", Icon: Boxes }, { name: "activity", title: "Activity", href: "/activity", Icon: History }, ] as const; diff --git a/src/app/(main)/(home)/defi.tsx b/src/app/(main)/(home)/defi.tsx index ef35d2a6a7..39ff2dd7dc 100644 --- a/src/app/(main)/(home)/defi.tsx +++ b/src/app/(main)/(home)/defi.tsx @@ -1 +1,7 @@ -export { default } from "../../../components/defi/DeFi"; +import React from "react"; + +import { Redirect } from "expo-router"; + +export default function DeFi() { + return ; +} diff --git a/src/app/(main)/add-funds/_layout.tsx b/src/app/(main)/add-funds/_layout.tsx index 7a9d15ba86..5d879484e4 100644 --- a/src/app/(main)/add-funds/_layout.tsx +++ b/src/app/(main)/add-funds/_layout.tsx @@ -10,9 +10,11 @@ export default function AddFundsLayout() { + + diff --git a/src/app/(main)/add-funds/assets.tsx b/src/app/(main)/add-funds/assets.tsx new file mode 100644 index 0000000000..2baf829beb --- /dev/null +++ b/src/app/(main)/add-funds/assets.tsx @@ -0,0 +1 @@ +export { default } from "../../../components/add-funds/Assets"; diff --git a/src/app/(main)/add-funds/network.tsx b/src/app/(main)/add-funds/network.tsx new file mode 100644 index 0000000000..60ac5fc5ad --- /dev/null +++ b/src/app/(main)/add-funds/network.tsx @@ -0,0 +1 @@ +export { default } from "../../../components/add-funds/Network"; diff --git a/src/components/add-funds/AddCrypto.tsx b/src/components/add-funds/AddCrypto.tsx index d5100bad3d..9091c96bb8 100644 --- a/src/components/add-funds/AddCrypto.tsx +++ b/src/components/add-funds/AddCrypto.tsx @@ -1,21 +1,34 @@ import React, { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; import { PixelRatio, Pressable, Share } from "react-native"; +import QRCode from "react-native-qrcode-styled"; import { setStringAsync } from "expo-clipboard"; +import { selectionAsync } from "expo-haptics"; import { useLocalSearchParams, useRouter } from "expo-router"; -import { AlertTriangle, ArrowLeft, Copy, RefreshCw, Share as ShareIcon } from "@tamagui/lucide-icons"; +import { + AlertTriangle, + ArrowLeft, + Copy, + Hash, + Info, + QrCode, + RefreshCw, + Share as ShareIcon, +} from "@tamagui/lucide-icons"; import { useToastController } from "@tamagui/toast"; -import { ScrollView, XStack, YStack } from "tamagui"; +import { ScrollView, XStack, YStack, type ColorTokens } from "tamagui"; import { useQuery } from "@tanstack/react-query"; import chain from "@exactly/common/generated/chain"; import BridgeDisclaimer from "./BridgeDisclaimer"; -import SupportedAssetsSheet from "./SupportedAssetsSheet"; +import EducationSheet from "./EducationSheet"; +import alchemyChainById from "../../utils/alchemyChains"; import { presentArticle } from "../../utils/intercom"; +import { lifiChainsOptions } from "../../utils/lifi"; import networkLogos from "../../utils/networkLogos"; import reportError from "../../utils/reportError"; import { getRampQuote } from "../../utils/server"; @@ -27,6 +40,7 @@ import CopyAddressSheet from "../shared/CopyAddressSheet"; import IconButton from "../shared/IconButton"; import Image from "../shared/Image"; import SafeView from "../shared/SafeView"; +import SendWarning from "../shared/SendWarning"; import Skeleton from "../shared/Skeleton"; import Button from "../shared/StyledButton"; import Text from "../shared/Text"; @@ -38,9 +52,23 @@ export default function AddCrypto() { const { address: accountAddress } = useAccount(); const { supportedAssets, isPending } = useMarkets(); const { t } = useTranslation(); - const { provider, currency: currencyParameter, network: networkParameter } = useLocalSearchParams(); + const { + provider, + currency: currencyParameter, + network: networkParameter, + asset: assetParameter, + chainId: chainIdParameter, + symbol: symbolParameter, + } = useLocalSearchParams(); const currency = typeof currencyParameter === "string" ? currencyParameter : ""; const network = typeof networkParameter === "string" ? networkParameter : ""; + const asset = typeof assetParameter === "string" ? assetParameter : ""; + const symbol = typeof symbolParameter === "string" && symbolParameter ? symbolParameter : asset; + const parsed = Number(chainIdParameter); + const receiveChainId = + typeof chainIdParameter === "string" && Number.isInteger(parsed) && parsed > 0 && parsed !== chain.id + ? parsed + : undefined; const isBridge = provider === "bridge" && !!currency && !!network; const { data, isError, isFetching, refetch } = useQuery({ @@ -57,16 +85,30 @@ export default function AddCrypto() { const depositAddress = deposit && "address" in deposit ? deposit.address : undefined; const memo = deposit && "memo" in deposit ? deposit.memo : undefined; + const { data: receiveChain } = useQuery({ + ...lifiChainsOptions, + enabled: !!receiveChainId, + select: (chains) => chains.find((c) => c.id === receiveChainId), + }); + const address = isBridge ? depositAddress : accountAddress; - const networkName = isBridge && typeof network === "string" ? network : chain.name; - const assets = isBridge && typeof currency === "string" ? [currency] : supportedAssets; + const networkName = isBridge + ? network + : receiveChainId + ? (receiveChain?.name ?? alchemyChainById.get(receiveChainId)?.name ?? `#${receiveChainId}`) + : chain.name; + const assets = isBridge ? [currency] : asset ? [symbol] : supportedAssets; + const other = !!asset && !isPending && !supportedAssets.includes(asset); + const bridgeLogoURI = isBridge && network in networkLogos ? networkLogos[network] : undefined; const toast = useToastController(); const [copyAddressShown, setCopyAddressShown] = useState(false); + const [qrShown, setQRShown] = useState(false); const [supportedAssetsShown, setSupportedAssetsShown] = useState(false); const copy = useCallback(() => { if (!address) return; + selectionAsync().catch(reportError); setStringAsync(address) .then(() => { setCopyAddressShown(true); @@ -84,8 +126,8 @@ export default function AddCrypto() { return ( - - + + - - - - - {isBridge - ? t("{{network}} deposit address", { network: networkName }) - : t("Your {{chain}} address", { chain: networkName })} - - - {address ? ( - {address} - ) : isBridge && isError && !isFetching ? ( - {t("Failed to load deposit address.")} + + + + + setSupportedAssetsShown(true)} + /> + + + + + {t("Wallet address")} + + {qrShown && address ? ( + + + + + {bridgeLogoURI ? ( + + ) : ( + + )} + + + ) : ( - + + {address ? ( + + {address} + + ) : isBridge && isError && !isFetching ? ( + + {t("Failed to load deposit address.")} + + ) : ( + + )} + )} - - {isBridge && isError && !isFetching ? ( - - ) : ( - - - - - )} + {!!address && !memo && ( + setQRShown(!qrShown)}> + + + {qrShown ? t("Show wallet address") : t("Show QR")} + + {qrShown ? ( + + ) : ( + + )} + + + )} + {!!memo && ( @@ -198,106 +268,227 @@ export default function AddCrypto() { )} + + + {isBridge && } + {!!asset && (other || !!receiveChainId) && ( + } + > + + {other + ? receiveChainId + ? t( + "Once received, you'll need to bridge and swap {{asset}} to a supported asset on {{chain}}.", + { asset, chain: chain.name }, + ) + : t("Once received, you'll need to swap {{asset}} to a supported asset.", { asset }) + : t("Once received, you'll need to bridge to {{asset}} on {{chain}}.", { + asset, + chain: chain.name, + })} + + + )} + } + > + + + { + presentArticle("8950801").catch(reportError); + }} + > + {" "} + {t("Learn more about adding funds.")} + + + + { setCopyAddressShown(false); }} address={isBridge ? depositAddress : undefined} - network={isBridge && typeof network === "string" ? network : undefined} - networkLogo={isBridge && typeof network === "string" ? networkLogos[network] : undefined} - assets={isBridge ? assets : undefined} + network={isBridge ? network : receiveChainId ? networkName : undefined} + asset={isBridge ? currency : symbol || undefined} /> - {!isBridge && ( - { setSupportedAssetsShown(false); }} - /> - )} - - - {t("Network")} - - - {isBridge ? t("Asset") : t("Supported Assets")} - - - - - {isBridge && typeof network === "string" && network in networkLogos ? ( - - ) : ( - - )} - - {networkName} - - - setSupportedAssetsShown(true)} + title={t("Supported assets")} + article="8950805" > - {!isBridge && isPending - ? Array.from({ length: 5 }, (_, index) => ( - - - - )) - : assets.map((symbol, index) => ( - - - - ))} - - - - - - {isBridge && } - - - - - - - {isBridge - ? t( - "Only send {{crypto}} on {{network}}. Sending other assets or using other networks may cause permanent loss.", - { crypto: currency, network: networkName }, - ) - : t("Only send assets on {{chain}}. Sending funds from other networks may cause permanent loss.", { - chain: networkName, - })} - { - presentArticle("8950801").catch(reportError); - }} + - {" "} - {t("Learn more about adding funds.")} + {isPending + ? Array.from({ length: 5 }, (_, index) => ( + + )) + : supportedAssets.map((collateral) => ( + + ))} + + + {t( + "Only {{assets}} on {{chain}} serve as collateral, earn yield while held, and increase your Exa Card credit limit.", + { assets: supportedAssets.join(", "), chain: chain.name }, + )} - + + )} + + + + {isBridge && isError && !isFetching ? ( + + ) : ( + + + - + )} ); } + +function AssetChip({ assets, isPending, onPress }: { assets: string[]; isPending: boolean; onPress?: () => void }) { + const { t } = useTranslation(); + return ( + + + {t("Asset")} + + + {isPending ? ( + + ) : assets.length === 1 ? ( + <> + + + {assets[0]} + + + ) : ( + assets.map((symbol, index) => ( + + + + )) + )} + + + ); +} + +function Callout({ + background, + children, + icon, +}: { + background: ColorTokens; + children: React.ReactNode; + icon: React.ReactElement; +}) { + return ( + + {icon} + {children} + + ); +} + +function NetworkChip({ chainId, logoURI, name }: { chainId?: number; logoURI?: string; name: string }) { + const { t } = useTranslation(); + return ( + + + {t("Network")} + + + {logoURI ? ( + + ) : ( + + )} + + {name} + + + + ); +} diff --git a/src/components/add-funds/AddFunds.tsx b/src/components/add-funds/AddFunds.tsx index db6f37991d..d88a733ce4 100644 --- a/src/components/add-funds/AddFunds.tsx +++ b/src/components/add-funds/AddFunds.tsx @@ -1,16 +1,16 @@ -import React, { useMemo } from "react"; +import React from "react"; import { useTranslation } from "react-i18next"; -import { useLocalSearchParams, useRouter } from "expo-router"; +import { Redirect, useLocalSearchParams, useRouter } from "expo-router"; import { ArrowLeft, Banknote, Blocks, CircleHelp, Info, Wallet } from "@tamagui/lucide-icons"; import { useToastController } from "@tamagui/toast"; import { ScrollView, XStack, YStack } from "tamagui"; import { useQuery } from "@tanstack/react-query"; -import { isAfter, parseISO } from "date-fns"; import { isAddress } from "viem"; -import { base } from "viem/chains"; +import { base, mainnet } from "viem/chains"; +import { useEnsName } from "wagmi"; import domain from "@exactly/common/domain"; import chain from "@exactly/common/generated/chain"; @@ -18,15 +18,14 @@ import shortenHex from "@exactly/common/shortenHex"; import AddFundsOption from "./AddFundsOption"; import { presentArticle } from "../../utils/intercom"; -import openBrowser from "../../utils/openBrowser"; import queryClient, { type AuthMethod } from "../../utils/queryClient"; import reportError from "../../utils/reportError"; import { getKYCStatus, getRampProviders } from "../../utils/server"; import useBeginKYC from "../../utils/useBeginKYC"; +import useMarkets from "../../utils/useMarkets"; +import ownerConfig from "../../utils/wagmi/owner"; import RampButton from "../ramp/RampButton"; -import ChainLogo from "../shared/ChainLogo"; import IconButton from "../shared/IconButton"; -import InfoAlert from "../shared/InfoAlert"; import SafeView from "../shared/SafeView"; import Skeleton from "../shared/Skeleton"; import Text from "../shared/Text"; @@ -42,8 +41,15 @@ export default function AddFunds() { const { t } = useTranslation(); const { data: credential } = useQuery({ queryKey: ["credential"] }); const ownerAccount = credential && isAddress(credential.credentialId) ? credential.credentialId : undefined; + const { data: ensName } = useEnsName({ + config: ownerConfig, + chainId: mainnet.id, + address: ownerAccount, + query: { staleTime: 86_400_000, retry: false, meta: { dropError: () => true } }, + }); const { data: method } = useQuery({ queryKey: ["method"] }); + const { supportedAssets } = useMarkets(); const { data: kycStatus } = useQuery({ queryKey: ["kyc", "status"] }); const beginKYC = useBeginKYC(); const isKYCApproved = @@ -69,47 +75,7 @@ export default function AddFunds() { const hasFiat = providers && Object.values(providers).some((p) => p.onramp.currencies.some((item) => typeof item === "string")); - const hasCrypto = - providers && - Object.values(providers).some((p) => - p.onramp.currencies.some((item) => typeof item === "object" && "network" in item), - ); - const past = useMemo(() => isAfter(new Date(), parseISO("2026-07-01")), []); - - function renderProviders(filter: "crypto" | "fiat") { - if (countryCode && isPending) { - return ( - - - - ); - } - if (!providers) return null; - return ( - - {Object.entries(providers).flatMap(([providerKey, provider]) => - provider.onramp.currencies - .filter((item) => (filter === "crypto") === (typeof item === "object")) - .map((item) => { - const isCrypto = typeof item === "object"; - const currency = isCrypto ? item.currency : item; - const network = isCrypto ? item.network : undefined; - return ( - - ); - }), - )} - - ); - } - + if (type === "crypto") return ; return ( @@ -119,7 +85,7 @@ export default function AddFunds() { icon={ArrowLeft} aria-label={t("Back")} onPress={() => { - if (type === "crypto" || type === "fiat") { + if (type === "fiat") { if (router.canGoBack()) { router.back(); } else { @@ -131,7 +97,7 @@ export default function AddFunds() { }} /> - {t(type === "crypto" ? "Cryptocurrencies" : type === "fiat" ? "Bank transfers" : "Add Funds")} + {t(type === "fiat" ? "Bank transfers" : "Add Funds")} {type !== "crypto" && type !== "fiat" && ( <> + {method === "siwe" && ( + } + title={t("With connected wallet")} + subtitle={ + ownerAccount && + (ensName ? `${ensName} | ${shortenHex(ownerAccount, 4, 6)}` : shortenHex(ownerAccount, 4, 6)) + } + onPress={() => { + router.push("/add-funds/bridge"); + }} + /> + )} } title={t("Cryptocurrencies")} - subtitle={t("Multiple networks and wallets")} + subtitle={ + supportedAssets.length > 3 + ? t("{{assets}} and more", { assets: supportedAssets.slice(0, 3).join(", ") }) + : supportedAssets.join(", ") + } onPress={() => { - router.push({ pathname: "/add-funds", params: { type: "crypto" } }); + router.push("/add-funds/assets"); }} /> {hasFiat !== false && chain.id !== base.id && ( } title={t("Bank transfers")} - subtitle={t("From a bank account")} + subtitle={t("Pesos, dollars, or euros")} disabled={(isKYCApproved && !hasFiat) || beginKYC.isPending} loading={beginKYC.isPending} onPress={() => { @@ -191,64 +174,6 @@ export default function AddFunds() { )} )} - {type === "crypto" && ( - <> - {hasCrypto && ( - { - openBrowser("https://x.com/exa_app/status/2071690658339770622").catch(reportError); - }} - /> - )} - {!past && !isKYCApproved && chain.id !== base.id && ( - { - beginKYC.mutate(undefined, { - onError(error) { - toast.show(t("Error verifying identity"), { - duration: 1000, - burntOptions: { haptic: "error", preset: "error" }, - }); - reportError(error); - }, - }); - }} - loading={beginKYC.isPending} - /> - )} - {method === "siwe" && ( - } - title={t("From connected wallet")} - subtitle={ - // TODO add support for ens resolution - ownerAccount ? shortenHex(ownerAccount, 4, 6) : "" - } - onPress={() => { - router.push("/add-funds/bridge"); - }} - /> - )} - } - title={t("From another wallet")} - subtitle={t("On {{chain}}", { chain: chain.name })} - onPress={() => { - router.push("/add-funds/add-crypto"); - }} - /> - - {!past && renderProviders("crypto")} - - )} {type === "fiat" && countryCode && isPending && ( diff --git a/src/components/add-funds/AddFundsOption.tsx b/src/components/add-funds/AddFundsOption.tsx index 0dba030eb5..2da2f1ed36 100644 --- a/src/components/add-funds/AddFundsOption.tsx +++ b/src/components/add-funds/AddFundsOption.tsx @@ -7,6 +7,7 @@ import Text from "../shared/Text"; import View from "../shared/View"; export default function AddFundsOption({ + badge, icon, title, subtitle, @@ -14,11 +15,12 @@ export default function AddFundsOption({ loading, onPress, }: { + badge?: string; disabled?: boolean; icon: React.ReactElement; loading?: boolean; onPress: () => void; - subtitle: string; + subtitle?: string; title: string; }) { return ( @@ -50,11 +52,25 @@ export default function AddFundsOption({ {title} - - {subtitle} - + {!!subtitle && ( + + {subtitle} + + )} + {!!badge && ( + + + {badge} + + + )} {loading ? ( diff --git a/src/components/add-funds/AssetMatchSheet.tsx b/src/components/add-funds/AssetMatchSheet.tsx new file mode 100644 index 0000000000..fc7d857fcd --- /dev/null +++ b/src/components/add-funds/AssetMatchSheet.tsx @@ -0,0 +1,102 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Pressable } from "react-native"; + +import { ArrowRight, Check, CheckCircle } from "@tamagui/lucide-icons"; +import { ScrollView, XStack, YStack } from "tamagui"; + +import chain from "@exactly/common/generated/chain"; + +import AssetLogo from "../shared/AssetLogo"; +import ChainLogo from "../shared/ChainLogo"; +import ModalSheet from "../shared/ModalSheet"; +import SafeView from "../shared/SafeView"; +import Button from "../shared/StyledButton"; +import Text from "../shared/Text"; +import View from "../shared/View"; + +export default function AssetMatchSheet({ + destinationSymbol, + onClose, + onConfirm, + onSelectAnother, + open, + sourceChainId, + sourceLogoURI, + sourceNetwork, + sourceSymbol, +}: { + destinationSymbol: string; + onClose: () => void; + onConfirm: () => void; + onSelectAnother: () => void; + open: boolean; + sourceChainId?: number; + sourceLogoURI?: string; + sourceNetwork: string; + sourceSymbol: string; +}) { + const { t } = useTranslation(); + return ( + + + + + + + + {t("Supported asset match")} + + + + {t( + "{{source}} on {{network}} matches {{destination}} on {{chain}}. You can swap and bridge between these assets or just select another supported asset.", + { source: sourceSymbol, network: sourceNetwork, destination: destinationSymbol, chain: chain.name }, + )} + + + + + {sourceChainId !== undefined && ( + + + + )} + + + + + + + + + + + + + {t("Select another asset")} + + + + + + + ); +} diff --git a/src/components/add-funds/AssetSelectSheet.tsx b/src/components/add-funds/AssetSelectSheet.tsx index 3d62c8162b..f8d76eb00a 100644 --- a/src/components/add-funds/AssetSelectSheet.tsx +++ b/src/components/add-funds/AssetSelectSheet.tsx @@ -2,12 +2,13 @@ import React, { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Pressable } from "react-native"; -import { Search } from "@tamagui/lucide-icons"; +import { Check, ChevronDown, Network, Search } from "@tamagui/lucide-icons"; import { ScrollView, XStack, YStack } from "tamagui"; import { formatUnits } from "viem"; import AssetLogo from "../shared/AssetLogo"; +import ChainLogo from "../shared/ChainLogo"; import Input from "../shared/Input"; import ModalSheet from "../shared/ModalSheet"; import SafeView from "../shared/SafeView"; @@ -41,11 +42,15 @@ export default function AssetSelectSheet({ i18n: { language }, } = useTranslation(); const [searchQuery, setSearchQuery] = useState(""); + const [chainFilter, setChainFilter] = useState(); + const [filterOpen, setFilterOpen] = useState(false); const displayLabel = label ?? t("Select asset"); const filteredGroups = useMemo(() => { const normalizedQuery = searchQuery.trim().toLowerCase(); + const activeChain = groups.some((group) => group.chain.id === chainFilter) ? chainFilter : undefined; return groups + .filter((group) => activeChain === undefined || group.chain.id === activeChain) .map((group) => { if (!normalizedQuery) return group; const assets = group.assets.filter(({ token }) => { @@ -59,10 +64,12 @@ export default function AssetSelectSheet({ return { ...group, assets }; }) .filter((group) => group.assets.length > 0); - }, [groups, searchQuery]); + }, [groups, searchQuery, chainFilter]); const handleClose = useCallback(() => { setSearchQuery(""); + setChainFilter(undefined); + setFilterOpen(false); onClose(); }, [onClose]); @@ -73,35 +80,87 @@ export default function AssetSelectSheet({ {displayLabel} - - - - + + + + + + + {groups.length > 1 && ( + setFilterOpen(!filterOpen)}> + + {chainFilter === undefined ? ( + + ) : ( + + )} + + + + )} - + {filterOpen && ( + + + } + label={t("All networks")} + onPress={() => { + setChainFilter(undefined); + setFilterOpen(false); + }} + /> + {groups.map((group) => ( + } + label={group.chain.name} + onPress={() => { + setChainFilter(group.chain.id); + setFilterOpen(false); + }} + /> + ))} + + + )} + {filteredGroups.map((group) => ( @@ -171,7 +230,7 @@ export default function AssetSelectSheet({ {filteredGroups.length === 0 && ( - {searchQuery + {searchQuery || chainFilter !== undefined ? t("No assets match your filters.") : t("No assets with balance available to bridge.")} @@ -184,3 +243,32 @@ export default function AssetSelectSheet({ ); } + +function FilterRow({ + active, + icon, + label, + onPress, +}: { + active: boolean; + icon: React.ReactElement; + label: string; + onPress: () => void; +}) { + return ( + + + {icon} + + {label} + + {active && } + + + ); +} diff --git a/src/components/add-funds/Assets.tsx b/src/components/add-funds/Assets.tsx new file mode 100644 index 0000000000..6dabe3acdd --- /dev/null +++ b/src/components/add-funds/Assets.tsx @@ -0,0 +1,213 @@ +import React, { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Pressable } from "react-native"; + +import { useRouter } from "expo-router"; + +import { ArrowLeft, CircleHelp, Info, Search } from "@tamagui/lucide-icons"; +import { ScrollView, XStack, YStack } from "tamagui"; + +import { useQuery } from "@tanstack/react-query"; + +import chain, { allowlists } from "@exactly/common/generated/chain"; + +import AddFundsOption from "./AddFundsOption"; +import EducationSheet from "./EducationSheet"; +import { presentArticle } from "../../utils/intercom"; +import { lifiTokensOptions, tokenCorrelation } from "../../utils/lifi"; +import reportError from "../../utils/reportError"; +import useMarkets from "../../utils/useMarkets"; +import AssetLogo from "../shared/AssetLogo"; +import IconButton from "../shared/IconButton"; +import SafeView from "../shared/SafeView"; +import Skeleton from "../shared/Skeleton"; +import Text from "../shared/Text"; +import View from "../shared/View"; +import TokenSelectModal from "../swaps/SelectorModal"; + +export default function Assets() { + const router = useRouter(); + const { t } = useTranslation(); + const { markets, supportedAssets, isPending } = useMarkets(); + const { data: tokens } = useQuery(lifiTokensOptions); + const [collateralShown, setCollateralShown] = useState(false); + const [otherShown, setOtherShown] = useState(false); + const [moreShown, setMoreShown] = useState(false); + const assets = useMemo(() => { + if (!markets) return []; + const excluded = new Set(["USDC.e", "DAI"]); + const available = markets + .filter((market) => !excluded.has(market.symbol.slice(3))) + .map((market) => + market.symbol.slice(3) === "WETH" + ? { symbol: "ETH", name: "Ether" } + : { symbol: market.symbol.slice(3), name: market.assetName }, + ); + const pinned = ["USDC", "ETH", "WBTC", "wstETH", "OP"]; + return [ + ...pinned.flatMap((symbol) => available.find((asset) => asset.symbol === symbol) ?? []), + ...available.filter((asset) => !pinned.includes(asset.symbol)), + ]; + }, [markets]); + const others = useMemo(() => { + if (!tokens || !markets) return []; + const underlying = new Set(markets.map((market) => market.asset.toLowerCase())); + const excluded = new Set(supportedAssets); + const bySymbol = new Map(); + for (const token of tokens) { + const home = token.chainId === (chain.id as (typeof token)["chainId"]); + const allowed = allowlists[String(token.chainId)]; + if (!allowed?.some((address) => address.toLowerCase() === token.address.toLowerCase())) continue; + if (home && underlying.has(token.address.toLowerCase())) continue; + const correlated = + token.symbol in tokenCorrelation ? tokenCorrelation[token.symbol as keyof typeof tokenCorrelation] : undefined; + if (excluded.has(token.symbol) || (correlated && excluded.has(correlated))) continue; + const current = bySymbol.get(token.symbol); + if (!current || (home && current.chainId !== token.chainId)) bySymbol.set(token.symbol, token); + } + return [...bySymbol.values()]; + }, [tokens, markets, supportedAssets]); + const visibleOthers = others.slice(0, 4); + return ( + + + + { + if (router.canGoBack()) { + router.back(); + } else { + router.replace("/add-funds"); + } + }} + /> + + {t("Cryptocurrencies")} + + { + presentArticle("8950801").catch(reportError); + }} + /> + + + + + + + {t("Supported assets")} + + setCollateralShown(true)}> + + + + + {isPending + ? Array.from({ length: 5 }, (_, index) => ) + : assets.map(({ symbol, name }) => ( + } + title={symbol} + subtitle={name} + onPress={() => { + router.push({ pathname: "/add-funds/network", params: { asset: symbol } }); + }} + /> + ))} + + + {others.length > 0 && ( + + + + {t("Other assets")} + + setOtherShown(true)}> + + + + + {visibleOthers.map((token) => ( + } + title={token.symbol} + subtitle={token.name} + onPress={() => { + router.push({ pathname: "/add-funds/network", params: { asset: token.symbol } }); + }} + /> + ))} + {others.length > 4 && ( + } + title={t("More assets")} + onPress={() => setMoreShown(true)} + /> + )} + + + )} + + + { + setCollateralShown(false); + }} + title={t("Supported assets")} + article="8950805" + > + + {isPending + ? Array.from({ length: 5 }, (_, index) => ) + : supportedAssets.map((symbol) => )} + + + {t( + "Only {{assets}} on {{chain}} serve as collateral, earn yield while held, and increase your Exa Card credit limit.", + { assets: supportedAssets.join(", "), chain: chain.name }, + )} + + + { + setOtherShown(false); + }} + title={t("Other assets")} + article="8950805" + > + + {t( + "You can hold these assets, but they don't earn yield or increase your Exa Card credit limit. Go to your Portfolio to swap or bridge them to a supported asset on {{chain}}.", + { chain: chain.name }, + )} + + + { + setMoreShown(false); + router.push({ pathname: "/add-funds/network", params: { asset: token.symbol } }); + }} + onClose={() => setMoreShown(false)} + /> + + + ); +} diff --git a/src/components/add-funds/Bridge.tsx b/src/components/add-funds/Bridge.tsx index 7fcdd2b044..951db8a0e0 100644 --- a/src/components/add-funds/Bridge.tsx +++ b/src/components/add-funds/Bridge.tsx @@ -4,7 +4,7 @@ import { Pressable } from "react-native"; import { useLocalSearchParams, useRouter } from "expo-router"; -import { ArrowLeft, Check, CircleHelp, Clock, Repeat, X } from "@tamagui/lucide-icons"; +import { ArrowLeft, ArrowRight, Check, CircleHelp, Clock, Repeat, Wallet, X } from "@tamagui/lucide-icons"; import { useToastController } from "@tamagui/toast"; import { ScrollView, Spinner, Square, XStack, YStack } from "tamagui"; @@ -22,7 +22,15 @@ import { zeroAddress, type Hex, } from "viem"; -import { useReadContract, useSendCalls, useSendTransaction, useSimulateContract, useWriteContract } from "wagmi"; +import { mainnet } from "viem/chains"; +import { + useEnsName, + useReadContract, + useSendCalls, + useSendTransaction, + useSimulateContract, + useWriteContract, +} from "wagmi"; import alchemyAPIKey from "@exactly/common/alchemyAPIKey"; import alchemyGasPolicyId from "@exactly/common/alchemyGasPolicyId"; @@ -30,11 +38,13 @@ import chain from "@exactly/common/generated/chain"; import shortenHex from "@exactly/common/shortenHex"; import { WAD } from "@exactly/lib"; +import AssetMatchSheet from "./AssetMatchSheet"; import AssetSelectSheet from "./AssetSelectSheet"; import { callsStatus } from "../../utils/accountClient"; import alchemyChainById from "../../utils/alchemyChains"; import { balancesOptions, + bridgeSlippage, bridgeSourcesOptions, getRouteFrom, lifiTokensOptions, @@ -80,6 +90,7 @@ export default function Bridge() { } = useTranslation(); const [assetSheetOpen, setAssetSheetOpen] = useState(false); + const [assetMatch, setAssetMatch] = useState<{ chainId: number; destinationSymbol: string; token: Token }>(); const [destinationModalOpen, setDestinationModalOpen] = useState(false); const { address: account } = useAccount(); @@ -102,6 +113,12 @@ export default function Bridge() { const isExaSender = params.sender === "exa"; const senderConfig = isExaSender ? exaConfig : ownerConfig; const { address: senderAddress } = useAccount({ config: senderConfig }); + const { data: senderEnsName } = useEnsName({ + config: ownerConfig, + chainId: mainnet.id, + address: isExaSender ? undefined : senderAddress, + query: { staleTime: 86_400_000, retry: false, meta: { dropError: () => true } }, + }); const { mutateAsync: sendTx } = useSendTransaction({ config: senderConfig }); const { mutateAsync: sendCallsTx } = useSendCalls({ config: senderConfig }); const { mutateAsync: transfer } = useWriteContract({ config: senderConfig }); @@ -319,6 +336,10 @@ export default function Bridge() { }); const approvalRequired = canReadAllowance && (allowanceData ?? 0n) < sourceAmount; + const lifiFeeUSD = (quote?.estimate.feeCosts ?? []).reduce((sum, { amountUSD }) => sum + (Number(amountUSD) || 0), 0); + const transactionFeeUSD = (quote?.estimate.gasCosts ?? []) + .filter(({ type }) => type !== "APPROVE" || approvalRequired) + .reduce((sum, { amountUSD }) => sum + (Number(amountUSD) || 0), 0); const nativeGasReserve = useMemo(() => { if (!quote?.estimate.gasCosts || !nativeAddress) return 0n; @@ -759,108 +780,43 @@ export default function Bridge() { : undefined; if (processing) { - const isPending = isBridging || isTransferring; - const isSuccess = isBridgeSuccess || isTransferSuccess; - const isError = isBridgeError || isTransferError; - const labels = { - bridge: { - error: t("Bridge failed"), - success: t("Bridge transaction submitted"), - processing: t("Processing bridge"), - }, - swap: { - error: t("Swap failed"), - success: t("Swap transaction submitted"), - processing: t("Processing swap"), - }, - transfer: { - error: t("Transfer failed"), - success: t("Transfer transaction submitted"), - processing: t("Processing transfer"), - }, + const status = + isBridgeError || isTransferError ? "error" : isBridgeSuccess || isTransferSuccess ? "success" : "pending"; + const errorTitle = { + bridge: t("Bridge failed"), + swap: t("Swap failed"), + transfer: t("Transfer failed"), }[bridgePreview.operation]; const amount = Number(formatUnits(bridgePreview.sourceAmount, bridgePreview.sourceToken.decimals)); const price = Number(bridgePreview.sourceToken.priceUSD); const usdValue = Number.isNaN(amount) || Number.isNaN(price) ? 0 : amount * price; return ( - - - - { - if (!isPending) { - setSourceAmount(0n); - setBridgePreview(undefined); - resetBridgeMutation(); - resetTransferMutation(); - } - router.dismissTo("/activity"); - }} - /> - - - {isPending && } - {isSuccess && } - {isError && } - - - - {isError ? labels.error : isSuccess ? labels.success : labels.processing} - - - - - - {`${Number( - formatUnits(bridgePreview.sourceAmount, bridgePreview.sourceToken.decimals), - ).toLocaleString(language, { - maximumFractionDigits: Math.min(6, bridgePreview.sourceToken.decimals), - })} ${bridgePreview.sourceToken.symbol}`} - - - - {`$${usdValue.toLocaleString(language, { style: "decimal", minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} - - - - - {!isPending && ( - - { - setSourceAmount(0n); - setBridgePreview(undefined); - resetBridgeMutation(); - resetTransferMutation(); - router.dismissTo("/activity"); - }} - > - - {t("Close")} - - - - )} - + { + if (status !== "pending") { + setSourceAmount(0n); + setBridgePreview(undefined); + resetBridgeMutation(); + resetTransferMutation(); + } + router.dismissTo("/activity"); + }} + /> ); } @@ -940,7 +896,11 @@ export default function Bridge() { {assetGroups.length > 0 && ( - {isTransfer ? t("Destination") : t("Destination asset")} + {t("Receive on")} {t("Exa Account")} | {shortenHex(account ?? zeroAddress, 4, 6)} @@ -1101,110 +1061,24 @@ export default function Bridge() { sourceAmount > 0n && !insufficientBalance && ( - - - {t("You send")} - - - {`${Number(formatUnits(sourceAmount, sourceToken.decimals)).toLocaleString(language, { - minimumFractionDigits: 0, - maximumFractionDigits: sourceToken.decimals, - useGrouping: false, - })} ${sourceToken.symbol}`} - - - - - {t("Source network")} - - - {selectedGroup?.chain.name ?? (source?.chain ? t("Chain {{id}}", { id: source.chain }) : "—")} - - - - - {t("Estimated arrival")} - - - {quote.estimate.toAmount - ? `≈${Number( - formatUnits(BigInt(quote.estimate.toAmount), destinationToken.decimals), - ).toLocaleString(language, { - minimumFractionDigits: 0, - maximumFractionDigits: destinationToken.decimals, - useGrouping: false, - })} ${destinationToken.symbol}` - : "—"} - - - - - {t("Destination network")} - - - {chain.name} - - {quote.estimate.toAmountMin && ( - - - {t("Minimum received")} - - - {`${Number( - formatUnits(BigInt(quote.estimate.toAmountMin), destinationToken.decimals), - ).toLocaleString(language, { - minimumFractionDigits: 0, - maximumFractionDigits: destinationToken.decimals, - useGrouping: false, - })} ${destinationToken.symbol}`} - - - )} - - - {t("Fees")} - - - 0.25% - - - - - {t("Slippage")} - - - 2% - - - {quote.estimate.executionDuration ? ( - - - {t("Estimated time")} - - - {t("~{{minutes}} min", { - minutes: Math.max(1, Math.round(quote.estimate.executionDuration / 60)), - })} - - - ) : null} - {(quote.tool ?? quote.estimate.tool) && ( - - - {t("Exchange")} - - - {quote.tool ?? quote.estimate.tool} - - + )} + + + + )} {statusMessage && ( @@ -1253,6 +1127,18 @@ export default function Bridge() { )} + {!isExaSender && (isTransfer || !!quote) && ( + + + + + + + {t("You must confirm the transactions on your external wallet.")} + + + + )} + )} + + + {t("Close")} + + + + )} + + ); +} + const gasReserveBuffer = 300n; const bridgePolicyId = "97633483-b01d-4a91-bac5-11011a06b15d"; const bridgePolicySymbols = new Set(["USDC", "USDT", "USD₮0", "DAI", "USDe", "WETH", "WBTC", "WLD"]); diff --git a/src/components/add-funds/EducationSheet.tsx b/src/components/add-funds/EducationSheet.tsx new file mode 100644 index 0000000000..b9ee68e153 --- /dev/null +++ b/src/components/add-funds/EducationSheet.tsx @@ -0,0 +1,65 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Pressable } from "react-native"; + +import { ThumbsUp } from "@tamagui/lucide-icons"; +import { ScrollView, YStack } from "tamagui"; + +import { presentArticle } from "../../utils/intercom"; +import reportError from "../../utils/reportError"; +import ModalSheet from "../shared/ModalSheet"; +import SafeView from "../shared/SafeView"; +import Button from "../shared/StyledButton"; +import Text from "../shared/Text"; + +export default function EducationSheet({ + article, + children, + onClose, + open, + title, +}: { + article: string; + children: React.ReactNode; + onClose: () => void; + open: boolean; + title: string; +}) { + const { t } = useTranslation(); + return ( + + + + + + {title} + + {children} + + { + presentArticle(article).catch(reportError); + }} + > + + {t("Learn more")} + + + + + + + ); +} diff --git a/src/components/add-funds/Network.tsx b/src/components/add-funds/Network.tsx new file mode 100644 index 0000000000..f9644eb495 --- /dev/null +++ b/src/components/add-funds/Network.tsx @@ -0,0 +1,254 @@ +import React, { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Pressable } from "react-native"; + +import { Redirect, useLocalSearchParams, useRouter } from "expo-router"; + +import { ArrowLeft, CircleHelp, Info } from "@tamagui/lucide-icons"; +import { ScrollView, XStack, YStack } from "tamagui"; + +import { useQueries, useQuery } from "@tanstack/react-query"; +import { arbitrum, base, bsc, mainnet, optimism, polygon } from "viem/chains"; + +import chain, { allowlists } from "@exactly/common/generated/chain"; + +import AddFundsOption from "./AddFundsOption"; +import EducationSheet from "./EducationSheet"; +import ReceiveGuideSheet from "./ReceiveGuideSheet"; +import alchemyChainById from "../../utils/alchemyChains"; +import factoryOptions from "../../utils/factoryOptions"; +import { presentArticle } from "../../utils/intercom"; +import { lifiChainsOptions, lifiTokensOptions, tokenCorrelation } from "../../utils/lifi"; +import queryClient from "../../utils/queryClient"; +import reportError from "../../utils/reportError"; +import useMarkets from "../../utils/useMarkets"; +import ChainLogo from "../shared/ChainLogo"; +import IconButton from "../shared/IconButton"; +import SafeView from "../shared/SafeView"; +import Text from "../shared/Text"; +import View from "../shared/View"; + +import type { Credential } from "@exactly/common/validation"; + +export default function Network() { + const router = useRouter(); + const { t } = useTranslation(); + const { asset: assetParameter } = useLocalSearchParams(); + const asset = typeof assetParameter === "string" ? assetParameter : ""; + const [nativeShown, setNativeShown] = useState(false); + const [othersShown, setOthersShown] = useState(false); + const [pending, setPending] = useState<{ + chainId: number; + symbol: string; + variant: "bridge" | "bridgeSwap" | "swap"; + }>(); + const { data: lifiChains } = useQuery(lifiChainsOptions); + const { data: tokens } = useQuery(lifiTokensOptions); + const { data: credential } = useQuery({ queryKey: ["credential"] }); + const { data: bridgeAcknowledged } = useQuery({ queryKey: ["settings", "bridge-needed-shown"] }); + const { data: swapAcknowledged } = useQuery({ queryKey: ["settings", "swap-needed-shown"] }); + const { data: bridgeSwapAcknowledged } = useQuery({ queryKey: ["settings", "bridge-swap-needed-shown"] }); + const { supportedAssets, isPending } = useMarkets(); + const symbols = useMemo(() => { + const matched = new Map(); + for (const token of tokens ?? []) { + const correlated = + token.symbol in tokenCorrelation ? tokenCorrelation[token.symbol as keyof typeof tokenCorrelation] : undefined; + if (token.symbol !== asset && correlated !== asset) continue; + const allowed = allowlists[String(token.chainId)]; + if (!allowed?.some((address) => address.toLowerCase() === token.address.toLowerCase())) continue; + if (token.symbol === asset || !matched.has(token.chainId)) matched.set(token.chainId, token.symbol); + } + return matched; + }, [tokens, asset]); + const sorted = useMemo(() => { + const others = (lifiChains ?? []).filter( + (c) => + c.id !== chain.id && + c.mainnet && + symbols.has(c.id) && + alchemyChainById.has(c.id) && + !alchemyChainById.get(c.id)?.testnet, + ); + const pinned: number[] = [mainnet.id, base.id, arbitrum.id, polygon.id, bsc.id].filter((id) => id !== chain.id); + return [ + ...pinned.flatMap((id) => others.find((c) => c.id === id) ?? []), + ...others.filter((c) => !pinned.includes(c.id)).sort((a, b) => a.name.localeCompare(b.name)), + ]; + }, [lifiChains, symbols]); + const deployable = useQueries({ + queries: sorted.map((c) => factoryOptions(credential?.factory, c.id)), + combine: (results) => sorted.filter((c, index) => results[index]?.data), + }); + if (!asset) return ; + const receivable = isPending || !tokens || supportedAssets.includes(asset) || symbols.has(chain.id); + function navigate(chainId: number, symbol: string) { + router.push({ + pathname: "/add-funds/add-crypto", + params: chainId === chain.id ? { asset, symbol } : { asset, chainId: String(chainId), symbol }, + }); + } + function selectNetwork(chainId: number) { + const supported = isPending || supportedAssets.includes(asset); + const symbol = chainId === chain.id && supported ? asset : (symbols.get(chainId) ?? asset); + const variant = chainId === chain.id ? (supported ? undefined : "swap") : supported ? "bridge" : "bridgeSwap"; + const acknowledged = { bridge: bridgeAcknowledged, bridgeSwap: bridgeSwapAcknowledged, swap: swapAcknowledged }; + if (variant && !acknowledged[variant]) { + setPending({ chainId, symbol, variant }); + return; + } + navigate(chainId, symbol); + } + return ( + + + + { + if (router.canGoBack()) { + router.back(); + } else { + router.replace("/add-funds/assets"); + } + }} + /> + + {t("Select network")} + + { + presentArticle("8950801").catch(reportError); + }} + /> + + + + {receivable && ( + + + + {t("Native network")} + + setNativeShown(true)} + > + + + + } + title={chain.id === optimism.id ? "Optimism" : chain.name} + subtitle={chain.id === optimism.id ? optimism.name : undefined} + badge={t("Recommended")} + onPress={() => selectNetwork(chain.id)} + /> + + )} + {deployable.length > 0 && ( + + + + {t("Other networks")} + + setOthersShown(true)} + > + + + + + {deployable.map((c) => ( + } + title={c.name} + onPress={() => selectNetwork(c.id)} + /> + ))} + + + )} + + + { + setNativeShown(false); + }} + title={t("Native network")} + article="8950801" + > + + + + {chain.name} + + + + {t( + "{{chain}} is Exa App's native network. Supported assets received here generate yield and increase your Exa Card credit limit immediately. Other assets need to be swapped to a supported asset first.", + { chain: chain.name }, + )} + + + { + setOthersShown(false); + }} + title={t("Other networks")} + article="8950801" + > + + {t( + "Assets from these networks need to be bridged to {{chain}}. Some may also require a swap to a supported asset to generate yield and increase your Exa Card credit limit. You can do both from your Portfolio.", + { chain: chain.name }, + )} + + + c.id === pending?.chainId)?.name ?? "") + } + onClose={() => setPending(undefined)} + onContinue={(hide) => { + if (pending) { + if (hide) queryClient.setQueryData(["settings", settingsKeys[pending.variant]], true); + navigate(pending.chainId, pending.symbol); + } + setPending(undefined); + }} + /> + + + ); +} + +const settingsKeys = { + bridge: "bridge-needed-shown", + bridgeSwap: "bridge-swap-needed-shown", + swap: "swap-needed-shown", +}; diff --git a/src/components/add-funds/ReceiveGuideSheet.tsx b/src/components/add-funds/ReceiveGuideSheet.tsx new file mode 100644 index 0000000000..8905ec051e --- /dev/null +++ b/src/components/add-funds/ReceiveGuideSheet.tsx @@ -0,0 +1,218 @@ +import React, { useState } from "react"; +import { Trans, useTranslation } from "react-i18next"; +import { Pressable } from "react-native"; + +import { ArrowRight, Check, Info } from "@tamagui/lucide-icons"; +import { Checkbox, ScrollView, Separator, XStack, YStack } from "tamagui"; + +import chain from "@exactly/common/generated/chain"; + +import { presentArticle } from "../../utils/intercom"; +import reportError from "../../utils/reportError"; +import useMarkets from "../../utils/useMarkets"; +import AssetLogo from "../shared/AssetLogo"; +import ChainLogo from "../shared/ChainLogo"; +import ModalSheet from "../shared/ModalSheet"; +import SafeView from "../shared/SafeView"; +import Button from "../shared/StyledButton"; +import Text from "../shared/Text"; +import View from "../shared/View"; + +export default function ReceiveGuideSheet({ + asset, + chainId, + network, + onClose, + onContinue, + open, + symbol, + variant, +}: { + asset: string; + chainId?: number; + network: string; + onClose: () => void; + onContinue: (hide: boolean) => void; + open: boolean; + symbol: string; + variant: "bridge" | "bridgeSwap" | "swap"; +}) { + const { t } = useTranslation(); + const { supportedAssets } = useMarkets(); + const [hide, setHide] = useState(false); + const learn = ( + { + presentArticle("8950805").catch(reportError); + }} + /> + ); + const copy = { + bridge: { + title: t("Bridge needed after receiving"), + step: t("Bridge it to {{asset}} on {{chain}}.", { asset, chain: chain.name }), + note: ( + + ), + }, + swap: { + title: t("Swap needed after receiving"), + step: t("Swap it to a supported asset on {{chain}}.", { chain: chain.name }), + note: ( + + ), + }, + bridgeSwap: { + title: t("Bridge and swap needed after receiving"), + step: t("Bridge and swap it to a supported asset on {{chain}}.", { chain: chain.name }), + note: ( + + ), + }, + }[variant]; + return ( + { + setHide(false); + onClose(); + }} + disableDrag + > + + + + + {copy.title} + + + + + + + + + + + {variant === "bridge" ? ( + + ) : ( + + {supportedAssets.map((collateral, index) => ( + + + + ))} + + )} + + + + + + + + + + + + + + + + + {copy.note} + + + + setHide(!hide)}> + + + + + + + + {t("Don't show again")} + + + + + + + + ); +} + +function Step({ index, text }: { index: number; text: string }) { + return ( + + + + {index} + + + + {text} + + + ); +} diff --git a/src/components/add-funds/SupportedAssetsSheet.tsx b/src/components/add-funds/SupportedAssetsSheet.tsx deleted file mode 100644 index 7911d11d7d..0000000000 --- a/src/components/add-funds/SupportedAssetsSheet.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import React from "react"; -import { Trans, useTranslation } from "react-i18next"; - -import { AlertTriangle, X } from "@tamagui/lucide-icons"; -import { ScrollView, XStack, YStack } from "tamagui"; - -import chain from "@exactly/common/generated/chain"; - -import { presentArticle } from "../../utils/intercom"; -import reportError from "../../utils/reportError"; -import useMarkets from "../../utils/useMarkets"; -import AssetLogo from "../shared/AssetLogo"; -import ModalSheet from "../shared/ModalSheet"; -import SafeView from "../shared/SafeView"; -import Skeleton from "../shared/Skeleton"; -import Button from "../shared/StyledButton"; -import Text from "../shared/Text"; -import View from "../shared/View"; - -export default function SupportedAssetsSheet({ open, onClose }: { onClose: () => void; open: boolean }) { - const { t } = useTranslation(); - const { supportedAssets, isPending } = useMarkets(); - return ( - - - - - - - {t("Supported assets")} - - - - {isPending - ? Array.from({ length: 5 }, (_, index) => ( - - - - - )) - : supportedAssets.map((symbol) => ( - - - - {symbol} - - - ))} - - - - - - - - { - presentArticle("8950801").catch(reportError); - }} - /> - ), - }} - /> - - - - - - - - - ); -} - -function Chip({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} diff --git a/src/components/shared/CopyAddressSheet.tsx b/src/components/shared/CopyAddressSheet.tsx index 33f437ec4f..aaec91dc09 100644 --- a/src/components/shared/CopyAddressSheet.tsx +++ b/src/components/shared/CopyAddressSheet.tsx @@ -1,44 +1,39 @@ import React from "react"; import { useTranslation } from "react-i18next"; -import { CheckCircle, X } from "@tamagui/lucide-icons"; +import { AlertTriangle, CheckCircle, X } from "@tamagui/lucide-icons"; import { ScrollView, XStack, YStack } from "tamagui"; import chain from "@exactly/common/generated/chain"; -import AssetLogo from "./AssetLogo"; -import ChainLogo from "./ChainLogo"; -import Image from "./Image"; import ModalSheet from "./ModalSheet"; -import Skeleton from "./Skeleton"; +import SafeView from "./SafeView"; +import SendWarning from "./SendWarning"; +import Button from "./StyledButton"; +import Text from "./Text"; +import View from "./View"; +import { presentArticle } from "../../utils/intercom"; +import reportError from "../../utils/reportError"; import useAccount from "../../utils/useAccount"; -import useMarkets from "../../utils/useMarkets"; -import SafeView from "../shared/SafeView"; -import Button from "../shared/StyledButton"; -import Text from "../shared/Text"; export default function CopyAddressSheet({ open, onClose, address: overrideAddress, + asset, network, - networkLogo, - assets, }: { address?: string; - assets?: string[]; + asset?: string; network?: string; - networkLogo?: string; onClose: () => void; open: boolean; }) { const { address: accountAddress } = useAccount(); - const { supportedAssets, isPending } = useMarkets(); - const displayAssets = assets ?? supportedAssets; const { t } = useTranslation(); return ( - + - + {t("Address copied")} - + {t("Double-check your address before sending funds to avoid losing them.")} - + {overrideAddress ?? accountAddress} - - - - {t("Network")} - - - {displayAssets.length === 1 ? t("Asset") : t("Supported Assets")} - - - - - {networkLogo ? ( - - ) : ( - - )} - - {network ?? chain.name} + + + + + + + { + presentArticle("8950801").catch(reportError); + }} + > + {" "} + {t("Learn more about adding funds.")} - - - {!assets && isPending - ? Array.from({ length: 5 }, (_, index) => ( - - - - )) - : displayAssets.map((symbol, index) => ( - - - - ))} - + - -