Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Odos Zap Support #1549

Merged
merged 6 commits into from
Sep 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const beefyfinance = {
strategyFactory: '0xeF7746F16e511242e25Ad4FF9732bb5fC35EAB50',
zap: '0xf49F7bB6F4F50d272A0914a671895c4384696E5A',
zapTokenManager: '0x3395BDAE49853Bc7Ab9377d2A93f42BC3A18680e',
treasurySwapper: '0x8Ccf8606ccf0Aff9937B68e0297967e257eB148b',

/// CLM Contracts
clmFactory: '0xD41Ce2c0a0596635FC09BDe2C35946a984b8cB7A',
Expand Down
1 change: 1 addition & 0 deletions packages/address-book/src/types/beefyfinance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface BeefyFinance {
vaultFactory?: string;
zap?: string;
zapTokenManager?: string;
treasurySwapper?: string;

/// BIFI Token Contracts
mooBifiLockbox?: string;
Expand Down
38 changes: 29 additions & 9 deletions src/api/zap/api/kyber/KyberApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,33 @@ import {
KyberResponse,
QuoteData,
QuoteRequest,
QuoteResponse,
SwapData,
SwapRequest,
SwapResponse,
} from './types';
import { mapValues, omitBy } from 'lodash';
import { redactSecrets } from '../../../../utils/secrets';
import { ApiResponse, isErrorApiResponse } from '../common';
import { ApiChain } from '../../../../utils/chain';
import { getZapProviderFee } from '../../fees';

export class KyberApi implements IKyberApi {
constructor(protected readonly baseUrl: string, protected readonly clientId: string) {}
readonly feeReceiver: string;
readonly ZAP_FEE: number;
constructor(protected readonly baseUrl: string, protected readonly clientId: string, chain: ApiChain) {
const feeData = getZapProviderFee('kyber', chain);
this.ZAP_FEE = feeData.value;
if (!feeData.receiver) {
throw new Error('No fee receiver found for Kyber on ' + chain);
}
this.feeReceiver = feeData.receiver;
}

protected buildUrl<T extends {}>(path: string, request?: T) {
const params = request ? new URLSearchParams(request).toString() : '';
return params ? `${this.baseUrl}${path}?${params}` : `${this.baseUrl}${path}`;
}

protected toStringDict(
obj: Record<string, string | number | boolean | string[]>
): Record<string, string> {
protected toStringDict(obj: Record<string, string | number | boolean | string[]>): Record<string, string> {
return mapValues(
omitBy(obj, v => v === undefined),
v => (Array.isArray(v) ? v.join(',') : String(v))
Expand All @@ -49,6 +56,20 @@ export class KyberApi implements IKyberApi {
};
}

protected withFeeReceiver(
request?: Record<string, string | number | boolean | string[]>
): Record<string, string | number | boolean | string[]> {
return this.feeReceiver && (this.ZAP_FEE || 0) > 0
? {
...request,
feeAmount: (this.ZAP_FEE * 10000).toString(10), // *10000 to bps
isInBps: true,
chargeFeeBy: 'currency_in',
feeReceiver: this.feeReceiver,
}
: request;
}

protected async doGet<ResponseType extends object>(
path: string,
request?: Record<string, string>
Expand Down Expand Up @@ -131,8 +152,7 @@ export class KyberApi implements IKyberApi {

return {
code: response.status === 200 ? 500 : response.status,
message:
response.status === 200 ? 'upstream response not json' : redactSecrets(response.statusText),
message: response.status === 200 ? 'upstream response not json' : redactSecrets(response.statusText),
};
}

Expand All @@ -157,7 +177,7 @@ export class KyberApi implements IKyberApi {
}

async getProxiedQuote(request: QuoteRequest): Promise<ApiResponse<QuoteData>> {
return await this.priorityGet<QuoteData>('/routes', this.toStringDict(request));
return await this.priorityGet<QuoteData>('/routes', this.toStringDict(this.withFeeReceiver(request)));
}

async postProxiedSwap(request: SwapRequest): Promise<ApiResponse<SwapData>> {
Expand Down
5 changes: 3 additions & 2 deletions src/api/zap/api/kyber/RateLimitedKyberApi.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { KyberApi } from './KyberApi';
import PQueue from 'p-queue';
import { ApiResponse } from '../common';
import { ApiChain } from '../../../../utils/chain';

export class RateLimitedKyberApi extends KyberApi {
constructor(baseUrl: string, clientId: string, protected readonly queue: PQueue) {
super(baseUrl, clientId);
constructor(baseUrl: string, clientId: string, protected readonly queue: PQueue, chain: ApiChain) {
super(baseUrl, clientId, chain);
}

protected async get<ResponseType extends object>(
Expand Down
2 changes: 1 addition & 1 deletion src/api/zap/api/kyber/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export function getKyberApi(chain: AnyChain): IKyberApi {
throw new Error(`KYBER_CLIENT_ID env variable is not set`);
}

swapApiByChain[apiChain] = new RateLimitedKyberApi(baseUrl, clientId, swapApiQueue);
swapApiByChain[apiChain] = new RateLimitedKyberApi(baseUrl, clientId, swapApiQueue, apiChain);
}

return swapApiByChain[apiChain];
Expand Down
141 changes: 141 additions & 0 deletions src/api/zap/api/odos/OdosApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { URLSearchParams } from 'url';
import {
IOdosApi,
isOdosErrorResponse,
QuoteRequest,
QuoteResponse,
SwapRequest,
SwapResponse,
} from './types';
import { redactSecrets } from '../../../../utils/secrets';
import { ApiResponse, isErrorApiResponse } from '../common';
import { getZapProviderFee } from '../../fees';
import { ApiChain, toChainId } from '../../../../utils/chain';

export class OdosApi implements IOdosApi {
readonly ZAP_FEE: number;
readonly referralCode: number;
readonly chainId: number;
constructor(protected readonly baseUrl: string, protected readonly chain: ApiChain) {
this.referralCode = Number(process.env.ODOS_CODE || 0);
this.ZAP_FEE = getZapProviderFee('odos', chain).value;
this.chainId = toChainId(chain);
if (this.chainId === undefined) {
throw new Error(`Invalid chain ${chain}`);
}
}

protected buildUrl<T extends {}>(path: string, request?: T) {
const params = request ? new URLSearchParams(request).toString() : '';
return params ? `${this.baseUrl}${path}?${params}` : `${this.baseUrl}${path}`;
}

protected withChainId(request?: Record<string, unknown>): Record<string, unknown> {
return {
...request,
chainId: this.chainId,
};
}

protected withReferralCode(request?: Record<string, unknown>): Record<string, unknown> {
return {
...request,
referralCode: this.referralCode,
};
}

protected buildHeaders(additionalHeaders?: Record<string, string>): Record<string, string> {
return {
Accept: 'application/json,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
'User-Agent': 'BeefyApi',
...additionalHeaders,
};
}

protected async doPost<ResponseType extends object>(
path: string,
request: Record<string, unknown>
): Promise<ApiResponse<ResponseType>> {
const url = this.buildUrl(path);

const response = await fetch(url, {
method: 'POST',
headers: this.buildHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify(this.withChainId(request)),
});

return this.handleResponse(response);
}

protected async post<ResponseType extends object>(
path: string,
request: Record<string, unknown>
): Promise<ApiResponse<ResponseType>> {
return this.doPost(path, request);
}

protected async priorityPost<ResponseType extends object>(
path: string,
request: Record<string, unknown>
): Promise<ApiResponse<ResponseType>> {
return this.doPost(path, request);
}

protected async handleResponse<ResponseType extends object>(
response: Response
): Promise<ApiResponse<ResponseType>> {
if (response.headers.get('content-type')?.includes('application/json')) {
const body = await response.json();

if (response.status === 200) {
return {
code: 200,
data: body as ResponseType,
};
}

if (isOdosErrorResponse(body)) {
return {
code: response.status === 200 ? 500 : response.status,
message: redactSecrets(body.detail),
};
}
}

return {
code: response.status === 200 ? 500 : response.status,
message: response.status === 200 ? 'upstream response not json' : redactSecrets(response.statusText),
};
}

async postQuote(request: QuoteRequest): Promise<QuoteResponse> {
const response = await this.post<QuoteResponse>('/sor/quote/v2', request);

if (isErrorApiResponse(response)) {
throw new Error(`Error fetching quote: ${response.code} ${response.message}`);
}

return response.data;
}

async postProxiedQuote(request: QuoteRequest): Promise<ApiResponse<QuoteResponse>> {
return await this.priorityPost<QuoteResponse>('/sor/quote/v2', this.withReferralCode(request));
}

async postSwap(request: SwapRequest): Promise<SwapResponse> {
const response = await this.post<SwapResponse>('/sor/assemble', request);

if (isErrorApiResponse(response)) {
throw new Error(`Error fetching swap: ${response.message}`);
}

return response.data;
}

async postProxiedSwap(request: SwapRequest): Promise<ApiResponse<SwapResponse>> {
return await this.priorityPost<SwapResponse>('/sor/assemble', request);
}
}
25 changes: 25 additions & 0 deletions src/api/zap/api/odos/RateLimitedOdosApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { OdosApi } from './OdosApi';
import PQueue from 'p-queue';
import { ApiResponse } from '../common';
import { ApiChain } from '../../../../utils/chain';

export class RateLimitedOdosApi extends OdosApi {
constructor(baseUrl: string, chain: ApiChain, protected readonly queue: PQueue) {
super(baseUrl, chain);
}

protected async post<ResponseType extends object>(
path: string,
request?: Record<string, string>
): Promise<ApiResponse<ResponseType>> {
return this.queue.add(() => super.post(path, request));
}

protected async priorityPost<ResponseType extends object>(
path: string,
request?: Record<string, unknown>
): Promise<ApiResponse<ResponseType>> {
// Rate limit, but higher priority than normal post, as these are used for app api proxy
return this.queue.add(() => super.priorityPost(path, request), { priority: 2 });
}
}
53 changes: 53 additions & 0 deletions src/api/zap/api/odos/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import PQueue from 'p-queue';
import { RateLimitedOdosApi } from './RateLimitedOdosApi';
import { AnyChain, ApiChain, toApiChain } from '../../../../utils/chain';
import { IOdosApi } from './types';

// Configure rate limiting
const API_QUEUE_CONFIG = {
concurrency: 2,
intervalCap: 1, // 1 per 400ms is 2.5 RPS
interval: 400,
carryoverConcurrencyCount: true,
autoStart: true,
timeout: 30 * 1000,
throwOnTimeout: true,
};

// @see https://docs.odos.xyz/api/endpoints/#/Info/get_chain_ids_info_chains_get
export const supportedChains: Partial<Record<ApiChain, number>> = {
ethereum: 1,
zksync: 324,
base: 8453,
mantle: 5000,
polygon: 137,
optimism: 10,
mode: 34443,
avax: 43114,
linea: 59144,
arbitrum: 42161,
bsc: 56,
fantom: 250,
} as const;

const swapApiByChain: Partial<Record<ApiChain, IOdosApi>> = {};
let swapApiQueue: PQueue | undefined;

export function getOdosApi(chain: AnyChain): IOdosApi {
const apiChain = toApiChain(chain);
const odosChain = supportedChains[apiChain];
if (!odosChain) {
throw new Error(`Odos api is not supported on ${apiChain}`);
}

if (!swapApiByChain[apiChain]) {
if (!swapApiQueue) {
swapApiQueue = new PQueue(API_QUEUE_CONFIG);
}

const baseUrl = `https://api.odos.xyz`;
swapApiByChain[apiChain] = new RateLimitedOdosApi(baseUrl, apiChain, swapApiQueue);
}

return swapApiByChain[apiChain];
}
Loading
Loading