solace 0.1.8 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4e9ca7ab89db7412f9eea6ba3fc36e7a126fddf403fa639bb1d40fdf5a338a61
4
- data.tar.gz: d35705a7acb46f5da7a4d421d32f5be1fb3cfbcdc293623799ba6be8467a3f2d
3
+ metadata.gz: 2f6a793f450df697c4f216ab4e5a483d7a331f57a3fccb05ecd7c76e788fa4ed
4
+ data.tar.gz: 479fcb0c38b8e5ea8d82636922ab327ec68ae18455550658c79b06ddd11b2bbd
5
5
  SHA512:
6
- metadata.gz: a4621dc7f860419c640ad856ad602acb4c15dde56cc89a44130f4b505baf9856ac20e9227ffd88e7797c4df9aa58d3e9f0485e6f4e52acbd40a0de1d73b5feca
7
- data.tar.gz: 305310534a9a2373ddb8df5e0208f2ab5723d9ba6b7c4be4d21533cc07f7faf94819f6744be7564ec0f20a82fc255230390b68f2a0b67cf69bc7009a05d5d60a
6
+ metadata.gz: ec35bdf08bf2ecdd235d25ec67f9c897a7a5c0a167c46d3f2dea8cfb21600b7c3523e236f9db644638fdb2aadd3b219a67296676d580d3ce2ef2ad1a9c8ebe00
7
+ data.tar.gz: 9647d86486a247042220f53b621591e2de5599868bb154350eb367a4d523301c60ac1735cc664c46ebb18e181f7095c9972748257d25373863d81c520ab7ba3a
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Solace
6
+ # Every pair in a braced hash starts on its own line.
7
+ #
8
+ # A hash is read as a table: a reader scanning for one key, or diffing two
9
+ # of them, works down the left edge. Collapsing pairs onto a shared line
10
+ # hides a key mid-line and makes a one-key change show up as a rewritten
11
+ # line. Length is not the test — a short hash collapses just as badly as a
12
+ # long one, which is why the built-in layout cops are not enough: they only
13
+ # act once a hash is already multi-line.
14
+ #
15
+ # Only a hash written on one line is this cop's business. Once a hash spans
16
+ # lines, Layout/FirstHashElementLineBreak and Layout/MultilineHashKeyLineBreaks
17
+ # already break every pair onto its own line, and a second cop inserting the
18
+ # same break in the same correction loop leaves a blank line behind.
19
+ #
20
+ # Two more restrictions keep it from reaching where it does not belong. A
21
+ # hash of one pair is left inline, since it is already the only thing on
22
+ # its line. And only BRACED hashes are checked: a keyword argument list is
23
+ # a hash node too, and exploding it would break up every multi-argument
24
+ # call in the codebase.
25
+ #
26
+ # @example
27
+ # # bad
28
+ # page_id: { type: 'string', format: 'uuid' }
29
+ #
30
+ # # good
31
+ # page_id: {
32
+ # type: 'string',
33
+ # format: 'uuid'
34
+ # }
35
+ #
36
+ # # good — one pair, and a keyword argument list
37
+ # { user: payload[:user_id] }
38
+ # create(:user, name: 'Ada', email: 'ada@example.com')
39
+ class ExplodedHash < Base
40
+ extend AutoCorrector
41
+
42
+ MSG = 'Each pair in a hash starts on its own line.'
43
+
44
+ def on_hash(node)
45
+ pairs = braced_pairs(node)
46
+ return unless pairs
47
+
48
+ pairs.each { |pair| flag(pair) }
49
+ end
50
+
51
+ private
52
+
53
+ # Nil for anything this cop leaves alone: a hash already spanning lines,
54
+ # a keyword argument list, which carries no braces, and a hash of a
55
+ # single pair.
56
+ def braced_pairs(node)
57
+ return unless node.loc.begin
58
+ return if node.multiline?
59
+
60
+ pairs = node.pairs
61
+ pairs if pairs.size >= 2
62
+ end
63
+
64
+ # The break replaces the space in front of the pair rather than being
65
+ # inserted before it, or the old separator is left behind as trailing
66
+ # whitespace. Indentation is left to Layout/FirstHashElementIndentation
67
+ # and Layout/HashAlignment, which run in the same correction loop.
68
+ def flag(pair)
69
+ add_offense(pair) { |corrector| corrector.replace(leading_space(pair), "\n") }
70
+ end
71
+
72
+ def leading_space(pair)
73
+ range = pair.source_range
74
+ source = range.source_buffer.source
75
+ start = range.begin_pos
76
+ start -= 1 while start.positive? && [' ', "\t"].include?(source[start - 1])
77
+ Parser::Source::Range.new(range.source_buffer, start, range.begin_pos)
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -21,9 +21,8 @@ module Solace
21
21
  # table.reference(loaded_writable, loaded_readonly) # => Solace::AddressLookupTable or nil
