solana-studio 0.4.6 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3fca2972e96c5cc005be5505673727eb85a24549f7632f0b1d3d9809ec27411d
4
- data.tar.gz: 6dc069c098e0b6e664ed5fededf8c15848b73a39d845199357c009acf785298e
3
+ metadata.gz: d9f198cf717ac69a1a570b082b7ebf76a7d0560e1f863d3410bbc4eeeeca71e5
4
+ data.tar.gz: 4bbea890a22fafaba9449bd4aac5483ecfdf3366e694ac9c142c75e898d4435d
5
5
  SHA512:
6
- metadata.gz: 8186c3bf21e01bda36d230e1c43a4fb775a9e98812cdd87fa3d7c2513c44ac12178f981b083b3566f8e26eb3062daaaba2427ec6f32310ccfa8b3108ab8ab6ec
7
- data.tar.gz: 67274a43c5f27c1e9765b0ab3ffbd0f871acb22484506bdfdfe74785c02ae4598e702244cf23a90f9ad0e86c08a4bada67ba0e1bbac2d3fbf3cf89907652f376
6
+ metadata.gz: 5becc3ebd6544d5275f1107e1eb9338b47ce5cfeb19e682c9ebdf9bc44e5814f72c3b999f7f4839c1205fd4b9393713dc060edd94264334b999c997c75437a16
7
+ data.tar.gz: 90e26c9214ef297a84e43f910bda3d8d22278703672f1eb564a36c83071c6c90717cae87f836b5ba57844b7f1a4ab5b2793c01ec5d16b7cea1cf820eddb1c3dc
data/CHANGELOG.md CHANGED
@@ -2,6 +2,52 @@
2
2
 
3
3
  The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4
4
 
5
+ ## Unreleased — targets v0.5.0 (minor)
6
+
7
+ Ships the gem's first Rails half, and its first CI. The version number is
8
+ allocated by the release conductor, not by this entry.
9
+
10
+ ### Added
11
+ - **The browser lane** — a real Chromium against the gem's real onchain surface. `e2e/boot.rb` serves a minimal `test/dummy` host (no `bin/rails`, no `config.ru`, no database — the partial's only inputs are locals and one data attribute) and COPIES the shipped `network_guard.js` into it, so the specs drive the bytes a consumer installs rather than a re-typed copy. Runs as a PARALLEL CI job, ~40s, and is deliberately not in `bin/release-check`: a release should not install a browser to publish. Pieces: `playwright.config.js`, `e2e/*.spec.js`, `test/dummy/`, `config/e2e_lane.yml`, `bin/e2e-executed-set-check`, `test/e2e_lane_contract_test.rb`.
12
+ - **`config/e2e_lane.yml` + `bin/e2e-executed-set-check`** — the lane's verdict gate. A test lane can report that its specs PASSED; it structurally cannot report that it RAN THEM ALL. The gate reads Playwright's own receipt and asserts `expected == total_specs && skipped == 0 && unexpected == 0`, so a runtime `testInfo.skip()`, a `--grep`, `--only-changed` and an uncollected file all surface as one arithmetic failure instead of four invisible ones. It also refuses a `--list` receipt by name rather than reading it as a catastrophic failure, and NAMES an unreadable one — a run killed mid-write leaves a truncated file, and "the lane never finished" and "the gate is broken" are different findings that a bare parse error makes look identical. Mutation-proven ELEVEN ways, every mode red: a runtime skip, a `--grep`, a dropped spec file, a stale total, per-file drift that keeps the total right, a missing receipt, a `--list` receipt, an all-zero receipt, a truncated receipt, a receipt that is not a Playwright report, and a `grepInvert`/`shard` added to the config.
13
+
14
+ ### Fixed
15
+ - **The network-mismatch modal leaked `%>` into the rendered card.** Its header comment spelled the registration example out in real ERB tags, and the example's own closing marker ended the comment early — so the remaining prose rendered as literal text in the card, and the broken structure took Alpine down with it (`Cannot set properties of null`). It compiled, so `test/views_test.rb` was green; it passed review, CI and 114 tests. **The browser lane caught it on its first real run**, which is the clearest possible statement of what the lane is for. The comment now describes the integration in words; the runnable example lives in the README, where a stray marker cannot escape into a page.
16
+ - The modal's documented host requirements now name the one that is easy to miss: an **ancestor `x-data` scope** (normally a bare `x-data` on the host's `<body>`). studio-engine's modal host roots itself at a `template x-if` with no `x-data` of its own, and Alpine only walks trees beneath an `x-data` root — without it the host is never processed, the modal never opens, and the served HTML looks perfectly correct.
17
+
18
+ ### Tests
19
+ - `test/e2e_lane_contract_test.rb` (9): the contract names exactly the spec files that exist, per-file counts match the committed specs, the total equals their sum, no spec or config can narrow the set (`test.only`, `testInfo.skip`, `test.fixme`, `grep`, `grepInvert`, `--only-changed`, `testIgnore`, `testMatch`, `shard`, retries), the lab renders the gem's real partials by name, `boot.rb` copies the real assets, and the layout supplies the Alpine root the host requires.
20
+ - `test/e2e_executed_set_test.rb` (10): the gate's arithmetic without a browser — a clean run, a runtime skip, a failure, a narrowed run, a spec moved between files, a `--list` receipt, a truncated receipt, valid JSON that is not a Playwright report, a real receipt parsing, and nested-suite flattening.
21
+
22
+ ### Added
23
+ - **`Solana::Network`** — cluster identity, Rails-free and RPC-free. Pinned genesis hashes for mainnet-beta/devnet/testnet, `cluster_for_genesis` (what an RPC URL *actually* points at, as opposed to what its hostname claims), Wallet Standard chain ids, alias normalization, and `expected_for_environment`. `alignment(cluster:, genesis_hash:)` returns **three** states — `:aligned`, `:mismatched`, `:unverifiable` — because localnet has no pinnable genesis and an unknown cluster name has no hash to compare: collapsing `:unverifiable` into `:mismatched` refuses to boot every local validator, and collapsing it into `:aligned` trusts a chain nobody checked.
24
+ - **`SolanaStudio::Engine`** — an **optional** Rails engine, required only when `Rails::Engine` is already defined. `railties` is deliberately NOT a runtime dependency: plain-Ruby consumers (chain-ops scripts, rake tasks, `ruby -e`) load the gem exactly as before and never install Rails. Both directions are asserted, in separate subprocesses, in `test/engine_test.rb`.
25
+ - **`solana_studio/modals/_network_mismatch`** — the network-mismatch explainer modal, rendering through studio-engine's shared modal blocks.
26
+ - **`solana_studio/network_guard.js`** — `SolanaStudio.network`, a validation wrapper for onchain actions. A website **cannot** read which network a wallet is set to (Phantom does not expose it; the Wallet Standard `chains` array lists what a wallet supports, not what it selected), so the guard does the only two things that work: `withSignInChainId` asserts the app's cluster as a SIWS `chainId` and lets the wallet contradict it, and `guard(fn, {onHint})` classifies a *failure* after the fact. It never blocks, and it re-throws the original rejection untouched — a hint beside the real error, never instead of it. `classify` is calibrated to under-claim, and precedence is what makes that true: a custom program error means the program RAN on a chain that has it, so it outranks the "Transaction simulation failed" wrapper that Solana puts around every failed simulation. Without that ordering an ordinary "contest is full" classified as a probable network problem at top confidence — the exact wrong-thing-to-fix this feature exists to prevent, in the feature's own voice.
27
+ - **`bin/release-check`** — one entry point for local cert, CI, and release. Enumerates test files by glob (no curated list to drift) and fails any file that runs **zero** tests or **skips** one.
28
+ - **`.github/workflows/gem-ci.yml`** — the gem's first CI lane. Runs the suite, builds the gem, and asserts the engine's files are inside the **built artifact** (a manifest can be right while the build excludes a file; only the artifact is what a consumer installs).
29
+
30
+ ### Changed
31
+ - **`spec.files` now includes `app/**/*`.** This is the highest-consequence line in the gemspec: the release sweep publishes on membership rather than content, so a view left outside this glob would ship an engine with no views in it — green suite, green CI, green sweep, and a `missing partial` raised in a consumer app. `test/gemspec_test.rb` asserts the **invariant** (every file in a shipped tree is in the manifest) rather than a filename checklist, which would pass forever while the next new file escaped.
32
+
33
+ ### Tests
34
+ - `test/network_test.rb` (13): genesis hashes asserted literally rather than round-tripped through the table that defines them; the three-state alignment, each state separately; `canonical` returning nil rather than a default for an unknown name; and that no boolean exists to collapse the third state.
35
+ - `test/engine_test.rb` (6): the conditional in both directions, in separate processes (`require` is idempotent, so one process cannot observe both).
36
+ - `test/gemspec_test.rb` (8): the packaging invariant, no directory entries, the version file's single literal asserted with the RELEASE CONDUCTOR'S OWN regex rather than a proxy for it, the gemspec holding no literal of its own, and no Rails runtime dependency.
37
+ - `test/network_guard_js_test.rb` (18): the classifier under node, including the inversion found in review — a custom program error outranks the "Transaction simulation failed" wrapper, so a full contest is never reported as a network problem — plus the wrapper's transparency: original error re-thrown, success passed through, a throwing hint callback unable to replace the real error.
38
+ - `test/views_test.rb` (3): every shipped ERB template compiles under **ActionView's** Erubi (stdlib ERB cannot compile the `render … do` block form), the glob is non-empty, and the modal still renders the wallet's own error text.
39
+
40
+ ## v0.4.7 (2026-06-05)
41
+
42
+ ### Added
43
+ - **`Solana::Transaction.cosign_wire(signed_wire_bytes, signer:, require_complete:)`** — client-first cosign. Adds one signature to an already-(partially-)signed wire tx WITHOUT rebuilding it: parses the compact-u16 signature count + message header, finds the signer's account-key index, asserts that slot is currently zero (never clobbers a real signature), signs the EXACT message bytes, writes the 64-byte signature in, and (when `require_complete:`, default true) re-asserts OPSEC-017 — every required slot non-zero. Pure Ruby, no RPC. Enables the Phantom-signs-FIRST / server-cosigns-SECOND entry flow that clears Phantom's multi-signer-order "could be malicious" Lighthouse banner. `cosign_wire_base64` is the base64 wrapper.
44
+ - **`Solana::Transaction.read_compact_u16(bytes, offset)`** — ShortVec compact-u16 decoder, `[value, next_offset]` (wire-parser primitive behind `cosign_wire`).
45
+ - **`Solana::Client#simulate_transaction(tx_base64, sig_verify:, replace_recent_blockhash:, commitment:)`** — server-side `simulateTransaction` pre-flight; returns the RPC `value` object (`err`/`logs`/`unitsConsumed`). Lets the server run the same pre-broadcast simulation the entry board did client-side, now that broadcast moved server-side.
46
+
47
+ ### Tests
48
+ - `test/transaction_test.rb` (+9 tests): correct slot filled + verifies over message, other signer's sig + message bytes untouched, 2/2 sigs valid, refuses to clobber a filled slot, rejects a non-signer, off-by-one slot guard, malformed-header count-mismatch rejection, base64 round-trip.
49
+ - `test/client_test.rb` (+2 tests): simulate_transaction sends the right RPC + parses `value`; surfaces a program error in `value["err"]`.
50
+
5
51
  ## v0.4.6 (2026-06-02)
