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: dddb792ac729b3981104e68f18131191f6775d864e785d25cd47ad5d169e94cc
4
- data.tar.gz: 4fd955c2fb214186034906194a530ad62461ee7d3169b7e0718233de4331d4a8
3
+ metadata.gz: 4a6261831e387559dd450f4da24966c5fb21a3d6d079eca64bc1c19577f1517c
4
+ data.tar.gz: bb79df5d4217eb20f8fc07b9ae89e6ce070fbcb38f193b4e775d38b1cfe72998
5
5
  SHA512:
6
- metadata.gz: 9341acdd7f36e55078fa7a21989ec8e29aa00a351e52e3caa05a2f2b14ccee3fbe443f6b35d9914ccb4e53192e215e12659c4c7f83090bc2c027c9ff4fa52f87
7
- data.tar.gz: 0dc330cd1fc6f3177d283eb6b251e0ad187c92f482c328aae1c72a9881bf338206ff2a9fadf7ab3c0a9f4744ce798244bac7368cb8517beee3cb89252abcea75
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('Stake11111111111111111111111111111111111111111'),
26
+ Addresses::Address.new('Stake11111111111111111111111111111111111111'),
27
27
  Addresses::Address
28
28
  )
29
29
 
@@ -4,7 +4,7 @@
4
4
  module Solana
5
5
  module Ruby
6
6
  module Kit
7
- VERSION = '0.1.6'
7
+ VERSION = '0.1.7'
8
8
  end
9
9
  end
10
10
  end
@@ -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
@@ -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.6
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