solana-studio 0.7.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 752d18d346d95e1960408a04f03b7879bb25f34dfa3851a233e9070db6a1ec4d
4
- data.tar.gz: 4d7bcb63da94cb07279077dc525f35a0e11df1e104cad6538d67e15261311ae5
3
+ metadata.gz: e7dab4511993a7a64ace8ad5319dfe98f8436fba4ed8ce02a086b754df0f6a6c
4
+ data.tar.gz: 8c95f186599f9d825b58756f8932577801485ebc6e621b9725eb34563c2ffa51
5
5
  SHA512:
6
- metadata.gz: 2d265a31b6049386a96f286cabd2abd0dbd94520c068cccc5827c58c5b407ee75d95875c20aec7d9bb8893891f6ec26bd02a28acc5d565a6c6efb5378906ee08
7
- data.tar.gz: 62a5d328df9adf8f2959bc3137b4d07b7345ca5e83841dcc69f572d84f46b5fe78c62863482a50550a50d916178aefb67a5d14157c85c1eb16d499de4a9fd0d2
6
+ metadata.gz: 539111d8afe1c593ca295bd37140c0cf9e437a08fff46db02bf87b39706d5187621d7769bd124808c07206823ffd5154f079049b96bc17bd4e748b718b32c0b5
7
+ data.tar.gz: 22d646247fad4661ad66b020b00034cfebe0c045106e04cf4a5fb9f36a4f558e2415d6d5b5e2d97c7677f85baeed988c1cf00e443adca8064c7905204b40c594
data/README.md CHANGED
@@ -351,9 +351,11 @@ shipped on exactly these terms.
351
351
 
352
352
  #### The host JavaScript these modals reach for
353
353
 
354
- Every global below belongs to the **host**. This gem ships one JavaScript file
355
- (`solana_studio/network_guard.js`) and **no routes at all**, so none of these can
356
- live here. Each is reached behind a `typeof` guard: an absent one degrades the
354
+ Every global below belongs to the **host**. This gem ships **no routes at all**,
355
+ and the JavaScript it does ship is a different category from the globals below:
356
+ `solana_studio/network_guard.js` plus the redirect-transport primitives
357
+ (`wallet_transport`, `redirect_provider`, `wallet_journal`, `wallet_ops`), none of
358
+ which provide any global in this table. So none of these can live here. Each is reached behind a `typeof` guard: an absent one degrades the
357
359
  card rather than breaking it, and the whole point of writing the list down is
358
360
  that a consumer meets it here instead of rediscovering it.
359
361
 
