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
@@ -15,14 +15,134 @@ module Solana::Ruby::Kit
15
15
  # defeated the "directly available as Codecs.x" intent in codecs.rb.
16
16
  extend self
17
17
 
18
+ # Byte order mark, U+FEFF, in its UTF-8 encoding.
19
+ BOM = T.let("\xEF\xBB\xBF".b.freeze, String)
20
+
21
+ # Remove every null character from a decoded string.
22
+ # Mirrors `removeNullCharacters`.
23
+ sig { params(value: String).returns(String) }
24
+ def remove_null_characters(value)
25
+ value.delete("\x00")
26
+ end
27
+
28
+ # Find the first malformed UTF-8 sequence in +bytes+, starting at +offset+.
29
+ # Returns the byte offset at which it starts, or -1 when the bytes are
30
+ # well-formed.
31
+ #
32
+ # This rejects everything the Unicode standard rejects: unexpected
33
+ # continuation bytes, overlong encodings, encoded surrogates, code points
34
+ # above U+10FFFF and truncated sequences. Ruby's own +valid_encoding?+
35
+ # answers the same question, but only yes or no - this reports *where*,
36
+ # which is what the error context needs.
37
+ # Mirrors `findMalformedUtf8SequenceOffset`.
38
+ sig { params(bytes: String, offset: Integer).returns(Integer) }
39
+ def find_malformed_utf8_sequence_offset(bytes, offset = 0)
40
+ arr = T.cast(T.unsafe(bytes.b).unpack('C*'), T::Array[Integer])
41
+ length = arr.length
42
+ index = offset
43
+ while index < length
44
+ lead = T.must(arr[index])
45
+ min = 0x80
46
+ max = 0xBF
47
+ if lead < 0x80
48
+ index += 1
49
+ next
50
+ elsif lead >= 0xC2 && lead <= 0xDF
51
+ count = 1
52
+ elsif lead >= 0xE0 && lead <= 0xEF
53
+ count = 2
54
+ min = 0xA0 if lead == 0xE0 # Overlong.
55
+ max = 0x9F if lead == 0xED # Encoded surrogate.
56
+ elsif lead >= 0xF0 && lead <= 0xF4
57
+ count = 3
58
+ min = 0x90 if lead == 0xF0 # Overlong.
59
+ max = 0x8F if lead == 0xF4 # Above U+10FFFF.
60
+ else
61
+ return index
62
+ end
63
+
64
+ start = index
65
+ index += 1
66
+ count.times do |i|
67
+ return start if index >= length
68
+
69
+ byte = T.must(arr[index])
70
+ in_range = i.zero? ? byte >= min && byte <= max : byte >= 0x80 && byte <= 0xBF
71
+ return start unless in_range
72
+
73
+ index += 1
74
+ end
75
+ end
76
+ -1
77
+ end
78
+
79
+ # Raise unless +bytes+, from +offset+, form well-formed UTF-8.
80
+ # Mirrors `assertIsWellFormedUtf8Bytes`.
81
+ sig { params(bytes: String, offset: Integer).void }
82
+ def assert_is_well_formed_utf8_bytes(bytes, offset = 0)
83
+ malformed = find_malformed_utf8_sequence_offset(bytes, offset)
84
+ return if malformed == -1
85
+
86
+ Kernel.raise SolanaError.new(
87
+ SolanaError::CODECS__INVALID_UTF8_BYTES,
88
+ { bytes: bytes.b, offset: malformed }
89
+ )
90
+ end
91
+
92
+ # Raise unless +value+ can be encoded as UTF-8 without loss.
93
+ # Mirrors `assertIsWellFormedUtf8String`.
94
+ #
95
+ # Upstream guards against lone surrogates, which a JavaScript string can
96
+ # hold because it is a sequence of UTF-16 code units. A Ruby String is a
97
+ # byte sequence with an encoding tag, so the equivalent flaw is a String
98
+ # whose bytes are not valid UTF-8. Upstream's reported +index+ counts
99
+ # UTF-16 code units; the +index+ here is a byte offset.
100
+ sig { params(value: String).void }
101
+ def assert_is_well_formed_utf8_string(value)
102
+ index = find_malformed_utf8_sequence_offset(value.b)
103
+ return if index == -1
104
+
105
+ Kernel.raise SolanaError.new(
106
+ SolanaError::CODECS__INVALID_UTF8_STRING,
107
+ { index: index, value: value }
108
+ )
109
+ end
110
+
18
111
  # UTF-8 string codec.
