solana-studio 0.4.5 → 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: c2ffb18603f4ee37c57d42d010090922d657450172cf5d6623e9d637f1650eb5
4
- data.tar.gz: 1e50dff5950f8dd69dae20590c899930f0e6c4342173cbac93d36b8828554307
3
+ metadata.gz: 29449e0ba9a251d17b60b4c453754953630d4b41604dd93285889f58f2d7df3b
4
+ data.tar.gz: a37bd87c44b6934571e0c3e82959caa519f327c5345fa674a956a9695c71c0eb
5
5
  SHA512:
6
- metadata.gz: 41cba2106cced3ae803cb268ed5a5b94d46052ba20045ec50004b40e6f4745443515a015244417d0ab65558d79ff274ceffcaa1a43398d6c11bcdfc7256a3eb6
7
- data.tar.gz: 444c36f828dcc16865f48cd749b5490cf541298f470b8c8f1628875f3d05df33f23e4c1295170772e6f9adb92d9aad3064bd31104b2e5b1c065b06cad4cdf8b0
6
+ metadata.gz: 6537687173850b20cfed221277a3cabf714572ccc765782961ae95ab66bcf6f33150183690e32ca1e0ca3881753c2149661e22eaa413c8c606632fde1c0f513f
7
+ data.tar.gz: f4426121b94008ccf816394f812b31496e53b421da57e29fef38e256bdd3776a659b4d918ee60cc7660800c5995a98a228db91e5dddc638dedd9ead04732ec88
data/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
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
+
16
+ ## v0.4.6 (2026-06-02)
17
+
18
+ ### Added
19
+ - **`Solana::SystemProgram`** — System-Program instruction encoders for durable nonce support: `create_account` (ix 0), `advance_nonce_account` (4), `withdraw_nonce_account` (5), `initialize_nonce_account` (6), `authorize_nonce_account` (7). Plus constants `RECENT_BLOCKHASHES_SYSVAR`, `RENT_SYSVAR`, `NONCE_ACCOUNT_LENGTH` (80). A durable nonce lets a tx stay valid indefinitely (until consumed) instead of expiring with a ~90s recent blockhash — the canonical pattern for long / async / multi-party signing.
20
+ - **`Solana::NonceAccount.parse(bytes)`** — parses an 80-byte nonce account (version, state, authority, stored nonce, lamports_per_signature) with `initialized?` + `authority?(expected)` guards.
21
+
22
+ ### Tests
23
+ - `test/system_program_test.rb` (8 tests): **byte-match** each encoder against the exact `@solana/web3.js` layout (u32 LE index + fields, account metas + signer flags), nonce-account parse round-trip (init + uninit), and an advance-instruction-into-partial-tx composition check.
24
+
25
+ ## v0.4.5 (2026-06-02)
26
+
27
+ ### Fixed
28
+ - **Fully keyless `serialize_partial`** — a build with zero local `@signers` (every required signature supplied externally) now works: the empty-signers guard fires only when neither a local nor an additional signer is present, the fee payer falls back to the first additional signer, and `@signers.drop(1)` is nil-safe. Enables the no-server-key multi-party signing console. (v0.4.4 began this; v0.4.5 completed the fee-payer/signers fallback.)
29
+
5
30
  ## v0.4.3 (2026-05-27)
6
31
 
