solana-studio 0.11.0 → 0.12.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 +46 -3
- data/README.md +239 -26
- data/app/assets/javascripts/solana_studio/wallet_identity.js +524 -0
- data/lib/solana/auth_verifier.rb +12 -0
- data/lib/solana/client.rb +54 -1
- data/lib/solana/compute_budget.rb +73 -0
- data/lib/solana/cosign/builder.rb +159 -0
- data/lib/solana/cosign/completer.rb +217 -0
- data/lib/solana/cosign/expectation.rb +272 -0
- data/lib/solana/cosign.rb +198 -0
- data/lib/solana/ed25519_strict.rb +185 -0
- data/lib/solana/keypair.rb +4 -1
- data/lib/solana/wire_message.rb +223 -0
- data/lib/solana_studio/engine.rb +1 -0
- data/lib/solana_studio/version.rb +1 -1
- data/lib/solana_studio.rb +6 -0
- metadata +10 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a067bc88c6dbfae4cd19a6a0132c0c54864e20e2c405211d935b91d134855111
|
|
4
|
+
data.tar.gz: 2c15956cee0c63082e85dd73f53c601b2f7c5fac5ec6e455830225e649ca8a2a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8788bdff89c7b8d0d80d22fe2159c97e49d9955e573dcb41ec4667361ab05e354ca8ff30227a6de4bbf904e79682d309aeeef9c868d720c83850e42824d14706
|
|
7
|
+
data.tar.gz: f77ab0deb992f4105085f5ddfb3eb2267c52c6b2373c2eba81401369553fc5b6803fbad70e345761190f7c10ed05d88b413338afc3c7d678109abb334e6cff17
|
data/CHANGELOG.md
CHANGED
|
@@ -4,9 +4,55 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## v0.12.0 (2026-09-17)
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- **`Solana::Cosign::Expectation#verify!` reads Lighthouse instructions instead of waving them through** (`lib/solana/cosign.rb`, `lib/solana/cosign/expectation.rb`). The guard skipped every instruction of Phantom's Lighthouse program without reading its data, on the belief that Lighthouse only asserts. Two of its variants spend a signer's lamports: MemoryWrite (0) makes the signer named as payer fund a memory account whose size the instruction picks, and MemoryClose (1) refunds one. The fee payer signs every cosigned wire, so a wallet, or anyone holding the user's key, could return a wire naming the fee payer as payer, and the completer would sign it and lock the house's SOL, about 0.05 SOL per 10 KiB and repeatable. A Lighthouse instruction is now admitted only when its first byte is an assertion variant, 2 through 17 (`Cosign::LIGHTHOUSE_ASSERTIONS`). `WireRejected` refuses 0 (`lighthouse_memory_write`), 1 (`lighthouse_memory_close`), empty data (`lighthouse_empty_data`) and every other byte (`lighthouse_unknown_disc`), the same codes turf-monster's guards use. Naming the fee payer is not the test, because all five real Phantom transactions assert against the fee payer. The variants were checked against the deployed, immutable program on 2026-09-16 by read-only simulation, and the evidence sits in the `LIGHTHOUSE_PROGRAM_ID` comment. `extra_programs:` now accepts only a program with a rule that reads its instructions. That is Lighthouse alone, so every other program id raises `ArgumentError`, including the fund-moving ones `REFUSED_EXTRA_PROGRAMS` used to list. `Solana::Cosign` has not shipped, so no host is affected.
|
|
11
|
+
- **Tests: `test/cosign_lighthouse_test.rb` (11, new)** — every one of the 37 Lighthouse instructions in five cosigned mainnet Phantom transactions (32 of variant 6, 5 of variant 10), in their on-chain placement, still cosigns and completes. All 256 first bytes are judged, and exactly 2..17 are admitted. A MemoryWrite whose payer is the fee payer is refused, before or after the app instruction and hidden among real assertions, and `#complete` refuses it with no RPC call. MemoryClose, empty data and variants 18, 99 and 255 are refused, and a Memo program id is refused as an extra program. `test/cosign_support.rb`'s `lighthouse_instruction` now carries a real assertion; its old data began with byte 1, a MemoryClose.
|
|
12
|
+
- **`Solana::AuthVerifier.verify!` refuses small-order and non-canonical public keys** (`lib/solana/auth_verifier.rb`, new `lib/solana/ed25519_strict.rb`). Small-order public keys were accepted by `verify!`: `Ed25519::VerifyKey` (ref10) checks the verification equation and nothing about the key, so an address with no secret key behind it could complete a sign-in. `Solana::Ed25519Strict` now runs first and requires the key to be a canonical encoding of a point that is not small-order and lies in the prime-order subgroup, R to be canonical and not small-order, and S to be reduced below the group order. That is the cluster's own `verify_strict` rule plus the subgroup check on the key, which every wallet-generated key passes. `verify!` raises `VerificationError` naming which part failed. **Ship this in the same release as the all-`1` decode fix below**: that fix makes the System Program id decode to a 32-byte key, one of the spellings refused here.
|
|
13
|
+
- **`WireMessage#signature_valid?` uses the same strict check**, so `Cosign::Completer` refuses a signer slot the cluster would refuse before the fee payer signs, as its contract already says it does.
|
|
14
|
+
- **Tests: `test/ed25519_strict_test.rb` (12, new), `test/auth_verifier_test.rb` (+7), `test/wire_message_test.rb` (+1)**, with the shared vectors in `test/ed25519_forgery_support.rb`. Every refusal is preceded by a control asserting the raw `ed25519` gem accepts the same bytes, and real sign-ins from 26 keys still verify. 10 mutants against the new checks (each check removed in turn, the torsion multiplier weakened, `signature_valid?` reverted), all 10 KILLED.
|
|
15
|
+
- **`Keypair.decode_base58` of an all-`1` string now returns one zero byte per character** (`lib/solana/keypair.rb`). A value of zero has no body, but the decoder emitted a `00` body for it on top of the leading-zero bytes, so the System Program id `11111111111111111111111111111111` decoded to 33 bytes. `Solana::Cosign.key_bytes` drops the special case that routed around it. A consumer that decodes that address and hands it to `Borsh.encode_pubkey` got `Invalid base58 character "\x00"` before this fix (encode_pubkey re-read the 33-byte result as base58); it now gets the zero key. turf-monster's `Solana::Vault#build_update_signers` pads its signer array exactly that way.
|
|
16
|
+
- **Tests: `test/keypair_test.rb` (+4)** — the all-`1` System Program id, every all-`1` length from 1 to 44, the zero key's base58 round trip, and leading `1`s before a non-zero body. The first three fail on the old decoder.
|
|
17
|
+
- **Tests: `test/borsh_test.rb` (+1)** — an Anchor signer array padded with the zero address, built the way turf-monster builds `update_signers`, encodes 32 bytes per slot.
|
|
18
|
+
- **Tests: `test/cosign_key_bytes_test.rb` (5, new)** — `Cosign.key_bytes` of the all-`1` address is the zero key, never its ASCII bytes. With the special case removed and the old decoder kept, three of the five fail.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
- **`SolanaStudio.walletIdentity` — the connected wallet as a session identity source** (`app/assets/javascripts/solana_studio/wallet_identity.js`, new; `lib/solana_studio/engine.rb` precompiles it). studio-engine's session-drift primitive compares the identities a page was rendered for with what the browser observes, and stays web2 by rule; this is the wallet half it plugs in. `register(options)` creates the source and hands it to `window.StudioSession.registerIdentitySource`; `create(options)` returns the bare source. It reports four statuses to the page (`unknown`, `none`, `disconnected`, `connected`) and the engine's three values to the session (undefined, null, the address), so "cannot tell yet" never reads as "no wallet". It reads Phantom's injected provider and raw Wallet Standard wallets live (`publicKey`, `wallet.accounts`), never from a cached account, and re-reads on `focus`, `visibilitychange` and `pageshow`, so a switch made while the tab was hidden is caught. Only a different connected address mismatches; a disconnect does not unless `disconnectIsMismatch: true`. A silent connect (`trustedConnect`) is off by default because it can pop Phantom's unlock prompt. It never signs, sends, writes storage or opens a modal.
|
|
22
|
+
- **`Solana::Cosign` — gasless, cosigned transactions any Solana app can stand up without copying a file out of turf-monster** (`lib/solana/cosign.rb`, `lib/solana/cosign/{builder,expectation,completer}.rb`). The orchestration around `Transaction.cosign_wire` used to live only in turf-monster's `Solana::Vault` (`build_partial_unsigned`, the three `assert_*_cosign_safe!` guards, `cosign_and_broadcast_*`, `simulate_and_broadcast`). The generic half is here now, with no key storage, no environment variable and no program of anyone's: the caller passes the fee payer `Solana::Keypair` in and supplies its own instructions. `Builder#build(instructions:, cosigners:, compute_unit_price:, compute_unit_limit:, commitment: "confirmed", presign: false)` puts the fee payer in account 0, leaves every cosigner slot empty and returns `Prepared` with the blockhash AND `last_valid_block_height`, so a caller finally knows when a prepared transaction dies. `Completer#verify!` / `#cosign` / `#complete` judge the wallet-returned wire by meaning (fee payer account 0 and writable, exact signer set, built instructions exactly, ComputeBudget read and capped at 10x the builder's fee, Lighthouse assertions admitted), check every cosigner signature, fill the fee payer's slot, check the block height, simulate, call `before_send(signature)` so the caller records the signature BEFORE the broadcast, send and confirm. `Expectation.from_wire` rebuilds the expectation from a wire the server stored, for the request that receives the signature. Every entry point takes `encoding: :base58` as well as the default base64, and `Prepared#wire_base58` / `Cosigned#wire_base58` hand back the format `SolanaStudio.walletOps` speaks, so a host needs none of the `atob` / `base58.encode` / `btoa` conversions turf-monster's entry intent carries today.
|
|
23
|
+
- **The error hierarchy is the safety seam.** `WireRejected` (nothing signed); `PreflightRejected` → `BlockhashExpired`, `SimulationFailed` (provably never sent, rebuild freely); `BroadcastFailed` → `BroadcastExpired` (may be on chain, reconcile `#signature` first); `TransactionFailed` (landed, failed). A send-time `Blockhash not found` is deliberately a `BroadcastExpired`, never a `PreflightRejected`: `Client#call` re-posts the same wire on a lost answer, so the first attempt may have been forwarded. This carries turf-monster's `Vault::PreflightRejected` seam into the gem.
|
|
24
|
+
- **`Solana::ComputeBudget`** (`set_compute_unit_limit`, `set_compute_unit_price`, `parse`, `priority_fee_micro_lamports`), byte-matched against `@solana/web3.js`, and **`Solana::WireMessage`**, a fail-closed legacy wire decoder (versioned messages, truncation, trailing bytes, out-of-range indices and duplicate account keys all raise `MalformedError`), with `#signature`, `#signer?`, `#writable?` and `#signature_valid?`.
|
|
25
|
+
- **`Client#latest_blockhash(commitment: "confirmed")`** returns the hash with `last_valid_block_height`; **`#get_block_height`** and **`#blockhash_valid?`** answer the deadline; **`#send_transaction(..., preflight_commitment:)`** sends the commitment only when given. All additive: `#get_latest_blockhash` still returns a bare `"finalized"` hash and `#send_transaction` sends exactly the options it sent before.
|
|
26
|
+
|
|
27
|
+
### Tests
|
|
28
|
+
- **`test/wallet_identity_js_test.rb` (new)** runs the shipped script under node against a fake Phantom injected provider, a fake Wallet Standard wallet and a stub `StudioSession` written from studio-engine's `docs/SESSION_DRIFT.md` contract, with manual timers. It covers discovery (`unknown` until the window closes, then `none`; late injection; a rescan event), connect, switch, lock and disconnect on both shapes, a Wallet Standard change read from the wallet's live `accounts` rather than the event's copy, a switch and a disconnect made while the tab was hidden, a superseded provider ignored and detached, listeners bound once (including a provider that cannot remove them), the silent connect (unknown until it settles, a rejection reads disconnected, a wallet event during it wins, no flash while a reconcile probes), and, through the stub, an undeclared switch mismatching and resolving, a hold making it expected, a disconnect not mismatching, and a pre-auth page never mismatching.
|
|
29
|
+
- **`e2e/wallet_identity.spec.js` (4, new)** drives the shipped script in Chromium through a new lab page that registers it and paints what it reports: it executes, the default resolver finds a Phantom provider injected before page scripts, real timers run discovery (`unknown`, then `none`; a wallet registered after load is found), and listeners on a real Window and Document repaint on a change. Headless Chromium never hides a tab (measured: `bringToFront` and a CDP window minimize both leave `visibilityState` visible), so the hidden-tab case emulates visibility and says so. `config/e2e_lane.yml` goes from 37 to 41 specs.
|
|
30
|
+
- **`test/engine_test.rb` (+1)** runs the `solana_studio.assets` initializer and asserts its precompile list equals the scripts on disk, so a new script cannot ship packaged but unserved on a sprockets host. `test/gemspec_test.rb` pins the new file in the manifest, and CI's built-gem check requires it.
|
|
31
|
+
- **`Solana::Cosign`: 5 new test files, 92 tests** — `test/cosign_expectation_test.rb` (28) hands the guard wires assembled independently of the builder, as a wallet or an attacker with the user's key would: a Lighthouse assertion inserted anywhere, a permuted account list and a raised-but-capped price are ADMITTED; a different fee payer, a read-only fee payer, a System transfer draining the fee payer, a nonce advance, an altered amount, a swapped account, a duplicated or missing instruction, an unknown program, an unbuilt SPL transfer, an attacker's extra signer, a missing cosigner, a price or total fee over the cap, a duplicate or unknown ComputeBudget instruction and a pinned-blockhash mismatch are REFUSED, each with no RPC call. `test/cosign_completer_test.rb` (29) pins the order (height check, simulate, `before_send`, send, confirm), the commitment carried from build to send, and every error class and its place in the hierarchy. Plus `test/cosign_builder_test.rb` (15), `test/wire_message_test.rb` (13), `test/compute_budget_test.rb` (7) and 6 new `test/client_test.rb` tests. Every key is generated in-test; nothing touches a network.
|
|
32
|
+
- **35 mutants against the new code, all 35 KILLED**, each reverted after (`git checkout`, tree verified clean): every guard rule disabled in turn, the cosigner signature check removed, a presigned-but-modified wire accepted, the expiry check removed, `before_send` dropped, `BroadcastExpired` retyped as `BlockhashExpired`, the preflight commitment dropped, the simulation's `replaceRecentBlockhash` flipped, the decoder's duplicate-key, trailing-byte, versioned-message and writable-layout checks removed, the builder's fee payer moved out of account 0, the client always sending `preflightCommitment`, and both `encoding: :base58` dispatches. Two survived the first run (the read-only fee payer check and the signer writable layout); `test_refuses_a_read_only_fee_payer` and `test_a_readonly_signer_is_not_writable` were added and both are now killed.
|
|
33
|
+
|
|
34
|
+
### Notes for hosts
|
|
35
|
+
- **`walletIdentity` changes nothing until a host registers it.** It needs studio-engine's session-drift primitive (`studio/session.js`, unreleased at the time of writing) for the mismatch half; without it the source still runs and reports to the page. The host owes the server binding (`studio_session_identities` returning `{ wallet: ... }` for sessions that signed in with a wallet, and nothing for the rest), the UI, and the re-auth. **A hold is per source, not per address**: `StudioSession.expectChange("wallet")` silences every switch until released, so a ceremony that declared specific wallets must still check the observed address against them.
|
|
36
|
+
- **`Solana::Cosign` changes nothing until a host calls it.** turf-monster still runs its own `Solana::Vault` cosign path. Moving it onto `Solana::Cosign` is a separate turf-monster task, and it should keep its own expectations (the entry PDA, the token PDA the server chose, the contest params, the cash-out destination) as the instructions it passes in. Two differences to plan for: the guard compares every built instruction EXACTLY, where turf's entry guard checks the discriminator and two accounts; and the builder defaults to a `"confirmed"` blockhash, which must be sent with `preflight_commitment: "confirmed"` — the completer does that, a hand-rolled send does not.
|
|
37
|
+
|
|
38
|
+
## v0.11.0 (2026-09-10)
|
|
39
|
+
|
|
40
|
+
### Tests
|
|
41
|
+
- **`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.
|
|
42
|
+
- **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.
|
|
43
|
+
- **`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.
|
|
44
|
+
|
|
45
|
+
## v0.10.0 (2026-09-10)
|
|
46
|
+
|
|
7
47
|
### Added
|
|
8
48
|
- **`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
49
|
- **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.
|
|
50
|
+
|
|
51
|
+
## v0.9.3 (2026-09-09)
|
|
52
|
+
|
|
53
|
+
## v0.9.2 (2026-09-09)
|
|
54
|
+
|
|
55
|
+
### Added
|
|
10
56
|
- **`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.
|
|
11
57
|
- **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.
|
|
12
58
|
|
|
@@ -22,9 +68,6 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
22
68
|
- **`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`.
|
|
23
69
|
- **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.
|
|
24
70
|
- **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.
|
|
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.
|
|
28
71
|
|
|
29
72
|
### Notes for hosts
|
|
30
73
|
- **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.
|
data/README.md
CHANGED
|
@@ -39,7 +39,10 @@ signature = kp.sign("hello".b)
|
|
|
39
39
|
client = Solana::Client.new(rpc_url: "https://api.devnet.solana.com")
|
|
40
40
|
|
|
41
41
|
client.get_balance("9Fy8P3DvKBh3awt...")
|
|
42
|
-
client.get_latest_blockhash
|
|
42
|
+
client.get_latest_blockhash # hash only, "finalized"
|
|
43
|
+
client.latest_blockhash # hash + last_valid_block_height, "confirmed"
|
|
44
|
+
client.get_block_height # compare against last_valid_block_height
|
|
45
|
+
client.send_transaction(wire_b64, preflight_commitment: "confirmed") # match the fetch
|
|
43
46
|
client.request_airdrop("9Fy8P3DvKBh3awt...", 1_000_000_000)
|
|
44
47
|
client.send_and_confirm(signed_tx_base64)
|
|
45
48
|
```
|
|
@@ -106,6 +109,108 @@ genesis is minted per boot) or an unrecognized cluster name. Treating it as
|
|
|
106
109
|
`:mismatched` refuses to boot every local validator; treating it as `:aligned`
|
|
107
110
|
trusts a chain nobody checked.
|
|
108
111
|
|
|
112
|
+
### Gasless cosigned transactions (`Solana::Cosign`)
|
|
113
|
+
|
|
114
|
+
The pattern every app with a house wallet needs: **the user's wallet signs, the
|
|
115
|
+
house pays the fee**, so users never hold SOL. The server builds the transaction
|
|
116
|
+
with its fee payer in account 0 and an empty slot for each cosigner, the wallet
|
|
117
|
+
signs, and the server proves the returned wire is still what it built before it
|
|
118
|
+
adds its own signature.
|
|
119
|
+
|
|
120
|
+
The gem holds no keys and knows no program. The caller passes the fee payer's
|
|
121
|
+
`Solana::Keypair` in and supplies its own instructions.
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
client = Solana::Client.new
|
|
125
|
+
builder = Solana::Cosign::Builder.new(client: client, fee_payer: house_keypair)
|
|
126
|
+
|
|
127
|
+
prepared = builder.build(
|
|
128
|
+
instructions: [my_program_instruction], # { program_id:, accounts:, data: }
|
|
129
|
+
cosigners: [user_wallet_address],
|
|
130
|
+
compute_unit_price: 50_000, # a priority fee; fee-less txs drop on mainnet
|
|
131
|
+
compute_unit_limit: 200_000
|
|
132
|
+
)
|
|
133
|
+
prepared.wire_base64 # hand this to the wallet
|
|
134
|
+
prepared.wire_base58 # or this: what a walletOps `prepare` returns
|
|
135
|
+
prepared.last_valid_block_height # the deadline: past this height it can never land
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Store `prepared.wire_base64` and `prepared.last_valid_block_height` server-side
|
|
139
|
+
when the signature comes back in a later request, then:
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
expectation = Solana::Cosign::Expectation.from_wire(stored_wire, fee_payer: house_keypair,
|
|
143
|
+
last_valid_block_height: stored_height)
|
|
144
|
+
completer = Solana::Cosign::Completer.new(client: client, fee_payer: house_keypair)
|
|
145
|
+
|
|
146
|
+
result = completer.complete(signed_wire_from_wallet, expectation: expectation,
|
|
147
|
+
before_send: ->(signature) { record.update!(signature: signature) })
|
|
148
|
+
result.signature
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
A wire from `SolanaStudio.walletOps` arrives in base58: pass `encoding: :base58`
|
|
152
|
+
to `#verify!`, `#cosign`, `#complete` or `Expectation.from_wire`. The encoding is
|
|
153
|
+
declared, never guessed — every base58 string is also made of base64 characters.
|
|
154
|
+
|
|
155
|
+
`#complete` runs, in order: judge the wire, check every cosigner signature, fill
|
|
156
|
+
the fee payer's slot (`Transaction.cosign_wire`), check the block height against
|
|
157
|
+
the deadline, simulate, call `before_send`, send, confirm. `#cosign` stops after
|
|
158
|
+
the fee payer signs (no RPC), for a flow whose browser broadcasts. `#verify!`
|
|
159
|
+
only judges.
|
|
160
|
+
|
|
161
|
+
**How the wire is judged.** A wallet re-encodes what it signs, and on mainnet
|
|
162
|
+
Phantom may insert Lighthouse instructions, so bytes are never compared. The
|
|
163
|
+
fee payer must be account 0 and writable; the signer set must be exactly the
|
|
164
|
+
fee payer plus the cosigners; with ComputeBudget and admitted extra programs
|
|
165
|
+
(Lighthouse by default) set aside, the instructions must equal the built ones —
|
|
166
|
+
program, ordered accounts, data and order; ComputeBudget is read and the fee it
|
|
167
|
+
makes the house pay is capped at 10x the builder's own. A System transfer from
|
|
168
|
+
the fee payer, a nonce advance, an extra signer, an altered amount: each is
|
|
169
|
+
refused before the house signs.
|
|
170
|
+
|
|
171
|
+
**Lighthouse is read, not waved through.** Most Lighthouse instructions are
|
|
172
|
+
assertions, which can only make a transaction fail. Two are not. MemoryWrite
|
|
173
|
+
(variant 0) makes a signer fund a "memory" account of any size, and the fee
|
|
174
|
+
payer signs every cosigned wire, so a wire naming it as payer would lock the
|
|
175
|
+
house's SOL. MemoryClose (variant 1) refunds one. The guard therefore admits a
|
|
176
|
+
Lighthouse instruction only when its first data byte is an assertion variant,
|
|
177
|
+
2 through 17. It refuses 0 (`lighthouse_memory_write`), 1
|
|
178
|
+
(`lighthouse_memory_close`), empty data (`lighthouse_empty_data`) and any other
|
|
179
|
+
byte (`lighthouse_unknown_disc`). It does not refuse an assertion for naming
|
|
180
|
+
the fee payer, because Phantom's assertions check the fee payer's own state.
|
|
181
|
+
The deployed program is immutable, so the variants cannot drift; the mainnet
|
|
182
|
+
evidence is in the `Solana::Cosign::LIGHTHOUSE_PROGRAM_ID` comment.
|
|
183
|
+
`extra_programs:` accepts only programs the guard has such a rule for, which
|
|
184
|
+
today is Lighthouse alone; pass `extra_programs: []` to refuse Lighthouse
|
|
185
|
+
entirely.
|
|
186
|
+
|
|
187
|
+
**The error class tells you what you may do next.**
|
|
188
|
+
|
|
189
|
+
| Raised | Sent? | What to do |
|
|
190
|
+
|---|---|---|
|
|
191
|
+
| `WireRejected` | No | Refuse. `#reason` is a stable code; the message is for logs only. |
|
|
192
|
+
| `PreflightRejected` → `BlockhashExpired`, `SimulationFailed` | Provably not | Rebuild freely. `BlockhashExpired` means ask the user to sign again. |
|
|
193
|
+
| `BroadcastFailed` → `BroadcastExpired` | Maybe | Look `#signature` up on chain before rebuilding. |
|
|
194
|
+
| `TransactionFailed` | Landed, failed | The fee was paid. `#err` has the program error. |
|
|
195
|
+
|
|
196
|
+
`BroadcastExpired` is deliberately NOT a `PreflightRejected`: `Solana::Client`
|
|
197
|
+
retries a lost answer by re-posting the same wire, so a send-time "Blockhash not
|
|
198
|
+
found" can follow an attempt that was already forwarded.
|
|
199
|
+
|
|
200
|
+
**Build and send at the same commitment.** The builder fetches at `"confirmed"`
|
|
201
|
+
by default and the completer preflights at the commitment the expectation
|
|
202
|
+
carries. A confirmed blockhash sent with the RPC's default `"finalized"`
|
|
203
|
+
preflight is refused as `Blockhash not found` while still valid.
|
|
204
|
+
|
|
205
|
+
**Wallet-first by default.** Every slot starts empty and the house signs last.
|
|
206
|
+
`presign: true` signs the fee payer's slot at build instead — the order Phantom
|
|
207
|
+
can flag as "could be malicious" — and exists only so server-first flows can
|
|
208
|
+
adopt the builder before they flip.
|
|
209
|
+
|
|
210
|
+
No durable nonce: a nonce transaction is recognized only when
|
|
211
|
+
`advanceNonceAccount` is instruction 0, and Phantom inserts instructions ahead of
|
|
212
|
+
it, so a nonce cannot anchor a wallet-signed transaction.
|
|
213
|
+
|
|
109
214
|
## Rails engine (optional)
|
|
110
215
|
|
|
111
216
|
The gem is Rails-free by default — `railties` is **not** a runtime dependency,
|
|
@@ -664,9 +769,116 @@ the RPC. The redirect leg also needs a host callback page to call
|
|
|
664
769
|
`walletOps.resume(params, { navigate })`; studio-engine's
|
|
665
770
|
`solana_sessions/phantom_callback` does this from 0.73.0.
|
|
666
771
|
|
|
772
|
+
### The wallet as a session identity (`walletIdentity`)
|
|
773
|
+
|
|
774
|
+
`solana_studio/wallet_identity.js` makes the connected wallet an **identity
|
|
775
|
+
source** for studio-engine's session-drift primitive (`window.StudioSession`,
|
|
776
|
+
documented in studio-engine's `docs/SESSION_DRIFT.md`). The engine compares the
|
|
777
|
+
identities a page was rendered for with the identities the browser observes now,
|
|
778
|
+
and it stays web2: it never learns what a wallet is. This file supplies that
|
|
779
|
+
half, and nothing else in the gem depends on it.
|
|
780
|
+
|
|
781
|
+
Load it after `studio/session.js`, then register once per window. Registering
|
|
782
|
+
the same name twice throws, and on a Turbo host the session store and its
|
|
783
|
+
registrations outlive a visit, so a script that runs on every visit must register
|
|
784
|
+
only the first time:
|
|
785
|
+
|
|
786
|
+
```erb
|
|
787
|
+
<%= javascript_include_tag "studio/session" %>
|
|
788
|
+
<%= javascript_include_tag "solana_studio/wallet_identity" %>
|
|
789
|
+
```
|
|
790
|
+
|
|
791
|
+
```js
|
|
792
|
+
var wallet = SolanaStudio.walletIdentity.register({
|
|
793
|
+
getProvider: hostResolver, // your registry's pick, or omit for window.phantom.solana
|
|
794
|
+
trustedConnect: sessionHasAWallet, // see the options table
|
|
795
|
+
rescanOn: ["wallet-provider:registered"] // your registry's "a wallet arrived" event, if it has one
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
wallet.source.current(); // { status, address, providerName }
|
|
799
|
+
wallet.source.subscribe(function (next, previous) { /* repaint the navbar */ });
|
|
800
|
+
document.addEventListener("session:mismatch", function (event) {
|
|
801
|
+
if (event.detail.source === "wallet") { /* an undeclared switch */ }
|
|
802
|
+
});
|
|
803
|
+
```
|
|
804
|
+
|
|
805
|
+
`register` returns `{ source, registration }`. Without a `StudioSession` on the
|
|
806
|
+
page, `registration` is null and the source still runs, so a page can render
|
|
807
|
+
wallet state without the session primitive.
|
|
808
|
+
`SolanaStudio.walletIdentity.create(options)` returns the bare source for a host
|
|
809
|
+
that registers it itself.
|
|
810
|
+
|
|
811
|
+
#### What it reports
|
|
812
|
+
|
|
813
|
+
| `status` | Reported to the session | Meaning |
|
|
814
|
+
|----------|-------------------------|---------|
|
|
815
|
+
| `unknown` | `undefined` (cannot tell) | Provider discovery or a silent connect is still pending |
|
|
816
|
+
| `none` | `null` | No wallet provider appeared before the discovery window closed |
|
|
817
|
+
| `disconnected` | `null` | A provider is present and holds no account for this site |
|
|
818
|
+
| `connected` | the base58 address | This wallet is connected |
|
|
819
|
+
|
|
820
|
+
`unknown` and `none` never collapse: a page that cannot tell yet must not render
|
|
821
|
+
as "you have no wallet". The session sees only the middle column.
|
|
822
|
+
|
|
823
|
+
**A mismatch is a different connected address, and nothing else.** A disconnect,
|
|
824
|
+
a locked extension, or a page with no wallet is not a switch to someone else, so
|
|
825
|
+
the source's `equals` treats an observed `null` as agreeing with the bound
|
|
826
|
+
address. The page still sees the disconnect through `current()`. Pass
|
|
827
|
+
`disconnectIsMismatch: true` to count it.
|
|
828
|
+
|
|
829
|
+
#### How it reads the wallet
|
|
830
|
+
|
|
831
|
+
- **Two provider shapes.** An injected provider (Phantom's
|
|
832
|
+
`window.phantom.solana`, or a host adapter normalized to it): live
|
|
833
|
+
`publicKey`, plus `accountChanged`, `connect` and `disconnect`. A raw Wallet
|
|
834
|
+
Standard wallet: live `accounts` and `standard:events` `change`.
|
|
835
|
+
- **Live, never cached.** Every read goes back to the wallet. On a Wallet
|
|
836
|
+
Standard `change` it reads `wallet.accounts`, not the event's copy, because
|
|
837
|
+
an adapter that cached its account once reported the previous account forever
|
|
838
|
+
after a switch the wallet never announced.
|
|
839
|
+
- **Events are best-effort.** `focus`, `visibilitychange` to visible and
|
|
840
|
+
`pageshow` re-resolve the provider and re-read it. That catches a switch made
|
|
841
|
+
while the tab was hidden.
|
|
842
|
+
- **One binding per provider object**, however often the page reconciles. A
|
|
843
|
+
provider replaced by a later one (a Wallet Standard registration superseding
|
|
844
|
+
the injected object) is detached, and its events are ignored.
|
|
845
|
+
|
|
846
|
+
#### Options
|
|
847
|
+
|
|
848
|
+
| Option | Default | |
|
|
849
|
+
|--------|---------|---|
|
|
850
|
+
| `getProvider` | `window.phantom.solana \|\| window.solana` | The provider to watch now, or null. Called on every reconcile. |
|
|
851
|
+
| `trustedConnect` | `false` | When the wallet holds no account, ask it silently (`onlyIfTrusted` / `{ silent: true }`) before believing `disconnected`. **Off by default** because a silent connect can pop Phantom's unlock prompt; turn it on only where a wallet session is already expected. |
|
|
852
|
+
| `discoveryMs`, `discoveryIntervalMs` | `3000`, `100` | How long "no provider yet" stays `unknown` while a late injection is polled for. `0` reads `none` at once. |
|
|
853
|
+
| `rescanOn` | `[]` | Extra `window` events that re-resolve the provider. |
|
|
854
|
+
| `name` | `"wallet"` | The identity source name, and the key the server binds under. |
|
|
855
|
+
| `bound` | engine default | Passed through to the engine: `bound(snapshot)` returns the bound identity. |
|
|
856
|
+
| `disconnectIsMismatch` | `false` | See above. |
|
|
857
|
+
| `session` | `window.StudioSession` | The store `register` uses. |
|
|
858
|
+
|
|
859
|
+
#### What the host owes
|
|
860
|
+
|
|
861
|
+
- **The server half.** Bind the session under the same name:
|
|
862
|
+
`studio_session_identities` returns `{ wallet: <the wallet this session signed
|
|
863
|
+
in with> }`. Bind nothing for a session that has no wallet of its own (a guest,
|
|
864
|
+
or a managed wallet the browser never holds). An unbound source still reports
|
|
865
|
+
what it sees and never mismatches.
|
|
866
|
+
- **Holds are per SOURCE, not per address.** `StudioSession.expectChange("wallet")`
|
|
867
|
+
marks every switch expected until it is released. A flow that walks through
|
|
868
|
+
specific wallets, such as a multi-signer ceremony, must still check the observed
|
|
869
|
+
address against the wallets it declared, or a switch to any other wallet goes
|
|
870
|
+
quiet for the length of the hold.
|
|
871
|
+
- **The UI and the re-auth.** The switch card, the navbar, and signing in again
|
|
872
|
+
with the new wallet (then `StudioSession.refresh()`) are the host's.
|
|
873
|
+
|
|
874
|
+
It never signs, sends, writes storage or opens a modal.
|
|
875
|
+
|
|
667
876
|
## Dependencies
|
|
668
877
|
|
|
669
|
-
- `ed25519` (~> 1.3) — Ed25519 signing
|
|
878
|
+
- `ed25519` (~> 1.3) — Ed25519 signing and the verification equation. It
|
|
879
|
+
does not vet the public key, so every verify in this gem
|
|
880
|
+
(`AuthVerifier.verify!`, `WireMessage#signature_valid?`) runs
|
|
881
|
+
`Solana::Ed25519Strict` first.
|
|
670
882
|
- Ruby stdlib only (net/http, json, digest, securerandom)
|
|
671
883
|
- **No Rails dependency.** `railties` is a development dependency only; the
|
|
672
884
|
engine loads solely when the host has already loaded Rails.
|
|
@@ -675,32 +887,33 @@ the RPC. The redirect leg also needs a host callback page to call
|
|
|
675
887
|
|
|
676
888
|
See [RUNBOOK.md](./RUNBOOK.md) for troubleshooting and local test commands.
|
|
677
889
|
|
|
678
|
-
###
|
|
890
|
+
### The durable-nonce primitives: one consumer left, and it is a dead route
|
|
679
891
|
|
|
680
892
|
`Solana::SystemProgram` and `Solana::NonceAccount` landed together in **v0.4.6
|
|
681
|
-
(2026-06-02, `11ec512`)** for
|
|
682
|
-
|
|
683
|
-
turf
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
signing console
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
893
|
+
(2026-06-02, `11ec512`)** for two consumers: McRitchie Studio's signing console
|
|
894
|
+
and turf-monster's operator transactions. **Neither is a live flow now.**
|
|
895
|
+
Re-measured 2026-09-16 against turf-monster `origin/accepted` (`61185cdd`); this
|
|
896
|
+
note used to say otherwise.
|
|
897
|
+
|
|
898
|
+
- **The signing console is gone.** It was frozen on 2026-08-31 and deleted on
|
|
899
|
+
2026-09-04 (the hub's `retire-signing-console` task), with its doc.
|
|
900
|
+
- **turf-monster reaches the nonce from one dead route.**
|
|
901
|
+
`Solana::Vault#durable_nonce_config` has one caller,
|
|
902
|
+
`#build_create_contest(admin_signs: true)`, reached only from
|
|
903
|
+
`ContestsController#prepare_onchain_contest`. Nothing under `app/views` or
|
|
904
|
+
`app/javascript` calls that route; only `e2e/rpc-mock.js` names it.
|
|
905
|
+
- **No cosign guard admits a nonce advance.** turf-monster's cosign guards
|
|
906
|
+
refuse every System instruction, `advanceNonceAccount` included, and so does
|
|
907
|
+
`Solana::Cosign`.
|
|
908
|
+
- **A nonce cannot anchor a wallet-signed transaction.** A nonce transaction is
|
|
909
|
+
recognized only when `advanceNonceAccount` is instruction 0, and Phantom
|
|
910
|
+
inserts Lighthouse instructions ahead of it (turf-monster mainnet incident,
|
|
911
|
+
2026-06-11). That is why Mr. McRitchie dropped nonce support from the
|
|
912
|
+
primitives extraction on 2026-09-16.
|
|
913
|
+
|
|
914
|
+
The files stay, byte-match tested in `test/system_program_test.rb`, until
|
|
915
|
+
turf-monster decides the dead route's fate. Removing them first would break that
|
|
916
|
+
route's build step.
|
|
704
917
|
|
|
705
918
|
### The browser lane
|
|
706
919
|
|