solana-studio 0.11.0 → 0.12.0
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/CHANGELOG.md +46 -3
- data/README.md +239 -26
- data/app/assets/javascripts/solana_studio/wallet_identity.js +524 -0
- data/lib/solana/auth_verifier.rb +12 -0
- data/lib/solana/client.rb +54 -1
- data/lib/solana/compute_budget.rb +73 -0
- data/lib/solana/cosign/builder.rb +159 -0
- data/lib/solana/cosign/completer.rb +217 -0
- data/lib/solana/cosign/expectation.rb +272 -0
- data/lib/solana/cosign.rb +198 -0
- data/lib/solana/ed25519_strict.rb +185 -0
- data/lib/solana/keypair.rb +4 -1
- data/lib/solana/wire_message.rb +223 -0
- data/lib/solana_studio/engine.rb +1 -0
- data/lib/solana_studio/version.rb +1 -1
- data/lib/solana_studio.rb +6 -0
- metadata +10 -2
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
module Solana
|
|
2
|
+
# Solana::Cosign — gasless, cosigned transactions, for any app.
|
|
3
|
+
#
|
|
4
|
+
# THE PATTERN. A user's wallet signs in the browser; the SERVER pays the fee.
|
|
5
|
+
# The server builds a transaction with its fee payer in account 0 and an empty
|
|
6
|
+
# signature slot for every named cosigner, hands the wire to the wallet, takes
|
|
7
|
+
# the signed wire back, proves it is still the transaction it built, fills its
|
|
8
|
+
# own slot, simulates, broadcasts and confirms. Users never hold SOL.
|
|
9
|
+
#
|
|
10
|
+
# builder = Solana::Cosign::Builder.new(client: client, fee_payer: house_keypair)
|
|
11
|
+
# prepared = builder.build(instructions: [ix], cosigners: [user_address],
|
|
12
|
+
# compute_unit_price: 50_000, compute_unit_limit: 200_000)
|
|
13
|
+
# # store prepared.wire_base64 + prepared.last_valid_block_height server-side,
|
|
14
|
+
# # send prepared.wire_base64 to the wallet, receive signed_wire_base64 back
|
|
15
|
+
# completer = Solana::Cosign::Completer.new(client: client, fee_payer: house_keypair)
|
|
16
|
+
# result = completer.complete(signed_wire_base64, expectation: prepared.expectation,
|
|
17
|
+
# before_send: ->(sig) { record.update!(signature: sig) })
|
|
18
|
+
#
|
|
19
|
+
# WHAT IS DELIBERATELY NOT HERE. No key storage (the caller passes the fee
|
|
20
|
+
# payer keypair in), no environment variables, no program of anyone's, and no
|
|
21
|
+
# durable nonce: a nonce transaction is only recognised when
|
|
22
|
+
# advanceNonceAccount is instruction 0, and Phantom inserts Lighthouse
|
|
23
|
+
# instructions ahead of it, so a nonce cannot anchor a wallet-signed
|
|
24
|
+
# transaction (turf-monster mainnet incident, 2026-06-11).
|
|
25
|
+
#
|
|
26
|
+
# THE ERROR HIERARCHY IS THE SAFETY SEAM. What a caller may do after a failure
|
|
27
|
+
# depends on one question it cannot answer afterwards — did the bytes leave?
|
|
28
|
+
#
|
|
29
|
+
# Cosign::Error
|
|
30
|
+
# WireRejected the wire is not what was built; nothing was sent
|
|
31
|
+
# PreflightRejected provably never sent: rebuild freely
|
|
32
|
+
# BlockhashExpired block height is already past last_valid_block_height
|
|
33
|
+
# SimulationFailed the program refused it (#err, #logs)
|
|
34
|
+
# BroadcastFailed MAY be on chain: reconcile #signature before rebuilding
|
|
35
|
+
# BroadcastExpired the node or the chain says the blockhash is dead
|
|
36
|
+
# TransactionFailed landed on chain and failed (#err); the fee was paid
|
|
37
|
+
#
|
|
38
|
+
# A caller that rewinds a claim on anything under BroadcastFailed can re-send a
|
|
39
|
+
# transaction that already landed. `rescue Solana::Cosign::PreflightRejected`
|
|
40
|
+
# is the only rescue that licenses a silent rebuild.
|
|
41
|
+
module Cosign
|
|
42
|
+
DEFAULT_COMMITMENT = "confirmed"
|
|
43
|
+
|
|
44
|
+
# The wallet may raise the priority fee on its own under load, so the cosign
|
|
45
|
+
# CAPS rather than refuses: at most this many times the builder's own price,
|
|
46
|
+
# and this many times the builder's whole priority fee. A builder price of 0
|
|
47
|
+
# makes both caps 0, so no wallet-added fee is ever paid for.
|
|
48
|
+
DEFAULT_FEE_MARGIN = 10
|
|
49
|
+
|
|
50
|
+
# Lighthouse, Phantom's transaction-protection program. On mainnet Phantom
|
|
51
|
+
# may insert Lighthouse instructions into a transaction it signs, so a guard
|
|
52
|
+
# that refuses the program refuses every protected Phantom signature
|
|
53
|
+
# (turf-monster, 2026-06-11).
|
|
54
|
+
#
|
|
55
|
+
# NOT EVERY LIGHTHOUSE INSTRUCTION IS AN ASSERTION. The first data byte is
|
|
56
|
+
# the instruction's variant, and two variants spend a signer's lamports:
|
|
57
|
+
#
|
|
58
|
+
# 0 MemoryWrite the signer named as `payer` funds the rent of a "memory"
|
|
59
|
+
# account whose size the instruction chooses. The fee payer
|
|
60
|
+
# signs every cosigned wire, so a wire naming it as payer
|
|
61
|
+
# locks its SOL — about 0.05 SOL per 10 KiB, repeatable, and
|
|
62
|
+
# recoverable only by a MemoryClose it signs. A drained fee
|
|
63
|
+
# payer stops every gasless transaction.
|
|
64
|
+
# 1 MemoryClose refunds a memory account's rent to its payer.
|
|
65
|
+
#
|
|
66
|
+
# Variants 2..17 are the sixteen assertions, AssertAccountData through
|
|
67
|
+
# AssertBubblegumTreeConfigAccount. Each can make a transaction fail; none
|
|
68
|
+
# moves funds or grants authority. So Expectation#verify! admits a Lighthouse
|
|
69
|
+
# instruction only when its first byte is 2..17, and refuses 0, 1, empty
|
|
70
|
+
# data and every other byte. Naming the fee payer is NOT the test: Phantom's
|
|
71
|
+
# assertions routinely target the fee payer's own post-state.
|
|
72
|
+
#
|
|
73
|
+
# Evidence, all read-only against mainnet on 2026-09-16:
|
|
74
|
+
# - The program is immutable (no upgrade authority), so the variants
|
|
75
|
+
# cannot drift.
|
|
76
|
+
# - Simulation: variant 0 made a funded payer fund a 10,008-byte memory
|
|
77
|
+
# account (51,490,880 lamports), and variant 1 refunded it. A true
|
|
78
|
+
# AssertSysvarClock (15) succeeded and a false one failed its assertion.
|
|
79
|
+
# - The program binary names sixteen assertion instructions, and the
|
|
80
|
+
# source enum (github.com/Jac0xb/lighthouse, instruction.rs) orders them
|
|
81
|
+
# 2..17.
|
|
82
|
+
# - All 37 Lighthouse instructions in five cosigned mainnet Phantom
|
|
83
|
+
# transactions are variant 6 (32) or 10 (5); test/cosign_lighthouse_test.rb
|
|
84
|
+
# carries every one.
|
|
85
|
+
LIGHTHOUSE_PROGRAM_ID = Keypair.decode_base58("L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95").freeze
|
|
86
|
+
LIGHTHOUSE_MEMORY_WRITE = 0
|
|
87
|
+
LIGHTHOUSE_MEMORY_CLOSE = 1
|
|
88
|
+
LIGHTHOUSE_ASSERTIONS = (2..17)
|
|
89
|
+
|
|
90
|
+
DEFAULT_EXTRA_PROGRAMS = [LIGHTHOUSE_PROGRAM_ID].freeze
|
|
91
|
+
|
|
92
|
+
BASE58_PUBKEY = /\A[1-9A-HJ-NP-Za-km-z]{32,44}\z/
|
|
93
|
+
|
|
94
|
+
class Error < StandardError
|
|
95
|
+
# The transaction's first signature (base58), when one exists. Present on
|
|
96
|
+
# every error raised after the fee payer's slot is filled, so a caller can
|
|
97
|
+
# log it, and — for BroadcastFailed — look it up on chain.
|
|
98
|
+
attr_reader :signature
|
|
99
|
+
|
|
100
|
+
def initialize(message = nil, signature: nil)
|
|
101
|
+
@signature = signature
|
|
102
|
+
super(message)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The returned wire is not the transaction the server built, or cannot be
|
|
107
|
+
# completed. Raised before the fee payer signs; nothing is returned or sent.
|
|
108
|
+
# #reason is a short stable code; the message carries detail for SERVER LOGS
|
|
109
|
+
# ONLY — never echo it to a client, it tells an attacker which check tripped.
|
|
110
|
+
class WireRejected < Error
|
|
111
|
+
attr_reader :reason
|
|
112
|
+
|
|
113
|
+
def initialize(reason, detail = nil)
|
|
114
|
+
@reason = reason.to_s
|
|
115
|
+
super(detail ? "#{@reason}: #{detail}" : @reason)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Signed in memory, provably never sent.
|
|
120
|
+
class PreflightRejected < Error; end
|
|
121
|
+
|
|
122
|
+
class BlockhashExpired < PreflightRejected
|
|
123
|
+
attr_reader :block_height, :last_valid_block_height
|
|
124
|
+
|
|
125
|
+
def initialize(message = nil, signature: nil, block_height: nil, last_valid_block_height: nil)
|
|
126
|
+
@block_height = block_height
|
|
127
|
+
@last_valid_block_height = last_valid_block_height
|
|
128
|
+
super(message, signature: signature)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
class SimulationFailed < PreflightRejected
|
|
133
|
+
attr_reader :err, :logs
|
|
134
|
+
|
|
135
|
+
def initialize(message = nil, signature: nil, err: nil, logs: [])
|
|
136
|
+
@err = err
|
|
137
|
+
@logs = Array(logs)
|
|
138
|
+
super(message, signature: signature)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Everything from the send onward. Solana::Client#call retries a read
|
|
143
|
+
# timeout or a reset connection by re-posting the same wire, and hands back
|
|
144
|
+
# only the last answer, so no exception from a send proves the transaction
|
|
145
|
+
# was not forwarded. Reconcile #signature against the chain before rebuilding.
|
|
146
|
+
class BroadcastFailed < Error; end
|
|
147
|
+
|
|
148
|
+
class BroadcastExpired < BroadcastFailed; end
|
|
149
|
+
|
|
150
|
+
class TransactionFailed < Error
|
|
151
|
+
attr_reader :err
|
|
152
|
+
|
|
153
|
+
def initialize(message = nil, signature: nil, err: nil)
|
|
154
|
+
@err = err
|
|
155
|
+
super(message, signature: signature)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
module_function
|
|
160
|
+
|
|
161
|
+
# Normalize a public key to its 32 raw bytes. Accepts a Solana::Keypair, a
|
|
162
|
+
# base58 address, or 32 raw bytes. A BINARY string of 32 bytes is always raw;
|
|
163
|
+
# a string that reads as base58 is decoded. (That order matters: a 32-byte
|
|
164
|
+
# raw key can happen to be 32 base58-legal characters.)
|
|
165
|
+
def key_bytes(value)
|
|
166
|
+
return value.public_key_bytes.b if value.respond_to?(:public_key_bytes)
|
|
167
|
+
raise ArgumentError, "public key required, got #{value.inspect}" unless value.is_a?(String) && !value.empty?
|
|
168
|
+
return value.b if value.encoding == Encoding::BINARY && value.bytesize == 32
|
|
169
|
+
|
|
170
|
+
if value.match?(BASE58_PUBKEY)
|
|
171
|
+
decoded = Keypair.decode_base58(value)
|
|
172
|
+
return decoded.b if decoded.bytesize == 32
|
|
173
|
+
end
|
|
174
|
+
return value.b if value.bytesize == 32
|
|
175
|
+
|
|
176
|
+
raise ArgumentError, "not a 32-byte public key: #{value.inspect}"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def base58(bytes)
|
|
180
|
+
Keypair.encode_base58(bytes)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# [max_compute_unit_price, max_priority_fee_micro_lamports] for a builder
|
|
184
|
+
# that set this price and limit. See DEFAULT_FEE_MARGIN.
|
|
185
|
+
def fee_caps(compute_unit_price:, compute_unit_limit:, margin: DEFAULT_FEE_MARGIN)
|
|
186
|
+
price = Integer(compute_unit_price || 0)
|
|
187
|
+
limit = Integer(compute_unit_limit || ComputeBudget::MAX_COMPUTE_UNIT_LIMIT)
|
|
188
|
+
margin = Integer(margin)
|
|
189
|
+
raise ArgumentError, "fee margin must be >= 1, got #{margin}" if margin < 1
|
|
190
|
+
|
|
191
|
+
[price * margin, price * limit * margin]
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
require_relative "cosign/expectation"
|
|
197
|
+
require_relative "cosign/builder"
|
|
198
|
+
require_relative "cosign/completer"
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
require "ed25519"
|
|
2
|
+
|
|
3
|
+
module Solana
|
|
4
|
+
# Ed25519 verification with the checks the `ed25519` gem leaves out.
|
|
5
|
+
#
|
|
6
|
+
# `Ed25519::VerifyKey` (ref10) answers one question: does the verification
|
|
7
|
+
# equation hold? It does not ask whether the public key is a key anyone could
|
|
8
|
+
# hold a secret for. A small-order point — or a non-canonical spelling of one —
|
|
9
|
+
# decodes, and signatures verify against it with no secret key behind them. For
|
|
10
|
+
# a sign-in that means an address nobody owns can sign in.
|
|
11
|
+
#
|
|
12
|
+
# So before the equation runs, this module requires:
|
|
13
|
+
#
|
|
14
|
+
# public key a CANONICAL encoding (y < p, no negative zero) of a curve point
|
|
15
|
+
# that is NOT small-order and IS in the prime-order subgroup
|
|
16
|
+
# ([L]A is the identity). Every key a wallet generates is [a]B,
|
|
17
|
+
# which always passes; nothing a real user holds is refused.
|
|
18
|
+
# R a canonical encoding of a point that is NOT small-order.
|
|
19
|
+
# S reduced: S < L. The library only checks the top three bits,
|
|
20
|
+
# which leaves S + L as a second valid spelling.
|
|
21
|
+
#
|
|
22
|
+
# That is Solana's own rule (`verify_strict`: small-order A and R and an
|
|
23
|
+
# unreduced S are refused) plus the subgroup check on A, which the chain does
|
|
24
|
+
# not make but which no generated key can fail. A signature this module
|
|
25
|
+
# accepts is one the cluster accepts too.
|
|
26
|
+
#
|
|
27
|
+
# Pure Ruby on Integer#pow: no new dependency. A full check costs a few
|
|
28
|
+
# milliseconds, dominated by one 253-bit scalar multiplication.
|
|
29
|
+
module Ed25519Strict
|
|
30
|
+
PUBLIC_KEY_BYTES = 32
|
|
31
|
+
SIGNATURE_BYTES = 64
|
|
32
|
+
|
|
33
|
+
# Field prime, group order, curve constant d, and sqrt(-1) mod p.
|
|
34
|
+
P = 2**255 - 19
|
|
35
|
+
L = 2**252 + 27_742_317_777_372_353_535_851_937_790_883_648_493
|
|
36
|
+
D = (-121_665 * 121_666.pow(P - 2, P)) % P
|
|
37
|
+
D2 = (2 * D) % P
|
|
38
|
+
SQRT_M1 = 2.pow((P - 1) / 4, P)
|
|
39
|
+
|
|
40
|
+
# Extended coordinates [X, Y, Z, T], x = X/Z, y = Y/Z, xy = T/Z.
|
|
41
|
+
IDENTITY = [0, 1, 1, 0].freeze
|
|
42
|
+
|
|
43
|
+
module_function
|
|
44
|
+
|
|
45
|
+
# nil when `bytes` is a public key a secret key can stand behind; otherwise
|
|
46
|
+
# a short reason, suitable for an error message.
|
|
47
|
+
def public_key_problem(bytes)
|
|
48
|
+
bytes = bytes.to_s.b
|
|
49
|
+
return "must be #{PUBLIC_KEY_BYTES} bytes, got #{bytes.bytesize}" unless bytes.bytesize == PUBLIC_KEY_BYTES
|
|
50
|
+
|
|
51
|
+
point = decode_point(bytes)
|
|
52
|
+
return "is not a canonical encoding of an ed25519 point" if point.nil?
|
|
53
|
+
return "is a small-order ed25519 point" if small_order?(point)
|
|
54
|
+
return "is not in the ed25519 prime-order subgroup" unless in_prime_order_subgroup?(point)
|
|
55
|
+
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# nil when `bytes` is a well-formed signature (R canonical and not
|
|
60
|
+
# small-order, S reduced); otherwise a short reason. Says nothing about
|
|
61
|
+
# whether it verifies — that is #verify.
|
|
62
|
+
def signature_problem(bytes)
|
|
63
|
+
bytes = bytes.to_s.b
|
|
64
|
+
return "must be #{SIGNATURE_BYTES} bytes, got #{bytes.bytesize}" unless bytes.bytesize == SIGNATURE_BYTES
|
|
65
|
+
|
|
66
|
+
r = decode_point(bytes.byteslice(0, 32))
|
|
67
|
+
return "R is not a canonical encoding of an ed25519 point" if r.nil?
|
|
68
|
+
return "R is a small-order ed25519 point" if small_order?(r)
|
|
69
|
+
return "S is not reduced below the group order" unless le_int(bytes.byteslice(32, 32)) < L
|
|
70
|
+
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def valid_public_key?(bytes)
|
|
75
|
+
public_key_problem(bytes).nil?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# True only when the key and signature pass the checks above AND the
|
|
79
|
+
# verification equation holds. Never raises on malformed input.
|
|
80
|
+
def verify(public_key, signature, message)
|
|
81
|
+
public_key = public_key.to_s.b
|
|
82
|
+
signature = signature.to_s.b
|
|
83
|
+
return false if public_key_problem(public_key) || signature_problem(signature)
|
|
84
|
+
|
|
85
|
+
Ed25519::VerifyKey.new(public_key).verify(signature, message)
|
|
86
|
+
true
|
|
87
|
+
rescue Ed25519::VerifyError, ArgumentError
|
|
88
|
+
false
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# RFC 8032 §5.1.3, strict: a y at or above p, a negative zero x, and a y
|
|
92
|
+
# with no x on the curve all return nil.
|
|
93
|
+
def decode_point(bytes)
|
|
94
|
+
bytes = bytes.to_s.b
|
|
95
|
+
return nil unless bytes.bytesize == PUBLIC_KEY_BYTES
|
|
96
|
+
|
|
97
|
+
int = le_int(bytes)
|
|
98
|
+
y = int & ((1 << 255) - 1)
|
|
99
|
+
sign = int >> 255
|
|
100
|
+
return nil if y >= P
|
|
101
|
+
|
|
102
|
+
u = (y * y - 1) % P
|
|
103
|
+
v = (D * y * y + 1) % P
|
|
104
|
+
x = (u * v.pow(3, P) * (u * v.pow(7, P)).pow((P - 5) / 8, P)) % P
|
|
105
|
+
vx2 = (v * x * x) % P
|
|
106
|
+
if vx2 == u
|
|
107
|
+
# x is a square root of u/v
|
|
108
|
+
elsif vx2 == (P - u) % P
|
|
109
|
+
x = (x * SQRT_M1) % P
|
|
110
|
+
else
|
|
111
|
+
return nil
|
|
112
|
+
end
|
|
113
|
+
return nil if x.zero? && sign == 1
|
|
114
|
+
|
|
115
|
+
x = P - x if (x & 1) != sign
|
|
116
|
+
[x, y, 1, (x * y) % P]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# RFC 8032 §5.1.2.
|
|
120
|
+
def encode_point(point)
|
|
121
|
+
x, y, z, = point
|
|
122
|
+
z_inv = z.pow(P - 2, P)
|
|
123
|
+
x = (x * z_inv) % P
|
|
124
|
+
y = (y * z_inv) % P
|
|
125
|
+
int = y | ((x & 1) << 255)
|
|
126
|
+
Array.new(32) { |i| (int >> (8 * i)) & 0xff }.pack("C*")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def identity?(point)
|
|
130
|
+
x, y, z, = point
|
|
131
|
+
(x % P).zero? && ((y - z) % P).zero?
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# [8]P is the identity: P is one of the eight points of the torsion subgroup.
|
|
135
|
+
def small_order?(point)
|
|
136
|
+
identity?(double(double(double(point))))
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# [L]P is the identity. The identity itself passes, so callers check
|
|
140
|
+
# small_order? first.
|
|
141
|
+
def in_prime_order_subgroup?(point)
|
|
142
|
+
identity?(scalar_mult(L, point))
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# RFC 8032 §5.1.4 addition (complete for this curve).
|
|
146
|
+
def add(p1, p2)
|
|
147
|
+
x1, y1, z1, t1 = p1
|
|
148
|
+
x2, y2, z2, t2 = p2
|
|
149
|
+
a = ((y1 - x1) * (y2 - x2)) % P
|
|
150
|
+
b = ((y1 + x1) * (y2 + x2)) % P
|
|
151
|
+
c = (t1 * D2 % P * t2) % P
|
|
152
|
+
d = (z1 * 2 * z2) % P
|
|
153
|
+
e = b - a
|
|
154
|
+
f = d - c
|
|
155
|
+
g = d + c
|
|
156
|
+
h = b + a
|
|
157
|
+
[(e * f) % P, (g * h) % P, (f * g) % P, (e * h) % P]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def double(point)
|
|
161
|
+
x1, y1, z1, = point
|
|
162
|
+
a = (x1 * x1) % P
|
|
163
|
+
b = (y1 * y1) % P
|
|
164
|
+
c = (2 * z1 * z1) % P
|
|
165
|
+
h = a + b
|
|
166
|
+
e = h - (x1 + y1)**2
|
|
167
|
+
g = a - b
|
|
168
|
+
f = c + g
|
|
169
|
+
[(e * f) % P, (g * h) % P, (f * g) % P, (e * h) % P]
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def scalar_mult(scalar, point)
|
|
173
|
+
result = IDENTITY
|
|
174
|
+
(scalar.bit_length - 1).downto(0) do |i|
|
|
175
|
+
result = double(result)
|
|
176
|
+
result = add(result, point) if scalar[i] == 1
|
|
177
|
+
end
|
|
178
|
+
result
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def le_int(bytes)
|
|
182
|
+
bytes.unpack("C*").each_with_index.sum { |byte, i| byte << (8 * i) }
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
data/lib/solana/keypair.rb
CHANGED
|
@@ -90,7 +90,10 @@ module Solana
|
|
|
90
90
|
num = num * 58 + idx
|
|
91
91
|
end
|
|
92
92
|
|
|
93
|
-
|
|
93
|
+
# A value of zero has NO body: every byte of it is a leading '1', counted
|
|
94
|
+
# below. `0.to_s(16)` is "0", which padded to "00" used to add a 33rd byte
|
|
95
|
+
# to the all-'1' System Program id.
|
|
96
|
+
hex = num.zero? ? "" : num.to_s(16)
|
|
94
97
|
hex = "0" + hex if hex.length.odd?
|
|
95
98
|
|
|
96
99
|
# Count leading '1's (zero bytes)
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "ed25519"
|
|
3
|
+
require_relative "ed25519_strict"
|
|
4
|
+
|
|
5
|
+
module Solana
|
|
6
|
+
# A decoded LEGACY wire transaction: the signature array plus the message it
|
|
7
|
+
# signs. Pure — no RPC, no keys.
|
|
8
|
+
#
|
|
9
|
+
# WHY A SERVER NEEDS TO READ ITS OWN WIRE BACK. A wallet does not return the
|
|
10
|
+
# bytes it was given. It re-serializes them, and on mainnet Phantom may insert
|
|
11
|
+
# Lighthouse assertion instructions (and their accounts) before signing, so a
|
|
12
|
+
# byte comparison against the prepared wire refuses legitimate signatures. A
|
|
13
|
+
# cosigning server has to decode what came back and judge it by meaning. This
|
|
14
|
+
# is the decoder turf-monster kept privately in Vault#parse_wire_message, and
|
|
15
|
+
# the walk Transaction.cosign_wire does inline.
|
|
16
|
+
#
|
|
17
|
+
# FAILS CLOSED. Truncation, trailing bytes, an out-of-range index and a
|
|
18
|
+
# versioned (v0+) message all raise MalformedError. Versioned messages are
|
|
19
|
+
# refused rather than half-read: this decoder cannot walk address-table
|
|
20
|
+
# lookups, and Solana::Transaction only ever builds legacy messages.
|
|
21
|
+
class WireMessage
|
|
22
|
+
class MalformedError < ArgumentError; end
|
|
23
|
+
|
|
24
|
+
EMPTY_SIGNATURE = ("\x00" * 64).b.freeze
|
|
25
|
+
|
|
26
|
+
attr_reader :signatures, :message_bytes, :num_required_signatures,
|
|
27
|
+
:num_readonly_signed, :num_readonly_unsigned,
|
|
28
|
+
:account_keys, :recent_blockhash, :instructions
|
|
29
|
+
|
|
30
|
+
def self.parse_base64(wire_base64)
|
|
31
|
+
raise MalformedError, "wire is empty" if wire_base64.nil? || wire_base64.to_s.empty?
|
|
32
|
+
|
|
33
|
+
parse(Base64.strict_decode64(wire_base64.to_s))
|
|
34
|
+
rescue ArgumentError => e
|
|
35
|
+
raise e if e.is_a?(MalformedError)
|
|
36
|
+
|
|
37
|
+
raise MalformedError, "wire is not strict base64: #{e.message}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Base58 is the wire format of the gem's own browser seam (walletOps hands
|
|
41
|
+
# `prepare` and `complete` base58), so a host can pass it straight through.
|
|
42
|
+
def self.parse_base58(wire_base58)
|
|
43
|
+
raise MalformedError, "wire is empty" if wire_base58.nil? || wire_base58.to_s.empty?
|
|
44
|
+
|
|
45
|
+
parse(Keypair.decode_base58(wire_base58.to_s))
|
|
46
|
+
rescue ArgumentError => e
|
|
47
|
+
raise e if e.is_a?(MalformedError)
|
|
48
|
+
|
|
49
|
+
raise MalformedError, "wire is not base58: #{e.message}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# encoding: :base64 or :base58. Explicit, never sniffed: every base58
|
|
53
|
+
# string is also made of base64 characters, so guessing can misread one.
|
|
54
|
+
def self.parse_encoded(wire, encoding)
|
|
55
|
+
case encoding
|
|
56
|
+
when :base64 then parse_base64(wire)
|
|
57
|
+
when :base58 then parse_base58(wire)
|
|
58
|
+
else raise ArgumentError, "encoding must be :base64 or :base58, got #{encoding.inspect}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# `wire` is the binary wire transaction: compact-u16 signature count, the
|
|
63
|
+
# 64-byte signatures, then the message.
|
|
64
|
+
def self.parse(wire)
|
|
65
|
+
new(wire.to_s.b)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def initialize(wire)
|
|
69
|
+
@wire = wire
|
|
70
|
+
cursor = 0
|
|
71
|
+
|
|
72
|
+
sig_count, cursor = read_compact_u16(cursor)
|
|
73
|
+
malformed!("zero signature slots") if sig_count.zero?
|
|
74
|
+
need!(cursor + (sig_count * 64), "signature array")
|
|
75
|
+
@signatures = Array.new(sig_count) { |i| @wire.byteslice(cursor + (i * 64), 64) }
|
|
76
|
+
message_start = cursor + (sig_count * 64)
|
|
77
|
+
@message_bytes = @wire.byteslice(message_start, @wire.bytesize - message_start)
|
|
78
|
+
|
|
79
|
+
need!(message_start + 3, "message header")
|
|
80
|
+
first = @wire.getbyte(message_start)
|
|
81
|
+
malformed!("versioned (v0+) message is not supported") if (first & 0x80) != 0
|
|
82
|
+
@num_required_signatures = first
|
|
83
|
+
@num_readonly_signed = @wire.getbyte(message_start + 1)
|
|
84
|
+
@num_readonly_unsigned = @wire.getbyte(message_start + 2)
|
|
85
|
+
|
|
86
|
+
unless @num_required_signatures == sig_count
|
|
87
|
+
malformed!("header requires #{@num_required_signatures} signature(s) but the wire carries #{sig_count} slot(s)")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
cursor = message_start + 3
|
|
91
|
+
account_count, cursor = read_compact_u16(cursor)
|
|
92
|
+
need!(cursor + (account_count * 32), "account keys")
|
|
93
|
+
@account_keys = Array.new(account_count) { |i| @wire.byteslice(cursor + (i * 32), 32) }
|
|
94
|
+
cursor += account_count * 32
|
|
95
|
+
malformed!("#{account_count} account key(s) cannot cover #{@num_required_signatures} signer(s)") if account_count < @num_required_signatures
|
|
96
|
+
# The runtime refuses a key loaded twice. Refusing it here too means a
|
|
97
|
+
# signer check and an instruction check can never read two different
|
|
98
|
+
# entries for the same key.
|
|
99
|
+
malformed!("duplicate account key in the account list") unless @account_keys.uniq.size == account_count
|
|
100
|
+
if @num_readonly_signed > @num_required_signatures || @num_readonly_unsigned > (account_count - @num_required_signatures)
|
|
101
|
+
malformed!("readonly counts exceed the account list")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
need!(cursor + 32, "recent blockhash")
|
|
105
|
+
@recent_blockhash = @wire.byteslice(cursor, 32)
|
|
106
|
+
cursor += 32
|
|
107
|
+
|
|
108
|
+
ix_count, cursor = read_compact_u16(cursor)
|
|
109
|
+
@instructions = Array.new(ix_count) do |n|
|
|
110
|
+
need!(cursor + 1, "instruction #{n} program index")
|
|
111
|
+
program_id_index = @wire.getbyte(cursor)
|
|
112
|
+
cursor += 1
|
|
113
|
+
malformed!("instruction #{n} program index #{program_id_index} is out of range") if program_id_index >= account_count
|
|
114
|
+
|
|
115
|
+
accounts_len, cursor = read_compact_u16(cursor)
|
|
116
|
+
need!(cursor + accounts_len, "instruction #{n} account indices")
|
|
117
|
+
account_indices = @wire.byteslice(cursor, accounts_len).bytes
|
|
118
|
+
cursor += accounts_len
|
|
119
|
+
bad = account_indices.find { |idx| idx >= account_count }
|
|
120
|
+
malformed!("instruction #{n} account index #{bad} is out of range") if bad
|
|
121
|
+
|
|
122
|
+
data_len, cursor = read_compact_u16(cursor)
|
|
123
|
+
need!(cursor + data_len, "instruction #{n} data")
|
|
124
|
+
data = @wire.byteslice(cursor, data_len)
|
|
125
|
+
cursor += data_len
|
|
126
|
+
|
|
127
|
+
{
|
|
128
|
+
program_id_index: program_id_index,
|
|
129
|
+
program_id: @account_keys[program_id_index],
|
|
130
|
+
account_indices: account_indices,
|
|
131
|
+
accounts: account_indices.map { |idx| @account_keys[idx] },
|
|
132
|
+
data: data
|
|
133
|
+
}
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
malformed!("#{@wire.bytesize - cursor} trailing byte(s) after the last instruction") unless cursor == @wire.bytesize
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def to_bytes
|
|
140
|
+
@wire.dup
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def to_base64
|
|
144
|
+
Base64.strict_encode64(@wire)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def to_base58
|
|
148
|
+
Keypair.encode_base58(@wire)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The fee payer: account 0, always the first signer.
|
|
152
|
+
def fee_payer
|
|
153
|
+
@account_keys[0]
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def signer_keys
|
|
157
|
+
@account_keys.first(@num_required_signatures)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def signer?(key_or_index)
|
|
161
|
+
index = key_or_index.is_a?(Integer) ? key_or_index : @account_keys.index(key_or_index.to_s.b)
|
|
162
|
+
!index.nil? && index < @num_required_signatures
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Standard legacy layout: writable signers, readonly signers, writable
|
|
166
|
+
# non-signers, readonly non-signers.
|
|
167
|
+
def writable?(key_or_index)
|
|
168
|
+
index = key_or_index.is_a?(Integer) ? key_or_index : @account_keys.index(key_or_index.to_s.b)
|
|
169
|
+
return false if index.nil? || index >= @account_keys.length
|
|
170
|
+
|
|
171
|
+
if index < @num_required_signatures
|
|
172
|
+
index < (@num_required_signatures - @num_readonly_signed)
|
|
173
|
+
else
|
|
174
|
+
(index - @num_required_signatures) < (@account_keys.length - @num_required_signatures - @num_readonly_unsigned)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def signature_slot_empty?(index)
|
|
179
|
+
@signatures.fetch(index) == EMPTY_SIGNATURE
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# True when slot `index` holds a valid ed25519 signature, by the key in that
|
|
183
|
+
# signer slot, over these exact message bytes. An empty slot is not valid.
|
|
184
|
+
# Strict (Solana::Ed25519Strict): a slot the cluster's own signature check
|
|
185
|
+
# would refuse — a small-order key or R, an unreduced S — is not valid here
|
|
186
|
+
# either, so a completer refuses it before the fee payer signs.
|
|
187
|
+
def signature_valid?(index)
|
|
188
|
+
return false if index >= @num_required_signatures || signature_slot_empty?(index)
|
|
189
|
+
|
|
190
|
+
Ed25519Strict.verify(@account_keys[index], @signatures[index], @message_bytes)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# The transaction's identity on chain: its FIRST signature (the fee payer's),
|
|
194
|
+
# base58. Knowable before any broadcast, which is what lets a caller record
|
|
195
|
+
# it before the bytes leave. Raises when that slot is still empty, because an
|
|
196
|
+
# all-zero "signature" names nothing.
|
|
197
|
+
def signature
|
|
198
|
+
raise MalformedError, "the fee-payer slot is empty, so this wire has no signature yet" if signature_slot_empty?(0)
|
|
199
|
+
|
|
200
|
+
Keypair.encode_base58(@signatures[0])
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def recent_blockhash_base58
|
|
204
|
+
Keypair.encode_base58(@recent_blockhash)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
private
|
|
208
|
+
|
|
209
|
+
def read_compact_u16(offset)
|
|
210
|
+
Transaction.read_compact_u16(@wire, offset)
|
|
211
|
+
rescue RuntimeError => e
|
|
212
|
+
malformed!(e.message)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def need!(end_offset, what)
|
|
216
|
+
malformed!("truncated #{what}") if @wire.bytesize < end_offset
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def malformed!(reason)
|
|
220
|
+
raise MalformedError, "malformed wire: #{reason}"
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
data/lib/solana_studio/engine.rb
CHANGED
|
@@ -16,5 +16,5 @@ module SolanaStudio
|
|
|
16
16
|
# through the normal cycle. Splitting the version out is the same shape
|
|
17
17
|
# studio-engine already uses (lib/studio/version.rb) and hands each file back
|
|
18
18
|
# to its real owner: this one to the release, the gemspec to the PR.
|
|
19
|
-
VERSION = "0.
|
|
19
|
+
VERSION = "0.12.0"
|
|
20
20
|
end
|
data/lib/solana_studio.rb
CHANGED
|
@@ -5,8 +5,14 @@ require_relative "solana/transaction"
|
|
|
5
5
|
require_relative "solana/spl_token"
|
|
6
6
|
require_relative "solana/system_program"
|
|
7
7
|
require_relative "solana/nonce_account"
|
|
8
|
+
require_relative "solana/ed25519_strict"
|
|
8
9
|
require_relative "solana/auth_verifier"
|
|
9
10
|
require_relative "solana/network"
|
|
11
|
+
require_relative "solana/compute_budget"
|
|
12
|
+
require_relative "solana/wire_message"
|
|
13
|
+
# Gasless, cosigned transactions: the house pays the fee, the wallet signs, the
|
|
14
|
+
# server verifies and fills its slot. Keyless — the caller passes the fee payer.
|
|
15
|
+
require_relative "solana/cosign"
|
|
10
16
|
|
|
11
17
|
# The version is its own file so the release conductor has exactly one literal
|
|
12
18
|
# to rewrite, and so editing this gem's MANIFEST does not read as editing its
|