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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b69fe07789c9b9f7f1db1627257d1bf48b9f4a0c8008d041737c56eeba6cf39c
4
- data.tar.gz: 73c86733f7b77f5ace0cec51a59c5cc003d2fc728ce45d868b429abd0963bf45
3
+ metadata.gz: e7dab4511993a7a64ace8ad5319dfe98f8436fba4ed8ce02a086b754df0f6a6c
4
+ data.tar.gz: 8c95f186599f9d825b58756f8932577801485ebc6e621b9725eb34563c2ffa51
5
5
  SHA512:
6
- metadata.gz: ba01f48f7ae0f245db23c4d782b7b195f7cc7bea2e6dc59c8e657a2226e59d616fe09cc2ac88cc8024575bb5835576b4f50e5d1c4fdf0275e97e0d417cb320f8
7
- data.tar.gz: ee63a339452249039f48ab8ce681d45e308a17c334230ec2cfd7887a05327529d9973694288a94d06f32333450a4a04ab2f7f26f9e5d5f4ef0c147db59c67754
6
+ metadata.gz: 539111d8afe1c593ca295bd37140c0cf9e437a08fff46db02bf87b39706d5187621d7769bd124808c07206823ffd5154f079049b96bc17bd4e748b718b32c0b5
7
+ data.tar.gz: 22d646247fad4661ad66b020b00034cfebe0c045106e04cf4a5fb9f36a4f558e2415d6d5b5e2d97c7677f85baeed988c1cf00e443adca8064c7905204b40c594
data/CHANGELOG.md CHANGED
@@ -5,17 +5,24 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
5
5
  ## Unreleased
6
6
 
7
7
  ### Added
8
+ - **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.
8
9
  - **An optional `on_click` local on the wallet sign-in button** (`app/views/solana_studio/auth/_wallet_credential.html.erb`). The click was hardcoded to swap straight to the wallet-connect picker, and that single line blocked Turf Monster's adoption: its board stages a contest lineup in `localStorage`, the picker's redirect is UNCONDITIONAL, and `pendingContestEntry` has exactly one writer app-wide with no `beforeunload` fallback and no server draft for guests — so the default handler navigates away with a staged lineup never written anywhere. Lost, not mis-routed. Turf passes `on_click: "openWalletHub()"` and lets its own board listener save the cart and swap. **The seam is the TAIL of the handler, not the whole of it**: `attested()` stays template text no local can reach, so a host cannot drop the legal-age gate by simply never calling it, which a seam carrying the whole handler could — and the button would look and behave perfectly normal. Keeping the gate out of the local guarantees it is CALLED; making it CONTROL the tail needed one more step, because both land in ONE JavaScript expression and precedence decides which wins. `&&` binds tighter than `||`, `?:` and comma, so an override built on any of those reparsed and ran its tail with the gate FALSE — measured in Chromium against the vendored Alpine 3.16.1, where `on_click: "saveCart() || openWalletHub()"` ran `openWalletHub()` with the box unticked. The override is now **parenthesised** before it is emitted, so any expression is safe to pass. Never reachable in a shipped release: the seam has not shipped, and byte-identity proves no host passes the local. A host that passes no locals renders **byte-identical** output, measured by rendering the partial before and after and diffing, not asserted.
9
10
  - **The wallet sign-in button, contributed into studio-engine's auth modal** (`app/views/solana_studio/auth/_wallet_credential.html.erb`). Part of the base/bolt-on split: studio-engine plus McRitchie Studio is the template every app is built from, web2 and web3 alike, and this gem plus Turf Monster is the web3 bolt-on. Sign-in is a base concern so the modal stays in the engine; **wallets are not**, so the button moved here. The engine renders whatever resolves at `solana_studio/auth/wallet_credential` and nothing when the path is empty, so bundling this gem IS the registration and a web2 app carries no wallet markup at all. Deliberately NOT solved by giving this gem its own auth modal: that would fork a surface both apps sign in through, which is how the wallet picker reached three drifting copies before it was promoted. The engine keeps both halves of the existing gate (`Studio.auth_method?(:wallet)` and `Studio.feature?(:web3)`) so the hub — which bundles this gem for its signing primitives with web3 off — still renders a web2 sign-in modal.
10
11
  - **The web3 modals move here** — `solana_studio/modals/wallet_connect`, `solana_studio/modals/web3_step_up`, `solana_studio/phantom_deeplink` and `solana_studio/deeplink_assets`, promoted out of studio-engine byte for byte. They are Solana-specific to the last line, and studio-engine is the engine EVERY app bundles, including apps that ship no chain feature at all. Only the home changes: the markup, the locals and the behaviour are identical, and the four files are added here before studio-engine drops its copies, so no consumer is ever without a home for them.
