From 7ae69db34fff7d33300978d615b6e71ed0f003d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sehyun=20Chung=20=E2=9C=8C=EF=B8=8E?= <41171808+sehyunc@users.noreply.github.com> Date: Fri, 8 Mar 2024 16:03:17 -0800 Subject: [PATCH] trade: Implement Permit2 --- .../[base]/[quote]/deposit-button.tsx | 120 +++++++++++------- .../components/panels/wallets-panel.tsx | 54 ++++---- trade.renegade.fi/env.mjs | 2 + trade.renegade.fi/generated.ts | 6 +- trade.renegade.fi/lib/permit2.ts | 118 +++++++++++++++++ trade.renegade.fi/lib/utils.ts | 20 ++- 6 files changed, 246 insertions(+), 74 deletions(-) create mode 100644 trade.renegade.fi/lib/permit2.ts diff --git a/trade.renegade.fi/app/(desktop)/[base]/[quote]/deposit-button.tsx b/trade.renegade.fi/app/(desktop)/[base]/[quote]/deposit-button.tsx index c6c99c63..230a4f9a 100644 --- a/trade.renegade.fi/app/(desktop)/[base]/[quote]/deposit-button.tsx +++ b/trade.renegade.fi/app/(desktop)/[base]/[quote]/deposit-button.tsx @@ -1,23 +1,27 @@ -import { ArrowForwardIcon } from "@chakra-ui/icons" -import { Button, useDisclosure } from "@chakra-ui/react" -import { Token } from "@renegade-fi/renegade-js" -import { useQueryClient } from '@tanstack/react-query' -import { useEffect } from 'react' -import { toast } from "sonner" -import { formatUnits, parseUnits } from "viem" -import { useAccount, useBlockNumber } from 'wagmi' - -import { renegade } from "@/app/providers" -import { CreateStepper } from "@/components/steppers/create-stepper/create-stepper" import { useDeposit } from "@/contexts/Deposit/deposit-context" import { useRenegade } from "@/contexts/Renegade/renegade-context" import { env } from "@/env.mjs" import { useReadErc20Allowance, useReadErc20BalanceOf, - useWriteErc20Approve + useWriteErc20Approve, } from "@/generated" +import { ArrowForwardIcon } from "@chakra-ui/icons" +import { Button, useDisclosure } from "@chakra-ui/react" +import { Token } from "@renegade-fi/renegade-js" +import { useQueryClient } from "@tanstack/react-query" +import { useEffect } from "react" +import { toast } from "sonner" +import { + parseUnits +} from "viem" +import { useAccount, useBlockNumber, useWalletClient } from "wagmi" + +import { renegade } from "@/app/providers" +import { CreateStepper } from "@/components/steppers/create-stepper/create-stepper" import { useButton } from "@/hooks/use-button" +import { stylusDevnetEc2 } from "@/lib/chain" +import { signPermit2 } from "@/lib/permit2" const MAX_INT = BigInt( "115792089237316195423570985008687907853269984665640564039457584007913129639935" @@ -40,19 +44,24 @@ export default function DepositButton() { const { address } = useAccount() // Get L1 ERC20 balance - const { data: l1Balance, queryKey: l1BalanceQueryKey } = useReadErc20BalanceOf({ - address: Token.findAddressByTicker(baseTicker) as `0x${string}`, - args: [address ?? "0x"], - }) + const { data: l1Balance, queryKey: l1BalanceQueryKey } = + useReadErc20BalanceOf({ + address: Token.findAddressByTicker(baseTicker) as `0x${string}`, + args: [address ?? "0x"], + }) // TODO: Adjust decimals - console.log("Balance on L1: ", formatUnits(l1Balance ?? BigInt(0), 18)) + // console.log("Balance on L1: ", formatUnits(l1Balance ?? BigInt(0), 18)) // Get L1 ERC20 Allowance - const { data: allowance, queryKey: allowanceQueryKey } = useReadErc20Allowance({ - address: Token.findAddressByTicker(baseTicker) as `0x${string}`, - args: [address ?? "0x", env.NEXT_PUBLIC_DARKPOOL_CONTRACT as `0x${string}`], - }) - console.log(`${baseTicker} allowance: `, allowance) + const { data: allowance, queryKey: allowanceQueryKey } = + useReadErc20Allowance({ + address: Token.findAddressByTicker(baseTicker) as `0x${string}`, + args: [ + address ?? "0x", + env.NEXT_PUBLIC_PERMIT2_CONTRACT as `0x${string}`, + ], + }) + // console.log(`${baseTicker} allowance on Permit2 contract: `, allowance) const queryClient = useQueryClient() const { data: blockNumber } = useBlockNumber({ watch: true }) @@ -61,22 +70,16 @@ export default function DepositButton() { queryClient.invalidateQueries({ queryKey: allowanceQueryKey }) }, [allowanceQueryKey, blockNumber, l1BalanceQueryKey, queryClient]) - // L1 ERC20 Approval - const { writeContract: approve, status: approveStatus } = useWriteErc20Approve() + const { writeContract: approve, status: approveStatus } = + useWriteErc20Approve() + + const { data: walletClient } = useWalletClient() const hasRpcConnectionError = typeof allowance === "undefined" - console.log( - "🚀 ~ DepositButton ~ hasRpcConnectionError:", - hasRpcConnectionError - ) const hasInsufficientBalance = l1Balance ? l1Balance < parseUnits(baseTokenAmount, 18) : false - console.log( - "🚀 ~ DepositButton ~ hasInsufficientBalance:", - hasInsufficientBalance - ) const needsApproval = allowance === BigInt(0) && approveStatus !== "success" const isDisabled = @@ -88,10 +91,45 @@ export default function DepositButton() { if (!accountId) return approve({ address: Token.findAddressByTicker(baseTicker) as `0x${string}`, - args: [env.NEXT_PUBLIC_DARKPOOL_CONTRACT as `0x${string}`, MAX_INT], + args: [env.NEXT_PUBLIC_PERMIT2_CONTRACT as `0x${string}`, MAX_INT], + }) + } + + const handleSignAndDeposit = async () => { + if (!accountId || !walletClient) return + const token = new Token({ address: Token.findAddressByTicker(baseTicker) }) + const amount = parseUnits(baseTokenAmount, 18) + + // Generate Permit2 Signature + const { signature, nonce, deadline } = await signPermit2({ + amount, + chainId: stylusDevnetEc2.id, + spender: env.NEXT_PUBLIC_DARKPOOL_CONTRACT as `0x${string}`, + permit2Address: env.NEXT_PUBLIC_PERMIT2_CONTRACT as `0x${string}`, + token, + walletClient, }) + + // Deposit + await renegade.task + .deposit( + accountId, + token, + amount, + walletClient.account.address, + nonce, + deadline, + signature + ) + .then(() => + toast.message(`Started to deposit ${baseTokenAmount} ${baseTicker}`, { + description: "Check the history tab for the status of the task", + }) + ) + .catch((error) => toast.error(`Error depositing: ${error}`)) } + const handleClick = async () => { if (shouldUse) { buttonOnClick() @@ -100,21 +138,9 @@ export default function DepositButton() { } else if (needsApproval) { handleApprove() } else { - if (!accountId || !address) return - await renegade.task - .deposit( - accountId, - new Token({ address: Token.findAddressByTicker(baseTicker) }), - BigInt(baseTokenAmount), - address - ) - .then(() => - toast.message(`Started to deposit ${baseTokenAmount} ${baseTicker}`, { - description: "Check the history tab for the status of the task", - }) - ) - .catch((error) => toast.error(`Error depositing: ${error}`)) + handleSignAndDeposit() } + } return ( diff --git a/trade.renegade.fi/components/panels/wallets-panel.tsx b/trade.renegade.fi/components/panels/wallets-panel.tsx index d3ed2334..e62aae35 100644 --- a/trade.renegade.fi/components/panels/wallets-panel.tsx +++ b/trade.renegade.fi/components/panels/wallets-panel.tsx @@ -1,5 +1,8 @@ "use client" +import { useMemo } from "react" +import Image from "next/image" +import { useRouter } from "next/navigation" import { ViewEnum, useApp } from "@/contexts/App/app-context" import { useRenegade } from "@/contexts/Renegade/renegade-context" import { TaskState } from "@/contexts/Renegade/types" @@ -13,26 +16,24 @@ import { import { Box, Button, Flex, Spacer, Spinner, Text } from "@chakra-ui/react" import { Token, tokenMappings } from "@renegade-fi/renegade-js" import { useModal as useModalConnectKit } from "connectkit" -import Image from "next/image" -import { useRouter } from "next/navigation" -import { useMemo } from "react" import SimpleBar from "simplebar-react" import { useAccount, useAccount as useAccountWagmi } from "wagmi" -import { ConnectWalletButton, SignInButton } from "@/app/(desktop)/main-nav" -import { Panel, expandedPanelWidth } from "@/components/panels/panels" +import { formatAmount } from "@/lib/utils" import { useBalance } from "@/hooks/use-balance" +import { useTasks } from "@/hooks/use-tasks" import { useUSDPrice } from "@/hooks/use-usd-price" - +import { Panel, expandedPanelWidth } from "@/components/panels/panels" +import { ConnectWalletButton, SignInButton } from "@/app/(desktop)/main-nav" import { renegade } from "@/app/providers" -import { useTasks } from "@/hooks/use-tasks" + import "simplebar-react/dist/simplebar.min.css" import { toast } from "sonner" interface TokenBalanceProps { tokenAddr: string userAddr?: string - amount: bigint + amount: string } function TokenBalance(props: TokenBalanceProps) { const { tokenIcons } = useApp() @@ -44,7 +45,7 @@ function TokenBalance(props: TokenBalanceProps) { const ticker = Token.findTickerByAddress(`${props.tokenAddr}`) const usdPrice = useUSDPrice(ticker, Number(props.amount)) - const isZero = props.amount === BigInt(0) + const isZero = props.amount === "0" return ( - toast.message(`Started to withdraw 1 ${Token.findTickerByAddress(props.tokenAddr)}`, { - description: "Check the history tab for the status of the task", - }) + toast.message( + `Started to withdraw 1 ${Token.findTickerByAddress( + props.tokenAddr + )}`, + { + description: + "Check the history tab for the status of the task", + } + ) ) .catch((error) => toast.error(`Error withdrawing: ${error}`)) } @@ -179,19 +186,20 @@ function RenegadeWalletPanel(props: RenegadeWalletPanelProps) { const balances = useBalance() const { accountId } = useRenegade() - const formattedBalances = useMemo>(() => { - const wethAddress = Token.findAddressByTicker("WETH").replace("0x", "") - const usdcAddress = Token.findAddressByTicker("USDC").replace("0x", "") - - const nonzero: Array<[string, bigint]> = Object.entries(balances).map( - ([_, b]) => [b.mint.address, b.amount] - ) - const placeholders: Array<[string, bigint]> = tokenMappings.tokens + const formattedBalances = useMemo(() => { + const nonzero = Object.entries(balances).map(([_, b]) => [ + b.mint.address, + formatAmount(b.amount, new Token({ address: b.mint.address })), + ]) + const placeholders = tokenMappings.tokens .filter((t) => !nonzero.some(([a]) => `0x${a}` === t.address)) - .map((t) => [t.address.replace("0x", ""), BigInt(0)]) + .map((t) => [t.address.replace("0x", ""), "0"]) const combined = [...nonzero, ...placeholders] + const wethAddress = Token.findAddressByTicker("WETH").replace("0x", "") + const usdcAddress = Token.findAddressByTicker("USDC").replace("0x", "") + combined.sort((a, b) => { if (a[0] === wethAddress) return -1 if (b[0] === wethAddress) return 1 @@ -261,8 +269,8 @@ function RenegadeWalletPanel(props: RenegadeWalletPanelProps) { {accountId ? "Deposit tokens into your Renegade Account to get started." : address - ? "Sign in to create a Renegade account and view your balances." - : "Connect your Ethereum wallet before signing in."} + ? "Sign in to create a Renegade account and view your balances." + : "Connect your Ethereum wallet before signing in."} ) diff --git a/trade.renegade.fi/env.mjs b/trade.renegade.fi/env.mjs index d5ef270e..81c14626 100644 --- a/trade.renegade.fi/env.mjs +++ b/trade.renegade.fi/env.mjs @@ -8,6 +8,7 @@ export const env = createEnv({ NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID: z.string().min(1), NEXT_PUBLIC_INTERCOM_APP_ID: z.string().min(1), NEXT_PUBLIC_DARKPOOL_CONTRACT: z.string().min(1), + NEXT_PUBLIC_PERMIT2_CONTRACT: z.string().nonempty() }, // For Next.js >= 13.4.4, you only need to destructure client variables: experimental__runtimeEnv: { @@ -17,5 +18,6 @@ export const env = createEnv({ process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID, NEXT_PUBLIC_INTERCOM_APP_ID: process.env.NEXT_PUBLIC_INTERCOM_APP_ID, NEXT_PUBLIC_DARKPOOL_CONTRACT: process.env.NEXT_PUBLIC_DARKPOOL_CONTRACT, + NEXT_PUBLIC_PERMIT2_CONTRACT: process.env.NEXT_PUBLIC_PERMIT2_CONTRACT, }, }) diff --git a/trade.renegade.fi/generated.ts b/trade.renegade.fi/generated.ts index 786a1586..e5bf9b8d 100644 --- a/trade.renegade.fi/generated.ts +++ b/trade.renegade.fi/generated.ts @@ -117,6 +117,6 @@ export const useWatchErc20Event = /*#__PURE__*/ createUseWatchContractEvent({ */ export const useWatchErc20ApprovalEvent = /*#__PURE__*/ createUseWatchContractEvent({ - abi: erc20Abi, - eventName: 'Approval', -}) + abi: erc20Abi, + eventName: 'Approval', + }) diff --git a/trade.renegade.fi/lib/permit2.ts b/trade.renegade.fi/lib/permit2.ts new file mode 100644 index 00000000..3e547d92 --- /dev/null +++ b/trade.renegade.fi/lib/permit2.ts @@ -0,0 +1,118 @@ +import { Token } from "@renegade-fi/renegade-js" +import { + Address, + TypedDataDomain, + WalletClient, + hashTypedData, + verifyTypedData, +} from "viem" +import { publicKeyToAddress, recoverPublicKey } from "viem/utils" + +export function millisecondsToSeconds(milliseconds: number): number { + return Math.floor(milliseconds / 1000) +} + +const TOKEN_PERMISSIONS = [ + { name: "token", type: "address" }, + { name: "amount", type: "uint256" }, +] + +const PERMIT_TRANSFER_FROM_TYPES = { + PermitTransferFrom: [ + { name: "permitted", type: "TokenPermissions" }, + { name: "spender", type: "address" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" }, + ], + TokenPermissions: TOKEN_PERMISSIONS, +} + +/** + * Signs a permit allowing a specified spender to transfer a specified amount of tokens from the signer's account. + * This function constructs a domain and message for the permit, signs it using the wallet client, and verifies the signature. + * It also ensures the public key recovered from the signature matches the wallet's public key. + * @param {bigint} amount - The decimal-adjusted amount of tokens to permit the spender to transfer. + * @param {number} chainId - The chain ID of the network. + * @param {string} spender - The address of the spender who is permitted to transfer the tokens. + * @param {string} permit2Address - The address of the deployed Permit2 contract. + * @param {Token} token - The token object containing the address of the token to be transferred. + * @param {WalletClient} walletClient - The wallet client used to sign the permit. + * + * @returns {Promise<{signature: string, nonce: bigint, deadline: bigint}>} An object containing the signature, nonce, and deadline of the permit. + * + * @throws {Error} Throws an error if the wallet client's account address is not found, the signature is invalid, or the recovered public key does not match the wallet's public key. + */ +export async function signPermit2({ + amount, + chainId, + spender, + permit2Address, + token, + walletClient, +}: { + amount: bigint + chainId: number + spender: Address + permit2Address: Address + token: Token + walletClient: WalletClient +}) { + if (!walletClient.account) + throw new Error("Address not found on wallet client") + + // Construct Domain + const domain: TypedDataDomain = { + name: "Permit2", + chainId, + verifyingContract: permit2Address as `0x${string}`, + } + + // Construct Message + const message = { + permitted: { + token: `0x${token.address}` as `0x${string}`, + amount + }, + spender, + nonce: BigInt(Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)), + deadline: BigInt(millisecondsToSeconds(Date.now() + 1000 * 60 * 30)), + } as const + + // Generate signature + const signature = await walletClient.signTypedData({ + account: walletClient.account.address, + domain, + types: PERMIT_TRANSFER_FROM_TYPES, + primaryType: "PermitTransferFrom", + message, + }) + + // Verify signature + const valid = await verifyTypedData({ + address: walletClient.account.address, + domain, + types: PERMIT_TRANSFER_FROM_TYPES, + primaryType: "PermitTransferFrom", + message, + signature, + }) + if (!valid) throw new Error("Invalid signature") + + // Ensure correct public key is recovered + const hash = hashTypedData({ + domain, + types: PERMIT_TRANSFER_FROM_TYPES, + primaryType: "PermitTransferFrom", + message, + }) + const recoveredPubKey = publicKeyToAddress( + await recoverPublicKey({ + hash, + signature, + }) + ) + if (recoveredPubKey !== walletClient.account.address) + throw new Error("Recovered public key does not match wallet public key") + + return { signature, nonce: message.nonce, deadline: message.deadline } +} diff --git a/trade.renegade.fi/lib/utils.ts b/trade.renegade.fi/lib/utils.ts index 3ddf327a..d19d9d88 100644 --- a/trade.renegade.fi/lib/utils.ts +++ b/trade.renegade.fi/lib/utils.ts @@ -1,5 +1,5 @@ import { Balance, BalanceId, Renegade, Token } from "@renegade-fi/renegade-js" -import { isAddress } from "viem" +import { formatUnits, isAddress, parseUnits } from "viem" import { DISPLAYED_TICKERS } from "@/lib/tokens" @@ -63,3 +63,21 @@ export function getToken({ return new Token({ ticker: input }) } } + +export function formatAmount(amount: bigint, token: Token) { + const decimals = token.decimals + if (!decimals) throw new Error(`Decimals not found for 0x${token.address}`) + let formatted = formatUnits(amount, decimals) + if (formatted.includes(".")) { + const [integerPart, decimalPart] = formatted.split(".") + formatted = `${integerPart}.${decimalPart.substring(0, 2)}` + } + return formatted +} + +export function parseAmount(amount: string, token: Token) { + const decimals = token.decimals + if (!decimals) throw new Error(`Decimals not found for 0x${token.address}`) + // TODO: Should try to fetch decimals from on chain + return parseUnits(amount, decimals) +}