solana-ruby-kit 7.0.0 → 7.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.
data/README.md ADDED
@@ -0,0 +1,761 @@
1
+ # solana-ruby-kit
2
+
3
+ A Ruby port of [@anza-xyz/kit](https://github.com/anza-xyz/kit) — the official Solana TypeScript SDK — translated into idiomatic Ruby with [Sorbet](https://sorbet.org) static types.
4
+
5
+ Every module maps 1-to-1 to a TypeScript package. All methods are synchronous (Ruby's RbNaCl is synchronous; TypeScript's Web Crypto API is not). This port now tracks the anza-xyz/kit version.
6
+
7
+ ## Requirements
8
+
9
+ - Ruby >= 3.2
10
+ - libsodium (required by `rbnacl`)
11
+
12
+ ```bash
13
+ # macOS
14
+ brew install libsodium
15
+
16
+ # Debian / Ubuntu
17
+ apt-get install libsodium-dev
18
+ ```
19
+
20
+ ## Installation
21
+
22
+ ```ruby
23
+ # Gemfile
24
+ gem 'solana-ruby-kit'
25
+ ```
26
+
27
+ ```bash
28
+ bundle install
29
+ ```
30
+
31
+ ## Examples
32
+
33
+ Full worked examples live on the [wiki](https://github.com/pzupan/solana-ruby-kit/wiki):
34
+
35
+ - [Quick Start](https://github.com/pzupan/solana-ruby-kit/wiki/Quick-Start)
36
+ - [Create a Wallet](https://github.com/pzupan/solana-ruby-kit/wiki/Create-a-Wallet)
37
+ - [Transfer SOL](https://github.com/pzupan/solana-ruby-kit/wiki/Transfer-SOL)
38
+ - [Create an Associated Token Account](https://github.com/pzupan/solana-ruby-kit/wiki/Create-an-Associated-Token-Account)
39
+ - [Stake SOL with a Validator](https://github.com/pzupan/solana-ruby-kit/wiki/Stake-SOL-with-a-Validator)
40
+ - [Verify a Wallet-Signed Transaction in Rails](https://github.com/pzupan/solana-ruby-kit/wiki/Verify-a-Wallet-Signed-Transaction-in-Rails)
41
+ - [Build a Transaction for Browser Signing](https://github.com/pzupan/solana-ruby-kit/wiki/Build-a-Transaction-for-Browser-Signing)
42
+
43
+ The sections below cover configuration and the per-module API reference.
44
+
45
+ ## Rails
46
+
47
+ The gem includes a Railtie that auto-configures when Rails is present. Add it to your `Gemfile` as usual, then run the install generator:
48
+
49
+ ```bash
50
+ rails generate solana:ruby:kit:install
51
+ ```
52
+
53
+ This creates `config/initializers/ruby_kit.rb`:
54
+
55
+ ```ruby
56
+ Solana::Ruby::Kit.configure do |config|
57
+ config.rpc_url = 'https://api.mainnet-beta.solana.com'
58
+ config.ws_url = 'wss://api.mainnet-beta.solana.com'
59
+ config.commitment = :confirmed
60
+ config.timeout = 30
61
+ end
62
+ ```
63
+
64
+ Or configure via `config/application.rb`:
65
+
66
+ ```ruby
67
+ config.ruby_kit.rpc_url = ENV['SOLANA_RPC_URL']
68
+ config.ruby_kit.commitment = :finalized
69
+ ```
70
+
71
+ Get a pre-configured client anywhere in your app:
72
+
73
+ ```ruby
74
+ rpc = Solana::Ruby::Kit.rpc_client
75
+ ```
76
+
77
+ ## Configuration
78
+
79
+ | Option | Default | Description |
80
+ |--------|---------|-------------|
81
+ | `rpc_url` | `https://api.mainnet-beta.solana.com` | JSON-RPC endpoint |
82
+ | `ws_url` | `nil` | WebSocket endpoint for subscriptions |
83
+ | `commitment` | `:confirmed` | Default commitment level |
84
+ | `timeout` | `30` | HTTP read timeout in seconds |
85
+
86
+ ## Modules
87
+
88
+ ### `Solana::Ruby::Kit::Addresses` — `@solana/addresses`
89
+
90
+ Validate and work with base58-encoded Solana addresses.
91
+
92
+ ```ruby
93
+ Addr = Solana::Ruby::Kit::Addresses
94
+
95
+ # Validate
96
+ Addr.address?('11111111111111111111111111111111') # => true
97
+
98
+ # Wrap into a typed Address value object (raises on invalid input)
99
+ addr = Addr.address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA')
100
+
101
+ # Encode / decode raw bytes
102
+ bytes = Addr.decode_address(addr) # => 32-byte binary String
103
+ str = Addr.encode_address(bytes) # => base58 String
104
+
105
+ # Program Derived Addresses (PDAs)
106
+ program = Addr.address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA')
107
+ pda = Addr.get_program_derived_address(
108
+ program_address: program,
109
+ seeds: ['my-seed', [1, 2, 3]]
110
+ )
111
+ puts pda.address # => Address
112
+ puts pda.bump # => Integer (0-255)
113
+ ```
114
+
115
+ ### `Solana::Ruby::Kit::Keys` — `@solana/keys`
116
+
117
+ Ed25519 key generation, signing, and verification via libsodium.
118
+
119
+ ```ruby
120
+ Keys = Solana::Ruby::Kit::Keys
121
+
122
+ # Generate
123
+ kp = Keys.generate_key_pair
124
+ kp.signing_key # => RbNaCl::SigningKey
125
+ kp.verify_key # => RbNaCl::VerifyKey
126
+
127
+ # From 64 raw bytes (seed || public key)
128
+ kp = Keys.create_key_pair_from_bytes(File.binread('wallet.bin'))
129
+
130
+ # From 32-byte private seed only
131
+ kp = Keys.create_key_pair_from_private_key_bytes(seed_bytes)
132
+
133
+ # Sign / verify
134
+ sig = Keys.sign_bytes(kp.signing_key, data)
135
+ ok = Keys.verify_signature(kp.verify_key, sig, data)
136
+
137
+ # Base58-encode a signature
138
+ Keys.encode_signature(sig) # => Signature string
139
+ Keys.decode_signature(str) # => SignatureBytes
140
+ ```
141
+
142
+ ### `Solana::Ruby::Kit::Signers` — `@solana/signers`
143
+
144
+ High-level signer abstraction that wraps a key pair and exposes an `address`.
145
+
146
+ ```ruby
147
+ Signers = Solana::Ruby::Kit::Signers
148
+
149
+ # Random new signer
150
+ signer = Signers.generate_key_pair_signer
151
+ signer.address # => Addresses::Address
152
+ signer.to_s # => base58 string
153
+
154
+ # From existing key pair
155
+ signer = Signers.create_signer_from_key_pair(kp)
156
+
157
+ # From raw bytes
158
+ signer = Signers.create_key_pair_signer_from_bytes(bytes_64)
159
+ signer = Signers.create_key_pair_signer_from_private_key_bytes(seed_32)
160
+
161
+ # Sign arbitrary data
162
+ sig = signer.sign(message_bytes)
163
+
164
+ # Sign a batch of messages for multiple signers
165
+ map = Signers.sign_message_bytes_with_signers([signer1, signer2], bytes)
166
+ # => { "addr1" => SignatureBytes, "addr2" => SignatureBytes }
167
+ ```
168
+
169
+ ### `Solana::Ruby::Kit::TransactionMessages` — `@solana/transaction-messages`
170
+
171
+ Immutable transaction message builder. Every method returns a new struct; originals are unmodified.
172
+
173
+ ```ruby
174
+ TxMsg = Solana::Ruby::Kit::TransactionMessages
175
+
176
+ msg = TxMsg.create_transaction_message(version: 0)
177
+
178
+ # Set fee payer
179
+ msg = TxMsg.set_fee_payer(signer.address, msg)
180
+
181
+ # Set blockhash lifetime
182
+ constraint = TxMsg::BlockhashLifetimeConstraint.new(
183
+ blockhash: '4vJ9...', last_valid_block_height: 123_456
184
+ )
185
+ msg = TxMsg.set_blockhash_lifetime(constraint, msg)
186
+
187
+ # Append / prepend instructions
188
+ msg = TxMsg.append_instructions(msg, [instruction])
189
+ msg = TxMsg.prepend_instructions(msg, [priority_fee_ix])
190
+
191
+ # Compute unit limit (SetComputeUnitLimit instruction from the Compute Budget program)
192
+ msg = TxMsg.set_transaction_message_compute_unit_limit(200_000, msg)
193
+ TxMsg.get_transaction_message_compute_unit_limit(msg) # => 200_000
194
+
195
+ # Loaded accounts data size limit
196
+ msg = TxMsg.set_transaction_message_loaded_accounts_data_size_limit(64_000, msg)
197
+ TxMsg.get_transaction_message_loaded_accounts_data_size_limit(msg) # => 64_000
198
+
199
+ # Durable nonce lifetime
200
+ nonce_constraint = TxMsg::DurableNonceLifetimeConstraint.new(
201
+ nonce: 'abc...',
202
+ nonce_account_address: nonce_addr
203
+ )
204
+ msg = TxMsg.set_durable_nonce_lifetime(nonce_constraint, msg)
205
+ ```
206
+
207
+ ### `Solana::Ruby::Kit::Transactions` — `@solana/transactions`
208
+
209
+ Compile, sign, and inspect transactions.
210
+
211
+ ```ruby
212
+ Txns = Solana::Ruby::Kit::Transactions
213
+
214
+ # Compile a TransactionMessage into wire bytes + an empty signatures map.
215
+ # message_bytes are the bytes that each required signer must sign.
216
+ # A lifetime constraint (blockhash or durable nonce) is optional at compile
217
+ # time — omitting it writes 32 zero bytes into the blockhash field, which
218
+ # must be replaced before the transaction is valid for submission.
219
+ transaction = Txns.compile_transaction_message(message)
220
+
221
+ # Partially sign (one or more keys, not necessarily all signers)
222
+ tx = Txns.partially_sign_transaction([kp.signing_key], transaction)
223
+
224
+ # Fully sign (raises unless all signers have signed)
225
+ signed_tx = Txns.sign_transaction([kp.signing_key], transaction)
226
+
227
+ # Encode the fully signed transaction for submission via sendTransaction.
228
+ # Prepends compact-u16 signature count + 64-byte signatures to message bytes.
229
+ wire_bytes = Txns.wire_encode_transaction(signed_tx)
230
+ wire_base64 = Base64.strict_encode64(wire_bytes)
231
+
232
+ # Get the transaction signature (fee payer's signature, base58)
233
+ sig = Txns.get_signature_from_transaction(signed_tx)
234
+
235
+ # Check completeness and size
236
+ Txns.fully_signed_transaction?(tx) # => true / false
237
+ Txns.assert_fully_signed_transaction!(tx)
238
+
239
+ Txns.within_size_limit?(tx) # => true if wire size <= 1232 bytes
240
+ Txns.assert_within_size_limit!(tx)
241
+
242
+ # Sendable = fully signed AND within size limit
243
+ Txns.sendable_transaction?(signed_tx) # => true / false
244
+ Txns.assert_sendable_transaction!(signed_tx)
245
+ ```
246
+
247
+ ### `Solana::Ruby::Kit::Rpc` — `@solana/rpc`
248
+
249
+ Synchronous JSON-RPC client backed by `Net::HTTP`.
250
+
251
+ ```ruby
252
+ rpc = Solana::Ruby::Kit::Rpc::Client.new(
253
+ Solana::Ruby::Kit::RpcTypes.devnet,
254
+ timeout: 10,
255
+ open_timeout: 5
256
+ )
257
+
258
+ rpc.get_slot # => Integer
259
+ rpc.get_block_height # => Integer
260
+ rpc.get_balance(address) # resp.value => lamports
261
+ rpc.get_latest_blockhash # resp.value.blockhash, .last_valid_block_height
262
+ rpc.get_account_info(address, encoding: 'base64')
263
+ rpc.get_multiple_accounts([addr1, addr2])
264
+ rpc.get_program_accounts(program_address)
265
+ rpc.get_signature_statuses([sig_str])
266
+ rpc.is_blockhash_valid(blockhash, commitment: :confirmed)
267
+ rpc.get_minimum_balance_for_rent_exemption(data_length)
268
+ rpc.get_transaction(signature, encoding: 'base64')
269
+ rpc.get_token_account_balance(token_account)
270
+ rpc.get_token_accounts_by_owner(owner, mint: mint_address)
271
+ rpc.get_epoch_info
272
+ rpc.get_epoch_schedule
273
+ rpc.get_block_time(slot)
274
+ rpc.get_inflation_reward([address])
275
+ rpc.get_signatures_for_address(address)
276
+ rpc.get_vote_accounts
277
+ rpc.simulate_transaction(encoded_tx)
278
+ rpc.send_transaction(encoded_tx)
279
+ rpc.request_airdrop(address, lamports) # devnet / testnet only
280
+ ```
281
+
282
+ Errors:
283
+
284
+ ```ruby
285
+ rescue Solana::Ruby::Kit::Rpc::RpcError => e
286
+ puts e.code # JSON-RPC error code
287
+ puts e.message # JSON-RPC error message
288
+ rescue Solana::Ruby::Kit::Rpc::HttpTransportError => e
289
+ puts e.status_code # HTTP status code
290
+ ```
291
+
292
+ ### `Solana::Ruby::Kit::RpcTypes` — `@solana/rpc-types`
293
+
294
+ Cluster URL helpers and typed wrappers.
295
+
296
+ ```ruby
297
+ RpcTypes = Solana::Ruby::Kit::RpcTypes
298
+
299
+ RpcTypes.mainnet # default mainnet URL
300
+ RpcTypes.mainnet('https://my-rpc.com') # custom mainnet URL
301
+ RpcTypes.devnet # devnet
302
+ RpcTypes.testnet # testnet
303
+ RpcTypes.cluster_url('http://localhost:8899') # custom / localnet
304
+ ```
305
+
306
+ ### `Solana::Ruby::Kit::Options` — `@solana/options`
307
+
308
+ Rust-style `Option<T>` for Solana's on-chain option codec pattern.
309
+
310
+ ```ruby
311
+ Opts = Solana::Ruby::Kit::Options
312
+
313
+ some = Opts.some(42) # => Some(42)
314
+ none = Opts.none # => None
315
+
316
+ Opts.some?(some) # => true
317
+ Opts.none?(none) # => true
318
+ Opts.option?(some) # => true
319
+
320
+ Opts.unwrap_option(some) # => 42
321
+ Opts.unwrap_option(none) # => nil
322
+ Opts.unwrap_option(none, -> { 0 }) # => 0
323
+
324
+ Opts.wrap_nullable(nil) # => None
325
+ Opts.wrap_nullable(42) # => Some(42)
326
+
327
+ # Recursively unwrap nested structures
328
+ Opts.unwrap_option_recursively({ a: some, b: [none, some] })
329
+ # => { a: 42, b: [nil, 42] }
330
+ ```
331
+
332
+ ### `Solana::Ruby::Kit::Functional` — `@solana/functional`
333
+
334
+ Functional pipeline composition.
335
+
336
+ ```ruby
337
+ Kit = Solana::Ruby::Kit
338
+
339
+ result = Kit::Functional.pipe(
340
+ Kit::TransactionMessages.create_transaction_message(version: 0),
341
+ ->(tx) { Kit::TransactionMessages.set_fee_payer(fee_payer, tx) },
342
+ ->(tx) { Kit::TransactionMessages.set_blockhash_lifetime(constraint, tx) },
343
+ ->(tx) { Kit::TransactionMessages.append_instructions(tx, [ix]) }
344
+ )
345
+ ```
346
+
347
+ ### `Solana::Ruby::Kit::Codecs` — `@solana/codecs`
348
+
349
+ Binary encoder/decoder framework for Solana on-chain data.
350
+
351
+ ```ruby
352
+ Codecs = Solana::Ruby::Kit::Codecs
353
+
354
+ # Numbers
355
+ u8 = Codecs.u8
356
+ u16 = Codecs.u16_le # little-endian (default for Solana)
357
+ u32 = Codecs.u32_le
358
+ u64 = Codecs.u64_le
359
+ i8 = Codecs.i8
360
+ f32 = Codecs.f32_le
361
+
362
+ u16.encode(1000) # => "\xe8\x03"
363
+ u16.decode("\xe8\x03") # => 1000
364
+
365
+ # Strings
366
+ utf8 = Codecs.utf8
367
+ bytes = Codecs.bytes_codec
368
+
369
+ # Data structures
370
+ struct_codec = Codecs.struct_codec([
371
+ ['amount', u64],
372
+ ['mint', bytes]
373
+ ])
374
+ ```
375
+
376
+ ### `Solana::Ruby::Kit::RpcSubscriptions` — `@solana/rpc-subscriptions`
377
+
378
+ WebSocket-based subscription client.
379
+
380
+ ```ruby
381
+ ws = Solana::Ruby::Kit::RpcSubscriptions::Client.new(
382
+ 'wss://api.devnet.solana.com'
383
+ )
384
+
385
+ sub = ws.account_subscribe(address, commitment: :confirmed)
386
+ sub.on_message { |notification| puts notification }
387
+ sub.on_error { |err| puts err }
388
+
389
+ ws.account_unsubscribe(sub.id)
390
+ ws.close
391
+ ```
392
+
393
+ ### `Solana::Ruby::Kit::Sysvars` — `@solana/sysvars`
394
+
395
+ Well-known sysvar addresses and decoded account data.
396
+
397
+ ```ruby
398
+ Sysvars = Solana::Ruby::Kit::Sysvars
399
+
400
+ Sysvars::Addresses::CLOCK_ADDRESS # => Address
401
+ Sysvars::Addresses::RENT_ADDRESS
402
+ Sysvars::Addresses::EPOCH_SCHEDULE_ADDRESS
403
+
404
+ # Fetch and decode via an RPC client
405
+ clock = Sysvars.fetch_sysvar_clock(rpc)
406
+ clock.slot # => Integer
407
+ clock.epoch # => Integer
408
+ clock.unix_timestamp # => Integer
409
+
410
+ rent = Sysvars.fetch_sysvar_rent(rpc)
411
+ rent.lamports_per_byte_year # => Integer
412
+ rent.exemption_threshold # => Float
413
+ ```
414
+
415
+ ### `Solana::Ruby::Kit::Programs` — `@solana/programs`
416
+
417
+ Program error helpers and well-known program interfaces.
418
+
419
+ ```ruby
420
+ Programs = Solana::Ruby::Kit::Programs
421
+
422
+ # Inspect custom program errors in transaction simulation results
423
+ Programs.program_error?(err) # => true / false
424
+ Programs.program_error?(err, expected_code: 1) # match a specific code
425
+ Programs.get_program_error_code(err) # => Integer or nil
426
+ ```
427
+
428
+ #### `Programs::StakeProgram`
429
+
430
+ Create and delegate stake accounts.
431
+
432
+ ```ruby
433
+ Stake = Solana::Ruby::Kit::Programs::StakeProgram
434
+
435
+ # Well-known addresses
436
+ Stake::PROGRAM_ID # Stake11111111111111111111111111111111111111
437
+ Stake::STAKE_CONFIG_ID # StakeConfig11111111111111111111111111111111
438
+ Stake::STAKE_ACCOUNT_SPACE # => 200 (bytes required for a stake account)
439
+
440
+ # Build two instructions that allocate and initialise a new stake account.
441
+ # The caller appends both to a transaction message.
442
+ create_ixs = Stake.create_account_instructions(
443
+ from: fee_payer_address, # funding wallet (writable, signer)
444
+ stake_account: stake_keypair.address, # new stake account address (writable, signer)
445
+ authorized: owner_address, # becomes both staker and withdrawer
446
+ lamports: 2_282_880 # enough for rent + some stake
447
+ )
448
+
449
+ # Build one instruction that delegates an initialised stake account.
450
+ delegate_ix = Stake.delegate_instruction(
451
+ stake_account: stake_keypair.address,
452
+ vote_account: validator_vote_address,
453
+ authorized: owner_address # must sign the transaction
454
+ )
455
+ ```
456
+
457
+ #### `Programs::AssociatedTokenAccount`
458
+
459
+ Create SPL token accounts at their canonical (Associated Token Account) address.
460
+
461
+ ```ruby
462
+ ATA = Solana::Ruby::Kit::Programs::AssociatedTokenAccount
463
+
464
+ # Well-known program IDs
465
+ ATA::PROGRAM_ID # ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL
466
+ ATA::TOKEN_PROGRAM_ID # TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA
467
+ ATA::TOKEN_2022_PROGRAM_ID
468
+ ATA::SYSTEM_PROGRAM_ID
469
+
470
+ # Derive the ATA address (no RPC call required)
471
+ pda = ATA.get_associated_token_address(
472
+ wallet: owner_address,
473
+ mint: mint_address,
474
+ token_program_id: ATA::TOKEN_PROGRAM_ID # default; omit for SPL Token
475
+ )
476
+ pda.address # => Addresses::Address (the ATA)
477
+ pda.bump # => Integer
478
+
479
+ # Build the createAssociatedTokenAccount instruction
480
+ ix = ATA.create_instruction(
481
+ payer: fee_payer_address, # pays rent
482
+ wallet: owner_address, # will own the ATA
483
+ mint: mint_address,
484
+ token_program_id: ATA::TOKEN_PROGRAM_ID,
485
+ idempotent: true # use CreateIdempotent — safe to call if ATA exists
486
+ )
487
+ ```
488
+
489
+ ### `Solana::Ruby::Kit::OffchainMessages` — `@solana/signers`, `@solana/offchain-messages`
490
+
491
+ Sign and verify off-chain messages (Phantom wallet standard).
492
+
493
+ ```ruby
494
+ OffChain = Solana::Ruby::Kit::OffchainMessages
495
+
496
+ msg = OffChain::Message.new(
497
+ version: 1, # 0 = legacy printable ASCII, 1 = extended UTF-8
498
+ domain: 'example.com',
499
+ message: 'Hello, Solana!'
500
+ )
501
+
502
+ encoded = OffChain.encode_offchain_message(msg)
503
+ decoded = OffChain.decode_offchain_message(encoded)
504
+
505
+ signature = OffChain.sign_offchain_message(signer, msg)
506
+ OffChain.verify_offchain_message_signature(verify_key_bytes, signature, msg) # => true
507
+ ```
508
+
509
+ #### Checking that a signer signed what you asked
510
+
511
+ A wallet returns the message bytes it signed alongside its signature. Verifying that
512
+ signature only proves the signer produced it over *those* bytes — not that those bytes
513
+ are the message you asked for. Compare the two before you verify the signature, so a
514
+ signer that signed the wrong thing is reported as a content mismatch rather than as a
515
+ confusing cryptographic failure.
516
+
517
+ ```ruby
518
+ Addresses = Solana::Ruby::Kit::Addresses
519
+
520
+ expected = OffChain::MessageV1.new(
521
+ content: 'Transfer 1 SOL to Alice',
522
+ required_signatories: [OffChain::Signatory.new(address: Addresses::Address.new(my_address))]
523
+ )
524
+
525
+ # Raises SolanaError if the content or the required signatories differ.
526
+ # Required signatories are compared ignoring order; content is compared exactly.
527
+ OffChain.assert_offchain_message_v1_equal(received, expected)
528
+ ```
529
+
530
+ The error context reports content lengths in UTF-8 bytes rather than the content itself,
531
+ so message text never reaches your logs or error reporting.
532
+
533
+ > **Note:** `MessageV1` mirrors the newer `@solana/offchain-messages` v1 shape (UTF-8
534
+ > `content` plus `required_signatories`) and is a distinct type from `Message` above,
535
+ > which models the older `@solana/signers` domain form. The v1 wire codec is not yet
536
+ > translated, so `received` must come from your own decoding for now.
537
+
538
+ ### `Solana::Ruby::Kit::ResourceLimitEstimation` — `@solana/kit`
539
+
540
+ Estimate and set compute unit limits and loaded accounts data size limits by simulating
541
+ the transaction before sending.
542
+
543
+ ```ruby
544
+ RLE = Solana::Ruby::Kit::ResourceLimitEstimation
545
+
546
+ # 1. Fill provisory (0) limits as placeholders during message construction.
547
+ # This reserves space in the transaction for the limit instructions so the
548
+ # size estimate used by compile_transaction_message is accurate.
549
+ msg = RLE.fill_transaction_message_provisory_resource_limits(msg)
550
+
551
+ # 2. After construction, estimate actual resource usage via simulation and
552
+ # stamp the real values onto the message.
553
+ estimator = RLE.estimate_resource_limits_factory(rpc: rpc)
554
+ # estimate returns { compute_unit_limit: Integer, loaded_accounts_data_size_limit?: Integer }
555
+ estimate = estimator.call(msg)
556
+
557
+ # 3. Or combine steps 2 + set in one call.
558
+ setter = RLE.estimate_and_set_resource_limits_factory(estimator)
559
+ msg = setter.call(msg)
560
+ ```
561
+
562
+ ### `Solana::Ruby::Kit::TransactionConfirmation` — `@solana/transaction-confirmation`
563
+
564
+ Poll for transaction confirmation with timeout.
565
+
566
+ ```ruby
567
+ Confirm = Solana::Ruby::Kit::TransactionConfirmation
568
+
569
+ Confirm.wait_for_confirmation(
570
+ rpc,
571
+ sig,
572
+ commitment: :confirmed,
573
+ timeout_secs: 60
574
+ )
575
+ ```
576
+
577
+ ### `Solana::Ruby::Kit::InstructionPlans` — `@solana/instruction-plans`
578
+
579
+ Plan and execute instruction sequences that may span multiple transactions.
580
+ The planner packs instructions into messages respecting the 1232-byte transaction
581
+ size limit; the executor walks the resulting plan tree and sends each transaction.
582
+
583
+ ```ruby
584
+ require 'base64'
585
+ Plans = Solana::Ruby::Kit::InstructionPlans
586
+ Kit = Solana::Ruby::Kit
587
+
588
+ # ── 1. Build an instruction plan ─────────────────────────────────────────────
589
+ # Instructions are auto-wrapped in SingleInstructionPlan.
590
+ # Use parallel_instruction_plan for instructions that can run concurrently.
591
+ plan = Plans.sequential_instruction_plan([ix1, ix2, ix3])
592
+
593
+ # ── 2. Create a planner ───────────────────────────────────────────────────────
594
+ # create_transaction_message is called whenever a new (empty) transaction is
595
+ # needed. It must return a message with a fee payer and blockhash already set.
596
+ #
597
+ # max_instructions_per_transaction caps how many top-level instructions the
598
+ # planner packs into a single message. Must be a positive integer no greater
599
+ # than 64 (the transaction format's hard limit); defaults to 16, leaving
600
+ # headroom for inner (CPI) instructions the planner can't see ahead of time.
601
+ planner = Plans.create_transaction_planner(
602
+ create_transaction_message: -> {
603
+ Kit::Functional.pipe(
604
+ Kit::TransactionMessages.create_transaction_message(version: :legacy),
605
+ ->(tx) { Kit::TransactionMessages.set_fee_payer(signer.address, tx) },
606
+ ->(tx) { Kit::TransactionMessages.set_blockhash_lifetime(constraint, tx) }
607
+ )
608
+ },
609
+ max_instructions_per_transaction: 12 # optional; omit for the default of 16
610
+ )
611
+
612
+ # plan! distributes instructions across as few transactions as possible.
613
+ # The cap can also be overridden per call:
614
+ transaction_plan = planner.call(plan, max_instructions_per_transaction: 8)
615
+
616
+ # ── 3. Create an executor and run it ─────────────────────────────────────────
617
+ # execute_transaction_message receives a fresh, mutable context Hash and each
618
+ # fully-packed TransactionMessage, and returns the context a successful result
619
+ # should carry. The two serve different outcomes: what you *store* on the context
620
+ # reaches a failed or canceled result, what you *return* reaches a successful one
621
+ # (merged over what was stored, the returned value winning).
622
+ # If it raises, the executor cancels all remaining messages and re-raises — and the
623
+ # context as it stood at that moment is preserved on the failed result.
624
+ executor = Plans.create_transaction_plan_executor(
625
+ execute_transaction_message: ->(context, message) {
626
+ transaction = Kit::Transactions.compile_transaction_message(message)
627
+ signed = Kit::Transactions.sign_transaction([signer.key_pair.signing_key], transaction)
628
+ context[:transaction] = signed # recorded now, so it survives a failure below
629
+ wire = Base64.strict_encode64(Kit::Transactions.wire_encode_transaction(signed))
630
+ rpc.send_transaction(wire)
631
+ { transaction: signed }
632
+ }
633
+ )
634
+
635
+ result = executor.call(transaction_plan)
636
+ # result is a TransactionPlanResult tree mirroring the transaction_plan structure.
637
+ # Each leaf is a SingleTransactionPlanResult with status :successful, :failed, or :canceled.
638
+ # Every status carries a #context; a :successful one also exposes #transaction.
639
+
640
+ # A one-argument lambda returning { transaction:, context: } — the shape this method
641
+ # required before v7.1.0 — is still accepted and adapted onto the flow above.
642
+
643
+ # ── 4. Plan types ─────────────────────────────────────────────────────────────
644
+ Plans.single_instruction_plan(ix) # wrap one instruction
645
+ Plans.sequential_instruction_plan([ix1, ix2]) # ordered, divisible
646
+ Plans.non_divisible_sequential_instruction_plan([ix1, ix2]) # must be atomic
647
+ Plans.parallel_instruction_plan([ix1, ix2]) # any order / same tx
648
+
649
+ # MessagePacker plans for instructions of variable size (e.g. large data writes)
650
+ Plans.get_linear_message_packer_instruction_plan(
651
+ total_length: data.bytesize,
652
+ get_instruction: ->(offset, length) { build_write_ix(offset, data[offset, length]) }
653
+ )
654
+ ```
655
+
656
+ ### `Solana::Ruby::Kit::WalletStandard` — `@solana/wallet-standard`
657
+
658
+ Server-side handling of the [Wallet Standard](https://github.com/wallet-standard/wallet-standard)
659
+ `signTransaction` interface. A browser wallet (Phantom, Backpack, Solflare, …) signs a
660
+ transaction and returns wire bytes; Rails decodes those bytes and verifies every Ed25519
661
+ signature without broadcasting — because Solana addresses **are** Ed25519 public keys, no
662
+ additional key lookup is required.
663
+
664
+ Full worked examples: [Verify a Wallet-Signed Transaction in Rails](https://github.com/pzupan/solana-ruby-kit/wiki/Verify-a-Wallet-Signed-Transaction-in-Rails) and [Build a Transaction for Browser Signing](https://github.com/pzupan/solana-ruby-kit/wiki/Build-a-Transaction-for-Browser-Signing).
665
+
666
+ #### Wallet Standard feature constants
667
+
668
+ Use these when building frontend metadata or documenting required wallet capabilities:
669
+
670
+ ```ruby
671
+ WS = Solana::Ruby::Kit::WalletStandard
672
+
673
+ WS::SIGN_TRANSACTION # => 'solana:signTransaction'
674
+ WS::SIGN_AND_SEND_TRANSACTION # => 'solana:signAndSendTransaction'
675
+ WS::SIGN_MESSAGE # => 'solana:signMessage'
676
+ WS::CONNECT # => 'standard:connect'
677
+ ```
678
+
679
+ ### `Solana::Ruby::Kit::TransactionIntrospection` — `@solana/transaction-introspection`
680
+
681
+ Decode a confirmed `getTransaction` response and walk its outer and inner (CPI)
682
+ instructions — useful for indexers, explorers, or auditing what a transaction
683
+ actually did on-chain.
684
+
685
+ ```ruby
686
+ TI = Solana::Ruby::Kit::TransactionIntrospection
687
+ rpc = Solana::Ruby::Kit.rpc_client
688
+
689
+ rpc_tx = rpc.get_transaction(
690
+ signature,
691
+ encoding: 'base64',
692
+ max_supported_transaction_version: 0
693
+ )
694
+
695
+ # Decodes 'base64', 'base58', or 'json' getTransaction responses.
696
+ # `transaction` is only present for 'base64'/'base58' (a re-encodable
697
+ # Transactions::Transaction); 'json' responses carry no wire bytes to round-trip.
698
+ decoded = TI.decode_transaction_from_rpc_response(rpc_tx)
699
+
700
+ # Outer instructions only, with account indices resolved to full AccountMetas.
701
+ outer = TI.get_instructions_from_compiled_transaction_message(
702
+ decoded.compiled_message,
703
+ decoded.loaded_addresses
704
+ )
705
+
706
+ # Every instruction — outer and inner (CPI) — in the order an explorer
707
+ # displays them, each tagged with a `trace` describing its position.
708
+ instructions = TI.walk_instructions(
709
+ compiled_message: decoded.compiled_message,
710
+ loaded_addresses: decoded.loaded_addresses,
711
+ meta: rpc_tx['meta']
712
+ )
713
+
714
+ instructions.each do |traced|
715
+ ix = traced.instruction
716
+ puts "#{traced.trace[:kind]} ix -> #{ix.program_address}"
717
+ end
718
+ ```
719
+
720
+ > **Note** `'jsonParsed'` responses are not supported — their instructions are
721
+ > pre-parsed by the server and lack raw bytes, so they can't be resolved here;
722
+ > fetch with `'json'` or `'base64'` instead. Wire-format (`'base64'`/`'base58'`)
723
+ > decoding only handles legacy and v0 transactions — Ruby's transaction compiler
724
+ > doesn't produce v1 transactions yet either, so there is nothing to decompile
725
+ > for that version.
726
+
727
+ ## Error handling
728
+
729
+ All errors inherit from `Solana::Ruby::Kit::SolanaError`.
730
+
731
+ ```ruby
732
+ rescue Solana::Ruby::Kit::SolanaError => e
733
+ puts e.code # => :SOLANA_ERROR__ADDRESSES__INVALID_BASE58_ENCODED_ADDRESS
734
+ puts e.message # => human-readable description
735
+ puts e.context # => Hash of structured context values
736
+ end
737
+ ```
738
+
739
+ Error codes match the TypeScript `@solana/errors` package constants.
740
+
741
+ ## Type checking with Sorbet
742
+
743
+ Every public method has a `sig` block. To enable static type checking in your project:
744
+
745
+ ```bash
746
+ bundle exec srb init
747
+ bundle exec srb tc
748
+ ```
749
+
750
+ ## Development
751
+
752
+ ```bash
753
+ bundle install
754
+ bundle exec rspec # run tests
755
+ bundle exec srb tc # type-check
756
+ bundle exec tapioca gems # regenerate gem RBI files (first time or after gem updates)
757
+ ```
758
+
759
+ ## License
760
+
761
+ MIT