11
12
 
12
13
  ### Changed
14
+ - **Both catch blocks capture the wallet's RAW string before the mapper runs.** `raw` was previously an inline sub-expression consumed by the ternary and discarded; it is now a named binding sent alongside the mapped sentence. Only the pair makes a MIS-mapping diagnosable, and the 2026-09-06 incident WAS a correct mapper meeting a string it had never seen — with only the mapped half in hand there is nothing to notice. The 4001 decline path keeps the wallet's own words as `raw` while the user still reads `Signature rejected`.
15
+ - **The report call is wrapped in its own `try`/`catch`, which is NOT redundant with the reporter's.** This gem does not own the reporter; a host does, and a host's implementation may throw. At the step-up call site that is load-bearing rather than defensive: `this.connecting = false` sits OUTSIDE the catch block, so a throw from the reporter skips it and leaves the sign-in button disabled forever with an error the user cannot retry — and Alpine swallows a throw out of an async handler, so nothing anywhere would say so. An observer that can wedge the card is a worse bug than the darkness it was added to fix. The call is also the LAST statement in each block, after every user-facing assignment: observation may not delay, block or alter the line the user has just been handed.
16
+ - **Neither call site reports an error already tagged `walletFailureReported`.** A host's `solanaConnectAndVerify` substitutes its own sentence for an unusable wallet and reports the pair from in there, where the wallet's words still exist; reporting again from the surface files a second row whose `raw` and `mapped` are BOTH the host's sentence — the useless row, and the one an operator meets first.
13
17
  - **The wallet step-up card leads with the WALLET, not a padlock** (`app/views/solana_studio/modals/_web3_step_up.html.erb`). Four operator edits in one pass. (1) The card's mark is now the remembered brand's own sprite at 56px, falling back to the neutral billfold this file already draws for its no-brand button — it replaces a lock emoji, on the reasoning that a card asking someone to reach for a specific object should show them the object. Hand-rolled rather than passed to `studio/modals/blocks/_card_header`, because that block takes an emoji, a spinner or one of two fixed icons and has NO slot for a brand mark; its else-branch paints a check tinted with the host's `primary` token. Per the modal-lifecycle module the trigger for a primitive is a second consumer or a third copy, and this is the first — when a second card wants a brand header, add an icon slot to `card_header` rather than a second hand-rolled one. (2) The default `subtext` drops from four lines to one: *This account is secured by a Solana wallet. You are signed in, but this session can't sign on-chain — so on-chain actions still need your wallet.* becomes *Your account is secured by a Solana wallet.* **A host that overrides `subtext` never sees this** — see Notes for hosts. (3) The truncated address moved UP out of the footnote into the body, inside an `x-if` on `walletHint`, so the card names the wallet it is asking for where the sentence is rather than under the button. The footnote it replaces is gone. (4) The **full-width "Use a different wallet" row is gone**, replaced by a quiet **"Not your wallet?" link beside the address** — same `openPicker()` handler, a fraction of the layout, and the card still runs CTA → `Not now`. Both are operator calls. The link carries TWO gates and they answer different questions: the `x-if` is `walletHint` (no address, no sentence to correct) and the `x-show` is `canOneClick` (with no remembered brand the PRIMARY button is already the picker, so the link would be a second door into the same room). **Those two states are not the same** — an account that linked its wallet before the host stored the brand has an address and no brand — which is why the link needs both. Without it the address made a mismatch VISIBLE without making it ACTIONABLE: `providerMissing` is written in exactly one place, inside `signIn()` after the `reachable()` guard, so nothing auto-flips, and a remembered wallet that was simply the WRONG one had no route to the picker at all.
14
18
  - **The body block carries `mb-5`, and that is a FIX rather than a taste change.** The old callsite passed a BLOCK to `card_header`, which wraps `yield` in a `<p>` of its own — so the rendered card emitted `<p class="text-xs text-secondary mb-5"><p class="text-sm text-body">` and the HTML parser auto-closed it into a stray EMPTY paragraph. That empty paragraph WAS the body-to-CTA gap. Hand-rolling the header removed the accident, so the margin now has to be declared. Both new icon branches also carry `aria-hidden`, which `card_header`'s svg did not.