6
52
 
7
53
  ### Added
data/README.md CHANGED
@@ -8,9 +8,11 @@ Ruby primitives for building on Solana — JSON-RPC client, Ed25519 keypairs, Bo
8
8
 
9
9
  ```ruby
10
10
  # Gemfile
11
- gem "solana-studio", git: "https://github.com/amcritchie/solana-studio.git"
11
+ gem "solana-studio", "~> 0.5"
12
12
  ```
13
13
 
14
+ Consumer apps use the RubyGems release. Use a local path only while actively developing the gem, and restore the RubyGems dependency before merging.
15
+
14
16
  ## Usage
15
17
 
16
18
  ### Keypair
@@ -77,14 +79,155 @@ pda, bump = Solana::Transaction.find_pda(
77
79
  )
78
80
  ```
79
81
 
82
+ ### Network (cluster identity)
83
+
84
+ A Solana cluster has three names that must agree, and nothing in the protocol
85
+ makes them agree for you: the operator's name (`devnet`), the chain's own
86
+ fingerprint (its genesis hash), and the wallet's name (`solana:devnet`).
87
+ `Solana::Network` is the lookup table that relates them.
88
+
89
+ ```ruby
90
+ Solana::Network.genesis_hash("devnet") # => "EtWTRABZaYq6..."
91
+ Solana::Network.cluster_for_genesis(hash) # => "mainnet-beta" (what an RPC ACTUALLY is)
92
+ Solana::Network.wallet_standard_chain("mainnet-beta") # => "solana:mainnet" (note: no -beta)
93
+ Solana::Network.canonical("mainnet") # => "mainnet-beta"; nil if unrecognized
94
+ Solana::Network.expected_for_environment("qa") # => "devnet"
95
+ ```
96
+
97
+ Alignment has **three** outcomes, and collapsing the middle one is a bug:
98
+
99
+ ```ruby
100
+ Solana::Network.alignment(cluster: "devnet", genesis_hash: live_hash)
101
+ # => :aligned | :mismatched | :unverifiable
102
+ ```
103
+
104
+ `:unverifiable` means there was no pinned hash to compare — localnet (whose
105
+ genesis is minted per boot) or an unrecognized cluster name. Treating it as
106
+ `:mismatched` refuses to boot every local validator; treating it as `:aligned`
107
+ trusts a chain nobody checked.
108
+
109
+ ## Rails engine (optional)
110
+
111
+ The gem is Rails-free by default — `railties` is **not** a runtime dependency,
112
+ and plain-Ruby consumers (scripts, rake tasks, `chain-ops`) never load a line of
113
+ it. When the gem is required inside a Rails process, `SolanaStudio::Engine`
114
+ defines itself and contributes the onchain UI primitives.
115
+
116
+ ### Network mismatch guard
117
+
118
+ The problem: a user whose wallet is set to Mainnet, using a QA app that runs on
119
+ Devnet. Their wallet simulates against the wrong chain, shows a frightening
120
+ approval sheet and a balance from a chain nobody is using, and they abandon the
121
+ flow.
122
+
123
+ **A website cannot detect this directly.** Phantom does not expose its selected
124
+ network, and the Wallet Standard `chains` array lists what a wallet *supports*,
125
+ not what it has *selected*. There is no pre-flight read to write. So the guard
126
+ does the only two things that work:
127
+
128
+ **1. Assert at sign-in.** Hand the wallet a SIWS `chainId` and let it contradict
129
+ you — the one pre-emptive signal that exists.
130
+
131
+ ```js
132
+ var signInInput = SolanaStudio.network.withSignInChainId({
133
+ domain: window.location.host,
134
+ nonce: nonce
135
+ });
136
+ // => adds chainId: "solana:devnet"
137
+ ```
138
+
139
+ **2. Explain after a failure.** Wrap any onchain action. The guard never blocks
140
+ and never swallows an error — it re-throws the original rejection untouched, and
141
+ hands you a hint only when a mismatch would actually explain the failure.
142
+
143
+ ```js
144
+ SolanaStudio.network.guard(
145
+ function() { return provider.signTransaction(tx); },
146
+ {
147
+ action: "Entering this contest",
148
+ onHint: function(hint) { Alpine.store('modals').open('network-mismatch', hint); }
149
+ }
150
+ ).then(broadcast); // your existing .catch still receives the real error
151
+ ```
152
+
153
+ `classify(err)` returns `"likely"`, `"possible"`, or `"unrelated"`, and is
154
+ calibrated to **under-claim**: an insufficient-funds error stays an
155
+ insufficient-funds error. Dressing up an unrelated failure as a network problem
156
+ sends the user to fix the wrong thing, which is the bug this feature exists to
157
+ remove.
158
+
159
+ ### Host setup
160
+
161
+ ```erb
162
+ <%# once, inside your modal host %>
163
+ <template x-if="$store.modals.current().id === 'network-mismatch'">
164
+ <%= render "solana_studio/modals/network_mismatch" %>
165
+ </template>
166
+ ```
167
+
168
+ ```erb
169
+ <%# so the browser can read what the server knows %>
170
+ <body data-solana-network="<%= Solana::Network.describe(
171
+ Solana::Config::NETWORK, environment: Rails.env).to_json %>">
172
+ ```
173
+
174
+ The guard falls back to discrete `data-solana-cluster` / `data-app-environment`
175
+ attributes, so a host can adopt it before changing its layout.
176
+
177
+ Requires studio-engine's modal host (`Alpine.store('modals')`) and its shared
178
+ modal blocks. The JS is `solana_studio/network_guard.js` on the asset path.
179
+
80
180
  ## Dependencies
81
181
 
82
182
  - `ed25519` (~> 1.3) — Ed25519 signing
83
183
  - Ruby stdlib only (net/http, json, digest, securerandom)
184
+ - **No Rails dependency.** `railties` is a development dependency only; the
185
+ engine loads solely when the host has already loaded Rails.
84
186
 
85
187
  ## Development Notes
86
188
 
87
- See [CLAUDE.md](./CLAUDE.md) for detailed development context including the full API reference, design decisions, and AI agent instructions.
189
+ See [RUNBOOK.md](./RUNBOOK.md) for troubleshooting and local test commands.
190
+
191
+ ### The browser lane
192
+
193
+ The Ruby suite cannot see two things this gem ships: whether
194
+ `network_guard.js` actually **runs** in a browser, and whether
195
+ `_network_mismatch.html.erb` **renders** (rendering it needs studio-engine's modal
196
+ blocks and a view context, so `test/views_test.rb` only proves it compiles).
197
+
198
+ ```bash
199
+ npm ci && npx playwright install chromium
200
+ npx playwright test # ~40s, boots its own server
201
+ npx playwright test --headed # watch it
202
+ bin/e2e-executed-set-check # did the lane run its WHOLE declared set?
203
+ ```
204
+
205
+ The lane drives the **shipped bytes**: `e2e/boot.rb` copies the real
206
+ `app/assets/javascripts/solana_studio/network_guard.js` into the dummy's `public/`,
207
+ and the lab pages render the real partials by name. A lab page may set up a
208
+ partial's locals and nothing else — `test/e2e_lane_contract_test.rb` asserts that,
209
+ because a page that hand-rolled what the gem does would leave the specs grading the
210
+ lab while reporting green over untested gem code.
211
+
212
+ It is **not** in `bin/release-check`. It runs as a parallel CI job, so it adds ~0
213
+ to the wall time a PR waits and a release does not install a browser to publish.
214
+
215
+ **Two halves, one number.** `config/e2e_lane.yml` declares how many specs must
216
+ execute. `bin/e2e-executed-set-check` reads Playwright's own receipt after the run
217
+ and asserts the executed set against it; `test/e2e_lane_contract_test.rb` asserts
218
+ the committed specs still declare it. Static counting answers "how many are
219
+ DECLARED" and can never answer "how many RAN" — and a runtime skip, a stray
220
+ `--grep`, `--only-changed`, and an uncollected file are four spellings of the same
221
+ event. The receipt turns all four into one arithmetic failure.
222
+
223
+ Derive the counts, never hand-count them: `npx playwright test --list`.
224
+
225
+ Run the suite with `bin/release-check` — the same entry point CI and the release
226
+ gate use, so they cannot drift apart. It enumerates test files by glob (no list
227
+ to forget a file from) and **fails a file that runs zero tests or skips one**,
228
+ because a suite that quietly stops covering something is the failure a green
229
+ build cannot show you. `node` is required: the browser guard's suite runs the
230
+ shipped `.js` under node with `window`/`document` stubs.
88
231
 
89
232
  ## License
90
233
 
@@ -0,0 +1,299 @@
1
+ // SolanaStudio.network — cluster-mismatch validation for onchain actions.
2
+ //
3
+ // THE CONSTRAINT THAT SHAPES ALL OF THIS: a website cannot read which network a
4
+ // browser wallet is set to. Phantom does not expose it, and the Wallet Standard
5
+ // `chains` array advertises what a wallet SUPPORTS, not what it has selected.
6
+ // So there is no pre-flight check to write. You cannot ask the wallet where it
7
+ // is. You can only do two things:
8
+ //
9
+ // 1. ASSERT, at sign-in, and let the wallet contradict you. Handing the wallet
10
+ // a SIWS `chainId` makes it compare that against its own selected network
11
+ // and object. This is the one pre-emptive signal that exists, it costs a
12
+ // signature the user is already making, and it fires once per session.
13
+ //
14
+ // 2. EXPLAIN, after a failure. When a wallet action fails in a way a cluster
15
+ // mismatch would explain, say so — as a hint beside the real error, never
16
+ // instead of it. A misclassification that HIDES the true error is a worse
17
+ // bug than the one this file exists to fix.
18
+ //
19
+ // Deliberately not a blocker. In a `signTransaction` + app-side
20
+ // `sendRawTransaction` architecture the app's own RPC decides where the
21
+ // transaction lands, so a wrong-network wallet cannot misroute funds. What it
22
+ // does is make the wallet simulate against the wrong chain — a frightening
23
+ // approval sheet, a balance from a chain nobody is using, and an abandoned
24
+ // flow. That is a clarity problem, and clarity is what this ships.
25
+ (function() {
26
+ "use strict";
27
+
28
+ var W = window;
29
+ W.SolanaStudio = W.SolanaStudio || {};
30
+
31
+ var LABELS = {
32
+ "mainnet-beta": "Mainnet",
33
+ "devnet": "Devnet",
34
+ "testnet": "Testnet",
35
+ "localnet": "Localnet"
36
+ };
37
+
38
+ // Mirrors Solana::Network::WALLET_STANDARD_CHAINS. Note mainnet loses its
39
+ // -beta suffix here — "solana:mainnet-beta" is not a chain any wallet knows.
40
+ var WALLET_STANDARD_CHAINS = {
41
+ "mainnet-beta": "solana:mainnet",
42
+ "devnet": "solana:devnet",
43
+ "testnet": "solana:testnet",
44
+ "localnet": "solana:localnet"
45
+ };
46
+
47
+ var ENVIRONMENT_LABELS = {
48
+ qa: "QA",
49
+ production: "Production",
50
+ development: "Development",
51
+ test: "Test"
52
+ };
53
+
54
+ // --- Page facts ------------------------------------------------------------
55
+ // The server is the authority on which cluster this app runs against; the
56
+ // browser only reads it. Preferred form is one JSON blob written from
57
+ // Solana::Network.describe. The discrete attributes are a fallback so a host
58
+ // can adopt the guard before it changes its layout.
59
+ function context() {
60
+ var body = document.body;
61
+ if (!body || !body.dataset) return blank();
62
+
63
+ var described = null;
64
+ if (body.dataset.solanaNetwork) {
65
+ try { described = JSON.parse(body.dataset.solanaNetwork); } catch (e) { described = null; }
66
+ }
67
+
68
+ var cluster = (described && described.cluster) || body.dataset.solanaCluster || "";
69
+ var environment = (described && described.environment) || body.dataset.appEnvironment || "";
70
+
71
+ return {
72
+ cluster: cluster,
73
+ known: !!LABELS[cluster],
74
+ label: (described && described.label) || LABELS[cluster] || "Unknown Network",
75
+ environment: environment,
76
+ environmentLabel: ENVIRONMENT_LABELS[environment] ||
77
+ (environment ? environment.charAt(0).toUpperCase() + environment.slice(1) : "Unknown"),
78
+ walletStandardChain: (described && described.wallet_standard_chain) ||
79
+ WALLET_STANDARD_CHAINS[cluster] || null
80
+ };
81
+ }
82
+
83
+ function blank() {
84
+ return {
85
+ cluster: "", known: false, label: "Unknown Network",
86
+ environment: "", environmentLabel: "Unknown", walletStandardChain: null
87
+ };
88
+ }
89
+
90
+ // --- 1. Assert at sign-in --------------------------------------------------
91
+ // The value to put on a SIWS input's `chainId`. Returns null when this app's
92
+ // cluster has no Wallet Standard name, in which case the field must be OMITTED
93
+ // rather than sent empty — an unrecognized chainId reads to the wallet as a
94
+ // mismatch against every network, so a blank one rejects everybody.
95
+ function signInChainId() {
96
+ return context().walletStandardChain;
97
+ }
98
+
99
+ // Apply the chainId to a SIWS input, non-destructively. A host that does not
100
+ // want the assertion simply does not call this.
101
+ function withSignInChainId(signInInput) {
102
+ var chainId = signInChainId();
103
+ if (!chainId) return signInInput;
104
+
105
+ var out = {};
106
+ for (var k in signInInput) {
107
+ if (Object.prototype.hasOwnProperty.call(signInInput, k)) out[k] = signInInput[k];
108
+ }
109
+ out.chainId = chainId;
110
+ return out;
111
+ }
112
+
113
+ // --- 2. Explain after a failure -------------------------------------------
114
+ // Error shapes a cluster mismatch would explain, ranked by how much of the
115
+ // message the mismatch actually accounts for.
116
+ //
117
+ // UNRELATED, AND CHECKED FIRST. A custom program error means the program RAN:
118
+ // it was found, on a chain that has it, and it rejected the instruction on its
119
+ // own terms. Whatever else the envelope says, a cluster mismatch cannot explain
120
+ // that outcome.
121
+ //
122
+ // This precedence is the fix for a real inversion. "Transaction simulation
123
+ // failed" is the WRAPPER Solana puts around every failed simulation, including
124
+ // ordinary business rejections, so matching it as LIKELY classified
125
+ //
126
+ // "Transaction simulation failed: Error processing Instruction 0:
127
+ // custom program error: 0x1770"
128
+ //
129
+ // — a plain "contest is full" — as a probable network problem at top
130
+ // confidence. That sends a user to change their wallet's network over a full
131
+ // contest: the exact wrong-thing-to-fix this feature exists to prevent, with the
132
+ // feature's own voice behind it. Found in review by executing this file.
133
+ // Every entry here must name a PROGRAM CODE — proof the program ran and chose
134
+ // this outcome. "Error processing Instruction 0:" is deliberately NOT here: it
135
+ // is Solana's generic InstructionError envelope, and it wraps the not-found
136
+ // failures below just as readily as it wraps a business rejection. Putting it
137
+ // here fixed the first inversion by creating its mirror image: a wrapper at this
138
+ // precedence answered `unrelated` for
139
+ //
140
+ // "Transaction simulation failed: Error processing Instruction 0:
141
+ // Attempt to load a program that does not exist"
142
+ //
143
+ // so explain() returned null and the modal never opened. Precedence, verified
144
+ // against solana-sdk's transaction-error source: ProgramAccountNotFound,
145
+ // AccountNotFound and BlockhashNotFound all render TOP-LEVEL — only
146
+ // InstructionError carries the "Error processing Instruction {i}:" wrapper. So
147
+ // the composed string above is DEFENSIVE rather than observed; our own captured
148
+ // corpus has never wrapped a not-found. It costs nothing to order correctly and
149
+ // the rule generalizes: a wrapper must never decide confidence. That was the
150
+ // lesson of the first fix, and this list is where it has to hold.
151
+ var PROGRAM_ERROR = [
152
+ /custom program error/i,
153
+ /\b0x1[0-9a-f]{3}\b/i, // Anchor's 6000+ user error range, as hex
154
+ /\bAnchorError\b/i
155
+ ];
156
+
157
+ // The generic instruction envelope, checked AFTER the not-found signals. On its
158
+ // own — no program code, and nothing above matched — an instruction failed on
159
+ // its own terms and there is no wrong-chain signal to explain it, so it demotes
160
+ // an otherwise-POSSIBLE shrug to `unrelated` rather than deciding anything.
161
+ var INSTRUCTION_ERROR = [
162
+ /error processing instruction/i,
163
+ /\bInstructionError\b/i
164
+ ];
165
+
166
+ // LIKELY — the wallet looked for our program or our blockhash on a chain that
167
+ // does not have them. Each of these names a thing that WAS NOT FOUND, which is
168
+ // what a wrong chain actually produces; none of them is a wrapper.
169
+ var LIKELY = [
170
+ /attempt to load a program that does not exist/i,
171
+ /program that does not exist/i,
172
+ /ProgramAccountNotFound/i,
173
+ /unlikely to succeed/i,
174
+ /blockhash not found/i,
175
+ // TransactionError::AccountNotFound. The canonical "this account has no
176
+ // balance on the chain being simulated against" — top-level, unwrapped, and
177
+ // with no business-logic reading, which is what earns it a place here when
178
+ // the Anchor account errors deliberately do not get one (an
179
+ // AccountNotInitialized on a PDA is genuinely ambiguous: wrong network, or a
180
+ // right-network account nobody initialized).
181
+ /found no record of a prior credit/i,
182
+ /\bAccountNotFound\b/i
183
+ ];
184
+
185
+ // POSSIBLE — genuinely ambiguous. Phantom collapses a failed simulation into
186
+ // a bare "Unexpected error", and a user who bails at a scary approval sheet is
187
+ // indistinguishable from one who changed their mind. Worth a hint; never worth
188
+ // replacing the real message.
189
+ var POSSIBLE = [
190
+ /^unexpected error$/i,
191
+ /^unexpected/i,
192
+ /user rejected/i,
193
+ /user declined/i,
194
+ // The bare wrapper, with no program error to account for it. Genuinely
195
+ // ambiguous — worth a hint, never worth top confidence.
196
+ /transaction simulation failed/i
197
+ ];
198
+
199
+ function messageOf(err) {
200
+ if (!err) return "";
201
+ if (typeof err === "string") return err;
202
+ return err.message || err.error || String(err);
203
+ }
204
+
205
+ // How much would a cluster mismatch explain this failure?
206
+ // "likely" | "possible" | "unrelated"
207
+ //
208
+ // Nothing here is proof. The wallet never tells us where it was, so this is
209
+ // inference over error text, and it is calibrated to under-claim: an
210
+ // insufficient-funds error stays an insufficient-funds error.
211
+ function classify(err) {
212
+ var msg = messageOf(err);
213
+ if (!msg) return "unrelated";
214
+
215
+ var i;
216
+ // Precedence, not just membership: a program error is checked BEFORE the
217
+ // LIKELY list, because the wrapper text can carry both.
218
+ for (i = 0; i < PROGRAM_ERROR.length; i++) {
219
+ if (PROGRAM_ERROR[i].test(msg)) return "unrelated";
220
+ }
221
+ for (i = 0; i < LIKELY.length; i++) {
222
+ if (LIKELY[i].test(msg)) return "likely";
223
+ }
224
+ // AFTER the not-found signals, never before: the envelope wraps them too.
225
+ for (i = 0; i < INSTRUCTION_ERROR.length; i++) {
226
+ if (INSTRUCTION_ERROR[i].test(msg)) return "unrelated";
227
+ }
228
+ for (i = 0; i < POSSIBLE.length; i++) {
229
+ if (POSSIBLE[i].test(msg)) return "possible";
230
+ }
231
+ return "unrelated";
232
+ }
233
+
234
+ function couldBeMismatch(err) {
235
+ return classify(err) !== "unrelated";
236
+ }
237
+
238
+ // The props a mismatch explainer modal needs. Returns null when a mismatch
239
+ // would not explain the failure, so `var hint = explain(err); if (hint) {...}`
240
+ // is the whole integration.
241
+ function explain(err, opts) {
242
+ opts = opts || {};
243
+ var confidence = classify(err);
244
+ if (confidence === "unrelated") return null;
245
+
246
+ var ctx = context();
247
+ return {
248
+ confidence: confidence,
249
+ cluster: ctx.cluster,
250
+ networkLabel: ctx.label,
251
+ environment: ctx.environment,
252
+ environmentLabel: ctx.environmentLabel,
253
+ action: opts.action || "This transaction",
254
+ originalMessage: messageOf(err),
255
+ title: "Check your wallet's network",
256
+ message: (opts.action || "This transaction") + " runs on " + ctx.label +
257
+ ", because you're on " + ctx.environmentLabel + ". If your wallet " +
258
+ "is set to a different network, it will warn you and show the " +
259
+ "wrong balance."
260
+ };
261
+ }
262
+
263
+ // --- The wrapper -----------------------------------------------------------
264
+ // Put this around any onchain action. It does not gate the call — it cannot,
265
+ // and pretending otherwise would be a lie in the shape of a safety feature.
266
+ // It runs the action, and when the action fails in a mismatch-shaped way it
267
+ // hands the host a hint to show beside the error.
268
+ //
269
+ // SolanaStudio.network.guard(
270
+ // function() { return provider.signTransaction(tx); },
271
+ // { action: "Entering this contest", onHint: showNetworkHint }
272
+ // )
273
+ //
274
+ // The original rejection is always re-thrown, unchanged. Callers keep their
275
+ // existing error handling exactly as written.
276
+ function guard(fn, opts) {
277
+ opts = opts || {};
278
+ return Promise.resolve()
279
+ .then(fn)
280
+ .catch(function(err) {
281
+ var hint = explain(err, opts);
282
+ if (hint && typeof opts.onHint === "function") {
283
+ try { opts.onHint(hint, err); } catch (e) { /* a broken hint must not eat the error */ }
284
+ }
285
+ throw err;
286
+ });
287
+ }
288
+
289
+ W.SolanaStudio.network = {
290
+ context: context,
291
+ signInChainId: signInChainId,
292
+ withSignInChainId: withSignInChainId,
293
+ classify: classify,
294
+ couldBeMismatch: couldBeMismatch,
295
+ explain: explain,
296
+ guard: guard,
297
+ WALLET_STANDARD_CHAINS: WALLET_STANDARD_CHAINS
298
+ };
299
+ })();
@@ -0,0 +1,83 @@
1
+ <%#
2
+ Network-mismatch explainer — the onchain modal primitive from solana-studio.
3
+
4
+ Opened when a wallet action fails in a way a cluster mismatch would explain.
5
+ Props come from SolanaStudio.network.explain(err) (see
6
+ app/assets/javascripts/solana_studio/network_guard.js) and carry:
7
+
8
+ confidence "likely" | "possible" — how much a mismatch accounts for it
9
+ networkLabel the cluster THIS APP runs on ("Devnet")
10
+ environmentLabel the environment the user is in ("QA")
11
+ action what they were doing ("Entering this contest")
12
+ originalMessage the wallet's own error text — always shown, never replaced
13
+
14
+ This modal EXPLAINS; it does not block. A website cannot read which network a
15
+ wallet is set to (Phantom does not expose it, and the Wallet Standard `chains`
16
+ array lists what a wallet supports rather than what it selected), so an
17
+ honest modal states what the app knows — its own network — and leaves the
18
+ comparison to the person who can actually see the wallet.
19
+
20
+ HOST REQUIREMENTS — both are load-bearing, and the second is easy to miss:
21
+ 1. studio-engine's modal host (Alpine $store.modals) and the shared modal
22
+ blocks, with this partial registered under the id "network-mismatch"
23
+ inside a template x-if on that id.
24
+ 2. AN ANCESTOR x-data SCOPE, normally a bare `x-data` on the host app's
25
+ <body>. The engine's modal host roots itself at a template x-if with no
26
+ x-data of its own, and Alpine only walks trees beneath an x-data root —
27
+ without it the host is never processed, the modal never opens, and the
28
+ page looks perfectly correct in the served HTML.
29
+
30
+ NO ERB TAGS IN THIS COMMENT, deliberately. An earlier version spelled the
31
+ registration example out in real tags; the example's own closing marker ended
32
+ this comment early and the remaining prose leaked into the rendered card as
33
+ literal text. It compiled, it passed every Ruby tier, and only the browser lane
34
+ saw it. Describe the integration in words here; the runnable example lives in
35
+ the gem README, where a stray marker cannot escape into a page.
36
+ %>
37
+ <div x-data="{
38
+ get props() { var c = $store.modals.current(); return (c && c.props) || {}; }
39
+ }">
40
+ <%= render 'studio/modals/blocks/card_header',
41
+ icon_emoji: '🔀',
42
+ title_key: "props.title || 'Check your wallet\\'s network'" do %>
43
+ <span x-text="props.message"></span>
44
+ <% end %>
45
+
46
+ <%# The app's own facts. Stated plainly and without hedging, because these two
47
+ rows are the half of the comparison we can actually vouch for. %>
48
+ <div class="rounded-lg border border-subtle bg-surface-alt p-3 mb-4 text-sm">
49
+ <div class="flex items-center justify-between gap-3">
50
+ <span class="text-secondary">This app runs on</span>
51
+ <span class="font-semibold text-heading" x-text="props.networkLabel"></span>
52
+ </div>
53
+ <div class="flex items-center justify-between gap-3 mt-2">
54
+ <span class="text-secondary">Environment</span>
55
+ <span class="font-semibold text-heading" x-text="props.environmentLabel"></span>
56
+ </div>
57
+ </div>
58
+
59
+ <div class="rounded-lg border border-subtle p-3 mb-4">
60
+ <p class="text-xs font-semibold text-heading mb-2">If your wallet is on a different network</p>
61
+ <p class="text-xs text-secondary">
62
+ Open Phantom, then <span class="font-semibold text-heading">Settings → Developer Settings</span>
63
+ and switch the network to <span class="font-semibold text-heading" x-text="props.networkLabel"></span>.
64
+ Your wallet shows a separate balance for every network, so a wallet on the
65
+ wrong one looks empty even when it isn't.
66
+ </p>
67
+ </div>
68
+
69
+ <%# The wallet's real error, always. The hint sits BESIDE the error, never in
70
+ place of it — a mismatch is inferred from error text, and a wrong guess
71
+ that swallowed the true message would be a worse bug than the confusion
72
+ this modal exists to clear up. %>
73
+ <template x-if="props.originalMessage">
74
+ <details class="mb-4 rounded-lg border border-subtle bg-surface-alt p-3">
75
+ <summary class="text-xs text-secondary cursor-pointer">What the wallet reported</summary>
76
+ <p class="mt-2 text-xs text-secondary break-words" x-text="props.originalMessage"></p>
77
+ </details>
78
+ </template>
79
+
80
+ <button type="button" class="btn btn-primary btn-lg w-full" @click="$store.modals.close()">
81
+ Got it
82
+ </button>
83
+ </div>
data/lib/solana/client.rb CHANGED
@@ -54,6 +54,22 @@ module Solana
54
54
  call("sendTransaction", [signed_tx_base64, opts])
55
55
  end
56
56
 
57
+ # Server-side pre-flight: run simulateTransaction against a base64 wire tx.
58
+ # sig_verify:false lets us simulate a tx without all signatures present (or
59
+ # without re-verifying ones that are). Returns the RPC `value` object
60
+ # ({ "err" =>, "logs" =>, "unitsConsumed" =>, … }); `value["err"]` is nil on
61
+ # success. Mirrors the client-side simulate the entry board used to run.
62
+ def simulate_transaction(signed_tx_base64, sig_verify: false, replace_recent_blockhash: false, commitment: "confirmed")
63
+ opts = {
64
+ encoding: "base64",
65
+ sigVerify: sig_verify,
66
+ replaceRecentBlockhash: replace_recent_blockhash,
67
+ commitment: commitment
68
+ }
69
+ result = call("simulateTransaction", [signed_tx_base64, opts])
70
+ result&.dig("value")
71
+ end
72
+
57
73
  def confirm_transaction(signature, commitment: "confirmed")
58
74
  call("getSignatureStatuses", [[signature], { searchTransactionHistory: true }])
59
75
  end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Solana
4
+ # Cluster identity — the one place that knows what "devnet" IS.
5
+ #
6
+ # A Solana cluster has three names that must agree, and nothing in the
7
+ # protocol makes them agree for you:
8
+ #
9
+ # 1. The operator's name for it — "devnet" (SOLANA_NETWORK)
10
+ # 2. The chain's own fingerprint — genesis hash (getGenesisHash)
11
+ # 3. The wallet's name for it — "solana:devnet" (Wallet Standard)
12
+ #
13
+ # Every mis-alignment bug in this ecosystem is two of those three disagreeing:
14
+ # a mainnet program ID against a devnet RPC (caught at boot by the host's
15
+ # alignment check), or an app on devnet against a wallet on mainnet (caught at
16
+ # sign-in, by handing the wallet name #3 and letting it object).
17
+ #
18
+ # This module is deliberately Rails-free and RPC-free — it is a lookup table
19
+ # with opinions. Fetching the live genesis hash is the HOST's job — the RPC call
20
+ # is `getGenesisHash`, which this gem's Solana::Client does not wrap today (turf
21
+ # monster adds it in an initializer); deciding what to DO about a mismatch is the
22
+ # host's too.
23
+ module Network
24
+ # Canonical cluster names, as Solana's own tooling spells them. Note
25
+ # "mainnet-beta" — the hyphenated form is the real one; "mainnet" is an
26
+ # alias people type, normalized by .canonical.
27
+ MAINNET = "mainnet-beta"
28
+ DEVNET = "devnet"
29
+ TESTNET = "testnet"
30
+ LOCALNET = "localnet"
31
+
32
+ CLUSTERS = [MAINNET, DEVNET, TESTNET, LOCALNET].freeze
33
+
34
+ # The genesis block hash of each public cluster — a chain's immutable
35
+ # fingerprint, and the only cluster identifier that cannot be misconfigured.
36
+ # An RPC URL can lie (a proxy, a fork, a typo'd host); its genesis hash
37
+ # cannot. localnet is absent on purpose: a fresh validator generates a new
38
+ # genesis every time it boots, so there is no constant to pin.
39
+ GENESIS_HASHES = {
40
+ MAINNET => "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d",
41
+ DEVNET => "EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG",
42
+ TESTNET => "4uhcVJyU9pJkvQyS88uRDiswHXSCkY3zQawwpjk2NsNY"
43
+ }.freeze
44
+
45
+ # Wallet Standard chain identifiers (CAIP-2 namespace "solana"), the form a
46
+ # browser wallet speaks. Phantom and every Wallet-Standard wallet advertise
47
+ # these in `wallet.chains` and accept one as the SIWS `chainId`.
48
+ #
49
+ # Mind the asymmetry: the Wallet Standard spells mainnet "solana:mainnet",
50
+ # WITHOUT the -beta suffix the operator name carries. Passing
51
+ # "solana:mainnet-beta" is not a near-miss the wallet forgives — it is an
52
+ # unrecognized chain.
53
+ WALLET_STANDARD_CHAINS = {
54
+ MAINNET => "solana:mainnet",
55
+ DEVNET => "solana:devnet",
56
+ TESTNET => "solana:testnet",
57
+ # localnet is a valid Wallet Standard chain, but NOT a valid SIWS chainId —
58
+ # no wallet can verify a sign-in against a chain only your machine has. It is
59
+ # here for completeness of the chain table; do not send it as a chainId.
60
+ LOCALNET => "solana:localnet"
61
+ }.freeze
62
+
63
+ # Human labels for UI. Anything unrecognized is deliberately NOT given a
64
+ # friendly name — an unknown cluster is a fact worth showing the operator,
65
+ # not something to paper over.
66
+ LABELS = {
67
+ MAINNET => "Mainnet",
68
+ DEVNET => "Devnet",
69
+ TESTNET => "Testnet",
70
+ LOCALNET => "Localnet"
71
+ }.freeze
72
+
73
+ ALIASES = {
74
+ "mainnet" => MAINNET,
75
+ "mainnet-beta" => MAINNET,
76
+ "main" => MAINNET,
77
+ "dev" => DEVNET,
78
+ "local" => LOCALNET,
79
+ "localhost" => LOCALNET
80
+ }.freeze
81
+
82
+ module_function
83
+
84
+ # Normalize an operator-supplied cluster name to its canonical spelling.
85
+ # Returns nil for anything unrecognized — callers decide whether an unknown
86
+ # cluster is a warning or a refusal, and they need to be able to tell.
87
+ def canonical(cluster)
88
+ return nil if cluster.nil?
89
+
90
+ name = cluster.to_s.strip.downcase
91
+ return nil if name.empty?
92
+ return name if CLUSTERS.include?(name)
93
+
94
+ ALIASES[name]
95
+ end
96
+
97
+ def known?(cluster)
98
+ !canonical(cluster).nil?
99
+ end
100
+
101
+ # The pinned genesis hash for a cluster, or nil when there isn't one
102
+ # (unknown cluster, or localnet — whose genesis is per-boot).
103
+ def genesis_hash(cluster)
104
+ GENESIS_HASHES[canonical(cluster)]
105
+ end
106
+
107
+ # Reverse lookup: given a hash from getGenesisHash, which cluster is this?
108
+ # This is how you learn what an RPC URL ACTUALLY points at, as opposed to
109
+ # what its hostname claims. nil means "no public cluster we know" — which
110
+ # for a localnet validator is the correct and expected answer.
111
+ def cluster_for_genesis(hash)
112
+ return nil if hash.nil? || hash.to_s.strip.empty?
113
+
114
+ GENESIS_HASHES.key(hash.to_s.strip)
115
+ end
116
+
117
+ # The Wallet Standard chain id for a cluster — the value to hand a browser
118
+ # wallet as the SIWS `chainId` so the wallet compares it against its OWN
119
+ # selected network and objects when they differ.
120
+ #
121
+ # This is the ONLY pre-emptive mismatch signal available to a website:
122
+ # wallets do not expose their selected cluster for reading. You cannot ask;
123
+ # you can only assert and let the wallet contradict you.
124
+ def wallet_standard_chain(cluster)
125
+ WALLET_STANDARD_CHAINS[canonical(cluster)]
126
+ end
127
+
128
+ def label(cluster)
129
+ LABELS[canonical(cluster)] || "Unknown Network"
130
+ end
131
+
132
+ # The cluster an environment is SUPPOSED to run against. Production means
133
+ # real money, so it means mainnet; everything else means devnet. A host that
134
+ # disagrees (a mainnet-facing staging rehearsal, say) passes its own map.
135
+ def expected_for_environment(environment)
136
+ environment.to_s == "production" ? MAINNET : DEVNET
137
+ end
138
+
139
+ # Does a live genesis hash confirm the declared cluster?
140
+ #
141
+ # Three outcomes, and the middle one matters: :aligned (the chain is who it
142
+ # says), :mismatched (it is a DIFFERENT known chain — the dangerous case),
143
+ # and :unverifiable (no pinned hash to compare, i.e. localnet or an unknown
144
+ # cluster name). Callers must not collapse :unverifiable into either of the
145
+ # other two — refusing to boot on localnet is as wrong as trusting a
146
+ # mainnet RPC that answered with a devnet genesis.
147
+ #
148
+ # THERE IS DELIBERATELY NO `aligned?` BOOLEAN. One existed and was removed in
149
+ # review: it answered `false` for :unverifiable, so a host writing the obvious
150
+ # `raise unless aligned?` would refuse to boot against a local validator —
151
+ # precisely the mistake the paragraph above warns about, re-introduced by the
152
+ # convenience wrapper meant to save callers from it. Three states, three
153
+ # branches; the caller decides what :unverifiable means for them.
154
+ def alignment(cluster:, genesis_hash:)
155
+ expected = self.genesis_hash(cluster)
156
+ return :unverifiable if expected.nil?
157
+ return :unverifiable if genesis_hash.nil? || genesis_hash.to_s.strip.empty?
158
+
159
+ genesis_hash.to_s.strip == expected ? :aligned : :mismatched
160
+ end
161
+
162
+ # Everything a UI needs to explain a network to a person, in one hash.
163
+ # Serialized straight into a data attribute by the host so the browser guard
164
+ # reads the same facts the server holds.
165
+ def describe(cluster, environment: nil)
166
+ canonical_name = canonical(cluster)
167
+ {
168
+ cluster: canonical_name,
169
+ declared: cluster.to_s,
170
+ known: !canonical_name.nil?,
171
+ label: label(cluster),
172
+ genesis_hash: genesis_hash(cluster),
173
+ wallet_standard_chain: wallet_standard_chain(cluster),
174
+ environment: environment&.to_s,
175
+ expected_cluster: environment.nil? ? nil : expected_for_environment(environment)
176
+ }
177
+ end
178
+ end
179
+ end
@@ -155,6 +155,131 @@ module Solana
155
155
  Base64.strict_encode64(serialize_partial(additional_signers: additional_signers))
156
156
  end
157
157
 
158
+ # Add one signature to an already-(partially-)signed wire transaction WITHOUT
159
+ # rebuilding it. This is the inverse-order cosign: a client wallet (Phantom)
160
+ # signs FIRST and returns the wire bytes with its slot filled and the other
161
+ # slots zero; the server then drops its own signature into the correct slot.
162
+ #
163
+ # Why this exists (Phantom "could be malicious" banner fix): when the SERVER
164
+ # pre-signs and Phantom signs SECOND, Phantom's Lighthouse heuristics flag
165
+ # the multi-signer ordering. Flipping the order — Phantom signs the
166
+ # fully-unsigned tx first, server cosigns after — clears that rule. The
167
+ # server can't rebuild-and-resign (that would change the message bytes and
168
+ # invalidate Phantom's signature), so it must surgically patch the existing
169
+ # wire payload.
170
+ #
171
+ # Pure Ruby, no RPC. Parses the compact-u16 signature count + the message
172
+ # header, locates `signer` in the account-key list, asserts that slot is
173
+ # still zero (never clobber a real signature), signs the EXACT message bytes
174
+ # Phantom signed, and writes the 64-byte signature into that slot. Re-asserts
175
+ # OPSEC-017 afterwards: every one of the numRequiredSignatures slots must be
176
+ # non-zero (the tx is now fully signed and broadcastable).
177
+ #
178
+ # signed_wire_bytes : String (binary) — the wire-format tx (sig count + sigs + message)
179
+ # signer: : Solana::Keypair — the cosigner (e.g. the admin keypair)
180
+ # require_complete: : when true (default) re-assert OPSEC-017 AFTER the write —
181
+ # every one of the numRequiredSignatures slots must be non-zero, i.e. this
182
+ # cosigner is the LAST one and the tx is now fully broadcastable. The
183
+ # turf-monster server cosign is always the final signer, so it leaves this
184
+ # on. Pass false for an intermediate cosign in a 3+-signer chain.
185
+ # Returns the patched wire bytes (binary String). Phantom's signature and the
186
+ # message bytes are left byte-for-byte untouched.
187
+ def self.cosign_wire(signed_wire_bytes, signer:, require_complete: true)
188
+ bytes = signed_wire_bytes.b.dup
189
+ cursor = 0
190
+
191
+ # 1. Compact-u16 signature count.
192
+ sig_count, cursor = read_compact_u16(bytes, cursor)
193
+ raise "cosign_wire: zero signatures in wire payload" if sig_count.zero?
194
+
195
+ sigs_start = cursor
196
+ sigs_len = sig_count * 64
197
+ raise "cosign_wire: truncated signature array" if bytes.bytesize < sigs_start + sigs_len
198
+ message_start = sigs_start + sigs_len
199
+
200
+ # 2. Message header — first byte is numRequiredSignatures. It MUST equal the
201
+ # signature-array length (a well-formed message reserves exactly one slot
202
+ # per declared signer). Guard against an off-by-one / malformed payload.
203
+ num_required = bytes.getbyte(message_start)
204
+ raise "cosign_wire: empty message" if num_required.nil?
205
+ unless num_required == sig_count
206
+ raise "cosign_wire: header numRequiredSignatures=#{num_required} != " \
207
+ "signature slots=#{sig_count} (malformed wire payload)"
208
+ end
209
+
210
+ # 3. Account keys. Header is 3 bytes, then a compact-u16 account count,
211
+ # then `count` * 32-byte keys. The first `num_required` account keys are
212
+ # the signer slots, in the SAME order as the signature array.
213
+ acct_cursor = message_start + 3
214
+ account_count, acct_cursor = read_compact_u16(bytes, acct_cursor)
215
+ raise "cosign_wire: account count #{account_count} < required signers #{num_required}" if account_count < num_required
216
+
217
+ target = signer.public_key_bytes.b
218
+ slot_index = nil
219
+ num_required.times do |i|
220
+ key = bytes.byteslice(acct_cursor + (i * 32), 32)
221
+ if key == target
222
+ slot_index = i
223
+ break
224
+ end
225
+ end
226
+ raise "cosign_wire: signer #{signer.address} is not a required signer of this transaction" if slot_index.nil?
227
+
228
+ # 4. The target slot must be empty (all-zero). Never clobber a signature
229
+ # that's already there (Phantom's, or a prior cosigner's).
230
+ slot_offset = sigs_start + (slot_index * 64)
231
+ existing = bytes.byteslice(slot_offset, 64)
232
+ unless existing == ("\x00" * 64).b
233
+ raise "cosign_wire: slot #{slot_index} for #{signer.address} already holds a signature — refusing to clobber"
234
+ end
235
+
236
+ # 5. Sign the EXACT message bytes Phantom signed and write the signature in.
237
+ message = bytes.byteslice(message_start, bytes.bytesize - message_start)
238
+ signature = signer.sign(message)
239
+ raise "cosign_wire: signature is not 64 bytes" unless signature.bytesize == 64
240
+ bytes[slot_offset, 64] = signature.b
241
+
242
+ # 6. OPSEC-017 post-condition (when require_complete): the tx must now be
243
+ # fully signed — every one of the num_required slots non-zero. A leftover
244
+ # zero slot means another signer is still missing and the payload is not
245
+ # broadcastable. The server cosign is the last signer, so it asserts this;
246
+ # an intermediate cosign in a 3+-signer chain passes require_complete:false.
247
+ if require_complete
248
+ num_required.times do |i|
249
+ off = sigs_start + (i * 64)
250
+ if bytes.byteslice(off, 64) == ("\x00" * 64).b
251
+ raise "cosign_wire: slot #{i} is still empty after cosign — " \
252
+ "transaction needs #{num_required} signatures and is not yet complete"
253
+ end
254
+ end
255
+ end
256
+
257
+ bytes
258
+ end
259
+
260
+ # Convenience: cosign a base64 wire tx, return base64.
261
+ def self.cosign_wire_base64(signed_wire_base64, signer:, require_complete: true)
262
+ require "base64"
263
+ patched = cosign_wire(Base64.decode64(signed_wire_base64), signer: signer, require_complete: require_complete)
264
+ Base64.strict_encode64(patched)
265
+ end
266
+
267
+ # Decode a compact-u16 (ShortVec) starting at `offset`. Returns [value, next_offset].
268
+ def self.read_compact_u16(bytes, offset)
269
+ value = 0
270
+ shift = 0
271
+ loop do
272
+ byte = bytes.getbyte(offset)
273
+ raise "read_compact_u16: ran off the end of the buffer" if byte.nil?
274
+ offset += 1
275
+ value |= (byte & 0x7F) << shift
276
+ break if (byte & 0x80).zero?
277
+ shift += 7
278
+ raise "read_compact_u16: value too large" if shift > 21
279
+ end
280
+ [value, offset]
281
+ end
282
+
158
283
  private
159
284
 
160
285
  def normalize_pubkey(key)
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolanaStudio
4
+ # The gem's OPTIONAL Rails half.
5
+ #
6
+ # solana-studio is a Rails-free gem by design — chain-ops scripts, rake tasks
7
+ # and bare `ruby -e` consumers load Solana::Keypair and Solana::Transaction
8
+ # without ever paying for railties. This file is the one place that assumes
9
+ # Rails, and lib/solana_studio.rb requires it ONLY when Rails::Engine is
10
+ # already defined. Nothing here runs for a non-Rails consumer, and railties is
11
+ # deliberately absent from the gemspec's runtime dependencies: the host app
12
+ # supplies Rails, this engine merely joins it.
13
+ #
14
+ # What the engine adds is the onchain UI surface — the modals and browser
15
+ # guard that every Solana-backed app in the ecosystem would otherwise fork.
16
+ # Rails picks up app/views automatically for any Engine subclass, so a host
17
+ # can `render "solana_studio/modals/network_mismatch"` with no configuration.
18
+ #
19
+ # Namespace is NOT isolated, matching studio-engine. These are partials a host
20
+ # renders into its own modal host, not a mounted sub-application; isolating
21
+ # would scope helpers and routes away from the host for no gain.
22
+ class Engine < ::Rails::Engine
23
+ # Sprockets hosts (mcritchie-studio, turf-monster) need every gem-shipped
24
+ # asset named here or it 404s in production with no local warning —
25
+ # propshaft hosts serve everything on config.assets.paths and ignore this.
26
+ # Guarded because a propshaft host has no config.assets.precompile at all.
27
+ initializer "solana_studio.assets" do |app|
28
+ next unless app.config.respond_to?(:assets)
29
+ next unless app.config.assets.respond_to?(:precompile)
30
+
31
+ app.config.assets.precompile += %w[
32
+ solana_studio/network_guard.js
33
+ ]
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolanaStudio
4
+ # THE gem's version, and the only place it is written.
5
+ #
6
+ # This file exists to be rewritten by the release conductor and by nothing
7
+ # else. `bin/release prepare` allocates the number from the candidate's
8
+ # membership and rewrites the single literal below
9
+ # (Release::GemVersion.rewrite_version, which refuses a file declaring more
10
+ # than one rather than guess which is real), so keep it to exactly one.
11
+ #
12
+ # It used to live in solana-studio.gemspec, which made the WHOLE gemspec
13
+ # release-owned in bin/dor-check's eyes — including spec.files, the runtime
14
+ # dependencies and the metadata, none of which the conductor touches and none
15
+ # of which had another writer. That left the manifest permanently un-editable
16
+ # through the normal cycle. Splitting the version out is the same shape
17
+ # studio-engine already uses (lib/studio/version.rb) and hands each file back
18
+ # to its real owner: this one to the release, the gemspec to the PR.
19
+ VERSION = "0.5.0"
20
+ end
data/lib/solana_studio.rb CHANGED
@@ -6,7 +6,14 @@ require_relative "solana/spl_token"
6
6
  require_relative "solana/system_program"
7
7
  require_relative "solana/nonce_account"
8
8
  require_relative "solana/auth_verifier"
9
+ require_relative "solana/network"
9
10
 
10
- module SolanaStudio
11
- VERSION = "0.4.6"
12
- end
11
+ # The version is its own file so the release conductor has exactly one literal
12
+ # to rewrite, and so editing this gem's MANIFEST does not read as editing its
13
+ # version. See lib/solana_studio/version.rb.
14
+ require_relative "solana_studio/version"
15
+
16
+ # The Rails half of the gem — views, assets, and the browser network guard.
17
+ # Loaded only inside a Rails process; a plain-Ruby consumer never sees it and
18
+ # never needs railties installed. See lib/solana_studio/engine.rb.
19
+ require_relative "solana_studio/engine" if defined?(::Rails::Engine)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solana-studio
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.6
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-02 00:00:00.000000000 Z
11
+ date: 2026-08-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ed25519
@@ -37,16 +37,21 @@ files:
37
37
  - CHANGELOG.md
38
38
  - LICENSE
39
39
  - README.md
40
+ - app/assets/javascripts/solana_studio/network_guard.js
41
+ - app/views/solana_studio/modals/_network_mismatch.html.erb
40
42
  - lib/solana-studio.rb
41
43
  - lib/solana/auth_verifier.rb
42
44
  - lib/solana/borsh.rb
43
45
  - lib/solana/client.rb
44
46
  - lib/solana/keypair.rb
47
+ - lib/solana/network.rb
45
48
  - lib/solana/nonce_account.rb
46
49
  - lib/solana/spl_token.rb
47
50
  - lib/solana/system_program.rb
48
51
  - lib/solana/transaction.rb
49
52
  - lib/solana_studio.rb
53
+ - lib/solana_studio/engine.rb
54
+ - lib/solana_studio/version.rb
50
55
  homepage: https://github.com/amcritchie/solana-studio
51
56
  licenses:
52
57
  - MIT
@@ -70,7 +75,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
70
75
  - !ruby/object:Gem::Version
71
76
  version: '0'
72
77
  requirements: []
73
- rubygems_version: 3.5.11
78
+ rubygems_version: 3.5.22
74
79
  signing_key:
75
80
  specification_version: 4
76
81
  summary: 'Ruby primitives for Solana: JSON-RPC client, Ed25519 keypairs, Borsh serialization,