xrpl-ruby 0.6.1 → 0.8.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.
@@ -3,6 +3,16 @@
3
3
  module BinaryCodec
4
4
 
5
5
  class Uint < ComparableSerializedType
6
+ # UInt64 fields that rippled renders in base 10 rather than as hex. They
7
+ # hold MPToken amounts, where a hex string would be a needless surprise.
8
+ BASE10_UINT64_FIELDS = %w[
9
+ MaximumAmount
10
+ OutstandingAmount
11
+ MPTAmount
12
+ LockedAmount
13
+ ConfidentialOutstandingAmount
14
+ ].freeze
15
+
6
16
  # Returns the width of the Uint type in bytes.
7
17
  # @return [Integer] The width.
8
18
  def self.width
@@ -20,22 +30,14 @@ module BinaryCodec
20
30
  return value if value.is_a?(self)
21
31
 
22
32
  if value.is_a?(String)
23
- # Special handling for TransactionType and LedgerEntryType
24
- if self == Uint16
25
- transaction_types = Definitions.instance.instance_variable_get(:@transaction_types)
26
- if transaction_types&.key?(value)
27
- return new(int_to_bytes(transaction_types[value], width))
28
- end
29
- ledger_entry_types = Definitions.instance.instance_variable_get(:@ledger_entry_types)
30
- if ledger_entry_types&.key?(value)
31
- return new(int_to_bytes(ledger_entry_types[value], width))
32
- end
33
- elsif self == Uint8
34
- transaction_results = Definitions.instance.instance_variable_get(:@transaction_results)
35
- if transaction_results&.key?(value)
36
- return new(int_to_bytes(transaction_results[value], width))
37
- end
38
- end
33
+ # Names for the fields that carry one: TransactionType and
34
+ # LedgerEntryType (UInt16), TransactionResult (UInt8) and the
35
+ # PermissionValue of a DelegateSet (UInt32). A name is never a valid
36
+ # number, so the lookup cannot capture a numeric string.
37
+ code = NAMED_VALUES.fetch(self, [])
38
+ .filter_map { |table| Definitions.instance.public_send(table)[value] }
39
+ .first
40
+ return new(int_to_bytes(code, width)) if code
39
41
 
40
42
  # Handle hex strings or numeric strings
41
43
  if valid_hex?(value) && value.length == self.width * 2
@@ -68,36 +70,28 @@ module BinaryCodec
68
70
  # Returns the JSON representation of the Uint.
69
71
  # @return [Integer, String] The value.
70
72
  def to_json(_definitions = nil, _field_name = nil)
71
- # Special handling for TransactionType, LedgerEntryType, and TransactionResult
72
- # ONLY when requested via a field name that matches.
73
- if _field_name == 'TransactionType'
74
- val = value_of
75
- transaction_types = Definitions.instance.instance_variable_get(:@transaction_types)
76
- if transaction_types
77
- name = transaction_types.key(val)
78
- return name if name
79
- end
80
- elsif _field_name == 'LedgerEntryType'
81
- val = value_of
82
- ledger_entry_types = Definitions.instance.instance_variable_get(:@ledger_entry_types)
83
- if ledger_entry_types
84
- name = ledger_entry_types.key(val)
85
- return name if name
86
- end
87
- elsif _field_name == 'TransactionResult'
88
- val = value_of
89
- transaction_results = Definitions.instance.instance_variable_get(:@transaction_results)
90
- if transaction_results
91
- name = transaction_results.key(val)
92
- return name if name
93
- end
73
+ # The fields that carry a name render it, the way rippled does. A code
74
+ # without a name falls through and renders as the number.
75
+ table = NAMED_FIELDS[_field_name]
76
+ if table
77
+ name = Definitions.instance.public_send(table).key(value_of)
78
+ return name if name
94
79
  end
95
80
 
96
- # For Uint8/16/32/64 and Int32/64 we return padded hex, to satisfy existing Ruby tests.
97
- # We use unsigned value for hex representation of signed types.
98
- u_val = value_of
99
- u_val += (1 << (self.class.width * 8)) if u_val < 0
100
- return u_val.to_s(16).upcase.rjust(self.class.width * 2, '0')
81
+ # rippled renders the narrow unsigned integers as JSON numbers and UInt64
82
+ # as a 16 digit hex string, because a UInt64 does not survive a round trip
83
+ # through a JSON number. The MPToken amount fields are the exception to
84
+ # that exception: they are UInt64 but carry a base 10 string.
85
+ #
86
+ # Everything wider than 8 bytes (Uint96 and up) is hash-like and stays
87
+ # hex. Do not widen the numeric branch to cover it.
88
+ val = value_of
89
+ return val if self.class.width < 8
90
+ return val.to_s if self.class.width == 8 && BASE10_UINT64_FIELDS.include?(_field_name)
91
+
92
+ # Hex is unsigned, so a negative signed value has to wrap first.
93
+ val += (1 << (self.class.width * 8)) if val < 0
94
+ val.to_s(16).upcase.rjust(self.class.width * 2, '0')
101
95
  end