15
19
  - **The wallet button's visibility gate tolerates a host that never defined `methodOn`.** `x-show="methodOn('wallet')"` became `x-show="typeof methodOn === 'function' ? methodOn('wallet') : true"`. A bare call in a host without the member THROWS, Alpine grades the throw as falsy, and the button **silently never renders** — no error a user can see, no missing asset, nothing in the page to debug. That was the second, independent blocker on Turf Monster's adoption, and it is invisible by construction. MEASURED in Chromium against the vendored Alpine 3.16.1, driving the real rendered markup: without the guard the console carries `methodOn is not defined` and the button resolves to `display:none`; with it the button shows, and a host that DOES define `methodOn` still follows the toggle in **both** directions. The fallback is `true` rather than false because bundling the gem has already answered *is wallet implemented* — a host with no toggle has expressed no opinion about showing it, and defaulting to false rebuilds the same invisible button from the other side. The test stays in **Alpine** deliberately: Ruby decides whether the partial EXISTS and Alpine decides whether it SHOWS, and folding the visibility into Ruby collapses that split and brings back the floating-divider bug on a toggle page.
16
20
  - `.github/workflows/gem-ci.yml` asserts all four new partials are inside the BUILT gem, not just the manifest. `test/gemspec_test.rb` already asserts the invariant that every `app/` file reaches `spec.files`; the artifact check is a different claim, and only the artifact is what a consumer installs.
17
21
 
18
22
  ### Tests
23
+ - **`e2e/wallet_failure_report.spec.js` (7, new)** — the reporting seam in a real Chromium, against both real partials through the engine's real modal host. It is the only tier that can see it: every line under test is inlined JavaScript inside an `x-data` attribute, so a Ruby test can assert the characters shipped but cannot run them. **FAIL-OPEN IS PROVED BY A REAL THROW.** The trap on the consumer half of this feature was that `fetch()` RESOLVES on 4xx/5xx, so four specs stubbing a 500 stayed green with the error handling deleted; the equivalent trap here is different in shape and identical in kind, and only a host reporter that genuinely throws exercises the gem's guard. **10 mutants, all 10 killed**, each shown applied by its own diffstat against a 19/19 green baseline and reverted after: the picker sending the other surface's stage; `raw` and `mapped` swapped; `raw` captured AFTER the mapper so both halves are the mapped one; the 4001 branch overwriting `raw` with our sentence; the `try`/`catch` unwrapped at each call site separately; the `typeof` guard negated; the `walletFailureReported` check dropped; and the report REORDERED above the user-facing assignment. Two are recorded because the first measurement was wrong and the correction is the finding: negating the `typeof` guard SURVIVED the absent-reporter spec, because the surrounding `try`/`catch` swallows the resulting `TypeError` — the two guards are genuinely redundant AT THAT SEAM, each covering the other. It is killed by the present-reporter specs instead (a negated guard reports nothing at all), and removing the PAIR together takes the absent-reporter spec red, which is what proves that spec is not inert. Single-mutating either guard alone could never have shown this.
24
+ - **`test/e2e_lane_contract_test.rb`** — the new lab page is asserted to render both gem partials and the engine's real host BY NAME, and refuted from ever CALLING `window.reportWalletFailure`. The page legitimately DEFINES that global (it is the host's to provide and the lab is the host); a lab that also invoked it would satisfy every reporting spec while the gem's call sites never fired.
25
+ - `config/e2e_lane.yml` 12 -> 19 specs, derived with `npx playwright test --list` rather than counted by hand, per the file's own instruction.
19
26
  - **`test/views/web3_step_up_modal_test.rb` (17 -> 24)** — seven tests for the polish pass above, one of them the CONTROL that keeps the other six honest. **The first version of them was INERT and this is recorded because the failure mode is invisible from inside the file**: they asserted `'#se-wallet-' + provider` and `canOneClick` against the WHOLE rendered card, and both strings already lived in it — the first in the pre-existing 36px CTA row, the second in the x-data getter. Deleting the ENTIRE new brand header left the suite at 22 runs / 118 assertions / **0 failures** while a probe on the same tree confirmed the header was gone from the HTML (header div present true->false, `w-14 h-14` tiles 3->0, sprite-binding sites 2->1). **Six of nine mutants survived.** The fix is STRUCTURAL rather than a longer string: `brand_header` slices the element immediately ABOVE the heading and `header_branch` slices one `x-if` branch of it by its GUARD, so no assertion can be satisfied by markup elsewhere in the card and a swap of the two branches moves the markup to the other guard. `test_the_header_slice_can_come_up_empty` is the control — strip the header from the rendered HTML and the slice must find no branch — and it is not decorative: widening `brand_header` to the whole card takes it RED against the CTA row, which is the original bug reproduced. Re-measured on the SHIPPED tree across **20 mutants, every one KILLED, no survivors**, each shown to reach the file by its own diffstat against a baseline of **24 runs / 151 assertions / 0 failures**, restored clean after every one. The card: delete the whole header (+0/-15, 2 failures), swap its two branches (+5/-5), delete the billfold fallback (+0/-8), hardcode the mark to `#se-wallet-phantom` (+1/-1), drop both `x-if` guards (+0/-4), an HTML-entity padlock (+1/-6) and a literal-codepoint one (+1/-6) — **both** forms, because `&#128274;` renders identically and sails past a codepoint-only refutation — restore the alternate-wallet row (+4/-0), `mb-5` -> `mb-0` (+1/-1), and the margin drifting onto an empty neighbour (+2/-1, 4 failures). The correction link: delete it (+1/-3), move it OUTSIDE the `walletHint` guard (+4/-3), point it at `dismiss()` (+1/-1), drop the `canOneClick` gate (+1/-1), INVERT that gate (+1/-1), relabel it as the row it replaced (+1/-1, 2 failures), let it grow back to `w-full` (+1/-1), delete the address and keep the link (+1/-2, 2 failures), split the nowrap group so the link wraps onto its own line (+1/-1), and unguard the address line entirely (+0/-1, 2 failures). The link and the address are asserted SEPARATELY even though they share one block, because an assertion matching the block as a whole cannot say which half broke — the same shape as the inert-header bug.
