diff --git a/.changeset/busy-guests-ring.md b/.changeset/busy-guests-ring.md new file mode 100644 index 0000000000..8065b0f14b --- /dev/null +++ b/.changeset/busy-guests-ring.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +🐛 use unified auth error classification diff --git a/.changeset/clever-toes-beam.md b/.changeset/clever-toes-beam.md new file mode 100644 index 0000000000..84528204d7 --- /dev/null +++ b/.changeset/clever-toes-beam.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +🐛 improve repay disabled button condition diff --git a/.changeset/cool-icons-grow.md b/.changeset/cool-icons-grow.md new file mode 100644 index 0000000000..7ad205cb45 --- /dev/null +++ b/.changeset/cool-icons-grow.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +🐛 forward chain id in account client diff --git a/.changeset/cyan-flies-camp.md b/.changeset/cyan-flies-camp.md new file mode 100644 index 0000000000..4f21c256ad --- /dev/null +++ b/.changeset/cyan-flies-camp.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +🐛 fix swaps query invalidation diff --git a/.changeset/every-singers-create.md b/.changeset/every-singers-create.md new file mode 100644 index 0000000000..86eb644e9c --- /dev/null +++ b/.changeset/every-singers-create.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +🐛 preserve quoted amount on swap token flip diff --git a/.changeset/funny-aliens-mix.md b/.changeset/funny-aliens-mix.md new file mode 100644 index 0000000000..e51b913abf --- /dev/null +++ b/.changeset/funny-aliens-mix.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +🐛 fix pay simulation pending state diff --git a/.changeset/great-dryers-kick.md b/.changeset/great-dryers-kick.md new file mode 100644 index 0000000000..3a09c3175b --- /dev/null +++ b/.changeset/great-dryers-kick.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +🐛 pass chain id to bridge calls diff --git a/.changeset/humble-cities-drop.md b/.changeset/humble-cities-drop.md new file mode 100644 index 0000000000..e5c68bc508 --- /dev/null +++ b/.changeset/humble-cities-drop.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +♻️ pass explicit chain id to send calls hooks diff --git a/.changeset/khaki-pens-post.md b/.changeset/khaki-pens-post.md new file mode 100644 index 0000000000..d55b6abf7d --- /dev/null +++ b/.changeset/khaki-pens-post.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +♻️ decouple swap input state from route result diff --git a/.changeset/legal-eyes-clap.md b/.changeset/legal-eyes-clap.md new file mode 100644 index 0000000000..cffc12ca4a --- /dev/null +++ b/.changeset/legal-eyes-clap.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +🐛 preserve swap amounts on auth cancel diff --git a/.changeset/loose-papers-take.md b/.changeset/loose-papers-take.md new file mode 100644 index 0000000000..d54edf7de0 --- /dev/null +++ b/.changeset/loose-papers-take.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +♻️ migrate remaining flows to send calls diff --git a/.changeset/wide-cats-hug.md b/.changeset/wide-cats-hug.md new file mode 100644 index 0000000000..9c5b922d79 --- /dev/null +++ b/.changeset/wide-cats-hug.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +♻️ pass explicit chain id to read hooks diff --git a/src/components/add-funds/Bridge.tsx b/src/components/add-funds/Bridge.tsx index 545c694785..c7f20954ba 100644 --- a/src/components/add-funds/Bridge.tsx +++ b/src/components/add-funds/Bridge.tsx @@ -9,7 +9,7 @@ import { useToastController } from "@tamagui/toast"; import { ScrollView, Spinner, Square, XStack, YStack } from "tamagui"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { switchChain, waitForTransactionReceipt } from "@wagmi/core"; +import { switchChain, waitForCallsStatus, waitForTransactionReceipt } from "@wagmi/core"; import { encodeFunctionData, erc20Abi, @@ -17,8 +17,6 @@ import { getAddress, isAddress, parseUnits, - TransactionExecutionError, - UserRejectedRequestError, zeroAddress, type Hex, } from "viem"; @@ -32,7 +30,7 @@ import AssetSelectSheet from "./AssetSelectSheet"; import { getBridgeSources, getRouteFrom, tokenCorrelation, type BridgeSources, type RouteFrom } from "../../utils/lifi"; import openBrowser from "../../utils/openBrowser"; import queryClient from "../../utils/queryClient"; -import reportError from "../../utils/reportError"; +import reportError, { classifyError } from "../../utils/reportError"; import useAccount from "../../utils/useAccount"; import useMarkets from "../../utils/useMarkets"; import ownerConfig from "../../utils/wagmi/owner"; @@ -117,7 +115,7 @@ export default function Bridge() { const previousSourceRef = useRef(undefined); - const effectiveSource = useMemo(() => { + const source = useMemo(() => { if (assetGroups.length === 0) return; const isValid = !!selectedSource && @@ -136,8 +134,8 @@ export default function Bridge() { if (group && asset) return { chain: group.chain.id, address: asset.token.address }; }, [assetGroups, selectedSource, bridge?.defaultChainId, bridge?.defaultTokenAddress]); - const selectedGroup = assetGroups.find((group) => group.chain.id === effectiveSource?.chain); - const selectedAsset = selectedGroup?.assets.find((asset) => asset.token.address === effectiveSource?.address); + const selectedGroup = assetGroups.find((group) => group.chain.id === source?.chain); + const selectedAsset = selectedGroup?.assets.find((asset) => asset.token.address === source?.address); const sourceToken = selectedAsset?.token; const sourceBalance = selectedAsset?.balance ?? 0n; @@ -145,8 +143,8 @@ export default function Bridge() { const sourceTokenSymbol = sourceToken?.symbol; const insufficientBalance = sourceAmount > sourceBalance; - const isSameChain = effectiveSource?.chain === chain.id; - const isNativeSource = effectiveSource?.address === zeroAddress; + const isSameChain = source?.chain === chain.id; + const isNativeSource = source?.address === zeroAddress; const destinationTokens = useMemo(() => bridge?.tokensByChain[chain.id] ?? [], [bridge?.tokensByChain]); const destinationBalances = useMemo(() => bridge?.balancesByChain[chain.id] ?? [], [bridge?.balancesByChain]); @@ -199,7 +197,7 @@ export default function Bridge() { const bridgeQuoteEnabled = !!senderAddress && !!account && - !!effectiveSource && + !!source && !!sourceToken && !!destinationToken && sourceAmount > 0n && @@ -216,7 +214,7 @@ export default function Bridge() { "quote", senderAddress, account, - effectiveSource, + source, sourceToken, destinationToken, sourceAmount, @@ -226,7 +224,7 @@ export default function Bridge() { if ( !senderAddress || !account || - !effectiveSource || + !source || !sourceToken || !destinationToken || sourceAmount === 0n || @@ -234,7 +232,7 @@ export default function Bridge() { ) throw new Error("invalid bridge parameters"); return getRouteFrom({ - fromChainId: effectiveSource.chain, + fromChainId: source.chain, toChainId: chain.id, fromTokenAddress: sourceToken.address, toTokenAddress: destinationToken.address, @@ -245,6 +243,7 @@ export default function Bridge() { }, enabled: bridgeQuoteEnabled, refetchInterval: 15_000, + meta: { warnError: () => true }, }); const toAmount = bridgeQuote ? BigInt(bridgeQuote.estimate.toAmount) : sourceAmount; @@ -265,22 +264,22 @@ export default function Bridge() { } = useSimulateContract({ config: senderConfig, account: senderAddress, - chainId: transferSimulationEnabled ? effectiveSource.chain : undefined, - address: transferSimulationEnabled ? getAddress(effectiveSource.address) : undefined, + chainId: transferSimulationEnabled ? source.chain : undefined, + address: transferSimulationEnabled ? getAddress(source.address) : undefined, abi: erc20Abi, functionName: "transfer", args: transferSimulationEnabled ? ([getAddress(account), sourceAmount] as const) : undefined, - query: { enabled: transferSimulationEnabled }, + query: { enabled: transferSimulationEnabled, meta: { warnError: () => true } }, }); - const approvalTokenAddress = - effectiveSource?.address && isAddress(effectiveSource.address) ? effectiveSource.address : undefined; + const approvalTokenAddress = source?.address && isAddress(source.address) ? source.address : undefined; const approvalSpenderAddress = bridgeQuote?.estimate.approvalAddress; const approvalChainId = bridgeQuote?.chainId; const canReadAllowance = !!senderAddress && !!approvalTokenAddress && + approvalTokenAddress !== zeroAddress && !!approvalChainId && !!approvalSpenderAddress && approvalSpenderAddress !== zeroAddress && @@ -310,19 +309,15 @@ export default function Bridge() { setBridgePreview({ sourceToken, sourceAmount: BigInt(route.estimate.fromAmount) }); }, mutationFn: async (from) => { - if (!senderAddress || !effectiveSource || !account) throw new Error("missing bridge context"); + if (!senderAddress || !source || !account) throw new Error("missing bridge context"); if (isSameChain) throw new Error("invalid bridge context"); - - setBridgeStatus(t("Switching to {{chain}}...", { chain: selectedGroup?.chain.name ?? `Chain ${from.chainId}` })); - await switchChain(senderConfig, { chainId: from.chainId }); - const spender = from.estimate.approvalAddress; const requiresApproval = !!spender && spender !== zeroAddress && - effectiveSource.address !== zeroAddress && + source.address !== zeroAddress && isAddress(spender) && - isAddress(effectiveSource.address); + isAddress(source.address); let approval: Hex | undefined; let currentAllowance = allowanceData; @@ -349,24 +344,40 @@ export default function Bridge() { } } setBridgeStatus(t("Submitting bridge transaction...")); + let id: string | undefined; try { - await sendCallsTx({ + const result = await sendCallsTx({ + chainId: source.chain, calls: [ - ...(approval ? [{ to: getAddress(effectiveSource.address), data: approval }] : []), + ...(approval ? [{ to: getAddress(source.address), data: approval }] : []), { to: from.to, data: from.data, value: from.value }, ], }); - setBridgeStatus(t("Bridge transaction submitted")); + id = result.id; } catch (error) { - reportError(error); - if (approval) { - const hash = await sendTx({ to: getAddress(effectiveSource.address), data: approval }); - await waitForTransactionReceipt(senderConfig, { hash }); + if (classifyError(error).authKnown) throw error; + reportError(error, { + level: "warning", + extra: error instanceof Error ? { cause: error.cause } : undefined, + }); + await switchChain(senderConfig, { chainId: source.chain }); + try { + 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); } - const hash = await sendTx({ to: from.to, data: from.data, value: from.value }); - await waitForTransactionReceipt(senderConfig, { hash }); 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")); }, onSuccess: async () => { toast.show(t("Bridge transaction submitted"), { @@ -398,20 +409,19 @@ export default function Bridge() { setBridgePreview({ sourceToken, sourceAmount }); }, mutationFn: async () => { - if (!senderAddress || !effectiveSource || !account) throw new Error("missing transfer context"); + if (!senderAddress || !source || !account) throw new Error("missing transfer context"); if (!isSameChain) throw new Error("transfer mutation invoked for different chains"); - - await switchChain(senderConfig, { chainId: effectiveSource.chain }); setBridgeStatus(t("Submitting transfer transaction...")); + await switchChain(senderConfig, { chainId: chain.id }); const recipient = getAddress(account); let hash: Hex; if (isNativeSource) { - hash = await sendTx({ to: recipient, value: sourceAmount }); + hash = await sendTx({ chainId: source.chain, to: recipient, value: sourceAmount }); } else { if (!transferSimulation) throw new Error("missing transfer simulation"); - hash = await transfer(transferSimulation.request); + hash = await transfer({ ...transferSimulation.request, chainId: source.chain }); } - await waitForTransactionReceipt(senderConfig, { hash }); + await waitForTransactionReceipt(senderConfig, { hash, chainId: source.chain }); setBridgeStatus(t("Transfer transaction submitted")); }, onSuccess: async () => { @@ -791,8 +801,7 @@ export default function Bridge() { {t("Source network")} - {selectedGroup?.chain.name ?? - (effectiveSource?.chain ? t("Chain {{id}}", { id: effectiveSource.chain }) : "—")} + {selectedGroup?.chain.name ?? (source?.chain ? t("Chain {{id}}", { id: source.chain }) : "—")} @@ -962,7 +971,7 @@ export default function Bridge() { setAssetSheetOpen(false); }} groups={assetGroups} - selected={effectiveSource} + selected={source} onSelect={(chainId, token) => { setSourceAmount(0n); setSelectedSource({ chain: chainId, address: token.address }); @@ -988,12 +997,10 @@ export default function Bridge() { } function handleError(error: unknown, toast: ReturnType, t: TFunction, isTransfer?: boolean) { - if (error instanceof UserRejectedRequestError) return; - if (error instanceof TransactionExecutionError && error.shortMessage === "User rejected the request.") return; - toast.show(isTransfer ? t("Transfer failed. Please try again.") : t("Bridge failed. Please try again."), { - native: true, - duration: 1000, - burntOptions: { haptic: "error", preset: "error" }, - }); - reportError(error); + 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/card/Card.tsx b/src/components/card/Card.tsx index 188cf42e3f..7429f37bf1 100644 --- a/src/components/card/Card.tsx +++ b/src/components/card/Card.tsx @@ -11,7 +11,7 @@ import { ScrollView, Separator, Spinner, Square, Switch, XStack, YStack } from " import { useMutation, useQuery } from "@tanstack/react-query"; import accountInit from "@exactly/common/accountInit"; -import { marketUSDCAddress } from "@exactly/common/generated/chain"; +import chain, { marketUSDCAddress } from "@exactly/common/generated/chain"; import { useReadUpgradeableModularAccountGetInstalledPlugins } from "@exactly/common/generated/hooks"; import CardDetails from "./CardDetails"; @@ -97,6 +97,7 @@ export default function Card() { const { refetch: refetchInstalledPlugins, isFetching: isFetchingPlugins } = useReadUpgradeableModularAccountGetInstalledPlugins({ address, + chainId: chain.id, factory: credential?.factory, factoryData: credential && accountInit(credential), query: { enabled: !!address && !!credential }, diff --git a/src/components/defi/DeFi.tsx b/src/components/defi/DeFi.tsx index 70bb96620e..d2abcdabec 100644 --- a/src/components/defi/DeFi.tsx +++ b/src/components/defi/DeFi.tsx @@ -9,6 +9,8 @@ import { ScrollView, useTheme, XStack, YStack } from "tamagui"; import { useQuery } from "@tanstack/react-query"; import { useBytecode } from "wagmi"; +import chain from "@exactly/common/generated/chain"; + import AboutDefiSheet from "./AboutDefiSheet"; import ConnectionSheet from "./ConnectionSheet"; import DisconnectSheet from "./DisconnectSheet"; @@ -32,7 +34,7 @@ export default function DeFi() { const { data: fundingConnected } = useQuery({ queryKey: ["defi", "usdc-funding-connected"] }); const { data: lifiConnected } = useQuery({ queryKey: ["defi", "lifi-connected"] }); const { address } = useAccount(); - const { data: bytecode } = useBytecode({ address, query: { enabled: !!address } }); + const { data: bytecode } = useBytecode({ address, chainId: chain.id, query: { enabled: !!address } }); const [aboutDefiSheetOpen, setAboutDefiSheetOpen] = useState(false); const [fundingSheetOpen, setFundingSheetOpen] = useState(false); const [lifiSheetOpen, setLifiSheetOpen] = useState(false); diff --git a/src/components/getting-started/GettingStarted.tsx b/src/components/getting-started/GettingStarted.tsx index 7f5a0c4173..9e5dfb681b 100644 --- a/src/components/getting-started/GettingStarted.tsx +++ b/src/components/getting-started/GettingStarted.tsx @@ -10,6 +10,8 @@ import { ScrollView, XStack, YStack } from "tamagui"; import { useQuery } from "@tanstack/react-query"; import { useBytecode } from "wagmi"; +import chain from "@exactly/common/generated/chain"; + import Step from "./Step"; import { presentArticle } from "../../utils/intercom"; import reportError from "../../utils/reportError"; @@ -26,7 +28,7 @@ import type { KYCStatus } from "../../utils/server"; function useOnboardingState() { const { address: account } = useAccount(); - const { data: bytecode } = useBytecode({ address: account, query: { enabled: !!account } }); + const { data: bytecode } = useBytecode({ address: account, chainId: chain.id, query: { enabled: !!account } }); const { data: kycStatus } = useQuery({ queryKey: ["kyc", "status"] }); const isDeployed = !!bytecode; const hasKYC = Boolean( diff --git a/src/components/home/Home.tsx b/src/components/home/Home.tsx index 4e77f00e32..875b93e7f5 100644 --- a/src/components/home/Home.tsx +++ b/src/components/home/Home.tsx @@ -12,7 +12,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { useBytecode } from "wagmi"; import accountInit from "@exactly/common/accountInit"; -import { exaPluginAddress, exaPreviewerAddress, marketUSDCAddress } from "@exactly/common/generated/chain"; +import chain, { exaPluginAddress, exaPreviewerAddress, marketUSDCAddress } from "@exactly/common/generated/chain"; import { useReadExaPreviewerPendingProposals, useReadUpgradeableModularAccountGetInstalledPlugins, @@ -91,10 +91,12 @@ export default function Home() { const { data: credential } = useQuery({ queryKey: ["credential"] }); const { data: bytecode, refetch: refetchBytecode } = useBytecode({ address: account, + chainId: chain.id, query: { enabled: !!account }, }); const { data: installedPlugins } = useReadUpgradeableModularAccountGetInstalledPlugins({ address: account, + chainId: chain.id, factory: credential?.factory, factoryData: credential && accountInit(credential), query: { enabled: !!account && !!credential }, @@ -116,6 +118,7 @@ export default function Home() { }); const { refetch: refetchPendingProposals } = useReadExaPreviewerPendingProposals({ address: exaPreviewerAddress, + chainId: chain.id, args: account ? [account] : undefined, query: { enabled: !!account && !!bytecode, gcTime: 0, refetchInterval: 30_000 }, }); diff --git a/src/components/home/HomeActions.tsx b/src/components/home/HomeActions.tsx index e5f8d90858..76589c39a0 100644 --- a/src/components/home/HomeActions.tsx +++ b/src/components/home/HomeActions.tsx @@ -10,7 +10,7 @@ import { useQuery } from "@tanstack/react-query"; import { useBytecode, useReadContract } from "wagmi"; import accountInit from "@exactly/common/accountInit"; -import { exaPluginAddress } from "@exactly/common/generated/chain"; +import chain, { exaPluginAddress } from "@exactly/common/generated/chain"; import { upgradeableModularAccountAbi, useReadUpgradeableModularAccountGetInstalledPlugins, @@ -26,7 +26,7 @@ export default function HomeActions() { const router = useRouter(); const { address: account } = useAccount(); const { data: credential } = useQuery({ queryKey: ["credential"] }); - const { data: bytecode } = useBytecode({ address: account, query: { enabled: !!account } }); + const { data: bytecode } = useBytecode({ address: account, chainId: chain.id, query: { enabled: !!account } }); const { t } = useTranslation(); const actions = useMemo( () => [ @@ -39,12 +39,14 @@ export default function HomeActions() { const { data: installedPlugins } = useReadUpgradeableModularAccountGetInstalledPlugins({ address: account, + chainId: chain.id, factory: credential?.factory, factoryData: credential && accountInit(credential), query: { enabled: !!account && !!credential }, }); const isLatestPlugin = installedPlugins?.[0] === exaPluginAddress; const { refetch: fetchProposals, isPending } = useReadContract({ + chainId: chain.id, functionName: "proposals", abi: [ ...upgradeableModularAccountAbi, diff --git a/src/components/home/card-upgrade/UpgradeAccount.tsx b/src/components/home/card-upgrade/UpgradeAccount.tsx index cb468a2a95..981cfab786 100644 --- a/src/components/home/card-upgrade/UpgradeAccount.tsx +++ b/src/components/home/card-upgrade/UpgradeAccount.tsx @@ -40,11 +40,12 @@ export default function UpgradeAccount() { const { data: installedPlugins, refetch: refetchInstalledPlugins } = useReadUpgradeableModularAccountGetInstalledPlugins({ address, + chainId: chain.id, factory: credential?.factory, factoryData: credential && accountInit(credential), query: { refetchOnMount: true, enabled: !!address && !!credential }, }); - const { data: pluginManifest } = useReadExaPluginPluginManifest({ address: exaPluginAddress }); + const { data: pluginManifest } = useReadExaPluginPluginManifest({ address: exaPluginAddress, chainId: chain.id }); const isLatestPlugin = installedPlugins?.[0] === exaPluginAddress; const toast = useToastController(); @@ -61,6 +62,7 @@ export default function UpgradeAccount() { if (!pluginManifest) throw new Error("invalid manifest"); const { id } = await mutateSendCalls({ + chainId: chain.id, calls: [ { to: address, @@ -100,12 +102,13 @@ export default function UpgradeAccount() { queryClient.setQueryData(["card-upgrade"], 2); await refetchInstalledPlugins(); }, - onError: () => { - toast.show(t("Error upgrading account"), { - native: true, - duration: 1000, - burntOptions: { haptic: "error", preset: "error" }, - }); + onError(error) { + if (!reportError(error).authKnown) + toast.show(t("Error upgrading account"), { + native: true, + duration: 1000, + burntOptions: { haptic: "error", preset: "error" }, + }); }, }); return ( diff --git a/src/components/loans/Amount.tsx b/src/components/loans/Amount.tsx index 107762f6b7..37e68116e7 100644 --- a/src/components/loans/Amount.tsx +++ b/src/components/loans/Amount.tsx @@ -10,6 +10,8 @@ import { useQuery } from "@tanstack/react-query"; import { formatUnits } from "viem"; import { useBytecode } from "wagmi"; +import chain from "@exactly/common/generated/chain"; + import AmountSelector from "./AmountSelector"; import { presentArticle } from "../../utils/intercom"; import queryClient from "../../utils/queryClient"; @@ -32,7 +34,7 @@ export default function Amount() { t, i18n: { language }, } = useTranslation(); - const { data: bytecode } = useBytecode({ address, query: { enabled: !!address } }); + const { data: bytecode } = useBytecode({ address, chainId: chain.id, query: { enabled: !!address } }); const { markets } = useMarkets({ enabled: !!bytecode }); const { data: loan } = useQuery({ queryKey: ["loan"], enabled: !!address }); const { market, borrowAvailable } = useAsset(loan?.market); diff --git a/src/components/loans/CreditLine.tsx b/src/components/loans/CreditLine.tsx index 8e865274b5..c0f55f9c0c 100644 --- a/src/components/loans/CreditLine.tsx +++ b/src/components/loans/CreditLine.tsx @@ -9,7 +9,7 @@ import { Separator, XStack, YStack } from "tamagui"; import { formatUnits } from "viem"; import { useBytecode } from "wagmi"; -import { marketUSDCAddress } from "@exactly/common/generated/chain"; +import chain, { marketUSDCAddress } from "@exactly/common/generated/chain"; import { borrowLimit } from "@exactly/lib"; import queryClient, { type Loan } from "../../utils/queryClient"; @@ -26,7 +26,7 @@ export default function CreditLine() { t, i18n: { language }, } = useTranslation(); - const { data: bytecode } = useBytecode({ address, query: { enabled: !!address } }); + const { data: bytecode } = useBytecode({ address, chainId: chain.id, query: { enabled: !!address } }); const { markets, firstMaturity } = useMarkets({ enabled: !!bytecode }); return ( diff --git a/src/components/loans/LoanSummary.tsx b/src/components/loans/LoanSummary.tsx index b285ab5a08..01227573f5 100644 --- a/src/components/loans/LoanSummary.tsx +++ b/src/components/loans/LoanSummary.tsx @@ -5,7 +5,7 @@ import { XStack, YStack } from "tamagui"; import { useBytecode } from "wagmi"; -import { previewerAddress } from "@exactly/common/generated/chain"; +import chain, { previewerAddress } from "@exactly/common/generated/chain"; import { useReadPreviewerPreviewBorrowAtMaturity } from "@exactly/common/generated/hooks"; import { WAD } from "@exactly/lib"; @@ -24,7 +24,11 @@ export default function LoanSummary({ loan }: { loan: Loan }) { i18n: { language }, } = useTranslation(); const { address } = useAccount(); - const { data: bytecode } = useBytecode({ address: previewerAddress, query: { enabled: !!address } }); + const { data: bytecode } = useBytecode({ + address: previewerAddress, + chainId: chain.id, + query: { enabled: !!address }, + }); const { market, timestamp, isFetching: isMarketFetching } = useAsset(loan.market); const symbol = market?.symbol.slice(3) === "WETH" ? "ETH" : market?.symbol.slice(3); const isBorrow = loan.installments === 1; @@ -39,6 +43,7 @@ export default function LoanSummary({ loan }: { loan: Loan }) { }); const { data: borrow, isLoading: isBorrowPending } = useReadPreviewerPreviewBorrowAtMaturity({ address: previewerAddress, + chainId: chain.id, args: loan.market && loan.amount ? [loan.market, loan.maturity ?? BigInt(firstMaturity), loan.amount] : undefined, query: { enabled: isBorrow && !!loan.amount && !!loan.market && !!address && !!bytecode, diff --git a/src/components/loans/Review.tsx b/src/components/loans/Review.tsx index 084c92e0ab..cea3b751c5 100644 --- a/src/components/loans/Review.tsx +++ b/src/components/loans/Review.tsx @@ -72,10 +72,11 @@ export default function Review() { const singleInstallment = count === 1; const { data: credential } = useQuery({ queryKey: ["credential"] }); - const { data: bytecode } = useBytecode({ address, query: { enabled: !!address } }); + const { data: bytecode } = useBytecode({ address, chainId: chain.id, query: { enabled: !!address } }); const { data: borrow, isPending: isBorrowPending } = useReadPreviewerPreviewBorrowAtMaturity({ address: previewerAddress, + chainId: chain.id, args: [marketUSDCAddress, maturity ?? 0n, amount ?? 0n], query: { enabled: !!address && !!bytecode && !!maturity && !!amount && singleInstallment }, }); @@ -111,6 +112,7 @@ export default function Review() { isPending: isProposingBorrowInstallments, isSuccess: isProposingBorrowInstallmentsSuccess, error: proposeBorrowInstallmentsError, + reset: resetProposal, } = useMutation({ async mutationFn() { if (!address) throw new Error("no account"); @@ -142,6 +144,7 @@ export default function Review() { calls.push({ to: address, data }); } const { id } = await mutateSendCalls({ + chainId: chain.id, calls, capabilities: { paymasterService: { @@ -153,7 +156,9 @@ export default function Review() { const { status } = await waitForCallsStatus(exa, { id }); if (status === "failure") throw new Error("failed to submit borrow proposal"); }, - onError: reportError, + onError(error) { + if (reportError(error).authKnown) resetProposal(); + }, }); const maturityLabel = useMemo( @@ -193,6 +198,7 @@ export default function Review() { const { data: installedPlugins } = useReadUpgradeableModularAccountGetInstalledPlugins({ address, + chainId: chain.id, factory: credential?.factory, factoryData: credential && accountInit(credential), query: { enabled: !!address && !!credential }, diff --git a/src/components/pay/OverduePayments.tsx b/src/components/pay/OverduePayments.tsx index 73a75c3e3d..13a07f9a30 100644 --- a/src/components/pay/OverduePayments.tsx +++ b/src/components/pay/OverduePayments.tsx @@ -8,7 +8,7 @@ import { Separator, XStack, YStack } from "tamagui"; import { useBytecode } from "wagmi"; -import { marketUSDCAddress } from "@exactly/common/generated/chain"; +import chain, { marketUSDCAddress } from "@exactly/common/generated/chain"; import { WAD } from "@exactly/lib"; import reportError from "../../utils/reportError"; @@ -30,7 +30,7 @@ export default function OverduePayments({ i18n: { language }, } = useTranslation(); const { address } = useAccount(); - const { data: bytecode } = useBytecode({ address, query: { enabled: !!address } }); + const { data: bytecode } = useBytecode({ address, chainId: chain.id, query: { enabled: !!address } }); const { isProcessing } = usePendingOperations(); const { markets, timestamp } = useMarkets({ enabled: !!bytecode, refetchInterval: 30_000 }); const exaUSDC = markets?.find(({ market }) => market === marketUSDCAddress); diff --git a/src/components/pay/Pay.tsx b/src/components/pay/Pay.tsx index 6ac633a748..b46b4770fa 100644 --- a/src/components/pay/Pay.tsx +++ b/src/components/pay/Pay.tsx @@ -53,6 +53,7 @@ export default function Pay() { const { data: credential } = useQuery({ queryKey: ["credential"] }); const { data: installedPlugins } = useReadUpgradeableModularAccountGetInstalledPlugins({ address, + chainId: chain.id, factory: credential?.factory, factoryData: credential && accountInit(credential), query: { refetchOnMount: true, enabled: !!address && !!credential }, diff --git a/src/components/pay/PaymentSheet.tsx b/src/components/pay/PaymentSheet.tsx index e46693678a..28bf8f9319 100644 --- a/src/components/pay/PaymentSheet.tsx +++ b/src/components/pay/PaymentSheet.tsx @@ -41,6 +41,7 @@ export default function PaymentSheet({ onRolloverIntro }: { onRolloverIntro?: (m const { data: credential } = useQuery({ queryKey: ["credential"] }); const { data: installedPlugins } = useReadUpgradeableModularAccountGetInstalledPlugins({ address, + chainId: chain.id, factory: credential?.factory, factoryData: credential && accountInit(credential), query: { refetchOnMount: true, enabled: !!address && !!credential }, diff --git a/src/components/pay/Repay.tsx b/src/components/pay/Repay.tsx index 71611b283e..ded9e46df6 100644 --- a/src/components/pay/Repay.tsx +++ b/src/components/pay/Repay.tsx @@ -10,8 +10,8 @@ import { ScrollView, Separator, XStack, YStack } from "tamagui"; import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query"; import { waitForCallsStatus } from "@wagmi/core/actions"; import { digits, nonEmpty, parse, pipe, safeParse, string, transform } from "valibot"; -import { ContractFunctionExecutionError, ContractFunctionRevertedError, erc20Abi } from "viem"; -import { useBytecode, useReadContract, useSendCalls, useSimulateContract, useWriteContract } from "wagmi"; +import { ContractFunctionExecutionError, ContractFunctionRevertedError, encodeFunctionData, erc20Abi } from "viem"; +import { useBytecode, useReadContract, useSendCalls, useSimulateContract } from "wagmi"; import accountInit from "@exactly/common/accountInit"; import alchemyAPIKey from "@exactly/common/alchemyAPIKey"; @@ -100,9 +100,10 @@ export default function Repay() { }); const { mutateAsync: mutateSendCalls } = useSendCalls(); const { data: credential } = useQuery({ queryKey: ["credential"] }); - const { data: bytecode } = useBytecode({ address: account, query: { enabled: !!account } }); + const { data: bytecode } = useBytecode({ address: account, chainId: chain.id, query: { enabled: !!account } }); const { data: installedPlugins } = useReadUpgradeableModularAccountGetInstalledPlugins({ address: account, + chainId: chain.id, factory: credential?.factory, factoryData: credential && accountInit(credential), query: { enabled: !!account && !!credential }, @@ -132,6 +133,7 @@ export default function Repay() { const { data: fixedRepaySnapshot } = useReadContract({ address: integrationPreviewerAddress, + chainId: chain.id, abi: integrationPreviewerAbi, functionName: "fixedRepaySnapshot", args: account ? [account, marketUSDCAddress, maturity ?? 0n] : undefined, @@ -140,6 +142,7 @@ export default function Repay() { const { data: proposalDelay, isLoading: isProposalDelayLoading } = useReadProposalManagerDelay({ address: proposalManagerAddress, + chainId: chain.id, }); const simulationTimestamp = proposalDelay === undefined ? undefined : Number(timestamp) + Number(proposalDelay); @@ -162,6 +165,7 @@ export default function Repay() { const { data: balancerUSDCBalance } = useReadContract({ address: usdcAddress, + chainId: chain.id, abi: erc20Abi, functionName: "balanceOf", args: balancerVaultAddress ? [balancerVaultAddress] : undefined, @@ -318,6 +322,7 @@ export default function Repay() { isPending: isSimulatingLegacyRepay, } = useSimulateContract({ address: account, + chainId: chain.id, functionName: "repay", args: [maturity ?? 0n], abi: [ @@ -341,6 +346,7 @@ export default function Repay() { isPending: isSimulatingLegacyCrossRepay, } = useSimulateContract({ address: account, + chainId: chain.id, functionName: "crossRepay", args: selectedAsset.address && maturity ? [maturity, selectedAsset.address] : undefined, abi: [ @@ -362,60 +368,84 @@ export default function Repay() { }); const { - mutate, + mutate: repay, isPending: isRepaying, isSuccess: isRepaySuccess, error: writeContractError, - } = useWriteContract({ - mutation: { - onSuccess: () => queryClient.invalidateQueries({ queryKey: assetQueryKey }).catch(reportError), + reset: resetRepay, + } = useMutation({ + async mutationFn() { + if (!repayMarket) throw new Error("no repay market"); + const amount = withUSDC ? repayAssets : route?.fromAmount; + if (!amount) throw new Error("no route"); + const call = (() => { + switch (mode) { + case "repay": { + if (!repayPropose) throw new Error("no repay simulation"); + const { address, abi, functionName, args } = repayPropose; + return { to: address, data: encodeFunctionData({ abi, functionName, args }) }; + } + + case "legacyRepay": { + if (!legacyRepaySimulation) throw new Error("no legacy repay simulation"); + const { address, abi, functionName, args } = legacyRepaySimulation.request; + return { to: address, data: encodeFunctionData({ abi, functionName, args }) }; + } + case "crossRepay": { + if (!crossRepayPropose) throw new Error("no cross repay simulation"); + const { address, abi, functionName, args } = crossRepayPropose; + return { to: address, data: encodeFunctionData({ abi, functionName, args }) }; + } + + case "legacyCrossRepay": { + if (!legacyCrossRepaySimulation) throw new Error("no legacy cross repay simulation"); + const { address, abi, functionName, args } = legacyCrossRepaySimulation.request; + return { to: address, data: encodeFunctionData({ abi, functionName, args }) }; + } + default: + throw new Error("unexpected mode"); + } + })(); + const { id } = await mutateSendCalls({ + chainId: chain.id, + calls: [call], + capabilities: { + paymasterService: { + url: `${chain.rpcUrls.alchemy.http[0]}/${alchemyAPIKey}`, + context: { policyId: alchemyGasPolicyId }, + }, + }, + }); + const { status } = await waitForCallsStatus(exa, { id }); + if (status === "failure") throw new Error("failed to repay"); + }, + onMutate() { + setEnableSimulations(false); + if (!repayMarket) return; + const amount = withUSDC ? repayAssets : route?.fromAmount; + if (!amount) return; + setDisplayValues({ + amount: Number(amount) / 10 ** repayMarket.decimals, + usdAmount: Number(previewValueUSD) / 1e18, + }); + }, + onSuccess() { + queryClient.invalidateQueries({ queryKey: assetQueryKey }).catch(reportError); + }, + onSettled() { + setEnableSimulations(true); + }, + onError(error) { + if (reportError(error).authKnown) resetRepay(); }, }); - const handlePayment = useCallback(() => { - if (!repayMarket) return; - setDisplayValues({ - amount: Number(withUSDC ? repayAssets : route?.fromAmount) / 10 ** repayMarket.decimals, - usdAmount: Number(previewValueUSD) / 1e18, - }); - switch (mode) { - case "repay": - if (!repayPropose) throw new Error("no repay simulation"); - mutate(repayPropose); - break; - case "legacyRepay": - if (!legacyRepaySimulation) throw new Error("no legacy repay simulation"); - mutate(legacyRepaySimulation.request); - break; - case "crossRepay": - if (!crossRepayPropose) throw new Error("no cross repay simulation"); - mutate(crossRepayPropose); - break; - case "legacyCrossRepay": - if (!legacyCrossRepaySimulation) throw new Error("no legacy cross repay simulation"); - mutate(legacyCrossRepaySimulation.request); - break; - } - setEnableSimulations(false); - }, [ - crossRepayPropose, - legacyCrossRepaySimulation, - legacyRepaySimulation, - mode, - previewValueUSD, - repayAssets, - repayMarket, - repayPropose, - route?.fromAmount, - withUSDC, - mutate, - ]); - const { mutateAsync: repayWithExternalAsset, isPending: isExternalRepaying, isSuccess: isExternalRepaySuccess, error: externalRepayError, + reset: resetExternalRepay, } = useMutation({ async mutationFn() { if (!account) throw new Error("no account"); @@ -427,11 +457,8 @@ export default function Repay() { if (!route.fromAmount) throw new Error("no route from amount"); if (!positionAssets) throw new Error("no position assets"); if (!maxRepay) throw new Error("no max repay"); - setDisplayValues({ - amount: Number(route.fromAmount) / 10 ** externalAsset.decimals, - usdAmount: (Number(externalAsset.priceUSD) * Number(route.fromAmount)) / 10 ** externalAsset.decimals, - }); const { id } = await mutateSendCalls({ + chainId: chain.id, calls: [ { to: selectedAsset.address, @@ -460,12 +487,27 @@ export default function Repay() { }, }, }); - setEnableSimulations(false); const { status } = await waitForCallsStatus(exa, { id }); if (status === "failure") throw new Error("failed to repay with external asset"); }, + onMutate() { + setEnableSimulations(false); + if (!externalAsset) return; + if (!route?.fromAmount) return; + setDisplayValues({ + amount: Number(route.fromAmount) / 10 ** externalAsset.decimals, + usdAmount: (Number(externalAsset.priceUSD) * Number(route.fromAmount)) / 10 ** externalAsset.decimals, + }); + }, + onSuccess() { + queryClient.invalidateQueries({ queryKey: assetQueryKey }).catch(reportError); + queryClient.invalidateQueries({ queryKey: ["lifi", "tokenBalances"] }).catch(reportError); + }, + onSettled() { + setEnableSimulations(true); + }, onError(error) { - reportError(error); + if (reportError(error).authKnown) resetExternalRepay(); }, }); @@ -509,8 +551,14 @@ export default function Repay() { setManuallySelectedAsset({ address, external }); }, []); + const needsRoute = mode === "crossRepay" || mode === "legacyCrossRepay" || mode === "external"; const disabled = - isSimulating || !!simulationError || (selectedAsset.external && !route) || repayAssets > maxRepayInput; + mode === "none" || + repayAssets === 0n || + isSimulating || + !!simulationError || + (needsRoute && !route) || + repayAssets > maxRepayInput; const loading = isSimulating || isPending || (selectedAsset.external && isRoutePending); const symbol = @@ -801,7 +849,7 @@ export default function Repay() { primary loading={loading && positionAssets > 0n} disabled={disabled} - onPress={selectedAsset.external ? () => repayWithExternalAsset() : handlePayment} + onPress={selectedAsset.external ? () => repayWithExternalAsset() : () => repay()} > {handleButtonText()} diff --git a/src/components/pay/UpcomingPayments.tsx b/src/components/pay/UpcomingPayments.tsx index c82a4030b1..ca83329539 100644 --- a/src/components/pay/UpcomingPayments.tsx +++ b/src/components/pay/UpcomingPayments.tsx @@ -9,7 +9,7 @@ import { Separator, XStack, YStack } from "tamagui"; import { addDays, isSameDay } from "date-fns"; import { useBytecode } from "wagmi"; -import { marketUSDCAddress } from "@exactly/common/generated/chain"; +import chain, { marketUSDCAddress } from "@exactly/common/generated/chain"; import { WAD } from "@exactly/lib"; import reportError from "../../utils/reportError"; @@ -33,7 +33,7 @@ export default function UpcomingPayments({ i18n: { language }, } = useTranslation(); const { address } = useAccount(); - const { data: bytecode } = useBytecode({ address, query: { enabled: !!address } }); + const { data: bytecode } = useBytecode({ address, chainId: chain.id, query: { enabled: !!address } }); const { isProcessing } = usePendingOperations(); const { markets, timestamp } = useMarkets({ enabled: !!bytecode, refetchInterval: 30_000 }); const exaUSDC = markets?.find(({ market }) => market === marketUSDCAddress); diff --git a/src/components/roll-debt/RollDebt.tsx b/src/components/roll-debt/RollDebt.tsx index 449c9b39b1..af6378a7ce 100644 --- a/src/components/roll-debt/RollDebt.tsx +++ b/src/components/roll-debt/RollDebt.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from "react"; +import React, { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -8,11 +8,15 @@ import { ArrowLeft, ArrowRight } from "@tamagui/lucide-icons"; import { useToastController } from "@tamagui/toast"; import { ScrollView, Separator, XStack, YStack } from "tamagui"; +import { useMutation } from "@tanstack/react-query"; +import { waitForCallsStatus } from "@wagmi/core/actions"; import { nonEmpty, pipe, safeParse, string } from "valibot"; -import { ContractFunctionExecutionError } from "viem"; -import { useWriteContract } from "wagmi"; +import { encodeFunctionData } from "viem"; +import { useSendCalls } from "wagmi"; -import { exaPreviewerAddress, marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; +import alchemyAPIKey from "@exactly/common/alchemyAPIKey"; +import alchemyGasPolicyId from "@exactly/common/alchemyGasPolicyId"; +import chain, { exaPreviewerAddress, marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; import { useReadExaPreviewerPendingProposals, useReadPreviewerPreviewBorrowAtMaturity, @@ -28,6 +32,7 @@ import reportError from "../../utils/reportError"; import useAccount from "../../utils/useAccount"; import useAsset from "../../utils/useAsset"; import useSimulateProposal from "../../utils/useSimulateProposal"; +import exa from "../../utils/wagmi/exa"; import Skeleton from "../shared/Skeleton"; import Button from "../shared/StyledButton"; @@ -67,6 +72,7 @@ export default function Pay() { const { data: borrowPreview } = useReadPreviewerPreviewBorrowAtMaturity({ address: previewerAddress, + chainId: chain.id, args: [marketUSDCAddress, BigInt(borrowMaturity), borrow?.previewValue ?? 0n], query: { enabled: !!exaUSDC && !!borrow && !!address && !!borrowMaturity }, }); @@ -247,42 +253,55 @@ function RolloverButton({ isPending: isPendingProposalsPending, } = useReadExaPreviewerPendingProposals({ address: exaPreviewerAddress, + chainId: chain.id, args: address ? [address] : undefined, query: { enabled: !!address, gcTime: 0, refetchInterval: 30_000 }, }); + const { mutateAsync: mutateSendCalls } = useSendCalls(); const { - mutate, + mutate: proposeRollDebt, isPending: isProposeRollDebtPending, - error: proposeRollDebtError, - } = useWriteContract({ - mutation: { - onSuccess: () => { - toast.show(t("Processing rollover"), { - native: true, - duration: 1000, - burntOptions: { haptic: "success", preset: "done" }, - }); - if (address) refetchPendingProposals().catch(reportError); - router.dismissTo("/activity"); - }, - onError: (error) => { + isError, + reset: resetProposeRollDebt, + } = useMutation({ + async mutationFn() { + if (!address) throw new Error("no address"); + if (!proposeSimulation) throw new Error("no propose roll debt simulation"); + const { address: to, abi, functionName, args } = proposeSimulation; + const { id } = await mutateSendCalls({ + chainId: chain.id, + calls: [{ to, data: encodeFunctionData({ abi, functionName, args }) }], + capabilities: { + paymasterService: { + url: `${chain.rpcUrls.alchemy.http[0]}/${alchemyAPIKey}`, + context: { policyId: alchemyGasPolicyId }, + }, + }, + }); + const { status } = await waitForCallsStatus(exa, { id }); + if (status === "failure") throw new Error("failed to propose rollover"); + }, + onSuccess() { + toast.show(t("Processing rollover"), { + native: true, + duration: 1000, + burntOptions: { haptic: "success", preset: "done" }, + }); + if (address) refetchPendingProposals().catch(reportError); + router.dismissTo("/activity"); + }, + onError(error) { + if (reportError(error).authKnown) resetProposeRollDebt(); + else toast.show(t("Rollover failed"), { native: true, duration: 1000, burntOptions: { haptic: "error", preset: "error" }, }); - reportError(error); - }, }, }); - const proposeRollDebt = useCallback(() => { - if (!address) throw new Error("no address"); - if (!proposeSimulation) throw new Error("no propose roll debt simulation"); - mutate(proposeSimulation); - }, [address, proposeSimulation, mutate]); - const hasProposed = pendingProposals?.some( ({ proposal }) => proposal.market === marketUSDCAddress && @@ -290,22 +309,15 @@ function RolloverButton({ proposal.amount === maxRepayAssets, ); - const isError = - proposeRollDebtError && - !( - proposeRollDebtError instanceof ContractFunctionExecutionError && - proposeRollDebtError.shortMessage === "User rejected the request." - ); - const disabled = - !!isError || + isError || !!executeProposalError || isProposeRollDebtPending || isPendingProposalsPending || !proposeSimulation || hasProposed; return ( -