solana-ruby-kit 7.1.1.2 → 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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +106 -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 +12 -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 +107 -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/api/get_ag_genesis_cert.rb +72 -0
  40. data/lib/solana/ruby/kit/rpc/client.rb +2 -0
  41. data/lib/solana/ruby/kit/rpc_parsed_types/address_lookup_table.rb +1 -1
  42. data/lib/solana/ruby/kit/rpc_parsed_types/nonce_account.rb +1 -1
  43. data/lib/solana/ruby/kit/rpc_parsed_types/stake_account.rb +1 -1
  44. data/lib/solana/ruby/kit/rpc_parsed_types/token_account.rb +1 -1
  45. data/lib/solana/ruby/kit/rpc_parsed_types/vote_account.rb +1 -1
  46. data/lib/solana/ruby/kit/rpc_types/cluster_url.rb +1 -1
  47. data/lib/solana/ruby/kit/rpc_types/commitment.rb +1 -1
  48. data/lib/solana/ruby/kit/rpc_types/lamports.rb +1 -1
  49. data/lib/solana/ruby/kit/rpc_types/sol.rb +1 -1
  50. data/lib/solana/ruby/kit/signers/keypair_signer.rb +1 -1
  51. data/lib/solana/ruby/kit/subscribable/async_iterable.rb +1 -1
  52. data/lib/solana/ruby/kit/subscribable/bridge_store_to_async_iterable.rb +1 -1
  53. data/lib/solana/ruby/kit/subscribable/reactive_action_store.rb +1 -1
  54. data/lib/solana/ruby/kit/subscribable/reactive_stream_store.rb +1 -1
  55. data/lib/solana/ruby/kit/sysvars/clock.rb +1 -1
  56. data/lib/solana/ruby/kit/sysvars/epoch_schedule.rb +1 -1
  57. data/lib/solana/ruby/kit/sysvars/last_restart_slot.rb +1 -1
  58. data/lib/solana/ruby/kit/sysvars/rent.rb +1 -1
  59. data/lib/solana/ruby/kit/sysvars.rb +1 -1
  60. data/lib/solana/ruby/kit/transaction_confirmation.rb +1 -1
  61. data/lib/solana/ruby/kit/transaction_introspection/compiled_transaction_message.rb +1 -1
  62. data/lib/solana/ruby/kit/transaction_introspection/decode_rpc_transaction.rb +1 -1
  63. data/lib/solana/ruby/kit/transaction_introspection/get_inner_instructions.rb +1 -1
  64. data/lib/solana/ruby/kit/transaction_introspection/get_instructions.rb +1 -1
  65. data/lib/solana/ruby/kit/transaction_introspection/walk_instructions.rb +1 -1
  66. data/lib/solana/ruby/kit/transaction_messages/compute_budget.rb +3 -1
  67. data/lib/solana/ruby/kit/transaction_messages/resource_limit_validation.rb +70 -0
  68. data/lib/solana/ruby/kit/transaction_messages/transaction_message.rb +1 -1
  69. data/lib/solana/ruby/kit/transaction_messages.rb +1 -0
  70. data/lib/solana/ruby/kit/transactions/compiler.rb +2 -2
  71. data/lib/solana/ruby/kit/transactions/transaction.rb +30 -10
  72. data/lib/solana/ruby/kit/version.rb +1 -1
  73. data/lib/solana/ruby/kit/wallet_standard.rb +3 -3
  74. metadata +3 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4a30b73ebab9578e0345910ab3f7d019cb6cacc9840797abfb8a177a3de9b2bb
4
- data.tar.gz: e540ed2b2b3cc1bb87a5f584b587d1ea4f86cad324c04385311289501ce48cf7
3
+ metadata.gz: c281fe52274deb9bbf493d3848b3bcb64ecc42b11e4acc6b727ef5e301dcb252
4
+ data.tar.gz: 6fefa37ee1da4bc377bc58f609adad46c2940b3377a9333168df0f6ff83cef6c
5
5
  SHA512:
6
- metadata.gz: 29728a4e25c5f9d7dabd89e75cb9fd2b583f03930b6640c48c68ff070e90e06345bbfa75d636383dda51c9dc8044f9942f4a299866f39f8b93189f832fbf03a6
7
- data.tar.gz: 04c68d1f2e6f249c4be788e1c9e6919b056f9780eebd5a26bb6dc8aae6b15ceafe7838d39ed6af4ba24269ebdc5cbdd4cc028f8947db842631aace626750df45
6
+ metadata.gz: ba02f18c40de9b8b9ab544551ac477e94b1f4dcd4b177e8d513680b3d406830991acb770f4f427b5e9d4ca4abaf0cdda28d5849d649e6e50037104fe653390a7
7
+ data.tar.gz: 5ccc70a501ac546deb6520394b9efedc8ddbd7b2278318ab0fdf3944352e9a66ac06d66bd8b8288b1bf4b10a9cb395266d23f3feec3d7708741421a83033af9d
data/README.md CHANGED
@@ -189,9 +189,17 @@ msg = TxMsg.append_instructions(msg, [instruction])
189
189
  msg = TxMsg.prepend_instructions(msg, [priority_fee_ix])
