diff --git a/libs/defi/oeth/src/redeem/components/RedeemActionCard.tsx b/libs/defi/oeth/src/redeem/components/RedeemActionCard.tsx index 621947001..23d9dd023 100644 --- a/libs/defi/oeth/src/redeem/components/RedeemActionCard.tsx +++ b/libs/defi/oeth/src/redeem/components/RedeemActionCard.tsx @@ -1,7 +1,12 @@ import { alpha, Card, Stack, SvgIcon, Typography } from '@mui/material'; import { ValueLabel } from '@origin/shared/components'; import { OETH } from '@origin/shared/icons'; -import { routeEq, useSwapState } from '@origin/shared/providers'; +import { + routeEq, + useHandleSelectSwapRoute, + useIsSwapRouteAvailable, + useSwapState, +} from '@origin/shared/providers'; import { isNilOrEmpty } from '@origin/shared/utils'; import { useIntl } from 'react-intl'; @@ -29,15 +34,23 @@ export const RedeemActionCard = ({ selectedSwapRoute, swapRoutes, swapActions, + estimatedSwapRoutes, }, ] = useSwapState(); + const handleSelectSwapRoute = useHandleSelectSwapRoute(); + const route = swapRoutes.find((r) => routeEq({ tokenIn, tokenOut, action }, r), ) as SwapRoute; + const { data: isRouteAvailable, isLoading: isRouteAvailableLoading } = + useIsSwapRouteAvailable(route); + const estimatedRoute = estimatedSwapRoutes.find((r) => routeEq(route, r)); const isSelected = routeEq({ tokenIn, tokenOut, action }, selectedSwapRoute); const isComingSoon = (route as SwapRoute)?.meta?.comingSoon ?? false; const routeLabel = swapActions[action].routeLabel; + const isDisabled = + !isRouteAvailable || isRouteAvailableLoading || isComingSoon; return ( 0n && - !isComingSoon && { - cursor: 'pointer', - '&:hover': { - borderColor: 'primary.main', - }, - }), - ...(isSelected && { - borderColor: 'primary.main', - }), + ...(isDisabled + ? { opacity: 0.5, cursor: 'default' } + : isSelected + ? { + borderColor: 'primary.main', + backgroundColor: 'background.highlight', + } + : amountIn > 0n + ? { + cursor: 'pointer', + '&:hover': { + borderColor: 'primary.main', + }, + } + : {}), ...rest?.sx, }} + role="button" + onClick={() => { + if (!isDisabled && estimatedRoute && amountIn > 0n) { + handleSelectSwapRoute(estimatedRoute); + } + }} > {isComingSoon && ( [] = [ meta: { icon: ARM, waitTime: defineMessage({ defaultMessage: '~1 min' }), - comingSoon: true, }, }, { diff --git a/libs/shared/contracts/src/abis/ARM.ts b/libs/shared/contracts/src/abis/ARM.ts new file mode 100644 index 000000000..582c3893f --- /dev/null +++ b/libs/shared/contracts/src/abis/ARM.ts @@ -0,0 +1,191 @@ +export const ARMABI = [ + { + inputs: [ + { internalType: 'address', name: '_oeth', type: 'address' }, + { internalType: 'address', name: '_weth', type: 'address' }, + { internalType: 'address', name: '_oethVault', type: 'address' }, + ], + stateMutability: 'nonpayable', + type: 'constructor', + }, + { + anonymous: false, + inputs: [ + { + indexed: false, + internalType: 'address', + name: 'previousAdmin', + type: 'address', + }, + { + indexed: false, + internalType: 'address', + name: 'newAdmin', + type: 'address', + }, + ], + name: 'AdminChanged', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: false, + internalType: 'address', + name: 'previousAdmin', + type: 'address', + }, + { + indexed: false, + internalType: 'address', + name: 'newAdmin', + type: 'address', + }, + ], + name: 'OperatorChanged', + type: 'event', + }, + { + inputs: [{ internalType: 'uint256', name: 'requestId', type: 'uint256' }], + name: 'claimWithdrawal', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'uint256[]', name: 'requestIds', type: 'uint256[]' }, + ], + name: 'claimWithdrawals', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'oethVault', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'operator', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'owner', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'uint256', name: 'amount', type: 'uint256' }], + name: 'requestWithdrawal', + outputs: [ + { internalType: 'uint256', name: 'requestId', type: 'uint256' }, + { internalType: 'uint256', name: 'queued', type: 'uint256' }, + ], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'newOperator', type: 'address' }], + name: 'setOperator', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'newOwner', type: 'address' }], + name: 'setOwner', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'uint256', name: 'amountIn', type: 'uint256' }, + { internalType: 'uint256', name: 'amountOutMin', type: 'uint256' }, + { internalType: 'address[]', name: 'path', type: 'address[]' }, + { internalType: 'address', name: 'to', type: 'address' }, + { internalType: 'uint256', name: 'deadline', type: 'uint256' }, + ], + name: 'swapExactTokensForTokens', + outputs: [ + { internalType: 'uint256[]', name: 'amounts', type: 'uint256[]' }, + ], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'contract IERC20', name: 'inToken', type: 'address' }, + { internalType: 'contract IERC20', name: 'outToken', type: 'address' }, + { internalType: 'uint256', name: 'amountIn', type: 'uint256' }, + { internalType: 'uint256', name: 'amountOutMin', type: 'uint256' }, + { internalType: 'address', name: 'to', type: 'address' }, + ], + name: 'swapExactTokensForTokens', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'uint256', name: 'amountOut', type: 'uint256' }, + { internalType: 'uint256', name: 'amountInMax', type: 'uint256' }, + { internalType: 'address[]', name: 'path', type: 'address[]' }, + { internalType: 'address', name: 'to', type: 'address' }, + { internalType: 'uint256', name: 'deadline', type: 'uint256' }, + ], + name: 'swapTokensForExactTokens', + outputs: [ + { internalType: 'uint256[]', name: 'amounts', type: 'uint256[]' }, + ], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'contract IERC20', name: 'inToken', type: 'address' }, + { internalType: 'contract IERC20', name: 'outToken', type: 'address' }, + { internalType: 'uint256', name: 'amountOut', type: 'uint256' }, + { internalType: 'uint256', name: 'amountInMax', type: 'uint256' }, + { internalType: 'address', name: 'to', type: 'address' }, + ], + name: 'swapTokensForExactTokens', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'token0', + outputs: [{ internalType: 'contract IERC20', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'token1', + outputs: [{ internalType: 'contract IERC20', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'token', type: 'address' }, + { internalType: 'address', name: 'to', type: 'address' }, + { internalType: 'uint256', name: 'amount', type: 'uint256' }, + ], + name: 'transferToken', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, +] as const; diff --git a/libs/shared/contracts/src/contracts.ts b/libs/shared/contracts/src/contracts.ts index 52dcc752b..b3557de90 100644 --- a/libs/shared/contracts/src/contracts.ts +++ b/libs/shared/contracts/src/contracts.ts @@ -1,5 +1,6 @@ import { arbitrum, mainnet } from 'wagmi/chains'; +import { ARMABI } from './abis/ARM'; import { CCIPEvm2EvmOnRamp } from './abis/CCIPEvm2EvmOnRamp'; import { CCIPRouterABI } from './abis/CCIPRouter'; import { ChainlinkOracleABI } from './abis/ChainlinkOracle'; @@ -33,6 +34,12 @@ import { xOGNGovernanceABI } from './abis/xOGNGovernance'; export const contracts = { mainnet: { + ARM: { + address: '0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D', // TODO update address when available + chainId: mainnet.id, + abi: ARMABI, + name: 'ARM', + }, // Chainlink CCIP ccipRouter: { address: '0x80226fc0Ee2b096224EeAc085Bb9a8cba1146f7D', @@ -93,6 +100,7 @@ export const contracts = { name: 'lrtConfig', }, // OETH + OETHCurvePool: { address: '0x94B17476A93b3262d87B9a326965D1E91f9c13E7', chainId: mainnet.id, diff --git a/libs/shared/contracts/src/whales.ts b/libs/shared/contracts/src/whales.ts index e39df71e2..ff8845448 100644 --- a/libs/shared/contracts/src/whales.ts +++ b/libs/shared/contracts/src/whales.ts @@ -7,5 +7,6 @@ export const whales = { wOETH: '0xC460B0b6c9b578A4Cb93F99A691e16dB96Ee5833', OUSD: '0x70fCE97d671E81080CA3ab4cc7A59aAc2E117137', wOUSD: '0x3dD413Fd4D03b1d8fD2C9Ed34553F7DeC3B26F5C', + WETH: '0xF04a5cC80B1E94C69B48f5ee68a08CD2F09A7c3E', }, } as const; diff --git a/libs/shared/routes/src/oeth/redeemArmOeth.ts b/libs/shared/routes/src/oeth/redeemArmOeth.ts index c7868ec93..0d703eeb9 100644 --- a/libs/shared/routes/src/oeth/redeemArmOeth.ts +++ b/libs/shared/routes/src/oeth/redeemArmOeth.ts @@ -1,5 +1,20 @@ -import { formatUnits, maxUint256 } from 'viem'; +import { contracts, tokens, whales } from '@origin/shared/contracts'; +import { simulateContractWithTxTracker } from '@origin/shared/providers'; +import { + isNilOrEmpty, + subPercentage, + ZERO_ADDRESS, +} from '@origin/shared/utils'; +import { + getAccount, + getPublicClient, + readContract, + simulateContract, + writeContract, +} from '@wagmi/core'; +import { erc20Abi, formatUnits } from 'viem'; +import { GAS_BUFFER } from '../constants'; import { defaultRoute } from '../defaultRoute'; import type { @@ -13,10 +28,19 @@ import type { } from '@origin/shared/providers'; import type { EstimateAmount } from '@origin/shared/providers'; -const isRouteAvailable: IsRouteAvailable = async ( - config, - { tokenIn, amountIn }, -) => { +const isRouteAvailable: IsRouteAvailable = async ({ config }, { amountIn }) => { + try { + const wethBalance = await readContract(config, { + address: tokens.mainnet.WETH.address, + abi: tokens.mainnet.WETH.abi, + functionName: 'balanceOf', + args: [contracts.mainnet.ARM.address], + chainId: tokens.mainnet.WETH.chainId, + }); + + return wethBalance >= amountIn; + } catch {} + return false; }; @@ -24,19 +48,94 @@ const estimateAmount: EstimateAmount = async (config, { amountIn }) => { return amountIn; }; -const estimateGas: EstimateGas = async (config, { tokenIn, amountIn }) => { - return 0n; +const estimateGas: EstimateGas = async ( + { config }, + { tokenIn, tokenOut, amountIn, amountOut, slippage }, +) => { + const { address } = getAccount(config); + const publicClient = getPublicClient(config, { + chainId: contracts.mainnet.ARM.chainId, + }); + + if ( + amountIn === 0n || + !publicClient || + !tokenIn?.address || + !tokenOut?.address + ) { + return 0n; + } + + const minAmountOut = subPercentage( + [amountOut ?? 0n, tokenOut.decimals], + slippage, + ); + + let requestGasEstimate = 0n; + try { + requestGasEstimate = await publicClient.estimateContractGas({ + address: contracts.mainnet.ARM.address, + abi: contracts.mainnet.ARM.abi, + functionName: 'swapExactTokensForTokens', + args: [ + tokenIn.address, + tokenOut.address, + amountIn, + minAmountOut[0], + address ?? ZERO_ADDRESS, + ], + account: whales.mainnet.OETH, + }); + } catch { + requestGasEstimate = 161_000n; + } + + return requestGasEstimate; }; -const allowance: Allowance = async (config, { tokenIn }) => { - return maxUint256; +const allowance: Allowance = async ({ config }, { tokenIn }) => { + const { address } = getAccount(config); + + if (!address || !tokenIn?.address) { + return 0n; + } + + const allowance = await readContract(config, { + address: tokenIn.address, + abi: erc20Abi, + functionName: 'allowance', + args: [address, contracts.mainnet.ARM.address], + chainId: tokenIn.chainId, + }); + + return allowance; }; const estimateApprovalGas: EstimateApprovalGas = async ( - config, + { config }, { tokenIn, amountIn }, ) => { - return 0n; + let approvalEstimate = 0n; + const { address } = getAccount(config); + const publicClient = getPublicClient(config, { chainId: tokenIn.chainId }); + + if (amountIn === 0n || !address || !tokenIn?.address || !publicClient) { + return approvalEstimate; + } + + try { + approvalEstimate = await publicClient.estimateContractGas({ + address: tokenIn.address, + abi: erc20Abi, + functionName: 'approve', + args: [contracts.mainnet.ARM.address, amountIn], + account: address, + }); + } catch { + approvalEstimate = 200000n; + } + + return approvalEstimate; }; const estimateRoute: EstimateRoute = async ( @@ -54,18 +153,18 @@ const estimateRoute: EstimateRoute = async ( }; } - const [estimatedAmount, allowanceAmount, approvalGas, gas] = - await Promise.all([ - estimateAmount(config, { tokenIn, tokenOut, amountIn }), - allowance(config, { tokenIn, tokenOut }), - estimateApprovalGas(config, { amountIn, tokenIn, tokenOut }), - estimateGas(config, { - tokenIn, - tokenOut, - amountIn, - slippage, - }), - ]); + const [estimatedAmount, allowanceAmount, approvalGas] = await Promise.all([ + estimateAmount(config, { tokenIn, tokenOut, amountIn }), + allowance(config, { tokenIn, tokenOut }), + estimateApprovalGas(config, { amountIn, tokenIn, tokenOut }), + ]); + const gas = await estimateGas(config, { + tokenIn, + tokenOut, + amountIn, + amountOut: estimatedAmount, + slippage, + }); return { ...route, @@ -79,15 +178,76 @@ const estimateRoute: EstimateRoute = async ( }; }; -const approve: Approve = async (config, { tokenIn, tokenOut, amountIn }) => { - return null; +const approve: Approve = async ({ config }, { tokenIn, amountIn }) => { + if (!tokenIn?.address) { + return null; + } + + const { request } = await simulateContract(config, { + address: tokenIn.address, + abi: erc20Abi, + functionName: 'approve', + args: [contracts.mainnet.ARM.address, amountIn], + chainId: tokenIn.chainId, + }); + const hash = await writeContract(config, request); + + return hash; }; const swap: Swap = async ( - config, + { config, queryClient }, { tokenIn, tokenOut, amountIn, slippage, amountOut }, ) => { - return null; + const { address } = getAccount(config); + + if (amountIn === 0n || isNilOrEmpty(address)) { + return null; + } + + const approved = await allowance( + { config, queryClient }, + { tokenIn, tokenOut }, + ); + + if (approved < amountIn) { + throw new Error(`ARM is not approved`); + } + + const minAmountOut = subPercentage( + [amountOut ?? 0n, tokenOut.decimals], + slippage, + ); + + const estimatedGas = await estimateGas( + { config, queryClient }, + { + amountIn, + slippage, + tokenIn, + tokenOut, + amountOut, + }, + ); + const gas = estimatedGas + (estimatedGas * GAS_BUFFER) / 100n; + + const { request } = await simulateContractWithTxTracker(config, { + address: contracts.mainnet.ARM.address, + abi: contracts.mainnet.ARM.abi, + functionName: 'swapExactTokensForTokens', + args: [ + tokenIn.address, + tokenOut.address, + amountIn, + minAmountOut[0], + address, + ], + gas, + chainId: contracts.mainnet.CurveRouter.chainId, + }); + const hash = await writeContract(config, request); + + return hash; }; export const redeemArmOeth = {