diff --git a/packages/corsair/core/constants.ts b/packages/corsair/core/constants.ts index 509d644f2..619648410 100644 --- a/packages/corsair/core/constants.ts +++ b/packages/corsair/core/constants.ts @@ -28,10 +28,10 @@ export const BaseProviders = [ 'agenty', 'ahrefs', 'aimlapi', - 'allimagesai', 'airtable', 'alchemy', 'algolia', + 'allimagesai', 'alphavantage', 'altoviz', 'alttextai', @@ -175,6 +175,7 @@ export const BaseProviders = [ 'twochat', 'typeform', 'unione', + 'uniswapapi', 'vapi', 'vercel', 'webflow', @@ -205,10 +206,10 @@ export const ProviderDisplayNames = { agenty: 'Agenty', ahrefs: 'Ahrefs', aimlapi: 'AI/ML API', - allimagesai: 'All Images AI', airtable: 'Airtable', alchemy: 'Alchemy', algolia: 'Algolia', + allimagesai: 'All Images AI', alphavantage: 'Alpha Vantage', altoviz: 'Altoviz', alttextai: 'AltText.ai', @@ -352,6 +353,7 @@ export const ProviderDisplayNames = { twochat: 'TwoChat', typeform: 'Typeform', unione: 'Unione', + uniswapapi: 'Uniswap', vapi: 'Vapi', vercel: 'Vercel', webflow: 'Webflow', @@ -389,10 +391,10 @@ export type AllProviders = | 'agenty' | 'ahrefs' | 'aimlapi' - | 'allimagesai' | 'airtable' | 'alchemy' | 'algolia' + | 'allimagesai' | 'alphavantage' | 'altoviz' | 'alttextai' @@ -536,6 +538,7 @@ export type AllProviders = | 'twochat' | 'typeform' | 'unione' + | 'uniswapapi' | 'vapi' | 'vercel' | 'webflow' diff --git a/packages/uniswapapi/client.test.ts b/packages/uniswapapi/client.test.ts new file mode 100644 index 000000000..b69948e0b --- /dev/null +++ b/packages/uniswapapi/client.test.ts @@ -0,0 +1,183 @@ +import { AuthMissingError } from 'corsair/core'; +import { ApiError, request } from 'corsair/http'; +import { makeUniswapApiRequest, UniswapApiAPIError } from './client'; +import { errorHandlers } from './error-handlers'; +import { uniswapapi } from './index'; + +jest.mock('corsair/http', () => ({ + ...jest.requireActual('corsair/http'), + request: jest.fn(), +})); + +const mockedRequest = request as jest.MockedFunction; + +function apiError(status: number, retryAfter?: number): ApiError { + return new ApiError( + { method: 'GET', url: '/v1/orders' }, + { + url: 'https://trade-api.gateway.uniswap.org/v1/orders', + ok: false, + status, + statusText: 'Too Many Requests', + body: { + detail: 'Please slow down before trying again.', + errorCode: 'TOO_MANY_REQUESTS', + }, + }, + 'Please slow down before trying again.', + { retryAfter }, + ); +} + +async function captureError(promise: Promise) { + try { + await promise; + } catch (error) { + return error as UniswapApiAPIError; + } + throw new Error('expected the request to reject'); +} + +beforeEach(() => { + mockedRequest.mockReset(); +}); + +describe('makeUniswapApiRequest', () => { + it('preserves status and retryAfter on wrapped ApiError', async () => { + mockedRequest.mockRejectedValueOnce(apiError(429, 2500)); + + const error = await captureError( + makeUniswapApiRequest('/v1/orders', 'key'), + ); + + expect(error).toBeInstanceOf(UniswapApiAPIError); + expect(error.status).toBe(429); + expect(error.retryAfter).toBe(2500); + expect(error.code).toBe('TOO_MANY_REQUESTS'); + }); + + it('falls back to the HTTP status as code when no errorCode is present', async () => { + mockedRequest.mockRejectedValueOnce( + new ApiError( + { method: 'GET', url: '/v1/orders' }, + { + url: 'https://trade-api.gateway.uniswap.org/v1/orders', + ok: false, + status: 500, + statusText: 'Internal Server Error', + body: { detail: 'Something went wrong.' }, + }, + 'Something went wrong.', + ), + ); + + const error = await captureError( + makeUniswapApiRequest('/v1/orders', 'key'), + ); + + expect(error.status).toBe(500); + expect(error.message).toBe('Something went wrong.'); + expect(error.code).toBe('500'); + }); + + it('wraps non-HTTP errors while keeping their message', async () => { + mockedRequest.mockRejectedValueOnce(new Error('network down')); + + const error = await captureError( + makeUniswapApiRequest('/v1/orders', 'key'), + ); + + expect(error).toBeInstanceOf(UniswapApiAPIError); + expect(error.status).toBeUndefined(); + expect(error.retryAfter).toBeUndefined(); + expect(error.message).toBe('network down'); + }); + + it('authenticates with x-api-key and does not set a bearer TOKEN', async () => { + mockedRequest.mockResolvedValueOnce({ requestId: 'req-1' }); + + await makeUniswapApiRequest('/v1/orders', 'test-api-key'); + + expect(mockedRequest).toHaveBeenCalledWith( + expect.objectContaining({ + TOKEN: undefined, + HEADERS: expect.objectContaining({ + 'x-api-key': 'test-api-key', + }), + }), + expect.anything(), + ); + }); +}); + +describe('errorHandlers', () => { + it('routes a wrapped 429 without relying on message text', async () => { + mockedRequest.mockRejectedValueOnce(apiError(429, 2500)); + const error = await captureError( + makeUniswapApiRequest('/v1/orders', 'key'), + ); + + expect(error.message).not.toContain('429'); + expect(error.message).not.toContain('rate_limited'); + expect(errorHandlers.RATE_LIMIT_ERROR.match(error)).toBe(true); + await expect( + errorHandlers.RATE_LIMIT_ERROR.handler(error), + ).resolves.toEqual({ + maxRetries: 5, + headersRetryAfterMs: 2500, + }); + }); + + it('does not treat an unrelated message that mentions 429 as a rate limit', () => { + const error = new UniswapApiAPIError('order 429 is not a valid status'); + expect(errorHandlers.RATE_LIMIT_ERROR.match(error)).toBe(false); + }); +}); + +describe('keyBuilder', () => { + function getKeyBuilder(plugin: ReturnType) { + return plugin.keyBuilder as ( + ctx: unknown, + source: string, + ) => Promise; + } + + it('returns an explicit option key', async () => { + const keyBuilder = getKeyBuilder(uniswapapi({ key: 'explicit-key' })); + await expect( + keyBuilder( + { + authType: 'api_key', + keys: { get_api_key: jest.fn() }, + }, + 'endpoint', + ), + ).resolves.toBe('explicit-key'); + }); + + it('returns the stored api key', async () => { + const keyBuilder = getKeyBuilder(uniswapapi()); + await expect( + keyBuilder( + { + authType: 'api_key', + keys: { get_api_key: async () => 'stored-key' }, + }, + 'endpoint', + ), + ).resolves.toBe('stored-key'); + }); + + it('throws AuthMissingError when no key is available', async () => { + const keyBuilder = getKeyBuilder(uniswapapi()); + await expect( + keyBuilder( + { + authType: 'api_key', + keys: { get_api_key: async () => undefined }, + }, + 'endpoint', + ), + ).rejects.toBeInstanceOf(AuthMissingError); + }); +}); diff --git a/packages/uniswapapi/client.ts b/packages/uniswapapi/client.ts new file mode 100644 index 000000000..e04210852 --- /dev/null +++ b/packages/uniswapapi/client.ts @@ -0,0 +1,115 @@ +import type { ApiRequestOptions, OpenAPIConfig } from 'corsair/http'; +import { ApiError, request } from 'corsair/http'; + +type UniswapApiErrorOptions = { + cause?: Error; + status?: number; + statusText?: string; + body?: unknown; + retryAfter?: number; +}; + +export class UniswapApiAPIError extends Error { + public readonly status?: number; + public readonly statusText?: string; + public readonly body?: unknown; + public readonly retryAfter?: number; + + constructor( + message: string, + public readonly code?: string, + options: UniswapApiErrorOptions = {}, + ) { + super(message, options); + this.name = 'UniswapApiAPIError'; + + if (options.cause instanceof ApiError) { + this.status = options.cause.status; + this.statusText = options.cause.statusText; + this.body = options.cause.body; + this.retryAfter = options.cause.retryAfter; + } else { + this.status = options.status; + this.statusText = options.statusText; + this.body = options.body; + this.retryAfter = options.retryAfter; + } + } +} + +const UNISWAPAPI_API_BASE = 'https://trade-api.gateway.uniswap.org'; + +export async function makeUniswapApiRequest( + endpoint: string, + apiKey: string, + options: { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + body?: Record; + // Arrays serialize as repeated query params (e.g. txHashes=0x..&txHashes=0x..), + // which is how the Trading API expects list filters. + query?: Record< + string, + string | number | boolean | (string | number | boolean)[] | undefined + >; + } = {}, +): Promise { + const { method = 'GET', body, query } = options; + + const config: OpenAPIConfig = { + BASE: UNISWAPAPI_API_BASE, + VERSION: '1.0.0', + WITH_CREDENTIALS: false, + CREDENTIALS: 'omit', + TOKEN: undefined, + HEADERS: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'x-permit2-disabled': 'false', + }, + }; + + const requestOptions: ApiRequestOptions = { + method, + url: endpoint, + body: + method === 'POST' || method === 'PUT' || method === 'PATCH' + ? body + : undefined, + mediaType: 'application/json; charset=utf-8', + query: method === 'GET' ? query : undefined, + }; + + try { + return await request(config, requestOptions); + } catch (error) { + if (error instanceof ApiError) { + // UniswapApi error responses use { errorCode, detail } instead of the + // generic { code, message } shape — extract those fields explicitly, + // falling back to error.message / error.status if the body doesn't match. + const body = error.body; + + const message = + typeof body === 'object' && + body !== null && + 'detail' in body && + typeof body.detail === 'string' + ? body.detail + : error.message; + + const code = + typeof body === 'object' && + body !== null && + 'errorCode' in body && + typeof body.errorCode === 'string' + ? body.errorCode + : error.status?.toString(); + throw new UniswapApiAPIError(message, code, { cause: error }); + } + + if (error instanceof Error) { + throw new UniswapApiAPIError(error.message); + } + + throw new UniswapApiAPIError('Unknown error'); + } +} diff --git a/packages/uniswapapi/endpoints/approval.ts b/packages/uniswapapi/endpoints/approval.ts new file mode 100644 index 000000000..f988ed448 --- /dev/null +++ b/packages/uniswapapi/endpoints/approval.ts @@ -0,0 +1,35 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeUniswapApiRequest } from '../client'; +import type { + UniswapApiEndpointContext, + UniswapApiEndpointInputs, + UniswapApiEndpointOutputs, +} from './types'; +import { UniswapApiEndpointOutputSchemas } from './types'; + +export const check = async ( + ctx: UniswapApiEndpointContext, + input: UniswapApiEndpointInputs['approvalCheck'], +): Promise => { + const response = await makeUniswapApiRequest< + UniswapApiEndpointOutputs['approvalCheck'] + >('/v1/check_approval', ctx.key, { + method: 'POST', + body: { + token: input.token, + amount: input.amount, + walletAddress: input.walletAddress, + chainId: input.chainId, + }, + }); + const parsedResponse = + UniswapApiEndpointOutputSchemas.approvalCheck.parse(response); + + await logEventFromContext( + ctx, + 'uniswapapi.approval.check', + { ...input }, + 'completed', + ); + return parsedResponse; +}; diff --git a/packages/uniswapapi/endpoints/delegation.ts b/packages/uniswapapi/endpoints/delegation.ts new file mode 100644 index 000000000..36758ce35 --- /dev/null +++ b/packages/uniswapapi/endpoints/delegation.ts @@ -0,0 +1,33 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeUniswapApiRequest } from '../client'; +import type { + UniswapApiEndpointContext, + UniswapApiEndpointInputs, + UniswapApiEndpointOutputs, +} from './types'; +import { UniswapApiEndpointOutputSchemas } from './types'; + +export const check = async ( + ctx: UniswapApiEndpointContext, + input: UniswapApiEndpointInputs['delegationCheck'], +): Promise => { + const response = await makeUniswapApiRequest< + UniswapApiEndpointOutputs['delegationCheck'] + >('/v1/wallet/check_delegation', ctx.key, { + method: 'POST', + body: { + walletAddresses: input.walletAddresses, + chainIds: input.chainIds, + }, + }); + const parsedResponse = + UniswapApiEndpointOutputSchemas.delegationCheck.parse(response); + + await logEventFromContext( + ctx, + 'uniswapapi.delegation.check', + { ...input }, + 'completed', + ); + return parsedResponse; +}; diff --git a/packages/uniswapapi/endpoints/endpoints.test.ts b/packages/uniswapapi/endpoints/endpoints.test.ts new file mode 100644 index 000000000..a09f5e392 --- /dev/null +++ b/packages/uniswapapi/endpoints/endpoints.test.ts @@ -0,0 +1,507 @@ +import { logEventFromContext } from 'corsair/core'; +import * as client from '../client'; +import { + Approval, + Delegation, + Order, + Quote, + Swap, + SwappableTokens, + Transaction, +} from './index'; +import type { + CheckApprovalResponse, + GetOrderStatusResponse, + GetQuoteResponse, + GetSwappableTokensResponse, + GetSwapStatusResponse, + UniswapApiEndpointContext, +} from './types'; + +jest.mock('corsair/core', () => { + const actual = + jest.requireActual('corsair/core'); + + return { + ...actual, + logEventFromContext: jest.fn().mockResolvedValue(null), + }; +}); + +// Keep the real module shape so error-handlers can still reference +// UniswapApiAPIError; only the request function is replaced. +jest.mock('../client', () => ({ + ...jest.requireActual('../client'), + makeUniswapApiRequest: jest.fn(), +})); + +const mockedRequest = client.makeUniswapApiRequest as jest.MockedFunction< + typeof client.makeUniswapApiRequest +>; + +const ctx: UniswapApiEndpointContext = { + key: 'test-api-key', + $getAccountId: async () => 'account-1', +}; + +const tx = { + to: '0x1234567890abcdef1234567890abcdef12345678', + data: '0x1234', + value: '0', + chainId: 1, +}; + +describe('Uniswap API endpoints', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('approval', () => { + it('checks token approval with correct path and body', async () => { + const response: CheckApprovalResponse = { + requestId: 'req-1', + approval: null, + }; + mockedRequest.mockResolvedValueOnce(response); + const input = { + token: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + amount: '1000000', + walletAddress: '0x1234567890abcdef1234567890abcdef12345678', + chainId: 1, + }; + + await Approval.check(ctx, input); + + expect(mockedRequest).toHaveBeenCalledWith( + '/v1/check_approval', + ctx.key, + { + method: 'POST', + body: { + token: input.token, + amount: input.amount, + walletAddress: input.walletAddress, + chainId: input.chainId, + }, + }, + ); + }); + + it('accepts an approval transaction when one is required', async () => { + mockedRequest.mockResolvedValueOnce({ + requestId: 'req-2', + approval: { ...tx, from: '0xabc' }, + gasFee: '1000000000000000', + }); + + await expect( + Approval.check(ctx, { + token: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + amount: '1000000', + walletAddress: '0x1234567890abcdef1234567890abcdef12345678', + chainId: 1, + }), + ).resolves.toMatchObject({ + requestId: 'req-2', + gasFee: '1000000000000000', + }); + }); + }); + + describe('quote', () => { + it('gets a quote with slippageTolerance mode', async () => { + const response: GetQuoteResponse = { + requestId: 'req-3', + routing: 'CLASSIC', + quote: { quoteId: 'quote-123' }, + permitData: null, + }; + mockedRequest.mockResolvedValueOnce(response); + const input = { + type: 'EXACT_INPUT' as const, + tokenIn: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + tokenInChainId: 1, + tokenOut: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + tokenOutChainId: 1, + amount: '1000000', + swapper: '0x1234567890abcdef1234567890abcdef12345678', + slippageTolerance: 0.5, + }; + + await Quote.get(ctx, input); + + expect(mockedRequest).toHaveBeenCalledWith('/v1/quote', ctx.key, { + method: 'POST', + body: { + type: 'EXACT_INPUT', + tokenIn: input.tokenIn, + tokenInChainId: 1, + tokenOut: input.tokenOut, + tokenOutChainId: 1, + amount: '1000000', + swapper: input.swapper, + slippageTolerance: 0.5, + }, + }); + }); + + it('forwards autoSlippage and optional fields when provided', async () => { + const response: GetQuoteResponse = { + requestId: 'req-4', + routing: 'DUTCH_V2', + quote: { quoteId: 'quote-456' }, + permitData: { domain: {} }, + }; + mockedRequest.mockResolvedValueOnce(response); + const input = { + type: 'EXACT_OUTPUT' as const, + tokenIn: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + tokenInChainId: 1, + tokenOut: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + tokenOutChainId: 1, + amount: '500000000000000000', + swapper: '0x1234567890abcdef1234567890abcdef12345678', + autoSlippage: 'DEFAULT' as const, + urgency: 'urgent' as const, + recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + protocols: ['V4'], + }; + + await Quote.get(ctx, input); + + expect(mockedRequest).toHaveBeenCalledWith('/v1/quote', ctx.key, { + method: 'POST', + body: expect.objectContaining({ + autoSlippage: 'DEFAULT', + urgency: 'urgent', + recipient: input.recipient, + protocols: ['V4'], + }), + }); + }); + }); + + describe('swap', () => { + it('creates swap calldata from a quote without permit fields', async () => { + mockedRequest.mockResolvedValueOnce({ + requestId: 'req-5', + swap: tx, + }); + const quote = { quoteId: 'quote-123', tokenIn: '0xabc' }; + + await Swap.create(ctx, { quote }); + + expect(mockedRequest).toHaveBeenCalledWith('/v1/swap', ctx.key, { + method: 'POST', + body: { + quote, + }, + }); + expect(logEventFromContext).toHaveBeenCalledWith( + ctx, + 'uniswapapi.swap.create', + { quote }, + 'completed', + ); + }); + + it('sends permitData together with its signature', async () => { + mockedRequest.mockResolvedValueOnce({ + requestId: 'req-6', + swap: tx, + }); + const permitData = { domain: {}, values: {}, types: {} }; + + await Swap.create(ctx, { + quote: { quoteId: 'quote-456' }, + signature: '0xsig', + permitData, + }); + + expect(mockedRequest).toHaveBeenCalledWith('/v1/swap', ctx.key, { + method: 'POST', + body: { + quote: { quoteId: 'quote-456' }, + signature: '0xsig', + permitData, + }, + }); + }); + + it('queries /v1/swaps by transaction hashes', async () => { + const response: GetSwapStatusResponse = { + requestId: 'req-7', + swaps: [{ status: 'SUCCESS', txHash: '0xdead' }], + }; + mockedRequest.mockResolvedValueOnce(response); + + await Swap.getStatus(ctx, { + txHashes: [ + '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + ], + chainId: 1, + }); + + expect(mockedRequest).toHaveBeenCalledWith('/v1/swaps', ctx.key, { + method: 'GET', + query: { + txHashes: [ + '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + ], + userOpHashes: undefined, + chainId: 1, + swapper: undefined, + }, + }); + }); + + it('supports userOpHash queries and returns parsed rows', async () => { + mockedRequest.mockResolvedValueOnce({ + requestId: 'req-8', + swaps: [ + { status: 'PENDING', userOpHash: '0xbeef', hashType: 'USER_OP' }, + ], + }); + + const result = await Swap.getStatus(ctx, { + userOpHashes: ['0x1234567890abcdef'], + chainId: 1, + }); + + expect(mockedRequest).toHaveBeenCalledWith('/v1/swaps', ctx.key, { + method: 'GET', + query: { + txHashes: undefined, + userOpHashes: ['0x1234567890abcdef'], + chainId: 1, + swapper: undefined, + }, + }); + expect(result.swaps).toHaveLength(1); + }); + + it('rejects malformed provider output before returning', async () => { + mockedRequest.mockResolvedValueOnce({ + requestId: 'req-9', + swaps: [{ status: 'UNKNOWN_STATUS' }], + }); + + await expect( + Swap.getStatus(ctx, { + txHashes: [ + '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + ], + chainId: 1, + }), + ).rejects.toThrow(); + }); + }); + + describe('order', () => { + it('gets gasless orders by order ID with pagination cursor', async () => { + const orderId = `0x${'a'.repeat(64)}`; + const response: GetOrderStatusResponse = { + requestId: 'req-10', + orders: [ + { + orderId, + orderStatus: 'filled', + chainId: 1, + type: 'Dutch_V2', + }, + ], + }; + mockedRequest.mockResolvedValueOnce(response); + + const result = await Order.getStatus(ctx, { + orderId, + limit: 10, + cursor: 'cursor-1', + }); + + expect(mockedRequest).toHaveBeenCalledWith('/v1/orders', ctx.key, { + method: 'GET', + query: { + orderId, + orderIds: undefined, + orderStatus: undefined, + swapper: undefined, + filler: undefined, + limit: 10, + cursor: 'cursor-1', + sortKey: undefined, + }, + }); + expect(result.orders[0]?.orderStatus).toBe('filled'); + }); + + it('filters by order status across multiple IDs', async () => { + mockedRequest.mockResolvedValueOnce({ + requestId: 'req-11', + orders: [], + }); + const orderIds = [`0x${'b'.repeat(64)}`, `0x${'c'.repeat(64)}`]; + + await Order.getStatus(ctx, { + orderIds, + orderStatus: 'open', + }); + + expect(mockedRequest).toHaveBeenCalledWith('/v1/orders', ctx.key, { + method: 'GET', + query: expect.objectContaining({ + orderIds, + orderStatus: 'open', + }), + }); + }); + }); + + describe('delegation', () => { + it('checks wallet delegation across chains on the wallet path', async () => { + mockedRequest.mockResolvedValueOnce({ + requestId: 'req-12', + delegationDetails: { + '0x1234567890abcdef1234567890abcdef12345678': { + '1': { + isWalletDelegatedToUniswap: true, + currentDelegationAddress: + '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + latestDelegationAddress: + '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + }, + }, + }, + }); + const input = { + walletAddresses: ['0x1234567890abcdef1234567890abcdef12345678'], + chainIds: [1, 10, 8453], + }; + + const result = await Delegation.check(ctx, input); + + expect(mockedRequest).toHaveBeenCalledWith( + '/v1/wallet/check_delegation', + ctx.key, + { + method: 'POST', + body: { + walletAddresses: input.walletAddresses, + chainIds: [1, 10, 8453], + }, + }, + ); + expect( + result.delegationDetails[input.walletAddresses[0] ?? '']?.['1'] + ?.isWalletDelegatedToUniswap, + ).toBe(true); + }); + + it('accepts a null current delegation address', async () => { + mockedRequest.mockResolvedValueOnce({ + requestId: 'req-13', + delegationDetails: { + '0x1234567890abcdef1234567890abcdef12345678': { + '8453': { + isWalletDelegatedToUniswap: false, + currentDelegationAddress: null, + latestDelegationAddress: + '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + }, + }, + }, + }); + + await expect( + Delegation.check(ctx, { + walletAddresses: ['0x1234567890abcdef1234567890abcdef12345678'], + chainIds: [8453], + }), + ).resolves.toBeTruthy(); + }); + }); + + describe('transaction', () => { + it('encodes 7702 calls on the wallet path', async () => { + mockedRequest.mockResolvedValueOnce({ + requestId: 'req-14', + encoded: tx, + }); + const input = { + calls: [ + { to: '0xabc', data: '0x12345678', value: '0x0', chainId: 1 }, + { to: '0xdef', data: '0x56781234', value: '0x0', chainId: 1 }, + ], + smartContractDelegationAddress: + '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + walletAddress: '0x1234567890abcdef1234567890abcdef12345678', + }; + + await Transaction.encode7702(ctx, input); + + expect(mockedRequest).toHaveBeenCalledWith( + '/v1/wallet/encode_7702', + ctx.key, + { + method: 'POST', + body: { + calls: input.calls, + smartContractDelegationAddress: + '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + walletAddress: input.walletAddress, + }, + }, + ); + }); + }); + + describe('swappableTokens', () => { + it('lists destination tokens for a source token', async () => { + const response: GetSwappableTokensResponse = { + requestId: 'req-15', + tokens: [ + { + address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + chainId: 8453, + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + }, + ], + }; + mockedRequest.mockResolvedValueOnce(response); + + const result = await SwappableTokens.get(ctx, { + tokenIn: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + tokenInChainId: 1, + }); + + expect(mockedRequest).toHaveBeenCalledWith( + '/v1/swappable_tokens', + ctx.key, + { + method: 'GET', + query: { + tokenIn: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + tokenInChainId: 1, + }, + }, + ); + expect(result.tokens[0]?.symbol).toBe('WETH'); + }); + + it('rejects token rows missing required metadata', async () => { + mockedRequest.mockResolvedValueOnce({ + requestId: 'req-16', + tokens: [{ address: '0xabc', chainId: 8453, name: 'Junk Token' }], + }); + + await expect( + SwappableTokens.get(ctx, { + tokenIn: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + tokenInChainId: 1, + }), + ).rejects.toThrow(); + }); + }); +}); diff --git a/packages/uniswapapi/endpoints/index.ts b/packages/uniswapapi/endpoints/index.ts new file mode 100644 index 000000000..faf48f83c --- /dev/null +++ b/packages/uniswapapi/endpoints/index.ts @@ -0,0 +1,38 @@ +import { check as approvalCheck } from './approval'; +import { check as delegationCheck } from './delegation'; +import { getStatus as orderGetStatus } from './order'; +import { get as quoteGet } from './quote'; +import { create as swapCreate, getStatus as swapGetStatus } from './swap'; +import { get as swappableTokensGet } from './swappable-tokens'; +import { encode7702 as transactionEncode7702 } from './transaction'; + +export const Approval = { + check: approvalCheck, +}; + +export const Quote = { + get: quoteGet, +}; + +export const Swap = { + create: swapCreate, + getStatus: swapGetStatus, +}; + +export const Order = { + getStatus: orderGetStatus, +}; + +export const Delegation = { + check: delegationCheck, +}; + +export const Transaction = { + encode7702: transactionEncode7702, +}; + +export const SwappableTokens = { + get: swappableTokensGet, +}; + +export * from './types'; diff --git a/packages/uniswapapi/endpoints/order.ts b/packages/uniswapapi/endpoints/order.ts new file mode 100644 index 000000000..6c6c19a76 --- /dev/null +++ b/packages/uniswapapi/endpoints/order.ts @@ -0,0 +1,39 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeUniswapApiRequest } from '../client'; +import type { + UniswapApiEndpointContext, + UniswapApiEndpointInputs, + UniswapApiEndpointOutputs, +} from './types'; +import { UniswapApiEndpointOutputSchemas } from './types'; + +export const getStatus = async ( + ctx: UniswapApiEndpointContext, + input: UniswapApiEndpointInputs['orderGetStatus'], +): Promise => { + const response = await makeUniswapApiRequest< + UniswapApiEndpointOutputs['orderGetStatus'] + >('/v1/orders', ctx.key, { + method: 'GET', + query: { + orderId: input.orderId, + orderIds: input.orderIds, + orderStatus: input.orderStatus, + swapper: input.swapper, + filler: input.filler, + limit: input.limit, + cursor: input.cursor, + sortKey: input.sortKey, + }, + }); + const parsedResponse = + UniswapApiEndpointOutputSchemas.orderGetStatus.parse(response); + + await logEventFromContext( + ctx, + 'uniswapapi.order.getStatus', + { ...input }, + 'completed', + ); + return parsedResponse; +}; diff --git a/packages/uniswapapi/endpoints/quote.ts b/packages/uniswapapi/endpoints/quote.ts new file mode 100644 index 000000000..080f2ea8a --- /dev/null +++ b/packages/uniswapapi/endpoints/quote.ts @@ -0,0 +1,45 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeUniswapApiRequest } from '../client'; +import type { + UniswapApiEndpointContext, + UniswapApiEndpointInputs, + UniswapApiEndpointOutputs, +} from './types'; +import { UniswapApiEndpointOutputSchemas } from './types'; + +export const get = async ( + ctx: UniswapApiEndpointContext, + input: UniswapApiEndpointInputs['quoteGet'], +): Promise => { + const response = await makeUniswapApiRequest< + UniswapApiEndpointOutputs['quoteGet'] + >('/v1/quote', ctx.key, { + method: 'POST', + body: { + type: input.type, + tokenIn: input.tokenIn, + tokenInChainId: input.tokenInChainId, + tokenOut: input.tokenOut, + tokenOutChainId: input.tokenOutChainId, + amount: input.amount, + swapper: input.swapper, + ...(input.slippageTolerance !== undefined && { + slippageTolerance: input.slippageTolerance, + }), + ...(input.autoSlippage && { autoSlippage: input.autoSlippage }), + ...(input.urgency && { urgency: input.urgency }), + ...(input.recipient && { recipient: input.recipient }), + ...(input.protocols && { protocols: input.protocols }), + }, + }); + const parsedResponse = + UniswapApiEndpointOutputSchemas.quoteGet.parse(response); + + await logEventFromContext( + ctx, + 'uniswapapi.quote.get', + { ...input }, + 'completed', + ); + return parsedResponse; +}; diff --git a/packages/uniswapapi/endpoints/swap.ts b/packages/uniswapapi/endpoints/swap.ts new file mode 100644 index 000000000..137d55358 --- /dev/null +++ b/packages/uniswapapi/endpoints/swap.ts @@ -0,0 +1,69 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeUniswapApiRequest } from '../client'; +import type { + UniswapApiEndpointContext, + UniswapApiEndpointInputs, + UniswapApiEndpointOutputs, +} from './types'; +import { UniswapApiEndpointOutputSchemas } from './types'; + +export const create = async ( + ctx: UniswapApiEndpointContext, + input: UniswapApiEndpointInputs['swapCreate'], +): Promise => { + const response = await makeUniswapApiRequest< + UniswapApiEndpointOutputs['swapCreate'] + >('/v1/swap', ctx.key, { + method: 'POST', + body: { + quote: input.quote, + // The API requires signature and permitData to be sent together + // (or both omitted); the input schema enforces that pairing. + ...(input.signature && { signature: input.signature }), + ...(input.permitData && { permitData: input.permitData }), + ...(input.refreshGasPrice !== undefined && { + refreshGasPrice: input.refreshGasPrice, + }), + ...(input.simulateTransaction !== undefined && { + simulateTransaction: input.simulateTransaction, + }), + }, + }); + const parsedResponse = + UniswapApiEndpointOutputSchemas.swapCreate.parse(response); + + await logEventFromContext( + ctx, + 'uniswapapi.swap.create', + { ...input }, + 'completed', + ); + return parsedResponse; +}; + +export const getStatus = async ( + ctx: UniswapApiEndpointContext, + input: UniswapApiEndpointInputs['swapGetStatus'], +): Promise => { + const response = await makeUniswapApiRequest< + UniswapApiEndpointOutputs['swapGetStatus'] + >('/v1/swaps', ctx.key, { + method: 'GET', + query: { + txHashes: input.txHashes, + userOpHashes: input.userOpHashes, + chainId: input.chainId, + swapper: input.swapper, + }, + }); + const parsedResponse = + UniswapApiEndpointOutputSchemas.swapGetStatus.parse(response); + + await logEventFromContext( + ctx, + 'uniswapapi.swap.getStatus', + { ...input }, + 'completed', + ); + return parsedResponse; +}; diff --git a/packages/uniswapapi/endpoints/swappable-tokens.ts b/packages/uniswapapi/endpoints/swappable-tokens.ts new file mode 100644 index 000000000..54d833d1b --- /dev/null +++ b/packages/uniswapapi/endpoints/swappable-tokens.ts @@ -0,0 +1,33 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeUniswapApiRequest } from '../client'; +import type { + UniswapApiEndpointContext, + UniswapApiEndpointInputs, + UniswapApiEndpointOutputs, +} from './types'; +import { UniswapApiEndpointOutputSchemas } from './types'; + +export const get = async ( + ctx: UniswapApiEndpointContext, + input: UniswapApiEndpointInputs['swappableTokensGet'], +): Promise => { + const response = await makeUniswapApiRequest< + UniswapApiEndpointOutputs['swappableTokensGet'] + >('/v1/swappable_tokens', ctx.key, { + method: 'GET', + query: { + tokenIn: input.tokenIn, + tokenInChainId: input.tokenInChainId, + }, + }); + const parsedResponse = + UniswapApiEndpointOutputSchemas.swappableTokensGet.parse(response); + + await logEventFromContext( + ctx, + 'uniswapapi.swappableTokens.get', + { ...input }, + 'completed', + ); + return parsedResponse; +}; diff --git a/packages/uniswapapi/endpoints/transaction.ts b/packages/uniswapapi/endpoints/transaction.ts new file mode 100644 index 000000000..fc92e92ed --- /dev/null +++ b/packages/uniswapapi/endpoints/transaction.ts @@ -0,0 +1,34 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeUniswapApiRequest } from '../client'; +import type { + UniswapApiEndpointContext, + UniswapApiEndpointInputs, + UniswapApiEndpointOutputs, +} from './types'; +import { UniswapApiEndpointOutputSchemas } from './types'; + +export const encode7702 = async ( + ctx: UniswapApiEndpointContext, + input: UniswapApiEndpointInputs['transactionEncode7702'], +): Promise => { + const response = await makeUniswapApiRequest< + UniswapApiEndpointOutputs['transactionEncode7702'] + >('/v1/wallet/encode_7702', ctx.key, { + method: 'POST', + body: { + calls: input.calls, + smartContractDelegationAddress: input.smartContractDelegationAddress, + walletAddress: input.walletAddress, + }, + }); + const parsedResponse = + UniswapApiEndpointOutputSchemas.transactionEncode7702.parse(response); + + await logEventFromContext( + ctx, + 'uniswapapi.transaction.encode7702', + { ...input }, + 'completed', + ); + return parsedResponse; +}; diff --git a/packages/uniswapapi/endpoints/types.test.ts b/packages/uniswapapi/endpoints/types.test.ts new file mode 100644 index 000000000..207a86a13 --- /dev/null +++ b/packages/uniswapapi/endpoints/types.test.ts @@ -0,0 +1,388 @@ +import { + UniswapApiEndpointInputSchemas, + UniswapApiEndpointOutputSchemas, +} from './types'; + +const transaction = { + to: '0x1234567890abcdef1234567890abcdef12345678', + data: '0x1234', + value: '0x0', + chainId: 1, +}; + +const quoteBase = { + type: 'EXACT_INPUT' as const, + tokenIn: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + tokenInChainId: 1, + tokenOut: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + tokenOutChainId: 1, + amount: '1000000', + swapper: '0x1234567890abcdef1234567890abcdef12345678', +}; + +const ORDER_ID = `0x${'a'.repeat(64)}`; + +const orderBase = { + orderId: ORDER_ID, + orderStatus: 'filled' as const, + chainId: 1, + type: 'Dutch_V2' as const, +}; + +describe('Uniswap API output schemas', () => { + it.each([ + [ + 'approvalCheck', + UniswapApiEndpointOutputSchemas.approvalCheck, + { requestId: 'req-1', approval: null }, + ], + [ + 'approvalCheck transaction', + UniswapApiEndpointOutputSchemas.approvalCheck, + { requestId: 'req-1', approval: transaction }, + ], + [ + 'quoteGet', + UniswapApiEndpointOutputSchemas.quoteGet, + { + requestId: 'req-1', + routing: 'CLASSIC', + quote: { quoteId: 'quote-1' }, + permitData: null, + }, + ], + [ + 'swapCreate', + UniswapApiEndpointOutputSchemas.swapCreate, + { requestId: 'req-1', swap: transaction }, + ], + [ + 'swapGetStatus', + UniswapApiEndpointOutputSchemas.swapGetStatus, + { + requestId: 'req-1', + swaps: [{ status: 'NOT_FOUND', txHash: '0xdead' }], + }, + ], + [ + 'orderGetStatus', + UniswapApiEndpointOutputSchemas.orderGetStatus, + { requestId: 'req-1', orders: [orderBase], cursor: 'next' }, + ], + [ + 'delegationCheck', + UniswapApiEndpointOutputSchemas.delegationCheck, + { + requestId: 'req-1', + delegationDetails: { + '0x1234567890abcdef1234567890abcdef12345678': { + '1': { + isWalletDelegatedToUniswap: false, + currentDelegationAddress: null, + latestDelegationAddress: + '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + }, + }, + }, + }, + ], + [ + 'delegationCheck with no delegated wallets', + UniswapApiEndpointOutputSchemas.delegationCheck, + { requestId: 'req-1', delegationDetails: {} }, + ], + [ + 'transactionEncode7702', + UniswapApiEndpointOutputSchemas.transactionEncode7702, + { requestId: 'req-1', encoded: transaction }, + ], + [ + 'swappableTokensGet', + UniswapApiEndpointOutputSchemas.swappableTokensGet, + { + requestId: 'req-1', + tokens: [ + { + address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + chainId: 8453, + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + }, + ], + }, + ], + ])('accepts a complete %s response', (_name, schema, response) => { + expect(schema.safeParse(response).success).toBe(true); + }); + + it.each([ + ['approvalCheck', UniswapApiEndpointOutputSchemas.approvalCheck, {}], + [ + 'approvalCheck missing requestId', + UniswapApiEndpointOutputSchemas.approvalCheck, + { approval: null }, + ], + [ + 'approvalCheck empty approval', + UniswapApiEndpointOutputSchemas.approvalCheck, + { requestId: 'req-1', approval: {} }, + ], + [ + 'quoteGet missing routing', + UniswapApiEndpointOutputSchemas.quoteGet, + { requestId: 'req-1', quote: { a: 1 }, permitData: null }, + ], + [ + 'quoteGet empty quote', + UniswapApiEndpointOutputSchemas.quoteGet, + { requestId: 'req-1', routing: 'CLASSIC', quote: {}, permitData: null }, + ], + [ + 'swapCreate missing swap', + UniswapApiEndpointOutputSchemas.swapCreate, + { requestId: 'req-1' }, + ], + [ + 'swapCreate partial transaction', + UniswapApiEndpointOutputSchemas.swapCreate, + { requestId: 'req-1', swap: { to: transaction.to, data: '0x1234' } }, + ], + [ + 'swapGetStatus missing swaps', + UniswapApiEndpointOutputSchemas.swapGetStatus, + { requestId: 'req-1' }, + ], + [ + 'swapGetStatus partial row', + UniswapApiEndpointOutputSchemas.swapGetStatus, + { requestId: 'req-1', swaps: [{ txHash: '0xdead' }] }, + ], + [ + 'swapGetStatus unknown status', + UniswapApiEndpointOutputSchemas.swapGetStatus, + { requestId: 'req-1', swaps: [{ status: 'MAYBE' }] }, + ], + [ + 'orderGetStatus empty orders item', + UniswapApiEndpointOutputSchemas.orderGetStatus, + { requestId: 'req-1', orders: [{}] }, + ], + [ + 'orderGetStatus unknown status', + UniswapApiEndpointOutputSchemas.orderGetStatus, + { + requestId: 'req-1', + orders: [{ ...orderBase, orderStatus: 'unknown' }], + }, + ], + [ + 'delegationCheck partial delegation', + UniswapApiEndpointOutputSchemas.delegationCheck, + { + requestId: 'req-1', + delegationDetails: { + '0x1234567890abcdef1234567890abcdef12345678': { '1': {} }, + }, + }, + ], + [ + 'transactionEncode7702 missing encoded', + UniswapApiEndpointOutputSchemas.transactionEncode7702, + { requestId: 'req-1' }, + ], + [ + 'transactionEncode7702 empty calldata', + UniswapApiEndpointOutputSchemas.transactionEncode7702, + { requestId: 'req-1', encoded: { ...transaction, data: '0x' } }, + ], + [ + 'swappableTokensGet partial token', + UniswapApiEndpointOutputSchemas.swappableTokensGet, + { + requestId: 'req-1', + tokens: [{ address: '0xabc', chainId: 8453, name: 'Junk Token' }], + }, + ], + ])('rejects an incomplete %s response', (_name, schema, response) => { + expect(schema.safeParse(response).success).toBe(false); + }); +}); + +describe('Uniswap API input schemas', () => { + it.each([ + ['slippageTolerance mode', { ...quoteBase, slippageTolerance: 0.5 }], + ['autoSlippage mode', { ...quoteBase, autoSlippage: 'DEFAULT' }], + [ + 'urgent urgency', + { ...quoteBase, urgency: 'urgent', autoSlippage: 'DEFAULT' }, + ], + ])('accepts a quote input with %s', (_name, input) => { + expect( + UniswapApiEndpointInputSchemas.quoteGet.safeParse(input).success, + ).toBe(true); + }); + + it.each([ + ['neither slippage mode', quoteBase], + [ + 'both slippage modes', + { ...quoteBase, slippageTolerance: 0.5, autoSlippage: 'DEFAULT' }, + ], + ])('rejects a quote input with %s', (_name, input) => { + expect( + UniswapApiEndpointInputSchemas.quoteGet.safeParse(input).success, + ).toBe(false); + }); + + it.each([ + [ + 'signature and permitData together', + { quote: { a: 1 }, signature: '0xsig', permitData: { domain: {} } }, + ], + ['no permit fields', { quote: { a: 1 } }], + ['refreshGasPrice flag', { quote: { a: 1 }, refreshGasPrice: true }], + ])('accepts a swap create input with %s', (_name, input) => { + expect( + UniswapApiEndpointInputSchemas.swapCreate.safeParse(input).success, + ).toBe(true); + }); + + it.each([ + ['signature only', { quote: { a: 1 }, signature: '0xsig' }], + ['permitData only', { quote: { a: 1 }, permitData: { domain: {} } }], + ['empty quote', { quote: {} }], + ])('rejects a swap create input with %s', (_name, input) => { + expect( + UniswapApiEndpointInputSchemas.swapCreate.safeParse(input).success, + ).toBe(false); + }); + + it.each([ + ['txHashes', { txHashes: ['0xdead'], chainId: 1 }], + ['userOpHashes', { userOpHashes: ['0xbeef'], chainId: 1 }], + [ + 'hashes plus swapper filter', + { txHashes: ['0xdead'], chainId: 1, swapper: '0xabc' }, + ], + ])('accepts a swap status input with %s', (_name, input) => { + expect( + UniswapApiEndpointInputSchemas.swapGetStatus.safeParse(input).success, + ).toBe(true); + }); + + it('rejects a swap status input without the required chainId', () => { + expect( + UniswapApiEndpointInputSchemas.swapGetStatus.safeParse({ + txHashes: ['0xdead'], + }).success, + ).toBe(false); + }); + + it.each([ + ['no hashes', {}], + ['empty arrays', { txHashes: [], userOpHashes: [] }], + ])('rejects a swap status input with %s', (_name, input) => { + expect( + UniswapApiEndpointInputSchemas.swapGetStatus.safeParse(input).success, + ).toBe(false); + }); + + it.each([ + ['orderId', { orderId: ORDER_ID }], + [ + 'orderIds plus status filter', + { orderIds: [ORDER_ID], orderStatus: 'open' }, + ], + [ + 'orderId plus swapper and pagination', + { orderId: ORDER_ID, swapper: '0xabc', limit: 5, cursor: 'c' }, + ], + ])('accepts an order status input with %s', (_name, input) => { + expect( + UniswapApiEndpointInputSchemas.orderGetStatus.safeParse(input).success, + ).toBe(true); + }); + + it.each([ + ['no reference at all', {}], + ['orderStatus only', { orderStatus: 'open' }], + ['swapper only', { swapper: '0xabc' }], + ['filler only', { filler: '0xdef', limit: 5 }], + ['malformed orderId', { orderId: 'order-abc-123' }], + ])('rejects an order status input with %s', (_name, input) => { + expect( + UniswapApiEndpointInputSchemas.orderGetStatus.safeParse(input).success, + ).toBe(false); + }); + + it('accepts a delegation input and rejects empty arrays', () => { + const schema = UniswapApiEndpointInputSchemas.delegationCheck; + expect( + schema.safeParse({ + walletAddresses: ['0x1234567890abcdef1234567890abcdef12345678'], + chainIds: [1], + }).success, + ).toBe(true); + expect( + schema.safeParse({ walletAddresses: [], chainIds: [1] }).success, + ).toBe(false); + }); + + it('requires calls, delegation address, and wallet for encode7702 input', () => { + const schema = UniswapApiEndpointInputSchemas.transactionEncode7702; + expect( + schema.safeParse({ + calls: [{ ...transaction, data: '0x12345678' }], + smartContractDelegationAddress: + '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + walletAddress: '0x1234567890abcdef1234567890abcdef12345678', + }).success, + ).toBe(true); + expect( + schema.safeParse({ + calls: [], + smartContractDelegationAddress: + '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + walletAddress: '0x1234567890abcdef1234567890abcdef12345678', + }).success, + ).toBe(false); + }); + + it('rejects encode7702 calls without a 4-byte selector or with decimal wei', () => { + const schema = UniswapApiEndpointInputSchemas.transactionEncode7702; + expect( + schema.safeParse({ + calls: [{ ...transaction, data: '0x12' }], + smartContractDelegationAddress: + '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + walletAddress: '0x1234567890abcdef1234567890abcdef12345678', + }).success, + ).toBe(false); + expect( + schema.safeParse({ + calls: [ + { ...transaction, data: '0x12345678', value: '1000000000000000000' }, + ], + smartContractDelegationAddress: + '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + walletAddress: '0x1234567890abcdef1234567890abcdef12345678', + }).success, + ).toBe(false); + }); + + it('requires tokenIn and tokenInChainId for swappable tokens input', () => { + const schema = UniswapApiEndpointInputSchemas.swappableTokensGet; + expect( + schema.safeParse({ + tokenIn: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + tokenInChainId: 1, + }).success, + ).toBe(true); + expect( + schema.safeParse({ + tokenIn: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + }).success, + ).toBe(false); + }); +}); diff --git a/packages/uniswapapi/endpoints/types.ts b/packages/uniswapapi/endpoints/types.ts new file mode 100644 index 000000000..1f28f443a --- /dev/null +++ b/packages/uniswapapi/endpoints/types.ts @@ -0,0 +1,526 @@ +import type { EventLoggingContext } from 'corsair/core'; +import { z } from 'zod'; + +const TransactionDataSchema = z + .string() + .min(1) + .refine((data) => data !== '0x', { + message: 'Transaction calldata must not be empty', + }); + +// TransactionRequest per the Trading API spec: required fields are +// `to`, `data`, `value`, and `chainId`. `from` is declared required there too, +// but the gateway omits it on some approval transactions, so it stays optional +// here to avoid rejecting valid responses. +const TransactionRequestSchema = z + .object({ + to: z.string().min(1), + from: z.string().min(1).optional(), + data: TransactionDataSchema, + value: z.string(), + chainId: z.number(), + gasLimit: z.string().optional(), + maxFeePerGas: z.string().optional(), + maxPriorityFeePerGas: z.string().optional(), + gasPrice: z.string().optional(), + }) + .passthrough(); + +const NonEmptyObjectSchema = z + .record(z.string(), z.unknown()) + .refine((value) => Object.keys(value).length > 0, { + message: 'Expected result object to include provider data', + }); + +const RequestIdSchema = z.string().min(1); + +// ═══════════════════════════════════════════════════════════════════ +// 1. Check Approval — POST /v1/check_approval +// ═══════════════════════════════════════════════════════════════════ +const CheckApprovalInputSchema = z.object({ + token: z.string().describe('Token contract address'), + amount: z.string().describe('Amount to check approval for'), + walletAddress: z.string().describe('Wallet address to check'), + chainId: z.number().describe('Chain ID'), +}); +export type CheckApprovalInput = z.infer; + +const CheckApprovalResponseSchema = z + .object({ + requestId: RequestIdSchema, + approval: TransactionRequestSchema.nullable(), + cancel: TransactionRequestSchema.nullable().optional(), + gasFee: z.string().optional(), + cancelGasFee: z.string().optional(), + }) + .passthrough(); +export type CheckApprovalResponse = z.infer; + +// ═══════════════════════════════════════════════════════════════════ +// 2. Get Quote — POST /v1/quote +// ═══════════════════════════════════════════════════════════════════ +const GetQuoteInputSchema = z + .object({ + type: z.enum(['EXACT_INPUT', 'EXACT_OUTPUT']).describe('Swap type'), + tokenIn: z.string().describe('Input token contract address'), + tokenInChainId: z.number().describe('Input token chain ID'), + tokenOut: z.string().describe('Output token contract address'), + tokenOutChainId: z.number().describe('Output token chain ID'), + amount: z.string().describe('Token amount (in smallest unit)'), + swapper: z.string().describe('Address of the swapper wallet'), + slippageTolerance: z + .number() + .optional() + .describe('Slippage tolerance as a percentage (e.g. 0.5 = 0.5%)'), + autoSlippage: z + .enum(['DEFAULT']) + .optional() + .describe( + 'Let the API compute slippage automatically; cannot be combined with slippageTolerance', + ), + urgency: z + .enum(['normal', 'fast', 'urgent']) + .optional() + .describe('Trade urgency'), + recipient: z + .string() + .optional() + .describe('Recipient address, if different from swapper'), + protocols: z + .array(z.string()) + .optional() + .describe('Protocols to route through'), + }) + .superRefine((input, ctx) => { + // The Trading API requires exactly one of the two slippage modes: + // neither set, or both set, is rejected with a 400 by the API. + if ( + (input.slippageTolerance === undefined) === + (input.autoSlippage === undefined) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'Exactly one of slippageTolerance or autoSlippage must be provided', + }); + } + }); +export type GetQuoteInput = z.infer; + +const RoutingSchema = z.enum([ + 'CLASSIC', + 'DUTCH_LIMIT', + 'DUTCH_V2', + 'DUTCH_V3', + 'BRIDGE', + 'LIMIT_ORDER', + 'PRIORITY', + 'WRAP', + 'UNWRAP', + 'CHAINED', +]); + +const GetQuoteResponseSchema = z + .object({ + requestId: RequestIdSchema, + routing: RoutingSchema, + quote: NonEmptyObjectSchema, + // The API always returns permitData on quotes, as null when no + // Permit2 signature is needed for the route. + permitData: z.record(z.string(), z.unknown()).nullable(), + quoteId: z.string().optional(), + tokenIn: z.string().optional(), + tokenOut: z.string().optional(), + amountIn: z.string().optional(), + amountOut: z.string().optional(), + swapper: z.string().optional(), + gasEstimate: z.string().optional(), + gasFee: z.string().optional(), + gasFeeUSD: z.string().optional(), + route: z.array(z.array(z.record(z.string(), z.unknown()))).optional(), + routeString: z.string().optional(), + }) + .passthrough(); +export type GetQuoteResponse = z.infer; + +// ═══════════════════════════════════════════════════════════════════ +// 3. Create Swap — POST /v1/swap +// ═══════════════════════════════════════════════════════════════════ +const CreateSwapInputSchema = z + .object({ + quote: NonEmptyObjectSchema.describe( + 'The quote object returned from /v1/quote', + ), + signature: z + .string() + .optional() + .describe( + 'Signed Permit2 message; required when the quote returned permitData', + ), + permitData: z + .record(z.string(), z.unknown()) + .optional() + .describe( + 'Permit2 message from the quote; must be sent together with its signature', + ), + refreshGasPrice: z + .boolean() + .optional() + .describe('Whether to refresh gas price'), + simulateTransaction: z + .boolean() + .optional() + .describe('Whether to simulate the transaction'), + }) + .superRefine((input, ctx) => { + // The API rejects /v1/swap requests where only one of the pair is set. + const hasSignature = input.signature !== undefined; + const hasPermitData = input.permitData !== undefined; + if (hasSignature !== hasPermitData) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'signature and permitData must be provided together or omitted together', + }); + } + }); +export type CreateSwapInput = z.infer; + +const CreateSwapResponseSchema = z + .object({ + requestId: RequestIdSchema, + swap: TransactionRequestSchema, + gasFee: z.string().optional(), + }) + .passthrough(); +export type CreateSwapResponse = z.infer; + +// ═══════════════════════════════════════════════════════════════════ +// 4. Get Swap Status — GET /v1/swaps +// ═══════════════════════════════════════════════════════════════════ +const GetSwapStatusInputSchema = z + .object({ + txHashes: z + .array(z.string().min(1)) + .optional() + .describe('On-chain transaction hashes to query'), + userOpHashes: z + .array(z.string().min(1)) + .optional() + .describe('ERC-4337 userOperation hashes to query'), + // Required by the live API even though the published spec marks it + // optional — requests without chainId fail validation. + chainId: z.number().describe('Chain ID the transactions belong to'), + swapper: z + .string() + .optional() + .describe('Filter results by swapper address'), + }) + .superRefine((input, ctx) => { + const hasTxHashes = (input.txHashes?.length ?? 0) > 0; + const hasUserOpHashes = (input.userOpHashes?.length ?? 0) > 0; + if (!hasTxHashes && !hasUserOpHashes) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'At least one transaction hash or userOperation hash is required', + }); + } + }); +export type GetSwapStatusInput = z.infer; + +const SwapStatusValueSchema = z.enum([ + 'PENDING', + 'SUCCESS', + 'NOT_FOUND', + 'FAILED', + 'EXPIRED', +]); + +const SwapStatusRowSchema = z + .object({ + status: SwapStatusValueSchema, + swapType: RoutingSchema.optional(), + txHash: z.string().optional(), + userOpHash: z.string().optional(), + swapId: z.string().optional(), + hashType: z.enum(['TX', 'USER_OP']).optional(), + }) + .passthrough(); + +const GetSwapStatusResponseSchema = z + .object({ + requestId: RequestIdSchema, + swaps: z.array(SwapStatusRowSchema), + }) + .passthrough(); +export type GetSwapStatusResponse = z.infer; + +// ═══════════════════════════════════════════════════════════════════ +// 5. Get Gasless Orders — GET /v1/orders +// ═══════════════════════════════════════════════════════════════════ +const OrderStatusValueSchema = z.enum([ + 'open', + 'expired', + 'error', + 'cancelled', + 'filled', + 'unverified', + 'insufficient-funds', +]); + +const GaslessOrderSchema = z + .object({ + orderId: z.string(), + orderStatus: OrderStatusValueSchema, + chainId: z.number(), + type: z.enum(['DutchLimit', 'Dutch', 'Dutch_V2', 'Dutch_V3', 'Priority']), + encodedOrder: z.string().optional(), + signature: z.string().optional(), + nonce: z.string().optional(), + quoteId: z.string().optional(), + swapper: z.string().optional(), + txHash: z.string().optional(), + }) + .passthrough(); + +// UniswapX order IDs are 32-byte hex strings — the live API rejects any +// other format on /v1/orders. +const OrderIdSchema = z + .string() + .regex(/^0x[a-fA-F0-9]{64}$/, 'Order ID must be a 32-byte hex string'); + +const GetOrderStatusInputSchema = z + .object({ + orderId: OrderIdSchema.optional().describe('Single UniswapX order ID'), + orderIds: z + .array(OrderIdSchema) + .optional() + .describe('Multiple UniswapX order IDs'), + orderStatus: OrderStatusValueSchema.optional().describe( + 'Filter orders by status', + ), + swapper: z.string().optional().describe('Filter orders by swapper address'), + filler: z.string().optional().describe('Filter orders by filler address'), + limit: z + .number() + .int() + .positive() + .optional() + .describe('Maximum number of orders to return per page'), + cursor: z + .string() + .optional() + .describe('Pagination cursor from a previous response'), + sortKey: z + .enum(['createdAt']) + .optional() + .describe('Field to sort results by'), + }) + .superRefine((input, ctx) => { + // The live API rejects /v1/orders queries without an order ID — + // status/swapper/filler only narrow the results, they cannot drive + // the query on their own. + const hasOrderRef = + input.orderId !== undefined || (input.orderIds?.length ?? 0) > 0; + if (!hasOrderRef) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'At least one of orderId or orderIds is required', + }); + } + }); +export type GetOrderStatusInput = z.infer; + +const GetOrderStatusResponseSchema = z + .object({ + requestId: RequestIdSchema, + orders: z.array(GaslessOrderSchema), + cursor: z.string().optional(), + }) + .passthrough(); +export type GetOrderStatusResponse = z.infer< + typeof GetOrderStatusResponseSchema +>; + +// ═══════════════════════════════════════════════════════════════════ +// 6. Check Delegation — POST /v1/wallet/check_delegation +// ═══════════════════════════════════════════════════════════════════ +const CheckDelegationInputSchema = z.object({ + walletAddresses: z + .array(z.string().min(1)) + .min(1) + .describe('Wallet addresses to check delegation for'), + chainIds: z + .array(z.number()) + .min(1) + .describe('Chain IDs to check delegation status for'), +}); +export type CheckDelegationInput = z.infer; + +const DelegationDetailsSchema = z + .object({ + isWalletDelegatedToUniswap: z.boolean(), + // Null when the wallet does not currently delegate to any address. + currentDelegationAddress: z.string().nullable(), + latestDelegationAddress: z.string(), + }) + .passthrough(); + +// Response maps wallet address → chain ID (serialized as string map keys) +// → delegation details. +const CheckDelegationResponseSchema = z + .object({ + requestId: RequestIdSchema, + delegationDetails: z.record( + z.string(), + z.record(z.string(), DelegationDetailsSchema), + ), + }) + .passthrough(); +export type CheckDelegationResponse = z.infer< + typeof CheckDelegationResponseSchema +>; + +// ═══════════════════════════════════════════════════════════════════ +// 7. Encode 7702 Transaction — POST /v1/wallet/encode_7702 +// ═══════════════════════════════════════════════════════════════════ +const Encode7702CallSchema = z + .object({ + to: z.string().min(1), + from: z.string().min(1).optional(), + // The API requires calldata to start with a 4-byte selector + // (0x + at least 8 hex chars); shorter blobs fail validation. + data: z + .string() + .regex( + /^0x([a-fA-F0-9]{8})([a-fA-F0-9]*)$/, + 'Calldata must be hex starting with a 4-byte selector', + ), + // The API requires hex-encoded wei (e.g. "0x0"); decimal strings fail + // validation. + value: z + .string() + .regex(/^0x[a-fA-F0-9]+$/, 'Value must be a hex-encoded wei string'), + chainId: z.number(), + }) + .passthrough(); + +const Encode7702TransactionInputSchema = z.object({ + calls: z + .array(Encode7702CallSchema) + .min(1) + .describe('Transactions to encode; all calls must share the same chainId'), + smartContractDelegationAddress: z + .string() + .min(1) + .describe('Smart contract delegation implementation address to use'), + walletAddress: z + .string() + .min(1) + .describe('Wallet address the transactions are encoded for'), +}); +export type Encode7702TransactionInput = z.infer< + typeof Encode7702TransactionInputSchema +>; + +const Encode7702TransactionResponseSchema = z + .object({ + requestId: RequestIdSchema, + encoded: TransactionRequestSchema, + }) + .passthrough(); +export type Encode7702TransactionResponse = z.infer< + typeof Encode7702TransactionResponseSchema +>; + +// ═══════════════════════════════════════════════════════════════════ +// 8. Get Swappable Tokens — GET /v1/swappable_tokens +// ═══════════════════════════════════════════════════════════════════ +const GetSwappableTokensInputSchema = z.object({ + tokenIn: z.string().describe('Source token contract address'), + tokenInChainId: z.number().describe('Source token chain ID'), +}); +export type GetSwappableTokensInput = z.infer< + typeof GetSwappableTokensInputSchema +>; + +const SwappableTokenProjectSchema = z + .object({ + logo: z.record(z.string(), z.unknown()).nullable().optional(), + safetyLevel: z.string().optional(), + isSpam: z.boolean().optional(), + }) + .passthrough(); + +const SwappableTokenSchema = z + .object({ + address: z.string(), + chainId: z.number(), + name: z.string(), + symbol: z.string(), + decimals: z.number(), + project: SwappableTokenProjectSchema.optional(), + isSpam: z.boolean().optional(), + }) + .passthrough(); + +const GetSwappableTokensResponseSchema = z + .object({ + requestId: RequestIdSchema, + tokens: z.array(SwappableTokenSchema), + }) + .passthrough(); +export type GetSwappableTokensResponse = z.infer< + typeof GetSwappableTokensResponseSchema +>; + +// ═══════════════════════════════════════════════════════════════════ +// Aggregate types +// ═══════════════════════════════════════════════════════════════════ + +export type UniswapApiEndpointContext = EventLoggingContext & { key: string }; + +export type UniswapApiEndpointInputs = { + approvalCheck: CheckApprovalInput; + quoteGet: GetQuoteInput; + swapCreate: CreateSwapInput; + swapGetStatus: GetSwapStatusInput; + orderGetStatus: GetOrderStatusInput; + delegationCheck: CheckDelegationInput; + transactionEncode7702: Encode7702TransactionInput; + swappableTokensGet: GetSwappableTokensInput; +}; + +export type UniswapApiEndpointOutputs = { + approvalCheck: CheckApprovalResponse; + quoteGet: GetQuoteResponse; + swapCreate: CreateSwapResponse; + swapGetStatus: GetSwapStatusResponse; + orderGetStatus: GetOrderStatusResponse; + delegationCheck: CheckDelegationResponse; + transactionEncode7702: Encode7702TransactionResponse; + swappableTokensGet: GetSwappableTokensResponse; +}; + +export const UniswapApiEndpointInputSchemas = { + approvalCheck: CheckApprovalInputSchema, + quoteGet: GetQuoteInputSchema, + swapCreate: CreateSwapInputSchema, + swapGetStatus: GetSwapStatusInputSchema, + orderGetStatus: GetOrderStatusInputSchema, + delegationCheck: CheckDelegationInputSchema, + transactionEncode7702: Encode7702TransactionInputSchema, + swappableTokensGet: GetSwappableTokensInputSchema, +} as const; + +export const UniswapApiEndpointOutputSchemas = { + approvalCheck: CheckApprovalResponseSchema, + quoteGet: GetQuoteResponseSchema, + swapCreate: CreateSwapResponseSchema, + swapGetStatus: GetSwapStatusResponseSchema, + orderGetStatus: GetOrderStatusResponseSchema, + delegationCheck: CheckDelegationResponseSchema, + transactionEncode7702: Encode7702TransactionResponseSchema, + swappableTokensGet: GetSwappableTokensResponseSchema, +} as const; diff --git a/packages/uniswapapi/error-handlers.ts b/packages/uniswapapi/error-handlers.ts new file mode 100644 index 000000000..a4bfd17e3 --- /dev/null +++ b/packages/uniswapapi/error-handlers.ts @@ -0,0 +1,46 @@ +import type { CorsairErrorHandler } from 'corsair/core'; +import { ApiError } from 'corsair/http'; +import { UniswapApiAPIError } from './client'; + +function getStatus(error: Error): number | undefined { + if (error instanceof ApiError) return error.status; + if (error instanceof UniswapApiAPIError) return error.status; + return undefined; +} + +function getRetryAfter(error: Error): number | undefined { + if (error instanceof ApiError) return error.retryAfter; + if (error instanceof UniswapApiAPIError) return error.retryAfter; + return undefined; +} + +export const errorHandlers = { + RATE_LIMIT_ERROR: { + match: (error: Error) => { + if (getStatus(error) === 429) return true; + if (error instanceof UniswapApiAPIError) { + const code = error.code?.toLowerCase(); + if (code === 'too_many_requests' || code === 'rate_limited') { + return true; + } + } + const msg = error.message.toLowerCase(); + return msg.includes('rate_limited') || msg.includes('too many requests'); + }, + handler: async (error: Error) => { + return { maxRetries: 5, headersRetryAfterMs: getRetryAfter(error) }; + }, + }, + AUTH_ERROR: { + match: (error: Error) => { + if (getStatus(error) === 401) return true; + const msg = error.message.toLowerCase(); + return msg.includes('unauthorized') || msg.includes('invalid_auth'); + }, + handler: async () => ({ maxRetries: 0 }), + }, + DEFAULT: { + match: () => true, + handler: async () => ({ maxRetries: 0 }), + }, +} satisfies CorsairErrorHandler; diff --git a/packages/uniswapapi/index.ts b/packages/uniswapapi/index.ts new file mode 100644 index 000000000..bec74f126 --- /dev/null +++ b/packages/uniswapapi/index.ts @@ -0,0 +1,267 @@ +import type { + AuthTypes, + BindEndpoints, + CorsairEndpoint, + CorsairErrorHandler, + CorsairPlugin, + CorsairPluginContext, + KeyBuilderContext, + PickAuth, + PluginAuthConfig, + PluginPermissionsConfig, + RequiredPluginEndpointMeta, + RequiredPluginEndpointSchemas, +} from 'corsair/core'; +import { AuthMissingError } from 'corsair/core'; +import { + Approval, + Delegation, + Order, + Quote, + Swap, + SwappableTokens, + Transaction, +} from './endpoints'; +import type { + UniswapApiEndpointInputs, + UniswapApiEndpointOutputs, +} from './endpoints/types'; +import { + UniswapApiEndpointInputSchemas, + UniswapApiEndpointOutputSchemas, +} from './endpoints/types'; +import { errorHandlers } from './error-handlers'; +import { UniswapApiSchema } from './schema'; + +export type UniswapApiPluginOptions = { + authType?: PickAuth<'api_key'>; + key?: string; + hooks?: InternalUniswapApiPlugin['hooks']; + errorHandlers?: CorsairErrorHandler; + permissions?: PluginPermissionsConfig; +}; + +export type UniswapApiContext = CorsairPluginContext< + typeof UniswapApiSchema, + UniswapApiPluginOptions +>; + +export type UniswapApiKeyBuilderContext = + KeyBuilderContext; + +export type UniswapApiBoundEndpoints = BindEndpoints< + typeof uniswapApiEndpointsNested +>; + +type UniswapApiEndpoint = + CorsairEndpoint< + UniswapApiContext, + UniswapApiEndpointInputs[K], + UniswapApiEndpointOutputs[K] + >; + +export type UniswapApiEndpoints = { + approvalCheck: UniswapApiEndpoint<'approvalCheck'>; + quoteGet: UniswapApiEndpoint<'quoteGet'>; + swapCreate: UniswapApiEndpoint<'swapCreate'>; + swapGetStatus: UniswapApiEndpoint<'swapGetStatus'>; + orderGetStatus: UniswapApiEndpoint<'orderGetStatus'>; + delegationCheck: UniswapApiEndpoint<'delegationCheck'>; + transactionEncode7702: UniswapApiEndpoint<'transactionEncode7702'>; + swappableTokensGet: UniswapApiEndpoint<'swappableTokensGet'>; +}; + +const uniswapApiEndpointsNested = { + approval: { + check: Approval.check, + }, + quote: { + get: Quote.get, + }, + swap: { + create: Swap.create, + getStatus: Swap.getStatus, + }, + order: { + getStatus: Order.getStatus, + }, + delegation: { + check: Delegation.check, + }, + transaction: { + encode7702: Transaction.encode7702, + }, + swappableTokens: { + get: SwappableTokens.get, + }, +} as const; + +// No webhooks — Uniswap Trading API uses polling for status +const uniswapApiWebhooksNested = {} as const; + +export const uniswapApiEndpointSchemas = { + 'approval.check': { + input: UniswapApiEndpointInputSchemas.approvalCheck, + output: UniswapApiEndpointOutputSchemas.approvalCheck, + }, + 'quote.get': { + input: UniswapApiEndpointInputSchemas.quoteGet, + output: UniswapApiEndpointOutputSchemas.quoteGet, + }, + 'swap.create': { + input: UniswapApiEndpointInputSchemas.swapCreate, + output: UniswapApiEndpointOutputSchemas.swapCreate, + }, + 'swap.getStatus': { + input: UniswapApiEndpointInputSchemas.swapGetStatus, + output: UniswapApiEndpointOutputSchemas.swapGetStatus, + }, + 'order.getStatus': { + input: UniswapApiEndpointInputSchemas.orderGetStatus, + output: UniswapApiEndpointOutputSchemas.orderGetStatus, + }, + 'delegation.check': { + input: UniswapApiEndpointInputSchemas.delegationCheck, + output: UniswapApiEndpointOutputSchemas.delegationCheck, + }, + 'transaction.encode7702': { + input: UniswapApiEndpointInputSchemas.transactionEncode7702, + output: UniswapApiEndpointOutputSchemas.transactionEncode7702, + }, + 'swappableTokens.get': { + input: UniswapApiEndpointInputSchemas.swappableTokensGet, + output: UniswapApiEndpointOutputSchemas.swappableTokensGet, + }, +} as const satisfies RequiredPluginEndpointSchemas< + typeof uniswapApiEndpointsNested +>; + +const uniswapApiWebhookSchemas = {} as const; + +const defaultAuthType: AuthTypes = 'api_key'; + +const uniswapApiEndpointMeta = { + 'approval.check': { + riskLevel: 'read', + description: 'Check if a wallet has the required token approval for a swap', + }, + 'quote.get': { + riskLevel: 'read', + description: 'Get a swap/bridge/wrap quote with route and estimated gas', + }, + 'swap.create': { + riskLevel: 'write', + description: 'Create swap calldata (unsigned transaction) for broadcast', + }, + 'swap.getStatus': { + riskLevel: 'read', + description: + 'Get swap status (PENDING, SUCCESS, NOT_FOUND, FAILED, EXPIRED) by tx or userOp hash', + }, + 'order.getStatus': { + riskLevel: 'read', + description: 'Get the status and details of a gasless UniswapX order', + }, + 'delegation.check': { + riskLevel: 'read', + description: + 'Check wallet delegation status for smart contract wallets across chains', + }, + 'transaction.encode7702': { + riskLevel: 'write', + description: + 'Batch transactions into one for EIP-7702 smart contract wallet execution', + }, + 'swappableTokens.get': { + riskLevel: 'read', + description: + 'List tokens and chains a source token can be swapped or bridged to', + }, +} as const satisfies RequiredPluginEndpointMeta< + typeof uniswapApiEndpointsNested +>; + +export const uniswapApiAuthConfig = { + api_key: { + account: ['tenant_external_id'] as const, + }, +} as const satisfies PluginAuthConfig; + +export type BaseUniswapApiPlugin = + CorsairPlugin< + 'uniswapapi', + typeof UniswapApiSchema, + typeof uniswapApiEndpointsNested, + typeof uniswapApiWebhooksNested, + T, + typeof defaultAuthType + >; + +export type InternalUniswapApiPlugin = + BaseUniswapApiPlugin; + +export type ExternalUniswapApiPlugin = + BaseUniswapApiPlugin; + +export function uniswapapi( + incomingOptions: UniswapApiPluginOptions & T = {} as UniswapApiPluginOptions & + T, +): ExternalUniswapApiPlugin { + const options = { + ...incomingOptions, + authType: incomingOptions.authType ?? defaultAuthType, + }; + return { + id: 'uniswapapi', + authConfig: uniswapApiAuthConfig, + schema: UniswapApiSchema, + options: options, + hooks: options.hooks, + endpoints: uniswapApiEndpointsNested, + webhooks: uniswapApiWebhooksNested, + endpointMeta: uniswapApiEndpointMeta, + endpointSchemas: uniswapApiEndpointSchemas, + webhookSchemas: uniswapApiWebhookSchemas, + pluginWebhookMatcher: (_request) => { + // No webhooks — Uniswap Trading API uses polling + return false; + }, + errorHandlers: { + ...errorHandlers, + ...options.errorHandlers, + }, + keyBuilder: async (ctx: UniswapApiKeyBuilderContext, source) => { + if (source === 'endpoint' && options.key) { + return options.key; + } + + if (source === 'endpoint' && ctx.authType === 'api_key') { + const res = await ctx.keys.get_api_key(); + if (res) return res; + } + + throw new AuthMissingError('uniswapapi', 'api_key'); + }, + } satisfies InternalUniswapApiPlugin; +} + +export type { + CheckApprovalInput, + CheckApprovalResponse, + CheckDelegationInput, + CheckDelegationResponse, + CreateSwapInput, + CreateSwapResponse, + Encode7702TransactionInput, + Encode7702TransactionResponse, + GetOrderStatusInput, + GetOrderStatusResponse, + GetQuoteInput, + GetQuoteResponse, + GetSwappableTokensInput, + GetSwappableTokensResponse, + GetSwapStatusInput, + GetSwapStatusResponse, + UniswapApiEndpointInputs, + UniswapApiEndpointOutputs, +} from './endpoints/types'; diff --git a/packages/uniswapapi/jest.config.cjs b/packages/uniswapapi/jest.config.cjs new file mode 100644 index 000000000..8c6218f64 --- /dev/null +++ b/packages/uniswapapi/jest.config.cjs @@ -0,0 +1,55 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: [''], + testMatch: [ + '**/*.test.ts', + '**/tests/**/*.test.ts', + '**/plugins/**/*.test.ts', + '**/setup/**/*.test.ts', + ], + collectCoverageFrom: [ + '**/*.ts', + '!**/*.d.ts', + '!**/node_modules/**', + '!**/dist/**', + '!jest.config.ts', + '!tests/**', + ], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], + transform: { + '^.+\\.yaml$': '/../corsair/jest-yaml-transform.cjs', + '^.+\\.ts$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + verbatimModuleSyntax: false, + module: 'ESNext', + moduleResolution: 'Bundler', + }, + }, + ], + '.*\\.js$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + }, + }, + ], + }, + moduleNameMapper: { + '^corsair/core$': '/../corsair/core.ts', + '^corsair/http$': '/../corsair/http.ts', + '^(\\.\\.?/.*)\\.js$': '$1', + }, + transformIgnorePatterns: ['node_modules/(?!.*uuid.*)'], + extensionsToTreatAsEsm: ['.ts'], + testTimeout: 30000, + verbose: true, +}; diff --git a/packages/uniswapapi/package.json b/packages/uniswapapi/package.json new file mode 100644 index 000000000..bd269d16b --- /dev/null +++ b/packages/uniswapapi/package.json @@ -0,0 +1,44 @@ +{ + "name": "@corsair-dev/uniswapapi", + "version": "0.1.0", + "description": "Uniswap Trading API plugin for Corsair", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "dev-source": "./index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "rm -rf dist && tsc --build --force && tsup", + "typecheck": "tsc --noEmit", + "test": "jest" + }, + "peerDependencies": { + "corsair": ">=0.1.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "corsair": "workspace:*", + "jest": "^29.7.0", + "ts-jest": "^29.4.9", + "tsup": "^8.0.1", + "typescript": "catalog:", + "zod": "^4.1.13" + }, + "keywords": [ + "corsair", + "uniswapapi", + "plugin" + ], + "author": "", + "license": "Apache-2.0", + "files": [ + "dist" + ] +} diff --git a/packages/uniswapapi/schema.test.ts b/packages/uniswapapi/schema.test.ts new file mode 100644 index 000000000..c4310f54f --- /dev/null +++ b/packages/uniswapapi/schema.test.ts @@ -0,0 +1,38 @@ +import { UniswapApiSchema } from './schema'; +import { UniswapSwapStatus } from './schema/database'; + +describe('UniswapApi schema', () => { + it('declares a semver version', () => { + expect(UniswapApiSchema.version).toBeDefined(); + expect(UniswapApiSchema.version).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it('declares an entities map', () => { + expect(typeof UniswapApiSchema.entities).toBe('object'); + expect(UniswapApiSchema.entities).not.toBeNull(); + expect(Array.isArray(Object.keys(UniswapApiSchema.entities))).toBe(true); + for (const entity of Object.values(UniswapApiSchema.entities)) { + expect(entity).toBeDefined(); + } + }); + + it('accepts live Trading API swap statuses', () => { + expect( + UniswapSwapStatus.safeParse({ + txHash: '0xdead', + chainId: 1, + status: 'SUCCESS', + }).success, + ).toBe(true); + expect( + UniswapSwapStatus.safeParse({ + txHash: '0xdead', + chainId: 1, + status: 'confirmed', + }).success, + ).toBe(false); + }); +}); + +// Per .github/PLUGIN_PR_RULES.md (R2), every implemented endpoint +// needs a corresponding test. diff --git a/packages/uniswapapi/schema/database.ts b/packages/uniswapapi/schema/database.ts new file mode 100644 index 000000000..82e04ea82 --- /dev/null +++ b/packages/uniswapapi/schema/database.ts @@ -0,0 +1,62 @@ +import { z } from 'zod'; + +// ── Token ────────────────────────────────────────────────────────── +export const UniswapToken = z.object({ + chainId: z.number(), + address: z.string(), + decimals: z.number(), + symbol: z.string().optional(), + name: z.string().optional(), + buyFeeBps: z.string().optional(), + sellFeeBps: z.string().optional(), +}); +export type UniswapToken = z.infer; + +// ── Quote ────────────────────────────────────────────────────────── +export const UniswapQuote = z.object({ + requestId: z.string().optional(), + quoteId: z.string().optional(), + tokenIn: z.string(), + tokenOut: z.string(), + tokenInChainId: z.number(), + tokenOutChainId: z.number(), + amountIn: z.string().optional(), + amountOut: z.string().optional(), + swapper: z.string().optional(), + gasEstimate: z.string().optional(), + gasFeeUSD: z.string().optional(), + gasFeeQuote: z.string().optional(), + routeString: z.string().optional(), +}); +export type UniswapQuote = z.infer; + +// ── Swap Status ──────────────────────────────────────────────────── +export const UniswapSwapStatus = z.object({ + txHash: z.string(), + chainId: z.number(), + status: z + .enum(['PENDING', 'SUCCESS', 'NOT_FOUND', 'FAILED', 'EXPIRED']) + .optional(), +}); +export type UniswapSwapStatus = z.infer; + +// ── Approval ─────────────────────────────────────────────────────── +export const UniswapApproval = z.object({ + walletAddress: z.string(), + token: z.string(), + amount: z.string(), + chainId: z.number(), + approvalNeeded: z.boolean().optional(), +}); +export type UniswapApproval = z.infer; + +// ── Gasless (UniswapX) Order ─────────────────────────────────────── +export const UniswapGaslessOrder = z.object({ + orderId: z.string().optional(), + orderHash: z.string().optional(), + orderStatus: z.string().optional(), + chainId: z.number().optional(), + swapper: z.string().optional(), + txHash: z.string().optional(), +}); +export type UniswapGaslessOrder = z.infer; diff --git a/packages/uniswapapi/schema/index.ts b/packages/uniswapapi/schema/index.ts new file mode 100644 index 000000000..1581a1795 --- /dev/null +++ b/packages/uniswapapi/schema/index.ts @@ -0,0 +1,18 @@ +import { + UniswapApproval, + UniswapGaslessOrder, + UniswapQuote, + UniswapSwapStatus, + UniswapToken, +} from './database'; + +export const UniswapApiSchema = { + version: '1.0.0', + entities: { + token: UniswapToken, + quote: UniswapQuote, + swapStatus: UniswapSwapStatus, + approval: UniswapApproval, + gaslessOrder: UniswapGaslessOrder, + }, +} as const; diff --git a/packages/uniswapapi/tsconfig.json b/packages/uniswapapi/tsconfig.json new file mode 100644 index 000000000..15e507a13 --- /dev/null +++ b/packages/uniswapapi/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["esnext"], + "types": ["node", "jest"], + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "./dist", + "rootDir": "./", + "composite": true, + "incremental": true, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "skipLibCheck": true + }, + "include": ["./**/*"], + "exclude": ["dist", "node_modules"], + "references": [] +} diff --git a/packages/uniswapapi/tsup.config.ts b/packages/uniswapapi/tsup.config.ts new file mode 100644 index 000000000..3ec221e23 --- /dev/null +++ b/packages/uniswapapi/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + clean: false, + dts: false, + format: ['esm'], + target: 'esnext', + platform: 'node', + bundle: true, + splitting: true, + minify: true, + outDir: 'dist', + external: ['corsair', 'zod'], + entry: ['index.ts'], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18845437d..35efb08e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,7 +130,7 @@ importers: version: link:../../packages/slack corsair: specifier: ^0.1.4 - version: 0.1.119(postgres@3.4.7)(react@19.2.7) + version: link:../../packages/corsair dotenv: specifier: ^17.4.2 version: 17.4.2 @@ -737,7 +737,7 @@ importers: specifier: 4.4.3 version: 4.4.3 - packages/alphavantage: + packages/allimagesai: devDependencies: '@types/jest': specifier: ^29.5.14 @@ -761,7 +761,7 @@ importers: specifier: 4.4.3 version: 4.4.3 - packages/altoviz: + packages/alphavantage: devDependencies: '@types/jest': specifier: ^29.5.14 @@ -785,7 +785,7 @@ importers: specifier: 4.4.3 version: 4.4.3 - packages/alttextai: + packages/altoviz: devDependencies: '@types/jest': specifier: ^29.5.14 @@ -793,9 +793,6 @@ importers: corsair: specifier: workspace:* version: link:../corsair - dotenv: - specifier: ^17.2.3 - version: 17.4.2 jest: specifier: ^29.7.0 version: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) @@ -812,17 +809,17 @@ importers: specifier: 4.4.3 version: 4.4.3 - packages/amara: + packages/alttextai: devDependencies: '@types/jest': specifier: ^29.5.14 version: 29.5.14 - '@types/node': - specifier: ^24.10.1 - version: 24.10.1 corsair: specifier: workspace:* version: link:../corsair + dotenv: + specifier: ^17.2.3 + version: 17.4.2 jest: specifier: ^29.7.0 version: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) @@ -839,11 +836,14 @@ importers: specifier: 4.4.3 version: 4.4.3 - packages/ambee: + packages/amara: devDependencies: '@types/jest': specifier: ^29.5.14 version: 29.5.14 + '@types/node': + specifier: ^24.10.1 + version: 24.10.1 corsair: specifier: workspace:* version: link:../corsair @@ -863,7 +863,7 @@ importers: specifier: 4.4.3 version: 4.4.3 - packages/ambientweather: + packages/ambee: devDependencies: '@types/jest': specifier: ^29.5.14 @@ -887,7 +887,7 @@ importers: specifier: 4.4.3 version: 4.4.3 - packages/allimagesai: + packages/ambientweather: devDependencies: '@types/jest': specifier: ^29.5.14 @@ -4622,6 +4622,30 @@ importers: specifier: 4.4.3 version: 4.4.3 + packages/uniswapapi: + devDependencies: + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 + corsair: + specifier: workspace:* + version: link:../corsair + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) + ts-jest: + specifier: ^29.4.9 + version: 29.4.9(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.27.0)(jest-util@30.4.1)(jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3) + tsup: + specifier: ^8.0.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: 'catalog:' + version: 5.9.3 + zod: + specifier: 4.4.3 + version: 4.4.3 + packages/vapi: devDependencies: '@types/jest': @@ -6221,36 +6245,6 @@ packages: '@codemirror/view@6.43.3': resolution: {integrity: sha512-MwEwCAr/o0agJefhC2+reBv5kfOQpMcDRUNQrRYZgWlhH8IwQcerMZrpqWyUFSyO0ebgN2cnh/w87F7G4BGSng==} - '@corsair-dev/frpc-darwin-arm64@0.1.117': - resolution: {integrity: sha512-PrxfqHlRKzm5xW5J2wnZ1knVF4xV6svgmeo11YvHMAAUqsNBY6mczMQFlu1HJcMvZ9yysqMiTZBKdjAC+DnhlQ==} - cpu: [arm64] - os: [darwin] - - '@corsair-dev/frpc-darwin-x64@0.1.117': - resolution: {integrity: sha512-8GZP5G5kPhu/TPGLnSgecvyyxXB6+ZdwDmXAhz3PvqiE8mmQ2PRa7FaoVYdOa4nY8XWa5YmnXC/DcjsTLIfPmQ==} - cpu: [x64] - os: [darwin] - - '@corsair-dev/frpc-linux-arm64@0.1.117': - resolution: {integrity: sha512-TwVeRBYd17hy6YJByCUouMVBo2itK4fPuvm3TPwdq5ZVxN64PEKE2KHQIo+uW7opxcMXre6DVSzLQyFmrqI/Xg==} - cpu: [arm64] - os: [linux] - - '@corsair-dev/frpc-linux-x64@0.1.117': - resolution: {integrity: sha512-Ch98qSFQXYSA6sg2ysTKrRCZ08tNWfXjSEHKT3nMM7vYhuDXkQcBmXM4tbPEO8SI6BzLa9/g1lsXT5T0hx48dQ==} - cpu: [x64] - os: [linux] - - '@corsair-dev/frpc-win32-arm64@0.1.117': - resolution: {integrity: sha512-QyLWoaVNrq1C5V81d7DIOLm63n4+0YSKcKJK1MUTdaQAEroaejybeAuskiYhq11LCJ2wb+WTkv3qaWF0yLgg6g==} - cpu: [arm64] - os: [win32] - - '@corsair-dev/frpc-win32-x64@0.1.117': - resolution: {integrity: sha512-V4sI1JUwlNBi9nAJHedXPXzmivfi5DHaU2yJMwnf++5PQvVcxRMJQcec1p9+Q8bPyAZ7gtq+4XdRuBJVZXdFkg==} - cpu: [x64] - os: [win32] - '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -11305,14 +11299,6 @@ packages: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} - corsair@0.1.119: - resolution: {integrity: sha512-Cl86/VhMNOdzTbUzmeb9RwlhuucojM1gEmgTQJsQYdcxP2qFiPPly2VGK9M2h5GkHLZ3kZ4HRALTQP3wIhepBQ==} - peerDependencies: - react: '>=18.0.0' - peerDependenciesMeta: - react: - optional: true - cosmiconfig@9.0.1: resolution: {integrity: sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==} engines: {node: '>=14'} @@ -17700,24 +17686,6 @@ snapshots: style-mod: 4.1.3 w3c-keyname: 2.2.8 - '@corsair-dev/frpc-darwin-arm64@0.1.117': - optional: true - - '@corsair-dev/frpc-darwin-x64@0.1.117': - optional: true - - '@corsair-dev/frpc-linux-arm64@0.1.117': - optional: true - - '@corsair-dev/frpc-linux-x64@0.1.117': - optional: true - - '@corsair-dev/frpc-win32-arm64@0.1.117': - optional: true - - '@corsair-dev/frpc-win32-x64@0.1.117': - optional: true - '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 @@ -23251,23 +23219,6 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 - corsair@0.1.119(postgres@3.4.7)(react@19.2.7): - dependencies: - kysely: 0.28.17 - kysely-postgres-js: 3.0.0(kysely@0.28.17)(postgres@3.4.7) - uuid: 13.0.0 - zod: 4.4.3 - optionalDependencies: - '@corsair-dev/frpc-darwin-arm64': 0.1.117 - '@corsair-dev/frpc-darwin-x64': 0.1.117 - '@corsair-dev/frpc-linux-arm64': 0.1.117 - '@corsair-dev/frpc-linux-x64': 0.1.117 - '@corsair-dev/frpc-win32-arm64': 0.1.117 - '@corsair-dev/frpc-win32-x64': 0.1.117 - react: 19.2.7 - transitivePeerDependencies: - - postgres - cosmiconfig@9.0.1(typescript@5.9.3): dependencies: env-paths: 2.2.1 @@ -25495,12 +25446,6 @@ snapshots: kleur@4.1.5: {} - kysely-postgres-js@3.0.0(kysely@0.28.17)(postgres@3.4.7): - dependencies: - kysely: 0.28.17 - optionalDependencies: - postgres: 3.4.7 - kysely-postgres-js@3.0.0(kysely@0.28.9)(postgres@3.4.7): dependencies: kysely: 0.28.9