solana-ruby-kit 8.2.0 → 8.4.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 +164 -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 +115 -5
  9. data/lib/solana/ruby/kit/codecs/data_structures.rb +246 -47
  10. data/lib/solana/ruby/kit/codecs/numbers.rb +96 -61
  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 +18 -1
  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 +31 -26
  17. data/lib/solana/ruby/kit/instruction_plans/message_packer_errors.rb +131 -0
  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 +11 -14
  22. data/lib/solana/ruby/kit/instruction_plans.rb +1 -1
  23. data/lib/solana/ruby/kit/instructions/accounts.rb +1 -1
  24. data/lib/solana/ruby/kit/instructions/instruction.rb +1 -1
  25. data/lib/solana/ruby/kit/instructions/roles.rb +1 -1
  26. data/lib/solana/ruby/kit/keys/key_pair.rb +1 -1
  27. data/lib/solana/ruby/kit/keys/private_key.rb +1 -1
  28. data/lib/solana/ruby/kit/keys/public_key.rb +1 -1
  29. data/lib/solana/ruby/kit/keys/signatures.rb +1 -1
  30. data/lib/solana/ruby/kit/offchain_messages/codec.rb +1 -1
  31. data/lib/solana/ruby/kit/offchain_messages/message_v1.rb +1 -1
  32. data/lib/solana/ruby/kit/options/option.rb +1 -1
  33. data/lib/solana/ruby/kit/plugin_core.rb +1 -1
  34. data/lib/solana/ruby/kit/programs/associated_token_account.rb +1 -1
  35. data/lib/solana/ruby/kit/programs/stake_program.rb +1 -1
  36. data/lib/solana/ruby/kit/programs/system_program.rb +1 -1
  37. data/lib/solana/ruby/kit/programs.rb +1 -1
  38. data/lib/solana/ruby/kit/promises.rb +1 -1
  39. data/lib/solana/ruby/kit/resource_limit_estimation.rb +1 -1
  40. data/lib/solana/ruby/kit/rpc_parsed_types/address_lookup_table.rb +1 -1
  41. data/lib/solana/ruby/kit/rpc_parsed_types/nonce_account.rb +1 -1
  42. data/lib/solana/ruby/kit/rpc_parsed_types/stake_account.rb +1 -1
  43. data/lib/solana/ruby/kit/rpc_parsed_types/token_account.rb +1 -1
  44. data/lib/solana/ruby/kit/rpc_parsed_types/vote_account.rb +1 -1
  45. data/lib/solana/ruby/kit/rpc_types/cluster_url.rb +1 -1
  46. data/lib/solana/ruby/kit/rpc_types/commitment.rb +1 -1
  47. data/lib/solana/ruby/kit/rpc_types/lamports.rb +1 -1
  48. data/lib/solana/ruby/kit/rpc_types/sol.rb +1 -1
  49. data/lib/solana/ruby/kit/signers/keypair_signer.rb +1 -1
  50. data/lib/solana/ruby/kit/subscribable/async_iterable.rb +1 -1
  51. data/lib/solana/ruby/kit/subscribable/bridge_store_to_async_iterable.rb +1 -1
  52. data/lib/solana/ruby/kit/subscribable/reactive_action_store.rb +1 -1
  53. data/lib/solana/ruby/kit/subscribable/reactive_stream_store.rb +1 -1
  54. data/lib/solana/ruby/kit/sysvars/clock.rb +1 -1
  55. data/lib/solana/ruby/kit/sysvars/epoch_schedule.rb +1 -1
  56. data/lib/solana/ruby/kit/sysvars/last_restart_slot.rb +1 -1
  57. data/lib/solana/ruby/kit/sysvars/rent.rb +1 -1
  58. data/lib/solana/ruby/kit/sysvars.rb +1 -1
  59. data/lib/solana/ruby/kit/transaction_confirmation.rb +1 -1
  60. data/lib/solana/ruby/kit/transaction_introspection/compiled_transaction_message.rb +8 -7
  61. data/lib/solana/ruby/kit/transaction_introspection/decode_rpc_transaction.rb +1 -1
  62. data/lib/solana/ruby/kit/transaction_introspection/get_inner_instructions.rb +1 -1
  63. data/lib/solana/ruby/kit/transaction_introspection/get_instructions.rb +1 -1
  64. data/lib/solana/ruby/kit/transaction_introspection/walk_instructions.rb +1 -1
  65. data/lib/solana/ruby/kit/transaction_messages/compute_budget.rb +1 -1
  66. data/lib/solana/ruby/kit/transaction_messages/resource_limit_validation.rb +1 -1
  67. data/lib/solana/ruby/kit/transaction_messages/transaction_message.rb +1 -1
  68. data/lib/solana/ruby/kit/transactions/compiler.rb +2 -2
  69. data/lib/solana/ruby/kit/transactions/transaction.rb +30 -10
  70. data/lib/solana/ruby/kit/version.rb +1 -1
  71. data/lib/solana/ruby/kit/wallet_standard.rb +10 -9
  72. data/solana-ruby-kit.gemspec +1 -1
  73. metadata +6 -6
  74. data/lib/solana/ruby/kit/instruction_plans/max_instructions.rb +0 -59
