solana-ruby-kit 8.2.0 → 8.3.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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +82 -12
  3. data/lib/solana/ruby/kit/accounts/maybe_account.rb +1 -1
  4. data/lib/solana/ruby/kit/addresses/address.rb +1 -1
  5. data/lib/solana/ruby/kit/addresses/curve.rb +1 -1
  6. data/lib/solana/ruby/kit/addresses/program_derived_address.rb +1 -1
  7. data/lib/solana/ruby/kit/addresses/public_key.rb +1 -1
  8. data/lib/solana/ruby/kit/codecs/codec.rb +95 -1
  9. data/lib/solana/ruby/kit/codecs/data_structures.rb +51 -8
  10. data/lib/solana/ruby/kit/codecs/numbers.rb +49 -50
  11. data/lib/solana/ruby/kit/codecs/strings.rb +132 -6
  12. data/lib/solana/ruby/kit/encoding/base58.rb +1 -1
  13. data/lib/solana/ruby/kit/errors.rb +6 -0
  14. data/lib/solana/ruby/kit/fast_stable_stringify.rb +1 -1
  15. data/lib/solana/ruby/kit/functional.rb +1 -1
  16. data/lib/solana/ruby/kit/instruction_plans/instruction_plan.rb +1 -1
  17. data/lib/solana/ruby/kit/instruction_plans/max_instructions.rb +1 -1
  18. data/lib/solana/ruby/kit/instruction_plans/transaction_plan.rb +1 -1
  19. data/lib/solana/ruby/kit/instruction_plans/transaction_plan_executor.rb +2 -2
  20. data/lib/solana/ruby/kit/instruction_plans/transaction_plan_result.rb +1 -1
  21. data/lib/solana/ruby/kit/instruction_plans/transaction_planner.rb +2 -2
  22. data/lib/solana/ruby/kit/instructions/accounts.rb +1 -1
  23. data/lib/solana/ruby/kit/instructions/instruction.rb +1 -1
  24. data/lib/solana/ruby/kit/instructions/roles.rb +1 -1
  25. data/lib/solana/ruby/kit/keys/key_pair.rb +1 -1
  26. data/lib/solana/ruby/kit/keys/private_key.rb +1 -1
  27. data/lib/solana/ruby/kit/keys/public_key.rb +1 -1
  28. data/lib/solana/ruby/kit/keys/signatures.rb +1 -1
  29. data/lib/solana/ruby/kit/offchain_messages/codec.rb +1 -1
  30. data/lib/solana/ruby/kit/offchain_messages/message_v1.rb +1 -1
  31. data/lib/solana/ruby/kit/options/option.rb +1 -1
  32. data/lib/solana/ruby/kit/plugin_core.rb +1 -1
  33. data/lib/solana/ruby/kit/programs/associated_token_account.rb +1 -1
  34. data/lib/solana/ruby/kit/programs/stake_program.rb +1 -1
  35. data/lib/solana/ruby/kit/programs/system_program.rb +1 -1
  36. data/lib/solana/ruby/kit/programs.rb +1 -1
  37. data/lib/solana/ruby/kit/promises.rb +1 -1
  38. data/lib/solana/ruby/kit/resource_limit_estimation.rb +1 -1
  39. data/lib/solana/ruby/kit/rpc_parsed_types/address_lookup_table.rb +1 -1
  40. data/lib/solana/ruby/kit/rpc_parsed_types/nonce_account.rb +1 -1
  41. data/lib/solana/ruby/kit/rpc_parsed_types/stake_account.rb +1 -1
  42. data/lib/solana/ruby/kit/rpc_parsed_types/token_account.rb +1 -1
  43. data/lib/solana/ruby/kit/rpc_parsed_types/vote_account.rb +1 -1
  44. data/lib/solana/ruby/kit/rpc_types/cluster_url.rb +1 -1
  45. data/lib/solana/ruby/kit/rpc_types/commitment.rb +1 -1
  46. data/lib/solana/ruby/kit/rpc_types/lamports.rb +1 -1
  47. data/lib/solana/ruby/kit/rpc_types/sol.rb +1 -1
  48. data/lib/solana/ruby/kit/signers/keypair_signer.rb +1 -1
  49. data/lib/solana/ruby/kit/subscribable/async_iterable.rb +1 -1
  50. data/lib/solana/ruby/kit/subscribable/bridge_store_to_async_iterable.rb +1 -1
  51. data/lib/solana/ruby/kit/subscribable/reactive_action_store.rb +1 -1
  52. data/lib/solana/ruby/kit/subscribable/reactive_stream_store.rb +1 -1
  53. data/lib/solana/ruby/kit/sysvars/clock.rb +1 -1
  54. data/lib/solana/ruby/kit/sysvars/epoch_schedule.rb +1 -1
  55. data/lib/solana/ruby/kit/sysvars/last_restart_slot.rb +1 -1
  56. data/lib/solana/ruby/kit/sysvars/rent.rb +1 -1
  57. data/lib/solana/ruby/kit/sysvars.rb +1 -1
  58. data/lib/solana/ruby/kit/transaction_confirmation.rb +1 -1
  59. data/lib/solana/ruby/kit/transaction_introspection/compiled_transaction_message.rb +1 -1
  60. data/lib/solana/ruby/kit/transaction_introspection/decode_rpc_transaction.rb +1 -1
  61. data/lib/solana/ruby/kit/transaction_introspection/get_inner_instructions.rb +1 -1
  62. data/lib/solana/ruby/kit/transaction_introspection/get_instructions.rb +1 -1
  63. data/lib/solana/ruby/kit/transaction_introspection/walk_instructions.rb +1 -1
  64. data/lib/solana/ruby/kit/transaction_messages/compute_budget.rb +1 -1
  65. data/lib/solana/ruby/kit/transaction_messages/resource_limit_validation.rb +1 -1
  66. data/lib/solana/ruby/kit/transaction_messages/transaction_message.rb +1 -1
  67. data/lib/solana/ruby/kit/transactions/compiler.rb +2 -2
  68. data/lib/solana/ruby/kit/transactions/transaction.rb +30 -10
  69. data/lib/solana/ruby/kit/version.rb +1 -1
  70. data/lib/solana/ruby/kit/wallet_standard.rb +3 -3
  71. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ba7d2659d83b01af6565c7af483364d61e61fcc9761cbca9d63d8d41f6b4d73a
