solana-ruby-kit 0.1.9 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1d26df33c1471059cde38ac46ec2604e0c26ffc8d64c580de4171396516530fd
4
- data.tar.gz: dba1b05e818b10e8fa7692f6ae4af89c3946bcd19707be52cfdfd1c503e81b63
3
+ metadata.gz: 7b2766067cfcd434e5c657e1fde1dab4b44b23880e0f514f924d28a94ea0ca4a
4
+ data.tar.gz: 32814612b342df38df079aaba08b8ea17c9128ac9f54279ab3abad622ba02492
5
5
  SHA512:
6
- metadata.gz: 56674f680f3059cd3247591fe2a14f303853b8806e251f4c147de90fb4ff7ebcea498394a2650aab40089d3cb5e52b10686cd3c5797e171c230a21ef05602eb3
7
- data.tar.gz: b57dd91be86b034b1a207c809a04c38ca045700f608e4481ac125536d546f21f68e796f01930259a5054ea5ecf72f020f9260d4b63f6661dd177525c32ab924d
6
+ metadata.gz: 3bcfbd20e6f610a47053cb385511931004590646ff04e0edf4587e5699df3db8bb148af2b706cf025e8399f26e4736ae9db35ebfd04d68be287df8302bc9f4de
7
+ data.tar.gz: d40b332b7cc427730b17b01a091c53815c596c0ac114bca93f52bed4d35775cd2b57ca5146893e2c94d46cebcbe0f51ccf98d2c6e2cad308cd51c2ff7b022d62
@@ -56,6 +56,10 @@ module Solana::Ruby::Kit
56
56
  TRANSACTIONS__FAILED_TO_DECOMPILE_ADDRESS_LOOKUP_TABLE_CONTENTS = :SOLANA_ERROR__TRANSACTIONS__FAILED_TO_DECOMPILE_ADDRESS_LOOKUP_TABLE_CONTENTS
57
57
  TRANSACTIONS__FAILED_TO_DECOMPILE_FEE_PAYER_MISSING = :SOLANA_ERROR__TRANSACTIONS__FAILED_TO_DECOMPILE_FEE_PAYER_MISSING
58
58
  TRANSACTIONS__FAILED_TO_DECOMPILE_INSTRUCTION_PROGRAM_ADDRESS_NOT_FOUND = :SOLANA_ERROR__TRANSACTIONS__FAILED_TO_DECOMPILE_INSTRUCTION_PROGRAM_ADDRESS_NOT_FOUND
59
+ # context: { index: }
60
+ TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_ACCOUNT_INDEX_OUT_OF_RANGE = :SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_ACCOUNT_INDEX_OUT_OF_RANGE
61
+ # context: { unsupported_version: }
62
+ TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED = :SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED
59
63
  TRANSACTIONS__SEND_TRANSACTION_PREFLIGHT_FAILURE = :SOLANA_ERROR__TRANSACTIONS__SEND_TRANSACTION_PREFLIGHT_FAILURE
60
64
  TRANSACTIONS__BLOCKHASH_NOT_FOUND = :SOLANA_ERROR__TRANSACTIONS__BLOCKHASH_NOT_FOUND
61
65
  TRANSACTIONS__FAILED_TRANSACTION_PLAN = :SOLANA_ERROR__TRANSACTIONS__FAILED_TRANSACTION_PLAN
@@ -118,6 +122,16 @@ module Solana::Ruby::Kit
118
122
  INSTRUCTION_PLANS__MESSAGE_PACKER_ALREADY_COMPLETE = :SOLANA_ERROR__INSTRUCTION_PLANS__MESSAGE_PACKER_ALREADY_COMPLETE
119
123
  INSTRUCTION_PLANS__EMPTY_INSTRUCTION_PLAN = :SOLANA_ERROR__INSTRUCTION_PLANS__EMPTY_INSTRUCTION_PLAN
120
124
  INSTRUCTION_PLANS__FAILED_TO_EXECUTE_TRANSACTION_PLAN = :SOLANA_ERROR__INSTRUCTION_PLANS__FAILED_TO_EXECUTE_TRANSACTION_PLAN
125
+ # context: { max_instructions:, transaction_instruction_limit: }
126
+ INSTRUCTION_PLANS__INVALID_MAX_INSTRUCTIONS_PER_TRANSACTION = :SOLANA_ERROR__INSTRUCTION_PLANS__INVALID_MAX_INSTRUCTIONS_PER_TRANSACTION
127
+ # context: { max_instructions:, num_instructions: }
128
+ INSTRUCTION_PLANS__MAX_INSTRUCTIONS_PER_TRANSACTION_EXCEEDED = :SOLANA_ERROR__INSTRUCTION_PLANS__MAX_INSTRUCTIONS_PER_TRANSACTION_EXCEEDED
129
+
130
+ # ── Transaction introspection ────────────────────────────────────────────
131
+ TRANSACTION_INTROSPECTION__CANNOT_DECODE_JSON_PARSED_TRANSACTION = :SOLANA_ERROR__TRANSACTION_INTROSPECTION__CANNOT_DECODE_JSON_PARSED_TRANSACTION
132
+ TRANSACTION_INTROSPECTION__UNRECOGNIZED_GET_TRANSACTION_RESPONSE = :SOLANA_ERROR__TRANSACTION_INTROSPECTION__UNRECOGNIZED_GET_TRANSACTION_RESPONSE
133
+ # context: { needed_bytes: }
134
+ TRANSACTION_INTROSPECTION__MALFORMED_COMPILED_MESSAGE = :SOLANA_ERROR__TRANSACTION_INTROSPECTION__MALFORMED_COMPILED_MESSAGE
121
135
 
122
136
  # ── Wallet Standard ───────────────────────────────────────────────────────
123
137
  WALLET_STANDARD__INVALID_WIRE_FORMAT = :SOLANA_ERROR__WALLET_STANDARD__INVALID_WIRE_FORMAT
@@ -184,7 +198,9 @@ module Solana::Ruby::Kit
184
198
  TRANSACTIONS__VERSION_NUMBER_OUT_OF_RANGE => 'Transaction version %{version} is out of range',
185
199
  TRANSACTIONS__FAILED_TO_DECOMPILE_ADDRESS_LOOKUP_TABLE_CONTENTS => 'Failed to decompile address lookup table contents',
186
200
  TRANSACTIONS__FAILED_TO_DECOMPILE_FEE_PAYER_MISSING => 'Failed to decompile transaction: fee payer is missing',
187
- TRANSACTIONS__FAILED_TO_DECOMPILE_INSTRUCTION_PROGRAM_ADDRESS_NOT_FOUND => 'Failed to decompile instruction: program address not found',
201
+ TRANSACTIONS__FAILED_TO_DECOMPILE_INSTRUCTION_PROGRAM_ADDRESS_NOT_FOUND => 'Failed to decompile instruction: program address not found at index %{index}',
202
+ TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_ACCOUNT_INDEX_OUT_OF_RANGE => 'Failed to decompile instruction: account index %{index} is out of range',
203
+ TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED => 'Transaction version %{unsupported_version} is not supported',
188
204
  TRANSACTIONS__SEND_TRANSACTION_PREFLIGHT_FAILURE => 'Transaction simulation failed: %{message}',
