Skip to content

Commit

Permalink
Odos Zap Support (#1549)
Browse files Browse the repository at this point in the history
* odos zap support

* add arbitrum treasurySwapper address

* add fee data to zap requests

* allow for custom zap fee amounts per chain

* single function for zap aggregator fee

* typo
  • Loading branch information
seguido authored Sep 25, 2024
1 parent 7846868 commit 17040ee
Show file tree
Hide file tree
Showing 18 changed files with 545 additions and 21 deletions.
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

0 comments on commit 17040ee

Please sign in to comment.