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 +4 -4
- data/README.md +54 -0
- data/lib/solana/ruby/kit/codecs/data_structures.rb +15 -0
- data/lib/solana/ruby/kit/codecs/strings.rb +11 -2
- data/lib/solana/ruby/kit/errors.rb +5 -0
- data/lib/solana/ruby/kit/keys/signatures.rb +13 -0
- data/lib/solana/ruby/kit/keys/small_order.rb +46 -0
- data/lib/solana/ruby/kit/offchain_messages/codec.rb +3 -3
- data/lib/solana/ruby/kit/rpc/client.rb +21 -3
- data/lib/solana/ruby/kit/rpc/retry_policy.rb +149 -0
- data/lib/solana/ruby/kit/rpc/transport.rb +55 -6
- data/lib/solana/ruby/kit/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 727cac096a118ea677195240a8ea3440f593b1f7930b0f0f603ae8156b66862a
|
|
4
|
+
data.tar.gz: e8a03418fa257b58b243933a9fe6c5918c7882f1bdfe47ad49f87bbd63a95a28
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
rescue RbNaCl::
|
|
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(
|
|
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(
|
|
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
|
-
|
|
37
|
-
|
|
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 =
|
|
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
|
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
|
+
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
|