solana-ruby-kit 0.1.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 +7 -0
- data/lib/core_extensions/tapioca/name_patch.rb +32 -0
- data/lib/core_extensions/tapioca/required_ancestors.rb +13 -0
- data/lib/generators/solana/ruby/kit/install/install_generator.rb +17 -0
- data/lib/generators/solana/ruby/kit/install/templates/solana_ruby_kit.rb.tt +8 -0
- data/lib/solana/ruby/kit/accounts/account.rb +47 -0
- data/lib/solana/ruby/kit/accounts/maybe_account.rb +86 -0
- data/lib/solana/ruby/kit/accounts.rb +6 -0
- data/lib/solana/ruby/kit/addresses/address.rb +133 -0
- data/lib/solana/ruby/kit/addresses/curve.rb +112 -0
- data/lib/solana/ruby/kit/addresses/program_derived_address.rb +155 -0
- data/lib/solana/ruby/kit/addresses/public_key.rb +39 -0
- data/lib/solana/ruby/kit/addresses.rb +11 -0
- data/lib/solana/ruby/kit/codecs/bytes.rb +58 -0
- data/lib/solana/ruby/kit/codecs/codec.rb +135 -0
- data/lib/solana/ruby/kit/codecs/data_structures.rb +177 -0
- data/lib/solana/ruby/kit/codecs/decoder.rb +43 -0
- data/lib/solana/ruby/kit/codecs/encoder.rb +52 -0
- data/lib/solana/ruby/kit/codecs/numbers.rb +217 -0
- data/lib/solana/ruby/kit/codecs/strings.rb +116 -0
- data/lib/solana/ruby/kit/codecs.rb +25 -0
- data/lib/solana/ruby/kit/configuration.rb +48 -0
- data/lib/solana/ruby/kit/encoding/base58.rb +62 -0
- data/lib/solana/ruby/kit/errors.rb +226 -0
- data/lib/solana/ruby/kit/fast_stable_stringify.rb +62 -0
- data/lib/solana/ruby/kit/functional.rb +29 -0
- data/lib/solana/ruby/kit/instruction_plans/plans.rb +27 -0
- data/lib/solana/ruby/kit/instruction_plans.rb +47 -0
- data/lib/solana/ruby/kit/instructions/accounts.rb +80 -0
- data/lib/solana/ruby/kit/instructions/instruction.rb +71 -0
- data/lib/solana/ruby/kit/instructions/roles.rb +84 -0
- data/lib/solana/ruby/kit/instructions.rb +7 -0
- data/lib/solana/ruby/kit/keys/key_pair.rb +84 -0
- data/lib/solana/ruby/kit/keys/private_key.rb +39 -0
- data/lib/solana/ruby/kit/keys/public_key.rb +31 -0
- data/lib/solana/ruby/kit/keys/signatures.rb +171 -0
- data/lib/solana/ruby/kit/keys.rb +11 -0
- data/lib/solana/ruby/kit/offchain_messages/codec.rb +107 -0
- data/lib/solana/ruby/kit/offchain_messages/message.rb +22 -0
- data/lib/solana/ruby/kit/offchain_messages.rb +16 -0
- data/lib/solana/ruby/kit/options/option.rb +132 -0
- data/lib/solana/ruby/kit/options.rb +5 -0
- data/lib/solana/ruby/kit/plugin_core.rb +58 -0
- data/lib/solana/ruby/kit/programs.rb +42 -0
- data/lib/solana/ruby/kit/promises.rb +85 -0
- data/lib/solana/ruby/kit/railtie.rb +18 -0
- data/lib/solana/ruby/kit/rpc/api/get_account_info.rb +76 -0
- data/lib/solana/ruby/kit/rpc/api/get_balance.rb +41 -0
- data/lib/solana/ruby/kit/rpc/api/get_block_height.rb +29 -0
- data/lib/solana/ruby/kit/rpc/api/get_epoch_info.rb +47 -0
- data/lib/solana/ruby/kit/rpc/api/get_latest_blockhash.rb +52 -0
- data/lib/solana/ruby/kit/rpc/api/get_minimum_balance_for_rent_exemption.rb +29 -0
- data/lib/solana/ruby/kit/rpc/api/get_multiple_accounts.rb +56 -0
- data/lib/solana/ruby/kit/rpc/api/get_program_accounts.rb +60 -0
- data/lib/solana/ruby/kit/rpc/api/get_signature_statuses.rb +56 -0
- data/lib/solana/ruby/kit/rpc/api/get_slot.rb +30 -0
- data/lib/solana/ruby/kit/rpc/api/get_token_account_balance.rb +38 -0
- data/lib/solana/ruby/kit/rpc/api/get_token_accounts_by_owner.rb +48 -0
- data/lib/solana/ruby/kit/rpc/api/get_transaction.rb +36 -0
- data/lib/solana/ruby/kit/rpc/api/get_vote_accounts.rb +62 -0
- data/lib/solana/ruby/kit/rpc/api/is_blockhash_valid.rb +41 -0
- data/lib/solana/ruby/kit/rpc/api/request_airdrop.rb +35 -0
- data/lib/solana/ruby/kit/rpc/api/send_transaction.rb +61 -0
- data/lib/solana/ruby/kit/rpc/api/simulate_transaction.rb +47 -0
- data/lib/solana/ruby/kit/rpc/client.rb +83 -0
- data/lib/solana/ruby/kit/rpc/transport.rb +137 -0
- data/lib/solana/ruby/kit/rpc.rb +13 -0
- data/lib/solana/ruby/kit/rpc_parsed_types/address_lookup_table.rb +33 -0
- data/lib/solana/ruby/kit/rpc_parsed_types/nonce_account.rb +33 -0
- data/lib/solana/ruby/kit/rpc_parsed_types/stake_account.rb +51 -0
- data/lib/solana/ruby/kit/rpc_parsed_types/token_account.rb +52 -0
- data/lib/solana/ruby/kit/rpc_parsed_types/vote_account.rb +38 -0
- data/lib/solana/ruby/kit/rpc_parsed_types.rb +16 -0
- data/lib/solana/ruby/kit/rpc_subscriptions/api/account_notifications.rb +29 -0
- data/lib/solana/ruby/kit/rpc_subscriptions/api/logs_notifications.rb +28 -0
- data/lib/solana/ruby/kit/rpc_subscriptions/api/program_notifications.rb +30 -0
- data/lib/solana/ruby/kit/rpc_subscriptions/api/root_notifications.rb +19 -0
- data/lib/solana/ruby/kit/rpc_subscriptions/api/signature_notifications.rb +28 -0
- data/lib/solana/ruby/kit/rpc_subscriptions/api/slot_notifications.rb +19 -0
- data/lib/solana/ruby/kit/rpc_subscriptions/autopinger.rb +42 -0
- data/lib/solana/ruby/kit/rpc_subscriptions/client.rb +80 -0
- data/lib/solana/ruby/kit/rpc_subscriptions/subscription.rb +58 -0
- data/lib/solana/ruby/kit/rpc_subscriptions/transport.rb +163 -0
- data/lib/solana/ruby/kit/rpc_subscriptions.rb +12 -0
- data/lib/solana/ruby/kit/rpc_types/account_info.rb +53 -0
- data/lib/solana/ruby/kit/rpc_types/cluster_url.rb +56 -0
- data/lib/solana/ruby/kit/rpc_types/commitment.rb +52 -0
- data/lib/solana/ruby/kit/rpc_types/lamports.rb +43 -0
- data/lib/solana/ruby/kit/rpc_types.rb +8 -0
- data/lib/solana/ruby/kit/signers/keypair_signer.rb +126 -0
- data/lib/solana/ruby/kit/signers.rb +5 -0
- data/lib/solana/ruby/kit/subscribable/async_iterable.rb +80 -0
- data/lib/solana/ruby/kit/subscribable/data_publisher.rb +90 -0
- data/lib/solana/ruby/kit/subscribable.rb +13 -0
- data/lib/solana/ruby/kit/sysvars/addresses.rb +19 -0
- data/lib/solana/ruby/kit/sysvars/clock.rb +37 -0
- data/lib/solana/ruby/kit/sysvars/epoch_schedule.rb +34 -0
- data/lib/solana/ruby/kit/sysvars/last_restart_slot.rb +22 -0
- data/lib/solana/ruby/kit/sysvars/rent.rb +29 -0
- data/lib/solana/ruby/kit/sysvars.rb +33 -0
- data/lib/solana/ruby/kit/transaction_confirmation.rb +159 -0
- data/lib/solana/ruby/kit/transaction_messages/transaction_message.rb +168 -0
- data/lib/solana/ruby/kit/transaction_messages.rb +5 -0
- data/lib/solana/ruby/kit/transactions/transaction.rb +135 -0
- data/lib/solana/ruby/kit/transactions.rb +5 -0
- data/lib/solana/ruby/kit/version.rb +10 -0
- data/lib/solana/ruby/kit.rb +100 -0
- data/solana-ruby-kit.gemspec +29 -0
- metadata +311 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Solana::Ruby::Kit
|
|
5
|
+
module RpcSubscriptions
|
|
6
|
+
# A live subscription to a Solana WebSocket notification channel.
|
|
7
|
+
# Wraps a DataPublisher channel and exposes an Enumerator interface.
|
|
8
|
+
#
|
|
9
|
+
# Typical usage:
|
|
10
|
+
# sub = client.slot_subscribe
|
|
11
|
+
# sub.take(10).each { |notification| puts notification }
|
|
12
|
+
# sub.unsubscribe # or break from the enumerator loop
|
|
13
|
+
class Subscription
|
|
14
|
+
extend T::Sig
|
|
15
|
+
extend T::Generic
|
|
16
|
+
|
|
17
|
+
Elem = type_member { { fixed: T.untyped } }
|
|
18
|
+
|
|
19
|
+
sig { returns(T::Enumerator[T.untyped]) }
|
|
20
|
+
attr_reader :enumerator
|
|
21
|
+
|
|
22
|
+
sig do
|
|
23
|
+
params(
|
|
24
|
+
enumerator: T::Enumerator[T.untyped],
|
|
25
|
+
unsubscribe: T.proc.void,
|
|
26
|
+
timeout: T.nilable(Float)
|
|
27
|
+
).void
|
|
28
|
+
end
|
|
29
|
+
def initialize(enumerator:, unsubscribe:, timeout: nil)
|
|
30
|
+
@enumerator = enumerator
|
|
31
|
+
@unsubscribe = unsubscribe
|
|
32
|
+
@timeout = timeout
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Stop the subscription and clean up.
|
|
36
|
+
sig { void }
|
|
37
|
+
def unsubscribe
|
|
38
|
+
@unsubscribe.call
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Delegate Enumerable methods to the enumerator for convenience.
|
|
42
|
+
sig { override.params(block: T.proc.params(item: T.untyped).void).void }
|
|
43
|
+
def each(&block)
|
|
44
|
+
@enumerator.each(&block)
|
|
45
|
+
ensure
|
|
46
|
+
unsubscribe
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
sig { params(n: Integer).returns(T::Array[T.untyped]) }
|
|
50
|
+
def take(n) = @enumerator.take(n)
|
|
51
|
+
|
|
52
|
+
sig { returns(T.untyped) }
|
|
53
|
+
def next = @enumerator.next
|
|
54
|
+
|
|
55
|
+
include Enumerable
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'websocket-client-simple'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'thread'
|
|
7
|
+
|
|
8
|
+
module Solana::Ruby::Kit
|
|
9
|
+
module RpcSubscriptions
|
|
10
|
+
# WebSocket JSON-RPC transport for Solana subscription methods.
|
|
11
|
+
# Runs in a background thread; dispatches incoming messages to a
|
|
12
|
+
# DataPublisher keyed by subscription ID.
|
|
13
|
+
#
|
|
14
|
+
# Thread safety: all public methods are Mutex-protected.
|
|
15
|
+
class Transport
|
|
16
|
+
extend T::Sig
|
|
17
|
+
|
|
18
|
+
# Channel name used for raw incoming messages (before routing to sub-ID).
|
|
19
|
+
MESSAGE_CHANNEL = T.let(:__message__, Symbol)
|
|
20
|
+
ERROR_CHANNEL = T.let(:error, Symbol)
|
|
21
|
+
CLOSE_CHANNEL = T.let(:close, Symbol)
|
|
22
|
+
|
|
23
|
+
sig { returns(String) }
|
|
24
|
+
attr_reader :url
|
|
25
|
+
|
|
26
|
+
sig do
|
|
27
|
+
params(
|
|
28
|
+
url: String,
|
|
29
|
+
headers: T::Hash[String, String],
|
|
30
|
+
send_buffer_high_watermark: Integer
|
|
31
|
+
).void
|
|
32
|
+
end
|
|
33
|
+
def initialize(url:, headers: {}, send_buffer_high_watermark: 40)
|
|
34
|
+
@url = url
|
|
35
|
+
@headers = headers
|
|
36
|
+
@hwm = send_buffer_high_watermark
|
|
37
|
+
@mutex = T.let(Mutex.new, Mutex)
|
|
38
|
+
@id_seq = T.let(0, Integer)
|
|
39
|
+
@pending = T.let({}, T::Hash[Integer, Queue]) # request id → response Queue
|
|
40
|
+
@subscribers = T.let(
|
|
41
|
+
Hash.new { |h, k| h[k] = [] },
|
|
42
|
+
T::Hash[T.untyped, T::Array[T.proc.params(msg: T::Hash[String, T.untyped]).void]]
|
|
43
|
+
)
|
|
44
|
+
@send_buffer = T.let([], T::Array[String])
|
|
45
|
+
@ws = T.let(nil, T.nilable(WebSocket::Client::Simple::Client))
|
|
46
|
+
@connected = T.let(false, T::Boolean)
|
|
47
|
+
@publisher = T.let(Subscribable::DataPublisher.new, Subscribable::DataPublisher)
|
|
48
|
+
|
|
49
|
+
_connect
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Send a JSON-RPC request and return the result synchronously (blocks).
|
|
53
|
+
sig do
|
|
54
|
+
params(method: String, params: T::Array[T.untyped])
|
|
55
|
+
.returns(T.untyped)
|
|
56
|
+
end
|
|
57
|
+
def request(method, params = [])
|
|
58
|
+
id = _next_id
|
|
59
|
+
q = Queue.new
|
|
60
|
+
@mutex.synchronize { @pending[id] = q }
|
|
61
|
+
|
|
62
|
+
payload = JSON.generate({ 'jsonrpc' => '2.0', 'id' => id, 'method' => method, 'params' => params })
|
|
63
|
+
_send(payload)
|
|
64
|
+
|
|
65
|
+
response = q.pop
|
|
66
|
+
raise Rpc::RpcError.new(response['error']['message'], response['error']['code']) if response['error']
|
|
67
|
+
|
|
68
|
+
response['result']
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Subscribe to a channel (subscription ID from the server).
|
|
72
|
+
# Returns an unsubscribe lambda.
|
|
73
|
+
sig do
|
|
74
|
+
params(
|
|
75
|
+
sub_id: T.untyped,
|
|
76
|
+
block: T.proc.params(msg: T::Hash[String, T.untyped]).void
|
|
77
|
+
).returns(T.proc.void)
|
|
78
|
+
end
|
|
79
|
+
def subscribe(sub_id, &block)
|
|
80
|
+
@mutex.synchronize { T.must(@subscribers[sub_id]) << block }
|
|
81
|
+
lambda { @mutex.synchronize { T.must(@subscribers[sub_id]).delete(block) } }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Access the underlying DataPublisher for AsyncIterable integration.
|
|
85
|
+
sig { returns(Subscribable::DataPublisher) }
|
|
86
|
+
attr_reader :publisher
|
|
87
|
+
|
|
88
|
+
sig { void }
|
|
89
|
+
def close
|
|
90
|
+
@ws&.close
|
|
91
|
+
@publisher.close
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
sig { returns(Integer) }
|
|
97
|
+
def _next_id
|
|
98
|
+
@mutex.synchronize { @id_seq += 1; @id_seq }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
sig { params(payload: String).void }
|
|
102
|
+
def _send(payload)
|
|
103
|
+
if @connected
|
|
104
|
+
@ws&.send(payload)
|
|
105
|
+
else
|
|
106
|
+
@send_buffer << payload
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
sig { void }
|
|
111
|
+
def _connect # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
|
112
|
+
transport = self
|
|
113
|
+
@ws = WebSocket::Client::Simple.connect(@url, headers: @headers)
|
|
114
|
+
|
|
115
|
+
ws = @ws
|
|
116
|
+
ws.on(:open) do
|
|
117
|
+
transport.instance_variable_set(:@connected, true)
|
|
118
|
+
transport.instance_variable_get(:@send_buffer).each { |m| ws.send(m) }
|
|
119
|
+
transport.instance_variable_get(:@send_buffer).clear
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
ws.on(:message) do |msg|
|
|
123
|
+
next unless msg.type == :text
|
|
124
|
+
|
|
125
|
+
begin
|
|
126
|
+
data = JSON.parse(msg.data)
|
|
127
|
+
rescue JSON::ParserError
|
|
128
|
+
next
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
if data.key?('id')
|
|
132
|
+
# Response to a request
|
|
133
|
+
id = data['id']
|
|
134
|
+
q = transport.instance_variable_get(:@mutex).synchronize do
|
|
135
|
+
transport.instance_variable_get(:@pending).delete(id)
|
|
136
|
+
end
|
|
137
|
+
q&.push(data)
|
|
138
|
+
elsif data.key?('method')
|
|
139
|
+
# Push notification (subscription event)
|
|
140
|
+
sub_id = data.dig('params', 'subscription')
|
|
141
|
+
subs = transport.instance_variable_get(:@mutex).synchronize do
|
|
142
|
+
(transport.instance_variable_get(:@subscribers)[sub_id] || []).dup
|
|
143
|
+
end
|
|
144
|
+
subs.each { |blk| blk.call(data) }
|
|
145
|
+
transport.instance_variable_get(:@publisher).publish(sub_id, data)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
ws.on(:error) do |e|
|
|
150
|
+
transport.instance_variable_get(:@publisher).publish(
|
|
151
|
+
Subscribable::DataPublisher::ERROR_CHANNEL,
|
|
152
|
+
e.is_a?(StandardError) ? e : RuntimeError.new(e.to_s)
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
ws.on(:close) do
|
|
157
|
+
transport.instance_variable_set(:@connected, false)
|
|
158
|
+
transport.instance_variable_get(:@publisher).close
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Mirrors @solana/rpc-subscriptions + @solana/rpc-subscriptions-api.
|
|
5
|
+
# Provides a WebSocket-based subscription client for Solana push notifications.
|
|
6
|
+
require_relative 'subscribable'
|
|
7
|
+
require_relative 'rpc_subscriptions/client'
|
|
8
|
+
|
|
9
|
+
module Solana::Ruby::Kit
|
|
10
|
+
module RpcSubscriptions
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative '../addresses/address'
|
|
5
|
+
|
|
6
|
+
module Solana::Ruby::Kit
|
|
7
|
+
module RpcTypes
|
|
8
|
+
# Base fields present in every account-info response.
|
|
9
|
+
# Mirrors TypeScript's `AccountInfoBase`.
|
|
10
|
+
class AccountInfoBase < T::Struct
|
|
11
|
+
const :executable, T::Boolean
|
|
12
|
+
# Balance in lamports.
|
|
13
|
+
const :lamports, Integer
|
|
14
|
+
# Address of the program that owns this account.
|
|
15
|
+
const :owner, String # base58 address string
|
|
16
|
+
# Allocated storage in bytes (excludes the 128-byte header).
|
|
17
|
+
# TypeScript uses bigint; Ruby uses Integer.
|
|
18
|
+
const :space, Integer
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Account info with raw base64-encoded data (the most common encoding).
|
|
22
|
+
# Mirrors `AccountInfoWithBase64EncodedData`.
|
|
23
|
+
class AccountInfoWithBase64Data < T::Struct
|
|
24
|
+
const :executable, T::Boolean
|
|
25
|
+
const :lamports, Integer
|
|
26
|
+
const :owner, String
|
|
27
|
+
const :space, Integer
|
|
28
|
+
const :rent_epoch, Integer, default: 0
|
|
29
|
+
# Tuple: [base64_string, "base64"]
|
|
30
|
+
const :data, T::Array[String]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Account info with JSON-parsed data (program-specific parsing on the RPC node).
|
|
34
|
+
# Mirrors `AccountInfoWithJsonData`.
|
|
35
|
+
class AccountInfoWithJsonData < T::Struct
|
|
36
|
+
const :executable, T::Boolean
|
|
37
|
+
const :lamports, Integer
|
|
38
|
+
const :owner, String
|
|
39
|
+
const :space, Integer
|
|
40
|
+
const :rent_epoch, Integer, default: 0
|
|
41
|
+
# Either a hash (parsed JSON) or a base64 fallback tuple.
|
|
42
|
+
const :data, T.untyped
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Slot + context wrapper returned by context-aware RPC methods.
|
|
46
|
+
# Mirrors TypeScript's `SolanaRpcResponse<T>`:
|
|
47
|
+
# { context: { slot: bigint }, value: T }
|
|
48
|
+
class RpcContextualValue < T::Struct
|
|
49
|
+
const :slot, Integer # the slot at which the data was read
|
|
50
|
+
const :value, T.untyped # the actual result (typed by the caller)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Solana::Ruby::Kit
|
|
5
|
+
module RpcTypes
|
|
6
|
+
extend T::Sig
|
|
7
|
+
# Well-known cluster endpoint URLs.
|
|
8
|
+
MAINNET_URL = T.let('https://api.mainnet-beta.solana.com', String)
|
|
9
|
+
DEVNET_URL = T.let('https://api.devnet.solana.com', String)
|
|
10
|
+
TESTNET_URL = T.let('https://api.testnet.solana.com', String)
|
|
11
|
+
|
|
12
|
+
# A cluster-tagged URL string.
|
|
13
|
+
# Mirrors TypeScript's branded string types:
|
|
14
|
+
# MainnetUrl, DevnetUrl, TestnetUrl, ClusterUrl
|
|
15
|
+
#
|
|
16
|
+
# In Ruby we carry the cluster tag as a symbol on a wrapper struct,
|
|
17
|
+
# since Ruby cannot brand primitive String values.
|
|
18
|
+
class ClusterUrl < T::Struct
|
|
19
|
+
extend T::Sig
|
|
20
|
+
const :url, String
|
|
21
|
+
const :cluster, T.nilable(Symbol) # :mainnet | :devnet | :testnet | nil
|
|
22
|
+
|
|
23
|
+
sig { returns(String) }
|
|
24
|
+
def to_s = @url
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# Wraps a URL string and tags it as mainnet.
|
|
30
|
+
# Mirrors `mainnet(url)`.
|
|
31
|
+
sig { params(url: String).returns(ClusterUrl) }
|
|
32
|
+
def mainnet(url = MAINNET_URL)
|
|
33
|
+
ClusterUrl.new(url: url, cluster: :mainnet)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Wraps a URL string and tags it as devnet.
|
|
37
|
+
# Mirrors `devnet(url)`.
|
|
38
|
+
sig { params(url: String).returns(ClusterUrl) }
|
|
39
|
+
def devnet(url = DEVNET_URL)
|
|
40
|
+
ClusterUrl.new(url: url, cluster: :devnet)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Wraps a URL string and tags it as testnet.
|
|
44
|
+
# Mirrors `testnet(url)`.
|
|
45
|
+
sig { params(url: String).returns(ClusterUrl) }
|
|
46
|
+
def testnet(url = TESTNET_URL)
|
|
47
|
+
ClusterUrl.new(url: url, cluster: :testnet)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Wraps a custom URL with no cluster tag.
|
|
51
|
+
sig { params(url: String).returns(ClusterUrl) }
|
|
52
|
+
def cluster_url(url)
|
|
53
|
+
ClusterUrl.new(url: url, cluster: nil)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Solana::Ruby::Kit
|
|
5
|
+
module RpcTypes
|
|
6
|
+
extend T::Sig
|
|
7
|
+
# Network confirmation level for an RPC request.
|
|
8
|
+
# Mirrors TypeScript's `Commitment = 'finalized' | 'confirmed' | 'processed'`.
|
|
9
|
+
#
|
|
10
|
+
# Each level is a measure of how many validators have confirmed a block:
|
|
11
|
+
# - :finalized — the block has been voted on by a supermajority and is permanent.
|
|
12
|
+
# - :confirmed — the block has been voted on by a supermajority (not yet rooted).
|
|
13
|
+
# - :processed — the node has seen the block but it may yet be rolled back.
|
|
14
|
+
Commitment = T.type_alias { Symbol }
|
|
15
|
+
|
|
16
|
+
FINALIZED = T.let(:finalized, Symbol)
|
|
17
|
+
CONFIRMED = T.let(:confirmed, Symbol)
|
|
18
|
+
PROCESSED = T.let(:processed, Symbol)
|
|
19
|
+
|
|
20
|
+
VALID_COMMITMENTS = T.let(
|
|
21
|
+
[FINALIZED, CONFIRMED, PROCESSED].freeze,
|
|
22
|
+
T::Array[Symbol]
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# Returns a numeric score for a commitment level (higher = more confirmed).
|
|
28
|
+
# Mirrors `getCommitmentScore()`.
|
|
29
|
+
sig { params(commitment: Symbol).returns(Integer) }
|
|
30
|
+
def commitment_score(commitment)
|
|
31
|
+
case commitment
|
|
32
|
+
when FINALIZED then 2
|
|
33
|
+
when CONFIRMED then 1
|
|
34
|
+
when PROCESSED then 0
|
|
35
|
+
else Kernel.raise ArgumentError, "Unknown commitment: #{commitment.inspect}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Compares two commitment levels. Returns -1, 0, or 1.
|
|
40
|
+
# Mirrors `commitmentComparator()`.
|
|
41
|
+
sig { params(a: Symbol, b: Symbol).returns(Integer) }
|
|
42
|
+
def commitment_comparator(a, b)
|
|
43
|
+
commitment_score(a) <=> commitment_score(b)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Returns true if the symbol is a valid commitment level.
|
|
47
|
+
sig { params(value: T.untyped).returns(T::Boolean) }
|
|
48
|
+
def commitment?(value)
|
|
49
|
+
VALID_COMMITMENTS.include?(value)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative '../errors'
|
|
5
|
+
|
|
6
|
+
module Solana::Ruby::Kit
|
|
7
|
+
module RpcTypes
|
|
8
|
+
extend T::Sig
|
|
9
|
+
# The smallest denomination of SOL (1 SOL = 1_000_000_000 lamports).
|
|
10
|
+
# Mirrors TypeScript's `Lamports` branded bigint.
|
|
11
|
+
# In Ruby, arbitrary-precision Integer replaces JS bigint with no loss of range.
|
|
12
|
+
#
|
|
13
|
+
# Valid range: 0 .. 18_446_744_073_709_551_615 (u64)
|
|
14
|
+
Lamports = T.type_alias { Integer }
|
|
15
|
+
|
|
16
|
+
LAMPORTS_U64_MAX = T.let(T.unsafe(2**64 - 1), Integer)
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# Returns true if the integer is a valid u64 lamport value.
|
|
21
|
+
# Mirrors `isLamports()`.
|
|
22
|
+
sig { params(value: T.untyped).returns(T::Boolean) }
|
|
23
|
+
def lamports?(value)
|
|
24
|
+
!!(value.is_a?(Integer) && value >= 0 && value <= LAMPORTS_U64_MAX)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Raises SolanaError if the value is not a valid lamport amount.
|
|
28
|
+
# Mirrors `assertIsLamports()`.
|
|
29
|
+
sig { params(value: T.untyped).void }
|
|
30
|
+
def assert_lamports!(value)
|
|
31
|
+
Kernel.raise SolanaError.new(:SOLANA_ERROR__LAMPORTS__AMOUNT_MUST_BE_POSITIVE) if value.is_a?(Integer) && value < 0
|
|
32
|
+
Kernel.raise SolanaError.new(:SOLANA_ERROR__LAMPORTS__AMOUNT_OUT_OF_RANGE) unless lamports?(value)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Validates and returns the lamport value.
|
|
36
|
+
# Mirrors `lamports()`.
|
|
37
|
+
sig { params(value: Integer).returns(Integer) }
|
|
38
|
+
def lamports(value)
|
|
39
|
+
assert_lamports!(value)
|
|
40
|
+
value
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Core RPC type definitions — mirrors @solana/rpc-types.
|
|
5
|
+
require_relative 'rpc_types/commitment'
|
|
6
|
+
require_relative 'rpc_types/lamports'
|
|
7
|
+
require_relative 'rpc_types/cluster_url'
|
|
8
|
+
require_relative 'rpc_types/account_info'
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'rbnacl'
|
|
5
|
+
require_relative '../errors'
|
|
6
|
+
require_relative '../addresses/address'
|
|
7
|
+
require_relative '../keys/key_pair'
|
|
8
|
+
require_relative '../keys/signatures'
|
|
9
|
+
require_relative '../transactions/transaction'
|
|
10
|
+
|
|
11
|
+
module Solana::Ruby::Kit
|
|
12
|
+
module Signers
|
|
13
|
+
extend T::Sig
|
|
14
|
+
# A signer backed by an Ed25519 key pair, capable of signing both
|
|
15
|
+
# transaction messages and off-chain messages.
|
|
16
|
+
#
|
|
17
|
+
# Mirrors TypeScript's `KeyPairSigner<TAddress>`, which combines
|
|
18
|
+
# `MessagePartialSigner` and `TransactionPartialSigner` with a
|
|
19
|
+
# `CryptoKeyPair` reference.
|
|
20
|
+
#
|
|
21
|
+
# In Ruby we keep it simple: the signer holds an RbNaCl::SigningKey
|
|
22
|
+
# (which contains the private key seed and can derive the public key)
|
|
23
|
+
# and exposes the signer's address.
|
|
24
|
+
class KeyPairSigner
|
|
25
|
+
extend T::Sig
|
|
26
|
+
|
|
27
|
+
sig { returns(Addresses::Address) }
|
|
28
|
+
attr_reader :address
|
|
29
|
+
|
|
30
|
+
sig { returns(Keys::KeyPair) }
|
|
31
|
+
attr_reader :key_pair
|
|
32
|
+
|
|
33
|
+
sig { params(key_pair: Keys::KeyPair).void }
|
|
34
|
+
def initialize(key_pair)
|
|
35
|
+
@key_pair = T.let(key_pair, Keys::KeyPair)
|
|
36
|
+
@address = T.let(
|
|
37
|
+
Addresses::Address.new(Addresses.encode_address(key_pair.verify_key.to_bytes)),
|
|
38
|
+
Addresses::Address
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
sig { returns(String) }
|
|
43
|
+
def to_s = @address.to_s
|
|
44
|
+
|
|
45
|
+
sig { returns(String) }
|
|
46
|
+
def inspect = "#<KeyPairSigner address=#{@address}>"
|
|
47
|
+
|
|
48
|
+
# Signs raw bytes with the private key, returning a SignatureBytes.
|
|
49
|
+
# Used internally by sign_transaction and sign_message.
|
|
50
|
+
sig { params(data: String).returns(Keys::SignatureBytes) }
|
|
51
|
+
def sign(data)
|
|
52
|
+
Keys.sign_bytes(@key_pair.signing_key, data)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Verifies a signature against data using this signer's public key.
|
|
56
|
+
sig { params(sig_bytes: Keys::SignatureBytes, data: String).returns(T::Boolean) }
|
|
57
|
+
def verify(sig_bytes, data)
|
|
58
|
+
Keys.verify_signature(@key_pair.verify_key, sig_bytes, data)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
module_function
|
|
63
|
+
|
|
64
|
+
# Creates a KeyPairSigner from an existing Keys::KeyPair.
|
|
65
|
+
# Mirrors `createSignerFromKeyPair(keyPair)`.
|
|
66
|
+
sig { params(key_pair: Keys::KeyPair).returns(KeyPairSigner) }
|
|
67
|
+
def create_signer_from_key_pair(key_pair)
|
|
68
|
+
KeyPairSigner.new(key_pair)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Generates a fresh random key pair and wraps it in a KeyPairSigner.
|
|
72
|
+
# Mirrors `generateKeyPairSigner()`.
|
|
73
|
+
sig { returns(KeyPairSigner) }
|
|
74
|
+
def generate_key_pair_signer
|
|
75
|
+
KeyPairSigner.new(Keys.generate_key_pair)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Creates a KeyPairSigner from 64 raw bytes (seed || public key).
|
|
79
|
+
# Mirrors `createKeyPairSignerFromBytes(bytes)`.
|
|
80
|
+
sig { params(bytes: String).returns(KeyPairSigner) }
|
|
81
|
+
def create_key_pair_signer_from_bytes(bytes)
|
|
82
|
+
KeyPairSigner.new(Keys.create_key_pair_from_bytes(bytes))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Creates a KeyPairSigner from a 32-byte private key seed.
|
|
86
|
+
# Mirrors `createKeyPairSignerFromPrivateKeyBytes(bytes)`.
|
|
87
|
+
sig { params(bytes: String).returns(KeyPairSigner) }
|
|
88
|
+
def create_key_pair_signer_from_private_key_bytes(bytes)
|
|
89
|
+
KeyPairSigner.new(Keys.create_key_pair_from_private_key_bytes(bytes))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Returns true if the value is a KeyPairSigner.
|
|
93
|
+
# Mirrors `isKeyPairSigner(value)`.
|
|
94
|
+
sig { params(value: T.untyped).returns(T::Boolean) }
|
|
95
|
+
def key_pair_signer?(value)
|
|
96
|
+
value.is_a?(KeyPairSigner)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Raises SolanaError if the value is not a KeyPairSigner.
|
|
100
|
+
# Mirrors `assertIsKeyPairSigner(value)`.
|
|
101
|
+
sig { params(value: T.untyped).void }
|
|
102
|
+
def assert_key_pair_signer!(value)
|
|
103
|
+
Kernel.raise SolanaError.new(:SOLANA_ERROR__SIGNER__EXPECTED_KEY_PAIR_SIGNER) unless key_pair_signer?(value)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Signs a transaction message's bytes using all provided signers.
|
|
107
|
+
# Returns a hash mapping each signer's address to its SignatureBytes.
|
|
108
|
+
#
|
|
109
|
+
# This is the Ruby analogue of the TypeScript signers' sign-transaction
|
|
110
|
+
# workflow: each signer produces its Ed25519 signature of the compiled
|
|
111
|
+
# message bytes, and the signatures are collected into a map.
|
|
112
|
+
#
|
|
113
|
+
# Mirrors the combined behaviour of `TransactionPartialSigner.signTransactions`.
|
|
114
|
+
sig do
|
|
115
|
+
params(
|
|
116
|
+
signers: T::Array[KeyPairSigner],
|
|
117
|
+
message_bytes: String
|
|
118
|
+
).returns(T::Hash[String, Keys::SignatureBytes])
|
|
119
|
+
end
|
|
120
|
+
def sign_message_bytes_with_signers(signers, message_bytes)
|
|
121
|
+
signers.each_with_object({}) do |signer, map|
|
|
122
|
+
map[signer.address.value] = signer.sign(message_bytes)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Solana::Ruby::Kit
|
|
5
|
+
module Subscribable
|
|
6
|
+
# Creates an Enumerator backed by a thread-safe Queue.
|
|
7
|
+
#
|
|
8
|
+
# Mirrors createAsyncIterableFromDataPublisher from @solana/subscribable.
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# enum = AsyncIterable.from_publisher(publisher, data_channel: :accountNotification)
|
|
12
|
+
# enum.each { |notification| process(notification) }
|
|
13
|
+
#
|
|
14
|
+
# The enumerator blocks on Queue#pop until the publisher closes or the
|
|
15
|
+
# optional +timeout+ expires. When the publisher emits on the error
|
|
16
|
+
# channel the exception is re-raised inside the enumerator.
|
|
17
|
+
module AsyncIterable
|
|
18
|
+
extend T::Sig
|
|
19
|
+
|
|
20
|
+
# Sentinel value pushed into the queue when the stream is done.
|
|
21
|
+
DONE = T.let(Object.new.freeze, Object)
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# @param publisher [DataPublisher]
|
|
26
|
+
# @param data_channel channel name for data messages
|
|
27
|
+
# @param error_channel channel name for errors (default: :error)
|
|
28
|
+
# @param timeout [Float, nil] per-item pop timeout in seconds
|
|
29
|
+
# @return [Enumerator]
|
|
30
|
+
sig do
|
|
31
|
+
params(
|
|
32
|
+
publisher: DataPublisher,
|
|
33
|
+
data_channel: T.untyped,
|
|
34
|
+
error_channel: T.untyped,
|
|
35
|
+
timeout: T.nilable(Float)
|
|
36
|
+
).returns(T::Enumerator[T.untyped])
|
|
37
|
+
end
|
|
38
|
+
def from_publisher(publisher, data_channel:, error_channel: :error, timeout: nil)
|
|
39
|
+
queue = T.let(Queue.new, Queue)
|
|
40
|
+
|
|
41
|
+
# Subscribe to data
|
|
42
|
+
unsub_data = publisher.on(data_channel) { |data| queue.push([:data, data]) }
|
|
43
|
+
|
|
44
|
+
# Subscribe to errors — re-raise inside the enumerator
|
|
45
|
+
unsub_error = publisher.on(error_channel) { |err| queue.push([:error, err]) }
|
|
46
|
+
|
|
47
|
+
# Subscribe to close
|
|
48
|
+
unsub_close = publisher.on(DataPublisher::CLOSE_CHANNEL) { queue.push([:done, nil]) }
|
|
49
|
+
|
|
50
|
+
cleanup = Kernel.lambda do
|
|
51
|
+
unsub_data.call
|
|
52
|
+
unsub_error.call
|
|
53
|
+
unsub_close.call
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
Enumerator.new do |yielder|
|
|
57
|
+
Kernel.loop do
|
|
58
|
+
kind, payload = if timeout
|
|
59
|
+
begin
|
|
60
|
+
Timeout.timeout(timeout) { queue.pop }
|
|
61
|
+
rescue Timeout::Error
|
|
62
|
+
[:done, nil]
|
|
63
|
+
end
|
|
64
|
+
else
|
|
65
|
+
queue.pop
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
case kind
|
|
69
|
+
when :data then yielder.yield(payload)
|
|
70
|
+
when :error then Kernel.raise T.cast(payload, StandardError)
|
|
71
|
+
else break
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
ensure
|
|
75
|
+
cleanup.call
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|