102
96
  # @param other [Uint] The other Uint to compare to.
103
97
  # @return [Integer] Comparison result (-1, 0, or 1).
@@ -213,4 +207,21 @@ module BinaryCodec
213
207
  end
214
208
  end
215
209
 
210
+ class Uint
211
+ # Which name tables a width accepts on the way in ...
212
+ NAMED_VALUES = {
213
+ Uint16 => %i[transaction_types ledger_entry_types],
214
+ Uint8 => %i[transaction_results],
215
+ Uint32 => %i[delegatable_permissions]
216
+ }.freeze
217
+
218
+ # ... and which field renders its value by name on the way out.
219
+ NAMED_FIELDS = {
220
+ 'TransactionType' => :transaction_types,
221
+ 'LedgerEntryType' => :ledger_entry_types,
222
+ 'TransactionResult' => :transaction_results,
223
+ 'PermissionValue' => :delegatable_permissions
224
+ }.freeze
225
+ end
226
+
216
227
  end
@@ -15,9 +15,9 @@ module BinaryCodec
15
15
 
16
16
  if value.is_a?(::Hash)
17
17
  bytes = []
18
- bytes.concat(AccountId.from(value['LockingChainDoor']).to_bytes)
18
+ bytes.concat(door_bytes(value['LockingChainDoor']))
19
19
  bytes.concat(Issue.from(value['LockingChainIssue']).to_bytes)
20
- bytes.concat(AccountId.from(value['IssuingChainDoor']).to_bytes)
20
+ bytes.concat(door_bytes(value['IssuingChainDoor']))
21
21
  bytes.concat(Issue.from(value['IssuingChainIssue']).to_bytes)
22
22
  return XChainBridge.new(bytes)
23
23
  end
@@ -25,10 +25,23 @@ module BinaryCodec
25
25
  raise StandardError, "Cannot construct XChainBridge from #{value.class}"
26
26
  end
27
27
 
28
+ # An account inside a bridge carries the same length prefix as a
29
+ # standalone AccountID field. Leaving it off produces bytes that decode
30
+ # as something else entirely, which is why every XChain fixture failed
31
+ # to serialise.
32
+ DOOR_LENGTH_PREFIX = 0x14
33
+
34
+ def self.door_bytes(account)
35
+ [DOOR_LENGTH_PREFIX] + AccountId.from(account).to_bytes
36
+ end
37
+ private_class_method :door_bytes
38
+
28
39
  def self.from_parser(parser, _hint = nil)
29
40
  bytes = []
41
+ bytes.concat(parser.read(1)) # length prefix
30
42
  bytes.concat(parser.read(20)) # LockingChainDoor
31
43
  bytes.concat(Issue.from_parser(parser, 40).to_bytes) # LockingChainIssue
44
+ bytes.concat(parser.read(1)) # length prefix
32
45
  bytes.concat(parser.read(20)) # IssuingChainDoor
33
46
  bytes.concat(Issue.from_parser(parser, 40).to_bytes) # IssuingChainIssue
34
47
  XChainBridge.new(bytes)
@@ -37,8 +50,10 @@ module BinaryCodec
37
50
  def to_json(_definitions = nil, _field_name = nil)
38
51
  parser = BinaryParser.new(to_hex)
39
52
  result = {}
53
+ parser.read(1) # length prefix
40
54
  result['LockingChainDoor'] = AccountId.from_parser(parser).to_json
41
55
  result['LockingChainIssue'] = Issue.from_parser(parser, 40).to_json
56
+ parser.read(1) # length prefix
42
57
  result['IssuingChainDoor'] = AccountId.from_parser(parser).to_json
43
58
  result['IssuingChainIssue'] = Issue.from_parser(parser, 40).to_json
44
59
  result
