Files
xahau.js/packages/xrpl/src/client/index.ts
Denis Angell 4447a7af09 NetworkID (#11)
* update definitions

* add network id to base transaction

* update server info

* update base tx

* add network id to autofill

* update tests and fixtures

* network id option 3

fixup

add network id to server info fixture

fixup!

Revert "update tests and fixtures"

This reverts commit a5deee1274.

* update autofill

* update getNetworkID

* remove set network func

* fixup!

remove autoset network id

remove my mistake

fixup!
2023-03-28 20:27:38 +00:00

658 lines
20 KiB
TypeScript

/* 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 * as assert from 'assert'
import { EventEmitter } from 'events'
import { NotFoundError, ValidationError, XrplError } from '../errors'
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,
} from '../models/methods'
import { BaseRequest, BaseResponse } from '../models/methods/baseMethod'
import {
autofill,
ensureClassicAddress,
getLedgerIndex,
getOrderbook,
getBalances,
getXrpBalance,
submit,
submitAndWait,
getNetworkID,
} from '../sugar'
import fundWallet from '../Wallet/fundWallet'
import {
Connection,
ConnectionUserOptions,
INTENTIONAL_DISCONNECT_CODE,
} from './connection'
import {
handlePartialPayment,
handleStreamPartialPayment,
} from './partialPayment'
export interface ClientOptions extends ConnectionUserOptions {
feeCushion?: number
maxFeeXRP?: string
proxy?: string
timeout?: number
}
/**
* 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
* properly count across many requests.
*
* @param command - The rippled request command.
* @returns The property key corresponding to the command.
*/
function getCollectKeyFromCommand(command: string): string | null {
switch (command) {
case 'account_channels':
return 'channels'
case 'account_lines':
return 'lines'
case 'account_objects':
return 'account_objects'
case 'account_tx':
return 'transactions'
case 'account_offers':
case 'book_offers':
return 'offers'
case 'ledger_data':
return 'state'
default:
return null
}
}
function clamp(value: number, min: number, max: number): number {
assert.ok(min <= max, 'Illegal clamp bounds')
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'
const MIN_LIMIT = 10
const MAX_LIMIT = 400
const NORMAL_DISCONNECT_CODE = 1000
/**
* Client for interacting with rippled servers.
*
* @category Clients
*/
class Client extends EventEmitter {
/*
* Underlying connection to rippled.
*/
public readonly connection: Connection
/**
* Factor to multiply estimated fee by to provide a cushion in case the
* required fee rises during submission of a transaction. Defaults to 1.2.
*
* @category Fee
*/
public readonly feeCushion: number
/**
* Maximum transaction cost to allow, in decimal XRP. Must be a string-encoded
* number. Defaults to '2'.
*
* @category Fee
*/
public readonly maxFeeXRP: string
/**
* Network ID of the server this sdk is connected to
*
* @category Fee
*/
public networkID: number
/**
* Creates a new Client with a websocket connection to a rippled server.
*
* @param server - URL of the server to connect to.
* @param options - Options for client settings.
* @category Constructor
*/
// 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 = {}) {
super()
if (typeof server !== 'string' || !/wss?(?:\+unix)?:\/\//u.exec(server)) {
throw new ValidationError(
'server URI must start with `wss://`, `ws://`, `wss+unix://`, or `ws+unix://`.',
)
}
this.feeCushion = options.feeCushion ?? DEFAULT_FEE_CUSHION
this.maxFeeXRP = options.maxFeeXRP ?? DEFAULT_MAX_FEE_XRP
this.networkID = 1
this.connection = new Connection(server, options)
this.connection.on('error', (errorCode, errorMessage, data) => {
this.emit('error', errorCode, errorMessage, data)
})
this.connection.on('connected', () => {
this.emit('connected')
})
this.connection.on('disconnected', (code: number) => {
let finalCode = code
/*
* 4000: Connection uses a 4000 code internally to indicate a manual disconnect/close
* Since 4000 is a normal disconnect reason, we convert this to the standard exit code 1000
*/
if (finalCode === INTENTIONAL_DISCONNECT_CODE) {
finalCode = NORMAL_DISCONNECT_CODE
}
this.emit('disconnected', finalCode)
})
this.connection.on('ledgerClosed', (ledger) => {
this.emit('ledgerClosed', ledger)
})
this.connection.on('transaction', (tx) => {
// mutates `tx` to add warnings
handleStreamPartialPayment(tx, this.connection.trace)
this.emit('transaction', tx)
})
this.connection.on('validationReceived', (validation) => {
this.emit('validationReceived', validation)
})
this.connection.on('manifestReceived', (manifest) => {
this.emit('manifestReceived', manifest)
})
this.connection.on('peerStatusChange', (status) => {
this.emit('peerStatusChange', status)
})
this.connection.on('consensusPhase', (consensus) => {
this.emit('consensusPhase', consensus)
})
this.connection.on('path_find', (path) => {
this.emit('path_find', path)
})
}
/**
* Get the url that the client is connected to.
*
* @returns The URL of the server this client is connected to.
* @category Network
*/
public get url(): string {
return this.connection.getUrl()
}
/**
* @category Network
*/
public async request(
r: AccountChannelsRequest,
): Promise<AccountChannelsResponse>
public async request(
r: AccountCurrenciesRequest,
): Promise<AccountCurrenciesResponse>
public async request(r: AccountInfoRequest): Promise<AccountInfoResponse>
public async request(r: AccountLinesRequest): Promise<AccountLinesResponse>
public async request(r: AccountNFTsRequest): Promise<AccountNFTsResponse>
public async request(
r: AccountObjectsRequest,
): Promise<AccountObjectsResponse>
public async request(r: AccountOffersRequest): Promise<AccountOffersResponse>
public async request(r: AccountTxRequest): Promise<AccountTxResponse>
public async request(r: BookOffersRequest): Promise<BookOffersResponse>
public async request(r: ChannelVerifyRequest): Promise<ChannelVerifyResponse>
public async request(
r: DepositAuthorizedRequest,
): Promise<DepositAuthorizedResponse>
public async request(r: FeeRequest): Promise<FeeResponse>
public async request(
r: GatewayBalancesRequest,
): Promise<GatewayBalancesResponse>
public async request(r: LedgerRequest): Promise<LedgerResponse>
public async request(r: LedgerClosedRequest): Promise<LedgerClosedResponse>
public async request(r: LedgerCurrentRequest): Promise<LedgerCurrentResponse>
public async request(r: LedgerDataRequest): Promise<LedgerDataResponse>
public async request(r: LedgerEntryRequest): Promise<LedgerEntryResponse>
public async request(r: ManifestRequest): Promise<ManifestResponse>
public async request(r: NFTBuyOffersRequest): Promise<NFTBuyOffersResponse>
public async request(r: NFTSellOffersRequest): Promise<NFTSellOffersResponse>
public async request(r: NoRippleCheckRequest): Promise<NoRippleCheckResponse>
public async request(r: PathFindRequest): Promise<PathFindResponse>
public async request(r: PingRequest): Promise<PingResponse>
public async request(r: RandomRequest): Promise<RandomResponse>
public async request(
r: RipplePathFindRequest,
): Promise<RipplePathFindResponse>
public async request(r: ServerInfoRequest): Promise<ServerInfoResponse>
public async request(r: ServerStateRequest): Promise<ServerStateResponse>
public async request(r: SubmitRequest): Promise<SubmitResponse>
public async request(
r: SubmitMultisignedRequest,
): Promise<SubmitMultisignedResponse>
public request(r: SubscribeRequest): Promise<SubscribeResponse>
public request(r: UnsubscribeRequest): Promise<UnsubscribeResponse>
public async request(
r: TransactionEntryRequest,
): Promise<TransactionEntryResponse>
public async request(r: TxRequest): Promise<TxResponse>
public async request<R extends BaseRequest, T extends BaseResponse>(
r: R,
): Promise<T>
/**
* Makes a request to the client with the given command and
* additional request body parameters.
*
* @param req - Request to send to the server.
* @returns The response from the server.
* @category Network
*/
public async request<R extends Request, T extends Response>(
req: R,
): Promise<T> {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Necessary for overloading
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)
return response
}
/**
* @category Network
*/
public async requestNextPage(
req: AccountChannelsRequest,
resp: AccountChannelsResponse,
): Promise<AccountChannelsResponse>
public async requestNextPage(
req: AccountLinesRequest,
resp: AccountLinesResponse,
): Promise<AccountLinesResponse>
public async requestNextPage(
req: AccountObjectsRequest,
resp: AccountObjectsResponse,
): Promise<AccountObjectsResponse>
public async requestNextPage(
req: AccountOffersRequest,
resp: AccountOffersResponse,
): Promise<AccountOffersResponse>
public async requestNextPage(
req: AccountTxRequest,
resp: AccountTxResponse,
): Promise<AccountTxResponse>
public async requestNextPage(
req: LedgerDataRequest,
resp: LedgerDataResponse,
): Promise<LedgerDataResponse>
/**
* Requests the next page of data.
*
* @param req - Request to send.
* @param resp - Response with the marker to use in the request.
* @returns The response with the next page of data.
*/
public async requestNextPage<
T extends MarkerRequest,
U extends MarkerResponse,
>(req: T, resp: U): Promise<U> {
if (!resp.result.marker) {
return Promise.reject(
new NotFoundError('response does not have a next page'),
)
}
const nextPageRequest = { ...req, marker: resp.result.marker }
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Necessary for overloading
return this.request(nextPageRequest) as unknown as U
}
/**
* Event handler for subscription streams.
*
* @example
* ```ts
* const api = new Client('wss://s.altnet.rippletest.net:51233')
*
* api.on('transactions', (tx: TransactionStream) => {
* console.log("Received Transaction")
* console.log(tx)
* })
*
* await api.connect()
* const response = await api.request({
* command: 'subscribe',
* 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)
}
/**
* @category Network
*/
public async requestAll(
req: AccountChannelsRequest,
): Promise<AccountChannelsResponse[]>
public async requestAll(
req: AccountLinesRequest,
): Promise<AccountLinesResponse[]>
public async requestAll(
req: AccountObjectsRequest,
): Promise<AccountObjectsResponse[]>
public async requestAll(
req: AccountOffersRequest,
): Promise<AccountOffersResponse[]>
public async requestAll(req: AccountTxRequest): Promise<AccountTxResponse[]>
public async requestAll(req: BookOffersRequest): Promise<BookOffersResponse[]>
public async requestAll(req: LedgerDataRequest): Promise<LedgerDataResponse[]>
/**
* Makes multiple paged requests to the client to return a given number of
* resources. Multiple paged requests will be made until the `limit`
* number of resources is reached (if no `limit` is provided, a single request
* will be made).
*
* If the command is unknown, an additional `collect` property is required to
* know which response key contains the array of resources.
*
* NOTE: This command is used by existing methods and is not recommended for
* general use. Instead, use rippled's built-in pagination and make multiple
* requests as needed.
*
* @param request - The initial request to send to the server.
* @param collect - (Optional) the param to use to collect the array of resources (only needed if command is unknown).
* @returns The array of all responses.
* @throws ValidationError if there is no collection key (either from a known command or for the unknown command).
*/
public async requestAll<T extends MarkerRequest, U extends MarkerResponse>(
request: T,
collect?: string,
): Promise<U[]> {
/*
* The data under collection is keyed based on the command. Fail if command
* not recognized and collection key not provided.
*/
const collectKey = collect ?? getCollectKeyFromCommand(request.command)
if (!collectKey) {
throw new ValidationError(`no collect key for command ${request.command}`)
}
/*
* If limit is not provided, fetches all data over multiple requests.
* NOTE: This may return much more than needed. Set limit when possible.
*/
const countTo: number = request.limit == null ? Infinity : request.limit
let count = 0
let marker: unknown = request.marker
let lastBatchLength: number
const results: U[] = []
do {
const countRemaining = clamp(countTo - count, MIN_LIMIT, MAX_LIMIT)
const repeatProps = {
...request,
limit: countRemaining,
marker,
}
// eslint-disable-next-line no-await-in-loop -- Necessary for this, it really has to wait
const singleResponse = await this.connection.request(repeatProps)
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Should be true
const singleResult = (singleResponse as U).result
if (!(collectKey in singleResult)) {
throw new XrplError(`${collectKey} not in result`)
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- Should be true
const collectedData = singleResult[collectKey]
marker = singleResult.marker
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Should be true
results.push(singleResponse as U)
// Make sure we handle when no data (not even an empty array) is returned.
if (Array.isArray(collectedData)) {
count += collectedData.length
lastBatchLength = collectedData.length
} else {
lastBatchLength = 0
}
} while (Boolean(marker) && count < countTo && lastBatchLength !== 0)
return results
}
/**
* Tells the Client instance to connect to its rippled server.
*
* @returns A promise that resolves with a void value when a connection is established.
* @category Network
*/
public async connect(): Promise<void> {
return this.connection.connect()
}
/**
* Tells the Client instance to disconnect from it's rippled server.
*
* @returns A promise that resolves with a void value when a connection is destroyed.
* @category Network
*/
public async disconnect(): Promise<void> {
/*
* backwards compatibility: connection.disconnect() can return a number, but
* this method returns nothing. SO we await but don't return any result.
*/
await this.connection.disconnect()
}
/**
* Checks if the Client instance is connected to its rippled server.
*
* @returns Whether the client instance is connected.
* @category Network
*/
public isConnected(): boolean {
return this.connection.isConnected()
}
/**
* @category Core
*/
public autofill = autofill
/**
* @category Core
*/
public submit = submit
/**
* @category Core
*/
public submitAndWait = submitAndWait
/**
* @deprecated Use autofill instead, provided for users familiar with v1
*/
public prepareTransaction = autofill
/**
* @category Abstraction
*/
public getXrpBalance = getXrpBalance
/**
* @category Abstraction
*/
public getBalances = getBalances
/**
* @category Abstraction
*/
public getOrderbook = getOrderbook
/**
* @category Abstraction
*/
public getLedgerIndex = getLedgerIndex
/**
* @category Abstraction
*/
public getNetworkID = getNetworkID
/**
* @category Faucet
*/
public fundWallet = fundWallet
}
export { Client }