22
22
  #
23
23
  # @example Read a table's on-chain state
24
- # data = Base64.decode64(connection.get_account_info(address)['data'][0])
25
- # table = Solace::Accounts::AddressLookupTable.deserialize(StringIO.new(data))
26
- # table.addresses # => the stored addresses
24
+ # table = Solace::Accounts::AddressLookupTable.fetch(address, connection: connection)
25
+ # table&.addresses # => the stored addresses, or nil if the chain holds no such account
27
26
  #
28
27
  # @see Solace::AddressLookupTable
29
28
  # @see Solace::TransactionComposer
@@ -53,38 +52,61 @@ module Solace
53
52
  # @return [Integer, nil] The slot the table was last extended in
54
53
  attr_reader :last_extended_slot
55
54
 
56
- # Deserialize an on-chain lookup table account
57
- #
58
- # The BufferLayout is:
59
- # - [State type (4 bytes, u32 LE)]
60
- # - [Deactivation slot (8 bytes, u64 LE)]
61
- # - [Last extended slot (8 bytes, u64 LE)]
62
- # - [Last extended start index (1 byte)]
63
- # - [Authority (Borsh Option<Pubkey>)]
64
- # - [Padding, up to {META_SIZE}]
65
- # - [Addresses (32 bytes each, to end of data)]
66
- #
67
- # @param io [IO, StringIO] The account data to read from
68
- # @return [AddressLookupTable] The parsed table
69
- def self.deserialize(io)
70
- Utils::Codecs.decode_le_u32(io) # state type (1 = lookup table); positional
71
- deactivation_slot = Utils::Codecs.decode_le_u64(io)
72
- last_extended_slot = Utils::Codecs.decode_le_u64(io)
73
-
74
- Utils::Codecs.decode_u8(io) # last extended start index; positional
75
- authority = Utils::Codecs.decode_option_pubkey(io)
76
-
77
- io.seek(META_SIZE) # addresses begin after the fixed-size metadata region
78
-
79
- addresses = []
80
- addresses << Utils::Codecs.decode_pubkey(io) until io.eof?
81
-
82
- new(
83
- deactivation_slot: deactivation_slot,
84
- last_extended_slot: last_extended_slot,
85
- authority: authority,
86
- addresses: addresses
87
- )
55
+ class << self
56
+ # Fetch a lookup table account from chain
57
+ #
58
+ # Like {Solace::Connection#get_account_info}, answers nil when the chain
59
+ # holds no account at the address; the caller decides whether that is an error.
60
+ #
61
+ # @param account [#to_s, PublicKey] The lookup table's on-chain address
62
+ # @param connection [Solace::Connection] The connection to read it through
63
+ # @return [AddressLookupTable, nil] The table with its address set, or nil
64
+ def fetch(account, connection:)
65
+ info = connection.get_account_info(account.to_s)
66
+ return unless info
67
+
68
+ base64, = info['data'] # the RPC answers [data, encoding]
69
+ deserialize(Utils::Codecs.base64_to_bytestream(base64), account: account)
70
+ end
71
+
72
+ # Deserialize an on-chain lookup table account
73
+ #
74
+ # The BufferLayout is:
75
+ # - [State type (4 bytes, u32 LE)]
76
+ # - [Deactivation slot (8 bytes, u64 LE)]
77
+ # - [Last extended slot (8 bytes, u64 LE)]
78
+ # - [Last extended start index (1 byte)]
79
+ # - [Authority (Borsh Option<Pubkey>)]
80
+ # - [Padding, up to {META_SIZE}]
81
+ # - [Addresses (32 bytes each, to end of data)]
82
+ #
83
+ # The account data does not carry its own address, so pass it to have
84
+ # the table know where it lives.
85
+ #
86
+ # @param io [IO, StringIO] The account data to read from
87
+ # @param account [#to_s, PublicKey, nil] The lookup table's on-chain address
88
+ # @return [AddressLookupTable] The parsed table
89
+ def deserialize(io, account: nil)
90
+ Utils::Codecs.decode_le_u32(io) # state type (1 = lookup table); positional
91
+ deactivation_slot = Utils::Codecs.decode_le_u64(io)
92
+ last_extended_slot = Utils::Codecs.decode_le_u64(io)
93
+
94
+ Utils::Codecs.decode_u8(io) # last extended start index; positional
95
+ authority = Utils::Codecs.decode_option_pubkey(io)
96
+
97
+ io.seek(META_SIZE) # addresses begin after the fixed-size metadata region
98
+
99
+ addresses = []
100
+ addresses << Utils::Codecs.decode_pubkey(io) until io.eof?
101
+
102
+ new(
103
+ account: account,
104
+ deactivation_slot: deactivation_slot,
105
+ last_extended_slot: last_extended_slot,
106
+ authority: authority,
107
+ addresses: addresses
108
+ )
109
+ end
88
110
  end
