diff --git a/docker/devnet.conf b/docker/devnet.conf index 0be6da031..5ac8e23a8 100644 --- a/docker/devnet.conf +++ b/docker/devnet.conf @@ -38,7 +38,6 @@ alephium.node.event-log.index-by-tx-id = true alephium.node.event-log.index-by-block-hash = true alephium.network.rest-port = 22973 -alephium.network.ws-port = 21973 alephium.network.miner-api-port = 20973 alephium.api.network-interface = "0.0.0.0" alephium.api.api-key-enabled = false diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 2cd4b9fc1..900bb7eee 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -35,7 +35,6 @@ services: - 19973:19973/tcp - 19973:19973/udp - 127.0.0.1:20973:20973 - - 127.0.0.1:21973:21973 - 127.0.0.1:22973:22973 environment: - ALEPHIUM_LOG_LEVEL=DEBUG diff --git a/packages/web3/package.json b/packages/web3/package.json index 3bf3caa18..38e73fe43 100644 --- a/packages/web3/package.json +++ b/packages/web3/package.json @@ -46,6 +46,7 @@ }, "type": "commonjs", "dependencies": { + "ws": "^8.5.4", "@noble/secp256k1": "1.7.1", "base-x": "4.0.0", "bignumber.js": "^9.1.1", @@ -67,6 +68,7 @@ "@types/mock-fs": "^4.13.1", "@types/node": "^16.18.23", "@types/rewire": "^2.5.28", + "@types/ws": "^8.5.4", "@typescript-eslint/eslint-plugin": "^5.57.0", "@typescript-eslint/parser": "^5.57.0", "clean-webpack-plugin": "4.0.0", diff --git a/packages/web3/src/api/api-alephium.ts b/packages/web3/src/api/api-alephium.ts index 2c79a9d0c..793a106aa 100644 --- a/packages/web3/src/api/api-alephium.ts +++ b/packages/web3/src/api/api-alephium.ts @@ -911,8 +911,6 @@ export interface PeerAddress { /** @format int32 */ restPort: number /** @format int32 */ - wsPort: number - /** @format int32 */ minerApiPort: number } diff --git a/packages/web3/src/fixtures/self-clique.json b/packages/web3/src/fixtures/self-clique.json index 7222df936..ab0d1c2f7 100644 --- a/packages/web3/src/fixtures/self-clique.json +++ b/packages/web3/src/fixtures/self-clique.json @@ -7,7 +7,6 @@ { "address": "127.0.0.1", "restPort": 12973, - "wsPort": 11973, "minerApiPort": 10973 } ], diff --git a/packages/web3/src/index.ts b/packages/web3/src/index.ts index f1883aac3..9fdb5e659 100644 --- a/packages/web3/src/index.ts +++ b/packages/web3/src/index.ts @@ -26,6 +26,7 @@ export * from './signer' export * from './utils' export * from './transaction' export * from './token' +export * from './ws' export * from './constants' export * as web3 from './global' diff --git a/packages/web3/src/ws/index.ts b/packages/web3/src/ws/index.ts new file mode 100644 index 000000000..14d138e27 --- /dev/null +++ b/packages/web3/src/ws/index.ts @@ -0,0 +1,19 @@ +/* +Copyright 2018 - 2022 The Alephium Authors +This file is part of the alephium project. + +The library is free software: you can redistribute it and/or modify +it under the terms of the GNU Lesser General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +The library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public License +along with the library. If not, see . +*/ + +export { WebSocketClient } from './websocket-client' diff --git a/packages/web3/src/ws/websocket-client.ts b/packages/web3/src/ws/websocket-client.ts new file mode 100644 index 000000000..7ffe59f52 --- /dev/null +++ b/packages/web3/src/ws/websocket-client.ts @@ -0,0 +1,142 @@ +/* +Copyright 2018 - 2022 The Alephium Authors +This file is part of the alephium project. + +The library is free software: you can redistribute it and/or modify +it under the terms of the GNU Lesser General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +The library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public License +along with the library. If not, see . +*/ +import WebSocket from 'ws'; +import { EventEmitter } from 'eventemitter3'; + +export class WebSocketClient extends EventEmitter { + private ws: WebSocket; + private requestId: number; + private isConnected: boolean; + private notifications: any[]; + + constructor(url: string) { + super(); + this.ws = new WebSocket(url); + this.requestId = 0; + this.isConnected = false; + this.notifications = []; + + this.ws.on('open', () => { + this.isConnected = true; + this.emit('connected'); + }); + + this.ws.on('message', (data: WebSocket.Data) => { + try { + const message = JSON.parse(data.toString()); + if (message.method === 'subscription') { + // Emit and store notifications + const params = message.params; + this.notifications.push(params); + this.emit('notification', params); + } else { + this.emit(`response_${message.id}`, message); + } + } catch (error) { + this.emit('error', error); + } + }); + + this.ws.on('close', () => { + this.isConnected = false; + this.emit('disconnected'); + }); + } + + public subscribe(method: string, params: unknown[] = []): Promise { + const id = this.getRequestId(); + const request = { + jsonrpc: '2.0', + id, + method, + params + }; + + return new Promise((resolve, reject) => { + this.once(`response_${id}`, (response) => { + if (response.result) { + resolve(response.result); + } else { + reject(response.error); + } + }); + this.ws.send(JSON.stringify(request)); + }); + } + + public unsubscribe(subscriptionId: string): Promise { + const id = this.getRequestId(); + const request = { + jsonrpc: '2.0', + id, + method: 'unsubscribe', + params: [subscriptionId] + }; + + return new Promise((resolve, reject) => { + this.once(`response_${id}`, (response) => { + if (response.result === true) { + resolve(true); + } else { + reject(response.error); + } + }); + this.ws.send(JSON.stringify(request)); + }); + } + + public async subscribeToBlock(): Promise { + return this.subscribe('subscribe', ['block']); + } + + public async subscribeToTx(): Promise { + return this.subscribe('subscribe', ['tx']); + } + + public async subscribeToContractEvents(addresses: string[]): Promise { + return this.subscribe('subscribe', ['contract', {addresses: addresses}]); + } + + public async subscribeToFilteredContractEvents(eventIndex: number, addresses: string[]): Promise { + return this.subscribe('subscribe', ['contract', {eventIndex: eventIndex, addresses: addresses}]); + } + + public onConnected(callback: () => void) { + if (this.isConnected) { + callback(); + } else { + this.on('connected', callback); + } + } + + public onNotification(callback: (params: any) => void) { + for (const notification of this.notifications) { + callback(notification); + } + + this.on('notification', callback); + } + + public disconnect() { + this.ws.close(); + } + + private getRequestId(): number { + return ++this.requestId; + } +} diff --git a/test/websocket-client.test.ts b/test/websocket-client.test.ts new file mode 100644 index 000000000..e89bf1918 --- /dev/null +++ b/test/websocket-client.test.ts @@ -0,0 +1,94 @@ +/* +Copyright 2018 - 2022 The Alephium Authors +This file is part of the alephium project. + +The library is free software: you can redistribute it and/or modify +it under the terms of the GNU Lesser General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +The library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public License +along with the library. If not, see . +*/ + +import {ONE_ALPH, SignerProviderSimple, SignTransferTxResult, utils, web3, WebSocketClient} from '@alephium/web3'; +import {getSigner, randomContractAddress} from '@alephium/web3-test'; + +const NODE_PROVIDER = 'http://127.0.0.1:22973' +const WS_ENDPOINT = 'ws://127.0.0.1:22973/ws'; + +describe('WebSocketClient', () => { + let client: WebSocketClient; + let signer: SignerProviderSimple; + + async function signAndSubmitTx(): Promise { + const address = (await signer.getSelectedAccount()).address; + const attoAlphAmount = ONE_ALPH; + return await signer.signAndSubmitTransferTx({ + signerAddress: address, + destinations: [{ address, attoAlphAmount }], + }); + } + + beforeEach(async () => { + client = new WebSocketClient(WS_ENDPOINT); + signer = await getSigner(); + web3.setCurrentNodeProvider(NODE_PROVIDER, undefined, fetch); + }); + + afterEach(() => { + client.disconnect(); + }); + + test('should subscribe, receive notifications and unsubscribe', (done) => { + let notificationCount = 0; + let blockNotificationReceived = false; + let txNotificationReceived = false; + let blockSubscriptionId: string; + let txSubscriptionId: string; + let contractEventsSubscriptionId: string; + + + client.onConnected(async () => { + try { + blockSubscriptionId = await client.subscribeToBlock(); + txSubscriptionId = await client.subscribeToTx(); + contractEventsSubscriptionId = await client.subscribeToContractEvents([randomContractAddress()]); + await signAndSubmitTx(); + } catch (error) { + done(error); + } + }); + + client.onNotification(async (params) => { + expect(params).toBeDefined(); + if (params.result.block) { + blockNotificationReceived = true; + } else if (params.result.unsigned) { + txNotificationReceived = true; + } + + notificationCount += 1; + if (notificationCount === 2) { + try { + expect(blockNotificationReceived).toBe(true); + expect(txNotificationReceived).toBe(true); + const blockUnsubscriptionResponse = await client.unsubscribe(blockSubscriptionId); + expect(blockUnsubscriptionResponse).toBe(true); + const txUnsubscriptionResponse = await client.unsubscribe(txSubscriptionId); + expect(txUnsubscriptionResponse).toBe(true); + const contractEventsUnsubscriptionResponse = await client.unsubscribe(contractEventsSubscriptionId); + expect(contractEventsUnsubscriptionResponse).toBe(true); + done(); + } catch (error) { + done(error); + } + } + }); + }); +});