solana-ruby-kit 0.1.4 → 0.1.5

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: 8be22a1330f9190db447c2a209a2dc44991c75efc6758bbe9e94f6e56b579473
4
- data.tar.gz: c33dc52550866af9c572ce7911fe0baa9d053dc0f4723e5b700f0473ee5ffee4
3
+ metadata.gz: 12771cac3706269f104b012ee691d4cd42856f61560d54b9e959308184c90be6
4
+ data.tar.gz: 52bc18d7dff9bce3c2f27115772275e8a2b0decd985faa4ef2f43723056030d9
5
5
  SHA512:
6
- metadata.gz: ee653c6ea8f2b6b2dd5359e6d49281431d9127db48600d836d560ebd32dde7e75294fc6cc44f8b609de4fb0bc565bf3e810a30ca050ce78d7e8506177e7ba6cd
7
- data.tar.gz: 5483176779980f87b8102e81dca85afc4f71a413281b5b296d50edee38ebae9b000c370d75362a442abd84589ec6014b661e075623aa5ba7cf5920df0b20bd4b
6
+ metadata.gz: c1f7feeaf404a2911bf4498377e2e2426f10357cd415cb70e4a57d70b05a81a73b51b817983e33bd1a9aa169ee9dcd9d80018c050964b0a51ada8afebba772d2
7
+ data.tar.gz: 6f3f9b4d8de10136120b08d4be8927ba13546edc902f81a3f46b64e89e5947b9623ef3645857c721c44bd5bc7d16c4ba13eee74d65bca03d09bee78fd0b3a932
@@ -46,6 +46,7 @@ module Solana::Ruby::Kit
46
46
 
47
47
  # ── Transactions ──────────────────────────────────────────────────────────
48
48
  TRANSACTIONS__TRANSACTION_NOT_SIGNABLE = :SOLANA_ERROR__TRANSACTIONS__TRANSACTION_NOT_SIGNABLE
49
+ TRANSACTIONS__EXCEEDS_SIZE_LIMIT = :SOLANA_ERROR__TRANSACTION__EXCEEDS_SIZE_LIMIT
49
50
  TRANSACTIONS__MISSING_SIGNER = :SOLANA_ERROR__TRANSACTIONS__MISSING_SIGNER
50
51
  TRANSACTIONS__VERSION_NUMBER_OUT_OF_RANGE = :SOLANA_ERROR__TRANSACTIONS__VERSION_NUMBER_OUT_OF_RANGE
51
52
  TRANSACTIONS__FAILED_TO_DECOMPILE_ADDRESS_LOOKUP_TABLE_CONTENTS = :SOLANA_ERROR__TRANSACTIONS__FAILED_TO_DECOMPILE_ADDRESS_LOOKUP_TABLE_CONTENTS
@@ -87,6 +88,13 @@ module Solana::Ruby::Kit
87
88
  RPC__INTEGER_OVERFLOW_WHILE_SERIALIZING_LARGE_INTEGER = :SOLANA_ERROR__RPC__INTEGER_OVERFLOW_WHILE_SERIALIZING_LARGE_INTEGER
88
89
  RPC__INTEGER_OVERFLOW_WHILE_DESERIALIZING_LARGE_INTEGER = :SOLANA_ERROR__RPC__INTEGER_OVERFLOW_WHILE_DESERIALIZING_LARGE_INTEGER
89
90
  RPC__TRANSPORT_HTTP_ERROR = :SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR
91
+ # JSON-RPC server errors (-32017..-32019); context keys mirror TypeScript SolanaErrorContext:
92
+ # EPOCH_REWARDS_PERIOD_ACTIVE: { slot:, current_block_height:, rewards_complete_block_height: }
93
+ # SLOT_NOT_EPOCH_BOUNDARY: { slot: }
94
+ # LONG_TERM_STORAGE_UNREACHABLE: (no context)
95
+ JSON_RPC__SERVER_ERROR_EPOCH_REWARDS_PERIOD_ACTIVE = :SOLANA_ERROR__JSON_RPC__SERVER_ERROR_EPOCH_REWARDS_PERIOD_ACTIVE
96
+ JSON_RPC__SERVER_ERROR_SLOT_NOT_EPOCH_BOUNDARY = :SOLANA_ERROR__JSON_RPC__SERVER_ERROR_SLOT_NOT_EPOCH_BOUNDARY
97
+ JSON_RPC__SERVER_ERROR_LONG_TERM_STORAGE_UNREACHABLE = :SOLANA_ERROR__JSON_RPC__SERVER_ERROR_LONG_TERM_STORAGE_UNREACHABLE
90
98
  RPC_SUBSCRIPTIONS__CANNOT_CREATE_SUBSCRIPTION_REQUEST = :SOLANA_ERROR__RPC_SUBSCRIPTIONS__CANNOT_CREATE_SUBSCRIPTION_REQUEST
91
99
  RPC_SUBSCRIPTIONS__EXPECTED_SERVER_SUBSCRIPTION_ID = :SOLANA_ERROR__RPC_SUBSCRIPTIONS__EXPECTED_SERVER_SUBSCRIPTION_ID
92
100
  RPC_SUBSCRIPTIONS__CHANNEL_CLOSED_BEFORE_MESSAGE_BUFFERED = :SOLANA_ERROR__RPC_SUBSCRIPTIONS__CHANNEL_CLOSED_BEFORE_MESSAGE_BUFFERED
@@ -101,9 +109,18 @@ module Solana::Ruby::Kit
101
109
  OFFCHAIN_MESSAGES__MESSAGE_TOO_LONG = :SOLANA_ERROR__OFFCHAIN_MESSAGES__MESSAGE_TOO_LONG
102
110
  OFFCHAIN_MESSAGES__LEADING_ZERO_IN_SIGNING_DOMAIN = :SOLANA_ERROR__OFFCHAIN_MESSAGES__LEADING_ZERO_IN_SIGNING_DOMAIN
103
111
 
112
+ # ── Instruction plans ─────────────────────────────────────────────────────
113
+ # context: { num_bytes_required:, num_free_bytes: }
114
+ INSTRUCTION_PLANS__MESSAGE_CANNOT_ACCOMMODATE_PLAN = :SOLANA_ERROR__INSTRUCTION_PLANS__MESSAGE_CANNOT_ACCOMMODATE_PLAN
115
+ INSTRUCTION_PLANS__MESSAGE_PACKER_ALREADY_COMPLETE = :SOLANA_ERROR__INSTRUCTION_PLANS__MESSAGE_PACKER_ALREADY_COMPLETE
116
+ INSTRUCTION_PLANS__EMPTY_INSTRUCTION_PLAN = :SOLANA_ERROR__INSTRUCTION_PLANS__EMPTY_INSTRUCTION_PLAN
117
+ INSTRUCTION_PLANS__FAILED_TO_EXECUTE_TRANSACTION_PLAN = :SOLANA_ERROR__INSTRUCTION_PLANS__FAILED_TO_EXECUTE_TRANSACTION_PLAN
118
+
104
119
  # ── Invariant violations (internal) ──────────────────────────────────────
105
120
  INVARIANT_VIOLATION__SUBSCRIPTION_ITERATOR_STATE_MISSING = :SOLANA_ERROR__INVARIANT_VIOLATION__SUBSCRIPTION_ITERATOR_STATE_MISSING
106
121
  INVARIANT_VIOLATION__SUBSCRIPTION_ITERATOR_MUST_NOT_POLL_BEFORE_RESOLVING_EXISTING_MESSAGE_PROMISE = :SOLANA_ERROR__INVARIANT_VIOLATION__SUBSCRIPTION_ITERATOR_MUST_NOT_POLL_BEFORE_RESOLVING_EXISTING_MESSAGE_PROMISE
