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.
@@ -8,6 +8,26 @@ module BinaryCodec
8
8
 
9
9
  @@instance = nil
10
10
 
11
+ # The permissions a DelegateSet can grant that are narrower than a whole
12
+ # transaction type. rippled numbers them from 65537 upwards, above the
13
+ # range the transaction types occupy. The table is not part of
14
+ # definitions.json - xrpl.js carries it in code as well
15
+ # (XrplDefinitionsBase#granularPermissions) - so it lives here.
16
+ GRANULAR_PERMISSIONS = {
17
+ 'TrustlineAuthorize' => 65_537,
18
+ 'TrustlineFreeze' => 65_538,
19
+ 'TrustlineUnfreeze' => 65_539,
20
+ 'AccountDomainSet' => 65_540,
21
+ 'AccountEmailHashSet' => 65_541,
22
+ 'AccountMessageKeySet' => 65_542,
23
+ 'AccountTransferRateSet' => 65_543,
24
+ 'AccountTickSizeSet' => 65_544,
25
+ 'PaymentMint' => 65_545,
26
+ 'PaymentBurn' => 65_546,
27
+ 'MPTokenIssuanceLock' => 65_547,
28
+ 'MPTokenIssuanceUnlock' => 65_548
29
+ }.freeze
30
+
11
31
  def initialize
12
32
  file_path = File.join(__dir__, 'definitions.json') #
13
33
  contents = File.read(file_path)
@@ -18,6 +38,15 @@ module BinaryCodec
18
38
  @transaction_results = @definitions['TRANSACTION_RESULTS']
19
39
  @transaction_types = @definitions['TRANSACTION_TYPES']
20
40
 
41
+ # PermissionValue names a delegatable permission. A whole transaction
42
+ # type is its type code plus one, so that Payment (type 0) is not
43
+ # confused with "no permission"; the granular permissions follow above.
44
+ @delegatable_permissions = @transaction_types
45
+ .reject { |_, code| code.negative? }
46
+ .transform_values { |code| code + 1 }
47
+ .merge(GRANULAR_PERMISSIONS)
48
+ .freeze
49
+
21
50
  @field_info_map = {}
22
51
  @field_id_name_map = {}
23
52
  @field_header_map = {}
@@ -47,6 +76,19 @@ module BinaryCodec
47
76
 
48
77
  end
49
78
 
79
+ # The parsed definitions.json, for the sections this class does not index
80
+ # itself - TRANSACTION_FORMATS and TRANSACTION_FLAGS, which the transaction
81
+ # models are built from.
82
+ #
83
+ # @return [Hash] the raw document
84
+ attr_reader :definitions
85
+ alias raw definitions
86
+
87
+ # Name -> code tables, for the types that render as names in JSON.
88
+ # @return [Hash{String => Integer}]
89
+ attr_reader :transaction_types, :ledger_entry_types, :transaction_results,
90
+ :delegatable_permissions
91
+
50
92
  # Returns the singleton instance of the Definitions class.
51
93
  # @return [Definitions] The singleton instance.
52
94
  def self.instance
@@ -69,9 +111,12 @@ module BinaryCodec
69
111
 
70
112
  # Returns a FieldInstance for a given field name.
71
113
  # @param field_name [String] The name of the field.
72
- # @return [FieldInstance] The field instance.
114
+ # @return [FieldInstance, nil] The field instance, nil for a name the
115
+ # definitions do not know
73
116
  def get_field_instance(field_name)
74
117
  field_info = @field_info_map[field_name]
118
+ return nil if field_info.nil?
119
+
75
120
  field_header = get_field_header_from_name(field_name)
76
121
 
