solana-studio 0.4.6 → 0.4.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/CHANGELOG.md +11 -0
- data/lib/solana/client.rb +16 -0
- data/lib/solana/transaction.rb +125 -0
- data/lib/solana_studio.rb +1 -1
- metadata +3 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 29449e0ba9a251d17b60b4c453754953630d4b41604dd93285889f58f2d7df3b
|
|
4
|
+
data.tar.gz: a37bd87c44b6934571e0c3e82959caa519f327c5345fa674a956a9695c71c0eb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6537687173850b20cfed221277a3cabf714572ccc765782961ae95ab66bcf6f33150183690e32ca1e0ca3881753c2149661e22eaa413c8c606632fde1c0f513f
|
|
7
|
+
data.tar.gz: f4426121b94008ccf816394f812b31496e53b421da57e29fef38e256bdd3776a659b4d918ee60cc7660800c5995a98a228db91e5dddc638dedd9ead04732ec88
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
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
4
|
|
|
5
|
+
## v0.4.7 (2026-06-05)
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **`Solana::Transaction.cosign_wire(signed_wire_bytes, signer:, require_complete:)`** — client-first cosign. Adds one signature to an already-(partially-)signed wire tx WITHOUT rebuilding it: parses the compact-u16 signature count + message header, finds the signer's account-key index, asserts that slot is currently zero (never clobbers a real signature), signs the EXACT message bytes, writes the 64-byte signature in, and (when `require_complete:`, default true) re-asserts OPSEC-017 — every required slot non-zero. Pure Ruby, no RPC. Enables the Phantom-signs-FIRST / server-cosigns-SECOND entry flow that clears Phantom's multi-signer-order "could be malicious" Lighthouse banner. `cosign_wire_base64` is the base64 wrapper.
|
|
9
|
+
- **`Solana::Transaction.read_compact_u16(bytes, offset)`** — ShortVec compact-u16 decoder, `[value, next_offset]` (wire-parser primitive behind `cosign_wire`).
|
|
10
|
+
- **`Solana::Client#simulate_transaction(tx_base64, sig_verify:, replace_recent_blockhash:, commitment:)`** — server-side `simulateTransaction` pre-flight; returns the RPC `value` object (`err`/`logs`/`unitsConsumed`). Lets the server run the same pre-broadcast simulation the entry board did client-side, now that broadcast moved server-side.
|
|
11
|
+
|
|
12
|
+
### Tests
|
|
13
|
+
- `test/transaction_test.rb` (+9 tests): correct slot filled + verifies over message, other signer's sig + message bytes untouched, 2/2 sigs valid, refuses to clobber a filled slot, rejects a non-signer, off-by-one slot guard, malformed-header count-mismatch rejection, base64 round-trip.
|
|
14
|
+
- `test/client_test.rb` (+2 tests): simulate_transaction sends the right RPC + parses `value`; surfaces a program error in `value["err"]`.
|
|
15
|
+
|
|
5
16
|
## v0.4.6 (2026-06-02)
|
|
6
17
|
|
|
7
18
|
### Added
|
data/lib/solana/client.rb
CHANGED
|
@@ -54,6 +54,22 @@ module Solana
|
|
|
54
54
|
call("sendTransaction", [signed_tx_base64, opts])
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
# Server-side pre-flight: run simulateTransaction against a base64 wire tx.
|
|
58
|
+
# sig_verify:false lets us simulate a tx without all signatures present (or
|
|
59
|
+
# without re-verifying ones that are). Returns the RPC `value` object
|
|
60
|
+
# ({ "err" =>, "logs" =>, "unitsConsumed" =>, … }); `value["err"]` is nil on
|
|
61
|
+
# success. Mirrors the client-side simulate the entry board used to run.
|
|
62
|
+
def simulate_transaction(signed_tx_base64, sig_verify: false, replace_recent_blockhash: false, commitment: "confirmed")
|
|
63
|
+
opts = {
|
|
64
|
+
encoding: "base64",
|
|
65
|
+
sigVerify: sig_verify,
|
|
66
|
+
replaceRecentBlockhash: replace_recent_blockhash,
|
|
67
|
+
commitment: commitment
|
|
68
|
+
}
|
|
69
|
+
result = call("simulateTransaction", [signed_tx_base64, opts])
|
|
70
|
+
result&.dig("value")
|
|
71
|
+
end
|
|
72
|
+
|
|
57
73
|
def confirm_transaction(signature, commitment: "confirmed")
|
|
58
74
|
call("getSignatureStatuses", [[signature], { searchTransactionHistory: true }])
|
|
59
75
|
end
|
data/lib/solana/transaction.rb
CHANGED
|
@@ -155,6 +155,131 @@ module Solana
|
|
|
155
155
|
Base64.strict_encode64(serialize_partial(additional_signers: additional_signers))
|
|
156
156
|
end
|
|
157
157
|
|
|
158
|
+
# Add one signature to an already-(partially-)signed wire transaction WITHOUT
|
|
159
|
+
# rebuilding it. This is the inverse-order cosign: a client wallet (Phantom)
|
|
160
|
+
# signs FIRST and returns the wire bytes with its slot filled and the other
|
|
161
|
+
# slots zero; the server then drops its own signature into the correct slot.
|
|
162
|
+
#
|
|
163
|
+
# Why this exists (Phantom "could be malicious" banner fix): when the SERVER
|
|
164
|
+
# pre-signs and Phantom signs SECOND, Phantom's Lighthouse heuristics flag
|
|
165
|
+
# the multi-signer ordering. Flipping the order — Phantom signs the
|
|
166
|
+
# fully-unsigned tx first, server cosigns after — clears that rule. The
|
|
167
|
+
# server can't rebuild-and-resign (that would change the message bytes and
|
|
168
|
+
# invalidate Phantom's signature), so it must surgically patch the existing
|
|
169
|
+
# wire payload.
|
|
170
|
+
#
|
|
171
|
+
# Pure Ruby, no RPC. Parses the compact-u16 signature count + the message
|
|
172
|
+
# header, locates `signer` in the account-key list, asserts that slot is
|
|
173
|
+
# still zero (never clobber a real signature), signs the EXACT message bytes
|
|
174
|
+
# Phantom signed, and writes the 64-byte signature into that slot. Re-asserts
|
|
175
|
+
# OPSEC-017 afterwards: every one of the numRequiredSignatures slots must be
|
|
176
|
+
# non-zero (the tx is now fully signed and broadcastable).
|
|
177
|
+
#
|
|
178
|
+
# signed_wire_bytes : String (binary) — the wire-format tx (sig count + sigs + message)
|
|
179
|
+
# signer: : Solana::Keypair — the cosigner (e.g. the admin keypair)
|
|
180
|
+
# require_complete: : when true (default) re-assert OPSEC-017 AFTER the write —
|
|
181
|
+
# every one of the numRequiredSignatures slots must be non-zero, i.e. this
|
|
182
|
+
# cosigner is the LAST one and the tx is now fully broadcastable. The
|
|
183
|
+
# turf-monster server cosign is always the final signer, so it leaves this
|
|
184
|
+
# on. Pass false for an intermediate cosign in a 3+-signer chain.
|
|
185
|
+
# Returns the patched wire bytes (binary String). Phantom's signature and the
|
|
186
|
+
# message bytes are left byte-for-byte untouched.
|
|
187
|
+
def self.cosign_wire(signed_wire_bytes, signer:, require_complete: true)
|
|
188
|
+
bytes = signed_wire_bytes.b.dup
|
|
189
|
+
cursor = 0
|
|
190
|
+
|
|
191
|
+
# 1. Compact-u16 signature count.
|
|
192
|
+
sig_count, cursor = read_compact_u16(bytes, cursor)
|
|
193
|
+
raise "cosign_wire: zero signatures in wire payload" if sig_count.zero?
|
|
194
|
+
|
|
195
|
+
sigs_start = cursor
|
|
196
|
+
sigs_len = sig_count * 64
|
|
197
|
+
raise "cosign_wire: truncated signature array" if bytes.bytesize < sigs_start + sigs_len
|
|
198
|
+
message_start = sigs_start + sigs_len
|
|
199
|
+
|
|
200
|
+
# 2. Message header — first byte is numRequiredSignatures. It MUST equal the
|
|
201
|
+
# signature-array length (a well-formed message reserves exactly one slot
|
|
202
|
+
# per declared signer). Guard against an off-by-one / malformed payload.
|
|
203
|
+
num_required = bytes.getbyte(message_start)
|
|
204
|
+
raise "cosign_wire: empty message" if num_required.nil?
|
|
205
|
+
unless num_required == sig_count
|
|
206
|
+
raise "cosign_wire: header numRequiredSignatures=#{num_required} != " \
|
|
207
|
+
"signature slots=#{sig_count} (malformed wire payload)"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# 3. Account keys. Header is 3 bytes, then a compact-u16 account count,
|
|
211
|
+
# then `count` * 32-byte keys. The first `num_required` account keys are
|
|
212
|
+
# the signer slots, in the SAME order as the signature array.
|
|
213
|
+
acct_cursor = message_start + 3
|
|
214
|
+
account_count, acct_cursor = read_compact_u16(bytes, acct_cursor)
|
|
215
|
+
raise "cosign_wire: account count #{account_count} < required signers #{num_required}" if account_count < num_required
|
|
216
|
+
|
|
217
|
+
target = signer.public_key_bytes.b
|
|
218
|
+
slot_index = nil
|
|
219
|
+
num_required.times do |i|
|
|
220
|
+
key = bytes.byteslice(acct_cursor + (i * 32), 32)
|
|
221
|
+
if key == target
|
|
222
|
+
slot_index = i
|
|
223
|
+
break
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
raise "cosign_wire: signer #{signer.address} is not a required signer of this transaction" if slot_index.nil?
|
|
227
|
+
|
|
228
|
+
# 4. The target slot must be empty (all-zero). Never clobber a signature
|
|
229
|
+
# that's already there (Phantom's, or a prior cosigner's).
|
|
230
|
+
slot_offset = sigs_start + (slot_index * 64)
|
|
231
|
+
existing = bytes.byteslice(slot_offset, 64)
|
|
232
|
+
unless existing == ("\x00" * 64).b
|
|
233
|
+
raise "cosign_wire: slot #{slot_index} for #{signer.address} already holds a signature — refusing to clobber"
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# 5. Sign the EXACT message bytes Phantom signed and write the signature in.
|
|
237
|
+
message = bytes.byteslice(message_start, bytes.bytesize - message_start)
|
|
238
|
+
signature = signer.sign(message)
|
|
239
|
+
raise "cosign_wire: signature is not 64 bytes" unless signature.bytesize == 64
|
|
240
|
+
bytes[slot_offset, 64] = signature.b
|
|
241
|
+
|
|
242
|
+
# 6. OPSEC-017 post-condition (when require_complete): the tx must now be
|
|
243
|
+
# fully signed — every one of the num_required slots non-zero. A leftover
|
|
244
|
+
# zero slot means another signer is still missing and the payload is not
|
|
245
|
+
# broadcastable. The server cosign is the last signer, so it asserts this;
|
|
246
|
+
# an intermediate cosign in a 3+-signer chain passes require_complete:false.
|
|
247
|
+
if require_complete
|
|
248
|
+
num_required.times do |i|
|
|
249
|
+
off = sigs_start + (i * 64)
|
|
250
|
+
if bytes.byteslice(off, 64) == ("\x00" * 64).b
|
|
251
|
+
raise "cosign_wire: slot #{i} is still empty after cosign — " \
|
|
252
|
+
"transaction needs #{num_required} signatures and is not yet complete"
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
bytes
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Convenience: cosign a base64 wire tx, return base64.
|
|
261
|
+
def self.cosign_wire_base64(signed_wire_base64, signer:, require_complete: true)
|
|
262
|
+
require "base64"
|
|
263
|
+
patched = cosign_wire(Base64.decode64(signed_wire_base64), signer: signer, require_complete: require_complete)
|
|
264
|
+
Base64.strict_encode64(patched)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Decode a compact-u16 (ShortVec) starting at `offset`. Returns [value, next_offset].
|
|
268
|
+
def self.read_compact_u16(bytes, offset)
|
|
269
|
+
value = 0
|
|
270
|
+
shift = 0
|
|
271
|
+
loop do
|
|
272
|
+
byte = bytes.getbyte(offset)
|
|
273
|
+
raise "read_compact_u16: ran off the end of the buffer" if byte.nil?
|
|
274
|
+
offset += 1
|
|
275
|
+
value |= (byte & 0x7F) << shift
|
|
276
|
+
break if (byte & 0x80).zero?
|
|
277
|
+
shift += 7
|
|
278
|
+
raise "read_compact_u16: value too large" if shift > 21
|
|
279
|
+
end
|
|
280
|
+
[value, offset]
|
|
281
|
+
end
|
|
282
|
+
|
|
158
283
|
private
|
|
159
284
|
|
|
160
285
|
def normalize_pubkey(key)
|
data/lib/solana_studio.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: solana-studio
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.4.
|
|
4
|
+
version: 0.4.7
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: ed25519
|
|
@@ -55,7 +54,6 @@ metadata:
|
|
|
55
54
|
source_code_uri: https://github.com/amcritchie/solana-studio
|
|
56
55
|
bug_tracker_uri: https://github.com/amcritchie/solana-studio/issues
|
|
57
56
|
changelog_uri: https://github.com/amcritchie/solana-studio/blob/main/CHANGELOG.md
|
|
58
|
-
post_install_message:
|
|
59
57
|
rdoc_options: []
|
|
60
58
|
require_paths:
|
|
61
59
|
- lib
|
|
@@ -70,8 +68,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
70
68
|
- !ruby/object:Gem::Version
|
|
71
69
|
version: '0'
|
|
72
70
|
requirements: []
|
|
73
|
-
rubygems_version:
|
|
74
|
-
signing_key:
|
|
71
|
+
rubygems_version: 4.0.9
|
|
75
72
|
specification_version: 4
|
|
76
73
|
summary: 'Ruby primitives for Solana: JSON-RPC client, Ed25519 keypairs, Borsh serialization,
|
|
77
74
|
transaction builder, wallet signature verifier'
|