7
32
  ### Fixed
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
@@ -0,0 +1,64 @@
1
+ module Solana
2
+ # Parser for a System-Program durable NONCE account's on-chain data (80 bytes):
3
+ #
4
+ # version u32 (offset 0)
5
+ # state u32 (offset 4) 0 = Uninitialized, 1 = Initialized
6
+ # authority [32] (offset 8)
7
+ # stored_nonce/blockhash [32] (offset 40) ← anchor a tx's recentBlockhash on this
8
+ # fee_calculator u64 (offset 72) lamports_per_signature
9
+ #
10
+ # Used to read the value a durable-nonce-anchored tx must use, and to verify the
11
+ # account is initialized + owned by the expected authority before trusting it.
12
+ class NonceAccount
13
+ UNINITIALIZED = 0
14
+ INITIALIZED = 1
15
+
16
+ attr_reader :version, :state, :authority, :nonce, :lamports_per_signature
17
+
18
+ def initialize(version:, state:, authority:, nonce:, lamports_per_signature:)
19
+ @version = version
20
+ @state = state
21
+ @authority = authority # base58
22
+ @nonce = nonce # base58 — use as the tx recentBlockhash
23
+ @lamports_per_signature = lamports_per_signature
24
+ end
25
+
26
+ # `data` is the raw account bytes (binary). Accepts a base64 string too.
27
+ def self.parse(data)
28
+ bytes = data
29
+ if bytes.is_a?(String) && bytes.encoding != Encoding::ASCII_8BIT
30
+ bytes = bytes.b
31
+ end
32
+ # Tolerate a base64-encoded blob (what getAccountInfo returns in data[0]).
33
+ if bytes.bytesize != NonceLength && looks_base64?(bytes)
34
+ require "base64"
35
+ bytes = Base64.decode64(bytes).b
36
+ end
37
+ raise ArgumentError, "nonce account too small (#{bytes.bytesize} bytes, need >= 80)" if bytes.bytesize < 80
38
+
39
+ new(
40
+ version: bytes[0, 4].unpack1("V"),
41
+ state: bytes[4, 4].unpack1("V"),
42
+ authority: Keypair.encode_base58(bytes.byteslice(8, 32)),
43
+ nonce: Keypair.encode_base58(bytes.byteslice(40, 32)),
44
+ lamports_per_signature: bytes[72, 8].unpack1("Q<")
45
+ )
46
+ end
47
+
48
+ NonceLength = 80
49
+
50
+ def self.looks_base64?(str)
51
+ str.bytesize > 80 && str.match?(%r{\A[A-Za-z0-9+/=\r\n]+\z})
52
+ end
53
+
54
+ def initialized?
55
+ state == INITIALIZED
56
+ end
57
+
58
+ # True when the account is initialized AND its authority matches `expected`
59
+ # (base58). The guard before anchoring any tx on this nonce.
60
+ def authority?(expected)
61
+ initialized? && authority == expected.to_s
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,112 @@
1
+ module Solana
2
+ # System Program instruction encoders — the subset needed for DURABLE NONCE
3
+ # accounts (plus CreateAccount, which nonce creation needs). Mirrors the
4
+ # SplToken encoder pattern: each method returns a { program_id, accounts, data }
5
+ # hash for Transaction#add_instruction.
6
+ #
7
+ # A durable nonce lets a transaction stay valid INDEFINITELY (until consumed)
8
+ # instead of expiring with a recent blockhash (~90s) — the canonical pattern
9
+ # for long / async / multi-party signing. A nonce-anchored tx sets
10
+ # recentBlockhash = the account's stored nonce and MUST carry advance_nonce_account
11
+ # as its FIRST instruction, signed by the nonce authority.
12
+ #
13
+ # Instruction data is `u32 LE index` + fields (the System Program's bincode
14
+ # layout). Indices: CreateAccount 0, AdvanceNonceAccount 4, WithdrawNonceAccount 5,
15
+ # InitializeNonceAccount 6, AuthorizeNonceAccount 7. Each encoder is byte-match
16
+ # tested against a known-good @solana/web3.js reference before being trusted.
17
+ module SystemProgram
18
+ module_function
19
+
20
+ PROGRAM_ID = Transaction::SYSTEM_PROGRAM_ID # 32 zero bytes
21
+ RECENT_BLOCKHASHES_SYSVAR = Keypair.decode_base58("SysvarRecentB1ockHashes11111111111111111111")
22
+ RENT_SYSVAR = Transaction::SYSVAR_RENT_PUBKEY
23
+ NONCE_ACCOUNT_LENGTH = 80
24
+
25
+ # CreateAccount (ix 0): fund + allocate `space` bytes owned by `owner`.
26
+ # Both `from` (payer) and `new_account` must sign.
27
+ def create_account(from:, new_account:, lamports:, space:, owner:)
28
+ data = u32(0) + u64(lamports) + u64(space) + normalize(owner)
29
+ {
30
+ program_id: PROGRAM_ID,
31
+ accounts: [
32
+ { pubkey: normalize(from), is_signer: true, is_writable: true },
33
+ { pubkey: normalize(new_account), is_signer: true, is_writable: true }
34
+ ],
35
+ data: data
36
+ }
37
+ end
38
+
39
+ # AdvanceNonceAccount (ix 4): MUST be the first instruction of any tx anchored
40
+ # on this nonce. The `authority` signs it.
41
+ def advance_nonce_account(nonce:, authority:)
42
+ {
43
+ program_id: PROGRAM_ID,
44
+ accounts: [
45
+ { pubkey: normalize(nonce), is_signer: false, is_writable: true },
46
+ { pubkey: RECENT_BLOCKHASHES_SYSVAR, is_signer: false, is_writable: false },
47
+ { pubkey: normalize(authority), is_signer: true, is_writable: false }
48
+ ],
49
+ data: u32(4)
50
+ }
51
+ end
52
+
53
+ # WithdrawNonceAccount (ix 5): reclaim lamports from the nonce account.
54
+ def withdraw_nonce_account(nonce:, to:, authority:, lamports:)
55
+ {
56
+ program_id: PROGRAM_ID,
57
+ accounts: [
58
+ { pubkey: normalize(nonce), is_signer: false, is_writable: true },
59
+ { pubkey: normalize(to), is_signer: false, is_writable: true },
60
+ { pubkey: RECENT_BLOCKHASHES_SYSVAR, is_signer: false, is_writable: false },
61
+ { pubkey: RENT_SYSVAR, is_signer: false, is_writable: false },
62
+ { pubkey: normalize(authority), is_signer: true, is_writable: false }
63
+ ],
64
+ data: u32(5) + u64(lamports)
65
+ }
66
+ end
67
+
68
+ # InitializeNonceAccount (ix 6): turn a freshly-created account into a nonce
69
+ # account owned by `authority`. Paired with create_account in one tx.
70
+ def initialize_nonce_account(nonce:, authority:)
71
+ {
72
+ program_id: PROGRAM_ID,
73
+ accounts: [
74
+ { pubkey: normalize(nonce), is_signer: false, is_writable: true },
75
+ { pubkey: RECENT_BLOCKHASHES_SYSVAR, is_signer: false, is_writable: false },
76
+ { pubkey: RENT_SYSVAR, is_signer: false, is_writable: false }
77
+ ],
78
+ data: u32(6) + normalize(authority)
79
+ }
80
+ end
81
+
82
+ # AuthorizeNonceAccount (ix 7): rotate the nonce authority. Current authority signs.
83
+ def authorize_nonce_account(nonce:, authority:, new_authority:)
84
+ {
85
+ program_id: PROGRAM_ID,
86
+ accounts: [
87
+ { pubkey: normalize(nonce), is_signer: false, is_writable: true },
88
+ { pubkey: normalize(authority), is_signer: true, is_writable: false }
89
+ ],
90
+ data: u32(7) + normalize(new_authority)
91
+ }
92
+ end
93
+
94
+ # --- helpers --------------------------------------------------------------
95
+
96
+ def u32(n) = [n].pack("V")
97
+ def u64(n) = [n].pack("Q<")
98
+
99
+ # base58 string / Keypair / 32-byte binary → 32-byte binary.
100
+ def normalize(value)
101
+ if value.is_a?(Keypair)
102
+ value.public_key_bytes
103
+ elsif value.is_a?(String) && value.bytesize == 32
104
+ value.b
105
+ elsif value.is_a?(String)
106
+ Keypair.decode_base58(value)
107
+ else
108
+ value
109
+ end
110
+ end
111
+ end
112
+ 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
@@ -3,8 +3,10 @@ require_relative "solana/borsh"
3
3
  require_relative "solana/client"
