Sitelet https://github.com/ChainSafe/canton-snap/commit/b7ab0f1a010a74a1bc9c34e07b41cdba1c726eaa
Skip to content

Commit b7ab0f1

Browse files
authored
feat(dapp): custodial-only readiness + dApp release-please (#55)
* feat(dapp): gate non-custodial flow behind VITE_ENABLE_NON_CUSTODIAL; add dApp to release-please v1.0 ships custodial-only. Non-custodial code stays in the bundle behind a build-time flag so a flag-flipped build re-enables the full flow without code changes. Gating (UI-only — dashboard/transfer/balances already branch on profile.keyMode, so they need no changes): - New helper packages/dapp/src/lib/features.ts exports NON_CUSTODIAL_ENABLED = (import.meta.env.VITE_ENABLE_NON_CUSTODIAL === "true"). - App.tsx routes new users straight to custodial-pending instead of the registration-choice screen when the flag is off. The choice page and the two non-custodial registration pages still exist but their renderers are guarded by the flag so they're unreachable. - CustodialRegistrationPage.onBack is now optional; when omitted (flag off) the "← Back to registration" link and the inline "Cancel" button are hidden, since there's nowhere to go back to. - Bundle: 273 KB with flag off vs 283 KB with flag on — Vite tree-shakes the dead non-custodial branches. release-please: - Add packages/dapp to release-please-config.json with component=dapp. - Add packages/dapp: 0.1.0 to .release-please-manifest.json. - No publish step: only the snap is on npm; the dApp gets a tag + GitHub release + CHANGELOG entry on each user-facing dApp commit. Regular CI on the release PR validates the dApp builds. * fix(dapp): skip wallet_getSnaps probe when non-custodial is disabled useSnap()'s mount effect called wallet_getSnaps unconditionally to detect a previously-installed snap. Standard MetaMask doesn't support that method and rejects with RPC 4100. The error was caught (returns null), so behavior was correct, but MetaMask logs the error before returning the rejection, and the failed RPC also showed up as 'StreamMiddleware - Unknown response id' noise. useRegistration() mounts useSnap() unconditionally, so even the custodial flow triggered the probe. Now the effect is gated on NON_CUSTODIAL_ENABLED — custodial-only builds never call any snap RPC, console stays clean, and standard MetaMask is fully supported. * fix(dapp): switch-first chain handling so wallet_addEthereumChain doesn't reject stale chains ensureChainAdded() unconditionally called wallet_addEthereumChain when MM wasn't already on the target chainId. If MM had previously stored that chainId with a different nativeCurrency.symbol (e.g. an older dApp build that used a different name), MM returns -32602: 'nativeCurrency.symbol does not match currency symbol for a network the user already has added with the same chainId'. EIP-3326 covers this: switch first, fall back to add only on 4902 (chain not added). Switch tolerates symbol/name drift because it doesn't try to mutate the existing entry. Add still runs cleanly for a genuinely-unknown chain. * fix(dapp): route TransferPage chain check through ensureChainAdded handleMetaMaskSign called addEthChain directly, bypassing the switch-first pattern in ensureChainAdded. If MM had the chain stored with even minor metadata drift (e.g. a previous Canton Devnet entry using a different nativeCurrency.symbol), every transfer attempt would hit -32602 'nativeCurrency.symbol does not match'. ensureChainAdded already does the right thing: switch first, fall back to add only on 4902. Drop the inline addEthChain + ethChainId calls and use the shared helper. * feat(dapp): remove bridge tab + persist dashboard tab in URL hash Bridge: drop the Bridge sidebar entry (was disabled), the BridgeIcon component, the 'Bridge assets' quick-action button on the profile page, and the Transfers/Bridge sub-tab toggle on the activity page along with its associated CSS. Activity now just shows transfers. Tab persistence: dashboardTab now syncs with window.location.hash, so refreshing on /#balances stays on Balances instead of snapping back to Profile. Hash updates use history.replaceState so back/forward across tabs works without polluting history with intermediate entries. The hash is only written while on the dashboard — landing/registration don't touch it. * refactor(dapp): dedupe custodial entry; hide snap status on custodial profile - Gemini review: handleCustodial is now a useCallback, and goRegister calls it instead of duplicating the setMode/setPage pair. - Self-review: the Profile page's 'CANTON SNAP — Not installed' block is now hidden for custodial users entirely. The block was always meaningless for them and looked like a warning in the v1.0 custodial- only build where useSnap's probe is also skipped. * fix: format
1 parent a839483 commit b7ab0f1

16 files changed

Lines changed: 185 additions & 245 deletions

‎.release-please-manifest.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
{
2-
"packages/snap": "0.2.0"
2+
"packages/snap": "0.2.0",
3+
"packages/dapp": "0.1.0"
34
}

‎packages/dapp/.env.example‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,8 @@ VITE_NETWORK=devnet
77

88
# Middleware REST API base URL
99
VITE_MIDDLEWARE_URL=https://middleware-api-dev1.01.chainsafe.dev
10+
11+
# Non-custodial flow toggle. Unset / "false" = v1.0 default (custodial-only:
12+
# registration skips the choice screen and goes straight to custodial). Set
13+
# to "true" to re-enable the registration choice and the snap-backed flow.
14+
# VITE_ENABLE_NON_CUSTODIAL=true

‎packages/dapp/src/App.tsx‎

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useMetaMask } from "./hooks/useMetaMask";
33
import { useRegistration } from "./hooks/useRegistration";
44
import { useAutoNetworkSwitch } from "./hooks/useAutoNetworkSwitch";
55
import { NETWORK } from "./lib/config";
6+
import { NON_CUSTODIAL_ENABLED } from "./lib/features";
67
import { personalSign } from "./lib/ethereum";
78
import { getUser, SessionExpiredError, type UserProfile } from "./lib/middleware";
89
import { getSession, storeSession, clearSession, clearAllSessions } from "./lib/session";
@@ -27,13 +28,45 @@ type Page =
2728
| "registration-done"
2829
| "dashboard";
2930

