solace 0.1.7 → 0.1.9
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/rubocop/cop/solace/exploded_hash.rb +82 -0
- data/lib/solace/accounts/address_lookup_table.rb +156 -0
- data/lib/solace/composers/address_lookup_table_program_create_composer.rb +95 -0
- data/lib/solace/composers/address_lookup_table_program_extend_composer.rb +87 -0
- data/lib/solace/composers/opaque_instruction_composer.rb +84 -0
- data/lib/solace/connection.rb +10 -0
- data/lib/solace/errors/address_lookup_table_not_found.rb +29 -0
- data/lib/solace/errors/rpc_error.rb +7 -1
- data/lib/solace/errors.rb +1 -0
- data/lib/solace/instructions/address_lookup_table_program/create_lookup_table_instruction.rb +80 -0
- data/lib/solace/instructions/address_lookup_table_program/extend_lookup_table_instruction.rb +69 -0
- data/lib/solace/transaction_composer.rb +195 -17
- data/lib/solace/transaction_decomposer.rb +250 -0
- data/lib/solace/utils/account_context.rb +73 -37
- data/lib/solace/utils/compute_budget.rb +85 -0
- data/lib/solace/version.rb +1 -1
- data/lib/solace.rb +9 -1
- metadata +12 -2
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Solace
|
|
4
|
+
module Instructions
|
|
5
|
+
module AddressLookupTableProgram
|
|
6
|
+
# Instruction for appending addresses to an existing address lookup table.
|
|
7
|
+
#
|
|
8
|
+
# Newly added addresses become usable one slot after the extend lands.
|
|
9
|
+
#
|
|
10
|
+
# @example Build an ExtendLookupTable instruction
|
|
11
|
+
# instruction = Solace::Instructions::AddressLookupTableProgram::ExtendLookupTableInstruction.build(
|
|
12
|
+
# addresses: [recipient1, recipient2],
|
|
13
|
+
# program_index: 4,
|
|
14
|
+
# table_index: 1,
|
|
15
|
+
# authority_index: 0,
|
|
16
|
+
# payer_index: 0,
|
|
17
|
+
# system_program_index: 3
|
|
18
|
+
# )
|
|
19
|
+
#
|
|
20
|
+
# @since 0.1.8
|
|
21
|
+
class ExtendLookupTableInstruction
|
|
22
|
+
# Instruction discriminator for ExtendLookupTable (u32 LE)
|
|
23
|
+
INSTRUCTION_ID = [2, 0, 0, 0].freeze
|
|
24
|
+
|
|
25
|
+
# Builds a Solace::Instruction for extending a lookup table
|
|
26
|
+
#
|
|
27
|
+
# @param addresses [Array<#to_s>] The addresses to append to the table
|
|
28
|
+
# @param program_index [Integer] Index of the lookup table program
|
|
29
|
+
# @param table_index [Integer] Index of the table account (writable)
|
|
30
|
+
# @param authority_index [Integer] Index of the table authority (signer)
|
|
31
|
+
# @param payer_index [Integer] Index of the rent payer (writable signer)
|
|
32
|
+
# @param system_program_index [Integer] Index of the system program
|
|
33
|
+
# @return [Solace::Instruction]
|
|
34
|
+
def self.build(
|
|
35
|
+
addresses:,
|
|
36
|
+
program_index:,
|
|
37
|
+
table_index:,
|
|
38
|
+
authority_index:,
|
|
39
|
+
payer_index:,
|
|
40
|
+
system_program_index:
|
|
41
|
+
)
|
|
42
|
+
Instruction.new.tap do |ix|
|
|
43
|
+
ix.program_index = program_index
|
|
44
|
+
ix.accounts = [table_index, authority_index, payer_index, system_program_index]
|
|
45
|
+
ix.data = data(addresses)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Instruction data for an extend lookup table instruction
|
|
50
|
+
#
|
|
51
|
+
# The BufferLayout is:
|
|
52
|
+
# - [Instruction discriminator (4 bytes, u32 LE)]
|
|
53
|
+
# - [Number of addresses (8 bytes, u64 LE)]
|
|
54
|
+
# - [Addresses (32 bytes each)]
|
|
55
|
+
#
|
|
56
|
+
# The address vector uses a u64 length prefix (bincode), not the u32
|
|
57
|
+
# Borsh prefix of {Utils::Codecs.encode_vec_pubkeys}.
|
|
58
|
+
#
|
|
59
|
+
# @param addresses [Array<#to_s>] The addresses to append
|
|
60
|
+
# @return [Array<Integer>]
|
|
61
|
+
def self.data(addresses)
|
|
62
|
+
INSTRUCTION_ID +
|
|
63
|
+
Utils::Codecs.encode_le_u64(addresses.length).bytes +
|
|
64
|
+
addresses.flat_map { |address| Utils::Codecs.encode_pubkey(address) }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -73,13 +73,33 @@ module Solace
|
|
|
73
73
|
# The instruction composers
|
|
74
74
|
attr_reader :instruction_composers
|
|
75
75
|
|
|
76
|
+
# @!attribute address_lookup_tables
|
|
77
|
+
# The registered address lookup tables
|
|
78
|
+
attr_reader :address_lookup_tables
|
|
79
|
+
|
|
80
|
+
# @!attribute version
|
|
81
|
+
# The transaction version (nil for legacy, 0 for v0); see {#set_version}
|
|
82
|
+
attr_reader :version
|
|
83
|
+
|
|
84
|
+
# @!attribute compute_budget
|
|
85
|
+
# The compute budget set on the composer (see {Utils::ComputeBudget})
|
|
86
|
+
attr_reader :compute_budget
|
|
87
|
+
|
|
88
|
+
# @!attribute blockhash
|
|
89
|
+
# The blockhash set on the composer, or nil when the latest is fetched at compose time
|
|
90
|
+
attr_reader :blockhash
|
|
91
|
+
|
|
76
92
|
# Initialize the composer
|
|
77
93
|
#
|
|
78
94
|
# @param connection [Solace::Connection] The connection to the Solana cluster
|
|
79
95
|
def initialize(connection:)
|
|
80
96
|
@connection = connection
|
|
97
|
+
@blockhash = nil
|
|
81
98
|
@instruction_composers = []
|
|
82
99
|
@context = Utils::AccountContext.new
|
|
100
|
+
@address_lookup_tables = []
|
|
101
|
+
@version = nil
|
|
102
|
+
@compute_budget = Utils::ComputeBudget.new
|
|
83
103
|
end
|
|
84
104
|
|
|
85
105
|
# Add an instruction composer to the transaction
|
|
@@ -87,7 +107,7 @@ module Solace
|
|
|
87
107
|
# @param composer [Composers::Base] The instruction composer
|
|
88
108
|
# @return [TransactionComposer] Self for chaining
|
|
89
109
|
def add_instruction(composer)
|
|
90
|
-
|
|
110
|
+
context.merge_from(composer.account_context)
|
|
91
111
|
instruction_composers << composer
|
|
92
112
|
self
|
|
93
113
|
end
|
|
@@ -99,7 +119,7 @@ module Solace
|
|
|
99
119
|
#
|
|
100
120
|
# @since 0.1.0
|
|
101
121
|
def prepend_instruction(composer)
|
|
102
|
-
|
|
122
|
+
context.merge_from(composer.account_context)
|
|
103
123
|
instruction_composers.unshift(composer)
|
|
104
124
|
self
|
|
105
125
|
end
|
|
@@ -112,7 +132,7 @@ module Solace
|
|
|
112
132
|
#
|
|
113
133
|
# @since 0.1.0
|
|
114
134
|
def insert_instruction(index, composer)
|
|
115
|
-
|
|
135
|
+
context.merge_from(composer.account_context)
|
|
116
136
|
instruction_composers.insert(index, composer)
|
|
117
137
|
self
|
|
118
138
|
end
|
|
@@ -126,7 +146,7 @@ module Solace
|
|
|
126
146
|
#
|
|
127
147
|
# @since 0.1.0
|
|
128
148
|
def merge(other, placement: :add, index: nil)
|
|
129
|
-
|
|
149
|
+
absorb(other)
|
|
130
150
|
|
|
131
151
|
case placement
|
|
132
152
|
when :add
|
|
@@ -154,36 +174,194 @@ module Solace
|
|
|
154
174
|
self
|
|
155
175
|
end
|
|
156
176
|
|
|
177
|
+
# Set the transaction version
|
|
178
|
+
#
|
|
179
|
+
# Registering a lookup table opts into v0 on its own; this is for a v0
|
|
180
|
+
# transaction that loads nothing through a table, which is still v0 on the
|
|
181
|
+
# wire. nil composes a legacy message.
|
|
182
|
+
#
|
|
183
|
+
# @param version [Integer, nil] 0 for v0, nil for legacy
|
|
184
|
+
# @return [TransactionComposer] Self for chaining
|
|
185
|
+
# @raise [ArgumentError] For a version other than 0 or nil
|
|
186
|
+
def set_version(version)
|
|
187
|
+
raise ArgumentError, "Unsupported transaction version: #{version.inspect}" unless [nil, 0].include?(version)
|
|
188
|
+
|
|
189
|
+
@version = version
|
|
190
|
+
self
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Set the blockhash to compose against
|
|
194
|
+
#
|
|
195
|
+
# Beside {#set_fee_payer}: a caller re-composing a transaction it was handed
|
|
196
|
+
# wants the blockhash that transaction carried, so the expiry its origin
|
|
197
|
+
# reports stays true. With none set, {#compose_transaction} fetches the
|
|
198
|
+
# latest from the connection. nil clears it.
|
|
199
|
+
#
|
|
200
|
+
# @example
|
|
201
|
+
# composer.set_blockhash(original.message.recent_blockhash)
|
|
202
|
+
#
|
|
203
|
+
# @param blockhash [#to_s, nil] The blockhash (base58)
|
|
204
|
+
# @return [TransactionComposer] Self for chaining
|
|
205
|
+
def set_blockhash(blockhash)
|
|
206
|
+
@blockhash = blockhash&.to_s
|
|
207
|
+
self
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Set the compute budget for the transaction
|
|
211
|
+
#
|
|
212
|
+
# The composer owns the budget as a setting: when it composes, the
|
|
213
|
+
# ComputeBudget instructions are written first, and any
|
|
214
|
+
# ComputeBudget composer of the same kind that was added as a plain
|
|
215
|
+
# instruction is left out, so the budget can never be declared twice
|
|
216
|
+
# (a duplicate `SetComputeUnitLimit` fails on chain). Each keyword
|
|
217
|
+
# replaces that setting; nil clears it. Read it back through {#compute_budget}.
|
|
218
|
+
#
|
|
219
|
+
# @example
|
|
220
|
+
# composer.set_compute_budget(units: 200_000, micro_lamports: 50_000)
|
|
221
|
+
#
|
|
222
|
+
# @param units [Integer, nil] The compute unit limit
|
|
223
|
+
# @param micro_lamports [Integer, nil] The compute unit price in micro-lamports
|
|
224
|
+
# @return [TransactionComposer] Self for chaining
|
|
225
|
+
def set_compute_budget(units: nil, micro_lamports: nil)
|
|
226
|
+
apply_compute_budget(Utils::ComputeBudget.new(units: units, micro_lamports: micro_lamports))
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Make an address lookup table available to the transaction
|
|
230
|
+
#
|
|
231
|
+
# Registering a table opts the transaction into the v0 format: every
|
|
232
|
+
# compiled account found in a table that is allowed to load (a non-signer
|
|
233
|
+
# that is not the fee payer and not an invoked program id) is referenced by
|
|
234
|
+
# table index instead of occupying a static account slot. Adding the same
|
|
235
|
+
# table (by account) twice is a no-op.
|
|
236
|
+
#
|
|
237
|
+
# @example
|
|
238
|
+
# composer.add_address_lookup_table(account: table_address, addresses: on_chain_addresses)
|
|
239
|
+
#
|
|
240
|
+
# @param account [#to_s, PublicKey] The lookup table's on-chain address
|
|
241
|
+
# @param addresses [Array<#to_s>, nil] The full, ordered list of addresses stored in the table
|
|
242
|
+
# @return [TransactionComposer] Self for chaining
|
|
243
|
+
#
|
|
244
|
+
# @since 0.1.8
|
|
245
|
+
def add_address_lookup_table(account:, addresses: nil)
|
|
246
|
+
account = account.to_s
|
|
247
|
+
set_version(0) # Lookup tables require a v0 transaction
|
|
248
|
+
|
|
249
|
+
unless address_lookup_tables.any? { |table| table.account == account }
|
|
250
|
+
address_lookup_tables << Solace::Accounts::AddressLookupTable.new(account: account, addresses: addresses)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
self
|
|
254
|
+
end
|
|
255
|
+
|
|
157
256
|
# Compose the final transaction
|
|
158
257
|
#
|
|
258
|
+
# Emits a message at the composer's {#version} — legacy by default, or v0
|
|
259
|
+
# once a lookup table has been added — loading eligible accounts through any
|
|
260
|
+
# registered tables.
|
|
261
|
+
#
|
|
262
|
+
# Composes against the {#blockhash} set with {#set_blockhash}, or the latest
|
|
263
|
+
# fetched from the connection when none is set.
|
|
264
|
+
#
|
|
159
265
|
# @return [Transaction] The composed transaction (unsigned)
|
|
160
266
|
def compose_transaction
|
|
161
267
|
context.compile
|
|
162
268
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
accounts: context.accounts,
|
|
166
|
-
instructions: build_instructions,
|
|
167
|
-
recent_blockhash: connection.get_latest_blockhash[0]
|
|
168
|
-
)
|
|
269
|
+
writable, readonly, references = resolve_address_lookup_tables
|
|
270
|
+
context.compile(loaded_accounts: writable + readonly)
|
|
169
271
|
|
|
170
|
-
Solace::Transaction.new(message:
|
|
272
|
+
Solace::Transaction.new(message: build_message(references))
|
|
171
273
|
end
|
|
172
274
|
|
|
173
275
|
private
|
|
174
276
|
|
|
175
|
-
#
|
|
277
|
+
# Fold the registered tables into the accounts they load and the references
|
|
278
|
+
# the message carries — a single pass over the tables (the order that
|
|
279
|
+
# defines the v0 combined account space).
|
|
280
|
+
#
|
|
281
|
+
# First table wins when tables share an address; within a table the on-chain
|
|
282
|
+
# address order is preserved, so the accumulated writable/readonly segments
|
|
283
|
+
# match the runtime's loaded-address order once concatenated.
|
|
284
|
+
#
|
|
285
|
+
# @return [Array(Array<String>, Array<String>, Array<Solace::AddressLookupTable>)]
|
|
286
|
+
# The loaded writable pubkeys, loaded readonly pubkeys, and table references
|
|
287
|
+
def resolve_address_lookup_tables
|
|
288
|
+
loadable = loadable_accounts
|
|
289
|
+
writable = []
|
|
290
|
+
readonly = []
|
|
291
|
+
|
|
292
|
+
references = address_lookup_tables.filter_map do |table|
|
|
293
|
+
loaded = table.addresses & loadable
|
|
294
|
+
loadable -= loaded
|
|
295
|
+
on, off = loaded.partition { |pubkey| context.writable?(pubkey) }
|
|
296
|
+
writable.concat(on)
|
|
297
|
+
readonly.concat(off)
|
|
298
|
+
table.reference(on, off)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
[writable, readonly, references]
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Accounts eligible to load through a table: referenced by the transaction,
|
|
305
|
+
# not a signer (which covers the fee payer), and not an invoked program id.
|
|
306
|
+
#
|
|
307
|
+
# @return [Array<String>] The loadable account pubkeys
|
|
308
|
+
def loadable_accounts
|
|
309
|
+
programs = program_ids
|
|
310
|
+
context.accounts.reject { |pubkey| context.signer?(pubkey) || programs.include?(pubkey) }
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Program ids invoked by the built instructions — these must stay static
|
|
314
|
+
#
|
|
315
|
+
# @return [Array<String>] The invoked program id pubkeys
|
|
316
|
+
def program_ids
|
|
317
|
+
build_instructions.map { context.accounts[_1.program_index] }.uniq
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Build the composed message at the composer's version (legacy or v0)
|
|
321
|
+
#
|
|
322
|
+
# @param references [Array<Solace::AddressLookupTable>] The table references
|
|
323
|
+
# @return [Solace::Message] The composed message
|
|
324
|
+
def build_message(references)
|
|
325
|
+
Solace::Message.new(
|
|
326
|
+
version: version,
|
|
327
|
+
header: context.header,
|
|
328
|
+
accounts: context.accounts,
|
|
329
|
+
instructions: build_instructions,
|
|
330
|
+
recent_blockhash: blockhash || connection.get_latest_blockhash[0],
|
|
331
|
+
address_lookup_tables: references
|
|
332
|
+
)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Build all instructions with resolved indices: the budget first, then the
|
|
336
|
+
# added composers minus any ComputeBudget composer the budget supersedes
|
|
176
337
|
#
|
|
177
338
|
# @return [Array<Solace::Instruction>] The built instructions
|
|
178
339
|
def build_instructions
|
|
179
|
-
instruction_composers.
|
|
340
|
+
kept = instruction_composers.reject { |composer| compute_budget.supersedes?(composer) }
|
|
341
|
+
|
|
342
|
+
(compute_budget.composers + kept).map { _1.build_instruction(context) }.flatten
|
|
180
343
|
end
|
|
181
344
|
|
|
182
|
-
#
|
|
345
|
+
# Replace the compute budget, bringing its program into the account context
|
|
183
346
|
#
|
|
184
|
-
# @param
|
|
185
|
-
|
|
186
|
-
|
|
347
|
+
# @param budget [Utils::ComputeBudget] The budget to hold
|
|
348
|
+
# @return [TransactionComposer] Self for chaining
|
|
349
|
+
def apply_compute_budget(budget)
|
|
350
|
+
@compute_budget = budget
|
|
351
|
+
budget.composers.each { |composer| context.merge_from(composer.account_context) }
|
|
352
|
+
self
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Take on another composer's accounts, tables (deduped by account) and
|
|
356
|
+
# budget (where it has one set)
|
|
357
|
+
#
|
|
358
|
+
# @param other [TransactionComposer] The other composer
|
|
359
|
+
def absorb(other)
|
|
360
|
+
context.merge_from(other.context)
|
|
361
|
+
other.address_lookup_tables.each do |table|
|
|
362
|
+
add_address_lookup_table(account: table.account, addresses: table.addresses)
|
|
363
|
+
end
|
|
364
|
+
apply_compute_budget(compute_budget.merge(other.compute_budget))
|
|
187
365
|
end
|
|
188
366
|
end
|
|
189
367
|
end
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'composers/base'
|
|
4
|
+
require_relative 'composers/opaque_instruction_composer'
|
|
5
|
+
require_relative 'instructions/compute_budget/set_compute_unit_limit_instruction'
|
|
6
|
+
require_relative 'instructions/compute_budget/set_compute_unit_price_instruction'
|
|
7
|
+
|
|
8
|
+
module Solace
|
|
9
|
+
# Takes a transaction apart into the composer that composes it again
|
|
10
|
+
#
|
|
11
|
+
# The reverse of {TransactionComposer}: given a transaction (or its base64),
|
|
12
|
+
# answers a {TransactionComposer} holding one
|
|
13
|
+
# {Composers::OpaqueInstructionComposer} per instruction, in order, each
|
|
14
|
+
# carrying the program it invokes, its accounts with the signer and writable
|
|
15
|
+
# flags the message header gave them, and its data untouched. The lookup
|
|
16
|
+
# tables a v0 message references are read from chain once and registered, and
|
|
17
|
+
# the version is kept, so the composer composes as v0 again (with or without
|
|
18
|
+
# tables); a `SetComputeUnitLimit` or
|
|
19
|
+
# `SetComputeUnitPrice` the transaction carried becomes the composer's
|
|
20
|
+
# {TransactionComposer#compute_budget}; and the blockhash it was composed
|
|
21
|
+
# against is set as the composer's {TransactionComposer#blockhash}.
|
|
22
|
+
#
|
|
23
|
+
# The Solana facts applied:
|
|
24
|
+
#
|
|
25
|
+
# - Static keys take their flags from the header ordering: signers first,
|
|
26
|
+
# writable before read-only, then non-signers the same way.
|
|
27
|
+
# - A v0 message's instruction indexes address the combined space: the
|
|
28
|
+
# static keys, then every table's writable entries in table order, then
|
|
29
|
+
# every table's read-only entries. A loaded address never signs and is
|
|
30
|
+
# writable by the segment it sits in.
|
|
31
|
+
# - A table that does not exist on chain is an error, not an empty list.
|
|
32
|
+
#
|
|
33
|
+
# For a transaction a {TransactionComposer} built, the composer answered here
|
|
34
|
+
# composes the same bytes again.
|
|
35
|
+
#
|
|
36
|
+
# @example
|
|
37
|
+
# composer = Solace::TransactionDecomposer.new(connection: connection).decompose_transaction(transaction)
|
|
38
|
+
# composer.instruction_composers # => the recovered instructions
|
|
39
|
+
# composer.compose_transaction # => the transaction again
|
|
40
|
+
#
|
|
41
|
+
# @see Solace::TransactionComposer
|
|
42
|
+
# @since 0.1.9
|
|
43
|
+
class TransactionDecomposer
|
|
44
|
+
# The discriminators of the two directives the budget setting holds
|
|
45
|
+
LIMIT_INDEX = Instructions::ComputeBudget::SetComputeUnitLimitInstruction::INSTRUCTION_INDEX.first
|
|
46
|
+
PRICE_INDEX = Instructions::ComputeBudget::SetComputeUnitPriceInstruction::INSTRUCTION_INDEX.first
|
|
47
|
+
private_constant :LIMIT_INDEX, :PRICE_INDEX
|
|
48
|
+
|
|
49
|
+
# What one call reads off a message: the message, the tables it references
|
|
50
|
+
# (by account, read from chain), the combined account space in index
|
|
51
|
+
# order, and a context declaring every account's role
|
|
52
|
+
Reading = Struct.new(:message, :tables, :accounts, :roles, keyword_init: true)
|
|
53
|
+
private_constant :Reading
|
|
54
|
+
|
|
55
|
+
# @!attribute connection
|
|
56
|
+
# The connection lookup tables are read through
|
|
57
|
+
attr_reader :connection
|
|
58
|
+
|
|
59
|
+
# @param connection [Solace::Connection] The connection to read lookup tables through
|
|
60
|
+
def initialize(connection:)
|
|
61
|
+
@connection = connection
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Take a transaction apart
|
|
65
|
+
#
|
|
66
|
+
# @param transaction [Solace::Transaction, String] The transaction, or its base64
|
|
67
|
+
# @return [Solace::TransactionComposer] A composer that composes it again
|
|
68
|
+
# @raise [Solace::Errors::AddressLookupTableNotFound] When a referenced table is not on chain
|
|
69
|
+
def decompose_transaction(transaction)
|
|
70
|
+
message = message_of(transaction)
|
|
71
|
+
reading = read(message)
|
|
72
|
+
composer = TransactionComposer.new(connection: connection)
|
|
73
|
+
.set_version(message.version)
|
|
74
|
+
.set_blockhash(message.recent_blockhash)
|
|
75
|
+
|
|
76
|
+
seed(composer, reading)
|
|
77
|
+
fill(composer, reading)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# Read a message: its tables from chain, its combined account space, and
|
|
83
|
+
# the role of every account in it
|
|
84
|
+
#
|
|
85
|
+
# @param message [Solace::Message]
|
|
86
|
+
# @return [Reading]
|
|
87
|
+
def read(message)
|
|
88
|
+
tables = fetch_tables(message)
|
|
89
|
+
writable = loaded(message, tables, :writable_indexes)
|
|
90
|
+
readonly = loaded(message, tables, :readonly_indexes)
|
|
91
|
+
roles = Utils::AccountContext.new
|
|
92
|
+
|
|
93
|
+
declare_static(roles, message)
|
|
94
|
+
writable.each { |pubkey| roles.merge_account(pubkey, signer: false, writable: true) }
|
|
95
|
+
readonly.each { |pubkey| roles.merge_account(pubkey, signer: false, writable: false) }
|
|
96
|
+
|
|
97
|
+
accounts = message.accounts + writable + readonly
|
|
98
|
+
|
|
99
|
+
Reading.new(message: message, tables: tables, accounts: accounts, roles: roles)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Declare the static keys with the roles their header positions give them:
|
|
103
|
+
# signers first, writable before read-only, then non-signers the same way
|
|
104
|
+
#
|
|
105
|
+
# @param roles [Utils::AccountContext]
|
|
106
|
+
# @param message [Solace::Message]
|
|
107
|
+
# @return [void]
|
|
108
|
+
def declare_static(roles, message)
|
|
109
|
+
signers, readonly_signed, readonly_unsigned = message.header
|
|
110
|
+
|
|
111
|
+
message.accounts.each_with_index do |pubkey, index|
|
|
112
|
+
is_signer = index < signers
|
|
113
|
+
readonly_from = is_signer ? signers - readonly_signed : message.accounts.size - readonly_unsigned
|
|
114
|
+
is_writable = index < readonly_from
|
|
115
|
+
|
|
116
|
+
roles.merge_account(pubkey, signer: is_signer, writable: is_writable)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Seed the composer's accounts in message order and set the fee payer, so
|
|
121
|
+
# it composes the same account order again
|
|
122
|
+
#
|
|
123
|
+
# @param composer [Solace::TransactionComposer]
|
|
124
|
+
# @param reading [Reading]
|
|
125
|
+
# @return [void]
|
|
126
|
+
def seed(composer, reading)
|
|
127
|
+
composer.context.merge_from(reading.roles)
|
|
128
|
+
composer.set_fee_payer(reading.message.accounts.first)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Add the instructions, register the tables, and set the budget
|
|
132
|
+
#
|
|
133
|
+
# @param composer [Solace::TransactionComposer]
|
|
134
|
+
# @param reading [Reading]
|
|
135
|
+
# @return [Solace::TransactionComposer] The composer
|
|
136
|
+
def fill(composer, reading)
|
|
137
|
+
instruction_composers(reading).each { |instruction| composer.add_instruction(instruction) }
|
|
138
|
+
|
|
139
|
+
reading.tables.each_value do |table|
|
|
140
|
+
composer.add_address_lookup_table(account: table.account, addresses: table.addresses)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
budget = compute_budget(reading)
|
|
144
|
+
composer.set_compute_budget(units: budget.units, micro_lamports: budget.micro_lamports) if budget.set?
|
|
145
|
+
|
|
146
|
+
composer
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The message of a transaction given as the object or its base64
|
|
150
|
+
#
|
|
151
|
+
# @param transaction [Solace::Transaction, String]
|
|
152
|
+
# @return [Solace::Message]
|
|
153
|
+
def message_of(transaction)
|
|
154
|
+
transaction = Solace::Transaction.from(transaction) if transaction.is_a?(String)
|
|
155
|
+
transaction.message
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# The tables the message references, read from chain once each, in message order
|
|
159
|
+
#
|
|
160
|
+
# @param message [Solace::Message]
|
|
161
|
+
# @return [Hash{String => Accounts::AddressLookupTable}] Keyed by table account
|
|
162
|
+
def fetch_tables(message)
|
|
163
|
+
references = Array(message.address_lookup_tables)
|
|
164
|
+
accounts = references.map(&:account).uniq
|
|
165
|
+
|
|
166
|
+
accounts.to_h do |account|
|
|
167
|
+
table = Accounts::AddressLookupTable.fetch(account, connection: connection)
|
|
168
|
+
raise Errors::AddressLookupTableNotFound, account unless table
|
|
169
|
+
|
|
170
|
+
[account, table]
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# The pubkeys one segment loads, across every table in table order
|
|
175
|
+
#
|
|
176
|
+
# @param message [Solace::Message]
|
|
177
|
+
# @param tables [Hash{String => Accounts::AddressLookupTable}]
|
|
178
|
+
# @param kind [Symbol] :writable_indexes or :readonly_indexes
|
|
179
|
+
# @return [Array<String>]
|
|
180
|
+
def loaded(message, tables, kind)
|
|
181
|
+
references = Array(message.address_lookup_tables)
|
|
182
|
+
|
|
183
|
+
references.flat_map do |reference|
|
|
184
|
+
addresses = tables.fetch(reference.account).addresses
|
|
185
|
+
indexes = kind == :writable_indexes ? reference.writable_indexes : reference.readonly_indexes
|
|
186
|
+
|
|
187
|
+
indexes.map { |index| addresses.fetch(index) }
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# The message's instructions as composers, the budget directives left out
|
|
192
|
+
#
|
|
193
|
+
# @param reading [Reading]
|
|
194
|
+
# @return [Array<Composers::OpaqueInstructionComposer>]
|
|
195
|
+
def instruction_composers(reading)
|
|
196
|
+
lifted = reading.message.instructions.reject { |instruction| budget?(instruction, reading) }
|
|
197
|
+
|
|
198
|
+
lifted.map do |instruction|
|
|
199
|
+
program_id = reading.accounts.fetch(instruction.program_index)
|
|
200
|
+
accounts = instruction.accounts.map { |index| reading.accounts.fetch(index) }
|
|
201
|
+
|
|
202
|
+
Composers::OpaqueInstructionComposer.new(
|
|
203
|
+
program_id: program_id,
|
|
204
|
+
accounts: accounts,
|
|
205
|
+
data: instruction.data,
|
|
206
|
+
roles: reading.roles
|
|
207
|
+
)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Whether an instruction is a limit or price directive the budget setting holds
|
|
212
|
+
#
|
|
213
|
+
# @param instruction [Solace::Instruction]
|
|
214
|
+
# @param reading [Reading]
|
|
215
|
+
# @return [Boolean]
|
|
216
|
+
def budget?(instruction, reading)
|
|
217
|
+
program_id = reading.accounts.fetch(instruction.program_index)
|
|
218
|
+
discriminator = instruction.data.first
|
|
219
|
+
|
|
220
|
+
program_id == Constants::COMPUTE_BUDGET_PROGRAM_ID && [LIMIT_INDEX, PRICE_INDEX].include?(discriminator)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# The budget the message carried, decoded from its limit and price directives
|
|
224
|
+
#
|
|
225
|
+
# @param reading [Reading]
|
|
226
|
+
# @return [Utils::ComputeBudget] Unset when the message carried neither
|
|
227
|
+
def compute_budget(reading)
|
|
228
|
+
directives = reading.message.instructions.select { |instruction| budget?(instruction, reading) }
|
|
229
|
+
|
|
230
|
+
Utils::ComputeBudget.new(
|
|
231
|
+
units: decode(directives, LIMIT_INDEX, 'L<'),
|
|
232
|
+
micro_lamports: decode(directives, PRICE_INDEX, 'Q<')
|
|
233
|
+
)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# The value of the first directive with the given discriminator, or nil
|
|
237
|
+
#
|
|
238
|
+
# @param directives [Array<Solace::Instruction>] The budget directives
|
|
239
|
+
# @param discriminator [Integer] The directive's first data byte
|
|
240
|
+
# @param format [String] The pack format of the value that follows it
|
|
241
|
+
# @return [Integer, nil]
|
|
242
|
+
def decode(directives, discriminator, format)
|
|
243
|
+
directive = directives.find { |instruction| instruction.data.first == discriminator }
|
|
244
|
+
return unless directive
|
|
245
|
+
|
|
246
|
+
value_bytes = directive.data.drop(1)
|
|
247
|
+
value_bytes.pack('C*').unpack1(format)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|