@@ -3,6 +3,38 @@
3
3
 
4
4
  module Solana::Ruby::Kit
5
5
  module Codecs
6
+ # A size strategy for +array_codec+ / +map_codec+ / +set_codec+ where the
7
+ # collection ends when the bytes at the next item position match a constant
8
+ # +sentinel+. Mirrors upstream's `ArrayLikeCodecSentinelSize`
9
+ # (`{ __kind: 'sentinel', sentinel, strategy? }`), itself a mirror of
10
+ # Codama's `sentinelCountNode`.
11
+ #
12
+ # Unlike a sentinel searched for in the byte stream, this one is compared at
13
+ # item boundaries only, so its bytes may occur *inside* an item without
14
+ # terminating the collection.
15
+ #
16
+ # +strategy+ controls whether the sentinel is written and required:
17
+ # - +:required+ (default) - written after the last item; decoding raises
18
+ # +CODECS__SENTINEL_MISSING_AT_END_OF_BYTES+ if the bytes run out first.
19
+ # - +:optional+ - written, but decoding also stops at the end of the bytes,
20
+ # to tolerate tightly sized or legacy data that lacks it.
21
+ # - +:omitted+ - never written; decoding stops at the end of the bytes, and
22
+ # consumes the sentinel if one happens to be there.
23
+ #
24
+ # Two invariants must hold for a round trip, and - as upstream - the codec
25
+ # does NOT enforce them:
26
+ # 1. No item may *begin* with the sentinel's bytes; decoding cannot tell
27
+ # such an item from the terminator and stops early.
28
+ # 2. Under +:optional+ / +:omitted+ the sentinel must be no wider than the
29
+ # smallest possible item, or a short trailing item is silently dropped:
30
+ # decoding stops once fewer bytes than the sentinel remain.
31
+ class SentinelSize < T::Struct
32
+ STRATEGIES = T.let(%i[required optional omitted].freeze, T::Array[Symbol])
33
+
34
+ const :sentinel, String
35
+ const :strategy, Symbol, default: :required
36
+ end
37
+
6
38
  # Data-structure codecs — mirrors @solana/codecs-data-structures.
7
39
  module DataStructures
8
40
  extend T::Sig
@@ -15,7 +47,8 @@ module Solana::Ruby::Kit
15
47
 
16
48
  # Encode/decode a fixed ordered list of named fields.
17
49
  # +fields+ is an Array of [name, codec] pairs (name can be String or Symbol).
18
- # Encodes to a Hash on decode; expects a Hash for encode.
50
+ # Decodes to a Hash with Symbol keys. Encode takes a Hash keyed by either
51
+ # form, whichever way the field was named, so a decoded value re-encodes.
19
52
  sig { params(fields: T::Array[[T.any(String, Symbol), Codec]]).returns(Codec) }
20
53
  def struct_codec(fields)
21
54
  fixed = fields.all? { |_, c| c.fixed_size }
@@ -23,7 +56,11 @@ module Solana::Ruby::Kit
23
56
 
24
57
  enc = Encoder.new(fixed_size: total) do |value|
25
58
  h = T.cast(value, T::Hash[T.untyped, T.untyped])
