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

View File

@@ -1,42 +1,69 @@
// Original code based on "backo" - https://github.com/segmentio/backo // Original code based on "backo" - https://github.com/segmentio/backo
// MIT License - Copyright 2014 Segment.io // 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 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 * A Back off strategy that increases exponentially. Useful with repeated
* setTimeout calls over a network (where the destination may be down). * setTimeout calls over a network (where the destination may be down).
*/ */
export class ExponentialBackoff { export default class ExponentialBackoff {
private readonly ms: number; private readonly ms: number;
private readonly max: number; private readonly max: number;
private readonly factor: number = 2; private readonly factor: number = 2;
private readonly jitter: number = 0; private numAttempts = 0;
attempts = 0;
constructor(opts: { min?: number; max?: number } = {}) { /**
this.ms = opts.min || 100; * Constructs an ExponentialBackoff object.
this.max = opts.max || 10000; *
* @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. * Return the backoff duration.
*
* @returns The backoff duration in milliseconds.
*/ */
duration() { public duration(): number {
let ms = this.ms * this.factor ** this.attempts++; const ms = this.ms * this.factor ** this.numAttempts;
if (this.jitter) { this.numAttempts += 1;
const rand = Math.random(); return Math.floor(Math.min(ms, this.max));
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;
} }
/** /**
* Reset the number of attempts. * Reset the number of attempts.
*/ */
reset() { public reset(): void {
this.attempts = 0; this.numAttempts = 0;
} }
} }

View File

@@ -1,10 +1,18 @@
import { Client, ClientOptions } from "."; import { Client, ClientOptions } from ".";
class BroadcastClient extends Client { /**
ledgerVersion: number | undefined = undefined; * Client that can rely on multiple different servers.
private readonly _clients: Client[]; */
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); super(servers[0], options);
const clients: Client[] = servers.map( const clients: Client[] = servers.map(
@@ -12,33 +20,23 @@ class BroadcastClient extends Client {
); );
// exposed for testing // exposed for testing
this._clients = clients; this.clients = clients;
this.getMethodNames().forEach((name) => { this.getMethodNames().forEach((name: string) => {
this[name] = function () { this[name] = async (...args): Promise<unknown> =>
// eslint-disable-line no-loop-func // eslint-disable-next-line max-len -- Need a long comment, TODO: figure out how to avoid this weirdness
return Promise.race( /* eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call -- Types are outlined in Client class */
clients.map((client) => client[name](...arguments)) Promise.race(clients.map(async (client) => client[name](...args)));
);
};
}); });
// connection methods must be overridden to apply to all client instances // connection methods must be overridden to apply to all client instances
this.connect = async function () { this.connect = async (): Promise<void> => {
await Promise.all(clients.map((client) => client.connect())); await Promise.all(clients.map(async (client) => client.connect()));
}; };
this.disconnect = async function () { this.disconnect = async (): Promise<void> => {
await Promise.all(clients.map((client) => client.disconnect())); await Promise.all(clients.map(async (client) => client.disconnect()));
}; };
this.isConnected = function () { this.isConnected = (): boolean =>
return clients.map((client) => client.isConnected()).every(Boolean); 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);
});
clients.forEach((client) => { clients.forEach((client) => {
client.on("error", (errorCode, errorMessage, data) => 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 methodNames: string[] = [];
const firstClient = this._clients[0]; const firstClient = this.clients[0];
const methods = Object.getOwnPropertyNames(firstClient); const methods = Object.getOwnPropertyNames(firstClient);
methods.push( methods.push(
...Object.getOwnPropertyNames(Object.getPrototypeOf(firstClient)) ...Object.getOwnPropertyNames(Object.getPrototypeOf(firstClient))
@@ -62,5 +65,3 @@ class BroadcastClient extends Client {
return methodNames; return methodNames;
} }
} }
export { BroadcastClient };

View File

@@ -1,26 +1,32 @@
/* eslint-disable max-lines -- Connection is a big class */
import { EventEmitter } from "events"; 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 { parse as parseURL } from "url";
import _ from "lodash"; import _ from "lodash";
import WebSocket from "ws"; import WebSocket from "ws";
import { import {
RippledError,
DisconnectedError, DisconnectedError,
NotConnectedError, NotConnectedError,
TimeoutError,
ResponseFormatError,
ConnectionError, ConnectionError,
RippleError, RippleError,
} from "../common/errors"; } 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. * ConnectionOptions is the configuration for the Connection class.
*/ */
export interface ConnectionOptions { interface ConnectionOptions {
trace?: boolean | ((id: string, message: string) => void); trace?: boolean | ((id: string, message: string) => void);
proxy?: string; proxy?: string;
proxyAuthorization?: string; proxyAuthorization?: string;
@@ -29,7 +35,8 @@ export interface ConnectionOptions {
key?: string; key?: string;
passphrase?: string; passphrase?: string;
certificate?: string; certificate?: string;
timeout: number; // request timeout // request timeout
timeout: number;
connectionTimeout: number; connectionTimeout: number;
} }
@@ -45,19 +52,13 @@ export type ConnectionUserOptions = Partial<ConnectionOptions>;
// WebSocket spec allows 4xxx codes for app/library specific codes. // WebSocket spec allows 4xxx codes for app/library specific codes.
// See: https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent // See: https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent
// //
const INTENTIONAL_DISCONNECT_CODE = 4000; export const INTENTIONAL_DISCONNECT_CODE = 4000;
/** type WebsocketState = 0 | 1 | 2 | 3;
* Create a new websocket given your URL and optional proxy/certificate
* configuration. function getAgent(url: string, config: ConnectionOptions): Agent | undefined {
*
* @param url
* @param config
*/
function createWebSocket(url: string, config: ConnectionOptions): WebSocket {
const options: WebSocket.ClientOptions = {};
if (config.proxy != null) {
// TODO: replace deprecated method // TODO: replace deprecated method
if (config.proxy != null) {
const parsedURL = parseURL(url); const parsedURL = parseURL(url);
const parsedProxyURL = parseURL(config.proxy); const parsedProxyURL = parseURL(config.proxy);
const proxyOverrides = _.omitBy( const proxyOverrides = _.omitBy(
@@ -75,12 +76,32 @@ function createWebSocket(url: string, config: ConnectionOptions): WebSocket {
const proxyOptions = { ...parsedProxyURL, ...proxyOverrides }; const proxyOptions = { ...parsedProxyURL, ...proxyOverrides };
let HttpsProxyAgent; let HttpsProxyAgent;
try { 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"); HttpsProxyAgent = require("https-proxy-agent");
} catch (error) { } catch (_error) {
throw new Error('"proxy" option is not supported in the browser'); 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) { if (config.authorization != null) {
const base64 = Buffer.from(config.authorization).toString("base64"); const base64 = Buffer.from(config.authorization).toString("base64");
options.headers = { Authorization: `Basic ${base64}` }; options.headers = { Authorization: `Basic ${base64}` };
@@ -107,10 +128,14 @@ function createWebSocket(url: string, config: ConnectionOptions): WebSocket {
/** /**
* Ws.send(), but promisified. * Ws.send(), but promisified.
* *
* @param ws * @param ws - Websocket to send with.
* @param message * @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) => { return new Promise<void>((resolve, reject) => {
ws.send(message, (error) => { ws.send(message, (error) => {
if (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 * The main Connection class. Responsible for connecting to & managing
* an active WebSocket connection to a XRPL node. * an active WebSocket connection to a XRPL node.
*
* @param errorOrCode
*/ */
export class Connection extends EventEmitter { export class Connection extends EventEmitter {
private readonly _url: string | undefined; private readonly url: string | undefined;
private _ws: null | WebSocket = null; private ws: WebSocket | null = null;
private _reconnectTimeoutID: null | NodeJS.Timeout = null; private reconnectTimeoutID: null | NodeJS.Timeout = null;
private _heartbeatIntervalID: null | NodeJS.Timeout = null; private heartbeatIntervalID: null | NodeJS.Timeout = null;
private readonly _retryConnectionBackoff = new ExponentialBackoff({ private readonly retryConnectionBackoff = new ExponentialBackoff({
min: 100, min: 100,
max: 60 * 1000, max: SECONDS_PER_MINUTE * 1000,
}); });
private readonly _trace: (id: string, message: string) => void = () => {}; private readonly config: ConnectionOptions;
private readonly _config: ConnectionOptions; private readonly requestManager = new RequestManager();
private readonly _requestManager = new RequestManager(); private readonly connectionManager = new ConnectionManager();
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(); super();
this.setMaxListeners(Infinity); this.setMaxListeners(Infinity);
this._url = url; this.url = url;
this._config = { this.config = {
timeout: 20 * 1000, timeout: TIMEOUT * 1000,
connectionTimeout: 5 * 1000, connectionTimeout: CONNECTION_TIMEOUT * 1000,
...options, ...options,
}; };
if (typeof options.trace === "function") { if (typeof options.trace === "function") {
this._trace = options.trace; this.trace = options.trace;
} else if (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. * Returns whether the websocket is connected.
* If this succeeds, we're good. If it fails, disconnect so that the consumer can reconnect, if desired. *
* @returns Whether the websocket connection is open.
*/ */
private readonly _heartbeat = () => { public isConnected(): boolean {
return this.request({ command: "ping" }).catch(() => { return this.state === WebSocket.OPEN;
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;
} }
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()) { if (this.isConnected()) {
return Promise.resolve(); return Promise.resolve();
} }
if (this._state === WebSocket.CONNECTING) { if (this.state === WebSocket.CONNECTING) {
return this._connectionManager.awaitConnection(); return this.connectionManager.awaitConnection();
} }
if (!this._url) { if (!this.url) {
return Promise.reject( return Promise.reject(
new ConnectionError("Cannot connect because no server was specified") new ConnectionError("Cannot connect because no server was specified")
); );
} }
if (this._ws) { if (this.ws != null) {
return Promise.reject( return Promise.reject(
new RippleError("Websocket connection never cleaned up.", { 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. // Create the connection timeout, in case the connection hangs longer than expected.
const connectionTimeoutID = setTimeout(() => { const connectionTimeoutID = setTimeout(() => {
this._onConnectionFailed( this.onConnectionFailed(
new ConnectionError( 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. ` + `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.` `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. // 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"); throw new Error("Connect: created null websocket");
} }
this._ws.on("error", this._onConnectionFailed); this.ws.on("error", (error) => this.onConnectionFailed(error));
this._ws.on("error", () => clearTimeout(connectionTimeoutID)); this.ws.on("error", () => clearTimeout(connectionTimeoutID));
this._ws.on("close", this._onConnectionFailed); this.ws.on("close", (reason) => this.onConnectionFailed(reason));
this._ws.on("close", () => clearTimeout(connectionTimeoutID)); this.ws.on("close", () => clearTimeout(connectionTimeoutID));
this._ws.once("open", async () => { // eslint-disable-next-line @typescript-eslint/no-misused-promises -- TODO: resolve this
if (this._ws == null) { this.ws.once("open", async () => this.onceOpen(connectionTimeoutID));
throw new Error("onceOpen: ws is null"); return this.connectionManager.awaitConnection();
}
// 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();
} }
/** /**
@@ -481,30 +255,33 @@ export class Connection extends EventEmitter {
* should still successfully close with the relevant error code returned. * 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. * 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`). * 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> { public async disconnect(): Promise<number | undefined> {
if (this._reconnectTimeoutID !== null) { if (this.reconnectTimeoutID !== null) {
clearTimeout(this._reconnectTimeoutID); clearTimeout(this.reconnectTimeoutID);
this._reconnectTimeoutID = null; this.reconnectTimeoutID = null;
} }
if (this._state === WebSocket.CLOSED) { if (this.state === WebSocket.CLOSED) {
return Promise.resolve(undefined); return Promise.resolve(undefined);
} }
if (this._ws === null) { if (this.ws == null) {
return Promise.resolve(undefined); return Promise.resolve(undefined);
} }
return new Promise((resolve) => { return new Promise((resolve) => {
if (this._ws === null) { if (this.ws == null) {
return Promise.resolve(undefined); 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. // Connection already has a disconnect handler for the disconnect logic.
// Just close the websocket manually (with our "intentional" code) to // Just close the websocket manually (with our "intentional" code) to
// trigger that. // trigger that.
if (this._ws != null && this._state !== WebSocket.CLOSING) { if (this.ws != null && this.state !== WebSocket.CLOSING) {
this._ws.close(INTENTIONAL_DISCONNECT_CODE); this.ws.close(INTENTIONAL_DISCONNECT_CODE);
} }
}); });
} }
@@ -512,7 +289,7 @@ export class Connection extends EventEmitter {
/** /**
* Disconnect the websocket, then connect again. * Disconnect the websocket, then connect again.
*/ */
async reconnect() { public async reconnect(): Promise<void> {
// NOTE: We currently have a "reconnecting" event, but that only triggers // NOTE: We currently have a "reconnecting" event, but that only triggers
// through an unexpected connection retry logic. // through an unexpected connection retry logic.
// See: https://github.com/ripple/ripple-lib/pull/1101#issuecomment-565360423 // See: https://github.com/ripple/ripple-lib/pull/1101#issuecomment-565360423
@@ -521,20 +298,28 @@ export class Connection extends EventEmitter {
await this.connect(); 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, request: T,
timeout?: number timeout?: number
): Promise<any> { ): Promise<unknown> {
if (!this._shouldBeConnected || this._ws == null) { if (!this.shouldBeConnected || this.ws == null) {
throw new NotConnectedError(); throw new NotConnectedError();
} }
const [id, message, responsePromise] = this._requestManager.createRequest( const [id, message, responsePromise] = this.requestManager.createRequest(
request, request,
timeout || this._config.timeout timeout ?? this.config.timeout
); );
this._trace("send", message); this.trace("send", message);
websocketSendAsync(this._ws, message).catch((error) => { websocketSendAsync(this.ws, message).catch((error) => {
this._requestManager.reject(id, error); this.requestManager.reject(id, error);
}); });
return responsePromise; return responsePromise;
@@ -545,7 +330,195 @@ export class Connection extends EventEmitter {
* *
* @returns The Websocket connection URL. * @returns The Websocket connection URL.
*/ */
getUrl(): string { public getUrl(): string {
return this._url ?? ""; 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 { EventEmitter } from "events";
import { import {
@@ -18,7 +21,7 @@ import {
} from "ripple-address-codec"; } from "ripple-address-codec";
import { constants, errors, txFlags, ensureClassicAddress } from "../common"; import { constants, errors, txFlags, ensureClassicAddress } from "../common";
import { ValidationError } from "../common/errors"; import { RippledError, ValidationError } from "../common/errors";
import { getFee } from "../common/fee"; import { getFee } from "../common/fee";
import getBalances from "../ledger/balances"; import getBalances from "../ledger/balances";
import { getOrderbook, formatBidsAndAsks } from "../ledger/orderbook"; import { getOrderbook, formatBidsAndAsks } from "../ledger/orderbook";
@@ -26,8 +29,6 @@ import getPaths from "../ledger/pathfind";
import getTrustlines from "../ledger/trustlines"; import getTrustlines from "../ledger/trustlines";
import { clamp } from "../ledger/utils"; import { clamp } from "../ledger/utils";
import { import {
Request,
Response,
// account methods // account methods
AccountChannelsRequest, AccountChannelsRequest,
AccountChannelsResponse, AccountChannelsResponse,
@@ -94,6 +95,7 @@ import {
RandomRequest, RandomRequest,
RandomResponse, RandomResponse,
} from "../models/methods"; } from "../models/methods";
import { BaseRequest, BaseResponse } from "../models/methods/baseMethod";
import prepareCheckCancel from "../transaction/check-cancel"; import prepareCheckCancel from "../transaction/check-cancel";
import prepareCheckCash from "../transaction/check-cash"; import prepareCheckCash from "../transaction/check-cash";
import prepareCheckCreate from "../transaction/check-create"; import prepareCheckCreate from "../transaction/check-create";
@@ -116,7 +118,11 @@ import * as transactionUtils from "../transaction/utils";
import { deriveAddress, deriveXAddress } from "../utils/derive"; import { deriveAddress, deriveXAddress } from "../utils/derive";
import generateFaucetWallet from "../wallet/generateFaucetWallet"; import generateFaucetWallet from "../wallet/generateFaucetWallet";
import { Connection, ConnectionUserOptions } from "./connection"; import {
Connection,
ConnectionUserOptions,
INTENTIONAL_DISCONNECT_CODE,
} from "./connection";
export interface ClientOptions extends ConnectionUserOptions { export interface ClientOptions extends ConnectionUserOptions {
feeCushion?: number; 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 * command. This varies from command to command, but we need to know it to
* properly count across many requests. * 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 { function getCollectKeyFromCommand(command: string): string | null {
switch (command) { switch (command) {
@@ -152,44 +159,51 @@ function getCollectKeyFromCommand(command: string): string | null {
} }
} }
type MarkerRequest = interface MarkerRequest extends BaseRequest {
| AccountChannelsRequest limit?: number;
| AccountLinesRequest marker?: unknown;
| AccountObjectsRequest }
| AccountOffersRequest
| AccountTxRequest
| LedgerDataRequest;
type MarkerResponse = interface MarkerResponse extends BaseResponse {
| AccountChannelsResponse result: {
| AccountLinesResponse marker?: unknown;
| AccountObjectsResponse };
| AccountOffersResponse }
| AccountTxResponse
| LedgerDataResponse; const DEFAULT_FEE_CUSHION = 1.2;
const DEFAULT_MAX_FEE_XRP = "2";
const MIN_LIMIT = 10;
const MAX_LIMIT = 400;
class Client extends EventEmitter { 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 // New in > 0.21.0
// non-validated ledger versions are allowed, and passed to rippled as-is. // 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(); super();
if (typeof server !== "string" || !/^(wss?|wss?\+unix):\/\//.exec(server)) { if (typeof server !== "string" || !/wss?(?:\+unix)?:\/\//u.exec(server)) {
throw new ValidationError( throw new ValidationError(
"server URI must start with `wss://`, `ws://`, `wss+unix://`, or `ws+unix://`." "server URI must start with `wss://`, `ws://`, `wss+unix://`, or `ws+unix://`."
); );
} }
this._feeCushion = options.feeCushion || 1.2; this.feeCushion = options.feeCushion ?? DEFAULT_FEE_CUSHION;
this._maxFeeXRP = options.maxFeeXRP || "2"; this.maxFeeXRP = options.maxFeeXRP ?? DEFAULT_MAX_FEE_XRP;
this.connection = new Connection(server, options); this.connection = new Connection(server, options);
@@ -201,67 +215,17 @@ class Client extends EventEmitter {
this.emit("connected"); this.emit("connected");
}); });
this.connection.on("disconnected", (code) => { this.connection.on("disconnected", (code: number) => {
let finalCode = code; let finalCode = code;
// 4000: Connection uses a 4000 code internally to indicate a manual disconnect/close // 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 // 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; finalCode = 1000;
} }
this.emit("disconnected", finalCode); 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. * 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. * 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); 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, req: AccountChannelsRequest,
resp: AccountChannelsResponse resp: AccountChannelsResponse
): Promise<AccountChannelsResponse>; ): Promise<AccountChannelsResponse>;
async requestNextPage( public async requestNextPage(
req: AccountLinesRequest, req: AccountLinesRequest,
resp: AccountLinesResponse resp: AccountLinesResponse
): Promise<AccountLinesResponse>; ): Promise<AccountLinesResponse>;
async requestNextPage( public async requestNextPage(
req: AccountObjectsRequest, req: AccountObjectsRequest,
resp: AccountObjectsResponse resp: AccountObjectsResponse
): Promise<AccountObjectsResponse>; ): Promise<AccountObjectsResponse>;
async requestNextPage( public async requestNextPage(
req: AccountOffersRequest, req: AccountOffersRequest,
resp: AccountOffersResponse resp: AccountOffersResponse
): Promise<AccountOffersResponse>; ): Promise<AccountOffersResponse>;
async requestNextPage( public async requestNextPage(
req: AccountTxRequest, req: AccountTxRequest,
resp: AccountTxResponse resp: AccountTxResponse
): Promise<AccountTxResponse>; ): Promise<AccountTxResponse>;
async requestNextPage( public async requestNextPage(
req: LedgerDataRequest, req: LedgerDataRequest,
resp: LedgerDataResponse resp: LedgerDataResponse
): Promise<LedgerDataResponse>; ): Promise<LedgerDataResponse>;
async requestNextPage<T extends MarkerRequest, U extends MarkerResponse>( /**
req: T, * Requests the next page of data.
resp: U *
): Promise<U> { * @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) { if (!resp.result.marker) {
return Promise.reject( return Promise.reject(
new errors.NotFoundError("response does not have a next page") new errors.NotFoundError("response does not have a next page")
); );
} }
const nextPageRequest = { ...req, marker: resp.result.marker }; 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. * You can later submit the transaction with a `submit` request.
* *
* @param txJSON * @param txJSON - TODO: will be deleted.
* @param instructions * @param instructions - TODO: will be deleted.
* @returns TODO: will be deleted.
*/ */
async prepareTransaction( public async prepareTransaction(
txJSON: TransactionJSON, txJSON: TransactionJSON,
instructions: Instructions = {} instructions: Instructions = {}
): Promise<Prepare> { ): Promise<Prepare> {
return transactionUtils.prepareTransaction(txJSON, this, instructions); return transactionUtils.prepareTransaction(txJSON, this, instructions);
} }
/** public async requestAll(
* Convert a string to hex. req: AccountChannelsRequest
* ): Promise<AccountChannelsResponse[]>;
* This can be used to generate `MemoData`, `MemoType`, and `MemoFormat`. public async requestAll(
* req: AccountLinesRequest
* @param string - String to convert to hex. ): Promise<AccountLinesResponse[]>;
*/ public async requestAll(
convertStringToHex(string: string): string { req: AccountObjectsRequest
return transactionUtils.convertStringToHex(string); ): 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 * Makes multiple paged requests to the client to return a given number of
* resources. Multiple paged requests will be made until the `limit` * 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 * 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 * general use. Instead, use rippled's built-in pagination and make multiple
* requests as needed. * 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( public async requestAll<T extends MarkerRequest, U extends MarkerResponse>(
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>(
request: T, request: T,
options: { collect?: string } = {} collect?: string
): Promise<U[]> { ): Promise<U[]> {
// The data under collection is keyed based on the command. Fail if command // The data under collection is keyed based on the command. Fail if command
// not recognized and collection key not provided. // not recognized and collection key not provided.
const collectKey = const collectKey = collect ?? getCollectKeyFromCommand(request.command);
options.collect || getCollectKeyFromCommand(request.command);
if (!collectKey) { if (!collectKey) {
throw new errors.ValidationError( throw new ValidationError(
`no collect key for command ${request.command}` `no collect key for command ${request.command}`
); );
} }
// If limit is not provided, fetches all data over multiple requests. // If limit is not provided, fetches all data over multiple requests.
// NOTE: This may return much more than needed. Set limit when possible. // 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 count = 0;
let marker = request.marker; let marker: unknown = request.marker;
let lastBatchLength: number; let lastBatchLength: number;
const results: any[] = []; const results: U[] = [];
do { do {
const countRemaining = clamp(countTo - count, 10, 400); const countRemaining = clamp(countTo - count, MIN_LIMIT, MAX_LIMIT);
const repeatProps = { const repeatProps = {
...request, ...request,
limit: countRemaining, limit: countRemaining,
marker, 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 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]; const collectedData = singleResult[collectKey];
marker = singleResult.marker; 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. // Make sure we handle when no data (not even an empty array) is returned.
const isExpectedFormat = Array.isArray(collectedData); if (Array.isArray(collectedData)) {
if (isExpectedFormat) {
count += collectedData.length; count += collectedData.length;
lastBatchLength = collectedData.length; lastBatchLength = collectedData.length;
} else { } else {
@@ -407,78 +453,93 @@ class Client extends EventEmitter {
return results; return results;
} }
isConnected(): boolean { /**
return this.connection.isConnected(); * Tells the Client instance to connect to its rippled server.
} *
* @returns A promise that resolves with a void value when a connection is established.
async connect(): Promise<void> { */
public async connect(): Promise<void> {
return this.connection.connect(); 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 // backwards compatibility: connection.disconnect() can return a number, but
// this method returns nothing. SO we await but don't return any result. // this method returns nothing. SO we await but don't return any result.
await this.connection.disconnect(); 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; public getFee = getFee;
getBalances = getBalances;
getPaths = getPaths;
getOrderbook = getOrderbook;
preparePayment = preparePayment; public getTrustlines = getTrustlines;
prepareTrustline = prepareTrustline; public getBalances = getBalances;
prepareOrder = prepareOrder; public getPaths = getPaths;
prepareOrderCancellation = prepareOrderCancellation; public getOrderbook = getOrderbook;
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;
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 // 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 methods to expose ripple-address-codec methods.
*/ */
static classicAddressToXAddress = classicAddressToXAddress; public static classicAddressToXAddress = classicAddressToXAddress;
static xAddressToClassicAddress = xAddressToClassicAddress; public static xAddressToClassicAddress = xAddressToClassicAddress;
static isValidXAddress = isValidXAddress; public static isValidXAddress = isValidXAddress;
static isValidClassicAddress = isValidClassicAddress; public static isValidClassicAddress = isValidClassicAddress;
static encodeSeed = encodeSeed; public static encodeSeed = encodeSeed;
static decodeSeed = decodeSeed; public static decodeSeed = decodeSeed;
static encodeAccountID = encodeAccountID; public static encodeAccountID = encodeAccountID;
static decodeAccountID = decodeAccountID; public static decodeAccountID = decodeAccountID;
static encodeNodePublic = encodeNodePublic; public static encodeNodePublic = encodeNodePublic;
static decodeNodePublic = decodeNodePublic; public static decodeNodePublic = decodeNodePublic;
static encodeAccountPublic = encodeAccountPublic; public static encodeAccountPublic = encodeAccountPublic;
static decodeAccountPublic = decodeAccountPublic; public static decodeAccountPublic = decodeAccountPublic;
static encodeXAddress = encodeXAddress; public static encodeXAddress = encodeXAddress;
static decodeXAddress = decodeXAddress; public static decodeXAddress = decodeXAddress;
txFlags = txFlags; public txFlags = txFlags;
static txFlags = txFlags; public static txFlags = txFlags;
accountSetFlags = constants.AccountSetFlags; public accountSetFlags = constants.AccountSetFlags;
static accountSetFlags = constants.AccountSetFlags; public static accountSetFlags = constants.AccountSetFlags;
} }
export { Client, Connection }; 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"; import { EventEmitter } from "events";
// Define the global WebSocket class found on the native browser // Define the global WebSocket class found on the native browser
declare class WebSocket { declare class WebSocket {
onclose?: Function; public onclose?: () => void;
onopen?: Function; public onopen?: () => void;
onerror?: Function; public onerror?: (error: Error) => void;
onmessage?: Function; public onmessage?: (message: MessageEvent) => void;
readyState: number; public readyState: number;
constructor(url: string); public constructor(url: string);
close(); public close(code?: number): void;
send(message: string); 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. * same, as `ws` package provides.
*/ */
export default class WSWrapper extends EventEmitter { export default class WSWrapper extends EventEmitter {
private readonly _ws: WebSocket; public static CONNECTING = 0;
static CONNECTING = 0; public static OPEN = 1;
static OPEN = 1; public static CLOSING = 2;
static CLOSING = 2; // eslint-disable-next-line @typescript-eslint/no-magic-numbers -- magic number is being defined here
static CLOSED = 3; 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(); super();
this.setMaxListeners(Infinity); this.setMaxListeners(Infinity);
this._ws = new WebSocket(url); this.ws = new WebSocket(url);
this._ws.onclose = () => { this.ws.onclose = (): void => {
this.emit("close"); this.emit("close");
}; };
this._ws.onopen = () => { this.ws.onopen = (): void => {
this.emit("open"); this.emit("open");
}; };
this._ws.onerror = (error) => { this.ws.onerror = (error): void => {
this.emit("error", error); this.emit("error", error);
}; };
this._ws.onmessage = (message) => { this.ws.onmessage = (message: MessageEvent): void => {
this.emit("message", message.data); this.emit("message", message.data);
}; };
} }
close() { /**
* Closes the websocket.
*/
public close(): void {
if (this.readyState === 1) { 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 BigNumber from "bignumber.js";
import _ from "lodash"; import _ from "lodash";
import { Client } from ".."; import type { Client } from "..";
// This is a public API that can be called directly. // This is a public API that can be called directly.
// This is not used by the `prepare*` methods. See `src/transaction/utils.ts` // This is not used by the `prepare*` methods. See `src/transaction/utils.ts`
async function getFee(this: Client, cushion?: number): Promise<string> { async function getFee(this: Client, cushion?: number): Promise<string> {
if (cushion == null) { if (cushion == null) {
cushion = this._feeCushion; cushion = this.feeCushion;
} }
if (cushion == null) { if (cushion == null) {
cushion = 1.2; 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); let fee = baseFeeXrp.times(serverInfo.load_factor).times(cushion);
// Cap fee to `this._maxFeeXRP` // Cap fee to `this.maxFeeXRP`
fee = BigNumber.min(fee, this._maxFeeXRP); fee = BigNumber.min(fee, this.maxFeeXRP);
// Round fee to 6 decimal places // Round fee to 6 decimal places
return new BigNumber(fee.toFixed(6)).toString(10); 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 { Client } from "./client";
export * from "./transaction/types"; export * from "./transaction/types";
@@ -8,7 +11,6 @@ export * from "./models/methods";
export * from "./utils"; export * from "./utils";
// Broadcast client is experimental export { BroadcastClient };
export { BroadcastClient } from "./client/broadcastClient";
export { default as Wallet } from "./wallet"; export { default as Wallet } from "./wallet";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { Client } from ".."; import type { Client } from "..";
import { Amount } from "../common/types/objects"; import { Amount } from "../common/types/objects";
import { ISOTimeToRippleTime, toRippledAmount } from "../utils"; 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 { Memo } from "../common/types/objects";
import { Instructions, Prepare, TransactionJSON } from "./types"; 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 { Memo } from "../common/types/objects";
import { ISOTimeToRippleTime, xrpToDrops } from "../utils"; 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 { Memo } from "../common/types/objects";
import { Instructions, Prepare, TransactionJSON } from "./types"; 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 { FormattedOrderSpecification } from "../common/types/objects/index";
import { ISOTimeToRippleTime, toRippledAmount } from "../utils"; 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 { Instructions, Prepare, TransactionJSON } from "./types";
import * as utils from "./utils"; import * as utils from "./utils";

View File

@@ -1,4 +1,4 @@
import { Client } from ".."; import type { Client } from "..";
import { xrpToDrops } from "../utils"; import { xrpToDrops } from "../utils";
import { Instructions, Prepare, TransactionJSON } from "./types"; 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 { ISOTimeToRippleTime, xrpToDrops } from "../utils";
import { Instructions, Prepare, TransactionJSON } from "./types"; 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 { ISOTimeToRippleTime, xrpToDrops } from "../utils";
import { Instructions, Prepare, TransactionJSON } from "./types"; import { Instructions, Prepare, TransactionJSON } from "./types";

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import {
isValidXAddress, isValidXAddress,
} from "ripple-address-codec"; } from "ripple-address-codec";
import { Client } from ".."; import type { Client } from "..";
import * as common from "../common"; import * as common from "../common";
import { ValidationError } from "../common/errors"; import { ValidationError } from "../common/errors";
import { Memo } from "../common/types/objects"; import { Memo } from "../common/types/objects";
@@ -31,6 +31,8 @@ export interface ApiMemo {
MemoFormat?: string; MemoFormat?: string;
} }
// TODO: move relevant methods from here to `src/utils` (such as `convertStringToHex`?)
function formatPrepareResponse(txJSON: any): Prepare { function formatPrepareResponse(txJSON: any): Prepare {
const instructions: any = { const instructions: any = {
fee: dropsToXrp(txJSON.Fee), fee: dropsToXrp(txJSON.Fee),
@@ -307,11 +309,11 @@ async function prepareTransaction(
instructions.signersCount == null ? 1 : instructions.signersCount + 1; instructions.signersCount == null ? 1 : instructions.signersCount + 1;
if (instructions.fee != null) { if (instructions.fee != null) {
const fee = new BigNumber(instructions.fee); const fee = new BigNumber(instructions.fee);
if (fee.isGreaterThan(client._maxFeeXRP)) { if (fee.isGreaterThan(client.maxFeeXRP)) {
return Promise.reject( return Promise.reject(
new ValidationError( new ValidationError(
`Fee of ${fee.toString(10)} XRP exceeds ` + `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." "`maxFeeXRP` in the Client constructor."
) )
); );
@@ -319,7 +321,7 @@ async function prepareTransaction(
newTxJSON.Fee = scaleValue(xrpToDrops(instructions.fee), multiplier); newTxJSON.Fee = scaleValue(xrpToDrops(instructions.fee), multiplier);
return Promise.resolve(); return Promise.resolve();
} }
const cushion = client._feeCushion; const cushion = client.feeCushion;
return client.getFee(cushion).then(async (fee) => { return client.getFee(cushion).then(async (fee) => {
return client return client
.request({ command: "fee" }) .request({ command: "fee" })
@@ -338,8 +340,8 @@ async function prepareTransaction(
)); ));
const feeDrops = xrpToDrops(fee); const feeDrops = xrpToDrops(fee);
const maxFeeXRP = instructions.maxFee const maxFeeXRP = instructions.maxFee
? BigNumber.min(client._maxFeeXRP, instructions.maxFee) ? BigNumber.min(client.maxFeeXRP, instructions.maxFee)
: client._maxFeeXRP; : client.maxFeeXRP;
const maxFeeDrops = xrpToDrops(maxFeeXRP); const maxFeeDrops = xrpToDrops(maxFeeXRP);
const normalFee = scaleValue(feeDrops, multiplier, extraFee); const normalFee = scaleValue(feeDrops, multiplier, extraFee);
newTxJSON.Fee = BigNumber.min(normalFee, maxFeeDrops).toString(10); 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 { isValidClassicAddress } from "ripple-address-codec";
import { Client, Wallet } from ".."; import type { Client } from "..";
import { errors } from "../common"; import { errors } from "../common";
import { RippledError } from "../common/errors"; import { RippledError } from "../common/errors";
import { GeneratedAddress } from "../utils/generateAddress"; import { GeneratedAddress } from "../utils/generateAddress";
import Wallet from ".";
export interface FaucetWallet { export interface FaucetWallet {
account: GeneratedAddress; account: GeneratedAddress;
amount: number; amount: number;
@@ -21,14 +23,14 @@ export enum FaucetNetwork {
const INTERVAL_SECONDS = 1; // Interval to check an account balance const INTERVAL_SECONDS = 1; // Interval to check an account balance
const MAX_ATTEMPTS = 20; // Maximum attempts to retrieve a balance const MAX_ATTEMPTS = 20; // Maximum attempts to retrieve a balance
/** //
* Generates a random wallet with some amount of XRP (usually 1000 XRP). // Generates a random wallet with some amount of XRP (usually 1000 XRP).
* //
* @param client - Client. // @param client - Client.
* @param wallet - An existing XRPL Wallet to fund, if undefined, a new Wallet will be created. // @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. // @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. // @throws When either Client isn't connected or unable to fund wallet address.
*/ // z
async function generateFaucetWallet( async function generateFaucetWallet(
client: Client, client: Client,
wallet?: Wallet wallet?: Wallet

View File

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

View File

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

View File

@@ -33,7 +33,7 @@ describe("Client", function () {
it("Client valid options", function () { it("Client valid options", function () {
const client = new Client("wss://s:1"); 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"); assert.deepEqual(privateConnectionUrl, "wss://s:1");
}); });

View File

@@ -14,7 +14,7 @@ describe("client constructor", function () {
it("Client valid options", function () { it("Client valid options", function () {
const client = new Client("wss://s:1"); 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"); assert.deepEqual(privateConnectionUrl, "wss://s:1");
}); });

View File

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

View File

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

View File

@@ -460,7 +460,7 @@ describe("client.preparePayment", function () {
"account_info", "account_info",
rippled.account_info.normal rippled.account_info.normal
); );
this.client._feeCushion = 1000000; this.client.feeCushion = 1000000;
const expectedResponse = { const expectedResponse = {
txJSON: 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}', '{"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", "account_info",
rippled.account_info.normal rippled.account_info.normal
); );
this.client._maxFeeXRP = "2.2"; this.client.maxFeeXRP = "2.2";
const localInstructions = { const localInstructions = {
...instructionsWithMaxLedgerVersionOffset, ...instructionsWithMaxLedgerVersionOffset,
fee: "2.1", fee: "2.1",
@@ -516,7 +516,7 @@ describe("client.preparePayment", function () {
"account_info", "account_info",
rippled.account_info.normal rippled.account_info.normal
); );
this.client._feeCushion = 1000000; this.client.feeCushion = 1000000;
const expectedResponse = { const expectedResponse = {
txJSON: 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}', '{"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", "account_info",
rippled.account_info.normal rippled.account_info.normal
); );
this.client._feeCushion = 1000000; this.client.feeCushion = 1000000;
this.client._maxFeeXRP = "3"; this.client.maxFeeXRP = "3";
const localInstructions = { const localInstructions = {
...instructionsWithMaxLedgerVersionOffset, ...instructionsWithMaxLedgerVersionOffset,
maxFee: "4", maxFee: "4",
@@ -573,8 +573,8 @@ describe("client.preparePayment", function () {
"account_info", "account_info",
rippled.account_info.normal rippled.account_info.normal
); );
this.client._feeCushion = 1000000; this.client.feeCushion = 1000000;
this.client._maxFeeXRP = "5"; this.client.maxFeeXRP = "5";
const localInstructions = { const localInstructions = {
...instructionsWithMaxLedgerVersionOffset, ...instructionsWithMaxLedgerVersionOffset,
maxFee: "4", maxFee: "4",

View File

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

View File

@@ -1,5 +1,6 @@
import { assert } from "chai"; import { assert } from "chai";
import { Client } from "../../src";
import rippled from "../fixtures/rippled"; import rippled from "../fixtures/rippled";
import setupClient from "../setupClient"; import setupClient from "../setupClient";
import { assertRejects } from "../testUtils"; import { assertRejects } from "../testUtils";
@@ -34,7 +35,7 @@ describe("client.requestNextPage", function () {
{ command: "ledger_data" }, { command: "ledger_data" },
response response
); );
assert(!this.client.hasNextPage(responseNextPage)); assert(!Client.hasNextPage(responseNextPage));
await assertRejects( await assertRejects(
this.client.requestNextPage({ command: "ledger_data" }, responseNextPage), this.client.requestNextPage({ command: "ledger_data" }, responseNextPage),
Error, 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 () { 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 secret = "shsWGZcmZz6YsWWmcnpfr6fLTdtFV";
const request = { const request = {
// TODO: This fails when address is X-address // 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 () { 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 secret = "shsWGZcmZz6YsWWmcnpfr6fLTdtFV";
const request = { const request = {
txJSON: `{"Flags":2147483648,"TransactionType":"AccountSet","Account":"${test.address}","Domain":"6578616D706C652E636F6D","LastLedgerSequence":8820051,"Fee":"2010000","Sequence":23,"SigningPubKey":"02F89EAEC7667B30F33D0687BBA86C3FE2A08CCA40A9186C5BDE2DAA6FA97A37D8"}`, 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 () { it("default options", function () {
const connection: any = new Connection("url"); const connection: any = new Connection("url");
assert.strictEqual(connection._url, "url"); assert.strictEqual(connection.url, "url");
assert(connection._config.proxy == null); assert(connection.config.proxy == null);
assert(connection._config.authorization == null); assert(connection.config.authorization == null);
}); });
describe("trace", function () { describe("trace", function () {
@@ -65,9 +65,9 @@ describe("Connection", function () {
const messages: any[] = []; const messages: any[] = [];
console.log = (id, message) => messages.push([id, message]); console.log = (id, message) => messages.push([id, message]);
const connection: any = new Connection("url", { trace: false }); const connection: any = new Connection("url", { trace: false });
connection._ws = { send() {} }; connection.ws = { send() {} };
connection.request(mockedRequestData); connection.request(mockedRequestData);
connection._onMessage(mockedResponse); connection.onMessage(mockedResponse);
assert.deepEqual(messages, []); assert.deepEqual(messages, []);
}); });
@@ -75,9 +75,9 @@ describe("Connection", function () {
const messages: any[] = []; const messages: any[] = [];
console.log = (id, message) => messages.push([id, message]); console.log = (id, message) => messages.push([id, message]);
const connection: any = new Connection("url", { trace: true }); const connection: any = new Connection("url", { trace: true });
connection._ws = { send() {} }; connection.ws = { send() {} };
connection.request(mockedRequestData); connection.request(mockedRequestData);
connection._onMessage(mockedResponse); connection.onMessage(mockedResponse);
assert.deepEqual(messages, expectedMessages); assert.deepEqual(messages, expectedMessages);
}); });
@@ -86,9 +86,9 @@ describe("Connection", function () {
const connection: any = new Connection("url", { const connection: any = new Connection("url", {
trace: (id, message) => messages.push([id, message]), trace: (id, message) => messages.push([id, message]),
}); });
connection._ws = { send() {} }; connection.ws = { send() {} };
connection.request(mockedRequestData); connection.request(mockedRequestData);
connection._onMessage(mockedResponse); connection.onMessage(mockedResponse);
assert.deepEqual(messages, expectedMessages); assert.deepEqual(messages, expectedMessages);
}); });
}); });
@@ -116,7 +116,7 @@ describe("Connection", function () {
authorization: "authorization", authorization: "authorization",
trustedCertificates: ["path/to/pem"], 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) => { connection.connect().catch((err) => {
assert(err instanceof NotConnectedError); assert(err instanceof NotConnectedError);
}); });
@@ -177,7 +177,7 @@ describe("Connection", function () {
}); });
it("TimeoutError", function () { it("TimeoutError", function () {
this.client.connection._ws.send = function (_, callback) { this.client.connection.ws.send = function (_, callback) {
callback(null); callback(null);
}; };
const request = { command: "server_info" }; const request = { command: "server_info" };
@@ -192,7 +192,7 @@ describe("Connection", function () {
}); });
it("DisconnectedError on send", function () { it("DisconnectedError on send", function () {
this.client.connection._ws.send = function (_, callback) { this.client.connection.ws.send = function (_, callback) {
callback({ message: "not connected" }); callback({ message: "not connected" });
}; };
return this.client 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 // _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 // do not rely on the client.setup hook to test this as it bypasses the case, disconnect client connection first
await this.client.disconnect(); await this.client.disconnect();
// stub _onOpen to only run logic relevant to test case // 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 // 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 // recent ws throws this error instead of calling back
throw new Error("WebSocket is not open: readyState 0 (CONNECTING)"); throw new Error("WebSocket is not open: readyState 0 (CONNECTING)");
}; };
@@ -225,7 +225,8 @@ describe("Connection", function () {
try { try {
await this.client.connect(); await this.client.connect();
} catch (error) { } catch (error) {
assert(error instanceof DisconnectedError); console.log(error);
assert.instanceOf(error, DisconnectedError);
assert.strictEqual( assert.strictEqual(
error.message, error.message,
"WebSocket is not open: readyState 0 (CONNECTING)" "WebSocket is not open: readyState 0 (CONNECTING)"
@@ -252,7 +253,7 @@ describe("Connection", function () {
done(); done();
}); });
setTimeout(() => { setTimeout(() => {
this.client.connection._ws.close(); this.client.connection.ws.close();
}, 1); }, 1);
}); });
@@ -329,13 +330,13 @@ describe("Connection", function () {
} }
} }
// Set the heartbeat to less than the 1 second ping response // 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 // Drop the test runner timeout, since this should be a quick test
this.timeout(5000); this.timeout(5000);
// Hook up a listener for the reconnect event // Hook up a listener for the reconnect event
this.client.connection.on("reconnect", () => done()); this.client.connection.on("reconnect", () => done());
// Trigger a heartbeat // Trigger a heartbeat
this.client.connection._heartbeat().catch((error) => { this.client.connection.heartbeat().catch((error) => {
/* ignore - test expects heartbeat failure */ /* ignore - test expects heartbeat failure */
}); });
}); });
@@ -349,7 +350,7 @@ describe("Connection", function () {
} }
} }
// Set the heartbeat to less than the 1 second ping response // 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 // Drop the test runner timeout, since this should be a quick test
this.timeout(5000); this.timeout(5000);
// fail on reconnect/connection // fail on reconnect/connection
@@ -364,7 +365,7 @@ describe("Connection", function () {
return done(new Error("Expected error on reconnect")); return done(new Error("Expected error on reconnect"));
}); });
// Trigger a heartbeat // Trigger a heartbeat
this.client.connection._heartbeat(); this.client.connection.heartbeat();
}); });
it("should emit disconnected event with code 1000 (CLOSE_NORMAL)", function (done) { 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) { it("should emit connected event on after reconnect", function (done) {
this.client.once("connected", done); this.client.once("connected", done);
this.client.connection._ws.close(); this.client.connection.ws.close();
}); });
it("Multiply connect calls", function () { it("Multiply connect calls", function () {
@@ -450,17 +451,17 @@ describe("Connection", function () {
done(); done();
}); });
this.client.connection._onMessage( this.client.connection.onMessage(
JSON.stringify({ JSON.stringify({
type: "transaction", type: "transaction",
}) })
); );
this.client.connection._onMessage( this.client.connection.onMessage(
JSON.stringify({ JSON.stringify({
type: "path_find", type: "path_find",
}) })
); );
this.client.connection._onMessage( this.client.connection.onMessage(
JSON.stringify({ JSON.stringify({
type: "response", type: "response",
id: 1, id: 1,
@@ -475,7 +476,7 @@ describe("Connection", function () {
assert.strictEqual(message, '{"type":"response","id":"must be integer"}'); assert.strictEqual(message, '{"type":"response","id":"must be integer"}');
done(); done();
}); });
this.client.connection._onMessage( this.client.connection.onMessage(
JSON.stringify({ JSON.stringify({
type: "response", type: "response",
id: "must be integer", id: "must be integer",
@@ -490,7 +491,7 @@ describe("Connection", function () {
assert.deepEqual(data, { error: "slowDown", error_message: "slow down" }); assert.deepEqual(data, { error: "slowDown", error_message: "slow down" });
done(); done();
}); });
this.client.connection._onMessage( this.client.connection.onMessage(
JSON.stringify({ JSON.stringify({
error: "slowDown", error: "slowDown",
error_message: "slow down", error_message: "slow down",
@@ -525,13 +526,13 @@ describe("Connection", function () {
done(); 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 () { // it('should clean up websocket connection if error after websocket is opened', async function () {
// await this.client.disconnect() // await this.client.disconnect()
// // fail on connection // // fail on connection
// this.client.connection._subscribeToLedger = async () => { // this.client.connection.subscribeToLedger = async () => {
// throw new Error('error on _subscribeToLedger') // throw new Error('error on _subscribeToLedger')
// } // }
// try { // try {
@@ -541,7 +542,7 @@ describe("Connection", function () {
// assert(err.message === 'error on _subscribeToLedger') // 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 // // _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 // // do not fail on connection anymore
// this.client.connection._subscribeToLedger = async () => {} // this.client.connection.subscribeToLedger = async () => {}
// await this.client.connection.reconnect() // await this.client.connection.reconnect()
// } // }
// }) // })

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import _ from "lodash";
import { Server as WebSocketServer } from "ws"; import { Server as WebSocketServer } from "ws";
import type { Request } from "../src"; import type { Request } from "../src";
import type { BaseResponse } from "../src/models/methods/baseMethod";
import { getFreePort } from "./testUtils"; import { getFreePort } from "./testUtils";
@@ -29,6 +30,12 @@ function ping(conn, request) {
}, 1000 * 2); }, 1000 * 2);
} }
export interface PortResponse extends BaseResponse {
result: {
port: number;
};
}
// We mock out WebSocketServer in these tests and add a lot of custom // We mock out WebSocketServer in these tests and add a lot of custom
// properties not defined on the normal WebSocketServer object. // properties not defined on the normal WebSocketServer object.
type MockedWebSocketServer = any; 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 { Client, BroadcastClient } from "xrpl-local";
import { PortResponse } from "./mockRippled";
const port = 34371; const port = 34371;
const baseUrl = "ws://testripple.circleci.com:"; const baseUrl = "ws://testripple.circleci.com:";
@@ -15,7 +17,7 @@ function setup(this: any, port_ = port) {
}) })
.then((got) => { .then((got) => {
return new Promise<void>((resolve, reject) => { 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); this.client.connect().then(resolve).catch(reject);
}); });
}) })

View File

@@ -13,21 +13,21 @@ describe("Get Faucet URL", function () {
it("returns the Devnet URL", function () { it("returns the Devnet URL", function () {
const expectedFaucet = FaucetNetwork.Devnet; const expectedFaucet = FaucetNetwork.Devnet;
this.client.connection._url = FaucetNetwork.Devnet; this.client.connection.url = FaucetNetwork.Devnet;
assert.strictEqual(getFaucetUrl(this.client), expectedFaucet); assert.strictEqual(getFaucetUrl(this.client), expectedFaucet);
}); });
it("returns the Testnet URL", function () { it("returns the Testnet URL", function () {
const expectedFaucet = FaucetNetwork.Testnet; const expectedFaucet = FaucetNetwork.Testnet;
this.client.connection._url = FaucetNetwork.Testnet; this.client.connection.url = FaucetNetwork.Testnet;
assert.strictEqual(getFaucetUrl(this.client), expectedFaucet); assert.strictEqual(getFaucetUrl(this.client), expectedFaucet);
}); });
it("returns the Testnet URL with the XRPL Labs server", function () { it("returns the Testnet URL with the XRPL Labs server", function () {
const expectedFaucet = FaucetNetwork.Testnet; 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); assert.strictEqual(getFaucetUrl(this.client), expectedFaucet);
}); });