You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

xrpl-binding.ts 11KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. import { defaultOptions, Memo, Options } from '../util/types'
  2. import { Client, DisconnectedError, Payment, TxResponse, Wallet, } from 'xrpl'
  3. import * as zlib from 'zlib'
  4. import * as util from 'util'
  5. import { NON_ZERO_TX_HASH } from '../util/protocol.constants'
  6. import { BadTxHashError, CannotVerifyOwnerError } from '../util/errors'
  7. const compressB64 = async (data: string) => (await util.promisify(zlib.deflate)(Buffer.from(data, 'utf-8'))).toString('base64')
  8. const decompressB64 = async (data: string) => (await util.promisify(zlib.inflate)(Buffer.from(data, 'base64'))).toString('utf-8')
  9. const hexDecode = (str: string) => Buffer.from(str, 'hex').toString('utf8')
  10. const hexEncode = (str: string) => Buffer.from(str, 'utf8').toString('hex').toUpperCase()
  11. const chunkString = (str: string, length: number) => str.match(new RegExp('.{1,' + length + '}', 'gs'));
  12. const genRandHex = size => [...Array(size)].map(() => Math.floor(Math.random() * 16).toString(16)).join('');
  13. const PAYLOAD_SIZE = 925
  14. const XRP_PER_DROP = 0.000001
  15. const DROP_PER_XRP = 1000000
  16. export class xrpIO {
  17. private api: Client
  18. constructor(
  19. private server: string,
  20. private options: Options = defaultOptions
  21. ) {
  22. this.options.debug = options.debug ? Boolean(options.debug) : defaultOptions.debug
  23. this.options.connectionTimeout = options.connectionTimeout ? Number(options.connectionTimeout) : defaultOptions.connectionTimeout
  24. this.options.readMaxRetry = options.readMaxRetry ? Number(options.readMaxRetry) : defaultOptions.readMaxRetry
  25. this.options.readRetryTimeout = options.readRetryTimeout ? Number(options.readRetryTimeout) : defaultOptions.readRetryTimeout
  26. this.options.readFreshApi = options.readFreshApi ? Boolean(options.readFreshApi) : defaultOptions.readFreshApi
  27. this.options.writeMaxRetry = options.writeMaxRetry ? Number(options.readFreshApi) : defaultOptions.writeMaxRetry
  28. this.options.writeRetryTimeout = options.writeRetryTimeout ? Number(options.writeRetryTimeout) : defaultOptions.writeRetryTimeout
  29. this.api = new Client(server, {
  30. connectionTimeout: this.options.connectionTimeout
  31. })
  32. }
  33. public async connect(): Promise<void> {
  34. if (!this.api.isConnected())
  35. await this.api.connect()
  36. }
  37. public async disconnect(): Promise<void> {
  38. try {
  39. await this.api.disconnect()
  40. } catch (e) {
  41. console.log("DISCONNECT ERROR", e)
  42. }
  43. }
  44. private async cloneApi(): Promise<Client> {
  45. let _api = new Client(this.server, {
  46. connectionTimeout: this.options.connectionTimeout
  47. })
  48. while (!_api.isConnected()) {
  49. try {
  50. await _api.connect()
  51. return _api
  52. } catch (e) {
  53. this.dbg('CLONEAPI ERR', 'Connection failed', String(e['message']))
  54. await _api.disconnect()
  55. _api = new Client(this.server, {
  56. connectionTimeout: this.options.connectionTimeout
  57. })
  58. }
  59. }
  60. }
  61. public async sendPayment(data: Memo, to: string, secret: string, sequence?: number, amount: string = "1", retry = 0): Promise<TxResponse> {
  62. const wallet = Wallet.fromSecret(secret)
  63. this.dbg("Sending payment", wallet.address, '->', to)
  64. const _api = await this.cloneApi()
  65. const payment: Payment = await _api.autofill({
  66. TransactionType: 'Payment',
  67. Account: wallet.address,
  68. Destination: to,
  69. Sequence: sequence,
  70. Amount: amount,
  71. Memos: [{
  72. Memo: {
  73. MemoData: hexEncode(data.data || ""),
  74. MemoFormat: hexEncode(data.format || ""),
  75. MemoType: hexEncode(data.type || "")
  76. }
  77. }]
  78. })
  79. let response: TxResponse;
  80. try {
  81. response = await _api.submitAndWait(payment, { wallet })
  82. } catch (error: any) {
  83. this.dbg("SENDPAYMENT ERROR", error)
  84. if(error instanceof DisconnectedError){
  85. //Usually caused by hitting rate limits. Recoverable.
  86. if(this.options.writeMaxRetry != -1 && retry >= this.options.writeMaxRetry){
  87. //exceeded retry quota
  88. throw error
  89. }
  90. await new Promise(res => setTimeout(res, this.options.writeRetryTimeout))
  91. return await this.sendPayment(data, to, secret, sequence, amount, retry+1)
  92. }
  93. throw error
  94. }finally{
  95. await _api.disconnect()
  96. }
  97. //We cannot afford this payment. Irrecoverable error
  98. if(response.result.meta && response.result.meta['TransactionResult'] && response.result.meta['TransactionResult'] === 'tecUNFUNDED_PAYMENT'){
  99. throw new Error(`Insufficient funds to send transaction. See tx ${response.result.hash}`)
  100. }
  101. this.dbg("Tx finalized", response.result.hash, response.result.Sequence)
  102. return response
  103. }
  104. public async writeRaw(data: Memo, to: string, secret: string, sequence?: number, amount: string = "1"): Promise<string> {
  105. this.dbg("Writing data", data)
  106. const tx = await this.sendPayment(data, to, secret, sequence, amount)
  107. return tx.result.hash
  108. }
  109. public async getTransaction(hash: string, retry = 0): Promise<TxResponse> {
  110. if (!NON_ZERO_TX_HASH.test(hash)) {
  111. throw new BadTxHashError(hash)
  112. }
  113. this.dbg("Getting Tx", hash)
  114. const _api = this.options.readFreshApi ? await this.cloneApi() : this.api
  115. try {
  116. return await _api.request({
  117. command: 'tx',
  118. transaction: hash,
  119. })
  120. } catch (e: any) {
  121. this.dbg("getTransaction err", e)
  122. if(e.data){ //RippledError
  123. switch(e.data.error){
  124. //irrecoverable errors
  125. case 'amendmentBlocked': //server is amendment blocked and needs to be updated to the latest version to stay synced with the XRP Ledger network.
  126. case 'invalid_API_version': //The server does not support the API version number from the request.
  127. case 'jsonInvalid': //(WebSocket only) The request is not a proper JSON object.
  128. case 'missingCommand': //(WebSocket only) The request did not specify a command field
  129. case 'noClosed': //The server does not have a closed ledger, typically because it has not finished starting up.
  130. case 'txnNotFound': //Either the transaction does not exist, or it was part of an ledger version that rippled does not have available
  131. case 'unknownCmd': //The request does not contain a command that the rippled server recognizes
  132. case 'wsTextRequired': //(WebSocket only) The request's opcode is not text.
  133. case 'invalidParams': //One or more fields are specified incorrectly, or one or more required fields are missing.
  134. case 'excessiveLgrRange': //The min_ledger and max_ledger fields of the request are more than 1000 apart
  135. case 'invalidLgrRange': //The specified min_ledger is larger than the max_ledger, or one of those parameters is not a valid ledger index
  136. throw e
  137. //potentially recoverable errors
  138. case 'failedToForward': //(Reporting Mode servers only) The server tried to forward this request to a P2P Mode server, but the connection failed
  139. case 'noCurrent': //The server does not know what the current ledger is, due to high load, network problems, validator failures, incorrect configuration, or some other problem.
  140. case 'noNetwork': //The server is having trouble connecting to the rest of the XRP Ledger peer-to-peer network (and is not running in stand-alone mode).
  141. case 'tooBusy': //The server is under too much load to do this command right now. Generally not returned if you are connected as an admin
  142. default: //some undocumented error, might as well give it a re-try
  143. //fall through
  144. }
  145. }
  146. //some other error, potentially recoverable
  147. if (this.options.readMaxRetry != -1 && retry >= this.options.readMaxRetry) { //not doing infinite retries and exhausted retry quota
  148. throw e
  149. }else{
  150. await new Promise(res => setTimeout(res, this.options.readRetryTimeout))
  151. return await this.getTransaction(hash, retry + 1)
  152. }
  153. }finally{
  154. if(this.options.readFreshApi) await _api.disconnect()
  155. }
  156. }
  157. public async readRaw(hash: string, verifyOwner?: string): Promise<Memo> {
  158. if (!NON_ZERO_TX_HASH.test(hash)) {
  159. throw new BadTxHashError(hash)
  160. }
  161. const tx = await this.getTransaction(hash)
  162. if(verifyOwner && tx.result.Account != verifyOwner){
  163. throw new CannotVerifyOwnerError(hash, tx.result.Account, verifyOwner)
  164. }
  165. const memo = tx.result.Memos[0].Memo
  166. const memoParsed = {
  167. data: hexDecode(memo.MemoData),
  168. format: hexDecode(memo.MemoFormat),
  169. type: hexDecode(memo.MemoType)
  170. }
  171. this.dbg(hash, "data", memoParsed)
  172. return memoParsed
  173. }
  174. public async treeWrite(data: string, to: string, secret: string, format: 'L' | 'N' = 'L'): Promise<string> {
  175. const wallet = Wallet.fromSecret(secret)
  176. data = await compressB64(data)
  177. const chunks = chunkString(data, PAYLOAD_SIZE)
  178. const latestSequence = await this.getAccountSequence(wallet.address)
  179. const hashes = await Promise.all(Object.entries(chunks).map(([i, chunk]) => this.writeRaw({ data: chunk, format: format }, to, secret, latestSequence + Number(i))))
  180. if (hashes.length === 1) {
  181. return hashes[0]
  182. }
  183. return await this.treeWrite(JSON.stringify(hashes), to, secret, 'N')
  184. }
  185. public async treeRead(hashes: string[], verifyOwner?:string): Promise<string> {
  186. const bad_hash = hashes.find(hash => !NON_ZERO_TX_HASH.test(hash))
  187. if (bad_hash)
  188. throw new BadTxHashError(bad_hash)
  189. const memos = await Promise.all(hashes.map(hash => this.readRaw(hash, verifyOwner)))
  190. const payload: string = await decompressB64(memos.map(memo => memo.data).join(''))
  191. if (memos.some(memo => memo.format === 'N')) {
  192. return await this.treeRead(JSON.parse(payload), verifyOwner)
  193. }
  194. return payload
  195. }
  196. public async getAccountSequence(address: string): Promise<number> {
  197. this.dbg("Getting acc info for", address)
  198. const accountInfo = await this.api.request({
  199. command: 'account_info',
  200. account: address,
  201. strict: true,
  202. })
  203. this.dbg("Got account_info", accountInfo)
  204. return Number(accountInfo.result.account_data.Sequence)
  205. }
  206. public async estimateFee(data: string, denomination: 'XRP' | 'DROPS' = 'DROPS', cost = 0): Promise<number> {
  207. data = await compressB64(data)
  208. const chunks = chunkString(data, PAYLOAD_SIZE)
  209. if (chunks.length === 1) {
  210. return (denomination === "DROPS" ? (cost + 1) : this.dropsToXrp(cost + 1))
  211. }
  212. return this.estimateFee(JSON.stringify(chunks.map(_ => genRandHex(64))), denomination, cost + chunks.length)
  213. }
  214. public xrpToDrops(xrp: number): number {
  215. return xrp * DROP_PER_XRP
  216. }
  217. public dropsToXrp(drops: number): number {
  218. return drops * XRP_PER_DROP
  219. }
  220. private dbg(...args: any[]) {
  221. if (this.options.debug) {
  222. console.log.apply(console, args)
  223. }
  224. }
  225. }