solana-ruby-kit 0.1.9 → 7.0.0.1

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.
@@ -0,0 +1,172 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../errors'
5
+ require_relative 'reactive_stream_store'
6
+
7
+ module Solana::Ruby::Kit
8
+ module Subscribable
9
+ extend T::Sig
10
+
11
+ module_function
12
+
13
+ # Adapts a ReactiveStreamStore into an Enumerator, so a *push*-based
14
+ # reactive store can be driven by *pull*-based code that consumes it with
15
+ # `#each`, `#next`, `#take`, or any other Enumerable method.
16
+ #
17
+ # The bridge only *observes* the store; it does not open or tear down the
18
+ # connection. The caller owns the store's lifecycle. The bridge subscribes,
19
+ # yields the store's current and subsequent values, and unsubscribes when
20
+ # iteration ends — however it ends (exhaustion, error, `break`, or abort).
21
+ # It does not reset the store; that is the caller's decision.
22
+ #
23
+ # This is the store-backed counterpart to AsyncIterable.from_publisher.
24
+ # That helper turns a raw DataPublisher into an Enumerator and queues every
25
+ # message so none are dropped. `bridge_store_to_async_iterable` instead sits
26
+ # on top of a ReactiveStreamStore, so it reflects the store's
27
+ # loading/loaded/error/retrying lifecycle — and, because a store only ever
28
+ # holds the *latest* snapshot, it is latest-wins rather than fully buffered.
29
+ #
30
+ # On iteration it seeds from the store's current snapshot, then yields its
31
+ # lifecycle:
32
+ # - 'loaded' → yields the value (the one already present when iteration
33
+ # begins, then each subsequent update), unless the optional
34
+ # +should_yield+ predicate rejects it. Latest-wins: if several
35
+ # notifications land between pulls, only the most recent
36
+ # unconsumed value is yielded.
37
+ # - 'error' → raises, so the consuming loop propagates it. Substitutes a
38
+ # SolanaError::SUBSCRIBABLE__STREAM_CLOSED_WITHOUT_ERROR
39
+ # sentinel when the store reports an error with a nil payload.
40
+ # An error takes precedence over a buffered value: if a loaded
41
+ # value is still pending when an error arrives, that value is
42
+ # dropped and the error propagates.
43
+ # - 'loading' / 'retrying' → carry no new value and no error; nothing to yield.
44
+ #
45
+ # +signal+ follows the same convention as the rest of Subscribable: a lambda
46
+ # called to test for abort, which *raises* once aborted. When it raises,
47
+ # iteration ends cleanly (no error) — an abort is teardown, so the store's
48
+ # incidental abort-driven error state is not surfaced. Because a Ruby
49
+ # consumer parks in a blocking wait rather than on an event loop, the signal
50
+ # is re-tested every +poll_interval+ seconds while parked; it is checked
51
+ # immediately on every store change regardless. With no +signal+, the
52
+ # enumerator parks indefinitely between values — a subscription never
53
+ # completes on its own, so `break` out of the loop to stop.
54
+ #
55
+ # @param store [ReactiveStreamStore] a store to observe. Connect it yourself.
56
+ # @param signal [Proc, nil] raises once aborted; ends iteration cleanly.
57
+ # @param should_yield [Proc, nil] gate run against each loaded value before
58
+ # it is yielded. Return false to drop the value.
59
+ # @param poll_interval [Float] seconds between signal re-tests while parked.
60
+ # @return [Enumerator]
61
+ #
62
+ # @example
63
+ # store = Subscribable.create_reactive_store_from_data_publisher(
64
+ # publisher, data_channel: :slotNotification, error_channel: :error
65
+ # )
66
+ # Subscribable.bridge_store_to_async_iterable(store).each do |notification|
67
+ # puts "Latest slot: #{notification['slot']}"
68
+ # end
69
+ #
70
+ # Mirrors bridgeStoreToAsyncIterable from @solana/subscribable.
71
+ sig do
72
+ params(
73
+ store: ReactiveStreamStore,
74
+ signal: T.nilable(T.proc.void),
75
+ should_yield: T.nilable(T.proc.params(value: T.untyped).returns(T::Boolean)),
76
+ poll_interval: Float
77
+ ).returns(T::Enumerator[T.untyped])
78
+ end
79
+ def bridge_store_to_async_iterable(store, signal: nil, should_yield: nil, poll_interval: 0.05)
80
+ Enumerator.new do |yielder|
81
+ mutex = Mutex.new
82
+ cond = ConditionVariable.new
83
+ # Latest-wins single-slot buffer. Both are one-element arrays used as
84
+ # "present or nil" boxes so a legitimately-nil value is distinguishable
85
+ # from "nothing buffered".
86
+ latest = T.let(nil, T.nilable(T::Array[T.untyped]))
87
+ failure = T.let(nil, T.nilable(T::Array[T.untyped]))
88
+
89
+ on_change = Kernel.lambda do
90
+ state = store.get_unified_state
91
+ case state.status
92
+ when 'loaded'
93
+ # Drop a value the gate rejects; park again rather than yielding it.
94
+ next if should_yield && !should_yield.call(state.data)
95
+ mutex.synchronize do
96
+ latest = [state.data]
97
+ cond.broadcast
98
+ end
99
+ when 'error'
100
+ # A nil error would otherwise surface as a value-less success;
101
+ # substitute a sentinel so the failure propagates.
102
+ err = state.error || SolanaError.new(SolanaError::SUBSCRIBABLE__STREAM_CLOSED_WITHOUT_ERROR)
103
+ mutex.synchronize do
104
+ failure = [err]
105
+ cond.broadcast
106
+ end
107
+ end
108
+ end
109
+
110
+ aborted = Kernel.lambda do
111
+ next false if signal.nil?
112
+
113
+ begin
114
+ signal.call
115
+ false
116
+ rescue StandardError
117
+ true
118
+ end
119
+ end
120
+
121
+ unsubscribe = store.subscribe(&on_change)
122
+ # Seed from the store's current snapshot: the caller may already have
123
+ # connected (and a value or error may already be present) before
124
+ # iteration began. The bridge never connects the store itself.
125
+ on_change.call
126
+
127
+ begin
128
+ Kernel.loop do
129
+ # Abort wins over everything: end cleanly without raising.
130
+ break if aborted.call
131
+
132
+ action = T.let(:park, Symbol)
133
+ payload = T.let(nil, T.untyped)
134
+
135
+ mutex.synchronize do
136
+ if failure
137
+ action = :raise
138
+ payload = failure[0]
139
+ elsif latest
140
+ action = :yield
141
+ payload = latest[0]
142
+ latest = nil
143
+ else
144
+ cond.wait(mutex, signal ? poll_interval : nil)
145
+ end
146
+ end
147
+
148
+ case action
149
+ when :yield then yielder.yield(payload)
150
+ when :raise then Kernel.raise(coerce_error(payload))
151
+ end
152
+ end
153
+ ensure
154
+ unsubscribe.call
155
+ end
156
+ end
157
+ end
158
+
159
+ # The store's error channel carries whatever the publisher emitted, which
160
+ # need not be an Exception. Ruby can only raise exceptions, so wrap anything
161
+ # else rather than losing the failure.
162
+ sig { params(error: T.untyped).returns(Exception) }
163
+ def coerce_error(error)
164
+ case error
165
+ when Exception then error
166
+ when String then RuntimeError.new(error)
167
+ else RuntimeError.new(error.inspect)
168
+ end
169
+ end
170
+ private_class_method :coerce_error
171
+ end
172
+ end
@@ -8,6 +8,7 @@ require_relative 'subscribable/data_publisher'
8
8
  require_relative 'subscribable/async_iterable'
