xrpl-ruby 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +44 -0
- data/LICENSE +21 -0
- data/README.md +101 -0
- data/lib/binary-codec/binary_codec.rb +3 -1
- data/lib/binary-codec/enums/definitions.rb +6 -4
- data/lib/binary-codec/enums/fields.rb +1 -1
- data/lib/binary-codec/serdes/binary_parser.rb +6 -10
- data/lib/binary-codec/serdes/binary_serializer.rb +12 -0
- data/lib/binary-codec/serdes/bytes_list.rb +2 -2
- data/lib/binary-codec/types/amount.rb +81 -55
- data/lib/binary-codec/types/blob.rb +1 -1
- data/lib/binary-codec/types/currency.rb +1 -1
- data/lib/binary-codec/types/hash.rb +1 -1
- data/lib/binary-codec/types/issue.rb +8 -11
- data/lib/binary-codec/types/path_set.rb +1 -1
- data/lib/binary-codec/types/st_array.rb +53 -18
- data/lib/binary-codec/types/st_object.rb +56 -17
- data/lib/binary-codec/types/uint.rb +51 -1
- data/lib/binary-codec/types/xchain_bridge.rb +5 -5
- data/lib/key-pairs/ed25519.rb +7 -2
- data/lib/key-pairs/secp256k1.rb +86 -33
- data/lib/wallet/wallet.rb +69 -7
- data/lib/xrpl/client.rb +498 -0
- data/lib/xrpl/faucet.rb +138 -0
- data/lib/xrpl/version.rb +5 -0
- data/lib/xrpl-ruby.rb +6 -1
- metadata +70 -4
data/lib/xrpl/client.rb
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'eventmachine'
|
|
4
|
+
require 'faye/websocket'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'securerandom'
|
|
7
|
+
require 'timeout'
|
|
8
|
+
|
|
9
|
+
module XRPL
|
|
10
|
+
# Raised when the WebSocket connection fails to open (or errors) before it
|
|
11
|
+
# becomes ready to accept requests.
|
|
12
|
+
class ConnectionError < StandardError; end
|
|
13
|
+
|
|
14
|
+
# Raised when a submitted transaction fails or cannot be confirmed as
|
|
15
|
+
# included in a validated ledger.
|
|
16
|
+
class TransactionError < StandardError; end
|
|
17
|
+
|
|
18
|
+
class Client
|
|
19
|
+
MAINNET_URL = 'wss://s1.ripple.com'
|
|
20
|
+
TESTNET_URL = 'wss://s.altnet.rippletest.net:51233'
|
|
21
|
+
DEVNET_URL = 'wss://s.devnet.rippletest.net:51233'
|
|
22
|
+
|
|
23
|
+
NETWORK_URLS = {
|
|
24
|
+
'mainnet' => MAINNET_URL,
|
|
25
|
+
'testnet' => TESTNET_URL,
|
|
26
|
+
'devnet' => DEVNET_URL
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
# Added to the current ledger index to set LastLedgerSequence during autofill.
|
|
30
|
+
LEDGER_OFFSET = 20
|
|
31
|
+
# Approximate seconds between validated ledgers; used when polling for finality.
|
|
32
|
+
LEDGER_CLOSE_TIME = 3
|
|
33
|
+
# Default fee (drops) if the server's fee cannot be determined.
|
|
34
|
+
DEFAULT_FEE_DROPS = 10
|
|
35
|
+
|
|
36
|
+
attr_reader :url, :connection
|
|
37
|
+
|
|
38
|
+
# @param url [String, Symbol] a network alias (:testnet/:mainnet/:devnet) or a WebSocket URL.
|
|
39
|
+
# @param logger [Logger, nil] optional logger for diagnostic messages. When nil
|
|
40
|
+
# (the default), the client stays silent — a library must not write to the
|
|
41
|
+
# host application's stdout uninvited. Pass e.g. +Logger.new($stdout)+ to opt in.
|
|
42
|
+
def initialize(url, logger: nil)
|
|
43
|
+
@url = resolve_url(url)
|
|
44
|
+
@connection = nil
|
|
45
|
+
@requests = {}
|
|
46
|
+
@open = false
|
|
47
|
+
@ready_queue = Queue.new
|
|
48
|
+
@logger = logger
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Opens the WebSocket connection.
|
|
52
|
+
#
|
|
53
|
+
# By default this is non-blocking (preserving the previous behaviour) and
|
|
54
|
+
# returns +self+. Pass <tt>wait: true</tt> (or use {#connect!}) to block
|
|
55
|
+
# until the socket is actually open, so a following request can't race with
|
|
56
|
+
# connection setup and hit "Not connected".
|
|
57
|
+
#
|
|
58
|
+
# @param wait [Boolean] block until the connection is open.
|
|
59
|
+
# @param timeout [Numeric] seconds to wait when +wait+ is true.
|
|
60
|
+
# @return [self]
|
|
61
|
+
def connect(wait: false, timeout: 10)
|
|
62
|
+
@open = false
|
|
63
|
+
@ready_queue = Queue.new
|
|
64
|
+
|
|
65
|
+
Thread.new { EM.run } unless EM.reactor_running?
|
|
66
|
+
|
|
67
|
+
EM.next_tick do
|
|
68
|
+
@connection = Faye::WebSocket::Client.new(@url)
|
|
69
|
+
|
|
70
|
+
@connection.on :open do |event|
|
|
71
|
+
@open = true
|
|
72
|
+
@ready_queue.push(:open)
|
|
73
|
+
log("Connected to #{@url}")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
@connection.on :message do |event|
|
|
77
|
+
handle_message(JSON.parse(event.data))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
@connection.on :error do |event|
|
|
81
|
+
@ready_queue.push([:error, event.message])
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
@connection.on :close do |event|
|
|
85
|
+
@open = false
|
|
86
|
+
@connection = nil
|
|
87
|
+
log("Connection closed: #{event.code} #{event.reason}")
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
wait_until_open(timeout: timeout) if wait
|
|
92
|
+
self
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Opens the connection and blocks until it is ready to accept requests.
|
|
96
|
+
#
|
|
97
|
+
# @param timeout [Numeric] seconds to wait for the socket to open.
|
|
98
|
+
# @return [self]
|
|
99
|
+
def connect!(timeout: 10)
|
|
100
|
+
connect(wait: true, timeout: timeout)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @return [Boolean] whether the WebSocket connection is currently open.
|
|
104
|
+
def open?
|
|
105
|
+
@open
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Blocks the calling thread until the connection is open.
|
|
109
|
+
#
|
|
110
|
+
# @param timeout [Numeric] seconds to wait before giving up.
|
|
111
|
+
# @return [true] once the socket is open.
|
|
112
|
+
# @raise [XRPL::ConnectionError] if the connection reports an error first.
|
|
113
|
+
# @raise [Timeout::Error] if the socket does not open within +timeout+.
|
|
114
|
+
def wait_until_open(timeout: 10)
|
|
115
|
+
return true if @open
|
|
116
|
+
|
|
117
|
+
signal = Timeout.timeout(timeout) { @ready_queue.pop }
|
|
118
|
+
if signal.is_a?(Array) && signal.first == :error
|
|
119
|
+
raise ConnectionError, "WebSocket connection failed: #{signal.last}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
true
|
|
123
|
+
rescue Timeout::Error
|
|
124
|
+
raise Timeout::Error, "Connection did not open within #{timeout} seconds"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def disconnect
|
|
128
|
+
@connection&.close
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def request(command, params = {})
|
|
132
|
+
id = SecureRandom.uuid
|
|
133
|
+
register_pending_request(id)
|
|
134
|
+
payload = {
|
|
135
|
+
id: id,
|
|
136
|
+
command: command
|
|
137
|
+
}.merge(params)
|
|
138
|
+
|
|
139
|
+
send_message(payload)
|
|
140
|
+
# TODO: Implement promise/future or callback for response
|
|
141
|
+
id
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def request_with_response(command, params = {}, timeout: 10)
|
|
145
|
+
id = SecureRandom.uuid
|
|
146
|
+
queue = Queue.new
|
|
147
|
+
register_pending_request(id, queue: queue)
|
|
148
|
+
|
|
149
|
+
payload = {
|
|
150
|
+
id: id,
|
|
151
|
+
command: command
|
|
152
|
+
}.merge(params)
|
|
153
|
+
|
|
154
|
+
send_message(payload)
|
|
155
|
+
|
|
156
|
+
Timeout.timeout(timeout) { queue.pop }
|
|
157
|
+
rescue Timeout::Error
|
|
158
|
+
@requests.delete(id)
|
|
159
|
+
raise Timeout::Error, "Request timed out after #{timeout} seconds"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def request_with_retry(command, params = {}, max_attempts: 3, timeout: 10,
|
|
163
|
+
retry_exceptions: [RuntimeError, Timeout::Error], **keyword_params)
|
|
164
|
+
attempt = 0
|
|
165
|
+
request_params = keyword_params.empty? ? params : params.merge(keyword_params)
|
|
166
|
+
|
|
167
|
+
begin
|
|
168
|
+
attempt += 1
|
|
169
|
+
request_with_response(command, request_params, timeout: timeout)
|
|
170
|
+
rescue *retry_exceptions => error
|
|
171
|
+
raise error if attempt >= max_attempts
|
|
172
|
+
|
|
173
|
+
retry
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def subscribe(**params)
|
|
178
|
+
request('subscribe', **params)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def unsubscribe(**params)
|
|
182
|
+
request('unsubscribe', **params)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def account_channels(**params)
|
|
186
|
+
request('account_channels', **params)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def account_currencies(**params)
|
|
190
|
+
request('account_currencies', **params)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def account_info(**params)
|
|
194
|
+
request('account_info', **params)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def account_info_response(**params)
|
|
198
|
+
request_with_retry('account_info', params)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def account_lines(**params)
|
|
202
|
+
request('account_lines', **params)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def account_nfts(**params)
|
|
206
|
+
request('account_nfts', **params)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def account_objects(**params)
|
|
210
|
+
request('account_objects', **params)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def account_offers(**params)
|
|
214
|
+
request('account_offers', **params)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def account_tx(**params)
|
|
218
|
+
request('account_tx', **params)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def account_tx_response(**params)
|
|
222
|
+
request_with_retry('account_tx', params)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def account_tx_all(**params)
|
|
226
|
+
page_limit = params.delete(:page_limit)
|
|
227
|
+
max_attempts = params.delete(:max_attempts) || 3
|
|
228
|
+
timeout = params.delete(:timeout) || 10
|
|
229
|
+
|
|
230
|
+
current_params = params.dup
|
|
231
|
+
responses = []
|
|
232
|
+
|
|
233
|
+
loop do
|
|
234
|
+
response = request_with_retry('account_tx', current_params, max_attempts: max_attempts, timeout: timeout)
|
|
235
|
+
responses << response
|
|
236
|
+
|
|
237
|
+
marker = response.dig('result', 'marker')
|
|
238
|
+
break unless marker
|
|
239
|
+
break if page_limit && responses.size >= page_limit
|
|
240
|
+
|
|
241
|
+
current_params = current_params.merge(marker: marker)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
responses
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def summarize_account_tx(response)
|
|
248
|
+
result = response.fetch('result', {})
|
|
249
|
+
transactions = Array(result['transactions'])
|
|
250
|
+
|
|
251
|
+
{
|
|
252
|
+
'ledger_index_min' => result['ledger_index_min'],
|
|
253
|
+
'ledger_index_max' => result['ledger_index_max'],
|
|
254
|
+
'transaction_count' => transactions.size,
|
|
255
|
+
'validated' => result['validated'] == true,
|
|
256
|
+
'marker_present' => !result['marker'].nil?
|
|
257
|
+
}
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def gateway_balances(**params)
|
|
261
|
+
request('gateway_balances', **params)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def noripple_check(**params)
|
|
265
|
+
request('noripple_check', **params)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def ledger(**params)
|
|
269
|
+
request('ledger', **params)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def ledger_closed(**params)
|
|
273
|
+
request('ledger_closed', **params)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def ledger_current(**params)
|
|
277
|
+
request('ledger_current', **params)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def ledger_data(**params)
|
|
281
|
+
request('ledger_data', **params)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def ledger_entry(**params)
|
|
285
|
+
request('ledger_entry', **params)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def fee(**params)
|
|
289
|
+
request('fee', **params)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def fee_response(**params)
|
|
293
|
+
request_with_retry('fee', params)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def tx(**params)
|
|
297
|
+
request('tx', **params)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def tx_response(**params)
|
|
301
|
+
request_with_retry('tx', params)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# --- Transaction lifecycle (client-centric orchestration; see ADR-001) ---
|
|
305
|
+
|
|
306
|
+
# Fills in the fields a transaction needs before signing: +Sequence+, +Fee+
|
|
307
|
+
# and +LastLedgerSequence+. Existing values are never overwritten.
|
|
308
|
+
#
|
|
309
|
+
# @param transaction [Hash] the (string-keyed) transaction to complete.
|
|
310
|
+
# @param signers_count [Integer] number of signatures for multisign fee scaling.
|
|
311
|
+
# @return [Hash] a copy of the transaction with the missing fields filled in.
|
|
312
|
+
def autofill(transaction, signers_count: 0)
|
|
313
|
+
tx = transaction.dup
|
|
314
|
+
tx['Sequence'] ||= fetch_sequence(tx.fetch('Account'))
|
|
315
|
+
tx['Fee'] ||= calculate_fee(signers_count)
|
|
316
|
+
tx['LastLedgerSequence'] ||= current_ledger_index + LEDGER_OFFSET
|
|
317
|
+
tx
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Autofills (optional), signs with the given wallet and submits a transaction.
|
|
321
|
+
#
|
|
322
|
+
# @param transaction [Hash] the transaction to submit.
|
|
323
|
+
# @param wallet [Wallet::Wallet] wallet used to sign.
|
|
324
|
+
# @param autofill [Boolean] whether to autofill missing fields first.
|
|
325
|
+
# @param fail_hard [Boolean] reject the transaction rather than queueing it.
|
|
326
|
+
# @return [Hash] the raw +submit+ response.
|
|
327
|
+
def submit(transaction, wallet:, autofill: true, fail_hard: false)
|
|
328
|
+
prepared = prepare_for_submit(transaction, wallet: wallet, autofill: autofill)
|
|
329
|
+
submit_blob(prepared[:tx_blob], fail_hard: fail_hard)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Like {#submit}, but then polls the ledger until the transaction is final
|
|
333
|
+
# (included in a validated ledger, or definitively failed/expired).
|
|
334
|
+
#
|
|
335
|
+
# @param transaction [Hash] the transaction to submit.
|
|
336
|
+
# @param wallet [Wallet::Wallet] wallet used to sign.
|
|
337
|
+
# @param autofill [Boolean] whether to autofill missing fields first.
|
|
338
|
+
# @param fail_hard [Boolean] reject the transaction rather than queueing it.
|
|
339
|
+
# @param timeout [Numeric] max seconds to wait for validation.
|
|
340
|
+
# @return [Hash] the validated +tx+ response.
|
|
341
|
+
# @raise [XRPL::TransactionError] if the transaction fails, expires or times out.
|
|
342
|
+
def submit_and_wait(transaction, wallet:, autofill: true, fail_hard: false, timeout: 20)
|
|
343
|
+
prepared = prepare_for_submit(transaction, wallet: wallet, autofill: autofill)
|
|
344
|
+
last_ledger = prepared[:tx]['LastLedgerSequence']
|
|
345
|
+
unless last_ledger
|
|
346
|
+
raise ArgumentError, 'Transaction must contain a LastLedgerSequence for reliable submission'
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
response = submit_blob(prepared[:tx_blob], fail_hard: fail_hard)
|
|
350
|
+
preliminary = response.dig('result', 'engine_result')
|
|
351
|
+
|
|
352
|
+
wait_for_final_outcome(prepared[:hash], last_ledger, preliminary, timeout: timeout)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
private
|
|
356
|
+
|
|
357
|
+
def resolve_url(input)
|
|
358
|
+
return input unless input.is_a?(Symbol) || input.is_a?(String)
|
|
359
|
+
|
|
360
|
+
network_key = input.to_s
|
|
361
|
+
return NETWORK_URLS[network_key] if NETWORK_URLS.key?(network_key)
|
|
362
|
+
|
|
363
|
+
return input if network_key.include?('://')
|
|
364
|
+
|
|
365
|
+
if input.is_a?(Symbol)
|
|
366
|
+
raise ArgumentError, "Unsupported network alias: #{input}"
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
input
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def send_message(payload)
|
|
373
|
+
raise "Not connected" unless @connection
|
|
374
|
+
@connection.send(payload.to_json)
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def handle_message(message)
|
|
378
|
+
message_id = message['id']
|
|
379
|
+
return unless message_id
|
|
380
|
+
return unless @requests.key?(message_id)
|
|
381
|
+
|
|
382
|
+
request_entry = @requests[message_id]
|
|
383
|
+
request_entry[:queue]&.push(message) if request_entry.is_a?(Hash)
|
|
384
|
+
@requests.delete(message_id)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def register_pending_request(id, queue: nil)
|
|
388
|
+
@requests[id] = queue ? { queue: queue } : :pending
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Emits a diagnostic message via the injected logger, or stays silent when
|
|
392
|
+
# none was provided. The client never writes to stdout on its own.
|
|
393
|
+
def log(message)
|
|
394
|
+
@logger&.info(message)
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# --- Transaction lifecycle helpers ---
|
|
398
|
+
|
|
399
|
+
def prepare_for_submit(transaction, wallet:, autofill:)
|
|
400
|
+
raise ArgumentError, 'wallet: is required to sign the transaction' if wallet.nil?
|
|
401
|
+
|
|
402
|
+
tx = transaction.is_a?(Hash) ? transaction.dup : transaction
|
|
403
|
+
tx = autofill(tx) if autofill && tx.is_a?(Hash)
|
|
404
|
+
|
|
405
|
+
signed = wallet.sign(tx)
|
|
406
|
+
{ tx: tx, tx_blob: signed['tx_blob'], hash: signed['hash'] }
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def submit_blob(tx_blob, fail_hard:, timeout: 10)
|
|
410
|
+
# request_with_response has a keyword parameter (timeout:). If we pass the
|
|
411
|
+
# params as a *trailing* hash without also filling the keyword slot, Ruby 3
|
|
412
|
+
# and RSpec's verifying doubles treat that hash as keyword arguments and
|
|
413
|
+
# reject it ("Invalid keyword arguments"). We therefore mirror the exact,
|
|
414
|
+
# proven call shape used by #request_with_retry: a positional params
|
|
415
|
+
# variable followed by an explicit `timeout:` keyword. String keys are the
|
|
416
|
+
# JSON field names the `submit` command expects.
|
|
417
|
+
params = { 'tx_blob' => tx_blob, 'fail_hard' => fail_hard }
|
|
418
|
+
request_with_response('submit', params, timeout: timeout)
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def fetch_sequence(account)
|
|
422
|
+
response = account_info_response(account: account, ledger_index: 'current')
|
|
423
|
+
sequence = response.dig('result', 'account_data', 'Sequence')
|
|
424
|
+
raise TransactionError, "Could not determine Sequence for #{account}" unless sequence
|
|
425
|
+
|
|
426
|
+
Integer(sequence)
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def calculate_fee(signers_count)
|
|
430
|
+
base = base_fee_drops
|
|
431
|
+
total = signers_count.to_i.positive? ? base * (1 + signers_count.to_i) : base
|
|
432
|
+
total.to_s
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def base_fee_drops
|
|
436
|
+
response = request_with_retry('fee')
|
|
437
|
+
drops = response.dig('result', 'drops', 'open_ledger_fee') ||
|
|
438
|
+
response.dig('result', 'drops', 'base_fee')
|
|
439
|
+
drops ? Integer(drops) : DEFAULT_FEE_DROPS
|
|
440
|
+
rescue StandardError
|
|
441
|
+
DEFAULT_FEE_DROPS
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def current_ledger_index
|
|
445
|
+
response = request_with_retry('ledger_current')
|
|
446
|
+
index = response.dig('result', 'ledger_current_index')
|
|
447
|
+
raise TransactionError, 'Could not determine current ledger index' unless index
|
|
448
|
+
|
|
449
|
+
Integer(index)
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# Polls until the transaction is final. Checks the transaction result FIRST
|
|
453
|
+
# (return once validated), then whether the ledger has passed
|
|
454
|
+
# LastLedgerSequence — the reverse of xrpl-php, which can wrongly raise even
|
|
455
|
+
# though the transaction validated. See ADR-001.
|
|
456
|
+
def wait_for_final_outcome(tx_hash, last_ledger, preliminary_result, timeout:)
|
|
457
|
+
deadline = monotonic_time + timeout
|
|
458
|
+
|
|
459
|
+
loop do
|
|
460
|
+
response = tx_lookup(tx_hash)
|
|
461
|
+
if response
|
|
462
|
+
error = response.dig('result', 'error') || response['error']
|
|
463
|
+
if error.nil?
|
|
464
|
+
return response if response.dig('result', 'validated')
|
|
465
|
+
elsif error != 'txnNotFound'
|
|
466
|
+
raise TransactionError,
|
|
467
|
+
"Transaction #{tx_hash} failed: #{error} (preliminary: #{preliminary_result})"
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
latest = current_ledger_index
|
|
472
|
+
if latest > last_ledger
|
|
473
|
+
raise TransactionError,
|
|
474
|
+
"Transaction #{tx_hash} did not validate: ledger #{latest} passed " \
|
|
475
|
+
"LastLedgerSequence #{last_ledger} (preliminary: #{preliminary_result})"
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
if monotonic_time >= deadline
|
|
479
|
+
raise TransactionError,
|
|
480
|
+
"Timed out after #{timeout}s waiting for #{tx_hash} to validate " \
|
|
481
|
+
"(preliminary: #{preliminary_result})"
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
sleep LEDGER_CLOSE_TIME
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def tx_lookup(tx_hash)
|
|
489
|
+
request_with_retry('tx', { transaction: tx_hash })
|
|
490
|
+
rescue StandardError
|
|
491
|
+
nil
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def monotonic_time
|
|
495
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
end
|
data/lib/xrpl/faucet.rb
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'uri'
|
|
5
|
+
require 'json'
|
|
6
|
+
|
|
7
|
+
require_relative 'client'
|
|
8
|
+
|
|
9
|
+
module XRPL
|
|
10
|
+
# Raised when a faucet request fails or funding does not complete in time.
|
|
11
|
+
class FaucetError < StandardError; end
|
|
12
|
+
|
|
13
|
+
# Funds (and thereby activates) a wallet on an XRP Ledger test network via the
|
|
14
|
+
# public faucet, then waits until the account shows up on the ledger.
|
|
15
|
+
#
|
|
16
|
+
# This is the Ruby equivalent of the +fundWallet()+ helper used in the other
|
|
17
|
+
# XRPL SDKs. It is intended for Testnet/Devnet only.
|
|
18
|
+
#
|
|
19
|
+
# @example Fund a fresh wallet on the Testnet
|
|
20
|
+
# client = XRPL::Client.new(:testnet)
|
|
21
|
+
# client.connect!
|
|
22
|
+
# result = XRPL.fund_wallet(client)
|
|
23
|
+
# result[:wallet].classic_address # => "r..."
|
|
24
|
+
# result[:balance] # => 100000000000 (drops)
|
|
25
|
+
class Faucet
|
|
26
|
+
TESTNET_FAUCET = 'https://faucet.altnet.rippletest.net/accounts'
|
|
27
|
+
DEVNET_FAUCET = 'https://faucet.devnet.rippletest.net/accounts'
|
|
28
|
+
|
|
29
|
+
DEFAULT_TIMEOUT = 40
|
|
30
|
+
DEFAULT_POLL_INTERVAL = 2
|
|
31
|
+
|
|
32
|
+
attr_reader :faucet_url
|
|
33
|
+
|
|
34
|
+
# @param faucet_url [String] the faucet endpoint to POST to.
|
|
35
|
+
def initialize(faucet_url: TESTNET_FAUCET)
|
|
36
|
+
@faucet_url = faucet_url
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Convenience wrapper matching the tutorial call style.
|
|
40
|
+
#
|
|
41
|
+
# @param client [XRPL::Client] a connected client used to poll for funding.
|
|
42
|
+
# @param wallet [Wallet::Wallet, nil] wallet to fund; a new one is generated when nil.
|
|
43
|
+
# @return [Hash] +{ wallet:, balance: }+ (balance in drops).
|
|
44
|
+
def self.fund_wallet(client, wallet = nil, faucet_url: TESTNET_FAUCET, **opts)
|
|
45
|
+
new(faucet_url: faucet_url).fund_wallet(client, wallet, **opts)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Funds (and activates) a wallet on a test network.
|
|
49
|
+
#
|
|
50
|
+
# @param client [XRPL::Client] a connected client used to poll for funding.
|
|
51
|
+
# @param wallet [Wallet::Wallet, nil] wallet to fund; a new one is generated when nil.
|
|
52
|
+
# @param amount [Numeric, String, nil] optional XRP amount to request.
|
|
53
|
+
# @param timeout [Numeric] seconds to wait for the account to be funded.
|
|
54
|
+
# @param poll_interval [Numeric] seconds between ledger polls.
|
|
55
|
+
# @return [Hash] +{ wallet:, balance: }+ (balance in drops).
|
|
56
|
+
# @raise [XRPL::FaucetError] if the faucet request fails or funding times out.
|
|
57
|
+
def fund_wallet(client, wallet = nil, amount: nil, timeout: DEFAULT_TIMEOUT,
|
|
58
|
+
poll_interval: DEFAULT_POLL_INTERVAL)
|
|
59
|
+
wallet ||= Wallet::Wallet.generate
|
|
60
|
+
|
|
61
|
+
request_funds(wallet.classic_address, amount)
|
|
62
|
+
balance = await_funding(
|
|
63
|
+
client, wallet.classic_address,
|
|
64
|
+
timeout: timeout, poll_interval: poll_interval
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
{ wallet: wallet, balance: balance }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# POSTs a funding request to the faucet for the given address.
|
|
73
|
+
def request_funds(address, amount)
|
|
74
|
+
body = { destination: address }
|
|
75
|
+
body[:xrpAmount] = amount.to_s if amount
|
|
76
|
+
|
|
77
|
+
http_post(@faucet_url, body)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Polls the ledger until the account is funded, i.e. it exists and shows a
|
|
81
|
+
# positive balance. (For a Testnet/get-started helper this simple
|
|
82
|
+
# "funded = has a balance" rule is robust: unlike waiting for a strict
|
|
83
|
+
# balance *increase*, it can never hang when a wallet already holds XRP.)
|
|
84
|
+
def await_funding(client, address, timeout:, poll_interval:)
|
|
85
|
+
deadline = monotonic_time + timeout
|
|
86
|
+
|
|
87
|
+
loop do
|
|
88
|
+
balance = current_balance(client, address)
|
|
89
|
+
return balance if balance && balance.positive?
|
|
90
|
+
|
|
91
|
+
if monotonic_time >= deadline
|
|
92
|
+
raise FaucetError, "Account #{address} was not funded within #{timeout} seconds"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
sleep poll_interval
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Returns the account balance in drops, or nil if the account is not (yet)
|
|
100
|
+
# found or a transient lookup error occurs.
|
|
101
|
+
def current_balance(client, address)
|
|
102
|
+
response = client.account_info_response(account: address, ledger_index: 'validated')
|
|
103
|
+
drops = response.dig('result', 'account_data', 'Balance')
|
|
104
|
+
drops && Integer(drops)
|
|
105
|
+
rescue StandardError
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def http_post(url, body)
|
|
110
|
+
uri = URI(url)
|
|
111
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
112
|
+
http.use_ssl = uri.scheme == 'https'
|
|
113
|
+
|
|
114
|
+
request = Net::HTTP::Post.new(uri.request_uri, 'Content-Type' => 'application/json')
|
|
115
|
+
request.body = JSON.generate(body)
|
|
116
|
+
|
|
117
|
+
response = http.request(request)
|
|
118
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
119
|
+
raise FaucetError, "Faucet request failed: #{response.code} #{response.message}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
JSON.parse(response.body)
|
|
123
|
+
rescue JSON::ParserError
|
|
124
|
+
{}
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def monotonic_time
|
|
128
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Top-level convenience mirroring the other SDKs' +fundWallet()+ sugar.
|
|
133
|
+
#
|
|
134
|
+
# @see Faucet#fund_wallet
|
|
135
|
+
def self.fund_wallet(client, wallet = nil, **opts)
|
|
136
|
+
Faucet.fund_wallet(client, wallet, **opts)
|
|
137
|
+
end
|
|
138
|
+
end
|
data/lib/xrpl/version.rb
ADDED
data/lib/xrpl-ruby.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'xrpl/version'
|
|
4
|
+
|
|
3
5
|
require_relative 'core/base_x'
|
|
4
6
|
require_relative 'core/base_58_xrp'
|
|
5
7
|
require_relative 'core/core'
|
|
@@ -35,4 +37,7 @@ require_relative 'wallet/wallet'
|
|
|
35
37
|
|
|
36
38
|
require_relative 'key-pairs/ed25519'
|
|
37
39
|
require_relative 'key-pairs/secp256k1'
|
|
38
|
-
require_relative 'key-pairs/key_pairs'
|
|
40
|
+
require_relative 'key-pairs/key_pairs'
|
|
41
|
+
|
|
42
|
+
require_relative 'xrpl/client'
|
|
43
|
+
require_relative 'xrpl/faucet'
|