data/lib/wallet/wallet.rb CHANGED
@@ -162,6 +162,38 @@ module Wallet
162
162
  @key_pairs.verify(bytes_to_hex(signing_data), signature, @public_key)
163
163
  end
164
164
 
165
+ # Signs a payment channel claim: the promise that +amount+ drops of the
166
+ # channel's funds may be claimed. The signature goes into a
167
+ # PaymentChannelClaim transaction as its Signature.
168
+ #
169
+ # @param channel [String] the channel ID, 64 hex characters
170
+ # @param amount [String, Integer] the amount in drops
171
+ # @return [String] the signature as hex
172
+ def sign_payment_channel_claim(channel, amount)
173
+ data = BinaryCodec.signing_claim_data(channel: channel, amount: amount)
174
+ @key_pairs.sign(bytes_to_hex(data), @private_key, @algorithm)
175
+ end
176
+
177
+ # Verifies a payment channel claim against this wallet's public key.
178
+ #
179
+ # @param channel [String] the channel ID, 64 hex characters
180
+ # @param amount [String, Integer] the amount in drops
181
+ # @param signature [String] the signature as hex
182
+ # @return [Boolean] whether the claim was signed by this wallet
183
+ def verify_payment_channel_claim(channel, amount, signature)
184
+ self.class.verify_payment_channel_claim(channel, amount, signature, @public_key)
185
+ end
186
+
187
+ # Verifies a payment channel claim against any public key - the channel's
188
+ # PublicKey, as the receiving side has it.
189
+ #
190
+ # @param public_key [String] the public key as hex
191
+ # @return [Boolean] whether the claim was signed with that key
192
+ def self.verify_payment_channel_claim(channel, amount, signature, public_key)
193
+ data = BinaryCodec.signing_claim_data(channel: channel, amount: amount)
194
+ KeyPairs::KeyPairs.new.verify(bytes_to_hex(data), signature, public_key)
195
+ end
196
+
165
197
  # Derives the X-address for this wallet.
166
198
  # @param tag [Integer, false, nil] The destination tag.
167
199
  # @param test_network [Boolean] Whether the address is for a test network.
data/lib/xrpl/client.rb CHANGED
@@ -35,17 +35,35 @@ module XRPL
35
35
 
36
36
  attr_reader :url, :connection
37
37
 
38
+ # Accepts a transaction as a plain Hash or as an XRPL::Transaction and
39
+ # hands back the Hash the rest of the pipeline works with. Anything else
40
+ # is passed through untouched, so a pre-signed blob still reaches submit.
41
+ #
42
+ # @param transaction [Hash, XRPL::Transaction, Object]
43
+ # @return [Hash, Object]
44
+ def self.to_transaction_hash(transaction)
45
+ return transaction if transaction.is_a?(Hash)
46
+ return transaction.to_h if transaction.is_a?(XRPL::Transaction)
47
+
48
+ transaction
49
+ end
50
+
38
51
  # @param url [String, Symbol] a network alias (:testnet/:mainnet/:devnet) or a WebSocket URL.
39
52
  # @param logger [Logger, nil] optional logger for diagnostic messages. When nil
40
53
  # (the default), the client stays silent — a library must not write to the
41
54
  # host application's stdout uninvited. Pass e.g. +Logger.new($stdout)+ to opt in.
42
- 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)
43
60
  @url = resolve_url(url)
44
61
  @connection = nil
45
62
  @requests = {}
46
63
  @open = false
47
64
  @ready_queue = Queue.new
48
65
  @logger = logger
66
+ @max_fee_drops = max_fee_drops
49
67
  end
50
68
 
51
69
  # Opens the WebSocket connection.
@@ -306,13 +324,17 @@ module XRPL
306
324
  # Fills in the fields a transaction needs before signing: +Sequence+, +Fee+
307
325
  # and +LastLedgerSequence+. Existing values are never overwritten.
308
326
  #
309
- # @param transaction [Hash] the (string-keyed) transaction to complete.
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 inner transactions.
330
+ #
331
+ # @param transaction [Hash, XRPL::Transaction] the transaction to complete.
310
332
  # @param signers_count [Integer] number of signatures for multisign fee scaling.
311
333
  # @return [Hash] a copy of the transaction with the missing fields filled in.
312
334
  def autofill(transaction, signers_count: 0)
313
- tx = transaction.dup
335
+ tx = self.class.to_transaction_hash(transaction).dup
314
336
  tx['Sequence'] ||= fetch_sequence(tx.fetch('Account'))
