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.
@@ -76,6 +76,43 @@
76
76
  browser, or to do something else entirely. The default
77
77
  calls window.startPhantomDeepLink(linkMode, userId).
78
78
  onBack() the Back button; DEFAULT closes the modal
79
+
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
84
+ one degrades this card rather than breaking it — which is the whole reason the
85
+ list is written down here instead of being rediscovered per consumer.
86
+
87
+ window[connect_fn] REQUIRED. Connect + verify. Default
88
+ window.solanaConnectAndVerify.
89
+ window.walletProvider REQUIRED for the rows: .available() lists the
90
+ detected wallets this card paints.
91
+ parseSolanaError(msg) optional. Maps a wallet string to a sentence
92
+ a user can act on; absent means the wallet's
93
+ own words are shown unmapped.
94
+ window.handleSolanaVerifySuccess(result)
95
+ optional. Post-verify hook on the happy path.
96
+ window.startPhantomDeepLink(linkMode, userId)
97
+ optional. The mobile deep link; absent hides
98
+ the Phantom deep-link row entirely.
99
+ window.reportWalletFailure(stage, provider, raw, mapped)
100
+ optional. Fire-and-forget observation, called
101
+ ONCE per caught rejection with stage
102
+ 'wallet_connect'. `raw` is what the wallet
103
+ said and `mapped` is what the user was shown;
104
+ a host that records only one half cannot
105
+ diagnose a MIS-mapping, which is the failure
106
+ this exists for. Return value is ignored and
107
+ a throw is swallowed — see the call site.
108
+ Absent means this surface is DARK: the user
109
+ is served correctly and nothing is recorded
110
+ anywhere. Reference implementation and the
111
+ receiving endpoint (both host-owned, because
112
+ they need an ErrorLog this gem cannot assume)
113
+ live in turf-monster:
114
+ app/javascript/solana_errors.js and
115
+ POST /auth/solana/report_failure.
79
116
  %>
80
117
  <%
81
118
  store = local_assigns.fetch(:store, "modals")
@@ -158,9 +195,25 @@
158
195
  // SECOND Phantom row pointing at a desktop download page the user
159
196
  // cannot act on. ONLY when a deep link can replace it: with no deep
160
197
  // link the install row is the only Phantom path there is, dead end on
