Skip to content
Merged
8 changes: 8 additions & 0 deletions dapp/frontend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 72 additions & 4 deletions dapp/frontend/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion dapp/frontend/src/backend/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const OPERATOR_HINT = 'vesting-operator-'

const advice = (reason: string): Error => new Error(`${reason} — run pnpm run bootstrap`)

const call = async <T>(ledgerApi: LedgerApi, params: LedgerApiParams): Promise<T> =>
export const call = async <T>(ledgerApi: LedgerApi, params: LedgerApiParams): Promise<T> =>
(await ledgerApi(params)) as T

type ActiveContract = {
Expand Down
55 changes: 55 additions & 0 deletions dapp/frontend/src/backend/synchronizer.test.ts
Original file line number Diff line number Diff line change
@@ -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([])
})
})
20 changes: 20 additions & 0 deletions dapp/frontend/src/backend/synchronizer.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> => {
const { connectedSynchronizers } = await call<ConnectedSynchronizers>(ledgerApi, {
requestMethod: 'get',
resource: '/v2/state/connected-synchronizers',
query: { party },
})
return (connectedSynchronizers ?? [])
.map((one) => one.synchronizerId)
.filter((id): id is string => id !== undefined)
}
29 changes: 28 additions & 1 deletion dapp/frontend/src/backend/transferContext.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> => ({
templateId,
Expand Down Expand Up @@ -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()
})
})
12 changes: 11 additions & 1 deletion dapp/frontend/src/backend/transferContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -42,6 +44,14 @@ const rpc = async (method: string, params: Record<string, unknown>): Promise<unk
return body.result
}

// The network wallet-service answered for, which is the only thing comparable against the wallet's
// own. Its own read rather than a field on `fetchTransferContext`: the id sits on the AmuletRules
// disclosure alone, so it must not inherit that builder's wait for an open round.
export const fetchAppNetwork = async (party: string): Promise<string | undefined> => {
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<{
Expand All @@ -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)
Expand Down
16 changes: 13 additions & 3 deletions dapp/frontend/src/components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -38,16 +39,25 @@ export const AppShell = (): React.JSX.Element => {
>
Skip to main content
</a>
<WrongNetwork />
<TopBar />
<main
id="main"
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 `configError` names a missing deployment, but the network is the
cause, so the script it advises would not help. */}
{configError !== undefined && (
<Card role="alert" className="flex flex-col items-center gap-3 px-6 py-16 text-center">
<h1 className="text-base font-bold text-danger">No deployment</h1>
<p className="max-w-lg text-sm text-fg-muted">{configError}</p>
<h1 className="text-base font-bold text-danger">
{wrongNetwork ? 'Wrong network' : 'No deployment'}
</h1>
<p className="max-w-lg text-sm text-fg-muted">
{wrongNetwork
? 'This app found nothing on the network the wallet is connected to. Switch networks in the wallet to load it.'
: configError}
</p>
</Card>
)}
{configPending && <Loading />}
Expand Down
Loading