solana-studio 0.9.1 → 0.9.2
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 +15 -0
- data/README.md +150 -1
- data/app/assets/javascripts/solana_studio/wallet_ops.js +254 -9
- 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: b7241d6d065b9809d2860f069a5719e7e45ffe69d819b7b179556153a1e18fc8
|
|
4
|
+
data.tar.gz: 4f3463bdabd2e64fd778485856cfdf7615d5f75cb373bf025f044c24e4d1a000
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5fb81a7cd7050a2be3d6ae91f38d3ac2e39c0907d57585cca239e86d3e1ecc2a433561df2bf43cffb1d9f1593f9d97acdad6ae48a5d811bf5d99c7e55576c501
|
|
7
|
+
data.tar.gz: e19a0a621a94498d9fabb1c2fdeb5322fd135f2f695ff8d1adb58685eeea08133566f3e2b5f33a4e2a238e3734df2cc00ec380ae65f0a284489904358e001d66
|
data/CHANGELOG.md
CHANGED
|
@@ -5,12 +5,19 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
7
|
### Added
|
|
8
|
+
- **`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
|
+
- **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.
|
|
8
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.
|
|
9
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.
|
|
10
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.
|
|
11
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.
|
|
12
14
|
|
|
13
15
|
### Changed
|
|
16
|
+
- **`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.
|
|
17
|
+
- **The inline provider owns the conversion, in both directions, as a required two-method codec.** `provider.deserializeTransaction(base58)` → whatever `signTransaction` accepts; `provider.serializeTransaction(signed)` → base58 wire bytes. **This gem cannot do it**: deserializing needs `@solana/web3.js`, and the only JS dependency here is a guarded `window.nacl` — taking a browser library onto the critical path of a gem whose other consumers are plain-Ruby, to do work the consumer's wallet adapter already does, is the coupling `lib/solana_studio/engine.rb`'s guard exists to prevent. It belongs on the PROVIDER rather than the intent because the shape requirement is the provider's, the same reason `can('signAndSendTransaction')` is answered there rather than branched on here; one adapter per app serves every intent, while a per-intent hook is the same three lines of web3.js copied into each flow — per-call-site duplication wearing a different hat. **BOTH HALVES ARE CHECKED BEFORE THE WALLET IS TOUCHED**, and both are refused by name: without `deserializeTransaction` a base58 string reaches the extension and throws `t.serialize is not a function` from inside someone else's code, and `serializeTransaction` is not reached until AFTER the user has approved a signature, where discovering it missing costs a real signing prompt and strands signed bytes nothing can post. **The return leg is not garnish**: without it the redirect path hands `complete` a base58 string and the inline path hands it a signed Transaction object, the call site branches on which, and nothing has been unified — the promise would be lost on the return leg instead of the outbound one.
|
|
18
|
+
- **`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
|
+
- **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
|
+
- `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.
|
|
14
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`.
|
|
15
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.
|
|
16
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.
|
|
@@ -20,6 +27,10 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
20
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.
|
|
21
28
|
|
|
22
29
|
### Tests
|
|
30
|
+
- **`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
|
+
- **`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
|
+
- **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
|
+
- **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.
|
|
23
34
|
- **`e2e/wallet_failure_report.spec.js` (7, new)** — the reporting seam in a real Chromium, against both real partials through the engine's real modal host. It is the only tier that can see it: every line under test is inlined JavaScript inside an `x-data` attribute, so a Ruby test can assert the characters shipped but cannot run them. **FAIL-OPEN IS PROVED BY A REAL THROW.** The trap on the consumer half of this feature was that `fetch()` RESOLVES on 4xx/5xx, so four specs stubbing a 500 stayed green with the error handling deleted; the equivalent trap here is different in shape and identical in kind, and only a host reporter that genuinely throws exercises the gem's guard. **10 mutants, all 10 killed**, each shown applied by its own diffstat against a 19/19 green baseline and reverted after: the picker sending the other surface's stage; `raw` and `mapped` swapped; `raw` captured AFTER the mapper so both halves are the mapped one; the 4001 branch overwriting `raw` with our sentence; the `try`/`catch` unwrapped at each call site separately; the `typeof` guard negated; the `walletFailureReported` check dropped; and the report REORDERED above the user-facing assignment. Two are recorded because the first measurement was wrong and the correction is the finding: negating the `typeof` guard SURVIVED the absent-reporter spec, because the surrounding `try`/`catch` swallows the resulting `TypeError` — the two guards are genuinely redundant AT THAT SEAM, each covering the other. It is killed by the present-reporter specs instead (a negated guard reports nothing at all), and removing the PAIR together takes the absent-reporter spec red, which is what proves that spec is not inert. Single-mutating either guard alone could never have shown this.
|
|
24
35
|
- **`test/e2e_lane_contract_test.rb`** — the new lab page is asserted to render both gem partials and the engine's real host BY NAME, and refuted from ever CALLING `window.reportWalletFailure`. The page legitimately DEFINES that global (it is the host's to provide and the lab is the host); a lab that also invoked it would satisfy every reporting spec while the gem's call sites never fired.
|
|
25
36
|
- `config/e2e_lane.yml` 12 -> 19 specs, derived with `npx playwright test --list` rather than counted by hand, per the file's own instruction.
|
|
@@ -31,6 +42,10 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
31
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.
|
|
32
43
|
|
|
33
44
|
### Notes for hosts
|
|
45
|
+
- **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
|
+
- **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
|
+
- **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
|
+
- **`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
49
|
- **Nothing changes for a host that does nothing.** `window.reportWalletFailure` is optional and guarded; a consumer that never defines it renders and behaves exactly as on 0.6.1, with wallet failures recorded nowhere — the state every consumer is in today. This is additive, and there is no migration.
|
|
35
50
|
- **To light the surface up you must build the endpoint; the gem cannot.** It mounts no routes, so define `window.reportWalletFailure(stage, provider, raw, mapped)` and a path for it to POST to. Four things are worth copying rather than re-deriving, each of which cost something to learn: (1) record **both** message halves — a mis-mapping is invisible in the mapped half alone; (2) **scrub `raw` server-side** — it is free text a WALLET composed, and a wallet can quote your own nonce back at you inside a field your key allowlist has already approved, so redact by VALUE as well as by key, and leave the pubkey alone because over-redaction blinds the tool; (3) make it **fire and forget** — no `await`, swallow your own faults, and answer the same status whether or not you recorded anything, so no caller can block a user on observation; (4) tag any error you rethrow after substituting your own sentence with `walletFailureReported = true`, and report it from where you made the substitution, or the surface files a second useless row. turf-monster's `app/javascript/solana_errors.js` and `Solana::ClientFailureReport` are the worked example.
|
|
36
51
|
- **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.
|
data/README.md
CHANGED
|
@@ -354,7 +354,8 @@ shipped on exactly these terms.
|
|
|
354
354
|
Every global below belongs to the **host**. This gem ships **no routes at all**,
|
|
355
355
|
and the JavaScript it does ship is a different category from the globals below:
|
|
356
356
|
`solana_studio/network_guard.js` plus the redirect-transport primitives
|
|
357
|
-
(`wallet_transport`, `redirect_provider`, `wallet_journal`, `wallet_ops`
|
|
357
|
+
(`wallet_transport`, `redirect_provider`, `wallet_journal`, `wallet_ops` — see
|
|
358
|
+
[The wallet intent registry](#the-wallet-intent-registry-walletops)), none of
|
|
358
359
|
which provide any global in this table. So none of these can live here. Each is reached behind a `typeof` guard: an absent one degrades the
|
|
359
360
|
card rather than breaking it, and the whole point of writing the list down is
|
|
360
361
|
that a consumer meets it here instead of rediscovering it.
|
|
@@ -425,6 +426,154 @@ Adopt the loader only together with a callback that waits; otherwise keep a
|
|
|
425
426
|
blocking `<script>` tag of your own with the same SRI-pinned URL. turf-monster
|
|
426
427
|
deliberately does the latter.
|
|
427
428
|
|
|
429
|
+
### The wallet intent registry (`walletOps`)
|
|
430
|
+
|
|
431
|
+
`solana_studio/wallet_ops.js` is how one piece of product logic — enter a
|
|
432
|
+
contest, rename a user, export a wallet — runs over **both** wallet transports
|
|
433
|
+
from **one** call site. The inline transport is an injected provider where
|
|
434
|
+
`await` works; the redirect transport hands off to a wallet app by URL and the
|
|
435
|
+
page is destroyed mid-operation. A flow is declared once, by name:
|
|
436
|
+
|
|
437
|
+
```js
|
|
438
|
+
SolanaStudio.walletOps.define('contest_entry', {
|
|
439
|
+
prepare: function (ctx) { /* → { transaction: '<base58>', ...state } */ },
|
|
440
|
+
complete: function (ctx, result, state) { /* result.signedTransaction is base58 */ },
|
|
441
|
+
signOnly: true
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
SolanaStudio.walletOps.run('contest_entry', { contestId: 12 }, {
|
|
445
|
+
provider: walletProvider.detect(),
|
|
446
|
+
expectedAccount: session.address, // optional
|
|
447
|
+
appUrl: location.origin, // redirect transport only
|
|
448
|
+
redirectLink: location.origin + '/auth/phantom/callback',
|
|
449
|
+
cluster: document.body.dataset.solanaCluster
|
|
450
|
+
});
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
Handlers are registered **by name at page load**, never passed as closures: a
|
|
454
|
+
closure is precisely what cannot survive the redirect. Everything the flow needs
|
|
455
|
+
on the far side travels as JSON in the journal.
|
|
456
|
+
|
|
457
|
+
#### The transaction is base58 wire bytes, on every transport
|
|
458
|
+
|
|
459
|
+
`prepare()` **must** return `{ transaction: '<base58>' }`, and `complete()` is
|
|
460
|
+
handed base58 in `result.signedTransaction`, whichever transport ran. A
|
|
461
|
+
transaction that is not a non-empty string is refused by name at the call site,
|
|
462
|
+
on both paths.
|
|
463
|
+
|
|
464
|
+
This is not a preference. Base58 is what survives a page death; a
|
|
465
|
+
`solanaWeb3.Transaction` serialises into a journal as `{}`. Until 0.9.1 only the
|
|
466
|
+
redirect path obeyed it — the inline path passed `prepared.transaction` straight
|
|
467
|
+
to `provider.signTransaction`, which for an injected wallet must be a Transaction
|
|
468
|
+
**object** — so a single intent could not serve both transports and every
|
|
469
|
+
consumer kept a second, hand-rolled desktop call site.
|
|
470
|
+
|
|
471
|
+
#### The inline provider's transaction codec
|
|
472
|
+
|
|
473
|
+
The gem cannot convert between the two shapes: deserializing base58 into a
|
|
474
|
+
Transaction needs `@solana/web3.js`, and the only JavaScript dependency here is
|
|
475
|
+
a guarded `window.nacl`. Taking web3.js would put a browser library on the
|
|
476
|
+
critical path of a gem whose other consumers are plain-Ruby, to do work your
|
|
477
|
+
wallet adapter already does.
|
|
478
|
+
|
|
479
|
+
So the conversion is the **inline provider's**, and it is required in both
|
|
480
|
+
directions:
|
|
481
|
+
|
|
482
|
+
| Method | Given | Returns |
|
|
483
|
+
|---|---|---|
|
|
484
|
+
| `deserializeTransaction(base58)` | base58 wire bytes | whatever your `signTransaction` accepts |
|
|
485
|
+
| `serializeTransaction(signed)` | whatever `signTransaction` resolved | base58 wire bytes |
|
|
486
|
+
|
|
487
|
+
Both are checked **before** the wallet is touched, and an inline provider missing
|
|
488
|
+
either is refused by name — a base58 string reaching an extension's
|
|
489
|
+
`signTransaction` throws `t.serialize is not a function` from inside someone
|
|
490
|
+
else's code, and a missing `serializeTransaction` would not surface until after
|
|
491
|
+
the user had already approved a signature.
|
|
492
|
+
|
|
493
|
+
The return leg is not garnish. Without `serializeTransaction` the redirect path
|
|
494
|
+
hands `complete` a base58 string and the inline path hands it a signed
|
|
495
|
+
Transaction object, your call site branches on which, and nothing has been
|
|
496
|
+
unified.
|
|
497
|
+
|
|
498
|
+
A reference adapter, for a **co-signed** transaction — the server fills a second
|
|
499
|
+
signer slot, so both serialize flags are off and a bare `signed.serialize()`
|
|
500
|
+
would throw on the missing signature:
|
|
501
|
+
|
|
502
|
+
```js
|
|
503
|
+
var base58 = SolanaStudio.walletTransport.base58;
|
|
504
|
+
|
|
505
|
+
inlineProvider.deserializeTransaction = function (wire) {
|
|
506
|
+
return solanaWeb3.Transaction.from(base58.decode(wire));
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
inlineProvider.serializeTransaction = function (signed) {
|
|
510
|
+
return base58.encode(
|
|
511
|
+
signed.serialize({ requireAllSignatures: false, verifySignatures: false })
|
|
512
|
+
);
|
|
513
|
+
};
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
Write it once, on the object your `detect()` returns, and every intent in the app
|
|
517
|
+
is covered. Putting it on the intent instead is the same three lines of web3.js
|
|
518
|
+
copied into each flow — per-call-site duplication wearing a different hat.
|
|
519
|
+
|
|
520
|
+
#### `expectedAccount` — a UX guard, not a security one
|
|
521
|
+
|
|
522
|
+
`run(..., { expectedAccount: '<base58 address>' })` declares which account the
|
|
523
|
+
caller believes it is about to use, and walletOps refuses the trip when a
|
|
524
|
+
different one connects. **The ownership proof is on-chain** — Anchor rejects a
|
|
525
|
+
transaction whose signer does not match the PDA's owner, with or without this.
|
|
526
|
+
What the declaration buys is a sentence the user can act on instead of a program
|
|
527
|
+
error, plus, on the inline transport, a server-minted prepared transaction that
|
|
528
|
+
is never wasted.
|
|
529
|
+
|
|
530
|
+
The refusal carries `err.wrongAccount === true` and the full `err.expected` /
|
|
531
|
+
`err.connected` addresses, so a host can compose its own sentence rather than
|
|
532
|
+
parse the default one.
|
|
533
|
+
|
|
534
|
+
It is a **string**, not a `PublicKey`, and a non-string is refused: a PublicKey
|
|
535
|
+
would stringify correctly inline and be journalled as `{}` on the redirect path,
|
|
536
|
+
matching on a desktop and refusing every mobile trip.
|
|
537
|
+
|
|
538
|
+
Where it is checked, and what that costs, differs by transport — this is the one
|
|
539
|
+
place the two genuinely cannot be made identical:
|
|
540
|
+
|
|
541
|
+
| Transport | Checked | Cost of a wrong wallet |
|
|
542
|
+
|---|---|---|
|
|
543
|
+
| Inline | after `connect()`, **before** `prepare()` | nothing — `prepare` never runs |
|
|
544
|
+
| Redirect, cold session | on the connect callback, **before** the signing hop | whatever `prepare` already minted; no signing prompt |
|
|
545
|
+
| Redirect, warm session (`opts.session`) | **not checked** | — |
|
|
546
|
+
|
|
547
|
+
The redirect path cannot check earlier because the connect hop destroys the
|
|
548
|
+
page: everything `prepare` returns must already be in the journal before the
|
|
549
|
+
navigation. A warm session takes no connect hop at all, so walletOps never learns
|
|
550
|
+
an account — a caller holding a session learned the address when it established
|
|
551
|
+
one, and that is where the check belongs.
|
|
552
|
+
|
|
553
|
+
It is a declared **value** rather than a post-connect hook on purpose. The
|
|
554
|
+
connect callback is a different document — in this ecosystem, studio-engine's
|
|
555
|
+
wallet callback view, which knows nothing about any consumer's flows — and
|
|
556
|
+
`resume` deliberately does not require a registered handler to advance from
|
|
557
|
+
connect to signing. A hook would be looked up on exactly the hop it exists to
|
|
558
|
+
guard, come back empty, and be skipped in silence.
|
|
559
|
+
|
|
560
|
+
#### `signOnly`
|
|
561
|
+
|
|
562
|
+
A **co-signed** transaction cannot be broadcast by the wallet: the chain rejects
|
|
563
|
+
it for the missing signature, and the signed bytes the server needs never come
|
|
564
|
+
back. `signOnly: true` makes the transaction's requirement outrank the wallet's
|
|
565
|
+
capability, so the trip takes `signTransaction` even on Solflare and Backpack,
|
|
566
|
+
which do ship a send-side deeplink. Omit it and nothing changes: a wallet that
|
|
567
|
+
can broadcast still does. It is a boolean or it is refused.
|
|
568
|
+
|
|
569
|
+
#### What `walletOps` does not do
|
|
570
|
+
|
|
571
|
+
It does not broadcast, and it mounts no routes. `complete` is told which side
|
|
572
|
+
sent — `sendStrategy` is `'app-broadcasts'` or `'wallet-broadcasts'` — and owns
|
|
573
|
+
the RPC. The redirect leg also needs a host callback page to call
|
|
574
|
+
`walletOps.resume(params, { navigate })`; studio-engine's
|
|
575
|
+
`solana_sessions/phantom_callback` does this from 0.73.0.
|
|
576
|
+
|
|
428
577
|
## Dependencies
|
|
429
578
|
|
|
430
579
|
- `ed25519` (~> 1.3) — Ed25519 signing
|
|
@@ -15,7 +15,10 @@
|
|
|
15
15
|
// signOnly: true
|
|
16
16
|
// });
|
|
17
17
|
//
|
|
18
|
-
// walletOps.run('contest_entry', { contestId: 12 }, {
|
|
18
|
+
// walletOps.run('contest_entry', { contestId: 12 }, {
|
|
19
|
+
// provider: ...,
|
|
20
|
+
// expectedAccount: '<the address this account is linked to>' // optional
|
|
21
|
+
// });
|
|
19
22
|
//
|
|
20
23
|
// `signOnly` IS A REQUIREMENT OF THE TRANSACTION, NOT A PREFERENCE ABOUT THE
|
|
21
24
|
// WALLET, and it is the intent's to declare because only the intent knows the
|
|
@@ -38,6 +41,50 @@
|
|
|
38
41
|
// serialised. This is why the entry flow's server-side prepared-transaction slug
|
|
39
42
|
// matters so much — a slug survives the trip; a Transaction object does not.
|
|
40
43
|
//
|
|
44
|
+
// THE TRANSACTION IS WIRE BYTES ON EVERY TRANSPORT, AND THAT IS NOW ENFORCED.
|
|
45
|
+
// The rule above always implied it — base58 survives a page death and a
|
|
46
|
+
// `solanaWeb3.Transaction` does not — but only the redirect path obeyed it. The
|
|
47
|
+
// inline path passed `prepared.transaction` STRAIGHT to `provider.signTransaction`,
|
|
48
|
+
// which for an injected wallet must be a Transaction OBJECT, so a single intent
|
|
49
|
+
// could not serve both transports and every consumer kept a second call site for
|
|
50
|
+
// the desktop path. That is the duplication this file exists to prevent.
|
|
51
|
+
//
|
|
52
|
+
// One `prepare` cannot return both shapes, and this gem cannot convert between
|
|
53
|
+
// them: deserializing base58 into a Transaction needs @solana/web3.js, and the
|
|
54
|
+
// only JS dependency here is a GUARDED `window.nacl`. Taking web3.js would put a
|
|
55
|
+
// ~200KB browser library on the critical path of a gem whose other consumers are
|
|
56
|
+
// plain-Ruby, to do work the consumer's own wallet adapter already does.
|
|
57
|
+
//
|
|
58
|
+
// So the conversion is the INLINE PROVIDER'S, declared as a two-method codec:
|
|
59
|
+
//
|
|
60
|
+
// provider.deserializeTransaction(base58) → whatever signTransaction() takes
|
|
61
|
+
// provider.serializeTransaction(signed) → base58 wire bytes
|
|
62
|
+
//
|
|
63
|
+
// It belongs on the provider because the shape requirement is the PROVIDER'S,
|
|
64
|
+
// not the flow's — the same reason `can('signAndSendTransaction')` is answered
|
|
65
|
+
// there rather than branched on here. One adapter per app serves every intent;
|
|
66
|
+
// a per-intent hook would be the same three lines of web3.js copied into each
|
|
67
|
+
// flow, which is per-call-site duplication wearing a different hat. Both halves
|
|
68
|
+
// are REQUIRED and refused BY NAME, because the alternative failure is a base58
|
|
69
|
+
// string reaching an extension's signTransaction and throwing
|
|
70
|
+
// `t.serialize is not a function` from inside someone else's code.
|
|
71
|
+
//
|
|
72
|
+
// BOTH DIRECTIONS, and the return leg is not optional garnish: without
|
|
73
|
+
// `serializeTransaction` the redirect path hands `complete` a base58 string and
|
|
74
|
+
// the inline path hands it a signed Transaction object, so the call site
|
|
75
|
+
// branches anyway and nothing has been unified.
|
|
76
|
+
//
|
|
77
|
+
// EXPECTED ACCOUNT — A UX GUARD, NOT A SECURITY ONE, and worth saying plainly
|
|
78
|
+
// because the opposite claim is easy to make. `run(..., { expectedAccount })`
|
|
79
|
+
// declares which address the caller believes it is about to use; walletOps
|
|
80
|
+
// refuses the trip when a different one connects. THE OWNERSHIP PROOF IS
|
|
81
|
+
// ON-CHAIN — Anchor rejects a transaction whose signer does not match the PDA
|
|
82
|
+
// owner, with or without this — so what the declaration buys is a sentence a
|
|
83
|
+
// user can act on instead of a program error, and, on the inline transport, a
|
|
84
|
+
// server-minted prepared transaction that is never wasted. It rides in the
|
|
85
|
+
// journal as a STRING for the same reason `signOnly` does: it is the only kind
|
|
86
|
+
// of thing that survives the redirect.
|
|
87
|
+
//
|
|
41
88
|
// WHAT THIS FILE DOES NOT DO: it does not broadcast. Signing and sending are
|
|
42
89
|
// different responsibilities with different failure modes, and the wallet that
|
|
43
90
|
// broadcasts differs per vendor (Phantom deprecated its send-side deeplink, so
|
|
@@ -73,21 +120,168 @@
|
|
|
73
120
|
W.location.href = url;
|
|
74
121
|
}
|
|
75
122
|
|
|
123
|
+
// --- the transaction contract --------------------------------------------
|
|
124
|
+
//
|
|
125
|
+
// ONE SHAPE ON EVERY TRANSPORT: base58 wire bytes. Checked HERE, in the one
|
|
126
|
+
// place both transports pass through, rather than at each of the two sites
|
|
127
|
+
// that consume it — a contract enforced on one path only is how the inline
|
|
128
|
+
// path drifted into taking a Transaction object in the first place.
|
|
129
|
+
//
|
|
130
|
+
// A MISSING transaction is refused alongside a wrongly-typed one. Every hop
|
|
131
|
+
// this file can take is a transaction hop (`signingHop` reaches only
|
|
132
|
+
// signTransaction and signAndSendTransaction), so an intent with nothing to
|
|
133
|
+
// sign has no path through here — it would reach the wallet as
|
|
134
|
+
// `transaction=undefined` and come back as that wallet's own words about an
|
|
135
|
+
// invalid payload, one page death away from the handler that caused it.
|
|
136
|
+
function requireWireTransaction(name, prepared) {
|
|
137
|
+
var tx = prepared && prepared.transaction;
|
|
138
|
+
if (typeof tx === 'string' && tx !== '') return prepared;
|
|
139
|
+
// An EMPTY string is called out separately rather than reported as "a
|
|
140
|
+
// string", because that message would send the reader looking for a type
|
|
141
|
+
// error in a handler whose type is already right — the fault is a prepare
|
|
142
|
+
// that came back with nothing to sign, usually a server field read under
|
|
143
|
+
// the wrong name.
|
|
144
|
+
var got = (tx === undefined || tx === null) ? 'no'
|
|
145
|
+
: (tx === '') ? 'an empty'
|
|
146
|
+
: ('a ' + typeof tx);
|
|
147
|
+
throw new Error(
|
|
148
|
+
'walletOps intent "' + name + '" prepared ' + got + ' transaction — ' +
|
|
149
|
+
'prepare() must return { transaction: "<base58 wire bytes>", ...state } ' +
|
|
150
|
+
'on EVERY transport. A Transaction object cannot be written to the ' +
|
|
151
|
+
'journal, so it cannot survive a redirect.'
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// The inline transport's two-way conversion, which is the PROVIDER's because
|
|
156
|
+
// the shape requirement is the provider's. Checked BEFORE the wallet is
|
|
157
|
+
// touched, and both halves together: serializeTransaction is not reached
|
|
158
|
+
// until after the user has approved a signature, and discovering it missing
|
|
159
|
+
// there costs a real signing prompt and strands signed bytes nothing can post.
|
|
160
|
+
var INLINE_CODEC = ['deserializeTransaction', 'serializeTransaction'];
|
|
161
|
+
|
|
162
|
+
function requireInlineCodec(provider) {
|
|
163
|
+
for (var i = 0; i < INLINE_CODEC.length; i++) {
|
|
164
|
+
if (typeof provider[INLINE_CODEC[i]] !== 'function') {
|
|
165
|
+
throw new Error(
|
|
166
|
+
'The inline wallet provider has no ' + INLINE_CODEC[i] + '(). ' +
|
|
167
|
+
'walletOps hands every transport the same base58 wire bytes, and an ' +
|
|
168
|
+
'injected wallet signs a Transaction object, so the provider adapter ' +
|
|
169
|
+
'owns both conversions — this gem cannot, without taking a ' +
|
|
170
|
+
'@solana/web3.js dependency. See "The inline provider\'s transaction ' +
|
|
171
|
+
'codec" in the solana-studio README.'
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// --- the expected account ------------------------------------------------
|
|
178
|
+
//
|
|
179
|
+
// A DECLARED VALUE RATHER THAN A CALLBACK, and the reason is the same one
|
|
180
|
+
// that put `signOnly` in the journal: the connect callback is a DIFFERENT
|
|
181
|
+
// DOCUMENT, and `resume` deliberately does not require a handler to advance
|
|
182
|
+
// from connect to signing. A hook would therefore be unreachable on exactly
|
|
183
|
+
// the hop it exists to guard, and would silently not run there — the worst of
|
|
184
|
+
// the three outcomes. A string survives the redirect; a function does not.
|
|
185
|
+
function shortAddress(address) {
|
|
186
|
+
var s = String(address);
|
|
187
|
+
return s.length > 12 ? s.slice(0, 4) + '…' + s.slice(-4) : s;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Whatever the transport learned, reduced to an address string.
|
|
191
|
+
//
|
|
192
|
+
// WHAT THIS DELIBERATELY DOES NOT DO: construct, parse, or call into a web3
|
|
193
|
+
// object. `String(pk)` is the one thing a base58 string and a
|
|
194
|
+
// `solanaWeb3.PublicKey` both answer correctly, and stringifying a value
|
|
195
|
+
// someone handed us is not a dependency on the library that made it. Anything
|
|
196
|
+
// more — `.toBase58()`, `new PublicKey(...)` — would put web3.js on this
|
|
197
|
+
// gem's critical path, which is the boundary the wire-bytes contract exists
|
|
198
|
+
// to hold.
|
|
199
|
+
function connectedAddress(result, provider) {
|
|
200
|
+
var pk = (result && (result.publicKey || result.public_key)) ||
|
|
201
|
+
(provider && provider.publicKey) || null;
|
|
202
|
+
var s = pk ? String(pk) : '';
|
|
203
|
+
// '[object Object]' is what a provider handing back something with no
|
|
204
|
+
// meaningful toString produces. Reporting THAT as the connected account
|
|
205
|
+
// puts a wrong-wallet sentence in front of someone whose wallet is fine, so
|
|
206
|
+
// it reads as unknown instead and takes the unreadable branch below.
|
|
207
|
+
return (s && s.indexOf('[object') !== 0) ? s : null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Undeclared expectation → nothing happens, on every transport. This is the
|
|
211
|
+
// branch every existing consumer is on, and it must stay free.
|
|
212
|
+
function assertExpectedAccount(expected, connected) {
|
|
213
|
+
if (!expected) return;
|
|
214
|
+
if (!connected) {
|
|
215
|
+
throw new Error(
|
|
216
|
+
'This wallet did not say which account it connected as, so the ' +
|
|
217
|
+
'expected account could not be checked — reconnect and try again'
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
if (String(connected) === String(expected)) return;
|
|
221
|
+
// The message is what a user READS: studio-engine's wallet callback paints
|
|
222
|
+
// err.message directly. The full addresses ride on the error for a host
|
|
223
|
+
// that would rather compose its own sentence.
|
|
224
|
+
var e = new Error(
|
|
225
|
+
'Wrong wallet — this account is linked to ' + shortAddress(expected) +
|
|
226
|
+
', but the wallet connected as ' + shortAddress(connected) +
|
|
227
|
+
'. Switch accounts in your wallet and try again.'
|
|
228
|
+
);
|
|
229
|
+
e.wrongAccount = true;
|
|
230
|
+
e.expected = String(expected);
|
|
231
|
+
e.connected = String(connected);
|
|
232
|
+
throw e;
|
|
233
|
+
}
|
|
234
|
+
|
|
76
235
|
// --- inline transport ----------------------------------------------------
|
|
77
236
|
//
|
|
78
|
-
// The
|
|
79
|
-
//
|
|
80
|
-
//
|
|
237
|
+
// The provider is injected, promises resolve, nothing is written down. It
|
|
238
|
+
// stays deliberately close to what the existing desktop call sites do, so
|
|
239
|
+
// adopting walletOps is not also a rewrite of the flow that already worked —
|
|
240
|
+
// but it is no longer a DIFFERENT contract from the redirect path, which is
|
|
241
|
+
// what kept every consumer maintaining two of them.
|
|
242
|
+
//
|
|
243
|
+
// CONNECT COMES BEFORE PREPARE, and the order is the point. `prepare` is a
|
|
244
|
+
// server round trip that MINTS something — turf-monster's is a prepared
|
|
245
|
+
// transaction row with a fresh blockhash — so running it before the wallet
|
|
246
|
+
// has said who it is spends a real record to discover the wrong account is
|
|
247
|
+
// connected. Every hand-rolled desktop call site this replaces already
|
|
248
|
+
// connected first for exactly that reason, and a walletOps that prepared
|
|
249
|
+
// first would have made migrating to it a regression.
|
|
250
|
+
//
|
|
251
|
+
// THE REDIRECT TRANSPORT CANNOT COPY THIS, and no amount of shuffling makes
|
|
252
|
+
// it: the connect hop DESTROYS the page, so anything prepare returns must
|
|
253
|
+
// already be in the journal before the navigation. See runRedirect.
|
|
254
|
+
//
|
|
255
|
+
// WHAT DOES NOT FOLLOW FROM THAT ORDER: `prepare` is still called with `ctx`
|
|
256
|
+
// and nothing else. An intent that read the connected account here would work
|
|
257
|
+
// on a desktop and silently misbehave on a cold mobile session, where the
|
|
258
|
+
// account is not known until a page that no longer exists.
|
|
81
259
|
function runInline(name, ctx, opts) {
|
|
82
260
|
var handler = requireHandler(name);
|
|
83
261
|
var provider = opts.provider;
|
|
84
262
|
|
|
85
|
-
return Promise.resolve(
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
263
|
+
return Promise.resolve().then(function () {
|
|
264
|
+
requireInlineCodec(provider);
|
|
265
|
+
return provider.connect();
|
|
266
|
+
}).then(function (connection) {
|
|
267
|
+
assertExpectedAccount(opts.expectedAccount, connectedAddress(connection, provider));
|
|
268
|
+
return handler.prepare(ctx);
|
|
269
|
+
}).then(function (prepared) {
|
|
270
|
+
requireWireTransaction(name, prepared);
|
|
271
|
+
// `prepared` is NOT mutated. `complete` is handed the object `prepare`
|
|
272
|
+
// returned, byte for byte, so `state.transaction` is the same base58
|
|
273
|
+
// string on both transports — a deserialized copy left in there would
|
|
274
|
+
// reintroduce the split one layer down.
|
|
275
|
+
return Promise.resolve(
|
|
276
|
+
provider.signTransaction(provider.deserializeTransaction(prepared.transaction))
|
|
277
|
+
).then(function (signed) {
|
|
89
278
|
return handler.complete(ctx, {
|
|
90
|
-
|
|
279
|
+
// BACK TO WIRE BYTES BEFORE `complete` SEES THEM. Without this the
|
|
280
|
+
// redirect path hands over a base58 string and the inline path hands
|
|
281
|
+
// over a signed Transaction object, the call site branches on which,
|
|
282
|
+
// and the one-call-site promise is lost on the return leg instead of
|
|
283
|
+
// the outbound one.
|
|
284
|
+
signedTransaction: provider.serializeTransaction(signed),
|
|
91
285
|
signature: null,
|
|
92
286
|
sendStrategy: 'app-broadcasts'
|
|
93
287
|
}, prepared);
|
|
@@ -117,6 +311,12 @@
|
|
|
117
311
|
}
|
|
118
312
|
|
|
119
313
|
return Promise.resolve(handler.prepare(ctx)).then(function (prepared) {
|
|
314
|
+
// The SAME contract the inline path enforces, checked before anything is
|
|
315
|
+
// written down. A transaction that is not wire bytes cannot be journalled
|
|
316
|
+
// — it would serialise to `{}` and reach the wallet as an invalid payload
|
|
317
|
+
// one page death from the handler that caused it.
|
|
318
|
+
requireWireTransaction(name, prepared);
|
|
319
|
+
|
|
120
320
|
var intent = { op: name, ctx: ctx, state: prepared };
|
|
121
321
|
|
|
122
322
|
// THE DECLARATION TRAVELS IN THE JOURNAL, NOT LOOKED UP FROM THE HANDLER
|
|
@@ -136,6 +336,25 @@
|
|
|
136
336
|
// change, and bumping it would strand every trip already in flight.
|
|
137
337
|
if (handler.signOnly) intent.signOnly = true;
|
|
138
338
|
|
|
339
|
+
// THE EXPECTED ACCOUNT RIDES THE SAME WAY, AND FOR THE SAME REASON. It is
|
|
340
|
+
// checked on the connect callback — a different document, which may never
|
|
341
|
+
// have run define() — so it cannot be looked up from the handler there.
|
|
342
|
+
//
|
|
343
|
+
// NOTE WHAT THIS COSTS ON THIS TRANSPORT, because it is a real cost and
|
|
344
|
+
// the inline path does not pay it: `prepare` has ALREADY run by the time
|
|
345
|
+
// we get here, so a wrong wallet on a cold mobile session still spends
|
|
346
|
+
// whatever prepare minted. It cannot be otherwise — the connect hop
|
|
347
|
+
// destroys this page, and the journal is the only thing that crosses it,
|
|
348
|
+
// so prepare's state must exist before we navigate. What the declaration
|
|
349
|
+
// buys on this transport is a sentence instead of a program error, and no
|
|
350
|
+
// signing prompt for a transaction that could never have been accepted.
|
|
351
|
+
//
|
|
352
|
+
// Set ONLY when present, so an undeclared intent's journal stays
|
|
353
|
+
// byte-identical to the one it wrote before this option existed —
|
|
354
|
+
// the same rule signOnly follows, and the same reason JOURNAL_VERSION
|
|
355
|
+
// does not move.
|
|
356
|
+
if (opts.expectedAccount) intent.expectedAccount = String(opts.expectedAccount);
|
|
357
|
+
|
|
139
358
|
// Already connected? Go straight to signing. Otherwise connect first and
|
|
140
359
|
// carry the intent through — sessions do not expire on any of the three
|
|
141
360
|
// wallets, so this branch is taken once per user, not once per action.
|
|
@@ -242,6 +461,19 @@
|
|
|
242
461
|
return Promise.resolve({ pending: true, done: true, connect: connected });
|
|
243
462
|
}
|
|
244
463
|
|
|
464
|
+
// THE ONE MOMENT THIS TRANSPORT LEARNS WHO CONNECTED, and the last one
|
|
465
|
+
// before a signing prompt. Read off the JOURNAL, not a handler: this
|
|
466
|
+
// document may never have loaded the script that defined the intent,
|
|
467
|
+
// and the whole point of resume advancing without one is that it does
|
|
468
|
+
// not have to. The journal has already been taken, so a refusal here
|
|
469
|
+
// ends the trip cleanly rather than leaving half a request behind.
|
|
470
|
+
//
|
|
471
|
+
// A WARM SESSION NEVER REACHES THIS. `run` with an `opts.session`
|
|
472
|
+
// takes no connect hop at all, so there is no account for walletOps to
|
|
473
|
+
// check — a caller holding a session already learned the address when
|
|
474
|
+
// it established one, and that is where it belongs.
|
|
475
|
+
assertExpectedAccount(intent.expectedAccount, connected.publicKey);
|
|
476
|
+
|
|
245
477
|
var next = signingHop(provider, connected.journal, {
|
|
246
478
|
redirectLink: opts.redirectLink || journal.redirectLink
|
|
247
479
|
});
|
|
@@ -311,6 +543,19 @@
|
|
|
311
543
|
if (!opts.provider) {
|
|
312
544
|
return Promise.reject(new Error('walletOps.run needs a provider'));
|
|
313
545
|
}
|
|
546
|
+
// REFUSED RATHER THAN STRINGIFIED. A `solanaWeb3.PublicKey` passed here
|
|
547
|
+
// would String() correctly on the inline path and be written to the
|
|
548
|
+
// journal as `{}` on the redirect one — matching on a desktop and
|
|
549
|
+
// refusing every mobile trip with a wrong-wallet sentence naming an
|
|
550
|
+
// account nobody has. The two transports disagreeing about a value is
|
|
551
|
+
// precisely the failure this whole change exists to remove.
|
|
552
|
+
if (opts.expectedAccount !== undefined && opts.expectedAccount !== null &&
|
|
553
|
+
typeof opts.expectedAccount !== 'string') {
|
|
554
|
+
return Promise.reject(new Error(
|
|
555
|
+
'walletOps.run expectedAccount must be a base58 address string, got ' +
|
|
556
|
+
typeof opts.expectedAccount + ' — call .toString() on a PublicKey first'
|
|
557
|
+
));
|
|
558
|
+
}
|
|
314
559
|
return opts.provider.transport === 'redirect'
|
|
315
560
|
? runRedirect(name, ctx, opts)
|
|
316
561
|
: runInline(name, ctx, opts);
|
|
@@ -16,5 +16,5 @@ module SolanaStudio
|
|
|
16
16
|
# through the normal cycle. Splitting the version out is the same shape
|
|
17
17
|
# studio-engine already uses (lib/studio/version.rb) and hands each file back
|
|
18
18
|
# to its real owner: this one to the release, the gemspec to the PR.
|
|
19
|
-
VERSION = "0.9.
|
|
19
|
+
VERSION = "0.9.2"
|
|
20
20
|
end
|