26
- fields.map { |name, codec| codec.encode(h[name] || h[name.to_s]) }.join.b
59
+ fields.map do |name, codec|
60
+ # key? rather than `h[name] || ...`, which would skip a false value.
61
+ key = [name, name.to_sym, name.to_s].find { |k| h.key?(k) }
62
+ codec.encode(key.nil? ? nil : h[key])
63
+ end.join.b
27
64
  end
28
65
  dec = Decoder.new(fixed_size: total) do |bytes, offset|
29
66
  result = {}
@@ -61,55 +98,69 @@ module Solana::Ruby::Kit
61
98
  Codec.new(enc, dec)
62
99
  end
63
100
 
64
- # Encode/decode a variable-length array with a u32LE length prefix.
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)
68
- if size
69
- fixed = element_codec.fixed_size ? size * T.must(element_codec.fixed_size) : nil
70
- enc = Encoder.new(fixed_size: fixed) do |values|
71
- T.cast(values, T::Array[T.untyped]).map { |v| element_codec.encode(v) }.join.b
72
- end
73
- dec = Decoder.new(fixed_size: fixed) do |bytes, offset|
74
- result = []
75
- consumed = 0
76
- size.times do
77
- val, n = element_codec.decode(bytes, offset: offset + consumed)
78
- result << val
79
- consumed += n
80
- end
81
- [result, consumed]
82
- end
83
- Codec.new(enc, dec)
101
+ # Encode/decode an array, one +element_codec+ value per item. +size+
102
+ # picks how the item count is stored - mirrors upstream's
103
+ # `ArrayLikeCodecSize`:
104
+ #
105
+ # - +nil+ (default): a u32 little-endian count prefix.
106
+ # - a Codec, e.g. +u16_codec+ or +compact_u16_codec+: the count prefix is
107
+ # written and read with that codec instead.
108
+ # - an Integer: a fixed item count with no prefix. Encoding an array of
109
+ # any other length raises +CODECS__INVALID_NUMBER_OF_ITEMS+.
110
+ # - +:remainder+: no prefix; decoding reads items until the bytes run out,
111
+ # so the array must be the last thing in the buffer.
112
+ # - a SentinelSize: no prefix; the array ends at a sentinel. See
113
+ # SentinelSize for the strategies and the invariants you must uphold.
114
+ #
115
+ # When the count is a prefix and there are not enough bytes left to read
116
+ # it, the decoder yields an empty array rather than failing. That is
117
+ # deliberate: it lets a program append a collection to an existing
118
+ # account layout and still decode accounts written before the change.
119
+ # Formats that cannot accept that leniency - borsh, for one, requires the
120
+ # prefix to be present - can pass +require_size_prefix: true+ to make a
121
+ # truncated buffer raise instead. The option has no effect on the other
122
+ # strategies, none of which carries a prefix.
123
+ #
124
+ # +description+ names the codec in errors; it defaults to +'array'+.
125
+ sig do
126
+ params(
127
+ element_codec: Codec,
128
+ size: T.nilable(T.any(Integer, Symbol, Codec, SentinelSize)),
129
+ require_size_prefix: T::Boolean,
130
+ description: T.nilable(String)
131
+ ).returns(Codec)
132
+ end
133
+ def array_codec(element_codec, size: nil, require_size_prefix: false, description: nil)
134
+ description ||= 'array'
135
+ case size
136
+ when nil then ArrayLikeSize.prefixed(element_codec, Numbers.u32_codec, require_size_prefix)
137
+ when Codec then ArrayLikeSize.prefixed(element_codec, size, require_size_prefix)
138
+ when Integer then ArrayLikeSize.fixed_count(element_codec, size, description)
139
+ when SentinelSize then ArrayLikeSize.sentinel(element_codec, size, description)
140
+ when :remainder then ArrayLikeSize.remainder(element_codec)
84
141
  else