4
4
  require_relative "solana/transaction"
5
5
  require_relative "solana/spl_token"
6
+ require_relative "solana/system_program"
7
+ require_relative "solana/nonce_account"
6
8
  require_relative "solana/auth_verifier"
7
9
 
8
10
  module SolanaStudio
9
- VERSION = "0.4.5"
11
+ VERSION = "0.4.7"
10
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.5
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
@@ -42,7 +41,9 @@ files:
42
41
  - lib/solana/borsh.rb
43
42
  - lib/solana/client.rb
44
43
  - lib/solana/keypair.rb
44
+ - lib/solana/nonce_account.rb
45
45
  - lib/solana/spl_token.rb
46
+ - lib/solana/system_program.rb
46
47
  - lib/solana/transaction.rb
47
48
  - lib/solana_studio.rb
48
49
  homepage: https://github.com/amcritchie/solana-studio
@@ -53,7 +54,6 @@ metadata:
53
54
  source_code_uri: https://github.com/amcritchie/solana-studio
54
55
  bug_tracker_uri: https://github.com/amcritchie/solana-studio/issues
55
56
  changelog_uri: https://github.com/amcritchie/solana-studio/blob/main/CHANGELOG.md
56
- post_install_message:
57
57
  rdoc_options: []
58
58
  require_paths:
59
59
  - lib
@@ -68,8 +68,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
68
68
  - !ruby/object:Gem::Version
69
69
  version: '0'
70
70
  requirements: []
71
- rubygems_version: 3.5.11
72
- signing_key:
71
+ rubygems_version: 4.0.9
73
72
  specification_version: 4
74
73
  summary: 'Ruby primitives for Solana: JSON-RPC client, Ed25519 keypairs, Borsh serialization,
75
74
  transaction builder, wallet signature verifier'