189
205
  TRANSACTIONS__BLOCKHASH_NOT_FOUND => 'Blockhash not found',
190
206
  TRANSACTIONS__FAILED_TRANSACTION_PLAN => 'Failed to execute transaction plan',
@@ -245,6 +261,13 @@ module Solana::Ruby::Kit
245
261
  INSTRUCTION_PLANS__MESSAGE_PACKER_ALREADY_COMPLETE => 'Message packer is already complete; no more instructions to pack',
246
262
  INSTRUCTION_PLANS__EMPTY_INSTRUCTION_PLAN => 'Instruction plan is empty and produced no transaction messages',
247
263
  INSTRUCTION_PLANS__FAILED_TO_EXECUTE_TRANSACTION_PLAN => 'Failed to execute transaction plan',
264
+ 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.',
265
+ 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.',
266
+
267
+ # Transaction introspection
268
+ 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.",
269
+ TRANSACTION_INTROSPECTION__UNRECOGNIZED_GET_TRANSACTION_RESPONSE => 'Unrecognized getTransaction response shape; expected a base64, base58, or json-encoded transaction.',
270
+ TRANSACTION_INTROSPECTION__MALFORMED_COMPILED_MESSAGE => 'Malformed compiled transaction message: expected at least %{needed_bytes} byte(s)',
248
271
 
249
272
  # Subscribable
250
273
  SUBSCRIBABLE__RETRY_NOT_SUPPORTED => 'This reactive store does not support retry(); use create_reactive_store_from_data_publisher_factory for a retryable store',
@@ -5,6 +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
9
 
9
10
  module Solana::Ruby::Kit
10
11
  module InstructionPlans
@@ -68,9 +69,17 @@ module Solana::Ruby::Kit
68
69
  # Packs as many instructions as possible into +message+ and returns the
69
70
  # updated message. Raises SolanaError if the message is too small or the
70
71
  # 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)
72
+ #
73
+ # +max_instructions+ caps the number of top-level instructions allowed in the
74
+ # returned message (defaults to 16; must be a positive integer no greater than 64).
75
+ sig do
76
+ params(
77
+ message: TransactionMessages::TransactionMessage,
78
+ max_instructions: T.nilable(Integer)
79
+ ).returns(TransactionMessages::TransactionMessage)
80
+ end
81
+ def pack_message_to_capacity(message, max_instructions: nil)
82
+ @pack_proc.call(message, max_instructions)
74
83
  end
75
84
  end
76
85
 
@@ -122,11 +131,15 @@ module Solana::Ruby::Kit
122
131
  offset = T.let(0, Integer)
