solana-studio 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 39e8967f873b252f8204b4ff0be0a011b813f3744b2ee9c308b1e73740e9ca2a
4
+ data.tar.gz: 769171e5fd595cba388702be0e4dd6b1f9f46545451624393543ef329b2cc402
5
+ SHA512:
6
+ metadata.gz: '00851cd448aecb996b636f91ab2bf19192a0d64966223a06633b52584c38116ff32f3a163433a50a4299d15a72196426d14e65cb5aee599a0b71a98728f340ab'
7
+ data.tar.gz: 7fcbc86d1194c63055bf14f7fd3c23f1d117c872e295a9e20557fbf8528f20b88be991ac4daae0d276ac10b790037b98c52315170d8323d1082b2b072ce96136
data/CHANGELOG.md ADDED
@@ -0,0 +1,59 @@
1
+ # Changelog
2
+
3
+ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4
+
5
+ ## v0.4.1 (2026-05-17)
6
+
7
+ Pre-public-release security hardening per `SECURITY-AUDIT-2026-05-17.md`.
8
+
9
+ ### Fixed (security)
10
+ - **TLS enforcement in `Solana::Client`** — explicit `OpenSSL::SSL::VERIFY_PEER` and `TLS1_2_VERSION` minimum on every HTTPS RPC connection. Belt-and-suspenders against future downstream Net::HTTP regressions.
11
+ - **HTTPS-only RPC URL validation** — `Solana::Client` constructor now raises `Solana::Client::InsecureRpcUrlError` on `http://` URLs unless the host is `localhost`/`127.0.0.1`/`::1`. Prevents cleartext RPC traffic to public providers.
12
+ - **Borsh allocation-bomb guard** — `Solana::Borsh::MAX_DECODED_FIELD_BYTES = 10MB`. New `decode_string` + `decode_vec` helpers check the length prefix before allocating; raise `Solana::Borsh::DecodedFieldTooLarge` on overage. Protects callers from corrupt or malicious RPC responses.
13
+ - **Constant-time nonce compare in `Solana::AuthVerifier`** — `OpenSSL.fixed_length_secure_compare` replaces Ruby string `==`. Removes a (low-practical-impact) timing side channel.
14
+ - **Pubkey + signature length validation in `Solana::AuthVerifier.verify!`** — explicit checks before `Ed25519::VerifyKey.new` so malformed inputs raise `VerificationError("Public key must be 32 bytes...")` instead of being masked by the generic `"Signature verification failed"` catch-all.
15
+ - **Base58 input validation in `Solana::Keypair.decode_base58`** — explicit alphabet check raises `ArgumentError` with a clear message on invalid chars (`0`, `O`, `I`, `l`) instead of producing a confusing `TypeError` deep in the multiplication loop.
16
+
17
+ ### Changed
18
+ - `Solana::AuthVerifier` docstring now loudly states caller's responsibility for nonce invalidation (delete-before-verify pattern) and links to the canonical Rails session-adapter at `turf-monster/app/controllers/concerns/solana/session_auth.rb`.
19
+ - Gemspec author email changed from `alex@mcritchie.studio` (personal) to `solana-studio@mcritchie.studio` (project alias).
20
+
21
+ ## v0.4.0 (2026-05-17)
22
+
23
+ ### Changed (breaking)
24
+ - **Gem renamed from `solana_studio` to `solana-studio`.** Repo URL is now `github.com/amcritchie/solana-studio` (was `.../solana_studio`). Consumers must update their `Gemfile`:
25
+ ```ruby
26
+ # Before:
27
+ gem "solana_studio", git: "https://github.com/amcritchie/solana_studio.git", tag: "v0.3.0"
28
+ # After:
29
+ gem "solana-studio", git: "https://github.com/amcritchie/solana-studio.git", tag: "v0.4.0"
30
+ ```
31
+ - The Ruby `SolanaStudio` module name and the `Solana::*` namespace are **unchanged** — all call sites keep working without code changes.
32
+ - Gem entry point at `lib/solana-studio.rb` (a thin `require_relative "solana_studio"` shim) ensures `gem "solana-studio"` auto-requires correctly without a `require:` option in the Gemfile.
33
+
34
+ ### Added
35
+ - gemspec `metadata` (homepage / source / bugs / changelog URIs) — getting ready for RubyGems publishing.
36
+
37
+ ## v0.3.0 (2026-05-17)
38
+
39
+ ### Added
40
+ - **`Solana::AuthVerifier`** — pure module for verifying Phantom wallet signatures against an externally-stored nonce. Extracted from turf-monster's `app/services/solana/auth_verifier.rb`. Host apps keep a thin session adapter that delegates to `Solana::AuthVerifier.verify!`.
41
+ - `Solana::AuthVerifier::VerificationError`, `Solana::AuthVerifier::NONCE_MAX_AGE` constants now live in the gem.
42
+ - Updated CLAUDE.md with the gem-vs-app split rule for Solana code.
43
+
44
+ ### Fixed
45
+ - gemspec `spec.version` was bumped to "0.3.0" after the initial release (had been mistakenly left at "0.2.0").
46
+
47
+ ## v0.2.0 (2026-04-03)
48
+
49
+ - SPL Token instruction builders (`create_associated_token_account`, `mint_to`, `transfer`)
50
+ - Test suite: Keypair, Borsh, and Transaction tests (9 tests)
51
+ - Updated CLAUDE.md with test documentation
52
+
53
+ ## v0.1.0 (2026-04-02)
54
+
55
+ - Initial release
56
+ - `Solana::Client` — JSON-RPC over HTTP with retry logic
57
+ - `Solana::Keypair` — Ed25519 keygen, base58, sign, `from_base58` for env var loading
58
+ - `Solana::Borsh` — encode/decode primitives (u8, u16, u32, u64, i64, pubkey, string, vec, bool)
59
+ - `Solana::Transaction` — transaction builder, PDA derivation, Anchor discriminators, on_curve? check
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alex McRitchie
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,91 @@
1
+ # SolanaStudio
2
+
3
+ Ruby primitives for building on Solana — JSON-RPC client, Ed25519 keypairs, Borsh serialization, and transaction builder with PDA derivation.
4
+
5
+ > **Part of the McRitchie ecosystem** — see [`ECOSYSTEM.md`](https://github.com/amcritchie/mcritchie-studio/blob/main/docs/ECOSYSTEM.md) for the 5-repo map; [`house-burn-down.md`](https://github.com/amcritchie/mcritchie-studio/blob/main/docs/agents/system/house-burn-down.md) for fresh-Mac recovery.
6
+
7
+ ## Installation
8
+
9
+ ```ruby
10
+ # Gemfile
11
+ gem "solana-studio", git: "https://github.com/amcritchie/solana-studio.git"
12
+ ```
13
+
14
+ ## Usage
15
+
16
+ ### Keypair
17
+
18
+ ```ruby
19
+ require "solana-studio"
20
+
21
+ # Generate a new keypair
22
+ kp = Solana::Keypair.generate
23
+ kp.address # => "9Fy8P3DvKBh3awt1wr27g4CDh47oDqmJR2FAAQ1bc69D"
24
+ kp.to_bytes # => 64-byte Solana format
25
+
26
+ # Load from file or env
27
+ kp = Solana::Keypair.from_json_file("~/.config/solana/id.json")
28
+ kp = Solana::Keypair.from_base58(ENV["SOLANA_ADMIN_KEY"])
29
+
30
+ # Sign a message
31
+ signature = kp.sign("hello".b)
32
+ ```
33
+
34
+ ### Client (JSON-RPC)
35
+
36
+ ```ruby
37
+ client = Solana::Client.new(rpc_url: "https://api.devnet.solana.com")
38
+
39
+ client.get_balance("9Fy8P3DvKBh3awt...")
40
+ client.get_latest_blockhash
41
+ client.request_airdrop("9Fy8P3DvKBh3awt...", 1_000_000_000)
42
+ client.send_and_confirm(signed_tx_base64)
43
+ ```
44
+
45
+ ### Borsh Serialization
46
+
47
+ ```ruby
48
+ data = Solana::Borsh.encode_u64(1_000_000) +
49
+ Solana::Borsh.encode_string("hello") +
50
+ Solana::Borsh.encode_pubkey(kp.public_key_bytes)
51
+ ```
52
+
53
+ ### Transaction Builder
54
+
55
+ ```ruby
56
+ tx = Solana::Transaction.new
57
+ tx.set_recent_blockhash(client.get_latest_blockhash)
58
+ tx.add_signer(keypair)
59
+ tx.add_instruction(
60
+ program_id: "YourProgramId...",
61
+ accounts: [
62
+ { pubkey: keypair.public_key_bytes, is_signer: true, is_writable: true },
63
+ { pubkey: pda, is_signer: false, is_writable: true }
64
+ ],
65
+ data: Solana::Transaction.anchor_discriminator("your_instruction") + payload
66
+ )
67
+
68
+ signature = client.send_and_confirm(tx.serialize_base64)
69
+ ```
70
+
71
+ ### PDA Derivation
72
+
73
+ ```ruby
74
+ pda, bump = Solana::Transaction.find_pda(
75
+ ["vault".b, wallet_pubkey_bytes],
76
+ program_id_bytes
77
+ )
78
+ ```
79
+
80
+ ## Dependencies
81
+
82
+ - `ed25519` (~> 1.3) — Ed25519 signing
83
+ - Ruby stdlib only (net/http, json, digest, securerandom)
84
+
85
+ ## Development Notes
86
+
87
+ See [CLAUDE.md](./CLAUDE.md) for detailed development context including the full API reference, design decisions, and AI agent instructions.
88
+
89
+ ## License
90
+
91
+ MIT
@@ -0,0 +1,90 @@
1
+ require "ed25519"
2
+ require "openssl"
3
+
4
+ module Solana
5
+ # Verifies a Solana wallet signature against an externally-stored nonce.
6
+ # Pure module — no Rails / no session coupling. Host apps adapt their
7
+ # session storage and call `Solana::AuthVerifier.verify!`.
8
+ #
9
+ # **IMPORTANT — caller is responsible for replay prevention.** The host
10
+ # MUST invalidate `stored_nonce` immediately after this method returns
11
+ # (success OR failure). The canonical Rails-session adapter pattern is:
12
+ #
13
+ # stored_nonce = session.delete(:solana_nonce)
14
+ # nonce_at = session.delete(:solana_nonce_at)
15
+ # Solana::AuthVerifier.verify!(
16
+ # message: ..., signature_b58: ..., pubkey_b58: ...,
17
+ # stored_nonce: stored_nonce, nonce_at: nonce_at
18
+ # )
19
+ #
20
+ # The `session.delete(...)` BEFORE the verify! call is what prevents
21
+ # replay — once consumed, the nonce can never satisfy verify! again.
22
+ # See turf-monster `app/controllers/concerns/solana/session_auth.rb`
23
+ # for the production adapter.
24
+ module AuthVerifier
25
+ class VerificationError < StandardError; end
26
+
27
+ # Default max nonce age in seconds (5 minutes).
28
+ NONCE_MAX_AGE = 300
29
+
30
+ ED25519_PUBKEY_BYTES = 32
31
+ ED25519_SIGNATURE_BYTES = 64
32
+
33
+ # Verifies that `signature_b58` is a valid Ed25519 signature over
34
+ # `message` made by `pubkey_b58`, AND that the `Nonce: ...` field in
35
+ # the message matches `stored_nonce`, AND that the nonce is not stale.
36
+ #
37
+ # Returns the verified public key (base58 string) on success.
38
+ # Raises Solana::AuthVerifier::VerificationError on any failure.
39
+ #
40
+ # @param message [String] the signed message (must contain `Nonce: <value>`)
41
+ # @param signature_b58 [String] base58-encoded Ed25519 signature
42
+ # @param pubkey_b58 [String] base58-encoded public key
43
+ # @param stored_nonce [String, nil] the nonce the host issued + remembers
44
+ # @param nonce_at [Integer, nil] Unix timestamp when the nonce was issued
45
+ # @param max_age [Integer] seconds before a nonce expires (default 300)
46
+ def self.verify!(message:, signature_b58:, pubkey_b58:, stored_nonce:, nonce_at: nil, max_age: NONCE_MAX_AGE)
47
+ raise VerificationError, "No nonce provided" if stored_nonce.nil? || stored_nonce.empty?
48
+
49
+ if nonce_at && (Time.now.to_i - nonce_at.to_i) > max_age
50
+ raise VerificationError, "Nonce expired"
51
+ end
52
+
53
+ sig_bytes = Solana::Keypair.decode_base58(signature_b58)
54
+ pub_bytes = Solana::Keypair.decode_base58(pubkey_b58)
55
+
56
+ # Length-check BEFORE handing to Ed25519::VerifyKey to surface a clean
57
+ # error (instead of letting the library raise ArgumentError, which the
58
+ # rescue below would convert into a misleading "Signature verification
59
+ # failed" message).
60
+ unless pub_bytes.bytesize == ED25519_PUBKEY_BYTES
61
+ raise VerificationError, "Public key must be #{ED25519_PUBKEY_BYTES} bytes, got #{pub_bytes.bytesize}"
62
+ end
63
+ unless sig_bytes.bytesize == ED25519_SIGNATURE_BYTES
64
+ raise VerificationError, "Signature must be #{ED25519_SIGNATURE_BYTES} bytes, got #{sig_bytes.bytesize}"
65
+ end
66
+
67
+ verify_key = Ed25519::VerifyKey.new(pub_bytes)
68
+ verify_key.verify(sig_bytes, message)
69
+
70
+ claimed_nonce = message.match(/Nonce: (\w+)/)&.captures&.first
71
+ unless claimed_nonce && constant_time_eq?(claimed_nonce, stored_nonce)
72
+ raise VerificationError, "Invalid nonce"
73
+ end
74
+
75
+ pubkey_b58
76
+ rescue Ed25519::VerifyError => e
77
+ raise VerificationError, "Signature verification failed: #{e.message}"
78
+ end
79
+
80
+ # Constant-time string equality, sourced from OpenSSL's fixed_length_secure_compare
81
+ # (available since Ruby 2.5+). Returns false (not raise) if lengths differ.
82
+ # Used for nonce comparison so attackers can't time-leak match progress.
83
+ def self.constant_time_eq?(a, b)
84
+ a = a.to_s
85
+ b = b.to_s
86
+ return false unless a.bytesize == b.bytesize
87
+ OpenSSL.fixed_length_secure_compare(a, b)
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,116 @@
1
+ module Solana
2
+ module Borsh
3
+ # Cap on the bytes any single length-prefixed decode (vec, string) can
4
+ # claim. Malicious / corrupt RPC responses can carry a length field of
5
+ # e.g. 4_000_000_000, which without this guard would OOM the process
6
+ # when the caller allocates accordingly. 10MB is more than any sane
7
+ # Solana account / instruction payload — adjust per consumer needs.
8
+ MAX_DECODED_FIELD_BYTES = 10 * 1024 * 1024
9
+
10
+ class DecodedFieldTooLarge < StandardError; end
11
+
12
+ module_function
13
+
14
+ def encode_u8(value)
15
+ [value].pack("C")
16
+ end
17
+
18
+ def encode_u16(value)
19
+ [value].pack("v") # little-endian u16
20
+ end
21
+
22
+ def encode_u32(value)
23
+ [value].pack("V") # little-endian u32
24
+ end
25
+
26
+ def encode_u64(value)
27
+ [value].pack("Q<") # little-endian u64
28
+ end
29
+
30
+ def encode_i64(value)
31
+ [value].pack("q<") # little-endian i64
32
+ end
33
+
34
+ def encode_pubkey(pubkey_bytes)
35
+ pubkey_bytes = Keypair.decode_base58(pubkey_bytes) if pubkey_bytes.is_a?(String) && pubkey_bytes.length != 32
36
+ pubkey_bytes = pubkey_bytes.b if pubkey_bytes.is_a?(String)
37
+ raise "Pubkey must be 32 bytes, got #{pubkey_bytes.bytesize}" unless pubkey_bytes.bytesize == 32
38
+ pubkey_bytes
39
+ end
40
+
41
+ def encode_bytes32(bytes)
42
+ bytes = bytes.b if bytes.is_a?(String)
43
+ raise "Expected 32 bytes, got #{bytes.bytesize}" unless bytes.bytesize == 32
44
+ bytes
45
+ end
46
+
47
+ def encode_vec(items, &block)
48
+ encoded_items = items.map { |item| block.call(item) }.join
49
+ encode_u32(items.length) + encoded_items
50
+ end
51
+
52
+ def encode_string(str)
53
+ bytes = str.encode("UTF-8").b
54
+ encode_u32(bytes.bytesize) + bytes
55
+ end
56
+
57
+ def encode_bool(value)
58
+ encode_u8(value ? 1 : 0)
59
+ end
60
+
61
+ # Decode helpers
62
+
63
+ def decode_u8(bytes, offset = 0)
64
+ [bytes.byteslice(offset, 1).unpack1("C"), offset + 1]
65
+ end
66
+
67
+ def decode_u16(bytes, offset = 0)
68
+ [bytes.byteslice(offset, 2).unpack1("v"), offset + 2]
69
+ end
70
+
71
+ def decode_u32(bytes, offset = 0)
72
+ [bytes.byteslice(offset, 4).unpack1("V"), offset + 4]
73
+ end
74
+
75
+ def decode_u64(bytes, offset = 0)
76
+ [bytes.byteslice(offset, 8).unpack1("Q<"), offset + 8]
77
+ end
78
+
79
+ def decode_pubkey(bytes, offset = 0)
80
+ [bytes.byteslice(offset, 32), offset + 32]
81
+ end
82
+
83
+ # Length-prefixed string. Reads u32 length then `length` bytes of UTF-8.
84
+ # Raises DecodedFieldTooLarge if the declared length exceeds the cap —
85
+ # protects callers from allocation-bomb DoS via crafted RPC responses.
86
+ def decode_string(bytes, offset = 0)
87
+ length, offset = decode_u32(bytes, offset)
88
+ check_field_length!(length, "string")
89
+ str = bytes.byteslice(offset, length).to_s.force_encoding("UTF-8")
90
+ [str, offset + length]
91
+ end
92
+
93
+ # Length-prefixed array. block is called per element with (bytes, offset)
94
+ # and must return [value, new_offset]. Bounded by MAX_DECODED_FIELD_BYTES
95
+ # on the declared count to prevent allocation-bomb DoS.
96
+ def decode_vec(bytes, offset = 0, &block)
97
+ length, offset = decode_u32(bytes, offset)
98
+ check_field_length!(length, "vec")
99
+ items = []
100
+ length.times do
101
+ item, offset = block.call(bytes, offset)
102
+ items << item
103
+ end
104
+ [items, offset]
105
+ end
106
+
107
+ def check_field_length!(length, kind)
108
+ if length > MAX_DECODED_FIELD_BYTES
109
+ raise DecodedFieldTooLarge,
110
+ "Borsh #{kind} declared length #{length} exceeds cap " \
111
+ "(MAX_DECODED_FIELD_BYTES=#{MAX_DECODED_FIELD_BYTES}). " \
112
+ "Likely a corrupt or malicious RPC response."
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,182 @@
1
+ require "net/http"
2
+ require "json"
3
+ require "uri"
4
+ require "openssl"
5
+
6
+ module Solana
7
+ class Client
8
+ class RpcError < StandardError
9
+ attr_reader :code
10
+ def initialize(message, code: nil)
11
+ @code = code
12
+ super(message)
13
+ end
14
+ end
15
+
16
+ class InsecureRpcUrlError < ArgumentError; end
17
+
18
+ MAX_RETRIES = 3
19
+ RETRY_DELAY = 1 # seconds
20
+
21
+ DEFAULT_RPC_URL = "https://api.devnet.solana.com"
22
+
23
+ # Hostnames where plain http:// is permitted (local testing only).
24
+ HTTP_OK_HOSTS = %w[localhost 127.0.0.1 ::1 0.0.0.0].freeze
25
+
26
+ def initialize(rpc_url: nil)
27
+ @rpc_url = rpc_url || ENV.fetch("SOLANA_RPC_URL", DEFAULT_RPC_URL)
28
+ @uri = URI.parse(@rpc_url)
29
+ validate_rpc_scheme!
30
+ @request_id = 0
31
+ end
32
+
33
+ def get_account_info(pubkey, encoding: "base64", commitment: nil)
34
+ config = { encoding: encoding }
35
+ config[:commitment] = commitment if commitment
36
+ call("getAccountInfo", [pubkey, config])
37
+ end
38
+
39
+ def get_token_account_balance(pubkey)
40
+ call("getTokenAccountBalance", [pubkey])
41
+ end
42
+
43
+ def get_latest_blockhash(commitment: "finalized")
44
+ result = call("getLatestBlockhash", [{ commitment: commitment }])
45
+ result.dig("value", "blockhash")
46
+ end
47
+
48
+ def get_minimum_balance_for_rent_exemption(size)
49
+ call("getMinimumBalanceForRentExemption", [size])
50
+ end
51
+
52
+ def send_transaction(signed_tx_base64, skip_preflight: false)
53
+ opts = { encoding: "base64", skipPreflight: skip_preflight }
54
+ call("sendTransaction", [signed_tx_base64, opts])
55
+ end
56
+
57
+ def confirm_transaction(signature, commitment: "confirmed")
58
+ call("getSignatureStatuses", [[signature], { searchTransactionHistory: true }])
59
+ end
60
+
61
+ def send_and_confirm(signed_tx_base64, timeout: 30, skip_preflight: false)
62
+ signature = send_transaction(signed_tx_base64, skip_preflight: skip_preflight)
63
+
64
+ deadline = Time.now + timeout
65
+ loop do
66
+ sleep 1
67
+ result = confirm_transaction(signature)
68
+ status = result.dig("value", 0)
69
+
70
+ if status
71
+ if status["err"]
72
+ raise RpcError.new("Transaction failed: #{status['err']}")
73
+ end
74
+ return signature if status["confirmationStatus"] == "confirmed" || status["confirmationStatus"] == "finalized"
75
+ end
76
+
77
+ raise RpcError.new("Transaction confirmation timeout") if Time.now > deadline
78
+ end
79
+ end
80
+
81
+ def request_airdrop(pubkey, lamports)
82
+ call("requestAirdrop", [pubkey, lamports])
83
+ end
84
+
85
+ def get_balance(pubkey)
86
+ call("getBalance", [pubkey])
87
+ end
88
+
89
+ def get_transaction(signature, commitment: "confirmed")
90
+ call("getTransaction", [signature, { encoding: "json", commitment: commitment }])
91
+ end
92
+
93
+ def get_token_accounts_by_owner(owner_pubkey)
94
+ call("getTokenAccountsByOwner", [
95
+ owner_pubkey,
96
+ { programId: "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" },
97
+ { encoding: "jsonParsed" }
98
+ ])
99
+ end
100
+
101
+ private
102
+
103
+ def call(method, params = [])
104
+ @request_id += 1
105
+ body = {
106
+ jsonrpc: "2.0",
107
+ id: @request_id,
108
+ method: method,
109
+ params: params
110
+ }
111
+
112
+ retries = 0
113
+ begin
114
+ response = http_post(body)
115
+ parsed = JSON.parse(response.body)
116
+
117
+ if parsed["error"]
118
+ error = parsed["error"]
119
+ raise RpcError.new(error["message"], code: error["code"])
120
+ end
121
+
122
+ parsed["result"]
123
+ rescue RpcError => e
124
+ # Retry on rate limit (429) or blockhash expiry
125
+ if retries < MAX_RETRIES && retryable_error?(e)
126
+ retries += 1
127
+ sleep RETRY_DELAY * retries
128
+ retry
129
+ end
130
+ raise
131
+ rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET => e
132
+ if retries < MAX_RETRIES
133
+ retries += 1
134
+ sleep RETRY_DELAY * retries
135
+ retry
136
+ end
137
+ raise RpcError.new("Network error: #{e.message}")
138
+ end
139
+ end
140
+
141
+ def http_post(body)
142
+ http = Net::HTTP.new(@uri.host, @uri.port)
143
+ if @uri.scheme == "https"
144
+ http.use_ssl = true
145
+ # Belt-and-suspenders: Net::HTTP defaults to VERIFY_PEER in modern Ruby
146
+ # but a) some older builds have shipped with weaker defaults and b)
147
+ # being explicit here protects against future regressions or downstream
148
+ # monkey-patches.
149
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
150
+ http.min_version = OpenSSL::SSL::TLS1_2_VERSION
151
+ end
152
+ http.open_timeout = 10
153
+ http.read_timeout = 30
154
+
155
+ request = Net::HTTP::Post.new(@uri.path.empty? ? "/" : @uri.path)
156
+ request["Content-Type"] = "application/json"
157
+ request.body = body.to_json
158
+
159
+ http.request(request)
160
+ end
161
+
162
+ def retryable_error?(error)
163
+ return true if error.code == 429 # rate limited
164
+ return true if error.message.include?("Blockhash not found")
165
+ false
166
+ end
167
+
168
+ # Reject plain http:// RPC URLs unless the host is local. Prevents
169
+ # accidental cleartext communication with public RPC providers.
170
+ def validate_rpc_scheme!
171
+ return if @uri.scheme == "https"
172
+ if @uri.scheme == "http" && HTTP_OK_HOSTS.include?(@uri.host.to_s.downcase)
173
+ return
174
+ end
175
+ raise InsecureRpcUrlError,
176
+ "Solana::Client requires an https:// RPC URL (got #{@rpc_url.inspect}). " \
177
+ "Plain http:// is only allowed for localhost. Set SOLANA_RPC_URL to a " \
178
+ "TLS endpoint (e.g. https://api.mainnet-beta.solana.com or your " \
179
+ "paid provider's HTTPS endpoint)."
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,106 @@
1
+ require "ed25519"
2
+ require "securerandom"
3
+ require "json"
4
+
5
+ module Solana
6
+ class Keypair
7
+ BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
8
+
9
+ attr_reader :signing_key, :verify_key
10
+
11
+ def initialize(signing_key)
12
+ @signing_key = signing_key
13
+ @verify_key = signing_key.verify_key
14
+ end
15
+
16
+ # Generate a new random keypair
17
+ def self.generate
18
+ new(Ed25519::SigningKey.generate)
19
+ end
20
+
21
+ # Load from raw 64-byte secret key (Solana format: 32-byte private + 32-byte public)
22
+ def self.from_bytes(bytes)
23
+ bytes = bytes.pack("C*") if bytes.is_a?(Array)
24
+ private_key = bytes[0, 32]
25
+ new(Ed25519::SigningKey.new(private_key))
26
+ end
27
+
28
+ # Load from Solana CLI keypair JSON file (array of 64 bytes)
29
+ def self.from_json_file(path)
30
+ bytes = JSON.parse(File.read(path))
31
+ from_bytes(bytes)
32
+ end
33
+
34
+ # Load from base58-encoded secret key (e.g. from env var)
35
+ def self.from_base58(secret_key_base58)
36
+ bytes = decode_base58(secret_key_base58)
37
+ from_bytes(bytes)
38
+ end
39
+
40
+ # Public key as 32 bytes
41
+ def public_key_bytes
42
+ @verify_key.to_bytes
43
+ end
44
+
45
+ # Public key as base58 string (Solana address)
46
+ def to_base58
47
+ self.class.encode_base58(public_key_bytes)
48
+ end
49
+ alias_method :address, :to_base58
50
+
51
+ # Sign a message
52
+ def sign(message)
53
+ message = message.pack("C*") if message.is_a?(Array)
54
+ @signing_key.sign(message)
55
+ end
56
+
57
+ # Full 64-byte secret key (Solana format)
58
+ def to_bytes
59
+ @signing_key.to_bytes + public_key_bytes
60
+ end
61
+
62
+ # --- Base58 utilities ---
63
+
64
+ def self.encode_base58(bytes)
65
+ bytes = bytes.b if bytes.is_a?(String)
66
+ num = bytes.unpack1("H*").to_i(16)
67
+
68
+ result = ""
69
+ while num > 0
70
+ num, remainder = num.divmod(58)
71
+ result = BASE58_ALPHABET[remainder] + result
72
+ end
73
+
74
+ # Preserve leading zero bytes
75
+ bytes.each_byte do |byte|
76
+ break unless byte == 0
77
+ result = "1" + result
78
+ end
79
+
80
+ result
81
+ end
82
+
83
+ def self.decode_base58(string)
84
+ raise ArgumentError, "decode_base58 requires a non-empty String, got #{string.inspect}" if string.nil? || string.empty?
85
+
86
+ num = 0
87
+ string.each_char do |c|
88
+ idx = BASE58_ALPHABET.index(c)
89
+ raise ArgumentError, "Invalid base58 character #{c.inspect} (not in alphabet: 0OIl excluded)" unless idx
90
+ num = num * 58 + idx
91
+ end
92
+
93
+ hex = num.to_s(16)
94
+ hex = "0" + hex if hex.length.odd?
95
+
96
+ # Count leading '1's (zero bytes)
97
+ leading_zeros = string.chars.take_while { |c| c == "1" }.length
98
+ bytes = [("00" * leading_zeros) + hex].pack("H*")
99
+ bytes
100
+ end
101
+
102
+ def self.pubkey_from_base58(address)
103
+ decode_base58(address)
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,93 @@
1
+ module Solana
2
+ module SplToken
3
+ module_function
4
+
5
+ # Derive the Associated Token Address for a wallet + mint
6
+ # Returns [ata_bytes, bump]
7
+ def find_associated_token_address(wallet, mint)
8
+ wallet_bytes = normalize(wallet)
9
+ mint_bytes = normalize(mint)
10
+
11
+ Transaction.find_pda(
12
+ [wallet_bytes, Transaction::TOKEN_PROGRAM_ID, mint_bytes],
13
+ Transaction::ASSOCIATED_TOKEN_PROGRAM_ID
14
+ )
15
+ end
16
+
17
+ # Build a CreateAssociatedTokenAccount instruction
18
+ # Returns hash compatible with Transaction#add_instruction
19
+ def create_associated_token_account_instruction(payer:, wallet:, mint:)
20
+ payer_bytes = normalize(payer)
21
+ wallet_bytes = normalize(wallet)
22
+ mint_bytes = normalize(mint)
23
+ ata_bytes, _ = find_associated_token_address(wallet_bytes, mint_bytes)
24
+
25
+ {
26
+ program_id: Transaction::ASSOCIATED_TOKEN_PROGRAM_ID,
27
+ accounts: [
28
+ { pubkey: payer_bytes, is_signer: true, is_writable: true },
29
+ { pubkey: ata_bytes, is_signer: false, is_writable: true },
30
+ { pubkey: wallet_bytes, is_signer: false, is_writable: false },
31
+ { pubkey: mint_bytes, is_signer: false, is_writable: false },
32
+ { pubkey: Transaction::SYSTEM_PROGRAM_ID, is_signer: false, is_writable: false },
33
+ { pubkey: Transaction::TOKEN_PROGRAM_ID, is_signer: false, is_writable: false }
34
+ ],
35
+ data: "".b
36
+ }
37
+ end
38
+
39
+ # Build a SPL Token MintTo instruction (discriminator byte 7)
40
+ # Returns hash compatible with Transaction#add_instruction
41
+ def mint_to_instruction(mint:, destination:, authority:, amount:)
42
+ mint_bytes = normalize(mint)
43
+ dest_bytes = normalize(destination)
44
+ auth_bytes = normalize(authority)
45
+
46
+ data = [7].pack("C") + [amount].pack("Q<")
47
+
48
+ {
49
+ program_id: Transaction::TOKEN_PROGRAM_ID,
50
+ accounts: [
51
+ { pubkey: mint_bytes, is_signer: false, is_writable: true },
52
+ { pubkey: dest_bytes, is_signer: false, is_writable: true },
53
+ { pubkey: auth_bytes, is_signer: true, is_writable: false }
54
+ ],
55
+ data: data
56
+ }
57
+ end
58
+
59
+ # Build a SPL Token Transfer instruction (discriminator byte 3)
60
+ # Returns hash compatible with Transaction#add_instruction
61
+ def transfer_instruction(from:, to:, authority:, amount:)
62
+ from_bytes = normalize(from)
63
+ to_bytes = normalize(to)
64
+ auth_bytes = normalize(authority)
65
+
66
+ data = [3].pack("C") + [amount].pack("Q<")
67
+
68
+ {
69
+ program_id: Transaction::TOKEN_PROGRAM_ID,
70
+ accounts: [
71
+ { pubkey: from_bytes, is_signer: false, is_writable: true },
72
+ { pubkey: to_bytes, is_signer: false, is_writable: true },
73
+ { pubkey: auth_bytes, is_signer: true, is_writable: false }
74
+ ],
75
+ data: data
76
+ }
77
+ end
78
+
79
+ # Normalize base58 strings, Keypair objects, or raw bytes to 32-byte binary
80
+ def normalize(value)
81
+ if value.is_a?(Keypair)
82
+ value.public_key_bytes
83
+ elsif value.is_a?(String) && value.bytesize == 32
84
+ value.b
85
+ elsif value.is_a?(String)
86
+ Keypair.decode_base58(value)
87
+ else
88
+ value
89
+ end
90
+ end
91
+ private_class_method :normalize
92
+ end
93
+ end
@@ -0,0 +1,288 @@
1
+ require "digest"
2
+
3
+ module Solana
4
+ class Transaction
5
+ SYSTEM_PROGRAM_ID = "\x00" * 32
6
+ TOKEN_PROGRAM_ID = Keypair.decode_base58("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA")
7
+ ASSOCIATED_TOKEN_PROGRAM_ID = Keypair.decode_base58("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL")
8
+ SYSVAR_RENT_PUBKEY = Keypair.decode_base58("SysvarRent111111111111111111111111111111111")
9
+
10
+ attr_reader :instructions, :signers
11
+
12
+ def initialize
13
+ @instructions = []
14
+ @signers = []
15
+ @recent_blockhash = nil
16
+ end
17
+
18
+ # Compute Anchor instruction discriminator: SHA256("global:<name>")[0..7]
19
+ def self.anchor_discriminator(name)
20
+ Digest::SHA256.digest("global:#{name}")[0, 8]
21
+ end
22
+
23
+ # Derive PDA (Program Derived Address)
24
+ def self.find_pda(seeds, program_id_bytes)
25
+ program_id_bytes = Keypair.decode_base58(program_id_bytes) if program_id_bytes.is_a?(String) && program_id_bytes.length != 32
26
+
27
+ 255.downto(0) do |bump|
28
+ candidate_seeds = seeds + [[bump].pack("C")]
29
+ begin
30
+ hash_input = candidate_seeds.map { |s| s.is_a?(String) ? s.b : s.pack("C*") }.join
31
+ hash_input += program_id_bytes.b
32
+ hash_input += "ProgramDerivedAddress".b
33
+
34
+ candidate = Digest::SHA256.digest(hash_input)
35
+
36
+ # Check if the point is on the Ed25519 curve — PDA must NOT be on curve
37
+ unless on_curve?(candidate)
38
+ return [candidate, bump]
39
+ end
40
+ rescue
41
+ next
42
+ end
43
+ end
44
+ raise "Could not find PDA"
45
+ end
46
+
47
+ def set_recent_blockhash(blockhash)
48
+ @recent_blockhash = Keypair.decode_base58(blockhash)
49
+ self
50
+ end
51
+
52
+ def add_signer(keypair)
53
+ @signers << keypair
54
+ self
55
+ end
56
+
57
+ def add_instruction(program_id:, accounts:, data:)
58
+ program_id_bytes = normalize_pubkey(program_id)
59
+ @instructions << {
60
+ program_id: program_id_bytes,
61
+ accounts: accounts.map { |a|
62
+ {
63
+ pubkey: normalize_pubkey(a[:pubkey]),
64
+ is_signer: a[:is_signer] || false,
65
+ is_writable: a[:is_writable] || false
66
+ }
67
+ },
68
+ data: data.is_a?(String) ? data.b : data.pack("C*")
69
+ }
70
+ self
71
+ end
72
+
73
+ # Serialize and sign the transaction
74
+ def serialize
75
+ raise "No blockhash set" unless @recent_blockhash
76
+ raise "No signers" if @signers.empty?
77
+ raise "No instructions" if @instructions.empty?
78
+
79
+ # Collect all unique accounts in order
80
+ account_keys = collect_account_keys
81
+ num_required_signatures = count_required_signatures(account_keys)
82
+ num_readonly_signed = count_readonly_signed(account_keys)
83
+ num_readonly_unsigned = count_readonly_unsigned(account_keys)
84
+
85
+ # Build message
86
+ message = build_message(account_keys, num_required_signatures, num_readonly_signed, num_readonly_unsigned)
87
+
88
+ # Sign message
89
+ signatures = @signers.map { |signer| signer.sign(message) }
90
+
91
+ # Compact-array encode signature count + signatures + message
92
+ compact_u16(signatures.length) + signatures.join.b + message
93
+ end
94
+
95
+ def serialize_base64
96
+ require "base64"
97
+ Base64.strict_encode64(serialize)
98
+ end
99
+
100
+ # Serialize with partial signing — signs with available signers, leaves
101
+ # zero-byte placeholders for additional_signers that must sign client-side.
102
+ # additional_signers: array of pubkey bytes (32-byte strings) that will sign later.
103
+ def serialize_partial(additional_signers: [])
104
+ raise "No blockhash set" unless @recent_blockhash
105
+ raise "No signers" if @signers.empty?
106
+ raise "No instructions" if @instructions.empty?
107
+
108
+ # Mark additional signers so they appear in the account keys
109
+ additional_signers.each do |pubkey_bytes|
110
+ pk = normalize_pubkey(pubkey_bytes)
111
+ @_additional_signers ||= []
112
+ @_additional_signers << pk
113
+ end
114
+
115
+ account_keys = collect_account_keys
116
+ num_required_signatures = count_required_signatures(account_keys)
117
+ num_readonly_signed = count_readonly_signed(account_keys)
118
+ num_readonly_unsigned = count_readonly_unsigned(account_keys)
119
+
120
+ message = build_message(account_keys, num_required_signatures, num_readonly_signed, num_readonly_unsigned)
121
+
122
+ # Build ordered signature slots matching the account key order
123
+ signer_map = {}
124
+ @signers.each { |s| signer_map[s.public_key_bytes] = s.sign(message) }
125
+
126
+ signatures = account_keys.select { |_, meta| meta[:is_signer] }.map do |pk, _|
127
+ signer_map[pk] || ("\x00" * 64).b # zero placeholder for unsigned slots
128
+ end
129
+
130
+ compact_u16(signatures.length) + signatures.join.b + message
131
+ ensure
132
+ @_additional_signers = nil
133
+ end
134
+
135
+ def serialize_partial_base64(additional_signers: [])
136
+ require "base64"
137
+ Base64.strict_encode64(serialize_partial(additional_signers: additional_signers))
138
+ end
139
+
140
+ private
141
+
142
+ def normalize_pubkey(key)
143
+ if key.is_a?(String) && key.bytesize == 32
144
+ key.b
145
+ elsif key.is_a?(String)
146
+ Keypair.decode_base58(key)
147
+ elsif key.is_a?(Keypair)
148
+ key.public_key_bytes
149
+ else
150
+ key
151
+ end
152
+ end
153
+
154
+ def collect_account_keys
155
+ keys = {}
156
+
157
+ # Fee payer (first signer) is always first
158
+ fee_payer = @signers.first.public_key_bytes
159
+ keys[fee_payer] = { is_signer: true, is_writable: true }
160
+
161
+ # Other signers
162
+ @signers[1..].each do |signer|
163
+ pk = signer.public_key_bytes
164
+ keys[pk] ||= { is_signer: true, is_writable: false }
165
+ keys[pk][:is_signer] = true
166
+ end
167
+
168
+ # Additional signers (for partial signing — not in @signers but must be marked as signer)
169
+ if @_additional_signers
170
+ @_additional_signers.each do |pk|
171
+ keys[pk] ||= { is_signer: true, is_writable: false }
172
+ keys[pk][:is_signer] = true
173
+ end
174
+ end
175
+
176
+ # Instruction accounts
177
+ @instructions.each do |ix|
178
+ ix[:accounts].each do |account|
179
+ pk = account[:pubkey]
180
+ keys[pk] ||= { is_signer: false, is_writable: false }
181
+ keys[pk][:is_signer] ||= account[:is_signer]
182
+ keys[pk][:is_writable] ||= account[:is_writable]
183
+ end
184
+ # Program ID (always readonly, unsigned)
185
+ keys[ix[:program_id]] ||= { is_signer: false, is_writable: false }
186
+ end
187
+
188
+ # Sort: signer+writable, signer+readonly, non-signer+writable, non-signer+readonly
189
+ # Fee payer stays first
190
+ sorted = keys.to_a.sort_by do |pk, meta|
191
+ if pk == fee_payer
192
+ [0, 0, 0]
193
+ elsif meta[:is_signer] && meta[:is_writable]
194
+ [0, 0, 1]
195
+ elsif meta[:is_signer]
196
+ [0, 1, 0]
197
+ elsif meta[:is_writable]
198
+ [1, 0, 0]
199
+ else
200
+ [1, 1, 0]
201
+ end
202
+ end
203
+
204
+ sorted
205
+ end
206
+
207
+ def count_required_signatures(account_keys)
208
+ account_keys.count { |_, meta| meta[:is_signer] }
209
+ end
210
+
211
+ def count_readonly_signed(account_keys)
212
+ account_keys.count { |_, meta| meta[:is_signer] && !meta[:is_writable] }
213
+ end
214
+
215
+ def count_readonly_unsigned(account_keys)
216
+ account_keys.count { |_, meta| !meta[:is_signer] && !meta[:is_writable] }
217
+ end
218
+
219
+ def build_message(account_keys, num_required_signatures, num_readonly_signed, num_readonly_unsigned)
220
+ msg = "".b
221
+
222
+ # Header
223
+ msg << [num_required_signatures, num_readonly_signed, num_readonly_unsigned].pack("CCC")
224
+
225
+ # Account keys (compact array)
226
+ msg << compact_u16(account_keys.length)
227
+ account_keys.each { |pk, _| msg << pk.b }
228
+
229
+ # Recent blockhash
230
+ msg << @recent_blockhash.b
231
+
232
+ # Instructions (compact array)
233
+ msg << compact_u16(@instructions.length)
234
+ key_index = account_keys.map { |pk, _| pk }.each_with_index.to_h
235
+
236
+ @instructions.each do |ix|
237
+ msg << [key_index[ix[:program_id]]].pack("C")
238
+ msg << compact_u16(ix[:accounts].length)
239
+ ix[:accounts].each do |account|
240
+ msg << [key_index[account[:pubkey]]].pack("C")
241
+ end
242
+ msg << compact_u16(ix[:data].bytesize)
243
+ msg << ix[:data]
244
+ end
245
+
246
+ msg
247
+ end
248
+
249
+ def compact_u16(value)
250
+ bytes = []
251
+ loop do
252
+ byte = value & 0x7F
253
+ value >>= 7
254
+ byte |= 0x80 if value > 0
255
+ bytes << byte
256
+ break if value == 0
257
+ end
258
+ bytes.pack("C*")
259
+ end
260
+
261
+ # Check if 32 bytes represent a valid Ed25519 public key (point on curve).
262
+ # PDA addresses must NOT be on the curve.
263
+ ED25519_P = (2**255) - 19
264
+ ED25519_D = (-121_665 * 121_666.pow(ED25519_P - 2, ED25519_P)) % ED25519_P
265
+
266
+ def self.on_curve?(bytes)
267
+ bytes = bytes.b
268
+ # Decode y-coordinate (little-endian, clear high bit)
269
+ y = bytes.unpack("C*").each_with_index.sum { |b, i| b * (256**i) }
270
+ y &= (2**255) - 1 # clear sign bit
271
+ return false if y >= ED25519_P
272
+
273
+ # Check if x^2 = (y^2 - 1) / (d*y^2 + 1) has a square root mod p
274
+ y2 = y.pow(2, ED25519_P)
275
+ u = (y2 - 1) % ED25519_P
276
+ v = (ED25519_D * y2 + 1) % ED25519_P
277
+
278
+ # Compute candidate: x = (u/v)^((p+3)/8) mod p
279
+ v_inv = v.pow(ED25519_P - 2, ED25519_P)
280
+ x2 = (u * v_inv) % ED25519_P
281
+ x = x2.pow((ED25519_P + 3) / 8, ED25519_P)
282
+
283
+ # Verify: v * x^2 must equal u or -u mod p
284
+ vx2 = (v * x.pow(2, ED25519_P)) % ED25519_P
285
+ vx2 == u % ED25519_P || vx2 == (ED25519_P - u) % ED25519_P
286
+ end
287
+ end
288
+ end
@@ -0,0 +1,6 @@
1
+ # Entry point for the `solana-studio` gem. The actual code lives in
2
+ # `lib/solana_studio.rb` (which exports the `SolanaStudio` module + the
3
+ # `Solana::*` namespace). This shim exists so `gem "solana-studio"` in
4
+ # a Gemfile loads correctly without consumers needing to add
5
+ # `require: "solana_studio"`.
6
+ require_relative "solana_studio"
@@ -0,0 +1,10 @@
1
+ require_relative "solana/keypair"
2
+ require_relative "solana/borsh"
3
+ require_relative "solana/client"
4
+ require_relative "solana/transaction"
5
+ require_relative "solana/spl_token"
6
+ require_relative "solana/auth_verifier"
7
+
8
+ module SolanaStudio
9
+ VERSION = "0.4.1"
10
+ end
metadata ADDED
@@ -0,0 +1,76 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: solana-studio
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.4.1
5
+ platform: ruby
6
+ authors:
7
+ - Alex McRitchie
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-05-18 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: ed25519
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.3'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.3'
27
+ description: A lightweight Ruby gem providing generic Solana building blocks — JSON-RPC
28
+ client with retry, Ed25519 keypair management, Borsh encoding/decoding, transaction
29
+ builder with PDA derivation and Anchor discriminators, SPL Token instruction helpers,
30
+ and a pure-Ruby wallet-signature verifier (Solana::AuthVerifier).
31
+ email:
32
+ - solana-studio@mcritchie.studio
33
+ executables: []
34
+ extensions: []
35
+ extra_rdoc_files: []
36
+ files:
37
+ - CHANGELOG.md
38
+ - LICENSE
39
+ - README.md
40
+ - lib/solana-studio.rb
41
+ - lib/solana/auth_verifier.rb
42
+ - lib/solana/borsh.rb
43
+ - lib/solana/client.rb
44
+ - lib/solana/keypair.rb
45
+ - lib/solana/spl_token.rb
46
+ - lib/solana/transaction.rb
47
+ - lib/solana_studio.rb
48
+ homepage: https://github.com/amcritchie/solana-studio
49
+ licenses:
50
+ - MIT
51
+ metadata:
52
+ homepage_uri: https://github.com/amcritchie/solana-studio
53
+ source_code_uri: https://github.com/amcritchie/solana-studio
54
+ bug_tracker_uri: https://github.com/amcritchie/solana-studio/issues
55
+ changelog_uri: https://github.com/amcritchie/solana-studio/blob/main/CHANGELOG.md
56
+ post_install_message:
57
+ rdoc_options: []
58
+ require_paths:
59
+ - lib
60
+ required_ruby_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: '3.0'
65
+ required_rubygems_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ requirements: []
71
+ rubygems_version: 3.5.11
72
+ signing_key:
73
+ specification_version: 4
74
+ summary: 'Ruby primitives for Solana: JSON-RPC client, Ed25519 keypairs, Borsh serialization,
75
+ transaction builder, wallet signature verifier'
76
+ test_files: []