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" ;
3
5
import {
4
6
Address ,
5
7
encodeAbiParameters ,
@@ -10,7 +12,7 @@ import {
10
12
keccak256 ,
11
13
} from "viem" ;
12
14
import { privateKeyToAccount , sign , signatureToHex } from "viem/accounts" ;
13
-
15
+ import WebSocket from "isomorphic-ws" ;
14
16
/**
15
17
* ERC20 token with contract address and amount
16
18
*/
@@ -118,11 +120,172 @@ function checkTokenQty(token: { contract: string; amount: string }): TokenQty {
118
120
} ;
119
121
}
120
122
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
+
121
136
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 > ;
123
148
124
- constructor ( clientOptions ? : ClientOptions ) {
149
+ constructor ( clientOptions : ClientOptions , wsOptions ?: WsOptions ) {
125
150
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
+ } ) ;
126
289
}
127
290
128
291
/**
@@ -138,22 +301,11 @@ export class Client {
138
301
throw new Error ( "No opportunities found" ) ;
139
302
}
140
303
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 ) {
145
306
return [ ] ;
146
307
}
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 ;
157
309
} ) ;
158
310
}
159
311
0 commit comments