315
- tx['Fee'] ||= calculate_fee(signers_count)
337
+ tx['Fee'] ||= calculate_fee(tx, signers_count)
316
338
  tx['LastLedgerSequence'] ||= current_ledger_index + LEDGER_OFFSET
317
339
  tx
318
340
  end
@@ -399,7 +421,8 @@ module XRPL
399
421
  def prepare_for_submit(transaction, wallet:, autofill:)
400
422
  raise ArgumentError, 'wallet: is required to sign the transaction' if wallet.nil?
401
423
 
402
- tx = transaction.is_a?(Hash) ? transaction.dup : transaction
424
+ tx = self.class.to_transaction_hash(transaction)
425
+ tx = tx.dup if tx.is_a?(Hash)
403
426
  tx = autofill(tx) if autofill && tx.is_a?(Hash)
404
427
 
405
428
  signed = wallet.sign(tx)
@@ -426,10 +449,14 @@ module XRPL
426
449
  Integer(sequence)
427
450
  end
428
451
 
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
452
+ def calculate_fee(tx, signers_count)
453
+ Fee.calculate(
454
+ tx,
455
+ base_fee: base_fee_drops,
456
+ signers_count: signers_count.to_i,
457
+ max_fee: @max_fee_drops,
458
+ owner_reserve: -> { owner_reserve_drops }
459
+ ).to_s
433
460
  end
434
461
 
435
462
  def base_fee_drops
@@ -441,6 +468,17 @@ module XRPL
441
468
  DEFAULT_FEE_DROPS
442
469
  end
443
470
 
471
+ # The owner reserve (reserve_inc), which AccountDelete, AMMCreate and
472
+ # VaultCreate cost instead of a fee. There is no sensible default: a
473
+ # guess would be rejected or overpay by orders of magnitude.
474
+ def owner_reserve_drops
475
+ response = request_with_retry('server_state')
476
+ reserve = response.dig('result', 'state', 'validated_ledger', 'reserve_inc')
477
+ raise TransactionError, 'Could not determine the owner reserve from server_state' unless reserve
478
+
479
+ Integer(reserve)
480
+ end
481
+
444
482
  def current_ledger_index
445
483
  response = request_with_retry('ledger_current')
446
484
  index = response.dig('result', 'ledger_current_index')