89
111
 
90
112
  # Initialize a lookup table account
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Solace
4
+ module Composers
5
+ # Composer for an instruction it does not interpret.
6
+ #
7
+ # Where every other composer derives its accounts' roles from domain
8
+ # arguments, this one is handed the program id, the accounts in order, the
9
+ # data, and an {Utils::AccountContext} declaring each account's role, and
10
+ # rebuilds the instruction by index against whatever context it is
11
+ # composed into. It is what {Solace::TransactionDecomposer} answers for
12
+ # each instruction of a transaction taken apart, and what a caller reaches
13
+ # for to rebuild one of those with an account swapped out.
14
+ #
15
+ # @example Rebuild a recovered instruction with a different rent payer
16
+ # roles = Solace::Utils::AccountContext.new
17
+ # roles.merge_from(recovered.account_context)
18
+ # roles.add_writable_signer(sponsor)
19
+ #
20
+ # composer = Solace::Composers::OpaqueInstructionComposer.new(
21
+ # program_id: recovered.program_id,
22
+ # accounts: [sponsor, *recovered.accounts.drop(1)],
23
+ # data: recovered.data,
24
+ # roles: roles
25
+ # )
26
+ #
27
+ # @since 0.1.9
28
+ class OpaqueInstructionComposer < Base
29
+ # The program the instruction invokes
30
+ #
31
+ # @return [String] The program id
32
+ def program_id
33
+ params[:program_id].to_s
34
+ end
35
+
36
+ # The accounts the instruction touches, in order
37
+ #
38
+ # @return [Array<String>] The pubkeys
39
+ def accounts
40
+ @accounts ||= params[:accounts].map(&:to_s)
41
+ end
42
+
43
+ # The instruction data, untouched
44
+ #
45
+ # @return [Array<Integer>] The data bytes
46
+ def data
47
+ params[:data]
48
+ end
49
+
50
+ # The context declaring each account's role
51
+ #
52
+ # @return [Utils::AccountContext]
53
+ def roles
54
+ params[:roles]
55
+ end
56
+
57
+ # Declare every account with the role given for it, plus the program
58
+ #
59
+ # @return [void]
60
+ # @raise [ArgumentError] When an account has no role declared
61
+ def setup_accounts
62
+ accounts.each do |pubkey|
63
+ raise ArgumentError, "No role declared for account #{pubkey}" unless roles.pubkey_account_map.key?(pubkey)
64
+
65
+ account_context.merge_account(pubkey, signer: roles.signer?(pubkey), writable: roles.writable?(pubkey))
66
+ end
67
+
68
+ account_context.add_readonly_nonsigner(program_id)
69
+ end
70
+
71
+ # Build the instruction with indices resolved against the given context
72
+ #
73
+ # @param account_context [Utils::AccountContext] The account context
74
+ # @return [Solace::Instruction]
75
+ def build_instruction(account_context)
76
+ Solace::Instruction.new.tap do |instruction|
77
+ instruction.program_index = account_context.index_of(program_id)
78
+ instruction.accounts = accounts.map { |pubkey| account_context.index_of(pubkey) }
79
+ instruction.data = data
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Solace
4
+ module Errors
5
+ # Raised when an address lookup table a transaction references does not
6
+ # exist on chain. A message that loads addresses through a table cannot be
7
+ # taken apart without the table's address list, so a missing table is an
8
+ # error rather than an empty list.
9
+ #
10
+ # @example
11
+ # begin
12
+ # Solace::TransactionDecomposer.new(connection: connection).decompose_transaction(transaction)
13
+ # rescue Solace::Errors::AddressLookupTableNotFound => e
14
+ # puts "missing table: #{e.account}"
15
+ # end
16
+ #
17
+ # @since 0.1.9
18
+ class AddressLookupTableNotFound < Error
19
+ # @return [String] The lookup table's on-chain address
20
+ attr_reader :account
21
+
22
+ # @param account [String] The lookup table's on-chain address
23
+ def initialize(account)
24
+ super("address lookup table #{account} not found on chain")
25
+ @account = account
26
+ end
27
+ end
28
+ end
29
+ end
@@ -47,7 +47,13 @@ module Solace
47
47
  end
