xrpl-ruby 0.6.0 → 0.7.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.
@@ -47,6 +47,14 @@ module BinaryCodec
47
47
 
48
48
  end
49
49
 
50
+ # The parsed definitions.json, for the sections this class does not index
51
+ # itself - TRANSACTION_FORMATS and TRANSACTION_FLAGS, which the transaction
52
+ # models are built from.
53
+ #
54
+ # @return [Hash] the raw document
55
+ attr_reader :definitions
56
+ alias raw definitions
57
+
50
58
  # Returns the singleton instance of the Definitions class.
51
59
  # @return [Definitions] The singleton instance.
52
60
  def self.instance
@@ -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
@@ -3,6 +3,15 @@
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
+ ].freeze
14
+
6
15
  # Returns the width of the Uint type in bytes.
7
16
  # @return [Integer] The width.
8
17
  def self.width
@@ -93,11 +102,20 @@ module BinaryCodec
93
102
  end
94
103
  end
95
104
 
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')
105
+ # rippled renders the narrow unsigned integers as JSON numbers and UInt64
106
+ # as a 16 digit hex string, because a UInt64 does not survive a round trip
107
+ # through a JSON number. The MPToken amount fields are the exception to
108
+ # that exception: they are UInt64 but carry a base 10 string.
109
+ #
110
+ # Everything wider than 8 bytes (Uint96 and up) is hash-like and stays
111
+ # hex. Do not widen the numeric branch to cover it.
112
+ val = value_of
113
+ return val if self.class.width < 8
114
+ return val.to_s if self.class.width == 8 && BASE10_UINT64_FIELDS.include?(_field_name)
115
+
116
+ # Hex is unsigned, so a negative signed value has to wrap first.
117
+ val += (1 << (self.class.width * 8)) if val < 0
118
+ val.to_s(16).upcase.rjust(self.class.width * 2, '0')
101
119
  end
102
120
  # @param other [Uint] The other Uint to compare to.
103
121
  # @return [Integer] Comparison result (-1, 0, or 1).
@@ -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/xrpl/client.rb CHANGED
@@ -35,6 +35,19 @@ 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
@@ -306,11 +319,11 @@ module XRPL
306
319
  # Fills in the fields a transaction needs before signing: +Sequence+, +Fee+
307
320
  # and +LastLedgerSequence+. Existing values are never overwritten.
308
321
  #
309
- # @param transaction [Hash] the (string-keyed) transaction to complete.
322
+ # @param transaction [Hash, XRPL::Transaction] the transaction to complete.
310
323
  # @param signers_count [Integer] number of signatures for multisign fee scaling.
311
324
  # @return [Hash] a copy of the transaction with the missing fields filled in.
312
325
  def autofill(transaction, signers_count: 0)
313
- tx = transaction.dup
326
+ tx = self.class.to_transaction_hash(transaction).dup
314
327
  tx['Sequence'] ||= fetch_sequence(tx.fetch('Account'))
315
328
  tx['Fee'] ||= calculate_fee(signers_count)
316
329
  tx['LastLedgerSequence'] ||= current_ledger_index + LEDGER_OFFSET
@@ -399,7 +412,8 @@ module XRPL
399
412
  def prepare_for_submit(transaction, wallet:, autofill:)
400
413
  raise ArgumentError, 'wallet: is required to sign the transaction' if wallet.nil?
401
414
 
402
- tx = transaction.is_a?(Hash) ? transaction.dup : transaction
415
+ tx = self.class.to_transaction_hash(transaction)
416
+ tx = tx.dup if tx.is_a?(Hash)
403
417
  tx = autofill(tx) if autofill && tx.is_a?(Hash)
404
418
 
405
419
  signed = wallet.sign(tx)