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,524 @@
1
+ // SolanaStudio.walletIdentity — the browser wallet as a session identity source.
2
+ //
3
+ // WHAT IT IS. studio-engine's session-drift primitive (window.StudioSession,
4
+ // studio/session.js) compares the identities a page was rendered for against the
5
+ // identities the browser observes now. The engine is web2 by rule and knows
6
+ // nothing about wallets; it takes an IDENTITY SOURCE instead:
7
+ //
8
+ // StudioSession.registerIdentitySource({ name, start(report), bound?, equals? })
9
+ //
10
+ // This file is that source for a Solana wallet. The identity it observes is the
11
+ // connected wallet address. A host registers it once per window (a Turbo visit
12
+ // keeps the store and the registration; the same name twice throws):
13
+ //
14
+ // var wallet = SolanaStudio.walletIdentity.register({ getProvider: ..., trustedConnect: ... });
15
+ // wallet.source.current(); // { status, address, providerName }
16
+ //
17
+ // WHAT IT REPORTS, in the engine's vocabulary:
18
+ //
19
+ // status report() meaning
20
+ // unknown undefined cannot tell yet: discovery or a silent connect is pending
21
+ // none null no wallet provider on this page after discovery
22
+ // disconnected null a provider is present and holds no account for this site
23
+ // connected "<address>" this wallet is connected
24
+ //
25
+ // "Not yet known" and "no wallet" never collapse. A navbar that renders from
26
+ // `unknown` as if it were `none` tells someone with a wallet that they have none.
27
+ // The engine sees only the report column; the four statuses are for the page.
28
+ //
29
+ // WHAT A MISMATCH IS. Only a DIFFERENT connected address. A disconnect, a locked
30
+ // extension, or a page with no wallet at all is not a switch to someone else, so
31
+ // `equals` treats an observed null as agreeing with any bound address unless the
32
+ // host passes `disconnectIsMismatch: true`. The page still sees the disconnect
33
+ // through `current()` and `subscribe()`.
34
+ //
35
+ // THE LESSONS THIS CARRIES, each one paid for in turf-monster:
36
+ //
37
+ // · READ THE WALLET LIVE. A Wallet Standard wallet's `accounts` is its own
38
+ // current view. An adapter that answered from a value cached at connect time
39
+ // reported the PREVIOUS account forever after a switch the wallet never
40
+ // announced (turf-monster wallet-switch-rehydrates-session). Nothing here
41
+ // caches an address as the truth: every read goes back to the wallet.
42
+ //
43
+ // · EVENTS ARE BEST-EFFORT. A switch made while the tab was hidden, or while a
44
+ // wallet side panel held focus, can arrive late or not at all. Returning to
45
+ // the page (focus, visibilitychange to visible, pageshow) re-resolves the
46
+ // provider and re-reads it.
47
+ //
48
+ // · IDENTITY BY CLOSURE. A provider replaced by a later one (a Wallet Standard
49
+ // registration superseding the injected object) must stay ignored. That is
50
+ // decided by comparing the binding held in this closure, never a field read
51
+ // back off a reactive store, which returns a proxy that equals nothing.
52
+ //
53
+ // · BIND ONCE. Listeners attach once per provider object, however many times
54
+ // discovery and focus re-resolve it.
55
+ //
56
+ // · A SILENT CONNECT IS NOT FREE. `connect({ onlyIfTrusted: true })` can pop
57
+ // Phantom's unlock prompt on a locked extension, so it is OFF by default. A
58
+ // host turns it on only where a wallet session is already expected.
59
+ //
60
+ // It never signs, never sends, never writes storage, and never opens a modal.
61
+ // Written as ES5 with Promises, like the other assets in this engine, so every
62
+ // host's asset pipeline accepts it untouched.
63
+ (function (W) {
64
+ "use strict";
65
+
66
+ W.SolanaStudio = W.SolanaStudio || {};
67
+ if (W.SolanaStudio.walletIdentity && W.SolanaStudio.walletIdentity.loaded) return;
68
+
69
+ var STATUSES = ["unknown", "none", "disconnected", "connected"];
70
+
71
+ var DEFAULTS = {
72
+ name: "wallet",
73
+ discoveryMs: 3000,
74
+ discoveryIntervalMs: 100,
75
+ trustedConnect: false,
76
+ disconnectIsMismatch: false
77
+ };
78
+
79
+ function logError(error) {
80
+ if (typeof console !== "undefined" && console.error) console.error("[SolanaStudio.walletIdentity]", error);
81
+ }
82
+
83
+ // The injected provider a page with no registry of its own would watch.
84
+ function defaultProvider() {
85
+ var phantom = W.phantom && W.phantom.solana;
86
+ return phantom || W.solana || null;
87
+ }
88
+
89
+ // A raw Wallet Standard wallet carries `features` and its own `accounts`.
90
+ // Everything else is treated as an injected provider: Phantom's legacy
91
+ // `window.phantom.solana`, or a host adapter normalized to that shape.
92
+ function isWalletStandard(provider) {
93
+ return !!(provider && provider.features && typeof provider.features === "object" &&
94
+ "accounts" in provider);
95
+ }
96
+
97
+ // A key, a Wallet Standard account, or a base58 string → an address.
98
+ // null means "no account"; undefined means "an account I cannot read", which
99
+ // is "cannot tell", never "no wallet".
100
+ function addressOf(key) {
101
+ if (key == null) return null;
102
+ if (typeof key === "string") return key === "" ? null : key;
103
+ if (typeof key.address === "string") return key.address === "" ? null : key.address;
104
+ if (typeof key.toBase58 === "function") {
105
+ var text = key.toBase58();
106
+ return text ? String(text) : undefined;
107
+ }
108
+ return undefined;
109
+ }
110
+
111
+ // THE LIVE READ. Always the wallet's own current answer.
112
+ function liveAddress(provider) {
113
+ try {
114
+ if (isWalletStandard(provider)) {
115
+ var accounts = provider.accounts;
116
+ if (!accounts) return undefined;
117
+ return accounts.length ? addressOf(accounts[0]) : null;
118
+ }
119
+ return addressOf(provider.publicKey);
120
+ } catch (e) {
121
+ logError(e);
122
+ return undefined;
123
+ }
124
+ }
125
+
126
+ function providerNameOf(provider) {
127
+ return provider && typeof provider.name === "string" && provider.name ? provider.name : null;
128
+ }
129
+
130
+ function create(options) {
131
+ options = options || {};
132
+ var name = options.name == null ? DEFAULTS.name : String(options.name);
133
+ var getProvider = typeof options.getProvider === "function" ? options.getProvider : defaultProvider;
134
+ var trustedConnect = options.trustedConnect === undefined ? DEFAULTS.trustedConnect : !!options.trustedConnect;
135
+ var disconnectIsMismatch = !!options.disconnectIsMismatch;
136
+ var discoveryMs = typeof options.discoveryMs === "number" ? options.discoveryMs : DEFAULTS.discoveryMs;
137
+ var intervalMs = typeof options.discoveryIntervalMs === "number" && options.discoveryIntervalMs > 0
138
+ ? options.discoveryIntervalMs : DEFAULTS.discoveryIntervalMs;
139
+ var rescanOn = [].concat(options.rescanOn || []);
140
+
141
+ var running = false;
142
+ var reportFn = null;
143
+ var subscribers = [];
144
+
145
+ // The binding for the provider currently watched. One per provider OBJECT,
146
+ // kept for the life of the source so a provider re-resolved later is not
147
+ // bound a second time.
148
+ var current = null;
149
+ var bindings = [];
150
+
151
+ var discoveryTimer = null;
152
+ var discoveryTicks = 0;
153
+ var discoveryDone = false;
154
+
155
+ var state = { status: "unknown", address: null, providerName: null };
156
+ var reported; // the last value handed to report(); starts undefined
157
+ var probing = null; // the binding whose silent connect is in flight
158
+
159
+ function snapshot() {
160
+ return { status: state.status, address: state.address, providerName: state.providerName };
161
+ }
162
+
163
+ function reportValue(next) {
164
+ if (next.status === "connected") return next.address;
165
+ if (next.status === "unknown") return undefined;
166
+ return null;
167
+ }
168
+
169
+ function setState(next) {
170
+ if (next.status === state.status && next.address === state.address &&
171
+ next.providerName === state.providerName) return;
172
+ var previous = snapshot();
173
+ state = { status: next.status, address: next.address, providerName: next.providerName };
174
+
175
+ var value = reportValue(state);
176
+ if (value !== reported) {
177
+ reported = value;
178
+ if (reportFn) {
179
+ try { reportFn(value); } catch (e) { logError(e); }
180
+ }
181
+ }
182
+
183
+ var list = subscribers.slice();
184
+ for (var i = 0; i < list.length; i++) {
185
+ try { list[i](snapshot(), previous); } catch (e) { logError(e); }
186
+ }
187
+ }
188
+
189
+ // Folds one observation of `binding` into the state. `address` is null, a
190
+ // string, or undefined (cannot tell).
191
+ function observe(binding, address) {
192
+ if (!running || binding !== current) return;
193
+ if (address === undefined) {
194
+ // An unreadable answer changes nothing once a status has settled; before
195
+ // that, the page honestly cannot tell yet.
196
+ if (!binding.settled) setState({ status: "unknown", address: null, providerName: providerNameOf(binding.provider) });
197
+ return;
198
+ }
199
+ binding.settled = true;
200
+ setState({
201
+ status: address === null ? "disconnected" : "connected",
202
+ address: address,
203
+ providerName: providerNameOf(binding.provider)
204
+ });
205
+ }
206
+
207
+ function findBinding(provider) {
208
+ for (var i = 0; i < bindings.length; i++) {
209
+ if (bindings[i].provider === provider) return bindings[i];
210
+ }
211
+ return null;
212
+ }
213
+
214
+ function attach(binding) {
215
+ var provider = binding.provider;
216
+ binding.detach = null;
217
+ try {
218
+ if (isWalletStandard(provider)) {
219
+ var events = provider.features["standard:events"];
220
+ if (!events || typeof events.on !== "function") return;
221
+ var off = events.on("change", function (props) {
222
+ // Only a change that carries accounts says anything about identity.
223
+ if (!props || !("accounts" in props)) return;
224
+ binding.eventSeq += 1;
225
+ // The wallet's live view, not the event's copy of it.
226
+ var live = liveAddress(provider);
227
+ observe(binding, live === undefined ? addressOf((props.accounts || [])[0]) : live);
228
+ });
229
+ binding.detach = typeof off === "function" ? off : null;
230
+ return;
231
+ }
232
+
233
+ if (typeof provider.on !== "function") return;
234
+ var onAccountChanged = function (key) {
235
+ binding.eventSeq += 1;
236
+ // A null here is a real observation: Phantom sends it on a lock, a
237
+ // disconnect, and a switch to an account this site was never approved for.
238
+ if (key == null) { observe(binding, null); return; }
239
+ var address = addressOf(key);
240
+ observe(binding, address === undefined ? liveAddress(provider) : address);
241
+ };
242
+ var onConnect = function (key) {
243
+ binding.eventSeq += 1;
244
+ var address = key == null ? undefined : addressOf(key);
245
+ observe(binding, address === undefined ? liveAddress(provider) : address);
246
+ };
247
+ var onDisconnect = function () {
248
+ binding.eventSeq += 1;
249
+ observe(binding, null);
250
+ };
251
+ provider.on("accountChanged", onAccountChanged);
252
+ provider.on("connect", onConnect);
253
+ provider.on("disconnect", onDisconnect);
254
+ var remove = typeof provider.off === "function" ? "off"
255
+ : (typeof provider.removeListener === "function" ? "removeListener" : null);
256
+ if (remove) {
257
+ binding.detach = function () {
258
+ provider[remove]("accountChanged", onAccountChanged);
259
+ provider[remove]("connect", onConnect);
260
+ provider[remove]("disconnect", onDisconnect);
261
+ };
262
+ }
263
+ } catch (e) {
264
+ logError(e);
265
+ }
266
+ }
267
+
268
+ function detach(binding) {
269
+ if (!binding.attached) return;
270
+ if (binding.detach) {
271
+ try { binding.detach(); } catch (e) { logError(e); }
272
+ binding.attached = false;
273
+ binding.detach = null;
274
+ }
275
+ // A provider with no way to remove a listener keeps it; the closure check
276
+ // in observe() is what makes that listener inert, and `attached` staying
277
+ // true is what stops a second copy from ever being added.
278
+ }
279
+
280
+ function bind(provider) {
281
+ var binding = findBinding(provider);
282
+ if (!binding) {
283
+ binding = { provider: provider, attached: false, detach: null, settled: false, eventSeq: 0 };
284
+ bindings.push(binding);
285
+ }
286
+ if (current && current !== binding) detach(current);
287
+ current = binding;
288
+ binding.settled = false;
289
+ if (!binding.attached) {
290
+ binding.attached = true;
291
+ attach(binding);
292
+ }
293
+ return binding;
294
+ }
295
+
296
+ // Asks a trusted wallet for its account without prompting. The status stays
297
+ // `unknown` until it settles on a first bind; on a later reconcile the last
298
+ // settled status stands meanwhile, so the page never flashes back to unknown.
299
+ // Returns true while a probe is in flight for this binding.
300
+ function probe(binding) {
301
+ if (probing === binding) return true;
302
+ var provider = binding.provider;
303
+ var attempt;
304
+ try {
305
+ if (isWalletStandard(provider)) {
306
+ var connect = provider.features["standard:connect"];
307
+ if (!connect || typeof connect.connect !== "function") return false;
308
+ attempt = connect.connect({ silent: true });
309
+ } else {
310
+ if (typeof provider.connect !== "function") return false;
311
+ attempt = provider.connect({ onlyIfTrusted: true });
312
+ }
313
+ } catch (e) {
314
+ attempt = Promise.reject(e);
315
+ }
316
+ if (!attempt || typeof attempt.then !== "function") return false;
317
+
318
+ probing = binding;
319
+ var seq = binding.eventSeq;
320
+ if (!binding.settled) setState({ status: "unknown", address: null, providerName: providerNameOf(provider) });
321
+
322
+ attempt.then(function (result) {
323
+ if (probing === binding) probing = null;
324
+ // A wallet event since the probe began is fresher than its answer.
325
+ if (binding.eventSeq !== seq) return;
326
+ var live = liveAddress(provider);
327
+ if (live == null && result) {
328
+ var carried = result.publicKey !== undefined ? addressOf(result.publicKey)
329
+ : addressOf((result.accounts || [])[0]);
330
+ if (carried) live = carried;
331
+ }
332
+ observe(binding, live);
333
+ }, function () {
334
+ if (probing === binding) probing = null;
335
+ if (binding.eventSeq !== seq) return;
336
+ // Not trusted, locked, or dismissed: nothing is connected for this site.
337
+ var live = liveAddress(provider);
338
+ observe(binding, live === undefined ? null : live);
339
+ });
340
+ return true;
341
+ }
342
+
343
+ function resolveProvider() {
344
+ try {
345
+ return getProvider() || null;
346
+ } catch (e) {
347
+ logError(e);
348
+ return null;
349
+ }
350
+ }
351
+
352
+ function stopDiscovery() {
353
+ if (discoveryTimer !== null) {
354
+ W.clearTimeout(discoveryTimer);
355
+ discoveryTimer = null;
356
+ }
357
+ }
358
+
359
+ // Polls for a provider injected after this script ran. The window closes
360
+ // after discoveryMs, and only then does "no provider yet" become `none`.
361
+ function scheduleDiscovery() {
362
+ if (discoveryTimer !== null || discoveryDone) return;
363
+ discoveryTimer = W.setTimeout(function () {
364
+ discoveryTimer = null;
365
+ if (!running || discoveryDone) return;
366
+ discoveryTicks += 1;
367
+ var provider = resolveProvider();
368
+ if (provider) {
369
+ reconcileWith(provider);
370
+ return;
371
+ }
372
+ if (discoveryTicks * intervalMs >= discoveryMs) {
373
+ discoveryDone = true;
374
+ setState({ status: "none", address: null, providerName: null });
375
+ return;
376
+ }
377
+ scheduleDiscovery();
378
+ }, intervalMs);
379
+ }
380
+
381
+ // Re-resolves the provider and re-reads it live. Returns the binding watched
382
+ // afterwards, or null when there is still no provider.
383
+ function reconcile() {
384
+ if (!running) return null;
385
+ return reconcileWith(resolveProvider());
386
+ }
387
+
388
+ function reconcileWith(provider) {
389
+ if (!provider) {
390
+ // A provider already bound does not vanish from a live page; a resolver
391
+ // that briefly answers null keeps the binding it has.
392
+ if (current) {
393
+ readAndProbe(current);
394
+ return current;
395
+ }
396
+ // discoveryMs <= 0 is a host saying it will not wait for a late injection.
397
+ if (discoveryDone || discoveryMs <= 0) {
398
+ discoveryDone = true;
399
+ setState({ status: "none", address: null, providerName: null });
400
+ } else {
401
+ scheduleDiscovery();
402
+ }
403
+ return null;
404
+ }
405
+ stopDiscovery();
406
+ discoveryDone = true;
407
+ var binding = current && current.provider === provider ? current : bind(provider);
408
+ readAndProbe(binding);
409
+ return binding;
410
+ }
411
+
412
+ // A wallet holding no account may still be trusted by this site. With
413
+ // trustedConnect on, that is asked before `disconnected` is believed; the
414
+ // status meanwhile is whatever last settled, or `unknown` on a first bind.
415
+ function readAndProbe(binding) {
416
+ var live = liveAddress(binding.provider);
417
+ if (live === null && trustedConnect && probe(binding)) return;
418
+ observe(binding, live);
419
+ }
420
+
421
+ function onReturn() {
422
+ if (typeof document !== "undefined" && document.visibilityState && document.visibilityState !== "visible") return;
423
+ reconcile();
424
+ }
425
+
426
+ function onPageShow() { reconcile(); }
427
+ function onRescan() { reconcile(); }
428
+
429
+ function start(report) {
430
+ if (running) throw new Error("SolanaStudio.walletIdentity: source " + JSON.stringify(name) + " is already started");
431
+ running = true;
432
+ reportFn = typeof report === "function" ? report : null;
433
+ reported = undefined;
434
+ discoveryTicks = 0;
435
+ discoveryDone = false;
436
+ current = null;
437
+ setState({ status: "unknown", address: null, providerName: null });
438
+
439
+ if (typeof W.addEventListener === "function") {
440
+ W.addEventListener("focus", onReturn);
441
+ W.addEventListener("pageshow", onPageShow);
442
+ for (var i = 0; i < rescanOn.length; i++) W.addEventListener(rescanOn[i], onRescan);
443
+ }
444
+ if (typeof document !== "undefined" && typeof document.addEventListener === "function") {
445
+ document.addEventListener("visibilitychange", onReturn);
446
+ }
447
+
448
+ reconcile();
449
+ return stop;
450
+ }
451
+
452
+ function stop() {
453
+ if (!running) return;
454
+ running = false;
455
+ reportFn = null;
456
+ probing = null;
457
+ stopDiscovery();
458
+ if (typeof W.removeEventListener === "function") {
459
+ W.removeEventListener("focus", onReturn);
460
+ W.removeEventListener("pageshow", onPageShow);
461
+ for (var i = 0; i < rescanOn.length; i++) W.removeEventListener(rescanOn[i], onRescan);
462
+ }
463
+ if (typeof document !== "undefined" && typeof document.removeEventListener === "function") {
464
+ document.removeEventListener("visibilitychange", onReturn);
465
+ }
466
+ for (var j = 0; j < bindings.length; j++) detach(bindings[j]);
467
+ current = null;
468
+ }
469
+
470
+ var source = {
471
+ name: name,
472
+ start: start,
473
+ stop: stop,
474
+ equals: function (boundValue, observedValue) {
475
+ if (observedValue === null) return !disconnectIsMismatch;
476
+ return boundValue === observedValue;
477
+ },
478
+ current: snapshot,
479
+ subscribe: function (fn) {
480
+ if (typeof fn !== "function") throw new TypeError("SolanaStudio.walletIdentity: subscribe needs a function");
481
+ subscribers.push(fn);
482
+ return function () {
483
+ var at = subscribers.indexOf(fn);
484
+ if (at !== -1) subscribers.splice(at, 1);
485
+ };
486
+ },
487
+ reconcile: function () {
488
+ reconcile();
489
+ return snapshot();
490
+ },
491
+ isRunning: function () { return running; }
492
+ };
493
+ if (typeof options.bound === "function") source.bound = options.bound;
494
+ return source;
495
+ }
496
+
497
+ // create() plus registration with the session store. Without a store the
498
+ // source still runs, so a page can render wallet state before, or without,
499
+ // the session primitive.
500
+ function register(options) {
501
+ options = options || {};
502
+ var session = options.session || W.StudioSession;
503
+ var source = create(options);
504
+ if (!session || typeof session.registerIdentitySource !== "function") {
505
+ source.start();
506
+ return { source: source, registration: null };
507
+ }
508
+ return { source: source, registration: session.registerIdentitySource(source) };
509
+ }
510
+
511
+ W.SolanaStudio.walletIdentity = {
512
+ loaded: true,
513
+ STATUSES: STATUSES.slice(),
514
+ DEFAULTS: {
515
+ name: DEFAULTS.name,
516
+ discoveryMs: DEFAULTS.discoveryMs,
517
+ discoveryIntervalMs: DEFAULTS.discoveryIntervalMs,
518
+ trustedConnect: DEFAULTS.trustedConnect,
519
+ disconnectIsMismatch: DEFAULTS.disconnectIsMismatch
520
+ },
521
+ create: create,
522
+ register: register
523
+ };
524
+ })(window);
@@ -1,4 +1,5 @@
1
1
  require "ed25519"
