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 +4 -4
- data/README.md +5 -3
- 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 +23 -6
- data/lib/solana_studio/engine.rb +4 -0
- data/lib/solana_studio/version.rb +1 -1
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e7dab4511993a7a64ace8ad5319dfe98f8436fba4ed8ce02a086b754df0f6a6c
|
|
4
|
+
data.tar.gz: 8c95f186599f9d825b58756f8932577801485ebc6e621b9725eb34563c2ffa51
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
355
|
-
|
|
356
|
-
|
|
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
|
|
81
|
-
|
|
82
|
-
the
|
|
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.
|
|
198
|
-
//
|
|
199
|
-
//
|
|
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) {
|
data/lib/solana_studio/engine.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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-
|
|
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
|