4
- data.tar.gz: bd8402f845e9d8db5845be0e358676d00e308b2a0777afc1973e8814175e1c1c
3
+ metadata.gz: c281fe52274deb9bbf493d3848b3bcb64ecc42b11e4acc6b727ef5e301dcb252
4
+ data.tar.gz: 6fefa37ee1da4bc377bc58f609adad46c2940b3377a9333168df0f6ff83cef6c
5
5
  SHA512:
6
- metadata.gz: 589ade790ebb62b97f9905e8fe98113efc93deb5a6fad37239d377ab427915838eaa3d461644d213944ee8b80796134b28d5a6da43529a3920bb5037940a20a1
7
- data.tar.gz: 3c03649c3b4314b93e7321f74b77150cd19bd775535b8431c14b678fdf35d3e7d4703fac43f341c3480ec5031b0c78bd0c0e3b7e385bb01f1822fc57fa5afaec
6
+ metadata.gz: ba02f18c40de9b8b9ab544551ac477e94b1f4dcd4b177e8d513680b3d406830991acb770f4f427b5e9d4ca4abaf0cdda28d5849d649e6e50037104fe653390a7
7
+ data.tar.gz: 5ccc70a501ac546deb6520394b9efedc8ddbd7b2278318ab0fdf3944352e9a66ac06d66bd8b8288b1bf4b10a9cb395266d23f3feec3d7708741421a83033af9d
data/README.md CHANGED
@@ -360,20 +360,25 @@ Binary encoder/decoder framework for Solana on-chain data.
360
360
  ```ruby
361
361
  Codecs = Solana::Ruby::Kit::Codecs
362
362
 
363
- # Numbers
364
- u8 = Codecs.u8
365
- u16 = Codecs.u16_le # little-endian (default for Solana)
366
- u32 = Codecs.u32_le
367
- u64 = Codecs.u64_le
368
- i8 = Codecs.i8
369
- f32 = Codecs.f32_le
370
-
371
- u16.encode(1000) # => "\xe8\x03"
372
- u16.decode("\xe8\x03") # => 1000
363
+ # Numbers. Every multi-byte codec takes `endian:`, defaulting to :little
364
+ # (which is what Solana uses on-chain).
365
+ u8 = Codecs.u8_codec
366
+ u16 = Codecs.u16_codec
367
+ u32 = Codecs.u32_codec
368
+ u64 = Codecs.u64_codec
369
+ u128 = Codecs.u128_codec
370
+ u256 = Codecs.u256_codec # 32 bytes
371
+ i8 = Codecs.i8_codec
372
+ i256 = Codecs.i256_codec # 32 bytes, two's complement
373
+ f32 = Codecs.f32_codec
374
+ be = Codecs.u16_codec(endian: :big)
375
+
376
+ u16.encode(1000) # => "\xe8\x03"
377
+ u16.decode("\xe8\x03") # => [1000, 2] # [value, bytes consumed]
373
378
 
374
379
  # Strings
375
- utf8 = Codecs.utf8
376
- bytes = Codecs.bytes_codec
380
+ utf8 = Codecs.utf8_codec
381
+ bytes = Codecs.bytes_codec(32)
377
382
 
378
383
  # Data structures
379
384
  struct_codec = Codecs.struct_codec([
@@ -382,6 +387,71 @@ struct_codec = Codecs.struct_codec([
382
387
  ])
383
388
  ```
384
389
 
