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 +4 -4
- data/CHANGELOG.md +35 -0
- data/README.md +145 -2
- data/app/assets/javascripts/solana_studio/network_guard.js +299 -0
- data/app/views/solana_studio/modals/_network_mismatch.html.erb +83 -0
- data/lib/solana/network.rb +179 -0
- data/lib/solana_studio/engine.rb +36 -0
- data/lib/solana_studio/version.rb +20 -0
- data/lib/solana_studio.rb +10 -3
- metadata +11 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d9f198cf717ac69a1a570b082b7ebf76a7d0560e1f863d3410bbc4eeeeca71e5
|
|
4
|
+
data.tar.gz: 4bbea890a22fafaba9449bd4aac5483ecfdf3366e694ac9c142c75e898d4435d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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",
|
|
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 [
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
+
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:
|
|
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:
|
|
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'
|