data/lib/xrpl/fee.rb ADDED
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module XRPL
4
+ # The fee a transaction needs, in drops, by transaction type.
5
+ #
6
+ # rippled charges most transactions the network's base fee, but not all of
7
+ # them: an EscrowFinish pays for the size of its Fulfillment, AccountDelete,
8
+ # AMMCreate and VaultCreate cost an owner reserve rather than a fee, a Batch
9
+ # pays for its inner transactions, and the confidential MPT transactions
10
+ # cost ten base fees. Multisigning adds one base fee per signature. The
11
+ # rules are the ones xrpl.js applies in its autofill.
12
+ #
13
+ # This module is pure: it takes the base fee and, when a type needs it, a
14
+ # way to get the owner reserve. XRPL::Client wires it to the ledger.
15
+ module Fee
16
+ # Where an ordinary fee is capped, so that a spike in the open ledger fee
17
+ # cannot burn an account. The reserve-priced types are not capped: their
18
+ # cost is what it is.
19
+ MAX_FEE_DROPS = 2_000_000
20
+
21
+ # Types whose cost is the owner reserve (reserve_inc), not a fee.
22
+ RESERVE_PRICED = %w[AccountDelete AMMCreate VaultCreate].freeze
23
+
24
+ # rippled's kCONFIDENTIAL_FEE_MULTIPLIER: extra base fees on top of the
25
+ # standard one, so ten in total.
26
+ CONFIDENTIAL_MPT_MULTIPLIER = 9
27
+
28
+ CONFIDENTIAL_MPT_TYPES = %w[
29
+ ConfidentialMPTConvert ConfidentialMPTConvertBack ConfidentialMPTSend
30
+ ConfidentialMPTClawback ConfidentialMPTMergeInbox
31
+ ].freeze
32
+
33
+ module_function
34
+
35
+ # @param transaction [Hash, XRPL::Transaction] the transaction, with its
36
+ # TransactionType
37
+ # @param base_fee [Integer] the network's base fee in drops
38
+ # @param signers_count [Integer] number of signatures for multisign scaling
39
+ # @param max_fee [Integer] cap for ordinary fees
40
+ # @param owner_reserve [Integer, #call, nil] the owner reserve in drops,
41
+ # or something that fetches it; only consulted for the reserve-priced
42
+ # types
43
+ # @return [Integer] the fee in drops, rounded up
44
+ def calculate(transaction, base_fee:, signers_count: 0, max_fee: MAX_FEE_DROPS, owner_reserve: nil)
45
+ tx = transaction.respond_to?(:to_h) ? transaction.to_h : transaction
46
+ type = tx['TransactionType'] || tx[:TransactionType]
47
+ base_fee = Integer(base_fee)
48
+
49
+ fee = Rational(base_fee)
50
+ reserve_priced = RESERVE_PRICED.include?(type)
51
+
52
+ if type == 'EscrowFinish' && tx['Fulfillment']
53
+ # Base fee × (33 + Fulfillment size in bytes / 16)
54
+ bytes = (tx['Fulfillment'].to_s.length + 1) / 2
55
+ fee = base_fee * (33 + Rational(bytes, 16))
56
+ elsif reserve_priced
57
+ fee = Rational(resolve_owner_reserve(owner_reserve, type))
58
+ elsif type == 'Batch'
59
+ inner = Array(tx['RawTransactions']).sum(0r) do |raw|
60
+ calculate(raw['RawTransaction'] || raw, base_fee: base_fee, max_fee: max_fee,
61
+ owner_reserve: owner_reserve)
62
+ end
63
+ fee = base_fee * 2 + inner
64
+ elsif CONFIDENTIAL_MPT_TYPES.include?(type)
65
+ fee += base_fee * CONFIDENTIAL_MPT_MULTIPLIER
66
+ end
67
+
68
+ # Multisigned: base fee × (1 + number of signatures)
69
+ fee += base_fee * signers_count if signers_count.to_i.positive?
70
+
71
+ fee = [fee, Rational(max_fee)].min unless reserve_priced
72
+ fee.ceil
73
+ end
74
+
75
+ def resolve_owner_reserve(owner_reserve, type)
76
+ reserve = owner_reserve.respond_to?(:call) ? owner_reserve.call : owner_reserve
77
+ raise ArgumentError, "#{type} costs the owner reserve, which was not given" if reserve.nil?
78
+
79
+ Integer(reserve)
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module XRPL
4
+ # A ledger entry - AccountRoot, RippleState, Offer, Escrow and the other
5
+ # objects that make up the ledger's state - built from LEDGER_ENTRY_FORMATS
6
+ # in definitions.json, the same way XRPL::Transaction is built from
7
+ # TRANSACTION_FORMATS.
8
+ #
9
+ # entry = XRPL::LedgerEntry.from(response.dig('result', 'node'))
10
+ # entry.class # => XRPL::LedgerEntry::AccountRoot
11
+ # entry.balance # => "370000000"
12
+ # entry.flag?(:lsf_default_ripple)
13
+ # entry.flag_names # => ["lsfDefaultRipple"]
14
+ #
15
+ # Where rippled hands out the entry's own hash under "index" (ledger_entry,
16
+ # account_objects, ledger_data), that value is kept as #index; it is not a
17
+ # field of the object and is left out of #to_h.
18
+ class LedgerEntry < Model
19
+ # Fields every ledger entry carries, regardless of type.
20
+ COMMON_FORMAT = DEFINITIONS['LEDGER_ENTRY_FORMATS'].fetch('common').freeze
21
+
22
+ # LEDGER_ENTRY_FLAGS keys DirectoryNode's flags under rippled's short
23
+ # name for the type.
24
+ FLAG_ALIASES = { 'DirectoryNode' => 'DirNode' }.freeze
25
+
26
+ # The entry's hash, as rippled reports it under "index".
27
+ attr_accessor :index
28
+
29
+ class << self
30
+ # The ledger's name for this entry type, e.g. "AccountRoot".
31
+ alias ledger_entry_type type_name
32
+
33
+ def type_field
34
+ 'LedgerEntryType'
35
+ end
36
+
37
+ def label
38
+ 'ledger entry'
39
+ end
40
+ end
41
+
42
+ def []=(name, value)
43
+ if name.to_s == 'index'
44
+ @index = value
45
+ return
46
+ end
47
+
48
+ super
49
+ end
50
+
51
+ define_types!(DEFINITIONS['LEDGER_ENTRY_FORMATS'], DEFINITIONS['LEDGER_ENTRY_FLAGS'], FLAG_ALIASES)
52
+ end
53
+ end