9
9
  require_relative 'subscribable/reactive_stream_store'
10
10
  require_relative 'subscribable/reactive_action_store'
11
+ require_relative 'subscribable/bridge_store_to_async_iterable'
11
12
 
12
13
  module Solana::Ruby::Kit
13
14
  module Subscribable
@@ -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,176 @@
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 confirmed-transaction RPC 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 confirmed-transaction RPC 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 a raw (String-keyed) JSON hash. Only the `transaction` /
43
+ # `meta` / `version` envelope is read — not a method-specific response
44
+ # shape — so results from any method that returns confirmed transactions in
45
+ # these encodings work, and extra fields (`slot`, `blockTime`,
46
+ # `transactionIndex`, …) are ignored:
47
+ #
48
+ # # getTransaction — a single response hash
49
+ # rpc_tx = rpc.get_transaction(signature, encoding: 'base64')
50
+ # TransactionIntrospection.decode_transaction_from_rpc_response(rpc_tx)
51
+ #
52
+ # # getTransactionsForAddress — one hash per element of `data`
53
+ # page = rpc.get_transactions_for_address(
54
+ # address, transaction_details: :full, encoding: 'base64',
55
+ # max_supported_transaction_version: 0
56
+ # )
57
+ # page.data.map { |tx| TransactionIntrospection.decode_transaction_from_rpc_response(tx) }
58
+ #
59
+ # `'jsonParsed'` is not supported — its instructions arrive pre-parsed by
60
+ # the server and lack raw bytes, so they cannot be round-tripped. Passing
61
+ # a `'jsonParsed'` response raises
62
+ # SolanaError::TRANSACTION_INTROSPECTION__CANNOT_DECODE_JSON_PARSED_TRANSACTION;
63
+ # any other unrecognized input raises
64
+ # SolanaError::TRANSACTION_INTROSPECTION__UNRECOGNIZED_GET_TRANSACTION_RESPONSE.
65
+ #
66
+ # A transaction version this decoder cannot handle (anything but legacy
67
+ # or v0) raises SolanaError::TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED —
68
+ # Ruby's wire compiler does not produce v1 transactions either, so there
69
+ # is nothing to decompile for that version yet.
70
+ #
71
+ # Mirrors `decodeTransactionFromRpcResponse()`.
72
+ sig { params(rpc_tx: T::Hash[String, T.untyped]).returns(DecodedRpcTransaction) }
73
+ def decode_transaction_from_rpc_response(rpc_tx)
74
+ tx_field = rpc_tx['transaction']
75
+
76
+ if tx_field.is_a?(Array)
77
+ encoding = tx_field[1]
78
+ return decode_from_base64(rpc_tx) if encoding == 'base64'
79
+ return decode_from_base58(rpc_tx) if encoding == 'base58'
80
+ elsif tx_field.is_a?(Hash) && tx_field['message'].is_a?(Hash)
81
+ # An `encoding: 'json'` message carries the compiled-message `header`
82
+ # (signer/readonly counts). A `jsonParsed` message has no `header` —
83
+ # the server has already resolved account roles onto `accountKeys` —
84
+ # so checking for it distinguishes the two encodings.
85
+ return decode_from_json(rpc_tx) if tx_field['message']['header'].is_a?(Hash)
86
+
87
+ Kernel.raise SolanaError.new(SolanaError::TRANSACTION_INTROSPECTION__CANNOT_DECODE_JSON_PARSED_TRANSACTION)
88
+ end
89
+
90
+ Kernel.raise SolanaError.new(SolanaError::TRANSACTION_INTROSPECTION__UNRECOGNIZED_GET_TRANSACTION_RESPONSE)
91
+ end
92
+
93
+ # ── Private helpers ────────────────────────────────────────────────────────
94
+
95
+ sig { params(meta: T.untyped).returns(LoadedAddresses) }
96
+ def get_loaded_addresses(meta)
97
+ loaded = meta.is_a?(Hash) ? meta['loadedAddresses'] : nil
98
+ return EMPTY_LOADED_ADDRESSES unless loaded.is_a?(Hash)
99
+
100
+ LoadedAddresses.new(
101
+ readonly: (loaded['readonly'] || []).map { |a| Addresses.address(a) },
102
+ writable: (loaded['writable'] || []).map { |a| Addresses.address(a) }
103
+ )
104
+ end
105
+ private_class_method :get_loaded_addresses
106
+
107
+ sig { params(rpc_tx: T::Hash[String, T.untyped]).returns(DecodedRpcTransaction) }
108
+ def decode_from_base64(rpc_tx)
109
+ b64 = rpc_tx['transaction'][0]
110
+ transaction = WalletStandard.decode_wire_transaction(b64)
111
+ DecodedRpcTransaction.new(
112
+ compiled_message: decode_compiled_transaction_message(transaction.message_bytes),
113
+ loaded_addresses: get_loaded_addresses(rpc_tx['meta']),
114
+ transaction: transaction
115
+ )
116
+ end
117
+ private_class_method :decode_from_base64
118
+
119
+ sig { params(rpc_tx: T::Hash[String, T.untyped]).returns(DecodedRpcTransaction) }
120
+ def decode_from_base58(rpc_tx)
121
+ b58 = rpc_tx['transaction'][0]
122
+ transaction = WalletStandard.decode_wire_transaction(Encoding::Base58.decode(b58))
123
+ DecodedRpcTransaction.new(
124
+ compiled_message: decode_compiled_transaction_message(transaction.message_bytes),
125
+ loaded_addresses: get_loaded_addresses(rpc_tx['meta']),
126
+ transaction: transaction
127
+ )
128
+ end
129
+ private_class_method :decode_from_base58
130
+
131
+ sig { params(rpc_tx: T::Hash[String, T.untyped]).returns(DecodedRpcTransaction) }
132
+ def decode_from_json(rpc_tx)
133
+ message = rpc_tx['transaction']['message']
134
+
135
+ header = CompiledTransactionMessageHeader.new(
136
+ num_signer_accounts: message['header']['numRequiredSignatures'],
137
+ num_readonly_signer_accounts: message['header']['numReadonlySignedAccounts'],
138
+ num_readonly_non_signer_accounts: message['header']['numReadonlyUnsignedAccounts']
139
+ )
140
+
141
+ instructions = message['instructions'].map do |ix|
142
+ data_b58 = ix['data']
143
+ CompiledInstruction.new(
144
+ program_address_index: ix['programIdIndex'],
145
+ account_indices: ix['accounts'] || [],
146
+ data: data_b58.nil? || data_b58.empty? ? ''.b : Encoding::Base58.decode(data_b58)
147
+ )
148
+ end
149
+
150
+ # The envelope only carries `version` when `maxSupportedTransactionVersion`
151
+ # was set on the request; otherwise the response is necessarily legacy.
152
+ # A `version` key that is present but null counts as absent — some methods
153
+ # include the key unconditionally. Ruby's `||` is safe here where
154
+ # TypeScript needs `??`: 0 (v0) is truthy in Ruby, so a real v0 survives.
155
+ version = rpc_tx['version'] || :legacy
156
+ version = :legacy if version == 'legacy'
157
+
158
+ compiled_message = CompiledTransactionMessage.new(
159
+ version: version,
160
+ header: header,
161
+ static_accounts: message['accountKeys'].map { |a| Addresses.address(a) },
162
+ instructions: instructions,
163
+ # For durable-nonce transactions, `recentBlockhash` is the nonce value, not a
164
+ # blockhash — either way it is the message's lifetime token.
165
+ lifetime_token: message['recentBlockhash']
166
+ )
167
+
168
+ DecodedRpcTransaction.new(
169
+ compiled_message: compiled_message,
170
+ loaded_addresses: get_loaded_addresses(rpc_tx['meta']),
171
+ transaction: nil
172
+ )
173
+ end
174
+ private_class_method :decode_from_json
175
+ end
176
+ end
@@ -0,0 +1,88 @@
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 transaction `meta` 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 any method that returns
25
+ # transaction metadata without `jsonParsed` encoding — `getTransaction`,
26
+ # `getTransactionsForAddress`, or `getBlock`. Returns an empty array if it
27
+ # carries no `innerInstructions`.
28
+ #
29
+ # Raises if any `programIdIndex` or account index falls outside the
30
+ # supplied `account_metas` list.
31
+ #
32
+ # Mirrors `getInnerInstructionsFromMeta()`.
33
+ sig do
34
+ params(
35
+ meta: T.nilable(T::Hash[String, T.untyped]),
36
+ account_metas: T::Array[Instructions::AccountMeta]
37
+ ).returns(T::Array[TracedInstruction])
38
+ end
39
+ def get_inner_instructions_from_meta(meta, account_metas)
40
+ groups = meta && meta['innerInstructions']
41
+ return [] unless groups
42
+
43
+ result = T.let([], T::Array[TracedInstruction])
44
+
45
+ groups.each do |group|
46
+ outer_index = group['index']
47
+
48
+ group['instructions'].each_with_index do |ix, inner_index|
49
+ program_meta = account_metas[ix['programIdIndex']]
50
+ if program_meta.nil?
51
+ Kernel.raise SolanaError.new(
52
+ SolanaError::TRANSACTIONS__FAILED_TO_DECOMPILE_INSTRUCTION_PROGRAM_ADDRESS_NOT_FOUND,
53
+ { index: ix['programIdIndex'] }
54
+ )
55
+ end
56
+
57
+ accounts = (ix['accounts'] || []).map do |i|
58
+ account_meta = account_metas[i]
59
+ if account_meta.nil?
60
+ Kernel.raise SolanaError.new(
61
+ SolanaError::TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_ACCOUNT_INDEX_OUT_OF_RANGE,
62
+ { index: i }
63
+ )
64
+ end
65
+ account_meta
66
+ end
67
+
68
+ data_b58 = ix['data']
69
+ data = data_b58.nil? || data_b58.empty? ? nil : Encoding::Base58.decode(data_b58)
70
+
71
+ trace = { kind: :inner, outer_index: outer_index, inner_index: inner_index }
72
+ trace[:stack_height] = ix['stackHeight'] unless ix['stackHeight'].nil?
73
+
74
+ result << TracedInstruction.new(
75
+ instruction: Instructions::Instruction.new(
76
+ program_address: program_meta.address,
77
+ accounts: accounts.empty? ? nil : accounts,
78
+ data: data
79
+ ),
80
+ trace: trace
81
+ )
82
+ end
83
+ end
84
+
85
+ result
86
+ end
87
+ end
88
+ end