19
112
  # When +size+ is given the encoded bytes are fixed to that length
20
113
  # (zero-padded or truncated); otherwise the codec is variable-length
21
114
  # and must be used inside a size-prefixed container.
22
- sig { params(size: T.nilable(Integer)).returns(Codec) }
23
- def utf8_codec(size: nil)
115
+ #
116
+ # +fatal+ rejects invalid UTF-8 instead of passing it through: on encode a
117
+ # String whose bytes are not valid UTF-8, on decode a malformed byte
118
+ # sequence.
119
+ #
120
+ # +ignore_bom+ follows TextDecoder's confusing spelling: the default,
121
+ # +false+, *strips* a leading byte order mark; +true+ keeps it.
122
+ #
123
+ # +remove_null_characters+ strips every null character from the decoded
124
+ # string, which is what makes fixed-size padded strings read back cleanly.
125
+ # It is on by default, matching upstream, and makes the codec lossy for
126
+ # strings that legitimately contain nulls - pass +false+ for a lossless
127
+ # round trip.
128
+ sig do
129
+ params(
130
+ size: T.nilable(Integer),
131
+ fatal: T::Boolean,
132
+ ignore_bom: T::Boolean,
133
+ remove_null_characters: T::Boolean
134
+ ).returns(Codec)
135
+ end
136
+ def utf8_codec(size: nil, fatal: false, ignore_bom: false, remove_null_characters: true)
137
+ strip_nulls = remove_null_characters
24
138
  enc = Encoder.new(fixed_size: size) do |v|
25
- raw = v.to_s.encode('UTF-8').b
139
+ str = v.to_s
140
+ # Transcode anything that is not already UTF-8 (or raw bytes); a
141
+ # UTF-8 to UTF-8 `encode` is a no-op and would not validate, which is
142
+ # what `fatal` is for.
143
+ str = str.encode(::Encoding::UTF_8) unless [::Encoding::UTF_8, ::Encoding::BINARY].include?(str.encoding)
144
+ raw = str.b
145
+ assert_is_well_formed_utf8_string(raw) if fatal
26
146
  if size
27
147
  raw.bytesize <= size ? raw.ljust(size, "\x00") : raw.byteslice(0, size) || ''.b
28
148
  else
@@ -32,9 +152,15 @@ module Solana::Ruby::Kit
32
152
  dec = Decoder.new(fixed_size: size) do |bytes, offset|
33
153
  len = size || (bytes.bytesize - offset)
34
154
  slice = bytes.b.byteslice(offset, len) || ''.b
35
- # Strip null padding for fixed-size strings
36
- str = size ? slice.delete_suffix("\x00" * slice.bytesize.times.take_while { |i| slice.b[-1 - i] == "\x00" }.length) : slice
37
- [str.force_encoding('UTF-8'), len]
155
+ assert_is_well_formed_utf8_bytes(slice) if fatal
156
+ slice = T.must(slice.byteslice(BOM.bytesize..)) if !ignore_bom && slice.start_with?(BOM)
157
+ str = slice.force_encoding(::Encoding::UTF_8)
158
+ # Non-fatal decoding substitutes U+FFFD for malformed sequences, as
159
+ # TextDecoder does. `scrub` is Ruby's version of exactly that, and it
160
+ # also keeps the null-stripping below from raising on invalid bytes.
161
+ str = str.scrub unless fatal
162
+ str = remove_null_characters(str) if strip_nulls
163
+ [str, len]
38
164
  end
