From e046e01a0a91be3c46f04447235dfcebbbc2558f Mon Sep 17 00:00:00 2001 From: Stella Cannefax Date: Thu, 8 Sep 2022 16:44:45 -0500 Subject: [PATCH] Event subscription (TS sdk) (#4249) * add subscribeEvent stub * add onMessage callback to subscribeEvent * add json rpc version of subscribeEvent * add subscribeEvent to void provider * refine types for event sub, add websocket client instance * support insecure websocket * rename subscribeEvent parameter * update generated type guards * add debug logs to getWebsocketUrl * add SuiEventEnvelope typescript type * remove unused import, update type guards * {} to void return for subscribeevent * add basic event subscription test box * TEMPORARILY add eventsubscription to home page * remove broken checkbox select * add basic Event Filter Type element * lint changes * working objectId filter * lint changes * remove select form from event tester * remove jayson WebsocketClient * remove excess whitespace * add Subscription tracking * Update index.guard.ts * add unsubscribeEvent * remove console logs, add comments * style / organization changes in ts sdk * don't re-create websocket client * only create one default rpc client per network * lint changes * add on socket error * fix message subscription * add unsubscribe rpc call * allow timestamp as number in SuiEventEnvelope * remove LossLessJSON from event parsing * Update index.guard.ts * move socket event setup to method * add check for method id * remove explorer eventSubscription * make websocket rpc client not auto connection * bring back explorer test page for event sub * remove heartbeat * improve comment for socket type cast * improve timeout handling for websocket connection * add FilterSubHandler type * delete from active subs on remove * shorten lazy connect comment * use one single map to track subscription data * use .values() instead of entries() in sub refresh * improve comment about subscription refresh * Delete yarn.lock * remove explorer home changes for testing * use shorter console error * fix provider subscribe return value * fix doc comment on unsubscribe event * remove console.log * use jsdoc comment style * change EventType discriminator enum * make timestamp only a number * replace string manipulation with url object * update onMessage return types * change to doing socket setup in connect * add timeout error * shorten usage of reject * const instead of let for two vars * if spacing change * remove calbackwithType * Update client.ts * Update client.ts * move websocket client into its own class * Revert "remove explorer home changes for testing" This reverts commit a071c94615823193ebde7ba0d1d4acb5e364ef50. * rename most websocket client symbols * Revert "Revert "remove explorer home changes for testing"" This reverts commit ad1f441f7ab44b733cd6b911b3b91dfe626004bc. * validate minimum data with validation off * Update websocket-client.ts * add else if in socket message handler * activeSubs -> eventSubs * wsProvider -> wsClient * move websocket client into rpc folder * better styling for default websocket options * add maxReconnects option to websockets * Revert "Revert "Revert "remove explorer home changes for testing""" This reverts commit cce81e3e37eb7f93cee4b73cc593a5a711f8e07f. * remove old todo * Update sdk/typescript/src/providers/void-provider.ts Co-authored-by: Chris Li <76067158+666lcz@users.noreply.github.com> * add better usage comments for event subscription * Revert "Revert "Revert "Revert "remove explorer home changes for testing"""" This reverts commit d828f19069903be699f5a815b7a3491ff54af8db. * minor formatting change in provider * fix type guard import * Update pnpm-lock.yaml * fix capitalization on event filters * Update sdk/typescript/src/rpc/websocket-client.ts Co-authored-by: Chris Li <76067158+666lcz@users.noreply.github.com> * add websocket config jsdoc * add jsdoc for websocket client * update type guard * handle case of currently-connecting when calling connect Co-authored-by: Chris Li <76067158+666lcz@users.noreply.github.com> --- .../client/src/utils/api/DefaultRpcClient.ts | 11 +- pnpm-lock.yaml | 81 ++++- sdk/typescript/package.json | 1 + .../src/providers/json-rpc-provider.ts | 22 +- sdk/typescript/src/providers/provider.ts | 19 ++ sdk/typescript/src/providers/void-provider.ts | 14 + sdk/typescript/src/rpc/websocket-client.ts | 282 ++++++++++++++++++ sdk/typescript/src/types/events.ts | 42 ++- sdk/typescript/src/types/index.guard.ts | 110 ++++++- 9 files changed, 572 insertions(+), 10 deletions(-) create mode 100644 sdk/typescript/src/rpc/websocket-client.ts diff --git a/explorer/client/src/utils/api/DefaultRpcClient.ts b/explorer/client/src/utils/api/DefaultRpcClient.ts index 479ef3e30cf00..d500e4cf9abd0 100644 --- a/explorer/client/src/utils/api/DefaultRpcClient.ts +++ b/explorer/client/src/utils/api/DefaultRpcClient.ts @@ -7,5 +7,12 @@ import { getEndpoint, Network } from './rpcSetting'; export { Network, getEndpoint }; -export const DefaultRpcClient = (network: Network | string) => - new JsonRpcProvider(getEndpoint(network)); +const defaultRpcMap: Map = new Map(); +export const DefaultRpcClient = (network: Network | string) => { + const existingClient = defaultRpcMap.get(network); + if (existingClient) return existingClient; + + const provider = new JsonRpcProvider(getEndpoint(network)); + defaultRpcMap.set(network, provider); + return provider; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a002ff69fe1f..fcd89c340b6de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,6 +140,7 @@ importers: lossless-json: ^1.0.5 mockttp: ^2.7.0 prettier: ^2.7.1 + rpc-websockets: ^7.5.0 size-limit: ^7.0.8 ts-auto-guard: ^2.4.1 ts-node: ^10.9.1 @@ -159,6 +160,7 @@ importers: jayson: 3.7.0 js-sha3: 0.8.0 lossless-json: 1.0.5 + rpc-websockets: 7.5.0 tweetnacl: 1.0.3 devDependencies: '@mysten/sui-open-rpc': file:crates/sui-open-rpc @@ -2589,10 +2591,10 @@ packages: '@types/ws': 8.5.3 duplexify: 3.7.1 inherits: 2.0.4 - isomorphic-ws: 4.0.1_ws@7.5.9 + isomorphic-ws: 4.0.1_ws@8.8.1 readable-stream: 2.3.7 safe-buffer: 5.2.1 - ws: 7.5.9 + ws: 8.8.1 xtend: 4.0.2 transitivePeerDependencies: - bufferutil @@ -5060,6 +5062,14 @@ packages: readable-stream: 3.6.0 dev: true + /bufferutil/4.0.6: + resolution: {integrity: sha512-jduaYOYtnio4aIAyc6UbvPCVcgq7nYpVnucyxr6eCYg/Woad9Hf/oxxBRDnGGjPfjUm6j5O/uBWhIu4iLebFaw==} + engines: {node: '>=6.14.2'} + requiresBuild: true + dependencies: + node-gyp-build: 4.5.0 + dev: false + /bundle-require/3.0.4_esbuild@0.15.5: resolution: {integrity: sha512-VXG6epB1yrLAvWVQpl92qF347/UXmncQj7J3U8kZEbdVZ1ZkQyr4hYeL/9RvcE8vVVdp53dY78Fd/3pqfRqI1A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -7282,6 +7292,10 @@ packages: resolution: {integrity: sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==} dev: true + /eventemitter3/4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + dev: false + /events/3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -8759,6 +8773,15 @@ packages: ws: '*' dependencies: ws: 7.5.9 + dev: false + + /isomorphic-ws/4.0.1_ws@8.8.1: + resolution: {integrity: sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==} + peerDependencies: + ws: '*' + dependencies: + ws: 8.8.1 + dev: true /isstream/0.1.2: resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} @@ -9772,6 +9795,11 @@ packages: engines: {node: '>= 6.13.0'} dev: true + /node-gyp-build/4.5.0: + resolution: {integrity: sha512-2iGbaQBV+ITgCz76ZEjmhUKAKVf7xfY1sRl4UiKQspfZMH2h06SyhNsnSVy50cwkFQDGLyif6m/6uFXHkOZ6rg==} + hasBin: true + dev: false + /node-gyp/9.1.0: resolution: {integrity: sha512-HkmN0ZpQJU7FLbJauJTHkHlSVAXlNGDAzH/VYFZGDOnFyn/Na3GlNJfkudmufOdS6/jNFhy88ObzL7ERz9es1g==} engines: {node: ^12.22 || ^14.13 || >=16} @@ -11477,6 +11505,18 @@ packages: fsevents: 2.3.2 dev: true + /rpc-websockets/7.5.0: + resolution: {integrity: sha512-9tIRi1uZGy7YmDjErf1Ax3wtqdSSLIlnmL5OtOzgd5eqPKbsPpwDP5whUDO2LQay3Xp0CcHlcNSGzacNRluBaQ==} + dependencies: + '@babel/runtime': 7.18.9 + eventemitter3: 4.0.7 + uuid: 8.3.2 + ws: 8.8.1_22kvxa7zeyivx4jp72v2w3pkvy + optionalDependencies: + bufferutil: 4.0.6 + utf-8-validate: 5.0.9 + dev: false + /run-parallel/1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} dependencies: @@ -13279,6 +13319,14 @@ packages: engines: {node: '>=0.10.0'} dev: true + /utf-8-validate/5.0.9: + resolution: {integrity: sha512-Yek7dAy0v3Kl0orwMlvi7TPtiCNrdfHNd7Gcc/pLq4BLXqfAmd0J7OWMizUQnTTJsyjKn02mU7anqwfmUP4J8Q==} + engines: {node: '>=6.14.2'} + requiresBuild: true + dependencies: + node-gyp-build: 4.5.0 + dev: false + /util-deprecate/1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -13786,6 +13834,35 @@ packages: utf-8-validate: optional: true + /ws/8.8.1: + resolution: {integrity: sha512-bGy2JzvzkPowEJV++hF07hAD6niYSr0JzBNo/J29WsB57A2r7Wlc1UFcTR9IzrPvuNVO4B8LGqF8qcpsVOhJCA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: true + + /ws/8.8.1_22kvxa7zeyivx4jp72v2w3pkvy: + resolution: {integrity: sha512-bGy2JzvzkPowEJV++hF07hAD6niYSr0JzBNo/J29WsB57A2r7Wlc1UFcTR9IzrPvuNVO4B8LGqF8qcpsVOhJCA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dependencies: + bufferutil: 4.0.6 + utf-8-validate: 5.0.9 + dev: false + /xregexp/2.0.0: resolution: {integrity: sha512-xl/50/Cf32VsGq/1R8jJE5ajH1yMCQkpmoS10QbFZWl2Oor4H0Me64Pu2yxvsRWK3m6soJbmGfzSR7BYmDcWAA==} dev: true diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 59d85c447acd1..4570b014cd5f2 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -86,6 +86,7 @@ "buffer": "^6.0.3", "cross-fetch": "^3.1.5", "jayson": "^3.6.6", + "rpc-websockets": "^7.5.0", "js-sha3": "^0.8.0", "lossless-json": "^1.0.5", "tweetnacl": "^1.0.3" diff --git a/sdk/typescript/src/providers/json-rpc-provider.ts b/sdk/typescript/src/providers/json-rpc-provider.ts index 28301303e9481..fa5dca1acc88e 100644 --- a/sdk/typescript/src/providers/json-rpc-provider.ts +++ b/sdk/typescript/src/providers/json-rpc-provider.ts @@ -30,16 +30,22 @@ import { SuiObjectRef, getObjectReference, Coin, + SuiEventFilter, + SuiEventEnvelope, + SubscriptionId, ExecuteTransactionRequestType, SuiExecuteTransactionResponse, } from '../types'; import { SignatureScheme } from '../cryptography/publickey'; +import { DEFAULT_CLIENT_OPTIONS, WebsocketClient, WebsocketClientOptions } from '../rpc/websocket-client'; const isNumber = (val: any): val is number => typeof val === 'number'; const isAny = (_val: any): _val is any => true; + export class JsonRpcProvider extends Provider { protected client: JsonRpcClient; + protected wsClient: WebsocketClient; /** * Establish a connection to a Sui RPC endpoint * @@ -54,10 +60,13 @@ export class JsonRpcProvider extends Provider { */ constructor( public endpoint: string, - public skipDataValidation: boolean = false + public skipDataValidation: boolean = false, + public socketOptions: WebsocketClientOptions = DEFAULT_CLIENT_OPTIONS ) { super(); + this.client = new JsonRpcClient(endpoint); + this.wsClient = new WebsocketClient(endpoint, skipDataValidation, socketOptions); } // Move info @@ -421,4 +430,15 @@ export class JsonRpcProvider extends Provider { ); } } + + async subscribeEvent( + filter: SuiEventFilter, + onMessage: (event: SuiEventEnvelope) => void + ): Promise { + return this.wsClient.subscribeEvent(filter, onMessage); + } + + async unsubscribeEvent(id: SubscriptionId): Promise { + return this.wsClient.unsubscribeEvent(id); + } } diff --git a/sdk/typescript/src/providers/provider.ts b/sdk/typescript/src/providers/provider.ts index 67cdf00db55cf..1aead7d0f7f9e 100644 --- a/sdk/typescript/src/providers/provider.ts +++ b/sdk/typescript/src/providers/provider.ts @@ -14,6 +14,9 @@ import { SuiMoveNormalizedStruct, SuiMoveNormalizedModule, SuiMoveNormalizedModules, + SuiEventFilter, + SuiEventEnvelope, + SubscriptionId, ExecuteTransactionRequestType, SuiExecuteTransactionResponse, } from '../types'; @@ -136,5 +139,21 @@ export abstract class Provider { ): Promise; abstract syncAccountState(address: string): Promise; + + /** + * Subscribe to get notifications whenever an event matching the filter occurs + * @param filter - filter describing the subset of events to follow + * @param onMessage - function to run when we receive a notification of a new event matching the filter + */ + abstract subscribeEvent( + filter: SuiEventFilter, + onMessage: (event: SuiEventEnvelope) => void + ): Promise; + + /** + * Unsubscribe from an event subscription + * @param id - subscription id to unsubscribe from (previously received from subscribeEvent) + */ + abstract unsubscribeEvent(id: SubscriptionId): Promise; // TODO: add more interface methods } diff --git a/sdk/typescript/src/providers/void-provider.ts b/sdk/typescript/src/providers/void-provider.ts index 6033faa925308..18c1493de5509 100644 --- a/sdk/typescript/src/providers/void-provider.ts +++ b/sdk/typescript/src/providers/void-provider.ts @@ -16,6 +16,9 @@ import { SuiMoveNormalizedStruct, SuiMoveNormalizedModule, SuiMoveNormalizedModules, + SuiEventFilter, + SuiEventEnvelope, + SubscriptionId, ExecuteTransactionRequestType, SuiExecuteTransactionResponse, } from '../types'; @@ -123,6 +126,17 @@ export class VoidProvider extends Provider { throw this.newError('syncAccountState'); } + async subscribeEvent( + _filter: SuiEventFilter, + _onMessage: (event: SuiEventEnvelope) => void + ): Promise { + throw this.newError('subscribeEvent'); + } + + async unsubscribeEvent(_id: SubscriptionId): Promise { + throw this.newError('unsubscribeEvent'); + } + private newError(operation: string): Error { return new Error(`Please use a valid provider for ${operation}`); } diff --git a/sdk/typescript/src/rpc/websocket-client.ts b/sdk/typescript/src/rpc/websocket-client.ts new file mode 100644 index 0000000000000..d2b5f9fe49883 --- /dev/null +++ b/sdk/typescript/src/rpc/websocket-client.ts @@ -0,0 +1,282 @@ +// Copyright (c) 2022, Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { isSubscriptionEvent } from '../types/index.guard'; +import { + SuiEventFilter, + SuiEventEnvelope, + SubscriptionId, +} from '../types'; +import { Client as WsRpcClient} from 'rpc-websockets'; + + +export const getWebsocketUrl = (httpUrl: string, port?: number): string => { + const url = new URL(httpUrl); + url.protocol = url.protocol.replace('http', 'ws'); + url.port = (port ?? 9001).toString(); + return url.toString(); +}; + +enum ConnectionState { + NotConnected, + Connecting, + Connected +} + +type JsonRpcMethodMessage = { + jsonrpc: '2.0', + method: string, + params: T +} + +type FilterSubHandler = { + id: SubscriptionId, + onMessage: (event: SuiEventEnvelope) => void, + filter: SuiEventFilter +}; + +type SubscriptionData = { + filter: SuiEventFilter, + onMessage: (event: SuiEventEnvelope) => void +} + +type MinimumSubscriptionMessage = { + subscription: SubscriptionId, + result: object +} + +const isMinimumSubscriptionMessage = (msg: any): msg is MinimumSubscriptionMessage => + msg + && ('subscription' in msg && typeof msg['subscription'] === 'number') + && ('result' in msg && typeof msg['result'] === 'object') + +/** + * Configuration options for the websocket connection + */ + export type WebsocketClientOptions = { + /** + * Milliseconds before timing out while initially connecting + */ + connectTimeout: number, + /** + * Milliseconds before timing out while calling an RPC method + */ + callTimeout: number, + /** + * Milliseconds between attempts to connect + */ + reconnectInterval: number, + /** + * Maximum number of times to try connecting before giving up + */ + maxReconnects: number +} + +export const DEFAULT_CLIENT_OPTIONS: WebsocketClientOptions = { + connectTimeout: 15000, + callTimeout: 30000, + reconnectInterval: 3000, + maxReconnects: 5 +} + +const SUBSCRIBE_EVENT_METHOD = 'sui_subscribeEvent'; +const UNSUBSCRIBE_EVENT_METHOD = 'sui_unsubscribeEvent'; + +/** + * Interface with a Sui node's websocket capabilities + */ +export class WebsocketClient { + protected rpcClient: WsRpcClient; + protected connectionState: ConnectionState = ConnectionState.NotConnected; + protected connectionTimeout: number | null = null; + protected isSetup: boolean = false; + private connectionPromise: Promise | null = null; + + protected eventSubscriptions: Map = new Map(); + + /** + * @param endpoint Sui node endpoint to connect to (accepts websocket & http) + * @param skipValidation If `true`, the rpc client will not check if the responses + * from the RPC server conform to the schema defined in the TypeScript SDK + * @param options Configuration options, such as timeouts & connection behavior + */ + constructor( + public endpoint: string, + public skipValidation: boolean, + public options: WebsocketClientOptions = DEFAULT_CLIENT_OPTIONS + ) { + if (this.endpoint.startsWith('http')) + this.endpoint = getWebsocketUrl(this.endpoint); + + this.rpcClient = new WsRpcClient(this.endpoint, { + reconnect_interval: this.options.reconnectInterval, + max_reconnects: this.options.maxReconnects, + autoconnect: false + }); + } + + private setupSocket() { + if(this.isSetup) return; + + this.rpcClient.on('open', () => { + if(this.connectionTimeout) { + clearTimeout(this.connectionTimeout); + this.connectionTimeout = null; + } + this.connectionState = ConnectionState.Connected; + // underlying websocket is private, but we need it + // to access messages sent by the node + (this.rpcClient as any).socket + .on('message', this.onSocketMessage.bind(this)); + }); + + this.rpcClient.on('close', () => { + this.connectionState = ConnectionState.NotConnected; + }); + + this.rpcClient.on('error', console.error); + this.isSetup = true; + } + + // called for every message received from the node over websocket + private onSocketMessage(rawMessage: string): void { + const msg: JsonRpcMethodMessage = JSON.parse(rawMessage); + + const params = msg.params; + if (msg.method === SUBSCRIBE_EVENT_METHOD) { + // even with validation off, we must ensure a few properties at minimum in a message + if (this.skipValidation && isMinimumSubscriptionMessage(params)) { + const sub = this.eventSubscriptions.get(params.subscription); + if (sub) + // cast to bypass type validation of 'result' + (sub.onMessage as (m: any) => void)(params.result); + } + else if (isSubscriptionEvent(params)) { + // call any registered handler for the message's subscription + const sub = this.eventSubscriptions.get(params.subscription); + if (sub) + sub.onMessage(params.result); + } + } + } + + private async connect(): Promise { + // if the last attempt to connect hasn't finished, wait on it + if (this.connectionPromise) return this.connectionPromise; + if (this.connectionState === ConnectionState.Connected) + return Promise.resolve(); + + this.setupSocket(); + this.rpcClient.connect(); + this.connectionState = ConnectionState.Connecting; + + this.connectionPromise = new Promise((resolve, reject) => { + this.connectionTimeout = setTimeout( + () => reject(new Error('timeout')), + this.options.connectTimeout + ) as any as number; + + this.rpcClient.once('open', () => { + this.refreshSubscriptions(); + this.connectionPromise = null; + resolve(); + }); + this.rpcClient.once('error', (err) => { + this.connectionPromise = null; + reject(err); + }); + }); + return this.connectionPromise; + } + + /** + call only upon reconnecting to a node over websocket. + calling multiple times on the same connection will result + in multiple message handlers firing each time + */ + private async refreshSubscriptions() { + if (this.eventSubscriptions.size === 0) + return; + + try { + let newSubs: Map = new Map(); + + let newSubsArr: (FilterSubHandler | null)[] = await Promise.all( + Array.from(this.eventSubscriptions.values()) + .map(async sub => { + const onMessage = sub.onMessage; + const filter = sub.filter; + if(!filter || !onMessage) + return Promise.resolve(null); + /** + re-subscribe to the same filter & replace the subscription id. + we skip calling sui_unsubscribeEvent for the old sub id, because: + * we assume this is being called after a reconnection + * the node keys subscriptions with a combo of connection id & subscription id + */ + const id = await this.subscribeEvent(filter, onMessage); + return { id, onMessage, filter }; + }) + ); + + newSubsArr.forEach(entry => { + if(entry === null) return; + const filter = entry.filter; + const onMessage = entry.onMessage; + newSubs.set(entry.id, { filter, onMessage }); + }); + + this.eventSubscriptions = newSubs; + } catch (err) { + throw new Error(`error refreshing event subscriptions: ${err}`); + } + } + + async subscribeEvent( + filter: SuiEventFilter, + onMessage: (event: SuiEventEnvelope) => void + ): Promise { + try { + // lazily connect to websocket to avoid spamming node with connections + if (this.connectionState != ConnectionState.Connected) + await this.connect(); + + let subId = await this.rpcClient.call( + SUBSCRIBE_EVENT_METHOD, + [filter], + this.options.callTimeout + ) as SubscriptionId; + + this.eventSubscriptions.set(subId, { filter, onMessage }); + return subId; + } catch (err) { + throw new Error( + `Error subscribing to event: ${err}, filter: ${JSON.stringify(filter)}` + ); + } + } + + async unsubscribeEvent(id: SubscriptionId): Promise { + try { + if (this.connectionState != ConnectionState.Connected) + await this.connect(); + + let removedOnNode = await this.rpcClient.call( + UNSUBSCRIBE_EVENT_METHOD, + [id], + this.options.callTimeout + ) as boolean; + /** + if the connection closes before unsubscribe is called, + the remote node will remove us from its subscribers list without notification, + leading to removedOnNode being false. but if we still had a record of it locally, + we should still report that it was deleted successfully + */ + return this.eventSubscriptions.delete(id) || removedOnNode; + } catch (err) { + throw new Error( + `Error unsubscribing from event: ${err}, subscription: ${id}}` + ); + } + } +} \ No newline at end of file diff --git a/sdk/typescript/src/types/events.ts b/sdk/typescript/src/types/events.ts index 2b821f9fc8e3d..c4f1d95e094c5 100644 --- a/sdk/typescript/src/types/events.ts +++ b/sdk/typescript/src/types/events.ts @@ -1,8 +1,9 @@ // Copyright (c) 2022, Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 -import { SuiAddress, ObjectOwner } from "./common"; +import { SuiAddress, ObjectOwner, TransactionDigest } from "./common"; import { ObjectId, SequenceNumber } from "./objects"; +import { SuiJsonValue } from "./transactions"; // event types mirror those in "sui-json-rpc-types/lib.rs" @@ -11,7 +12,7 @@ export type MoveEvent = { transactionModule: string; sender: SuiAddress; type: string; - fields: { [key: string]: any; }; // TODO - better type + fields: { [key: string]: any; }; bcs: string; }; @@ -54,3 +55,40 @@ export type SuiEvent = | { newObject: NewObjectEvent } | { epochChange: bigint } | { checkpoint: bigint }; + +export type MoveEventField = { + path: string, + value: SuiJsonValue +} + +export type EventType = + | "MoveEvent" + | "Publish" + | "TransferObject" + | "DeleteObject" + | "NewObject" + | "EpochChange" + | "Checkpoint"; + +// mirrors sui_json_rpc_types::SuiEventFilter +export type SuiEventFilter = + | { "Package" : ObjectId } + | { "Module" : string } + | { "MoveEventType" : string } + | { "MoveEventField" : MoveEventField } + | { "SenderAddress" : SuiAddress } + | { "EventType" : EventType } + | { "All" : SuiEventFilter[] } + | { "Any" : SuiEventFilter[] } + | { "And" : [SuiEventFilter, SuiEventFilter] } + | { "Or" : [SuiEventFilter, SuiEventFilter] }; + +export type SuiEventEnvelope = { + timestamp: number, + txDigest: TransactionDigest, + event: SuiEvent +} + +export type SubscriptionId = number; + +export type SubscriptionEvent = { subscription: SubscriptionId, result: SuiEventEnvelope }; \ No newline at end of file diff --git a/sdk/typescript/src/types/index.guard.ts b/sdk/typescript/src/types/index.guard.ts index c140ccb3cd872..b8f7c67c13bed 100644 --- a/sdk/typescript/src/types/index.guard.ts +++ b/sdk/typescript/src/types/index.guard.ts @@ -7,7 +7,7 @@ * Generated type guards for "index.ts". * WARNING: Do not manually change this file. */ -import { TransactionDigest, SuiAddress, ObjectOwner, SuiObjectRef, SuiObjectInfo, ObjectContentFields, MovePackageContent, SuiData, SuiMoveObject, SuiMovePackage, SuiMoveFunctionArgTypesResponse, SuiMoveFunctionArgType, SuiMoveFunctionArgTypes, SuiMoveNormalizedModules, SuiMoveNormalizedModule, SuiMoveModuleId, SuiMoveNormalizedStruct, SuiMoveStructTypeParameter, SuiMoveNormalizedField, SuiMoveNormalizedFunction, SuiMoveVisibility, SuiMoveTypeParameterIndex, SuiMoveAbilitySet, SuiMoveNormalizedType, SuiMoveNormalizedTypeParameterType, SuiMoveNormalizedStructType, SuiObject, ObjectStatus, ObjectType, GetOwnedObjectsResponse, GetObjectDataResponse, ObjectDigest, ObjectId, SequenceNumber, MoveEvent, PublishEvent, TransferObjectEvent, DeleteObjectEvent, NewObjectEvent, SuiEvent, TransferObject, SuiTransferSui, SuiChangeEpoch, ExecuteTransactionRequestType, TransactionKindName, SuiTransactionKind, SuiTransactionData, EpochId, AuthorityQuorumSignInfo, CertifiedTransaction, GasCostSummary, ExecutionStatusType, ExecutionStatus, OwnedObjectRef, TransactionEffects, SuiTransactionResponse, SuiCertifiedTransactionEffects, SuiExecuteTransactionResponse, GatewayTxSeqNumber, GetTxnDigestsResponse, MoveCall, SuiJsonValue, EmptySignInfo, AuthorityName, AuthoritySignature, TransactionBytes, SuiParsedMergeCoinResponse, SuiParsedSplitCoinResponse, SuiParsedPublishResponse, SuiPackage, SuiParsedTransactionResponse, DelegationData, DelegationSuiObject, TransferObjectTx, TransferSuiTx, PublishTx, ObjectArg, CallArg, StructTag, TypeTag, MoveCallTx, Transaction, TransactionKind, TransactionData } from "./index"; +import { TransactionDigest, SuiAddress, ObjectOwner, SuiObjectRef, SuiObjectInfo, ObjectContentFields, MovePackageContent, SuiData, SuiMoveObject, SuiMovePackage, SuiMoveFunctionArgTypesResponse, SuiMoveFunctionArgType, SuiMoveFunctionArgTypes, SuiMoveNormalizedModules, SuiMoveNormalizedModule, SuiMoveModuleId, SuiMoveNormalizedStruct, SuiMoveStructTypeParameter, SuiMoveNormalizedField, SuiMoveNormalizedFunction, SuiMoveVisibility, SuiMoveTypeParameterIndex, SuiMoveAbilitySet, SuiMoveNormalizedType, SuiMoveNormalizedTypeParameterType, SuiMoveNormalizedStructType, SuiObject, ObjectStatus, ObjectType, GetOwnedObjectsResponse, GetObjectDataResponse, ObjectDigest, ObjectId, SequenceNumber, MoveEvent, PublishEvent, TransferObjectEvent, DeleteObjectEvent, NewObjectEvent, SuiEvent, MoveEventField, EventType, SuiEventFilter, SuiEventEnvelope, SubscriptionId, SubscriptionEvent, TransferObject, SuiTransferSui, SuiChangeEpoch, ExecuteTransactionRequestType, TransactionKindName, SuiTransactionKind, SuiTransactionData, EpochId, AuthorityQuorumSignInfo, CertifiedTransaction, GasCostSummary, ExecutionStatusType, ExecutionStatus, OwnedObjectRef, TransactionEffects, SuiTransactionResponse, SuiCertifiedTransactionEffects, SuiExecuteTransactionResponse, GatewayTxSeqNumber, GetTxnDigestsResponse, MoveCall, SuiJsonValue, EmptySignInfo, AuthorityName, AuthoritySignature, TransactionBytes, SuiParsedMergeCoinResponse, SuiParsedSplitCoinResponse, SuiParsedPublishResponse, SuiPackage, SuiParsedTransactionResponse, DelegationData, DelegationSuiObject, TransferObjectTx, TransferSuiTx, PublishTx, ObjectArg, CallArg, StructTag, TypeTag, MoveCallTx, Transaction, TransactionKind, TransactionData } from "./index"; export function isTransactionDigest(obj: any, _argumentName?: string): obj is TransactionDigest { return ( @@ -497,6 +497,110 @@ export function isSuiEvent(obj: any, _argumentName?: string): obj is SuiEvent { ) } +export function isMoveEventField(obj: any, _argumentName?: string): obj is MoveEventField { + return ( + (obj !== null && + typeof obj === "object" || + typeof obj === "function") && + isTransactionDigest(obj.path) as boolean && + isSuiJsonValue(obj.value) as boolean + ) +} + +export function isEventType(obj: any, _argumentName?: string): obj is EventType { + return ( + (obj === "MoveEvent" || + obj === "Publish" || + obj === "TransferObject" || + obj === "DeleteObject" || + obj === "NewObject" || + obj === "EpochChange" || + obj === "Checkpoint") + ) +} + +export function isSuiEventFilter(obj: any, _argumentName?: string): obj is SuiEventFilter { + return ( + ((obj !== null && + typeof obj === "object" || + typeof obj === "function") && + isTransactionDigest(obj.Package) as boolean || + (obj !== null && + typeof obj === "object" || + typeof obj === "function") && + isTransactionDigest(obj.Module) as boolean || + (obj !== null && + typeof obj === "object" || + typeof obj === "function") && + isTransactionDigest(obj.MoveEventType) as boolean || + (obj !== null && + typeof obj === "object" || + typeof obj === "function") && + isMoveEventField(obj.MoveEventField) as boolean || + (obj !== null && + typeof obj === "object" || + typeof obj === "function") && + isTransactionDigest(obj.SenderAddress) as boolean || + (obj !== null && + typeof obj === "object" || + typeof obj === "function") && + isEventType(obj.EventType) as boolean || + (obj !== null && + typeof obj === "object" || + typeof obj === "function") && + Array.isArray(obj.All) && + obj.All.every((e: any) => + isSuiEventFilter(e) as boolean + ) || + (obj !== null && + typeof obj === "object" || + typeof obj === "function") && + Array.isArray(obj.Any) && + obj.Any.every((e: any) => + isSuiEventFilter(e) as boolean + ) || + (obj !== null && + typeof obj === "object" || + typeof obj === "function") && + Array.isArray(obj.And) && + isSuiEventFilter(obj.And[0]) as boolean && + isSuiEventFilter(obj.And[1]) as boolean || + (obj !== null && + typeof obj === "object" || + typeof obj === "function") && + Array.isArray(obj.Or) && + isSuiEventFilter(obj.Or[0]) as boolean && + isSuiEventFilter(obj.Or[1]) as boolean) + ) +} + +export function isSuiEventEnvelope(obj: any, _argumentName?: string): obj is SuiEventEnvelope { + return ( + (obj !== null && + typeof obj === "object" || + typeof obj === "function") && + isSuiMoveTypeParameterIndex(obj.timestamp) as boolean && + isTransactionDigest(obj.txDigest) as boolean && + isSuiEvent(obj.event) as boolean + ) +} + +export function isSubscriptionId(obj: any, _argumentName?: string): obj is SubscriptionId { + return ( + typeof obj === "number" + ) +} + +export function isSubscriptionEvent(obj: any, _argumentName?: string): obj is SubscriptionEvent { + return ( + (obj !== null && + typeof obj === "object" || + typeof obj === "function") && + isSuiMoveTypeParameterIndex(obj.subscription) as boolean && + isSuiEventEnvelope(obj.result) as boolean + ) +} + export function isTransferObject(obj: any, _argumentName?: string): obj is TransferObject { return ( (obj !== null && @@ -539,8 +643,8 @@ export function isExecuteTransactionRequestType(obj: any, _argumentName?: string export function isTransactionKindName(obj: any, _argumentName?: string): obj is TransactionKindName { return ( - (obj === "TransferObject" || - obj === "Publish" || + (obj === "Publish" || + obj === "TransferObject" || obj === "Call" || obj === "TransferSui" || obj === "ChangeEpoch")