solana-studio 0.8.0 → 0.9.1

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: e7dab4511993a7a64ace8ad5319dfe98f8436fba4ed8ce02a086b754df0f6a6c
4
- data.tar.gz: 8c95f186599f9d825b58756f8932577801485ebc6e621b9725eb34563c2ffa51
3
+ metadata.gz: a00c3f09eb98073668c9218045e49e705be6e803348ad9338e4295d32ca7c091
4
+ data.tar.gz: 16189d3a834e1880bbba0b37113823fdf95d4a690926d98f67120213818b70db
5
5
  SHA512:
6
- metadata.gz: 539111d8afe1c593ca295bd37140c0cf9e437a08fff46db02bf87b39706d5187621d7769bd124808c07206823ffd5154f079049b96bc17bd4e748b718b32c0b5
7
- data.tar.gz: 22d646247fad4661ad66b020b00034cfebe0c045106e04cf4a5fb9f36a4f558e2415d6d5b5e2d97c7677f85baeed988c1cf00e443adca8064c7905204b40c594
6
+ metadata.gz: 43e0a031a5fb9c2bcb451c4bddcf2df0e79a1c1722c60e57e9fcd302699434736bf363dd12a6f1ee7f847f447a1b3aeb792e557a7874edc419714a66a9a8b094
7
+ data.tar.gz: 8613a4f8bcead4253fb9b1814b1fbb008d9d1f9c3db4e1e8be33b5ca5fc005ce2b7b4226f136454a52f4ef66ccec59c22cf968d6f78762558e51355a2e2de056
@@ -11,11 +11,22 @@
11
11
  //
12
12
  // walletOps.define('contest_entry', {
13
13
  // prepare: function (ctx) { ... return { transaction: <base58>, ...state }; },
14
- // complete: function (ctx, result, state) { ... }
14
+ // complete: function (ctx, result, state) { ... },
15
+ // signOnly: true
15
16
  // });
16
17
  //
17
18
  // walletOps.run('contest_entry', { contestId: 12 }, { provider: ... });
18
19
  //
20
+ // `signOnly` IS A REQUIREMENT OF THE TRANSACTION, NOT A PREFERENCE ABOUT THE
21
+ // WALLET, and it is the intent's to declare because only the intent knows the
22
+ // shape of the bytes it prepared. A CO-SIGNED transaction — one whose second
23
+ // signer slot is deliberately empty because a server fills it — cannot be
24
+ // broadcast by the wallet: the chain would reject it for a missing required
25
+ // signature, and, worse, the signed bytes the server needs would never come
26
+ // back to the app, so the flow fails with nothing to retry. Declaring it makes
27
+ // the transaction's own requirement outrank the wallet's capability. Omit it
28
+ // and nothing changes: a wallet that can broadcast still does.
29
+ //
19
30
  // THE ONE RULE A CALLER MUST FOLLOW: handlers are registered BY NAME at page
20
31
  // load, not passed as closures. A closure is precisely what cannot survive the
21
32
  // redirect — the page that held it no longer exists when the wallet answers. The
@@ -30,7 +41,8 @@
30
41
  // WHAT THIS FILE DOES NOT DO: it does not broadcast. Signing and sending are
31
42
  // different responsibilities with different failure modes, and the wallet that
32
43
  // 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
44
+ // the app sends; Solflare and Backpack send for you) and, where the intent
45
+ // declares `signOnly`, per TRANSACTION as well. `complete` is told which
34
46
  // happened and owns the RPC, exactly as the existing flows already do.
