solana-ruby-kit 0.1.5 → 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 +4 -4
- data/lib/solana/ruby/kit/errors.rb +8 -0
- data/lib/solana/ruby/kit/programs/stake_program.rb +162 -0
- data/lib/solana/ruby/kit/programs.rb +1 -0
- data/lib/solana/ruby/kit/version.rb +1 -1
- data/lib/solana/ruby/kit/wallet_standard.rb +254 -0
- data/lib/solana/ruby/kit.rb +4 -0
- metadata +5 -3
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)',
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative '../addresses/address'
|
|
5
|
+
require_relative '../encoding/base58'
|
|
6
|
+
require_relative '../instructions/instruction'
|
|
7
|
+
require_relative '../instructions/accounts'
|
|
8
|
+
require_relative '../sysvars/addresses'
|
|
9
|
+
require_relative 'system_program'
|
|
10
|
+
|
|
11
|
+
module Solana::Ruby::Kit
|
|
12
|
+
module Programs
|
|
13
|
+
# Ruby interface for the Solana Stake Program.
|
|
14
|
+
#
|
|
15
|
+
# The Stake Program manages stake accounts used for delegating SOL to
|
|
16
|
+
# validators and earning staking rewards. Instruction data uses
|
|
17
|
+
# little-endian encoding: a u32 discriminator followed by any
|
|
18
|
+
# instruction-specific fields.
|
|
19
|
+
#
|
|
20
|
+
# Reference: https://github.com/solana-labs/solana-web3.js/blob/master/packages/library-legacy/src/programs/stake.ts
|
|
21
|
+
module StakeProgram
|
|
22
|
+
extend T::Sig
|
|
23
|
+
|
|
24
|
+
# The Stake Program address.
|
|
25
|
+
PROGRAM_ID = T.let(
|
|
26
|
+
Addresses::Address.new('Stake11111111111111111111111111111111111111'),
|
|
27
|
+
Addresses::Address
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# The Stake Config sysvar / config account.
|
|
31
|
+
STAKE_CONFIG_ID = T.let(
|
|
32
|
+
Addresses::Address.new('StakeConfig11111111111111111111111111111111'),
|
|
33
|
+
Addresses::Address
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Number of bytes required to store a stake account on-chain.
|
|
37
|
+
STAKE_ACCOUNT_SPACE = T.let(200, Integer)
|
|
38
|
+
|
|
39
|
+
# Instruction discriminators (u32 little-endian).
|
|
40
|
+
DISCRIMINATOR_INITIALIZE = T.let(0, Integer)
|
|
41
|
+
DISCRIMINATOR_DELEGATE = T.let(2, Integer)
|
|
42
|
+
|
|
43
|
+
module_function
|
|
44
|
+
|
|
45
|
+
# Builds the two instructions needed to create and initialise a new stake
|
|
46
|
+
# account funded from +from+.
|
|
47
|
+
#
|
|
48
|
+
# Instruction 0 — System Program createAccount
|
|
49
|
+
# Data layout (52 bytes):
|
|
50
|
+
# [0..3] u32 LE = 0 (CREATE_ACCOUNT discriminant)
|
|
51
|
+
# [4..11] u64 LE = lamports
|
|
52
|
+
# [12..19] u64 LE = 200 (STAKE_ACCOUNT_SPACE)
|
|
53
|
+
# [20..51] 32 bytes (Stake Program ID — the owner to assign)
|
|
54
|
+
# Pack string: 'VQ<Q<a32'
|
|
55
|
+
#
|
|
56
|
+
# Instruction 1 — Stake Program Initialize
|
|
57
|
+
# Data layout (116 bytes):
|
|
58
|
+
# [0..3] u32 LE = 0 (Initialize discriminant)
|
|
59
|
+
# [4..35] 32 bytes (authorized staker pubkey)
|
|
60
|
+
# [36..67] 32 bytes (authorized withdrawer pubkey)
|
|
61
|
+
# [68..75] i64 LE = 0 (lockup unix_timestamp — no lockup)
|
|
62
|
+
# [76..83] u64 LE = 0 (lockup epoch — no lockup)
|
|
63
|
+
# [84..115] 32 bytes = \x00*32 (lockup custodian — zero pubkey)
|
|
64
|
+
# Pack string: 'Va32a32q<Q<a32'
|
|
65
|
+
#
|
|
66
|
+
# @param from [Addresses::Address] funding account (writable, signer)
|
|
67
|
+
# @param stake_account [Addresses::Address] new stake account (writable, signer)
|
|
68
|
+
# @param authorized [Addresses::Address] pubkey set as both staker and withdrawer
|
|
69
|
+
# @param lamports [Integer] total lamports to fund the stake account
|
|
70
|
+
# @return [T::Array[Instructions::Instruction]]
|
|
71
|
+
sig do
|
|
72
|
+
params(
|
|
73
|
+
from: Addresses::Address,
|
|
74
|
+
stake_account: Addresses::Address,
|
|
75
|
+
authorized: Addresses::Address,
|
|
76
|
+
lamports: Integer
|
|
77
|
+
).returns(T::Array[Instructions::Instruction])
|
|
78
|
+
end
|
|
79
|
+
def create_account_instructions(from:, stake_account:, authorized:, lamports:)
|
|
80
|
+
stake_program_bytes = Encoding::Base58.decode(PROGRAM_ID.value)
|
|
81
|
+
authorized_bytes = Encoding::Base58.decode(authorized.value)
|
|
82
|
+
zero_pubkey = ("\x00" * 32).b
|
|
83
|
+
|
|
84
|
+
# Instruction 0: System Program createAccount (discriminant = 0)
|
|
85
|
+
create_data = [0, lamports, STAKE_ACCOUNT_SPACE, stake_program_bytes].pack('VQ<Q<a32').b
|
|
86
|
+
|
|
87
|
+
create_account_ix = Instructions::Instruction.new(
|
|
88
|
+
program_address: SystemProgram::PROGRAM_ID,
|
|
89
|
+
accounts: [
|
|
90
|
+
Instructions.writable_signer_account(from), # 0 from
|
|
91
|
+
Instructions.writable_signer_account(stake_account), # 1 stake_account
|
|
92
|
+
],
|
|
93
|
+
data: create_data
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Instruction 1: Stake Program Initialize
|
|
97
|
+
initialize_data = [
|
|
98
|
+
DISCRIMINATOR_INITIALIZE,
|
|
99
|
+
authorized_bytes, # staker
|
|
100
|
+
authorized_bytes, # withdrawer
|
|
101
|
+
0, # lockup.unix_timestamp (i64)
|
|
102
|
+
0, # lockup.epoch (u64)
|
|
103
|
+
zero_pubkey # lockup.custodian
|
|
104
|
+
].pack('Va32a32q<Q<a32').b
|
|
105
|
+
|
|
106
|
+
initialize_ix = Instructions::Instruction.new(
|
|
107
|
+
program_address: PROGRAM_ID,
|
|
108
|
+
accounts: [
|
|
109
|
+
Instructions.writable_account(stake_account), # 0 stake_account
|
|
110
|
+
Instructions.readonly_account(Addresses::Address.new(Sysvars::SYSVAR_RENT_ADDRESS)), # 1 sysvar rent
|
|
111
|
+
],
|
|
112
|
+
data: initialize_data
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
[create_account_ix, initialize_ix]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Builds a DelegateStake instruction that delegates an initialised stake
|
|
119
|
+
# account to a validator vote account.
|
|
120
|
+
#
|
|
121
|
+
# Data layout (4 bytes):
|
|
122
|
+
# [0..3] u32 LE = 2 (DelegateStake discriminant)
|
|
123
|
+
# Pack string: 'V'
|
|
124
|
+
#
|
|
125
|
+
# Accounts:
|
|
126
|
+
# 0. stake_account — writable, not signer
|
|
127
|
+
# 1. vote_account — readonly, not signer
|
|
128
|
+
# 2. SYSVAR_CLOCK — readonly, not signer
|
|
129
|
+
# 3. SYSVAR_STAKE_HISTORY — readonly, not signer
|
|
130
|
+
# 4. STAKE_CONFIG_ID — readonly, not signer
|
|
131
|
+
# 5. authorized — readonly, signer
|
|
132
|
+
#
|
|
133
|
+
# @param stake_account [Addresses::Address] stake account to delegate
|
|
134
|
+
# @param vote_account [Addresses::Address] validator's vote account
|
|
135
|
+
# @param authorized [Addresses::Address] authorised staker (must sign)
|
|
136
|
+
# @return [Instructions::Instruction]
|
|
137
|
+
sig do
|
|
138
|
+
params(
|
|
139
|
+
stake_account: Addresses::Address,
|
|
140
|
+
vote_account: Addresses::Address,
|
|
141
|
+
authorized: Addresses::Address
|
|
142
|
+
).returns(Instructions::Instruction)
|
|
143
|
+
end
|
|
144
|
+
def delegate_instruction(stake_account:, vote_account:, authorized:)
|
|
145
|
+
data = [DISCRIMINATOR_DELEGATE].pack('V').b
|
|
146
|
+
|
|
147
|
+
Instructions::Instruction.new(
|
|
148
|
+
program_address: PROGRAM_ID,
|
|
149
|
+
accounts: [
|
|
150
|
+
Instructions.writable_account(stake_account), # 0 stake_account
|
|
151
|
+
Instructions.readonly_account(vote_account), # 1 vote_account
|
|
152
|
+
Instructions.readonly_account(Addresses::Address.new(Sysvars::SYSVAR_CLOCK_ADDRESS)), # 2 clock
|
|
153
|
+
Instructions.readonly_account(Addresses::Address.new(Sysvars::SYSVAR_STAKE_HISTORY_ADDRESS)), # 3 stake history
|
|
154
|
+
Instructions.readonly_account(STAKE_CONFIG_ID), # 4 stake config
|
|
155
|
+
Instructions.readonly_signer_account(authorized), # 5 authorized staker
|
|
156
|
+
],
|
|
157
|
+
data: data
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
# returned in Solana transaction failures.
|
|
6
6
|
require_relative 'programs/associated_token_account'
|
|
7
7
|
require_relative 'programs/system_program'
|
|
8
|
+
require_relative 'programs/stake_program'
|
|
8
9
|
#
|
|
9
10
|
# A program error in a transaction result looks like:
|
|
10
11
|
# { "InstructionError" => [0, { "Custom" => 1234 }] }
|
|
@@ -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,13 +1,13 @@
|
|
|
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.
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: openssl
|
|
@@ -231,6 +231,7 @@ files:
|
|
|
231
231
|
- lib/solana/ruby/kit/plugin_core.rb
|
|
232
232
|
- lib/solana/ruby/kit/programs.rb
|
|
233
233
|
- lib/solana/ruby/kit/programs/associated_token_account.rb
|
|
234
|
+
- lib/solana/ruby/kit/programs/stake_program.rb
|
|
234
235
|
- lib/solana/ruby/kit/programs/system_program.rb
|
|
235
236
|
- lib/solana/ruby/kit/promises.rb
|
|
236
237
|
- lib/solana/ruby/kit/railtie.rb
|
|
@@ -295,6 +296,7 @@ files:
|
|
|
295
296
|
- lib/solana/ruby/kit/transactions/compiler.rb
|
|
296
297
|
- lib/solana/ruby/kit/transactions/transaction.rb
|
|
297
298
|
- lib/solana/ruby/kit/version.rb
|
|
299
|
+
- lib/solana/ruby/kit/wallet_standard.rb
|
|
298
300
|
- solana-ruby-kit.gemspec
|
|
299
301
|
licenses:
|
|
300
302
|
- MIT
|
|
@@ -313,7 +315,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
313
315
|
- !ruby/object:Gem::Version
|
|
314
316
|
version: '0'
|
|
315
317
|
requirements: []
|
|
316
|
-
rubygems_version:
|
|
318
|
+
rubygems_version: 4.0.10
|
|
317
319
|
specification_version: 4
|
|
318
320
|
summary: Ruby port of the Anza TypeScript SDK (@anza-xyz/kit)
|
|
319
321
|
test_files: []
|