Browse Source

Added several improvements to failed read/write recovery strategies

master
nitowa 11 months ago
parent
commit
3fea9e1ac7
6 changed files with 78 additions and 216 deletions
  1. 0
    3
      src/index.ts
  2. 11
    2
      src/util/errors.ts
  3. 6
    2
      src/util/types.ts
  4. 0
    197
      src/xrpIO/ripple-binding.ts
  5. 31
    11
      src/xrpIO/xrpl-binding.ts
  6. 30
    1
      test/primitives.ts

+ 0
- 3
src/index.ts View File

@@ -1,6 +1,3 @@
1
-//export * from './xrpIO/ripple-binding'
2 1
 export * from './util/types'
3 2
 export * from './util/protocol.constants'
4
-//export * from 'ripple-lib'
5
-//export { RippleAPI } from 'ripple-lib'
6 3
 export * from './xrpIO/xrpl-binding'

+ 11
- 2
src/util/errors.ts View File

@@ -1,2 +1,11 @@
1
-export const ERR_BAD_TX_HASH = (hash:string) => new Error(`Bad tx hash format: "${hash}"`)
2
-export const ERR_NO_VERIFY_OWNER = (hash: string, actualAccount: string, desiredAccount: string) => new Error(`Expected tx "${hash}" to be initiated by ${desiredAccount} but was ${actualAccount}`)
1
+export class BadTxHashError extends Error{
2
+    constructor(hash:string){
3
+        super(`Bad tx hash format: "${hash}"`)
4
+    }
5
+}
6
+
7
+export class CannotVerifyOwnerError extends Error{
8
+    constructor(hash: string, actualAccount: string, desiredAccount: string){
9
+        super((`Expected tx "${hash}" to be initiated by ${desiredAccount} but was ${actualAccount}`))
10
+    }
11
+}

+ 6
- 2
src/util/types.ts View File

@@ -19,12 +19,16 @@ export type Options = {
19 19
     readMaxRetry?: number
20 20
     readRetryTimeout?: number,
21 21
     readFreshApi?:boolean,
22
+    writeMaxRetry?: number
23
+    writeRetryTimeout?: number
22 24
 }
23 25
 
