import { defaultOptions, Memo, Options } from '../util/types' import { Client, Payment, TxResponse, Wallet } from 'xrpl' import * as zlib from 'zlib' import * as util from 'util' import { NON_ZERO_TX_HASH } from '../util/protocol.constants' import { ERR_BAD_TX_HASH } from '../util/errors' const compressB64 = async (data: string) => (await util.promisify(zlib.deflate)(Buffer.from(data, 'utf-8'))).toString('base64') const decompressB64 = async (data: string) => (await util.promisify(zlib.inflate)(Buffer.from(data, 'base64'))).toString('utf-8') const hexDecode = (str: string) => Buffer.from(str, 'hex').toString('utf8') const hexEncode = (str: string) => Buffer.from(str, 'utf8').toString('hex').toUpperCase() const chunkString = (str: string, length: number) => str.match(new RegExp('.{1,' + length + '}', 'gs')); const genRandHex = size => [...Array(size)].map(() => Math.floor(Math.random() * 16).toString(16)).join(''); const PAYLOAD_SIZE = 925 const XRP_PER_DROP = 0.000001 const DROP_PER_XRP = 1000000 export class xrpIO { private api: Client constructor( private server: string, private options: Options = defaultOptions ) { this.options.debug = options.debug ? Boolean(options.debug) : defaultOptions.debug this.options.connectionTimeout = options.connectionTimeout ? Number(options.connectionTimeout) : defaultOptions.connectionTimeout this.options.readMaxRetry = options.readMaxRetry ? Number(options.readMaxRetry) : defaultOptions.readMaxRetry this.options.readRetryTimeout = options.readRetryTimeout ? Number(options.readRetryTimeout) : defaultOptions.readRetryTimeout this.api = new Client(server, { connectionTimeout: this.options.connectionTimeout }) } public async connect(): Promise { if (!this.api.isConnected()) await this.api.connect() } public async disconnect(): Promise { try { await this.api.disconnect() } catch (e) { console.log("DISCONNECT ERROR", e) } } private async cloneApi(): Promise { let _api = new Client(this.server, { connectionTimeout: this.options.connectionTimeout }) while (!_api.isConnected()) { try { await _api.connect() return _api } catch (e) { this.dbg('CLONEAPI ERR', 'Connection failed', String(e['message'])) await _api.disconnect() _api = new Client(this.server, { connectionTimeout: this.options.connectionTimeout }) } } } private async sendPayment(data: Memo, to: string, secret: string, sequence?: number, amount: string = "1"): Promise { const wallet = Wallet.fromSecret(secret) this.dbg("Sending payment", wallet.address, '->', to) const _api = await this.cloneApi() try { const payment: Payment = await _api.autofill({ TransactionType: 'Payment', Account: wallet.address, Destination: to, Sequence: sequence, Amount: amount, Memos: [{ Memo: { MemoData: hexEncode(data.data || ""), MemoFormat: hexEncode(data.format || ""), MemoType: hexEncode(data.type || "") } }] }) const response = await _api.submitAndWait(payment, { wallet }) await _api.disconnect() this.dbg("Tx finalized", response.result.hash, response.result.Sequence) return response } catch (error: any) { this.dbg("SENDPAYMENT ERROR", error) await _api.disconnect() throw error } } public async writeRaw(data: Memo, to: string, secret: string, sequence?: number, amount: string = "1"): Promise { this.dbg("Writing data", data) const tx = await this.sendPayment(data, to, secret, sequence, amount) return tx.result.hash } private async getTransaction(hash: string, retry = 0): Promise { this.dbg("Getting Tx", hash) try { return await this.api.request({ command: 'tx', transaction: hash, }) } catch (e) { this.dbg(e) if (this.options.readMaxRetry != -1) { if (retry >= this.options.readMaxRetry) console.error("Retry limit exceeded for", hash, ". This is an irrecoverable error") throw e } await new Promise(res => setTimeout(res, this.options.readRetryTimeout)) return await this.getTransaction(hash, retry + 1) } } public async readRaw(hash: string): Promise { if (!NON_ZERO_TX_HASH.test(hash)) { throw ERR_BAD_TX_HASH(hash) } const tx = await this.getTransaction(hash) const memo = tx.result.Memos[0].Memo const memoParsed = { data: hexDecode(memo.MemoData), format: hexDecode(memo.MemoFormat), type: hexDecode(memo.MemoType) } this.dbg(hash, "data", memoParsed) return memoParsed } public async treeWrite(data: string, to: string, secret: string, format: 'L' | 'N' = 'L'): Promise { const wallet = Wallet.fromSecret(secret) data = await compressB64(data) const chunks = chunkString(data, PAYLOAD_SIZE) const latestSequence = await this.getAccountSequence(wallet.address) const hashes = await Promise.all(Object.entries(chunks).map(([i, chunk]) => this.writeRaw({ data: chunk, format: format }, to, secret, latestSequence + Number(i)))) if (hashes.length === 1) { return hashes[0] } return await this.treeWrite(JSON.stringify(hashes), to, secret, 'N') } public async treeRead(hashes: string[]): Promise { const bad_hash = hashes.find(hash => !NON_ZERO_TX_HASH.test(hash)) if (bad_hash) throw ERR_BAD_TX_HASH(bad_hash) const memos = await Promise.all(hashes.map(hash => this.readRaw(hash))) const payload: string = await decompressB64(memos.map(memo => memo.data).join('')) if (memos.some(memo => memo.format === 'N')) { return await this.treeRead(JSON.parse(payload)) } return payload } public async getAccountSequence(address: string): Promise { this.dbg("Getting acc info for", address) const accountInfo = await this.api.request({ command: 'account_info', account: address, strict: true, }) this.dbg("Got account_info", accountInfo) return Number(accountInfo.result.account_data.Sequence) } public async estimateFee(data: string, denomination: 'XRP' | 'DROPS' = 'DROPS', cost = 0): Promise { data = await compressB64(data) const chunks = chunkString(data, PAYLOAD_SIZE) if (chunks.length === 1) { return (denomination === "DROPS" ? (cost + 1) : this.dropsToXrp(cost + 1)) } return this.estimateFee(JSON.stringify(chunks.map(_ => genRandHex(64))), denomination, cost + chunks.length) } public xrpToDrops(xrp: number): number { return xrp * DROP_PER_XRP } public dropsToXrp(drops: number): number { return drops * XRP_PER_DROP } private dbg(...args: any[]) { if (this.options.debug) { console.log.apply(console, args) } } }