Skip to content

Commit f22c0c8

Browse files
authored
feat(express-relay): Support websocket in the js sdk (#1301)
1 parent 3c1348c commit f22c0c8

File tree

6 files changed

+423
-171
lines changed

6 files changed

+423
-171
lines changed

express_relay/sdk/js/README.md

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,24 +35,22 @@ import {
3535

3636
const client = new Client({ baseUrl: "https://per-staging.dourolabs.app/" });
3737

38-
function calculateOpportunityBid(
39-
opportunity: OpportunityParams
40-
): BidInfo | null {
38+
function calculateOpportunityBid(opportunity: Opportunity): BidInfo | null {
4139
// searcher implementation here
4240
// if the opportunity is not suitable for the searcher, return null
4341
}
44-
const opportunities = await client.getOpportunities();
4542

46-
for (const opportunity of opportunities) {
47-
const bidInfo = calculateOpportunityBid(order);
48-
if (bidInfo === null) continue;
43+
client.setOpportunityHandler(async (opportunity: Opportunity) => {
44+
const bidInfo = calculateOpportunityBid(opportunity);
45+
if (bidInfo === null) return;
4946
const opportunityBid = await client.signOpportunityBid(
5047
opportunity,
5148
bidInfo,
5249
privateKey // searcher private key with appropriate permissions and assets
5350
);
5451
await client.submitOpportunityBid(opportunityBid);
55-
}
52+
});
53+
await client.subscribeChains([chain_id]); // chain id you want to subscribe to
5654
```
5755

5856
### Example

express_relay/sdk/js/package.json

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@pythnetwork/express-relay-evm-js",
3-
"version": "0.1.0",
3+
"version": "0.1.1",
44
"description": "Utilities for interacting with the express relay protocol",
55
"homepage": "https://pyth.network",
66
"author": {
@@ -36,20 +36,22 @@
3636
"directory": "express_relay/sdk/js"
3737
},
3838
"dependencies": {
39+
"isomorphic-ws": "^5.0.0",
3940
"openapi-client-axios": "^7.5.4",
4041
"openapi-fetch": "^0.8.2",
41-
"viem": "^2.7.6"
42+
"viem": "^2.7.6",
43+
"ws": "^8.16.0"
4244
},
4345
"devDependencies": {
4446
"@types/yargs": "^17.0.10",
4547
"@typescript-eslint/eslint-plugin": "^5.21.0",
4648
"@typescript-eslint/parser": "^5.21.0",
4749
"eslint": "^8.56.0",
50+
"jest": "^27.5.1",
4851
"openapi-typescript": "^6.5.5",
4952
"prettier": "^2.6.2",
5053
"typescript": "^5.1",
51-
"yargs": "^17.4.1",
52-
"jest": "^27.5.1"
54+
"yargs": "^17.4.1"
5355
},
5456
"license": "Apache-2.0"
5557
}

express_relay/sdk/js/src/examples/SimpleSearcher.ts

Lines changed: 32 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,9 @@
11
import yargs from "yargs";
22
import { hideBin } from "yargs/helpers";
3-
import { Client } from "../index";
3+
import { checkHex, Client } from "../index";
44
import { privateKeyToAccount } from "viem/accounts";
55
import { isHex } from "viem";
66

7-
function sleep(ms: number) {
8-
return new Promise((resolve) => setTimeout(resolve, ms));
9-
}
10-
117
const argv = yargs(hideBin(process.argv))
128
.option("endpoint", {
139
description:
@@ -18,6 +14,7 @@ const argv = yargs(hideBin(process.argv))
1814
.option("chain-id", {
1915
description: "Chain id to fetch opportunities for. e.g: sepolia",
2016
type: "string",
17+
demandOption: true,
2118
})
2219
.option("bid", {
2320
description: "Bid amount in wei",
@@ -43,35 +40,38 @@ async function run() {
4340
throw new Error(`Invalid private key: ${argv.privateKey}`);
4441
}
4542
const DAY_IN_SECONDS = 60 * 60 * 24;
46-
// eslint-disable-next-line no-constant-condition
47-
while (true) {
48-
const opportunities = await client.getOpportunities(argv.chainId);
49-
console.log(`Fetched ${opportunities.length} opportunities`);
50-
for (const opportunity of opportunities) {
51-
const bid = BigInt(argv.bid);
52-
// Bid info should be generated by evaluating the opportunity
53-
// here for simplicity we are using a constant bid and 24 hours of validity
54-
const bidInfo = {
55-
amount: bid,
56-
validUntil: BigInt(Math.round(Date.now() / 1000 + DAY_IN_SECONDS)),
57-
};
58-
const opportunityBid = await client.signOpportunityBid(
59-
opportunity,
60-
bidInfo,
61-
argv.privateKey
43+
client.setOpportunityHandler(async (opportunity) => {
44+
const bid = BigInt(argv.bid);
45+
// Bid info should be generated by evaluating the opportunity
46+
// here for simplicity we are using a constant bid and 24 hours of validity
47+
const bidInfo = {
48+
amount: bid,
49+
validUntil: BigInt(Math.round(Date.now() / 1000 + DAY_IN_SECONDS)),
50+
};
51+
const opportunityBid = await client.signOpportunityBid(
52+
opportunity,
53+
bidInfo,
54+
checkHex(argv.privateKey)
55+
);
56+
try {
57+
await client.submitOpportunityBid(opportunityBid);
58+
console.log(
59+
`Successful bid ${bid} on opportunity ${opportunity.opportunityId}`
60+
);
61+
} catch (error) {
62+
console.error(
63+
`Failed to bid on opportunity ${opportunity.opportunityId}: ${error}`
6264
);
63-
try {
64-
await client.submitOpportunityBid(opportunityBid);
65-
console.log(
66-
`Successful bid ${bid} on opportunity ${opportunity.opportunityId}`
67-
);
68-
} catch (error) {
69-
console.error(
70-
`Failed to bid on opportunity ${opportunity.opportunityId}: ${error}`
71-
);
72-
}
7365
}
74-
await sleep(5000);
66+
});
67+
try {
68+
await client.subscribeChains([argv.chainId]);
69+
console.log(
70+
`Subscribed to chain ${argv.chainId}. Waiting for opportunities...`
71+
);
72+
} catch (error) {
73+
console.error(error);
74+
client.websocket?.close();
7575
}
7676
}
7777

express_relay/sdk/js/src/index.ts

Lines changed: 171 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
import type { paths } from "./types";
2-
import createClient, { ClientOptions } from "openapi-fetch";
1+
import type { paths, components } from "./types";
2+
import createClient, {
3+
ClientOptions as FetchClientOptions,
4+
} from "openapi-fetch";
35
import {
46
Address,
57
encodeAbiParameters,
@@ -10,7 +12,7 @@ import {
1012
keccak256,
1113
} from "viem";
1214
import { privateKeyToAccount, sign, signatureToHex } from "viem/accounts";
13-
15+
import WebSocket from "isomorphic-ws";
1416
/**
1517
* ERC20 token with contract address and amount
1618
*/
@@ -118,11 +120,172 @@ function checkTokenQty(token: { contract: string; amount: string }): TokenQty {
118120
};
119121
}
120122

123+
type ClientOptions = FetchClientOptions & { baseUrl: string };
124+
125+
export interface WsOptions {
126+
/**
127+
* Max time to wait for a response from the server in milliseconds
128+
*/
129+
response_timeout: number;
130+
}
131+
132+
const DEFAULT_WS_OPTIONS: WsOptions = {
133+
response_timeout: 5000,
134+
};
135+
121136
export class Client {
122-
private clientOptions?: ClientOptions;
137+
public clientOptions: ClientOptions;
138+
public wsOptions: WsOptions;
139+
public websocket?: WebSocket;
140+
public idCounter = 0;
141+
public callbackRouter: Record<
142+
string,
143+
(response: components["schemas"]["ServerResultMessage"]) => void
144+
> = {};
145+
private websocketOpportunityCallback?: (
146+
opportunity: Opportunity
147+
) => Promise<void>;
123148

124-
constructor(clientOptions?: ClientOptions) {
149+
constructor(clientOptions: ClientOptions, wsOptions?: WsOptions) {
125150
this.clientOptions = clientOptions;
151+
this.wsOptions = { ...DEFAULT_WS_OPTIONS, ...wsOptions };
152+
}
153+
154+
private connectWebsocket() {
155+
const websocketEndpoint = new URL(this.clientOptions.baseUrl);
156+
websocketEndpoint.protocol =
157+
websocketEndpoint.protocol === "https:" ? "wss:" : "ws:";
158+
websocketEndpoint.pathname = "/v1/ws";
159+
160+
this.websocket = new WebSocket(websocketEndpoint.toString());
161+
this.websocket.on("message", async (data) => {
162+
const message:
163+
| components["schemas"]["ServerResultResponse"]
164+
| components["schemas"]["ServerUpdateResponse"] = JSON.parse(
165+
data.toString()
166+
);
167+
if ("id" in message && message.id) {
168+
const callback = this.callbackRouter[message.id];
169+
if (callback !== undefined) {
170+
callback(message);
171+
delete this.callbackRouter[message.id];
172+
}
173+
} else if ("type" in message && message.type === "new_opportunity") {
174+
if (this.websocketOpportunityCallback !== undefined) {
175+
const convertedOpportunity = this.convertOpportunity(
176+
message.opportunity
177+
);
178+
if (convertedOpportunity !== undefined) {
179+
await this.websocketOpportunityCallback(convertedOpportunity);
180+
}
181+
}
182+
} else if ("error" in message) {
183+
// Can not route error messages to the callback router as they don't have an id
184+
console.error(message.error);
185+
}
186+
});
187+
}
188+
189+
/**
190+
* Converts an opportunity from the server to the client format
191+
* Returns undefined if the opportunity version is not supported
192+
* @param opportunity
193+
*/
194+
private convertOpportunity(
195+
opportunity: components["schemas"]["OpportunityParamsWithMetadata"]
196+
): Opportunity | undefined {
197+
if (opportunity.version != "v1") {
198+
console.warn(
199+
`Can not handle opportunity version: ${opportunity.version}. Please upgrade your client.`
200+
);
201+
return undefined;
202+
}
203+
return {
204+
chainId: opportunity.chain_id,
205+
opportunityId: opportunity.opportunity_id,
206+
permissionKey: checkHex(opportunity.permission_key),
207+
contract: checkAddress(opportunity.contract),
208+
calldata: checkHex(opportunity.calldata),
209+
value: BigInt(opportunity.value),
210+
repayTokens: opportunity.repay_tokens.map(checkTokenQty),
211+
receiptTokens: opportunity.receipt_tokens.map(checkTokenQty),
212+
};
213+
}
214+
215+
public setOpportunityHandler(
216+
callback: (opportunity: Opportunity) => Promise<void>
217+
) {
218+
this.websocketOpportunityCallback = callback;
219+
}
220+
221+
/**
222+
* Subscribes to the specified chains
223+
*
224+
* The opportunity handler will be called for opportunities on the specified chains
225+
* If the opportunity handler is not set, an error will be thrown
226+
* @param chains
227+
*/
228+
async subscribeChains(chains: string[]) {
229+
if (this.websocketOpportunityCallback === undefined) {
230+
throw new Error("Opportunity handler not set");
231+
}
232+
return this.sendWebsocketMessage({
233+
method: "subscribe",
234+
params: {
235+
chain_ids: chains,
236+
},
237+
});
238+
}
239+
240+
/**
241+
* Unsubscribes from the specified chains
242+
*
243+
* The opportunity handler will no longer be called for opportunities on the specified chains
244+
* @param chains
245+
*/
246+
async unsubscribeChains(chains: string[]) {
247+
return this.sendWebsocketMessage({
248+
method: "unsubscribe",
249+
params: {
250+
chain_ids: chains,
251+
},
252+
});
253+
}
254+
255+
async sendWebsocketMessage(
256+
msg: components["schemas"]["ClientMessage"]
257+
): Promise<void> {
258+
const msg_with_id: components["schemas"]["ClientRequest"] = {
259+
...msg,
260+
id: (this.idCounter++).toString(),
261+
};
262+
return new Promise((resolve, reject) => {
263+
this.callbackRouter[msg_with_id.id] = (response) => {
264+
if (response.status === "success") {
265+
resolve();
266+
} else {
267+
reject(response.result);
268+
}
269+
};
270+
if (this.websocket === undefined) {
271+
this.connectWebsocket();
272+
}
273+
if (this.websocket !== undefined) {
274+
if (this.websocket.readyState === WebSocket.CONNECTING) {
275+
this.websocket.on("open", () => {
276+
this.websocket?.send(JSON.stringify(msg_with_id));
277+
});
278+
} else if (this.websocket.readyState === WebSocket.OPEN) {
279+
this.websocket.send(JSON.stringify(msg_with_id));
280+
} else {
281+
reject("Websocket connection closing or already closed");
282+
}
283+
}
284+
setTimeout(() => {
285+
delete this.callbackRouter[msg_with_id.id];
286+
reject("Websocket response timeout");
287+
}, this.wsOptions.response_timeout);
288+
});
126289
}
127290

128291
/**
@@ -138,22 +301,11 @@ export class Client {
138301
throw new Error("No opportunities found");
139302
}
140303
return opportunities.data.flatMap((opportunity) => {
141-
if (opportunity.version != "v1") {
142-
console.warn(
143-
`Can not handle opportunity version: ${opportunity.version}. Please upgrade your client.`
144-
);
304+
const convertedOpportunity = this.convertOpportunity(opportunity);
305+
if (convertedOpportunity === undefined) {
145306
return [];
146307
}
147-
return {
148-
chainId: opportunity.chain_id,
149-
opportunityId: opportunity.opportunity_id,
150-
permissionKey: checkHex(opportunity.permission_key),
151-
contract: checkAddress(opportunity.contract),
152-
calldata: checkHex(opportunity.calldata),
153-
value: BigInt(opportunity.value),
154-
repayTokens: opportunity.repay_tokens.map(checkTokenQty),
155-
receiptTokens: opportunity.receipt_tokens.map(checkTokenQty),
156-
};
308+
return convertedOpportunity;
157309
});
158310
}
159311

0 commit comments

Comments
 (0)