solana-ruby-kit 8.3.0 → 8.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c281fe52274deb9bbf493d3848b3bcb64ecc42b11e4acc6b727ef5e301dcb252
4
- data.tar.gz: 6fefa37ee1da4bc377bc58f609adad46c2940b3377a9333168df0f6ff83cef6c
3
+ metadata.gz: 3067fc0036448f0aef241f14f4e0b95f0faf6d6cedd4ed8aae5206d6e3169e28
4
+ data.tar.gz: a689c349c2e41d519b6e06814319d9760b2816bf59f48109d8d839790c4e6312
5
5
  SHA512:
6
- metadata.gz: ba02f18c40de9b8b9ab544551ac477e94b1f4dcd4b177e8d513680b3d406830991acb770f4f427b5e9d4ca4abaf0cdda28d5849d649e6e50037104fe653390a7
7
- data.tar.gz: 5ccc70a501ac546deb6520394b9efedc8ddbd7b2278318ab0fdf3944352e9a66ac06d66bd8b8288b1bf4b10a9cb395266d23f3feec3d7708741421a83033af9d
6
+ metadata.gz: a02f7ce5743af32d1d6401f2023fc7293e33abfe1c0f495ec91f00a7ca4399e6fd654c7baa3f3c966ed34d6495514607432315323fb3425f294e8bf1713035ae
7
+ data.tar.gz: bfd06cf91f1fc1652cb5a578b822595c5f976537561fddbad22e04e6dc5567f1284b211b4e41be6472426005f3f4b0197c486fe5d87a046e2dac8989e30acb61
data/README.md CHANGED
@@ -133,6 +133,9 @@ kp = Keys.create_key_pair_from_private_key_bytes(seed_bytes)
133
133
  # Sign / verify
134
134
  sig = Keys.sign_bytes(kp.signing_key, data)
135
135
  ok = Keys.verify_signature(kp.verify_key, sig, data)
136
+ # verify_signature is strict: a small-order public key or signature R
137
+ # component returns false (as the Solana runtime does), and passing anything
138
+ # other than an RbNaCl::VerifyKey raises KEYS__VERIFICATION_REQUIRES_EXTRACTABLE_PUBLIC_KEY.
136
139
 
137
140
  # Base58-encode a signature
138
141
  Keys.encode_signature(sig) # => Signature string
@@ -379,12 +382,17 @@ u16.decode("\xe8\x03") # => [1000, 2] # [value, bytes consumed]
379
382
  # Strings
380
383
  utf8 = Codecs.utf8_codec
381
384
  bytes = Codecs.bytes_codec(32)
385
+ # A borsh-style string: a u32 byte length, then that many UTF-8 bytes.
386
+ name = Codecs.add_codec_size_prefix(utf8, u32)
382
387
 
383
388
  # Data structures
384
389
  struct_codec = Codecs.struct_codec([
385
390
  ['amount', u64],
391
+ ['name', name],
386
392
  ['mint', bytes]
387
393
  ])
394
+ struct_codec.decode(struct_codec.encode({ amount: 5, name: 'gold', mint: "\x01".b * 32 }))
395
+ # => [{ amount: 5, name: "gold", mint: "\x01\x01..." }, 48]
388
396
  ```
389
397
 
390
398
  #### UTF-8 options
@@ -412,6 +420,25 @@ Codecs.remove_null_characters("a\x00b") # => "ab"
412
420
  Note that `ignore_bom` follows `TextDecoder`'s confusing spelling: the default,
413
421
  `false`, *strips* the mark; `true` keeps it.
414
422
 
423
+ #### Collection sizes
424
+
425
+ `array_codec`, `map_codec` and `set_codec` take a `size:` that sets how the item
426
+ count is stored:
427
+
428
+ ```ruby
429
+ Codecs.array_codec(u8).encode([1, 2, 3]) # => "\x03\x00\x00\x00\x01\x02\x03" (u32 count)
430
+ Codecs.array_codec(u8, size: u8).encode([1, 2, 3]) # => "\x03\x01\x02\x03" (any number codec as the count)
431
+ Codecs.array_codec(u8, size: Codecs.compact_u16_codec).encode([1]) # => "\x01\x01" (Solana's shortU16)
432
+ Codecs.array_codec(u8, size: 3).encode([1, 2, 3]) # => "\x01\x02\x03" (fixed count, no prefix)
433
+ Codecs.array_codec(u8, size: 3).encode([1, 2]) # raises SolanaError (wrong number of items)
434
+ Codecs.array_codec(u8, size: :remainder).encode([1, 2, 3]) # => "\x01\x02\x03" (no prefix...)
435
+ Codecs.array_codec(u8, size: :remainder).decode("\x01\x02\x03".b) # => [[1, 2, 3], 3] (...reads to the end)
436
+ ```
437
+
438
+ A `:remainder` array must be the last thing in the buffer. Upstream spells it
439
+ `'remainder'`; Ruby uses the Symbol. The fifth option, a `Codecs::SentinelSize`,
440
+ is described below.
441
+
415
442
  #### Requiring a size prefix
416
443
 
417
444
  A prefixed collection decodes an exhausted buffer to an empty collection rather
@@ -424,8 +451,39 @@ Codecs.array_codec(u8).decode(''.b) # => [[], 0]
424
451
  Codecs.array_codec(u8, require_size_prefix: true).decode(''.b) # raises SolanaError
425
452
  ```
426
453
 
427
- `map_codec` and `set_codec` take the same option. It has no effect on
428
- fixed-count collections, which carry no prefix.
454
+ `map_codec` and `set_codec` take the same option, and it applies to a custom
455
+ count codec too. It has no effect on fixed-count, `:remainder` or
456
+ sentinel-terminated collections, none of which carries a prefix.
457
+
458
+ #### Sentinel-terminated collections
459
+
460
+ Instead of a length prefix, `size:` can be a `Codecs::SentinelSize`: the
461
+ collection ends where the bytes at the next item position match the sentinel.
462
+ It is compared at item boundaries only, so its bytes may appear *inside* an item.
463
+
464
+ ```ruby
465
+ zero = Codecs::SentinelSize.new(sentinel: "\x00".b)
466
+ list = Codecs.array_codec(u8, size: zero)
467
+ list.encode([1, 2, 3]) # => "\x01\x02\x03\x00"
468
+ list.decode("\x01\x02\x03\x00".b) # => [[1, 2, 3], 4]
469
+ list.decode("\x01\x02".b) # raises SolanaError (sentinel missing)
470
+
471
+ # :optional still writes the sentinel but tolerates its absence on decode;
472
+ # :omitted never writes it. Both stop at the end of the bytes.
473
+ lenient = Codecs.array_codec(u8, size: Codecs::SentinelSize.new(sentinel: "\x00".b, strategy: :optional))
474
+ lenient.decode("\x01\x02".b) # => [[1, 2], 2]
475
+ ```
476
+
477
+ `map_codec` and `set_codec` accept the same `size:`. The codec does **not**
478
+ check two invariants, so it is up to you to make sure they hold. First, no item
479
+ may *begin* with the sentinel's bytes, or decoding stops early at that item.
480
+ Second, under `:optional` / `:omitted` the sentinel must be no wider than the
481
+ smallest item, or a short trailing item is silently dropped.
482
+
483
+ An item codec with a fixed size of zero (e.g. `struct_codec([])`) can never
484
+ reach a sentinel or the end of the bytes, so pairing one with a `SentinelSize`
485
+ or `:remainder` raises `CODECS__UNEXPECTED_ZERO_FIXED_SIZE_ITEM_FOR_ARRAY_LIKE_SIZE_STRATEGY`
486
+ when the codec is built. Explicit counts and prefixes still allow it.
429
487
 
430
488
  #### Tap combinators
431
489
 
@@ -745,6 +803,38 @@ Plans.get_linear_message_packer_instruction_plan(
745
803
  total_length: data.bytesize,
746
804
  get_instruction: ->(offset, length) { build_write_ix(offset, data[offset, length]) }
747
805
  )
