pyth-crosschain/express_relay/sdk/js/src/index.ts

452 lines
14 KiB
TypeScript

import type { components, paths } from "./serverTypes";
import createClient, {
ClientOptions as FetchClientOptions,
} from "openapi-fetch";
import { Address, Hex, isAddress, isHex } from "viem";
import { privateKeyToAccount, signTypedData } from "viem/accounts";
import WebSocket from "isomorphic-ws";
import {
Bid,
BidId,
BidParams,
BidStatusUpdate,
Opportunity,
EIP712Domain,
OpportunityBid,
OpportunityParams,
TokenAmount,
} from "./types";
export * from "./types";
export class ClientError extends Error {}
type ClientOptions = FetchClientOptions & { baseUrl: string };
export interface WsOptions {
/**
* Max time to wait for a response from the server in milliseconds
*/
response_timeout: number;
}
const DEFAULT_WS_OPTIONS: WsOptions = {
response_timeout: 5000,
};
export function checkHex(hex: string): Hex {
if (isHex(hex)) {
return hex;
}
throw new ClientError(`Invalid hex: ${hex}`);
}
export function checkAddress(address: string): Address {
if (isAddress(address)) {
return address;
}
throw new ClientError(`Invalid address: ${address}`);
}
export function checkTokenQty(token: {
token: string;
amount: string;
}): TokenAmount {
return {
token: checkAddress(token.token),
amount: BigInt(token.amount),
};
}
export class Client {
public clientOptions: ClientOptions;
public wsOptions: WsOptions;
public websocket?: WebSocket;
public idCounter = 0;
public callbackRouter: Record<
string,
(response: components["schemas"]["ServerResultMessage"]) => void
> = {};
private websocketOpportunityCallback?: (
opportunity: Opportunity
) => Promise<void>;
private websocketBidStatusCallback?: (
statusUpdate: BidStatusUpdate
) => Promise<void>;
constructor(
clientOptions: ClientOptions,
wsOptions?: WsOptions,
opportunityCallback?: (opportunity: Opportunity) => Promise<void>,
bidStatusCallback?: (statusUpdate: BidStatusUpdate) => Promise<void>
) {
this.clientOptions = clientOptions;
this.wsOptions = { ...DEFAULT_WS_OPTIONS, ...wsOptions };
this.websocketOpportunityCallback = opportunityCallback;
this.websocketBidStatusCallback = bidStatusCallback;
}
private connectWebsocket() {
const websocketEndpoint = new URL(this.clientOptions.baseUrl);
websocketEndpoint.protocol =
websocketEndpoint.protocol === "https:" ? "wss:" : "ws:";
websocketEndpoint.pathname = "/v1/ws";
this.websocket = new WebSocket(websocketEndpoint.toString());
this.websocket.on("message", async (data: string) => {
const message:
| components["schemas"]["ServerResultResponse"]
| components["schemas"]["ServerUpdateResponse"] = JSON.parse(
data.toString()
);
if ("type" in message && message.type === "new_opportunity") {
if (this.websocketOpportunityCallback !== undefined) {
const convertedOpportunity = this.convertOpportunity(
message.opportunity
);
if (convertedOpportunity !== undefined) {
await this.websocketOpportunityCallback(convertedOpportunity);
}
}
} else if ("type" in message && message.type === "bid_status_update") {
if (this.websocketBidStatusCallback !== undefined) {
await this.websocketBidStatusCallback({
id: message.status.id,
...message.status.bid_status,
});
}
} else if ("id" in message && message.id) {
// Response to a request sent earlier via the websocket with the same id
const callback = this.callbackRouter[message.id];
if (callback !== undefined) {
callback(message);
delete this.callbackRouter[message.id];
}
} else if ("error" in message) {
// Can not route error messages to the callback router as they don't have an id
console.error(message.error);
}
});
}
private convertEIP712Domain(
eip712Domain: components["schemas"]["EIP712Domain"]
): EIP712Domain {
return {
name: eip712Domain.name,
version: eip712Domain.version,
verifyingContract: checkAddress(eip712Domain.verifying_contract),
chainId: BigInt(eip712Domain.chain_id),
};
}
/**
* Converts an opportunity from the server to the client format
* Returns undefined if the opportunity version is not supported
* @param opportunity
*/
private convertOpportunity(
opportunity: components["schemas"]["OpportunityParamsWithMetadata"]
): Opportunity | undefined {
if (opportunity.version != "v1") {
console.warn(
`Can not handle opportunity version: ${opportunity.version}. Please upgrade your client.`
);
return undefined;
}
return {
chainId: opportunity.chain_id,
opportunityId: opportunity.opportunity_id,
permissionKey: checkHex(opportunity.permission_key),
targetContract: checkAddress(opportunity.target_contract),
targetCalldata: checkHex(opportunity.target_calldata),
targetCallValue: BigInt(opportunity.target_call_value),
sellTokens: opportunity.sell_tokens.map(checkTokenQty),
buyTokens: opportunity.buy_tokens.map(checkTokenQty),
eip712Domain: this.convertEIP712Domain(opportunity.eip_712_domain),
};
}
/**
* Subscribes to the specified chains
*
* The opportunity handler will be called for opportunities on the specified chains
* If the opportunity handler is not set, an error will be thrown
* @param chains
*/
async subscribeChains(chains: string[]): Promise<void> {
if (this.websocketOpportunityCallback === undefined) {
throw new ClientError("Opportunity handler not set");
}
await this.requestViaWebsocket({
method: "subscribe",
params: {
chain_ids: chains,
},
});
}
/**
* Unsubscribes from the specified chains
*
* The opportunity handler will no longer be called for opportunities on the specified chains
* @param chains
*/
async unsubscribeChains(chains: string[]): Promise<void> {
await this.requestViaWebsocket({
method: "unsubscribe",
params: {
chain_ids: chains,
},
});
}
async requestViaWebsocket(
msg: components["schemas"]["ClientMessage"]
): Promise<components["schemas"]["APIResponse"] | null> {
const msg_with_id: components["schemas"]["ClientRequest"] = {
...msg,
id: (this.idCounter++).toString(),
};
return new Promise((resolve, reject) => {
this.callbackRouter[msg_with_id.id] = (response) => {
if (response.status === "success") {
resolve(response.result);
} else {
reject(response.result);
}
};
if (this.websocket === undefined) {
this.connectWebsocket();
}
if (this.websocket !== undefined) {
if (this.websocket.readyState === WebSocket.CONNECTING) {
this.websocket.on("open", () => {
this.websocket?.send(JSON.stringify(msg_with_id));
});
} else if (this.websocket.readyState === WebSocket.OPEN) {
this.websocket.send(JSON.stringify(msg_with_id));
} else {
reject("Websocket connection closing or already closed");
}
}
setTimeout(() => {
delete this.callbackRouter[msg_with_id.id];
reject("Websocket response timeout");
}, this.wsOptions.response_timeout);
});
}
/**
* Fetches opportunities
* @param chainId Chain id to fetch opportunities for. e.g: sepolia
*/
async getOpportunities(chainId?: string): Promise<Opportunity[]> {
const client = createClient<paths>(this.clientOptions);
const opportunities = await client.GET("/v1/opportunities", {
params: { query: { chain_id: chainId } },
});
if (opportunities.data === undefined) {
throw new ClientError("No opportunities found");
}
return opportunities.data.flatMap((opportunity) => {
const convertedOpportunity = this.convertOpportunity(opportunity);
if (convertedOpportunity === undefined) {
return [];
}
return convertedOpportunity;
});
}
/**
* Submits an opportunity to be exposed to searchers
* @param opportunity Opportunity to submit
*/
async submitOpportunity(opportunity: OpportunityParams) {
const client = createClient<paths>(this.clientOptions);
const response = await client.POST("/v1/opportunities", {
body: {
chain_id: opportunity.chainId,
version: "v1",
permission_key: opportunity.permissionKey,
target_contract: opportunity.targetContract,
target_calldata: opportunity.targetCalldata,
target_call_value: opportunity.targetCallValue.toString(),
sell_tokens: opportunity.sellTokens.map(({ token, amount }) => ({
token,
amount: amount.toString(),
})),
buy_tokens: opportunity.buyTokens.map(({ token, amount }) => ({
token,
amount: amount.toString(),
})),
},
});
if (response.error) {
throw new ClientError(response.error.error);
}
}
/**
* Creates a signed bid for an opportunity
* @param opportunity Opportunity to bid on
* @param bidParams Bid amount and valid until timestamp
* @param privateKey Private key to sign the bid with
*/
async signOpportunityBid(
opportunity: Opportunity,
bidParams: BidParams,
privateKey: Hex
): Promise<OpportunityBid> {
const types = {
SignedParams: [
{ name: "executionParams", type: "ExecutionParams" },
{ name: "signer", type: "address" },
{ name: "deadline", type: "uint256" },
],
ExecutionParams: [
{ name: "sellTokens", type: "TokenAmount[]" },
{ name: "buyTokens", type: "TokenAmount[]" },
{ name: "targetContract", type: "address" },
{ name: "targetCalldata", type: "bytes" },
{ name: "targetCallValue", type: "uint256" },
{ name: "bidAmount", type: "uint256" },
],
TokenAmount: [
{ name: "token", type: "address" },
{ name: "amount", type: "uint256" },
],
};
const account = privateKeyToAccount(privateKey);
const signature = await signTypedData({
privateKey,
domain: {
...opportunity.eip712Domain,
chainId: Number(opportunity.eip712Domain.chainId),
},
types,
primaryType: "SignedParams",
message: {
executionParams: {
sellTokens: opportunity.sellTokens,
buyTokens: opportunity.buyTokens,
targetContract: opportunity.targetContract,
targetCalldata: opportunity.targetCalldata,
targetCallValue: opportunity.targetCallValue,
bidAmount: bidParams.amount,
},
signer: account.address,
deadline: bidParams.validUntil,
},
});
return {
permissionKey: opportunity.permissionKey,
bid: bidParams,
executor: account.address,
signature,
opportunityId: opportunity.opportunityId,
};
}
private toServerOpportunityBid(
bid: OpportunityBid
): components["schemas"]["OpportunityBid"] {
return {
amount: bid.bid.amount.toString(),
executor: bid.executor,
permission_key: bid.permissionKey,
signature: bid.signature,
valid_until: bid.bid.validUntil.toString(),
};
}
private toServerBid(bid: Bid): components["schemas"]["Bid"] {
return {
amount: bid.amount.toString(),
target_calldata: bid.targetCalldata,
chain_id: bid.chainId,
target_contract: bid.targetContract,
permission_key: bid.permissionKey,
};
}
/**
* Submits a bid for an opportunity
* @param bid
* @param subscribeToUpdates If true, the client will subscribe to bid status updates via websocket and will call the bid status callback if set
* @returns The id of the submitted bid, you can use this id to track the status of the bid
*/
async submitOpportunityBid(
bid: OpportunityBid,
subscribeToUpdates = true
): Promise<BidId> {
const serverBid = this.toServerOpportunityBid(bid);
if (subscribeToUpdates) {
const result = await this.requestViaWebsocket({
method: "post_opportunity_bid",
params: {
opportunity_bid: serverBid,
opportunity_id: bid.opportunityId,
},
});
if (result === null) {
throw new ClientError("Empty response in websocket for bid submission");
}
return result.id;
} else {
const client = createClient<paths>(this.clientOptions);
const response = await client.POST(
"/v1/opportunities/{opportunity_id}/bids",
{
body: serverBid,
params: { path: { opportunity_id: bid.opportunityId } },
}
);
if (response.error) {
throw new ClientError(response.error.error);
} else if (response.data === undefined) {
throw new ClientError("No data returned");
} else {
return response.data.id;
}
}
}
/**
* Submits a raw bid for a permission key
* @param bid
* @param subscribeToUpdates If true, the client will subscribe to bid status updates via websocket and will call the bid status callback if set
* @returns The id of the submitted bid, you can use this id to track the status of the bid
*/
async submitBid(bid: Bid, subscribeToUpdates = true): Promise<BidId> {
const serverBid = this.toServerBid(bid);
if (subscribeToUpdates) {
const result = await this.requestViaWebsocket({
method: "post_bid",
params: {
bid: serverBid,
},
});
if (result === null) {
throw new ClientError("Empty response in websocket for bid submission");
}
return result.id;
} else {
const client = createClient<paths>(this.clientOptions);
const response = await client.POST("/v1/bids", {
body: serverBid,
});
if (response.error) {
throw new ClientError(response.error.error);
} else if (response.data === undefined) {
throw new ClientError("No data returned");
} else {
return response.data.id;
}
}
}
}