Lints src/client (#1577)

* lint backoff

* lint wsWrapper

* remove rangeset - not used

* split out connection.ts classes

* lint requestManager

* lint connectionManager

* lint most of connection

* fix most of client

* lint broadcastClient

* resolve more linter issues

* resolve magic numbers

* clean up more linting

* resolve rest of issues

* fix tests

* fix browser tests

* fix tests after rebase

* respond to comments

* fix dependency cycles
This commit is contained in:
Mayukha Vadari
2021-09-03 10:43:25 -05:00
parent aa6cef520c
commit 8c5bc22317
48 changed files with 1046 additions and 864 deletions

View File

@@ -38,12 +38,11 @@ module.exports = {
format: ["snake_case"],
},
],
// Ignore type imports when counting dependencies.
"import/max-dependencies": [
"error",
{
max: 5,
max: 10,
ignoreTypeImports: true,
},
],
@@ -64,6 +63,8 @@ module.exports = {
skipComments: true,
},
],
"max-statements": ["warn", 25],
"id-length": ["error", { exceptions: ["_"] }], // exception for lodash
},
overrides: [
{

View File

@@ -1,42 +1,69 @@
// Original code based on "backo" - https://github.com/segmentio/backo
// MIT License - Copyright 2014 Segment.io
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation
// files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy,
// modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software
// is furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
// WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
// ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
interface ExponentialBackoffOptions {
// The min backoff duration.
min?: number;
// The max backoff duration.
max?: number;
}
const DEFAULT_MIN = 100;
const DEFAULT_MAX = 1000;
/**
* A Back off strategy that increases exponentially. Useful with repeated
* setTimeout calls over a network (where the destination may be down).
*/
export class ExponentialBackoff {
export default class ExponentialBackoff {
private readonly ms: number;
private readonly max: number;
private readonly factor: number = 2;
private readonly jitter: number = 0;
attempts = 0;
private numAttempts = 0;
constructor(opts: { min?: number; max?: number } = {}) {
this.ms = opts.min || 100;
this.max = opts.max || 10000;
/**
* Constructs an ExponentialBackoff object.
*
* @param opts - The options for the object.
*/
public constructor(opts: ExponentialBackoffOptions = {}) {
this.ms = opts.min ?? DEFAULT_MIN;
this.max = opts.max ?? DEFAULT_MAX;
}
/**
* Number of attempts for backoff so far.
*
* @returns Number of attempts.
*/
public get attempts(): number {
return this.numAttempts;
}
/**
* Return the backoff duration.
*
* @returns The backoff duration in milliseconds.
*/
duration() {
let ms = this.ms * this.factor ** this.attempts++;
if (this.jitter) {
const rand = Math.random();
const deviation = Math.floor(rand * this.jitter * ms);
ms = (Math.floor(rand * 10) & 1) == 0 ? ms - deviation : ms + deviation;
}
return Math.min(ms, this.max) | 0;
public duration(): number {
const ms = this.ms * this.factor ** this.numAttempts;
this.numAttempts += 1;
return Math.floor(Math.min(ms, this.max));
}
/**
* Reset the number of attempts.
*/
reset() {
this.attempts = 0;
public reset(): void {
this.numAttempts = 0;
}
}

View File

@@ -1,10 +1,18 @@
import { Client, ClientOptions } from ".";
class BroadcastClient extends Client {
ledgerVersion: number | undefined = undefined;
private readonly _clients: Client[];
/**
* Client that can rely on multiple different servers.
*/
export default class BroadcastClient extends Client {
private readonly clients: Client[];
constructor(servers, options: ClientOptions = {}) {
/**
* Creates a new BroadcastClient.
*
* @param servers - An array of names of servers.
* @param options - Options for the clients.
*/
public constructor(servers: string[], options: ClientOptions = {}) {
super(servers[0], options);
const clients: Client[] = servers.map(
@@ -12,33 +20,23 @@ class BroadcastClient extends Client {
);
// exposed for testing
this._clients = clients;
this.getMethodNames().forEach((name) => {
this[name] = function () {
// eslint-disable-line no-loop-func
return Promise.race(
clients.map((client) => client[name](...arguments))
);
};
this.clients = clients;
this.getMethodNames().forEach((name: string) => {
this[name] = async (...args): Promise<unknown> =>
// eslint-disable-next-line max-len -- Need a long comment, TODO: figure out how to avoid this weirdness
/* eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call -- Types are outlined in Client class */
Promise.race(clients.map(async (client) => client[name](...args)));
});
// connection methods must be overridden to apply to all client instances
this.connect = async function () {
await Promise.all(clients.map((client) => client.connect()));
this.connect = async (): Promise<void> => {
await Promise.all(clients.map(async (client) => client.connect()));
};
this.disconnect = async function () {
await Promise.all(clients.map((client) => client.disconnect()));
this.disconnect = async (): Promise<void> => {
await Promise.all(clients.map(async (client) => client.disconnect()));
};
this.isConnected = function () {
return clients.map((client) => client.isConnected()).every(Boolean);
};
// synchronous methods are all passed directly to the first client instance
const defaultClient = clients[0];
const syncMethods = ["sign"];
syncMethods.forEach((name) => {
this[name] = defaultClient[name].bind(defaultClient);
});
this.isConnected = (): boolean =>
clients.map((client) => client.isConnected()).every(Boolean);
clients.forEach((client) => {
client.on("error", (errorCode, errorMessage, data) =>
@@ -47,9 +45,14 @@ class BroadcastClient extends Client {
});
}
getMethodNames() {
/**
* Gets the method names of all the methods of the client.
*
* @returns A list of the names of all the methods of the client.
*/
private getMethodNames(): string[] {
const methodNames: string[] = [];
const firstClient = this._clients[0];
const firstClient = this.clients[0];
const methods = Object.getOwnPropertyNames(firstClient);
methods.push(
...Object.getOwnPropertyNames(Object.getPrototypeOf(firstClient))
@@ -62,5 +65,3 @@ class BroadcastClient extends Client {
return methodNames;
}
}
export { BroadcastClient };

View File

@@ -1,26 +1,32 @@
/* eslint-disable max-lines -- Connection is a big class */
import { EventEmitter } from "events";
import { Agent } from "http";
// eslint-disable-next-line node/no-deprecated-api -- TODO: resolve this
import { parse as parseURL } from "url";
import _ from "lodash";
import WebSocket from "ws";
import {
RippledError,
DisconnectedError,
NotConnectedError,
TimeoutError,
ResponseFormatError,
ConnectionError,
RippleError,
} from "../common/errors";
import { Response } from "../models/methods";
import { BaseRequest } from "../models/methods/baseMethod";
import { ExponentialBackoff } from "./backoff";
import ExponentialBackoff from "./backoff";
import ConnectionManager from "./connectionManager";
import RequestManager from "./requestManager";
const SECONDS_PER_MINUTE = 60;
const TIMEOUT = 20;
const CONNECTION_TIMEOUT = 5;
/**
* ConnectionOptions is the configuration for the Connection class.
*/
export interface ConnectionOptions {
interface ConnectionOptions {
trace?: boolean | ((id: string, message: string) => void);
proxy?: string;
proxyAuthorization?: string;
@@ -29,7 +35,8 @@ export interface ConnectionOptions {
key?: string;
passphrase?: string;
certificate?: string;
timeout: number; // request timeout
// request timeout
timeout: number;
connectionTimeout: number;
}
@@ -45,19 +52,13 @@ export type ConnectionUserOptions = Partial<ConnectionOptions>;
// WebSocket spec allows 4xxx codes for app/library specific codes.
// See: https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent
//
const INTENTIONAL_DISCONNECT_CODE = 4000;
export const INTENTIONAL_DISCONNECT_CODE = 4000;
/**
* Create a new websocket given your URL and optional proxy/certificate
* configuration.
*
* @param url
* @param config
*/
function createWebSocket(url: string, config: ConnectionOptions): WebSocket {
const options: WebSocket.ClientOptions = {};
if (config.proxy != null) {
type WebsocketState = 0 | 1 | 2 | 3;
function getAgent(url: string, config: ConnectionOptions): Agent | undefined {
// TODO: replace deprecated method
if (config.proxy != null) {
const parsedURL = parseURL(url);
const parsedProxyURL = parseURL(config.proxy);
const proxyOverrides = _.omitBy(
@@ -75,12 +76,32 @@ function createWebSocket(url: string, config: ConnectionOptions): WebSocket {
const proxyOptions = { ...parsedProxyURL, ...proxyOverrides };
let HttpsProxyAgent;
try {
// eslint-disable-next-line max-len -- Long eslint-disable-next-line TODO: figure out how to make this nicer
// eslint-disable-next-line import/max-dependencies, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-require-imports, node/global-require, global-require, -- Necessary for the `require`
HttpsProxyAgent = require("https-proxy-agent");
} catch (error) {
} catch (_error) {
throw new Error('"proxy" option is not supported in the browser');
}
options.agent = new HttpsProxyAgent(proxyOptions);
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-unsafe-call -- Necessary
return new HttpsProxyAgent(proxyOptions) as unknown as Agent;
}
return undefined;
}
/**
* Create a new websocket given your URL and optional proxy/certificate
* configuration.
*
* @param url - The URL to connect to.
* @param config - THe configuration options for the WebSocket.
* @returns A Websocket that fits the given configuration parameters.
*/
function createWebSocket(
url: string,
config: ConnectionOptions
): WebSocket | null {
const options: WebSocket.ClientOptions = {};
options.agent = getAgent(url, config);
if (config.authorization != null) {
const base64 = Buffer.from(config.authorization).toString("base64");
options.headers = { Authorization: `Basic ${base64}` };
@@ -107,10 +128,14 @@ function createWebSocket(url: string, config: ConnectionOptions): WebSocket {
/**
* Ws.send(), but promisified.
*
* @param ws
* @param message
* @param ws - Websocket to send with.
* @param message - Message to send.
* @returns When the message has been sent.
*/
function websocketSendAsync(ws: WebSocket, message: string) {
async function websocketSendAsync(
ws: WebSocket,
message: string
): Promise<void> {
return new Promise<void>((resolve, reject) => {
ws.send(message, (error) => {
if (error) {
@@ -122,357 +147,106 @@ function websocketSendAsync(ws: WebSocket, message: string) {
});
}
/**
* Manage all the requests made to the websocket, and their async responses
* that come in from the WebSocket. Because they come in over the WS connection
* after-the-fact.
*/
class ConnectionManager {
private promisesAwaitingConnection: Array<{
resolve: Function;
reject: Function;
}> = [];
resolveAllAwaiting() {
this.promisesAwaitingConnection.map(({ resolve }) => resolve());
this.promisesAwaitingConnection = [];
}
rejectAllAwaiting(error: Error) {
this.promisesAwaitingConnection.map(({ reject }) => reject(error));
this.promisesAwaitingConnection = [];
}
awaitConnection(): Promise<void> {
return new Promise((resolve, reject) => {
this.promisesAwaitingConnection.push({ resolve, reject });
});
}
}
/**
* Manage all the requests made to the websocket, and their async responses
* that come in from the WebSocket. Responses come in over the WS connection
* after-the-fact, so this manager will tie that response to resolve the
* original request.
*/
class RequestManager {
private nextId = 0;
private promisesAwaitingResponse: Array<{
resolve: Function;
reject: Function;
timer: NodeJS.Timeout;
}> = [];
cancel(id: number) {
const { timer } = this.promisesAwaitingResponse[id];
clearTimeout(timer);
delete this.promisesAwaitingResponse[id];
}
resolve(id: string | number, data: Response) {
const { timer, resolve } = this.promisesAwaitingResponse[id];
clearTimeout(timer);
resolve(data);
delete this.promisesAwaitingResponse[id];
}
reject(id: string | number, error: Error) {
const { timer, reject } = this.promisesAwaitingResponse[id];
clearTimeout(timer);
reject(error);
delete this.promisesAwaitingResponse[id];
}
rejectAll(error: Error) {
this.promisesAwaitingResponse.forEach((_, id) => {
this.reject(id, error);
});
}
/**
* Creates a new WebSocket request. This sets up a timeout timer to catch
* hung responses, and a promise that will resolve with the response once
* the response is seen & handled.
*
* @param data
* @param timeout
*/
createRequest(
data: any,
timeout: number
): [string | number, string, Promise<any>] {
const newId = data.id ? data.id : this.nextId++;
const newData = JSON.stringify({ ...data, id: newId });
const timer = setTimeout(
() => this.reject(newId, new TimeoutError()),
timeout
);
// Node.js won't exit if a timer is still running, so we tell Node to ignore.
// (Node will still wait for the request to complete).
if (timer.unref) {
timer.unref();
}
const newPromise = new Promise(
(resolve: (data: Response) => void, reject) => {
this.promisesAwaitingResponse[newId] = { resolve, reject, timer };
}
);
return [newId, newData, newPromise];
}
/**
* Handle a "response". Responses match to the earlier request handlers,
* and resolve/reject based on the data received.
*
* @param data
*/
handleResponse(data: Response) {
if (!Number.isInteger(data.id) || data.id < 0) {
throw new ResponseFormatError("valid id not found in response", data);
}
if (!this.promisesAwaitingResponse[data.id]) {
return;
}
if (data.status === "error") {
const error = new RippledError(data.error_message || data.error, data);
this.reject(data.id, error);
return;
}
if (data.status !== "success") {
const error = new ResponseFormatError(
`unrecognized response.status: ${data.status}`,
data
);
this.reject(data.id, error);
return;
}
this.resolve(data.id, data);
}
}
/**
* The main Connection class. Responsible for connecting to & managing
* an active WebSocket connection to a XRPL node.
*
* @param errorOrCode
*/
export class Connection extends EventEmitter {
private readonly _url: string | undefined;
private _ws: null | WebSocket = null;
private _reconnectTimeoutID: null | NodeJS.Timeout = null;
private _heartbeatIntervalID: null | NodeJS.Timeout = null;
private readonly _retryConnectionBackoff = new ExponentialBackoff({
private readonly url: string | undefined;
private ws: WebSocket | null = null;
private reconnectTimeoutID: null | NodeJS.Timeout = null;
private heartbeatIntervalID: null | NodeJS.Timeout = null;
private readonly retryConnectionBackoff = new ExponentialBackoff({
min: 100,
max: 60 * 1000,
max: SECONDS_PER_MINUTE * 1000,
});
private readonly _trace: (id: string, message: string) => void = () => {};
private readonly _config: ConnectionOptions;
private readonly _requestManager = new RequestManager();
private readonly _connectionManager = new ConnectionManager();
private readonly config: ConnectionOptions;
private readonly requestManager = new RequestManager();
private readonly connectionManager = new ConnectionManager();
constructor(url?: string, options: ConnectionUserOptions = {}) {
/**
* Creates a new Connection object.
*
* @param url - URL to connect to.
* @param options - Options for the Connection object.
*/
public constructor(url?: string, options: ConnectionUserOptions = {}) {
super();
this.setMaxListeners(Infinity);
this._url = url;
this._config = {
timeout: 20 * 1000,
connectionTimeout: 5 * 1000,
this.url = url;
this.config = {
timeout: TIMEOUT * 1000,
connectionTimeout: CONNECTION_TIMEOUT * 1000,
...options,
};
if (typeof options.trace === "function") {
this._trace = options.trace;
this.trace = options.trace;
} else if (options.trace) {
this._trace = console.log;
// eslint-disable-next-line no-console -- Used for tracing only
this.trace = console.log;
}
}
private _onMessage(message) {
this._trace("receive", message);
let data: any;
try {
data = JSON.parse(message);
} catch (error) {
this.emit("error", "badMessage", error.message, message);
return;
}
if (data.type == null && data.error) {
this.emit("error", data.error, data.error_message, data); // e.g. slowDown
return;
}
if (data.type) {
this.emit(data.type, data);
}
if (data.type === "response") {
try {
this._requestManager.handleResponse(data);
} catch (error) {
this.emit("error", "badMessage", error.message, message);
}
}
}
private get _state() {
return this._ws ? this._ws.readyState : WebSocket.CLOSED;
}
private get _shouldBeConnected() {
return this._ws !== null;
}
private readonly _clearHeartbeatInterval = () => {
if (this._heartbeatIntervalID) {
clearInterval(this._heartbeatIntervalID);
}
};
private readonly _startHeartbeatInterval = () => {
this._clearHeartbeatInterval();
this._heartbeatIntervalID = setInterval(
() => this._heartbeat(),
this._config.timeout
);
};
/**
* A heartbeat is just a "ping" command, sent on an interval.
* If this succeeds, we're good. If it fails, disconnect so that the consumer can reconnect, if desired.
* Returns whether the websocket is connected.
*
* @returns Whether the websocket connection is open.
*/
private readonly _heartbeat = () => {
return this.request({ command: "ping" }).catch(() => {
return this.reconnect().catch((error) => {
this.emit("error", "reconnect", error.message, error);
});
});
};
private readonly _onConnectionFailed = (
errorOrCode: Error | number | null
) => {
if (this._ws) {
this._ws.removeAllListeners();
this._ws.on("error", () => {
// Correctly listen for -- but ignore -- any future errors: If you
// don't have a listener on "error" node would log a warning on error.
});
this._ws.close();
this._ws = null;
}
if (typeof errorOrCode === "number") {
this._connectionManager.rejectAllAwaiting(
new NotConnectedError(`Connection failed with code ${errorOrCode}.`, {
code: errorOrCode,
})
);
} else if (errorOrCode && errorOrCode.message) {
this._connectionManager.rejectAllAwaiting(
new NotConnectedError(errorOrCode.message, errorOrCode)
);
} else {
this._connectionManager.rejectAllAwaiting(
new NotConnectedError("Connection failed.")
);
}
};
isConnected() {
return this._state === WebSocket.OPEN;
public isConnected(): boolean {
return this.state === WebSocket.OPEN;
}
connect(): Promise<void> {
/**
* Connects the websocket to the provided URL.
*
* @returns When the websocket is connected.
* @throws ConnectionError if there is a connection error, RippleError if there is already a WebSocket in existence.
*/
public async connect(): Promise<void> {
if (this.isConnected()) {
return Promise.resolve();
}
if (this._state === WebSocket.CONNECTING) {
return this._connectionManager.awaitConnection();
if (this.state === WebSocket.CONNECTING) {
return this.connectionManager.awaitConnection();
}
if (!this._url) {
if (!this.url) {
return Promise.reject(
new ConnectionError("Cannot connect because no server was specified")
);
}
if (this._ws) {
if (this.ws != null) {
return Promise.reject(
new RippleError("Websocket connection never cleaned up.", {
state: this._state,
state: this.state,
})
);
}
// Create the connection timeout, in case the connection hangs longer than expected.
const connectionTimeoutID = setTimeout(() => {
this._onConnectionFailed(
this.onConnectionFailed(
new ConnectionError(
`Error: connect() timed out after ${this._config.connectionTimeout} ms. ` +
`Error: connect() timed out after ${this.config.connectionTimeout} ms. ` +
`If your internet connection is working, the rippled server may be blocked or inaccessible. ` +
`You can also try setting the 'connectionTimeout' option in the Client constructor.`
)
);
}, this._config.connectionTimeout);
}, this.config.connectionTimeout);
// Connection listeners: these stay attached only until a connection is done/open.
this._ws = createWebSocket(this._url, this._config);
this.ws = createWebSocket(this.url, this.config);
if (this._ws == null) {
if (this.ws == null) {
throw new Error("Connect: created null websocket");
}
this._ws.on("error", this._onConnectionFailed);
this._ws.on("error", () => clearTimeout(connectionTimeoutID));
this._ws.on("close", this._onConnectionFailed);
this._ws.on("close", () => clearTimeout(connectionTimeoutID));
this._ws.once("open", async () => {
if (this._ws == null) {
throw new Error("onceOpen: ws is null");
}
// Once the connection completes successfully, remove all old listeners
this._ws.removeAllListeners();
clearTimeout(connectionTimeoutID);
// Add new, long-term connected listeners for messages and errors
this._ws.on("message", (message: string) => this._onMessage(message));
this._ws.on("error", (error) =>
this.emit("error", "websocket", error.message, error)
);
// Handle a closed connection: reconnect if it was unexpected
this._ws.once("close", (code, reason) => {
if (this._ws == null) {
throw new Error("onceClose: ws is null");
}
this._clearHeartbeatInterval();
this._requestManager.rejectAll(
new DisconnectedError(`websocket was closed, ${reason}`)
);
this._ws.removeAllListeners();
this._ws = null;
this.emit("disconnected", code);
// If this wasn't a manual disconnect, then lets reconnect ASAP.
if (code !== INTENTIONAL_DISCONNECT_CODE) {
const retryTimeout = this._retryConnectionBackoff.duration();
this._trace("reconnect", `Retrying connection in ${retryTimeout}ms.`);
this.emit("reconnecting", this._retryConnectionBackoff.attempts);
// Start the reconnect timeout, but set it to `this._reconnectTimeoutID`
// so that we can cancel one in-progress on disconnect.
this._reconnectTimeoutID = setTimeout(() => {
this.reconnect().catch((error) => {
this.emit("error", "reconnect", error.message, error);
});
}, retryTimeout);
}
});
// Finalize the connection and resolve all awaiting connect() requests
try {
this._retryConnectionBackoff.reset();
this._startHeartbeatInterval();
this._connectionManager.resolveAllAwaiting();
this.emit("connected");
} catch (error) {
this._connectionManager.rejectAllAwaiting(error);
await this.disconnect().catch(() => {}); // Ignore this error, propagate the root cause.
}
});
return this._connectionManager.awaitConnection();
this.ws.on("error", (error) => this.onConnectionFailed(error));
this.ws.on("error", () => clearTimeout(connectionTimeoutID));
this.ws.on("close", (reason) => this.onConnectionFailed(reason));
this.ws.on("close", () => clearTimeout(connectionTimeoutID));
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- TODO: resolve this
this.ws.once("open", async () => this.onceOpen(connectionTimeoutID));
return this.connectionManager.awaitConnection();
}
/**
@@ -481,30 +255,33 @@ export class Connection extends EventEmitter {
* should still successfully close with the relevant error code returned.
* See https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent for the full list.
* If no open websocket connection exists, resolve with no code (`undefined`).
*
* @returns A promise containing either `undefined` or a disconnected code, that resolves when the connection is destroyed.
*/
disconnect(): Promise<number | undefined> {
if (this._reconnectTimeoutID !== null) {
clearTimeout(this._reconnectTimeoutID);
this._reconnectTimeoutID = null;
public async disconnect(): Promise<number | undefined> {
if (this.reconnectTimeoutID !== null) {
clearTimeout(this.reconnectTimeoutID);
this.reconnectTimeoutID = null;
}
if (this._state === WebSocket.CLOSED) {
if (this.state === WebSocket.CLOSED) {
return Promise.resolve(undefined);
}
if (this._ws === null) {
if (this.ws == null) {
return Promise.resolve(undefined);
}
return new Promise((resolve) => {
if (this._ws === null) {
return Promise.resolve(undefined);
if (this.ws == null) {
resolve(undefined);
}
if (this.ws != null) {
this.ws.once("close", (code) => resolve(code));
}
this._ws.once("close", (code) => resolve(code));
// Connection already has a disconnect handler for the disconnect logic.
// Just close the websocket manually (with our "intentional" code) to
// trigger that.
if (this._ws != null && this._state !== WebSocket.CLOSING) {
this._ws.close(INTENTIONAL_DISCONNECT_CODE);
if (this.ws != null && this.state !== WebSocket.CLOSING) {
this.ws.close(INTENTIONAL_DISCONNECT_CODE);
}
});
}
@@ -512,7 +289,7 @@ export class Connection extends EventEmitter {
/**
* Disconnect the websocket, then connect again.
*/
async reconnect() {
public async reconnect(): Promise<void> {
// NOTE: We currently have a "reconnecting" event, but that only triggers
// through an unexpected connection retry logic.
// See: https://github.com/ripple/ripple-lib/pull/1101#issuecomment-565360423
@@ -521,20 +298,28 @@ export class Connection extends EventEmitter {
await this.connect();
}
async request<T extends { command: string }>(
/**
* Sends a request to the rippled server.
*
* @param request - The request to send to the server.
* @param timeout - How long the Connection instance should wait before assuming that there will not be a response.
* @returns The response from the rippled server.
* @throws NotConnectedError if the Connection isn't connected to a server.
*/
public async request<T extends BaseRequest>(
request: T,
timeout?: number
): Promise<any> {
if (!this._shouldBeConnected || this._ws == null) {
): Promise<unknown> {
if (!this.shouldBeConnected || this.ws == null) {
throw new NotConnectedError();
}
const [id, message, responsePromise] = this._requestManager.createRequest(
const [id, message, responsePromise] = this.requestManager.createRequest(
request,
timeout || this._config.timeout
timeout ?? this.config.timeout
);
this._trace("send", message);
websocketSendAsync(this._ws, message).catch((error) => {
this._requestManager.reject(id, error);
this.trace("send", message);
websocketSendAsync(this.ws, message).catch((error) => {
this.requestManager.reject(id, error);
});
return responsePromise;
@@ -545,7 +330,195 @@ export class Connection extends EventEmitter {
*
* @returns The Websocket connection URL.
*/
getUrl(): string {
return this._url ?? "";
public getUrl(): string {
return this.url ?? "";
}
// eslint-disable-next-line @typescript-eslint/no-empty-function -- Does nothing on default
private readonly trace: (id: string, message: string) => void = () => {};
/**
* Handler for when messages are received from the server.
*
* @param message - The message received from the server.
*/
private onMessage(message): void {
this.trace("receive", message);
let data: Record<string, unknown>;
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- Must be a JSON dictionary
data = JSON.parse(message);
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- Errors have messages
this.emit("error", "badMessage", error.message, message);
return;
}
if (data.type == null && data.error) {
// e.g. slowDown
this.emit("error", data.error, data.error_message, data);
return;
}
if (data.type) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Should be true
this.emit(data.type as string, data);
}
if (data.type === "response") {
try {
this.requestManager.handleResponse(data);
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- Errors have messages
this.emit("error", "badMessage", error.message, message);
}
}
}
/**
* Gets the state of the websocket.
*
* @returns The Websocket's ready state.
*/
private get state(): WebsocketState {
return this.ws ? this.ws.readyState : WebSocket.CLOSED;
}
/**
* Returns whether the server should be connected.
*
* @returns Whether the server should be connected.
*/
private get shouldBeConnected(): boolean {
return this.ws !== null;
}
/**
* Handler for what to do once the connection to the server is open.
*
* @param connectionTimeoutID - Timeout in case the connection hangs longer than expected.
* @returns A promise that resolves to void when the connection is fully established.
* @throws Error if the websocket initialized is somehow null.
*/
private async onceOpen(connectionTimeoutID: NodeJS.Timeout): Promise<void> {
if (this.ws == null) {
throw new Error("onceOpen: ws is null");
}
// Once the connection completes successfully, remove all old listeners
this.ws.removeAllListeners();
clearTimeout(connectionTimeoutID);
// Add new, long-term connected listeners for messages and errors
this.ws.on("message", (message: string) => this.onMessage(message));
this.ws.on("error", (error) =>
this.emit("error", "websocket", error.message, error)
);
// Handle a closed connection: reconnect if it was unexpected
this.ws.once("close", (code, reason) => {
if (this.ws == null) {
throw new Error("onceClose: ws is null");
}
this.clearHeartbeatInterval();
this.requestManager.rejectAll(
new DisconnectedError(`websocket was closed, ${reason}`)
);
this.ws.removeAllListeners();
this.ws = null;
this.emit("disconnected", code);
// If this wasn't a manual disconnect, then lets reconnect ASAP.
if (code !== INTENTIONAL_DISCONNECT_CODE) {
this.intentionalDisconnect();
}
});
// Finalize the connection and resolve all awaiting connect() requests
try {
this.retryConnectionBackoff.reset();
this.startHeartbeatInterval();
this.connectionManager.resolveAllAwaiting();
this.emit("connected");
} catch (error) {
this.connectionManager.rejectAllAwaiting(error);
// Ignore this error, propagate the root cause.
// eslint-disable-next-line @typescript-eslint/no-empty-function -- Need empty catch
await this.disconnect().catch(() => {});
}
}
private intentionalDisconnect(): void {
const retryTimeout = this.retryConnectionBackoff.duration();
this.trace("reconnect", `Retrying connection in ${retryTimeout}ms.`);
this.emit("reconnecting", this.retryConnectionBackoff.attempts);
// Start the reconnect timeout, but set it to `this.reconnectTimeoutID`
// so that we can cancel one in-progress on disconnect.
this.reconnectTimeoutID = setTimeout(() => {
this.reconnect().catch((error: Error) => {
this.emit("error", "reconnect", error.message, error);
});
}, retryTimeout);
}
/**
* Clears the heartbeat connection interval.
*/
private clearHeartbeatInterval(): void {
if (this.heartbeatIntervalID) {
clearInterval(this.heartbeatIntervalID);
}
}
/**
* Starts a heartbeat to check the connection with the server.
*/
private startHeartbeatInterval(): void {
this.clearHeartbeatInterval();
this.heartbeatIntervalID = setInterval(
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- TODO: resolve this
async () => this.heartbeat(),
this.config.timeout
);
}
/**
* A heartbeat is just a "ping" command, sent on an interval.
* If this succeeds, we're good. If it fails, disconnect so that the consumer can reconnect, if desired.
*
* @returns A Promise that resolves to void when the heartbeat returns successfully.
*/
private async heartbeat(): Promise<void> {
this.request({ command: "ping" }).catch(async () => {
return this.reconnect().catch((error: Error) => {
this.emit("error", "reconnect", error.message, error);
});
});
}
/**
* Process a failed connection.
*
* @param errorOrCode - (Optional) Error or code for connection failure.
*/
private onConnectionFailed(errorOrCode: Error | number | null): void {
if (this.ws) {
this.ws.removeAllListeners();
this.ws.on("error", () => {
// Correctly listen for -- but ignore -- any future errors: If you
// don't have a listener on "error" node would log a warning on error.
});
this.ws.close();
this.ws = null;
}
if (typeof errorOrCode === "number") {
this.connectionManager.rejectAllAwaiting(
new NotConnectedError(`Connection failed with code ${errorOrCode}.`, {
code: errorOrCode,
})
);
} else if (errorOrCode?.message) {
this.connectionManager.rejectAllAwaiting(
new NotConnectedError(errorOrCode.message, errorOrCode)
);
} else {
this.connectionManager.rejectAllAwaiting(
new NotConnectedError("Connection failed.")
);
}
}
}

View File

@@ -0,0 +1,40 @@
/**
* Manage all the requests made to the websocket, and their async responses
* that come in from the WebSocket. Because they come in over the WS connection
* after-the-fact.
*/
export default class ConnectionManager {
private promisesAwaitingConnection: Array<{
resolve: (value?: void | PromiseLike<void>) => void;
reject: (value?: Error) => void;
}> = [];
/**
* Resolves all awaiting connections.
*/
public resolveAllAwaiting(): void {
this.promisesAwaitingConnection.map(({ resolve }) => resolve());
this.promisesAwaitingConnection = [];
}
/**
* Rejects all awaiting connections.
*
* @param error - Error to throw in the rejection.
*/
public rejectAllAwaiting(error: Error): void {
this.promisesAwaitingConnection.map(({ reject }) => reject(error));
this.promisesAwaitingConnection = [];
}
/**
* Await a new connection.
*
* @returns A promise for resolving the connection.
*/
public async awaitConnection(): Promise<void> {
return new Promise((resolve, reject) => {
this.promisesAwaitingConnection.push({ resolve, reject });
});
}
}

View File

@@ -1,3 +1,6 @@
/* eslint-disable import/max-dependencies -- Client needs a lot of dependencies by definition */
/* eslint-disable @typescript-eslint/member-ordering -- TODO: remove when instance methods aren't members */
/* eslint-disable max-lines -- This might not be necessary later, but this file needs to be big right now */
import { EventEmitter } from "events";
import {
@@ -18,7 +21,7 @@ import {
} from "ripple-address-codec";
import { constants, errors, txFlags, ensureClassicAddress } from "../common";
import { ValidationError } from "../common/errors";
import { RippledError, ValidationError } from "../common/errors";
import { getFee } from "../common/fee";
import getBalances from "../ledger/balances";
import { getOrderbook, formatBidsAndAsks } from "../ledger/orderbook";
@@ -26,8 +29,6 @@ import getPaths from "../ledger/pathfind";
import getTrustlines from "../ledger/trustlines";
import { clamp } from "../ledger/utils";
import {
Request,
Response,
// account methods
AccountChannelsRequest,
AccountChannelsResponse,
@@ -94,6 +95,7 @@ import {
RandomRequest,
RandomResponse,
} from "../models/methods";
import { BaseRequest, BaseResponse } from "../models/methods/baseMethod";
import prepareCheckCancel from "../transaction/check-cancel";
import prepareCheckCash from "../transaction/check-cash";
import prepareCheckCreate from "../transaction/check-create";
@@ -116,7 +118,11 @@ import * as transactionUtils from "../transaction/utils";
import { deriveAddress, deriveXAddress } from "../utils/derive";
import generateFaucetWallet from "../wallet/generateFaucetWallet";
import { Connection, ConnectionUserOptions } from "./connection";
import {
Connection,
ConnectionUserOptions,
INTENTIONAL_DISCONNECT_CODE,
} from "./connection";
export interface ClientOptions extends ConnectionUserOptions {
feeCushion?: number;
@@ -130,7 +136,8 @@ export interface ClientOptions extends ConnectionUserOptions {
* command. This varies from command to command, but we need to know it to
* properly count across many requests.
*
* @param command
* @param command - The rippled request command.
* @returns The property key corresponding to the command.
*/
function getCollectKeyFromCommand(command: string): string | null {
switch (command) {
@@ -152,44 +159,51 @@ function getCollectKeyFromCommand(command: string): string | null {
}
}
type MarkerRequest =
| AccountChannelsRequest
| AccountLinesRequest
| AccountObjectsRequest
| AccountOffersRequest
| AccountTxRequest
| LedgerDataRequest;
interface MarkerRequest extends BaseRequest {
limit?: number;
marker?: unknown;
}
type MarkerResponse =
| AccountChannelsResponse
| AccountLinesResponse
| AccountObjectsResponse
| AccountOffersResponse
| AccountTxResponse
| LedgerDataResponse;
interface MarkerResponse extends BaseResponse {
result: {
marker?: unknown;
};
}
const DEFAULT_FEE_CUSHION = 1.2;
const DEFAULT_MAX_FEE_XRP = "2";
const MIN_LIMIT = 10;
const MAX_LIMIT = 400;
class Client extends EventEmitter {
// Factor to multiply estimated fee by to provide a cushion in case the
// required fee rises during submission of a transaction. Defaults to 1.2.
_feeCushion: number;
// Maximum fee to use with transactions, in XRP. Must be a string-encoded
// number. Defaults to '2'.
_maxFeeXRP: string;
// New in > 0.21.0
// non-validated ledger versions are allowed, and passed to rippled as-is.
connection: Connection;
public readonly connection: Connection;
constructor(server: string, options: ClientOptions = {}) {
// Factor to multiply estimated fee by to provide a cushion in case the
// required fee rises during submission of a transaction. Defaults to 1.2.
public readonly feeCushion: number;
// Maximum fee to use with transactions, in XRP. Must be a string-encoded
// number. Defaults to '2'.
public readonly maxFeeXRP: string;
/**
* Creates a new Client with a websocket connection to a rippled server.
*
* @param server - URL of the server to connect to.
* @param options - Options for client settings.
*/
public constructor(server: string, options: ClientOptions = {}) {
super();
if (typeof server !== "string" || !/^(wss?|wss?\+unix):\/\//.exec(server)) {
if (typeof server !== "string" || !/wss?(?:\+unix)?:\/\//u.exec(server)) {
throw new ValidationError(
"server URI must start with `wss://`, `ws://`, `wss+unix://`, or `ws+unix://`."
);
}
this._feeCushion = options.feeCushion || 1.2;
this._maxFeeXRP = options.maxFeeXRP || "2";
this.feeCushion = options.feeCushion ?? DEFAULT_FEE_CUSHION;
this.maxFeeXRP = options.maxFeeXRP ?? DEFAULT_MAX_FEE_XRP;
this.connection = new Connection(server, options);
@@ -201,67 +215,17 @@ class Client extends EventEmitter {
this.emit("connected");
});
this.connection.on("disconnected", (code) => {
this.connection.on("disconnected", (code: number) => {
let finalCode = code;
// 4000: Connection uses a 4000 code internally to indicate a manual disconnect/close
// Since 4000 is a normal disconnect reason, we convert this to the standard exit code 1000
if (finalCode === 4000) {
if (finalCode === INTENTIONAL_DISCONNECT_CODE) {
finalCode = 1000;
}
this.emit("disconnected", finalCode);
});
}
/**
* Makes a request to the client with the given command and
* additional request body parameters.
*/
public request(r: AccountChannelsRequest): Promise<AccountChannelsResponse>;
public request(
r: AccountCurrenciesRequest
): Promise<AccountCurrenciesResponse>;
public request(r: AccountInfoRequest): Promise<AccountInfoResponse>;
public request(r: AccountLinesRequest): Promise<AccountLinesResponse>;
public request(r: AccountObjectsRequest): Promise<AccountObjectsResponse>;
public request(r: AccountOffersRequest): Promise<AccountOffersResponse>;
public request(r: AccountTxRequest): Promise<AccountTxResponse>;
public request(r: BookOffersRequest): Promise<BookOffersResponse>;
public request(r: ChannelVerifyRequest): Promise<ChannelVerifyResponse>;
public request(
r: DepositAuthorizedRequest
): Promise<DepositAuthorizedResponse>;
public request(r: FeeRequest): Promise<FeeResponse>;
public request(r: GatewayBalancesRequest): Promise<GatewayBalancesResponse>;
public request(r: LedgerRequest): Promise<LedgerResponse>;
public request(r: LedgerClosedRequest): Promise<LedgerClosedResponse>;
public request(r: LedgerCurrentRequest): Promise<LedgerCurrentResponse>;
public request(r: LedgerDataRequest): Promise<LedgerDataResponse>;
public request(r: LedgerEntryRequest): Promise<LedgerEntryResponse>;
public request(r: ManifestRequest): Promise<ManifestResponse>;
public request(r: NoRippleCheckRequest): Promise<NoRippleCheckResponse>;
public request(r: PathFindRequest): Promise<PathFindResponse>;
public request(r: PingRequest): Promise<PingResponse>;
public request(r: RandomRequest): Promise<RandomResponse>;
public request(r: RipplePathFindRequest): Promise<RipplePathFindResponse>;
public request(r: ServerInfoRequest): Promise<ServerInfoResponse>;
public request(r: ServerStateRequest): Promise<ServerStateResponse>;
public request(r: SubmitRequest): Promise<SubmitResponse>;
public request(
r: SubmitMultisignedRequest
): Promise<SubmitMultisignedResponse>;
public request(r: TransactionEntryRequest): Promise<TransactionEntryResponse>;
public request(r: TxRequest): Promise<TxResponse>;
public async request<R extends Request, T extends Response>(
r: R
): Promise<T> {
// TODO: should this be typed with `extends BaseRequest/BaseResponse`?
return this.connection.request({
...r,
// @ts-expect-error
account: r.account ? ensureClassicAddress(r.account) : undefined,
});
}
/**
* Returns true if there are more pages of data.
*
@@ -270,47 +234,121 @@ class Client extends EventEmitter {
*
* See https://ripple.com/build/rippled-apis/#markers-and-pagination.
*
* @param response
* @param response - Response to check for more pages on.
* @returns Whether the response has more pages of data.
*/
hasNextPage(response: MarkerResponse): boolean {
public static hasNextPage(response: MarkerResponse): boolean {
return Boolean(response.result.marker);
}
async requestNextPage(
public async request(
r: AccountChannelsRequest
): Promise<AccountChannelsResponse>;
public async request(
r: AccountCurrenciesRequest
): Promise<AccountCurrenciesResponse>;
public async request(r: AccountInfoRequest): Promise<AccountInfoResponse>;
public async request(r: AccountLinesRequest): Promise<AccountLinesResponse>;
public async request(
r: AccountObjectsRequest
): Promise<AccountObjectsResponse>;
public async request(r: AccountOffersRequest): Promise<AccountOffersResponse>;
public async request(r: AccountTxRequest): Promise<AccountTxResponse>;
public async request(r: BookOffersRequest): Promise<BookOffersResponse>;
public async request(r: ChannelVerifyRequest): Promise<ChannelVerifyResponse>;
public async request(
r: DepositAuthorizedRequest
): Promise<DepositAuthorizedResponse>;
public async request(r: FeeRequest): Promise<FeeResponse>;
public async request(
r: GatewayBalancesRequest
): Promise<GatewayBalancesResponse>;
public async request(r: LedgerRequest): Promise<LedgerResponse>;
public async request(r: LedgerClosedRequest): Promise<LedgerClosedResponse>;
public async request(r: LedgerCurrentRequest): Promise<LedgerCurrentResponse>;
public async request(r: LedgerDataRequest): Promise<LedgerDataResponse>;
public async request(r: LedgerEntryRequest): Promise<LedgerEntryResponse>;
public async request(r: ManifestRequest): Promise<ManifestResponse>;
public async request(r: NoRippleCheckRequest): Promise<NoRippleCheckResponse>;
public async request(r: PathFindRequest): Promise<PathFindResponse>;
public async request(r: PingRequest): Promise<PingResponse>;
public async request(r: RandomRequest): Promise<RandomResponse>;
public async request(
r: RipplePathFindRequest
): Promise<RipplePathFindResponse>;
public async request(r: ServerInfoRequest): Promise<ServerInfoResponse>;
public async request(r: ServerStateRequest): Promise<ServerStateResponse>;
public async request(r: SubmitRequest): Promise<SubmitResponse>;
public async request(
r: SubmitMultisignedRequest
): Promise<SubmitMultisignedResponse>;
public async request(
r: TransactionEntryRequest
): Promise<TransactionEntryResponse>;
public async request(r: TxRequest): Promise<TxResponse>;
/**
* Makes a request to the client with the given command and
* additional request body parameters.
*
* @param req - Request to send to the server.
* @returns The response from the server.
*/
public async request<R extends BaseRequest, T extends BaseResponse>(
req: R
): Promise<T> {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Necessary for overloading
return this.connection.request({
...req,
account: req.account
? // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Must be string
ensureClassicAddress(req.account as string)
: undefined,
}) as unknown as T;
}
public async requestNextPage(
req: AccountChannelsRequest,
resp: AccountChannelsResponse
): Promise<AccountChannelsResponse>;
async requestNextPage(
public async requestNextPage(
req: AccountLinesRequest,
resp: AccountLinesResponse
): Promise<AccountLinesResponse>;
async requestNextPage(
public async requestNextPage(
req: AccountObjectsRequest,
resp: AccountObjectsResponse
): Promise<AccountObjectsResponse>;
async requestNextPage(
public async requestNextPage(
req: AccountOffersRequest,
resp: AccountOffersResponse
): Promise<AccountOffersResponse>;
async requestNextPage(
public async requestNextPage(
req: AccountTxRequest,
resp: AccountTxResponse
): Promise<AccountTxResponse>;
async requestNextPage(
public async requestNextPage(
req: LedgerDataRequest,
resp: LedgerDataResponse
): Promise<LedgerDataResponse>;
async requestNextPage<T extends MarkerRequest, U extends MarkerResponse>(
req: T,
resp: U
): Promise<U> {
/**
* Requests the next page of data.
*
* @param req - Request to send.
* @param resp - Response with the marker to use in the request.
* @returns The response with the next page of data.
*/
public async requestNextPage<
T extends MarkerRequest,
U extends MarkerResponse
>(req: T, resp: U): Promise<U> {
if (!resp.result.marker) {
return Promise.reject(
new errors.NotFoundError("response does not have a next page")
);
}
const nextPageRequest = { ...req, marker: resp.result.marker };
return this.connection.request(nextPageRequest);
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Necessary for overloading
return this.connection.request(nextPageRequest) as unknown as U;
}
/**
@@ -318,27 +356,36 @@ class Client extends EventEmitter {
*
* You can later submit the transaction with a `submit` request.
*
* @param txJSON
* @param instructions
* @param txJSON - TODO: will be deleted.
* @param instructions - TODO: will be deleted.
* @returns TODO: will be deleted.
*/
async prepareTransaction(
public async prepareTransaction(
txJSON: TransactionJSON,
instructions: Instructions = {}
): Promise<Prepare> {
return transactionUtils.prepareTransaction(txJSON, this, instructions);
}
/**
* Convert a string to hex.
*
* This can be used to generate `MemoData`, `MemoType`, and `MemoFormat`.
*
* @param string - String to convert to hex.
*/
convertStringToHex(string: string): string {
return transactionUtils.convertStringToHex(string);
}
public async requestAll(
req: AccountChannelsRequest
): Promise<AccountChannelsResponse[]>;
public async requestAll(
req: AccountLinesRequest
): Promise<AccountLinesResponse[]>;
public async requestAll(
req: AccountObjectsRequest
): Promise<AccountObjectsResponse[]>;
public async requestAll(
req: AccountOffersRequest
): Promise<AccountOffersResponse[]>;
public async requestAll(req: AccountTxRequest): Promise<AccountTxResponse[]>;
public async requestAll(
req: BookOffersRequest
): Promise<BookOffersResponse[]>;
public async requestAll(
req: LedgerDataRequest
): Promise<LedgerDataResponse[]>;
/**
* Makes multiple paged requests to the client to return a given number of
* resources. Multiple paged requests will be made until the `limit`
@@ -351,53 +398,52 @@ class Client extends EventEmitter {
* NOTE: This command is used by existing methods and is not recommended for
* general use. Instead, use rippled's built-in pagination and make multiple
* requests as needed.
*
* @param request - The initial request to send to the server.
* @param collect - (Optional) the param to use to collect the array of resources (only needed if command is unknown).
* @returns The array of all responses.
* @throws ValidationError if there is no collection key (either from a known command or for the unknown command).
*/
async requestAll(
req: AccountChannelsRequest
): Promise<AccountChannelsResponse[]>;
async requestAll(req: AccountLinesRequest): Promise<AccountLinesResponse[]>;
async requestAll(
req: AccountObjectsRequest
): Promise<AccountObjectsResponse[]>;
async requestAll(req: AccountOffersRequest): Promise<AccountOffersResponse[]>;
async requestAll(req: AccountTxRequest): Promise<AccountTxResponse[]>;
async requestAll(req: BookOffersRequest): Promise<BookOffersResponse[]>;
async requestAll(req: LedgerDataRequest): Promise<LedgerDataResponse[]>;
async requestAll<T extends MarkerRequest, U extends MarkerResponse>(
public async requestAll<T extends MarkerRequest, U extends MarkerResponse>(
request: T,
options: { collect?: string } = {}
collect?: string
): Promise<U[]> {
// The data under collection is keyed based on the command. Fail if command
// not recognized and collection key not provided.
const collectKey =
options.collect || getCollectKeyFromCommand(request.command);
const collectKey = collect ?? getCollectKeyFromCommand(request.command);
if (!collectKey) {
throw new errors.ValidationError(
throw new ValidationError(
`no collect key for command ${request.command}`
);
}
// If limit is not provided, fetches all data over multiple requests.
// NOTE: This may return much more than needed. Set limit when possible.
const countTo: number = request.limit != null ? request.limit : Infinity;
const countTo: number = request.limit == null ? Infinity : request.limit;
let count = 0;
let marker = request.marker;
let marker: unknown = request.marker;
let lastBatchLength: number;
const results: any[] = [];
const results: U[] = [];
do {
const countRemaining = clamp(countTo - count, 10, 400);
const countRemaining = clamp(countTo - count, MIN_LIMIT, MAX_LIMIT);
const repeatProps = {
...request,
limit: countRemaining,
marker,
};
// eslint-disable-next-line no-await-in-loop -- Necessary for this, it really has to wait
const singleResponse = await this.connection.request(repeatProps);
const singleResult = singleResponse.result;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Should be true
const singleResult = (singleResponse as U).result;
if (!(collectKey in singleResult)) {
throw new RippledError(`${collectKey} not in result`);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- Should be true
const collectedData = singleResult[collectKey];
marker = singleResult.marker;
results.push(singleResponse);
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Should be true
results.push(singleResponse as U);
// Make sure we handle when no data (not even an empty array) is returned.
const isExpectedFormat = Array.isArray(collectedData);
if (isExpectedFormat) {
if (Array.isArray(collectedData)) {
count += collectedData.length;
lastBatchLength = collectedData.length;
} else {
@@ -407,78 +453,93 @@ class Client extends EventEmitter {
return results;
}
isConnected(): boolean {
return this.connection.isConnected();
}
async connect(): Promise<void> {
/**
* Tells the Client instance to connect to its rippled server.
*
* @returns A promise that resolves with a void value when a connection is established.
*/
public async connect(): Promise<void> {
return this.connection.connect();
}
async disconnect(): Promise<void> {
/**
* Tells the Client instance to disconnect from it's rippled server.
*
* @returns A promise that resolves with a void value when a connection is destroyed.
*/
public async disconnect(): Promise<void> {
// backwards compatibility: connection.disconnect() can return a number, but
// this method returns nothing. SO we await but don't return any result.
await this.connection.disconnect();
}
getFee = getFee;
/**
* Checks if the Client instance is connected to its rippled server.
*
* @returns Whether the client instance is connected.
*/
public isConnected(): boolean {
return this.connection.isConnected();
}
getTrustlines = getTrustlines;
getBalances = getBalances;
getPaths = getPaths;
getOrderbook = getOrderbook;
public getFee = getFee;
preparePayment = preparePayment;
prepareTrustline = prepareTrustline;
prepareOrder = prepareOrder;
prepareOrderCancellation = prepareOrderCancellation;
prepareEscrowCreation = prepareEscrowCreation;
prepareEscrowExecution = prepareEscrowExecution;
prepareEscrowCancellation = prepareEscrowCancellation;
preparePaymentChannelCreate = preparePaymentChannelCreate;
preparePaymentChannelFund = preparePaymentChannelFund;
preparePaymentChannelClaim = preparePaymentChannelClaim;
prepareCheckCreate = prepareCheckCreate;
prepareCheckCash = prepareCheckCash;
prepareCheckCancel = prepareCheckCancel;
prepareTicketCreate = prepareTicketCreate;
prepareSettings = prepareSettings;
sign = sign;
combine = combine;
public getTrustlines = getTrustlines;
public getBalances = getBalances;
public getPaths = getPaths;
public getOrderbook = getOrderbook;
generateFaucetWallet = generateFaucetWallet;
public preparePayment = preparePayment;
public prepareTrustline = prepareTrustline;
public prepareOrder = prepareOrder;
public prepareOrderCancellation = prepareOrderCancellation;
public prepareEscrowCreation = prepareEscrowCreation;
public prepareEscrowExecution = prepareEscrowExecution;
public prepareEscrowCancellation = prepareEscrowCancellation;
public preparePaymentChannelCreate = preparePaymentChannelCreate;
public preparePaymentChannelFund = preparePaymentChannelFund;
public preparePaymentChannelClaim = preparePaymentChannelClaim;
public prepareCheckCreate = prepareCheckCreate;
public prepareCheckCash = prepareCheckCash;
public prepareCheckCancel = prepareCheckCancel;
public prepareTicketCreate = prepareTicketCreate;
public prepareSettings = prepareSettings;
public sign = sign;
public combine = combine;
errors = errors;
public generateFaucetWallet = generateFaucetWallet;
static deriveXAddress = deriveXAddress;
public errors = errors;
public static deriveXAddress = deriveXAddress;
// Client.deriveClassicAddress (static) is a new name for client.deriveAddress
static deriveClassicAddress = deriveAddress;
public static deriveClassicAddress = deriveAddress;
static formatBidsAndAsks = formatBidsAndAsks;
public static formatBidsAndAsks = formatBidsAndAsks;
/**
* Static methods to expose ripple-address-codec methods.
*/
static classicAddressToXAddress = classicAddressToXAddress;
static xAddressToClassicAddress = xAddressToClassicAddress;
static isValidXAddress = isValidXAddress;
static isValidClassicAddress = isValidClassicAddress;
static encodeSeed = encodeSeed;
static decodeSeed = decodeSeed;
static encodeAccountID = encodeAccountID;
static decodeAccountID = decodeAccountID;
static encodeNodePublic = encodeNodePublic;
static decodeNodePublic = decodeNodePublic;
static encodeAccountPublic = encodeAccountPublic;
static decodeAccountPublic = decodeAccountPublic;
static encodeXAddress = encodeXAddress;
static decodeXAddress = decodeXAddress;
public static classicAddressToXAddress = classicAddressToXAddress;
public static xAddressToClassicAddress = xAddressToClassicAddress;
public static isValidXAddress = isValidXAddress;
public static isValidClassicAddress = isValidClassicAddress;
public static encodeSeed = encodeSeed;
public static decodeSeed = decodeSeed;
public static encodeAccountID = encodeAccountID;
public static decodeAccountID = decodeAccountID;
public static encodeNodePublic = encodeNodePublic;
public static decodeNodePublic = decodeNodePublic;
public static encodeAccountPublic = encodeAccountPublic;
public static decodeAccountPublic = decodeAccountPublic;
public static encodeXAddress = encodeXAddress;
public static decodeXAddress = decodeXAddress;
txFlags = txFlags;
static txFlags = txFlags;
accountSetFlags = constants.AccountSetFlags;
static accountSetFlags = constants.AccountSetFlags;
public txFlags = txFlags;
public static txFlags = txFlags;
public accountSetFlags = constants.AccountSetFlags;
public static accountSetFlags = constants.AccountSetFlags;
}
export { Client, Connection };

View File

@@ -1,64 +0,0 @@
import * as assert from "assert";
import * as _ from "lodash";
type Interval = [number, number];
function mergeIntervals(intervals: Interval[]): Interval[] {
const stack: Interval[] = [[-Infinity, -Infinity]];
_.sortBy(intervals, (x) => x[0]).forEach((interval) => {
const lastInterval: Interval = stack.pop()!;
if (interval[0] <= lastInterval[1] + 1) {
stack.push([lastInterval[0], Math.max(interval[1], lastInterval[1])]);
} else {
stack.push(lastInterval);
stack.push(interval);
}
});
return stack.slice(1);
}
class RangeSet {
ranges: Array<[number, number]> = [];
constructor() {
this.reset();
}
reset() {
this.ranges = [];
}
serialize() {
return this.ranges
.map((range) => `${range[0].toString()}-${range[1].toString()}`)
.join(",");
}
addRange(start: number, end: number) {
assert.ok(start <= end, `invalid range ${start} <= ${end}`);
this.ranges = mergeIntervals(this.ranges.concat([[start, end]]));
}
addValue(value: number) {
this.addRange(value, value);
}
parseAndAddRanges(rangesString: string) {
const rangeStrings = rangesString.split(",");
rangeStrings.forEach((rangeString) => {
const range = rangeString.split("-").map(Number);
this.addRange(range[0], range.length === 1 ? range[0] : range[1]);
});
}
containsRange(start: number, end: number) {
return this.ranges.some((range) => range[0] <= start && range[1] >= end);
}
containsValue(value: number) {
return this.containsRange(value, value);
}
}
export default RangeSet;

View File

@@ -0,0 +1,171 @@
import {
ResponseFormatError,
RippledError,
TimeoutError,
} from "../common/errors";
import { Response } from "../models/methods";
import { BaseRequest } from "../models/methods/baseMethod";
/**
* Manage all the requests made to the websocket, and their async responses
* that come in from the WebSocket. Responses come in over the WS connection
* after-the-fact, so this manager will tie that response to resolve the
* original request.
*/
export default class RequestManager {
private nextId = 0;
private readonly promisesAwaitingResponse = new Map<
string | number,
{
resolve: (value?: Response | PromiseLike<Response>) => void;
reject: (value?: Error) => void;
timer: NodeJS.Timeout;
}
>();
/**
* Cancels a request.
*
* @param id - ID of the request.
* @throws Error if no existing promise with the given ID.
*/
public cancel(id: string | number): void {
const promise = this.promisesAwaitingResponse.get(id);
if (promise == null) {
throw new Error(`No existing promise with id ${id}`);
}
clearTimeout(promise.timer);
this.deletePromise(id);
}
/**
* Successfully resolves a request.
*
* @param id - ID of the request.
* @param response - Response to return.
* @throws Error if no existing promise with the given ID.
*/
public resolve(id: string | number, response: Response): void {
const promise = this.promisesAwaitingResponse.get(id);
if (promise == null) {
throw new Error(`No existing promise with id ${id}`);
}
clearTimeout(promise.timer);
promise.resolve(response);
this.deletePromise(id);
}
/**
* Rejects a request.
*
* @param id - ID of the request.
* @param error - Error to throw with the reject.
* @throws Error if no existing promise with the given ID.
*/
public reject(id: string | number, error: Error): void {
const promise = this.promisesAwaitingResponse.get(id);
if (promise == null) {
throw new Error(`No existing promise with id ${id}`);
}
clearTimeout(promise.timer);
promise.reject(error);
this.deletePromise(id);
}
/**
* Reject all pending requests.
*
* @param error - Error to throw with the reject.
*/
public rejectAll(error: Error): void {
this.promisesAwaitingResponse.forEach((_promise, id, _map) => {
this.reject(id, error);
});
}
/**
* Creates a new WebSocket request. This sets up a timeout timer to catch
* hung responses, and a promise that will resolve with the response once
* the response is seen & handled.
*
* @param request - Request to create.
* @param timeout - Timeout length to catch hung responses.
* @returns Request ID, new request form, and the promise for resolving the request.
*/
public createRequest<T extends BaseRequest>(
request: T,
timeout: number
): [string | number, string, Promise<Response>] {
const newId = request.id ? request.id : this.nextId;
this.nextId += 1;
const newRequest = JSON.stringify({ ...request, id: newId });
const timer = setTimeout(
() => this.reject(newId, new TimeoutError()),
timeout
);
// Node.js won't exit if a timer is still running, so we tell Node to ignore.
// (Node will still wait for the request to complete).
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Reason above.
if (timer.unref) {
timer.unref();
}
const newPromise = new Promise(
(resolve: (value?: Response | PromiseLike<Response>) => void, reject) => {
this.promisesAwaitingResponse.set(newId, { resolve, reject, timer });
}
);
return [newId, newRequest, newPromise];
}
/**
* Handle a "response". Responses match to the earlier request handlers,
* and resolve/reject based on the data received.
*
* @param response - The response to handle.
* @throws ResponseFormatError if the response format is invalid, RippledError if rippled returns an error.
*/
public handleResponse(response: Partial<Response>): void {
if (
response.id == null ||
!Number.isInteger(response.id) ||
response.id < 0
) {
throw new ResponseFormatError("valid id not found in response", response);
}
if (!this.promisesAwaitingResponse.has(response.id)) {
return;
}
if (response.status == null) {
const error = new ResponseFormatError("Response has no status");
this.reject(response.id, error);
}
if (response.status === "error") {
const error = new RippledError(
response.error_message ?? response.error,
response
);
this.reject(response.id, error);
return;
}
if (response.status !== "success") {
const error = new ResponseFormatError(
`unrecognized response.status: ${response.status ?? ""}`,
response
);
this.reject(response.id, error);
return;
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Must be a valid Response here
this.resolve(response.id, response as unknown as Response);
}
/**
* Delete a promise after it has been returned.
*
* @param id - ID of the request.
*/
private deletePromise(id: string | number): void {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Needs to delete promise after request has been fulfilled.
delete this.promisesAwaitingResponse[id];
}
}

View File

@@ -1,15 +1,27 @@
/* eslint-disable import/no-unused-modules -- This is used by webpack */
/* eslint-disable max-classes-per-file -- Needs to be a wrapper for ws */
import { EventEmitter } from "events";
// Define the global WebSocket class found on the native browser
declare class WebSocket {
onclose?: Function;
onopen?: Function;
onerror?: Function;
onmessage?: Function;
readyState: number;
constructor(url: string);
close();
send(message: string);
public onclose?: () => void;
public onopen?: () => void;
public onerror?: (error: Error) => void;
public onmessage?: (message: MessageEvent) => void;
public readyState: number;
public constructor(url: string);
public close(code?: number): void;
public send(message: string): void;
}
interface WSWrapperOptions {
perMessageDeflate: boolean;
handshakeTimeout: number;
protocolVersion: number;
origin: string;
maxPayload: number;
followRedirects: boolean;
maxRedirects: number;
}
/**
@@ -17,46 +29,71 @@ declare class WebSocket {
* same, as `ws` package provides.
*/
export default class WSWrapper extends EventEmitter {
private readonly _ws: WebSocket;
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
public static CONNECTING = 0;
public static OPEN = 1;
public static CLOSING = 2;
// eslint-disable-next-line @typescript-eslint/no-magic-numbers -- magic number is being defined here
public static CLOSED = 3;
private readonly ws: WebSocket;
constructor(url, _protocols: any, _websocketOptions: any) {
/**
* Constructs a browser-safe websocket.
*
* @param url - URL to connect to.
* @param _protocols - Not used.
* @param _websocketOptions - Not used.
*/
public constructor(
url: string,
_protocols: string | string[] | WSWrapperOptions | undefined,
_websocketOptions: WSWrapperOptions
) {
super();
this.setMaxListeners(Infinity);
this._ws = new WebSocket(url);
this.ws = new WebSocket(url);
this._ws.onclose = () => {
this.ws.onclose = (): void => {
this.emit("close");
};
this._ws.onopen = () => {
this.ws.onopen = (): void => {
this.emit("open");
};
this._ws.onerror = (error) => {
this.ws.onerror = (error): void => {
this.emit("error", error);
};
this._ws.onmessage = (message) => {
this.ws.onmessage = (message: MessageEvent): void => {
this.emit("message", message.data);
};
}
close() {
/**
* Closes the websocket.
*/
public close(): void {
if (this.readyState === 1) {
this._ws.close();
this.ws.close();
}
}
send(message) {
this._ws.send(message);
/**
* Sends a message over the Websocket connection.
*
* @param message - Message to send.
*/
public send(message: string): void {
this.ws.send(message);
}
get readyState() {
return this._ws.readyState;
/**
* Get the ready state of the websocket.
*
* @returns The Websocket's ready state.
*/
public get readyState(): number {
return this.ws.readyState;
}
}

View File

@@ -1,13 +1,13 @@
import BigNumber from "bignumber.js";
import _ from "lodash";
import { Client } from "..";
import type { Client } from "..";
// This is a public API that can be called directly.
// This is not used by the `prepare*` methods. See `src/transaction/utils.ts`
async function getFee(this: Client, cushion?: number): Promise<string> {
if (cushion == null) {
cushion = this._feeCushion;
cushion = this.feeCushion;
}
if (cushion == null) {
cushion = 1.2;
@@ -29,8 +29,8 @@ async function getFee(this: Client, cushion?: number): Promise<string> {
}
let fee = baseFeeXrp.times(serverInfo.load_factor).times(cushion);
// Cap fee to `this._maxFeeXRP`
fee = BigNumber.min(fee, this._maxFeeXRP);
// Cap fee to `this.maxFeeXRP`
fee = BigNumber.min(fee, this.maxFeeXRP);
// Round fee to 6 decimal places
return new BigNumber(fee.toFixed(6)).toString(10);
}

View File

@@ -1,3 +1,6 @@
// Broadcast client is experimental
import BroadcastClient from "./client/broadcastClient";
export { Client } from "./client";
export * from "./transaction/types";
@@ -8,7 +11,6 @@ export * from "./models/methods";
export * from "./utils";
// Broadcast client is experimental
export { BroadcastClient } from "./client/broadcastClient";
export { BroadcastClient };
export { default as Wallet } from "./wallet";

View File

@@ -1,5 +1,4 @@
import { Client } from "..";
import { Connection } from "../client";
import type { Client } from "..";
import { ensureClassicAddress } from "../common";
import { FormattedTrustline } from "../common/types/objects/trustlines";
@@ -44,13 +43,13 @@ function formatBalances(
}
async function getLedgerVersionHelper(
connection: Connection,
client: Client,
optionValue?: number
): Promise<number> {
if (optionValue != null && optionValue !== null) {
return Promise.resolve(optionValue);
}
return connection
return client
.request({
command: "ledger",
ledger_index: "validated",
@@ -71,7 +70,7 @@ async function getBalances(
address = ensureClassicAddress(address);
return Promise.all([
getLedgerVersionHelper(this.connection, options.ledgerVersion).then(
getLedgerVersionHelper(this, options.ledgerVersion).then(
async (ledgerVersion) => utils.getXRPBalance(this, address, ledgerVersion)
),
this.getTrustlines(address, options),

View File

@@ -2,7 +2,7 @@ import BigNumber from "bignumber.js";
import _ from "lodash";
import type { Client } from "..";
import { Connection } from "../client";
import type { Connection } from "../client";
import { errors } from "../common";
import { RippledAmount, Amount } from "../common/types/objects";
import { RipplePathFindRequest } from "../models/methods";

View File

@@ -2,12 +2,13 @@ import * as assert from "assert";
import _ from "lodash";
import { Client, dropsToXrp } from "..";
import { Connection } from "../client";
import type { Client } from "..";
import type { Connection } from "../client";
import * as common from "../common";
import { Issue } from "../common/types/objects";
import { AccountInfoRequest } from "../models/methods";
import { FormattedTransactionType } from "../transaction/types";
import { dropsToXrp } from "../utils";
export interface RecursiveData {
marker: string;

View File

@@ -1,4 +1,5 @@
export interface BaseRequest {
[x: string]: unknown;
id?: number | string;
command: string;
api_version?: number;

View File

@@ -1,4 +1,4 @@
import { Client } from "..";
import type { Client } from "..";
import { Instructions, Prepare, TransactionJSON } from "./types";
import { prepareTransaction } from "./utils";

View File

@@ -1,4 +1,4 @@
import { Client } from "..";
import type { Client } from "..";
import { ValidationError } from "../common/errors";
import { Amount } from "../common/types/objects";
import { toRippledAmount } from "../utils";

View File

@@ -1,4 +1,4 @@
import { Client } from "..";
import type { Client } from "..";
import { Amount } from "../common/types/objects";
import { ISOTimeToRippleTime, toRippledAmount } from "../utils";

View File

@@ -1,4 +1,4 @@
import { Client } from "..";
import type { Client } from "..";
import { Memo } from "../common/types/objects";
import { Instructions, Prepare, TransactionJSON } from "./types";

View File

@@ -1,4 +1,4 @@
import { Client } from "..";
import type { Client } from "..";
import { Memo } from "../common/types/objects";
import { ISOTimeToRippleTime, xrpToDrops } from "../utils";

View File

@@ -1,4 +1,4 @@
import { Client } from "..";
import type { Client } from "..";
import { Memo } from "../common/types/objects";
import { Instructions, Prepare, TransactionJSON } from "./types";

View File

@@ -1,4 +1,4 @@
import { Client } from "..";
import type { Client } from "..";
import { FormattedOrderSpecification } from "../common/types/objects/index";
import { ISOTimeToRippleTime, toRippledAmount } from "../utils";

View File

@@ -1,4 +1,4 @@
import { Client } from "..";
import type { Client } from "..";
import { Instructions, Prepare, TransactionJSON } from "./types";
import * as utils from "./utils";

View File

@@ -1,4 +1,4 @@
import { Client } from "..";
import type { Client } from "..";
import { xrpToDrops } from "../utils";
import { Instructions, Prepare, TransactionJSON } from "./types";

View File

@@ -1,4 +1,4 @@
import { Client } from "..";
import type { Client } from "..";
import { ISOTimeToRippleTime, xrpToDrops } from "../utils";
import { Instructions, Prepare, TransactionJSON } from "./types";

View File

@@ -1,4 +1,4 @@
import { Client } from "..";
import type { Client } from "..";
import { ISOTimeToRippleTime, xrpToDrops } from "../utils";
import { Instructions, Prepare, TransactionJSON } from "./types";

View File

@@ -2,7 +2,7 @@ import * as assert from "assert";
import BigNumber from "bignumber.js";
import { Client } from "..";
import type { Client } from "..";
import { FormattedSettings, WeightedSigner } from "../common/types/objects";
import {

View File

@@ -3,7 +3,7 @@ import _ from "lodash";
import binaryCodec from "ripple-binary-codec";
import keypairs from "ripple-keypairs";
import { Client, Wallet } from "..";
import type { Client, Wallet } from "..";
import { SignedTransaction } from "../common/types/objects";
import { xrpToDrops } from "../utils";
import { computeBinaryTransactionHash } from "../utils/hashes";
@@ -206,7 +206,7 @@ function checkTxSerialization(serialized: string, tx: TransactionJSON): void {
*/
function checkFee(client: Client, txFee: string): void {
const fee = new BigNumber(txFee);
const maxFeeDrops = xrpToDrops(client._maxFeeXRP);
const maxFeeDrops = xrpToDrops(client.maxFeeXRP);
if (fee.isGreaterThan(maxFeeDrops)) {
throw new utils.common.errors.ValidationError(
`"Fee" should not exceed "${maxFeeDrops}". ` +

View File

@@ -1,6 +1,6 @@
import BigNumber from "bignumber.js";
import { Client } from "..";
import type { Client } from "..";
import { FormattedTrustlineSpecification } from "../common/types/objects/trustlines";
import { Instructions, Prepare, TransactionJSON } from "./types";

View File

@@ -4,7 +4,7 @@ import {
isValidXAddress,
} from "ripple-address-codec";
import { Client } from "..";
import type { Client } from "..";
import * as common from "../common";
import { ValidationError } from "../common/errors";
import { Memo } from "../common/types/objects";
@@ -31,6 +31,8 @@ export interface ApiMemo {
MemoFormat?: string;
}
// TODO: move relevant methods from here to `src/utils` (such as `convertStringToHex`?)
function formatPrepareResponse(txJSON: any): Prepare {
const instructions: any = {
fee: dropsToXrp(txJSON.Fee),
@@ -307,11 +309,11 @@ async function prepareTransaction(
instructions.signersCount == null ? 1 : instructions.signersCount + 1;
if (instructions.fee != null) {
const fee = new BigNumber(instructions.fee);
if (fee.isGreaterThan(client._maxFeeXRP)) {
if (fee.isGreaterThan(client.maxFeeXRP)) {
return Promise.reject(
new ValidationError(
`Fee of ${fee.toString(10)} XRP exceeds ` +
`max of ${client._maxFeeXRP} XRP. To use this fee, increase ` +
`max of ${client.maxFeeXRP} XRP. To use this fee, increase ` +
"`maxFeeXRP` in the Client constructor."
)
);
@@ -319,7 +321,7 @@ async function prepareTransaction(
newTxJSON.Fee = scaleValue(xrpToDrops(instructions.fee), multiplier);
return Promise.resolve();
}
const cushion = client._feeCushion;
const cushion = client.feeCushion;
return client.getFee(cushion).then(async (fee) => {
return client
.request({ command: "fee" })
@@ -338,8 +340,8 @@ async function prepareTransaction(
));
const feeDrops = xrpToDrops(fee);
const maxFeeXRP = instructions.maxFee
? BigNumber.min(client._maxFeeXRP, instructions.maxFee)
: client._maxFeeXRP;
? BigNumber.min(client.maxFeeXRP, instructions.maxFee)
: client.maxFeeXRP;
const maxFeeDrops = xrpToDrops(maxFeeXRP);
const normalFee = scaleValue(feeDrops, multiplier, extraFee);
newTxJSON.Fee = BigNumber.min(normalFee, maxFeeDrops).toString(10);

View File

@@ -2,11 +2,13 @@ import https = require("https");
import { isValidClassicAddress } from "ripple-address-codec";
import { Client, Wallet } from "..";
import type { Client } from "..";
import { errors } from "../common";
import { RippledError } from "../common/errors";
import { GeneratedAddress } from "../utils/generateAddress";
import Wallet from ".";
export interface FaucetWallet {
account: GeneratedAddress;
amount: number;
@@ -21,14 +23,14 @@ export enum FaucetNetwork {
const INTERVAL_SECONDS = 1; // Interval to check an account balance
const MAX_ATTEMPTS = 20; // Maximum attempts to retrieve a balance
/**
* Generates a random wallet with some amount of XRP (usually 1000 XRP).
*
* @param client - Client.
* @param wallet - An existing XRPL Wallet to fund, if undefined, a new Wallet will be created.
* @returns A Wallet on the Testnet or Devnet that contains some amount of XRP.
* @throws When either Client isn't connected or unable to fund wallet address.
*/
//
// Generates a random wallet with some amount of XRP (usually 1000 XRP).
//
// @param client - Client.
// @param wallet - An existing XRPL Wallet to fund, if undefined, a new Wallet will be created.
// @returns A Wallet on the Testnet or Devnet that contains some amount of XRP.
// @throws When either Client isn't connected or unable to fund wallet address.
// z
async function generateFaucetWallet(
client: Client,
wallet?: Wallet

View File

@@ -1,6 +1,6 @@
import { assert } from "chai";
import { ExponentialBackoff } from "../src/client/backoff";
import ExponentialBackoff from "../src/client/backoff";
describe("ExponentialBackoff", function () {
it("duration() return value starts with the min value", function () {

View File

@@ -42,7 +42,7 @@ describe("BroadcastClient", function () {
assert.strictEqual(info, "info");
done();
});
this.client._clients[1].connection
this.client.clients[1].connection
.request({
command: "echo",
data,

View File

@@ -33,7 +33,7 @@ describe("Client", function () {
it("Client valid options", function () {
const client = new Client("wss://s:1");
const privateConnectionUrl = (client.connection as any)._url;
const privateConnectionUrl = (client.connection as any).url;
assert.deepEqual(privateConnectionUrl, "wss://s:1");
});

View File

@@ -14,7 +14,7 @@ describe("client constructor", function () {
it("Client valid options", function () {
const client = new Client("wss://s:1");
const privateConnectionUrl = (client.connection as any)._url;
const privateConnectionUrl = (client.connection as any).url;
assert.deepEqual(privateConnectionUrl, "wss://s:1");
});

View File

@@ -18,7 +18,7 @@ describe("client.getFee", function () {
it("getFee default", async function () {
this.mockRippled.addResponse("server_info", rippled.server_info.normal);
this.client._feeCushion = undefined as unknown as number;
this.client.feeCushion = undefined as unknown as number;
const fee = await this.client.getFee();
assert.strictEqual(fee, "0.000012");
});
@@ -39,14 +39,14 @@ describe("client.getFee", function () {
);
// Ensure that overriding with high maxFeeXRP of '51540' causes no errors.
// (fee will actually be 51539.607552)
this.client._maxFeeXRP = "51540";
this.client.maxFeeXRP = "51540";
const fee = await this.client.getFee();
assert.strictEqual(fee, "51539.607552");
});
it("getFee custom cushion", async function () {
this.mockRippled.addResponse("server_info", rippled.server_info.normal);
this.client._feeCushion = 1.4;
this.client.feeCushion = 1.4;
const fee = await this.client.getFee();
assert.strictEqual(fee, "0.000014");
});
@@ -55,7 +55,7 @@ describe("client.getFee", function () {
// less than the base fee. However, this test verifies the existing behavior.
it("getFee cushion less than 1.0", async function () {
this.mockRippled.addResponse("server_info", rippled.server_info.normal);
this.client._feeCushion = 0.9;
this.client.feeCushion = 0.9;
const fee = await this.client.getFee();
assert.strictEqual(fee, "0.000009");
});

View File

@@ -1,5 +1,6 @@
import { assert } from "chai";
import { Client } from "../../src";
import rippled from "../fixtures/rippled";
import setupClient from "../setupClient";
@@ -10,7 +11,7 @@ describe("client.hasNextPage", function () {
it("returns true when there is another page", async function () {
this.mockRippled.addResponse("ledger_data", rippled.ledger_data.first_page);
const response = await this.client.request({ command: "ledger_data" });
assert(this.client.hasNextPage(response));
assert(Client.hasNextPage(response));
});
it("returns false when there are no more pages", async function () {
@@ -26,6 +27,6 @@ describe("client.hasNextPage", function () {
{ command: "ledger_data" },
response
);
assert(!this.client.hasNextPage(responseNextPage));
assert(!Client.hasNextPage(responseNextPage));
});
});

View File

@@ -460,7 +460,7 @@ describe("client.preparePayment", function () {
"account_info",
rippled.account_info.normal
);
this.client._feeCushion = 1000000;
this.client.feeCushion = 1000000;
const expectedResponse = {
txJSON:
'{"Flags":2147483648,"TransactionType":"Payment","Account":"r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59","Destination":"rpZc4mVfWUif9CRoHRKKcmhu1nx2xktxBo","Amount":{"value":"0.01","currency":"USD","issuer":"rMH4UxPrbuMa1spCBR98hLLyNJp4d8p4tM"},"SendMax":{"value":"0.01","currency":"USD","issuer":"rMH4UxPrbuMa1spCBR98hLLyNJp4d8p4tM"},"LastLedgerSequence":8820051,"Fee":"2000000","Sequence":23}',
@@ -486,7 +486,7 @@ describe("client.preparePayment", function () {
"account_info",
rippled.account_info.normal
);
this.client._maxFeeXRP = "2.2";
this.client.maxFeeXRP = "2.2";
const localInstructions = {
...instructionsWithMaxLedgerVersionOffset,
fee: "2.1",
@@ -516,7 +516,7 @@ describe("client.preparePayment", function () {
"account_info",
rippled.account_info.normal
);
this.client._feeCushion = 1000000;
this.client.feeCushion = 1000000;
const expectedResponse = {
txJSON:
'{"Flags":2147483648,"TransactionType":"Payment","Account":"r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59","Destination":"rpZc4mVfWUif9CRoHRKKcmhu1nx2xktxBo","Amount":{"value":"0.01","currency":"USD","issuer":"rMH4UxPrbuMa1spCBR98hLLyNJp4d8p4tM"},"SendMax":{"value":"0.01","currency":"USD","issuer":"rMH4UxPrbuMa1spCBR98hLLyNJp4d8p4tM"},"LastLedgerSequence":8820051,"Fee":"2000000","Sequence":23}',
@@ -542,8 +542,8 @@ describe("client.preparePayment", function () {
"account_info",
rippled.account_info.normal
);
this.client._feeCushion = 1000000;
this.client._maxFeeXRP = "3";
this.client.feeCushion = 1000000;
this.client.maxFeeXRP = "3";
const localInstructions = {
...instructionsWithMaxLedgerVersionOffset,
maxFee: "4",
@@ -573,8 +573,8 @@ describe("client.preparePayment", function () {
"account_info",
rippled.account_info.normal
);
this.client._feeCushion = 1000000;
this.client._maxFeeXRP = "5";
this.client.feeCushion = 1000000;
this.client.maxFeeXRP = "5";
const localInstructions = {
...instructionsWithMaxLedgerVersionOffset,
maxFee: "4",

View File

@@ -1005,7 +1005,7 @@ describe("client.prepareTransaction", function () {
this.mockRippled.addResponse("fee", rippled.fee);
this.mockRippled.addResponse("ledger_current", rippled.ledger_current);
this.mockRippled.addResponse("account_info", rippled.account_info.normal);
this.client._feeCushion = 1000000;
this.client.feeCushion = 1000000;
const txJSON = {
Flags: 2147483648,
@@ -1046,7 +1046,7 @@ describe("client.prepareTransaction", function () {
this.mockRippled.addResponse("fee", rippled.fee);
this.mockRippled.addResponse("ledger_current", rippled.ledger_current);
this.mockRippled.addResponse("account_info", rippled.account_info.normal);
this.client._feeCushion = 1000000;
this.client.feeCushion = 1000000;
const txJSON = {
Flags: 2147483648,
@@ -1092,8 +1092,8 @@ describe("client.prepareTransaction", function () {
this.mockRippled.addResponse("fee", rippled.fee);
this.mockRippled.addResponse("ledger_current", rippled.ledger_current);
this.mockRippled.addResponse("account_info", rippled.account_info.normal);
this.client._feeCushion = 1000000;
this.client._maxFeeXRP = "3";
this.client.feeCushion = 1000000;
this.client.maxFeeXRP = "3";
const localInstructions = {
maxFee: "4", // We are testing that this does not matter; fee is still capped to maxFeeXRP
};
@@ -1139,8 +1139,8 @@ describe("client.prepareTransaction", function () {
this.mockRippled.addResponse("fee", rippled.fee);
this.mockRippled.addResponse("ledger_current", rippled.ledger_current);
this.mockRippled.addResponse("account_info", rippled.account_info.normal);
this.client._feeCushion = 1000000;
this.client._maxFeeXRP = "5";
this.client.feeCushion = 1000000;
this.client.maxFeeXRP = "5";
const localInstructions = {
maxFee: "4", // maxFeeXRP does not matter if maxFee is lower than maxFeeXRP
};

View File

@@ -1,5 +1,6 @@
import { assert } from "chai";
import { Client } from "../../src";
import rippled from "../fixtures/rippled";
import setupClient from "../setupClient";
import { assertRejects } from "../testUtils";
@@ -34,7 +35,7 @@ describe("client.requestNextPage", function () {
{ command: "ledger_data" },
response
);
assert(!this.client.hasNextPage(responseNextPage));
assert(!Client.hasNextPage(responseNextPage));
await assertRejects(
this.client.requestNextPage({ command: "ledger_data" }, responseNextPage),
Error,

View File

@@ -199,7 +199,7 @@ describe("client.sign", function () {
});
it("permits fee exceeding 2000000 drops when maxFeeXRP is higher than 2 XRP", async function () {
this.client._maxFeeXRP = "2.1";
this.client.maxFeeXRP = "2.1";
const secret = "shsWGZcmZz6YsWWmcnpfr6fLTdtFV";
const request = {
// TODO: This fails when address is X-address
@@ -247,7 +247,7 @@ describe("client.sign", function () {
});
it("throws when Fee exceeds maxFeeXRP (in drops) - custom maxFeeXRP", async function () {
this.client._maxFeeXRP = "1.9";
this.client.maxFeeXRP = "1.9";
const secret = "shsWGZcmZz6YsWWmcnpfr6fLTdtFV";
const request = {
txJSON: `{"Flags":2147483648,"TransactionType":"AccountSet","Account":"${test.address}","Domain":"6578616D706C652E636F6D","LastLedgerSequence":8820051,"Fee":"2010000","Sequence":23,"SigningPubKey":"02F89EAEC7667B30F33D0687BBA86C3FE2A08CCA40A9186C5BDE2DAA6FA97A37D8"}`,

View File

@@ -42,9 +42,9 @@ describe("Connection", function () {
it("default options", function () {
const connection: any = new Connection("url");
assert.strictEqual(connection._url, "url");
assert(connection._config.proxy == null);
assert(connection._config.authorization == null);
assert.strictEqual(connection.url, "url");
assert(connection.config.proxy == null);
assert(connection.config.authorization == null);
});
describe("trace", function () {
@@ -65,9 +65,9 @@ describe("Connection", function () {
const messages: any[] = [];
console.log = (id, message) => messages.push([id, message]);
const connection: any = new Connection("url", { trace: false });
connection._ws = { send() {} };
connection.ws = { send() {} };
connection.request(mockedRequestData);
connection._onMessage(mockedResponse);
connection.onMessage(mockedResponse);
assert.deepEqual(messages, []);
});
@@ -75,9 +75,9 @@ describe("Connection", function () {
const messages: any[] = [];
console.log = (id, message) => messages.push([id, message]);
const connection: any = new Connection("url", { trace: true });
connection._ws = { send() {} };
connection.ws = { send() {} };
connection.request(mockedRequestData);
connection._onMessage(mockedResponse);
connection.onMessage(mockedResponse);
assert.deepEqual(messages, expectedMessages);
});
@@ -86,9 +86,9 @@ describe("Connection", function () {
const connection: any = new Connection("url", {
trace: (id, message) => messages.push([id, message]),
});
connection._ws = { send() {} };
connection.ws = { send() {} };
connection.request(mockedRequestData);
connection._onMessage(mockedResponse);
connection.onMessage(mockedResponse);
assert.deepEqual(messages, expectedMessages);
});
});
@@ -116,7 +116,7 @@ describe("Connection", function () {
authorization: "authorization",
trustedCertificates: ["path/to/pem"],
};
const connection = new Connection(this.client.connection._url, options);
const connection = new Connection(this.client.connection.url, options);
connection.connect().catch((err) => {
assert(err instanceof NotConnectedError);
});
@@ -177,7 +177,7 @@ describe("Connection", function () {
});
it("TimeoutError", function () {
this.client.connection._ws.send = function (_, callback) {
this.client.connection.ws.send = function (_, callback) {
callback(null);
};
const request = { command: "server_info" };
@@ -192,7 +192,7 @@ describe("Connection", function () {
});
it("DisconnectedError on send", function () {
this.client.connection._ws.send = function (_, callback) {
this.client.connection.ws.send = function (_, callback) {
callback({ message: "not connected" });
};
return this.client
@@ -206,15 +206,15 @@ describe("Connection", function () {
});
});
it("DisconnectedError on initial _onOpen send", async function () {
it("DisconnectedError on initial onOpen send", async function () {
// _onOpen previously could throw PromiseRejectionHandledWarning: Promise rejection was handled asynchronously
// do not rely on the client.setup hook to test this as it bypasses the case, disconnect client connection first
await this.client.disconnect();
// stub _onOpen to only run logic relevant to test case
this.client.connection._onOpen = () => {
this.client.connection.onOpen = () => {
// overload websocket send on open when _ws exists
this.client.connection._ws.send = function (_0, _1, _2) {
this.client.connection.ws.send = function (_0, _1, _2) {
// recent ws throws this error instead of calling back
throw new Error("WebSocket is not open: readyState 0 (CONNECTING)");
};
@@ -225,7 +225,8 @@ describe("Connection", function () {
try {
await this.client.connect();
} catch (error) {
assert(error instanceof DisconnectedError);
console.log(error);
assert.instanceOf(error, DisconnectedError);
assert.strictEqual(
error.message,
"WebSocket is not open: readyState 0 (CONNECTING)"
@@ -252,7 +253,7 @@ describe("Connection", function () {
done();
});
setTimeout(() => {
this.client.connection._ws.close();
this.client.connection.ws.close();
}, 1);
});
@@ -329,13 +330,13 @@ describe("Connection", function () {
}
}
// Set the heartbeat to less than the 1 second ping response
this.client.connection._config.timeout = 500;
this.client.connection.config.timeout = 500;
// Drop the test runner timeout, since this should be a quick test
this.timeout(5000);
// Hook up a listener for the reconnect event
this.client.connection.on("reconnect", () => done());
// Trigger a heartbeat
this.client.connection._heartbeat().catch((error) => {
this.client.connection.heartbeat().catch((error) => {
/* ignore - test expects heartbeat failure */
});
});
@@ -349,7 +350,7 @@ describe("Connection", function () {
}
}
// Set the heartbeat to less than the 1 second ping response
this.client.connection._config.timeout = 500;
this.client.connection.config.timeout = 500;
// Drop the test runner timeout, since this should be a quick test
this.timeout(5000);
// fail on reconnect/connection
@@ -364,7 +365,7 @@ describe("Connection", function () {
return done(new Error("Expected error on reconnect"));
});
// Trigger a heartbeat
this.client.connection._heartbeat();
this.client.connection.heartbeat();
});
it("should emit disconnected event with code 1000 (CLOSE_NORMAL)", function (done) {
@@ -393,7 +394,7 @@ describe("Connection", function () {
it("should emit connected event on after reconnect", function (done) {
this.client.once("connected", done);
this.client.connection._ws.close();
this.client.connection.ws.close();
});
it("Multiply connect calls", function () {
@@ -450,17 +451,17 @@ describe("Connection", function () {
done();
});
this.client.connection._onMessage(
this.client.connection.onMessage(
JSON.stringify({
type: "transaction",
})
);
this.client.connection._onMessage(
this.client.connection.onMessage(
JSON.stringify({
type: "path_find",
})
);
this.client.connection._onMessage(
this.client.connection.onMessage(
JSON.stringify({
type: "response",
id: 1,
@@ -475,7 +476,7 @@ describe("Connection", function () {
assert.strictEqual(message, '{"type":"response","id":"must be integer"}');
done();
});
this.client.connection._onMessage(
this.client.connection.onMessage(
JSON.stringify({
type: "response",
id: "must be integer",
@@ -490,7 +491,7 @@ describe("Connection", function () {
assert.deepEqual(data, { error: "slowDown", error_message: "slow down" });
done();
});
this.client.connection._onMessage(
this.client.connection.onMessage(
JSON.stringify({
error: "slowDown",
error_message: "slow down",
@@ -525,13 +526,13 @@ describe("Connection", function () {
done();
});
this.client.connection._onMessage(JSON.stringify({ type: "unknown" }));
this.client.connection.onMessage(JSON.stringify({ type: "unknown" }));
});
// it('should clean up websocket connection if error after websocket is opened', async function () {
// await this.client.disconnect()
// // fail on connection
// this.client.connection._subscribeToLedger = async () => {
// this.client.connection.subscribeToLedger = async () => {
// throw new Error('error on _subscribeToLedger')
// }
// try {
@@ -541,7 +542,7 @@ describe("Connection", function () {
// assert(err.message === 'error on _subscribeToLedger')
// // _ws.close event listener should have cleaned up the socket when disconnect _ws.close is run on connection error
// // do not fail on connection anymore
// this.client.connection._subscribeToLedger = async () => {}
// this.client.connection.subscribeToLedger = async () => {}
// await this.client.connection.reconnect()
// }
// })

View File

@@ -24,8 +24,6 @@
<script src="../testCompiledForWeb/connection.js"></script>
<script src="../testCompiledForWeb/rangeSet.js"></script>
<script>
mocha.run()
</script>

View File

@@ -24,8 +24,6 @@
<script src="../testCompiledForWeb/connection.js"></script>
<script src="../testCompiledForWeb/rangeSet.js"></script>
<script>
mocha.run()
</script>

View File

@@ -3,6 +3,7 @@ import _ from "lodash";
import { Server as WebSocketServer } from "ws";
import type { Request } from "../src";
import type { BaseResponse } from "../src/models/methods/baseMethod";
import { getFreePort } from "./testUtils";
@@ -29,6 +30,12 @@ function ping(conn, request) {
}, 1000 * 2);
}
export interface PortResponse extends BaseResponse {
result: {
port: number;
};
}
// We mock out WebSocketServer in these tests and add a lot of custom
// properties not defined on the normal WebSocketServer object.
type MockedWebSocketServer = any;

View File

@@ -1,80 +0,0 @@
import assert from "assert";
import RangeSet from "xrpl-local/client/rangeSet";
describe("RangeSet", function () {
it("addRange()/addValue()", function () {
const r = new RangeSet();
r.addRange(4, 5);
r.addRange(7, 10);
r.addRange(1, 2);
r.addValue(3);
assert.deepEqual(r.serialize(), "1-5,7-10");
});
it("addValue()/addRange() -- malformed", function () {
const r = new RangeSet();
assert.throws(function () {
r.addRange(2, 1);
});
});
it("parseAndAddRanges()", function () {
const r = new RangeSet();
r.parseAndAddRanges("4-5,7-10,1-2,3-3");
assert.deepEqual(r.serialize(), "1-5,7-10");
});
it("parseAndAddRanges() -- single ledger", function () {
const r = new RangeSet();
r.parseAndAddRanges("3");
assert.strictEqual(r.serialize(), "3-3");
assert(r.containsValue(3));
assert(!r.containsValue(0));
assert(!r.containsValue(2));
assert(!r.containsValue(4));
assert(r.containsRange(3, 3));
assert(!r.containsRange(2, 3));
assert(!r.containsRange(3, 4));
r.parseAndAddRanges("1-5");
assert.strictEqual(r.serialize(), "1-5");
assert(r.containsValue(3));
assert(r.containsValue(1));
assert(r.containsValue(5));
assert(!r.containsValue(6));
assert(!r.containsValue(0));
assert(r.containsRange(1, 5));
assert(r.containsRange(2, 4));
assert(!r.containsRange(1, 6));
assert(!r.containsRange(0, 3));
});
it("containsValue()", function () {
const r = new RangeSet();
r.addRange(32570, 11005146);
r.addValue(11005147);
assert.strictEqual(r.containsValue(1), false);
assert.strictEqual(r.containsValue(32569), false);
assert.strictEqual(r.containsValue(32570), true);
assert.strictEqual(r.containsValue(50000), true);
assert.strictEqual(r.containsValue(11005146), true);
assert.strictEqual(r.containsValue(11005147), true);
assert.strictEqual(r.containsValue(11005148), false);
assert.strictEqual(r.containsValue(12000000), false);
});
it("reset()", function () {
const r = new RangeSet();
r.addRange(4, 5);
r.addRange(7, 10);
r.reset();
assert.deepEqual(r.serialize(), "");
});
});

View File

@@ -1,5 +1,7 @@
import { Client, BroadcastClient } from "xrpl-local";
import { PortResponse } from "./mockRippled";
const port = 34371;
const baseUrl = "ws://testripple.circleci.com:";
@@ -15,7 +17,7 @@ function setup(this: any, port_ = port) {
})
.then((got) => {
return new Promise<void>((resolve, reject) => {
this.client = new Client(baseUrl + got.port);
this.client = new Client(baseUrl + (got as PortResponse).result.port);
this.client.connect().then(resolve).catch(reject);
});
})

View File

@@ -13,21 +13,21 @@ describe("Get Faucet URL", function () {
it("returns the Devnet URL", function () {
const expectedFaucet = FaucetNetwork.Devnet;
this.client.connection._url = FaucetNetwork.Devnet;
this.client.connection.url = FaucetNetwork.Devnet;
assert.strictEqual(getFaucetUrl(this.client), expectedFaucet);
});
it("returns the Testnet URL", function () {
const expectedFaucet = FaucetNetwork.Testnet;
this.client.connection._url = FaucetNetwork.Testnet;
this.client.connection.url = FaucetNetwork.Testnet;
assert.strictEqual(getFaucetUrl(this.client), expectedFaucet);
});
it("returns the Testnet URL with the XRPL Labs server", function () {
const expectedFaucet = FaucetNetwork.Testnet;
this.client.connection._url = "wss://testnet.xrpl-labs.com";
this.client.connection.url = "wss://testnet.xrpl-labs.com";
assert.strictEqual(getFaucetUrl(this.client), expectedFaucet);
});