solana-ruby-kit 8.4.0 → 8.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 874d4a76636d15a05ab0474d28d1ea069b1a8c1d50b18d9178a9f0639747fd92
4
- data.tar.gz: 28cfc822396c2c7156daa6da853e25be997b4febbcbdec66e71aa687ad05c782
3
+ metadata.gz: 727cac096a118ea677195240a8ea3440f593b1f7930b0f0f603ae8156b66862a
4
+ data.tar.gz: e8a03418fa257b58b243933a9fe6c5918c7882f1bdfe47ad49f87bbd63a95a28
5
5
  SHA512:
6
- metadata.gz: ca743643e0257eadc50274add4ce4f843dcb19f4c63ca9a4c1b4e9ebf3310e02945eee1d6b5cfe2d91fb153452e8d03ea6a44b6becad3a210c8f92f04350c2f5
7
- data.tar.gz: 162cdeb79aee32775b4e35bc1fb80be095e11cb65f15217c3b93076de5172c76181ed94bb26b7d3b16a7b65394a28676b352cca03cee16ecb62ac96ab218b188
6
+ metadata.gz: 7fdcd2a35bde1be1eba49bf48f373d5bf701cd399c23f54f1bc53c4a7fdf8a21c4b3f478079be2a57095bd91aff7eb736a31fa047d357231bbcb3410bbaa6ad6
7
+ data.tar.gz: 890775283c250e98d91ab31263afd4b5c6f3fd093ae1c96422623d9c63716deb69a5376537b11811fd9deeb0961445d319f69086a985e41ee5d800d7f7bdb008
data/README.md CHANGED
@@ -133,6 +133,9 @@ kp = Keys.create_key_pair_from_private_key_bytes(seed_bytes)
133
133
  # Sign / verify
134
134
  sig = Keys.sign_bytes(kp.signing_key, data)
135
135
  ok = Keys.verify_signature(kp.verify_key, sig, data)
136
+ # verify_signature is strict: a small-order public key or signature R
137
+ # component returns false (as the Solana runtime does), and passing anything
138
+ # other than an RbNaCl::VerifyKey raises KEYS__VERIFICATION_REQUIRES_EXTRACTABLE_PUBLIC_KEY.
136
139
 
137
140
  # Base58-encode a signature
138
141
  Keys.encode_signature(sig) # => Signature string
@@ -296,8 +299,54 @@ rescue Solana::Ruby::Kit::Rpc::RpcError => e
296
299
  puts e.message # JSON-RPC error message
297
300
  rescue Solana::Ruby::Kit::Rpc::HttpTransportError => e
298
301
  puts e.status_code # HTTP status code
