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);
+ }
+ }
+ });
+ });
+});