solana-ruby-kit 8.5.0 → 8.6.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3067fc0036448f0aef241f14f4e0b95f0faf6d6cedd4ed8aae5206d6e3169e28
4
- data.tar.gz: a689c349c2e41d519b6e06814319d9760b2816bf59f48109d8d839790c4e6312
3
+ metadata.gz: c6c87d85cd6d590f5db4b09599ae8f0dc73be1c3f6ca5d387b184933bf9e5db3
4
+ data.tar.gz: 26b99981a5c5313a825f1bc6a0dc89c4f73e7dea53708a809af00c52e1b4047a
5
5
  SHA512:
6
- metadata.gz: a02f7ce5743af32d1d6401f2023fc7293e33abfe1c0f495ec91f00a7ca4399e6fd654c7baa3f3c966ed34d6495514607432315323fb3425f294e8bf1713035ae
7
- data.tar.gz: bfd06cf91f1fc1652cb5a578b822595c5f976537561fddbad22e04e6dc5567f1284b211b4e41be6472426005f3f4b0197c486fe5d87a046e2dac8989e30acb61
6
+ metadata.gz: 9a9a8a706b215942873c2e3208e34ed016e03355e11968b41f55fcf3b4191c4e5b4486c61e92dc223ea7f06c3b4217cb9f0b7bff733893ef7aab7344c44e5340
7
+ data.tar.gz: e9b515766f64ff010c570a8835a44c201863d45fc79947462bbef51adfe19192a5dfe5d8fa347a4e6d2be6f5ddf30e9ea052097233113001fb9f62495d6d2b09
data/README.md CHANGED
@@ -213,6 +213,12 @@ nonce_constraint = TxMsg::DurableNonceLifetimeConstraint.new(
213
213
  nonce_account_address: nonce_addr
214
214
  )
215
215
  msg = TxMsg.set_durable_nonce_lifetime(nonce_constraint, msg)
216
+
217
+ # Version 1 transaction config: compile to a mask + values, and back.
218
+ config = TxMsg::V1TransactionConfig.new(compute_unit_limit: 300_000, priority_fee_lamports: 5_000)
219
+ mask = TxMsg.get_transaction_config_mask(config) # => 0b111
220
+ values = TxMsg.get_transaction_config_values(config) # => [u64 5_000, u32 300_000]
221
+ TxMsg.decompile_transaction_config(mask, values).compute_unit_limit # => 300_000
216
222
  ```
217
223
 
218
224
  ### `Solana::Ruby::Kit::Transactions` — `@solana/transactions`
@@ -250,6 +256,9 @@ Txns.assert_fully_signed_transaction!(tx)
250
256
  Txns.within_size_limit?(tx) # => true if wire size <= 1232 bytes
251
257
  Txns.assert_within_size_limit!(tx)
252
258
 
259
+ Txns::LEGACY_TRANSACTION_SIZE_LIMIT # => 1232 (legacy and v0)
260
+ Txns::V1_TRANSACTION_SIZE_LIMIT # => 4096
261
+
253
262
  # Sendable = fully signed AND within size limit
254
263
  Txns.sendable_transaction?(signed_tx) # => true / false
255
264
  Txns.assert_sendable_transaction!(signed_tx)
@@ -287,6 +296,7 @@ rpc.get_signatures_for_address(address)
287
296
  rpc.get_vote_accounts
288
297
  rpc.get_ag_genesis_cert # Alpenglow genesis cert, or nil
289
298
  rpc.simulate_transaction(encoded_tx)
299
+ rpc.simulate_transaction(encoded_tx, inner_instructions: true, inner_instructions_encoding: 'json')
290
300
  rpc.send_transaction(encoded_tx)
291
301
  rpc.request_airdrop(address, lamports) # devnet / testnet only
292
302
  ```
@@ -299,8 +309,54 @@ rescue Solana::Ruby::Kit::Rpc::RpcError => e
299
309
  puts e.message # JSON-RPC error message
300
310
  rescue Solana::Ruby::Kit::Rpc::HttpTransportError => e
301
311
  puts e.status_code # HTTP status code
