solace-squads-smart-accounts 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 02a25a8f810ed514e72c62cf865dd3ea92a4edb614260fc5fbc110f913d79b6e
4
- data.tar.gz: 898e4feceedc71e37de62c3dcd02ef19cc79fbed1d6a982a6b3c294ddc58e11a
3
+ metadata.gz: 8e2d91139fbc5fb9289950a43d758100543f42bb0e3ad2dd537279e8c65020ba
4
+ data.tar.gz: df7ec92b6b9f92708397131d871cb03367bab0121e9bec4e7d228319367703e4
5
5
  SHA512:
6
- metadata.gz: 15b4b488be4b53df4c2d61729c58fdae258478d24b59bd5b35f361b6087b9bcb2fdc4b204f3d796bcd21b833e0f76d476df7bdacd2f084b7fd3643ce5f0e421f
7
- data.tar.gz: 746d685aba1a248a94f947910350396ce64b1dbf84ba9b86d50d29ab84a7ea597f65286003a0becf963630af77167e2fd5f37a1b530154503553cc5189708c21
6
+ metadata.gz: 46612e52fe84c894caee64a5fdcc037584c2fee6ba18621bd67cb211db1b78726053159b729338d69625dce186025d879bdd2d56b061538d4815441ff5ce5ed6
7
+ data.tar.gz: bbaaf0afb4bfa57582cd834ecfd6276366da02b5b73f41502d2a575504714fed1103211327d3ea1b402e617d41ee8a8ce2ba6d6ea5426e5ffb7596a1a51de9f2
@@ -189,6 +189,14 @@ module Solace
189
189
  Array.new(decode_le_u32(stream)) { decode_pubkey(stream) }
190
190
  end
191
191
 
192
+ # Decodes a Borsh bytes / Vec<u8> field: u32 LE length prefix + raw bytes.
193
+ #
194
+ # @param stream [IO, StringIO] The stream to read from.
195
+ # @return [String] The raw bytes as a binary string.
196
+ def decode_bytes(stream)
197
+ stream.read(decode_le_u32(stream))
198
+ end
199
+
192
200
  # Decodes a u8 from 1 byte.
193
201
  #
194
202
  # @param stream [IO, StringIO] The stream to read from.
@@ -7,8 +7,12 @@ module Solace
7
7
  # Required params:
8
8
  # :creator [String] Base58 pubkey of the account creating the smart account.
9
9
  # :treasury [String] Base58 pubkey of the treasury (from ProgramConfig).
10
- # :settings [String] Base58 pubkey of the settings PDA to be created
11
- # derive via Programs::SquadsSmartAccount.get_settings_address.
10
+ # :settings [#to_s, Array<#to_s>] The settings PDA(s) to offer as remaining accounts.
11
+ # Pass a single address for deterministic creation
12
+ # (derive via Programs::SquadsSmartAccount.get_settings_address),
13
+ # or an array (a "window" of candidates) for race-free creation —
14
+ # the program initializes whichever matches the incremented
15
+ # counter. See Programs::SquadsSmartAccount.next_smart_account_candidates.
12
16
  # :threshold [Integer] Number of approvals required to execute a transaction.
13
17
  # :signers [Array<SquadsSmartAccounts::SmartAccountSigner>] Signers on the smart account.
14
18
  # :time_lock [Integer] Seconds between proposal and execution (0 to disable).
@@ -32,11 +36,14 @@ module Solace
32
36
  params[:creator].to_s
33
37
  end
34
38
 
35
- # Extracts the settings address from the params
39
+ # Normalizes the :settings param to an array of base58 addresses.
36
40
  #
37
- # @return [String] The settings address
41
+ # Accepts a single pubkey (deterministic creation) or an array of candidate
42
+ # pubkeys (windowed, race-free creation), so both flows share this composer.
43
+ #
44
+ # @return [Array<String>] The settings address(es), in seed order.
38
45
  def settings
39
- params[:settings].to_s
46
+ Array(params[:settings]).map(&:to_s)
40
47
  end
41
48
 
42
49
  # Returns the program config address from the constants
@@ -111,7 +118,9 @@ module Solace
111
118
  # Writable accounts
112
119
  account_context.add_writable_nonsigner(config)
113
120
  account_context.add_writable_nonsigner(treasury)
114
- account_context.add_writable_nonsigner(settings)
121
+
122
+ # Each candidate settings PDA is offered as a writable remaining account.
123
+ settings.each { |address| account_context.add_writable_nonsigner(address) }
115
124
 
116
125
  # Writable signers
117
126
  account_context.add_writable_signer(creator)
@@ -134,7 +143,7 @@ module Solace
134
143
  creator_index: context.index_of(creator),
135
144
  system_program_index: context.index_of(system_program),
136
145
  program_index: context.index_of(program_id),
137
- settings_index: context.index_of(settings)
146
+ settings_index: settings.map { |address| context.index_of(address) }
138
147
  )
139
148
  end
140
149
  end
@@ -14,7 +14,9 @@ module Solace
14
14
  # 2. creator — writable, signer
15
15
  # 3. systemProgram — readonly, non-signer
16
16
  # 4. program — readonly, non-signer
17
- # 5. settings — writable, non-signer (remaining account PDA to be created)
17
+ # 5+. settings — writable, non-signer (one or more candidate settings
18
+ # PDAs as remaining accounts; the program initializes
19
+ # the one matching the freshly incremented counter)
18
20
  class CreateSmartAccountInstruction
19
21
  # 8-byte Anchor discriminator: SHA256("global:create_smart_account")[0..7]
20
22
  DISCRIMINATOR = [197, 102, 253, 231, 77, 84, 50, 17].freeze
@@ -33,7 +35,9 @@ module Solace
33
35
  # @param creator_index [Integer] Account index of creator.
34
36
  # @param system_program_index [Integer] Account index of systemProgram.
35
37
  # @param program_index [Integer] Account index of the Squads program.
36
- # @param settings_index [Integer] Account index of the settings PDA to be created.
38
+ # @param settings_index [Integer, Array<Integer>] Account index/indices of the
39
+ # candidate settings PDA(s), appended as remaining accounts. Pass a single
40
+ # index for deterministic creation, or an array (a window) for race-free creation.
37
41
  # @return [Solace::Instruction]
38
42
  def self.build(
39
43
  settings_authority:,
@@ -57,7 +61,7 @@ module Solace
57
61
  creator_index,
58
62
  system_program_index,
59
63
  program_index,
60
- settings_index
64
+ *Array(settings_index)
61
65
  ]
62
66
 
63
67
  ix.data = data(
@@ -17,6 +17,13 @@ module Solace
17
17
  #
18
18
  # @see Solace::SquadsSmartAccounts
19
19
  class SquadsSmartAccount < Base
20
+ # Default number of candidate settings PDAs offered by windowed creation.
21
+ # The on-chain counter must land inside this window for the transaction to
22
+ # succeed; ~20 absorbs heavy concurrency while staying well under the
23
+ # transaction account limit. Non-winning candidates are never initialized,
24
+ # so a larger window costs transaction size only, never rent.
25
+ DEFAULT_CREATION_WINDOW = 20
26
+
20
27
  class << self
21
28
  # Gets the address of the settings PDA for a given settings seed.
22
29
  #
@@ -229,6 +236,36 @@ module Solace
229
236
  )
230
237
  end
231
238
 