123
132
  MessagePacker.new(
124
133
  done_proc: -> { offset >= total_length },
125
- pack_proc: ->(message) {
134
+ pack_proc: ->(message, max_instructions) {
126
135
  if offset >= total_length
127
136
  Kernel.raise SolanaError.new(SolanaError::INSTRUCTION_PLANS__MESSAGE_PACKER_ALREADY_COMPLETE)
128
137
  end
129
138
 
139
+ InstructionPlans.assert_valid_max_instructions_per_transaction(max_instructions)
140
+ resolved_max = InstructionPlans.resolve_max_instructions(max_instructions)
141
+ InstructionPlans.assert_max_instructions_per_transaction(message.instructions.length + 1, resolved_max)
142
+
130
143
  base_ix = get_instruction.call(offset, 0)
131
144
  with_base = TransactionMessages.append_instructions(message, [base_ix])
132
145
  base_size = Transactions.get_transaction_message_size(with_base)
@@ -164,18 +177,30 @@ module Solana::Ruby::Kit
164
177
  idx = T.let(0, Integer)
165
178
  MessagePacker.new(
166
179
  done_proc: -> { idx >= instructions.length },
167
- pack_proc: ->(message) {
180
+ pack_proc: ->(message, max_instructions) {
168
181
  if idx >= instructions.length
169
182
  Kernel.raise SolanaError.new(SolanaError::INSTRUCTION_PLANS__MESSAGE_PACKER_ALREADY_COMPLETE)
170
183
  end
171
184
 
185
+ InstructionPlans.assert_valid_max_instructions_per_transaction(max_instructions)
186
+ resolved_max = InstructionPlans.resolve_max_instructions(max_instructions)
187
+ InstructionPlans.assert_max_instructions_per_transaction(message.instructions.length + 1, resolved_max)
188
+
172
189
  original_size = Transactions.get_transaction_message_size(message)
173
190
  packed = T.let(message, TransactionMessages::TransactionMessage)
174
191
  start_idx = idx
175
192
 
176
193
  (idx...instructions.length).each do |i|
177
- packed = TransactionMessages.append_instructions(packed, [instructions[i]])
178
- size = Transactions.get_transaction_message_size(packed)
194
+ # Stop once the message is full, before compiling a message that would exceed
195
+ # the instruction limit (which would raise). The assertion above guarantees at
196
+ # least the first instruction fits, so reaching the limit here is a graceful stop.
197
+ if packed.instructions.length >= resolved_max
198
+ idx = i
199
+ return packed
200
+ end
201
+
202
+ next_packed = TransactionMessages.append_instructions(packed, [instructions[i]])
203
+ size = Transactions.get_transaction_message_size(next_packed)
179
204
 
180
205
  if size > Transactions::TRANSACTION_SIZE_LIMIT
181
206
  if i == start_idx
@@ -190,6 +215,8 @@ module Solana::Ruby::Kit
190
215
  idx = i
191
216
  return packed
192
217
  end
218
+
219
+ packed = next_packed
193
220
  end
194
221
 
195
222
  idx = instructions.length
@@ -0,0 +1,59 @@
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
+ module_function
20
+
21
+ # Resolves the effective maximum number of instructions allowed in a transaction message,
22
+ # falling back to the default when no value is provided.
23
+ # Mirrors `resolveMaxInstructions(maxInstructions)`.
24
+ sig { params(max_instructions: T.nilable(Integer)).returns(Integer) }
25
+ def resolve_max_instructions(max_instructions)
26
+ max_instructions || DEFAULT_MAX_INSTRUCTIONS_PER_TRANSACTION
27
+ end
28
+
29
+ # Asserts that a configured maximum number of instructions per transaction is valid.
30
+ # +nil+ is allowed and falls back to the default.
31
+ # Mirrors `assertValidMaxInstructionsPerTransaction(maxInstructions)`.
32
+ sig { params(max_instructions: T.nilable(Integer)).void }
33
+ def assert_valid_max_instructions_per_transaction(max_instructions)
34
+ return if max_instructions.nil?
35
+
36
+ if max_instructions <= 0 || max_instructions > TRANSACTION_INSTRUCTION_LIMIT
37
+ Kernel.raise SolanaError.new(
38
+ SolanaError::INSTRUCTION_PLANS__INVALID_MAX_INSTRUCTIONS_PER_TRANSACTION,
39
+ {
40
+ max_instructions: max_instructions,
41
+ transaction_instruction_limit: TRANSACTION_INSTRUCTION_LIMIT
42
+ }
43
+ )
44
+ end
45
+ end
46
+
47
+ # Raises if +num_instructions+ exceeds +max_instructions+.
48
+ # Mirrors `assertMaxInstructionsPerTransaction(numInstructions, maxInstructions)`.
49
+ sig { params(num_instructions: Integer, max_instructions: Integer).void }
50
+ def assert_max_instructions_per_transaction(num_instructions, max_instructions)
51
+ return if num_instructions <= max_instructions
52
+
53
+ Kernel.raise SolanaError.new(
54
+ SolanaError::INSTRUCTION_PLANS__MAX_INSTRUCTIONS_PER_TRANSACTION_EXCEEDED,
55
+ { max_instructions: max_instructions, num_instructions: num_instructions }
56
+ )
57
+ end
58
+ end
59
+ end
@@ -23,27 +23,45 @@ module Solana::Ruby::Kit
23
23
  # Called after instructions are appended to a message. Must return the
24
24
  # (possibly further updated) message. Defaults to identity.
25
25
  #
26
- # The returned planner is a lambda: planner.call(instruction_plan) -> TransactionPlan
26
+ # max_instructions_per_transaction: Integer (optional)
27
+ # Maximum number of top-level instructions allowed in each planned transaction.
28
+ # Must be a positive integer no greater than 64. Defaults to 16. Can be overridden
29
+ # per-call via the returned planner's `max_instructions_per_transaction:` kwarg.
30
+ #
31
+ # The returned planner is a lambda:
32
+ # planner.call(instruction_plan, max_instructions_per_transaction: nil) -> TransactionPlan
27
33
  #
28
34
  # Mirrors `createTransactionPlanner(config)` from @solana/instruction-plans.
29
35
  # NOTE: Ruby translation is synchronous; abort-signal support is omitted.
30
36
  sig do
31
37
  params(
32
- create_transaction_message: T.untyped,
33
- on_transaction_message_updated: T.untyped
38
+ create_transaction_message: T.untyped,
39
+ on_transaction_message_updated: T.untyped,
40
+ max_instructions_per_transaction: T.nilable(Integer)
34
41
  ).returns(T.untyped)
35
42
  end
36
43
  def create_transaction_planner(
37
44
  create_transaction_message:,
38
- on_transaction_message_updated: ->(msg) { msg }
45
+ on_transaction_message_updated: ->(msg) { msg },
46
+ max_instructions_per_transaction: nil
39
47
  )
40
48
  ctx = {
41
49
  create_transaction_message: create_transaction_message,
42
50
  on_transaction_message_updated: on_transaction_message_updated
43
51
  }
52
+ config_max_instructions_per_transaction = max_instructions_per_transaction
53
+
54
+ ->(instruction_plan, max_instructions_per_transaction: nil) {
55
+ resolved_max = max_instructions_per_transaction || config_max_instructions_per_transaction
56
+
57
+ # Reject up front any configured maximum the transaction format could never satisfy,
58
+ # rather than discovering it mid-plan when a message fails to compile.
59
+ InstructionPlans.assert_valid_max_instructions_per_transaction(resolved_max)
44
60
 
45
- ->(instruction_plan) {
46
- mutable = planner_traverse(instruction_plan, ctx.merge(parent: nil, parent_candidates: []))
61
+ mutable = planner_traverse(
62
+ instruction_plan,
63
+ ctx.merge(parent: nil, parent_candidates: [], max_instructions_per_transaction: resolved_max)
64
+ )
47
65
  Kernel.raise SolanaError.new(SolanaError::INSTRUCTION_PLANS__EMPTY_INSTRUCTION_PLAN) unless mutable
48
66
  planner_freeze(mutable)
49
67
  }
@@ -136,7 +154,7 @@ module Solana::Ruby::Kit
136
154
  candidates = ctx[:parent_candidates].dup
137
155
 
138
156
  until packer.done?
139
- predicate = ->(msg) { packer.pack_message_to_capacity(msg) }
157
+ predicate = ->(msg) { packer.pack_message_to_capacity(msg, max_instructions: ctx[:max_instructions_per_transaction]) }
140
158
  candidate = planner_select_and_mutate_candidate(ctx, candidates, &predicate)
141
159
  unless candidate
142
160
  msg = planner_create_new_message(ctx, &predicate)
@@ -162,11 +180,18 @@ module Solana::Ruby::Kit
162
180
  begin
163
181
  updated = ctx[:on_transaction_message_updated].call(predicate.call(candidate[:message]))
164
182
  if Transactions.get_transaction_message_size(updated) <= Transactions::TRANSACTION_SIZE_LIMIT
183
+ InstructionPlans.assert_max_instructions_per_transaction(
184
+ updated.instructions.length,
185
+ InstructionPlans.resolve_max_instructions(ctx[:max_instructions_per_transaction])
186
+ )
165
187
  candidate[:message] = updated
166
188
  return candidate
167
189
  end
168
190
  rescue SolanaError => e
169
- next if e.code == SolanaError::INSTRUCTION_PLANS__MESSAGE_CANNOT_ACCOMMODATE_PLAN
191
+ next if [
192
+ SolanaError::INSTRUCTION_PLANS__MESSAGE_CANNOT_ACCOMMODATE_PLAN,
193
+ SolanaError::INSTRUCTION_PLANS__MAX_INSTRUCTIONS_PER_TRANSACTION_EXCEEDED
194
+ ].include?(e.code)
170
195
  Kernel.raise
171
196
  end
172
197
  end
@@ -187,6 +212,11 @@ module Solana::Ruby::Kit
187
212
  )
188
213
  end
189
214
 
215
+ InstructionPlans.assert_max_instructions_per_transaction(
216
+ updated_msg.instructions.length,
217
+ InstructionPlans.resolve_max_instructions(ctx[:max_instructions_per_transaction])
218
+ )
219
+
190
220
  updated_msg
191
221
  end
192
222
 
@@ -205,11 +235,21 @@ module Solana::Ruby::Kit
205
235
  num_free_bytes: Transactions::TRANSACTION_SIZE_LIMIT - base_size }
206
236
  )
207
237
  end
238
+ InstructionPlans.assert_max_instructions_per_transaction(
239
+ updated.instructions.length,
240
+ InstructionPlans.resolve_max_instructions(ctx[:max_instructions_per_transaction])
241
+ )
208
242
  updated
209
243
  when :message_packer
210
244
  packer = plan.get_message_packer.call
211
245
  msg = message
212
- msg = packer.pack_message_to_capacity(msg) until packer.done?
246
+ until packer.done?
247
+ msg = packer.pack_message_to_capacity(msg, max_instructions: ctx[:max_instructions_per_transaction])
248
+ InstructionPlans.assert_max_instructions_per_transaction(
249
+ msg.instructions.length,
250
+ InstructionPlans.resolve_max_instructions(ctx[:max_instructions_per_transaction])
251
+ )
252
+ end
213
253
  msg
214
254
  else
215
255
  Kernel.raise SolanaError.new(
@@ -3,6 +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
7
  require_relative 'instruction_plans/instruction_plan'
7
8
  require_relative 'instruction_plans/transaction_plan'
8
9
  require_relative 'instruction_plans/transaction_plan_result'
@@ -12,6 +12,7 @@ module Solana::Ruby::Kit
12
12
  :activated_stake,
13
13
  :epoch_vote_account,
14
14
  :commission,
15
+ :inflation_rewards_commission_bps,
15
16
  :last_vote,
16
17
  :epoch_credits,
17
18
  :root_slot,
@@ -45,6 +46,7 @@ module Solana::Ruby::Kit
45
46
  activated_stake: Kernel.Integer(v['activatedStake']),
46
47
  epoch_vote_account: v['epochVoteAccount'],
47
48
  commission: Kernel.Integer(v['commission']),
49
+ inflation_rewards_commission_bps: v['inflationRewardsCommissionBps'] ? Kernel.Integer(v['inflationRewardsCommissionBps']) : nil,
48
50
  last_vote: Kernel.Integer(v['lastVote']),
49
51
  epoch_credits: v['epochCredits'],
50
52
  root_slot: v['rootSlot'] ? Kernel.Integer(v['rootSlot']) : nil
@@ -0,0 +1,168 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../addresses/address'
5
+ require_relative '../errors'
6
+
7
+ module Solana::Ruby::Kit
8
+ module TransactionIntrospection
9
+ extend T::Sig
10
+
11
+ # The signer/readonly account counts from a compiled transaction message.
12
+ # Mirrors the `header` field of TypeScript's `CompiledTransactionMessage`.
13
+ class CompiledTransactionMessageHeader < T::Struct
14
+ const :num_signer_accounts, Integer
15
+ const :num_readonly_signer_accounts, Integer
16
+ const :num_readonly_non_signer_accounts, Integer
17
+ end
18
+
19
+ # A single instruction inside a compiled transaction message, with account
20
+ # indices and data still unresolved (see GetInstructions for resolution).
21
+ class CompiledInstruction < T::Struct
22
+ const :program_address_index, Integer
23
+ const :account_indices, T::Array[Integer]
24
+ const :data, String # binary; may be empty
25
+ end
26
+
27
+ # A decoded transaction message: the account roles, static account list,
28
+ # instructions, and lifetime token (recent blockhash or durable nonce value).
29
+ #
30
+ # Mirrors TypeScript's `CompiledTransactionMessage & CompiledTransactionMessageWithLifetime`.
31
+ # Ruby normalizes legacy/v0/v1 instruction representations into a single
32
+ # `instructions` array (TypeScript keeps v1's headers/payloads split) since
33
+ # nothing downstream needs the raw per-version shape.
34
+ class CompiledTransactionMessage < T::Struct
35
+ const :version, T.untyped # :legacy or Integer (0 or 1)
36
+ const :header, CompiledTransactionMessageHeader
37
+ const :static_accounts, T::Array[Addresses::Address]
38
+ const :instructions, T::Array[CompiledInstruction]
39
+ const :lifetime_token, String # base58-encoded blockhash or nonce value
40
+ end
41
+
42
+ module_function
43
+
44
+ # Decodes legacy or v0 compiled transaction message bytes (the
45
+ # `message_bytes` of a `Transactions::Transaction`) into a
46
+ # CompiledTransactionMessage.
47
+ #
48
+ # v1 messages use a different (message-first) wire envelope and are not
49
+ # decoded here — Ruby's wire compiler (`Transactions.compile_transaction_message`)
50
+ # does not yet produce v1 transactions either, so there is nothing to
51
+ # round-trip. Raises SolanaError::TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED
52
+ # for any version this decoder cannot handle.
53
+ #
54
+ # Mirrors `getCompiledTransactionMessageDecoder()` from @solana/transaction-messages.
55
+ sig { params(message_bytes: String).returns(CompiledTransactionMessage) }
56
+ def decode_compiled_transaction_message(message_bytes)
57
+ bytes = message_bytes.b
58
+ offset = 0
59
+
60
+ first_byte = bytes.getbyte(0).to_i
61
+ if (first_byte & 0x80) != 0
62
+ version = first_byte & 0x7f
63
+ unless version == 0
64
+ Kernel.raise SolanaError.new(
65
+ SolanaError::TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED,
66
+ { unsupported_version: version }
67
+ )
68
+ end
69
+ offset += 1
70
+ else
71
+ version = :legacy
72
+ end
73
+
74
+ num_signer_accounts, offset = read_byte(bytes, offset)
75
+ num_readonly_signer_accounts, offset = read_byte(bytes, offset)
76
+ num_readonly_non_signer_accounts, offset = read_byte(bytes, offset)
77
+
78
+ num_accounts, offset = decode_compact_u16(bytes, offset)
79
+ static_accounts = T.let([], T::Array[Addresses::Address])
80
+ num_accounts.times do
81
+ static_accounts << Addresses.address(Addresses.encode_address(read_bytes(bytes, offset, 32)))
82
+ offset += 32
83
+ end
84
+
85
+ lifetime_token = Addresses.encode_address(read_bytes(bytes, offset, 32))
86
+ offset += 32
87
+
88
+ num_instructions, offset = decode_compact_u16(bytes, offset)
89
+ instructions = T.let([], T::Array[CompiledInstruction])
90
+ num_instructions.times do
91
+ program_address_index, offset = read_byte(bytes, offset)
92
+
93
+ num_ix_accounts, offset = decode_compact_u16(bytes, offset)
94
+ account_indices = T.let([], T::Array[Integer])
95
+ num_ix_accounts.times do
96
+ index, offset = read_byte(bytes, offset)
97
+ account_indices << index
98
+ end
99
+
100
+ data_length, offset = decode_compact_u16(bytes, offset)
101
+ data = read_bytes(bytes, offset, data_length)
102
+ offset += data_length
103
+
104
+ instructions << CompiledInstruction.new(
105
+ program_address_index: program_address_index,
106
+ account_indices: account_indices,
107
+ data: data
108
+ )
109
+ end
110
+
111
+ CompiledTransactionMessage.new(
112
+ version: version,
113
+ header: CompiledTransactionMessageHeader.new(
114
+ num_signer_accounts: num_signer_accounts,
115
+ num_readonly_signer_accounts: num_readonly_signer_accounts,
116
+ num_readonly_non_signer_accounts: num_readonly_non_signer_accounts
117
+ ),
118
+ static_accounts: static_accounts,
119
+ instructions: instructions,
120
+ lifetime_token: lifetime_token
121
+ )
122
+ end
123
+
124
+ # ── Private helpers ────────────────────────────────────────────────────────
125
+
126
+ sig { params(bytes: String, offset: Integer, length: Integer).returns(String) }
127
+ def read_bytes(bytes, offset, length)
128
+ slice = bytes[offset, length]
129
+ if slice.nil? || slice.bytesize != length
130
+ Kernel.raise SolanaError.new(
131
+ SolanaError::TRANSACTION_INTROSPECTION__MALFORMED_COMPILED_MESSAGE,
132
+ { needed_bytes: offset + length }
133
+ )
134
+ end
135
+ slice.b
136
+ end
137
+ private_class_method :read_bytes
138
+
139
+ sig { params(bytes: String, offset: Integer).returns([Integer, Integer]) }
140
+ def read_byte(bytes, offset)
141
+ byte = bytes.getbyte(offset)
142
+ if byte.nil?
143
+ Kernel.raise SolanaError.new(
144
+ SolanaError::TRANSACTION_INTROSPECTION__MALFORMED_COMPILED_MESSAGE,
145
+ { needed_bytes: offset + 1 }
146
+ )
147
+ end
148
+ [byte, offset + 1]
149
+ end
150
+ private_class_method :read_byte
151
+
152
+ # Reads a Solana compact-u16 from +bytes+ starting at +offset+.
153
+ # Returns [decoded_integer, next_offset].
154
+ sig { params(bytes: String, offset: Integer).returns([Integer, Integer]) }
155
+ def decode_compact_u16(bytes, offset)
156
+ value = 0
157
+ shift = 0
158
+ Kernel.loop do
159
+ byte, offset = read_byte(bytes, offset)
160
+ value |= (byte & 0x7f) << shift
161
+ shift += 7
162
+ break unless (byte & 0x80) != 0
163
+ end
164
+ [value, offset]
165
+ end
166
+ private_class_method :decode_compact_u16
167
+ end
168
+ end
@@ -0,0 +1,159 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'base64'
5
+ require_relative '../addresses'
6
+ require_relative '../encoding/base58'
7
+ require_relative '../errors'
8
+ require_relative '../transactions/transaction'
9
+ require_relative '../wallet_standard'
10
+ require_relative 'compiled_transaction_message'
11
+ require_relative 'loaded_addresses'
12
+
13
+ module Solana::Ruby::Kit
14
+ module TransactionIntrospection
15
+ extend T::Sig
16
+
17
+ # The result of decoding a `getTransaction` response: the
18
+ # CompiledTransactionMessage, the loaded ALT addresses pulled from `meta`
19
+ # (if any), and — for `'base64'` and `'base58'` responses — the
20
+ # re-encodable wire-format Transactions::Transaction.
21
+ #
22
+ # `transaction` is nil for `encoding: 'json'` responses: the server has
23
+ # already decompiled the wire format, so there are no message bytes to
24
+ # round-trip. Fetch with `encoding: 'base64'` if you need a re-encodable
25
+ # Transaction.
26
+ #
27
+ # Mirrors `DecodedRpcTransaction` from @solana/transaction-introspection.
28
+ class DecodedRpcTransaction < T::Struct
29
+ const :compiled_message, CompiledTransactionMessage
30
+ const :loaded_addresses, LoadedAddresses
31
+ const :transaction, T.nilable(Transactions::Transaction)
32
+ end
33
+
34
+ module_function
35
+
36
+ # Decodes a `getTransaction` response (any of `encoding: 'base64'`,
37
+ # `'base58'`, or `'json'`) into a CompiledTransactionMessage plus, for
38
+ # `'base64'` and `'base58'`, a re-encodable Transactions::Transaction.
39
+ # The JSON path does not produce a Transaction: the server has already
40
+ # decompiled the wire format, so there are no message bytes to carry.
41
+ #
42
+ # +rpc_tx+ is the raw (String-keyed) JSON hash returned by
43
+ # `Rpc::Api::GetTransaction#get_transaction`.
44
+ #
45
+ # `'jsonParsed'` is not supported — its instructions arrive pre-parsed by
46
+ # the server and lack raw bytes, so they cannot be round-tripped. Passing
47
+ # a `'jsonParsed'` response raises
48
+ # SolanaError::TRANSACTION_INTROSPECTION__CANNOT_DECODE_JSON_PARSED_TRANSACTION;
49
+ # any other unrecognized input raises
50
+ # SolanaError::TRANSACTION_INTROSPECTION__UNRECOGNIZED_GET_TRANSACTION_RESPONSE.
51
+ #
52
+ # A transaction version this decoder cannot handle (anything but legacy
53
+ # or v0) raises SolanaError::TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED —
54
+ # Ruby's wire compiler does not produce v1 transactions either, so there
55
+ # is nothing to decompile for that version yet.
56
+ #
57
+ # Mirrors `decodeTransactionFromRpcResponse()`.
58
+ sig { params(rpc_tx: T::Hash[String, T.untyped]).returns(DecodedRpcTransaction) }
59
+ def decode_transaction_from_rpc_response(rpc_tx)
60
+ tx_field = rpc_tx['transaction']
61
+
62
+ if tx_field.is_a?(Array)
63
+ encoding = tx_field[1]
64
+ return decode_from_base64(rpc_tx) if encoding == 'base64'
65
+ return decode_from_base58(rpc_tx) if encoding == 'base58'
66
+ elsif tx_field.is_a?(Hash) && tx_field['message'].is_a?(Hash)
67
+ # An `encoding: 'json'` message carries the compiled-message `header`
68
+ # (signer/readonly counts). A `jsonParsed` message has no `header` —
69
+ # the server has already resolved account roles onto `accountKeys` —
70
+ # so checking for it distinguishes the two encodings.
71
+ return decode_from_json(rpc_tx) if tx_field['message']['header'].is_a?(Hash)
72
+
73
+ Kernel.raise SolanaError.new(SolanaError::TRANSACTION_INTROSPECTION__CANNOT_DECODE_JSON_PARSED_TRANSACTION)
74
+ end
75
+
76
+ Kernel.raise SolanaError.new(SolanaError::TRANSACTION_INTROSPECTION__UNRECOGNIZED_GET_TRANSACTION_RESPONSE)
77
+ end
78
+
79
+ # ── Private helpers ────────────────────────────────────────────────────────
80
+
81
+ sig { params(meta: T.untyped).returns(LoadedAddresses) }
82
+ def get_loaded_addresses(meta)
83
+ loaded = meta.is_a?(Hash) ? meta['loadedAddresses'] : nil
84
+ return EMPTY_LOADED_ADDRESSES unless loaded.is_a?(Hash)
85
+
86
+ LoadedAddresses.new(
87
+ readonly: (loaded['readonly'] || []).map { |a| Addresses.address(a) },
88
+ writable: (loaded['writable'] || []).map { |a| Addresses.address(a) }
89
+ )
90
+ end
91
+ private_class_method :get_loaded_addresses
92
+
93
+ sig { params(rpc_tx: T::Hash[String, T.untyped]).returns(DecodedRpcTransaction) }
94
+ def decode_from_base64(rpc_tx)
95
+ b64 = rpc_tx['transaction'][0]
96
+ transaction = WalletStandard.decode_wire_transaction(b64)
97
+ DecodedRpcTransaction.new(
98
+ compiled_message: decode_compiled_transaction_message(transaction.message_bytes),
99
+ loaded_addresses: get_loaded_addresses(rpc_tx['meta']),
100
+ transaction: transaction
101
+ )
102
+ end
103
+ private_class_method :decode_from_base64
104
+
105
+ sig { params(rpc_tx: T::Hash[String, T.untyped]).returns(DecodedRpcTransaction) }
106
+ def decode_from_base58(rpc_tx)
107
+ b58 = rpc_tx['transaction'][0]
108
+ transaction = WalletStandard.decode_wire_transaction(Encoding::Base58.decode(b58))
109
+ DecodedRpcTransaction.new(
110
+ compiled_message: decode_compiled_transaction_message(transaction.message_bytes),
111
+ loaded_addresses: get_loaded_addresses(rpc_tx['meta']),
112
+ transaction: transaction
113
+ )
114
+ end
115
+ private_class_method :decode_from_base58
116
+
117
+ sig { params(rpc_tx: T::Hash[String, T.untyped]).returns(DecodedRpcTransaction) }
118
+ def decode_from_json(rpc_tx)
119
+ message = rpc_tx['transaction']['message']
120
+
121
+ header = CompiledTransactionMessageHeader.new(
122
+ num_signer_accounts: message['header']['numRequiredSignatures'],
123
+ num_readonly_signer_accounts: message['header']['numReadonlySignedAccounts'],
124
+ num_readonly_non_signer_accounts: message['header']['numReadonlyUnsignedAccounts']
125
+ )
126
+
127
+ instructions = message['instructions'].map do |ix|
128
+ data_b58 = ix['data']
129
+ CompiledInstruction.new(
130
+ program_address_index: ix['programIdIndex'],
131
+ account_indices: ix['accounts'] || [],
132
+ data: data_b58.nil? || data_b58.empty? ? ''.b : Encoding::Base58.decode(data_b58)
133
+ )
134
+ end
135
+
136
+ # The envelope only carries `version` when `maxSupportedTransactionVersion`
137
+ # was set on the request; otherwise the response is necessarily legacy.
138
+ version = rpc_tx.key?('version') ? rpc_tx['version'] : :legacy
139
+ version = :legacy if version == 'legacy'
140
+
141
+ compiled_message = CompiledTransactionMessage.new(
142
+ version: version,
143
+ header: header,
144
+ static_accounts: message['accountKeys'].map { |a| Addresses.address(a) },
145
+ instructions: instructions,
146
+ # For durable-nonce transactions, `recentBlockhash` is the nonce value, not a
147
+ # blockhash — either way it is the message's lifetime token.
148
+ lifetime_token: message['recentBlockhash']
149
+ )
150
+
151
+ DecodedRpcTransaction.new(
152
+ compiled_message: compiled_message,
153
+ loaded_addresses: get_loaded_addresses(rpc_tx['meta']),
154
+ transaction: nil
155
+ )
156
+ end
157
+ private_class_method :decode_from_json
158
+ end
159
+ end
@@ -0,0 +1,86 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../encoding/base58'
5
+ require_relative '../errors'
6
+ require_relative '../instructions/instruction'
7
+ require_relative 'types'
8
+
9
+ module Solana::Ruby::Kit
10
+ module TransactionIntrospection
11
+ extend T::Sig
12
+
13
+ module_function
14
+
15
+ # Returns the inner instructions in a `getTransaction` response as
16
+ # TracedInstructions.
17
+ #
18
+ # The RPC returns inner instructions in a different shape from the wire
19
+ # format: indices reference the same flat account list as the outer
20
+ # instructions, but `data` is a base58-encoded string. This decodes the
21
+ # data, resolves the indices against the supplied AccountMeta list, and
22
+ # tags each instruction with an `inner` trace.
23
+ #
24
+ # +meta+ is the raw (String-keyed) `meta` hash from a `getTransaction`
25
+ # response. Returns an empty array if it carries no `innerInstructions`.
26
+ #
27
+ # Raises if any `programIdIndex` or account index falls outside the
28
+ # supplied `account_metas` list.
29
+ #
30
+ # Mirrors `getInnerInstructionsFromMeta()`.
31
+ sig do
32
+ params(
33
+ meta: T.nilable(T::Hash[String, T.untyped]),
34
+ account_metas: T::Array[Instructions::AccountMeta]
35
+ ).returns(T::Array[TracedInstruction])
36
+ end
37
+ def get_inner_instructions_from_meta(meta, account_metas)
38
+ groups = meta && meta['innerInstructions']
39
+ return [] unless groups
40
+
41
+ result = T.let([], T::Array[TracedInstruction])
42
+
43
+ groups.each do |group|
44
+ outer_index = group['index']
45
+
46
+ group['instructions'].each_with_index do |ix, inner_index|
47
+ program_meta = account_metas[ix['programIdIndex']]
48
+ if program_meta.nil?
49
+ Kernel.raise SolanaError.new(
50
+ SolanaError::TRANSACTIONS__FAILED_TO_DECOMPILE_INSTRUCTION_PROGRAM_ADDRESS_NOT_FOUND,
51
+ { index: ix['programIdIndex'] }
52
+ )
53
+ end
54
+
55
+ accounts = (ix['accounts'] || []).map do |i|
56
+ account_meta = account_metas[i]
57
+ if account_meta.nil?
58
+ Kernel.raise SolanaError.new(
59
+ SolanaError::TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_ACCOUNT_INDEX_OUT_OF_RANGE,
60
+ { index: i }
61
+ )
62
+ end
63
+ account_meta
64
+ end
65
+
66
+ data_b58 = ix['data']
67
+ data = data_b58.nil? || data_b58.empty? ? nil : Encoding::Base58.decode(data_b58)
68
+
69
+ trace = { kind: :inner, outer_index: outer_index, inner_index: inner_index }
70
+ trace[:stack_height] = ix['stackHeight'] unless ix['stackHeight'].nil?
71
+
72
+ result << TracedInstruction.new(
73
+ instruction: Instructions::Instruction.new(
74
+ program_address: program_meta.address,
75
+ accounts: accounts.empty? ? nil : accounts,
76
+ data: data
77
+ ),
78
+ trace: trace
79
+ )
80
+ end
81
+ end
82
+
83
+ result
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,134 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../errors'
5
+ require_relative '../instructions/instruction'
6
+ require_relative '../instructions/roles'
7
+ require_relative 'compiled_transaction_message'
8
+ require_relative 'loaded_addresses'
9
+
10
+ module Solana::Ruby::Kit
11
+ module TransactionIntrospection
12
+ extend T::Sig
13
+
14
+ module_function
15
+
16
+ # Builds the full ordered list of AccountMetas for a compiled transaction
17
+ # message.
18
+ #
19
+ # The order matches the runtime's resolution order:
20
+ # 1. Static accounts, with role bits derived from the message header
21
+ # (writable signers, readonly signers, writable non-signers, readonly
22
+ # non-signers).
23
+ # 2. ALT-loaded writable accounts (always non-signer, writable).
24
+ # 3. ALT-loaded readonly accounts (always non-signer, readonly).
25
+ #
26
+ # Inner-instruction account indices reference the same flat list, so this
27
+ # helper is also useful for resolving inner instructions.
28
+ #
29
+ # Mirrors `getAccountMetasFromCompiledTransactionMessage()`.
30
+ sig do
31
+ params(
32
+ compiled_message: CompiledTransactionMessage,
33
+ loaded_addresses: T.nilable(LoadedAddresses)
34
+ ).returns(T::Array[Instructions::AccountMeta])
35
+ end
36
+ def get_account_metas_from_compiled_transaction_message(compiled_message, loaded_addresses = nil)
37
+ header = compiled_message.header
38
+ static_accounts = compiled_message.static_accounts
39
+
40
+ num_writable_signer_accounts =
41
+ header.num_signer_accounts - header.num_readonly_signer_accounts
42
+ num_writable_non_signer_accounts =
43
+ static_accounts.length - header.num_signer_accounts - header.num_readonly_non_signer_accounts
44
+
45
+ metas = T.let([], T::Array[Instructions::AccountMeta])
46
+ i = 0
47
+ num_writable_signer_accounts.times do
48
+ metas << Instructions::AccountMeta.new(address: T.must(static_accounts[i]), role: Instructions::AccountRole::WRITABLE_SIGNER)
49
+ i += 1
50
+ end
51
+ header.num_readonly_signer_accounts.times do
52
+ metas << Instructions::AccountMeta.new(address: T.must(static_accounts[i]), role: Instructions::AccountRole::READONLY_SIGNER)
53
+ i += 1
54
+ end
55
+ num_writable_non_signer_accounts.times do
56
+ metas << Instructions::AccountMeta.new(address: T.must(static_accounts[i]), role: Instructions::AccountRole::WRITABLE)
57
+ i += 1
58
+ end
59
+ header.num_readonly_non_signer_accounts.times do
60
+ metas << Instructions::AccountMeta.new(address: T.must(static_accounts[i]), role: Instructions::AccountRole::READONLY)
61
+ i += 1
62
+ end
63
+
64
+ if loaded_addresses
65
+ loaded_addresses.writable.each { |addr| metas << Instructions::AccountMeta.new(address: addr, role: Instructions::AccountRole::WRITABLE) }
66
+ loaded_addresses.readonly.each { |addr| metas << Instructions::AccountMeta.new(address: addr, role: Instructions::AccountRole::READONLY) }
67
+ end
68
+
69
+ metas
70
+ end
71
+
72
+ # Returns the outer instructions of a compiled transaction message as
73
+ # Instructions::Instruction objects, with account indices resolved to
74
+ # AccountMetas and data exposed as a raw binary String. `accounts` and
75
+ # `data` are omitted (nil) when empty.
76
+ #
77
+ # Mirrors `getInstructionsFromCompiledTransactionMessage()`.
78
+ sig do
79
+ params(
80
+ compiled_message: CompiledTransactionMessage,
81
+ loaded_addresses: T.nilable(LoadedAddresses)
82
+ ).returns(T::Array[Instructions::Instruction])
83
+ end
84
+ def get_instructions_from_compiled_transaction_message(compiled_message, loaded_addresses = nil)
85
+ metas = get_account_metas_from_compiled_transaction_message(compiled_message, loaded_addresses)
86
+ get_instructions_from_compiled_transaction_message_with_metas(compiled_message, metas)
87
+ end
88
+
89
+ # Internal variant of get_instructions_from_compiled_transaction_message
90
+ # that takes pre-built AccountMetas. Used by WalkInstructions to avoid
91
+ # rebuilding the meta list when it is already needed for resolving inner
92
+ # instructions.
93
+ sig do
94
+ params(
95
+ compiled_message: CompiledTransactionMessage,
96
+ account_metas: T::Array[Instructions::AccountMeta]
97
+ ).returns(T::Array[Instructions::Instruction])
98
+ end
99
+ def get_instructions_from_compiled_transaction_message_with_metas(compiled_message, account_metas)
100
+ compiled_message.instructions.map { |ix| resolve_instruction(ix, account_metas) }
101
+ end
102
+
103
+ # ── Private helpers ────────────────────────────────────────────────────────
104
+
105
+ sig { params(ix: CompiledInstruction, metas: T::Array[Instructions::AccountMeta]).returns(Instructions::Instruction) }
106
+ def resolve_instruction(ix, metas)
107
+ program_meta = metas[ix.program_address_index]
108
+ if program_meta.nil?
109
+ Kernel.raise SolanaError.new(
110
+ SolanaError::TRANSACTIONS__FAILED_TO_DECOMPILE_INSTRUCTION_PROGRAM_ADDRESS_NOT_FOUND,
111
+ { index: ix.program_address_index }
112
+ )
113
+ end
114
+
115
+ accounts = ix.account_indices.map do |i|
116
+ account_meta = metas[i]
117
+ if account_meta.nil?
118
+ Kernel.raise SolanaError.new(
119
+ SolanaError::TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_ACCOUNT_INDEX_OUT_OF_RANGE,
120
+ { index: i }
121
+ )
122
+ end
123
+ account_meta
124
+ end
125
+
126
+ Instructions::Instruction.new(
127
+ program_address: program_meta.address,
128
+ accounts: accounts.empty? ? nil : accounts,
129
+ data: ix.data.empty? ? nil : ix.data
130
+ )
131
+ end
132
+ private_class_method :resolve_instruction
133
+ end
134
+ end
@@ -0,0 +1,19 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../addresses/address'
5
+
6
+ module Solana::Ruby::Kit
7
+ module TransactionIntrospection
8
+ # Loaded ALT addresses as returned by `getTransaction`'s `meta.loadedAddresses`.
9
+ # The two arrays are kept in the same order the runtime uses to resolve
10
+ # instruction account indices.
11
+ # Mirrors `LoadedAddresses` from @solana/transaction-introspection.
12
+ class LoadedAddresses < T::Struct
13
+ const :readonly, T::Array[Addresses::Address]
14
+ const :writable, T::Array[Addresses::Address]
15
+ end
16
+
17
+ EMPTY_LOADED_ADDRESSES = T.let(LoadedAddresses.new(readonly: [], writable: []), LoadedAddresses)
18
+ end
19
+ end
@@ -0,0 +1,29 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../instructions/instruction'
5
+
6
+ module Solana::Ruby::Kit
7
+ module TransactionIntrospection
8
+ # A ResolvedInstruction carrying its location in the transaction as a
9
+ # `trace` Hash.
10
+ #
11
+ # TypeScript flattens `TracedInstruction` into the instruction itself
12
+ # (`ResolvedInstruction & { trace }`), so callers there access
13
+ # `ix.programAddress` directly. Ruby's `Instructions::Instruction` is a
14
+ # `T::Struct`, which is final and cannot be subclassed (see
15
+ # `Transactions::FullySignedTransaction` for the same constraint), so here
16
+ # the instruction and its trace are held as sibling fields instead:
17
+ # `traced.instruction.program_address`, `traced.trace`.
18
+ #
19
+ # `trace` is one of:
20
+ # { kind: :outer, index: Integer }
21
+ # { kind: :inner, outer_index: Integer, inner_index: Integer, stack_height: T.nilable(Integer) }
22
+ #
23
+ # Mirrors `TracedInstruction` / `InstructionTrace` from @solana/transaction-introspection.
24
+ class TracedInstruction < T::Struct
25
+ const :instruction, Instructions::Instruction
26
+ const :trace, T::Hash[Symbol, T.untyped]
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,60 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'compiled_transaction_message'
5
+ require_relative 'get_inner_instructions'
6
+ require_relative 'get_instructions'
7
+ require_relative 'loaded_addresses'
8
+ require_relative 'types'
9
+
10
+ module Solana::Ruby::Kit
11
+ module TransactionIntrospection
12
+ extend T::Sig
13
+
14
+ module_function
15
+
16
+ # Returns every instruction in a confirmed transaction as
17
+ # TracedInstructions, in the order an explorer displays them: each outer
18
+ # instruction followed immediately by the inner instructions its CPIs
19
+ # produced.
20
+ #
21
+ # If +meta+ is nil, only outer instructions are returned. If
22
+ # +loaded_addresses+ is nil, only static accounts are used to resolve
23
+ # indices — pass the `loadedAddresses` from a `getTransaction` response's
24
+ # `meta` for v0 transactions that load accounts from address lookup tables.
25
+ #
26
+ # Mirrors `walkInstructions({ compiledMessage, loadedAddresses, meta })`.
27
+ sig do
28
+ params(
29
+ compiled_message: CompiledTransactionMessage,
30
+ loaded_addresses: T.nilable(LoadedAddresses),
31
+ meta: T.nilable(T::Hash[String, T.untyped])
32
+ ).returns(T::Array[TracedInstruction])
33
+ end
34
+ def walk_instructions(compiled_message:, loaded_addresses: nil, meta: nil)
35
+ account_metas = get_account_metas_from_compiled_transaction_message(compiled_message, loaded_addresses)
36
+ outer_instructions = get_instructions_from_compiled_transaction_message_with_metas(compiled_message, account_metas)
37
+
38
+ inner_by_outer_index = T.let({}, T::Hash[Integer, T::Array[TracedInstruction]])
39
+ if meta
40
+ get_inner_instructions_from_meta(meta, account_metas).each do |inner|
41
+ outer_index = inner.trace.fetch(:outer_index)
42
+ (inner_by_outer_index[outer_index] ||= []) << inner
43
+ end
44
+ end
45
+
46
+ result = T.let([], T::Array[TracedInstruction])
47
+ outer_instructions.each_with_index do |instruction, index|
48
+ result << TracedInstruction.new(instruction: instruction, trace: { kind: :outer, index: index })
49
+ group = inner_by_outer_index.delete(index)
50
+ result.concat(group) if group
51
+ end
52
+ # Inner groups whose index matches no outer instruction can only come
53
+ # from malformed input (e.g. `meta` paired with the wrong message).
54
+ # Append them rather than dropping them so no instruction is ever lost.
55
+ inner_by_outer_index.each_value { |group| result.concat(group) }
56
+
57
+ result
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,13 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ # Helpers for inspecting confirmed Solana transactions and walking their
5
+ # outer and inner instructions.
6
+ # Mirrors @solana/transaction-introspection.
7
+ require_relative 'transaction_introspection/loaded_addresses'
8
+ require_relative 'transaction_introspection/compiled_transaction_message'
9
+ require_relative 'transaction_introspection/types'
10
+ require_relative 'transaction_introspection/get_instructions'
11
+ require_relative 'transaction_introspection/get_inner_instructions'
12
+ require_relative 'transaction_introspection/walk_instructions'
13
+ require_relative 'transaction_introspection/decode_rpc_transaction'
@@ -4,7 +4,7 @@
4
4
  module Solana
5
5
  module Ruby
6
6
  module Kit
7
- VERSION = '0.1.9'
7
+ VERSION = '7.0.0'
8
8
  end
9
9
  end
10
10
  end
@@ -51,6 +51,7 @@ require_relative 'kit/sysvars'
51
51
  require_relative 'kit/transaction_confirmation'
52
52
  require_relative 'kit/instruction_plans'
53
53
  require_relative 'kit/resource_limit_estimation'
54
+ require_relative 'kit/transaction_introspection'
54
55
 
55
56
  # Solana::Ruby::Kit is a Ruby translation of @anza-xyz/kit — the JavaScript SDK for
56
57
  # building Solana apps — into idiomatic Ruby with Sorbet static types.
@@ -80,6 +81,7 @@ require_relative 'kit/resource_limit_estimation'
80
81
  # Solana::Ruby::Kit::TransactionConfirmation — confirmation polling (@solana/transaction-confirmation)
81
82
  # Solana::Ruby::Kit::InstructionPlans — multi-tx planning (@solana/instruction-plans)
82
83
  # Solana::Ruby::Kit::WalletStandard — server-side sig verify (@solana/wallet-standard)
84
+ # Solana::Ruby::Kit::TransactionIntrospection — decode/walk instructions (@solana/transaction-introspection)
83
85
 
84
86
  module Solana::Ruby::Kit
85
87
  extend T::Sig
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solana-ruby-kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.9
4
+ version: 7.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Paul Zupan, Idhra Inc.
@@ -209,6 +209,7 @@ files:
209
209
  - lib/solana/ruby/kit/functional.rb
210
210
  - lib/solana/ruby/kit/instruction_plans.rb
211
211
  - lib/solana/ruby/kit/instruction_plans/instruction_plan.rb
212
+ - lib/solana/ruby/kit/instruction_plans/max_instructions.rb
212
213
  - lib/solana/ruby/kit/instruction_plans/plans.rb
213
214
  - lib/solana/ruby/kit/instruction_plans/transaction_plan.rb
214
215
  - lib/solana/ruby/kit/instruction_plans/transaction_plan_executor.rb
@@ -298,6 +299,14 @@ files:
298
299
  - lib/solana/ruby/kit/sysvars/last_restart_slot.rb
299
300
  - lib/solana/ruby/kit/sysvars/rent.rb
300
301
  - lib/solana/ruby/kit/transaction_confirmation.rb
302
+ - lib/solana/ruby/kit/transaction_introspection.rb
303
+ - lib/solana/ruby/kit/transaction_introspection/compiled_transaction_message.rb
304
+ - lib/solana/ruby/kit/transaction_introspection/decode_rpc_transaction.rb
305
+ - lib/solana/ruby/kit/transaction_introspection/get_inner_instructions.rb
306
+ - lib/solana/ruby/kit/transaction_introspection/get_instructions.rb
307
+ - lib/solana/ruby/kit/transaction_introspection/loaded_addresses.rb
308
+ - lib/solana/ruby/kit/transaction_introspection/types.rb
309
+ - lib/solana/ruby/kit/transaction_introspection/walk_instructions.rb
301
310
  - lib/solana/ruby/kit/transaction_messages.rb
302
311
  - lib/solana/ruby/kit/transaction_messages/compute_budget.rb
303
312
  - lib/solana/ruby/kit/transaction_messages/transaction_message.rb