85
- prefix = Numbers.u32_codec
86
- enc = Encoder.new do |values|
87
- arr = T.cast(values, T::Array[T.untyped])
88
- header = prefix.encode(arr.length)
89
- body = arr.map { |v| element_codec.encode(v) }.join.b
90
- header + body
91
- end
92
- dec = Decoder.new do |bytes, offset|
93
- len, prefix_bytes = prefix.decode(bytes, offset: offset)
94
- result = []
95
- consumed = prefix_bytes
96
- len.times do
97
- val, n = element_codec.decode(bytes, offset: offset + consumed)
98
- result << val
99
- consumed += n
100
- end
101
- [result, consumed]
102
- end
103
- Codec.new(enc, dec)
142
+ Kernel.raise ArgumentError,
143
+ "Unknown array size #{size.inspect}; expected nil, a number Codec, an Integer, " \
144
+ ':remainder or a SentinelSize'
104
145
  end
105
146
  end
106
147
 
107
148
  # Encode/decode a Hash.
108
149
  # 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)
150
+ # See +array_codec+ for what +size+ and +require_size_prefix+ do; +size+
151
+ # counts entries, and a SentinelSize is compared at entry (key)
152
+ # boundaries.
153
+ sig do
154
+ params(
155
+ key_codec: Codec,
156
+ value_codec: Codec,
157
+ size: T.nilable(T.any(Integer, Symbol, Codec, SentinelSize)),
158
+ require_size_prefix: T::Boolean
159
+ ).returns(Codec)
160
+ end
161
+ def map_codec(key_codec, value_codec, size: nil, require_size_prefix: false)
111
162
  pair_codec = tuple_codec([key_codec, value_codec])
112
- array_codec(pair_codec, size: size).transform_decoder do |pairs|
163
+ array_codec(pair_codec, size: size, require_size_prefix: require_size_prefix).transform_decoder do |pairs|
113
164
  pairs.each_with_object({}) { |(k, v), h| h[k] = v }
114
165
  end.transform_encoder do |hash|
115
166
  T.cast(hash, T::Hash[T.untyped, T.untyped]).map { |k, v| [k, v] }
@@ -117,9 +168,16 @@ module Solana::Ruby::Kit
117
168
  end
118
169
 
119
170
  # 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)
171
+ # See +array_codec+ for what +size+ and +require_size_prefix+ do.
172
+ sig do
173
+ params(
174
+ element_codec: Codec,
175
+ size: T.nilable(T.any(Integer, Symbol, Codec, SentinelSize)),
176
+ require_size_prefix: T::Boolean
177
+ ).returns(Codec)
178
+ end
179
+ def set_codec(element_codec, size: nil, require_size_prefix: false)
180
+ array_codec(element_codec, size: size, require_size_prefix: require_size_prefix)
123
181
  .transform_encoder { |s| T.cast(s, T::Set[T.untyped]).to_a }
124
182
  .transform_decoder { |arr| Set.new(arr) }
125
183
  end
@@ -177,5 +235,146 @@ module Solana::Ruby::Kit
177
235
  Codec.new(enc, dec)
178
236
  end
179
237
  end
