solana-studio 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +120 -22
- data/README.md +239 -26
- data/app/assets/javascripts/solana_studio/wallet_identity.js +524 -0
- data/lib/solana/auth_verifier.rb +12 -0
- data/lib/solana/client.rb +54 -1
- data/lib/solana/compute_budget.rb +73 -0
- data/lib/solana/cosign/builder.rb +159 -0
- data/lib/solana/cosign/completer.rb +217 -0
- data/lib/solana/cosign/expectation.rb +272 -0
- data/lib/solana/cosign.rb +198 -0
- data/lib/solana/ed25519_strict.rb +185 -0
- data/lib/solana/keypair.rb +4 -1
- data/lib/solana/wire_message.rb +223 -0
- data/lib/solana_studio/engine.rb +1 -0
- data/lib/solana_studio/version.rb +1 -1
- data/lib/solana_studio.rb +6 -0
- metadata +10 -2
data/README.md
CHANGED
|
@@ -39,7 +39,10 @@ signature = kp.sign("hello".b)
|
|
|
39
39
|
client = Solana::Client.new(rpc_url: "https://api.devnet.solana.com")
|
|
40
40
|
|
|
41
41
|
client.get_balance("9Fy8P3DvKBh3awt...")
|
|
42
|
-
client.get_latest_blockhash
|
|
42
|
+
client.get_latest_blockhash # hash only, "finalized"
|
|
43
|
+
client.latest_blockhash # hash + last_valid_block_height, "confirmed"
|
|
44
|
+
client.get_block_height # compare against last_valid_block_height
|
|
45
|
+
client.send_transaction(wire_b64, preflight_commitment: "confirmed") # match the fetch
|
|
43
46
|
client.request_airdrop("9Fy8P3DvKBh3awt...", 1_000_000_000)
|
|
44
47
|
client.send_and_confirm(signed_tx_base64)
|
|
45
48
|
```
|
|
@@ -106,6 +109,108 @@ genesis is minted per boot) or an unrecognized cluster name. Treating it as
|
|
|
106
109
|
`:mismatched` refuses to boot every local validator; treating it as `:aligned`
|
|
107
110
|
trusts a chain nobody checked.
|
|
108
111
|
|
|
112
|
+
### Gasless cosigned transactions (`Solana::Cosign`)
|
|
113
|
+
|
|
114
|
+
The pattern every app with a house wallet needs: **the user's wallet signs, the
|
|
115
|
+
house pays the fee**, so users never hold SOL. The server builds the transaction
|
|
116
|
+
with its fee payer in account 0 and an empty slot for each cosigner, the wallet
|
|
117
|
+
signs, and the server proves the returned wire is still what it built before it
|
|
118
|
+
adds its own signature.
|
|
119
|
+
|
|
120
|
+
The gem holds no keys and knows no program. The caller passes the fee payer's
|
|
121
|
+
`Solana::Keypair` in and supplies its own instructions.
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
client = Solana::Client.new
|
|
125
|
+
builder = Solana::Cosign::Builder.new(client: client, fee_payer: house_keypair)
|
|
126
|
+
|
|
127
|
+
prepared = builder.build(
|
|
128
|
+
instructions: [my_program_instruction], # { program_id:, accounts:, data: }
|
|
129
|
+
cosigners: [user_wallet_address],
|
|
130
|
+
compute_unit_price: 50_000, # a priority fee; fee-less txs drop on mainnet
|
|
131
|
+
compute_unit_limit: 200_000
|
|
132
|
+
)
|
|
133
|
+
prepared.wire_base64 # hand this to the wallet
|
|
134
|
+
prepared.wire_base58 # or this: what a walletOps `prepare` returns
|
|
135
|
+
prepared.last_valid_block_height # the deadline: past this height it can never land
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Store `prepared.wire_base64` and `prepared.last_valid_block_height` server-side
|
|
139
|
+
when the signature comes back in a later request, then:
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
expectation = Solana::Cosign::Expectation.from_wire(stored_wire, fee_payer: house_keypair,
|
|
143
|
+
last_valid_block_height: stored_height)
|
|
144
|
+
completer = Solana::Cosign::Completer.new(client: client, fee_payer: house_keypair)
|
|
145
|
+
|
|
146
|
+
result = completer.complete(signed_wire_from_wallet, expectation: expectation,
|
|
147
|
+
before_send: ->(signature) { record.update!(signature: signature) })
|
|
148
|
+
result.signature
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
A wire from `SolanaStudio.walletOps` arrives in base58: pass `encoding: :base58`
|
|
152
|
+
to `#verify!`, `#cosign`, `#complete` or `Expectation.from_wire`. The encoding is
|
|
153
|
+
declared, never guessed — every base58 string is also made of base64 characters.
|
|
154
|
+
|
|
155
|
+
`#complete` runs, in order: judge the wire, check every cosigner signature, fill
|
|
156
|
+
the fee payer's slot (`Transaction.cosign_wire`), check the block height against
|
|
157
|
+
the deadline, simulate, call `before_send`, send, confirm. `#cosign` stops after
|
|
158
|
+
the fee payer signs (no RPC), for a flow whose browser broadcasts. `#verify!`
|
|
159
|
+
only judges.
|
|
160
|
+
|
|
161
|
+
**How the wire is judged.** A wallet re-encodes what it signs, and on mainnet
|
|
162
|
+
Phantom may insert Lighthouse instructions, so bytes are never compared. The
|
|
163
|
+
fee payer must be account 0 and writable; the signer set must be exactly the
|
|
164
|
+
fee payer plus the cosigners; with ComputeBudget and admitted extra programs
|
|
165
|
+
(Lighthouse by default) set aside, the instructions must equal the built ones —
|
|
166
|
+
program, ordered accounts, data and order; ComputeBudget is read and the fee it
|
|
167
|
+
makes the house pay is capped at 10x the builder's own. A System transfer from
|
|
168
|
+
the fee payer, a nonce advance, an extra signer, an altered amount: each is
|
|
169
|
+
refused before the house signs.
|
|
170
|
+
|
|
171
|
+
**Lighthouse is read, not waved through.** Most Lighthouse instructions are
|
|
172
|
+
assertions, which can only make a transaction fail. Two are not. MemoryWrite
|
|
173
|
+
(variant 0) makes a signer fund a "memory" account of any size, and the fee
|
|
174
|
+
payer signs every cosigned wire, so a wire naming it as payer would lock the
|
|
175
|
+
house's SOL. MemoryClose (variant 1) refunds one. The guard therefore admits a
|
|
176
|
+
Lighthouse instruction only when its first data byte is an assertion variant,
|
|
177
|
+
2 through 17. It refuses 0 (`lighthouse_memory_write`), 1
|
|
178
|
+
(`lighthouse_memory_close`), empty data (`lighthouse_empty_data`) and any other
|
|
179
|
+
byte (`lighthouse_unknown_disc`). It does not refuse an assertion for naming
|
|
180
|
+
the fee payer, because Phantom's assertions check the fee payer's own state.
|
|
181
|
+
The deployed program is immutable, so the variants cannot drift; the mainnet
|
|
182
|
+
evidence is in the `Solana::Cosign::LIGHTHOUSE_PROGRAM_ID` comment.
|
|
183
|
+
`extra_programs:` accepts only programs the guard has such a rule for, which
|
|
184
|
+
today is Lighthouse alone; pass `extra_programs: []` to refuse Lighthouse
|
|
185
|
+
entirely.
|
|
186
|
+
|
|
187
|
+
**The error class tells you what you may do next.**
|
|
188
|
+
|
|
189
|
+
| Raised | Sent? | What to do |
|
|
190
|
+
|---|---|---|
|
|
191
|
+
| `WireRejected` | No | Refuse. `#reason` is a stable code; the message is for logs only. |
|
|
192
|
+
| `PreflightRejected` → `BlockhashExpired`, `SimulationFailed` | Provably not | Rebuild freely. `BlockhashExpired` means ask the user to sign again. |
|
|
193
|
+
| `BroadcastFailed` → `BroadcastExpired` | Maybe | Look `#signature` up on chain before rebuilding. |
|
|
194
|
+
| `TransactionFailed` | Landed, failed | The fee was paid. `#err` has the program error. |
|
|
195
|
+
|
|
196
|
+
`BroadcastExpired` is deliberately NOT a `PreflightRejected`: `Solana::Client`
|
|
197
|
+
retries a lost answer by re-posting the same wire, so a send-time "Blockhash not
|
|
198
|
+
found" can follow an attempt that was already forwarded.
|
|
199
|
+
|
|
200
|
+
**Build and send at the same commitment.** The builder fetches at `"confirmed"`
|
|
201
|
+
by default and the completer preflights at the commitment the expectation
|
|
202
|
+
carries. A confirmed blockhash sent with the RPC's default `"finalized"`
|
|
203
|
+
preflight is refused as `Blockhash not found` while still valid.
|
|
204
|
+
|
|
205
|
+
**Wallet-first by default.** Every slot starts empty and the house signs last.
|
|
206
|
+
`presign: true` signs the fee payer's slot at build instead — the order Phantom
|
|
207
|
+
can flag as "could be malicious" — and exists only so server-first flows can
|
|
208
|
+
adopt the builder before they flip.
|
|
209
|
+
|
|
210
|
+
No durable nonce: a nonce transaction is recognized only when
|
|
211
|
+
`advanceNonceAccount` is instruction 0, and Phantom inserts instructions ahead of
|
|
212
|
+
it, so a nonce cannot anchor a wallet-signed transaction.
|
|
213
|
+
|
|
109
214
|
## Rails engine (optional)
|
|
110
215
|
|
|
111
216
|
The gem is Rails-free by default — `railties` is **not** a runtime dependency,
|
|
@@ -664,9 +769,116 @@ the RPC. The redirect leg also needs a host callback page to call
|
|
|
664
769
|
`walletOps.resume(params, { navigate })`; studio-engine's
|
|
665
770
|
`solana_sessions/phantom_callback` does this from 0.73.0.
|
|
666
771
|
|
|
772
|
+
### The wallet as a session identity (`walletIdentity`)
|
|
773
|
+
|
|
774
|
+
`solana_studio/wallet_identity.js` makes the connected wallet an **identity
|
|
775
|
+
source** for studio-engine's session-drift primitive (`window.StudioSession`,
|
|
776
|
+
documented in studio-engine's `docs/SESSION_DRIFT.md`). The engine compares the
|
|
777
|
+
identities a page was rendered for with the identities the browser observes now,
|
|
778
|
+
and it stays web2: it never learns what a wallet is. This file supplies that
|
|
779
|
+
half, and nothing else in the gem depends on it.
|
|
780
|
+
|
|
781
|
+
Load it after `studio/session.js`, then register once per window. Registering
|
|
782
|
+
the same name twice throws, and on a Turbo host the session store and its
|
|
783
|
+
registrations outlive a visit, so a script that runs on every visit must register
|
|
784
|
+
only the first time:
|
|
785
|
+
|
|
786
|
+
```erb
|
|
787
|
+
<%= javascript_include_tag "studio/session" %>
|
|
788
|
+
<%= javascript_include_tag "solana_studio/wallet_identity" %>
|
|
789
|
+
```
|
|
790
|
+
|
|
791
|
+
```js
|
|
792
|
+
var wallet = SolanaStudio.walletIdentity.register({
|
|
793
|
+
getProvider: hostResolver, // your registry's pick, or omit for window.phantom.solana
|
|
794
|
+
trustedConnect: sessionHasAWallet, // see the options table
|
|
795
|
+
rescanOn: ["wallet-provider:registered"] // your registry's "a wallet arrived" event, if it has one
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
wallet.source.current(); // { status, address, providerName }
|
|
799
|
+
wallet.source.subscribe(function (next, previous) { /* repaint the navbar */ });
|
|
800
|
+
document.addEventListener("session:mismatch", function (event) {
|
|
801
|
+
if (event.detail.source === "wallet") { /* an undeclared switch */ }
|
|
802
|
+
});
|
|
803
|
+
```
|
|
804
|
+
|
|
805
|
+
`register` returns `{ source, registration }`. Without a `StudioSession` on the
|
|
806
|
+
page, `registration` is null and the source still runs, so a page can render
|
|
807
|
+
wallet state without the session primitive.
|
|
808
|
+
`SolanaStudio.walletIdentity.create(options)` returns the bare source for a host
|
|
809
|
+
that registers it itself.
|
|
810
|
+
|
|
811
|
+
#### What it reports
|
|
812
|
+
|
|
813
|
+
| `status` | Reported to the session | Meaning |
|
|
814
|
+
|----------|-------------------------|---------|
|
|
815
|
+
| `unknown` | `undefined` (cannot tell) | Provider discovery or a silent connect is still pending |
|
|
816
|
+
| `none` | `null` | No wallet provider appeared before the discovery window closed |
|
|
817
|
+
| `disconnected` | `null` | A provider is present and holds no account for this site |
|
|
818
|
+
| `connected` | the base58 address | This wallet is connected |
|
|
819
|
+
|
|
820
|
+
`unknown` and `none` never collapse: a page that cannot tell yet must not render
|
|
821
|
+
as "you have no wallet". The session sees only the middle column.
|
|
822
|
+
|
|
823
|
+
**A mismatch is a different connected address, and nothing else.** A disconnect,
|
|
824
|
+
a locked extension, or a page with no wallet is not a switch to someone else, so
|
|
825
|
+
the source's `equals` treats an observed `null` as agreeing with the bound
|
|
826
|
+
address. The page still sees the disconnect through `current()`. Pass
|
|
827
|
+
`disconnectIsMismatch: true` to count it.
|
|
828
|
+
|
|
829
|
+
#### How it reads the wallet
|
|
830
|
+
|
|
831
|
+
- **Two provider shapes.** An injected provider (Phantom's
|
|
832
|
+
`window.phantom.solana`, or a host adapter normalized to it): live
|
|
833
|
+
`publicKey`, plus `accountChanged`, `connect` and `disconnect`. A raw Wallet
|
|
834
|
+
Standard wallet: live `accounts` and `standard:events` `change`.
|
|
835
|
+
- **Live, never cached.** Every read goes back to the wallet. On a Wallet
|
|
836
|
+
Standard `change` it reads `wallet.accounts`, not the event's copy, because
|
|
837
|
+
an adapter that cached its account once reported the previous account forever
|
|
838
|
+
after a switch the wallet never announced.
|
|
839
|
+
- **Events are best-effort.** `focus`, `visibilitychange` to visible and
|
|
840
|
+
`pageshow` re-resolve the provider and re-read it. That catches a switch made
|
|
841
|
+
while the tab was hidden.
|
|
842
|
+
- **One binding per provider object**, however often the page reconciles. A
|
|
843
|
+
provider replaced by a later one (a Wallet Standard registration superseding
|
|
844
|
+
the injected object) is detached, and its events are ignored.
|
|
845
|
+
|
|
846
|
+
#### Options
|
|
847
|
+
|
|
848
|
+
| Option | Default | |
|
|
849
|
+
|--------|---------|---|
|
|
850
|
+
| `getProvider` | `window.phantom.solana \|\| window.solana` | The provider to watch now, or null. Called on every reconcile. |
|
|
851
|
+
| `trustedConnect` | `false` | When the wallet holds no account, ask it silently (`onlyIfTrusted` / `{ silent: true }`) before believing `disconnected`. **Off by default** because a silent connect can pop Phantom's unlock prompt; turn it on only where a wallet session is already expected. |
|
|
852
|
+
| `discoveryMs`, `discoveryIntervalMs` | `3000`, `100` | How long "no provider yet" stays `unknown` while a late injection is polled for. `0` reads `none` at once. |
|
|
853
|
+
| `rescanOn` | `[]` | Extra `window` events that re-resolve the provider. |
|
|
854
|
+
| `name` | `"wallet"` | The identity source name, and the key the server binds under. |
|
|
855
|
+
| `bound` | engine default | Passed through to the engine: `bound(snapshot)` returns the bound identity. |
|
|
856
|
+
| `disconnectIsMismatch` | `false` | See above. |
|
|
857
|
+
| `session` | `window.StudioSession` | The store `register` uses. |
|
|
858
|
+
|
|
859
|
+
#### What the host owes
|
|
860
|
+
|
|
861
|
+
- **The server half.** Bind the session under the same name:
|
|
862
|
+
`studio_session_identities` returns `{ wallet: <the wallet this session signed
|
|
863
|
+
in with> }`. Bind nothing for a session that has no wallet of its own (a guest,
|
|
864
|
+
or a managed wallet the browser never holds). An unbound source still reports
|
|
865
|
+
what it sees and never mismatches.
|
|
866
|
+
- **Holds are per SOURCE, not per address.** `StudioSession.expectChange("wallet")`
|
|
867
|
+
marks every switch expected until it is released. A flow that walks through
|
|
868
|
+
specific wallets, such as a multi-signer ceremony, must still check the observed
|
|
869
|
+
address against the wallets it declared, or a switch to any other wallet goes
|
|
870
|
+
quiet for the length of the hold.
|
|
871
|
+
- **The UI and the re-auth.** The switch card, the navbar, and signing in again
|
|
872
|
+
with the new wallet (then `StudioSession.refresh()`) are the host's.
|
|
873
|
+
|
|
874
|
+
It never signs, sends, writes storage or opens a modal.
|
|
875
|
+
|
|
667
876
|
## Dependencies
|
|
668
877
|
|
|
669
|
-
- `ed25519` (~> 1.3) — Ed25519 signing
|
|
878
|
+
- `ed25519` (~> 1.3) — Ed25519 signing and the verification equation. It
|
|
879
|
+
does not vet the public key, so every verify in this gem
|
|
880
|
+
(`AuthVerifier.verify!`, `WireMessage#signature_valid?`) runs
|
|
881
|
+
`Solana::Ed25519Strict` first.
|
|
670
882
|
- Ruby stdlib only (net/http, json, digest, securerandom)
|
|
671
883
|
- **No Rails dependency.** `railties` is a development dependency only; the
|
|
672
884
|
engine loads solely when the host has already loaded Rails.
|
|
@@ -675,32 +887,33 @@ the RPC. The redirect leg also needs a host callback page to call
|
|
|
675
887
|
|
|
676
888
|
See [RUNBOOK.md](./RUNBOOK.md) for troubleshooting and local test commands.
|
|
677
889
|
|
|
678
|
-
###
|
|
890
|
+
### The durable-nonce primitives: one consumer left, and it is a dead route
|
|
679
891
|
|
|
680
892
|
`Solana::SystemProgram` and `Solana::NonceAccount` landed together in **v0.4.6
|
|
681
|
-
(2026-06-02, `11ec512`)** for
|
|
682
|
-
|
|
683
|
-
turf
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
signing console
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
893
|
+
(2026-06-02, `11ec512`)** for two consumers: McRitchie Studio's signing console
|
|
894
|
+
and turf-monster's operator transactions. **Neither is a live flow now.**
|
|
895
|
+
Re-measured 2026-09-16 against turf-monster `origin/accepted` (`61185cdd`); this
|
|
896
|
+
note used to say otherwise.
|
|
897
|
+
|
|
898
|
+
- **The signing console is gone.** It was frozen on 2026-08-31 and deleted on
|
|
899
|
+
2026-09-04 (the hub's `retire-signing-console` task), with its doc.
|
|
900
|
+
- **turf-monster reaches the nonce from one dead route.**
|
|
901
|
+
`Solana::Vault#durable_nonce_config` has one caller,
|
|
902
|
+
`#build_create_contest(admin_signs: true)`, reached only from
|
|
903
|
+
`ContestsController#prepare_onchain_contest`. Nothing under `app/views` or
|
|
904
|
+
`app/javascript` calls that route; only `e2e/rpc-mock.js` names it.
|
|
905
|
+
- **No cosign guard admits a nonce advance.** turf-monster's cosign guards
|
|
906
|
+
refuse every System instruction, `advanceNonceAccount` included, and so does
|
|
907
|
+
`Solana::Cosign`.
|
|
908
|
+
- **A nonce cannot anchor a wallet-signed transaction.** A nonce transaction is
|
|
909
|
+
recognized only when `advanceNonceAccount` is instruction 0, and Phantom
|
|
910
|
+
inserts Lighthouse instructions ahead of it (turf-monster mainnet incident,
|
|
911
|
+
2026-06-11). That is why Mr. McRitchie dropped nonce support from the
|
|
912
|
+
primitives extraction on 2026-09-16.
|
|
913
|
+
|
|
914
|
+
The files stay, byte-match tested in `test/system_program_test.rb`, until
|
|
915
|
+
turf-monster decides the dead route's fate. Removing them first would break that
|
|
916
|
+
route's build step.
|
|
704
917
|
|
|
705
918
|
### The browser lane
|
|
706
919
|
|