diff --git a/apps/staking/src/config/server.ts b/apps/staking/src/config/server.ts index 081020a5fd..5237da5e67 100644 --- a/apps/staking/src/config/server.ts +++ b/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, diff --git a/apps/staking/src/middleware.ts b/apps/staking/src/middleware.ts index 26e8a14a6f..cc8795d526 100644 --- a/apps/staking/src/middleware.ts +++ b/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) + ); } }; diff --git a/express_relay/examples/easy_lend/src/monitor.ts b/express_relay/examples/easy_lend/src/monitor.ts index f5b6cd2844..bc12dfbb31 100644 --- a/express_relay/examples/easy_lend/src/monitor.ts +++ b/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, diff --git a/express_relay/sdk/js/package.json b/express_relay/sdk/js/package.json index 318f1a7562..bbee24dddf 100644 --- a/express_relay/sdk/js/package.json +++ b/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", diff --git a/express_relay/sdk/js/src/const.ts b/express_relay/sdk/js/src/const.ts index 58374b0d2c..ec31296d25 100644 --- a/express_relay/sdk/js/src/const.ts +++ b/express_relay/sdk/js/src/const.ts @@ -25,12 +25,6 @@ export const OPPORTUNITY_ADAPTER_CONFIGS: Record< export const SVM_CONSTANTS: Record = { "development-solana": { - relayerSigner: new PublicKey( - "GEeEguHhepHtPVo3E9RA1wvnxgxJ61iSc9dJfd433w3K" - ), - feeReceiverRelayer: new PublicKey( - "feesJcX9zwLiEZs9iQGXeBd65b9m2Zc1LjjyHngQF29" - ), expressRelayProgram: new PublicKey( "PytERJFhAKuNNuaiXkApLfWzwNwSNDACpigT3LwQfou" ), diff --git a/express_relay/sdk/js/src/evm.ts b/express_relay/sdk/js/src/evm.ts new file mode 100644 index 0000000000..aec2d0aa90 --- /dev/null +++ b/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 { + 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 { + const account = privateKeyToAccount(privateKey); + const signature = await getSignature(opportunity, bidParams, privateKey); + + return { + permissionKey: opportunity.permissionKey, + bid: bidParams, + executor: account.address, + signature, + opportunityId: opportunity.opportunityId, + }; +} diff --git a/express_relay/sdk/js/src/examples/simpleSearcherEvm.ts b/express_relay/sdk/js/src/examples/simpleSearcherEvm.ts index 8df89326e7..743f5e6c5f 100644 --- a/express_relay/sdk/js/src/examples/simpleSearcherEvm.ts +++ b/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 diff --git a/express_relay/sdk/js/src/examples/simpleSearcherLimo.ts b/express_relay/sdk/js/src/examples/simpleSearcherLimo.ts index 98ee428d71..f1da29e0e3 100644 --- a/express_relay/sdk/js/src/examples/simpleSearcherLimo.ts +++ b/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(); diff --git a/express_relay/sdk/js/src/examples/simpleSearcherSvm.ts b/express_relay/sdk/js/src/examples/simpleSearcherSvm.ts index 949251f132..9f9c995cb0 100644 --- a/express_relay/sdk/js/src/examples/simpleSearcherSvm.ts +++ b/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 = { @@ -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 { diff --git a/express_relay/sdk/js/src/index.ts b/express_relay/sdk/js/src/index.ts index f95874af9a..5c664f43b0 100644 --- a/express_relay/sdk/js/src/index.ts +++ b/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(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 { - 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 { - 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 { + 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 { + 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 { + 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 { - const expressRelay = new Program( - 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 { - 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", - }; } } diff --git a/express_relay/sdk/js/src/serverTypes.d.ts b/express_relay/sdk/js/src/serverTypes.d.ts index cc4676ce04..d4242af5dd 100644 --- a/express_relay/sdk/js/src/serverTypes.d.ts +++ b/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"]; diff --git a/express_relay/sdk/js/src/svm.ts b/express_relay/sdk/js/src/svm.ts new file mode 100644 index 0000000000..1eae25288a --- /dev/null +++ b/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 { + const expressRelay = new Program( + 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 { + 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 { + const provider = new AnchorProvider( + connection, + new NodeWallet(new Keypair()) + ); + const expressRelay = new Program( + expressRelayIdl as ExpressRelay, + provider + ); + const metadata = await expressRelay.account.expressRelayMetadata.fetch( + getExpressRelayMetadataPda(chainId) + ); + return { + feeReceiverRelayer: metadata.feeReceiverRelayer, + relayerSigner: metadata.relayerSigner, + }; +} diff --git a/express_relay/sdk/js/src/svmPda.ts b/express_relay/sdk/js/src/svmPda.ts deleted file mode 100644 index 970c8c2802..0000000000 --- a/express_relay/sdk/js/src/svmPda.ts +++ /dev/null @@ -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]; -} diff --git a/express_relay/sdk/js/src/types.ts b/express_relay/sdk/js/src/types.ts index c6b93398a0..a9adea4168 100644 --- a/express_relay/sdk/js/src/types.ts +++ b/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 + | Omit; + +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; 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; }; diff --git a/express_relay/sdk/python/express_relay/client.py b/express_relay/sdk/python/express_relay/client.py index eb1e69b63e..ce3d7edb15 100644 --- a/express_relay/sdk/python/express_relay/client.py +++ b/express_relay/sdk/python/express_relay/client.py @@ -38,6 +38,8 @@ 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 @@ async def ws_handler( 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 @@ async def get_opportunities(self, chain_id: str | None = None) -> list[Opportuni 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 @@ def get_svm_submit_bid_instruction( 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 @@ def get_svm_submit_bid_instruction( {"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. diff --git a/express_relay/sdk/python/express_relay/constants.py b/express_relay/sdk/python/express_relay/constants.py index 5e7abc2510..7d1d83896d 100644 --- a/express_relay/sdk/python/express_relay/constants.py +++ b/express_relay/sdk/python/express_relay/constants.py @@ -32,8 +32,6 @@ class SvmProgramConfig(TypedDict): express_relay_program: Pubkey - relayer_signer: Pubkey - fee_receiver_relayer: Pubkey SVM_CONFIGS: Dict[str, SvmProgramConfig] = { @@ -41,11 +39,5 @@ class SvmProgramConfig(TypedDict): "express_relay_program": Pubkey.from_string( "PytERJFhAKuNNuaiXkApLfWzwNwSNDACpigT3LwQfou" ), - "relayer_signer": Pubkey.from_string( - "GEeEguHhepHtPVo3E9RA1wvnxgxJ61iSc9dJfd433w3K" - ), - "fee_receiver_relayer": Pubkey.from_string( - "feesJcX9zwLiEZs9iQGXeBd65b9m2Zc1LjjyHngQF29" - ), } } diff --git a/express_relay/sdk/python/express_relay/express_relay_svm_types.py b/express_relay/sdk/python/express_relay/express_relay_svm_types.py new file mode 100644 index 0000000000..49ac5767fd --- /dev/null +++ b/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 diff --git a/express_relay/sdk/python/express_relay/express_relay_types.py b/express_relay/sdk/python/express_relay/express_relay_types.py index 44eed19c68..47eee7e715 100644 --- a/express_relay/sdk/python/express_relay/express_relay_types.py +++ b/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 @@ 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 @@ def check_result(self): @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 @@ def check_version(cls, data): ) 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): diff --git a/express_relay/sdk/python/express_relay/searcher/examples/simple_searcher.py b/express_relay/sdk/python/express_relay/searcher/examples/simple_searcher.py index ae06871ce2..eff22f2f88 100644 --- a/express_relay/sdk/python/express_relay/searcher/examples/simple_searcher.py +++ b/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 @@ Bytes32, BidStatus, BidStatusUpdate, + OpportunityEvm, ) logger = logging.getLogger(__name__) @@ -40,7 +43,7 @@ def __init__( 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 @@ async def opportunity_callback(self, opp: Opportunity): 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) diff --git a/express_relay/sdk/python/express_relay/searcher/examples/simple_searcher_svm.py b/express_relay/sdk/python/express_relay/searcher/examples/simple_searcher_svm.py index 968e9a706c..643605aaf0 100644 --- a/express_relay/sdk/python/express_relay/searcher/examples/simple_searcher_svm.py +++ b/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 @@ 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 @@ class SimpleSearcherSvm: + express_relay_metadata: ExpressRelayMetadata | None + def __init__( self, server_url: str, @@ -32,12 +42,13 @@ def __init__( 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 @@ def __init__( 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 @@ async def bid_status_callback(self, bid_status_update: BidStatusUpdate): 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 @@ async def evaluate_order(self, order: OrderStateAndAddress): 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 @@ async def evaluate_order(self, order: OrderStateAndAddress): 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 @@ async def evaluate_order(self, order: OrderStateAndAddress): 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 @@ async def evaluate_order(self, order: OrderStateAndAddress): [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__": diff --git a/express_relay/sdk/python/pyproject.toml b/express_relay/sdk/python/pyproject.toml index 26ef2cf768..d2769a7fb8 100644 --- a/express_relay/sdk/python/pyproject.toml +++ b/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" diff --git a/governance/pyth_staking_sdk/src/pyth-staking-client.ts b/governance/pyth_staking_sdk/src/pyth-staking-client.ts index 79e21fbdf2..68ecab1b8d 100644 --- a/governance/pyth_staking_sdk/src/pyth-staking-client.ts +++ b/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; } }