238
+
239
+ # The per-strategy builders behind +array_codec+ (and so +map_codec+ and
240
+ # +set_codec+). Call those instead. Kept out of DataStructures on purpose:
241
+ # everything there is re-exported as a public +Codecs.*+ helper, and these
242
+ # are an implementation detail.
243
+ module ArrayLikeSize
244
+ extend T::Sig
245
+ extend self
246
+
247
+ # A count prefix written and read with +prefix+.
248
+ sig { params(element_codec: Codec, prefix: Codec, require_size_prefix: T::Boolean).returns(Codec) }
249
+ def prefixed(element_codec, prefix, require_size_prefix)
250
+ enc = Encoder.new do |values|
251
+ arr = T.cast(values, T::Array[T.untyped])
252
+ prefix.encode(arr.length) + arr.map { |v| element_codec.encode(v) }.join.b
253
+ end
254
+ dec = Decoder.new do |bytes, offset|
255
+ # A fixed-size prefix needs all its bytes; a variable-size one (e.g.
256
+ # compact_u16) needs at least one, and raises for itself if the rest
257
+ # are missing.
258
+ prefix_size = prefix.fixed_size || 1
259
+ remaining = [bytes.bytesize - offset, 0].max
260
+ if remaining < prefix_size
261
+ # The prefix is missing or truncated. By default that decodes to an
262
+ # empty collection having consumed nothing; under
263
+ # +require_size_prefix+ it is an error.
264
+ Kernel.raise SolanaError.new(
265
+ SolanaError::CODECS__INVALID_BYTE_LENGTH,
266
+ { expected: prefix_size, actual: remaining }
267
+ ) if require_size_prefix
268
+
269
+ next [[], 0]
270
+ end
271
+ count, consumed = prefix.decode(bytes, offset: offset)
272
+ result = Array.new(Kernel.Integer(count)) do
273
+ val, n = element_codec.decode(bytes, offset: offset + consumed)
274
+ consumed += n
275
+ val
276
+ end
277
+ [result, consumed]
278
+ end
279
+ Codec.new(enc, dec)
280
+ end
281
+
282
+ # Exactly +count+ items and no prefix. Fixed-size when the items are - and
283
+ # always for a count of zero, whatever the item.
284
+ sig { params(element_codec: Codec, count: Integer, description: String).returns(Codec) }
285
+ def fixed_count(element_codec, count, description)
286
+ item_size = element_codec.fixed_size
287
+ fixed = count.zero? ? 0 : (item_size && count * item_size)
288
+ enc = Encoder.new(fixed_size: fixed) do |values|
289
+ arr = T.cast(values, T::Array[T.untyped])
290
+ unless arr.length == count
291
+ Kernel.raise SolanaError.new(
292
+ SolanaError::CODECS__INVALID_NUMBER_OF_ITEMS,
293
+ { codec_description: description, expected: count, actual: arr.length }
294
+ )
295
+ end
296
+ arr.map { |v| element_codec.encode(v) }.join.b
297
+ end
298
+ dec = Decoder.new(fixed_size: fixed) do |bytes, offset|
299
+ consumed = 0
300
+ result = Array.new(count) do
301
+ val, n = element_codec.decode(bytes, offset: offset + consumed)
302
+ consumed += n
303
+ val
304
+ end
305
+ [result, consumed]
306
+ end
307
+ Codec.new(enc, dec)
308
+ end
309
+
310
+ # No prefix; decoding reads items until the bytes run out.
311
+ sig { params(element_codec: Codec).returns(Codec) }
312
+ def remainder(element_codec)
313
+ enc = Encoder.new do |values|
314
+ T.cast(values, T::Array[T.untyped]).map { |v| element_codec.encode(v) }.join.b
315
+ end
316
+ dec = Decoder.new do |bytes, offset|
317
+ result = []
318
+ consumed = 0
319
+ while offset + consumed < bytes.bytesize
320
+ val, n = element_codec.decode(bytes, offset: offset + consumed)
321
+ # An item that consumes nothing would never reach the end.
322
+ Kernel.raise ArgumentError, 'a :remainder array item decoded zero bytes' if n.zero?
323
+
324
+ result << val
325
+ consumed += n
326
+ end
327
+ [result, consumed]
328
+ end
329
+ Codec.new(enc, dec)
330
+ end
331
+
332
+ # Ends at a sentinel instead of carrying a count. Always variable-size:
333
+ # the item count is only known by reading up to the sentinel.
334
+ sig { params(element_codec: Codec, size: SentinelSize, description: String).returns(Codec) }
335
+ def sentinel(element_codec, size, description)
336
+ sentinel = size.sentinel.b
337
+ strategy = size.strategy
338
+ # Raised at construction, as upstream does for both the encoder and the
339
+ # decoder: an empty sentinel matches everywhere and delimits nothing.
340
+ Kernel.raise SolanaError.new(SolanaError::CODECS__SENTINEL_MUST_NOT_BE_EMPTY) if sentinel.empty?
341
+ unless SentinelSize::STRATEGIES.include?(strategy)
342
+ Kernel.raise ArgumentError,
343
+ "Unknown sentinel strategy #{strategy.inspect}; expected one of #{SentinelSize::STRATEGIES.inspect}"
344
+ end
345
+
346
+ enc = Encoder.new do |values|
347
+ body = T.cast(values, T::Array[T.untyped]).map { |v| element_codec.encode(v) }.join.b
348
+ strategy == :omitted ? body : body + sentinel
349
+ end
350
+ dec = Decoder.new do |bytes, offset|
351
+ result = []
352
+ consumed = 0
353
+ Kernel.loop do
354
+ position = offset + consumed
355
+ if position + sentinel.bytesize > bytes.bytesize
356
+ # Not enough bytes remain to hold the sentinel.
357
+ if strategy == :required
358
+ Kernel.raise SolanaError.new(
359
+ SolanaError::CODECS__SENTINEL_MISSING_AT_END_OF_BYTES,
360
+ { codec_description: description, hex_sentinel: sentinel.unpack1('H*'), sentinel: sentinel }
361
+ )
362
+ end
363
+ break
364
+ end
365
+ if Bytes.contains_bytes?(bytes, sentinel, offset: position)
366
+ # The sentinel is present; consume it and stop.
367
+ consumed += sentinel.bytesize
368
+ break
369
+ end
370
+ val, n = element_codec.decode(bytes, offset: position)
371
+ result << val
372
+ consumed += n
373
+ end
374
+ [result, consumed]
375
+ end
376
+ Codec.new(enc, dec)
377
+ end
378
+ end
180
379
  end
