Parcourir la source

feat(per-js-sdk): Add support for bid state and raw bid submission + renames (#1329)

* feat(per-js-sdk): Add support for bid state and raw bid submission

* Add custom error class for easier excpetion handling

* Merge websocket/http apis inside a single function

* Adapt to new names on the server

* Adapt field names
Amin Moghaddam il y a 1 an
Parent
commit
f14dd35d1a

+ 19 - 11
express_relay/sdk/js/README.md

@@ -30,26 +30,35 @@ npm run generate-api-types
 import {
   Client,
   OpportunityParams,
-  BidInfo,
+  BidParams,
 } from "@pythnetwork/express-relay-evm-js";
 
-const client = new Client({ baseUrl: "https://per-staging.dourolabs.app/" });
-
-function calculateOpportunityBid(opportunity: Opportunity): BidInfo | null {
+function calculateOpportunityBid(opportunity: Opportunity): BidParams | null {
   // searcher implementation here
   // if the opportunity is not suitable for the searcher, return null
 }
 
-client.setOpportunityHandler(async (opportunity: Opportunity) => {
-  const bidInfo = calculateOpportunityBid(opportunity);
-  if (bidInfo === null) return;
+async function bidStatusCallback(bidStatus: BidStatusUpdate) {
+  console.log(`Bid status for bid ${bidStatus.id}: ${bidStatus.status.status}`);
+}
+
+async function opportunityCallback(opportunity: Opportunity) {
+  const bidParams = calculateOpportunityBid(opportunity);
+  if (bidParams === null) return;
   const opportunityBid = await client.signOpportunityBid(
     opportunity,
-    bidInfo,
+    bidParams,
     privateKey // searcher private key with appropriate permissions and assets
   );
   await client.submitOpportunityBid(opportunityBid);
-});
+}
+
+const client = new Client(
+  { baseUrl: "https://per-staging.dourolabs.app/" },
+  bidStatusCallback,
+  opportunityCallback
+);
+
 await client.subscribeChains([chain_id]); // chain id you want to subscribe to
 ```
 
@@ -59,14 +68,13 @@ There is an example searcher in [examples](./src/examples/) directory.
 
 #### SimpleSearcher
 
-[This example](./src/examples/SimpleSearcher.ts) fetches `OpportunityParams` from the specified endpoint,
+[This example](./src/examples/simpleSearcher.ts) fetches `OpportunityParams` from the specified endpoint,
 creates a fixed bid on each opportunity and signs them with the provided private key, and finally submits them back to the server. You can run it with
 `npm run simple-searcher`. A full command looks like this:
 
 ```bash
 npm run simple-searcher -- \
   --endpoint https://per-staging.dourolabs.app/ \
-  --bid 100000 \
   --chain-id op_sepolia \
   --private-key <YOUR-PRIVATE-KEY>
 ```

+ 3 - 3
express_relay/sdk/js/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@pythnetwork/express-relay-evm-js",
-  "version": "0.1.1",
+  "version": "0.2.0",
   "description": "Utilities for interacting with the express relay protocol",
   "homepage": "https://github.com/pyth-network/pyth-crosschain/tree/main/express_relay/sdk/js",
   "author": "Douro Labs",
@@ -15,8 +15,8 @@
   "scripts": {
     "build": "tsc",
     "test": "jest src/ --passWithNoTests",
-    "simple-searcher": "npm run build && node lib/examples/SimpleSearcher.js",
-    "generate-api-types": "openapi-typescript http://127.0.0.1:9000/docs/openapi.json --output src/types.d.ts",
+    "simple-searcher": "npm run build && node lib/examples/simpleSearcher.js",
+    "generate-api-types": "openapi-typescript http://127.0.0.1:9000/docs/openapi.json --output src/serverTypes.d.ts",
     "format": "prettier --write \"src/**/*.ts\"",
     "lint": "eslint src",
     "prepublishOnly": "npm run build && npm test && npm run lint",

+ 70 - 36
express_relay/sdk/js/src/examples/SimpleSearcher.ts → express_relay/sdk/js/src/examples/simpleSearcher.ts

@@ -3,6 +3,70 @@ import { hideBin } from "yargs/helpers";
 import { checkHex, Client } from "../index";
 import { privateKeyToAccount } from "viem/accounts";
 import { isHex } from "viem";
+import { BidStatusUpdate, Opportunity } from "../types";
+
+const DAY_IN_SECONDS = 60 * 60 * 24;
+
+class SimpleSearcher {
+  private client: Client;
+  constructor(
+    public endpoint: string,
+    public chainId: string,
+    public privateKey: string
+  ) {
+    this.client = new Client(
+      { baseUrl: endpoint },
+      undefined,
+      this.opportunityHandler.bind(this),
+      this.bidStatusHandler.bind(this)
+    );
+  }
+
+  async bidStatusHandler(bidStatus: BidStatusUpdate) {
+    console.log(
+      `Bid status for bid ${bidStatus.id}: ${bidStatus.status} ${
+        bidStatus.status == "submitted" ? bidStatus.result : ""
+      }`
+    );
+  }
+
+  async opportunityHandler(opportunity: Opportunity) {
+    const bid = BigInt(argv.bid);
+    // Bid info should be generated by evaluating the opportunity
+    // here for simplicity we are using a constant bid and 24 hours of validity
+    const bidParams = {
+      amount: bid,
+      validUntil: BigInt(Math.round(Date.now() / 1000 + DAY_IN_SECONDS)),
+    };
+    const opportunityBid = await this.client.signOpportunityBid(
+      opportunity,
+      bidParams,
+      checkHex(argv.privateKey)
+    );
+    try {
+      const bidId = await this.client.submitOpportunityBid(opportunityBid);
+      console.log(
+        `Successful bid. Opportunity id ${opportunityBid.opportunityId} Bid id ${bidId}`
+      );
+    } catch (error) {
+      console.error(
+        `Failed to bid on opportunity ${opportunity.opportunityId}: ${error}`
+      );
+    }
+  }
+
+  async start() {
+    try {
+      await this.client.subscribeChains([argv.chainId]);
+      console.log(
+        `Subscribed to chain ${argv.chainId}. Waiting for opportunities...`
+      );
+    } catch (error) {
+      console.error(error);
+      this.client.websocket?.close();
+    }
+  }
+}
 
 const argv = yargs(hideBin(process.argv))
   .option("endpoint", {
@@ -30,49 +94,19 @@ const argv = yargs(hideBin(process.argv))
   .help()
   .alias("help", "h")
   .parseSync();
-
 async function run() {
-  const client = new Client({ baseUrl: argv.endpoint });
   if (isHex(argv.privateKey)) {
     const account = privateKeyToAccount(argv.privateKey);
     console.log(`Using account: ${account.address}`);
   } else {
     throw new Error(`Invalid private key: ${argv.privateKey}`);
   }
-  const DAY_IN_SECONDS = 60 * 60 * 24;
-  client.setOpportunityHandler(async (opportunity) => {
-    const bid = BigInt(argv.bid);
-    // Bid info should be generated by evaluating the opportunity
-    // here for simplicity we are using a constant bid and 24 hours of validity
-    const bidInfo = {
-      amount: bid,
-      validUntil: BigInt(Math.round(Date.now() / 1000 + DAY_IN_SECONDS)),
-    };
-    const opportunityBid = await client.signOpportunityBid(
-      opportunity,
-      bidInfo,
-      checkHex(argv.privateKey)
-    );
-    try {
-      await client.submitOpportunityBid(opportunityBid);
-      console.log(
-        `Successful bid ${bid} on opportunity ${opportunity.opportunityId}`
-      );
-    } catch (error) {
-      console.error(
-        `Failed to bid on opportunity ${opportunity.opportunityId}: ${error}`
-      );
-    }
-  });
-  try {
-    await client.subscribeChains([argv.chainId]);
-    console.log(
-      `Subscribed to chain ${argv.chainId}. Waiting for opportunities...`
-    );
-  } catch (error) {
-    console.error(error);
-    client.websocket?.close();
-  }
+  const searcher = new SimpleSearcher(
+    argv.endpoint,
+    argv.chainId,
+    argv.privateKey
+  );
+  await searcher.start();
 }
 
 run();

+ 188 - 170
express_relay/sdk/js/src/index.ts

@@ -1,11 +1,10 @@
-import type { paths, components } from "./types";
+import type { components, paths } from "./serverTypes";
 import createClient, {
   ClientOptions as FetchClientOptions,
 } from "openapi-fetch";
 import {
   Address,
   encodeAbiParameters,
-  encodePacked,
   Hex,
   isAddress,
   isHex,
@@ -13,126 +12,58 @@ import {
 } from "viem";
 import { privateKeyToAccount, sign, signatureToHex } from "viem/accounts";
 import WebSocket from "isomorphic-ws";
-/**
- * ERC20 token with contract address and amount
- */
-export type TokenQty = {
-  contract: Address;
-  amount: bigint;
-};
-
-/**
- * Bid information
- */
-export type BidInfo = {
-  /**
-   * Bid amount in wei
-   */
-  amount: bigint;
-  /**
-   * Unix timestamp for when the bid is no longer valid in seconds
-   */
-  validUntil: bigint;
-};
+import {
+  Bid,
+  BidId,
+  BidParams,
+  BidStatusUpdate,
+  Opportunity,
+  OpportunityBid,
+  OpportunityParams,
+  TokenAmount,
+} from "./types";
 
-/**
- * All the parameters necessary to represent a liquidation opportunity
- */
-export type Opportunity = {
-  /**
-   * The chain id where the liquidation will be executed.
-   */
-  chainId: string;
+export * from "./types";
 
-  /**
-   * Unique identifier for the opportunity
-   */
-  opportunityId: string;
-  /**
-   * Permission key required for succesful execution of the liquidation.
-   */
-  permissionKey: Hex;
-  /**
-   * Contract address to call for execution of the liquidation.
-   */
-  contract: Address;
-  /**
-   * Calldata for the contract call.
-   */
-  calldata: Hex;
-  /**
-   * Value to send with the contract call.
-   */
-  value: bigint;
+export class ClientError extends Error {}
 
-  /**
-   * Tokens required to repay the debt
-   */
-  repayTokens: TokenQty[];
-  /**
-   * Tokens to receive after the liquidation
-   */
-  receiptTokens: TokenQty[];
-};
+type ClientOptions = FetchClientOptions & { baseUrl: string };
 
-/**
- * Represents a bid for a liquidation opportunity
- */
-export type OpportunityBid = {
-  /**
-   * Opportunity unique identifier in uuid format
-   */
-  opportunityId: string;
-  /**
-   * The permission key required for succesful execution of the liquidation.
-   */
-  permissionKey: Hex;
-  /**
-   * Liquidator address
-   */
-  liquidator: Address;
+export interface WsOptions {
   /**
-   * Signature of the liquidator
+   * Max time to wait for a response from the server in milliseconds
    */
-  signature: Hex;
+  response_timeout: number;
+}
 
-  bid: BidInfo;
+const DEFAULT_WS_OPTIONS: WsOptions = {
+  response_timeout: 5000,
 };
 
 export function checkHex(hex: string): Hex {
   if (isHex(hex)) {
     return hex;
   }
-  throw new Error(`Invalid hex: ${hex}`);
+  throw new ClientError(`Invalid hex: ${hex}`);
 }
 
 export function checkAddress(address: string): Address {
   if (isAddress(address)) {
     return address;
   }
-  throw new Error(`Invalid address: ${address}`);
+  throw new ClientError(`Invalid address: ${address}`);
 }
 
-function checkTokenQty(token: { contract: string; amount: string }): TokenQty {
+export function checkTokenQty(token: {
+  token: string;
+  amount: string;
+}): TokenAmount {
   return {
-    contract: checkAddress(token.contract),
+    token: checkAddress(token.token),
     amount: BigInt(token.amount),
   };
 }
 
-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 class Client {
   public clientOptions: ClientOptions;
   public wsOptions: WsOptions;
@@ -146,9 +77,20 @@ export class Client {
     opportunity: Opportunity
   ) => Promise<void>;
 
-  constructor(clientOptions: ClientOptions, wsOptions?: WsOptions) {
+  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() {
@@ -164,13 +106,7 @@ export class Client {
         | components["schemas"]["ServerUpdateResponse"] = JSON.parse(
         data.toString()
       );
-      if ("id" in message && message.id) {
-        const callback = this.callbackRouter[message.id];
-        if (callback !== undefined) {
-          callback(message);
-          delete this.callbackRouter[message.id];
-        }
-      } else if ("type" in message && message.type === "new_opportunity") {
+      if ("type" in message && message.type === "new_opportunity") {
         if (this.websocketOpportunityCallback !== undefined) {
           const convertedOpportunity = this.convertOpportunity(
             message.opportunity
@@ -179,6 +115,20 @@ export class Client {
             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);
@@ -204,20 +154,14 @@ export class Client {
       chainId: opportunity.chain_id,
       opportunityId: opportunity.opportunity_id,
       permissionKey: checkHex(opportunity.permission_key),
-      contract: checkAddress(opportunity.contract),
-      calldata: checkHex(opportunity.calldata),
-      value: BigInt(opportunity.value),
-      repayTokens: opportunity.repay_tokens.map(checkTokenQty),
-      receiptTokens: opportunity.receipt_tokens.map(checkTokenQty),
+      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),
     };
   }
 
-  public setOpportunityHandler(
-    callback: (opportunity: Opportunity) => Promise<void>
-  ) {
-    this.websocketOpportunityCallback = callback;
-  }
-
   /**
    * Subscribes to the specified chains
    *
@@ -225,11 +169,11 @@ export class Client {
    * If the opportunity handler is not set, an error will be thrown
    * @param chains
    */
-  async subscribeChains(chains: string[]) {
+  async subscribeChains(chains: string[]): Promise<void> {
     if (this.websocketOpportunityCallback === undefined) {
-      throw new Error("Opportunity handler not set");
+      throw new ClientError("Opportunity handler not set");
     }
-    return this.sendWebsocketMessage({
+    await this.requestViaWebsocket({
       method: "subscribe",
       params: {
         chain_ids: chains,
@@ -243,8 +187,8 @@ export class Client {
    * The opportunity handler will no longer be called for opportunities on the specified chains
    * @param chains
    */
-  async unsubscribeChains(chains: string[]) {
-    return this.sendWebsocketMessage({
+  async unsubscribeChains(chains: string[]): Promise<void> {
+    await this.requestViaWebsocket({
       method: "unsubscribe",
       params: {
         chain_ids: chains,
@@ -252,9 +196,9 @@ export class Client {
     });
   }
 
-  async sendWebsocketMessage(
+  async requestViaWebsocket(
     msg: components["schemas"]["ClientMessage"]
-  ): Promise<void> {
+  ): Promise<components["schemas"]["APIResponse"] | null> {
     const msg_with_id: components["schemas"]["ClientRequest"] = {
       ...msg,
       id: (this.idCounter++).toString(),
@@ -262,7 +206,7 @@ export class Client {
     return new Promise((resolve, reject) => {
       this.callbackRouter[msg_with_id.id] = (response) => {
         if (response.status === "success") {
-          resolve();
+          resolve(response.result);
         } else {
           reject(response.result);
         }
@@ -289,16 +233,16 @@ export class Client {
   }
 
   /**
-   * Fetches liquidation opportunities
+   * 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/liquidation/opportunities", {
+    const opportunities = await client.GET("/v1/opportunities", {
       params: { query: { chain_id: chainId } },
     });
     if (opportunities.data === undefined) {
-      throw new Error("No opportunities found");
+      throw new ClientError("No opportunities found");
     }
     return opportunities.data.flatMap((opportunity) => {
       const convertedOpportunity = this.convertOpportunity(opportunity);
@@ -310,49 +254,49 @@ export class Client {
   }
 
   /**
-   * Submits a liquidation opportunity to be exposed to searchers
+   * Submits an opportunity to be exposed to searchers
    * @param opportunity Opportunity to submit
    */
-  async submitOpportunity(opportunity: Omit<Opportunity, "opportunityId">) {
+  async submitOpportunity(opportunity: OpportunityParams) {
     const client = createClient<paths>(this.clientOptions);
-    const response = await client.POST("/v1/liquidation/opportunities", {
+    const response = await client.POST("/v1/opportunities", {
       body: {
         chain_id: opportunity.chainId,
         version: "v1",
         permission_key: opportunity.permissionKey,
-        contract: opportunity.contract,
-        calldata: opportunity.calldata,
-        value: opportunity.value.toString(),
-        repay_tokens: opportunity.repayTokens.map((token) => ({
-          contract: token.contract,
-          amount: token.amount.toString(),
+        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(),
         })),
-        receipt_tokens: opportunity.receiptTokens.map((token) => ({
-          contract: token.contract,
-          amount: token.amount.toString(),
+        buy_tokens: opportunity.buyTokens.map(({ token, amount }) => ({
+          token,
+          amount: amount.toString(),
         })),
       },
     });
     if (response.error) {
-      throw new Error(response.error.error);
+      throw new ClientError(response.error.error);
     }
   }
 
   /**
-   * Creates a signed bid for a liquidation opportunity
+   * Creates a signed bid for an opportunity
    * @param opportunity Opportunity to bid on
-   * @param bidInfo Bid amount and valid until timestamp
+   * @param bidParams Bid amount and valid until timestamp
    * @param privateKey Private key to sign the bid with
    */
   async signOpportunityBid(
     opportunity: Opportunity,
-    bidInfo: BidInfo,
+    bidParams: BidParams,
     privateKey: Hex
   ): Promise<OpportunityBid> {
     const account = privateKeyToAccount(privateKey);
-    const convertTokenQty = (token: TokenQty): [Hex, bigint] => [
-      token.contract,
-      token.amount,
+    const convertTokenQty = ({ token, amount }: TokenAmount): [Hex, bigint] => [
+      token,
+      amount,
     ];
     const payload = encodeAbiParameters(
       [
@@ -387,13 +331,13 @@ export class Client {
         { name: "validUntil", type: "uint256" },
       ],
       [
-        opportunity.repayTokens.map(convertTokenQty),
-        opportunity.receiptTokens.map(convertTokenQty),
-        opportunity.contract,
-        opportunity.calldata,
-        opportunity.value,
-        bidInfo.amount,
-        bidInfo.validUntil,
+        opportunity.sellTokens.map(convertTokenQty),
+        opportunity.buyTokens.map(convertTokenQty),
+        opportunity.targetContract,
+        opportunity.targetCalldata,
+        opportunity.targetCallValue,
+        bidParams.amount,
+        bidParams.validUntil,
       ]
     );
 
@@ -402,34 +346,108 @@ export class Client {
     const hash = signatureToHex(await sign({ hash: msgHash, privateKey }));
     return {
       permissionKey: opportunity.permissionKey,
-      bid: bidInfo,
-      liquidator: account.address,
+      bid: bidParams,
+      executor: account.address,
       signature: hash,
       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 a liquidation opportunity
+   * 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) {
-    const client = createClient<paths>(this.clientOptions);
-    const response = await client.POST(
-      "/v1/liquidation/opportunities/{opportunity_id}/bids",
-      {
-        body: {
-          amount: bid.bid.amount.toString(),
-          liquidator: bid.liquidator,
-          permission_key: bid.permissionKey,
-          signature: bid.signature,
-          valid_until: bid.bid.validUntil.toString(),
+  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,
         },
-        params: { path: { 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;
       }
-    );
-    if (response.error) {
-      throw new Error(response.error.error);
     }
   }
 }

+ 174 - 69
express_relay/sdk/js/src/types.d.ts → express_relay/sdk/js/src/serverTypes.d.ts

@@ -10,31 +10,38 @@ export interface paths {
      * @description Bid on a specific permission key for a specific chain.
      *
      * Your bid will be simulated and verified by the server. Depending on the outcome of the auction, a transaction
-     * containing the contract call will be sent to the blockchain expecting the bid amount to be paid after the call.
+     * containing the targetContract call will be sent to the blockchain expecting the bid amount to be paid after the call.
      */
     post: operations["bid"];
   };
-  "/v1/liquidation/opportunities": {
+  "/v1/bids/{bid_id}": {
     /**
-     * Fetch all liquidation opportunities ready to be exectued.
-     * @description Fetch all liquidation opportunities ready to be exectued.
+     * Query the status of a specific bid.
+     * @description Query the status of a specific bid.
+     */
+    get: operations["bid_status"];
+  };
+  "/v1/opportunities": {
+    /**
+     * Fetch all opportunities ready to be exectued.
+     * @description Fetch all opportunities ready to be exectued.
      */
     get: operations["get_opportunities"];
     /**
-     * Submit a liquidation opportunity ready to be executed.
-     * @description Submit a liquidation opportunity ready to be executed.
+     * Submit an opportunity ready to be executed.
+     * @description Submit an opportunity ready to be executed.
      *
      * The opportunity will be verified by the server. If the opportunity is valid, it will be stored in the database
      * and will be available for bidding.
      */
     post: operations["post_opportunity"];
   };
-  "/v1/liquidation/opportunities/{opportunity_id}/bids": {
+  "/v1/opportunities/{opportunity_id}/bids": {
     /**
-     * Bid on liquidation opportunity
-     * @description Bid on liquidation opportunity
+     * Bid on opportunity
+     * @description Bid on opportunity
      */
-    post: operations["post_bid"];
+    post: operations["opportunity_bid"];
   };
 }
 
@@ -42,49 +49,92 @@ export type webhooks = Record<string, never>;
 
 export interface components {
   schemas: {
+    APIResponse: components["schemas"]["BidResult"];
     Bid: {
       /**
        * @description Amount of bid in wei.
        * @example 10
        */
       amount: string;
-      /**
-       * @description Calldata for the contract call.
-       * @example 0xdeadbeef
-       */
-      calldata: string;
       /**
        * @description The chain id to bid on.
        * @example sepolia
        */
       chain_id: string;
-      /**
-       * @description The contract address to call.
-       * @example 0xcA11bde05977b3631167028862bE2a173976CA11
-       */
-      contract: string;
       /**
        * @description The permission key to bid on.
        * @example 0xdeadbeef
        */
       permission_key: string;
+      /**
+       * @description Calldata for the targetContract call.
+       * @example 0xdeadbeef
+       */
+      target_calldata: string;
+      /**
+       * @description The targetContract address to call.
+       * @example 0xcA11bde05977b3631167028862bE2a173976CA11
+       */
+      target_contract: string;
     };
     BidResult: {
+      /**
+       * @description The unique id created to identify the bid. This id can be used to query the status of the bid.
+       * @example beedbeed-58cc-4372-a567-0e02b2c3d479
+       */
+      id: string;
       status: string;
     };
+    BidStatus:
+      | {
+          /** @enum {string} */
+          status: "pending";
+        }
+      | {
+          /**
+           * @description The bid won the auction and was submitted to the chain in a transaction with the given hash
+           * @example 0x103d4fbd777a36311b5161f2062490f761f25b67406badb2bace62bb170aa4e3
+           */
+          result: string;
+          /** @enum {string} */
+          status: "submitted";
+        }
+      | {
+          /** @enum {string} */
+          status: "lost";
+        };
+    BidStatusWithId: {
+      bid_status: components["schemas"]["BidStatus"];
+      id: string;
+    };
     ClientMessage:
       | {
           /** @enum {string} */
           method: "subscribe";
           params: {
-            chain_ids: components["schemas"]["ChainId"][];
+            chain_ids: string[];
           };
         }
       | {
           /** @enum {string} */
           method: "unsubscribe";
           params: {
-            chain_ids: components["schemas"]["ChainId"][];
+            chain_ids: string[];
+          };
+        }
+      | {
+          /** @enum {string} */
+          method: "post_bid";
+          params: {
+            bid: components["schemas"]["Bid"];
+          };
+        }
+      | {
+          /** @enum {string} */
+          method: "post_opportunity_bid";
+          params: {
+            opportunity_bid: components["schemas"]["OpportunityBid"];
+            opportunity_id: string;
           };
         };
     ClientRequest: components["schemas"]["ClientMessage"] & {
@@ -100,10 +150,10 @@ export interface components {
        */
       amount: string;
       /**
-       * @description Liquidator address
+       * @description Executor address
        * @example 0x5FbDB2315678afecb367f032d93F642f64180aa2
        */
-      liquidator: string;
+      executor: string;
       /**
        * @description The opportunity permission key
        * @example 0xdeadbeefcafe
@@ -123,50 +173,59 @@ export interface components {
     };
     /**
      * @description Opportunity parameters needed for on-chain execution
-     * If a searcher signs the opportunity and have approved enough tokens to liquidation adapter,
-     * by calling this contract with the given calldata and structures, they will receive the tokens specified
-     * in the receipt_tokens field, and will send the tokens specified in the repay_tokens field.
+     * If a searcher signs the opportunity and have approved enough tokens to opportunity adapter,
+     * by calling this target targetContract with the given target targetCalldata and structures, they will
+     * send the tokens specified in the sell_tokens field and receive the tokens specified in the buy_tokens field.
      */
     OpportunityParamsV1: {
+      buy_tokens: components["schemas"]["TokenAmount"][];
       /**
-       * @description Calldata for the contract call.
-       * @example 0xdeadbeef
-       */
-      calldata: string;
-      /**
-       * @description The chain id where the liquidation will be executed.
+       * @description The chain id where the opportunity will be executed.
        * @example sepolia
        */
       chain_id: string;
       /**
-       * @description The contract address to call for execution of the liquidation.
-       * @example 0xcA11bde05977b3631167028862bE2a173976CA11
-       */
-      contract: string;
-      /**
-       * @description The permission key required for succesful execution of the liquidation.
+       * @description The permission key required for successful execution of the opportunity.
        * @example 0xdeadbeefcafe
        */
       permission_key: string;
-      receipt_tokens: components["schemas"]["TokenQty"][];
-      repay_tokens: components["schemas"]["TokenQty"][];
+      sell_tokens: components["schemas"]["TokenAmount"][];
       /**
-       * @description The value to send with the contract call.
+       * @description The targetCallValue to send with the targetContract call.
        * @example 1
        */
-      value: string;
+      target_call_value: string;
+      /**
+       * @description Calldata for the target targetContract call.
+       * @example 0xdeadbeef
+       */
+      target_calldata: string;
+      /**
+       * @description The targetContract address to call for execution of the opportunity.
+       * @example 0xcA11bde05977b3631167028862bE2a173976CA11
+       */
+      target_contract: string;
     };
     /** @description Similar to OpportunityParams, but with the opportunity id included. */
-    OpportunityParamsWithMetadata: components["schemas"]["OpportunityParams"] & {
-      creation_time: components["schemas"]["UnixTimestamp"];
+    OpportunityParamsWithMetadata: (components["schemas"]["OpportunityParamsV1"] & {
+      /** @enum {string} */
+      version: "v1";
+    }) & {
+      /**
+       * Format: int64
+       * @description Creation time of the opportunity
+       * @example 1700000000
+       */
+      creation_time: number;
       /**
        * @description The opportunity unique id
-       * @example f47ac10b-58cc-4372-a567-0e02b2c3d479
+       * @example obo3ee3e-58cc-4372-a567-0e02b2c3d479
        */
       opportunity_id: string;
     };
     ServerResultMessage:
       | {
+          result: components["schemas"]["APIResponse"] | null;
           /** @enum {string} */
           status: "success";
         }
@@ -183,28 +242,39 @@ export interface components {
       id?: string | null;
     };
     /** @description This enum is used to send an update to the client for any subscriptions made */
-    ServerUpdateResponse: {
-      opportunity: components["schemas"]["OpportunityParamsWithMetadata"];
-      /** @enum {string} */
-      type: "new_opportunity";
-    };
-    TokenQty: {
+    ServerUpdateResponse:
+      | {
+          opportunity: components["schemas"]["OpportunityParamsWithMetadata"];
+          /** @enum {string} */
+          type: "new_opportunity";
+        }
+      | {
+          status: components["schemas"]["BidStatusWithId"];
+          /** @enum {string} */
+          type: "bid_status_update";
+        };
+    TokenAmount: {
       /**
        * @description Token amount
        * @example 1000
        */
       amount: string;
       /**
-       * @description Token contract address
+       * @description Token targetContract address
        * @example 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
        */
-      contract: string;
+      token: string;
     };
   };
   responses: {
     BidResult: {
       content: {
         "application/json": {
+          /**
+           * @description The unique id created to identify the bid. This id can be used to query the status of the bid.
+           * @example beedbeed-58cc-4372-a567-0e02b2c3d479
+           */
+          id: string;
           status: string;
         };
       };
@@ -220,11 +290,19 @@ export interface components {
     /** @description Similar to OpportunityParams, but with the opportunity id included. */
     OpportunityParamsWithMetadata: {
       content: {
-        "application/json": components["schemas"]["OpportunityParams"] & {
-          creation_time: components["schemas"]["UnixTimestamp"];
+        "application/json": (components["schemas"]["OpportunityParamsV1"] & {
+          /** @enum {string} */
+          version: "v1";
+        }) & {
+          /**
+           * Format: int64
+           * @description Creation time of the opportunity
+           * @example 1700000000
+           */
+          creation_time: number;
           /**
            * @description The opportunity unique id
-           * @example f47ac10b-58cc-4372-a567-0e02b2c3d479
+           * @example obo3ee3e-58cc-4372-a567-0e02b2c3d479
            */
           opportunity_id: string;
         };
@@ -247,7 +325,7 @@ export interface operations {
    * @description Bid on a specific permission key for a specific chain.
    *
    * Your bid will be simulated and verified by the server. Depending on the outcome of the auction, a transaction
-   * containing the contract call will be sent to the blockchain expecting the bid amount to be paid after the call.
+   * containing the targetContract call will be sent to the blockchain expecting the bid amount to be paid after the call.
    */
   bid: {
     requestBody: {
@@ -256,7 +334,7 @@ export interface operations {
       };
     };
     responses: {
-      /** @description Bid was placed succesfully */
+      /** @description Bid was placed successfully */
       200: {
         content: {
           "application/json": components["schemas"]["BidResult"];
@@ -272,8 +350,35 @@ export interface operations {
     };
   };
   /**
-   * Fetch all liquidation opportunities ready to be exectued.
-   * @description Fetch all liquidation opportunities ready to be exectued.
+   * Query the status of a specific bid.
+   * @description Query the status of a specific bid.
+   */
+  bid_status: {
+    parameters: {
+      path: {
+        /** @description Bid id to query for */
+        bid_id: string;
+      };
+    };
+    responses: {
+      /** @description Latest status of the bid */
+      200: {
+        content: {
+          "application/json": components["schemas"]["BidStatus"];
+        };
+      };
+      400: components["responses"]["ErrorBodyResponse"];
+      /** @description Bid was not found */
+      404: {
+        content: {
+          "application/json": components["schemas"]["ErrorBodyResponse"];
+        };
+      };
+    };
+  };
+  /**
+   * Fetch all opportunities ready to be exectued.
+   * @description Fetch all opportunities ready to be exectued.
    */
   get_opportunities: {
     parameters: {
@@ -283,10 +388,10 @@ export interface operations {
       };
     };
     responses: {
-      /** @description Array of liquidation opportunities ready for bidding */
+      /** @description Array of opportunities ready for bidding */
       200: {
         content: {
-          "application/json": components["schemas"]["OpportunityParamsWithId"][];
+          "application/json": components["schemas"]["OpportunityParamsWithMetadata"][];
         };
       };
       400: components["responses"]["ErrorBodyResponse"];
@@ -299,8 +404,8 @@ export interface operations {
     };
   };
   /**
-   * Submit a liquidation opportunity ready to be executed.
-   * @description Submit a liquidation opportunity ready to be executed.
+   * Submit an opportunity ready to be executed.
+   * @description Submit an opportunity ready to be executed.
    *
    * The opportunity will be verified by the server. If the opportunity is valid, it will be stored in the database
    * and will be available for bidding.
@@ -315,7 +420,7 @@ export interface operations {
       /** @description The created opportunity */
       200: {
         content: {
-          "application/json": components["schemas"]["OpportunityParamsWithId"];
+          "application/json": components["schemas"]["OpportunityParamsWithMetadata"];
         };
       };
       400: components["responses"]["ErrorBodyResponse"];
@@ -328,10 +433,10 @@ export interface operations {
     };
   };
   /**
-   * Bid on liquidation opportunity
-   * @description Bid on liquidation opportunity
+   * Bid on opportunity
+   * @description Bid on opportunity
    */
-  post_bid: {
+  opportunity_bid: {
     parameters: {
       path: {
         /** @description Opportunity id to bid on */

+ 124 - 0
express_relay/sdk/js/src/types.ts

@@ -0,0 +1,124 @@
+import { Address, Hex } from "viem";
+import type { components } from "./serverTypes";
+
+/**
+ * ERC20 token with contract address and amount
+ */
+export type TokenAmount = {
+  token: Address;
+  amount: bigint;
+};
+export type BidId = string;
+export type ChainId = string;
+/**
+ * Bid parameters
+ */
+export type BidParams = {
+  /**
+   * Bid amount in wei
+   */
+  amount: bigint;
+  /**
+   * Unix timestamp for when the bid is no longer valid in seconds
+   */
+  validUntil: bigint;
+};
+/**
+ * Represents a valid opportunity ready to be executed
+ */
+export type Opportunity = {
+  /**
+   * The chain id where the opportunity will be executed.
+   */
+  chainId: ChainId;
+
+  /**
+   * Unique identifier for the opportunity
+   */
+  opportunityId: string;
+  /**
+   * Permission key required for successful execution of the opportunity.
+   */
+  permissionKey: Hex;
+  /**
+   * Contract address to call for execution of the opportunity.
+   */
+  targetContract: Address;
+  /**
+   * Calldata for the targetContract call.
+   */
+  targetCalldata: Hex;
+  /**
+   * Value to send with the targetContract call.
+   */
+  targetCallValue: bigint;
+  /**
+   * Tokens required to repay the debt
+   */
+  sellTokens: TokenAmount[];
+  /**
+   * Tokens to receive after the opportunity is executed
+   */
+  buyTokens: TokenAmount[];
+};
+/**
+ * All the parameters necessary to represent an opportunity
+ */
+export type OpportunityParams = Omit<Opportunity, "opportunityId">;
+/**
+ * Represents a bid for an opportunity
+ */
+export type OpportunityBid = {
+  /**
+   * Opportunity unique identifier in uuid format
+   */
+  opportunityId: string;
+  /**
+   * The permission key required for successful execution of the opportunity.
+   */
+  permissionKey: Hex;
+  /**
+   * Executor address
+   */
+  executor: Address;
+  /**
+   * Signature of the executor
+   */
+  signature: Hex;
+
+  bid: BidParams;
+};
+/**
+ * Represents a raw bid on acquiring a permission key
+ */
+export type Bid = {
+  /**
+   * The permission key to bid on
+   * @example 0xc0ffeebabe
+   *
+   */
+  permissionKey: Hex;
+  /**
+   * @description Amount of bid in wei.
+   * @example 10
+   */
+  amount: bigint;
+  /**
+   * @description Calldata for the targetContract call.
+   * @example 0xdeadbeef
+   */
+  targetCalldata: Hex;
+  /**
+   * @description The chain id to bid on.
+   * @example sepolia
+   */
+  chainId: ChainId;
+  /**
+   * @description The targetContract address to call.
+   * @example 0xcA11bde05977b3631167028862bE2a173976CA11
+   */
+  targetContract: Address;
+};
+export type BidStatusUpdate = {
+  id: BidId;
+} & components["schemas"]["BidStatus"];

+ 1 - 1
package-lock.json

@@ -652,7 +652,7 @@
     },
     "express_relay/sdk/js": {
       "name": "@pythnetwork/express-relay-evm-js",
-      "version": "0.1.1",
+      "version": "0.2.0",
       "license": "Apache-2.0",
       "dependencies": {
         "isomorphic-ws": "^5.0.0",