190
190
 
191
191
  # Compute unit limit (SetComputeUnitLimit instruction from the Compute Budget program)
192
+ # The limit is validated: it must be an integer in [0, MAX_COMPUTE_UNIT_LIMIT] (1,400,000).
193
+ # The runtime silently clamps a larger request rather than failing, so asking for more than
194
+ # the maximum would run on a budget you did not ask for — this raises instead.
192
195
  msg = TxMsg.set_transaction_message_compute_unit_limit(200_000, msg)
193
196
  TxMsg.get_transaction_message_compute_unit_limit(msg) # => 200_000
194
197
 
198
+ # The check is also available on its own, alongside one for heap frame sizes — a whole
199
+ # number of KiB in [MIN_HEAP_SIZE, MAX_HEAP_SIZE] (32 KiB to 256 KiB).
200
+ TxMsg.assert_is_valid_compute_unit_limit(200_000)
201
+ TxMsg.assert_is_valid_heap_size(64 * 1024)
202
+
195
203
  # Loaded accounts data size limit
196
204
  msg = TxMsg.set_transaction_message_loaded_accounts_data_size_limit(64_000, msg)
197
205
  TxMsg.get_transaction_message_loaded_accounts_data_size_limit(msg) # => 64_000
@@ -274,6 +282,7 @@ rpc.get_block_time(slot)
274
282
  rpc.get_inflation_reward([address])
275
283
  rpc.get_signatures_for_address(address)
276
284
  rpc.get_vote_accounts
285
+ rpc.get_ag_genesis_cert # Alpenglow genesis cert, or nil
277
286
  rpc.simulate_transaction(encoded_tx)
278
287
  rpc.send_transaction(encoded_tx)
279
288
  rpc.request_airdrop(address, lamports) # devnet / testnet only
@@ -351,20 +360,25 @@ Binary encoder/decoder framework for Solana on-chain data.
351
360
  ```ruby
352
361
  Codecs = Solana::Ruby::Kit::Codecs
353
362
 
354
- # Numbers
355
- u8 = Codecs.u8
356
- u16 = Codecs.u16_le # little-endian (default for Solana)
357
- u32 = Codecs.u32_le
358
- u64 = Codecs.u64_le
359
- i8 = Codecs.i8
360
- f32 = Codecs.f32_le
361
-
362
- u16.encode(1000) # => "\xe8\x03"
363
- 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]
364
378
 
365
379
  # Strings
366
- utf8 = Codecs.utf8
367
- bytes = Codecs.bytes_codec
380
+ utf8 = Codecs.utf8_codec
381
+ bytes = Codecs.bytes_codec(32)
368
382
 
369
383
  # Data structures
370
384
  struct_codec = Codecs.struct_codec([
@@ -373,6 +387,71 @@ struct_codec = Codecs.struct_codec([
373
387
  ])
374
388
  ```
375
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
+
376
455
  ### `Solana::Ruby::Kit::RpcSubscriptions` — `@solana/rpc-subscriptions`
377
456
 
378
457
  WebSocket-based subscription client.
@@ -640,6 +719,21 @@ result = executor.call(transaction_plan)
640
719
  # A one-argument lambda returning { transaction:, context: } — the shape this method
641
720
  # required before v7.1.0 — is still accepted and adapted onto the flow above.
642
721
 
722
+ # ── 3b. Executing every leaf concurrently ────────────────────────────────────
723
+ # For work that has no execution dependencies — signing or serializing, say —
724
+ # create_transaction_plan_executor_with_concurrent_leaves takes the same callback
725
+ # contract but starts every leaf immediately, each on its own thread. It preserves
726
+ # the plan's nesting, order and divisibility in the result, supports non-divisible
727
+ # sequential plans, and cancels nothing: a leaf that raises does not stop the others,
728
+ # so every leaf runs to completion before the failure is reported. Because the leaves
729
+ # run at the same time, the callback must be safe to call concurrently with itself.
730
+ concurrent = Plans.create_transaction_plan_executor_with_concurrent_leaves(
731
+ execute_transaction_message: ->(_context, message) {
732
+ { transaction: Kit::Transactions.compile_transaction_message(message) }
733
+ }
734
+ )
735
+ result = concurrent.call(transaction_plan)
736
+
643
737
  # ── 4. Plan types ─────────────────────────────────────────────────────────────
644
738
  Plans.single_instruction_plan(ix) # wrap one instruction
645
739
  Plans.sequential_instruction_plan([ix1, ix2]) # ordered, divisible
@@ -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 ───────────────────────────────────────────────────────