From c7ea34e146e41905bd350716469d011b954e5541 Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:29:14 -0300 Subject: [PATCH 1/5] feat(wrong network): show topbar strip when the wrong network is selected --- dapp/frontend/architecture.md | 17 ++++++++-- .../src/backend/transferContext.test.ts | 18 +++++++++++ dapp/frontend/src/backend/transferContext.ts | 6 ++++ dapp/frontend/src/components/AppShell.tsx | 2 ++ dapp/frontend/src/components/WrongNetwork.tsx | 29 +++++++++++++++++ dapp/frontend/src/providers/Backend.tsx | 32 ++++++++++++++++++- dapp/frontend/src/utils/network.test.ts | 25 +++++++++++++++ dapp/frontend/src/utils/network.ts | 11 +++++++ 8 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 dapp/frontend/src/components/WrongNetwork.tsx create mode 100644 dapp/frontend/src/utils/network.test.ts create mode 100644 dapp/frontend/src/utils/network.ts diff --git a/dapp/frontend/architecture.md b/dapp/frontend/architecture.md index bf459659..c914654b 100644 --- a/dapp/frontend/architecture.md +++ b/dapp/frontend/architecture.md @@ -13,10 +13,10 @@ interfaces carry that, and every other decision hangs off them. | Path | Role | |------|------| | `src/backend/` | The `VestingBackend` interface, `LedgerBackend` (its one implementation), the pure ACS→domain mappers, the command builders, the `WalletFns` seam, `transferContext.ts`, which builds the Amulet context off wallet-service's `amulet.tap`, and `config.ts`, which loads the deployment. | -| `src/providers/` | `Backend`: builds the backend from the deployment plus the wallet session, and nothing else. The theme and token-list providers come from the kit, the session provider from `canton-connect`. | +| `src/providers/` | `Backend`: builds the backend from the deployment plus the wallet session, and reads the two synchronizer ids the network check compares. The theme and token-list providers come from the kit, the session provider from `canton-connect`. | | `src/hooks/` | `useParty` narrows the `canton-connect` session to what the UI needs, `useConnectErrorToast` gives a rejected connection somewhere to surface, and `useRoleLens` / `useCreateGrant` keep the role lens and the create dialog in the URL. `AppShell` keys React Router's `ScrollRestoration` on the pathname rather than on the default location key, so opening a grant starts at the top of the page while writing one of those params leaves the scroll where it was. | | `src/store/useVestingStore.ts` | Backend-backed zustand store; actions submit then refresh. | -| `src/utils/` | Pure helpers, `schedule.ts` chief among them, plus `env.ts`, the environment contract `vite.config.ts` validates against, `config.ts`, which reads the literals that validation left behind, and `tokens.tsx`, the one instrument this deployment knows. `toast.ts` is here too, the one module whose view lives elsewhere: it holds the Ark toaster and the three tone helpers, and `components/Toaster/` renders them. | +| `src/utils/` | Pure helpers, `schedule.ts` chief among them, plus `env.ts`, the environment contract `vite.config.ts` validates against, `config.ts`, which reads the literals that validation left behind, `network.ts`, the rule behind the wrong-network strip, and `tokens.tsx`, the one instrument this deployment knows. `toast.ts` is here too, the one module whose view lives elsewhere: it holds the Ark toaster and the three tone helpers, and `components/Toaster/` renders them. | | `src/components/` | What two or more places render: the shell, the top bar and its account menu, the footer, the dialogs, and the primitives the pages compose. | | `src/icons/` | The brand and house marks only, one per file over a shared `Svg` wrapper and re-exported from `index.ts`. Every generic icon comes from `lucide-react`. | | `src/pages/` | Dashboard, pending grants and grant detail, each a folder whose `index.tsx` is the route and whose siblings are what only that page renders. | @@ -57,6 +57,19 @@ together, because a write needs both and sending one without the other fails at rather than in the model. The record is flat: nesting the round under a `context` key fails preprocessing on a missing `openMiningRound`. +It returns one more thing no write reads: the `synchronizerId` wallet-service stamped on those +disclosures. That is the network the app's own contracts live on, and +[`config.ts`](src/backend/config.ts)'s `synchronizerId` is the network the connected wallet submits +to. When the two differ every write fails at the participant, because the `AmuletRules` and mining +round ids do not exist on the ledger the wallet reaches, so +[`WrongNetwork`](src/components/WrongNetwork.tsx) says so in a strip above the header and names both. +[`Backend`](src/providers/Backend.tsx) asks for a context once on connect for that comparison alone: +a write would read one too late to warn about, and tap submits nothing, so the answer costs a read. +A failed read stays silent — a wallet-service that is down is not a wrong network — and the rule in +[`src/utils/network.ts`](src/utils/network.ts) treats a missing id the same way, or a slow read would +warn about nothing. `party.networkId` is not what is compared: CIP-0103 only recommends a CAIP-2 +label, so two wallets may spell one network differently. + That call is a build-and-discard: `amulet.tap` returns the command it composed and submits nothing, so no coin is minted and the answer is only read for its disclosures. It replaced a pair of reads against Scan's unauthenticated API, which a browser cannot reach on devnet at all — the SV endpoints diff --git a/dapp/frontend/src/backend/transferContext.test.ts b/dapp/frontend/src/backend/transferContext.test.ts index 00f8eb22..22824c0a 100644 --- a/dapp/frontend/src/backend/transferContext.test.ts +++ b/dapp/frontend/src/backend/transferContext.test.ts @@ -63,6 +63,24 @@ describe('fetchTransferContext', () => { expect(rulesTemplateId).toBe('rulespkg:Splice.AmuletRules:AmuletRules') }) + it('returns the network wallet-service stamped on the disclosures', async () => { + stubTap([RULES, ROUND]) + + await expect(fetchTransferContext('funder::1')).resolves.toHaveProperty( + 'synchronizerId', + 'global-domain::1220', + ) + }) + + // Nothing in a write needs it, so an answer without one is not an error; it only leaves the + // network comparison with nothing to compare. + it('omits the network when the disclosures carry none', async () => { + const { synchronizerId: _dropped, ...bare } = RULES + stubTap([bare, ROUND]) + + await expect(fetchTransferContext('funder::1')).resolves.not.toHaveProperty('synchronizerId') + }) + // The url is the build's, so only the request is asserted on here. it('asks for a tap to the connected party', async () => { const { calls } = stubTap([RULES, ROUND]) diff --git a/dapp/frontend/src/backend/transferContext.ts b/dapp/frontend/src/backend/transferContext.ts index 827336e3..39b0eab2 100644 --- a/dapp/frontend/src/backend/transferContext.ts +++ b/dapp/frontend/src/backend/transferContext.ts @@ -48,6 +48,7 @@ export const fetchTransferContext = async ( ctx: AppTransferContext disclosed: DisclosedContract[] rulesTemplateId: string + synchronizerId?: string }> => { const result = (await rpc('amulet.tap', { receiver: party })) as TapResult const disclosures = result.disclosedContracts ?? [] @@ -73,5 +74,10 @@ export const fetchTransferContext = async ( })), // The split exercises AmuletRules directly, so it needs the resolved id the filters skip. rulesTemplateId: amuletRules.templateId, + // Returned beside the disclosures rather than left on them: this is the network wallet-service + // answered for, which is the only thing that can be compared against the wallet's own. + ...(amuletRules.synchronizerId === undefined + ? {} + : { synchronizerId: amuletRules.synchronizerId }), } } diff --git a/dapp/frontend/src/components/AppShell.tsx b/dapp/frontend/src/components/AppShell.tsx index 0556862a..36e50680 100644 --- a/dapp/frontend/src/components/AppShell.tsx +++ b/dapp/frontend/src/components/AppShell.tsx @@ -6,6 +6,7 @@ import { Footer } from '@/components/Footer' import { Loading } from '@/components/Loading' import { Toaster } from '@/components/Toaster' import { TopBar } from '@/components/TopBar' +import { WrongNetwork } from '@/components/WrongNetwork' import { useConnectErrorToast } from '@/hooks/useConnectErrorToast' import { useCreateGrant } from '@/hooks/useCreateGrant' import { useBackend } from '@/providers/Backend' @@ -38,6 +39,7 @@ export const AppShell = (): React.JSX.Element => { > Skip to main content +
{ + const { wrongNetwork } = useBackend() + + if (wrongNetwork === undefined) { + return null + } + + return ( +
+

+

+
+ ) +} diff --git a/dapp/frontend/src/providers/Backend.tsx b/dapp/frontend/src/providers/Backend.tsx index 57b75006..aa7e8211 100644 --- a/dapp/frontend/src/providers/Backend.tsx +++ b/dapp/frontend/src/providers/Backend.tsx @@ -2,8 +2,10 @@ import { useExecute, useLedger, useParty } from '@bootnodedev/canton-connect' import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from 'react' import { type Deployment, loadBackendConfig } from '@/backend/config' import { LedgerBackend } from '@/backend/LedgerBackend' +import { fetchTransferContext } from '@/backend/transferContext' import type { VestingBackend } from '@/backend/VestingBackend' import { errorText } from '@/utils/errorText' +import { type WrongNetwork, wrongNetwork } from '@/utils/network' // `backend` is undefined until a deployment is loaded and the wallet reports a party; both are // needed to reach the ledger, so pages render a connect placeholder rather than empty data. The @@ -14,6 +16,7 @@ export interface BackendState { configError: string | undefined configPending: boolean sessionPending: boolean + wrongNetwork: WrongNetwork | undefined } // canton-connect cannot say whether a restore is still in flight: its status sits at `idle` both @@ -35,6 +38,8 @@ export const Backend = ({ children }: { children: ReactNode }): React.JSX.Elemen // re-read the ACS whenever the wallet re-pushes the same account. const { party } = useParty() const hasParty = party !== undefined + const partyId = party?.partyId + const [appNetwork, setAppNetwork] = useState(undefined) const [checkingSession, setCheckingSession] = useState(true) useEffect(() => { @@ -69,6 +74,30 @@ export const Backend = ({ children }: { children: ReactNode }): React.JSX.Elemen } }, [hasParty, ledgerApi]) + // wallet-service stamps the network it answers for on every disclosure, and only a write would + // otherwise read one, which is too late to warn about. tap builds a command and submits nothing, + // so asking on connect costs a read. A failure is left silent: the write that needs a context + // reports its own, and a wallet-service that is merely down is not a wrong network. + useEffect(() => { + if (partyId === undefined) { + return + } + let cancelled = false + + void fetchTransferContext(partyId).then( + ({ synchronizerId }) => { + if (!cancelled) { + setAppNetwork(synchronizerId) + } + }, + () => undefined, + ) + + return () => { + cancelled = true + } + }, [partyId]) + // Its own memo, because the grace timer below flips a purely visual flag: sharing one would mint a // new backend identity mid-session and re-run every read that keys off it. const backend = useMemo( @@ -85,8 +114,9 @@ export const Backend = ({ children }: { children: ReactNode }): React.JSX.Elemen configPending: hasParty && deployment === undefined && configError === undefined, configError, sessionPending: checkingSession && !hasParty, + wrongNetwork: wrongNetwork(deployment?.synchronizerId, appNetwork), }), - [backend, checkingSession, configError, deployment, hasParty], + [appNetwork, backend, checkingSession, configError, deployment, hasParty], ) return {children} diff --git a/dapp/frontend/src/utils/network.test.ts b/dapp/frontend/src/utils/network.test.ts new file mode 100644 index 00000000..91d7afea --- /dev/null +++ b/dapp/frontend/src/utils/network.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { wrongNetwork } from '@/utils/network' + +const WALLET = 'global-domain::1220f4a17c9e2b6d80c3' +const APP = 'global-domain::12203c8fd51a9e7b2460' + +describe('wrongNetwork', () => { + it('reports nothing when both halves are on the same network', () => { + expect(wrongNetwork(WALLET, WALLET)).toBeUndefined() + }) + + it('names both networks when they differ', () => { + expect(wrongNetwork(WALLET, APP)).toEqual({ app: APP, wallet: WALLET }) + }) + + // A read that has not landed yet, or a factory row carrying no synchronizer id, must not warn: + // the two are not known to differ. + it.each([ + ['the wallet id', undefined, APP], + ['the app id', WALLET, undefined], + ['both ids', undefined, undefined], + ])('reports nothing while %s is missing', (_case, wallet, app) => { + expect(wrongNetwork(wallet, app)).toBeUndefined() + }) +}) diff --git a/dapp/frontend/src/utils/network.ts b/dapp/frontend/src/utils/network.ts new file mode 100644 index 00000000..69995316 --- /dev/null +++ b/dapp/frontend/src/utils/network.ts @@ -0,0 +1,11 @@ +// Which Canton network each half of the app is on. Both ids are read rather than configured, so +// either can be absent: an older factory row carries no `synchronizerId`, and the wallet-service +// read can fail. A missing half is not a mismatch, or a slow read would warn about nothing. + +export type WrongNetwork = { app: string; wallet: string } + +export const wrongNetwork = ( + wallet: string | undefined, + app: string | undefined, +): WrongNetwork | undefined => + wallet === undefined || app === undefined || wallet === app ? undefined : { app, wallet } From 2238d9a00a01ed0b3f0b1fb88ac961f079f5ed05 Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:41:35 -0300 Subject: [PATCH 2/5] fix(wrong network): re-read the networks so the strip tracks a wallet switch The strip only reflected what was true at connect. Nothing re-read either synchronizer id, so switching networks in the wallet left it stale, which made the warning useless in the case it exists for. - useWrongNetwork re-reads on three triggers: the party changing, the page regaining focus, and every 30 seconds. Focus catches a switch as it happens, because using the wallet takes focus off the page. visibilitychange misses it, since an extension popup draws over the tab rather than hiding it. - synchronizer.ts reads /v2/state/connected-synchronizers in one request. The wallet's side no longer comes from the factory row in config.ts: that read is three round trips and its answer rebuilds the backend, so polling it would re-run every ledger read. - wrongNetwork tests membership instead of equality. A participant can be connected to several synchronizers, and reaching the app's one is what decides whether a write lands. - A failed read stays silent and leaves the last answer standing. It has to poll: CIP-0103 defines no network-change event and the SDK pushes accounts only. --- dapp/frontend/architecture.md | 49 ++++++++++++------ dapp/frontend/src/backend/synchronizer.ts | 20 ++++++++ dapp/frontend/src/hooks/useWrongNetwork.ts | 58 ++++++++++++++++++++++ dapp/frontend/src/providers/Backend.tsx | 34 ++----------- dapp/frontend/src/utils/network.test.ts | 23 +++++---- dapp/frontend/src/utils/network.ts | 17 ++++--- 6 files changed, 141 insertions(+), 60 deletions(-) create mode 100644 dapp/frontend/src/backend/synchronizer.ts create mode 100644 dapp/frontend/src/hooks/useWrongNetwork.ts diff --git a/dapp/frontend/architecture.md b/dapp/frontend/architecture.md index c914654b..7a39ba1e 100644 --- a/dapp/frontend/architecture.md +++ b/dapp/frontend/architecture.md @@ -12,9 +12,9 @@ interfaces carry that, and every other decision hangs off them. | Path | Role | |------|------| -| `src/backend/` | The `VestingBackend` interface, `LedgerBackend` (its one implementation), the pure ACS→domain mappers, the command builders, the `WalletFns` seam, `transferContext.ts`, which builds the Amulet context off wallet-service's `amulet.tap`, and `config.ts`, which loads the deployment. | -| `src/providers/` | `Backend`: builds the backend from the deployment plus the wallet session, and reads the two synchronizer ids the network check compares. The theme and token-list providers come from the kit, the session provider from `canton-connect`. | -| `src/hooks/` | `useParty` narrows the `canton-connect` session to what the UI needs, `useConnectErrorToast` gives a rejected connection somewhere to surface, and `useRoleLens` / `useCreateGrant` keep the role lens and the create dialog in the URL. `AppShell` keys React Router's `ScrollRestoration` on the pathname rather than on the default location key, so opening a grant starts at the top of the page while writing one of those params leaves the scroll where it was. | +| `src/backend/` | The `VestingBackend` interface, `LedgerBackend` (its one implementation), the pure ACS→domain mappers, the command builders, the `WalletFns` seam, `transferContext.ts`, which builds the Amulet context off wallet-service's `amulet.tap`, `config.ts`, which loads the deployment, and `synchronizer.ts`, which reads the networks the wallet's participant is on. | +| `src/providers/` | `Backend`: builds the backend from the deployment plus the wallet session, and carries the wrong-network state alongside it. The theme and token-list providers come from the kit, the session provider from `canton-connect`. | +| `src/hooks/` | `useParty` narrows the `canton-connect` session to what the UI needs, `useConnectErrorToast` gives a rejected connection somewhere to surface, `useWrongNetwork` watches whether the wallet can still reach the app's network, and `useRoleLens` / `useCreateGrant` keep the role lens and the create dialog in the URL. `AppShell` keys React Router's `ScrollRestoration` on the pathname rather than on the default location key, so opening a grant starts at the top of the page while writing one of those params leaves the scroll where it was. | | `src/store/useVestingStore.ts` | Backend-backed zustand store; actions submit then refresh. | | `src/utils/` | Pure helpers, `schedule.ts` chief among them, plus `env.ts`, the environment contract `vite.config.ts` validates against, `config.ts`, which reads the literals that validation left behind, `network.ts`, the rule behind the wrong-network strip, and `tokens.tsx`, the one instrument this deployment knows. `toast.ts` is here too, the one module whose view lives elsewhere: it holds the Ark toaster and the three tone helpers, and `components/Toaster/` renders them. | | `src/components/` | What two or more places render: the shell, the top bar and its account menu, the footer, the dialogs, and the primitives the pages compose. | @@ -57,19 +57,6 @@ together, because a write needs both and sending one without the other fails at rather than in the model. The record is flat: nesting the round under a `context` key fails preprocessing on a missing `openMiningRound`. -It returns one more thing no write reads: the `synchronizerId` wallet-service stamped on those -disclosures. That is the network the app's own contracts live on, and -[`config.ts`](src/backend/config.ts)'s `synchronizerId` is the network the connected wallet submits -to. When the two differ every write fails at the participant, because the `AmuletRules` and mining -round ids do not exist on the ledger the wallet reaches, so -[`WrongNetwork`](src/components/WrongNetwork.tsx) says so in a strip above the header and names both. -[`Backend`](src/providers/Backend.tsx) asks for a context once on connect for that comparison alone: -a write would read one too late to warn about, and tap submits nothing, so the answer costs a read. -A failed read stays silent — a wallet-service that is down is not a wrong network — and the rule in -[`src/utils/network.ts`](src/utils/network.ts) treats a missing id the same way, or a slow read would -warn about nothing. `party.networkId` is not what is compared: CIP-0103 only recommends a CAIP-2 -label, so two wallets may spell one network differently. - That call is a build-and-discard: `amulet.tap` returns the command it composed and submits nothing, so no coin is minted and the answer is only read for its disclosures. It replaced a pair of reads against Scan's unauthenticated API, which a browser cannot reach on devnet at all — the SV endpoints @@ -97,6 +84,36 @@ The DSO party the split has to name is the one thing tap cannot supply — a dis opaque blob and no payload — so `LedgerBackend` reads it off an Amulet the split is about to consume. Every Amulet is DSO-signed, so it is the same party by construction. +## Telling the user they are on the wrong network + +A write fails at the participant when the wallet submits to a network the app's contracts do not +live on, because the `AmuletRules` and mining round ids do not exist on the ledger the wallet +reaches. Both sides of that are read. +[`transferContext.ts`](src/backend/transferContext.ts) returns one thing no write uses, the +`synchronizerId` wallet-service stamped on the disclosures, which is the network the app's contracts +are on. [`synchronizer.ts`](src/backend/synchronizer.ts) reads the other side, the synchronizers the +wallet's own participant is connected to. That is a read of its own rather than +[`config.ts`](src/backend/config.ts)'s `synchronizerId` off the factory row, which the deployment +already carries: that one is three round trips and its answer rebuilds the backend, so repeating it +would re-run every ledger read along with it. + +The rule in [`src/utils/network.ts`](src/utils/network.ts) is membership rather than equality, +because a participant can be connected to several synchronizers and reaching the app's one is what +decides whether a write lands. A missing side is not a mismatch, or the strip would warn about a read +that has not come back yet. `party.networkId` is not what is compared: CIP-0103 only recommends a +CAIP-2 label, so two wallets may spell one network differently. + +[`useWrongNetwork`](src/hooks/useWrongNetwork.ts) is what keeps it current, and it polls because a +wallet-side switch reaches the app through nothing at all: CIP-0103 defines no network-change event +and the SDK pushes accounts only. So it re-reads on three triggers — the party changing, the page +regaining focus, and every 30 seconds. Focus is the one that catches a switch as it happens, since +switching networks means using the wallet and the wallet takes focus; `visibilitychange` misses it, +because an extension popup draws over the tab rather than hiding it. The interval is the backstop for +a switch made in a window the user never comes back from. A failed read is silent and leaves the last +answer standing, because a wallet-service that is down, or a wallet that has just locked, is not a +wrong network. [`WrongNetwork`](src/components/WrongNetwork.tsx) renders the result as a strip above +the header naming both networks, and nothing dismisses it, because only the wallet can put it right. + ## Creating a grant takes two approvals A pending grant records the contract ids of the Amulets its Accept will lock, and that Accept diff --git a/dapp/frontend/src/backend/synchronizer.ts b/dapp/frontend/src/backend/synchronizer.ts new file mode 100644 index 00000000..9cc54795 --- /dev/null +++ b/dapp/frontend/src/backend/synchronizer.ts @@ -0,0 +1,20 @@ +// Which synchronizers the wallet's participant will submit to. Read on its own rather than off the +// factory row `config.ts` already carries: that one is three round trips and its answer rebuilds the +// backend, so repeating it to watch for a network switch would re-run every ledger read with it. + +import type { LedgerApi } from '@/backend/config' + +type ConnectedSynchronizers = { connectedSynchronizers?: { synchronizerId?: string }[] } + +export const walletSynchronizers = async ( + ledgerApi: LedgerApi, + party: string, +): Promise => { + const { connectedSynchronizers } = (await ledgerApi({ + requestMethod: 'get', + resource: `/v2/state/connected-synchronizers?party=${encodeURIComponent(party)}`, + })) as ConnectedSynchronizers + return (connectedSynchronizers ?? []) + .map((one) => one.synchronizerId) + .filter((id): id is string => id !== undefined) +} diff --git a/dapp/frontend/src/hooks/useWrongNetwork.ts b/dapp/frontend/src/hooks/useWrongNetwork.ts new file mode 100644 index 00000000..bfbb51e9 --- /dev/null +++ b/dapp/frontend/src/hooks/useWrongNetwork.ts @@ -0,0 +1,58 @@ +// Watches whether the connected wallet can still reach the app's network. A wallet-side switch +// reaches the app through nothing at all — CIP-0103 defines no network-change event and the SDK +// pushes only accounts — so the two ids are re-read rather than waited for. + +import { useEffect, useState } from 'react' +import type { LedgerApi } from '@/backend/config' +import { walletSynchronizers } from '@/backend/synchronizer' +import { fetchTransferContext } from '@/backend/transferContext' +import { type WrongNetwork, wrongNetwork } from '@/utils/network' + +// Backstop for a switch made in a window the user never comes back from. +const RECHECK_MS = 30_000 + +export const useWrongNetwork = ( + ledgerApi: LedgerApi, + partyId: string | undefined, +): WrongNetwork | undefined => { + const [mismatch, setMismatch] = useState(undefined) + + useEffect(() => { + if (partyId === undefined) { + setMismatch(undefined) + return + } + let cancelled = false + + const check = (): void => { + void Promise.all([ + walletSynchronizers(ledgerApi, partyId), + fetchTransferContext(partyId), + ]).then( + ([wallet, { synchronizerId }]) => { + if (!cancelled) { + setMismatch(wrongNetwork(wallet, synchronizerId)) + } + }, + // Either read failing says nothing about the network: a wallet-service that is down, or a + // wallet that has just locked, is not a wrong network. The last answer stands. + () => undefined, + ) + } + + check() + const timer = setInterval(check, RECHECK_MS) + // Switching networks means using the wallet, which takes focus, so coming back to the page is + // when a switch has just happened. `visibilitychange` misses it: an extension popup draws over + // the tab rather than hiding it. + window.addEventListener('focus', check) + + return () => { + cancelled = true + clearInterval(timer) + window.removeEventListener('focus', check) + } + }, [ledgerApi, partyId]) + + return mismatch +} diff --git a/dapp/frontend/src/providers/Backend.tsx b/dapp/frontend/src/providers/Backend.tsx index aa7e8211..6f9df975 100644 --- a/dapp/frontend/src/providers/Backend.tsx +++ b/dapp/frontend/src/providers/Backend.tsx @@ -2,10 +2,10 @@ import { useExecute, useLedger, useParty } from '@bootnodedev/canton-connect' import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from 'react' import { type Deployment, loadBackendConfig } from '@/backend/config' import { LedgerBackend } from '@/backend/LedgerBackend' -import { fetchTransferContext } from '@/backend/transferContext' import type { VestingBackend } from '@/backend/VestingBackend' +import { useWrongNetwork } from '@/hooks/useWrongNetwork' import { errorText } from '@/utils/errorText' -import { type WrongNetwork, wrongNetwork } from '@/utils/network' +import type { WrongNetwork } from '@/utils/network' // `backend` is undefined until a deployment is loaded and the wallet reports a party; both are // needed to reach the ledger, so pages render a connect placeholder rather than empty data. The @@ -39,8 +39,8 @@ export const Backend = ({ children }: { children: ReactNode }): React.JSX.Elemen const { party } = useParty() const hasParty = party !== undefined const partyId = party?.partyId - const [appNetwork, setAppNetwork] = useState(undefined) const [checkingSession, setCheckingSession] = useState(true) + const wrongNetwork = useWrongNetwork(ledgerApi, partyId) useEffect(() => { const timer = setTimeout(() => setCheckingSession(false), SESSION_GRACE_MS) @@ -74,30 +74,6 @@ export const Backend = ({ children }: { children: ReactNode }): React.JSX.Elemen } }, [hasParty, ledgerApi]) - // wallet-service stamps the network it answers for on every disclosure, and only a write would - // otherwise read one, which is too late to warn about. tap builds a command and submits nothing, - // so asking on connect costs a read. A failure is left silent: the write that needs a context - // reports its own, and a wallet-service that is merely down is not a wrong network. - useEffect(() => { - if (partyId === undefined) { - return - } - let cancelled = false - - void fetchTransferContext(partyId).then( - ({ synchronizerId }) => { - if (!cancelled) { - setAppNetwork(synchronizerId) - } - }, - () => undefined, - ) - - return () => { - cancelled = true - } - }, [partyId]) - // Its own memo, because the grace timer below flips a purely visual flag: sharing one would mint a // new backend identity mid-session and re-run every read that keys off it. const backend = useMemo( @@ -114,9 +90,9 @@ export const Backend = ({ children }: { children: ReactNode }): React.JSX.Elemen configPending: hasParty && deployment === undefined && configError === undefined, configError, sessionPending: checkingSession && !hasParty, - wrongNetwork: wrongNetwork(deployment?.synchronizerId, appNetwork), + wrongNetwork, }), - [appNetwork, backend, checkingSession, configError, deployment, hasParty], + [backend, checkingSession, configError, deployment, hasParty, wrongNetwork], ) return {children} diff --git a/dapp/frontend/src/utils/network.test.ts b/dapp/frontend/src/utils/network.test.ts index 91d7afea..c584e001 100644 --- a/dapp/frontend/src/utils/network.test.ts +++ b/dapp/frontend/src/utils/network.test.ts @@ -5,20 +5,25 @@ const WALLET = 'global-domain::1220f4a17c9e2b6d80c3' const APP = 'global-domain::12203c8fd51a9e7b2460' describe('wrongNetwork', () => { - it('reports nothing when both halves are on the same network', () => { - expect(wrongNetwork(WALLET, WALLET)).toBeUndefined() + it('reports nothing when the wallet is on the app network', () => { + expect(wrongNetwork([APP], APP)).toBeUndefined() }) - it('names both networks when they differ', () => { - expect(wrongNetwork(WALLET, APP)).toEqual({ app: APP, wallet: WALLET }) + it('names both networks when the wallet cannot reach the app one', () => { + expect(wrongNetwork([WALLET], APP)).toEqual({ app: APP, wallet: WALLET }) }) - // A read that has not landed yet, or a factory row carrying no synchronizer id, must not warn: - // the two are not known to differ. + // A participant on several synchronizers submits to the app's whichever one it lists first. + it('reports nothing when the app network is one of several the wallet has', () => { + expect(wrongNetwork([WALLET, APP], APP)).toBeUndefined() + }) + + // A read that has not landed yet, or an answer carrying no id, must not warn: the two are not + // known to differ. it.each([ - ['the wallet id', undefined, APP], - ['the app id', WALLET, undefined], - ['both ids', undefined, undefined], + ['the app id', [WALLET], undefined], + ['the wallet ids', [], APP], + ['both', [], undefined], ])('reports nothing while %s is missing', (_case, wallet, app) => { expect(wrongNetwork(wallet, app)).toBeUndefined() }) diff --git a/dapp/frontend/src/utils/network.ts b/dapp/frontend/src/utils/network.ts index 69995316..0477e950 100644 --- a/dapp/frontend/src/utils/network.ts +++ b/dapp/frontend/src/utils/network.ts @@ -1,11 +1,16 @@ -// Which Canton network each half of the app is on. Both ids are read rather than configured, so -// either can be absent: an older factory row carries no `synchronizerId`, and the wallet-service -// read can fail. A missing half is not a mismatch, or a slow read would warn about nothing. +// A participant can be connected to several synchronizers, so the wallet's side is a list and the +// rule is membership rather than equality: the app's network being one of them is what lets a write +// reach it. Either side can be missing, since both are read rather than configured, and a missing +// side is not a mismatch — the strip would warn about a read that has simply not landed. export type WrongNetwork = { app: string; wallet: string } export const wrongNetwork = ( - wallet: string | undefined, + wallet: readonly string[], app: string | undefined, -): WrongNetwork | undefined => - wallet === undefined || app === undefined || wallet === app ? undefined : { app, wallet } +): WrongNetwork | undefined => { + const submitsTo = wallet[0] + return app === undefined || submitsTo === undefined || wallet.includes(app) + ? undefined + : { app, wallet: submitsTo } +} From d55ab3cb5b68065be0a8ed9aa1c11abd1c4102e5 Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:25:41 -0300 Subject: [PATCH 3/5] fix(wrong network): name only the network the wallet reports The strip claimed to name a target network to switch to, and nothing can name one a reader could act on. CIP-0103 defines `networkId` for a wallet only, and the label wallet-service could supply from its NETWORK variable is typed by hand on both sides, so the two read the same for different networks as easily as differently for one. - The strip now reads "Wrong network: wallet is connected to bootnode-devnet, switch networks to proceed." It names the wallet's own label and no target, and drops the synchronizer ids, which were unreadable and told the user nothing they could act on. - networkLabel drops the CAIP-2 namespace, since every network this app sees is under canton. A label with no namespace stays whole. - wrongNetwork returns a boolean. Nothing rendered the ids any more, so the result payload went with them. - architecture.md records why there is no target label and that a wallet quiet about its network reads as canton:local. --- dapp/frontend/architecture.md | 23 ++++++++++++++-- dapp/frontend/src/components/WrongNetwork.tsx | 17 ++++++------ dapp/frontend/src/hooks/useWrongNetwork.ts | 11 +++----- dapp/frontend/src/providers/Backend.tsx | 3 +-- dapp/frontend/src/utils/network.test.ts | 27 ++++++++++++++----- dapp/frontend/src/utils/network.ts | 22 ++++++++------- 6 files changed, 67 insertions(+), 36 deletions(-) diff --git a/dapp/frontend/architecture.md b/dapp/frontend/architecture.md index 7a39ba1e..cf466897 100644 --- a/dapp/frontend/architecture.md +++ b/dapp/frontend/architecture.md @@ -103,6 +103,25 @@ decides whether a write lands. A missing side is not a mismatch, or the strip wo that has not come back yet. `party.networkId` is not what is compared: CIP-0103 only recommends a CAIP-2 label, so two wallets may spell one network differently. +The rule reports a verdict and not the ids behind it, because **the strip names the wallet's network +and no target.** That is a limit rather than a choice. `networkId` is the only network name CIP-0103 +defines — `Network` is `{ networkId, ledgerApi?, accessToken? }`, with no display name or alias — and +the spec says what a *wallet* answers, so nothing in it names the app's side. wallet-service does +expose a label of its own, `getActiveNetwork` off its `NETWORK` variable, and reaching it would take +allowing a second method in [`api/rpc.ts`](api/rpc.ts). It was not worth it: that value and the +wallet's are both typed by hand, by different people, so they read the same for two networks as +easily as differently for one, and a strip saying "switch to canton:localnet" while already claiming +to be on it is worse than one naming no target. Nothing checks either label against the id it claims +to name, and no single source knows both sides — the wallet only knows the network it is on, and +wallet-service only its own. + +One thing to know about the label that is shown: `CantonConnectProvider` defaults `networkId` to +`canton:local` where the wallet reports none, and nothing downstream can tell that default from a +real answer, so a wallet quiet about its network reads as local wherever it actually is. Only a +non-compliant wallet gets there — the spec makes `networkId` required on an account entry, and +canton-connect's own comment says the fallback exists for `createMockAdapter`. It can mislabel the +sentence but never decides whether the strip appears, which is what keeps it acceptable. + [`useWrongNetwork`](src/hooks/useWrongNetwork.ts) is what keeps it current, and it polls because a wallet-side switch reaches the app through nothing at all: CIP-0103 defines no network-change event and the SDK pushes accounts only. So it re-reads on three triggers — the party changing, the page @@ -111,8 +130,8 @@ switching networks means using the wallet and the wallet takes focus; `visibilit because an extension popup draws over the tab rather than hiding it. The interval is the backstop for a switch made in a window the user never comes back from. A failed read is silent and leaves the last answer standing, because a wallet-service that is down, or a wallet that has just locked, is not a -wrong network. [`WrongNetwork`](src/components/WrongNetwork.tsx) renders the result as a strip above -the header naming both networks, and nothing dismisses it, because only the wallet can put it right. +wrong network. [`WrongNetwork`](src/components/WrongNetwork.tsx) renders the verdict as a strip above +the header, and nothing dismisses it, because only the wallet can put it right. ## Creating a grant takes two approvals diff --git a/dapp/frontend/src/components/WrongNetwork.tsx b/dapp/frontend/src/components/WrongNetwork.tsx index b3f6a278..f816734b 100644 --- a/dapp/frontend/src/components/WrongNetwork.tsx +++ b/dapp/frontend/src/components/WrongNetwork.tsx @@ -1,15 +1,14 @@ -import { truncateIdentifier } from '@bootnodedev/canton-dappbooster' +import { useParty } from '@/hooks/useParty' import { useBackend } from '@/providers/Backend' - -// A shade under the sentence's `text-xs`, because the mono face reads larger at the same size. -const idClass = 'font-mono text-[0.72rem] font-bold' +import { networkLabel } from '@/utils/network' // Above the header rather than on a page: the mismatch outlives any route, and every write fails // while it stands. Nothing dismisses it, because only the wallet can put it right. export const WrongNetwork = (): React.JSX.Element | null => { const { wrongNetwork } = useBackend() + const { party } = useParty() - if (wrongNetwork === undefined) { + if (!wrongNetwork || party === undefined) { return null } @@ -20,9 +19,11 @@ export const WrongNetwork = (): React.JSX.Element | null => { aria-hidden="true" className="mr-2 inline-block size-[5px] rounded-full bg-warning align-middle" /> - Wrong network. Currently on{' '} - {truncateIdentifier(wrongNetwork.wallet)}, switch to{' '} - {truncateIdentifier(wrongNetwork.app)}. + {/* The wallet's own label is the only name worth showing: the app's network has none a + reader could act on, so the sentence points at the wallet instead of naming a target. */} + Wrong network: wallet is connected to{' '} + {networkLabel(party.networkId)}, switch networks to + proceed.

) diff --git a/dapp/frontend/src/hooks/useWrongNetwork.ts b/dapp/frontend/src/hooks/useWrongNetwork.ts index bfbb51e9..49825bcb 100644 --- a/dapp/frontend/src/hooks/useWrongNetwork.ts +++ b/dapp/frontend/src/hooks/useWrongNetwork.ts @@ -6,20 +6,17 @@ import { useEffect, useState } from 'react' import type { LedgerApi } from '@/backend/config' import { walletSynchronizers } from '@/backend/synchronizer' import { fetchTransferContext } from '@/backend/transferContext' -import { type WrongNetwork, wrongNetwork } from '@/utils/network' +import { wrongNetwork } from '@/utils/network' // Backstop for a switch made in a window the user never comes back from. const RECHECK_MS = 30_000 -export const useWrongNetwork = ( - ledgerApi: LedgerApi, - partyId: string | undefined, -): WrongNetwork | undefined => { - const [mismatch, setMismatch] = useState(undefined) +export const useWrongNetwork = (ledgerApi: LedgerApi, partyId: string | undefined): boolean => { + const [mismatch, setMismatch] = useState(false) useEffect(() => { if (partyId === undefined) { - setMismatch(undefined) + setMismatch(false) return } let cancelled = false diff --git a/dapp/frontend/src/providers/Backend.tsx b/dapp/frontend/src/providers/Backend.tsx index 6f9df975..1fbd2630 100644 --- a/dapp/frontend/src/providers/Backend.tsx +++ b/dapp/frontend/src/providers/Backend.tsx @@ -5,7 +5,6 @@ import { LedgerBackend } from '@/backend/LedgerBackend' import type { VestingBackend } from '@/backend/VestingBackend' import { useWrongNetwork } from '@/hooks/useWrongNetwork' import { errorText } from '@/utils/errorText' -import type { WrongNetwork } from '@/utils/network' // `backend` is undefined until a deployment is loaded and the wallet reports a party; both are // needed to reach the ledger, so pages render a connect placeholder rather than empty data. The @@ -16,7 +15,7 @@ export interface BackendState { configError: string | undefined configPending: boolean sessionPending: boolean - wrongNetwork: WrongNetwork | undefined + wrongNetwork: boolean } // canton-connect cannot say whether a restore is still in flight: its status sits at `idle` both diff --git a/dapp/frontend/src/utils/network.test.ts b/dapp/frontend/src/utils/network.test.ts index c584e001..af3842ac 100644 --- a/dapp/frontend/src/utils/network.test.ts +++ b/dapp/frontend/src/utils/network.test.ts @@ -1,21 +1,21 @@ import { describe, expect, it } from 'vitest' -import { wrongNetwork } from '@/utils/network' +import { networkLabel, wrongNetwork } from '@/utils/network' const WALLET = 'global-domain::1220f4a17c9e2b6d80c3' const APP = 'global-domain::12203c8fd51a9e7b2460' describe('wrongNetwork', () => { it('reports nothing when the wallet is on the app network', () => { - expect(wrongNetwork([APP], APP)).toBeUndefined() + expect(wrongNetwork([APP], APP)).toBe(false) }) - it('names both networks when the wallet cannot reach the app one', () => { - expect(wrongNetwork([WALLET], APP)).toEqual({ app: APP, wallet: WALLET }) + it('reports a mismatch when the wallet cannot reach the app network', () => { + expect(wrongNetwork([WALLET], APP)).toBe(true) }) - // A participant on several synchronizers submits to the app's whichever one it lists first. + // A participant on several synchronizers can still reach the app's, whichever one it lists first. it('reports nothing when the app network is one of several the wallet has', () => { - expect(wrongNetwork([WALLET, APP], APP)).toBeUndefined() + expect(wrongNetwork([WALLET, APP], APP)).toBe(false) }) // A read that has not landed yet, or an answer carrying no id, must not warn: the two are not @@ -25,6 +25,19 @@ describe('wrongNetwork', () => { ['the wallet ids', [], APP], ['both', [], undefined], ])('reports nothing while %s is missing', (_case, wallet, app) => { - expect(wrongNetwork(wallet, app)).toBeUndefined() + expect(wrongNetwork(wallet, app)).toBe(false) + }) +}) + +describe('networkLabel', () => { + it('drops the CAIP-2 namespace', () => { + expect(networkLabel('canton:bootnode-devnet')).toBe('bootnode-devnet') + }) + + it.each([ + ['no namespace', 'localnet', 'localnet'], + ['nothing after the colon', 'canton:', 'canton:'], + ])('keeps a label with %s whole', (_case, networkId, expected) => { + expect(networkLabel(networkId)).toBe(expected) }) }) diff --git a/dapp/frontend/src/utils/network.ts b/dapp/frontend/src/utils/network.ts index 0477e950..b34e2d0a 100644 --- a/dapp/frontend/src/utils/network.ts +++ b/dapp/frontend/src/utils/network.ts @@ -1,16 +1,18 @@ // A participant can be connected to several synchronizers, so the wallet's side is a list and the // rule is membership rather than equality: the app's network being one of them is what lets a write // reach it. Either side can be missing, since both are read rather than configured, and a missing -// side is not a mismatch — the strip would warn about a read that has simply not landed. +// side is not a mismatch — the strip would warn about a read that has not landed. -export type WrongNetwork = { app: string; wallet: string } +// A verdict and not the ids behind it, because nothing can name the app's network to a reader: +// wallet-service's label and the wallet's are typed by hand and by different people, so they can +// read the same for two networks or differently for one. The synchronizer ids decide this; only the +// wallet's own label is fit to show. +export const wrongNetwork = (wallet: readonly string[], app: string | undefined): boolean => + app !== undefined && wallet.length > 0 && !wallet.includes(app) -export const wrongNetwork = ( - wallet: readonly string[], - app: string | undefined, -): WrongNetwork | undefined => { - const submitsTo = wallet[0] - return app === undefined || submitsTo === undefined || wallet.includes(app) - ? undefined - : { app, wallet: submitsTo } +// CAIP-2 is `namespace:reference`, and the namespace is `canton` for every network this app can be +// pointed at, so it tells a reader nothing. Anything without one is shown whole. +export const networkLabel = (networkId: string): string => { + const reference = networkId.slice(networkId.indexOf(':') + 1) + return reference === '' ? networkId : reference } From 0cb837f684f90e80144e1113aae32696304908a2 Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:59:32 -0300 Subject: [PATCH 4/5] fix(wrong network): correct the read, the polling and the message The connected-synchronizers read put the party in the resource string. A wallet that checks the resource against the ledger API's own route list rejects that, and the hook swallows the failure, so the strip never appears. The party now travels in `query`, the way every other read passes one. Three fixes in `useWrongNetwork`: - Only the last check started may write, so a slow older read cannot overwrite a newer verdict. - The verdict carries the party it was read for, so the previous party's answer is not shown against the new one's network. - The app's synchronizer id is read once and kept. wallet-service answers for one network, so every later check is a single participant read rather than another `amulet.tap` every 30 seconds. On the wrong network `config.ts` finds no operator and throws its `run pnpm run bootstrap` advice, which filled the page with a symptom. The card now names the network, and the advice stays for a ledger that really has no deployment. Adds `synchronizer.test.ts`: the happy path, the request shape, the empty cases. --- dapp/frontend/architecture.md | 20 ++++++- .../frontend/src/backend/synchronizer.test.ts | 58 +++++++++++++++++++ dapp/frontend/src/backend/synchronizer.ts | 5 +- dapp/frontend/src/components/AppShell.tsx | 14 ++++- dapp/frontend/src/hooks/useWrongNetwork.ts | 35 +++++++---- 5 files changed, 115 insertions(+), 17 deletions(-) create mode 100644 dapp/frontend/src/backend/synchronizer.test.ts diff --git a/dapp/frontend/architecture.md b/dapp/frontend/architecture.md index cf466897..9fc9f611 100644 --- a/dapp/frontend/architecture.md +++ b/dapp/frontend/architecture.md @@ -130,8 +130,24 @@ switching networks means using the wallet and the wallet takes focus; `visibilit because an extension popup draws over the tab rather than hiding it. The interval is the backstop for a switch made in a window the user never comes back from. A failed read is silent and leaves the last answer standing, because a wallet-service that is down, or a wallet that has just locked, is not a -wrong network. [`WrongNetwork`](src/components/WrongNetwork.tsx) renders the verdict as a strip above -the header, and nothing dismisses it, because only the wallet can put it right. +wrong network. + +Only the wallet's side is on that poll. wallet-service answers for the one network its `NETWORK` +variable names, so the app's side is read once and kept, and every later check is a single read of +the wallet's participant rather than another `amulet.tap` through +[`api/rpc.ts`](api/rpc.ts). Two of those checks can still be in flight at once — a focus landing +mid-interval — so each carries a sequence number and only the last one started may write. The +verdict carries the party it was read for too, or the previous party's answer would be shown against +the new one's network for as long as the first read for that party takes. +[`WrongNetwork`](src/components/WrongNetwork.tsx) renders the verdict as a strip above the header, +and nothing dismisses it, because only the wallet can put it right. + +The verdict also decides what the page itself says. On the wrong network `config.ts` finds no +operator on the ledger the wallet reaches, so it throws its `run pnpm run bootstrap` advice and +[`AppShell`](src/components/AppShell.tsx) would fill the page with it. That message names a symptom: +the deployment is there, the wallet is not looking at it. So where the verdict stands, the card +carries the network instead and the advice is held back for the case it was written for, a ledger +that really has no deployment. ## Creating a grant takes two approvals diff --git a/dapp/frontend/src/backend/synchronizer.test.ts b/dapp/frontend/src/backend/synchronizer.test.ts new file mode 100644 index 00000000..1d46f3ad --- /dev/null +++ b/dapp/frontend/src/backend/synchronizer.test.ts @@ -0,0 +1,58 @@ +import type { LedgerApiParams } from '@bootnodedev/canton-connect' +import { describe, expect, it } from 'vitest' +import { walletSynchronizers } from '@/backend/synchronizer' + +const stubLedger = ( + answer: unknown, +): { calls: LedgerApiParams[]; ledgerApi: (params: LedgerApiParams) => Promise } => { + const calls: LedgerApiParams[] = [] + return { + calls, + ledgerApi: async (params) => { + calls.push(params) + return answer + }, + } +} + +describe('walletSynchronizers', () => { + it('returns every synchronizer the participant reports', async () => { + const { ledgerApi } = stubLedger({ + connectedSynchronizers: [ + { synchronizerAlias: 'global', synchronizerId: 'global-domain::1220a' }, + { synchronizerAlias: 'other', synchronizerId: 'other-domain::1220b' }, + ], + }) + + await expect(walletSynchronizers(ledgerApi, 'alice::1')).resolves.toEqual([ + 'global-domain::1220a', + 'other-domain::1220b', + ]) + }) + + // A wallet may allowlist the resource against the ledger API's own route list, which a path + // carrying a query string misses, so the party has to travel in `query`. + it('asks the route by name and passes the party as a query parameter', async () => { + const { calls, ledgerApi } = stubLedger({ connectedSynchronizers: [] }) + + await walletSynchronizers(ledgerApi, 'alice::1') + + expect(calls).toEqual([ + { + requestMethod: 'get', + resource: '/v2/state/connected-synchronizers', + query: { party: 'alice::1' }, + }, + ]) + }) + + it.each([ + ['the key is absent', {}], + ['the list is empty', { connectedSynchronizers: [] }], + ['an entry carries no id', { connectedSynchronizers: [{ synchronizerAlias: 'global' }] }], + ])('reports nothing when %s', async (_case, answer) => { + const { ledgerApi } = stubLedger(answer) + + await expect(walletSynchronizers(ledgerApi, 'alice::1')).resolves.toEqual([]) + }) +}) diff --git a/dapp/frontend/src/backend/synchronizer.ts b/dapp/frontend/src/backend/synchronizer.ts index 9cc54795..8d88b89a 100644 --- a/dapp/frontend/src/backend/synchronizer.ts +++ b/dapp/frontend/src/backend/synchronizer.ts @@ -10,9 +10,12 @@ export const walletSynchronizers = async ( ledgerApi: LedgerApi, party: string, ): Promise => { + // The party goes in `query`, never spelled into `resource`: a wallet is free to allowlist the + // resource against the ledger API's own route list, which a path carrying a query string misses. const { connectedSynchronizers } = (await ledgerApi({ requestMethod: 'get', - resource: `/v2/state/connected-synchronizers?party=${encodeURIComponent(party)}`, + resource: '/v2/state/connected-synchronizers', + query: { party }, })) as ConnectedSynchronizers return (connectedSynchronizers ?? []) .map((one) => one.synchronizerId) diff --git a/dapp/frontend/src/components/AppShell.tsx b/dapp/frontend/src/components/AppShell.tsx index 36e50680..060c8831 100644 --- a/dapp/frontend/src/components/AppShell.tsx +++ b/dapp/frontend/src/components/AppShell.tsx @@ -12,7 +12,7 @@ import { useCreateGrant } from '@/hooks/useCreateGrant' import { useBackend } from '@/providers/Backend' export const AppShell = (): React.JSX.Element => { - const { backend, configPending, configError, sessionPending } = useBackend() + const { backend, configPending, configError, sessionPending, wrongNetwork } = useBackend() // Mounted here rather than per page, because `?create=1` is route state: every page that offers // the action would otherwise repeat the mount, and a reader can open it from any of them. const [creating, setCreating] = useCreateGrant() @@ -46,10 +46,18 @@ export const AppShell = (): React.JSX.Element => { tabIndex={-1} className="mx-auto w-full max-w-6xl flex-1 overflow-x-clip px-5 py-8 sm:px-8" > + {/* On the wrong network the read fails for want of the operator, so `configError` names a + missing deployment and tells the reader to run a script. The network is the cause. */} {configError !== undefined && ( -