806
+
807
+ # ── 5. Custom message packers ────────────────────────────────────────────────
808
+ # A custom packer enforces the same limits the built-in ones do, and can refuse a
809
+ # message for a reason of its own with MESSAGE_REJECTED_BY_PACKER. The planner
810
+ # treats all three errors as "try another message"
811
+ # (Plans.message_packer_error_that_requires_new_candidate?).
812
+ one_per_message = Plans::MessagePackerInstructionPlan.new(
813
+ get_message_packer: -> {
814
+ remaining = [ix1, ix2]
815
+ Plans::MessagePacker.new(
816
+ done_proc: -> { remaining.empty? },
817
+ pack_proc: ->(message, max_instructions) {
818
+ max = Plans.resolve_max_instructions_per_transaction(max_instructions)
819
+ Plans.assert_max_instructions_per_transaction(message.instructions.length + 1, max)
820
+ unless message.instructions.empty?
821
+ raise Kit::SolanaError.new(
822
+ Kit::SolanaError::INSTRUCTION_PLANS__MESSAGE_REJECTED_BY_PACKER,
823
+ { reason: 'these instructions must each have a transaction to themselves' }
824
+ )
825
+ end
826
+ next_message = Kit::TransactionMessages.append_instructions(message, [remaining.first])
827
+ Plans.assert_message_can_accommodate_size(
828
+ current_size: Kit::Transactions.get_transaction_message_size(message),
829
+ next_size: Kit::Transactions.get_transaction_message_size(next_message),
830
+ size_limit: Kit::Transactions::TRANSACTION_SIZE_LIMIT
831
+ )
832
+ remaining.shift
833
+ next_message
834
+ }
835
+ )
836
+ }
837
+ )
748
838
  ```
749
839
 
750
840
  ### `Solana::Ruby::Kit::WalletStandard` — `@solana/wallet-standard`
@@ -88,17 +88,33 @@ module Solana::Ruby::Kit
88
88
 
89
89
  # Prefix encoded data with its byte length using +prefix_codec+
90
90
  # (typically a u32 little-endian codec).
91
+ #
92
+ # On decode the inner codec sees exactly the prefixed number of bytes, as
93
+ # upstream's `addDecoderSizePrefix` does - so a variable-size inner codec
94
+ # such as +utf8_codec+ stops at its own end instead of reading the rest of
95
+ # the buffer. Fewer bytes than the prefix promises raise
96
+ # +CODECS__INVALID_BYTE_LENGTH+.
91
97
  sig { params(codec: Codec, prefix_codec: Codec).returns(Codec) }
92
98
  def add_codec_size_prefix(codec, prefix_codec)
93
- enc = Encoder.new do |value|
99
+ inner_size = codec.fixed_size
100
+ fixed = inner_size && prefix_codec.fixed_size && (inner_size + T.must(prefix_codec.fixed_size))
101
+ enc = Encoder.new(fixed_size: fixed) do |value|
94
102
  data = codec.encode(value)
95
103
  prefix = prefix_codec.encode(data.bytesize)
96
104
  prefix + data
97
105
  end
98
- dec = Decoder.new do |bytes, offset|
106
+ dec = Decoder.new(fixed_size: fixed) do |bytes, offset|
99
107
  len, prefix_size = prefix_codec.decode(bytes, offset: offset)
100
- value, data_size = codec.decode(bytes, offset: offset + prefix_size)
101
- [value, prefix_size + data_size]
108
+ len = Kernel.Integer(len)
109
+ data = bytes.byteslice(offset + prefix_size, len) || ''.b
110
+ if data.bytesize < len
111
+ Kernel.raise SolanaError.new(
112
+ SolanaError::CODECS__INVALID_BYTE_LENGTH,
113
+ { codec_description: 'addDecoderSizePrefix', expected: len, actual: data.bytesize }
114
+ )
115
+ end
116
+ value, = codec.decode(data)
117
+ [value, prefix_size + len]
102
118
  end
103
119
  Codec.new(enc, dec)
104
120
  end
@@ -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,85 +98,64 @@ 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).
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`:
66
104
  #
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
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
70
118
  # account layout and still decode accounts written before the change.
71
119
  # Formats that cannot accept that leniency - borsh, for one, requires the
72
120
  # 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.
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'+.
75
125
  sig do
76
126
  params(
77
127
  element_codec: Codec,
78
- size: T.nilable(Integer),
79
- require_size_prefix: T::Boolean
128
+ size: T.nilable(T.any(Integer, Symbol, Codec, SentinelSize)),
129
+ require_size_prefix: T::Boolean,
130
+ description: T.nilable(String)
80
131
  ).returns(Codec)
81
132
  end
82
- def array_codec(element_codec, size: nil, require_size_prefix: false)
83
- if size
84
- fixed = element_codec.fixed_size ? size * T.must(element_codec.fixed_size) : nil
85
- enc = Encoder.new(fixed_size: fixed) do |values|
86
- T.cast(values, T::Array[T.untyped]).map { |v| element_codec.encode(v) }.join.b
87
- end
88
- dec = Decoder.new(fixed_size: fixed) do |bytes, offset|
89
- result = []
90
- consumed = 0
91
- size.times do
92
- val, n = element_codec.decode(bytes, offset: offset + consumed)
93
- result << val
94
- consumed += n
95
- end
96
- [result, consumed]
97
- end
98
- Codec.new(enc, dec)
133
+ def array_codec(element_codec, size: nil, require_size_prefix: false, description: nil)
134
+ description ||= 'array'
135
+ ArrayLikeSize.assert_item_codec_supports_size_strategy(description, size, element_codec.fixed_size)
136
+ case size
137
+ when nil then ArrayLikeSize.prefixed(element_codec, Numbers.u32_codec, require_size_prefix)
138
+ when Codec then ArrayLikeSize.prefixed(element_codec, size, require_size_prefix)
139
+ when Integer then ArrayLikeSize.fixed_count(element_codec, size, description)
140
+ when SentinelSize then ArrayLikeSize.sentinel(element_codec, size, description)
141
+ when :remainder then ArrayLikeSize.remainder(element_codec)
99
142
  else
100
- prefix = Numbers.u32_codec
101
- enc = Encoder.new do |values|
102
- arr = T.cast(values, T::Array[T.untyped])
103
- header = prefix.encode(arr.length)
104
- body = arr.map { |v| element_codec.encode(v) }.join.b
105
- header + body
106
- end
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
121
- len, prefix_bytes = prefix.decode(bytes, offset: offset)
122
- result = []
123
- consumed = prefix_bytes
124
- len.times do
125
- val, n = element_codec.decode(bytes, offset: offset + consumed)
126
- result << val
127
- consumed += n
128
- end
129
- [result, consumed]
130
- end
131
- Codec.new(enc, dec)
143
+ Kernel.raise ArgumentError,
144
+ "Unknown array size #{size.inspect}; expected nil, a number Codec, an Integer, " \
145
+ ':remainder or a SentinelSize'
132
146
  end
133
147
  end
134
148
 
135
149
  # Encode/decode a Hash.
136
150
  # Encoded as: [length prefix] + [key, value, key, value, ...]
137
- # See +array_codec+ for what +require_size_prefix+ does.
151
+ # See +array_codec+ for what +size+ and +require_size_prefix+ do; +size+
152
+ # counts entries, and a SentinelSize is compared at entry (key)
153
+ # boundaries.
138
154
  sig do
139
155
  params(
140
156
  key_codec: Codec,
141
157
  value_codec: Codec,
142
- size: T.nilable(Integer),
158
+ size: T.nilable(T.any(Integer, Symbol, Codec, SentinelSize)),
143
159
  require_size_prefix: T::Boolean
144
160
  ).returns(Codec)
145
161
  end
@@ -153,11 +169,11 @@ module Solana::Ruby::Kit
153
169
  end
154
170
 
155
171
  # Encode/decode a Set (stored as an array of unique elements).
156
- # See +array_codec+ for what +require_size_prefix+ does.
172
+ # See +array_codec+ for what +size+ and +require_size_prefix+ do.
157
173
  sig do
158
174
  params(
159
175
  element_codec: Codec,
160
- size: T.nilable(Integer),
176
+ size: T.nilable(T.any(Integer, Symbol, Codec, SentinelSize)),
161
177
  require_size_prefix: T::Boolean
162
178
  ).returns(Codec)
