Bläddra i källkod

Merge remote-tracking branch 'upstream/main' into staking-app-release-batch

Connor Prussin 1 år sedan
förälder
incheckning
37fb9b6bd7

+ 5 - 0
apps/staking/src/config/server.ts

@@ -56,6 +56,11 @@ export const PYTHNET_RPC = getOr("PYTHNET_RPC", "https://pythnet.rpcpool.com");
 export const HERMES_URL = getOr("HERMES_URL", "https://hermes.pyth.network");
 export const BLOCKED_REGIONS = transformOr("BLOCKED_REGIONS", fromCsv, []);
 export const IP_ALLOWLIST = transformOr("IP_ALLOWLIST", fromCsv, []);
+export const VPN_ORGANIZATION_ALLOWLIST = transformOr(
+  "VPN_ORGANIZATION_ALLOWLIST",
+  fromCsv,
+  ["iCloud Private Relay"],
+);
 export const GOVERNANCE_ONLY_REGIONS = transformOr(
   "GOVERNANCE_ONLY_REGIONS",
   fromCsv,

+ 8 - 2
apps/staking/src/middleware.ts

@@ -12,6 +12,7 @@ import {
   GOVERNANCE_ONLY_REGIONS,
   PROXYCHECK_API_KEY,
   IP_ALLOWLIST,
+  VPN_ORGANIZATION_ALLOWLIST,
 } from "./config/server";
 
 const GEO_BLOCKED_PATH = `/${GEO_BLOCKED_SEGMENT}`;
@@ -61,8 +62,13 @@ const isProxyBlocked = async ({ ip }: NextRequest) => {
   if (proxyCheckClient === undefined || ip === undefined) {
     return false;
   } else {
-    const result = await proxyCheckClient.checkIP(ip, { vpn: 2 });
-    return result[ip]?.proxy === "yes";
+    const response = await proxyCheckClient.checkIP(ip, { vpn: 2 });
+    const result = response[ip];
+    return (
+      result &&
+      result.proxy === "yes" &&
+      !VPN_ORGANIZATION_ALLOWLIST.includes(result.organisation)
+    );
   }
 };
 

+ 2 - 2
express_relay/examples/easy_lend/src/monitor.ts

@@ -3,7 +3,7 @@ import { hideBin } from "yargs/helpers";
 import {
   checkAddress,
   Client,
-  OpportunityParams,
+  OpportunityCreate,
 } from "@pythnetwork/express-relay-js";
 import type { ContractFunctionReturnType } from "viem";
 import {
@@ -133,7 +133,7 @@ class ProtocolMonitor {
         { token: this.wethContract, amount: targetCallValue },
       ];
     }
-    const opportunity: OpportunityParams = {
+    const opportunity: OpportunityCreate = {
       chainId: this.chainId,
       targetContract: this.vaultContract,
       targetCalldata: calldata,

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

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

+ 0 - 6
express_relay/sdk/js/src/const.ts

@@ -25,12 +25,6 @@ export const OPPORTUNITY_ADAPTER_CONFIGS: Record<
 
 export const SVM_CONSTANTS: Record<string, SvmConstantsConfig> = {
   "development-solana": {
-    relayerSigner: new PublicKey(
-      "GEeEguHhepHtPVo3E9RA1wvnxgxJ61iSc9dJfd433w3K"
-    ),
-    feeReceiverRelayer: new PublicKey(
-      "feesJcX9zwLiEZs9iQGXeBd65b9m2Zc1LjjyHngQF29"
-    ),
     expressRelayProgram: new PublicKey(
       "PytERJFhAKuNNuaiXkApLfWzwNwSNDACpigT3LwQfou"
     ),

+ 212 - 0
express_relay/sdk/js/src/evm.ts

@@ -0,0 +1,212 @@
+import {
+  Bid,
+  BidParams,
+  OpportunityBid,
+  OpportunityEvm,
+  TokenAmount,
+  TokenPermissions,
+} from "./types";
+import { Address, encodeFunctionData, getContractAddress, Hex } from "viem";
+import { privateKeyToAccount, signTypedData } from "viem/accounts";
+import { checkAddress, ClientError } from "./index";
+import { OPPORTUNITY_ADAPTER_CONFIGS } from "./const";
+import { executeOpportunityAbi } from "./abi";
+
+/**
+ * Converts sellTokens, bidAmount, and callValue to permitted tokens
+ * @param tokens List of sellTokens
+ * @param bidAmount
+ * @param callValue
+ * @param weth
+ * @returns List of permitted tokens
+ */
+function getPermittedTokens(
+  tokens: TokenAmount[],
+  bidAmount: bigint,
+  callValue: bigint,
+  weth: Address
+): TokenPermissions[] {
+  const permitted: TokenPermissions[] = tokens.map(({ token, amount }) => ({
+    token,
+    amount,
+  }));
+  const wethIndex = permitted.findIndex(({ token }) => token === weth);
+  const extraWethNeeded = bidAmount + callValue;
+  if (wethIndex !== -1) {
+    permitted[wethIndex].amount += extraWethNeeded;
+    return permitted;
+  }
+  if (extraWethNeeded > 0) {
+    permitted.push({ token: weth, amount: extraWethNeeded });
+  }
+  return permitted;
+}
+
+function getOpportunityConfig(chainId: string) {
+  const opportunityAdapterConfig = OPPORTUNITY_ADAPTER_CONFIGS[chainId];
+  if (!opportunityAdapterConfig) {
+    throw new ClientError(
+      `Opportunity adapter config not found for chain id: ${chainId}`
+    );
+  }
+  return opportunityAdapterConfig;
+}
+
+export async function signBid(
+  opportunity: OpportunityEvm,
+  bidParams: BidParams,
+  privateKey: Hex
+): Promise<Bid> {
+  const opportunityAdapterConfig = getOpportunityConfig(opportunity.chainId);
+  const executor = privateKeyToAccount(privateKey).address;
+  const permitted = getPermittedTokens(
+    opportunity.sellTokens,
+    bidParams.amount,
+    opportunity.targetCallValue,
+    checkAddress(opportunityAdapterConfig.weth)
+  );
+  const signature = await getSignature(opportunity, bidParams, privateKey);
+
+  const calldata = makeAdapterCalldata(
+    opportunity,
+    permitted,
+    executor,
+    bidParams,
+    signature
+  );
+
+  return {
+    amount: bidParams.amount,
+    targetCalldata: calldata,
+    chainId: opportunity.chainId,
+    targetContract: opportunityAdapterConfig.opportunity_adapter_factory,
+    permissionKey: opportunity.permissionKey,
+    env: "evm",
+  };
+}
+
+/**
+ * Constructs the calldata for the opportunity adapter contract.
+ * @param opportunity Opportunity to bid on
+ * @param permitted Permitted tokens
+ * @param executor Address of the searcher's wallet
+ * @param bidParams Bid amount, nonce, and deadline timestamp
+ * @param signature Searcher's signature for opportunity params and bidParams
+ * @returns Calldata for the opportunity adapter contract
+ */
+function makeAdapterCalldata(
+  opportunity: OpportunityEvm,
+  permitted: TokenPermissions[],
+  executor: Address,
+  bidParams: BidParams,
+  signature: Hex
+): Hex {
+  return encodeFunctionData({
+    abi: [executeOpportunityAbi],
+    args: [
+      [
+        [permitted, bidParams.nonce, bidParams.deadline],
+        [
+          opportunity.buyTokens,
+          executor,
+          opportunity.targetContract,
+          opportunity.targetCalldata,
+          opportunity.targetCallValue,
+          bidParams.amount,
+        ],
+      ],
+      signature,
+    ],
+  });
+}
+
+export async function getSignature(
+  opportunity: OpportunityEvm,
+  bidParams: BidParams,
+  privateKey: Hex
+): Promise<`0x${string}`> {
+  const types = {
+    PermitBatchWitnessTransferFrom: [
+      { name: "permitted", type: "TokenPermissions[]" },
+      { name: "spender", type: "address" },
+      { name: "nonce", type: "uint256" },
+      { name: "deadline", type: "uint256" },
+      { name: "witness", type: "OpportunityWitness" },
+    ],
+    OpportunityWitness: [
+      { name: "buyTokens", type: "TokenAmount[]" },
+      { name: "executor", type: "address" },
+      { 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" },
+    ],
+    TokenPermissions: [
+      { name: "token", type: "address" },
+      { name: "amount", type: "uint256" },
+    ],
+  };
+
+  const account = privateKeyToAccount(privateKey);
+  const executor = account.address;
+  const opportunityAdapterConfig = getOpportunityConfig(opportunity.chainId);
+  const permitted = getPermittedTokens(
+    opportunity.sellTokens,
+    bidParams.amount,
+    opportunity.targetCallValue,
+    checkAddress(opportunityAdapterConfig.weth)
+  );
+  const create2Address = getContractAddress({
+    bytecodeHash:
+      opportunityAdapterConfig.opportunity_adapter_init_bytecode_hash,
+    from: opportunityAdapterConfig.opportunity_adapter_factory,
+    opcode: "CREATE2",
+    salt: `0x${executor.replace("0x", "").padStart(64, "0")}`,
+  });
+
+  return signTypedData({
+    privateKey,
+    domain: {
+      name: "Permit2",
+      verifyingContract: checkAddress(opportunityAdapterConfig.permit2),
+      chainId: opportunityAdapterConfig.chain_id,
+    },
+    types,
+    primaryType: "PermitBatchWitnessTransferFrom",
+    message: {
+      permitted,
+      spender: create2Address,
+      nonce: bidParams.nonce,
+      deadline: bidParams.deadline,
+      witness: {
+        buyTokens: opportunity.buyTokens,
+        executor,
+        targetContract: opportunity.targetContract,
+        targetCalldata: opportunity.targetCalldata,
+        targetCallValue: opportunity.targetCallValue,
+        bidAmount: bidParams.amount,
+      },
+    },
+  });
+}
+
+export async function signOpportunityBid(
+  opportunity: OpportunityEvm,
+  bidParams: BidParams,
+  privateKey: Hex
+): Promise<OpportunityBid> {
+  const account = privateKeyToAccount(privateKey);
+  const signature = await getSignature(opportunity, bidParams, privateKey);
+
+  return {
+    permissionKey: opportunity.permissionKey,
+    bid: bidParams,
+    executor: account.address,
+    signature,
+    opportunityId: opportunity.opportunityId,
+  };
+}

+ 2 - 0
express_relay/sdk/js/src/examples/simpleSearcherEvm.ts

@@ -46,6 +46,8 @@ class SimpleSearcherEvm {
   }
 
   async opportunityHandler(opportunity: Opportunity) {
+    if (!("targetContract" in opportunity))
+      throw new Error("Not a valid EVM opportunity");
     const bidAmount = 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

+ 85 - 48
express_relay/sdk/js/src/examples/simpleSearcherLimo.ts

@@ -1,6 +1,11 @@
 import yargs from "yargs";
 import { hideBin } from "yargs/helpers";
-import { Client } from "../index";
+import {
+  Client,
+  ExpressRelaySvmConfig,
+  Opportunity,
+  OpportunitySvm,
+} from "../index";
 import { BidStatusUpdate } from "../types";
 import { SVM_CONSTANTS } from "../const";
 
@@ -9,10 +14,7 @@ import { Keypair, PublicKey, Connection } from "@solana/web3.js";
 
 import * as limo from "@kamino-finance/limo-sdk";
 import { Decimal } from "decimal.js";
-import {
-  getPdaAuthority,
-  OrderStateAndAddress,
-} from "@kamino-finance/limo-sdk/dist/utils";
+import { getPdaAuthority } from "@kamino-finance/limo-sdk/dist/utils";
 
 const DAY_IN_SECONDS = 60 * 60 * 24;
 
@@ -20,13 +22,14 @@ class SimpleSearcherLimo {
   private client: Client;
   private connectionSvm: Connection;
   private clientLimo: limo.LimoClient;
-  private searcher: Keypair;
+  private expressRelayConfig: ExpressRelaySvmConfig | undefined;
   constructor(
     public endpointExpressRelay: string,
     public chainId: string,
-    privateKey: string,
+    private searcher: Keypair,
     public endpointSvm: string,
     public globalConfig: PublicKey,
+    public fillRate: number,
     public apiKey?: string
   ) {
     this.client = new Client(
@@ -35,15 +38,11 @@ class SimpleSearcherLimo {
         apiKey,
       },
       undefined,
-      () => {
-        return Promise.resolve();
-      },
+      this.opportunityHandler.bind(this),
       this.bidStatusHandler.bind(this)
     );
     this.connectionSvm = new Connection(endpointSvm, "confirmed");
     this.clientLimo = new limo.LimoClient(this.connectionSvm, globalConfig);
-    const secretKey = anchor.utils.bytes.bs58.decode(privateKey);
-    this.searcher = Keypair.fromSecretKey(secretKey);
   }
 
   async bidStatusHandler(bidStatus: BidStatusUpdate) {
@@ -60,7 +59,8 @@ class SimpleSearcherLimo {
     );
   }
 
-  async evaluateOrder(order: OrderStateAndAddress) {
+  async generateBid(opportunity: OpportunitySvm) {
+    const order = opportunity.order;
     const inputMintDecimals = await this.clientLimo.getOrderInputMintDecimals(
       order
     );
@@ -69,13 +69,20 @@ class SimpleSearcherLimo {
     );
     const inputAmountDecimals = new Decimal(
       order.state.remainingInputAmount.toNumber()
-    ).div(new Decimal(10).pow(inputMintDecimals));
+    )
+      .div(new Decimal(10).pow(inputMintDecimals))
+      .mul(this.fillRate)
+      .div(100);
 
     const outputAmountDecimals = new Decimal(
       order.state.expectedOutputAmount.toNumber()
-    ).div(new Decimal(10).pow(outputMintDecimals));
+    )
+      .div(new Decimal(10).pow(outputMintDecimals))
+      .mul(this.fillRate)
+      .div(100);
 
     console.log("Order address", order.address.toBase58());
+    console.log("Fill rate", this.fillRate);
     console.log(
       "Sell token",
       order.state.inputMint.toBase58(),
@@ -104,6 +111,12 @@ class SimpleSearcherLimo {
       order.state.globalConfig
     );
     const bidAmount = new anchor.BN(argv.bid);
+    if (!this.expressRelayConfig) {
+      this.expressRelayConfig = await this.client.getExpressRelaySvmConfig(
+        this.chainId,
+        this.connectionSvm
+      );
+    }
 
     const bid = await this.client.constructSvmBid(
       txRaw,
@@ -112,41 +125,39 @@ class SimpleSearcherLimo {
       order.address,
       bidAmount,
       new anchor.BN(Math.round(Date.now() / 1000 + DAY_IN_SECONDS)),
-      this.chainId
+      this.chainId,
+      this.expressRelayConfig.relayerSigner,
+      this.expressRelayConfig.feeReceiverRelayer
     );
 
+    bid.transaction.recentBlockhash = opportunity.blockHash;
+    bid.transaction.sign(this.searcher);
+    return bid;
+  }
+
+  async opportunityHandler(opportunity: Opportunity) {
+    const bid = await this.generateBid(opportunity as OpportunitySvm);
     try {
-      const { blockhash } = await this.connectionSvm.getLatestBlockhash();
-      bid.transaction.recentBlockhash = blockhash;
-      bid.transaction.sign(this.searcher);
       const bidId = await this.client.submitBid(bid);
-      console.log(`Successful bid. Bid id ${bidId}`);
+      console.log(
+        `Successful bid. Opportunity id ${opportunity.opportunityId} Bid id ${bidId}`
+      );
     } catch (error) {
-      console.error(`Failed to bid: ${error}`);
-    }
-  }
-
-  async bidOnNewOrders() {
-    let allOrders =
-      await this.clientLimo.getAllOrdersStateAndAddressWithFilters([]);
-    allOrders = allOrders.filter(
-      (order) => !order.state.remainingInputAmount.isZero()
-    );
-    if (allOrders.length === 0) {
-      console.log("No orders to bid on");
-      return;
-    }
-    for (const order of allOrders) {
-      await this.evaluateOrder(order);
+      console.error(
+        `Failed to bid on opportunity ${opportunity.opportunityId}: ${error}`
+      );
     }
-    // Note: You need to parallelize this in production with something like:
-    // await Promise.all(allOrders.map((order) => this.evaluateOrder(order)));
   }
 
   async start() {
-    for (;;) {
-      await this.bidOnNewOrders();
-      await new Promise((resolve) => setTimeout(resolve, 2000));
+    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();
     }
   }
 }
@@ -174,9 +185,15 @@ const argv = yargs(hideBin(process.argv))
     default: "100",
   })
   .option("private-key", {
-    description: "Private key to sign the bid with. In 64-byte base58 format",
+    description: "Private key of the searcher in base58 format",
     type: "string",
-    demandOption: true,
+    conflicts: "private-key-json-file",
+  })
+  .option("private-key-json-file", {
+    description:
+      "Path to a json file containing the private key of the searcher in array of bytes format",
+    type: "string",
+    conflicts: "private-key",
   })
   .option("api-key", {
     description:
@@ -189,6 +206,11 @@ const argv = yargs(hideBin(process.argv))
     type: "string",
     demandOption: true,
   })
+  .option("fill-rate", {
+    description: "How much of the order to fill in percentage. Default is 100%",
+    type: "number",
+    default: 100,
+  })
   .help()
   .alias("help", "h")
   .parseSync();
@@ -196,17 +218,32 @@ async function run() {
   if (!SVM_CONSTANTS[argv.chainId]) {
     throw new Error(`SVM constants not found for chain ${argv.chainId}`);
   }
-  const searcherSvm = Keypair.fromSecretKey(
-    anchor.utils.bytes.bs58.decode(argv.privateKey)
-  );
-  console.log(`Using searcher pubkey: ${searcherSvm.publicKey.toBase58()}`);
+  let searcherKeyPair;
+
+  if (argv.privateKey) {
+    const secretKey = anchor.utils.bytes.bs58.decode(argv.privateKey);
+    searcherKeyPair = Keypair.fromSecretKey(secretKey);
+  } else if (argv.privateKeyJsonFile) {
+    searcherKeyPair = Keypair.fromSecretKey(
+      Buffer.from(
+        // eslint-disable-next-line @typescript-eslint/no-var-requires
+        JSON.parse(require("fs").readFileSync(argv.privateKeyJsonFile))
+      )
+    );
+  } else {
+    throw new Error(
+      "Either private-key or private-key-json-file must be provided"
+    );
+  }
+  console.log(`Using searcher pubkey: ${searcherKeyPair.publicKey.toBase58()}`);
 
   const simpleSearcher = new SimpleSearcherLimo(
     argv.endpointExpressRelay,
     argv.chainId,
-    argv.privateKey,
+    searcherKeyPair,
     argv.endpointSvm,
     new PublicKey(argv.globalConfig),
+    argv.fillRate,
     argv.apiKey
   );
   await simpleSearcher.start();

+ 8 - 3
express_relay/sdk/js/src/examples/simpleSearcherSvm.ts

@@ -9,7 +9,7 @@ import { Program, AnchorProvider } from "@coral-xyz/anchor";
 import { Keypair, PublicKey, Connection } from "@solana/web3.js";
 import dummyIdl from "./idl/idlDummy.json";
 import { Dummy } from "./dummyTypes";
-import { getConfigRouterPda, getExpressRelayMetadataPda } from "../svmPda";
+import { getConfigRouterPda, getExpressRelayMetadataPda } from "../svm";
 
 const DAY_IN_SECONDS = 60 * 60 * 24;
 const DUMMY_PIDS: Record<string, PublicKey> = {
@@ -102,7 +102,10 @@ class SimpleSearcherSvm {
     ixDummy.programId = dummyPid;
 
     const txRaw = new anchor.web3.Transaction().add(ixDummy);
-
+    const expressRelayConfig = await this.client.getExpressRelaySvmConfig(
+      this.chainId,
+      this.connectionSvm
+    );
     const bid = await this.client.constructSvmBid(
       txRaw,
       searcher.publicKey,
@@ -110,7 +113,9 @@ class SimpleSearcherSvm {
       permission,
       bidAmount,
       new anchor.BN(Math.round(Date.now() / 1000 + DAY_IN_SECONDS)),
-      this.chainId
+      this.chainId,
+      expressRelayConfig.relayerSigner,
+      expressRelayConfig.feeReceiverRelayer
     );
 
     try {

+ 202 - 338
express_relay/sdk/js/src/index.ts

@@ -2,41 +2,33 @@ import type { components, paths } from "./serverTypes";
 import createClient, {
   ClientOptions as FetchClientOptions,
 } from "openapi-fetch";
-import {
-  Address,
-  Hex,
-  isAddress,
-  isHex,
-  getContractAddress,
-  encodeFunctionData,
-} from "viem";
-import { privateKeyToAccount, signTypedData } from "viem/accounts";
+import { Address, Hex, isAddress, isHex } from "viem";
 import WebSocket from "isomorphic-ws";
 import {
   Bid,
   BidId,
   BidParams,
+  BidsResponse,
   BidStatusUpdate,
   BidSvm,
+  ExpressRelaySvmConfig,
   Opportunity,
-  OpportunityParams,
-  TokenAmount,
-  BidsResponse,
-  TokenPermissions,
   OpportunityBid,
+  OpportunityEvm,
+  OpportunityCreate,
+  TokenAmount,
 } from "./types";
-import { executeOpportunityAbi } from "./abi";
-import { OPPORTUNITY_ADAPTER_CONFIGS, SVM_CONSTANTS } from "./const";
 import {
+  Connection,
   PublicKey,
   Transaction,
   TransactionInstruction,
 } from "@solana/web3.js";
 import * as anchor from "@coral-xyz/anchor";
-import { AnchorProvider, Program } from "@coral-xyz/anchor";
-import expressRelayIdl from "./idl/idlExpressRelay.json";
-import { ExpressRelay } from "./expressRelayTypes";
-import { getConfigRouterPda, getExpressRelayMetadataPda } from "./svmPda";
+import { limoId, Order } from "@kamino-finance/limo-sdk";
+import { getPdaAuthority } from "@kamino-finance/limo-sdk/dist/utils";
+import * as evm from "./evm";
+import * as svm from "./svm";
 
 export * from "./types";
 
@@ -82,46 +74,6 @@ export function checkTokenQty(token: {
   };
 }
 
-function getOpportunityConfig(chainId: string) {
-  const opportunityAdapterConfig = OPPORTUNITY_ADAPTER_CONFIGS[chainId];
-  if (!opportunityAdapterConfig) {
-    throw new ClientError(
-      `Opportunity adapter config not found for chain id: ${chainId}`
-    );
-  }
-  return opportunityAdapterConfig;
-}
-
-/**
- * Converts sellTokens, bidAmount, and callValue to permitted tokens
- * @param tokens List of sellTokens
- * @param bidAmount
- * @param callValue
- * @param weth
- * @returns List of permitted tokens
- */
-function getPermittedTokens(
-  tokens: TokenAmount[],
-  bidAmount: bigint,
-  callValue: bigint,
-  weth: Address
-): TokenPermissions[] {
-  const permitted: TokenPermissions[] = tokens.map(({ token, amount }) => ({
-    token,
-    amount,
-  }));
-  const wethIndex = permitted.findIndex(({ token }) => token === weth);
-  const extraWethNeeded = bidAmount + callValue;
-  if (wethIndex !== -1) {
-    permitted[wethIndex].amount += extraWethNeeded;
-    return permitted;
-  }
-  if (extraWethNeeded > 0) {
-    permitted.push({ token: weth, amount: extraWethNeeded });
-  }
-  return permitted;
-}
-
 export class Client {
   public clientOptions: ClientOptions;
   public wsOptions: WsOptions;
@@ -208,33 +160,6 @@ export class Client {
     });
   }
 
-  /**
-   * Converts an opportunity from the server to the client format
-   * Returns undefined if the opportunity version is not supported
-   * @param opportunity
-   * @returns Opportunity in the converted client format
-   */
-  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),
-    };
-  }
-
   /**
    * Subscribes to the specified chains
    *
@@ -331,12 +256,50 @@ export class Client {
    * Submits an opportunity to be exposed to searchers
    * @param opportunity Opportunity to submit
    */
-  async submitOpportunity(opportunity: OpportunityParams) {
+  async submitOpportunity(opportunity: OpportunityCreate) {
     const client = createClient<paths>(this.clientOptions);
-    const response = await client.POST("/v1/opportunities", {
-      body: {
+    let body;
+    if ("order" in opportunity) {
+      const encoded_order = Buffer.alloc(
+        Order.discriminator.length + Order.layout.span
+      );
+      Order.discriminator.copy(encoded_order);
+      Order.layout.encode(
+        opportunity.order.state,
+        encoded_order,
+        Order.discriminator.length
+      );
+      body = {
         chain_id: opportunity.chainId,
-        version: "v1",
+        version: "v1" as const,
+        program: opportunity.program,
+
+        order: encoded_order.toString("base64"),
+        slot: opportunity.slot,
+        block_hash: opportunity.blockHash,
+        order_address: opportunity.order.address.toBase58(),
+        buy_tokens: [
+          {
+            token: opportunity.order.state.inputMint.toBase58(),
+            amount: opportunity.order.state.remainingInputAmount.toNumber(),
+          },
+        ],
+        sell_tokens: [
+          {
+            token: opportunity.order.state.outputMint.toBase58(),
+            amount: opportunity.order.state.expectedOutputAmount.toNumber(),
+          },
+        ],
+        permission_account: opportunity.order.address.toBase58(),
+        router: getPdaAuthority(
+          limoId,
+          opportunity.order.state.globalConfig
+        ).toBase58(),
+      };
+    } else {
+      body = {
+        chain_id: opportunity.chainId,
+        version: "v1" as const,
         permission_key: opportunity.permissionKey,
         target_contract: opportunity.targetContract,
         target_calldata: opportunity.targetCalldata,
@@ -349,220 +312,16 @@ export class Client {
           token,
           amount: amount.toString(),
         })),
-      },
+      };
+    }
+    const response = await client.POST("/v1/opportunities", {
+      body: body,
     });
     if (response.error) {
       throw new ClientError(response.error.error);
     }
   }
 
-  /**
-   * Constructs the calldata for the opportunity adapter contract.
-   * @param opportunity Opportunity to bid on
-   * @param permitted Permitted tokens
-   * @param executor Address of the searcher's wallet
-   * @param bidParams Bid amount, nonce, and deadline timestamp
-   * @param signature Searcher's signature for opportunity params and bidParams
-   * @returns Calldata for the opportunity adapter contract
-   */
-  private makeAdapterCalldata(
-    opportunity: Opportunity,
-    permitted: TokenPermissions[],
-    executor: Address,
-    bidParams: BidParams,
-    signature: Hex
-  ): Hex {
-    return encodeFunctionData({
-      abi: [executeOpportunityAbi],
-      args: [
-        [
-          [permitted, bidParams.nonce, bidParams.deadline],
-          [
-            opportunity.buyTokens,
-            executor,
-            opportunity.targetContract,
-            opportunity.targetCalldata,
-            opportunity.targetCallValue,
-            bidParams.amount,
-          ],
-        ],
-        signature,
-      ],
-    });
-  }
-
-  /**
-   * Creates a signature for the bid and opportunity
-   * @param opportunity Opportunity to bid on
-   * @param bidParams Bid amount, nonce, and deadline timestamp
-   * @param privateKey Private key to sign the bid with
-   * @returns Signature for the bid and opportunity
-   */
-  async getSignature(
-    opportunity: Opportunity,
-    bidParams: BidParams,
-    privateKey: Hex
-  ): Promise<`0x${string}`> {
-    const types = {
-      PermitBatchWitnessTransferFrom: [
-        { name: "permitted", type: "TokenPermissions[]" },
-        { name: "spender", type: "address" },
-        { name: "nonce", type: "uint256" },
-        { name: "deadline", type: "uint256" },
-        { name: "witness", type: "OpportunityWitness" },
-      ],
-      OpportunityWitness: [
-        { name: "buyTokens", type: "TokenAmount[]" },
-        { name: "executor", type: "address" },
-        { 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" },
-      ],
-      TokenPermissions: [
-        { name: "token", type: "address" },
-        { name: "amount", type: "uint256" },
-      ],
-    };
-
-    const account = privateKeyToAccount(privateKey);
-    const executor = account.address;
-    const opportunityAdapterConfig = getOpportunityConfig(opportunity.chainId);
-    const permitted = getPermittedTokens(
-      opportunity.sellTokens,
-      bidParams.amount,
-      opportunity.targetCallValue,
-      checkAddress(opportunityAdapterConfig.weth)
-    );
-    const create2Address = getContractAddress({
-      bytecodeHash:
-        opportunityAdapterConfig.opportunity_adapter_init_bytecode_hash,
-      from: opportunityAdapterConfig.opportunity_adapter_factory,
-      opcode: "CREATE2",
-      salt: `0x${executor.replace("0x", "").padStart(64, "0")}`,
-    });
-
-    return signTypedData({
-      privateKey,
-      domain: {
-        name: "Permit2",
-        verifyingContract: checkAddress(opportunityAdapterConfig.permit2),
-        chainId: opportunityAdapterConfig.chain_id,
-      },
-      types,
-      primaryType: "PermitBatchWitnessTransferFrom",
-      message: {
-        permitted,
-        spender: create2Address,
-        nonce: bidParams.nonce,
-        deadline: bidParams.deadline,
-        witness: {
-          buyTokens: opportunity.buyTokens,
-          executor,
-          targetContract: opportunity.targetContract,
-          targetCalldata: opportunity.targetCalldata,
-          targetCallValue: opportunity.targetCallValue,
-          bidAmount: bidParams.amount,
-        },
-      },
-    });
-  }
-
-  /**
-   * Creates a signed opportunity 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
-   * @returns Signed opportunity bid
-   */
-  async signOpportunityBid(
-    opportunity: Opportunity,
-    bidParams: BidParams,
-    privateKey: Hex
-  ): Promise<OpportunityBid> {
-    const account = privateKeyToAccount(privateKey);
-    const signature = await this.getSignature(
-      opportunity,
-      bidParams,
-      privateKey
-    );
-
-    return {
-      permissionKey: opportunity.permissionKey,
-      bid: bidParams,
-      executor: account.address,
-      signature,
-      opportunityId: opportunity.opportunityId,
-    };
-  }
-
-  /**
-   * Creates a signed bid for an opportunity
-   * @param opportunity Opportunity to bid on
-   * @param bidParams Bid amount, nonce, and deadline timestamp
-   * @param privateKey Private key to sign the bid with
-   * @returns Signed bid
-   */
-  async signBid(
-    opportunity: Opportunity,
-    bidParams: BidParams,
-    privateKey: Hex
-  ): Promise<Bid> {
-    const opportunityAdapterConfig = getOpportunityConfig(opportunity.chainId);
-    const executor = privateKeyToAccount(privateKey).address;
-    const permitted = getPermittedTokens(
-      opportunity.sellTokens,
-      bidParams.amount,
-      opportunity.targetCallValue,
-      checkAddress(opportunityAdapterConfig.weth)
-    );
-    const signature = await this.getSignature(
-      opportunity,
-      bidParams,
-      privateKey
-    );
-
-    const calldata = this.makeAdapterCalldata(
-      opportunity,
-      permitted,
-      executor,
-      bidParams,
-      signature
-    );
-
-    return {
-      amount: bidParams.amount,
-      targetCalldata: calldata,
-      chainId: opportunity.chainId,
-      targetContract: opportunityAdapterConfig.opportunity_adapter_factory,
-      permissionKey: opportunity.permissionKey,
-      env: "evm",
-    };
-  }
-
-  private toServerBid(bid: Bid): components["schemas"]["Bid"] {
-    if (bid.env == "evm") {
-      return {
-        amount: bid.amount.toString(),
-        target_calldata: bid.targetCalldata,
-        chain_id: bid.chainId,
-        target_contract: bid.targetContract,
-        permission_key: bid.permissionKey,
-      };
-    }
-
-    return {
-      chain_id: bid.chainId,
-      transaction: bid.transaction
-        .serialize({ requireAllSignatures: false })
-        .toString("base64"),
-    };
-  }
-
   /**
    * Submits a raw bid for a permission key
    * @param bid
@@ -616,6 +375,127 @@ export class Client {
     }
   }
 
+  private toServerBid(bid: Bid): components["schemas"]["Bid"] {
+    if (bid.env === "evm") {
+      return {
+        amount: bid.amount.toString(),
+        target_calldata: bid.targetCalldata,
+        chain_id: bid.chainId,
+        target_contract: bid.targetContract,
+        permission_key: bid.permissionKey,
+      };
+    }
+
+    return {
+      chain_id: bid.chainId,
+      transaction: bid.transaction
+        .serialize({ requireAllSignatures: false })
+        .toString("base64"),
+    };
+  }
+
+  /**
+   * Converts an opportunity from the server to the client format
+   * Returns undefined if the opportunity version is not supported
+   * @param opportunity
+   * @returns Opportunity in the converted client format
+   */
+  private convertOpportunity(
+    opportunity: components["schemas"]["Opportunity"]
+  ): Opportunity | undefined {
+    if (opportunity.version !== "v1") {
+      console.warn(
+        `Can not handle opportunity version: ${opportunity.version}. Please upgrade your client.`
+      );
+      return undefined;
+    }
+    if ("target_calldata" in opportunity) {
+      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),
+      };
+    }
+    const order = Order.decode(Buffer.from(opportunity.order, "base64"));
+    return {
+      chainId: opportunity.chain_id,
+      slot: opportunity.slot,
+      blockHash: opportunity.block_hash,
+      opportunityId: opportunity.opportunity_id,
+      order: {
+        state: order,
+        address: new PublicKey(opportunity.order_address),
+      },
+      program: "limo",
+    };
+  }
+
+  // EVM specific functions
+
+  /**
+   * Creates a signed opportunity bid for an opportunity
+   * @param opportunity EVM Opportunity to bid on
+   * @param bidParams Bid amount and valid until timestamp
+   * @param privateKey Private key to sign the bid with
+   * @returns Signed opportunity bid
+   */
+  async signOpportunityBid(
+    opportunity: OpportunityEvm,
+    bidParams: BidParams,
+    privateKey: Hex
+  ): Promise<OpportunityBid> {
+    return evm.signOpportunityBid(opportunity, bidParams, privateKey);
+  }
+
+  /**
+   * Creates a signed bid for an EVM opportunity
+   * @param opportunity EVM Opportunity to bid on
+   * @param bidParams Bid amount, nonce, and deadline timestamp
+   * @param privateKey Private key to sign the bid with
+   * @returns Signed bid
+   */
+  async signBid(
+    opportunity: OpportunityEvm,
+    bidParams: BidParams,
+    privateKey: Hex
+  ): Promise<Bid> {
+    return evm.signBid(opportunity, bidParams, privateKey);
+  }
+
+  /**
+   * Creates a signature for the bid and opportunity
+   * @param opportunity EVM Opportunity to bid on
+   * @param bidParams Bid amount, nonce, and deadline timestamp
+   * @param privateKey Private key to sign the bid with
+   * @returns Signature for the bid and opportunity
+   */
+  async getSignature(
+    opportunity: OpportunityEvm,
+    bidParams: BidParams,
+    privateKey: Hex
+  ): Promise<`0x${string}`> {
+    return evm.getSignature(opportunity, bidParams, privateKey);
+  }
+
+  // SVM specific functions
+
+  /**
+   * Fetches the Express Relay SVM config necessary for bidding
+   * @param chainId The id for the chain you want to fetch the config for
+   * @param connection The connection to use for fetching the config
+   */
+  async getExpressRelaySvmConfig(
+    chainId: string,
+    connection: Connection
+  ): Promise<ExpressRelaySvmConfig> {
+    return svm.getExpressRelaySvmConfig(chainId, connection);
+  }
+
   /**
    * Constructs a SubmitBid instruction, which can be added to a transaction to permission it on the given permission key
    * @param searcher The address of the searcher that is submitting the bid
@@ -624,6 +504,8 @@ export class Client {
    * @param bidAmount The amount of the bid in lamports
    * @param deadline The deadline for the bid in seconds since Unix epoch
    * @param chainId The chain ID as a string, e.g. "solana"
+   * @param relayerSigner The address of the relayer that is submitting the bid
+   * @param feeReceiverRelayer The fee collection address of the relayer
    * @returns The SubmitBid instruction
    */
   async constructSubmitBidInstruction(
@@ -632,37 +514,20 @@ export class Client {
     permissionKey: PublicKey,
     bidAmount: anchor.BN,
     deadline: anchor.BN,
-    chainId: string
+    chainId: string,
+    relayerSigner: PublicKey,
+    feeReceiverRelayer: PublicKey
   ): Promise<TransactionInstruction> {
-    const expressRelay = new Program<ExpressRelay>(
-      expressRelayIdl as ExpressRelay,
-      {} as AnchorProvider
+    return svm.constructSubmitBidInstruction(
+      searcher,
+      router,
+      permissionKey,
+      bidAmount,
+      deadline,
+      chainId,
+      relayerSigner,
+      feeReceiverRelayer
     );
-
-    const configRouter = getConfigRouterPda(chainId, router);
-    const expressRelayMetadata = getExpressRelayMetadataPda(chainId);
-    const svmConstants = SVM_CONSTANTS[chainId];
-
-    const ixSubmitBid = await expressRelay.methods
-      .submitBid({
-        deadline,
-        bidAmount,
-      })
-      .accountsStrict({
-        searcher,
-        relayerSigner: svmConstants.relayerSigner,
-        permission: permissionKey,
-        router,
-        configRouter,
-        expressRelayMetadata,
-        feeReceiverRelayer: svmConstants.feeReceiverRelayer,
-        systemProgram: anchor.web3.SystemProgram.programId,
-        sysvarInstructions: anchor.web3.SYSVAR_INSTRUCTIONS_PUBKEY,
-      })
-      .instruction();
-    ixSubmitBid.programId = svmConstants.expressRelayProgram;
-
-    return ixSubmitBid;
   }
 
   /**
@@ -674,6 +539,8 @@ export class Client {
    * @param bidAmount The amount of the bid in lamports
    * @param deadline The deadline for the bid in seconds since Unix epoch
    * @param chainId The chain ID as a string, e.g. "solana"
+   * @param relayerSigner The address of the relayer that is submitting the bid
+   * @param feeReceiverRelayer The fee collection address of the relayer
    * @returns The constructed SVM bid
    */
   async constructSvmBid(
@@ -683,23 +550,20 @@ export class Client {
     permissionKey: PublicKey,
     bidAmount: anchor.BN,
     deadline: anchor.BN,
-    chainId: string
+    chainId: string,
+    relayerSigner: PublicKey,
+    feeReceiverRelayer: PublicKey
   ): Promise<BidSvm> {
-    const ixSubmitBid = await this.constructSubmitBidInstruction(
+    return svm.constructSvmBid(
+      tx,
       searcher,
       router,
       permissionKey,
       bidAmount,
       deadline,
-      chainId
+      chainId,
+      relayerSigner,
+      feeReceiverRelayer
     );
-
-    tx.instructions.unshift(ixSubmitBid);
-
-    return {
-      transaction: tx,
-      chainId: chainId,
-      env: "svm",
-    };
   }
 }

+ 173 - 34
express_relay/sdk/js/src/serverTypes.d.ts

@@ -26,6 +26,7 @@ export interface paths {
      * Fetch opportunities ready for execution or historical opportunities
      * @description depending on the mode. You need to provide `chain_id` for historical mode.
      * Opportunities are sorted by creation time in ascending order in historical mode.
+     * Total number of opportunities returned is limited by 20.
      */
     get: operations["get_opportunities"];
     /**
@@ -195,7 +196,7 @@ export interface components {
           /** @enum {string} */
           method: "post_opportunity_bid";
           params: {
-            opportunity_bid: components["schemas"]["OpportunityBid"];
+            opportunity_bid: components["schemas"]["OpportunityBidEvm"];
             opportunity_id: string;
           };
         };
@@ -205,7 +206,10 @@ export interface components {
     ErrorBodyResponse: {
       error: string;
     };
-    OpportunityBid: {
+    Opportunity:
+      | components["schemas"]["OpportunityEvm"]
+      | components["schemas"]["OpportunitySvm"];
+    OpportunityBidEvm: {
       /**
        * @description The bid amount in wei.
        * @example 1000000000000000000
@@ -234,9 +238,39 @@ export interface components {
       /** @example 0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12 */
       signature: string;
     };
-    /** @enum {string} */
-    OpportunityMode: "live" | "historical";
-    OpportunityParams: components["schemas"]["OpportunityParamsV1"] & {
+    OpportunityBidResult: {
+      /**
+       * @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;
+      /** @example OK */
+      status: string;
+    };
+    /** @description The input type for creating a new opportunity */
+    OpportunityCreate:
+      | components["schemas"]["OpportunityCreateEvm"]
+      | components["schemas"]["OpportunityCreateSvm"];
+    OpportunityCreateEvm: components["schemas"]["OpportunityCreateV1Evm"] & {
+      /** @enum {string} */
+      version: "v1";
+    };
+    /** @description Program specific parameters for the opportunity */
+    OpportunityCreateProgramParamsV1Svm: {
+      /**
+       * @description The Limo order to be executed, encoded in base64
+       * @example DUcTi3rDyS5QEmZ4BNRBejtArmDCWaPYGfN44vBJXKL5
+       */
+      order: string;
+      /**
+       * @description Address of the order account
+       * @example DUcTi3rDyS5QEmZ4BNRBejtArmDCWaPYGfN44vBJXKL5
+       */
+      order_address: string;
+      /** @enum {string} */
+      program: "limo";
+    };
+    OpportunityCreateSvm: components["schemas"]["OpportunityCreateV1Svm"] & {
       /** @enum {string} */
       version: "v1";
     };
@@ -246,8 +280,8 @@ export interface components {
      * by calling this target contract with the given target calldata 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"][];
+    OpportunityCreateV1Evm: {
+      buy_tokens: components["schemas"]["TokenAmountEvm"][];
       /**
        * @description The chain id where the opportunity will be executed.
        * @example op_sepolia
@@ -258,7 +292,7 @@ export interface components {
        * @example 0xdeadbeefcafe
        */
       permission_key: string;
-      sell_tokens: components["schemas"]["TokenAmount"][];
+      sell_tokens: components["schemas"]["TokenAmountEvm"][];
       /**
        * @description The value to send with the contract call.
        * @example 1
@@ -275,8 +309,54 @@ export interface components {
        */
       target_contract: string;
     };
-    /** @description Similar to OpportunityParams, but with the opportunity id included. */
-    OpportunityParamsWithMetadata: (components["schemas"]["OpportunityParamsV1"] & {
+    /**
+     * @description Opportunity parameters needed for on-chain execution.
+     * Parameters may differ for each program
+     */
+    OpportunityCreateV1Svm: {
+      /**
+       * @description The Limo order to be executed, encoded in base64
+       * @example DUcTi3rDyS5QEmZ4BNRBejtArmDCWaPYGfN44vBJXKL5
+       */
+      order: string;
+      /**
+       * @description Address of the order account
+       * @example DUcTi3rDyS5QEmZ4BNRBejtArmDCWaPYGfN44vBJXKL5
+       */
+      order_address: string;
+      /** @enum {string} */
+      program: "limo";
+    } & {
+      /**
+       * @description The block hash to be used for the opportunity execution
+       * @example DUcTi3rDyS5QEmZ4BNRBejtArmDCWaPYGfN44vBJXKL5
+       */
+      block_hash: string;
+      buy_tokens: components["schemas"]["TokenAmountSvm"][];
+      /**
+       * @description The chain id where the opportunity will be executed.
+       * @example solana
+       */
+      chain_id: string;
+      /**
+       * @description The permission account to be permitted by the ER contract for the opportunity execution of the protocol
+       * @example DUcTi3rDyS5QEmZ4BNRBejtArmDCWaPYGfN44vBJXKL5
+       */
+      permission_account: string;
+      /**
+       * @description The router account to be used for the opportunity execution of the protocol
+       * @example DUcTi3rDyS5QEmZ4BNRBejtArmDCWaPYGfN44vBJXKL5
+       */
+      router: string;
+      sell_tokens: components["schemas"]["TokenAmountSvm"][];
+      /**
+       * Format: int64
+       * @description The slot where the program params were fetched from using the RPC
+       * @example 293106477
+       */
+      slot: number;
+    };
+    OpportunityEvm: (components["schemas"]["OpportunityParamsV1Evm"] & {
       /** @enum {string} */
       version: "v1";
     }) & {
@@ -291,6 +371,64 @@ export interface components {
        */
       opportunity_id: string;
     };
+    /** @enum {string} */
+    OpportunityMode: "live" | "historical";
+    OpportunityParamsEvm: components["schemas"]["OpportunityParamsV1Evm"] & {
+      /** @enum {string} */
+      version: "v1";
+    };
+    OpportunityParamsSvm: components["schemas"]["OpportunityParamsV1Svm"] & {
+      /** @enum {string} */
+      version: "v1";
+    };
+    OpportunityParamsV1Evm: components["schemas"]["OpportunityCreateV1Evm"];
+    /**
+     * @description Opportunity parameters needed for on-chain execution.
+     * Parameters may differ for each program
+     */
+    OpportunityParamsV1Svm: {
+      /**
+       * @description The Limo order to be executed, encoded in base64
+       * @example DUcTi3rDyS5QEmZ4BNRBejtArmDCWaPYGfN44vBJXKL5
+       */
+      order: string;
+      /**
+       * @description Address of the order account
+       * @example DUcTi3rDyS5QEmZ4BNRBejtArmDCWaPYGfN44vBJXKL5
+       */
+      order_address: string;
+      /** @enum {string} */
+      program: "limo";
+    } & {
+      /** @example solana */
+      chain_id: string;
+    };
+    OpportunitySvm: (components["schemas"]["OpportunityParamsV1Svm"] & {
+      /** @enum {string} */
+      version: "v1";
+    }) & {
+      /**
+       * @description The block hash to be used for the opportunity execution
+       * @example DUcTi3rDyS5QEmZ4BNRBejtArmDCWaPYGfN44vBJXKL5
+       */
+      block_hash: string;
+      /**
+       * @description Creation time of the opportunity (in microseconds since the Unix epoch)
+       * @example 1700000000000000
+       */
+      creation_time: number;
+      /**
+       * @description The opportunity unique id
+       * @example obo3ee3e-58cc-4372-a567-0e02b2c3d479
+       */
+      opportunity_id: string;
+      /**
+       * Format: int64
+       * @description The slot where the program params were fetched from using the RPC
+       * @example 293106477
+       */
+      slot: number;
+    };
     ServerResultMessage:
       | {
           result: components["schemas"]["APIResponse"] | null;
@@ -312,7 +450,7 @@ export interface components {
     /** @description This enum is used to send an update to the client for any subscriptions made */
     ServerUpdateResponse:
       | {
-          opportunity: components["schemas"]["OpportunityParamsWithMetadata"];
+          opportunity: components["schemas"]["Opportunity"];
           /** @enum {string} */
           type: "new_opportunity";
         }
@@ -418,7 +556,7 @@ export interface components {
     SimulatedBids: {
       items: components["schemas"]["SimulatedBid"][];
     };
-    TokenAmount: {
+    TokenAmountEvm: {
       /**
        * @description Token amount
        * @example 1000
@@ -430,6 +568,19 @@ export interface components {
        */
       token: string;
     };
+    TokenAmountSvm: {
+      /**
+       * Format: int64
+       * @description Token amount in lamports
+       * @example 1000
+       */
+      amount: number;
+      /**
+       * @description Token contract address
+       * @example DUcTi3rDyS5QEmZ4BNRBejtArmDCWaPYGfN44vBJXKL5
+       */
+      token: string;
+    };
   };
   responses: {
     BidResult: {
@@ -452,24 +603,11 @@ export interface components {
         };
       };
     };
-    /** @description Similar to OpportunityParams, but with the opportunity id included. */
-    OpportunityParamsWithMetadata: {
+    Opportunity: {
       content: {
-        "application/json": (components["schemas"]["OpportunityParamsV1"] & {
-          /** @enum {string} */
-          version: "v1";
-        }) & {
-          /**
-           * @description Creation time of the opportunity (in microseconds since the Unix epoch)
-           * @example 1700000000000000
-           */
-          creation_time: number;
-          /**
-           * @description The opportunity unique id
-           * @example obo3ee3e-58cc-4372-a567-0e02b2c3d479
-           */
-          opportunity_id: string;
-        };
+        "application/json":
+          | components["schemas"]["OpportunityEvm"]
+          | components["schemas"]["OpportunitySvm"];
       };
     };
     SimulatedBids: {
@@ -567,6 +705,7 @@ export interface operations {
    * Fetch opportunities ready for execution or historical opportunities
    * @description depending on the mode. You need to provide `chain_id` for historical mode.
    * Opportunities are sorted by creation time in ascending order in historical mode.
+   * Total number of opportunities returned is limited by 20.
    */
   get_opportunities: {
     parameters: {
@@ -591,7 +730,7 @@ export interface operations {
       /** @description Array of opportunities ready for bidding */
       200: {
         content: {
-          "application/json": components["schemas"]["OpportunityParamsWithMetadata"][];
+          "application/json": components["schemas"]["Opportunity"][];
         };
       };
       400: components["responses"]["ErrorBodyResponse"];
@@ -611,14 +750,14 @@ export interface operations {
   post_opportunity: {
     requestBody: {
       content: {
-        "application/json": components["schemas"]["OpportunityParams"];
+        "application/json": components["schemas"]["OpportunityCreate"];
       };
     };
     responses: {
       /** @description The created opportunity */
       200: {
         content: {
-          "application/json": components["schemas"]["OpportunityParamsWithMetadata"];
+          "application/json": components["schemas"]["Opportunity"];
         };
       };
       400: components["responses"]["ErrorBodyResponse"];
@@ -640,14 +779,14 @@ export interface operations {
     };
     requestBody: {
       content: {
-        "application/json": components["schemas"]["OpportunityBid"];
+        "application/json": components["schemas"]["OpportunityBidEvm"];
       };
     };
     responses: {
       /** @description Bid Result */
       200: {
         content: {
-          "application/json": components["schemas"]["BidResult"];
+          "application/json": components["schemas"]["OpportunityBidResult"];
         };
       };
       400: components["responses"]["ErrorBodyResponse"];

+ 135 - 0
express_relay/sdk/js/src/svm.ts

@@ -0,0 +1,135 @@
+import {
+  Connection,
+  Keypair,
+  PublicKey,
+  Transaction,
+  TransactionInstruction,
+} from "@solana/web3.js";
+import * as anchor from "@coral-xyz/anchor";
+import { BidSvm, ExpressRelaySvmConfig } from "./types";
+import { AnchorProvider, Program } from "@coral-xyz/anchor";
+import { ExpressRelay } from "./expressRelayTypes";
+import expressRelayIdl from "./idl/idlExpressRelay.json";
+import { SVM_CONSTANTS } from "./const";
+import NodeWallet from "@coral-xyz/anchor/dist/cjs/nodewallet";
+
+function getExpressRelayProgram(chain: string): PublicKey {
+  if (!SVM_CONSTANTS[chain]) {
+    throw new Error(`Chain ${chain} not supported`);
+  }
+  return SVM_CONSTANTS[chain].expressRelayProgram;
+}
+
+export function getConfigRouterPda(
+  chain: string,
+  router: PublicKey
+): PublicKey {
+  const expressRelayProgram = getExpressRelayProgram(chain);
+
+  return PublicKey.findProgramAddressSync(
+    [Buffer.from("config_router"), router.toBuffer()],
+    expressRelayProgram
+  )[0];
+}
+
+export function getExpressRelayMetadataPda(chain: string): PublicKey {
+  const expressRelayProgram = getExpressRelayProgram(chain);
+
+  return PublicKey.findProgramAddressSync(
+    [Buffer.from("metadata")],
+    expressRelayProgram
+  )[0];
+}
+
+export async function constructSubmitBidInstruction(
+  searcher: PublicKey,
+  router: PublicKey,
+  permissionKey: PublicKey,
+  bidAmount: anchor.BN,
+  deadline: anchor.BN,
+  chainId: string,
+  relayerSigner: PublicKey,
+  feeReceiverRelayer: PublicKey
+): Promise<TransactionInstruction> {
+  const expressRelay = new Program<ExpressRelay>(
+    expressRelayIdl as ExpressRelay,
+    {} as AnchorProvider
+  );
+
+  const configRouter = getConfigRouterPda(chainId, router);
+  const expressRelayMetadata = getExpressRelayMetadataPda(chainId);
+  const svmConstants = SVM_CONSTANTS[chainId];
+
+  const ixSubmitBid = await expressRelay.methods
+    .submitBid({
+      deadline,
+      bidAmount,
+    })
+    .accountsStrict({
+      searcher,
+      relayerSigner,
+      permission: permissionKey,
+      router,
+      configRouter,
+      expressRelayMetadata,
+      feeReceiverRelayer,
+      systemProgram: anchor.web3.SystemProgram.programId,
+      sysvarInstructions: anchor.web3.SYSVAR_INSTRUCTIONS_PUBKEY,
+    })
+    .instruction();
+  ixSubmitBid.programId = svmConstants.expressRelayProgram;
+
+  return ixSubmitBid;
+}
+
+export async function constructSvmBid(
+  tx: Transaction,
+  searcher: PublicKey,
+  router: PublicKey,
+  permissionKey: PublicKey,
+  bidAmount: anchor.BN,
+  deadline: anchor.BN,
+  chainId: string,
+  relayerSigner: PublicKey,
+  feeReceiverRelayer: PublicKey
+): Promise<BidSvm> {
+  const ixSubmitBid = await constructSubmitBidInstruction(
+    searcher,
+    router,
+    permissionKey,
+    bidAmount,
+    deadline,
+    chainId,
+    relayerSigner,
+    feeReceiverRelayer
+  );
+
+  tx.instructions.unshift(ixSubmitBid);
+
+  return {
+    transaction: tx,
+    chainId: chainId,
+    env: "svm",
+  };
+}
+
+export async function getExpressRelaySvmConfig(
+  chainId: string,
+  connection: Connection
+): Promise<ExpressRelaySvmConfig> {
+  const provider = new AnchorProvider(
+    connection,
+    new NodeWallet(new Keypair())
+  );
+  const expressRelay = new Program<ExpressRelay>(
+    expressRelayIdl as ExpressRelay,
+    provider
+  );
+  const metadata = await expressRelay.account.expressRelayMetadata.fetch(
+    getExpressRelayMetadataPda(chainId)
+  );
+  return {
+    feeReceiverRelayer: metadata.feeReceiverRelayer,
+    relayerSigner: metadata.relayerSigner,
+  };
+}

+ 0 - 23
express_relay/sdk/js/src/svmPda.ts

@@ -1,23 +0,0 @@
-import { PublicKey } from "@solana/web3.js";
-import { SVM_CONSTANTS } from "./const";
-
-export function getConfigRouterPda(
-  chain: string,
-  router: PublicKey
-): PublicKey {
-  const expressRelayProgram = SVM_CONSTANTS[chain].expressRelayProgram;
-
-  return PublicKey.findProgramAddressSync(
-    [Buffer.from("config_router"), router.toBuffer()],
-    expressRelayProgram
-  )[0];
-}
-
-export function getExpressRelayMetadataPda(chain: string): PublicKey {
-  const expressRelayProgram = SVM_CONSTANTS[chain].expressRelayProgram;
-
-  return PublicKey.findProgramAddressSync(
-    [Buffer.from("metadata")],
-    expressRelayProgram
-  )[0];
-}

+ 50 - 10
express_relay/sdk/js/src/types.ts

@@ -1,6 +1,7 @@
 import { Address, Hex } from "viem";
 import type { components } from "./serverTypes";
-import { PublicKey, Transaction } from "@solana/web3.js";
+import { Blockhash, PublicKey, Transaction } from "@solana/web3.js";
+import { OrderStateAndAddress } from "@kamino-finance/limo-sdk/dist/utils";
 
 /**
  * ERC20 token with contract address and amount
@@ -62,16 +63,12 @@ export type OpportunityAdapterConfig = {
 /**
  * Represents a valid opportunity ready to be executed
  */
-export type Opportunity = {
+export type OpportunityEvm = {
   /**
    * 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.
    */
@@ -89,14 +86,45 @@ export type Opportunity = {
    */
   targetCallValue: bigint;
   /**
-   * Tokens required to repay the debt
+   * Tokens required to execute the opportunity
    */
   sellTokens: TokenAmount[];
   /**
    * Tokens to receive after the opportunity is executed
    */
   buyTokens: TokenAmount[];
+  /**
+   * Unique identifier for the opportunity
+   */
+  opportunityId: string;
 };
+
+export type OpportunitySvm = {
+  order: OrderStateAndAddress;
+  program: "limo";
+  /**
+   * The chain id where the opportunity will be executed.
+   */
+  chainId: ChainId;
+  /**
+   * Slot where the opportunity was found
+   */
+  slot: number;
+  /**
+   * Blockhash that can be used to sign transactions for this opportunity
+   */
+  blockHash: Blockhash;
+  /**
+   * Unique identifier for the opportunity
+   */
+  opportunityId: string;
+};
+
+export type OpportunityCreate =
+  | Omit<OpportunityEvm, "opportunityId">
+  | Omit<OpportunitySvm, "opportunityId">;
+
+export type Opportunity = OpportunityEvm | OpportunitySvm;
 /**
  * Represents a bid for an opportunity
  */
@@ -123,7 +151,6 @@ export type OpportunityBid = {
 /**
  * All the parameters necessary to represent an opportunity
  */
-export type OpportunityParams = Omit<Opportunity, "opportunityId">;
 
 export type Bid = BidEvm | BidSvm;
 /**
@@ -161,6 +188,21 @@ export type BidEvm = {
    */
   env: "evm";
 };
+
+/**
+ * Necessary accounts for submitting a SVM bid. These can be fetched from on-chain program data.
+ */
+export type ExpressRelaySvmConfig = {
+  /**
+   * @description The relayer signer account. All submitted transactions will be signed by this account.
+   */
+  relayerSigner: PublicKey;
+  /**
+   * @description The fee collection account for the relayer.
+   */
+  feeReceiverRelayer: PublicKey;
+};
+
 /**
  * Represents a raw SVM bid on acquiring a permission key
  */
@@ -201,7 +243,5 @@ export type BidsResponse = {
 };
 
 export type SvmConstantsConfig = {
-  relayerSigner: PublicKey;
-  feeReceiverRelayer: PublicKey;
   expressRelayProgram: PublicKey;
 };

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

@@ -38,6 +38,8 @@ from express_relay.express_relay_types import (
     TokenAmount,
     OpportunityBidParams,
     BidEvm,
+    OpportunityRoot,
+    OpportunityEvm,
 )
 from express_relay.svm.generated.express_relay.instructions import submit_bid
 from express_relay.svm.generated.express_relay.program_id import (
@@ -324,11 +326,11 @@ class ExpressRelayClient:
             if msg_json.get("type"):
                 if msg_json.get("type") == "new_opportunity":
                     if opportunity_callback is not None:
-                        opportunity = Opportunity.process_opportunity_dict(
+                        opportunity = OpportunityRoot.model_validate(
                             msg_json["opportunity"]
                         )
                         if opportunity:
-                            asyncio.create_task(opportunity_callback(opportunity))
+                            asyncio.create_task(opportunity_callback(opportunity.root))
 
                 elif msg_json.get("type") == "bid_status_update":
                     if bid_status_callback is not None:
@@ -365,11 +367,11 @@ class ExpressRelayClient:
 
         resp.raise_for_status()
 
-        opportunities = []
+        opportunities: list[Opportunity] = []
         for opportunity in resp.json():
-            opportunity_processed = Opportunity.process_opportunity_dict(opportunity)
+            opportunity_processed = OpportunityRoot.model_validate(opportunity)
             if opportunity_processed:
-                opportunities.append(opportunity_processed)
+                opportunities.append(opportunity_processed.root)
 
         return opportunities
 
@@ -431,6 +433,8 @@ class ExpressRelayClient:
         bid_amount: int,
         deadline: int,
         chain_id: str,
+        fee_receiver_relayer: Pubkey,
+        relayer_signer: Pubkey,
     ) -> Instruction:
         if chain_id not in SVM_CONFIGS:
             raise ValueError(f"Chain ID {chain_id} not supported")
@@ -445,12 +449,12 @@ class ExpressRelayClient:
             {"data": SubmitBidArgs(deadline=deadline, bid_amount=bid_amount)},
             {
                 "searcher": searcher,
-                "relayer_signer": svm_config["relayer_signer"],
+                "relayer_signer": relayer_signer,
                 "permission": permission_key,
                 "router": router,
                 "config_router": config_router,
                 "express_relay_metadata": express_relay_metadata,
-                "fee_receiver_relayer": svm_config["fee_receiver_relayer"],
+                "fee_receiver_relayer": fee_receiver_relayer,
                 "sysvar_instructions": INSTRUCTIONS,
             },
             svm_config["express_relay_program"],
@@ -487,7 +491,7 @@ def compute_create2_address(
 
 
 def make_adapter_calldata(
-    opportunity: Opportunity,
+    opportunity: OpportunityEvm,
     permitted: list[dict[str, Union[str, int]]],
     executor: Address,
     bid_params: OpportunityBidParams,
@@ -541,7 +545,7 @@ def get_opportunity_adapter_config(chain_id: str):
 
 
 def get_signature(
-    opportunity: Opportunity,
+    opportunity: OpportunityEvm,
     bid_params: OpportunityBidParams,
     private_key: str,
 ) -> SignedMessage:
@@ -632,7 +636,7 @@ def get_signature(
 
 
 def sign_opportunity_bid(
-    opportunity: Opportunity,
+    opportunity: OpportunityEvm,
     bid_params: OpportunityBidParams,
     private_key: str,
 ) -> OpportunityBid:
@@ -661,7 +665,7 @@ def sign_opportunity_bid(
 
 
 def sign_bid(
-    opportunity: Opportunity, bid_params: OpportunityBidParams, private_key: str
+    opportunity: OpportunityEvm, bid_params: OpportunityBidParams, private_key: str
 ) -> BidEvm:
     """
     Constructs a signature for a searcher's bid and returns the Bid object to be submitted to the server.

+ 0 - 8
express_relay/sdk/python/express_relay/constants.py

@@ -32,8 +32,6 @@ EXECUTION_PARAMS_TYPESTRING = (
 
 class SvmProgramConfig(TypedDict):
     express_relay_program: Pubkey
-    relayer_signer: Pubkey
-    fee_receiver_relayer: Pubkey
 
 
 SVM_CONFIGS: Dict[str, SvmProgramConfig] = {
@@ -41,11 +39,5 @@ SVM_CONFIGS: Dict[str, SvmProgramConfig] = {
         "express_relay_program": Pubkey.from_string(
             "PytERJFhAKuNNuaiXkApLfWzwNwSNDACpigT3LwQfou"
         ),
-        "relayer_signer": Pubkey.from_string(
-            "GEeEguHhepHtPVo3E9RA1wvnxgxJ61iSc9dJfd433w3K"
-        ),
-        "fee_receiver_relayer": Pubkey.from_string(
-            "feesJcX9zwLiEZs9iQGXeBd65b9m2Zc1LjjyHngQF29"
-        ),
     }
 }

+ 260 - 0
express_relay/sdk/python/express_relay/express_relay_svm_types.py

@@ -0,0 +1,260 @@
+import base64
+from typing import Any, Annotated, ClassVar
+
+from pydantic import (
+    GetCoreSchemaHandler,
+    GetJsonSchemaHandler,
+    BaseModel,
+    model_validator,
+)
+from pydantic.json_schema import JsonSchemaValue
+from pydantic_core import core_schema
+from solders.hash import Hash as _SvmHash
+from solders.signature import Signature as _SvmSignature
+from solders.pubkey import Pubkey as _SvmAddress
+from solders.transaction import Transaction as _SvmTransaction
+
+from express_relay.express_relay_types import (
+    IntString,
+    UUIDString,
+    UnsupportedOpportunityVersionException,
+)
+from express_relay.svm.generated.limo.accounts import Order
+
+
+class _TransactionPydanticAnnotation:
+    @classmethod
+    def __get_pydantic_core_schema__(
+        cls,
+        _source_type: Any,
+        _handler: GetCoreSchemaHandler,
+    ) -> core_schema.CoreSchema:
+        def validate_from_str(value: str) -> _SvmTransaction:
+            return _SvmTransaction.from_bytes(base64.b64decode(value))
+
+        from_str_schema = core_schema.chain_schema(
+            [
+                core_schema.str_schema(),
+                core_schema.no_info_plain_validator_function(validate_from_str),
+            ]
+        )
+
+        return core_schema.json_or_python_schema(
+            json_schema=from_str_schema,
+            python_schema=core_schema.union_schema(
+                [
+                    # check if it's an instance first before doing any further work
+                    core_schema.is_instance_schema(_SvmTransaction),
+                    from_str_schema,
+                ]
+            ),
+            serialization=core_schema.plain_serializer_function_ser_schema(
+                lambda instance: base64.b64encode(bytes(instance)).decode("utf-8")
+            ),
+        )
+
+    @classmethod
+    def __get_pydantic_json_schema__(
+        cls, _core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
+    ) -> JsonSchemaValue:
+        # Use the same schema that would be used for `str`
+        return handler(core_schema.str_schema())
+
+
+class _SvmAddressPydanticAnnotation:
+    @classmethod
+    def __get_pydantic_core_schema__(
+        cls,
+        _source_type: Any,
+        _handler: GetCoreSchemaHandler,
+    ) -> core_schema.CoreSchema:
+        def validate_from_str(value: str) -> _SvmAddress:
+            return _SvmAddress.from_string(value)
+
+        from_str_schema = core_schema.chain_schema(
+            [
+                core_schema.str_schema(),
+                core_schema.no_info_plain_validator_function(validate_from_str),
+            ]
+        )
+
+        return core_schema.json_or_python_schema(
+            json_schema=from_str_schema,
+            python_schema=core_schema.union_schema(
+                [
+                    # check if it's an instance first before doing any further work
+                    core_schema.is_instance_schema(_SvmTransaction),
+                    from_str_schema,
+                ]
+            ),
+            serialization=core_schema.plain_serializer_function_ser_schema(
+                lambda instance: str(instance)
+            ),
+        )
+
+    @classmethod
+    def __get_pydantic_json_schema__(
+        cls, _core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
+    ) -> JsonSchemaValue:
+        # Use the same schema that would be used for `str`
+        return handler(core_schema.str_schema())
+
+
+class _HashPydanticAnnotation:
+    @classmethod
+    def __get_pydantic_core_schema__(
+        cls,
+        _source_type: Any,
+        _handler: GetCoreSchemaHandler,
+    ) -> core_schema.CoreSchema:
+        def validate_from_str(value: str) -> _SvmHash:
+            return _SvmHash.from_string(value)
+
+        from_str_schema = core_schema.chain_schema(
+            [
+                core_schema.str_schema(),
+                core_schema.no_info_plain_validator_function(validate_from_str),
+            ]
+        )
+
+        return core_schema.json_or_python_schema(
+            json_schema=from_str_schema,
+            python_schema=core_schema.union_schema(
+                [
+                    # check if it's an instance first before doing any further work
+                    core_schema.is_instance_schema(Order),
+                    from_str_schema,
+                ]
+            ),
+            serialization=core_schema.plain_serializer_function_ser_schema(str),
+        )
+
+    @classmethod
+    def __get_pydantic_json_schema__(
+        cls, _core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
+    ) -> JsonSchemaValue:
+        # Use the same schema that would be used for `str`
+        return handler(core_schema.str_schema())
+
+
+class _SignaturePydanticAnnotation:
+    @classmethod
+    def __get_pydantic_core_schema__(
+        cls,
+        _source_type: Any,
+        _handler: GetCoreSchemaHandler,
+    ) -> core_schema.CoreSchema:
+        def validate_from_str(value: str) -> _SvmSignature:
+            return _SvmSignature.from_string(value)
+
+        from_str_schema = core_schema.chain_schema(
+            [
+                core_schema.str_schema(),
+                core_schema.no_info_plain_validator_function(validate_from_str),
+            ]
+        )
+
+        return core_schema.json_or_python_schema(
+            json_schema=from_str_schema,
+            python_schema=core_schema.union_schema(
+                [
+                    # check if it's an instance first before doing any further work
+                    core_schema.is_instance_schema(Order),
+                    from_str_schema,
+                ]
+            ),
+            serialization=core_schema.plain_serializer_function_ser_schema(str),
+        )
+
+    @classmethod
+    def __get_pydantic_json_schema__(
+        cls, _core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
+    ) -> JsonSchemaValue:
+        # Use the same schema that would be used for `str`
+        return handler(core_schema.str_schema())
+
+
+SvmTransaction = Annotated[_SvmTransaction, _TransactionPydanticAnnotation]
+SvmAddress = Annotated[_SvmAddress, _SvmAddressPydanticAnnotation]
+SvmHash = Annotated[_SvmHash, _HashPydanticAnnotation]
+SvmSignature = Annotated[_SvmSignature, _SignaturePydanticAnnotation]
+
+
+class _OrderPydanticAnnotation:
+    @classmethod
+    def __get_pydantic_core_schema__(
+        cls,
+        _source_type: Any,
+        _handler: GetCoreSchemaHandler,
+    ) -> core_schema.CoreSchema:
+        def validate_from_str(value: str) -> Order:
+            return Order.decode(base64.b64decode(value))
+
+        from_str_schema = core_schema.chain_schema(
+            [
+                core_schema.str_schema(),
+                core_schema.no_info_plain_validator_function(validate_from_str),
+            ]
+        )
+
+        return core_schema.json_or_python_schema(
+            json_schema=from_str_schema,
+            python_schema=core_schema.union_schema(
+                [
+                    # check if it's an instance first before doing any further work
+                    core_schema.is_instance_schema(Order),
+                    from_str_schema,
+                ]
+            ),
+            serialization=core_schema.plain_serializer_function_ser_schema(
+                lambda instance: base64.b64encode(Order.layout.build(instance)).decode(
+                    "utf-8"
+                )
+            ),
+        )
+
+    @classmethod
+    def __get_pydantic_json_schema__(
+        cls, _core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
+    ) -> JsonSchemaValue:
+        # Use the same schema that would be used for `str`
+        return handler(core_schema.str_schema())
+
+
+class OpportunitySvm(BaseModel):
+    """
+    Attributes:
+        chain_id: The chain ID to bid on.
+        version: The version of the opportunity.
+        creation_time: The creation time of the opportunity.
+        opportunity_id: The ID of the opportunity.
+        blockHash: The block hash to use for execution.
+        slot: The slot where this order was created or updated
+        program: The program which handles this opportunity
+        order: The order to be executed.
+        order_address: The address of the order.
+    """
+
+    chain_id: str
+    version: str
+    creation_time: IntString
+    opportunity_id: UUIDString
+
+    blockHash: SvmHash
+    slot: int
+
+    program: str
+    order: Annotated[Order, _OrderPydanticAnnotation]
+    order_address: SvmAddress
+
+    supported_versions: ClassVar[list[str]] = ["v1"]
+    supported_programs: ClassVar[list[str]] = ["limo"]
+
+    @model_validator(mode="before")
+    @classmethod
+    def check_version(cls, data):
+        if data["version"] not in cls.supported_versions:
+            raise UnsupportedOpportunityVersionException(
+                f"Cannot handle opportunity version: {data['version']}. Please upgrade your client."
+            )
+        return data

+ 13 - 65
express_relay/sdk/python/express_relay/express_relay_types.py

@@ -1,13 +1,11 @@
-import base64
 from datetime import datetime
 from enum import Enum
 from pydantic import (
     BaseModel,
     model_validator,
-    GetCoreSchemaHandler,
-    GetJsonSchemaHandler,
     Tag,
     Discriminator,
+    RootModel,
 )
 from pydantic.functional_validators import AfterValidator
 from pydantic.functional_serializers import PlainSerializer
@@ -15,14 +13,17 @@ from uuid import UUID
 import web3
 from typing import Union, ClassVar, Any
 from pydantic import Field
-from pydantic.json_schema import JsonSchemaValue
-from pydantic_core import core_schema
-from solders.transaction import Transaction as _SvmTransaction
 from typing_extensions import Literal, Annotated
 import warnings
 import string
 from eth_account.datastructures import SignedMessage
 
+from express_relay.express_relay_svm_types import (
+    SvmTransaction,
+    SvmSignature,
+    OpportunitySvm,
+)
+
 
 class UnsupportedOpportunityVersionException(Exception):
     pass
@@ -113,48 +114,6 @@ class BidEvm(BaseModel):
     permission_key: HexString
 
 
-class _TransactionPydanticAnnotation:
-    @classmethod
-    def __get_pydantic_core_schema__(
-        cls,
-        _source_type: Any,
-        _handler: GetCoreSchemaHandler,
-    ) -> core_schema.CoreSchema:
-        def validate_from_str(value: str) -> _SvmTransaction:
-            return _SvmTransaction.from_bytes(base64.b64decode(value))
-
-        from_str_schema = core_schema.chain_schema(
-            [
-                core_schema.str_schema(),
-                core_schema.no_info_plain_validator_function(validate_from_str),
-            ]
-        )
-
-        return core_schema.json_or_python_schema(
-            json_schema=from_str_schema,
-            python_schema=core_schema.union_schema(
-                [
-                    # check if it's an instance first before doing any further work
-                    core_schema.is_instance_schema(_SvmTransaction),
-                    from_str_schema,
-                ]
-            ),
-            serialization=core_schema.plain_serializer_function_ser_schema(
-                lambda instance: base64.b64encode(bytes(instance)).decode("utf-8")
-            ),
-        )
-
-    @classmethod
-    def __get_pydantic_json_schema__(
-        cls, _core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
-    ) -> JsonSchemaValue:
-        # Use the same schema that would be used for `str`
-        return handler(core_schema.str_schema())
-
-
-SvmTransaction = Annotated[_SvmTransaction, _TransactionPydanticAnnotation]
-
-
 class BidSvm(BaseModel):
     """
     Attributes:
@@ -187,7 +146,7 @@ class BidStatusUpdate(BaseModel):
 
     id: UUIDString
     bid_status: BidStatus
-    result: Bytes32 | None = Field(default=None)
+    result: Bytes32 | SvmSignature | None = Field(default=None)
     index: int | None = Field(default=None)
 
     @model_validator(mode="after")
@@ -202,6 +161,8 @@ class BidStatusUpdate(BaseModel):
 
     @model_validator(mode="after")
     def check_index(self):
+        if isinstance(self.result, SvmSignature):
+            return self
         if self.bid_status == BidStatus("submitted") or self.bid_status == BidStatus(
             "won"
         ):
@@ -363,7 +324,7 @@ class OpportunityParams(BaseModel):
     params: Union[OpportunityParamsV1] = Field(..., discriminator="version")
 
 
-class Opportunity(BaseModel):
+class OpportunityEvm(BaseModel):
     """
     Attributes:
         target_calldata: The calldata for the contract call.
@@ -400,22 +361,9 @@ class Opportunity(BaseModel):
             )
         return data
 
-    @classmethod
-    def process_opportunity_dict(cls, opportunity_dict: dict):
-        """
-        Processes an opportunity dictionary and converts to a class object.
-
-        Args:
-            opportunity_dict: The opportunity dictionary to convert.
 
-        Returns:
-            The opportunity as a class object.
-        """
-        try:
-            return cls.model_validate(opportunity_dict)
-        except UnsupportedOpportunityVersionException as e:
-            warnings.warn(str(e))
-            return None
+Opportunity = Union[OpportunityEvm, OpportunitySvm]
+OpportunityRoot = RootModel[Opportunity]
 
 
 class SubscribeMessageParams(BaseModel):

+ 5 - 2
express_relay/sdk/python/express_relay/searcher/examples/simple_searcher.py

@@ -1,6 +1,8 @@
 import argparse
 import asyncio
 import logging
+import typing
+
 from eth_account.account import Account
 from secrets import randbits
 
@@ -16,6 +18,7 @@ from express_relay.express_relay_types import (
     Bytes32,
     BidStatus,
     BidStatusUpdate,
+    OpportunityEvm,
 )
 
 logger = logging.getLogger(__name__)
@@ -40,7 +43,7 @@ class SimpleSearcher:
 
     def assess_opportunity(
         self,
-        opp: Opportunity,
+        opp: OpportunityEvm,
     ) -> BidEvm | None:
         """
         Assesses whether an opportunity is worth executing; if so, returns a Bid object.
@@ -72,7 +75,7 @@ class SimpleSearcher:
         Args:
             opp: An object representing a single opportunity.
         """
-        bid = self.assess_opportunity(opp)
+        bid = self.assess_opportunity(typing.cast(OpportunityEvm, opp))
         if bid:
             try:
                 await self.client.submit_bid(bid)

+ 80 - 21
express_relay/sdk/python/express_relay/searcher/examples/simple_searcher_svm.py

@@ -1,9 +1,11 @@
 import argparse
 import asyncio
 import logging
+import typing
 from decimal import Decimal
 
 from solana.rpc.async_api import AsyncClient
+from solana.rpc.commitment import Finalized
 from solders.keypair import Keypair
 from solders.pubkey import Pubkey
 from solders.transaction import Transaction
@@ -16,6 +18,12 @@ from express_relay.express_relay_types import (
     BidStatus,
     BidStatusUpdate,
     BidSvm,
+    Opportunity,
+)
+from express_relay.express_relay_svm_types import OpportunitySvm
+from express_relay.svm.generated.express_relay.accounts import ExpressRelayMetadata
+from express_relay.svm.generated.express_relay.program_id import (
+    PROGRAM_ID as SVM_EXPRESS_RELAY_PROGRAM_ID,
 )
 from express_relay.svm.limo_client import LimoClient, OrderStateAndAddress
 
@@ -24,6 +32,8 @@ logger = logging.getLogger(__name__)
 
 
 class SimpleSearcherSvm:
+    express_relay_metadata: ExpressRelayMetadata | None
+
     def __init__(
         self,
         server_url: str,
@@ -32,12 +42,13 @@ class SimpleSearcherSvm:
         chain_id: str,
         svm_rpc_endpoint: str,
         limo_global_config: str,
+        fill_rate: int,
         api_key: str | None = None,
     ):
         self.client = ExpressRelayClient(
             server_url,
             api_key,
-            None,
+            self.opportunity_callback,
             self.bid_status_callback,
         )
         self.private_key = private_key
@@ -50,6 +61,26 @@ class SimpleSearcherSvm:
         self.limo_client = LimoClient(
             self.rpc_client, global_config=Pubkey.from_string(limo_global_config)
         )
+        self.fill_rate = fill_rate
+        self.express_relay_metadata = None
+
+    async def opportunity_callback(self, opp: Opportunity):
+        """
+        Callback function to run when a new opportunity is found.
+
+        Args:
+            opp: An object representing a single opportunity.
+        """
+        bid = await self.assess_opportunity(typing.cast(OpportunitySvm, opp))
+
+        if bid:
+            try:
+                await self.client.submit_bid(bid)
+                logger.info(f"Submitted bid for opportunity {str(opp.opportunity_id)}")
+            except Exception as e:
+                logger.error(
+                    f"Error submitting bid for opportunity {str(opp.opportunity_id)}: {e}"
+                )
 
     async def bid_status_callback(self, bid_status_update: BidStatusUpdate):
         """
@@ -70,20 +101,8 @@ class SimpleSearcherSvm:
                 result_details = f", transaction {result}"
         logger.info(f"Bid status for bid {id}: {bid_status.value}{result_details}")
 
-    async def bid_on_new_orders(self):
-        orders = await self.limo_client.get_all_orders_state_and_address_with_filters(
-            []
-        )
-        orders = [
-            order for order in orders if order["state"].remaining_input_amount > 0
-        ]
-        if len(orders) == 0:
-            logger.info("No orders to bid on")
-            return
-        for order in orders:
-            await self.evaluate_order(order)
-
-    async def evaluate_order(self, order: OrderStateAndAddress):
+    async def assess_opportunity(self, opp: OpportunitySvm) -> BidSvm:
+        order: OrderStateAndAddress = {"address": opp.order_address, "state": opp.order}
         input_mint_decimals = await self.limo_client.get_mint_decimals(
             order["state"].input_mint
         )
@@ -93,6 +112,9 @@ class SimpleSearcherSvm:
         input_amount_decimals = Decimal(
             order["state"].remaining_input_amount
         ) / Decimal(10**input_mint_decimals)
+        input_amount_decimals = (
+            input_amount_decimals * Decimal(self.fill_rate) / Decimal(100)
+        )
         output_amount_decimals = Decimal(
             order["state"].expected_output_amount
         ) / Decimal(10**output_mint_decimals)
@@ -111,6 +133,18 @@ class SimpleSearcherSvm:
         router = self.limo_client.get_pda_authority(
             self.limo_client.get_program_id(), order["state"].global_config
         )
+
+        if self.express_relay_metadata is None:
+            self.express_relay_metadata = await ExpressRelayMetadata.fetch(
+                self.rpc_client,
+                self.limo_client.get_express_relay_metadata_pda(
+                    SVM_EXPRESS_RELAY_PROGRAM_ID
+                ),
+                commitment=Finalized,
+            )
+            if self.express_relay_metadata is None:
+                raise ValueError("Express relay metadata account not found")
+
         submit_bid_ix = self.client.get_svm_submit_bid_instruction(
             searcher=self.private_key.pubkey(),
             router=router,
@@ -118,6 +152,8 @@ class SimpleSearcherSvm:
             bid_amount=self.bid_amount,
             deadline=DEADLINE,
             chain_id=self.chain_id,
+            fee_receiver_relayer=self.express_relay_metadata.fee_receiver_relayer,
+            relayer_signer=self.express_relay_metadata.relayer_signer,
         )
         transaction = Transaction.new_with_payer(
             [submit_bid_ix] + ixs_take_order, self.private_key.pubkey()
@@ -128,19 +164,23 @@ class SimpleSearcherSvm:
             [self.private_key], recent_blockhash=blockhash.blockhash
         )
         bid = BidSvm(transaction=transaction, chain_id=self.chain_id)
-        bid_id = await self.client.submit_bid(bid, False)
-        print(f"Submitted bid {bid_id} for order {order['address']}")
+        return bid
 
 
 async def main():
     parser = argparse.ArgumentParser()
     parser.add_argument("-v", "--verbose", action="count", default=0)
-    parser.add_argument(
+    group = parser.add_mutually_exclusive_group(required=True)
+    group.add_argument(
         "--private-key",
         type=str,
-        required=True,
         help="Private key of the searcher in base58 format",
     )
+    group.add_argument(
+        "--private-key-json-file",
+        type=str,
+        help="Path to a json file containing the private key of the searcher in array of bytes format",
+    )
     parser.add_argument(
         "--chain-id",
         type=str,
@@ -178,6 +218,14 @@ async def main():
         required=True,
         help="The amount of bid to submit for each opportunity",
     )
+    parser.add_argument(
+        "--fill-rate",
+        type=int,
+        default=100,
+        required=True,
+        help="How much of the order to fill in percentage. Default is 100%",
+    )
+
     args = parser.parse_args()
 
     logger.setLevel(logging.INFO if args.verbose == 0 else logging.DEBUG)
@@ -189,17 +237,28 @@ async def main():
     log_handler.setFormatter(formatter)
     logger.addHandler(log_handler)
 
+    if args.private_key:
+        searcher_keypair = Keypair.from_base58_string(args.private_key)
+    else:
+        with open(args.private_key_json_file, "r") as f:
+            searcher_keypair = Keypair.from_json(f.read())
+
+    print("Using Keypair with pubkey:", searcher_keypair.pubkey())
     searcher = SimpleSearcherSvm(
         args.endpoint_express_relay,
-        Keypair.from_base58_string(args.private_key),
+        searcher_keypair,
         args.bid,
         args.chain_id,
         args.endpoint_svm,
         args.global_config,
+        args.fill_rate,
         args.api_key,
     )
 
-    await searcher.bid_on_new_orders()
+    await searcher.client.subscribe_chains([args.chain_id])
+
+    task = await searcher.client.get_ws_loop()
+    await task
 
 
 if __name__ == "__main__":

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

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

+ 23 - 8
governance/pyth_staking_sdk/src/pyth-staking-client.ts

@@ -172,7 +172,9 @@ export class PythStakingClient {
     publisher: PublicKey,
   ) {
     return this.integrityPoolProgram.account.delegationRecord
-      .fetch(getDelegationRecordAddress(stakeAccountPositions, publisher))
+      .fetchNullable(
+        getDelegationRecordAddress(stakeAccountPositions, publisher),
+      )
       .then((record) => convertBNToBigInt(record));
   }
 
@@ -685,10 +687,23 @@ export class PythStakingClient {
       ),
     );
 
+    const delegationRecords = await Promise.all(
+      publishers.map(({ pubkey }) =>
+        this.getDelegationRecord(stakeAccountPositions, pubkey),
+      ),
+    );
+
+    const currentEpoch = await getCurrentEpoch(this.connection);
+
+    // Filter out delegationRecord that are up to date
+    const filteredPublishers = publishers.filter((_, index) => {
+      return !(delegationRecords[index]?.lastEpoch === currentEpoch);
+    });
+
     // anchor does not calculate the correct pda for other programs
     // therefore we need to manually calculate the pdas
     const advanceDelegationRecordInstructions = await Promise.all(
-      publishers.map(({ pubkey, stakeAccount }) =>
+      filteredPublishers.map(({ pubkey, stakeAccount }) =>
         this.integrityPoolProgram.methods
           .advanceDelegationRecord()
           .accountsPartial({
@@ -761,7 +776,7 @@ export class PythStakingClient {
       totalRewards += BigInt("0x" + buffer.toString("hex"));
     }
 
-    const delegationRecords = await Promise.allSettled(
+    const delegationRecords = await Promise.all(
       instructions.publishers.map(({ pubkey }) =>
         this.getDelegationRecord(stakeAccountPositions, pubkey),
       ),
@@ -769,11 +784,11 @@ export class PythStakingClient {
 
     let lowestEpoch: bigint | undefined;
     for (const record of delegationRecords) {
-      if (record.status === "fulfilled") {
-        const { lastEpoch } = record.value;
-        if (lowestEpoch === undefined || lastEpoch < lowestEpoch) {
-          lowestEpoch = lastEpoch;
-        }
+      if (
+        record !== null &&
+        (lowestEpoch === undefined || record.lastEpoch < lowestEpoch)
+      ) {
+        lowestEpoch = record.lastEpoch;
       }
     }