solana-ruby-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. checksums.yaml +7 -0
  2. data/lib/core_extensions/tapioca/name_patch.rb +32 -0
  3. data/lib/core_extensions/tapioca/required_ancestors.rb +13 -0
  4. data/lib/generators/solana/ruby/kit/install/install_generator.rb +17 -0
  5. data/lib/generators/solana/ruby/kit/install/templates/solana_ruby_kit.rb.tt +8 -0
  6. data/lib/solana/ruby/kit/accounts/account.rb +47 -0
  7. data/lib/solana/ruby/kit/accounts/maybe_account.rb +86 -0
  8. data/lib/solana/ruby/kit/accounts.rb +6 -0
  9. data/lib/solana/ruby/kit/addresses/address.rb +133 -0
  10. data/lib/solana/ruby/kit/addresses/curve.rb +112 -0
  11. data/lib/solana/ruby/kit/addresses/program_derived_address.rb +155 -0
  12. data/lib/solana/ruby/kit/addresses/public_key.rb +39 -0
  13. data/lib/solana/ruby/kit/addresses.rb +11 -0
  14. data/lib/solana/ruby/kit/codecs/bytes.rb +58 -0
  15. data/lib/solana/ruby/kit/codecs/codec.rb +135 -0
  16. data/lib/solana/ruby/kit/codecs/data_structures.rb +177 -0
  17. data/lib/solana/ruby/kit/codecs/decoder.rb +43 -0
  18. data/lib/solana/ruby/kit/codecs/encoder.rb +52 -0
  19. data/lib/solana/ruby/kit/codecs/numbers.rb +217 -0
  20. data/lib/solana/ruby/kit/codecs/strings.rb +116 -0
  21. data/lib/solana/ruby/kit/codecs.rb +25 -0
  22. data/lib/solana/ruby/kit/configuration.rb +48 -0
  23. data/lib/solana/ruby/kit/encoding/base58.rb +62 -0
  24. data/lib/solana/ruby/kit/errors.rb +226 -0
  25. data/lib/solana/ruby/kit/fast_stable_stringify.rb +62 -0
  26. data/lib/solana/ruby/kit/functional.rb +29 -0
  27. data/lib/solana/ruby/kit/instruction_plans/plans.rb +27 -0
  28. data/lib/solana/ruby/kit/instruction_plans.rb +47 -0
  29. data/lib/solana/ruby/kit/instructions/accounts.rb +80 -0
  30. data/lib/solana/ruby/kit/instructions/instruction.rb +71 -0
  31. data/lib/solana/ruby/kit/instructions/roles.rb +84 -0
  32. data/lib/solana/ruby/kit/instructions.rb +7 -0
  33. data/lib/solana/ruby/kit/keys/key_pair.rb +84 -0
  34. data/lib/solana/ruby/kit/keys/private_key.rb +39 -0
  35. data/lib/solana/ruby/kit/keys/public_key.rb +31 -0
  36. data/lib/solana/ruby/kit/keys/signatures.rb +171 -0
  37. data/lib/solana/ruby/kit/keys.rb +11 -0
  38. data/lib/solana/ruby/kit/offchain_messages/codec.rb +107 -0
  39. data/lib/solana/ruby/kit/offchain_messages/message.rb +22 -0
  40. data/lib/solana/ruby/kit/offchain_messages.rb +16 -0
  41. data/lib/solana/ruby/kit/options/option.rb +132 -0
  42. data/lib/solana/ruby/kit/options.rb +5 -0
  43. data/lib/solana/ruby/kit/plugin_core.rb +58 -0
  44. data/lib/solana/ruby/kit/programs.rb +42 -0
  45. data/lib/solana/ruby/kit/promises.rb +85 -0
  46. data/lib/solana/ruby/kit/railtie.rb +18 -0
  47. data/lib/solana/ruby/kit/rpc/api/get_account_info.rb +76 -0
  48. data/lib/solana/ruby/kit/rpc/api/get_balance.rb +41 -0
  49. data/lib/solana/ruby/kit/rpc/api/get_block_height.rb +29 -0
  50. data/lib/solana/ruby/kit/rpc/api/get_epoch_info.rb +47 -0
  51. data/lib/solana/ruby/kit/rpc/api/get_latest_blockhash.rb +52 -0
  52. data/lib/solana/ruby/kit/rpc/api/get_minimum_balance_for_rent_exemption.rb +29 -0
  53. data/lib/solana/ruby/kit/rpc/api/get_multiple_accounts.rb +56 -0
  54. data/lib/solana/ruby/kit/rpc/api/get_program_accounts.rb +60 -0
  55. data/lib/solana/ruby/kit/rpc/api/get_signature_statuses.rb +56 -0
  56. data/lib/solana/ruby/kit/rpc/api/get_slot.rb +30 -0
  57. data/lib/solana/ruby/kit/rpc/api/get_token_account_balance.rb +38 -0
  58. data/lib/solana/ruby/kit/rpc/api/get_token_accounts_by_owner.rb +48 -0
  59. data/lib/solana/ruby/kit/rpc/api/get_transaction.rb +36 -0
  60. data/lib/solana/ruby/kit/rpc/api/get_vote_accounts.rb +62 -0
  61. data/lib/solana/ruby/kit/rpc/api/is_blockhash_valid.rb +41 -0
  62. data/lib/solana/ruby/kit/rpc/api/request_airdrop.rb +35 -0
  63. data/lib/solana/ruby/kit/rpc/api/send_transaction.rb +61 -0
  64. data/lib/solana/ruby/kit/rpc/api/simulate_transaction.rb +47 -0
  65. data/lib/solana/ruby/kit/rpc/client.rb +83 -0
  66. data/lib/solana/ruby/kit/rpc/transport.rb +137 -0
  67. data/lib/solana/ruby/kit/rpc.rb +13 -0
  68. data/lib/solana/ruby/kit/rpc_parsed_types/address_lookup_table.rb +33 -0
  69. data/lib/solana/ruby/kit/rpc_parsed_types/nonce_account.rb +33 -0
  70. data/lib/solana/ruby/kit/rpc_parsed_types/stake_account.rb +51 -0
  71. data/lib/solana/ruby/kit/rpc_parsed_types/token_account.rb +52 -0
  72. data/lib/solana/ruby/kit/rpc_parsed_types/vote_account.rb +38 -0
  73. data/lib/solana/ruby/kit/rpc_parsed_types.rb +16 -0
  74. data/lib/solana/ruby/kit/rpc_subscriptions/api/account_notifications.rb +29 -0
  75. data/lib/solana/ruby/kit/rpc_subscriptions/api/logs_notifications.rb +28 -0
  76. data/lib/solana/ruby/kit/rpc_subscriptions/api/program_notifications.rb +30 -0
  77. data/lib/solana/ruby/kit/rpc_subscriptions/api/root_notifications.rb +19 -0
  78. data/lib/solana/ruby/kit/rpc_subscriptions/api/signature_notifications.rb +28 -0
  79. data/lib/solana/ruby/kit/rpc_subscriptions/api/slot_notifications.rb +19 -0
  80. data/lib/solana/ruby/kit/rpc_subscriptions/autopinger.rb +42 -0
  81. data/lib/solana/ruby/kit/rpc_subscriptions/client.rb +80 -0
  82. data/lib/solana/ruby/kit/rpc_subscriptions/subscription.rb +58 -0
  83. data/lib/solana/ruby/kit/rpc_subscriptions/transport.rb +163 -0
  84. data/lib/solana/ruby/kit/rpc_subscriptions.rb +12 -0
  85. data/lib/solana/ruby/kit/rpc_types/account_info.rb +53 -0
  86. data/lib/solana/ruby/kit/rpc_types/cluster_url.rb +56 -0
  87. data/lib/solana/ruby/kit/rpc_types/commitment.rb +52 -0
  88. data/lib/solana/ruby/kit/rpc_types/lamports.rb +43 -0
  89. data/lib/solana/ruby/kit/rpc_types.rb +8 -0
  90. data/lib/solana/ruby/kit/signers/keypair_signer.rb +126 -0
  91. data/lib/solana/ruby/kit/signers.rb +5 -0
  92. data/lib/solana/ruby/kit/subscribable/async_iterable.rb +80 -0
  93. data/lib/solana/ruby/kit/subscribable/data_publisher.rb +90 -0
  94. data/lib/solana/ruby/kit/subscribable.rb +13 -0
  95. data/lib/solana/ruby/kit/sysvars/addresses.rb +19 -0
  96. data/lib/solana/ruby/kit/sysvars/clock.rb +37 -0
  97. data/lib/solana/ruby/kit/sysvars/epoch_schedule.rb +34 -0
  98. data/lib/solana/ruby/kit/sysvars/last_restart_slot.rb +22 -0
  99. data/lib/solana/ruby/kit/sysvars/rent.rb +29 -0
  100. data/lib/solana/ruby/kit/sysvars.rb +33 -0
  101. data/lib/solana/ruby/kit/transaction_confirmation.rb +159 -0
  102. data/lib/solana/ruby/kit/transaction_messages/transaction_message.rb +168 -0
  103. data/lib/solana/ruby/kit/transaction_messages.rb +5 -0
  104. data/lib/solana/ruby/kit/transactions/transaction.rb +135 -0
  105. data/lib/solana/ruby/kit/transactions.rb +5 -0
  106. data/lib/solana/ruby/kit/version.rb +10 -0
  107. data/lib/solana/ruby/kit.rb +100 -0
  108. data/solana-ruby-kit.gemspec +29 -0
  109. metadata +311 -0