390
+ #### UTF-8 options
391
+
392
+ By default the UTF-8 codec substitutes `U+FFFD` for malformed input, strips a
393
+ leading byte order mark, and strips null characters - the last of which is what
394
+ makes fixed-size padded strings read back cleanly, at the cost of being lossy
395
+ for strings that legitimately contain nulls.
396
+
397
+ ```ruby
398
+ # Reject invalid UTF-8 instead of substituting the replacement character.
399
+ strict = Codecs.utf8_codec(fatal: true)
400
+ strict.encode("a\xC0\x80") # raises SolanaError (invalid UTF-8 string)
401
+ strict.decode("\xFF".b) # raises SolanaError (invalid UTF-8 bytes)
402
+
403
+ # Decode losslessly, keeping null characters and a leading byte order mark.
404
+ lossless = Codecs.utf8_codec(ignore_bom: true, remove_null_characters: false)
405
+
406
+ # The assertions are usable on their own.
407
+ Codecs.find_malformed_utf8_sequence_offset("a\xC0\x80".b) # => 1 (overlong)
408
+ Codecs.assert_is_well_formed_utf8_bytes("\xE8\xAA\x9E".b) # passes ("語")
409
+ Codecs.remove_null_characters("a\x00b") # => "ab"
410
+ ```
411
+
412
+ Note that `ignore_bom` follows `TextDecoder`'s confusing spelling: the default,
413
+ `false`, *strips* the mark; `true` keeps it.
414
+
415
+ #### Requiring a size prefix
416
+
417
+ A prefixed collection decodes an exhausted buffer to an empty collection rather
418
+ than failing, so a program can append a collection to an existing account layout
419
+ and still read accounts written before the change. Formats that cannot accept
420
+ that leniency - borsh requires the prefix to be present - can opt into failing:
421
+
422
+ ```ruby
423
+ Codecs.array_codec(u8).decode(''.b) # => [[], 0]
424
+ Codecs.array_codec(u8, require_size_prefix: true).decode(''.b) # raises SolanaError
425
+ ```
426
+
427
+ `map_codec` and `set_codec` take the same option. It has no effect on
428
+ fixed-count collections, which carry no prefix.
429
+
430
+ #### Tap combinators
431
+
432
+ Wrap a codec in one that observes a value - or the bytes - without changing it,
433
+ for validation guards or logging. A tap that raises aborts the operation.
434
+
435
+ ```ruby
436
+ guarded = Codecs.tap_encoder(u8.encoder) do |value|
437
+ raise ArgumentError, 'Value must not exceed 100' if value > 100
438
+ end
439
+ guarded.encode(42) # => "\x2a"
440
+ guarded.encode(200) # raises ArgumentError
441
+
442
+ # Both sides at once; decode_tap is optional.
443
+ Codecs.tap_codec(u8, encode_tap: ->(v) { log(v) }, decode_tap: ->(v) { log(v) })
444
+
445
+ # Observe the bytes instead of the value.
446
+ Codecs.tap_decoder_bytes(u8.decoder) do |bytes, offset|
447
+ raise ArgumentError, 'Expected a 0 or a 1' if bytes.bytes[offset] > 1
448
+ end
449
+ ```
450
+
451
+ Upstream's byte taps take `(bytes, pre_offset, post_offset)` because encoders
452
+ there write into a shared buffer. A Ruby `Encoder` returns a standalone byte
453
+ String, so the encode-side taps receive exactly the bytes that were written.
454
+
385
455
  ### `Solana::Ruby::Kit::RpcSubscriptions` — `@solana/rpc-subscriptions`
386
456
 
387
457
  WebSocket-based subscription client.
@@ -28,7 +28,7 @@ module Solana::Ruby::Kit
28
28
  const :space, T.nilable(Integer)
29
29
  end
30
30
 
31
- module_function
31
+ extend self
32
32
 
33
33
  # Builds a MaybeAccount representing a found account.
34
34
  sig { params(account: Account).returns(MaybeAccount) }
@@ -61,7 +61,7 @@ module Solana::Ruby::Kit
61
61
  ADDRESS_MIN_STR_LEN = T.let(32, Integer)
62
62
  ADDRESS_MAX_STR_LEN = T.let(44, Integer)
63
63
 
64
- module_function
64
+ extend self
65
65
 
66
66
  # Encodes raw bytes (binary String, length == 32) to a base58 address string.
67
67
  # Mirrors `getAddressEncoder()` in TypeScript.
@@ -27,7 +27,7 @@ module Solana::Ruby::Kit
27
27
  # sqrt(−1) mod p = 2^((p−1)/4) mod p
28
28
  CURVE_SQRT_M1 = T.let(2.pow((CURVE_P - 1) / 4, CURVE_P), Integer)
29
29
 
30
- module_function
30
+ extend self
31
31
 
32
32
  # Returns true if the 32-byte binary string represents a point on the
33
33
  # Ed25519 twisted-Edwards curve.