No deployment

-

{configError}

+

+ {wrongNetwork ? 'Wrong network' : 'No deployment'} +

+

+ {wrongNetwork + ? 'This app found nothing on the network the wallet is connected to. Switch networks in the wallet to load it.' + : configError} +

)} {configPending && } diff --git a/dapp/frontend/src/hooks/useWrongNetwork.ts b/dapp/frontend/src/hooks/useWrongNetwork.ts index 49825bcb..41fa409f 100644 --- a/dapp/frontend/src/hooks/useWrongNetwork.ts +++ b/dapp/frontend/src/hooks/useWrongNetwork.ts @@ -2,7 +2,7 @@ // reaches the app through nothing at all — CIP-0103 defines no network-change event and the SDK // pushes only accounts — so the two ids are re-read rather than waited for. -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import type { LedgerApi } from '@/backend/config' import { walletSynchronizers } from '@/backend/synchronizer' import { fetchTransferContext } from '@/backend/transferContext' @@ -11,24 +11,37 @@ import { wrongNetwork } from '@/utils/network' // Backstop for a switch made in a window the user never comes back from. const RECHECK_MS = 30_000 +// Carries the party it was read for, so the previous one's answer is not shown against the new +// one's network while the first read for that party is still out. +type Verdict = { party: string; wrong: boolean } + export const useWrongNetwork = (ledgerApi: LedgerApi, partyId: string | undefined): boolean => { - const [mismatch, setMismatch] = useState(false) + const [verdict, setVerdict] = useState(undefined) + // wallet-service answers for the one network its `NETWORK` variable names, for as long as it is + // up, so the app's side is read once and the poll re-reads only the wallet's. + const appNetwork = useRef(undefined) useEffect(() => { if (partyId === undefined) { - setMismatch(false) return } let cancelled = false + // Two checks can be in flight at once — a focus landing mid-interval — and they can answer out + // of order, so only the last one started is allowed to write. + let started = 0 const check = (): void => { - void Promise.all([ - walletSynchronizers(ledgerApi, partyId), - fetchTransferContext(partyId), - ]).then( - ([wallet, { synchronizerId }]) => { - if (!cancelled) { - setMismatch(wrongNetwork(wallet, synchronizerId)) + const seq = ++started + const app = + appNetwork.current === undefined + ? fetchTransferContext(partyId).then(({ synchronizerId }) => synchronizerId) + : Promise.resolve(appNetwork.current) + + void Promise.all([walletSynchronizers(ledgerApi, partyId), app]).then( + ([wallet, network]) => { + appNetwork.current = network + if (!cancelled && seq === started) { + setVerdict({ party: partyId, wrong: wrongNetwork(wallet, network) }) } }, // Either read failing says nothing about the network: a wallet-service that is down, or a @@ -51,5 +64,5 @@ export const useWrongNetwork = (ledgerApi: LedgerApi, partyId: string | undefine } }, [ledgerApi, partyId]) - return mismatch + return verdict !== undefined && verdict.party === partyId && verdict.wrong } From 271ae9d8f9f46f0add1cc498a3ab2b703aab90bb Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:21:39 -0300 Subject: [PATCH 5/5] fix(wrong network): read the app's network without waiting for a round Split `fetchAppNetwork` out of `fetchTransferContext`. That builder needs an AmuletRules and an open mining round both, and the SV opens the first round minutes after a LocalNet start, so the check could not answer until then. The network id sits on the AmuletRules disclosure alone. Also: - Box the cached app network, so an answer carrying no id counts as read. Before, the ref held the bare id, so every later check re-ran the tap. - Read the wallet's synchronizers through `call` in config.ts instead of a second inline cast. - Simplify `networkLabel` to one expression and drop a redundant test row. - Move the rule about passing a filter in `query` into dapp/frontend/CLAUDE.md, where the next author looks for it. - Trim comments that restated the code or copied architecture.md. --- dapp/frontend/CLAUDE.md | 8 ++++ dapp/frontend/architecture.md | 9 ++-- dapp/frontend/src/backend/config.ts | 2 +- .../frontend/src/backend/synchronizer.test.ts | 7 +-- dapp/frontend/src/backend/synchronizer.ts | 13 ++--- .../src/backend/transferContext.test.ts | 47 +++++++++++-------- dapp/frontend/src/backend/transferContext.ts | 18 ++++--- dapp/frontend/src/components/AppShell.tsx | 4 +- dapp/frontend/src/components/WrongNetwork.tsx | 2 - dapp/frontend/src/hooks/useWrongNetwork.ts | 14 +++--- dapp/frontend/src/utils/network.test.ts | 1 - dapp/frontend/src/utils/network.ts | 11 ++--- 12 files changed, 72 insertions(+), 64 deletions(-) diff --git a/dapp/frontend/CLAUDE.md b/dapp/frontend/CLAUDE.md index 9a022fc2..0d575735 100644 --- a/dapp/frontend/CLAUDE.md +++ b/dapp/frontend/CLAUDE.md @@ -72,6 +72,14 @@ One implementation each, so a second one is a bug and not a choice: Text takes `text-primary-strong`, `text-accent-strong` or `bg-pink-strong`, defined per theme to clear AA; the plain tokens stay for fills, borders and gradients. +## Ledger reads + +- **A read goes through `call` in [`backend/config.ts`](src/backend/config.ts),** which is the one + place the untyped `ledgerApi` answer is cast. A second inline `as` is a duplicated type. +- **A filter travels in `query`, never spelled into `resource` as a query string.** A wallet is free + to allowlist the resource against the ledger API's own route list, which a path carrying `?…` + misses. A route's own path segments still interpolate (`/v2/users/${id}/rights`). + ## Naming - No name repeats what its folder, its parent, or its own markup already says. `Claim`, not diff --git a/dapp/frontend/architecture.md b/dapp/frontend/architecture.md index 9fc9f611..ec20fb80 100644 --- a/dapp/frontend/architecture.md +++ b/dapp/frontend/architecture.md @@ -89,9 +89,12 @@ consume. Every Amulet is DSO-signed, so it is the same party by construction. A write fails at the participant when the wallet submits to a network the app's contracts do not live on, because the `AmuletRules` and mining round ids do not exist on the ledger the wallet reaches. Both sides of that are read. -[`transferContext.ts`](src/backend/transferContext.ts) returns one thing no write uses, the -`synchronizerId` wallet-service stamped on the disclosures, which is the network the app's contracts -are on. [`synchronizer.ts`](src/backend/synchronizer.ts) reads the other side, the synchronizers the +[`transferContext.ts`](src/backend/transferContext.ts) carries `fetchAppNetwork`, which taps and +returns only the `synchronizerId` wallet-service stamped on the disclosures, the network the app's +contracts are on. Its own export rather than a field on the transfer context: that builder waits for +an `AmuletRules` and an open mining round both, and the SV opens the first round minutes after a +LocalNet start, while the id sits on the rules alone. +[`synchronizer.ts`](src/backend/synchronizer.ts) reads the other side, the synchronizers the wallet's own participant is connected to. That is a read of its own rather than [`config.ts`](src/backend/config.ts)'s `synchronizerId` off the factory row, which the deployment already carries: that one is three round trips and its answer rebuilds the backend, so repeating it diff --git a/dapp/frontend/src/backend/config.ts b/dapp/frontend/src/backend/config.ts index 6d8d60dc..0ebaebb3 100644 --- a/dapp/frontend/src/backend/config.ts +++ b/dapp/frontend/src/backend/config.ts @@ -19,7 +19,7 @@ const OPERATOR_HINT = 'vesting-operator-' const advice = (reason: string): Error => new Error(`${reason} — run pnpm run bootstrap`) -const call = async (ledgerApi: LedgerApi, params: LedgerApiParams): Promise => +export const call = async (ledgerApi: LedgerApi, params: LedgerApiParams): Promise => (await ledgerApi(params)) as T type ActiveContract = { diff --git a/dapp/frontend/src/backend/synchronizer.test.ts b/dapp/frontend/src/backend/synchronizer.test.ts index 1d46f3ad..58d58e0e 100644 --- a/dapp/frontend/src/backend/synchronizer.test.ts +++ b/dapp/frontend/src/backend/synchronizer.test.ts @@ -1,10 +1,9 @@ import type { LedgerApiParams } from '@bootnodedev/canton-connect' import { describe, expect, it } from 'vitest' +import type { LedgerApi } from '@/backend/config' import { walletSynchronizers } from '@/backend/synchronizer' -const stubLedger = ( - answer: unknown, -): { calls: LedgerApiParams[]; ledgerApi: (params: LedgerApiParams) => Promise } => { +const stubLedger = (answer: unknown): { calls: LedgerApiParams[]; ledgerApi: LedgerApi } => { const calls: LedgerApiParams[] = [] return { calls, @@ -30,8 +29,6 @@ describe('walletSynchronizers', () => { ]) }) - // A wallet may allowlist the resource against the ledger API's own route list, which a path - // carrying a query string misses, so the party has to travel in `query`. it('asks the route by name and passes the party as a query parameter', async () => { const { calls, ledgerApi } = stubLedger({ connectedSynchronizers: [] }) diff --git a/dapp/frontend/src/backend/synchronizer.ts b/dapp/frontend/src/backend/synchronizer.ts index 8d88b89a..9ed64d8c 100644 --- a/dapp/frontend/src/backend/synchronizer.ts +++ b/dapp/frontend/src/backend/synchronizer.ts @@ -1,8 +1,7 @@ -// Which synchronizers the wallet's participant will submit to. Read on its own rather than off the -// factory row `config.ts` already carries: that one is three round trips and its answer rebuilds the -// backend, so repeating it to watch for a network switch would re-run every ledger read with it. +// Which synchronizers the wallet's participant will submit to. Read on its own rather than off +// `config.ts`'s factory row, whose answer rebuilds the backend and would re-run every ledger read. -import type { LedgerApi } from '@/backend/config' +import { call, type LedgerApi } from '@/backend/config' type ConnectedSynchronizers = { connectedSynchronizers?: { synchronizerId?: string }[] } @@ -10,13 +9,11 @@ export const walletSynchronizers = async ( ledgerApi: LedgerApi, party: string, ): Promise => { - // The party goes in `query`, never spelled into `resource`: a wallet is free to allowlist the - // resource against the ledger API's own route list, which a path carrying a query string misses. - const { connectedSynchronizers } = (await ledgerApi({ + const { connectedSynchronizers } = await call(ledgerApi, { requestMethod: 'get', resource: '/v2/state/connected-synchronizers', query: { party }, - })) as ConnectedSynchronizers + }) return (connectedSynchronizers ?? []) .map((one) => one.synchronizerId) .filter((id): id is string => id !== undefined) diff --git a/dapp/frontend/src/backend/transferContext.test.ts b/dapp/frontend/src/backend/transferContext.test.ts index 22824c0a..53e97116 100644 --- a/dapp/frontend/src/backend/transferContext.test.ts +++ b/dapp/frontend/src/backend/transferContext.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { fetchTransferContext } from '@/backend/transferContext' +import { fetchAppNetwork, fetchTransferContext } from '@/backend/transferContext' const disclosure = (templateId: string, contractId: string): Record => ({ templateId, @@ -63,24 +63,6 @@ describe('fetchTransferContext', () => { expect(rulesTemplateId).toBe('rulespkg:Splice.AmuletRules:AmuletRules') }) - it('returns the network wallet-service stamped on the disclosures', async () => { - stubTap([RULES, ROUND]) - - await expect(fetchTransferContext('funder::1')).resolves.toHaveProperty( - 'synchronizerId', - 'global-domain::1220', - ) - }) - - // Nothing in a write needs it, so an answer without one is not an error; it only leaves the - // network comparison with nothing to compare. - it('omits the network when the disclosures carry none', async () => { - const { synchronizerId: _dropped, ...bare } = RULES - stubTap([bare, ROUND]) - - await expect(fetchTransferContext('funder::1')).resolves.not.toHaveProperty('synchronizerId') - }) - // The url is the build's, so only the request is asserted on here. it('asks for a tap to the connected party', async () => { const { calls } = stubTap([RULES, ROUND]) @@ -161,3 +143,30 @@ describe('fetchTransferContext', () => { ) }) }) + +describe('fetchAppNetwork', () => { + it('reads the network wallet-service stamped on the AmuletRules disclosure', async () => { + stubTap([RULES, ROUND]) + + await expect(fetchAppNetwork('funder::1')).resolves.toBe('global-domain::1220') + }) + + // The id sits on the rules alone, and the SV opens the first round minutes after a LocalNet + // start, so waiting for one the way `fetchTransferContext` must would answer nothing until then. + it('answers before the SV has opened a round', async () => { + stubTap([RULES]) + + await expect(fetchAppNetwork('funder::1')).resolves.toBe('global-domain::1220') + }) + + const { synchronizerId: _dropped, ...RULES_WITHOUT_NETWORK } = RULES + + it.each([ + ['no AmuletRules is disclosed', [ROUND]], + ['the disclosure carries no network', [RULES_WITHOUT_NETWORK]], + ])('reports nothing when %s', async (_case, disclosures) => { + stubTap(disclosures) + + await expect(fetchAppNetwork('funder::1')).resolves.toBeUndefined() + }) +}) diff --git a/dapp/frontend/src/backend/transferContext.ts b/dapp/frontend/src/backend/transferContext.ts index 39b0eab2..7ffe2957 100644 --- a/dapp/frontend/src/backend/transferContext.ts +++ b/dapp/frontend/src/backend/transferContext.ts @@ -19,6 +19,8 @@ export type AppTransferContext = { openMiningRound: string } +const AMULET_RULES = ':Splice.AmuletRules:AmuletRules' + // Suffix match: the resolved package id differs per network and, on devnet, between entries. const byTemplate = (disclosures: DisclosedContract[], entity: string): DisclosedContract[] => disclosures.filter((disclosure) => disclosure.templateId.endsWith(entity)) @@ -42,17 +44,24 @@ const rpc = async (method: string, params: Record): Promise => { + const result = (await rpc('amulet.tap', { receiver: party })) as TapResult + return byTemplate(result.disclosedContracts ?? [], AMULET_RULES).at(0)?.synchronizerId +} + export const fetchTransferContext = async ( party: string, ): Promise<{ ctx: AppTransferContext disclosed: DisclosedContract[] rulesTemplateId: string - synchronizerId?: string }> => { const result = (await rpc('amulet.tap', { receiver: party })) as TapResult const disclosures = result.disclosedContracts ?? [] - const amuletRules = byTemplate(disclosures, ':Splice.AmuletRules:AmuletRules').at(0) + const amuletRules = byTemplate(disclosures, AMULET_RULES).at(0) const rounds = byTemplate(disclosures, ':Splice.Round:OpenMiningRound') const chosen = result.commands?.ExerciseCommand?.choiceArgument?.openRound const round = rounds.find((one) => one.contractId === chosen) ?? rounds.at(0) @@ -74,10 +83,5 @@ export const fetchTransferContext = async ( })), // The split exercises AmuletRules directly, so it needs the resolved id the filters skip. rulesTemplateId: amuletRules.templateId, - // Returned beside the disclosures rather than left on them: this is the network wallet-service - // answered for, which is the only thing that can be compared against the wallet's own. - ...(amuletRules.synchronizerId === undefined - ? {} - : { synchronizerId: amuletRules.synchronizerId }), } } diff --git a/dapp/frontend/src/components/AppShell.tsx b/dapp/frontend/src/components/AppShell.tsx index 060c8831..9cf404c3 100644 --- a/dapp/frontend/src/components/AppShell.tsx +++ b/dapp/frontend/src/components/AppShell.tsx @@ -46,8 +46,8 @@ export const AppShell = (): React.JSX.Element => { tabIndex={-1} className="mx-auto w-full max-w-6xl flex-1 overflow-x-clip px-5 py-8 sm:px-8" > - {/* On the wrong network the read fails for want of the operator, so `configError` names a - missing deployment and tells the reader to run a script. The network is the cause. */} + {/* On the wrong network `configError` names a missing deployment, but the network is the + cause, so the script it advises would not help. */} {configError !== undefined && (

