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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3fca2972e96c5cc005be5505673727eb85a24549f7632f0b1d3d9809ec27411d
4
- data.tar.gz: 6dc069c098e0b6e664ed5fededf8c15848b73a39d845199357c009acf785298e
3
+ metadata.gz: 29449e0ba9a251d17b60b4c453754953630d4b41604dd93285889f58f2d7df3b
4
+ data.tar.gz: a37bd87c44b6934571e0c3e82959caa519f327c5345fa674a956a9695c71c0eb
5
5
  SHA512:
6
- metadata.gz: 8186c3bf21e01bda36d230e1c43a4fb775a9e98812cdd87fa3d7c2513c44ac12178f981b083b3566f8e26eb3062daaaba2427ec6f32310ccfa8b3108ab8ab6ec
7
- data.tar.gz: 67274a43c5f27c1e9765b0ab3ffbd0f871acb22484506bdfdfe74785c02ae4598e702244cf23a90f9ad0e86c08a4bada67ba0e1bbac2d3fbf3cf89907652f376
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
@@ -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
@@ -8,5 +8,5 @@ require_relative "solana/nonce_account"
8
8
  require_relative "solana/auth_verifier"
9
9
 
10
10
  module SolanaStudio
11
- VERSION = "0.4.6"
11
+ VERSION = "0.4.7"
12
12
  end
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.6
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: 2026-06-02 00:00:00.000000000 Z
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: 3.5.11
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'