solana-studio 0.9.3 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6298dedd5eff0a7e2c908b874052c189c7084964bf98fa660e99bb2c3a02a3ed
4
- data.tar.gz: 89dc78d9370c93ef76a58f936c4285b08336c06a0651e17b246cff26e6563af5
3
+ metadata.gz: b39e73cb89b5702df9fe20f382981a4828b8b1323f2ef9080f1b001c8e8396c2
4
+ data.tar.gz: be1c8214f63bcf63d87c2f8b25954f36d09c61e11aad4adf51241a07a4ff8f5e
5
5
  SHA512:
6
- metadata.gz: e50c4f0545d30084afc32da7e1c44a370e122da1beeed22627f2e4aa3aae109fbcc47918a93383504015342c52b6c596e97f49419457e0da9330beeafeff0f2c
7
- data.tar.gz: 8769de22b4f7ec6d27f3136b65a7459ea871032571d451d52de45f07fcf5de8b11bd756090a3095c5286f04032b0d45299b4db438e14f1f288b5fff772903b5a
6
+ metadata.gz: 3178007eec399ce2356ca861d2d8ebbb48bf13d9c9ca5da4deaedf9c0a1e7fe0df80114571a08263e58fce779a8f1557b4a4cee00b9d1484c7ab933f46e2a673
7
+ data.tar.gz: 39d356918c3e8b0681c1447538946f178e8dc6ff8173de5c8b95d95b2b1b7725190de477fdd7e4656544d7552344c61688634f22a9b2216a0cebbafe468b3556
data/CHANGELOG.md CHANGED
@@ -5,6 +5,8 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
5
5
  ## Unreleased
6
6
 
7
7
  ### Added
