solana-studio 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +57 -0
- data/app/views/solana_studio/modals/_wallet_connect.html.erb +74 -1
- data/app/views/solana_studio/modals/_web3_step_up.html.erb +44 -2
- data/lib/solana_studio/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 752d18d346d95e1960408a04f03b7879bb25f34dfa3851a233e9070db6a1ec4d
|
|
4
|
+
data.tar.gz: 4d7bcb63da94cb07279077dc525f35a0e11df1e104cad6538d67e15261311ae5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2d265a31b6049386a96f286cabd2abd0dbd94520c068cccc5827c58c5b407ee75d95875c20aec7d9bb8893891f6ec26bd02a28acc5d565a6c6efb5378906ee08
|
|
7
|
+
data.tar.gz: 62a5d328df9adf8f2959bc3137b4d07b7345ca5e83841dcc69f572d84f46b5fe78c62863482a50550a50d916178aefb67a5d14157c85c1eb16d499de4a9fd0d2
|
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 `🔒` 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,63 @@ 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 one JavaScript file
|
|
355
|
+
(`solana_studio/network_guard.js`) and **no routes at all**, so none of these can
|
|
356
|
+
live here. Each is reached behind a `typeof` guard: an absent one degrades the
|
|
357
|
+
card rather than breaking it, and the whole point of writing the list down is
|
|
358
|
+
that a consumer meets it here instead of rediscovering it.
|
|
359
|
+
|
|
360
|
+
| Global | Needed by | Absent means |
|
|
361
|
+
|---|---|---|
|
|
362
|
+
| `window.solanaConnectAndVerify(name, opts)` | both | **required** — nothing can connect |
|
|
363
|
+
| `window.walletProvider` | both | **required** — no wallet rows paint |
|
|
364
|
+
| `window.handleSolanaVerifySuccess(result)` | both | no post-verify hook runs |
|
|
365
|
+
| `window.startPhantomDeepLink(linkMode, userId)` | picker | the mobile Phantom row is hidden |
|
|
366
|
+
| `parseSolanaError(msg)` | both | the wallet's raw words are shown unmapped |
|
|
367
|
+
| `window.reportWalletFailure(stage, provider, raw, mapped)` | both | **this surface is dark** |
|
|
368
|
+
|
|
369
|
+
##### `reportWalletFailure`, and why the endpoint is yours
|
|
370
|
+
|
|
371
|
+
Everything on the wallet surface fails **client-side**: the throw is caught,
|
|
372
|
+
mapped, and painted into a paragraph. Without this call nothing about it exists
|
|
373
|
+
outside the browser — which is how a user whose Phantom held no keypair was told
|
|
374
|
+
to check their USDC balance seven times in one production session (2026-09-06)
|
|
375
|
+
before an operator noticed by hand.
|
|
376
|
+
|
|
377
|
+
The gem calls it; it does not implement it. **The reporter and its endpoint are
|
|
378
|
+
host-owned because they need an `ErrorLog` this gem cannot assume** — the engine
|
|
379
|
+
here mounts no routes by design, so a gem-side reporter would POST at a path a
|
|
380
|
+
consumer is not guaranteed to have, and a report that 404s silently is strictly
|
|
381
|
+
worse than the silence it replaced: the surface still looks wired. Reference
|
|
382
|
+
implementation in turf-monster: `app/javascript/solana_errors.js`, receiving at
|
|
383
|
+
`POST /auth/solana/report_failure`.
|
|
384
|
+
|
|
385
|
+
The contract the call sites keep:
|
|
386
|
+
|
|
387
|
+
- **Called once per caught rejection**, with the stage for that surface —
|
|
388
|
+
`'wallet_connect'` from the picker, `'web3_step_up'` from the step-up card. A
|
|
389
|
+
stage the host does not recognise should be recorded, not refused; the report
|
|
390
|
+
is still worth having.
|
|
391
|
+
- **Both message halves, always.** `raw` is what the wallet said, captured before
|
|
392
|
+
the mapper runs; `mapped` is what the user read. A **mis-mapping** is invisible
|
|
393
|
+
in the mapped half alone, and that is the failure the 2026-09-06 incident
|
|
394
|
+
actually was — a correct mapper meeting a string it had never seen.
|
|
395
|
+
- **Skipped when the error is already tagged `walletFailureReported`.** Set that
|
|
396
|
+
property on any error your `solanaConnectAndVerify` rethrows after substituting
|
|
397
|
+
a sentence of your own, and report it from in there, where the wallet's words
|
|
398
|
+
still exist. Without the tag the same failure files a second row carrying your
|
|
399
|
+
sentence in both halves — the useless row, and the one an operator meets first.
|
|
400
|
+
- **Fire and forget.** The return value is ignored and a **throw is swallowed**.
|
|
401
|
+
Do not rely on that: a reporter that throws is a bug, and the guard exists
|
|
402
|
+
because an observer must never become the incident.
|
|
403
|
+
- **Four values, and only these four.** No signature, no nonce, no signed SIWS
|
|
404
|
+
message — the modals never hold any of them, so none can leak. `raw` is free
|
|
405
|
+
text a **wallet** composed, so scrub it **server-side**: a wallet can quote your
|
|
406
|
+
own nonce back at you inside a field your key allowlist has already approved. A
|
|
407
|
+
pubkey is fine and should be kept; over-redaction blinds the tool.
|
|
408
|
+
|
|
352
409
|
#### The signed statement is not configurable
|
|
353
410
|
|
|
354
411
|
`solana_studio/phantom_deeplink` emits `Studio.wallet_sign_in_statement`, and it
|
|
@@ -76,6 +76,42 @@
|
|
|
76
76
|
browser, or to do something else entirely. The default
|
|
77
77
|
calls window.startPhantomDeepLink(linkMode, userId).
|
|
78
78
|
onBack() the Back button; DEFAULT closes the modal
|
|
79
|
+
|
|
80
|
+
CONTRACT WITH THE HOST'S JS. This gem ships one JavaScript file
|
|
81
|
+
(solana_studio/network_guard.js) and no routes at all, so every global below is
|
|
82
|
+
the HOST'S to provide. Each is reached behind a `typeof` guard, and an absent
|
|
83
|
+
one degrades this card rather than breaking it — which is the whole reason the
|
|
84
|
+
list is written down here instead of being rediscovered per consumer.
|
|
85
|
+
|
|
86
|
+
window[connect_fn] REQUIRED. Connect + verify. Default
|
|
87
|
+
window.solanaConnectAndVerify.
|
|
88
|
+
window.walletProvider REQUIRED for the rows: .available() lists the
|
|
89
|
+
detected wallets this card paints.
|
|
90
|
+
parseSolanaError(msg) optional. Maps a wallet string to a sentence
|
|
91
|
+
a user can act on; absent means the wallet's
|
|
92
|
+
own words are shown unmapped.
|
|
93
|
+
window.handleSolanaVerifySuccess(result)
|
|
94
|
+
optional. Post-verify hook on the happy path.
|
|
95
|
+
window.startPhantomDeepLink(linkMode, userId)
|
|
96
|
+
optional. The mobile deep link; absent hides
|
|
97
|
+
the Phantom deep-link row entirely.
|
|
98
|
+
window.reportWalletFailure(stage, provider, raw, mapped)
|
|
99
|
+
optional. Fire-and-forget observation, called
|
|
100
|
+
ONCE per caught rejection with stage
|
|
101
|
+
'wallet_connect'. `raw` is what the wallet
|
|
102
|
+
said and `mapped` is what the user was shown;
|
|
103
|
+
a host that records only one half cannot
|
|
104
|
+
diagnose a MIS-mapping, which is the failure
|
|
105
|
+
this exists for. Return value is ignored and
|
|
106
|
+
a throw is swallowed — see the call site.
|
|
107
|
+
Absent means this surface is DARK: the user
|
|
108
|
+
is served correctly and nothing is recorded
|
|
109
|
+
anywhere. Reference implementation and the
|
|
110
|
+
receiving endpoint (both host-owned, because
|
|
111
|
+
they need an ErrorLog this gem cannot assume)
|
|
112
|
+
live in turf-monster:
|
|
113
|
+
app/javascript/solana_errors.js and
|
|
114
|
+
POST /auth/solana/report_failure.
|
|
79
115
|
%>
|
|
80
116
|
<%
|
|
81
117
|
store = local_assigns.fetch(:store, "modals")
|
|
@@ -195,9 +231,46 @@
|
|
|
195
231
|
this.connecting = false; this.picking = '';
|
|
196
232
|
}
|
|
197
233
|
} catch (e) {
|
|
198
|
-
|
|
234
|
+
// BOTH HALVES OR NEITHER. raw is what the WALLET said, captured
|
|
235
|
+
// before the mapper runs; msg is what the user reads. A mis-mapping
|
|
236
|
+
// is invisible in the mapped half alone, and that is not theoretical
|
|
237
|
+
// -- a correct mapper meeting a string it had never seen is how an
|
|
238
|
+
// empty Phantom came to be answered with balance advice seven times
|
|
239
|
+
// in one production session on 2026-09-06.
|
|
240
|
+
var raw = (e && e.message) || '';
|
|
241
|
+
var msg = (e && e.code === 4001) ? 'Signature rejected' : (raw || 'Connection failed');
|
|
199
242
|
if (typeof parseSolanaError === 'function') msg = parseSolanaError(msg);
|
|
200
243
|
this.error = msg; this.connecting = false; this.picking = '';
|
|
244
|
+
// ── OBSERVATION, AFTER THE USER HAS BEEN SERVED ──────────────────
|
|
245
|
+
//
|
|
246
|
+
// Deliberately the LAST statement in this block, and the three lines
|
|
247
|
+
// above are deliberately not in it: reporting may not delay, block or
|
|
248
|
+
// alter the line the user has just been handed.
|
|
249
|
+
//
|
|
250
|
+
// HOST-SUPPLIED, like every other global this partial reaches for
|
|
251
|
+
// (see CONTRACT WITH THE HOST'S JS in the header). The endpoint it
|
|
252
|
+
// posts to writes an ErrorLog, which is host-owned -- this gem ships
|
|
253
|
+
// no routes at all -- so a consumer that has not built one simply
|
|
254
|
+
// does not define this, the guard is false, and the card behaves
|
|
255
|
+
// exactly as it did before. Silence, never a 404.
|
|
256
|
+
//
|
|
257
|
+
// ALREADY REPORTED UPSTREAM? The connect helper substitutes its own
|
|
258
|
+
// sentence for an unusable wallet and reports the pair from in there,
|
|
259
|
+
// where the wallet's words still exist. Reporting again from out here
|
|
260
|
+
// would add a second row whose raw and mapped are BOTH our sentence
|
|
261
|
+
// -- the useless row, and the one an operator meets first.
|
|
262
|
+
//
|
|
263
|
+
// THE try/catch IS THIS GEM'S OWN LAYER, and it is not redundant with
|
|
264
|
+
// the reporter's. We do not own the reporter -- a host does, and a
|
|
265
|
+
// host's implementation may throw. A throw here escapes an async Alpine
|
|
266
|
+
// handler silently, which is the failure mode this whole feature
|
|
267
|
+
// exists to remove -- so the observation cannot be allowed to become
|
|
268
|
+
// the incident. Four values, and only these four: opts,
|
|
269
|
+
// verifyArgs() and this.props are all in scope and carry
|
|
270
|
+
// currentUserId, none of which belongs in an error log.
|
|
271
|
+
if (typeof window.reportWalletFailure === 'function' && !(e && e.walletFailureReported)) {
|
|
272
|
+
try { window.reportWalletFailure('wallet_connect', name, raw, msg); } catch (_e) {}
|
|
273
|
+
}
|
|
201
274
|
}
|
|
202
275
|
},
|
|
203
276
|
deepLink() {
|
|
@@ -64,6 +64,19 @@
|
|
|
64
64
|
you into that wallet's account, exactly as a standalone wallet button does. The
|
|
65
65
|
walletHint is shown precisely so that is a visible choice, not a surprise.
|
|
66
66
|
|
|
67
|
+
Two more host globals, both OPTIONAL and both reached behind a `typeof` guard:
|
|
68
|
+
`parseSolanaError(msg)` maps a wallet string to a sentence a user can act on,
|
|
69
|
+
and `window.reportWalletFailure(stage, provider, raw, mapped)` records a caught
|
|
70
|
+
rejection with stage 'web3_step_up'. The reporter is fire-and-forget — its
|
|
71
|
+
return value is ignored and a throw is swallowed — and it is called ONCE per
|
|
72
|
+
rejection, skipping any error solanaConnectAndVerify already reported upstream.
|
|
73
|
+
Send BOTH message halves: `raw` is what the wallet said, `mapped` is what the
|
|
74
|
+
user read, and only the pair makes a MIS-mapping diagnosable. A host that
|
|
75
|
+
defines neither gets this card exactly as it behaved before, with the failure
|
|
76
|
+
recorded nowhere. The reporter and its endpoint stay host-owned because they
|
|
77
|
+
need an ErrorLog this gem cannot assume — reference implementation in
|
|
78
|
+
turf-monster: app/javascript/solana_errors.js, POST /auth/solana/report_failure.
|
|
79
|
+
|
|
67
80
|
CRITICAL (Alpine): this partial is cloned from a <template x-if> by the modal
|
|
68
81
|
host, so it must have ONE root element, and the x-data below is a
|
|
69
82
|
DOUBLE-QUOTED attribute — a single " anywhere inside it (a code comment
|
|
@@ -167,9 +180,38 @@
|
|
|
167
180
|
}
|
|
168
181
|
this.error = (result && result.error) || 'Verification failed.';
|
|
169
182
|
} catch (e) {
|
|
170
|
-
|
|
183
|
+
// BOTH HALVES OR NEITHER. raw is what the WALLET said, captured
|
|
184
|
+
// before the mapper runs; msg is what the user reads. A mis-mapping
|
|
185
|
+
// is invisible in the mapped half alone -- a correct mapper meeting a
|
|
186
|
+
// string it had never seen is how an empty Phantom came to be
|
|
187
|
+
// answered with balance advice seven times in one production session
|
|
188
|
+
// on 2026-09-06, which is the incident this reporting exists for.
|
|
189
|
+
var raw = (e && e.message) || '';
|
|
190
|
+
var msg = (e && e.code === 4001) ? 'Signature rejected' : (raw || 'Connection failed');
|
|
171
191
|
if (typeof parseSolanaError === 'function') msg = parseSolanaError(msg);
|
|
172
192
|
this.error = msg;
|
|
193
|
+
// Observation, LAST and after the user has been served. Host-supplied
|
|
194
|
+
// like every other global here (see CONTRACT WITH THE HOST'S JS in
|
|
195
|
+
// the header): the endpoint it posts to writes an ErrorLog, which is
|
|
196
|
+
// host-owned -- this gem ships no routes -- so a consumer that built
|
|
197
|
+
// none simply leaves this undefined and the card behaves exactly as
|
|
198
|
+
// before. The walletFailureReported tag means solanaConnectAndVerify
|
|
199
|
+
// already reported this one from where the wallet's words still
|
|
200
|
+
// existed; reporting again would file a second row carrying OUR
|
|
201
|
+
// sentence in both halves, and that is the row an operator meets
|
|
202
|
+
// first. Sibling call site in modals/_wallet_connect carries the long
|
|
203
|
+
// form of both arguments.
|
|
204
|
+
//
|
|
205
|
+
// THE try/catch IS LOAD-BEARING HERE IN A WAY IT IS NOT THERE.
|
|
206
|
+
// this.connecting = false sits OUTSIDE this catch block, on the
|
|
207
|
+
// line below. A host reporter that throws would skip it, leaving the
|
|
208
|
+
// sign-in button disabled forever with an error the user cannot
|
|
209
|
+
// retry -- and Alpine swallows a throw out of an async handler, so
|
|
210
|
+
// nothing anywhere would say so. An observer that can wedge the card
|
|
211
|
+
// is a worse bug than the darkness it was added to fix.
|
|
212
|
+
if (typeof window.reportWalletFailure === 'function' && !(e && e.walletFailureReported)) {
|
|
213
|
+
try { window.reportWalletFailure('web3_step_up', name, raw, msg); } catch (_e) {}
|
|
214
|
+
}
|
|
173
215
|
}
|
|
174
216
|
this.connecting = false;
|
|
175
217
|
},
|
|
@@ -281,7 +323,7 @@
|
|
|
281
323
|
Installed badge, chevron — so a wallet reads identically everywhere it is
|
|
282
324
|
offered and this card does not invent a third look for one action.
|
|
283
325
|
|
|
284
|
-
It carries
|
|
326
|
+
It carries pulse-cta (engine-motion) because it is the ONE target on the
|
|
285
327
|
card and the whole point of the card is that the user should press it. %>
|
|
286
328
|
<template x-if="canOneClick">
|
|
287
329
|
<div>
|
|
@@ -16,5 +16,5 @@ module SolanaStudio
|
|
|
16
16
|
# through the normal cycle. Splitting the version out is the same shape
|
|
17
17
|
# studio-engine already uses (lib/studio/version.rb) and hands each file back
|
|
18
18
|
# to its real owner: this one to the release, the gemspec to the PR.
|
|
19
|
-
VERSION = "0.
|
|
19
|
+
VERSION = "0.7.0"
|
|
20
20
|
end
|