48
48
 
49
49
  # @return [Hash] The error as a hash
50
- def to_h = { code: rpc_code, message: rpc_message, data: rpc_data }
50
+ def to_h
51
+ {
52
+ code: rpc_code,
53
+ message: rpc_message,
54
+ data: rpc_data
55
+ }
56
+ end
51
57
  end
52
58
  end
53
59
  end
data/lib/solace/errors.rb CHANGED
@@ -21,5 +21,6 @@ module Solace
21
21
  require 'solace/errors/http_error'
22
22
  require 'solace/errors/parse_error'
23
23
  require 'solace/errors/confirmation_timeout'
24
+ require 'solace/errors/address_lookup_table_not_found'
24
25
  end
25
26
  end
@@ -78,18 +78,28 @@ module Solace
78
78
  attr_reader :address_lookup_tables
79
79
 
80
80
  # @!attribute version
81
- # The transaction version (nil for legacy, 0 for v0)
81
+ # The transaction version (nil for legacy, 0 for v0); see {#set_version}
82
82
  attr_reader :version
83
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
+
84
92
  # Initialize the composer
85
93
  #
86
94
  # @param connection [Solace::Connection] The connection to the Solana cluster
87
95
  def initialize(connection:)
88
96
  @connection = connection
97
+ @blockhash = nil
89
98
  @instruction_composers = []
90
99
  @context = Utils::AccountContext.new
91
100
  @address_lookup_tables = []
92
101
  @version = nil
102
+ @compute_budget = Utils::ComputeBudget.new
93
103
  end
94
104
 
95
105
  # Add an instruction composer to the transaction
@@ -97,7 +107,7 @@ module Solace
97
107
  # @param composer [Composers::Base] The instruction composer
98
108
  # @return [TransactionComposer] Self for chaining
