diff --git a/dapp/frontend/CLAUDE.md b/dapp/frontend/CLAUDE.md index 89ca2391..07581f7c 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 a7d72be7..7096024d 100644 --- a/dapp/frontend/architecture.md +++ b/dapp/frontend/architecture.md @@ -12,11 +12,11 @@ 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; `Tokens` builds the token list from every source and hands it to the kit's `TokenListProvider`, which is why it sits inside `Backend`: Canton Coin's figures are the backend's to report. The theme provider comes 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; `Tokens` builds the token list from every source and hands it to the kit's `TokenListProvider`, which is why it sits inside `Backend`: Canton Coin's figures are the backend's to report. The theme provider comes 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, `tokens.tsx`, the artwork and wording this deployment gives Canton Coin, and `assetList.ts`, which reads the curated token list. `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, `tokens.tsx`, the artwork and wording this deployment gives Canton Coin, and `assetList.ts`, which reads the curated token list. `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. | @@ -84,6 +84,74 @@ 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) 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 +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. + +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 +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. + +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 A pending grant records the contract ids of the Amulets its Accept will lock, and that Accept 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 new file mode 100644 index 00000000..58d58e0e --- /dev/null +++ b/dapp/frontend/src/backend/synchronizer.test.ts @@ -0,0 +1,55 @@ +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: LedgerApi } => { + 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', + ]) + }) + + 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 new file mode 100644 index 00000000..9ed64d8c --- /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 +// `config.ts`'s factory row, whose answer rebuilds the backend and would re-run every ledger read. + +import { call, type LedgerApi } from '@/backend/config' + +type ConnectedSynchronizers = { connectedSynchronizers?: { synchronizerId?: string }[] } + +export const walletSynchronizers = async ( + ledgerApi: LedgerApi, + party: string, +): Promise => { + const { connectedSynchronizers } = await call(ledgerApi, { + requestMethod: 'get', + resource: '/v2/state/connected-synchronizers', + query: { party }, + }) + 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 00f8eb22..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, @@ -143,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 827336e3..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,6 +44,14 @@ 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<{ @@ -51,7 +61,7 @@ export const fetchTransferContext = async ( }> => { 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) diff --git a/dapp/frontend/src/components/AppShell.tsx b/dapp/frontend/src/components/AppShell.tsx index 0556862a..9cf404c3 100644 --- a/dapp/frontend/src/components/AppShell.tsx +++ b/dapp/frontend/src/components/AppShell.tsx @@ -6,12 +6,13 @@ 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' 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() @@ -38,16 +39,25 @@ export const AppShell = (): React.JSX.Element => { > Skip to main content +
+ {/* 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 && ( -

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/components/WrongNetwork.tsx b/dapp/frontend/src/components/WrongNetwork.tsx new file mode 100644 index 00000000..cb5c9052 --- /dev/null +++ b/dapp/frontend/src/components/WrongNetwork.tsx @@ -0,0 +1,28 @@ +import { useParty } from '@/hooks/useParty' +import { useBackend } from '@/providers/Backend' +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 || party === undefined) { + return null + } + + return ( +
+

+

+
+ ) +} diff --git a/dapp/frontend/src/hooks/useWrongNetwork.ts b/dapp/frontend/src/hooks/useWrongNetwork.ts new file mode 100644 index 00000000..0407ea73 --- /dev/null +++ b/dapp/frontend/src/hooks/useWrongNetwork.ts @@ -0,0 +1,66 @@ +// 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, useRef, useState } from 'react' +import type { LedgerApi } from '@/backend/config' +import { walletSynchronizers } from '@/backend/synchronizer' +import { fetchAppNetwork } from '@/backend/transferContext' +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 [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. 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) { + 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 => { + const seq = ++started + 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.id) }) + } + }, + // 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 verdict !== undefined && verdict.party === partyId && verdict.wrong +} diff --git a/dapp/frontend/src/providers/Backend.tsx b/dapp/frontend/src/providers/Backend.tsx index 57b75006..1fbd2630 100644 --- a/dapp/frontend/src/providers/Backend.tsx +++ b/dapp/frontend/src/providers/Backend.tsx @@ -3,6 +3,7 @@ import { createContext, type ReactNode, useContext, useEffect, useMemo, useState import { type Deployment, loadBackendConfig } from '@/backend/config' import { LedgerBackend } from '@/backend/LedgerBackend' import type { VestingBackend } from '@/backend/VestingBackend' +import { useWrongNetwork } from '@/hooks/useWrongNetwork' import { errorText } from '@/utils/errorText' // `backend` is undefined until a deployment is loaded and the wallet reports a party; both are @@ -14,6 +15,7 @@ export interface BackendState { configError: string | undefined configPending: boolean sessionPending: boolean + wrongNetwork: boolean } // canton-connect cannot say whether a restore is still in flight: its status sits at `idle` both @@ -35,7 +37,9 @@ 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 [checkingSession, setCheckingSession] = useState(true) + const wrongNetwork = useWrongNetwork(ledgerApi, partyId) useEffect(() => { const timer = setTimeout(() => setCheckingSession(false), SESSION_GRACE_MS) @@ -85,8 +89,9 @@ export const Backend = ({ children }: { children: ReactNode }): React.JSX.Elemen configPending: hasParty && deployment === undefined && configError === undefined, configError, sessionPending: checkingSession && !hasParty, + wrongNetwork, }), - [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 new file mode 100644 index 00000000..3356c899 --- /dev/null +++ b/dapp/frontend/src/utils/network.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +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)).toBe(false) + }) + + it('reports a mismatch when the wallet cannot reach the app network', () => { + expect(wrongNetwork([WALLET], APP)).toBe(true) + }) + + // 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)).toBe(false) + }) + + // 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 app id', [WALLET], undefined], + ['the wallet ids', [], APP], + ])('reports nothing while %s is missing', (_case, wallet, app) => { + 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 new file mode 100644 index 00000000..c49a65f3 --- /dev/null +++ b/dapp/frontend/src/utils/network.ts @@ -0,0 +1,13 @@ +// 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 not landed. + +// 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 => + networkId.slice(networkId.indexOf(':') + 1) || networkId