diff --git a/src/actions/actions.unit.handlers.ts b/src/actions/actions.unit.handlers.ts new file mode 100644 index 00000000..67e17d30 --- /dev/null +++ b/src/actions/actions.unit.handlers.ts @@ -0,0 +1,78 @@ +import { findDefaultToken } from '@lifi/data-types' +import { ChainId, CoinKey } from '@lifi/types' +import { HttpResponse, http } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, vi } from 'vitest' +import { createClient } from '../client/createClient.js' +import { requestSettings } from '../request.js' + +const client = createClient({ + integrator: 'lifi-sdk', +}) + +export const handlers = [ + http.post(`${client.config.apiUrl}/advanced/routes`, async () => { + return HttpResponse.json({}) + }), + http.post(`${client.config.apiUrl}/advanced/possibilities`, async () => + HttpResponse.json({}) + ), + http.get(`${client.config.apiUrl}/token`, async () => HttpResponse.json({})), + http.get(`${client.config.apiUrl}/quote`, async () => HttpResponse.json({})), + http.get(`${client.config.apiUrl}/status`, async () => HttpResponse.json({})), + http.get(`${client.config.apiUrl}/chains`, async () => + HttpResponse.json({ chains: [{ id: 1 }] }) + ), + http.get(`${client.config.apiUrl}/tools`, async () => + HttpResponse.json({ bridges: [], exchanges: [] }) + ), + http.get(`${client.config.apiUrl}/tokens`, async () => + HttpResponse.json({ + tokens: { + [ChainId.ETH]: [findDefaultToken(CoinKey.ETH, ChainId.ETH)], + }, + }) + ), + http.post(`${client.config.apiUrl}/advanced/stepTransaction`, async () => + HttpResponse.json({}) + ), + http.get(`${client.config.apiUrl}/gas/suggestion/${ChainId.OPT}`, async () => + HttpResponse.json({}) + ), + http.get(`${client.config.apiUrl}/connections`, async () => + HttpResponse.json({ connections: [] }) + ), + http.get(`${client.config.apiUrl}/analytics/transfers`, async () => + HttpResponse.json({}) + ), +] + +/** + * Sets up MSW server with common handlers for HTTP-based tests + * Call this function at the top level of your test file + */ +export const setupTestServer = () => { + const server = setupServer(...handlers) + + beforeAll(() => { + server.listen({ + onUnhandledRequest: 'warn', + }) + requestSettings.retries = 0 + }) + + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => server.resetHandlers()) + + afterAll(() => { + requestSettings.retries = 1 + server.close() + }) + + return server +} + +export { client } diff --git a/src/actions/getChains.ts b/src/actions/getChains.ts new file mode 100644 index 00000000..9cca561e --- /dev/null +++ b/src/actions/getChains.ts @@ -0,0 +1,54 @@ +import type { + ChainsRequest, + ChainsResponse, + ExtendedChain, + RequestOptions, +} from '@lifi/types' +import { request } from '../request.js' +import type { SDKBaseConfig, SDKClient } from '../types/core.js' +import { withDedupe } from '../utils/withDedupe.js' + +/** + * Get all available chains + * @param client - The SDK client + * @param params - The configuration of the requested chains + * @param options - Request options + * @returns A list of all available chains + * @throws {LiFiError} Throws a LiFiError if request fails. + */ +export const getChains = async ( + client: SDKClient, + params?: ChainsRequest, + options?: RequestOptions +): Promise => { + return await getChainsFromConfig(client.config, params, options) +} + +export const getChainsFromConfig = async ( + config: SDKBaseConfig, + params?: ChainsRequest, + options?: RequestOptions +): Promise => { + if (params) { + for (const key of Object.keys(params)) { + if (!params[key as keyof ChainsRequest]) { + delete params[key as keyof ChainsRequest] + } + } + } + const urlSearchParams = new URLSearchParams( + params as Record + ).toString() + const response = await withDedupe( + () => + request( + config, + `${config.apiUrl}/chains?${urlSearchParams}`, + { + signal: options?.signal, + } + ), + { id: `${getChains.name}.${urlSearchParams}` } + ) + return response.chains +} diff --git a/src/actions/getChains.unit.spec.ts b/src/actions/getChains.unit.spec.ts new file mode 100644 index 00000000..7c202e85 --- /dev/null +++ b/src/actions/getChains.unit.spec.ts @@ -0,0 +1,19 @@ +import { describe, expect, it, vi } from 'vitest' +import * as request from '../request.js' +import { client, setupTestServer } from './actions.unit.handlers.js' +import { getChains } from './getChains.js' + +const mockedFetch = vi.spyOn(request, 'request') + +describe('getChains', () => { + setupTestServer() + + describe('and the backend call is successful', () => { + it('call the server once', async () => { + const chains = await getChains(client) + + expect(chains[0]?.id).toEqual(1) + expect(mockedFetch).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/src/actions/getConnections.ts b/src/actions/getConnections.ts new file mode 100644 index 00000000..079d9c80 --- /dev/null +++ b/src/actions/getConnections.ts @@ -0,0 +1,55 @@ +import type { + ConnectionsRequest, + ConnectionsResponse, + RequestOptions, +} from '@lifi/types' +import { request } from '../request.js' +import type { SDKClient } from '../types/core.js' + +/** + * Get all the available connections for swap/bridging tokens + * @param client - The SDK client + * @param connectionRequest ConnectionsRequest + * @param options - Request options + * @returns ConnectionsResponse + */ +export const getConnections = async ( + client: SDKClient, + connectionRequest: ConnectionsRequest, + options?: RequestOptions +): Promise => { + const url = new URL(`${client.config.apiUrl}/connections`) + + const { fromChain, fromToken, toChain, toToken } = connectionRequest + + if (fromChain) { + url.searchParams.append('fromChain', fromChain as unknown as string) + } + if (fromToken) { + url.searchParams.append('fromToken', fromToken) + } + if (toChain) { + url.searchParams.append('toChain', toChain as unknown as string) + } + if (toToken) { + url.searchParams.append('toToken', toToken) + } + const connectionRequestArrayParams: Array = [ + 'allowBridges', + 'denyBridges', + 'preferBridges', + 'allowExchanges', + 'denyExchanges', + 'preferExchanges', + ] + for (const parameter of connectionRequestArrayParams) { + const connectionRequestArrayParam = connectionRequest[parameter] as string[] + + if (connectionRequestArrayParam?.length) { + for (const value of connectionRequestArrayParam) { + url.searchParams.append(parameter, value) + } + } + } + return await request(client.config, url, options) +} diff --git a/src/actions/getConnections.unit.spec.ts b/src/actions/getConnections.unit.spec.ts new file mode 100644 index 00000000..de9815c3 --- /dev/null +++ b/src/actions/getConnections.unit.spec.ts @@ -0,0 +1,45 @@ +import { findDefaultToken } from '@lifi/data-types' +import type { ConnectionsRequest } from '@lifi/types' +import { ChainId, CoinKey } from '@lifi/types' +import { HttpResponse, http } from 'msw' +import { describe, expect, it, vi } from 'vitest' +import * as request from '../request.js' +import { client, setupTestServer } from './actions.unit.handlers.js' +import { getConnections } from './getConnections.js' + +const mockedFetch = vi.spyOn(request, 'request') + +describe('getConnections', () => { + const server = setupTestServer() + + it('returns empty array in response', async () => { + server.use( + http.get(`${client.config.apiUrl}/connections`, async () => + HttpResponse.json({ connections: [] }) + ) + ) + + const connectionRequest: ConnectionsRequest = { + fromChain: ChainId.BSC, + toChain: ChainId.OPT, + fromToken: findDefaultToken(CoinKey.USDC, ChainId.BSC).address, + toToken: findDefaultToken(CoinKey.USDC, ChainId.OPT).address, + allowBridges: ['connext', 'uniswap', 'polygon'], + allowExchanges: ['1inch', 'ParaSwap', 'SushiSwap'], + denyBridges: ['Hop', 'Multichain'], + preferBridges: ['Hyphen', 'Across'], + denyExchanges: ['UbeSwap', 'BeamSwap'], + preferExchanges: ['Evmoswap', 'Diffusion'], + } + + const generatedURL = + 'https://li.quest/v1/connections?fromChain=56&fromToken=0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d&toChain=10&toToken=0x0b2c639c533813f4aa9d7837caf62653d097ff85&allowBridges=connext&allowBridges=uniswap&allowBridges=polygon&denyBridges=Hop&denyBridges=Multichain&preferBridges=Hyphen&preferBridges=Across&allowExchanges=1inch&allowExchanges=ParaSwap&allowExchanges=SushiSwap&denyExchanges=UbeSwap&denyExchanges=BeamSwap&preferExchanges=Evmoswap&preferExchanges=Diffusion' + + await expect(getConnections(client, connectionRequest)).resolves.toEqual({ + connections: [], + }) + + expect((mockedFetch.mock.calls[0][1] as URL).href).toEqual(generatedURL) + expect(mockedFetch).toHaveBeenCalledOnce() + }) +}) diff --git a/src/actions/getContractCallsQuote.ts b/src/actions/getContractCallsQuote.ts new file mode 100644 index 00000000..bd9109c0 --- /dev/null +++ b/src/actions/getContractCallsQuote.ts @@ -0,0 +1,81 @@ +import type { + ContractCallsQuoteRequest, + LiFiStep, + RequestOptions, +} from '@lifi/types' +import { + isContractCallsRequestWithFromAmount, + isContractCallsRequestWithToAmount, +} from '@lifi/types' +import { ValidationError } from '../errors/errors.js' +import { SDKError } from '../errors/SDKError.js' +import { request } from '../request.js' +import type { SDKClient } from '../types/core.js' + +/** + * Get a quote for a destination contract call + * @param client - The SDK client + * @param params - The configuration of the requested destination call + * @param options - Request options + * @throws {LiFiError} - Throws a LiFiError if request fails + * @returns - Returns step. + */ +export const getContractCallsQuote = async ( + client: SDKClient, + params: ContractCallsQuoteRequest, + options?: RequestOptions +): Promise => { + // validation + const requiredParameters: Array = [ + 'fromChain', + 'fromToken', + 'fromAddress', + 'toChain', + 'toToken', + 'contractCalls', + ] + for (const requiredParameter of requiredParameters) { + if (!params[requiredParameter]) { + throw new SDKError( + new ValidationError( + `Required parameter "${requiredParameter}" is missing.` + ) + ) + } + } + if ( + !isContractCallsRequestWithFromAmount(params) && + !isContractCallsRequestWithToAmount(params) + ) { + throw new SDKError( + new ValidationError( + `Required parameter "fromAmount" or "toAmount" is missing.` + ) + ) + } + // apply defaults + // option.order is not used in this endpoint + params.integrator ??= client.config.integrator + params.slippage ??= client.config.routeOptions?.slippage + params.referrer ??= client.config.routeOptions?.referrer + params.fee ??= client.config.routeOptions?.fee + params.allowBridges ??= client.config.routeOptions?.bridges?.allow + params.denyBridges ??= client.config.routeOptions?.bridges?.deny + params.preferBridges ??= client.config.routeOptions?.bridges?.prefer + params.allowExchanges ??= client.config.routeOptions?.exchanges?.allow + params.denyExchanges ??= client.config.routeOptions?.exchanges?.deny + params.preferExchanges ??= client.config.routeOptions?.exchanges?.prefer + // send request + return await request( + client.config, + `${client.config.apiUrl}/quote/contractCalls`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(params), + signal: options?.signal, + } + ) +} diff --git a/src/actions/getContractCallsQuote.unit.spec.ts b/src/actions/getContractCallsQuote.unit.spec.ts new file mode 100644 index 00000000..b3d4d2d8 --- /dev/null +++ b/src/actions/getContractCallsQuote.unit.spec.ts @@ -0,0 +1,323 @@ +import type { + ContractCallsQuoteRequest, + LiFiStep, + RequestOptions, +} from '@lifi/types' +import { ChainId } from '@lifi/types' +import { HttpResponse, http } from 'msw' +import { describe, expect, it } from 'vitest' +import { ValidationError } from '../errors/errors.js' +import { SDKError } from '../errors/SDKError.js' +import { client, setupTestServer } from './actions.unit.handlers.js' +import { getContractCallsQuote } from './getContractCallsQuote.js' + +describe('getContractCallsQuote', () => { + const server = setupTestServer() + + const createMockContractCallsRequest = ( + overrides: Partial = {} + ): ContractCallsQuoteRequest => ({ + fromChain: ChainId.ETH, + fromToken: '0xA0b86a33E6441c8C06DDD4f36e4C4C5B4c3B4c3B', + fromAddress: '0x1234567890123456789012345678901234567890', + toChain: ChainId.POL, + toToken: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', + contractCalls: [ + { + fromAmount: '1000000', + fromTokenAddress: '0xA0b86a33E6441c8C06DDD4f36e4C4C5B4c3B4c3B', + toContractAddress: '0x1234567890123456789012345678901234567890', + toContractCallData: '0x1234567890abcdef', + toContractGasLimit: '100000', + }, + ], + fromAmount: '1000000', + ...overrides, + }) + + const mockLiFiStep: LiFiStep = { + id: 'test-step-id', + type: 'lifi', + includedSteps: [], + tool: 'test-tool', + toolDetails: { + key: 'test-tool', + name: 'Test Tool', + logoURI: 'https://example.com/logo.png', + }, + action: { + fromChainId: ChainId.ETH, + toChainId: ChainId.POL, + fromToken: { + address: '0xA0b86a33E6441c8C06DDD4f36e4C4C5B4c3B4c3B', + symbol: 'USDC', + decimals: 6, + chainId: ChainId.ETH, + name: 'USD Coin', + priceUSD: '1.00', + }, + toToken: { + address: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', + symbol: 'USDC', + decimals: 6, + chainId: ChainId.POL, + name: 'USD Coin', + priceUSD: '1.00', + }, + fromAmount: '1000000', + fromAddress: '0x1234567890123456789012345678901234567890', + toAddress: '0x1234567890123456789012345678901234567890', + }, + estimate: { + fromAmount: '1000000', + toAmount: '1000000', + toAmountMin: '970000', + approvalAddress: '0x1234567890123456789012345678901234567890', + tool: 'test-tool', + executionDuration: 30000, + }, + transactionRequest: { + to: '0x1234567890123456789012345678901234567890', + data: '0x', + value: '0', + gasLimit: '100000', + }, + } + + describe('success scenarios', () => { + it('should get contract calls quote successfully with fromAmount', async () => { + server.use( + http.post(`${client.config.apiUrl}/quote/contractCalls`, async () => { + return HttpResponse.json(mockLiFiStep) + }) + ) + + const request = createMockContractCallsRequest({ + fromAmount: '1000000', + }) + + const result = await getContractCallsQuote(client, request) + + expect(result).toEqual(mockLiFiStep) + }) + + it('should get contract calls quote successfully with toAmount', async () => { + server.use( + http.post(`${client.config.apiUrl}/quote/contractCalls`, async () => { + return HttpResponse.json(mockLiFiStep) + }) + ) + + const request = createMockContractCallsRequest({ + toAmount: '1000000', + fromAmount: undefined, + }) + + const result = await getContractCallsQuote(client, request) + + expect(result).toEqual(mockLiFiStep) + }) + + it('should pass request options correctly', async () => { + const mockAbortController = new AbortController() + const options: RequestOptions = { + signal: mockAbortController.signal, + } + + let capturedOptions: any + server.use( + http.post( + `${client.config.apiUrl}/quote/contractCalls`, + async ({ request }) => { + capturedOptions = request + return HttpResponse.json(mockLiFiStep) + } + ) + ) + + const request = createMockContractCallsRequest() + + await getContractCallsQuote(client, request, options) + + expect(capturedOptions.signal).toBeDefined() + expect(capturedOptions.signal).toBeInstanceOf(AbortSignal) + }) + }) + + describe('validation scenarios', () => { + it('should throw SDKError when fromChain is missing', async () => { + const invalidRequest = createMockContractCallsRequest({ + fromChain: undefined as any, + }) + + await expect( + getContractCallsQuote(client, invalidRequest) + ).rejects.toThrow(SDKError) + + try { + await getContractCallsQuote(client, invalidRequest) + } catch (error) { + expect(error).toBeInstanceOf(SDKError) + expect((error as SDKError).cause).toBeInstanceOf(ValidationError) + expect((error as SDKError).cause.message).toBe( + 'Required parameter "fromChain" is missing.' + ) + } + }) + + it('should throw SDKError when fromToken is missing', async () => { + const invalidRequest = createMockContractCallsRequest({ + fromToken: undefined as any, + }) + + await expect( + getContractCallsQuote(client, invalidRequest) + ).rejects.toThrow(SDKError) + + try { + await getContractCallsQuote(client, invalidRequest) + } catch (error) { + expect(error).toBeInstanceOf(SDKError) + expect((error as SDKError).cause).toBeInstanceOf(ValidationError) + expect((error as SDKError).cause.message).toBe( + 'Required parameter "fromToken" is missing.' + ) + } + }) + + it('should throw SDKError when fromAddress is missing', async () => { + const invalidRequest = createMockContractCallsRequest({ + fromAddress: undefined as any, + }) + + await expect( + getContractCallsQuote(client, invalidRequest) + ).rejects.toThrow(SDKError) + + try { + await getContractCallsQuote(client, invalidRequest) + } catch (error) { + expect(error).toBeInstanceOf(SDKError) + expect((error as SDKError).cause).toBeInstanceOf(ValidationError) + expect((error as SDKError).cause.message).toBe( + 'Required parameter "fromAddress" is missing.' + ) + } + }) + + it('should throw SDKError when toChain is missing', async () => { + const invalidRequest = createMockContractCallsRequest({ + toChain: undefined as any, + }) + + await expect( + getContractCallsQuote(client, invalidRequest) + ).rejects.toThrow(SDKError) + + try { + await getContractCallsQuote(client, invalidRequest) + } catch (error) { + expect(error).toBeInstanceOf(SDKError) + expect((error as SDKError).cause).toBeInstanceOf(ValidationError) + expect((error as SDKError).cause.message).toBe( + 'Required parameter "toChain" is missing.' + ) + } + }) + + it('should throw SDKError when toToken is missing', async () => { + const invalidRequest = createMockContractCallsRequest({ + toToken: undefined as any, + }) + + await expect( + getContractCallsQuote(client, invalidRequest) + ).rejects.toThrow(SDKError) + + try { + await getContractCallsQuote(client, invalidRequest) + } catch (error) { + expect(error).toBeInstanceOf(SDKError) + expect((error as SDKError).cause).toBeInstanceOf(ValidationError) + expect((error as SDKError).cause.message).toBe( + 'Required parameter "toToken" is missing.' + ) + } + }) + + it('should throw SDKError when contractCalls is missing', async () => { + const invalidRequest = createMockContractCallsRequest({ + contractCalls: undefined as any, + }) + + await expect( + getContractCallsQuote(client, invalidRequest) + ).rejects.toThrow(SDKError) + + try { + await getContractCallsQuote(client, invalidRequest) + } catch (error) { + expect(error).toBeInstanceOf(SDKError) + expect((error as SDKError).cause).toBeInstanceOf(ValidationError) + expect((error as SDKError).cause.message).toBe( + 'Required parameter "contractCalls" is missing.' + ) + } + }) + + it('should throw SDKError when both fromAmount and toAmount are missing', async () => { + const invalidRequest = createMockContractCallsRequest() + // Remove both fromAmount and toAmount to test validation + delete (invalidRequest as any).fromAmount + delete (invalidRequest as any).toAmount + + await expect( + getContractCallsQuote(client, invalidRequest) + ).rejects.toThrow(SDKError) + + try { + await getContractCallsQuote(client, invalidRequest) + } catch (error) { + expect(error).toBeInstanceOf(SDKError) + expect((error as SDKError).cause).toBeInstanceOf(ValidationError) + expect((error as SDKError).cause.message).toBe( + 'Required parameter "fromAmount" or "toAmount" is missing.' + ) + } + }) + }) + + describe('error scenarios', () => { + it('should throw SDKError when network request fails', async () => { + server.use( + http.post(`${client.config.apiUrl}/quote/contractCalls`, async () => { + return HttpResponse.error() + }) + ) + + const request = createMockContractCallsRequest() + + await expect(getContractCallsQuote(client, request)).rejects.toThrow( + SDKError + ) + }) + + it('should throw SDKError when request times out', async () => { + server.use( + http.post(`${client.config.apiUrl}/quote/contractCalls`, async () => { + // Simulate timeout by not responding + await new Promise(() => {}) // Never resolves + }) + ) + + const request = createMockContractCallsRequest() + const timeoutOptions: RequestOptions = { + signal: AbortSignal.timeout(100), // 100ms timeout + } + + await expect( + getContractCallsQuote(client, request, timeoutOptions) + ).rejects.toThrow() + }) + }) +}) diff --git a/src/actions/getGasRecommendation.ts b/src/actions/getGasRecommendation.ts new file mode 100644 index 00000000..47412fd9 --- /dev/null +++ b/src/actions/getGasRecommendation.ts @@ -0,0 +1,47 @@ +import type { + GasRecommendationRequest, + GasRecommendationResponse, + RequestOptions, +} from '@lifi/types' +import { ValidationError } from '../errors/errors.js' +import { SDKError } from '../errors/SDKError.js' +import { request } from '../request.js' +import type { SDKClient } from '../types/core.js' + +/** + * Get gas recommendation for a certain chain + * @param client - The SDK client + * @param params - Configuration of the requested gas recommendation. + * @param options - Request options + * @throws {LiFiError} Throws a LiFiError if request fails. + * @returns Gas recommendation response. + */ +export const getGasRecommendation = async ( + client: SDKClient, + params: GasRecommendationRequest, + options?: RequestOptions +): Promise => { + if (!params.chainId) { + throw new SDKError( + new ValidationError('Required parameter "chainId" is missing.') + ) + } + + const url = new URL( + `${client.config.apiUrl}/gas/suggestion/${params.chainId}` + ) + if (params.fromChain) { + url.searchParams.append('fromChain', params.fromChain as unknown as string) + } + if (params.fromToken) { + url.searchParams.append('fromToken', params.fromToken) + } + + return await request( + client.config, + url.toString(), + { + signal: options?.signal, + } + ) +} diff --git a/src/actions/getGasRecommendation.unit.spec.ts b/src/actions/getGasRecommendation.unit.spec.ts new file mode 100644 index 00000000..29369248 --- /dev/null +++ b/src/actions/getGasRecommendation.unit.spec.ts @@ -0,0 +1,40 @@ +import { ChainId } from '@lifi/types' +import { describe, expect, it, vi } from 'vitest' +import { ValidationError } from '../errors/errors.js' +import { SDKError } from '../errors/SDKError.js' +import * as request from '../request.js' +import { client, setupTestServer } from './actions.unit.handlers.js' +import { getGasRecommendation } from './getGasRecommendation.js' + +const mockedFetch = vi.spyOn(request, 'request') + +describe('getGasRecommendation', () => { + setupTestServer() + + describe('user input is invalid', () => { + it('throw an error', async () => { + await expect( + getGasRecommendation(client, { + chainId: undefined as unknown as number, + }) + ).rejects.toThrowError( + new SDKError( + new ValidationError('Required parameter "chainId" is missing.') + ) + ) + expect(mockedFetch).toHaveBeenCalledTimes(0) + }) + }) + + describe('user input is valid', () => { + describe('and the backend call is successful', () => { + it('call the server once', async () => { + await getGasRecommendation(client, { + chainId: ChainId.OPT, + }) + + expect(mockedFetch).toHaveBeenCalledTimes(1) + }) + }) + }) +}) diff --git a/src/services/getNameServiceAddress.ts b/src/actions/getNameServiceAddress.ts similarity index 51% rename from src/services/getNameServiceAddress.ts rename to src/actions/getNameServiceAddress.ts index d8cb1e7a..a2bc3422 100644 --- a/src/services/getNameServiceAddress.ts +++ b/src/actions/getNameServiceAddress.ts @@ -1,14 +1,26 @@ import type { ChainType } from '@lifi/types' -import { config } from '../config.js' +import type { SDKClient } from '../types/core.js' +/** + * Get the address of a name service + * @param client - The SDK client + * @param name - The name to resolve + * @param chainType - The chain type to resolve the name on + * @returns The address of the name service + */ export const getNameServiceAddress = async ( + client: SDKClient, name: string, chainType?: ChainType ): Promise => { try { - let providers = config.get().providers + let providers = [] if (chainType) { - providers = providers.filter((provider) => provider.type === chainType) + providers = client.providers.filter( + (provider) => provider.type === chainType + ) + } else { + providers = client.providers } const resolvers = providers.map((provider) => provider.resolveAddress) if (!resolvers.length) { @@ -16,7 +28,7 @@ export const getNameServiceAddress = async ( } const result = await Promise.any( resolvers.map(async (resolve) => { - const address = await resolve(name) + const address = await resolve(name, client) if (!address) { throw undefined } diff --git a/src/actions/getNameServiceAddress.unit.spec.ts b/src/actions/getNameServiceAddress.unit.spec.ts new file mode 100644 index 00000000..a6e42787 --- /dev/null +++ b/src/actions/getNameServiceAddress.unit.spec.ts @@ -0,0 +1,157 @@ +import type { ChainType } from '@lifi/types' +import { describe, expect, it, vi } from 'vitest' +import { EVM } from '../core/EVM/EVM.js' +import { Solana } from '../core/Solana/Solana.js' +import { UTXO } from '../core/UTXO/UTXO.js' +import { client } from './actions.unit.handlers.js' +import { getNameServiceAddress } from './getNameServiceAddress.js' + +describe('getNameServiceAddress', () => { + describe('success scenarios', () => { + it('should resolve address successfully with single provider', async () => { + const mockResolveAddress = vi + .fn() + .mockResolvedValue('0x1234567890123456789012345678901234567890') + + const provider = EVM({ getWalletClient: vi.fn() }) + vi.spyOn(provider, 'resolveAddress').mockImplementation( + mockResolveAddress + ) + + client.setProviders([provider]) + + const result = await getNameServiceAddress(client, 'test.eth') + + expect(result).toBe('0x1234567890123456789012345678901234567890') + expect(mockResolveAddress).toHaveBeenCalledWith('test.eth', client) + }) + + it('should resolve address successfully with multiple providers', async () => { + const mockResolveAddress1 = vi + .fn() + .mockResolvedValue('0x1111111111111111111111111111111111111111') + const mockResolveAddress2 = vi + .fn() + .mockResolvedValue('0x2222222222222222222222222222222222222222') + + const provider1 = EVM({ getWalletClient: vi.fn() }) + const provider2 = Solana({ getWalletAdapter: vi.fn() }) + + vi.spyOn(provider1, 'resolveAddress').mockImplementation( + mockResolveAddress1 + ) + vi.spyOn(provider2, 'resolveAddress').mockImplementation( + mockResolveAddress2 + ) + + client.setProviders([provider1, provider2]) + + const result = await getNameServiceAddress(client, 'test.sol') + + // Should return the first successful result + expect(result).toBe('0x1111111111111111111111111111111111111111') + expect(mockResolveAddress1).toHaveBeenCalledWith('test.sol', client) + }) + + it('should resolve address with specific chain type', async () => { + const mockResolveAddress = vi + .fn() + .mockResolvedValue('0x1234567890123456789012345678901234567890') + + const evmProvider = EVM({ getWalletClient: vi.fn() }) + const svmProvider = Solana({ getWalletAdapter: vi.fn() }) + + vi.spyOn(evmProvider, 'resolveAddress').mockImplementation( + mockResolveAddress + ) + + client.setProviders([evmProvider, svmProvider]) + + const result = await getNameServiceAddress( + client, + 'test.eth', + 'EVM' as ChainType + ) + + expect(result).toBe('0x1234567890123456789012345678901234567890') + expect(mockResolveAddress).toHaveBeenCalledWith('test.eth', client) + }) + }) + + describe('chain type filtering', () => { + it('should filter providers by chain type', async () => { + const mockResolveAddress = vi + .fn() + .mockResolvedValue('0x1234567890123456789012345678901234567890') + + const evmProvider = EVM({ getWalletClient: vi.fn() }) + const svmProvider = Solana({ getWalletAdapter: vi.fn() }) + const utxoProvider = UTXO({ getWalletClient: vi.fn() }) + + vi.spyOn(evmProvider, 'resolveAddress').mockImplementation( + mockResolveAddress + ) + + client.setProviders([evmProvider, svmProvider, utxoProvider]) + + const result = await getNameServiceAddress( + client, + 'test.eth', + 'EVM' as ChainType + ) + + expect(result).toBe('0x1234567890123456789012345678901234567890') + expect(mockResolveAddress).toHaveBeenCalledWith('test.eth', client) + }) + + it('should return undefined when no providers match chain type', async () => { + const mockResolveAddress = vi + .fn() + .mockResolvedValue('0x1234567890123456789012345678901234567890') + const evmProvider = EVM({ getWalletClient: vi.fn() }) + client.setProviders([evmProvider]) + + const result = await getNameServiceAddress( + client, + 'test.name', + 'SVM' as ChainType + ) + + expect(result).toBeUndefined() + expect(mockResolveAddress).not.toHaveBeenCalled() + }) + }) + + describe('error scenarios', () => { + it('should handle mixed success and failure scenarios', async () => { + const mockResolveAddress1 = vi + .fn() + .mockRejectedValue(new Error('Provider 1 failed')) + const mockResolveAddress2 = vi + .fn() + .mockResolvedValue('0x2222222222222222222222222222222222222222') + const mockResolveAddress3 = vi.fn().mockResolvedValue(undefined) + + const provider1 = EVM({ getWalletClient: vi.fn() }) + const provider2 = Solana({ getWalletAdapter: vi.fn() }) + const provider3 = UTXO({ getWalletClient: vi.fn() }) + + vi.spyOn(provider1, 'resolveAddress').mockImplementation( + mockResolveAddress1 + ) + vi.spyOn(provider2, 'resolveAddress').mockImplementation( + mockResolveAddress2 + ) + vi.spyOn(provider3, 'resolveAddress').mockImplementation( + mockResolveAddress3 + ) + + client.setProviders([provider1, provider2, provider3]) + + const result = await getNameServiceAddress(client, 'test.name') + + // Should return the first successful result (provider2) + expect(result).toBe('0x2222222222222222222222222222222222222222') + }) + }) +}) diff --git a/src/services/api.int.spec.ts b/src/actions/getQuote.int.spec.ts similarity index 79% rename from src/services/api.int.spec.ts rename to src/actions/getQuote.int.spec.ts index ff3b56aa..3c31d3aa 100644 --- a/src/services/api.int.spec.ts +++ b/src/actions/getQuote.int.spec.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from 'vitest' -import { getQuote } from './api.js' +import { client } from './actions.unit.handlers.js' +import { getQuote } from './getQuote.js' describe('ApiService Integration Tests', () => { it('should successfully request a quote', async () => { - const quote = await getQuote({ + const quote = await getQuote(client, { fromChain: '1', fromToken: '0x0000000000000000000000000000000000000000', fromAddress: '0x552008c0f6870c2f77e5cC1d2eb9bdff03e30Ea0', diff --git a/src/actions/getQuote.ts b/src/actions/getQuote.ts new file mode 100644 index 00000000..4cd23ae1 --- /dev/null +++ b/src/actions/getQuote.ts @@ -0,0 +1,102 @@ +import type { LiFiStep, RequestOptions } from '@lifi/types' +import { ValidationError } from '../errors/errors.js' +import { SDKError } from '../errors/SDKError.js' +import { request } from '../request.js' +import type { + QuoteRequest, + QuoteRequestFromAmount, + QuoteRequestToAmount, +} from '../types/actions.js' +import type { SDKClient } from '../types/core.js' + +/** + * Get a quote for a token transfer + * @param client - The SDK client + * @param params - The configuration of the requested quote + * @param options - Request options + * @throws {LiFiError} - Throws a LiFiError if request fails + * @returns Quote for a token transfer + */ +export async function getQuote( + client: SDKClient, + params: QuoteRequestFromAmount, + options?: RequestOptions +): Promise +export async function getQuote( + client: SDKClient, + params: QuoteRequestToAmount, + options?: RequestOptions +): Promise +export async function getQuote( + client: SDKClient, + params: QuoteRequest, + options?: RequestOptions +): Promise { + const requiredParameters: Array = [ + 'fromChain', + 'fromToken', + 'fromAddress', + 'toChain', + 'toToken', + ] + + for (const requiredParameter of requiredParameters) { + if (!params[requiredParameter]) { + throw new SDKError( + new ValidationError( + `Required parameter "${requiredParameter}" is missing.` + ) + ) + } + } + + const isFromAmountRequest = + 'fromAmount' in params && params.fromAmount !== undefined + const isToAmountRequest = + 'toAmount' in params && params.toAmount !== undefined + + if (!isFromAmountRequest && !isToAmountRequest) { + throw new SDKError( + new ValidationError( + 'Required parameter "fromAmount" or "toAmount" is missing.' + ) + ) + } + + if (isFromAmountRequest && isToAmountRequest) { + throw new SDKError( + new ValidationError( + 'Cannot provide both "fromAmount" and "toAmount" parameters.' + ) + ) + } + + // apply defaults + params.integrator ??= client.config.integrator + params.order ??= client.config.routeOptions?.order + params.slippage ??= client.config.routeOptions?.slippage + params.referrer ??= client.config.routeOptions?.referrer + params.fee ??= client.config.routeOptions?.fee + params.allowBridges ??= client.config.routeOptions?.bridges?.allow + params.denyBridges ??= client.config.routeOptions?.bridges?.deny + params.preferBridges ??= client.config.routeOptions?.bridges?.prefer + params.allowExchanges ??= client.config.routeOptions?.exchanges?.allow + params.denyExchanges ??= client.config.routeOptions?.exchanges?.deny + params.preferExchanges ??= client.config.routeOptions?.exchanges?.prefer + + for (const key of Object.keys(params)) { + if (!params[key as keyof QuoteRequest]) { + delete params[key as keyof QuoteRequest] + } + } + + return await request( + client.config, + `${client.config.apiUrl}/${isFromAmountRequest ? 'quote' : 'quote/toAmount'}?${new URLSearchParams( + params as unknown as Record + )}`, + { + signal: options?.signal, + } + ) +} diff --git a/src/actions/getQuote.unit.spec.ts b/src/actions/getQuote.unit.spec.ts new file mode 100644 index 00000000..f2fe2cc9 --- /dev/null +++ b/src/actions/getQuote.unit.spec.ts @@ -0,0 +1,154 @@ +import { ChainId } from '@lifi/types' +import { describe, expect, it, vi } from 'vitest' +import { ValidationError } from '../errors/errors.js' +import { SDKError } from '../errors/SDKError.js' +import * as request from '../request.js' +import { client, setupTestServer } from './actions.unit.handlers.js' +import { getQuote } from './getQuote.js' + +const mockedFetch = vi.spyOn(request, 'request') + +describe('getQuote', () => { + setupTestServer() + + const fromChain = ChainId.DAI + const fromToken = 'DAI' + const fromAddress = 'Some wallet address' + const fromAmount = '1000' + const toChain = ChainId.POL + const toToken = 'MATIC' + const toAmount = '1000' + + describe('user input is invalid', () => { + it('throw an error', async () => { + await expect( + getQuote(client, { + fromChain: undefined as unknown as ChainId, + fromToken, + fromAddress, + fromAmount, + toChain, + toToken, + }) + ).rejects.toThrowError( + new SDKError( + new ValidationError('Required parameter "fromChain" is missing.') + ) + ) + + await expect( + getQuote(client, { + fromChain, + fromToken: undefined as unknown as string, + fromAddress, + fromAmount, + toChain, + toToken, + }) + ).rejects.toThrowError( + new SDKError( + new ValidationError('Required parameter "fromToken" is missing.') + ) + ) + + await expect( + getQuote(client, { + fromChain, + fromToken, + fromAddress: undefined as unknown as string, + fromAmount, + toChain, + toToken, + }) + ).rejects.toThrowError( + new SDKError( + new ValidationError('Required parameter "fromAddress" is missing.') + ) + ) + + await expect( + getQuote(client, { + fromChain, + fromToken, + fromAddress, + fromAmount: undefined as unknown as string, + toChain, + toToken, + }) + ).rejects.toThrowError( + new SDKError( + new ValidationError( + 'Required parameter "fromAmount" or "toAmount" is missing.' + ) + ) + ) + + await expect( + getQuote(client, { + fromChain, + fromToken, + fromAddress, + fromAmount, + toChain, + toToken, + toAmount, + } as any) + ).rejects.toThrowError( + new SDKError( + new ValidationError( + 'Cannot provide both "fromAmount" and "toAmount" parameters.' + ) + ) + ) + + await expect( + getQuote(client, { + fromChain, + fromToken, + fromAddress, + fromAmount, + toChain: undefined as unknown as ChainId, + toToken, + }) + ).rejects.toThrowError( + new SDKError( + new ValidationError('Required parameter "toChain" is missing.') + ) + ) + + await expect( + getQuote(client, { + fromChain, + fromToken, + fromAddress, + fromAmount, + toChain, + toToken: undefined as unknown as string, + }) + ).rejects.toThrowError( + new SDKError( + new ValidationError('Required parameter "toToken" is missing.') + ) + ) + + expect(mockedFetch).toHaveBeenCalledTimes(0) + }) + }) + + describe('user input is valid', () => { + describe('and the backend call is successful', () => { + it('call the server once', async () => { + await getQuote(client, { + fromChain, + fromToken, + fromAddress, + fromAmount, + toChain, + toToken, + }) + + expect(mockedFetch).toHaveBeenCalledTimes(1) + }) + }) + }) +}) diff --git a/src/actions/getRelayedTransactionStatus.ts b/src/actions/getRelayedTransactionStatus.ts new file mode 100644 index 00000000..a066fe2b --- /dev/null +++ b/src/actions/getRelayedTransactionStatus.ts @@ -0,0 +1,54 @@ +import type { + RelayStatusRequest, + RelayStatusResponse, + RelayStatusResponseData, + RequestOptions, +} from '@lifi/types' +import { BaseError } from '../errors/baseError.js' +import { ErrorName } from '../errors/constants.js' +import { ValidationError } from '../errors/errors.js' +import { SDKError } from '../errors/SDKError.js' +import { request } from '../request.js' +import type { SDKClient } from '../types/core.js' + +/** + * Get the status of a relayed transaction + * @param client - The SDK client + * @param params - Parameters for the relay status request + * @param options - Request options + * @throws {LiFiError} - Throws a LiFiError if request fails + * @returns Status of the relayed transaction + */ +export const getRelayedTransactionStatus = async ( + client: SDKClient, + params: RelayStatusRequest, + options?: RequestOptions +): Promise => { + if (!params.taskId) { + throw new SDKError( + new ValidationError('Required parameter "taskId" is missing.') + ) + } + + const { taskId, ...otherParams } = params + const queryParams = new URLSearchParams( + otherParams as unknown as Record + ) + const result = await request( + client.config, + `${client.config.apiUrl}/relayer/status/${taskId}?${queryParams}`, + { + signal: options?.signal, + } + ) + + if (result.status === 'error') { + throw new BaseError( + ErrorName.ServerError, + result.data.code, + result.data.message + ) + } + + return result.data +} diff --git a/src/actions/getRelayedTransactionStatus.unit.spec.ts b/src/actions/getRelayedTransactionStatus.unit.spec.ts new file mode 100644 index 00000000..09988291 --- /dev/null +++ b/src/actions/getRelayedTransactionStatus.unit.spec.ts @@ -0,0 +1,243 @@ +import type { + RelayStatusRequest, + RelayStatusResponse, + RelayStatusResponseData, + RequestOptions, +} from '@lifi/types' +import { HttpResponse, http } from 'msw' +import { describe, expect, it } from 'vitest' +import { BaseError } from '../errors/baseError.js' +import { ErrorName } from '../errors/constants.js' +import { ValidationError } from '../errors/errors.js' +import { SDKError } from '../errors/SDKError.js' +import { client, setupTestServer } from './actions.unit.handlers.js' +import { getRelayedTransactionStatus } from './getRelayedTransactionStatus.js' + +describe('getRelayedTransactionStatus', () => { + const server = setupTestServer() + + const createMockRelayStatusRequest = ( + overrides: Partial = {} + ): RelayStatusRequest => ({ + taskId: + '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + ...overrides, + }) + + const mockStatusResponseData: RelayStatusResponseData = { + status: 'PENDING', + metadata: { + chainId: 1, + }, + } + + const mockSuccessResponse: RelayStatusResponse = { + status: 'ok', + data: mockStatusResponseData, + } + + const mockErrorResponse: RelayStatusResponse = { + status: 'error', + data: { + code: 404, + message: 'Task not found', + }, + } + + describe('success scenarios', () => { + it('should get relayed transaction status successfully', async () => { + server.use( + http.get( + `${client.config.apiUrl}/relayer/status/0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef`, + async () => { + return HttpResponse.json(mockSuccessResponse) + } + ) + ) + + const request = createMockRelayStatusRequest() + + const result = await getRelayedTransactionStatus(client, request) + + expect(result).toEqual(mockStatusResponseData) + }) + + it('should pass request options correctly', async () => { + const mockAbortController = new AbortController() + const options: RequestOptions = { + signal: mockAbortController.signal, + } + + let capturedOptions: any + server.use( + http.get( + `${client.config.apiUrl}/relayer/status/0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef`, + async ({ request }) => { + capturedOptions = request + return HttpResponse.json(mockSuccessResponse) + } + ) + ) + + const request = createMockRelayStatusRequest() + + await getRelayedTransactionStatus(client, request, options) + + expect(capturedOptions.signal).toBeDefined() + expect(capturedOptions.signal).toBeInstanceOf(AbortSignal) + }) + + it('should handle different task statuses', async () => { + const pendingResponse = { + ...mockSuccessResponse, + data: { ...mockStatusResponseData, status: 'PENDING' }, + } + const completedResponse = { + ...mockSuccessResponse, + data: { ...mockStatusResponseData, status: 'COMPLETED' }, + } + const failedResponse = { + ...mockSuccessResponse, + data: { ...mockStatusResponseData, status: 'FAILED' }, + } + + // Test PENDING status + server.use( + http.get( + `${client.config.apiUrl}/relayer/status/0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef`, + async () => { + return HttpResponse.json(pendingResponse) + } + ) + ) + + let result = await getRelayedTransactionStatus( + client, + createMockRelayStatusRequest() + ) + expect(result.status).toBe('PENDING') + + // Test COMPLETED status + server.resetHandlers() + server.use( + http.get( + `${client.config.apiUrl}/relayer/status/0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef`, + async () => { + return HttpResponse.json(completedResponse) + } + ) + ) + + result = await getRelayedTransactionStatus( + client, + createMockRelayStatusRequest() + ) + expect(result.status).toBe('COMPLETED') + + // Test FAILED status + server.resetHandlers() + server.use( + http.get( + `${client.config.apiUrl}/relayer/status/0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef`, + async () => { + return HttpResponse.json(failedResponse) + } + ) + ) + + result = await getRelayedTransactionStatus( + client, + createMockRelayStatusRequest() + ) + expect(result.status).toBe('FAILED') + }) + }) + + describe('validation scenarios', () => { + it('should throw SDKError when taskId is missing', async () => { + const invalidRequest = createMockRelayStatusRequest({ + taskId: undefined as any, + }) + + await expect( + getRelayedTransactionStatus(client, invalidRequest) + ).rejects.toThrow(SDKError) + + try { + await getRelayedTransactionStatus(client, invalidRequest) + } catch (error) { + expect(error).toBeInstanceOf(SDKError) + expect((error as SDKError).cause).toBeInstanceOf(ValidationError) + expect((error as SDKError).cause.message).toBe( + 'Required parameter "taskId" is missing.' + ) + } + }) + }) + + describe('error scenarios', () => { + it('should throw BaseError when server returns error status', async () => { + server.use( + http.get( + `${client.config.apiUrl}/relayer/status/0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef`, + async () => { + return HttpResponse.json(mockErrorResponse) + } + ) + ) + + const request = createMockRelayStatusRequest() + + await expect( + getRelayedTransactionStatus(client, request) + ).rejects.toThrow(BaseError) + + try { + await getRelayedTransactionStatus(client, request) + } catch (error) { + expect(error).toBeInstanceOf(BaseError) + expect((error as BaseError).name).toBe(ErrorName.ServerError) + expect((error as BaseError).code).toBe(404) + expect((error as BaseError).message).toBe('Task not found') + } + }) + + it('should throw SDKError when network request fails', async () => { + server.use( + http.get( + `${client.config.apiUrl}/relayer/status/0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef`, + async () => { + return HttpResponse.error() + } + ) + ) + + const request = createMockRelayStatusRequest() + + await expect( + getRelayedTransactionStatus(client, request) + ).rejects.toThrow(SDKError) + }) + + it('should throw SDKError when request times out', async () => { + server.use( + http.get( + `${client.config.apiUrl}/relayer/status/0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef`, + async () => { + // Simulate timeout by not responding + await new Promise(() => {}) // Never resolves + } + ) + ) + + const request = createMockRelayStatusRequest() + const timeoutOptions: RequestOptions = { + signal: AbortSignal.timeout(100), // 100ms timeout + } + + await expect( + getRelayedTransactionStatus(client, request, timeoutOptions) + ).rejects.toThrow() + }) + }) +}) diff --git a/src/actions/getRelayerQuote.ts b/src/actions/getRelayerQuote.ts new file mode 100644 index 00000000..fae19e30 --- /dev/null +++ b/src/actions/getRelayerQuote.ts @@ -0,0 +1,83 @@ +import type { + LiFiStep, + RelayerQuoteResponse, + RequestOptions, +} from '@lifi/types' +import { BaseError } from '../errors/baseError.js' +import { ErrorName } from '../errors/constants.js' +import { ValidationError } from '../errors/errors.js' +import { SDKError } from '../errors/SDKError.js' +import { request } from '../request.js' +import type { QuoteRequest, QuoteRequestFromAmount } from '../types/actions.js' +import type { SDKClient } from '../types/core.js' + +/** + * Get a relayer quote for a token transfer + * @param client - The SDK client + * @param params - The configuration of the requested quote + * @param options - Request options + * @throws {LiFiError} - Throws a LiFiError if request fails + * @returns Relayer quote for a token transfer + */ +export const getRelayerQuote = async ( + client: SDKClient, + params: QuoteRequestFromAmount, + options?: RequestOptions +): Promise => { + const requiredParameters: Array = [ + 'fromChain', + 'fromToken', + 'fromAddress', + 'fromAmount', + 'toChain', + 'toToken', + ] + for (const requiredParameter of requiredParameters) { + if (!params[requiredParameter]) { + throw new SDKError( + new ValidationError( + `Required parameter "${requiredParameter}" is missing.` + ) + ) + } + } + + // apply defaults + params.integrator ??= client.config.integrator + params.order ??= client.config.routeOptions?.order + params.slippage ??= client.config.routeOptions?.slippage + params.referrer ??= client.config.routeOptions?.referrer + params.fee ??= client.config.routeOptions?.fee + params.allowBridges ??= client.config.routeOptions?.bridges?.allow + params.denyBridges ??= client.config.routeOptions?.bridges?.deny + params.preferBridges ??= client.config.routeOptions?.bridges?.prefer + params.allowExchanges ??= client.config.routeOptions?.exchanges?.allow + params.denyExchanges ??= client.config.routeOptions?.exchanges?.deny + params.preferExchanges ??= client.config.routeOptions?.exchanges?.prefer + + for (const key of Object.keys(params)) { + if (!params[key as keyof QuoteRequest]) { + delete params[key as keyof QuoteRequest] + } + } + + const result = await request( + client.config, + `${client.config.apiUrl}/relayer/quote?${new URLSearchParams( + params as unknown as Record + )}`, + { + signal: options?.signal, + } + ) + + if (result.status === 'error') { + throw new BaseError( + ErrorName.ServerError, + result.data.code, + result.data.message + ) + } + + return result.data +} diff --git a/src/actions/getRelayerQuote.unit.spec.ts b/src/actions/getRelayerQuote.unit.spec.ts new file mode 100644 index 00000000..6b410162 --- /dev/null +++ b/src/actions/getRelayerQuote.unit.spec.ts @@ -0,0 +1,220 @@ +import type { + LiFiStep, + RelayerQuoteResponse, + RequestOptions, +} from '@lifi/types' +import { ChainId } from '@lifi/types' +import { HttpResponse, http } from 'msw' +import { describe, expect, it } from 'vitest' +import { BaseError } from '../errors/baseError.js' +import { ErrorName } from '../errors/constants.js' +import { ValidationError } from '../errors/errors.js' +import { SDKError } from '../errors/SDKError.js' +import type { QuoteRequestFromAmount } from '../types/actions.js' +import { client, setupTestServer } from './actions.unit.handlers.js' +import { getRelayerQuote } from './getRelayerQuote.js' + +describe('getRelayerQuote', () => { + const server = setupTestServer() + + const createMockQuoteRequest = ( + overrides: Partial = {} + ): QuoteRequestFromAmount => ({ + fromChain: ChainId.ETH, + fromToken: '0xA0b86a33E6441c8C06DDD4f36e4C4C5B4c3B4c3B', + fromAddress: '0x1234567890123456789012345678901234567890', + fromAmount: '1000000', + toChain: ChainId.POL, + toToken: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', + ...overrides, + }) + + const mockLiFiStep: LiFiStep = { + id: 'test-step-id', + type: 'lifi', + includedSteps: [], + tool: 'test-tool', + toolDetails: { + key: 'test-tool', + name: 'Test Tool', + logoURI: 'https://example.com/logo.png', + }, + action: { + fromChainId: ChainId.ETH, + toChainId: ChainId.POL, + fromToken: { + address: '0xA0b86a33E6441c8C06DDD4f36e4C4C5B4c3B4c3B', + symbol: 'USDC', + decimals: 6, + chainId: ChainId.ETH, + name: 'USD Coin', + priceUSD: '1.00', + }, + toToken: { + address: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', + symbol: 'USDC', + decimals: 6, + chainId: ChainId.POL, + name: 'USD Coin', + priceUSD: '1.00', + }, + fromAmount: '1000000', + fromAddress: '0x1234567890123456789012345678901234567890', + toAddress: '0x1234567890123456789012345678901234567890', + }, + estimate: { + fromAmount: '1000000', + toAmount: '1000000', + toAmountMin: '970000', + approvalAddress: '0x1234567890123456789012345678901234567890', + tool: 'test-tool', + executionDuration: 30000, + }, + transactionRequest: { + to: '0x1234567890123456789012345678901234567890', + data: '0x', + value: '0', + gasLimit: '100000', + }, + } + + const mockSuccessResponse: RelayerQuoteResponse = { + status: 'ok', + data: mockLiFiStep, + } + + const mockErrorResponse: RelayerQuoteResponse = { + status: 'error', + data: { + code: 400, + message: 'Invalid request parameters', + }, + } + + describe('success scenarios', () => { + it('should get relayer quote successfully', async () => { + server.use( + http.get(`${client.config.apiUrl}/relayer/quote`, async () => { + return HttpResponse.json(mockSuccessResponse) + }) + ) + + const request = createMockQuoteRequest() + + const result = await getRelayerQuote(client, request) + + expect(result).toEqual(mockLiFiStep) + }) + + it('should pass request options correctly', async () => { + const mockAbortController = new AbortController() + const options: RequestOptions = { + signal: mockAbortController.signal, + } + + let capturedOptions: any + server.use( + http.get( + `${client.config.apiUrl}/relayer/quote`, + async ({ request }) => { + capturedOptions = request + return HttpResponse.json(mockSuccessResponse) + } + ) + ) + + const request = createMockQuoteRequest() + + await getRelayerQuote(client, request, options) + + expect(capturedOptions.signal).toBeDefined() + expect(capturedOptions.signal).toBeInstanceOf(AbortSignal) + }) + }) + + describe('validation scenarios', () => { + it('should throw SDKError when required parameters are missing', async () => { + const testCases = [ + { param: 'fromChain', value: undefined }, + { param: 'fromToken', value: undefined }, + { param: 'fromAddress', value: undefined }, + { param: 'fromAmount', value: undefined }, + { param: 'toChain', value: undefined }, + { param: 'toToken', value: undefined }, + ] + + for (const testCase of testCases) { + const invalidRequest = createMockQuoteRequest({ + [testCase.param]: testCase.value, + }) + + await expect(getRelayerQuote(client, invalidRequest)).rejects.toThrow( + SDKError + ) + + try { + await getRelayerQuote(client, invalidRequest) + } catch (error) { + expect(error).toBeInstanceOf(SDKError) + expect((error as SDKError).cause).toBeInstanceOf(ValidationError) + expect((error as SDKError).cause.message).toBe( + `Required parameter "${testCase.param}" is missing.` + ) + } + } + }) + }) + + describe('error scenarios', () => { + it('should throw BaseError when server returns error status', async () => { + server.use( + http.get(`${client.config.apiUrl}/relayer/quote`, async () => { + return HttpResponse.json(mockErrorResponse) + }) + ) + + const request = createMockQuoteRequest() + + await expect(getRelayerQuote(client, request)).rejects.toThrow(BaseError) + + try { + await getRelayerQuote(client, request) + } catch (error) { + expect(error).toBeInstanceOf(BaseError) + expect((error as BaseError).name).toBe(ErrorName.ServerError) + expect((error as BaseError).code).toBe(400) + expect((error as BaseError).message).toBe('Invalid request parameters') + } + }) + + it('should throw SDKError when network request fails', async () => { + server.use( + http.get(`${client.config.apiUrl}/relayer/quote`, async () => { + return HttpResponse.error() + }) + ) + + const request = createMockQuoteRequest() + + await expect(getRelayerQuote(client, request)).rejects.toThrow(SDKError) + }) + + it('should throw SDKError when request times out', async () => { + server.use( + http.get(`${client.config.apiUrl}/relayer/quote`, async () => { + // Simulate timeout by not responding + await new Promise(() => {}) // Never resolves + }) + ) + + const request = createMockQuoteRequest() + const timeoutOptions: RequestOptions = { + signal: AbortSignal.timeout(100), // 100ms timeout + } + + await expect( + getRelayerQuote(client, request, timeoutOptions) + ).rejects.toThrow() + }) + }) +}) diff --git a/src/actions/getRoutes.ts b/src/actions/getRoutes.ts new file mode 100644 index 00000000..3c07331e --- /dev/null +++ b/src/actions/getRoutes.ts @@ -0,0 +1,43 @@ +import type { RequestOptions, RoutesRequest, RoutesResponse } from '@lifi/types' +import { ValidationError } from '../errors/errors.js' +import { SDKError } from '../errors/SDKError.js' +import { request } from '../request.js' +import { isRoutesRequest } from '../typeguards.js' +import type { SDKClient } from '../types/core.js' + +/** + * Get a set of routes for a request that describes a transfer of tokens. + * @param client - The SDK client. + * @param params - A description of the transfer. + * @param options - Request options + * @returns The resulting routes that can be used to realize the described transfer of tokens. + * @throws {LiFiError} Throws a LiFiError if request fails. + */ +export const getRoutes = async ( + client: SDKClient, + params: RoutesRequest, + options?: RequestOptions +): Promise => { + if (!isRoutesRequest(params)) { + throw new SDKError(new ValidationError('Invalid routes request.')) + } + // apply defaults + params.options = { + integrator: client.config.integrator, + ...client.config.routeOptions, + ...params.options, + } + + return await request( + client.config, + `${client.config.apiUrl}/advanced/routes`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(params), + signal: options?.signal, + } + ) +} diff --git a/src/actions/getRoutes.unit.spec.ts b/src/actions/getRoutes.unit.spec.ts new file mode 100644 index 00000000..2dce9534 --- /dev/null +++ b/src/actions/getRoutes.unit.spec.ts @@ -0,0 +1,112 @@ +import { findDefaultToken } from '@lifi/data-types' +import type { RoutesRequest } from '@lifi/types' +import { ChainId, CoinKey } from '@lifi/types' +import { describe, expect, it, vi } from 'vitest' +import * as request from '../request.js' +import { client, setupTestServer } from './actions.unit.handlers.js' +import { getRoutes } from './getRoutes.js' + +const mockedFetch = vi.spyOn(request, 'request') + +describe('getRoutes', () => { + setupTestServer() + + const getRoutesRequest = ({ + fromChainId = ChainId.BSC, + fromAmount = '10000000000000', + fromTokenAddress = findDefaultToken(CoinKey.USDC, ChainId.BSC).address, + toChainId = ChainId.DAI, + toTokenAddress = findDefaultToken(CoinKey.USDC, ChainId.DAI).address, + options = { slippage: 0.03 }, + }: { + fromChainId?: ChainId + fromAmount?: string + fromTokenAddress?: string + toChainId?: ChainId + toTokenAddress?: string + options?: { slippage: number } + }): RoutesRequest => ({ + fromChainId, + fromAmount, + fromTokenAddress, + toChainId, + toTokenAddress, + options, + }) + + describe('user input is invalid', () => { + it('should throw Error because of invalid fromChainId type', async () => { + const request = getRoutesRequest({ + fromChainId: 'xxx' as unknown as ChainId, + }) + + await expect(getRoutes(client, request)).rejects.toThrow( + 'Invalid routes request.' + ) + expect(mockedFetch).toHaveBeenCalledTimes(0) + }) + + it('should throw Error because of invalid fromAmount type', async () => { + const request = getRoutesRequest({ + fromAmount: 10000000000000 as unknown as string, + }) + + await expect(getRoutes(client, request)).rejects.toThrow( + 'Invalid routes request.' + ) + expect(mockedFetch).toHaveBeenCalledTimes(0) + }) + + it('should throw Error because of invalid fromTokenAddress type', async () => { + const request = getRoutesRequest({ + fromTokenAddress: 1234 as unknown as string, + }) + + await expect(getRoutes(client, request)).rejects.toThrow( + 'Invalid routes request.' + ) + expect(mockedFetch).toHaveBeenCalledTimes(0) + }) + + it('should throw Error because of invalid toChainId type', async () => { + const request = getRoutesRequest({ + toChainId: 'xxx' as unknown as ChainId, + }) + + await expect(getRoutes(client, request)).rejects.toThrow( + 'Invalid routes request.' + ) + expect(mockedFetch).toHaveBeenCalledTimes(0) + }) + + it('should throw Error because of invalid toTokenAddress type', async () => { + const request = getRoutesRequest({ toTokenAddress: '' }) + + await expect(getRoutes(client, request)).rejects.toThrow( + 'Invalid routes request.' + ) + expect(mockedFetch).toHaveBeenCalledTimes(0) + }) + + it('should throw Error because of invalid options type', async () => { + const request = getRoutesRequest({ + options: { slippage: 'not a number' as unknown as number }, + }) + + await expect(getRoutes(client, request)).rejects.toThrow( + 'Invalid routes request.' + ) + expect(mockedFetch).toHaveBeenCalledTimes(0) + }) + }) + + describe('user input is valid', () => { + describe('and the backend call is successful', () => { + it('call the server once', async () => { + const request = getRoutesRequest({}) + await getRoutes(client, request) + expect(mockedFetch).toHaveBeenCalledTimes(1) + }) + }) + }) +}) diff --git a/src/actions/getStatus.ts b/src/actions/getStatus.ts new file mode 100644 index 00000000..5921cc3e --- /dev/null +++ b/src/actions/getStatus.ts @@ -0,0 +1,36 @@ +import type { RequestOptions, StatusResponse } from '@lifi/types' +import { ValidationError } from '../errors/errors.js' +import { SDKError } from '../errors/SDKError.js' +import { request } from '../request.js' +import type { GetStatusRequestExtended } from '../types/actions.js' +import type { SDKClient } from '../types/core.js' + +/** + * Check the status of a transfer. For cross chain transfers, the "bridge" parameter is required. + * @param client - The SDK client + * @param params - Configuration of the requested status + * @param options - Request options. + * @throws {LiFiError} - Throws a LiFiError if request fails + * @returns Returns status response. + */ +export const getStatus = async ( + client: SDKClient, + params: GetStatusRequestExtended, + options?: RequestOptions +): Promise => { + if (!params.txHash) { + throw new SDKError( + new ValidationError('Required parameter "txHash" is missing.') + ) + } + const queryParams = new URLSearchParams( + params as unknown as Record + ) + return await request( + client.config, + `${client.config.apiUrl}/status?${queryParams}`, + { + signal: options?.signal, + } + ) +} diff --git a/src/actions/getStatus.unit.spec.ts b/src/actions/getStatus.unit.spec.ts new file mode 100644 index 00000000..5fb9ade6 --- /dev/null +++ b/src/actions/getStatus.unit.spec.ts @@ -0,0 +1,51 @@ +import { ChainId } from '@lifi/types' +import { describe, expect, it, vi } from 'vitest' +import { ValidationError } from '../errors/errors.js' +import { SDKError } from '../errors/SDKError.js' +import * as request from '../request.js' +import { client, setupTestServer } from './actions.unit.handlers.js' +import { getStatus } from './getStatus.js' + +const mockedFetch = vi.spyOn(request, 'request') + +describe('getStatus', () => { + setupTestServer() + + const fromChain = ChainId.DAI + const toChain = ChainId.POL + const txHash = 'some tx hash' + const bridge = 'some bridge tool' + + describe('user input is invalid', () => { + it('throw an error', async () => { + await expect( + getStatus(client, { + bridge, + fromChain, + toChain, + txHash: undefined as unknown as string, + }) + ).rejects.toThrowError( + new SDKError( + new ValidationError('Required parameter "txHash" is missing.') + ) + ) + + expect(mockedFetch).toHaveBeenCalledTimes(0) + }) + }) + + describe('user input is valid', () => { + describe('and the backend call is successful', () => { + it('call the server once', async () => { + await getStatus(client, { + bridge, + fromChain, + toChain, + txHash, + }) + expect(mockedFetch).toHaveBeenCalledTimes(1) + }) + }) + }) +}) diff --git a/src/actions/getStepTransaction.ts b/src/actions/getStepTransaction.ts new file mode 100644 index 00000000..9e34ec4d --- /dev/null +++ b/src/actions/getStepTransaction.ts @@ -0,0 +1,36 @@ +import type { LiFiStep, RequestOptions, SignedLiFiStep } from '@lifi/types' +import { request } from '../request.js' +import { isStep } from '../typeguards.js' +import type { SDKClient } from '../types/core.js' + +/** + * Get the transaction data for a single step of a route + * @param client - The SDK client + * @param step - The step object. + * @param options - Request options + * @returns The step populated with the transaction data. + * @throws {LiFiError} Throws a LiFiError if request fails. + */ +export const getStepTransaction = async ( + client: SDKClient, + step: LiFiStep | SignedLiFiStep, + options?: RequestOptions +): Promise => { + if (!isStep(step)) { + // While the validation fails for some users we should not enforce it + console.warn('SDK Validation: Invalid Step', step) + } + + return await request( + client.config, + `${client.config.apiUrl}/advanced/stepTransaction`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(step), + signal: options?.signal, + } + ) +} diff --git a/src/actions/getStepTransaction.unit.spec.ts b/src/actions/getStepTransaction.unit.spec.ts new file mode 100644 index 00000000..7e89e37a --- /dev/null +++ b/src/actions/getStepTransaction.unit.spec.ts @@ -0,0 +1,140 @@ +import { findDefaultToken } from '@lifi/data-types' +import type { Action, Estimate, LiFiStep, StepTool, Token } from '@lifi/types' +import { ChainId, CoinKey } from '@lifi/types' +import { describe, expect, it, vi } from 'vitest' +import * as request from '../request.js' +import { client, setupTestServer } from './actions.unit.handlers.js' +import { getStepTransaction } from './getStepTransaction.js' + +const mockedFetch = vi.spyOn(request, 'request') + +describe('getStepTransaction', () => { + setupTestServer() + + const getAction = ({ + fromChainId = ChainId.BSC, + fromAmount = '10000000000000', + fromToken = findDefaultToken(CoinKey.USDC, ChainId.BSC), + fromAddress = 'some from address', // we don't validate the format of addresses atm + toChainId = ChainId.DAI, + toToken = findDefaultToken(CoinKey.USDC, ChainId.DAI), + toAddress = 'some to address', + slippage = 0.03, + }): Action => ({ + fromChainId, + fromAmount, + fromToken: fromToken as Token, + fromAddress, + toChainId, + toToken: toToken as Token, + toAddress, + slippage, + }) + + const getEstimate = ({ + fromAmount = '10000000000000', + toAmount = '10000000000000', + toAmountMin = '999999999999', + approvalAddress = 'some approval address', // we don't validate the format of addresses atm; + executionDuration = 300, + tool = '1inch', + }): Estimate => ({ + fromAmount, + toAmount, + toAmountMin, + approvalAddress, + executionDuration, + tool, + }) + + const getStep = ({ + id = 'some random id', + type = 'lifi', + tool = 'some swap tool', + action = getAction({}), + estimate = getEstimate({}), + }: { + id?: string + type?: 'lifi' + tool?: StepTool + action?: Action + estimate?: Estimate + }): LiFiStep => ({ + id, + type, + tool, + toolDetails: { + key: tool, + name: tool, + logoURI: '', + }, + action, + estimate, + includedSteps: [], + }) + + describe('with a swap step', () => { + // While the validation fails for some users we should not enforce it + describe.skip('user input is invalid', () => { + it('should throw Error because of invalid id', async () => { + const step = getStep({ id: null as unknown as string }) + + await expect(getStepTransaction(client, step)).rejects.toThrow( + 'Invalid step.' + ) + expect(mockedFetch).toHaveBeenCalledTimes(0) + }) + + it('should throw Error because of invalid type', async () => { + const step = getStep({ type: 42 as unknown as 'lifi' }) + + await expect(getStepTransaction(client, step)).rejects.toThrow( + 'Invalid Step' + ) + expect(mockedFetch).toHaveBeenCalledTimes(0) + }) + + it('should throw Error because of invalid tool', async () => { + const step = getStep({ tool: null as unknown as StepTool }) + + await expect(getStepTransaction(client, step)).rejects.toThrow( + 'Invalid step.' + ) + expect(mockedFetch).toHaveBeenCalledTimes(0) + }) + + // more indepth checks for the action type should be done once we have real schema validation + it('should throw Error because of invalid action', async () => { + const step = getStep({ action: 'xxx' as unknown as Action }) + + await expect(getStepTransaction(client, step)).rejects.toThrow( + 'Invalid step.' + ) + expect(mockedFetch).toHaveBeenCalledTimes(0) + }) + + // more indepth checks for the estimate type should be done once we have real schema validation + it('should throw Error because of invalid estimate', async () => { + const step = getStep({ + estimate: 'Is this really an estimate?' as unknown as Estimate, + }) + + await expect(getStepTransaction(client, step)).rejects.toThrow( + 'Invalid step.' + ) + expect(mockedFetch).toHaveBeenCalledTimes(0) + }) + }) + + describe('user input is valid', () => { + describe('and the backend call is successful', () => { + it('call the server once', async () => { + const step = getStep({}) + + await getStepTransaction(client, step) + expect(mockedFetch).toHaveBeenCalledTimes(1) + }) + }) + }) + }) +}) diff --git a/src/actions/getToken.ts b/src/actions/getToken.ts new file mode 100644 index 00000000..c2acfd0b --- /dev/null +++ b/src/actions/getToken.ts @@ -0,0 +1,47 @@ +import type { + ChainId, + ChainKey, + RequestOptions, + TokenExtended, +} from '@lifi/types' +import { ValidationError } from '../errors/errors.js' +import { SDKError } from '../errors/SDKError.js' +import { request } from '../request.js' +import type { SDKClient } from '../types/core.js' + +/** + * Fetch information about a Token + * @param client - The SDK client + * @param chain - Id or key of the chain that contains the token + * @param token - Address or symbol of the token on the requested chain + * @param options - Request options + * @throws {LiFiError} - Throws a LiFiError if request fails + * @returns Token information + */ +export const getToken = async ( + client: SDKClient, + chain: ChainKey | ChainId, + token: string, + options?: RequestOptions +): Promise => { + if (!chain) { + throw new SDKError( + new ValidationError('Required parameter "chain" is missing.') + ) + } + if (!token) { + throw new SDKError( + new ValidationError('Required parameter "token" is missing.') + ) + } + return await request( + client.config, + `${client.config.apiUrl}/token?${new URLSearchParams({ + chain, + token, + } as Record)}`, + { + signal: options?.signal, + } + ) +} diff --git a/src/actions/getToken.unit.spec.ts b/src/actions/getToken.unit.spec.ts new file mode 100644 index 00000000..04f5d054 --- /dev/null +++ b/src/actions/getToken.unit.spec.ts @@ -0,0 +1,45 @@ +import { ChainId } from '@lifi/types' +import { describe, expect, it, vi } from 'vitest' +import { ValidationError } from '../errors/errors.js' +import { SDKError } from '../errors/SDKError.js' +import * as request from '../request.js' +import { client, setupTestServer } from './actions.unit.handlers.js' +import { getToken } from './getToken.js' + +const mockedFetch = vi.spyOn(request, 'request') + +describe('getToken', () => { + setupTestServer() + + describe('user input is invalid', () => { + it('throw an error', async () => { + await expect( + getToken(client, undefined as unknown as ChainId, 'DAI') + ).rejects.toThrowError( + new SDKError( + new ValidationError('Required parameter "chain" is missing.') + ) + ) + expect(mockedFetch).toHaveBeenCalledTimes(0) + + await expect( + getToken(client, ChainId.ETH, undefined as unknown as string) + ).rejects.toThrowError( + new SDKError( + new ValidationError('Required parameter "token" is missing.') + ) + ) + expect(mockedFetch).toHaveBeenCalledTimes(0) + }) + }) + + describe('user input is valid', () => { + describe('and the backend call is successful', () => { + it('call the server once', async () => { + await getToken(client, ChainId.DAI, 'DAI') + + expect(mockedFetch).toHaveBeenCalledTimes(1) + }) + }) + }) +}) diff --git a/src/actions/getTokenBalance.ts b/src/actions/getTokenBalance.ts new file mode 100644 index 00000000..ef473f4f --- /dev/null +++ b/src/actions/getTokenBalance.ts @@ -0,0 +1,21 @@ +import type { Token, TokenAmount } from '@lifi/types' +import type { SDKClient } from '../types/core.js' +import { getTokenBalances } from './getTokenBalances.js' + +/** + * Returns the balances of a specific token a wallet holds across all aggregated chains. + * @param client - The SDK client. + * @param walletAddress - A wallet address. + * @param token - A Token object. + * @returns An object containing the token and the amounts on different chains. + * @throws {ValidationError} Throws a ValidationError if validation fails. + * @throws {Error} Throws an Error if the SDK Provider for the wallet address is not found. + */ +export const getTokenBalance = async ( + client: SDKClient, + walletAddress: string, + token: Token +): Promise => { + const tokenAmounts = await getTokenBalances(client, walletAddress, [token]) + return tokenAmounts.length ? tokenAmounts[0] : null +} diff --git a/src/actions/getTokenBalance.unit.spec.ts b/src/actions/getTokenBalance.unit.spec.ts new file mode 100644 index 00000000..951e1c2b --- /dev/null +++ b/src/actions/getTokenBalance.unit.spec.ts @@ -0,0 +1,61 @@ +import { findDefaultToken } from '@lifi/data-types' +import type { Token } from '@lifi/types' +import { ChainId, CoinKey } from '@lifi/types' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { client } from './actions.unit.handlers.js' +import { getTokenBalance } from './getTokenBalance.js' + +const mockedGetTokenBalance = vi.spyOn( + await import('./getTokenBalance.js'), + 'getTokenBalance' +) + +describe('getTokenBalance', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const SOME_TOKEN = { + ...findDefaultToken(CoinKey.USDC, ChainId.DAI), + priceUSD: '', + } + const SOME_WALLET_ADDRESS = 'some wallet address' + + describe('user input is invalid', () => { + it('should throw Error because of missing walletAddress', async () => { + await expect(getTokenBalance(client, '', SOME_TOKEN)).rejects.toThrow( + 'Missing walletAddress.' + ) + }) + + it('should throw Error because of invalid token', async () => { + await expect( + getTokenBalance(client, SOME_WALLET_ADDRESS, { + address: 'some wrong stuff', + chainId: 'not a chain Id', + } as unknown as Token) + ).rejects.toThrow('Invalid tokens passed.') + }) + }) + + describe('user input is valid', () => { + it('should call the balance service', async () => { + const balanceResponse = { + ...SOME_TOKEN, + amount: 123n, + blockNumber: 1n, + } + + mockedGetTokenBalance.mockReturnValue(Promise.resolve(balanceResponse)) + + const result = await getTokenBalance( + client, + SOME_WALLET_ADDRESS, + SOME_TOKEN + ) + + expect(mockedGetTokenBalance).toHaveBeenCalledTimes(1) + expect(result).toEqual(balanceResponse) + }) + }) +}) diff --git a/src/actions/getTokenBalances.ts b/src/actions/getTokenBalances.ts new file mode 100644 index 00000000..d5fe6fbc --- /dev/null +++ b/src/actions/getTokenBalances.ts @@ -0,0 +1,47 @@ +import type { + Token, + TokenAmount, + TokenAmountExtended, + TokenExtended, +} from '@lifi/types' +import type { SDKClient } from '../types/core.js' +import { getTokenBalancesByChain } from './getTokenBalancesByChain.js' + +/** + * Returns the balances for a list tokens a wallet holds across all aggregated chains. + * @param client - The SDK client. + * @param walletAddress - A wallet address. + * @param tokens - A list of Token (or TokenExtended) objects. + * @returns A list of objects containing the tokens and the amounts on different chains. + * @throws {ValidationError} Throws a ValidationError if validation fails. + * @throws {Error} Throws an Error if the SDK Provider for the wallet address is not found. + */ +export async function getTokenBalances( + client: SDKClient, + walletAddress: string, + tokens: Token[] +): Promise +export async function getTokenBalances( + client: SDKClient, + walletAddress: string, + tokens: TokenExtended[] +): Promise { + // split by chain + const tokensByChain = tokens.reduce( + (tokens, token) => { + if (!tokens[token.chainId]) { + tokens[token.chainId] = [] + } + tokens[token.chainId].push(token) + return tokens + }, + {} as { [chainId: number]: Token[] | TokenExtended[] } + ) + + const tokenAmountsByChain = await getTokenBalancesByChain( + client, + walletAddress, + tokensByChain + ) + return Object.values(tokenAmountsByChain).flat() +} diff --git a/src/actions/getTokenBalances.unit.spec.ts b/src/actions/getTokenBalances.unit.spec.ts new file mode 100644 index 00000000..5dcc05fa --- /dev/null +++ b/src/actions/getTokenBalances.unit.spec.ts @@ -0,0 +1,68 @@ +import { findDefaultToken } from '@lifi/data-types' +import type { Token } from '@lifi/types' +import { ChainId, CoinKey } from '@lifi/types' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { client } from './actions.unit.handlers.js' +import { getTokenBalances } from './getTokenBalances.js' + +const mockedGetTokenBalances = vi.spyOn( + await import('./getTokenBalances.js'), + 'getTokenBalances' +) + +describe('getTokenBalances', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const SOME_TOKEN = { + ...findDefaultToken(CoinKey.USDC, ChainId.DAI), + priceUSD: '', + } + const SOME_WALLET_ADDRESS = 'some wallet address' + + describe('user input is invalid', () => { + it('should throw Error because of missing walletAddress', async () => { + await expect(getTokenBalances(client, '', [SOME_TOKEN])).rejects.toThrow( + 'Missing walletAddress.' + ) + }) + + it('should throw Error because of an invalid token', async () => { + await expect( + getTokenBalances(client, SOME_WALLET_ADDRESS, [ + SOME_TOKEN, + { not: 'a token' } as unknown as Token, + ]) + ).rejects.toThrow('Invalid tokens passed.') + }) + + it('should return empty token list as it is', async () => { + mockedGetTokenBalances.mockReturnValue(Promise.resolve([])) + const result = await getTokenBalances(client, SOME_WALLET_ADDRESS, []) + expect(result).toEqual([]) + expect(mockedGetTokenBalances).toHaveBeenCalledTimes(1) + }) + }) + + describe('user input is valid', () => { + it('should call the balance service', async () => { + const balanceResponse = [ + { + ...SOME_TOKEN, + amount: 123n, + blockNumber: 1n, + }, + ] + + mockedGetTokenBalances.mockReturnValue(Promise.resolve(balanceResponse)) + + const result = await getTokenBalances(client, SOME_WALLET_ADDRESS, [ + SOME_TOKEN, + ]) + + expect(mockedGetTokenBalances).toHaveBeenCalledTimes(1) + expect(result).toEqual(balanceResponse) + }) + }) +}) diff --git a/src/actions/getTokenBalancesByChain.ts b/src/actions/getTokenBalancesByChain.ts new file mode 100644 index 00000000..e591f498 --- /dev/null +++ b/src/actions/getTokenBalancesByChain.ts @@ -0,0 +1,76 @@ +import type { + Token, + TokenAmount, + TokenAmountExtended, + TokenExtended, +} from '@lifi/types' +import { ValidationError } from '../errors/errors.js' +import { isToken } from '../typeguards.js' +import type { SDKClient } from '../types/core.js' + +/** + * This method queries the balances of tokens for a specific list of chains for a given wallet. + * @param client - The SDK client. + * @param walletAddress - A wallet address. + * @param tokensByChain - A list of token objects organized by chain ids. + * @returns A list of objects containing the tokens and the amounts on different chains organized by the chosen chains. + * @throws {ValidationError} Throws a ValidationError if validation fails. + * @throws {Error} Throws an Error if the SDK Provider for the wallet address is not found. + */ +export async function getTokenBalancesByChain( + client: SDKClient, + walletAddress: string, + tokensByChain: { [chainId: number]: Token[] } +): Promise<{ [chainId: number]: TokenAmount[] }> +export async function getTokenBalancesByChain( + client: SDKClient, + walletAddress: string, + tokensByChain: { [chainId: number]: TokenExtended[] } +): Promise<{ [chainId: number]: TokenAmountExtended[] }> { + if (!walletAddress) { + throw new ValidationError('Missing walletAddress.') + } + + const tokenList = Object.values(tokensByChain).flat() + const invalidTokens = tokenList.filter((token) => !isToken(token)) + if (invalidTokens.length) { + throw new ValidationError('Invalid tokens passed.') + } + + const provider = client.providers.find((provider) => + provider.isAddress(walletAddress) + ) + if (!provider) { + throw new Error(`SDK Token Provider for ${walletAddress} is not found.`) + } + + const tokenAmountsByChain: { + [chainId: number]: TokenAmount[] | TokenAmountExtended[] + } = {} + const tokenAmountsSettled = await Promise.allSettled( + Object.keys(tokensByChain).map(async (chainIdStr) => { + const chainId = Number.parseInt(chainIdStr, 10) + const chain = await client.getChainById(chainId) + if (provider.type === chain.chainType) { + const tokenAmounts = await provider.getBalance( + client, + walletAddress, + tokensByChain[chainId] + ) + tokenAmountsByChain[chainId] = tokenAmounts + } else { + // if the provider is not the same as the chain type, + // return the tokens as is + tokenAmountsByChain[chainId] = tokensByChain[chainId] + } + }) + ) + if (client.config.debug) { + for (const result of tokenAmountsSettled) { + if (result.status === 'rejected') { + console.warn("Couldn't fetch token balance.", result.reason) + } + } + } + return tokenAmountsByChain +} diff --git a/src/actions/getTokenBalancesByChain.unit.spec.ts b/src/actions/getTokenBalancesByChain.unit.spec.ts new file mode 100644 index 00000000..d8d0aabb --- /dev/null +++ b/src/actions/getTokenBalancesByChain.unit.spec.ts @@ -0,0 +1,108 @@ +import { findDefaultToken } from '@lifi/data-types' +import type { Token } from '@lifi/types' +import { ChainId, CoinKey } from '@lifi/types' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { client } from './actions.unit.handlers.js' +import { getTokenBalancesByChain } from './getTokenBalancesByChain.js' + +const mockedGetTokenBalancesForChains = vi.spyOn( + await import('./getTokenBalancesByChain.js'), + 'getTokenBalancesByChain' +) + +describe('getTokenBalancesByChain', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const SOME_TOKEN = { + ...findDefaultToken(CoinKey.USDC, ChainId.DAI), + priceUSD: '', + } + const SOME_WALLET_ADDRESS = 'some wallet address' + + describe('user input is invalid', () => { + it('should throw Error because of missing walletAddress', async () => { + await expect( + getTokenBalancesByChain(client, '', { + [ChainId.DAI]: [SOME_TOKEN], + }) + ).rejects.toThrow('Missing walletAddress.') + }) + + it('should throw Error because of an invalid token', async () => { + await expect( + getTokenBalancesByChain(client, SOME_WALLET_ADDRESS, { + [ChainId.DAI]: [{ not: 'a token' } as unknown as Token], + }) + ).rejects.toThrow('Invalid tokens passed.') + }) + + it('should return empty token list as it is', async () => { + mockedGetTokenBalancesForChains.mockReturnValue(Promise.resolve([])) + + const result = await getTokenBalancesByChain( + client, + SOME_WALLET_ADDRESS, + { + [ChainId.DAI]: [], + } + ) + + expect(result).toEqual([]) + expect(mockedGetTokenBalancesForChains).toHaveBeenCalledTimes(1) + }) + }) + + describe('user input is valid', () => { + it('should call the balance service', async () => { + const balanceResponse = { + [ChainId.DAI]: [ + { + ...SOME_TOKEN, + amount: 123n, + blockNumber: 1n, + }, + ], + } + + mockedGetTokenBalancesForChains.mockReturnValue( + Promise.resolve(balanceResponse) + ) + + const result = await getTokenBalancesByChain( + client, + SOME_WALLET_ADDRESS, + { + [ChainId.DAI]: [SOME_TOKEN], + } + ) + + expect(mockedGetTokenBalancesForChains).toHaveBeenCalledTimes(1) + expect(result).toEqual(balanceResponse) + }) + }) + + describe('provider is not the same as the chain type', () => { + it('should return the tokens as is', async () => { + const balanceResponse = { + [ChainId.DAI]: [SOME_TOKEN], + } + + mockedGetTokenBalancesForChains.mockReturnValue( + Promise.resolve(balanceResponse) + ) + + const result = await getTokenBalancesByChain( + client, + SOME_WALLET_ADDRESS, + { + [ChainId.DAI]: [SOME_TOKEN], + } + ) + + expect(mockedGetTokenBalancesForChains).toHaveBeenCalledTimes(1) + expect(result).toEqual(balanceResponse) + }) + }) +}) diff --git a/src/actions/getTokens.ts b/src/actions/getTokens.ts new file mode 100644 index 00000000..4690818a --- /dev/null +++ b/src/actions/getTokens.ts @@ -0,0 +1,54 @@ +import type { + RequestOptions, + TokensExtendedResponse, + TokensRequest, + TokensResponse, +} from '@lifi/types' +import { request } from '../request.js' +import type { SDKClient } from '../types/core.js' +import { withDedupe } from '../utils/withDedupe.js' + +/** + * Get all known tokens. + * @param client - The SDK client + * @param params - The configuration of the requested tokens + * @param options - Request options + * @returns The tokens that are available on the requested chains + */ +export async function getTokens( + client: SDKClient, + params?: TokensRequest & { extended?: false | undefined }, + options?: RequestOptions +): Promise +export async function getTokens( + client: SDKClient, + params: TokensRequest & { extended: true }, + options?: RequestOptions +): Promise +export async function getTokens( + client: SDKClient, + params?: TokensRequest, + options?: RequestOptions +): Promise { + if (params) { + for (const key of Object.keys(params)) { + if (!params[key as keyof TokensRequest]) { + delete params[key as keyof TokensRequest] + } + } + } + const urlSearchParams = new URLSearchParams( + params as Record + ).toString() + const isExtended = params?.extended === true + const response = await withDedupe( + () => + request< + typeof isExtended extends true ? TokensExtendedResponse : TokensResponse + >(client.config, `${client.config.apiUrl}/tokens?${urlSearchParams}`, { + signal: options?.signal, + }), + { id: `${getTokens.name}.${urlSearchParams}` } + ) + return response +} diff --git a/src/actions/getTokens.unit.spec.ts b/src/actions/getTokens.unit.spec.ts new file mode 100644 index 00000000..2a022d02 --- /dev/null +++ b/src/actions/getTokens.unit.spec.ts @@ -0,0 +1,16 @@ +import { ChainId } from '@lifi/types' +import { describe, expect, it } from 'vitest' +import { client, setupTestServer } from './actions.unit.handlers.js' +import { getTokens } from './getTokens.js' + +describe('getTokens', () => { + setupTestServer() + + it('return the tokens', async () => { + const result = await getTokens(client, { + chains: [ChainId.ETH, ChainId.POL], + }) + expect(result).toBeDefined() + expect(result.tokens[ChainId.ETH]).toBeDefined() + }) +}) diff --git a/src/actions/getTools.ts b/src/actions/getTools.ts new file mode 100644 index 00000000..f2a87fa3 --- /dev/null +++ b/src/actions/getTools.ts @@ -0,0 +1,33 @@ +import type { RequestOptions, ToolsRequest, ToolsResponse } from '@lifi/types' +import { request } from '../request.js' +import type { SDKClient } from '../types/core.js' + +/** + * Get the available tools to bridge and swap tokens. + * @param client - The SDK client + * @param params - The configuration of the requested tools + * @param options - Request options + * @returns The tools that are available on the requested chains + */ +export const getTools = async ( + client: SDKClient, + params?: ToolsRequest, + options?: RequestOptions +): Promise => { + if (params) { + for (const key of Object.keys(params)) { + if (!params[key as keyof ToolsRequest]) { + delete params[key as keyof ToolsRequest] + } + } + } + return await request( + client.config, + `${client.config.apiUrl}/tools?${new URLSearchParams( + params as Record + )}`, + { + signal: options?.signal, + } + ) +} diff --git a/src/actions/getTools.unit.spec.ts b/src/actions/getTools.unit.spec.ts new file mode 100644 index 00000000..99aa7a16 --- /dev/null +++ b/src/actions/getTools.unit.spec.ts @@ -0,0 +1,20 @@ +import { ChainId } from '@lifi/types' +import { describe, expect, it } from 'vitest' +import { client, setupTestServer } from './actions.unit.handlers.js' +import { getTools } from './getTools.js' + +describe('getTools', () => { + setupTestServer() + + describe('and the backend succeeds', () => { + it('returns the tools', async () => { + const tools = await getTools(client, { + chains: [ChainId.ETH, ChainId.POL], + }) + + expect(tools).toBeDefined() + expect(tools.bridges).toBeDefined() + expect(tools.exchanges).toBeDefined() + }) + }) +}) diff --git a/src/actions/getTransactionHistory.ts b/src/actions/getTransactionHistory.ts new file mode 100644 index 00000000..5b7539e6 --- /dev/null +++ b/src/actions/getTransactionHistory.ts @@ -0,0 +1,54 @@ +import type { + RequestOptions, + TransactionAnalyticsRequest, + TransactionAnalyticsResponse, +} from '@lifi/types' +import { ValidationError } from '../errors/errors.js' +import { request } from '../request.js' +import type { SDKClient } from '../types/core.js' + +/** + * Get the transaction history for a wallet + * @param client - The SDK client + * @param params - The parameters for the transaction history request + * @param params.wallet - The wallet address + * @param params.status - The status of the transactions + * @param params.fromTimestamp - The start timestamp for the transactions + * @param params.toTimestamp - The end timestamp for the transactions + * @param options - Request options + * @throws {ValidationError} - Throws a ValidationError if parameters are invalid + * @throws {LiFiError} - Throws a LiFiError if request fails. + * @returns The transaction history response + */ +export const getTransactionHistory = async ( + client: SDKClient, + { wallet, status, fromTimestamp, toTimestamp }: TransactionAnalyticsRequest, + options?: RequestOptions +): Promise => { + if (!wallet) { + throw new ValidationError('Required parameter "wallet" is missing.') + } + + const url = new URL(`${client.config.apiUrl}/analytics/transfers`) + + url.searchParams.append('integrator', client.config.integrator) + url.searchParams.append('wallet', wallet) + + if (status) { + url.searchParams.append('status', status) + } + + if (fromTimestamp) { + url.searchParams.append('fromTimestamp', fromTimestamp.toString()) + } + + if (toTimestamp) { + url.searchParams.append('toTimestamp', toTimestamp.toString()) + } + + return await request( + client.config, + url, + options + ) +} diff --git a/src/actions/getTransactionHistory.unit.spec.ts b/src/actions/getTransactionHistory.unit.spec.ts new file mode 100644 index 00000000..c303709f --- /dev/null +++ b/src/actions/getTransactionHistory.unit.spec.ts @@ -0,0 +1,36 @@ +import type { TransactionAnalyticsRequest } from '@lifi/types' +import { HttpResponse, http } from 'msw' +import { describe, expect, it, vi } from 'vitest' +import * as request from '../request.js' +import { client, setupTestServer } from './actions.unit.handlers.js' +import { getTransactionHistory } from './getTransactionHistory.js' + +const mockedFetch = vi.spyOn(request, 'request') + +describe('getTransactionHistory', () => { + const server = setupTestServer() + + it('returns empty array in response', async () => { + server.use( + http.get(`${client.config.apiUrl}/analytics/transfers`, async () => + HttpResponse.json({}) + ) + ) + + const walletAnalyticsRequest: TransactionAnalyticsRequest = { + fromTimestamp: 1696326609361, + toTimestamp: 1696326609362, + wallet: '0x5520abcd', + } + + const generatedURL = + 'https://li.quest/v1/analytics/transfers?integrator=lifi-sdk&wallet=0x5520abcd&fromTimestamp=1696326609361&toTimestamp=1696326609362' + + await expect( + getTransactionHistory(client, walletAnalyticsRequest) + ).resolves.toEqual({}) + + expect((mockedFetch.mock.calls[0][1] as URL).href).toEqual(generatedURL) + expect(mockedFetch).toHaveBeenCalledOnce() + }) +}) diff --git a/src/actions/getWalletBalances.ts b/src/actions/getWalletBalances.ts new file mode 100644 index 00000000..59ad54ed --- /dev/null +++ b/src/actions/getWalletBalances.ts @@ -0,0 +1,36 @@ +import type { + GetWalletBalanceExtendedResponse, + RequestOptions, + WalletTokenExtended, +} from '@lifi/types' +import { ValidationError } from '../errors/errors.js' +import { request } from '../request.js' +import type { SDKClient } from '../types/core.js' + +/** + * Returns the balances of tokens a wallet holds across EVM chains. + * @param client - The SDK client. + * @param walletAddress - A wallet address. + * @param options - Optional request options. + * @returns An object containing the tokens and the amounts organized by chain ids. + * @throws {ValidationError} Throws a ValidationError if parameters are invalid. + */ +export const getWalletBalances = async ( + client: SDKClient, + walletAddress: string, + options?: RequestOptions +): Promise> => { + if (!walletAddress) { + throw new ValidationError('Missing walletAddress.') + } + + const response = await request( + client.config, + `${client.config.apiUrl}/wallets/${walletAddress}/balances?extended=true`, + { + signal: options?.signal, + } + ) + + return (response?.balances || {}) as Record +} diff --git a/src/actions/getWalletBalances.unit.spec.ts b/src/actions/getWalletBalances.unit.spec.ts new file mode 100644 index 00000000..05a9c635 --- /dev/null +++ b/src/actions/getWalletBalances.unit.spec.ts @@ -0,0 +1,90 @@ +import { findDefaultToken } from '@lifi/data-types' +import type { WalletTokenExtended } from '@lifi/types' +import { ChainId, CoinKey } from '@lifi/types' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { client } from './actions.unit.handlers.js' +import { getWalletBalances } from './getWalletBalances.js' + +const mockedGetWalletBalances = vi.spyOn( + await import('./getWalletBalances.js'), + 'getWalletBalances' +) + +describe('getWalletBalances', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const SOME_TOKEN = { + ...findDefaultToken(CoinKey.USDC, ChainId.DAI), + priceUSD: '', + } + const SOME_WALLET_ADDRESS = 'some wallet address' + + describe('user input is invalid', () => { + it('should throw Error because of missing walletAddress', async () => { + await expect(getWalletBalances(client, '')).rejects.toThrow( + 'Missing walletAddress.' + ) + }) + }) + + describe('user input is valid', () => { + it('should call the balance service without options', async () => { + const balanceResponse: Record = { + [ChainId.DAI]: [ + { + ...SOME_TOKEN, + amount: '123', + marketCapUSD: 1000000, + volumeUSD24H: 50000, + fdvUSD: 2000000, + }, + ], + } + + mockedGetWalletBalances.mockReturnValue(Promise.resolve(balanceResponse)) + + const result = await getWalletBalances(client, SOME_WALLET_ADDRESS) + + expect(mockedGetWalletBalances).toHaveBeenCalledTimes(1) + expect(mockedGetWalletBalances).toHaveBeenCalledWith( + client, + SOME_WALLET_ADDRESS + ) + expect(result).toEqual(balanceResponse) + }) + + it('should call the balance service with options', async () => { + const balanceResponse: Record = { + [ChainId.DAI]: [ + { + ...SOME_TOKEN, + amount: '123', + marketCapUSD: 1000000, + volumeUSD24H: 50000, + fdvUSD: 2000000, + }, + ], + } + + const options = { signal: new AbortController().signal } + + mockedGetWalletBalances.mockReturnValue(Promise.resolve(balanceResponse)) + + const result = await getWalletBalances( + client, + SOME_WALLET_ADDRESS, + options + ) + + expect(mockedGetWalletBalances).toHaveBeenCalledTimes(1) + expect(mockedGetWalletBalances).toHaveBeenCalledWith( + client, + SOME_WALLET_ADDRESS, + options + ) + expect(result).toEqual(balanceResponse) + }) + }) +}) diff --git a/src/actions/index.ts b/src/actions/index.ts new file mode 100644 index 00000000..1d2e0fae --- /dev/null +++ b/src/actions/index.ts @@ -0,0 +1,329 @@ +import type { + ChainId, + ChainKey, + ChainsRequest, + ChainType, + ConnectionsRequest, + ConnectionsResponse, + ContractCallsQuoteRequest, + ExtendedChain, + GasRecommendationRequest, + GasRecommendationResponse, + LiFiStep, + RelayRequest, + RelayResponseData, + RelayStatusRequest, + RelayStatusResponseData, + RequestOptions, + RoutesRequest, + RoutesResponse, + SignedLiFiStep, + StatusResponse, + Token, + TokenAmount, + TokenExtended, + TokensExtendedResponse, + TokensRequest, + TokensResponse, + ToolsRequest, + ToolsResponse, + TransactionAnalyticsRequest, + TransactionAnalyticsResponse, + WalletTokenExtended, +} from '@lifi/types' +import type { + GetStatusRequestExtended, + QuoteRequestFromAmount, +} from '../types/actions.js' +import type { SDKClient } from '../types/core.js' +import { getChains } from './getChains.js' +import { getConnections } from './getConnections.js' +import { getContractCallsQuote } from './getContractCallsQuote.js' +import { getGasRecommendation } from './getGasRecommendation.js' +import { getNameServiceAddress } from './getNameServiceAddress.js' +import { getQuote } from './getQuote.js' +import { getRelayedTransactionStatus } from './getRelayedTransactionStatus.js' +import { getRelayerQuote } from './getRelayerQuote.js' +import { getRoutes } from './getRoutes.js' +import { getStatus } from './getStatus.js' +import { getStepTransaction } from './getStepTransaction.js' +import { getToken } from './getToken.js' +import { getTokenBalance } from './getTokenBalance.js' +import { getTokenBalances } from './getTokenBalances.js' +import { getTokenBalancesByChain } from './getTokenBalancesByChain.js' +import { getTokens } from './getTokens.js' +import { getTools } from './getTools.js' +import { getTransactionHistory } from './getTransactionHistory.js' +import { getWalletBalances } from './getWalletBalances.js' +import { relayTransaction } from './relayTransaction.js' + +export type Actions = { + /** + * Get all available chains + * @param params - The configuration of the requested chains + * @param options - Request options + * @returns A list of all available chains + */ + getChains: ( + params?: ChainsRequest, + options?: RequestOptions + ) => Promise + + /** + * Get connections between chains + * @param params - The configuration of the requested connections + * @param options - Request options + * @returns A list of connections + */ + getConnections: ( + params: ConnectionsRequest, + options?: RequestOptions + ) => Promise + + /** + * Get a quote for contract calls + * @param params - The configuration of the requested contract calls quote + * @param options - Request options + * @returns Quote for contract calls + */ + getContractCallsQuote: ( + params: ContractCallsQuoteRequest, + options?: RequestOptions + ) => Promise + + /** + * Get gas recommendation for a chain + * @param params - The configuration of the requested gas recommendation + * @param options - Request options + * @returns Gas recommendation + */ + getGasRecommendation: ( + params: GasRecommendationRequest, + options?: RequestOptions + ) => Promise + + /** + * Get the address of a name service + * @param name - The name to resolve + * @param chainType - The chain type to resolve the name on + * @returns The address of the name service + */ + getNameServiceAddress: ( + name: string, + chainType?: ChainType + ) => Promise + + /** + * Get a quote for a token transfer + * @param params - The configuration of the requested quote + * @param options - Request options + * @returns Quote for a token transfer + */ + getQuote: ( + params: Parameters[1], + options?: RequestOptions + ) => Promise + + /** + * Get the status of a relayed transaction + * @param params - The configuration of the requested relay status + * @param options - Request options + * @returns Status of the relayed transaction + */ + getRelayedTransactionStatus: ( + params: RelayStatusRequest, + options?: RequestOptions + ) => Promise + + /** + * Get a quote from a relayer + * @param params - The configuration of the requested relayer quote + * @param options - Request options + * @returns Quote from a relayer + */ + getRelayerQuote: ( + params: QuoteRequestFromAmount, + options?: RequestOptions + ) => Promise + + /** + * Get a set of routes for a request that describes a transfer of tokens + * @param params - A description of the transfer + * @param options - Request options + * @returns The resulting routes that can be used to realize the described transfer + */ + getRoutes: ( + params: RoutesRequest, + options?: RequestOptions + ) => Promise + + /** + * Get the status of a transaction + * @param params - The configuration of the requested status + * @param options - Request options + * @returns Status of the transaction + */ + getStatus: ( + params: GetStatusRequestExtended, + options?: RequestOptions + ) => Promise + + /** + * Get a step transaction + * @param params - The configuration of the requested step transaction + * @param options - Request options + * @returns Step transaction + */ + getStepTransaction: ( + params: LiFiStep | SignedLiFiStep, + options?: RequestOptions + ) => Promise + + /** + * Get a specific token + * @param chain - Id or key of the chain that contains the token + * @param token - Address or symbol of the token on the requested chain + * @param options - Request options + * @returns Token information + */ + getToken: ( + chain: ChainKey | ChainId, + token: string, + options?: RequestOptions + ) => Promise + + /** + * Get token balance for a specific token + * @param walletAddress - A wallet address + * @param token - A Token object + * @returns Token balance + */ + getTokenBalance: ( + walletAddress: string, + token: Token + ) => Promise + + /** + * Get token balances for multiple tokens + * @param walletAddress - A wallet address + * @param tokens - A list of Token objects + * @returns Token balances + */ + getTokenBalances: ( + walletAddress: string, + tokens: Token[] + ) => Promise + + /** + * Get token balances by chain + * @param walletAddress - A wallet address + * @param tokensByChain - A list of token objects organized by chain ids + * @returns Token balances by chain + */ + getTokenBalancesByChain: ( + walletAddress: string, + tokensByChain: { [chainId: number]: Token[] } + ) => Promise<{ + [chainId: number]: TokenAmount[] + }> + + /** + * Get all available tokens + * @param params - The configuration of the requested tokens + * @param options - Request options + * @returns A list of all available tokens + */ + getTokens: { + ( + params?: TokensRequest & { extended?: false | undefined }, + options?: RequestOptions + ): Promise + ( + params: TokensRequest & { extended: true }, + options?: RequestOptions + ): Promise + } + + /** + * Get all available tools (bridges and exchanges) + * @param params - The configuration of the requested tools + * @param options - Request options + * @returns A list of all available tools + */ + getTools: ( + params?: ToolsRequest, + options?: RequestOptions + ) => Promise + + /** + * Get transaction history + * @param params - The configuration of the requested transaction history + * @param options - Request options + * @returns Transaction history + */ + getTransactionHistory: ( + params: TransactionAnalyticsRequest, + options?: RequestOptions + ) => Promise + + /** + * Get wallet balances + * @param params - The configuration of the requested wallet balances + * @param options - Request options + * @returns Wallet balances + */ + getWalletBalances: ( + walletAddress: string, + options?: RequestOptions + ) => Promise> + + /** + * Relay a transaction through the relayer service + * @param params - The configuration for the relay request + * @param options - Request options + * @returns Task ID and transaction link for the relayed transaction + */ + relayTransaction: ( + params: RelayRequest, + options?: RequestOptions + ) => Promise +} + +export function actions(client: SDKClient): Actions { + return { + getChains: (params, options) => getChains(client, params, options), + getConnections: (params, options) => + getConnections(client, params, options), + getContractCallsQuote: (params, options) => + getContractCallsQuote(client, params, options), + getGasRecommendation: (params, options) => + getGasRecommendation(client, params, options), + getNameServiceAddress: (name, chainType) => + getNameServiceAddress(client, name, chainType), + getTokens: (params, options) => getTokens(client, params as any, options), + getTools: (params, options) => getTools(client, params, options), + getQuote: (params, options) => getQuote(client, params, options), + getRelayedTransactionStatus: (params, options) => + getRelayedTransactionStatus(client, params, options), + getRelayerQuote: (params, options) => + getRelayerQuote(client, params, options), + getRoutes: (params, options) => getRoutes(client, params, options), + getStatus: (params, options) => getStatus(client, params, options), + getStepTransaction: (params, options) => + getStepTransaction(client, params, options), + getToken: (chain, token, options) => + getToken(client, chain, token, options), + getTokenBalance: (walletAddress, token) => + getTokenBalance(client, walletAddress, token), + getTokenBalances: (walletAddress, tokens) => + getTokenBalances(client, walletAddress, tokens), + getTokenBalancesByChain: (walletAddress, tokensByChain) => + getTokenBalancesByChain(client, walletAddress, tokensByChain), + getTransactionHistory: (params, options) => + getTransactionHistory(client, params, options), + getWalletBalances: (walletAddress, options) => + getWalletBalances(client, walletAddress, options), + relayTransaction: (params, options) => + relayTransaction(client, params, options), + } +} diff --git a/src/actions/relayTransaction.ts b/src/actions/relayTransaction.ts new file mode 100644 index 00000000..aee89f9e --- /dev/null +++ b/src/actions/relayTransaction.ts @@ -0,0 +1,74 @@ +import type { + RelayRequest, + RelayResponse, + RelayResponseData, + RequestOptions, +} from '@lifi/types' +import { BaseError } from '../errors/baseError.js' +import { ErrorName } from '../errors/constants.js' +import { ValidationError } from '../errors/errors.js' +import { SDKError } from '../errors/SDKError.js' +import { request } from '../request.js' +import type { SDKClient } from '../types/core.js' + +/** + * Relay a transaction through the relayer service + * @param client - The SDK client + * @param params - The configuration for the relay request + * @param options - Request options + * @throws {LiFiError} - Throws a LiFiError if request fails + * @returns Task ID and transaction link for the relayed transaction + */ +export const relayTransaction = async ( + client: SDKClient, + params: RelayRequest, + options?: RequestOptions +): Promise => { + const requiredParameters: Array = ['typedData'] + + for (const requiredParameter of requiredParameters) { + if (!params[requiredParameter]) { + throw new SDKError( + new ValidationError( + `Required parameter "${requiredParameter}" is missing.` + ) + ) + } + } + + // Determine if the request is for a gasless relayer service or advanced relayer service + // We will use the same endpoint for both after the gasless relayer service is deprecated + const relayerPath = params.typedData.some( + (t) => t.primaryType === 'PermitWitnessTransferFrom' + ) + ? '/relayer/relay' + : '/advanced/relay' + + const result = await request( + client.config, + `${client.config.apiUrl}${relayerPath}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(params, (_, value) => { + if (typeof value === 'bigint') { + return value.toString() + } + return value + }), + signal: options?.signal, + } + ) + + if (result.status === 'error') { + throw new BaseError( + ErrorName.ServerError, + result.data.code, + result.data.message + ) + } + + return result.data +} diff --git a/src/actions/relayTransaction.unit.spec.ts b/src/actions/relayTransaction.unit.spec.ts new file mode 100644 index 00000000..6f054110 --- /dev/null +++ b/src/actions/relayTransaction.unit.spec.ts @@ -0,0 +1,229 @@ +import type { RelayRequest, RelayResponse, RequestOptions } from '@lifi/types' +import { HttpResponse, http } from 'msw' +import { describe, expect, it } from 'vitest' +import { BaseError } from '../errors/baseError.js' +import { ErrorName } from '../errors/constants.js' +import { SDKError } from '../errors/SDKError.js' +import { client, setupTestServer } from './actions.unit.handlers.js' +import { relayTransaction } from './relayTransaction.js' + +describe('relayTransaction', () => { + const server = setupTestServer() + + const createMockRelayRequest = (typedData: any[]): RelayRequest => ({ + type: 'lifi', + id: 'test-step-id', + includedSteps: [], + tool: 'test-tool', + toolDetails: { + key: 'test-tool', + name: 'Test Tool', + logoURI: 'https://example.com/logo.png', + }, + action: { + fromChainId: 1, + toChainId: 1, + fromToken: { + address: '0x1234567890123456789012345678901234567890', + symbol: 'TEST', + decimals: 18, + chainId: 1, + name: 'Test Token', + priceUSD: '1.00', + }, + toToken: { + address: '0x0987654321098765432109876543210987654321', + symbol: 'TEST2', + decimals: 18, + chainId: 1, + name: 'Test Token 2', + priceUSD: '1.00', + }, + fromAmount: '1000000000000000000', + }, + estimate: { + fromAmount: '1000000000000000000', + toAmount: '1000000000000000000', + toAmountMin: '1000000000000000000', + approvalAddress: '0x1234567890123456789012345678901234567890', + tool: 'test-tool', + executionDuration: 30000, + }, + transactionRequest: { + to: '0x1234567890123456789012345678901234567890', + data: '0x', + value: '0', + gasLimit: '100000', + }, + typedData, + }) + + const mockRelayRequest: RelayRequest = createMockRelayRequest([ + { + domain: { + name: 'Test Token', + version: '1', + chainId: 1, + verifyingContract: '0x1234567890123456789012345678901234567890', + }, + types: { + Permit: [ + { name: 'owner', type: 'address' }, + { name: 'spender', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + ], + }, + primaryType: 'Permit', + message: { + owner: '0x1234567890123456789012345678901234567890', + spender: '0x0987654321098765432109876543210987654321', + value: '1000000000000000000', + nonce: 0, + deadline: 1234567890, + }, + signature: + '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12', + }, + ]) + + const mockRelayRequestWithPermitWitness: RelayRequest = + createMockRelayRequest([ + { + domain: { + name: 'Test Token', + version: '1', + chainId: 1, + verifyingContract: '0x1234567890123456789012345678901234567890', + }, + types: { + PermitWitnessTransferFrom: [ + { name: 'owner', type: 'address' }, + { name: 'spender', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + ], + }, + primaryType: 'PermitWitnessTransferFrom', + message: { + owner: '0x1234567890123456789012345678901234567890', + spender: '0x0987654321098765432109876543210987654321', + value: '1000000000000000000', + nonce: 0, + deadline: 1234567890, + }, + signature: + '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12', + }, + ]) + + const mockSuccessResponse: RelayResponse = { + status: 'ok', + data: { + taskId: 'test-task-id-123', + }, + } + + const mockErrorResponse: RelayResponse = { + status: 'error', + data: { + code: 400, + message: 'Invalid request parameters', + }, + } + + describe('success scenarios', () => { + it('should relay transaction successfully for advanced relayer', async () => { + server.use( + http.post(`${client.config.apiUrl}/advanced/relay`, async () => { + return HttpResponse.json(mockSuccessResponse) + }) + ) + + const result = await relayTransaction(client, mockRelayRequest) + + expect(result).toEqual(mockSuccessResponse.data) + }) + + it('should relay transaction successfully for gasless relayer', async () => { + server.use( + http.post(`${client.config.apiUrl}/relayer/relay`, async () => { + return HttpResponse.json(mockSuccessResponse) + }) + ) + + const result = await relayTransaction( + client, + mockRelayRequestWithPermitWitness + ) + + expect(result).toEqual(mockSuccessResponse.data) + }) + }) + + describe('error scenarios', () => { + it('should throw BaseError when server returns error status', async () => { + server.use( + http.post(`${client.config.apiUrl}/advanced/relay`, async () => { + return HttpResponse.json(mockErrorResponse) + }) + ) + + await expect(relayTransaction(client, mockRelayRequest)).rejects.toThrow( + BaseError + ) + + try { + await relayTransaction(client, mockRelayRequest) + } catch (error) { + expect(error).toBeInstanceOf(BaseError) + expect((error as BaseError).name).toBe(ErrorName.ServerError) + expect((error as BaseError).code).toBe(400) + expect((error as BaseError).message).toBe('Invalid request parameters') + } + }) + + it('should throw SDKError when network request fails', async () => { + server.use( + http.post(`${client.config.apiUrl}/advanced/relay`, async () => { + return HttpResponse.error() + }) + ) + + await expect(relayTransaction(client, mockRelayRequest)).rejects.toThrow( + SDKError + ) + }) + + it('should throw SDKError when request times out', async () => { + server.use( + http.post(`${client.config.apiUrl}/advanced/relay`, async () => { + // Simulate timeout by not responding + await new Promise(() => {}) // Never resolves + }) + ) + + const timeoutOptions: RequestOptions = { + signal: AbortSignal.timeout(100), // 100ms timeout + } + + await expect( + relayTransaction(client, mockRelayRequest, timeoutOptions) + ).rejects.toThrow() + }) + }) + + describe('validation scenarios', () => { + it('should throw SDKError when typedData is missing', async () => { + const invalidRequest = createMockRelayRequest([]) + // Remove typedData to test validation + delete (invalidRequest as any).typedData + + await expect(relayTransaction(client, invalidRequest)).rejects.toThrow( + SDKError + ) + }) + }) +}) diff --git a/src/client/createClient.ts b/src/client/createClient.ts new file mode 100644 index 00000000..afc4f196 --- /dev/null +++ b/src/client/createClient.ts @@ -0,0 +1,94 @@ +import type { ChainId, ChainType } from '@lifi/types' +import type { + SDKBaseConfig, + SDKClient, + SDKConfig, + SDKProvider, +} from '../types/core.js' +import { checkPackageUpdates } from '../utils/checkPackageUpdates.js' +import { name, version } from '../version.js' +import { getClientStorage } from './getClientStorage.js' + +export function createClient(options: SDKConfig): SDKClient { + if (!options.integrator) { + throw new Error( + 'Integrator not found. Please see documentation https://docs.li.fi/integrate-li.fi-js-sdk/set-up-the-sdk' + ) + } + + if (!options.disableVersionCheck && process.env.NODE_ENV === 'development') { + checkPackageUpdates(name, version) + } + + const _config: SDKBaseConfig = { + ...options, + apiUrl: options?.apiUrl ?? 'https://li.quest/v1', + rpcUrls: options?.rpcUrls ?? {}, + debug: options?.debug ?? false, + integrator: options?.integrator ?? 'lifi-sdk', + } + + let _providers: SDKProvider[] = [] + const _storage = getClientStorage(_config) + + const client: SDKClient = { + get config() { + return _config + }, + get providers() { + return _providers + }, + getProvider(type: ChainType) { + return this.providers.find((provider) => provider.type === type) + }, + setProviders(newProviders: SDKProvider[]) { + const providerMap = new Map( + this.providers.map((provider) => [provider.type, provider]) + ) + for (const provider of newProviders) { + providerMap.set(provider.type, provider) + } + _providers = Array.from(providerMap.values()) + }, + async getChains() { + return await _storage.getChains() + }, + async getChainById(chainId: ChainId) { + const chains = await this.getChains() + const chain = chains?.find((chain) => chain.id === chainId) + if (!chain) { + throw new Error(`ChainId ${chainId} not found`) + } + return chain + }, + async getRpcUrls() { + return await _storage.getRpcUrls() + }, + async getRpcUrlsByChainId(chainId: ChainId) { + const rpcUrls = await this.getRpcUrls() + const chainRpcUrls = rpcUrls[chainId] + if (!chainRpcUrls?.length) { + throw new Error(`RPC URL not found for chainId: ${chainId}`) + } + return chainRpcUrls + }, + } + + function extend( + base: TClient + ): >( + extendFn: (client: TClient) => TExtensions + ) => TClient & TExtensions { + return (extendFn) => { + const extensions = extendFn(base) + const extended = { ...base, ...extensions } as TClient & typeof extensions + + // Preserve the extend function for further extensions + return Object.assign(extended, { + extend: extend(extended), + }) + } + } + + return Object.assign(client, { extend: extend(client) }) +} diff --git a/src/client/createClient.unit.spec.ts b/src/client/createClient.unit.spec.ts new file mode 100644 index 00000000..c212c9c4 --- /dev/null +++ b/src/client/createClient.unit.spec.ts @@ -0,0 +1,263 @@ +import { ChainId, ChainType } from '@lifi/types' +import { describe, expect, it, vi } from 'vitest' +import { EVM } from '../core/EVM/EVM.js' +import { Solana } from '../core/Solana/Solana.js' +import { UTXO } from '../core/UTXO/UTXO.js' +import type { SDKConfig } from '../types/core.js' +import { createClient } from './createClient.js' + +// Mock the version check +vi.mock('../utils/checkPackageUpdates.js', () => ({ + checkPackageUpdates: vi.fn(), +})) + +// Mock the client storage +vi.mock('./getClientStorage.js', () => ({ + getClientStorage: vi.fn(() => ({ + getChains: vi.fn().mockResolvedValue([ + { id: 1, name: 'Ethereum', type: ChainType.EVM }, + { id: 137, name: 'Polygon', type: ChainType.EVM }, + ]), + getRpcUrls: vi.fn().mockResolvedValue({ + [ChainId.ETH]: ['https://eth-mainnet.alchemyapi.io/v2/test'], + [ChainId.POL]: ['https://polygon-rpc.com'], + }), + })), +})) + +describe('createClient', () => { + describe('basic functionality', () => { + it('should create a client with minimal config', () => { + const client = createClient({ + integrator: 'test-app', + }) + + expect(client).toBeDefined() + expect(client.config.integrator).toBe('test-app') + expect(client.config.apiUrl).toBe('https://li.quest/v1') + expect(client.config.debug).toBe(false) + expect(client.providers).toEqual([]) + }) + + it('should create a client with full config', () => { + const config: SDKConfig = { + integrator: 'test-app', + apiKey: 'test-api-key', + apiUrl: 'https://custom-api.com', + userId: 'user-123', + debug: true, + disableVersionCheck: true, + widgetVersion: '1.0.0', + rpcUrls: { + [ChainId.ETH]: ['https://eth-mainnet.alchemyapi.io/v2/test'], + }, + } + + const client = createClient(config) + + expect(client.config).toEqual({ + integrator: 'test-app', + apiKey: 'test-api-key', + apiUrl: 'https://custom-api.com', + userId: 'user-123', + debug: true, + disableVersionCheck: true, + widgetVersion: '1.0.0', + rpcUrls: { + [ChainId.ETH]: ['https://eth-mainnet.alchemyapi.io/v2/test'], + }, + }) + }) + + it('should throw error when integrator is missing', () => { + expect(() => { + createClient({} as SDKConfig) + }).toThrow( + 'Integrator not found. Please see documentation https://docs.li.fi/integrate-li.fi-js-sdk/set-up-the-sdk' + ) + }) + + it('should throw error when integrator is empty string', () => { + expect(() => { + createClient({ integrator: '' }) + }).toThrow( + 'Integrator not found. Please see documentation https://docs.li.fi/integrate-li.fi-js-sdk/set-up-the-sdk' + ) + }) + }) + + describe('provider management', () => { + it('should handle empty providers list', () => { + const client = createClient({ integrator: 'test-app' }) + expect(client.providers).toEqual([]) + expect(client.getProvider(ChainType.EVM)).toBeUndefined() + }) + + it('should set and get providers', () => { + const client = createClient({ integrator: 'test-app' }) + const evmProvider = EVM() + const solanaProvider = Solana() + + client.setProviders([evmProvider, solanaProvider]) + + expect(client.providers).toHaveLength(2) + expect(client.getProvider(ChainType.EVM)).toBe(evmProvider) + expect(client.getProvider(ChainType.SVM)).toBe(solanaProvider) + expect(client.getProvider(ChainType.UTXO)).toBeUndefined() + }) + + it('should merge providers when setting new ones', () => { + const client = createClient({ integrator: 'test-app' }) + const evmProvider = EVM() + const utxoProvider = UTXO() + + client.setProviders([evmProvider]) + expect(client.providers).toHaveLength(1) + + client.setProviders([utxoProvider]) + expect(client.providers).toHaveLength(2) + expect(client.getProvider(ChainType.EVM)).toBe(evmProvider) + expect(client.getProvider(ChainType.UTXO)).toBe(utxoProvider) + }) + + it('should merge providers when setting overlapping types', () => { + const client = createClient({ integrator: 'test-app' }) + const evmProvider1 = EVM() + const evmProvider2 = EVM() + const solanaProvider = Solana() + + client.setProviders([evmProvider1]) + client.setProviders([evmProvider2, solanaProvider]) + + expect(client.providers).toHaveLength(2) + expect(client.getProvider(ChainType.EVM)).toBe(evmProvider2) + expect(client.getProvider(ChainType.SVM)).toBe(solanaProvider) + }) + }) + + describe('chain management', () => { + it('should get chains from storage', async () => { + const client = createClient({ integrator: 'test-app' }) + const chains = await client.getChains() + + expect(chains).toEqual([ + { id: 1, name: 'Ethereum', type: ChainType.EVM }, + { id: 137, name: 'Polygon', type: ChainType.EVM }, + ]) + }) + + it('should get chain by id', async () => { + const client = createClient({ integrator: 'test-app' }) + const chain = await client.getChainById(1) + + expect(chain).toEqual({ id: 1, name: 'Ethereum', type: ChainType.EVM }) + }) + + it('should throw error when chain not found', async () => { + const client = createClient({ integrator: 'test-app' }) + + await expect(client.getChainById(999)).rejects.toThrow( + 'ChainId 999 not found' + ) + }) + }) + + describe('RPC URL management', () => { + it('should get RPC URLs from storage', async () => { + const client = createClient({ integrator: 'test-app' }) + const rpcUrls = await client.getRpcUrls() + + expect(rpcUrls).toEqual({ + [ChainId.ETH]: ['https://eth-mainnet.alchemyapi.io/v2/test'], + [ChainId.POL]: ['https://polygon-rpc.com'], + }) + }) + + it('should get RPC URLs by chain id', async () => { + const client = createClient({ integrator: 'test-app' }) + const ethUrls = await client.getRpcUrlsByChainId(ChainId.ETH) + const polUrls = await client.getRpcUrlsByChainId(ChainId.POL) + + expect(ethUrls).toEqual(['https://eth-mainnet.alchemyapi.io/v2/test']) + expect(polUrls).toEqual(['https://polygon-rpc.com']) + }) + + it('should throw error when RPC URLs not found for chain', async () => { + const client = createClient({ integrator: 'test-app' }) + + await expect(client.getRpcUrlsByChainId(999)).rejects.toThrow( + 'RPC URL not found for chainId: 999' + ) + }) + }) + + describe('extend functionality', () => { + it('should extend client with additional functionality', () => { + const client = createClient({ integrator: 'test-app' }) + + const extendedClient = (client as any).extend((_baseClient: any) => ({ + customMethod: () => 'custom-value', + anotherMethod: (value: string) => `processed-${value}`, + })) + + expect(extendedClient.customMethod()).toBe('custom-value') + expect(extendedClient.anotherMethod('test')).toBe('processed-test') + + // Should preserve original functionality + expect(extendedClient.config.integrator).toBe('test-app') + expect(extendedClient.providers).toEqual([]) + }) + + it('should allow chaining multiple extensions', () => { + const client = createClient({ integrator: 'test-app' }) + + const extendedClient = (client as any) + .extend((_baseClient: any) => ({ + firstExtension: () => 'first', + })) + .extend((_baseClient: any) => ({ + secondExtension: () => 'second', + })) + + expect(extendedClient.firstExtension()).toBe('first') + expect(extendedClient.secondExtension()).toBe('second') + expect(extendedClient.config.integrator).toBe('test-app') + }) + + it('should preserve extend function after extension', () => { + const client = createClient({ integrator: 'test-app' }) + + const extendedClient = (client as any).extend((_baseClient: any) => ({ + customMethod: () => 'custom-value', + })) + + expect(typeof extendedClient.extend).toBe('function') + + const doubleExtendedClient = extendedClient.extend( + (_baseClient: any) => ({ + anotherMethod: () => 'another-value', + }) + ) + + expect(doubleExtendedClient.customMethod()).toBe('custom-value') + expect(doubleExtendedClient.anotherMethod()).toBe('another-value') + }) + }) + + describe('error handling', () => { + it('should handle storage errors gracefully', async () => { + // Mock storage to throw error + const { getClientStorage } = await import('./getClientStorage.js') + vi.mocked(getClientStorage).mockReturnValueOnce({ + needReset: false, + getChains: vi.fn().mockRejectedValue(new Error('Storage error')), + getRpcUrls: vi.fn().mockRejectedValue(new Error('Storage error')), + }) + + const newClient = createClient({ integrator: 'test-app' }) + + await expect(newClient.getChains()).rejects.toThrow('Storage error') + await expect(newClient.getRpcUrls()).rejects.toThrow('Storage error') + }) + }) +}) diff --git a/src/client/getClientStorage.ts b/src/client/getClientStorage.ts new file mode 100644 index 00000000..52e66bfb --- /dev/null +++ b/src/client/getClientStorage.ts @@ -0,0 +1,40 @@ +import { ChainId, ChainType, type ExtendedChain } from '@lifi/types' +import { getChainsFromConfig } from '../actions/getChains.js' +import { getRpcUrlsFromChains } from '../core/utils.js' +import type { RPCUrls, SDKBaseConfig } from '../types/core.js' + +export const getClientStorage = (config: SDKBaseConfig) => { + let _chains = [] as ExtendedChain[] + let _rpcUrls = { ...config.rpcUrls } as RPCUrls + let _chainsUpdatedAt: number | undefined + + return { + get needReset() { + return ( + !_chainsUpdatedAt || + Date.now() - _chainsUpdatedAt >= 1000 * 60 * 60 * 24 + ) + }, + async getChains() { + if (this.needReset || !_chains.length) { + _chains = await getChainsFromConfig(config, { + chainTypes: [ + ChainType.EVM, + ChainType.SVM, + ChainType.UTXO, + ChainType.MVM, + ], + }) + _chainsUpdatedAt = Date.now() + } + return _chains + }, + async getRpcUrls() { + if (this.needReset || !Object.keys(_rpcUrls).length) { + const chains = await this.getChains() + _rpcUrls = getRpcUrlsFromChains(_rpcUrls, chains, [ChainId.SOL]) + } + return _rpcUrls + }, + } +} diff --git a/src/client/getClientStorage.unit.spec.ts b/src/client/getClientStorage.unit.spec.ts new file mode 100644 index 00000000..321e3d53 --- /dev/null +++ b/src/client/getClientStorage.unit.spec.ts @@ -0,0 +1,382 @@ +import { + ChainId, + ChainKey, + ChainType, + CoinKey, + type ExtendedChain, +} from '@lifi/types' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RPCUrls, SDKBaseConfig } from '../types/core.js' +import { getClientStorage } from './getClientStorage.js' + +// Mock the dependencies +vi.mock('../actions/getChains.js', () => ({ + getChainsFromConfig: vi.fn(), +})) + +vi.mock('../core/utils.js', () => ({ + getRpcUrlsFromChains: vi.fn(), +})) + +describe('getClientStorage', () => { + let mockConfig: SDKBaseConfig + let mockChains: ExtendedChain[] + let mockRpcUrls: RPCUrls + + beforeEach(() => { + mockConfig = { + integrator: 'test-app', + apiUrl: 'https://li.quest/v1', + debug: false, + rpcUrls: { + [ChainId.ETH]: ['https://eth-mainnet.alchemyapi.io/v2/test'], + }, + } + + mockChains = [ + { + id: ChainId.ETH, + name: 'Ethereum', + chainType: ChainType.EVM, + key: ChainKey.ETH, + coin: CoinKey.ETH, + mainnet: true, + nativeToken: { + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + decimals: 18, + name: 'Ethereum', + chainId: ChainId.ETH, + priceUSD: '0', + }, + metamask: { + chainId: '0x1', + chainName: 'Ethereum', + nativeCurrency: { + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + }, + rpcUrls: ['https://eth-mainnet.alchemyapi.io/v2/test'], + blockExplorerUrls: ['https://etherscan.io'], + }, + }, + { + id: ChainId.POL, + name: 'Polygon', + chainType: ChainType.EVM, + key: ChainKey.POL, + coin: CoinKey.MATIC, + mainnet: true, + nativeToken: { + address: '0x0000000000000000000000000000000000000000', + symbol: 'MATIC', + decimals: 18, + name: 'Polygon', + chainId: ChainId.POL, + priceUSD: '0', + }, + metamask: { + chainId: '0x89', + chainName: 'Polygon', + nativeCurrency: { + name: 'Polygon', + symbol: 'MATIC', + decimals: 18, + }, + rpcUrls: ['https://polygon-rpc.com'], + blockExplorerUrls: ['https://polygonscan.com'], + }, + }, + ] + + mockRpcUrls = { + [ChainId.ETH]: ['https://eth-mainnet.alchemyapi.io/v2/test'], + [ChainId.POL]: ['https://polygon-rpc.com'], + } + + // Reset mocks + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('needReset property', () => { + it('should return true when chainsUpdatedAt is undefined', () => { + const storage = getClientStorage(mockConfig) + expect(storage.needReset).toBe(true) // Because _chainsUpdatedAt is undefined + }) + + it('should return true when chains are older than 24 hours', async () => { + const { getChainsFromConfig } = await import('../actions/getChains.js') + const { getRpcUrlsFromChains } = await import('../core/utils.js') + + vi.mocked(getChainsFromConfig).mockResolvedValue(mockChains) + vi.mocked(getRpcUrlsFromChains).mockReturnValue(mockRpcUrls) + + const storage = getClientStorage(mockConfig) + + // First call to getChains sets the timestamp + await storage.getChains() + + // Mock Date.now to return a time 25 hours later + const originalDateNow = Date.now + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 25 * 60 * 60 * 1000) + + expect(storage.needReset).toBe(true) + + // Restore Date.now + Date.now = originalDateNow + }) + + it('should return false when chains are fresh', async () => { + const { getChainsFromConfig } = await import('../actions/getChains.js') + const { getRpcUrlsFromChains } = await import('../core/utils.js') + + vi.mocked(getChainsFromConfig).mockResolvedValue(mockChains) + vi.mocked(getRpcUrlsFromChains).mockReturnValue(mockRpcUrls) + + const storage = getClientStorage(mockConfig) + + // First call to getChains sets the timestamp + await storage.getChains() + + // Should not need reset immediately after + expect(storage.needReset).toBe(false) + }) + }) + + describe('getChains method', () => { + it('should fetch chains when needReset is true', async () => { + const { getChainsFromConfig } = await import('../actions/getChains.js') + const { getRpcUrlsFromChains } = await import('../core/utils.js') + + vi.mocked(getChainsFromConfig).mockResolvedValue(mockChains) + vi.mocked(getRpcUrlsFromChains).mockReturnValue(mockRpcUrls) + + const storage = getClientStorage(mockConfig) + const chains = await storage.getChains() + + expect(getChainsFromConfig).toHaveBeenCalledWith(mockConfig, { + chainTypes: [ + ChainType.EVM, + ChainType.SVM, + ChainType.UTXO, + ChainType.MVM, + ], + }) + expect(chains).toEqual(mockChains) + }) + + it('should return cached chains when not needReset and chains exist', async () => { + const { getChainsFromConfig } = await import('../actions/getChains.js') + const { getRpcUrlsFromChains } = await import('../core/utils.js') + + vi.mocked(getChainsFromConfig).mockResolvedValue(mockChains) + vi.mocked(getRpcUrlsFromChains).mockReturnValue(mockRpcUrls) + + const storage = getClientStorage(mockConfig) + + // First call fetches chains + const chains1 = await storage.getChains() + expect(getChainsFromConfig).toHaveBeenCalledTimes(1) + + // Second call should return cached chains + const chains2 = await storage.getChains() + expect(getChainsFromConfig).toHaveBeenCalledTimes(1) + expect(chains1).toBe(chains2) // Same reference + }) + + it('should handle errors from getChainsFromConfig', async () => { + const { getChainsFromConfig } = await import('../actions/getChains.js') + + const error = new Error('Failed to fetch chains') + vi.mocked(getChainsFromConfig).mockRejectedValue(error) + + const configWithoutRpcUrls = { + ...mockConfig, + rpcUrls: {}, + } + const storage = getClientStorage(configWithoutRpcUrls) + + await expect(storage.getChains()).rejects.toThrow( + 'Failed to fetch chains' + ) + }) + }) + + describe('getRpcUrls method', () => { + it('should fetch RPC URLs when needReset is true', async () => { + const { getChainsFromConfig } = await import('../actions/getChains.js') + const { getRpcUrlsFromChains } = await import('../core/utils.js') + + vi.mocked(getChainsFromConfig).mockResolvedValue(mockChains) + vi.mocked(getRpcUrlsFromChains).mockReturnValue(mockRpcUrls) + + const configWithoutRpcUrls = { + ...mockConfig, + rpcUrls: {}, + } + const storage = getClientStorage(configWithoutRpcUrls) + const rpcUrls = await storage.getRpcUrls() + + expect(getRpcUrlsFromChains).toHaveBeenCalledWith({}, mockChains, [ + ChainId.SOL, + ]) + expect(rpcUrls).toEqual(mockRpcUrls) + }) + + it('should return cached RPC URLs when not needReset and RPC URLs exist', async () => { + const { getChainsFromConfig } = await import('../actions/getChains.js') + const { getRpcUrlsFromChains } = await import('../core/utils.js') + + vi.mocked(getChainsFromConfig).mockResolvedValue(mockChains) + vi.mocked(getRpcUrlsFromChains).mockReturnValue(mockRpcUrls) + + const storage = getClientStorage(mockConfig) + + // First call getChains to set the timestamp and make needReset false + await storage.getChains() + + // Now getRpcUrls should use existing RPC URLs without calling getRpcUrlsFromChains + const rpcUrls1 = await storage.getRpcUrls() + expect(getRpcUrlsFromChains).toHaveBeenCalledTimes(0) // Should use existing RPC URLs + + // Second call should return cached RPC URLs + const rpcUrls2 = await storage.getRpcUrls() + expect(getRpcUrlsFromChains).toHaveBeenCalledTimes(0) + expect(rpcUrls1).toBe(rpcUrls2) // Same reference + }) + + it('should handle errors from getRpcUrlsFromChains', async () => { + const { getChainsFromConfig } = await import('../actions/getChains.js') + const { getRpcUrlsFromChains } = await import('../core/utils.js') + + vi.mocked(getChainsFromConfig).mockResolvedValue(mockChains) + const error = new Error('Failed to process RPC URLs') + vi.mocked(getRpcUrlsFromChains).mockImplementation(() => { + throw error + }) + + const configWithoutRpcUrls = { + ...mockConfig, + rpcUrls: {}, + } + const storage = getClientStorage(configWithoutRpcUrls) + + await expect(storage.getRpcUrls()).rejects.toThrow( + 'Failed to process RPC URLs' + ) + }) + }) + + describe('caching behavior', () => { + it('should reset cache when needReset becomes true', async () => { + const { getChainsFromConfig } = await import('../actions/getChains.js') + const { getRpcUrlsFromChains } = await import('../core/utils.js') + + vi.mocked(getChainsFromConfig).mockResolvedValue(mockChains) + vi.mocked(getRpcUrlsFromChains).mockReturnValue(mockRpcUrls) + + const configWithoutRpcUrls = { + ...mockConfig, + rpcUrls: {}, + } + const storage = getClientStorage(configWithoutRpcUrls) + + // First call + await storage.getChains() + await storage.getRpcUrls() + + // Mock Date.now to return a time 25 hours later + const originalDateNow = Date.now + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 25 * 60 * 60 * 1000) + + // Should refetch when needReset is true + await storage.getChains() + await storage.getRpcUrls() + + expect(getChainsFromConfig).toHaveBeenCalledTimes(2) + expect(getRpcUrlsFromChains).toHaveBeenCalledTimes(1) // Only called once because we have existing RPC URLs + + // Restore Date.now + Date.now = originalDateNow + }) + }) + + describe('edge cases', () => { + it('should handle chains without metamask RPC URLs', async () => { + const { getChainsFromConfig } = await import('../actions/getChains.js') + const { getRpcUrlsFromChains } = await import('../core/utils.js') + + const chainsWithoutRpcUrls = [ + { + id: ChainId.ETH, + name: 'Ethereum', + key: ChainKey.ETH, + chainType: ChainType.EVM, + coin: CoinKey.ETH, + mainnet: true, + nativeToken: { + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + decimals: 18, + name: 'Ethereum', + chainId: ChainId.ETH, + priceUSD: '0', + }, + metamask: { + chainId: '0x1', + chainName: 'Ethereum', + nativeCurrency: { + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + }, + rpcUrls: [], + blockExplorerUrls: ['https://etherscan.io'], + }, + }, + ] + + vi.mocked(getChainsFromConfig).mockResolvedValue(chainsWithoutRpcUrls) + vi.mocked(getRpcUrlsFromChains).mockReturnValue({}) + + const configWithoutRpcUrls = { + ...mockConfig, + rpcUrls: {}, + } + const storage = getClientStorage(configWithoutRpcUrls) + + const rpcUrls = await storage.getRpcUrls() + + expect(getRpcUrlsFromChains).toHaveBeenCalledWith( + {}, + chainsWithoutRpcUrls, + [ChainId.SOL] + ) + expect(rpcUrls).toEqual({}) + }) + + it('should preserve existing RPC URLs from config', async () => { + const { getChainsFromConfig } = await import('../actions/getChains.js') + const { getRpcUrlsFromChains } = await import('../core/utils.js') + + vi.mocked(getChainsFromConfig).mockResolvedValue(mockChains) + vi.mocked(getRpcUrlsFromChains).mockReturnValue(mockRpcUrls) + + const storage = getClientStorage(mockConfig) + + // First call getChains to set the timestamp and make needReset false + await storage.getChains() + + const rpcUrls = await storage.getRpcUrls() + + // Should not call getRpcUrlsFromChains because we already have RPC URLs + expect(getRpcUrlsFromChains).not.toHaveBeenCalled() + expect(rpcUrls).toEqual(mockConfig.rpcUrls) + }) + }) +}) diff --git a/src/config.ts b/src/config.ts deleted file mode 100644 index 9c322fc1..00000000 --- a/src/config.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { ChainId, type ChainType, type ExtendedChain } from '@lifi/types' -import type { SDKProvider } from './core/types.js' -import type { RPCUrls, SDKBaseConfig, SDKConfig } from './types/internal.js' - -export const config = (() => { - const _config: SDKBaseConfig = { - integrator: 'lifi-sdk', - apiUrl: 'https://li.quest/v1', - rpcUrls: {}, - chains: [], - providers: [], - preloadChains: true, - debug: false, - } - let _loading: Promise | undefined - return { - set loading(loading: Promise) { - _loading = loading - }, - get() { - return _config - }, - set(options: SDKConfig) { - const { chains, providers, rpcUrls, ...otherOptions } = options - Object.assign(_config, otherOptions) - if (chains) { - this.setChains(chains) - } - if (providers) { - this.setProviders(providers) - } - if (rpcUrls) { - this.setRPCUrls(rpcUrls) - } - return _config - }, - getProvider(type: ChainType) { - return _config.providers.find((provider) => provider.type === type) - }, - setProviders(providers: SDKProvider[]) { - const providerMap = new Map( - _config.providers.map((provider) => [provider.type, provider]) - ) - for (const provider of providers) { - providerMap.set(provider.type, provider) - } - _config.providers = Array.from(providerMap.values()) - }, - setChains(chains: ExtendedChain[]) { - const rpcUrls = chains.reduce((rpcUrls, chain) => { - if (chain.metamask?.rpcUrls?.length) { - rpcUrls[chain.id as ChainId] = chain.metamask.rpcUrls - } - return rpcUrls - }, {} as RPCUrls) - this.setRPCUrls(rpcUrls, [ChainId.SOL]) - _config.chains = chains - _loading = undefined - }, - async getChains() { - if (_loading) { - await _loading - } - return _config.chains - }, - async getChainById(chainId: ChainId) { - if (_loading) { - await _loading - } - const chain = _config.chains?.find((chain) => chain.id === chainId) - if (!chain) { - throw new Error(`ChainId ${chainId} not found`) - } - return chain - }, - setRPCUrls(rpcUrls: RPCUrls, skipChains?: ChainId[]) { - for (const rpcUrlsKey in rpcUrls) { - const chainId = Number(rpcUrlsKey) as ChainId - const urls = rpcUrls[chainId] - if (!urls?.length) { - continue - } - if (!_config.rpcUrls[chainId]?.length) { - _config.rpcUrls[chainId] = Array.from(urls) - } else if (!skipChains?.includes(chainId)) { - const filteredUrls = urls.filter( - (url) => !_config.rpcUrls[chainId]?.includes(url) - ) - _config.rpcUrls[chainId].push(...filteredUrls) - } - } - }, - async getRPCUrls() { - if (_loading) { - await _loading - } - return _config.rpcUrls - }, - } -})() diff --git a/src/core/BaseStepExecutor.ts b/src/core/BaseStepExecutor.ts index 854f15a7..d4a6c636 100644 --- a/src/core/BaseStepExecutor.ts +++ b/src/core/BaseStepExecutor.ts @@ -1,11 +1,12 @@ import type { LiFiStep } from '@lifi/types' -import { StatusManager } from './StatusManager.js' import type { ExecutionOptions, InteractionSettings, + SDKClient, StepExecutor, StepExecutorOptions, -} from './types.js' +} from '../types/core.js' +import { StatusManager } from './StatusManager.js' // Please be careful when changing the defaults as it may break the behavior (e.g., background execution) const defaultInteractionSettings = { @@ -36,5 +37,5 @@ export abstract class BaseStepExecutor implements StepExecutor { this.allowExecution = interactionSettings.allowExecution } - abstract executeStep(step: LiFiStep): Promise + abstract executeStep(client: SDKClient, step: LiFiStep): Promise } diff --git a/src/core/EVM/EVM.ts b/src/core/EVM/EVM.ts index 763e0775..f20b0012 100644 --- a/src/core/EVM/EVM.ts +++ b/src/core/EVM/EVM.ts @@ -1,6 +1,6 @@ import { ChainType } from '@lifi/types' import { isAddress } from 'viem' -import type { StepExecutorOptions } from '../types.js' +import type { StepExecutorOptions } from '../../types/core.js' import { EVMStepExecutor } from './EVMStepExecutor.js' import { getEVMBalance } from './getEVMBalance.js' import { resolveEVMAddress } from './resolveEVMAddress.js' diff --git a/src/core/EVM/EVMStepExecutor.ts b/src/core/EVM/EVMStepExecutor.ts index 77bc382c..35386804 100644 --- a/src/core/EVM/EVMStepExecutor.ts +++ b/src/core/EVM/EVMStepExecutor.ts @@ -16,25 +16,23 @@ import { signTypedData, } from 'viem/actions' import { getAction, isHex } from 'viem/utils' -import { config } from '../../config.js' +import { getRelayerQuote } from '../../actions/getRelayerQuote.js' +import { getStepTransaction } from '../../actions/getStepTransaction.js' +import { relayTransaction } from '../../actions/relayTransaction.js' import { LiFiErrorCode } from '../../errors/constants.js' import { TransactionError } from '../../errors/errors.js' -import { - getRelayerQuote, - getStepTransaction, - relayTransaction, -} from '../../services/api.js' -import { isZeroAddress } from '../../utils/isZeroAddress.js' -import { BaseStepExecutor } from '../BaseStepExecutor.js' -import { checkBalance } from '../checkBalance.js' -import { stepComparison } from '../stepComparison.js' import type { LiFiStepExtended, Process, + SDKClient, StepExecutorOptions, TransactionMethodType, TransactionParameters, -} from '../types.js' +} from '../../types/core.js' +import { isZeroAddress } from '../../utils/isZeroAddress.js' +import { BaseStepExecutor } from '../BaseStepExecutor.js' +import { checkBalance } from '../checkBalance.js' +import { stepComparison } from '../stepComparison.js' import { waitForDestinationChainTransaction } from '../waitForDestinationChainTransaction.js' import { checkAllowance } from './checkAllowance.js' import { getActionWithFallback } from './getActionWithFallback.js' @@ -124,19 +122,22 @@ export class EVMStepExecutor extends BaseStepExecutor { return updatedClient } - waitForTransaction = async ({ - step, - process, - fromChain, - toChain, - isBridgeExecution, - }: { - step: LiFiStepExtended - process: Process - fromChain: ExtendedChain - toChain: ExtendedChain - isBridgeExecution: boolean - }) => { + waitForTransaction = async ( + client: SDKClient, + { + step, + process, + fromChain, + toChain, + isBridgeExecution, + }: { + step: LiFiStepExtended + process: Process + fromChain: ExtendedChain + toChain: ExtendedChain + isBridgeExecution: boolean + } + ) => { const updateProcessWithReceipt = ( transactionReceipt: TransactionReceipt | WalletCallReceipt | undefined ) => { @@ -187,12 +188,13 @@ export class EVMStepExecutor extends BaseStepExecutor { break case 'relayed': transactionReceipt = await waitForRelayedTransactionReceipt( + client, process.taskId as Hash, step ) break default: - transactionReceipt = await waitForTransactionReceipt({ + transactionReceipt = await waitForTransactionReceipt(client, { client: this.client, chainId: fromChain.id, txHash: process.txHash as Hash, @@ -212,6 +214,7 @@ export class EVMStepExecutor extends BaseStepExecutor { } await waitForDestinationChainTransaction( + client, step, process, fromChain, @@ -221,6 +224,7 @@ export class EVMStepExecutor extends BaseStepExecutor { } private prepareUpdatedStep = async ( + client: SDKClient, step: LiFiStepExtended, signedTypedData?: SignedTypedData[] ) => { @@ -230,7 +234,7 @@ export class EVMStepExecutor extends BaseStepExecutor { const gaslessStep = isGaslessStep(step) let updatedStep: LiFiStep if (relayerStep && gaslessStep) { - const updatedRelayedStep = await getRelayerQuote({ + const updatedRelayedStep = await getRelayerQuote(client, { fromChain: stepBase.action.fromChainId, fromToken: stepBase.action.fromToken.address, fromAddress: stepBase.action.fromAddress!, @@ -253,7 +257,7 @@ export class EVMStepExecutor extends BaseStepExecutor { const params = filteredSignedTypedData?.length ? { ...restStepBase, typedData: filteredSignedTypedData } : restStepBase - updatedStep = await getStepTransaction(params) + updatedStep = await getStepTransaction(client, params) } const comparedStep = await stepComparison( @@ -296,7 +300,7 @@ export class EVMStepExecutor extends BaseStepExecutor { // : undefined, maxPriorityFeePerGas: this.client.account?.type === 'local' - ? await getMaxPriorityFeePerGas(this.client) + ? await getMaxPriorityFeePerGas(client, this.client) : step.transactionRequest.maxPriorityFeePerGas ? BigInt(step.transactionRequest.maxPriorityFeePerGas) : undefined, @@ -327,6 +331,7 @@ export class EVMStepExecutor extends BaseStepExecutor { } private estimateTransactionRequest = async ( + client: SDKClient, transactionRequest: TransactionParameters, fromChain: ExtendedChain ) => { @@ -335,6 +340,7 @@ export class EVMStepExecutor extends BaseStepExecutor { try { // Try to re-estimate the gas due to additional Permit data const estimatedGas = await getActionWithFallback( + client, this.client, estimateGas, 'estimateGas', @@ -360,6 +366,7 @@ export class EVMStepExecutor extends BaseStepExecutor { } executeStep = async ( + client: SDKClient, step: LiFiStepExtended, // Explicitly set to true if the wallet rejected the upgrade to 7702 account, based on the EIP-5792 capabilities atomicityNotReady = false @@ -388,8 +395,8 @@ export class EVMStepExecutor extends BaseStepExecutor { } } - const fromChain = await config.getChainById(step.action.fromChainId) - const toChain = await config.getChainById(step.action.toChainId) + const fromChain = await client.getChainById(step.action.fromChainId) + const toChain = await client.getChainById(step.action.toChainId) // Check if the wallet supports atomic batch transactions (EIP-5792) const calls: Call[] = [] @@ -403,7 +410,7 @@ export class EVMStepExecutor extends BaseStepExecutor { const batchingSupported = atomicityNotReady || step.tool === 'thorswap' || isRelayerStep(step) ? false - : await isBatchingSupported({ + : await isBatchingSupported(client, { client: this.client, chainId: fromChain.id, }) @@ -447,7 +454,7 @@ export class EVMStepExecutor extends BaseStepExecutor { if (checkForAllowance) { // Check if token needs approval and get approval transaction or message data when available - const allowanceResult = await checkAllowance({ + const allowanceResult = await checkAllowance(client, { checkClient: this.checkClient, chain: fromChain, step, @@ -482,6 +489,7 @@ export class EVMStepExecutor extends BaseStepExecutor { try { if (process?.status === 'DONE') { await waitForDestinationChainTransaction( + client, step, process, fromChain, @@ -499,7 +507,7 @@ export class EVMStepExecutor extends BaseStepExecutor { return step } - await this.waitForTransaction({ + await this.waitForTransaction(client, { step, process, fromChain, @@ -517,11 +525,11 @@ export class EVMStepExecutor extends BaseStepExecutor { chainId: fromChain.id, }) - await checkBalance(this.client.account!.address, step) + await checkBalance(client, this.client.account!.address, step) // Try to prepare a new transaction request and update the step with typed data let { transactionRequest, isRelayerTransaction } = - await this.prepareUpdatedStep(step, signedTypedData) + await this.prepareUpdatedStep(client, step, signedTypedData) // Make sure that the chain is still correct const updatedClient = await this.checkClient(step, process) @@ -614,7 +622,7 @@ export class EVMStepExecutor extends BaseStepExecutor { // biome-ignore lint/correctness/noUnusedVariables: destructuring const { execution, ...stepBase } = step - const relayedTransaction = await relayTransaction({ + const relayedTransaction = await relayTransaction(client, { ...stepBase, typedData: signedTypedData, }) @@ -647,7 +655,7 @@ export class EVMStepExecutor extends BaseStepExecutor { process.type, 'MESSAGE_REQUIRED' ) - const permit2Signature = await signPermit2Message({ + const permit2Signature = await signPermit2Message(client, { client: this.client, chain: fromChain, tokenAddress: step.action.fromToken.address as Address, @@ -671,6 +679,7 @@ export class EVMStepExecutor extends BaseStepExecutor { if (signedNativePermitTypedData || permit2Supported) { transactionRequest = await this.estimateTransactionRequest( + client, transactionRequest, fromChain ) @@ -709,7 +718,7 @@ export class EVMStepExecutor extends BaseStepExecutor { } ) - await this.waitForTransaction({ + await this.waitForTransaction(client, { step, process, fromChain, @@ -723,7 +732,7 @@ export class EVMStepExecutor extends BaseStepExecutor { // If the wallet rejected the upgrade to 7702 account, we need to try again with the standard flow if (isAtomicReadyWalletRejectedUpgradeError(e) && !atomicityNotReady) { step.execution = undefined - return this.executeStep(step, true) + return this.executeStep(client, step, true) } const error = await parseEVMErrors(e, step, process) process = this.statusManager.updateProcess( diff --git a/src/core/EVM/checkAllowance.ts b/src/core/EVM/checkAllowance.ts index 2154f82d..14674933 100644 --- a/src/core/EVM/checkAllowance.ts +++ b/src/core/EVM/checkAllowance.ts @@ -3,13 +3,14 @@ import type { Address, Client, Hash } from 'viem' import { signTypedData } from 'viem/actions' import { getAction } from 'viem/utils' import { MaxUint256 } from '../../constants.js' -import type { StatusManager } from '../StatusManager.js' import type { ExecutionOptions, LiFiStepExtended, Process, ProcessType, -} from '../types.js' + SDKClient, +} from '../../types/core.js' +import type { StatusManager } from '../StatusManager.js' import { getActionWithFallback } from './getActionWithFallback.js' import { getAllowance } from './getAllowance.js' import { parseEVMErrors } from './parseEVMErrors.js' @@ -50,17 +51,20 @@ type AllowanceResult = data: SignedTypedData[] } -export const checkAllowance = async ({ - checkClient, - chain, - step, - statusManager, - executionOptions, - allowUserInteraction = false, - batchingSupported = false, - permit2Supported = false, - disableMessageSigning = false, -}: CheckAllowanceParams): Promise => { +export const checkAllowance = async ( + client: SDKClient, + { + checkClient, + chain, + step, + statusManager, + executionOptions, + allowUserInteraction = false, + batchingSupported = false, + permit2Supported = false, + disableMessageSigning = false, + }: CheckAllowanceParams +): Promise => { let sharedProcess: Process | undefined let signedTypedData: SignedTypedData[] = [] try { @@ -164,6 +168,7 @@ export const checkAllowance = async ({ // Handle existing pending transaction if (sharedProcess.txHash && sharedProcess.status !== 'DONE') { await waitForApprovalTransaction( + client, updatedClient, sharedProcess.txHash as Address, sharedProcess.type, @@ -184,6 +189,7 @@ export const checkAllowance = async ({ const fromAmount = BigInt(step.action.fromAmount) const approved = await getAllowance( + client, updatedClient, step.action.fromToken.address as Address, updatedClient.account!.address, @@ -203,10 +209,13 @@ export const checkAllowance = async ({ let nativePermitData: NativePermitData | undefined if (isNativePermitAvailable) { nativePermitData = await getActionWithFallback( + client, updatedClient, getNativePermit, 'getNativePermit', { + client, + viemClient: updatedClient, chainId: chain.id, tokenAddress: step.action.fromToken.address as Address, spenderAddress: chain.permit2Proxy as Address, @@ -274,6 +283,7 @@ export const checkAllowance = async ({ // Set new allowance const approveAmount = permit2Supported ? MaxUint256 : fromAmount const approveTxHash = await setAllowance( + client, updatedClient, step.action.fromToken.address as Address, spenderAddress as Address, @@ -302,6 +312,7 @@ export const checkAllowance = async ({ } await waitForApprovalTransaction( + client, updatedClient, approveTxHash, sharedProcess.type, @@ -332,7 +343,8 @@ export const checkAllowance = async ({ } const waitForApprovalTransaction = async ( - client: Client, + client: SDKClient, + viemClient: Client, txHash: Hash, processType: ProcessType, step: LiFiStep, @@ -347,8 +359,8 @@ const waitForApprovalTransaction = async ( txLink: getTxLink(txHash), }) - const transactionReceipt = await waitForTransactionReceipt({ - client, + const transactionReceipt = await waitForTransactionReceipt(client, { + client: viemClient, chainId: chain.id, txHash, onReplaced(response) { diff --git a/src/core/EVM/checkPermitSupport.ts b/src/core/EVM/checkPermitSupport.ts index 9be2bb6d..09f5316b 100644 --- a/src/core/EVM/checkPermitSupport.ts +++ b/src/core/EVM/checkPermitSupport.ts @@ -1,7 +1,6 @@ -import type { ExtendedChain } from '@lifi/types' -import { ChainType } from '@lifi/types' +import { ChainType, type ExtendedChain } from '@lifi/types' import type { Address } from 'viem' -import { config } from '../../config.js' +import type { SDKClient } from '../../types/core.js' import { getActionWithFallback } from './getActionWithFallback.js' import { getAllowance } from './getAllowance.js' import { getNativePermit } from './permits/getNativePermit.js' @@ -21,36 +20,42 @@ type PermitSupport = { * 1. Native permit (EIP-2612) support * 2. Permit2 availability and allowance * + * @param client - The SDK client * @param chain - The chain to check permit support on * @param tokenAddress - The token address to check * @param ownerAddress - The address that would sign the permit * @param amount - The amount to check allowance against for Permit2 * @returns Object indicating which permit types are supported */ -export const checkPermitSupport = async ({ - chain, - tokenAddress, - ownerAddress, - amount, -}: { - chain: ExtendedChain - tokenAddress: Address - ownerAddress: Address - amount: bigint -}): Promise => { - const provider = config.getProvider(ChainType.EVM) as EVMProvider | undefined - - let client = await provider?.getWalletClient?.() +export const checkPermitSupport = async ( + client: SDKClient, + { + chain, + tokenAddress, + ownerAddress, + amount, + }: { + chain: ExtendedChain + tokenAddress: Address + ownerAddress: Address + amount: bigint + } +): Promise => { + const provider = client.getProvider(ChainType.EVM) as EVMProvider | undefined + let viemClient = await provider?.getWalletClient?.() - if (!client) { - client = await getPublicClient(chain.id) + if (!viemClient) { + viemClient = await getPublicClient(client, chain.id) } const nativePermit = await getActionWithFallback( client, + viemClient, getNativePermit, 'getNativePermit', { + client, + viemClient, chainId: chain.id, tokenAddress, spenderAddress: chain.permit2Proxy as Address, @@ -63,6 +68,7 @@ export const checkPermitSupport = async ({ if (chain.permit2) { permit2Allowance = await getAllowance( client, + viemClient, tokenAddress, ownerAddress, chain.permit2 as Address diff --git a/src/core/EVM/getActionWithFallback.ts b/src/core/EVM/getActionWithFallback.ts index f54e4816..8a75cfc9 100644 --- a/src/core/EVM/getActionWithFallback.ts +++ b/src/core/EVM/getActionWithFallback.ts @@ -8,6 +8,7 @@ import type { WalletActions, } from 'viem' import { getAction } from 'viem/utils' +import type { SDKClient } from '../../types/core.js' import { getPublicClient } from './publicClient.js' /** @@ -17,6 +18,7 @@ import { getPublicClient } from './publicClient.js' * Note: Only falls back to public client if the initial client was a wallet client (has an account address). * If the initial client was already a public client, no fallback will occur. * + * @param client - The SDK client * @param walletClient - The wallet client to use primarily * @param action - The function or method to execute * @param actionName - The name of the action (used for error handling) @@ -33,6 +35,7 @@ export const getActionWithFallback = async < parameters, returnType, >( + client: SDKClient, walletClient: client, actionFn: (_: client, parameters: parameters) => returnType, name: keyof PublicActions | keyof WalletActions | (string & {}), @@ -52,7 +55,7 @@ export const getActionWithFallback = async < throw error } - const publicClient = await getPublicClient(chainId) + const publicClient = await getPublicClient(client, chainId) return await getAction(publicClient, actionFn, name)(params) } } diff --git a/src/core/EVM/getAllowance.int.spec.ts b/src/core/EVM/getAllowance.int.spec.ts index 776ef466..cd65e2fd 100644 --- a/src/core/EVM/getAllowance.int.spec.ts +++ b/src/core/EVM/getAllowance.int.spec.ts @@ -1,9 +1,10 @@ import { findDefaultToken } from '@lifi/data-types' import { ChainId, CoinKey } from '@lifi/types' import type { Address } from 'viem' -import { beforeAll, describe, expect, it } from 'vitest' -import { setupTestEnvironment } from '../../../tests/setup.js' -import { getTokens } from '../../services/api.js' +import { describe, expect, it } from 'vitest' +import { getTokens } from '../../actions/getTokens.js' +import { createClient } from '../../client/createClient.js' +import { EVM } from './EVM.js' import { getAllowance, getAllowanceMulticall, @@ -12,6 +13,11 @@ import { import { getPublicClient } from './publicClient.js' import type { TokenSpender } from './types.js' +const client = createClient({ + integrator: 'lifi-sdk', +}) +client.setProviders([EVM()]) + const defaultWalletAddress = '0x552008c0f6870c2f77e5cC1d2eb9bdff03e30Ea0' const defaultSpenderAddress = '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE' const memeToken = { @@ -27,13 +33,12 @@ const defaultMemeAllowance = 123000000000000000000n const retryTimes = 2 const timeout = 10000 -beforeAll(setupTestEnvironment) - describe('allowance integration tests', { retry: retryTimes, timeout }, () => { it('should work for ERC20 on POL', async () => { - const client = await getPublicClient(memeToken.chainId) + const viemClient = await getPublicClient(client, memeToken.chainId) const allowance = await getAllowance( client, + viemClient, memeToken.address as Address, defaultWalletAddress, defaultSpenderAddress @@ -47,9 +52,10 @@ describe('allowance integration tests', { retry: retryTimes, timeout }, () => { { retry: retryTimes, timeout }, async () => { const token = findDefaultToken(CoinKey.POL, ChainId.POL) - const client = await getPublicClient(token.chainId) + const viemClient = await getPublicClient(client, token.chainId) const allowance = await getAllowance( client, + viemClient, token.address as Address, defaultWalletAddress, defaultSpenderAddress @@ -65,9 +71,10 @@ describe('allowance integration tests', { retry: retryTimes, timeout }, () => { async () => { const invalidToken = findDefaultToken(CoinKey.POL, ChainId.POL) invalidToken.address = '0x2170ed0880ac9a755fd29b2688956bd959f933f8' - const client = await getPublicClient(invalidToken.chainId) + const viemClient = await getPublicClient(client, invalidToken.chainId) const allowance = await getAllowance( client, + viemClient, invalidToken.address as Address, defaultWalletAddress, defaultSpenderAddress @@ -80,9 +87,10 @@ describe('allowance integration tests', { retry: retryTimes, timeout }, () => { 'should handle empty lists with multicall', { retry: retryTimes, timeout }, async () => { - const client = await getPublicClient(137) + const viemClient = await getPublicClient(client, 137) const allowances = await getAllowanceMulticall( client, + viemClient, 137, [], defaultWalletAddress @@ -95,7 +103,7 @@ describe('allowance integration tests', { retry: retryTimes, timeout }, () => { 'should handle token lists with more than 10 tokens', { retry: retryTimes, timeout }, async () => { - const { tokens } = await getTokens({ + const { tokens } = await getTokens(client, { chains: [ChainId.POL], }) const filteredTokens = tokens[ChainId.POL] @@ -111,6 +119,7 @@ describe('allowance integration tests', { retry: retryTimes, timeout }, () => { if (tokenSpenders?.length) { const tokens = await getTokenAllowanceMulticall( + client, defaultWalletAddress, tokenSpenders.slice(0, 10) ) diff --git a/src/core/EVM/getAllowance.ts b/src/core/EVM/getAllowance.ts index b32a15e8..e6d2d5ee 100644 --- a/src/core/EVM/getAllowance.ts +++ b/src/core/EVM/getAllowance.ts @@ -1,6 +1,7 @@ import type { BaseToken, ChainId } from '@lifi/types' import type { Address, Client } from 'viem' import { multicall, readContract } from 'viem/actions' +import type { SDKClient } from '../../types/core.js' import { isZeroAddress } from '../../utils/isZeroAddress.js' import { allowanceAbi } from './abi.js' import { getActionWithFallback } from './getActionWithFallback.js' @@ -13,7 +14,8 @@ import type { import { getMulticallAddress } from './utils.js' export const getAllowance = async ( - client: Client, + client: SDKClient, + viemClient: Client, tokenAddress: Address, ownerAddress: Address, spenderAddress: Address @@ -21,6 +23,7 @@ export const getAllowance = async ( try { const approved = await getActionWithFallback( client, + viemClient, readContract, 'readContract', { @@ -37,7 +40,8 @@ export const getAllowance = async ( } export const getAllowanceMulticall = async ( - client: Client, + client: SDKClient, + viemClient: Client, chainId: ChainId, tokens: TokenSpender[], ownerAddress: Address @@ -45,11 +49,10 @@ export const getAllowanceMulticall = async ( if (!tokens.length) { return [] } - const multicallAddress = await getMulticallAddress(chainId) + const multicallAddress = await getMulticallAddress(client.config, chainId) if (!multicallAddress) { throw new Error(`No multicall address configured for chainId ${chainId}.`) } - const contracts = tokens.map((token) => ({ address: token.token.address as Address, abi: allowanceAbi, @@ -57,10 +60,16 @@ export const getAllowanceMulticall = async ( args: [ownerAddress, token.spenderAddress], })) - const results = await getActionWithFallback(client, multicall, 'multicall', { - contracts, - multicallAddress: multicallAddress as Address, - }) + const results = await getActionWithFallback( + client, + viemClient, + multicall, + 'multicall', + { + contracts, + multicallAddress: multicallAddress as Address, + } + ) if (!results.length) { throw new Error( @@ -77,12 +86,14 @@ export const getAllowanceMulticall = async ( /** * Get the current allowance for a certain token. + * @param client - The SDK client * @param token - The token that should be checked * @param ownerAddress - The owner of the token * @param spenderAddress - The spender address that has to be approved * @returns Returns allowance */ export const getTokenAllowance = async ( + client: SDKClient, token: BaseToken, ownerAddress: Address, spenderAddress: Address @@ -92,10 +103,11 @@ export const getTokenAllowance = async ( return } - const client = await getPublicClient(token.chainId) + const viemClient = await getPublicClient(client, token.chainId) const approved = await getAllowance( client, + viemClient, token.address as Address, ownerAddress, spenderAddress @@ -105,11 +117,13 @@ export const getTokenAllowance = async ( /** * Get the current allowance for a list of token/spender address pairs. + * @param client - The SDK client * @param ownerAddress - The owner of the tokens * @param tokens - A list of token and spender address pairs * @returns Returns array of tokens and their allowance */ export const getTokenAllowanceMulticall = async ( + client: SDKClient, ownerAddress: Address, tokens: TokenSpender[] ): Promise => { @@ -132,10 +146,11 @@ export const getTokenAllowanceMulticall = async ( const allowances = ( await Promise.all( chainKeys.map(async (chainId) => { - const client = await getPublicClient(chainId) + const viemClient = await getPublicClient(client, chainId) // get allowances for current chain and token list return getAllowanceMulticall( client, + viemClient, chainId, tokenDataByChain[chainId], ownerAddress diff --git a/src/core/EVM/getEVMBalance.int.spec.ts b/src/core/EVM/getEVMBalance.int.spec.ts index f1f5ab11..280edbc4 100644 --- a/src/core/EVM/getEVMBalance.int.spec.ts +++ b/src/core/EVM/getEVMBalance.int.spec.ts @@ -2,24 +2,29 @@ import { findDefaultToken } from '@lifi/data-types' import type { StaticToken, Token } from '@lifi/types' import { ChainId, CoinKey } from '@lifi/types' import type { Address } from 'viem' -import { beforeAll, describe, expect, it } from 'vitest' -import { setupTestEnvironment } from '../../../tests/setup.js' -import { getTokens } from '../../services/api.js' +import { describe, expect, it } from 'vitest' +import { getTokens } from '../../actions/getTokens.js' +import { createClient } from '../../client/createClient.js' +import { EVM } from './EVM.js' import { getEVMBalance } from './getEVMBalance.js' +const client = createClient({ + integrator: 'lifi-sdk', +}) +client.setProviders([EVM()]) + const defaultWalletAddress = '0x552008c0f6870c2f77e5cC1d2eb9bdff03e30Ea0' const retryTimes = 2 const timeout = 10000 -beforeAll(setupTestEnvironment) - describe('getBalances integration tests', () => { const loadAndCompareTokenAmounts = async ( walletAddress: string, tokens: StaticToken[] ) => { const tokenBalances = await getEVMBalance( + client, walletAddress as Address, tokens as Token[] ) @@ -81,6 +86,7 @@ describe('getBalances integration tests', () => { const tokens = [findDefaultToken(CoinKey.USDC, ChainId.POL), invalidToken] const tokenBalances = await getEVMBalance( + client, walletAddress, tokens as Token[] ) @@ -116,7 +122,7 @@ describe('getBalances integration tests', () => { { retry: retryTimes, timeout }, async () => { const walletAddress = defaultWalletAddress - const { tokens } = await getTokens({ + const { tokens } = await getTokens(client, { chains: [ChainId.OPT], }) expect(tokens[ChainId.OPT]?.length).toBeGreaterThan(100) diff --git a/src/core/EVM/getEVMBalance.ts b/src/core/EVM/getEVMBalance.ts index b6da8a42..cf9e20c5 100644 --- a/src/core/EVM/getEVMBalance.ts +++ b/src/core/EVM/getEVMBalance.ts @@ -1,20 +1,22 @@ -import type { ChainId, Token, TokenAmount } from '@lifi/types' -import type { Address } from 'viem' +import type { Token, TokenAmount } from '@lifi/types' +import type { Address, Client } from 'viem' import { getBalance, getBlockNumber, multicall, readContract, } from 'viem/actions' +import type { SDKClient } from '../../types/core.js' import { isZeroAddress } from '../../utils/isZeroAddress.js' import { balanceOfAbi, getEthBalanceAbi } from './abi.js' import { getPublicClient } from './publicClient.js' import { getMulticallAddress } from './utils.js' export const getEVMBalance = async ( + client: SDKClient, walletAddress: Address, tokens: Token[] -): Promise => { +) => { if (tokens.length === 0) { return [] } @@ -25,27 +27,26 @@ export const getEVMBalance = async ( } } - const multicallAddress = await getMulticallAddress(chainId) + const multicallAddress = await getMulticallAddress(client.config, chainId) + const viemClient = await getPublicClient(client, chainId) if (multicallAddress && tokens.length > 1) { return getEVMBalanceMulticall( - chainId, + viemClient, tokens, walletAddress, multicallAddress ) } - return getEVMBalanceDefault(chainId, tokens, walletAddress) + return getEVMBalanceDefault(viemClient, tokens, walletAddress) } const getEVMBalanceMulticall = async ( - chainId: ChainId, + client: Client, tokens: Token[], walletAddress: string, multicallAddress: string ): Promise => { - const client = await getPublicClient(chainId) - const contracts = tokens.map((token) => { if (isZeroAddress(token.address)) { return { @@ -85,12 +86,10 @@ const getEVMBalanceMulticall = async ( } const getEVMBalanceDefault = async ( - chainId: ChainId, + client: Client, tokens: Token[], walletAddress: Address ): Promise => { - const client = await getPublicClient(chainId) - const queue: Promise[] = tokens.map((token) => { if (isZeroAddress(token.address)) { return getBalance(client, { diff --git a/src/core/EVM/isBatchingSupported.ts b/src/core/EVM/isBatchingSupported.ts index 90c70ac6..642b80d6 100644 --- a/src/core/EVM/isBatchingSupported.ts +++ b/src/core/EVM/isBatchingSupported.ts @@ -2,23 +2,26 @@ import { ChainType } from '@lifi/types' import type { Client } from 'viem' import { getCapabilities } from 'viem/actions' import { getAction } from 'viem/utils' -import { config } from '../../config.js' +import type { SDKClient } from '../../types/core.js' import { sleep } from '../../utils/sleep.js' import type { EVMProvider } from './types.js' -export async function isBatchingSupported({ - client, - chainId, - skipReady = false, -}: { - client?: Client - chainId: number - skipReady?: boolean -}): Promise { +export async function isBatchingSupported( + client: SDKClient, + { + client: viemClient, + chainId, + skipReady = false, + }: { + client?: Client + chainId: number + skipReady?: boolean + } +): Promise { const _client = - client ?? + viemClient ?? (await ( - config.getProvider(ChainType.EVM) as EVMProvider + client.getProvider(ChainType.EVM) as EVMProvider )?.getWalletClient?.()) if (!_client) { diff --git a/src/core/EVM/parseEVMErrors.ts b/src/core/EVM/parseEVMErrors.ts index 7065c3b1..ffc8d5b3 100644 --- a/src/core/EVM/parseEVMErrors.ts +++ b/src/core/EVM/parseEVMErrors.ts @@ -4,8 +4,8 @@ import { BaseError } from '../../errors/baseError.js' import { ErrorMessage, LiFiErrorCode } from '../../errors/constants.js' import { TransactionError, UnknownError } from '../../errors/errors.js' import { SDKError } from '../../errors/SDKError.js' +import type { Process } from '../../types/core.js' import { fetchTxErrorDetails } from '../../utils/fetchTxErrorDetails.js' -import type { Process } from '../types.js' export const parseEVMErrors = async ( e: Error, diff --git a/src/core/EVM/parseEVMErrors.unit.spec.ts b/src/core/EVM/parseEVMErrors.unit.spec.ts index 81c4c21e..3bec4bc0 100644 --- a/src/core/EVM/parseEVMErrors.unit.spec.ts +++ b/src/core/EVM/parseEVMErrors.unit.spec.ts @@ -1,7 +1,5 @@ import type { LiFiStep } from '@lifi/types' -import { beforeAll, describe, expect, it, vi } from 'vitest' -import { buildStepObject } from '../../../tests/fixtures.js' -import { setupTestEnvironment } from '../../../tests/setup.js' +import { describe, expect, it, vi } from 'vitest' import { BaseError } from '../../errors/baseError.js' import { ErrorMessage, @@ -10,12 +8,11 @@ import { } from '../../errors/constants.js' import { TransactionError } from '../../errors/errors.js' import { SDKError } from '../../errors/SDKError.js' +import { buildStepObject } from '../../tests/fixtures.js' +import type { Process } from '../../types/core.js' import * as helpers from '../../utils/fetchTxErrorDetails.js' -import type { Process } from '../types.js' import { parseEVMErrors } from './parseEVMErrors.js' -beforeAll(setupTestEnvironment) - describe('parseEVMStepErrors', () => { describe('when a SDKError is passed', async () => { it('should return the original error', async () => { diff --git a/src/core/EVM/permits/getNativePermit.ts b/src/core/EVM/permits/getNativePermit.ts index 3b272393..886269b3 100644 --- a/src/core/EVM/permits/getNativePermit.ts +++ b/src/core/EVM/permits/getNativePermit.ts @@ -9,6 +9,7 @@ import { zeroHash, } from 'viem' import { getCode, multicall, readContract } from 'viem/actions' +import type { SDKClient } from '../../../types/core.js' import { eip2612Abi } from '../abi.js' import { getActionWithFallback } from '../getActionWithFallback.js' import { getMulticallAddress, isDelegationDesignatorCode } from '../utils.js' @@ -21,6 +22,8 @@ import { import type { NativePermitData } from './types.js' type GetNativePermitParams = { + client: SDKClient + viemClient: Client chainId: number tokenAddress: Address spenderAddress: Address @@ -128,17 +131,22 @@ function validateDomainSeparator({ * 1. Account has no code (EOA) * 2. Account is EOA and has EIP-7702 delegation designator code * - * @param client - The Viem client instance + * @param client - The SDK client + * @param viemClient - The Viem client instance * @returns Promise - Whether the account can use native permits */ -const canAccountUseNativePermits = async (client: Client): Promise => { +const canAccountUseNativePermits = async ( + client: SDKClient, + viemClient: Client +): Promise => { try { const accountCode = await getActionWithFallback( client, + viemClient, getCode, 'getCode', { - address: client.account!.address, + address: viemClient.account!.address, } ) @@ -164,18 +172,20 @@ const canAccountUseNativePermits = async (client: Client): Promise => { /** * Attempts to retrieve contract data using EIP-5267 eip712Domain() function * @link https://eips.ethereum.org/EIPS/eip-5267 - * @param client - The Viem client instance + * @param client - The SDK client + * @param viemClient - The Viem client instance * @param chainId - The chain ID * @param tokenAddress - The token contract address * @returns Contract data if EIP-5267 is supported, undefined otherwise */ const getEIP712DomainData = async ( - client: Client, + client: SDKClient, + viemClient: Client, chainId: number, tokenAddress: Address ) => { try { - const multicallAddress = await getMulticallAddress(chainId) + const multicallAddress = await getMulticallAddress(client.config, chainId) const contractCalls = [ { @@ -187,7 +197,7 @@ const getEIP712DomainData = async ( address: tokenAddress, abi: eip2612Abi, functionName: 'nonces', - args: [client.account!.address], + args: [viemClient.account!.address], }, ] as const @@ -195,6 +205,7 @@ const getEIP712DomainData = async ( try { const [eip712DomainResult, noncesResult] = await getActionWithFallback( client, + viemClient, multicall, 'multicall', { @@ -255,7 +266,13 @@ const getEIP712DomainData = async ( // Fallback to individual contract calls const [eip712DomainResult, noncesResult] = (await Promise.allSettled( contractCalls.map((call) => - getActionWithFallback(client, readContract, 'readContract', call) + getActionWithFallback( + client, + viemClient, + readContract, + 'readContract', + call + ) ) )) as [ PromiseSettledResult< @@ -311,19 +328,25 @@ const getEIP712DomainData = async ( } const getContractData = async ( - client: Client, + client: SDKClient, + viemClient: Client, chainId: number, tokenAddress: Address ) => { try { // First try EIP-5267 approach - returns domain object directly - const eip5267Data = await getEIP712DomainData(client, chainId, tokenAddress) + const eip5267Data = await getEIP712DomainData( + client, + viemClient, + chainId, + tokenAddress + ) if (eip5267Data) { return eip5267Data } // Fallback to legacy approach - validates and returns domain object - const multicallAddress = await getMulticallAddress(chainId) + const multicallAddress = await getMulticallAddress(client.config, chainId) const contractCalls = [ { @@ -345,7 +368,7 @@ const getContractData = async ( address: tokenAddress, abi: eip2612Abi, functionName: 'nonces', - args: [client.account!.address], + args: [viemClient.account!.address], }, { address: tokenAddress, @@ -362,10 +385,16 @@ const getContractData = async ( permitTypehashResult, noncesResult, versionResult, - ] = await getActionWithFallback(client, multicall, 'multicall', { - contracts: contractCalls, - multicallAddress, - }) + ] = await getActionWithFallback( + client, + viemClient, + multicall, + 'multicall', + { + contracts: contractCalls, + multicallAddress, + } + ) if ( nameResult.status !== 'success' || @@ -412,7 +441,13 @@ const getContractData = async ( versionResult, ] = (await Promise.allSettled( contractCalls.map((call) => - getActionWithFallback(client, readContract, 'readContract', call) + getActionWithFallback( + client, + viemClient, + readContract, + 'readContract', + call + ) ) )) as [ PromiseSettledResult, @@ -465,22 +500,34 @@ const getContractData = async ( /** * Retrieves native permit data (EIP-2612) for a token on a specific chain * @link https://eips.ethereum.org/EIPS/eip-2612 - * @param client - The Viem client instance + @param client - The SDK client + * @param viemClient - The Viem client instance * @param chain - The extended chain object containing chain details * @param tokenAddress - The address of the token to check for permit support * @returns {Promise} Object containing permit data including name, version, nonce and support status */ export const getNativePermit = async ( - client: Client, - { chainId, tokenAddress, spenderAddress, amount }: GetNativePermitParams + viemClient: Client, + { + client, + chainId, + tokenAddress, + spenderAddress, + amount, + }: GetNativePermitParams ): Promise => { // Check if the account can use native permits (EOA or EIP-7702 delegated account) - const canUsePermits = await canAccountUseNativePermits(client) + const canUsePermits = await canAccountUseNativePermits(client, viemClient) if (!canUsePermits) { return undefined } - const contractData = await getContractData(client, chainId, tokenAddress) + const contractData = await getContractData( + client, + viemClient, + chainId, + tokenAddress + ) if (!contractData) { return undefined } @@ -494,7 +541,7 @@ export const getNativePermit = async ( const deadline = BigInt(Math.floor(Date.now() / 1000) + 30 * 60).toString() // 30 minutes const message = { - owner: client.account!.address, + owner: viemClient.account!.address, spender: spenderAddress, value: amount.toString(), nonce: contractData.nonce.toString(), diff --git a/src/core/EVM/permits/getPermitTransferFromValues.ts b/src/core/EVM/permits/getPermitTransferFromValues.ts index 93de5d6d..5ecd7742 100644 --- a/src/core/EVM/permits/getPermitTransferFromValues.ts +++ b/src/core/EVM/permits/getPermitTransferFromValues.ts @@ -1,25 +1,28 @@ import type { ExtendedChain } from '@lifi/types' import type { Address, Client } from 'viem' import { readContract } from 'viem/actions' +import type { SDKClient } from '../../../types/core.js' import { permit2ProxyAbi } from '../abi.js' import { getActionWithFallback } from '../getActionWithFallback.js' import type { PermitTransferFrom } from './signatureTransfer.js' export const getPermitTransferFromValues = async ( - client: Client, + client: SDKClient, + viemClient: Client, chain: ExtendedChain, tokenAddress: Address, amount: bigint ): Promise => { const nonce = await getActionWithFallback( client, + viemClient, readContract, 'readContract', { address: chain.permit2Proxy as Address, abi: permit2ProxyAbi, functionName: 'nextNonce' as const, - args: [client.account!.address] as const, + args: [viemClient.account!.address] as const, } ) diff --git a/src/core/EVM/permits/signPermit2Message.ts b/src/core/EVM/permits/signPermit2Message.ts index 6918ede4..9ec981de 100644 --- a/src/core/EVM/permits/signPermit2Message.ts +++ b/src/core/EVM/permits/signPermit2Message.ts @@ -3,6 +3,7 @@ import type { Address, Client, Hex } from 'viem' import { keccak256 } from 'viem' import { signTypedData } from 'viem/actions' import { getAction } from 'viem/utils' +import type { SDKClient } from '../../../types/core.js' import { getPermitTransferFromValues } from './getPermitTransferFromValues.js' import { getPermitData } from './signatureTransfer.js' @@ -16,12 +17,21 @@ interface SignPermit2MessageParams { } export async function signPermit2Message( + client: SDKClient, params: SignPermit2MessageParams ): Promise { - const { client, chain, tokenAddress, amount, data, witness } = params + const { + client: viemClient, + chain, + tokenAddress, + amount, + data, + witness, + } = params const permitTransferFrom = await getPermitTransferFromValues( client, + viemClient, chain, tokenAddress, amount @@ -56,11 +66,11 @@ export async function signPermit2Message( : 'PermitTransferFrom' const signature = await getAction( - client, + viemClient, signTypedData, 'signTypedData' )({ - account: client.account!, + account: viemClient.account!, primaryType, domain: permitData.domain, types: permitData.types, diff --git a/src/core/EVM/publicClient.ts b/src/core/EVM/publicClient.ts index df2ae7fd..694715ad 100644 --- a/src/core/EVM/publicClient.ts +++ b/src/core/EVM/publicClient.ts @@ -2,8 +2,7 @@ import { ChainId, ChainType } from '@lifi/types' import type { Client } from 'viem' import { type Address, createClient, fallback, http, webSocket } from 'viem' import { type Chain, mainnet } from 'viem/chains' -import { config } from '../../config.js' -import { getRpcUrls } from '../rpc.js' +import type { SDKClient } from '../../types/core.js' import type { EVMProvider } from './types.js' import { UNS_PROXY_READER_ADDRESSES } from './uns/constants.js' @@ -12,15 +11,19 @@ const publicClients: Record = {} /** * Get an instance of a provider for a specific chain + * @param client - The SDK client * @param chainId - Id of the chain the provider is for * @returns The public client for the given chain */ -export const getPublicClient = async (chainId: number): Promise => { +export const getPublicClient = async ( + client: SDKClient, + chainId: number +): Promise => { if (publicClients[chainId]) { return publicClients[chainId] } - const urls = await getRpcUrls(chainId) + const urls = await client.getRpcUrlsByChainId(chainId) const fallbackTransports = urls.map((url) => url.startsWith('wss') ? webSocket(url) @@ -30,7 +33,7 @@ export const getPublicClient = async (chainId: number): Promise => { }, }) ) - const _chain = await config.getChainById(chainId) + const _chain = await client.getChainById(chainId) const chain: Chain = { ..._chain, ..._chain.metamask, @@ -58,7 +61,7 @@ export const getPublicClient = async (chainId: number): Promise => { } } - const provider = config.getProvider(ChainType.EVM) as EVMProvider | undefined + const provider = client.getProvider(ChainType.EVM) as EVMProvider | undefined publicClients[chainId] = createClient({ chain: chain, transport: fallback( diff --git a/src/core/EVM/resolveENSAddress.ts b/src/core/EVM/resolveENSAddress.ts index 0b5b1164..03f4cfea 100644 --- a/src/core/EVM/resolveENSAddress.ts +++ b/src/core/EVM/resolveENSAddress.ts @@ -1,13 +1,15 @@ import { ChainId } from '@lifi/types' import { getEnsAddress, normalize } from 'viem/ens' +import type { SDKClient } from '../../types/core.js' import { getPublicClient } from './publicClient.js' export const resolveENSAddress = async ( + client: SDKClient, name: string ): Promise => { try { - const client = await getPublicClient(ChainId.ETH) - const address = await getEnsAddress(client, { + const viemClient = await getPublicClient(client, ChainId.ETH) + const address = await getEnsAddress(viemClient, { name: normalize(name), }) return address as string | undefined diff --git a/src/core/EVM/resolveEVMAddress.ts b/src/core/EVM/resolveEVMAddress.ts index 4296dcee..a98675b9 100644 --- a/src/core/EVM/resolveEVMAddress.ts +++ b/src/core/EVM/resolveEVMAddress.ts @@ -1,15 +1,17 @@ import type { ChainId, CoinKey } from '@lifi/types' import { ChainType } from '@lifi/types' +import type { SDKClient } from '../../types/core.js' import { resolveENSAddress } from './resolveENSAddress.js' import { resolveUNSAddress } from './uns/resolveUNSAddress.js' export async function resolveEVMAddress( name: string, + client: SDKClient, chainId?: ChainId, token?: CoinKey ): Promise { return ( - (await resolveENSAddress(name)) || - (await resolveUNSAddress(name, ChainType.EVM, chainId, token)) + (await resolveENSAddress(client, name)) || + (await resolveUNSAddress(client, name, ChainType.EVM, chainId, token)) ) } diff --git a/src/core/EVM/setAllowance.int.spec.ts b/src/core/EVM/setAllowance.int.spec.ts index 92f36031..466efdc3 100644 --- a/src/core/EVM/setAllowance.int.spec.ts +++ b/src/core/EVM/setAllowance.int.spec.ts @@ -1,13 +1,19 @@ import type { Address, Client } from 'viem' -import { createClient, http } from 'viem' +import { createClient as createViemClient, http } from 'viem' import { mnemonicToAccount } from 'viem/accounts' import { waitForTransactionReceipt } from 'viem/actions' import { polygon } from 'viem/chains' -import { beforeAll, describe, expect, it } from 'vitest' -import { setupTestEnvironment } from '../../../tests/setup.js' +import { describe, expect, it } from 'vitest' +import { createClient } from '../../client/createClient.js' +import { EVM } from './EVM.js' import { revokeTokenApproval, setTokenAllowance } from './setAllowance.js' import { retryCount, retryDelay } from './utils.js' +const client = createClient({ + integrator: 'lifi-sdk', +}) +client.setProviders([EVM()]) + const defaultSpenderAddress = '0x9b11bc9FAc17c058CAB6286b0c785bE6a65492EF' const testToken = { name: 'USDT', @@ -29,29 +35,30 @@ describe.skipIf(!MNEMONIC)('Approval integration tests', () => { } const account = mnemonicToAccount(MNEMONIC as Address) - const client: Client = createClient({ + const walletClient: Client = createViemClient({ account, chain: polygon, transport: http(), }) - beforeAll(setupTestEnvironment) - it( 'should revoke allowance for ERC20 on POL', async () => { - const revokeTxHash = await revokeTokenApproval({ - walletClient: client, + const revokeTxHash = await revokeTokenApproval(client, { + walletClient, token: testToken, spenderAddress: defaultSpenderAddress, }) if (revokeTxHash) { - const transactionReceipt = await waitForTransactionReceipt(client, { - hash: revokeTxHash!, - retryCount, - retryDelay, - }) + const transactionReceipt = await waitForTransactionReceipt( + walletClient, + { + hash: revokeTxHash!, + retryCount, + retryDelay, + } + ) expect(transactionReceipt.status).toBe('success') } @@ -62,19 +69,22 @@ describe.skipIf(!MNEMONIC)('Approval integration tests', () => { it( 'should set allowance ERC20 on POL', async () => { - const approvalTxHash = await setTokenAllowance({ - walletClient: client, + const approvalTxHash = await setTokenAllowance(client, { + walletClient: walletClient, token: testToken, spenderAddress: defaultSpenderAddress, amount: defaultAllowance, }) if (approvalTxHash) { - const transactionReceipt = await waitForTransactionReceipt(client, { - hash: approvalTxHash!, - retryCount, - retryDelay, - }) + const transactionReceipt = await waitForTransactionReceipt( + walletClient, + { + hash: approvalTxHash!, + retryCount, + retryDelay, + } + ) expect(transactionReceipt.status).toBe('success') } diff --git a/src/core/EVM/setAllowance.ts b/src/core/EVM/setAllowance.ts index 7f4be5f4..04a91e7b 100644 --- a/src/core/EVM/setAllowance.ts +++ b/src/core/EVM/setAllowance.ts @@ -2,15 +2,20 @@ import type { Address, Client, Hash, SendTransactionParameters } from 'viem' import { encodeFunctionData } from 'viem' import { sendTransaction } from 'viem/actions' import { getAction } from 'viem/utils' +import type { + ExecutionOptions, + SDKClient, + TransactionParameters, +} from '../../types/core.js' import { isZeroAddress } from '../../utils/isZeroAddress.js' -import type { ExecutionOptions, TransactionParameters } from '../types.js' import { approveAbi } from './abi.js' import { getAllowance } from './getAllowance.js' import type { ApproveTokenRequest, RevokeApprovalRequest } from './types.js' import { getMaxPriorityFeePerGas } from './utils.js' export const setAllowance = async ( - client: Client, + client: SDKClient, + viemClient: Client, tokenAddress: Address, contractAddress: Address, amount: bigint, @@ -31,8 +36,8 @@ export const setAllowance = async ( to: tokenAddress, data, maxPriorityFeePerGas: - client.account?.type === 'local' - ? await getMaxPriorityFeePerGas(client) + viemClient.account?.type === 'local' + ? await getMaxPriorityFeePerGas(client, viemClient) : undefined, } @@ -50,12 +55,12 @@ export const setAllowance = async ( } return getAction( - client, + viemClient, sendTransaction, 'sendTransaction' )({ to: transactionRequest.to, - account: client.account!, + account: viemClient.account!, data: transactionRequest.data, gas: transactionRequest.gas, gasPrice: transactionRequest.gasPrice, @@ -66,6 +71,7 @@ export const setAllowance = async ( /** * Set approval for a certain token and amount. + * @param client - The SDK client * @param request - The approval request * @param request.walletClient - The Viem wallet client used to send the transaction * @param request.token - The token for which to set the allowance @@ -73,17 +79,16 @@ export const setAllowance = async ( * @param request.amount - The amount of tokens to approve * @returns Returns Hash or nothing */ -export const setTokenAllowance = async ({ - walletClient, - token, - spenderAddress, - amount, -}: ApproveTokenRequest): Promise => { +export const setTokenAllowance = async ( + client: SDKClient, + { walletClient, token, spenderAddress, amount }: ApproveTokenRequest +): Promise => { // native token don't need approval if (isZeroAddress(token.address)) { return } const approvedAmount = await getAllowance( + client, walletClient, token.address as Address, walletClient.account!.address, @@ -92,6 +97,7 @@ export const setTokenAllowance = async ({ if (amount > approvedAmount) { const approveTx = await setAllowance( + client, walletClient, token.address as Address, spenderAddress as Address, @@ -104,22 +110,23 @@ export const setTokenAllowance = async ({ /** * Revoke approval for a certain token. + * @param client - The SDK client * @param request - The revoke request * @param request.walletClient - The Viem wallet client used to send the transaction * @param request.token - The token for which to revoke the allowance * @param request.spenderAddress - The address of the spender * @returns Returns Hash or nothing */ -export const revokeTokenApproval = async ({ - walletClient, - token, - spenderAddress, -}: RevokeApprovalRequest): Promise => { +export const revokeTokenApproval = async ( + client: SDKClient, + { walletClient, token, spenderAddress }: RevokeApprovalRequest +): Promise => { // native token don't need approval if (isZeroAddress(token.address)) { return } const approvedAmount = await getAllowance( + client, walletClient, token.address as Address, walletClient.account!.address, @@ -127,6 +134,7 @@ export const revokeTokenApproval = async ({ ) if (approvedAmount > 0) { const approveTx = await setAllowance( + client, walletClient, token.address as Address, spenderAddress as Address, diff --git a/src/core/EVM/switchChain.ts b/src/core/EVM/switchChain.ts index c8aae985..3c17d226 100644 --- a/src/core/EVM/switchChain.ts +++ b/src/core/EVM/switchChain.ts @@ -3,8 +3,12 @@ import { getChainId } from 'viem/actions' import { getAction } from 'viem/utils' import { LiFiErrorCode } from '../../errors/constants.js' import { ProviderError } from '../../errors/errors.js' +import type { + ExecutionOptions, + LiFiStepExtended, + Process, +} from '../../types/core.js' import type { StatusManager } from '../StatusManager.js' -import type { ExecutionOptions, LiFiStepExtended, Process } from '../types.js' /** * This method checks whether the wallet client is configured for the correct chain. diff --git a/src/core/EVM/switchChain.unit.spec.ts b/src/core/EVM/switchChain.unit.spec.ts index 12e031f4..08472d29 100644 --- a/src/core/EVM/switchChain.unit.spec.ts +++ b/src/core/EVM/switchChain.unit.spec.ts @@ -2,11 +2,11 @@ import type { LiFiStep } from '@lifi/types' import type { Client } from 'viem' import type { Mock } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { buildStepObject } from '../../../tests/fixtures.js' import { LiFiErrorCode } from '../../errors/constants.js' import { ProviderError } from '../../errors/errors.js' +import { buildStepObject } from '../../tests/fixtures.js' +import type { ExecutionOptions, Process } from '../../types/core.js' import type { StatusManager } from '../StatusManager.js' -import type { ExecutionOptions, Process } from '../types.js' import { switchChain } from './switchChain.js' let client: Client diff --git a/src/core/EVM/typeguards.ts b/src/core/EVM/typeguards.ts index 3dc1614e..bc1f58b1 100644 --- a/src/core/EVM/typeguards.ts +++ b/src/core/EVM/typeguards.ts @@ -1,5 +1,5 @@ import type { ExtendedChain, LiFiStep } from '@lifi/types' -import type { LiFiStepExtended } from '../types.js' +import type { LiFiStepExtended } from '../../types/core.js' type RelayerStep = (LiFiStepExtended | LiFiStep) & { typedData: NonNullable<(LiFiStepExtended | LiFiStep)['typedData']> diff --git a/src/core/EVM/types.ts b/src/core/EVM/types.ts index 906976d0..2d630bdf 100644 --- a/src/core/EVM/types.ts +++ b/src/core/EVM/types.ts @@ -6,7 +6,7 @@ import type { FallbackTransportConfig, Hex, } from 'viem' -import type { SDKProvider, SwitchChainHook } from '../types.js' +import type { SDKProvider, SwitchChainHook } from '../../types/core.js' export interface EVMProviderOptions { getWalletClient?: () => Promise diff --git a/src/core/EVM/uns/resolveUNSAddress.ts b/src/core/EVM/uns/resolveUNSAddress.ts index 7b0ca177..826f698e 100644 --- a/src/core/EVM/uns/resolveUNSAddress.ts +++ b/src/core/EVM/uns/resolveUNSAddress.ts @@ -3,8 +3,8 @@ import type { Address, Client } from 'viem' import { readContract } from 'viem/actions' import { namehash } from 'viem/ens' import { getAction, trim } from 'viem/utils' +import type { SDKClient } from '../../../types/core.js' import { getPublicClient } from '../publicClient.js' - import { CHAIN_ID_UNS_CHAIN_MAP, CHAIN_TYPE_FAMILY_MAP, @@ -14,14 +14,15 @@ import { } from './constants.js' export const resolveUNSAddress = async ( + client: SDKClient, name: string, chainType: ChainType, chain?: ChainId, token?: CoinKey ): Promise => { try { - const L1Client = await getPublicClient(ChainId.ETH) - const L2Client = await getPublicClient(ChainId.POL) + const L1Client = await getPublicClient(client, ChainId.ETH) + const L2Client = await getPublicClient(client, ChainId.POL) const nameHash = namehash(name) const keys: string[] = [] diff --git a/src/core/EVM/utils.ts b/src/core/EVM/utils.ts index 02e01a59..cfeec582 100644 --- a/src/core/EVM/utils.ts +++ b/src/core/EVM/utils.ts @@ -1,7 +1,9 @@ import type { ChainId, ExtendedChain } from '@lifi/types' +import { ChainType } from '@lifi/types' import type { Address, Chain, Client, Transaction, TypedDataDomain } from 'viem' import { getBlock } from 'viem/actions' -import { config } from '../../config.js' +import { getChainsFromConfig } from '../../actions/getChains.js' +import type { SDKBaseConfig, SDKClient } from '../../types/core.js' import { median } from '../../utils/median.js' import { getActionWithFallback } from './getActionWithFallback.js' @@ -57,11 +59,18 @@ export function isExtendedChain(chain: any): chain is ExtendedChain { } export const getMaxPriorityFeePerGas = async ( - client: Client + client: SDKClient, + viemClient: Client ): Promise => { - const block = await getActionWithFallback(client, getBlock, 'getBlock', { - includeTransactions: true, - }) + const block = await getActionWithFallback( + client, + viemClient, + getBlock, + 'getBlock', + { + includeTransactions: true, + } + ) const maxPriorityFeePerGasList = (block.transactions as Transaction[]) .filter((tx) => tx.maxPriorityFeePerGas) @@ -88,10 +97,13 @@ export const getMaxPriorityFeePerGas = async ( // Multicall export const getMulticallAddress = async ( + config: SDKBaseConfig, chainId: ChainId ): Promise
=> { - const chains = await config.getChains() - return chains.find((chain) => chain.id === chainId) + const chains = await getChainsFromConfig(config, { + chainTypes: [ChainType.EVM], + }) + return chains?.find((chain) => chain.id === chainId) ?.multicallAddress as Address } diff --git a/src/core/EVM/waitForRelayedTransactionReceipt.ts b/src/core/EVM/waitForRelayedTransactionReceipt.ts index edc18c07..d33cd5d3 100644 --- a/src/core/EVM/waitForRelayedTransactionReceipt.ts +++ b/src/core/EVM/waitForRelayedTransactionReceipt.ts @@ -1,18 +1,20 @@ import type { ExtendedTransactionInfo, LiFiStep } from '@lifi/types' import type { Hash } from 'viem' +import { getRelayedTransactionStatus } from '../../actions/getRelayedTransactionStatus.js' import { LiFiErrorCode } from '../../errors/constants.js' import { TransactionError } from '../../errors/errors.js' -import { getRelayedTransactionStatus } from '../../services/api.js' +import type { SDKClient } from '../../types/core.js' import { waitForResult } from '../../utils/waitForResult.js' import type { WalletCallReceipt } from './types.js' export const waitForRelayedTransactionReceipt = async ( + client: SDKClient, taskId: Hash, step: LiFiStep ): Promise => { return waitForResult( async () => { - const result = await getRelayedTransactionStatus({ + const result = await getRelayedTransactionStatus(client, { taskId, fromChain: step.action.fromChainId, toChain: step.action.toChainId, diff --git a/src/core/EVM/waitForTransactionReceipt.ts b/src/core/EVM/waitForTransactionReceipt.ts index 59f5f0e2..c4294ca1 100644 --- a/src/core/EVM/waitForTransactionReceipt.ts +++ b/src/core/EVM/waitForTransactionReceipt.ts @@ -10,6 +10,7 @@ import type { import { waitForTransactionReceipt as waitForTransactionReceiptInternal } from 'viem/actions' import { LiFiErrorCode } from '../../errors/constants.js' import { TransactionError } from '../../errors/errors.js' +import type { SDKClient } from '../../types/core.js' import { getPublicClient } from './publicClient.js' interface WaitForTransactionReceiptProps { @@ -19,20 +20,23 @@ interface WaitForTransactionReceiptProps { onReplaced?: (response: ReplacementReturnType) => void } -export async function waitForTransactionReceipt({ - client, - chainId, - txHash, - onReplaced, -}: WaitForTransactionReceiptProps): Promise { +export async function waitForTransactionReceipt( + client: SDKClient, + { + client: viemClient, + chainId, + txHash, + onReplaced, + }: WaitForTransactionReceiptProps +): Promise { let { transactionReceipt, replacementReason } = await waitForReceipt( - client, + viemClient, txHash, onReplaced ) if (!transactionReceipt?.status) { - const publicClient = await getPublicClient(chainId) + const publicClient = await getPublicClient(client, chainId) const result = await waitForReceipt(publicClient, txHash, onReplaced) transactionReceipt = result.transactionReceipt replacementReason = result.replacementReason diff --git a/src/core/Solana/Solana.ts b/src/core/Solana/Solana.ts index 49ac9a4e..f306a576 100644 --- a/src/core/Solana/Solana.ts +++ b/src/core/Solana/Solana.ts @@ -1,5 +1,5 @@ import { ChainType } from '@lifi/types' -import type { StepExecutorOptions } from '../types.js' +import type { StepExecutorOptions } from '../../types/core.js' import { getSolanaBalance } from './getSolanaBalance.js' import { isSVMAddress } from './isSVMAddress.js' import { resolveSolanaAddress } from './resolveSolanaAddress.js' diff --git a/src/core/Solana/SolanaStepExecutor.ts b/src/core/Solana/SolanaStepExecutor.ts index 502204c2..49efb8fe 100644 --- a/src/core/Solana/SolanaStepExecutor.ts +++ b/src/core/Solana/SolanaStepExecutor.ts @@ -1,15 +1,18 @@ import type { SignerWalletAdapter } from '@solana/wallet-adapter-base' import { VersionedTransaction } from '@solana/web3.js' import { withTimeout } from 'viem' -import { config } from '../../config.js' +import { getStepTransaction } from '../../actions/getStepTransaction.js' import { LiFiErrorCode } from '../../errors/constants.js' import { TransactionError } from '../../errors/errors.js' -import { getStepTransaction } from '../../services/api.js' +import type { + LiFiStepExtended, + SDKClient, + TransactionParameters, +} from '../../types/core.js' import { base64ToUint8Array } from '../../utils/base64ToUint8Array.js' import { BaseStepExecutor } from '../BaseStepExecutor.js' import { checkBalance } from '../checkBalance.js' import { stepComparison } from '../stepComparison.js' -import type { LiFiStepExtended, TransactionParameters } from '../types.js' import { waitForDestinationChainTransaction } from '../waitForDestinationChainTransaction.js' import { callSolanaWithRetry } from './connection.js' import { parseSolanaErrors } from './parseSolanaErrors.js' @@ -34,11 +37,14 @@ export class SolanaStepExecutor extends BaseStepExecutor { } } - executeStep = async (step: LiFiStepExtended): Promise => { + executeStep = async ( + client: SDKClient, + step: LiFiStepExtended + ): Promise => { step.execution = this.statusManager.initExecutionObject(step) - const fromChain = await config.getChainById(step.action.fromChainId) - const toChain = await config.getChainById(step.action.toChainId) + const fromChain = await client.getChainById(step.action.fromChainId) + const toChain = await client.getChainById(step.action.toChainId) const isBridgeExecution = fromChain.id !== toChain.id const currentProcessType = isBridgeExecution ? 'CROSS_CHAIN' : 'SWAP' @@ -58,13 +64,17 @@ export class SolanaStepExecutor extends BaseStepExecutor { ) // Check balance - await checkBalance(this.walletAdapter.publicKey!.toString(), step) + await checkBalance( + client, + this.walletAdapter.publicKey!.toString(), + step + ) // Create new transaction if (!step.transactionRequest) { // biome-ignore lint/correctness/noUnusedVariables: destructuring const { execution, ...stepBase } = step - const updatedStep = await getStepTransaction(stepBase) + const updatedStep = await getStepTransaction(client, stepBase) const comparedStep = await stepComparison( this.statusManager, step, @@ -145,11 +155,13 @@ export class SolanaStepExecutor extends BaseStepExecutor { 'PENDING' ) - const simulationResult = await callSolanaWithRetry((connection) => - connection.simulateTransaction(signedTx, { - commitment: 'confirmed', - replaceRecentBlockhash: true, - }) + const simulationResult = await callSolanaWithRetry( + client, + (connection) => + connection.simulateTransaction(signedTx, { + commitment: 'confirmed', + replaceRecentBlockhash: true, + }) ) if (simulationResult.value.err) { @@ -159,7 +171,7 @@ export class SolanaStepExecutor extends BaseStepExecutor { ) } - const confirmedTx = await sendAndConfirmTransaction(signedTx) + const confirmedTx = await sendAndConfirmTransaction(client, signedTx) if (!confirmedTx.signatureResult) { throw new TransactionError( @@ -212,6 +224,7 @@ export class SolanaStepExecutor extends BaseStepExecutor { } await waitForDestinationChainTransaction( + client, step, process, fromChain, diff --git a/src/core/Solana/connection.ts b/src/core/Solana/connection.ts index 062e1a4f..d470a126 100644 --- a/src/core/Solana/connection.ts +++ b/src/core/Solana/connection.ts @@ -1,6 +1,6 @@ import { ChainId } from '@lifi/types' import { Connection } from '@solana/web3.js' -import { getRpcUrls } from '../rpc.js' +import type { SDKClient } from '../../types/core.js' const connections = new Map() @@ -8,8 +8,8 @@ const connections = new Map() * Initializes the Solana connections if they haven't been initialized yet. * @returns - Promise that resolves when connections are initialized. */ -const ensureConnections = async (): Promise => { - const rpcUrls = await getRpcUrls(ChainId.SOL) +const ensureConnections = async (client: SDKClient): Promise => { + const rpcUrls = await client.getRpcUrlsByChainId(ChainId.SOL) for (const rpcUrl of rpcUrls) { if (!connections.get(rpcUrl)) { const connection = new Connection(rpcUrl) @@ -22,21 +22,25 @@ const ensureConnections = async (): Promise => { * Wrapper around getting the connection (RPC provider) for Solana * @returns - Solana RPC connections */ -export const getSolanaConnections = async (): Promise => { - await ensureConnections() +export const getSolanaConnections = async ( + client: SDKClient +): Promise => { + await ensureConnections(client) return Array.from(connections.values()) } /** * Calls a function on the Connection instances with retry logic. + * @param client - The SDK client * @param fn - The function to call, which receives a Connection instance. * @returns - The result of the function call. */ export async function callSolanaWithRetry( + client: SDKClient, fn: (connection: Connection) => Promise ): Promise { // Ensure connections are initialized - await ensureConnections() + await ensureConnections(client) let lastError: any = null for (const connection of connections.values()) { try { diff --git a/src/core/Solana/getSolanaBalance.int.spec.ts b/src/core/Solana/getSolanaBalance.int.spec.ts index 68083420..8701ea86 100644 --- a/src/core/Solana/getSolanaBalance.int.spec.ts +++ b/src/core/Solana/getSolanaBalance.int.spec.ts @@ -1,23 +1,28 @@ import { findDefaultToken } from '@lifi/data-types' import type { StaticToken, Token } from '@lifi/types' import { ChainId, CoinKey } from '@lifi/types' -import { beforeAll, describe, expect, it } from 'vitest' -import { setupTestEnvironment } from '../../../tests/setup.js' +import { describe, expect, it } from 'vitest' +import { createClient } from '../../client/createClient.js' import { getSolanaBalance } from './getSolanaBalance.js' +import { Solana } from './Solana.js' + +const client = createClient({ + integrator: 'lifi-sdk', +}) +client.setProviders([Solana()]) const defaultWalletAddress = '9T655zHa6bYrTHWdy59NFqkjwoaSwfMat2yzixE1nb56' const retryTimes = 2 const timeout = 10000 -beforeAll(setupTestEnvironment) - describe.sequential('Solana token balance', async () => { const loadAndCompareTokenAmounts = async ( walletAddress: string, tokens: StaticToken[] ) => { const tokenBalances = await getSolanaBalance( + client, walletAddress, tokens as Token[] ) @@ -71,6 +76,7 @@ describe.sequential('Solana token balance', async () => { const tokens = [findDefaultToken(CoinKey.USDC, ChainId.SOL), invalidToken] const tokenBalances = await getSolanaBalance( + client, walletAddress, tokens as Token[] ) @@ -100,7 +106,7 @@ describe.sequential('Solana token balance', async () => { // console.log(quote) - // await executeRoute(convertQuoteToRoute(quote), { + // await executeRoute(client, convertQuoteToRoute(quote), { // updateRouteHook: (route) => { // console.log(route.steps?.[0].execution) // }, diff --git a/src/core/Solana/getSolanaBalance.ts b/src/core/Solana/getSolanaBalance.ts index 78c80946..760f2071 100644 --- a/src/core/Solana/getSolanaBalance.ts +++ b/src/core/Solana/getSolanaBalance.ts @@ -1,11 +1,13 @@ import type { ChainId, Token, TokenAmount } from '@lifi/types' import { PublicKey } from '@solana/web3.js' import { SolSystemProgram } from '../../constants.js' +import type { SDKClient } from '../../types/core.js' import { withDedupe } from '../../utils/withDedupe.js' import { callSolanaWithRetry } from './connection.js' import { Token2022ProgramId, TokenProgramId } from './types.js' export const getSolanaBalance = async ( + client: SDKClient, walletAddress: string, tokens: Token[] ): Promise => { @@ -19,10 +21,11 @@ export const getSolanaBalance = async ( } } - return getSolanaBalanceDefault(chainId, tokens, walletAddress) + return getSolanaBalanceDefault(client, chainId, tokens, walletAddress) } const getSolanaBalanceDefault = async ( + client: SDKClient, _chainId: ChainId, tokens: Token[], walletAddress: string @@ -34,19 +37,21 @@ const getSolanaBalanceDefault = async ( await Promise.allSettled([ withDedupe( () => - callSolanaWithRetry((connection) => connection.getSlot('confirmed')), + callSolanaWithRetry(client, (connection) => + connection.getSlot('confirmed') + ), { id: `${getSolanaBalanceDefault.name}.getSlot` } ), withDedupe( () => - callSolanaWithRetry((connection) => + callSolanaWithRetry(client, (connection) => connection.getBalance(accountPublicKey, 'confirmed') ), { id: `${getSolanaBalanceDefault.name}.getBalance` } ), withDedupe( () => - callSolanaWithRetry((connection) => + callSolanaWithRetry(client, (connection) => connection.getParsedTokenAccountsByOwner( accountPublicKey, { @@ -61,7 +66,7 @@ const getSolanaBalanceDefault = async ( ), withDedupe( () => - callSolanaWithRetry((connection) => + callSolanaWithRetry(client, (connection) => connection.getParsedTokenAccountsByOwner( accountPublicKey, { diff --git a/src/core/Solana/parseSolanaError.unit.spec.ts b/src/core/Solana/parseSolanaError.unit.spec.ts index 264fa51f..e6898ed0 100644 --- a/src/core/Solana/parseSolanaError.unit.spec.ts +++ b/src/core/Solana/parseSolanaError.unit.spec.ts @@ -1,14 +1,11 @@ -import { beforeAll, describe, expect, it } from 'vitest' -import { buildStepObject } from '../../../tests/fixtures.js' -import { setupTestEnvironment } from '../../../tests/setup.js' +import { describe, expect, it } from 'vitest' import { BaseError } from '../../errors/baseError.js' import { ErrorName, LiFiErrorCode } from '../../errors/constants.js' import { TransactionError } from '../../errors/errors.js' import { SDKError } from '../../errors/SDKError.js' +import { buildStepObject } from '../../tests/fixtures.js' import { parseSolanaErrors } from './parseSolanaErrors.js' -beforeAll(setupTestEnvironment) - describe('parseSolanaStepError', () => { describe('when a SDKError is passed', () => { it('should return the original error', async () => { diff --git a/src/core/Solana/parseSolanaErrors.ts b/src/core/Solana/parseSolanaErrors.ts index 31853b6a..ab9645de 100644 --- a/src/core/Solana/parseSolanaErrors.ts +++ b/src/core/Solana/parseSolanaErrors.ts @@ -3,7 +3,7 @@ import { BaseError } from '../../errors/baseError.js' import { ErrorMessage, LiFiErrorCode } from '../../errors/constants.js' import { TransactionError, UnknownError } from '../../errors/errors.js' import { SDKError } from '../../errors/SDKError.js' -import type { Process } from '../types.js' +import type { Process } from '../../types/core.js' export const parseSolanaErrors = async ( e: Error, diff --git a/src/core/Solana/sendAndConfirmTransaction.ts b/src/core/Solana/sendAndConfirmTransaction.ts index 37d450ac..4bb3f8cd 100644 --- a/src/core/Solana/sendAndConfirmTransaction.ts +++ b/src/core/Solana/sendAndConfirmTransaction.ts @@ -4,6 +4,7 @@ import type { VersionedTransaction, } from '@solana/web3.js' import bs58 from 'bs58' +import type { SDKClient } from '../../types/core.js' import { sleep } from '../../utils/sleep.js' import { getSolanaConnections } from './connection.js' @@ -15,13 +16,15 @@ type ConfirmedTransactionResult = { /** * Sends a Solana transaction to multiple RPC endpoints and returns the confirmation * as soon as any of them confirm the transaction. + * @param client - The SDK client. * @param signedTx - The signed transaction to send. * @returns - The confirmation result of the transaction. */ export async function sendAndConfirmTransaction( + client: SDKClient, signedTx: VersionedTransaction ): Promise { - const connections = await getSolanaConnections() + const connections = await getSolanaConnections(client) const signedTxSerialized = signedTx.serialize() // Create transaction hash (signature) diff --git a/src/core/Solana/types.ts b/src/core/Solana/types.ts index 0a784f22..6f39c9d4 100644 --- a/src/core/Solana/types.ts +++ b/src/core/Solana/types.ts @@ -1,6 +1,6 @@ import { ChainType } from '@lifi/types' import type { SignerWalletAdapter } from '@solana/wallet-adapter-base' -import type { SDKProvider, StepExecutorOptions } from '../types.js' +import type { SDKProvider, StepExecutorOptions } from '../../types/core.js' export interface SolanaProviderOptions { getWalletAdapter?: () => Promise diff --git a/src/core/StatusManager.ts b/src/core/StatusManager.ts index 0f8c400a..4c4d630e 100644 --- a/src/core/StatusManager.ts +++ b/src/core/StatusManager.ts @@ -1,6 +1,4 @@ import type { ChainId, LiFiStep } from '@lifi/types' -import { executionState } from './executionState.js' -import { getProcessMessage } from './processMessages.js' import type { Execution, ExecutionStatus, @@ -8,7 +6,9 @@ import type { Process, ProcessStatus, ProcessType, -} from './types.js' +} from '../types/core.js' +import { executionState } from './executionState.js' +import { getProcessMessage } from './processMessages.js' type FindOrCreateProcessProps = { step: LiFiStepExtended diff --git a/src/core/StatusManager.unit.spec.ts b/src/core/StatusManager.unit.spec.ts index 804ac729..4bebb6f1 100644 --- a/src/core/StatusManager.unit.spec.ts +++ b/src/core/StatusManager.unit.spec.ts @@ -1,24 +1,21 @@ import type { Route } from '@lifi/types' import type { Mock } from 'vitest' -import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { buildRouteObject, buildStepObject, SOME_DATE, -} from '../../tests/fixtures.js' -import { setupTestEnvironment } from '../../tests/setup.js' -import { executionState } from './executionState.js' -import { StatusManager } from './StatusManager.js' +} from '../tests/fixtures.js' import type { ExecutionStatus, LiFiStepExtended, ProcessStatus, -} from './types.js' +} from '../types/core.js' +import { executionState } from './executionState.js' +import { StatusManager } from './StatusManager.js' // Note: using structuredClone when passing objects to the StatusManager shall make sure that we are not facing any unknown call-by-reference-issues anymore -beforeAll(setupTestEnvironment) - describe('StatusManager', () => { let statusManager: StatusManager let updateRouteHookMock: Mock diff --git a/src/core/Sui/Sui.ts b/src/core/Sui/Sui.ts index b416010f..cf565184 100644 --- a/src/core/Sui/Sui.ts +++ b/src/core/Sui/Sui.ts @@ -1,6 +1,6 @@ import { ChainType } from '@lifi/types' import { isValidSuiAddress } from '@mysten/sui/utils' -import type { StepExecutorOptions } from '../types.js' +import type { StepExecutorOptions } from '../../types/core.js' import { getSuiBalance } from './getSuiBalance.js' import { resolveSuiAddress } from './resolveSuiAddress.js' import { SuiStepExecutor } from './SuiStepExecutor.js' diff --git a/src/core/Sui/SuiStepExecutor.ts b/src/core/Sui/SuiStepExecutor.ts index eb7b0877..b3af15f4 100644 --- a/src/core/Sui/SuiStepExecutor.ts +++ b/src/core/Sui/SuiStepExecutor.ts @@ -2,14 +2,17 @@ import { signAndExecuteTransaction, type WalletWithRequiredFeatures, } from '@mysten/wallet-standard' -import { config } from '../../config.js' +import { getStepTransaction } from '../../actions/getStepTransaction.js' import { LiFiErrorCode } from '../../errors/constants.js' import { TransactionError } from '../../errors/errors.js' -import { getStepTransaction } from '../../services/api.js' +import type { + LiFiStepExtended, + SDKClient, + TransactionParameters, +} from '../../types/core.js' import { BaseStepExecutor } from '../BaseStepExecutor.js' import { checkBalance } from '../checkBalance.js' import { stepComparison } from '../stepComparison.js' -import type { LiFiStepExtended, TransactionParameters } from '../types.js' import { waitForDestinationChainTransaction } from '../waitForDestinationChainTransaction.js' import { parseSuiErrors } from './parseSuiErrors.js' import { callSuiWithRetry } from './suiClient.js' @@ -37,11 +40,14 @@ export class SuiStepExecutor extends BaseStepExecutor { } } - executeStep = async (step: LiFiStepExtended): Promise => { + executeStep = async ( + client: SDKClient, + step: LiFiStepExtended + ): Promise => { step.execution = this.statusManager.initExecutionObject(step) - const fromChain = await config.getChainById(step.action.fromChainId) - const toChain = await config.getChainById(step.action.toChainId) + const fromChain = await client.getChainById(step.action.fromChainId) + const toChain = await client.getChainById(step.action.toChainId) const isBridgeExecution = fromChain.id !== toChain.id const currentProcessType = isBridgeExecution ? 'CROSS_CHAIN' : 'SWAP' @@ -61,13 +67,13 @@ export class SuiStepExecutor extends BaseStepExecutor { ) // Check balance - await checkBalance(step.action.fromAddress!, step) + await checkBalance(client, step.action.fromAddress!, step) // Create new transaction if (!step.transactionRequest) { // biome-ignore lint/correctness/noUnusedVariables: destructuring const { execution, ...stepBase } = step - const updatedStep = await getStepTransaction(stepBase) + const updatedStep = await getStepTransaction(client, stepBase) const comparedStep = await stepComparison( this.statusManager, step, @@ -143,7 +149,7 @@ export class SuiStepExecutor extends BaseStepExecutor { 'PENDING' ) - const result = await callSuiWithRetry((client) => + const result = await callSuiWithRetry(client, (client) => client.waitForTransaction({ digest: signedTx.digest, options: { @@ -192,6 +198,7 @@ export class SuiStepExecutor extends BaseStepExecutor { } await waitForDestinationChainTransaction( + client, step, process, fromChain, diff --git a/src/core/Sui/getSuiBalance.int.spec.ts b/src/core/Sui/getSuiBalance.int.spec.ts index e2e06a37..8f081e4f 100644 --- a/src/core/Sui/getSuiBalance.int.spec.ts +++ b/src/core/Sui/getSuiBalance.int.spec.ts @@ -1,24 +1,30 @@ import { findDefaultToken } from '@lifi/data-types' import type { StaticToken, Token } from '@lifi/types' import { ChainId, CoinKey } from '@lifi/types' -import { beforeAll, describe, expect, it } from 'vitest' -import { setupTestEnvironment } from '../../../tests/setup.js' +import { describe, expect, it } from 'vitest' +import { createClient } from '../../client/createClient.js' import { getSuiBalance } from './getSuiBalance.js' +const client = createClient({ + integrator: 'lifi-sdk', +}) + const defaultWalletAddress = '0xd2fdd62880764fa73b895a9824ecec255a4bd9d654a125e58de33088cbf5eb67' const retryTimes = 2 const timeout = 10000 -beforeAll(setupTestEnvironment) - describe.sequential('Sui token balance', async () => { const loadAndCompareTokenAmounts = async ( walletAddress: string, tokens: StaticToken[] ) => { - const tokenBalances = await getSuiBalance(walletAddress, tokens as Token[]) + const tokenBalances = await getSuiBalance( + client, + walletAddress, + tokens as Token[] + ) expect(tokenBalances.length).toEqual(tokens.length) @@ -69,6 +75,7 @@ describe.sequential('Sui token balance', async () => { const tokens = [findDefaultToken(CoinKey.SUI, ChainId.SUI), invalidToken] const tokenBalances = await getSuiBalance( + client, walletAddress, tokens as Token[] ) diff --git a/src/core/Sui/getSuiBalance.ts b/src/core/Sui/getSuiBalance.ts index 3bd4649c..f02be4d5 100644 --- a/src/core/Sui/getSuiBalance.ts +++ b/src/core/Sui/getSuiBalance.ts @@ -1,9 +1,11 @@ import type { Token, TokenAmount } from '@lifi/types' +import type { SDKClient } from '../../types/core.js' import { withDedupe } from '../../utils/withDedupe.js' import { callSuiWithRetry } from './suiClient.js' import { SuiTokenLongAddress, SuiTokenShortAddress } from './types.js' export async function getSuiBalance( + client: SDKClient, walletAddress: string, tokens: Token[] ): Promise { @@ -18,10 +20,11 @@ export async function getSuiBalance( } } - return getSuiBalanceDefault(chainId, tokens, walletAddress) + return getSuiBalanceDefault(client, chainId, tokens, walletAddress) } const getSuiBalanceDefault = async ( + client: SDKClient, _chainId: number, tokens: Token[], walletAddress: string @@ -29,7 +32,7 @@ const getSuiBalanceDefault = async ( const [coins, checkpoint] = await Promise.allSettled([ withDedupe( () => - callSuiWithRetry((client) => + callSuiWithRetry(client, (client) => client.getAllBalances({ owner: walletAddress, }) @@ -38,7 +41,7 @@ const getSuiBalanceDefault = async ( ), withDedupe( () => - callSuiWithRetry((client) => + callSuiWithRetry(client, (client) => client.getLatestCheckpointSequenceNumber() ), { id: `${getSuiBalanceDefault.name}.getLatestCheckpointSequenceNumber` } diff --git a/src/core/Sui/parseSuiErrors.ts b/src/core/Sui/parseSuiErrors.ts index 75c59fe3..b7e7574a 100644 --- a/src/core/Sui/parseSuiErrors.ts +++ b/src/core/Sui/parseSuiErrors.ts @@ -3,7 +3,7 @@ import { BaseError } from '../../errors/baseError.js' import { ErrorMessage, LiFiErrorCode } from '../../errors/constants.js' import { TransactionError, UnknownError } from '../../errors/errors.js' import { SDKError } from '../../errors/SDKError.js' -import type { Process } from '../types.js' +import type { Process } from '../../types/core.js' export const parseSuiErrors = async ( e: Error, diff --git a/src/core/Sui/suiClient.ts b/src/core/Sui/suiClient.ts index 0fa186c2..7af46177 100644 --- a/src/core/Sui/suiClient.ts +++ b/src/core/Sui/suiClient.ts @@ -1,6 +1,6 @@ import { ChainId } from '@lifi/types' import { SuiClient } from '@mysten/sui/client' -import { getRpcUrls } from '../rpc.js' +import type { SDKClient } from '../../types/core.js' const clients = new Map() @@ -8,8 +8,8 @@ const clients = new Map() * Initializes the Sui clients if they haven't been initialized yet. * @returns - Promise that resolves when clients are initialized. */ -const ensureClients = async (): Promise => { - const rpcUrls = await getRpcUrls(ChainId.SUI) +const ensureClients = async (client: SDKClient): Promise => { + const rpcUrls = await client.getRpcUrlsByChainId(ChainId.SUI) for (const rpcUrl of rpcUrls) { if (!clients.get(rpcUrl)) { const client = new SuiClient({ url: rpcUrl }) @@ -20,14 +20,16 @@ const ensureClients = async (): Promise => { /** * Calls a function on the SuiClient instances with retry logic. + * @param client - The SDK client * @param fn - The function to call, which receives a SuiClient instance. * @returns - The result of the function call. */ export async function callSuiWithRetry( + client: SDKClient, fn: (client: SuiClient) => Promise ): Promise { // Ensure clients are initialized - await ensureClients() + await ensureClients(client) let lastError: any = null for (const client of clients.values()) { try { diff --git a/src/core/Sui/types.ts b/src/core/Sui/types.ts index 47aaf3a0..d82ca6e6 100644 --- a/src/core/Sui/types.ts +++ b/src/core/Sui/types.ts @@ -1,6 +1,6 @@ import { ChainType } from '@lifi/types' import type { WalletWithRequiredFeatures } from '@mysten/wallet-standard' -import type { SDKProvider, StepExecutorOptions } from '../types.js' +import type { SDKProvider, StepExecutorOptions } from '../../types/core.js' export interface SuiProviderOptions { getWallet?: () => Promise diff --git a/src/core/UTXO/UTXO.ts b/src/core/UTXO/UTXO.ts index 3174ecde..9f213115 100644 --- a/src/core/UTXO/UTXO.ts +++ b/src/core/UTXO/UTXO.ts @@ -1,6 +1,6 @@ import { isUTXOAddress } from '@bigmi/core' import { ChainType } from '@lifi/types' -import type { StepExecutorOptions } from '../types.js' +import type { StepExecutorOptions } from '../../types/core.js' import { getUTXOBalance } from './getUTXOBalance.js' import { resolveUTXOAddress } from './resolveUTXOAddress.js' import type { UTXOProvider, UTXOProviderOptions } from './types.js' diff --git a/src/core/UTXO/UTXOStepExecutor.ts b/src/core/UTXO/UTXOStepExecutor.ts index 5788b34e..f78cb7c9 100644 --- a/src/core/UTXO/UTXOStepExecutor.ts +++ b/src/core/UTXO/UTXOStepExecutor.ts @@ -10,18 +10,18 @@ import { import * as ecc from '@bitcoinerlab/secp256k1' import { ChainId } from '@lifi/types' import { address, initEccLib, networks, Psbt } from 'bitcoinjs-lib' -import { config } from '../../config.js' +import { getStepTransaction } from '../../actions/getStepTransaction.js' import { LiFiErrorCode } from '../../errors/constants.js' import { TransactionError } from '../../errors/errors.js' -import { getStepTransaction } from '../../services/api.js' -import { BaseStepExecutor } from '../BaseStepExecutor.js' -import { checkBalance } from '../checkBalance.js' -import { stepComparison } from '../stepComparison.js' import type { LiFiStepExtended, + SDKClient, StepExecutorOptions, TransactionParameters, -} from '../types.js' +} from '../../types/core.js' +import { BaseStepExecutor } from '../BaseStepExecutor.js' +import { checkBalance } from '../checkBalance.js' +import { stepComparison } from '../stepComparison.js' import { waitForDestinationChainTransaction } from '../waitForDestinationChainTransaction.js' import { getUTXOPublicClient } from './getUTXOPublicClient.js' import { parseUTXOErrors } from './parseUTXOErrors.js' @@ -50,11 +50,14 @@ export class UTXOStepExecutor extends BaseStepExecutor { } } - executeStep = async (step: LiFiStepExtended): Promise => { + executeStep = async ( + client: SDKClient, + step: LiFiStepExtended + ): Promise => { step.execution = this.statusManager.initExecutionObject(step) - const fromChain = await config.getChainById(step.action.fromChainId) - const toChain = await config.getChainById(step.action.toChainId) + const fromChain = await client.getChainById(step.action.fromChainId) + const toChain = await client.getChainById(step.action.toChainId) const isBridgeExecution = fromChain.id !== toChain.id const currentProcessType = isBridgeExecution ? 'CROSS_CHAIN' : 'SWAP' @@ -65,7 +68,7 @@ export class UTXOStepExecutor extends BaseStepExecutor { chainId: fromChain.id, }) - const publicClient = await getUTXOPublicClient(ChainId.BTC) + const publicClient = await getUTXOPublicClient(client, ChainId.BTC) if (process.status !== 'DONE') { try { @@ -86,13 +89,13 @@ export class UTXOStepExecutor extends BaseStepExecutor { ) // Check balance - await checkBalance(this.client.account!.address, step) + await checkBalance(client, this.client.account!.address, step) // Create new transaction if (!step.transactionRequest) { // biome-ignore lint/correctness/noUnusedVariables: destructuring const { execution, ...stepBase } = step - const updatedStep = await getStepTransaction(stepBase) + const updatedStep = await getStepTransaction(client, stepBase) const comparedStep = await stepComparison( this.statusManager, step, @@ -324,6 +327,7 @@ export class UTXOStepExecutor extends BaseStepExecutor { } await waitForDestinationChainTransaction( + client, step, process, fromChain, diff --git a/src/core/UTXO/getUTXOBalance.int.spec.ts b/src/core/UTXO/getUTXOBalance.int.spec.ts index 1ebff64b..7aa088c4 100644 --- a/src/core/UTXO/getUTXOBalance.int.spec.ts +++ b/src/core/UTXO/getUTXOBalance.int.spec.ts @@ -1,23 +1,33 @@ import { findDefaultToken } from '@lifi/data-types' import type { StaticToken, Token } from '@lifi/types' import { ChainId, CoinKey } from '@lifi/types' -import { beforeAll, describe, expect, it } from 'vitest' -import { setupTestEnvironment } from '../../../tests/setup.js' +import { describe, expect, it } from 'vitest' +import { createClient } from '../../client/createClient.js' +import type { SDKClient } from '../../types/core.js' import { getUTXOBalance } from './getUTXOBalance.js' +import { UTXO } from './UTXO.js' + +const client = createClient({ + integrator: 'lifi-sdk', +}) +client.setProviders([UTXO()]) const defaultWalletAddress = 'bc1q5hx26klsnyqqc9255vuh0s96guz79x0cc54896' const retryTimes = 2 const timeout = 10000 -beforeAll(setupTestEnvironment) - describe('getBalances integration tests', () => { const loadAndCompareTokenAmounts = async ( + client: SDKClient, walletAddress: string, tokens: StaticToken[] ) => { - const tokenBalances = await getUTXOBalance(walletAddress, tokens as Token[]) + const tokenBalances = await getUTXOBalance( + client, + walletAddress, + tokens as Token[] + ) expect(tokenBalances.length).toEqual(tokens.length) @@ -48,7 +58,7 @@ describe('getBalances integration tests', () => { findDefaultToken(CoinKey.USDT, ChainId.POL), ] - await loadAndCompareTokenAmounts(walletAddress, tokens) + await loadAndCompareTokenAmounts(client, walletAddress, tokens) } ) }) diff --git a/src/core/UTXO/getUTXOBalance.ts b/src/core/UTXO/getUTXOBalance.ts index 59a63d28..515b7931 100644 --- a/src/core/UTXO/getUTXOBalance.ts +++ b/src/core/UTXO/getUTXOBalance.ts @@ -1,7 +1,9 @@ import { ChainId, type Token, type TokenAmount } from '@lifi/types' +import type { SDKClient } from '../../types/core.js' import { getUTXOPublicClient } from './getUTXOPublicClient.js' export const getUTXOBalance = async ( + client: SDKClient, walletAddress: string, tokens: Token[] ): Promise => { @@ -14,10 +16,10 @@ export const getUTXOBalance = async ( console.warn('Requested tokens have to be on the same chain.') } } - const client = await getUTXOPublicClient(ChainId.BTC) + const bigmiClient = await getUTXOPublicClient(client, ChainId.BTC) const [balance, blockCount] = await Promise.all([ - client.getBalance({ address: walletAddress }), - client.getBlockCount(), + bigmiClient.getBalance({ address: walletAddress }), + bigmiClient.getBlockCount(), ]) return tokens.map((token) => ({ diff --git a/src/core/UTXO/getUTXOPublicClient.ts b/src/core/UTXO/getUTXOPublicClient.ts index 4b114706..6b6040f7 100644 --- a/src/core/UTXO/getUTXOPublicClient.ts +++ b/src/core/UTXO/getUTXOPublicClient.ts @@ -17,9 +17,7 @@ import { type WalletActions, walletActions, } from '@bigmi/core' - -import { config } from '../../config.js' -import { getRpcUrls } from '../rpc.js' +import type { SDKClient } from '../../types/core.js' import { toBigmiChainId } from './utils.js' type PublicClient = Client< @@ -35,14 +33,16 @@ const publicClients: Record = {} /** * Get an instance of a provider for a specific chain + * @param client - The SDK client * @param chainId - Id of the chain the provider is for * @returns The public client for the given chain */ export const getUTXOPublicClient = async ( + client: SDKClient, chainId: number ): Promise => { if (!publicClients[chainId]) { - const urls = await getRpcUrls(chainId) + const urls = await client.getRpcUrlsByChainId(chainId) const fallbackTransports = urls.map((url) => http(url, { fetchOptions: { @@ -50,7 +50,7 @@ export const getUTXOPublicClient = async ( }, }) ) - const _chain = await config.getChainById(chainId) + const _chain = await client.getChainById(chainId) const chain: Chain = { ..._chain, ..._chain.metamask, @@ -61,7 +61,7 @@ export const getUTXOPublicClient = async ( public: { http: _chain.metamask.rpcUrls }, }, } - const client = createClient({ + const bigmiClient = createClient({ chain, rpcSchema: rpcSchema(), transport: fallback([ @@ -74,7 +74,7 @@ export const getUTXOPublicClient = async ( }) .extend(publicActions) .extend(walletActions) - publicClients[chainId] = client + publicClients[chainId] = bigmiClient } if (!publicClients[chainId]) { diff --git a/src/core/UTXO/parseUTXOErrors.ts b/src/core/UTXO/parseUTXOErrors.ts index 83a78790..ae7a7a41 100644 --- a/src/core/UTXO/parseUTXOErrors.ts +++ b/src/core/UTXO/parseUTXOErrors.ts @@ -3,7 +3,7 @@ import { BaseError } from '../../errors/baseError.js' import { ErrorMessage, LiFiErrorCode } from '../../errors/constants.js' import { TransactionError, UnknownError } from '../../errors/errors.js' import { SDKError } from '../../errors/SDKError.js' -import type { Process } from '../types.js' +import type { Process } from '../../types/core.js' export const parseUTXOErrors = async ( e: Error, diff --git a/src/core/UTXO/types.ts b/src/core/UTXO/types.ts index 754a2d95..0100f22d 100644 --- a/src/core/UTXO/types.ts +++ b/src/core/UTXO/types.ts @@ -1,7 +1,7 @@ import type { Client } from '@bigmi/core' import { ChainType } from '@lifi/types' -import type { SDKProvider } from '../types.js' +import type { SDKProvider } from '../../types/core.js' export interface UTXOProviderOptions { getWalletClient?: () => Promise diff --git a/src/core/checkBalance.ts b/src/core/checkBalance.ts index c8d413d6..90133a8a 100644 --- a/src/core/checkBalance.ts +++ b/src/core/checkBalance.ts @@ -1,15 +1,21 @@ import type { LiFiStep } from '@lifi/types' import { formatUnits } from 'viem' +import { getTokenBalance } from '../actions/getTokenBalance.js' import { BalanceError } from '../errors/errors.js' -import { getTokenBalance } from '../services/balance.js' +import type { SDKClient } from '../types/core.js' import { sleep } from '../utils/sleep.js' export const checkBalance = async ( + client: SDKClient, walletAddress: string, step: LiFiStep, depth = 0 ): Promise => { - const token = await getTokenBalance(walletAddress, step.action.fromToken) + const token = await getTokenBalance( + client, + walletAddress, + step.action.fromToken + ) if (token) { const currentBalance = token.amount ?? 0n const neededBalance = BigInt(step.action.fromAmount) @@ -17,7 +23,7 @@ export const checkBalance = async ( if (currentBalance < neededBalance) { if (depth <= 3) { await sleep(200) - await checkBalance(walletAddress, step, depth + 1) + await checkBalance(client, walletAddress, step, depth + 1) } else if ( (neededBalance * BigInt((1 - (step.action.slippage ?? 0)) * 1_000_000_000)) / diff --git a/src/core/execution.ts b/src/core/execution.ts index 82a2620a..d1653e7d 100644 --- a/src/core/execution.ts +++ b/src/core/execution.ts @@ -1,19 +1,25 @@ import type { Route } from '@lifi/types' -import { config } from '../config.js' import { LiFiErrorCode } from '../errors/constants.js' import { ProviderError } from '../errors/errors.js' +import type { + ExecutionOptions, + RouteExtended, + SDKClient, + SDKProvider, +} from '../types/core.js' import { executionState } from './executionState.js' import { prepareRestart } from './prepareRestart.js' -import type { ExecutionOptions, RouteExtended } from './types.js' /** * Execute a route. + * @param client - The SDK client. * @param route - The route that should be executed. Cannot be an active route. * @param executionOptions - An object containing settings and callbacks. * @returns The executed route. * @throws {LiFiError} Throws a LiFiError if the execution fails. */ export const executeRoute = async ( + client: SDKClient, route: Route, executionOptions?: ExecutionOptions ): Promise => { @@ -27,7 +33,7 @@ export const executeRoute = async ( } executionState.create({ route: clonedRoute, executionOptions }) - executionPromise = executeSteps(clonedRoute) + executionPromise = executeSteps(client, clonedRoute) executionState.update({ route: clonedRoute, promise: executionPromise, @@ -38,12 +44,14 @@ export const executeRoute = async ( /** * Resume the execution of a route that has been stopped or had an error while executing. + * @param client - The SDK client. * @param route - The route that is to be executed. Cannot be an active route. * @param executionOptions - An object containing settings and callbacks. * @returns The executed route. * @throws {LiFiError} Throws a LiFiError if the execution fails. */ export const resumeRoute = async ( + client: SDKClient, route: Route, executionOptions?: ExecutionOptions ): Promise => { @@ -68,10 +76,13 @@ export const resumeRoute = async ( prepareRestart(route) - return executeRoute(route, executionOptions) + return executeRoute(client, route, executionOptions) } -const executeSteps = async (route: RouteExtended): Promise => { +const executeSteps = async ( + client: SDKClient, + route: RouteExtended +): Promise => { // Loop over steps and execute them for (let index = 0; index < route.steps.length; index++) { const execution = executionState.get(route.id) @@ -103,9 +114,9 @@ const executeSteps = async (route: RouteExtended): Promise => { throw new Error('Action fromAddress is not specified.') } - const provider = config - .get() - .providers.find((provider) => provider.isAddress(fromAddress)) + const provider = client.providers.find((provider: SDKProvider) => + provider.isAddress(fromAddress) + ) if (!provider) { throw new ProviderError( @@ -125,7 +136,7 @@ const executeSteps = async (route: RouteExtended): Promise => { updateRouteExecution(route, execution.executionOptions) } - const executedStep = await stepExecutor.executeStep(step) + const executedStep = await stepExecutor.executeStep(client, step) // We may reach this point if user interaction isn't allowed. We want to stop execution until we resume it if (executedStep.execution?.status !== 'DONE') { diff --git a/src/core/execution.unit.handlers.ts b/src/core/execution.unit.handlers.ts index 1431cde3..123c6336 100644 --- a/src/core/execution.unit.handlers.ts +++ b/src/core/execution.unit.handlers.ts @@ -1,16 +1,18 @@ import { HttpResponse, http } from 'msw' -import { buildStepObject } from '../../tests/fixtures.js' -import { config } from '../config.js' +import { createClient } from '../client/createClient.js' +import { buildStepObject } from '../tests/fixtures.js' import { mockChainsResponse, mockStatus, mockStepTransactionWithTxRequest, } from './execution.unit.mock.js' -const _config = config.get() +const client = createClient({ + integrator: 'lifi-sdk', +}) export const lifiHandlers = [ - http.post(`${_config.apiUrl}/advanced/stepTransaction`, async () => + http.post(`${client.config.apiUrl}/advanced/stepTransaction`, async () => HttpResponse.json( mockStepTransactionWithTxRequest( buildStepObject({ @@ -19,12 +21,12 @@ export const lifiHandlers = [ ) ) ), - http.get(`${_config.apiUrl}/chains`, async () => + http.get(`${client.config.apiUrl}/chains`, async () => HttpResponse.json({ chains: mockChainsResponse, }) ), - http.get(`${_config.apiUrl}/status`, async () => + http.get(`${client.config.apiUrl}/status`, async () => HttpResponse.json(mockStatus) ), ] diff --git a/src/core/execution.unit.mock.ts b/src/core/execution.unit.mock.ts index 3fd2665b..9d93c3de 100644 --- a/src/core/execution.unit.mock.ts +++ b/src/core/execution.unit.mock.ts @@ -1,5 +1,5 @@ import type { LiFiStep } from '@lifi/types' -import { buildStepObject } from '../../tests/fixtures.js' +import { buildStepObject } from '../tests/fixtures.js' export const mockChainsResponse = [ { diff --git a/src/core/execution.unit.spec.ts b/src/core/execution.unit.spec.ts index bbf1845f..d3f3f51f 100644 --- a/src/core/execution.unit.spec.ts +++ b/src/core/execution.unit.spec.ts @@ -11,19 +11,29 @@ import { it, vi, } from 'vitest' -import { buildRouteObject, buildStepObject } from '../../tests/fixtures.js' +import { createClient } from '../client/createClient.js' import { requestSettings } from '../request.js' +import { buildRouteObject, buildStepObject } from '../tests/fixtures.js' +import { EVM } from './EVM/EVM.js' import { executeRoute } from './execution.js' import { lifiHandlers } from './execution.unit.handlers.js' +import { Solana } from './Solana/Solana.js' +import { Sui } from './Sui/Sui.js' +import { UTXO } from './UTXO/UTXO.js' -let client: Partial +const client = createClient({ + integrator: 'lifi-sdk', +}) +client.setProviders([EVM(), UTXO(), Solana(), Sui()]) + +let viemClient: Partial vi.mock('../balance', () => ({ checkBalance: vi.fn(() => Promise.resolve([])), })) vi.mock('../execution/switchChain', () => ({ - switchChain: vi.fn(() => Promise.resolve(client)), + switchChain: vi.fn(() => Promise.resolve(viemClient)), })) vi.mock('../allowance/getAllowance', () => ({ @@ -43,7 +53,7 @@ describe.skip('Should pick up gas from wallet client estimation', () => { requestSettings.retries = 0 vi.clearAllMocks() - client = { + viemClient = { sendTransaction: () => Promise.resolve('0xabc'), getChainId: () => Promise.resolve(137), getAddresses: () => @@ -61,9 +71,9 @@ describe.skip('Should pick up gas from wallet client estimation', () => { step, }) - await executeRoute(route) + await executeRoute(client, route) - expect(sendTransaction).toHaveBeenCalledWith(client, { + expect(sendTransaction).toHaveBeenCalledWith(viemClient, { gasLimit: 125000n, gasPrice: 100000n, // TODO: Check the cause for gasLimit being outside transactionRequest. Currently working as expected in widget diff --git a/src/core/executionState.ts b/src/core/executionState.ts index aae66930..fc9c7961 100644 --- a/src/core/executionState.ts +++ b/src/core/executionState.ts @@ -1,4 +1,8 @@ -import type { ExecutionOptions, RouteExtended, StepExecutor } from './types.js' +import type { + ExecutionOptions, + RouteExtended, + StepExecutor, +} from '../types/core.js' interface ExecutionData { route: RouteExtended diff --git a/src/core/prepareRestart.ts b/src/core/prepareRestart.ts index a012b902..c8fb8f20 100644 --- a/src/core/prepareRestart.ts +++ b/src/core/prepareRestart.ts @@ -1,4 +1,4 @@ -import type { RouteExtended } from './types.js' +import type { RouteExtended } from '../types/core.js' export const prepareRestart = (route: RouteExtended) => { for (let index = 0; index < route.steps.length; index++) { diff --git a/src/core/processMessages.ts b/src/core/processMessages.ts index db0cc7ee..d7fdb142 100644 --- a/src/core/processMessages.ts +++ b/src/core/processMessages.ts @@ -1,5 +1,5 @@ import type { StatusMessage, Substatus } from '@lifi/types' -import type { ProcessStatus, ProcessType } from './types.js' +import type { ProcessStatus, ProcessType } from '../types/core.js' const processMessages: Record< ProcessType, diff --git a/src/core/rpc.ts b/src/core/rpc.ts deleted file mode 100644 index 035d1b36..00000000 --- a/src/core/rpc.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { ChainId } from '@lifi/types' -import { config } from '../config.js' - -export const getRpcUrls = async (chainId: ChainId): Promise => { - const rpcUrls = (await config.getRPCUrls())[chainId] - if (!rpcUrls?.length) { - throw new Error(`RPC URL not found for chainId: ${chainId}`) - } - return rpcUrls -} diff --git a/src/core/stepComparison.ts b/src/core/stepComparison.ts index cd3135c3..3a64445a 100644 --- a/src/core/stepComparison.ts +++ b/src/core/stepComparison.ts @@ -1,8 +1,8 @@ import type { LiFiStep } from '@lifi/types' import { LiFiErrorCode } from '../errors/constants.js' import { TransactionError } from '../errors/errors.js' +import type { ExecutionOptions } from '../types/core.js' import type { StatusManager } from './StatusManager.js' -import type { ExecutionOptions } from './types.js' import { checkStepSlippageThreshold } from './utils.js' /** diff --git a/src/core/utils.ts b/src/core/utils.ts index c9b5f5a4..66a83740 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -1,4 +1,5 @@ -import type { LiFiStep } from '@lifi/types' +import type { ChainId, ExtendedChain, LiFiStep } from '@lifi/types' +import type { RPCUrls } from '../types/core.js' // Standard threshold for destination amount difference (0.5%) const standardThreshold = 0.005 @@ -28,3 +29,31 @@ export function checkStepSlippageThreshold( } return actualSlippage <= setSlippage } + +export function getRpcUrlsFromChains( + existingRpcUrls: RPCUrls, + chains: ExtendedChain[], + skipChains?: ChainId[] +) { + const rpcUrlsFromChains = chains.reduce((rpcUrls, chain) => { + if (chain.metamask?.rpcUrls?.length) { + rpcUrls[chain.id as ChainId] = chain.metamask.rpcUrls + } + return rpcUrls + }, {} as RPCUrls) + const result = { ...existingRpcUrls } + for (const rpcUrlsKey in rpcUrlsFromChains) { + const chainId = Number(rpcUrlsKey) as ChainId + const urls = rpcUrlsFromChains[chainId] + if (!urls?.length) { + continue + } + if (!result[chainId]?.length) { + result[chainId] = Array.from(urls) + } else if (!skipChains?.includes(chainId)) { + const filteredUrls = urls.filter((url) => !result[chainId]?.includes(url)) + result[chainId].push(...filteredUrls) + } + } + return result +} diff --git a/src/core/waitForDestinationChainTransaction.ts b/src/core/waitForDestinationChainTransaction.ts index 0dba81b6..28ab5734 100644 --- a/src/core/waitForDestinationChainTransaction.ts +++ b/src/core/waitForDestinationChainTransaction.ts @@ -4,12 +4,13 @@ import type { FullStatusData, } from '@lifi/types' import { LiFiErrorCode } from '../errors/constants.js' +import type { LiFiStepExtended, Process, SDKClient } from '../types/core.js' import { getTransactionFailedMessage } from '../utils/getTransactionMessage.js' import type { StatusManager } from './StatusManager.js' -import type { LiFiStepExtended, Process } from './types.js' import { waitForTransactionStatus } from './waitForTransactionStatus.js' export async function waitForDestinationChainTransaction( + client: SDKClient, step: LiFiStepExtended, process: Process, fromChain: ExtendedChain, @@ -40,6 +41,7 @@ export async function waitForDestinationChainTransaction( } const statusResponse = (await waitForTransactionStatus( + client, statusManager, transactionHash, step, @@ -84,6 +86,7 @@ export async function waitForDestinationChainTransaction( return step } catch (e: unknown) { const htmlMessage = await getTransactionFailedMessage( + client, step, `${toChain.metamask.blockExplorerUrls[0]}tx/${transactionHash}` ) diff --git a/src/core/waitForTransactionStatus.ts b/src/core/waitForTransactionStatus.ts index 8661c80e..8b8a2191 100644 --- a/src/core/waitForTransactionStatus.ts +++ b/src/core/waitForTransactionStatus.ts @@ -1,14 +1,15 @@ import type { FullStatusData, LiFiStep, StatusResponse } from '@lifi/types' +import { getStatus } from '../actions/getStatus.js' import { ServerError } from '../errors/errors.js' -import { getStatus } from '../services/api.js' +import type { ProcessType, SDKClient } from '../types/core.js' import { waitForResult } from '../utils/waitForResult.js' import { getSubstatusMessage } from './processMessages.js' import type { StatusManager } from './StatusManager.js' -import type { ProcessType } from './types.js' const TRANSACTION_HASH_OBSERVERS: Record> = {} export async function waitForTransactionStatus( + client: SDKClient, statusManager: StatusManager, txHash: string, step: LiFiStep, @@ -16,7 +17,7 @@ export async function waitForTransactionStatus( interval = 5_000 ): Promise { const _getStatus = (): Promise => { - return getStatus({ + return getStatus(client, { fromChain: step.action.fromChainId, fromAddress: step.action.fromAddress, toChain: step.action.toChainId, diff --git a/src/createConfig.ts b/src/createConfig.ts deleted file mode 100644 index 0452c1fd..00000000 --- a/src/createConfig.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { ChainType } from '@lifi/types' -import { config } from './config.js' -import { getChains } from './services/api.js' -import type { SDKConfig } from './types/internal.js' -import { checkPackageUpdates } from './utils/checkPackageUpdates.js' -import { name, version } from './version.js' - -function createBaseConfig(options: SDKConfig) { - if (!options.integrator) { - throw new Error( - 'Integrator not found. Please see documentation https://docs.li.fi/integrate-li.fi-js-sdk/set-up-the-sdk' - ) - } - const _config = config.set(options) - if (!options.disableVersionCheck && process.env.NODE_ENV === 'development') { - checkPackageUpdates(name, version) - } - return _config -} - -async function createChainsConfig() { - config.loading = getChains({ - chainTypes: [ChainType.EVM, ChainType.SVM, ChainType.UTXO, ChainType.MVM], - }) - .then((chains) => config.setChains(chains)) - .catch() - await config.loading -} - -export function createConfig(options: SDKConfig) { - const _config = createBaseConfig(options) - if (_config.preloadChains) { - createChainsConfig() - } - return _config -} diff --git a/src/errors/SDKError.ts b/src/errors/SDKError.ts index 2dc29fb8..c06bd507 100644 --- a/src/errors/SDKError.ts +++ b/src/errors/SDKError.ts @@ -1,5 +1,5 @@ import type { LiFiStep } from '@lifi/types' -import type { Process } from '../core/types.js' +import type { Process } from '../types/core.js' import { version } from '../version.js' import type { BaseError } from './baseError.js' import type { ErrorCode } from './constants.js' diff --git a/src/index.ts b/src/index.ts index 2d23255e..f316bc4f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,28 @@ // biome-ignore lint/performance/noBarrelFile: module entrypoint // biome-ignore lint/performance/noReExportAll: types export * from '@lifi/types' -export { config } from './config.js' +export { getChains } from './actions/getChains.js' +export { getConnections } from './actions/getConnections.js' +export { getContractCallsQuote } from './actions/getContractCallsQuote.js' +export { getGasRecommendation } from './actions/getGasRecommendation.js' +export { getNameServiceAddress } from './actions/getNameServiceAddress.js' +export { getQuote } from './actions/getQuote.js' +export { getRelayedTransactionStatus } from './actions/getRelayedTransactionStatus.js' +export { getRelayerQuote } from './actions/getRelayerQuote.js' +export { getRoutes } from './actions/getRoutes.js' +export { getStatus } from './actions/getStatus.js' +export { getStepTransaction } from './actions/getStepTransaction.js' +export { getToken } from './actions/getToken.js' +export { getTokenBalance } from './actions/getTokenBalance.js' +export { getTokenBalances } from './actions/getTokenBalances.js' +export { getTokenBalancesByChain } from './actions/getTokenBalancesByChain.js' +export { getTokens } from './actions/getTokens.js' +export { getTools } from './actions/getTools.js' +export { getTransactionHistory } from './actions/getTransactionHistory.js' +export { getWalletBalances } from './actions/getWalletBalances.js' +export { actions } from './actions/index.js' +export { relayTransaction } from './actions/relayTransaction.js' +export { createClient } from './client/createClient.js' export { checkPermitSupport } from './core/EVM/checkPermitSupport.js' export { EVM } from './core/EVM/EVM.js' export { @@ -53,6 +74,23 @@ export { StatusManager } from './core/StatusManager.js' export { Sui } from './core/Sui/Sui.js' export type { SuiProvider, SuiProviderOptions } from './core/Sui/types.js' export { isSui } from './core/Sui/types.js' +export type { UTXOProvider, UTXOProviderOptions } from './core/UTXO/types.js' +export { isUTXO } from './core/UTXO/types.js' +export { UTXO } from './core/UTXO/UTXO.js' +export { BaseError } from './errors/baseError.js' +export type { ErrorCode } from './errors/constants.js' +export { ErrorMessage, ErrorName, LiFiErrorCode } from './errors/constants.js' +export { + BalanceError, + ProviderError, + RPCError, + ServerError, + TransactionError, + UnknownError, + ValidationError, +} from './errors/errors.js' +export { HTTPError } from './errors/httpError.js' +export { SDKError } from './errors/SDKError.js' export type { AcceptExchangeRateUpdateHook, AcceptSlippageUpdateHook, @@ -70,6 +108,10 @@ export type { RouteExecutionDataDictionary, RouteExecutionDictionary, RouteExtended, + RPCUrls, + SDKBaseConfig, + SDKClient, + SDKConfig, SDKProvider, StepExecutor, StepExecutorOptions, @@ -79,50 +121,7 @@ export type { TransactionRequestParameters, TransactionRequestUpdateHook, UpdateRouteHook, -} from './core/types.js' -export type { UTXOProvider, UTXOProviderOptions } from './core/UTXO/types.js' -export { isUTXO } from './core/UTXO/types.js' -export { UTXO } from './core/UTXO/UTXO.js' -export { createConfig } from './createConfig.js' -export { BaseError } from './errors/baseError.js' -export type { ErrorCode } from './errors/constants.js' -export { ErrorMessage, ErrorName, LiFiErrorCode } from './errors/constants.js' -export { - BalanceError, - ProviderError, - RPCError, - ServerError, - TransactionError, - UnknownError, - ValidationError, -} from './errors/errors.js' -export { HTTPError } from './errors/httpError.js' -export { SDKError } from './errors/SDKError.js' -export { - getChains, - getConnections, - getContractCallsQuote, - getGasRecommendation, - getQuote, - getRelayedTransactionStatus, - getRelayerQuote, - getRoutes, - getStatus, - getStepTransaction, - getToken, - getTokens, - getTools, - getTransactionHistory, - relayTransaction, -} from './services/api.js' -export { - getTokenBalance, - getTokenBalances, - getTokenBalancesByChain, - getWalletBalances, -} from './services/balance.js' -export { getNameServiceAddress } from './services/getNameServiceAddress.js' -export type { RPCUrls, SDKBaseConfig, SDKConfig } from './types/internal.js' +} from './types/core.js' export { checkPackageUpdates } from './utils/checkPackageUpdates.js' export { convertQuoteToRoute } from './utils/convertQuoteToRoute.js' export { fetchTxErrorDetails } from './utils/fetchTxErrorDetails.js' diff --git a/src/request.ts b/src/request.ts index 0483d93a..b2ef9234 100644 --- a/src/request.ts +++ b/src/request.ts @@ -1,7 +1,7 @@ -import { config } from './config.js' import { ValidationError } from './errors/errors.js' import { HTTPError } from './errors/httpError.js' import { SDKError } from './errors/SDKError.js' +import type { SDKBaseConfig } from './types/core.js' import type { ExtendedRequestInit } from './types/request.js' import { sleep } from './utils/sleep.js' import { version } from './version.js' @@ -18,12 +18,13 @@ const stripExtendRequestInitProperties = ({ }) export const request = async ( + config: SDKBaseConfig, url: RequestInfo | URL, options: ExtendedRequestInit = { retries: requestSettings.retries, } ): Promise => { - const { userId, integrator, widgetVersion, apiKey } = config.get() + const { userId, integrator, widgetVersion, apiKey } = config if (!integrator) { throw new SDKError( @@ -83,7 +84,10 @@ export const request = async ( } catch (error) { if (options.retries > 0 && (error as HTTPError).status === 500) { await sleep(500) - return request(url, { ...options, retries: options.retries - 1 }) + return request(config, url, { + ...options, + retries: options.retries - 1, + }) } await (error as HTTPError).buildAdditionalDetails?.() diff --git a/src/request.unit.spec.ts b/src/request.unit.spec.ts index 67618c7b..0e30b207 100644 --- a/src/request.unit.spec.ts +++ b/src/request.unit.spec.ts @@ -10,24 +10,25 @@ import { it, vi, } from 'vitest' -import { setupTestEnvironment } from '../tests/setup.js' -import { config } from './config.js' +import { handlers } from './actions/actions.unit.handlers.js' +import { createClient } from './client/createClient.js' import { ValidationError } from './errors/errors.js' import type { HTTPError } from './errors/httpError.js' import { SDKError } from './errors/SDKError.js' import { request } from './request.js' -import { handlers } from './services/api.unit.handlers.js' -import type { SDKBaseConfig } from './types/internal.js' import type { ExtendedRequestInit } from './types/request.js' import { version } from './version.js' -const apiUrl = config.get().apiUrl +const client = createClient({ + integrator: 'lifi-sdk', +}) +const config = client.config +const apiUrl = client.config.apiUrl describe('request new', () => { const server = setupServer(...handlers) beforeAll(() => { - setupTestEnvironment() server.listen({ onUnhandledRequest: 'warn', }) @@ -50,7 +51,7 @@ describe('request new', () => { it('should be able to successfully make a fetch request', async () => { const url = `${apiUrl}/advanced/routes` - const response = await request<{ message: string }>(url, { + const response = await request<{ message: string }>(config, url, { method: 'POST', retries: 0, }) @@ -79,7 +80,7 @@ describe('request new', () => { retries: 0, } - const response = await request<{ message: string }>(url, options) + const response = await request<{ message: string }>(config, url, options) expect(response).toEqual(successResponse) }) @@ -112,20 +113,20 @@ describe('request new', () => { }, } - const response = await request<{ message: string }>(url, options) + const response = await request<{ message: string }>(config, url, options) expect(response).toEqual(successResponse) }) describe('when dealing with errors', () => { it('should throw an error if the Integrator property is missing from the config', async () => { - const originalIntegrator = config.get().integrator - config.set({ integrator: '' } as SDKBaseConfig) + const originalIntegrator = config.integrator + config.integrator = '' const url = `${apiUrl}/advanced/routes` await expect( - request<{ message: string }>(url, { + request<{ message: string }>(config, url, { method: 'POST', retries: 0, }) @@ -137,7 +138,7 @@ describe('request new', () => { ) ) - config.set({ integrator: originalIntegrator } as SDKBaseConfig) + config.integrator = originalIntegrator }) it('should throw a error with when the request fails', async () => { expect.assertions(2) @@ -152,7 +153,10 @@ describe('request new', () => { ) try { - await request<{ message: string }>(url, { method: 'POST', retries: 0 }) + await request<{ message: string }>(config, url, { + method: 'POST', + retries: 0, + }) } catch (e) { expect((e as SDKError).name).toEqual('SDKError') expect(((e as SDKError).cause as HTTPError).status).toEqual(400) @@ -171,7 +175,7 @@ describe('request new', () => { ) try { - await request<{ message: string }>(url, { + await request<{ message: string }>(config, url, { method: 'POST', retries: 0, }) diff --git a/src/services/api.ts b/src/services/api.ts deleted file mode 100644 index 4a4ae98f..00000000 --- a/src/services/api.ts +++ /dev/null @@ -1,697 +0,0 @@ -import { - type ChainId, - type ChainKey, - type ChainsRequest, - type ChainsResponse, - type ConnectionsRequest, - type ConnectionsResponse, - type ContractCallsQuoteRequest, - type ExtendedChain, - type GasRecommendationRequest, - type GasRecommendationResponse, - isContractCallsRequestWithFromAmount, - isContractCallsRequestWithToAmount, - type LiFiStep, - type RelayerQuoteResponse, - type RelayRequest, - type RelayResponse, - type RelayResponseData, - type RelayStatusRequest, - type RelayStatusResponse, - type RelayStatusResponseData, - type RequestOptions, - type RoutesRequest, - type RoutesResponse, - type SignedLiFiStep, - type StatusResponse, - type TokenExtended, - type TokensExtendedResponse, - type TokensRequest, - type TokensResponse, - type ToolsRequest, - type ToolsResponse, - type TransactionAnalyticsRequest, - type TransactionAnalyticsResponse, -} from '@lifi/types' -import { config } from '../config.js' -import { BaseError } from '../errors/baseError.js' -import { ErrorName } from '../errors/constants.js' -import { ValidationError } from '../errors/errors.js' -import { SDKError } from '../errors/SDKError.js' -import { request } from '../request.js' -import { isRoutesRequest, isStep } from '../typeguards.js' -import { withDedupe } from '../utils/withDedupe.js' -import type { - GetStatusRequestExtended, - QuoteRequest, - QuoteRequestFromAmount, - QuoteRequestToAmount, -} from './types.js' - -/** - * Get a quote for a token transfer - * @param params - The configuration of the requested quote - * @param options - Request options - * @throws {LiFiError} - Throws a LiFiError if request fails - * @returns Quote for a token transfer - */ -export async function getQuote( - params: QuoteRequestFromAmount, - options?: RequestOptions -): Promise -export async function getQuote( - params: QuoteRequestToAmount, - options?: RequestOptions -): Promise -export async function getQuote( - params: QuoteRequest, - options?: RequestOptions -): Promise { - const requiredParameters: Array = [ - 'fromChain', - 'fromToken', - 'fromAddress', - 'toChain', - 'toToken', - ] - - for (const requiredParameter of requiredParameters) { - if (!params[requiredParameter]) { - throw new SDKError( - new ValidationError( - `Required parameter "${requiredParameter}" is missing.` - ) - ) - } - } - - const isFromAmountRequest = - 'fromAmount' in params && params.fromAmount !== undefined - const isToAmountRequest = - 'toAmount' in params && params.toAmount !== undefined - - if (!isFromAmountRequest && !isToAmountRequest) { - throw new SDKError( - new ValidationError( - 'Required parameter "fromAmount" or "toAmount" is missing.' - ) - ) - } - - if (isFromAmountRequest && isToAmountRequest) { - throw new SDKError( - new ValidationError( - 'Cannot provide both "fromAmount" and "toAmount" parameters.' - ) - ) - } - const _config = config.get() - // apply defaults - params.integrator ??= _config.integrator - params.order ??= _config.routeOptions?.order - params.slippage ??= _config.routeOptions?.slippage - params.referrer ??= _config.routeOptions?.referrer - params.fee ??= _config.routeOptions?.fee - params.allowBridges ??= _config.routeOptions?.bridges?.allow - params.denyBridges ??= _config.routeOptions?.bridges?.deny - params.preferBridges ??= _config.routeOptions?.bridges?.prefer - params.allowExchanges ??= _config.routeOptions?.exchanges?.allow - params.denyExchanges ??= _config.routeOptions?.exchanges?.deny - params.preferExchanges ??= _config.routeOptions?.exchanges?.prefer - - for (const key of Object.keys(params)) { - if (!params[key as keyof QuoteRequest]) { - delete params[key as keyof QuoteRequest] - } - } - - return await request( - `${_config.apiUrl}/${isFromAmountRequest ? 'quote' : 'quote/toAmount'}?${new URLSearchParams( - params as unknown as Record - )}`, - { - signal: options?.signal, - } - ) -} - -/** - * Get a set of routes for a request that describes a transfer of tokens. - * @param params - A description of the transfer. - * @param options - Request options - * @returns The resulting routes that can be used to realize the described transfer of tokens. - * @throws {LiFiError} Throws a LiFiError if request fails. - */ -export const getRoutes = async ( - params: RoutesRequest, - options?: RequestOptions -): Promise => { - if (!isRoutesRequest(params)) { - throw new SDKError(new ValidationError('Invalid routes request.')) - } - const _config = config.get() - // apply defaults - params.options = { - integrator: _config.integrator, - ..._config.routeOptions, - ...params.options, - } - - return await request(`${_config.apiUrl}/advanced/routes`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(params), - signal: options?.signal, - }) -} - -/** - * Get a quote for a destination contract call - * @param params - The configuration of the requested destination call - * @param options - Request options - * @throws {LiFiError} - Throws a LiFiError if request fails - * @returns - Returns step. - */ -export const getContractCallsQuote = async ( - params: ContractCallsQuoteRequest, - options?: RequestOptions -): Promise => { - // validation - const requiredParameters: Array = [ - 'fromChain', - 'fromToken', - 'fromAddress', - 'toChain', - 'toToken', - 'contractCalls', - ] - for (const requiredParameter of requiredParameters) { - if (!params[requiredParameter]) { - throw new SDKError( - new ValidationError( - `Required parameter "${requiredParameter}" is missing.` - ) - ) - } - } - if ( - !isContractCallsRequestWithFromAmount(params) && - !isContractCallsRequestWithToAmount(params) - ) { - throw new SDKError( - new ValidationError( - `Required parameter "fromAmount" or "toAmount" is missing.` - ) - ) - } - const _config = config.get() - // apply defaults - // option.order is not used in this endpoint - params.integrator ??= _config.integrator - params.slippage ??= _config.routeOptions?.slippage - params.referrer ??= _config.routeOptions?.referrer - params.fee ??= _config.routeOptions?.fee - params.allowBridges ??= _config.routeOptions?.bridges?.allow - params.denyBridges ??= _config.routeOptions?.bridges?.deny - params.preferBridges ??= _config.routeOptions?.bridges?.prefer - params.allowExchanges ??= _config.routeOptions?.exchanges?.allow - params.denyExchanges ??= _config.routeOptions?.exchanges?.deny - params.preferExchanges ??= _config.routeOptions?.exchanges?.prefer - // send request - return await request(`${_config.apiUrl}/quote/contractCalls`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(params), - signal: options?.signal, - }) -} - -/** - * Get the transaction data for a single step of a route - * @param step - The step object. - * @param options - Request options - * @returns The step populated with the transaction data. - * @throws {LiFiError} Throws a LiFiError if request fails. - */ -export const getStepTransaction = async ( - step: LiFiStep | SignedLiFiStep, - options?: RequestOptions -): Promise => { - if (!isStep(step)) { - // While the validation fails for some users we should not enforce it - console.warn('SDK Validation: Invalid Step', step) - } - - return await request( - `${config.get().apiUrl}/advanced/stepTransaction`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(step), - signal: options?.signal, - } - ) -} - -/** - * Check the status of a transfer. For cross chain transfers, the "bridge" parameter is required. - * @param params - Configuration of the requested status - * @param options - Request options. - * @throws {LiFiError} - Throws a LiFiError if request fails - * @returns Returns status response. - */ -export const getStatus = async ( - params: GetStatusRequestExtended, - options?: RequestOptions -): Promise => { - if (!params.txHash) { - throw new SDKError( - new ValidationError('Required parameter "txHash" is missing.') - ) - } - const queryParams = new URLSearchParams( - params as unknown as Record - ) - return await request( - `${config.get().apiUrl}/status?${queryParams}`, - { - signal: options?.signal, - } - ) -} - -/** - * Get a relayer quote for a token transfer - * @param params - The configuration of the requested quote - * @param options - Request options - * @throws {LiFiError} - Throws a LiFiError if request fails - * @returns Relayer quote for a token transfer - */ -export const getRelayerQuote = async ( - params: QuoteRequestFromAmount, - options?: RequestOptions -): Promise => { - const requiredParameters: Array = [ - 'fromChain', - 'fromToken', - 'fromAddress', - 'fromAmount', - 'toChain', - 'toToken', - ] - for (const requiredParameter of requiredParameters) { - if (!params[requiredParameter]) { - throw new SDKError( - new ValidationError( - `Required parameter "${requiredParameter}" is missing.` - ) - ) - } - } - const _config = config.get() - // apply defaults - params.integrator ??= _config.integrator - params.order ??= _config.routeOptions?.order - params.slippage ??= _config.routeOptions?.slippage - params.referrer ??= _config.routeOptions?.referrer - params.fee ??= _config.routeOptions?.fee - params.allowBridges ??= _config.routeOptions?.bridges?.allow - params.denyBridges ??= _config.routeOptions?.bridges?.deny - params.preferBridges ??= _config.routeOptions?.bridges?.prefer - params.allowExchanges ??= _config.routeOptions?.exchanges?.allow - params.denyExchanges ??= _config.routeOptions?.exchanges?.deny - params.preferExchanges ??= _config.routeOptions?.exchanges?.prefer - - for (const key of Object.keys(params)) { - if (!params[key as keyof QuoteRequest]) { - delete params[key as keyof QuoteRequest] - } - } - - const result = await request( - `${config.get().apiUrl}/relayer/quote?${new URLSearchParams( - params as unknown as Record - )}`, - { - signal: options?.signal, - } - ) - - if (result.status === 'error') { - throw new BaseError( - ErrorName.ServerError, - result.data.code, - result.data.message - ) - } - - return result.data -} - -/** - * Relay a transaction through the relayer service - * @param params - The configuration for the relay request - * @param options - Request options - * @throws {LiFiError} - Throws a LiFiError if request fails - * @returns Task ID for the relayed transaction - */ -export const relayTransaction = async ( - params: RelayRequest, - options?: RequestOptions -): Promise => { - const requiredParameters: Array = ['typedData'] - - for (const requiredParameter of requiredParameters) { - if (!params[requiredParameter]) { - throw new SDKError( - new ValidationError( - `Required parameter "${requiredParameter}" is missing.` - ) - ) - } - } - - // Determine if the request is for a gasless relayer service or advanced relayer service - // We will use the same endpoint for both after the gasless relayer service is deprecated - const relayerPath = params.typedData.some( - (t) => t.primaryType === 'PermitWitnessTransferFrom' - ) - ? '/relayer/relay' - : '/advanced/relay' - - const result = await request( - `${config.get().apiUrl}${relayerPath}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(params, (_, value) => { - if (typeof value === 'bigint') { - return value.toString() - } - return value - }), - signal: options?.signal, - } - ) - - if (result.status === 'error') { - throw new BaseError( - ErrorName.ServerError, - result.data.code, - result.data.message - ) - } - - return result.data -} - -/** - * Get the status of a relayed transaction - * @param params - Parameters for the relay status request - * @param options - Request options - * @throws {LiFiError} - Throws a LiFiError if request fails - * @returns Status of the relayed transaction - */ -export const getRelayedTransactionStatus = async ( - params: RelayStatusRequest, - options?: RequestOptions -): Promise => { - if (!params.taskId) { - throw new SDKError( - new ValidationError('Required parameter "taskId" is missing.') - ) - } - - const { taskId, ...otherParams } = params - const queryParams = new URLSearchParams( - otherParams as unknown as Record - ) - const result = await request( - `${config.get().apiUrl}/relayer/status/${taskId}?${queryParams}`, - { - signal: options?.signal, - } - ) - - if (result.status === 'error') { - throw new BaseError( - ErrorName.ServerError, - result.data.code, - result.data.message - ) - } - - return result.data -} - -/** - * Get all available chains - * @param params - The configuration of the requested chains - * @param options - Request options - * @returns A list of all available chains - * @throws {LiFiError} Throws a LiFiError if request fails. - */ -export const getChains = async ( - params?: ChainsRequest, - options?: RequestOptions -): Promise => { - if (params) { - for (const key of Object.keys(params)) { - if (!params[key as keyof ChainsRequest]) { - delete params[key as keyof ChainsRequest] - } - } - } - const urlSearchParams = new URLSearchParams( - params as Record - ).toString() - const response = await withDedupe( - () => - request( - `${config.get().apiUrl}/chains?${urlSearchParams}`, - { - signal: options?.signal, - } - ), - { id: `${getChains.name}.${urlSearchParams}` } - ) - return response.chains -} - -/** - * Get all known tokens. - * @param params - The configuration of the requested tokens - * @param options - Request options - * @returns The tokens that are available on the requested chains - */ -export async function getTokens( - params?: TokensRequest & { extended?: false | undefined }, - options?: RequestOptions -): Promise -export async function getTokens( - params: TokensRequest & { extended: true }, - options?: RequestOptions -): Promise -export async function getTokens( - params?: TokensRequest, - options?: RequestOptions -): Promise { - if (params) { - for (const key of Object.keys(params)) { - if (!params[key as keyof TokensRequest]) { - delete params[key as keyof TokensRequest] - } - } - } - const urlSearchParams = new URLSearchParams( - params as Record - ).toString() - const isExtended = params?.extended === true - const response = await withDedupe( - () => - request< - typeof isExtended extends true ? TokensExtendedResponse : TokensResponse - >(`${config.get().apiUrl}/tokens?${urlSearchParams}`, { - signal: options?.signal, - }), - { id: `${getTokens.name}.${urlSearchParams}` } - ) - return response -} - -/** - * Fetch information about a Token - * @param chain - Id or key of the chain that contains the token - * @param token - Address or symbol of the token on the requested chain - * @param options - Request options - * @throws {LiFiError} - Throws a LiFiError if request fails - * @returns Token information - */ -export const getToken = async ( - chain: ChainKey | ChainId, - token: string, - options?: RequestOptions -): Promise => { - if (!chain) { - throw new SDKError( - new ValidationError('Required parameter "chain" is missing.') - ) - } - if (!token) { - throw new SDKError( - new ValidationError('Required parameter "token" is missing.') - ) - } - return await request( - `${config.get().apiUrl}/token?${new URLSearchParams({ - chain, - token, - } as Record)}`, - { - signal: options?.signal, - } - ) -} - -/** - * Get the available tools to bridge and swap tokens. - * @param params - The configuration of the requested tools - * @param options - Request options - * @returns The tools that are available on the requested chains - */ -export const getTools = async ( - params?: ToolsRequest, - options?: RequestOptions -): Promise => { - if (params) { - for (const key of Object.keys(params)) { - if (!params[key as keyof ToolsRequest]) { - delete params[key as keyof ToolsRequest] - } - } - } - return await request( - `${config.get().apiUrl}/tools?${new URLSearchParams( - params as Record - )}`, - { - signal: options?.signal, - } - ) -} - -/** - * Get gas recommendation for a certain chain - * @param params - Configuration of the requested gas recommendation. - * @param options - Request options - * @throws {LiFiError} Throws a LiFiError if request fails. - * @returns Gas recommendation response. - */ -export const getGasRecommendation = async ( - params: GasRecommendationRequest, - options?: RequestOptions -): Promise => { - if (!params.chainId) { - throw new SDKError( - new ValidationError('Required parameter "chainId" is missing.') - ) - } - - const url = new URL(`${config.get().apiUrl}/gas/suggestion/${params.chainId}`) - if (params.fromChain) { - url.searchParams.append('fromChain', params.fromChain as unknown as string) - } - if (params.fromToken) { - url.searchParams.append('fromToken', params.fromToken) - } - - return await request(url.toString(), { - signal: options?.signal, - }) -} - -/** - * Get all the available connections for swap/bridging tokens - * @param connectionRequest ConnectionsRequest - * @param options - Request options - * @returns ConnectionsResponse - */ -export const getConnections = async ( - connectionRequest: ConnectionsRequest, - options?: RequestOptions -): Promise => { - const url = new URL(`${config.get().apiUrl}/connections`) - - const { fromChain, fromToken, toChain, toToken } = connectionRequest - - if (fromChain) { - url.searchParams.append('fromChain', fromChain as unknown as string) - } - if (fromToken) { - url.searchParams.append('fromToken', fromToken) - } - if (toChain) { - url.searchParams.append('toChain', toChain as unknown as string) - } - if (toToken) { - url.searchParams.append('toToken', toToken) - } - const connectionRequestArrayParams: Array = [ - 'allowBridges', - 'denyBridges', - 'preferBridges', - 'allowExchanges', - 'denyExchanges', - 'preferExchanges', - ] - for (const parameter of connectionRequestArrayParams) { - const connectionRequestArrayParam = connectionRequest[parameter] as string[] - - if (connectionRequestArrayParam?.length) { - for (const value of connectionRequestArrayParam) { - url.searchParams.append(parameter, value) - } - } - } - return await request(url, options) -} - -export const getTransactionHistory = async ( - { wallet, status, fromTimestamp, toTimestamp }: TransactionAnalyticsRequest, - options?: RequestOptions -): Promise => { - if (!wallet) { - throw new SDKError( - new ValidationError('Required parameter "wallet" is missing.') - ) - } - - const _config = config.get() - - const url = new URL(`${_config.apiUrl}/analytics/transfers`) - - url.searchParams.append('integrator', _config.integrator) - url.searchParams.append('wallet', wallet) - - if (status) { - url.searchParams.append('status', status) - } - - if (fromTimestamp) { - url.searchParams.append('fromTimestamp', fromTimestamp.toString()) - } - - if (toTimestamp) { - url.searchParams.append('toTimestamp', toTimestamp.toString()) - } - - return await request(url, options) -} diff --git a/src/services/api.unit.handlers.ts b/src/services/api.unit.handlers.ts deleted file mode 100644 index 6149b608..00000000 --- a/src/services/api.unit.handlers.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { findDefaultToken } from '@lifi/data-types' -import { ChainId, CoinKey } from '@lifi/types' -import { HttpResponse, http } from 'msw' -import { config } from '../config.js' - -const _config = config.get() - -export const handlers = [ - http.post(`${_config.apiUrl}/advanced/routes`, async () => { - return HttpResponse.json({}) - }), - http.post(`${_config.apiUrl}/advanced/possibilities`, async () => - HttpResponse.json({}) - ), - http.get(`${_config.apiUrl}/token`, async () => HttpResponse.json({})), - http.get(`${_config.apiUrl}/quote`, async () => HttpResponse.json({})), - http.get(`${_config.apiUrl}/status`, async () => HttpResponse.json({})), - http.get(`${_config.apiUrl}/chains`, async () => - HttpResponse.json({ chains: [{ id: 1 }] }) - ), - http.get(`${_config.apiUrl}/tools`, async () => - HttpResponse.json({ bridges: [], exchanges: [] }) - ), - http.get(`${_config.apiUrl}/tokens`, async () => - HttpResponse.json({ - tokens: { - [ChainId.ETH]: [findDefaultToken(CoinKey.ETH, ChainId.ETH)], - }, - }) - ), - http.post(`${_config.apiUrl}/advanced/stepTransaction`, async () => - HttpResponse.json({}) - ), - http.get(`${_config.apiUrl}/gas/suggestion/${ChainId.OPT}`, async () => - HttpResponse.json({}) - ), -] diff --git a/src/services/api.unit.spec.ts b/src/services/api.unit.spec.ts deleted file mode 100644 index 109368b5..00000000 --- a/src/services/api.unit.spec.ts +++ /dev/null @@ -1,620 +0,0 @@ -import { findDefaultToken } from '@lifi/data-types' -import type { - Action, - ConnectionsRequest, - Estimate, - LiFiStep, - RoutesRequest, - StepTool, - Token, - TransactionAnalyticsRequest, -} from '@lifi/types' -import { ChainId, CoinKey } from '@lifi/types' -import { HttpResponse, http } from 'msw' -import { setupServer } from 'msw/node' -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, - vi, -} from 'vitest' -import { setupTestEnvironment } from '../../tests/setup.js' -import { config } from '../config.js' -import { ValidationError } from '../errors/errors.js' -import { SDKError } from '../errors/SDKError.js' -import * as request from '../request.js' -import { requestSettings } from '../request.js' -import * as ApiService from './api.js' -import { handlers } from './api.unit.handlers.js' - -const mockedFetch = vi.spyOn(request, 'request') - -describe('ApiService', () => { - const _config = config.get() - const server = setupServer(...handlers) - beforeAll(() => { - setupTestEnvironment() - server.listen({ - onUnhandledRequest: 'warn', - }) - requestSettings.retries = 0 - // server.use(...handlers) - }) - beforeEach(() => { - vi.clearAllMocks() - }) - afterEach(() => server.resetHandlers()) - afterAll(() => { - requestSettings.retries = 1 - server.close() - }) - - describe('getRoutes', () => { - const getRoutesRequest = ({ - fromChainId = ChainId.BSC, - fromAmount = '10000000000000', - fromTokenAddress = findDefaultToken(CoinKey.USDC, ChainId.BSC).address, - toChainId = ChainId.DAI, - toTokenAddress = findDefaultToken(CoinKey.USDC, ChainId.DAI).address, - options = { slippage: 0.03 }, - }: { - fromChainId?: ChainId - fromAmount?: string - fromTokenAddress?: string - toChainId?: ChainId - toTokenAddress?: string - options?: { slippage: number } - }): RoutesRequest => ({ - fromChainId, - fromAmount, - fromTokenAddress, - toChainId, - toTokenAddress, - options, - }) - - describe('user input is invalid', () => { - it('should throw Error because of invalid fromChainId type', async () => { - const request = getRoutesRequest({ - fromChainId: 'xxx' as unknown as ChainId, - }) - - await expect(ApiService.getRoutes(request)).rejects.toThrow( - 'Invalid routes request.' - ) - expect(mockedFetch).toHaveBeenCalledTimes(0) - }) - - it('should throw Error because of invalid fromAmount type', async () => { - const request = getRoutesRequest({ - fromAmount: 10000000000000 as unknown as string, - }) - - await expect(ApiService.getRoutes(request)).rejects.toThrow( - 'Invalid routes request.' - ) - expect(mockedFetch).toHaveBeenCalledTimes(0) - }) - - it('should throw Error because of invalid fromTokenAddress type', async () => { - const request = getRoutesRequest({ - fromTokenAddress: 1234 as unknown as string, - }) - - await expect(ApiService.getRoutes(request)).rejects.toThrow( - 'Invalid routes request.' - ) - expect(mockedFetch).toHaveBeenCalledTimes(0) - }) - - it('should throw Error because of invalid toChainId type', async () => { - const request = getRoutesRequest({ - toChainId: 'xxx' as unknown as ChainId, - }) - - await expect(ApiService.getRoutes(request)).rejects.toThrow( - 'Invalid routes request.' - ) - expect(mockedFetch).toHaveBeenCalledTimes(0) - }) - - it('should throw Error because of invalid toTokenAddress type', async () => { - const request = getRoutesRequest({ toTokenAddress: '' }) - - await expect(ApiService.getRoutes(request)).rejects.toThrow( - 'Invalid routes request.' - ) - expect(mockedFetch).toHaveBeenCalledTimes(0) - }) - - it('should throw Error because of invalid options type', async () => { - const request = getRoutesRequest({ - options: { slippage: 'not a number' as unknown as number }, - }) - - await expect(ApiService.getRoutes(request)).rejects.toThrow( - 'Invalid routes request.' - ) - expect(mockedFetch).toHaveBeenCalledTimes(0) - }) - }) - - describe('user input is valid', () => { - describe('and the backend call is successful', () => { - it('call the server once', async () => { - const request = getRoutesRequest({}) - await ApiService.getRoutes(request) - expect(mockedFetch).toHaveBeenCalledTimes(1) - }) - }) - }) - }) - - describe('getToken', () => { - describe('user input is invalid', () => { - it('throw an error', async () => { - await expect( - ApiService.getToken(undefined as unknown as ChainId, 'DAI') - ).rejects.toThrowError( - new SDKError( - new ValidationError('Required parameter "chain" is missing.') - ) - ) - expect(mockedFetch).toHaveBeenCalledTimes(0) - - await expect( - ApiService.getToken(ChainId.ETH, undefined as unknown as string) - ).rejects.toThrowError( - new SDKError( - new ValidationError('Required parameter "token" is missing.') - ) - ) - expect(mockedFetch).toHaveBeenCalledTimes(0) - }) - }) - - describe('user input is valid', () => { - describe('and the backend call is successful', () => { - it('call the server once', async () => { - await ApiService.getToken(ChainId.DAI, 'DAI') - - expect(mockedFetch).toHaveBeenCalledTimes(1) - }) - }) - }) - }) - - describe('getQuote', () => { - const fromChain = ChainId.DAI - const fromToken = 'DAI' - const fromAddress = 'Some wallet address' - const fromAmount = '1000' - const toChain = ChainId.POL - const toToken = 'MATIC' - const toAmount = '1000' - - describe('user input is invalid', () => { - it('throw an error', async () => { - await expect( - ApiService.getQuote({ - fromChain: undefined as unknown as ChainId, - fromToken, - fromAddress, - fromAmount, - toChain, - toToken, - }) - ).rejects.toThrowError( - new SDKError( - new ValidationError('Required parameter "fromChain" is missing.') - ) - ) - - await expect( - ApiService.getQuote({ - fromChain, - fromToken: undefined as unknown as string, - fromAddress, - fromAmount, - toChain, - toToken, - }) - ).rejects.toThrowError( - new SDKError( - new ValidationError('Required parameter "fromToken" is missing.') - ) - ) - - await expect( - ApiService.getQuote({ - fromChain, - fromToken, - fromAddress: undefined as unknown as string, - fromAmount, - toChain, - toToken, - }) - ).rejects.toThrowError( - new SDKError( - new ValidationError('Required parameter "fromAddress" is missing.') - ) - ) - - await expect( - ApiService.getQuote({ - fromChain, - fromToken, - fromAddress, - fromAmount: undefined as unknown as string, - toChain, - toToken, - }) - ).rejects.toThrowError( - new SDKError( - new ValidationError( - 'Required parameter "fromAmount" or "toAmount" is missing.' - ) - ) - ) - - await expect( - ApiService.getQuote({ - fromChain, - fromToken, - fromAddress, - fromAmount, - toChain, - toToken, - toAmount, - } as any) - ).rejects.toThrowError( - new SDKError( - new ValidationError( - 'Cannot provide both "fromAmount" and "toAmount" parameters.' - ) - ) - ) - - await expect( - ApiService.getQuote({ - fromChain, - fromToken, - fromAddress, - fromAmount, - toChain: undefined as unknown as ChainId, - toToken, - }) - ).rejects.toThrowError( - new SDKError( - new ValidationError('Required parameter "toChain" is missing.') - ) - ) - - await expect( - ApiService.getQuote({ - fromChain, - fromToken, - fromAddress, - fromAmount, - toChain, - toToken: undefined as unknown as string, - }) - ).rejects.toThrowError( - new SDKError( - new ValidationError('Required parameter "toToken" is missing.') - ) - ) - - expect(mockedFetch).toHaveBeenCalledTimes(0) - }) - }) - - describe('user input is valid', () => { - describe('and the backend call is successful', () => { - it('call the server once', async () => { - await ApiService.getQuote({ - fromChain, - fromToken, - fromAddress, - fromAmount, - toChain, - toToken, - }) - - expect(mockedFetch).toHaveBeenCalledTimes(1) - }) - }) - }) - }) - - describe('getStatus', () => { - const fromChain = ChainId.DAI - const toChain = ChainId.POL - const txHash = 'some tx hash' - const bridge = 'some bridge tool' - - describe('user input is invalid', () => { - it('throw an error', async () => { - await expect( - ApiService.getStatus({ - bridge, - fromChain, - toChain, - txHash: undefined as unknown as string, - }) - ).rejects.toThrowError( - new SDKError( - new ValidationError('Required parameter "txHash" is missing.') - ) - ) - - expect(mockedFetch).toHaveBeenCalledTimes(0) - }) - }) - - describe('user input is valid', () => { - describe('and the backend call is successful', () => { - it('call the server once', async () => { - await ApiService.getStatus({ bridge, fromChain, toChain, txHash }) - expect(mockedFetch).toHaveBeenCalledTimes(1) - }) - }) - }) - }) - - describe('getChains', () => { - describe('and the backend call is successful', () => { - it('call the server once', async () => { - const chains = await ApiService.getChains() - - expect(chains[0]?.id).toEqual(1) - expect(mockedFetch).toHaveBeenCalledTimes(1) - }) - }) - }) - - describe('getTools', () => { - describe('and the backend succeeds', () => { - it('returns the tools', async () => { - const tools = await ApiService.getTools({ - chains: [ChainId.ETH, ChainId.POL], - }) - - expect(tools).toBeDefined() - expect(tools.bridges).toBeDefined() - expect(tools.exchanges).toBeDefined() - }) - }) - }) - - describe('getTokens', () => { - it('return the tokens', async () => { - const result = await ApiService.getTokens({ - chains: [ChainId.ETH, ChainId.POL], - }) - expect(result).toBeDefined() - expect(result.tokens[ChainId.ETH]).toBeDefined() - }) - }) - - describe('getStepTransaction', () => { - const getAction = ({ - fromChainId = ChainId.BSC, - fromAmount = '10000000000000', - fromToken = findDefaultToken(CoinKey.USDC, ChainId.BSC), - fromAddress = 'some from address', // we don't validate the format of addresses atm - toChainId = ChainId.DAI, - toToken = findDefaultToken(CoinKey.USDC, ChainId.DAI), - toAddress = 'some to address', - slippage = 0.03, - }): Action => ({ - fromChainId, - fromAmount, - fromToken: fromToken as Token, - fromAddress, - toChainId, - toToken: toToken as Token, - toAddress, - slippage, - }) - - const getEstimate = ({ - fromAmount = '10000000000000', - toAmount = '10000000000000', - toAmountMin = '999999999999', - approvalAddress = 'some approval address', // we don't validate the format of addresses atm; - executionDuration = 300, - tool = '1inch', - }): Estimate => ({ - fromAmount, - toAmount, - toAmountMin, - approvalAddress, - executionDuration, - tool, - }) - - const getStep = ({ - id = 'some random id', - type = 'lifi', - tool = 'some swap tool', - action = getAction({}), - estimate = getEstimate({}), - }: { - id?: string - type?: 'lifi' - tool?: StepTool - action?: Action - estimate?: Estimate - }): LiFiStep => ({ - id, - type, - tool, - toolDetails: { - key: tool, - name: tool, - logoURI: '', - }, - action, - estimate, - includedSteps: [], - }) - - describe('with a swap step', () => { - // While the validation fails for some users we should not enforce it - describe.skip('user input is invalid', () => { - it('should throw Error because of invalid id', async () => { - const step = getStep({ id: null as unknown as string }) - - await expect(ApiService.getStepTransaction(step)).rejects.toThrow( - 'Invalid step.' - ) - expect(mockedFetch).toHaveBeenCalledTimes(0) - }) - - it('should throw Error because of invalid type', async () => { - const step = getStep({ type: 42 as unknown as 'lifi' }) - - await expect(ApiService.getStepTransaction(step)).rejects.toThrow( - 'Invalid Step' - ) - expect(mockedFetch).toHaveBeenCalledTimes(0) - }) - - it('should throw Error because of invalid tool', async () => { - const step = getStep({ tool: null as unknown as StepTool }) - - await expect(ApiService.getStepTransaction(step)).rejects.toThrow( - 'Invalid step.' - ) - expect(mockedFetch).toHaveBeenCalledTimes(0) - }) - - // more indepth checks for the action type should be done once we have real schema validation - it('should throw Error because of invalid action', async () => { - const step = getStep({ action: 'xxx' as unknown as Action }) - - await expect(ApiService.getStepTransaction(step)).rejects.toThrow( - 'Invalid step.' - ) - expect(mockedFetch).toHaveBeenCalledTimes(0) - }) - - // more indepth checks for the estimate type should be done once we have real schema validation - it('should throw Error because of invalid estimate', async () => { - const step = getStep({ - estimate: 'Is this really an estimate?' as unknown as Estimate, - }) - - await expect(ApiService.getStepTransaction(step)).rejects.toThrow( - 'Invalid step.' - ) - expect(mockedFetch).toHaveBeenCalledTimes(0) - }) - }) - - describe('user input is valid', () => { - describe('and the backend call is successful', () => { - it('call the server once', async () => { - const step = getStep({}) - - await ApiService.getStepTransaction(step) - expect(mockedFetch).toHaveBeenCalledTimes(1) - }) - }) - }) - }) - }) - - describe('getGasRecommendation', () => { - describe('user input is invalid', () => { - it('throw an error', async () => { - await expect( - ApiService.getGasRecommendation({ - chainId: undefined as unknown as number, - }) - ).rejects.toThrowError( - new SDKError( - new ValidationError('Required parameter "chainId" is missing.') - ) - ) - expect(mockedFetch).toHaveBeenCalledTimes(0) - }) - }) - - describe('user input is valid', () => { - describe('and the backend call is successful', () => { - it('call the server once', async () => { - await ApiService.getGasRecommendation({ - chainId: ChainId.OPT, - }) - - expect(mockedFetch).toHaveBeenCalledTimes(1) - }) - }) - }) - }) - describe('getAvailableConnections', () => { - it('returns empty array in response', async () => { - server.use( - http.get(`${_config.apiUrl}/connections`, async () => - HttpResponse.json({ connections: [] }) - ) - ) - - const connectionRequest: ConnectionsRequest = { - fromChain: ChainId.BSC, - toChain: ChainId.OPT, - fromToken: findDefaultToken(CoinKey.USDC, ChainId.BSC).address, - toToken: findDefaultToken(CoinKey.USDC, ChainId.OPT).address, - allowBridges: ['connext', 'uniswap', 'polygon'], - allowExchanges: ['1inch', 'ParaSwap', 'SushiSwap'], - denyBridges: ['Hop', 'Multichain'], - preferBridges: ['Hyphen', 'Across'], - denyExchanges: ['UbeSwap', 'BeamSwap'], - preferExchanges: ['Evmoswap', 'Diffusion'], - } - - const generatedURL = - 'https://li.quest/v1/connections?fromChain=56&fromToken=0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d&toChain=10&toToken=0x0b2c639c533813f4aa9d7837caf62653d097ff85&allowBridges=connext&allowBridges=uniswap&allowBridges=polygon&denyBridges=Hop&denyBridges=Multichain&preferBridges=Hyphen&preferBridges=Across&allowExchanges=1inch&allowExchanges=ParaSwap&allowExchanges=SushiSwap&denyExchanges=UbeSwap&denyExchanges=BeamSwap&preferExchanges=Evmoswap&preferExchanges=Diffusion' - - await expect( - ApiService.getConnections(connectionRequest) - ).resolves.toEqual({ - connections: [], - }) - - expect((mockedFetch.mock.calls[0][0] as URL).href).toEqual(generatedURL) - expect(mockedFetch).toHaveBeenCalledOnce() - }) - }) - describe('getTransactionHistory', () => { - it('returns empty array in response', async () => { - server.use( - http.get(`${_config.apiUrl}/analytics/transfers`, async () => - HttpResponse.json({}) - ) - ) - - const walletAnalyticsRequest: TransactionAnalyticsRequest = { - fromTimestamp: 1696326609361, - toTimestamp: 1696326609362, - wallet: '0x5520abcd', - } - - const generatedURL = - 'https://li.quest/v1/analytics/transfers?integrator=lifi-sdk&wallet=0x5520abcd&fromTimestamp=1696326609361&toTimestamp=1696326609362' - - await expect( - ApiService.getTransactionHistory(walletAnalyticsRequest) - ).resolves.toEqual({}) - - expect((mockedFetch.mock.calls[0][0] as URL).href).toEqual(generatedURL) - expect(mockedFetch).toHaveBeenCalledOnce() - }) - }) -}) diff --git a/src/services/balance.ts b/src/services/balance.ts deleted file mode 100644 index 103fc774..00000000 --- a/src/services/balance.ts +++ /dev/null @@ -1,149 +0,0 @@ -import type { - GetWalletBalanceExtendedResponse, - RequestOptions, - Token, - TokenAmount, - TokenAmountExtended, - TokenExtended, - WalletTokenExtended, -} from '@lifi/types' -import { config } from '../config.js' -import { ValidationError } from '../errors/errors.js' -import { request } from '../request.js' -import { isToken } from '../typeguards.js' - -/** - * Returns the balances of a specific token a wallet holds across all aggregated chains. - * @param walletAddress - A wallet address. - * @param token - A Token object. - * @returns An object containing the token and the amounts on different chains. - * @throws {BaseError} Throws a ValidationError if parameters are invalid. - */ -export const getTokenBalance = async ( - walletAddress: string, - token: Token -): Promise => { - const tokenAmounts = await getTokenBalances(walletAddress, [token]) - return tokenAmounts.length ? tokenAmounts[0] : null -} - -/** - * Returns the balances for a list tokens a wallet holds across all aggregated chains. - * @param walletAddress - A wallet address. - * @param tokens - A list of Token (or TokenExtended) objects. - * @returns A list of objects containing the tokens and the amounts on different chains. - * @throws {BaseError} Throws a ValidationError if parameters are invalid. - */ -export async function getTokenBalances( - walletAddress: string, - tokens: Token[] -): Promise -export async function getTokenBalances( - walletAddress: string, - tokens: TokenExtended[] -): Promise { - // split by chain - const tokensByChain = tokens.reduce( - (tokens, token) => { - if (!tokens[token.chainId]) { - tokens[token.chainId] = [] - } - tokens[token.chainId].push(token) - return tokens - }, - {} as { [chainId: number]: Token[] | TokenExtended[] } - ) - - const tokenAmountsByChain = await getTokenBalancesByChain( - walletAddress, - tokensByChain - ) - return Object.values(tokenAmountsByChain).flat() -} - -/** - * This method queries the balances of tokens for a specific list of chains for a given wallet. - * @param walletAddress - A wallet address. - * @param tokensByChain - A list of token objects organized by chain ids. - * @returns A list of objects containing the tokens and the amounts on different chains organized by the chosen chains. - * @throws {BaseError} Throws a ValidationError if parameters are invalid. - */ -export async function getTokenBalancesByChain( - walletAddress: string, - tokensByChain: { [chainId: number]: Token[] } -): Promise<{ [chainId: number]: TokenAmount[] }> -export async function getTokenBalancesByChain( - walletAddress: string, - tokensByChain: { [chainId: number]: TokenExtended[] } -): Promise<{ [chainId: number]: TokenAmountExtended[] }> { - if (!walletAddress) { - throw new ValidationError('Missing walletAddress.') - } - - const tokenList = Object.values(tokensByChain).flat() - const invalidTokens = tokenList.filter((token) => !isToken(token)) - if (invalidTokens.length) { - throw new ValidationError('Invalid tokens passed.') - } - - const provider = config - .get() - .providers.find((provider) => provider.isAddress(walletAddress)) - if (!provider) { - throw new Error(`SDK Token Provider for ${walletAddress} is not found.`) - } - - const tokenAmountsByChain: { - [chainId: number]: TokenAmount[] | TokenAmountExtended[] - } = {} - const tokenAmountsSettled = await Promise.allSettled( - Object.keys(tokensByChain).map(async (chainIdStr) => { - const chainId = Number.parseInt(chainIdStr, 10) - const chain = await config.getChainById(chainId) - if (provider.type === chain.chainType) { - const tokenAmounts = await provider.getBalance( - walletAddress, - tokensByChain[chainId] - ) - tokenAmountsByChain[chainId] = tokenAmounts - } else { - // if the provider is not the same as the chain type, - // return the tokens as is - tokenAmountsByChain[chainId] = tokensByChain[chainId] - } - }) - ) - if (config.get().debug) { - for (const result of tokenAmountsSettled) { - if (result.status === 'rejected') { - console.warn("Couldn't fetch token balance.", result.reason) - } - } - } - return tokenAmountsByChain -} - -/** - * Returns the balances of tokens a wallet holds across EVM chains. - * @param walletAddress - A wallet address. - * @param options - Optional request options. - * @returns An object containing the tokens and the amounts organized by chain ids. - * @throws {BaseError} Throws a ValidationError if parameters are invalid. - */ -export const getWalletBalances = async ( - walletAddress: string, - options?: RequestOptions -): Promise> => { - if (!walletAddress) { - throw new ValidationError('Missing walletAddress.') - } - - const response = await request( - `${config.get().apiUrl}/wallets/${walletAddress}/balances?extended=true`, - { - signal: options?.signal, - } - ) - - return (response?.balances || {}) as Record -} diff --git a/src/services/balance.unit.spec.ts b/src/services/balance.unit.spec.ts deleted file mode 100644 index ca915f81..00000000 --- a/src/services/balance.unit.spec.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { findDefaultToken } from '@lifi/data-types' -import type { Token, WalletTokenExtended } from '@lifi/types' -import { ChainId, CoinKey } from '@lifi/types' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import * as balance from './balance.js' - -const mockedGetTokenBalance = vi.spyOn(balance, 'getTokenBalance') -const mockedGetTokenBalances = vi.spyOn(balance, 'getTokenBalances') -const mockedGetTokenBalancesForChains = vi.spyOn( - balance, - 'getTokenBalancesByChain' -) -const mockedGetWalletBalances = vi.spyOn(balance, 'getWalletBalances') - -describe('Balance service tests', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - const SOME_TOKEN = { - ...findDefaultToken(CoinKey.USDC, ChainId.DAI), - priceUSD: '', - } - const SOME_WALLET_ADDRESS = 'some wallet address' - - describe('getTokenBalance', () => { - describe('user input is invalid', () => { - it('should throw Error because of missing walletAddress', async () => { - await expect(balance.getTokenBalance('', SOME_TOKEN)).rejects.toThrow( - 'Missing walletAddress.' - ) - }) - - it('should throw Error because of invalid token', async () => { - await expect( - balance.getTokenBalance(SOME_WALLET_ADDRESS, { - address: 'some wrong stuff', - chainId: 'not a chain Id', - } as unknown as Token) - ).rejects.toThrow('Invalid tokens passed.') - }) - }) - - describe('user input is valid', () => { - it('should call the balance service', async () => { - const balanceResponse = { - ...SOME_TOKEN, - amount: 123n, - blockNumber: 1n, - } - - mockedGetTokenBalance.mockReturnValue(Promise.resolve(balanceResponse)) - - const result = await balance.getTokenBalance( - SOME_WALLET_ADDRESS, - SOME_TOKEN - ) - - expect(mockedGetTokenBalance).toHaveBeenCalledTimes(1) - expect(result).toEqual(balanceResponse) - }) - }) - }) - - describe('getTokenBalances', () => { - describe('user input is invalid', () => { - it('should throw Error because of missing walletAddress', async () => { - await expect( - balance.getTokenBalances('', [SOME_TOKEN]) - ).rejects.toThrow('Missing walletAddress.') - }) - - it('should throw Error because of an invalid token', async () => { - await expect( - balance.getTokenBalances(SOME_WALLET_ADDRESS, [ - SOME_TOKEN, - { not: 'a token' } as unknown as Token, - ]) - ).rejects.toThrow('Invalid tokens passed.') - }) - - it('should return empty token list as it is', async () => { - mockedGetTokenBalances.mockReturnValue(Promise.resolve([])) - const result = await balance.getTokenBalances(SOME_WALLET_ADDRESS, []) - expect(result).toEqual([]) - expect(mockedGetTokenBalances).toHaveBeenCalledTimes(1) - }) - }) - - describe('user input is valid', () => { - it('should call the balance service', async () => { - const balanceResponse = [ - { - ...SOME_TOKEN, - amount: 123n, - blockNumber: 1n, - }, - ] - - mockedGetTokenBalances.mockReturnValue(Promise.resolve(balanceResponse)) - - const result = await balance.getTokenBalances(SOME_WALLET_ADDRESS, [ - SOME_TOKEN, - ]) - - expect(mockedGetTokenBalances).toHaveBeenCalledTimes(1) - expect(result).toEqual(balanceResponse) - }) - }) - }) - - describe('getTokenBalancesForChains', () => { - describe('user input is invalid', () => { - it('should throw Error because of missing walletAddress', async () => { - await expect( - balance.getTokenBalancesByChain('', { [ChainId.DAI]: [SOME_TOKEN] }) - ).rejects.toThrow('Missing walletAddress.') - }) - - it('should throw Error because of an invalid token', async () => { - await expect( - balance.getTokenBalancesByChain(SOME_WALLET_ADDRESS, { - [ChainId.DAI]: [{ not: 'a token' } as unknown as Token], - }) - ).rejects.toThrow('Invalid tokens passed.') - }) - - it('should return empty token list as it is', async () => { - mockedGetTokenBalancesForChains.mockReturnValue(Promise.resolve([])) - - const result = await balance.getTokenBalancesByChain( - SOME_WALLET_ADDRESS, - { - [ChainId.DAI]: [], - } - ) - - expect(result).toEqual([]) - expect(mockedGetTokenBalancesForChains).toHaveBeenCalledTimes(1) - }) - }) - - describe('user input is valid', () => { - it('should call the balance service', async () => { - const balanceResponse = { - [ChainId.DAI]: [ - { - ...SOME_TOKEN, - amount: 123n, - blockNumber: 1n, - }, - ], - } - - mockedGetTokenBalancesForChains.mockReturnValue( - Promise.resolve(balanceResponse) - ) - - const result = await balance.getTokenBalancesByChain( - SOME_WALLET_ADDRESS, - { - [ChainId.DAI]: [SOME_TOKEN], - } - ) - - expect(mockedGetTokenBalancesForChains).toHaveBeenCalledTimes(1) - expect(result).toEqual(balanceResponse) - }) - }) - - describe('provider is not the same as the chain type', () => { - it('should return the tokens as is', async () => { - const balanceResponse = { - [ChainId.DAI]: [SOME_TOKEN], - } - - mockedGetTokenBalancesForChains.mockReturnValue( - Promise.resolve(balanceResponse) - ) - - const result = await balance.getTokenBalancesByChain( - SOME_WALLET_ADDRESS, - { - [ChainId.DAI]: [SOME_TOKEN], - } - ) - - expect(mockedGetTokenBalancesForChains).toHaveBeenCalledTimes(1) - expect(result).toEqual(balanceResponse) - }) - }) - }) - - describe('getWalletBalances', () => { - describe('user input is invalid', () => { - it('should throw Error because of missing walletAddress', async () => { - await expect(balance.getWalletBalances('')).rejects.toThrow( - 'Missing walletAddress.' - ) - }) - }) - - describe('user input is valid', () => { - it('should call the balance service without options', async () => { - const balanceResponse: Record = { - [ChainId.DAI]: [ - { - ...SOME_TOKEN, - amount: '123', - marketCapUSD: 1000000, - volumeUSD24H: 50000, - fdvUSD: 2000000, - }, - ], - } - - mockedGetWalletBalances.mockReturnValue( - Promise.resolve(balanceResponse) - ) - - const result = await balance.getWalletBalances(SOME_WALLET_ADDRESS) - - expect(mockedGetWalletBalances).toHaveBeenCalledTimes(1) - expect(mockedGetWalletBalances).toHaveBeenCalledWith( - SOME_WALLET_ADDRESS - ) - expect(result).toEqual(balanceResponse) - }) - - it('should call the balance service with options', async () => { - const balanceResponse: Record = { - [ChainId.DAI]: [ - { - ...SOME_TOKEN, - amount: '123', - marketCapUSD: 1000000, - volumeUSD24H: 50000, - fdvUSD: 2000000, - }, - ], - } - - const options = { signal: new AbortController().signal } - - mockedGetWalletBalances.mockReturnValue( - Promise.resolve(balanceResponse) - ) - - const result = await balance.getWalletBalances( - SOME_WALLET_ADDRESS, - options - ) - - expect(mockedGetWalletBalances).toHaveBeenCalledTimes(1) - expect(mockedGetWalletBalances).toHaveBeenCalledWith( - SOME_WALLET_ADDRESS, - options - ) - expect(result).toEqual(balanceResponse) - }) - }) - }) -}) diff --git a/tests/fixtures.ts b/src/tests/fixtures.ts similarity index 98% rename from tests/fixtures.ts rename to src/tests/fixtures.ts index 4718bea9..1fa8938d 100644 --- a/tests/fixtures.ts +++ b/src/tests/fixtures.ts @@ -3,7 +3,7 @@ import { findDefaultToken } from '@lifi/data-types' import type { LiFiStep, Route, Token } from '@lifi/types' import { ChainId, CoinKey } from '@lifi/types' -import type { LiFiStepExtended } from '../src/index.js' +import type { LiFiStepExtended } from '../index.js' const SOME_TOKEN: Token = { ...findDefaultToken(CoinKey.USDC, ChainId.DAI), diff --git a/src/services/types.ts b/src/types/actions.ts similarity index 100% rename from src/services/types.ts rename to src/types/actions.ts diff --git a/src/core/types.ts b/src/types/core.ts similarity index 78% rename from src/core/types.ts rename to src/types/core.ts index 8b60e35a..b20c768b 100644 --- a/src/core/types.ts +++ b/src/types/core.ts @@ -2,10 +2,12 @@ import type { ChainId, ChainType, CoinKey, + ExtendedChain, FeeCost, GasCost, LiFiStep, Route, + RouteOptions, Step, Substatus, Token, @@ -13,16 +15,51 @@ import type { } from '@lifi/types' import type { Client } from 'viem' +export interface SDKBaseConfig { + apiKey?: string + apiUrl: string + integrator: string + userId?: string + routeOptions?: RouteOptions + executionOptions?: ExecutionOptions + rpcUrls: RPCUrls + disableVersionCheck?: boolean + widgetVersion?: string + debug: boolean +} + +export interface SDKConfig extends Partial> { + integrator: string +} + +export type RPCUrls = Partial> + export interface SDKProvider { readonly type: ChainType isAddress(address: string): boolean resolveAddress( name: string, + client: SDKClient, chainId?: ChainId, token?: CoinKey ): Promise getStepExecutor(options: StepExecutorOptions): Promise - getBalance(walletAddress: string, tokens: Token[]): Promise + getBalance( + client: SDKClient, + walletAddress: string, + tokens: Token[] + ): Promise +} + +export interface SDKClient { + config: SDKBaseConfig + providers: SDKProvider[] + getProvider(type: ChainType): SDKProvider | undefined + setProviders(providers: SDKProvider[]): void + getChains(): Promise + getChainById(chainId: ChainId): Promise + getRpcUrls(): Promise + getRpcUrlsByChainId(chainId: ChainId): Promise } export interface StepExecutorOptions { @@ -40,9 +77,22 @@ export interface StepExecutor { allowUserInteraction: boolean allowExecution: boolean setInteraction(settings?: InteractionSettings): void - executeStep(step: LiFiStepExtended): Promise + executeStep( + client: SDKClient, + step: LiFiStepExtended + ): Promise +} + +export interface RouteExecutionData { + route: Route + executors: StepExecutor[] + executionOptions?: ExecutionOptions } +export type RouteExecutionDataDictionary = Partial< + Record +> + export interface RouteExtended extends Omit { steps: LiFiStepExtended[] } @@ -68,16 +118,6 @@ export type TransactionParameters = { maxPriorityFeePerGas?: bigint } -export interface RouteExecutionData { - route: Route - executors: StepExecutor[] - executionOptions?: ExecutionOptions -} - -export type RouteExecutionDataDictionary = Partial< - Record -> - export type RouteExecutionDictionary = Partial>> export type UpdateRouteHook = (updatedRoute: RouteExtended) => void diff --git a/src/types/internal.ts b/src/types/internal.ts deleted file mode 100644 index fb5527ef..00000000 --- a/src/types/internal.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { ChainId, ExtendedChain, RouteOptions } from '@lifi/types' -import type { SDKProvider } from '../core/types.js' - -export interface SDKBaseConfig { - apiKey?: string - apiUrl: string - integrator: string - userId?: string - providers: SDKProvider[] - routeOptions?: RouteOptions - rpcUrls: RPCUrls - chains: ExtendedChain[] - disableVersionCheck?: boolean - widgetVersion?: string - preloadChains: boolean - debug: boolean -} - -export interface SDKConfig extends Partial> { - integrator: string -} - -export type RPCUrls = Partial> diff --git a/src/utils/getTransactionMessage.ts b/src/utils/getTransactionMessage.ts index 8e5a764a..e1717d4a 100644 --- a/src/utils/getTransactionMessage.ts +++ b/src/utils/getTransactionMessage.ts @@ -1,11 +1,12 @@ import type { LiFiStep } from '@lifi/types' -import { config } from '../config.js' +import type { SDKClient } from '../types/core.js' export const getTransactionFailedMessage = async ( + client: SDKClient, step: LiFiStep, txLink?: string ): Promise => { - const chain = await config.getChainById(step.action.toChainId) + const chain = await client.getChainById(step.action.toChainId) const baseString = `It appears that your transaction may not have been successful. However, to confirm this, please check your ${chain.name} wallet for ${step.action.toToken.symbol}.` diff --git a/tests/setup.ts b/tests/setup.ts deleted file mode 100644 index b192c791..00000000 --- a/tests/setup.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createConfig } from '../src/createConfig.js' -import { EVM, Solana, Sui, UTXO } from '../src/index.js' - -export const setupTestEnvironment = () => { - createConfig({ - integrator: 'lifi-sdk', - providers: [EVM(), Solana(), UTXO(), Sui()], - }) -}