xrpl-ruby 0.7.0 → 0.9.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 +261 -0
- data/README.md +160 -3
- data/lib/binary-codec/binary_codec.rb +122 -1
- data/lib/binary-codec/enums/definitions.json +84 -288
- data/lib/binary-codec/enums/definitions.rb +38 -1
- data/lib/binary-codec/serdes/binary_serializer.rb +7 -1
- data/lib/binary-codec/types/st_array.rb +7 -2
- data/lib/binary-codec/types/st_object.rb +7 -11
- data/lib/binary-codec/types/uint.rb +109 -50
- data/lib/wallet/wallet.rb +180 -6
- data/lib/xrpl/batch.rb +167 -0
- data/lib/xrpl/client.rb +48 -7
- data/lib/xrpl/fee.rb +94 -0
- data/lib/xrpl/ledger_entry.rb +53 -0
- data/lib/xrpl/model.rb +239 -0
- data/lib/xrpl/transaction.rb +14 -163
- data/lib/xrpl/version.rb +1 -1
- data/lib/xrpl-ruby.rb +4 -0
- metadata +6 -2
|
@@ -10,6 +10,7 @@ module BinaryCodec
|
|
|
10
10
|
OutstandingAmount
|
|
11
11
|
MPTAmount
|
|
12
12
|
LockedAmount
|
|
13
|
+
ConfidentialOutstandingAmount
|
|
13
14
|
].freeze
|
|
14
15
|
|
|
15
16
|
# Returns the width of the Uint type in bytes.
|
|
@@ -23,43 +24,79 @@ module BinaryCodec
|
|
|
23
24
|
end
|
|
24
25
|
|
|
25
26
|
# Creates a new Uint instance from a value.
|
|
27
|
+
#
|
|
28
|
+
# A String is read the way rippled 3.4.1 reads it (STParsedJSON):
|
|
29
|
+
# UInt8, UInt16 and UInt32 as a whole decimal number; UInt64 as hex of
|
|
30
|
+
# any length, except in the base-10 fields (BASE10_UINT64_FIELDS),
|
|
31
|
+
# which are decimal. The fields that carry a name also take the name.
|
|
32
|
+
# ripple-binary-codec reads them the same way. Anything else, and any
|
|
33
|
+
# value outside the type's range, raises ArgumentError. Names count only
|
|
34
|
+
# for their own field (NAMED_FIELDS) when the field is given.
|
|
35
|
+
#
|
|
26
36
|
# @param value [Uint, String, Integer] The value to convert.
|
|
37
|
+
# @param field_name [String, nil] the field, which decides how a UInt64
|
|
38
|
+
# String is read; without it, as hex
|
|
27
39
|
# @return [Uint] The created instance.
|
|
28
|
-
def self.from(value)
|
|
40
|
+
def self.from(value, field_name = nil)
|
|
29
41
|
return value if value.is_a?(self)
|
|
30
42
|
|
|
31
43
|
if value.is_a?(String)
|
|
32
|
-
#
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
end
|
|
42
|
-
elsif self == Uint8
|
|
43
|
-
transaction_results = Definitions.instance.instance_variable_get(:@transaction_results)
|
|
44
|
-
if transaction_results&.key?(value)
|
|
45
|
-
return new(int_to_bytes(transaction_results[value], width))
|
|
46
|
-
end
|
|
47
|
-
end
|
|
44
|
+
# Names for the fields that carry one: TransactionType and
|
|
45
|
+
# LedgerEntryType (UInt16), TransactionResult (UInt8) and the
|
|
46
|
+
# PermissionValue of a DelegateSet (UInt32). A name is never a valid
|
|
47
|
+
# number, so the lookup cannot capture a numeric string.
|
|
48
|
+
# With the field known, only that field's names count, so a
|
|
49
|
+
# Sequence cannot take "TrustSet" or a TransferFee "Payment".
|
|
50
|
+
tables = field_name ? Array(NAMED_FIELDS[field_name]) : NAMED_VALUES.fetch(self, [])
|
|
51
|
+
code = tables.filter_map { |table| Definitions.instance.public_send(table)[value] }.first
|
|
52
|
+
return new(int_to_bytes(code, width)) if code
|
|
48
53
|
|
|
49
|
-
|
|
50
|
-
if valid_hex?(value) && value.length == self.width * 2
|
|
51
|
-
return new(hex_to_bytes(value))
|
|
52
|
-
end
|
|
53
|
-
return new(int_to_bytes(value.to_i, width))
|
|
54
|
+
return new(int_to_bytes(in_range!(parse_string(value, field_name)), width))
|
|
54
55
|
end
|
|
55
56
|
|
|
56
|
-
if value.is_a?(Integer)
|
|
57
|
-
return new(int_to_bytes(value, width))
|
|
58
|
-
end
|
|
57
|
+
return new(int_to_bytes(in_range!(value), width)) if value.is_a?(Integer)
|
|
59
58
|
|
|
60
59
|
raise StandardError, "Cannot construct #{self} from the value given"
|
|
61
60
|
end
|
|
62
61
|
|
|
62
|
+
# How a String becomes a number for this width. UInt8/16/32: decimal.
|
|
63
|
+
# UInt64: hex, or decimal in a base-10 field. The wider types keep the
|
|
64
|
+
# codec's earlier reading: hex in their full width, otherwise decimal.
|
|
65
|
+
def self.parse_string(value, field_name)
|
|
66
|
+
if width == 8
|
|
67
|
+
if BASE10_UINT64_FIELDS.include?(field_name)
|
|
68
|
+
return Integer(value, 10) if value.match?(/\A\d+\z/)
|
|
69
|
+
|
|
70
|
+
raise ArgumentError, "#{field_name} is base 10 and needs a whole decimal number, got #{value.inspect}"
|
|
71
|
+
end
|
|
72
|
+
return Integer(value, 16) if value.match?(/\A\h{1,16}\z/)
|
|
73
|
+
|
|
74
|
+
raise ArgumentError, "#{name} needs up to 16 hex digits, got #{value.inspect}"
|
|
75
|
+
elsif width > 8 && valid_hex?(value) && value.length == width * 2
|
|
76
|
+
Integer(value, 16)
|
|
77
|
+
elsif value.match?(/\A\d+\z/) || (width == 4 && value.match?(/\A\+\d+\z/))
|
|
78
|
+
# A leading '+' only for UInt32: rippled reads it with
|
|
79
|
+
# beast::lexicalCast, which skips it. Its UInt8 and UInt16 parsers
|
|
80
|
+
# take a String that does not start with a digit as a name and
|
|
81
|
+
# refuse "+5"; UInt64 above uses std::from_chars and takes no sign;
|
|
82
|
+
# the wider types are hex in rippled.
|
|
83
|
+
Integer(value, 10)
|
|
84
|
+
else
|
|
85
|
+
raise ArgumentError, "#{name} needs a whole decimal number, got #{value.inspect}"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
private_class_method :parse_string
|
|
89
|
+
|
|
90
|
+
# A value that does not fit the width used to come out a byte too long
|
|
91
|
+
# (2**32 as a UInt32) or, if negative, as 0.
|
|
92
|
+
def self.in_range!(number)
|
|
93
|
+
max = (1 << (width * 8)) - 1
|
|
94
|
+
return number if number.between?(0, max)
|
|
95
|
+
|
|
96
|
+
raise ArgumentError, "#{name} takes 0 to #{max}, got #{number}"
|
|
97
|
+
end
|
|
98
|
+
private_class_method :in_range!
|
|
99
|
+
|
|
63
100
|
# Creates a Uint instance from a parser.
|
|
64
101
|
# @param parser [BinaryParser] The parser to read from.
|
|
65
102
|
# @param _hint [Integer, nil] Unused hint.
|
|
@@ -77,29 +114,12 @@ module BinaryCodec
|
|
|
77
114
|
# Returns the JSON representation of the Uint.
|
|
78
115
|
# @return [Integer, String] The value.
|
|
79
116
|
def to_json(_definitions = nil, _field_name = nil)
|
|
80
|
-
#
|
|
81
|
-
#
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if
|
|
86
|
-
name = transaction_types.key(val)
|
|
87
|
-
return name if name
|
|
88
|
-
end
|
|
89
|
-
elsif _field_name == 'LedgerEntryType'
|
|
90
|
-
val = value_of
|
|
91
|
-
ledger_entry_types = Definitions.instance.instance_variable_get(:@ledger_entry_types)
|
|
92
|
-
if ledger_entry_types
|
|
93
|
-
name = ledger_entry_types.key(val)
|
|
94
|
-
return name if name
|
|
95
|
-
end
|
|
96
|
-
elsif _field_name == 'TransactionResult'
|
|
97
|
-
val = value_of
|
|
98
|
-
transaction_results = Definitions.instance.instance_variable_get(:@transaction_results)
|
|
99
|
-
if transaction_results
|
|
100
|
-
name = transaction_results.key(val)
|
|
101
|
-
return name if name
|
|
102
|
-
end
|
|
117
|
+
# The fields that carry a name render it, the way rippled does. A code
|
|
118
|
+
# without a name falls through and renders as the number.
|
|
119
|
+
table = NAMED_FIELDS[_field_name]
|
|
120
|
+
if table
|
|
121
|
+
name = Definitions.instance.public_send(table).key(value_of)
|
|
122
|
+
return name if name
|
|
103
123
|
end
|
|
104
124
|
|
|
105
125
|
# rippled renders the narrow unsigned integers as JSON numbers and UInt64
|
|
@@ -191,8 +211,15 @@ module BinaryCodec
|
|
|
191
211
|
# Creates a new Int32 instance from a value.
|
|
192
212
|
# @param value [Int32, Integer] The value to convert.
|
|
193
213
|
# @return [Int32] The created instance.
|
|
194
|
-
def self.from(value)
|
|
214
|
+
def self.from(value, _field_name = nil)
|
|
195
215
|
return value if value.is_a?(self)
|
|
216
|
+
# rippled reads an Int32 String as a signed decimal number, and
|
|
217
|
+
# nothing else: not as the hex a UInt64 would take.
|
|
218
|
+
if value.is_a?(String)
|
|
219
|
+
raise ArgumentError, "#{name} needs a signed decimal number, got #{value.inspect}" unless value.match?(/\A[+-]?\d+\z/)
|
|
220
|
+
|
|
221
|
+
value = Integer(value, 10)
|
|
222
|
+
end
|
|
196
223
|
if value.is_a?(Integer)
|
|
197
224
|
# Ensure it fits in 32-bit signed
|
|
198
225
|
if value < -2147483648 || value > 2147483647
|
|
@@ -215,11 +242,26 @@ module BinaryCodec
|
|
|
215
242
|
val > 0x7FFFFFFFFFFFFFFF ? val - 0x10000000000000000 : val
|
|
216
243
|
end
|
|
217
244
|
|
|
245
|
+
# The signed value as a decimal String: not the 16 hex digits of a
|
|
246
|
+
# UInt64, so the decoder's output goes back in through .from; and a
|
|
247
|
+
# String, as rippled renders 64-bit values, so a JSON reader that holds
|
|
248
|
+
# numbers as doubles (JavaScript) cannot round it.
|
|
249
|
+
def to_json(_definitions = nil, _field_name = nil)
|
|
250
|
+
value_of.to_s
|
|
251
|
+
end
|
|
252
|
+
|
|
218
253
|
# Creates a new Int64 instance from a value.
|
|
219
254
|
# @param value [Int64, Integer] The value to convert.
|
|
220
255
|
# @return [Int64] The created instance.
|
|
221
|
-
def self.from(value)
|
|
256
|
+
def self.from(value, _field_name = nil)
|
|
222
257
|
return value if value.is_a?(self)
|
|
258
|
+
# rippled reads an Int64 String as a signed decimal number, and
|
|
259
|
+
# nothing else: not as the hex a UInt64 would take.
|
|
260
|
+
if value.is_a?(String)
|
|
261
|
+
raise ArgumentError, "#{name} needs a signed decimal number, got #{value.inspect}" unless value.match?(/\A[+-]?\d+\z/)
|
|
262
|
+
|
|
263
|
+
value = Integer(value, 10)
|
|
264
|
+
end
|
|
223
265
|
if value.is_a?(Integer)
|
|
224
266
|
if value < -9223372036854775808 || value > 9223372036854775807
|
|
225
267
|
raise StandardError, "Value #{value} out of range for Int64"
|
|
@@ -231,4 +273,21 @@ module BinaryCodec
|
|
|
231
273
|
end
|
|
232
274
|
end
|
|
233
275
|
|
|
276
|
+
class Uint
|
|
277
|
+
# Which name tables a width accepts on the way in ...
|
|
278
|
+
NAMED_VALUES = {
|
|
279
|
+
Uint16 => %i[transaction_types ledger_entry_types],
|
|
280
|
+
Uint8 => %i[transaction_results],
|
|
281
|
+
Uint32 => %i[delegatable_permissions]
|
|
282
|
+
}.freeze
|
|
283
|
+
|
|
284
|
+
# ... and which field renders its value by name on the way out.
|
|
285
|
+
NAMED_FIELDS = {
|
|
286
|
+
'TransactionType' => :transaction_types,
|
|
287
|
+
'LedgerEntryType' => :ledger_entry_types,
|
|
288
|
+
'TransactionResult' => :transaction_results,
|
|
289
|
+
'PermissionValue' => :delegatable_permissions
|
|
290
|
+
}.freeze
|
|
291
|
+
end
|
|
292
|
+
|
|
234
293
|
end
|
data/lib/wallet/wallet.rb
CHANGED
|
@@ -124,14 +124,10 @@ module Wallet
|
|
|
124
124
|
# 4. Serialize the signed transaction
|
|
125
125
|
tx_blob = BinaryCodec.json_to_binary(signed_tx)
|
|
126
126
|
|
|
127
|
-
# 5.
|
|
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
|
-
|
|
127
|
+
# 5. The transaction ID: SHA512Half of 'TXN\0' and the blob
|
|
132
128
|
return {
|
|
133
129
|
'tx_blob' => tx_blob,
|
|
134
|
-
'hash' =>
|
|
130
|
+
'hash' => BinaryCodec.transaction_id(tx_blob)
|
|
135
131
|
}
|
|
136
132
|
end
|
|
137
133
|
|
|
@@ -162,6 +158,184 @@ module Wallet
|
|
|
162
158
|
@key_pairs.verify(bytes_to_hex(signing_data), signature, @public_key)
|
|
163
159
|
end
|
|
164
160
|
|
|
161
|
+
# Signs a payment channel claim: the promise that +amount+ drops of the
|
|
162
|
+
# channel's funds may be claimed. The signature goes into a
|
|
163
|
+
# PaymentChannelClaim transaction as its Signature.
|
|
164
|
+
#
|
|
165
|
+
# @param channel [String] the channel ID, 64 hex characters
|
|
166
|
+
# @param amount [String, Integer] the amount in drops
|
|
167
|
+
# @return [String] the signature as hex
|
|
168
|
+
def sign_payment_channel_claim(channel, amount)
|
|
169
|
+
data = BinaryCodec.signing_claim_data(channel: channel, amount: amount)
|
|
170
|
+
@key_pairs.sign(bytes_to_hex(data), @private_key, @algorithm)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Verifies a payment channel claim against this wallet's public key.
|
|
174
|
+
#
|
|
175
|
+
# @param channel [String] the channel ID, 64 hex characters
|
|
176
|
+
# @param amount [String, Integer] the amount in drops
|
|
177
|
+
# @param signature [String] the signature as hex
|
|
178
|
+
# @return [Boolean] whether the claim was signed by this wallet
|
|
179
|
+
def verify_payment_channel_claim(channel, amount, signature)
|
|
180
|
+
self.class.verify_payment_channel_claim(channel, amount, signature, @public_key)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Verifies a payment channel claim against any public key - the channel's
|
|
184
|
+
# PublicKey, as the receiving side has it.
|
|
185
|
+
#
|
|
186
|
+
# @param public_key [String] the public key as hex
|
|
187
|
+
# @return [Boolean] whether the claim was signed with that key
|
|
188
|
+
def self.verify_payment_channel_claim(channel, amount, signature, public_key)
|
|
189
|
+
data = BinaryCodec.signing_claim_data(channel: channel, amount: amount)
|
|
190
|
+
KeyPairs::KeyPairs.new.verify(bytes_to_hex(data), signature, public_key)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Signs a Batch (XLS-56) for an account with inner transactions in it:
|
|
194
|
+
# the BatchSigners entry that account contributes. The outer account does
|
|
195
|
+
# not need one; it signs the whole Batch with #sign once the entries are
|
|
196
|
+
# in (see .combine_batch_signers).
|
|
197
|
+
#
|
|
198
|
+
# The Batch must be complete: Account, Sequence, Flags and the inner
|
|
199
|
+
# transactions as XRPL::Client#autofill leaves them, because the
|
|
200
|
+
# signature covers the inner transaction IDs.
|
|
201
|
+
#
|
|
202
|
+
# @param batch [Hash, XRPL::Transaction] the Batch
|
|
203
|
+
# @param batch_account [String, nil] the account to sign for; by default
|
|
204
|
+
# this wallet's. Another account when this wallet is its regular key,
|
|
205
|
+
# or the account whose signer list this wallet belongs to.
|
|
206
|
+
# @param multisign [Boolean, String] true to sign as one signer of
|
|
207
|
+
# batch_account's signer list, as this wallet's own account; or the
|
|
208
|
+
# signer account, when this wallet is that signer's regular key
|
|
209
|
+
# @return [Hash] a copy of the Batch whose BatchSigners holds this one entry
|
|
210
|
+
# @raise [ArgumentError] for anything but a Batch, or an account with no
|
|
211
|
+
# inner transaction in it
|
|
212
|
+
def sign_batch(batch, batch_account: nil, multisign: false)
|
|
213
|
+
batch = self.class.batch_hash(batch)
|
|
214
|
+
# rippled requires a Sequence, and the signature covers its value (or
|
|
215
|
+
# the TicketSequence). Signed without one, the entry matches no Batch
|
|
216
|
+
# that can be submitted: autofill first, then sign.
|
|
217
|
+
unless BinaryCodec.batch_sequence_value(batch).positive?
|
|
218
|
+
raise ArgumentError, 'A Batch needs its Sequence or TicketSequence before it is signed; autofill it first'
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
batch_account ||= @classic_address
|
|
222
|
+
unless self.class.batch_signer_accounts(batch).include?(batch_account)
|
|
223
|
+
raise ArgumentError, "#{batch_account} has no inner transaction to sign for in this Batch"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
entry = { 'Account' => batch_account }
|
|
227
|
+
if multisign
|
|
228
|
+
signer_account = multisign.is_a?(String) ? multisign : @classic_address
|
|
229
|
+
data = BinaryCodec.signing_batch_data(batch, batch_account: batch_account, signer_account: signer_account)
|
|
230
|
+
entry['Signers'] = [{ 'Signer' => {
|
|
231
|
+
'Account' => signer_account,
|
|
232
|
+
'SigningPubKey' => @public_key,
|
|
233
|
+
'TxnSignature' => @key_pairs.sign(bytes_to_hex(data), @private_key, @algorithm)
|
|
234
|
+
} }]
|
|
235
|
+
else
|
|
236
|
+
data = BinaryCodec.signing_batch_data(batch, batch_account: batch_account)
|
|
237
|
+
entry['SigningPubKey'] = @public_key
|
|
238
|
+
entry['TxnSignature'] = @key_pairs.sign(bytes_to_hex(data), @private_key, @algorithm)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
batch.merge('BatchSigners' => [{ 'BatchSigner' => entry }])
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# The accounts that sign a Batch's BatchSigners: the Account of every
|
|
245
|
+
# inner transaction, or its Delegate when it has one, since the delegate
|
|
246
|
+
# acts for the account. Each account once, in order of appearance. The
|
|
247
|
+
# outer account is included; its entry is dropped when combining.
|
|
248
|
+
#
|
|
249
|
+
# @param batch [Hash, XRPL::Transaction] the Batch
|
|
250
|
+
# @return [Array<String>]
|
|
251
|
+
def self.batch_signer_accounts(batch)
|
|
252
|
+
batch_hash(batch)['RawTransactions'].map do |wrapper|
|
|
253
|
+
inner = wrapper['RawTransaction'] || {}
|
|
254
|
+
inner['Delegate'] || inner['Account']
|
|
255
|
+
end.compact.uniq
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Merges the copies of a Batch that each signer returned from
|
|
259
|
+
# #sign_batch into one Batch carrying all BatchSigners entries, ready for
|
|
260
|
+
# the outer account to sign with #sign.
|
|
261
|
+
#
|
|
262
|
+
# Entries are sorted by numeric AccountID, as rippled requires. The outer
|
|
263
|
+
# account's entry is dropped: its signature is the outer one. Signatures
|
|
264
|
+
# of the same account's signer list are merged into one entry with its
|
|
265
|
+
# Signers sorted the same way; an identical entry given twice is kept once.
|
|
266
|
+
#
|
|
267
|
+
# @param batches [Array<Hash, String>] the signed copies, as Hashes or
|
|
268
|
+
# serialised hex
|
|
269
|
+
# @return [Hash] the Batch with the combined BatchSigners
|
|
270
|
+
# @raise [ArgumentError] when the copies are not of the same Batch, one
|
|
271
|
+
# carries no BatchSigners, or the outer transaction is already signed
|
|
272
|
+
def self.combine_batch_signers(batches)
|
|
273
|
+
batches = Array(batches).map { |b| b.is_a?(String) ? BinaryCodec.binary_to_json(b) : batch_hash(b) }
|
|
274
|
+
raise ArgumentError, 'combine_batch_signers needs at least one signed Batch' if batches.empty?
|
|
275
|
+
|
|
276
|
+
batches.each do |b|
|
|
277
|
+
raise ArgumentError, 'The Batch must not be signed yet; the outer account signs last' if b['TxnSignature'] || b['Signers']
|
|
278
|
+
raise ArgumentError, 'Every Batch to combine needs BatchSigners' if Array(b['BatchSigners']).empty?
|
|
279
|
+
end
|
|
280
|
+
unsigned = batches.map { |b| b.reject { |key, _| key == 'BatchSigners' } }
|
|
281
|
+
unless unsigned.uniq.size == 1
|
|
282
|
+
raise ArgumentError, 'The Batches to combine have to be the same apart from BatchSigners'
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
outer = batches.first['Account']
|
|
286
|
+
by_account = {}
|
|
287
|
+
batches.flat_map { |b| b['BatchSigners'] }.each do |wrapper|
|
|
288
|
+
entry = wrapper['BatchSigner']
|
|
289
|
+
account = entry['Account']
|
|
290
|
+
next if account == outer
|
|
291
|
+
|
|
292
|
+
by_account[account] = merge_batch_signer(by_account[account], entry)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
entries = by_account.values.sort_by { |entry| account_sort_key(entry['Account']) }
|
|
296
|
+
unsigned.first.merge('BatchSigners' => entries.map { |entry| { 'BatchSigner' => entry } })
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# The Batch as a Hash, its inner transactions included: a model, or a
|
|
300
|
+
# Hash whose RawTransaction entries are models, comes back as plain
|
|
301
|
+
# Hashes, so the copy that sign_batch returns can be serialised.
|
|
302
|
+
#
|
|
303
|
+
# @api private
|
|
304
|
+
def self.batch_hash(batch)
|
|
305
|
+
batch = batch.to_h if !batch.is_a?(::Hash) && batch.respond_to?(:to_h)
|
|
306
|
+
unless batch.is_a?(::Hash) && batch['TransactionType'] == 'Batch'
|
|
307
|
+
raise ArgumentError, 'sign_batch needs a Batch transaction'
|
|
308
|
+
end
|
|
309
|
+
return batch unless batch['RawTransactions'].is_a?(Array)
|
|
310
|
+
|
|
311
|
+
raw = batch['RawTransactions'].map do |wrapper|
|
|
312
|
+
inner = wrapper.is_a?(::Hash) ? wrapper['RawTransaction'] : nil
|
|
313
|
+
inner.is_a?(XRPL::Transaction) ? wrapper.merge('RawTransaction' => inner.to_h) : wrapper
|
|
314
|
+
end
|
|
315
|
+
batch.merge('RawTransactions' => raw)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Two entries for the same account: identical ones collapse, signer-list
|
|
319
|
+
# entries merge their Signers, anything else is a conflict.
|
|
320
|
+
def self.merge_batch_signer(existing, entry)
|
|
321
|
+
return entry if existing.nil? || existing == entry
|
|
322
|
+
|
|
323
|
+
unless existing['Signers'] && entry['Signers']
|
|
324
|
+
raise ArgumentError, "Conflicting BatchSigners entries for #{entry['Account']}"
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
signers = (existing['Signers'] + entry['Signers'])
|
|
328
|
+
.uniq { |s| s['Signer']['Account'] }
|
|
329
|
+
.sort_by { |s| account_sort_key(s['Signer']['Account']) }
|
|
330
|
+
existing.merge('Signers' => signers)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def self.account_sort_key(address)
|
|
334
|
+
AddressCodec::AddressCodec.new.decode_account_id(address).to_a
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
private_class_method :merge_batch_signer, :account_sort_key
|
|
338
|
+
|
|
165
339
|
# Derives the X-address for this wallet.
|
|
166
340
|
# @param tag [Integer, false, nil] The destination tag.
|
|
167
341
|
# @param test_network [Boolean] Whether the address is for a test network.
|
data/lib/xrpl/batch.rb
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module XRPL
|
|
4
|
+
# Prepares the inner transactions of a Batch (XLS-56) before it is signed.
|
|
5
|
+
#
|
|
6
|
+
# The ledger requires every inner transaction to carry the tfInnerBatchTxn
|
|
7
|
+
# flag, a Fee of "0" and an empty SigningPubKey: the outer transaction pays
|
|
8
|
+
# the fees, and the outer signature together with BatchSigners authorises
|
|
9
|
+
# the inner transactions. They must also carry their final Sequence, because
|
|
10
|
+
# the BatchSigners sign over the inner transaction IDs. So the Batch has to
|
|
11
|
+
# be complete before anyone signs it.
|
|
12
|
+
#
|
|
13
|
+
# The module is pure: XRPL::Client#autofill passes in a way to fetch an
|
|
14
|
+
# account's sequence. What the ledger would refuse is refused here, before
|
|
15
|
+
# anything is signed or sent.
|
|
16
|
+
module Batch
|
|
17
|
+
FLAGS = Model::DEFINITIONS['TRANSACTION_FLAGS']
|
|
18
|
+
|
|
19
|
+
# The four modes; a Batch names exactly one of them.
|
|
20
|
+
MODE_FLAGS = FLAGS.fetch('Batch').values.reduce(0, :|)
|
|
21
|
+
|
|
22
|
+
# Marks a transaction as the inner transaction of a Batch.
|
|
23
|
+
TF_INNER_BATCH_TXN = FLAGS.fetch('universal').fetch('tfInnerBatchTxn')
|
|
24
|
+
|
|
25
|
+
MIN_INNER = 2
|
|
26
|
+
MAX_INNER = 8
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# @param batch [Hash] the Batch, with its outer Sequence (or
|
|
31
|
+
# TicketSequence) already set; it is not modified
|
|
32
|
+
# @param sequence_for [#call] takes an account and returns its next
|
|
33
|
+
# sequence; only called for accounts whose inner transactions lack a
|
|
34
|
+
# Sequence and a TicketSequence, once per account
|
|
35
|
+
# @return [Hash] a copy of the Batch with its inner transactions filled in
|
|
36
|
+
# @raise [ArgumentError] when the ledger would refuse the Batch
|
|
37
|
+
def prepare(batch, sequence_for:)
|
|
38
|
+
check_mode!(batch['Flags'])
|
|
39
|
+
check_sequence_and_ticket!(batch, 'The Batch')
|
|
40
|
+
raw = batch['RawTransactions']
|
|
41
|
+
unless raw.is_a?(Array) && raw.size.between?(MIN_INNER, MAX_INNER)
|
|
42
|
+
count = raw.is_a?(Array) ? raw.size : 0
|
|
43
|
+
raise ArgumentError, "A Batch carries #{MIN_INNER} to #{MAX_INNER} inner transactions, this one has #{count}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Per account: the next sequence to hand out, and the lowest one still
|
|
47
|
+
# free after the explicit sequences seen so far. An inner transaction
|
|
48
|
+
# without a sequence gets the higher of the two, so it never repeats
|
|
49
|
+
# one given explicitly earlier in the Batch.
|
|
50
|
+
next_sequence = {}
|
|
51
|
+
floor = Hash.new(0)
|
|
52
|
+
take = lambda do |account|
|
|
53
|
+
[next_sequence[account] || first_sequence(batch, account, sequence_for), floor[account]].max
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Everything is checked before the first sequence is handed out, so a
|
|
57
|
+
# Batch the ledger would refuse never reaches sequence_for.
|
|
58
|
+
inners = raw.each_with_index.map do |wrapper, index|
|
|
59
|
+
unwrap(wrapper, index).tap { |inner| check_inner!(inner, index) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
prepared = inners.map do |inner|
|
|
63
|
+
inner['Flags'] = Integer(inner['Flags'] || 0) | TF_INNER_BATCH_TXN
|
|
64
|
+
inner['Fee'] = '0'
|
|
65
|
+
inner['SigningPubKey'] = ''
|
|
66
|
+
|
|
67
|
+
account = inner['Account']
|
|
68
|
+
# A TicketCreate also takes a sequence for every ticket it creates.
|
|
69
|
+
reserved = inner['TransactionType'] == 'TicketCreate' ? Integer(inner['TicketCount'] || 0) : 0
|
|
70
|
+
|
|
71
|
+
if !inner['TicketSequence'].nil? && BinaryCodec.uint32_value(inner['Sequence']).zero?
|
|
72
|
+
# On a ticket: Sequence is a required field and 0 here. Only a
|
|
73
|
+
# TicketCreate still moves the account's sequence on, by the
|
|
74
|
+
# tickets it creates, starting at the current one.
|
|
75
|
+
inner['Sequence'] = 0
|
|
76
|
+
next_sequence[account] = take.call(account) + reserved if reserved.positive?
|
|
77
|
+
elsif inner['Sequence'].nil?
|
|
78
|
+
sequence = take.call(account)
|
|
79
|
+
inner['Sequence'] = sequence
|
|
80
|
+
next_sequence[account] = sequence + 1 + reserved
|
|
81
|
+
else
|
|
82
|
+
floor[account] = [floor[account], BinaryCodec.uint32_value(inner['Sequence']) + 1 + reserved].max
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
{ 'RawTransaction' => inner }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
batch.merge('RawTransactions' => prepared)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The number of signatures a BatchSigners array carries: one per entry,
|
|
92
|
+
# or, for an entry signed by a signer list, one per nested signer.
|
|
93
|
+
#
|
|
94
|
+
# @param batch_signers [Array<Hash>, nil]
|
|
95
|
+
# @return [Integer]
|
|
96
|
+
def signature_count(batch_signers)
|
|
97
|
+
Array(batch_signers).sum do |entry|
|
|
98
|
+
signer = entry.is_a?(Hash) ? (entry['BatchSigner'] || entry) : {}
|
|
99
|
+
nested = signer['Signers']
|
|
100
|
+
nested.is_a?(Array) && !nested.empty? ? nested.size : 1
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def check_mode!(flags)
|
|
105
|
+
mode = Integer(flags || 0) & MODE_FLAGS
|
|
106
|
+
return if mode.positive? && (mode & (mode - 1)).zero?
|
|
107
|
+
|
|
108
|
+
raise ArgumentError, 'A Batch needs exactly one of tfAllOrNothing, tfOnlyOne, ' \
|
|
109
|
+
'tfUntilFailure and tfIndependent in Flags'
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def unwrap(wrapper, index)
|
|
113
|
+
inner = wrapper.is_a?(Hash) && wrapper.keys == ['RawTransaction'] ? wrapper['RawTransaction'] : nil
|
|
114
|
+
inner = inner.to_h if inner.is_a?(XRPL::Transaction)
|
|
115
|
+
unless inner.is_a?(Hash)
|
|
116
|
+
raise ArgumentError, "RawTransactions[#{index}] must be { 'RawTransaction' => transaction }"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
inner.dup
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# On a ticket, Sequence is 0; rippled refuses a nonzero Sequence next to
|
|
123
|
+
# a TicketSequence (temSEQ_AND_TICKET). Refused before any sequence is
|
|
124
|
+
# handed out, so it cannot push the account's later sequences up.
|
|
125
|
+
def check_sequence_and_ticket!(transaction, label)
|
|
126
|
+
# Reading both values here refuses a malformed or out-of-range one in
|
|
127
|
+
# the checking pass, not after sequences were already fetched.
|
|
128
|
+
sequence = BinaryCodec.uint32_value(transaction['Sequence'])
|
|
129
|
+
BinaryCodec.uint32_value(transaction['TicketSequence'])
|
|
130
|
+
return if transaction['TicketSequence'].nil? || sequence.zero?
|
|
131
|
+
|
|
132
|
+
raise ArgumentError, "#{label} has both a Sequence and a TicketSequence; " \
|
|
133
|
+
'on a ticket, Sequence is 0 (temSEQ_AND_TICKET)'
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def check_inner!(inner, index)
|
|
137
|
+
prefix = "RawTransactions[#{index}]"
|
|
138
|
+
raise ArgumentError, "#{prefix} has no Account" if inner['Account'].nil?
|
|
139
|
+
|
|
140
|
+
check_sequence_and_ticket!(inner, prefix)
|
|
141
|
+
raise ArgumentError, "#{prefix} is a Batch; a Batch cannot nest" if inner['TransactionType'] == 'Batch'
|
|
142
|
+
if inner.key?('TxnSignature') || inner.key?('Signers')
|
|
143
|
+
raise ArgumentError, "#{prefix} must not be signed; the Batch is signed as a whole"
|
|
144
|
+
end
|
|
145
|
+
if inner.key?('Fee') && inner['Fee'].to_s != '0'
|
|
146
|
+
raise ArgumentError, "#{prefix} must have Fee \"0\"; the Batch pays the fees"
|
|
147
|
+
end
|
|
148
|
+
return unless inner.key?('SigningPubKey') && inner['SigningPubKey'].to_s != ''
|
|
149
|
+
|
|
150
|
+
raise ArgumentError, "#{prefix} must have an empty SigningPubKey"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# The outer account's inner transactions follow the outer one, which
|
|
154
|
+
# consumes its Sequence; when it uses a ticket instead, the account's
|
|
155
|
+
# sequence is still free. Every other account starts at its own.
|
|
156
|
+
def first_sequence(batch, account, sequence_for)
|
|
157
|
+
outer_sequence = BinaryCodec.uint32_value(batch['Sequence'])
|
|
158
|
+
if account == batch['Account'] && outer_sequence.positive? && batch['TicketSequence'].nil?
|
|
159
|
+
outer_sequence + 1
|
|
160
|
+
else
|
|
161
|
+
Integer(sequence_for.call(account))
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
private_class_method :check_mode!, :check_sequence_and_ticket!, :unwrap, :check_inner!, :first_sequence
|
|
166
|
+
end
|
|
167
|
+
end
|
data/lib/xrpl/client.rb
CHANGED
|
@@ -52,13 +52,18 @@ module XRPL
|
|
|
52
52
|
# @param logger [Logger, nil] optional logger for diagnostic messages. When nil
|
|
53
53
|
# (the default), the client stays silent — a library must not write to the
|
|
54
54
|
# host application's stdout uninvited. Pass e.g. +Logger.new($stdout)+ to opt in.
|
|
55
|
-
|
|
55
|
+
# @param max_fee_drops [Integer] cap for the fee autofill computes, so a
|
|
56
|
+
# spike in the open ledger fee cannot burn an account. Transactions that
|
|
57
|
+
# cost an owner reserve (AccountDelete, AMMCreate, VaultCreate) are not
|
|
58
|
+
# capped.
|
|
59
|
+
def initialize(url, logger: nil, max_fee_drops: Fee::MAX_FEE_DROPS)
|
|
56
60
|
@url = resolve_url(url)
|
|
57
61
|
@connection = nil
|
|
58
62
|
@requests = {}
|
|
59
63
|
@open = false
|
|
60
64
|
@ready_queue = Queue.new
|
|
61
65
|
@logger = logger
|
|
66
|
+
@max_fee_drops = max_fee_drops
|
|
62
67
|
end
|
|
63
68
|
|
|
64
69
|
# Opens the WebSocket connection.
|
|
@@ -319,13 +324,33 @@ module XRPL
|
|
|
319
324
|
# Fills in the fields a transaction needs before signing: +Sequence+, +Fee+
|
|
320
325
|
# and +LastLedgerSequence+. Existing values are never overwritten.
|
|
321
326
|
#
|
|
327
|
+
# The fee follows the transaction type (see XRPL::Fee): an EscrowFinish
|
|
328
|
+
# pays for its Fulfillment, AccountDelete, AMMCreate and VaultCreate cost
|
|
329
|
+
# the owner reserve, a Batch pays for its signatures and its inner
|
|
330
|
+
# transactions.
|
|
331
|
+
#
|
|
332
|
+
# A Batch also gets its inner transactions prepared (see XRPL::Batch):
|
|
333
|
+
# tfInnerBatchTxn, Fee "0", an empty SigningPubKey and their sequences.
|
|
334
|
+
# The BatchSigners sign over the finished Batch, so its fee has to be set
|
|
335
|
+
# before they exist; +batch_signatures:+ says how many there will be.
|
|
336
|
+
#
|
|
322
337
|
# @param transaction [Hash, XRPL::Transaction] the transaction to complete.
|
|
323
338
|
# @param signers_count [Integer] number of signatures for multisign fee scaling.
|
|
339
|
+
# @param batch_signatures [Integer] for a Batch: the signatures its
|
|
340
|
+
# BatchSigners will carry, one per other account (or one per signer of
|
|
341
|
+
# an account signing with a signer list). Zero for a Batch of one account.
|
|
324
342
|
# @return [Hash] a copy of the transaction with the missing fields filled in.
|
|
325
|
-
|
|
343
|
+
# @raise [ArgumentError] for a Batch the ledger would refuse.
|
|
344
|
+
def autofill(transaction, signers_count: 0, batch_signatures: 0)
|
|
326
345
|
tx = self.class.to_transaction_hash(transaction).dup
|
|
346
|
+
# On a ticket, Sequence is 0; the account's own would be refused
|
|
347
|
+
# (temSEQ_AND_TICKET).
|
|
348
|
+
tx['Sequence'] = 0 if tx['Sequence'].nil? && !tx['TicketSequence'].nil?
|
|
327
349
|
tx['Sequence'] ||= fetch_sequence(tx.fetch('Account'))
|
|
328
|
-
tx['
|
|
350
|
+
if tx['TransactionType'] == 'Batch'
|
|
351
|
+
tx = Batch.prepare(tx, sequence_for: ->(account) { fetch_sequence(account) })
|
|
352
|
+
end
|
|
353
|
+
tx['Fee'] ||= calculate_fee(tx, signers_count, batch_signatures)
|
|
329
354
|
tx['LastLedgerSequence'] ||= current_ledger_index + LEDGER_OFFSET
|
|
330
355
|
tx
|
|
331
356
|
end
|
|
@@ -440,10 +465,15 @@ module XRPL
|
|
|
440
465
|
Integer(sequence)
|
|
441
466
|
end
|
|
442
467
|
|
|
443
|
-
def calculate_fee(signers_count)
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
468
|
+
def calculate_fee(tx, signers_count, batch_signatures = 0)
|
|
469
|
+
Fee.calculate(
|
|
470
|
+
tx,
|
|
471
|
+
base_fee: base_fee_drops,
|
|
472
|
+
signers_count: signers_count.to_i,
|
|
473
|
+
batch_signatures: batch_signatures.to_i,
|
|
474
|
+
max_fee: @max_fee_drops,
|
|
475
|
+
owner_reserve: -> { owner_reserve_drops }
|
|
476
|
+
).to_s
|
|
447
477
|
end
|
|
448
478
|
|
|
449
479
|
def base_fee_drops
|
|
@@ -455,6 +485,17 @@ module XRPL
|
|
|
455
485
|
DEFAULT_FEE_DROPS
|
|
456
486
|
end
|
|
457
487
|
|
|
488
|
+
# The owner reserve (reserve_inc), which AccountDelete, AMMCreate and
|
|
489
|
+
# VaultCreate cost instead of a fee. There is no sensible default: a
|
|
490
|
+
# guess would be rejected or overpay by orders of magnitude.
|
|
491
|
+
def owner_reserve_drops
|
|
492
|
+
response = request_with_retry('server_state')
|
|
493
|
+
reserve = response.dig('result', 'state', 'validated_ledger', 'reserve_inc')
|
|
494
|
+
raise TransactionError, 'Could not determine the owner reserve from server_state' unless reserve
|
|
495
|
+
|
|
496
|
+
Integer(reserve)
|
|
497
|
+
end
|
|
498
|
+
|
|
458
499
|
def current_ledger_index
|
|
459
500
|
response = request_with_retry('ledger_current')
|
|
460
501
|
index = response.dig('result', 'ledger_current_index')
|