import { assert } from 'chai' import omit from 'lodash/omit' import throttle from 'lodash/throttle' import { decode } from 'xahau-binary-codec' import { Client, Wallet, AccountInfoRequest, type SubmitResponse, TimeoutError, NotConnectedError, ECDSA, AccountLinesRequest, IssuedCurrency, } from '../../src' import { Payment, SubmittableTransaction, Transaction, } from '../../src/models/transactions' import { hashSignedTx } from '../../src/utils/hashes' export const GENESIS_ACCOUNT = 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh' const GENESIS_SECRET = 'snoPBrXtMeMyMHUVTgbuqAfg1SUTb' export async function sendLedgerAccept(client: Client): Promise { return client.connection.request({ command: 'ledger_accept' }) } /** * Throttles an async function in a way that can be awaited. * By default throttle doesn't return a promise for async functions unless it's invoking them immediately. * * @param func - async function to throttle calls for. * @param wait - same function as lodash.throttle's wait parameter. Call this function at most this often. * @returns a promise which will be resolved/ rejected only if the function is executed, with the result of the underlying call. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Proper function asyncThrottle Promise>( func: F, wait?: number, ): (...args: Parameters) => ReturnType { const throttled = throttle((resolve, reject, args: Parameters) => { func(...args) .then(resolve) .catch(reject) }, wait) const ret = (...args: Parameters): ReturnType => new Promise((resolve, reject) => { throttled(resolve, reject, args) }) as ReturnType return ret } const throttledLedgerAccept = asyncThrottle(sendLedgerAccept, 1000) export async function ledgerAccept( client: Client, retries?: number, shouldThrottle?: boolean, ): Promise { return new Promise((resolve, reject) => { const ledgerAcceptFunc = shouldThrottle ? throttledLedgerAccept : sendLedgerAccept ledgerAcceptFunc(client) .then(resolve) .catch((error) => { if (retries === undefined) { setTimeout(() => { resolve(ledgerAccept(client, 10)) }, 1000) } else if (retries > 0) { setTimeout(() => { resolve(ledgerAccept(client, retries - 1)) }, 1000) } else { reject(error) } }) }) } export function subscribeDone(client: Client): void { client.removeAllListeners() } export async function submitTransaction({ client, transaction, wallet, retry = { count: 5, delayMs: 1000 }, }: { client: Client transaction: SubmittableTransaction wallet: Wallet retry?: { count: number delayMs: number } }): Promise { let response: SubmitResponse try { response = await client.submit(transaction, { wallet }) // Retry if another transaction finished before this one while ( ['tefPAST_SEQ', 'tefMAX_LEDGER'].includes( response.result.engine_result, ) && retry.count > 0 ) { // eslint-disable-next-line no-param-reassign -- we want to decrement the count retry.count -= 1 // eslint-disable-next-line no-await-in-loop, no-promise-executor-return -- We are waiting on retries await new Promise((resolve) => setTimeout(resolve, retry.delayMs)) // eslint-disable-next-line no-await-in-loop -- We are retrying in a loop on purpose response = await client.submit(transaction, { wallet }) } } catch (error) { if (error instanceof TimeoutError || error instanceof NotConnectedError) { // retry return submitTransaction({ client, transaction, wallet, retry: { ...retry, count: retry.count > 0 ? retry.count - 1 : 0, }, }) } throw error } return response } export async function fundAccount( client: Client, wallet: Wallet, retry?: { count: number delayMs: number }, ): Promise { const payment: Payment = { TransactionType: 'Payment', Account: GENESIS_ACCOUNT, Destination: wallet.classicAddress, // 2 times the amount needed for a new account (20 XAH) Amount: '400000000', } const wal = Wallet.fromSeed(GENESIS_SECRET, { algorithm: ECDSA.secp256k1 }) const response = await submitTransaction({ client, wallet: wal, transaction: payment, retry, }) if (response.result.engine_result !== 'tesSUCCESS') { // eslint-disable-next-line no-console -- happens only when something goes wrong console.log(response) assert.fail(`Response not successful, ${response.result.engine_result}`) } await ledgerAccept(client) const signedTx = omit(response.result.tx_json, 'hash') await verifySubmittedTransaction(client, signedTx as Transaction) return response } export async function generateFundedWallet(client: Client): Promise { const wallet = Wallet.generate() await fundAccount(client, wallet) return wallet } export async function verifySubmittedTransaction( client: Client, tx: Transaction | string, hashTx?: string, ): Promise { const hash = hashTx ?? hashSignedTx(tx) const data = await client.request({ command: 'tx', transaction: hash, }) // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO: handle this API change for 2.0.0 const decodedTx: any = typeof tx === 'string' ? decode(tx) : tx if (decodedTx.TransactionType === 'Payment' && client.apiVersion !== 1) { decodedTx.DeliverMax = decodedTx.Amount delete decodedTx.Amount } assert(data.result) assert.deepEqual( omit(data.result, [ 'ctid', 'date', 'hash', 'inLedger', 'ledger_index', 'meta', 'validated', ]), decodedTx, ) if (typeof data.result.meta === 'object') { assert.strictEqual(data.result.meta.TransactionResult, 'tesSUCCESS') } else { assert.strictEqual(data.result.meta, 'tesSUCCESS') } } /** * Sends a test transaction for integration testing. * * @param client - The XAHL client * @param transaction - The transaction object to send. * @param wallet - The wallet to send the transaction from. * @param retry - As of Sep 2022, xrpl.js does not track requests sent in parallel. Our sequence numbers can get off from * the server's sequence numbers. This is a fix to retry the transaction if it fails due to tefPAST_SEQ. * @param retry.count - How many times the request should be retried. * @param retry.delayMs - How long to wait between retries. * @returns The response of the transaction. */ // eslint-disable-next-line max-params -- Test function, many params are needed export async function testTransaction( client: Client, transaction: SubmittableTransaction, wallet: Wallet, retry?: { count: number delayMs: number }, ): Promise { // Accept any un-validated changes. // sign/submit the transaction const response = await submitTransaction({ client, wallet, transaction, retry, }) // check that the transaction was successful assert.equal(response.type, 'response') if (response.result.engine_result !== 'tesSUCCESS') { // eslint-disable-next-line no-console -- See output console.error( `Transaction was not successful. Expected response.result.engine_result to be tesSUCCESS but got ${response.result.engine_result}`, ) // eslint-disable-next-line no-console -- See output console.error('The transaction was: ', transaction) // eslint-disable-next-line no-console -- See output console.error('The response was: ', JSON.stringify(response)) } assert.equal( response.result.engine_result, 'tesSUCCESS', response.result.engine_result_message, ) // check that the transaction is on the ledger const signedTx = omit(response.result.tx_json, 'hash') await ledgerAccept(client) await verifySubmittedTransaction(client, signedTx as Transaction) return response } export async function getXAHBalance( client: Client, account: string | Wallet, ): Promise { const address: string = typeof account === 'string' ? account : account.classicAddress const request: AccountInfoRequest = { command: 'account_info', account: address, } return (await client.request(request)).result.account_data.Balance } /** * Retrieves the close time of the ledger. * * @param client - The client object. * @returns - A promise that resolves to the close time of the ledger. * * @example * const closeTime = await getLedgerCloseTime(client); * console.log(closeTime); // Output: 1626424978 */ export async function getLedgerCloseTime(client: Client): Promise { const CLOSE_TIME: number = ( await client.request({ command: 'ledger', ledger_index: 'validated', }) ).result.ledger.close_time return CLOSE_TIME } /** * Waits for the ledger time to reach a specific value and forces ledger progress if necessary. * * @param client - The client object. * @param ledgerTime - The target ledger time. * @param [retries=20] - The number of retries before throwing an error. * @returns - A promise that resolves when the ledger time reaches the target value. * * @example * try { * await waitForAndForceProgressLedgerTime(client, 1626424978, 10); * console.log('Ledger time reached.'); // Output: Ledger time reached. * } catch (error) { * console.error(error); * } */ export async function waitForAndForceProgressLedgerTime( client: Client, ledgerTime: number, retries = 20, ): Promise { async function getCloseTime(): Promise { const CLOSE_TIME: number = await getLedgerCloseTime(client) if (CLOSE_TIME >= ledgerTime) { return true } return false } let retryCounter = retries || 0 while (retryCounter > 0) { // eslint-disable-next-line no-await-in-loop -- Necessary for retries if (await getCloseTime()) { return } // eslint-disable-next-line no-await-in-loop -- Necessary for retries await ledgerAccept(client) retryCounter -= 1 } throw new Error(`Ledger time not reached after ${retries} retries.`) } export async function getIOUBalance( client: Client, wallet: Wallet, currency: IssuedCurrency, ): Promise { const request: AccountLinesRequest = { command: 'account_lines', account: wallet.classicAddress, peer: currency.issuer, } return (await client.request(request)).result.lines[0].balance }