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.
@@ -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
@@ -90,7 +90,10 @@ module Solana
90
90
  num = num * 58 + idx
91
91
  end
92
92
 
93
- hex = num.to_s(16)
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
@@ -34,6 +34,7 @@ module SolanaStudio
34
34
  solana_studio/redirect_provider.js
35
35
  solana_studio/wallet_journal.js
36
36
  solana_studio/wallet_ops.js
37
+ solana_studio/wallet_identity.js
37
38
  ]
38
39
  end
39
40
  end
@@ -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.11.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