99
109
  def add_instruction(composer)
100
- merge_accounts(composer.account_context)
110
+ context.merge_from(composer.account_context)
101
111
  instruction_composers << composer
102
112
  self
103
113
  end
@@ -109,7 +119,7 @@ module Solace
109
119
  #
110
120
  # @since 0.1.0
111
121
  def prepend_instruction(composer)
112
- merge_accounts(composer.account_context)
122
+ context.merge_from(composer.account_context)
113
123
  instruction_composers.unshift(composer)
114
124
  self
115
125
  end
@@ -122,7 +132,7 @@ module Solace
122
132
  #
123
133
  # @since 0.1.0
124
134
  def insert_instruction(index, composer)
125
- merge_accounts(composer.account_context)
135
+ context.merge_from(composer.account_context)
126
136
  instruction_composers.insert(index, composer)
127
137
  self
128
138
  end
@@ -136,8 +146,7 @@ module Solace
136
146
  #
137
147
  # @since 0.1.0
138
148
  def merge(other, placement: :add, index: nil)
139
- merge_accounts(other.context)
140
- merge_address_lookup_tables(other.address_lookup_tables)
149
+ absorb(other)
141
150
 
142
151
  case placement
143
152
  when :add
@@ -165,6 +174,58 @@ module Solace
165
174
  self
166
175
  end
167
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
+
168
229
  # Make an address lookup table available to the transaction
169
230
  #
170
231
  # Registering a table opts the transaction into the v0 format: every
@@ -182,8 +243,8 @@ module Solace
182
243
  #
183
244
  # @since 0.1.8
184
245
  def add_address_lookup_table(account:, addresses: nil)
185
- account = account.to_s
186
- @version = 0 # Lookup tables require a v0 transaction
246
+ account = account.to_s
247
+ set_version(0) # Lookup tables require a v0 transaction
187
248
 
188
249
  unless address_lookup_tables.any? { |table| table.account == account }
189
250
  address_lookup_tables << Solace::Accounts::AddressLookupTable.new(account: account, addresses: addresses)
@@ -198,6 +259,9 @@ module Solace
198
259
  # once a lookup table has been added — loading eligible accounts through any
199
260
  # registered tables.
200
261
  #
262
+ # Composes against the {#blockhash} set with {#set_blockhash}, or the latest
263
+ # fetched from the connection when none is set.
264
+ #
201
265
  # @return [Transaction] The composed transaction (unsigned)
202
266
  def compose_transaction
203
267
  context.compile
@@ -263,30 +327,41 @@ module Solace
263
327
  header: context.header,
264
328
  accounts: context.accounts,
265
329
  instructions: build_instructions,
266
- recent_blockhash: connection.get_latest_blockhash[0],
330
+ recent_blockhash: blockhash || connection.get_latest_blockhash[0],
267
331
  address_lookup_tables: references
268
332
  )
269
333
  end
270
334
 
271
- # Build all instructions with resolved indices
335
+ # Build all instructions with resolved indices: the budget first, then the
336
+ # added composers minus any ComputeBudget composer the budget supersedes
272
337
  #
273
338
  # @return [Array<Solace::Instruction>] The built instructions
274
339
  def build_instructions
275
- instruction_composers.map { _1.build_instruction(context) }.flatten
340
+ kept = instruction_composers.reject { |composer| compute_budget.supersedes?(composer) }
341
+
342
+ (compute_budget.composers + kept).map { _1.build_instruction(context) }.flatten
276
343
  end
277
344
 
278
- # Merge all accounts from another AccountContext into this one
345
+ # Replace the compute budget, bringing its program into the account context
279
346
  #
280
- # @param account_context [AccountContext] The other context to merge from
281
- def merge_accounts(account_context)
282
- context.merge_from(account_context)
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
283
353
  end
284
354
 
285
- # Merge registered tables from another composer, deduped by account
355
+ # Take on another composer's accounts, tables (deduped by account) and
356
+ # budget (where it has one set)
286
357
  #
