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.
@@ -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
- # Special handling for TransactionType and LedgerEntryType
33
- if self == Uint16
34
- transaction_types = Definitions.instance.instance_variable_get(:@transaction_types)
35
- if transaction_types&.key?(value)
36
- return new(int_to_bytes(transaction_types[value], width))
37
- end
38
- ledger_entry_types = Definitions.instance.instance_variable_get(:@ledger_entry_types)
39
- if ledger_entry_types&.key?(value)
40
- return new(int_to_bytes(ledger_entry_types[value], width))
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
- # Handle hex strings or numeric strings
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
- # Special handling for TransactionType, LedgerEntryType, and TransactionResult
81
- # ONLY when requested via a field name that matches.
82
- if _field_name == 'TransactionType'
83
- val = value_of
84
- transaction_types = Definitions.instance.instance_variable_get(:@transaction_types)
85
- if transaction_types
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. 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
-
127
+ # 5. The transaction ID: SHA512Half of 'TXN\0' and the blob
132
128
  return {
133
129
  'tx_blob' => tx_blob,
134
- 'hash' => 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
- def initialize(url, logger: nil)
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
- def autofill(transaction, signers_count: 0)
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['Fee'] ||= calculate_fee(signers_count)
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
- base = base_fee_drops
445
- total = signers_count.to_i.positive? ? base * (1 + signers_count.to_i) : base
446
- total.to_s
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')