2
+ require_relative "ed25519_strict"
2
3
  require "openssl"
3
4
 
4
5
  module Solana
@@ -70,6 +71,17 @@ module Solana
70
71
  raise VerificationError, "Signature must be #{ED25519_SIGNATURE_BYTES} bytes, got #{sig_bytes.bytesize}"
71
72
  end
72
73
 
74
+ # Ed25519::VerifyKey checks the equation and nothing else, so a
75
+ # small-order or non-canonical key — an address with no secret key behind
76
+ # it — can pass it. Refuse those, and a malformed R or S, before it runs.
77
+ # See Solana::Ed25519Strict.
78
+ if (problem = Solana::Ed25519Strict.public_key_problem(pub_bytes))
79
+ raise VerificationError, "Public key #{problem}"
80
+ end
81
+ if (problem = Solana::Ed25519Strict.signature_problem(sig_bytes))
82
+ raise VerificationError, "Signature #{problem}"
83
+ end
84
+
73
85
  verify_key = Ed25519::VerifyKey.new(pub_bytes)
74
86
  verify_key.verify(sig_bytes, message)
75
87
 
data/lib/solana/client.rb CHANGED
@@ -45,12 +45,65 @@ module Solana
45
45
  result.dig("value", "blockhash")
46
46
  end