122
+ INVARIANT_VIOLATION__INVALID_INSTRUCTION_PLAN_KIND = :SOLANA_ERROR__INVARIANT_VIOLATION__INVALID_INSTRUCTION_PLAN_KIND
123
+ INVARIANT_VIOLATION__INVALID_TRANSACTION_PLAN_KIND = :SOLANA_ERROR__INVARIANT_VIOLATION__INVALID_TRANSACTION_PLAN_KIND
107
124
 
108
125
  ERROR_MESSAGES = T.let(
109
126
  {
@@ -145,6 +162,7 @@ module Solana::Ruby::Kit
145
162
 
146
163
  # Transactions
147
164
  TRANSACTIONS__TRANSACTION_NOT_SIGNABLE => 'Transaction is not signable (missing fee payer or lifetime constraint)',
165
+ TRANSACTIONS__EXCEEDS_SIZE_LIMIT => 'Transaction wire size (%{actual_size} bytes) exceeds the limit of %{limit} bytes',
148
166
  TRANSACTIONS__MISSING_SIGNER => 'Transaction is missing a required signer: %{address}',
149
167
  TRANSACTIONS__VERSION_NUMBER_OUT_OF_RANGE => 'Transaction version %{version} is out of range',
150
168
  TRANSACTIONS__FAILED_TO_DECOMPILE_ADDRESS_LOOKUP_TABLE_CONTENTS => 'Failed to decompile address lookup table contents',
@@ -186,6 +204,9 @@ module Solana::Ruby::Kit
186
204
  RPC__INTEGER_OVERFLOW_WHILE_SERIALIZING_LARGE_INTEGER => 'Integer overflow while serializing large integer %{value}',
187
205
  RPC__INTEGER_OVERFLOW_WHILE_DESERIALIZING_LARGE_INTEGER => 'Integer overflow while deserializing large integer %{value}',
188
206
  RPC__TRANSPORT_HTTP_ERROR => 'HTTP transport error: %{status} %{message}',
207
+ JSON_RPC__SERVER_ERROR_EPOCH_REWARDS_PERIOD_ACTIVE => 'Epoch rewards period still active at slot %{slot}',
208
+ JSON_RPC__SERVER_ERROR_SLOT_NOT_EPOCH_BOUNDARY => "Rewards cannot be found because slot %{slot} is not the epoch boundary. This may be due to gap in the queried node's local ledger or long-term storage",
209
+ JSON_RPC__SERVER_ERROR_LONG_TERM_STORAGE_UNREACHABLE => 'Failed to query long-term storage; please try again',
189
210
  RPC_SUBSCRIPTIONS__CANNOT_CREATE_SUBSCRIPTION_REQUEST => 'Cannot create subscription request',
190
211
  RPC_SUBSCRIPTIONS__EXPECTED_SERVER_SUBSCRIPTION_ID => 'Expected server to return a subscription ID',
191
212
  RPC_SUBSCRIPTIONS__CHANNEL_CLOSED_BEFORE_MESSAGE_BUFFERED => 'WebSocket channel closed before message could be buffered',
@@ -200,9 +221,17 @@ module Solana::Ruby::Kit
200
221
  OFFCHAIN_MESSAGES__MESSAGE_TOO_LONG => 'Offchain message is too long (%{length} bytes, max %{max})',
201
222
  OFFCHAIN_MESSAGES__LEADING_ZERO_IN_SIGNING_DOMAIN => 'Offchain message signing domain must not start with a null byte',
202
223
 
224
+ # Instruction plans
225
+ INSTRUCTION_PLANS__MESSAGE_CANNOT_ACCOMMODATE_PLAN => 'Transaction message cannot accommodate the plan: requires %{num_bytes_required} bytes but only %{num_free_bytes} are available',
226
+ INSTRUCTION_PLANS__MESSAGE_PACKER_ALREADY_COMPLETE => 'Message packer is already complete; no more instructions to pack',
227
+ INSTRUCTION_PLANS__EMPTY_INSTRUCTION_PLAN => 'Instruction plan is empty and produced no transaction messages',
228
+ INSTRUCTION_PLANS__FAILED_TO_EXECUTE_TRANSACTION_PLAN => 'Failed to execute transaction plan',
229
+
203
230
  # Invariant violations
204
231
  INVARIANT_VIOLATION__SUBSCRIPTION_ITERATOR_STATE_MISSING => 'Subscription iterator state is missing (internal error)',
205
232
  INVARIANT_VIOLATION__SUBSCRIPTION_ITERATOR_MUST_NOT_POLL_BEFORE_RESOLVING_EXISTING_MESSAGE_PROMISE => 'Subscription iterator must not poll before resolving existing message (internal error)',
233
+ INVARIANT_VIOLATION__INVALID_INSTRUCTION_PLAN_KIND => 'Invalid instruction plan kind: %{kind} (internal error)',
234
+ INVARIANT_VIOLATION__INVALID_TRANSACTION_PLAN_KIND => 'Invalid transaction plan kind: %{kind} (internal error)',
206
235
  }.freeze,
207
236
  T::Hash[Symbol, String]
208
237
  )