287
- # @param tables [Array<Solace::Accounts::AddressLookupTable>] The other composer's tables
288
- def merge_address_lookup_tables(tables)
289
- tables.each { |table| add_address_lookup_table(account: table.account, addresses: table.addresses) }
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))
290
365
  end
291
366
  end
292
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
@@ -101,6 +101,27 @@ module Solace
101
101
  merge_account(pubkey, signer: false, writable: false)
102
102
  end
103
103
 
104
+ # Add or merge an account into the context
105
+ #
106
+ # The primitive the +add_*+ methods above name a role for; call it
107
+ # directly when the flags are data in hand. Permissions only ever widen:
108
+ # an account added twice keeps the union of what it was given.
109
+ #
110
+ # @param pubkey [#to_s, PublicKey] The public key of the account
111
+ # @param signer [Boolean] Whether the account is a signer
112
+ # @param writable [Boolean] Whether the account is writable
113
+ # @param fee_payer [Boolean] Whether the account pays the fee (see {#set_fee_payer})
114
+ def merge_account(pubkey, signer:, writable:, fee_payer: false)
115
+ pubkey_str = pubkey.to_s
116
+
117
+ @pubkey_account_map[pubkey_str] ||= DEFAULT_ACCOUNT.dup
118
+ @pubkey_account_map[pubkey_str][:signer] ||= signer
119
+ @pubkey_account_map[pubkey_str][:writable] ||= writable
120
+ @pubkey_account_map[pubkey_str][:fee_payer] ||= fee_payer
121
+
122
+ self
123
+ end
124
+
104
125
  # Predicate to check if an account is a fee payer
105
126
  #
106
127
  # @param pubkey [String] The pubkey of the account
@@ -212,23 +233,6 @@ module Solace
212
233
 
213
234
  private
214
235
 
215
- # Add or merge an account into the context
216
- #
217
- # @param pubkey [#to_s, PublicKey] The public key of the account
218
- # @param signer [Boolean] Whether the account is a signer
219
- # @param writable [Boolean] Whether the account is writable
220
- # @param [Boolean] fee_payer
221
- def merge_account(pubkey, signer:, writable:, fee_payer: false)
222
- pubkey_str = pubkey.is_a?(String) ? pubkey : pubkey.address
223
-
224
- @pubkey_account_map[pubkey_str] ||= DEFAULT_ACCOUNT.dup
225
- @pubkey_account_map[pubkey_str][:signer] ||= signer
226
- @pubkey_account_map[pubkey_str][:writable] ||= writable
227
- @pubkey_account_map[pubkey_str][:fee_payer] ||= fee_payer
228
-
229
- self
230
- end
231
-
232
236
  # Order the static accounts by signer, writable, readonly signer, readonly
233
237
  #
234
238
  # Loaded accounts are excluded — they leave the static account list and are
@@ -237,15 +241,23 @@ module Solace
237
241
  # @param loaded_accounts [Array<String>] Pubkeys resolved through lookup tables
238
242
  # @return [Array<String>] The ordered static accounts
239
243
  def order_accounts(loaded_accounts)
240
- (@pubkey_account_map.keys - loaded_accounts).sort_by do |pubkey|
241
- if fee_payer?(pubkey) then 0
242
- elsif writable_signer?(pubkey) then 1
243
- elsif readonly_signer?(pubkey) then 2
244
- elsif writable_nonsigner?(pubkey) then 3
245
- elsif readonly_nonsigner?(pubkey) then 4
246
- else
247
- raise ArgumentError, "Unknown account type for pubkey: #{pubkey}"
248
- end
244
+ # Keyed on the position too: sort_by is not stable, and accounts of the
245
+ # same rank must keep the order they were added in
246
+ (@pubkey_account_map.keys - loaded_accounts).sort_by.with_index { |pubkey, index| [rank_of(pubkey), index] }
247
+ end
248
+
249
+ # The position of an account's segment in the static account list
250
+ #
251
+ # @param pubkey [String] The pubkey of the account
252
+ # @return [Integer] The segment rank
253
+ def rank_of(pubkey)
254
+ if fee_payer?(pubkey) then 0
255
+ elsif writable_signer?(pubkey) then 1
256
+ elsif readonly_signer?(pubkey) then 2
257
+ elsif writable_nonsigner?(pubkey) then 3
258
+ elsif readonly_nonsigner?(pubkey) then 4
259
+ else
260
+ raise ArgumentError, "Unknown account type for pubkey: #{pubkey}"
249
261
  end
