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.
- checksums.yaml +4 -4
- data/lib/solana/ruby/kit/errors.rb +37 -5
- data/lib/solana/ruby/kit/instruction_plans/instruction_plan.rb +34 -7
- data/lib/solana/ruby/kit/instruction_plans/max_instructions.rb +59 -0
- data/lib/solana/ruby/kit/instruction_plans/transaction_planner.rb +49 -9
- data/lib/solana/ruby/kit/instruction_plans.rb +1 -0
- data/lib/solana/ruby/kit/resource_limit_estimation.rb +156 -0
- data/lib/solana/ruby/kit/rpc/api/get_signatures_for_address.rb +72 -0
- data/lib/solana/ruby/kit/rpc/api/get_vote_accounts.rb +2 -0
- data/lib/solana/ruby/kit/rpc/client.rb +2 -0
- data/lib/solana/ruby/kit/rpc/transport.rb +3 -4
- data/lib/solana/ruby/kit/transaction_introspection/compiled_transaction_message.rb +168 -0
- data/lib/solana/ruby/kit/transaction_introspection/decode_rpc_transaction.rb +159 -0
- data/lib/solana/ruby/kit/transaction_introspection/get_inner_instructions.rb +86 -0
- data/lib/solana/ruby/kit/transaction_introspection/get_instructions.rb +134 -0
- data/lib/solana/ruby/kit/transaction_introspection/loaded_addresses.rb +19 -0
- data/lib/solana/ruby/kit/transaction_introspection/types.rb +29 -0
- data/lib/solana/ruby/kit/transaction_introspection/walk_instructions.rb +60 -0
- data/lib/solana/ruby/kit/transaction_introspection.rb +13 -0
- data/lib/solana/ruby/kit/transaction_messages/compute_budget.rb +145 -0
- data/lib/solana/ruby/kit/transaction_messages.rb +1 -0
- data/lib/solana/ruby/kit/version.rb +1 -1
- data/lib/solana/ruby/kit.rb +3 -0
- metadata +13 -1
|
@@ -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']
|
|
121
|
-
req['Accept']
|
|
122
|
-
req['
|
|
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
|