diff --git a/dapp/frontend/src/components/WrongNetwork.tsx b/dapp/frontend/src/components/WrongNetwork.tsx index f816734b..cb5c9052 100644 --- a/dapp/frontend/src/components/WrongNetwork.tsx +++ b/dapp/frontend/src/components/WrongNetwork.tsx @@ -19,8 +19,6 @@ export const WrongNetwork = (): React.JSX.Element | null => { aria-hidden="true" className="mr-2 inline-block size-[5px] rounded-full bg-warning align-middle" /> - {/* The wallet's own label is the only name worth showing: the app's network has none a - reader could act on, so the sentence points at the wallet instead of naming a target. */} Wrong network: wallet is connected to{' '} {networkLabel(party.networkId)}, switch networks to proceed. diff --git a/dapp/frontend/src/hooks/useWrongNetwork.ts b/dapp/frontend/src/hooks/useWrongNetwork.ts index 41fa409f..0407ea73 100644 --- a/dapp/frontend/src/hooks/useWrongNetwork.ts +++ b/dapp/frontend/src/hooks/useWrongNetwork.ts @@ -5,7 +5,7 @@ import { useEffect, useRef, useState } from 'react' import type { LedgerApi } from '@/backend/config' import { walletSynchronizers } from '@/backend/synchronizer' -import { fetchTransferContext } from '@/backend/transferContext' +import { fetchAppNetwork } from '@/backend/transferContext' import { wrongNetwork } from '@/utils/network' // Backstop for a switch made in a window the user never comes back from. @@ -18,8 +18,9 @@ type Verdict = { party: string; wrong: boolean } export const useWrongNetwork = (ledgerApi: LedgerApi, partyId: string | undefined): boolean => { const [verdict, setVerdict] = useState(undefined) // wallet-service answers for the one network its `NETWORK` variable names, for as long as it is - // up, so the app's side is read once and the poll re-reads only the wallet's. - const appNetwork = useRef(undefined) + // up, so the app's side is read once and the poll re-reads only the wallet's. Boxed so that an + // answer carrying no id counts as read; the bare id would re-tap on every check. + const appNetwork = useRef<{ id: string | undefined } | undefined>(undefined) useEffect(() => { if (partyId === undefined) { @@ -32,16 +33,13 @@ export const useWrongNetwork = (ledgerApi: LedgerApi, partyId: string | undefine const check = (): void => { const seq = ++started - const app = - appNetwork.current === undefined - ? fetchTransferContext(partyId).then(({ synchronizerId }) => synchronizerId) - : Promise.resolve(appNetwork.current) + const app = appNetwork.current ?? fetchAppNetwork(partyId).then((id) => ({ id })) void Promise.all([walletSynchronizers(ledgerApi, partyId), app]).then( ([wallet, network]) => { appNetwork.current = network if (!cancelled && seq === started) { - setVerdict({ party: partyId, wrong: wrongNetwork(wallet, network) }) + setVerdict({ party: partyId, wrong: wrongNetwork(wallet, network.id) }) } }, // Either read failing says nothing about the network: a wallet-service that is down, or a diff --git a/dapp/frontend/src/utils/network.test.ts b/dapp/frontend/src/utils/network.test.ts index af3842ac..3356c899 100644 --- a/dapp/frontend/src/utils/network.test.ts +++ b/dapp/frontend/src/utils/network.test.ts @@ -23,7 +23,6 @@ describe('wrongNetwork', () => { it.each([ ['the app id', [WALLET], undefined], ['the wallet ids', [], APP], - ['both', [], undefined], ])('reports nothing while %s is missing', (_case, wallet, app) => { expect(wrongNetwork(wallet, app)).toBe(false) }) diff --git a/dapp/frontend/src/utils/network.ts b/dapp/frontend/src/utils/network.ts index b34e2d0a..c49a65f3 100644 --- a/dapp/frontend/src/utils/network.ts +++ b/dapp/frontend/src/utils/network.ts @@ -3,16 +3,11 @@ // reach it. Either side can be missing, since both are read rather than configured, and a missing // side is not a mismatch — the strip would warn about a read that has not landed. -// A verdict and not the ids behind it, because nothing can name the app's network to a reader: -// wallet-service's label and the wallet's are typed by hand and by different people, so they can -// read the same for two networks or differently for one. The synchronizer ids decide this; only the -// wallet's own label is fit to show. +// Ids, not labels: two wallets may spell one network differently. export const wrongNetwork = (wallet: readonly string[], app: string | undefined): boolean => app !== undefined && wallet.length > 0 && !wallet.includes(app) // CAIP-2 is `namespace:reference`, and the namespace is `canton` for every network this app can be // pointed at, so it tells a reader nothing. Anything without one is shown whole. -export const networkLabel = (networkId: string): string => { - const reference = networkId.slice(networkId.indexOf(':') + 1) - return reference === '' ? networkId : reference -} +export const networkLabel = (networkId: string): string => + networkId.slice(networkId.indexOf(':') + 1) || networkId