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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -3
- 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,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);
|
data/lib/solana/auth_verifier.rb
CHANGED
|
@@ -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
|
-
|
|
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
|