@@ -38,7 +38,7 @@ module Solana::Ruby::Kit
38
38
  # Marker bytes appended during hashing: UTF-8 "ProgramDerivedAddress".
39
39
  PDA_MARKER_BYTES = T.let('ProgramDerivedAddress'.b, String)
40
40
 
41
- module_function
41
+ extend self
42
42
 
43
43
  # Returns true if the value is a well-formed ProgramDerivedAddress.
44
44
  # Mirrors `isProgramDerivedAddress()` in TypeScript.
@@ -8,7 +8,7 @@ require_relative '../errors'
8
8
  module Solana::Ruby::Kit
9
9
  module Addresses
10
10
  extend T::Sig
11
- module_function
11
+ extend self
12
12
 
13
13
  # Given an RbNaCl::VerifyKey (Ed25519 public key), returns its Solana Address.
14
14
  #
@@ -64,7 +64,7 @@ module Solana::Ruby::Kit
64
64
 
65
65
  # ── Combinators ─────────────────────────────────────────────────────────────
66
66
 
67
- module_function
67
+ extend self
68
68
 
69
69
  # Return a Codec whose output is always exactly +size+ bytes
70
70
  # (zero-padded on right, truncated if too large).
@@ -117,6 +117,100 @@ module Solana::Ruby::Kit
117
117
  Codec.new(enc, dec)
118
118
  end
119
119
 
120
+ # ── Tap combinators ─────────────────────────────────────────────────────────
121
+ #
122
+ # Each of these wraps a codec in one that observes a value (or the bytes)
123
+ # without changing it, for validation guards, logging or other read-only
124
+ # side effects. A tap that raises aborts the operation and the error
125
+ # propagates to the caller.
126
+ #
127
+ # Where upstream's byte taps take +(bytes, pre_offset, post_offset)+ over a
128
+ # shared output buffer, a Ruby Encoder returns a standalone byte String, so
129
+ # the encode-side taps below receive exactly the bytes that were written -
130
+ # the window upstream's tap has to slice out for itself. The decode-side
131
+ # taps keep +(bytes, offset)+, which Ruby Decoders already receive.
132
+
133
+ # Observe each value before it is encoded, leaving it unchanged.
134
+ sig do
135
+ params(encoder: Encoder, tap_fn: T.proc.params(value: T.untyped).void).returns(Encoder)
136
+ end
137
+ def tap_encoder(encoder, &tap_fn)
138
+ Encoder.new(fixed_size: encoder.fixed_size, max_size: encoder.max_size) do |value|
139
+ tap_fn.call(value)
140
+ encoder.encode(value)
141
+ end
142
+ end
143
+
144
+ # Observe each decoded value after it is decoded, leaving it unchanged.
145
+ sig do
146
+ params(decoder: Decoder, tap_fn: T.proc.params(value: T.untyped).void).returns(Decoder)
147
+ end
148
+ def tap_decoder(decoder, &tap_fn)
149
+ Decoder.new(fixed_size: decoder.fixed_size) do |bytes, offset|
150
+ value, consumed = decoder.decode(bytes, offset: offset)
151
+ tap_fn.call(value)
152
+ [value, consumed]
153
+ end
154
+ end
155
+
156
+ # Observe a codec's values on both sides. +decode_tap+ is optional.
157
+ sig do
158
+ params(
159
+ codec: Codec,
160
+ encode_tap: T.proc.params(value: T.untyped).void,
161
+ decode_tap: T.nilable(T.proc.params(value: T.untyped).void)
162
+ ).returns(Codec)
163
+ end
164
+ def tap_codec(codec, encode_tap:, decode_tap: nil)
165
+ enc = tap_encoder(codec.encoder) { |value| encode_tap.call(value) }
166
+ dec = decode_tap ? tap_decoder(codec.decoder) { |value| decode_tap.call(value) } : codec.decoder
167
+ Codec.new(enc, dec)
168
+ end
169
+
170
+ # Observe the bytes an encoder produced, after they are written.
171
+ sig do
172
+ params(encoder: Encoder, tap_fn: T.proc.params(bytes: String).void).returns(Encoder)
173
+ end
174
+ def tap_encoder_bytes(encoder, &tap_fn)
175
+ Encoder.new(fixed_size: encoder.fixed_size, max_size: encoder.max_size) do |value|
176
+ bytes = encoder.encode(value)
177
+ tap_fn.call(bytes)
178
+ bytes
179
+ end
180
+ end
181
+
182
+ # Observe the raw bytes a decoder is about to read, before decoding.
183
+ sig do
184
+ params(
185
+ decoder: Decoder,
186
+ tap_fn: T.proc.params(bytes: String, offset: Integer).void
187
+ ).returns(Decoder)
188
+ end
189
+ def tap_decoder_bytes(decoder, &tap_fn)
190
+ Decoder.new(fixed_size: decoder.fixed_size) do |bytes, offset|
191
+ tap_fn.call(bytes, offset)
192
+ decoder.decode(bytes, offset: offset)
193
+ end
194
+ end
195
+
196
+ # Observe a codec's raw bytes on both sides. +decode_tap+ is optional.
197
+ sig do
198
+ params(
199
+ codec: Codec,
200
+ encode_tap: T.proc.params(bytes: String).void,
201
+ decode_tap: T.nilable(T.proc.params(bytes: String, offset: Integer).void)
202
+ ).returns(Codec)
203
+ end
204
+ def tap_codec_bytes(codec, encode_tap:, decode_tap: nil)
205
+ enc = tap_encoder_bytes(codec.encoder) { |bytes| encode_tap.call(bytes) }
206
+ dec = if decode_tap
207
+ tap_decoder_bytes(codec.decoder) { |bytes, offset| decode_tap.call(bytes, offset) }
208
+ else
209
+ codec.decoder
210
+ end
211
+ Codec.new(enc, dec)
212
+ end
213
+
120
214
  # Reverse the byte order of the encoded output (and input).