77
122
  FieldInstance.new(
@@ -166,8 +166,11 @@ module BinaryCodec
166
166
 
167
167
  # value = mantissa * 10^exponent
168
168
  value = BigDecimal(mantissa_int) * (BigDecimal(10)**exponent)
169
- value = -value unless is_positive
170
-
169
+
170
+ # A zero IOU has its own encoding in which the sign bit is not set, so
171
+ # negating it would produce "-0" where rippled writes "0".
172
+ value = -value unless is_positive || mantissa_int.zero?
173
+
171
174
  # Format the value string to match xrpl.js (stripping trailing .0)
172
175
  formatted_value = value.to_s('F').sub(/\.0$/, '')
173
176
 
@@ -51,8 +51,15 @@ module BinaryCodec
51
51
  end
52
52
 
53
53
  # Returns the JSON representation of the currency.
54
+ #
55
+ # The two ignored parameters are the SerializedType contract. Every type
56
+ # is called as to_json(definitions, field_name) from STObject, so a
57
+ # zero-arity to_json raises ArgumentError for every Currency field nested
58
+ # in an object - which is what silently dropped BaseAsset and QuoteAsset
59
+ # from PriceDataSeries.
60
+ #
54
61
  # @return [String] The ISO code or hex string.
55
- def to_json
62
+ def to_json(_definitions = nil, _field_name = nil)
56
63
  iso = self.iso
57
64
  return iso unless iso.nil?
58
65
 
@@ -1,47 +1,92 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BinaryCodec
4
+ # An asset without an amount: XRP, an issued token, or an MPT.
5
+ #
6
+ # The width is not fixed, and it cannot be supplied from outside. XRP is the
7
+ # 20 byte currency code on its own, an issued token adds the 20 byte issuer,
8
+ # and an MPT carries the issuer, a reserved placeholder and the four byte
9
+ # issuance sequence. Only the currency code says which of the three it is, so
10
+ # the parser has to read that before it knows how much to consume.
11
+ #
12
+ # This class used to take a size hint and read a flat 40 bytes, which for an
13
+ # XRP issue swallowed the field that follows - that is why every AMM and
14
+ # XChain fixture decoded with a nil Asset or the wrong door account.
4
15
  class Issue < SerializedType
16
+ CURRENCY_LENGTH = 20
17
+ ISSUER_LENGTH = 20
18
+ SEQUENCE_LENGTH = 4
19
+ MPT_LENGTH = CURRENCY_LENGTH + ISSUER_LENGTH + SEQUENCE_LENGTH
20
+
21
+ # The reserved "no account" value. Sitting in the issuer slot, it marks the
22
+ # issue as an MPT rather than as a token issued by that account.
23
+ NO_ACCOUNT = '0000000000000000000000000000000000000001'
24
+
5
25
  def initialize(bytes = nil)
6
26
  super(bytes || [])
7
27
  end
8
28
 
9
29
  def self.from(value)
10
30
  return value if value.is_a?(Issue)
31
+ return Issue.new(hex_to_bytes(value)) if value.is_a?(String)
11
32
 
12
- if value.is_a?(String)
13
- return Issue.new(hex_to_bytes(value))
33
+ unless value.is_a?(::Hash)
34
+ raise StandardError, "Cannot construct Issue from #{value.class}"
14
35
  end
15
36
 
16
- if value.is_a?(Hash) || value.is_a?(::Hash)
17
- bytes = []
18
- bytes.concat(Currency.from(value['currency']).to_bytes)
19
- bytes.concat(AccountId.from(value['issuer']).to_bytes) if value['issuer']
20
- return Issue.new(bytes)
21
- end
37
+ return Issue.new(mpt_bytes(value['mpt_issuance_id'])) if value['mpt_issuance_id']
22
38
 
23
- raise StandardError, "Cannot construct Issue from #{value.class}"
39
+ bytes = Currency.from(value['currency']).to_bytes
40
+ bytes += AccountId.from(value['issuer']).to_bytes if value['issuer']
41
+ Issue.new(bytes)
24
42
  end
25
43
 
26
- def self.from_parser(parser, size_hint = nil)
27
- bytes = []
28
- return Issue.new(bytes) if parser.end?
29
- bytes.concat(parser.read(20)) # Currency
30
- unless parser.end? || (size_hint && size_hint <= 20)
31
- bytes.concat(parser.read(20))
32
- end
44
+ # An MPT issuance id is the issuance sequence followed by the issuer. The
45
+ # sequence is big endian there but little endian on the wire.
46
+ def self.mpt_bytes(issuance_id)
47
+ id = hex_to_bytes(issuance_id)
48
+ sequence = id[0, SEQUENCE_LENGTH]
49
+ issuer = id[SEQUENCE_LENGTH..]
50
+
51
+ issuer + hex_to_bytes(NO_ACCOUNT) + sequence.reverse
52
+ end
53
+ private_class_method :mpt_bytes
54
+
55
+ # The size hint is accepted and ignored: callers cannot know the width, and
56
+ # passing one in is how the fixed 40 byte read came about.
57
+ def self.from_parser(parser, _size_hint = nil)
58
+ return Issue.new([]) if parser.end?
59
+
60
+ currency = parser.read(CURRENCY_LENGTH)
61
+ return Issue.new(currency) if Currency.new(currency).to_json == 'XRP'
62
+
63
+ issuer = parser.read(ISSUER_LENGTH)
64
+ bytes = currency + issuer
65
+ bytes += parser.read(SEQUENCE_LENGTH) if bytes_to_hex(issuer).upcase == NO_ACCOUNT
66
+
33
67
  Issue.new(bytes)
34
68
  end
35
69
 
36
70
  def to_json(_definitions = nil, _field_name = nil)
71
+ return { 'mpt_issuance_id' => mpt_issuance_id } if to_bytes.length == MPT_LENGTH
72
+
37
73
  parser = BinaryParser.new(to_hex)
38
- result = {}
39
- result['currency'] = Currency.from_parser(parser).to_json
40
- result['issuer'] = AccountId.from_parser(parser).to_json unless parser.end?
41
- result
42
- rescue
43
- # Fallback for partial/invalid binary
44
- { 'currency' => Currency.new(to_bytes[0, 20]).to_json }
74
+ currency = Currency.from_parser(parser).to_json
75
+
76
+ # An XRP issue has no issuer. Reporting one means 20 bytes of the next
77
+ # field were read as an account.
78
+ return { 'currency' => currency } if currency == 'XRP'
79
+
80
+ { 'currency' => currency, 'issuer' => AccountId.from_parser(parser).to_json }
81
+ end
82
+
83
+ private
84
+
85
+ def mpt_issuance_id
86
+ issuer = to_bytes[0, ISSUER_LENGTH]
87
+ sequence = to_bytes[CURRENCY_LENGTH + ISSUER_LENGTH, SEQUENCE_LENGTH].reverse
88
+
89
+ bytes_to_hex(sequence + issuer).upcase
45
90
  end
46
91
  end
47
92
  end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BinaryCodec
4
+ # STNumber, the XRPL "Number" type.
5
+ #
6
+ # Always 12 bytes: a signed 64 bit mantissa followed by a signed 32 bit
7
+ # exponent, both big endian. Used by the Vault and Lending Protocol fields
8
+ # (AssetsAvailable, DebtTotal, PeriodicPayment and 14 others), which could
9
+ # not be serialised at all before this class existed.
10
+ #
11
+ # Reference: xrpl.js packages/ripple-binary-codec/src/types/st-number.ts
12
+ class Number < SerializedType
13
+ BYTE_LENGTH = 12
14
+
15
+ MIN_MANTISSA = 10**18
16
+ MAX_MANTISSA = 10**19 - 1
17
+ MAX_INT64 = 2**63 - 1
18
+
19
+ MIN_EXPONENT = -32_768
20
+ MAX_EXPONENT = 32_768
21
+
22
+ # The exponent rippled uses to encode a canonical zero.
23
+ DEFAULT_VALUE_EXPONENT = -2_147_483_648
24
+
25
+ # Significant decimal digits rippled renders with.
26
+ RANGE_LOG = 18
27
+
28
+ NUMBER_PATTERN = /\A([-+]?)([0-9]+)(?:\.([0-9]+))?(?:[eE]([+-]?[0-9]+))?\z/
29
+
30
+ def initialize(bytes = nil)
31
+ bytes ||= Array.new(BYTE_LENGTH, 0)
32
+
33
+ unless bytes.length == BYTE_LENGTH
34
+ raise StandardError, "Invalid Number length #{bytes.length}"
35
+ end
36
+
37
+ super(bytes)
38
+ end
39
+
40
+ def self.from(value)
41
+ return value if value.is_a?(Number)
42
+ mantissa, exponent = extract_parts(value.to_s)
43
+ mantissa, exponent = normalize(mantissa, exponent)
44
+
45
+ Number.new(pack_signed(mantissa, 8) + pack_signed(exponent, 4))
46
+ end
47
+
48
+ def self.from_parser(parser, _size_hint = nil)
49
+ Number.new(parser.read(BYTE_LENGTH))
50
+ end
51
+
52
+ def to_json(_definitions = nil, _field_name = nil)
53
+ mantissa = self.class.unpack_signed(to_bytes[0, 8])
54
+ exponent = self.class.unpack_signed(to_bytes[8, 4])
55
+
56
+ return '0' if mantissa.zero? && exponent == DEFAULT_VALUE_EXPONENT
57
+
58
+ negative = mantissa.negative?
59
+ mantissa = mantissa.abs
60
+
61
+ # A mantissa above 2^63-1 is shrunk by one digit before serialisation.
62
+ # Restore it so the rendering matches rippled's internal value.
63
+ if !mantissa.zero? && mantissa < MIN_MANTISSA
64
+ mantissa *= 10
65
+ exponent -= 1
66
+ end
67
+
68
+ sign = negative ? '-' : ''
69
+
70
+ if exponent != 0 && (exponent < -(RANGE_LOG + 10) || exponent > -(RANGE_LOG - 10))
71
+ return sign + scientific(mantissa, exponent)
72
+ end
73
+
74
+ sign + positional(mantissa, exponent)
75
+ end
76
+
77
+ # Scientific notation, with trailing zeros moved into the exponent.
78
+ def self.scientific(mantissa, exponent)
79
+ while !mantissa.zero? && (mantissa % 10).zero? && exponent < MAX_EXPONENT
80
+ mantissa /= 10
81
+ exponent += 1
82
+ end
83
+
84
+ "#{mantissa}e#{exponent}"
85
+ end
86
+
87
+ def scientific(mantissa, exponent)
88
+ self.class.scientific(mantissa, exponent)
89
+ end
90
+
91
+ # Plain decimal notation, built by padding the digits out far enough that
92
+ # the decimal point always lands inside the string.
93
+ def positional(mantissa, exponent)
94
+ pad_prefix = RANGE_LOG + 12
95
+ pad_suffix = RANGE_LOG + 8
96
+
97
+ raw = ('0' * pad_prefix) + mantissa.to_s + ('0' * pad_suffix)
98
+ offset = exponent + pad_prefix + RANGE_LOG + 1
99
+
100
+ integer = raw[0, offset].sub(/\A0+/, '')
101
+ integer = '0' if integer.empty?
102
+ fraction = raw[offset..].sub(/0+\z/, '')
103
+
104
+ fraction.empty? ? integer : "#{integer}.#{fraction}"
105
+ end
106
+
107
+ # Split a number string into an unnormalised mantissa and exponent.
108
+ def self.extract_parts(value)
109
+ match = NUMBER_PATTERN.match(value)
110
+ raise StandardError, "Unable to parse number from string: #{value}" unless match
111
+
112
+ sign, int_part, frac_part, exp_part = match.captures
113
+
114
+ digits = int_part.sub(/\A0+(?=.)/, '')
115
+ exponent = 0
116
+
117
+ unless frac_part.nil? || frac_part.empty?
118
+ digits += frac_part
119
+ exponent -= frac_part.length
120
+ end
121
+ exponent += exp_part.to_i unless exp_part.nil? || exp_part.empty?
122
+
123
+ while digits.length > 1 && digits.end_with?('0')
124
+ digits = digits[0..-2]
125
+ exponent += 1
126
+ end
127
+
128
+ mantissa = digits.to_i
129
+ mantissa = -mantissa if sign == '-'
130
+
131
+ [mantissa, exponent]
132
+ end
133
+
134
+ # Bring mantissa and exponent into the range rippled expects.
135
+ def self.normalize(mantissa, exponent)
136
+ return [0, DEFAULT_VALUE_EXPONENT] if mantissa.zero?
137
+
138
+ negative = mantissa.negative?
139
+ m = mantissa.abs
140
+
141
+ while m < MIN_MANTISSA && exponent > MIN_EXPONENT
142
+ exponent -= 1
143
+ m *= 10
144
+ end
145
+
146
+ last_digit = nil
147
+ while m > MAX_MANTISSA
148
+ raise StandardError, 'Mantissa and exponent are too large' if exponent >= MAX_EXPONENT
149
+
150
+ exponent += 1
151
+ last_digit = m % 10
152
+ m /= 10
153
+ end
154
+
155
+ raise StandardError, 'Underflow: value too small to represent' if exponent < MIN_EXPONENT || m < MIN_MANTISSA
156
+ raise StandardError, 'Exponent overflow: value too large to represent' if exponent > MAX_EXPONENT
157
+
158
+ if m > MAX_INT64
159
+ raise StandardError, 'Exponent overflow: value too large to represent' if exponent >= MAX_EXPONENT
160
+
161
+ exponent += 1
162
+ last_digit = m % 10
163
+ m /= 10
164
+ end
165
+
166
+ if last_digit && last_digit >= 5
167
+ m += 1
168
+
169
+ if m > MAX_INT64
170
+ raise StandardError, 'Exponent overflow: value too large to represent' if exponent >= MAX_EXPONENT
171
+
172
+ last_digit = m % 10
173
+ exponent += 1
174
+ m /= 10
175
+ m += 1 if last_digit >= 5
176
+ end
177
+ end
178
+
179
+ [negative ? -m : m, exponent]
180
+ end
181
+
182
+ # Big endian two's complement.
183
+ def self.pack_signed(value, byte_length)
184
+ value += 2**(byte_length * 8) if value.negative?
185
+
186
+ Array.new(byte_length) { |i| (value >> (8 * (byte_length - 1 - i))) & 0xFF }
187
+ end
188
+
189
+ def self.unpack_signed(bytes)
190
+ value = bytes.reduce(0) { |acc, b| (acc << 8) + b }
191
+ boundary = 2**(bytes.length * 8 - 1)
192
+
193
+ value >= boundary ? value - 2**(bytes.length * 8) : value
194
+ end
195
+ end
196
+ end
@@ -99,8 +99,11 @@ module BinaryCodec
99
99
  when "UInt160" then Uint160
100
100
  when "UInt192" then Uint192
101
101
  when "UInt256" then Uint256
102
- when "UInt384" then Uint384
103
- when "UInt512" then Uint512
102
+ # The reference definitions call these Hash384/Hash512. The old UInt
103
+ # names are kept so a caller passing them still resolves.
104
+ when "Hash384", "UInt384" then Uint384
105
+ when "Hash512", "UInt512" then Uint512
106
+ when "Number" then Number
104
107
  when "Int32" then Int32
105
108
  when "Int64" then Int64
106
109
  when "PathSet" then PathSet
@@ -2,6 +2,8 @@
2
2
 
3
3
  module BinaryCodec
4
4
  class STArray < SerializedType
5
+ ARRAY_END_MARKER = 0xF1
6
+
5
7
  def initialize(byte_buf = nil)
6
8
  super(byte_buf || [])
7
9
  end
@@ -38,7 +40,7 @@ module BinaryCodec
38
40
  raise StandardError, "STArray item must be a Hash, got #{item.class}"
39
41
  end
40
42
  end
41
- bytes.concat([0xF1]) # ArrayEndMarker
43
+ bytes.concat([ARRAY_END_MARKER])
42
44
  return STArray.new(bytes)
43
45
  end
44
46
 
@@ -53,7 +55,7 @@ module BinaryCodec
53
55
  bytes = []
54
56
  until parser.end?
55
57
  # Check if we reached the ArrayEndMarker (0xF1)
56
- if parser.peek == 0xF1
58
+ if parser.peek == ARRAY_END_MARKER
57
59
  parser.read(1) # Consume 0xF1
58
60
  break
59
61
  end
@@ -70,6 +72,11 @@ module BinaryCodec
70
72
  bytes.concat(obj.to_bytes)
71
73
  bytes.concat([0xE1]) unless bytes.last == 0xE1
72
74
  end
75
+
76
+ # The ArrayEndMarker has to go back in. Without it the reconstructed
77
+ # bytes have no terminator, so re-parsing them - which is what to_json
78
+ # does - runs the array on into whatever field follows it.
79
+ bytes.concat([ARRAY_END_MARKER])
73
80
  STArray.new(bytes)
74
81
  end
75
82
 
@@ -82,23 +89,14 @@ module BinaryCodec
82
89
  parser = BinaryParser.new(to_hex)
83
90
  result = []
84
91
  until parser.end?
85
- begin
86
- # Check if we reached the ArrayEndMarker (0xF1) or if peek fails
87
- break if parser.peek == 0xF1
88
-
89
- # Read field header of the array item (e.g., "Signer")
90
- field_header = parser.read_field_header
91
- field_name = definitions.get_field_name_from_header(field_header)
92
-
93
- # Read the STObject item
94
- obj = STObject.from_parser(parser)
95
-
96
- # Array item in JSON is { "FieldName": { ... } }
97
- item_json = obj.to_json(definitions)
98
- result << { field_name => item_json.is_a?(String) ? JSON.parse(item_json) : item_json }
99
- rescue => e
100
- break
101
- end
92
+ break if parser.peek == ARRAY_END_MARKER
93
+
94
+ # Each item is an STObject behind its own field header, e.g. "Signer".
95
+ field_header = parser.read_field_header
96
+ field_name = definitions.get_field_name_from_header(field_header)
97
+ obj = STObject.from_parser(parser)
98
+
99
+ result << { field_name => obj.to_json(definitions) }
102
100
  end
103
101
  result
104
102
  end
@@ -119,9 +119,14 @@ module BinaryCodec
119
119
  STObject.new(list.to_bytes)
120
120
  end
121
121
 
122
- # Method to get the JSON interpretation of self.bytes
122
+ # The JSON interpretation of self.bytes.
123
123
  #
124
- # @return [String] A stringified JSON object
124
+ # Returns a Hash, not a JSON string. It used to return a string, which
125
+ # forced every nested value to be serialised and parsed again on the way
126
+ # out - and that round trip is what turned native XRP amounts into
127
+ # integers, because JSON.parse("370000000") is a number.
128
+ #
129
+ # @return [Hash] The decoded object
125
130
  def to_json(_definitions = nil, _field_name = nil)
126
131
  definitions = _definitions || Definitions.instance
127
132
  parser = BinaryParser.new(to_hex)
@@ -129,7 +134,7 @@ module BinaryCodec
129
134
 
130
135
  until parser.end?
131
136
  begin
132
- # Check if we are at the end marker (0xE1) or if peek fails
137
+ # Check if we are at the end marker (0xE1)
133
138
  break if parser.peek == 0xE1
134
139
 
135
140
  field = parser.read_field
@@ -142,21 +147,18 @@ module BinaryCodec
142
147
  else
143
148
  value_obj = parser.read_field_value(field)
144
149
  value = value_obj.to_json(definitions, field.name)
145
-
146
- # Re-parse if it's a nested structure to keep it as a Hash/Array in the accumulator
147
- if field.type == 'STObject' || field.type == 'Amount' || field.type == 'STArray'
148
- value = JSON.parse(value) if value.is_a?(String)
149
- end
150
+
151
+ # Some types still hand back a serialised structure. Parse only
152
+ # what actually is one: an Amount for XRP is the string
153
+ # "370000000", and parsing that yields a number where rippled has
154
+ # a string.
155
+ value = JSON.parse(value) if value.is_a?(String) && value.start_with?('{', '[')
150
156
  end
151
157
  accumulator[field.name] = value
152
- rescue => e
153
- break
154
158
  end
155
159
  end
156
160
 
157
- # Existing tests expect a JSON string for STObject#to_json
158
- # To satisfy spec/binary-codec/types/st_object_spec.rb:10
159
- JSON.generate(accumulator)
161
+ accumulator
160
162
  end
161
163
 
162
164
  private