181
380
  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 ───────────────────────────────────────────────────────
@@ -201,21 +200,57 @@ module Solana::Ruby::Kit
201
200
  bytes.pack('C*')
202
201
  end
203
202
  dec = Decoder.new do |bytes, offset|
204
- b = bytes.b
205
- n = 0
206
- shift = 0
207
- consumed = 0
208
- Kernel.loop do
209
- byte = b.byteslice(offset + consumed, 1)&.unpack1('C') || 0
210
- consumed += 1
211
- n |= (byte & 0x7F) << shift
212
- shift += 7
213
- break if (byte & 0x80).zero?
203
+ b = bytes.b
204
+ n = 0
205
+ decoded = (1..3).each do |byte_count|
206
+ # The chain must terminate within the bytes that remain; a buffer
207
+ # that ends mid-chain is truncated, not an implicit zero byte.
208
+ remaining = b.bytesize - offset
209
+ if remaining < byte_count
210
+ Kernel.raise SolanaError.new(
211
+ SolanaError::CODECS__INVALID_BYTE_LENGTH,
212
+ { codec_description: 'shortU16', expected: byte_count, actual: [remaining, 0].max }
213
+ )
214
+ end
215
+
216
+ byte = T.must(b.getbyte(offset + byte_count - 1))
217
+ n |= (byte & 0x7F) << ((byte_count - 1) * 7)
218
+ next unless (byte & 0x80).zero?
219
+
220
+ Numbers.assert_short_u16_in_range(n)
221
+ break [n, byte_count]
214
222
  end
215
- [n, consumed]
223
+ # `each` only returns its range when no byte terminated the chain.
224
+ next decoded if decoded.is_a?(Array)
225
+
226
+ Numbers.raise_short_u16_too_long
216
227
  end
217
228
  Codec.new(enc, dec)
218
229
  end
230
+
231
+ # Three terminated shortU16 bytes can hold up to 2^21 - 1, but only the
232
+ # u16 domain is valid. Shared with the wire-format readers in
233
+ # WalletStandard and TransactionIntrospection, which decode shortU16
234
+ # inline so they can raise their own truncation errors.
235
+ sig { params(value: Integer).void }
236
+ def assert_short_u16_in_range(value)
237
+ return if value <= 0xFFFF
238
+
239
+ Kernel.raise SolanaError.new(
240
+ SolanaError::CODECS__NUMBER_OUT_OF_RANGE,
241
+ { codec_description: 'shortU16', min: 0, max: 0xFFFF, value: value }
242
+ )
243
+ end
244
+
245
+ # Raised when all three shortU16 bytes carry a continuation bit: the
246
+ # encoding would need a fourth byte, which the format does not allow.
247
+ sig { returns(T.noreturn) }
248
+ def raise_short_u16_too_long
249
+ Kernel.raise SolanaError.new(
250
+ SolanaError::CODECS__INVALID_BYTE_LENGTH,
251
+ { codec_description: 'shortU16', expected: 3, actual: 4 }
252
+ )
253
+ end
219
254
  end
220
255
  end
221
256
  end