@@ -0,0 +1,90 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Solana::Ruby::Kit
5
+ module Subscribable
6
+ # Thread-safe pub/sub hub — mirrors @solana/subscribable DataPublisher.
7
+ #
8
+ # Channels are arbitrary symbols/strings. Subscribers are blocks called
9
+ # synchronously from #publish (in the calling thread). An optional
10
+ # +signal+ lambda is checked before each dispatch; if it raises the
11
+ # subscriber is automatically removed.
12
+ #
13
+ # Returns an unsubscribe lambda from #on.
14
+ class DataPublisher
15
+ extend T::Sig
16
+
17
+ ERROR_CHANNEL = T.let(:error, Symbol)
18
+ CLOSE_CHANNEL = T.let(:close, Symbol)
19
+
20
+ sig { void }
21
+ def initialize
22
+ # channel_name → [[subscriber_proc, signal_lambda_or_nil], ...]
23
+ @subscribers = T.let(
24
+ Hash.new { |h, k| h[k] = [] },
25
+ T::Hash[T.untyped, T::Array[[T.proc.params(data: T.untyped).void, T.nilable(T.proc.void)]]]
26
+ )
27
+ @mutex = T.let(Mutex.new, Mutex)
28
+ @closed = T.let(false, T::Boolean)
29
+ end
30
+
31
+ # Register +block+ as a subscriber on +channel_name+.
32
+ # +signal+ is an optional lambda that is called before each dispatch;
33
+ # if it raises (e.g. a Timeout::Error or custom AbortError) the
34
+ # subscription is removed automatically.
35
+ # Returns a lambda that removes the subscription when called.
36
+ sig do
37
+ params(
38
+ channel_name: T.untyped,
39
+ signal: T.nilable(T.proc.void),
40
+ block: T.proc.params(data: T.untyped).void
41
+ ).returns(T.proc.void)
42
+ end
43
+ def on(channel_name, signal: nil, &block)
44
+ entry = [block, signal]
45
+ @mutex.synchronize { T.must(@subscribers[channel_name]) << entry }
46
+
47
+ # Return unsubscribe lambda
48
+ lambda do
49
+ @mutex.synchronize { T.must(@subscribers[channel_name]).delete(entry) }
50
+ end
51
+ end
52
+
53
+ # Deliver +data+ to all subscribers on +channel_name+.
54
+ # Subscribers whose signal has fired are pruned automatically.
55
+ sig { params(channel_name: T.untyped, data: T.untyped).void }
56
+ def publish(channel_name, data)
57
+ entries = @mutex.synchronize { (@subscribers[channel_name] || []).dup }
58
+ entries.each do |subscriber, signal|
59
+ begin
60
+ signal&.call
61
+ rescue StandardError
62
+ # Signal fired — remove this subscriber and skip dispatch
63
+ @mutex.synchronize { T.must(@subscribers[channel_name]).delete([subscriber, signal]) }
64
+ next
65
+ end
66
+ begin
67
+ subscriber.call(data)
68
+ rescue StandardError => e
69
+ # Dispatch errors are re-published on the error channel (not raised)
70
+ publish(ERROR_CHANNEL, e) unless channel_name == ERROR_CHANNEL
71
+ end
72
+ end
73
+ end
74
+
75
+ # Close the publisher: emit a final event on the close channel and remove
76
+ # all subscribers.
77
+ sig { void }
78
+ def close
79
+ publish(CLOSE_CHANNEL, nil)
80
+ @mutex.synchronize do
81
+ @subscribers.clear
82
+ @closed = true
83
+ end
84
+ end
85
+
86
+ sig { returns(T::Boolean) }
87
+ def closed? = @closed
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,13 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'timeout'
5
+
6
+ # Mirrors @solana/subscribable.
7
+ require_relative 'subscribable/data_publisher'
8
+ require_relative 'subscribable/async_iterable'
9
+
10
+ module Solana::Ruby::Kit
11
+ module Subscribable
12
+ end
13
+ end
@@ -0,0 +1,19 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Solana::Ruby::Kit
5
+ module Sysvars
6
+ # Well-known on-chain addresses for all Solana sysvar accounts.
7
+ SYSVAR_CLOCK_ADDRESS = T.let('SysvarC1ock11111111111111111111111111111111', String)
8
+ SYSVAR_RENT_ADDRESS = T.let('SysvarRent111111111111111111111111111111111', String)
9
+ SYSVAR_EPOCH_SCHEDULE_ADDRESS = T.let('SysvarEpochSchedu1e111111111111111111111111', String)
10
+ SYSVAR_FEES_ADDRESS = T.let('SysvarFees111111111111111111111111111111111', String)
11
+ SYSVAR_RECENT_BLOCKHASHES_ADDRESS = T.let('SysvarRecentB1ockHashes11111111111111111111', String)
12
+ SYSVAR_SLOT_HASHES_ADDRESS = T.let('SysvarS1otHashes111111111111111111111111111', String)
13
+ SYSVAR_SLOT_HISTORY_ADDRESS = T.let('SysvarS1otHistory11111111111111111111111111', String)
14
+ SYSVAR_STAKE_HISTORY_ADDRESS = T.let('SysvarStakeHistory1111111111111111111111111', String)
15
+ SYSVAR_INSTRUCTIONS_ADDRESS = T.let('Sysvar1nstructions1111111111111111111111111', String)
16
+ SYSVAR_LAST_RESTART_SLOT_ADDRESS = T.let('SysvarLastRestartS1ot1111111111111111111111', String)
17
+ SYSVAR_EPOCH_REWARDS_ADDRESS = T.let('SysvarEpochRewards1111111111111111111111111', String)
18
+ end
19
+ end
@@ -0,0 +1,37 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'base64'
5
+
6
+ module Solana::Ruby::Kit
7
+ module Sysvars
8
+ # Clock sysvar — 40 bytes little-endian layout:
9
+ # slot(u64) + epoch_start_timestamp(i64) + epoch(u64) + leader_schedule_epoch(u64) + unix_timestamp(i64)
10
+ class SysvarClock < T::Struct
11
+ const :slot, Integer
12
+ const :epoch_start_timestamp, Integer # Unix seconds (i64)
13
+ const :epoch, Integer
14
+ const :leader_schedule_epoch, Integer
15
+ const :unix_timestamp, Integer # Unix seconds (i64)
16
+ end
17
+
18
+ CLOCK_SIZE = T.let(40, Integer)
19
+
20
+ module_function
21
+
22
+ sig { params(rpc: Rpc::Client).returns(SysvarClock) }
23
+ def fetch_sysvar_clock(rpc)
24
+ res = rpc.get_account_info(SYSVAR_CLOCK_ADDRESS, encoding: 'base64')
25
+ data = _decode_account_data(res)
26
+
27
+ unpacked = T.cast(T.unsafe(data).unpack('Q<q<Q<Q<q<'), T::Array[Integer])
28
+ SysvarClock.new(
29
+ slot: T.must(unpacked[0]),
30
+ epoch_start_timestamp: T.must(unpacked[1]),
31
+ epoch: T.must(unpacked[2]),
32
+ leader_schedule_epoch: T.must(unpacked[3]),
33
+ unix_timestamp: T.must(unpacked[4])
34
+ )
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,34 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Solana::Ruby::Kit
5
+ module Sysvars
6
+ # EpochSchedule sysvar — 33 bytes:
7
+ # slots_per_epoch(u64) + leader_schedule_slot_offset(u64)
8
+ # + warmup(bool/u8) + first_normal_epoch(u64) + first_normal_slot(u64)
9
+ class SysvarEpochSchedule < T::Struct
10
+ const :slots_per_epoch, Integer
11
+ const :leader_schedule_slot_offset, Integer
12
+ const :warmup, T::Boolean
13
+ const :first_normal_epoch, Integer
14
+ const :first_normal_slot, Integer
15
+ end
16
+
17
+ module_function
18
+
19
+ sig { params(rpc: Rpc::Client).returns(SysvarEpochSchedule) }
20
+ def fetch_sysvar_epoch_schedule(rpc)
21
+ res = rpc.get_account_info(SYSVAR_EPOCH_SCHEDULE_ADDRESS, encoding: 'base64')
22
+ data = _decode_account_data(res)
23
+
24
+ unpacked = T.unsafe(data).unpack('Q<Q<CQ<Q<')
25
+ SysvarEpochSchedule.new(
26
+ slots_per_epoch: unpacked[0],
27
+ leader_schedule_slot_offset: unpacked[1],
28
+ warmup: unpacked[2] == 1,
29
+ first_normal_epoch: unpacked[3],
30
+ first_normal_slot: unpacked[4]
31
+ )
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,22 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Solana::Ruby::Kit
5
+ module Sysvars
6
+ # LastRestartSlot sysvar — 8 bytes: last_restart_slot(u64 LE)
7
+ class SysvarLastRestartSlot < T::Struct
8
+ const :last_restart_slot, Integer
9
+ end
10
+
11
+ module_function
12
+
13
+ sig { params(rpc: Rpc::Client).returns(SysvarLastRestartSlot) }
14
+ def fetch_sysvar_last_restart_slot(rpc)
15
+ res = rpc.get_account_info(SYSVAR_LAST_RESTART_SLOT_ADDRESS, encoding: 'base64')
16
+ data = _decode_account_data(res)
17
+
18
+ slot = data.unpack1('Q<')
19
+ SysvarLastRestartSlot.new(last_restart_slot: slot)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,29 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Solana::Ruby::Kit
5
+ module Sysvars
6
+ # Rent sysvar — 17 bytes:
7
+ # lamports_per_byte_year(u64) + exemption_threshold(f64) + burn_percent(u8)
8
+ class SysvarRent < T::Struct
9
+ const :lamports_per_byte_year, Integer
10
+ const :exemption_threshold, Float
11
+ const :burn_percent, Integer
12
+ end
13
+
14
+ module_function
15
+
16
+ sig { params(rpc: Rpc::Client).returns(SysvarRent) }
17
+ def fetch_sysvar_rent(rpc)
18
+ res = rpc.get_account_info(SYSVAR_RENT_ADDRESS, encoding: 'base64')
19
+ data = _decode_account_data(res)
20
+
21
+ unpacked = T.unsafe(data).unpack('Q<EC')
22
+ SysvarRent.new(
23
+ lamports_per_byte_year: unpacked[0],
24
+ exemption_threshold: unpacked[1],
25
+ burn_percent: unpacked[2]
26
+ )
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,33 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'base64'
5
+
6
+ # Mirrors @solana/sysvars.
7
+ # Provides addresses and fetch helpers for Solana sysvar accounts.
8
+
9
+ # Set up the module (extend T::Sig) before requiring sub-files that use `sig`.
10
+ module Solana::Ruby::Kit
11
+ module Sysvars
12
+ extend T::Sig
13
+
14
+ module_function
15
+
16
+ # Decode base64 account data from an RpcContextualValue returned by
17
+ # get_account_info(encoding: 'base64').
18
+ sig { params(res: RpcTypes::RpcContextualValue).returns(String) }
19
+ def _decode_account_data(res)
20
+ info = res.value
21
+ Kernel.raise ArgumentError, 'Account not found' if info.nil?
22
+
23
+ raw = T.cast(info, RpcTypes::AccountInfoWithBase64Data)
24
+ Base64.decode64(raw.data.first.to_s).b
25
+ end
26
+ end
27
+ end
28
+
29
+ require_relative 'sysvars/addresses'
30
+ require_relative 'sysvars/clock'
31
+ require_relative 'sysvars/rent'
32
+ require_relative 'sysvars/epoch_schedule'
33
+ require_relative 'sysvars/last_restart_slot'
@@ -0,0 +1,159 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'timeout'
5
+
6
+ # Mirrors @solana/transaction-confirmation.
7
+ # Provides synchronous polling strategies that wait for a transaction to reach
8
+ # a desired commitment level.
9
+ module Solana::Ruby::Kit
10
+ module TransactionConfirmation
11
+ extend T::Sig
12
+
13
+ module_function
14
+
15
+ # Poll getSignatureStatuses until +signature+ reaches +commitment+ or
16
+ # the blockheight/timeout deadline is exceeded.
17
+ #
18
+ # @param rpc [Rpc::Client]
19
+ # @param signature [Keys::Signature, String]
20
+ # @param commitment [:processed, :confirmed, :finalized]
21
+ # @param timeout_secs [Integer]
22
+ # @param poll_interval [Float]
23
+ # @return [Rpc::Api::SignatureStatus]
24
+ # @raise [Timeout::Error] if the deadline is reached
25
+ # @raise [Solana::Ruby::Kit::SolanaError] if the transaction failed
26
+ sig do
27
+ params(
28
+ rpc: Rpc::Client,
29
+ signature: T.any(Keys::Signature, String),
30
+ commitment: Symbol,
31
+ timeout_secs: Integer,
32
+ poll_interval: Float
33
+ ).returns(T.untyped) # Rpc::Api::SignatureStatus
34
+ end
35
+ def wait_for_confirmation(
36
+ rpc,
37
+ signature,
38
+ commitment: :confirmed,
39
+ timeout_secs: 30,
40
+ poll_interval: 0.5
41
+ )
42
+ sig_str = signature.respond_to?(:value) ? T.cast(signature, Keys::Signature).value : signature.to_s
43
+ commitment_order = %i[processed confirmed finalized]
44
+ min_order = commitment_order.index(commitment) || 1
45
+
46
+ Timeout.timeout(timeout_secs) do
47
+ Kernel.loop do
48
+ res = rpc.get_signature_statuses([sig_str], search_transaction_history: false)
49
+ status = res.value.first
50
+ next Kernel.sleep(poll_interval) if status.nil?
51
+
52
+ err = status.respond_to?(:err) ? status.err : status[:err]
53
+ Kernel.raise SolanaError.new(SolanaError::TRANSACTIONS__FAILED_TRANSACTION_PLAN, err: err.inspect) if err
54
+
55
+ current_commitment = status.respond_to?(:confirmation_status) ? status.confirmation_status : status[:confirmation_status]
56
+ current_order = commitment_order.index(current_commitment) || 0
57
+
58
+ return status if current_order >= min_order
59
+
60
+ Kernel.sleep(poll_interval)
61
+ end
62
+ end
63
+ end
64
+
65
+ # Wait until the transaction is confirmed or the blockhash lifetime expires
66
+ # (i.e. the current block height exceeds +last_valid_block_height+).
67
+ sig do
68
+ params(
69
+ rpc: Rpc::Client,
70
+ signature: T.any(Keys::Signature, String),
71
+ last_valid_block_height: Integer,
72
+ commitment: Symbol,
73
+ poll_interval: Float
74
+ ).returns(T.untyped)
75
+ end
76
+ def wait_for_blockheight_lifetime(
77
+ rpc,
78
+ signature,
79
+ last_valid_block_height:,
80
+ commitment: :confirmed,
81
+ poll_interval: 0.5
82
+ )
83
+ sig_str = signature.respond_to?(:value) ? T.cast(signature, Keys::Signature).value : signature.to_s
84
+ commitment_order = %i[processed confirmed finalized]
85
+ min_order = commitment_order.index(commitment) || 1
86
+
87
+ Kernel.loop do
88
+ # Check if blockheight has been exceeded
89
+ current_height = rpc.get_block_height
90
+ Kernel.raise Timeout::Error, 'Transaction lifetime expired (blockheight)' if current_height > last_valid_block_height
91
+
92
+ res = rpc.get_signature_statuses([sig_str], search_transaction_history: false)
93
+ status = res.value.first
94
+ next Kernel.sleep(poll_interval) if status.nil?
95
+
96
+ err = status.respond_to?(:err) ? status.err : status[:err]
97
+ Kernel.raise SolanaError.new(SolanaError::TRANSACTIONS__FAILED_TRANSACTION_PLAN, err: err.inspect) if err
98
+
99
+ current_commitment = status.respond_to?(:confirmation_status) ? status.confirmation_status : status[:confirmation_status]
100
+ current_order = commitment_order.index(current_commitment) || 0
101
+
102
+ return status if current_order >= min_order
103
+
104
+ Kernel.sleep(poll_interval)
105
+ end
106
+ end
107
+
108
+ # Wait for confirmation using a durable nonce strategy.
109
+ # The transaction is considered expired once the nonce account's blockhash
110
+ # changes (indicating the nonce was advanced by someone else or the TX landed).
111
+ sig do
112
+ params(
113
+ rpc: Rpc::Client,
114
+ signature: T.any(Keys::Signature, String),
115
+ nonce_account: String,
116
+ nonce: String,
117
+ commitment: Symbol,
118
+ timeout_secs: Integer,
119
+ poll_interval: Float
120
+ ).returns(T.untyped)
121
+ end
122
+ def wait_for_nonce_invalidation(
123
+ rpc,
124
+ signature,
125
+ nonce_account:,
126
+ nonce:,
127
+ commitment: :confirmed,
128
+ timeout_secs: 120,
129
+ poll_interval: 1.0
130
+ )
131
+ sig_str = signature.respond_to?(:value) ? T.cast(signature, Keys::Signature).value : signature.to_s
132
+ commitment_order = %i[processed confirmed finalized]
133
+ min_order = commitment_order.index(commitment) || 1
134
+
135
+ Timeout.timeout(timeout_secs) do
136
+ Kernel.loop do
137
+ # First check if transaction already confirmed
138
+ res = rpc.get_signature_statuses([sig_str], search_transaction_history: false)
139
+ status = res.value.first
140
+ unless status.nil?
141
+ err = status.respond_to?(:err) ? status.err : status[:err]
142
+ Kernel.raise SolanaError.new(SolanaError::TRANSACTIONS__FAILED_TRANSACTION_PLAN, err: err.inspect) if err
143
+
144
+ current_commitment = status.respond_to?(:confirmation_status) ? status.confirmation_status : status[:confirmation_status]
145
+ current_order = commitment_order.index(current_commitment) || 0
146
+ return status if current_order >= min_order
147
+ end
148
+
149
+ # Check if nonce has advanced (transaction expired without confirming)
150
+ nonce_res = rpc.get_account_info(nonce_account, encoding: 'jsonParsed')
151
+ current_nonce_hash = nonce_res.value&.respond_to?(:data) ? nonce_res.value.data&.dig('parsed', 'info', 'blockhash') : nil
152
+ Kernel.raise Timeout::Error, 'Nonce advanced — transaction expired' if current_nonce_hash && current_nonce_hash != nonce
153
+
154
+ Kernel.sleep(poll_interval)
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,168 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../addresses/address'
5
+ require_relative '../errors'
6
+ require_relative '../instructions/instruction'
7
+
8
+ module Solana::Ruby::Kit
9
+ module TransactionMessages
10
+ extend T::Sig
11
+ # Supported transaction versions.
12
+ # Mirrors TypeScript's `TransactionVersion = 'legacy' | 0 | 1`.
13
+ # Version 1 is defined but not yet supported by most tooling.
14
+ TransactionVersion = T.type_alias { T.any(Symbol, Integer) }
15
+
16
+ LEGACY_VERSION = T.let(:legacy, Symbol)
17
+ V0_VERSION = T.let(0, Integer)
18
+ V1_VERSION = T.let(1, Integer)
19
+
20
+ MAX_SUPPORTED_TRANSACTION_VERSION = T.let(1, Integer)
21
+
22
+ # A blockhash-based lifetime constraint.
23
+ # Mirrors TypeScript's `BlockhashLifetimeConstraint`.
24
+ class BlockhashLifetimeConstraint < T::Struct
25
+ # A recent blockhash string (base58-encoded 32 bytes).
26
+ const :blockhash, String
27
+ # Block height after which the blockhash is considered expired.
28
+ # TypeScript uses bigint; Ruby uses Integer.
29
+ const :last_valid_block_height, Integer
30
+ end
31
+
32
+ # A durable-nonce lifetime constraint.
33
+ # Mirrors TypeScript's `DurableNonceTransactionMessageLifetimeConstraint`.
34
+ class DurableNonceLifetimeConstraint < T::Struct
35
+ const :nonce, String # base58 nonce value
36
+ const :nonce_account_address, Addresses::Address
37
+ end
38
+
39
+ # Lifetime can be a blockhash constraint, a durable-nonce constraint, or nil.
40
+ Lifetime = T.type_alias { T.nilable(T.any(BlockhashLifetimeConstraint, DurableNonceLifetimeConstraint)) }
41
+
42
+ # The core transaction message structure.
43
+ # Mirrors TypeScript's `TransactionMessage` (legacy | V0).
44
+ #
45
+ # TypeScript uses compile-time type narrowing to differentiate versions;
46
+ # Ruby uses the `version` field at runtime.
47
+ class TransactionMessage < T::Struct
48
+ # :legacy or 0 (V1 reserved)
49
+ const :version, T.untyped # Symbol or Integer
50
+ # Ordered list of instructions.
51
+ const :instructions, T::Array[Instructions::Instruction]
52
+ # The account that pays the transaction fee (nil on a freshly created message).
53
+ const :fee_payer, T.nilable(Addresses::Address)
54
+ # Lifetime constraint (nil until explicitly set).
55
+ const :lifetime_constraint, Lifetime
56
+ # V0 only: lookup table addresses mapped to the accounts they hold.
57
+ const :address_table_lookups, T.nilable(T::Hash[String, T::Array[Integer]])
58
+ end
59
+
60
+ module_function
61
+
62
+ # Creates an empty transaction message at the given version.
63
+ # Mirrors `createTransactionMessage({ version })`.
64
+ sig { params(version: T.untyped).returns(TransactionMessage) }
65
+ def create_transaction_message(version:)
66
+ TransactionMessage.new(
67
+ version: version,
68
+ instructions: [],
69
+ fee_payer: nil,
70
+ lifetime_constraint: nil,
71
+ address_table_lookups: nil
72
+ )
73
+ end
74
+
75
+ # Sets the fee payer on a transaction message.
76
+ # Mirrors `setTransactionMessageFeePayer(feePayer, transactionMessage)`.
77
+ sig { params(fee_payer: Addresses::Address, message: TransactionMessage).returns(TransactionMessage) }
78
+ def set_fee_payer(fee_payer, message)
79
+ return message if message.fee_payer == fee_payer
80
+
81
+ TransactionMessage.new(
82
+ version: message.version,
83
+ instructions: message.instructions,
84
+ fee_payer: fee_payer,
85
+ lifetime_constraint: message.lifetime_constraint,
86
+ address_table_lookups: message.address_table_lookups
87
+ )
88
+ end
89
+
90
+ # Sets a blockhash-based lifetime constraint on a transaction message.
91
+ # Mirrors `setTransactionMessageLifetimeUsingBlockhash(constraint, message)`.
92
+ sig { params(constraint: BlockhashLifetimeConstraint, message: TransactionMessage).returns(TransactionMessage) }
93
+ def set_blockhash_lifetime(constraint, message)
94
+ existing = message.lifetime_constraint
95
+ if existing.is_a?(BlockhashLifetimeConstraint) &&
96
+ existing.blockhash == constraint.blockhash &&
97
+ existing.last_valid_block_height == constraint.last_valid_block_height
98
+ return message
99
+ end
100
+
101
+ TransactionMessage.new(
102
+ version: message.version,
103
+ instructions: message.instructions,
104
+ fee_payer: message.fee_payer,
105
+ lifetime_constraint: constraint,
106
+ address_table_lookups: message.address_table_lookups
107
+ )
108
+ end
109
+
110
+ # Sets a durable-nonce lifetime constraint on a transaction message.
111
+ # Mirrors `setTransactionMessageLifetimeUsingDurableNonce(constraint, message)`.
112
+ sig { params(constraint: DurableNonceLifetimeConstraint, message: TransactionMessage).returns(TransactionMessage) }
113
+ def set_durable_nonce_lifetime(constraint, message)
114
+ TransactionMessage.new(
115
+ version: message.version,
116
+ instructions: message.instructions,
117
+ fee_payer: message.fee_payer,
118
+ lifetime_constraint: constraint,
119
+ address_table_lookups: message.address_table_lookups
120
+ )
121
+ end
122
+
123
+ # Appends one or more instructions to a transaction message.
124
+ # Mirrors `appendTransactionMessageInstruction(instruction, message)` and
125
+ # `appendTransactionMessageInstructions(instructions, message)`.
126
+ sig { params(message: TransactionMessage, instructions: T::Array[Instructions::Instruction]).returns(TransactionMessage) }
127
+ def append_instructions(message, instructions)
128
+ TransactionMessage.new(
129
+ version: message.version,
130
+ instructions: message.instructions + instructions,
131
+ fee_payer: message.fee_payer,
132
+ lifetime_constraint: message.lifetime_constraint,
133
+ address_table_lookups: message.address_table_lookups
134
+ )
135
+ end
136
+
137
+ # Prepends one or more instructions to a transaction message.
138
+ # Mirrors `prependTransactionMessageInstruction(instruction, message)`.
139
+ sig { params(message: TransactionMessage, instructions: T::Array[Instructions::Instruction]).returns(TransactionMessage) }
140
+ def prepend_instructions(message, instructions)
141
+ TransactionMessage.new(
142
+ version: message.version,
143
+ instructions: instructions + message.instructions,
144
+ fee_payer: message.fee_payer,
145
+ lifetime_constraint: message.lifetime_constraint,
146
+ address_table_lookups: message.address_table_lookups
147
+ )
148
+ end
149
+
150
+ # Returns true if the message has a blockhash-based lifetime.
151
+ sig { params(message: TransactionMessage).returns(T::Boolean) }
152
+ def blockhash_lifetime?(message)
153
+ message.lifetime_constraint.is_a?(BlockhashLifetimeConstraint)
154
+ end
155
+
156
+ # Returns true if the message has a durable-nonce lifetime.
157
+ sig { params(message: TransactionMessage).returns(T::Boolean) }
158
+ def durable_nonce_lifetime?(message)
159
+ message.lifetime_constraint.is_a?(DurableNonceLifetimeConstraint)
160
+ end
161
+
162
+ # Raises SolanaError unless the message has a blockhash lifetime.
163
+ sig { params(message: TransactionMessage).void }
164
+ def assert_blockhash_lifetime!(message)
165
+ Kernel.raise SolanaError.new(:SOLANA_ERROR__TRANSACTION__EXPECTED_BLOCKHASH_LIFETIME) unless blockhash_lifetime?(message)
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,5 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ # Transaction message construction utilities — mirrors @solana/transaction-messages.
5
+ require_relative 'transaction_messages/transaction_message'