39
165
  Codec.new(enc, dec)
40
166
  end
@@ -13,7 +13,7 @@ module Solana::Ruby::Kit
13
13
  String
14
14
  )
15
15
 
16
- module_function
16
+ extend self
17
17
 
18
18
  # Encodes a binary String to a base58 string.
19
19
  sig { params(bytes: String).returns(String) }
@@ -95,6 +95,15 @@ module Solana::Ruby::Kit
95
95
  CODECS__INVALID_BYTE_LENGTH = :SOLANA_ERROR__CODECS__INVALID_BYTE_LENGTH
96
96
  CODECS__EXPECTED_ZERO_VALUE_TO_MATCH_ITEM_FIXED_DISCRIMINATOR = :SOLANA_ERROR__CODECS__EXPECTED_ZERO_VALUE_TO_MATCH_ITEM_FIXED_DISCRIMINATOR
97
97
  CODECS__FIXED_NULLABLE_CANNOT_WRAP_VARIABLE_SIZE_CODEC = :SOLANA_ERROR__CODECS__FIXED_NULLABLE_CANNOT_WRAP_VARIABLE_SIZE_CODEC
98
+ # context: { bytes:, offset: }
99
+ CODECS__INVALID_UTF8_BYTES = :SOLANA_ERROR__CODECS__INVALID_UTF8_BYTES
100
+ # context: { index:, value: }
101
+ CODECS__INVALID_UTF8_STRING = :SOLANA_ERROR__CODECS__INVALID_UTF8_STRING
102
+ # context: { codec_description:, min:, max:, value: }
103
+ CODECS__NUMBER_OUT_OF_RANGE = :SOLANA_ERROR__CODECS__NUMBER_OUT_OF_RANGE
104
+ # context: { codec_description:, hex_sentinel:, sentinel: }
105
+ CODECS__SENTINEL_MISSING_AT_END_OF_BYTES = :SOLANA_ERROR__CODECS__SENTINEL_MISSING_AT_END_OF_BYTES
106
+ CODECS__SENTINEL_MUST_NOT_BE_EMPTY = :SOLANA_ERROR__CODECS__SENTINEL_MUST_NOT_BE_EMPTY
98
107
 
99
108
  # ── RPC / JSON-RPC ────────────────────────────────────────────────────────
100
109
  RPC__INTEGER_OVERFLOW_WHILE_SERIALIZING_LARGE_INTEGER = :SOLANA_ERROR__RPC__INTEGER_OVERFLOW_WHILE_SERIALIZING_LARGE_INTEGER
@@ -134,6 +143,8 @@ module Solana::Ruby::Kit
134
143
  INSTRUCTION_PLANS__INVALID_MAX_INSTRUCTIONS_PER_TRANSACTION = :SOLANA_ERROR__INSTRUCTION_PLANS__INVALID_MAX_INSTRUCTIONS_PER_TRANSACTION
135
144
  # context: { max_instructions:, num_instructions: }
136
145
  INSTRUCTION_PLANS__MAX_INSTRUCTIONS_PER_TRANSACTION_EXCEEDED = :SOLANA_ERROR__INSTRUCTION_PLANS__MAX_INSTRUCTIONS_PER_TRANSACTION_EXCEEDED
146
+ # context: { reason: }
147
+ INSTRUCTION_PLANS__MESSAGE_REJECTED_BY_PACKER = :SOLANA_ERROR__INSTRUCTION_PLANS__MESSAGE_REJECTED_BY_PACKER
137
148
 
138
149
  # ── Transaction introspection ────────────────────────────────────────────
139
150
  TRANSACTION_INTROSPECTION__CANNOT_DECODE_JSON_PARSED_TRANSACTION = :SOLANA_ERROR__TRANSACTION_INTROSPECTION__CANNOT_DECODE_JSON_PARSED_TRANSACTION