312
+ puts e.retry_after # raw Retry-After header, or nil
302
313
  ```
303
314
 
315
+ #### Retries
316
+
317
+ Transient failures are retried automatically, for every RPC method, with
318
+ full-jitter exponential backoff. Defaults: `max_attempts: 5`, `base_delay: 0.25`,
319
+ `max_delay: 8.0` (seconds). The wait before retry *n* is
320
+ `rand * min(base_delay * 2**(n - 1), max_delay)`; a `Retry-After` header
321
+ overrides it (still capped at `max_delay`).
322
+
323
+ What gets retried depends on whether the request could have reached the node:
324
+
325
+ | Failure | Retried for |
326
+ |---|---|
327
+ | Connect-phase: `Errno::ENETUNREACH`, `EHOSTUNREACH`, `ECONNREFUSED`, `Net::OpenTimeout`, `SocketError` | every method — the bytes never left the machine |
328
+ | In-flight: `Errno::ECONNRESET`, `EPIPE`, `EOFError`, `Net::ReadTimeout`, `Net::WriteTimeout`, `OpenSSL::SSL::SSLError` | idempotent methods only |
329
+ | HTTP 429 / 502 / 503 / 504 | idempotent methods only |
330
+ | JSON-RPC error in a 200 response (`RpcError`) | never — the node answered |
331
+
332
+ Every attempt resends the same bytes with the same JSON-RPC `id`; nothing is
333
+ re-signed and no blockhash is refetched. Resending an identical signed
334
+ `sendTransaction` is safe because the cluster dedupes by signature. When
335
+ retries run out, the original error is re-raised.
336
+
337
+ **`requestAirdrop` is not idempotent**: each accepted request is a new airdrop,
338
+ so it is retried only on connect-phase errors. Add any of your own
339
+ non-idempotent methods to `non_idempotent:`.
340
+
341
+ ```ruby
342
+ # Customize, with logging
343
+ policy = Solana::Ruby::Kit::Rpc::RetryPolicy.new(
344
+ max_attempts: 8,
345
+ max_delay: 15.0,
346
+ on_retry: ->(method, attempt, error, wait) {
347
+ logger.warn("#{method} attempt #{attempt} failed (#{error.class}: #{error.message}); retrying in #{wait.round(2)}s")
348
+ }
349
+ )
350
+ rpc = Solana::Ruby::Kit::Rpc::Client.new(Solana::Ruby::Kit::RpcTypes.devnet, retry_policy: policy)
351
+
352
+ # Disable
353
+ rpc = Solana::Ruby::Kit::Rpc::Client.new(url, retry_policy: Solana::Ruby::Kit::Rpc::RetryPolicy.none)
354
+ ```
355
+
356
+ `Rpc::Transport.new` takes the same `retry_policy:`, plus `sleeper:` (a
357
+ callable receiving the wait in seconds, `Kernel.method(:sleep)` by default) so
358
+ tests need not actually wait. The WebSocket subscriptions client is not covered.
359
+
304
360
  ### `Solana::Ruby::Kit::RpcTypes` — `@solana/rpc-types`
305
361
 
306
362
  Cluster URL helpers and typed wrappers.
@@ -906,7 +962,9 @@ end
906
962
  > fetch with `'json'` or `'base64'` instead. Wire-format (`'base64'`/`'base58'`)
907
963
  > decoding only handles legacy and v0 transactions — Ruby's transaction compiler
908
964
  > doesn't produce v1 transactions yet either, so there is nothing to decompile
909
- > for that version.
965
+ > for that version. A `'json'` response is not wire-decoded, so it may be v1; its
966
+ > `transactionConfig` lands in `compiled_message.config_mask` / `config_values`,
967
+ > which `TransactionMessages.decompile_transaction_config` reads back.
910
968
 
911
969
  ## Error handling
912
970
 
@@ -922,6 +980,19 @@ end
922
980
 
923
981
  Error codes match the TypeScript `@solana/errors` package constants.
924
982
 
983
+ `SolanaError.simulation_error?` identifies a failed transaction simulation, including
984
+ the `Rpc::RpcError` that `send_transaction` raises when preflight fails:
985
+
986
+ ```ruby
987
+ begin
988
+ rpc.send_transaction(signed_tx)
989
+ rescue StandardError => e
990
+ raise unless Solana::Ruby::Kit::SolanaError.simulation_error?(e)
991
+
992
+ puts e.is_a?(Solana::Ruby::Kit::Rpc::RpcError) ? e.data['logs'] : e.context[:logs]
993
+ end
994
+ ```
995
+
925
996
  ## Type checking with Sorbet
926
997
 
927
998
  Every public method has a `sig` block. To enable static type checking in your project:
@@ -61,6 +61,10 @@ module Solana::Ruby::Kit
61
61
  TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_ACCOUNT_INDEX_OUT_OF_RANGE = :SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_ACCOUNT_INDEX_OUT_OF_RANGE
62
62
  # context: { unsupported_version: }
63
63
  TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED = :SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED
64
+ # context: { mask: }
65
+ TRANSACTION__INVALID_CONFIG_MASK_PRIORITY_FEE_BITS = :SOLANA_ERROR__TRANSACTION__INVALID_CONFIG_MASK_PRIORITY_FEE_BITS
66
+ # context: { actual_kind:, config_name:, expected_kind: }
67
+ TRANSACTION__INVALID_CONFIG_VALUE_KIND = :SOLANA_ERROR__TRANSACTION__INVALID_CONFIG_VALUE_KIND
64
68
  # context: { compute_unit_limit:, max_compute_unit_limit: }
65
69
  TRANSACTION__COMPUTE_UNIT_LIMIT_OUT_OF_RANGE = :SOLANA_ERROR__TRANSACTION__COMPUTE_UNIT_LIMIT_OUT_OF_RANGE
66
70
  # context: { heap_size:, max_heap_size:, min_heap_size:, multiple_of: }
@@ -226,6 +230,8 @@ module Solana::Ruby::Kit
226
230
  TRANSACTIONS__FAILED_TO_DECOMPILE_INSTRUCTION_PROGRAM_ADDRESS_NOT_FOUND => 'Failed to decompile instruction: program address not found at index %{index}',
227
231
  TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_ACCOUNT_INDEX_OUT_OF_RANGE => 'Failed to decompile instruction: account index %{index} is out of range',
228
232
  TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED => 'Transaction version %{unsupported_version} is not supported',
233
+ TRANSACTION__INVALID_CONFIG_MASK_PRIORITY_FEE_BITS => 'Invalid transaction config mask: %{mask}. Bits 0 and 1 must match (both set or both unset)',
234
+ TRANSACTION__INVALID_CONFIG_VALUE_KIND => 'The transaction config value for %{config_name} has the incorrect kind. Expected %{expected_kind}, got %{actual_kind}.',
229
235
  TRANSACTION__COMPUTE_UNIT_LIMIT_OUT_OF_RANGE => 'Transaction compute unit limit must be an integer in the range [0, %{max_compute_unit_limit}]. `%{compute_unit_limit}` given',
230
236
  TRANSACTION__INVALID_HEAP_SIZE => 'Transaction heap size must be an integer multiple of %{multiple_of} bytes in the range [%{min_heap_size}, %{max_heap_size}]. `%{heap_size}` given',
231
237
  TRANSACTIONS__SEND_TRANSACTION_PREFLIGHT_FAILURE => 'Transaction simulation failed: %{message}',
@@ -330,6 +336,33 @@ module Solana::Ruby::Kit
330
336
  T::Hash[Symbol, String]
331
337
  )
332
338
 
339
+ SIMULATION_ERROR_CODES = T.let(
340
+ [
341
+ TRANSACTIONS__SEND_TRANSACTION_PREFLIGHT_FAILURE,
342
+ TRANSACTION__FAILED_WHEN_SIMULATING_TO_ESTIMATE_COMPUTE_LIMIT,
343
+ TRANSACTION__FAILED_WHEN_SIMULATING_TO_ESTIMATE_RESOURCE_LIMITS
344
+ ].freeze,
345
+ T::Array[Symbol]
346
+ )
347
+
348
+ # The JSON-RPC error code of a `sendTransaction` whose preflight simulation failed.
349
+ JSON_RPC_SEND_TRANSACTION_PREFLIGHT_FAILURE = T.let(-32_002, Integer)
350
+
351
+ # Returns true if +error+ was raised because a transaction simulation failed.
352
+ #
353
+ # `Rpc::Client#send_transaction` reports a preflight failure as an `Rpc::RpcError`
354
+ # rather than a SolanaError, so that counts too; its `data` holds the simulation
355
+ # result (`data['logs']`). A SolanaError holds it in `context` (`context[:logs]`).
356
+ #
357
+ # Mirrors `isSimulationError()` from @solana/errors.
358
+ sig { params(error: T.untyped).returns(T::Boolean) }
359
+ def self.simulation_error?(error)
360
+ return SIMULATION_ERROR_CODES.include?(error.code) if error.is_a?(SolanaError)
361
+ return false unless defined?(Rpc::RpcError) && error.is_a?(Rpc::RpcError)
362
+
363
+ error.code == JSON_RPC_SEND_TRANSACTION_PREFLIGHT_FAILURE
364
+ end
365
+
333
366
  sig { returns(Symbol) }