@@ -0,0 +1,251 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../errors'
5
+ require_relative '../instructions/instruction'
6
+ require_relative '../transaction_messages/transaction_message'
7
+ require_relative '../transactions/compiler'
8
+
9
+ module Solana::Ruby::Kit
10
+ module InstructionPlans
11
+ extend T::Sig
12
+
13
+ # ── Plan types ─────────────────────────────────────────────────────────────
14
+ #
15
+ # InstructionPlan is a recursive tree that describes operations which may
16
+ # span multiple transactions. Mirrors @solana/instruction-plans.
17
+ #
18
+ # InstructionPlan = SingleInstructionPlan
19
+ # | SequentialInstructionPlan
20
+ # | ParallelInstructionPlan
21
+ # | MessagePackerInstructionPlan
22
+
23
+ # A plan that wraps a single instruction.
24
+ class SingleInstructionPlan < T::Struct
25
+ const :instruction, Instructions::Instruction
26
+ def kind = :single
27
+ end
28
+
29
+ # A plan whose children must execute in order.
30
+ # +divisible: true+ allows the planner to split children across transactions.
31
+ # +divisible: false+ means all children should be atomic (one tx or a bundle).
32
+ class SequentialInstructionPlan < T::Struct
33
+ const :plans, T::Array[T.untyped] # Array[InstructionPlan]
34
+ const :divisible, T::Boolean
35
+ def kind = :sequential
36
+ end
37
+
38
+ # A plan whose children may execute concurrently or be packed in any order.
39
+ class ParallelInstructionPlan < T::Struct
40
+ const :plans, T::Array[T.untyped] # Array[InstructionPlan]
41
+ def kind = :parallel
42
+ end
43
+
44
+ # Provides a MessagePacker that dynamically packs instructions into messages.
45
+ # The +get_message_packer+ proc returns a fresh MessagePacker each call.
46
+ class MessagePackerInstructionPlan < T::Struct
47
+ const :get_message_packer, T.untyped # Proc -> MessagePacker
48
+ def kind = :message_packer
49
+ end
50
+
51
+ # Returned by MessagePackerInstructionPlan#get_message_packer.
52
+ # Mirrors the TypeScript MessagePacker interface.
53
+ class MessagePacker
54
+ extend T::Sig
55
+
56
+ sig { params(done_proc: T.untyped, pack_proc: T.untyped).void }
57
+ def initialize(done_proc:, pack_proc:)
58
+ @done_proc = T.let(done_proc, T.untyped)
59
+ @pack_proc = T.let(pack_proc, T.untyped)
60
+ end
61
+
62
+ # Returns true when all instructions have been packed.
63
+ sig { returns(T::Boolean) }
64
+ def done?
65
+ @done_proc.call
66
+ end
67
+
68
+ # Packs as many instructions as possible into +message+ and returns the
69
+ # updated message. Raises SolanaError if the message is too small or the
70
+ # packer is already done.
71
+ sig { params(message: TransactionMessages::TransactionMessage).returns(TransactionMessages::TransactionMessage) }
72
+ def pack_message_to_capacity(message)
73
+ @pack_proc.call(message)
74
+ end
75
+ end
76
+
77
+ # ── Factory helpers ────────────────────────────────────────────────────────
78
+
79
+ module_function
80
+
81
+ # Wraps a single instruction in a plan.
82
+ # Mirrors `singleInstructionPlan(instruction)`.
83
+ sig { params(instruction: Instructions::Instruction).returns(SingleInstructionPlan) }
84
+ def single_instruction_plan(instruction)
85
+ SingleInstructionPlan.new(instruction: instruction)
86
+ end
87
+
88
+ # Creates a divisible sequential plan (children may be split across txs).
89
+ # Mirrors `sequentialInstructionPlan(plans)`.
90
+ # Accepts raw Instruction objects; wraps them in SingleInstructionPlan automatically.
91
+ sig { params(plans: T::Array[T.untyped]).returns(SequentialInstructionPlan) }
92
+ def sequential_instruction_plan(plans)
93
+ SequentialInstructionPlan.new(plans: parse_single_instruction_plans(plans), divisible: true)
94
+ end
95
+
96
+ # Creates a non-divisible sequential plan (children must be atomic).
97
+ # Mirrors `nonDivisibleSequentialInstructionPlan(plans)`.
98
+ sig { params(plans: T::Array[T.untyped]).returns(SequentialInstructionPlan) }
99
+ def non_divisible_sequential_instruction_plan(plans)
100
+ SequentialInstructionPlan.new(plans: parse_single_instruction_plans(plans), divisible: false)
101
+ end
102
+
103
+ # Creates a parallel plan (children may execute concurrently).
104
+ # Mirrors `parallelInstructionPlan(plans)`.
105
+ sig { params(plans: T::Array[T.untyped]).returns(ParallelInstructionPlan) }
106
+ def parallel_instruction_plan(plans)
107
+ ParallelInstructionPlan.new(plans: parse_single_instruction_plans(plans))
108
+ end
109
+
110
+ # Creates a MessagePackerInstructionPlan that packs a fixed byte stream into
111
+ # instructions of maximum size, calling +get_instruction.(offset, length)+.
112
+ # Mirrors `getLinearMessagePackerInstructionPlan({ getInstruction, totalLength })`.
113
+ sig do
114
+ params(
115
+ total_length: Integer,
116
+ get_instruction: T.untyped # Proc(offset, length) -> Instruction
117
+ ).returns(MessagePackerInstructionPlan)
118
+ end
119
+ def get_linear_message_packer_instruction_plan(total_length:, get_instruction:)
120
+ MessagePackerInstructionPlan.new(
121
+ get_message_packer: -> {
122
+ offset = T.let(0, Integer)
123
+ MessagePacker.new(
124
+ done_proc: -> { offset >= total_length },
125
+ pack_proc: ->(message) {
126
+ if offset >= total_length
127
+ Kernel.raise SolanaError.new(SolanaError::INSTRUCTION_PLANS__MESSAGE_PACKER_ALREADY_COMPLETE)
128
+ end
129
+
130
+ base_ix = get_instruction.call(offset, 0)
131
+ with_base = TransactionMessages.append_instructions(message, [base_ix])
132
+ base_size = Transactions.get_transaction_message_size(with_base)
133
+ # -1 leeway for compact-u16 headers
134
+ free_space = Transactions::TRANSACTION_SIZE_LIMIT - base_size - 1
135
+
136
+ if free_space <= 0
137
+ msg_size = Transactions.get_transaction_message_size(message)
138
+ Kernel.raise SolanaError.new(
139
+ SolanaError::INSTRUCTION_PLANS__MESSAGE_CANNOT_ACCOMMODATE_PLAN,
140
+ {
141
+ num_bytes_required: base_size - msg_size + 1,
142
+ num_free_bytes: Transactions::TRANSACTION_SIZE_LIMIT - msg_size - 1
143
+ }
144
+ )
145
+ end
146
+
147
+ length = [total_length - offset, free_space].min
148
+ ix = get_instruction.call(offset, length)
149
+ offset += length
150
+ TransactionMessages.append_instructions(message, [ix])
151
+ }
152
+ )
153
+ }
154
+ )
155
+ end
156
+
157
+ # Creates a MessagePackerInstructionPlan that iterates over a fixed list of
158
+ # instructions, packing as many as fit into each message.
159
+ # Mirrors `getMessagePackerInstructionPlanFromInstructions(instructions)`.
160
+ sig { params(instructions: T::Array[Instructions::Instruction]).returns(MessagePackerInstructionPlan) }
161
+ def get_message_packer_instruction_plan_from_instructions(instructions)
162
+ MessagePackerInstructionPlan.new(
163
+ get_message_packer: -> {
164
+ idx = T.let(0, Integer)
165
+ MessagePacker.new(
166
+ done_proc: -> { idx >= instructions.length },
167
+ pack_proc: ->(message) {
168
+ if idx >= instructions.length
169
+ Kernel.raise SolanaError.new(SolanaError::INSTRUCTION_PLANS__MESSAGE_PACKER_ALREADY_COMPLETE)
170
+ end
171
+
172
+ original_size = Transactions.get_transaction_message_size(message)
173
+ packed = T.let(message, TransactionMessages::TransactionMessage)
174
+ start_idx = idx
175
+
176
+ (idx...instructions.length).each do |i|
177
+ packed = TransactionMessages.append_instructions(packed, [instructions[i]])
178
+ size = Transactions.get_transaction_message_size(packed)
179
+
180
+ if size > Transactions::TRANSACTION_SIZE_LIMIT
181
+ if i == start_idx
182
+ Kernel.raise SolanaError.new(
183
+ SolanaError::INSTRUCTION_PLANS__MESSAGE_CANNOT_ACCOMMODATE_PLAN,
184
+ {
185
+ num_bytes_required: size - original_size,
186
+ num_free_bytes: Transactions::TRANSACTION_SIZE_LIMIT - original_size
187
+ }
188
+ )
189
+ end
190
+ idx = i
191
+ return packed
192
+ end
193
+ end
194
+
195
+ idx = instructions.length
196
+ packed
197
+ }
198
+ )
199
+ }
200
+ )
201
+ end
202
+
203
+ # Creates a MessagePackerInstructionPlan that splits +total_size+ bytes into
204
+ # chunks of REALLOC_LIMIT (10,240) bytes, calling +get_instruction.(size)+.
205
+ # Mirrors `getReallocMessagePackerInstructionPlan({ getInstruction, totalSize })`.
206
+ sig do
207
+ params(
208
+ total_size: Integer,
209
+ get_instruction: T.untyped # Proc(size) -> Instruction
210
+ ).returns(MessagePackerInstructionPlan)
211
+ end
212
+ def get_realloc_message_packer_instruction_plan(total_size:, get_instruction:)
213
+ realloc_limit = 10_240
214
+ num_instructions = (total_size.to_f / realloc_limit).ceil
215
+ last_size = total_size % realloc_limit
216
+
217
+ instructions = num_instructions.times.map do |i|
218
+ chunk = (i == num_instructions - 1) ? last_size : realloc_limit
219
+ get_instruction.call(chunk)
220
+ end
221
+
222
+ get_message_packer_instruction_plan_from_instructions(instructions)
223
+ end
224
+
225
+ # Recursively extracts all instructions from a plan tree (depth-first).
226
+ sig { params(plan: T.untyped).returns(T::Array[Instructions::Instruction]) }
227
+ def flatten_instruction_plan(plan)
228
+ case plan
229
+ when SingleInstructionPlan
230
+ [plan.instruction]
231
+ when SequentialInstructionPlan, ParallelInstructionPlan
232
+ plan.plans.flat_map { |p| flatten_instruction_plan(p) }
233
+ when MessagePackerInstructionPlan
234
+ Kernel.raise ArgumentError, 'Cannot flatten a MessagePackerInstructionPlan (instructions are dynamically generated)'
235
+ else
236
+ Kernel.raise ArgumentError, "Unknown InstructionPlan type: #{plan.class}"
237
+ end
238
+ end
239
+
240
+ # ── Private helpers ────────────────────────────────────────────────────────
241
+
242
+ # Wraps bare Instruction objects in SingleInstructionPlan; passes plans through.
243
+ sig { params(items: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
244
+ def parse_single_instruction_plans(items)
245
+ items.map do |item|
246
+ item.is_a?(Instructions::Instruction) ? single_instruction_plan(item) : item
247
+ end
248
+ end
249
+ private_class_method :parse_single_instruction_plans
250
+ end
251
+ end
@@ -1,27 +1,5 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- module Solana::Ruby::Kit
5
- module InstructionPlans
6
- # A plan that wraps a single instruction.
7
- class SingleInstructionPlan < T::Struct
8
- const :instruction, Instructions::Instruction
9
- end
10
-
11
- # A plan that executes a sequence of sub-plans in order.
12
- # When +divisible+ is true the planner may split steps across transactions.
13
- class SequentialInstructionPlan < T::Struct
14
- const :steps, T::Array[T.untyped] # Array[InstructionPlan]
15
- const :divisible, T::Boolean, default: false
16
- end
17
-
18
- # A plan whose sub-plans may execute concurrently or be packed into the
19
- # same transaction in any order.
20
- class ParallelInstructionPlan < T::Struct
21
- const :plans, T::Array[T.untyped] # Array[InstructionPlan]
22
- end
23
-
24
- # Union type alias (used only in doc strings; Ruby is duck-typed).
25
- # InstructionPlan = SingleInstructionPlan | SequentialInstructionPlan | ParallelInstructionPlan
26
- end
27
- end
4
+ # Kept for backwards compatibility — content moved to instruction_plan.rb.
5
+ require_relative 'instruction_plan'
@@ -0,0 +1,91 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../transaction_messages/transaction_message'
5
+
6
+ module Solana::Ruby::Kit
7
+ module InstructionPlans
8
+ extend T::Sig
9
+
10
+ # ── Transaction plan types ────────────────────────────────────────────────
11
+ #
12
+ # A TransactionPlan is a recursive tree of compiled transaction messages that
13
+ # mirrors the structure of an InstructionPlan after the planner has resolved it.
14
+ #
15
+ # TransactionPlan = SingleTransactionPlan
16
+ # | SequentialTransactionPlan
17
+ # | ParallelTransactionPlan
18
+
19
+ class SingleTransactionPlan < T::Struct
20
+ const :message, TransactionMessages::TransactionMessage
21
+ def kind = :single
22
+ end
23
+
24
+ class SequentialTransactionPlan < T::Struct
25
+ const :plans, T::Array[T.untyped] # Array[TransactionPlan]
26
+ const :divisible, T::Boolean
27
+ def kind = :sequential
28
+ end
29
+
30
+ class ParallelTransactionPlan < T::Struct
31
+ const :plans, T::Array[T.untyped] # Array[TransactionPlan]
32
+ def kind = :parallel
33
+ end
34
+
35
+ module_function
36
+
37
+ # Creates a single-message plan.
38
+ # Mirrors `singleTransactionPlan(transactionMessage)`.
39
+ sig { params(message: TransactionMessages::TransactionMessage).returns(SingleTransactionPlan) }
40
+ def single_transaction_plan(message)
41
+ SingleTransactionPlan.new(message: message)
42
+ end
43
+
44
+ # Creates a divisible sequential plan.
45
+ # Accepts TransactionMessage objects directly (wraps them automatically).
46
+ # Mirrors `sequentialTransactionPlan(plans)`.
47
+ sig { params(plans: T::Array[T.untyped]).returns(SequentialTransactionPlan) }
48
+ def sequential_transaction_plan(plans)
49
+ SequentialTransactionPlan.new(plans: parse_single_transaction_plans(plans), divisible: true)
50
+ end
51
+
52
+ # Creates a non-divisible sequential plan.
53
+ # Mirrors `nonDivisibleSequentialTransactionPlan(plans)`.
54
+ sig { params(plans: T::Array[T.untyped]).returns(SequentialTransactionPlan) }
55
+ def non_divisible_sequential_transaction_plan(plans)
56
+ SequentialTransactionPlan.new(plans: parse_single_transaction_plans(plans), divisible: false)
57
+ end
58
+
59
+ # Creates a parallel plan.
60
+ # Accepts TransactionMessage objects directly (wraps them automatically).
61
+ # Mirrors `parallelTransactionPlan(plans)`.
62
+ sig { params(plans: T::Array[T.untyped]).returns(ParallelTransactionPlan) }
63
+ def parallel_transaction_plan(plans)
64
+ ParallelTransactionPlan.new(plans: parse_single_transaction_plans(plans))
65
+ end
66
+
67
+ # Recursively collects all SingleTransactionPlan leaves from a plan tree.
68
+ # Mirrors `getAllSingleTransactionPlans(transactionPlan)`.
69
+ sig { params(plan: T.untyped).returns(T::Array[SingleTransactionPlan]) }
70
+ def get_all_single_transaction_plans(plan)
71
+ case plan
72
+ when SingleTransactionPlan
73
+ [plan]
74
+ when SequentialTransactionPlan, ParallelTransactionPlan
75
+ plan.plans.flat_map { |p| get_all_single_transaction_plans(p) }
76
+ else
77
+ Kernel.raise ArgumentError, "Unknown TransactionPlan type: #{plan.class}"
78
+ end
79
+ end
80
+
81
+ # ── Private helpers ────────────────────────────────────────────────────────
82
+
83
+ sig { params(items: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
84
+ def parse_single_transaction_plans(items)
85
+ items.map do |item|
86
+ item.is_a?(TransactionMessages::TransactionMessage) ? single_transaction_plan(item) : item
87
+ end
88
+ end
89
+ private_class_method :parse_single_transaction_plans
90
+ end
91
+ end
@@ -0,0 +1,117 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../errors'
5
+ require_relative 'transaction_plan'
6
+ require_relative 'transaction_plan_result'
7
+
8
+ module Solana::Ruby::Kit
9
+ module InstructionPlans
10
+ extend T::Sig
11
+
12
+ # Creates a TransactionPlanExecutor — a callable that traverses a TransactionPlan,
13
+ # executing each single transaction message and collecting results.
14
+ #
15
+ # Configuration:
16
+ # execute_transaction_message: ->(message) { { transaction:, context: } }
17
+ # Called for each SingleTransactionPlan. Must return a hash with:
18
+ # :transaction — the signed Transaction
19
+ # :context — optional Hash of extra data (defaults to {})
20
+ # Raise a SolanaError to signal failure; remaining plans will be canceled.
21
+ #
22
+ # The returned executor is a lambda: executor.call(transaction_plan) -> TransactionPlanResult
23
+ #
24
+ # Mirrors `createTransactionPlanExecutor(config)` from @solana/instruction-plans.
25
+ # NOTE: Ruby translation is synchronous; abort-signal support is omitted.
26
+ sig { params(execute_transaction_message: T.untyped).returns(T.untyped) }
27
+ def create_transaction_plan_executor(execute_transaction_message:)
28
+ ->(transaction_plan) {
29
+ state = { canceled: false }
30
+ result = executor_traverse(transaction_plan, execute_transaction_message, state)
31
+
32
+ if state[:canceled]
33
+ cause = executor_find_error(result)
34
+ err = SolanaError.new(
35
+ SolanaError::INSTRUCTION_PLANS__FAILED_TO_EXECUTE_TRANSACTION_PLAN,
36
+ { cause: cause }
37
+ )
38
+ # Store the full result tree as a non-enumerable attribute for recovery.
39
+ err.instance_variable_set(:@transaction_plan_result, result)
40
+ err.define_singleton_method(:transaction_plan_result) { @transaction_plan_result }
41
+ Kernel.raise err
42
+ end
43
+
44
+ result
45
+ }
46
+ end
47
+
48
+ module_function :create_transaction_plan_executor
49
+
50
+ # ── Private helpers ────────────────────────────────────────────────────────
51
+
52
+ module_function
53
+
54
+ def executor_traverse(plan, execute_fn, state)
55
+ case plan.kind
56
+ when :sequential then executor_traverse_sequential(plan, execute_fn, state)
57
+ when :parallel then executor_traverse_parallel(plan, execute_fn, state)
58
+ when :single then executor_traverse_single(plan, execute_fn, state)
59
+ else
60
+ Kernel.raise SolanaError.new(
61
+ SolanaError::INVARIANT_VIOLATION__INVALID_TRANSACTION_PLAN_KIND,
62
+ { kind: plan.kind }
63
+ )
64
+ end
65
+ end
66
+
67
+ def executor_traverse_sequential(plan, execute_fn, state)
68
+ results = plan.plans.map { |sub| executor_traverse(sub, execute_fn, state) }
69
+ plan.divisible \
70
+ ? sequential_transaction_plan_result(results) \
71
+ : non_divisible_sequential_transaction_plan_result(results)
72
+ end
73
+
74
+ def executor_traverse_parallel(plan, execute_fn, state)
75
+ results = plan.plans.map { |sub| executor_traverse(sub, execute_fn, state) }
76
+ parallel_transaction_plan_result(results)
77
+ end
78
+
79
+ def executor_traverse_single(plan, execute_fn, state)
80
+ return canceled_single_transaction_plan_result(plan.message) if state[:canceled]
81
+
82
+ begin
83
+ result = execute_fn.call(plan.message)
84
+ transaction = result.fetch(:transaction)
85
+ context = result.fetch(:context, {})
86
+ successful_single_transaction_plan_result(plan.message, transaction, context)
87
+ rescue SolanaError => e
88
+ state[:canceled] = true
89
+ failed_single_transaction_plan_result(plan.message, e)
90
+ rescue => e
91
+ state[:canceled] = true
92
+ wrapped = SolanaError.new(
93
+ SolanaError::INSTRUCTION_PLANS__FAILED_TO_EXECUTE_TRANSACTION_PLAN,
94
+ { cause: e }
95
+ )
96
+ failed_single_transaction_plan_result(plan.message, wrapped)
97
+ end
98
+ end
99
+
100
+ def executor_find_error(result)
101
+ return result.status.error if result.kind == :single && result.status.kind == :failed
102
+ return nil if result.kind == :single
103
+
104
+ result.plans.each do |sub|
105
+ err = executor_find_error(sub)
106
+ return err if err
107
+ end
108
+ nil
109
+ end
110
+
111
+ private_class_method :executor_traverse
112
+ private_class_method :executor_traverse_sequential
113
+ private_class_method :executor_traverse_parallel
114
+ private_class_method :executor_traverse_single
115
+ private_class_method :executor_find_error
116
+ end
117
+ end
@@ -0,0 +1,117 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../errors'
5
+ require_relative '../transaction_messages/transaction_message'
6
+ require_relative '../transactions/transaction'
7
+
8
+ module Solana::Ruby::Kit
9
+ module InstructionPlans
10
+ extend T::Sig
11
+
12
+ # ── Result status types ───────────────────────────────────────────────────
13
+ #
14
+ # Each single transaction produces one of three statuses:
15
+ # successful: { kind: :successful, transaction:, context: }
16
+ # failed: { kind: :failed, error: }
17
+ # canceled: { kind: :canceled }
18
+ #
19
+ # Mirrors TypeScript's TransactionPlanResultStatus union.
20
+
21
+ class SuccessfulStatus < T::Struct
22
+ const :transaction, Transactions::Transaction
23
+ const :context, T::Hash[T.untyped, T.untyped]
24
+ def kind = :successful
25
+ end
26
+
27
+ class FailedStatus < T::Struct
28
+ const :error, SolanaError
29
+ def kind = :failed
30
+ end
31
+
32
+ class CanceledStatus < T::Struct
33
+ def kind = :canceled
34
+ end
35
+
36
+ # ── Result plan types ──────────────────────────────────────────────────────
37
+
38
+ class SingleTransactionPlanResult < T::Struct
39
+ const :message, TransactionMessages::TransactionMessage
40
+ const :status, T.untyped # SuccessfulStatus | FailedStatus | CanceledStatus
41
+ def kind = :single
42
+ end
43
+
44
+ class SequentialTransactionPlanResult < T::Struct
45
+ const :plans, T::Array[T.untyped] # Array[TransactionPlanResult]
46
+ const :divisible, T::Boolean
47
+ def kind = :sequential
48
+ end
49
+
50
+ class ParallelTransactionPlanResult < T::Struct
51
+ const :plans, T::Array[T.untyped] # Array[TransactionPlanResult]
52
+ def kind = :parallel
53
+ end
54
+
55
+ module_function
56
+
57
+ # ── Result factory helpers ────────────────────────────────────────────────
58
+
59
+ # Mirrors `sequentialTransactionPlanResult(plans)`.
60
+ sig { params(plans: T::Array[T.untyped]).returns(SequentialTransactionPlanResult) }
61
+ def sequential_transaction_plan_result(plans)
62
+ SequentialTransactionPlanResult.new(plans: plans, divisible: true)
63
+ end
64
+
65
+ # Mirrors `nonDivisibleSequentialTransactionPlanResult(plans)`.
66
+ sig { params(plans: T::Array[T.untyped]).returns(SequentialTransactionPlanResult) }
67
+ def non_divisible_sequential_transaction_plan_result(plans)
68
+ SequentialTransactionPlanResult.new(plans: plans, divisible: false)
69
+ end
70
+
71
+ # Mirrors `parallelTransactionPlanResult(plans)`.
72
+ sig { params(plans: T::Array[T.untyped]).returns(ParallelTransactionPlanResult) }
73
+ def parallel_transaction_plan_result(plans)
74
+ ParallelTransactionPlanResult.new(plans: plans)
75
+ end
76
+
77
+ # Mirrors `successfulSingleTransactionPlanResult(message, transaction, context)`.
78
+ sig do
79
+ params(
80
+ message: TransactionMessages::TransactionMessage,
81
+ transaction: Transactions::Transaction,
82
+ context: T::Hash[T.untyped, T.untyped]
83
+ ).returns(SingleTransactionPlanResult)
84
+ end
85
+ def successful_single_transaction_plan_result(message, transaction, context = {})
86
+ SingleTransactionPlanResult.new(
87
+ message: message,
88
+ status: SuccessfulStatus.new(transaction: transaction, context: context)
89
+ )
90
+ end
91
+
92
+ # Mirrors `failedSingleTransactionPlanResult(message, error)`.
93
+ sig do
94
+ params(
95
+ message: TransactionMessages::TransactionMessage,
96
+ error: SolanaError
97
+ ).returns(SingleTransactionPlanResult)
98
+ end
99
+ def failed_single_transaction_plan_result(message, error)
100
+ SingleTransactionPlanResult.new(
101
+ message: message,
102
+ status: FailedStatus.new(error: error)
103
+ )
104
+ end
105
+
106
+ # Mirrors `canceledSingleTransactionPlanResult(message)`.
107
+ sig do
108
+ params(message: TransactionMessages::TransactionMessage).returns(SingleTransactionPlanResult)
109
+ end
110
+ def canceled_single_transaction_plan_result(message)
111
+ SingleTransactionPlanResult.new(
112
+ message: message,
113
+ status: CanceledStatus.new
114
+ )
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,264 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../errors'
5
+ require_relative '../transaction_messages/transaction_message'
6
+ require_relative '../transactions/compiler'
7
+ require_relative 'instruction_plan'
8
+ require_relative 'transaction_plan'
9
+
10
+ module Solana::Ruby::Kit
11
+ module InstructionPlans
12
+ extend T::Sig
13
+
14
+ # Creates a TransactionPlanner — a callable that converts an InstructionPlan
15
+ # into a TransactionPlan by packing instructions into transaction messages.
16
+ #
17
+ # Configuration:
18
+ # create_transaction_message: -> { TransactionMessage }
19
+ # Called whenever a new (empty) message is needed. Must return a message
20
+ # with at least a fee payer and blockhash already set.
21
+ #
22
+ # on_transaction_message_updated: ->(message) { TransactionMessage } (optional)
23
+ # Called after instructions are appended to a message. Must return the
24
+ # (possibly further updated) message. Defaults to identity.
25
+ #
26
+ # The returned planner is a lambda: planner.call(instruction_plan) -> TransactionPlan
27
+ #
28
+ # Mirrors `createTransactionPlanner(config)` from @solana/instruction-plans.
29
+ # NOTE: Ruby translation is synchronous; abort-signal support is omitted.
30
+ sig do
31
+ params(
32
+ create_transaction_message: T.untyped,
33
+ on_transaction_message_updated: T.untyped
34
+ ).returns(T.untyped)
35
+ end
36
+ def create_transaction_planner(
37
+ create_transaction_message:,
38
+ on_transaction_message_updated: ->(msg) { msg }
39
+ )
40
+ ctx = {
41
+ create_transaction_message: create_transaction_message,
42
+ on_transaction_message_updated: on_transaction_message_updated
43
+ }
44
+
45
+ ->(instruction_plan) {
46
+ mutable = planner_traverse(instruction_plan, ctx.merge(parent: nil, parent_candidates: []))
47
+ Kernel.raise SolanaError.new(SolanaError::INSTRUCTION_PLANS__EMPTY_INSTRUCTION_PLAN) unless mutable
48
+ planner_freeze(mutable)
49
+ }
50
+ end
51
+
52
+ module_function :create_transaction_planner
53
+
54
+ # ── Private helpers ────────────────────────────────────────────────────────
55
+
56
+ module_function
57
+
58
+ def planner_traverse(plan, ctx)
59
+ case plan.kind
60
+ when :sequential then planner_traverse_sequential(plan, ctx)
61
+ when :parallel then planner_traverse_parallel(plan, ctx)
62
+ when :single then planner_traverse_single(plan, ctx)
63
+ when :message_packer then planner_traverse_message_packer(plan, ctx)
64
+ else
65
+ Kernel.raise SolanaError.new(
66
+ SolanaError::INVARIANT_VIOLATION__INVALID_INSTRUCTION_PLAN_KIND,
67
+ { kind: plan.kind }
68
+ )
69
+ end
70
+ end
71
+
72
+ def planner_traverse_sequential(plan, ctx)
73
+ must_fit_in_parent = ctx[:parent] && (ctx[:parent].kind == :parallel || !plan.divisible)
74
+
75
+ if must_fit_in_parent
76
+ candidate = planner_select_and_mutate_candidate(ctx, ctx[:parent_candidates]) do |msg|
77
+ planner_fit_entire_plan(plan, msg, ctx)
78
+ end
79
+ return nil if candidate
80
+ end
81
+
82
+ candidate = (!must_fit_in_parent && !ctx[:parent_candidates].empty?) ? ctx[:parent_candidates][0] : nil
83
+ tx_plans = []
84
+
85
+ plan.plans.each do |sub_plan|
86
+ sub_ctx = ctx.merge(parent: plan, parent_candidates: candidate ? [candidate] : [])
87
+ tx_plan = planner_traverse(sub_plan, sub_ctx)
88
+ next unless tx_plan
89
+
90
+ candidate = planner_get_sequential_candidate(tx_plan)
91
+ new_plans =
92
+ tx_plan[:kind] == :sequential && (tx_plan[:divisible] || !plan.divisible) \
93
+ ? tx_plan[:plans] \
94
+ : [tx_plan]
95
+ tx_plans.concat(new_plans)
96
+ end
97
+
98
+ return nil if tx_plans.empty?
99
+ return tx_plans[0] if tx_plans.length == 1
100
+ { kind: :sequential, divisible: plan.divisible, plans: tx_plans }
101
+ end
102
+
103
+ def planner_traverse_parallel(plan, ctx)
104
+ candidates = ctx[:parent_candidates].dup
105
+ tx_plans = []
106
+
107
+ sorted = plan.plans.sort_by { |p| p.kind == :message_packer ? 1 : 0 }
108
+
109
+ sorted.each do |sub_plan|
110
+ sub_ctx = ctx.merge(parent: plan, parent_candidates: candidates)
111
+ tx_plan = planner_traverse(sub_plan, sub_ctx)
112
+ next unless tx_plan
113
+
114
+ candidates.concat(planner_get_parallel_candidates(tx_plan))
115
+ new_plans = tx_plan[:kind] == :parallel ? tx_plan[:plans] : [tx_plan]
116
+ tx_plans.concat(new_plans)
117
+ end
118
+
119
+ return nil if tx_plans.empty?
120
+ return tx_plans[0] if tx_plans.length == 1
121
+ { kind: :parallel, plans: tx_plans }
122
+ end
123
+
124
+ def planner_traverse_single(plan, ctx)
125
+ predicate = ->(msg) { TransactionMessages.append_instructions(msg, [plan.instruction]) }
126
+ candidate = planner_select_and_mutate_candidate(ctx, ctx[:parent_candidates], &predicate)
127
+ return nil if candidate
128
+
129
+ msg = planner_create_new_message(ctx, &predicate)
130
+ { kind: :single, message: msg }
131
+ end
132
+
133
+ def planner_traverse_message_packer(plan, ctx)
134
+ packer = plan.get_message_packer.call
135
+ tx_plans = []
136
+ candidates = ctx[:parent_candidates].dup
137
+
138
+ until packer.done?
139
+ predicate = ->(msg) { packer.pack_message_to_capacity(msg) }
140
+ candidate = planner_select_and_mutate_candidate(ctx, candidates, &predicate)
141
+ unless candidate
142
+ msg = planner_create_new_message(ctx, &predicate)
143
+ new_plan = { kind: :single, message: msg }
144
+ tx_plans << new_plan
145
+ candidates << new_plan
146
+ end
147
+ end
148
+
149
+ return nil if tx_plans.empty?
150
+ return tx_plans[0] if tx_plans.length == 1
151
+
152
+ if ctx[:parent]&.kind == :parallel
153
+ { kind: :parallel, plans: tx_plans }
154
+ else
155
+ divisible = ctx[:parent]&.kind == :sequential ? ctx[:parent].divisible : true
156
+ { kind: :sequential, divisible: divisible, plans: tx_plans }
157
+ end
158
+ end
159
+
160
+ def planner_select_and_mutate_candidate(ctx, candidates, &predicate)
161
+ candidates.each do |candidate|
162
+ begin
163
+ updated = ctx[:on_transaction_message_updated].call(predicate.call(candidate[:message]))
164
+ if Transactions.get_transaction_message_size(updated) <= Transactions::TRANSACTION_SIZE_LIMIT
165
+ candidate[:message] = updated
166
+ return candidate
167
+ end
168
+ rescue SolanaError => e
169
+ next if e.code == SolanaError::INSTRUCTION_PLANS__MESSAGE_CANNOT_ACCOMMODATE_PLAN
170
+ Kernel.raise
171
+ end
172
+ end
173
+ nil
174
+ end
175
+
176
+ def planner_create_new_message(ctx, &predicate)
177
+ new_msg = ctx[:create_transaction_message].call
178
+ updated_msg = ctx[:on_transaction_message_updated].call(predicate.call(new_msg))
179
+ updated_size = Transactions.get_transaction_message_size(updated_msg)
180
+
181
+ if updated_size > Transactions::TRANSACTION_SIZE_LIMIT
182
+ new_size = Transactions.get_transaction_message_size(new_msg)
183
+ Kernel.raise SolanaError.new(
184
+ SolanaError::INSTRUCTION_PLANS__MESSAGE_CANNOT_ACCOMMODATE_PLAN,
185
+ { num_bytes_required: updated_size - new_size,
186
+ num_free_bytes: Transactions::TRANSACTION_SIZE_LIMIT - new_size }
187
+ )
188
+ end
189
+
190
+ updated_msg
191
+ end
192
+
193
+ def planner_fit_entire_plan(plan, message, ctx)
194
+ case plan.kind
195
+ when :sequential, :parallel
196
+ plan.plans.reduce(message) { |msg, sub| planner_fit_entire_plan(sub, msg, ctx) }
197
+ when :single
198
+ updated = TransactionMessages.append_instructions(message, [plan.instruction])
199
+ new_size = Transactions.get_transaction_message_size(updated)
200
+ if new_size > Transactions::TRANSACTION_SIZE_LIMIT
201
+ base_size = Transactions.get_transaction_message_size(message)
202
+ Kernel.raise SolanaError.new(
203
+ SolanaError::INSTRUCTION_PLANS__MESSAGE_CANNOT_ACCOMMODATE_PLAN,
204
+ { num_bytes_required: new_size - base_size,
205
+ num_free_bytes: Transactions::TRANSACTION_SIZE_LIMIT - base_size }
206
+ )
207
+ end
208
+ updated
209
+ when :message_packer
210
+ packer = plan.get_message_packer.call
211
+ msg = message
212
+ msg = packer.pack_message_to_capacity(msg) until packer.done?
213
+ msg
214
+ else
215
+ Kernel.raise SolanaError.new(
216
+ SolanaError::INVARIANT_VIOLATION__INVALID_INSTRUCTION_PLAN_KIND,
217
+ { kind: plan.kind }
218
+ )
219
+ end
220
+ end
221
+
222
+ def planner_get_sequential_candidate(plan)
223
+ return plan if plan[:kind] == :single
224
+ return nil unless plan[:kind] == :sequential && plan[:plans]&.any?
225
+ planner_get_sequential_candidate(plan[:plans].last)
226
+ end
227
+
228
+ def planner_get_parallel_candidates(plan)
229
+ return [plan] if plan[:kind] == :single
230
+ (plan[:plans] || []).flat_map { |p| planner_get_parallel_candidates(p) }
231
+ end
232
+
233
+ def planner_freeze(plan)
234
+ case plan[:kind]
235
+ when :single
236
+ single_transaction_plan(plan[:message])
237
+ when :sequential
238
+ frozen_plans = plan[:plans].map { |p| planner_freeze(p) }
239
+ plan[:divisible] \
240
+ ? sequential_transaction_plan(frozen_plans) \
241
+ : non_divisible_sequential_transaction_plan(frozen_plans)
242
+ when :parallel
243
+ parallel_transaction_plan(plan[:plans].map { |p| planner_freeze(p) })
244
+ else
245
+ Kernel.raise SolanaError.new(
246
+ SolanaError::INVARIANT_VIOLATION__INVALID_TRANSACTION_PLAN_KIND,
247
+ { kind: plan[:kind] }
248
+ )
249
+ end
250
+ end
251
+
252
+ private_class_method :planner_traverse
253
+ private_class_method :planner_traverse_sequential
254
+ private_class_method :planner_traverse_parallel
255
+ private_class_method :planner_traverse_single
256
+ private_class_method :planner_traverse_message_packer
257
+ private_class_method :planner_select_and_mutate_candidate
258
+ private_class_method :planner_create_new_message
259
+ private_class_method :planner_fit_entire_plan
260
+ private_class_method :planner_get_sequential_candidate
261
+ private_class_method :planner_get_parallel_candidates
262
+ private_class_method :planner_freeze
263
+ end
264
+ end
@@ -3,45 +3,8 @@
3
3
 
4
4
  # Mirrors @solana/instruction-plans.
5
5
  # An InstructionPlan describes operations that may span multiple transactions.
6
- require_relative 'instruction_plans/plans'
7
-
8
- module Solana::Ruby::Kit
9
- module InstructionPlans
10
- extend T::Sig
11
-
12
- module_function
13
-
14
- # Build a plan that wraps a single instruction.
15
- sig { params(instruction: Instructions::Instruction).returns(SingleInstructionPlan) }
16
- def single_instruction_plan(instruction)
17
- SingleInstructionPlan.new(instruction: instruction)
18
- end
19
-
20
- # Build a sequential plan from an array of sub-plans.
21
- sig { params(steps: T::Array[T.untyped], divisible: T::Boolean).returns(SequentialInstructionPlan) }
22
- def sequential_instruction_plan(steps, divisible: false)
23
- SequentialInstructionPlan.new(steps: steps, divisible: divisible)
24
- end
25
-
26
- # Build a parallel plan from an array of sub-plans.
27
- sig { params(plans: T::Array[T.untyped]).returns(ParallelInstructionPlan) }
28
- def parallel_instruction_plan(plans)
29
- ParallelInstructionPlan.new(plans: plans)
30
- end
31
-
32
- # Flatten a plan tree into a single ordered Array of Instructions.
33
- sig { params(plan: T.untyped).returns(T::Array[Instructions::Instruction]) }
34
- def flatten_instruction_plan(plan)
35
- case plan
36
- when SingleInstructionPlan
37
- [plan.instruction]
38
- when SequentialInstructionPlan
39
- plan.steps.flat_map { |s| flatten_instruction_plan(s) }
40
- when ParallelInstructionPlan
41
- plan.plans.flat_map { |p| flatten_instruction_plan(p) }
42
- else
43
- Kernel.raise ArgumentError, "Unknown InstructionPlan type: #{plan.class}"
44
- end
45
- end
46
- end
47
- end
6
+ require_relative 'instruction_plans/instruction_plan'
7
+ require_relative 'instruction_plans/transaction_plan'
8
+ require_relative 'instruction_plans/transaction_plan_result'
9
+ require_relative 'instruction_plans/transaction_planner'
10
+ require_relative 'instruction_plans/transaction_plan_executor'
@@ -9,8 +9,19 @@ require_relative '../errors'
9
9
 
10
10
  module Solana::Ruby::Kit
11
11
  module Transactions
12
+ # Maximum wire-encoded transaction size in bytes (Solana protocol limit).
13
+ TRANSACTION_SIZE_LIMIT = T.let(1232, Integer)
14
+
12
15
  module_function
13
16
 
17
+ # Returns the compiled byte-size of a transaction message.
18
+ # Mirrors `getTransactionMessageSize()` from @solana/transactions.
19
+ # The message must have a fee payer and a blockhash lifetime set.
20
+ sig { params(message: TransactionMessages::TransactionMessage).returns(Integer) }
21
+ def get_transaction_message_size(message)
22
+ compile_transaction_message(message).message_bytes.bytesize
23
+ end
24
+
14
25
  # ---------------------------------------------------------------------------
15
26
  # compile_transaction_message
16
27
  # ---------------------------------------------------------------------------
@@ -40,10 +51,6 @@ module Solana::Ruby::Kit
40
51
  Kernel.raise SolanaError.new(:SOLANA_ERROR__TRANSACTION__FEE_PAYER_MISSING) if message.fee_payer.nil?
41
52
  fee_payer = T.must(message.fee_payer)
42
53
 
43
- constraint = message.lifetime_constraint
44
- Kernel.raise SolanaError.new(:SOLANA_ERROR__TRANSACTION__EXPECTED_BLOCKHASH_LIFETIME) unless constraint.is_a?(TransactionMessages::BlockhashLifetimeConstraint)
45
- blockhash_str = constraint.blockhash
46
-
47
54
  # ── 1. Collect accounts and merge roles ────────────────────────────────
48
55
  # Insertion-ordered hash: address_str → merged AccountRole integer.
49
56
  account_roles = T.let({}, T::Hash[String, Integer])
@@ -115,7 +122,19 @@ module Solana::Ruby::Kit
115
122
  end
116
123
 
117
124
  # ── 6. Recent blockhash (32 bytes) ─────────────────────────────────────
118
- blockhash_bytes = Addresses.decode_address(Addresses::Address.new(blockhash_str))
125
+ # Mirrors upstream compile-transaction.ts #581:
126
+ # - BlockhashLifetime → use the blockhash bytes
127
+ # - DurableNonceLifetime → use the nonce value bytes (stored in the same field)
128
+ # - No lifetime → 32 zero bytes (placeholder; must be replaced before signing)
129
+ constraint = message.lifetime_constraint
130
+ blockhash_bytes =
131
+ if constraint.is_a?(TransactionMessages::BlockhashLifetimeConstraint)
132
+ Addresses.decode_address(Addresses::Address.new(constraint.blockhash))
133
+ elsif constraint.is_a?(TransactionMessages::DurableNonceLifetimeConstraint)
134
+ Addresses.decode_address(Addresses::Address.new(constraint.nonce))
135
+ else
136
+ ("\x00" * 32).b
137
+ end
119
138
 
120
139
  # ── 7. Instructions section ────────────────────────────────────────────
121
140
  ixs_section = encode_compact_u16(message.instructions.size)
@@ -67,6 +67,42 @@ module Solana::Ruby::Kit
67
67
  )
68
68
  end
69
69
 
70
+ # Returns true if the wire-encoded transaction fits within TRANSACTION_SIZE_LIMIT.
71
+ # Mirrors `isTransactionWithinSizeLimit(transaction)`.
72
+ sig { params(transaction: Transaction).returns(T::Boolean) }
73
+ def within_size_limit?(transaction)
74
+ wire_encode_transaction(transaction).bytesize <= TRANSACTION_SIZE_LIMIT
75
+ end
76
+
77
+ # Raises SolanaError if the wire-encoded transaction exceeds TRANSACTION_SIZE_LIMIT.
78
+ # Mirrors `assertIsTransactionWithinSizeLimit(transaction)`.
79
+ sig { params(transaction: Transaction).void }
80
+ def assert_within_size_limit!(transaction)
81
+ actual = wire_encode_transaction(transaction).bytesize
82
+ return if actual <= TRANSACTION_SIZE_LIMIT
83
+
84
+ Kernel.raise SolanaError.new(
85
+ SolanaError::TRANSACTIONS__EXCEEDS_SIZE_LIMIT,
86
+ { actual_size: actual, limit: TRANSACTION_SIZE_LIMIT }
87
+ )
88
+ end
89
+
90
+ # Returns true if the transaction is both fully signed and within the size limit.
91
+ # Mirrors `isSendableTransaction(transaction)`.
92
+ # sendableTransaction = FullySignedTransaction & TransactionWithinSizeLimit
93
+ sig { params(transaction: Transaction).returns(T::Boolean) }
94
+ def sendable_transaction?(transaction)
95
+ fully_signed_transaction?(transaction) && within_size_limit?(transaction)
96
+ end
97
+
98
+ # Raises SolanaError unless the transaction is fully signed and within size limit.
99
+ # Mirrors `assertIsSendableTransaction(transaction)`.
100
+ sig { params(transaction: Transaction).void }
101
+ def assert_sendable_transaction!(transaction)
102
+ assert_fully_signed_transaction!(transaction)
103
+ assert_within_size_limit!(transaction)
104
+ end
105
+
70
106
  # Signs a transaction with one or more RbNaCl::SigningKey objects.
71
107
  # Only keys whose address appears in `transaction.signatures` are applied.
72
108
  # Raises SolanaError if a key is not expected to sign this transaction.
@@ -4,7 +4,7 @@
4
4
  module Solana
5
5
  module Ruby
6
6
  module Kit
7
- VERSION = '0.1.4'
7
+ VERSION = '0.1.5'
8
8
  end
9
9
  end
10
10
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solana-ruby-kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.4
4
+ version: 0.1.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Paul Zupan, Idhra Inc.
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-02-24 00:00:00.000000000 Z
10
+ date: 2026-04-09 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: openssl
@@ -208,7 +208,12 @@ files:
208
208
  - lib/solana/ruby/kit/fast_stable_stringify.rb
209
209
  - lib/solana/ruby/kit/functional.rb
210
210
  - lib/solana/ruby/kit/instruction_plans.rb
211
+ - lib/solana/ruby/kit/instruction_plans/instruction_plan.rb
211
212
  - lib/solana/ruby/kit/instruction_plans/plans.rb
213
+ - lib/solana/ruby/kit/instruction_plans/transaction_plan.rb
214
+ - lib/solana/ruby/kit/instruction_plans/transaction_plan_executor.rb
215
+ - lib/solana/ruby/kit/instruction_plans/transaction_plan_result.rb
216
+ - lib/solana/ruby/kit/instruction_plans/transaction_planner.rb
212
217
  - lib/solana/ruby/kit/instructions.rb
213
218
  - lib/solana/ruby/kit/instructions/accounts.rb
214
219
  - lib/solana/ruby/kit/instructions/instruction.rb