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 (
+
+
+
+ Wrong network. Currently on{' '}
+ {truncateIdentifier(wrongNetwork.wallet)}, switch to{' '}
+ {truncateIdentifier(wrongNetwork.app)}.
+
+
+ )
+}
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