@@ -233,7 +244,7 @@ module Solana::Ruby::Kit
233
244
  CODECS__EXPECTED_POSITIVE_BYTE_LENGTH => 'Expected a positive byte length, got %{byte_length}',
234
245
  CODECS__ENCODER_DECODER_FIXED_SIZE_MISMATCH => 'Encoder fixed size (%{encoder_size}) does not match decoder fixed size (%{decoder_size})',
235
246
  CODECS__ENCODER_DECODER_MAX_SIZE_MISMATCH => 'Encoder max size (%{encoder_max}) does not match decoder max size (%{decoder_max})',
236
- CODECS__INVALID_NUMBER_OF_ITEMS => 'Expected %{expected} items but got %{actual}',
247
+ CODECS__INVALID_NUMBER_OF_ITEMS => 'Expected [%{codec_description}] to have %{expected} items, got %{actual}.',
237
248
  CODECS__ENUM_DISCRIMINATOR_OUT_OF_RANGE => 'Enum discriminator %{discriminator} is out of range [0, %{max}]',
238
249
  CODECS__UNION_VARIANT_OUT_OF_RANGE => 'Union variant index %{index} is out of range',
239
250
  CODECS__OFFSET_OUT_OF_RANGE => 'Codec offset %{offset} is out of range for byte array of length %{byte_length}',
@@ -244,6 +255,11 @@ module Solana::Ruby::Kit
244
255
  CODECS__INVALID_BYTE_LENGTH => 'Expected %{expected} bytes but got %{actual}',
245
256
  CODECS__EXPECTED_ZERO_VALUE_TO_MATCH_ITEM_FIXED_DISCRIMINATOR => 'Expected zero value to match fixed discriminator',
246
257
  CODECS__FIXED_NULLABLE_CANNOT_WRAP_VARIABLE_SIZE_CODEC => 'A fixed-size nullable codec cannot wrap a variable-size codec',
258
+ CODECS__INVALID_UTF8_BYTES => 'Invalid UTF-8 byte sequence at offset %{offset}',
259
+ CODECS__INVALID_UTF8_STRING => 'Invalid UTF-8 string at index %{index}',
260
+ CODECS__NUMBER_OUT_OF_RANGE => 'Codec [%{codec_description}] expected number to be in the range [%{min}, %{max}], got %{value}.',
261
+ 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.',
262
+ CODECS__SENTINEL_MUST_NOT_BE_EMPTY => 'The sentinel must not be empty.',
247
263
 
248
264
  # RPC
249
265
  RPC__INTEGER_OVERFLOW_WHILE_SERIALIZING_LARGE_INTEGER => 'Integer overflow while serializing large integer %{value}',
@@ -281,6 +297,7 @@ module Solana::Ruby::Kit
281
297
  INSTRUCTION_PLANS__FAILED_TO_EXECUTE_TRANSACTION_PLAN => 'Failed to execute transaction plan',
282
298
  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.',
283
299
  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.',
300
+ INSTRUCTION_PLANS__MESSAGE_REJECTED_BY_PACKER => 'The message packer rejected the provided transaction message: %{reason}.',
284
301
 
285
302
  # Transaction introspection
286
303
  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.",
@@ -8,7 +8,7 @@ module Solana::Ruby::Kit
8
8
  module FastStableStringify
9
9
  extend T::Sig
10
10
 
11
- module_function
11
+ extend self
12
12
 
13
13
  # Serialize +value+ to a deterministic JSON string.
14
14
  # Hash keys are sorted lexicographically at every level.
@@ -6,7 +6,7 @@ module Solana::Ruby::Kit
6
6
  # Mirrors the TypeScript package @solana/functional.
7
7
  module Functional
8
8
  extend T::Sig
9
- module_function
9
+ extend self
10
10
 
11
11
  # Passes an initial value through a series of single-argument callables,
12
12
  # returning the final result.
@@ -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,
@@ -97,7 +105,7 @@ module Solana::Ruby::Kit
97
105
 
