erpc-sdk 0.9.1 → 0.11.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.
@@ -0,0 +1,574 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "json"
5
+
6
+ require_relative "errors"
7
+
8
+ module ERPC
9
+ # The resource and numeric limits are intentionally private to the new
10
+ # Bitcoin decoder. Existing JSON-RPC namespaces continue to use their
11
+ # released parser and limits.
12
+ BitcoinResponseLimits = Struct.new(
13
+ :decoded_entity_bytes,
14
+ :container_depth,
15
+ :json_tokens,
16
+ :numeric_lexeme_bytes,
17
+ :coefficient_digits,
18
+ :explicit_exponent_min,
19
+ :explicit_exponent_max,
20
+ :canonical_exponent_min,
21
+ :canonical_exponent_max,
22
+ keyword_init: true
23
+ ) do
24
+ def initialize(**values)
25
+ super
26
+ each_pair { |key, value| self[key] = Integer(value) }
27
+ freeze
28
+ end
29
+ end
30
+
31
+ BITCOIN_RESPONSE_LIMITS = BitcoinResponseLimits.new(
32
+ decoded_entity_bytes: 67_108_864,
33
+ container_depth: 64,
34
+ json_tokens: 1_000_000,
35
+ numeric_lexeme_bytes: 1_024,
36
+ coefficient_digits: 1_000,
37
+ explicit_exponent_min: -10_000,
38
+ explicit_exponent_max: 10_000,
39
+ canonical_exponent_min: -10_000,
40
+ canonical_exponent_max: 10_000
41
+ )
42
+
43
+ BitcoinNumberTuple = Struct.new(:sign, :coefficient, :exponent10, keyword_init: true) do
44
+ def initialize(sign:, coefficient:, exponent10:)
45
+ super
46
+ freeze
47
+ end
48
+ end
49
+
50
+ # Scanner metadata is deliberately small: the parsed JSON object is still
51
+ # constructed by Ruby's JSON parser, while this metadata lets the typed
52
+ # block-count projection retain the selected original result token.
53
+ BitcoinPreflightResult = Struct.new(
54
+ :token_count,
55
+ :numeric_token_count,
56
+ :max_depth,
57
+ :number_tokens,
58
+ :root_result_token,
59
+ keyword_init: true
60
+ ) do
61
+ def initialize(**values)
62
+ values[:number_tokens] = values.fetch(:number_tokens).freeze
63
+ super
64
+ freeze
65
+ end
66
+ end
67
+
68
+ BitcoinNumberToken = Struct.new(:raw, :integer, :tuple, keyword_init: true) do
69
+ def initialize(raw:, integer:, tuple:)
70
+ super
71
+ freeze
72
+ end
73
+ end
74
+
75
+ module BitcoinCodec
76
+ module_function
77
+
78
+ INTEGER_TOKEN = /\A-?(?:0|[1-9][0-9]*)\z/.freeze
79
+ UNSIGNED_INTEGER_TOKEN = /\A(?:0|[1-9][0-9]*)\z/.freeze
80
+ NUMBER_TOKEN = /\A(-?)(0|[1-9][0-9]*)(?:\.([0-9]+))?(?:[eE]([+-]?[0-9]+))?\z/.freeze
81
+ TOKEN_DELIMITERS = " \t\r\n,:[}]".freeze
82
+
83
+ # Canonicalize one original JSON number lexeme without converting it to a
84
+ # floating point value or expanding an exponent.
85
+ def canonicalize_number(raw, limits: BITCOIN_RESPONSE_LIMITS)
86
+ unless raw.is_a?(String) && raw.ascii_only? && raw.bytesize <= limits.numeric_lexeme_bytes
87
+ invalid_response!
88
+ end
89
+
90
+ match = NUMBER_TOKEN.match(raw)
91
+ invalid_response! unless match
92
+
93
+ sign_token, integer_part, fraction_part, exponent_part = match.captures
94
+ fraction_part ||= ""
95
+ explicit_exponent = checked_exponent(exponent_part || "0", limits: limits)
96
+ raw_coefficient = integer_part + fraction_part
97
+ invalid_response! if raw_coefficient.length > limits.coefficient_digits
98
+
99
+ without_leading_zeroes = raw_coefficient.sub(/\A0+/, "")
100
+ return BitcoinNumberTuple.new(sign: 0, coefficient: "0", exponent10: "0") if without_leading_zeroes.empty?
101
+
102
+ coefficient = without_leading_zeroes.sub(/0+\z/, "")
103
+ trailing_zeroes = without_leading_zeroes.length - coefficient.length
104
+ exponent10 = explicit_exponent - fraction_part.length + trailing_zeroes
105
+ unless exponent10.between?(limits.canonical_exponent_min, limits.canonical_exponent_max)
106
+ invalid_response!
107
+ end
108
+
109
+ BitcoinNumberTuple.new(
110
+ sign: sign_token == "-" ? -1 : 1,
111
+ coefficient: coefficient,
112
+ exponent10: exponent10.to_s
113
+ )
114
+ end
115
+
116
+ alias canonicalize_bitcoin_number canonicalize_number
117
+ module_function :canonicalize_bitcoin_number
118
+
119
+ def preflight(text, limits: BITCOIN_RESPONSE_LIMITS)
120
+ invalid_response! unless text.is_a?(String)
121
+ invalid_response! unless text.encoding == Encoding::UTF_8 && text.valid_encoding?
122
+
123
+ scanner = Scanner.new(text, limits)
124
+ scanner.run
125
+ rescue InvalidResponseError
126
+ raise
127
+ rescue StandardError
128
+ invalid_response!
129
+ end
130
+
131
+ alias preflight_bitcoin_json preflight
132
+ module_function :preflight_bitcoin_json
133
+
134
+ def decode(text, expected_id, redactions: [], projector: nil, limits: BITCOIN_RESPONSE_LIMITS)
135
+ metadata = preflight(text, limits: limits)
136
+ response = parse_document(text, metadata, limits: limits)
137
+ unless response.is_a?(Hash)
138
+ invalid_response!
139
+ end
140
+
141
+ actual_id = response["id"]
142
+ unless valid_response_id?(actual_id) && actual_id == expected_id
143
+ invalid_response!
144
+ end
145
+
146
+ if response.key?("error") && !response["error"].nil?
147
+ raise_rpc_error(response["error"], redactions)
148
+ end
149
+ invalid_response! unless response.key?("result")
150
+
151
+ if projector
152
+ begin
153
+ return projector.call(response["result"], metadata.root_result_token)
154
+ rescue InvalidResponseError
155
+ raise
156
+ rescue StandardError
157
+ invalid_response!
158
+ end
159
+ end
160
+
161
+ response["result"]
162
+ rescue InvalidResponseError
163
+ raise
164
+ rescue JSON::ParserError, JSON::NestingError, ArgumentError, EncodingError, SystemStackError
165
+ invalid_response!
166
+ end
167
+
168
+ alias unwrap_bitcoin_rpc_response decode
169
+ module_function :unwrap_bitcoin_rpc_response
170
+
171
+ def as_string(value, _token = nil)
172
+ invalid_response! unless value.is_a?(String)
173
+
174
+ value
175
+ end
176
+
177
+ alias as_bitcoin_string as_string
178
+ module_function :as_bitcoin_string
179
+
180
+ def as_block_count(value, token = nil)
181
+ raw = token.is_a?(BitcoinNumberToken) ? token.raw : nil
182
+ invalid_response! unless raw&.match?(UNSIGNED_INTEGER_TOKEN)
183
+ invalid_response! unless value.is_a?(Integer) && !value.is_a?(TrueClass) && value >= 0
184
+ invalid_response! if value > 18_446_744_073_709_551_615
185
+ value
186
+ end
187
+
188
+ alias as_bitcoin_block_count as_block_count
189
+ module_function :as_bitcoin_block_count
190
+
191
+ def as_nullable_object(value, _token = nil)
192
+ return nil if value.nil?
193
+
194
+ invalid_response! unless value.is_a?(Hash)
195
+
196
+ value
197
+ end
198
+
199
+ alias as_bitcoin_nullable_object as_nullable_object
200
+ module_function :as_bitcoin_nullable_object
201
+
202
+ def invalid_response!
203
+ raise InvalidResponseError.new("ERPC returned an invalid Bitcoin response"), cause: nil
204
+ end
205
+ private_class_method :invalid_response!
206
+
207
+ def checked_exponent(raw, limits: BITCOIN_RESPONSE_LIMITS)
208
+ sign = raw.start_with?("-") ? -1 : 1
209
+ digits = raw.start_with?("+", "-") ? raw[1..] : raw
210
+ invalid_response! if digits.nil? || digits.empty?
211
+
212
+ normalized = digits.sub(/\A0+/, "")
213
+ normalized = "0" if normalized.empty?
214
+ value = 0
215
+ normalized.each_byte do |byte|
216
+ invalid_response! unless byte.between?(48, 57)
217
+
218
+ value = (value * 10) + byte - 48
219
+ invalid_response! if value > limits.explicit_exponent_max
220
+ end
221
+ result = sign * value
222
+ invalid_response! unless result.between?(limits.explicit_exponent_min, limits.explicit_exponent_max)
223
+
224
+ result
225
+ end
226
+ private_class_method :checked_exponent
227
+
228
+ def parse_document(text, metadata, limits: BITCOIN_RESPONSE_LIMITS)
229
+ decimal_tokens = metadata.number_tokens.reject(&:integer)
230
+ decimal_index = 0
231
+ adapter = Class.new do
232
+ define_singleton_method(:new) do |raw|
233
+ descriptor = decimal_tokens.fetch(decimal_index) do
234
+ BitcoinCodec.send(:invalid_response!)
235
+ end
236
+ decimal_index += 1
237
+ BitcoinCodec.send(:invalid_response!) unless raw == descriptor.raw
238
+
239
+ value = BigDecimal(raw)
240
+ tuple = BitcoinCodec.send(:big_decimal_tuple, value, limits: limits)
241
+ if !value.finite? || (descriptor.tuple.sign != 0 && value.zero?) || tuple != descriptor.tuple
242
+ BitcoinCodec.send(:invalid_response!)
243
+ end
244
+ value
245
+ rescue InvalidResponseError
246
+ raise
247
+ rescue StandardError
248
+ BitcoinCodec.send(:invalid_response!)
249
+ end
250
+ end
251
+
252
+ JSON.parse(
253
+ text,
254
+ create_additions: false,
255
+ allow_nan: false,
256
+ max_nesting: limits.container_depth,
257
+ decimal_class: adapter
258
+ )
259
+ rescue InvalidResponseError
260
+ raise
261
+ rescue JSON::ParserError, JSON::NestingError, ArgumentError, TypeError, SystemStackError
262
+ invalid_response!
263
+ end
264
+ private_class_method :parse_document
265
+
266
+ def big_decimal_tuple(value, limits: BITCOIN_RESPONSE_LIMITS)
267
+ invalid_response! unless value.is_a?(BigDecimal) && value.finite?
268
+ return BitcoinNumberTuple.new(sign: 0, coefficient: "0", exponent10: "0") if value.zero?
269
+
270
+ sign, digits, _base, exponent = value.split
271
+ coefficient = digits.sub(/\A0+/, "")
272
+ invalid_response! if coefficient.empty?
273
+ coefficient = coefficient.sub(/0+\z/, "")
274
+ trailing_zeroes = digits.length - coefficient.length
275
+ exponent10 = exponent - digits.length + trailing_zeroes
276
+ invalid_response! unless exponent10.between?(limits.canonical_exponent_min, limits.canonical_exponent_max)
277
+
278
+ BitcoinNumberTuple.new(
279
+ sign: sign.negative? ? -1 : 1,
280
+ coefficient: coefficient,
281
+ exponent10: exponent10.to_s
282
+ )
283
+ end
284
+ private_class_method :big_decimal_tuple
285
+
286
+ def valid_response_id?(value)
287
+ (value.is_a?(Integer) && !value.is_a?(TrueClass) && !value.is_a?(FalseClass)) || value.is_a?(String)
288
+ end
289
+ private_class_method :valid_response_id?
290
+
291
+ def raise_rpc_error(error, redactions)
292
+ unless error.is_a?(Hash) && error["code"].is_a?(Integer) &&
293
+ !error["code"].is_a?(TrueClass) && !error["code"].is_a?(FalseClass) &&
294
+ error["message"].is_a?(String)
295
+ invalid_response!
296
+ end
297
+
298
+ credentials = redactions.is_a?(Array) ? redactions : [redactions]
299
+ data = error.key?("data") ? Redaction.value(error["data"], credentials) : nil
300
+ message = Redaction.text(error["message"], credentials)
301
+ raise JsonRpcError.new(error["code"], message, data), cause: nil
302
+ end
303
+ private_class_method :raise_rpc_error
304
+
305
+ class Scanner
306
+ def initialize(text, limits)
307
+ @text = text
308
+ @limits = limits
309
+ @index = 0
310
+ @token_count = 0
311
+ @numeric_token_count = 0
312
+ @max_depth = 0
313
+ @number_tokens = []
314
+ @stack = []
315
+ @root_result_token = nil
316
+ @root_result_seen = false
317
+ end
318
+
319
+ def run
320
+ skip_whitespace
321
+ parse_value(root: true)
322
+ skip_whitespace
323
+ invalid! unless @index == @text.length
324
+ invalid! unless @stack.empty?
325
+
326
+ BitcoinPreflightResult.new(
327
+ token_count: @token_count,
328
+ numeric_token_count: @numeric_token_count,
329
+ max_depth: @max_depth,
330
+ number_tokens: @number_tokens,
331
+ root_result_token: @root_result_seen ? @root_result_token : nil
332
+ )
333
+ rescue InvalidResponseError
334
+ raise
335
+ rescue StandardError
336
+ invalid!
337
+ end
338
+
339
+ private
340
+
341
+ def parse_value(root: false, object_key: nil)
342
+ skip_whitespace
343
+ invalid! if @index >= @text.length
344
+ value_start = @index
345
+ character = @text[@index]
346
+
347
+ case character
348
+ when '"'
349
+ add_token
350
+ scan_string
351
+ mark_root_result(nil, object_key) if root || object_key == "result"
352
+ when "{"
353
+ add_token
354
+ parse_object
355
+ mark_root_result(nil, object_key) if root || object_key == "result"
356
+ when "["
357
+ add_token
358
+ parse_array
359
+ mark_root_result(nil, object_key) if root || object_key == "result"
360
+ when "-", /[0-9]/
361
+ raw = scan_number
362
+ tuple = BitcoinCodec.canonicalize_number(raw, limits: @limits)
363
+ token = BitcoinNumberToken.new(
364
+ raw: raw,
365
+ integer: !raw.include?(".") && !raw.match?(/[eE]/),
366
+ tuple: tuple
367
+ )
368
+ @number_tokens << token
369
+ @numeric_token_count += 1
370
+ add_token
371
+ mark_root_result(token, object_key) if root || object_key == "result"
372
+ when "t"
373
+ scan_literal("true")
374
+ add_token
375
+ mark_root_result(nil, object_key) if root || object_key == "result"
376
+ when "f"
377
+ scan_literal("false")
378
+ add_token
379
+ mark_root_result(nil, object_key) if root || object_key == "result"
380
+ when "n"
381
+ scan_literal("null")
382
+ add_token
383
+ mark_root_result(nil, object_key) if root || object_key == "result"
384
+ else
385
+ invalid!
386
+ end
387
+ value_start
388
+ end
389
+
390
+ def parse_object
391
+ push_container("{")
392
+ @index += 1
393
+ skip_whitespace
394
+ if current_character == "}"
395
+ @index += 1
396
+ pop_container("{")
397
+ return
398
+ end
399
+
400
+ loop do
401
+ invalid! unless current_character == '"'
402
+ add_token
403
+ key_start = @index
404
+ scan_string
405
+ key = decode_key(@text[key_start...@index])
406
+ skip_whitespace
407
+ invalid! unless current_character == ":"
408
+ @index += 1
409
+ parse_value(object_key: key)
410
+ skip_whitespace
411
+ case current_character
412
+ when ","
413
+ @index += 1
414
+ skip_whitespace
415
+ when "}"
416
+ @index += 1
417
+ pop_container("{")
418
+ break
419
+ else
420
+ invalid!
421
+ end
422
+ end
423
+ end
424
+
425
+ def parse_array
426
+ push_container("[")
427
+ @index += 1
428
+ skip_whitespace
429
+ if current_character == "]"
430
+ @index += 1
431
+ pop_container("[")
432
+ return
433
+ end
434
+
435
+ loop do
436
+ parse_value
437
+ skip_whitespace
438
+ case current_character
439
+ when ","
440
+ @index += 1
441
+ skip_whitespace
442
+ when "]"
443
+ @index += 1
444
+ pop_container("[")
445
+ break
446
+ else
447
+ invalid!
448
+ end
449
+ end
450
+ end
451
+
452
+ def push_container(type)
453
+ @stack << type
454
+ @max_depth = [@max_depth, @stack.length].max
455
+ invalid! if @stack.length > @limits.container_depth
456
+ end
457
+
458
+ def pop_container(type)
459
+ invalid! unless @stack.pop == type
460
+ end
461
+
462
+ def scan_string
463
+ invalid! unless current_character == '"'
464
+ @index += 1
465
+ while @index < @text.length
466
+ character = @text[@index]
467
+ if character == '"'
468
+ @index += 1
469
+ return
470
+ elsif character == "\\"
471
+ @index += 1
472
+ invalid! if @index >= @text.length
473
+ escape = @text[@index]
474
+ if escape == "u"
475
+ 4.times do
476
+ @index += 1
477
+ invalid! if @index >= @text.length || !@text[@index].match?(/[0-9A-Fa-f]/)
478
+ end
479
+ elsif !%w[" \\ / b f n r t].include?(escape)
480
+ invalid!
481
+ end
482
+ elsif character.ord < 0x20
483
+ invalid!
484
+ end
485
+ @index += 1
486
+ end
487
+ invalid!
488
+ end
489
+
490
+ def decode_key(raw)
491
+ JSON.parse(raw, create_additions: false)
492
+ rescue JSON::ParserError, ArgumentError, TypeError
493
+ invalid!
494
+ end
495
+
496
+ def scan_number
497
+ start = @index
498
+ @index += 1 if current_character == "-"
499
+ invalid! if @index >= @text.length
500
+ if current_character == "0"
501
+ @index += 1
502
+ invalid! if digit?(current_character)
503
+ elsif current_character&.match?(/[1-9]/)
504
+ @index += 1 while digit?(current_character)
505
+ else
506
+ invalid!
507
+ end
508
+
509
+ if current_character == "."
510
+ @index += 1
511
+ fraction_start = @index
512
+ @index += 1 while digit?(current_character)
513
+ invalid! if @index == fraction_start
514
+ end
515
+ if %w[e E].include?(current_character)
516
+ @index += 1
517
+ @index += 1 if %w[+ -].include?(current_character)
518
+ exponent_start = @index
519
+ @index += 1 while digit?(current_character)
520
+ invalid! if @index == exponent_start
521
+ end
522
+ unless @index >= @text.length || TOKEN_DELIMITERS.include?(current_character)
523
+ invalid!
524
+ end
525
+
526
+ @text[start...@index]
527
+ end
528
+
529
+ def scan_literal(literal)
530
+ invalid! unless @text[@index, literal.length] == literal
531
+ @index += literal.length
532
+ unless @index >= @text.length || TOKEN_DELIMITERS.include?(current_character)
533
+ invalid!
534
+ end
535
+ end
536
+
537
+ def mark_root_result(token, object_key)
538
+ return unless @stack.empty? || (@stack.length == 1 && @stack.first == "{")
539
+ return unless object_key == "result"
540
+
541
+ @root_result_seen = true
542
+ @root_result_token = token
543
+ end
544
+
545
+ def add_token
546
+ @token_count += 1
547
+ invalid! if @token_count > @limits.json_tokens
548
+ end
549
+
550
+ def skip_whitespace
551
+ @index += 1 while @index < @text.length && " \t\r\n".include?(@text[@index])
552
+ end
553
+
554
+ def current_character
555
+ @text[@index]
556
+ end
557
+
558
+ def digit?(character)
559
+ character && character.match?(/[0-9]/)
560
+ end
561
+
562
+ def invalid!
563
+ raise InvalidResponseError.new("ERPC returned an invalid Bitcoin response"), cause: nil
564
+ end
565
+ end
566
+ private_constant :Scanner
567
+ end
568
+
569
+ BITCOIN_POLICY_ID = "bitcoin-exact-json-v1".freeze
570
+ BITCOIN_CODEC = BitcoinCodec
571
+
572
+ private_constant :BitcoinResponseLimits, :BITCOIN_RESPONSE_LIMITS, :BitcoinNumberTuple,
573
+ :BitcoinPreflightResult, :BitcoinNumberToken, :BitcoinCodec, :BITCOIN_CODEC
574
+ end