35
47
  (function (W) {
36
48
  'use strict';
@@ -107,6 +119,23 @@
107
119
  return Promise.resolve(handler.prepare(ctx)).then(function (prepared) {
108
120
  var intent = { op: name, ctx: ctx, state: prepared };
109
121
 
122
+ // THE DECLARATION TRAVELS IN THE JOURNAL, NOT LOOKED UP FROM THE HANDLER
123
+ // AT THE FAR END, and that is the whole reason this line exists here
124
+ // rather than inside signingHop. The signing hop is taken from TWO
125
+ // places: this one, where the handler is certainly registered, and the
126
+ // connect callback, which is a DIFFERENT PAGE that may not have loaded
127
+ // the script that defined this intent. A handler lookup there would come
128
+ // back empty and silently fall through to the send-side default — the
129
+ // exact branch a co-signed transaction must never take. A boolean on the
130
+ // intent is JSON-serialisable, which is the one thing this file requires
131
+ // of anything that has to survive a redirect.
132
+ //
133
+ // Set ONLY when true, so the journal an undeclared intent writes is
134
+ // byte-identical to the one it wrote before this option existed. That is
135
+ // also why JOURNAL_VERSION does not move: no reader's expectations
136
+ // change, and bumping it would strand every trip already in flight.
137
+ if (handler.signOnly) intent.signOnly = true;
138
+
110
139
  // Already connected? Go straight to signing. Otherwise connect first and
111
140
  // carry the intent through — sessions do not expire on any of the three
112
141
  // wallets, so this branch is taken once per user, not once per action.
@@ -145,18 +174,38 @@
145
174
  return signingHop(provider, connected, opts);
146
175
  }
147
176
 
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.
177
+ // Which signing method this wallet gets is TWO questions asked in order, and
178
+ // the order is the point.
179
+ //
180
+ // FIRST, what does the TRANSACTION allow? A co-signed transaction has an
181
+ // empty signer slot the server fills, so the wallet must sign and hand the
182
+ // bytes back — broadcasting it is not a worse option, it is a broken one.
183
+ // An intent that knows this declares `signOnly` and that answer is final.
184
+ //
185
+ // SECOND, and only for everything else, what can the WALLET do? Phantom's
186
+ // send-side deeplink is deprecated, so it signs and the app broadcasts;
187
+ // Solflare and Backpack broadcast for you. Asking the provider keeps that
188
+ // fact in the profile table where it is asserted, rather than branching on a
189
+ // wallet name here.
190
+ //
191
+ // Asking them the other way round is the bug this replaced: capability alone
192
+ // sent every co-signed transaction to the wallet's broadcaster on any wallet
193
+ // that had one.
194
+ //
195
+ // A wallet with no signTransaction at all would be refused BY NAME by the
196
+ // provider's own capability gate rather than quietly broadcast here. No
197
+ // profile is in that state today (all three sign), and this is the branch
198
+ // that would have to be revisited if one ever were.
152
199
  function signingHop(provider, journal, opts) {
200
+ var intent = journal.intent;
153
201
  var payload = {
154
202
  journal: journal,
155
- transaction: journal.intent.state.transaction,
203
+ transaction: intent.state.transaction,
156
204
  redirectLink: opts.redirectLink,
157
- intent: journal.intent
205
+ intent: intent
158
206
  };
159
- return provider.can('signAndSendTransaction')
207
+ var signOnly = !!(intent && intent.signOnly);
208
+ return (!signOnly && provider.can('signAndSendTransaction'))
160
209
  ? provider.beginSignAndSendTransaction(payload)
161
210
  : provider.beginSignTransaction(payload);
162
211
  }
@@ -234,6 +283,18 @@
234
283
  if (!handler || typeof handler.prepare !== 'function' || typeof handler.complete !== 'function') {
235
284
  throw new Error('walletOps.define("' + name + '") needs both prepare and complete');
236
285
  }
286
+ // REFUSED RATHER THAN COERCED, because every wrong answer here fails in
287
+ // the dangerous direction. A typo'd `signOnly: 'true'` read as truthy
288
+ // would sign-only a flow that wanted the wallet to broadcast; read
289
+ // strictly, it silently sends a co-signed transaction to a broadcaster.
290
+ // Neither is discoverable at the call site, and both surface as a chain
291
+ // error one page death later. So the option is a boolean or it is a bug.
292
+ if (handler.signOnly !== undefined && typeof handler.signOnly !== 'boolean') {
293
+ throw new Error(
294
+ 'walletOps.define("' + name + '") signOnly must be true or false, got ' +
295
+ typeof handler.signOnly
296
+ );
297
+ }
237
298
  handlers[name] = handler;
238
299
  },
239
300
 
@@ -214,11 +214,55 @@
214
214
  // assertions in test/views/wallet_connect_picker_test.rb, and rewriting
215
215
  // those belongs with the behaviour change rather than riding along with
216
216
  // new primitives.
217
+ // THE REDIRECT-TRANSPORT REGISTRY, guarded the same way canDeepLink is and
218
+ // for the same reason: a consumer that does not load
219
+ // solana_studio/redirect_provider.js has no registry, and an absent
220
+ // capability must not default to the permissive branch. Empty list, no
221
+ // rows, install rows keep their place.
222
+ get redirectProviders() {
223
+ var rp = window.SolanaStudio && window.SolanaStudio.redirectProvider;
224
+ if (!rp || typeof rp.all !== 'function') return [];
225
+ return rp.all().filter(function(w) { return w.can('browse'); });
226
+ },
227
+ // Wallets we can hand a phone to RIGHT NOW, and the honesty rule that
228
+ // shapes this list: a row is painted only when tapping it does something.
229
+ //
230
+ // The handoff is the browse deeplink — it opens this page inside the
231
+ // wallet's OWN in-app browser, where the wallet injects a provider and the
232
+ // ordinary detected-row flow above works unchanged. That needs no signing
233
+ // protocol on our side, which is exactly why it can ship before one.
234
+ //
235
+ // Excluded: a wallet already injected (the detected row above is a better
236
+ // offer than leaving the page), and Phantom when canDeepLink is true
237
+ // (its own row below is a full sign-in, not a handoff — offering both
238
+ // would be two Phantom rows racing each other).
239
+ get mobileHandoffs() {
240
+ if (!this.isMobile) return [];
241
+ var self = this;
242
+ return this.redirectProviders.filter(function(w) {
243
+ if (self.hasWallet(w.name)) return false;
244
+ if (w.key === 'phantom' && self.canDeepLink) return false;
245
+ return true;
246
+ });
247
+ },
248
+ // Navigate into the wallet's browser, carrying THIS page as the target so
249
+ // the user lands where they already were rather than on a home screen.
250
+ openInWallet(key) {
251
+ var rp = window.SolanaStudio && window.SolanaStudio.redirectProvider;
252
+ var w = rp && rp.forWallet(key);
253
+ if (!w) return;
254
+ window.location.href = w.browseUrl(window.location.href, window.location.origin);
255
+ },
217
256
  get missingInstalls() {
218
257
  var self = this;
258
+ var handoffs = this.mobileHandoffs.map(function(w) { return w.name.toLowerCase(); });
219
259
  return this.installs.filter(function(i) {
220
260
  if (self.hasWallet(i.name)) return false;
221
261
  if (self.isMobile && self.canDeepLink && i.name === 'Phantom') return false;
262
+ // A wallet with a working handoff row must not ALSO offer a desktop
263
+ // download page. That pairing is what sent Solflare and Backpack users
264
+ // on a phone to solflare.com/download — a dead end with no error.
265
+ if (self.isMobile && handoffs.indexOf(i.name.toLowerCase()) !== -1) return false;
222
266
  return true;
223
267
  });
224
268
  },
@@ -387,6 +431,23 @@
387
431
  </span>
388
432
  </button>
389
433
 
434
+ <%# Wallets we can hand this phone to. Rendered ABOVE the install rows on
435
+ purpose: a path that works belongs before a download page. Each row is
436
+ painted only when tapping it does something — see mobileHandoffs. %>
437
+ <template x-for="w in mobileHandoffs" :key="'handoff-' + w.key">
438
+ <button type="button" @click="openInWallet(w.key)"
439
+ class="w-full flex items-center gap-3 p-3 rounded-xl bg-surface-alt border border-strong hover:bg-surface transition text-left">
440
+ <svg class="w-9 h-9 shrink-0" aria-hidden="true"><use :href="'#se-wallet-' + brandIcon(w.name)"></use></svg>
441
+ <span class="font-semibold text-heading" x-text="w.name"></span>
442
+ <span class="ml-auto flex items-center gap-2">
443
+ <span class="text-xs text-muted uppercase tracking-wide">Open app</span>
444
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4 text-muted">
445
+ <path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
446
+ </svg>
447
+ </span>
448
+ </button>
449
+ </template>
450
+
390
451
  <%# Featured wallets that aren't installed — open the install page %>
391
452
  <template x-for="i in missingInstalls" :key="i.name">
392
453
  <a :href="i.url" target="_blank" rel="noopener noreferrer"
@@ -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.8.0"
19
+ VERSION = "0.9.1"
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.8.0
4
+ version: 0.9.1
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-08 00:00:00.000000000 Z
11
+ date: 2026-09-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ed25519