8
+ - **`SolanaStudio.walletSession` — the wallet session is written down, so a returning user signs in ONE hop** (`app/assets/javascripts/solana_studio/wallet_journal.js`, `.../wallet_ops.js`). Sessions **never expire** on Phantom, Solflare or Backpack — all three vendors state it in their own docs (verified 2026-09-07, recorded per wallet in `wallet_transport.js`'s profile table) — but nothing persisted one, so EVERY mobile signing trip paid two app switches: connect, then sign. `runRedirect` already had the warm branch and a comment claiming it was "taken once per user, not once per action"; no caller could be on it, because there was nowhere for a session to live. Declare `run(..., { owner })` and there is: the connect hop's session is stored, scoped to that user, that wallet and that cluster, and the next trip goes straight to `signTransaction`. That halves the app switches on the flow that takes money and halves the surface where a trip can break — a real user's entry was lost on QA in precisely the second hop. **A SEPARATE RECORD, IN THE SAME FILE, and both halves of that are deliberate.** The journal is single-use and expires in ten minutes BY DESIGN (`take()` clears it so a double-fired callback cannot advance a step twice); a session is the opposite, long-lived and reusable. Share one record and every completed signature would `take()` the session away with it, which is the two-hop behaviour this removes. What must be separate is the RECORD, not the file: everything about reaching `localStorage` safely — the guarded accessor, the three browser states it really presents, the quota branch, the corrupt-entry drop — is one algorithm, and a second file would be a second copy of it (see the `B58_ALPHABET` note in `wallet_transport.js` for what that costs here). Sharing the file also keeps `purge()` whole: ONE call sweeps by key prefix and clears the session with the journal, so no consumer has to learn a second call. **But it is a call the host MUST ADD, not one it already makes** — turf-monster's layout sweeps the older `phantom_dl_` prefix, which does not match this subsystem's `wallet_dl_` and so clears NEITHER record. Adopt `owner` and that call in the same change, or ship a session that never expires — and the dapp encryption secret beside it — surviving a logout on a shared device. **THE TOKEN IS OPAQUE AND STAYS OPAQUE.** It decodes to a 64-byte signature plus JSON carrying `app_url`, timestamp, chain and cluster; none of it is read. The wallet is the only authority on validity, so a local parse can only ever be a second opinion that is wrong in one of two directions — `test_a_remembered_session_is_recalled_verbatim_and_never_parsed` stores a value containing `0`, `O`, `I` and `l` (the four characters base58 omits) and asserts it round-trips, so a decode added later cannot pass. **THE OWNER IS REFUSED RATHER THAN STRINGIFIED**, which is the guard that matters most: an object would `String()` to `[object Object]` — one token every user of that browser matches — and the failure would be a stranger offered a one-hop signature with a session they never established. A string or a number is a real handle; anything else is refused at `run()` by name and again inside `remember()`. An ANONYMOUS trip is not remembered at all: a session with nobody to scope it to cannot be kept from the next person, and there is no logout event on an anonymous page for `purge()` to ride. The scope is read from the JOURNAL in preference to the callback page's `opts.owner`, and that direction is the safety property — the journal says who STARTED the trip, and stamping whoever is signed in when the wallet answers is the one read that could hand a session to someone else. **A DECLARED `expectedAccount` IS NOW HONOURED ON A WARM TRIP**, which it never was: the session records the address it was established for, so a wallet keypair change is a local MISS that falls through to a connect hop instead of two app switches and a refusal. Omit `owner` and nothing changes — same hops, and an undeclared intent's journal is byte-identical to the one it wrote before this existed, which is why `JOURNAL_VERSION` does not move.
9
+ - **A refused session recovers through a connect hop instead of losing the entry** (`app/assets/javascripts/solana_studio/wallet_ops.js`). A stored session can be refused mid-trip, and the vendors name the causes: an explicit disconnect, a wallet keypair change, the user switching networks, an `app_url` blocklisting. The refusal lands on the SIGNING callback — by which point the user has already left for their wallet and come back, so they are committed, and failing there loses their work. So the trip takes the hop it skipped: forget the session, navigate to `connect` carrying THE SAME INTENT, and the ordinary connect callback advances it to signing on its own. One extra app switch — the two-hop cost they would have paid anyway — rather than starting over. **`prepare()` IS NOT RE-RUN**: the journalled `intent.state` is reused, because prepare MINTS things (turf-monster's mints a prepared-transaction row with a fresh blockhash) and a second one would strand the first and charge the flow twice for one user action. **RECOVERY IS BOUNDED TO ONCE BY SHAPE, NOT BY A COUNTER**: the retry intent carries its `scope` but NOT its `recovery` block, so the hop it produces has nothing to recover to and a second refusal surfaces the wallet's own words — there is no field anyone can forget to decrement. **A `4001` USER REJECTION IS NOT A REFUSED SESSION** and reaches the caller as itself; sending someone who just declined back to their wallet for another look is hostile, and their session is fine. A decryption failure is likewise out of the rule: it carries no error code, no vendor documents it as a refusal channel, and a corrupt payload should read as itself rather than as a session problem. The rule is deliberately WIDER than the four documented causes, whose codes are not pinned here — guessing narrow costs the user their entry, guessing wide costs one app switch and then reports the same error honestly, and when the two mistakes are that asymmetric you take the cheap one. Recovery also RESTORES a guard the warm path gives up: the recovered trip goes through connect, where a declared `expectedAccount` is checked, so a keypair change ends in a sentence the user can act on rather than a chain error. Covered by 20 node tests across simulated page deaths and 3 browser specs in `e2e/wallet_session.spec.js`; all 24 mutations of the new guards were confirmed to fail a named test.
8
10
  - **`walletOps.run(..., { expectedAccount })` — a declared account the trip refuses to run without** (`app/assets/javascripts/solana_studio/wallet_ops.js`). Consumers were checking the connected pubkey against the session's linked address by hand, on the inline path only, because the redirect path had nowhere to put it: `resume()` goes from `completeConnect` straight into the signing hop. Declaring the address at `run()` gives both transports the check. **IT IS UX, NOT SECURITY, and overselling it would be the more expensive mistake**: the ownership proof is on-chain — Anchor rejects any `enter_contest_direct` whose signer does not match the entry PDA's owner, with or without this. What it buys is a sentence a user can act on (`Wrong wallet — this account is linked to GkxH…kQrM, but the wallet connected as 9WzD…AWWM`) instead of a program error, and on the inline transport a server-minted prepared transaction that is never spent to discover the wrong wallet is connected. The refusal carries `err.wrongAccount`, `err.expected` and `err.connected` so a host can compose its own sentence instead of parsing the default one. **A DECLARED VALUE RATHER THAN A POST-CONNECT HOOK, which is the obvious design and the wrong one**: the connect callback is a DIFFERENT DOCUMENT — studio-engine's `solana_sessions/phantom_callback`, which knows nothing about any consumer's flows — and `resume` deliberately does not require a registered handler to advance from connect to signing (`test_the_sign_only_declaration_holds_when_the_callback_page_lacks_the_intent` pins that). A hook would therefore be looked up on exactly the hop it exists to guard, come back empty, and be SKIPPED IN SILENCE. A string in the journal cannot be skipped, because there is nothing to look up — the same argument that put `signOnly` there, and it is proven by a test that clears the registry between the run and the callback. Stamped ONLY when declared, so an undeclared intent's journal stays byte-identical and `JOURNAL_VERSION` does not move.
9
11
  - **Where the check lands differs per transport, and that asymmetry is documented rather than papered over.** Inline: after `connect()`, BEFORE `prepare()` — which is why connect now runs first (below). Redirect on a cold session: on the connect callback, before the signing hop, so `prepare` has already run and whatever it minted is spent — unavoidable, because the connect hop destroys the page and the journal is the only thing that crosses it. Redirect on a WARM session (`opts.session`): not checked at all, because no connect hop happens and walletOps never learns an account; a caller holding a session learned the address when it established one. A README table states all three.
10
12
  - **Wallet failures on both web3 modals now reach the host's error log** (`app/views/solana_studio/modals/_wallet_connect.html.erb`, `_web3_step_up.html.erb`). Each catch block already mapped the wallet's throw into a paragraph and stopped there, so every rejection on this surface died in the browser. Both now call the host's optional `window.reportWalletFailure(stage, provider, raw, mapped)` behind a `typeof` guard, with stage `wallet_connect` and `web3_step_up`. **The reporter and its endpoint stay HOST-owned, and that is a dependency call rather than a convenience**: by the two-axis test in `docs/agents/modules/modal-lifecycle.md` neither axis fires on the reporter — it needs no gem code to render and binds to no wallet/chain runtime, only `fetch` and a CSRF meta every base app already has — so it is not solana-studio-bound, and what makes it LOOK gem-shaped is subject matter, the error that module names outright (`blocks/_wallet_brand_sprite` is wallet-depicting and engine-owned for the same reason). The decisive half is that the ENDPOINT cannot move: it writes an `ErrorLog` through `rescue_and_log`, and `lib/solana_studio/engine.rb` mounts no routes by design. A gem-side reporter would therefore be half a mechanism whose other half every consumer still hand-rolls, and its failure mode would upgrade from silence to a **silent 404** on a surface that now looks wired — harder to catch than the darkness it replaced. The graduation trigger has not fired either: one app has the reporter, zero second adopters, and the module's rule is a second app that has ALREADY shipped the shape, never a forecast. What answers the rediscovery cost instead is DECLARING the contract where a consumer meets it — both partial headers and a new README section now name all six host globals, what each absence degrades, and the four-value body.
data/README.md CHANGED
@@ -444,6 +444,7 @@ SolanaStudio.walletOps.define('contest_entry', {
444
444
  SolanaStudio.walletOps.run('contest_entry', { contestId: 12 }, {
445
445
  provider: walletProvider.detect(),
446
446
  expectedAccount: session.address, // optional
447
+ owner: currentUser.id, // optional — see below
447
448
  appUrl: location.origin, // redirect transport only
448
449
  redirectLink: location.origin + '/auth/phantom/callback',
449
450
  cluster: document.body.dataset.solanaCluster
@@ -542,13 +543,19 @@ place the two genuinely cannot be made identical:
542
543
  |---|---|---|
543
544
  | Inline | after `connect()`, **before** `prepare()` | nothing — `prepare` never runs |
544
545
  | Redirect, cold session | on the connect callback, **before** the signing hop | whatever `prepare` already minted; no signing prompt |
546
+ | Redirect, warm session (`owner`) | locally, **before** the trip starts | nothing — the trip falls back to a connect hop |
545
547
  | Redirect, warm session (`opts.session`) | **not checked** | — |
546
548
 
547
549
  The redirect path cannot check earlier because the connect hop destroys the
548
550
  page: everything `prepare` returns must already be in the journal before the
549
- navigation. A warm session takes no connect hop at all, so walletOps never learns
550
- an account — a caller holding a session learned the address when it established
551
- one, and that is where the check belongs.
551
+ navigation.
552
+
553
+ A session recalled through `owner` records the address it was established for,
554
+ so the check happens locally and costs nothing: a mismatch is a **miss**, the
555
+ trip takes an ordinary connect hop, and the account is checked there. A session
556
+ handed in as `opts.session` carries no address walletOps knows about, so it stays
557
+ unchecked — a caller holding one learned the address when it established one, and
558
+ that is where the check belongs.
552
559
 
553
560
  It is a declared **value** rather than a post-connect hook on purpose. The
554
561
  connect callback is a different document — in this ecosystem, studio-engine's
@@ -557,6 +564,89 @@ wallet callback view, which knows nothing about any consumer's flows — and
557
564
  connect to signing. A hook would be looked up on exactly the hop it exists to
558
565
  guard, come back empty, and be skipped in silence.
559
566
 
567
+ #### `owner` — one hop for a returning user
568
+
569
+ Sessions **never expire** on Phantom, Solflare or Backpack. All three vendors say
570
+ so in their own docs — recorded per wallet as `sessionsExpire: false`, each with
571
+ the `sessionDocs` URL it came from, in `wallet_transport.js`'s `PROFILES` table
572
+ (verified 2026-09-07). Nothing persisted one, so every
573
+ mobile signing trip paid **two app switches** — connect, then sign — and the
574
+ second one is where a real user's entry was lost on QA.
575
+
576
+ Declare an `owner` and the connect hop's session is written down. The next trip
577
+ skips straight to signing:
578
+
579
+ ```js
580
+ SolanaStudio.walletOps.run('contest_entry', { contestId: 12 }, {
581
+ provider: walletProvider.detect(),
582
+ owner: currentUser.id, // string or number; an object is refused
583
+ cluster: document.body.dataset.solanaCluster,
584
+ // ...
585
+ });
586
+ ```
587
+
588
+ Omit it and every trip behaves exactly as it did before — same hops, same journal
589
+ bytes, including a caller that supplies its own `opts.session`. Recovery and the
590
+ scope stamp are written only for a session **this gem recalled**; a session you
591
+ hand in stays yours to manage, because the gem has no record of it to forget.
592
+ The session lives in `SolanaStudio.walletSession`
593
+ (`solana_studio/wallet_journal.js`, a **separate record** from the journal: the
594
+ journal is single-use and expires in ten minutes, a session is reusable and does
595
+ not).
596
+
597
+ **The token is opaque.** It decodes to a 64-byte signature plus JSON, and nothing
598
+ in this gem reads any of it. The wallet is the only authority on whether a
599
+ session is still good, so a local parse can only produce a second opinion that is
600
+ wrong in one of two directions.
601
+
602
+ ##### What the host owes
603
+
604
+ | Call | When | Why |
605
+ |---|---|---|
606
+ | `run(..., { owner, cluster })` | every trip | scopes the session; without it nothing is stored |
607
+ | `resume(params, { owner, cluster })` | the callback page, for a **plain sign-in** connect | walletOps did not start that trip, so the owner can only come from here — and a sign-in session is what makes a user's *first* action one hop |
608
+ | `walletJournal.purge()` | logout / user switch | sweeps both records. **A session outliving a logout is a stranger signing.** |
609
+
610
+ > ⚠ **`purge()` is a call you must ADD, not one you already make.** It sweeps the
611
+ > `wallet_dl_` prefix. A host carrying a `phantom_dl_`-era sweep from before this
612
+ > gem existed — turf-monster's layout does — clears **neither** record, because
613
+ > the prefixes do not match. Adopt `owner` and that call in the same change:
614
+ > without it a session that *never expires*, and the dapp encryption secret
615
+ > stored beside it, both survive a logout on a shared device.
616
+
617
+ A trip started through `run` carries its own owner in the journal, so `resume`
618
+ needs nothing for it. The stamp is read from the journal in preference to
619
+ `opts.owner` deliberately: the journal says who *started* the trip, and stamping
620
+ the user who happens to be signed in when the wallet answers is the one outcome
621
+ that could hand a session to someone who never established it.
622
+
623
+ ##### When the wallet refuses a stored session
624
+
625
+ It can, and vendor docs name the causes: an **explicit disconnect**, a **wallet
626
+ keypair change**, the **user switching networks**, and an **`app_url`
627
+ blocklisting**. The refusal arrives on the signing callback — by which point the
628
+ user has already left for their wallet and come back, so failing there loses
629
+ whatever they were doing.
630
+
631
+ So the trip does not fail. walletOps forgets the session and takes the hop it
632
+ skipped: a connect, carrying the **same intent**, which the ordinary connect
633
+ callback picks up and advances to signing on its own. The user pays one extra app
634
+ switch — the two-hop cost they would have paid anyway — instead of starting over.
635
+
636
+ - **`prepare()` is not re-run.** The journalled intent is reused, so a
637
+ prepared-transaction row minted for the first attempt is the one that gets
638
+ signed. Re-preparing would strand the first and mint a second for one user
639
+ action.
640
+ - **Recovery happens at most once**, structurally: the retry intent carries no
641
+ recovery block, so a second refusal surfaces the wallet's own error.
642
+ - **A user rejection (`4001`) is not a refusal.** The session is fine, the user
643
+ said no, and it reaches the caller as itself.
644
+
645
+ Its lifetime ends at exactly four events — a wallet refusal, a scope change
646
+ (different user, wallet or cluster), `purge()`, or `walletSession.forget()`.
647
+ There is **no timer**, because inventing one would contradict the vendor fact the
648
+ feature rests on.
649
+
560
650
  #### `signOnly`
561
651
 
562
652
  A **co-signed** transaction cannot be broadcast by the wallet: the chain rejects
@@ -1,10 +1,44 @@
1
- // SolanaStudio.walletJournal — the only thing that survives the page's death.
1
+ // The redirect transport's STORAGE — the only thing that survives the page's
2
+ // death. Two records, two opposite lifetimes, one store.
3
+ //
4
+ // SolanaStudio.walletJournal — ONE TRIP. Single-use, expiring.
5
+ // SolanaStudio.walletSession — ONE WALLET, ONE USER. Long-lived, reusable.
2
6
  //
3
7
  // A redirect destroys the document. Whatever the app needs on the other side has
4
8
  // to be written down first, and this is where. Everything else about the
5
9
  // redirect transport is pure functions over data; this file is the one place
6
10
  // that touches storage, which is why it is small and separately testable.
7
11
  //
12
+ // WHY THE SESSION LIVES HERE AND NOT IN A FILE OF ITS OWN, since the two records
13
+ // could hardly be more different. Everything about reaching localStorage safely
14
+ // — the guarded accessor, the three browser states it really presents, the
15
+ // quota branch, the corrupt-entry drop — is one algorithm, and a second file
16
+ // would be a second copy of it. This codebase has already been bitten by exactly
17
+ // that (see the B58_ALPHABET note in wallet_transport.js, and the "one factory,
18
+ // not three adapters" note in redirect_provider.js). What must be separate is
19
+ // the RECORD, not the file, and the records below share no key, no lifetime and
20
+ // no reader.
21
+ //
22
+ // It also keeps `purge()` whole: ONE call clears both records, because both are
23
+ // stored under this PREFIX. Split them and a host would have to learn a second
24
+ // call, and the one it forgot would be the session.
25
+ //
26
+ // ⚠ BUT `purge()` IS NOT WIRED UP IN THIS ECOSYSTEM TODAY, and the difference
27
+ // between "a call a host already makes" and "a call a host MUST add" is a
28
+ // stranger signing. No consumer calls it: turf-monster's layout sweeps the
29
+ // `phantom_dl_` prefix from the era before this gem existed, and THIS subsystem's
30
+ // prefix is `wallet_dl_`, so that sweep clears NEITHER record. Exposure is zero
31
+ // only because no consumer passes `owner` yet — the first one that does inherits
32
+ // a session that never expires AND the dapp secret key beside it, both surviving
33
+ // a logout on a shared device. Adopting `owner` means adding the purge call in
34
+ // the same change. See the host-owes table in the README.
35
+ //
36
+ // THE NAME ON THE FILE IS NOW NARROWER THAN ITS CONTENTS, and that is a
37
+ // deliberate cost, not an oversight. The path is shipped: consumers load
38
+ // `solana_studio/wallet_journal.js` from a script tag, lib/solana_studio/engine.rb
39
+ // precompiles it by name, and test/gemspec_test.rb pins it. Renaming a shipped
40
+ // asset path to improve a noun breaks every consumer at once.
41
+ //
8
42
  // WHY NOT sessionStorage. The wallet round trip leaves the browser entirely and
9
43
  // may return in a NEW TAB — iOS in particular does not guarantee the originating
10
44
  // tab is what receives a universal link. sessionStorage is per-tab and would be
@@ -29,6 +63,7 @@
29
63
 
30
64
  var PREFIX = 'wallet_dl';
31
65
  var KEY = PREFIX + '_journal';
66
+ var SESSION_KEY = PREFIX + '_session';
32
67
 
33
68
  // Ten minutes. Long enough for a human to read a wallet approval screen,
34
69
  // think, and approve; short enough that an abandoned trip is gone before it
@@ -48,11 +83,17 @@
48
83
  }
49
84
  }
50
85
 
51
- function save(journal) {
86
+ // --- the shared record primitives ----------------------------------------
87
+ //
88
+ // Both records go through these. Written once because the interesting part is
89
+ // not the JSON, it is the failure handling around it, and two copies of that
90
+ // is how one of them quietly stops guarding.
91
+
92
+ function writeRecord(key, record) {
52
93
  var s = store();
53
- if (!s || !journal) return false;
94
+ if (!s || !record) return false;
54
95
  try {
55
- s.setItem(KEY, JSON.stringify(journal));
96
+ s.setItem(key, JSON.stringify(record));
56
97
  return true;
57
98
  } catch (e) {
58
99
  // Quota, private mode, or a disabled store. The caller is about to
@@ -62,29 +103,44 @@
62
103
  }
63
104
  }
64
105
 
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() {
106
+ function readRecord(key) {
68
107
  var s = store();
69
108
  if (!s) return null;
70
109
  var raw;
71
110
  try {
72
- raw = s.getItem(KEY);
111
+ raw = s.getItem(key);
73
112
  } catch (e) {
74
113
  return null;
75
114
  }
76
115
  if (!raw) return null;
77
116
 
78
- var journal;
117
+ var record;
79
118
  try {
80
- journal = JSON.parse(raw);
119
+ record = JSON.parse(raw);
81
120
  } catch (e) {
82
121
  // Corrupt entry: drop it rather than leave it to fail every future read.
83
- clear();
122
+ removeRecord(key);
84
123
  return null;
85
124
  }
125
+ if (!record || typeof record !== 'object') { removeRecord(key); return null; }
126
+ return record;
127
+ }
86
128
 
87
- if (!journal || typeof journal !== 'object') { clear(); return null; }
129
+ function removeRecord(key) {
130
+ var s = store();
131
+ if (!s) return;
132
+ try { s.removeItem(key); } catch (e) { /* nothing to do and nothing to say */ }
133
+ }
134
+
135
+ function save(journal) {
136
+ return writeRecord(KEY, journal);
137
+ }
138
+
139
+ // Read WITHOUT clearing. For a callback page that wants to inspect before
140
+ // committing to advancing — the resume path uses take().
141
+ function peek() {
142
+ var journal = readRecord(KEY);
143
+ if (!journal) return null;
88
144
 
89
145
  if (typeof journal.startedAt === 'number' && (Date.now() - journal.startedAt) > MAX_AGE_MS) {
90
146
  // EXPIRED IS NOT AN ERROR, it is an answer. Clearing here means the next
@@ -105,14 +161,19 @@
105
161
  }
106
162
 
107
163
  function clear() {
108
- var s = store();
109
- if (!s) return;
110
- try { s.removeItem(KEY); } catch (e) { /* nothing to do and nothing to say */ }
164
+ removeRecord(KEY);
111
165
  }
112
166
 
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.
167
+ // Purge every key this subsystem owns — BOTH records. A host MUST call this on
168
+ // logout and user switch: a journal belongs to the person who started it, and
169
+ // one that outlived a logout would offer to resume a stranger's signature. The
170
+ // wallet session is the same hazard with a much longer fuse — it never expires
171
+ // — and it is swept by this same call because it shares the PREFIX.
172
+ //
173
+ // MUST, not DOES. Nothing in this ecosystem calls it yet; turf-monster sweeps
174
+ // the older `phantom_dl_` prefix, which does not match `wallet_dl_` and so
175
+ // clears neither record. A consumer adopting `owner` owes this call in the same
176
+ // change.
116
177
  function purge() {
117
178
  var s = store();
118
179
  if (!s) return;
@@ -151,4 +212,179 @@
151
212
  }
152
213
  }
153
214
  };
215
+
216
+ // === SolanaStudio.walletSession ==========================================
217
+ //
218
+ // THE RECORD THAT MAKES A RETURNING USER ONE HOP. Every mobile signing trip
219
+ // costs two app switches today — connect, then sign — and the second one is
220
+ // paid over and over for a session that, per Phantom's, Solflare's and
221
+ // Backpack's own docs (verified 2026-09-07; recorded per wallet as
222
+ // `sessionsExpire: false` alongside a `sessionDocs` URL in wallet_transport.js's
223
+ // PROFILES table), NEVER EXPIRES. Nothing wrote it down, so nothing could reuse
224
+ // it. This is the writing down.
225
+ //
226
+ // THE OPPOSITE OF THE JOURNAL IN EVERY DIMENSION THAT MATTERS, which is why it
227
+ // is a separate record rather than another field on one:
228
+ //
229
+ // journal session
230
+ // reads take() — clears recall() — leaves it in place
231
+ // life 10 minutes until invalidated (see below)
232
+ // scope one trip one wallet, for one user
233
+ //
234
+ // Share the record and every completed trip would take() the session away with
235
+ // it, which is precisely the two-hop behaviour this exists to remove.
236
+ //
237
+ // WHAT INVALIDATES IT — the complete list, and there is no timer in it:
238
+ //
239
+ // 1. The WALLET refuses it. Vendor docs name the causes: an explicit
240
+ // disconnect, a wallet keypair change, the user switching networks, an
241
+ // app_url blocklisting. walletOps forgets the session and recovers the
242
+ // trip through a connect hop; see wallet_ops.js.
243
+ // 2. The SCOPE no longer matches — a different user, wallet or cluster.
244
+ // That is a miss, not an error: the caller takes an ordinary connect hop.
245
+ // 3. purge(), which a host calls on logout or user switch.
246
+ // 4. forget(), for an explicit disconnect in the app's own UI.
247
+ //
248
+ // No max age, deliberately. Inventing one would contradict the vendor fact
249
+ // this feature rests on and would re-introduce the second hop on a schedule.
250
+ //
251
+ // THE TOKEN IS OPAQUE AND STAYS OPAQUE. It decodes to a 64-byte signature plus
252
+ // JSON carrying app_url, timestamp, chain and cluster — and none of that is
253
+ // read here, on purpose. The WALLET is the only authority on whether a session
254
+ // is still good; a local parse can only ever produce a second opinion that is
255
+ // wrong in one of two directions. Nothing below decodes, validates, or
256
+ // inspects `credentials.session`. It is stored as given and handed back as
257
+ // given.
258
+ //
259
+ // WHAT IS STORED, AND THE ONE THING WORTH SAYING OUT LOUD. The credentials
260
+ // block is exactly the four fields the signing hop needs, and one of them is
261
+ // the dapp's ephemeral secret key. redirect_provider.js explains why that is
262
+ // safe — a per-connect x25519 keypair that cannot sign, spend or authorise
263
+ // anything, whose loss costs one reconnect — and that argument is unchanged
264
+ // here. What DOES change is its lifetime: it now lives until an invalidation
265
+ // above rather than ten minutes. The blast radius is still "can decrypt this
266
+ // app's wallet replies on this device", and purge() on logout is what keeps it
267
+ // from outliving the person it belongs to.
268
+ //
269
+ // NO user private key, NO unsigned transaction bytes, NO personal data — the
270
+ // same rule the journal keeps, and the owner token below is the host's own
271
+ // opaque handle, not an email or a name.
272
+ var SESSION_VERSION = 1;
273
+
274
+ // A scope value, reduced to the string it will be compared as. Numbers are
275
+ // accepted because a host's user id usually IS one; anything else is refused
276
+ // by owner() below rather than stringified.
277
+ function scopeValue(v) {
278
+ return (v === null || v === undefined) ? '' : String(v);
279
+ }
280
+
281
+ // The owner is the whole reason a session cannot be a stranger's.
282
+ //
283
+ // REFUSED RATHER THAN STRINGIFIED, and this is the guard that matters most in
284
+ // the file. An object handed in here would String() to '[object Object]' —
285
+ // one token that EVERY user matches — and the failure would be a stranger
286
+ // signing with a wallet session they never established, on a shared device,
287
+ // discovered by nobody. A string or a number is a real handle; anything else
288
+ // is a bug, and an unremembered session costs one extra app switch.
289
+ function ownerToken(owner) {
290
+ var t = typeof owner;
291
+ if (t !== 'string' && t !== 'number') return null;
292
+ var s = String(owner);
293
+ return s === '' ? null : s;
294
+ }
295
+
296
+ // Persist the session for (owner, wallet, cluster). Returns whether it stuck —
297
+ // a caller that cannot store one simply keeps paying the connect hop, which is
298
+ // the behaviour it had before this record existed.
299
+ //
300
+ // ANONYMOUS IS REFUSED. A session with nobody to scope it to cannot be kept
301
+ // away from the next person at this browser, and there is no logout event on
302
+ // an anonymous page for purge() to ride. Not remembering costs one hop; the
303
+ // alternative costs a signature.
304
+ function remember(record) {
305
+ if (!record) return false;
306
+ var owner = ownerToken(record.owner);
307
+ if (!owner) return false;
308
+
309
+ var wallet = scopeValue(record.wallet);
310
+ if (!wallet) return false;
311
+
312
+ var c = record.credentials;
313
+ // ALL FOUR OR NONE. The signing hop re-derives the shared secret from
314
+ // walletPublicKey + dappSecretKey and puts `session` in the payload; a
315
+ // record missing any of them would be recalled, used, and fail inside the
316
+ // codec — a decryption error standing in for a storage bug, one page death
317
+ // from here.
318
+ if (!c || !c.session || !c.walletPublicKey || !c.dappSecretKey || !c.dappPublicKey) return false;
319
+
320
+ return writeRecord(SESSION_KEY, {
321
+ v: SESSION_VERSION,
322
+ owner: owner,
323
+ wallet: wallet,
324
+ cluster: scopeValue(record.cluster),
325
+ // The address this session signs as. Stored so a declared expectedAccount
326
+ // can be checked BEFORE the trip rather than discovered by a wallet
327
+ // refusal two app switches later.
328
+ publicKey: record.publicKey ? String(record.publicKey) : null,
329
+ credentials: {
330
+ dappSecretKey: c.dappSecretKey,
331
+ dappPublicKey: c.dappPublicKey,
332
+ walletPublicKey: c.walletPublicKey,
333
+ session: c.session
334
+ },
335
+ rememberedAt: Date.now()
336
+ });
337
+ }
338
+
339
+ // The session for this exact scope, or null. `scope`:
340
+ // { owner, wallet, cluster, expectedAccount } — expectedAccount optional.
341
+ //
342
+ // EXACT MATCH ON ALL THREE SCOPE FIELDS, and a miss is an ordinary answer that
343
+ // costs a connect hop. There is ONE stored session rather than a table keyed
344
+ // by scope, which is the honest model: a user who switches wallet or network
345
+ // has changed their mind, and evicting the old one keeps "a stranger cannot
346
+ // read this" a property of one stamped record instead of an invariant over a
347
+ // growing set.
348
+ function recall(scope) {
349
+ scope = scope || {};
350
+ var owner = ownerToken(scope.owner);
351
+ if (!owner) return null;
352
+
353
+ var record = readRecord(SESSION_KEY);
354
+ if (!record) return null;
355
+
356
+ // A VERSION WE DO NOT UNDERSTAND READS AS NO SESSION — note the contrast
357
+ // with the journal, which THROWS on the same event. The journal is a trip in
358
+ // flight, where carrying on would decrypt garbage and the only safe answer
359
+ // is to stop loudly. Nothing is in flight here, and "connect again" is a
360
+ // complete and correct answer, so a stale shape costs one hop and no words.
361
+ if (record.v !== SESSION_VERSION) { forget(); return null; }
362
+
363
+ if (record.owner !== owner) return null;
364
+ if (record.wallet !== scopeValue(scope.wallet)) return null;
365
+ if (record.cluster !== scopeValue(scope.cluster)) return null;
366
+
367
+ // The account guard, applied where it is FREE. walletOps checks a declared
368
+ // expectedAccount on the connect callback; a warm session takes no connect
369
+ // hop, so without this the check would silently not happen on exactly the
370
+ // trips this feature adds. A wallet keypair change would eventually be
371
+ // refused by the wallet anyway — this turns two app switches and a refusal
372
+ // into an immediate, correct connect hop.
373
+ if (scope.expectedAccount && String(scope.expectedAccount) !== String(record.publicKey)) return null;
374
+
375
+ if (!record.credentials || !record.credentials.session) { forget(); return null; }
376
+ return record;
377
+ }
378
+
379
+ function forget() {
380
+ removeRecord(SESSION_KEY);
381
+ }
382
+
383
+ W.SolanaStudio.walletSession = {
384
+ KEY: SESSION_KEY,
385
+ VERSION: SESSION_VERSION,
386
+ remember: remember,
387
+ recall: recall,
388
+ forget: forget
389
+ };
154
390
  })(typeof window !== 'undefined' ? window : globalThis);
@@ -17,9 +17,33 @@
17
17
  //
18
18
  // walletOps.run('contest_entry', { contestId: 12 }, {
19
19
  // provider: ...,
20
- // expectedAccount: '<the address this account is linked to>' // optional
20
+ // expectedAccount: '<the address this account is linked to>', // optional
21
+ // owner: currentUser.id // optional
21
22
  // });
22
23
  //
24
+ // ONE HOP FOR A RETURNING USER — WHAT `owner` BUYS. Sessions never expire on
25
+ // Phantom, Solflare or Backpack (all three vendors' docs, verified 2026-09-07,
26
+ // recorded per wallet as `sessionsExpire: false` in wallet_transport.js's
27
+ // PROFILES table, each with the doc URL it came from). Nothing persisted one, so
28
+ // every mobile signing trip paid TWO app switches — connect, then sign — and the
29
+ // second one is where a real user's entry was lost on QA. Declare an `owner` and
30
+ // the connect hop's session is remembered, scoped to that user, that wallet and
31
+ // that cluster; the next trip skips straight to signing. Omit it and every trip
32
+ // behaves exactly as it did before, journal bytes included.
33
+ //
34
+ // The session lives in SolanaStudio.walletSession (wallet_journal.js), which
35
+ // documents its own lifetime and the four things that invalidate it. One of them
36
+ // is this file's to handle and is handled below: a wallet that refuses a stored
37
+ // session mid-trip recovers through a connect hop rather than losing the user's
38
+ // work (recoverThroughConnect).
39
+ //
40
+ // ⚠ ANOTHER IS THE HOST'S AND IS NOT WIRED UP TODAY. A logout must call
41
+ // walletJournal.purge(), which sweeps the session with the journal — and no
42
+ // consumer does: turf-monster sweeps the older `phantom_dl_` prefix, which does
43
+ // not match this subsystem's `wallet_dl_`. Passing `owner` without adding that
44
+ // call leaves a never-expiring session, and the dapp secret key beside it,
45
+ // alive across a logout on a shared device. Adopt them together.
46
+ //
23
47
  // `signOnly` IS A REQUIREMENT OF THE TRANSACTION, NOT A PREFERENCE ABOUT THE
24
48
  // WALLET, and it is the intent's to declare because only the intent knows the
25
49
  // shape of the bytes it prepared. A CO-SIGNED transaction — one whose second
@@ -182,6 +206,14 @@
182
206
  // from connect to signing. A hook would therefore be unreachable on exactly
183
207
  // the hop it exists to guard, and would silently not run there — the worst of
184
208
  // the three outcomes. A string survives the redirect; a function does not.
209
+ // A cluster reduced to the string walletSession will compare it as. Absent,
210
+ // null and '' all collapse to the same value on both sides, so a host that
211
+ // never declares a cluster still gets a consistent scope rather than a session
212
+ // it can store and never recall.
213
+ function scopeCluster(cluster) {
214
+ return (cluster === null || cluster === undefined) ? '' : String(cluster);
215
+ }
216
+
185
217
  function shortAddress(address) {
186
218
  var s = String(address);
187
219
  return s.length > 12 ? s.slice(0, 4) + '…' + s.slice(-4) : s;
@@ -358,7 +390,73 @@
358
390
  // Already connected? Go straight to signing. Otherwise connect first and
359
391
  // carry the intent through — sessions do not expire on any of the three
360
392
  // wallets, so this branch is taken once per user, not once per action.
393
+ //
394
+ // THAT SENTENCE WAS ONLY TRUE FOR A CALLER THAT KEPT THE SESSION ITSELF.
395
+ // Nothing did, so every trip paid the connect hop and the "once per user"
396
+ // claim described a code path nobody was on. `opts.owner` is what makes it
397
+ // literal: walletSession recalls the stored session for this exact
398
+ // (owner, wallet, cluster), and a returning user signs in ONE hop.
361
399
  var existing = opts.session || null;
400
+ // WHETHER *WE* RECALLED IT, which is a different question from "is there a
401
+ // session". A caller that hands in `opts.session` owns that session: this
402
+ // file did not store it, has no record of it, and must not offer to
403
+ // recover or forget it. Recovery is a service for the sessions walletOps
404
+ // itself recalled, and this flag is what keeps the two apart.
405
+ var recalled = false;
406
+ if (!existing && opts.owner) {
407
+ var stored = studio().walletSession.recall({
408
+ owner: opts.owner,
409
+ wallet: provider.key,
410
+ cluster: opts.cluster,
411
+ // Checked HERE, where it is free. A warm trip takes no connect hop, so
412
+ // this is the only place a declared expectation can be honoured before
413
+ // the user is committed. A mismatch is a miss, not an error — the trip
414
+ // falls through to the connect hop below and is checked there.
415
+ expectedAccount: opts.expectedAccount
416
+ });
417
+ if (stored) { existing = stored.credentials; recalled = true; }
418
+ }
419
+
420
+ // A TRIP THAT SKIPS THE CONNECT HOP HAS NO CONNECT HOP TO FALL BACK ON,
421
+ // so it writes down how to build one. The wallet can refuse a stored
422
+ // session mid-trip — vendor docs name an explicit disconnect, a keypair
423
+ // change, a network switch and an app_url blocklisting — and by then the
424
+ // user has already committed. The refusal arrives on the SIGNING callback,
425
+ // a different document that knows none of these values, so they travel in
426
+ // the journal for the same reason signOnly and expectedAccount do.
427
+ //
428
+ // `scope` is WHO this trip belongs to; `recovery` is HOW to rebuild the
429
+ // connect. They are deliberately separate records, on two different
430
+ // conditions:
431
+ //
432
+ // scope — whenever an owner is declared, warm or COLD. The cold trip
433
+ // is where a session is first learned, and the connect
434
+ // callback is a different document that may know no owner of
435
+ // its own. Stamping it from the journal is also the SAFER
436
+ // read: the session belongs to whoever started the trip, not
437
+ // to whoever happens to be signed in at the browser when the
438
+ // wallet answers.
439
+ // recovery — only on a trip that skipped the connect hop USING A SESSION
440
+ // WE RECALLED. Gated on `recalled`, not on `existing`: a
441
+ // caller-supplied `opts.session` also skips the hop, but this
442
+ // file has no record of it, so forgetting it would evict a
443
+ // DIFFERENT scope's session and the recovery would have no
444
+ // cluster to rebuild the connect with. The retry keeps scope
445
+ // and drops recovery, which bounds recovery to once. See
446
+ // recoverThroughConnect.
447
+ //
448
+ // Both set ONLY when the new options are used, so a caller that declares
449
+ // no owner writes a journal byte-identical to the one it wrote before this
450
+ // feature existed — INCLUDING one that supplies its own `opts.session`,
451
+ // which is the case the `existing` gate used to get wrong. Same rule
452
+ // signOnly follows, and the same reason JOURNAL_VERSION does not move.
453
+ if (opts.owner) {
454
+ intent.scope = { owner: String(opts.owner), cluster: scopeCluster(opts.cluster) };
455
+ }
456
+ if (recalled) {
457
+ intent.recovery = { appUrl: opts.appUrl || null, redirectLink: opts.redirectLink || null };
458
+ }
459
+
362
460
  var begun = existing
363
461
  ? beginSigning(provider, intent, existing, opts)
364
462
  : provider.beginConnect({
@@ -429,6 +527,127 @@
429
527
  : provider.beginSignTransaction(payload);
430
528
  }
431
529
 
530
+ // --- the persisted session -----------------------------------------------
531
+ //
532
+ // WRITTEN ON THE CONNECT CALLBACK, which is the one moment this transport
533
+ // holds all four credential fields AND the address they belong to. Everything
534
+ // after this is a lookup.
535
+ //
536
+ // The scope is read off the JOURNAL first and `opts` only as a fallback, and
537
+ // the order is the safety property, not a convenience. The journal says who
538
+ // STARTED the trip; `opts` says who is signed in at the browser when the
539
+ // wallet answers. Those are the same person in every ordinary case, and when
540
+ // they differ — a shared phone, a logout mid-trip — stamping the CURRENT user
541
+ // onto a wallet session someone else established is the one outcome that lets
542
+ // a stranger sign. Stamping the originator cannot: the next recall names a
543
+ // different owner and misses.
544
+ //
545
+ // A trip that declared no owner anywhere is simply not remembered. That is the
546
+ // behaviour every caller had before this existed, and it costs one app switch,
547
+ // not a signature.
548
+ function rememberSession(journal, connected, opts) {
549
+ var intent = connected.journal && connected.journal.intent;
550
+ var scope = (intent && intent.scope) || null;
551
+ var owner = (scope && scope.owner) || opts.owner || null;
552
+ if (!owner) return false;
553
+
554
+ return studio().walletSession.remember({
555
+ owner: owner,
556
+ wallet: journal.wallet,
557
+ cluster: scope ? scope.cluster : scopeCluster(opts.cluster),
558
+ publicKey: connected.publicKey,
559
+ credentials: {
560
+ dappSecretKey: connected.journal.dappSecretKey,
561
+ dappPublicKey: connected.journal.dappPublicKey,
562
+ walletPublicKey: connected.journal.walletPublicKey,
563
+ session: connected.session
564
+ }
565
+ });
566
+ }
567
+
568
+ // THE REFUSAL PATH, AND THE REASON THIS FEATURE IS NOT JUST A CACHE.
569
+ //
570
+ // A stored session can be refused by the wallet mid-trip — vendor docs name an
571
+ // explicit disconnect, a wallet keypair change, the user switching networks,
572
+ // and an app_url blocklisting — and it is refused at the WORST possible
573
+ // moment: the user has already left for their wallet app and come back, so
574
+ // they are committed. Failing there loses whatever they were doing, which is
575
+ // exactly the class of loss this epic exists to remove (a real user's entry
576
+ // was lost on QA in this second hop).
577
+ //
578
+ // So the trip does not fail; it takes the hop it skipped. Forget the session,
579
+ // navigate to connect carrying THE SAME INTENT, and the ordinary connect
580
+ // callback picks it up and advances to signing on its own. The user pays one
581
+ // extra app switch — the two-hop cost they would have paid anyway — instead of
582
+ // starting over.
583
+ //
584
+ // `prepare()` IS NOT RE-RUN, and that is the point of reusing the journalled
585
+ // intent rather than rebuilding one. prepare MINTS things — turf-monster's
586
+ // mints a prepared-transaction row with a fresh blockhash — so re-running it
587
+ // would strand the first one and charge the flow twice for one user action.
588
+ // `intent.state` came back from the original prepare and is signed unchanged.
589
+ //
590
+ // ONCE, STRUCTURALLY. The retry intent carries `scope` but NOT `recovery`, so
591
+ // the hop it produces cannot recover again; a second refusal surfaces the
592
+ // wallet's own words. That is a shape, not a counter — there is no field to
593
+ // forget to decrement.
594
+ //
595
+ // AND IT RESTORES A GUARD THE WARM PATH GIVES UP. A session-skipping trip
596
+ // never learns which account connected, so a declared expectedAccount goes
597
+ // unchecked on it. The recovered trip goes through connect, where it IS
598
+ // checked — so the recovery from a keypair change ends in a sentence the user
599
+ // can act on rather than a chain error.
600
+ function recoverThroughConnect(provider, journal, opts, navigate) {
601
+ var intent = journal.intent;
602
+ var recovery = intent && intent.recovery;
603
+ // No recovery block means this trip already had a connect hop, or ran on a
604
+ // session this file never stored. There is nothing better to do than report
605
+ // what the wallet said.
606
+ if (!recovery) return null;
607
+
608
+ // A recovery block is only ever written beside a scope (both come from the
609
+ // same recall), so this is unreachable from a journal THIS release wrote.
610
+ // It is checked anyway because the alternative is silent and expensive: the
611
+ // cluster below would be null, `query()` drops a null, and the wallet would
612
+ // default the recovered connect to MAINNET-BETA — real funds on a trip that
613
+ // began on devnet. A journal from an older release is exactly the way that
614
+ // becomes reachable.
615
+ if (!intent.scope) return null;
616
+
617
+ // The redirect link is what the wallet returns to. Since #44, url.connect
618
+ // REFUSES to build without one, and that throw would escape this function
619
+ // and reach the user INSTEAD of the wallet's own error — a worse report
620
+ // about a different subject. Answer null and let the wallet speak.
621
+ var redirectLink = opts.redirectLink || recovery.redirectLink;
622
+ if (!redirectLink) return null;
623
+
624
+ // Only now, once the retry is certain to be buildable. Forgetting before
625
+ // this point would drop the session on a trip that then reports the wallet's
626
+ // error anyway, costing the user a stored session for nothing.
627
+ studio().walletSession.forget();
628
+
629
+ var again = { op: intent.op, ctx: intent.ctx, state: intent.state };
630
+ if (intent.signOnly) again.signOnly = true;
631
+ if (intent.expectedAccount) again.expectedAccount = intent.expectedAccount;
632
+ again.scope = intent.scope;
633
+
634
+ var begun = provider.beginConnect({
635
+ appUrl: recovery.appUrl,
636
+ redirectLink: redirectLink,
637
+ // THE CLUSTER THE TRIP BEGAN ON, never null. See the scope guard above.
638
+ cluster: intent.scope.cluster,
639
+ intent: again
640
+ });
641
+
642
+ // A store that cannot record the retry cannot complete it either. Returning
643
+ // null hands the caller back to the wallet's own error, which is a truer
644
+ // report than "could not record" for a user whose browser just refused a
645
+ // write.
646
+ if (!studio().walletJournal.save(begun.journal)) return null;
647
+ navigate(begun.url);
648
+ return { pending: true, suspended: true, recovered: true, url: begun.url };
649
+ }
650
+
432
651
  // Called by the callback page. Reads the pending journal, advances one step,
433
652
  // and either navigates again or hands back the finished result.
434
653
  //
@@ -458,6 +677,12 @@
458
677
  // next hop, which is what makes a transaction on a cold session two
459
678
  // navigations rather than two user-initiated attempts.
460
679
  if (!intent) {
680
+ // A PLAIN SIGN-IN IS THE MOST VALUABLE SESSION THERE IS, because it is
681
+ // the one every user establishes before they ever ask to sign
682
+ // anything. Remembering it here is what makes a returning user's FIRST
683
+ // action one hop rather than their second. The host owns this connect
684
+ // (walletOps did not start it), so the owner can only come from opts.
685
+ rememberSession(journal, connected, opts);
461
686
  return Promise.resolve({ pending: true, done: true, connect: connected });
462
687
  }
463
688
 
@@ -474,6 +699,12 @@
474
699
  // it established one, and that is where it belongs.
475
700
  assertExpectedAccount(intent.expectedAccount, connected.publicKey);
476
701
 
702
+ // AFTER the account guard, never before. A wrong wallet must not evict
703
+ // the session belonging to the right one — and a session stored for an
704
+ // account the caller has already refused is a record that can only ever
705
+ // be recalled into the same refusal.
706
+ rememberSession(journal, connected, opts);
707
+
477
708
  var next = signingHop(provider, connected.journal, {
478
709
  redirectLink: opts.redirectLink || journal.redirectLink
479
710
  });
@@ -492,9 +723,37 @@
492
723
  var handler = requireHandler(journal.intent && journal.intent.op);
493
724
 
494
725
  var wallet = journal.step === 'signAndSendTransaction';
495
- var out = wallet
496
- ? provider.completeSignAndSendTransaction(params, journal)
497
- : provider.completeSignTransaction(params, journal);
726
+ var out;
727
+ try {
728
+ out = wallet
729
+ ? provider.completeSignAndSendTransaction(params, journal)
730
+ : provider.completeSignTransaction(params, journal);
731
+ } catch (walletError) {
732
+ // WHICH FAILURES RECOVER, stated as one rule: an error the WALLET
733
+ // reported, that is not the user saying no.
734
+ //
735
+ // `code` is set only by throwIfWalletError, so its presence IS "the
736
+ // wallet answered with an error redirect" — the channel every
737
+ // documented refusal cause arrives on. `rejected` (4001) is carved out
738
+ // because a user who declined has a perfectly good session and being
739
+ // sent back to their wallet for another look is hostile.
740
+ //
741
+ // Deliberately WIDER than the four documented causes. Their codes are
742
+ // not pinned here, and guessing a narrow list wrong costs the user
743
+ // their entry — the exact failure this feature removes — while
744
+ // guessing wide costs one app switch and then surfaces the same error
745
+ // honestly, because the retry cannot recover again. When the two
746
+ // mistakes are that asymmetric, take the cheap one.
747
+ //
748
+ // DECRYPTION FAILURES ARE NOT IN THIS RULE, and that is on purpose:
749
+ // they carry no `code`, they are not a refusal channel any vendor
750
+ // documents, and a corrupt payload is a different finding that should
751
+ // read as itself rather than as a session problem.
752
+ if (!walletError.code || walletError.rejected) throw walletError;
753
+ var recovered = recoverThroughConnect(provider, journal, opts, navigate);
754
+ if (recovered) return Promise.resolve(recovered);
755
+ throw walletError;
756
+ }
498
757
  return Promise.resolve(handler.complete(journal.intent.ctx, {
499
758
  signature: out.signature || null,
500
759
  signedTransaction: out.transaction || null,
@@ -556,6 +815,22 @@
556
815
  typeof opts.expectedAccount + ' — call .toString() on a PublicKey first'
557
816
  ));
558
817
  }
818
+ // REFUSED RATHER THAN STRINGIFIED, and for a sharper reason than
819
+ // expectedAccount's. An object here would String() to '[object Object]' —
820
+ // ONE owner token that every user of this browser matches — and the
821
+ // symptom would be a stranger offered a one-hop signature with a wallet
822
+ // session they never established. walletSession.remember() refuses the
823
+ // same value on its own, so nothing can actually be stored under it; this
824
+ // check exists so the bug is named at the call site that wrote it rather
825
+ // than showing up as a feature that quietly never works.
826
+ if (opts.owner !== undefined && opts.owner !== null &&
827
+ typeof opts.owner !== 'string' && typeof opts.owner !== 'number') {
828
+ return Promise.reject(new Error(
829
+ 'walletOps.run owner must be a string or number identifying the ' +
830
+ 'signed-in user, got ' + typeof opts.owner + ' — an object would ' +
831
+ 'stringify to one token every user shares'
832
+ ));
833
+ }
559
834
  return opts.provider.transport === 'redirect'
560
835
  ? runRedirect(name, ctx, opts)
561
836
  : runInline(name, ctx, opts);
@@ -114,6 +114,15 @@
114
114
  // different from its own provider methods.
115
115
  browsePath: '/ul/browse/',
116
116
  clusters: ['mainnet-beta', 'testnet', 'devnet'],
117
+ // SESSIONS DO NOT EXPIRE. Phantom's "Handling Sessions" page states it
118
+ // outright: a session token stays valid until the user disconnects, the
119
+ // wallet's keypair changes, the user switches networks, or the app_url is
120
+ // blocklisted — there is no TTL. This is the fact SolanaStudio.walletSession
121
+ // is built on, so it is recorded HERE, as data, next to every other
122
+ // per-wallet fact, rather than asserted in a comment somewhere downstream.
123
+ // Verified 2026-09-07.
124
+ sessionsExpire: false,
125
+ sessionDocs: 'https://docs.phantom.com/phantom-deeplinks/handling-sessions',
117
126
  // DEPRECATED BY PHANTOM: "The signAndSendTransaction deeplink is
118
127
  // deprecated. Use signAllTransactions or signTransaction instead." So on
119
128
  // Phantom the APP still broadcasts — sendRawTransaction + a confirmation
@@ -136,6 +145,10 @@
136
145
  connectKeys: ['solflare_encryption_public_key'],
137
146
  browsePath: '/ul/v1/browse/',
138
147
  clusters: ['mainnet-beta', 'testnet', 'devnet'],
148
+ // Same as Phantom, which is expected — Solflare forked the spec and its
149
+ // docs link Phantom's own blocklist repo. Verified 2026-09-07.
150
+ sessionsExpire: false,
151
+ sessionDocs: 'https://docs.solflare.com/solflare/technical/deeplinks/provider-methods/connect',
139
152
  send: 'wallet-broadcasts',
140
153
  methods: {
141
154
  connect: true, disconnect: true, signMessage: true,
@@ -164,6 +177,13 @@
164
177
  // devnet cannot currently QA this wallet, which is a lane decision, not a
165
178
  // bug to paper over here. supportsCluster() reports it honestly.
166
179
  clusters: ['mainnet-beta'],
180
+ // Same as the other two. Backpack's session handling is the forked
181
+ // Phantom spec, and nothing in its corpus documents a TTL.
182
+ // Verified 2026-09-07 — and see the connectKeys note above: this vendor's
183
+ // docs are the least settled of the three, so this is the entry to
184
+ // re-confirm first if a session ever comes back refused for no reason.
185
+ sessionsExpire: false,
186
+ sessionDocs: 'https://docs.backpack.app/backpack-deeplinks/provider-methods/connect',
167
187
  send: 'wallet-broadcasts',
168
188
  methods: {
169
189
  connect: true, disconnect: true, signMessage: true,
@@ -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.9.3"
19
+ VERSION = "0.10.0"
20
20
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solana-studio
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.3
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie