diff --git a/.changeset/five-berries-warn.md b/.changeset/five-berries-warn.md new file mode 100644 index 0000000000..08f1ba4f03 --- /dev/null +++ b/.changeset/five-berries-warn.md @@ -0,0 +1,5 @@ +--- +"@coinbase/onchainkit": patch +--- + +**feat**: add Permit2 approval process for UniversalRouter by @0xAlec #980 diff --git a/src/swap/components/SwapProvider.tsx b/src/swap/components/SwapProvider.tsx index b58e2d077e..66ee6d4d5b 100644 --- a/src/swap/components/SwapProvider.tsx +++ b/src/swap/components/SwapProvider.tsx @@ -172,6 +172,7 @@ export function SwapProvider({ sendTransactionAsync, onStart, onSuccess, + useAggregator, }); // TODO: refresh balances diff --git a/src/swap/constants.ts b/src/swap/constants.ts index 01cfe9500d..af8bc6fd55 100644 --- a/src/swap/constants.ts +++ b/src/swap/constants.ts @@ -2,7 +2,11 @@ export const GENERAL_SWAP_ERROR_CODE = 'SWAP_ERROR'; export const GENERAL_SWAP_QUOTE_ERROR_CODE = 'SWAP_QUOTE_ERROR'; export const GENERAL_SWAP_BALANCE_ERROR_CODE = 'SWAP_BALANCE_ERROR'; export const LOW_LIQUIDITY_ERROR_CODE = 'SWAP_QUOTE_LOW_LIQUIDITY_ERROR'; +export const PERMIT2_CONTRACT_ADDRESS = + '0x000000000022D473030F116dDEE9F6B43aC78BA3'; export const TOO_MANY_REQUESTS_ERROR_CODE = 'TOO_MANY_REQUESTS_ERROR'; export const UNCAUGHT_SWAP_QUOTE_ERROR_CODE = 'UNCAUGHT_SWAP_QUOTE_ERROR'; export const UNCAUGHT_SWAP_ERROR_CODE = 'UNCAUGHT_SWAP_ERROR'; +export const UNIVERSALROUTER_CONTRACT_ADDRESS = + '0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD'; export const USER_REJECTED_ERROR_CODE = 'USER_REJECTED'; diff --git a/src/swap/utils/processSwapTransaction.test.ts b/src/swap/utils/processSwapTransaction.test.ts index c3e2fa9bb1..318fee3a68 100644 --- a/src/swap/utils/processSwapTransaction.test.ts +++ b/src/swap/utils/processSwapTransaction.test.ts @@ -21,6 +21,11 @@ describe('processSwapTransaction', () => { .fn() .mockResolvedValueOnce('approveTxHash') .mockResolvedValueOnce('txHash'); + const sendTransactionAsyncPermit2 = vi + .fn() + .mockResolvedValueOnce('approveTxHash') + .mockResolvedValueOnce('permit2TxHash') + .mockResolvedValueOnce('txHash'); const onSuccess = vi.fn(); const onStart = vi.fn(); const onSuccessAsync = vi.fn().mockImplementation(async (_txHash: string) => { @@ -117,6 +122,7 @@ describe('processSwapTransaction', () => { sendTransactionAsync, onSuccess, onStart, + useAggregator: true, }); expect(setPendingTransaction).toHaveBeenCalledTimes(4); @@ -210,6 +216,7 @@ describe('processSwapTransaction', () => { sendTransactionAsync, onSuccess, onStart, + useAggregator: true, }); expect(setPendingTransaction).toHaveBeenCalledTimes(2); @@ -304,6 +311,7 @@ describe('processSwapTransaction', () => { sendTransactionAsync: sendTransactionAsync2, onSuccess: onSuccessAsync, onStart: onStartAsync, + useAggregator: true, }); expect(setPendingTransaction).toHaveBeenCalledTimes(4); @@ -320,4 +328,105 @@ describe('processSwapTransaction', () => { expect(onStartAsync).toHaveBeenNthCalledWith(1, 'approveTxHash'); expect(onStartAsync).toHaveBeenNthCalledWith(2, 'txHash'); }); + + it('should successfully use Permit2 approval process for `useAggregators`=false', async () => { + const swapTransaction: BuildSwapTransaction = { + transaction: { + to: '0x123', + value: 0n, + data: '0x', + chainId: 8453, + gas: 0n, + }, + approveTransaction: { + to: '0x456', + value: 0n, + data: '0x123', + chainId: 8453, + gas: 0n, + }, + quote: { + from: { + name: 'USDC', + address: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', + symbol: 'USDC', + decimals: 6, + image: + 'https://d3r81g40ycuhqg.cloudfront.net/wallet/wais/44/2b/442b80bd16af0c0d9b22e03a16753823fe826e5bfd457292b55fa0ba8c1ba213-ZWUzYjJmZGUtMDYxNy00NDcyLTg0NjQtMWI4OGEwYjBiODE2', + chainId: 8453, + }, + to: { + address: '0x4ed4e862860bed51a9570b96d89af5e1b0efefed', + chainId: 8453, + decimals: 18, + image: + 'https://d3r81g40ycuhqg.cloudfront.net/wallet/wais/3b/bf/3bbf118b5e6dc2f9e7fc607a6e7526647b4ba8f0bea87125f971446d57b296d2-MDNmNjY0MmEtNGFiZi00N2I0LWIwMTItMDUyMzg2ZDZhMWNm', + name: 'DEGEN', + symbol: 'DEGEN', + }, + fromAmount: '100000000000000', + toAmount: '19395353519910973703', + amountReference: 'from', + priceImpact: '0.94', + hasHighPriceImpact: false, + slippage: '3', + warning: undefined, + }, + fee: { + baseAsset: { + name: 'DEGEN', + address: '0x4ed4e862860bed51a9570b96d89af5e1b0efefed', + symbol: 'DEGEN', + decimals: 18, + image: + 'https://d3r81g40ycuhqg.cloudfront.net/wallet/wais/3b/bf/3bbf118b5e6dc2f9e7fc607a6e7526647b4ba8f0bea87125f971446d57b296d2-MDNmNjY0MmEtNGFiZi00N2I0LWIwMTItMDUyMzg2ZDZhMWNm', + chainId: 8453, + }, + percentage: '1', + amount: '195912661817282562', + }, + }; + const config = createConfig({ + chains: [mainnet, sepolia], + connectors: [ + mock({ + accounts: [ + '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', + '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC', + ], + }), + ], + transports: { + [mainnet.id]: http(), + [sepolia.id]: http(), + }, + }); + + await processSwapTransaction({ + swapTransaction, + config, + setPendingTransaction, + setLoading, + sendTransactionAsync: sendTransactionAsyncPermit2, + onSuccess, + onStart, + useAggregator: false, + }); + + expect(setPendingTransaction).toHaveBeenCalledTimes(6); + expect(setPendingTransaction).toHaveBeenCalledWith(true); + expect(setPendingTransaction).toHaveBeenCalledWith(false); + expect(sendTransactionAsyncPermit2).toHaveBeenCalledTimes(3); + expect(waitForTransactionReceipt).toHaveBeenCalledTimes(3); + + expect(setLoading).toHaveBeenCalledTimes(1); + expect(setLoading).toHaveBeenCalledWith(true); + expect(onSuccess).toHaveBeenCalledTimes(1); + expect(onSuccess).toHaveBeenCalledWith({}); + expect(onStart).toHaveBeenCalledTimes(3); + expect(onStart).toHaveBeenNthCalledWith(1, 'approveTxHash'); + expect(onStart).toHaveBeenNthCalledWith(2, 'permit2TxHash'); + expect(onStart).toHaveBeenNthCalledWith(3, 'txHash'); + }); }); diff --git a/src/swap/utils/processSwapTransaction.ts b/src/swap/utils/processSwapTransaction.ts index a069a038a0..fce440ca66 100644 --- a/src/swap/utils/processSwapTransaction.ts +++ b/src/swap/utils/processSwapTransaction.ts @@ -1,7 +1,12 @@ -import type { TransactionReceipt } from 'viem'; +import type { Address, TransactionReceipt } from 'viem'; +import { encodeFunctionData, parseAbi } from 'viem'; import type { Config } from 'wagmi'; import { waitForTransactionReceipt } from 'wagmi/actions'; import type { SendTransactionMutateAsync } from 'wagmi/query'; +import { + PERMIT2_CONTRACT_ADDRESS, + UNIVERSALROUTER_CONTRACT_ADDRESS, +} from '../constants'; import type { BuildSwapTransaction } from '../types'; export async function processSwapTransaction({ @@ -12,6 +17,7 @@ export async function processSwapTransaction({ sendTransactionAsync, onStart, onSuccess, + useAggregator, }: { swapTransaction: BuildSwapTransaction; config: Config; @@ -22,12 +28,15 @@ export async function processSwapTransaction({ onSuccess: | ((txReceipt: TransactionReceipt) => void | Promise) | undefined; + useAggregator: boolean; }) { - const { transaction, approveTransaction } = swapTransaction; + const { transaction, approveTransaction, quote } = swapTransaction; // for swaps from ERC-20 tokens, // if there is an approveTransaction present, // request approval for the amount + // for V1 API, `approveTx` will be an ERC-20 approval against the Router + // for V2 API, `approveTx` will be an ERC-20 approval against the `Permit2` contract if (approveTransaction?.data) { setPendingTransaction(true); const approveTxHash = await sendTransactionAsync({ @@ -41,6 +50,39 @@ export async function processSwapTransaction({ confirmations: 1, }); setPendingTransaction(false); + + // for the V2 API, we use Uniswap's `UniversalRouter`, which uses `Permit2` for ERC-20 approvals + // this adds an additional transaction/step to the swap process + // since we need to make an extra transaction to `Permit2` to allow the UniversalRouter to spend the approved funds + // this would typically be a (gasless) signature, but we're using a transaction here to allow batching for Smart Wallets + // read more: https://blog.uniswap.org/permit2-and-universal-router + if (!useAggregator) { + setPendingTransaction(true); + const permit2ContractAbi = parseAbi([ + 'function approve(address token, address spender, uint160 amount, uint48 expiration) external', + ]); + const data = encodeFunctionData({ + abi: permit2ContractAbi, + functionName: 'approve', + args: [ + quote.from.address as Address, + UNIVERSALROUTER_CONTRACT_ADDRESS, + BigInt(quote.fromAmount), + 20_000_000_000_000, // The deadline where the approval is no longer valid - see https://docs.uniswap.org/contracts/permit2/reference/allowance-transfer + ], + }); + const permitTxnHash = await sendTransactionAsync({ + to: PERMIT2_CONTRACT_ADDRESS, + data: data, + value: 0n, + }); + await Promise.resolve(onStart?.(permitTxnHash)); + await waitForTransactionReceipt(config, { + hash: permitTxnHash, + confirmations: 1, + }); + setPendingTransaction(false); + } } // make the swap