Initial version of js sdk for express relay (#1281)

This commit is contained in:
Amin Moghaddam 2024-02-08 10:56:17 +01:00 committed by GitHub
parent ca852ea989
commit 5c8e372ef6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 3018 additions and 0 deletions

View File

@ -0,0 +1,6 @@
module.exports = {
root: true,
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint"],
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
};

1
express_relay/sdk/js/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
lib/

View File

@ -0,0 +1,74 @@
# Pyth Express Relay JS SDK
Utility library for interacting with the Pyth Express Relay API.
## Installation
### npm
```
$ npm install --save @pythnetwork/express-relay-evm-js
```
### Yarn
```
$ yarn add @pythnetwork/express-relay-evm-js
```
## Development
To generate the latest type declarations from the server openapi schema, run:
```bash
npm run generate-api-types
```
## Quickstart
```typescript
import {
Client,
OpportunityParams,
BidInfo,
} from "@pythnetwork/express-relay-evm-js";
const client = new Client({ baseUrl: "https://per-staging.dourolabs.app/" });
function calculateOpportunityBid(
opportunity: OpportunityParams
): BidInfo | null {
// searcher implementation here
// if the opportunity is not suitable for the searcher, return null
}
const opportunities = await client.getOpportunities();
for (const opportunity of opportunities) {
const bidInfo = calculateOpportunityBid(order);
if (bidInfo === null) continue;
const opportunityBid = await client.signOpportunityBid(
opportunity,
bidInfo,
privateKey // searcher private key with appropriate permissions and assets
);
await client.submitOpportunityBid(opportunityBid);
}
```
### Example
There is an example searcher in [examples](./src/examples/) directory.
#### SimpleSearcher
[This example](./src/examples/SimpleSearcher.ts) fetches `OpportunityParams` from the specified endpoint,
creates a fixed bid on each opportunity and signs them with the provided private key, and finally submits them back to the server. You can run it with
`npm run simple-searcher`. A full command looks like this:
```bash
npm run simple-searcher -- \
--endpoint https://per-staging.dourolabs.app/ \
--bid 100000 \
--chain-id op_sepolia \
--private-key <YOUR-PRIVATE-KEY>
```

2192
express_relay/sdk/js/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,50 @@
{
"name": "@pythnetwork/express-relay-evm-js",
"version": "0.1.0",
"description": "Utilities for interacting with the express relay protocol",
"homepage": "https://pyth.network",
"author": {
"name": "Pyth Data Association"
},
"main": "lib/index.js",
"types": "lib/index.d.ts",
"files": [
"lib/**/*"
],
"scripts": {
"build": "tsc",
"simple-searcher": "npm run build && node lib/examples/SimpleSearcher.js",
"generate-api-types": "openapi-typescript http://127.0.0.1:9000/docs/openapi.json --output src/types.d.ts",
"format": "prettier --write \"src/**/*.ts\"",
"lint": "eslint src",
"prepublishOnly": "npm run build && npm test && npm run lint",
"preversion": "npm run lint",
"version": "npm run format && git add -A src"
},
"keywords": [
"pyth",
"oracle",
"relay"
],
"repository": {
"type": "git",
"url": "https://github.com/pyth-network/pyth-crosschain",
"directory": "express_relay/sdk/js"
},
"dependencies": {
"openapi-client-axios": "^7.5.4",
"openapi-fetch": "^0.8.2",
"viem": "^2.7.6"
},
"devDependencies": {
"@types/yargs": "^17.0.10",
"@typescript-eslint/eslint-plugin": "^5.21.0",
"@typescript-eslint/parser": "^5.21.0",
"eslint": "^8.56.0",
"openapi-typescript": "^6.5.5",
"prettier": "^2.6.2",
"typescript": "5.1",
"yargs": "^17.4.1"
},
"license": "Apache-2.0"
}

View File

@ -0,0 +1,78 @@
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
import { Client } from "../index";
import { privateKeyToAccount } from "viem/accounts";
import { isHex } from "viem";
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const argv = yargs(hideBin(process.argv))
.option("endpoint", {
description:
"Express relay endpoint. e.g: https://per-staging.dourolabs.app/",
type: "string",
demandOption: true,
})
.option("chain-id", {
description: "Chain id to fetch opportunities for. e.g: sepolia",
type: "string",
})
.option("bid", {
description: "Bid amount in wei",
type: "string",
default: "100",
})
.option("private-key", {
description:
"Private key to sign the bid with in hex format with 0x prefix. e.g: 0xdeadbeef...",
type: "string",
demandOption: true,
})
.help()
.alias("help", "h")
.parseSync();
async function run() {
const client = new Client({ baseUrl: argv.endpoint });
if (isHex(argv.privateKey)) {
const account = privateKeyToAccount(argv.privateKey);
console.log(`Using account: ${account.address}`);
} else {
throw new Error(`Invalid private key: ${argv.privateKey}`);
}
const DAY_IN_SECONDS = 60 * 60 * 24;
// eslint-disable-next-line no-constant-condition
while (true) {
const opportunities = await client.getOpportunities(argv.chainId);
console.log(`Fetched ${opportunities.length} opportunities`);
for (const opportunity of opportunities) {
const bid = BigInt(argv.bid);
// Bid info should be generated by evaluating the opportunity
// here for simplicity we are using a constant bid and 24 hours of validity
const bidInfo = {
amount: bid,
validUntil: BigInt(Math.round(Date.now() / 1000 + DAY_IN_SECONDS)),
};
const opportunityBid = await client.signOpportunityBid(
opportunity,
bidInfo,
argv.privateKey
);
try {
await client.submitOpportunityBid(opportunityBid);
console.log(
`Successful bid ${bid} on opportunity ${opportunity.opportunityId}`
);
} catch (error) {
console.error(
`Failed to bid on opportunity ${opportunity.opportunityId}: ${error}`
);
}
}
await sleep(5000);
}
}
run();

View File

@ -0,0 +1,283 @@
import type { paths } from "./types";
import createClient, { ClientOptions } from "openapi-fetch";
import {
Address,
encodeAbiParameters,
encodePacked,
Hex,
isAddress,
isHex,
keccak256,
} from "viem";
import { privateKeyToAccount, sign, signatureToHex } from "viem/accounts";
/**
* ERC20 token with contract address and amount
*/
export type TokenQty = {
contract: Address;
amount: bigint;
};
/**
* Bid information
*/
export type BidInfo = {
/**
* Bid amount in wei
*/
amount: bigint;
/**
* Unix timestamp for when the bid is no longer valid in seconds
*/
validUntil: bigint;
};
/**
* All the parameters necessary to represent a liquidation opportunity
*/
export type Opportunity = {
/**
* The chain id where the liquidation will be executed.
*/
chainId: string;
/**
* Unique identifier for the opportunity
*/
opportunityId: string;
/**
* Permission key required for succesful execution of the liquidation.
*/
permissionKey: Hex;
/**
* Contract address to call for execution of the liquidation.
*/
contract: Address;
/**
* Calldata for the contract call.
*/
calldata: Hex;
/**
* Value to send with the contract call.
*/
value: bigint;
/**
* Tokens required to repay the debt
*/
repayTokens: TokenQty[];
/**
* Tokens to receive after the liquidation
*/
receiptTokens: TokenQty[];
};
/**
* Represents a bid for a liquidation opportunity
*/
export type OpportunityBid = {
/**
* Opportunity unique identifier in uuid format
*/
opportunityId: string;
/**
* The permission key required for succesful execution of the liquidation.
*/
permissionKey: Hex;
/**
* Liquidator address
*/
liquidator: Address;
/**
* Signature of the liquidator
*/
signature: Hex;
bid: BidInfo;
};
export function checkHex(hex: string): Hex {
if (isHex(hex)) {
return hex;
}
throw new Error(`Invalid hex: ${hex}`);
}
export function checkAddress(address: string): Address {
if (isAddress(address)) {
return address;
}
throw new Error(`Invalid address: ${address}`);
}
function checkTokenQty(token: { contract: string; amount: string }): TokenQty {
return {
contract: checkAddress(token.contract),
amount: BigInt(token.amount),
};
}
export class Client {
private clientOptions?: ClientOptions;
constructor(clientOptions?: ClientOptions) {
this.clientOptions = clientOptions;
}
/**
* Fetches liquidation opportunities
* @param chainId Chain id to fetch opportunities for. e.g: sepolia
*/
async getOpportunities(chainId?: string): Promise<Opportunity[]> {
const client = createClient<paths>(this.clientOptions);
const opportunities = await client.GET("/v1/liquidation/opportunities", {
params: { query: { chain_id: chainId } },
});
if (opportunities.data === undefined) {
throw new Error("No opportunities found");
}
return opportunities.data.flatMap((opportunity) => {
if (opportunity.version != "v1") {
console.warn(
`Can not handle opportunity version: ${opportunity.version}. Please upgrade your client.`
);
return [];
}
return {
chainId: opportunity.chain_id,
opportunityId: opportunity.opportunity_id,
permissionKey: checkHex(opportunity.permission_key),
contract: checkAddress(opportunity.contract),
calldata: checkHex(opportunity.calldata),
value: BigInt(opportunity.value),
repayTokens: opportunity.repay_tokens.map(checkTokenQty),
receiptTokens: opportunity.receipt_tokens.map(checkTokenQty),
};
});
}
/**
* Submits a liquidation opportunity to be exposed to searchers
* @param opportunity Opportunity to submit
*/
async submitOpportunity(opportunity: Omit<Opportunity, "opportunityId">) {
const client = createClient<paths>(this.clientOptions);
const response = await client.POST("/v1/liquidation/opportunities", {
body: {
chain_id: opportunity.chainId,
version: "v1",
permission_key: opportunity.permissionKey,
contract: opportunity.contract,
calldata: opportunity.calldata,
value: opportunity.value.toString(),
repay_tokens: opportunity.repayTokens.map((token) => ({
contract: token.contract,
amount: token.amount.toString(),
})),
receipt_tokens: opportunity.receiptTokens.map((token) => ({
contract: token.contract,
amount: token.amount.toString(),
})),
},
});
if (response.error) {
throw new Error(response.error.error);
}
}
/**
* Creates a signed bid for a liquidation opportunity
* @param opportunity Opportunity to bid on
* @param bidInfo Bid amount and valid until timestamp
* @param privateKey Private key to sign the bid with
*/
async signOpportunityBid(
opportunity: Opportunity,
bidInfo: BidInfo,
privateKey: Hex
): Promise<OpportunityBid> {
const account = privateKeyToAccount(privateKey);
const convertTokenQty = (token: TokenQty): [Hex, bigint] => [
token.contract,
token.amount,
];
const payload = encodeAbiParameters(
[
{
name: "repayTokens",
type: "tuple[]",
components: [
{
type: "address",
},
{
type: "uint256",
},
],
},
{
name: "receiptTokens",
type: "tuple[]",
components: [
{
type: "address",
},
{
type: "uint256",
},
],
},
{ name: "contract", type: "address" },
{ name: "calldata", type: "bytes" },
{ name: "value", type: "uint256" },
{ name: "bid", type: "uint256" },
],
[
opportunity.repayTokens.map(convertTokenQty),
opportunity.receiptTokens.map(convertTokenQty),
opportunity.contract,
opportunity.calldata,
opportunity.value,
bidInfo.amount,
]
);
const msgHash = keccak256(
encodePacked(["bytes", "uint256"], [payload, bidInfo.validUntil])
);
const hash = signatureToHex(await sign({ hash: msgHash, privateKey }));
return {
permissionKey: opportunity.permissionKey,
bid: bidInfo,
liquidator: account.address,
signature: hash,
opportunityId: opportunity.opportunityId,
};
}
/**
* Submits a bid for a liquidation opportunity
* @param bid
*/
async submitOpportunityBid(bid: OpportunityBid) {
const client = createClient<paths>(this.clientOptions);
const response = await client.POST(
"/v1/liquidation/opportunities/{opportunity_id}/bids",
{
body: {
amount: bid.bid.amount.toString(),
liquidator: bid.liquidator,
permission_key: bid.permissionKey,
signature: bid.signature,
valid_until: bid.bid.validUntil.toString(),
},
params: { path: { opportunity_id: bid.opportunityId } },
}
);
if (response.error) {
throw new Error(response.error.error);
}
}
}

319
express_relay/sdk/js/src/types.d.ts vendored Normal file
View File

@ -0,0 +1,319 @@
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
"/v1/bids": {
/**
* Bid on a specific permission key for a specific chain.
* @description Bid on a specific permission key for a specific chain.
*
* Your bid will be simulated and verified by the server. Depending on the outcome of the auction, a transaction
* containing the contract call will be sent to the blockchain expecting the bid amount to be paid after the call.
*/
post: operations["bid"];
};
"/v1/liquidation/opportunities": {
/**
* Fetch all liquidation opportunities ready to be exectued.
* @description Fetch all liquidation opportunities ready to be exectued.
*/
get: operations["get_opportunities"];
/**
* Submit a liquidation opportunity ready to be executed.
* @description Submit a liquidation opportunity ready to be executed.
*
* The opportunity will be verified by the server. If the opportunity is valid, it will be stored in the database
* and will be available for bidding.
*/
post: operations["post_opportunity"];
};
"/v1/liquidation/opportunities/{opportunity_id}/bids": {
/**
* Bid on liquidation opportunity
* @description Bid on liquidation opportunity
*/
post: operations["post_bid"];
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
Bid: {
/**
* @description Amount of bid in wei.
* @example 10
*/
amount: string;
/**
* @description Calldata for the contract call.
* @example 0xdeadbeef
*/
calldata: string;
/**
* @description The chain id to bid on.
* @example sepolia
*/
chain_id: string;
/**
* @description The contract address to call.
* @example 0xcA11bde05977b3631167028862bE2a173976CA11
*/
contract: string;
/**
* @description The permission key to bid on.
* @example 0xdeadbeef
*/
permission_key: string;
};
BidResult: {
status: string;
};
ErrorBodyResponse: {
error: string;
};
OpportunityBid: {
/**
* @description The bid amount in wei.
* @example 1000000000000000000
*/
amount: string;
/**
* @description Liquidator address
* @example 0x5FbDB2315678afecb367f032d93F642f64180aa2
*/
liquidator: string;
/**
* @description The opportunity permission key
* @example 0xdeadbeefcafe
*/
permission_key: string;
/** @example 0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12 */
signature: string;
/**
* @description How long the bid will be valid for.
* @example 1000000000000000000
*/
valid_until: string;
};
OpportunityParams: components["schemas"]["OpportunityParamsV1"] & {
/** @enum {string} */
version: "v1";
};
/**
* @description Opportunity parameters needed for on-chain execution
* If a searcher signs the opportunity and have approved enough tokens to liquidation adapter,
* by calling this contract with the given calldata and structures, they will receive the tokens specified
* in the receipt_tokens field, and will send the tokens specified in the repay_tokens field.
*/
OpportunityParamsV1: {
/**
* @description Calldata for the contract call.
* @example 0xdeadbeef
*/
calldata: string;
/**
* @description The chain id where the liquidation will be executed.
* @example sepolia
*/
chain_id: string;
/**
* @description The contract address to call for execution of the liquidation.
* @example 0xcA11bde05977b3631167028862bE2a173976CA11
*/
contract: string;
/**
* @description The permission key required for succesful execution of the liquidation.
* @example 0xdeadbeefcafe
*/
permission_key: string;
receipt_tokens: components["schemas"]["TokenQty"][];
repay_tokens: components["schemas"]["TokenQty"][];
/**
* @description The value to send with the contract call.
* @example 1
*/
value: string;
};
/** @description Similar to OpportunityParams, but with the opportunity id included. */
OpportunityParamsWithId: components["schemas"]["OpportunityParams"] & {
/**
* @description The opportunity unique id
* @example f47ac10b-58cc-4372-a567-0e02b2c3d479
*/
opportunity_id: string;
};
TokenQty: {
/**
* @description Token amount
* @example 1000
*/
amount: string;
/**
* @description Token contract address
* @example 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
*/
contract: string;
};
};
responses: {
BidResult: {
content: {
"application/json": {
status: string;
};
};
};
/** @description An error occurred processing the request */
ErrorBodyResponse: {
content: {
"application/json": {
error: string;
};
};
};
/** @description Similar to OpportunityParams, but with the opportunity id included. */
OpportunityParamsWithId: {
content: {
"application/json": components["schemas"]["OpportunityParams"] & {
/**
* @description The opportunity unique id
* @example f47ac10b-58cc-4372-a567-0e02b2c3d479
*/
opportunity_id: string;
};
};
};
};
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export type external = Record<string, never>;
export interface operations {
/**
* Bid on a specific permission key for a specific chain.
* @description Bid on a specific permission key for a specific chain.
*
* Your bid will be simulated and verified by the server. Depending on the outcome of the auction, a transaction
* containing the contract call will be sent to the blockchain expecting the bid amount to be paid after the call.
*/
bid: {
requestBody: {
content: {
"application/json": components["schemas"]["Bid"];
};
};
responses: {
/** @description Bid was placed succesfully */
200: {
content: {
"application/json": components["schemas"]["BidResult"];
};
};
400: components["responses"]["ErrorBodyResponse"];
/** @description Chain id was not found */
404: {
content: {
"application/json": components["schemas"]["ErrorBodyResponse"];
};
};
};
};
/**
* Fetch all liquidation opportunities ready to be exectued.
* @description Fetch all liquidation opportunities ready to be exectued.
*/
get_opportunities: {
parameters: {
query?: {
/** @example sepolia */
chain_id?: string | null;
};
};
responses: {
/** @description Array of liquidation opportunities ready for bidding */
200: {
content: {
"application/json": components["schemas"]["OpportunityParamsWithId"][];
};
};
400: components["responses"]["ErrorBodyResponse"];
/** @description Chain id was not found */
404: {
content: {
"application/json": components["schemas"]["ErrorBodyResponse"];
};
};
};
};
/**
* Submit a liquidation opportunity ready to be executed.
* @description Submit a liquidation opportunity ready to be executed.
*
* The opportunity will be verified by the server. If the opportunity is valid, it will be stored in the database
* and will be available for bidding.
*/
post_opportunity: {
requestBody: {
content: {
"application/json": components["schemas"]["OpportunityParams"];
};
};
responses: {
/** @description The created opportunity */
200: {
content: {
"application/json": components["schemas"]["OpportunityParamsWithId"];
};
};
400: components["responses"]["ErrorBodyResponse"];
/** @description Chain id was not found */
404: {
content: {
"application/json": components["schemas"]["ErrorBodyResponse"];
};
};
};
};
/**
* Bid on liquidation opportunity
* @description Bid on liquidation opportunity
*/
post_bid: {
parameters: {
path: {
/** @description Opportunity id to bid on */
opportunity_id: string;
};
};
requestBody: {
content: {
"application/json": components["schemas"]["OpportunityBid"];
};
};
responses: {
/** @description Bid Result */
200: {
content: {
"application/json": components["schemas"]["BidResult"];
};
};
400: components["responses"]["ErrorBodyResponse"];
/** @description Opportunity or chain id was not found */
404: {
content: {
"application/json": components["schemas"]["ErrorBodyResponse"];
};
};
};
};
}

View File

@ -0,0 +1,15 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"target": "esnext",
"module": "commonjs",
"declaration": true,
"rootDir": "src/",
"outDir": "./lib",
"strict": true,
"esModuleInterop": true,
"resolveJsonModule": true
},
"include": ["src"],
"exclude": ["node_modules", "**/__tests__/*"]
}