20
27
  - **`test/views/auth_wallet_credential_test.rb`** (14, new) — the RENDER tier for the credential button, which this gem never had. It is the tier that can see this change at all: the default handler is assembled by ERB, so the string hosts depend on appears ZERO times in the template source and a source grep stays green on a partial whose default never reaches the attribute. Pins the default handler against the literal previous bytes, the default tracking `modal_store` (a hardcoded `"modals"` would give the style guide a dead button), the override replacing the default, the override leaving **no** trace of it, the age gate LEADING an override — narrowly, and the assertion now says so, because `start_with?` cannot see whether the gate also CONTROLS the tail — the handler reaching the page **unescaped** — read from the RAW html, never the parsed attribute, because Nokogiri decodes entities and the assertion would otherwise pass on the very bug it exists to catch — the default carrying no double quote that would close the attribute, and both halves of the visibility gate. Mutation-proven five ways, every mode red: dropping `raw`, hardcoding the store name, reverting the gate to the bare call, letting `on_click` swallow the age gate, and dropping the picker's props. A **behavioural tier** was added alongside the string assertions: it runs the rendered handler in a real JS engine, built the way Alpine builds it, and asserts the gate CONTROLS the tail for every top-level operator that binds looser than `&&`. It carries its own control — the pre-fix spelling, asserted to STILL bypass — so a harness that quietly stopped seeing anything goes red instead of green. Both were mutation-proven: blanking the harness took three tests red, pinning `attested()` to a constant took two.
21
28
  - `test/auth_credential_test.rb` (7 -> 11): the partial sits at the path the engine resolves, the click handler consults the modal's age gate, visibility binds to the shared `methodOn('wallet')` toggle, `modal_store` is required with no default, the swap carries the picker's `backTo`/`ageAttested` contract, the brand gradient id is namespaced against collisions, and the mark stays inline rather than becoming a sprockets asset request. Every assertion reads ERB-comment-stripped source, because this file documents its own contract in prose and a bare grep would match the paragraph instead of the code — proven by mutating the gate into a comment and watching it go red. The `on_click` assertion is bound to the CONCERN rather than one spelling of it: it pinned `fetch(:on_click) do`, which went red when the precedence fix traded that for a `key?` branch while preserving every property the assertion existed to protect. It now asserts the local is read through an accessor that tolerates its ABSENCE, which is the thing that actually matters. A new assertion pins the parentheses around an overridden tail, labelled as the WEAKER copy — it matches characters, and the render tier next door is the authority.
@@ -24,6 +31,9 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
24
31
  - **`test/erb_comment_leak_test.rb`** (6) — ported from studio-engine, whose glob stops covering these files the moment they leave it. An ERB comment ends at its FIRST close sequence, so a comment quoting a tag leaks its tail into the page as visible prose; the two most comment-dense files in the move (`_wallet_connect`, `_web3_step_up`, 8 comments each) were exactly the ones losing the guard. All three leak signatures ported with their guard-the-guard probes, and mutation-proved on a real moved file.
