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 +7 -0
- data/CHANGELOG.md +59 -0
- data/LICENSE +21 -0
- data/README.md +91 -0
- data/lib/solana/auth_verifier.rb +90 -0
- data/lib/solana/borsh.rb +116 -0
- data/lib/solana/client.rb +182 -0
- data/lib/solana/keypair.rb +106 -0
- data/lib/solana/spl_token.rb +93 -0
- data/lib/solana/transaction.rb +288 -0
- data/lib/solana-studio.rb +6 -0
- data/lib/solana_studio.rb +10 -0
- metadata +76 -0
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
|
data/lib/solana/borsh.rb
ADDED
|
@@ -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: []
|