163
179
  end
@@ -220,5 +236,160 @@ module Solana::Ruby::Kit
220
236
  Codec.new(enc, dec)
221
237
  end
222
238
  end
239
+
240
+ # The per-strategy builders behind +array_codec+ (and so +map_codec+ and
241
+ # +set_codec+). Call those instead. Kept out of DataStructures on purpose:
242
+ # everything there is re-exported as a public +Codecs.*+ helper, and these
243
+ # are an implementation detail.
244
+ module ArrayLikeSize
245
+ extend T::Sig
246
+ extend self
247
+
248
+ # Raises when a zero-byte item codec is paired with a strategy that relies
249
+ # on items consuming bytes to make progress: a sentinel could never be
250
+ # reached, nor could the end of the bytes under +:remainder+.
251
+ sig { params(description: String, size: T.untyped, item_fixed_size: T.nilable(Integer)).void }
252
+ def assert_item_codec_supports_size_strategy(description, size, item_fixed_size)
253
+ return unless item_fixed_size&.zero?
254
+ return unless size == :remainder || size.is_a?(SentinelSize)
255
+
256
+ Kernel.raise SolanaError.new(
257
+ SolanaError::CODECS__UNEXPECTED_ZERO_FIXED_SIZE_ITEM_FOR_ARRAY_LIKE_SIZE_STRATEGY,
258
+ { codec_description: description, size_strategy: size == :remainder ? 'remainder' : 'sentinel' }
259
+ )
260
+ end
261
+
262
+ # A count prefix written and read with +prefix+.
263
+ sig { params(element_codec: Codec, prefix: Codec, require_size_prefix: T::Boolean).returns(Codec) }
264
+ def prefixed(element_codec, prefix, require_size_prefix)
265
+ enc = Encoder.new do |values|
266
+ arr = T.cast(values, T::Array[T.untyped])
267
+ prefix.encode(arr.length) + arr.map { |v| element_codec.encode(v) }.join.b
268
+ end
269
+ dec = Decoder.new do |bytes, offset|
270
+ # A fixed-size prefix needs all its bytes; a variable-size one (e.g.
271
+ # compact_u16) needs at least one, and raises for itself if the rest
272
+ # are missing.
273
+ prefix_size = prefix.fixed_size || 1
274
+ remaining = [bytes.bytesize - offset, 0].max
275
+ if remaining < prefix_size
276
+ # The prefix is missing or truncated. By default that decodes to an
277
+ # empty collection having consumed nothing; under
278
+ # +require_size_prefix+ it is an error.
279
+ Kernel.raise SolanaError.new(
280
+ SolanaError::CODECS__INVALID_BYTE_LENGTH,
281
+ { expected: prefix_size, actual: remaining }
282
+ ) if require_size_prefix
283
+
284
+ next [[], 0]
285
+ end
286
+ count, consumed = prefix.decode(bytes, offset: offset)
287
+ result = Array.new(Kernel.Integer(count)) do
288
+ val, n = element_codec.decode(bytes, offset: offset + consumed)
289
+ consumed += n
290
+ val
291
+ end
292
+ [result, consumed]
293
+ end
294
+ Codec.new(enc, dec)
295
+ end
296
+
297
+ # Exactly +count+ items and no prefix. Fixed-size when the items are - and
298
+ # always for a count of zero, whatever the item.
299
+ sig { params(element_codec: Codec, count: Integer, description: String).returns(Codec) }
300
+ def fixed_count(element_codec, count, description)
301
+ item_size = element_codec.fixed_size
302
+ fixed = count.zero? ? 0 : (item_size && count * item_size)
303
+ enc = Encoder.new(fixed_size: fixed) do |values|
304
+ arr = T.cast(values, T::Array[T.untyped])
305
+ unless arr.length == count
306
+ Kernel.raise SolanaError.new(
307
+ SolanaError::CODECS__INVALID_NUMBER_OF_ITEMS,
308
+ { codec_description: description, expected: count, actual: arr.length }
309
+ )
310
+ end
311
+ arr.map { |v| element_codec.encode(v) }.join.b
312
+ end
313
+ dec = Decoder.new(fixed_size: fixed) do |bytes, offset|
314
+ consumed = 0
315
+ result = Array.new(count) do
316
+ val, n = element_codec.decode(bytes, offset: offset + consumed)
317
+ consumed += n
318
+ val
319
+ end
320
+ [result, consumed]
321
+ end
322
+ Codec.new(enc, dec)
323
+ end
324
+
325
+ # No prefix; decoding reads items until the bytes run out.
326
+ sig { params(element_codec: Codec).returns(Codec) }
327
+ def remainder(element_codec)
328
+ enc = Encoder.new do |values|
329
+ T.cast(values, T::Array[T.untyped]).map { |v| element_codec.encode(v) }.join.b
330
+ end
331
+ dec = Decoder.new do |bytes, offset|
332
+ result = []
333
+ consumed = 0
334
+ while offset + consumed < bytes.bytesize
335
+ val, n = element_codec.decode(bytes, offset: offset + consumed)
336
+ # An item that consumes nothing would never reach the end.
337
+ Kernel.raise ArgumentError, 'a :remainder array item decoded zero bytes' if n.zero?
338
+
339
+ result << val
340
+ consumed += n
341
+ end
342
+ [result, consumed]
343
+ end
344
+ Codec.new(enc, dec)
345
+ end
346
+
347
+ # Ends at a sentinel instead of carrying a count. Always variable-size:
348
+ # the item count is only known by reading up to the sentinel.
349
+ sig { params(element_codec: Codec, size: SentinelSize, description: String).returns(Codec) }
350
+ def sentinel(element_codec, size, description)
351
+ sentinel = size.sentinel.b
352
+ strategy = size.strategy
353
+ # Raised at construction, as upstream does for both the encoder and the
354
+ # decoder: an empty sentinel matches everywhere and delimits nothing.
355
+ Kernel.raise SolanaError.new(SolanaError::CODECS__SENTINEL_MUST_NOT_BE_EMPTY) if sentinel.empty?
356
+ unless SentinelSize::STRATEGIES.include?(strategy)
357
+ Kernel.raise ArgumentError,
358
+ "Unknown sentinel strategy #{strategy.inspect}; expected one of #{SentinelSize::STRATEGIES.inspect}"
359
+ end
360
+
361
+ enc = Encoder.new do |values|
362
+ body = T.cast(values, T::Array[T.untyped]).map { |v| element_codec.encode(v) }.join.b
363
+ strategy == :omitted ? body : body + sentinel
364
+ end
365
+ dec = Decoder.new do |bytes, offset|
366
+ result = []
367
+ consumed = 0
368
+ Kernel.loop do
369
+ position = offset + consumed
370
+ if position + sentinel.bytesize > bytes.bytesize
371
+ # Not enough bytes remain to hold the sentinel.
372
+ if strategy == :required
373
+ Kernel.raise SolanaError.new(
374
+ SolanaError::CODECS__SENTINEL_MISSING_AT_END_OF_BYTES,
375
+ { codec_description: description, hex_sentinel: sentinel.unpack1('H*'), sentinel: sentinel }
376
+ )
377
+ end
378
+ break
379
+ end
380
+ if Bytes.contains_bytes?(bytes, sentinel, offset: position)
381
+ # The sentinel is present; consume it and stop.
382
+ consumed += sentinel.bytesize
383
+ break
384
+ end
385
+ val, n = element_codec.decode(bytes, offset: position)
386
+ result << val
387
+ consumed += n
388
+ end
389
+ [result, consumed]
390
+ end
391
+ Codec.new(enc, dec)
392
+ end
393
+ end
223
394
  end
224
395
  end
@@ -200,21 +200,57 @@ module Solana::Ruby::Kit
200
200
  bytes.pack('C*')
201
201
  end
202
202
  dec = Decoder.new do |bytes, offset|
203
- b = bytes.b
204
- n = 0
205
- shift = 0
206
- consumed = 0
207
- Kernel.loop do
208
- byte = b.byteslice(offset + consumed, 1)&.unpack1('C') || 0
209
- consumed += 1
210
- n |= (byte & 0x7F) << shift
211
- shift += 7
212
- 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]
213
222
  end
