solana-studio 0.10.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +120 -22
- data/README.md +239 -26
- data/app/assets/javascripts/solana_studio/wallet_identity.js +524 -0
- data/lib/solana/auth_verifier.rb +12 -0
- data/lib/solana/client.rb +54 -1
- data/lib/solana/compute_budget.rb +73 -0
- data/lib/solana/cosign/builder.rb +159 -0
- data/lib/solana/cosign/completer.rb +217 -0
- data/lib/solana/cosign/expectation.rb +272 -0
- data/lib/solana/cosign.rb +198 -0
- data/lib/solana/ed25519_strict.rb +185 -0
- data/lib/solana/keypair.rb +4 -1
- data/lib/solana/wire_message.rb +223 -0
- data/lib/solana_studio/engine.rb +1 -0
- data/lib/solana_studio/version.rb +1 -1
- data/lib/solana_studio.rb +6 -0
- metadata +10 -2
|
@@ -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
|