diff --git a/.changeset/honest-jeans-change.md b/.changeset/honest-jeans-change.md new file mode 100644 index 0000000000..f5ed1600d2 --- /dev/null +++ b/.changeset/honest-jeans-change.md @@ -0,0 +1,5 @@ +--- +"@coinbase/onchainkit": minor +--- + +**feat**: add `getSwap` by @0xAlec #503 diff --git a/jest.config.cjs b/jest.config.cjs index f8a1b1f0ea..b74eadf869 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -7,6 +7,7 @@ module.exports = { statements: 100, }, }, + maxWorkers: 1, modulePathIgnorePatterns: ['/framegear/'], setupFilesAfterEnv: ['/jest.setup.ts'], transform: { diff --git a/src/definitions/swap.ts b/src/definitions/swap.ts index 0269eb5470..5f9d6e36a2 100644 --- a/src/definitions/swap.ts +++ b/src/definitions/swap.ts @@ -1,2 +1,3 @@ export const CDP_LISTSWAPASSETS = 'cdp_listSwapAssets'; export const CDP_GETSWAPQUOTE = 'cdp_getSwapQuote'; +export const CDP_GETSWAPTRADE = 'cdp_getSwapTrade'; diff --git a/src/swap/core/getParamsForToken.ts b/src/swap/core/getParamsForToken.ts index b7e567f25d..5080841d35 100644 --- a/src/swap/core/getParamsForToken.ts +++ b/src/swap/core/getParamsForToken.ts @@ -1,15 +1,17 @@ import { formatDecimals } from './formatDecimals'; -import type { GetQuoteParams, GetQuoteAPIParams } from '../types'; +import type { SwapParams, SwapAPIParams, GetSwapParams } from '../types'; /** * Converts parameters with `Token` to ones with address. Additionally adds default values for optional request fields. */ -export function getParamsForToken(params: GetQuoteParams): GetQuoteAPIParams { +export function getParamsForToken(params: SwapParams): SwapAPIParams { const { from, to, amount, amountReference, isAmountInDecimals } = params; + const { fromAddress } = params as GetSwapParams; const decimals = amountReference === 'from' ? from.decimals : to.decimals; return { + fromAddress: fromAddress, from: from.address || 'ETH', to: to.address || 'ETH', amount: isAmountInDecimals ? amount : formatDecimals(amount, false, decimals), diff --git a/src/swap/core/getQuote.ts b/src/swap/core/getQuote.ts index facb088389..7cde44b2c2 100644 --- a/src/swap/core/getQuote.ts +++ b/src/swap/core/getQuote.ts @@ -1,18 +1,12 @@ import { CDP_GETSWAPQUOTE } from '../../definitions/swap'; import { sendRequest } from '../../queries/request'; -import type { - GetQuoteResponse, - GetQuoteParams, - GetQuoteAPIParams, - Quote, - SwapError, -} from '../types'; import { getParamsForToken } from './getParamsForToken'; +import type { GetQuoteResponse, Quote, SwapError, SwapParams, SwapAPIParams } from '../types'; /** * Retrieves a quote for a swap from Token A to Token B. */ -export async function getQuote(params: GetQuoteParams): Promise { +export async function getQuote(params: SwapParams): Promise { // Default parameters const defaultParams = { amountReference: 'from', @@ -22,7 +16,7 @@ export async function getQuote(params: GetQuoteParams): Promise(CDP_GETSWAPQUOTE, [apiParams]); + const res = await sendRequest(CDP_GETSWAPQUOTE, [apiParams]); if (res.error) { return { diff --git a/src/swap/core/getSwap.test.ts b/src/swap/core/getSwap.test.ts new file mode 100644 index 0000000000..be48428180 --- /dev/null +++ b/src/swap/core/getSwap.test.ts @@ -0,0 +1,287 @@ +import { getParamsForToken } from './getParamsForToken'; +import { getSwap } from './getSwap'; +import { getTransaction } from './getTransaction'; +import { sendRequest } from '../../queries/request'; +import { CDP_GETSWAPTRADE } from '../../definitions/swap'; +import type { Token } from '../../token/types'; +import { Swap } from '../types'; + +jest.mock('../../queries/request'); + +const ETH: Token = { + name: 'ETH', + address: '', + symbol: 'ETH', + decimals: 18, + image: 'https://wallet-api-production.s3.amazonaws.com/uploads/tokens/eth_288.png', + chainId: 8453, +}; +const DEGEN: Token = { + name: 'DEGEN', + address: '0x4ed4e862860bed51a9570b96d89af5e1b0efefed', + symbol: 'DEGEN', + decimals: 18, + image: + 'https://d3r81g40ycuhqg.cloudfront.net/wallet/wais/3b/bf/3bbf118b5e6dc2f9e7fc607a6e7526647b4ba8f0bea87125f971446d57b296d2-MDNmNjY0MmEtNGFiZi00N2I0LWIwMTItMDUyMzg2ZDZhMWNm', + chainId: 8453, +}; +const testFromAddress = '0x6Cd01c0F55ce9E0Bf78f5E90f72b4345b16d515d'; +const testAmount = '3305894409732200'; +const testAmountReference = 'from'; + +describe('getSwap', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return a swap', async () => { + const mockParams = { + fromAddress: testFromAddress as `0x${string}`, + amountReference: testAmountReference, + from: ETH, + to: DEGEN, + amount: testAmount, + }; + const mockApiParams = getParamsForToken(mockParams); + + const mockResponse = { + id: 1, + jsonrpc: '2.0', + result: { + tx: { + data: '0x0000000000000', + gas: '463613', + gasPrice: '2106527', + from: '0xaeE5834a78a30F6762407F6F8c3A2090054b0086', + to: '0xdef1c0ded9bec7f1a1670819833240f027b25eff', + value: '100000000000000', + }, + quote: { + from: { + address: '', + chainId: 8453, + decimals: 18, + image: 'https://wallet-api-production.s3.amazonaws.com/uploads/tokens/eth_288.png', + name: 'ETH', + symbol: 'ETH', + }, + 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', + chainId: 8453, + highPriceImpact: false, + slippage: '3', + warning: undefined, + }, + fee: { + baseAsset: { + name: 'DEGEN', + address: '0x4ed4e862860bed51a9570b96d89af5e1b0efefed', + currencyCode: 'DEGEN', + decimals: 18, + imageURL: + 'https://d3r81g40ycuhqg.cloudfront.net/wallet/wais/3b/bf/3bbf118b5e6dc2f9e7fc607a6e7526647b4ba8f0bea87125f971446d57b296d2-MDNmNjY0MmEtNGFiZi00N2I0LWIwMTItMDUyMzg2ZDZhMWNm', + blockchain: 'eth', + aggregators: [Array], + swappable: true, + unverified: false, + chainId: 8453, + }, + percentage: '1', + amount: '195912661817282562', + }, + approveTx: undefined, + chainId: '8453', + }, + }; + + (sendRequest as jest.Mock).mockResolvedValue(mockResponse); + + const trade = mockResponse.result; + const expectedResponse = { + approveTransaction: trade.approveTx + ? getTransaction(trade.approveTx, trade.chainId) + : undefined, + fee: trade.fee, + quote: trade.quote, + transaction: getTransaction(trade.tx, trade.chainId), + warning: trade.quote.warning, + }; + + const quote = (await getSwap(mockParams)) as Swap; + + expect(quote.approveTransaction?.transaction).toEqual( + expectedResponse.approveTransaction?.transaction, + ); + expect(quote.transaction.transaction).toEqual(expectedResponse.transaction.transaction); + expect(quote.fee).toEqual(expectedResponse.fee); + expect(quote.warning).toEqual(expectedResponse.warning); + + expect(sendRequest).toHaveBeenCalledTimes(1); + expect(sendRequest).toHaveBeenCalledWith(CDP_GETSWAPTRADE, [mockApiParams]); + }); + + it('should return a swap with an approve transaction', async () => { + const mockParams = { + fromAddress: testFromAddress as `0x${string}`, + amountReference: testAmountReference, + from: DEGEN, + to: ETH, + amount: testAmount, + }; + const mockApiParams = getParamsForToken(mockParams); + + const mockResponse = { + id: 1, + jsonrpc: '2.0', + result: { + approveTx: { + data: '0x0000000000000', + gas: '463613', + gasPrice: '2106527', + from: '0xaeE5834a78a30F6762407F6F8c3A2090054b0086', + to: '0xdef1c0ded9bec7f1a1670819833240f027b25eff', + value: '100000000000000', + }, + tx: { + data: '0x0000000000000', + gas: '463613', + gasPrice: '2106527', + from: '0xaeE5834a78a30F6762407F6F8c3A2090054b0086', + to: '0xdef1c0ded9bec7f1a1670819833240f027b25eff', + value: '100000000000000', + }, + quote: { + from: { + address: '', + chainId: 8453, + decimals: 18, + image: 'https://wallet-api-production.s3.amazonaws.com/uploads/tokens/eth_288.png', + name: 'ETH', + symbol: 'ETH', + }, + 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', + chainId: 8453, + highPriceImpact: false, + slippage: '3', + warning: undefined, + }, + fee: { + baseAsset: { + name: 'DEGEN', + address: '0x4ed4e862860bed51a9570b96d89af5e1b0efefed', + currencyCode: 'DEGEN', + decimals: 18, + imageURL: + 'https://d3r81g40ycuhqg.cloudfront.net/wallet/wais/3b/bf/3bbf118b5e6dc2f9e7fc607a6e7526647b4ba8f0bea87125f971446d57b296d2-MDNmNjY0MmEtNGFiZi00N2I0LWIwMTItMDUyMzg2ZDZhMWNm', + blockchain: 'eth', + aggregators: [Array], + swappable: true, + unverified: false, + chainId: 8453, + }, + percentage: '1', + amount: '195912661817282562', + }, + chainId: '8453', + }, + }; + + (sendRequest as jest.Mock).mockResolvedValue(mockResponse); + + const trade = mockResponse.result; + const expectedResponse = { + approveTransaction: trade.approveTx + ? getTransaction(trade.approveTx, trade.chainId) + : undefined, + fee: trade.fee, + quote: trade.quote, + transaction: getTransaction(trade.tx, trade.chainId), + warning: trade.quote.warning, + }; + + const quote = (await getSwap(mockParams)) as Swap; + + expect(quote.approveTransaction?.transaction).toEqual( + expectedResponse.approveTransaction?.transaction, + ); + expect(quote.transaction.transaction).toEqual(expectedResponse.transaction.transaction); + expect(quote.fee).toEqual(expectedResponse.fee); + expect(quote.warning).toEqual(expectedResponse.warning); + + expect(sendRequest).toHaveBeenCalledTimes(1); + expect(sendRequest).toHaveBeenCalledWith(CDP_GETSWAPTRADE, [mockApiParams]); + }); + + it('should throw an error if sendRequest fails', async () => { + const mockParams = { + fromAddress: testFromAddress as `0x${string}`, + amountReference: testAmountReference, + from: ETH, + to: DEGEN, + amount: testAmount, + }; + const mockApiParams = getParamsForToken(mockParams); + + const mockError = new Error('getSwap: Error: Failed to send request'); + (sendRequest as jest.Mock).mockRejectedValue(mockError); + + await expect(getSwap(mockParams)).rejects.toThrow('getSwap: Error: Failed to send request'); + + expect(sendRequest).toHaveBeenCalledTimes(1); + expect(sendRequest).toHaveBeenCalledWith(CDP_GETSWAPTRADE, [mockApiParams]); + }); + + it('should return an error object from getSwap', async () => { + const mockParams = { + fromAddress: testFromAddress as `0x${string}`, + amountReference: testAmountReference, + from: ETH, + to: DEGEN, + amount: testAmount, + }; + const mockApiParams = getParamsForToken(mockParams); + + const mockResponse = { + id: 1, + jsonrpc: '2.0', + error: { + code: -1, + message: 'Invalid response', + }, + }; + + (sendRequest as jest.Mock).mockResolvedValue(mockResponse); + + const error = await getSwap(mockParams); + expect(error).toEqual({ + code: -1, + error: 'Invalid response', + }); + + expect(sendRequest).toHaveBeenCalledTimes(1); + expect(sendRequest).toHaveBeenCalledWith(CDP_GETSWAPTRADE, [mockApiParams]); + }); +}); diff --git a/src/swap/core/getSwap.ts b/src/swap/core/getSwap.ts new file mode 100644 index 0000000000..d56081f8ae --- /dev/null +++ b/src/swap/core/getSwap.ts @@ -0,0 +1,41 @@ +import { CDP_GETSWAPTRADE } from '../../definitions/swap'; +import { sendRequest } from '../../queries/request'; +import { getParamsForToken } from './getParamsForToken'; +import { getTransaction } from './getTransaction'; +import type { GetSwapParams, Trade, SwapError, SwapAPIParams, GetSwapResponse } from '../types'; + +/** + * Retrieves an unsigned transaction for a swap from Token A to Token B. + */ +export async function getSwap(params: GetSwapParams): Promise { + // Default parameters + const defaultParams = { + amountReference: 'from', + isAmountInDecimals: false, + }; + + const apiParams = getParamsForToken({ ...defaultParams, ...params }); + + try { + const res = await sendRequest(CDP_GETSWAPTRADE, [apiParams]); + if (res.error) { + return { + code: res.error.code, + error: res.error.message, + } as SwapError; + } + + const trade = res.result; + return { + approveTransaction: trade.approveTx + ? getTransaction(trade.approveTx, trade.chainId) + : undefined, + fee: trade.fee, + quote: trade.quote, + transaction: getTransaction(trade.tx, trade.chainId), + warning: trade.quote.warning, + }; + } catch (error) { + throw new Error(`getSwap: ${error}`); + } +} diff --git a/src/swap/core/getTransaction.test.ts b/src/swap/core/getTransaction.test.ts new file mode 100644 index 0000000000..18e11ecb72 --- /dev/null +++ b/src/swap/core/getTransaction.test.ts @@ -0,0 +1,82 @@ +import { RawTransactionData } from '../types'; +import { TransactionParams } from '../types'; +import { getTransaction } from './getTransaction'; + +jest.mock('../../queries/request'); + +describe('getTransaction', () => { + it('should construct an unsigned transaction', () => { + const tx: RawTransactionData = { + data: '0x123456', + gas: '100000', + to: '0xabcdef', + from: '0xabcdef', + gasPrice: '100000', + value: '1000000000000000000', + }; + const chainId = '1'; + + const expectedTransaction = { + transaction: { + chainId: 1, + data: '0x123456', + gas: BigInt(100000), + to: '0xabcdef', + value: BigInt('1000000000000000000'), + }, + withParams(params: TransactionParams) { + const { nonce, maxFeePerGas, maxPriorityFeePerGas } = params; + + return { + chainId: 1, + data: '0x123456', + gas: BigInt(100000), + to: '0xabcdef', + value: BigInt('1000000000000000000'), + nonce, + maxFeePerGas, + maxPriorityFeePerGas, + }; + }, + }; + + const result = getTransaction(tx, chainId); + + expect(result.transaction).toEqual(expectedTransaction.transaction); + }); + + it('should be able to add arbitrary params', () => { + const tx: RawTransactionData = { + data: '0x123456', + gas: '100000', + to: '0xabcdef', + from: '0xabcdef', + gasPrice: '100000', + value: '1000000000000000000', + }; + const chainId = '1'; + + const expectedTransaction = { + transaction: { + chainId: 1, + data: '0x123456', + gas: BigInt(100000), + to: '0xabcdef', + value: BigInt('1000000000000000000'), + nonce: 1, + maxFeePerGas: BigInt(100000), + maxPriorityFeePerGas: BigInt(100000), + }, + }; + + const result = getTransaction(tx, chainId); + + const params: TransactionParams = { + nonce: 1, + maxFeePerGas: BigInt(100000), + maxPriorityFeePerGas: BigInt(100000), + }; + + expect(result.withParams(params)).toEqual(expectedTransaction.transaction); + }); +}); diff --git a/src/swap/core/getTransaction.ts b/src/swap/core/getTransaction.ts new file mode 100644 index 0000000000..9bf214baec --- /dev/null +++ b/src/swap/core/getTransaction.ts @@ -0,0 +1,30 @@ +import { RawTransactionData, Transaction } from '../types'; + +/** + * Constructs an unsigned transaction. + */ +export function getTransaction(tx: RawTransactionData, chainId: string): Transaction { + const { data, gas, to, value } = tx; + + const txData = { + chainId: Number(chainId), + data: data as `0x${string}`, + gas: BigInt(gas), + to: to as `0x${string}`, + value: BigInt(value), + }; + + return { + transaction: txData, + withParams(params) { + const { nonce, maxFeePerGas, maxPriorityFeePerGas } = params; + + return { + ...txData, + nonce, + maxFeePerGas, + maxPriorityFeePerGas, + }; + }, + }; +} diff --git a/src/swap/index.ts b/src/swap/index.ts index e51725e16b..00c6e152a6 100644 --- a/src/swap/index.ts +++ b/src/swap/index.ts @@ -1,10 +1,15 @@ // 🌲☀️🌲 export { getQuote } from './core/getQuote'; +export { getSwap } from './core/getSwap'; export type { Fee, - GetQuoteParams, GetQuoteResponse, + GetSwapResponse, Quote, QuoteWarning, + Swap, SwapError, + SwapParams, + Transaction, + TransactionParams, } from './types'; diff --git a/src/swap/types.ts b/src/swap/types.ts index 5e73f0365b..e4e8f7804f 100644 --- a/src/swap/types.ts +++ b/src/swap/types.ts @@ -12,6 +12,14 @@ export type Fee = { percentage: string; // The percentage of the fee }; +export type GetQuoteParams = { + from: Token; // The source token for the swap + to: Token; // The destination token for the swap + amount: string; // The amount to be swapped + amountReference?: string; // The reference amount for the swap + isAmountInDecimals?: boolean; // Whether the amount is in decimals +}; + export type GetQuoteAPIParams = { from: AddressOrETH | ''; // The source address or 'ETH' for Ethereum to: AddressOrETH | ''; // The destination address or 'ETH' for Ethereum @@ -19,21 +27,23 @@ export type GetQuoteAPIParams = { amountReference?: string; // The reference amount for the swap }; +export type GetSwapParams = GetQuoteParams & { + fromAddress: Address; // The address of the user +}; + +export type GetSwapAPIParams = GetQuoteAPIParams & { + fromAddress: Address; // The address of the user +}; + /** * Note: exported as public Type */ -export type GetQuoteParams = { - from: Token; // The source token for the swap - to: Token; // The destination token for the swap - amount: string; // The amount to be swapped - amountReference?: string; // The reference amount for the swap - isAmountInDecimals?: boolean; // Whether the amount is in decimals -}; +export type GetQuoteResponse = Quote | SwapError; /** * Note: exported as public Type */ -export type GetQuoteResponse = Quote | SwapError; +export type GetSwapResponse = Swap | SwapError; /** * Note: exported as public Type @@ -59,6 +69,26 @@ export type QuoteWarning = { type?: string; // The type of the warning }; +export type RawTransactionData = { + data: string; // The transaction data + from: string; // The sender address + gas: string; // The gas limit + gasPrice: string; // The gas price + to: string; // The recipient address + value: string; // The value of the transaction +}; + +/** + * Note: exported as public Type + */ +export type Swap = { + approveTransaction?: Transaction; // The approval transaction + fee: Fee; // The fee for the swap + quote: Quote; // The quote for the swap + transaction: Transaction; // The swap transaction + warning?: QuoteWarning; // The warning associated with the swap +}; + /** * Note: exported as public Type */ @@ -67,18 +97,48 @@ export type SwapError = { error: string; // The error message }; +/** + * Note: exported as public Type + */ +export type SwapParams = GetQuoteParams | GetSwapParams; + +export type SwapAPIParams = GetQuoteAPIParams | GetSwapAPIParams; + +/** + * Note: exported as public Type + */ +export interface Transaction { + transaction: TransactionData; + + withParams(params: TransactionParams): TransactionData; +} + +/** + * Note: exported as public Type + */ +export type TransactionData = { + chainId: number; // The chain ID + data: `0x${string}`; // The data for the transaction + gas: bigint; // The gas limit + to: `0x${string}`; // The recipient address + value: bigint; // The value of the transaction + nonce?: number; // The nonce for the transaction + maxFeePerGas?: bigint | undefined; // The maximum fee per gas + maxPriorityFeePerGas?: bigint | undefined; // The maximum priority fee per gas +}; +/** + * Note: exported as public Type + */ +export type TransactionParams = { + nonce: number; // The nonce for the transaction + maxFeePerGas: bigint | undefined; // The maximum fee per gas + maxPriorityFeePerGas: bigint | undefined; // The maximum priority fee per gas +}; + export type Trade = { - approveTx?: Transaction; // The approval transaction + approveTx?: RawTransactionData; // The approval transaction chainId: string; // The chain ID fee: Fee; // The fee for the trade - tx: Transaction; // The trade transaction -}; - -export type Transaction = { - data: string; // The transaction data - from: string; // The sender address - gas: string; // The gas limit - gasPrice: string; // The gas price - to: string; // The recipient address - value: string; // The value of the transaction + quote: Quote; // The quote for the trade + tx: RawTransactionData; // The trade transaction };