solana-ruby-kit 0.1.8 → 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.
@@ -117,10 +117,9 @@ module Solana::Ruby::Kit
117
117
  http.open_timeout = @open_timeout
118
118
 
119
119
  req = Net::HTTP::Post.new(T.cast(@uri, URI::HTTP).request_uri)
120
- req['Content-Type'] = 'application/json; charset=utf-8'
121
- req['Accept'] = 'application/json'
122
- req['Content-Length'] = body.bytesize.to_s
123
- req['solana-client'] = 'ruby-kit'
120
+ req['Content-Type'] = 'application/json; charset=utf-8'
121
+ req['Accept'] = 'application/json'
122
+ req['solana-client'] = 'ruby-kit'
124
123
  @headers.each { |k, v| req[k] = v }
125
124
  req.body = body
126
125
 
@@ -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