solana-studio 0.6.1 → 0.8.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 +10 -0
- data/README.md +59 -0
- data/app/assets/javascripts/solana_studio/redirect_provider.js +299 -0
- data/app/assets/javascripts/solana_studio/wallet_journal.js +154 -0
- data/app/assets/javascripts/solana_studio/wallet_ops.js +260 -0
- data/app/assets/javascripts/solana_studio/wallet_transport.js +367 -0
- data/app/views/solana_studio/modals/_wallet_connect.html.erb +94 -4
- data/app/views/solana_studio/modals/_web3_step_up.html.erb +44 -2
- data/lib/solana_studio/engine.rb +4 -0
- data/lib/solana_studio/version.rb +1 -1
- metadata +6 -2
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// SolanaStudio.walletOps — one call site, every platform.
|
|
2
|
+
//
|
|
3
|
+
// THE PROBLEM THIS SOLVES, and it is not "mobile support". It is that the SAME
|
|
4
|
+
// piece of product logic — enter a contest, rename a user, export a wallet — has
|
|
5
|
+
// to run over two transports with incompatible shapes: one where `await` works,
|
|
6
|
+
// and one where the page is destroyed mid-operation. Written per-call-site, that
|
|
7
|
+
// becomes two implementations of every flow, and the mobile one silently rots
|
|
8
|
+
// because nobody exercises it on a laptop.
|
|
9
|
+
//
|
|
10
|
+
// So a flow is declared ONCE, as an intent:
|
|
11
|
+
//
|
|
12
|
+
// walletOps.define('contest_entry', {
|
|
13
|
+
// prepare: function (ctx) { ... return { transaction: <base58>, ...state }; },
|
|
14
|
+
// complete: function (ctx, result, state) { ... }
|
|
15
|
+
// });
|
|
16
|
+
//
|
|
17
|
+
// walletOps.run('contest_entry', { contestId: 12 }, { provider: ... });
|
|
18
|
+
//
|
|
19
|
+
// THE ONE RULE A CALLER MUST FOLLOW: handlers are registered BY NAME at page
|
|
20
|
+
// load, not passed as closures. A closure is precisely what cannot survive the
|
|
21
|
+
// redirect — the page that held it no longer exists when the wallet answers. The
|
|
22
|
+
// name is the only thing that can be written down and looked up again. Every
|
|
23
|
+
// other constraint in this file follows from that one.
|
|
24
|
+
//
|
|
25
|
+
// AND THE CORRESPONDING RULE ON STATE: whatever `prepare` returns must be
|
|
26
|
+
// JSON-serialisable, because on the redirect transport it is literally
|
|
27
|
+
// serialised. This is why the entry flow's server-side prepared-transaction slug
|
|
28
|
+
// matters so much — a slug survives the trip; a Transaction object does not.
|
|
29
|
+
//
|
|
30
|
+
// WHAT THIS FILE DOES NOT DO: it does not broadcast. Signing and sending are
|
|
31
|
+
// different responsibilities with different failure modes, and the wallet that
|
|
32
|
+
// broadcasts differs per vendor (Phantom deprecated its send-side deeplink, so
|
|
33
|
+
// the app sends; Solflare and Backpack send for you). `complete` is told which
|
|
34
|
+
// happened and owns the RPC, exactly as the existing flows already do.
|
|
35
|
+
(function (W) {
|
|
36
|
+
'use strict';
|
|
37
|
+
|
|
38
|
+
W.SolanaStudio = W.SolanaStudio || {};
|
|
39
|
+
|
|
40
|
+
var handlers = {};
|
|
41
|
+
|
|
42
|
+
function studio() { return W.SolanaStudio; }
|
|
43
|
+
|
|
44
|
+
function requireHandler(name) {
|
|
45
|
+
var h = handlers[name];
|
|
46
|
+
if (!h) {
|
|
47
|
+
// NAMED, because the most likely cause is a real and specific bug: a
|
|
48
|
+
// callback page that did not load the script defining this intent. The
|
|
49
|
+
// resume then fails here, far from the define() that never ran, and a
|
|
50
|
+
// generic "not found" sends the reader hunting the journal instead.
|
|
51
|
+
throw new Error(
|
|
52
|
+
'No wallet intent registered as "' + name + '" — the page handling this ' +
|
|
53
|
+
'callback must load the same script that defined it'
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return h;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Default navigation, injectable so the whole surface stays testable in node.
|
|
60
|
+
function defaultNavigate(url) {
|
|
61
|
+
W.location.href = url;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --- inline transport ----------------------------------------------------
|
|
65
|
+
//
|
|
66
|
+
// The path that already worked: the provider is injected, promises resolve,
|
|
67
|
+
// nothing is written down. Kept deliberately close to what the existing call
|
|
68
|
+
// sites do, so adopting walletOps is not also a rewrite of the desktop flow.
|
|
69
|
+
function runInline(name, ctx, opts) {
|
|
70
|
+
var handler = requireHandler(name);
|
|
71
|
+
var provider = opts.provider;
|
|
72
|
+
|
|
73
|
+
return Promise.resolve(handler.prepare(ctx)).then(function (prepared) {
|
|
74
|
+
return Promise.resolve(provider.connect()).then(function () {
|
|
75
|
+
return provider.signTransaction(prepared.transaction);
|
|
76
|
+
}).then(function (signed) {
|
|
77
|
+
return handler.complete(ctx, {
|
|
78
|
+
signedTransaction: signed,
|
|
79
|
+
signature: null,
|
|
80
|
+
sendStrategy: 'app-broadcasts'
|
|
81
|
+
}, prepared);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// --- redirect transport --------------------------------------------------
|
|
87
|
+
//
|
|
88
|
+
// Two or three hops, each ending in the page's destruction. `run` gets as far
|
|
89
|
+
// as the first navigation; `resume` picks up whatever the callback carries and
|
|
90
|
+
// either navigates again or finishes.
|
|
91
|
+
function runRedirect(name, ctx, opts) {
|
|
92
|
+
var handler = requireHandler(name);
|
|
93
|
+
var provider = opts.provider;
|
|
94
|
+
var journalStore = studio().walletJournal;
|
|
95
|
+
var navigate = opts.navigate || defaultNavigate;
|
|
96
|
+
|
|
97
|
+
// REFUSE EARLY rather than strand the user in their wallet app. Without a
|
|
98
|
+
// writable store there is nothing to resume from, and the trip would end in
|
|
99
|
+
// a callback page that finds no pending request and can only apologise.
|
|
100
|
+
if (!journalStore.writable()) {
|
|
101
|
+
return Promise.reject(new Error(
|
|
102
|
+
'This browser cannot store a pending wallet request — open this page in ' +
|
|
103
|
+
'your wallet app instead'
|
|
104
|
+
));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return Promise.resolve(handler.prepare(ctx)).then(function (prepared) {
|
|
108
|
+
var intent = { op: name, ctx: ctx, state: prepared };
|
|
109
|
+
|
|
110
|
+
// Already connected? Go straight to signing. Otherwise connect first and
|
|
111
|
+
// carry the intent through — sessions do not expire on any of the three
|
|
112
|
+
// wallets, so this branch is taken once per user, not once per action.
|
|
113
|
+
var existing = opts.session || null;
|
|
114
|
+
var begun = existing
|
|
115
|
+
? beginSigning(provider, intent, existing, opts)
|
|
116
|
+
: provider.beginConnect({
|
|
117
|
+
appUrl: opts.appUrl,
|
|
118
|
+
redirectLink: opts.redirectLink,
|
|
119
|
+
cluster: opts.cluster,
|
|
120
|
+
intent: intent
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
if (!journalStore.save(begun.journal)) {
|
|
124
|
+
return Promise.reject(new Error('Could not record the pending wallet request'));
|
|
125
|
+
}
|
|
126
|
+
navigate(begun.url);
|
|
127
|
+
// Nothing resolves here in a real browser — the page is gone. The value is
|
|
128
|
+
// for tests and for a caller that wants to know a trip started.
|
|
129
|
+
return { suspended: true, url: begun.url };
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function beginSigning(provider, intent, session, opts) {
|
|
134
|
+
var connected = {
|
|
135
|
+
v: provider.JOURNAL_VERSION,
|
|
136
|
+
step: 'connected'
|
|
137
|
+
};
|
|
138
|
+
// A caller supplying its own session hands us the journal fields the connect
|
|
139
|
+
// hop would have produced. Kept explicit rather than reconstructed, because
|
|
140
|
+
// guessing them is how a shared secret ends up derived from the wrong key.
|
|
141
|
+
for (var k in session) {
|
|
142
|
+
if (Object.prototype.hasOwnProperty.call(session, k)) connected[k] = session[k];
|
|
143
|
+
}
|
|
144
|
+
connected.intent = intent;
|
|
145
|
+
return signingHop(provider, connected, opts);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Which signing method this wallet gets is a CAPABILITY QUESTION, not a
|
|
149
|
+
// preference: Phantom's send-side deeplink is deprecated, so it signs and the
|
|
150
|
+
// app broadcasts. Asking the provider keeps that fact in the profile table
|
|
151
|
+
// where it is asserted, rather than branching on a wallet name here.
|
|
152
|
+
function signingHop(provider, journal, opts) {
|
|
153
|
+
var payload = {
|
|
154
|
+
journal: journal,
|
|
155
|
+
transaction: journal.intent.state.transaction,
|
|
156
|
+
redirectLink: opts.redirectLink,
|
|
157
|
+
intent: journal.intent
|
|
158
|
+
};
|
|
159
|
+
return provider.can('signAndSendTransaction')
|
|
160
|
+
? provider.beginSignAndSendTransaction(payload)
|
|
161
|
+
: provider.beginSignTransaction(payload);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Called by the callback page. Reads the pending journal, advances one step,
|
|
165
|
+
// and either navigates again or hands back the finished result.
|
|
166
|
+
//
|
|
167
|
+
// `take()` rather than `peek()` — reading clears, so a reloaded or
|
|
168
|
+
// double-fired callback cannot advance the same step twice.
|
|
169
|
+
function resume(params, opts) {
|
|
170
|
+
opts = opts || {};
|
|
171
|
+
var journalStore = studio().walletJournal;
|
|
172
|
+
var navigate = opts.navigate || defaultNavigate;
|
|
173
|
+
|
|
174
|
+
var journal = journalStore.take();
|
|
175
|
+
if (!journal) return Promise.resolve({ pending: false });
|
|
176
|
+
|
|
177
|
+
var provider = opts.provider ||
|
|
178
|
+
studio().redirectProvider.forWallet(journal.wallet);
|
|
179
|
+
if (!provider) {
|
|
180
|
+
return Promise.reject(new Error('No provider for wallet "' + journal.wallet + '"'));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
if (journal.step === 'connect') {
|
|
185
|
+
var connected = provider.completeConnect(params, journal);
|
|
186
|
+
var intent = connected.journal.intent;
|
|
187
|
+
|
|
188
|
+
// Connect with no intent behind it is a plain sign-in: hand the caller
|
|
189
|
+
// the account and stop. Connect CARRYING an intent immediately takes the
|
|
190
|
+
// next hop, which is what makes a transaction on a cold session two
|
|
191
|
+
// navigations rather than two user-initiated attempts.
|
|
192
|
+
if (!intent) {
|
|
193
|
+
return Promise.resolve({ pending: true, done: true, connect: connected });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
var next = signingHop(provider, connected.journal, {
|
|
197
|
+
redirectLink: opts.redirectLink || journal.redirectLink
|
|
198
|
+
});
|
|
199
|
+
if (!journalStore.save(next.journal)) {
|
|
200
|
+
return Promise.reject(new Error('Could not record the pending wallet request'));
|
|
201
|
+
}
|
|
202
|
+
navigate(next.url);
|
|
203
|
+
return Promise.resolve({ pending: true, suspended: true, url: next.url });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (journal.step === 'signTransaction' || journal.step === 'signAndSendTransaction') {
|
|
207
|
+
// HANDLER FIRST, BEFORE ANY DECRYPTION. If the intent is not registered
|
|
208
|
+
// on this page, no amount of successful decryption helps — and a crypto
|
|
209
|
+
// error surfacing here would send the reader after the shared secret
|
|
210
|
+
// when the real fault is a script the callback page did not load.
|
|
211
|
+
var handler = requireHandler(journal.intent && journal.intent.op);
|
|
212
|
+
|
|
213
|
+
var wallet = journal.step === 'signAndSendTransaction';
|
|
214
|
+
var out = wallet
|
|
215
|
+
? provider.completeSignAndSendTransaction(params, journal)
|
|
216
|
+
: provider.completeSignTransaction(params, journal);
|
|
217
|
+
return Promise.resolve(handler.complete(journal.intent.ctx, {
|
|
218
|
+
signature: out.signature || null,
|
|
219
|
+
signedTransaction: out.transaction || null,
|
|
220
|
+
sendStrategy: wallet ? 'wallet-broadcasts' : 'app-broadcasts'
|
|
221
|
+
}, journal.intent.state)).then(function (value) {
|
|
222
|
+
return { pending: true, done: true, value: value };
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return Promise.reject(new Error('Unknown wallet journal step: ' + journal.step));
|
|
227
|
+
} catch (e) {
|
|
228
|
+
return Promise.reject(e);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
W.SolanaStudio.walletOps = {
|
|
233
|
+
define: function (name, handler) {
|
|
234
|
+
if (!handler || typeof handler.prepare !== 'function' || typeof handler.complete !== 'function') {
|
|
235
|
+
throw new Error('walletOps.define("' + name + '") needs both prepare and complete');
|
|
236
|
+
}
|
|
237
|
+
handlers[name] = handler;
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
defined: function (name) { return !!handlers[name]; },
|
|
241
|
+
names: function () { return Object.keys(handlers); },
|
|
242
|
+
|
|
243
|
+
// Test seam only. Production pages define once at load and never clear.
|
|
244
|
+
reset: function () { handlers = {}; },
|
|
245
|
+
|
|
246
|
+
// The single call site. Chooses the transport from the provider it is given,
|
|
247
|
+
// so a view never asks what platform it is on.
|
|
248
|
+
run: function (name, ctx, opts) {
|
|
249
|
+
opts = opts || {};
|
|
250
|
+
if (!opts.provider) {
|
|
251
|
+
return Promise.reject(new Error('walletOps.run needs a provider'));
|
|
252
|
+
}
|
|
253
|
+
return opts.provider.transport === 'redirect'
|
|
254
|
+
? runRedirect(name, ctx, opts)
|
|
255
|
+
: runInline(name, ctx, opts);
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
resume: resume
|
|
259
|
+
};
|
|
260
|
+
})(typeof window !== 'undefined' ? window : globalThis);
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// SolanaStudio.walletTransport — the REDIRECT transport's shared core.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS FILE EXISTS. `walletProvider` in the consuming apps models exactly one
|
|
4
|
+
// way of reaching a wallet: an object injected into the page. That is true of a
|
|
5
|
+
// desktop extension and of a wallet's own in-app browser, and it is false of
|
|
6
|
+
// every ordinary mobile browser. On iOS Safari and Android Chrome there is no
|
|
7
|
+
// injected provider, `detect()` returns null, and every call site that reaches
|
|
8
|
+
// for `provider.connect()` throws a null-dereference into a user-facing modal.
|
|
9
|
+
//
|
|
10
|
+
// The second transport is a REDIRECT: the page hands off to the wallet app by
|
|
11
|
+
// URL and the answer comes back on a callback URL, with the original page
|
|
12
|
+
// destroyed in between. A promise cannot survive that, which is the single fact
|
|
13
|
+
// this whole design is shaped around.
|
|
14
|
+
//
|
|
15
|
+
// WHAT THIS FILE IS AND IS NOT. It is the wallet-agnostic HALF: the codec, the
|
|
16
|
+
// per-wallet profile table, and the URL builders. It performs no navigation,
|
|
17
|
+
// touches no localStorage, and knows nothing about intents or resume — those
|
|
18
|
+
// belong to the journal (studio-engine) and the intent registry, which build ON
|
|
19
|
+
// this. Keeping them apart is what lets this half be exercised in node without a
|
|
20
|
+
// browser, which is how its per-wallet differences are actually pinned.
|
|
21
|
+
//
|
|
22
|
+
// THE PROTOCOLS ARE ~95% IDENTICAL, WHICH IS THE WHOLE OPPORTUNITY. Solflare and
|
|
23
|
+
// Backpack both forked Phantom's deeplink spec: same x25519 + nacl.box, same
|
|
24
|
+
// 24-byte nonce, base58 everywhere, byte-identical error tables, identical
|
|
25
|
+
// payload JSON keys, identical response keys. Solflare's own docs even link
|
|
26
|
+
// Phantom's blocklist repo. So the codec below is genuinely shared and only a
|
|
27
|
+
// small profile varies. Verified against all three vendors' live docs
|
|
28
|
+
// 2026-09-07; every divergence is recorded in PROFILES with its reason.
|
|
29
|
+
//
|
|
30
|
+
// ONE DEPENDENCY, AND IT IS GUARDED: window.nacl (tweetnacl). Base58 is INLINE
|
|
31
|
+
// and self-contained on purpose — a previous extraction left B58_ALPHABET behind
|
|
32
|
+
// at module scope where a classic script could not reach it, and every mobile
|
|
33
|
+
// sign-in threw "B58_ALPHABET is not defined" on the first keypair encode. That
|
|
34
|
+
// was invisible to eleven passing view tests and to a browser spec that checked
|
|
35
|
+
// `typeof` without ever CALLING the function. Nothing here reads a free
|
|
36
|
+
// variable it does not declare.
|
|
37
|
+
(function (W) {
|
|
38
|
+
'use strict';
|
|
39
|
+
|
|
40
|
+
W.SolanaStudio = W.SolanaStudio || {};
|
|
41
|
+
|
|
42
|
+
var B58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
|
43
|
+
|
|
44
|
+
// CORRECTED DURING EXTRACTION, and deliberately NOT a faithful copy. Both
|
|
45
|
+
// shipped encoders this was lifted from (turf-monster's deep link and
|
|
46
|
+
// studio-engine's callback) seed `digits` with [0] and then convert EVERY
|
|
47
|
+
// byte, including the leading zeros they also emit as '1' separately. For any
|
|
48
|
+
// input that is entirely zero bytes that yields one character too many —
|
|
49
|
+
// encode([0]) returns '11', which decodes back to [0, 0] — and encode([])
|
|
50
|
+
// returns '1' rather than the empty string.
|
|
51
|
+
//
|
|
52
|
+
// NOT A LIVE BUG IN EITHER CONSUMER, and worth saying so plainly rather than
|
|
53
|
+
// overstating the find: the only things they encode are 32-byte x25519 keys
|
|
54
|
+
// and 24-byte nonces, and an all-zero one of either is not reachable. It is
|
|
55
|
+
// still wrong, and a shared core that other code will build on should not
|
|
56
|
+
// carry a round-trip that fails on its simplest input.
|
|
57
|
+
//
|
|
58
|
+
// The fix is to count the leading zero bytes, convert only what remains, and
|
|
59
|
+
// let a zero value contribute no digits at all.
|
|
60
|
+
function encodeBase58(bytes) {
|
|
61
|
+
var zeros = 0;
|
|
62
|
+
while (zeros < bytes.length && bytes[zeros] === 0) zeros++;
|
|
63
|
+
|
|
64
|
+
var digits = [];
|
|
65
|
+
for (var i = zeros; i < bytes.length; i++) {
|
|
66
|
+
var carry = bytes[i];
|
|
67
|
+
for (var j = 0; j < digits.length; j++) {
|
|
68
|
+
carry += digits[j] << 8;
|
|
69
|
+
digits[j] = carry % 58;
|
|
70
|
+
carry = (carry / 58) | 0;
|
|
71
|
+
}
|
|
72
|
+
while (carry) { digits.push(carry % 58); carry = (carry / 58) | 0; }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
var str = '';
|
|
76
|
+
for (var k = 0; k < zeros; k++) str += '1';
|
|
77
|
+
// The most significant digit of a non-zero number is never 0, so the '1'
|
|
78
|
+
// characters above stay unambiguously the zero-byte prefix on decode.
|
|
79
|
+
for (var m = digits.length - 1; m >= 0; m--) str += B58_ALPHABET[digits[m]];
|
|
80
|
+
return str;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function decodeBase58(str) {
|
|
84
|
+
var bytes = [];
|
|
85
|
+
for (var i = 0; i < str.length; i++) {
|
|
86
|
+
var idx = B58_ALPHABET.indexOf(str[i]);
|
|
87
|
+
if (idx < 0) throw new Error('Invalid base58 character');
|
|
88
|
+
var carry = idx;
|
|
89
|
+
for (var j = 0; j < bytes.length; j++) {
|
|
90
|
+
carry += bytes[j] * 58;
|
|
91
|
+
bytes[j] = carry & 0xff;
|
|
92
|
+
carry >>= 8;
|
|
93
|
+
}
|
|
94
|
+
while (carry) { bytes.push(carry & 0xff); carry >>= 8; }
|
|
95
|
+
}
|
|
96
|
+
for (var k = 0; k < str.length && str[k] === '1'; k++) bytes.push(0);
|
|
97
|
+
return new Uint8Array(bytes.reverse());
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// --- Per-wallet profiles -------------------------------------------------
|
|
101
|
+
//
|
|
102
|
+
// Everything that is NOT shared. Each field below is a place one vendor
|
|
103
|
+
// diverged, and each carries why — because the next reader's instinct will be
|
|
104
|
+
// "these are all the same, collapse them", and four of them are traps.
|
|
105
|
+
var PROFILES = {
|
|
106
|
+
phantom: {
|
|
107
|
+
name: 'Phantom',
|
|
108
|
+
host: 'https://phantom.app',
|
|
109
|
+
// Phantom's scheme has NO /ul/ segment. Solflare's does. This is the one
|
|
110
|
+
// place a "just swap the host" adapter breaks.
|
|
111
|
+
scheme: 'phantom://v1/',
|
|
112
|
+
connectKeys: ['phantom_encryption_public_key'],
|
|
113
|
+
// browse carries NO version segment on Phantom — documented that way, and
|
|
114
|
+
// different from its own provider methods.
|
|
115
|
+
browsePath: '/ul/browse/',
|
|
116
|
+
clusters: ['mainnet-beta', 'testnet', 'devnet'],
|
|
117
|
+
// DEPRECATED BY PHANTOM: "The signAndSendTransaction deeplink is
|
|
118
|
+
// deprecated. Use signAllTransactions or signTransaction instead." So on
|
|
119
|
+
// Phantom the APP still broadcasts — sendRawTransaction + a confirmation
|
|
120
|
+
// poll stay. This is the opposite of the other two, and it applies to the
|
|
121
|
+
// wallet most users hold, so it is not an edge case.
|
|
122
|
+
send: 'app-broadcasts',
|
|
123
|
+
methods: {
|
|
124
|
+
connect: true, disconnect: true, signMessage: true,
|
|
125
|
+
signTransaction: true, signAllTransactions: true,
|
|
126
|
+
signAndSendTransaction: false, browse: true, signIn: false
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
solflare: {
|
|
131
|
+
name: 'Solflare',
|
|
132
|
+
host: 'https://solflare.com',
|
|
133
|
+
// KEEPS /ul/ — unlike Phantom. Evidence is Solflare's own sample app; the
|
|
134
|
+
// scheme form is not documented in prose.
|
|
135
|
+
scheme: 'solflare://ul/v1/',
|
|
136
|
+
connectKeys: ['solflare_encryption_public_key'],
|
|
137
|
+
browsePath: '/ul/v1/browse/',
|
|
138
|
+
clusters: ['mainnet-beta', 'testnet', 'devnet'],
|
|
139
|
+
send: 'wallet-broadcasts',
|
|
140
|
+
methods: {
|
|
141
|
+
connect: true, disconnect: true, signMessage: true,
|
|
142
|
+
signTransaction: true, signAllTransactions: true,
|
|
143
|
+
signAndSendTransaction: true, browse: true, signIn: false
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
backpack: {
|
|
148
|
+
name: 'Backpack',
|
|
149
|
+
host: 'https://backpack.app',
|
|
150
|
+
// NO custom scheme is documented anywhere in Backpack's corpus — universal
|
|
151
|
+
// links only. Unlike Phantom there is no scheme fallback, so a caller that
|
|
152
|
+
// needs one must handle null rather than assume a template.
|
|
153
|
+
scheme: null,
|
|
154
|
+
// Backpack's docs CONTRADICT THEMSELVES on this key: its encryption page
|
|
155
|
+
// says wallet_encryption_public_key, its connect page says `wallet_xxx`,
|
|
156
|
+
// which reads as an unresolved placeholder. Both are listed so the
|
|
157
|
+
// resolver tries the documented name first and still works if the
|
|
158
|
+
// placeholder turns out to be literal. Confirm on a device before trusting
|
|
159
|
+
// either — this is the highest-risk unknown in the profile table.
|
|
160
|
+
connectKeys: ['wallet_encryption_public_key', 'wallet_xxx'],
|
|
161
|
+
browsePath: '/ul/v1/browse/',
|
|
162
|
+
// DEVNET IS NOT DOCUMENTED for Backpack — its cluster parameter documents
|
|
163
|
+
// only mainnet-beta (plus an Eclipse chain id). Consumers that test on
|
|
164
|
+
// devnet cannot currently QA this wallet, which is a lane decision, not a
|
|
165
|
+
// bug to paper over here. supportsCluster() reports it honestly.
|
|
166
|
+
clusters: ['mainnet-beta'],
|
|
167
|
+
send: 'wallet-broadcasts',
|
|
168
|
+
methods: {
|
|
169
|
+
connect: true, disconnect: true, signMessage: true,
|
|
170
|
+
signTransaction: true, signAllTransactions: true,
|
|
171
|
+
signAndSendTransaction: true, browse: true, signIn: false
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// NO WALLET SHIPS A DOCUMENTED signIn DEEPLINK — every profile above says
|
|
177
|
+
// false, and that is a measurement, not an oversight. Phantom's 404s in its
|
|
178
|
+
// docs and exists only in its official demo app, where the payload is base58
|
|
179
|
+
// PLAINTEXT rather than ciphertext and the response key is `address` or
|
|
180
|
+
// `public_key` depending on version. Consumers currently depending on that
|
|
181
|
+
// endpoint are depending on something unspecified. Mobile sign-in is
|
|
182
|
+
// connect-then-signMessage — two hops — on all three wallets.
|
|
183
|
+
|
|
184
|
+
function key(wallet) {
|
|
185
|
+
return String(wallet == null ? '' : wallet).toLowerCase();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function profile(wallet) {
|
|
189
|
+
return PROFILES[key(wallet)] || null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Can THIS wallet do THIS method over the redirect transport?
|
|
193
|
+
//
|
|
194
|
+
// The capability question is the one that prevents this whole bug class: a
|
|
195
|
+
// button that renders without asking is how a null provider reached
|
|
196
|
+
// `.connect()` in the first place. An unknown wallet answers false rather
|
|
197
|
+
// than throwing — a caller asking about a wallet we have never heard of
|
|
198
|
+
// wants "no", not an exception.
|
|
199
|
+
function can(wallet, method) {
|
|
200
|
+
var p = profile(wallet);
|
|
201
|
+
return !!(p && p.methods[method] === true);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function supportsCluster(wallet, cluster) {
|
|
205
|
+
var p = profile(wallet);
|
|
206
|
+
return !!(p && p.clusters.indexOf(String(cluster)) !== -1);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Which side broadcasts a signed transaction for this wallet.
|
|
210
|
+
// 'app-broadcasts' → sign only, then the app sends and confirms (Phantom)
|
|
211
|
+
// 'wallet-broadcasts' → signAndSendTransaction returns a signature
|
|
212
|
+
function sendStrategy(wallet) {
|
|
213
|
+
var p = profile(wallet);
|
|
214
|
+
return p ? p.send : null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Pull the wallet's encryption public key out of the connect redirect's query
|
|
218
|
+
// params. This is the ONLY response key that differs between wallets, which is
|
|
219
|
+
// exactly why it is resolved here instead of at call sites.
|
|
220
|
+
//
|
|
221
|
+
// `params` is anything with a .get (URLSearchParams, or a plain-object shim).
|
|
222
|
+
function connectPublicKey(wallet, params) {
|
|
223
|
+
var p = profile(wallet);
|
|
224
|
+
if (!p || !params) return null;
|
|
225
|
+
var get = typeof params.get === 'function'
|
|
226
|
+
? function (k) { return params.get(k); }
|
|
227
|
+
: function (k) { return params[k]; };
|
|
228
|
+
for (var i = 0; i < p.connectKeys.length; i++) {
|
|
229
|
+
var v = get(p.connectKeys[i]);
|
|
230
|
+
if (v) return v;
|
|
231
|
+
}
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// --- Codec ---------------------------------------------------------------
|
|
236
|
+
//
|
|
237
|
+
// Shared by all three wallets without variation. Throws a NAMED error when
|
|
238
|
+
// nacl is absent rather than a bare TypeError on `nacl.box` — the whole point
|
|
239
|
+
// of a guarded dependency is that its absence reads as itself.
|
|
240
|
+
function nacl() {
|
|
241
|
+
if (!W.nacl) throw new Error('SolanaStudio.walletTransport requires tweetnacl (window.nacl)');
|
|
242
|
+
return W.nacl;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
var codec = {
|
|
246
|
+
keypair: function () { return nacl().box.keyPair(); },
|
|
247
|
+
|
|
248
|
+
sharedSecret: function (walletPublicKeyB58, dappSecretKey) {
|
|
249
|
+
return nacl().box.before(decodeBase58(walletPublicKeyB58), dappSecretKey);
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
// Returns the two halves a request needs, both base58, ready to be query
|
|
253
|
+
// params. The nonce is fresh per request — reusing one across requests under
|
|
254
|
+
// the same shared secret is a real break, not a style preference.
|
|
255
|
+
encrypt: function (payloadObject, sharedSecret) {
|
|
256
|
+
var n = nacl();
|
|
257
|
+
var nonce = n.randomBytes(24);
|
|
258
|
+
var bytes = new TextEncoder().encode(JSON.stringify(payloadObject));
|
|
259
|
+
return {
|
|
260
|
+
nonce: encodeBase58(nonce),
|
|
261
|
+
payload: encodeBase58(n.box.after(bytes, nonce, sharedSecret))
|
|
262
|
+
};
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
decrypt: function (dataB58, nonceB58, sharedSecret) {
|
|
266
|
+
var opened = nacl().box.open.after(
|
|
267
|
+
decodeBase58(dataB58), decodeBase58(nonceB58), sharedSecret
|
|
268
|
+
);
|
|
269
|
+
if (!opened) throw new Error('Decryption failed — wrong shared secret or corrupt payload');
|
|
270
|
+
return JSON.parse(new TextDecoder().decode(opened));
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// --- URL builders --------------------------------------------------------
|
|
275
|
+
function base(wallet, useScheme) {
|
|
276
|
+
var p = profile(wallet);
|
|
277
|
+
if (!p) throw new Error('Unknown wallet: ' + wallet);
|
|
278
|
+
// A caller may ASK for the scheme and not get it — Backpack documents none.
|
|
279
|
+
// Falling back to the universal link is correct and silent here; refusing
|
|
280
|
+
// would strand a caller that has a perfectly good link available.
|
|
281
|
+
if (useScheme && p.scheme) return { profile: p, prefix: p.scheme };
|
|
282
|
+
return { profile: p, prefix: p.host + '/ul/v1/' };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function query(pairs) {
|
|
286
|
+
var parts = [];
|
|
287
|
+
for (var k in pairs) {
|
|
288
|
+
if (!Object.prototype.hasOwnProperty.call(pairs, k)) continue;
|
|
289
|
+
if (pairs[k] === null || pairs[k] === undefined || pairs[k] === '') continue;
|
|
290
|
+
parts.push(encodeURIComponent(k) + '=' + encodeURIComponent(pairs[k]));
|
|
291
|
+
}
|
|
292
|
+
return parts.join('&');
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
var url = {
|
|
296
|
+
// connect carries NO nonce and NO payload — the shared secret does not exist
|
|
297
|
+
// yet. Every other method requires both.
|
|
298
|
+
connect: function (wallet, opts) {
|
|
299
|
+
var b = base(wallet, opts && opts.useScheme);
|
|
300
|
+
return b.prefix + 'connect?' + query({
|
|
301
|
+
app_url: opts.appUrl,
|
|
302
|
+
dapp_encryption_public_key: opts.dappPublicKey,
|
|
303
|
+
redirect_link: opts.redirectLink,
|
|
304
|
+
cluster: opts.cluster
|
|
305
|
+
});
|
|
306
|
+
},
|
|
307
|
+
|
|
308
|
+
method: function (wallet, method, opts) {
|
|
309
|
+
if (!can(wallet, method)) {
|
|
310
|
+
throw new Error(wallet + ' does not support ' + method + ' over the redirect transport');
|
|
311
|
+
}
|
|
312
|
+
var b = base(wallet, opts && opts.useScheme);
|
|
313
|
+
return b.prefix + method + '?' + query({
|
|
314
|
+
dapp_encryption_public_key: opts.dappPublicKey,
|
|
315
|
+
nonce: opts.nonce,
|
|
316
|
+
redirect_link: opts.redirectLink,
|
|
317
|
+
payload: opts.payload
|
|
318
|
+
});
|
|
319
|
+
},
|
|
320
|
+
|
|
321
|
+
// The tier-3 handoff: open a page inside the wallet's own in-app browser,
|
|
322
|
+
// where the INJECTED provider works and the existing inline transport needs
|
|
323
|
+
// no changes at all. The target is a PATH segment, not a query param, and
|
|
324
|
+
// the version segment differs per wallet — both encoded in browsePath.
|
|
325
|
+
browse: function (wallet, targetUrl, refUrl) {
|
|
326
|
+
var p = profile(wallet);
|
|
327
|
+
if (!p) throw new Error('Unknown wallet: ' + wallet);
|
|
328
|
+
if (!can(wallet, 'browse')) throw new Error(p.name + ' has no browse deeplink');
|
|
329
|
+
return p.host + p.browsePath + encodeURIComponent(targetUrl) +
|
|
330
|
+
'?' + query({ ref: refUrl });
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
// Error redirects are IDENTICAL across all three wallets, codes included, so
|
|
335
|
+
// this needs no per-wallet branch. Read it BEFORE attempting any decryption:
|
|
336
|
+
// an error redirect carries no `data` and no `nonce`, so a decrypt-first
|
|
337
|
+
// reader turns a clean user rejection into a decryption exception.
|
|
338
|
+
var USER_REJECTED = '4001';
|
|
339
|
+
|
|
340
|
+
function errorFrom(params) {
|
|
341
|
+
if (!params) return null;
|
|
342
|
+
var get = typeof params.get === 'function'
|
|
343
|
+
? function (k) { return params.get(k); }
|
|
344
|
+
: function (k) { return params[k]; };
|
|
345
|
+
var code = get('errorCode');
|
|
346
|
+
if (!code) return null;
|
|
347
|
+
return {
|
|
348
|
+
code: String(code),
|
|
349
|
+
message: get('errorMessage') || 'Wallet request failed',
|
|
350
|
+
rejected: String(code) === USER_REJECTED
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
W.SolanaStudio.walletTransport = {
|
|
355
|
+
PROFILES: PROFILES,
|
|
356
|
+
profile: profile,
|
|
357
|
+
can: can,
|
|
358
|
+
supportsCluster: supportsCluster,
|
|
359
|
+
sendStrategy: sendStrategy,
|
|
360
|
+
connectPublicKey: connectPublicKey,
|
|
361
|
+
errorFrom: errorFrom,
|
|
362
|
+
USER_REJECTED: USER_REJECTED,
|
|
363
|
+
base58: { encode: encodeBase58, decode: decodeBase58 },
|
|
364
|
+
codec: codec,
|
|
365
|
+
url: url
|
|
366
|
+
};
|
|
367
|
+
})(typeof window !== 'undefined' ? window : globalThis);
|