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.
@@ -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);