121
215
  sig { params(codec: Codec).returns(Codec) }
122
216
  def reverse_codec(codec)
@@ -63,8 +63,23 @@ module Solana::Ruby::Kit
63
63
 
64
64
  # Encode/decode a variable-length array with a u32LE length prefix.
65
65
  # When +size+ is given the array has a fixed element count (no prefix).
66
- sig { params(element_codec: Codec, size: T.nilable(Integer)).returns(Codec) }
67
- def array_codec(element_codec, size: nil)
66
+ #
67
+ # When the size is stored as a prefix and there are not enough bytes left
68
+ # to read it, the decoder yields an empty array rather than failing. That
69
+ # is deliberate: it lets a program append a collection to an existing
70
+ # account layout and still decode accounts written before the change.
71
+ # Formats that cannot accept that leniency - borsh, for one, requires the
72
+ # prefix to be present - can pass +require_size_prefix: true+ to make a
73
+ # truncated buffer raise instead. The option has no effect when +size+ is
74
+ # given, since a fixed-count array carries no prefix.
75
+ sig do
76
+ params(
77
+ element_codec: Codec,
78
+ size: T.nilable(Integer),
79
+ require_size_prefix: T::Boolean
80
+ ).returns(Codec)
81
+ end
82
+ def array_codec(element_codec, size: nil, require_size_prefix: false)
68
83
  if size
69
84
  fixed = element_codec.fixed_size ? size * T.must(element_codec.fixed_size) : nil
70
85
  enc = Encoder.new(fixed_size: fixed) do |values|
@@ -90,6 +105,19 @@ module Solana::Ruby::Kit
90
105
  header + body
91
106
  end
92
107
  dec = Decoder.new do |bytes, offset|
108
+ prefix_size = T.must(prefix.fixed_size)
109
+ remaining = [bytes.b.bytesize - offset, 0].max
110
+ if remaining < prefix_size
111
+ # The prefix is missing or truncated. By default that decodes to
112
+ # an empty collection having consumed nothing; under
113
+ # +require_size_prefix+ it is an error.
114
+ Kernel.raise SolanaError.new(
115
+ SolanaError::CODECS__INVALID_BYTE_LENGTH,
116
+ { expected: prefix_size, actual: remaining }
117
+ ) if require_size_prefix
118
+
119
+ next [[], 0]
120
+ end
93
121
  len, prefix_bytes = prefix.decode(bytes, offset: offset)
94
122
  result = []
95
123
  consumed = prefix_bytes
@@ -106,10 +134,18 @@ module Solana::Ruby::Kit
106
134
 
107
135
  # Encode/decode a Hash.
108
136
  # Encoded as: [length prefix] + [key, value, key, value, ...]
109
- sig { params(key_codec: Codec, value_codec: Codec, size: T.nilable(Integer)).returns(Codec) }
110
- def map_codec(key_codec, value_codec, size: nil)
137
+ # See +array_codec+ for what +require_size_prefix+ does.
138
+ sig do
139
+ params(
140
+ key_codec: Codec,
141
+ value_codec: Codec,
142
+ size: T.nilable(Integer),
143
+ require_size_prefix: T::Boolean
144
+ ).returns(Codec)
145
+ end
146
+ def map_codec(key_codec, value_codec, size: nil, require_size_prefix: false)
111
147
  pair_codec = tuple_codec([key_codec, value_codec])
112
- array_codec(pair_codec, size: size).transform_decoder do |pairs|
148
+ array_codec(pair_codec, size: size, require_size_prefix: require_size_prefix).transform_decoder do |pairs|
113
149
  pairs.each_with_object({}) { |(k, v), h| h[k] = v }
114
150
  end.transform_encoder do |hash|
115
151
  T.cast(hash, T::Hash[T.untyped, T.untyped]).map { |k, v| [k, v] }
@@ -117,9 +153,16 @@ module Solana::Ruby::Kit
117
153
  end
118
154
 
119
155
  # Encode/decode a Set (stored as an array of unique elements).