31+
const DASHBOARD_TABS: readonly DashboardTab[] = ["profile", "balances", "transfer", "activity"];
32+
33+
function readTabFromHash(): DashboardTab {
34+
const h = window.location.hash.replace(/^#/, "");
35+
return (DASHBOARD_TABS as readonly string[]).includes(h) ? (h as DashboardTab) : "profile";
36+
}
37+
3038
export default function App() {
3139
const [page, setPage] = useState<Page>("landing");
3240
const [mode, setMode] = useState<"custodial" | "noncustodial">("custodial");
3341
const [profile, setProfile] = useState<UserProfile | null>(null);
3442
const [connectError, setConnectError] = useState<string | null>(null);
3543
const [reconnecting, setReconnecting] = useState(false);
36-
const [dashboardTab, setDashboardTab] = useState<DashboardTab>("profile");
44+
const [dashboardTab, setDashboardTab] = useState<DashboardTab>(readTabFromHash);
45+
46+
// Mirror dashboardTab into the URL hash so a refresh on /#balances stays on
47+
// Balances instead of snapping back to Profile. Only mutate the hash while
48+
// we're actually on the dashboard — on landing/registration the hash is
49+
// irrelevant and clobbering it would surprise the user.
50+
useEffect(() => {
51+
if (page !== "dashboard") return;
52+
const want = `#${dashboardTab}`;
53+
if (window.location.hash !== want) {
54+
window.history.replaceState(
55+
null,
56+
"",
57+
`${window.location.pathname}${window.location.search}${want}`,
58+
);
59+
}
60+
}, [page, dashboardTab]);
61+
62+
// Browser back/forward across tabs.
63+
useEffect(() => {
64+
function onHashChange() {
65+
setDashboardTab(readTabFromHash());
66+
}
67+
window.addEventListener("hashchange", onHashChange);
68+
return () => window.removeEventListener("hashchange", onHashChange);
69+
}, []);
3770

3871
const mm = useMetaMask();
3972
const reg = useRegistration(NETWORK.middlewareUrl);
@@ -89,16 +122,28 @@ export default function App() {
89122
setPage("landing");
90123
}
91124

92-
function handleCustodial() {
125+
const handleCustodial = useCallback(() => {
93126
setMode("custodial");
94127
setPage("custodial-pending");
95-
}
128+
}, []);
96129

97130
function handleNonCustodial() {
98131
setMode("noncustodial");
99132
setPage(reg.snap.alreadyInstalled ? "noncustodial-sign" : "noncustodial-install");
100133
}
101134

135+
// v1.0 ships custodial-only: skip the registration-choice screen and go
136+
// straight to the custodial flow. The non-custodial pages stay in the
137+
// bundle behind NON_CUSTODIAL_ENABLED so a build with the flag set
138+
// re-enables the full chooser without code changes.
139+
const goRegister = useCallback(() => {
140+
if (NON_CUSTODIAL_ENABLED) {
141+
setPage("registration-choice");
142+
} else {
143+
handleCustodial();
144+
}
145+
}, [handleCustodial]);
146+
102147
const address = mm.address ?? "";
103148
const snapInstalled = reg.snap.installed || reg.snap.alreadyInstalled;
104149
const snapVersion = reg.snap.version;
@@ -158,7 +203,7 @@ export default function App() {
158203
setProfile(existing);
159204
setPage("dashboard");
160205
} else {
161-
setPage("registration-choice");
206+
goRegister();
162207
}
163208
} catch (e) {
164209
if (e instanceof SessionExpiredError) {
@@ -179,7 +224,7 @@ export default function App() {
179224
setProfile(existing);
180225
setPage("dashboard");
181226
} else {
182-
setPage("registration-choice");
227+
goRegister();
183228
}
184229
} catch (e2) {
185230
setConnectError((e2 as Error).message);
@@ -193,7 +238,7 @@ export default function App() {
193238
);
194239
}
195240

196-
if (page === "registration-choice") {
241+
if (page === "registration-choice" && NON_CUSTODIAL_ENABLED) {
197242
return (
198243
<RegistrationChoicePage
199244
address={address}
@@ -210,14 +255,14 @@ export default function App() {
210255
address={address}
211256
pending={reg.pending}
212257
error={reg.error}
213-
onBack={() => setPage("registration-choice")}
258+
onBack={NON_CUSTODIAL_ENABLED ? () => setPage("registration-choice") : undefined}
214259
onRegister={handleRegisterCustodial}
215260
onDisconnect={handleDisconnect}
216261
/>
217262
);
218263
}
219264

220-
if (page === "noncustodial-install") {
265+
if (page === "noncustodial-install" && NON_CUSTODIAL_ENABLED) {
221266
return (
222267
<NonCustodialRegistrationPage
223268
address={address}
@@ -238,7 +283,7 @@ export default function App() {
238283
);
239284
}
240285

241-
if (page === "noncustodial-sign") {
286+
if (page === "noncustodial-sign" && NON_CUSTODIAL_ENABLED) {
242287
return (
243288
<NonCustodialRegistrationPage
244289
address={address}

‎packages/dapp/src/components/DashboardLayout.tsx‎

Lines changed: 2 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -68,27 +68,6 @@ function TransferIcon() {
6868
);
6969
}
7070

71-
function BridgeIcon() {
72-
return (
73-
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
74-
<path
75-
d="M3 8H15M12 5L15 8L12 11"
76-
stroke="currentColor"
77-
strokeWidth="1.6"
78-
strokeLinecap="round"
79-
strokeLinejoin="round"
80-
/>
81-
<path
82-
d="M17 12H5M8 15L5 12L8 9"
83-
stroke="currentColor"
84-
strokeWidth="1.6"
85-
strokeLinecap="round"
86-
strokeLinejoin="round"
87-
/>
88-
</svg>
89-
);
90-
}
91-
9271
function ActivityIcon() {
9372
return (
9473
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
@@ -102,7 +81,6 @@ const NAV = [
10281
{ id: "profile" as DashboardTab, label: "Profile", Icon: ProfileIcon },
10382
{ id: "balances" as DashboardTab, label: "Balances", Icon: BalancesIcon },
10483
{ id: "transfer" as DashboardTab, label: "Transfer", Icon: TransferIcon },
105-
{ id: "bridge", label: "Bridge", Icon: BridgeIcon, disabled: true },
10684
{ id: "activity" as DashboardTab, label: "Activity", Icon: ActivityIcon },
10785
];
10886

@@ -143,24 +121,14 @@ export function DashboardLayout({
143121
</div>
144122
<div className={styles.sidebarDivider} />
145123
<nav className={styles.sidebarNav}>
146-
{NAV.map(({ id, label, Icon, disabled }) => {
124+
{NAV.map(({ id, label, Icon }) => {
147125
const isActive = id === activeTab;
148-
const isDisabled = disabled === true;
149126
return (
150127
<button
151128
key={id}
152129
className={cn(styles.navItem, isActive && styles.navItemActive)}
153-
disabled={isDisabled}
154130
aria-current={isActive ? "page" : undefined}
155-
onClick={() => {
156-
if (
157-
id === "profile" ||
158-
id === "balances" ||
159-
id === "transfer" ||
160-
id === "activity"
161-
)
162-
onTabChange(id);
163-
}}
131+
onClick={() => onTabChange(id)}
164132
>
165133
<Icon />
166134
<span>{label}</span>

‎packages/dapp/src/hooks/useSnap.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useState, useEffect, useCallback } from "react";
22
import { installSnap, getInstalledSnap, invokeSnap } from "../lib/ethereum";
3+
import { NON_CUSTODIAL_ENABLED } from "../lib/features";
34

45
export interface SnapPublicKey {
56
compressedPubKey: string;
@@ -39,8 +40,13 @@ export function useSnap(): SnapState {
3940
const [version, setVersion] = useState<string | null>(null);
4041
const [error, setError] = useState<string | null>(null);
4142

42-
// Check if snap was installed from a previous session
43+
// Check if snap was installed from a previous session. Skip entirely when
44+
// the non-custodial flow is disabled: standard MetaMask rejects
45+
// wallet_getSnaps with RPC 4100, which is harmless (we catch it) but
46+
// pollutes the console and confuses MetaMask's stream middleware. Custodial
47+
// builds never need to know about the snap.
4348
useEffect(() => {
49+
if (!NON_CUSTODIAL_ENABLED) return;
4450
getInstalledSnap().then((snap) => {
4551
if (snap) {
4652
setInstalled(true);

‎packages/dapp/src/lib/ethereum.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,13 @@ export async function addEthChain(params: {
7878
await getEthereum().request({ method: "wallet_addEthereumChain", params: [params] });
7979
}
8080

81+
export async function switchEthChain(chainId: string): Promise<void> {
82+
await getEthereum().request({
83+
method: "wallet_switchEthereumChain",
84+
params: [{ chainId }],
85+
});
86+
}
87+
8188
export async function watchAsset(params: {
8289
address: string;
8390
symbol: string;

‎packages/dapp/src/lib/features.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**
2+
* Build-time feature flags. Read from Vite env so each deployment can opt in
3+
* without rebuilding the source.
4+
*
5+
* NON_CUSTODIAL_ENABLED — gates the registration-choice screen and the
6+
* snap-backed registration flow. v1.0 ships custodial-only (flag off); the
7+
* non-custodial code remains in the tree and is re-enabled by setting
8+
* VITE_ENABLE_NON_CUSTODIAL=true at build time. Account-aware UI (dashboard
9+
* pages, transfer flow) is unaffected because those branches off
10+
* profile.keyMode, not this flag.
11+
*/
12+
13+
export const NON_CUSTODIAL_ENABLED = import.meta.env.VITE_ENABLE_NON_CUSTODIAL === "true";

‎packages/dapp/src/lib/network.ts‎

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { addEthChain, getEthereum } from "./ethereum";
1+
import { addEthChain, getEthereum, switchEthChain } from "./ethereum";
22
import { ethChainId } from "./ethrpc";
33
import type { NetworkConfig } from "./config";
44

@@ -15,16 +15,39 @@ async function targetChainId(network: NetworkConfig): Promise<string> {
1515
return fetched;
1616
}
1717

18-
// Ensures MetaMask is on `network`'s chain, prompting the wallet_addEthereumChain
19-
// flow when it isn't. Returns the target chainId. Callers that need to dedupe
20-
// repeated prompts (e.g. after user rejection) should do so at the call site —
21-
// this helper is intentionally stateless beyond the chainId cache.
18+
// MetaMask error code for "chain has not been added to the wallet" (EIP-3326).
19+
const CHAIN_NOT_ADDED = 4902;
20+
21+
function errorCode(err: unknown): number | undefined {
22+
if (typeof err === "object" && err !== null && "code" in err) {
23+
const c = (err as { code: unknown }).code;
24+
return typeof c === "number" ? c : undefined;
25+
}
26+
return undefined;
27+
}
28+
29+
// Ensures MetaMask is on `network`'s chain. Uses the EIP-3326 switch-first
30+
// pattern: switch_chain succeeds when the chain is already added (regardless
31+
// of whether MM was using it before this call), and only falls back to
32+
// wallet_addEthereumChain when MM doesn't yet know the chain. Without this,
33+
// re-adding a chain whose nativeCurrency.symbol has drifted between dApp
34+
// versions surfaces as `-32602 nativeCurrency.symbol does not match ...`,
35+
// because wallet_addEthereumChain refuses to overwrite an existing chain's
36+
// metadata.
2237
export async function ensureChainAdded(network: NetworkConfig): Promise<string> {
2338
const target = await targetChainId(network);
2439
const current = (
2540
(await getEthereum().request({ method: "eth_chainId" })) as string
2641
).toLowerCase();
2742
if (current === target) return target;
43+
44+
try {
45+
await switchEthChain(target);
46+
return target;
47+
} catch (err) {
48+
if (errorCode(err) !== CHAIN_NOT_ADDED) throw err;
49+
}
50+
2851
await addEthChain({
2952
chainId: target,
3053
chainName: network.name,

‎packages/dapp/src/pages/CustodialRegistrationPage.tsx‎

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ interface Props {
1010
address: string;
1111
pending: boolean;
1212
error: string | null;
13-
onBack: () => void;
13+
// Optional: when omitted (custodial-only build), the back link is hidden
14+
// and the user can only cancel via disconnect.
15+
onBack?: () => void;
1416
onRegister: () => void;
1517
onDisconnect: () => void;
1618
}
@@ -51,11 +53,13 @@ export function CustodialRegistrationPage({
5153
<AmbientOrb opacity={0.14} size={840} y="62%" />
5254
<TopBar address={address} onDisconnect={onDisconnect} />
5355

54-
<div className={styles.backBar}>
55-
<button className="back-link" onClick={onBack}>
56-
← Back to registration
57-
</button>
58-
</div>
56+
{onBack && (
57+
<div className={styles.backBar}>
58+
<button className="back-link" onClick={onBack}>
59+
← Back to registration
60+
</button>
61+
</div>
62+
)}
5963

6064
<main className={styles.main}>
6165
<div className={cn("card", styles.card)}>
@@ -96,10 +100,15 @@ export function CustodialRegistrationPage({
96100

97101
{!error && (
98102
<p className={styles.waitingText}>
99-
Waiting for signature…{" "}
100-
<Button variant="ghost" onClick={onBack}>
101-
Cancel
102-
</Button>
103+
Waiting for signature…
104+
{onBack && (
105+
<>
106+
{" "}
107+
<Button variant="ghost" onClick={onBack}>
108+
Cancel
109+
</Button>
110+
</>
111+
)}
103112
</p>
104113
)}
105114
</div>

0 commit comments

Comments
 (0)