solana-ruby-kit 0.1.6 → 0.1.7
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
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4a6261831e387559dd450f4da24966c5fb21a3d6d079eca64bc1c19577f1517c
|
|
4
|
+
data.tar.gz: bb79df5d4217eb20f8fc07b9ae89e6ce070fbcb38f193b4e775d38b1cfe72998
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6259e78a36520927779d1912d3cd2190690b207fdb538473ed2eabc47e153f9e16380570043087ff94aba46019ef66c5be25383f237f82ad21949cccdb6200c9
|
|
7
|
+
data.tar.gz: 9155affe61d202d5e9847b9df5da10958ee85089410cf0bd2f856b77087710d38b3318c3c178057fa9112cfee16d5bb85f51b3d9ae36bfcfce1c01b351f04a00
|
|
@@ -116,6 +116,10 @@ module Solana::Ruby::Kit
|
|
|
116
116
|
INSTRUCTION_PLANS__EMPTY_INSTRUCTION_PLAN = :SOLANA_ERROR__INSTRUCTION_PLANS__EMPTY_INSTRUCTION_PLAN
|
|
117
117
|
INSTRUCTION_PLANS__FAILED_TO_EXECUTE_TRANSACTION_PLAN = :SOLANA_ERROR__INSTRUCTION_PLANS__FAILED_TO_EXECUTE_TRANSACTION_PLAN
|
|
118
118
|
|
|
119
|
+
# ── Wallet Standard ───────────────────────────────────────────────────────
|
|
120
|
+
WALLET_STANDARD__INVALID_WIRE_FORMAT = :SOLANA_ERROR__WALLET_STANDARD__INVALID_WIRE_FORMAT
|
|
121
|
+
WALLET_STANDARD__SIGNATURE_VERIFICATION_FAILED = :SOLANA_ERROR__WALLET_STANDARD__SIGNATURE_VERIFICATION_FAILED
|
|
122
|
+
|
|
119
123
|
# ── Invariant violations (internal) ──────────────────────────────────────
|
|
120
124
|
INVARIANT_VIOLATION__SUBSCRIPTION_ITERATOR_STATE_MISSING = :SOLANA_ERROR__INVARIANT_VIOLATION__SUBSCRIPTION_ITERATOR_STATE_MISSING
|
|
121
125
|
INVARIANT_VIOLATION__SUBSCRIPTION_ITERATOR_MUST_NOT_POLL_BEFORE_RESOLVING_EXISTING_MESSAGE_PROMISE = :SOLANA_ERROR__INVARIANT_VIOLATION__SUBSCRIPTION_ITERATOR_MUST_NOT_POLL_BEFORE_RESOLVING_EXISTING_MESSAGE_PROMISE
|
|
@@ -227,6 +231,10 @@ module Solana::Ruby::Kit
|
|
|
227
231
|
INSTRUCTION_PLANS__EMPTY_INSTRUCTION_PLAN => 'Instruction plan is empty and produced no transaction messages',
|
|
228
232
|
INSTRUCTION_PLANS__FAILED_TO_EXECUTE_TRANSACTION_PLAN => 'Failed to execute transaction plan',
|
|
229
233
|
|
|
234
|
+
# Wallet Standard
|
|
235
|
+
WALLET_STANDARD__INVALID_WIRE_FORMAT => 'Invalid transaction wire format: %{reason}',
|
|
236
|
+
WALLET_STANDARD__SIGNATURE_VERIFICATION_FAILED => 'Signature verification failed for signer %{address}',
|
|
237
|
+
|
|
230
238
|
# Invariant violations
|
|
231
239
|
INVARIANT_VIOLATION__SUBSCRIPTION_ITERATOR_STATE_MISSING => 'Subscription iterator state is missing (internal error)',
|
|
232
240
|
INVARIANT_VIOLATION__SUBSCRIPTION_ITERATOR_MUST_NOT_POLL_BEFORE_RESOLVING_EXISTING_MESSAGE_PROMISE => 'Subscription iterator must not poll before resolving existing message (internal error)',
|
|
@@ -23,7 +23,7 @@ module Solana::Ruby::Kit
|
|
|
23
23
|
|
|
24
24
|
# The Stake Program address.
|
|
25
25
|
PROGRAM_ID = T.let(
|
|
26
|
-
Addresses::Address.new('
|
|
26
|
+
Addresses::Address.new('Stake11111111111111111111111111111111111111'),
|
|
27
27
|
Addresses::Address
|
|
28
28
|
)
|
|
29
29
|
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'base64'
|
|
5
|
+
require 'rbnacl'
|
|
6
|
+
require_relative 'addresses/address'
|
|
7
|
+
require_relative 'keys/signatures'
|
|
8
|
+
require_relative 'transactions/transaction'
|
|
9
|
+
require_relative 'errors'
|
|
10
|
+
|
|
11
|
+
module Solana::Ruby::Kit
|
|
12
|
+
# Server-side Ruby port of the @solana/wallet-standard signTransaction interface.
|
|
13
|
+
#
|
|
14
|
+
# When a browser wallet (Phantom, Backpack, Solflare, …) calls
|
|
15
|
+
# wallet.features['solana:signTransaction'].signTransaction(transaction)
|
|
16
|
+
# the signed transaction is returned as serialised wire bytes. Rails receives
|
|
17
|
+
# those bytes (typically base64-encoded in a JSON request body) and uses this
|
|
18
|
+
# module to:
|
|
19
|
+
#
|
|
20
|
+
# 1. Decode the wire bytes back into a +Transactions::Transaction+ struct.
|
|
21
|
+
# 2. Verify every present Ed25519 signature against the message bytes.
|
|
22
|
+
# 3. Confirm that a specific wallet address (or all required addresses) signed.
|
|
23
|
+
# 4. Optionally broadcast the verified transaction via +Rpc+.
|
|
24
|
+
#
|
|
25
|
+
# Solana addresses ARE Ed25519 public keys (32-byte base58), so signature
|
|
26
|
+
# verification requires no external key lookup — the verify key is derived
|
|
27
|
+
# directly from the signer's address string.
|
|
28
|
+
#
|
|
29
|
+
# Typical Rails controller usage:
|
|
30
|
+
#
|
|
31
|
+
# wire_bytes = Base64.strict_decode64(params[:signed_transaction])
|
|
32
|
+
# tx = Solana::Ruby::Kit::WalletStandard.verify_signed_transaction!(wire_bytes)
|
|
33
|
+
# Solana::Ruby::Kit::Transactions.assert_fully_signed_transaction!(tx)
|
|
34
|
+
# # tx is now safe to inspect or forward to the RPC node
|
|
35
|
+
#
|
|
36
|
+
# Mirrors @solana/wallet-standard (anza-xyz/kit) and @wallet-standard/features.
|
|
37
|
+
module WalletStandard
|
|
38
|
+
extend T::Sig
|
|
39
|
+
|
|
40
|
+
# ── Wallet Standard feature identifiers ──────────────────────────────────
|
|
41
|
+
# String constants that identify which signing capabilities a browser wallet
|
|
42
|
+
# supports. Client-side JavaScript reads wallet.features to detect these;
|
|
43
|
+
# the constants are documented here so Rails code can reference them by name
|
|
44
|
+
# when building feature-negotiation metadata for the frontend.
|
|
45
|
+
|
|
46
|
+
# The wallet can sign transactions without broadcasting them.
|
|
47
|
+
SIGN_TRANSACTION = T.let('solana:signTransaction', String)
|
|
48
|
+
# The wallet can sign and broadcast in a single round-trip.
|
|
49
|
+
SIGN_AND_SEND_TRANSACTION = T.let('solana:signAndSendTransaction', String)
|
|
50
|
+
# The wallet can sign arbitrary off-chain messages.
|
|
51
|
+
SIGN_MESSAGE = T.let('solana:signMessage', String)
|
|
52
|
+
# Base Wallet Standard lifecycle features.
|
|
53
|
+
CONNECT = T.let('standard:connect', String)
|
|
54
|
+
DISCONNECT = T.let('standard:disconnect', String)
|
|
55
|
+
EVENTS = T.let('standard:events', String)
|
|
56
|
+
|
|
57
|
+
module_function
|
|
58
|
+
|
|
59
|
+
# ── Wire-format decoder ───────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
# Decodes a Solana wire-encoded transaction into a +Transactions::Transaction+.
|
|
62
|
+
#
|
|
63
|
+
# Accepts the binary output of a wallet's +signTransaction+ call. Binary
|
|
64
|
+
# (ASCII_8BIT) strings are used as-is; any other encoding is treated as
|
|
65
|
+
# standard base64 and decoded automatically.
|
|
66
|
+
#
|
|
67
|
+
# Solana wire format (legacy; v0 versioned transactions add a one-byte
|
|
68
|
+
# version prefix to the message section):
|
|
69
|
+
#
|
|
70
|
+
# [compact-u16] num_signatures
|
|
71
|
+
# [64 bytes × n] signature slots (all-zero slot means unfilled)
|
|
72
|
+
# [message bytes …]
|
|
73
|
+
#
|
|
74
|
+
# The returned Transaction carries:
|
|
75
|
+
# +message_bytes+ — raw bytes that were (or will be) signed
|
|
76
|
+
# +signatures+ — ordered Hash of signer_address → raw_sig_bytes or nil
|
|
77
|
+
#
|
|
78
|
+
# Raises +SolanaError::WALLET_STANDARD__INVALID_WIRE_FORMAT+ for malformed input.
|
|
79
|
+
#
|
|
80
|
+
# Mirrors +getTransactionDecoder()+ from @solana/transactions.
|
|
81
|
+
sig { params(wire_bytes: String).returns(Transactions::Transaction) }
|
|
82
|
+
def decode_wire_transaction(wire_bytes)
|
|
83
|
+
bytes = coerce_to_binary(wire_bytes)
|
|
84
|
+
offset = 0
|
|
85
|
+
|
|
86
|
+
# 1. Signature count (compact-u16)
|
|
87
|
+
sig_count, offset = decode_compact_u16(bytes, offset)
|
|
88
|
+
if sig_count > 19
|
|
89
|
+
Kernel.raise SolanaError.new(
|
|
90
|
+
SolanaError::WALLET_STANDARD__INVALID_WIRE_FORMAT,
|
|
91
|
+
reason: "signature count #{sig_count} exceeds the maximum of 19"
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# 2. Signature slots — 64 bytes each; all-zero means the slot is unfilled.
|
|
96
|
+
zero64 = ("\x00" * 64).b
|
|
97
|
+
raw_sigs = T.let([], T::Array[T.nilable(String)])
|
|
98
|
+
sig_count.times do
|
|
99
|
+
slot = bytes[offset, 64]
|
|
100
|
+
if slot.nil? || slot.bytesize != 64
|
|
101
|
+
Kernel.raise SolanaError.new(
|
|
102
|
+
SolanaError::WALLET_STANDARD__INVALID_WIRE_FORMAT,
|
|
103
|
+
reason: 'truncated signature slot'
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
raw_sigs << (slot.b == zero64 ? nil : slot.b)
|
|
107
|
+
offset += 64
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# 3. Everything after the signatures section is the message.
|
|
111
|
+
msg_slice = bytes[offset..]
|
|
112
|
+
if msg_slice.nil? || msg_slice.empty?
|
|
113
|
+
Kernel.raise SolanaError.new(
|
|
114
|
+
SolanaError::WALLET_STANDARD__INVALID_WIRE_FORMAT,
|
|
115
|
+
reason: 'message section is missing'
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
message_bytes = T.must(msg_slice).b
|
|
119
|
+
|
|
120
|
+
# 4. Determine version and locate the message header.
|
|
121
|
+
# Legacy transactions: first byte = num_required_signatures (0..127)
|
|
122
|
+
# Versioned (v0) trans.: first byte = 0x80 | version (≥ 128); skip it.
|
|
123
|
+
msg_pos = 0
|
|
124
|
+
first_byte = message_bytes.getbyte(msg_pos).to_i
|
|
125
|
+
msg_pos += 1 if (first_byte & 0x80) != 0
|
|
126
|
+
|
|
127
|
+
num_required_sigs = message_bytes.getbyte(msg_pos).to_i
|
|
128
|
+
msg_pos += 1
|
|
129
|
+
msg_pos += 2 # skip num_readonly_signed + num_readonly_unsigned
|
|
130
|
+
|
|
131
|
+
# 5. Parse the account list to recover signer addresses.
|
|
132
|
+
num_accounts, msg_pos = decode_compact_u16(message_bytes, msg_pos)
|
|
133
|
+
if msg_pos + (num_accounts * 32) > message_bytes.bytesize
|
|
134
|
+
Kernel.raise SolanaError.new(
|
|
135
|
+
SolanaError::WALLET_STANDARD__INVALID_WIRE_FORMAT,
|
|
136
|
+
reason: 'account table truncated'
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
account_addrs = T.let([], T::Array[String])
|
|
141
|
+
num_accounts.times do
|
|
142
|
+
account_addrs << Addresses.encode_address(message_bytes[msg_pos, 32].b)
|
|
143
|
+
msg_pos += 32
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# 6. Build the signatures map.
|
|
147
|
+
# The first +num_required_sigs+ accounts in the account table are the
|
|
148
|
+
# signers; their raw_sigs slots are positionally aligned.
|
|
149
|
+
signer_addrs = account_addrs[0, num_required_sigs]
|
|
150
|
+
signatures = T.let({}, T::Hash[String, T.nilable(String)])
|
|
151
|
+
signer_addrs.each_with_index { |addr, i| signatures[addr] = raw_sigs[i] }
|
|
152
|
+
|
|
153
|
+
Transactions::Transaction.new(message_bytes: message_bytes, signatures: signatures)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# ── Signature verification ────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
# Verifies every filled (non-nil) signature in +transaction.signatures+.
|
|
159
|
+
#
|
|
160
|
+
# For each signed slot the signer's Ed25519 public key is reconstructed
|
|
161
|
+
# directly from their address (Solana addresses are base58-encoded 32-byte
|
|
162
|
+
# Ed25519 public keys), so no external key lookup is required.
|
|
163
|
+
#
|
|
164
|
+
# Raises +SolanaError::WALLET_STANDARD__SIGNATURE_VERIFICATION_FAILED+ if
|
|
165
|
+
# any signature does not verify. Nil (unfilled) slots are skipped silently;
|
|
166
|
+
# use +Transactions.assert_fully_signed_transaction!+ to enforce that all
|
|
167
|
+
# required signers have signed.
|
|
168
|
+
sig { params(transaction: Transactions::Transaction).void }
|
|
169
|
+
def verify_transaction_signatures!(transaction)
|
|
170
|
+
transaction.signatures.each do |addr_str, sig_raw|
|
|
171
|
+
next if sig_raw.nil?
|
|
172
|
+
|
|
173
|
+
verify_key = RbNaCl::VerifyKey.new(Addresses.decode_address(Addresses::Address.new(addr_str)))
|
|
174
|
+
sig_bytes = Keys::SignatureBytes.new(sig_raw)
|
|
175
|
+
|
|
176
|
+
unless Keys.verify_signature(verify_key, sig_bytes, transaction.message_bytes)
|
|
177
|
+
Kernel.raise SolanaError.new(
|
|
178
|
+
SolanaError::WALLET_STANDARD__SIGNATURE_VERIFICATION_FAILED,
|
|
179
|
+
address: addr_str
|
|
180
|
+
)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Convenience method for Rails controllers: decode wire bytes received from a
|
|
186
|
+
# browser wallet and verify all present signatures in one call.
|
|
187
|
+
#
|
|
188
|
+
# Returns the decoded +Transaction+ on success.
|
|
189
|
+
# Raises +SolanaError+ for malformed wire bytes or any invalid signature.
|
|
190
|
+
# Does NOT require the transaction to be fully signed; follow up with
|
|
191
|
+
# +Transactions.assert_fully_signed_transaction!+ when broadcasting.
|
|
192
|
+
#
|
|
193
|
+
# Accepts binary or base64-encoded input (see +decode_wire_transaction+).
|
|
194
|
+
sig { params(wire_bytes: String).returns(Transactions::Transaction) }
|
|
195
|
+
def verify_signed_transaction!(wire_bytes)
|
|
196
|
+
tx = decode_wire_transaction(wire_bytes)
|
|
197
|
+
verify_transaction_signatures!(tx)
|
|
198
|
+
tx
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Returns +true+ if +address+ has provided a cryptographically valid
|
|
202
|
+
# signature for +transaction+; +false+ if the slot is absent, nil, or the
|
|
203
|
+
# signature does not verify against +transaction.message_bytes+.
|
|
204
|
+
sig { params(transaction: Transactions::Transaction, address: Addresses::Address).returns(T::Boolean) }
|
|
205
|
+
def signed_by?(transaction, address)
|
|
206
|
+
sig_raw = transaction.signatures[address.value]
|
|
207
|
+
return false if sig_raw.nil?
|
|
208
|
+
|
|
209
|
+
verify_key = RbNaCl::VerifyKey.new(Addresses.decode_address(address))
|
|
210
|
+
sig_bytes = Keys::SignatureBytes.new(sig_raw)
|
|
211
|
+
Keys.verify_signature(verify_key, sig_bytes, transaction.message_bytes)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# ── Private helpers ───────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
# Forces +input+ to a binary (ASCII_8BIT) String.
|
|
217
|
+
# ASCII_8BIT input is returned as-is; anything else is treated as base64.
|
|
218
|
+
sig { params(input: String).returns(String) }
|
|
219
|
+
def coerce_to_binary(input)
|
|
220
|
+
return input.b if input.encoding == ::Encoding::ASCII_8BIT
|
|
221
|
+
|
|
222
|
+
stripped = input.strip
|
|
223
|
+
begin
|
|
224
|
+
Base64.strict_decode64(stripped).b
|
|
225
|
+
rescue ArgumentError
|
|
226
|
+
Base64.decode64(stripped).b
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
private_class_method :coerce_to_binary
|
|
230
|
+
|
|
231
|
+
# Reads a Solana compact-u16 from +bytes+ starting at +offset+.
|
|
232
|
+
# Returns +[decoded_integer, next_offset]+.
|
|
233
|
+
sig { params(bytes: String, offset: Integer).returns([Integer, Integer]) }
|
|
234
|
+
def decode_compact_u16(bytes, offset)
|
|
235
|
+
value = 0
|
|
236
|
+
shift = 0
|
|
237
|
+
loop do
|
|
238
|
+
byte = bytes.getbyte(offset)
|
|
239
|
+
if byte.nil?
|
|
240
|
+
Kernel.raise SolanaError.new(
|
|
241
|
+
SolanaError::WALLET_STANDARD__INVALID_WIRE_FORMAT,
|
|
242
|
+
reason: 'compact-u16 read past end of buffer'
|
|
243
|
+
)
|
|
244
|
+
end
|
|
245
|
+
offset += 1
|
|
246
|
+
value |= (byte & 0x7f) << shift
|
|
247
|
+
shift += 7
|
|
248
|
+
break unless (byte & 0x80) != 0
|
|
249
|
+
end
|
|
250
|
+
[value, offset]
|
|
251
|
+
end
|
|
252
|
+
private_class_method :decode_compact_u16
|
|
253
|
+
end
|
|
254
|
+
end
|
data/lib/solana/ruby/kit.rb
CHANGED
|
@@ -41,6 +41,9 @@ require_relative 'kit/rpc_subscriptions'
|
|
|
41
41
|
# ── Plugin system ─────────────────────────────────────────────────────────────
|
|
42
42
|
require_relative 'kit/plugin_core'
|
|
43
43
|
|
|
44
|
+
# ── Wallet Standard (server-side signature verification) ──────────────────────
|
|
45
|
+
require_relative 'kit/wallet_standard'
|
|
46
|
+
|
|
44
47
|
# ── Higher-level helpers ──────────────────────────────────────────────────────
|
|
45
48
|
require_relative 'kit/offchain_messages'
|
|
46
49
|
require_relative 'kit/programs'
|
|
@@ -75,6 +78,7 @@ require_relative 'kit/instruction_plans'
|
|
|
75
78
|
# Solana::Ruby::Kit::Sysvars — sysvar fetch/decode (@solana/sysvars)
|
|
76
79
|
# Solana::Ruby::Kit::TransactionConfirmation — confirmation polling (@solana/transaction-confirmation)
|
|
77
80
|
# Solana::Ruby::Kit::InstructionPlans — multi-tx planning (@solana/instruction-plans)
|
|
81
|
+
# Solana::Ruby::Kit::WalletStandard — server-side sig verify (@solana/wallet-standard)
|
|
78
82
|
|
|
79
83
|
module Solana::Ruby::Kit
|
|
80
84
|
extend T::Sig
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: solana-ruby-kit
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.7
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Paul Zupan, Idhra Inc.
|
|
@@ -296,6 +296,7 @@ files:
|
|
|
296
296
|
- lib/solana/ruby/kit/transactions/compiler.rb
|
|
297
297
|
- lib/solana/ruby/kit/transactions/transaction.rb
|
|
298
298
|
- lib/solana/ruby/kit/version.rb
|
|
299
|
+
- lib/solana/ruby/kit/wallet_standard.rb
|
|
299
300
|
- solana-ruby-kit.gemspec
|
|
300
301
|
licenses:
|
|
301
302
|
- MIT
|