120
- sig { params(element_codec: Codec, size: T.nilable(Integer)).returns(Codec) }
121
- def set_codec(element_codec, size: nil)
122
- array_codec(element_codec, size: size)
156
+ # See +array_codec+ for what +require_size_prefix+ does.
157
+ sig do
158
+ params(
159
+ element_codec: Codec,
160
+ size: T.nilable(Integer),
161
+ require_size_prefix: T::Boolean
162
+ ).returns(Codec)
163
+ end
164
+ def set_codec(element_codec, size: nil, require_size_prefix: false)
165
+ array_codec(element_codec, size: size, require_size_prefix: require_size_prefix)
123
166
  .transform_encoder { |s| T.cast(s, T::Set[T.untyped]).to_a }
124
167
  .transform_decoder { |arr| Set.new(arr) }
125
168
  end
@@ -19,6 +19,43 @@ module Solana::Ruby::Kit
19
19
  # defeated the "directly available as Codecs.x" intent in codecs.rb.
20
20
  extend self
21
21
 
22
+ # ── Multi-byte integers ──────────────────────────────────────────────────
23
+
24
+ # Shared implementation behind u128 / i128 / u256 / i256.
25
+ #
26
+ # Upstream splits these values into 64-bit words because a JS `DataView`
27
+ # has no accessor wider than 64 bits. Ruby's Integer is arbitrary
28
+ # precision, so there is nothing to split: the value is shifted out one
29
+ # byte at a time and reassembled the same way, which keeps a single
30
+ # implementation correct for any +byte_count+.
31
+ #
32
+ # +signed+ selects two's-complement interpretation over +byte_count * 8+
33
+ # bits, so negative values round-trip.
34
+ sig { params(byte_count: Integer, signed: T::Boolean, endian: Symbol).returns(Codec) }
35
+ def big_int_codec(byte_count, signed:, endian: :little)
36
+ bits = byte_count * 8
37
+ enc = Encoder.new(fixed_size: byte_count) do |v|
38
+ n = Kernel.Integer(v)
39
+ n += (1 << bits) if signed && n.negative?
40
+ out = Array.new(byte_count) do
41
+ byte = n & 0xFF
42
+ n >>= 8
43
+ byte
44
+ end
45
+ # `out` is least-significant-byte first.
46
+ (endian == :little ? out : out.reverse).pack('C*')
47
+ end
48
+ dec = Decoder.new(fixed_size: byte_count) do |bytes, offset|
49
+ slice = bytes.b.byteslice(offset, byte_count) || ("\x00" * byte_count).b
50
+ arr = T.cast(T.unsafe(slice).unpack('C*'), T::Array[Integer])
51
+ arr = arr.reverse if endian == :little
52
+ n = arr.reduce(0) { |acc, byte| (acc << 8) | byte }
53
+ n -= (1 << bits) if signed && n >= (1 << (bits - 1))
54
+ [n, byte_count]
55
+ end
56
+ Codec.new(enc, dec)
57
+ end
58
+
22
59
  # ── Unsigned integers ────────────────────────────────────────────────────
23
60
 
24
61
  sig { returns(Codec) }
@@ -62,29 +99,12 @@ module Solana::Ruby::Kit
62
99
 
63
100
  sig { params(endian: Symbol).returns(Codec) }
64
101
  def u128_codec(endian: :little)
65
- enc = Encoder.new(fixed_size: 16) do |v|
66
- n = Kernel.Integer(v)
67
- if endian == :little
68
- bytes = []
69
- 16.times { bytes << (n & 0xFF); n >>= 8 }
70
- bytes.pack('C*')
71
- else
72
- bytes = []
73
- 16.times { bytes.unshift(n & 0xFF); n >>= 8 }
74
- bytes.pack('C*')
75
- end
76
- end
77
- dec = Decoder.new(fixed_size: 16) do |bytes, offset|
78
- slice = bytes.b.byteslice(offset, 16) || ("\x00" * 16).b
79
- arr = T.cast(T.unsafe(slice).unpack('C*'), T::Array[Integer])
80
- n = if endian == :little
81
- arr.reverse.reduce(0) { |acc, b| (acc << 8) | b }
82
- else
83
- arr.reduce(0) { |acc, b| (acc << 8) | b }
84
- end
85
- [n, 16]
86
- end
87
- Codec.new(enc, dec)
102
+ big_int_codec(16, signed: false, endian: endian)
103
+ end
104
+
105
+ sig { params(endian: Symbol).returns(Codec) }
106
+ def u256_codec(endian: :little)
107
+ big_int_codec(32, signed: false, endian: endian)
88
108
  end
89
109
 
90
110
  # ── Signed integers ──────────────────────────────────────────────────────
@@ -130,33 +150,12 @@ module Solana::Ruby::Kit
130
150
 
131
151
  sig { params(endian: Symbol).returns(Codec) }
132
152
  def i128_codec(endian: :little)
