studio-engine 0.66.1 → 0.66.2
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/Gemfile +25 -0
- data/README.md +22 -24
- data/app/views/solana_sessions/phantom_callback.html.erb +6 -5
- data/app/views/style/_modals.html.erb +41 -17
- data/app/views/style/modals/_wallet_connect.html.erb +11 -5
- data/lib/studio/version.rb +1 -1
- metadata +1 -5
- data/app/views/studio/modals/_wallet_connect.html.erb +0 -330
- data/app/views/studio/modals/_web3_step_up.html.erb +0 -292
- data/app/views/studio/solana/_deeplink_assets.html.erb +0 -37
- data/app/views/studio/solana/_phantom_deeplink.html.erb +0 -167
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 575e62e3700969f869d9e6b079c13a142c164cd655bf95d22be6eea95817e2c8
|
|
4
|
+
data.tar.gz: c8ea9ca891cf76ebe2a1f4dbdf1e343546cca37917851feb7151fe59517b3a06
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7387284b53eb7df98f34204d84bff4e10525c6fc1ff81a9e4ef01ae2418d5a689f523984ee94c128746ac260cc3af9c258c5fd15f81486f506d77e182a8dfb7c
|
|
7
|
+
data.tar.gz: a1d93bcf649e7494cac13bab83cc76051e1e86c92f942558e2740f97ee98934ac77c2ebae989cee815c61d50f8e72c305f55f0cd714ebe9b8c4cfa8dd2bec7de
|
data/Gemfile
CHANGED
|
@@ -30,3 +30,28 @@ group :development, :test do
|
|
|
30
30
|
# test fixture. Not a runtime dependency — consuming apps bring their own server.
|
|
31
31
|
gem "puma", ">= 6.0"
|
|
32
32
|
end
|
|
33
|
+
|
|
34
|
+
# solana-studio backs the style guide's THREE web3 specimens (Connect wallet and
|
|
35
|
+
# the two Sign Wallet cards). Those partials used to live here, in
|
|
36
|
+
# app/views/studio/modals; the two-template split moved them to the gem —
|
|
37
|
+
# BASE is studio-engine + mcritchie-studio, WEB3 ADD is solana-studio +
|
|
38
|
+
# turf-monster — because, as the call was made, "the real op sec vector is
|
|
39
|
+
# managing sessions safely. Anything wallet based should be in the app."
|
|
40
|
+
#
|
|
41
|
+
# DEVELOPMENT AND TEST ONLY, and deliberately NOT a gemspec runtime dependency.
|
|
42
|
+
# Declaring it there would push a Solana stack onto every BASE consumer
|
|
43
|
+
# (acquisition-studio, mcritchie-industries, moms-app mount this engine and
|
|
44
|
+
# bundle no solana-studio), which is precisely the coupling the split removed.
|
|
45
|
+
# It is here so the DUMMY app can resolve the gem's partials and the style guide
|
|
46
|
+
# renders the real shipped cards rather than a fork of them.
|
|
47
|
+
#
|
|
48
|
+
# There is no dependency cycle: solana-studio's GEMSPEC declares only ed25519.
|
|
49
|
+
# It lists studio-engine in its own Gemfile, dev-only, which Bundler never reads
|
|
50
|
+
# when solana-studio is consumed as a gem.
|
|
51
|
+
#
|
|
52
|
+
# The style guide self-gates on the partial RESOLVING (style/_modals.html.erb),
|
|
53
|
+
# so an app without this gem renders /admin/style with the web3 cards badged
|
|
54
|
+
# rather than raising a missing-template error.
|
|
55
|
+
group :development, :test do
|
|
56
|
+
gem "solana-studio", ">= 0.5.3"
|
|
57
|
+
end
|
data/README.md
CHANGED
|
@@ -238,30 +238,28 @@ the block:
|
|
|
238
238
|
<% end %>
|
|
239
239
|
```
|
|
240
240
|
|
|
241
|
-
**
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
a compiled Rails partial, so a block-shaped slot yields the captured page body
|
|
264
|
-
into the card whenever no block is passed.
|
|
241
|
+
**Wallet modals moved to `solana-studio`.** The Connect Wallet picker, the Web3
|
|
242
|
+
step-up card and the Phantom deep link used to ship here as
|
|
243
|
+
`studio/modals/wallet_connect`, `studio/modals/web3_step_up` and
|
|
244
|
+
`studio/solana/phantom_deeplink`. They now live in the **solana-studio** gem as
|
|
245
|
+
`solana_studio/modals/wallet_connect`, `solana_studio/modals/web3_step_up` and
|
|
246
|
+
`solana_studio/phantom_deeplink`; render them from those paths and read that
|
|
247
|
+
gem's README for their locals and hooks.
|
|
248
|
+
|
|
249
|
+
This is the two-template split: **BASE** is studio-engine + mcritchie-studio,
|
|
250
|
+
**WEB3 ADD** is solana-studio + turf-monster. The engine has no business
|
|
251
|
+
shipping wallet UI — as the call was made, *"the real op sec vector is managing
|
|
252
|
+
sessions safely. Anything wallet based should be in the app."*
|
|
253
|
+
|
|
254
|
+
What the engine **keeps** is the SESSION half of Solana sign-in:
|
|
255
|
+
`SolanaSessionsController`, `Solana::SessionAuth`, the `/auth/solana/nonce` and
|
|
256
|
+
`/auth/solana/verify` routes, the `solana_sessions/phantom_callback` view, and
|
|
257
|
+
`Studio.wallet_sign_in_statement` — the single source for the signed statement,
|
|
258
|
+
which the gem's deep link reads so the two cannot drift.
|
|
259
|
+
|
|
260
|
+
It also keeps two blocks the gem renders **by name** across the gem boundary:
|
|
261
|
+
`studio/modals/blocks/wallet_brand_sprite` and `studio/modals/blocks/card_header`.
|
|
262
|
+
Renaming either is a cross-repo change.
|
|
265
263
|
|
|
266
264
|
**Page-scoped hosts — `studio/modals/scoped_host`.** When a page must bring its
|
|
267
265
|
own modals (because not every consuming app renders a shared host, and the ones
|
|
@@ -313,11 +313,12 @@
|
|
|
313
313
|
dbg('Verify result', JSON.stringify(result));
|
|
314
314
|
if (result.success) {
|
|
315
315
|
cleanup();
|
|
316
|
-
// Guarded, matching studio
|
|
317
|
-
// that
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
316
|
+
// Guarded, matching solana-studio's wallet_connect picker (the partial
|
|
317
|
+
// that used to sit two files over, before the two-template split moved
|
|
318
|
+
// the wallet UI to the gem). An app that does not define this hook would
|
|
319
|
+
// otherwise throw HERE — after a successful verify, with the session
|
|
320
|
+
// already set — so the user is signed in and the page dies before
|
|
321
|
+
// redirecting. The engine cannot require a hook it does not ship.
|
|
321
322
|
if (typeof window.handleSolanaVerifySuccess === 'function') window.handleSolanaVerifySuccess(result);
|
|
322
323
|
dbg('OK', 'Login success! Redirecting...');
|
|
323
324
|
window.location.href = result.redirect || '/';
|
|
@@ -18,9 +18,10 @@
|
|
|
18
18
|
app-supplied-policy age-gate DOB primitive, gated by Studio.feature?(:age_gate),
|
|
19
19
|
plus the app-specific entry-tokens purchase flow documented via engine chrome),
|
|
20
20
|
Profile (crop / upload, opened
|
|
21
|
-
through studio/cropper_assets), Web3 (wallet-connect
|
|
22
|
-
|
|
23
|
-
gated by Studio.feature?(:web3) —
|
|
21
|
+
through studio/cropper_assets), Web3 (wallet-connect and web3-step-up now
|
|
22
|
+
rendered from the solana-studio gem / on-chain-tx / deposit / the generic
|
|
23
|
+
entry-confirmed celebration, gated by Studio.feature?(:web3) —
|
|
24
|
+
disabled-but-present-yet-openable when off),
|
|
24
25
|
System and status (the reusable card blocks), Templates (the copy-from
|
|
25
26
|
archetypes), and Rewards (level-up + the entry-confirmed seeds bar + the
|
|
26
27
|
Free Entry Earned reward, gated by :leveling).
|
|
@@ -33,6 +34,25 @@
|
|
|
33
34
|
leveling_on = Studio.feature?(:leveling)
|
|
34
35
|
age_gate_on = Studio.feature?(:age_gate)
|
|
35
36
|
|
|
37
|
+
# THE WEB3 SPECIMENS NOW COME FROM A GEM THIS ENGINE DOES NOT DEPEND ON.
|
|
38
|
+
# wallet_connect and web3_step_up used to live in app/views/studio/modals and
|
|
39
|
+
# were rendered here unconditionally. The two-template split moved them to
|
|
40
|
+
# solana-studio: BASE is studio-engine + mcritchie-studio, WEB3 ADD is
|
|
41
|
+
# solana-studio + turf-monster. The engine keeps the SESSION half (the
|
|
42
|
+
# /auth/solana routes, Solana::SessionAuth, the phantom callback) and ships no
|
|
43
|
+
# wallet UI at all.
|
|
44
|
+
#
|
|
45
|
+
# So resolution is a RUNTIME question here, not a build-time one. Three of the
|
|
46
|
+
# six apps mounting this engine (acquisition-studio, mcritchie-industries,
|
|
47
|
+
# moms-app) bundle no solana-studio, and an unconditional render would turn
|
|
48
|
+
# their /admin/style into a missing-template 500. The gem is a development and
|
|
49
|
+
# test dependency of this engine precisely so the guide can show the REAL
|
|
50
|
+
# cards; where it is absent the specimens stay listed but unopenable.
|
|
51
|
+
#
|
|
52
|
+
# Same three-term lookup_context.exists? the modal host uses for host_extras
|
|
53
|
+
# (studio/modals/_host.html.erb): name, prefixes, partial.
|
|
54
|
+
web3_gem = lookup_context.exists?("wallet_connect", ["solana_studio/modals"], true)
|
|
55
|
+
|
|
36
56
|
# Auth method-toggle defaults — mirror the app's Studio.auth_method? config;
|
|
37
57
|
# Solana Wallet also needs the web3 capability, so it defaults OFF on an app
|
|
38
58
|
# (like McRitchie Studio) that ships web3 off.
|
|
@@ -432,17 +452,21 @@
|
|
|
432
452
|
<template x-if="$store.dsModals.current().id === 'cosign-rejected'">
|
|
433
453
|
<div><%= render "style/modals/cosign_rejected" %></div>
|
|
434
454
|
</template>
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
455
|
+
<%# Both web3 sign-in cards render from SOLANA-STUDIO, and both are the
|
|
456
|
+
REAL shipped partials rather than style-guide forks — the same files
|
|
457
|
+
turf-monster renders in production, so what is reviewed here IS the
|
|
458
|
+
card that ships. They left this engine with the two-template split.
|
|
459
|
+
Registered only when the gem resolves (see web3_gem above): an app
|
|
460
|
+
that bundles no solana-studio still lists these specimens, it just
|
|
461
|
+
cannot open them. %>
|
|
462
|
+
<% if web3_gem %>
|
|
463
|
+
<template x-if="$store.dsModals.current().id === 'wallet-connect'">
|
|
464
|
+
<div><%= render "style/modals/wallet_connect" %></div>
|
|
465
|
+
</template>
|
|
466
|
+
<template x-if="$store.dsModals.current().id === 'web3-step-up'">
|
|
467
|
+
<div><%= render "solana_studio/modals/web3_step_up", modal_store: "dsModals" %></div>
|
|
468
|
+
</template>
|
|
469
|
+
<% end %>
|
|
446
470
|
<template x-if="$store.dsModals.current().id === 'onchain-tx'">
|
|
447
471
|
<div><%= render "style/modals/onchain_tx" %></div>
|
|
448
472
|
</template>
|
|
@@ -1116,7 +1140,7 @@
|
|
|
1116
1140
|
<%# 1. Connect wallet — the walk's entry; picking a wallet swaps to Processing. %>
|
|
1117
1141
|
<%= render layout: "style/modal_specimen", locals: {
|
|
1118
1142
|
label: "Connect wallet",
|
|
1119
|
-
reference: %(the Web3 "Connect wallet" picker (studio
|
|
1143
|
+
reference: %(the Web3 "Connect wallet" picker (solana-studio solana_studio/modals/_wallet_connect, configured by style/modals/_wallet_connect) — the walk's entry: picking a wallet runs a brief connecting state then swaps to the on-chain Processing modal. Open with $store.dsModals.open('wallet-connect')),
|
|
1120
1144
|
open_expr: "$store.dsModals.open('wallet-connect')",
|
|
1121
1145
|
glow_when: ds_glow.call("wallet-connect"),
|
|
1122
1146
|
disabled: !web3_on, openable: true } do %>
|
|
@@ -1182,7 +1206,7 @@
|
|
|
1182
1206
|
wallet, so no user can ever meet both. %>
|
|
1183
1207
|
<%= render layout: "style/modal_specimen", locals: {
|
|
1184
1208
|
label: "Sign Wallet",
|
|
1185
|
-
reference: %(the Web3 "Sign Wallet" card (studio/modals/_web3_step_up — the REAL shared partial, not a specimen copy) — shown to an account that holds a self-custody wallet but authenticated this session with a web2 credential. One wallet row, glowing, because it is the only thing to press. Open with $store.dsModals.open('web3-step-up', { provider: 'phantom', providerLabel: 'Phantom', walletHint: '7xKp…JZ2Q' })),
|
|
1209
|
+
reference: %(the Web3 "Sign Wallet" card (solana-studio solana_studio/modals/_web3_step_up — the REAL shared partial, not a specimen copy) — shown to an account that holds a self-custody wallet but authenticated this session with a web2 credential. One wallet row, glowing, because it is the only thing to press. Open with $store.dsModals.open('web3-step-up', { provider: 'phantom', providerLabel: 'Phantom', walletHint: '7xKp…JZ2Q' })),
|
|
1186
1210
|
open_expr: "$store.dsModals.open('web3-step-up', { provider: 'phantom', providerLabel: 'Phantom', walletHint: '7xKp…JZ2Q' })",
|
|
1187
1211
|
glow_when: ds_glow.call("web3-step-up", provider: true),
|
|
1188
1212
|
disabled: !web3_on, openable: true } do %>
|
|
@@ -1205,7 +1229,7 @@
|
|
|
1205
1229
|
|
|
1206
1230
|
<%= render layout: "style/modal_specimen", locals: {
|
|
1207
1231
|
label: "Sign Wallet (no remembered brand)",
|
|
1208
|
-
reference: %(the Web3 "Sign Wallet" card with NO remembered brand (studio/modals/_web3_step_up) — every wallet linked before a host recorded brands lands here, so it is a live population and not a defensive branch. The row falls back to the picker rather than dead-ending. Open with $store.dsModals.open('web3-step-up', {})),
|
|
1232
|
+
reference: %(the Web3 "Sign Wallet" card with NO remembered brand (solana-studio solana_studio/modals/_web3_step_up) — every wallet linked before a host recorded brands lands here, so it is a live population and not a defensive branch. The row falls back to the picker rather than dead-ending. Open with $store.dsModals.open('web3-step-up', {})),
|
|
1209
1233
|
open_expr: "$store.dsModals.open('web3-step-up', {})",
|
|
1210
1234
|
glow_when: ds_glow.call("web3-step-up", provider: false),
|
|
1211
1235
|
disabled: !web3_on, openable: true } do %>
|
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
<%#
|
|
2
2
|
"Connect Wallet" specimen for the living style guide.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
the apps render, so a drift between the specimen and production is no longer
|
|
4
|
+
This is a THIN CONFIGURATION of the real picker rather than a parallel port of
|
|
5
|
+
it. That is the point: the card on /admin/style demonstrates the SAME code the
|
|
6
|
+
apps render, so a drift between the specimen and production is no longer
|
|
8
7
|
possible. Before this, the picker existed three times — turf-monster's,
|
|
9
8
|
mcritchie-studio's, and this one — and only this one was ever looked at.
|
|
10
9
|
|
|
10
|
+
THE PARTIAL IS NO LONGER THIS ENGINE'S. It moved to solana-studio
|
|
11
|
+
(solana_studio/modals/_wallet_connect) with the two-template split: BASE is
|
|
12
|
+
studio-engine + mcritchie-studio, WEB3 ADD is solana-studio + turf-monster.
|
|
13
|
+
The engine keeps the session half and ships no wallet UI. Nothing renders this
|
|
14
|
+
file unless the gem resolved — style/_modals.html.erb gates the registration
|
|
15
|
+
on web3_gem — so an app with no solana-studio never reaches this render.
|
|
16
|
+
|
|
11
17
|
What the configuration supplies, all of it demo scaffolding:
|
|
12
18
|
store — the guide's page-scoped host ($store.dsModals), not the app
|
|
13
19
|
host, so the specimen never collides with a consuming app's
|
|
@@ -75,7 +81,7 @@
|
|
|
75
81
|
}
|
|
76
82
|
JS
|
|
77
83
|
%>
|
|
78
|
-
<%= render "
|
|
84
|
+
<%= render "solana_studio/modals/wallet_connect",
|
|
79
85
|
store: "dsModals",
|
|
80
86
|
connect_fn: "dsWalletConnectDemo",
|
|
81
87
|
slot: "style/modals/wallet_connect_slot",
|
data/lib/studio/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.66.
|
|
4
|
+
version: 0.66.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
@@ -445,8 +445,6 @@ files:
|
|
|
445
445
|
- app/views/studio/modals/_load_convention.html.erb
|
|
446
446
|
- app/views/studio/modals/_saving.html.erb
|
|
447
447
|
- app/views/studio/modals/_scoped_host.html.erb
|
|
448
|
-
- app/views/studio/modals/_wallet_connect.html.erb
|
|
449
|
-
- app/views/studio/modals/_web3_step_up.html.erb
|
|
450
448
|
- app/views/studio/modals/auth/_resend_footer.html.erb
|
|
451
449
|
- app/views/studio/modals/blocks/_age_gate.html.erb
|
|
452
450
|
- app/views/studio/modals/blocks/_birthday.html.erb
|
|
@@ -499,8 +497,6 @@ files:
|
|
|
499
497
|
- app/views/studio/profiles/_save_controls.html.erb
|
|
500
498
|
- app/views/studio/profiles/edit.html.erb
|
|
501
499
|
- app/views/studio/profiles/show.html.erb
|
|
502
|
-
- app/views/studio/solana/_deeplink_assets.html.erb
|
|
503
|
-
- app/views/studio/solana/_phantom_deeplink.html.erb
|
|
504
500
|
- app/views/style/_modal_specimen.html.erb
|
|
505
501
|
- app/views/style/_modals.html.erb
|
|
506
502
|
- app/views/style/_specimen.html.erb
|
|
@@ -1,330 +0,0 @@
|
|
|
1
|
-
<%#
|
|
2
|
-
"Connect Wallet" picker — the reown-style wallet chooser, OWNED BY THE ENGINE.
|
|
3
|
-
Promoted out of style/modals/_wallet_connect (where only the living style guide
|
|
4
|
-
could render it) so the apps stop each keeping their own copy: before this the
|
|
5
|
-
same screen existed THREE times — turf-monster 226 lines, mcritchie-studio 107,
|
|
6
|
-
and the guide's 176 — sharing no code and drifting apart. The hub's copy still
|
|
7
|
-
pointed at app-served PNGs the engine's brand sprite replaced in 0.20.
|
|
8
|
-
|
|
9
|
-
Mount it inside the modal host's <template x-if="id==='wallet-connect'">, so
|
|
10
|
-
this MUST have a SINGLE ROOT — everything lives inside the outer <div>.
|
|
11
|
-
|
|
12
|
-
Props (set by whoever opens the modal, read off the store entry):
|
|
13
|
-
linkMode — true when this is an account-LINKING flow (already logged in)
|
|
14
|
-
currentUserId — bound into the SIWS message when linking
|
|
15
|
-
returnUrl — where to go after a successful connect
|
|
16
|
-
|
|
17
|
-
Locals:
|
|
18
|
-
store — Alpine store name backing the modal. Default "modals".
|
|
19
|
-
connect_fn — name of the window function that connects AND verifies a
|
|
20
|
-
wallet, called as window[connect_fn](name, opts) and expected
|
|
21
|
-
to resolve { success: true, redirect } or { success: false,
|
|
22
|
-
error }. Default "solanaConnectAndVerify".
|
|
23
|
-
title — card heading. Default "Connect Wallet".
|
|
24
|
-
extra_data — EXTRA x-data members for this component, as a JS fragment with
|
|
25
|
-
NO surrounding braces, merged after the built-ins. This is how
|
|
26
|
-
an app adds its own state (and the hooks below) without forking
|
|
27
|
-
the picker. Default "".
|
|
28
|
-
|
|
29
|
-
SHAPED LIKE style/_modal_specimen's card_data but NOT handled
|
|
30
|
-
the same way, and the difference is deliberate. That file marks
|
|
31
|
-
open_expr and glow_when html_safe and leaves card_data escaped
|
|
32
|
-
(:42-44), so its fragment reaches the attribute as entities.
|
|
33
|
-
Both work — a browser decodes entities inside an attribute —
|
|
34
|
-
but only one is readable in View Source, and a reader comparing
|
|
35
|
-
the two seams deserves to be told they diverge rather than left
|
|
36
|
-
to assume a typo. If they are ever reconciled, reconcile them
|
|
37
|
-
in one pass; do not quietly copy this decision back.
|
|
38
|
-
slot — OPTIONAL partial path rendered between the heading and the
|
|
39
|
-
wallet rows: the place for an app's legal-age attestation or
|
|
40
|
-
any other pre-connect consent. It renders INSIDE this
|
|
41
|
-
component's root, so it can bind straight to the members
|
|
42
|
-
extra_data contributes.
|
|
43
|
-
slot_locals — locals for that partial. Default {}.
|
|
44
|
-
|
|
45
|
-
A NAMED LOCAL AND NOT A BLOCK, and this is load-bearing. `block_given?` is
|
|
46
|
-
ALWAYS TRUE inside a compiled Rails partial — PartialRenderer hands the
|
|
47
|
-
template a block either way — so a bare `yield if block_given?` falls through
|
|
48
|
-
to view_flow[:layout] and prints THE WHOLE CAPTURED PAGE BODY inside the card.
|
|
49
|
-
Both consuming apps mount the modal host in application.html.erb, which is
|
|
50
|
-
exactly when that flow is populated, and the hub's planned adoption is a bare
|
|
51
|
-
render with no block. Caught in review of this partial's own first version.
|
|
52
|
-
blocks/_card_header documents the same hazard and blocks/_entry_confirmed made
|
|
53
|
-
the same choice (above_seeds) for the same reason.
|
|
54
|
-
|
|
55
|
-
HOOKS — optional methods an app defines in extra_data. Each is called only if
|
|
56
|
-
it exists, so an app that needs none passes no extra_data at all. They are
|
|
57
|
-
METHODS rather than string-expression locals on purpose: an Alpine expression
|
|
58
|
-
threaded through an ERB local has to survive a double-quoted HTML attribute,
|
|
59
|
-
and an escaped handler there renders working-looking HTML that passes every
|
|
60
|
-
markup assertion. Methods live inside the x-data where they are just code.
|
|
61
|
-
|
|
62
|
-
onInit() extra init, run BEFORE the first wallet refresh
|
|
63
|
-
canPick() return falsy to ABORT a pick or a deep link (a consent
|
|
64
|
-
gate); absent means always allowed
|
|
65
|
-
verifyArgs() object merged into the connect options (extra fields
|
|
66
|
-
the app's verify endpoint needs)
|
|
67
|
-
onConnected(result) what to do on success; DEFAULT navigates to
|
|
68
|
-
props.returnUrl || result.redirect || '/'. Override it
|
|
69
|
-
for a demo or an in-page continuation — and note the
|
|
70
|
-
override OWNS the reset: pick() leaves `connecting` and
|
|
71
|
-
`picking` set, because the default navigates away and
|
|
72
|
-
never comes back. An override that stays on the page
|
|
73
|
-
must clear both or the rows stay disabled.
|
|
74
|
-
onDeepLink() REPLACES the Phantom deep-link action. Use it to stash
|
|
75
|
-
state that must survive the round trip out of the
|
|
76
|
-
browser, or to do something else entirely. The default
|
|
77
|
-
calls window.startPhantomDeepLink(linkMode, userId).
|
|
78
|
-
onBack() the Back button; DEFAULT closes the modal
|
|
79
|
-
%>
|
|
80
|
-
<%
|
|
81
|
-
store = local_assigns.fetch(:store, "modals")
|
|
82
|
-
connect_fn = local_assigns.fetch(:connect_fn, "solanaConnectAndVerify")
|
|
83
|
-
title = local_assigns.fetch(:title, "Connect Wallet")
|
|
84
|
-
# html_safe, and deliberately: extra_data is a DEVELOPER-AUTHORED JavaScript
|
|
85
|
-
# fragment, never user input. Without it ActionView escapes the fragment on
|
|
86
|
-
# its way into the x-data attribute and every single quote becomes ' —
|
|
87
|
-
# which still PARSES (the browser decodes entities in an attribute), so the
|
|
88
|
-
# page works and only the source reads wrong. That is exactly the failure a
|
|
89
|
-
# markup assertion cannot see. On style/_modal_specimen: see the header. It
|
|
90
|
-
# marks open_expr and glow_when safe but leaves card_data ESCAPED, so the two
|
|
91
|
-
# seams DIVERGE — deliberately, and not because one of them has a typo.
|
|
92
|
-
slot = local_assigns[:slot].presence
|
|
93
|
-
slot_locals = local_assigns.fetch(:slot_locals, {})
|
|
94
|
-
extra_data = local_assigns.fetch(:extra_data, "").to_s.strip
|
|
95
|
-
# Built here and marked safe ONCE, rather than interpolated at the call site.
|
|
96
|
-
# Two traps sit on this line, both of which render a working-looking page:
|
|
97
|
-
# 1. Marking extra_data safe is NOT enough. Interpolating a SafeBuffer into
|
|
98
|
-
# a plain string literal yields a PLAIN String, the safety is lost, and
|
|
99
|
-
# every quote in the fragment escapes to an entity. It still PARSES (a
|
|
100
|
-
# browser decodes entities in an attribute), so only the source reads
|
|
101
|
-
# wrong. Concatenate, then mark the result.
|
|
102
|
-
# 2. The fragment is developer-authored JavaScript, never user input. That
|
|
103
|
-
# is what makes html_safe correct here rather than a hole.
|
|
104
|
-
extra_fragment = extra_data.present? ? (",\n " + extra_data).html_safe : "".html_safe
|
|
105
|
-
%>
|
|
106
|
-
<div x-data="{
|
|
107
|
-
get props() { var c = $store.<%= store %>.current(); return (c && c.props) || {}; },
|
|
108
|
-
wallets: [],
|
|
109
|
-
installs: [
|
|
110
|
-
{ name: 'Phantom', url: 'https://phantom.app/download' },
|
|
111
|
-
{ name: 'Solflare', url: 'https://solflare.com/download' },
|
|
112
|
-
{ name: 'Backpack', url: 'https://backpack.app/downloads' }
|
|
113
|
-
],
|
|
114
|
-
connecting: false,
|
|
115
|
-
picking: '',
|
|
116
|
-
error: '',
|
|
117
|
-
init() {
|
|
118
|
-
if (typeof this.onInit === 'function') this.onInit();
|
|
119
|
-
this.refresh();
|
|
120
|
-
// Wallet Standard registration can land a tick after the module
|
|
121
|
-
// loads — re-read once so a just-registered wallet still appears.
|
|
122
|
-
var self = this;
|
|
123
|
-
setTimeout(function() { if (!self.connecting) self.refresh(); }, 300);
|
|
124
|
-
},
|
|
125
|
-
refresh() {
|
|
126
|
-
this.wallets = (window.walletProvider && window.walletProvider.available && window.walletProvider.available()) || [];
|
|
127
|
-
},
|
|
128
|
-
get isMobile() { return !!(window.walletProvider && window.walletProvider.isMobile && window.walletProvider.isMobile()); },
|
|
129
|
-
hasWallet(name) {
|
|
130
|
-
var n = ('' + name).toLowerCase();
|
|
131
|
-
return (this.wallets || []).some(function(w) { return w.name && w.name.toLowerCase() === n; });
|
|
132
|
-
},
|
|
133
|
-
allowed() {
|
|
134
|
-
return typeof this.canPick === 'function' ? !!this.canPick() : true;
|
|
135
|
-
},
|
|
136
|
-
// Resolve a known install-brand name to its sprite symbol suffix. The
|
|
137
|
-
// three install rows are always known brands, so this always resolves;
|
|
138
|
-
// an unknown name returns null and the use paints nothing (detected rows
|
|
139
|
-
// never use it — they keep the wallet's own Wallet-Standard icon).
|
|
140
|
-
brandIcon(name) {
|
|
141
|
-
var n = ('' + name).toLowerCase();
|
|
142
|
-
return ['phantom', 'solflare', 'backpack'].indexOf(n) !== -1 ? n : null;
|
|
143
|
-
},
|
|
144
|
-
// Is there anywhere for a mobile Phantom row to GO? A consumer that does
|
|
145
|
-
// not render studio/solana/phantom_deeplink has no startPhantomDeepLink,
|
|
146
|
-
// and every branch below turns on this answer.
|
|
147
|
-
//
|
|
148
|
-
// WHY IT IS CHECKED AT ALL: without it, adopting this picker replaced an
|
|
149
|
-
// app's dead-end install row with a dead BUTTON — the install row
|
|
150
|
-
// suppressed, the deep-link row painted, and its tap a no-op. Found
|
|
151
|
-
// before the hub adopted, which had no mobile wallet path whatsoever.
|
|
152
|
-
// An absent capability must not default to the permissive branch.
|
|
153
|
-
get canDeepLink() {
|
|
154
|
-
return typeof startPhantomDeepLink === 'function';
|
|
155
|
-
},
|
|
156
|
-
// A phone has no extension to install, and Phantom's own row on mobile
|
|
157
|
-
// is the deep link below — so drop Phantom here rather than paint a
|
|
158
|
-
// SECOND Phantom row pointing at a desktop download page the user
|
|
159
|
-
// cannot act on. ONLY when a deep link can replace it: with no deep
|
|
160
|
-
// link the install row is the only Phantom path there is, dead end on
|
|
161
|
-
// iOS or not, and removing it leaves the user nothing. Solflare and
|
|
162
|
-
// Backpack keep their install rows either way — there is no deep link
|
|
163
|
-
// for them, so the download page is still their only path.
|
|
164
|
-
get missingInstalls() {
|
|
165
|
-
var self = this;
|
|
166
|
-
return this.installs.filter(function(i) {
|
|
167
|
-
if (self.hasWallet(i.name)) return false;
|
|
168
|
-
if (self.isMobile && self.canDeepLink && i.name === 'Phantom') return false;
|
|
169
|
-
return true;
|
|
170
|
-
});
|
|
171
|
-
},
|
|
172
|
-
// ONE Phantom row in every state: injected (Phantom's in-app browser)
|
|
173
|
-
// means the detected row above already offers a working connect, so a
|
|
174
|
-
// deep link would only offer to leave Phantom to open Phantom.
|
|
175
|
-
get showPhantomDeepLink() {
|
|
176
|
-
return this.isMobile && !this.hasWallet('Phantom') && this.canDeepLink;
|
|
177
|
-
},
|
|
178
|
-
async pick(name) {
|
|
179
|
-
if (this.connecting) return;
|
|
180
|
-
if (!this.allowed()) return;
|
|
181
|
-
this.connecting = true; this.picking = name; this.error = '';
|
|
182
|
-
try {
|
|
183
|
-
var opts = { linkMode: this.props.linkMode, currentUserId: this.props.currentUserId };
|
|
184
|
-
if (typeof this.verifyArgs === 'function') Object.assign(opts, this.verifyArgs() || {});
|
|
185
|
-
var result = await window.<%= connect_fn %>(name, opts);
|
|
186
|
-
if (result && result.success) {
|
|
187
|
-
if (typeof window.handleSolanaVerifySuccess === 'function') window.handleSolanaVerifySuccess(result);
|
|
188
|
-
if (typeof this.onConnected === 'function') {
|
|
189
|
-
this.onConnected(result);
|
|
190
|
-
} else {
|
|
191
|
-
window.location.href = this.props.returnUrl || result.redirect || '/';
|
|
192
|
-
}
|
|
193
|
-
} else {
|
|
194
|
-
this.error = (result && result.error) || 'Verification failed.';
|
|
195
|
-
this.connecting = false; this.picking = '';
|
|
196
|
-
}
|
|
197
|
-
} catch (e) {
|
|
198
|
-
var msg = (e && e.code === 4001) ? 'Signature rejected' : ((e && e.message) || 'Connection failed');
|
|
199
|
-
if (typeof parseSolanaError === 'function') msg = parseSolanaError(msg);
|
|
200
|
-
this.error = msg; this.connecting = false; this.picking = '';
|
|
201
|
-
}
|
|
202
|
-
},
|
|
203
|
-
deepLink() {
|
|
204
|
-
// Same connecting guard as pick(). Without it a tap during an in-flight
|
|
205
|
-
// SIWS verify fires the deep link and walks the user out of the browser
|
|
206
|
-
// mid-signature. The app copies this was promoted from had the same
|
|
207
|
-
// hole; sharing the partial is the moment to close it for everyone.
|
|
208
|
-
if (this.connecting) return;
|
|
209
|
-
if (!this.allowed()) return;
|
|
210
|
-
if (typeof this.onDeepLink === 'function') { this.onDeepLink(); return; }
|
|
211
|
-
if (typeof startPhantomDeepLink === 'function') {
|
|
212
|
-
startPhantomDeepLink(this.props.linkMode || false, (this.props.linkMode && this.props.currentUserId) || null);
|
|
213
|
-
}
|
|
214
|
-
},
|
|
215
|
-
back() {
|
|
216
|
-
if (this.connecting) return;
|
|
217
|
-
if (typeof this.onBack === 'function') { this.onBack(); return; }
|
|
218
|
-
// Alpine.store(), not the $store magic: this runs as a METHOD BODY, and
|
|
219
|
-
// both consuming apps reach the store this way from inside one. The
|
|
220
|
-
// $store form stays where it is proven — the props getter and the
|
|
221
|
-
// Alpine attribute expressions in the markup below.
|
|
222
|
-
Alpine.store('<%= store %>').close();
|
|
223
|
-
}<%= extra_fragment %>
|
|
224
|
-
}"
|
|
225
|
-
class="relative">
|
|
226
|
-
|
|
227
|
-
<%# Wallet brand-icon sprite. Inline SVG symbol marks for the known install
|
|
228
|
-
brands, referenced by the rows below via use href="#se-wallet-…". MUST
|
|
229
|
-
live inside this picker's single root so the host's template x-if clones
|
|
230
|
-
it with the card — a sibling of the root is dropped, painting empty
|
|
231
|
-
icons. Replaces the per-app wallet PNGs. %>
|
|
232
|
-
<%= render "studio/modals/blocks/wallet_brand_sprite" %>
|
|
233
|
-
|
|
234
|
-
<div class="relative mb-4">
|
|
235
|
-
<h3 class="text-heading font-bold text-lg text-center pt-1"><%= title %></h3>
|
|
236
|
-
<button @click="$store.<%= store %>.close()"
|
|
237
|
-
class="absolute top-0 right-0 -mr-1 text-secondary hover:text-heading text-xl leading-none"
|
|
238
|
-
aria-label="Close">×</button>
|
|
239
|
-
</div>
|
|
240
|
-
|
|
241
|
-
<%# App slot — consent, legal copy, anything that must be answered before a
|
|
242
|
-
wallet is picked. Rendered from a NAMED LOCAL, never a block: see the
|
|
243
|
-
header. The app's own x-data members (and canPick) arrive through
|
|
244
|
-
extra_data, so the slot partial can bind straight to them. %>
|
|
245
|
-
<% if slot %>
|
|
246
|
-
<%= render slot, **slot_locals %>
|
|
247
|
-
<% end %>
|
|
248
|
-
|
|
249
|
-
<div class="space-y-2">
|
|
250
|
-
<%# Detected wallets — INSTALLED, click to connect %>
|
|
251
|
-
<template x-for="w in wallets" :key="w.name">
|
|
252
|
-
<button type="button" @click="pick(w.name)" :disabled="connecting"
|
|
253
|
-
class="w-full flex items-center gap-3 p-3 rounded-xl bg-surface-alt border border-strong hover:bg-surface transition text-left disabled:opacity-60 disabled:cursor-wait">
|
|
254
|
-
<%# Brand mark, three ways, and NONE of them an app-served file. A wallet
|
|
255
|
-
that registered through Wallet Standard supplies its own icon, and
|
|
256
|
-
that is the most accurate mark available, so it wins. Otherwise the
|
|
257
|
-
engine sprite by brand name. Otherwise a letter tile. The markup
|
|
258
|
-
promoted from the app fell back to /solana-mark.svg, a per-app asset
|
|
259
|
-
— exactly what blocks/_wallet_brand_sprite exists to end, and a 404
|
|
260
|
-
in any app that had not copied it. %>
|
|
261
|
-
<template x-if="w.icon">
|
|
262
|
-
<img :src="w.icon" alt="" class="w-9 h-9 rounded-lg shrink-0">
|
|
263
|
-
</template>
|
|
264
|
-
<template x-if="!w.icon && brandIcon(w.name)">
|
|
265
|
-
<span class="w-9 h-9 rounded-lg overflow-hidden flex items-center justify-center shrink-0">
|
|
266
|
-
<svg class="w-9 h-9 shrink-0" aria-hidden="true"><use :href="'#se-wallet-' + brandIcon(w.name)"></use></svg>
|
|
267
|
-
</span>
|
|
268
|
-
</template>
|
|
269
|
-
<template x-if="!w.icon && !brandIcon(w.name)">
|
|
270
|
-
<span class="w-9 h-9 rounded-lg bg-inset flex items-center justify-center text-sm font-bold text-heading shrink-0" x-text="w.name.slice(0,1)"></span>
|
|
271
|
-
</template>
|
|
272
|
-
<span class="font-semibold text-heading" x-text="w.name"></span>
|
|
273
|
-
<span class="ml-auto flex items-center gap-2">
|
|
274
|
-
<span x-show="picking === w.name" class="inline-flex items-center gap-1.5 text-xs text-secondary"><span class="spinner" aria-hidden="true"></span>Connecting…</span>
|
|
275
|
-
<span x-show="picking !== w.name" class="badge border-primary text-primary">Installed</span>
|
|
276
|
-
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4 text-muted">
|
|
277
|
-
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
|
278
|
-
</svg>
|
|
279
|
-
</span>
|
|
280
|
-
</button>
|
|
281
|
-
</template>
|
|
282
|
-
|
|
283
|
-
<%# Mobile Phantom — the deep link IS the Phantom row here, so it sits in
|
|
284
|
-
Phantom's usual first position wearing the same brand icon and chevron
|
|
285
|
-
as the install rows, not as a second Phantom entry at the bottom of the
|
|
286
|
-
list. Phantom's universal link covers both states on its own: app
|
|
287
|
-
installed opens it, app absent falls through to Phantom's install page. %>
|
|
288
|
-
<button x-show="showPhantomDeepLink" x-cloak type="button" @click="deepLink()"
|
|
289
|
-
class="w-full flex items-center gap-3 p-3 rounded-xl bg-surface-alt border border-strong hover:bg-surface transition text-left">
|
|
290
|
-
<svg class="w-9 h-9 shrink-0" aria-hidden="true"><use href="#se-wallet-phantom"></use></svg>
|
|
291
|
-
<span class="font-semibold text-heading">Phantom</span>
|
|
292
|
-
<span class="ml-auto flex items-center gap-2">
|
|
293
|
-
<span class="text-xs text-muted uppercase tracking-wide">Open app</span>
|
|
294
|
-
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4 text-muted">
|
|
295
|
-
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
|
296
|
-
</svg>
|
|
297
|
-
</span>
|
|
298
|
-
</button>
|
|
299
|
-
|
|
300
|
-
<%# Featured wallets that aren't installed — open the install page %>
|
|
301
|
-
<template x-for="i in missingInstalls" :key="i.name">
|
|
302
|
-
<a :href="i.url" target="_blank" rel="noopener noreferrer"
|
|
303
|
-
class="w-full flex items-center gap-3 p-3 rounded-xl bg-surface-alt border border-strong hover:bg-surface transition no-underline">
|
|
304
|
-
<svg class="w-9 h-9 shrink-0" aria-hidden="true"><use :href="'#se-wallet-' + brandIcon(i.name)"></use></svg>
|
|
305
|
-
<span class="font-semibold text-heading" x-text="i.name"></span>
|
|
306
|
-
<span class="ml-auto flex items-center gap-2">
|
|
307
|
-
<span class="text-xs text-muted uppercase tracking-wide">Install</span>
|
|
308
|
-
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4 text-muted">
|
|
309
|
-
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
|
310
|
-
</svg>
|
|
311
|
-
</span>
|
|
312
|
-
</a>
|
|
313
|
-
</template>
|
|
314
|
-
</div>
|
|
315
|
-
|
|
316
|
-
<%# role="alert" — the engine requires every rendered error paragraph to carry
|
|
317
|
-
a live region (test/integration/modal_error_announcement_render_test.rb).
|
|
318
|
-
The app markup this was promoted from had none, so a connect failure was
|
|
319
|
-
shown and never ANNOUNCED. Adopting the primitive is what surfaced it: the
|
|
320
|
-
apps were never subject to this test. Same defect, same fix, as the
|
|
321
|
-
attestation error in studio/modals/shared/_age_attestation. %>
|
|
322
|
-
<template x-if="error">
|
|
323
|
-
<p role="alert" class="text-red-400 text-sm mt-3 text-center" x-text="error"></p>
|
|
324
|
-
</template>
|
|
325
|
-
|
|
326
|
-
<button type="button" @click="back()" :disabled="connecting"
|
|
327
|
-
class="mt-4 w-full text-center text-sm text-secondary hover:text-heading disabled:opacity-50">
|
|
328
|
-
← Back
|
|
329
|
-
</button>
|
|
330
|
-
</div>
|
|
@@ -1,292 +0,0 @@
|
|
|
1
|
-
<%#
|
|
2
|
-
Web3 step-up — the shared "your session cannot sign on-chain" card.
|
|
3
|
-
|
|
4
|
-
THE SITUATION IT NAMES: an account that holds a self-custody wallet signs in
|
|
5
|
-
with a WEB2 credential — a magic link, email, or Google. Both facts are
|
|
6
|
-
ordinary alone; together they mean the person is signed in but cannot sign
|
|
7
|
-
anything on-chain. `SessionContext` has modelled that intersection since it was
|
|
8
|
-
lifted into this engine (`web2?` AND `phantom_linked?`) and nothing acted on
|
|
9
|
-
it, so every web3 Studio app logged a wallet owner straight in with no web3
|
|
10
|
-
beat at all. This partial is the missing step; SessionContext stays the state.
|
|
11
|
-
|
|
12
|
-
Ported from turf-monster (2026-08-21), where it shipped first as the working
|
|
13
|
-
vertical. What is NOT shared — and deliberately stays in the host — is WHEN to
|
|
14
|
-
show it and what it costs to dismiss: the host's policy decides who sees the
|
|
15
|
-
card, and the host's gates keep their own teeth. This partial owns ONE step.
|
|
16
|
-
|
|
17
|
-
ADVISORY BY CONSTRUCTION. The card is dismissible and says so. A self-custody
|
|
18
|
-
wallet is the one credential a host cannot reset for a user, so a card that
|
|
19
|
-
could not be closed would lock a legitimate owner out of their own account
|
|
20
|
-
over a wallet they merely cannot reach right now. That is why the escape hatch
|
|
21
|
-
below is not optional decoration — and why enforcement must live in the host's
|
|
22
|
-
on-chain gates, never in this card.
|
|
23
|
-
|
|
24
|
-
Locals (all optional, defaults via local_assigns.fetch):
|
|
25
|
-
heading — (default "Sign in with your wallet")
|
|
26
|
-
subtext — the one-line why
|
|
27
|
-
help_url — the escape hatch for a user who cannot reach their wallet
|
|
28
|
-
(default "/help"). Set to nil to drop the line entirely.
|
|
29
|
-
help_label — (default "Get help")
|
|
30
|
-
picker_modal_id — the host's brand picker, reached by "Use a different
|
|
31
|
-
wallet" (default "wallet-connect"). The picker is expected
|
|
32
|
-
to honour a `backTo` prop pointing back at this card.
|
|
33
|
-
modal_id — this card's own id, passed to the picker as backTo
|
|
34
|
-
(default "web3-step-up")
|
|
35
|
-
modal_store — Alpine store name (default "modals"; the living style
|
|
36
|
-
guide mounts a page-scoped host and passes "dsModals")
|
|
37
|
-
dismiss_event — window event dispatched on dismissal (default
|
|
38
|
-
"web3-step-up-dismissed"). The HOST decides what happens
|
|
39
|
-
next — typically releasing an onboarding chain it held
|
|
40
|
-
while this card had the screen. This partial never opens
|
|
41
|
-
another modal itself.
|
|
42
|
-
|
|
43
|
-
PROPS (set by the opener, all optional — the host's policy produces them):
|
|
44
|
-
provider — normalised brand key ('phantom' / 'solflare' / 'backpack'),
|
|
45
|
-
or null when the account linked its wallet before the host
|
|
46
|
-
started remembering the brand.
|
|
47
|
-
providerLabel — how that brand writes its own name, for the row.
|
|
48
|
-
walletHint — the truncated address (4…4), so the user can confirm the
|
|
49
|
-
card is asking for the wallet they think it is.
|
|
50
|
-
|
|
51
|
-
WITH a remembered provider this is ONE row. Without one it is the same card
|
|
52
|
-
with the picker as its primary action — the fallback is never a dead end.
|
|
53
|
-
|
|
54
|
-
CONTRACT WITH THE HOST'S JS. Signing calls `window.solanaConnectAndVerify(name,
|
|
55
|
-
{ linkMode: false })`, the global every web3 Studio app already provides, and
|
|
56
|
-
hands a success to `window.handleSolanaVerifySuccess` when present. linkMode is
|
|
57
|
-
deliberately FALSE: the link path binds to the current user but does not grant
|
|
58
|
-
the on-chain session, which is the thing this card exists to obtain. One
|
|
59
|
-
inherited consequence, stated plainly: signing with a DIFFERENT wallet signs
|
|
60
|
-
you into that wallet's account, exactly as a standalone wallet button does. The
|
|
61
|
-
walletHint is shown precisely so that is a visible choice, not a surprise.
|
|
62
|
-
|
|
63
|
-
CRITICAL (Alpine): this partial is cloned from a <template x-if> by the modal
|
|
64
|
-
host, so it must have ONE root element, and the x-data below is a
|
|
65
|
-
DOUBLE-QUOTED attribute — a single " anywhere inside it (a code comment
|
|
66
|
-
included) closes it early and the whole component mounts as a silent no-op that
|
|
67
|
-
still renders markup. Keep every inner string SINGLE-quoted.
|
|
68
|
-
%>
|
|
69
|
-
<%
|
|
70
|
-
heading = local_assigns.fetch(:heading, "Sign in with your wallet")
|
|
71
|
-
subtext = local_assigns.fetch(:subtext,
|
|
72
|
-
"This account is secured by a Solana wallet. You are signed in, but this " \
|
|
73
|
-
"session can’t sign on-chain — so on-chain actions still need your wallet.")
|
|
74
|
-
help_url = local_assigns.fetch(:help_url, "/help")
|
|
75
|
-
help_label = local_assigns.fetch(:help_label, "Get help")
|
|
76
|
-
picker_modal_id = local_assigns.fetch(:picker_modal_id, "wallet-connect")
|
|
77
|
-
modal_id = local_assigns.fetch(:modal_id, "web3-step-up")
|
|
78
|
-
modal_store = local_assigns.fetch(:modal_store, "modals")
|
|
79
|
-
dismiss_event = local_assigns.fetch(:dismiss_event, "web3-step-up-dismissed")
|
|
80
|
-
%>
|
|
81
|
-
<div x-data="{
|
|
82
|
-
get props() { var c = $store.<%= modal_store %>.current(); return (c && c.props) || {}; },
|
|
83
|
-
connecting: false,
|
|
84
|
-
error: '',
|
|
85
|
-
// Set when the remembered wallet turns out not to be reachable in THIS
|
|
86
|
-
// browser (a different machine, the extension removed). It flips the card
|
|
87
|
-
// to the picker rather than leaving the user pressing a button that
|
|
88
|
-
// cannot work.
|
|
89
|
-
providerMissing: false,
|
|
90
|
-
// Which wallets this document can actually see. Read on a poll, NOT once
|
|
91
|
-
// at mount: wallet provider registration fills in asynchronously, and
|
|
92
|
-
// this card auto-opens on the render right after auth — the worst
|
|
93
|
-
// possible moment. A single early read is a coin flip that would badge an
|
|
94
|
-
// installed wallet as missing.
|
|
95
|
-
wallets: [],
|
|
96
|
-
_poll: null,
|
|
97
|
-
_onRegister: null,
|
|
98
|
-
get provider() { return this.props.provider || null; },
|
|
99
|
-
get providerLabel() { return this.props.providerLabel || null; },
|
|
100
|
-
get walletHint() { return this.props.walletHint || null; },
|
|
101
|
-
// One-click only while we remember a brand AND it is still reachable.
|
|
102
|
-
get canOneClick() { return !!this.provider && !this.providerMissing; },
|
|
103
|
-
// Does the remembered brand answer in THIS document right now? Drives the
|
|
104
|
-
// Installed badge, the same one the connect picker shows, so a detected
|
|
105
|
-
// wallet reads identically in both places.
|
|
106
|
-
get detected() {
|
|
107
|
-
var name = ('' + (this.provider || '')).toLowerCase();
|
|
108
|
-
if (!name) return false;
|
|
109
|
-
return (this.wallets || []).some(function(w) { return w.name && w.name.toLowerCase() === name; });
|
|
110
|
-
},
|
|
111
|
-
init() {
|
|
112
|
-
var self = this;
|
|
113
|
-
this.refresh();
|
|
114
|
-
this._onRegister = function() { if (!self.connecting) self.refresh(); };
|
|
115
|
-
window.addEventListener('wallet-standard:register-wallet', this._onRegister);
|
|
116
|
-
this._poll = setInterval(function() {
|
|
117
|
-
if (self.connecting) return;
|
|
118
|
-
self.refresh();
|
|
119
|
-
if (self.wallets.length > 0) { clearInterval(self._poll); self._poll = null; }
|
|
120
|
-
}, 1000);
|
|
121
|
-
},
|
|
122
|
-
destroy() {
|
|
123
|
-
if (this._poll) clearInterval(this._poll);
|
|
124
|
-
if (this._onRegister) window.removeEventListener('wallet-standard:register-wallet', this._onRegister);
|
|
125
|
-
},
|
|
126
|
-
refresh() {
|
|
127
|
-
this.wallets = (window.walletProvider && window.walletProvider.available && window.walletProvider.available()) || [];
|
|
128
|
-
},
|
|
129
|
-
reachable(name) {
|
|
130
|
-
if (!window.walletProvider || !window.walletProvider.get) return false;
|
|
131
|
-
return !!window.walletProvider.get(name);
|
|
132
|
-
},
|
|
133
|
-
async signIn() {
|
|
134
|
-
if (this.connecting) return;
|
|
135
|
-
var name = this.provider;
|
|
136
|
-
if (!name) return this.openPicker();
|
|
137
|
-
if (!this.reachable(name)) {
|
|
138
|
-
this.providerMissing = true;
|
|
139
|
-
this.error = 'We could not reach your ' + (this.providerLabel || 'wallet') + ' in this browser.';
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
if (typeof window.solanaConnectAndVerify !== 'function') {
|
|
143
|
-
this.error = 'Wallet sign-in is unavailable on this page.';
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
this.connecting = true; this.error = '';
|
|
147
|
-
try {
|
|
148
|
-
var result = await window.solanaConnectAndVerify(name, { linkMode: false });
|
|
149
|
-
if (result && result.success) {
|
|
150
|
-
if (typeof window.handleSolanaVerifySuccess === 'function') window.handleSolanaVerifySuccess(result);
|
|
151
|
-
// Reload rather than follow a redirect: the user was already
|
|
152
|
-
// somewhere, and the only thing that changed is that this session
|
|
153
|
-
// can now sign. Sending them to a post-login landing would move
|
|
154
|
-
// them off the page they were reading.
|
|
155
|
-
window.location.reload();
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
this.error = (result && result.error) || 'Verification failed.';
|
|
159
|
-
} catch (e) {
|
|
160
|
-
var msg = (e && e.code === 4001) ? 'Signature rejected' : ((e && e.message) || 'Connection failed');
|
|
161
|
-
if (typeof parseSolanaError === 'function') msg = parseSolanaError(msg);
|
|
162
|
-
this.error = msg;
|
|
163
|
-
}
|
|
164
|
-
this.connecting = false;
|
|
165
|
-
},
|
|
166
|
-
// Hand off to the host's brand picker. swap() rather than open() so the
|
|
167
|
-
// picker's back arrow returns HERE instead of stranding the user.
|
|
168
|
-
openPicker() {
|
|
169
|
-
if (this.connecting) return;
|
|
170
|
-
$store.<%= modal_store %>.swap('<%= picker_modal_id %>',
|
|
171
|
-
{ backTo: '<%= modal_id %>', stepUpProps: this.props, returnUrl: window.location.href });
|
|
172
|
-
},
|
|
173
|
-
// Dismiss. Dispatched so the HOST knows this card is done and can release
|
|
174
|
-
// whatever it was holding — this partial never opens another modal.
|
|
175
|
-
dismiss() {
|
|
176
|
-
if (this.connecting) return;
|
|
177
|
-
window.dispatchEvent(new CustomEvent('<%= dismiss_event %>'));
|
|
178
|
-
$store.<%= modal_store %>.close();
|
|
179
|
-
}
|
|
180
|
-
}"
|
|
181
|
-
class="relative">
|
|
182
|
-
|
|
183
|
-
<%# Brand sprite — MUST live inside this card's single root so the host's
|
|
184
|
-
<template x-if> clones it along with the card. A sibling of the root is
|
|
185
|
-
dropped and the icon paints empty. %>
|
|
186
|
-
<%= render "studio/modals/blocks/wallet_brand_sprite" %>
|
|
187
|
-
|
|
188
|
-
<div class="relative mb-1">
|
|
189
|
-
<button @click="dismiss()" :disabled="connecting"
|
|
190
|
-
class="absolute top-0 right-0 -mr-1 text-secondary hover:text-heading text-xl leading-none disabled:opacity-50"
|
|
191
|
-
aria-label="Close">×</button>
|
|
192
|
-
</div>
|
|
193
|
-
|
|
194
|
-
<%= render "studio/modals/blocks/card_header", icon_emoji: "🔐", title: heading do %>
|
|
195
|
-
<p class="text-sm text-body"><%= subtext %></p>
|
|
196
|
-
<% end %>
|
|
197
|
-
|
|
198
|
-
<%# PRIMARY — THE STANDARD WEB3 AUTH BUTTON: a wallet row, not a filled CTA.
|
|
199
|
-
Same shape the connect picker uses — brand mark, the wallet's own name, an
|
|
200
|
-
Installed badge, chevron — so a wallet reads identically everywhere it is
|
|
201
|
-
offered and this card does not invent a third look for one action.
|
|
202
|
-
|
|
203
|
-
It carries `pulse-cta` (engine-motion) because it is the ONE target on the
|
|
204
|
-
card and the whole point of the card is that the user should press it. %>
|
|
205
|
-
<template x-if="canOneClick">
|
|
206
|
-
<div>
|
|
207
|
-
<button type="button" @click="signIn()" :disabled="connecting"
|
|
208
|
-
style="--pulse-cta-color: var(--color-primary); --pulse-cta-strength: 0.4; --pulse-cta-scale: 1.02"
|
|
209
|
-
class="pulse-cta w-full flex items-center gap-3 p-3 rounded-xl bg-surface-alt border border-strong hover:bg-surface transition text-left disabled:opacity-60 disabled:cursor-wait">
|
|
210
|
-
<span class="w-9 h-9 rounded-lg overflow-hidden flex items-center justify-center shrink-0">
|
|
211
|
-
<svg class="w-9 h-9" aria-hidden="true"><use :href="'#se-wallet-' + provider"></use></svg>
|
|
212
|
-
</span>
|
|
213
|
-
<span class="font-semibold text-heading" x-text="providerLabel"></span>
|
|
214
|
-
<span class="ml-auto flex items-center gap-2">
|
|
215
|
-
<span x-show="connecting" class="inline-flex items-center gap-1.5 text-xs text-secondary"><span class="spinner" aria-hidden="true"></span>Connecting…</span>
|
|
216
|
-
<span x-show="!connecting && detected" class="badge border-primary text-primary">Installed</span>
|
|
217
|
-
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4 text-muted">
|
|
218
|
-
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
|
219
|
-
</svg>
|
|
220
|
-
</span>
|
|
221
|
-
</button>
|
|
222
|
-
<%# One line on what pressing it DOES. A signature prompt is alarming
|
|
223
|
-
without it, and "no funds move" is the difference between clicking and
|
|
224
|
-
bailing. The address rides here so the user can confirm the card is
|
|
225
|
-
asking for the wallet they think it is. %>
|
|
226
|
-
<p class="text-[11px] text-muted mt-1.5 px-1">
|
|
227
|
-
Signing proves the wallet is yours — it does not move any funds.
|
|
228
|
-
<template x-if="walletHint">
|
|
229
|
-
<span>Wallet <span class="font-mono text-secondary" x-text="walletHint"></span></span>
|
|
230
|
-
</template>
|
|
231
|
-
</p>
|
|
232
|
-
</div>
|
|
233
|
-
</template>
|
|
234
|
-
|
|
235
|
-
<%# No remembered brand — the same row shape with no mark to show, so the two
|
|
236
|
-
halves of this card look like one card. %>
|
|
237
|
-
<template x-if="!canOneClick">
|
|
238
|
-
<button type="button" @click="openPicker()" :disabled="connecting"
|
|
239
|
-
style="--pulse-cta-color: var(--color-primary); --pulse-cta-strength: 0.4; --pulse-cta-scale: 1.02"
|
|
240
|
-
class="pulse-cta w-full flex items-center gap-3 p-3 rounded-xl bg-surface-alt border border-strong hover:bg-surface transition text-left disabled:opacity-60">
|
|
241
|
-
<%# A neutral wallet mark, NOT an emoji. This tile sits inches from a real
|
|
242
|
-
brand mark, so a platform emoji (the first pass used U+1F45B PURSE)
|
|
243
|
-
reads as a pink handbag next to Phantom and is the one thing on the
|
|
244
|
-
card that belongs to no design system. Stroked in the same idiom as
|
|
245
|
-
the chevron beside it, on theme tokens.
|
|
246
|
-
|
|
247
|
-
A billfold: body plus clasp, and NO band across the top. The first
|
|
248
|
-
drawing had one and it read as a credit-card magstripe at 20px — the
|
|
249
|
-
wrong object on a card whose whole subject is a wallet. %>
|
|
250
|
-
<span class="w-9 h-9 rounded-lg bg-inset flex items-center justify-center shrink-0" aria-hidden="true">
|
|
251
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" class="w-5 h-5 text-secondary">
|
|
252
|
-
<rect x="3" y="6.5" width="18" height="11" rx="2.5" />
|
|
253
|
-
<circle cx="16.6" cy="12" r="1.3" fill="currentColor" stroke="none" />
|
|
254
|
-
</svg>
|
|
255
|
-
</span>
|
|
256
|
-
<span class="font-semibold text-heading">Connect your wallet</span>
|
|
257
|
-
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="ml-auto w-4 h-4 text-muted">
|
|
258
|
-
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
|
259
|
-
</svg>
|
|
260
|
-
</button>
|
|
261
|
-
</template>
|
|
262
|
-
|
|
263
|
-
<template x-if="error">
|
|
264
|
-
<p role="alert" class="text-red-400 text-sm mt-3 text-center" x-text="error"></p>
|
|
265
|
-
</template>
|
|
266
|
-
|
|
267
|
-
<%# SECONDARY — always reachable, so a user whose remembered wallet is the
|
|
268
|
-
wrong one is never cornered by our memory of it. %>
|
|
269
|
-
<template x-if="canOneClick">
|
|
270
|
-
<button type="button" @click="openPicker()" :disabled="connecting"
|
|
271
|
-
class="mt-3 block w-full text-center text-sm text-secondary hover:text-heading disabled:opacity-50">
|
|
272
|
-
Use a different wallet
|
|
273
|
-
</button>
|
|
274
|
-
</template>
|
|
275
|
-
|
|
276
|
-
<div class="mt-4 pt-3 border-t border-strong">
|
|
277
|
-
<button type="button" @click="dismiss()" :disabled="connecting"
|
|
278
|
-
class="block w-full text-center text-sm text-secondary hover:text-heading disabled:opacity-50">
|
|
279
|
-
Not now
|
|
280
|
-
</button>
|
|
281
|
-
<%# THE ESCAPE HATCH. A self-custody wallet is the one credential a host
|
|
282
|
-
cannot reset for the user, so the card must not end without a way to
|
|
283
|
-
reach a human. Droppable only by passing help_url: nil, which is a
|
|
284
|
-
deliberate act at the callsite rather than an omission. %>
|
|
285
|
-
<% if help_url.present? %>
|
|
286
|
-
<p class="mt-2 text-center text-[11px] text-muted">
|
|
287
|
-
Can’t access your wallet?
|
|
288
|
-
<a href="<%= help_url %>" class="underline hover:text-secondary"><%= help_label %></a>
|
|
289
|
-
</p>
|
|
290
|
-
<% end %>
|
|
291
|
-
</div>
|
|
292
|
-
</div>
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
<%#
|
|
2
|
-
tweetnacl, for the Phantom mobile deep link. Both halves of that flow need
|
|
3
|
-
window.nacl: the deep link generates an x25519 keypair so Phantom can encrypt
|
|
4
|
-
its reply, and the callback opens that box with the stashed secret.
|
|
5
|
-
|
|
6
|
-
IDEMPOTENT ON PURPOSE. turf-monster already loads tweetnacl from its own
|
|
7
|
-
layout, so this must not load a second copy there; the guard means a consumer
|
|
8
|
-
that already supplies nacl keeps its own and one that does not (the hub, which
|
|
9
|
-
had no mobile wallet path at all) gets it here.
|
|
10
|
-
|
|
11
|
-
Pinned to an exact version rather than a range: this is signing-path crypto,
|
|
12
|
-
and a range lets a CDN swap it under a running app. The same reasoning is
|
|
13
|
-
written out beside turf-monster's web3.js pin.
|
|
14
|
-
|
|
15
|
-
Render before studio/solana/phantom_deeplink and in the callback view.
|
|
16
|
-
%>
|
|
17
|
-
<script>
|
|
18
|
-
// NOT document.write. Under turbo-rails a Drive visit re-executes body scripts
|
|
19
|
-
// at readyState === 'complete', where document.write implicitly calls
|
|
20
|
-
// document.open() and BLANKS THE PAGE — on exactly the consumer this guard
|
|
21
|
-
// exists for. Append a real element instead; it is also non-blocking.
|
|
22
|
-
(function () {
|
|
23
|
-
if (typeof window.nacl !== 'undefined') return;
|
|
24
|
-
if (document.querySelector('script[data-studio-nacl]')) return;
|
|
25
|
-
var s = document.createElement('script');
|
|
26
|
-
s.src = 'https://cdn.jsdelivr.net/npm/tweetnacl@1.0.3/nacl-fast.min.js';
|
|
27
|
-
// Derived with `openssl dgst -sha384 -binary | openssl base64 -A` against the
|
|
28
|
-
// fetched file, and cross-checked against the value turf-monster already pins
|
|
29
|
-
// for this exact URL. NEVER hand-write an SRI: a plausible-looking wrong one
|
|
30
|
-
// makes the browser refuse the script and nacl silently absent, which fails
|
|
31
|
-
// every mobile sign-in before any other code runs.
|
|
32
|
-
s.integrity = 'sha384-05+sicyRJQ56XpL4U9HJ8YbtSzFDvAg7apPKOGV6A0JsAJKFM68jp5oLnUjG5mEp';
|
|
33
|
-
s.crossOrigin = 'anonymous';
|
|
34
|
-
s.setAttribute('data-studio-nacl', '');
|
|
35
|
-
document.head.appendChild(s);
|
|
36
|
-
})();
|
|
37
|
-
</script>
|
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
<%#
|
|
2
|
-
Phantom MOBILE deep link — the round trip a phone needs to sign in with a
|
|
3
|
-
wallet. On desktop Phantom is a browser extension the page can call directly;
|
|
4
|
-
on a phone it is a separate APP, so the page hands off to it and comes back.
|
|
5
|
-
|
|
6
|
-
PROMOTED from turf-monster (app/javascript/phantom_deeplink.js) so every
|
|
7
|
-
consumer gets it. The server half was already shared — the engine's
|
|
8
|
-
solana_sessions_controller owns nonce + verify and Solana::SessionAuth owns
|
|
9
|
-
verify_solana_signature! — only this mobile round trip was app-side.
|
|
10
|
-
|
|
11
|
-
Render it once, wherever the Connect-Wallet flow can be reached — an ERB
|
|
12
|
-
output tag rendering the partial path "studio/solana/phantom_deeplink".
|
|
13
|
-
(Described rather than quoted on purpose: an ERB comment ends at the first
|
|
14
|
-
close sequence, so spelling the tag out here would terminate this comment and
|
|
15
|
-
leak the rest of it as visible text into every consuming app. The engine's
|
|
16
|
-
erb_comment_leak_test enforces that, and it caught this very paragraph.)
|
|
17
|
-
|
|
18
|
-
It defines window.startPhantomDeepLink(linkMode, currentUserId). The engine's
|
|
19
|
-
wallet picker (studio/modals/_wallet_connect) gates its mobile Phantom row on
|
|
20
|
-
that function EXISTING, so a consumer that does not render this partial keeps
|
|
21
|
-
its install row instead of painting a button that does nothing.
|
|
22
|
-
|
|
23
|
-
THREE THINGS THAT MUST NOT BE MADE CONFIGURABLE — traced through
|
|
24
|
-
Solana::SessionAuth#verify_solana_signature! before this was generalised:
|
|
25
|
-
|
|
26
|
-
domain the client builds it from window.location.host and the server
|
|
27
|
-
checks it against request.host_with_port (OPSEC-018). An app
|
|
28
|
-
override breaks sign-in.
|
|
29
|
-
User-ID line OPSEC-005 does a SUBSTRING match on "User-ID: <id>". Do not
|
|
30
|
-
reformat it and do not translate it.
|
|
31
|
-
the nonce deleted server-side BEFORE verification; that is the replay
|
|
32
|
-
protection for the login path.
|
|
33
|
-
|
|
34
|
-
The STATEMENT is the one part that varies, and it varies safely: the server
|
|
35
|
-
does NOT verify it — it is what the human reads inside Phantom. It derives
|
|
36
|
-
from Studio.app_name, which reproduces turf-monster's previous literal
|
|
37
|
-
("Sign in to Turf Monster") BYTE FOR BYTE, so promoting this changed no
|
|
38
|
-
signed message. It MUST match the callback's copy exactly, because the
|
|
39
|
-
callback reconstructs the signed message to post for verification — both read
|
|
40
|
-
this same helper, which is why they cannot drift.
|
|
41
|
-
|
|
42
|
-
Requires tweetnacl (window.nacl); render "studio/solana/deeplink_assets"
|
|
43
|
-
first, or supply it yourself.
|
|
44
|
-
%>
|
|
45
|
-
<script>
|
|
46
|
-
(function () {
|
|
47
|
-
var STATEMENT = <%= Studio.wallet_sign_in_statement.to_json.html_safe %>;
|
|
48
|
-
|
|
49
|
-
// Base58, INLINE and self-contained. turf-monster took this from a separate
|
|
50
|
-
// app/javascript/base58.js module that assigned window.encodeBase58, so its
|
|
51
|
-
// deep link depended on a second global. Promoting only the deep link would
|
|
52
|
-
// have left encodeBase58 undefined in any consumer without that file — it
|
|
53
|
-
// throws on the first keypair encode, at the moment the user taps Connect.
|
|
54
|
-
// The callback view already carries its own codec for the same reason; this
|
|
55
|
-
// matches it, so the partial has exactly one dependency (nacl) and it is a
|
|
56
|
-
// guarded one.
|
|
57
|
-
// THE CONSTANT THE ENCODER READS. It was left behind when the function body
|
|
58
|
-
// was inlined: base58.js declares it at module scope, so it is unreachable
|
|
59
|
-
// from a classic script, and encodeBase58 referenced a free variable that
|
|
60
|
-
// resolved to nothing AT CALL TIME. Every mobile sign-in threw
|
|
61
|
-
// "B58_ALPHABET is not defined" on the first keypair encode.
|
|
62
|
-
//
|
|
63
|
-
// Invisible to eleven passing view tests and to a browser spec that checked
|
|
64
|
-
// `typeof startPhantomDeepLink` without ever CALLING it. A parse-time
|
|
65
|
-
// mutation cannot reach this class either — the program parses fine.
|
|
66
|
-
const B58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
|
67
|
-
|
|
68
|
-
function encodeBase58(bytes) {
|
|
69
|
-
const digits = [0];
|
|
70
|
-
for (let i = 0; i < bytes.length; i++) {
|
|
71
|
-
let carry = bytes[i];
|
|
72
|
-
for (let j = 0; j < digits.length; j++) {
|
|
73
|
-
carry += digits[j] << 8;
|
|
74
|
-
digits[j] = carry % 58;
|
|
75
|
-
carry = (carry / 58) | 0;
|
|
76
|
-
}
|
|
77
|
-
while (carry) { digits.push(carry % 58); carry = (carry / 58) | 0; }
|
|
78
|
-
}
|
|
79
|
-
let str = '';
|
|
80
|
-
for (let i = 0; i < bytes.length && bytes[i] === 0; i++) str += '1';
|
|
81
|
-
for (let i = digits.length - 1; i >= 0; i--) str += B58_ALPHABET[digits[i]];
|
|
82
|
-
return str;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Phantom deep link protocol for mobile browsers
|
|
86
|
-
// Uses signIn deep link — ONE trip to Phantom (connect + sign combined)
|
|
87
|
-
// Flow: generate keypair → fetch nonce → build SIWS input → redirect to Phantom signIn
|
|
88
|
-
|
|
89
|
-
function startPhantomDeepLink(linkMode, currentUserId) {
|
|
90
|
-
// The consumer declares its cluster on <body data-solana-cluster="...">.
|
|
91
|
-
// turf-monster does; an app that does NOT silently signs against devnet while
|
|
92
|
-
// its wallet is on mainnet, which reads to the user as a rejected signature
|
|
93
|
-
// and to the log as nothing. Warn loudly rather than guess quietly — the
|
|
94
|
-
// default stays devnet so behaviour is unchanged for anyone already relying
|
|
95
|
-
// on it.
|
|
96
|
-
var cluster = document.body.dataset.solanaCluster;
|
|
97
|
-
if (!cluster) {
|
|
98
|
-
console.warn('[StudioSolana] no data-solana-cluster on <body>; defaulting to devnet. ' +
|
|
99
|
-
'Set it on the body tag to sign against the cluster you mean.');
|
|
100
|
-
cluster = 'devnet';
|
|
101
|
-
}
|
|
102
|
-
var callbackUrl = window.location.origin + '/auth/phantom/callback';
|
|
103
|
-
|
|
104
|
-
// Generate x25519 keypair for decrypting Phantom's response
|
|
105
|
-
var dappKeyPair = nacl.box.keyPair();
|
|
106
|
-
|
|
107
|
-
// Fetch nonce from server, then redirect to Phantom signIn
|
|
108
|
-
fetch('/auth/solana/nonce')
|
|
109
|
-
.then(function(r) { return r.json(); })
|
|
110
|
-
.then(function(data) {
|
|
111
|
-
// OPSEC-005: when linking a wallet to a logged-in user, embed
|
|
112
|
-
// `User-ID: <id>` so the server can refuse signatures captured
|
|
113
|
-
// against a different session's nonce. Login (no currentUserId)
|
|
114
|
-
// skips the binding line.
|
|
115
|
-
var statement = STATEMENT;
|
|
116
|
-
if (linkMode && currentUserId) {
|
|
117
|
-
statement = statement + '\nUser-ID: ' + currentUserId;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// Build SIWS input (CAIP-122 / Sign In With Solana format)
|
|
121
|
-
// Note: chainId omitted — optional per spec, avoids mismatch warning
|
|
122
|
-
// when app is on devnet but user's wallet is on mainnet
|
|
123
|
-
var signInInput = {
|
|
124
|
-
domain: window.location.host,
|
|
125
|
-
statement: statement,
|
|
126
|
-
uri: window.location.origin,
|
|
127
|
-
version: '1',
|
|
128
|
-
nonce: data.nonce,
|
|
129
|
-
issuedAt: new Date().toISOString()
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
// Save state to localStorage (persists across redirect)
|
|
133
|
-
localStorage.setItem('phantom_dl_secret', encodeBase58(dappKeyPair.secretKey));
|
|
134
|
-
localStorage.setItem('phantom_dl_pubkey', encodeBase58(dappKeyPair.publicKey));
|
|
135
|
-
localStorage.setItem('phantom_dl_nonce', data.nonce);
|
|
136
|
-
localStorage.setItem('phantom_dl_nonce_at', Date.now().toString());
|
|
137
|
-
localStorage.setItem('phantom_dl_step', 'signIn');
|
|
138
|
-
localStorage.setItem('phantom_dl_link_mode', linkMode ? 'true' : 'false');
|
|
139
|
-
localStorage.setItem('phantom_dl_cluster', cluster);
|
|
140
|
-
if (currentUserId) {
|
|
141
|
-
localStorage.setItem('phantom_dl_user_id', String(currentUserId));
|
|
142
|
-
} else {
|
|
143
|
-
localStorage.removeItem('phantom_dl_user_id');
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// Base58-encode the SIWS input JSON (NOT encrypted — per Phantom signIn protocol)
|
|
147
|
-
var payloadB58 = encodeBase58(new TextEncoder().encode(JSON.stringify(signInInput)));
|
|
148
|
-
|
|
149
|
-
// Build Phantom signIn deep link
|
|
150
|
-
var params = new URLSearchParams({
|
|
151
|
-
dapp_encryption_public_key: encodeBase58(dappKeyPair.publicKey),
|
|
152
|
-
cluster: cluster,
|
|
153
|
-
app_url: window.location.origin,
|
|
154
|
-
redirect_link: callbackUrl,
|
|
155
|
-
payload: payloadB58
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
window.location.href = 'https://phantom.app/ul/v1/signIn?' + params.toString();
|
|
159
|
-
})
|
|
160
|
-
.catch(function(err) {
|
|
161
|
-
alert('Failed to start Phantom connection: ' + err.message);
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
window.startPhantomDeepLink = startPhantomDeepLink;
|
|
166
|
-
})();
|
|
167
|
-
</script>
|