@electrum-cash/network
Version:
@electrum-cash/network is a lightweight JavaScript library that lets you connect with one or more Electrum servers.
698 lines (690 loc) • 26.3 kB
JavaScript
import debug from "@electrum-cash/debug-logs";
import { ElectrumWebSocket } from "@electrum-cash/web-socket";
import { EventEmitter } from "eventemitter3";
import { parse, parseNumberAndBigInt } from "lossless-json";
import { Mutex } from "async-mutex";
//#region source/electrum-protocol.ts
/**
* Grouping of utilities that simplifies implementation of the Electrum protocol.
*
* @ignore
*/
var ElectrumProtocol = class {
/**
* Helper function that builds an Electrum request object.
*
* @param method - method to call.
* @param parameters - method parameters for the call.
* @param requestId - unique string or number referencing this request.
*
* @returns a properly formatted Electrum request string.
*/
static buildRequestObject(method, parameters, requestId) {
return JSON.stringify({
method,
params: parameters,
id: requestId
});
}
/**
* Constant used to verify if a provided string is a valid version number.
*
* @returns a regular expression that matches valid version numbers.
*/
static get versionRegexp() {
return /^\d+(\.\d+)+$/;
}
/**
* Constant used to separate statements/messages in a stream of data.
*
* @returns the delimiter used by Electrum to separate statements.
*/
static get statementDelimiter() {
return "\n";
}
};
//#endregion
//#region source/rpc-interfaces.ts
const isRPCErrorResponse = function(message) {
return "id" in message && "error" in message;
};
const isRPCStatement = function(message) {
return "id" in message && "result" in message;
};
const isRPCNotification = function(message) {
return !("id" in message) && "method" in message;
};
const isRPCRequest = function(message) {
return "id" in message && "method" in message;
};
//#endregion
//#region source/enums.ts
/**
* Enum that denotes the connection status of an ElectrumConnection.
* @enum {number}
* @property {0} DISCONNECTED The connection is disconnected.
* @property {1} AVAILABLE The connection is connected.
* @property {2} DISCONNECTING The connection is disconnecting.
* @property {3} CONNECTING The connection is connecting.
* @property {4} RECONNECTING The connection is restarting.
*/
let ConnectionStatus = /* @__PURE__ */ function(ConnectionStatus$1) {
ConnectionStatus$1[ConnectionStatus$1["DISCONNECTED"] = 0] = "DISCONNECTED";
ConnectionStatus$1[ConnectionStatus$1["CONNECTED"] = 1] = "CONNECTED";
ConnectionStatus$1[ConnectionStatus$1["DISCONNECTING"] = 2] = "DISCONNECTING";
ConnectionStatus$1[ConnectionStatus$1["CONNECTING"] = 3] = "CONNECTING";
ConnectionStatus$1[ConnectionStatus$1["RECONNECTING"] = 4] = "RECONNECTING";
return ConnectionStatus$1;
}({});
//#endregion
//#region source/interfaces.ts
/**
* @ignore
*/
const isVersionRejected = function(object) {
return "error" in object;
};
/**
* @ignore
*/
const isVersionNegotiated = function(object) {
return "software" in object && "protocol" in object;
};
//#endregion
//#region source/electrum-connection.ts
/**
* Wrapper around TLS/WSS sockets that gracefully separates a network stream into Electrum protocol messages.
*/
var ElectrumConnection = class extends EventEmitter {
status = ConnectionStatus.DISCONNECTED;
lastReceivedTimestamp;
socket;
keepAliveTimer;
reconnectTimer;
verifications = [];
messageBuffer = "";
/**
* Sets up network configuration for an Electrum client connection.
*
* @param application - your application name, used to identify to the electrum host.
* @param version - protocol version to use with the host.
* @param socketOrHostname - pre-configured electrum socket or fully qualified domain name or IP number of the host
* @param options - ...
*
* @throws {Error} if `version` is not a valid version string.
*/
constructor(application, version, socketOrHostname, options) {
super();
this.application = application;
this.version = version;
this.socketOrHostname = socketOrHostname;
this.options = options;
if (!ElectrumProtocol.versionRegexp.test(version)) throw /* @__PURE__ */ new Error(`Provided version string (${version}) is not a valid protocol version number.`);
if (typeof socketOrHostname === "string") this.socket = new ElectrumWebSocket(socketOrHostname);
else this.socket = socketOrHostname;
this.socket.on("connected", this.onSocketConnect.bind(this));
this.socket.on("disconnected", this.onSocketDisconnect.bind(this));
this.socket.on("data", this.parseMessageChunk.bind(this));
if (typeof document !== "undefined" && !this.options.disableBrowserVisibilityHandling) document.addEventListener("visibilitychange", this.handleVisibilityChange.bind(this));
if (typeof window !== "undefined" && !this.options.disableBrowserConnectivityHandling) {
window.addEventListener("online", this.handleNetworkChange.bind(this));
window.addEventListener("offline", this.handleNetworkChange.bind(this));
}
}
get hostIdentifier() {
return this.socket.hostIdentifier;
}
get encrypted() {
return this.socket.encrypted;
}
/**
* Assembles incoming data into statements and hands them off to the message parser.
*
* @param data - data to append to the current message buffer, as a string.
*
* @throws {SyntaxError} if the passed statement parts are not valid JSON.
*/
parseMessageChunk(data) {
this.lastReceivedTimestamp = Date.now();
this.emit("received");
this.verifications.forEach((timer) => clearTimeout(timer));
this.verifications.length = 0;
this.messageBuffer += data;
while (this.messageBuffer.includes(ElectrumProtocol.statementDelimiter)) {
const statementParts = this.messageBuffer.split(ElectrumProtocol.statementDelimiter);
while (statementParts.length > 1) {
let statementList = parse(String(statementParts.shift()), null, this.options.useBigInt ? parseNumberAndBigInt : parseFloat);
if (!Array.isArray(statementList)) statementList = [statementList];
while (statementList.length > 0) {
const currentStatement = statementList.shift();
if (isRPCNotification(currentStatement)) {
this.emit("response", currentStatement);
continue;
}
if (currentStatement.id === "versionNegotiation") {
if (isRPCErrorResponse(currentStatement)) this.emit("version", { error: currentStatement.error });
else {
const [software, protocol] = currentStatement.result;
this.emit("version", {
software,
protocol
});
}
continue;
}
if (currentStatement.id === "keepAlive") continue;
this.emit("response", currentStatement);
}
}
this.messageBuffer = statementParts.shift() || "";
}
}
/**
* Sends a keep-alive message to the host.
*
* @returns true if the ping message was fully flushed to the socket, false if
* part of the message is queued in the user memory
*/
ping() {
debug.ping(`Sending keep-alive ping to '${this.hostIdentifier}'`);
const message = ElectrumProtocol.buildRequestObject("server.ping", [], "keepAlive");
return this.send(message);
}
/**
* Initiates the network connection negotiates a protocol version. Also emits the 'connect' signal if successful.
*
* @throws {Error} if the socket connection fails.
* @returns a promise resolving when the connection is established
*/
async connect() {
if (this.status === ConnectionStatus.CONNECTED) return;
this.status = ConnectionStatus.CONNECTING;
this.emit("connecting");
const connectionResolver = (resolve, reject) => {
const rejector = (error) => {
this.status = ConnectionStatus.DISCONNECTED;
this.emit("disconnected");
reject(error);
};
this.socket.removeAllListeners("error");
this.socket.once("error", rejector);
const versionNegotiator = () => {
debug.network(`Requesting protocol version ${this.version} with '${this.hostIdentifier}'.`);
this.socket.removeListener("error", rejector);
const versionMessage = ElectrumProtocol.buildRequestObject("server.version", [this.application, this.version], "versionNegotiation");
const versionValidator = (version) => {
if (isVersionRejected(version)) {
this.disconnect(true);
const errorMessage = "unsupported protocol version.";
debug.errors(`Failed to connect with ${this.hostIdentifier} due to ${errorMessage}`);
reject(errorMessage);
} else if (version.protocol !== this.version && `${version.protocol}.0` !== this.version && `${version.protocol}.0.0` !== this.version) {
this.disconnect(true);
const errorMessage = `incompatible protocol version negotiated (${version.protocol} !== ${this.version}).`;
debug.errors(`Failed to connect with ${this.hostIdentifier} due to ${errorMessage}`);
reject(errorMessage);
} else {
debug.network(`Negotiated protocol version ${version.protocol} with '${this.hostIdentifier}', powered by ${version.software}.`);
this.status = ConnectionStatus.CONNECTED;
this.emit("connected");
resolve();
}
};
this.once("version", versionValidator);
this.send(versionMessage);
};
this.socket.once("connected", versionNegotiator);
this.socket.on("error", this.onSocketError.bind(this));
this.socket.connect();
};
await new Promise(connectionResolver);
}
/**
* Restores the network connection.
*/
async reconnect() {
await this.clearReconnectTimer();
debug.network(`Trying to reconnect to '${this.hostIdentifier}'..`);
this.status = ConnectionStatus.RECONNECTING;
this.emit("reconnecting");
this.socket.disconnect();
try {
await this.connect();
} catch (_error) {}
}
/**
* Removes the current reconnect timer.
*/
clearReconnectTimer() {
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
this.reconnectTimer = void 0;
}
/**
* Removes the current keep-alive timer.
*/
clearKeepAliveTimer() {
if (this.keepAliveTimer) clearTimeout(this.keepAliveTimer);
this.keepAliveTimer = void 0;
}
/**
* Initializes the keep alive timer loop.
*/
setupKeepAliveTimer() {
if (!this.keepAliveTimer) this.keepAliveTimer = setTimeout(this.ping.bind(this), this.options.sendKeepAliveIntervalInMilliSeconds);
}
/**
* Tears down the current connection and removes all event listeners on disconnect.
*
* @param force - disconnect even if the connection has not been fully established yet.
* @param intentional - update connection state if disconnect is intentional.
*
* @returns true if successfully disconnected, or false if there was no connection.
*/
async disconnect(force = false, intentional = true) {
if (this.status === ConnectionStatus.DISCONNECTED && !force) return false;
if (intentional) this.status = ConnectionStatus.DISCONNECTING;
this.emit("disconnecting");
await this.clearKeepAliveTimer();
await this.clearReconnectTimer();
const disconnectResolver = (resolve) => {
this.once("disconnected", () => resolve(true));
this.socket.disconnect();
};
return new Promise(disconnectResolver);
}
/**
* Updates the connection state based on browser reported connectivity.
*
* Most modern browsers are able to provide information on the connection state
* which allows for significantly faster response times to network changes compared
* to waiting for network requests to fail.
*
* When available, we make use of this to fail early to provide a better user experience.
*/
async handleNetworkChange() {
if (typeof window.navigator === "undefined") return;
if (window.navigator.onLine === true) this.reconnect();
if (window.navigator.onLine !== true) this.disconnect(true, true);
}
/**
* Updates connection state based on application visibility.
*
* Some browsers will disconnect network connections when the browser is out of focus,
* which would normally cause our reconnect-on-timeout routines to trigger, but that
* results in a poor user experience since the events are not handled consistently
* and sometimes it can take some time after restoring focus to the browser.
*
* By manually disconnecting when this happens we prevent the default reconnection routines
* and make the behavior consistent across browsers.
*/
async handleVisibilityChange() {
if (document.visibilityState === "hidden") this.disconnect(true, true);
if (document.visibilityState === "visible") this.reconnect();
}
/**
* Sends an arbitrary message to the server.
*
* @param message - json encoded request object to send to the server, as a string.
*
* @returns true if the message was fully flushed to the socket, false if part of the message
* is queued in the user memory
*/
send(message) {
this.clearKeepAliveTimer();
const currentTime = Date.now();
const verificationTimer = setTimeout(this.verifySend.bind(this, currentTime), this.socket.timeout);
this.verifications.push(verificationTimer);
this.setupKeepAliveTimer();
return this.socket.write(message + ElectrumProtocol.statementDelimiter);
}
/**
* Marks the connection as timed out and schedules reconnection if we have not
* received data within the expected time frame.
*/
verifySend(sentTimestamp) {
if (Number(this.lastReceivedTimestamp) < sentTimestamp) {
if (this.status === ConnectionStatus.DISCONNECTED || this.status === ConnectionStatus.DISCONNECTING) return;
this.clearKeepAliveTimer();
debug.network(`Connection to '${this.hostIdentifier}' timed out.`);
this.socket.disconnect();
}
}
/**
* Updates the connection status when a connection is confirmed.
*/
onSocketConnect() {
this.clearReconnectTimer();
this.lastReceivedTimestamp = Date.now();
this.setupKeepAliveTimer();
this.socket.removeAllListeners("error");
this.socket.on("error", this.onSocketError.bind(this));
}
/**
* Updates the connection status when a connection is ended.
*/
onSocketDisconnect() {
this.clearKeepAliveTimer();
if (this.status === ConnectionStatus.DISCONNECTING) {
this.status = ConnectionStatus.DISCONNECTED;
this.emit("disconnected");
this.clearReconnectTimer();
this.removeAllListeners();
debug.network(`Disconnected from '${this.hostIdentifier}'.`);
} else {
if (this.status === ConnectionStatus.CONNECTED) debug.errors(`Connection with '${this.hostIdentifier}' was closed, trying to reconnect in ${this.options.reconnectAfterMilliSeconds / 1e3} seconds.`);
this.status = ConnectionStatus.DISCONNECTED;
this.emit("disconnected");
if (!this.reconnectTimer) this.reconnectTimer = setTimeout(this.reconnect.bind(this), this.options.reconnectAfterMilliSeconds);
}
}
/**
* Notify administrator of any unexpected errors.
*/
onSocketError(error) {
if (typeof error === "undefined") return;
debug.errors(`Network error ('${this.hostIdentifier}'): `, error);
}
};
//#endregion
//#region source/constants.ts
const MILLI_SECONDS_PER_SECOND = 1e3;
/**
* Configure default options.
*/
const defaultNetworkOptions = {
useBigInt: false,
sendKeepAliveIntervalInMilliSeconds: 1 * MILLI_SECONDS_PER_SECOND,
reconnectAfterMilliSeconds: 5 * MILLI_SECONDS_PER_SECOND,
verifyConnectionTimeoutInMilliSeconds: 5 * MILLI_SECONDS_PER_SECOND,
disableBrowserVisibilityHandling: false,
disableBrowserConnectivityHandling: false
};
//#endregion
//#region source/electrum-client.ts
/**
* High-level Electrum client that lets applications send requests and subscribe to notification events from a server.
*/
var ElectrumClient = class extends EventEmitter {
/**
* The name and version of the server software indexing the blockchain.
*/
software;
/**
* The genesis hash of the blockchain indexed by the server.
* @remarks This is only available after a 'server.features' call.
*/
genesisHash;
/**
* The chain height of the blockchain indexed by the server.
* @remarks This is only available after a 'blockchain.headers.subscribe' call.
*/
chainHeight;
/**
* Timestamp of when we last received data from the server indexing the blockchain.
*/
lastReceivedTimestamp;
/**
* Number corresponding to the underlying connection status.
*/
get status() {
return this.connection.status;
}
connection;
subscriptionMethods = {};
requestId = 0;
requestResolvers = {};
connectionLock = new Mutex();
/**
* Initializes an Electrum client.
*
* @param application - your application name, used to identify to the electrum host.
* @param version - protocol version to use with the host.
* @param socketOrHostname - pre-configured electrum socket or fully qualified domain name or IP number of the host
* @param options - ...
*
* @throws {Error} if `version` is not a valid version string.
*/
constructor(application, version, socketOrHostname, options = {}) {
super();
this.application = application;
this.version = version;
this.socketOrHostname = socketOrHostname;
this.options = options;
this.connection = new ElectrumConnection(application, version, socketOrHostname, {
...defaultNetworkOptions,
...options
});
}
get hostIdentifier() {
return this.connection.hostIdentifier;
}
get encrypted() {
return this.connection.encrypted;
}
/**
* Connects to the remote server.
*
* @throws {Error} if the socket connection fails.
* @returns a promise resolving when the connection is established.
*/
async connect() {
const unlock = await this.connectionLock.acquire();
try {
if (this.connection.status === ConnectionStatus.CONNECTED) return;
this.connection.on("response", this.response.bind(this));
this.connection.on("connected", this.resubscribeOnConnect.bind(this));
this.connection.on("disconnected", this.onConnectionDisconnect.bind(this));
this.connection.on("connecting", this.handleConnectionStatusChanges.bind(this, "connecting"));
this.connection.on("disconnecting", this.handleConnectionStatusChanges.bind(this, "disconnecting"));
this.connection.on("reconnecting", this.handleConnectionStatusChanges.bind(this, "reconnecting"));
this.connection.on("version", this.storeSoftwareVersion.bind(this));
this.connection.on("received", this.updateLastReceivedTimestamp.bind(this));
this.connection.on("error", this.emit.bind(this, "error"));
await this.connection.connect();
} finally {
unlock();
}
}
/**
* Disconnects from the remote server and removes all event listeners/subscriptions and open requests.
*
* @param force - disconnect even if the connection has not been fully established yet.
* @param retainSubscriptions - retain subscription data so they will be restored on reconnection.
*
* @returns true if successfully disconnected, or false if there was no connection.
*/
async disconnect(force = false, retainSubscriptions = false) {
if (!retainSubscriptions) {
this.removeAllListeners();
this.subscriptionMethods = {};
}
return this.connection.disconnect(force);
}
/**
* Calls a method on the remote server with the supplied parameters.
*
* @param method - name of the method to call.
* @param parameters - one or more parameters for the method.
*
* @throws {Error} if the client is disconnected.
* @returns a promise that resolves with the result of the method or an Error.
*/
async request(method, ...parameters) {
if (this.connection.status !== ConnectionStatus.CONNECTED) throw /* @__PURE__ */ new Error(`Unable to send request to a disconnected server '${this.hostIdentifier}'.`);
this.requestId += 1;
const id = this.requestId;
const message = ElectrumProtocol.buildRequestObject(method, parameters, id);
const requestResolver = (resolve) => {
this.requestResolvers[id] = (error, data) => {
if (error) resolve(error);
else resolve(data);
};
this.connection.send(message);
};
debug.network(`Sending request '${method}' to '${this.hostIdentifier}'`);
return new Promise(requestResolver);
}
/**
* Subscribes to the method and payload at the server.
*
* @remarks the response for the subscription request is issued as a notification event.
*
* @param method - one of the subscribable methods the server supports.
* @param parameters - one or more parameters for the method.
*
* @throws {Error} if the client is disconnected.
* @returns a promise resolving when the subscription is established.
*/
async subscribe(method, ...parameters) {
if (!this.subscriptionMethods[method]) this.subscriptionMethods[method] = /* @__PURE__ */ new Set();
this.subscriptionMethods[method].add(JSON.stringify(parameters));
const requestData = await this.request(method, ...parameters);
if (requestData instanceof Error) throw requestData;
if (Array.isArray(requestData)) throw /* @__PURE__ */ new Error("Subscription request returned an more than one data point.");
const notification = {
jsonrpc: "2.0",
method,
params: [...parameters, requestData]
};
this.emit("notification", notification);
this.updateChainHeightFromHeadersNotifications(notification);
}
/**
* Unsubscribes to the method at the server and removes any callback functions
* when there are no more subscriptions for the method.
*
* @param method - a previously subscribed to method.
* @param parameters - one or more parameters for the method.
*
* @throws {Error} if no subscriptions exist for the combination of the provided `method` and `parameters.
* @throws {Error} if the client is disconnected.
* @returns a promise resolving when the subscription is removed.
*/
async unsubscribe(method, ...parameters) {
if (this.connection.status !== ConnectionStatus.CONNECTED) throw /* @__PURE__ */ new Error(`Unable to send unsubscribe request to a disconnected server '${this.hostIdentifier}'.`);
if (!this.subscriptionMethods[method]) throw /* @__PURE__ */ new Error(`Cannot unsubscribe from '${method}' since the method has no subscriptions.`);
const subscriptionParameters = JSON.stringify(parameters);
if (!this.subscriptionMethods[method].has(subscriptionParameters)) throw /* @__PURE__ */ new Error(`Cannot unsubscribe from '${method}' since it has no subscription with the given parameters.`);
this.subscriptionMethods[method].delete(subscriptionParameters);
await this.request(method.replace(".subscribe", ".unsubscribe"), ...parameters);
debug.client(`Unsubscribed from '${String(method)}' for the '${subscriptionParameters}' parameters.`);
}
/**
* Restores existing subscriptions without updating status or triggering manual callbacks.
*
* @throws {Error} if subscription data cannot be found for all stored event names.
* @throws {Error} if the client is disconnected.
* @returns a promise resolving to true when the subscriptions are restored.
*
* @ignore
*/
async resubscribeOnConnect() {
debug.client(`Connected to '${this.hostIdentifier}'.`);
this.handleConnectionStatusChanges("connected");
const resubscriptionPromises = [];
for (const method in this.subscriptionMethods) {
for (const parameterJSON of this.subscriptionMethods[method].values()) {
const parameters = JSON.parse(parameterJSON);
resubscriptionPromises.push(this.subscribe(method, ...parameters));
}
await Promise.all(resubscriptionPromises);
}
if (resubscriptionPromises.length > 0) debug.client(`Restored ${resubscriptionPromises.length} previous subscriptions for '${this.hostIdentifier}'`);
}
/**
* Parser messages from the remote server to resolve request promises and emit subscription events.
*
* @param message - the response message
*
* @throws {Error} if the message ID does not match an existing request.
* @ignore
*/
response(message) {
if (isRPCNotification(message)) {
debug.client(`Received notification for '${message.method}' from '${this.hostIdentifier}'`);
this.emit("notification", message);
this.updateChainHeightFromHeadersNotifications(message);
return;
}
if (message.id === null) throw /* @__PURE__ */ new Error("Internal error: Received an RPC response with ID null.");
const requestResolver = this.requestResolvers[message.id];
if (!requestResolver) {
debug.warning(`Ignoring response #${message.id} as the request has already been rejected.`);
return;
}
delete this.requestResolvers[message.id];
if (isRPCErrorResponse(message)) requestResolver(new Error(message.error.message));
else {
requestResolver(void 0, message.result);
this.storeGenesisHashFromFeaturesResponse(message);
}
}
/**
* Callback function that is called when connection to the Electrum server is lost.
* Aborts all active requests with an error message indicating that connection was lost.
*
* @ignore
*/
async onConnectionDisconnect() {
for (const resolverId in this.requestResolvers) {
const requestResolver = this.requestResolvers[resolverId];
requestResolver(/* @__PURE__ */ new Error("Connection lost"));
delete this.requestResolvers[resolverId];
}
this.handleConnectionStatusChanges("disconnected");
}
/**
* Stores the server provider software version field on successful version negotiation.
*
* @ignore
*/
async storeSoftwareVersion(versionStatement) {
if (versionStatement.error) return;
this.software = versionStatement.software;
}
/**
* Updates the last received timestamp.
*
* @ignore
*/
async updateLastReceivedTimestamp() {
this.lastReceivedTimestamp = Date.now();
}
/**
* Checks if the provided message is a response to a headers subscription,
* and if so updates the locally stored chain height value for this client.
*
* @ignore
*/
async updateChainHeightFromHeadersNotifications(message) {
if (message.method === "blockchain.headers.subscribe") this.chainHeight = message.params[0].height;
}
/**
* Checks if the provided message is a response to a server.features request,
* and if so stores the genesis hash for this client locally.
*
* @ignore
*/
async storeGenesisHashFromFeaturesResponse(message) {
try {
if (typeof message.result.genesis_hash !== "undefined") this.genesisHash = message.result.genesis_hash;
} catch (_ignored) {}
}
/**
* Helper function to synchronize state and events with the underlying connection.
*/
async handleConnectionStatusChanges(eventName) {
this.emit(eventName);
}
connecting;
connected;
disconnecting;
disconnected;
reconnecting;
notification;
error;
};
var electrum_client_default = ElectrumClient;
//#endregion
export { ConnectionStatus, electrum_client_default as ElectrumClient, isRPCErrorResponse, isRPCNotification, isRPCRequest, isRPCStatement, isVersionNegotiated, isVersionRejected };
//# sourceMappingURL=index.mjs.map