Bläddra i källkod

feat(express-relay): Add api key (#1619)

Dani Mehrjerdi 1 år sedan
förälder
incheckning
f8ed6ddb22

+ 1 - 1
express_relay/sdk/js/package-lock.json

@@ -1,6 +1,6 @@
 {
   "name": "@pythnetwork/express-relay-evm-js",
-  "version": "0.5.0",
+  "version": "0.6.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {

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

@@ -1,6 +1,6 @@
 {
   "name": "@pythnetwork/express-relay-evm-js",
-  "version": "0.5.0",
+  "version": "0.6.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",

+ 14 - 3
express_relay/sdk/js/src/examples/simpleSearcher.ts

@@ -12,10 +12,14 @@ class SimpleSearcher {
   constructor(
     public endpoint: string,
     public chainId: string,
-    public privateKey: string
+    public privateKey: string,
+    public apiKey?: string
   ) {
     this.client = new Client(
-      { baseUrl: endpoint },
+      {
+        baseUrl: endpoint,
+        apiKey,
+      },
       undefined,
       this.opportunityHandler.bind(this),
       this.bidStatusHandler.bind(this)
@@ -103,6 +107,12 @@ const argv = yargs(hideBin(process.argv))
     type: "string",
     demandOption: true,
   })
+  .option("api-key", {
+    description:
+      "The API key of the searcher to authenticate with the server for fetching and submitting bids",
+    type: "string",
+    demandOption: false,
+  })
   .help()
   .alias("help", "h")
   .parseSync();
@@ -116,7 +126,8 @@ async function run() {
   const searcher = new SimpleSearcher(
     argv.endpoint,
     argv.chainId,
-    argv.privateKey
+    argv.privateKey,
+    argv.apiKey
   );
   await searcher.start();
 }

+ 37 - 2
express_relay/sdk/js/src/index.ts

@@ -1,6 +1,7 @@
 import type { components, paths } from "./serverTypes";
 import createClient, {
   ClientOptions as FetchClientOptions,
+  HeadersOptions,
 } from "openapi-fetch";
 import { Address, Hex, isAddress, isHex } from "viem";
 import { privateKeyToAccount, signTypedData } from "viem/accounts";
@@ -15,13 +16,14 @@ import {
   OpportunityBid,
   OpportunityParams,
   TokenAmount,
+  BidsResponse,
 } from "./types";
 
 export * from "./types";
 
 export class ClientError extends Error {}
 
-type ClientOptions = FetchClientOptions & { baseUrl: string };
+type ClientOptions = FetchClientOptions & { baseUrl: string; apiKey?: string };
 
 export interface WsOptions {
   /**
@@ -75,6 +77,14 @@ export class Client {
     statusUpdate: BidStatusUpdate
   ) => Promise<void>;
 
+  private getAuthorization() {
+    return this.clientOptions.apiKey
+      ? {
+          Authorization: `Bearer ${this.clientOptions.apiKey}`,
+        }
+      : {};
+  }
+
   constructor(
     clientOptions: ClientOptions,
     wsOptions?: WsOptions,
@@ -82,6 +92,10 @@ export class Client {
     bidStatusCallback?: (statusUpdate: BidStatusUpdate) => Promise<void>
   ) {
     this.clientOptions = clientOptions;
+    this.clientOptions.headers = {
+      ...(this.clientOptions.headers ?? {}),
+      ...this.getAuthorization(),
+    };
     this.wsOptions = { ...DEFAULT_WS_OPTIONS, ...wsOptions };
     this.websocketOpportunityCallback = opportunityCallback;
     this.websocketBidStatusCallback = bidStatusCallback;
@@ -93,7 +107,9 @@ export class Client {
       websocketEndpoint.protocol === "https:" ? "wss:" : "ws:";
     websocketEndpoint.pathname = "/v1/ws";
 
-    this.websocket = new WebSocket(websocketEndpoint.toString());
+    this.websocket = new WebSocket(websocketEndpoint.toString(), {
+      headers: this.getAuthorization(),
+    });
     this.websocket.on("message", async (data: string) => {
       const message:
         | components["schemas"]["ServerResultResponse"]
@@ -443,4 +459,23 @@ export class Client {
       }
     }
   }
+
+  /**
+   * Get bids for an api key
+   * @param fromTime The datetime to fetch bids from. If undefined or null, fetches from the beginning of time.
+   * @returns The paginated bids response
+   */
+  async getBids(fromTime?: Date): Promise<BidsResponse> {
+    const client = createClient<paths>(this.clientOptions);
+    const response = await client.GET("/v1/bids", {
+      params: { query: { from_time: fromTime?.toISOString() } },
+    });
+    if (response.error) {
+      throw new ClientError(response.error.error);
+    } else if (response.data === undefined) {
+      throw new ClientError("No data returned");
+    } else {
+      return response.data;
+    }
+  }
 }

+ 110 - 35
express_relay/sdk/js/src/serverTypes.d.ts

@@ -5,43 +5,42 @@
 
 export interface paths {
   "/v1/bids": {
+    /**
+     * Returns at most 20 bids which were submitted after a specific time.
+     * @description If no time is provided, the server will return the first bids.
+     */
+    get: operations["get_bids_by_time"];
     /**
      * Bid on a specific permission key for a specific chain.
-     * @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
+     * @description 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.
      */
     post: operations["bid"];
   };
   "/v1/bids/{bid_id}": {
-    /**
-     * Query the status of a specific bid.
-     * @description Query the status of a specific bid.
-     */
+    /** 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.
-     */
+    /** Fetch all opportunities ready to be exectued. */
     get: operations["get_opportunities"];
     /**
      * 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
+     * @description 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/opportunities/{opportunity_id}/bids": {
+    /** Bid on opportunity */
+    post: operations["opportunity_bid"];
+  };
+  "/v1/profiles/access_tokens": {
     /**
-     * Bid on opportunity
-     * @description Bid on opportunity
+     * Revoke the authenticated profile access token.
+     * @description Returns empty response.
      */
-    post: operations["opportunity_bid"];
+    delete: operations["delete_profile_access_token"];
   };
 }
 
@@ -295,6 +294,54 @@ export interface components {
           /** @enum {string} */
           type: "bid_status_update";
         };
+    /** BidResponse */
+    SimulatedBid: {
+      /**
+       * @description Amount of bid in wei.
+       * @example 10
+       */
+      bid_amount: string;
+      /**
+       * @description The chain id for bid.
+       * @example op_sepolia
+       */
+      chain_id: string;
+      /**
+       * @description The unique id for bid.
+       * @example obo3ee3e-58cc-4372-a567-0e02b2c3d479
+       */
+      id: string;
+      /**
+       * @description The time server received the bid formatted in rfc3339.
+       * @example 2024-05-23T21:26:57.329954Z
+       */
+      initiation_time: string;
+      /**
+       * @description The permission key for bid.
+       * @example 0xdeadbeef
+       */
+      permission_key: string;
+      /**
+       * @description The profile id for the bid owner.
+       * @example
+       */
+      profile_id: string;
+      status: components["schemas"]["BidStatus"];
+      /**
+       * @description Calldata for the contract call.
+       * @example 0xdeadbeef
+       */
+      target_calldata: string;
+      /**
+       * @description The contract address to call.
+       * @example 0xcA11bde05977b3631167028862bE2a173976CA11
+       */
+      target_contract: string;
+    };
+    /** BidsResponse */
+    SimulatedBids: {
+      items: components["schemas"]["SimulatedBid"][];
+    };
     TokenAmount: {
       /**
        * @description Token amount
@@ -350,6 +397,13 @@ export interface components {
         };
       };
     };
+    SimulatedBids: {
+      content: {
+        "application/json": {
+          items: components["schemas"]["SimulatedBid"][];
+        };
+      };
+    };
   };
   parameters: never;
   requestBodies: never;
@@ -362,11 +416,30 @@ export type $defs = Record<string, never>;
 export type external = Record<string, never>;
 
 export interface operations {
+  /**
+   * Returns at most 20 bids which were submitted after a specific time.
+   * @description If no time is provided, the server will return the first bids.
+   */
+  get_bids_by_time: {
+    parameters: {
+      query?: {
+        /** @example 2024-05-23T21:26:57.329954Z */
+        from_time?: string | null;
+      };
+    };
+    responses: {
+      /** @description Paginated list of bids for the specified query */
+      200: {
+        content: {
+          "application/json": components["schemas"]["SimulatedBids"];
+        };
+      };
+      400: components["responses"]["ErrorBodyResponse"];
+    };
+  };
   /**
    * Bid on a specific permission key for a specific chain.
-   * @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
+   * @description 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.
    */
   bid: {
@@ -391,10 +464,7 @@ export interface operations {
       };
     };
   };
-  /**
-   * Query the status of a specific bid.
-   * @description Query the status of a specific bid.
-   */
+  /** Query the status of a specific bid. */
   bid_status: {
     parameters: {
       path: {
@@ -418,10 +488,7 @@ export interface operations {
       };
     };
   };
-  /**
-   * Fetch all opportunities ready to be exectued.
-   * @description Fetch all opportunities ready to be exectued.
-   */
+  /** Fetch all opportunities ready to be exectued. */
   get_opportunities: {
     parameters: {
       query?: {
@@ -447,9 +514,7 @@ export interface operations {
   };
   /**
    * 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
+   * @description 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_opportunity: {
@@ -474,10 +539,7 @@ export interface operations {
       };
     };
   };
-  /**
-   * Bid on opportunity
-   * @description Bid on opportunity
-   */
+  /** Bid on opportunity */
   opportunity_bid: {
     parameters: {
       path: {
@@ -506,4 +568,17 @@ export interface operations {
       };
     };
   };
+  /**
+   * Revoke the authenticated profile access token.
+   * @description Returns empty response.
+   */
+  delete_profile_access_token: {
+    responses: {
+      /** @description The token successfully revoked */
+      200: {
+        content: never;
+      };
+      400: components["responses"]["ErrorBodyResponse"];
+    };
+  };
 }

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

@@ -150,3 +150,8 @@ export type Bid = {
 export type BidStatusUpdate = {
   id: BidId;
 } & components["schemas"]["BidStatus"];
+
+export type BidResponse = components["schemas"]["SimulatedBid"];
+export type BidsResponse = {
+  items: BidResponse[];
+};

+ 47 - 11
express_relay/sdk/python/express_relay/client.py

@@ -1,5 +1,6 @@
 import asyncio
 from asyncio import Task
+from datetime import datetime
 import json
 import urllib.parse
 from typing import Callable, Any
@@ -10,10 +11,10 @@ import websockets
 from websockets.client import WebSocketClientProtocol
 from eth_account.account import Account
 from express_relay.express_relay_types import (
+    BidResponse,
     Opportunity,
     BidStatusUpdate,
     ClientMessage,
-    BidStatus,
     Bid,
     OpportunityBid,
     OpportunityParams,
@@ -28,6 +29,7 @@ class ExpressRelayClient:
     def __init__(
         self,
         server_url: str,
+        api_key: str | None = None,
         opportunity_callback: (
             Callable[[Opportunity], Coroutine[Any, Any, Any]] | None
         ) = None,
@@ -56,6 +58,7 @@ class ExpressRelayClient:
             raise ValueError("Invalid server URL")
 
         self.server_url = server_url
+        self.api_key = api_key
         self.ws_endpoint = parsed_url._replace(scheme=ws_scheme, path="/v1/ws").geturl()
         self.ws_msg_counter = 0
         self.ws: WebSocketClientProtocol
@@ -71,6 +74,14 @@ class ExpressRelayClient:
         self.http_options = http_options
         self.opportunity_callback = opportunity_callback
         self.bid_status_callback = bid_status_callback
+        if self.api_key:
+            authorization_header = f"Bearer {self.api_key}"
+            if "headers" not in self.http_options:
+                self.http_options["headers"] = {}
+            self.http_options["headers"]["Authorization"] = authorization_header
+            if "extra_headers" not in self.ws_options:
+                self.ws_options["extra_headers"] = {}
+            self.ws_options["extra_headers"]["Authorization"] = authorization_header
 
     async def start_ws(self):
         """
@@ -318,17 +329,11 @@ class ExpressRelayClient:
 
                 elif msg_json.get("type") == "bid_status_update":
                     if bid_status_callback is not None:
-                        id = msg_json["status"]["id"]
-                        bid_status = msg_json["status"]["bid_status"]["type"]
-                        result = msg_json["status"]["bid_status"].get("result")
-                        index = msg_json["status"]["bid_status"].get("index")
-                        bid_status_update = BidStatusUpdate(
-                            id=id,
-                            bid_status=BidStatus(bid_status),
-                            result=result,
-                            index=index,
+                        bid_status_update = BidStatusUpdate.process_bid_status_dict(
+                            msg_json["status"]
                         )
-                        asyncio.create_task(bid_status_callback(bid_status_update))
+                        if bid_status_update:
+                            asyncio.create_task(bid_status_callback(bid_status_update))
 
             elif msg_json.get("id"):
                 future = self.ws_msg_futures.pop(msg_json["id"])
@@ -384,6 +389,37 @@ class ExpressRelayClient:
         resp.raise_for_status()
         return UUID(resp.json()["opportunity_id"])
 
+    async def get_bids(self, from_time: datetime | None = None) -> list[BidResponse]:
+        """
+        Fetches bids for an api key from the server with pagination of 20 bids per page.
+
+        Args:
+            from_time: The datetime to fetch bids from. If None, fetches from the beginning of time.
+        Returns:
+            A list of bids.
+        """
+        async with httpx.AsyncClient(**self.http_options) as client:
+            resp = await client.get(
+                urllib.parse.urlparse(self.server_url)
+                ._replace(path="/v1/bids")
+                .geturl(),
+                params=(
+                    {"from_time": from_time.astimezone().isoformat()}
+                    if from_time
+                    else None
+                ),
+            )
+
+        resp.raise_for_status()
+
+        bids = []
+        for bid in resp.json()["items"]:
+            bid_processed = BidResponse.process_bid_response_dict(bid)
+            if bid_processed:
+                bids.append(bid_processed)
+
+        return bids
+
 
 def sign_bid(
     opportunity: Opportunity,

+ 79 - 0
express_relay/sdk/python/express_relay/express_relay_types.py

@@ -1,3 +1,4 @@
+from datetime import datetime
 from enum import Enum
 from pydantic import BaseModel, model_validator
 from pydantic.functional_validators import AfterValidator
@@ -144,6 +145,84 @@ class BidStatusUpdate(BaseModel):
             assert self.index is None, "index must be None"
         return self
 
+    @classmethod
+    def process_bid_status_dict(cls, bid_status_dict: dict):
+        """
+        Processes a bid status dictionary and converts to a class object.
+
+        Args:
+            bid_status_dict: The bid status dictionary to convert.
+
+        Returns:
+            The bid status as a class object.
+        """
+        try:
+            return cls.model_validate(
+                dict(
+                    id=bid_status_dict.get("id"),
+                    bid_status=bid_status_dict.get("bid_status", {}).pop("type"),
+                    **bid_status_dict.get("bid_status", {}),
+                )
+            )
+        except Exception as e:
+            warnings.warn(str(e))
+            return None
+
+
+class BidResponse(BaseModel):
+    """
+    Attributes:
+        id: The unique id for bid.
+        amount: The amount of the bid in wei.
+        target_calldata: Calldata for the contract call.
+        chain_id: The chain ID to bid on.
+        target_contract: The contract address to call.
+        permission_key: The permission key to bid on.
+        status: The latest status for bid.
+        initiation_time: The time server received the bid formatted in rfc3339.
+        profile_id: The profile id for the bid owner.
+    """
+
+    id: UUIDString
+    bid_amount: IntString
+    target_calldata: HexString
+    chain_id: str
+    target_contract: Address
+    permission_key: HexString
+    status: BidStatusUpdate
+    initiation_time: datetime
+    profile_id: str | None = Field(default=None)
+
+    @classmethod
+    def process_bid_response_dict(cls, bid_response_dict: dict):
+        """
+        Processes a bid response dictionary and converts to a class object.
+
+        Args:
+            bid_response_dict: The bid response dictionary to convert.
+
+        Returns:
+            The bid response as a class object.
+        """
+        try:
+            return cls.model_validate(
+                dict(
+                    status=BidStatusUpdate.process_bid_status_dict(
+                        dict(
+                            id=bid_response_dict.get("id"),
+                            bid_status=bid_response_dict.pop("status"),
+                        )
+                    ),
+                    initiation_time=datetime.fromisoformat(
+                        bid_response_dict.pop("initiation_time")
+                    ),
+                    **bid_response_dict,
+                )
+            )
+        except UnsupportedOpportunityVersionException as e:
+            warnings.warn(str(e))
+            return None
+
 
 class OpportunityBid(BaseModel):
     """

+ 15 - 4
express_relay/sdk/python/express_relay/searcher/examples/simple_searcher.py

@@ -19,9 +19,14 @@ VALID_UNTIL_MAX = 2**256 - 1
 
 
 class SimpleSearcher:
-    def __init__(self, server_url: str, private_key: Bytes32):
+    def __init__(
+        self, server_url: str, private_key: Bytes32, api_key: str | None = None
+    ):
         self.client = ExpressRelayClient(
-            server_url, self.opportunity_callback, self.bid_status_callback
+            server_url,
+            api_key,
+            self.opportunity_callback,
+            self.bid_status_callback,
         )
         self.private_key = private_key
         self.public_key = Account.from_key(private_key).address
@@ -85,7 +90,7 @@ class SimpleSearcher:
                 result_details = f", transaction {result}"
             if index:
                 result_details += f", index {index} of multicall"
-        logger.error(
+        logger.info(
             f"Bid status for bid {id}: {bid_status.value.replace('_', ' ')}{result_details}"
         )
 
@@ -112,6 +117,12 @@ async def main():
         required=True,
         help="Server endpoint to use for fetching opportunities and submitting bids",
     )
+    parser.add_argument(
+        "--api-key",
+        type=str,
+        required=False,
+        help="The API key of the searcher to authenticate with the server for fetching and submitting bids",
+    )
     args = parser.parse_args()
 
     logger.setLevel(logging.INFO if args.verbose == 0 else logging.DEBUG)
@@ -123,7 +134,7 @@ async def main():
     log_handler.setFormatter(formatter)
     logger.addHandler(log_handler)
 
-    simple_searcher = SimpleSearcher(args.server_url, args.private_key)
+    simple_searcher = SimpleSearcher(args.server_url, args.private_key, args.api_key)
     logger.info("Searcher address: %s", simple_searcher.public_key)
 
     await simple_searcher.client.subscribe_chains(args.chain_ids)

+ 1 - 1
express_relay/sdk/python/pyproject.toml

@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "express-relay"
-version = "0.5.0"
+version = "0.6.0"
 description = "Utilities for searchers and protocols to interact with the Express Relay protocol."
 authors = ["dourolabs"]
 license = "Proprietary"

+ 1 - 1
package-lock.json

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