250
262
  end
251
263
 
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../composers/base'
4
+ require_relative '../composers/compute_budget_program_set_compute_unit_limit_composer'
5
+ require_relative '../composers/compute_budget_program_set_compute_unit_price_composer'
6
+
7
+ module Solace
8
+ module Utils
9
+ # The compute budget a transaction composer carries as a setting
10
+ #
11
+ # Holds the compute unit limit and price, answers the ComputeBudget composers
12
+ # they translate to (limit then price), and decides which directly added
13
+ # ComputeBudget composers the setting supersedes so the budget is never
14
+ # declared twice. Instances are immutable; {#merge} answers a new one.
15
+ #
16
+ # @example
17
+ # budget = Solace::Utils::ComputeBudget.new(units: 200_000, micro_lamports: 50_000)
18
+ # budget.composers # => [SetComputeUnitLimit composer, SetComputeUnitPrice composer]
19
+ #
20
+ # @see Solace::TransactionComposer#set_compute_budget
21
+ # @since 0.1.9
22
+ class ComputeBudget
23
+ # The composer a compute unit limit translates to
24
+ LIMIT_COMPOSER = Composers::ComputeBudgetProgramSetComputeUnitLimitComposer
25
+ private_constant :LIMIT_COMPOSER
26
+
27
+ # The composer a compute unit price translates to
28
+ PRICE_COMPOSER = Composers::ComputeBudgetProgramSetComputeUnitPriceComposer
29
+ private_constant :PRICE_COMPOSER
30
+
31
+ # @!attribute units
32
+ # The compute unit limit, or nil when none is set
33
+ attr_reader :units
34
+
35
+ # @!attribute micro_lamports
36
+ # The compute unit price in micro-lamports, or nil when none is set
37
+ attr_reader :micro_lamports
38
+
39
+ # Initialize the compute budget
40
+ #
41
+ # @param units [Integer, nil] The compute unit limit
42
+ # @param micro_lamports [Integer, nil] The compute unit price in micro-lamports
43
+ def initialize(units: nil, micro_lamports: nil)
44
+ @units = units
45
+ @micro_lamports = micro_lamports
46
+ end
47
+
48
+ # Whether any part of the budget is set
49
+ #
50
+ # @return [Boolean]
51
+ def set?
52
+ !(units.nil? && micro_lamports.nil?)
53
+ end
54
+
55
+ # The ComputeBudget composers the budget translates to, limit then price
56
+ #
57
+ # @return [Array<Composers::Base>] The composers, empty when nothing is set
58
+ def composers
59
+ [
60
+ (LIMIT_COMPOSER.new(units: units) if units),
61
+ (PRICE_COMPOSER.new(micro_lamports: micro_lamports) if micro_lamports)
62
+ ].compact
63
+ end
64
+
65
+ # Whether a directly added composer is replaced by a setting of the same kind
66
+ #
67
+ # @param composer [Composers::Base] The added composer
68
+ # @return [Boolean]
69
+ def supersedes?(composer)
70
+ (!units.nil? && composer.is_a?(LIMIT_COMPOSER)) || (!micro_lamports.nil? && composer.is_a?(PRICE_COMPOSER))
71
+ end
72
+
73
+ # A budget with the other's settings where it has them, and this one's otherwise
74
+ #
75
+ # @param other [ComputeBudget] The budget to fold in
76
+ # @return [ComputeBudget] The merged budget
77
+ def merge(other)
78
+ self.class.new(
79
+ units: other.units || units,
80
+ micro_lamports: other.micro_lamports || micro_lamports
81
+ )
82
+ end
83
+ end
84
+ end
85
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Solace
4
4
  # Latest version of the Solace gem.
