solana-ruby-kit 0.1.0 → 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: 328621bd69f0601b600df302ea5169001983cf56fa44cf23225e3f2515d5d34a
4
- data.tar.gz: 5e2bc165e53ff6818b5650f5d8551e6ec3331f65df3e656d0bb7ecd588c16fb3
3
+ metadata.gz: 12771cac3706269f104b012ee691d4cd42856f61560d54b9e959308184c90be6
4
+ data.tar.gz: 52bc18d7dff9bce3c2f27115772275e8a2b0decd985faa4ef2f43723056030d9
5
5
  SHA512:
6
- metadata.gz: 7664963dae246c46ef5d09c7f1d3d05b5c67cecc38dbd961955da8e86cce9a4d0aeff55fcc47425b35826dfe552bb032715f5f178dea8e8c716828013ebb151d
7
- data.tar.gz: a5ee1ed23cca39029dc7a5e84f6319f06ef3319aaeb62488b12b702c6f7c391b8256ed7c706c140e4242285042a4d5b8c0ce7eb0fa484368a182556aa7ac0b84
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