solana-studio 0.9.3 → 0.11.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 +79 -22
- data/README.md +93 -3
- data/app/assets/javascripts/solana_studio/wallet_journal.js +254 -18
- data/app/assets/javascripts/solana_studio/wallet_ops.js +279 -4
- data/app/assets/javascripts/solana_studio/wallet_transport.js +20 -0
- 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: 858a442a5d0cfbb26c34c7a22a37f5b368842c0f768a3f0b45b0e6697c6d7c16
|
|
4
|
+
data.tar.gz: 03ed7c7e4fd3fd089fc5650a6f8352331755aaa8e5056741d5125f0d23d4d6df
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5dfcf51b5572081ed599c8142a4ccfe7f7cce2183b20f451760997b50d7e45008c4b9703594d670ec0765b28f8e62fdca2c5d90755854ed08aa67d733661e78a
|
|
7
|
+
data.tar.gz: f43a5201aa933ae8a4b4fbd338e5ac509bfb955fc02ac34a3e57dcecdafd200853aa28d7aff8a270e65be4d1252b0c76a800d31d99b8ff739fda298bb7886645
|
data/CHANGELOG.md
CHANGED
|
@@ -5,12 +5,10 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
7
|
### Added
|
|
8
|
+
- **`SolanaStudio.walletSession` — the wallet session is written down, so a returning user signs in ONE hop** (`app/assets/javascripts/solana_studio/wallet_journal.js`, `.../wallet_ops.js`). Sessions **never expire** on Phantom, Solflare or Backpack — all three vendors state it in their own docs (verified 2026-09-07, recorded per wallet in `wallet_transport.js`'s profile table) — but nothing persisted one, so EVERY mobile signing trip paid two app switches: connect, then sign. `runRedirect` already had the warm branch and a comment claiming it was "taken once per user, not once per action"; no caller could be on it, because there was nowhere for a session to live. Declare `run(..., { owner })` and there is: the connect hop's session is stored, scoped to that user, that wallet and that cluster, and the next trip goes straight to `signTransaction`. That halves the app switches on the flow that takes money and halves the surface where a trip can break — a real user's entry was lost on QA in precisely the second hop. **A SEPARATE RECORD, IN THE SAME FILE, and both halves of that are deliberate.** The journal is single-use and expires in ten minutes BY DESIGN (`take()` clears it so a double-fired callback cannot advance a step twice); a session is the opposite, long-lived and reusable. Share one record and every completed signature would `take()` the session away with it, which is the two-hop behaviour this removes. What must be separate is the RECORD, not the file: everything about reaching `localStorage` safely — the guarded accessor, the three browser states it really presents, the quota branch, the corrupt-entry drop — is one algorithm, and a second file would be a second copy of it (see the `B58_ALPHABET` note in `wallet_transport.js` for what that costs here). Sharing the file also keeps `purge()` whole: ONE call sweeps by key prefix and clears the session with the journal, so no consumer has to learn a second call. **But it is a call the host MUST ADD, not one it already makes** — turf-monster's layout sweeps the older `phantom_dl_` prefix, which does not match this subsystem's `wallet_dl_` and so clears NEITHER record. Adopt `owner` and that call in the same change, or ship a session that never expires — and the dapp encryption secret beside it — surviving a logout on a shared device. **THE TOKEN IS OPAQUE AND STAYS OPAQUE.** It decodes to a 64-byte signature plus JSON carrying `app_url`, timestamp, chain and cluster; none of it is read. The wallet is the only authority on validity, so a local parse can only ever be a second opinion that is wrong in one of two directions — `test_a_remembered_session_is_recalled_verbatim_and_never_parsed` stores a value containing `0`, `O`, `I` and `l` (the four characters base58 omits) and asserts it round-trips, so a decode added later cannot pass. **THE OWNER IS REFUSED RATHER THAN STRINGIFIED**, which is the guard that matters most: an object would `String()` to `[object Object]` — one token every user of that browser matches — and the failure would be a stranger offered a one-hop signature with a session they never established. A string or a number is a real handle; anything else is refused at `run()` by name and again inside `remember()`. An ANONYMOUS trip is not remembered at all: a session with nobody to scope it to cannot be kept from the next person, and there is no logout event on an anonymous page for `purge()` to ride. The scope is read from the JOURNAL in preference to the callback page's `opts.owner`, and that direction is the safety property — the journal says who STARTED the trip, and stamping whoever is signed in when the wallet answers is the one read that could hand a session to someone else. **A DECLARED `expectedAccount` IS NOW HONOURED ON A WARM TRIP**, which it never was: the session records the address it was established for, so a wallet keypair change is a local MISS that falls through to a connect hop instead of two app switches and a refusal. Omit `owner` and nothing changes — same hops, and an undeclared intent's journal is byte-identical to the one it wrote before this existed, which is why `JOURNAL_VERSION` does not move.
|
|
9
|
+
- **A refused session recovers through a connect hop instead of losing the entry** (`app/assets/javascripts/solana_studio/wallet_ops.js`). A stored session can be refused mid-trip, and the vendors name the causes: an explicit disconnect, a wallet keypair change, the user switching networks, an `app_url` blocklisting. The refusal lands on the SIGNING callback — by which point the user has already left for their wallet and come back, so they are committed, and failing there loses their work. So the trip takes the hop it skipped: forget the session, navigate to `connect` carrying THE SAME INTENT, and the ordinary connect callback advances it to signing on its own. One extra app switch — the two-hop cost they would have paid anyway — rather than starting over. **`prepare()` IS NOT RE-RUN**: the journalled `intent.state` is reused, because prepare MINTS things (turf-monster's mints a prepared-transaction row with a fresh blockhash) and a second one would strand the first and charge the flow twice for one user action. **RECOVERY IS BOUNDED TO ONCE BY SHAPE, NOT BY A COUNTER**: the retry intent carries its `scope` but NOT its `recovery` block, so the hop it produces has nothing to recover to and a second refusal surfaces the wallet's own words — there is no field anyone can forget to decrement. **A `4001` USER REJECTION IS NOT A REFUSED SESSION** and reaches the caller as itself; sending someone who just declined back to their wallet for another look is hostile, and their session is fine. A decryption failure is likewise out of the rule: it carries no error code, no vendor documents it as a refusal channel, and a corrupt payload should read as itself rather than as a session problem. The rule is deliberately WIDER than the four documented causes, whose codes are not pinned here — guessing narrow costs the user their entry, guessing wide costs one app switch and then reports the same error honestly, and when the two mistakes are that asymmetric you take the cheap one. Recovery also RESTORES a guard the warm path gives up: the recovered trip goes through connect, where a declared `expectedAccount` is checked, so a keypair change ends in a sentence the user can act on rather than a chain error. Covered by 20 node tests across simulated page deaths and 3 browser specs in `e2e/wallet_session.spec.js`; all 24 mutations of the new guards were confirmed to fail a named test.
|
|
8
10
|
- **`walletOps.run(..., { expectedAccount })` — a declared account the trip refuses to run without** (`app/assets/javascripts/solana_studio/wallet_ops.js`). Consumers were checking the connected pubkey against the session's linked address by hand, on the inline path only, because the redirect path had nowhere to put it: `resume()` goes from `completeConnect` straight into the signing hop. Declaring the address at `run()` gives both transports the check. **IT IS UX, NOT SECURITY, and overselling it would be the more expensive mistake**: the ownership proof is on-chain — Anchor rejects any `enter_contest_direct` whose signer does not match the entry PDA's owner, with or without this. What it buys is a sentence a user can act on (`Wrong wallet — this account is linked to GkxH…kQrM, but the wallet connected as 9WzD…AWWM`) instead of a program error, and on the inline transport a server-minted prepared transaction that is never spent to discover the wrong wallet is connected. The refusal carries `err.wrongAccount`, `err.expected` and `err.connected` so a host can compose its own sentence instead of parsing the default one. **A DECLARED VALUE RATHER THAN A POST-CONNECT HOOK, which is the obvious design and the wrong one**: the connect callback is a DIFFERENT DOCUMENT — studio-engine's `solana_sessions/phantom_callback`, which knows nothing about any consumer's flows — and `resume` deliberately does not require a registered handler to advance from connect to signing (`test_the_sign_only_declaration_holds_when_the_callback_page_lacks_the_intent` pins that). A hook would therefore be looked up on exactly the hop it exists to guard, come back empty, and be SKIPPED IN SILENCE. A string in the journal cannot be skipped, because there is nothing to look up — the same argument that put `signOnly` there, and it is proven by a test that clears the registry between the run and the callback. Stamped ONLY when declared, so an undeclared intent's journal stays byte-identical and `JOURNAL_VERSION` does not move.
|
|
9
11
|
- **Where the check lands differs per transport, and that asymmetry is documented rather than papered over.** Inline: after `connect()`, BEFORE `prepare()` — which is why connect now runs first (below). Redirect on a cold session: on the connect callback, before the signing hop, so `prepare` has already run and whatever it minted is spent — unavoidable, because the connect hop destroys the page and the journal is the only thing that crosses it. Redirect on a WARM session (`opts.session`): not checked at all, because no connect hop happens and walletOps never learns an account; a caller holding a session learned the address when it established one. A README table states all three.
|
|
10
|
-
- **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.
|
|
11
|
-
- **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.
|
|
12
|
-
- **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.
|
|
13
|
-
- **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.
|
|
14
12
|
|
|
15
13
|
### Changed
|
|
16
14
|
- **`prepare()` returns base58 WIRE BYTES on every transport, and that is now enforced instead of merely written down** (`wallet_ops.js`). The file's own docblock has always said so — `return { transaction: <base58>, ...state }`, because base58 survives a page death and a `solanaWeb3.Transaction` serialises into a journal as `{}` — but only the redirect path obeyed it. The inline path passed `prepared.transaction` STRAIGHT to `provider.signTransaction`, which for an injected wallet must be a Transaction OBJECT. One `prepare` cannot return both shapes, so a single intent could not serve both transports and turf-monster shipped the redirect path through walletOps while keeping its hand-rolled desktop call site — **two paths, which this file exists to prevent**. `requireWireTransaction` now refuses a transaction that is not a non-empty string, BY NAME, on BOTH paths, in the one place both pass through: a contract enforced on one path only is exactly how the inline path drifted in the first place. An EMPTY string gets its own phrase rather than being reported as "a string", because that message sends the reader after a type error in a handler whose type is already right — the fault is a prepare that came back with nothing to sign.
|
|
@@ -18,45 +16,104 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
18
16
|
- **`prepared` is not mutated.** `complete` is handed the object `prepare` returned, byte for byte, so `state.transaction` is the same base58 string on both transports. A deserialized copy left in there would reintroduce the split one layer down, where a handler reading `state.transaction` gets a different type per transport.
|
|
19
17
|
- **The inline transport connects BEFORE it prepares.** `prepare` is a server round trip that MINTS something — turf-monster's is a prepared-transaction row with a fresh blockhash — so running it before the wallet has said who it is spends a real record to discover the wrong account is connected. Every hand-rolled desktop call site this replaces already connected first for exactly that reason, and a walletOps that prepared first would have made migrating to it a REGRESSION on the majority path. The redirect transport cannot copy this and no shuffling makes it: the connect hop destroys the page, so anything `prepare` returns must already be in the journal before the navigation. **What does NOT follow from the new order:** `prepare` is still called with `ctx` and nothing else. An intent that read the connected account there would work on a desktop and silently misbehave on a cold mobile session, where the account is not known until a page that no longer exists — so the account is not passed, deliberately.
|
|
20
18
|
- `expectedAccount` is **refused rather than stringified** when it is not a string. A `solanaWeb3.PublicKey` would `String()` correctly on the inline path and be written to the journal as `{}` on the redirect one — matching on a desktop and refusing every mobile trip with a wrong-wallet sentence naming an account nobody has. The two transports disagreeing about a value is precisely the failure this change exists to remove. The refusal names the fix (`call .toString() on a PublicKey first`); an explicit `null` reads as "nothing to check", not a type error.
|
|
21
|
-
- **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`.
|
|
22
|
-
- **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.
|
|
23
|
-
- **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.
|
|
24
|
-
- **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.
|
|
25
|
-
- **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.
|
|
26
|
-
- **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.
|
|
27
|
-
- `.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.
|
|
28
19
|
|
|
29
20
|
### Tests
|
|
30
21
|
- **`test/wallet_ops_js_test.rb` (19 → 31, 124 → 251 assertions)** — nine new tests plus two rewritten inline ones, all under node against the shipped `.js` with real tweetnacl on both sides of the redirect handshake. The headline is `test_both_transports_hand_complete_the_same_result_and_state_shapes`: ONE registered intent run over BOTH transports, with `complete` recording what it was actually handed — the only test in the file that can see the acceptance criterion, because the old defect was invisible from inside either transport alone. The inline fake is shaped like a REAL adapter and not like the code under test: `deserializeTransaction` hands back an object, `signTransaction` THROWS on anything that is not one, `serializeTransaction` puts it back to a string — a fake taking a string on both sides could not tell the fixed code from the bug it replaced. `calls` records the ORDER, which is how connect-before-prepare is pinned rather than assumed. The rest: both codec halves refused by name and BEFORE any wallet call (asserted on an empty `calls`); a wrongly-shaped, missing and empty transaction refused on BOTH transports; the expected account refusing before `prepare` inline and before the SECOND NAVIGATION on the redirect connect callback (asserted on `world.urls.length === 1`, because a refusal arriving after that navigation would already have sent the user into their wallet to approve a transaction that could never be accepted); the same refusal holding with `O.reset()` between the run and the callback, which is the property that justifies a declared value over a hook; the matching wallet running through untouched; the account read off `provider.publicKey` when `connect()` resolves nothing; an unreadable account refused as UNKNOWN rather than reported as wrong; a non-string refused; and an undeclared account leaving the journal's intent at exactly `{ctx, op, state}`.
|
|
31
22
|
- **`e2e/wallet_ops_inline.spec.js` (3, new)** — the inline transport driven by the SHIPPED bytes in a real Chromium, through a new `labInlineRun` hook on the existing wallet-transport lab page. It answers two things the node suite cannot, and deliberately restates none of what it can. First, does the inline path EXECUTE when a browser parses the file a consumer installs — it is a promise chain over a provider object, and a node `require` is a different loader with different tolerances; this lane exists because a free variable at module scope once broke every mobile sign-in while eleven view tests stayed green. Second, whether the connected account survives being a REAL OBJECT: a `solanaWeb3.PublicKey` is an object whose `toString()` is its base58 address, and the gem reads it with `String(pk)` precisely so it can do that without touching web3.js — the node harness has only plain strings, so this is the tier where the object exists. The lab's adapter refuses to be lenient: its `signTransaction` THROWS on anything that is not an object, exactly as an injected wallet does, so a regression that handed it base58 fails here rather than passing quietly. `config/e2e_lane.yml` 28 → 31 specs, DERIVED with `npx playwright test --list` per the file's own instruction, and the runtime executed-set gate confirms `expected=31 skipped=0 unexpected=0`.
|
|
32
23
|
- **4 mutants against the browser lane, all 4 KILLED**, each reverted after: `complete` handed the wallet's signed object, the expected-account check deleted, the codec check deleted, and — the control — the lab hook BLANKED, which must take all three specs red or the specs are grading the lab instead of the gem. It did. **The first reading of this run said SURVIVED for all four and was wrong**: the harness graded on the summary line, and Playwright reports a partial failure as `1 failed` on its own line above `2 passed`, so a parser looking only for a passed/failed count in one line saw green. Re-run reading the EXIT CODE, every one is red (`EXIT: 1`). Recorded because the failure mode is a measurement that agrees with itself — a mutation count is worth nothing until the thing reading it can observe a failure.
|
|
33
24
|
- **16 mutants applied to `wallet_ops.js`, all 16 KILLED, no survivors**, each against a 31 runs / 251 assertions / 0 failures baseline and reverted after: the codec check removed; `serializeTransaction` dropped from the checked list; `complete` handed the wallet's signed OBJECT; the wallet handed base58 instead of a deserialized object; `state.transaction` mutated to the deserialized object; the expected-account check moved AFTER prepare; prepare moved before connect; the check deleted from the redirect connect callback; an unreadable account skipping the check instead of refusing; the account stamped into the journal unconditionally; `requireWireTransaction` dropped from each transport separately; a non-string `expectedAccount` coerced; and `err.wrongAccount` inverted. **Two of them are recorded because the first measurement was wrong and the correction is the finding.** An empty-string transaction (`typeof tx === 'string'` alone) SURVIVED — the guard was type-correct and value-blind, and no test covered the type-correct wrong answer; it is now refused with its own phrase and killed. The other, "the wallet handed base58 instead of a deserialized object", first read as a skip because the anchor string did not match after line wrapping — a mutant that does not apply is not a mutant that was killed, so it was re-anchored and re-run rather than counted.
|
|
34
|
-
- **`
|
|
35
|
-
-
|
|
36
|
-
- `
|
|
37
|
-
- **`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.
|
|
38
|
-
- **`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.
|
|
39
|
-
- `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.
|
|
40
|
-
- `test/gemspec_test.rb` and `.github/workflows/gem-ci.yml` name the new partial alongside the modal, so a `spec.files` glob that stopped matching it fails at build rather than in a consumer.
|
|
41
|
-
- **`test/web3_modals_test.rb`** (5) — resolves each partial BY VIRTUAL PATH through an `ActionView::LookupContext` over the engine's own view paths, which is the mechanism a consumer uses, rather than `File.exist?` (true of a file no consumer can reach). Each identifier must sit under this gem's view root — asserted that way rather than by matching `/gems/` in the path, because turf-monster's own `test/support/resolved_wallet_picker.rb` warns against that form: it fails a path checkout while proving nothing extra.
|
|
42
|
-
- **`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
|
+
- **`test/docs/changelog_structure_test.rb` (6 tests, 53 assertions, new)** — the guard for this file's own shape, added with the pass that moved twenty-nine shipped entries out of `## Unreleased` and under the versions that actually published them (the bucket held forty-four; the other fifteen had not shipped when the pass ran and stayed put). It asserts SHAPE and never prose, so ordinary changelog writing cannot turn it red: one bucket, leading the file; every other `## ` heading parsing as `## vX.Y.Z (YYYY-MM-DD)`; strictly decreasing, no version twice; no `### ` above the first `## `; and the one that bites — `SolanaStudio::VERSION` no more than two minor versions ahead of the newest heading, matched by hand to the value the PENDING hub guard will carry (`Release::Changelog::MAX_MINOR_DRIFT = 2`, in mcritchie-studio PR #1344, unmerged — no such constant exists on the hub today, and nothing tests that the two agree). **Its parse floor is DERIVED, not a copied count**: every heading below the bucket must parse, asserted as an equality against the file's own heading count, because a hard-coded minimum carried between repos is a vacuous pass wearing a number.
|
|
26
|
+
- **8 mutants against the guard, all 8 KILLED**, each applied to `CHANGELOG.md` and reverted after (sha256-verified restore): the pre-attribution file, a foreign `## 0.9.1 — 2026-09-09` heading, headings dropped until the drift reached 3, a duplicated version, a genuine order inversion, the bucket displaced from the top, a heading AHEAD of `VERSION`, and an orphan `### ` above the first `## `. Two earlier mutants SURVIVED and were diagnosed rather than papered over — one sat inside the deliberate two-minor tolerance, the other never actually broke the ordering it claimed to — and each was replaced by one that violates the property.
|
|
27
|
+
- **`RUNBOOK.md`'s 'Running Tests' section, corrected where it was wrong rather than merely thin.** Its test command named three files by hand (`keypair`, `borsh`, `transaction`) out of the twenty-five the suite now carries — the curated list `bin/release-check` exists to replace — so it now points at that script and says why (one entry point shared by local certs, CI and the release sweep; glob-enumerated; fails a file that runs zero tests or skips one). Diagnoses are added for traps met while writing the guard above. The JS lanes fail rather than skip on a missing dependency, and there are TWO such dependencies with different messages and different fixes, so they get a block each: node absent from `PATH` (every `test/*_js_test.rb` probes it, so it takes all of them out) and `node_modules` absent (only the lanes that load the REAL tweetnacl by absolute path, fixed by `npm ci`). Neither is keyed to a file list or a count, because both go stale as lanes are added — the earlier draft of this bullet named four files, two of which never emit the tweetnacl message at all. And a red `test/docs/changelog_structure_test.rb` is a bookkeeping defect in `CHANGELOG.md`, with the three-pass git attribution method written out and the note that this guard stands alone today: the hub's `bin/release prepare` does not read `CHANGELOG.md` at all yet, and the change that makes it refuse on this drift is pending in mcritchie-studio PR #1344.
|
|
43
28
|
|
|
44
29
|
### Notes for hosts
|
|
45
30
|
- **A HOST THAT USES `walletOps.run` OVER THE INLINE TRANSPORT MUST ADD THE CODEC, and there is exactly one such call site in the ecosystem — none of them inline.** `walletOps.run` is called once, in turf-monster's `app/views/contests/_turf_totals_board.html.erb`, and it is INSIDE `if (provider.transport === 'redirect')`. studio-engine calls only `walletOps.resume` and supplies no provider at all (its gemspec does not even depend on this gem; the coupling is duck-typed at runtime). So no shipped inline caller exists to break, and this lands as a contract a migrating call site adopts rather than a regression an existing one suffers. Nothing changes for a host that does nothing.
|
|
46
31
|
- **Adding the codec is six lines, written once on the object your `detect()` returns**, and the two flags are load-bearing for a CO-SIGNED transaction — the server fills a second signer slot, so a bare `signed.serialize()` throws on the missing signature: `deserializeTransaction: wire => solanaWeb3.Transaction.from(base58.decode(wire))` and `serializeTransaction: signed => base58.encode(signed.serialize({ requireAllSignatures: false, verifySignatures: false }))`. Full worked example in the README's new **The wallet intent registry (`walletOps`)** section, which is also where the contract is now written down — it had no README section at all before this release, which is part of why the inline path was able to disobey it.
|
|
47
32
|
- **turf-monster is the consumer that must move, and the move DELETES code rather than adding it.** Its inline contest-entry block (`_turf_totals_board.html.erb`, the branch after the redirect fork: `atob` → `solanaWeb3.Transaction.from` → `provider.signTransaction(tx)` → `signedTx.serialize({requireAllSignatures:false, verifySignatures:false})` → base64 → POST) collapses into the same `walletOps.run('contest_entry', ...)` the redirect branch already calls, with `expectedAccount: sess.address` replacing the hand-rolled wrong-wallet throw. Its registered intent already speaks this contract on both sides — `prepare` returns `walletTransport.base58.encode(bytes)` and `complete` starts with `base58.decode(result.signedTransaction)` — so the intent itself needs no change. The tombstone comment in that file naming this task's three blockers can go with it. Four more flows are then straightforward conversions rather than fresh decisions: World Cup survivor entry (a near-clone, also `signOnly`), username rename (client broadcasts, so NOT `signOnly`), create contest, and the contest generator. Wallet export is `signMessage` only and has no intent shape here yet.
|
|
48
33
|
- **`expectedAccount` is opt-in and nothing is checked without it.** Every existing intent is on the undeclared branch, which is free: no journal field, no check, no behaviour change.
|
|
34
|
+
|
|
35
|
+
## v0.9.1 (2026-09-09)
|
|
36
|
+
|
|
37
|
+
## v0.9.0 (2026-09-07)
|
|
38
|
+
|
|
39
|
+
## v0.8.0 (2026-09-07)
|
|
40
|
+
|
|
41
|
+
## v0.7.0 (2026-09-07)
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
- **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.
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
- **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`.
|
|
48
|
+
- **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.
|
|
49
|
+
- **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.
|
|
50
|
+
|
|
51
|
+
### Tests
|
|
52
|
+
- **`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.
|
|
53
|
+
- **`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.
|
|
54
|
+
- `config/e2e_lane.yml` 12 -> 19 specs, derived with `npx playwright test --list` rather than counted by hand, per the file's own instruction.
|
|
55
|
+
|
|
56
|
+
### Notes for hosts
|
|
49
57
|
- **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.
|
|
50
58
|
- **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.
|
|
51
59
|
- **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.
|
|
60
|
+
|
|
61
|
+
## v0.6.1 (2026-09-07)
|
|
62
|
+
|
|
63
|
+
### Changed
|
|
64
|
+
- **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.
|
|
65
|
+
- **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.
|
|
66
|
+
|
|
67
|
+
### Tests
|
|
68
|
+
- **`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.
|
|
69
|
+
|
|
70
|
+
### Notes for hosts
|
|
52
71
|
- **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.
|
|
53
72
|
- **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.
|
|
54
73
|
- **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.
|
|
74
|
+
|
|
75
|
+
## v0.6.0 (2026-09-02)
|
|
76
|
+
|
|
77
|
+
### Added
|
|
78
|
+
- **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.
|
|
79
|
+
|
|
80
|
+
### Changed
|
|
81
|
+
- **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.
|
|
82
|
+
|
|
83
|
+
### Tests
|
|
84
|
+
- **`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.
|
|
85
|
+
- `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.
|
|
86
|
+
|
|
87
|
+
## v0.5.7 (2026-09-02)
|
|
88
|
+
|
|
89
|
+
### Notes for hosts
|
|
90
|
+
- **The wallet sign-in button needs studio-engine >= 0.68.0.** 0.68.0 is the first engine whose `style/modals/_auth` looks up `solana_studio/auth/wallet_credential`; until it, the engine drew that button INLINE and never consulted the path at all, so this gem shipped the partial, its tests and its README section for a button no host rendered. Below the floor the partial is inert and NOTHING SAYS SO — no render, no button, no error — so the symptom is silence rather than a stack trace, which is the failure mode this ecosystem's Gemfile pin comments exist to prevent. DERIVED by unpacking the published gems, not read off a changelog: 0.67.2 carries no `solana_studio/auth` reference anywhere in `app/`, and 0.68.0 gates the render on `lookup_context.exists?("wallet_credential", ["solana_studio/auth"], true)`. Match on the NAMESPACE, not the basename — `style/modals/_wallet_connect` ships unchanged in both releases and answers an unrelated question. It pairs with a floor on THIS gem: 0.5.2 shipped the credential partial WITHOUT `solana_studio/modals/_wallet_connect`, which the engine's `web3_gem` capability gate requires, so on 0.5.2 the button is suppressed either side of the floor and 0.5.3 is the first version that can draw it. Full matrix in the README.
|
|
91
|
+
|
|
92
|
+
## v0.5.3 (2026-09-01)
|
|
93
|
+
|
|
94
|
+
### Added
|
|
95
|
+
- **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.
|
|
96
|
+
|
|
97
|
+
### Changed
|
|
98
|
+
- `.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.
|
|
99
|
+
|
|
100
|
+
### Tests
|
|
101
|
+
- **`test/web3_modals_test.rb`** (5) — resolves each partial BY VIRTUAL PATH through an `ActionView::LookupContext` over the engine's own view paths, which is the mechanism a consumer uses, rather than `File.exist?` (true of a file no consumer can reach). Each identifier must sit under this gem's view root — asserted that way rather than by matching `/gems/` in the path, because turf-monster's own `test/support/resolved_wallet_picker.rb` warns against that form: it fails a path checkout while proving nothing extra.
|
|
102
|
+
- **`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.
|
|
103
|
+
|
|
104
|
+
### Notes for hosts
|
|
55
105
|
- **These require studio-engine at render time** — they render its modal host and its shared blocks by name, and paint with its utilities and theme tokens. `solana_studio/modals/network_mismatch` already shipped on exactly these terms, so this is the established arrangement rather than new coupling. studio-engine stays a DEVELOPMENT dependency; a runtime one would drag Rails into every plain-Ruby consumer.
|
|
56
106
|
- **`phantom_deeplink` still emits `Studio.wallet_sign_in_statement`, deliberately.** studio-engine's `solana_sessions/phantom_callback` rebuilds the signed message to verify it, so both halves read one accessor precisely so they cannot drift. Parameterising it would decouple the gem, read as an improvement, and break the signature check on every mobile sign-in. Pinned from both directions in `test/web3_modals_test.rb`.
|
|
57
|
-
- **The wallet sign-in button needs studio-engine >= 0.68.0.** 0.68.0 is the first engine whose `style/modals/_auth` looks up `solana_studio/auth/wallet_credential`; until it, the engine drew that button INLINE and never consulted the path at all, so this gem shipped the partial, its tests and its README section for a button no host rendered. Below the floor the partial is inert and NOTHING SAYS SO — no render, no button, no error — so the symptom is silence rather than a stack trace, which is the failure mode this ecosystem's Gemfile pin comments exist to prevent. DERIVED by unpacking the published gems, not read off a changelog: 0.67.2 carries no `solana_studio/auth` reference anywhere in `app/`, and 0.68.0 gates the render on `lookup_context.exists?("wallet_credential", ["solana_studio/auth"], true)`. Match on the NAMESPACE, not the basename — `style/modals/_wallet_connect` ships unchanged in both releases and answers an unrelated question. It pairs with a floor on THIS gem: 0.5.2 shipped the credential partial WITHOUT `solana_studio/modals/_wallet_connect`, which the engine's `web3_gem` capability gate requires, so on 0.5.2 the button is suppressed either side of the floor and 0.5.3 is the first version that can draw it. Full matrix in the README.
|
|
58
107
|
|
|
59
|
-
## v0.5.
|
|
108
|
+
## v0.5.2 (2026-08-31)
|
|
109
|
+
|
|
110
|
+
### Added
|
|
111
|
+
- **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.
|
|
112
|
+
|
|
113
|
+
### Tests
|
|
114
|
+
- `test/gemspec_test.rb` and `.github/workflows/gem-ci.yml` name the new partial alongside the modal, so a `spec.files` glob that stopped matching it fails at build rather than in a consumer.
|
|
115
|
+
|
|
116
|
+
## v0.5.0 (2026-08-21)
|
|
60
117
|
|
|
61
118
|
Ships the gem's first Rails half, and its first CI. The version number is
|
|
62
119
|
allocated by the release conductor, not by this entry.
|
data/README.md
CHANGED
|
@@ -444,6 +444,7 @@ SolanaStudio.walletOps.define('contest_entry', {
|
|
|
444
444
|
SolanaStudio.walletOps.run('contest_entry', { contestId: 12 }, {
|
|
445
445
|
provider: walletProvider.detect(),
|
|
446
446
|
expectedAccount: session.address, // optional
|
|
447
|
+
owner: currentUser.id, // optional — see below
|
|
447
448
|
appUrl: location.origin, // redirect transport only
|
|
448
449
|
redirectLink: location.origin + '/auth/phantom/callback',
|
|
449
450
|
cluster: document.body.dataset.solanaCluster
|
|
@@ -542,13 +543,19 @@ place the two genuinely cannot be made identical:
|
|
|
542
543
|
|---|---|---|
|
|
543
544
|
| Inline | after `connect()`, **before** `prepare()` | nothing — `prepare` never runs |
|
|
544
545
|
| Redirect, cold session | on the connect callback, **before** the signing hop | whatever `prepare` already minted; no signing prompt |
|
|
546
|
+
| Redirect, warm session (`owner`) | locally, **before** the trip starts | nothing — the trip falls back to a connect hop |
|
|
545
547
|
| Redirect, warm session (`opts.session`) | **not checked** | — |
|
|
546
548
|
|
|
547
549
|
The redirect path cannot check earlier because the connect hop destroys the
|
|
548
550
|
page: everything `prepare` returns must already be in the journal before the
|
|
549
|
-
navigation.
|
|
550
|
-
|
|
551
|
-
|
|
551
|
+
navigation.
|
|
552
|
+
|
|
553
|
+
A session recalled through `owner` records the address it was established for,
|
|
554
|
+
so the check happens locally and costs nothing: a mismatch is a **miss**, the
|
|
555
|
+
trip takes an ordinary connect hop, and the account is checked there. A session
|
|
556
|
+
handed in as `opts.session` carries no address walletOps knows about, so it stays
|
|
557
|
+
unchecked — a caller holding one learned the address when it established one, and
|
|
558
|
+
that is where the check belongs.
|
|
552
559
|
|
|
553
560
|
It is a declared **value** rather than a post-connect hook on purpose. The
|
|
554
561
|
connect callback is a different document — in this ecosystem, studio-engine's
|
|
@@ -557,6 +564,89 @@ wallet callback view, which knows nothing about any consumer's flows — and
|
|
|
557
564
|
connect to signing. A hook would be looked up on exactly the hop it exists to
|
|
558
565
|
guard, come back empty, and be skipped in silence.
|
|
559
566
|
|
|
567
|
+
#### `owner` — one hop for a returning user
|
|
568
|
+
|
|
569
|
+
Sessions **never expire** on Phantom, Solflare or Backpack. All three vendors say
|
|
570
|
+
so in their own docs — recorded per wallet as `sessionsExpire: false`, each with
|
|
571
|
+
the `sessionDocs` URL it came from, in `wallet_transport.js`'s `PROFILES` table
|
|
572
|
+
(verified 2026-09-07). Nothing persisted one, so every
|
|
573
|
+
mobile signing trip paid **two app switches** — connect, then sign — and the
|
|
574
|
+
second one is where a real user's entry was lost on QA.
|
|
575
|
+
|
|
576
|
+
Declare an `owner` and the connect hop's session is written down. The next trip
|
|
577
|
+
skips straight to signing:
|
|
578
|
+
|
|
579
|
+
```js
|
|
580
|
+
SolanaStudio.walletOps.run('contest_entry', { contestId: 12 }, {
|
|
581
|
+
provider: walletProvider.detect(),
|
|
582
|
+
owner: currentUser.id, // string or number; an object is refused
|
|
583
|
+
cluster: document.body.dataset.solanaCluster,
|
|
584
|
+
// ...
|
|
585
|
+
});
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
Omit it and every trip behaves exactly as it did before — same hops, same journal
|
|
589
|
+
bytes, including a caller that supplies its own `opts.session`. Recovery and the
|
|
590
|
+
scope stamp are written only for a session **this gem recalled**; a session you
|
|
591
|
+
hand in stays yours to manage, because the gem has no record of it to forget.
|
|
592
|
+
The session lives in `SolanaStudio.walletSession`
|
|
593
|
+
(`solana_studio/wallet_journal.js`, a **separate record** from the journal: the
|
|
594
|
+
journal is single-use and expires in ten minutes, a session is reusable and does
|
|
595
|
+
not).
|
|
596
|
+
|
|
597
|
+
**The token is opaque.** It decodes to a 64-byte signature plus JSON, and nothing
|
|
598
|
+
in this gem reads any of it. The wallet is the only authority on whether a
|
|
599
|
+
session is still good, so a local parse can only produce a second opinion that is
|
|
600
|
+
wrong in one of two directions.
|
|
601
|
+
|
|
602
|
+
##### What the host owes
|
|
603
|
+
|
|
604
|
+
| Call | When | Why |
|
|
605
|
+
|---|---|---|
|
|
606
|
+
| `run(..., { owner, cluster })` | every trip | scopes the session; without it nothing is stored |
|
|
607
|
+
| `resume(params, { owner, cluster })` | the callback page, for a **plain sign-in** connect | walletOps did not start that trip, so the owner can only come from here — and a sign-in session is what makes a user's *first* action one hop |
|
|
608
|
+
| `walletJournal.purge()` | logout / user switch | sweeps both records. **A session outliving a logout is a stranger signing.** |
|
|
609
|
+
|
|
610
|
+
> ⚠ **`purge()` is a call you must ADD, not one you already make.** It sweeps the
|
|
611
|
+
> `wallet_dl_` prefix. A host carrying a `phantom_dl_`-era sweep from before this
|
|
612
|
+
> gem existed — turf-monster's layout does — clears **neither** record, because
|
|
613
|
+
> the prefixes do not match. Adopt `owner` and that call in the same change:
|
|
614
|
+
> without it a session that *never expires*, and the dapp encryption secret
|
|
615
|
+
> stored beside it, both survive a logout on a shared device.
|
|
616
|
+
|
|
617
|
+
A trip started through `run` carries its own owner in the journal, so `resume`
|
|
618
|
+
needs nothing for it. The stamp is read from the journal in preference to
|
|
619
|
+
`opts.owner` deliberately: the journal says who *started* the trip, and stamping
|
|
620
|
+
the user who happens to be signed in when the wallet answers is the one outcome
|
|
621
|
+
that could hand a session to someone who never established it.
|
|
622
|
+
|
|
623
|
+
##### When the wallet refuses a stored session
|
|
624
|
+
|
|
625
|
+
It can, and vendor docs name the causes: an **explicit disconnect**, a **wallet
|
|
626
|
+
keypair change**, the **user switching networks**, and an **`app_url`
|
|
627
|
+
blocklisting**. The refusal arrives on the signing callback — by which point the
|
|
628
|
+
user has already left for their wallet and come back, so failing there loses
|
|
629
|
+
whatever they were doing.
|
|
630
|
+
|
|
631
|
+
So the trip does not fail. walletOps forgets the session and takes the hop it
|
|
632
|
+
skipped: a connect, carrying the **same intent**, which the ordinary connect
|
|
633
|
+
callback picks up and advances to signing on its own. The user pays one extra app
|
|
634
|
+
switch — the two-hop cost they would have paid anyway — instead of starting over.
|
|
635
|
+
|
|
636
|
+
- **`prepare()` is not re-run.** The journalled intent is reused, so a
|
|
637
|
+
prepared-transaction row minted for the first attempt is the one that gets
|
|
638
|
+
signed. Re-preparing would strand the first and mint a second for one user
|
|
639
|
+
action.
|
|
640
|
+
- **Recovery happens at most once**, structurally: the retry intent carries no
|
|
641
|
+
recovery block, so a second refusal surfaces the wallet's own error.
|
|
642
|
+
- **A user rejection (`4001`) is not a refusal.** The session is fine, the user
|
|
643
|
+
said no, and it reaches the caller as itself.
|
|
644
|
+
|
|
645
|
+
Its lifetime ends at exactly four events — a wallet refusal, a scope change
|
|
646
|
+
(different user, wallet or cluster), `purge()`, or `walletSession.forget()`.
|
|
647
|
+
There is **no timer**, because inventing one would contradict the vendor fact the
|
|
648
|
+
feature rests on.
|
|
649
|
+
|
|
560
650
|
#### `signOnly`
|
|
561
651
|
|
|
562
652
|
A **co-signed** transaction cannot be broadcast by the wallet: the chain rejects
|
|
@@ -1,10 +1,44 @@
|
|
|
1
|
-
//
|
|
1
|
+
// The redirect transport's STORAGE — the only thing that survives the page's
|
|
2
|
+
// death. Two records, two opposite lifetimes, one store.
|
|
3
|
+
//
|
|
4
|
+
// SolanaStudio.walletJournal — ONE TRIP. Single-use, expiring.
|
|
5
|
+
// SolanaStudio.walletSession — ONE WALLET, ONE USER. Long-lived, reusable.
|
|
2
6
|
//
|
|
3
7
|
// A redirect destroys the document. Whatever the app needs on the other side has
|
|
4
8
|
// to be written down first, and this is where. Everything else about the
|
|
5
9
|
// redirect transport is pure functions over data; this file is the one place
|
|
6
10
|
// that touches storage, which is why it is small and separately testable.
|
|
7
11
|
//
|
|
12
|
+
// WHY THE SESSION LIVES HERE AND NOT IN A FILE OF ITS OWN, since the two records
|
|
13
|
+
// could hardly be more different. Everything about reaching localStorage safely
|
|
14
|
+
// — the guarded accessor, the three browser states it really presents, the
|
|
15
|
+
// quota branch, the corrupt-entry drop — is one algorithm, and a second file
|
|
16
|
+
// would be a second copy of it. This codebase has already been bitten by exactly
|
|
17
|
+
// that (see the B58_ALPHABET note in wallet_transport.js, and the "one factory,
|
|
18
|
+
// not three adapters" note in redirect_provider.js). What must be separate is
|
|
19
|
+
// the RECORD, not the file, and the records below share no key, no lifetime and
|
|
20
|
+
// no reader.
|
|
21
|
+
//
|
|
22
|
+
// It also keeps `purge()` whole: ONE call clears both records, because both are
|
|
23
|
+
// stored under this PREFIX. Split them and a host would have to learn a second
|
|
24
|
+
// call, and the one it forgot would be the session.
|
|
25
|
+
//
|
|
26
|
+
// ⚠ BUT `purge()` IS NOT WIRED UP IN THIS ECOSYSTEM TODAY, and the difference
|
|
27
|
+
// between "a call a host already makes" and "a call a host MUST add" is a
|
|
28
|
+
// stranger signing. No consumer calls it: turf-monster's layout sweeps the
|
|
29
|
+
// `phantom_dl_` prefix from the era before this gem existed, and THIS subsystem's
|
|
30
|
+
// prefix is `wallet_dl_`, so that sweep clears NEITHER record. Exposure is zero
|
|
31
|
+
// only because no consumer passes `owner` yet — the first one that does inherits
|
|
32
|
+
// a session that never expires AND the dapp secret key beside it, both surviving
|
|
33
|
+
// a logout on a shared device. Adopting `owner` means adding the purge call in
|
|
34
|
+
// the same change. See the host-owes table in the README.
|
|
35
|
+
//
|
|
36
|
+
// THE NAME ON THE FILE IS NOW NARROWER THAN ITS CONTENTS, and that is a
|
|
37
|
+
// deliberate cost, not an oversight. The path is shipped: consumers load
|
|
38
|
+
// `solana_studio/wallet_journal.js` from a script tag, lib/solana_studio/engine.rb
|
|
39
|
+
// precompiles it by name, and test/gemspec_test.rb pins it. Renaming a shipped
|
|
40
|
+
// asset path to improve a noun breaks every consumer at once.
|
|
41
|
+
//
|
|
8
42
|
// WHY NOT sessionStorage. The wallet round trip leaves the browser entirely and
|
|
9
43
|
// may return in a NEW TAB — iOS in particular does not guarantee the originating
|
|
10
44
|
// tab is what receives a universal link. sessionStorage is per-tab and would be
|
|
@@ -29,6 +63,7 @@
|
|
|
29
63
|
|
|
30
64
|
var PREFIX = 'wallet_dl';
|
|
31
65
|
var KEY = PREFIX + '_journal';
|
|
66
|
+
var SESSION_KEY = PREFIX + '_session';
|
|
32
67
|
|
|
33
68
|
// Ten minutes. Long enough for a human to read a wallet approval screen,
|
|
34
69
|
// think, and approve; short enough that an abandoned trip is gone before it
|
|
@@ -48,11 +83,17 @@
|
|
|
48
83
|
}
|
|
49
84
|
}
|
|
50
85
|
|
|
51
|
-
|
|
86
|
+
// --- the shared record primitives ----------------------------------------
|
|
87
|
+
//
|
|
88
|
+
// Both records go through these. Written once because the interesting part is
|
|
89
|
+
// not the JSON, it is the failure handling around it, and two copies of that
|
|
90
|
+
// is how one of them quietly stops guarding.
|
|
91
|
+
|
|
92
|
+
function writeRecord(key, record) {
|
|
52
93
|
var s = store();
|
|
53
|
-
if (!s || !
|
|
94
|
+
if (!s || !record) return false;
|
|
54
95
|
try {
|
|
55
|
-
s.setItem(
|
|
96
|
+
s.setItem(key, JSON.stringify(record));
|
|
56
97
|
return true;
|
|
57
98
|
} catch (e) {
|
|
58
99
|
// Quota, private mode, or a disabled store. The caller is about to
|
|
@@ -62,29 +103,44 @@
|
|
|
62
103
|
}
|
|
63
104
|
}
|
|
64
105
|
|
|
65
|
-
|
|
66
|
-
// committing to advancing — the resume path uses take().
|
|
67
|
-
function peek() {
|
|
106
|
+
function readRecord(key) {
|
|
68
107
|
var s = store();
|
|
69
108
|
if (!s) return null;
|
|
70
109
|
var raw;
|
|
71
110
|
try {
|
|
72
|
-
raw = s.getItem(
|
|
111
|
+
raw = s.getItem(key);
|
|
73
112
|
} catch (e) {
|
|
74
113
|
return null;
|
|
75
114
|
}
|
|
76
115
|
if (!raw) return null;
|
|
77
116
|
|
|
78
|
-
var
|
|
117
|
+
var record;
|
|
79
118
|
try {
|
|
80
|
-
|
|
119
|
+
record = JSON.parse(raw);
|
|
81
120
|
} catch (e) {
|
|
82
121
|
// Corrupt entry: drop it rather than leave it to fail every future read.
|
|
83
|
-
|
|
122
|
+
removeRecord(key);
|
|
84
123
|
return null;
|
|
85
124
|
}
|
|
125
|
+
if (!record || typeof record !== 'object') { removeRecord(key); return null; }
|
|
126
|
+
return record;
|
|
127
|
+
}
|
|
86
128
|
|
|
87
|
-
|
|
129
|
+
function removeRecord(key) {
|
|
130
|
+
var s = store();
|
|
131
|
+
if (!s) return;
|
|
132
|
+
try { s.removeItem(key); } catch (e) { /* nothing to do and nothing to say */ }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function save(journal) {
|
|
136
|
+
return writeRecord(KEY, journal);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Read WITHOUT clearing. For a callback page that wants to inspect before
|
|
140
|
+
// committing to advancing — the resume path uses take().
|
|
141
|
+
function peek() {
|
|
142
|
+
var journal = readRecord(KEY);
|
|
143
|
+
if (!journal) return null;
|
|
88
144
|
|
|
89
145
|
if (typeof journal.startedAt === 'number' && (Date.now() - journal.startedAt) > MAX_AGE_MS) {
|
|
90
146
|
// EXPIRED IS NOT AN ERROR, it is an answer. Clearing here means the next
|
|
@@ -105,14 +161,19 @@
|
|
|
105
161
|
}
|
|
106
162
|
|
|
107
163
|
function clear() {
|
|
108
|
-
|
|
109
|
-
if (!s) return;
|
|
110
|
-
try { s.removeItem(KEY); } catch (e) { /* nothing to do and nothing to say */ }
|
|
164
|
+
removeRecord(KEY);
|
|
111
165
|
}
|
|
112
166
|
|
|
113
|
-
// Purge every key this subsystem owns. A host
|
|
114
|
-
// journal belongs to the person who started it, and
|
|
115
|
-
// would offer to resume a stranger's signature.
|
|
167
|
+
// Purge every key this subsystem owns — BOTH records. A host MUST call this on
|
|
168
|
+
// logout and user switch: a journal belongs to the person who started it, and
|
|
169
|
+
// one that outlived a logout would offer to resume a stranger's signature. The
|
|
170
|
+
// wallet session is the same hazard with a much longer fuse — it never expires
|
|
171
|
+
// — and it is swept by this same call because it shares the PREFIX.
|
|
172
|
+
//
|
|
173
|
+
// MUST, not DOES. Nothing in this ecosystem calls it yet; turf-monster sweeps
|
|
174
|
+
// the older `phantom_dl_` prefix, which does not match `wallet_dl_` and so
|
|
175
|
+
// clears neither record. A consumer adopting `owner` owes this call in the same
|
|
176
|
+
// change.
|
|
116
177
|
function purge() {
|
|
117
178
|
var s = store();
|
|
118
179
|
if (!s) return;
|
|
@@ -151,4 +212,179 @@
|
|
|
151
212
|
}
|
|
152
213
|
}
|
|
153
214
|
};
|
|
215
|
+
|
|
216
|
+
// === SolanaStudio.walletSession ==========================================
|
|
217
|
+
//
|
|
218
|
+
// THE RECORD THAT MAKES A RETURNING USER ONE HOP. Every mobile signing trip
|
|
219
|
+
// costs two app switches today — connect, then sign — and the second one is
|
|
220
|
+
// paid over and over for a session that, per Phantom's, Solflare's and
|
|
221
|
+
// Backpack's own docs (verified 2026-09-07; recorded per wallet as
|
|
222
|
+
// `sessionsExpire: false` alongside a `sessionDocs` URL in wallet_transport.js's
|
|
223
|
+
// PROFILES table), NEVER EXPIRES. Nothing wrote it down, so nothing could reuse
|
|
224
|
+
// it. This is the writing down.
|
|
225
|
+
//
|
|
226
|
+
// THE OPPOSITE OF THE JOURNAL IN EVERY DIMENSION THAT MATTERS, which is why it
|
|
227
|
+
// is a separate record rather than another field on one:
|
|
228
|
+
//
|
|
229
|
+
// journal session
|
|
230
|
+
// reads take() — clears recall() — leaves it in place
|
|
231
|
+
// life 10 minutes until invalidated (see below)
|
|
232
|
+
// scope one trip one wallet, for one user
|
|
233
|
+
//
|
|
234
|
+
// Share the record and every completed trip would take() the session away with
|
|
235
|
+
// it, which is precisely the two-hop behaviour this exists to remove.
|
|
236
|
+
//
|
|
237
|
+
// WHAT INVALIDATES IT — the complete list, and there is no timer in it:
|
|
238
|
+
//
|
|
239
|
+
// 1. The WALLET refuses it. Vendor docs name the causes: an explicit
|
|
240
|
+
// disconnect, a wallet keypair change, the user switching networks, an
|
|
241
|
+
// app_url blocklisting. walletOps forgets the session and recovers the
|
|
242
|
+
// trip through a connect hop; see wallet_ops.js.
|
|
243
|
+
// 2. The SCOPE no longer matches — a different user, wallet or cluster.
|
|
244
|
+
// That is a miss, not an error: the caller takes an ordinary connect hop.
|
|
245
|
+
// 3. purge(), which a host calls on logout or user switch.
|
|
246
|
+
// 4. forget(), for an explicit disconnect in the app's own UI.
|
|
247
|
+
//
|
|
248
|
+
// No max age, deliberately. Inventing one would contradict the vendor fact
|
|
249
|
+
// this feature rests on and would re-introduce the second hop on a schedule.
|
|
250
|
+
//
|
|
251
|
+
// THE TOKEN IS OPAQUE AND STAYS OPAQUE. It decodes to a 64-byte signature plus
|
|
252
|
+
// JSON carrying app_url, timestamp, chain and cluster — and none of that is
|
|
253
|
+
// read here, on purpose. The WALLET is the only authority on whether a session
|
|
254
|
+
// is still good; a local parse can only ever produce a second opinion that is
|
|
255
|
+
// wrong in one of two directions. Nothing below decodes, validates, or
|
|
256
|
+
// inspects `credentials.session`. It is stored as given and handed back as
|
|
257
|
+
// given.
|
|
258
|
+
//
|
|
259
|
+
// WHAT IS STORED, AND THE ONE THING WORTH SAYING OUT LOUD. The credentials
|
|
260
|
+
// block is exactly the four fields the signing hop needs, and one of them is
|
|
261
|
+
// the dapp's ephemeral secret key. redirect_provider.js explains why that is
|
|
262
|
+
// safe — a per-connect x25519 keypair that cannot sign, spend or authorise
|
|
263
|
+
// anything, whose loss costs one reconnect — and that argument is unchanged
|
|
264
|
+
// here. What DOES change is its lifetime: it now lives until an invalidation
|
|
265
|
+
// above rather than ten minutes. The blast radius is still "can decrypt this
|
|
266
|
+
// app's wallet replies on this device", and purge() on logout is what keeps it
|
|
267
|
+
// from outliving the person it belongs to.
|
|
268
|
+
//
|
|
269
|
+
// NO user private key, NO unsigned transaction bytes, NO personal data — the
|
|
270
|
+
// same rule the journal keeps, and the owner token below is the host's own
|
|
271
|
+
// opaque handle, not an email or a name.
|
|
272
|
+
var SESSION_VERSION = 1;
|
|
273
|
+
|
|
274
|
+
// A scope value, reduced to the string it will be compared as. Numbers are
|
|
275
|
+
// accepted because a host's user id usually IS one; anything else is refused
|
|
276
|
+
// by owner() below rather than stringified.
|
|
277
|
+
function scopeValue(v) {
|
|
278
|
+
return (v === null || v === undefined) ? '' : String(v);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// The owner is the whole reason a session cannot be a stranger's.
|
|
282
|
+
//
|
|
283
|
+
// REFUSED RATHER THAN STRINGIFIED, and this is the guard that matters most in
|
|
284
|
+
// the file. An object handed in here would String() to '[object Object]' —
|
|
285
|
+
// one token that EVERY user matches — and the failure would be a stranger
|
|
286
|
+
// signing with a wallet session they never established, on a shared device,
|
|
287
|
+
// discovered by nobody. A string or a number is a real handle; anything else
|
|
288
|
+
// is a bug, and an unremembered session costs one extra app switch.
|
|
289
|
+
function ownerToken(owner) {
|
|
290
|
+
var t = typeof owner;
|
|
291
|
+
if (t !== 'string' && t !== 'number') return null;
|
|
292
|
+
var s = String(owner);
|
|
293
|
+
return s === '' ? null : s;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Persist the session for (owner, wallet, cluster). Returns whether it stuck —
|
|
297
|
+
// a caller that cannot store one simply keeps paying the connect hop, which is
|
|
298
|
+
// the behaviour it had before this record existed.
|
|
299
|
+
//
|
|
300
|
+
// ANONYMOUS IS REFUSED. A session with nobody to scope it to cannot be kept
|
|
301
|
+
// away from the next person at this browser, and there is no logout event on
|
|
302
|
+
// an anonymous page for purge() to ride. Not remembering costs one hop; the
|
|
303
|
+
// alternative costs a signature.
|
|
304
|
+
function remember(record) {
|
|
305
|
+
if (!record) return false;
|
|
306
|
+
var owner = ownerToken(record.owner);
|
|
307
|
+
if (!owner) return false;
|
|
308
|
+
|
|
309
|
+
var wallet = scopeValue(record.wallet);
|
|
310
|
+
if (!wallet) return false;
|
|
311
|
+
|
|
312
|
+
var c = record.credentials;
|
|
313
|
+
// ALL FOUR OR NONE. The signing hop re-derives the shared secret from
|
|
314
|
+
// walletPublicKey + dappSecretKey and puts `session` in the payload; a
|
|
315
|
+
// record missing any of them would be recalled, used, and fail inside the
|
|
316
|
+
// codec — a decryption error standing in for a storage bug, one page death
|
|
317
|
+
// from here.
|
|
318
|
+
if (!c || !c.session || !c.walletPublicKey || !c.dappSecretKey || !c.dappPublicKey) return false;
|
|
319
|
+
|
|
320
|
+
return writeRecord(SESSION_KEY, {
|
|
321
|
+
v: SESSION_VERSION,
|
|
322
|
+
owner: owner,
|
|
323
|
+
wallet: wallet,
|
|
324
|
+
cluster: scopeValue(record.cluster),
|
|
325
|
+
// The address this session signs as. Stored so a declared expectedAccount
|
|
326
|
+
// can be checked BEFORE the trip rather than discovered by a wallet
|
|
327
|
+
// refusal two app switches later.
|
|
328
|
+
publicKey: record.publicKey ? String(record.publicKey) : null,
|
|
329
|
+
credentials: {
|
|
330
|
+
dappSecretKey: c.dappSecretKey,
|
|
331
|
+
dappPublicKey: c.dappPublicKey,
|
|
332
|
+
walletPublicKey: c.walletPublicKey,
|
|
333
|
+
session: c.session
|
|
334
|
+
},
|
|
335
|
+
rememberedAt: Date.now()
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// The session for this exact scope, or null. `scope`:
|
|
340
|
+
// { owner, wallet, cluster, expectedAccount } — expectedAccount optional.
|
|
341
|
+
//
|
|
342
|
+
// EXACT MATCH ON ALL THREE SCOPE FIELDS, and a miss is an ordinary answer that
|
|
343
|
+
// costs a connect hop. There is ONE stored session rather than a table keyed
|
|
344
|
+
// by scope, which is the honest model: a user who switches wallet or network
|
|
345
|
+
// has changed their mind, and evicting the old one keeps "a stranger cannot
|
|
346
|
+
// read this" a property of one stamped record instead of an invariant over a
|
|
347
|
+
// growing set.
|
|
348
|
+
function recall(scope) {
|
|
349
|
+
scope = scope || {};
|
|
350
|
+
var owner = ownerToken(scope.owner);
|
|
351
|
+
if (!owner) return null;
|
|
352
|
+
|
|
353
|
+
var record = readRecord(SESSION_KEY);
|
|
354
|
+
if (!record) return null;
|
|
355
|
+
|
|
356
|
+
// A VERSION WE DO NOT UNDERSTAND READS AS NO SESSION — note the contrast
|
|
357
|
+
// with the journal, which THROWS on the same event. The journal is a trip in
|
|
358
|
+
// flight, where carrying on would decrypt garbage and the only safe answer
|
|
359
|
+
// is to stop loudly. Nothing is in flight here, and "connect again" is a
|
|
360
|
+
// complete and correct answer, so a stale shape costs one hop and no words.
|
|
361
|
+
if (record.v !== SESSION_VERSION) { forget(); return null; }
|
|
362
|
+
|
|
363
|
+
if (record.owner !== owner) return null;
|
|
364
|
+
if (record.wallet !== scopeValue(scope.wallet)) return null;
|
|
365
|
+
if (record.cluster !== scopeValue(scope.cluster)) return null;
|
|
366
|
+
|
|
367
|
+
// The account guard, applied where it is FREE. walletOps checks a declared
|
|
368
|
+
// expectedAccount on the connect callback; a warm session takes no connect
|
|
369
|
+
// hop, so without this the check would silently not happen on exactly the
|
|
370
|
+
// trips this feature adds. A wallet keypair change would eventually be
|
|
371
|
+
// refused by the wallet anyway — this turns two app switches and a refusal
|
|
372
|
+
// into an immediate, correct connect hop.
|
|
373
|
+
if (scope.expectedAccount && String(scope.expectedAccount) !== String(record.publicKey)) return null;
|
|
374
|
+
|
|
375
|
+
if (!record.credentials || !record.credentials.session) { forget(); return null; }
|
|
376
|
+
return record;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function forget() {
|
|
380
|
+
removeRecord(SESSION_KEY);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
W.SolanaStudio.walletSession = {
|
|
384
|
+
KEY: SESSION_KEY,
|
|
385
|
+
VERSION: SESSION_VERSION,
|
|
386
|
+
remember: remember,
|
|
387
|
+
recall: recall,
|
|
388
|
+
forget: forget
|
|
389
|
+
};
|
|
154
390
|
})(typeof window !== 'undefined' ? window : globalThis);
|
|
@@ -17,9 +17,33 @@
|
|
|
17
17
|
//
|
|
18
18
|
// walletOps.run('contest_entry', { contestId: 12 }, {
|
|
19
19
|
// provider: ...,
|
|
20
|
-
// expectedAccount: '<the address this account is linked to>' // optional
|
|
20
|
+
// expectedAccount: '<the address this account is linked to>', // optional
|
|
21
|
+
// owner: currentUser.id // optional
|
|
21
22
|
// });
|
|
22
23
|
//
|
|
24
|
+
// ONE HOP FOR A RETURNING USER — WHAT `owner` BUYS. Sessions never expire on
|
|
25
|
+
// Phantom, Solflare or Backpack (all three vendors' docs, verified 2026-09-07,
|
|
26
|
+
// recorded per wallet as `sessionsExpire: false` in wallet_transport.js's
|
|
27
|
+
// PROFILES table, each with the doc URL it came from). Nothing persisted one, so
|
|
28
|
+
// every mobile signing trip paid TWO app switches — connect, then sign — and the
|
|
29
|
+
// second one is where a real user's entry was lost on QA. Declare an `owner` and
|
|
30
|
+
// the connect hop's session is remembered, scoped to that user, that wallet and
|
|
31
|
+
// that cluster; the next trip skips straight to signing. Omit it and every trip
|
|
32
|
+
// behaves exactly as it did before, journal bytes included.
|
|
33
|
+
//
|
|
34
|
+
// The session lives in SolanaStudio.walletSession (wallet_journal.js), which
|
|
35
|
+
// documents its own lifetime and the four things that invalidate it. One of them
|
|
36
|
+
// is this file's to handle and is handled below: a wallet that refuses a stored
|
|
37
|
+
// session mid-trip recovers through a connect hop rather than losing the user's
|
|
38
|
+
// work (recoverThroughConnect).
|
|
39
|
+
//
|
|
40
|
+
// ⚠ ANOTHER IS THE HOST'S AND IS NOT WIRED UP TODAY. A logout must call
|
|
41
|
+
// walletJournal.purge(), which sweeps the session with the journal — and no
|
|
42
|
+
// consumer does: turf-monster sweeps the older `phantom_dl_` prefix, which does
|
|
43
|
+
// not match this subsystem's `wallet_dl_`. Passing `owner` without adding that
|
|
44
|
+
// call leaves a never-expiring session, and the dapp secret key beside it,
|
|
45
|
+
// alive across a logout on a shared device. Adopt them together.
|
|
46
|
+
//
|
|
23
47
|
// `signOnly` IS A REQUIREMENT OF THE TRANSACTION, NOT A PREFERENCE ABOUT THE
|
|
24
48
|
// WALLET, and it is the intent's to declare because only the intent knows the
|
|
25
49
|
// shape of the bytes it prepared. A CO-SIGNED transaction — one whose second
|
|
@@ -182,6 +206,14 @@
|
|
|
182
206
|
// from connect to signing. A hook would therefore be unreachable on exactly
|
|
183
207
|
// the hop it exists to guard, and would silently not run there — the worst of
|
|
184
208
|
// the three outcomes. A string survives the redirect; a function does not.
|
|
209
|
+
// A cluster reduced to the string walletSession will compare it as. Absent,
|
|
210
|
+
// null and '' all collapse to the same value on both sides, so a host that
|
|
211
|
+
// never declares a cluster still gets a consistent scope rather than a session
|
|
212
|
+
// it can store and never recall.
|
|
213
|
+
function scopeCluster(cluster) {
|
|
214
|
+
return (cluster === null || cluster === undefined) ? '' : String(cluster);
|
|
215
|
+
}
|
|
216
|
+
|
|
185
217
|
function shortAddress(address) {
|
|
186
218
|
var s = String(address);
|
|
187
219
|
return s.length > 12 ? s.slice(0, 4) + '…' + s.slice(-4) : s;
|
|
@@ -358,7 +390,73 @@
|
|
|
358
390
|
// Already connected? Go straight to signing. Otherwise connect first and
|
|
359
391
|
// carry the intent through — sessions do not expire on any of the three
|
|
360
392
|
// wallets, so this branch is taken once per user, not once per action.
|
|
393
|
+
//
|
|
394
|
+
// THAT SENTENCE WAS ONLY TRUE FOR A CALLER THAT KEPT THE SESSION ITSELF.
|
|
395
|
+
// Nothing did, so every trip paid the connect hop and the "once per user"
|
|
396
|
+
// claim described a code path nobody was on. `opts.owner` is what makes it
|
|
397
|
+
// literal: walletSession recalls the stored session for this exact
|
|
398
|
+
// (owner, wallet, cluster), and a returning user signs in ONE hop.
|
|
361
399
|
var existing = opts.session || null;
|
|
400
|
+
// WHETHER *WE* RECALLED IT, which is a different question from "is there a
|
|
401
|
+
// session". A caller that hands in `opts.session` owns that session: this
|
|
402
|
+
// file did not store it, has no record of it, and must not offer to
|
|
403
|
+
// recover or forget it. Recovery is a service for the sessions walletOps
|
|
404
|
+
// itself recalled, and this flag is what keeps the two apart.
|
|
405
|
+
var recalled = false;
|
|
406
|
+
if (!existing && opts.owner) {
|
|
407
|
+
var stored = studio().walletSession.recall({
|
|
408
|
+
owner: opts.owner,
|
|
409
|
+
wallet: provider.key,
|
|
410
|
+
cluster: opts.cluster,
|
|
411
|
+
// Checked HERE, where it is free. A warm trip takes no connect hop, so
|
|
412
|
+
// this is the only place a declared expectation can be honoured before
|
|
413
|
+
// the user is committed. A mismatch is a miss, not an error — the trip
|
|
414
|
+
// falls through to the connect hop below and is checked there.
|
|
415
|
+
expectedAccount: opts.expectedAccount
|
|
416
|
+
});
|
|
417
|
+
if (stored) { existing = stored.credentials; recalled = true; }
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// A TRIP THAT SKIPS THE CONNECT HOP HAS NO CONNECT HOP TO FALL BACK ON,
|
|
421
|
+
// so it writes down how to build one. The wallet can refuse a stored
|
|
422
|
+
// session mid-trip — vendor docs name an explicit disconnect, a keypair
|
|
423
|
+
// change, a network switch and an app_url blocklisting — and by then the
|
|
424
|
+
// user has already committed. The refusal arrives on the SIGNING callback,
|
|
425
|
+
// a different document that knows none of these values, so they travel in
|
|
426
|
+
// the journal for the same reason signOnly and expectedAccount do.
|
|
427
|
+
//
|
|
428
|
+
// `scope` is WHO this trip belongs to; `recovery` is HOW to rebuild the
|
|
429
|
+
// connect. They are deliberately separate records, on two different
|
|
430
|
+
// conditions:
|
|
431
|
+
//
|
|
432
|
+
// scope — whenever an owner is declared, warm or COLD. The cold trip
|
|
433
|
+
// is where a session is first learned, and the connect
|
|
434
|
+
// callback is a different document that may know no owner of
|
|
435
|
+
// its own. Stamping it from the journal is also the SAFER
|
|
436
|
+
// read: the session belongs to whoever started the trip, not
|
|
437
|
+
// to whoever happens to be signed in at the browser when the
|
|
438
|
+
// wallet answers.
|
|
439
|
+
// recovery — only on a trip that skipped the connect hop USING A SESSION
|
|
440
|
+
// WE RECALLED. Gated on `recalled`, not on `existing`: a
|
|
441
|
+
// caller-supplied `opts.session` also skips the hop, but this
|
|
442
|
+
// file has no record of it, so forgetting it would evict a
|
|
443
|
+
// DIFFERENT scope's session and the recovery would have no
|
|
444
|
+
// cluster to rebuild the connect with. The retry keeps scope
|
|
445
|
+
// and drops recovery, which bounds recovery to once. See
|
|
446
|
+
// recoverThroughConnect.
|
|
447
|
+
//
|
|
448
|
+
// Both set ONLY when the new options are used, so a caller that declares
|
|
449
|
+
// no owner writes a journal byte-identical to the one it wrote before this
|
|
450
|
+
// feature existed — INCLUDING one that supplies its own `opts.session`,
|
|
451
|
+
// which is the case the `existing` gate used to get wrong. Same rule
|
|
452
|
+
// signOnly follows, and the same reason JOURNAL_VERSION does not move.
|
|
453
|
+
if (opts.owner) {
|
|
454
|
+
intent.scope = { owner: String(opts.owner), cluster: scopeCluster(opts.cluster) };
|
|
455
|
+
}
|
|
456
|
+
if (recalled) {
|
|
457
|
+
intent.recovery = { appUrl: opts.appUrl || null, redirectLink: opts.redirectLink || null };
|
|
458
|
+
}
|
|
459
|
+
|
|
362
460
|
var begun = existing
|
|
363
461
|
? beginSigning(provider, intent, existing, opts)
|
|
364
462
|
: provider.beginConnect({
|
|
@@ -429,6 +527,127 @@
|
|
|
429
527
|
: provider.beginSignTransaction(payload);
|
|
430
528
|
}
|
|
431
529
|
|
|
530
|
+
// --- the persisted session -----------------------------------------------
|
|
531
|
+
//
|
|
532
|
+
// WRITTEN ON THE CONNECT CALLBACK, which is the one moment this transport
|
|
533
|
+
// holds all four credential fields AND the address they belong to. Everything
|
|
534
|
+
// after this is a lookup.
|
|
535
|
+
//
|
|
536
|
+
// The scope is read off the JOURNAL first and `opts` only as a fallback, and
|
|
537
|
+
// the order is the safety property, not a convenience. The journal says who
|
|
538
|
+
// STARTED the trip; `opts` says who is signed in at the browser when the
|
|
539
|
+
// wallet answers. Those are the same person in every ordinary case, and when
|
|
540
|
+
// they differ — a shared phone, a logout mid-trip — stamping the CURRENT user
|
|
541
|
+
// onto a wallet session someone else established is the one outcome that lets
|
|
542
|
+
// a stranger sign. Stamping the originator cannot: the next recall names a
|
|
543
|
+
// different owner and misses.
|
|
544
|
+
//
|
|
545
|
+
// A trip that declared no owner anywhere is simply not remembered. That is the
|
|
546
|
+
// behaviour every caller had before this existed, and it costs one app switch,
|
|
547
|
+
// not a signature.
|
|
548
|
+
function rememberSession(journal, connected, opts) {
|
|
549
|
+
var intent = connected.journal && connected.journal.intent;
|
|
550
|
+
var scope = (intent && intent.scope) || null;
|
|
551
|
+
var owner = (scope && scope.owner) || opts.owner || null;
|
|
552
|
+
if (!owner) return false;
|
|
553
|
+
|
|
554
|
+
return studio().walletSession.remember({
|
|
555
|
+
owner: owner,
|
|
556
|
+
wallet: journal.wallet,
|
|
557
|
+
cluster: scope ? scope.cluster : scopeCluster(opts.cluster),
|
|
558
|
+
publicKey: connected.publicKey,
|
|
559
|
+
credentials: {
|
|
560
|
+
dappSecretKey: connected.journal.dappSecretKey,
|
|
561
|
+
dappPublicKey: connected.journal.dappPublicKey,
|
|
562
|
+
walletPublicKey: connected.journal.walletPublicKey,
|
|
563
|
+
session: connected.session
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// THE REFUSAL PATH, AND THE REASON THIS FEATURE IS NOT JUST A CACHE.
|
|
569
|
+
//
|
|
570
|
+
// A stored session can be refused by the wallet mid-trip — vendor docs name an
|
|
571
|
+
// explicit disconnect, a wallet keypair change, the user switching networks,
|
|
572
|
+
// and an app_url blocklisting — and it is refused at the WORST possible
|
|
573
|
+
// moment: the user has already left for their wallet app and come back, so
|
|
574
|
+
// they are committed. Failing there loses whatever they were doing, which is
|
|
575
|
+
// exactly the class of loss this epic exists to remove (a real user's entry
|
|
576
|
+
// was lost on QA in this second hop).
|
|
577
|
+
//
|
|
578
|
+
// So the trip does not fail; it takes the hop it skipped. Forget the session,
|
|
579
|
+
// navigate to connect carrying THE SAME INTENT, and the ordinary connect
|
|
580
|
+
// callback picks it up and advances to signing on its own. The user pays one
|
|
581
|
+
// extra app switch — the two-hop cost they would have paid anyway — instead of
|
|
582
|
+
// starting over.
|
|
583
|
+
//
|
|
584
|
+
// `prepare()` IS NOT RE-RUN, and that is the point of reusing the journalled
|
|
585
|
+
// intent rather than rebuilding one. prepare MINTS things — turf-monster's
|
|
586
|
+
// mints a prepared-transaction row with a fresh blockhash — so re-running it
|
|
587
|
+
// would strand the first one and charge the flow twice for one user action.
|
|
588
|
+
// `intent.state` came back from the original prepare and is signed unchanged.
|
|
589
|
+
//
|
|
590
|
+
// ONCE, STRUCTURALLY. The retry intent carries `scope` but NOT `recovery`, so
|
|
591
|
+
// the hop it produces cannot recover again; a second refusal surfaces the
|
|
592
|
+
// wallet's own words. That is a shape, not a counter — there is no field to
|
|
593
|
+
// forget to decrement.
|
|
594
|
+
//
|
|
595
|
+
// AND IT RESTORES A GUARD THE WARM PATH GIVES UP. A session-skipping trip
|
|
596
|
+
// never learns which account connected, so a declared expectedAccount goes
|
|
597
|
+
// unchecked on it. The recovered trip goes through connect, where it IS
|
|
598
|
+
// checked — so the recovery from a keypair change ends in a sentence the user
|
|
599
|
+
// can act on rather than a chain error.
|
|
600
|
+
function recoverThroughConnect(provider, journal, opts, navigate) {
|
|
601
|
+
var intent = journal.intent;
|
|
602
|
+
var recovery = intent && intent.recovery;
|
|
603
|
+
// No recovery block means this trip already had a connect hop, or ran on a
|
|
604
|
+
// session this file never stored. There is nothing better to do than report
|
|
605
|
+
// what the wallet said.
|
|
606
|
+
if (!recovery) return null;
|
|
607
|
+
|
|
608
|
+
// A recovery block is only ever written beside a scope (both come from the
|
|
609
|
+
// same recall), so this is unreachable from a journal THIS release wrote.
|
|
610
|
+
// It is checked anyway because the alternative is silent and expensive: the
|
|
611
|
+
// cluster below would be null, `query()` drops a null, and the wallet would
|
|
612
|
+
// default the recovered connect to MAINNET-BETA — real funds on a trip that
|
|
613
|
+
// began on devnet. A journal from an older release is exactly the way that
|
|
614
|
+
// becomes reachable.
|
|
615
|
+
if (!intent.scope) return null;
|
|
616
|
+
|
|
617
|
+
// The redirect link is what the wallet returns to. Since #44, url.connect
|
|
618
|
+
// REFUSES to build without one, and that throw would escape this function
|
|
619
|
+
// and reach the user INSTEAD of the wallet's own error — a worse report
|
|
620
|
+
// about a different subject. Answer null and let the wallet speak.
|
|
621
|
+
var redirectLink = opts.redirectLink || recovery.redirectLink;
|
|
622
|
+
if (!redirectLink) return null;
|
|
623
|
+
|
|
624
|
+
// Only now, once the retry is certain to be buildable. Forgetting before
|
|
625
|
+
// this point would drop the session on a trip that then reports the wallet's
|
|
626
|
+
// error anyway, costing the user a stored session for nothing.
|
|
627
|
+
studio().walletSession.forget();
|
|
628
|
+
|
|
629
|
+
var again = { op: intent.op, ctx: intent.ctx, state: intent.state };
|
|
630
|
+
if (intent.signOnly) again.signOnly = true;
|
|
631
|
+
if (intent.expectedAccount) again.expectedAccount = intent.expectedAccount;
|
|
632
|
+
again.scope = intent.scope;
|
|
633
|
+
|
|
634
|
+
var begun = provider.beginConnect({
|
|
635
|
+
appUrl: recovery.appUrl,
|
|
636
|
+
redirectLink: redirectLink,
|
|
637
|
+
// THE CLUSTER THE TRIP BEGAN ON, never null. See the scope guard above.
|
|
638
|
+
cluster: intent.scope.cluster,
|
|
639
|
+
intent: again
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
// A store that cannot record the retry cannot complete it either. Returning
|
|
643
|
+
// null hands the caller back to the wallet's own error, which is a truer
|
|
644
|
+
// report than "could not record" for a user whose browser just refused a
|
|
645
|
+
// write.
|
|
646
|
+
if (!studio().walletJournal.save(begun.journal)) return null;
|
|
647
|
+
navigate(begun.url);
|
|
648
|
+
return { pending: true, suspended: true, recovered: true, url: begun.url };
|
|
649
|
+
}
|
|
650
|
+
|
|
432
651
|
// Called by the callback page. Reads the pending journal, advances one step,
|
|
433
652
|
// and either navigates again or hands back the finished result.
|
|
434
653
|
//
|
|
@@ -458,6 +677,12 @@
|
|
|
458
677
|
// next hop, which is what makes a transaction on a cold session two
|
|
459
678
|
// navigations rather than two user-initiated attempts.
|
|
460
679
|
if (!intent) {
|
|
680
|
+
// A PLAIN SIGN-IN IS THE MOST VALUABLE SESSION THERE IS, because it is
|
|
681
|
+
// the one every user establishes before they ever ask to sign
|
|
682
|
+
// anything. Remembering it here is what makes a returning user's FIRST
|
|
683
|
+
// action one hop rather than their second. The host owns this connect
|
|
684
|
+
// (walletOps did not start it), so the owner can only come from opts.
|
|
685
|
+
rememberSession(journal, connected, opts);
|
|
461
686
|
return Promise.resolve({ pending: true, done: true, connect: connected });
|
|
462
687
|
}
|
|
463
688
|
|
|
@@ -474,6 +699,12 @@
|
|
|
474
699
|
// it established one, and that is where it belongs.
|
|
475
700
|
assertExpectedAccount(intent.expectedAccount, connected.publicKey);
|
|
476
701
|
|
|
702
|
+
// AFTER the account guard, never before. A wrong wallet must not evict
|
|
703
|
+
// the session belonging to the right one — and a session stored for an
|
|
704
|
+
// account the caller has already refused is a record that can only ever
|
|
705
|
+
// be recalled into the same refusal.
|
|
706
|
+
rememberSession(journal, connected, opts);
|
|
707
|
+
|
|
477
708
|
var next = signingHop(provider, connected.journal, {
|
|
478
709
|
redirectLink: opts.redirectLink || journal.redirectLink
|
|
479
710
|
});
|
|
@@ -492,9 +723,37 @@
|
|
|
492
723
|
var handler = requireHandler(journal.intent && journal.intent.op);
|
|
493
724
|
|
|
494
725
|
var wallet = journal.step === 'signAndSendTransaction';
|
|
495
|
-
var out
|
|
496
|
-
|
|
497
|
-
|
|
726
|
+
var out;
|
|
727
|
+
try {
|
|
728
|
+
out = wallet
|
|
729
|
+
? provider.completeSignAndSendTransaction(params, journal)
|
|
730
|
+
: provider.completeSignTransaction(params, journal);
|
|
731
|
+
} catch (walletError) {
|
|
732
|
+
// WHICH FAILURES RECOVER, stated as one rule: an error the WALLET
|
|
733
|
+
// reported, that is not the user saying no.
|
|
734
|
+
//
|
|
735
|
+
// `code` is set only by throwIfWalletError, so its presence IS "the
|
|
736
|
+
// wallet answered with an error redirect" — the channel every
|
|
737
|
+
// documented refusal cause arrives on. `rejected` (4001) is carved out
|
|
738
|
+
// because a user who declined has a perfectly good session and being
|
|
739
|
+
// sent back to their wallet for another look is hostile.
|
|
740
|
+
//
|
|
741
|
+
// Deliberately WIDER than the four documented causes. Their codes are
|
|
742
|
+
// not pinned here, and guessing a narrow list wrong costs the user
|
|
743
|
+
// their entry — the exact failure this feature removes — while
|
|
744
|
+
// guessing wide costs one app switch and then surfaces the same error
|
|
745
|
+
// honestly, because the retry cannot recover again. When the two
|
|
746
|
+
// mistakes are that asymmetric, take the cheap one.
|
|
747
|
+
//
|
|
748
|
+
// DECRYPTION FAILURES ARE NOT IN THIS RULE, and that is on purpose:
|
|
749
|
+
// they carry no `code`, they are not a refusal channel any vendor
|
|
750
|
+
// documents, and a corrupt payload is a different finding that should
|
|
751
|
+
// read as itself rather than as a session problem.
|
|
752
|
+
if (!walletError.code || walletError.rejected) throw walletError;
|
|
753
|
+
var recovered = recoverThroughConnect(provider, journal, opts, navigate);
|
|
754
|
+
if (recovered) return Promise.resolve(recovered);
|
|
755
|
+
throw walletError;
|
|
756
|
+
}
|
|
498
757
|
return Promise.resolve(handler.complete(journal.intent.ctx, {
|
|
499
758
|
signature: out.signature || null,
|
|
500
759
|
signedTransaction: out.transaction || null,
|
|
@@ -556,6 +815,22 @@
|
|
|
556
815
|
typeof opts.expectedAccount + ' — call .toString() on a PublicKey first'
|
|
557
816
|
));
|
|
558
817
|
}
|
|
818
|
+
// REFUSED RATHER THAN STRINGIFIED, and for a sharper reason than
|
|
819
|
+
// expectedAccount's. An object here would String() to '[object Object]' —
|
|
820
|
+
// ONE owner token that every user of this browser matches — and the
|
|
821
|
+
// symptom would be a stranger offered a one-hop signature with a wallet
|
|
822
|
+
// session they never established. walletSession.remember() refuses the
|
|
823
|
+
// same value on its own, so nothing can actually be stored under it; this
|
|
824
|
+
// check exists so the bug is named at the call site that wrote it rather
|
|
825
|
+
// than showing up as a feature that quietly never works.
|
|
826
|
+
if (opts.owner !== undefined && opts.owner !== null &&
|
|
827
|
+
typeof opts.owner !== 'string' && typeof opts.owner !== 'number') {
|
|
828
|
+
return Promise.reject(new Error(
|
|
829
|
+
'walletOps.run owner must be a string or number identifying the ' +
|
|
830
|
+
'signed-in user, got ' + typeof opts.owner + ' — an object would ' +
|
|
831
|
+
'stringify to one token every user shares'
|
|
832
|
+
));
|
|
833
|
+
}
|
|
559
834
|
return opts.provider.transport === 'redirect'
|
|
560
835
|
? runRedirect(name, ctx, opts)
|
|
561
836
|
: runInline(name, ctx, opts);
|
|
@@ -114,6 +114,15 @@
|
|
|
114
114
|
// different from its own provider methods.
|
|
115
115
|
browsePath: '/ul/browse/',
|
|
116
116
|
clusters: ['mainnet-beta', 'testnet', 'devnet'],
|
|
117
|
+
// SESSIONS DO NOT EXPIRE. Phantom's "Handling Sessions" page states it
|
|
118
|
+
// outright: a session token stays valid until the user disconnects, the
|
|
119
|
+
// wallet's keypair changes, the user switches networks, or the app_url is
|
|
120
|
+
// blocklisted — there is no TTL. This is the fact SolanaStudio.walletSession
|
|
121
|
+
// is built on, so it is recorded HERE, as data, next to every other
|
|
122
|
+
// per-wallet fact, rather than asserted in a comment somewhere downstream.
|
|
123
|
+
// Verified 2026-09-07.
|
|
124
|
+
sessionsExpire: false,
|
|
125
|
+
sessionDocs: 'https://docs.phantom.com/phantom-deeplinks/handling-sessions',
|
|
117
126
|
// DEPRECATED BY PHANTOM: "The signAndSendTransaction deeplink is
|
|
118
127
|
// deprecated. Use signAllTransactions or signTransaction instead." So on
|
|
119
128
|
// Phantom the APP still broadcasts — sendRawTransaction + a confirmation
|
|
@@ -136,6 +145,10 @@
|
|
|
136
145
|
connectKeys: ['solflare_encryption_public_key'],
|
|
137
146
|
browsePath: '/ul/v1/browse/',
|
|
138
147
|
clusters: ['mainnet-beta', 'testnet', 'devnet'],
|
|
148
|
+
// Same as Phantom, which is expected — Solflare forked the spec and its
|
|
149
|
+
// docs link Phantom's own blocklist repo. Verified 2026-09-07.
|
|
150
|
+
sessionsExpire: false,
|
|
151
|
+
sessionDocs: 'https://docs.solflare.com/solflare/technical/deeplinks/provider-methods/connect',
|
|
139
152
|
send: 'wallet-broadcasts',
|
|
140
153
|
methods: {
|
|
141
154
|
connect: true, disconnect: true, signMessage: true,
|
|
@@ -164,6 +177,13 @@
|
|
|
164
177
|
// devnet cannot currently QA this wallet, which is a lane decision, not a
|
|
165
178
|
// bug to paper over here. supportsCluster() reports it honestly.
|
|
166
179
|
clusters: ['mainnet-beta'],
|
|
180
|
+
// Same as the other two. Backpack's session handling is the forked
|
|
181
|
+
// Phantom spec, and nothing in its corpus documents a TTL.
|
|
182
|
+
// Verified 2026-09-07 — and see the connectKeys note above: this vendor's
|
|
183
|
+
// docs are the least settled of the three, so this is the entry to
|
|
184
|
+
// re-confirm first if a session ever comes back refused for no reason.
|
|
185
|
+
sessionsExpire: false,
|
|
186
|
+
sessionDocs: 'https://docs.backpack.app/backpack-deeplinks/provider-methods/connect',
|
|
167
187
|
send: 'wallet-broadcasts',
|
|
168
188
|
methods: {
|
|
169
189
|
connect: true, disconnect: true, signMessage: true,
|
|
@@ -16,5 +16,5 @@ module SolanaStudio
|
|
|
16
16
|
# through the normal cycle. Splitting the version out is the same shape
|
|
17
17
|
# studio-engine already uses (lib/studio/version.rb) and hands each file back
|
|
18
18
|
# to its real owner: this one to the release, the gemspec to the PR.
|
|
19
|
-
VERSION = "0.
|
|
19
|
+
VERSION = "0.11.0"
|
|
20
20
|
end
|