239
+ # Fetches a confirmed createSmartAccount transaction and deserializes the
240
+ # CreateSmartAccountEvent it emitted, revealing the settings address the
241
+ # program actually created.
242
+ #
243
+ # This is how a windowed creation (see {#compose_create_smart_account} with
244
+ # `window > 1`) learns which candidate won: the program picks one of the
245
+ # offered PDAs, observable only after the transaction lands. Match the
246
+ # returned `new_settings_pubkey` against your {#next_smart_account_candidates}
247
+ # to recover the seed and vault.
248
+ #
249
+ # @param signature [String] Signature of the confirmed createSmartAccount transaction.
250
+ # @return [SquadsSmartAccounts::CreateSmartAccountEvent] The deserialized event.
251
+ # @raise [RuntimeError] If the transaction is missing or carries no logEvent.
252
+ def get_created_smart_account_event(signature:)
253
+ transaction = connection.get_transaction(
254
+ signature,
255
+ commitment: 'confirmed',
256
+ encoding: 'json',
257
+ maxSupportedTransactionVersion: 0
258
+ )
259
+ raise "Transaction not found for signature #{signature}" unless transaction
260
+
261
+ io = log_event_stream(transaction)
262
+ raise "No logEvent inner instruction found in transaction #{signature}" unless io
263
+
264
+ args = Solace::SquadsSmartAccounts::LogEventArgsV2.deserialize(io)
265
+
266
+ Solace::SquadsSmartAccounts::CreateSmartAccountEvent.deserialize(StringIO.new(args.event))
267
+ end
268
+
232
269
  # Gets the full deterministic identity of the next smart account to be
233
270
  # created: the settings seed, settings address, and default vault address.
234
271
  #
@@ -252,6 +289,33 @@ module Solace
252
289
  )
253
290
  end
254
291
 
292
+ # Gets a window of candidate identities for race-free creation: the next
293
+ # `count` consecutive smart accounts (seeds `index+1 .. index+count`).
294
+ #
295
+ # Pass the candidates' settings addresses to {#create_smart_account_windowed},
296
+ # which offers them all and lets the program pick whichever matches the
297
+ # freshly incremented counter — so creation succeeds even if other accounts
298
+ # are created concurrently, as long as the true index lands in the window.
299
+ #
300
+ # @param count [Integer] Size of the candidate window (default: {DEFAULT_CREATION_WINDOW}).
301
+ # @return [Array<SquadsSmartAccounts::SmartAccountIdentity>] Candidate identities, in seed order.
302
+ def next_smart_account_candidates(count: DEFAULT_CREATION_WINDOW)
303
+ start_seed = get_program_config.smart_account_index + 1
304
+
305
+ Array.new(count) do |offset|
306
+ settings_seed = start_seed + offset
307
+
308
+ settings_address, = get_settings_address(settings_seed:)
309
+ smart_account_address, = get_smart_account_address(settings_address:)
310
+
311
+ Solace::SquadsSmartAccounts::SmartAccountIdentity.new(
312
+ settings_seed:,
313
+ settings_address:,
314
+ smart_account_address:
315
+ )
316
+ end
317
+ end
318
+
255
319
  # Creates a new smart account, signs it, and (optionally) sends it.
256
320
  #
257
321
  # @example Create a smart account, retaining the values to index
@@ -300,10 +364,18 @@ module Solace
300
364
  # clients can derive and persist the settings and vault addresses before
301
365
  # sending the transaction.
302
366
  #
303
- # @param settings_seed [Integer] The seed for the settings PDA (see {#next_settings_seed}).
367
+ # With the default `window: 1` this offers a single settings PDA and is
368
+ # subject to the same races as {#next_smart_account}. Pass `window > 1` to
369
+ # offer a window of consecutive candidate PDAs (seeds `settings_seed ..
370
+ # settings_seed + window - 1`); the program initializes whichever matches the
371
+ # freshly incremented counter, so creation tolerates concurrent creations.
372
+ # The chosen address is then resolved via {#get_created_smart_account_event}.
373
+ #
374
+ # @param settings_seed [Integer] The (starting) seed for the settings PDA (see {#next_settings_seed}).
304
375
  # @param creator [#to_s, Keypair] The account creating the smart account (must sign).
305
376
  # @param threshold [Integer] Number of approvals required to execute a transaction.
306
377
  # @param signers [Array<SquadsSmartAccounts::SmartAccountSigner>] Signers on the smart account.