161
- // iOS or not, and removing it leaves the user nothing. Solflare and
162
- // Backpack keep their install rows either way — there is no deep link
163
- // for them, so the download page is still their only path.
198
+ // iOS or not, and removing it leaves the user nothing.
199
+ //
200
+ // SOLFLARE AND BACKPACK STILL GET INSTALL ROWS HERE, and the reason is NOT
201
+ // the one this comment used to give. It claimed there was no deep link for
202
+ // them, which is FALSE and was costing those users: verified against both
203
+ // vendors' docs 2026-09-07, Solflare (solflare.com/ul/v1) and Backpack
204
+ // (backpack.app/ul/v1) each ship a full deeplink protocol, forked from
205
+ // Phantom's and sharing its encryption scheme and parameter names. So on a
206
+ // phone this row hands them a DESKTOP EXTENSION download page — a silent
207
+ // dead end, with no error, for a wallet that could have worked.
208
+ //
209
+ // What is actually missing is on OUR side: canDeepLink below asks whether
210
+ // ONE Phantom-specific global exists, so no other wallet can answer yes.
211
+ // solana_studio/redirect_provider.js now knows all three, and rewiring
212
+ // these three getters to ask it is the fix. Deliberately NOT done in the
213
+ // change that added that file: these getters are pinned by exact-source
214
+ // assertions in test/views/wallet_connect_picker_test.rb, and rewriting
215
+ // those belongs with the behaviour change rather than riding along with
216
+ // new primitives.
164
217
  get missingInstalls() {
165
218
  var self = this;
166
219
  return this.installs.filter(function(i) {
@@ -195,9 +248,46 @@
195
248
  this.connecting = false; this.picking = '';
196
249
  }
197
250
  } catch (e) {
198
- var msg = (e && e.code === 4001) ? 'Signature rejected' : ((e && e.message) || 'Connection failed');
251
+ // BOTH HALVES OR NEITHER. raw is what the WALLET said, captured
252
+ // before the mapper runs; msg is what the user reads. A mis-mapping
253
+ // is invisible in the mapped half alone, and that is not theoretical
254
+ // -- a correct mapper meeting a string it had never seen is how an
255
+ // empty Phantom came to be answered with balance advice seven times
256
+ // in one production session on 2026-09-06.
257
+ var raw = (e && e.message) || '';
258
+ var msg = (e && e.code === 4001) ? 'Signature rejected' : (raw || 'Connection failed');
199
259
  if (typeof parseSolanaError === 'function') msg = parseSolanaError(msg);
200
260
  this.error = msg; this.connecting = false; this.picking = '';
261
+ // ── OBSERVATION, AFTER THE USER HAS BEEN SERVED ──────────────────
262
+ //
263
+ // Deliberately the LAST statement in this block, and the three lines
264
+ // above are deliberately not in it: reporting may not delay, block or
265
+ // alter the line the user has just been handed.
266
+ //
267
+ // HOST-SUPPLIED, like every other global this partial reaches for
268
+ // (see CONTRACT WITH THE HOST'S JS in the header). The endpoint it
269
+ // posts to writes an ErrorLog, which is host-owned -- this gem ships
270
+ // no routes at all -- so a consumer that has not built one simply
271
+ // does not define this, the guard is false, and the card behaves
272
+ // exactly as it did before. Silence, never a 404.
273
+ //
274
+ // ALREADY REPORTED UPSTREAM? The connect helper substitutes its own
275
+ // sentence for an unusable wallet and reports the pair from in there,
276
+ // where the wallet's words still exist. Reporting again from out here
277
+ // would add a second row whose raw and mapped are BOTH our sentence
278
+ // -- the useless row, and the one an operator meets first.
279
+ //
280
+ // THE try/catch IS THIS GEM'S OWN LAYER, and it is not redundant with
281
+ // the reporter's. We do not own the reporter -- a host does, and a
282
+ // host's implementation may throw. A throw here escapes an async Alpine
283
+ // handler silently, which is the failure mode this whole feature
284
+ // exists to remove -- so the observation cannot be allowed to become
285
+ // the incident. Four values, and only these four: opts,
286
+ // verifyArgs() and this.props are all in scope and carry
287
+ // currentUserId, none of which belongs in an error log.
288
+ if (typeof window.reportWalletFailure === 'function' && !(e && e.walletFailureReported)) {
289
+ try { window.reportWalletFailure('wallet_connect', name, raw, msg); } catch (_e) {}
290
+ }
201
291
  }
202
292
  },
203
293
  deepLink() {
@@ -64,6 +64,19 @@
64
64
  you into that wallet's account, exactly as a standalone wallet button does. The
65
65
  walletHint is shown precisely so that is a visible choice, not a surprise.
66
66
 
67
+ Two more host globals, both OPTIONAL and both reached behind a `typeof` guard:
68
+ `parseSolanaError(msg)` maps a wallet string to a sentence a user can act on,
69
+ and `window.reportWalletFailure(stage, provider, raw, mapped)` records a caught
70
+ rejection with stage 'web3_step_up'. The reporter is fire-and-forget — its
71
+ return value is ignored and a throw is swallowed — and it is called ONCE per
72
+ rejection, skipping any error solanaConnectAndVerify already reported upstream.
73
+ Send BOTH message halves: `raw` is what the wallet said, `mapped` is what the
74
+ user read, and only the pair makes a MIS-mapping diagnosable. A host that
75
+ defines neither gets this card exactly as it behaved before, with the failure
76
+ recorded nowhere. The reporter and its endpoint stay host-owned because they
77
+ need an ErrorLog this gem cannot assume — reference implementation in
78
+ turf-monster: app/javascript/solana_errors.js, POST /auth/solana/report_failure.
79
+
67
80
  CRITICAL (Alpine): this partial is cloned from a <template x-if> by the modal
68
81
  host, so it must have ONE root element, and the x-data below is a
69
82
  DOUBLE-QUOTED attribute — a single " anywhere inside it (a code comment
@@ -167,9 +180,38 @@
167
180
  }
