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,159 @@
1
+ module Solana
2
+ module Cosign
3
+ # A built transaction, ready for its cosigners, with the deadline attached.
4
+ #
5
+ # wire_base64 the wire to hand the wallet(s); #wire_base58 too
6
+ # blockhash the recent blockhash it is anchored on (base58)
7
+ # last_valid_block_height once the cluster's block height passes this, the
8
+ # transaction can never land — rebuild BEFORE
9
+ # prompting if the remainder is short
10
+ # commitment what the blockhash was fetched at; send at the same
11
+ # fee_payer base58
12
+ # signers base58, in signature-slot order (fee payer first)
13
+ # expectation what Completer judges the returned wire against
14
+ #
15
+ # Store wire_base64 and last_valid_block_height server-side if the signature
16
+ # comes back in a later request; Expectation.from_wire rebuilds the rest.
17
+ Prepared = Struct.new(:wire_base64, :blockhash, :last_valid_block_height, :commitment,
18
+ :fee_payer, :signers, :expectation, keyword_init: true) do
19
+ # The same wire in base58, the format SolanaStudio.walletOps `prepare`
20
+ # returns to the browser — no conversion in host JavaScript.
21
+ def wire_base58
22
+ Keypair.encode_base58(Base64.strict_decode64(wire_base64))
23
+ end
24
+ end
25
+
26
+ # Builds a transaction the SERVER pays for.
27
+ #
28
+ # The fee payer is account 0 and every named cosigner gets an empty slot. By
29
+ # default the fee payer's slot is empty too (WALLET-FIRST): the wallet signs
30
+ # an unsigned transaction, and the server fills its own slot afterwards. That
31
+ # is the order Phantom does not flag. When the server signs first and the
32
+ # wallet second, Phantom's Lighthouse heuristics can warn "could be
33
+ # malicious", which is why turf-monster flipped its entries on 2026-06-05.
34
+ #
35
+ # `presign: true` signs the fee payer's slot at build time instead. It exists
36
+ # for flows that still sign server-first (several N-signer operator flows do)
37
+ # so they can adopt this builder before they flip; prefer the default. A
38
+ # presigned wire that a wallet modifies no longer carries a valid fee-payer
39
+ # signature, and Completer refuses it.
40
+ class Builder
41
+ def initialize(client:, fee_payer:)
42
+ @client = client
43
+ @fee_payer = fee_payer
44
+ @fee_payer_bytes = Cosign.key_bytes(fee_payer)
45
+ end
46
+
47
+ # instructions: [{ program_id:, accounts: [{ pubkey:, is_signer:, is_writable: }], data: }]
48
+ # — the hashes Transaction#add_instruction takes (SplToken and
49
+ # SystemProgram encoders return this shape). Keys may be base58, raw
50
+ # bytes, or a Keypair.
51
+ # cosigners: public keys whose slots stay empty for the wallet(s).
52
+ # compute_unit_price: micro-lamports per CU (a priority fee). A fee-less
53
+ # transaction lands on an idle devnet and is dropped by a loaded mainnet.
54
+ # compute_unit_limit: CU cap; the fee is price x limit.
55
+ # commitment: the blockhash is fetched at this commitment and the expectation
56
+ # carries it, so Completer preflights at the same one.
57
+ def build(instructions:, cosigners:, compute_unit_price: nil, compute_unit_limit: nil,
58
+ commitment: DEFAULT_COMMITMENT, presign: false, fee_margin: DEFAULT_FEE_MARGIN,
59
+ extra_programs: DEFAULT_EXTRA_PROGRAMS)
60
+ if presign && !(@fee_payer.respond_to?(:sign) && @fee_payer.respond_to?(:public_key_bytes))
61
+ raise ArgumentError, "presign: true needs the fee payer as a Solana::Keypair, not a bare public key"
62
+ end
63
+
64
+ cosigner_bytes = Array(cosigners).map { |k| Cosign.key_bytes(k) }
65
+ raise ArgumentError, "cosigners must be distinct" unless cosigner_bytes.uniq.size == cosigner_bytes.size
66
+ raise ArgumentError, "the fee payer cannot also be a cosigner" if cosigner_bytes.include?(@fee_payer_bytes)
67
+
68
+ built = Array(instructions).map { |ix| normalize_instruction(ix) }
69
+ raise ArgumentError, "at least one instruction is required" if built.empty?
70
+
71
+ assert_signers_named!(built, cosigner_bytes)
72
+
73
+ latest = @client.latest_blockhash(commitment: commitment)
74
+
75
+ tx = Transaction.new
76
+ tx.set_recent_blockhash(latest.blockhash)
77
+ tx.add_signer(@fee_payer) if presign
78
+ tx.add_instruction(**ComputeBudget.set_compute_unit_price(compute_unit_price)) unless compute_unit_price.nil?
79
+ tx.add_instruction(**ComputeBudget.set_compute_unit_limit(compute_unit_limit)) unless compute_unit_limit.nil?
80
+ built.each { |ix| tx.add_instruction(**ix) }
81
+
82
+ # Keyless build: Transaction#serialize_partial takes the FIRST additional
83
+ # signer as the fee payer when it holds no local signer.
84
+ additional = presign ? cosigner_bytes : [@fee_payer_bytes, *cosigner_bytes]
85
+ wire = tx.serialize_partial(additional_signers: additional)
86
+
87
+ message = WireMessage.parse(wire)
88
+ unless message.fee_payer == @fee_payer_bytes
89
+ raise "Cosign::Builder invariant: account 0 is #{Cosign.base58(message.fee_payer)}, not the fee payer"
90
+ end
91
+
92
+ max_price, max_fee = Cosign.fee_caps(compute_unit_price: compute_unit_price,
93
+ compute_unit_limit: compute_unit_limit, margin: fee_margin)
94
+ expectation = Expectation.new(
95
+ fee_payer: @fee_payer_bytes,
96
+ cosigners: cosigner_bytes,
97
+ instructions: built,
98
+ last_valid_block_height: latest.last_valid_block_height,
99
+ commitment: commitment,
100
+ max_compute_unit_price: max_price,
101
+ max_priority_fee_micro_lamports: max_fee,
102
+ extra_programs: extra_programs
103
+ )
104
+
105
+ Prepared.new(
106
+ wire_base64: message.to_base64,
107
+ blockhash: latest.blockhash,
108
+ last_valid_block_height: latest.last_valid_block_height,
109
+ commitment: commitment,
110
+ fee_payer: Cosign.base58(@fee_payer_bytes),
111
+ signers: message.signer_keys.map { |k| Cosign.base58(k) },
112
+ expectation: expectation
113
+ )
114
+ end
115
+
116
+ private
117
+
118
+ # Every key normalized to raw bytes BEFORE it reaches Transaction, whose
119
+ # own normalizer reads any 32-byte string as raw — a 32-character base58
120
+ # address included.
121
+ def normalize_instruction(ix)
122
+ raise ArgumentError, "instruction must be a Hash with program_id:, accounts:, data:" unless ix.is_a?(Hash)
123
+
124
+ program_id = Cosign.key_bytes(ix.fetch(:program_id))
125
+ if program_id == ComputeBudget::PROGRAM_ID.b
126
+ raise ArgumentError, "pass compute_unit_price:/compute_unit_limit: instead of a ComputeBudget instruction"
127
+ end
128
+
129
+ data = ix.fetch(:data)
130
+ {
131
+ program_id: program_id,
132
+ accounts: Array(ix.fetch(:accounts)).map do |meta|
133
+ raise ArgumentError, "account metas must be Hashes with pubkey:" unless meta.is_a?(Hash)
134
+
135
+ { pubkey: Cosign.key_bytes(meta.fetch(:pubkey)),
136
+ is_signer: meta[:is_signer] ? true : false,
137
+ is_writable: meta[:is_writable] ? true : false }
138
+ end,
139
+ data: data.is_a?(Array) ? data.pack("C*") : data.to_s.b
140
+ }
141
+ end
142
+
143
+ # A signer no one will sign for makes a transaction that can never land;
144
+ # say which key, here, instead of a signer-count mismatch at serialize.
145
+ def assert_signers_named!(built, cosigner_bytes)
146
+ named = [@fee_payer_bytes, *cosigner_bytes]
147
+ built.each_with_index do |ix, n|
148
+ ix[:accounts].each do |meta|
149
+ next unless meta[:is_signer]
150
+ next if named.include?(meta[:pubkey])
151
+
152
+ raise ArgumentError, "instruction #{n} requires a signature from #{Cosign.base58(meta[:pubkey])}, " \
153
+ "which is neither the fee payer nor a named cosigner"
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,217 @@
1
+ module Solana
2
+ module Cosign
3
+ # Takes a wallet-signed wire back, and finishes it — or refuses it.
4
+ #
5
+ # Three levels, so a flow can stop where its broadcast lives:
6
+ #
7
+ # #verify! judge the wire against the Expectation. No key, no RPC.
8
+ # #cosign verify, check every cosigner signature, fill the fee payer's
9
+ # slot (Transaction.cosign_wire). No RPC. For a flow whose
10
+ # browser broadcasts.
11
+ # #complete cosign, check the deadline, simulate, record, send, confirm.
12
+ #
13
+ # THE FEE PAYER SIGNS LAST, AND ONLY WHAT PASSED. Every check that can refuse
14
+ # the wire runs before the fee payer's key is used, so a refused wire leaves
15
+ # nothing behind that could be broadcast.
16
+ class Completer
17
+ Cosigned = Struct.new(:wire_base64, :signature, :message, keyword_init: true) do
18
+ def wire_base58
19
+ message.to_base58
20
+ end
21
+ end
22
+ Completed = Struct.new(:signature, :wire_base64, :confirmation_status, keyword_init: true)
23
+
24
+ COMMITMENT_RANK = { "processed" => 0, "confirmed" => 1, "finalized" => 2 }.freeze
25
+
26
+ # fee_payer: the Solana::Keypair whose slot this server fills. Passed in;
27
+ # this class never loads, stores or derives a key.
28
+ # sleeper / clock / poll_interval: injectable so the confirmation loop can be
29
+ # tested without waiting on a real clock.
30
+ def initialize(client:, fee_payer:, poll_interval: 1,
31
+ sleeper: ->(seconds) { sleep(seconds) },
32
+ clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
33
+ unless fee_payer.respond_to?(:sign) && fee_payer.respond_to?(:public_key_bytes)
34
+ raise ArgumentError, "fee_payer must be a Solana::Keypair — the completer signs with it"
35
+ end
36
+
37
+ @client = client
38
+ @fee_payer = fee_payer
39
+ @fee_payer_bytes = fee_payer.public_key_bytes.b
40
+ @poll_interval = poll_interval
41
+ @sleeper = sleeper
42
+ @clock = clock
43
+ end
44
+
45
+ # signed_wire: the wallet-returned wire, base64 by default; pass
46
+ # `encoding: :base58` for what SolanaStudio.walletOps hands `complete`.
47
+ # Returns the decoded WireMessage, or raises WireRejected.
48
+ def verify!(signed_wire, expectation:, encoding: :base64)
49
+ unless expectation.fee_payer == @fee_payer_bytes
50
+ # A wiring mistake in the caller, not something the wire did.
51
+ raise ArgumentError, "the expectation's fee payer #{Cosign.base58(expectation.fee_payer)} " \
52
+ "is not this completer's key #{Cosign.base58(@fee_payer_bytes)}"
53
+ end
54
+
55
+ message =
56
+ begin
57
+ WireMessage.parse_encoded(signed_wire, encoding)
58
+ rescue WireMessage::MalformedError => e
59
+ raise WireRejected.new(:unparseable_wire, e.message)
60
+ end
61
+ expectation.verify!(message)
62
+ message
63
+ end
64
+
65
+ # Returns Cosigned(wire_base64, signature, message). The signature is the
66
+ # transaction's id, known BEFORE anything is sent: record it first.
67
+ def cosign(signed_wire, expectation:, encoding: :base64)
68
+ message = verify!(signed_wire, expectation: expectation, encoding: encoding)
69
+
70
+ # Every cosigner slot must already hold a valid signature over THESE
71
+ # bytes. An empty or forged slot is refused here, before the fee payer
72
+ # signs — rather than by the node, after the fee payer has.
73
+ (1...message.num_required_signatures).each do |slot|
74
+ signer = Cosign.base58(message.account_keys[slot])
75
+ raise WireRejected.new(:signature_missing, "slot #{slot} (#{signer}) is unsigned") if message.signature_slot_empty?(slot)
76
+ raise WireRejected.new(:signature_invalid, "slot #{slot} (#{signer}) does not verify") unless message.signature_valid?(slot)
77
+ end
78
+
79
+ wire =
80
+ if message.signature_slot_empty?(0)
81
+ Transaction.cosign_wire(message.to_bytes, signer: @fee_payer, require_complete: true)
82
+ elsif message.signature_valid?(0)
83
+ message.to_bytes # presigned at build, and the message is unchanged since
84
+ else
85
+ raise WireRejected.new(:fee_payer_signature_invalid,
86
+ "slot 0 holds a signature that does not verify over this message — " \
87
+ "the transaction changed after the fee payer signed it")
88
+ end
89
+
90
+ signed = WireMessage.parse(wire)
91
+ Cosigned.new(wire_base64: signed.to_base64, signature: signed.signature, message: signed)
92
+ end
93
+
94
+ # Cosign, then put it on chain.
95
+ #
96
+ # before_send: called with the signature after every provable check and
97
+ # before the send. Persist it there. If it raises, nothing is sent and the
98
+ # exception propagates unchanged.
99
+ # simulate: run simulateTransaction first (sigVerify false — the signatures
100
+ # were verified locally; replaceRecentBlockhash true — the deadline check
101
+ # judges the blockhash, the simulation judges the program).
102
+ # confirm_timeout: seconds to wait for the expectation's commitment; nil
103
+ # returns right after the send with confirmation_status nil.
104
+ def complete(signed_wire, expectation:, encoding: :base64, simulate: true, before_send: nil, confirm_timeout: 30)
105
+ cosigned = cosign(signed_wire, expectation: expectation, encoding: encoding)
106
+ signature = cosigned.signature
107
+ wire_base64 = cosigned.wire_base64
108
+ commitment = expectation.commitment
109
+
110
+ assert_not_expired!(expectation, signature)
111
+ run_simulation!(wire_base64, commitment, signature) if simulate
112
+
113
+ before_send&.call(signature)
114
+
115
+ returned =
116
+ begin
117
+ @client.send_transaction(wire_base64, preflight_commitment: commitment)
118
+ rescue StandardError => e
119
+ if blockhash_not_found?(e)
120
+ raise BroadcastExpired.new("the node no longer knows this blockhash: #{e.message}", signature: signature)
121
+ end
122
+
123
+ raise BroadcastFailed.new("send failed — reconcile #{signature} before rebuilding: #{e.message}", signature: signature)
124
+ end
125
+
126
+ if returned && returned != signature
127
+ raise BroadcastFailed.new("the node returned #{returned} for a wire whose own first signature is #{signature} — " \
128
+ "reconcile both on chain before acting", signature: signature)
129
+ end
130
+
131
+ return Completed.new(signature: signature, wire_base64: wire_base64, confirmation_status: nil) if confirm_timeout.nil?
132
+
133
+ status = await_confirmation!(signature, expectation, confirm_timeout)
134
+ Completed.new(signature: signature, wire_base64: wire_base64, confirmation_status: status)
135
+ end
136
+
137
+ private
138
+
139
+ def assert_not_expired!(expectation, signature)
140
+ limit = expectation.last_valid_block_height
141
+ return if limit.nil?
142
+
143
+ height =
144
+ begin
145
+ @client.get_block_height(commitment: expectation.commitment)
146
+ rescue StandardError => e
147
+ raise PreflightRejected.new("could not read the block height to check the deadline: #{e.message}", signature: signature)
148
+ end
149
+ return if height <= limit
150
+
151
+ raise BlockhashExpired.new("blockhash expired: block height #{height} is past last valid height #{limit}",
152
+ signature: signature, block_height: height, last_valid_block_height: limit)
153
+ end
154
+
155
+ def run_simulation!(wire_base64, commitment, signature)
156
+ result =
157
+ begin
158
+ @client.simulate_transaction(wire_base64, sig_verify: false, replace_recent_blockhash: true, commitment: commitment)
159
+ rescue StandardError => e
160
+ raise PreflightRejected.new("simulation could not be run: #{e.message}", signature: signature)
161
+ end
162
+ return unless result && result["err"]
163
+
164
+ logs = Array(result["logs"])
165
+ raise SimulationFailed.new("simulation failed: #{result['err'].inspect}", signature: signature,
166
+ err: result["err"], logs: logs)
167
+ end
168
+
169
+ # Polls until the expectation's commitment is reached. A status read that
170
+ # fails is not a verdict; the loop keeps asking until the deadline.
171
+ def await_confirmation!(signature, expectation, timeout)
172
+ wanted = COMMITMENT_RANK.fetch(expectation.commitment, COMMITMENT_RANK["confirmed"])
173
+ deadline = @clock.call + timeout
174
+
175
+ loop do
176
+ @sleeper.call(@poll_interval)
177
+ status = read_status(signature)
178
+
179
+ if status
180
+ if status["err"]
181
+ raise TransactionFailed.new("landed and failed: #{status['err'].inspect}", signature: signature, err: status["err"])
182
+ end
183
+
184
+ reached = COMMITMENT_RANK[status["confirmationStatus"].to_s]
185
+ return status["confirmationStatus"] if reached && reached >= wanted
186
+ elsif chain_says_expired?(expectation)
187
+ raise BroadcastExpired.new("no status for #{signature} and the block height is past its last valid height",
188
+ signature: signature)
189
+ end
190
+
191
+ if @clock.call > deadline
192
+ raise BroadcastFailed.new("confirmation timed out after #{timeout}s — reconcile #{signature} before rebuilding",
193
+ signature: signature)
194
+ end
195
+ end
196
+ end
197
+
198
+ def read_status(signature)
199
+ @client.confirm_transaction(signature)&.dig("value", 0)
200
+ rescue StandardError
201
+ nil
202
+ end
203
+
204
+ def chain_says_expired?(expectation)
205
+ return false if expectation.last_valid_block_height.nil?
206
+
207
+ @client.get_block_height(commitment: expectation.commitment) > expectation.last_valid_block_height
208
+ rescue StandardError
209
+ false
210
+ end
211
+
212
+ def blockhash_not_found?(error)
213
+ error.message.to_s.match?(/blockhash not found/i)
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,272 @@
1
+ module Solana
2
+ module Cosign
3
+ # What the server built, in the form a returned wire is judged against.
4
+ #
5
+ # A wallet re-encodes the wire and may insert its own protection
6
+ # instructions, so the judgement is by MEANING, never by bytes:
7
+ #
8
+ # 1. Account 0 — the fee payer — is this server's fee payer, and writable.
9
+ # 2. The signer set is EXACTLY the fee payer plus the named cosigners. No
10
+ # extra signer (an attacker's own key), none missing.
11
+ # 3. Remove ComputeBudget instructions and instructions of an admitted
12
+ # extra program (Lighthouse by default). What remains must EQUAL the
13
+ # built instruction list: same programs, same ordered account keys, same
14
+ # data, same order. A generic library cannot know which of an app's
15
+ # accounts matter, so every one of them does. A System transfer from the
16
+ # fee payer, a nonce advance, a duplicated or swapped instruction, an
17
+ # altered amount — each fails here.
18
+ # 4. ComputeBudget is READ, never waved through: only SetComputeUnitLimit
19
+ # and SetComputeUnitPrice, once each, and the fee they make the fee payer
20
+ # pay is capped (Cosign::DEFAULT_FEE_MARGIN).
21
+ # 5. An admitted extra program is READ too, never waved through. A
22
+ # Lighthouse instruction passes only when its first data byte is an
23
+ # assertion variant (Cosign::LIGHTHOUSE_ASSERTIONS). MemoryWrite, which
24
+ # makes a signer — the fee payer included — fund an account, MemoryClose,
25
+ # empty data and an unknown variant are refused. Its accounts are not
26
+ # compared: an assertion takes no payer and writes nothing, so what it
27
+ # names cannot move funds, and Phantom's assertions name the fee payer.
28
+ # 6. Optionally, the recent blockhash is the one built on (`blockhash:`).
29
+ # Off by default: turf-monster's production guards never pinned it, and
30
+ # whether any wallet rewrites it on mainnet has not been measured. Pin it
31
+ # when last_valid_block_height must describe the returned wire exactly.
32
+ #
33
+ # Writable flags other than the fee payer's are not compared: they cannot
34
+ # move the fee payer's funds, and a wallet re-encoding them is not an attack
35
+ # on the house.
36
+ class Expectation
37
+ # The only programs an app may admit as "extra", each with the rule that
38
+ # reads its instructions. An admitted instruction skips the comparison in
39
+ # rule 3, so something must prove it cannot move the fee payer's funds,
40
+ # and a program id alone proves nothing: Lighthouse carries assertions
41
+ # AND a MemoryWrite that spends its payer's lamports. A program with no
42
+ # rule here cannot be admitted.
43
+ EXTRA_PROGRAM_RULES = { LIGHTHOUSE_PROGRAM_ID.b => :admit_lighthouse! }.freeze
44
+
45
+ attr_reader :fee_payer, :cosigners, :instructions, :blockhash, :last_valid_block_height,
46
+ :commitment, :max_compute_unit_price, :max_priority_fee_micro_lamports, :extra_programs
47
+
48
+ # instructions: [{ program_id:, accounts: [meta Hash or key], data: }] — the
49
+ # same hashes Transaction#add_instruction takes. Account metas may carry
50
+ # is_signer / is_writable; only the ordered keys are compared.
51
+ def initialize(fee_payer:, cosigners:, instructions:, max_compute_unit_price:, max_priority_fee_micro_lamports:,
52
+ blockhash: nil, last_valid_block_height: nil, commitment: DEFAULT_COMMITMENT,
53
+ extra_programs: DEFAULT_EXTRA_PROGRAMS)
54
+ @fee_payer = Cosign.key_bytes(fee_payer)
55
+ @cosigners = Array(cosigners).map { |k| Cosign.key_bytes(k) }
56
+ raise ArgumentError, "cosigners must be distinct" unless @cosigners.uniq.size == @cosigners.size
57
+ raise ArgumentError, "the fee payer cannot also be a cosigner" if @cosigners.include?(@fee_payer)
58
+
59
+ @extra_programs = Array(extra_programs).map { |k| Cosign.key_bytes(k) }.uniq
60
+ unruled = @extra_programs.reject { |k| EXTRA_PROGRAM_RULES.key?(k) }
61
+ unless unruled.empty?
62
+ raise ArgumentError, "cannot admit #{unruled.map { |k| Cosign.base58(k) }.join(', ')} as an extra program: " \
63
+ "no rule reads its instructions, so nothing proves they cannot move the fee payer's " \
64
+ "funds (admissible: #{EXTRA_PROGRAM_RULES.keys.map { |k| Cosign.base58(k) }.join(', ')})"
65
+ end
66
+
67
+ @instructions = Array(instructions).map { |ix| normalize_instruction(ix) }
68
+ raise ArgumentError, "at least one instruction is required" if @instructions.empty?
69
+
70
+ @instructions.each do |ix|
71
+ if ix[:program_id] == ComputeBudget::PROGRAM_ID.b
72
+ raise ArgumentError, "ComputeBudget is priced by the caps, not listed as an instruction"
73
+ end
74
+ if @extra_programs.include?(ix[:program_id])
75
+ raise ArgumentError, "#{Cosign.base58(ix[:program_id])} is both a built instruction and an admitted extra program"
76
+ end
77
+ end
78
+
79
+ @blockhash = blockhash.nil? ? nil : Cosign.key_bytes(blockhash)
80
+ @last_valid_block_height = last_valid_block_height.nil? ? nil : Integer(last_valid_block_height)
81
+ @commitment = commitment.to_s
82
+ @max_compute_unit_price = non_negative!(max_compute_unit_price, "max_compute_unit_price")
83
+ @max_priority_fee_micro_lamports = non_negative!(max_priority_fee_micro_lamports, "max_priority_fee_micro_lamports")
84
+ end
85
+
86
+ # Rebuild the expectation from a wire THIS SERVER BUILT and kept in its own
87
+ # storage (a database row, never a request parameter), for the request that
88
+ # receives the wallet's signature. The built wire's ComputeBudget pair
89
+ # derives the fee caps; its signer slots after account 0 are the cosigners.
90
+ #
91
+ # A wire from the client is not a source of expectations — it is the thing
92
+ # being judged. Passing one here judges the wire against itself.
93
+ def self.from_wire(built_wire, fee_payer:, last_valid_block_height: nil, commitment: DEFAULT_COMMITMENT,
94
+ pin_blockhash: false, fee_margin: DEFAULT_FEE_MARGIN, extra_programs: DEFAULT_EXTRA_PROGRAMS,
95
+ encoding: :base64)
96
+ message = WireMessage.parse_encoded(built_wire, encoding)
97
+ payer = Cosign.key_bytes(fee_payer)
98
+ unless message.fee_payer == payer
99
+ raise ArgumentError, "the built wire's fee payer is #{Cosign.base58(message.fee_payer)}, not #{Cosign.base58(payer)}"
100
+ end
101
+
102
+ budget = {}
103
+ instructions = []
104
+ message.instructions.each do |ix|
105
+ if ix[:program_id] == ComputeBudget::PROGRAM_ID.b
106
+ kind, value = ComputeBudget.parse(ix[:data])
107
+ raise ArgumentError, "the built wire repeats ComputeBudget #{kind}" if budget.key?(kind)
108
+
109
+ budget[kind] = value
110
+ else
111
+ instructions << { program_id: ix[:program_id], accounts: ix[:accounts], data: ix[:data] }
112
+ end
113
+ end
114
+
115
+ max_price, max_fee = Cosign.fee_caps(compute_unit_price: budget[:price], compute_unit_limit: budget[:limit],
116
+ margin: fee_margin)
117
+ new(
118
+ fee_payer: payer,
119
+ cosigners: message.signer_keys.drop(1),
120
+ instructions: instructions,
121
+ blockhash: pin_blockhash ? message.recent_blockhash : nil,
122
+ last_valid_block_height: last_valid_block_height,
123
+ commitment: commitment,
124
+ max_compute_unit_price: max_price,
125
+ max_priority_fee_micro_lamports: max_fee,
126
+ extra_programs: extra_programs
127
+ )
128
+ end
129
+
130
+ # The expectation with the recent blockhash pinned. See rule 6 above.
131
+ def pinned_to(blockhash)
132
+ dup.tap { |copy| copy.instance_variable_set(:@blockhash, Cosign.key_bytes(blockhash)) }
133
+ end
134
+
135
+ def signer_set
136
+ [@fee_payer, *@cosigners]
137
+ end
138
+
139
+ # Judge a decoded wire. Returns true, or raises WireRejected naming the
140
+ # first rule it breaks.
141
+ def verify!(message)
142
+ unless message.fee_payer == @fee_payer
143
+ reject!(:fee_payer_mismatch, "account 0 is #{Cosign.base58(message.fee_payer)}, expected #{Cosign.base58(@fee_payer)}")
144
+ end
145
+ reject!(:fee_payer_not_writable, "account 0 is not writable") unless message.writable?(0)
146
+
147
+ actual_signers = message.signer_keys
148
+ extra = actual_signers - signer_set
149
+ missing = signer_set - actual_signers
150
+ unless extra.empty? && missing.empty?
151
+ reject!(:signer_set_mismatch,
152
+ "unexpected signer(s) [#{extra.map { |k| Cosign.base58(k) }.join(', ')}], " \
153
+ "missing signer(s) [#{missing.map { |k| Cosign.base58(k) }.join(', ')}]")
154
+ end
155
+
156
+ if @blockhash && message.recent_blockhash != @blockhash
157
+ reject!(:blockhash_mismatch, "wire anchors on #{message.recent_blockhash_base58}, built on #{Cosign.base58(@blockhash)}")
158
+ end
159
+
160
+ budget = {}
161
+ observed = []
162
+ message.instructions.each_with_index do |ix, index|
163
+ if ix[:program_id] == ComputeBudget::PROGRAM_ID.b
164
+ read_compute_budget!(ix, index, budget)
165
+ elsif @extra_programs.include?(ix[:program_id])
166
+ send(EXTRA_PROGRAM_RULES.fetch(ix[:program_id]), ix, index)
167
+ else
168
+ observed << ix.merge(index: index)
169
+ end
170
+ end
171
+
172
+ compare_instructions!(observed)
173
+ assert_fee_capped!(budget)
174
+ true
175
+ end
176
+
177
+ private
178
+
179
+ def normalize_instruction(ix)
180
+ raise ArgumentError, "instruction must be a Hash with program_id:, accounts:, data:" unless ix.is_a?(Hash)
181
+
182
+ data = ix.fetch(:data)
183
+ {
184
+ program_id: Cosign.key_bytes(ix.fetch(:program_id)),
185
+ accounts: Array(ix.fetch(:accounts)).map { |a| Cosign.key_bytes(a.is_a?(Hash) ? a.fetch(:pubkey) : a) },
186
+ data: data.is_a?(Array) ? data.pack("C*") : data.to_s.b
187
+ }
188
+ end
189
+
190
+ def non_negative!(value, name)
191
+ value = Integer(value)
192
+ raise ArgumentError, "#{name} must be >= 0, got #{value}" if value.negative?
193
+
194
+ value
195
+ end
196
+
197
+ def read_compute_budget!(ix, index, budget)
198
+ kind, value =
199
+ begin
200
+ ComputeBudget.parse(ix[:data])
201
+ rescue ArgumentError => e
202
+ reject!(:compute_budget_not_allowed, "ix #{index}: #{e.message}")
203
+ end
204
+ reject!(:compute_budget_duplicate, "ix #{index} repeats #{kind}") if budget.key?(kind)
205
+ budget[kind] = value
206
+ end
207
+
208
+ # Rule 5 for Lighthouse: see Cosign::LIGHTHOUSE_PROGRAM_ID for the variants
209
+ # and the mainnet evidence behind this list.
210
+ def admit_lighthouse!(ix, index)
211
+ variant = ix[:data].getbyte(0)
212
+ case variant
213
+ when nil
214
+ reject!(:lighthouse_empty_data, "ix #{index} carries no instruction variant")
215
+ when LIGHTHOUSE_MEMORY_WRITE
216
+ reject!(:lighthouse_memory_write,
217
+ "ix #{index} is Lighthouse MemoryWrite (0): a signer would fund a memory account")
218
+ when LIGHTHOUSE_MEMORY_CLOSE
219
+ reject!(:lighthouse_memory_close,
220
+ "ix #{index} is Lighthouse MemoryClose (1): a memory-account operation, not an assertion")
221
+ when LIGHTHOUSE_ASSERTIONS
222
+ nil
223
+ else
224
+ reject!(:lighthouse_unknown_disc, "ix #{index} variant #{variant} is not a Lighthouse assertion")
225
+ end
226
+ end
227
+
228
+ def compare_instructions!(observed)
229
+ @instructions.each_with_index do |want, n|
230
+ got = observed[n]
231
+ reject!(:instruction_missing, "built instruction #{n} (#{Cosign.base58(want[:program_id])}) is absent") if got.nil?
232
+
233
+ if got[:program_id] != want[:program_id]
234
+ reject!(:unexpected_instruction,
235
+ "ix #{got[:index]} program #{program_label(got[:program_id])} where built instruction #{n} " \
236
+ "expects #{Cosign.base58(want[:program_id])}")
237
+ end
238
+ reject!(:instruction_data_mismatch, "ix #{got[:index]} data differs from built instruction #{n}") if got[:data] != want[:data]
239
+ if got[:accounts] != want[:accounts]
240
+ reject!(:instruction_accounts_mismatch,
241
+ "ix #{got[:index]} accounts [#{got[:accounts].map { |k| Cosign.base58(k) }.join(', ')}] differ from built instruction #{n}")
242
+ end
243
+ end
244
+
245
+ return if observed.size == @instructions.size
246
+
247
+ extra = observed[@instructions.size]
248
+ reject!(:unexpected_instruction, "ix #{extra[:index]} program #{program_label(extra[:program_id])} was not built")
249
+ end
250
+
251
+ def assert_fee_capped!(budget)
252
+ price = budget.fetch(:price, 0)
253
+ if price > @max_compute_unit_price
254
+ reject!(:compute_unit_price_over_cap, "#{price} > #{@max_compute_unit_price} micro-lamports/CU")
255
+ end
256
+
257
+ fee = ComputeBudget.priority_fee_micro_lamports(price: price, limit: budget[:limit])
258
+ return if fee <= @max_priority_fee_micro_lamports
259
+
260
+ reject!(:priority_fee_over_cap, "#{fee} > #{@max_priority_fee_micro_lamports} micro-lamports")
261
+ end
262
+
263
+ def program_label(program_id)
264
+ program_id == Transaction::SYSTEM_PROGRAM_ID.b ? "System" : Cosign.base58(program_id)
265
+ end
266
+
267
+ def reject!(reason, detail)
268
+ raise WireRejected.new(reason, detail)
269
+ end
270
+ end
271
+ end
272
+ end