378
+ # @param window [Integer] (Optional) Number of candidate PDAs to offer (default: 1).
307
379
  # @param time_lock [Integer] (Optional) Seconds between proposal and execution (default: 0).
308
380
  # @param settings_authority [#to_s] (Optional) Pubkey of the reconfiguration authority.
309
381
  # @param rent_collector [#to_s] (Optional) Pubkey for reclaiming rent on closed accounts.
@@ -314,6 +386,7 @@ module Solace
314
386
  creator:,
315
387
  threshold:,
316
388
  signers:,
389
+ window: 1,
317
390
  time_lock: 0,
318
391
  settings_authority: nil,
319
392
  rent_collector: nil,
@@ -321,12 +394,15 @@ module Solace
321
394
  )
322
395
  program_config = get_program_config
323
396
 
324
- settings_address, = get_settings_address(settings_seed:)
397
+ settings = Array.new(window) do |offset|
398
+ address, = get_settings_address(settings_seed: settings_seed + offset)
399
+ address
400
+ end
325
401
 
326
402
  create_smart_account_ix = Composers::SquadsSmartAccountsCreateSmartAccountComposer.new(
327
403
  creator:,
328
404
  treasury: program_config.treasury,
329
- settings: settings_address,
405
+ settings:,
330
406
  threshold:,
331
407
  signers:,
332
408
  time_lock:,
@@ -1813,6 +1889,30 @@ module Solace
1813
1889
 
1814
1890
  private
1815
1891
 
1892
+ # Locates the program's `logEvent` self-CPI among a landed transaction's
1893
+ # inner instructions and returns its args as a bytestream (the instruction
1894
+ # data past the 8-byte discriminator), or nil if absent. The instruction is
1895
+ # identified by its stable discriminator, so the match is exact.
1896
+ #
1897
+ # @param transaction [Hash] The raw getTransaction result.
1898
+ # @return [StringIO, nil] Stream positioned at the start of the LogEventArgsV2.
1899
+ def log_event_stream(transaction)
1900
+ groups = transaction.dig('meta', 'innerInstructions') || []
1901
+
1902
+ groups.each do |group|
1903
+ Array(group['instructions']).each do |ix|
1904
+ next if ix['data'].nil?
1905
+
1906
+ binary = Solace::Utils::Codecs.base58_to_binary(ix['data'])
1907
+ next unless binary.byteslice(0, 8).bytes == Solace::SquadsSmartAccounts::LogEventArgsV2::DISCRIMINATOR
1908
+
1909
+ return StringIO.new(binary.byteslice(8..))
1910
+ end
1911
+ end
1912
+
1913
+ nil
1914
+ end
1915
+
1816
1916
  # Derives the vault and destination ATAs for a token spending-limit spend.
1817
1917
  #
1818
1918
  # @param smart_account [#to_s] The vault PDA (owner of the source ATA).
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Solace
4
+ module SquadsSmartAccounts
5
+ # Immutable value object representing the `CreateSmartAccountEvent` the program
6
+ # emits when a smart account is created. It is the `CreateSmartAccountEvent`
7
+ # variant of the Borsh `SmartAccountEvent` enum carried inside
8
+ # {LogEventArgs#event}, so deserialization reads the 1-byte enum variant tag
9
+ # before the event fields.
10
+ #
11
+ # On windowed creation the program picks one candidate settings PDA from the
12
+ # offered window; this event is the only way to learn which one was chosen
13
+ # (see Solace::Programs::SquadsSmartAccount#created_smart_account_settings).
14
+ #
15
+ # The on-chain event also carries `new_settings_content: Settings`, but only
16
+ # `new_settings_pubkey` is decoded here — it is all the caller needs to derive
17
+ # the vault, and the trailing Settings can be fetched on demand.
18
+ #
19
+ # @example
20
+ # event = CreateSmartAccountEvent.deserialize(StringIO.new(log_event_args.event))
21
+ # event.new_settings_pubkey # => base58 settings address
22
+ CreateSmartAccountEvent = Data.define(
23
+ :new_settings_pubkey # String — base58 address of the newly created settings account
24
+ ) do
25
+ # Deserializes a CreateSmartAccountEvent from a stream of Borsh-encoded
26
+ # SmartAccountEvent bytes (positioned at the enum variant tag).
27
+ #
28
+ # @param io [IO, StringIO] Stream positioned at the start of the event.
29
+ # @return [CreateSmartAccountEvent] The deserialized, frozen event value.
30
+ def self.deserialize(io)
31
+ Solace::Utils::Codecs.decode_u8(io) # SmartAccountEvent enum variant tag
32
+
33
+ new(
34
+ new_settings_pubkey: Solace::Utils::Codecs.decode_pubkey(io)
35
+ )
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Solace
4
+ module SquadsSmartAccounts
5
+ # Immutable value object for the arguments of the program's `logEvent`
6
+ # instruction, the self-CPI through which the program records events. The
7
+ # deployed program takes `LogEventArgsV2 { event: Vec<u8> }` — a single
8
+ # Borsh-encoded `SmartAccountEvent`. (An older `LogEventArgs` form carrying
9
+ # `account_seeds`/`bump` is dead code in the program and described by the
10
+ # bundled IDL, but is never emitted.)
11
+ #
12
+ # Extracting the args bytestream from a landed transaction is the Program
13
+ # layer's responsibility — see
14
+ # Solace::Programs::SquadsSmartAccount#get_created_smart_account_event.
15
+ #
16
+ # @example
17
+ # args = LogEventArgsV2.deserialize(io)
18
+ # event = CreateSmartAccountEvent.deserialize(StringIO.new(args.event))
19
+ LogEventArgsV2 = Data.define(
20
+ :event # String — Borsh-encoded SmartAccountEvent bytes (binary string)
21
+ ) do
22
+ # Deserializes a LogEventArgsV2 from a stream of Borsh-encoded instruction
23
+ # data (positioned just past the 8-byte logEvent discriminator).
24
+ #
25
+ # @param io [IO, StringIO] Stream positioned at the start of the args.
26
+ # @return [LogEventArgsV2] The deserialized, frozen args value.
27
+ def self.deserialize(io)
28
+ new(event: Solace::Utils::Codecs.decode_bytes(io))
29
+ end
30
+ end
31
+
32
+ # 8-byte Anchor discriminator of the `logEvent` instruction (sha256("global:
33
+ # log_event")[0, 8]), a stable name-derived instruction discriminator — not
34
+ # per-event data. The Program layer uses it to locate the logEvent self-CPI
35
+ # among a transaction's inner instructions. (Defined here rather than inside
36
+ # the Data.define block, where constant assignment would leak to the enclosing
37
+ # module instead of the class.)
38
+ LogEventArgsV2::DISCRIMINATOR = [5, 9, 90, 141, 223, 134, 57, 217].freeze
39
+ end
40
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Solace
4
4
  module SquadsSmartAccounts
5
- VERSION = '0.1.0'
5
+ VERSION = '0.1.1'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solace-squads-smart-accounts
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
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-06-13 00:00:00.000000000 Z
11
+ date: 2026-06-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -165,6 +165,8 @@ files:
165
165
  - lib/solace/squads_smart_accounts/instructions/set_time_lock_as_authority_instruction.rb
166
166
  - lib/solace/squads_smart_accounts/instructions/use_spending_limit_instruction.rb
167
167
  - lib/solace/squads_smart_accounts/programs/squads_smart_account.rb
168
+ - lib/solace/squads_smart_accounts/types/create_smart_account_event.rb
169
+ - lib/solace/squads_smart_accounts/types/log_event_args_v2.rb
168
170
  - lib/solace/squads_smart_accounts/types/period.rb
169
171
  - lib/solace/squads_smart_accounts/types/permissions.rb
170
172
  - lib/solace/squads_smart_accounts/types/program_config.rb