@@ -0,0 +1,299 @@
1
+ // SolanaStudio.redirectProvider — the redirect transport's provider surface.
2
+ //
3
+ // ONE FACTORY, NOT THREE ADAPTERS, and that is a deliberate call worth defending
4
+ // rather than discovering later. Phantom, Solflare and Backpack all fork the
5
+ // same deeplink spec: identical request parameters, identical payload JSON keys,
6
+ // identical response keys, identical error codes, identical crypto. Every real
7
+ // divergence already lives as data in walletTransport.PROFILES. Three files
8
+ // would therefore be three copies of one algorithm differing by a lookup — and
9
+ // the copies drift, which is the failure this codebase has been bitten by
10
+ // before. So the behaviour is written once and PARAMETERISED by profile, and
11
+ // the per-wallet differences are pinned by per-wallet tests instead.
12
+ //
13
+ // WHAT A REDIRECT PROVIDER CANNOT BE. A promise. `await provider.connect()`
14
+ // works because the page survives the call; here the page is DESTROYED and the
15
+ // answer arrives on a callback URL in a fresh document. So every operation
16
+ // splits in two:
17
+ //
18
+ // begin<Op>(opts) → { url, journal } — caller navigates + persists
19
+ // complete<Op>(params, journal) → the result — caller ran the callback
20
+ //
21
+ // This object performs NO navigation and touches NO storage. That is not
22
+ // squeamishness: it is what keeps the whole surface runnable in node, which is
23
+ // how the per-wallet differences are actually asserted rather than hoped for.
24
+ // The caller owns `window.location` and the journal's storage; the intent
25
+ // registry and the resume journal own when and where.
26
+ //
27
+ // THE JOURNAL IS VERSIONED FROM ITS FIRST COMMIT. An old callback meeting a new
28
+ // journal must fail loudly rather than decrypt garbage — this spans three repos
29
+ // with a gem floor between them, and the Gemfile records several rounds of
30
+ // silent failure from exactly that drift. JOURNAL_VERSION is the cheapest
31
+ // insurance in the design.
32
+ (function (W) {
33
+ 'use strict';
34
+
35
+ W.SolanaStudio = W.SolanaStudio || {};
36
+
37
+ var JOURNAL_VERSION = 1;
38
+
39
+ function core() {
40
+ var t = W.SolanaStudio && W.SolanaStudio.walletTransport;
41
+ if (!t) throw new Error('SolanaStudio.redirectProvider requires solana_studio/wallet_transport.js');
42
+ return t;
43
+ }
44
+
45
+ // The journal carries the DAPP's ephemeral secret key, base58. That is the
46
+ // established shape (turf-monster's deep link already stores phantom_dl_secret
47
+ // the same way) and it is worth stating why it is safe: this keypair is
48
+ // generated per connect, exists only to decrypt the wallet's replies to THIS
49
+ // app, and is not the user's wallet key. It cannot sign, spend, or authorise
50
+ // anything. Losing it costs one reconnect.
51
+ //
52
+ // What must NEVER go in here is anything the wallet signs over or the user
53
+ // owns — a transaction's bytes, a private key, a session the user did not
54
+ // establish. The server-side prepared-transaction slug exists precisely so a
55
+ // transaction never has to travel this way.
56
+ function newJournal(walletKey, step, extra) {
57
+ var j = {
58
+ v: JOURNAL_VERSION,
59
+ wallet: walletKey,
60
+ step: step,
61
+ startedAt: Date.now()
62
+ };
63
+ for (var k in extra) {
64
+ if (Object.prototype.hasOwnProperty.call(extra, k)) j[k] = extra[k];
65
+ }
66
+ return j;
67
+ }
68
+
69
+ function requireJournal(journal, expectedStep) {
70
+ if (!journal) throw new Error('No pending wallet request');
71
+ if (journal.v !== JOURNAL_VERSION) {
72
+ // NAMED, and refusing. A version we do not understand is not something to
73
+ // best-effort our way through — the shared secret would decrypt to
74
+ // nonsense and the failure would surface somewhere unrelated.
75
+ throw new Error(
76
+ 'Wallet journal version ' + journal.v + ' is not supported (expected ' +
77
+ JOURNAL_VERSION + ') — the wallet request was started by a different release'
78
+ );
79
+ }
80
+ if (expectedStep && journal.step !== expectedStep) {
81
+ throw new Error('Wallet journal is at step ' + journal.step + ', expected ' + expectedStep);
82
+ }
83
+ return journal;
84
+ }
85
+
86
+ // Every completion starts here. An error redirect carries NO data and NO
87
+ // nonce, so a decrypt-first reader turns a clean user rejection into a
88
+ // decryption exception — which is exactly the class of miscategorised failure
89
+ // that puts balance advice in front of someone who attempted no transaction.
90
+ function throwIfWalletError(params) {
91
+ var err = core().errorFrom(params);
92
+ if (!err) return;
93
+ var e = new Error(err.message);
94
+ e.code = err.code;
95
+ e.rejected = err.rejected;
96
+ throw e;
97
+ }
98
+
99
+ function readParam(params, name) {
100
+ if (!params) return null;
101
+ return typeof params.get === 'function' ? params.get(name) : params[name];
102
+ }
103
+
104
+ // Re-derive the shared secret from what the journal kept. This is what makes
105
+ // resume possible at all: the secret itself is binary and never stored, only
106
+ // the two base58 keys needed to recompute it.
107
+ function sharedSecretFrom(journal) {
108
+ var t = core();
109
+ if (!journal.walletPublicKey) throw new Error('Wallet journal carries no wallet public key — connect first');
110
+ return t.codec.sharedSecret(journal.walletPublicKey, t.base58.decode(journal.dappSecretKey));
111
+ }
112
+
113
+ function build(walletKey) {
114
+ var t = core();
115
+ var p = t.profile(walletKey);
116
+ if (!p) throw new Error('Unknown wallet: ' + walletKey);
117
+
118
+ // A signing request, built once for every method that takes one. The only
119
+ // thing that varies between signMessage, signTransaction and
120
+ // signAndSendTransaction is the payload's shape and the method name — the
121
+ // envelope, the encryption and the journal are identical.
122
+ function beginSigned(method, step, payloadFields, opts) {
123
+ if (!t.can(walletKey, method)) {
124
+ throw new Error(p.name + ' does not support ' + method + ' over the redirect transport');
125
+ }
126
+ var journal = requireJournal(opts.journal);
127
+ var secret = sharedSecretFrom(journal);
128
+ var payload = { session: journal.session };
129
+ for (var k in payloadFields) {
130
+ if (Object.prototype.hasOwnProperty.call(payloadFields, k)) payload[k] = payloadFields[k];
131
+ }
132
+ var sealed = t.codec.encrypt(payload, secret);
133
+ return {
134
+ url: t.url.method(walletKey, method, {
135
+ dappPublicKey: journal.dappPublicKey,
136
+ nonce: sealed.nonce,
137
+ redirectLink: opts.redirectLink,
138
+ payload: sealed.payload,
139
+ useScheme: opts.useScheme
140
+ }),
141
+ journal: newJournal(walletKey, step, {
142
+ dappSecretKey: journal.dappSecretKey,
143
+ dappPublicKey: journal.dappPublicKey,
144
+ walletPublicKey: journal.walletPublicKey,
145
+ session: journal.session,
146
+ intent: opts.intent || journal.intent || null
147
+ })
148
+ };
149
+ }
150
+
151
+ function completeSigned(step, params, journal) {
152
+ throwIfWalletError(params);
153
+ requireJournal(journal, step);
154
+ var data = readParam(params, 'data');
155
+ var nonce = readParam(params, 'nonce');
156
+ if (!data || !nonce) throw new Error('Wallet redirect carried no payload');
157
+ return t.codec.decrypt(data, nonce, sharedSecretFrom(journal));
158
+ }
159
+
160
+ return {
161
+ name: p.name,
162
+ key: walletKey,
163
+ transport: 'redirect',
164
+ // Exposed per-provider, not only on the module, so a caller holding just a
165
+ // provider can stamp a journal it builds itself (the supplied-session path
166
+ // in walletOps does exactly that).
167
+ JOURNAL_VERSION: JOURNAL_VERSION,
168
+
169
+ // The capability gate, per wallet. A caller asks BEFORE it paints a
170
+ // button — asking after is how a null provider reached .connect().
171
+ can: function (method) { return t.can(walletKey, method); },
172
+ supportsCluster: function (cluster) { return t.supportsCluster(walletKey, cluster); },
173
+ sendStrategy: function () { return t.sendStrategy(walletKey); },
174
+
175
+ // --- connect ---------------------------------------------------------
176
+ // Carries NO nonce and NO payload: the shared secret does not exist yet.
177
+ // This is the one asymmetry in the protocol and the reason connect cannot
178
+ // reuse beginSigned.
179
+ beginConnect: function (opts) {
180
+ var pair = t.codec.keypair();
181
+ var dappPublicKey = t.base58.encode(pair.publicKey);
182
+ return {
183
+ url: t.url.connect(walletKey, {
184
+ appUrl: opts.appUrl,
185
+ dappPublicKey: dappPublicKey,
186
+ redirectLink: opts.redirectLink,
187
+ cluster: opts.cluster,
188
+ useScheme: opts.useScheme
189
+ }),
190
+ journal: newJournal(walletKey, 'connect', {
191
+ dappSecretKey: t.base58.encode(pair.secretKey),
192
+ dappPublicKey: dappPublicKey,
193
+ intent: opts.intent || null
194
+ })
195
+ };
196
+ },
197
+
198
+ completeConnect: function (params, journal) {
199
+ throwIfWalletError(params);
200
+ requireJournal(journal, 'connect');
201
+ // The one response key that differs between wallets — resolved by the
202
+ // core so Backpack's documented/placeholder ambiguity lives in one place.
203
+ var walletPublicKey = t.connectPublicKey(walletKey, params);
204
+ if (!walletPublicKey) {
205
+ throw new Error(p.name + ' redirect carried no encryption public key');
206
+ }
207
+ var data = readParam(params, 'data');
208
+ var nonce = readParam(params, 'nonce');
209
+ if (!data || !nonce) throw new Error('Wallet redirect carried no payload');
210
+
211
+ var secret = t.codec.sharedSecret(walletPublicKey, t.base58.decode(journal.dappSecretKey));
212
+ var decoded = t.codec.decrypt(data, nonce, secret);
213
+
214
+ return {
215
+ publicKey: decoded.public_key,
216
+ session: decoded.session,
217
+ // The journal a caller persists to make later signing possible.
218
+ journal: newJournal(walletKey, 'connected', {
219
+ dappSecretKey: journal.dappSecretKey,
220
+ dappPublicKey: journal.dappPublicKey,
221
+ walletPublicKey: walletPublicKey,
222
+ session: decoded.session,
223
+ intent: journal.intent || null
224
+ })
225
+ };
226
+ },
227
+
228
+ // --- signMessage -----------------------------------------------------
229
+ // Sign-in is connect THEN signMessage on every wallet — no vendor ships a
230
+ // documented signIn deeplink, so there is no one-hop path to prefer here.
231
+ beginSignMessage: function (opts) {
232
+ return beginSigned('signMessage', 'signMessage', {
233
+ message: opts.message, // base58, per the protocol
234
+ display: opts.display || 'utf8'
235
+ }, opts);
236
+ },
237
+ completeSignMessage: function (params, journal) {
238
+ return completeSigned('signMessage', params, journal);
239
+ },
240
+
241
+ // --- signTransaction -------------------------------------------------
242
+ // The app broadcasts afterwards. On Phantom this is the ONLY path, because
243
+ // its signAndSendTransaction deeplink is deprecated.
244
+ beginSignTransaction: function (opts) {
245
+ return beginSigned('signTransaction', 'signTransaction', {
246
+ transaction: opts.transaction
247
+ }, opts);
248
+ },
249
+ completeSignTransaction: function (params, journal) {
250
+ return completeSigned('signTransaction', params, journal);
251
+ },
252
+
253
+ // --- signAndSendTransaction ------------------------------------------
254
+ // The wallet broadcasts. Refused on Phantom by the capability gate, which
255
+ // is the point: the deprecation is data, not a special case here.
256
+ beginSignAndSendTransaction: function (opts) {
257
+ return beginSigned('signAndSendTransaction', 'signAndSendTransaction', {
258
+ transaction: opts.transaction,
259
+ sendOptions: opts.sendOptions
260
+ }, opts);
261
+ },
262
+ completeSignAndSendTransaction: function (params, journal) {
263
+ return completeSigned('signAndSendTransaction', params, journal);
264
+ },
265
+
266
+ // --- browse ----------------------------------------------------------
267
+ // The handoff that needs no protocol at all: open the page inside the
268
+ // wallet's own in-app browser, where the INJECTED provider works and the
269
+ // existing inline transport runs unchanged. Every wallet that ships this
270
+ // gets a working mobile path even with no adapter behind it.
271
+ browseUrl: function (targetUrl, refUrl) {
272
+ return t.url.browse(walletKey, targetUrl, refUrl);
273
+ }
274
+ };
275
+ }
276
+
277
+ W.SolanaStudio.redirectProvider = {
278
+ JOURNAL_VERSION: JOURNAL_VERSION,
279
+
280
+ // Build a provider for one wallet. Returns null for a wallet with no
281
+ // profile rather than throwing — callers enumerate.
282
+ forWallet: function (walletKey) {
283
+ var t = core();
284
+ return t.profile(walletKey) ? build(String(walletKey).toLowerCase()) : null;
285
+ },
286
+
287
+ // Every wallet reachable over the redirect transport. This is what a picker
288
+ // enumerates on a phone, and what `detect()` in a consuming app chooses from
289
+ // when no provider is injected.
290
+ all: function () {
291
+ var t = core();
292
+ var out = [];
293
+ for (var k in t.PROFILES) {
294
+ if (Object.prototype.hasOwnProperty.call(t.PROFILES, k)) out.push(build(k));
295
+ }
296
+ return out;
297
+ }
298
+ };
299
+ })(typeof window !== 'undefined' ? window : globalThis);
@@ -0,0 +1,154 @@
1
+ // SolanaStudio.walletJournal — the only thing that survives the page's death.
2
+ //
3
+ // A redirect destroys the document. Whatever the app needs on the other side has
4
+ // to be written down first, and this is where. Everything else about the
5
+ // redirect transport is pure functions over data; this file is the one place
6
+ // that touches storage, which is why it is small and separately testable.
7
+ //
8
+ // WHY NOT sessionStorage. The wallet round trip leaves the browser entirely and
9
+ // may return in a NEW TAB — iOS in particular does not guarantee the originating
10
+ // tab is what receives a universal link. sessionStorage is per-tab and would be
11
+ // empty exactly when it mattered. localStorage is the only store that survives
12
+ // the trip, which is also why the entries below expire and are single-use.
13
+ //
14
+ // SINGLE-USE, AND EXPIRING, BOTH ON PURPOSE. A journal that lingers is a journal
15
+ // that gets replayed: a user who abandons a signature, wanders off and comes back
16
+ // an hour later should get a clean "start again", not a resumed transaction they
17
+ // have forgotten authorising. `take()` reads and clears in one motion so a
18
+ // double-fired callback cannot advance the same step twice.
19
+ //
20
+ // WHAT MUST NEVER BE WRITTEN HERE, stated positively because the temptation is
21
+ // real: no private keys belonging to the user, no unsigned transaction bytes, no
22
+ // personal data. The dapp's ephemeral encryption secret IS here and is safe —
23
+ // see the note in redirect_provider.js. Transactions stay server-side behind a
24
+ // prepared-transaction slug, which is precisely why that slug exists.
25
+ (function (W) {
26
+ 'use strict';
27
+
28
+ W.SolanaStudio = W.SolanaStudio || {};
29
+
30
+ var PREFIX = 'wallet_dl';
31
+ var KEY = PREFIX + '_journal';
32
+
33
+ // Ten minutes. Long enough for a human to read a wallet approval screen,
34
+ // think, and approve; short enough that an abandoned trip is gone before it
35
+ // can be resumed by accident. Phantom's own nonce guidance is looser than
36
+ // this, so the tighter bound is ours and deliberate.
37
+ var MAX_AGE_MS = 10 * 60 * 1000;
38
+
39
+ // EVERY access is guarded. localStorage throws outright in a Safari private
40
+ // window and in some embedded webviews — the exact browsers a mobile wallet
41
+ // flow runs in. A storage failure must degrade to "no pending request", never
42
+ // to an exception thrown out of a callback page that then renders nothing.
43
+ function store() {
44
+ try {
45
+ return W.localStorage || null;
46
+ } catch (e) {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ function save(journal) {
52
+ var s = store();
53
+ if (!s || !journal) return false;
54
+ try {
55
+ s.setItem(KEY, JSON.stringify(journal));
56
+ return true;
57
+ } catch (e) {
58
+ // Quota, private mode, or a disabled store. The caller is about to
59
+ // navigate to a wallet; telling it the write failed lets it refuse the
60
+ // trip rather than take one it can never complete.
61
+ return false;
62
+ }
63
+ }
64
+
65
+ // Read WITHOUT clearing. For a callback page that wants to inspect before
66
+ // committing to advancing — the resume path uses take().
67
+ function peek() {
68
+ var s = store();
69
+ if (!s) return null;
70
+ var raw;
71
+ try {
72
+ raw = s.getItem(KEY);
73
+ } catch (e) {
74
+ return null;
75
+ }
76
+ if (!raw) return null;
77
+
78
+ var journal;
79
+ try {
80
+ journal = JSON.parse(raw);
81
+ } catch (e) {
82
+ // Corrupt entry: drop it rather than leave it to fail every future read.
83
+ clear();
84
+ return null;
85
+ }
86
+
87
+ if (!journal || typeof journal !== 'object') { clear(); return null; }
88
+
89
+ if (typeof journal.startedAt === 'number' && (Date.now() - journal.startedAt) > MAX_AGE_MS) {
90
+ // EXPIRED IS NOT AN ERROR, it is an answer. Clearing here means the next
91
+ // read reports "nothing pending" instead of re-deciding expiry forever.
92
+ clear();
93
+ return null;
94
+ }
95
+
96
+ return journal;
97
+ }
98
+
99
+ // Read and clear in one motion. The resume path uses this so a callback that
100
+ // fires twice — a reload, a back button — cannot advance the same step twice.
101
+ function take() {
102
+ var journal = peek();
103
+ if (journal) clear();
104
+ return journal;
105
+ }
106
+
107
+ function clear() {
108
+ var s = store();
109
+ if (!s) return;
110
+ try { s.removeItem(KEY); } catch (e) { /* nothing to do and nothing to say */ }
111
+ }
112
+
113
+ // Purge every key this subsystem owns. A host calls this on user switch: a
114
+ // journal belongs to the person who started it, and one that outlived a logout
115
+ // would offer to resume a stranger's signature.
116
+ function purge() {
117
+ var s = store();
118
+ if (!s) return;
119
+ try {
120
+ var doomed = [];
121
+ for (var i = 0; i < s.length; i++) {
122
+ var k = s.key(i);
123
+ if (k && k.indexOf(PREFIX) === 0) doomed.push(k);
124
+ }
125
+ for (var j = 0; j < doomed.length; j++) s.removeItem(doomed[j]);
126
+ } catch (e) { /* a store we cannot enumerate is a store with nothing to purge */ }
127
+ }
128
+
129
+ W.SolanaStudio.walletJournal = {
130
+ KEY: KEY,
131
+ PREFIX: PREFIX,
132
+ MAX_AGE_MS: MAX_AGE_MS,
133
+ save: save,
134
+ peek: peek,
135
+ take: take,
136
+ clear: clear,
137
+ purge: purge,
138
+ // Whether a journal could be persisted at all. A caller that cannot write
139
+ // must not start a redirect it will be unable to finish — it should fall
140
+ // back to the browse handoff, which needs no journal.
141
+ writable: function () {
142
+ var s = store();
143
+ if (!s) return false;
144
+ try {
145
+ var probe = PREFIX + '_probe';
146
+ s.setItem(probe, '1');
147
+ s.removeItem(probe);
148
+ return true;
149
+ } catch (e) {
150
+ return false;
151
+ }
152
+ }
153
+ };
154
+ })(typeof window !== 'undefined' ? window : globalThis);
@@ -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);
@@ -77,9 +77,10 @@
77
77
  calls window.startPhantomDeepLink(linkMode, userId).
78
78
  onBack() the Back button; DEFAULT closes the modal
79
79
 
80
- CONTRACT WITH THE HOST'S JS. This gem ships one JavaScript file
81
- (solana_studio/network_guard.js) and no routes at all, so every global below is
82
- the HOST'S to provide. Each is reached behind a `typeof` guard, and an absent
80
+ CONTRACT WITH THE HOST'S JS. This gem ships no routes at all, and the JavaScript
81
+ it does ship provides no global in this list: solana_studio/network_guard.js
82
+ plus the redirect-transport primitives (wallet_transport, redirect_provider,
83
+ wallet_journal, wallet_ops). So every global below is the HOST'S to provide. Each is reached behind a `typeof` guard, and an absent
83
84
  one degrades this card rather than breaking it — which is the whole reason the
84
85
  list is written down here instead of being rediscovered per consumer.
85
86
 
@@ -194,9 +195,25 @@
194
195
  // SECOND Phantom row pointing at a desktop download page the user
195
196
  // cannot act on. ONLY when a deep link can replace it: with no deep
196
197
  // link the install row is the only Phantom path there is, dead end on
197
- // iOS or not, and removing it leaves the user nothing. Solflare and
198
- // Backpack keep their install rows either way — there is no deep link
199
- // for them, so the download page is still their only path.
198
+ // iOS or not, and removing it leaves the user nothing.
199
+ //
200
+ // SOLFLARE AND BACKPACK STILL GET INSTALL ROWS HERE, and the reason is NOT
201
+ // the one this comment used to give. It claimed there was no deep link for
202
+ // them, which is FALSE and was costing those users: verified against both
203
+ // vendors' docs 2026-09-07, Solflare (solflare.com/ul/v1) and Backpack
204
+ // (backpack.app/ul/v1) each ship a full deeplink protocol, forked from
205
+ // Phantom's and sharing its encryption scheme and parameter names. So on a
206
+ // phone this row hands them a DESKTOP EXTENSION download page — a silent
207
+ // dead end, with no error, for a wallet that could have worked.
208
+ //
209
+ // What is actually missing is on OUR side: canDeepLink below asks whether
210
+ // ONE Phantom-specific global exists, so no other wallet can answer yes.
211
+ // solana_studio/redirect_provider.js now knows all three, and rewiring
212
+ // these three getters to ask it is the fix. Deliberately NOT done in the
213
+ // change that added that file: these getters are pinned by exact-source
214
+ // assertions in test/views/wallet_connect_picker_test.rb, and rewriting
215
+ // those belongs with the behaviour change rather than riding along with
216
+ // new primitives.
200
217
  get missingInstalls() {
201
218
  var self = this;
202
219
  return this.installs.filter(function(i) {
@@ -30,6 +30,10 @@ module SolanaStudio
30
30
 
31
31
  app.config.assets.precompile += %w[
32
32
  solana_studio/network_guard.js
33
+ solana_studio/wallet_transport.js
34
+ solana_studio/redirect_provider.js
35
+ solana_studio/wallet_journal.js
36
+ solana_studio/wallet_ops.js
33
37
  ]
34
38
  end
35
39
  end
@@ -16,5 +16,5 @@ module SolanaStudio
16
16
  # through the normal cycle. Splitting the version out is the same shape
17
17
  # studio-engine already uses (lib/studio/version.rb) and hands each file back
18
18
  # to its real owner: this one to the release, the gemspec to the PR.
19
- VERSION = "0.7.0"
19
+ VERSION = "0.8.0"
20
20
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solana-studio
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-07 00:00:00.000000000 Z
11
+ date: 2026-09-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ed25519
@@ -38,6 +38,10 @@ files:
38
38
  - LICENSE
39
39
  - README.md
40
40
  - app/assets/javascripts/solana_studio/network_guard.js
41
+ - app/assets/javascripts/solana_studio/redirect_provider.js
42
+ - app/assets/javascripts/solana_studio/wallet_journal.js
43
+ - app/assets/javascripts/solana_studio/wallet_ops.js
44
+ - app/assets/javascripts/solana_studio/wallet_transport.js
41
45
  - app/views/solana_studio/_deeplink_assets.html.erb
42
46
  - app/views/solana_studio/_phantom_deeplink.html.erb
43
47
  - app/views/solana_studio/auth/_wallet_credential.html.erb