47
47
 
48
+ # A blockhash together with the deadline that comes with it.
49
+ #
50
+ # #get_latest_blockhash returns the hash alone, and that discards the one
51
+ # number that says when a transaction built on it dies:
52
+ # `last_valid_block_height`. Once the cluster's block height passes it, no
53
+ # block can include the transaction. Keep it, and a caller can tell a user
54
+ # "this expired" instead of guessing from an RPC error string.
55
+ LatestBlockhash = Struct.new(:blockhash, :last_valid_block_height, :slot, :commitment, keyword_init: true)
56
+
57
+ # Defaults to "confirmed", unlike #get_latest_blockhash (which stays
58
+ # "finalized" so no existing caller changes). A finalized hash is already
59
+ # about 32 slots (~13s) old when fetched, out of a ~150-block life.
60
+ #
61
+ # PAIR IT. A transaction built on a "confirmed" hash must be sent with
62
+ # `preflight_commitment: "confirmed"` (see #send_transaction). The RPC's
63
+ # default preflight commitment is "finalized", and a finalized bank does not
64
+ # yet know a fresh confirmed hash, so it answers "Blockhash not found" for a
65
+ # perfectly valid transaction. Solana::Cosign carries the commitment from
66
+ # build to send for exactly this reason.
67
+ def latest_blockhash(commitment: "confirmed")
68
+ result = call("getLatestBlockhash", [{ commitment: commitment }])
69
+ value = result && result["value"]
70
+ unless value && value["blockhash"] && value["lastValidBlockHeight"]
71
+ raise RpcError.new("getLatestBlockhash returned no blockhash/lastValidBlockHeight")
72
+ end
73
+
74
+ LatestBlockhash.new(
75
+ blockhash: value["blockhash"],
76
+ last_valid_block_height: Integer(value["lastValidBlockHeight"]),
77
+ slot: result.dig("context", "slot"),
78
+ commitment: commitment
79
+ )
80
+ end
81
+
82
+ # The cluster's current block height — the number to compare against a
83
+ # LatestBlockhash#last_valid_block_height. Block height, not slot: skipped
84
+ # slots do not advance it.
85
+ def get_block_height(commitment: "confirmed")
86
+ Integer(call("getBlockHeight", [{ commitment: commitment }]))
87
+ end
88
+
89
+ # Whether the cluster still accepts a transaction anchored on `blockhash`.
90
+ # Cheap enough to ask before prompting a wallet, when a rebuild is still free
91
+ # because nothing has been signed.
92
+ def blockhash_valid?(blockhash, commitment: "confirmed")
93
+ result = call("isBlockhashValid", [blockhash, { commitment: commitment }])
94
+ result.is_a?(Hash) ? result["value"] == true : result == true
95
+ end
96
+
48
97
  def get_minimum_balance_for_rent_exemption(size)
