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.
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
- ### 🧊 The durable-nonce primitives have two consumers, and one of them is on ice
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 **two** consumers at once, and the commit says so:
682
- *"the reusable core for the signing console's two-browser flow and for making
683
- turf's operator tx flows expiry-immune."*
684
-
685
- **The first of those went on ice on 2026-08-31.** McRitchie Studio's admin
686
- signing console — N wallets signing in separate browsers, anchored on a durable
687
- nonce so a half-signed transaction does not expire between signers — is **frozen
688
- in place: still working, not removed, not deprecated**, and not expected to drive
689
- any further work in this gem. Its full note (why it was frozen, and the one
690
- question that would revive it) lives in the hub, at `docs/SIGNING_CONSOLE_V2.md`.
691
-
692
- **Do not read that as permission to drop these two files.** The second consumer
693
- is the one in production:
694
-
695
- | Primitive | Live use |
696
- |---|---|
697
- | `Solana::SystemProgram.advance_nonce_account` | turf-monster prepends it as **instruction #0** of a durable-nonce vault cosign transaction (`app/services/solana/vault.rb`). Its cosign validator also **allow-lists exactly this one System instruction** — a nonce-anchored entry with any other System instruction is rejected. |
698
- | `Solana::NonceAccount.parse` | turf-monster reads the on-chain nonce account in the same path. |
699
-
700
- So the frozen consumer is the *quieter* one, never the only one. Both primitives
701
- are byte-match tested in `test/system_program_test.rb`, and a change to either
702
- still lands on turf-monster's money path — treat them as `onchain`, not as dead
703
- code left over from a shelved tool.
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