solana-ruby-kit 0.1.7 → 0.1.8
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/lib/solana/ruby/kit/errors.rb +12 -0
- data/lib/solana/ruby/kit/rpc/api/get_block_time.rb +20 -0
- data/lib/solana/ruby/kit/rpc/api/get_epoch_schedule.rb +40 -0
- data/lib/solana/ruby/kit/rpc/api/get_inflation_reward.rb +59 -0
- data/lib/solana/ruby/kit/rpc/client.rb +6 -0
- data/lib/solana/ruby/kit/rpc_types/sol.rb +102 -0
- data/lib/solana/ruby/kit/rpc_types.rb +1 -0
- data/lib/solana/ruby/kit/subscribable/reactive_action_store.rb +207 -0
- data/lib/solana/ruby/kit/subscribable/reactive_stream_store.rb +255 -0
- data/lib/solana/ruby/kit/subscribable.rb +2 -0
- data/lib/solana/ruby/kit/version.rb +1 -1
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f96f7c22b28fbf9bec4186e3bf92ec52483d532b7e398da89bf143473b267878
|
|
4
|
+
data.tar.gz: 4a2e915aba2620726be4b5c89880c49fd7d54acf950618d4002ce19b54ac7884
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8a7fef4cd3632770e3e7d67f5b4722585f66f2ad45c601687d6664fc38433a85d39de7f4d582062e6396be7df29ea3cf089ca77184a7d8c28c3cf2b3d37015ae
|
|
7
|
+
data.tar.gz: '0967fe5ea552384a656b22f5f03d08674e4066c373e0ee83cc31bd45598573aacade37f4295a0c24341eb0e31b7d543720be99cf347aba0ab626429f5c629085'
|
|
@@ -120,6 +120,12 @@ module Solana::Ruby::Kit
|
|
|
120
120
|
WALLET_STANDARD__INVALID_WIRE_FORMAT = :SOLANA_ERROR__WALLET_STANDARD__INVALID_WIRE_FORMAT
|
|
121
121
|
WALLET_STANDARD__SIGNATURE_VERIFICATION_FAILED = :SOLANA_ERROR__WALLET_STANDARD__SIGNATURE_VERIFICATION_FAILED
|
|
122
122
|
|
|
123
|
+
# ── Subscribable ──────────────────────────────────────────────────────────
|
|
124
|
+
SUBSCRIBABLE__RETRY_NOT_SUPPORTED = :SOLANA_ERROR__SUBSCRIBABLE__RETRY_NOT_SUPPORTED
|
|
125
|
+
|
|
126
|
+
# ── Fixed-points ──────────────────────────────────────────────────────────
|
|
127
|
+
FIXED_POINTS__STRICT_MODE_PRECISION_LOSS = :SOLANA_ERROR__FIXED_POINTS__STRICT_MODE_PRECISION_LOSS
|
|
128
|
+
|
|
123
129
|
# ── Invariant violations (internal) ──────────────────────────────────────
|
|
124
130
|
INVARIANT_VIOLATION__SUBSCRIPTION_ITERATOR_STATE_MISSING = :SOLANA_ERROR__INVARIANT_VIOLATION__SUBSCRIPTION_ITERATOR_STATE_MISSING
|
|
125
131
|
INVARIANT_VIOLATION__SUBSCRIPTION_ITERATOR_MUST_NOT_POLL_BEFORE_RESOLVING_EXISTING_MESSAGE_PROMISE = :SOLANA_ERROR__INVARIANT_VIOLATION__SUBSCRIPTION_ITERATOR_MUST_NOT_POLL_BEFORE_RESOLVING_EXISTING_MESSAGE_PROMISE
|
|
@@ -231,6 +237,12 @@ module Solana::Ruby::Kit
|
|
|
231
237
|
INSTRUCTION_PLANS__EMPTY_INSTRUCTION_PLAN => 'Instruction plan is empty and produced no transaction messages',
|
|
232
238
|
INSTRUCTION_PLANS__FAILED_TO_EXECUTE_TRANSACTION_PLAN => 'Failed to execute transaction plan',
|
|
233
239
|
|
|
240
|
+
# Subscribable
|
|
241
|
+
SUBSCRIBABLE__RETRY_NOT_SUPPORTED => 'This reactive store does not support retry(); use create_reactive_store_from_data_publisher_factory for a retryable store',
|
|
242
|
+
|
|
243
|
+
# Fixed-points
|
|
244
|
+
FIXED_POINTS__STRICT_MODE_PRECISION_LOSS => 'Value has more than 9 fractional digits and cannot be represented exactly as a Sol amount',
|
|
245
|
+
|
|
234
246
|
# Wallet Standard
|
|
235
247
|
WALLET_STANDARD__INVALID_WIRE_FORMAT => 'Invalid transaction wire format: %{reason}',
|
|
236
248
|
WALLET_STANDARD__SIGNATURE_VERIFICATION_FAILED => 'Signature verification failed for signer %{address}',
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Solana::Ruby::Kit
|
|
5
|
+
module Rpc
|
|
6
|
+
module Api
|
|
7
|
+
# Returns the estimated production time of a block.
|
|
8
|
+
# Mirrors TypeScript's GetBlockTimeApi.getBlockTime.
|
|
9
|
+
module GetBlockTime
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
sig { params(slot: Integer).returns(T.nilable(Integer)) }
|
|
13
|
+
def get_block_time(slot)
|
|
14
|
+
raw = transport.request('getBlockTime', [slot])
|
|
15
|
+
raw.nil? ? nil : Kernel.Integer(raw)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Solana::Ruby::Kit
|
|
5
|
+
module Rpc
|
|
6
|
+
module Api
|
|
7
|
+
# Struct returned by get_epoch_schedule.
|
|
8
|
+
EpochSchedule = T.let(
|
|
9
|
+
Struct.new(
|
|
10
|
+
:first_normal_epoch, # Integer — first full-length epoch after warmup
|
|
11
|
+
:first_normal_slot, # Integer — first slot after warmup period
|
|
12
|
+
:leader_schedule_slot_offset, # Integer — slots before epoch to compute leader schedule
|
|
13
|
+
:slots_per_epoch, # Integer — max slots per epoch
|
|
14
|
+
:warmup, # Boolean — whether epochs start short and grow
|
|
15
|
+
keyword_init: true
|
|
16
|
+
),
|
|
17
|
+
T.untyped
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# Returns the epoch schedule from the cluster's genesis config.
|
|
21
|
+
# Mirrors TypeScript's GetEpochScheduleApi.getEpochSchedule.
|
|
22
|
+
module GetEpochSchedule
|
|
23
|
+
extend T::Sig
|
|
24
|
+
|
|
25
|
+
sig { returns(T.untyped) }
|
|
26
|
+
def get_epoch_schedule
|
|
27
|
+
raw = transport.request('getEpochSchedule', [])
|
|
28
|
+
|
|
29
|
+
EpochSchedule.new(
|
|
30
|
+
first_normal_epoch: Kernel.Integer(raw['firstNormalEpoch']),
|
|
31
|
+
first_normal_slot: Kernel.Integer(raw['firstNormalSlot']),
|
|
32
|
+
leader_schedule_slot_offset: Kernel.Integer(raw['leaderScheduleSlotOffset']),
|
|
33
|
+
slots_per_epoch: Kernel.Integer(raw['slotsPerEpoch']),
|
|
34
|
+
warmup: raw['warmup'] == true
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Solana::Ruby::Kit
|
|
5
|
+
module Rpc
|
|
6
|
+
module Api
|
|
7
|
+
# Struct for a single entry in the getInflationReward response.
|
|
8
|
+
# nil entries indicate the address was not found or had no reward.
|
|
9
|
+
InflationReward = T.let(
|
|
10
|
+
Struct.new(
|
|
11
|
+
:amount, # Integer (Lamports) — reward credited
|
|
12
|
+
:commission, # Integer — vote account commission at reward time
|
|
13
|
+
:effective_slot, # Integer — slot in which rewards were delivered
|
|
14
|
+
:epoch, # Integer — epoch for which reward occurred
|
|
15
|
+
:post_balance, # Integer (Lamports) — post-reward account balance
|
|
16
|
+
keyword_init: true
|
|
17
|
+
),
|
|
18
|
+
T.untyped
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Returns the inflation/staking reward for a list of addresses for an epoch.
|
|
22
|
+
# Mirrors TypeScript's GetInflationRewardApi.getInflationReward.
|
|
23
|
+
module GetInflationReward
|
|
24
|
+
extend T::Sig
|
|
25
|
+
|
|
26
|
+
sig do
|
|
27
|
+
params(
|
|
28
|
+
addresses: T::Array[T.untyped],
|
|
29
|
+
commitment: T.nilable(Symbol),
|
|
30
|
+
epoch: T.nilable(Integer),
|
|
31
|
+
min_context_slot: T.nilable(Integer)
|
|
32
|
+
).returns(T::Array[T.nilable(T.untyped)])
|
|
33
|
+
end
|
|
34
|
+
def get_inflation_reward(addresses, commitment: nil, epoch: nil, min_context_slot: nil)
|
|
35
|
+
addr_strings = addresses.map { |a| a.respond_to?(:value) ? a.value : a.to_s }
|
|
36
|
+
config = {}
|
|
37
|
+
config['commitment'] = commitment.to_s if commitment
|
|
38
|
+
config['epoch'] = epoch if epoch
|
|
39
|
+
config['minContextSlot'] = min_context_slot if min_context_slot
|
|
40
|
+
|
|
41
|
+
params = config.empty? ? [addr_strings] : [addr_strings, config]
|
|
42
|
+
raw_list = transport.request('getInflationReward', params)
|
|
43
|
+
|
|
44
|
+
raw_list.map do |entry|
|
|
45
|
+
next nil if entry.nil?
|
|
46
|
+
|
|
47
|
+
InflationReward.new(
|
|
48
|
+
amount: Kernel.Integer(entry['amount']),
|
|
49
|
+
commission: Kernel.Integer(entry['commission']),
|
|
50
|
+
effective_slot: Kernel.Integer(entry['effectiveSlot']),
|
|
51
|
+
epoch: Kernel.Integer(entry['epoch']),
|
|
52
|
+
post_balance: Kernel.Integer(entry['postBalance'])
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -20,6 +20,9 @@ require_relative 'api/get_token_accounts_by_owner'
|
|
|
20
20
|
require_relative 'api/get_epoch_info'
|
|
21
21
|
require_relative 'api/get_vote_accounts'
|
|
22
22
|
require_relative 'api/simulate_transaction'
|
|
23
|
+
require_relative 'api/get_block_time'
|
|
24
|
+
require_relative 'api/get_epoch_schedule'
|
|
25
|
+
require_relative 'api/get_inflation_reward'
|
|
23
26
|
|
|
24
27
|
module Solana::Ruby::Kit
|
|
25
28
|
module Rpc
|
|
@@ -56,6 +59,9 @@ module Solana::Ruby::Kit
|
|
|
56
59
|
include Api::GetEpochInfo
|
|
57
60
|
include Api::GetVoteAccounts
|
|
58
61
|
include Api::SimulateTransaction
|
|
62
|
+
include Api::GetBlockTime
|
|
63
|
+
include Api::GetEpochSchedule
|
|
64
|
+
include Api::GetInflationReward
|
|
59
65
|
|
|
60
66
|
sig { returns(Transport) }
|
|
61
67
|
attr_reader :transport
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'bigdecimal'
|
|
5
|
+
require_relative '../errors'
|
|
6
|
+
require_relative 'lamports'
|
|
7
|
+
|
|
8
|
+
module Solana::Ruby::Kit
|
|
9
|
+
module RpcTypes
|
|
10
|
+
# SOL amounts expressed as a decimal fixed-point value with 9 decimal places.
|
|
11
|
+
# 1 SOL == 1_000_000_000 lamports, so Sol#raw is the exact Lamports count.
|
|
12
|
+
#
|
|
13
|
+
# Mirrors TypeScript's Sol (DecimalFixedPoint<'unsigned', 64, 9>).
|
|
14
|
+
class Sol
|
|
15
|
+
extend T::Sig
|
|
16
|
+
|
|
17
|
+
# The Lamports count as an unsigned integer (u64 range).
|
|
18
|
+
sig { returns(Integer) }
|
|
19
|
+
attr_reader :raw
|
|
20
|
+
|
|
21
|
+
sig { params(raw: Integer).void }
|
|
22
|
+
def initialize(raw)
|
|
23
|
+
@raw = T.let(raw, Integer)
|
|
24
|
+
freeze
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Returns the decimal string representation (e.g. "1.5", "0.000000001").
|
|
28
|
+
sig { returns(String) }
|
|
29
|
+
def to_s
|
|
30
|
+
whole = @raw / 10**9
|
|
31
|
+
fraction = @raw % 10**9
|
|
32
|
+
return whole.to_s if fraction.zero?
|
|
33
|
+
|
|
34
|
+
frac_str = fraction.to_s.rjust(9, '0').sub(/0+\z/, '')
|
|
35
|
+
"#{whole}.#{frac_str}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
sig { returns(String) }
|
|
39
|
+
def inspect = "#<#{self.class} #{self}>"
|
|
40
|
+
|
|
41
|
+
sig { params(other: T.untyped).returns(T::Boolean) }
|
|
42
|
+
def ==(other)
|
|
43
|
+
other.is_a?(Sol) && @raw == other.raw
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
sig { returns(Integer) }
|
|
47
|
+
def hash = @raw.hash
|
|
48
|
+
|
|
49
|
+
alias eql? ==
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
SOL_DECIMALS = T.let(9, Integer)
|
|
53
|
+
SOL_SCALE = T.let(10**9, Integer)
|
|
54
|
+
SOL_RAW_MAX = T.let(2**64 - 1, Integer)
|
|
55
|
+
|
|
56
|
+
module_function
|
|
57
|
+
|
|
58
|
+
# Parses a decimal string and returns a Sol value.
|
|
59
|
+
# Raises FIXED_POINTS__STRICT_MODE_PRECISION_LOSS if the string has more
|
|
60
|
+
# than 9 fractional digits (mirrors TypeScript's default 'strict' mode).
|
|
61
|
+
#
|
|
62
|
+
# Examples:
|
|
63
|
+
# sol('1.5') # => Sol(raw: 1_500_000_000)
|
|
64
|
+
# sol('0.000000001') # => Sol(raw: 1) — one lamport
|
|
65
|
+
#
|
|
66
|
+
# Mirrors TypeScript's sol(value, rounding?).
|
|
67
|
+
sig { params(value: String).returns(Sol) }
|
|
68
|
+
def sol(value)
|
|
69
|
+
bd = BigDecimal(value)
|
|
70
|
+
Kernel.raise SolanaError.new(SolanaError::FIXED_POINTS__STRICT_MODE_PRECISION_LOSS) if bd < 0
|
|
71
|
+
|
|
72
|
+
scaled = bd * SOL_SCALE
|
|
73
|
+
raw = scaled.to_i
|
|
74
|
+
|
|
75
|
+
unless scaled == BigDecimal(raw.to_s)
|
|
76
|
+
Kernel.raise SolanaError.new(SolanaError::FIXED_POINTS__STRICT_MODE_PRECISION_LOSS)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
Kernel.raise SolanaError.new(SolanaError::SOLANA_ERROR__LAMPORTS_OUT_OF_RANGE) if raw > SOL_RAW_MAX
|
|
80
|
+
|
|
81
|
+
Sol.new(raw)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Converts a Sol value to its equivalent Lamports integer.
|
|
85
|
+
# The conversion is exact — Sol#raw is the Lamports count.
|
|
86
|
+
#
|
|
87
|
+
# Mirrors TypeScript's solToLamports(value).
|
|
88
|
+
sig { params(value: Sol).returns(Lamports) }
|
|
89
|
+
def sol_to_lamports(value)
|
|
90
|
+
value.raw
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Converts a Lamports integer to its equivalent Sol value.
|
|
94
|
+
# The conversion is exact.
|
|
95
|
+
#
|
|
96
|
+
# Mirrors TypeScript's lamportsToSol(value).
|
|
97
|
+
sig { params(value: Lamports).returns(Sol) }
|
|
98
|
+
def lamports_to_sol(value)
|
|
99
|
+
Sol.new(value)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Solana::Ruby::Kit
|
|
5
|
+
module Subscribable
|
|
6
|
+
extend T::Sig
|
|
7
|
+
|
|
8
|
+
# Lifecycle status of a ReactiveActionStore.
|
|
9
|
+
# Mirrors TypeScript's ReactiveActionStatus.
|
|
10
|
+
ReactiveActionStatus = T.type_alias { String } # 'idle' | 'running' | 'success' | 'error'
|
|
11
|
+
|
|
12
|
+
# Lifecycle snapshot of a ReactiveActionStore.
|
|
13
|
+
# `data` persists across running/error states so callers can render stale
|
|
14
|
+
# content while a retry is in flight; only reset() clears it.
|
|
15
|
+
#
|
|
16
|
+
# Mirrors TypeScript's ReactiveActionState<TResult>.
|
|
17
|
+
class ReactiveActionState
|
|
18
|
+
extend T::Sig
|
|
19
|
+
|
|
20
|
+
sig { returns(String) }
|
|
21
|
+
attr_reader :status
|
|
22
|
+
|
|
23
|
+
sig { returns(T.untyped) }
|
|
24
|
+
attr_reader :data
|
|
25
|
+
|
|
26
|
+
sig { returns(T.untyped) }
|
|
27
|
+
attr_reader :error
|
|
28
|
+
|
|
29
|
+
sig { params(status: String, data: T.untyped, error: T.untyped).void }
|
|
30
|
+
def initialize(status:, data: nil, error: nil)
|
|
31
|
+
@status = T.let(status, String)
|
|
32
|
+
@data = T.let(data, T.untyped)
|
|
33
|
+
@error = T.let(error, T.untyped)
|
|
34
|
+
freeze
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
sig { params(other: T.untyped).returns(T::Boolean) }
|
|
38
|
+
def ==(other)
|
|
39
|
+
other.is_a?(ReactiveActionState) &&
|
|
40
|
+
@status == other.status &&
|
|
41
|
+
@data.equal?(other.data) &&
|
|
42
|
+
@error.equal?(other.error)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
REACTIVE_ACTION_IDLE_STATE = T.let(
|
|
47
|
+
ReactiveActionState.new(status: 'idle').freeze,
|
|
48
|
+
ReactiveActionState
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# A thread-safe state machine wrapping a callable. Exposes
|
|
52
|
+
# dispatch / dispatch_async / get_state / subscribe / reset
|
|
53
|
+
# so that observers can react to in-flight, succeeded, or failed calls.
|
|
54
|
+
#
|
|
55
|
+
# dispatch — fire-and-forget; runs the callable in a background thread.
|
|
56
|
+
# dispatch_async — blocking; runs the callable in the current thread and
|
|
57
|
+
# returns the result (or raises on failure / supersession).
|
|
58
|
+
#
|
|
59
|
+
# Only the most recent dispatch can mutate state — superseded calls silently
|
|
60
|
+
# drop their result via a generation counter.
|
|
61
|
+
#
|
|
62
|
+
# Mirrors TypeScript's ReactiveActionStore<TArgs, TResult>.
|
|
63
|
+
class ReactiveActionStore
|
|
64
|
+
extend T::Sig
|
|
65
|
+
|
|
66
|
+
sig { params(fn: T.proc.params(args: T.untyped).returns(T.untyped)).void }
|
|
67
|
+
def initialize(fn)
|
|
68
|
+
@fn = T.let(fn, T.proc.params(args: T.untyped).returns(T.untyped))
|
|
69
|
+
@state = T.let(REACTIVE_ACTION_IDLE_STATE, ReactiveActionState)
|
|
70
|
+
@listeners = T.let([], T::Array[T.proc.void])
|
|
71
|
+
@mutex = T.let(Mutex.new, Mutex)
|
|
72
|
+
@current_gen = T.let(0, Integer)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Returns the current lifecycle snapshot.
|
|
76
|
+
sig { returns(ReactiveActionState) }
|
|
77
|
+
def get_state
|
|
78
|
+
@mutex.synchronize { @state }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Fire-and-forget dispatch. Runs fn in a background thread. Only the
|
|
82
|
+
# most recent call's result updates state; superseded threads are ignored.
|
|
83
|
+
sig { params(args: T.untyped).void }
|
|
84
|
+
def dispatch(*args)
|
|
85
|
+
gen = nil
|
|
86
|
+
prev_data = nil
|
|
87
|
+
@mutex.synchronize do
|
|
88
|
+
@current_gen += 1
|
|
89
|
+
gen = @current_gen
|
|
90
|
+
prev_data = @state.data
|
|
91
|
+
@state = ReactiveActionState.new(status: 'running', data: prev_data)
|
|
92
|
+
end
|
|
93
|
+
_notify
|
|
94
|
+
|
|
95
|
+
Thread.new do
|
|
96
|
+
begin
|
|
97
|
+
result = @fn.call(*args)
|
|
98
|
+
active = @mutex.synchronize do
|
|
99
|
+
if @current_gen == gen
|
|
100
|
+
@state = ReactiveActionState.new(status: 'success', data: result)
|
|
101
|
+
true
|
|
102
|
+
else
|
|
103
|
+
false
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
_notify if active
|
|
107
|
+
rescue => e
|
|
108
|
+
active = @mutex.synchronize do
|
|
109
|
+
if @current_gen == gen
|
|
110
|
+
@state = ReactiveActionState.new(status: 'error', data: prev_data, error: e)
|
|
111
|
+
true
|
|
112
|
+
else
|
|
113
|
+
false
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
_notify if active
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Blocking dispatch. Runs fn in the current thread. Returns the result on
|
|
124
|
+
# success. Raises the error on failure. Raises RuntimeError if superseded
|
|
125
|
+
# by a concurrent dispatch or reset().
|
|
126
|
+
sig { params(args: T.untyped).returns(T.untyped) }
|
|
127
|
+
def dispatch_async(*args)
|
|
128
|
+
gen = nil
|
|
129
|
+
prev_data = nil
|
|
130
|
+
@mutex.synchronize do
|
|
131
|
+
@current_gen += 1
|
|
132
|
+
gen = @current_gen
|
|
133
|
+
prev_data = @state.data
|
|
134
|
+
@state = ReactiveActionState.new(status: 'running', data: prev_data)
|
|
135
|
+
end
|
|
136
|
+
_notify
|
|
137
|
+
|
|
138
|
+
result = @fn.call(*args)
|
|
139
|
+
|
|
140
|
+
active = @mutex.synchronize do
|
|
141
|
+
if @current_gen == gen
|
|
142
|
+
@state = ReactiveActionState.new(status: 'success', data: result)
|
|
143
|
+
true
|
|
144
|
+
else
|
|
145
|
+
false
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
_notify if active
|
|
149
|
+
Kernel.raise 'ReactiveActionStore: call was superseded' unless active
|
|
150
|
+
result
|
|
151
|
+
rescue RuntimeError
|
|
152
|
+
Kernel.raise
|
|
153
|
+
rescue => e
|
|
154
|
+
active = @mutex.synchronize do
|
|
155
|
+
if @current_gen == gen
|
|
156
|
+
@state = ReactiveActionState.new(status: 'error', data: prev_data, error: e)
|
|
157
|
+
true
|
|
158
|
+
else
|
|
159
|
+
false
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
_notify if active
|
|
163
|
+
Kernel.raise e
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Aborts any in-flight background dispatch (by incrementing generation)
|
|
167
|
+
# and resets state to idle.
|
|
168
|
+
sig { void }
|
|
169
|
+
def reset
|
|
170
|
+
@mutex.synchronize do
|
|
171
|
+
@current_gen += 1
|
|
172
|
+
@state = REACTIVE_ACTION_IDLE_STATE
|
|
173
|
+
end
|
|
174
|
+
_notify
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Registers a listener called on every state change.
|
|
178
|
+
# Returns an unsubscribe lambda.
|
|
179
|
+
sig { params(listener: T.proc.void).returns(T.proc.void) }
|
|
180
|
+
def subscribe(&listener)
|
|
181
|
+
@mutex.synchronize { @listeners << listener }
|
|
182
|
+
lambda { @mutex.synchronize { @listeners.delete(listener) } }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
private
|
|
186
|
+
|
|
187
|
+
sig { void }
|
|
188
|
+
def _notify
|
|
189
|
+
subs = @mutex.synchronize { @listeners.dup }
|
|
190
|
+
subs.each(&:call)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
module_function
|
|
195
|
+
|
|
196
|
+
# Wraps a callable in a ReactiveActionStore.
|
|
197
|
+
# fn receives the arguments passed to dispatch/dispatch_async.
|
|
198
|
+
#
|
|
199
|
+
# Mirrors TypeScript's createReactiveActionStore.
|
|
200
|
+
sig do
|
|
201
|
+
params(fn: T.proc.params(args: T.untyped).returns(T.untyped)).returns(ReactiveActionStore)
|
|
202
|
+
end
|
|
203
|
+
def create_reactive_action_store(&fn)
|
|
204
|
+
ReactiveActionStore.new(T.let(fn, T.proc.params(args: T.untyped).returns(T.untyped)))
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative '../errors'
|
|
5
|
+
require_relative 'data_publisher'
|
|
6
|
+
|
|
7
|
+
module Solana::Ruby::Kit
|
|
8
|
+
module Subscribable
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
# Lifecycle snapshot of a ReactiveStreamStore — a single frozen value
|
|
12
|
+
# capturing status, last-known data, and any error.
|
|
13
|
+
#
|
|
14
|
+
# status values:
|
|
15
|
+
# 'loading' — waiting for first value; data is nil
|
|
16
|
+
# 'loaded' — value has arrived, no active error
|
|
17
|
+
# 'error' — stream failed; data is last known value (may be nil)
|
|
18
|
+
# 'retrying' — retry in progress after error; data preserved
|
|
19
|
+
#
|
|
20
|
+
# Mirrors TypeScript's ReactiveState<T>.
|
|
21
|
+
class ReactiveState
|
|
22
|
+
extend T::Sig
|
|
23
|
+
|
|
24
|
+
sig { returns(String) }
|
|
25
|
+
attr_reader :status
|
|
26
|
+
|
|
27
|
+
sig { returns(T.untyped) }
|
|
28
|
+
attr_reader :data
|
|
29
|
+
|
|
30
|
+
sig { returns(T.untyped) }
|
|
31
|
+
attr_reader :error
|
|
32
|
+
|
|
33
|
+
sig { params(status: String, data: T.untyped, error: T.untyped).void }
|
|
34
|
+
def initialize(status:, data: nil, error: nil)
|
|
35
|
+
@status = T.let(status, String)
|
|
36
|
+
@data = T.let(data, T.untyped)
|
|
37
|
+
@error = T.let(error, T.untyped)
|
|
38
|
+
freeze
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
sig { params(other: T.untyped).returns(T::Boolean) }
|
|
42
|
+
def ==(other)
|
|
43
|
+
other.is_a?(ReactiveState) &&
|
|
44
|
+
@status == other.status &&
|
|
45
|
+
@data.equal?(other.data) &&
|
|
46
|
+
@error.equal?(other.error)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
REACTIVE_LOADING_STATE = T.let(
|
|
51
|
+
ReactiveState.new(status: 'loading').freeze,
|
|
52
|
+
ReactiveState
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Thread-safe store that tracks the latest value published to a data channel
|
|
56
|
+
# and notifies subscribers on every state change.
|
|
57
|
+
#
|
|
58
|
+
# Compatible with any observer pattern that expects a
|
|
59
|
+
# { subscribe, get_unified_state } contract.
|
|
60
|
+
#
|
|
61
|
+
# Mirrors TypeScript's ReactiveStreamStore<T>.
|
|
62
|
+
class ReactiveStreamStore
|
|
63
|
+
extend T::Sig
|
|
64
|
+
|
|
65
|
+
sig { void }
|
|
66
|
+
def initialize
|
|
67
|
+
@state = T.let(REACTIVE_LOADING_STATE, ReactiveState)
|
|
68
|
+
@subscribers = T.let([], T::Array[T.proc.void])
|
|
69
|
+
@mutex = T.let(Mutex.new, Mutex)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Returns the current lifecycle snapshot.
|
|
73
|
+
sig { returns(ReactiveState) }
|
|
74
|
+
def get_unified_state
|
|
75
|
+
@mutex.synchronize { @state }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Returns the most recent data value, or nil if no value has arrived yet.
|
|
79
|
+
# @deprecated Use get_unified_state instead.
|
|
80
|
+
sig { returns(T.untyped) }
|
|
81
|
+
def get_state
|
|
82
|
+
@mutex.synchronize { @state.data }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Returns the current error, or nil if no error has occurred.
|
|
86
|
+
# @deprecated Use get_unified_state instead.
|
|
87
|
+
sig { returns(T.untyped) }
|
|
88
|
+
def get_error
|
|
89
|
+
@mutex.synchronize { @state.error }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Re-opens the stream after an error. Raises RETRY_NOT_SUPPORTED on
|
|
93
|
+
# DataPublisher-backed stores — use create_reactive_store_from_data_publisher_factory
|
|
94
|
+
# for a retryable store.
|
|
95
|
+
sig { void }
|
|
96
|
+
def retry
|
|
97
|
+
Kernel.raise SolanaError.new(SolanaError::SUBSCRIBABLE__RETRY_NOT_SUPPORTED)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Registers a callback invoked on every state change.
|
|
101
|
+
# Returns an unsubscribe lambda.
|
|
102
|
+
sig { params(callback: T.proc.void).returns(T.proc.void) }
|
|
103
|
+
def subscribe(&callback)
|
|
104
|
+
@mutex.synchronize { @subscribers << callback }
|
|
105
|
+
lambda { @mutex.synchronize { @subscribers.delete(callback) } }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Internal: transition state and notify all subscribers.
|
|
109
|
+
sig { params(new_state: ReactiveState).void }
|
|
110
|
+
def _set_state(new_state)
|
|
111
|
+
subs = nil
|
|
112
|
+
@mutex.synchronize do
|
|
113
|
+
return if @state.equal?(new_state)
|
|
114
|
+
@state = new_state
|
|
115
|
+
subs = @subscribers.dup
|
|
116
|
+
end
|
|
117
|
+
subs&.each(&:call)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Deprecated alias — use ReactiveStreamStore.
|
|
122
|
+
ReactiveStore = ReactiveStreamStore
|
|
123
|
+
|
|
124
|
+
# A ReactiveStreamStore that supports retry() by re-invoking a factory proc
|
|
125
|
+
# to get a fresh DataPublisher on each connection attempt.
|
|
126
|
+
# Mirrors TypeScript's createReactiveStoreFromDataPublisherFactory result.
|
|
127
|
+
class RetryableReactiveStreamStore < ReactiveStreamStore
|
|
128
|
+
extend T::Sig
|
|
129
|
+
|
|
130
|
+
sig do
|
|
131
|
+
params(
|
|
132
|
+
data_channel: T.untyped,
|
|
133
|
+
error_channel: T.untyped,
|
|
134
|
+
create_publisher: T.proc.returns(DataPublisher),
|
|
135
|
+
signal: T.nilable(T.proc.void)
|
|
136
|
+
).void
|
|
137
|
+
end
|
|
138
|
+
def initialize(data_channel:, error_channel:, create_publisher:, signal: nil)
|
|
139
|
+
super()
|
|
140
|
+
@data_channel = T.let(data_channel, T.untyped)
|
|
141
|
+
@error_channel = T.let(error_channel, T.untyped)
|
|
142
|
+
@outer_signal = T.let(signal, T.nilable(T.proc.void))
|
|
143
|
+
@create_publisher = T.let(create_publisher, T.proc.returns(DataPublisher))
|
|
144
|
+
@stopped = T.let(false, T::Boolean)
|
|
145
|
+
# Per-connection active flag shared with subscriber lambdas via closure.
|
|
146
|
+
@conn_active = T.let([false], T::Array[T::Boolean])
|
|
147
|
+
_connect
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
sig { override.void }
|
|
151
|
+
def retry
|
|
152
|
+
stopped = @mutex.synchronize { @stopped }
|
|
153
|
+
return if stopped
|
|
154
|
+
return unless get_unified_state.status == 'error'
|
|
155
|
+
|
|
156
|
+
stale_data = get_unified_state.data
|
|
157
|
+
# Deactivate old connection's subscribers
|
|
158
|
+
@mutex.synchronize { @conn_active[0] = false }
|
|
159
|
+
_set_state(ReactiveState.new(status: 'retrying', data: stale_data))
|
|
160
|
+
_connect
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
sig { void }
|
|
166
|
+
def _connect
|
|
167
|
+
return if @mutex.synchronize { @stopped }
|
|
168
|
+
|
|
169
|
+
# Fresh active flag for this connection window
|
|
170
|
+
active = [true]
|
|
171
|
+
@mutex.synchronize { @conn_active = active }
|
|
172
|
+
|
|
173
|
+
# inner_signal fires (raises) once this connection is superseded or errors out,
|
|
174
|
+
# which causes DataPublisher to prune those subscribers automatically.
|
|
175
|
+
inner_signal = lambda do
|
|
176
|
+
@outer_signal&.call
|
|
177
|
+
Kernel.raise 'connection_inactive' unless active[0]
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
begin
|
|
181
|
+
publisher = @create_publisher.call
|
|
182
|
+
rescue => e
|
|
183
|
+
active[0] = false
|
|
184
|
+
stale_data = get_unified_state.data
|
|
185
|
+
_set_state(ReactiveState.new(status: 'error', data: stale_data, error: e))
|
|
186
|
+
return
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
publisher.on(@data_channel, signal: inner_signal) do |data|
|
|
190
|
+
_set_state(ReactiveState.new(status: 'loaded', data: data))
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
publisher.on(@error_channel, signal: inner_signal) do |err|
|
|
194
|
+
next unless active[0] && get_unified_state.status != 'error'
|
|
195
|
+
active[0] = false
|
|
196
|
+
stale_data = get_unified_state.data
|
|
197
|
+
_set_state(ReactiveState.new(status: 'error', data: stale_data, error: err))
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
module_function
|
|
203
|
+
|
|
204
|
+
# Creates a ReactiveStreamStore backed by a ready-made DataPublisher.
|
|
205
|
+
# retry() is not supported — see create_reactive_store_from_data_publisher_factory.
|
|
206
|
+
#
|
|
207
|
+
# Mirrors TypeScript's createReactiveStoreFromDataPublisher (deprecated in TS).
|
|
208
|
+
sig do
|
|
209
|
+
params(
|
|
210
|
+
publisher: DataPublisher,
|
|
211
|
+
data_channel: T.untyped,
|
|
212
|
+
error_channel: T.untyped,
|
|
213
|
+
signal: T.nilable(T.proc.void)
|
|
214
|
+
).returns(ReactiveStreamStore)
|
|
215
|
+
end
|
|
216
|
+
def create_reactive_store_from_data_publisher(publisher, data_channel:, error_channel:, signal: nil)
|
|
217
|
+
store = ReactiveStreamStore.new
|
|
218
|
+
|
|
219
|
+
publisher.on(data_channel, signal: signal) do |data|
|
|
220
|
+
store._set_state(ReactiveState.new(status: 'loaded', data: data))
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
publisher.on(error_channel, signal: signal) do |err|
|
|
224
|
+
unified = store.get_unified_state
|
|
225
|
+
next if unified.status == 'error'
|
|
226
|
+
store._set_state(ReactiveState.new(status: 'error', data: unified.data, error: err))
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
store
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Creates a retryable ReactiveStreamStore from a DataPublisher factory proc.
|
|
233
|
+
# The factory is called once immediately and again on each retry().
|
|
234
|
+
#
|
|
235
|
+
# Mirrors TypeScript's createReactiveStoreFromDataPublisherFactory.
|
|
236
|
+
sig do
|
|
237
|
+
params(
|
|
238
|
+
data_channel: T.untyped,
|
|
239
|
+
error_channel: T.untyped,
|
|
240
|
+
signal: T.nilable(T.proc.void),
|
|
241
|
+
blk: T.proc.returns(DataPublisher)
|
|
242
|
+
).returns(ReactiveStreamStore)
|
|
243
|
+
end
|
|
244
|
+
def create_reactive_store_from_data_publisher_factory(
|
|
245
|
+
data_channel:, error_channel:, signal: nil, &blk
|
|
246
|
+
)
|
|
247
|
+
RetryableReactiveStreamStore.new(
|
|
248
|
+
data_channel: data_channel,
|
|
249
|
+
error_channel: error_channel,
|
|
250
|
+
create_publisher: T.let(blk, T.proc.returns(DataPublisher)),
|
|
251
|
+
signal: signal
|
|
252
|
+
)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
@@ -6,6 +6,8 @@ require 'timeout'
|
|
|
6
6
|
# Mirrors @solana/subscribable.
|
|
7
7
|
require_relative 'subscribable/data_publisher'
|
|
8
8
|
require_relative 'subscribable/async_iterable'
|
|
9
|
+
require_relative 'subscribable/reactive_stream_store'
|
|
10
|
+
require_relative 'subscribable/reactive_action_store'
|
|
9
11
|
|
|
10
12
|
module Solana::Ruby::Kit
|
|
11
13
|
module Subscribable
|
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: 0.1.
|
|
4
|
+
version: 0.1.8
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Paul Zupan, Idhra Inc.
|
|
@@ -239,7 +239,10 @@ files:
|
|
|
239
239
|
- lib/solana/ruby/kit/rpc/api/get_account_info.rb
|
|
240
240
|
- lib/solana/ruby/kit/rpc/api/get_balance.rb
|
|
241
241
|
- lib/solana/ruby/kit/rpc/api/get_block_height.rb
|
|
242
|
+
- lib/solana/ruby/kit/rpc/api/get_block_time.rb
|
|
242
243
|
- lib/solana/ruby/kit/rpc/api/get_epoch_info.rb
|
|
244
|
+
- lib/solana/ruby/kit/rpc/api/get_epoch_schedule.rb
|
|
245
|
+
- lib/solana/ruby/kit/rpc/api/get_inflation_reward.rb
|
|
243
246
|
- lib/solana/ruby/kit/rpc/api/get_latest_blockhash.rb
|
|
244
247
|
- lib/solana/ruby/kit/rpc/api/get_minimum_balance_for_rent_exemption.rb
|
|
245
248
|
- lib/solana/ruby/kit/rpc/api/get_multiple_accounts.rb
|
|
@@ -278,11 +281,14 @@ files:
|
|
|
278
281
|
- lib/solana/ruby/kit/rpc_types/cluster_url.rb
|
|
279
282
|
- lib/solana/ruby/kit/rpc_types/commitment.rb
|
|
280
283
|
- lib/solana/ruby/kit/rpc_types/lamports.rb
|
|
284
|
+
- lib/solana/ruby/kit/rpc_types/sol.rb
|
|
281
285
|
- lib/solana/ruby/kit/signers.rb
|
|
282
286
|
- lib/solana/ruby/kit/signers/keypair_signer.rb
|
|
283
287
|
- lib/solana/ruby/kit/subscribable.rb
|
|
284
288
|
- lib/solana/ruby/kit/subscribable/async_iterable.rb
|
|
285
289
|
- lib/solana/ruby/kit/subscribable/data_publisher.rb
|
|
290
|
+
- lib/solana/ruby/kit/subscribable/reactive_action_store.rb
|
|
291
|
+
- lib/solana/ruby/kit/subscribable/reactive_stream_store.rb
|
|
286
292
|
- lib/solana/ruby/kit/sysvars.rb
|
|
287
293
|
- lib/solana/ruby/kit/sysvars/addresses.rb
|
|
288
294
|
- lib/solana/ruby/kit/sysvars/clock.rb
|