5
- VERSION = '0.1.8'
5
+ VERSION = '0.1.9'
6
6
  end
data/lib/solace.rb CHANGED
@@ -35,11 +35,14 @@ require_relative 'solace/version'
35
35
  require_relative 'solace/errors'
36
36
  require_relative 'solace/constants'
37
37
  require_relative 'solace/connection'
38
+ require_relative 'solace/concerns/binary_serializable'
39
+
40
+ # Utils
38
41
  require_relative 'solace/utils/codecs'
39
42
  require_relative 'solace/utils/pda'
40
43
  require_relative 'solace/utils/account_context'
44
+ require_relative 'solace/utils/compute_budget'
41
45
  require_relative 'solace/utils/curve25519_dalek'
42
- require_relative 'solace/concerns/binary_serializable'
43
46
 
44
47
  # Tokens
45
48
  require_relative 'solace/tokens'
@@ -60,6 +63,7 @@ require_relative 'solace/address_lookup_table'
60
63
  require_relative 'solace/accounts/address_lookup_table'
61
64
 
62
65
  require_relative 'solace/transaction_composer'
66
+ require_relative 'solace/transaction_decomposer'
63
67
 
64
68
  # Base Classes (Abstract classes)
65
69
  require_relative 'solace/programs/base'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solace
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.8
4
+ version: 0.1.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sebastian Scholl
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-19 00:00:00.000000000 Z
11
+ date: 2026-10-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base58
@@ -201,6 +201,7 @@ executables: []
201
201
  extensions: []
202
202
  extra_rdoc_files: []
203
203
  files:
204
+ - lib/rubocop/cop/solace/exploded_hash.rb
204
205
  - lib/solace.rb
205
206
  - lib/solace/accounts/address_lookup_table.rb
206
207
  - lib/solace/address_lookup_table.rb
@@ -211,6 +212,7 @@ files:
211
212
  - lib/solace/composers/base.rb
212
213
  - lib/solace/composers/compute_budget_program_set_compute_unit_limit_composer.rb
213
214
  - lib/solace/composers/compute_budget_program_set_compute_unit_price_composer.rb
215
+ - lib/solace/composers/opaque_instruction_composer.rb
214
216
  - lib/solace/composers/spl_token_program_close_account_composer.rb
215
217
  - lib/solace/composers/spl_token_program_initialize_mint_composer.rb
216
218
  - lib/solace/composers/spl_token_program_mint_to_composer.rb
@@ -227,6 +229,7 @@ files:
227
229
  - lib/solace/connection.rb
228
230
  - lib/solace/constants.rb
229
231
  - lib/solace/errors.rb
232
+ - lib/solace/errors/address_lookup_table_not_found.rb
230
233
  - lib/solace/errors/confirmation_timeout.rb
231
234
  - lib/solace/errors/connection_error.rb
232
235
  - lib/solace/errors/error.rb
@@ -276,8 +279,10 @@ files:
276
279
  - lib/solace/tokens/token.rb
277
280
  - lib/solace/transaction.rb
278
281
  - lib/solace/transaction_composer.rb
282
+ - lib/solace/transaction_decomposer.rb
279
283
  - lib/solace/utils/account_context.rb
280
284
  - lib/solace/utils/codecs.rb
285
+ - lib/solace/utils/compute_budget.rb
281
286
  - lib/solace/utils/curve25519_dalek.rb
282
287
  - lib/solace/utils/libcurve25519_dalek-linux-arm64/libcurve25519_dalek.so
283
288
  - lib/solace/utils/libcurve25519_dalek-linux/libcurve25519_dalek.so