solana-studio 0.4.7 → 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: 29449e0ba9a251d17b60b4c453754953630d4b41604dd93285889f58f2d7df3b
4
- data.tar.gz: a37bd87c44b6934571e0c3e82959caa519f327c5345fa674a956a9695c71c0eb
3
+ metadata.gz: d9f198cf717ac69a1a570b082b7ebf76a7d0560e1f863d3410bbc4eeeeca71e5
4
+ data.tar.gz: 4bbea890a22fafaba9449bd4aac5483ecfdf3366e694ac9c142c75e898d4435d
5
5
  SHA512:
6
- metadata.gz: 6537687173850b20cfed221277a3cabf714572ccc765782961ae95ab66bcf6f33150183690e32ca1e0ca3881753c2149661e22eaa413c8c606632fde1c0f513f
7
- data.tar.gz: f4426121b94008ccf816394f812b31496e53b421da57e29fef38e256bdd3776a659b4d918ee60cc7660800c5995a98a228db91e5dddc638dedd9ead04732ec88
6
+ metadata.gz: 5becc3ebd6544d5275f1107e1eb9338b47ce5cfeb19e682c9ebdf9bc44e5814f72c3b999f7f4839c1205fd4b9393713dc060edd94264334b999c997c75437a16
7
+ data.tar.gz: 90e26c9214ef297a84e43f910bda3d8d22278703672f1eb564a36c83071c6c90717cae87f836b5ba57844b7f1a4ab5b2793c01ec5d16b7cea1cf820eddb1c3dc
data/CHANGELOG.md CHANGED
@@ -2,6 +2,41 @@
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
+
5
40
  ## v0.4.7 (2026-06-05)
6
41
 
7
42
  ### 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>
@@ -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
@@ -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.7"
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,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solana-studio
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.7
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-08-21 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: ed25519
@@ -36,16 +37,21 @@ files:
36
37
  - CHANGELOG.md
37
38
  - LICENSE
38
39
  - README.md
40
+ - app/assets/javascripts/solana_studio/network_guard.js
41
+ - app/views/solana_studio/modals/_network_mismatch.html.erb
39
42
  - lib/solana-studio.rb
40
43
  - lib/solana/auth_verifier.rb
41
44
  - lib/solana/borsh.rb
42
45
  - lib/solana/client.rb
43
46
  - lib/solana/keypair.rb
47
+ - lib/solana/network.rb
44
48
  - lib/solana/nonce_account.rb
45
49
  - lib/solana/spl_token.rb
46
50
  - lib/solana/system_program.rb
47
51
  - lib/solana/transaction.rb
48
52
  - lib/solana_studio.rb
53
+ - lib/solana_studio/engine.rb
54
+ - lib/solana_studio/version.rb
49
55
  homepage: https://github.com/amcritchie/solana-studio
50
56
  licenses:
51
57
  - MIT
@@ -54,6 +60,7 @@ metadata:
54
60
  source_code_uri: https://github.com/amcritchie/solana-studio
55
61
  bug_tracker_uri: https://github.com/amcritchie/solana-studio/issues
56
62
  changelog_uri: https://github.com/amcritchie/solana-studio/blob/main/CHANGELOG.md
63
+ post_install_message:
57
64
  rdoc_options: []
58
65
  require_paths:
59
66
  - lib
@@ -68,7 +75,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
68
75
  - !ruby/object:Gem::Version
69
76
  version: '0'
70
77
  requirements: []
71
- rubygems_version: 4.0.9
78
+ rubygems_version: 3.5.22
79
+ signing_key:
72
80
  specification_version: 4
73
81
  summary: 'Ruby primitives for Solana: JSON-RPC client, Ed25519 keypairs, Borsh serialization,
74
82
  transaction builder, wallet signature verifier'