xrpl-ruby 0.2.4 → 0.6.0
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 +31 -0
- data/LICENSE +21 -0
- data/README.md +94 -0
- data/lib/address-codec/address_codec.rb +21 -4
- data/lib/address-codec/codec.rb +15 -2
- data/lib/address-codec/xrp_codec.rb +29 -2
- data/lib/binary-codec/binary_codec.rb +47 -21
- data/lib/binary-codec/enums/definitions.json +592 -1
- data/lib/binary-codec/enums/definitions.rb +23 -9
- data/lib/binary-codec/enums/fields.rb +3 -1
- data/lib/binary-codec/serdes/binary_parser.rb +44 -10
- data/lib/binary-codec/serdes/binary_serializer.rb +29 -6
- data/lib/binary-codec/serdes/bytes_list.rb +12 -1
- data/lib/binary-codec/types/account_id.rb +18 -37
- data/lib/binary-codec/types/amount.rb +123 -77
- data/lib/binary-codec/types/blob.rb +14 -5
- data/lib/binary-codec/types/currency.rb +15 -4
- data/lib/binary-codec/types/hash.rb +37 -36
- data/lib/binary-codec/types/issue.rb +47 -0
- data/lib/binary-codec/types/path_set.rb +93 -0
- data/lib/binary-codec/types/serialized_type.rb +52 -28
- data/lib/binary-codec/types/st_array.rb +106 -0
- data/lib/binary-codec/types/st_object.rb +150 -14
- data/lib/binary-codec/types/uint.rb +166 -3
- data/lib/binary-codec/types/vector256.rb +53 -0
- data/lib/binary-codec/types/xchain_bridge.rb +47 -0
- data/lib/binary-codec/utilities.rb +18 -0
- data/lib/core/base_58_xrp.rb +2 -0
- data/lib/core/base_x.rb +10 -0
- data/lib/core/core.rb +44 -6
- data/lib/core/utilities.rb +38 -0
- data/lib/key-pairs/ed25519.rb +69 -0
- data/lib/key-pairs/key_pairs.rb +92 -0
- data/lib/key-pairs/secp256k1.rb +169 -0
- data/lib/wallet/wallet.rb +179 -0
- 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 +30 -1
- metadata +80 -4
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
|
|
6
|
+
module Wallet
|
|
7
|
+
# Represents an XRPL wallet, providing methods for signing and address derivation.
|
|
8
|
+
class Wallet
|
|
9
|
+
# @return [String] The public key as hex.
|
|
10
|
+
attr_reader :public_key
|
|
11
|
+
# @return [String] The private key as hex.
|
|
12
|
+
attr_reader :private_key
|
|
13
|
+
# @return [String] The encoded seed.
|
|
14
|
+
attr_reader :seed
|
|
15
|
+
# @return [String] The classic address.
|
|
16
|
+
attr_reader :classic_address
|
|
17
|
+
# @return [String] The algorithm used ('secp256k1' or 'ed25519').
|
|
18
|
+
attr_reader :algorithm
|
|
19
|
+
|
|
20
|
+
# Initializes a new Wallet instance.
|
|
21
|
+
# @param public_key [String] The public key as hex.
|
|
22
|
+
# @param private_key [String] The private key as hex.
|
|
23
|
+
# @param seed [String, nil] The encoded seed.
|
|
24
|
+
# @param classic_address [String, nil] The classic address (optional, derived if not provided).
|
|
25
|
+
def initialize(public_key, private_key, seed: nil, classic_address: nil)
|
|
26
|
+
@public_key = public_key
|
|
27
|
+
@private_key = private_key
|
|
28
|
+
@seed = seed
|
|
29
|
+
@algorithm = public_key.start_with?('ED') ? 'ed25519' : 'secp256k1'
|
|
30
|
+
@key_pairs = KeyPairs::KeyPairs.new
|
|
31
|
+
@classic_address = classic_address || @key_pairs.derive_address(public_key)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Generates a new random wallet.
|
|
35
|
+
# @param algorithm [String] The algorithm to use ('secp256k1' or 'ed25519').
|
|
36
|
+
# @return [Wallet] A new Wallet instance.
|
|
37
|
+
def self.generate(algorithm = 'secp256k1')
|
|
38
|
+
kp = KeyPairs::KeyPairs.new
|
|
39
|
+
seed = kp.generate_seed(nil, algorithm)
|
|
40
|
+
from_seed(seed)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Creates a wallet from a seed.
|
|
44
|
+
# @param seed [String] The encoded seed.
|
|
45
|
+
# @param options [Hash] Options for key derivation.
|
|
46
|
+
# @return [Wallet] A new Wallet instance.
|
|
47
|
+
def self.from_seed(seed, options = {})
|
|
48
|
+
kp = KeyPairs::KeyPairs.new
|
|
49
|
+
keys = kp.derive_key_pair(seed, options)
|
|
50
|
+
new(keys[:public_key], keys[:private_key], seed: seed)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Creates a wallet from entropy.
|
|
54
|
+
# @param entropy [Array<Integer>] 16 bytes of entropy.
|
|
55
|
+
# @param algorithm [String] The algorithm to use ('secp256k1' or 'ed25519').
|
|
56
|
+
# @return [Wallet] A new Wallet instance.
|
|
57
|
+
def self.from_entropy(entropy, algorithm = 'secp256k1')
|
|
58
|
+
kp = KeyPairs::KeyPairs.new
|
|
59
|
+
seed = kp.generate_seed(entropy, algorithm)
|
|
60
|
+
from_seed(seed)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Signs a message (hex string) or transaction (Hash) with the wallet's private key.
|
|
64
|
+
# @param transaction [String, Hash] The message (hex string) or transaction (Hash) to sign.
|
|
65
|
+
# @param multisign [Boolean] Whether to sign for a multisigned transaction.
|
|
66
|
+
# @return [String, Hash] The signature (hex string) if a message was provided,
|
|
67
|
+
# or a hash containing :tx_blob and :hash if a transaction was provided.
|
|
68
|
+
def sign(transaction, multisign = false)
|
|
69
|
+
algorithm = @algorithm
|
|
70
|
+
# Check if message is a Hash (transaction)
|
|
71
|
+
if transaction.is_a?(::Hash)
|
|
72
|
+
prefix = multisign ? BinaryCodec::HASH_PREFIX[:transaction_multi_sig] : BinaryCodec::HASH_PREFIX[:transaction_sig]
|
|
73
|
+
|
|
74
|
+
# 1. Prepare the transaction for signing
|
|
75
|
+
tx_to_sign = transaction.dup
|
|
76
|
+
if multisign
|
|
77
|
+
# For multisigning, we need the SigningPubKey to be empty,
|
|
78
|
+
# and we need to add the Signers field later.
|
|
79
|
+
# We also need to add the Account of the signer to the signing data.
|
|
80
|
+
tx_to_sign['SigningPubKey'] = ""
|
|
81
|
+
# The multisign signing data MUST include the account of the signer.
|
|
82
|
+
else
|
|
83
|
+
tx_to_sign['SigningPubKey'] = @public_key
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Ensure SigningPubKey is serialized as a 0-length blob if empty
|
|
87
|
+
|
|
88
|
+
signing_data = BinaryCodec.signing_data(tx_to_sign, prefix, signing_fields_only: true)
|
|
89
|
+
|
|
90
|
+
if multisign
|
|
91
|
+
# Append the account ID of the signer to the signing data
|
|
92
|
+
account_id = AddressCodec::AddressCodec.new.decode_account_id(@classic_address)
|
|
93
|
+
signing_data += account_id
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
message = bytes_to_hex(signing_data)
|
|
97
|
+
|
|
98
|
+
# 2. Sign the message
|
|
99
|
+
signature = @key_pairs.sign(message, @private_key, algorithm)
|
|
100
|
+
|
|
101
|
+
# 3. Create the signed transaction
|
|
102
|
+
signed_tx = tx_to_sign.dup
|
|
103
|
+
if multisign
|
|
104
|
+
# For multisign=true, xrpl.js/PHP often returns a partially signed transaction
|
|
105
|
+
# or an object containing the signature for a specific signer.
|
|
106
|
+
# The PHP snippet expects a tx_blob.
|
|
107
|
+
# Looking at the PHP snippet, it calls $wallet->sign($tx, true).
|
|
108
|
+
# In XRPL, a multisigned transaction blob is usually the transaction
|
|
109
|
+
# WITH the Signers array.
|
|
110
|
+
|
|
111
|
+
signer = {
|
|
112
|
+
"Signer" => {
|
|
113
|
+
"Account" => @classic_address,
|
|
114
|
+
"SigningPubKey" => @public_key,
|
|
115
|
+
"TxnSignature" => signature
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
signed_tx['Signers'] = [signer]
|
|
119
|
+
signed_tx.delete('SigningPubKey') # Should be empty/not present in multisigned tx
|
|
120
|
+
else
|
|
121
|
+
signed_tx['TxnSignature'] = signature
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# 4. Serialize the signed transaction
|
|
125
|
+
tx_blob = BinaryCodec.json_to_binary(signed_tx)
|
|
126
|
+
|
|
127
|
+
# 5. Generate the hash (transaction ID)
|
|
128
|
+
# For transactions, the hash is SHA512Half of the serialized transaction with a prefix
|
|
129
|
+
hash_prefix = [0x54, 0x58, 0x4E, 0x00].pack('C*') # 'TXN\0'
|
|
130
|
+
hash = Digest::SHA512.digest(hash_prefix + [tx_blob].pack('H*'))[0...32].unpack1('H*').upcase
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
'tx_blob' => tx_blob,
|
|
134
|
+
'hash' => hash
|
|
135
|
+
}
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
@key_pairs.sign(transaction, @private_key, algorithm)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Verifies a signature for a message.
|
|
142
|
+
# @param message [String] The message as a hex string.
|
|
143
|
+
# @param signature [String] The signature as a hex string.
|
|
144
|
+
# @return [Boolean] True if the signature is valid.
|
|
145
|
+
def verify(message, signature)
|
|
146
|
+
@key_pairs.verify(message, signature, @public_key)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Verifies a signed transaction blob.
|
|
150
|
+
# @param signed_transaction [String] The signed transaction blob as a hex string.
|
|
151
|
+
# @return [Boolean] True if the transaction signature is valid.
|
|
152
|
+
def verify_transaction(signed_transaction)
|
|
153
|
+
decoded = BinaryCodec.binary_to_json(signed_transaction)
|
|
154
|
+
# The signing data is the transaction without the TxnSignature field,
|
|
155
|
+
# prefixed by 0x53545800 (STX\0).
|
|
156
|
+
|
|
157
|
+
tx_for_signing = decoded.dup
|
|
158
|
+
signature = tx_for_signing.delete('TxnSignature')
|
|
159
|
+
return false unless signature
|
|
160
|
+
|
|
161
|
+
signing_data = BinaryCodec.signing_data(tx_for_signing)
|
|
162
|
+
@key_pairs.verify(bytes_to_hex(signing_data), signature, @public_key)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Derives the X-address for this wallet.
|
|
166
|
+
# @param tag [Integer, false, nil] The destination tag.
|
|
167
|
+
# @param test_network [Boolean] Whether the address is for a test network.
|
|
168
|
+
# @return [String] The encoded X-address.
|
|
169
|
+
def get_x_address(tag: nil, test_network: false)
|
|
170
|
+
address_codec = AddressCodec::AddressCodec.new
|
|
171
|
+
address_codec.classic_address_to_x_address(@classic_address, tag, test_network)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# @return [String] String representation of the wallet.
|
|
175
|
+
def to_s
|
|
176
|
+
"Wallet(address: #{@classic_address}, public_key: #{@public_key})"
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
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
|