214
- [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
215
227
  end
216
228
  Codec.new(enc, dec)
217
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
218
254
  end
219
255
  end
220
256
  end
@@ -191,11 +191,20 @@ module Solana::Ruby::Kit
191
191
  Codec.new(enc, dec)
192
192
  end
193
193
 
194
- # Hex codec — lower-case hex string ↔ binary bytes.
194
+ # Hex codec — lower-case hex string ↔ binary bytes. Spaces are ignored on
195
+ # encode; any other non-hex character raises. An odd-length string keeps
196
+ # its last digit as a final low-nibble byte (+'abc'+ → +0xab 0x0c+), as
197
+ # upstream's base16 encoder does — +pack('H*')+ alone would pad it into
198
+ # the high nibble (+0xc0+).
195
199
  sig { returns(Codec) }
196
200
  def hex_codec
197
201
  enc = Encoder.new do |v|
198
- [v.to_s.tr(' ', '')].pack('H*')
202
+ value = v.to_s.tr(' ', '')
203
+ unless value.match?(/\A\h*\z/)
204
+ Kernel.raise SolanaError.new(SolanaError::CODECS__INVALID_STRING_FOR_HEX_CODEC, { value: v.to_s })
205
+ end
206
+ value = "#{value[0...-1]}0#{value[-1]}" if value.length.odd?
207
+ [value].pack('H*')
199
208
  end
200
209
  dec = Decoder.new do |bytes, offset|
201
210
  remaining = bytes.b.byteslice(offset..) || ''.b
@@ -36,6 +36,7 @@ module Solana::Ruby::Kit
36
36
  KEYS__PUBLIC_KEY_MUST_MATCH_PRIVATE_KEY = :SOLANA_ERROR__KEYS__PUBLIC_KEY_MUST_MATCH_PRIVATE_KEY
37
37
  KEYS__INVALID_SIGNATURE_BYTE_LENGTH = :SOLANA_ERROR__KEYS__INVALID_SIGNATURE_BYTE_LENGTH
38
38
  KEYS__SIGNATURE_STRING_LENGTH_OUT_OF_RANGE = :SOLANA_ERROR__KEYS__SIGNATURE_STRING_LENGTH_OUT_OF_RANGE
39
+ KEYS__VERIFICATION_REQUIRES_EXTRACTABLE_PUBLIC_KEY = :SOLANA_ERROR__KEYS__VERIFICATION_REQUIRES_EXTRACTABLE_PUBLIC_KEY
39
40
 
40
41
  # ── Instructions ──────────────────────────────────────────────────────────
41
42
  INSTRUCTIONS__EXPECTED_TO_HAVE_ACCOUNTS = :SOLANA_ERROR__INSTRUCTIONS__EXPECTED_TO_HAVE_ACCOUNTS
@@ -99,6 +100,13 @@ module Solana::Ruby::Kit
99
100
  CODECS__INVALID_UTF8_BYTES = :SOLANA_ERROR__CODECS__INVALID_UTF8_BYTES
100
101
  # context: { index:, value: }
101
102
  CODECS__INVALID_UTF8_STRING = :SOLANA_ERROR__CODECS__INVALID_UTF8_STRING
103
+ # context: { codec_description:, min:, max:, value: }
104
+ CODECS__NUMBER_OUT_OF_RANGE = :SOLANA_ERROR__CODECS__NUMBER_OUT_OF_RANGE
105
+ # context: { codec_description:, hex_sentinel:, sentinel: }
106
+ CODECS__SENTINEL_MISSING_AT_END_OF_BYTES = :SOLANA_ERROR__CODECS__SENTINEL_MISSING_AT_END_OF_BYTES
107
+ CODECS__SENTINEL_MUST_NOT_BE_EMPTY = :SOLANA_ERROR__CODECS__SENTINEL_MUST_NOT_BE_EMPTY
108
+ # context: { codec_description:, size_strategy: }
109
+ CODECS__UNEXPECTED_ZERO_FIXED_SIZE_ITEM_FOR_ARRAY_LIKE_SIZE_STRATEGY = :SOLANA_ERROR__CODECS__UNEXPECTED_ZERO_FIXED_SIZE_ITEM_FOR_ARRAY_LIKE_SIZE_STRATEGY
102
110
 
103
111
  # ── RPC / JSON-RPC ────────────────────────────────────────────────────────
104
112
  RPC__INTEGER_OVERFLOW_WHILE_SERIALIZING_LARGE_INTEGER = :SOLANA_ERROR__RPC__INTEGER_OVERFLOW_WHILE_SERIALIZING_LARGE_INTEGER
@@ -138,6 +146,8 @@ module Solana::Ruby::Kit
138
146
  INSTRUCTION_PLANS__INVALID_MAX_INSTRUCTIONS_PER_TRANSACTION = :SOLANA_ERROR__INSTRUCTION_PLANS__INVALID_MAX_INSTRUCTIONS_PER_TRANSACTION
139
147
  # context: { max_instructions:, num_instructions: }
140
148
  INSTRUCTION_PLANS__MAX_INSTRUCTIONS_PER_TRANSACTION_EXCEEDED = :SOLANA_ERROR__INSTRUCTION_PLANS__MAX_INSTRUCTIONS_PER_TRANSACTION_EXCEEDED
149
+ # context: { reason: }
150
+ INSTRUCTION_PLANS__MESSAGE_REJECTED_BY_PACKER = :SOLANA_ERROR__INSTRUCTION_PLANS__MESSAGE_REJECTED_BY_PACKER
141
151
 
142
152
  # ── Transaction introspection ────────────────────────────────────────────
143
153
  TRANSACTION_INTROSPECTION__CANNOT_DECODE_JSON_PARSED_TRANSACTION = :SOLANA_ERROR__TRANSACTION_INTROSPECTION__CANNOT_DECODE_JSON_PARSED_TRANSACTION
@@ -193,6 +203,7 @@ module Solana::Ruby::Kit
193
203
  KEYS__PUBLIC_KEY_MUST_MATCH_PRIVATE_KEY => 'The public key does not match the private key',
194
204
  KEYS__INVALID_SIGNATURE_BYTE_LENGTH => 'Signature must be 64 bytes, got %{actual_length}',
195
205
  KEYS__SIGNATURE_STRING_LENGTH_OUT_OF_RANGE => 'Signature string length out of range (64–88 chars), got %{actual_length}',
206
+ KEYS__VERIFICATION_REQUIRES_EXTRACTABLE_PUBLIC_KEY => 'Signatures can only be verified using a public key (an RbNaCl::VerifyKey).',
196
207
 
197
208
  # Instructions
198
209
  INSTRUCTIONS__EXPECTED_TO_HAVE_ACCOUNTS => 'Expected instruction to have accounts',
@@ -237,7 +248,7 @@ module Solana::Ruby::Kit
237
248
  CODECS__EXPECTED_POSITIVE_BYTE_LENGTH => 'Expected a positive byte length, got %{byte_length}',
238
249
  CODECS__ENCODER_DECODER_FIXED_SIZE_MISMATCH => 'Encoder fixed size (%{encoder_size}) does not match decoder fixed size (%{decoder_size})',
239
250
  CODECS__ENCODER_DECODER_MAX_SIZE_MISMATCH => 'Encoder max size (%{encoder_max}) does not match decoder max size (%{decoder_max})',
240
- CODECS__INVALID_NUMBER_OF_ITEMS => 'Expected %{expected} items but got %{actual}',
251
+ CODECS__INVALID_NUMBER_OF_ITEMS => 'Expected [%{codec_description}] to have %{expected} items, got %{actual}.',
241
252
  CODECS__ENUM_DISCRIMINATOR_OUT_OF_RANGE => 'Enum discriminator %{discriminator} is out of range [0, %{max}]',
242
253
  CODECS__UNION_VARIANT_OUT_OF_RANGE => 'Union variant index %{index} is out of range',
243
254
  CODECS__OFFSET_OUT_OF_RANGE => 'Codec offset %{offset} is out of range for byte array of length %{byte_length}',
@@ -250,6 +261,10 @@ module Solana::Ruby::Kit
250
261
  CODECS__FIXED_NULLABLE_CANNOT_WRAP_VARIABLE_SIZE_CODEC => 'A fixed-size nullable codec cannot wrap a variable-size codec',
251
262
  CODECS__INVALID_UTF8_BYTES => 'Invalid UTF-8 byte sequence at offset %{offset}',
252
263
  CODECS__INVALID_UTF8_STRING => 'Invalid UTF-8 string at index %{index}',
264
+ CODECS__NUMBER_OUT_OF_RANGE => 'Codec [%{codec_description}] expected number to be in the range [%{min}, %{max}], got %{value}.',
265
+ CODECS__SENTINEL_MISSING_AT_END_OF_BYTES => 'Codec [%{codec_description}] expected sentinel [%{hex_sentinel}] to terminate the collection, but reached the end of the byte array without it.',
266
+ CODECS__SENTINEL_MUST_NOT_BE_EMPTY => 'The sentinel must not be empty.',
267
+ CODECS__UNEXPECTED_ZERO_FIXED_SIZE_ITEM_FOR_ARRAY_LIKE_SIZE_STRATEGY => 'Codec [%{codec_description}] cannot use the [%{size_strategy}] size strategy: the item codec has a fixed size of zero and consumes no bytes.',
253
268
 
254
269
  # RPC
255
270
  RPC__INTEGER_OVERFLOW_WHILE_SERIALIZING_LARGE_INTEGER => 'Integer overflow while serializing large integer %{value}',
@@ -287,6 +302,7 @@ module Solana::Ruby::Kit
287
302
  INSTRUCTION_PLANS__FAILED_TO_EXECUTE_TRANSACTION_PLAN => 'Failed to execute transaction plan',
288
303
  INSTRUCTION_PLANS__INVALID_MAX_INSTRUCTIONS_PER_TRANSACTION => 'The configured maximum of %{max_instructions} instructions per transaction is invalid. It must be a positive integer no greater than the transaction format limit of %{transaction_instruction_limit} instructions per transaction.',
289
304
  INSTRUCTION_PLANS__MAX_INSTRUCTIONS_PER_TRANSACTION_EXCEEDED => 'Planning this transaction message would require %{num_instructions} instructions, which exceeds the configured maximum of %{max_instructions} instructions per transaction.',
305
+ INSTRUCTION_PLANS__MESSAGE_REJECTED_BY_PACKER => 'The message packer rejected the provided transaction message: %{reason}.',
290
306
 
291
307
  # Transaction introspection
292
308
  TRANSACTION_INTROSPECTION__CANNOT_DECODE_JSON_PARSED_TRANSACTION => "Cannot decode a 'jsonParsed'-encoded getTransaction response; its instructions are pre-parsed and lack raw bytes. Fetch with encoding: 'json' or 'base64' instead.",
@@ -5,7 +5,7 @@ require_relative '../errors'
5
5
  require_relative '../instructions/instruction'
6
6
  require_relative '../transaction_messages/transaction_message'
7
7
  require_relative '../transactions/compiler'
8
- require_relative 'max_instructions'
8
+ require_relative 'message_packer_errors'
9
9
 
10
10
  module Solana::Ruby::Kit
11
11
  module InstructionPlans
@@ -84,6 +84,14 @@ module Solana::Ruby::Kit
84
84
  #
85
85
  # +max_instructions+ caps the number of top-level instructions allowed in the
86
86
  # returned message (defaults to 16; must be a positive integer no greater than 64).
87
+ #
88
+ # A custom packer can enforce those limits with
89
+ # InstructionPlans.resolve_max_instructions_per_transaction,
90
+ # .assert_max_instructions_per_transaction and .assert_message_can_accommodate_size,
91
+ # and may raise INSTRUCTION_PLANS__MESSAGE_REJECTED_BY_PACKER (with a +reason:+) to
92
+ # refuse a message for any other reason - e.g. a constraint specific to the
93
+ # instructions being packed. The planner then tries another message; see
94
+ # InstructionPlans.message_packer_error_that_requires_new_candidate?.
87
95
  sig do
88
96
  params(
89
97
  message: TransactionMessages::TransactionMessage,
@@ -148,8 +156,7 @@ module Solana::Ruby::Kit
148
156
  Kernel.raise SolanaError.new(SolanaError::INSTRUCTION_PLANS__MESSAGE_PACKER_ALREADY_COMPLETE)
149
157
  end
150
158
 
151
- InstructionPlans.assert_valid_max_instructions_per_transaction(max_instructions)
152
- resolved_max = InstructionPlans.resolve_max_instructions(max_instructions)
159
+ resolved_max = InstructionPlans.resolve_max_instructions_per_transaction(max_instructions)
153
160
  InstructionPlans.assert_max_instructions_per_transaction(message.instructions.length + 1, resolved_max)
154
161
 
155
162
  base_ix = get_instruction.call(offset, 0)
@@ -194,8 +201,7 @@ module Solana::Ruby::Kit
194
201
  Kernel.raise SolanaError.new(SolanaError::INSTRUCTION_PLANS__MESSAGE_PACKER_ALREADY_COMPLETE)
195
202
  end
196
203
 
197
- InstructionPlans.assert_valid_max_instructions_per_transaction(max_instructions)
198
- resolved_max = InstructionPlans.resolve_max_instructions(max_instructions)
204
+ resolved_max = InstructionPlans.resolve_max_instructions_per_transaction(max_instructions)
199
205
  InstructionPlans.assert_max_instructions_per_transaction(message.instructions.length + 1, resolved_max)
200
206
 
201
207
  original_size = Transactions.get_transaction_message_size(message)
@@ -212,18 +218,16 @@ module Solana::Ruby::Kit
212
218
  end
213
219
 
214
220
  next_packed = TransactionMessages.append_instructions(packed, [T.must(instructions[i])])
215
- size = Transactions.get_transaction_message_size(next_packed)
216
-
217
- if size > Transactions::TRANSACTION_SIZE_LIMIT
218
- if i == start_idx
219
- Kernel.raise SolanaError.new(
220
- SolanaError::INSTRUCTION_PLANS__MESSAGE_CANNOT_ACCOMMODATE_PLAN,
221
- {
222
- num_bytes_required: size - original_size,
223
- num_free_bytes: Transactions::TRANSACTION_SIZE_LIMIT - original_size
224
- }
225
- )
226
- end
221
+ next_size = Transactions.get_transaction_message_size(next_packed)
222
+ size_limit = Transactions::TRANSACTION_SIZE_LIMIT
223
+
224
+ if i == start_idx
225
+ # The count was already asserted above, so the first instruction can
226
+ # only fail to fit because of the transaction size limit.
227
+ InstructionPlans.assert_message_can_accommodate_size(
228
+ current_size: original_size, next_size: next_size, size_limit: size_limit
229
+ )
230
+ elsif next_size > size_limit
227
231
  idx = i
228
232
  return packed
229
233
  end
@@ -240,7 +244,9 @@ module Solana::Ruby::Kit
240
244
  end
241
245
 
242
246
  # Creates a MessagePackerInstructionPlan that splits +total_size+ bytes into
243
- # chunks of REALLOC_LIMIT (10,240) bytes, calling +get_instruction.(size)+.
247
+ # chunks of at most REALLOC_LIMIT (10,240) bytes and creates one instruction
248
+ # per chunk, calling +get_instruction.(size)+. A +total_size+ that is an exact
249
+ # multiple of the limit yields only full-sized chunks, and zero yields none.
244
250
  # Mirrors `getReallocMessagePackerInstructionPlan({ getInstruction, totalSize })`.
245
251
  sig do
246
252
  params(
@@ -249,13 +255,12 @@ module Solana::Ruby::Kit
249
255
  ).returns(MessagePackerInstructionPlan)
250
256
  end
251
257
  def get_realloc_message_packer_instruction_plan(total_size:, get_instruction:)
252
- realloc_limit = 10_240
253
- num_instructions = (total_size.to_f / realloc_limit).ceil
254
- last_size = total_size % realloc_limit
255
-
256
- instructions = num_instructions.times.map do |i|
257
- chunk = (i == num_instructions - 1) ? last_size : realloc_limit
258
- get_instruction.call(chunk)
258
+ realloc_limit = 10_240
259
+ instructions = []
260
+ remaining = total_size
261
+ while remaining.positive?
262
+ instructions << get_instruction.call([realloc_limit, remaining].min)
263
+ remaining -= realloc_limit
259
264
  end
260
265
 
261
266
  get_message_packer_instruction_plan_from_instructions(instructions)
@@ -0,0 +1,131 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../errors'
5
+
6
+ module Solana::Ruby::Kit
7
+ module InstructionPlans
8
+ extend T::Sig
9
+
10
+ # The default maximum number of top-level instructions per planned transaction message.
11
+ #
12
+ # Intentionally lower than the transaction format's instruction limit to leave headroom
13
+ # for inner instructions (CPIs), which are not visible at planning time.
14
+ DEFAULT_MAX_INSTRUCTIONS_PER_TRANSACTION = T.let(16, Integer)
15
+
16
+ # The hard maximum number of top-level instructions the transaction format can encode.
17
+ TRANSACTION_INSTRUCTION_LIMIT = T.let(64, Integer)
18
+
19
+ # The error codes the transaction planner treats as "try another message": the message
20
+ # holds too many instructions, would be too large, or a message packer refused it for a
21
+ # custom reason. Upstream's list also has four `TRANSACTION__TOO_MANY_*` compile errors;
22
+ # Ruby's compiler raises none of them, so they are not listed.
23
+ MESSAGE_PACKER_ERRORS_THAT_REQUIRE_NEW_CANDIDATE = T.let(
24
+ [
25
+ SolanaError::INSTRUCTION_PLANS__MAX_INSTRUCTIONS_PER_TRANSACTION_EXCEEDED,
26
+ SolanaError::INSTRUCTION_PLANS__MESSAGE_CANNOT_ACCOMMODATE_PLAN,
27
+ SolanaError::INSTRUCTION_PLANS__MESSAGE_REJECTED_BY_PACKER
28
+ ].freeze,
29
+ T::Array[Symbol]
30
+ )
31
+
32
+ extend self
33
+
34
+ # Resolves the maximum number of instructions a message packer may put in a transaction
35
+ # message: the default of 16 when +max_instructions+ is nil, otherwise the given value,
36
+ # which must be a positive integer no greater than TRANSACTION_INSTRUCTION_LIMIT.
37
+ #
38
+ # This is typically the first thing a custom MessagePacker does with the
39
+ # +max_instructions+ it receives in +pack_message_to_capacity+:
40
+ #
41
+ # pack_proc: ->(message, max_instructions) {
42
+ # max = InstructionPlans.resolve_max_instructions_per_transaction(max_instructions)
43
+ # InstructionPlans.assert_max_instructions_per_transaction(message.instructions.length + 1, max)
44
+ # # ...
45
+ # }
46
+ #
47
+ # Raises INSTRUCTION_PLANS__INVALID_MAX_INSTRUCTIONS_PER_TRANSACTION for an out-of-range value.
48
+ # Mirrors `resolveMaxInstructionsPerTransaction(maxInstructions)`.
49
+ sig { params(max_instructions: T.nilable(Integer)).returns(Integer) }
50
+ def resolve_max_instructions_per_transaction(max_instructions = nil)
51
+ return DEFAULT_MAX_INSTRUCTIONS_PER_TRANSACTION if max_instructions.nil?
52
+
53
+ if max_instructions <= 0 || max_instructions > TRANSACTION_INSTRUCTION_LIMIT
54
+ Kernel.raise SolanaError.new(
55
+ SolanaError::INSTRUCTION_PLANS__INVALID_MAX_INSTRUCTIONS_PER_TRANSACTION,
56
+ {
57
+ max_instructions: max_instructions,
58
+ transaction_instruction_limit: TRANSACTION_INSTRUCTION_LIMIT
59
+ }
60
+ )
61
+ end
62
+
63
+ max_instructions
64
+ end
65
+
66
+ # Raises if a message holding +num_instructions+ instructions would exceed
67
+ # +max_instructions+. Use it in a custom MessagePacker before appending an instruction -
68
+ # passing the count the message would have after the append - so the planner knows to
69
+ # pack that instruction into another message.
70
+ #
71
+ # Raises INSTRUCTION_PLANS__MAX_INSTRUCTIONS_PER_TRANSACTION_EXCEEDED.
72
+ # Mirrors `assertMaxInstructionsPerTransaction(numInstructions, maxInstructions)`.
73
+ sig { params(num_instructions: Integer, max_instructions: Integer).void }
74
+ def assert_max_instructions_per_transaction(num_instructions, max_instructions)
75
+ return if num_instructions <= max_instructions
76
+
77
+ Kernel.raise SolanaError.new(
78
+ SolanaError::INSTRUCTION_PLANS__MAX_INSTRUCTIONS_PER_TRANSACTION_EXCEEDED,
79
+ { max_instructions: max_instructions, num_instructions: num_instructions }
80
+ )
81
+ end
82
+
83
+ # Raises if a message cannot grow from +current_size+ to +next_size+ bytes without
84
+ # exceeding +size_limit+. Use it in a custom MessagePacker after appending the next
85
+ # instruction(s), so the planner knows to pack them into another message. It takes sizes
86
+ # rather than messages so callers who already computed them do not pay for it twice:
87
+ #
88
+ # next_message = TransactionMessages.append_instructions(message, [ix])
89
+ # InstructionPlans.assert_message_can_accommodate_size(
90
+ # current_size: Transactions.get_transaction_message_size(message),
91
+ # next_size: Transactions.get_transaction_message_size(next_message),
92
+ # size_limit: Transactions::TRANSACTION_SIZE_LIMIT
93
+ # )
94
+ #
95
+ # Raises INSTRUCTION_PLANS__MESSAGE_CANNOT_ACCOMMODATE_PLAN, reporting how many bytes
96
+ # were required and how many were free.
97
+ # Mirrors `assertMessageCanAccommodateSize({ currentSize, nextSize, sizeLimit })`.
98
+ sig { params(current_size: Integer, next_size: Integer, size_limit: Integer).void }
99
+ def assert_message_can_accommodate_size(current_size:, next_size:, size_limit:)
100
+ return if next_size <= size_limit
101
+
102
+ Kernel.raise SolanaError.new(
103
+ SolanaError::INSTRUCTION_PLANS__MESSAGE_CANNOT_ACCOMMODATE_PLAN,
104
+ { num_bytes_required: next_size - current_size, num_free_bytes: size_limit - current_size }
105
+ )
106
+ end
107
+
108
+ # Whether +error+, raised while packing instructions into a message, means the message
109
+ # cannot take them and a new candidate message is required (see
110
+ # MESSAGE_PACKER_ERRORS_THAT_REQUIRE_NEW_CANDIDATE). Any other error is unexpected and
111
+ # should propagate:
112
+ #
113
+ # begin
114
+ # message = packer.pack_message_to_capacity(message)
115
+ # rescue => e
116
+ # raise unless InstructionPlans.message_packer_error_that_requires_new_candidate?(e)
117
+ # message = packer.pack_message_to_capacity(create_new_message)
118
+ # end
119
+ #
120
+ # A custom packer refuses a message for any other reason by raising
121
+ # INSTRUCTION_PLANS__MESSAGE_REJECTED_BY_PACKER with a +reason:+.
122
+ # Mirrors `isMessagePackerErrorThatRequiresNewCandidate(error)`.
123
+ sig { params(error: T.anything).returns(T::Boolean) }
124
+ def message_packer_error_that_requires_new_candidate?(error)
125
+ case error
126
+ when SolanaError then MESSAGE_PACKER_ERRORS_THAT_REQUIRE_NEW_CANDIDATE.include?(error.code)
127
+ else false
128
+ end
129
+ end
130
+ end
131
+ end
@@ -54,11 +54,11 @@ module Solana::Ruby::Kit
54
54
  config_max_instructions_per_transaction = max_instructions_per_transaction
55
55
 
56
56
  ->(instruction_plan, max_instructions_per_transaction: nil) {
57
- resolved_max = max_instructions_per_transaction || config_max_instructions_per_transaction
58
-
59
57
  # Reject up front any configured maximum the transaction format could never satisfy,
60
58
  # rather than discovering it mid-plan when a message fails to compile.
61
- InstructionPlans.assert_valid_max_instructions_per_transaction(resolved_max)
59
+ resolved_max = InstructionPlans.resolve_max_instructions_per_transaction(
60
+ max_instructions_per_transaction || config_max_instructions_per_transaction
61
+ )
62
62
 
63
63
  mutable = planner_traverse(
64
64
  instruction_plan,
@@ -194,17 +194,14 @@ module Solana::Ruby::Kit
194
194
  if Transactions.get_transaction_message_size(updated) <= Transactions::TRANSACTION_SIZE_LIMIT
195
195
  InstructionPlans.assert_max_instructions_per_transaction(
196
196
  updated.instructions.length,
197
- InstructionPlans.resolve_max_instructions(ctx[:max_instructions_per_transaction])
197
+ ctx[:max_instructions_per_transaction]
198
198
  )
199
199
  candidate[:message] = updated
200
200
  return candidate
201
201
  end
202
202
  rescue SolanaError => e
203
- next if [
204
- SolanaError::INSTRUCTION_PLANS__MESSAGE_CANNOT_ACCOMMODATE_PLAN,
205
- SolanaError::INSTRUCTION_PLANS__MAX_INSTRUCTIONS_PER_TRANSACTION_EXCEEDED
206
- ].include?(e.code)
207
- Kernel.raise
203
+ Kernel.raise unless InstructionPlans.message_packer_error_that_requires_new_candidate?(e)
204
+ # Try the next candidate.
208
205
  end
209
206
  end
210
207
  nil
@@ -228,7 +225,7 @@ module Solana::Ruby::Kit
228
225
 
229
226
  InstructionPlans.assert_max_instructions_per_transaction(
230
227
  updated_msg.instructions.length,
231
- InstructionPlans.resolve_max_instructions(ctx[:max_instructions_per_transaction])
228
+ ctx[:max_instructions_per_transaction]
232
229
  )
233
230
 
234
231
  updated_msg
@@ -253,7 +250,7 @@ module Solana::Ruby::Kit
253
250
  end
254
251
  InstructionPlans.assert_max_instructions_per_transaction(
255
252
  updated.instructions.length,
256
- InstructionPlans.resolve_max_instructions(ctx[:max_instructions_per_transaction])
253
+ ctx[:max_instructions_per_transaction]
257
254
  )
258
255
  updated
259
256
  when :message_packer
@@ -263,7 +260,7 @@ module Solana::Ruby::Kit
263
260
  msg = packer.pack_message_to_capacity(msg, max_instructions: ctx[:max_instructions_per_transaction])
264
261
  InstructionPlans.assert_max_instructions_per_transaction(
265
262
  msg.instructions.length,
266
- InstructionPlans.resolve_max_instructions(ctx[:max_instructions_per_transaction])
263
+ ctx[:max_instructions_per_transaction]
267
264
  )
268
265
  end
269
266
  msg
@@ -3,7 +3,7 @@
3
3
 
4
4
  # Mirrors @solana/instruction-plans.
5
5
  # An InstructionPlan describes operations that may span multiple transactions.
6
- require_relative 'instruction_plans/max_instructions'
6
+ require_relative 'instruction_plans/message_packer_errors'
7
7
  require_relative 'instruction_plans/instruction_plan'
8
8
  require_relative 'instruction_plans/transaction_plan'
9
9
  require_relative 'instruction_plans/transaction_plan_result'
@@ -4,6 +4,7 @@
4
4
  require 'rbnacl'
5
5
  require_relative '../errors'
6
6
  require_relative '../encoding/base58'
7
+ require_relative 'small_order'
7
8
 
8
9
  module Solana::Ruby::Kit
9
10
  module Keys
@@ -144,11 +145,23 @@ module Solana::Ruby::Kit
144
145
  # Mirrors `verifySignature(key, signature, data)` in TypeScript.
145
146
  # Returns false rather than raising on mismatch, matching the TS return type.
146
147
  #
148
+ # Verification is strict: a public key or a signature `R` component that is
149
+ # a point of small order is rejected without consulting RbNaCl, matching the
150
+ # Solana runtime whatever the linked libsodium does itself. Upstream raises
151
+ # for a non-extractable or non-public CryptoKey; an RbNaCl::VerifyKey always
152
+ # exposes its bytes, so here only a non-VerifyKey (e.g. a SigningKey) raises.
153
+ #
147
154
  # @param verify_key [RbNaCl::VerifyKey]
148
155
  # @param sig_bytes [SignatureBytes]
149
156
  # @param data [String] binary string that was signed
150
157
  sig { params(verify_key: T.untyped, sig_bytes: SignatureBytes, data: String).returns(T::Boolean) }
151
158
  def verify_signature(verify_key, sig_bytes, data)
159
+ unless verify_key.is_a?(RbNaCl::VerifyKey)
160
+ Kernel.raise SolanaError.new(SolanaError::KEYS__VERIFICATION_REQUIRES_EXTRACTABLE_PUBLIC_KEY)
161
+ end
162
+ return false if SmallOrder.small_order_point_encoding?(verify_key.to_bytes)
163
+ return false if SmallOrder.small_order_point_encoding?(sig_bytes.value.byteslice(0, 32).to_s)
164
+
152
165
  verify_key.verify(sig_bytes.value, data.b)
153
166
  true
154
167
  rescue RbNaCl::BadSignatureError
@@ -0,0 +1,46 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Solana::Ruby::Kit
5
+ module Keys
6
+ extend T::Sig
7
+
8
+ # Mirrors the private `isSmallOrderPointEncoding` in @solana/keys
9
+ # (`small-order.ts`). An Ed25519 public key or signature `R` that is a point
10
+ # of small order lets trivially forged signatures verify against any data;
11
+ # the Solana runtime rejects them, so `verify_signature` does too.
12
+ module SmallOrder
13
+ extend T::Sig
14
+ extend self
15
+
16
+ P = T.let((1 << 255) - 19, Integer)
17
+
18
+ # The y-coordinates of the eight small-order points (the x sign bit picks
19
+ # between each pair, so it is masked off before the lookup). The two large
20
+ # ones are parsed from Strings because Sorbet rejects bignum literals.
21
+ SMALL_ORDER_Y_COORDINATES = T.let(
22
+ [
23
+ 0,
24
+ 1,
25
+ P - 1,
26
+ Integer('2707385501144840649318225287225658788936804267575313519463743609750303402022'),
27
+ Integer('55188659117513257062467267217118295137698188065244968500265048394206261417927')
28
+ ].freeze,
29
+ T::Array[Integer]
30
+ )
31
+
32
+ # True when +bytes+ (a 32-byte binary String) encodes a point of small
33
+ # order, canonically or not (a y-coordinate >= p is reduced mod p first).
34
+ # Any other length returns false.
35
+ sig { params(bytes: String).returns(T::Boolean) }
36
+ def small_order_point_encoding?(bytes)
37
+ bytes = bytes.b
38
+ return false unless bytes.bytesize == 32
39
+
40
+ last = bytes.getbyte(31).to_i & 0x7f
41
+ y = (bytes.byteslice(0, 31).to_s + last.chr).reverse.unpack1('H*').to_i(16)
42
+ SMALL_ORDER_Y_COORDINATES.include?(y % P)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -105,9 +105,9 @@ module Solana::Ruby::Kit
105
105
  # `Signature` holds a base58 string (that is what `sign_offchain_message` returns),
106
106
  # so it has to be decoded back to the 64 raw bytes RbNaCl expects.
107
107
  sig_bytes = Solana::Ruby::Kit::Encoding::Base58.decode(signature.to_s)
108
- vk.verify(sig_bytes, payload)
109
- true
110
- rescue RbNaCl::BadSignatureError, RbNaCl::LengthError
108
+ # Through Keys.verify_signature for its small-order key/`R` rejection.
109
+ Keys.verify_signature(vk, Keys.signature_bytes(sig_bytes), payload)
110
+ rescue RbNaCl::LengthError, SolanaError
111
111
  false
112
112
  end
113
113
  end
@@ -154,14 +154,15 @@ module Solana::Ruby::Kit
154
154
  sig { params(bytes: String, offset: Integer).returns([Integer, Integer]) }
155
155
  def decode_compact_u16(bytes, offset)
156
156
  value = 0
157
- shift = 0
158
- Kernel.loop do
157
+ (0...3).each do |i|
159
158
  byte, offset = read_byte(bytes, offset)
160
- value |= (byte & 0x7f) << shift
161
- shift += 7
162
- break unless (byte & 0x80) != 0
159
+ value |= (byte & 0x7f) << (i * 7)
160
+ next unless (byte & 0x80).zero?
161
+
162
+ Codecs::Numbers.assert_short_u16_in_range(value)
163
+ return [value, offset]
163
164
  end
164
- [value, offset]
165
+ Codecs::Numbers.raise_short_u16_too_long
165
166
  end
166
167
  private_class_method :decode_compact_u16
167
168
  end
@@ -4,7 +4,7 @@
4
4
  module Solana
5
5
  module Ruby
6
6
  module Kit
7
- VERSION = '8.3.0'
7
+ VERSION = '8.5.0'
8
8
  end
9
9
  end
10
10
  end
@@ -233,8 +233,7 @@ module Solana::Ruby::Kit
233
233
  sig { params(bytes: String, offset: Integer).returns([Integer, Integer]) }
234
234
  def decode_compact_u16(bytes, offset)
235
235
  value = 0
236
- shift = 0
237
- Kernel.loop do
236
+ (0...3).each do |i|
238
237
  byte = bytes.getbyte(offset)
239
238
  if byte.nil?
240
239
  Kernel.raise SolanaError.new(
@@ -243,11 +242,13 @@ module Solana::Ruby::Kit
243
242
  )
244
243
  end
245
244
  offset += 1
246
- value |= (byte & 0x7f) << shift
247
- shift += 7
248
- break unless (byte & 0x80) != 0
245
+ value |= (byte & 0x7f) << (i * 7)
246
+ next unless (byte & 0x80).zero?
247
+
248
+ Codecs::Numbers.assert_short_u16_in_range(value)
249
+ return [value, offset]
249
250
  end
250
- [value, offset]
251
+ Codecs::Numbers.raise_short_u16_too_long
251
252
  end
252
253
  private_class_method :decode_compact_u16
253
254
  end
@@ -7,7 +7,7 @@ Gem::Specification.new do |spec|
7
7
  spec.version = Solana::Ruby::Kit::VERSION
8
8
  spec.authors = ['Paul Zupan, Idhra Inc.']
9
9
  spec.summary = 'Ruby port of the Anza TypeScript SDK (@anza-xyz/kit)'
10
- spec.homepage = 'https://github.com/pzupan/solana-ruby-kit'
10
+ spec.homepage = 'https://github.com/idhra/solana-ruby-kit'
11
11
  spec.license = 'MIT'
12
12
  spec.required_ruby_version = '>= 3.2.0'
13
13
  spec.require_paths = ['lib']
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solana-ruby-kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 8.3.0
4
+ version: 8.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Paul Zupan, Idhra Inc.
@@ -231,7 +231,7 @@ files:
231
231
  - lib/solana/ruby/kit/functional.rb
232
232
  - lib/solana/ruby/kit/instruction_plans.rb
233
233
  - lib/solana/ruby/kit/instruction_plans/instruction_plan.rb
234
- - lib/solana/ruby/kit/instruction_plans/max_instructions.rb
234
+ - lib/solana/ruby/kit/instruction_plans/message_packer_errors.rb
235
235
  - lib/solana/ruby/kit/instruction_plans/plans.rb
236
236
  - lib/solana/ruby/kit/instruction_plans/transaction_plan.rb
237
237
  - lib/solana/ruby/kit/instruction_plans/transaction_plan_executor.rb
@@ -246,6 +246,7 @@ files:
246
246
  - lib/solana/ruby/kit/keys/private_key.rb
247
247
  - lib/solana/ruby/kit/keys/public_key.rb
248
248
  - lib/solana/ruby/kit/keys/signatures.rb
249
+ - lib/solana/ruby/kit/keys/small_order.rb
249
250
  - lib/solana/ruby/kit/offchain_messages.rb
250
251
  - lib/solana/ruby/kit/offchain_messages/codec.rb
251
252
  - lib/solana/ruby/kit/offchain_messages/message.rb
@@ -346,13 +347,13 @@ files:
346
347
  - lib/solana/ruby/kit/version.rb
347
348
  - lib/solana/ruby/kit/wallet_standard.rb
348
349
  - solana-ruby-kit.gemspec
349
- homepage: https://github.com/pzupan/solana-ruby-kit
350
+ homepage: https://github.com/idhra/solana-ruby-kit
350
351
  licenses:
351
352
  - MIT
352
353
  metadata:
353
- source_code_uri: https://github.com/pzupan/solana-ruby-kit/tree/main
354
- documentation_uri: https://github.com/pzupan/solana-ruby-kit/wiki
355
- bug_tracker_uri: https://github.com/pzupan/solana-ruby-kit/issues
354
+ source_code_uri: https://github.com/idhra/solana-ruby-kit/tree/main
355
+ documentation_uri: https://github.com/idhra/solana-ruby-kit/wiki
356
+ bug_tracker_uri: https://github.com/idhra/solana-ruby-kit/issues
356
357
  rdoc_options: []
357
358
  require_paths:
358
359
  - lib
@@ -1,59 +0,0 @@
1
- # typed: strict
2
- # frozen_string_literal: true
3
-
4
- require_relative '../errors'
5
-
6
- module Solana::Ruby::Kit
7
- module InstructionPlans
8
- extend T::Sig
9
-
10
- # The default maximum number of top-level instructions per planned transaction message.
11
- #
12
- # Intentionally lower than the transaction format's instruction limit to leave headroom
13
- # for inner instructions (CPIs), which are not visible at planning time.
14
- DEFAULT_MAX_INSTRUCTIONS_PER_TRANSACTION = T.let(16, Integer)
15
-
16
- # The hard maximum number of top-level instructions the transaction format can encode.
17
- TRANSACTION_INSTRUCTION_LIMIT = T.let(64, Integer)
18
-
19
- extend self
20
-
21
- # Resolves the effective maximum number of instructions allowed in a transaction message,
22
- # falling back to the default when no value is provided.
23
- # Mirrors `resolveMaxInstructions(maxInstructions)`.
24
- sig { params(max_instructions: T.nilable(Integer)).returns(Integer) }
25
- def resolve_max_instructions(max_instructions)
26
- max_instructions || DEFAULT_MAX_INSTRUCTIONS_PER_TRANSACTION
27
- end
28
-
29
- # Asserts that a configured maximum number of instructions per transaction is valid.
30
- # +nil+ is allowed and falls back to the default.
31
- # Mirrors `assertValidMaxInstructionsPerTransaction(maxInstructions)`.
32
- sig { params(max_instructions: T.nilable(Integer)).void }
33
- def assert_valid_max_instructions_per_transaction(max_instructions)
34
- return if max_instructions.nil?
35
-
36
- if max_instructions <= 0 || max_instructions > TRANSACTION_INSTRUCTION_LIMIT
37
- Kernel.raise SolanaError.new(
38
- SolanaError::INSTRUCTION_PLANS__INVALID_MAX_INSTRUCTIONS_PER_TRANSACTION,
39
- {
40
- max_instructions: max_instructions,
41
- transaction_instruction_limit: TRANSACTION_INSTRUCTION_LIMIT
42
- }
43
- )
44
- end
45
- end
46
-
47
- # Raises if +num_instructions+ exceeds +max_instructions+.
48
- # Mirrors `assertMaxInstructionsPerTransaction(numInstructions, maxInstructions)`.
49
- sig { params(num_instructions: Integer, max_instructions: Integer).void }
50
- def assert_max_instructions_per_transaction(num_instructions, max_instructions)
51
- return if num_instructions <= max_instructions
52
-
53
- Kernel.raise SolanaError.new(
54
- SolanaError::INSTRUCTION_PLANS__MAX_INSTRUCTIONS_PER_TRANSACTION_EXCEEDED,
55
- { max_instructions: max_instructions, num_instructions: num_instructions }
56
- )
57
- end
58
- end
59
- end