133
- enc = Encoder.new(fixed_size: 16) do |v|
134
- n = Kernel.Integer(v)
135
- # Two's complement for negative numbers
136
- n += (1 << 128) if n.negative?
137
- if endian == :little
138
- bytes = []
139
- 16.times { bytes << (n & 0xFF); n >>= 8 }
140
- bytes.pack('C*')
141
- else
142
- bytes = []
143
- 16.times { bytes.unshift(n & 0xFF); n >>= 8 }
144
- bytes.pack('C*')
145
- end
146
- end
147
- dec = Decoder.new(fixed_size: 16) do |bytes, offset|
148
- slice = bytes.b.byteslice(offset, 16) || ("\x00" * 16).b
149
- arr = T.cast(T.unsafe(slice).unpack('C*'), T::Array[Integer])
150
- n = if endian == :little
151
- arr.reverse.reduce(0) { |acc, b| (acc << 8) | b }
152
- else
153
- arr.reduce(0) { |acc, b| (acc << 8) | b }
154
- end
155
- # Convert from unsigned to signed 128-bit
156
- n -= (1 << 128) if n >= (1 << 127)
157
- [n, 16]
158
- end
159
- Codec.new(enc, dec)
153
+ big_int_codec(16, signed: true, endian: endian)
154
+ end
155
+
156
+ sig { params(endian: Symbol).returns(Codec) }
157
+ def i256_codec(endian: :little)
158
+ big_int_codec(32, signed: true, endian: endian)
160
159
  end
161
160
 
162
161
  # ── Floating point ───────────────────────────────────────────────────────
@@ -15,14 +15,134 @@ module Solana::Ruby::Kit
15
15
  # defeated the "directly available as Codecs.x" intent in codecs.rb.
16
16
  extend self
17
17
 
18
+ # Byte order mark, U+FEFF, in its UTF-8 encoding.
19
+ BOM = T.let("\xEF\xBB\xBF".b.freeze, String)
20
+
21
+ # Remove every null character from a decoded string.
22
+ # Mirrors `removeNullCharacters`.
23
+ sig { params(value: String).returns(String) }
24
+ def remove_null_characters(value)
25
+ value.delete("\x00")
26
+ end
27
+
28
+ # Find the first malformed UTF-8 sequence in +bytes+, starting at +offset+.
29
+ # Returns the byte offset at which it starts, or -1 when the bytes are
30
+ # well-formed.
31
+ #
32
+ # This rejects everything the Unicode standard rejects: unexpected
33
+ # continuation bytes, overlong encodings, encoded surrogates, code points
34
+ # above U+10FFFF and truncated sequences. Ruby's own +valid_encoding?+
35
+ # answers the same question, but only yes or no - this reports *where*,
36
+ # which is what the error context needs.
37
+ # Mirrors `findMalformedUtf8SequenceOffset`.
38
+ sig { params(bytes: String, offset: Integer).returns(Integer) }
39
+ def find_malformed_utf8_sequence_offset(bytes, offset = 0)
40
+ arr = T.cast(T.unsafe(bytes.b).unpack('C*'), T::Array[Integer])
41
+ length = arr.length
42
+ index = offset
43
+ while index < length
44
+ lead = T.must(arr[index])
45
+ min = 0x80
46
+ max = 0xBF
47
+ if lead < 0x80
48
+ index += 1
49
+ next
50
+ elsif lead >= 0xC2 && lead <= 0xDF
51
+ count = 1
52
+ elsif lead >= 0xE0 && lead <= 0xEF
53
+ count = 2
54
+ min = 0xA0 if lead == 0xE0 # Overlong.
55
+ max = 0x9F if lead == 0xED # Encoded surrogate.
56
+ elsif lead >= 0xF0 && lead <= 0xF4
57
+ count = 3
58
+ min = 0x90 if lead == 0xF0 # Overlong.
59
+ max = 0x8F if lead == 0xF4 # Above U+10FFFF.
60
+ else
61
+ return index
62
+ end
63
+
64
+ start = index
65
+ index += 1
66
+ count.times do |i|
67
+ return start if index >= length
68
+
69
+ byte = T.must(arr[index])
70
+ in_range = i.zero? ? byte >= min && byte <= max : byte >= 0x80 && byte <= 0xBF
71
+ return start unless in_range
72
+
73
+ index += 1
74
+ end
75
+ end
76
+ -1
77
+ end
78
+
79
+ # Raise unless +bytes+, from +offset+, form well-formed UTF-8.
80
+ # Mirrors `assertIsWellFormedUtf8Bytes`.
81
+ sig { params(bytes: String, offset: Integer).void }
82
+ def assert_is_well_formed_utf8_bytes(bytes, offset = 0)
83
+ malformed = find_malformed_utf8_sequence_offset(bytes, offset)
84
+ return if malformed == -1
85
+
86
+ Kernel.raise SolanaError.new(
87
+ SolanaError::CODECS__INVALID_UTF8_BYTES,
88
+ { bytes: bytes.b, offset: malformed }
89
+ )
90
+ end
91
+
92
+ # Raise unless +value+ can be encoded as UTF-8 without loss.
93
+ # Mirrors `assertIsWellFormedUtf8String`.
94
+ #
95
+ # Upstream guards against lone surrogates, which a JavaScript string can
96
+ # hold because it is a sequence of UTF-16 code units. A Ruby String is a
97
+ # byte sequence with an encoding tag, so the equivalent flaw is a String
98
+ # whose bytes are not valid UTF-8. Upstream's reported +index+ counts
99
+ # UTF-16 code units; the +index+ here is a byte offset.
100
+ sig { params(value: String).void }
101
+ def assert_is_well_formed_utf8_string(value)
102
+ index = find_malformed_utf8_sequence_offset(value.b)
103
+ return if index == -1
104
+
105
+ Kernel.raise SolanaError.new(
106
+ SolanaError::CODECS__INVALID_UTF8_STRING,
107
+ { index: index, value: value }
108
+ )
109
+ end
110
+
18
111
  # UTF-8 string codec.