98
106
  # ── Factory helpers ────────────────────────────────────────────────────────
99
107
 
100
- module_function
108
+ extend self
101
109
 
102
110
  # Wraps a single instruction in a plan.
103
111
  # Mirrors `singleInstructionPlan(instruction)`.
@@ -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
@@ -41,7 +41,7 @@ module Solana::Ruby::Kit
41
41
  def kind = :parallel
42
42
  end
43
43
 
44
- module_function
44
+ extend self
45
45
 
46
46
  # Creates a single-message plan.
47
47
  # Mirrors `singleTransactionPlan(transactionMessage)`.
@@ -9,7 +9,7 @@ module Solana::Ruby::Kit
9
9
  module InstructionPlans
10
10
  extend T::Sig
11
11
 
12
- module_function
12
+ extend self
13
13
 
14
14
  # Creates a TransactionPlanExecutor — a callable that traverses a TransactionPlan,
15
15
  # executing each single transaction message and collecting results.
@@ -111,7 +111,7 @@ module Solana::Ruby::Kit
111
111
 
112
112
  # ── Private helpers ────────────────────────────────────────────────────────
113
113
 
114
- module_function
114
+ extend self
115
115
 
116
116
  sig { params(plan: T.untyped, execute_fn: T.untyped, state: T.untyped).returns(T.untyped) }
117
117
 
@@ -74,7 +74,7 @@ module Solana::Ruby::Kit
74
74
  def kind = :parallel
75
75
  end
76
76
 
77
- module_function
77
+ extend self
78
78
 
79
79
  # ── Result factory helpers ────────────────────────────────────────────────
80
80
 
@@ -11,7 +11,7 @@ module Solana::Ruby::Kit
11
11
  module InstructionPlans
12
12
  extend T::Sig
13
13
 
14
- module_function
14
+ extend self
15
15
 
16
16
  # Creates a TransactionPlanner — a callable that converts an InstructionPlan
17
17
  # into a TransactionPlan by packing instructions into transaction messages.
@@ -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,
@@ -71,7 +71,7 @@ module Solana::Ruby::Kit
71
71
 
72
72
  # ── Private helpers ────────────────────────────────────────────────────────
73
73
 
74
- module_function
74
+ extend self
75
75
 
76
76
  sig { params(plan: T.untyped, ctx: T.untyped).returns(T.untyped) }
77
77
 
@@ -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'
@@ -19,7 +19,7 @@ module Solana::Ruby::Kit
19
19
 
20
20
  # AccountMeta subtypes — mirrors the four TypeScript convenience types.
21
21
 
22
- module_function
22
+ extend self
23
23
 
24
24
  # Creates a read-only account reference.
25
25
  sig { params(address: Addresses::Address).returns(AccountMeta) }
@@ -18,7 +18,7 @@ module Solana::Ruby::Kit
18
18
  const :data, T.nilable(String) # binary String (Uint8Array equivalent)
19
19
  end
20
20
 
21
- module_function
21
+ extend self
22
22
 
23
23
  # Returns true if the instruction targets the given program address.
24
24
  # Mirrors `isInstructionForProgram()`.
@@ -19,7 +19,7 @@ module Solana::Ruby::Kit
19
19
 
20
20
  ALL = T.let([READONLY, WRITABLE, READONLY_SIGNER, WRITABLE_SIGNER].freeze, T::Array[Integer])
21
21
 
22
- module_function
22
+ extend self
23
23
 
24
24
  # Returns true if the role requires the account to sign the transaction.
25
25
  sig { params(role: Integer).returns(T::Boolean) }
@@ -24,7 +24,7 @@ module Solana::Ruby::Kit
24
24
  const :verify_key, T.untyped # RbNaCl::VerifyKey
25
25
  end
26
26
 
27
- module_function
27
+ extend self
28
28
 
29
29
  # Generates a fresh Ed25519 key pair using a cryptographically secure RNG.
30
30
  # Mirrors `generateKeyPair()` in TypeScript.