diff --git a/.changeset/better-feet-arrive.md b/.changeset/better-feet-arrive.md new file mode 100644 index 0000000000..d980c54968 --- /dev/null +++ b/.changeset/better-feet-arrive.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +🐛 walk cause chain when extracting error status diff --git a/.changeset/better-nights-brush.md b/.changeset/better-nights-brush.md new file mode 100644 index 0000000000..b5d6fb4b20 --- /dev/null +++ b/.changeset/better-nights-brush.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +🐛 namespace persisted storage by chain id diff --git a/.changeset/brisk-otters-sing.md b/.changeset/brisk-otters-sing.md new file mode 100644 index 0000000000..2ccbf5810e --- /dev/null +++ b/.changeset/brisk-otters-sing.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +🐛 fix usd display for tiny token balances diff --git a/.changeset/easy-rockets-reply.md b/.changeset/easy-rockets-reply.md new file mode 100644 index 0000000000..d291c25477 --- /dev/null +++ b/.changeset/easy-rockets-reply.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +🧱 add cross-chain account client support diff --git a/.changeset/fuzzy-icons-push.md b/.changeset/fuzzy-icons-push.md new file mode 100644 index 0000000000..6de3a8e1ec --- /dev/null +++ b/.changeset/fuzzy-icons-push.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +♻️ refactor bridge sources with multi-chain balance fetching diff --git a/.changeset/open-coats-battle.md b/.changeset/open-coats-battle.md new file mode 100644 index 0000000000..1451ef8144 --- /dev/null +++ b/.changeset/open-coats-battle.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +💄 align close buttons to start diff --git a/.changeset/purple-hats-occur.md b/.changeset/purple-hats-occur.md new file mode 100644 index 0000000000..d43684030a --- /dev/null +++ b/.changeset/purple-hats-occur.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +🐛 avoid precision loss in external asset value diff --git a/.changeset/smooth-plants-grab.md b/.changeset/smooth-plants-grab.md new file mode 100644 index 0000000000..87dae9f0af --- /dev/null +++ b/.changeset/smooth-plants-grab.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +♻️ split portfolio pending states diff --git a/.changeset/soft-ferrets-tap.md b/.changeset/soft-ferrets-tap.md new file mode 100644 index 0000000000..9b71a57377 --- /dev/null +++ b/.changeset/soft-ferrets-tap.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +💄 refine portfolio tap feedback diff --git a/.changeset/solid-snails-add.md b/.changeset/solid-snails-add.md new file mode 100644 index 0000000000..86057799bd --- /dev/null +++ b/.changeset/solid-snails-add.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +🐛 fix swap insufficient balance check diff --git a/.changeset/strong-kings-pay.md b/.changeset/strong-kings-pay.md new file mode 100644 index 0000000000..b0b55335e9 --- /dev/null +++ b/.changeset/strong-kings-pay.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +💄 improve defi sheets layout diff --git a/.changeset/tough-ducks-end.md b/.changeset/tough-ducks-end.md new file mode 100644 index 0000000000..14a754ef85 --- /dev/null +++ b/.changeset/tough-ducks-end.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +✨ recover funds from any network diff --git a/.changeset/twelve-points-sniff.md b/.changeset/twelve-points-sniff.md new file mode 100644 index 0000000000..444cf20103 --- /dev/null +++ b/.changeset/twelve-points-sniff.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +🥅 handle localized native auth errors by matching expo codes diff --git a/.changeset/wicked-buses-go.md b/.changeset/wicked-buses-go.md new file mode 100644 index 0000000000..5178a02e29 --- /dev/null +++ b/.changeset/wicked-buses-go.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +♻️ use sets for query client exclusions diff --git a/.changeset/wide-laws-travel.md b/.changeset/wide-laws-travel.md new file mode 100644 index 0000000000..2426aed298 --- /dev/null +++ b/.changeset/wide-laws-travel.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +🐛 allow provider access regardless of chain id diff --git a/.changeset/wild-geckos-remain.md b/.changeset/wild-geckos-remain.md new file mode 100644 index 0000000000..c5d67b40bb --- /dev/null +++ b/.changeset/wild-geckos-remain.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +🐛 fix proposal block override time diff --git a/.changeset/witty-emus-divide.md b/.changeset/witty-emus-divide.md new file mode 100644 index 0000000000..5807513641 --- /dev/null +++ b/.changeset/witty-emus-divide.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +🐛 stabilize receiver input controlled state diff --git a/src/components/add-funds/Bridge.tsx b/src/components/add-funds/Bridge.tsx index c7f20954ba..1162b5d92e 100644 --- a/src/components/add-funds/Bridge.tsx +++ b/src/components/add-funds/Bridge.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Pressable } from "react-native"; -import { useRouter } from "expo-router"; +import { useLocalSearchParams, useRouter } from "expo-router"; import { ArrowLeft, Check, CircleHelp, Clock, Repeat, X } from "@tamagui/lucide-icons"; import { useToastController } from "@tamagui/toast"; @@ -27,12 +27,22 @@ import shortenHex from "@exactly/common/shortenHex"; import { WAD } from "@exactly/lib"; import AssetSelectSheet from "./AssetSelectSheet"; -import { getBridgeSources, getRouteFrom, tokenCorrelation, type BridgeSources, type RouteFrom } from "../../utils/lifi"; +import deployedOptions from "../../utils/deployedOptions"; +import { + balancesOptions, + bridgeSourcesOptions, + getRouteFrom, + tokenAmountsToBalances, + tokenCorrelation, + type RouteFrom, + type TokenBalance, +} from "../../utils/lifi"; import openBrowser from "../../utils/openBrowser"; import queryClient from "../../utils/queryClient"; import reportError, { classifyError } from "../../utils/reportError"; import useAccount from "../../utils/useAccount"; -import useMarkets from "../../utils/useMarkets"; +import usePortfolio from "../../utils/usePortfolio"; +import exaConfig from "../../utils/wagmi/exa"; import ownerConfig from "../../utils/wagmi/owner"; import AssetLogo from "../shared/AssetLogo"; import ChainLogo from "../shared/ChainLogo"; @@ -47,10 +57,15 @@ import View from "../shared/View"; import TokenInput from "../swaps/TokenInput"; import type { Chain, Token } from "@lifi/sdk"; -import type { TFunction } from "i18next"; export default function Bridge() { const router = useRouter(); + const rawParameters = useLocalSearchParams(); + const params = { + sender: Array.isArray(rawParameters.sender) ? rawParameters.sender[0] : rawParameters.sender, + sourceChain: Array.isArray(rawParameters.sourceChain) ? rawParameters.sourceChain[0] : rawParameters.sourceChain, + sourceToken: Array.isArray(rawParameters.sourceToken) ? rawParameters.sourceToken[0] : rawParameters.sourceToken, + }; const toast = useToastController(); const { t, @@ -61,70 +76,74 @@ export default function Bridge() { const [destinationModalOpen, setDestinationModalOpen] = useState(false); const { address: account } = useAccount(); - const { markets } = useMarkets(); - const [selectedSource, setSelectedSource] = useState(); + const [selectedSource, setSelectedSource] = useState(() => { + if (!params.sourceChain || !params.sourceToken) return; + const chainId = Number(params.sourceChain); + if (!Number.isInteger(chainId) || chainId <= 0) return; + if (!isAddress(params.sourceToken)) return; + return { chain: chainId, address: params.sourceToken.toLowerCase() }; + }); const [selectedDestinationAddress, setSelectedDestinationAddress] = useState(); const [sourceAmount, setSourceAmount] = useState(0n); const [bridgeStatus, setBridgeStatus] = useState(); - const [bridgePreview, setBridgePreview] = useState(); + const [bridgePreview, setBridgePreview] = useState< + undefined | { operation: "bridge" | "swap" | "transfer"; sourceAmount: bigint; sourceToken: Token } + >(); - const senderConfig = ownerConfig; + const isExaSender = params.sender === "exa"; + const senderConfig = isExaSender ? exaConfig : ownerConfig; const { address: senderAddress } = useAccount({ config: senderConfig }); const { mutateAsync: sendTx } = useSendTransaction({ config: senderConfig }); const { mutateAsync: sendCallsTx } = useSendCalls({ config: senderConfig }); const { mutateAsync: transfer } = useWriteContract({ config: senderConfig }); + const { protocolSymbols } = usePortfolio(); - const protocolSymbols = useMemo(() => { - if (!markets) return []; - return [ - ...new Set([ - ...markets - .map((market) => market.symbol.slice(3)) - .filter((symbol) => symbol !== "USDC.e" && symbol !== "DAI" && symbol !== "WETH"), - "ETH", - ]), - ]; - }, [markets]); - - const { data: bridge, isPending: isSourcesPending } = useQuery({ - queryKey: ["bridge", "sources", senderAddress, protocolSymbols], - queryFn: () => getBridgeSources(senderAddress ?? undefined, protocolSymbols), - staleTime: 60_000, - enabled: !!senderAddress && !!markets && protocolSymbols.length > 0, + const { data: bridge, isPending: isSourcesPending } = useQuery({ + ...bridgeSourcesOptions(senderAddress, protocolSymbols), refetchInterval: 60_000, refetchIntervalInBackground: true, }); + const { data: balances } = useQuery(balancesOptions(senderAddress)); + const sameChainBalances = balances?.[chain.id]; const chains = bridge?.chains; - const ownerAssetsByChain = bridge?.ownerAssetsByChain; + const balancesByChain = bridge?.balancesByChain; const usdByToken = bridge?.usdByToken; + const sameChainAssets = useMemo( + () => (sameChainBalances ? tokenAmountsToBalances(sameChainBalances) : []), + [sameChainBalances], + ); + const assetGroups = useMemo(() => { - if (!chains) return []; - return chains.reduce<{ assets: { balance: bigint; token: Token; usdValue: number }[]; chain: Chain }[]>( - (accumulator, chainItem) => { - const assets = ownerAssetsByChain?.[chainItem.id] ?? []; - if (assets.length > 0) accumulator.push({ chain: chainItem, assets }); - return accumulator; - }, - [], - ); - }, [chains, ownerAssetsByChain]); + const next: { assets: TokenBalance[]; chain: Pick }[] = []; + const sameChainSource = sameChainAssets.length > 0 ? sameChainAssets : (balancesByChain?.[chain.id] ?? []); + if (sameChainSource.length > 0) { + const chainInfo = chains?.find((c) => c.id === chain.id) ?? { id: chain.id, name: chain.name }; + next.push({ chain: chainInfo, assets: sameChainSource }); + } + if (chains) { + for (const chainItem of chains) { + if (chainItem.id === chain.id) continue; + const assets = balancesByChain?.[chainItem.id] ?? []; + if (assets.length > 0) next.push({ chain: chainItem, assets }); + } + } + return next; + }, [chains, balancesByChain, sameChainAssets]); const previousSourceRef = useRef(undefined); const source = useMemo(() => { if (assetGroups.length === 0) return; - const isValid = - !!selectedSource && - assetGroups.some( - (group) => - group.chain.id === selectedSource.chain && - group.assets.some((asset) => asset.token.address === selectedSource.address), - ); - if (isValid) return selectedSource; + if (selectedSource) { + const matched = assetGroups + .find((group) => group.chain.id === selectedSource.chain) + ?.assets.find((asset) => asset.token.address.toLowerCase() === selectedSource.address); + if (matched) return { chain: selectedSource.chain, address: matched.token.address }; + } const defaultGroup = bridge?.defaultChainId ? assetGroups.find((group) => group.chain.id === bridge.defaultChainId) : undefined; @@ -144,10 +163,20 @@ export default function Bridge() { const insufficientBalance = sourceAmount > sourceBalance; const isSameChain = source?.chain === chain.id; + const isSwap = isSameChain && isExaSender; + const isTransfer = isSameChain && !isExaSender; const isNativeSource = source?.address === zeroAddress; + const isRecovery = isExaSender && !!source?.chain && source.chain !== chain.id; + const { + data: deployed, + error: deploymentError, + isLoading: isCheckingDeployment, + } = useQuery({ ...deployedOptions(senderAddress, source?.chain), enabled: isRecovery }); + const notDeployed = isRecovery && deployed === false; + const deploymentCheckFailed = isRecovery && deployed === undefined && !!deploymentError; + const destinationTokens = useMemo(() => bridge?.tokensByChain[chain.id] ?? [], [bridge?.tokensByChain]); - const destinationBalances = useMemo(() => bridge?.balancesByChain[chain.id] ?? [], [bridge?.balancesByChain]); const effectiveDestinationAddress = useMemo(() => { if (!sourceTokenAddress) return; @@ -168,7 +197,7 @@ export default function Bridge() { const destinationToken = destinationTokens.find((token) => token.address === effectiveDestinationAddress); const destinationBalance = destinationToken - ? (destinationBalances.find((item) => item.address === destinationToken.address)?.amount ?? 0n) + ? (sameChainBalances?.find((item) => item.address === destinationToken.address)?.amount ?? 0n) : 0n; const destinationAssetGroups = useMemo(() => { @@ -182,7 +211,7 @@ export default function Bridge() { const assets = destinationTokens .filter((token) => token.logoURI && protocolSymbols.includes(token.symbol)) .map((token) => { - const balance = destinationBalances.find((item) => item.address === token.address)?.amount ?? 0n; + const balance = sameChainBalances?.find((item) => item.address === token.address)?.amount ?? 0n; const usdKey = `${chain.id}:${token.address}`; const usdValue = usdByToken?.[usdKey] ?? 0; return { @@ -192,7 +221,7 @@ export default function Bridge() { }; }); return [{ chain: chainData, assets }]; - }, [chains, destinationBalances, destinationTokens, protocolSymbols, usdByToken]); + }, [chains, sameChainBalances, destinationTokens, protocolSymbols, usdByToken]); const bridgeQuoteEnabled = !!senderAddress && @@ -202,7 +231,7 @@ export default function Bridge() { !!destinationToken && sourceAmount > 0n && !insufficientBalance && - !isSameChain; + !isTransfer; const { data: bridgeQuote, @@ -218,7 +247,7 @@ export default function Bridge() { sourceToken, destinationToken, sourceAmount, - isSameChain, + isTransfer, ], queryFn: () => { if ( @@ -228,7 +257,7 @@ export default function Bridge() { !sourceToken || !destinationToken || sourceAmount === 0n || - isSameChain + isTransfer ) throw new Error("invalid bridge parameters"); return getRouteFrom({ @@ -246,10 +275,11 @@ export default function Bridge() { meta: { warnError: () => true }, }); - const toAmount = bridgeQuote ? BigInt(bridgeQuote.estimate.toAmount) : sourceAmount; + const quote = bridgeQuote && !bridgeQuoteError ? bridgeQuote : undefined; + const toAmount = quote ? BigInt(quote.estimate.toAmount) : sourceAmount; const transferSimulationEnabled = - isSameChain && + isTransfer && !isNativeSource && !!senderAddress && !!account && @@ -273,8 +303,8 @@ export default function Bridge() { }); const approvalTokenAddress = source?.address && isAddress(source.address) ? source.address : undefined; - const approvalSpenderAddress = bridgeQuote?.estimate.approvalAddress; - const approvalChainId = bridgeQuote?.chainId; + const approvalSpenderAddress = quote?.estimate.approvalAddress; + const approvalChainId = quote?.chainId; const canReadAllowance = !!senderAddress && @@ -306,11 +336,15 @@ export default function Bridge() { mutationKey: ["bridge", "execute"], onMutate: (route) => { if (!sourceToken || !destinationToken) return; - setBridgePreview({ sourceToken, sourceAmount: BigInt(route.estimate.fromAmount) }); + setBridgePreview({ + operation: isSwap ? "swap" : "bridge", + sourceToken, + sourceAmount: BigInt(route.estimate.fromAmount), + }); }, mutationFn: async (from) => { if (!senderAddress || !source || !account) throw new Error("missing bridge context"); - if (isSameChain) throw new Error("invalid bridge context"); + if (isTransfer) throw new Error("invalid bridge context"); const spender = from.estimate.approvalAddress; const requiresApproval = !!spender && @@ -343,52 +377,76 @@ export default function Bridge() { }); } } + if (!isExaSender) { + setBridgeStatus( + t("Switching to {{chain}}...", { chain: selectedGroup?.chain.name ?? `Chain ${from.chainId}` }), + ); + await switchChain(senderConfig, { chainId: source.chain }); + } setBridgeStatus(t("Submitting bridge transaction...")); - let id: string | undefined; try { - const result = await sendCallsTx({ - chainId: source.chain, - calls: [ - ...(approval ? [{ to: getAddress(source.address), data: approval }] : []), - { to: from.to, data: from.data, value: from.value }, - ], - }); - id = result.id; - } catch (error) { - if (classifyError(error).authKnown) throw error; - reportError(error, { - level: "warning", - extra: error instanceof Error ? { cause: error.cause } : undefined, - }); - await switchChain(senderConfig, { chainId: source.chain }); + let id: string | undefined; try { + const result = await sendCallsTx({ + chainId: source.chain, + calls: [ + ...(approval ? [{ to: getAddress(source.address), data: approval }] : []), + { to: from.to, data: from.data, value: from.value }, + ], + }); + id = result.id; + } catch (error) { + if (isExaSender) throw error; + if (classifyError(error).authKnown) throw error; + reportError(error, { + level: "warning", + extra: error instanceof Error ? { cause: error.cause } : undefined, + }); + await switchChain(senderConfig, { chainId: source.chain }); if (approval) { const hash = await sendTx({ chainId: source.chain, to: getAddress(source.address), data: approval }); await waitForTransactionReceipt(senderConfig, { hash, chainId: source.chain }); } const hash = await sendTx({ chainId: source.chain, to: from.to, data: from.data, value: from.value }); await waitForTransactionReceipt(senderConfig, { hash, chainId: source.chain }); - } finally { - await switchChain(senderConfig, { chainId: chain.id }).catch(reportError); + setBridgeStatus(t("Bridge transaction submitted")); + return; } + if (!id) throw new Error("missing sendCalls id"); + const { status } = await waitForCallsStatus(senderConfig, { id }); + if (status === "failure") throw new Error("failed to submit bridge transaction"); setBridgeStatus(t("Bridge transaction submitted")); - return; + } finally { + if (!isExaSender) await switchChain(senderConfig, { chainId: chain.id }).catch(reportError); } - if (!id) throw new Error("missing sendCalls id"); - const { status } = await waitForCallsStatus(senderConfig, { id }); - if (status === "failure") throw new Error("failed to submit bridge transaction"); - setBridgeStatus(t("Bridge transaction submitted")); }, onSuccess: async () => { - toast.show(t("Bridge transaction submitted"), { - native: true, - duration: 1000, - burntOptions: { haptic: "success", preset: "done" }, - }); - await queryClient.invalidateQueries({ queryKey: ["bridge", "sources"] }); + toast.show( + bridgePreview?.operation === "swap" ? t("Swap transaction submitted") : t("Bridge transaction submitted"), + { + native: true, + duration: 1000, + burntOptions: { haptic: "success", preset: "done" }, + }, + ); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["bridge", "sources"] }), + queryClient.invalidateQueries({ queryKey: ["lifi", "balances"] }), + ]); }, - onError: (error: unknown) => { - handleError(error, toast, t); + onError(error) { + if (classifyError(error).authKnown) { + setBridgePreview(undefined); + resetBridgeMutation(); + return; + } + reportError(error); + toast.show( + bridgePreview?.operation === "swap" + ? t("Swap failed. Please try again.") + : t("Bridge failed. Please try again."), + { native: true, duration: 1000, burntOptions: { haptic: "error", preset: "error" } }, + ); }, onSettled: () => { setBridgeStatus(undefined); @@ -406,17 +464,17 @@ export default function Bridge() { mutationKey: ["bridge", "transfer"], onMutate: () => { if (!sourceToken) return; - setBridgePreview({ sourceToken, sourceAmount }); + setBridgePreview({ operation: "transfer", sourceToken, sourceAmount }); }, mutationFn: async () => { if (!senderAddress || !source || !account) throw new Error("missing transfer context"); - if (!isSameChain) throw new Error("transfer mutation invoked for different chains"); - setBridgeStatus(t("Submitting transfer transaction...")); + if (!isTransfer) throw new Error("transfer mutation invoked for different chains"); await switchChain(senderConfig, { chainId: chain.id }); + setBridgeStatus(t("Submitting transfer transaction...")); const recipient = getAddress(account); let hash: Hex; if (isNativeSource) { - hash = await sendTx({ chainId: source.chain, to: recipient, value: sourceAmount }); + hash = await sendTx({ to: recipient, value: sourceAmount }); } else { if (!transferSimulation) throw new Error("missing transfer simulation"); hash = await transfer({ ...transferSimulation.request, chainId: source.chain }); @@ -430,10 +488,23 @@ export default function Bridge() { duration: 1000, burntOptions: { haptic: "success", preset: "done" }, }); - await queryClient.invalidateQueries({ queryKey: ["bridge", "sources"] }); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["bridge", "sources"] }), + queryClient.invalidateQueries({ queryKey: ["lifi", "balances"] }), + ]); }, - onError: (error: unknown) => { - handleError(error, toast, t, true); + onError(error) { + if (classifyError(error).authKnown) { + setBridgePreview(undefined); + resetTransferMutation(); + return; + } + reportError(error); + toast.show(t("Transfer failed. Please try again."), { + native: true, + duration: 1000, + burntOptions: { haptic: "error", preset: "error" }, + }); }, onSettled: () => { setBridgeStatus(undefined); @@ -444,7 +515,8 @@ export default function Bridge() { const isTransferSimulationPending = transferSimulationEnabled && isSimulatingTransfer; const isBridgeQuoteLoading = bridgeQuoteEnabled && isBridgeQuoteFetching; - const canShowBridgeQuote = !isSameChain && !!bridgeQuote; + const bridgeQuoteReady = !!quote; + const canShowBridgeQuote = !isTransfer && bridgeQuoteReady; const processing = !!bridgePreview && (isBridging || isBridgeSuccess || isBridgeError || isTransferring || isTransferSuccess || isTransferError); @@ -456,13 +528,15 @@ export default function Bridge() { isBridging || isTransferring || isTransferSimulationPending || + isCheckingDeployment || + notDeployed || + deploymentCheckFailed || !senderAddress || !account || !sourceToken || - !destinationToken || sourceAmount === 0n || insufficientBalance || - (!isSameChain && !bridgeQuote); + (!isTransfer && !bridgeQuoteReady); const statusMessage = isBridging || isTransferring @@ -477,7 +551,23 @@ export default function Bridge() { const isPending = isBridging || isTransferring; const isSuccess = isBridgeSuccess || isTransferSuccess; const isError = isBridgeError || isTransferError; - const isTransfer = isTransferring || isTransferSuccess || 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"), + }, + }[bridgePreview.operation]; const amount = Number(formatUnits(bridgePreview.sourceAmount, bridgePreview.sourceToken.decimals)); const price = Number(bridgePreview.sourceToken.priceUSD); @@ -487,6 +577,7 @@ export default function Bridge() { { @@ -517,17 +608,7 @@ export default function Bridge() { - {isError - ? isTransfer - ? t("Transfer failed") - : t("Bridge failed") - : isSuccess - ? isTransfer - ? t("Transfer transaction submitted") - : t("Bridge transaction submitted") - : isTransfer - ? t("Processing transfer") - : t("Processing bridge")} + {isError ? labels.error : isSuccess ? labels.success : labels.processing} @@ -678,14 +759,14 @@ export default function Bridge() { - {isSameChain ? t("Destination") : t("Destination asset")} + {isTransfer ? t("Destination") : t("Destination asset")} {t("Exa Account")} | {shortenHex(account ?? zeroAddress, 4, 6)} - {!isSameChain && ( + {!isTransfer && ( { @@ -809,9 +890,9 @@ export default function Bridge() { {t("Estimated arrival")} - {bridgeQuote.estimate.toAmount + {quote.estimate.toAmount ? `≈${Number( - formatUnits(BigInt(bridgeQuote.estimate.toAmount), destinationToken.decimals), + formatUnits(BigInt(quote.estimate.toAmount), destinationToken.decimals), ).toLocaleString(language, { minimumFractionDigits: 0, maximumFractionDigits: destinationToken.decimals, @@ -828,14 +909,14 @@ export default function Bridge() { {chain.name} - {bridgeQuote.estimate.toAmountMin && ( + {quote.estimate.toAmountMin && ( {t("Minimum received")} {`${Number( - formatUnits(BigInt(bridgeQuote.estimate.toAmountMin), destinationToken.decimals), + formatUnits(BigInt(quote.estimate.toAmountMin), destinationToken.decimals), ).toLocaleString(language, { minimumFractionDigits: 0, maximumFractionDigits: destinationToken.decimals, @@ -860,19 +941,19 @@ export default function Bridge() { 2% - {bridgeQuote.estimate.executionDuration ? ( + {quote.estimate.executionDuration ? ( {t("Estimated time")} {t("~{{minutes}} min", { - minutes: Math.max(1, Math.round(bridgeQuote.estimate.executionDuration / 60)), + minutes: Math.max(1, Math.round(quote.estimate.executionDuration / 60)), })} ) : null} - {(bridgeQuote.tool ?? bridgeQuote.estimate.tool) && ( + {(quote.tool ?? quote.estimate.tool) && ( {t("Exchange")} @@ -884,7 +965,7 @@ export default function Bridge() { flexShrink={1} textTransform="uppercase" > - {bridgeQuote.tool ?? bridgeQuote.estimate.tool} + {quote.tool ?? quote.estimate.tool} )} @@ -904,7 +985,7 @@ export default function Bridge() { )} {transferSimulationError && - isSameChain && + isTransfer && !isNativeSource && sourceAmount > 0n && !insufficientBalance && ( @@ -912,17 +993,33 @@ export default function Bridge() { {t("Unable to simulate a transfer right now. Please adjust the amount or try again later.")} )} + {notDeployed && ( + + + {t( + "Recovery from this network is not yet available. Your funds are safe and will be recoverable once we add support.", + )} + + + )} + {deploymentCheckFailed && ( + + + {t("Unable to verify this network right now. Please try again in a moment.")} + + + )} - {bridgeQuote?.estimate.approvalAddress && ( + {quote?.estimate.approvalAddress && ( @@ -941,21 +1038,23 @@ export default function Bridge() { width="100%" alignItems="center" onPress={() => { - if (isSameChain) { + if (isTransfer) { executeTransfer().catch(reportError); return; } - if (!bridgeQuote) return; - executeBridge(bridgeQuote).catch(reportError); + if (!quote) return; + executeBridge(quote).catch(reportError); }} disabled={isActionDisabled} loading={isBridging || isTransferring} > {sourceToken - ? isSameChain + ? isTransfer ? t("Transfer {{symbol}}", { symbol: sourceToken.symbol }) - : t("Bridge {{symbol}}", { symbol: sourceToken.symbol }) + : isSwap + ? t("Swap {{symbol}}", { symbol: sourceToken.symbol }) + : t("Bridge {{symbol}}", { symbol: sourceToken.symbol }) : t("Select source asset")} @@ -974,7 +1073,7 @@ export default function Bridge() { selected={source} onSelect={(chainId, token) => { setSourceAmount(0n); - setSelectedSource({ chain: chainId, address: token.address }); + setSelectedSource({ chain: chainId, address: token.address.toLowerCase() }); }} /> ); } - -function handleError(error: unknown, toast: ReturnType, t: TFunction, isTransfer?: boolean) { - if (!reportError(error).authKnown) - toast.show(isTransfer ? t("Transfer failed. Please try again.") : t("Bridge failed. Please try again."), { - native: true, - duration: 1000, - burntOptions: { haptic: "error", preset: "error" }, - }); -} diff --git a/src/components/defi/ConnectionSheet.tsx b/src/components/defi/ConnectionSheet.tsx index db4d92fcfd..644eaf5a5a 100644 --- a/src/components/defi/ConnectionSheet.tsx +++ b/src/components/defi/ConnectionSheet.tsx @@ -46,7 +46,7 @@ export default function ConnectionSheet({ - + diff --git a/src/components/defi/IntroSheet.tsx b/src/components/defi/IntroSheet.tsx index 927a699605..9698c10504 100644 --- a/src/components/defi/IntroSheet.tsx +++ b/src/components/defi/IntroSheet.tsx @@ -29,15 +29,15 @@ export default function IntroSheet({ open, onClose }: { onClose: () => void; ope - - + + - + {t("Welcome to DeFi")} - + {t("Access decentralized services provided by third-party DeFi protocols.")} diff --git a/src/components/home/AssetList.tsx b/src/components/home/AssetList.tsx index e321333cbf..389626b151 100644 --- a/src/components/home/AssetList.tsx +++ b/src/components/home/AssetList.tsx @@ -1,17 +1,60 @@ -import React from "react"; +import React, { useState } from "react"; import { useTranslation } from "react-i18next"; +import { Info } from "@tamagui/lucide-icons"; import { XStack, YStack } from "tamagui"; -import { parseUnits } from "viem"; - +import chain from "@exactly/common/generated/chain"; import { floatingDepositRates } from "@exactly/lib"; +import CollateralAssetsSheet from "./CollateralAssetsSheet"; import useMarkets from "../../utils/useMarkets"; -import usePortfolio from "../../utils/usePortfolio"; import AssetLogo from "../shared/AssetLogo"; +import ChainLogo from "../shared/ChainLogo"; import Skeleton from "../shared/Skeleton"; import Text from "../shared/Text"; +import View from "../shared/View"; + +export default function AssetList() { + const { t } = useTranslation(); + const { markets, rateSnapshot, timestamp } = useMarkets(); + const [sheetOpen, setSheetOpen] = useState(false); + + const rates = rateSnapshot ? floatingDepositRates(rateSnapshot, Number(timestamp)) : []; + + const collateralAssets = + markets + ?.map((market) => { + const symbol = market.symbol.slice(3) === "WETH" ? "ETH" : market.symbol.slice(3); + const rate = rates.find((r: { market: string; rate: bigint }) => r.market === market.market)?.rate; + return { + symbol, + name: symbol, + assetName: market.assetName === "Wrapped Ether" ? "Ether" : market.assetName, + market: market.market, + amount: market.floatingDepositAssets, + decimals: market.decimals, + usdPrice: market.usdPrice, + usdValue: (market.floatingDepositAssets * market.usdPrice) / BigInt(10 ** market.decimals), + rate, + }; + }) + .filter(({ amount, symbol }) => (symbol === "USDC.e" ? amount > 0n : true)) + .sort((a, b) => Number(b.usdValue) - Number(a.usdValue)) ?? []; + + return ( + <> + { + setSheetOpen(true); + }} + /> + setSheetOpen(false)} /> + + ); +} type AssetItem = { amount: bigint; @@ -34,7 +77,12 @@ function AssetRow({ asset }: { asset: AssetItem }) { return ( - + + + + + + {symbol} @@ -86,60 +134,27 @@ function AssetRow({ asset }: { asset: AssetItem }) { ); } -function AssetSection({ title, assets }: { assets: AssetItem[]; title: string }) { +function AssetSection({ + title, + assets, + onInfoPress, +}: { + assets: AssetItem[]; + onInfoPress?: () => void; + title: string; +}) { if (assets.length === 0) return null; return ( - - {title} - + + + {title} + + {onInfoPress ? : null} + {assets.map((asset) => ( ))} ); } - -export default function AssetList() { - const { t } = useTranslation(); - const { markets, rateSnapshot, timestamp } = useMarkets(); - const { externalAssets } = usePortfolio(); - - const rates = rateSnapshot ? floatingDepositRates(rateSnapshot, Number(timestamp)) : []; - - const collateralAssets = - markets - ?.map((market) => { - const symbol = market.symbol.slice(3) === "WETH" ? "ETH" : market.symbol.slice(3); - const rate = rates.find((r: { market: string; rate: bigint }) => r.market === market.market)?.rate; - return { - symbol, - name: symbol, - assetName: market.assetName === "Wrapped Ether" ? "Ether" : market.assetName, - market: market.market, - amount: market.floatingDepositAssets, - decimals: market.decimals, - usdPrice: market.usdPrice, - usdValue: (market.floatingDepositAssets * market.usdPrice) / BigInt(10 ** market.decimals), - rate, - }; - }) - .filter(({ amount, symbol }) => (symbol === "USDC.e" ? amount > 0n : true)) - .sort((a, b) => Number(b.usdValue) - Number(a.usdValue)) ?? []; - - const externalAssetItems = externalAssets.map(({ symbol, name, amount, decimals, usdValue, priceUSD }) => ({ - symbol, - name, - amount: amount ?? 0n, - decimals, - usdValue: parseUnits(usdValue.toFixed(18), 18), - usdPrice: parseUnits(priceUSD, 18), - })); - - return ( - - - - - ); -} diff --git a/src/components/home/CollateralAssetsSheet.tsx b/src/components/home/CollateralAssetsSheet.tsx new file mode 100644 index 0000000000..1f7d70070d --- /dev/null +++ b/src/components/home/CollateralAssetsSheet.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; + +import { ThumbsUp } from "@tamagui/lucide-icons"; +import { YStack } from "tamagui"; + +import Button from "../shared/Button"; +import ModalSheet from "../shared/ModalSheet"; +import Text from "../shared/Text"; + +export default function CollateralAssetsSheet({ onClose, open }: { onClose: () => void; open: boolean }) { + const { t } = useTranslation(); + return ( + + + + + {t("Collateral assets")} + + + {t( + "Assets you can use as backing to increase your credit limit and access features like Pay Later or funding.", + )} + + + + + + + + ); +} diff --git a/src/components/home/ExternalAssets.tsx b/src/components/home/ExternalAssets.tsx new file mode 100644 index 0000000000..5faacc0af8 --- /dev/null +++ b/src/components/home/ExternalAssets.tsx @@ -0,0 +1,261 @@ +import React, { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { selectionAsync } from "expo-haptics"; +import { useRouter } from "expo-router"; + +import { ChevronRight, Info } from "@tamagui/lucide-icons"; +import { Spinner, XStack, YStack } from "tamagui"; + +import { useQueries, useQuery } from "@tanstack/react-query"; +import { formatUnits } from "viem"; + +import chain from "@exactly/common/generated/chain"; + +import ExternalAssetsSheet from "./ExternalAssetsSheet"; +import UnsupportedNetworksSheet from "./UnsupportedNetworksSheet"; +import alchemyChainById from "../../utils/alchemyChains"; +import deployedOptions from "../../utils/deployedOptions"; +import { lifiChainsOptions } from "../../utils/lifi"; +import reportError from "../../utils/reportError"; +import useAccount from "../../utils/useAccount"; +import usePortfolio, { type ExternalAsset } from "../../utils/usePortfolio"; +import exaConfig from "../../utils/wagmi/exa"; +import AssetLogo from "../shared/AssetLogo"; +import ChainLogo from "../shared/ChainLogo"; +import Text from "../shared/Text"; +import View from "../shared/View"; + +export default function ExternalAssets() { + const { t } = useTranslation(); + const router = useRouter(); + const { address: exaAccount } = useAccount({ config: exaConfig }); + const { externalAssets, crossChainAssets } = usePortfolio(); + const { data: lifiChains } = useQuery(lifiChainsOptions); + const [infoSheetOpen, setInfoSheetOpen] = useState(false); + const [pendingUnsupported, setPendingUnsupported] = useState( + null, + ); + + const groups = useMemo(() => { + const byChain = new Map(); + for (const asset of [...externalAssets, ...crossChainAssets]) { + const list = byChain.get(asset.chainId) ?? []; + list.push(asset); + byChain.set(asset.chainId, list); + } + const chainName = (chainId: number) => { + if (chainId === chain.id) return chain.name; + return lifiChains?.find(({ id }) => id === chainId)?.name ?? `Chain ${chainId}`; + }; + const chainUsd = (assets: ExternalAsset[]) => assets.reduce((sum, asset) => sum + asset.usdValue, 0); + const next: NetworkGroup[] = [...byChain.entries()] + .map(([chainId, assets]) => ({ + chainId, + chainName: chainName(chainId), + assets: [...assets].sort((a, b) => b.usdValue - a.usdValue), + })) + .sort((a, b) => { + if (a.chainId === chain.id) return -1; + if (b.chainId === chain.id) return 1; + return chainUsd(b.assets) - chainUsd(a.assets); + }); + return next; + }, [externalAssets, crossChainAssets, lifiChains]); + + const crossChainIds = useMemo(() => { + const ids = new Set(); + for (const group of groups) { + if (group.chainId !== chain.id && alchemyChainById.has(group.chainId)) ids.add(group.chainId); + } + return [...ids]; + }, [groups]); + + const { deployedChains, pendingChains } = useQueries({ + queries: crossChainIds.map((chainId) => deployedOptions(exaAccount, chainId)), + combine: (results) => { + const pending = new Set(); + const deployed = new Map(); + for (const [index, chainId] of crossChainIds.entries()) { + const result = results[index]; + if (!result) continue; + if (typeof result.data === "boolean") deployed.set(chainId, result.data); + else if (result.isLoading || result.isFetching) pending.add(chainId); + } + return { deployedChains: deployed, pendingChains: pending }; + }, + }); + + function handleAssetPress(asset: ExternalAsset) { + if (!exaAccount) return; + if (isUnsupported(asset.chainId, deployedChains)) { + const group = groups.find(({ chainId }) => chainId === asset.chainId); + setPendingUnsupported({ asset, chainName: group?.chainName ?? `Chain ${asset.chainId}` }); + return; + } + selectionAsync().catch(reportError); + router.push({ + pathname: "/(main)/add-funds/bridge", + params: { sender: "exa", sourceChain: String(asset.chainId), sourceToken: asset.address }, + }); + } + + if (groups.length === 0) return null; + + return ( + <> + + setInfoSheetOpen(true)}> + + {t("Non-collateral assets")} + + + + {groups.map((group, index) => ( + + ))} + + setInfoSheetOpen(false)} /> + { + setPendingUnsupported(null); + }} + /> + + ); +} + +type NetworkGroup = { + assets: ExternalAsset[]; + chainId: number; + chainName: string; +}; + +function NetworkHeader({ name }: { name: string }) { + return ( + + + {name.toUpperCase()} + + + + ); +} + +function AssetRow({ + asset, + disabled, + onPress, + pending, +}: { + asset: ExternalAsset; + disabled: boolean; + onPress: () => void; + pending: boolean; +}) { + const { + i18n: { language }, + } = useTranslation(); + const balance = asset.amount ?? 0n; + const priceUSD = Number(asset.priceUSD); + const contentOpacity = disabled ? 0.5 : 1; + + return ( + + + + + + + + + + {asset.symbol} + + + {`$${priceUSD.toLocaleString(language, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} + + + + + {`$${asset.usdValue.toLocaleString(language, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} + + + {`${Number(formatUnits(balance, asset.decimals)).toLocaleString(language, { minimumFractionDigits: 2, maximumFractionDigits: Math.min(6, asset.decimals) })} ${asset.symbol}`} + + + {pending ? ( + + ) : disabled ? ( + + ) : ( + + )} + + ); +} + +function NetworkSection({ + group, + isFirst, + disabled, + onAssetPress, + pending, +}: { + disabled: boolean; + group: NetworkGroup; + isFirst: boolean; + onAssetPress: (asset: ExternalAsset) => void; + pending: boolean; +}) { + return ( + + + {group.assets.map((asset) => ( + { + onAssetPress(asset); + }} + /> + ))} + + ); +} + +function isUnsupported(chainId: number, deployedChains: Map) { + return chainId !== chain.id && (!alchemyChainById.has(chainId) || deployedChains.get(chainId) === false); +} + +const pressStyle = { opacity: 0.7 }; diff --git a/src/components/home/ExternalAssetsSheet.tsx b/src/components/home/ExternalAssetsSheet.tsx new file mode 100644 index 0000000000..1476b9a6c2 --- /dev/null +++ b/src/components/home/ExternalAssetsSheet.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; + +import { ThumbsUp } from "@tamagui/lucide-icons"; +import { YStack } from "tamagui"; + +import chain from "@exactly/common/generated/chain"; + +import Button from "../shared/Button"; +import ModalSheet from "../shared/ModalSheet"; +import Text from "../shared/Text"; + +export default function ExternalAssetsSheet({ onClose, open }: { onClose: () => void; open: boolean }) { + const { t } = useTranslation(); + return ( + + + + + {t("Non-collateral assets")} + + + {t( + "Assets you can hold, but they can't be used as backing. You can swap or bridge them to supported collateral assets on {{chain}} network to increase your credit limit.", + { chain: chain.name }, + )} + + + + + + + + ); +} diff --git a/src/components/home/Home.tsx b/src/components/home/Home.tsx index 875b93e7f5..341b0e367b 100644 --- a/src/components/home/Home.tsx +++ b/src/components/home/Home.tsx @@ -104,7 +104,7 @@ export default function Home() { const { portfolio: { balanceUSD }, averageRate, - assets, + allAssets, totalBalanceUSD, } = usePortfolio(); @@ -232,7 +232,7 @@ export default function Home() { diff --git a/src/components/home/Portfolio.tsx b/src/components/home/Portfolio.tsx index 27c7365518..248365ab06 100644 --- a/src/components/home/Portfolio.tsx +++ b/src/components/home/Portfolio.tsx @@ -5,10 +5,14 @@ import { RefreshControl } from "react-native"; import { useRouter } from "expo-router"; import { ArrowLeft, CircleHelp } from "@tamagui/lucide-icons"; -import { ScrollView, XStack } from "tamagui"; +import { ScrollView, XStack, YStack } from "tamagui"; + +import { useQuery } from "@tanstack/react-query"; import AssetList from "./AssetList"; +import ExternalAssets from "./ExternalAssets"; import { presentArticle } from "../../utils/intercom"; +import { balancesOptions } from "../../utils/lifi"; import openBrowser from "../../utils/openBrowser"; import reportError from "../../utils/reportError"; import useAccount from "../../utils/useAccount"; @@ -16,13 +20,14 @@ import useMarkets from "../../utils/useMarkets"; import usePortfolio from "../../utils/usePortfolio"; 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 WeightedRate from "../shared/WeightedRate"; export default function Portfolio() { const { address } = useAccount(); - const { averageRate, portfolio, totalBalanceUSD } = usePortfolio(); + const { averageRate, portfolio, totalBalanceUSD, isPending } = usePortfolio(); const router = useRouter(); const { t, @@ -31,6 +36,7 @@ export default function Portfolio() { const { balanceUSD } = portfolio; const { refetch: refetchMarkets, isFetching: isFetchingMarkets } = useMarkets(); + const { refetch: refetchBalances, isFetching: isFetchingBalances } = useQuery(balancesOptions(address)); return ( @@ -68,71 +74,139 @@ export default function Portfolio() { showsVerticalScrollIndicator={false} refreshControl={ { - if (address) refetchMarkets().catch(reportError); + if (!address) return; + refetchMarkets().catch(reportError); + refetchBalances().catch(reportError); }} /> } > - - - {t("Your Portfolio")} - - + + + + + + + + + {Array.from({ length: 3 }, (_, index) => ( + + + + + + + + + + + + + + + + ))} + + + + + + + + {Array.from({ length: 2 }, (_, index) => ( + + + + + + + + + + + + ))} + + + + ) : ( + - {`$${(Number(totalBalanceUSD) / 1e18).toLocaleString(language, { style: "decimal", minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} - - {balanceUSD > 0n ? ( - { - event.stopPropagation(); - presentArticle("12633694").catch(reportError); - }} - /> - ) : null} - - - - - - - { - openBrowser(`https://exact.ly/`).catch(reportError); - }} - /> - ), - }} - /> - - + + + {t("Your Portfolio")} + + + {`$${(Number(totalBalanceUSD) / 1e18).toLocaleString(language, { style: "decimal", minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} + + {balanceUSD > 0n ? ( + { + event.stopPropagation(); + presentArticle("12633694").catch(reportError); + }} + /> + ) : null} + + + + + + + + { + openBrowser(`https://exact.ly/`).catch(reportError); + }} + /> + ), + }} + /> + + + + )} ); diff --git a/src/components/home/PortfolioSummary.tsx b/src/components/home/PortfolioSummary.tsx index d5932e2fdd..2a8c5a27ba 100644 --- a/src/components/home/PortfolioSummary.tsx +++ b/src/components/home/PortfolioSummary.tsx @@ -1,6 +1,7 @@ import React from "react"; import { useTranslation } from "react-i18next"; +import { selectionAsync } from "expo-haptics"; import { useRouter } from "expo-router"; import { ChevronRight } from "@tamagui/lucide-icons"; @@ -9,6 +10,7 @@ import { View, XStack, YStack } from "tamagui"; import { useQuery } from "@tanstack/react-query"; import { selectBalance } from "../../utils/isProcessing"; +import reportError from "../../utils/reportError"; import Amount from "../shared/Amount"; import AssetLogo from "../shared/AssetLogo"; import Text from "../shared/Text"; @@ -40,16 +42,27 @@ export default function PortfolioSummary({ select: selectBalance, }); - const visible = assets.slice(0, 3); - const extra = assets.length - 3; + const uniqueAssets = assets.filter( + (asset, index, all) => all.findIndex(({ symbol }) => symbol === asset.symbol) === index, + ); + const visible = uniqueAssets.slice(0, 3); + const extra = uniqueAssets.length - 3; return ( - router.push("/portfolio")}> + { + selectionAsync().catch(reportError); + router.push("/portfolio"); + }} + > {t("Portfolio")} - + {t("Manage portfolio")} @@ -96,7 +109,7 @@ export default function PortfolioSummary({ {visible.map((asset, index) => ( 0 ? -12 : 0} zIndex={visible.length - index} > diff --git a/src/components/home/UnsupportedNetworksSheet.tsx b/src/components/home/UnsupportedNetworksSheet.tsx new file mode 100644 index 0000000000..54c61faebb --- /dev/null +++ b/src/components/home/UnsupportedNetworksSheet.tsx @@ -0,0 +1,107 @@ +import React, { useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; + +import { Headphones } from "@tamagui/lucide-icons"; +import { YStack } from "tamagui"; + +import { formatUnits } from "viem"; + +import { newMessage, present } from "../../utils/intercom"; +import reportError from "../../utils/reportError"; +import Button from "../shared/Button"; +import ModalSheet from "../shared/ModalSheet"; +import Text from "../shared/Text"; + +import type { ExternalAsset } from "../../utils/usePortfolio"; + +export default function UnsupportedNetworksSheet({ + asset, + chainName, + onClose, + open, +}: { + asset?: ExternalAsset; + chainName?: string; + onClose: () => void; + open: boolean; +}) { + const { + t, + i18n: { language }, + } = useTranslation(); + const pendingMessageRef = useRef(null); + return ( + + + + + {t("Non-supported network")} + + + + {t( + "This network isn't supported yet, so assets on it can't be recovered through the app for now. We're continuously working to add support for more networks, and this one may be supported in the future.", + )} + + + {t("You can contact us to share feedback and help us prioritize which networks to support next.")} + + + + + + + + + + ); +} + +function PresentSupportOnHide({ pendingMessage }: { pendingMessage: { current: null | string } }) { + useEffect(() => { + return () => { + const message = pendingMessage.current; + if (message === null) return; + pendingMessage.current = null; + (message.length > 0 ? newMessage(message) : present()).catch(reportError); + }; + }, [pendingMessage]); + return null; +} diff --git a/src/components/loans/Review.tsx b/src/components/loans/Review.tsx index 5bef62caaf..cea3b751c5 100644 --- a/src/components/loans/Review.tsx +++ b/src/components/loans/Review.tsx @@ -408,6 +408,7 @@ export default function Review() { { diff --git a/src/components/pay/Repay.tsx b/src/components/pay/Repay.tsx index ded9e46df6..d410edea95 100644 --- a/src/components/pay/Repay.tsx +++ b/src/components/pay/Repay.tsx @@ -501,7 +501,7 @@ export default function Repay() { }, onSuccess() { queryClient.invalidateQueries({ queryKey: assetQueryKey }).catch(reportError); - queryClient.invalidateQueries({ queryKey: ["lifi", "tokenBalances"] }).catch(reportError); + queryClient.invalidateQueries({ queryKey: ["lifi", "balances"] }).catch(reportError); }, onSettled() { setEnableSimulations(true); diff --git a/src/components/send-funds/Amount.tsx b/src/components/send-funds/Amount.tsx index f11c33e335..2b821ca808 100644 --- a/src/components/send-funds/Amount.tsx +++ b/src/components/send-funds/Amount.tsx @@ -390,6 +390,7 @@ export default function Amount() { { diff --git a/src/components/send-funds/Receiver.tsx b/src/components/send-funds/Receiver.tsx index 50b487c650..924fcf66f6 100644 --- a/src/components/send-funds/Receiver.tsx +++ b/src/components/send-funds/Receiver.tsx @@ -8,7 +8,6 @@ import { ScrollView, Separator, XStack, YStack } from "tamagui"; import { useForm } from "@tanstack/react-form"; import { useQuery } from "@tanstack/react-query"; -import { safeParse } from "valibot"; import chain from "@exactly/common/generated/chain"; import { Address } from "@exactly/common/validation"; @@ -39,19 +38,12 @@ export default function ReceiverSelection() { }); const form = useForm({ - defaultValues: { receiver: typeof receiver === "string" ? receiver : undefined }, + defaultValues: { receiver: typeof receiver === "string" ? receiver : "" }, onSubmit: ({ value }) => { - router.push({ pathname: "/send-funds/asset", params: { receiver: String(value.receiver) } }); + router.push({ pathname: "/send-funds/asset", params: { receiver: value.receiver } }); }, }); - const { success, output } = safeParse(Address, receiver); - - if (success) { - form.setFieldValue("receiver", output); - form.validateAllFields("change").catch(reportError); - } - return ( diff --git a/src/components/shared/AmountSelector.tsx b/src/components/shared/AmountSelector.tsx index 806493a795..304bcca72e 100644 --- a/src/components/shared/AmountSelector.tsx +++ b/src/components/shared/AmountSelector.tsx @@ -39,8 +39,7 @@ export default function AmountSelector({ onChange }: { onChange: (value: bigint) if (market) { setFieldValue("assetInput", text); const assets = parseUnits(text.replaceAll(/\D/g, ".").replaceAll(/\.(?=.*\.)/g, ""), market.decimals); - const assetsUSD = Number(formatUnits((assets * market.usdPrice) / WAD, market.decimals)); - setFieldValue("usdInput", assets > 0n ? String(assetsUSD) : ""); + setFieldValue("usdInput", assets > 0n ? formatUnits((assets * market.usdPrice) / WAD, market.decimals) : ""); onChange(assets); return; } @@ -48,8 +47,10 @@ export default function AmountSelector({ onChange }: { onChange: (value: bigint) setFieldValue("assetInput", text); const assets = parseUnits(text.replaceAll(/\D/g, ".").replaceAll(/\.(?=.*\.)/g, ""), externalAsset.decimals); const assetPriceUSD = parseUnits(externalAsset.priceUSD, 18); - const assetsUSD = Number(formatUnits((assets * assetPriceUSD) / WAD, externalAsset.decimals)); - setFieldValue("usdInput", assets > 0n ? String(assetsUSD) : ""); + setFieldValue( + "usdInput", + assets > 0n ? formatUnits((assets * assetPriceUSD) / WAD, externalAsset.decimals) : "", + ); onChange(assets); } }, @@ -85,18 +86,17 @@ export default function AmountSelector({ onChange }: { onChange: (value: bigint) if (market) { setOverlayShown(true); setFieldValue("assetInput", formatUnits(available, market.decimals)); - const assetsUSD = Number(formatUnits((available * market.usdPrice) / WAD, market.decimals)); - setFieldValue("usdInput", String(assetsUSD)); + setFieldValue("usdInput", formatUnits((available * market.usdPrice) / WAD, market.decimals)); onChange(available); return; } if (externalAsset) { setOverlayShown(true); setFieldValue("assetInput", formatUnits(available, externalAsset.decimals)); - const assetsUSD = Number( + setFieldValue( + "usdInput", formatUnits((available * parseUnits(externalAsset.priceUSD, 18)) / WAD, externalAsset.decimals), ); - setFieldValue("usdInput", String(assetsUSD)); onChange(available); } }, [available, market, externalAsset, onChange, setFieldValue]); diff --git a/src/components/shared/AssetSelector.tsx b/src/components/shared/AssetSelector.tsx index 88cb60f480..370ba2f481 100644 --- a/src/components/shared/AssetSelector.tsx +++ b/src/components/shared/AssetSelector.tsx @@ -27,10 +27,10 @@ export default function AssetSelector({ i18n: { language }, } = useTranslation(); const [selectedMarket, setSelectedMarket] = useState
(); - const { assets, externalAssets, markets, isPending } = usePortfolio({ sortBy }); + const { assets, externalAssets, markets, isPending, isBalancesPending } = usePortfolio({ sortBy }); if (assets.length === 0) { - if (isPending || !markets) { + if (isPending || isBalancesPending || !markets) { return ( @@ -125,7 +125,7 @@ export default function AssetSelector({ ); })} - {isPending ? : null} + {isBalancesPending ? : null} ); diff --git a/src/components/shared/Failure.tsx b/src/components/shared/Failure.tsx index f773eecefa..beedeb996d 100644 --- a/src/components/shared/Failure.tsx +++ b/src/components/shared/Failure.tsx @@ -41,7 +41,7 @@ export default function Failure({ - + diff --git a/src/components/shared/Pending.tsx b/src/components/shared/Pending.tsx index 670424b308..7a8b5759ba 100644 --- a/src/components/shared/Pending.tsx +++ b/src/components/shared/Pending.tsx @@ -40,7 +40,7 @@ export default function Pending({ - + diff --git a/src/components/shared/Success.tsx b/src/components/shared/Success.tsx index f455769f40..3910b0f4fe 100644 --- a/src/components/shared/Success.tsx +++ b/src/components/shared/Success.tsx @@ -58,7 +58,7 @@ export default function Success({ - + { diff --git a/src/components/swaps/Pending.tsx b/src/components/swaps/Pending.tsx index 6d048f1d01..5ed3793f89 100644 --- a/src/components/swaps/Pending.tsx +++ b/src/components/swaps/Pending.tsx @@ -54,7 +54,7 @@ export default function Pending({ - + diff --git a/src/components/swaps/Success.tsx b/src/components/swaps/Success.tsx index 4fb159c556..823567b53d 100644 --- a/src/components/swaps/Success.tsx +++ b/src/components/swaps/Success.tsx @@ -64,6 +64,7 @@ export default function Success({ { diff --git a/src/components/swaps/Swaps.tsx b/src/components/swaps/Swaps.tsx index df8e58d679..4236c56d01 100644 --- a/src/components/swaps/Swaps.tsx +++ b/src/components/swaps/Swaps.tsx @@ -228,9 +228,9 @@ export default function Swaps() { const tool = route?.tool ?? ""; const isInsufficientBalance = useMemo(() => { - if (!fromToken || !toToken) return false; + if (!fromToken) return false; return fromAmount > getBalance(fromToken.token); - }, [fromToken, toToken, fromAmount, getBalance]); + }, [fromToken, fromAmount, getBalance]); const { request: swapPropose, @@ -359,7 +359,7 @@ export default function Swaps() { updateSwap((old) => ({ ...old, enableSimulations: false })); }, onSuccess() { - queryClient.invalidateQueries({ queryKey: ["lifi", "tokenBalances"] }).catch(reportError); + queryClient.invalidateQueries({ queryKey: ["lifi", "balances"] }).catch(reportError); queryClient.invalidateQueries({ queryKey: marketsQueryKey }).catch(reportError); updateSwap((old) => ({ ...old, fromAmount: 0n, toAmount: 0n })); }, @@ -562,9 +562,7 @@ export default function Swaps() { ? "$interactiveOnDisabled" : caution && acknowledged ? "$interactiveOnBaseErrorSoft" - : caution && !acknowledged - ? "$interactiveOnBaseErrorSoft" - : "$interactiveOnBaseBrandDefault" + : "$interactiveOnBaseBrandDefault" } /> ) diff --git a/src/i18n/es-AR.json b/src/i18n/es-AR.json index 43132353cd..0cbc05eb6c 100644 --- a/src/i18n/es-AR.json +++ b/src/i18n/es-AR.json @@ -91,6 +91,7 @@ "Something went wrong. Please try again.": "Algo salió mal. Por favor, intentá de nuevo.", "Stay connected around the world.": "Mantenete conectado en todo el mundo.", "Stay connected everywhere": "Mantenete conectado en todas partes", + "Swap failed. Please try again.": "El intercambio falló. Intentalo de nuevo.", "Swapping this much of your collateral could instantly trigger liquidation. Try a smaller amount to stay protected.": "Intercambiar esta cantidad de tu garantía podría resultar en una liquidación inmediatamente. Intentá con una cantidad menor para mantenerte protegido.", "Tap here to change the number of installments": "Tocá aquí para cambiar la cantidad de cuotas", "The maximum amount you can spend using Pay Later.": "El monto máximo que podés gastar usando Pagar después.", diff --git a/src/i18n/es.json b/src/i18n/es.json index 85840c92bb..77c95cd614 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -69,6 +69,8 @@ "Arrival time ≈ {{minutes}} min.": "Tiempo de llegada ≈ {{minutes}} min.", "Asset": "Activo", "Assets are added to your balance as collateral to increase your credit limit.": "Los activos se agregan a tu balance como garantía para aumentar tu límite de crédito.", + "Assets you can hold, but they can't be used as backing. You can swap or bridge them to supported collateral assets on {{chain}} network to increase your credit limit.": "Activos que puedes mantener, pero que no pueden usarse como respaldo. Puedes intercambiarlos o transferirlos a activos de garantía compatibles en la red {{chain}} para aumentar tu límite de crédito.", + "Assets you can use as backing to increase your credit limit and access features like Pay Later or funding.": "Activos que puedes usar como respaldo para aumentar tu límite de crédito y acceder a funciones como Pagar Después o financiamiento.", "Authentication cancelled": "Autenticación cancelada", "Auto-pay": "Pago automático", "Available balance": "Saldo disponible", @@ -124,7 +126,7 @@ "CLABE": "CLABE", "Close": "Cerrar", "Collateral {{value}}": "Garantía {{value}}", - "Collateral Assets": "Activos de garantía", + "Collateral assets": "Activos de garantía", "Collateral": "Garantía", "COMING SOON": "PRÓXIMAMENTE", "Complete a quick identity check to access more networks.": "Completa una verificación de identidad rápida para acceder a más redes.", @@ -268,6 +270,7 @@ "Got it!": "¡Entendido!", "Help": "Ayuda", "Here you’ll find integrations with decentralized services powered by our partners. Exa App never controls your assets or how you use them when connected to the integrations provided by our partners.": "Aquí encontrarás integraciones con servicios descentralizados impulsados por nuestros socios. Exa App nunca controla tus activos ni cómo los usas cuando te conectas a las integraciones de nuestros socios.", + "Hi! I'd like help recovering an asset on a network that isn't supported yet.\n\nNetwork: {{chainName}} (chain ID {{chainId}})\nAsset: {{symbol}}\nToken address: {{address}}\nAmount: {{amount}} {{symbol}}\nCurrent value: ${{usdValue}}": "¡Hola! Quisiera ayuda para recuperar un activo en una red que aún no está soportada.\n\nRed: {{chainName}} (ID de red {{chainId}})\nActivo: {{symbol}}\nDirección del token: {{address}}\nMonto: {{amount}} {{symbol}}\nValor actual: ${{usdValue}}", "Hide PIN": "Ocultar PIN", "Hide sensitive": "Ocultar sensibles", "Home": "Inicio", @@ -370,6 +373,8 @@ "No saved contacts.": "No hay contactos guardados.", "No tokens available": "No hay tokens disponibles", "No tokens found": "No se encontraron tokens", + "Non-collateral assets": "Activos sin garantía", + "Non-supported network": "Red no compatible", "Nothing to see here for now. Once you add funds or make a payment, all your account activity will appear in this section.": "Nada que ver por ahora. Una vez que agregues fondos o realices un pago, toda la actividad de tu cuenta aparecerá en esta sección.", "Now": "Ahora", "On {{chain}}": "En {{chain}}", @@ -383,7 +388,6 @@ "Open your {{provider}} virtual account": "Abre tu cuenta virtual de {{provider}}", "Operation ID copied!": "¡ID de operación copiado!", "or": "o", - "Other Assets": "Otros activos", "Overdue payment {{date}}, {{amount}}": "Pago vencido {{date}}, {{amount}}", "Overdue payments": "Pagos vencidos", "Paid": "Pagado", @@ -429,6 +433,7 @@ "Processing balance → {{amount}}": "Saldo en procesamiento → {{amount}}", "Processing bridge": "Procesando bridge", "Processing rollover": "Procesando refinanciamiento", + "Processing swap": "Procesando intercambio", "Processing transfer": "Procesando transferencia", "Processing...": "Procesando...", "Processing": "Procesando", @@ -440,6 +445,7 @@ "Received": "Recibido", "Receiving address": "Dirección de recepción", "Recent": "Recientes", + "Recovery from this network is not yet available. Your funds are safe and will be recoverable once we add support.": "La recuperación desde esta red aún no está disponible. Tus fondos están seguros y podrán recuperarse cuando agreguemos soporte.", "Refinance at a better rate": "Refinancia a una mejor tasa", "Refinance your debt": "Refinancia tu deuda", "Refund details": "Detalles del reembolso", @@ -529,11 +535,15 @@ "Supported assets": "Activos soportados", "Supported Assets": "Activos Soportados", "Swap {{from}} for {{to}}": "Intercambiar {{from}} por {{to}}", + "Swap {{symbol}}": "Intercambiar {{symbol}}", + "Swap failed. Please try again.": "El intercambio falló. Inténtalo de nuevo.", + "Swap failed": "El intercambio falló", "Swap fee": "Comisión de intercambio", "Swap functionality is provided via LI.FI and executed on decentralized networks. Availability and pricing depend on network conditions and third-party protocols.": "La funcionalidad de intercambio es proporcionada por LI.FI y ejecutada en redes descentralizadas. La disponibilidad y los precios dependen de las condiciones de la red y de protocolos de terceros.", "Swap request sent": "Solicitud de intercambio enviada", "Swap service provided by LI.FI, executed on decentralized networks. Availability and pricing depend on network conditions and third-party protocols.": "Servicio de intercambio provisto por LI.FI, ejecutado en redes descentralizadas. La disponibilidad y los precios dependen de las condiciones de la red y de protocolos de terceros.", "Swap tokens": "Intercambiar tokens", + "Swap transaction submitted": "Transacción de intercambio enviada", "Swap via LI.FI": "Intercambiar vía LI.FI", "Swap via": "Intercambio a través de", "Swap": "Intercambiar", @@ -560,6 +570,7 @@ "This address has been removed from your contacts list.": "Esta dirección ha sido eliminada de tu lista de contactos.", "This may be due to missing or incorrect information. Please contact support to resolve it.": "Esto puede deberse a información faltante o incorrecta. Ponte en contacto con soporte para resolverlo.", "This may take a moment. Please wait.": "Esto puede tardar un momento. Por favor espera.", + "This network isn't supported yet, so assets on it can't be recovered through the app for now. We're continuously working to add support for more networks, and this one may be supported in the future.": "Esta red aún no está soportada, por lo que los activos en ella no pueden ser recuperados a través de la app por ahora. Trabajamos continuamente para agregar soporte a más redes, y esta podría ser soportada en el futuro.", "This payment is no longer available": "Este pago ya no está disponible", "This total includes all your purchases, loans, interest, and any applicable late fees.": "Este total incluye todas tus compras, préstamos, intereses y cualquier cargo por pago tardío.", "Time": "Hora", @@ -639,6 +650,7 @@ "Yield": "Rendimiento", "You are accessing a decentralized protocol using your crypto as collateral. The Exa App does not issue funding or provide credit. No credit checks or intermediaries are involved.": "Estás accediendo a un protocolo descentralizado usando tu cripto como garantía. La Exa App no emite financiamiento ni proporciona crédito. No se realizan verificaciones de crédito ni hay intermediarios involucrados.", "You are almost set to start using the Exa Card.": "Estás casi listo para empezar a usar la Exa Card.", + "You can contact us to share feedback and help us prioritize which networks to support next.": "Puedes contactarnos para compartir comentarios y ayudarnos a priorizar qué redes soportar a continuación.", "You can reconnect at any time.": "Puedes volver a conectarte en cualquier momento.", "You can repay early and save on interest. The final amount updates automatically before you confirm.": "Puedes pagar antes y ahorrar en intereses. El monto final se actualiza automáticamente antes de confirmar.", "You must repay each installment manually before its due date.": "Debes pagar cada cuota manualmente antes de su fecha de vencimiento.", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index c2de6749cb..c96cb143b2 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -69,6 +69,8 @@ "Arrival time ≈ {{minutes}} min.": "Tempo de chegada ≈ {{minutes}} min.", "Asset": "Ativo", "Assets are added to your balance as collateral to increase your credit limit.": "Os ativos são adicionados ao seu saldo como garantia para aumentar seu limite de crédito.", + "Assets you can hold, but they can't be used as backing. You can swap or bridge them to supported collateral assets on {{chain}} network to increase your credit limit.": "Ativos que você pode manter, mas que não podem ser usados como garantia. Você pode trocar ou transferi-los para ativos de garantia compatíveis na rede {{chain}} para aumentar seu limite de crédito.", + "Assets you can use as backing to increase your credit limit and access features like Pay Later or funding.": "Ativos que você pode usar como garantia para aumentar seu limite de crédito e acessar recursos como Pagar Depois ou financiamento.", "Authentication cancelled": "Autenticação cancelada", "Auto-pay": "Pagamento automático", "Available balance": "Saldo disponível", @@ -124,7 +126,7 @@ "CLABE": "CLABE", "Close": "Fechar", "Collateral {{value}}": "Garantia {{value}}", - "Collateral Assets": "Ativos de garantia", + "Collateral assets": "Ativos de garantia", "Collateral": "Garantia", "COMING SOON": "EM BREVE", "Complete a quick identity check to access more networks.": "Complete uma verificação de identidade rápida para acessar mais redes.", @@ -268,6 +270,7 @@ "Got it!": "Entendido!", "Help": "Ajuda", "Here you’ll find integrations with decentralized services powered by our partners. Exa App never controls your assets or how you use them when connected to the integrations provided by our partners.": "Aqui você encontrará integrações com serviços descentralizados impulsionados pelos nossos parceiros. O Exa App nunca controla seus ativos nem como você os usa quando conectado às integrações dos nossos parceiros.", + "Hi! I'd like help recovering an asset on a network that isn't supported yet.\n\nNetwork: {{chainName}} (chain ID {{chainId}})\nAsset: {{symbol}}\nToken address: {{address}}\nAmount: {{amount}} {{symbol}}\nCurrent value: ${{usdValue}}": "Olá! Gostaria de ajuda para recuperar um ativo em uma rede que ainda não é suportada.\n\nRede: {{chainName}} (ID da rede {{chainId}})\nAtivo: {{symbol}}\nEndereço do token: {{address}}\nValor: {{amount}} {{symbol}}\nValor atual: ${{usdValue}}", "Hide PIN": "Ocultar PIN", "Hide sensitive": "Ocultar sensíveis", "Home": "Início", @@ -370,6 +373,8 @@ "No saved contacts.": "Nenhum contato salvo.", "No tokens available": "Nenhum token disponível", "No tokens found": "Nenhum token encontrado", + "Non-collateral assets": "Ativos sem garantia", + "Non-supported network": "Rede não compatível", "Nothing to see here for now. Once you add funds or make a payment, all your account activity will appear in this section.": "Nada para ver por enquanto. Assim que você adicionar fundos ou fizer um pagamento, toda a atividade da sua conta aparecerá nesta seção.", "Now": "Agora", "On {{chain}}": "Na {{chain}}", @@ -383,7 +388,6 @@ "Open your {{provider}} virtual account": "Abra sua conta virtual da {{provider}}", "Operation ID copied!": "ID da operação copiado!", "or": "ou", - "Other Assets": "Outros ativos", "Overdue payment {{date}}, {{amount}}": "Pagamento atrasado {{date}}, {{amount}}", "Overdue payments": "Pagamentos atrasados", "Paid": "Pago", @@ -429,6 +433,7 @@ "Processing balance → {{amount}}": "Saldo em processamento → {{amount}}", "Processing bridge": "Processando bridge", "Processing rollover": "Processando refinanciamento", + "Processing swap": "Processando troca", "Processing transfer": "Processando transferência", "Processing...": "Processando...", "Processing": "Processando", @@ -440,6 +445,7 @@ "Received": "Recebido", "Receiving address": "Endereço de recebimento", "Recent": "Recentes", + "Recovery from this network is not yet available. Your funds are safe and will be recoverable once we add support.": "A recuperação desta rede ainda não está disponível. Seus fundos estão seguros e poderão ser recuperados assim que adicionarmos suporte.", "Refinance at a better rate": "Refinancie a uma taxa melhor", "Refinance your debt": "Refinancie sua dívida", "Refund details": "Detalhes do reembolso", @@ -529,11 +535,15 @@ "Supported assets": "Ativos suportados", "Supported Assets": "Ativos Suportados", "Swap {{from}} for {{to}}": "Trocar {{from}} por {{to}}", + "Swap {{symbol}}": "Trocar {{symbol}}", + "Swap failed. Please try again.": "A troca falhou. Tente novamente.", + "Swap failed": "A troca falhou", "Swap fee": "Taxa de troca", "Swap functionality is provided via LI.FI and executed on decentralized networks. Availability and pricing depend on network conditions and third-party protocols.": "A funcionalidade de troca é fornecida por LI.FI e executada em redes descentralizadas. A disponibilidade e os preços dependem das condições da rede e de protocolos de terceiros.", "Swap request sent": "Solicitação de troca enviada", "Swap service provided by LI.FI, executed on decentralized networks. Availability and pricing depend on network conditions and third-party protocols.": "Serviço de troca fornecido por LI.FI, executado em redes descentralizadas. A disponibilidade e os preços dependem das condições da rede e de protocolos de terceiros.", "Swap tokens": "Trocar tokens", + "Swap transaction submitted": "Transação de troca enviada", "Swap via LI.FI": "Trocar via LI.FI", "Swap via": "Troca via", "Swap": "Trocar", @@ -560,6 +570,7 @@ "This address has been removed from your contacts list.": "Este endereço foi removido da sua lista de contatos.", "This may be due to missing or incorrect information. Please contact support to resolve it.": "Isso pode ser devido a informações faltantes ou incorretas. Entre em contato com o suporte para resolver.", "This may take a moment. Please wait.": "Isso pode levar um momento. Por favor, aguarde.", + "This network isn't supported yet, so assets on it can't be recovered through the app for now. We're continuously working to add support for more networks, and this one may be supported in the future.": "Esta rede ainda não é suportada, então os ativos nela não podem ser recuperados pelo app por enquanto. Estamos continuamente trabalhando para adicionar suporte a mais redes, e esta pode ser suportada no futuro.", "This payment is no longer available": "Este pagamento não está mais disponível", "This total includes all your purchases, loans, interest, and any applicable late fees.": "Este total inclui todas as suas compras, empréstimos, juros e quaisquer taxas por atraso aplicáveis.", "Time": "Hora", @@ -639,6 +650,7 @@ "Yield": "Rendimento", "You are accessing a decentralized protocol using your crypto as collateral. The Exa App does not issue funding or provide credit. No credit checks or intermediaries are involved.": "Você está acessando um protocolo descentralizado usando sua cripto como garantia. O Exa App não emite financiamento nem fornece crédito. Não há verificações de crédito nem intermediários envolvidos.", "You are almost set to start using the Exa Card.": "Você está quase pronto para começar a usar o Exa Card.", + "You can contact us to share feedback and help us prioritize which networks to support next.": "Você pode entrar em contato conosco para compartilhar feedback e nos ajudar a priorizar quais redes suportar em seguida.", "You can reconnect at any time.": "Você pode se reconectar a qualquer momento.", "You can repay early and save on interest. The final amount updates automatically before you confirm.": "Você pode pagar antecipado e economizar em juros. O valor final é atualizado automaticamente antes de você confirmar.", "You must repay each installment manually before its due date.": "Você deve pagar cada parcela manualmente antes da data de vencimento.", diff --git a/src/utils/accountClient.ts b/src/utils/accountClient.ts index af30d7a705..09290e8c66 100644 --- a/src/utils/accountClient.ts +++ b/src/utils/accountClient.ts @@ -11,10 +11,17 @@ import { resolveProperties, smartAccountClientActions, toSmartContractAccount, + type ClientMiddlewareFn, + type SmartAccountClient, type SmartContractAccount, type UserOperationStruct_v6, } from "@aa-sdk/core"; -import { alchemyGasManagerMiddleware } from "@account-kit/infra"; +import { + alchemy, + alchemyGasAndPaymasterAndDataMiddleware, + alchemyGasManagerMiddleware, + createAlchemyPublicRpcClient, +} from "@account-kit/infra"; // @ts-expect-error deep import to avoid broken dependency import { standardExecutor } from "@account-kit/smart-contracts/dist/esm/src/msca/account/standardExecutor"; // cspell:ignore msca import { ECDSASigValue } from "@peculiar/asn1-ecc"; @@ -53,10 +60,12 @@ import { trim, type Address, type Call, + type Chain, type Hex, type TransactionRequest, + type Transport, } from "viem"; -import { anvil } from "viem/chains"; +import { anvil, base } from "viem/chains"; import accountInit from "@exactly/common/accountInit"; import alchemyAPIKey from "@exactly/common/alchemyAPIKey"; @@ -65,6 +74,7 @@ import deriveAddress from "@exactly/common/deriveAddress"; import domain from "@exactly/common/domain"; import chain, { upgradeableModularAccountAbi } from "@exactly/common/generated/chain"; +import alchemyChainById from "./alchemyChains"; import e2e from "./e2e"; import { login } from "./onesignal"; import publicClient from "./publicClient"; @@ -81,43 +91,40 @@ export default async function createAccountClient({ credentialId, factory, x, y setUser({ id: accountAddress }); login(accountAddress); const transport = custom(publicClient); - const entryPoint = getEntryPoint(chain); - const account = await toSmartContractAccount({ - chain, - transport, - entryPoint, + const signUserOperationHash = async (uoHash: Hex): Promise => { + if (queryClient.getQueryData(["method"]) === "siwe" && getConnection(ownerConfig).address) { + return wrapSignature(0, await signMessage(ownerConfig, { message: { raw: uoHash } })); + } + const credential = await get({ + rpId: domain, + challenge: bufferToBase64URLString(hexToBytes(hashMessage({ raw: uoHash }), { size: 32 }).buffer as ArrayBuffer), + allowCredentials: Platform.OS === "android" ? [] : [{ id: credentialId, type: "public-key" }], // HACK fix android credential filtering + userVerification: "preferred", + }); + if (!credential) throw new Error("no credential"); + const response: AuthenticatorAssertionResponseJSON = credential.response; + const clientDataJSON = new TextDecoder().decode(base64URLStringToBuffer(response.clientDataJSON)); + const typeIndex = BigInt(clientDataJSON.indexOf('"type":"')); + const challengeIndex = BigInt(clientDataJSON.indexOf('"challenge":"')); + const authenticatorData = bytesToHex(new Uint8Array(base64URLStringToBuffer(response.authenticatorData))); + const signature = AsnParser.parse(base64URLStringToBuffer(response.signature), ECDSASigValue); + const r = bytesToBigInt(new Uint8Array(signature.r)); + let s = bytesToBigInt(new Uint8Array(signature.s)); + if (s > P256_N / 2n) s = P256_N - s; // pass malleability guard + return webauthn({ authenticatorData, clientDataJSON, challengeIndex, typeIndex, r, s }); + }; + const accountOptions = { accountAddress, source: "WebauthnAccount" as const, getAccountInitCode: () => Promise.resolve(concatHex([factory, accountInit({ x, y })])), getDummySignature: () => DUMMY_SIGNATURE, - signUserOperationHash: async (uoHash) => { - if (queryClient.getQueryData(["method"]) === "siwe" && getConnection(ownerConfig).address) { - return wrapSignature(0, await signMessage(ownerConfig, { message: { raw: uoHash } })); - } - const credential = await get({ - rpId: domain, - challenge: bufferToBase64URLString( - hexToBytes(hashMessage({ raw: uoHash }), { size: 32 }).buffer as ArrayBuffer, - ), - allowCredentials: Platform.OS === "android" ? [] : [{ id: credentialId, type: "public-key" }], // HACK fix android credential filtering - userVerification: "preferred", - }); - if (!credential) throw new Error("no credential"); - const response: AuthenticatorAssertionResponseJSON = credential.response; - const clientDataJSON = new TextDecoder().decode(base64URLStringToBuffer(response.clientDataJSON)); - const typeIndex = BigInt(clientDataJSON.indexOf('"type":"')); - const challengeIndex = BigInt(clientDataJSON.indexOf('"challenge":"')); - const authenticatorData = bytesToHex(new Uint8Array(base64URLStringToBuffer(response.authenticatorData))); - const signature = AsnParser.parse(base64URLStringToBuffer(response.signature), ECDSASigValue); - const r = bytesToBigInt(new Uint8Array(signature.r)); - let s = bytesToBigInt(new Uint8Array(signature.s)); - if (s > P256_N / 2n) s = P256_N - s; // pass malleability guard - return webauthn({ authenticatorData, clientDataJSON, challengeIndex, typeIndex, r, s }); - }, + signUserOperationHash, signMessage: () => Promise.reject(new Error("not implemented")), signTypedData: () => Promise.reject(new Error("not implemented")), ...(standardExecutor as Pick), - }); + }; + const entryPoint = getEntryPoint(chain); + const account = await toSmartContractAccount({ chain, transport, entryPoint, ...accountOptions }); const client = createSmartAccountClient({ chain, transport, @@ -134,22 +141,50 @@ export default async function createAccountClient({ credentialId, factory, x, y dummyPaymasterAndData: (struct) => Promise.resolve({ ...struct, paymasterAndData: ethAddress }), paymasterAndData: (struct) => Promise.resolve({ ...struct, paymasterAndData: ethAddress }), }), - async customMiddleware(userOp) { - if ((await userOp.signature) === DUMMY_SIGNATURE) { - // dynamic dummy signature - userOp.signature = dummySignature( - bufferToBase64URLString( - hexToBytes(hashMessage({ raw: deepHexlify(await resolveProperties(userOp)) as Hex }), { size: 32 }) - .buffer as ArrayBuffer, - ), - ); - } - return userOp; - }, + customMiddleware: dummySignatureMiddleware, }); + const crossChainClients = new Map>>(); + function getCrossChainClient(targetChainId: number) { + const cached = crossChainClients.get(targetChainId); + if (cached) return cached; + const targetChain = alchemyChainById.get(targetChainId); + if (!targetChain) throw new Error(`unsupported chain ${targetChainId}`); + const config = + targetChain.id === base.id + ? { apiKey: "d_pBtamzsxX6zNEa5eQzT", gasPolicyId: "ac4d73b4-5e7d-404d-b972-55c99f14f134" } // cspell:ignore d_pBtamzsxX6zNEa5eQzT + : { + apiKey: alchemyAPIKey, + gasPolicyId: targetChain.testnet + ? "dc767b7d-9ce8-4512-ba67-ebe2cf7a1577" + : "cb9db554-658f-46eb-ae73-8bff8ed2556b", + }; + const alchemyTransport = alchemy({ apiKey: config.apiKey }); + const targetTransport = custom(createAlchemyPublicRpcClient({ chain: targetChain, transport: alchemyTransport })); + const promise = toSmartContractAccount({ + chain: targetChain, + transport: targetTransport, + entryPoint: getEntryPoint(targetChain), + ...accountOptions, + }) + .then((targetAccount) => + createSmartAccountClient({ + chain: targetChain, + transport: targetTransport, + account: targetAccount, + ...alchemyGasAndPaymasterAndDataMiddleware({ policyId: config.gasPolicyId, transport: alchemyTransport }), + customMiddleware: dummySignatureMiddleware, + }), + ) + .catch((error: unknown) => { + crossChainClients.delete(targetChainId); + throw error; + }); + crossChainClients.set(targetChainId, promise); + return promise; + } return createBundlerClient({ chain, - // @ts-expect-error -- bad alchemy types + // @ts-expect-error bad alchemy types account, type: "SmartAccountClient", transport: noRetry({ @@ -164,9 +199,17 @@ export default async function createAccountClient({ credentialId, factory, x, y id?: string; }; if (from && from !== accountAddress) throw new Error("bad account"); - const requestedChainId = chainId ? hexToNumber(chainId) : chain.id; + const targetChainId = chainId ? hexToNumber(chainId) : chain.id; + if (targetChainId !== chain.id) { + const crossClient = await getCrossChainClient(targetChainId); + const { hash } = await crossClient.sendUserOperation({ + uo: calls.map(({ to, data = "0x", value }) => ({ from: accountAddress, target: to, data, value })), + overrides: { verificationGasLimit: { multiplier: 2 } }, + }); + return { id: concat([hash, numberToHex(targetChainId, { size: 32 }), UO_MAGIC_ID]) }; + } if (queryClient.getQueryData(["method"]) === "webauthn") { - if (requestedChainId !== chain.id) throw new Error("unsupported chain"); + if (targetChainId !== chain.id) throw new Error("unsupported chain"); const { hash } = await client.sendUserOperation({ uo: calls.map(({ to, data = "0x", value }) => ({ from: accountAddress, target: to, data, value })), }); @@ -181,7 +224,7 @@ export default async function createAccountClient({ credentialId, factory, x, y try { return await sendCalls(ownerConfig, { id, - chainId: requestedChainId, + chainId: chain.id, calls: [execute], capabilities: { paymasterService: { @@ -198,30 +241,28 @@ export default async function createAccountClient({ credentialId, factory, x, y extra: error instanceof Error ? { cause: error.cause } : undefined, }); // TODO filter errors - await switchChain(ownerConfig, { chainId: requestedChainId }); - try { - const hash = await sendTransaction(ownerConfig, { - to: accountAddress, - data: encodeFunctionData(execute), - chainId: requestedChainId, - }); - return { id: concat([hash, numberToHex(requestedChainId, { size: 32 }), TX_MAGIC_ID]) }; - } finally { - await switchChain(ownerConfig, { chainId: chain.id }).catch(reportError); - } + await switchChain(ownerConfig, { chainId: chain.id }); + const hash = await sendTransaction(ownerConfig, { + to: accountAddress, + data: encodeFunctionData(execute), + chainId: chain.id, + }); + return { id: concat([hash, numberToHex(chain.id, { size: 32 }), TX_MAGIC_ID]) }; } } case "wallet_getCallsStatus": { if (!Array.isArray(params) || params.length !== 1 || typeof params[0] !== "string") throw new Error("bad"); if (params[0].endsWith(UO_MAGIC_ID.slice(2)) && isHex(params[0]) && params[0].length === 194) { - const receipt = await client.getUserOperationReceipt(sliceHex(params[0], 0, 32)); + const uoChainId = hexToNumber(trim(sliceHex(params[0], -64, -32))); + const uoClient = uoChainId === chain.id ? client : await getCrossChainClient(uoChainId); + const receipt = await uoClient.getUserOperationReceipt(sliceHex(params[0], 0, 32)); return { version: "2.0.0", id: params[0], atomic: true, receipts: receipt ? [receipt.receipt] : [], status: receipt ? (receipt.success ? 200 : 500) : 100, - chainId: hexToNumber(trim(sliceHex(params[0], -64, -32))), + chainId: uoChainId, }; } const result = await getCallsStatus(ownerConfig, { id: params[0] }); @@ -316,6 +357,18 @@ function dummySignature(challenge: string) { }); } +async function dummySignatureMiddleware[0]>(userOp: T) { + if ((await userOp.signature) === DUMMY_SIGNATURE) { + userOp.signature = dummySignature( + bufferToBase64URLString( + hexToBytes(hashMessage({ raw: deepHexlify(await resolveProperties(userOp)) as Hex }), { size: 32 }) + .buffer as ArrayBuffer, + ), + ); + } + return userOp; +} + const UO_MAGIC_ID = "0x4337433743374337433743374337433743374337433743374337433743374337"; const TX_MAGIC_ID = "0x5792579257925792579257925792579257925792579257925792579257925792"; const P256_N = 0xff_ff_ff_ff_00_00_00_00_ff_ff_ff_ff_ff_ff_ff_ff_bc_e6_fa_ad_a7_17_9e_84_f3_b9_ca_c2_fc_63_25_51n; diff --git a/src/utils/alchemyChains.ts b/src/utils/alchemyChains.ts new file mode 100644 index 0000000000..ff65072674 --- /dev/null +++ b/src/utils/alchemyChains.ts @@ -0,0 +1,9 @@ +import * as accountKitInfra from "@account-kit/infra"; + +import type { Chain } from "viem"; + +export default new Map( + Object.values(accountKitInfra) + .filter((c): c is Chain => typeof c === "object" && "id" in c && typeof c.id === "number") + .map((c) => [c.id, c]), +); diff --git a/src/utils/alchemyConnector.ts b/src/utils/alchemyConnector.ts index 5b32c6c4c8..3034f4cf5d 100644 --- a/src/utils/alchemyConnector.ts +++ b/src/utils/alchemyConnector.ts @@ -68,8 +68,7 @@ export default createConnector(({ accountClient = undefined; if (error) reportError(error); }, - getProvider({ chainId } = {}) { - if (chainId && chainId !== chain.id) throw new SwitchChainError(new ChainNotConfiguredError()); + getProvider() { return Promise.resolve(accountClient ?? publicClient); }, getChainId: () => Promise.resolve(chain.id), diff --git a/src/utils/deployedOptions.ts b/src/utils/deployedOptions.ts new file mode 100644 index 0000000000..38db6dd03d --- /dev/null +++ b/src/utils/deployedOptions.ts @@ -0,0 +1,18 @@ +import { queryOptions, skipToken } from "@tanstack/react-query"; +import { getBytecode } from "@wagmi/core/actions"; + +import exaConfig from "./wagmi/exa"; + +import type { Address } from "viem"; + +export default function deployedOptions(address: Address | undefined, chainId: number | undefined) { + return queryOptions({ + queryKey: ["deployed", address, chainId], + queryFn: + address !== undefined && chainId !== undefined + ? async () => !!(await getBytecode(exaConfig, { address, chainId })) + : skipToken, + staleTime: (query) => (query.state.data ? Infinity : 0), + gcTime: Infinity, + }); +} diff --git a/src/utils/lifi.ts b/src/utils/lifi.ts index 9050579fe8..176658ff00 100644 --- a/src/utils/lifi.ts +++ b/src/utils/lifi.ts @@ -15,7 +15,7 @@ import { type TokenAmount, } from "@lifi/sdk"; import { queryOptions } from "@tanstack/react-query"; -import { parse } from "valibot"; +import { array, looseObject, number, object, optional, parse, pipe, record, regex, string } from "valibot"; import { encodeFunctionData, formatUnits, getAddress, type Address } from "viem"; import { anvil } from "viem/chains"; @@ -33,7 +33,9 @@ export const lifiChainsOptions = queryOptions({ gcTime: Infinity, enabled: !chain.testnet && chain.id !== anvil.id, queryFn: async () => { + if (chain.testnet || chain.id === anvil.id) return []; try { + ensureConfig(); return await getChains({ chainTypes: [ChainType.EVM] }); } catch (error) { reportError(error); @@ -48,9 +50,11 @@ export const lifiTokensOptions = queryOptions({ gcTime: Infinity, enabled: !chain.testnet && chain.id !== anvil.id, queryFn: async () => { + if (chain.testnet || chain.id === anvil.id) return []; try { - const { tokens } = await getTokens({ chainTypes: [ChainType.EVM] }); - const allTokens = Object.values(tokens).flat(); + ensureConfig(); + const { tokens } = await getTokens({ chains: [chain.id] }); + const allTokens = tokens[chain.id] ?? []; if (chain.id !== optimism.id) return allTokens; const exa = await getToken(chain.id, "0x1e925De1c68ef83bD98eE3E130eF14a50309C01B").catch((error: unknown) => { reportError(error); @@ -63,31 +67,44 @@ export const lifiTokensOptions = queryOptions({ }, }); -export function tokenBalancesOptions(account: Address | undefined) { +export function balancesOptions(account: Address | undefined) { return queryOptions({ - queryKey: ["lifi", "tokenBalances", account], + queryKey: ["lifi", "balances", account], staleTime: 30_000, gcTime: 60_000, enabled: !!account && !chain.testnet && chain.id !== anvil.id, queryFn: async () => { - if (!account) return []; - try { - const allTokens = - queryClient.getQueryData(lifiTokensOptions.queryKey) ?? - (await queryClient.fetchQuery(lifiTokensOptions)); - const tokens = allTokens.filter((token) => (token.chainId as number) === chain.id); - if (tokens.length === 0) return []; - ensureConfig(); - const balances = await getTokenBalancesByChain(account, { [chain.id]: tokens }); - return balances[chain.id]?.filter((balance) => balance.amount && balance.amount > 0n) ?? []; - } catch (error) { - reportError(error); - return []; - } + if (!account) return {} as Record; + ensureConfig(); + const [balances, exa] = await Promise.all([ + getWalletBalances(account).catch((error: unknown) => { + reportError(error); + return {} as Record; + }), + chain.id === optimism.id + ? getToken(chain.id, "0x1e925De1c68ef83bD98eE3E130eF14a50309C01B") + .then((token) => getTokenBalancesByChain(account, { [chain.id]: [token] })) + .then((result) => result[chain.id]?.[0]) + .catch((error: unknown) => { + reportError(error); + }) + : undefined, + ]); + if (exa) balances[chain.id] = [exa, ...(balances[chain.id] ?? [])]; + return balances; }, }); } +export function bridgeSourcesOptions(account: Address | undefined, protocolSymbols: string[] = []) { + return queryOptions({ + queryKey: ["bridge", "sources", account], + queryFn: () => getBridgeSources(account), + staleTime: 60_000, + enabled: !!account && protocolSymbols.length > 0 && !chain.testnet && chain.id !== anvil.id, + }); +} + let configured = false; function ensureConfig() { if (configured || chain.testnet || chain.id === anvil.id) return; @@ -101,8 +118,6 @@ function ensureConfig() { }, }); configured = true; - queryClient.prefetchQuery(lifiTokensOptions).catch(reportError); - queryClient.prefetchQuery(lifiChainsOptions).catch(reportError); } export async function getRoute( @@ -174,16 +189,8 @@ export async function getRoute( } async function getAllTokens(): Promise { - ensureConfig(); if (chain.testnet || chain.id === anvil.id) return []; - const response = await getTokens({ chains: [chain.id] }); - const tokens = response.tokens[chain.id] ?? []; - try { - const exa = await getToken(chain.id, "0x1e925De1c68ef83bD98eE3E130eF14a50309C01B"); - return [exa, ...tokens]; - } catch { - return tokens; - } + return queryClient.getQueryData(lifiTokensOptions.queryKey) ?? queryClient.fetchQuery(lifiTokensOptions); } export async function getAsset(account: Address) { @@ -194,6 +201,7 @@ export async function getAsset(account: Address) { export async function getTokenBalances(account: Address) { if (chain.testnet || chain.id === anvil.id) return []; + ensureConfig(); const tokens = await getAllTokens(); const balances = await getTokenBalancesByChain(account, { [chain.id]: tokens }); return balances[chain.id]?.filter((balance) => balance.amount && balance.amount > 0n) ?? []; @@ -356,69 +364,68 @@ export async function getRouteFrom({ }; } +export type TokenBalance = { balance: bigint; token: Token; usdValue: number }; + +export function tokenAmountsToBalances(tokenAmounts: TokenAmount[]): TokenBalance[] { + return tokenAmounts + .filter((token): token is TokenAmount & { amount: bigint } => !!token.amount && token.amount > 0n) + .map((token) => { + const balance = token.amount; + const rawUsd = Number(formatUnits(balance, token.decimals)) * Number(token.priceUSD); + const usdValue = Number.isFinite(rawUsd) && rawUsd > 0 ? rawUsd : 0; + return { token, balance, usdValue }; + }) + .sort((a, b) => { + if (b.usdValue !== a.usdValue) return b.usdValue - a.usdValue; + return a.token.symbol.localeCompare(b.token.symbol); + }); +} + export type BridgeSources = { - balancesByChain: Record; + balancesByChain: Record; chains: ExtendedChain[]; defaultChainId?: number; defaultTokenAddress?: string; - ownerAssetsByChain: Record; tokensByChain: Record; usdByChain: Record; usdByToken: Record; }; -export async function getBridgeSources(account?: string, protocolSymbols: string[] = []): Promise { +export async function getBridgeSources(account?: Address): Promise { ensureConfig(); if (!account) throw new Error("account is required"); - const bridgeTokenSymbols = new Set(protocolSymbols); - if (bridgeTokenSymbols.size === 0) throw new Error("protocol symbols is required"); - const supportedChains = await getChains({ chainTypes: [ChainType.EVM] }); - const chainIds = supportedChains.map((item) => item.id); - const { tokens: supportedTokens } = await getTokens({ chainTypes: [ChainType.EVM] }); + const [supportedChains, destinationTokens, allBalances] = await Promise.all([ + queryClient.getQueryData(lifiChainsOptions.queryKey) ?? queryClient.fetchQuery(lifiChainsOptions), + queryClient.getQueryData(lifiTokensOptions.queryKey) ?? queryClient.fetchQuery(lifiTokensOptions), + queryClient.fetchQuery(balancesOptions(account)), + ]); const usdByChain: Record = {}; const usdByToken: Record = {}; - const tokensByChain: Record = {}; - const ownerAssetsByChain: Record = {}; - - for (const id of chainIds) { - const chainTokens = supportedTokens[id] ?? []; - if (chainTokens.length > 0) tokensByChain[id] = chainTokens; - } + const tokensByChain: Record = { [chain.id]: destinationTokens }; + const balancesByChain: Record = {}; - const balancesByChain = await getTokenBalancesByChain( - account, - Object.fromEntries(Object.entries(tokensByChain).map(([id, chainTokens]) => [Number(id), chainTokens])), - ); - - for (const [chainId, chainTokens] of Object.entries(tokensByChain)) { + for (const [chainId, tokenAmounts] of Object.entries(allBalances)) { const id = Number(chainId); - const tokenAmounts = balancesByChain[id] ?? []; - const assets = chainTokens.map((token) => { - const balance = tokenAmounts.find((t) => t.address === token.address)?.amount ?? 0n; - const key = `${id}:${token.address}`; - const usdValue = Number(formatUnits(balance, token.decimals)) * Number(token.priceUSD); - usdByToken[key] = usdValue; - return { token, balance, usdValue }; - }); + const balances = tokenAmountsToBalances(tokenAmounts); - const relevantAssets = assets - .filter(({ usdValue }) => usdValue > 0) - .sort((a, b) => { - if (b.usdValue !== a.usdValue) return b.usdValue - a.usdValue; - return a.token.symbol.localeCompare(b.token.symbol); - }); + if (id === chain.id) { + for (const { token, usdValue } of balances) { + const key = `${id}:${token.address}`; + usdByToken[key] = usdValue; + } + } - if (relevantAssets.length > 0) { - ownerAssetsByChain[id] = relevantAssets; + if (balances.length > 0) { + balancesByChain[id] = balances; } - const total = relevantAssets.reduce((sum, { usdValue }) => sum + usdValue, 0); + const total = balances.reduce((sum, { usdValue }) => sum + usdValue, 0); if (total > 0) usdByChain[id] = total; } const chains = [...supportedChains] - .filter((c) => (usdByChain[c.id] ?? 0) > 0) + .filter((c) => (balancesByChain[c.id]?.length ?? 0) > 0) .sort((a, b) => { const bValue = usdByChain[b.id] ?? 0; const aValue = usdByChain[a.id] ?? 0; @@ -430,22 +437,72 @@ export async function getBridgeSources(account?: string, protocolSymbols: string let defaultTokenAddress: string | undefined; if (defaultChainId !== undefined) { - const assetsForChain = ownerAssetsByChain[defaultChainId] ?? []; - defaultTokenAddress = assetsForChain[0]?.token.address; + defaultTokenAddress = balancesByChain[defaultChainId]?.[0]?.token.address; } return { chains, tokensByChain, - balancesByChain, usdByChain, usdByToken, - ownerAssetsByChain, + balancesByChain, defaultChainId, defaultTokenAddress, }; } +async function getWalletBalances(account: Address) { + const balances: Record = {}; + const lifiConfig = config.get(); + let offset: string | undefined; + do { + const url = new URL(`${lifiConfig.apiUrl}/wallets/${account}/balances`); + url.searchParams.set("extended", "true"); + url.searchParams.set("limit", "1000"); + if (offset) url.searchParams.set("offset", offset); + const response = await fetch(url, { + headers: { + ...(lifiConfig.apiKey && { "x-lifi-api-key": lifiConfig.apiKey }), + ...(lifiConfig.integrator && { "x-lifi-integrator": lifiConfig.integrator }), + }, + }); + if (!response.ok) throw new Error("wallet balances request failed"); + const json = parse( + object({ + balances: optional( + record( + string(), + array( + looseObject({ + chainId: number(), + address: string(), + symbol: string(), + decimals: number(), + name: string(), + priceUSD: optional(string(), "0"), + logoURI: optional(string()), + amount: pipe(string(), regex(/^\d+$/)), + }), + ), + ), + ), + offset: optional(string()), + }), + await response.json(), + ); + for (const [chainId, tokens] of Object.entries(json.balances ?? {})) { + const id = Number(chainId); + if (!Number.isInteger(id)) continue; + balances[id] = [ + ...(balances[id] ?? []), + ...tokens.map(({ amount, ...token }) => ({ ...token, amount: BigInt(amount) })), + ]; + } + offset = json.offset; + } while (offset); + return balances; +} + export const tokenCorrelation = { ETH: "ETH", WETH: "ETH", diff --git a/src/utils/queryClient.ts b/src/utils/queryClient.ts index 617d652172..3460fe5ba6 100644 --- a/src/utils/queryClient.ts +++ b/src/utils/queryClient.ts @@ -7,6 +7,8 @@ import { dehydrate, QueryCache, QueryClient, type Query } from "@tanstack/react- import { deserialize, serialize } from "wagmi"; import { hashFn, structuralSharing } from "wagmi/query"; +import chain from "@exactly/common/generated/chain"; + import reportError from "./reportError"; import { isAvailable as isOwnerAvailable } from "./wagmi/owner"; import release from "../generated/release"; @@ -20,7 +22,7 @@ const INVALIDATE_ON_UPGRADE = new Set(["kyc", "card", "pax"]); export function triage(error: unknown) { if (!(error instanceof APIError)) return; if (error.text === "bad kyc") return "warn"; - if (error.text === "no kyc" || error.text === "not started" || error.text === "kyc required") return "drop"; + if (["no kyc", "not started", "kyc required"].includes(error.text)) return "drop"; } function versionAwareDeserialize(cache: string): PersistedClient { @@ -34,6 +36,7 @@ function versionAwareDeserialize(cache: string): PersistedClient { } export const persister = createAsyncStoragePersister({ + key: `tanstack.${chain.id}`, serialize, deserialize: versionAwareDeserialize, storage: AsyncStorage, @@ -71,12 +74,7 @@ export const hydrated = const dehydrateOptions = { shouldDehydrateQuery: ({ queryKey, state }: Query) => state.status === "success" && - queryKey[0] !== "activity" && - queryKey[0] !== "externalAssets" && - queryKey[0] !== "kyc" && - queryKey[0] !== "card" && - queryKey[0] !== "pax" && - queryKey[0] !== "lifi" && + !["activity", "externalAssets", "kyc", "card", "pax", "lifi"].includes(queryKey[0] as string) && !(queryKey[0] === "ramp" && queryKey[1] === "kyc-tokens"), }; diff --git a/src/utils/reportError.ts b/src/utils/reportError.ts index e6aad7de43..41bea024f4 100644 --- a/src/utils/reportError.ts +++ b/src/utils/reportError.ts @@ -44,16 +44,20 @@ export default function reportError(error: unknown, hint?: Parameters 0 ? root.code : undefined; - const status = - typeof root === "object" && - root !== null && - "code" in root && - typeof root.code === "number" && - Number.isFinite(root.code) - ? String(root.code) - : undefined; + let status: string | undefined; + for ( + let cause: unknown = error; + cause != null && typeof cause === "object"; + cause = (cause as { cause?: unknown }).cause + ) { + if ("code" in cause && typeof cause.code === "number" && Number.isFinite(cause.code)) { + status = String(cause.code); + break; + } + } const name = typeof root === "object" && root !== null && "name" in root && typeof root.name === "string" && root.name.length > 0 ? root.name @@ -106,13 +113,14 @@ function parseError(error: unknown) { function classify({ code, message, name, reason, revert, status }: ParsedError) { const passkeyNotAllowed = name === "NotAllowedError" || (message !== undefined && authPrefixes.some((prefix) => message.startsWith(prefix))); - const passkeyCancelled = message !== undefined && passkeyCancelledMessages.has(message); + const passkeyCancelled = + (code !== undefined && passkeyCancelledCodes.has(code)) || + (message !== undefined && passkeyCancelledPatterns.some((pattern) => pattern.test(message))); const passkeyKnown = - message !== undefined && - (passkeyKnownMessages.has(message) || - message.includes("Biometrics must be enabled") || - message.includes("There is already a pending passkey request") || - authPrefixes.some((prefix) => message.startsWith(prefix))); + (code !== undefined && passkeyKnownCodes.has(code)) || + (message !== undefined && + (passkeyKnownPatterns.some((pattern) => pattern.test(message)) || + authPrefixes.some((prefix) => message.startsWith(prefix)))); const passkeyWarning = passkeyKnown && !passkeyCancelled && !passkeyNotAllowed; const biometric = code === "ERR_BIOMETRIC"; const walletRejected = status === "4001" || status === "5000"; diff --git a/src/utils/useAsset.ts b/src/utils/useAsset.ts index c3fe5fe311..c0a8a210d8 100644 --- a/src/utils/useAsset.ts +++ b/src/utils/useAsset.ts @@ -2,9 +2,10 @@ import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; +import chain from "@exactly/common/generated/chain"; import { borrowLimit, withdrawLimit } from "@exactly/lib"; -import { tokenBalancesOptions } from "./lifi"; +import { balancesOptions } from "./lifi"; import useAccount from "./useAccount"; import useMarkets from "./useMarkets"; @@ -14,10 +15,10 @@ export default function useAsset(address?: Address) { const { address: account } = useAccount(); const { markets, timestamp, firstMaturity, queryKey, isFetching: isMarketsFetching } = useMarkets(); const market = useMemo(() => markets?.find(({ market: m }) => m === address), [address, markets]); - const { data: tokenBalances, isFetching: isTokenBalancesFetching } = useQuery(tokenBalancesOptions(account)); + const { data: balances, isFetching: isBalancesFetching } = useQuery(balancesOptions(account)); const externalAsset = useMemo( - () => tokenBalances?.find((token) => token.address.toLowerCase() === address?.toLowerCase()) ?? null, - [tokenBalances, address], + () => balances?.[chain.id]?.find((token) => token.address.toLowerCase() === address?.toLowerCase()) ?? null, + [balances, address], ); const available = useMemo(() => { if (markets && market) return withdrawLimit(markets, market.market); @@ -39,6 +40,6 @@ export default function useAsset(address?: Address) { borrowAvailable, externalAsset, queryKey, - isFetching: isMarketsFetching || isTokenBalancesFetching, + isFetching: isMarketsFetching || isBalancesFetching, }; } diff --git a/src/utils/usePortfolio.ts b/src/utils/usePortfolio.ts index f6b1155721..b311c5f1b4 100644 --- a/src/utils/usePortfolio.ts +++ b/src/utils/usePortfolio.ts @@ -1,14 +1,17 @@ import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; +import { formatUnits } from "viem"; +import chain from "@exactly/common/generated/chain"; import { floatingDepositRates, withdrawLimit } from "@exactly/lib"; -import { tokenBalancesOptions } from "./lifi"; +import { balancesOptions } from "./lifi"; import useAccount from "./useAccount"; import useMarkets from "./useMarkets"; import type { Hex } from "@exactly/common/validation"; +import type { TokenAmount } from "@lifi/sdk"; export type ProtocolAsset = { asset: Hex; @@ -25,6 +28,7 @@ export type ProtocolAsset = { export type ExternalAsset = { address: string; amount?: bigint; + chainId: number; decimals: number; logoURI?: string; name: string; @@ -40,7 +44,15 @@ export default function usePortfolio(options?: { sortBy?: "usdcFirst" | "usdValu const { address: account } = useAccount(); const { markets, rateSnapshot, timestamp, isPending: isMarketsPending } = useMarkets(); - const { data: tokenBalances, isPending: isExternalPending } = useQuery(tokenBalancesOptions(account)); + const { data: balances, isLoading: isBalancesPending } = useQuery(balancesOptions(account)); + + const protocolSymbols = useMemo(() => { + if (!markets) return []; + const excluded = new Set(["USDC.e", "DAI", "WETH"]); + const symbols = new Set(markets.map((m) => m.symbol.slice(3)).filter((s) => !excluded.has(s))); + symbols.add("ETH"); + return [...symbols]; + }, [markets]); const portfolio = useMemo(() => { if (!markets) return { depositMarkets: [], balanceUSD: 0n }; @@ -92,45 +104,77 @@ export default function usePortfolio(options?: { sortBy?: "usdcFirst" | "usdValu }, [markets]); const externalAssets = useMemo(() => { - const balances = tokenBalances ?? []; - if (balances.length === 0) return []; - - const marketAddresses = new Set(markets?.map(({ market }) => market.toLowerCase())); - return balances - .filter(({ address }) => !marketAddresses.has(address.toLowerCase())) - .map((token) => ({ - ...token, - usdValue: (Number(token.priceUSD) * Number(token.amount ?? 0n)) / 10 ** token.decimals, - type: "external" as const, - })); - }, [tokenBalances, markets]); - - const assets = useMemo(() => { - const combined = [...protocolAssets, ...externalAssets]; - return combined.sort((a, b) => { - if (options?.sortBy === "usdcFirst") { - const aSymbol = a.symbol; - const bSymbol = b.symbol; - if (aSymbol === "USDC" && bSymbol !== "USDC") return -1; - if (bSymbol === "USDC" && aSymbol !== "USDC") return 1; + const tokens = balances?.[chain.id] ?? []; + if (!markets || tokens.length === 0) return []; + const marketAddresses = new Set(markets.map(({ market }) => market.toLowerCase())); + return tokens + .filter((token) => !marketAddresses.has(token.address.toLowerCase())) + .flatMap((token) => { + const asset = toExternalAsset(token); + return asset ? [asset] : []; + }); + }, [balances, markets]); + + const crossChainAssets = useMemo(() => { + if (!balances) return []; + const result: ExternalAsset[] = []; + for (const [chainIdKey, tokens] of Object.entries(balances)) { + const chainId = Number(chainIdKey); + if (!Number.isInteger(chainId) || chainId === chain.id) continue; + for (const token of tokens) { + const asset = toExternalAsset(token); + if (asset) result.push(asset); } - return b.usdValue - a.usdValue; - }); - }, [protocolAssets, externalAssets, options?.sortBy]); + } + return result; + }, [balances]); + + const assets = useMemo( + () => [...protocolAssets, ...externalAssets].sort(compareAssets(options?.sortBy)), + [protocolAssets, externalAssets, options?.sortBy], + ); + + const allAssets = useMemo( + () => [...protocolAssets, ...externalAssets, ...crossChainAssets].sort(compareAssets(options?.sortBy)), + [protocolAssets, externalAssets, crossChainAssets, options?.sortBy], + ); + + const externalUSD = useMemo(() => externalAssets.reduce((sum, asset) => sum + asset.usdValue, 0), [externalAssets]); const totalBalanceUSD = useMemo(() => { - const externalUSD = externalAssets.reduce((sum, asset) => sum + asset.usdValue, 0); - return portfolio.balanceUSD + BigInt(Math.round(externalUSD * 1e18)); - }, [portfolio.balanceUSD, externalAssets]); + const crossChainUSD = crossChainAssets.reduce((sum, asset) => sum + asset.usdValue, 0); + return portfolio.balanceUSD + BigInt(Math.round((externalUSD + crossChainUSD) * 1e18)); + }, [portfolio.balanceUSD, externalUSD, crossChainAssets]); return { portfolio, averageRate, assets, + allAssets, protocolAssets, externalAssets, + crossChainAssets, totalBalanceUSD, + protocolSymbols, markets, - isPending: isExternalPending || isMarketsPending, + isPending: isMarketsPending, + isBalancesPending, + }; +} + +function compareAssets(sortBy: "usdcFirst" | "usdValue" | undefined) { + return function compare(a: PortfolioAsset, b: PortfolioAsset) { + if (sortBy === "usdcFirst") { + if (a.symbol === "USDC" && b.symbol !== "USDC") return -1; + if (b.symbol === "USDC" && a.symbol !== "USDC") return 1; + } + return b.usdValue - a.usdValue; }; } + +function toExternalAsset(token: TokenAmount): ExternalAsset | undefined { + if (!token.amount) return undefined; + const rawUsd = Number(formatUnits(token.amount, token.decimals)) * Number(token.priceUSD); + const usdValue = Number.isFinite(rawUsd) && rawUsd > 0 ? rawUsd : 0; + return { ...token, type: "external", usdValue }; +} diff --git a/src/utils/useSimulateProposal.ts b/src/utils/useSimulateProposal.ts index 054d825c68..9b69095306 100644 --- a/src/utils/useSimulateProposal.ts +++ b/src/utils/useSimulateProposal.ts @@ -169,7 +169,7 @@ export default function useSimulateProposal({ blockNumber: blockNumber?.status === "success" ? blockNumber.result : undefined, blocks: [ { - blockOverrides: timestamp?.status === "success" ? { time: timestamp.result } : undefined, + blockOverrides: timestamp?.status === "success" ? { time: timestamp.result + 1n } : undefined, calls: request ? [ { @@ -183,7 +183,7 @@ export default function useSimulateProposal({ { blockOverrides: timestamp?.status === "success" && delay?.status === "success" - ? { time: timestamp.result + delay.result } + ? { time: timestamp.result + delay.result + 1n } : undefined, calls: executeRequest ? [ diff --git a/src/utils/wagmi/exa.ts b/src/utils/wagmi/exa.ts index c07c1d6089..e29fd07656 100644 --- a/src/utils/wagmi/exa.ts +++ b/src/utils/wagmi/exa.ts @@ -1,16 +1,19 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; -import { createConfig, createStorage, custom } from "wagmi"; +import * as chains from "viem/chains"; +import { createConfig, createStorage, custom, http } from "wagmi"; import chain from "@exactly/common/generated/chain"; import alchemyConnector from "../alchemyConnector"; import publicClient from "../publicClient"; +const others = Object.values(chains).filter((c) => c.id !== chain.id); + export default createConfig({ - chains: [chain], + chains: [chain, ...others], connectors: [alchemyConnector], - transports: { [chain.id]: custom(publicClient) }, - storage: createStorage({ key: "wagmi.exa", storage: AsyncStorage }), + transports: { ...Object.fromEntries(others.map((c) => [c.id, http()])), [chain.id]: custom(publicClient) }, + storage: createStorage({ key: `wagmi.exa.${chain.id}`, storage: AsyncStorage }), multiInjectedProviderDiscovery: false, }); diff --git a/src/utils/wagmi/owner.ts b/src/utils/wagmi/owner.ts index ccc13f1a79..f4bc0ad7d5 100644 --- a/src/utils/wagmi/owner.ts +++ b/src/utils/wagmi/owner.ts @@ -17,7 +17,7 @@ const config = createConfig({ ...Object.fromEntries(Object.values(chains).map((c) => [c.id, http()])), [chain.id]: custom(publicClient), }, - storage: createStorage({ key: "wagmi.owner", storage: AsyncStorage }), + storage: createStorage({ key: `wagmi.owner.${chain.id}`, storage: AsyncStorage }), }); export default config; diff --git a/tamagui.config.ts b/tamagui.config.ts index c0c650cb02..59c232201f 100644 --- a/tamagui.config.ts +++ b/tamagui.config.ts @@ -235,7 +235,7 @@ const body = createFont({ const tamagui = createTamagui({ ...config, tokens, - groups: { column: { pseudo: true } }, + groups: { column: { pseudo: true }, portfolio: { pseudo: true } }, fonts: { body, heading: body, @@ -555,7 +555,7 @@ declare module "tamagui" { interface TamaguiCustomConfig extends Config {} // eslint-disable-line @typescript-eslint/consistent-type-definitions, @typescript-eslint/no-empty-interface // eslint-disable-next-line @typescript-eslint/consistent-type-definitions interface TypeOverride { - groupNames(): "column"; + groupNames(): "column" | "portfolio"; } } export default tamagui;