19
112
  # When +size+ is given the encoded bytes are fixed to that length
20
113
  # (zero-padded or truncated); otherwise the codec is variable-length
21
114
  # and must be used inside a size-prefixed container.
22
- sig { params(size: T.nilable(Integer)).returns(Codec) }
23
- def utf8_codec(size: nil)
115
+ #
116
+ # +fatal+ rejects invalid UTF-8 instead of passing it through: on encode a
117
+ # String whose bytes are not valid UTF-8, on decode a malformed byte
118
+ # sequence.
119
+ #
120
+ # +ignore_bom+ follows TextDecoder's confusing spelling: the default,
121
+ # +false+, *strips* a leading byte order mark; +true+ keeps it.
122
+ #
123
+ # +remove_null_characters+ strips every null character from the decoded
124
+ # string, which is what makes fixed-size padded strings read back cleanly.
125
+ # It is on by default, matching upstream, and makes the codec lossy for
126
+ # strings that legitimately contain nulls - pass +false+ for a lossless
127
+ # round trip.
128
+ sig do
129
+ params(
130
+ size: T.nilable(Integer),
131
+ fatal: T::Boolean,
132
+ ignore_bom: T::Boolean,
133
+ remove_null_characters: T::Boolean
134
+ ).returns(Codec)
135
+ end
136
+ def utf8_codec(size: nil, fatal: false, ignore_bom: false, remove_null_characters: true)
137
+ strip_nulls = remove_null_characters
24
138
  enc = Encoder.new(fixed_size: size) do |v|
25
- raw = v.to_s.encode('UTF-8').b
139
+ str = v.to_s
140
+ # Transcode anything that is not already UTF-8 (or raw bytes); a
141
+ # UTF-8 to UTF-8 `encode` is a no-op and would not validate, which is
142
+ # what `fatal` is for.
143
+ str = str.encode(::Encoding::UTF_8) unless [::Encoding::UTF_8, ::Encoding::BINARY].include?(str.encoding)
144
+ raw = str.b
145
+ assert_is_well_formed_utf8_string(raw) if fatal
26
146
  if size
27
147
  raw.bytesize <= size ? raw.ljust(size, "\x00") : raw.byteslice(0, size) || ''.b
28
148
  else
@@ -32,9 +152,15 @@ module Solana::Ruby::Kit
32
152
  dec = Decoder.new(fixed_size: size) do |bytes, offset|
33
153
  len = size || (bytes.bytesize - offset)
34
154
  slice = bytes.b.byteslice(offset, len) || ''.b
35
- # Strip null padding for fixed-size strings
36
- str = size ? slice.delete_suffix("\x00" * slice.bytesize.times.take_while { |i| slice.b[-1 - i] == "\x00" }.length) : slice
37
- [str.force_encoding('UTF-8'), len]
155
+ assert_is_well_formed_utf8_bytes(slice) if fatal
156
+ slice = T.must(slice.byteslice(BOM.bytesize..)) if !ignore_bom && slice.start_with?(BOM)
157
+ str = slice.force_encoding(::Encoding::UTF_8)
158
+ # Non-fatal decoding substitutes U+FFFD for malformed sequences, as
159
+ # TextDecoder does. `scrub` is Ruby's version of exactly that, and it
160
+ # also keeps the null-stripping below from raising on invalid bytes.
161
+ str = str.scrub unless fatal
162
+ str = remove_null_characters(str) if strip_nulls
163
+ [str, len]
38
164
  end
39
165
  Codec.new(enc, dec)
40
166
  end
@@ -13,7 +13,7 @@ module Solana::Ruby::Kit
13
13
  String
14
14
  )
15
15
 
16
- module_function
16
+ extend self
17
17
 
18
18
  # Encodes a binary String to a base58 string.
19
19
  sig { params(bytes: String).returns(String) }