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 +4 -4
- data/README.md +72 -1
- data/lib/solana/ruby/kit/errors.rb +33 -0
- data/lib/solana/ruby/kit/resource_limit_estimation.rb +11 -1
- data/lib/solana/ruby/kit/rpc/api/simulate_transaction.rb +15 -2
- 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/rpc_subscriptions/transport.rb +3 -1
- data/lib/solana/ruby/kit/transaction_introspection/compiled_transaction_message.rb +4 -0
- data/lib/solana/ruby/kit/transaction_introspection/decode_rpc_transaction.rb +19 -1
- data/lib/solana/ruby/kit/transaction_messages/v1_transaction_config.rb +132 -0
- data/lib/solana/ruby/kit/transaction_messages.rb +1 -0
- data/lib/solana/ruby/kit/transactions/compiler.rb +11 -2
- 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: c6c87d85cd6d590f5db4b09599ae8f0dc73be1c3f6ca5d387b184933bf9e5db3
|
|
4
|
+
data.tar.gz: 26b99981a5c5313a825f1bc6a0dc89c4f73e7dea53708a809af00c52e1b4047a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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(
|
|
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
|
|
@@ -63,7 +63,9 @@ module Solana::Ruby::Kit
|
|
|
63
63
|
_send(payload)
|
|
64
64
|
|
|
65
65
|
response = q.pop
|
|
66
|
-
|
|
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
|
|
@@ -9,8 +9,17 @@ require_relative '../errors'
|
|
|
9
9
|
|
|
10
10
|
module Solana::Ruby::Kit
|
|
11
11
|
module Transactions
|
|
12
|
-
#
|
|
13
|
-
|
|
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
|
|
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.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
|