diff --git a/package-lock.json b/package-lock.json index 1d58532408..43b83a6471 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3973,6 +3973,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "is-nan": "^1.3.2", @@ -6175,12 +6176,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es6-object-assign": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/es6-object-assign/-/es6-object-assign-1.1.0.tgz", - "integrity": "sha512-MEl9uirslVwqQU369iHNWZXsI8yaZYGg/D65aOgZkeyFJwHYSxilf7rQzXKI7DdDuBPrBXbfk3sl9hJhmd5AUw==", - "dev": true - }, "node_modules/escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", @@ -11613,6 +11608,7 @@ "version": "4.1.4", "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.4.tgz", "integrity": "sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.4", @@ -13228,6 +13224,12 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/run-s": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/run-s/-/run-s-0.0.0.tgz", + "integrity": "sha512-KPDNauF2Tpnm3nG0+0LJuJxwBFrhAdthpM8bVdDvjWQA7pWP7QoNwEl1+dJ7WVJj81AQP/i6kl6JUmAk7tg3Og==", + "dev": true + }, "node_modules/rxjs": { "version": "6.6.7", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", @@ -14777,6 +14779,51 @@ "is-typedarray": "^1.0.0" } }, + "node_modules/typedoc": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.25.0.tgz", + "integrity": "sha512-FvCYWhO1n5jACE0C32qg6b3dSfQ8f2VzExnnRboowHtqUD6ARzM2r8YJeZFYXhcm2hI4C2oCRDgNPk/yaQUN9g==", + "dev": true, + "dependencies": { + "lunr": "^2.3.9", + "marked": "^4.3.0", + "minimatch": "^9.0.3", + "shiki": "^0.14.1" + }, + "bin": { + "typedoc": "bin/typedoc" + }, + "engines": { + "node": ">= 16" + }, + "peerDependencies": { + "typescript": "4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x || 5.1.x || 5.2.x" + } + }, + "node_modules/typedoc/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/typedoc/node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/typeforce": { "version": "1.18.0", "resolved": "https://registry.npmjs.org/typeforce/-/typeforce-1.18.0.tgz", @@ -15713,7 +15760,6 @@ "version": "1.11.0", "license": "ISC", "dependencies": { - "bignumber.js": "^9.0.0", "buffer": "6.0.3", "create-hash": "^1.2.0", "ripple-address-codec": "^4.3.1" @@ -15762,6 +15808,7 @@ "karma-webpack": "^5.0.0", "lodash": "^4.17.4", "react": "^18.2.0", + "run-s": "^0.0.0", "typedoc": "0.25.0" }, "engines": { @@ -18954,6 +19001,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", + "dev": true, "requires": { "call-bind": "^1.0.2", "is-nan": "^1.3.2", @@ -20714,12 +20762,6 @@ "is-symbol": "^1.0.2" } }, - "es6-object-assign": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/es6-object-assign/-/es6-object-assign-1.1.0.tgz", - "integrity": "sha512-MEl9uirslVwqQU369iHNWZXsI8yaZYGg/D65aOgZkeyFJwHYSxilf7rQzXKI7DdDuBPrBXbfk3sl9hJhmd5AUw==", - "dev": true - }, "escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", @@ -24921,6 +24963,7 @@ "version": "4.1.4", "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.4.tgz", "integrity": "sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==", + "dev": true, "requires": { "call-bind": "^1.0.2", "define-properties": "^1.1.4", @@ -26132,7 +26175,6 @@ "version": "file:packages/ripple-binary-codec", "requires": { "assert": "^2.0.0", - "bignumber.js": "^9.0.0", "buffer": "6.0.3", "create-hash": "^1.2.0", "ripple-address-codec": "^4.3.1" @@ -26163,6 +26205,12 @@ "queue-microtask": "^1.2.2" } }, + "run-s": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/run-s/-/run-s-0.0.0.tgz", + "integrity": "sha512-KPDNauF2Tpnm3nG0+0LJuJxwBFrhAdthpM8bVdDvjWQA7pWP7QoNwEl1+dJ7WVJj81AQP/i6kl6JUmAk7tg3Og==", + "dev": true + }, "rxjs": { "version": "6.6.7", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", @@ -27314,6 +27362,38 @@ "is-typedarray": "^1.0.0" } }, + "typedoc": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.25.0.tgz", + "integrity": "sha512-FvCYWhO1n5jACE0C32qg6b3dSfQ8f2VzExnnRboowHtqUD6ARzM2r8YJeZFYXhcm2hI4C2oCRDgNPk/yaQUN9g==", + "dev": true, + "requires": { + "lunr": "^2.3.9", + "marked": "^4.3.0", + "minimatch": "^9.0.3", + "shiki": "^0.14.1" + }, + "dependencies": { + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0" + } + }, + "minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "requires": { + "brace-expansion": "^2.0.1" + } + } + } + }, "typeforce": { "version": "1.18.0", "resolved": "https://registry.npmjs.org/typeforce/-/typeforce-1.18.0.tgz", @@ -27932,6 +28012,7 @@ "ripple-address-codec": "^4.3.1", "ripple-binary-codec": "^1.11.0", "ripple-keypairs": "^1.3.1", + "run-s": "^0.0.0", "typedoc": "0.25.0", "ws": "^8.2.2", "xrpl-secret-numbers": "^0.3.3" diff --git a/packages/xrpl/package.json b/packages/xrpl/package.json index cda63e0218..6883dc436b 100644 --- a/packages/xrpl/package.json +++ b/packages/xrpl/package.json @@ -33,6 +33,7 @@ "xrpl-secret-numbers": "^0.3.3" }, "devDependencies": { + "@types/node": "^16.18.38", "https-proxy-agent": "^7.0.1", "karma": "^6.4.1", @@ -40,6 +41,7 @@ "karma-jasmine": "^5.1.0", "karma-webpack": "^5.0.0", "lodash": "^4.17.4", + "run-s": "^0.0.0", "react": "^18.2.0", "typedoc": "0.25.0" }, diff --git a/packages/xrpl/snippets/src/getTransaction.ts b/packages/xrpl/snippets/src/getTransaction.ts index 11bfe1b865..777a9291e4 100644 --- a/packages/xrpl/snippets/src/getTransaction.ts +++ b/packages/xrpl/snippets/src/getTransaction.ts @@ -1,10 +1,10 @@ -import { Client, LedgerResponse, TxResponse } from '../../src' +import { Client } from '../../src' const client = new Client('wss://s2.ripple.com:51233') async function getTransaction(): Promise { await client.connect() - const ledger: LedgerResponse = await client.request({ + const ledger = await client.request({ command: 'ledger', transactions: true, ledger_index: 'validated', @@ -12,8 +12,8 @@ async function getTransaction(): Promise { console.log(ledger) const transactions = ledger.result.ledger.transactions - if (transactions) { - const tx: TxResponse = await client.request({ + if (transactions && transactions.length > 0) { + const tx = await client.request({ command: 'tx', transaction: transactions[0], }) diff --git a/packages/xrpl/snippets/src/paths.ts b/packages/xrpl/snippets/src/paths.ts index 444a79ca48..cd16273a42 100644 --- a/packages/xrpl/snippets/src/paths.ts +++ b/packages/xrpl/snippets/src/paths.ts @@ -1,4 +1,4 @@ -import { Client, Payment, RipplePathFindResponse } from '../../src' +import { Client, Payment } from '../../src' const client = new Client('wss://s.altnet.rippletest.net:51233') @@ -15,7 +15,8 @@ async function createTxWithPaths(): Promise { issuer: 'rVnYNK9yuxBz4uP8zC8LEFokM2nqH3poc', } - const request = { + const resp = await client.request({ + // TOOD: Replace with path_find - https://github.com/XRPLF/xrpl.js/issues/2385 command: 'ripple_path_find', source_account: wallet.classicAddress, source_currencies: [ @@ -25,9 +26,7 @@ async function createTxWithPaths(): Promise { ], destination_account, destination_amount, - } - - const resp: RipplePathFindResponse = await client.request(request) + }) console.log(resp) const paths = resp.result.alternatives[0].paths_computed diff --git a/packages/xrpl/src/Wallet/fundWallet.ts b/packages/xrpl/src/Wallet/fundWallet.ts index 6c7fd981f1..f685d1a4ab 100644 --- a/packages/xrpl/src/Wallet/fundWallet.ts +++ b/packages/xrpl/src/Wallet/fundWallet.ts @@ -2,7 +2,7 @@ import fetch from 'cross-fetch' import { isValidClassicAddress } from 'ripple-address-codec' import type { Client } from '../client' -import { RippledError, XRPLFaucetError } from '../errors' +import { XRPLFaucetError } from '../errors' import { FaucetWallet, @@ -45,107 +45,97 @@ export interface FundingOptions { usageContext?: string } -interface FaucetRequestBody { +/** + * Parameters to pass into a faucet request to fund an XRP account. + */ +export interface FaucetRequestBody { + /** + * The address to fund. If no address is provided the faucet will fund a random account. + */ destination?: string + /** + * The total amount of XRP to fund the account with. + */ xrpAmount?: string + /** + * An optional field to indicate the use case context of the faucet transaction + * Ex: integration test, code snippets. + */ usageContext?: string + /** + * Information about the context of where the faucet is being called from. + * Ex: xrpl.js or xrpl-py + */ userAgent: string } + /** - * The fundWallet() method is used to send an amount of XRP (usually 1000) to a new (randomly generated) - * or existing XRP Ledger wallet. - * - * @example - * - * Example 1: Fund a randomly generated wallet - * const { Client, Wallet } = require('xrpl') - * - * const client = new Client('wss://s.altnet.rippletest.net:51233') - * await client.connect() - * const { balance, wallet } = await client.fundWallet() - * - * Under the hood, this will use `Wallet.generate()` to create a new random wallet, then ask a testnet faucet - * To send it XRP on ledger to make it a real account. If successful, this will return the new account balance in XRP - * Along with the Wallet object to track the keys for that account. If you'd like, you can also re-fill an existing - * Account by passing in a Wallet you already have. - * ```ts - * const api = new xrpl.Client("wss://s.altnet.rippletest.net:51233") - * await api.connect() - * const { wallet, balance } = await api.fundWallet() - * ``` - * - * Example 2: Fund wallet using a custom faucet host and known wallet address + * Generate a new wallet to fund if no existing wallet is provided or its address is invalid. * - * `fundWallet` will try to infer the url of a faucet API from the network your client is connected to. - * There are hardcoded default faucets for popular test networks like testnet and devnet. - * However, if you're working with a newer or more obscure network, you may have to specify the faucetHost - * And faucetPath so `fundWallet` can ask that faucet to fund your wallet. - * - * ```ts - * const newWallet = Wallet.generate() - * const { balance, wallet } = await client.fundWallet(newWallet, { - * amount: '10', - * faucetHost: 'https://custom-faucet.example.com', - * faucetPath: '/accounts' - * }) - * console.log(`Sent 10 XRP to wallet: ${address} from the given faucet. Resulting balance: ${balance} XRP`) - * } catch (error) { - * console.error(`Failed to fund wallet: ${error}`) - * } - * } - * ``` - * - * @param this - Client. - * @param wallet - An existing XRPL Wallet to fund. If undefined or null, a new Wallet will be created. - * @param options - FundingOptions - - * @returns A Wallet on the Testnet or Devnet that contains some amount of XRP, - * and that wallet's balance in XRP. - * @throws When either Client isn't connected or unable to fund wallet address. + * @param wallet - Optional existing wallet. + * @returns The wallet to fund. */ -async function fundWallet( - this: Client, - wallet?: Wallet | null, - options: FundingOptions = {}, -): Promise<{ - wallet: Wallet - balance: number -}> { - if (!this.isConnected()) { - throw new RippledError('Client not connected, cannot call faucet') - } - const existingWallet = Boolean(wallet) - - // Generate a new Wallet if no existing Wallet is provided or its address is invalid to fund - const walletToFund = - wallet && isValidClassicAddress(wallet.classicAddress) - ? wallet - : Wallet.generate() - - // Create the POST request body - const postBody: FaucetRequestBody = { - destination: walletToFund.classicAddress, - xrpAmount: options.amount, - usageContext: options.usageContext, - userAgent: 'xrpl.js', +export function generateWalletToFund(wallet?: Wallet | null): Wallet { + if (wallet && isValidClassicAddress(wallet.classicAddress)) { + return wallet } + return Wallet.generate() +} +/** + * Get the starting balance of the wallet. + * + * @param client - The client object. + * @param classicAddress - The classic address of the wallet. + * @returns The starting balance. + */ +export async function getStartingBalance( + client: Client, + classicAddress: string, +): Promise { let startingBalance = 0 - if (existingWallet) { - try { - startingBalance = Number( - await this.getXrpBalance(walletToFund.classicAddress), - ) - } catch { - /* startingBalance remains what it was previously */ - } + try { + startingBalance = Number(await client.getXrpBalance(classicAddress)) + } catch { + // startingBalance remains '0' } + return startingBalance +} - return requestFunding(options, this, startingBalance, walletToFund, postBody) +export interface FundWalletOptions { + faucetHost?: string + faucetPath?: string + amount?: string + usageContext?: string } +/** + * + * Helper function to request funding from a faucet. Should not be called directly from outside the xrpl.js library. + * + * @param options - See below + * @param options.faucetHost - A custom host for a faucet server. On devnet, + * testnet, AMM devnet, and HooksV3 testnet, `fundWallet` will + * attempt to determine the correct server automatically. In other environments, + * or if you would like to customize the faucet host in devnet or testnet, + * you should provide the host using this option. + * @param options.faucetPath - A custom path for a faucet server. On devnet, + * testnet, AMM devnet, and HooksV3 testnet, `fundWallet` will + * attempt to determine the correct path automatically. In other environments, + * or if you would like to customize the faucet path in devnet or testnet, + * you should provide the path using this option. + * Ex: client.fundWallet(null,{'faucet.altnet.rippletest.net', '/accounts'}) + * specifies a request to 'faucet.altnet.rippletest.net/accounts' to fund a new wallet. + * @param options.amount - A custom amount to fund, if undefined or null, the default amount will be 1000. + * @param client - A connection to the XRPL to send requests and transactions. + * @param startingBalance - The amount of XRP in the given walletToFund on ledger already. + * @param walletToFund - An existing XRPL Wallet to fund. + * @param postBody - The content to send the faucet to indicate which address to fund, how much to fund it, and + * where the request is coming from. + * @returns A promise that resolves to a funded wallet and the balance within it. + */ // eslint-disable-next-line max-params -- Helper function created for organizational purposes -async function requestFunding( +export async function requestFunding( options: FundingOptions, client: Client, startingBalance: number, @@ -291,5 +281,3 @@ async function getUpdatedBalance( }, INTERVAL_SECONDS * 1000) }) } - -export default fundWallet diff --git a/packages/xrpl/src/client/RequestManager.ts b/packages/xrpl/src/client/RequestManager.ts index 9e7edb04cc..f9cff40da4 100644 --- a/packages/xrpl/src/client/RequestManager.ts +++ b/packages/xrpl/src/client/RequestManager.ts @@ -4,9 +4,15 @@ import { TimeoutError, XrplError, } from '../errors' -import { Response } from '../models/methods' +import { Response, RequestResponseMap } from '../models/methods' import { BaseRequest, ErrorResponse } from '../models/methods/baseMethod' +interface PromiseEntry { + resolve: (value: T | PromiseLike) => void + reject: (value: Error) => void + timer: ReturnType +} + /** * 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 @@ -17,13 +23,31 @@ export default class RequestManager { private nextId = 0 private readonly promisesAwaitingResponse = new Map< string | number, - { - resolve: (value: Response | PromiseLike) => void - reject: (value: Error) => void - timer: ReturnType - } + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Necessary and typed wrapper in addPromise method + PromiseEntry >() + /** + * Adds a promise to the collection of promises awaiting response. Handles typing with generics. + * + * @template T The generic type parameter representing the resolved value type. + * @param newId - The identifier for the new promise. + * @param timer - The timer associated with the promise. + * @returns A promise that resolves to the specified generic type. + */ + public async addPromise>( + newId: string | number, + timer: ReturnType, + ): Promise { + return new Promise((resolve, reject) => { + this.promisesAwaitingResponse.set(newId, { + resolve, + reject, + timer, + }) + }) + } + /** * Successfully resolves a request. * @@ -87,10 +111,10 @@ export default class RequestManager { * @returns Request ID, new request form, and the promise for resolving the request. * @throws XrplError if request with the same ID is already pending. */ - public createRequest( - request: T, + public createRequest>( + request: R, timeout: number, - ): [string | number, string, Promise] { + ): [string | number, string, Promise] { let newId: string | number if (request.id == null) { newId = this.nextId @@ -129,11 +153,13 @@ export default class RequestManager { request, ) } - const newPromise = new Promise( - (resolve: (value: Response | PromiseLike) => void, reject) => { - this.promisesAwaitingResponse.set(newId, { resolve, reject, timer }) - }, - ) + const newPromise = new Promise((resolve, reject) => { + this.promisesAwaitingResponse.set(newId, { + resolve, + reject, + timer, + }) + }) return [newId, newRequest, newPromise] } diff --git a/packages/xrpl/src/client/connection.ts b/packages/xrpl/src/client/connection.ts index 91db47e020..6eba4bb222 100644 --- a/packages/xrpl/src/client/connection.ts +++ b/packages/xrpl/src/client/connection.ts @@ -10,6 +10,7 @@ import { ConnectionError, XrplError, } from '../errors' +import type { RequestResponseMap } from '../models' import { BaseRequest } from '../models/methods/baseMethod' import ConnectionManager from './ConnectionManager' @@ -295,17 +296,17 @@ export class Connection extends EventEmitter { * @returns The response from the rippled server. * @throws NotConnectedError if the Connection isn't connected to a server. */ - public async request( - request: T, + public async request>( + request: R, timeout?: number, - ): Promise { + ): Promise { if (!this.shouldBeConnected || this.ws == null) { throw new NotConnectedError(JSON.stringify(request), request) } - const [id, message, responsePromise] = this.requestManager.createRequest( - request, - timeout ?? this.config.timeout, - ) + const [id, message, responsePromise] = this.requestManager.createRequest< + R, + T + >(request, timeout ?? this.config.timeout) this.trace('send', message) websocketSendAsync(this.ws, message).catch((error) => { this.requestManager.reject(id, error) diff --git a/packages/xrpl/src/client/index.ts b/packages/xrpl/src/client/index.ts index a43ae2d88c..73c994b14e 100644 --- a/packages/xrpl/src/client/index.ts +++ b/packages/xrpl/src/client/index.ts @@ -1,117 +1,78 @@ /* eslint-disable jsdoc/require-jsdoc -- Request has many aliases, but they don't need unique docs */ -/* eslint-disable @typescript-eslint/member-ordering -- TODO: remove when instance methods aren't members */ + /* eslint-disable max-lines -- Client is a large file w/ lots of imports/exports */ import { EventEmitter } from 'events' -import { NotFoundError, ValidationError, XrplError } from '../errors' +import { + RippledError, + NotFoundError, + ValidationError, + XrplError, +} from '../errors' +import type { LedgerIndex, Balance } from '../models/common' import { Request, - Response, // account methods AccountChannelsRequest, AccountChannelsResponse, - AccountCurrenciesRequest, - AccountCurrenciesResponse, AccountInfoRequest, - AccountInfoResponse, AccountLinesRequest, AccountLinesResponse, - AccountNFTsRequest, - AccountNFTsResponse, AccountObjectsRequest, AccountObjectsResponse, AccountOffersRequest, AccountOffersResponse, AccountTxRequest, AccountTxResponse, - GatewayBalancesRequest, - GatewayBalancesResponse, - NoRippleCheckRequest, - NoRippleCheckResponse, // ledger methods - LedgerRequest, - LedgerResponse, - LedgerClosedRequest, - LedgerClosedResponse, - LedgerCurrentRequest, - LedgerCurrentResponse, LedgerDataRequest, LedgerDataResponse, - LedgerEntryRequest, - LedgerEntryResponse, - // transaction methods - SubmitRequest, - SubmitResponse, - SubmitMultisignedRequest, - SubmitMultisignedResponse, - TransactionEntryRequest, - TransactionEntryResponse, - TxRequest, TxResponse, - // path and order book methods - BookOffersRequest, - BookOffersResponse, - DepositAuthorizedRequest, - DepositAuthorizedResponse, - PathFindRequest, - PathFindResponse, - RipplePathFindRequest, - RipplePathFindResponse, - // payment channel methods - ChannelVerifyRequest, - ChannelVerifyResponse, - // server info methods - FeeRequest, - FeeResponse, - ManifestRequest, - ManifestResponse, - ServerInfoRequest, - ServerInfoResponse, - ServerStateRequest, - ServerStateResponse, - // utility methods - PingRequest, - PingResponse, - RandomRequest, - RandomResponse, - LedgerStream, - ValidationStream, - TransactionStream, - PathFindStream, - PeerStatusStream, - ConsensusStream, - SubscribeRequest, - SubscribeResponse, - UnsubscribeRequest, - UnsubscribeResponse, - // NFT methods - NFTBuyOffersRequest, - NFTBuyOffersResponse, - NFTSellOffersRequest, - NFTSellOffersResponse, - // clio only methods - NFTInfoRequest, - NFTInfoResponse, - NFTHistoryRequest, - NFTHistoryResponse, - // AMM methods - AMMInfoRequest, - AMMInfoResponse, - ServerDefinitionsRequest, - ServerDefinitionsResponse, } from '../models/methods' -import { BaseRequest, BaseResponse } from '../models/methods/baseMethod' +import type { + RequestResponseMap, + RequestAllResponseMap, + MarkerRequest, + MarkerResponse, + SubmitResponse, +} from '../models/methods' +import type { BookOffer, BookOfferCurrency } from '../models/methods/bookOffers' +import type { OnEventToListenerMap } from '../models/methods/subscribe' +import type { Transaction } from '../models/transactions' +import { setTransactionFlagsToNumber } from '../models/utils/flags' import { - autofill, ensureClassicAddress, - getLedgerIndex, - getOrderbook, - getBalances, - getXrpBalance, - submit, - submitAndWait, + submitRequest, + getSignedTx, + getLastLedgerSequence, + waitForFinalTransactionOutcome, } from '../sugar' -import fundWallet from '../Wallet/fundWallet' +import { + setValidAddresses, + setNextValidSequenceNumber, + calculateFeePerTransactionType, + setLatestValidatedLedgerSequence, + checkAccountDeleteBlockers, + txNeedsNetworkID, +} from '../sugar/autofill' +import { formatBalances } from '../sugar/balances' +import { + validateOrderbookOptions, + createBookOffersRequest, + requestAllOffers, + reverseRequest, + extractOffers, + combineOrders, + separateBuySellOrders, + sortAndLimitOffers, +} from '../sugar/getOrderbook' +import { dropsToXrp, hashes, isValidClassicAddress } from '../utils' +import { Wallet } from '../Wallet' +import { + type FaucetRequestBody, + FundingOptions, + requestFunding, +} from '../Wallet/fundWallet' import { Connection, @@ -129,6 +90,29 @@ export interface ClientOptions extends ConnectionUserOptions { timeout?: number } +// Make sure to update both this and `RequestNextPageReturnMap` at the same time +type RequestNextPageType = + | AccountChannelsRequest + | AccountLinesRequest + | AccountObjectsRequest + | AccountOffersRequest + | AccountTxRequest + | LedgerDataRequest + +type RequestNextPageReturnMap = T extends AccountChannelsRequest + ? AccountChannelsResponse + : T extends AccountLinesRequest + ? AccountLinesResponse + : T extends AccountObjectsRequest + ? AccountObjectsResponse + : T extends AccountOffersRequest + ? AccountOffersResponse + : T extends AccountTxRequest + ? AccountTxResponse + : T extends LedgerDataRequest + ? LedgerDataResponse + : never + /** * Get the response key / property name that contains the listed data for a * command. This varies from command to command, but we need to know it to @@ -164,17 +148,6 @@ function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max) } -interface MarkerRequest extends BaseRequest { - limit?: number - marker?: unknown -} - -interface MarkerResponse extends BaseResponse { - result: { - marker?: unknown - } -} - const DEFAULT_FEE_CUSHION = 1.2 const DEFAULT_MAX_FEE_XRP = '2' @@ -228,6 +201,12 @@ class Client extends EventEmitter { * @param server - URL of the server to connect to. * @param options - Options for client settings. * @category Constructor + * + * @example + * ```ts + * import { Client } from "xrpl" + * const client = new Client('wss://s.altnet.rippletest.net:51233') + * ``` */ // eslint-disable-next-line max-lines-per-function -- okay because we have to set up all the connection handlers public constructor(server: string, options: ClientOptions = {}) { @@ -304,87 +283,34 @@ class Client extends EventEmitter { return this.connection.getUrl() } - /** - * @category Network - */ - public async request( - r: AccountChannelsRequest, - ): Promise - public async request( - r: AccountCurrenciesRequest, - ): Promise - public async request(r: AccountInfoRequest): Promise - public async request(r: AccountLinesRequest): Promise - public async request(r: AccountNFTsRequest): Promise - public async request( - r: AccountObjectsRequest, - ): Promise - public async request(r: AccountOffersRequest): Promise - public async request(r: AccountTxRequest): Promise - public async request(r: AMMInfoRequest): Promise - public async request(r: BookOffersRequest): Promise - public async request(r: ChannelVerifyRequest): Promise - public async request( - r: DepositAuthorizedRequest, - ): Promise - public async request(r: FeeRequest): Promise - public async request( - r: GatewayBalancesRequest, - ): Promise - public async request(r: LedgerRequest): Promise - public async request(r: LedgerClosedRequest): Promise - public async request(r: LedgerCurrentRequest): Promise - public async request(r: LedgerDataRequest): Promise - public async request(r: LedgerEntryRequest): Promise - public async request(r: ManifestRequest): Promise - public async request(r: NFTBuyOffersRequest): Promise - public async request(r: NFTSellOffersRequest): Promise - public async request(r: NFTInfoRequest): Promise - public async request(r: NFTHistoryRequest): Promise - public async request(r: NoRippleCheckRequest): Promise - public async request(r: PathFindRequest): Promise - public async request(r: PingRequest): Promise - public async request(r: RandomRequest): Promise - public async request( - r: RipplePathFindRequest, - ): Promise - public async request( - r: ServerDefinitionsRequest, - ): Promise - public async request(r: ServerInfoRequest): Promise - public async request(r: ServerStateRequest): Promise - public async request(r: SubmitRequest): Promise - public async request( - r: SubmitMultisignedRequest, - ): Promise - public request(r: SubscribeRequest): Promise - public request(r: UnsubscribeRequest): Promise - public async request( - r: TransactionEntryRequest, - ): Promise - public async request(r: TxRequest): Promise - public async request( - r: R, - ): Promise /** * Makes a request to the client with the given command and * additional request body parameters. * + * @category Network + * * @param req - Request to send to the server. * @returns The response from the server. - * @category Network + * + * @example + * ```ts + * const response = await client.request({ + * command: 'account_info', + * account: 'r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59', + * }) + * console.log(response) + * ``` */ - public async request( + public async request>( req: R, ): Promise { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Necessary for overloading - const response = (await this.connection.request({ + const response = await 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 T + }) // mutates `response` to add warnings handlePartialPayment(req.command, response) @@ -392,44 +318,34 @@ class Client extends EventEmitter { return response } - /** - * @category Network - */ - public async requestNextPage( - req: AccountChannelsRequest, - resp: AccountChannelsResponse, - ): Promise - public async requestNextPage( - req: AccountLinesRequest, - resp: AccountLinesResponse, - ): Promise - public async requestNextPage( - req: AccountObjectsRequest, - resp: AccountObjectsResponse, - ): Promise - public async requestNextPage( - req: AccountOffersRequest, - resp: AccountOffersResponse, - ): Promise - public async requestNextPage( - req: AccountTxRequest, - resp: AccountTxResponse, - ): Promise - public async requestNextPage( - req: LedgerDataRequest, - resp: LedgerDataResponse, - ): Promise /** * Requests the next page of data. * + * @category Network + * * @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. + * + * @example + * ```ts + * const response = await client.request({ + * command: 'account_tx', + * account: 'r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59', + * }) + * console.log(response) + * const nextResponse = await client.requestNextPage({ + * command: 'account_tx', + * account: 'r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59', + * }, + * response) + * console.log(nextResponse) + * ``` */ public async requestNextPage< - T extends MarkerRequest, - U extends MarkerResponse, - >(req: T, resp: U): Promise { + T extends RequestNextPageType, + U extends RequestNextPageReturnMap, + >(req: T, resp: U): Promise> { if (!resp.result.marker) { return Promise.reject( new NotFoundError('response does not have a next page'), @@ -443,7 +359,13 @@ class Client extends EventEmitter { /** * Event handler for subscription streams. * - * @example + * @category Network + * + * @param eventName - Name of the event. Only forwards streams. + * @param listener - Function to run on event. + * @returns This, because it inherits from EventEmitter. + * + * * @example * ```ts * const api = new Client('wss://s.altnet.rippletest.net:51233') * @@ -458,68 +380,15 @@ class Client extends EventEmitter { * streams: ['transactions_proposed'] * }) * ``` - * - * @category Network - */ - public on(event: 'connected', listener: () => void): this - public on(event: 'disconnected', listener: (code: number) => void): this - public on( - event: 'ledgerClosed', - listener: (ledger: LedgerStream) => void, - ): this - public on( - event: 'validationReceived', - listener: (validation: ValidationStream) => void, - ): this - public on( - event: 'transaction', - listener: (tx: TransactionStream) => void, - ): this - public on( - event: 'peerStatusChange', - listener: (status: PeerStatusStream) => void, - ): this - public on( - event: 'consensusPhase', - listener: (phase: ConsensusStream) => void, - ): this - public on( - event: 'manifestReceived', - listener: (manifest: ManifestResponse) => void, - ): this - public on(event: 'path_find', listener: (path: PathFindStream) => void): this - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- needs to be any for overload - public on(event: 'error', listener: (...err: any[]) => void): this - /** - * Event handler for subscription streams. - * - * @param eventName - Name of the event. Only forwards streams. - * @param listener - Function to run on event. - * @returns This, because it inherits from EventEmitter. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- needs to be any for overload - public on(eventName: string, listener: (...args: any[]) => void): this { - return super.on(eventName, listener) + public on>( + eventName: T, + listener: U, + ): this { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any -- Compatible + return super.on(eventName, listener as (...args: any[]) => void) } - /** - * @category Network - */ - public async requestAll( - req: AccountChannelsRequest, - ): Promise - public async requestAll( - req: AccountLinesRequest, - ): Promise - public async requestAll( - req: AccountObjectsRequest, - ): Promise - public async requestAll( - req: AccountOffersRequest, - ): Promise - public async requestAll(req: AccountTxRequest): Promise - public async requestAll(req: BookOffersRequest): Promise - public async requestAll(req: LedgerDataRequest): Promise /** * Makes multiple paged requests to the client to return a given number of * resources. Multiple paged requests will be made until the `limit` @@ -533,15 +402,27 @@ class Client extends EventEmitter { * general use. Instead, use rippled's built-in pagination and make multiple * requests as needed. * + * @category Network + * * @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). + * + * @example + * // Request all ledger data pages + * const allResponses = await client.requestAll({ command: 'ledger_data' }); + * console.log(allResponses); + * + * @example + * // Request all transaction data pages + * const allResponses = await client.requestAll({ command: 'transaction_data' }); + * console.log(allResponses); */ - public async requestAll( - request: T, - collect?: string, - ): Promise { + public async requestAll< + T extends MarkerRequest, + U = RequestAllResponseMap, + >(request: T, collect?: string): Promise { /* * The data under collection is keyed based on the command. Fail if command * not recognized and collection key not provided. @@ -569,7 +450,7 @@ class Client extends EventEmitter { // eslint-disable-next-line no-await-in-loop -- Necessary for this, it really has to wait const singleResponse = await this.connection.request(repeatProps) // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Should be true - const singleResult = (singleResponse as U).result + const singleResult = (singleResponse as MarkerResponse).result if (!(collectKey in singleResult)) { throw new XrplError(`${collectKey} not in result`) } @@ -591,6 +472,16 @@ class Client extends EventEmitter { /** * Get networkID and buildVersion from server_info + * + * @returns void + * @example + * ```ts + * const { Client } = require('xrpl') + * const client = new Client('wss://s.altnet.rippletest.net:51233') + * await client.getServerInfo() + * console.log(client.networkID) + * console.log(client.buildVersion) + * ``` */ public async getServerInfo(): Promise { try { @@ -623,6 +514,15 @@ class Client extends EventEmitter { * before exiting your application. * @returns A promise that resolves with a void value when a connection is established. * @category Network + * + * @example + * ```ts + * const { Client } = require('xrpl') + * const client = new Client('wss://s.altnet.rippletest.net:51233') + * await client.connect() + * // do something with the client + * await client.disconnect() + * ``` */ public async connect(): Promise { return this.connection.connect().then(async () => { @@ -664,54 +564,581 @@ class Client extends EventEmitter { * * @returns Whether the client instance is connected. * @category Network + * @example + * ```ts + * const { Client } = require('xrpl') + * const client = new Client('wss://s.altnet.rippletest.net:51233') + * await client.connect() + * console.log(client.isConnected()) + * // true + * await client.disconnect() + * console.log(client.isConnected()) + * // false + * ``` */ public isConnected(): boolean { return this.connection.isConnected() } /** + * Autofills fields in a transaction. This will set `Sequence`, `Fee`, + * `lastLedgerSequence` according to the current state of the server this Client + * is connected to. It also converts all X-Addresses to classic addresses and + * flags interfaces into numbers. + * * @category Core + * + * @example + * + * ```ts + * const { Client } = require('xrpl') + * + * const client = new Client('wss://s.altnet.rippletest.net:51233') + * + * async function createAndAutofillTransaction() { + * const transaction = { + * TransactionType: 'Payment', + * Account: 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh', + * Destination: 'r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59', + * Amount: '10000000' // 10 XRP in drops (1/1,000,000th of an XRP) + * } + * + * try { + * const autofilledTransaction = await client.autofill(transaction) + * console.log(autofilledTransaction) + * } catch (error) { + * console.error(`Failed to autofill transaction: ${error}`) + * } + * } + * + * createAndAutofillTransaction() + * ``` + * + * Autofill helps fill in fields which should be included in a transaction, but can be determined automatically + * such as `LastLedgerSequence` and `Fee`. If you override one of the fields `autofill` changes, your explicit + * values will be used instead. By default, this is done as part of `submit` and `submitAndWait` when you pass + * in an unsigned transaction along with your wallet to be submitted. + * + * @template T + * @param transaction - A {@link Transaction} in JSON format + * @param signersCount - The expected number of signers for this transaction. + * Only used for multisigned transactions. + * @returns The autofilled transaction. */ - public autofill = autofill + public async autofill( + transaction: T, + signersCount?: number, + ): Promise { + const tx = { ...transaction } + + setValidAddresses(tx) + + setTransactionFlagsToNumber(tx) + + const promises: Array> = [] + if (tx.NetworkID == null) { + tx.NetworkID = txNeedsNetworkID(this) ? this.networkID : undefined + } + if (tx.Sequence == null) { + promises.push(setNextValidSequenceNumber(this, tx)) + } + if (tx.Fee == null) { + promises.push(calculateFeePerTransactionType(this, tx, signersCount)) + } + if (tx.LastLedgerSequence == null) { + promises.push(setLatestValidatedLedgerSequence(this, tx)) + } + if (tx.TransactionType === 'AccountDelete') { + promises.push(checkAccountDeleteBlockers(this, tx)) + } + + return Promise.all(promises).then(() => tx) + } /** + * Submits a signed/unsigned transaction. + * Steps performed on a transaction: + * 1. Autofill. + * 2. Sign & Encode. + * 3. Submit. + * * @category Core + * + * @param transaction - A transaction to autofill, sign & encode, and submit. + * @param opts - (Optional) Options used to sign and submit a transaction. + * @param opts.autofill - If true, autofill a transaction. + * @param opts.failHard - If true, and the transaction fails locally, do not retry or relay the transaction to other servers. + * @param opts.wallet - A wallet to sign a transaction. It must be provided when submitting an unsigned transaction. + * + * @returns A promise that contains SubmitResponse. + * @throws RippledError if submit request fails. + * + * @example + * ```ts + * const { Client, Wallet } = require('xrpl') + * const client = new Client('wss://s.altnet.rippletest.net:51233') + * await client.connect() + * const wallet = Wallet.generate() + * const transaction = { + * TransactionType: 'Payment', + * Account: wallet.classicAddress, + * Destination: 'r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59', + * Amount: '10000000' // 10 XRP in drops (1/1,000,000th of an XRP) + * } + * const submitResponse = await client.submit(transaction, { wallet }) + * console.log(submitResponse) + * ``` */ - public submit = submit + public async submit( + transaction: Transaction | string, + opts?: { + // If true, autofill a transaction. + autofill?: boolean + // If true, and the transaction fails locally, do not retry or relay the transaction to other servers. + failHard?: boolean + // A wallet to sign a transaction. It must be provided when submitting an unsigned transaction. + wallet?: Wallet + }, + ): Promise { + const signedTx = await getSignedTx(this, transaction, opts) + return submitRequest(this, signedTx, opts?.failHard) + } + /** + * Asynchronously submits a transaction and verifies that it has been included in a + * validated ledger (or has errored/will not be included for some reason). + * See [Reliable Transaction Submission](https://xrpl.org/reliable-transaction-submission.html). + * * @category Core + * + * @example + * + * ```ts + * const { Client, Wallet } = require('xrpl') + * const client = new Client('wss://s.altnet.rippletest.net:51233') + * + * async function submitTransaction() { + * const senderWallet = client.fundWallet() + * const recipientWallet = client.fundWallet() + * + * const transaction = { + * TransactionType: 'Payment', + * Account: senderWallet.address, + * Destination: recipientWallet.address, + * Amount: '10' + * } + * + * try { + * await client.submit(signedTransaction, { wallet: senderWallet }) + * console.log(result) + * } catch (error) { + * console.error(`Failed to submit transaction: ${error}`) + * } + * } + * + * submitTransaction() + * ``` + * + * In this example we submit a payment transaction between two newly created testnet accounts. + * + * Under the hood, `submit` will call `client.autofill` by default, and because we've passed in a `Wallet` it + * Will also sign the transaction for us before submitting the signed transaction binary blob to the ledger. + * + * This is similar to `submitAndWait` which does all of the above, but also waits to see if the transaction has been validated. + * @param transaction - A transaction to autofill, sign & encode, and submit. + * @param opts - (Optional) Options used to sign and submit a transaction. + * @param opts.autofill - If true, autofill a transaction. + * @param opts.failHard - If true, and the transaction fails locally, do not retry or relay the transaction to other servers. + * @param opts.wallet - A wallet to sign a transaction. It must be provided when submitting an unsigned transaction. + * @throws Connection errors: If the `Client` object is unable to establish a connection to the specified WebSocket endpoint, + * an error will be thrown. + * @throws Transaction errors: If the submitted transaction is invalid or cannot be included in a validated ledger for any + * reason, the promise returned by `submitAndWait()` will be rejected with an error. This could include issues with insufficient + * balance, invalid transaction fields, or other issues specific to the transaction being submitted. + * @throws Ledger errors: If the ledger being used to submit the transaction is undergoing maintenance or otherwise unavailable, + * an error will be thrown. + * @throws Timeout errors: If the transaction takes longer than the specified timeout period to be included in a validated + * ledger, the promise returned by `submitAndWait()` will be rejected with an error. + * @returns A promise that contains TxResponse, that will return when the transaction has been validated. */ - public submitAndWait = submitAndWait + public async submitAndWait( + transaction: T | string, + opts?: { + // If true, autofill a transaction. + autofill?: boolean + // If true, and the transaction fails locally, do not retry or relay the transaction to other servers. + failHard?: boolean + // A wallet to sign a transaction. It must be provided when submitting an unsigned transaction. + wallet?: Wallet + }, + ): Promise> { + const signedTx = await getSignedTx(this, transaction, opts) + + const lastLedger = getLastLedgerSequence(signedTx) + if (lastLedger == null) { + throw new ValidationError( + 'Transaction must contain a LastLedgerSequence value for reliable submission.', + ) + } + + const response = await submitRequest(this, signedTx, opts?.failHard) + + const txHash = hashes.hashSignedTx(signedTx) + return waitForFinalTransactionOutcome( + this, + txHash, + lastLedger, + response.result.engine_result, + ) + } /** + * Deprecated: Use autofill instead, provided for users familiar with v1 + * + * @param transaction - A {@link Transaction} in JSON format + * @param signersCount - The expected number of signers for this transaction. + * Only used for multisigned transactions. * @deprecated Use autofill instead, provided for users familiar with v1 */ - public prepareTransaction = autofill + public async prepareTransaction( + transaction: Transaction, + signersCount?: number, + ): ReturnType { + return this.autofill(transaction, signersCount) + } /** + * Retrieves the XRP balance of a given account address. + * * @category Abstraction + * + * @example + * ```ts + * const client = new Client(wss://s.altnet.rippletest.net:51233) + * await client.connect() + * const balance = await client.getXrpBalance('rG1QQv2nh2gr7RCZ1P8YYcBUKCCN633jCn') + * console.log(balance) + * await client.disconnect() + * /// '200' + * ``` + * + * @param address - The XRP address to retrieve the balance for. + * @param [options] - Additional options for fetching the balance (optional). + * @param [options.ledger_hash] - The hash of the ledger to retrieve the balance from (optional). + * @param [options.ledger_index] - The index of the ledger to retrieve the balance from (optional). + * @returns A promise that resolves with the XRP balance as a string. */ - public getXrpBalance = getXrpBalance + public async getXrpBalance( + address: string, + options: { + ledger_hash?: string + ledger_index?: LedgerIndex + } = {}, + ): Promise { + const xrpRequest: AccountInfoRequest = { + command: 'account_info', + account: address, + ledger_index: options.ledger_index ?? 'validated', + ledger_hash: options.ledger_hash, + } + const response = await this.request(xrpRequest) + return dropsToXrp(response.result.account_data.Balance) + } /** + * Get XRP/non-XRP balances for an account. + * * @category Abstraction + * + * @example + * ```ts + * const { Client } = require('xrpl') + * const client = new Client('wss://s.altnet.rippletest.net:51233') + * await client.connect() + * + * async function getAccountBalances(address) { + * try { + * const options = { + * ledger_index: 'validated', + * limit: 10 + * }; + * + * const balances = await xrplClient.getBalances(address, options); + * + * console.log('Account Balances:'); + * balances.forEach((balance) => { + * console.log(`Currency: ${balance.currency}`); + * console.log(`Value: ${balance.value}`); + * console.log(`Issuer: ${balance.issuer}`); + * console.log('---'); + * }); + * } catch (error) { + * console.error('Error retrieving account balances:', error); + * } + * } + * + * const address = 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh'; + * await getAccountBalances(address); + * await client.disconnect(); + * ``` + * + * @param address - Address of the account to retrieve balances for. + * @param options - Allows the client to specify a ledger_hash, ledger_index, + * filter by peer, and/or limit number of balances. + * @param options.ledger_index - Retrieve the account balances at a given + * ledger_index. + * @param options.ledger_hash - Retrieve the account balances at the ledger with + * a given ledger_hash. + * @param options.peer - Filter balances by peer. + * @param options.limit - Limit number of balances to return. + * @returns An array of XRP/non-XRP balances for the given account. */ - public getBalances = getBalances + // eslint-disable-next-line max-lines-per-function -- Longer definition is required for end users to see the definition. + public async getBalances( + address: string, + options: { + ledger_hash?: string + ledger_index?: LedgerIndex + peer?: string + limit?: number + } = {}, + ): Promise< + Array<{ value: string; currency: string; issuer?: string | undefined }> + > { + const balances: Balance[] = [] + + // get XRP balance + let xrpPromise: Promise = Promise.resolve('') + if (!options.peer) { + xrpPromise = this.getXrpBalance(address, { + ledger_hash: options.ledger_hash, + ledger_index: options.ledger_index, + }) + } + + // get non-XRP balances + const linesRequest: AccountLinesRequest = { + command: 'account_lines', + account: address, + ledger_index: options.ledger_index ?? 'validated', + ledger_hash: options.ledger_hash, + peer: options.peer, + limit: options.limit, + } + const linesPromise = this.requestAll(linesRequest) + + // combine results + await Promise.all([xrpPromise, linesPromise]).then( + ([xrpBalance, linesResponses]) => { + const accountLinesBalance = linesResponses.flatMap((response) => + formatBalances(response.result.lines), + ) + if (xrpBalance !== '') { + balances.push({ currency: 'XRP', value: xrpBalance }) + } + balances.push(...accountLinesBalance) + }, + ) + return balances.slice(0, options.limit) + } /** + * Fetch orderbook (buy/sell orders) between two currency pairs. This checks both sides of the orderbook + * by making two `order_book` requests (with the second reversing takerPays and takerGets). Returned offers are + * not normalized in this function, so either currency could be takerGets or takerPays. + * * @category Abstraction + * + * @param currency1 - Specification of one currency involved. (With a currency code and optionally an issuer) + * @param currency2 - Specification of a second currency involved. (With a currency code and optionally an issuer) + * @param options - Options allowing the client to specify ledger_index, + * ledger_hash, filter by taker, and/or limit number of orders. + * @param options.ledger_index - Retrieve the orderbook at a given ledger_index. + * @param options.ledger_hash - Retrieve the orderbook at the ledger with a + * given ledger_hash. + * @param options.taker - Filter orders by taker. + * @param options.limit - The limit passed into each book_offers request. + * Can return more than this due to two calls being made. Defaults to 20. + * @returns An object containing buy and sell objects. */ - public getOrderbook = getOrderbook + + public async getOrderbook( + currency1: BookOfferCurrency, + currency2: BookOfferCurrency, + options: { + limit?: number + ledger_index?: LedgerIndex + ledger_hash?: string | null + taker?: string | null + } = {}, + ): Promise<{ + buy: BookOffer[] + sell: BookOffer[] + }> { + validateOrderbookOptions(options) + + const request = createBookOffersRequest(currency1, currency2, options) + + const directOfferResults = await requestAllOffers(this, request) + const reverseOfferResults = await requestAllOffers( + this, + reverseRequest(request), + ) + + const directOffers = extractOffers(directOfferResults) + const reverseOffers = extractOffers(reverseOfferResults) + + const orders = combineOrders(directOffers, reverseOffers) + + const { buy, sell } = separateBuySellOrders(orders) + + /* + * Sort the orders + * for both buys and sells, lowest quality is closest to mid-market + * we sort the orders so that earlier orders are closer to mid-market + */ + return { + buy: sortAndLimitOffers(buy, options.limit), + sell: sortAndLimitOffers(sell, options.limit), + } + } /** + * Returns the index of the most recently validated ledger. + * * @category Abstraction + * + * @returns The most recently validated ledger index. + * + * @example + * ```ts + * const { Client } = require('xrpl') + * const client = new Client('wss://s.altnet.rippletest.net:51233') + * await client.connect() + * const ledgerIndex = await client.getLedgerIndex() + * console.log(ledgerIndex) + * // 884039 + * ``` */ - public getLedgerIndex = getLedgerIndex + public async getLedgerIndex(): Promise { + const ledgerResponse = await this.request({ + command: 'ledger', + ledger_index: 'validated', + }) + return ledgerResponse.result.ledger_index + } /** + * The fundWallet() method is used to send an amount of XRP (usually 1000) to a new (randomly generated) + * or existing XRP Ledger wallet. + * * @category Faucet + * + * @example + * + * Example 1: Fund a randomly generated wallet + * const { Client, Wallet } = require('xrpl') + * + * const client = new Client('wss://s.altnet.rippletest.net:51233') + * await client.connect() + * const { balance, wallet } = await client.fundWallet() + * + * Under the hood, this will use `Wallet.generate()` to create a new random wallet, then ask a testnet faucet + * To send it XRP on ledger to make it a real account. If successful, this will return the new account balance in XRP + * Along with the Wallet object to track the keys for that account. If you'd like, you can also re-fill an existing + * Account by passing in a Wallet you already have. + * ```ts + * const api = new xrpl.Client("wss://s.altnet.rippletest.net:51233") + * await api.connect() + * const { wallet, balance } = await api.fundWallet() + * ``` + * + * Example 2: Fund wallet using a custom faucet host and known wallet address + * + * `fundWallet` will try to infer the url of a faucet API from the network your client is connected to. + * There are hardcoded default faucets for popular test networks like testnet and devnet. + * However, if you're working with a newer or more obscure network, you may have to specify the faucetHost + * And faucetPath so `fundWallet` can ask that faucet to fund your wallet. + * + * ```ts + * const newWallet = Wallet.generate() + * const { balance, wallet } = await client.fundWallet(newWallet, { + * amount: '10', + * faucetHost: 'https://custom-faucet.example.com', + * faucetPath: '/accounts' + * }) + * console.log(`Sent 10 XRP to wallet: ${address} from the given faucet. Resulting balance: ${balance} XRP`) + * } catch (error) { + * console.error(`Failed to fund wallet: ${error}`) + * } + * } + * ``` + * + * @param wallet - An existing XRPL Wallet to fund. If undefined or null, a new Wallet will be created. + * @param options - See below. + * @param options.faucetHost - A custom host for a faucet server. On devnet, + * testnet, AMM devnet, and HooksV3 testnet, `fundWallet` will + * attempt to determine the correct server automatically. In other environments, + * or if you would like to customize the faucet host in devnet or testnet, + * you should provide the host using this option. + * @param options.faucetPath - A custom path for a faucet server. On devnet, + * testnet, AMM devnet, and HooksV3 testnet, `fundWallet` will + * attempt to determine the correct path automatically. In other environments, + * or if you would like to customize the faucet path in devnet or testnet, + * you should provide the path using this option. + * Ex: client.fundWallet(null,{'faucet.altnet.rippletest.net', '/accounts'}) + * specifies a request to 'faucet.altnet.rippletest.net/accounts' to fund a new wallet. + * @param options.amount - A custom amount to fund, if undefined or null, the default amount will be 1000. + * @returns A Wallet on the Testnet or Devnet that contains some amount of XRP, + * and that wallet's balance in XRP. + * @throws When either Client isn't connected or unable to fund wallet address. */ - public fundWallet = fundWallet + public async fundWallet( + this: Client, + wallet?: Wallet | null, + options: FundingOptions = {}, + ): Promise<{ + wallet: Wallet + balance: number + }> { + if (!this.isConnected()) { + throw new RippledError('Client not connected, cannot call faucet') + } + const existingWallet = Boolean(wallet) + + // Generate a new Wallet if no existing Wallet is provided or its address is invalid to fund + const walletToFund = + wallet && isValidClassicAddress(wallet.classicAddress) + ? wallet + : Wallet.generate() + + // Create the POST request body + const postBody: FaucetRequestBody = { + destination: walletToFund.classicAddress, + xrpAmount: options.amount, + usageContext: options.usageContext, + userAgent: 'xrpl.js', + } + + let startingBalance = 0 + if (existingWallet) { + try { + startingBalance = Number( + await this.getXrpBalance(walletToFund.classicAddress), + ) + } catch { + /* startingBalance remains what it was previously */ + } + } + + return requestFunding( + options, + this, + startingBalance, + walletToFund, + postBody, + ) + } } export { Client } diff --git a/packages/xrpl/src/client/partialPayment.ts b/packages/xrpl/src/client/partialPayment.ts index f765a0fca3..043211eb32 100644 --- a/packages/xrpl/src/client/partialPayment.ts +++ b/packages/xrpl/src/client/partialPayment.ts @@ -3,13 +3,13 @@ import { decode } from 'ripple-binary-codec' import type { AccountTxResponse, - Response, - ResponseWarning, TransactionEntryResponse, TransactionStream, TxResponse, } from '..' import type { Amount } from '../models/common' +import type { RequestResponseMap } from '../models/methods' +import { BaseRequest, BaseResponse } from '../models/methods/baseMethod' import { PaymentFlags, PseudoTransaction, @@ -90,7 +90,10 @@ function accountTxHasPartialPayment(response: AccountTxResponse): boolean { return foo } -function hasPartialPayment(command: string, response: Response): boolean { +function hasPartialPayment>( + command: string, + response: T, +): boolean { /* eslint-disable @typescript-eslint/consistent-type-assertions -- Request type is known at runtime from command */ switch (command) { case 'tx': @@ -111,12 +114,13 @@ function hasPartialPayment(command: string, response: Response): boolean { * @param command - Command from the request, tells us what response to expect. * @param response - Response to check for a partial payment. */ -export function handlePartialPayment( - command: string, - response: Response, -): void { +export function handlePartialPayment< + R extends BaseRequest, + T = RequestResponseMap, +>(command: string, response: T): void { if (hasPartialPayment(command, response)) { - const warnings: ResponseWarning[] = response.warnings ?? [] + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- We are checking dynamically and safely. + const warnings = (response as BaseResponse).warnings ?? [] const warning = { id: WARN_PARTIAL_PAYMENT_CODE, @@ -125,6 +129,8 @@ export function handlePartialPayment( warnings.push(warning) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment -- We are checking dynamically and safely. + // @ts-expect-error -- We are checking dynamically and safely. response.warnings = warnings } } diff --git a/packages/xrpl/src/index.ts b/packages/xrpl/src/index.ts index 168389ba98..fdf34b844b 100644 --- a/packages/xrpl/src/index.ts +++ b/packages/xrpl/src/index.ts @@ -8,7 +8,7 @@ export { default as ECDSA } from './ECDSA' export * from './errors' -export { default as fundWallet, FundingOptions } from './Wallet/fundWallet' +export { FundingOptions } from './Wallet/fundWallet' export { Wallet } from './Wallet' export { walletFromSecretNumbers } from './Wallet/walletFromSecretNumbers' diff --git a/packages/xrpl/src/models/ledger/Ledger.ts b/packages/xrpl/src/models/ledger/Ledger.ts index 605ff15a53..7ec8159ec9 100644 --- a/packages/xrpl/src/models/ledger/Ledger.ts +++ b/packages/xrpl/src/models/ledger/Ledger.ts @@ -15,7 +15,7 @@ import { LedgerEntry } from './LedgerEntry' export default interface Ledger { /** The SHA-512Half of this ledger's state tree information. */ account_hash: string - /** All the state information in this ledger. */ + /** All the state information in this ledger. Admin only. */ accountState?: LedgerEntry[] /** A bit-map of flags relating to the closing of this ledger. */ close_flags: number diff --git a/packages/xrpl/src/models/methods/index.ts b/packages/xrpl/src/models/methods/index.ts index 98c687c636..f6929f8cc9 100644 --- a/packages/xrpl/src/models/methods/index.ts +++ b/packages/xrpl/src/models/methods/index.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-inline-comments -- Necessary for important note */ /* eslint-disable max-lines -- There is a lot to export */ import { AccountChannelsRequest, @@ -71,6 +72,11 @@ import { LedgerQueueData, LedgerRequest, LedgerResponse, + LedgerRequestExpandedTransactionsOnly, + LedgerResponseExpanded, + LedgerRequestExpandedAccountsAndTransactions, + LedgerRequestExpandedAccountsOnly, + LedgerRequestExpandedTransactionsBinary, } from './ledger' import { LedgerClosedRequest, LedgerClosedResponse } from './ledgerClosed' import { LedgerCurrentRequest, LedgerCurrentResponse } from './ledgerCurrent' @@ -259,6 +265,177 @@ type Response = // AMM methods | AMMInfoResponse +export type RequestResponseMap = T extends AccountChannelsRequest + ? AccountChannelsResponse + : T extends AccountCurrenciesRequest + ? AccountCurrenciesResponse + : T extends AccountInfoRequest + ? AccountInfoResponse + : T extends AccountLinesRequest + ? AccountLinesResponse + : T extends AccountNFTsRequest + ? AccountNFTsResponse + : T extends AccountObjectsRequest + ? AccountObjectsResponse + : T extends AccountOffersRequest + ? AccountOffersResponse + : T extends AccountTxRequest + ? AccountTxResponse + : T extends AMMInfoRequest + ? AMMInfoResponse + : T extends GatewayBalancesRequest + ? GatewayBalancesResponse + : T extends NoRippleCheckRequest + ? NoRippleCheckResponse + : // NOTE: The order of these LedgerRequest types is important + // to get the proper type matching overrides based on parameters set + // in the request. For example LedgerRequestExpandedTransactionsBinary + // should match LedgerRequestExpandedTransactionsOnly, but not + // LedgerRequestExpandedAccountsOnly. This is because the + // LedgerRequestExpandedTransactionsBinary type is a superset of + // LedgerRequestExpandedTransactionsOnly, but not of the other. + // This is why LedgerRequestExpandedTransactionsBinary is listed + // first in the type list. + // + // Here is an example using real data: + // LedgerRequestExpandedTransactionsBinary = { + // command: 'ledger', + // ledger_index: 'validated', + // expand: true, + // transactions: true, + // binary: true, + // } + // LedgerRequestExpandedTransactionsOnly = { + // command: 'ledger', + // ledger_index: 'validated', + // expand: true, + // transactions: true, + // } + // LedgerRequestExpandedAccountsOnly = { + // command: 'ledger', + // ledger_index: 'validated', + // accounts: true, + // expand: true, + // } + // LedgerRequest = { + // command: 'ledger', + // ledger_index: 'validated', + // } + // + // The type with the most parameters set should be listed first. In this + // case LedgerRequestExpandedTransactionsBinary has the most parameters (`expand`, `transactions`, and `binary`) + // set, so it is listed first. When TypeScript tries to match the type of + // a request to a response, it will try to match the request type to the + // response type in the order they are listed. So, if we have a request + // with the following parameters: + // { + // command: 'ledger', + // ledger_index: 'validated', + // expand: true, + // transactions: true, + // binary: true, + // } + // TypeScript will first try to match the request type to + // LedgerRequestExpandedTransactionsBinary, which will succeed. It will + // then try to match the response type to LedgerResponseExpanded, which + // will also succeed. If we had listed LedgerRequestExpandedTransactionsOnly + // first, TypeScript would have tried to match the request type to + // LedgerRequestExpandedTransactionsOnly, which would have succeeded, but + // then we'd get the wrong response type, LedgerResponse, instead of + // LedgerResponseExpanded. + T extends LedgerRequestExpandedTransactionsBinary + ? LedgerResponse + : T extends LedgerRequestExpandedAccountsAndTransactions + ? LedgerResponseExpanded + : T extends LedgerRequestExpandedTransactionsOnly + ? LedgerResponseExpanded + : T extends LedgerRequestExpandedAccountsOnly + ? LedgerResponseExpanded + : T extends LedgerRequest + ? LedgerResponse + : T extends LedgerClosedRequest + ? LedgerClosedResponse + : T extends LedgerCurrentRequest + ? LedgerCurrentResponse + : T extends LedgerDataRequest + ? LedgerDataResponse + : T extends LedgerEntryRequest + ? LedgerEntryResponse + : T extends SubmitRequest + ? SubmitResponse + : T extends SubmitMultisignedRequest + ? SubmitMultisignedResponse + : T extends TransactionEntryRequest + ? TransactionEntryResponse + : T extends TxRequest + ? TxResponse + : T extends BookOffersRequest + ? BookOffersResponse + : T extends DepositAuthorizedRequest + ? DepositAuthorizedResponse + : T extends PathFindRequest + ? PathFindResponse + : T extends RipplePathFindRequest + ? RipplePathFindResponse + : T extends ChannelVerifyRequest + ? ChannelVerifyResponse + : T extends SubscribeRequest + ? SubscribeResponse + : T extends UnsubscribeRequest + ? UnsubscribeResponse + : T extends FeeRequest + ? FeeResponse + : T extends ManifestRequest + ? ManifestResponse + : T extends ServerInfoRequest + ? ServerInfoResponse + : T extends ServerStateRequest + ? ServerStateResponse + : T extends ServerDefinitionsRequest + ? ServerDefinitionsResponse + : T extends PingRequest + ? PingResponse + : T extends RandomRequest + ? RandomResponse + : T extends NFTBuyOffersRequest + ? NFTBuyOffersResponse + : T extends NFTSellOffersRequest + ? NFTSellOffersResponse + : T extends NFTInfoRequest + ? NFTInfoResponse + : T extends NFTHistoryRequest + ? NFTHistoryResponse + : Response + +export type MarkerRequest = Request & { + limit?: number + marker?: unknown +} + +export type MarkerResponse = Response & { + result: { + marker?: unknown + } +} + +export type RequestAllResponseMap = T extends AccountChannelsRequest + ? AccountChannelsResponse + : T extends AccountLinesRequest + ? AccountLinesResponse + : T extends AccountObjectsRequest + ? AccountObjectsResponse + : T extends AccountOffersRequest + ? AccountOffersResponse + : T extends AccountTxRequest + ? AccountTxResponse + : T extends LedgerDataRequest + ? LedgerDataResponse + : T extends AccountTxRequest + ? AccountTxResponse + : T extends BookOffersRequest + ? BookOffersResponse + : MarkerResponse + export { // Allow users to define their own requests and responses. This is useful for releasing experimental versions BaseRequest, diff --git a/packages/xrpl/src/models/methods/ledger.ts b/packages/xrpl/src/models/methods/ledger.ts index e65ac9f89b..1ead273505 100644 --- a/packages/xrpl/src/models/methods/ledger.ts +++ b/packages/xrpl/src/models/methods/ledger.ts @@ -72,6 +72,112 @@ export interface LedgerRequest extends BaseRequest, LookupByLedgerRequest { type?: LedgerEntryFilter } +/** + * Retrieve information about the public ledger. Expects a response in the form + * of a {@link LedgerResponseExpanded}. Will return full JSON-formatted transaction data instead of string hashes. + * + * @example + * ```ts + * const ledger: LedgerRequest = { + * "id": 14, + * "command": "ledger", + * "ledger_index": "validated", + * "full": false, + * "accounts": false, + * "transactions": false, + * "expand": true, + * "owner_funds": false + * } + * ``` + * + * @category Requests + */ +export interface LedgerRequestExpandedTransactionsOnly extends LedgerRequest { + expand: true + transactions: true +} + +/** + * Retrieve information about the public ledger. Expects a response in the form + * of a {@link LedgerResponseExpanded}. Will return full JSON-formatted `accountState` data instead of string hashes. + * + * @example + * ```ts + * const ledger: LedgerRequest = { + * "id": 14, + * "command": "ledger", + * "ledger_index": "validated", + * "full": false, + * "accounts": true, + * "transactions": false, + * "expand": true, + * "owner_funds": false + * } + * ``` + * + * @category Requests + */ +export interface LedgerRequestExpandedAccountsOnly extends LedgerRequest { + expand: true + accounts: true +} + +/** + * Retrieve information about the public ledger. Expects a response in the form + * of a {@link LedgerResponseExpanded}. Will return full JSON-formatted `accountState` and `transactions` + * data instead of string hashes. + * + * @example + * ```ts + * const ledger: LedgerRequest = { + * "id": 14, + * "command": "ledger", + * "ledger_index": "validated", + * "full": false, + * "accounts": true, + * "transactions": true, + * "expand": true, + * "owner_funds": false + * } + * ``` + * + * @category Requests + */ +export interface LedgerRequestExpandedAccountsAndTransactions + extends LedgerRequest { + expand: true + accounts: true + transactions: true +} + +/** + * Retrieve information about the public ledger. Expects a response in the form + * of a {@link LedgerResponse}. Will return binary (hexadecimal string) format + * instead of JSON or string hashes for `transactions` data. + * + * @example + * ```ts + * const ledger: LedgerRequest = { + * "id": 14, + * "command": "ledger", + * "ledger_index": "validated", + * "full": false, + * "accounts": true, + * "transactions": true, + * "expand": true, + * "owner_funds": false, + * "binary": true + * } + * ``` + * + * @category Requests + */ +export interface LedgerRequestExpandedTransactionsBinary extends LedgerRequest { + expand: true + transactions: true + binary: true +} + /** * Special case transaction definition when the request contains `owner_funds: true`. */ @@ -101,30 +207,53 @@ export interface LedgerBinary transactions?: string[] } +interface LedgerResponseBase { + /** Unique identifying hash of the entire ledger. */ + ledger_hash: string + /** The Ledger Index of this ledger. */ + ledger_index: number + /** + * If true, this is a validated ledger version. If omitted or set to false, + * this ledger's data is not final. + */ + queue_data?: Array + /** + * Array of objects describing queued transactions, in the same order as + * the queue. If the request specified expand as true, members contain full + * representations of the transactions, in either JSON or binary depending + * on whether the request specified binary as true. + */ + validated?: boolean +} + +interface LedgerResponseResult extends LedgerResponseBase { + /** The complete header data of this {@link Ledger}. */ + ledger: LedgerBinary +} + /** * Response expected from a {@link LedgerRequest}. + * This is the default request response, triggered when `expand` and `binary` are both false. * * @category Responses */ export interface LedgerResponse extends BaseResponse { - result: { - /** The complete header data of this {@link Ledger}. */ - ledger: Ledger | LedgerBinary - /** Unique identifying hash of the entire ledger. */ - ledger_hash: string - /** The Ledger Index of this ledger. */ - ledger_index: number - /** - * If true, this is a validated ledger version. If omitted or set to false, - * this ledger's data is not final. - */ - queue_data?: Array - /** - * Array of objects describing queued transactions, in the same order as - * the queue. If the request specified expand as true, members contain full - * representations of the transactions, in either JSON or binary depending - * on whether the request specified binary as true. - */ - validated?: boolean - } + result: LedgerResponseResult +} + +interface LedgerResponseExpandedResult extends LedgerResponseBase { + /** The complete header data of this {@link Ledger}. */ + ledger: Ledger +} + +/** + * Response expected from a {@link LedgerRequest} when the request contains `expanded` is true. See {@link LedgerRequestExpanded}. + * This response will contain full JSON-formatted data instead of string hashes. + * The response will contain either `accounts` or `transactions` or both. + * `binary` will be missing altogether. + * + * @category Responses + */ +export interface LedgerResponseExpanded extends BaseResponse { + result: LedgerResponseExpandedResult } diff --git a/packages/xrpl/src/models/methods/subscribe.ts b/packages/xrpl/src/models/methods/subscribe.ts index 6a203fd61b..cd1e03e826 100644 --- a/packages/xrpl/src/models/methods/subscribe.ts +++ b/packages/xrpl/src/models/methods/subscribe.ts @@ -10,6 +10,7 @@ import { OfferCreate, Transaction } from '../transactions' import { TransactionMetadata } from '../transactions/metadata' import type { BaseRequest, BaseResponse } from './baseMethod' +import { ManifestRequest } from './manifest' export interface SubscribeBook { /** @@ -433,3 +434,29 @@ export type Stream = | PeerStatusStream | OrderBookStream | ConsensusStream + +export type OnEventToListenerMap = T extends 'connected' + ? () => void + : T extends 'disconnected' + ? (code: number) => void + : T extends 'ledgerClosed' + ? (ledger: LedgerStream) => void + : T extends 'validationReceived' + ? (validation: ValidationStream) => void + : T extends 'transaction' + ? (transaction: TransactionStream) => void + : T extends 'peerStatusChange' + ? (peerStatus: PeerStatusStream) => void + : T extends 'consensusPhase' + ? (consensus: ConsensusStream) => void + : T extends 'manifestReceived' + ? (manifest: ManifestRequest) => void + : T extends 'path_find' + ? (path: PathFindStream) => void + : T extends 'error' + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any -- needs to be any for overload + (...err: any[]) => void + : T extends string + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any -- needs to be any for overload + (...args: any[]) => void + : never diff --git a/packages/xrpl/src/sugar/autofill.ts b/packages/xrpl/src/sugar/autofill.ts index a60a6863a1..2b694217bf 100644 --- a/packages/xrpl/src/sugar/autofill.ts +++ b/packages/xrpl/src/sugar/autofill.ts @@ -5,7 +5,6 @@ import type { Client } from '..' import { ValidationError, XrplError } from '../errors' import { AccountInfoRequest, AccountObjectsRequest } from '../models/methods' import { Transaction } from '../models/transactions' -import { setTransactionFlagsToNumber } from '../models/utils/flags' import { xrpToDrops } from '../utils' import getFeeXrp from './getFeeXrp' @@ -19,83 +18,6 @@ const LEDGER_OFFSET = 20 const RESTRICTED_NETWORKS = 1024 const REQUIRED_NETWORKID_VERSION = '1.11.0' const HOOKS_TESTNET_ID = 21338 -interface ClassicAccountAndTag { - classicAccount: string - tag: number | false | undefined -} - -/** - * Autofills fields in a transaction. This will set `Sequence`, `Fee`, - * `lastLedgerSequence` according to the current state of the server this Client - * is connected to. It also converts all X-Addresses to classic addresses and - * flags interfaces into numbers. - * - * @example - * - * ```ts - * const { Client } = require('xrpl') - * - * const client = new Client('wss://s.altnet.rippletest.net:51233') - * - * async function createAndAutofillTransaction() { - * const transaction = { - * TransactionType: 'Payment', - * Account: 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh', - * Destination: 'r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59', - * Amount: '10000000' // 10 XRP in drops (1/1,000,000th of an XRP) - * } - * - * try { - * const autofilledTransaction = await client.autofill(transaction) - * console.log(autofilledTransaction) - * } catch (error) { - * console.error(`Failed to autofill transaction: ${error}`) - * } - * } - * - * createAndAutofillTransaction() - * ``` - * - * Autofill helps fill in fields which should be included in a transaction, but can be determined automatically - * such as `LastLedgerSequence` and `Fee`. If you override one of the fields `autofill` changes, your explicit - * values will be used instead. By default, this is done as part of `submit` and `submitAndWait` when you pass - * in an unsigned transaction along with your wallet to be submitted. - * - * @param this - A client. - * @param transaction - A {@link Transaction} in JSON format - * @param signersCount - The expected number of signers for this transaction. - * Only used for multisigned transactions. - * @returns The autofilled transaction. - */ -async function autofill( - this: Client, - transaction: T, - signersCount?: number, -): Promise { - const tx = { ...transaction } - - setValidAddresses(tx) - - setTransactionFlagsToNumber(tx) - const promises: Array> = [] - if (tx.NetworkID == null) { - tx.NetworkID = txNeedsNetworkID(this) ? this.networkID : undefined - } - if (tx.Sequence == null) { - promises.push(setNextValidSequenceNumber(this, tx)) - } - if (tx.Fee == null) { - promises.push(calculateFeePerTransactionType(this, tx, signersCount)) - } - if (tx.LastLedgerSequence == null) { - promises.push(setLatestValidatedLedgerSequence(this, tx)) - } - if (tx.TransactionType === 'AccountDelete') { - promises.push(checkAccountDeleteBlockers(this, tx)) - } - - return Promise.all(promises).then(() => tx) -} /** * Determines whether the source rippled version is not later than the target rippled version. @@ -171,7 +93,7 @@ function isNotLaterRippledVersion(source: string, target: string): boolean { * @param client -- The connected client. * @returns True if required networkID, false otherwise. */ -function txNeedsNetworkID(client: Client): boolean { +export function txNeedsNetworkID(client: Client): boolean { if ( client.networkID !== undefined && client.networkID > RESTRICTED_NETWORKS @@ -190,7 +112,17 @@ function txNeedsNetworkID(client: Client): boolean { return false } -function setValidAddresses(tx: Transaction): void { +interface ClassicAccountAndTag { + classicAccount: string + tag: number | false | undefined +} + +/** + * Sets valid addresses for the transaction. + * + * @param tx - The transaction object. + */ +export function setValidAddresses(tx: Transaction): void { validateAccountAddress(tx, 'Account', 'SourceTag') // eslint-disable-next-line @typescript-eslint/dot-notation -- Destination can exist on Transaction if (tx['Destination'] != null) { @@ -206,6 +138,14 @@ function setValidAddresses(tx: Transaction): void { convertToClassicAddress(tx, 'RegularKey') } +/** + * Validates the account address in a transaction object. + * + * @param tx - The transaction object. + * @param accountField - The field name for the account address in the transaction object. + * @param tagField - The field name for the tag in the transaction object. + * @throws {ValidationError} If the tag field does not match the tag of the account address. + */ function validateAccountAddress( tx: Transaction, accountField: string, @@ -227,6 +167,14 @@ function validateAccountAddress( } } +/** + * Retrieves the classic account and tag from an account address. + * + * @param Account - The account address. + * @param [expectedTag] - The expected tag for the account address. + * @returns The classic account and tag. + * @throws {ValidationError} If the address includes a tag that does not match the tag specified in the transaction. + */ function getClassicAccountAndTag( Account: string, expectedTag?: number, @@ -249,6 +197,12 @@ function getClassicAccountAndTag( } } +/** + * Converts the specified field of a transaction object to a classic address format. + * + * @param tx - The transaction object. + * @param fieldName - The name of the field to convert.export + */ function convertToClassicAddress(tx: Transaction, fieldName: string): void { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- assignment is safe const account = tx[fieldName] @@ -259,7 +213,15 @@ function convertToClassicAddress(tx: Transaction, fieldName: string): void { } } -async function setNextValidSequenceNumber( +/** + * Sets the next valid sequence number for a transaction. + * + * @param client - The client object used for making requests. + * @param tx - The transaction object for which the sequence number needs to be set. + * @returns A Promise that resolves when the sequence number is set. + * @throws {Error} If there is an error retrieving the account information. + */ +export async function setNextValidSequenceNumber( client: Client, tx: Transaction, ): Promise { @@ -273,7 +235,14 @@ async function setNextValidSequenceNumber( tx.Sequence = data.result.account_data.Sequence } -async function fetchOwnerReserveFee(client: Client): Promise { +/** + * Fetches the account deletion fee from the server state using the provided client. + * + * @param client - The client object used to make the request. + * @returns A Promise that resolves to the account deletion fee as a BigNumber. + * @throws {Error} Throws an error if the account deletion fee cannot be fetched. + */ +async function fetchAccountDeleteFee(client: Client): Promise { const response = await client.request({ command: 'server_state' }) const fee = response.result.state.validated_ledger?.reserve_inc @@ -284,7 +253,15 @@ async function fetchOwnerReserveFee(client: Client): Promise { return new BigNumber(fee) } -async function calculateFeePerTransactionType( +/** + * Calculates the fee per transaction type. + * + * @param client - The client object. + * @param tx - The transaction object. + * @param [signersCount=0] - The number of signers (default is 0). Only used for multisigning. + * @returns A promise that resolves with void. Modifies the `tx` parameter to give it the calculated fee. + */ +export async function calculateFeePerTransactionType( client: Client, tx: Transaction, signersCount = 0, @@ -309,7 +286,7 @@ async function calculateFeePerTransactionType( tx.TransactionType === 'AccountDelete' || tx.TransactionType === 'AMMCreate' ) { - baseFee = await fetchOwnerReserveFee(client) + baseFee = await fetchAccountDeleteFee(client) } /* @@ -331,11 +308,25 @@ async function calculateFeePerTransactionType( tx.Fee = totalFee.dp(0, BigNumber.ROUND_CEIL).toString(10) } +/** + * Scales the given value by multiplying it with the provided multiplier. + * + * @param value - The value to be scaled. + * @param multiplier - The multiplier to scale the value. + * @returns The scaled value as a string. + */ function scaleValue(value, multiplier): string { return new BigNumber(value).times(multiplier).toString() } -async function setLatestValidatedLedgerSequence( +/** + * Sets the latest validated ledger sequence for the transaction. + * + * @param client - The client object. + * @param tx - The transaction object. + * @returns A promise that resolves with void. Modifies the `tx` parameter setting `LastLedgerSequence`. + */ +export async function setLatestValidatedLedgerSequence( client: Client, tx: Transaction, ): Promise { @@ -344,7 +335,14 @@ async function setLatestValidatedLedgerSequence( tx.LastLedgerSequence = ledgerSequence + LEDGER_OFFSET } -async function checkAccountDeleteBlockers( +/** + * Checks for any blockers that prevent the deletion of an account. + * + * @param client - The client object. + * @param tx - The transaction object. + * @returns A promise that resolves with void if there are no blockers, or rejects with an XrplError if there are blockers. + */ +export async function checkAccountDeleteBlockers( client: Client, tx: Transaction, ): Promise { @@ -367,5 +365,3 @@ async function checkAccountDeleteBlockers( resolve() }) } - -export default autofill diff --git a/packages/xrpl/src/sugar/balances.ts b/packages/xrpl/src/sugar/balances.ts index 90ae141c40..a9de61fe9c 100644 --- a/packages/xrpl/src/sugar/balances.ts +++ b/packages/xrpl/src/sugar/balances.ts @@ -1,121 +1,15 @@ -import type { Balance, Client } from '..' -import { - AccountLinesRequest, - AccountLinesTrustline, - LedgerIndex, - AccountInfoRequest, -} from '../models' -import { dropsToXrp } from '../utils' +import { AccountLinesTrustline, Balance } from '../models' -function formatBalances(trustlines: AccountLinesTrustline[]): Balance[] { +/** + * Formats an array of trustlines into an array of balances. + * + * @param trustlines - The array of trustlines to format. + * @returns An array of balances, each containing the value, currency, and issuer. + */ +export function formatBalances(trustlines: AccountLinesTrustline[]): Balance[] { return trustlines.map((trustline) => ({ value: trustline.balance, currency: trustline.currency, issuer: trustline.account, })) } - -/** - * Get the XRP balance for an account. - * - * @example - * ```ts - * const client = new Client(wss://s.altnet.rippletest.net:51233) - * const balance = await client.getXrpBalance('rG1QQv2nh2gr7RCZ1P8YYcBUKCCN633jCn') - * console.log(balance) - * /// '200' - * ``` - * - * @param this - Client. - * @param address - Address of the account to retrieve XRP balance. - * @param options - Options to include for getting the XRP balance. - * @param options.ledger_index - Retrieve the account balances at a given - * ledger_index. - * @param options.ledger_hash - Retrieve the account balances at the ledger with - * a given ledger_hash. - * @returns The XRP balance of the account (as a string). - */ -async function getXrpBalance( - this: Client, - address: string, - options: { - ledger_hash?: string - ledger_index?: LedgerIndex - } = {}, -): Promise { - const xrpRequest: AccountInfoRequest = { - command: 'account_info', - account: address, - ledger_index: options.ledger_index ?? 'validated', - ledger_hash: options.ledger_hash, - } - const response = await this.request(xrpRequest) - return dropsToXrp(response.result.account_data.Balance) -} - -/** - * Get XRP/non-XRP balances for an account. - * - * @param this - Client. - * @param address - Address of the account to retrieve balances for. - * @param options - Allows the client to specify a ledger_hash, ledger_index, - * filter by peer, and/or limit number of balances. - * @param options.ledger_index - Retrieve the account balances at a given - * ledger_index. - * @param options.ledger_hash - Retrieve the account balances at the ledger with - * a given ledger_hash. - * @param options.peer - Filter balances by peer. - * @param options.limit - Limit number of balances to return. - * @returns An array of XRP/non-XRP balances for the given account. - */ -// eslint-disable-next-line max-lines-per-function -- Longer definition is required for end users to see the definition. -async function getBalances( - this: Client, - address: string, - options: { - ledger_hash?: string - ledger_index?: LedgerIndex - peer?: string - limit?: number - } = {}, -): Promise< - Array<{ value: string; currency: string; issuer?: string | undefined }> -> { - const balances: Balance[] = [] - - // get XRP balance - let xrpPromise: Promise = Promise.resolve('') - if (!options.peer) { - xrpPromise = this.getXrpBalance(address, { - ledger_hash: options.ledger_hash, - ledger_index: options.ledger_index, - }) - } - - // get non-XRP balances - const linesRequest: AccountLinesRequest = { - command: 'account_lines', - account: address, - ledger_index: options.ledger_index ?? 'validated', - ledger_hash: options.ledger_hash, - peer: options.peer, - limit: options.limit, - } - const linesPromise = this.requestAll(linesRequest) - - // combine results - await Promise.all([xrpPromise, linesPromise]).then( - ([xrpBalance, linesResponses]) => { - const accountLinesBalance = linesResponses.flatMap((response) => - formatBalances(response.result.lines), - ) - if (xrpBalance !== '') { - balances.push({ currency: 'XRP', value: xrpBalance }) - } - balances.push(...accountLinesBalance) - }, - ) - return balances.slice(0, options.limit) -} - -export { getXrpBalance, getBalances } diff --git a/packages/xrpl/src/sugar/getLedgerIndex.ts b/packages/xrpl/src/sugar/getLedgerIndex.ts deleted file mode 100644 index c4d1c3cb2f..0000000000 --- a/packages/xrpl/src/sugar/getLedgerIndex.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { Client } from '..' - -/** - * Returns the index of the most recently validated ledger. - * - * @param this - The Client used to connect to the ledger. - * @returns The most recently validated ledger index. - */ -export default async function getLedgerIndex(this: Client): Promise { - const ledgerResponse = await this.request({ - command: 'ledger', - ledger_index: 'validated', - }) - return ledgerResponse.result.ledger_index -} diff --git a/packages/xrpl/src/sugar/getOrderbook.ts b/packages/xrpl/src/sugar/getOrderbook.ts index 82a7620932..285a9d8f28 100644 --- a/packages/xrpl/src/sugar/getOrderbook.ts +++ b/packages/xrpl/src/sugar/getOrderbook.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines-per-function -- Needs to process orderbooks. */ import BigNumber from 'bignumber.js' import type { Client } from '../client' @@ -9,7 +8,6 @@ import { BookOffer, BookOfferCurrency, BookOffersRequest, - BookOffersResponse, } from '../models/methods/bookOffers' const DEFAULT_LIMIT = 20 @@ -31,43 +29,40 @@ const getOrderbookOptionsSet = new Set([ ]) /** - * Fetch orderbook (buy/sell orders) between two currency pairs. This checks both sides of the orderbook - * by making two `order_book` requests (with the second reversing takerPays and takerGets). Returned offers are - * not normalized in this function, so either currency could be takerGets or takerPays. + * Represents the options for retrieving the order book. + */ +export interface GetOrderBookOptions { + /** + * The limit on the number of offers to return. + */ + limit?: number + /** + * The ledger index of the ledger to use. + */ + ledger_index?: LedgerIndex + /** + * The ledger hash of the ledger to use. + */ + ledger_hash?: string | null + /** + * The account that takes the offers. + */ + taker?: string | null +} + +/** + * Validates the options for retrieving the order book. * - * @param this - Client. - * @param currency1 - Specification of one currency involved. (With a currency code and optionally an issuer) - * @param currency2 - Specification of a second currency involved. (With a currency code and optionally an issuer) - * @param options - Options allowing the client to specify ledger_index, - * ledger_hash, filter by taker, and/or limit number of orders. - * @param options.ledger_index - Retrieve the orderbook at a given ledger_index. - * @param options.ledger_hash - Retrieve the orderbook at the ledger with a - * given ledger_hash. - * @param options.taker - Filter orders by taker. - * @param options.limit - The limit passed into each book_offers request. - * Can return more than this due to two calls being made. Defaults to 20. - * @returns An object containing buy and sell objects. + * @param options - The options to validate. + * @throws {ValidationError} If any validation errors occur. */ -// eslint-disable-next-line max-params, complexity -- Once bound to Client, getOrderbook only has 3 parameters. -async function getOrderbook( - this: Client, - currency1: BookOfferCurrency, - currency2: BookOfferCurrency, - options: { - limit?: number - ledger_index?: LedgerIndex - ledger_hash?: string | null - taker?: string | null - } = {}, -): Promise<{ - buy: BookOffer[] - sell: BookOffer[] -}> { - Object.keys(options).forEach((key) => { +// eslint-disable-next-line complexity -- Necessary for validation. +export function validateOrderbookOptions(options: GetOrderBookOptions): void { + for (const key of Object.keys(options)) { if (!getOrderbookOptionsSet.has(key)) { throw new ValidationError(`Unexpected option: ${key}`, options) } - }) + } if (options.limit && typeof options.limit !== 'number') { throw new ValidationError('limit must be a number', options.limit) @@ -101,7 +96,30 @@ async function getOrderbook( if (options.taker !== undefined && typeof options.taker !== 'string') { throw new ValidationError('taker must be a string', options.taker) } +} +/** + * Creates a request object for retrieving book offers. + * + * @param currency1 - The first currency in the pair. + * @param currency2 - The second currency in the pair. + * @param options - Additional options for the request. + * @param [options.limit] - The maximum number of offers to retrieve. + * @param [options.ledger_index] - The ledger index to use for retrieval. + * @param [options.ledger_hash] - The ledger hash to use for retrieval. + * @param [options.taker] - The taker address for retrieval. + * @returns The created request object. + */ +export function createBookOffersRequest( + currency1: BookOfferCurrency, + currency2: BookOfferCurrency, + options: { + limit?: number + ledger_index?: LedgerIndex + ledger_hash?: string | null + taker?: string | null + }, +): BookOffersRequest { const request: BookOffersRequest = { command: 'book_offers', taker_pays: currency1, @@ -111,26 +129,78 @@ async function getOrderbook( limit: options.limit ?? DEFAULT_LIMIT, taker: options.taker ? options.taker : undefined, } - // 2. Make Request - const directOfferResults: BookOffersResponse[] = await this.requestAll( - request, - ) - request.taker_gets = currency1 - request.taker_pays = currency2 - const reverseOfferResults = await this.requestAll(request) - // 3. Return Formatted Response - - const directOffers = directOfferResults.flatMap( - (directOfferResult: BookOffersResponse) => directOfferResult.result.offers, - ) - const reverseOffers = reverseOfferResults.flatMap( - (reverseOfferResult) => reverseOfferResult.result.offers, - ) - - const orders = [...directOffers, ...reverseOffers] - // separate out the buy and sell orders + + return request +} + +type BookOfferResult = BookOffer[] + +/** + * Retrieves all book offer results using the given request. + * + * @param client - The Ripple client. + * @param request - The request object. + * @returns The array of book offer results. + */ +export async function requestAllOffers( + client: Client, + request: BookOffersRequest, +): Promise { + const results = await client.requestAll(request) + return results.map((result) => result.result.offers) +} + +/** + * Creates a reverse request object by swapping the taker pays and taker gets amounts. + * + * @param request - The original request object. + * @returns The reverse request object. + */ +export function reverseRequest(request: BookOffersRequest): BookOffersRequest { + return { + ...request, + taker_pays: request.taker_gets, + taker_gets: request.taker_pays, + } +} + +/** + * Extracts the offers from the book offer results. + * + * @param offerResults - The array of book offer results. + * @returns The extracted offers. + */ +export function extractOffers(offerResults: BookOfferResult[]): BookOffer[] { + return offerResults.flatMap((offerResult) => offerResult) +} + +/** + * Combines the direct and reverse offers into a single array. + * + * @param directOffers - The direct offers. + * @param reverseOffers - The reverse offers. + * @returns The combined array of offers. + */ +export function combineOrders( + directOffers: BookOffer[], + reverseOffers: BookOffer[], +): BookOffer[] { + return [...directOffers, ...reverseOffers] +} + +/** + * Separates the buy and sell orders from the given array of orders. + * + * @param orders - The array of orders. + * @returns The separated buy and sell orders. + */ +export function separateBuySellOrders(orders: BookOffer[]): { + buy: BookOffer[] + sell: BookOffer[] +} { const buy: BookOffer[] = [] const sell: BookOffer[] = [] + orders.forEach((order) => { // eslint-disable-next-line no-bitwise -- necessary for flags check if ((order.Flags & OfferFlags.lsfSell) === 0) { @@ -139,15 +209,21 @@ async function getOrderbook( sell.push(order) } }) - /* - * Sort the orders - * for both buys and sells, lowest quality is closest to mid-market - * we sort the orders so that earlier orders are closer to mid-market - */ - return { - buy: sortOffers(buy).slice(0, options.limit), - sell: sortOffers(sell).slice(0, options.limit), - } + + return { buy, sell } } -export default getOrderbook +/** + * Sorts and limits the given array of offers. + * + * @param offers - The array of offers to sort and limit. + * @param [limit] - The maximum number of offers to include. + * @returns The sorted and limited array of offers. + */ +export function sortAndLimitOffers( + offers: BookOffer[], + limit?: number, +): BookOffer[] { + const sortedOffers = sortOffers(offers) + return sortedOffers.slice(0, limit) +} diff --git a/packages/xrpl/src/sugar/index.ts b/packages/xrpl/src/sugar/index.ts index 991c1533b8..0047432011 100644 --- a/packages/xrpl/src/sugar/index.ts +++ b/packages/xrpl/src/sugar/index.ts @@ -1,11 +1,3 @@ -export { default as autofill } from './autofill' - -export { getBalances, getXrpBalance } from './balances' - -export { default as getLedgerIndex } from './getLedgerIndex' - -export { default as getOrderbook } from './getOrderbook' - export * from './submit' export * from './utils' diff --git a/packages/xrpl/src/sugar/submit.ts b/packages/xrpl/src/sugar/submit.ts index e1e44bc3c0..b5ae284256 100644 --- a/packages/xrpl/src/sugar/submit.ts +++ b/packages/xrpl/src/sugar/submit.ts @@ -6,7 +6,6 @@ import { Signer } from '../models/common' import { TxRequest, TxResponse } from '../models/methods' import { Transaction } from '../models/transactions' import { BaseTransaction } from '../models/transactions/common' -import { hashes } from '../utils' /** Approximate time for a ledger to close, in milliseconds */ const LEDGER_CLOSE_TIME = 1000 @@ -17,130 +16,31 @@ async function sleep(ms: number): Promise { }) } -/** - * Submits a signed/unsigned transaction. - * Steps performed on a transaction: - * 1. Autofill. - * 2. Sign & Encode. - * 3. Submit. - * - * @param this - A Client. - * @param transaction - A transaction to autofill, sign & encode, and submit. - * @param opts - (Optional) Options used to sign and submit a transaction. - * @param opts.autofill - If true, autofill a transaction. - * @param opts.failHard - If true, and the transaction fails locally, do not retry or relay the transaction to other servers. - * @param opts.wallet - A wallet to sign a transaction. It must be provided when submitting an unsigned transaction. - * @returns A promise that contains SubmitResponse. - * @throws RippledError if submit request fails. - */ -async function submit( - this: Client, - transaction: Transaction | string, - opts?: { - // If true, autofill a transaction. - autofill?: boolean - // If true, and the transaction fails locally, do not retry or relay the transaction to other servers. - failHard?: boolean - // A wallet to sign a transaction. It must be provided when submitting an unsigned transaction. - wallet?: Wallet - }, -): Promise { - const signedTx = await getSignedTx(this, transaction, opts) - return submitRequest(this, signedTx, opts?.failHard) -} +// Helper functions /** - * Asynchronously submits a transaction and verifies that it has been included in a - * validated ledger (or has errored/will not be included for some reason). - * See [Reliable Transaction Submission](https://xrpl.org/reliable-transaction-submission.html). - * - * @example - * - * ```ts - * const { Client, Wallet } = require('xrpl') - * const client = new Client('wss://s.altnet.rippletest.net:51233') - * - * async function submitTransaction() { - * const senderWallet = client.fundWallet() - * const recipientWallet = client.fundWallet() - * - * const transaction = { - * TransactionType: 'Payment', - * Account: senderWallet.address, - * Destination: recipientWallet.address, - * Amount: '10' - * } - * - * try { - * await client.submit(signedTransaction, { wallet: senderWallet }) - * console.log(result) - * } catch (error) { - * console.error(`Failed to submit transaction: ${error}`) - * } - * } - * - * submitTransaction() - * ``` + * Submits a request to the client with a signed transaction. * - * In this example we submit a payment transaction between two newly created testnet accounts. + * @param client - The client to submit the request to. + * @param signedTransaction - The signed transaction to submit. It can be either a Transaction object or a + * string (encode from ripple-binary-codec) representation of the transaction. + * @param [failHard=false] - Optional. Determines whether the submission should fail hard (true) or not (false). Default is false. + * @returns A promise that resolves with the response from the client. + * @throws {ValidationError} If the signed transaction is not valid (not signed). * - * Under the hood, `submit` will call `client.autofill` by default, and because we've passed in a `Wallet` it - * Will also sign the transaction for us before submitting the signed transaction binary blob to the ledger. + * @example + * import { Client } from "xrpl" + * const client = new Client("wss://s.altnet.rippletest.net:51233"); + * await client.connect(); + * const signedTransaction = createSignedTransaction(); + * // Example 1: Submitting a Transaction object + * const response1 = await submitRequest(client, signedTransaction); * - * This is similar to `submitAndWait` which does all of the above, but also waits to see if the transaction has been validated. - * @param this - A Client. - * @param transaction - A transaction to autofill, sign & encode, and submit. - * @param opts - (Optional) Options used to sign and submit a transaction. - * @param opts.autofill - If true, autofill a transaction. - * @param opts.failHard - If true, and the transaction fails locally, do not retry or relay the transaction to other servers. - * @param opts.wallet - A wallet to sign a transaction. It must be provided when submitting an unsigned transaction. - * @throws Connection errors: If the `Client` object is unable to establish a connection to the specified WebSocket endpoint, - * an error will be thrown. - * @throws Transaction errors: If the submitted transaction is invalid or cannot be included in a validated ledger for any - * reason, the promise returned by `submitAndWait()` will be rejected with an error. This could include issues with insufficient - * balance, invalid transaction fields, or other issues specific to the transaction being submitted. - * @throws Ledger errors: If the ledger being used to submit the transaction is undergoing maintenance or otherwise unavailable, - * an error will be thrown. - * @throws Timeout errors: If the transaction takes longer than the specified timeout period to be included in a validated - * ledger, the promise returned by `submitAndWait()` will be rejected with an error. - * @returns A promise that contains TxResponse, that will return when the transaction has been validated. + * // Example 2: Submitting a string representation of the transaction + * const signedTransactionString = encode(signedTransaction); + * const response2 = await submitRequest(client, signedTransactionString, true); */ -async function submitAndWait( - this: Client, - transaction: T | string, - opts?: { - // If true, autofill a transaction. - autofill?: boolean - // If true, and the transaction fails locally, do not retry or relay the transaction to other servers. - failHard?: boolean - // A wallet to sign a transaction. It must be provided when submitting an unsigned transaction. - wallet?: Wallet - }, -): Promise> { - const signedTx = await getSignedTx(this, transaction, opts) - - const lastLedger = getLastLedgerSequence(signedTx) - if (lastLedger == null) { - throw new ValidationError( - 'Transaction must contain a LastLedgerSequence value for reliable submission.', - ) - } - - const response = await submitRequest(this, signedTx, opts?.failHard) - - const txHash = hashes.hashSignedTx(signedTx) - return waitForFinalTransactionOutcome( - this, - txHash, - lastLedger, - response.result.engine_result, - ) -} - -// Helper functions - -// Encodes and submits a signed transaction. -async function submitRequest( +export async function submitRequest( client: Client, signedTransaction: Transaction | string, failHard = false, @@ -161,14 +61,49 @@ async function submitRequest( return client.request(request) } -/* - * The core logic of reliable submission. This polls the ledger until the result of the - * transaction can be considered final, meaning it has either been included in a - * validated ledger, or the transaction's lastLedgerSequence has been surpassed by the - * latest ledger sequence (meaning it will never be included in a validated ledger). +/** + * Waits for the final outcome of a transaction by polling the ledger until the result can be considered final, + * meaning it has either been included in a validated ledger, or the transaction's lastLedgerSequence has been + * surpassed by the latest ledger sequence (meaning it will never be included in a validated ledger). + * + * @template T - The type of the transaction. Defaults to `Transaction`. + * @param client - The client to use for requesting transaction information. + * @param txHash - The hash of the transaction to wait for. + * @param lastLedger - The last ledger sequence of the transaction. + * @param submissionResult - The preliminary result of the transaction. + * @returns A promise that resolves with the final transaction response. + * + * @throws {XrplError} If the latest ledger sequence surpasses the transaction's lastLedgerSequence. + * + * @example + * import { hashes, Client } from "xrpl" + * const client = new Client("wss://s.altnet.rippletest.net:51233") + * await client.connect() + * + * const transaction = createTransaction() // your transaction function + * + * const signedTx = await getSignedTx(this, transaction) + * + * const lastLedger = getLastLedgerSequence(signedTx) + * + * if (lastLedger == null) { + * throw new ValidationError( + * 'Transaction must contain a LastLedgerSequence value for reliable submission.', + * ) + * } + * + * const response = await submitRequest(this, signedTx, opts?.failHard) + * + * const txHash = hashes.hashSignedTx(signedTx) + * return waitForFinalTransactionOutcome( + * this, + * txHash, + * lastLedger, + * response.result.engine_result, + * ) */ // eslint-disable-next-line max-params, max-lines-per-function -- this function needs to display and do with more information. -async function waitForFinalTransactionOutcome< +export async function waitForFinalTransactionOutcome< T extends BaseTransaction = Transaction, >( client: Client, @@ -248,8 +183,40 @@ function isSigned(transaction: Transaction | string): boolean { return tx.SigningPubKey != null && tx.TxnSignature != null } -// initializes a transaction for a submit request -async function getSignedTx( +/** + * Updates a transaction with `autofill` then signs it if it is unsigned. + * + * @param client - The client from which to retrieve the signed transaction. + * @param transaction - The transaction to retrieve. It can be either a Transaction object or + * a string (encode from ripple-binary-codec) representation of the transaction. + * @param [options={}] - Optional. Additional options for retrieving the signed transaction. + * @param [options.autofill=true] - Optional. Determines whether the transaction should be autofilled (true) + * or not (false). Default is true. + * @param [options.wallet] - Optional. A wallet to sign the transaction. It must be provided when submitting + * an unsigned transaction. Default is undefined. + * @returns A promise that resolves with the signed transaction. + * + * @throws {ValidationError} If the transaction is not signed and no wallet is provided. + * + * @example + * import { Client } from "xrpl" + * import { encode } from "ripple-binary-codec" + * + * const client = new Client("wss://s.altnet.rippletest.net:51233"); + * await client.connect(): + * const transaction = createTransaction(); // createTransaction is your function to create a transaction + * const options = { + * autofill: true, + * wallet: myWallet, + * }; + * + * // Example 1: Retrieving a signed Transaction object + * const signedTx1 = await getSignedTx(client, transaction, options); + * + * // Example 2: Retrieving a string representation of the signed transaction + * const signedTxString = await getSignedTx(client, encode(transaction), options); + */ +export async function getSignedTx( client: Client, transaction: Transaction | string, { @@ -258,8 +225,6 @@ async function getSignedTx( }: { // If true, autofill a transaction. autofill?: boolean - // If true, and the transaction fails locally, do not retry or relay the transaction to other servers. - failHard?: boolean // A wallet to sign a transaction. It must be provided when submitting an unsigned transaction. wallet?: Wallet } = {}, @@ -288,7 +253,26 @@ async function getSignedTx( } // checks if there is a LastLedgerSequence as a part of the transaction -function getLastLedgerSequence( +/** + * Retrieves the last ledger sequence from a transaction. + * + * @param transaction - The transaction to retrieve the last ledger sequence from. It can be either a Transaction object or + * a string (encode from ripple-binary-codec) representation of the transaction. + * @returns The last ledger sequence of the transaction, or null if not available. + * + * @example + * const transaction = createTransaction(); // your function to create a transaction + * + * // Example 1: Retrieving the last ledger sequence from a Transaction object + * const lastLedgerSequence1 = getLastLedgerSequence(transaction); + * console.log(lastLedgerSequence1); // Output: 12345 + * + * // Example 2: Retrieving the last ledger sequence from a string representation of the transaction + * const transactionString = encode(transaction); + * const lastLedgerSequence2 = getLastLedgerSequence(transactionString); + * console.log(lastLedgerSequence2); // Output: 67890 + */ +export function getLastLedgerSequence( transaction: Transaction | string, ): number | null { const tx = typeof transaction === 'string' ? decode(transaction) : transaction @@ -301,5 +285,3 @@ function isAccountDelete(transaction: Transaction | string): boolean { const tx = typeof transaction === 'string' ? decode(transaction) : transaction return tx.TransactionType === 'AccountDelete' } - -export { submit, submitAndWait } diff --git a/packages/xrpl/test/client/partialPayments.test.ts b/packages/xrpl/test/client/partialPayments.test.ts index c9788cda29..b052a530e1 100644 --- a/packages/xrpl/test/client/partialPayments.test.ts +++ b/packages/xrpl/test/client/partialPayments.test.ts @@ -2,7 +2,6 @@ import { expect } from 'chai' import cloneDeep from 'lodash/cloneDeep' -import type { TransactionStream } from '../../src' import rippled from '../fixtures/rippled' import { setupClient, @@ -23,7 +22,10 @@ describe('client handling of tfPartialPayments', function () { it('Tx with no tfPartialPayment', async function () { testContext.mockRippled!.addResponse('tx', rippled.tx.Payment) - const resp = await testContext.client.request({ command: 'tx' }) + const resp = await testContext.client.request({ + command: 'tx', + transaction: rippled.tx.Payment.result.hash, + }) expect(resp.warnings).to.equal(undefined) }) @@ -31,7 +33,10 @@ describe('client handling of tfPartialPayments', function () { it('Tx with IOU tfPartialPayment', async function () { const mockResponse = { ...rippled.tx.Payment, result: partialPaymentIOU } testContext.mockRippled!.addResponse('tx', mockResponse) - const resp = await testContext.client.request({ command: 'tx' }) + const resp = await testContext.client.request({ + command: 'tx', + transaction: mockResponse.result.hash, + }) expect(resp.warnings).to.deep.equal([ { @@ -44,7 +49,10 @@ describe('client handling of tfPartialPayments', function () { it('Tx with XRP tfPartialPayment', async function () { const mockResponse = { ...rippled.tx.Payment, result: partialPaymentXRP } testContext.mockRippled!.addResponse('tx', mockResponse) - const resp = await testContext.client.request({ command: 'tx' }) + const resp = await testContext.client.request({ + command: 'tx', + transaction: mockResponse.result.hash, + }) expect(resp.warnings).to.deep.equal([ { @@ -59,7 +67,10 @@ describe('client handling of tfPartialPayments', function () { 'account_tx', rippled.account_tx.normal, ) - const resp = await testContext.client.request({ command: 'account_tx' }) + const resp = await testContext.client.request({ + command: 'account_tx', + account: rippled.account_tx.normal.result.account, + }) expect(resp.warnings).to.equal(undefined) }) @@ -119,6 +130,7 @@ describe('client handling of tfPartialPayments', function () { ) const resp = await testContext.client.request({ command: 'transaction_entry', + tx_hash: rippled.transaction_entry.result.tx_json.hash, }) expect(resp.warnings).to.equal(undefined) @@ -130,6 +142,7 @@ describe('client handling of tfPartialPayments', function () { testContext.mockRippled!.addResponse('transaction_entry', mockResponse) const resp = await testContext.client.request({ command: 'transaction_entry', + tx_hash: mockResponse.result.tx_json.hash, }) expect(resp.warnings).to.deep.equal([ @@ -145,7 +158,7 @@ describe('client handling of tfPartialPayments', function () { 'transaction_entry', rippled.transaction_entry, ) - testContext.client.on('transaction', (tx: TransactionStream) => { + testContext.client.on('transaction', (tx) => { expect(tx.warnings).to.equal(undefined) done() }) @@ -161,7 +174,7 @@ describe('client handling of tfPartialPayments', function () { 'transaction_entry', rippled.transaction_entry, ) - testContext.client.on('transaction', (tx: TransactionStream) => { + testContext.client.on('transaction', (tx) => { expect(tx.warnings).to.deep.equal([ { id: 2001, diff --git a/packages/xrpl/test/client/requestNextPage.test.ts b/packages/xrpl/test/client/requestNextPage.test.ts index a1500c1a6f..8ba5758620 100644 --- a/packages/xrpl/test/client/requestNextPage.test.ts +++ b/packages/xrpl/test/client/requestNextPage.test.ts @@ -57,4 +57,31 @@ describe('client.requestNextPage', function () { 'response does not have a next page', ) }) + + // TODO: Write this test to verify multiple types of commands can be run - https://github.com/XRPLF/xrpl.js/issues/2384 + // it('requests different types of commands', async function () { + // testContext.mockRippled!.addResponse('account_channels', { + // id: 0, + // result: { + // account: testContext.wallet.classicAddress, + // channels: [], + // ledger_hash: + // 'C8BFA74A740AA22AD9BD724781589319052398B0C6C817B88D55628E07B7B4A1', + // ledger_index: 150, + // validated: true, + // }, + // type: 'response', + // }) + // const response = await testContext.client.request({ + // command: 'ledger_data', + // }) + // const responseNextPage = await testContext.client.requestNextPage( + // { command: 'ledger_data' }, + // response, + // ) + // assert.equal( + // responseNextPage.result.state[0].index, + // '000B714B790C3C79FEE00D17C4DEB436B375466F29679447BA64F265FD63D731', + // ) + // }) }) diff --git a/packages/xrpl/test/connection.test.ts b/packages/xrpl/test/connection.test.ts index 0753f31b27..8f2d5805fd 100644 --- a/packages/xrpl/test/connection.test.ts +++ b/packages/xrpl/test/connection.test.ts @@ -13,6 +13,7 @@ import { ResponseFormatError, XrplError, TimeoutError, + SubscribeRequest, } from '../src' import { Connection } from '../src/client/connection' @@ -347,6 +348,7 @@ describe('Connection', function () { 'DisconnectedError', async () => { await clientContext.client + // @ts-expect-error -- Intentionally invalid command .request({ command: 'test_command', data: { closeServer: true } }) .then(() => { assert.fail('Should throw DisconnectedError') @@ -438,7 +440,7 @@ describe('Connection', function () { try { await clientContext.client.connect() } catch (error) { - // @ts-expect-error -- error.message is expected to be defined + // @ts-expect-error -- Error has a message expect(error.message).toEqual( "Error: connect() timed out after 5000 ms. If your internet connection is working, the rippled server may be blocked or inaccessible. You can also try setting the 'connectionTimeout' option in the Client constructor.", ) @@ -457,6 +459,7 @@ describe('Connection', function () { async () => { await clientContext.client .request({ + // @ts-expect-error -- Intentionally invalid command command: 'test_command', data: { unrecognizedResponse: true }, }) @@ -847,7 +850,10 @@ describe('Connection', function () { it( 'propagates RippledError data', async () => { - const request = { command: 'subscribe', streams: 'validations' } + const request: SubscribeRequest = { + command: 'subscribe', + streams: ['validations'], + } clientContext.mockRippled?.addResponse( request.command, rippled.subscribe.error, diff --git a/packages/xrpl/test/fixtures/rippled/tx/payment.json b/packages/xrpl/test/fixtures/rippled/tx/payment.json index d4509529a9..327982d98d 100644 --- a/packages/xrpl/test/fixtures/rippled/tx/payment.json +++ b/packages/xrpl/test/fixtures/rippled/tx/payment.json @@ -38,7 +38,6 @@ "hash": "F4AB442A6D4CBB935D66E1DA7309A5FC71C7143ED4049053EC14E3875B0CF9BF", "inLedger": 348860, "ledger_index": 348860, - "validated": true, "meta": { "AffectedNodes": [ { diff --git a/packages/xrpl/test/integration/onConnect.test.ts b/packages/xrpl/test/integration/onConnect.test.ts index 6a89ae0222..f1d8412121 100644 --- a/packages/xrpl/test/integration/onConnect.test.ts +++ b/packages/xrpl/test/integration/onConnect.test.ts @@ -28,7 +28,7 @@ describe('on handlers', function () { async () => { const client = new Client(serverUrl) return new Promise(function (resolve) { - client.on('disconnected', function (code: number) { + client.on('disconnected', function (code) { // should be the normal disconnect code assert.equal(code, 1000) client.removeAllListeners() diff --git a/packages/xrpl/test/integration/requests/submitMultisigned.test.ts b/packages/xrpl/test/integration/requests/submitMultisigned.test.ts index 89f0a746e6..ec21a1b94e 100644 --- a/packages/xrpl/test/integration/requests/submitMultisigned.test.ts +++ b/packages/xrpl/test/integration/requests/submitMultisigned.test.ts @@ -5,7 +5,6 @@ import { AccountSet, Client, SignerListSet, - SubmitMultisignedRequest, Transaction, SubmitMultisignedResponse, hashes, @@ -80,11 +79,10 @@ describe('submit_multisigned', function () { const signed1 = signerWallet1.sign(accountSetTx, true) const signed2 = signerWallet2.sign(accountSetTx, true) const multisigned = multisign([signed1.tx_blob, signed2.tx_blob]) - const multisignedRequest: SubmitMultisignedRequest = { + const submitResponse = await client.request({ command: 'submit_multisigned', tx_json: decode(multisigned) as unknown as Transaction, - } - const submitResponse = await client.request(multisignedRequest) + }) await ledgerAccept(client) assert.strictEqual(submitResponse.result.engine_result, 'tesSUCCESS') await verifySubmittedTransaction(testContext.client, multisigned) diff --git a/packages/xrpl/test/mockRippledTest.test.ts b/packages/xrpl/test/mockRippledTest.test.ts index f18d1a17e0..49140e6740 100644 --- a/packages/xrpl/test/mockRippledTest.test.ts +++ b/packages/xrpl/test/mockRippledTest.test.ts @@ -2,6 +2,7 @@ import { assert } from 'chai' import { RippledError } from '../src' +import rippledFixtures from './fixtures/rippled' import { setupClient, teardownClient, @@ -22,7 +23,11 @@ describe('mock rippled tests', function () { } await assertRejects( - testContext.client.request({ command: 'account_info' }), + testContext.client.request({ + command: 'account_info', + account: + rippledFixtures.account_info.normal.result.account_data.Account, + }), RippledError, ) }) @@ -44,7 +49,11 @@ describe('mock rippled tests', function () { return { data: request } }) await assertRejects( - testContext.client.request({ command: 'account_info', account: '' }), + testContext.client.request({ + command: 'account_info', + account: + rippledFixtures.account_info.normal.result.account_data.Account, + }), RippledError, ) }) diff --git a/packages/xrpl/typedoc.json b/packages/xrpl/typedoc.json index f304e46c80..20a856c098 100644 --- a/packages/xrpl/typedoc.json +++ b/packages/xrpl/typedoc.json @@ -8,7 +8,6 @@ "Signing", "Transaction Models", "Transaction Flags", - "Ledger Flags", "Utilities", "Requests", "Responses",