49
98
  call("getMinimumBalanceForRentExemption", [size])
50
99
  end
51
100
 
52
- def send_transaction(signed_tx_base64, skip_preflight: false)
101
+ # `preflight_commitment:` is sent only when given, so every existing caller
102
+ # keeps the RPC default ("finalized"). Pass the commitment the blockhash was
103
+ # fetched at — see #latest_blockhash for why the two must match.
104
+ def send_transaction(signed_tx_base64, skip_preflight: false, preflight_commitment: nil)
53
105
  opts = { encoding: "base64", skipPreflight: skip_preflight }
106
+ opts[:preflightCommitment] = preflight_commitment if preflight_commitment
54
107
  call("sendTransaction", [signed_tx_base64, opts])
55
108
  end
56
109
 
@@ -0,0 +1,73 @@
1
+ module Solana
2
+ # ComputeBudget program instruction encoders — the priority-fee pair every
3
+ # house-paid transaction needs, plus the reader a cosign guard uses to price
4
+ # what a wallet handed back.
5
+ #
6
+ # WHY THESE BELONG IN THE GEM. A fee-less transaction lands on an empty devnet
7
+ # and is dropped by a loaded mainnet leader (turf-monster lost two contest
8
+ # creates that way on 2026-06-02). Every app that pays fees for its users needs
9
+ # the same two instructions, and every app that cosigns a wallet-returned wire
10
+ # needs to READ them, because the fee payer pays price x limit whether the
11
+ # transaction then succeeds or fails.
12
+ #
13
+ # Each encoder returns a { program_id:, accounts:, data: } hash for
14
+ # Transaction#add_instruction, the same shape SplToken and SystemProgram use.
15
+ module ComputeBudget
16
+ module_function
17
+
18
+ PROGRAM_ID = Keypair.decode_base58("ComputeBudget111111111111111111111111111111")
19
+
20
+ # Discriminators (first data byte). Only these two are ever built or admitted
21
+ # by Solana::Cosign; RequestHeapFrame (1) and the deprecated RequestUnits (0)
22
+ # are shapes no house-paid builder asks for.
23
+ SET_COMPUTE_UNIT_LIMIT = 0x02 # followed by u32 LE units
24
+ SET_COMPUTE_UNIT_PRICE = 0x03 # followed by u64 LE micro-lamports per CU
25
+
26
+ # The runtime's ceiling. A wire that sets a price but no limit is charged at
27
+ # whatever limit the runtime applies, so a fee check assumes this — it may
28
+ # over-estimate, never under-.
29
+ MAX_COMPUTE_UNIT_LIMIT = 1_400_000
30
+
31
+ MICRO_LAMPORTS_PER_LAMPORT = 1_000_000
32
+
33
+ def set_compute_unit_limit(units)
34
+ units = Integer(units)
35
+ raise ArgumentError, "compute unit limit must be 1..#{MAX_COMPUTE_UNIT_LIMIT}, got #{units}" unless units.between?(1, MAX_COMPUTE_UNIT_LIMIT)
36
+
37
+ { program_id: PROGRAM_ID, accounts: [], data: [SET_COMPUTE_UNIT_LIMIT].pack("C") + [units].pack("V") }
38
+ end
39
+
40
+ def set_compute_unit_price(micro_lamports)
41
+ micro_lamports = Integer(micro_lamports)
42
+ raise ArgumentError, "compute unit price must be >= 0, got #{micro_lamports}" if micro_lamports.negative?
43
+
44
+ { program_id: PROGRAM_ID, accounts: [], data: [SET_COMPUTE_UNIT_PRICE].pack("C") + [micro_lamports].pack("Q<") }
45
+ end
46
+
47
+ # Decode one ComputeBudget instruction's data. Returns [:limit, units] or
48
+ # [:price, micro_lamports]; raises ArgumentError for any other discriminator
49
+ # or a length that is not the exact encoded size (a padded or truncated
50
+ # field is a shape no builder emits).
51
+ def parse(data)
52
+ data = data.to_s.b
53
+ case data.getbyte(0)
54
+ when SET_COMPUTE_UNIT_LIMIT
55
+ raise ArgumentError, "SetComputeUnitLimit must be 5 bytes, got #{data.bytesize}" unless data.bytesize == 5
56
+
57
+ [:limit, data.byteslice(1, 4).unpack1("V")]
58
+ when SET_COMPUTE_UNIT_PRICE
59
+ raise ArgumentError, "SetComputeUnitPrice must be 9 bytes, got #{data.bytesize}" unless data.bytesize == 9
60
+
61
+ [:price, data.byteslice(1, 8).unpack1("Q<")]
62
+ else
63
+ raise ArgumentError, "unsupported ComputeBudget instruction discriminator #{data.byteslice(0, 1).to_s.unpack1('H*').inspect}"
64
+ end
65
+ end
66
+
67
+ # The priority fee, in micro-lamports, a fee payer is charged for this
68
+ # price and limit. A nil limit is priced at the runtime maximum.
69
+ def priority_fee_micro_lamports(price:, limit: nil)
70
+ Integer(price) * Integer(limit || MAX_COMPUTE_UNIT_LIMIT)
71
+ end
72
+ end
73
+ end