334
367
  attr_reader :code
335
368
 
@@ -72,7 +72,8 @@ module Solana::Ruby::Kit
72
72
 
73
73
  if transaction_error
74
74
  Kernel.raise SolanaError.new(
75
- SolanaError::TRANSACTION__FAILED_WHEN_SIMULATING_TO_ESTIMATE_RESOURCE_LIMITS
75
+ SolanaError::TRANSACTION__FAILED_WHEN_SIMULATING_TO_ESTIMATE_RESOURCE_LIMITS,
76
+ simulation_result_context(sim_value)
76
77
  )
77
78
  end
78
79
 
@@ -152,5 +153,14 @@ module Solana::Ruby::Kit
152
153
  end
153
154
  result
154
155
  end
156
+
157
+ # The simulation result as a SolanaError context: `unitsConsumed` becomes
158
+ # `:units_consumed`, and so on. Upstream moves `err` onto the error's `cause` via a
159
+ # transaction error translator Ruby does not have, so here it stays in the context.
160
+ sig { params(sim_value: T::Hash[String, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
161
+ def simulation_result_context(sim_value)
162
+ sim_value.to_h { |key, value| [key.gsub(/[A-Z]/) { |c| "_#{c.downcase}" }.to_sym, value] }
163
+ end
164
+ private_class_method :simulation_result_context
155
165
  end
156
166
  end
@@ -9,6 +9,10 @@ module Solana::Ruby::Kit
9
9
  # Simulate a transaction without broadcasting it.
10
10
  # Mirrors TypeScript's SimulateTransactionApi.simulateTransaction.
11
11
  # Returns an RpcContextualValue whose value is the simulation result hash.
12
+ #
13
+ # +inner_instructions_encoding+ is `'json'` (compiled form) or `'jsonParsed'`, and has
14
+ # no effect unless +inner_instructions+ is true. Servers older than Agave 4.5 ignore
15
+ # it and always return `'jsonParsed'`, so callers must handle either shape.
12
16
  module SimulateTransaction
13
17
  extend T::Sig
14
18
  extend T::Helpers
@@ -22,7 +26,10 @@ module Solana::Ruby::Kit
22
26
  commitment: T.nilable(Symbol),
23
27
  sig_verify: T::Boolean,
24
28
  replace_recent_blockhash: T::Boolean,
25
- accounts: T.nilable(T::Hash[String, T.untyped])
29
+ accounts: T.nilable(T::Hash[String, T.untyped]),
30
+ min_context_slot: T.nilable(Integer),
31
+ inner_instructions: T::Boolean,
32
+ inner_instructions_encoding: T.nilable(String)
26
33
  ).returns(RpcTypes::RpcContextualValue)
27
34
  end
28
35
  def simulate_transaction(
@@ -31,7 +38,10 @@ module Solana::Ruby::Kit
31
38
  commitment: nil,
32
39
  sig_verify: false,
33
40
  replace_recent_blockhash: false,
34
- accounts: nil
41
+ accounts: nil,
42
+ min_context_slot: nil,
43
+ inner_instructions: false,
44
+ inner_instructions_encoding: nil
35
45
  )
36
46
  config = {
37
47
  'encoding' => encoding,
@@ -40,6 +50,9 @@ module Solana::Ruby::Kit
40
50
  }
41
51
  config['commitment'] = commitment.to_s if commitment
42
52
  config['accounts'] = accounts if accounts
53
+ config['minContextSlot'] = min_context_slot if min_context_slot
54
+ config['innerInstructions'] = true if inner_instructions
55
+ config['innerInstructionsEncoding'] = inner_instructions_encoding if inner_instructions_encoding
43
56
 
44
57
  result = transport.request('simulateTransaction', [encoded_transaction, config])
45
58
  slot = Kernel.Integer(result['context']['slot'])
@@ -80,18 +80,36 @@ module Solana::Ruby::Kit
80
80
  # @param cluster_url [RpcTypes::ClusterUrl, String] endpoint to connect to.
81
81
  # @param headers [Hash<String, String>] additional HTTP headers.
82
82
  # @param timeout [Integer] read timeout in seconds.
83
+ # @param retry_policy [RetryPolicy] transient-failure retries; `RetryPolicy.none` disables.
84
+ # @param sleeper [#call] called with the wait in seconds between retries.
83
85
  sig do
84
86
  params(
85
87
  cluster_url: T.untyped, # RpcTypes::ClusterUrl or String
86
88
  headers: T::Hash[String, String],
87
89
  timeout: Integer,
88
- open_timeout: Integer
90
+ open_timeout: Integer,
91
+ retry_policy: RetryPolicy,
92
+ sleeper: T.any(Proc, Method)
89
93
  ).void
90
94
  end
91
- def initialize(cluster_url, headers: {}, timeout: Transport::DEFAULT_TIMEOUT, open_timeout: Transport::DEFAULT_OPEN_TIMEOUT)
95
+ def initialize(
96
+ cluster_url,
97
+ headers: {},
98
+ timeout: Transport::DEFAULT_TIMEOUT,
99
+ open_timeout: Transport::DEFAULT_OPEN_TIMEOUT,
100
+ retry_policy: RetryPolicy.new,
101
+ sleeper: Kernel.method(:sleep)
102
+ )
92
103
  url = cluster_url.respond_to?(:url) ? cluster_url.url : cluster_url.to_s
93
104
  @transport = T.let(
94
- Transport.new(url: url, headers: headers, timeout: timeout, open_timeout: open_timeout),
105
+ Transport.new(
106
+ url: url,
107
+ headers: headers,
108
+ timeout: timeout,
109
+ open_timeout: open_timeout,
110
+ retry_policy: retry_policy,
111
+ sleeper: sleeper
112
+ ),
95
113
  Transport
96
114
  )
97
115
  end
@@ -0,0 +1,149 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'net/http'
5
+ require 'openssl'
6
+ require 'socket'
7
+ require 'time'
8
+
9
+ module Solana::Ruby::Kit
10
+ module Rpc
11
+ # Decides whether a failed JSON-RPC POST is retried, and how long to wait first.
12
+ # Ruby-only: upstream's `createHttpTransport` has no retry.
13
+ #
14
+ # Errors are classified by whether the request could have reached the node:
15
+ #
16
+ # - Connect-phase errors (`CONNECT_ERRORS`) are retried for every method — the
17
+ # request bytes never left the machine, so nothing can have been executed twice.
18
+ # - In-flight errors (`IN_FLIGHT_ERRORS`) and HTTP 429/502/503/504 are retried
19
+ # only for methods not listed in `non_idempotent`: the node may already have
20
+ # acted on the request.
21
+ # - JSON-RPC error objects (`RpcError`) are never retried — the node answered.
22
+ #
23
+ # Backoff is full-jitter exponential: before retry n (1-based) the transport
24
+ # waits `rand * min(base_delay * 2**(n - 1), max_delay)`. A `Retry-After` header
25
+ # overrides it, capped at `max_delay`.
26
+ class RetryPolicy
27
+ extend T::Sig
28
+
29
+ CONNECT_ERRORS = T.let(
30
+ [Errno::ENETUNREACH, Errno::EHOSTUNREACH, Errno::ECONNREFUSED, Net::OpenTimeout, SocketError].freeze,
31
+ T::Array[T.class_of(Exception)]
32
+ )
33
+
34
+ IN_FLIGHT_ERRORS = T.let(
35
+ [Errno::ECONNRESET, Errno::EPIPE, EOFError, Net::ReadTimeout, Net::WriteTimeout, OpenSSL::SSL::SSLError].freeze,
36
+ T::Array[T.class_of(Exception)]
37
+ )
38
+
39
+ RETRYABLE_HTTP_STATUSES = T.let([429, 502, 503, 504].freeze, T::Array[Integer])
40
+
41
+ DEFAULT_NON_IDEMPOTENT = T.let(['requestAirdrop'].freeze, T::Array[String])
42
+
43
+ sig { returns(Integer) }
44
+ attr_reader :max_attempts
45
+
46
+ sig { returns(Float) }
47
+ attr_reader :base_delay
48
+
49
+ sig { returns(Float) }
50
+ attr_reader :max_delay
51
+
52
+ sig { returns(T::Array[String]) }
53
+ attr_reader :non_idempotent
54
+
55
+ # Called before each retry's sleep as `on_retry.call(method, attempt, error, wait)`,
56
+ # where `attempt` is the 1-based number of the attempt that just failed.
57
+ sig { returns(T.nilable(T.any(Proc, Method))) }
58
+ attr_reader :on_retry
59
+
60
+ # A policy that never retries.
61
+ sig { returns(RetryPolicy) }
62
+ def self.none
63
+ new(max_attempts: 1)
64
+ end
65
+
66
+ sig do
67
+ params(
68
+ max_attempts: Integer,
69
+ base_delay: Numeric,
70
+ max_delay: Numeric,
71
+ non_idempotent: T::Array[String],
72
+ on_retry: T.nilable(T.any(Proc, Method)),
73
+ random: Random
74
+ ).void
75
+ end
76
+ def initialize(
77
+ max_attempts: 5,
78
+ base_delay: 0.25,
79
+ max_delay: 8.0,
80
+ non_idempotent: DEFAULT_NON_IDEMPOTENT,
81
+ on_retry: nil,
82
+ random: Random.new
83
+ )
84
+ raise ArgumentError, 'max_attempts must be at least 1' if max_attempts < 1
85
+
86
+ @max_attempts = T.let(max_attempts, Integer)
87
+ @base_delay = T.let(base_delay.to_f, Float)
88
+ @max_delay = T.let(max_delay.to_f, Float)
89
+ @non_idempotent = T.let(non_idempotent.dup.freeze, T::Array[String])
90
+ @on_retry = T.let(on_retry, T.nilable(T.any(Proc, Method)))
91
+ @random = T.let(random, Random)
92
+ end
93
+
94
+ # Whether attempt number `attempt` (1-based) of `method`, having failed with
95
+ # `error`, should be followed by another.
96
+ sig { params(method: String, error: Exception, attempt: Integer).returns(T::Boolean) }
97
+ def retry?(method, error, attempt)
98
+ attempt < @max_attempts && retryable?(method, error)
99
+ end
100
+
101
+ # Whether `error` is retryable for `method` at all, ignoring the attempt budget.
102
+ sig { params(method: String, error: Exception).returns(T::Boolean) }
103
+ def retryable?(method, error)
104
+ return true if connect_error?(error)
105
+ return false unless idempotent?(method)
106
+
107
+ case error
108
+ when HttpTransportError then RETRYABLE_HTTP_STATUSES.include?(error.status_code)
109
+ else IN_FLIGHT_ERRORS.any? { |klass| error.is_a?(klass) }
110
+ end
111
+ end
112
+
113
+ sig { params(method: String).returns(T::Boolean) }
114
+ def idempotent?(method)
115
+ !@non_idempotent.include?(method)
116
+ end
117
+
118
+ # Seconds to wait after attempt number `attempt` (1-based) failed.
119
+ # `retry_after` is the raw `Retry-After` header value, if any: delta-seconds
120
+ # or an HTTP-date. An unparseable value falls back to the backoff.
121
+ sig { params(attempt: Integer, retry_after: T.nilable(String)).returns(Float) }
122
+ def delay(attempt, retry_after: nil)
123
+ server_wait = parse_retry_after(retry_after)
124
+ return [server_wait, @max_delay].min if server_wait
125
+
126
+ @random.rand(1.0) * [@base_delay * (2.0**(attempt - 1)), @max_delay].min
127
+ end
128
+
129
+ private
130
+
131
+ sig { params(error: Exception).returns(T::Boolean) }
132
+ def connect_error?(error)
133
+ CONNECT_ERRORS.any? { |klass| error.is_a?(klass) }
134
+ end
135
+
136
+ sig { params(value: T.nilable(String)).returns(T.nilable(Float)) }
137
+ def parse_retry_after(value)
138
+ return nil if value.nil?
139
+
140
+ stripped = value.strip
141
+ return [stripped.to_f, 0.0].max if stripped.match?(/\A\d+(\.\d+)?\z/)
142
+
143
+ [Time.httpdate(stripped) - Time.now, 0.0].max
144
+ rescue ArgumentError
145
+ nil
146
+ end
147
+ end
148
+ end
149
+ end
@@ -5,6 +5,7 @@ require 'net/http'
5
5
  require 'json'
6
6
  require 'uri'
7
7
  require_relative '../errors'
8
+ require_relative 'retry_policy'
8
9
 
9
10
  module Solana::Ruby::Kit
10
11
  module Rpc
@@ -33,9 +34,14 @@ module Solana::Ruby::Kit
33
34
  sig { returns(Integer) }
34
35
  attr_reader :status_code
35
36
 
36
- sig { params(status_code: Integer, message: String).void }
37
- def initialize(status_code, message)
37
+ # Raw `Retry-After` header value, when the response carried one.
38
+ sig { returns(T.nilable(String)) }
39
+ attr_reader :retry_after
40
+
41
+ sig { params(status_code: Integer, message: String, retry_after: T.nilable(String)).void }
42
+ def initialize(status_code, message, retry_after: nil)
38
43
  @status_code = T.let(status_code, Integer)
44
+ @retry_after = T.let(retry_after, T.nilable(String))
39
45
  super("HTTP #{status_code}: #{message}")
40
46
  end
41
47
  end
@@ -45,6 +51,14 @@ module Solana::Ruby::Kit
45
51
  #
46
52
  # Makes synchronous POST requests — TypeScript's async `fetch` maps to
47
53
  # Ruby's blocking `Net::HTTP`. Use threads or Fibers for concurrency.
54
+ #
55
+ # Transient failures are retried according to `retry_policy` (see `RetryPolicy`;
56
+ # on by default, `RetryPolicy.none` disables it). The payload is built once, so
57
+ # every attempt sends the same bytes with the same JSON-RPC `id` — the transport
58
+ # never re-signs a transaction or refetches a blockhash.
59
+ #
60
+ # Each attempt opens its own connection (no keep-alive), so a Transport can be
61
+ # shared across threads and a retry never reuses a dead socket.
48
62
  class Transport
49
63
  extend T::Sig
50
64
 
@@ -61,7 +75,9 @@ module Solana::Ruby::Kit
61
75
  timeout: Integer,
62
76
  open_timeout: Integer,
63
77
  to_json: T.nilable(T.proc.params(arg0: T.untyped).returns(String)),
64
- from_json: T.nilable(T.proc.params(arg0: String, arg1: T.untyped).returns(T.untyped))
78
+ from_json: T.nilable(T.proc.params(arg0: String, arg1: T.untyped).returns(T.untyped)),
79
+ retry_policy: RetryPolicy,
80
+ sleeper: T.any(Proc, Method)
65
81
  ).void
66
82
  end
67
83
  def initialize(
@@ -70,7 +86,9 @@ module Solana::Ruby::Kit
70
86
  timeout: DEFAULT_TIMEOUT,
71
87
  open_timeout: DEFAULT_OPEN_TIMEOUT,
72
88
  to_json: nil,
73
- from_json: nil
89
+ from_json: nil,
90
+ retry_policy: RetryPolicy.new,
91
+ sleeper: Kernel.method(:sleep)
74
92
  )
75
93
  @url = T.let(url, String)
76
94
  @headers = T.let(headers, T::Hash[String, String])
@@ -80,8 +98,13 @@ module Solana::Ruby::Kit
80
98
  @from_json = T.let(from_json, T.nilable(T.proc.params(arg0: String, arg1: T.untyped).returns(T.untyped)))
81
99
  @request_id = T.let(0, Integer)
82
100
  @uri = T.let(URI.parse(url), URI::Generic)
101
+ @retry_policy = T.let(retry_policy, RetryPolicy)
102
+ @sleeper = T.let(sleeper, T.any(Proc, Method))
83
103
  end
84
104
 
105
+ sig { returns(RetryPolicy) }
106
+ attr_reader :retry_policy
107
+
85
108
  # Sends a single JSON-RPC request and returns the parsed `result`.
86
109
  # Raises `RpcError` on a JSON-RPC error, `HttpTransportError` on HTTP failure.
87
110
  sig { params(method: String, params: T::Array[T.untyped]).returns(T.untyped) }
@@ -89,7 +112,7 @@ module Solana::Ruby::Kit
89
112
  payload = build_payload(method, params)
90
113
  body = @to_json ? @to_json.call(payload) : JSON.generate(payload)
91
114
 
92
- response = post(body)
115
+ response = post_with_retry(method, body)
93
116
 
94
117
  parsed = @from_json ? @from_json.call(T.must(response.body), payload) : JSON.parse(T.must(response.body))
95
118
 
@@ -109,6 +132,32 @@ module Solana::Ruby::Kit
109
132
  { 'jsonrpc' => '2.0', 'id' => @request_id, 'method' => method, 'params' => params }
110
133
  end
111
134
 
135
+ # Posts `body` until it succeeds, the error is not retryable for `method`, or
136
+ # the policy's attempts run out — then re-raises the last error unchanged.
137
+ sig { params(method: String, body: String).returns(Net::HTTPResponse) }
138
+ def post_with_retry(method, body)
139
+ attempt = 0
140
+ begin
141
+ attempt += 1
142
+ post(body)
143
+ rescue StandardError => e
144
+ reset_connection unless e.is_a?(HttpTransportError)
145
+ raise unless @retry_policy.retry?(method, e, attempt)
146
+
147
+ retry_after = e.is_a?(HttpTransportError) ? e.retry_after : nil
148
+ wait = @retry_policy.delay(attempt, retry_after: retry_after)
149
+ @retry_policy.on_retry&.call(method, attempt, e, wait)
150
+ @sleeper.call(wait)
151
+ retry
152
+ end
153
+ end
154
+
155
+ # Called after every socket-level error, before any retry. Nothing to do
156
+ # while each attempt opens a fresh `Net::HTTP`; a keep-alive connection
157
+ # would be closed here so the retry could not reuse a dead socket.
158
+ sig { void }
159
+ def reset_connection; end
160
+
112
161
  sig { params(body: String).returns(Net::HTTPResponse) }
113
162
  def post(body)
114
163
  http = Net::HTTP.new(@uri.host, @uri.port)
@@ -126,7 +175,7 @@ module Solana::Ruby::Kit
126
175
  response = http.request(req)
127
176
 
128
177
  unless response.is_a?(Net::HTTPSuccess)
129
- raise HttpTransportError.new(response.code.to_i, response.message)
178
+ raise HttpTransportError.new(response.code.to_i, response.message, retry_after: response['Retry-After'])
130
179
  end
131
180
 
132
181
  response
@@ -63,7 +63,9 @@ module Solana::Ruby::Kit
63
63
  _send(payload)
64
64
 
65
65
  response = q.pop
66
- raise Rpc::RpcError.new(response['error']['message'], response['error']['code']) if response['error']
66
+ if (err = response['error'])
67
+ raise Rpc::RpcError.new(err['code'].to_i, err['message'].to_s, err['data'])
68
+ end
67
69
 
68
70
  response['result']
69
71
  end
@@ -3,6 +3,7 @@
3
3
 
4
4
  require_relative '../addresses/address'
5
5
  require_relative '../errors'
6
+ require_relative '../transaction_messages/v1_transaction_config'
6
7
 
7
8
  module Solana::Ruby::Kit
8
9
  module TransactionIntrospection
@@ -37,6 +38,9 @@ module Solana::Ruby::Kit
37
38
  const :static_accounts, T::Array[Addresses::Address]
38
39
  const :instructions, T::Array[CompiledInstruction]
39
40
  const :lifetime_token, String # base58-encoded blockhash or nonce value
41
+ # Version 1 only; see TransactionMessages.decompile_transaction_config.
42
+ const :config_mask, Integer, default: 0
43
+ const :config_values, T::Array[TransactionMessages::CompiledTransactionConfigValue], default: []
40
44
  end
41
45
 
42
46
  extend self
@@ -155,6 +155,9 @@ module Solana::Ruby::Kit
155
155
  version = rpc_tx['version'] || :legacy
156
156
  version = :legacy if version == 'legacy'
157
157
 
158
+ # Only a v1 message has a config; `transactionConfig` is absent otherwise.
159
+ config = get_v1_transaction_config(version == 1 ? message['transactionConfig'] : nil)
160
+
158
161
  compiled_message = CompiledTransactionMessage.new(
159
162
  version: version,
160
163
  header: header,
@@ -162,7 +165,9 @@ module Solana::Ruby::Kit
162
165
  instructions: instructions,
163
166
  # For durable-nonce transactions, `recentBlockhash` is the nonce value, not a
164
167
  # blockhash — either way it is the message's lifetime token.
165
- lifetime_token: message['recentBlockhash']
168
+ lifetime_token: message['recentBlockhash'],
169
+ config_mask: TransactionMessages.get_transaction_config_mask(config),
170
+ config_values: TransactionMessages.get_transaction_config_values(config)
166
171
  )
167
172
 
168
173
  DecodedRpcTransaction.new(
@@ -172,5 +177,18 @@ module Solana::Ruby::Kit
172
177
  )
173
178
  end
174
179
  private_class_method :decode_from_json
180
+
181
+ # The RPC names the priority fee `priorityFee`, and reports an unset field as null.
182
+ sig { params(transaction_config: T.nilable(T::Hash[String, T.untyped])).returns(TransactionMessages::V1TransactionConfig) }
183
+ def get_v1_transaction_config(transaction_config)
184
+ config = transaction_config || {}
185
+ TransactionMessages::V1TransactionConfig.new(
186
+ compute_unit_limit: config['computeUnitLimit'],
187
+ heap_size: config['heapSize'],
188
+ loaded_accounts_data_size_limit: config['loadedAccountsDataSizeLimit'],
189
+ priority_fee_lamports: config['priorityFee']
190
+ )
191
+ end
192
+ private_class_method :get_v1_transaction_config
175
193
  end
176
194
  end
@@ -0,0 +1,132 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../errors'
5
+
6
+ module Solana::Ruby::Kit
7
+ module TransactionMessages
8
+ # Set in a config mask when a priority fee is present. Both bits must match:
9
+ # a mask with only one of them set is invalid.
10
+ TRANSACTION_CONFIG_PRIORITY_FEE_LAMPORTS_BIT_MASK = T.let(0b11, Integer)
11
+ TRANSACTION_CONFIG_COMPUTE_UNIT_LIMIT_BIT_MASK = T.let(0b100, Integer)
12
+ TRANSACTION_CONFIG_LOADED_ACCOUNTS_DATA_SIZE_LIMIT_BIT_MASK = T.let(0b1000, Integer)
13
+ TRANSACTION_CONFIG_HEAP_SIZE_BIT_MASK = T.let(0b10000, Integer)
14
+
15
+ # The resource limits and priority fee of a version 1 transaction message.
16
+ # A nil field is absent from the config; 0 is a set value.
17
+ #
18
+ # Mirrors `V1TransactionConfig` from @solana/transaction-messages.
19
+ class V1TransactionConfig < T::Struct
20
+ const :compute_unit_limit, T.nilable(Integer)
21
+ const :heap_size, T.nilable(Integer)
22
+ const :loaded_accounts_data_size_limit, T.nilable(Integer)
23
+ const :priority_fee_lamports, T.nilable(Integer)
24
+ end
25
+
26
+ # A single entry of a compiled message's `config_values`, tagged with the
27
+ # width it is encoded with: `:u64` for the priority fee, `:u32` otherwise.
28
+ #
29
+ # Mirrors `CompiledTransactionConfigValue` (`kind` is a Symbol here, where
30
+ # upstream uses the strings `'u32'` / `'u64'`).
31
+ class CompiledTransactionConfigValue < T::Struct
32
+ const :kind, Symbol
33
+ const :value, Integer
34
+ end
35
+
36
+ extend self
37
+
38
+ # Returns true if the mask flags a priority fee. Raises
39
+ # TRANSACTION__INVALID_CONFIG_MASK_PRIORITY_FEE_BITS if only one of its two bits is set.
40
+ # Mirrors `transactionConfigMaskHasPriorityFee()`.
41
+ sig { params(mask: Integer).returns(T::Boolean) }
42
+ def transaction_config_mask_has_priority_fee?(mask)
43
+ bits = mask & TRANSACTION_CONFIG_PRIORITY_FEE_LAMPORTS_BIT_MASK
44
+ if [0b01, 0b10].include?(bits)
45
+ Kernel.raise SolanaError.new(SolanaError::TRANSACTION__INVALID_CONFIG_MASK_PRIORITY_FEE_BITS, { mask: mask })
46
+ end
47
+
48
+ bits == TRANSACTION_CONFIG_PRIORITY_FEE_LAMPORTS_BIT_MASK
49
+ end
50
+
51
+ # Mirrors `transactionConfigMaskHasComputeUnitLimit()`.
52
+ sig { params(mask: Integer).returns(T::Boolean) }
53
+ def transaction_config_mask_has_compute_unit_limit?(mask)
54
+ (mask & TRANSACTION_CONFIG_COMPUTE_UNIT_LIMIT_BIT_MASK) != 0
55
+ end
56
+
57
+ # Mirrors `transactionConfigMaskHasLoadedAccountsDataSizeLimit()`.
58
+ sig { params(mask: Integer).returns(T::Boolean) }
59
+ def transaction_config_mask_has_loaded_accounts_data_size_limit?(mask)
60
+ (mask & TRANSACTION_CONFIG_LOADED_ACCOUNTS_DATA_SIZE_LIMIT_BIT_MASK) != 0
61
+ end
62
+
63
+ # Mirrors `transactionConfigMaskHasHeapSize()`.
64
+ sig { params(mask: Integer).returns(T::Boolean) }
65
+ def transaction_config_mask_has_heap_size?(mask)
66
+ (mask & TRANSACTION_CONFIG_HEAP_SIZE_BIT_MASK) != 0
67
+ end
68
+
69
+ # Computes the `config_mask` of a compiled version 1 message.
70
+ # Mirrors `getTransactionConfigMask()`.
71
+ sig { params(config: V1TransactionConfig).returns(Integer) }
72
+ def get_transaction_config_mask(config)
73
+ mask = 0
74
+ mask |= TRANSACTION_CONFIG_PRIORITY_FEE_LAMPORTS_BIT_MASK unless config.priority_fee_lamports.nil?
75
+ mask |= TRANSACTION_CONFIG_COMPUTE_UNIT_LIMIT_BIT_MASK unless config.compute_unit_limit.nil?
76
+ mask |= TRANSACTION_CONFIG_LOADED_ACCOUNTS_DATA_SIZE_LIMIT_BIT_MASK unless config.loaded_accounts_data_size_limit.nil?
77
+ mask |= TRANSACTION_CONFIG_HEAP_SIZE_BIT_MASK unless config.heap_size.nil?
78
+ mask
79
+ end
80
+
81
+ # Computes the `config_values` of a compiled version 1 message: the set
82
+ # fields, in the order their bits appear in the mask.
83
+ # Mirrors `getTransactionConfigValues()`.
84
+ sig { params(config: V1TransactionConfig).returns(T::Array[CompiledTransactionConfigValue]) }
85
+ def get_transaction_config_values(config)
86
+ [
87
+ [:u64, config.priority_fee_lamports],
88
+ [:u32, config.compute_unit_limit],
89
+ [:u32, config.loaded_accounts_data_size_limit],
90
+ [:u32, config.heap_size]
91
+ ].filter_map do |kind, value|
92
+ value.nil? ? nil : CompiledTransactionConfigValue.new(kind: kind, value: value)
93
+ end
94
+ end
95
+
96
+ # Decodes a compiled message's `config_mask` and `config_values` back into a
97
+ # V1TransactionConfig holding only the fields present in the mask.
98
+ #
99
+ # Raises TRANSACTION__INVALID_CONFIG_MASK_PRIORITY_FEE_BITS if only one of the
100
+ # priority fee bits is set, and TRANSACTION__INVALID_CONFIG_VALUE_KIND if a
101
+ # value's kind does not match its field.
102
+ #
103
+ # Mirrors `decompileTransactionConfig()`.
104
+ sig do
105
+ params(config_mask: Integer, config_values: T::Array[CompiledTransactionConfigValue])
106
+ .returns(V1TransactionConfig)
107
+ end
108
+ def decompile_transaction_config(config_mask, config_values)
109
+ fields = [
110
+ [:priority_fee_lamports, :u64, transaction_config_mask_has_priority_fee?(config_mask)],
111
+ [:compute_unit_limit, :u32, transaction_config_mask_has_compute_unit_limit?(config_mask)],
112
+ [:loaded_accounts_data_size_limit, :u32, transaction_config_mask_has_loaded_accounts_data_size_limit?(config_mask)],
113
+ [:heap_size, :u32, transaction_config_mask_has_heap_size?(config_mask)]
114
+ ]
115
+
116
+ present = fields.select { |_name, _kind, set| set }
117
+ config = present.each_with_index.to_h do |(name, kind, _set), index|
118
+ value = T.must(config_values[index])
119
+ unless value.kind == kind
120
+ Kernel.raise SolanaError.new(
121
+ SolanaError::TRANSACTION__INVALID_CONFIG_VALUE_KIND,
122
+ { actual_kind: value.kind, config_name: name, expected_kind: kind }
123
+ )
124
+ end
125
+
126
+ [name, value.value]
127
+ end
128
+
129
+ V1TransactionConfig.new(**T.unsafe(config))
130
+ end
131
+ end
132
+ end
@@ -5,3 +5,4 @@
5
5
  require_relative 'transaction_messages/transaction_message'
6
6
  require_relative 'transaction_messages/resource_limit_validation'
7
7
  require_relative 'transaction_messages/compute_budget'
8
+ require_relative 'transaction_messages/v1_transaction_config'
@@ -9,8 +9,17 @@ require_relative '../errors'
9
9
 
10
10
  module Solana::Ruby::Kit
11
11
  module Transactions
12
- # Maximum wire-encoded transaction size in bytes (Solana protocol limit).
13
- TRANSACTION_SIZE_LIMIT = T.let(1232, Integer)
12
+ # The maximum size of a legacy (and v0) transaction in bytes.
13
+ # Mirrors `LEGACY_TRANSACTION_SIZE_LIMIT` from @solana/transactions.
14
+ LEGACY_TRANSACTION_SIZE_LIMIT = T.let(1232, Integer)
15
+
16
+ # The maximum size of a version 1 transaction in bytes. Nothing in Ruby compiles
17
+ # v1 transactions yet, so no size check here applies it.
18
+ # Mirrors `V1_TRANSACTION_SIZE_LIMIT` from @solana/transactions.
19
+ V1_TRANSACTION_SIZE_LIMIT = T.let(4096, Integer)
20
+
21
+ # The limit Ruby enforces: every transaction it can compile is legacy-sized.
22
+ TRANSACTION_SIZE_LIMIT = T.let(LEGACY_TRANSACTION_SIZE_LIMIT, Integer)
14
23
 
15
24
  extend self
16
25
 
@@ -4,7 +4,7 @@
4
4
  module Solana
5
5
  module Ruby
6
6
  module Kit
7
- VERSION = '8.5.0'
7
+ VERSION = '8.6.0'
8
8
  end
9
9
  end
10
10
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solana-ruby-kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 8.5.0
4
+ version: 8.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Paul Zupan, Idhra Inc.
@@ -289,6 +289,7 @@ files:
289
289
  - lib/solana/ruby/kit/rpc/api/send_transaction.rb
290
290
  - lib/solana/ruby/kit/rpc/api/simulate_transaction.rb
291
291
  - lib/solana/ruby/kit/rpc/client.rb
292
+ - lib/solana/ruby/kit/rpc/retry_policy.rb
292
293
  - lib/solana/ruby/kit/rpc/transport.rb
293
294
  - lib/solana/ruby/kit/rpc_parsed_types.rb
294
295
  - lib/solana/ruby/kit/rpc_parsed_types/address_lookup_table.rb
@@ -341,6 +342,7 @@ files:
341
342
  - lib/solana/ruby/kit/transaction_messages/compute_budget.rb
342
343
  - lib/solana/ruby/kit/transaction_messages/resource_limit_validation.rb
343
344
  - lib/solana/ruby/kit/transaction_messages/transaction_message.rb
345
+ - lib/solana/ruby/kit/transaction_messages/v1_transaction_config.rb
344
346
  - lib/solana/ruby/kit/transactions.rb
345
347
  - lib/solana/ruby/kit/transactions/compiler.rb
346
348
  - lib/solana/ruby/kit/transactions/transaction.rb