25
32
 
26
33
  ### Notes for hosts
34
+ - **Nothing changes for a host that does nothing.** `window.reportWalletFailure` is optional and guarded; a consumer that never defines it renders and behaves exactly as on 0.6.1, with wallet failures recorded nowhere — the state every consumer is in today. This is additive, and there is no migration.
35
+ - **To light the surface up you must build the endpoint; the gem cannot.** It mounts no routes, so define `window.reportWalletFailure(stage, provider, raw, mapped)` and a path for it to POST to. Four things are worth copying rather than re-deriving, each of which cost something to learn: (1) record **both** message halves — a mis-mapping is invisible in the mapped half alone; (2) **scrub `raw` server-side** — it is free text a WALLET composed, and a wallet can quote your own nonce back at you inside a field your key allowlist has already approved, so redact by VALUE as well as by key, and leave the pubkey alone because over-redaction blinds the tool; (3) make it **fire and forget** — no `await`, swallow your own faults, and answer the same status whether or not you recorded anything, so no caller can block a user on observation; (4) tag any error you rethrow after substituting your own sentence with `walletFailureReported = true`, and report it from where you made the substitution, or the surface files a second useless row. turf-monster's `app/javascript/solana_errors.js` and `Solana::ClientFailureReport` are the worked example.
36
+ - **Stage strings are a filter, so spell them exactly.** The picker sends `wallet_connect` and the step-up card sends `web3_step_up`. A host whose server normalises an unrecognised stage to `unknown` will still record the row, but it loses the one field triage runs on.
27
37
  - **The "Use a different wallet" row is GONE, and a host bound to it goes red at its next lock bump, not at this PR.** This gem ships only `.github/workflows/gem-ci.yml` — there is no consumer lane — so a green CI here cannot see a consumer at all. turf-monster bound to that row in TWO places: `test/controllers/web3_step_up_gallery_test.rb` ("a remembered wallet still offers a way to use another one", asserting both the label and `openPicker()`) and `e2e/web3_step_up.spec.js`, which does not merely assert copy — it clicks the row and walks step-up -> wallet-connect -> Back -> step-up, checking the props survive the round trip. It breaks at `bump_consumer_locks_for_qa` during the next `qa-release` sweep, which runs `bundle lock --update solana-studio --conservative` on every consumer. **THE ROUTE SURVIVES, ITS SPELLING DOES NOT.** A quiet **"Not your wallet?"** link beside the address calls the same `openPicker()` the row called, so a consumer's picker round trip is REPOINTED rather than deleted. Write that locator to accept EITHER spelling (`/Use a different wallet|Not your wallet\?/`) if the spec must be green on both sides of the bump — a consumer's own CI resolves the PUBLISHED gem, so a spec pinned to the new label is red on that consumer's PR and a spec pinned to the old one is red at the sweep. turf-monster's two files now do exactly that. The other unchanged route is the no-remembered-brand one: the "Connect your wallet" button IS `openPicker()` in both releases. Why the link exists at all, since the acceptance never named the cost: `providerMissing` is written in exactly ONE place (inside `signIn()`, after the `reachable()` guard) and nothing in `init()`, `refresh()` or the poll touches it, so there is no auto-flip — without the link a remembered-but-UNREACHABLE wallet had to press a button that could not work and read an error before the no-brand row appeared, and a remembered, REACHABLE, but WRONG wallet had no route to the picker at all. It is advisory either way: the session stays usable, `Not now` exits, and the help line still reaches a human.
28
38
  - **The default `subtext` is one line now, and a host that OVERRIDES it sees none of that.** turf-monster passed its own four-line `subtext:`, so the gem's default changed and the card a player actually met did not — it kept the long copy AND gained the new address line, which is more copy rather than less. If you override `subtext`, this release is the moment to decide whether you still mean to. This gem's own `test/views/web3_step_up_modal_test.rb` refutes the string `this session can`, which was live in a consumer's override at the time this shipped.