24
-export const defaultOptions = {
26
+export const defaultOptions: Options = {
25 27
     debug: false,
26 28
     connectionTimeout: 100000,
27 29
     readFreshApi: true,
28 30
     readMaxRetry: 50,
29
-    readRetryTimeout: 750
31
+    readRetryTimeout: 750,
32
+    writeMaxRetry: 10,
33
+    writeRetryTimeout: 10000
30 34
   }

+ 0
- 197
src/xrpIO/ripple-binding.ts View File

@@ -1,197 +0,0 @@
1
-import { Memo, Wallet } from '../util/types'
2
-import { MIN_XRP_FEE, MIN_XRP_TX_VALUE } from '../util/protocol.constants';
3
-
4
-import { Instructions, LedgerClosedEvent, RippleAPI } from 'ripple-lib'
5
-import { Payment } from 'ripple-lib/dist/npm/transaction/payment';
6
-
7
-import * as zlib from 'zlib'
8
-import * as util from 'util'
9
-
10
-const chunkString = (str: string, length: number) => str.match(new RegExp('.{1,' + length + '}', 'gs'));
11
-const PAYLOAD_SIZE = 925
12
-const debug = false
13
-
14
-const cloneApi = async (api: RippleAPI): Promise<RippleAPI> => {
15
-  try {
16
-    const subApi = new RippleAPI({ server: api.connection['_url'] })
17
-    await subApi.connect()
18
-    return subApi
19
-  } catch (e) {
20
-    if (debug) {
21
-      console.log("CLONEAPI ERR", e)
22
-    }
23
-    return await cloneApi(api)
24
-  }
25
-}
26
-
27
-export const getLatestSequence = async (api: RippleAPI, accountAddress: string): Promise<number> => {
28
-  if (debug) console.log("Getting acc info for", accountAddress)
29
-  const accountInfo = await api.getAccountInfo(accountAddress, {})
30
-  return Number(accountInfo.sequence - 1)
31
-}
32
-
33
-const compressB64 = async (data: string) => (await util.promisify(zlib.deflate)(Buffer.from(data, 'utf-8'))).toString('base64')
34
-const decompressB64 = async (data: string) => (await util.promisify(zlib.inflate)(Buffer.from(data, 'base64'))).toString('utf-8')
35
-
36
-const sendReliably = (api: RippleAPI, signed: any, preparedPayment,): Promise<any> => new Promise((res, rej) => {
37
-  const ledgerClosedCallback = async (event: LedgerClosedEvent) => {
38
-    let status
39
-    try {
40
-      status = await api.getTransaction(signed.id, {
41
-        minLedgerVersion: 25235454
42
-      })
43
-    } catch (e) {
44
-
45
-      // Typical error when the tx hasn't been validated yet:
46
-      if ((e as Error).name !== 'MissingLedgerHistoryError') {
47
-        //console.log(e)
48
-      }
49
-
50
-      if (event.ledger_index > preparedPayment.instructions.maxLedgerVersion + 3) {
51
-        // Assumptions:
52
-        // - We are still connected to the same rippled server
53
-        // - No ledger gaps occurred
54
-        // - All ledgers between the time we submitted the tx and now have been checked for the tx
55
-        status = {
56
-          finalResult: 'Transaction was not, and never will be, included in a validated ledger'
57
-        }
58
-        return rej(status);
59
-
60
-      } else {
61
-        // Check again later:
62
-        api.connection.once('ledgerClosed', ledgerClosedCallback)
63
-        return
64
-      }
65
-    }
66
-    return res(status)
67
-  }
68
-  api.connection.once('ledgerClosed', ledgerClosedCallback)
69
-})
70
-
71
-export const sendPayment = async (api: RippleAPI, data: Memo[], from: string, to: string, secret: string, sequence: number) => {
72
-  if (debug) console.log("Sending payment with seq", sequence)
73
-  const options: Instructions = {
74
-    maxLedgerVersionOffset: 5,
75
-    fee: MIN_XRP_FEE,
76
-    sequence: sequence,
77
-  };
78
-  const payment: Payment = {
79
-    source: {
80
-      address: from,
81
-      maxAmount: {
82
-        value: MIN_XRP_TX_VALUE,
83
-        currency: 'XRP'
84
-      },
85
-    },
86
-    destination: {
87
-      address: to,
88
-      amount: {
89
-        value: MIN_XRP_TX_VALUE,
90
-        currency: 'XRP'
91
-      },
92
-    },
93
-    memos: data,
94
-  };
95
-
96
-  const _api = await cloneApi(api)
97
-  try {
98
-    const prepared = await _api.preparePayment(from, payment, options)
99
-    const signed = _api.sign(prepared.txJSON, secret);
100
-    const txHash = await _api.submit(signed.signedTransaction)
101
-    //if(debug) console.log("Transaction submitted", txHash)
102
-    await sendReliably(_api, signed, prepared)
103
-
104
-    return txHash
105
-  } catch (error) {
106
-    if (debug)
107
-      console.log("SENDPAYMENT ERROR", error)
108
-    throw error
109
-  } finally {
110
-    _api.disconnect()
111
-  }
112
-}
113
-
114
-export const getTransactions = async (api: RippleAPI, address: string, minLedgerVersion: number = 25235454): Promise<any[]> => {
115
-  const txs = await api.getTransactions(address, {
116
-    minLedgerVersion: minLedgerVersion,
117
-    earliestFirst: true,
118
-    excludeFailures: true,
119
-  })
120
-  return txs
121
-}
122
-
123
-export const writeRaw = async (api: RippleAPI, data: Memo, from:
124
-  string, to: string, secret: string, sequence?: number): Promise<string> => {
125
-  //if (memoSize(data) > 1000) throw new Error("data length exceeds capacity")
126
-  try {
127
-    if (!sequence) {
128
-      const accountInfo = await getLatestSequence(api, from)
129
-      sequence = accountInfo + 1
130
-    }
131
-
132
-    const resp = await sendPayment(api, [data], from, to, secret, sequence)
133
-    return resp['tx_json'].hash
134
-  } catch (error) {
135
-    if (debug) {
136
-      console.log("WRITERAW ERR", error);
137
-    }
138
-    throw error
139
-  }
140
-}
141
-
142
-
143
-export const readRaw = async (api: RippleAPI, hash: string): Promise<Memo> => {
144
-  api = await cloneApi(api)
145
-  let tx
146
-  try {
147
-    tx = await api.getTransaction(hash, {
148
-      minLedgerVersion: 25235454
149
-    })
150
-  } catch (e) {
151
-    //    if(debug){
152
-    console.log("READRAW ERR", e)
153
-    api.isConnected
154
-    //    }
155
-    throw e
156
-  } finally {
157
-    await api.disconnect()
158
-  }
159
-
160
-  if (!tx || !tx.specification || !tx.specification['memos'] || !tx.specification['memos'][0]) {
161
-    console.log(tx)
162
-    throw new Error('Invalid Transaction ' + hash)
163
-  }
164
-  return tx.specification['memos'][0]
165
-}
166
-
167
-export const subscribe = async (api: RippleAPI, address: string, callback: (tx) => any) => {
168
-  api.connection.on('transaction', (tx) => callback(tx))
169
-  await api.connection.request({
170
-    command: 'subscribe',
171
-    accounts: [address],
172
-  })
173
-}
174
-
175
-export const treeWrite = async (api: RippleAPI, data: string, wallet: Wallet, to: string, format: 'L' | 'N' = 'L'): Promise<string> => {
176
-  data = await compressB64(data)
177
-  const chunks = chunkString(data, PAYLOAD_SIZE)
178
-  const latestSequence = await getLatestSequence(api, wallet.address)
179
-  const hashes = await Promise.all(Object.entries(chunks).map(([i, chunk]) => writeRaw(api, { data: chunk, format: format }, wallet.address, to, wallet.secret, latestSequence + Number(i) + 1)))
180
-
181
-  if (hashes.length === 1) {
182
-    return hashes[0]
183
-  }
184
-
185
-  return await treeWrite(api, JSON.stringify(hashes), wallet, to, 'N')
186
-}
187
-
188
-export const treeRead = async (api: RippleAPI, hashes: string[]): Promise<string> => {
189
-  const memos = await Promise.all(hashes.map(hash => readRaw(api, hash)))
190
-  const payload: string = await decompressB64(memos.map(memo => memo.data).join(''))
191
-
192
-  if (memos.some(memo => memo.format === 'N')) {
193
-    return await treeRead(api, JSON.parse(payload))
194
-  }
195
-
196
-  return payload
197
-}

+ 31
- 11
src/xrpIO/xrpl-binding.ts View File

@@ -1,10 +1,10 @@
1 1
 import { defaultOptions, Memo, Options } from '../util/types'
2
-import { Client, Payment, RippledError, TxResponse, Wallet } from 'xrpl'
2
+import { Client, DisconnectedError, Payment, TxResponse, Wallet,  } from 'xrpl'
3 3
 
4 4
 import * as zlib from 'zlib'
5 5
 import * as util from 'util'
6 6
 import { NON_ZERO_TX_HASH } from '../util/protocol.constants'
7
-import { ERR_BAD_TX_HASH, ERR_NO_VERIFY_OWNER } from '../util/errors'
7
+import { BadTxHashError, CannotVerifyOwnerError } from '../util/errors'
8 8
 
9 9
 const compressB64 = async (data: string) => (await util.promisify(zlib.deflate)(Buffer.from(data, 'utf-8'))).toString('base64')
10 10
 const decompressB64 = async (data: string) => (await util.promisify(zlib.inflate)(Buffer.from(data, 'base64'))).toString('utf-8')
@@ -30,6 +30,8 @@ export class xrpIO {
30 30
     this.options.readMaxRetry = options.readMaxRetry ? Number(options.readMaxRetry) : defaultOptions.readMaxRetry
31 31
     this.options.readRetryTimeout = options.readRetryTimeout ? Number(options.readRetryTimeout) : defaultOptions.readRetryTimeout
32 32
     this.options.readFreshApi = options.readFreshApi ? Boolean(options.readFreshApi) : defaultOptions.readFreshApi
33
+    this.options.writeMaxRetry = options.writeMaxRetry ? Number(options.readFreshApi) : defaultOptions.writeMaxRetry
34
+    this.options.writeRetryTimeout = options.writeRetryTimeout ? Number(options.writeRetryTimeout) : defaultOptions.writeRetryTimeout
33 35
 
34 36
     this.api = new Client(server, {
35 37
       connectionTimeout: this.options.connectionTimeout
@@ -68,7 +70,7 @@ export class xrpIO {
68 70
     }
69 71
   }
70 72
 
71
-  public async sendPayment(data: Memo, to: string, secret: string, sequence?: number, amount: string = "1"): Promise<TxResponse> {
73
+  public async sendPayment(data: Memo, to: string, secret: string, sequence?: number, amount: string = "1", retry = 0): Promise<TxResponse> {
72 74
     const wallet = Wallet.fromSecret(secret)
73 75
     this.dbg("Sending payment", wallet.address, '->', to)
74 76
 
@@ -88,17 +90,35 @@ export class xrpIO {
88 90
       }]
89 91
     })
90 92
 
93
+    let response: TxResponse;
91 94
     try {
92
-      const response = await _api.submitAndWait(payment, { wallet })
93
-      this.dbg("Tx finalized", response.result.hash, response.result.Sequence)
94
-      return response
95
+      response = await _api.submitAndWait(payment, { wallet })
95 96
     } catch (error: any) {
96 97
       this.dbg("SENDPAYMENT ERROR", error)
97
-      console.log("SENDPAYMENT ERROR", error)
98
+
99
+      if(error instanceof DisconnectedError){
100
+        //Usually caused by hitting rate limits. Recoverable.
101
+        if(this.options.writeMaxRetry != -1 && retry >= this.options.writeMaxRetry){
102
+          //exceeded retry quota
103
+          throw error
104
+        }
105
+        await new Promise(res => setTimeout(res, this.options.writeRetryTimeout))
106
+        return await this.sendPayment(data, to, secret, sequence, amount, retry+1)
107
+      }
108
+
98 109
       throw error
99 110
     }finally{
100 111
       await _api.disconnect()
101 112
     }
113
+
114
+    //We cannot afford this payment. Irrecoverable error
115
+    if(response.result.meta && response.result.meta['TransactionResult'] && response.result.meta['TransactionResult'] === 'tecUNFUNDED_PAYMENT'){
116
+      throw new Error(`Insufficient funds to send transaction. See tx ${response.result.hash}`)
117
+    }
118
+    
119
+    this.dbg("Tx finalized", response.result.hash, response.result.Sequence)
120
+    return response
121
+
102 122
   }
103 123
 
104 124
   public async writeRaw(data: Memo, to: string, secret: string, sequence?: number, amount: string = "1"): Promise<string> {
@@ -109,7 +129,7 @@ export class xrpIO {
109 129
 
110 130
   public async getTransaction(hash: string, retry = 0): Promise<TxResponse> {
111 131
     if (!NON_ZERO_TX_HASH.test(hash)) {
112
-      throw ERR_BAD_TX_HASH(hash)
132
+      throw new BadTxHashError(hash)
113 133
     }
114 134
 
115 135
     this.dbg("Getting Tx", hash)
@@ -163,12 +183,12 @@ export class xrpIO {
163 183
 
164 184
   public async readRaw(hash: string, verifyOwner?: string): Promise<Memo> {
165 185
     if (!NON_ZERO_TX_HASH.test(hash)) {
166
-      throw ERR_BAD_TX_HASH(hash)
186
+      throw new BadTxHashError(hash)
167 187
     }
168 188
     const tx = await this.getTransaction(hash)
169 189
 
170 190
     if(verifyOwner && tx.result.Account != verifyOwner){
171
-      throw ERR_NO_VERIFY_OWNER(hash, tx.result.Account, verifyOwner)
191
+      throw new CannotVerifyOwnerError(hash, tx.result.Account, verifyOwner)
172 192
     }
173 193
 
174 194
     const memo = tx.result.Memos[0].Memo
@@ -198,7 +218,7 @@ export class xrpIO {
198 218
   public async treeRead(hashes: string[], verifyOwner?:string): Promise<string> {
199 219
     const bad_hash = hashes.find(hash => !NON_ZERO_TX_HASH.test(hash))
200 220
     if (bad_hash)
201
-      throw ERR_BAD_TX_HASH(bad_hash)
221
+      throw new BadTxHashError(bad_hash)
202 222
 
203 223
     const memos = await Promise.all(hashes.map(hash => this.readRaw(hash, verifyOwner)))
204 224
     const payload: string = await decompressB64(memos.map(memo => memo.data).join(''))

+ 30
- 1
test/primitives.ts View File

@@ -7,6 +7,7 @@ const expect = chai.expect
7 7
 
8 8
 let sendWallet: Wallet
9 9
 let receiveWallet: Wallet
10
+let poorWallet: Wallet
10 11
 let api: xrpIO
11 12
 
12 13
 describe('XRPIO', () => {
@@ -14,6 +15,7 @@ describe('XRPIO', () => {
14 15
         this.timeout(15000)
15 16
         sendWallet = await makeTestnetWallet()
16 17
         receiveWallet = await makeTestnetWallet()
18
+        poorWallet = await makeTestnetWallet()
17 19
         await new Promise((res, rej) => setTimeout(res, 10000)) //it takes a moment for the wallets to become active
18 20
     })
19 21
 
@@ -35,6 +37,15 @@ describe('XRPIO', () => {
35 37
         }
36 38
     })
37 39
 
40
+
41
+    it('throws error if not enough funds', function(done){
42
+        this.timeout(15000)
43
+        console.log(poorWallet)
44
+        api.sendPayment({}, receiveWallet.address, poorWallet.secret, undefined, String(1000000 * 10001))
45
+        .then(_ => done(new Error('Expected error but succeeded')))
46
+        .catch(_ => done())
47
+    })
48
+
38 49
     it('getTransaction with bad hash', function(done){
39 50
         this.timeout(10000)
40 51
         api.getTransaction('73FECDA37ABBB2FC17460C5C2467BE6A0A8E1F4EB081FFFFFFFFFFFFFFFFFFFF') //technically this hash could exist, but probably never will
@@ -142,7 +153,25 @@ describe('XRPIO', () => {
142 153
         expect(data).to.exist
143 154
         expect(data).to.be.equal(htmlTxt)
144 155
     })
145
-    
156
+
157
+    it('sends even if rate limit exceeded', async function(){
158
+        this.timeout(10 * 60 * 1000) //10m
159
+        console.log("Testing if sending past rate limits correctly recovers and finishes. This may take a while.")
160
+        await api.treeWrite(htmlTxt, receiveWallet.address, sendWallet.secret)
161
+        console.log("1/7")
162
+        await api.treeWrite(htmlTxt, receiveWallet.address, sendWallet.secret)
163
+        console.log("2/7")
164
+        await api.treeWrite(htmlTxt, receiveWallet.address, sendWallet.secret)
165
+        console.log("3/7")
166
+        await api.treeWrite(htmlTxt, receiveWallet.address, sendWallet.secret)
167
+        console.log("4/7")
168
+        await api.treeWrite(htmlTxt, receiveWallet.address, sendWallet.secret)
169
+        console.log("5/7")
170
+        await api.treeWrite(htmlTxt, receiveWallet.address, sendWallet.secret)
171
+        console.log("6/7")
172
+        await api.treeWrite(htmlTxt, receiveWallet.address, sendWallet.secret)
173
+    })
174
+
146 175
     it('print open handles', function(){
147 176
         api.disconnect().then(_ => {
148 177
             wtf.dump()

Loading…
Cancel
Save