diff --git a/src/components/add-funds/AddCrypto.tsx b/src/components/add-funds/AddCrypto.tsx index 2138ea77..204352f0 100644 --- a/src/components/add-funds/AddCrypto.tsx +++ b/src/components/add-funds/AddCrypto.tsx @@ -33,7 +33,7 @@ export default function AddCrypto() { const copy = useCallback(() => { if (!address) return; setStringAsync(address).catch(handleError); - toast.show("Account address copied!"); + toast.show("Account address copied!", { customData: { type: "success" } }); setAlertShown(false); }, [address, toast]); diff --git a/src/components/payments/NextPayment.tsx b/src/components/payments/NextPayment.tsx index 3ddbf8d5..ff2c3d44 100644 --- a/src/components/payments/NextPayment.tsx +++ b/src/components/payments/NextPayment.tsx @@ -1,15 +1,19 @@ -import { marketUSDCAddress } from "@exactly/common/generated/chain"; -import { WAD } from "@exactly/lib"; -import { Coins, Info } from "@tamagui/lucide-icons"; +import { exaPluginAbi, marketUSDCAddress, upgradeableModularAccountAbi } from "@exactly/common/generated/chain"; +import { MATURITY_INTERVAL, WAD } from "@exactly/lib"; +import { Coins, Info, RefreshCcw } from "@tamagui/lucide-icons"; +import { useToastController } from "@tamagui/toast"; import { useQuery } from "@tanstack/react-query"; import { format, formatDistance, isAfter } from "date-fns"; import { router } from "expo-router"; -import React from "react"; +import React, { useCallback } from "react"; import { Pressable } from "react-native"; import { ms } from "react-native-size-matters"; -import { XStack } from "tamagui"; +import { Spinner, XStack } from "tamagui"; +import { useSimulateContract, useWriteContract } from "wagmi"; +import { auditorAbi, marketAbi } from "../../generated/contracts"; import handleError from "../../utils/handleError"; +import queryClient from "../../utils/queryClient"; import useIntercom from "../../utils/useIntercom"; import useMarketAccount from "../../utils/useMarketAccount"; import Button from "../shared/Button"; @@ -17,9 +21,10 @@ import Text from "../shared/Text"; import View from "../shared/View"; export default function NextPayment() { + const toast = useToastController(); const { presentArticle } = useIntercom(); const { data: hidden } = useQuery({ queryKey: ["settings", "sensitive"] }); - const { market: USDCMarket } = useMarketAccount(marketUSDCAddress); + const { market: USDCMarket, account, queryKey } = useMarketAccount(marketUSDCAddress); const usdDue = new Map(); if (USDCMarket) { const { fixedBorrowPositions, usdPrice, decimals } = USDCMarket; @@ -33,6 +38,48 @@ export default function NextPayment() { const maturity = usdDue.keys().next().value; const duePayment = usdDue.get(maturity ?? 0n); const discount = duePayment ? Number(WAD - (duePayment.previewValue * WAD) / duePayment.position) / 1e18 : 0; + const followingMaturity = Number(maturity) + MATURITY_INTERVAL; + + const repayMaturity = maturity ?? 0n; + const borrowMaturity = BigInt(followingMaturity); + const maxRepayAssets = duePayment ? duePayment.position : 0n; + const maxBorrowAssets = duePayment ? duePayment.position : 0n; + const percentage = WAD; + + const { + data: rolloverSimulation, + isPending: isSimulatingRollover, + error: rolloverSimulationError, + } = useSimulateContract({ + abi: [...exaPluginAbi, ...upgradeableModularAccountAbi, ...auditorAbi, ...marketAbi], + args: [repayMaturity, borrowMaturity, maxRepayAssets, maxBorrowAssets, percentage], + query: { retry: false, enabled: maxRepayAssets !== 0n && maxBorrowAssets !== 0n }, // TODO remove + functionName: "rollDebt", + address: account, + account, + }); + + const { writeContract: rollover, isPending: isRollingDebt } = useWriteContract({ + mutation: { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }).catch(handleError); + toast.show("Success", { customData: { type: "success" } }); + }, + onError: (error) => { + if (error.name === "ContractFunctionExecutionError" && error.details === "AA23 reverted (or OOG)") { + toast.show("Cancelled", { customData: { type: "error" } }); + return; + } + toast.show("An error ocurred", { customData: { type: "error" } }); + }, + }, + }); + + const rollDebt = useCallback(() => { + if (!rolloverSimulation || rolloverSimulationError || isRollingDebt) throw new Error("no rollover simulation"); + rollover(rolloverSimulation.request); + }, [isRollingDebt, rollover, rolloverSimulation, rolloverSimulationError]); + return ( {maturity ? ( @@ -131,6 +178,37 @@ export default function NextPayment() { > Pay + )} @@ -145,3 +223,11 @@ export default function NextPayment() { ); } + +const outlined = { + hoverStyle: { backgroundColor: "$interactiveBaseBrandSoftHover" }, + pressStyle: { + backgroundColor: "$interactiveBaseBrandSoftPressed", + color: "$interactiveOnBaseBrandSoft", + }, +}; diff --git a/src/components/payments/PaymentSheet.tsx b/src/components/payments/PaymentSheet.tsx index 6b55c529..a40fee9c 100644 --- a/src/components/payments/PaymentSheet.tsx +++ b/src/components/payments/PaymentSheet.tsx @@ -1,39 +1,11 @@ -import { marketUSDCAddress } from "@exactly/common/generated/chain"; -import { WAD } from "@exactly/lib"; -import { Coins, Info } from "@tamagui/lucide-icons"; -import { useQuery } from "@tanstack/react-query"; -import { format, formatDistance, isAfter } from "date-fns"; -import { router, useLocalSearchParams } from "expo-router"; +import { useLocalSearchParams } from "expo-router"; import React from "react"; -import { Pressable } from "react-native"; -import { ms } from "react-native-size-matters"; -import { Sheet, XStack } from "tamagui"; -import { nonEmpty, pipe, safeParse, string } from "valibot"; +import { Sheet } from "tamagui"; -import handleError from "../../utils/handleError"; -import useIntercom from "../../utils/useIntercom"; -import useMarketAccount from "../../utils/useMarketAccount"; -import Button from "../shared/Button"; -import SafeView from "../shared/SafeView"; -import Text from "../shared/Text"; -import View from "../shared/View"; +import PaymentSheetContent from "./PaymentSheetContent"; export default function PaymentSheet({ open, onClose }: { open: boolean; onClose: () => void }) { - const { presentArticle } = useIntercom(); - const { data: hidden } = useQuery({ queryKey: ["settings", "sensitive"] }); - const { market: USDCMarket } = useMarketAccount(marketUSDCAddress); - const { maturity: currentMaturity } = useLocalSearchParams(); - const { success, output: maturity } = safeParse(pipe(string(), nonEmpty("no maturity")), currentMaturity); - if (!success || !USDCMarket) return; - - const { fixedBorrowPositions, usdPrice, decimals } = USDCMarket; - const borrow = fixedBorrowPositions.find((b) => b.maturity === BigInt(maturity)); - - if (!borrow) return; - - const previewValue = (borrow.previewValue * usdPrice) / 10n ** BigInt(decimals); - const positionValue = ((borrow.position.principal + borrow.position.fee) * usdPrice) / 10n ** BigInt(decimals); - const discount = Number(WAD - (previewValue * WAD) / positionValue) / 1e18; + const maturity = useLocalSearchParams().maturity as string | undefined; return ( - - - - <> - - - - {isAfter(new Date(Number(maturity) * 1000), new Date()) - ? `Due in ${formatDistance(new Date(), new Date(Number(maturity) * 1000))}` - : `${formatDistance(new Date(Number(maturity) * 1000), new Date())} past due`} - -  - {format(new Date(Number(maturity) * 1000), "MMM dd, yyyy")} - - - { - presentArticle("10245778").catch(handleError); - }} - hitSlop={ms(15)} - > - - - - - - {(Number(previewValue) / 1e18).toLocaleString(undefined, { - style: "currency", - currency: "USD", - currencyDisplay: "narrowSymbol", - })} - - {discount >= 0 && ( - - {(Number(positionValue) / 1e18).toLocaleString(undefined, { - style: "currency", - currency: "USD", - currencyDisplay: "narrowSymbol", - })} - - )} - {!hidden && ( - = 0 ? "$interactiveBaseSuccessSoftDefault" : "$interactiveBaseErrorSoftDefault" - } - color={discount >= 0 ? "$uiSuccessSecondary" : "$uiErrorSecondary"} - > - {discount >= 0 ? "PAY NOW AND SAVE " : "DAILY PENALTIES "} - {(discount >= 0 ? discount : discount * -1).toLocaleString(undefined, { - style: "percent", - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })} - - )} - - - - - - - - - + {maturity && } ); } diff --git a/src/components/payments/PaymentSheetContent.tsx b/src/components/payments/PaymentSheetContent.tsx new file mode 100644 index 00000000..2038717c --- /dev/null +++ b/src/components/payments/PaymentSheetContent.tsx @@ -0,0 +1,222 @@ +import { exaPluginAbi, marketUSDCAddress, upgradeableModularAccountAbi } from "@exactly/common/generated/chain"; +import { MATURITY_INTERVAL, WAD } from "@exactly/lib"; +import { Coins, Info, RefreshCcw } from "@tamagui/lucide-icons"; +import { useToastController } from "@tamagui/toast"; +import { useQuery } from "@tanstack/react-query"; +import { format, formatDistance, isAfter } from "date-fns"; +import { router } from "expo-router"; +import React, { useCallback } from "react"; +import { Pressable } from "react-native"; +import { ms } from "react-native-size-matters"; +import { Spinner, XStack } from "tamagui"; +import { useSimulateContract, useWriteContract } from "wagmi"; + +import { auditorAbi, marketAbi } from "../../generated/contracts"; +import handleError from "../../utils/handleError"; +import queryClient from "../../utils/queryClient"; +import useIntercom from "../../utils/useIntercom"; +import useMarketAccount from "../../utils/useMarketAccount"; +import Button from "../shared/Button"; +import SafeView from "../shared/SafeView"; +import Text from "../shared/Text"; +import View from "../shared/View"; + +export default function PaymentSheetContent({ maturity, onClose }: { maturity: string; onClose: () => void }) { + const toast = useToastController(); + const { presentArticle } = useIntercom(); + const { data: hidden } = useQuery({ queryKey: ["settings", "sensitive"] }); + const { market: USDCMarket, account, queryKey } = useMarketAccount(marketUSDCAddress); + + const fixedBorrowPositions = USDCMarket ? USDCMarket.fixedBorrowPositions : undefined; + const usdPrice = USDCMarket ? USDCMarket.usdPrice : 0n; + const decimals = USDCMarket ? USDCMarket.decimals : 0; + const borrow = fixedBorrowPositions?.find((p) => p.maturity === BigInt(maturity)); + + const previewValue = borrow ? (borrow.previewValue * usdPrice) / 10n ** BigInt(decimals) : 0n; + const positionValue = + (((borrow?.position.principal ?? 0n) + (borrow?.position.fee ?? 0n)) * usdPrice) / 10n ** BigInt(decimals); + const discount = Number(WAD - (previewValue * WAD) / positionValue) / 1e18; + const followingMaturity = Number(maturity) + MATURITY_INTERVAL; + + const repayMaturity = BigInt(maturity); + const borrowMaturity = BigInt(followingMaturity); + const maxRepayAssets = positionValue; + const maxBorrowAssets = positionValue; + const percentage = WAD; + + const { + data: rolloverSimulation, + isPending: isSimulatingRollover, + error: rolloverSimulationError, + } = useSimulateContract({ + abi: [...exaPluginAbi, ...upgradeableModularAccountAbi, ...auditorAbi, ...marketAbi], + args: [repayMaturity, borrowMaturity, maxRepayAssets, maxBorrowAssets, percentage], + query: { retry: false, enabled: maxRepayAssets !== 0n && maxBorrowAssets !== 0n }, // TODO remove + functionName: "rollDebt", + address: account, + account, + }); + + const { writeContract: rollover, isPending: isRollingDebt } = useWriteContract({ + mutation: { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }).catch(handleError); + toast.show("Success", { customData: { type: "success" } }); + }, + onError: (error) => { + if (error.name === "ContractFunctionExecutionError" && error.details === "AA23 reverted (or OOG)") { + toast.show("Cancelled", { customData: { type: "error" } }); + return; + } + toast.show("An error ocurred", { customData: { type: "error" } }); + }, + }, + }); + + const rollDebt = useCallback(() => { + if (!rolloverSimulation || rolloverSimulationError || isRollingDebt) throw new Error("no rollover simulation"); + rollover(rolloverSimulation.request); + }, [isRollingDebt, rollover, rolloverSimulation, rolloverSimulationError]); + + return ( + + + <> + + + + {isAfter(new Date(Number(maturity) * 1000), new Date()) + ? `Due in ${formatDistance(new Date(), new Date(Number(maturity) * 1000))}` + : `${formatDistance(new Date(Number(maturity) * 1000), new Date())} past due`} + +  - {format(new Date(Number(maturity) * 1000), "MMM dd, yyyy")} + + + { + presentArticle("10245778").catch(handleError); + }} + hitSlop={ms(15)} + > + + + + + + {(Number(previewValue) / 1e18).toLocaleString(undefined, { + style: "currency", + currency: "USD", + currencyDisplay: "narrowSymbol", + })} + + {discount >= 0 && ( + + {(Number(positionValue) / 1e18).toLocaleString(undefined, { + style: "currency", + currency: "USD", + currencyDisplay: "narrowSymbol", + })} + + )} + {!hidden && ( + = 0 ? "$interactiveBaseSuccessSoftDefault" : "$interactiveBaseErrorSoftDefault" + } + color={discount >= 0 ? "$uiSuccessSecondary" : "$uiErrorSecondary"} + > + {discount >= 0 ? "PAY NOW AND SAVE " : "DAILY PENALTIES "} + {(discount >= 0 ? discount : discount * -1).toLocaleString(undefined, { + style: "percent", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + + )} + + + + + + + + + + ); +} + +const outlined = { + hoverStyle: { backgroundColor: "$interactiveBaseBrandSoftHover" }, + pressStyle: { + backgroundColor: "$interactiveBaseBrandSoftPressed", + color: "$interactiveOnBaseBrandSoft", + }, +}; diff --git a/src/components/shared/ProfileHeader.tsx b/src/components/shared/ProfileHeader.tsx index ab50fc59..28842492 100644 --- a/src/components/shared/ProfileHeader.tsx +++ b/src/components/shared/ProfileHeader.tsx @@ -47,7 +47,7 @@ export default function ProfileHeader() { function copy() { if (!address) return; setStringAsync(address).catch(handleError); - toast.show("Account address copied!"); + toast.show("Account address copied!", { customData: { type: "success" } }); setAlertShown(false); } return ( diff --git a/src/components/shared/Toast.tsx b/src/components/shared/Toast.tsx index c63c41d2..8cd10fc1 100644 --- a/src/components/shared/Toast.tsx +++ b/src/components/shared/Toast.tsx @@ -1,4 +1,4 @@ -import { Info } from "@tamagui/lucide-icons"; +import { Info as InfoIcon } from "@tamagui/lucide-icons"; import { Toast, useToastState } from "@tamagui/toast"; import React from "react"; import { ms } from "react-native-size-matters"; @@ -9,6 +9,7 @@ import View from "./View"; export default function NotificationToast() { const toast = useToastState(); + const type = toast?.customData?.type as "info" | "success" | "error" | undefined; if (!toast || toast.isHandledNatively) return null; return ( - + - + {toast.title}