From d70d8ace9aade822b8170db16ae1c5a06aec395f Mon Sep 17 00:00:00 2001 From: guillermo dieguez Date: Mon, 9 Mar 2026 12:54:18 -0300 Subject: [PATCH 01/14] =?UTF-8?q?=F0=9F=90=9B=20app:=20forward=20chain=20i?= =?UTF-8?q?d=20in=20account=20client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/cool-icons-grow.md | 5 +++++ src/utils/accountClient.ts | 34 ++++++++++++++++++++++++++++++---- 2 files changed, 35 insertions(+), 4 deletions(-) create mode 100644 .changeset/cool-icons-grow.md 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/src/utils/accountClient.ts b/src/utils/accountClient.ts index a800ebab37..5ed28033f1 100644 --- a/src/utils/accountClient.ts +++ b/src/utils/accountClient.ts @@ -25,7 +25,14 @@ import { bufferToBase64URLString, type AuthenticatorAssertionResponseJSON, } from "@simplewebauthn/browser"; -import { getCallsStatus, getConnection, sendCalls, sendTransaction, signMessage } from "@wagmi/core/actions"; +import { + getCallsStatus, + getConnection, + sendCalls, + sendTransaction, + signMessage, + switchChain, +} from "@wagmi/core/actions"; import { bytesToBigInt, bytesToHex, @@ -33,6 +40,7 @@ import { concatHex, custom, encodeAbiParameters, + encodeFunctionData, encodePacked, ethAddress, hashMessage, @@ -154,9 +162,16 @@ export default async function createAccountClient({ credentialId, factory, x, y switch (method) { case "wallet_sendCalls": { if (!Array.isArray(params) || params.length !== 1) throw new Error("bad params"); - const { calls, from, id } = params[0] as { calls: readonly Call[]; from?: Address; id?: string }; + const { calls, chainId, from, id } = params[0] as { + calls: readonly Call[]; + chainId?: Hex; + from?: Address; + id?: string; + }; if (from && from !== accountAddress) throw new Error("bad account"); + const requestedChainId = chainId ? hexToNumber(chainId) : chain.id; if (queryClient.getQueryData(["method"]) === "webauthn") { + if (requestedChainId !== 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 })), }); @@ -171,6 +186,7 @@ export default async function createAccountClient({ credentialId, factory, x, y try { return await sendCalls(ownerConfig, { id, + chainId: requestedChainId, calls: [execute], capabilities: { paymasterService: { @@ -186,8 +202,17 @@ export default async function createAccountClient({ credentialId, factory, x, y extra: error instanceof Error ? { cause: error.cause } : undefined, }); // TODO filter errors - const hash = await sendTransaction(ownerConfig, execute); - return { id: concat([hash, numberToHex(chain.id, { size: 32 }), TX_MAGIC_ID]) }; + 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); + } } } case "wallet_getCallsStatus": { @@ -212,6 +237,7 @@ export default async function createAccountClient({ credentialId, factory, x, y try { const { to, data = "0x", value = 0n } = params[0] as TransactionRequest; const { id } = await sendCalls(ownerConfig, { + chainId: chain.id, calls: [ { to: accountAddress, From 112ed96166c71183295d2b730ff4e8732e830ff3 Mon Sep 17 00:00:00 2001 From: guillermo dieguez Date: Mon, 9 Mar 2026 13:53:32 -0300 Subject: [PATCH 02/14] =?UTF-8?q?=F0=9F=90=9B=20app:=20pass=20chain=20id?= =?UTF-8?q?=20to=20bridge=20calls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/great-dryers-kick.md | 5 ++ src/components/add-funds/Bridge.tsx | 92 ++++++++++++++++------------- 2 files changed, 57 insertions(+), 40 deletions(-) create mode 100644 .changeset/great-dryers-kick.md 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/src/components/add-funds/Bridge.tsx b/src/components/add-funds/Bridge.tsx index 545c694785..b363a11a14 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, @@ -117,7 +117,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 +136,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 +145,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 +199,7 @@ export default function Bridge() { const bridgeQuoteEnabled = !!senderAddress && !!account && - !!effectiveSource && + !!source && !!sourceToken && !!destinationToken && sourceAmount > 0n && @@ -216,7 +216,7 @@ export default function Bridge() { "quote", senderAddress, account, - effectiveSource, + source, sourceToken, destinationToken, sourceAmount, @@ -226,7 +226,7 @@ export default function Bridge() { if ( !senderAddress || !account || - !effectiveSource || + !source || !sourceToken || !destinationToken || sourceAmount === 0n || @@ -234,7 +234,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 +245,7 @@ export default function Bridge() { }, enabled: bridgeQuoteEnabled, refetchInterval: 15_000, + meta: { warnError: () => true }, }); const toAmount = bridgeQuote ? BigInt(bridgeQuote.estimate.toAmount) : sourceAmount; @@ -265,22 +266,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 +311,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 +346,41 @@ 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 ( + error instanceof UserRejectedRequestError || + (error instanceof TransactionExecutionError && error.shortMessage === "User rejected the request.") + ) + throw error; + reportError(error, { level: "warning" }); + 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 +412,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 +804,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 +974,7 @@ export default function Bridge() { setAssetSheetOpen(false); }} groups={assetGroups} - selected={effectiveSource} + selected={source} onSelect={(chainId, token) => { setSourceAmount(0n); setSelectedSource({ chain: chainId, address: token.address }); From fed396b297c2fea3d996eb5f691aef87c8dbce61 Mon Sep 17 00:00:00 2001 From: guillermo dieguez Date: Tue, 10 Mar 2026 13:02:41 -0300 Subject: [PATCH 03/14] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20app:=20migrate=20rem?= =?UTF-8?q?aining=20flows=20to=20send=20calls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/loose-papers-take.md | 5 + src/components/pay/Repay.tsx | 128 ++++++++++++++++---------- src/components/roll-debt/RollDebt.tsx | 77 +++++++++------- src/components/send-funds/Amount.tsx | 112 +++++++++++----------- src/components/swaps/Swaps.tsx | 73 ++++++++++----- 5 files changed, 233 insertions(+), 162 deletions(-) create mode 100644 .changeset/loose-papers-take.md 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/src/components/pay/Repay.tsx b/src/components/pay/Repay.tsx index 71611b283e..11cfa3c54d 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"; @@ -362,55 +362,74 @@ export default function Repay() { }); const { - mutate, + mutate: repay, isPending: isRepaying, isSuccess: isRepaySuccess, error: writeContractError, - } = useWriteContract({ - mutation: { - onSuccess: () => queryClient.invalidateQueries({ queryKey: assetQueryKey }).catch(reportError), + } = useMutation({ + async mutationFn() { + if (!repayMarket) throw new Error("no repay market"); + 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({ + 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) { + reportError(error); }, }); - 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, @@ -427,10 +446,6 @@ 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({ calls: [ { @@ -460,10 +475,21 @@ 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, + }); + }, + onSettled() { + setEnableSimulations(true); + }, onError(error) { reportError(error); }, @@ -801,7 +827,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/roll-debt/RollDebt.tsx b/src/components/roll-debt/RollDebt.tsx index 449c9b39b1..8ef4279f97 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 { ContractFunctionExecutionError, 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"; @@ -247,42 +252,52 @@ 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) => { - toast.show(t("Rollover failed"), { - native: true, - duration: 1000, - burntOptions: { haptic: "error", preset: "error" }, - }); - reportError(error); - }, + } = 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({ + 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) { + 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 && @@ -305,7 +320,7 @@ function RolloverButton({ !proposeSimulation || hasProposed; return ( -