168
181
  this.error = (result && result.error) || 'Verification failed.';
169
182
  } catch (e) {
170
- var msg = (e && e.code === 4001) ? 'Signature rejected' : ((e && e.message) || 'Connection failed');
183
+ // BOTH HALVES OR NEITHER. raw is what the WALLET said, captured
184
+ // before the mapper runs; msg is what the user reads. A mis-mapping
185
+ // is invisible in the mapped half alone -- a correct mapper meeting a
186
+ // string it had never seen is how an empty Phantom came to be
187
+ // answered with balance advice seven times in one production session
188
+ // on 2026-09-06, which is the incident this reporting exists for.
189
+ var raw = (e && e.message) || '';
190
+ var msg = (e && e.code === 4001) ? 'Signature rejected' : (raw || 'Connection failed');
171
191
  if (typeof parseSolanaError === 'function') msg = parseSolanaError(msg);
172
192
  this.error = msg;
193
+ // Observation, LAST and after the user has been served. Host-supplied
194
+ // like every other global here (see CONTRACT WITH THE HOST'S JS in
195
+ // the header): the endpoint it posts to writes an ErrorLog, which is
196
+ // host-owned -- this gem ships no routes -- so a consumer that built
197
+ // none simply leaves this undefined and the card behaves exactly as
198
+ // before. The walletFailureReported tag means solanaConnectAndVerify
199
+ // already reported this one from where the wallet's words still
200
+ // existed; reporting again would file a second row carrying OUR
201
+ // sentence in both halves, and that is the row an operator meets
202
+ // first. Sibling call site in modals/_wallet_connect carries the long
203
+ // form of both arguments.
204
+ //
205
+ // THE try/catch IS LOAD-BEARING HERE IN A WAY IT IS NOT THERE.
206
+ // this.connecting = false sits OUTSIDE this catch block, on the
207
+ // line below. A host reporter that throws would skip it, leaving the
208
+ // sign-in button disabled forever with an error the user cannot
209
+ // retry -- and Alpine swallows a throw out of an async handler, so
210
+ // nothing anywhere would say so. An observer that can wedge the card
211
+ // is a worse bug than the darkness it was added to fix.
212
+ if (typeof window.reportWalletFailure === 'function' && !(e && e.walletFailureReported)) {
213
+ try { window.reportWalletFailure('web3_step_up', name, raw, msg); } catch (_e) {}
214
+ }
173
215
  }
174
216
  this.connecting = false;
175
217
  },
@@ -281,7 +323,7 @@
281
323
  Installed badge, chevron — so a wallet reads identically everywhere it is
282
324
  offered and this card does not invent a third look for one action.
283
325
 
284
- It carries `pulse-cta` (engine-motion) because it is the ONE target on the
326
+ It carries pulse-cta (engine-motion) because it is the ONE target on the
285
327
  card and the whole point of the card is that the user should press it. %>
286
328
  <template x-if="canOneClick">
287
329
  <div>
@@ -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.6.1"
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.6.1
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-07 00:00:00.000000000 Z
11
+ date: 2026-09-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ed25519
@@ -38,6 +38,10 @@ files:
38
38
  - LICENSE
39
39
  - README.md
40
40
  - app/assets/javascripts/solana_studio/network_guard.js
41
+ - app/assets/javascripts/solana_studio/redirect_provider.js
42
+ - app/assets/javascripts/solana_studio/wallet_journal.js
43
+ - app/assets/javascripts/solana_studio/wallet_ops.js
44
+ - app/assets/javascripts/solana_studio/wallet_transport.js
41
45
  - app/views/solana_studio/_deeplink_assets.html.erb
42
46
  - app/views/solana_studio/_phantom_deeplink.html.erb
43
47
  - app/views/solana_studio/auth/_wallet_credential.html.erb