302
+ puts e.retry_after # raw Retry-After header, or nil
299
303
  ```
300
304
 
305
+ #### Retries
306
+
307
+ Transient failures are retried automatically, for every RPC method, with
308
+ full-jitter exponential backoff. Defaults: `max_attempts: 5`, `base_delay: 0.25`,
309
+ `max_delay: 8.0` (seconds). The wait before retry *n* is
310
+ `rand * min(base_delay * 2**(n - 1), max_delay)`; a `Retry-After` header
311
+ overrides it (still capped at `max_delay`).
312
+
313
+ What gets retried depends on whether the request could have reached the node:
314
+
315
+ | Failure | Retried for |
316
+ |---|---|
317
+ | Connect-phase: `Errno::ENETUNREACH`, `EHOSTUNREACH`, `ECONNREFUSED`, `Net::OpenTimeout`, `SocketError` | every method — the bytes never left the machine |
318
+ | In-flight: `Errno::ECONNRESET`, `EPIPE`, `EOFError`, `Net::ReadTimeout`, `Net::WriteTimeout`, `OpenSSL::SSL::SSLError` | idempotent methods only |
319
+ | HTTP 429 / 502 / 503 / 504 | idempotent methods only |
320
+ | JSON-RPC error in a 200 response (`RpcError`) | never — the node answered |
321
+
322
+ Every attempt resends the same bytes with the same JSON-RPC `id`; nothing is
323
+ re-signed and no blockhash is refetched. Resending an identical signed
324
+ `sendTransaction` is safe because the cluster dedupes by signature. When
325
+ retries run out, the original error is re-raised.
326
+
327
+ **`requestAirdrop` is not idempotent**: each accepted request is a new airdrop,
328
+ so it is retried only on connect-phase errors. Add any of your own
329
+ non-idempotent methods to `non_idempotent:`.
330
+
331
+ ```ruby
332
+ # Customize, with logging
333
+ policy = Solana::Ruby::Kit::Rpc::RetryPolicy.new(
334
+ max_attempts: 8,
335
+ max_delay: 15.0,
336
+ on_retry: ->(method, attempt, error, wait) {
337
+ logger.warn("#{method} attempt #{attempt} failed (#{error.class}: #{error.message}); retrying in #{wait.round(2)}s")
338
+ }
339
+ )
340
+ rpc = Solana::Ruby::Kit::Rpc::Client.new(Solana::Ruby::Kit::RpcTypes.devnet, retry_policy: policy)
341
+
342
+ # Disable
343
+ rpc = Solana::Ruby::Kit::Rpc::Client.new(url, retry_policy: Solana::Ruby::Kit::Rpc::RetryPolicy.none)
344
+ ```
345
+
346
+ `Rpc::Transport.new` takes the same `retry_policy:`, plus `sleeper:` (a
347
+ callable receiving the wait in seconds, `Kernel.method(:sleep)` by default) so
348
+ tests need not actually wait. The WebSocket subscriptions client is not covered.
349
+
301
350
  ### `Solana::Ruby::Kit::RpcTypes` — `@solana/rpc-types`
302
351
 
303
352
  Cluster URL helpers and typed wrappers.
@@ -477,6 +526,11 @@ may *begin* with the sentinel's bytes, or decoding stops early at that item.
477
526
  Second, under `:optional` / `:omitted` the sentinel must be no wider than the
478
527
  smallest item, or a short trailing item is silently dropped.
479
528
 
529
+ An item codec with a fixed size of zero (e.g. `struct_codec([])`) can never
530
+ reach a sentinel or the end of the bytes, so pairing one with a `SentinelSize`
531
+ or `:remainder` raises `CODECS__UNEXPECTED_ZERO_FIXED_SIZE_ITEM_FOR_ARRAY_LIKE_SIZE_STRATEGY`
532
+ when the codec is built. Explicit counts and prefixes still allow it.
533
+
480
534
  #### Tap combinators
481
535
 
482
536
  Wrap a codec in one that observes a value - or the bytes - without changing it,
@@ -132,6 +132,7 @@ module Solana::Ruby::Kit
132
132
  end
133
133
  def array_codec(element_codec, size: nil, require_size_prefix: false, description: nil)
134
134
  description ||= 'array'
135
+ ArrayLikeSize.assert_item_codec_supports_size_strategy(description, size, element_codec.fixed_size)
135
136
  case size
136
137
  when nil then ArrayLikeSize.prefixed(element_codec, Numbers.u32_codec, require_size_prefix)
137
138
  when Codec then ArrayLikeSize.prefixed(element_codec, size, require_size_prefix)
@@ -244,6 +245,20 @@ module Solana::Ruby::Kit
244
245
  extend T::Sig
245
246
  extend self
246
247
 
248
+ # Raises when a zero-byte item codec is paired with a strategy that relies
249
+ # on items consuming bytes to make progress: a sentinel could never be
250
+ # reached, nor could the end of the bytes under +:remainder+.
251
+ sig { params(description: String, size: T.untyped, item_fixed_size: T.nilable(Integer)).void }
252
+ def assert_item_codec_supports_size_strategy(description, size, item_fixed_size)
253
+ return unless item_fixed_size&.zero?
254
+ return unless size == :remainder || size.is_a?(SentinelSize)
255
+
256
+ Kernel.raise SolanaError.new(
257
+ SolanaError::CODECS__UNEXPECTED_ZERO_FIXED_SIZE_ITEM_FOR_ARRAY_LIKE_SIZE_STRATEGY,
258
+ { codec_description: description, size_strategy: size == :remainder ? 'remainder' : 'sentinel' }
259
+ )
260
+ end
261
+
247
262
  # A count prefix written and read with +prefix+.
248
263
  sig { params(element_codec: Codec, prefix: Codec, require_size_prefix: T::Boolean).returns(Codec) }
249
264
  def prefixed(element_codec, prefix, require_size_prefix)
@@ -191,11 +191,20 @@ module Solana::Ruby::Kit
191
191
  Codec.new(enc, dec)
192
192
  end
193
193
 
194
- # Hex codec — lower-case hex string ↔ binary bytes.
194
+ # Hex codec — lower-case hex string ↔ binary bytes. Spaces are ignored on
195
+ # encode; any other non-hex character raises. An odd-length string keeps
196
+ # its last digit as a final low-nibble byte (+'abc'+ → +0xab 0x0c+), as
197
+ # upstream's base16 encoder does — +pack('H*')+ alone would pad it into
198
+ # the high nibble (+0xc0+).
195
199
  sig { returns(Codec) }
196
200
  def hex_codec
197
201
  enc = Encoder.new do |v|
198
- [v.to_s.tr(' ', '')].pack('H*')
202
+ value = v.to_s.tr(' ', '')
203
+ unless value.match?(/\A\h*\z/)
204
+ Kernel.raise SolanaError.new(SolanaError::CODECS__INVALID_STRING_FOR_HEX_CODEC, { value: v.to_s })
205
+ end
206
+ value = "#{value[0...-1]}0#{value[-1]}" if value.length.odd?
207
+ [value].pack('H*')
199
208
  end
200
209
  dec = Decoder.new do |bytes, offset|
201
210
  remaining = bytes.b.byteslice(offset..) || ''.b
@@ -36,6 +36,7 @@ module Solana::Ruby::Kit
36
36
  KEYS__PUBLIC_KEY_MUST_MATCH_PRIVATE_KEY = :SOLANA_ERROR__KEYS__PUBLIC_KEY_MUST_MATCH_PRIVATE_KEY
37
37
  KEYS__INVALID_SIGNATURE_BYTE_LENGTH = :SOLANA_ERROR__KEYS__INVALID_SIGNATURE_BYTE_LENGTH
38
38
  KEYS__SIGNATURE_STRING_LENGTH_OUT_OF_RANGE = :SOLANA_ERROR__KEYS__SIGNATURE_STRING_LENGTH_OUT_OF_RANGE
39
+ KEYS__VERIFICATION_REQUIRES_EXTRACTABLE_PUBLIC_KEY = :SOLANA_ERROR__KEYS__VERIFICATION_REQUIRES_EXTRACTABLE_PUBLIC_KEY
39
40
 
40
41
  # ── Instructions ──────────────────────────────────────────────────────────
41
42
  INSTRUCTIONS__EXPECTED_TO_HAVE_ACCOUNTS = :SOLANA_ERROR__INSTRUCTIONS__EXPECTED_TO_HAVE_ACCOUNTS
@@ -104,6 +105,8 @@ module Solana::Ruby::Kit
104
105
  # context: { codec_description:, hex_sentinel:, sentinel: }
105
106
  CODECS__SENTINEL_MISSING_AT_END_OF_BYTES = :SOLANA_ERROR__CODECS__SENTINEL_MISSING_AT_END_OF_BYTES
106
107
  CODECS__SENTINEL_MUST_NOT_BE_EMPTY = :SOLANA_ERROR__CODECS__SENTINEL_MUST_NOT_BE_EMPTY
108
+ # context: { codec_description:, size_strategy: }
109
+ CODECS__UNEXPECTED_ZERO_FIXED_SIZE_ITEM_FOR_ARRAY_LIKE_SIZE_STRATEGY = :SOLANA_ERROR__CODECS__UNEXPECTED_ZERO_FIXED_SIZE_ITEM_FOR_ARRAY_LIKE_SIZE_STRATEGY
107
110
 
108
111
  # ── RPC / JSON-RPC ────────────────────────────────────────────────────────
109
112
  RPC__INTEGER_OVERFLOW_WHILE_SERIALIZING_LARGE_INTEGER = :SOLANA_ERROR__RPC__INTEGER_OVERFLOW_WHILE_SERIALIZING_LARGE_INTEGER
@@ -200,6 +203,7 @@ module Solana::Ruby::Kit
200
203
  KEYS__PUBLIC_KEY_MUST_MATCH_PRIVATE_KEY => 'The public key does not match the private key',
201
204
  KEYS__INVALID_SIGNATURE_BYTE_LENGTH => 'Signature must be 64 bytes, got %{actual_length}',
202
205
  KEYS__SIGNATURE_STRING_LENGTH_OUT_OF_RANGE => 'Signature string length out of range (64–88 chars), got %{actual_length}',
206
+ KEYS__VERIFICATION_REQUIRES_EXTRACTABLE_PUBLIC_KEY => 'Signatures can only be verified using a public key (an RbNaCl::VerifyKey).',
203
207
 
204
208
  # Instructions
205
209
  INSTRUCTIONS__EXPECTED_TO_HAVE_ACCOUNTS => 'Expected instruction to have accounts',
@@ -260,6 +264,7 @@ module Solana::Ruby::Kit
260
264
  CODECS__NUMBER_OUT_OF_RANGE => 'Codec [%{codec_description}] expected number to be in the range [%{min}, %{max}], got %{value}.',
261
265
  CODECS__SENTINEL_MISSING_AT_END_OF_BYTES => 'Codec [%{codec_description}] expected sentinel [%{hex_sentinel}] to terminate the collection, but reached the end of the byte array without it.',
262
266
  CODECS__SENTINEL_MUST_NOT_BE_EMPTY => 'The sentinel must not be empty.',
267
+ CODECS__UNEXPECTED_ZERO_FIXED_SIZE_ITEM_FOR_ARRAY_LIKE_SIZE_STRATEGY => 'Codec [%{codec_description}] cannot use the [%{size_strategy}] size strategy: the item codec has a fixed size of zero and consumes no bytes.',
263
268
 
264
269
  # RPC
265
270
  RPC__INTEGER_OVERFLOW_WHILE_SERIALIZING_LARGE_INTEGER => 'Integer overflow while serializing large integer %{value}',
@@ -4,6 +4,7 @@
4
4
  require 'rbnacl'
5
5
  require_relative '../errors'
6
6
  require_relative '../encoding/base58'
7
+ require_relative 'small_order'
7
8
 
8
9
  module Solana::Ruby::Kit
9
10
  module Keys
@@ -144,11 +145,23 @@ module Solana::Ruby::Kit
144
145
  # Mirrors `verifySignature(key, signature, data)` in TypeScript.
145
146
  # Returns false rather than raising on mismatch, matching the TS return type.
146
147
  #
148
+ # Verification is strict: a public key or a signature `R` component that is
149
+ # a point of small order is rejected without consulting RbNaCl, matching the
150
+ # Solana runtime whatever the linked libsodium does itself. Upstream raises
151
+ # for a non-extractable or non-public CryptoKey; an RbNaCl::VerifyKey always
152
+ # exposes its bytes, so here only a non-VerifyKey (e.g. a SigningKey) raises.
153
+ #
147
154
  # @param verify_key [RbNaCl::VerifyKey]
148
155
  # @param sig_bytes [SignatureBytes]
149
156
  # @param data [String] binary string that was signed
150
157
  sig { params(verify_key: T.untyped, sig_bytes: SignatureBytes, data: String).returns(T::Boolean) }
151
158
  def verify_signature(verify_key, sig_bytes, data)
159
+ unless verify_key.is_a?(RbNaCl::VerifyKey)
160
+ Kernel.raise SolanaError.new(SolanaError::KEYS__VERIFICATION_REQUIRES_EXTRACTABLE_PUBLIC_KEY)
161
+ end
162
+ return false if SmallOrder.small_order_point_encoding?(verify_key.to_bytes)
163
+ return false if SmallOrder.small_order_point_encoding?(sig_bytes.value.byteslice(0, 32).to_s)
164
+
152
165
  verify_key.verify(sig_bytes.value, data.b)
153
166
  true
154
167
  rescue RbNaCl::BadSignatureError
@@ -0,0 +1,46 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Solana::Ruby::Kit
5
+ module Keys
6
+ extend T::Sig
7
+
8
+ # Mirrors the private `isSmallOrderPointEncoding` in @solana/keys
9
+ # (`small-order.ts`). An Ed25519 public key or signature `R` that is a point
10
+ # of small order lets trivially forged signatures verify against any data;
11
+ # the Solana runtime rejects them, so `verify_signature` does too.
12
+ module SmallOrder
13
+ extend T::Sig
14
+ extend self
15
+
16
+ P = T.let((1 << 255) - 19, Integer)
17
+
18
+ # The y-coordinates of the eight small-order points (the x sign bit picks
19
+ # between each pair, so it is masked off before the lookup). The two large
20
+ # ones are parsed from Strings because Sorbet rejects bignum literals.
21
+ SMALL_ORDER_Y_COORDINATES = T.let(
22
+ [
23
+ 0,
24
+ 1,
25
+ P - 1,
26
+ Integer('2707385501144840649318225287225658788936804267575313519463743609750303402022'),
27
+ Integer('55188659117513257062467267217118295137698188065244968500265048394206261417927')
28
+ ].freeze,
29
+ T::Array[Integer]
30
+ )
31
+
32
+ # True when +bytes+ (a 32-byte binary String) encodes a point of small
33
+ # order, canonically or not (a y-coordinate >= p is reduced mod p first).
34
+ # Any other length returns false.
35
+ sig { params(bytes: String).returns(T::Boolean) }
36
+ def small_order_point_encoding?(bytes)
37
+ bytes = bytes.b
38
+ return false unless bytes.bytesize == 32
39
+
40
+ last = bytes.getbyte(31).to_i & 0x7f
41
+ y = (bytes.byteslice(0, 31).to_s + last.chr).reverse.unpack1('H*').to_i(16)
42
+ SMALL_ORDER_Y_COORDINATES.include?(y % P)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -105,9 +105,9 @@ module Solana::Ruby::Kit
105
105
  # `Signature` holds a base58 string (that is what `sign_offchain_message` returns),
106
106
  # so it has to be decoded back to the 64 raw bytes RbNaCl expects.
107
107
  sig_bytes = Solana::Ruby::Kit::Encoding::Base58.decode(signature.to_s)
108
- vk.verify(sig_bytes, payload)
109
- true
110
- rescue RbNaCl::BadSignatureError, RbNaCl::LengthError
108
+ # Through Keys.verify_signature for its small-order key/`R` rejection.
109
+ Keys.verify_signature(vk, Keys.signature_bytes(sig_bytes), payload)
110
+ rescue RbNaCl::LengthError, SolanaError
111
111
  false
112
112
  end
113
113
  end
@@ -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
@@ -4,7 +4,7 @@
4
4
  module Solana
5
5
  module Ruby
6
6
  module Kit
7
- VERSION = '8.4.0'
7
+ VERSION = '8.5.1'
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.4.0
4
+ version: 8.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Paul Zupan, Idhra Inc.
@@ -246,6 +246,7 @@ files:
246
246
  - lib/solana/ruby/kit/keys/private_key.rb
247
247
  - lib/solana/ruby/kit/keys/public_key.rb
248
248
  - lib/solana/ruby/kit/keys/signatures.rb
249
+ - lib/solana/ruby/kit/keys/small_order.rb
249
250
  - lib/solana/ruby/kit/offchain_messages.rb
250
251
  - lib/solana/ruby/kit/offchain_messages/codec.rb
251
252
  - lib/solana/ruby/kit/offchain_messages/message.rb
@@ -288,6 +289,7 @@ files:
288
289
  - lib/solana/ruby/kit/rpc/api/send_transaction.rb
289
290
  - lib/solana/ruby/kit/rpc/api/simulate_transaction.rb
290
291
  - lib/solana/ruby/kit/rpc/client.rb
292
+ - lib/solana/ruby/kit/rpc/retry_policy.rb
291
293
  - lib/solana/ruby/kit/rpc/transport.rb
292
294
  - lib/solana/ruby/kit/rpc_parsed_types.rb
293
295
  - lib/solana/ruby/kit/rpc_parsed_types/address_lookup_table.rb