29
39
  - **RESOLVED — studio-engine's style-guide specimen no longer pins the OLD gate string.** This entry warned that `test/views/style_web3_specimens_test.rb` asserted the literal `x-show="methodOn('wallet')"`, which the new gate does not contain, and that it would go red the moment the engine bumped past this gem. Both halves have since moved and the warning is kept as provenance rather than as live guidance. Re-derived 2026-09-06: studio-engine's `Gemfile.lock` now resolves **solana-studio 0.6.0** (not the 0.5.3 this note was written against), and its assertion is now `assert_includes gate, "methodOn('wallet')"` — the SUBSTRING form this note asked for, landed by its own commit *Bind the wallet gate assertion to the CTA element*. The gate it reads, `typeof methodOn === 'function' ? methodOn('wallet') : true`, contains that substring, so the engine is green on the current pin. Nothing to do; the recorded reasoning stands, which is why it is corrected here rather than deleted.
data/README.md CHANGED
@@ -349,6 +349,65 @@ exists to prevent — so this is a documented host requirement, not something th
349
349
  gem can enforce from inside. `solana_studio/modals/network_mismatch` already
350
350
  shipped on exactly these terms.
351
351
 
352
+ #### The host JavaScript these modals reach for
353
+
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
359
+ card rather than breaking it, and the whole point of writing the list down is
360
+ that a consumer meets it here instead of rediscovering it.
361
+
362
+ | Global | Needed by | Absent means |
363
+ |---|---|---|
364
+ | `window.solanaConnectAndVerify(name, opts)` | both | **required** — nothing can connect |
365
+ | `window.walletProvider` | both | **required** — no wallet rows paint |
366
+ | `window.handleSolanaVerifySuccess(result)` | both | no post-verify hook runs |
367
+ | `window.startPhantomDeepLink(linkMode, userId)` | picker | the mobile Phantom row is hidden |
368
+ | `parseSolanaError(msg)` | both | the wallet's raw words are shown unmapped |
369
+ | `window.reportWalletFailure(stage, provider, raw, mapped)` | both | **this surface is dark** |
370
+
371
+ ##### `reportWalletFailure`, and why the endpoint is yours
372
+
373
+ Everything on the wallet surface fails **client-side**: the throw is caught,
374
+ mapped, and painted into a paragraph. Without this call nothing about it exists
375
+ outside the browser — which is how a user whose Phantom held no keypair was told
376
+ to check their USDC balance seven times in one production session (2026-09-06)
377
+ before an operator noticed by hand.
378
+
379
+ The gem calls it; it does not implement it. **The reporter and its endpoint are
380
+ host-owned because they need an `ErrorLog` this gem cannot assume** — the engine
381
+ here mounts no routes by design, so a gem-side reporter would POST at a path a
382
+ consumer is not guaranteed to have, and a report that 404s silently is strictly
383
+ worse than the silence it replaced: the surface still looks wired. Reference
384
+ implementation in turf-monster: `app/javascript/solana_errors.js`, receiving at
385
+ `POST /auth/solana/report_failure`.
386
+
387
+ The contract the call sites keep:
388
+
389
+ - **Called once per caught rejection**, with the stage for that surface —
390
+ `'wallet_connect'` from the picker, `'web3_step_up'` from the step-up card. A
391
+ stage the host does not recognise should be recorded, not refused; the report
392
+ is still worth having.
393
+ - **Both message halves, always.** `raw` is what the wallet said, captured before
394
+ the mapper runs; `mapped` is what the user read. A **mis-mapping** is invisible
395
+ in the mapped half alone, and that is the failure the 2026-09-06 incident
396
+ actually was — a correct mapper meeting a string it had never seen.
397
+ - **Skipped when the error is already tagged `walletFailureReported`.** Set that
398
+ property on any error your `solanaConnectAndVerify` rethrows after substituting
399
+ a sentence of your own, and report it from in there, where the wallet's words
400
+ still exist. Without the tag the same failure files a second row carrying your
401
+ sentence in both halves — the useless row, and the one an operator meets first.
402
+ - **Fire and forget.** The return value is ignored and a **throw is swallowed**.
403
+ Do not rely on that: a reporter that throws is a bug, and the guard exists
404
+ because an observer must never become the incident.
405
+ - **Four values, and only these four.** No signature, no nonce, no signed SIWS
406
+ message — the modals never hold any of them, so none can leak. `raw` is free
407
+ text a **wallet** composed, so scrub it **server-side**: a wallet can quote your
408
+ own nonce back at you inside a field your key allowlist has already approved. A
409
+ pubkey is fine and should be kept; over-redaction blinds the tool.
410
+
352
411
  #### The signed statement is not configurable
353
412
 
354
413
  `solana_studio/phantom_deeplink` emits `Studio.wallet_sign_in_statement`, and it
@@ -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);