diff --git a/packages/checkout/sdk-sample-app/src/components/Swap.tsx b/packages/checkout/sdk-sample-app/src/components/Swap.tsx new file mode 100644 index 0000000000..20ac799146 --- /dev/null +++ b/packages/checkout/sdk-sample-app/src/components/Swap.tsx @@ -0,0 +1,227 @@ +import { ChainId, Checkout, GetBalanceResult, TokenInfo } from '@imtbl/checkout-sdk'; +import { Web3Provider } from '@ethersproject/providers'; +import LoadingButton from './LoadingButton'; +import { useEffect, useState } from 'react'; +import { SuccessMessage, ErrorMessage, WarningMessage } from './messages'; +import { Box, FormControl, TextInput } from '@biom3/react'; +import React from 'react'; + +interface SwapProps { + checkout: Checkout | undefined; + provider: Web3Provider | undefined; +} + +export default function Swap(props: SwapProps) { + const { provider, checkout } = props; + + const [fromToken, setFromToken] = useState(); + const [toToken, setToToken] = useState(); + const [fromAmount, setFromAmount] = useState(''); + const [toAmount, setToAmount] = useState(''); + const [slippagePercent, setSlippagePercent] = useState('0.1'); + const [maxHops, setMaxHops] = useState('2'); + const [deadline, setDeadline] = useState(() => { + const fifteenMinutesInSeconds = 15 * 60; + return Math.floor(Date.now() / 1000 + fifteenMinutesInSeconds).toString(); + }); + + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + const [validationError, setValidationError] = useState(null); + + const [fromTokenDecimals, setFromTokenDecimals] = useState(18); + const [toTokenDecimals, setToTokenDecimals] = useState(18); + + const updateFromToken = (event: React.ChangeEvent) => { + setFromToken({ address: event.target.value, symbol: '', name: '', decimals: fromTokenDecimals }); + setError(null); + }; + + const updateToToken = (event: React.ChangeEvent) => { + setToToken({ address: event.target.value, symbol: '', name: '', decimals: toTokenDecimals }); + setError(null); + }; + + const updateFromTokenDecimals = (event: React.ChangeEvent) => { + const decimals = parseInt(event.target.value) || 18; + setFromTokenDecimals(decimals); + setFromToken(prevToken => prevToken ? { ...prevToken, decimals } : undefined); + setError(null); + }; + + const updateToTokenDecimals = (event: React.ChangeEvent) => { + const decimals = parseInt(event.target.value) || 18; + setToTokenDecimals(decimals); + setToToken(prevToken => prevToken ? { ...prevToken, decimals } : undefined); + setError(null); + }; + + const updateFromAmount = (event: React.ChangeEvent) => { + const newFromAmount = event.target.value; + setFromAmount(newFromAmount); + setError(null); + validateAmounts(newFromAmount, toAmount); + }; + + const updateToAmount = (event: React.ChangeEvent) => { + const newToAmount = event.target.value; + setToAmount(newToAmount); + setError(null); + validateAmounts(fromAmount, newToAmount); + }; + + const validateAmounts = (from: string, to: string) => { + if (from !== '' && to !== '') { + setValidationError('Please provide either From Amount or To Amount, not both.'); + } else { + setValidationError(null); + } + }; + + const updateSlippagePercent = (event: React.ChangeEvent) => { + setSlippagePercent(event.target.value); + setError(null); + }; + + const updateMaxHops = (event: React.ChangeEvent) => { + setMaxHops(event.target.value); + setError(null); + }; + + const updateDeadline = (event: React.ChangeEvent) => { + setDeadline(event.target.value); + setError(null); + }; + + async function performSwap() { + if (validationError) { + setError(new Error(validationError)); + return; + } + if (!checkout) { + console.error('missing checkout, please connect first'); + return; + } + if (!provider) { + console.error('missing provider, please connect first'); + return; + } + if (!fromToken || !toToken) { + console.error('missing token information'); + return; + } + setError(null); + setLoading(true); + setSuccess(false); + + try { + const result = await checkout.swap({ + provider, + fromToken, + toToken, + fromAmount, + toAmount, + slippagePercent: slippagePercent.trim() !== '' ? parseFloat(slippagePercent) : undefined, + maxHops: maxHops.trim() !== '' ? parseInt(maxHops) : undefined, + deadline: deadline.trim() !== '' ? parseInt(deadline) : undefined, + }); + console.log('Swap result:', result); + setSuccess(true); + setLoading(false); + } catch (err: any) { + setError(err); + setLoading(false); + console.log(err.message); + console.log(err.type); + console.log(err.data); + console.log(err.stack); + } + } + + useEffect(() => { + setError(null); + setLoading(false); + setSuccess(false); + }, [checkout]); + + return ( +
+ {!provider && Not connected.} + + + + + + + + + + + + + + + + + + +
From Token AddressDecimalsTo Token AddressDecimals
+ + + + + + + + + + + + + + + +
+ + From Amount + + + + To Amount + + + {validationError && {validationError}} + + Slippage Percent + + + + Max Hops + + + + Deadline (minutes) + + + + + Swap + + + {success && !error && ( + Swap successful. Check console for details. + )} + {error && ( + + {error.message}. Check console logs for more details. + + )} +
+
+ ); +} \ No newline at end of file diff --git a/packages/checkout/sdk-sample-app/src/pages/ConnectWidget.tsx b/packages/checkout/sdk-sample-app/src/pages/ConnectWidget.tsx index 046693dc12..2c207ac1fd 100644 --- a/packages/checkout/sdk-sample-app/src/pages/ConnectWidget.tsx +++ b/packages/checkout/sdk-sample-app/src/pages/ConnectWidget.tsx @@ -12,6 +12,7 @@ import { Environment } from '@imtbl/config'; import Provider from '../components/Provider'; import SendTransaction from '../components/SendTransaction'; import GetInjectedProviders from '../components/GetInjectedProviders'; +import Swap from '../components/Swap'; export default function ConnectWidget() { const [environment, setEnvironment] = useState(Environment.SANDBOX); @@ -168,6 +169,17 @@ export default function ConnectWidget() { Get injected providers + + + Swap + + + ); } diff --git a/packages/checkout/sdk/src/errors/checkoutError.ts b/packages/checkout/sdk/src/errors/checkoutError.ts index 7b2f7a1221..6e1246aa48 100644 --- a/packages/checkout/sdk/src/errors/checkoutError.ts +++ b/packages/checkout/sdk/src/errors/checkoutError.ts @@ -42,6 +42,7 @@ export enum CheckoutErrorType { API_ERROR = 'API_ERROR', ORDER_EXPIRED_ERROR = 'ORDER_EXPIRED_ERROR', WIDGETS_SCRIPT_LOAD_ERROR = 'WIDGETS_SCRIPT_LOAD_ERROR', + APPROVAL_TRANSACTION_FAILED = 'APPROVAL_TRANSACTION_FAILED', } /** diff --git a/packages/checkout/sdk/src/sdk.test.ts b/packages/checkout/sdk/src/sdk.test.ts index 9ab48fac68..911c40514e 100644 --- a/packages/checkout/sdk/src/sdk.test.ts +++ b/packages/checkout/sdk/src/sdk.test.ts @@ -55,6 +55,8 @@ import { FiatRampParams, ExchangeType } from './types/fiatRamp'; import { getItemRequirementsFromRequirements } from './smartCheckout/itemRequirements'; import { CheckoutErrorType } from './errors'; import { availabilityService } from './availability'; +import * as swap from './swap'; +import { SwapParams, SwapResult } from './types/swap'; jest.mock('./connect'); jest.mock('./network'); @@ -72,6 +74,7 @@ jest.mock('./smartCheckout'); jest.mock('./fiatRamp'); jest.mock('./smartCheckout/itemRequirements'); jest.mock('./availability'); +jest.mock('./swap'); describe('Connect', () => { let providerMock: ExternalProvider; @@ -989,4 +992,133 @@ describe('Connect', () => { expect(checkout.availability.checkDexAvailability).toBeCalledTimes(1); }); }); + + describe('Swap', () => { + let checkout: Checkout; + let web3Provider: Web3Provider; + + beforeEach(() => { + jest.resetAllMocks(); + + providerMock.request = jest.fn().mockResolvedValue('0x1'); + + web3Provider = new Web3Provider(providerMock, ChainId.ETHEREUM); + + (validateProvider as jest.Mock).mockResolvedValue(web3Provider); + + checkout = new Checkout({ + baseConfig: { environment: Environment.PRODUCTION }, + }); + }); + + it('should call swap function with correct parameters', async () => { + const swapParams: SwapParams = { + provider: web3Provider, + fromToken: { address: '0xFromTokenAddress', decimals: 18 } as TokenInfo, + toToken: { address: '0xToTokenAddress', decimals: 18 } as TokenInfo, + fromAmount: '1000000000000000000', // 1 ETH in wei + toAmount: '1000000', // Example USDC amount + slippagePercent: 0.5, + maxHops: 3, + deadline: 1234567890, + }; + + const mockSwapResult: SwapResult = { + swap: { + transaction: { + to: '0xSwapContractAddress', + data: '0xEncodedSwapData', + value: '0', + }, + gasFeeEstimate: { + token: { + chainId: 0, + address: '', + decimals: 0, + symbol: undefined, + name: undefined, + }, + value: BigNumber.from('1000000000000000000'), + }, + }, + quote: { + slippage: 0.1, + fees: [], + amount: { + token: { + chainId: 0, + address: '', + decimals: 0, + symbol: undefined, + name: undefined, + }, + value: BigNumber.from('1000000000000000000'), + }, + amountWithMaxSlippage: { + token: { + chainId: 0, + address: '', + decimals: 0, + symbol: undefined, + name: undefined, + }, + value: BigNumber.from('1050000000000000000'), // Example value with 5% max slippage + }, + }, + swapReceipt: { + to: '0xRecipientAddress', + from: '0xSenderAddress', + contractAddress: '0xContractAddress', + transactionIndex: 1, + gasUsed: BigNumber.from('21000'), + logsBloom: '0x', + blockHash: '0xBlockHash', + transactionHash: '0xTransactionHash', + logs: [], + blockNumber: 12345, + confirmations: 2, + cumulativeGasUsed: BigNumber.from('100000'), + effectiveGasPrice: BigNumber.from('20000000000'), + status: 1, + type: 2, + byzantium: true, + }, + }; + + (swap.swap as jest.Mock).mockResolvedValue(mockSwapResult); + + const result = await checkout.swap(swapParams); + + expect(validateProvider).toHaveBeenCalledWith(checkout.config, web3Provider); + expect(swap.swap).toHaveBeenCalledWith( + checkout.config, + web3Provider, + swapParams.fromToken, + swapParams.toToken, + swapParams.fromAmount, + swapParams.toAmount, + swapParams.slippagePercent, + swapParams.maxHops, + swapParams.deadline, + ); + expect(result).toEqual(mockSwapResult); + }); + + it('should throw an error if provider validation fails', async () => { + const error = new Error('Invalid provider'); + (validateProvider as jest.Mock).mockRejectedValue(error); + + const swapParams: SwapParams = { + provider: web3Provider, + fromToken: { address: '0xFromTokenAddress', decimals: 18 } as TokenInfo, + toToken: { address: '0xToTokenAddress', decimals: 18 } as TokenInfo, + fromAmount: '1000000000000000000', + toAmount: '1000000', + slippagePercent: 0.5, + }; + + await expect(checkout.swap(swapParams)).rejects.toThrow('Invalid provider'); + expect(swap.swap).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/checkout/sdk/src/sdk.ts b/packages/checkout/sdk/src/sdk.ts index ed1a85c58f..131bed0081 100644 --- a/packages/checkout/sdk/src/sdk.ts +++ b/packages/checkout/sdk/src/sdk.ts @@ -17,6 +17,7 @@ import * as buy from './smartCheckout/buy'; import * as cancel from './smartCheckout/cancel'; import * as sell from './smartCheckout/sell'; import * as smartCheckout from './smartCheckout'; +import * as swap from './swap'; import { AddNetworkParams, BuyParams, @@ -76,6 +77,7 @@ import { WidgetConfiguration } from './widgets/definitions/configurations'; import { SemanticVersion } from './widgets/definitions/types'; import { validateAndBuildVersion } from './widgets/version'; import { InjectedProvidersManager } from './provider/injectedProvidersManager'; +import { SwapParams, SwapResult } from './types/swap'; const SANDBOX_CONFIGURATION = { baseConfig: { @@ -722,4 +724,27 @@ export class Checkout { public async isSwapAvailable(): Promise { return this.availability.checkDexAvailability(); } + + /** + * Fetches the approval and swap transaction details including the quote for the swap. + * @param {SwapParams} params - The parameters for the swap. + * @returns {Promise} - A promise that resolves to the swap result (swap tx, swap tx receipt, quote used in the swap). + */ + public async swap(params: SwapParams): Promise { + const web3Provider = await provider.validateProvider( + this.config, + params.provider, + ); + return swap.swap( + this.config, + web3Provider, + params.fromToken, + params.toToken, + params.fromAmount, + params.toAmount, + params.slippagePercent, + params.maxHops, + params.deadline, + ); + } } diff --git a/packages/checkout/sdk/src/swap/index.ts b/packages/checkout/sdk/src/swap/index.ts new file mode 100644 index 0000000000..b16905623b --- /dev/null +++ b/packages/checkout/sdk/src/swap/index.ts @@ -0,0 +1 @@ +export * from './swap'; diff --git a/packages/checkout/sdk/src/swap/swap.test.ts b/packages/checkout/sdk/src/swap/swap.test.ts new file mode 100644 index 0000000000..d233645f95 --- /dev/null +++ b/packages/checkout/sdk/src/swap/swap.test.ts @@ -0,0 +1,190 @@ +import { Web3Provider } from '@ethersproject/providers'; +import { BigNumber, utils, ethers } from 'ethers'; +import { CheckoutConfiguration } from '../config/config'; +import { TokenInfo } from '../types'; +import { swap, swapQuote } from './swap'; +import { createExchangeInstance } from '../instance'; + +jest.mock('../instance', () => ({ + createExchangeInstance: jest.fn(), +})); + +describe('swapQuote', () => { + const mockChainId = 13473; + const mockConfig = {} as unknown as CheckoutConfiguration; + const mockProvider = { + getSigner: jest.fn().mockReturnValue({ + getAddress: jest.fn().mockResolvedValue('0xmockaddress'), + }), + } as unknown as Web3Provider; + const mockFromToken: TokenInfo = { + address: '0x123', + symbol: 'FROM', + decimals: 18, + name: 'From Token', + }; + const mockToToken: TokenInfo = { + address: '0x456', + symbol: 'TO', + decimals: 18, + name: 'To Token', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call createExchangeInstance and execute swapQuote with fromAmount', async () => { + const mockExchange = { + getUnsignedSwapTxFromAmountIn: jest.fn().mockResolvedValue({ quote: '0xquotehash' }), + }; + (createExchangeInstance as jest.Mock).mockResolvedValue(mockExchange); + + const result = await swapQuote(mockConfig, mockProvider, mockFromToken, mockToToken, '100'); + + expect(createExchangeInstance).toHaveBeenCalledWith(mockChainId, mockConfig); + expect(mockExchange.getUnsignedSwapTxFromAmountIn).toHaveBeenCalledWith( + '0xmockaddress', + mockFromToken.address, + mockToToken.address, + BigNumber.from(utils.parseUnits('100', mockFromToken.decimals)), + undefined, + undefined, + undefined, + ); + expect(result).toEqual({ quote: '0xquotehash' }); + }); + + it('should call createExchangeInstance and execute swapQuote with toAmount', async () => { + const mockExchange = { + getUnsignedSwapTxFromAmountOut: jest.fn().mockResolvedValue({ quote: '0xquotehash' }), + }; + (createExchangeInstance as jest.Mock).mockResolvedValue(mockExchange); + + const result = await swapQuote(mockConfig, mockProvider, mockFromToken, mockToToken, undefined, '200'); + + expect(createExchangeInstance).toHaveBeenCalledWith(mockChainId, mockConfig); + expect(mockExchange.getUnsignedSwapTxFromAmountOut).toHaveBeenCalledWith( + '0xmockaddress', + mockFromToken.address, + mockToToken.address, + BigNumber.from(utils.parseUnits('200', mockToToken.decimals)), + undefined, + undefined, + undefined, + ); + expect(result).toEqual({ quote: '0xquotehash' }); + }); + + it('should throw an error if fromToken address is missing', async () => { + const invalidFromToken = { ...mockFromToken, address: '' }; + await expect(swapQuote(mockConfig, mockProvider, invalidFromToken, mockToToken, '100')) + .rejects.toThrow('fromToken address or decimals is missing.'); + }); + + it('should throw an error if fromToken decimals is zero', async () => { + const invalidFromToken = { ...mockFromToken, decimals: 0 }; + await expect(swapQuote(mockConfig, mockProvider, invalidFromToken, mockToToken, '100')) + .rejects.toThrow('fromToken address or decimals is missing.'); + }); + + it('should throw an error if toToken address is missing', async () => { + const invalidToToken = { ...mockToToken, address: '' }; + await expect(swapQuote(mockConfig, mockProvider, mockFromToken, invalidToToken, '100')) + .rejects.toThrow('toToken address or decimals is missing.'); + }); + + it('should throw an error if toToken decimals is zero', async () => { + const invalidToToken = { ...mockToToken, decimals: 0 }; + await expect(swapQuote(mockConfig, mockProvider, mockFromToken, invalidToToken, '100')) + .rejects.toThrow('toToken address or decimals is missing.'); + }); + + it('should throw an error if both fromAmount and toAmount are provided', async () => { + await expect(swapQuote(mockConfig, mockProvider, mockFromToken, mockToToken, '100', '200')) + .rejects.toThrow('Only one of fromAmount or toAmount can be provided.'); + }); + + it('should throw an error if neither fromAmount nor toAmount is provided', async () => { + await expect(swapQuote(mockConfig, mockProvider, mockFromToken, mockToToken)) + .rejects.toThrow('fromAmount or toAmount must be provided.'); + }); +}); + +describe('swap', () => { + const mockChainId = 13473; + const mockConfig = {} as unknown as CheckoutConfiguration; + const mockSigner = { + getAddress: jest.fn().mockResolvedValue('0xmockaddress'), + sendTransaction: jest.fn().mockResolvedValue({ hash: '0xtxhash' }), + }; + const mockProvider = { + getSigner: jest.fn().mockReturnValue(mockSigner), + getNetwork: jest.fn().mockResolvedValue({ chainId: mockChainId }), + } as unknown as Web3Provider; + const mockFromToken: TokenInfo = { + address: '0x123', + symbol: 'FROM', + decimals: 18, + name: 'From Token', + }; + const mockToToken: TokenInfo = { + address: '0x456', + symbol: 'TO', + decimals: 18, + name: 'To Token', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should successfully execute a swap', async () => { + const mockTransactionResponse = { + wait: jest.fn().mockResolvedValue({ status: 1 }), + }; + + const mockExchange = { + getUnsignedSwapTxFromAmountIn: jest.fn().mockResolvedValue({ + quote: '0xquotehash', + swap: { transaction: { maxFeePerGas: '0xunsignedtx' } as ethers.providers.TransactionRequest }, + approve: { transaction: { maxFeePerGas: '0xunsignedtx' } as ethers.providers.TransactionRequest }, + }), + }; + (createExchangeInstance as jest.Mock).mockResolvedValue(mockExchange); + + mockSigner.sendTransaction.mockResolvedValue({ + ...mockTransactionResponse, + hash: '0xtxhash', + }); + + const result = await swap(mockConfig, mockProvider, mockFromToken, mockToToken, '100'); + + expect(createExchangeInstance).toHaveBeenCalledWith(mockChainId, mockConfig); + expect(mockExchange.getUnsignedSwapTxFromAmountIn).toHaveBeenCalledWith( + '0xmockaddress', + mockFromToken.address, + mockToToken.address, + BigNumber.from(utils.parseUnits('100', mockFromToken.decimals)), + undefined, + undefined, + undefined, + ); + expect(mockProvider.getNetwork).toHaveBeenCalled(); + expect(mockSigner.sendTransaction).toHaveBeenCalledWith({ + maxFeePerGas: BigNumber.from('0x037e11d600'), + maxPriorityFeePerGas: BigNumber.from('0x02540be400'), + }); + expect(mockTransactionResponse.wait).toHaveBeenCalled(); + expect(result).toEqual({ + quote: '0xquotehash', + swap: { + transaction: { + maxFeePerGas: BigNumber.from('0x037e11d600'), + maxPriorityFeePerGas: BigNumber.from('0x02540be400'), + }, + }, + swapReceipt: { status: 1 }, + }); + }); +}); diff --git a/packages/checkout/sdk/src/swap/swap.ts b/packages/checkout/sdk/src/swap/swap.ts new file mode 100644 index 0000000000..990b65e43b --- /dev/null +++ b/packages/checkout/sdk/src/swap/swap.ts @@ -0,0 +1,119 @@ +import { BigNumber, utils } from 'ethers'; +import { Web3Provider } from '@ethersproject/providers'; +import { CheckoutError, CheckoutErrorType } from '../errors'; +import { TokenInfo } from '../types'; +import { createExchangeInstance } from '../instance'; +import { CheckoutConfiguration, getL2ChainId } from '../config'; +import { SwapQuoteResult, SwapResult } from '../types/swap'; +import { sendTransaction } from '../transaction/transaction'; + +const swapQuote = async ( + config: CheckoutConfiguration, + provider: Web3Provider, + fromToken: TokenInfo, + toToken: TokenInfo, + fromAmount?: string, + toAmount?: string, + slippagePercent?: number, + maxHops?: number, + deadline?: number, +): Promise => { + if (!fromToken.address || fromToken.decimals === 0) { + throw new CheckoutError( + 'fromToken address or decimals is missing.', + CheckoutErrorType.MISSING_PARAMS, + ); + } + if (!toToken.address || toToken.decimals === 0) { + throw new CheckoutError( + 'toToken address or decimals is missing.', + CheckoutErrorType.MISSING_PARAMS, + ); + } + if (fromAmount && toAmount) { + throw new CheckoutError( + 'Only one of fromAmount or toAmount can be provided.', + CheckoutErrorType.MISSING_PARAMS, + ); + } + if (!fromAmount && !toAmount) { + throw new CheckoutError( + 'fromAmount or toAmount must be provided.', + CheckoutErrorType.MISSING_PARAMS, + ); + } + const chainId = getL2ChainId(config); + const exchange = await createExchangeInstance(chainId, config); + + const address = await provider.getSigner().getAddress(); + + if (fromAmount) { + return exchange.getUnsignedSwapTxFromAmountIn( + address, + fromToken.address as string, + toToken.address as string, + BigNumber.from(utils.parseUnits(fromAmount, fromToken.decimals)), + slippagePercent, + maxHops, + deadline, + ); + } + return exchange.getUnsignedSwapTxFromAmountOut( + address, + fromToken.address as string, + toToken.address as string, + BigNumber.from(utils.parseUnits(toAmount!, toToken.decimals)), + slippagePercent, + maxHops, + deadline, + ); +}; + +const swap = async ( + config: CheckoutConfiguration, + provider: Web3Provider, + fromToken: TokenInfo, + toToken: TokenInfo, + fromAmount?: string, + toAmount?: string, + slippagePercent?: number, + maxHops?: number, + deadline?: number, +): Promise => { + const quoteResult = await swapQuote( + config, + provider, + fromToken, + toToken, + fromAmount, + toAmount, + slippagePercent, + maxHops, + deadline, + ); + if (quoteResult.approval) { + const approvalTx = await sendTransaction(provider, quoteResult.approval.transaction); + const receipt = await approvalTx.transactionResponse.wait(); + if (receipt.status === 0) { + throw new CheckoutError( + 'Approval transaction failed and was reverted', + CheckoutErrorType.APPROVAL_TRANSACTION_FAILED, + ); + } + } + const swapTx = await sendTransaction(provider, quoteResult.swap.transaction); + const receipt = await swapTx.transactionResponse.wait(); + if (receipt.status === 0) { + throw new CheckoutError( + 'Swap transaction failed and was reverted', + CheckoutErrorType.TRANSACTION_FAILED, + ); + } + return { + swapReceipt: receipt, + quote: quoteResult.quote, + swap: quoteResult.swap, + }; +}; + +export { swapQuote, swap }; diff --git a/packages/checkout/sdk/src/transaction/transaction.ts b/packages/checkout/sdk/src/transaction/transaction.ts index 0834de62d4..c9a3c3983b 100644 --- a/packages/checkout/sdk/src/transaction/transaction.ts +++ b/packages/checkout/sdk/src/transaction/transaction.ts @@ -1,10 +1,31 @@ -import { ethers } from 'ethers'; +import { BigNumber, ethers } from 'ethers'; import { TransactionRequest, Web3Provider } from '@ethersproject/providers'; import { CheckoutError, CheckoutErrorType } from '../errors'; import { SendTransactionResult } from '../types'; import { IMMUTABLE_ZKVEM_GAS_OVERRIDES } from '../env'; import { isZkEvmChainId } from '../utils/utils'; +export function isPassportProvider(provider?: Web3Provider | null) { + return (provider?.provider as any)?.isPassport === true; +} + +/** + * Checks conditions to operate a gas-free flow. + * + * TODO: + * - Phase 1 (2024): Allow all passport wallets to be gas-free. + * - Phase 2 & 3 (2025): Not all passport wallets will be gas-free. + * Therefore, the gas-free condition must be checked against the relayer's + * `im_getFeeOptions` endpoint, which should return zero for + * passport accounts with gas sponsorship enabled. + * + * Refer to the docs for more details: + * https://docs.immutable.com/docs/zkevm/architecture/gas-sponsorship-for-gamers/ + */ +export function isGasFree(provider?: Web3Provider | null) { + return isPassportProvider(provider); +} + export const setTransactionGasLimits = async ( web3Provider: Web3Provider, transaction: TransactionRequest, @@ -14,10 +35,12 @@ export const setTransactionGasLimits = async ( const { chainId } = await web3Provider.getNetwork(); if (!isZkEvmChainId(chainId)) return rawTx; if (typeof rawTx.gasPrice !== 'undefined') return rawTx; - - rawTx.maxFeePerGas = IMMUTABLE_ZKVEM_GAS_OVERRIDES.maxFeePerGas; - rawTx.maxPriorityFeePerGas = IMMUTABLE_ZKVEM_GAS_OVERRIDES.maxPriorityFeePerGas; - + if (isGasFree(web3Provider)) { + rawTx.gasPrice = BigNumber.from(0); + } else { + rawTx.maxFeePerGas = IMMUTABLE_ZKVEM_GAS_OVERRIDES.maxFeePerGas; + rawTx.maxPriorityFeePerGas = IMMUTABLE_ZKVEM_GAS_OVERRIDES.maxPriorityFeePerGas; + } return rawTx; }; diff --git a/packages/checkout/sdk/src/types/swap.ts b/packages/checkout/sdk/src/types/swap.ts new file mode 100644 index 0000000000..5f0aab353d --- /dev/null +++ b/packages/checkout/sdk/src/types/swap.ts @@ -0,0 +1,50 @@ +import { TransactionReceipt, Web3Provider } from '@ethersproject/providers'; +import { Quote, TransactionDetails } from '@imtbl/dex-sdk'; +import { TokenInfo } from './tokenInfo'; + +/** + * Interface representing the parameters for {@link Checkout.swap}. + * @property {Web3Provider} provider - The provider used to get the wallet address. + * @property {TokenInfo} fromToken - The token to swap from. + * @property {TokenInfo} toToken - The token to swap to. + * @property {string | undefined} fromAmount - The amount to swap from. + * @property {string | undefined} toAmount - The amount to swap to. + * @property {number | undefined} slippagePercent - The percentage of slippage tolerance. Default = 0.1. Max = 50. Min = 0 + * @property {number | undefined} maxHops - Maximum hops allowed in optimal route. Default is 2 + * @property {number | undefined} deadline - Latest time swap can execute. Default is 15 minutes + */ + +export interface SwapParams { + provider: Web3Provider; + fromToken: TokenInfo, + toToken: TokenInfo, + fromAmount?: string, + toAmount?: string, + slippagePercent?: number, + maxHops?: number, + deadline?: number, +} + +/** + * Interface representing the result of {@link Checkout.swapQuote}. + * @property {TransactionDetails} approval - The approval transaction details. + * @property {TransactionDetails} swap - The swap transaction details. + * @property {Quote} quote - The quote for the swap. + */ +export interface SwapQuoteResult { + approval: TransactionDetails | null; + swap: TransactionDetails; + quote: Quote; +} + +/** + * Interface representing the result of {@link Checkout.swap}. + * @property {TransactionDetails} swap - The swap transaction details. + * @property {Quote} quote - The quote for the swap. + * @property {TransactionReceipt} swapReceipt - The receipt of the swap transaction. + */ +export interface SwapResult { + swap: TransactionDetails; + quote: Quote; + swapReceipt: TransactionReceipt; +}