diff --git a/.claude/skills/create-issue/SKILL.md b/.claude/skills/create-issue/SKILL.md index c844e79b..aaf34d41 100644 --- a/.claude/skills/create-issue/SKILL.md +++ b/.claude/skills/create-issue/SKILL.md @@ -35,6 +35,29 @@ The word limit above is a hard cap, not a guideline. A body that exceeds it is n - Over the hard cap means delete content, never compress it into one long sentence. - Steps and criteria are for a human to execute: numbered, imperative, one ultra brief line each, no assumed context. +## Writing Style + +Avoid dense technical writing. Use clear, concise, and non-redundant prose. Examples: Simple English Wikipedia, Mr. Rogers, Ernest Hemingway. + +Banned vocabulary: do not invent abbreviations, compound labels, framework names, or domain terms. + +List of usual words to avoid: + +- load-bearing +- blast radius +- footgun +- yak shaving +- belt-and-suspenders +- smoking gun +- spine +- seams +- gate +- substrate + +If writing documentation pay special attention to use words a maintainer would search for in the codebase. + +If something can be made a list, make it a list. It's easier to read. Prefer avoiding walls of text. + ## Step 1: Classify | Signal | Type | diff --git a/.claude/skills/create-pr/SKILL.md b/.claude/skills/create-pr/SKILL.md index 79212d33..90844482 100644 --- a/.claude/skills/create-pr/SKILL.md +++ b/.claude/skills/create-pr/SKILL.md @@ -38,6 +38,29 @@ The word limits above are hard caps, not guidelines. A body that exceeds one is - No preamble. `This PR adds...` → `Adds...`, then delete that too if the bullet below says it. - Over a hard cap means delete content, never compress it into one long sentence. +## Writing Style + +Avoid dense technical writing. Use clear, concise, and non-redundant prose. Examples: Simple English Wikipedia, Mr. Rogers, Ernest Hemingway. + +Banned vocabulary: do not invent abbreviations, compound labels, framework names, or domain terms. + +List of usual words to avoid: + +- load-bearing +- blast radius +- footgun +- yak shaving +- belt-and-suspenders +- smoking gun +- spine +- seams +- gate +- substrate + +If writing documentation pay special attention to use words a maintainer would search for in the codebase. + +If something can be made a list, make it a list. It's easier to read. Prefer avoiding walls of text. + ## Step 0: Scope Check Run this first. A developer must never discover at the end of the wizard that the branch should have been split. diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b8b535fc..41e910d3 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -61,6 +61,20 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm test + test-node-26: + name: 3 - test (node 26) + runs-on: ubuntu-latest + if: github.event.action != 'edited' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - run: corepack enable + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 26 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm test + commitlint: name: 4 - commitlint runs-on: ubuntu-latest diff --git a/.lintstagedrc.mjs b/.lintstagedrc.mjs index d4c907e8..7539f477 100644 --- a/.lintstagedrc.mjs +++ b/.lintstagedrc.mjs @@ -3,10 +3,11 @@ export default { 'canton-connect/src/**/*.{ts,tsx,js,jsx}': () => 'pnpm -C canton-connect test', 'canton-dappbooster/src/**/*.{ts,tsx,js,jsx}': () => 'pnpm -C canton-dappbooster test', + 'dapp/frontend/src/**/*.{ts,tsx,js,jsx}': () => 'pnpm -C dapp/frontend test', // One task for the whole doc gate, under the name CI uses: typedoc resolves both packages in a // single run anyway, so splitting it per package bought a second and two ways to drift. // Snippets live in the READMEs as well as in the doc blocks, so a doc edit re-runs the compile. - '{canton-connect,canton-dappbooster}/{src/**/*.{ts,tsx},doc-fixtures.d.ts,README.md,architecture.md,typedoc.json}': + '{canton-connect,canton-dappbooster}/{src/**/*.{ts,tsx},doc-fixtures.d.ts,README.md,architecture.md,coming-from-wagmi.md,typedoc.json}': () => 'pnpm docs:check', // Either side of the L2/L3 contract moving on its own is what this catches. '{canton-dappbooster/src/{components,providers}/**/anatomy.ts,canton-theme/src/**/*.css}': () => diff --git a/CLAUDE.md b/CLAUDE.md index 1f2cb1f2..4bc65912 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,4 @@ - + # Agent Configuration — Canton dAppBooster @@ -30,7 +30,7 @@ Use one reader per doc type, layered by scope: | `AGENTS.md` | Agent compatibility loader | Three-line shim beside every `CLAUDE.md`, pointing to the sibling `CLAUDE.md`. It is never canonical. | | `architecture.md` | Human or agent: what are the structural seams and internal subsystems? | Root always for cross-component seams. Subproject only when internals outgrow the README: three or more interacting subsystems, non-trivial control flow, or named abstractions. | | `architecture/.md` | Human or agent editing one subsystem: how does it behave in full? | Only beside an `architecture.md` that indexes it, when a section outgrows the seam it describes. The index keeps the seam and links the chapter; no chapter without its index entry. | -| Generated reference | Human: what does this export do and how do I call it? | Not hand-maintained and not a file anyone edits. `typedoc.json` builds it from the JSDoc on `canton-dappbooster` and `canton-connect`'s barrels; `@internal` keeps a symbol off it. Fix the doc block, never the site. | +| Generated reference | Human: what does this export do and how do I call it? | Not hand-maintained and not a file anyone edits. `typedoc.json` builds it from the JSDoc on `canton-dappbooster` and `canton-connect`'s barrels; `@internal` keeps a symbol off it. Fix the doc block, never the site. `canton-connect/coming-from-wagmi.md`, published through `projectDocuments`, is the one hand-written page in it: an exception for a mapping to another library's API, not a pattern to repeat. | Current distribution: @@ -83,7 +83,7 @@ A README may state that a contract exists and link to it. It may not restate it. | Path | Purpose | Stack | Port | |------|---------|-------|------| -| [`dapp/frontend/`](dapp/frontend/) | `DBT` vesting dApp over the local participant. Every read and write goes through the connected CIP-0103 wallet via `canton-connect`; the operator's factory and the instrument's `InstrumentConfig` arrive by explicit disclosure, the config from the token registry. Imported from `cn-dappbooster@feat/vesting-lite` (see its `PROVENANCE.md`). | Vite + React + Tailwind v4 + zustand + react-router + Biome | 3012 | +| [`dapp/frontend/`](dapp/frontend/) | `DBT` vesting dApp over the local participant. Every read and write goes through the connected CIP-0103 wallet via `canton-connect`; the operator's factory and the instrument's `InstrumentConfig` arrive by explicit disclosure, the config from the token registry. Imported from `cn-dappbooster@feat/vesting-lite` (see its `PROVENANCE.md`). | Vite + React + Ark UI + lucide-react + Tailwind v4 + zustand + react-router + Biome | 3012 | | [`canton-connect/`](canton-connect/) | wagmi-style React hooks wrapping the `dapp-sdk` facade; the SDK owns discovery, the picker, the session and the transports | TypeScript + React 19 + xstate 5 + Biome | n/a (library) | | [`canton-dappbooster/`](canton-dappbooster/) | L2 headless UI components for Canton dApps (tsdown-built, zero styling), plus the light/dark/system theme runtime that drives `data-theme`, plus the pure utilities the components are built on, the exact-decimal amount ones included. Styling lives in `canton-theme`. `src/index.ts` is the public API; `src/connect.ts` is the `/connect` sub-path, holding the components that read the wallet session so the main barrel stays free of the Canton SDK. | TypeScript + React 19 + tsdown + vitest + Biome | n/a (library) | | [`canton-theme/`](canton-theme/) | L3 plain-CSS theme for the kit: `--cnc-*` tokens + prestyled defaults, consumed by importing its CSS. | CSS | n/a (library) | @@ -330,6 +330,9 @@ package, because only `canton-dappbooster` splits markup from styles across a pa `onClick ?? doTheThing` silently drops the behaviour the component exists for. Where a state machine owns the handler, merge through its own utility (`mergeProps` in Zag) rather than by hand, so a handler the library adds later is not missed. +- **A ternary is for a two-way toggle between two things to render; a guard clause is for bailing + out of the whole render** (loading, error, no data). Two real faces get + `cond ? : `; a bail-out gets an `if` above the return. - Tests assert on roles, accessible names, and whatever contract the component declares. Never on styling. diff --git a/architecture.md b/architecture.md index 6f923138..41b0a327 100644 --- a/architecture.md +++ b/architecture.md @@ -8,7 +8,7 @@ | `scripts/` | Bash + Node | The local loop: `dev-stack.sh`, the DAR upload, the token mint, the vesting bootstrap | | wallet-service (external: [BootNodeDev/canton-wallet-service](https://github.com/BootNodeDev/canton-wallet-service)) | Node 24 + Express 5 + TypeScript + `@canton-network/wallet-sdk` | Bridge the wallet uses for external-party onboarding and participant JSON API calls. A git dependency pinned to a tag, run on the host by `scripts/dev-stack.sh` | | token registry (external: [BootNodeDev/canton-token-forge](https://github.com/BootNodeDev/canton-token-forge)) | Node + Express + TypeScript | Read-only CIP-56 registry over the `canton-token-forge` package: serves instrument metadata and the transfer-factory choice context. A git dependency pinned to a tag, run on the host by `scripts/dev-stack.sh` | -| `dapp/frontend/` | Vite + React + Tailwind v4 + zustand + react-router | `DBT` **vesting** dApp; every read and write goes through the connected CIP-0103 wallet via `canton-connect`, and the `InstrumentConfig` every write carries comes from the token registry | +| `dapp/frontend/` | Vite + React + Ark UI + lucide-react + Tailwind v4 + zustand + react-router | `DBT` **vesting** dApp; every read and write goes through the connected CIP-0103 wallet via `canton-connect`, and the `InstrumentConfig` every write carries comes from the token registry | | `canton-connect/` | TypeScript + React 19 | wagmi-style hooks wrapping the dapp-sdk facade | | `canton-dappbooster/` | TypeScript + React 19 + tsdown | L2 headless UI components, zero styling, plus the theme runtime and the pure utilities under the components, exact-decimal amounts included | | `canton-theme/` | CSS | L3 plain-CSS theme: `--cnc-*` tokens + prestyled defaults | diff --git a/canton-connect/CLAUDE.md b/canton-connect/CLAUDE.md index 35ac52d9..228f4834 100644 --- a/canton-connect/CLAUDE.md +++ b/canton-connect/CLAUDE.md @@ -19,19 +19,20 @@ the session and the transports. Browser-only, and built to stay cheap to delete. lifecycle; remount the provider (`key`) to change it. - **A state carries a tag for what it means to the outside.** The tags union in `machine/connectionMachine.ts` is the authority, and no other module names a state. A state that - answers an operation must carry its tag or the bridge waits forever: `waitFor` is unbounded here - (#105). + answers an operation must carry its tag or the bridge waits forever: `waitFor` is unbounded here, + and no clock will rescue it. - **A state's `exit` clears what that state alone justified.** `party` is cleared on leaving `session.authenticated`, because a wallet that stops serving requests has none to offer, and a lock cannot be told from a wallet-side disconnect. `sdk` has no exit; nothing outlives it. - **Listeners register only inside a state's `invoke`.** `sdk.onX` binds to the current client and `sdk.connect()` swaps it. -- **The provider selects nothing.** It publishes the config, the actor and three actions; each hook +- **The provider selects nothing.** It publishes the config, the actor and four actions; each hook selects its own slice. Never add a field a hook could select. - **Publish the narrowest type.** `ConnectionSubscription` puts `send` out of reach; `WalletSdk` narrows `DappSDK` to the methods this package calls. -- **React owns two things:** `lastTx` (`useExecute`) and the `toConnectError` memo (`useConnect`). - Anything else that looks like state belongs in the machine. +- **The machine owns the session; a hook owns what it asked for.** `sdk`, `party`, status and the + last connect error are machine context, never React state. A call's result or in-flight flag + (`lastTx`, a signature) is React state: the session does not depend on it. - **Import the SDK's types.** A `param as Parameters<…>` cast is a duplicated type: import the real one from `dapp-sdk` or `core-types`. - **The picker is `CantonConnectConfig.walletPicker`.** No picker UI in this package; that lives in @@ -46,10 +47,14 @@ the session and the transports. Browser-only, and built to stay cheap to delete. ## Bumping `dapp-sdk` -`guardedConnect` rests on two SDK internals no test can pin: that the picker window comes from -`window.open`, and the shape of the `SPLICE_WALLET_PICKER_RESULT` message it reads and posts. Why: -[`architecture/popup-close-guard.md`](architecture/popup-close-guard.md). Serve `dapp/frontend` and -walk all four: +`guardedConnect` and `retireSdk` rest on three SDK internals no test can pin: + +- the picker popup is opened through `window.open` +- the shape of the `SPLICE_WALLET_PICKER_RESULT` message +- `connect()` calls `this.walletPicker` only when it reaches the picker, so a swapped one is honored + +Why: [`architecture/popup-close-guard.md`](architecture/popup-close-guard.md). Serve `dapp/frontend` +and walk all five: 1. Close the picker without choosing, three times over: the button re-enables each time, and the next real connect raises exactly one approval prompt. @@ -60,6 +65,9 @@ walk all four: 4. Check that `new DappSDK()` still only initializes fields (true on 1.5.1). The machine constructs one inside a plain `assign`; if construction turns effectful, move the ritual into the provider's `createSdk` and dispose the abandoned instance on the same transition, never from an effect. +5. Click Connect, then the Cancel that replaces it, fast enough to beat the popup: the button reads + Connect again and no picker window appears. One appearing means `connect()` no longer reads + `walletPicker` off the instance at pick time. ## Layout diff --git a/canton-connect/README.md b/canton-connect/README.md index 0f1f241b..4c62d0ff 100644 --- a/canton-connect/README.md +++ b/canton-connect/README.md @@ -12,13 +12,14 @@ types. [`@partylayer/react`](https://partylayer.xyz) is the alternative, built over its own wallet adapters. This one wraps Digital Asset's official SDK, the dependency these dApps already carry, and -stays thin enough to delete. How close the result shapes should sit to wagmi's is open in #52. +stays thin enough to delete. How the hook results relate to wagmi's is in +[coming-from-wagmi.md](https://github.com/BootNodeDev/canton-dappbooster/blob/main/canton-connect/coming-from-wagmi.md). ## Why a state machine The connection lifecycle looks like four states (idle, connecting, connected, disconnected) and isn't. The hard part isn't holding state, it's canceling work -when the state that started it is gone: a picker the user walked out of, a lock +when the state that started it is gone: a picker the user abandoned, a lock that races the account read, a connect asked for mid-disconnect, a wallet that answers late or never. Handled one at a time these were five separate races (#76). A state machine folds them into one model: a state's invoked work is @@ -36,7 +37,7 @@ switching only once those gaps close. Until then, the machine earns its cost. | dapp-sdk gap | what it costs us | gone when | |---|---|---| -| `connect()` can't be aborted; a closed popup hangs it (#49) | `guardedConnect`, `settleAbandonedConnect`, `PickerClosedError`, the `retiring` state | `connect(signal)` truly aborts | +| `connect()` can't be aborted; a closed popup hangs it (#49) | `guardedConnect`, `settleAbandonedConnect`, `PickerClosedError`, the `retiring` state, `retireSdk`'s picker swap | `connect(signal)` truly aborts | | `init()` caches a rejected promise forever | `retireSdk`, the `retiring` state, `InitFailedError` | `init()` retries after a failure | | `disconnect()` has no timeout (#105) | `DISCONNECT_TIMEOUT_MS`, and `retireSdk` when it fires | `disconnect()` times out itself | | lock and wallet-side disconnect are one push | `session.unauthenticated`, party-dropped-on-lock | CIP-0103 separates them (spec, not SDK) | @@ -75,7 +76,7 @@ function App() { } function Dapp() { - const { connect, isConnecting, isConnected, connectError } = useConnect() + const { connect, isPending, isConnected, error } = useConnect() const { party } = useParty() const { isLocked } = useWalletStatus() const { signMessage } = useSignMessage() @@ -85,10 +86,10 @@ function Dapp() { if (!isConnected) { return (
- - {connectError !== undefined &&

{connectError.message}

} + {error !== undefined &&

{error.message}

}
) } @@ -103,12 +104,15 @@ function Dapp() { `connect()` opens the SDK's wallet picker, a popup by default. There is no mode argument: the picker is what chooses the wallet. Dismissing it rejects with `ConnectCancelledError`, which you filter by -`instanceof`, never by message. Whether `connectError` records it as well depends on which side saw +`instanceof`, never by message. Whether `error` records it as well depends on which side saw the close, so do not gate on that. `signMessage`, `execute` and `ledgerApi` refuse with no session, and refuse again while the wallet -reports it is not authenticated; that is `isLocked`, and it happens after a successful connect. The -SDK's status carries one `isConnected` flag, so a lock and a wallet-side disconnect look the same +reports it is not authenticated; that is `isLocked`, and it happens after a successful connect. +`signMessage` and `execute` also refuse with no party, which `ledgerApi` does not need. The +reference gateway also refuses `signMessage` for a local party; `usePartyType().readPartyType()` +tells local from external when you ask. The SDK's status carries one `isConnected` flag, so a lock +and a wallet-side disconnect look the same here. `useLedger().isReady` covers both, and `useParty().party` is `undefined` for the duration: gate session content on the party, and use `isLocked` only to explain why it went away. @@ -116,7 +120,7 @@ gate session content on the party, and use `isLocked` only to explain why it wen Every hook and every config field is documented in JSDoc, which your editor surfaces at the call site and which is published at -[docs-canton-dappbooster.vercel.app](https://docs-canton-dappbooster.vercel.app). Start at +[docs.dappbooster.cc](https://docs.dappbooster.cc/). Start at `CantonConnectProvider` and `CantonConnectConfig`. ## Testing helpers diff --git a/canton-connect/architecture.md b/canton-connect/architecture.md index 562839ce..601cce86 100644 --- a/canton-connect/architecture.md +++ b/canton-connect/architecture.md @@ -16,12 +16,12 @@ src/ accountsMachine.ts the account read, invoked inside session.authenticated accountsActors.ts listAccounts reader and accountsChanged listener CantonConnectProvider/ - index.tsx the context: publishes the actor and three actions + index.tsx the context: publishes the actor and four actions useConnectionActor.ts creates the actor, sends the boot restore useConnectBridge.ts connect() as a promise over the machine's tags useDisconnectBridge.ts disconnect() as a promise over the machine's tags adapters.ts buildAdditionalAdapters - hooks/ the six public hooks, plus useTxFeed and useWalletCall + hooks/ the seven public hooks, plus useTxFeed and useWalletCall mock/mockAdapter.ts createMockAdapter, a ProviderAdapter for dev and tests testing/ the ./testing doubles, plus suite-local helpers connectError.ts ConnectCancelledError, PickerClosedError, toConnectError @@ -62,8 +62,8 @@ flowchart LR ### The lifecycle: `machine/` -One model of connecting, session, lock and disconnect, so the impossible combinations (a status -with no party, an error beside a live session) cannot be built. Three decisions carry the weight: +One model of connecting, session, lock and disconnect, so the impossible combinations (a party with +no live session, an error beside a live session) cannot be built. Three decisions carry the weight: - `idle` is not `disconnected`. `idle` means the boot restore has not answered; `disconnected` means it has, and there is nothing. @@ -76,17 +76,19 @@ with no party, an error beside a live session) cannot be built. Three decisions ### The bridges `connect()` and `disconnect()` are a send plus a wait on a tag, so the promise over a transition -lives outside the machine. Neither passes a timeout. The connect wait is #105; the disconnect wait -the machine bounds itself, giving up on a wallet 10 s silent (`DISCONNECT_TIMEOUT_MS`), since the -SDK's request carries no deadline of its own. +lives outside the machine. Neither passes a timeout. The connect wait has no clock on purpose, since +a wallet login can take as long as it takes; it ends when the wallet answers or the user cancels +(`connect.cancel`). The disconnect wait the machine bounds itself, giving up on a wallet 10 s silent +(`DISCONNECT_TIMEOUT_MS`), since nobody is deciding anything in that window. ### The provider publishes, the hooks select The context value is the config, the actor as `ConnectionSubscription` (`send` is unreachable -through it, so the bridges stay the only senders) and three identity-stable actions. Each hook +through it, so the bridges stay the only senders) and four identity-stable actions. Each hook selects its own slice, which is wagmi's shape: `WagmiProvider` publishes, `useAccount` subscribes -itself. `useConnect`, `useParty` and `useWalletStatus` read session state; `useLedger`, `useExecute` -and `useSignMessage` select a guard plus the sdk and call it directly, never entering the machine. +itself. `useConnect`, `useDisconnect`, `useParty` and `useWalletStatus` read session state; +`useLedger`, `useExecute`, `useSignMessage` and `usePartyType` select a guard plus the sdk and +call it directly, never entering the machine. The machine's input is read once, when the actor is created, so a changed `config` prop needs a remount. One accepted cost: `sdk` in context makes the snapshot unserializable, which rules out @@ -100,7 +102,8 @@ the machine a `createSdk` closure rather than an instance. With the SDK popup in use, `guardedConnect` wraps `sdk.connect()` with a watchdog on the popup window, because the SDK misses a close (#49). A caught close rejects with `PickerClosedError`, which -takes the machine to `retiring`, where the `DappSDK` is replaced. +takes the machine to `retiring`, where the `DappSDK` is replaced. `cancelConnect` lands there too: +the guard closes the popup itself, off the abort xstate fires when it stops the connect actor. ### Adapters @@ -110,6 +113,16 @@ init actor passes `defaultAdapters: []`, dropping the SDK's bundled `localhost:3 `networkId` (default `'canton:local'`) is both the WalletConnect `chainId` and the fallback `Party.networkId` for a wallet that reports none. +### The party type + +A party under the hosting participant's namespace is local, any other is external. A dApp cares +because the reference gateway refuses `signMessage` for a local party. CIP-0103 has no field for +it, so `usePartyType().readPartyType` derives it when the consumer asks, never in the machine: one +`ledgerApi` read of the participant id (`GET /v2/parties/participant-id`, open to a `CanActAs` +token), its namespace compared with `Party.namespace`, which arrives from the wallet unchanged, as +`signingProviderId` does. A failed read rejects; what follows is the consumer's call. `isLocal` on +the parties endpoint means hosted here, external parties included, so it is not the signal. + ### Testing doubles `createFakeWallet` is a real CIP-0103 extension over `postMessage`, so a test walks the SDK's own diff --git a/canton-connect/architecture/connection-machine.md b/canton-connect/architecture/connection-machine.md index 908cb9a8..41d515c5 100644 --- a/canton-connect/architecture/connection-machine.md +++ b/canton-connect/architecture/connection-machine.md @@ -21,16 +21,16 @@ stateDiagram-v2 session --> connecting: connect (wallet change) connecting --> session: wallet approved connecting --> failure: declined or threw - connecting --> retiring: picker closed + connecting --> retiring: picker closed, or cancelled retiring --> restoring: replacement booted retiring --> failure: replacement failed too - session --> disconnecting: disconnect + session --> disconnecting: disconnect, or cancelled during the party read disconnecting --> disconnected: settled, or 10 s silence ``` `connecting`, `retiring` and `restoring` each split into `new` and `changing`: the variants carry whether a standing session is at stake. A connect from `session` runs as -`changing`, and a closed picker resumes that session through `retiring.changing` and +`changing`, and a closed picker or a cancel resumes that session through `retiring.changing` and `restoring.changing`, which `toConnectionStatus` reports as `'connecting'` rather than `'disconnected'` and `'idle'`: a consumer gating on status must not unmount the app while its session is on the way back. @@ -51,8 +51,8 @@ The tags are the machine's public face; the bridges and hooks read nothing else. `ConnectCancelledError`. The wait has no timeout, so a state that answers a connect must carry one of the three. - `disconnect()` waits for `disconnect.settled` the same way. -- Hooks select: `isConnecting` is `hasTag('connecting')`, `isLocked` is `hasTag('unauthenticated')`, - `status` is `toConnectionStatus`, `connectError` is `context.lastConnectError` through +- Hooks select: `isPending` is `hasTag('connecting')`, `isLocked` is `hasTag('unauthenticated')`, + `status` is `toConnectionStatus`, `error` is `context.lastConnectError` through `toConnectError`. Consequences a caller notices: @@ -74,6 +74,11 @@ Consequences a caller notices: Which states record, keep and clear `lastConnectError` is the machine's own rule; the context comment carries the why (a recovered session can still say why the attempt before it failed). +A user's own cancel records nothing either: `connect.cancel` takes the same route to `retiring`, +and the abort xstate fires on the stopped actor is what closes the picker window. Once the wallet +has approved, the read of the party still shows as connecting, but a session already stands, so a +cancel there goes to `disconnecting` and ends it; `disconnected` answers the wait as a cancel. + A picker close reaches the caller two ways. A close the watchdog catches records nothing: `connecting` goes to `retiring` and `connect()` rejects with a fresh `ConnectCancelledError`. A dismissal the SDK itself rejects goes to `failure` and is recorded; `toConnectError` classifies it diff --git a/canton-connect/architecture/popup-close-guard.md b/canton-connect/architecture/popup-close-guard.md index 38ea75a6..c662873d 100644 --- a/canton-connect/architecture/popup-close-guard.md +++ b/canton-connect/architecture/popup-close-guard.md @@ -22,21 +22,21 @@ Wrapping the call rather than supplying our own `walletPicker` is a stopgap: the deeper one and the SDK already offers it, but this package holds no picker UI by rule and the themed one (#50) has no merged implementation. -## The orphaned connect +## The abandoned connect A rejected race leaves the SDK's `connect()` running, still listening for a picker result and ready to swap the SDK's client from under the machine's event wiring on the next connect. That is what `PickerClosedError` is for: `connecting` takes it to `retiring`, which replaces the `DappSDK` and restores the session from the discovery session key that `connect()` never clears. -Replacing the instance does not by itself reach the orphan. The abandoned `connect()` is parked +Replacing the instance does not by itself reach it. The abandoned `connect()` is parked inside `core-wallet-ui-components` module scope, on a `message` listener keyed to nothing but our origin and the message type, so the next successful connect woke every past one: one `discovery.connect`, and one wallet approval prompt, per popup the user had closed. `settleAbandonedConnect` drains them at the close instead. It posts the SDK's own `SPLICE_WALLET_PICKER_RESULT` to our window, the only thing that makes that listener unsubscribe. -The `providerId` matches no registered adapter, so the orphan fails with `WalletNotFoundError` +The `providerId` matches no registered adapter, so it fails with `WalletNotFoundError` before reaching a wallet, then rejects out of `waitForWalletPickerRetrySelection` because the popup is closed. `walletType` stays `'browser'` to keep it out of the branch that registers a remote adapter from the message, and no `name` is sent, so anything else on the page watching for a pick @@ -44,7 +44,12 @@ can tell the two apart; `dapp/frontend` does exactly that to label its connect b skipped while a second guard is in flight, since the message would resolve that one's live waiter too. -This is a workaround, not containment: the orphan still runs. The real fix is an abort on +A cancel can land before the popup exists: `connect()` waits ~300 ms on extension discovery before +it calls the picker. A `connect.cancel` inside that wait finds nothing to close and nothing to +drain, and the popup opened after it. So `retireSdk` overwrites `walletPicker` on the retired +instance with one that rejects; `connect()` reads it only when it gets there. + +This is a workaround, not containment: the abandoned connect still runs. The real fix is an abort on `DappSDK.connect()`, upstream. CIP-0103 has no cancel for a sent `connect` either. ## The watchdog stands down once a wallet is chosen @@ -69,3 +74,8 @@ message: its type, its origin, and the `walletType` and `providerId` fields, non A rename turns the drain into a no-op that returns the duplicate prompts, and turns the stand-down back into abandoning a live connect, with nothing going red either way. Nor does any test reach #49's own cause, a real `WindowProxy` losing its `beforeunload` across the navigation. + +The pick refusal rests on a third internal: `connect()` reads `this.walletPicker` only when it +reaches the picker. A later SDK version that copies the field at construction would ignore the swap, +the popup after a cancel returns, and no test fails: the machine test only checks the field was +overwritten. diff --git a/canton-connect/coming-from-wagmi.md b/canton-connect/coming-from-wagmi.md new file mode 100644 index 00000000..7bf2b2c3 --- /dev/null +++ b/canton-connect/coming-from-wagmi.md @@ -0,0 +1,18 @@ +--- +title: Coming from wagmi +--- + +The hook names follow wagmi, so a developer arriving from it knows which one to reach for. The result shapes do not, and this is where. + +| wagmi | canton-connect | why | +|---|---|---| +| none | `useConnect().cancelConnect` | Abandons a connect in flight, rejecting it with `ConnectCancelledError`; wagmi has no cancel. | +| `useAccount().address` | `useParty().party.partyId` | A Canton identity is a party. | +| `useAccount().addresses`, `.connector`, `.chain` | none | Not exposed yet. | +| none | `useWalletStatus().isLocked` | Connected-but-locked is a CIP-0103 state. | +| none | `usePartyType().readPartyType()`, resolving `'local'` or `'external'` | Canton parties come in two kinds and the reference gateway refuses `signMessage` for a local one; wagmi has one kind of account. | +| `useWriteContract` then `useWaitForTransactionReceipt` | `useExecute().execute`, resolving after execution | The wallet submits and waits; one call covers both. | +| none | `useExecute().lastTx` | The wallet pushes `pending`, `signed`, `executed`, `failed` as it goes; wagmi has no hook returning a stream. | +| `useSignMessage().data`, a hex string | `useSignMessage().signature` | The name says the type. | +| `usePublicClient()`, a typed client | `useLedger().ledgerApi`, untyped, gated by `isReady` | The participant's JSON API, passed through the wallet's session. | +| `mutate`, `mutateAsync`, `status`, `variables`, `data` | none; `isPending`, `error`, `reset` carry over | No TanStack Query underneath. | diff --git a/canton-connect/doc-fixtures.d.ts b/canton-connect/doc-fixtures.d.ts index 1900c49a..8d1a48c9 100644 --- a/canton-connect/doc-fixtures.d.ts +++ b/canton-connect/doc-fixtures.d.ts @@ -11,7 +11,8 @@ type FixtureElement = import('react').ReactElement declare const partyId: string declare const PARTY: string -declare const connectError: Error | undefined +declare const party: import('#src/types').Party +declare const error: Error | undefined /* Consumer-side wiring */ diff --git a/canton-connect/src/CantonConnectProvider/CantonConnectProvider.connect.test.tsx b/canton-connect/src/CantonConnectProvider/CantonConnectProvider.connect.test.tsx index 96401958..9265a053 100644 --- a/canton-connect/src/CantonConnectProvider/CantonConnectProvider.connect.test.tsx +++ b/canton-connect/src/CantonConnectProvider/CantonConnectProvider.connect.test.tsx @@ -2,10 +2,12 @@ import { act, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' +import { ConnectCancelledError } from '#src/connectError' import { useConnect } from '#src/hooks/useConnect' import { createMockAdapter } from '#src/mock/mockAdapter' import { createAutoPicker } from '#src/testing/autoPicker' import { clearDiscoveryStorage } from '#src/testing/discoveryStorage' +import { hangingPicker } from '#src/testing/hangingPicker' import { renderSession } from '#src/testing/renderSession' import { throwingPicker } from '#src/testing/throwingPicker' import { useSession } from '#src/testing/useSession' @@ -70,7 +72,7 @@ describe('CantonConnectProvider connect flow', () => { expect(result.current.party?.networkId).toBe('canton:devnet') }) - it('sets connectError and rejects connect() when the picker throws', async () => { + it('sets error and rejects connect() when the picker throws', async () => { const { result } = renderSession(() => useSession(), { walletPicker: throwingPicker }) // What the message says is toConnectError's classification, owned by connectError.test.ts; @@ -78,47 +80,47 @@ describe('CantonConnectProvider connect flow', () => { const rejection = await act(() => result.current.connect().catch((error: unknown) => error)) expect(rejection).toBeInstanceOf(Error) - expect(result.current.connectError).toBe(rejection) + expect(result.current.error).toBe(rejection) expect(result.current.status).toBe('disconnected') }) - it('clears connectError on disconnect', async () => { + it('clears error on disconnect', async () => { const { result } = renderSession(() => useSession(), { walletPicker: throwingPicker }) await act(async () => { await expect(result.current.connect()).rejects.toThrow('cancel') }) - expect(result.current.connectError?.message).toBe('cancel') + expect(result.current.error?.message).toBe('cancel') await act(async () => { await result.current.disconnect() }) - expect(result.current.connectError).toBeUndefined() + expect(result.current.error).toBeUndefined() }) - it('reset() forgets connectError without disconnecting', async () => { + it('reset() forgets error without disconnecting', async () => { const { result } = renderSession(() => useConnect(), { walletPicker: throwingPicker }) await act(async () => { await expect(result.current.connect()).rejects.toThrow('cancel') }) - expect(result.current.connectError?.message).toBe('cancel') + expect(result.current.error?.message).toBe('cancel') act(() => { result.current.reset() }) - expect(result.current.connectError).toBeUndefined() + expect(result.current.error).toBeUndefined() // still connectable: reset cleared a message, not the machine await act(async () => { await expect(result.current.connect()).rejects.toThrow('cancel') }) - expect(result.current.connectError?.message).toBe('cancel') + expect(result.current.error?.message).toBe('cancel') }) // The pair below is the whole observable difference between the two picker configurations: the @@ -150,4 +152,48 @@ describe('CantonConnectProvider connect flow', () => { expect(openSpy).toHaveBeenCalled() }) + + it('cancels an attempt the wallet never answers', async () => { + const wallet = walletA() + const { result } = renderSession(() => useSession(), { walletPicker: hangingPicker }) + + let rejection: unknown + await act(async () => { + result.current.connect().catch((error: unknown) => { + rejection = error + }) + }) + + await waitFor(() => expect(result.current.isPending).toBe(true)) + + await act(async () => { + result.current.cancelConnect() + }) + + await waitFor(() => expect(result.current.status).toBe('disconnected')) + + expect(rejection).toBeInstanceOf(ConnectCancelledError) + // a cancel is the user walking away, not a failure, so there is nothing to show them + expect(result.current.error).toBeUndefined() + + wallet.dispose() + }) + + it('retires the sdk a cancelled connect abandoned', async () => { + const wallet = walletA() + const { result } = renderSession(() => useSession(), { walletPicker: hangingPicker }) + const abandoned = result.current.sdk + + await act(async () => { + result.current.connect().catch(() => undefined) + }) + + await act(async () => { + result.current.cancelConnect() + }) + + await waitFor(() => expect(result.current.sdk).not.toBe(abandoned)) + + wallet.dispose() + }) }) diff --git a/canton-connect/src/CantonConnectProvider/CantonConnectProvider.events.test.tsx b/canton-connect/src/CantonConnectProvider/CantonConnectProvider.events.test.tsx index d2b3ae50..751f4394 100644 --- a/canton-connect/src/CantonConnectProvider/CantonConnectProvider.events.test.tsx +++ b/canton-connect/src/CantonConnectProvider/CantonConnectProvider.events.test.tsx @@ -3,6 +3,7 @@ import { act, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { useConnect } from '#src/hooks/useConnect' +import { useDisconnect } from '#src/hooks/useDisconnect' import { useExecute } from '#src/hooks/useExecute' import { useParty } from '#src/hooks/useParty' import { clearDiscoveryStorage } from '#src/testing/discoveryStorage' @@ -45,6 +46,41 @@ describe('CantonConnectProvider wallet pushes', () => { wallet.dispose() }) + it('keeps execute stable when a push changes the party object but not its id', async () => { + const wallet = walletA() + + const { result } = renderSession(() => ({ + connect: useConnect(), + party: useParty(), + execute: useExecute(), + })) + + await act(async () => { + await result.current.connect.connect() + }) + + await waitFor(() => expect(result.current.party.party?.partyId).toBe('alice::1220ab')) + + const before = result.current.execute.execute + + act(() => { + wallet.push('accountsChanged', [ + { + partyId: 'alice::1220ab', + primary: true, + hint: 'alice renamed', + publicKey: 'pub-alice', + networkId: 'canton:local', + }, + ]) + }) + + await waitFor(() => expect(result.current.party.party?.name).toBe('alice renamed')) + expect(result.current.execute.execute).toBe(before) + + wallet.dispose() + }) + it('advances useExecute().lastTx through a pending then executed txChanged push', async () => { const wallet = walletA() @@ -83,6 +119,7 @@ describe('CantonConnectProvider wallet pushes', () => { const { result } = renderSession(() => ({ connect: useConnect(), + disconnect: useDisconnect(), party: useParty(), execute: useExecute(), })) @@ -100,7 +137,7 @@ describe('CantonConnectProvider wallet pushes', () => { await waitFor(() => expect(result.current.execute.lastTx?.status).toBe('pending')) await act(async () => { - await result.current.connect.disconnect() + await result.current.disconnect.disconnect() }) expect(result.current.party.party).toBe(undefined) diff --git a/canton-connect/src/CantonConnectProvider/CantonConnectProvider.restore.test.tsx b/canton-connect/src/CantonConnectProvider/CantonConnectProvider.restore.test.tsx index 6add13dc..ea008e5f 100644 --- a/canton-connect/src/CantonConnectProvider/CantonConnectProvider.restore.test.tsx +++ b/canton-connect/src/CantonConnectProvider/CantonConnectProvider.restore.test.tsx @@ -24,7 +24,7 @@ const strandOnClosedPicker = async ( result: { current: { sdk: WalletSdk; connect: () => Promise } }, popup: StubPopup, ): Promise => { - const stranded = result.current.sdk + const abandoned = result.current.sdk await act(async () => { const connecting = expect(result.current.connect()).rejects.toBeInstanceOf( @@ -35,8 +35,8 @@ const strandOnClosedPicker = async ( await connecting }) - await waitFor(() => expect(result.current.sdk).not.toBe(stranded)) - return stranded + await waitFor(() => expect(result.current.sdk).not.toBe(abandoned)) + return abandoned } describe('CantonConnectProvider restored sessions', () => { diff --git a/canton-connect/src/CantonConnectProvider/index.tsx b/canton-connect/src/CantonConnectProvider/index.tsx index 90d08e89..0c7b1263 100644 --- a/canton-connect/src/CantonConnectProvider/index.tsx +++ b/canton-connect/src/CantonConnectProvider/index.tsx @@ -103,6 +103,10 @@ export const CantonConnectProvider = ({ [actorRef], ) + // No bridge: the pending `connect()` is what answers a cancel, rejecting on the + // `connect.cancelled` the machine lands on. + const cancelConnect = useCallback(() => actorRef.send({ type: 'connect.cancel' }), [actorRef]) + const connect = useConnectBridge(actorRef) const disconnect = useDisconnectBridge(actorRef) @@ -111,10 +115,11 @@ export const CantonConnectProvider = ({ config, connection: actorRef, connect, + cancelConnect, disconnect, resetConnectError, }), - [config, actorRef, connect, disconnect, resetConnectError], + [config, actorRef, connect, cancelConnect, disconnect, resetConnectError], ) return {children} diff --git a/canton-connect/src/CantonConnectProvider/useConnectBridge.test.tsx b/canton-connect/src/CantonConnectProvider/useConnectBridge.test.tsx index f7779473..f0c9aa67 100644 --- a/canton-connect/src/CantonConnectProvider/useConnectBridge.test.tsx +++ b/canton-connect/src/CantonConnectProvider/useConnectBridge.test.tsx @@ -6,11 +6,12 @@ import type { AccountsInput } from '#src/machine/accountsActors' import { accountsMachine, type WalletAccounts } from '#src/machine/accountsMachine' import type { ConnectInput, InitInput } from '#src/machine/connectionActors' import { connectionMachine, type WalletStatusUpdate } from '#src/machine/connectionMachine' +import { testParty } from '#src/testing/party' import { pause } from '#src/testing/pause' import { startConnection } from '#src/testing/startConnection' const connection: WalletStatusUpdate['connection'] = { isConnected: true, isNetworkConnected: true } -const party = { partyId: 'alice::1220ab', networkId: 'canton:local' } +const party = testParty('alice::1220ab') const readingAccounts = (read: () => Promise) => accountsMachine.provide({ diff --git a/canton-connect/src/CantonConnectProvider/useDisconnectBridge.test.tsx b/canton-connect/src/CantonConnectProvider/useDisconnectBridge.test.tsx index 95375ad2..47b143d3 100644 --- a/canton-connect/src/CantonConnectProvider/useDisconnectBridge.test.tsx +++ b/canton-connect/src/CantonConnectProvider/useDisconnectBridge.test.tsx @@ -12,11 +12,12 @@ import type { RestoreInput, } from '#src/machine/connectionActors' import { connectionMachine, type WalletStatusUpdate } from '#src/machine/connectionMachine' +import { testParty } from '#src/testing/party' import { pause } from '#src/testing/pause' import { startConnection } from '#src/testing/startConnection' const connection: WalletStatusUpdate['connection'] = { isConnected: true, isNetworkConnected: true } -const party = { partyId: 'alice::1220ab', networkId: 'canton:local' } +const party = testParty('alice::1220ab') const accounts = accountsMachine.provide({ actors: { diff --git a/canton-connect/src/connectError.ts b/canton-connect/src/connectError.ts index f74891b6..75c1b346 100644 --- a/canton-connect/src/connectError.ts +++ b/canton-connect/src/connectError.ts @@ -37,8 +37,8 @@ export class InitFailedError extends Error { * A connect the user walked away from: the picker was closed rather than a wallet failing. * * @example - * if (connectError !== undefined && !(connectError instanceof ConnectCancelledError)) { - * toast.error(connectError.message) + * if (error !== undefined && !(error instanceof ConnectCancelledError)) { + * toast.error(error.message) * } * * @category Errors diff --git a/canton-connect/src/guardedConnect.test.ts b/canton-connect/src/guardedConnect.test.ts index dd40fa4e..b90d7ec9 100644 --- a/canton-connect/src/guardedConnect.test.ts +++ b/canton-connect/src/guardedConnect.test.ts @@ -1,6 +1,6 @@ import { DappSDK } from '@canton-network/dapp-sdk' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { PickerClosedError } from '#src/connectError' +import { ConnectCancelledError, PickerClosedError } from '#src/connectError' import { guardedConnect } from '#src/guardedConnect' import { createAutoPicker } from '#src/testing/autoPicker' import { createFakeWallet } from '#src/testing/fakeWallet' @@ -232,4 +232,80 @@ describe('guardedConnect', () => { await expect(guardedConnect(sdk)).rejects.toBe(pickerError) expect(window.open).toBe(original) }) + + it('rejects as a cancel when the caller abandons the connect', async () => { + openStub(watchable()) + const stubbed = window.open + const controller = new AbortController() + + const settled = expect( + guardedConnect(stubSdk({ opens: true }), controller.signal), + ).rejects.toBeInstanceOf(ConnectCancelledError) + + controller.abort() + await settled + // the forged pick is delivered on a task of its own, which counts as a pending timer here + await tick() + + // the race's `finally` is what releases both, so an unsettled race would leak them + expect(vi.getTimerCount()).toBe(0) + expect(window.open).toBe(stubbed) + }) + + it('closes the picker window it captured', async () => { + const popup = watchable() + const close = vi.fn(() => { + popup.closed = true + }) + popup.close = close + openStub(popup) + const controller = new AbortController() + + const settled = expect( + guardedConnect(stubSdk({ opens: true }), controller.signal), + ).rejects.toBeInstanceOf(ConnectCancelledError) + + controller.abort() + await settled + + expect(close).toHaveBeenCalledOnce() + }) + + it('rejects at once on a signal already aborted', async () => { + openStub(watchable()) + + await expect( + guardedConnect(stubSdk({ opens: true }), AbortSignal.abort()), + ).rejects.toBeInstanceOf(ConnectCancelledError) + }) + + it('forges the abandoned pick only when no other connect is in flight', async () => { + openStub(watchable()) + const posted = vi.spyOn(window, 'postMessage') + const abandonedPick = expect.objectContaining({ providerId: 'abandoned' }) + + const other = stubSdk() + const abandoned = guardedConnect(other) + const controller = new AbortController() + const cancelling = guardedConnect(stubSdk({ opens: true }), controller.signal) + + controller.abort() + await expect(cancelling).rejects.toBeInstanceOf(ConnectCancelledError) + + // the message reaches every picker listener on the page, so it would settle `other`'s pick too + expect(posted).not.toHaveBeenCalledWith(abandonedPick, expect.anything()) + + other.settle() + await abandoned + + const alone = new AbortController() + const solo = guardedConnect(stubSdk({ opens: true }), alone.signal) + + alone.abort() + await expect(solo).rejects.toBeInstanceOf(ConnectCancelledError) + + expect(posted).toHaveBeenCalledWith(abandonedPick, expect.anything()) + + posted.mockRestore() + }) }) diff --git a/canton-connect/src/guardedConnect.ts b/canton-connect/src/guardedConnect.ts index 0f9dcfce..6cf9d9bb 100644 --- a/canton-connect/src/guardedConnect.ts +++ b/canton-connect/src/guardedConnect.ts @@ -2,7 +2,7 @@ // popup leaves connect() pending forever. See architecture.md. import type { DappSDK } from '@canton-network/dapp-sdk' -import { PickerClosedError } from '#src/connectError' +import { ConnectCancelledError, PickerClosedError } from '#src/connectError' const POLL_MS = 400 @@ -61,6 +61,33 @@ const settleAbandonedConnect = (): void => { ) } +/** Rejects when the caller abandons the connect, closing the picker window on the way out. */ +const abandonOn = (signal: AbortSignal | undefined): Promise => + new Promise((_, reject) => { + if (signal === undefined) { + return + } + + const abandon = () => { + if (opened?.closed === false) { + opened.close() + } + + if (inFlight === 1) { + settleAbandonedConnect() + } + + reject(new ConnectCancelledError()) + } + + if (signal.aborted) { + abandon() + return + } + + signal.addEventListener('abort', abandon, { once: true }) + }) + /** Reports the wallet type from the picker's result message; call the return value to stop. */ const watchForPick = (picked: (walletType: unknown) => void): (() => void) => { const listener = (event: MessageEvent): void => { @@ -84,7 +111,10 @@ const watchForPick = (picked: (walletType: unknown) => void): (() => void) => { * const result = await guardedConnect(sdk) * if (!result.isConnected) throw new Error(result.reason) */ -export const guardedConnect = (sdk: Pick): ReturnType => { +export const guardedConnect = ( + sdk: Pick, + signal?: AbortSignal, +): ReturnType => { if (typeof window === 'undefined') { return sdk.connect() } @@ -121,7 +151,7 @@ export const guardedConnect = (sdk: Pick): ReturnType { + return Promise.race([sdk.connect(), dismissed, abandonOn(signal)]).finally(() => { clearInterval(poll) unwatchPick() inFlight -= 1 diff --git a/canton-connect/src/hooks/useConnect.ts b/canton-connect/src/hooks/useConnect.ts index ac65729a..a61c19a3 100644 --- a/canton-connect/src/hooks/useConnect.ts +++ b/canton-connect/src/hooks/useConnect.ts @@ -7,58 +7,56 @@ import { toConnectionStatus } from '#src/machine/connectionMachine' /** * Return shape of {@link useConnect}. * - * `connect` resolves once the party lands and rejects a cancel with {@link ConnectCancelledError}. - * - * `disconnect` settles within 10 s even unanswered; `reset` forgets only `connectError`. + * `connect` resolves once the party lands; `cancelConnect` abandons one in flight, rejecting it + * with {@link ConnectCancelledError}; `reset` forgets the error. * * @category Hooks */ export interface UseConnectResult { + cancelConnect: () => void connect: () => Promise - disconnect: () => Promise - isConnecting: boolean + isPending: boolean isConnected: boolean - connectError: Error | undefined + error: Error | undefined reset: () => void } /** - * Connects and disconnects the wallet, and reports that transition. `connect` takes no argument: - * the picker chooses the wallet, so there is no mode to pass. Gate a pending face on - * `isConnecting` and session-dependent content on `useParty().party`, not on `isConnected`. - * Wagmi: `useConnect` + `useDisconnect`, bundled because one provider owns the session. + * Connects the wallet and reports that transition. `connect` takes no argument: the picker chooses + * the wallet, so there is no mode to pass. Gate a pending face on `isPending` and + * session-dependent content on `useParty().party`, not on `isConnected`. * * @throws with no {@link CantonConnectProvider} above it, as every hook here does. * * @example - * const { connect, isConnecting } = useConnect() - * * * @category Hooks */ export const useConnect = (): UseConnectResult => { - const { connect, connection, disconnect, resetConnectError } = useCantonConnectContext() + const { cancelConnect, connect, connection, resetConnectError } = useCantonConnectContext() const status = useSelector(connection, toConnectionStatus) - const isConnecting = useSelector(connection, (snapshot) => snapshot.hasTag('connecting')) + const isPending = useSelector(connection, (snapshot) => snapshot.hasTag('connecting')) const lastConnectError = useSelector(connection, (snapshot) => snapshot.context.lastConnectError) // Classified in a memo rather than in the selector, so one failure keeps one identity: mapping it // on every snapshot would hand back a new Error each time, and a consumer comparing it across // renders would report the same failure twice. - const connectError = useMemo( + const error = useMemo( () => (lastConnectError === undefined ? undefined : toConnectError(lastConnectError)), [lastConnectError], ) return { + cancelConnect, connect, - disconnect, - isConnecting, + isPending, isConnected: status === 'connected', - connectError, + error, reset: resetConnectError, } } diff --git a/canton-connect/src/hooks/useDisconnect.test.tsx b/canton-connect/src/hooks/useDisconnect.test.tsx new file mode 100644 index 00000000..1513dd6f --- /dev/null +++ b/canton-connect/src/hooks/useDisconnect.test.tsx @@ -0,0 +1,21 @@ +import { renderHook } from '@testing-library/react' +import type { ReactNode } from 'react' +import { describe, expect, it } from 'vitest' +import { useDisconnect } from '#src/hooks/useDisconnect' +import { FakeSessionProvider, type FakeSessionProviderProps } from '#src/testing/fakeSession' + +const session = (status: FakeSessionProviderProps['status']) => ({ + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), +}) + +describe('useDisconnect', () => { + it('is pending only while the machine is disconnecting', () => { + const connected = renderHook(() => useDisconnect(), session('connected')) + const disconnecting = renderHook(() => useDisconnect(), session('disconnecting')) + + expect(connected.result.current.isPending).toBe(false) + expect(disconnecting.result.current.isPending).toBe(true) + }) +}) diff --git a/canton-connect/src/hooks/useDisconnect.ts b/canton-connect/src/hooks/useDisconnect.ts new file mode 100644 index 00000000..08e7936a --- /dev/null +++ b/canton-connect/src/hooks/useDisconnect.ts @@ -0,0 +1,36 @@ +import { useSelector } from '@xstate/react' +import type { CantonConnectProvider } from '#src/CantonConnectProvider' +import { useCantonConnectContext } from '#src/CantonConnectProvider' +import { toConnectionStatus } from '#src/machine/connectionMachine' + +/** + * Return shape of {@link useDisconnect}. `disconnect` settles within 10 s even unanswered. + * + * @category Hooks + */ +export interface UseDisconnectResult { + disconnect: () => Promise + isPending: boolean +} + +/** + * Disconnects the wallet and reports that transition. No `error`: a disconnect always settles, by + * the timeout if the wallet never answers. + * + * @throws with no {@link CantonConnectProvider} above it, as every hook here does. + * + * @example + * const { disconnect, isPending } = useDisconnect() + * + * + * @category Hooks + */ +export const useDisconnect = (): UseDisconnectResult => { + const { connection, disconnect } = useCantonConnectContext() + + const status = useSelector(connection, toConnectionStatus) + + return { disconnect, isPending: status === 'disconnecting' } +} diff --git a/canton-connect/src/hooks/useExecute.test.tsx b/canton-connect/src/hooks/useExecute.test.tsx new file mode 100644 index 00000000..fc1e6000 --- /dev/null +++ b/canton-connect/src/hooks/useExecute.test.tsx @@ -0,0 +1,81 @@ +import type { PrepareExecuteAndWaitResult } from '@canton-network/dapp-sdk' +import { act, renderHook } from '@testing-library/react' +import type { ReactNode } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { useExecute } from '#src/hooks/useExecute' +import { FakeSessionProvider } from '#src/testing/fakeSession' +import { testParty } from '#src/testing/party' +import type { Party, WalletSdk } from '#src/types' + +const party = testParty('alice::1220ab') + +const executed: PrepareExecuteAndWaitResult = { + tx: { + status: 'executed', + commandId: 'cmd-1', + payload: { updateId: 'update-1', completionOffset: 42 }, + }, +} + +const liveSession = ( + prepareExecuteAndWait: WalletSdk['prepareExecuteAndWait'], + connectedParty: Party | undefined, +) => { + const sdk: Partial = { + prepareExecuteAndWait, + onTxChanged: async () => undefined, + removeOnTxChanged: async () => undefined, + } + + return { + wrapper: ({ children }: { children: ReactNode }) => ( + + {children} + + ), + } +} + +describe('useExecute', () => { + it('fills actAs with the connected party when the caller sets none', async () => { + const prepareExecuteAndWait = vi + .fn() + .mockResolvedValue(executed) + const { result } = renderHook(() => useExecute(), liveSession(prepareExecuteAndWait, party)) + + await act(async () => { + await result.current.execute({ commands: [] }) + }) + + expect(prepareExecuteAndWait).toHaveBeenCalledWith({ commands: [], actAs: [party.partyId] }) + }) + + it("leaves a caller's own actAs alone", async () => { + const prepareExecuteAndWait = vi + .fn() + .mockResolvedValue(executed) + const { result } = renderHook(() => useExecute(), liveSession(prepareExecuteAndWait, party)) + + await act(async () => { + await result.current.execute({ commands: [], actAs: ['bob::1220cd'] }) + }) + + expect(prepareExecuteAndWait).toHaveBeenCalledWith({ commands: [], actAs: ['bob::1220cd'] }) + }) + + it('refuses a submit over a session that reports no party', async () => { + const prepareExecuteAndWait = vi + .fn() + .mockResolvedValue(executed) + const { result } = renderHook(() => useExecute(), liveSession(prepareExecuteAndWait, undefined)) + + await act(async () => { + await expect(result.current.execute({ commands: [] })).rejects.toThrow( + 'wallet reports no usable party', + ) + }) + + expect(prepareExecuteAndWait).not.toHaveBeenCalled() + expect(result.current.error).toBeUndefined() + }) +}) diff --git a/canton-connect/src/hooks/useExecute.ts b/canton-connect/src/hooks/useExecute.ts index d66d5865..f043d4f4 100644 --- a/canton-connect/src/hooks/useExecute.ts +++ b/canton-connect/src/hooks/useExecute.ts @@ -10,29 +10,33 @@ import type { TxStatusSnapshot } from '#src/types' */ export type { PrepareExecuteParams } +// An unset `actAs` lets the wallet pick its own primary, which may not be the party the kit shows. +/** Defaults `actAs` to the connected party, leaving a caller's own `actAs` untouched. */ +const withActAs = (params: PrepareExecuteParams, partyId: string): PrepareExecuteParams => + params.actAs === undefined ? { ...params, actAs: [partyId] } : params + /** * Return shape of {@link useExecute}. `execute` resolves once the ledger has executed rather than - * at submission, and throws when nothing is connected; `lastTx` follows the wallet's own - * `txChanged` pushes, so it moves even while `execute` is still pending. + * at submission, and throws when nothing is connected or no party is reported; `lastTx` follows + * the wallet's own `txChanged` pushes, so it moves even while `execute` is still pending. * * @category Hooks */ export interface UseExecuteResult { execute: (params: PrepareExecuteParams) => Promise lastTx: TxStatusSnapshot | undefined - isExecuting: boolean + isPending: boolean error: Error | undefined reset: () => void } /** * Submits ledger commands and tracks the transaction in `lastTx`, fed by the SDK's `txChanged` - * event. - * Wagmi: `useWriteContract` + `useWaitForTransactionReceipt`, since `execute` resolves after - * execution rather than at submission. + * event. `actAs` defaults to the party `useParty` reports, so a submit acts as the party the UI + * shows rather than the wallet's own primary. * * @throws with no {@link CantonConnectProvider} above it, and from `execute` where nothing is - * connected or the command fails, the failure also landing in `error`. + * connected or no party is reported. A command that fails throws too, and lands in `error`. * * @example * const { execute, lastTx } = useExecute() @@ -42,15 +46,17 @@ export interface UseExecuteResult { * @category Hooks */ export const useExecute = (): UseExecuteResult => { - const { call, isBusy, error, reset, connection, sdk } = useWalletCall() + const { call, isPending, error, reset, connection, sdk } = useWalletCall() const lastTx = useTxFeed(sdk, connection) const execute = useCallback( (params: PrepareExecuteParams): Promise => - call((walletSdk) => walletSdk.prepareExecuteAndWait(params)), + call((walletSdk, actingPartyId) => + walletSdk.prepareExecuteAndWait(withActAs(params, actingPartyId)), + ), [call], ) - return { execute, lastTx, isExecuting: isBusy, error, reset } + return { execute, lastTx, isPending, error, reset } } diff --git a/canton-connect/src/hooks/useLedger.test.tsx b/canton-connect/src/hooks/useLedger.test.tsx index 7bfb91da..f3ee88d4 100644 --- a/canton-connect/src/hooks/useLedger.test.tsx +++ b/canton-connect/src/hooks/useLedger.test.tsx @@ -5,9 +5,10 @@ import type { ReactNode } from 'react' import { describe, expect, it, vi } from 'vitest' import { type LedgerApiParams, useLedger } from '#src/hooks/useLedger' import { FakeSessionProvider } from '#src/testing/fakeSession' +import { testParty } from '#src/testing/party' import type { WalletSdk } from '#src/types' -const party = { partyId: 'alice::1220ab', networkId: 'canton:local' } +const party = testParty('alice::1220ab') const request: LedgerApiParams = { requestMethod: 'get', resource: '/v2/parties' } describe('useLedger', () => { diff --git a/canton-connect/src/hooks/useLedger.ts b/canton-connect/src/hooks/useLedger.ts index 51c34446..5f0add60 100644 --- a/canton-connect/src/hooks/useLedger.ts +++ b/canton-connect/src/hooks/useLedger.ts @@ -21,7 +21,6 @@ export interface UseLedgerResult { /** * Escape hatch for ledger reads `useExecute` and `useSignMessage` do not cover: the participant's * JSON API, passed through untyped. - * Wagmi: `usePublicClient`. * * @throws with no {@link CantonConnectProvider} above it, and from `ledgerApi` itself where nothing * is connected, which `isReady` is there to check first. @@ -33,7 +32,7 @@ export interface UseLedgerResult { * @category Hooks */ export const useLedger = (): UseLedgerResult => { - // Guards without `call`: a stateless query needs no busy/error renders around it. + // Guards without `call`: a stateless query needs no pending/error renders around it. const { sdk, status, isLocked } = useWalletCall() const ledgerApi = useCallback( diff --git a/canton-connect/src/hooks/useParty.ts b/canton-connect/src/hooks/useParty.ts index 2eb9bc89..9ff4b23a 100644 --- a/canton-connect/src/hooks/useParty.ts +++ b/canton-connect/src/hooks/useParty.ts @@ -4,8 +4,8 @@ import { toConnectionStatus } from '#src/machine/connectionMachine' import type { ConnectionStatus, Party } from '#src/types' /** - * Return shape of {@link useParty}. `party` follows the wallet's primary account, so it changes - * under a live session when the user switches accounts. + * Return shape of {@link useParty}. `party` is the primary among the accounts that can act on the + * ledger, so it need not be the one the wallet flags primary, and it changes under a live session. * * @category Hooks */ @@ -18,7 +18,6 @@ export interface UsePartyResult { /** * The connected account and status. `party` is `undefined` until a connect succeeds, and again * whenever a restored session is locked. - * Wagmi: `useAccount`, with `party.partyId` for `address`. * * @throws with no {@link CantonConnectProvider} above it. * diff --git a/canton-connect/src/hooks/usePartyType.test.tsx b/canton-connect/src/hooks/usePartyType.test.tsx new file mode 100644 index 00000000..5a9dde49 --- /dev/null +++ b/canton-connect/src/hooks/usePartyType.test.tsx @@ -0,0 +1,86 @@ +// The rule over a session that answers; a refusal reaches the caller as the rejection it was. + +import { act, renderHook } from '@testing-library/react' +import type { ReactNode } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { usePartyType } from '#src/hooks/usePartyType' +import { FakeSessionProvider } from '#src/testing/fakeSession' +import { testParty } from '#src/testing/party' +import type { Party, WalletSdk } from '#src/types' + +const party = testParty('alice::1220ab') + +const liveSession = (sdk: Partial, connectedParty: Party | undefined) => ({ + wrapper: ({ children }: { children: ReactNode }) => ( + + {children} + + ), +}) + +type LedgerAnswer = Awaited> + +const answering = (answer: LedgerAnswer) => + vi.fn().mockResolvedValue(answer) + +describe('usePartyType', () => { + it('reads the participant id and calls a party under its namespace local', async () => { + const ledgerApi = answering({ participantId: 'participant::1220ab' }) + const { result } = renderHook(() => usePartyType(), liveSession({ ledgerApi }, party)) + + expect(result.current.isReady).toBe(true) + + await act(async () => { + await expect(result.current.readPartyType()).resolves.toBe('local') + }) + + expect(ledgerApi).toHaveBeenCalledTimes(1) + expect(ledgerApi).toHaveBeenCalledWith({ + requestMethod: 'get', + resource: '/v2/parties/participant-id', + }) + }) + + it('calls a party under another namespace external', async () => { + const ledgerApi = answering({ participantId: 'participant::1220ff' }) + const { result } = renderHook(() => usePartyType(), liveSession({ ledgerApi }, party)) + + await act(async () => { + await expect(result.current.readPartyType()).resolves.toBe('external') + }) + }) + + it('hands a refusal back as the rejection it was', async () => { + const refused = new Error('RPC error: -32601 - method not allowed') + const ledgerApi = vi.fn().mockRejectedValue(refused) + const { result } = renderHook(() => usePartyType(), liveSession({ ledgerApi }, party)) + + await act(async () => { + await expect(result.current.readPartyType()).rejects.toBe(refused) + }) + }) + + it('rejects an answer carrying no participant id', async () => { + const ledgerApi = answering({ parties: [] }) + const { result } = renderHook(() => usePartyType(), liveSession({ ledgerApi }, party)) + + await act(async () => { + await expect(result.current.readPartyType()).rejects.toThrow( + 'participant id not found in {"parties":[]}', + ) + }) + }) + + it('refuses over a session that reports no party, without asking the ledger', async () => { + const ledgerApi = answering({ participantId: 'participant::1220ab' }) + const { result } = renderHook(() => usePartyType(), liveSession({ ledgerApi }, undefined)) + + expect(result.current.isReady).toBe(false) + + await act(async () => { + await expect(result.current.readPartyType()).rejects.toThrow('wallet reports no usable party') + }) + + expect(ledgerApi).not.toHaveBeenCalled() + }) +}) diff --git a/canton-connect/src/hooks/usePartyType.ts b/canton-connect/src/hooks/usePartyType.ts new file mode 100644 index 00000000..78800db9 --- /dev/null +++ b/canton-connect/src/hooks/usePartyType.ts @@ -0,0 +1,71 @@ +import { useSelector } from '@xstate/react' +import { useCallback } from 'react' +import type { CantonConnectProvider } from '#src/CantonConnectProvider' +import { assertUsable, useWalletCall } from '#src/hooks/useWalletCall' +import type { PartyType, WalletSdk } from '#src/types' + +const namespaceOf = (id: string): string | undefined => /::(.+)$/.exec(id)?.[1] + +const readParticipantNamespace = async (sdk: WalletSdk): Promise => { + const answer = await sdk.ledgerApi({ + requestMethod: 'get', + resource: '/v2/parties/participant-id', + }) + const namespace = + typeof answer.participantId === 'string' ? namespaceOf(answer.participantId) : undefined + + if (namespace === undefined) { + throw new Error(`participant id not found in ${JSON.stringify(answer)}`) + } + + return namespace +} + +/** + * Return shape of {@link usePartyType}. `readPartyType` throws when nothing is connected or no + * party is reported, which `isReady` is there to check first. + * + * @category Hooks + */ +export interface UsePartyTypeResult { + readPartyType: () => Promise + isReady: boolean +} + +/** + * Tells a local party from an external one when asked. Each `readPartyType` call is one + * `ledgerApi` read of the participant id, its namespace compared with the party's; nothing is + * cached, so hold the answer where several components need it. Reach for it before an action a + * local party cannot take, such as `signMessage`, which the reference gateway refuses. + * + * @throws with no {@link CantonConnectProvider} above it, and from `readPartyType` where nothing + * is connected or no party is reported, which `isReady` is there to check first. + * + * @example + * const { readPartyType } = usePartyType() + * if ((await readPartyType()) === 'local') { + * toast.error('This wallet cannot sign messages for a local party') + * } + * + * @category Hooks + */ +export const usePartyType = (): UsePartyTypeResult => { + // Guards without `call`: a stateless query needs no pending/error renders around it. + const { connection, sdk, status, isLocked } = useWalletCall() + const party = useSelector(connection, (snapshot) => snapshot.context.party) + + const readPartyType = useCallback(async (): Promise => { + assertUsable(status, isLocked) + + if (party === undefined) { + throw new Error('wallet reports no usable party - allocate one in the wallet') + } + + const participantNamespace = await readParticipantNamespace(sdk) + + // Canton's rule, not a wallet's: a local party shares the participant's namespace. + return party.namespace === participantNamespace ? 'local' : 'external' + }, [isLocked, party, sdk, status]) + + return { readPartyType, isReady: status === 'connected' && !isLocked && party !== undefined } +} diff --git a/canton-connect/src/hooks/useSignMessage.test.tsx b/canton-connect/src/hooks/useSignMessage.test.tsx index 0f31dd9f..1c13d2d0 100644 --- a/canton-connect/src/hooks/useSignMessage.test.tsx +++ b/canton-connect/src/hooks/useSignMessage.test.tsx @@ -6,13 +6,14 @@ import type { ReactNode } from 'react' import { describe, expect, it, vi } from 'vitest' import { useSignMessage } from '#src/hooks/useSignMessage' import { FakeSessionProvider } from '#src/testing/fakeSession' -import type { WalletSdk } from '#src/types' +import { testParty } from '#src/testing/party' +import type { Party, WalletSdk } from '#src/types' -const party = { partyId: 'alice::1220ab', networkId: 'canton:local' } +const party = testParty('alice::1220ab') -const liveSession = (sdk: Partial) => ({ +const liveSession = (sdk: Partial, connectedParty: Party | undefined) => ({ wrapper: ({ children }: { children: ReactNode }) => ( - + {children} ), @@ -21,7 +22,7 @@ const liveSession = (sdk: Partial) => ({ describe('useSignMessage', () => { it('publishes the signature the wallet answered with', async () => { const signMessage = vi.fn().mockResolvedValue({ signature: 'sig' }) - const { result } = renderHook(() => useSignMessage(), liveSession({ signMessage })) + const { result } = renderHook(() => useSignMessage(), liveSession({ signMessage }, party)) await act(async () => { await expect(result.current.signMessage('hello')).resolves.toBe('sig') @@ -30,13 +31,13 @@ describe('useSignMessage', () => { expect(signMessage).toHaveBeenCalledWith({ message: 'hello' }) expect(result.current.signature).toBe('sig') expect(result.current.error).toBeUndefined() - expect(result.current.isSigning).toBe(false) + expect(result.current.isPending).toBe(false) }) it('captures the wallet refusal and rethrows it', async () => { const refused = new Error('user refused to sign') const signMessage = vi.fn().mockRejectedValue(refused) - const { result } = renderHook(() => useSignMessage(), liveSession({ signMessage })) + const { result } = renderHook(() => useSignMessage(), liveSession({ signMessage }, party)) await act(async () => { await expect(result.current.signMessage('hello')).rejects.toBe(refused) @@ -44,13 +45,13 @@ describe('useSignMessage', () => { expect(result.current.error).toBe(refused) expect(result.current.signature).toBeUndefined() - expect(result.current.isSigning).toBe(false) + expect(result.current.isPending).toBe(false) }) it('publishes a refusal that arrived as a JSON-RPC object as an Error', async () => { const rpcError = { code: 4001, message: 'user refused to sign' } const signMessage = vi.fn().mockRejectedValue(rpcError) - const { result } = renderHook(() => useSignMessage(), liveSession({ signMessage })) + const { result } = renderHook(() => useSignMessage(), liveSession({ signMessage }, party)) await act(async () => { await expect(result.current.signMessage('hello')).rejects.toBeInstanceOf(Error) @@ -67,7 +68,7 @@ describe('useSignMessage', () => { .fn() .mockResolvedValueOnce({ signature: 'sig' }) .mockRejectedValueOnce(refused) - const { result } = renderHook(() => useSignMessage(), liveSession({ signMessage })) + const { result } = renderHook(() => useSignMessage(), liveSession({ signMessage }, party)) await act(async () => { await result.current.signMessage('hello') @@ -93,4 +94,18 @@ describe('useSignMessage', () => { expect(result.current.error).toBeUndefined() }) + + it('refuses a signature over a session that reports no party', async () => { + const signMessage = vi.fn().mockResolvedValue({ signature: 'sig' }) + const { result } = renderHook(() => useSignMessage(), liveSession({ signMessage }, undefined)) + + await act(async () => { + await expect(result.current.signMessage('hello')).rejects.toThrow( + 'wallet reports no usable party', + ) + }) + + expect(signMessage).not.toHaveBeenCalled() + expect(result.current.error).toBeUndefined() + }) }) diff --git a/canton-connect/src/hooks/useSignMessage.ts b/canton-connect/src/hooks/useSignMessage.ts index 8b13b96e..3c048d77 100644 --- a/canton-connect/src/hooks/useSignMessage.ts +++ b/canton-connect/src/hooks/useSignMessage.ts @@ -3,25 +3,24 @@ import type { CantonConnectProvider } from '#src/CantonConnectProvider' import { useWalletCall } from '#src/hooks/useWalletCall' /** - * Return shape of {@link useSignMessage}. `signMessage` throws when nothing is connected, and - * `reset` clears the last signature and error without touching the session. + * Return shape of {@link useSignMessage}. `signMessage` throws when nothing is connected or no + * party is reported, and `reset` clears the last signature and error without touching the session. * * @category Hooks */ export interface UseSignMessageResult { signMessage: (message: string) => Promise signature: string | undefined - isSigning: boolean + isPending: boolean error: Error | undefined reset: () => void } /** * Signs an arbitrary message with the connected wallet; the SDK owns the encoding. - * Wagmi: `useSignMessage`, same name and job. * * @throws with no {@link CantonConnectProvider} above it, and from `signMessage` where nothing is - * connected or the wallet rejects, the rejection also landing in `error`. + * connected or no party is reported. A wallet refusal throws too, and lands in `error`. * * @example * const { signMessage } = useSignMessage() @@ -30,7 +29,7 @@ export interface UseSignMessageResult { * @category Hooks */ export const useSignMessage = (): UseSignMessageResult => { - const { call, isBusy, error, reset: resetCall } = useWalletCall() + const { call, isPending, error, reset: resetCall } = useWalletCall() const [signature, setSignature] = useState(undefined) @@ -51,5 +50,5 @@ export const useSignMessage = (): UseSignMessageResult => { resetCall() }, [resetCall]) - return { signMessage, signature, isSigning: isBusy, error, reset } + return { signMessage, signature, isPending, error, reset } } diff --git a/canton-connect/src/hooks/useWalletCall.ts b/canton-connect/src/hooks/useWalletCall.ts index 12da5c0d..978278c5 100644 --- a/canton-connect/src/hooks/useWalletCall.ts +++ b/canton-connect/src/hooks/useWalletCall.ts @@ -6,12 +6,14 @@ import { toConnectionStatus } from '#src/machine/connectionMachine' import type { ConnectionStatus, ConnectionSubscription, WalletSdk } from '#src/types' /** The resting state, hoisted so a hook that never called keeps one identity across renders. */ -const IDLE = { isBusy: false, error: undefined } as const +const IDLE = { isPending: false, error: undefined } as const /** In-flight and last-failure bookkeeping for one wallet call. */ -type WalletCallState = { isBusy: boolean; error: Error | undefined } +type WalletCallState = { isPending: boolean; error: Error | undefined } + +/** What a caller hands `call`: the SDK client, and the id of the party the call acts as. */ +type WalletCallRun = (sdk: WalletSdk, partyId: string) => Promise -// The one home of the two guard messages the SDK-calling hooks throw. /** Throws when the wallet is disconnected or locked, the guard every SDK-calling hook shares. */ export const assertUsable = (status: ConnectionStatus, isLocked: boolean): void => { if (status !== 'connected') { @@ -23,13 +25,20 @@ export const assertUsable = (status: ConnectionStatus, isLocked: boolean): void } } +/** Throws when the session reports no party, which a connected one can. */ +function assertPartyId(partyId: string | undefined): asserts partyId is string { + if (partyId === undefined) { + throw new Error('wallet reports no usable party - allocate one in the wallet') + } +} + /** - * Return shape of {@link useWalletCall}: busy/error state around one call, plus the session + * Return shape of {@link useWalletCall}: pending/error state around one call, plus the session * pieces the public hooks assemble into their own results. */ export interface UseWalletCallResult { - call: (run: (sdk: WalletSdk) => Promise) => Promise - isBusy: boolean + call: (run: WalletCallRun) => Promise + isPending: boolean error: Error | undefined reset: () => void connection: ConnectionSubscription @@ -39,44 +48,46 @@ export interface UseWalletCallResult { } // The skeleton shared by the SDK-calling hooks: session selectors, the guards, and the -// busy/error bookkeeping around one call. Internal; the public hooks shape its pieces. +// pending/error bookkeeping around one call. Internal; the public hooks shape its pieces. /** - * Selects the session and wraps one SDK call with the connect/lock guard and busy/error - * bookkeeping that `useExecute`, `useSignMessage` and `useLedger` share. + * Selects the session and wraps one SDK call with the connect, lock and party guards plus the + * pending/error bookkeeping `useExecute` and `useSignMessage` share. */ export const useWalletCall = (): UseWalletCallResult => { const { connection } = useCantonConnectContext() const sdk = useSelector(connection, (snapshot) => snapshot.context.sdk) + const partyId = useSelector(connection, (snapshot) => snapshot.context.party?.partyId) const status = useSelector(connection, toConnectionStatus) const isLocked = useSelector(connection, (snapshot) => snapshot.hasTag('unauthenticated')) const [state, setState] = useState(IDLE) const call = useCallback( - async (run: (walletSdk: WalletSdk) => Promise): Promise => { + async (run: WalletCallRun): Promise => { assertUsable(status, isLocked) + assertPartyId(partyId) - setState({ isBusy: true, error: undefined }) + setState({ isPending: true, error: undefined }) try { - const result = await run(sdk) + const result = await run(sdk, partyId) setState(IDLE) return result } catch (err) { const error = toError(err) - setState({ isBusy: false, error }) + setState({ isPending: false, error }) throw error } }, - [isLocked, sdk, status], + [isLocked, partyId, sdk, status], ) const reset = useCallback((): void => setState(IDLE), []) return { call, - isBusy: state.isBusy, + isPending: state.isPending, error: state.error, reset, connection, diff --git a/canton-connect/src/index.ts b/canton-connect/src/index.ts index 6be9aebd..7fc97801 100644 --- a/canton-connect/src/index.ts +++ b/canton-connect/src/index.ts @@ -13,12 +13,16 @@ export { CantonConnectProvider, useCantonConnectContext } from '#src/CantonConne export { ConnectCancelledError } from '#src/connectError' export type { UseConnectResult } from '#src/hooks/useConnect' export { useConnect } from '#src/hooks/useConnect' +export type { UseDisconnectResult } from '#src/hooks/useDisconnect' +export { useDisconnect } from '#src/hooks/useDisconnect' export type { PrepareExecuteParams, UseExecuteResult } from '#src/hooks/useExecute' export { useExecute } from '#src/hooks/useExecute' export type { LedgerApiParams, UseLedgerResult } from '#src/hooks/useLedger' export { useLedger } from '#src/hooks/useLedger' export type { UsePartyResult } from '#src/hooks/useParty' export { useParty } from '#src/hooks/useParty' +export type { UsePartyTypeResult } from '#src/hooks/usePartyType' +export { usePartyType } from '#src/hooks/usePartyType' export type { UseSignMessageResult } from '#src/hooks/useSignMessage' export { useSignMessage } from '#src/hooks/useSignMessage' export type { UseWalletStatusResult } from '#src/hooks/useWalletStatus' @@ -34,6 +38,7 @@ export type { ConnectionStatus, ConnectionSubscription, Party, + PartyType, TxStatusSnapshot, WalletSdk, } from '#src/types' diff --git a/canton-connect/src/machine/accountsMachine.test.ts b/canton-connect/src/machine/accountsMachine.test.ts index 2871599e..d684bf83 100644 --- a/canton-connect/src/machine/accountsMachine.test.ts +++ b/canton-connect/src/machine/accountsMachine.test.ts @@ -10,12 +10,13 @@ import { connectionMachine, type WalletStatusUpdate } from '#src/machine/connect // DOM. import { accountsInput } from '#src/testing/accountsInput' import { connectionInput } from '#src/testing/connectionInput' +import { testParty } from '#src/testing/party' import { pause } from '#src/testing/pause' const connection: WalletStatusUpdate['connection'] = { isConnected: true, isNetworkConnected: true } -const party = { partyId: 'alice::1220ab', networkId: 'canton:local' } +const party = testParty('alice::1220ab') const accounts: WalletAccounts = { party } -const pushedParty = { partyId: 'bob::1220cd', networkId: 'canton:local' } +const pushedParty = testParty('bob::1220cd') const sessionWith = (accountsLogic: typeof accountsMachine) => connectionMachine.provide({ diff --git a/canton-connect/src/machine/connectionActors.ts b/canton-connect/src/machine/connectionActors.ts index 11bfefbb..db70afd9 100644 --- a/canton-connect/src/machine/connectionActors.ts +++ b/canton-connect/src/machine/connectionActors.ts @@ -1,6 +1,6 @@ import type { DappSDK, StatusEvent } from '@canton-network/dapp-sdk' import { type EventObject, fromCallback, fromPromise } from 'xstate' -import { InitFailedError, PickerClosedError } from '#src/connectError' +import { ConnectCancelledError, InitFailedError, PickerClosedError } from '#src/connectError' import { guardedConnect } from '#src/guardedConnect' import type { WalletStatusUpdate } from '#src/machine/connectionMachine' @@ -87,7 +87,7 @@ const standingSession = async (sdk: RestoreInput['sdk']): Promise(async ({ input }) => { +export const connect = fromPromise(async ({ input, signal }) => { const { sdk, guardPicker } = input try { @@ -98,7 +98,7 @@ export const connect = fromPromise(async ({ in } try { - const connection = await (guardPicker ? guardedConnect(sdk) : sdk.connect()) + const connection = await (guardPicker ? guardedConnect(sdk, signal) : sdk.connect()) const walletAnswer = { connection } if (connection.isConnected) { @@ -109,9 +109,9 @@ export const connect = fromPromise(async ({ in return recovered ?? walletAnswer } catch (error) { - // No recovering here: a closed picker leaves this sdk's connect() running, and a recovered - // session would keep a client the stranded connect can still swap (see `retireSdk`). - if (error instanceof PickerClosedError) { + // No recovering from either: both leave this sdk's connect() running, and a recovered session + // would keep a client the abandoned connect can still swap (see `retireSdk`). + if (error instanceof PickerClosedError || error instanceof ConnectCancelledError) { throw error } diff --git a/canton-connect/src/machine/connectionMachine.test.ts b/canton-connect/src/machine/connectionMachine.test.ts index a27560f7..02b975d8 100644 --- a/canton-connect/src/machine/connectionMachine.test.ts +++ b/canton-connect/src/machine/connectionMachine.test.ts @@ -1,4 +1,6 @@ // @vitest-environment node + +import type { WalletPickerFn } from '@canton-network/dapp-sdk' import { describe, expect, it, vi } from 'vitest' import { createActor, @@ -8,7 +10,7 @@ import { SimulatedClock, waitFor, } from 'xstate' -import { PickerClosedError } from '#src/connectError' +import { ConnectCancelledError, PickerClosedError } from '#src/connectError' import type { AccountsInput } from '#src/machine/accountsActors' import { accountsMachine, type WalletAccounts } from '#src/machine/accountsMachine' import { @@ -29,10 +31,11 @@ import { // Not the '#src/testing' barrel: it re-exports fakeSession, whose Lit-backed SDK import needs a // DOM. import { connectionInput } from '#src/testing/connectionInput' +import { testParty } from '#src/testing/party' import { pause } from '#src/testing/pause' const connection: WalletStatusUpdate['connection'] = { isConnected: true, isNetworkConnected: true } -const party = { partyId: 'alice::1220ab', networkId: 'canton:local' } +const party = testParty('alice::1220ab') // Provided wherever a recorded sequence walks through a session: the real read reaches an sdk // double that never answers, which would park those sequences in `reading`. @@ -1169,7 +1172,7 @@ describe('connectionMachine', () => { actor.start() actor.send({ type: 'restore' }) await pause(0) - const stranded = actor.getSnapshot().context.sdk + const abandoned = actor.getSnapshot().context.sdk actor.send({ type: 'disconnect' }) clock.increment(DISCONNECT_TIMEOUT_MS - 1) @@ -1178,7 +1181,7 @@ describe('connectionMachine', () => { clock.increment(1) expect(actor.getSnapshot().matches('disconnected')).toBe(true) - expect(actor.getSnapshot().context.sdk).not.toBe(stranded) + expect(actor.getSnapshot().context.sdk).not.toBe(abandoned) actor.stop() }) @@ -1312,16 +1315,16 @@ describe('connectionMachine', () => { }) }) - // A picker the user walked out on leaves sdk.connect() running inside the instance, so that + // A picker the user abandoned leaves sdk.connect() running inside the instance, so that // instance is abandoned and the session re-derived on a fresh one. - describe('retiring the stranded sdk', () => { + describe('retiring the abandoned sdk', () => { const closedPicker = fromPromise(() => Promise.reject(new PickerClosedError()), ) // One name per instance, so which of them the machine inits after the swap is visible. const namedSdks = () => { - const names = ['stranded', 'replacement'] + const names = ['abandoned', 'replacement'] const inited: string[] = [] const createSdk = () => { @@ -1338,12 +1341,12 @@ describe('connectionMachine', () => { return { createSdk, inited } } - it('answers the walked-out connect as a cancel, on a replaced sdk', async () => { + it('answers the abandoned connect as a cancel, on a replaced sdk', async () => { const machine = connectionMachine.provide({ actors: { accounts, connect: closedPicker } }) const actor = createActor(machine, { input: connectionInput() }) actor.start() - const stranded = actor.getSnapshot().context.sdk + const abandoned = actor.getSnapshot().context.sdk actor.send({ type: 'connect' }) @@ -1354,7 +1357,7 @@ describe('connectionMachine', () => { expect(settled.hasTag('connect.failed')).toBe(false) // nothing failed, so nothing is left for a consumer to read expect(settled.context.lastConnectError).toBeUndefined() - expect(settled.context.sdk).not.toBe(stranded) + expect(settled.context.sdk).not.toBe(abandoned) actor.stop() }) @@ -1477,7 +1480,7 @@ describe('connectionMachine', () => { // A status of disconnected or idle in between would unmount a status-gated app while its // session survives, so every step of the resume must read as the attempt still running. - it('resumes the standing session when a wallet change is walked out on', async () => { + it('resumes the standing session when a wallet change is abandoned', async () => { const machine = connectionMachine.provide({ actors: { accounts, init, restore, connect: closedPicker }, }) @@ -1593,6 +1596,135 @@ describe('connectionMachine', () => { }) }) + describe('cancelling a connect', () => { + // The wallet that took the request and answered nothing: no session, no error, no close to + // watch for, so only the user can end the wait. + const neverAnswers = fromPromise( + () => new Promise(() => {}), + ) + const nothingToRestore = fromPromise(() => + Promise.reject(new Error('no session')), + ) + + it('answers a wallet that never replies as a cancel, on a replaced sdk', () => { + const machine = connectionMachine.provide({ actors: { accounts, connect: neverAnswers } }) + const actor = createActor(machine, { input: connectionInput() }) + + actor.start() + const abandoned = actor.getSnapshot().context.sdk + + actor.send({ type: 'connect' }) + actor.send({ type: 'connect.cancel' }) + + const settled = actor.getSnapshot() + + expect(settled.matches('retiring')).toBe(true) + expect(settled.hasTag('connect.cancelled')).toBe(true) + expect(settled.hasTag('connect.failed')).toBe(false) + // nothing failed, so nothing is left for a consumer to read + expect(settled.context.lastConnectError).toBeUndefined() + // the abandoned connect() still holds this instance's client + expect(settled.context.sdk).not.toBe(abandoned) + + actor.stop() + }) + + it('lands disconnected once the replacement has booted', async () => { + const machine = connectionMachine.provide({ + actors: { accounts, init, connect: neverAnswers, restore: nothingToRestore }, + }) + const actor = createActor(machine, { input: connectionInput() }) + + actor.start() + actor.send({ type: 'connect' }) + actor.send({ type: 'connect.cancel' }) + await pause(0) + + expect(actor.getSnapshot().matches('disconnected')).toBe(true) + expect(actor.getSnapshot().context.lastConnectError).toBeUndefined() + + actor.stop() + }) + + // A status of disconnected or idle in between would unmount a status-gated app while its + // session survives, so every step of the resume must read as the attempt still running. + it('resumes the standing session when a wallet change is cancelled', async () => { + const machine = connectionMachine.provide({ + actors: { accounts, init, restore, connect: neverAnswers }, + }) + const actor = createActor(machine, { input: connectionInput() }) + + actor.start() + actor.send({ type: 'restore' }) + await pause(0) + + expect(actor.getSnapshot().matches({ session: { authenticated: 'ready' } })).toBe(true) + + const reported: string[] = [] + const subscription = actor.subscribe((snapshot) => { + reported.push(toConnectionStatus(snapshot)) + }) + + actor.send({ type: 'connect' }) + actor.send({ type: 'connect.cancel' }) + await pause(0) + + expect(actor.getSnapshot().matches({ session: { authenticated: 'ready' } })).toBe(true) + expect(reported).not.toContain('disconnected') + expect(reported).not.toContain('idle') + expect(reported.at(-1)).toBe('connected') + + subscription.unsubscribe() + actor.stop() + }) + + it('refuses the pick the abandoned connect still owes', async () => { + const machine = connectionMachine.provide({ actors: { accounts, connect: neverAnswers } }) + const actor = createActor(machine, { input: connectionInput() }) + + actor.start() + const abandoned = actor.getSnapshot().context.sdk as unknown as { + walletPicker: WalletPickerFn + } + + actor.send({ type: 'connect' }) + actor.send({ type: 'connect.cancel' }) + + await expect(abandoned.walletPicker([])).rejects.toBeInstanceOf(ConnectCancelledError) + + actor.stop() + }) + + it('ends the session when cancelled during the party ready', async () => { + const machine = connectionMachine.provide({ + actors: { + connect, + disconnect, + accounts: accountsMachine.provide({ + actors: { + readAccounts: fromPromise(() => new Promise(() => {})), + }, + }), + }, + }) + const actor = createActor(machine, { input: connectionInput() }) + + actor.start() + actor.send({ type: 'connect' }) + await pause(0) + + expect(actor.getSnapshot().matches({ session: { authenticated: 'reading' } })).toBe(true) + + actor.send({ type: 'connect.cancel' }) + await pause(0) + + expect(actor.getSnapshot().matches('disconnected')).toBe(true) + expect(actor.getSnapshot().hasTag('connect.cancelled')).toBe(true) + + actor.stop() + }) + }) + describe('reset', () => { it('forgets the last failure on request', async () => { const machine = connectionMachine.provide({ diff --git a/canton-connect/src/machine/connectionMachine.ts b/canton-connect/src/machine/connectionMachine.ts index 842eca37..5f252058 100644 --- a/canton-connect/src/machine/connectionMachine.ts +++ b/canton-connect/src/machine/connectionMachine.ts @@ -4,10 +4,11 @@ import { assign, type DoneActorEvent, type ErrorActorEvent, + enqueueActions, type SnapshotFrom, setup, } from 'xstate' -import { InitFailedError, PickerClosedError } from '#src/connectError' +import { ConnectCancelledError, InitFailedError, PickerClosedError } from '#src/connectError' import { accountsMachine } from '#src/machine/accountsMachine' import { connect, @@ -197,6 +198,13 @@ const askWallet = (retiringTarget: string) => ], }) as const +/** A connect attempt and the two exits that keep no session: the picker closing, and a cancel. */ +const connectAttempt = (retiringTarget: string) => + ({ + invoke: askWallet(retiringTarget), + on: { 'connect.cancel': { actions: { type: 'retireSdk' }, target: retiringTarget } }, + }) as const + /** * The lifecycle itself: what a connect, a restore, a lock and a disconnect mean, and the tags the * bridges and hooks read off them. `CantonConnectProvider` runs it; reach for it directly only to @@ -221,10 +229,17 @@ export const connectionMachine = setup({ ), })), forgetError: assign({ lastConnectError: undefined }), - // The walked-out connect keeps waiting inside the old sdk and nothing can stop it, so a later - // attempt on that sdk could have its client swapped mid-connect. Drop the instance, take a new - // one. - retireSdk: assign(({ context }) => ({ sdk: context.createSdk() })), + retireSdk: enqueueActions(({ enqueue }) => { + enqueue(({ context }) => { + // A cancel can land before the popup exists, while connect() is still in the SDK's + // discovery wait. That connect() keeps running and opens the popup by calling + // `walletPicker`; a rejecting one ends it first. + Object.assign(context.sdk, { + walletPicker: () => Promise.reject(new ConnectCancelledError()), + }) + }) + enqueue.assign({ sdk: ({ context }) => context.createSdk() }) + }), }, guards: { // A guard that throws stops the actor, so an answer missing `connection` reads as not @@ -247,7 +262,7 @@ export const connectionMachine = setup({ | 'connect.settled' // The connect is answered by the failure riding in `lastConnectError`. | 'connect.failed' - // The connect was walked out on: it ends with no session and no error recorded. + // The connect was abandoned: it ends with no session and no error recorded. | 'connect.cancelled' // A connect is still in progress to consumers, the account read after the wallet's answer // included. @@ -259,6 +274,7 @@ export const connectionMachine = setup({ | 'disconnect.settled', events: {} as | { type: 'connect' } + | { type: 'connect.cancel' } | { type: 'connectError.reset' } | { type: 'disconnect' } | { type: 'restore' } @@ -305,8 +321,8 @@ export const connectionMachine = setup({ // The variants carry what is at stake: `new` risks no session, `changing` is a // wallet change over a standing one, and a closed picker resumes that session. states: { - new: { invoke: askWallet('#connection.retiring.new') }, - changing: { invoke: askWallet('#connection.retiring.changing') }, + new: connectAttempt('#connection.retiring.new'), + changing: connectAttempt('#connection.retiring.changing'), }, on: { // Leaving the state is not enough: sdk.connect() keeps running past this, so the wallet @@ -354,7 +370,11 @@ export const connectionMachine = setup({ ], }, states: { - reading: { tags: ['connecting'] }, + reading: { + tags: ['connecting'], + // The wallet is connected by now, so ending the attempt means ending the session. + on: { 'connect.cancel': { target: '#connection.disconnecting' } }, + }, ready: { tags: ['connect.settled'] }, unavailable: { tags: ['connect.failed'] }, }, diff --git a/canton-connect/src/testing/fakeSession.test.tsx b/canton-connect/src/testing/fakeSession.test.tsx index 03699039..4433d1fe 100644 --- a/canton-connect/src/testing/fakeSession.test.tsx +++ b/canton-connect/src/testing/fakeSession.test.tsx @@ -12,12 +12,12 @@ describe('FakeSessionProvider', () => { ) const { result } = renderHook(() => useConnect(), { wrapper }) - expect(result.current.connectError).toBe(failed) + expect(result.current.error).toBe(failed) act(() => { result.current.reset() }) - expect(result.current.connectError).toBeUndefined() + expect(result.current.error).toBeUndefined() }) }) diff --git a/canton-connect/src/testing/fakeSession.tsx b/canton-connect/src/testing/fakeSession.tsx index 53a2f2e6..050df3de 100644 --- a/canton-connect/src/testing/fakeSession.tsx +++ b/canton-connect/src/testing/fakeSession.tsx @@ -105,7 +105,7 @@ export interface FakeSessionProviderProps { * * @example * render( - * + * * * , * ) @@ -146,6 +146,7 @@ export const FakeSessionProvider = ({ config: CONFIG, connection, connect, + cancelConnect: () => connection.send({ type: 'connect.cancel' }), disconnect, resetConnectError: () => connection.send({ type: 'connectError.reset' }), }), diff --git a/canton-connect/src/testing/hangingPicker.ts b/canton-connect/src/testing/hangingPicker.ts new file mode 100644 index 00000000..86ca2793 --- /dev/null +++ b/canton-connect/src/testing/hangingPicker.ts @@ -0,0 +1,4 @@ +import type { WalletPickerFn } from '@canton-network/dapp-sdk' + +/** For the wallet that takes a connect and goes quiet. */ +export const hangingPicker: WalletPickerFn = () => new Promise(() => {}) diff --git a/canton-connect/src/testing/party.ts b/canton-connect/src/testing/party.ts new file mode 100644 index 00000000..51ab6b8d --- /dev/null +++ b/canton-connect/src/testing/party.ts @@ -0,0 +1,9 @@ +import type { Party } from '#src/types' + +/** A `Party` for tests; the namespace is the id's suffix, as the fake and mock wallets report. */ +export const testParty = (partyId: string, networkId = 'canton:local'): Party => ({ + partyId, + networkId, + namespace: partyId.split('::')[1] ?? partyId, + signingProviderId: 'test', +}) diff --git a/canton-connect/src/testing/useSession.ts b/canton-connect/src/testing/useSession.ts index e9910b10..fdd0e9ea 100644 --- a/canton-connect/src/testing/useSession.ts +++ b/canton-connect/src/testing/useSession.ts @@ -1,17 +1,19 @@ import { useSelector } from '@xstate/react' import { useCantonConnectContext } from '#src/CantonConnectProvider' import { useConnect } from '#src/hooks/useConnect' +import { useDisconnect } from '#src/hooks/useDisconnect' import { useParty } from '#src/hooks/useParty' import { useWalletStatus } from '#src/hooks/useWalletStatus' import type { ConnectionStatus, Party, WalletSdk } from '#src/types' /** Every slice of the session in one object, which is what the suites assert against. */ type Session = { + cancelConnect: () => void connect: () => Promise - connectError: Error | undefined disconnect: () => Promise - isConnecting: boolean + error: Error | undefined isLocked: boolean + isPending: boolean party: Party | undefined reset: () => void sdk: WalletSdk @@ -21,7 +23,7 @@ type Session = { /** * Everything the reader hooks publish, in one object, so a provider test drives the real SDK and * asserts on the public surface. `sdk` rides along because no hook publishes it and a test watching - * a stranded instance get replaced has nothing else to watch. + * an abandoned instance get replaced has nothing else to watch. * * @example * const { result } = renderHook(() => useSession(), { wrapper }) @@ -30,11 +32,23 @@ type Session = { export const useSession = (): Session => { const { connection } = useCantonConnectContext() - const { connect, connectError, disconnect, isConnecting, reset } = useConnect() + const { cancelConnect, connect, error, isPending, reset } = useConnect() + const { disconnect } = useDisconnect() const { party, status } = useParty() const { isLocked } = useWalletStatus() const sdk = useSelector(connection, (snapshot) => snapshot.context.sdk) - return { connect, connectError, disconnect, isConnecting, isLocked, party, reset, sdk, status } + return { + cancelConnect, + connect, + disconnect, + error, + isLocked, + isPending, + party, + reset, + sdk, + status, + } } diff --git a/canton-connect/src/types.ts b/canton-connect/src/types.ts index 1916bcbb..54e73d04 100644 --- a/canton-connect/src/types.ts +++ b/canton-connect/src/types.ts @@ -52,14 +52,25 @@ export type ConnectionStatus = | 'disconnected' /** - * The connected account, normalized from the wallet's CIP-0103 account entry. `networkId` is the - * wallet's own, falling back to `CantonConnectConfig.networkId` where the wallet reports none. + * Canton's terms: a local party lives under the hosting participant's namespace and the participant + * signs for it; an external party lives under its own key's and signs for itself. + * + * @category Types + */ +export type PartyType = 'local' | 'external' + +/** + * The connected account, normalized from the wallet's CIP-0103 account entry. `networkId` falls + * back to `CantonConnectConfig.networkId` where the wallet reports none. `namespace` and + * `signingProviderId` come through as reported; CIP-0103 names no `signingProviderId` values. * * @category Types */ export interface Party { partyId: string networkId: string + namespace: string + signingProviderId: string name?: string publicKey?: string } @@ -114,7 +125,7 @@ export type ConnectionSubscription = Pick Promise + cancelConnect: () => void disconnect: () => Promise resetConnectError: () => void } diff --git a/canton-connect/src/walletAccount.test.ts b/canton-connect/src/walletAccount.test.ts index 049ea907..8f949ec5 100644 --- a/canton-connect/src/walletAccount.test.ts +++ b/canton-connect/src/walletAccount.test.ts @@ -2,14 +2,23 @@ import { WALLET_DISABLED_REASON } from '@canton-network/core-types' import { describe, expect, it } from 'vitest' import { selectPrimaryAccount, selectUsableAccounts, toParty } from '#src/walletAccount' +type RawAccount = Parameters[0] + +const raw = (partyId: string, rest: Partial = {}): RawAccount => ({ + partyId, + namespace: 'fp', + signingProviderId: 'test', + ...rest, +}) + const partyIdsOf = (accounts: { partyId: string }[]): string[] => accounts.map((account) => account.partyId) describe('selectUsableAccounts', () => { it('keeps an allocated account, and one a wallet reports no status for', () => { const usable = selectUsableAccounts([ - { partyId: 'allocated::fp', status: 'allocated' }, - { partyId: 'unstated::fp' }, + raw('allocated::fp', { status: 'allocated' }), + raw('unstated::fp'), ]) expect(partyIdsOf(usable)).toEqual(['allocated::fp', 'unstated::fp']) @@ -17,9 +26,9 @@ describe('selectUsableAccounts', () => { it('drops the statuses that hold no ledger rights', () => { const usable = selectUsableAccounts([ - { partyId: 'pending::fp', status: 'initialized' }, - { partyId: 'gone::fp', status: 'removed' }, - { partyId: 'live::fp', status: 'allocated' }, + raw('pending::fp', { status: 'initialized' }), + raw('gone::fp', { status: 'removed' }), + raw('live::fp', { status: 'allocated' }), ]) expect(partyIdsOf(usable)).toEqual(['live::fp']) @@ -27,12 +36,11 @@ describe('selectUsableAccounts', () => { it('keeps a disabled account whose signing provider went unmatched', () => { const usable = selectUsableAccounts([ - { - partyId: 'unmatched::fp', + raw('unmatched::fp', { status: 'allocated', disabled: true, reason: WALLET_DISABLED_REASON.NO_SIGNING_PROVIDER_MATCHED, - }, + }), ]) expect(partyIdsOf(usable)).toEqual(['unmatched::fp']) @@ -40,13 +48,12 @@ describe('selectUsableAccounts', () => { it('drops a disabled account for any other reason, a missing one included', () => { const usable = selectUsableAccounts([ - { - partyId: 'renamespaced::fp', + raw('renamespaced::fp', { status: 'allocated', disabled: true, reason: WALLET_DISABLED_REASON.PARTICIPANT_NAMESPACE_CHANGED, - }, - { partyId: 'unexplained::fp', status: 'allocated', disabled: true }, + }), + raw('unexplained::fp', { status: 'allocated', disabled: true }), ]) expect(usable).toEqual([]) @@ -60,32 +67,52 @@ describe('selectPrimaryAccount', () => { it('picks the entry flagged primary', () => { const primary = selectPrimaryAccount([ - { partyId: 'a::fp', primary: false }, - { partyId: 'b::fp', primary: true }, - { partyId: 'c::fp' }, + raw('a::fp', { primary: false }), + raw('b::fp', { primary: true }), + raw('c::fp'), ]) + expect(primary?.partyId).toBe('b::fp') }) it('falls back to the first entry when nothing is flagged primary', () => { - const primary = selectPrimaryAccount([{ partyId: 'a::fp' }, { partyId: 'b::fp' }]) + const primary = selectPrimaryAccount([raw('a::fp'), raw('b::fp')]) + expect(primary?.partyId).toBe('a::fp') }) }) describe('toParty', () => { it('maps the wallet account into Party shape and uses the fallback networkId when missing', () => { - const party = toParty({ partyId: 'alice::fp', hint: 'alice' }, 'canton:local') - expect(party).toEqual({ partyId: 'alice::fp', networkId: 'canton:local', name: 'alice' }) + const party = toParty(raw('alice::fp', { hint: 'alice' }), 'canton:local') + + expect(party).toEqual({ + partyId: 'alice::fp', + networkId: 'canton:local', + namespace: 'fp', + signingProviderId: 'test', + name: 'alice', + }) }) it('prefers the account-supplied networkId when present', () => { const party = toParty( - { partyId: 'alice::fp', networkId: 'canton:prod', publicKey: 'pk' }, + raw('alice::fp', { networkId: 'canton:prod', publicKey: 'pk' }), 'canton:local', ) + expect(party.networkId).toBe('canton:prod') expect(party.publicKey).toBe('pk') expect(party.name).toBe(undefined) }) + + it('passes namespace and signingProviderId through as the wallet states them', () => { + const party = toParty( + raw('op::1220aa', { namespace: '1220aa', signingProviderId: 'participant' }), + 'canton:local', + ) + + expect(party.namespace).toBe('1220aa') + expect(party.signingProviderId).toBe('participant') + }) }) diff --git a/canton-connect/src/walletAccount.ts b/canton-connect/src/walletAccount.ts index 729a3ce0..a37b40d3 100644 --- a/canton-connect/src/walletAccount.ts +++ b/canton-connect/src/walletAccount.ts @@ -11,6 +11,8 @@ import type { Party } from '#src/types' interface RawWalletAccount { primary?: boolean partyId: string + namespace: string + signingProviderId: string hint?: string publicKey?: string networkId?: string @@ -50,6 +52,8 @@ export const selectPrimaryAccount = (accounts: RawWalletAccount[]): RawWalletAcc export const toParty = (account: RawWalletAccount, fallbackNetworkId: string): Party => ({ partyId: account.partyId, networkId: account.networkId ?? fallbackNetworkId, + namespace: account.namespace, + signingProviderId: account.signingProviderId, ...(account.hint === undefined ? {} : { name: account.hint }), ...(account.publicKey === undefined ? {} : { publicKey: account.publicKey }), }) diff --git a/canton-connect/typedoc.json b/canton-connect/typedoc.json index 79a355ab..3da217ff 100644 --- a/canton-connect/typedoc.json +++ b/canton-connect/typedoc.json @@ -2,6 +2,7 @@ "$schema": "https://typedoc.org/schema.json", "extends": ["../typedoc.shared.json"], "entryPoints": ["src/index.ts", "src/testing/index.ts"], + "projectDocuments": ["coming-from-wagmi.md"], "intentionallyNotExported": ["ConnectionActorRef"], "categoryOrder": ["Components", "Hooks", "Configuration", "Types", "Errors", "Utilities", "*"], "defaultCategory": "Types", diff --git a/canton-connect/vitest.config.ts b/canton-connect/vitest.config.ts index ad6ca4cc..82028f6b 100644 --- a/canton-connect/vitest.config.ts +++ b/canton-connect/vitest.config.ts @@ -6,6 +6,8 @@ export default defineConfig({ test: { environment: 'jsdom', setupFiles: ['./vitest.setup.ts'], + // Node 26's own localStorage global shadows jsdom's under vitest 4; vitest 5 fixes it. + execArgv: ['--no-experimental-webstorage'], // The suite's wall clock is SDK timer waits, not CPU; without an explicit count, // core-starved CI runners get too few workers to overlap the files. maxWorkers: 8, diff --git a/canton-dappbooster/CLAUDE.md b/canton-dappbooster/CLAUDE.md index ddb046fa..14a5804b 100644 --- a/canton-dappbooster/CLAUDE.md +++ b/canton-dappbooster/CLAUDE.md @@ -26,7 +26,7 @@ L2 headless components; styling is L3, in [`canton-theme`](../canton-theme). See they would need tree-shaking to reach through a re-export chain into `@canton-network/dapp-sdk`, which declares no `sideEffects` field at all, so a bundler must keep the whole package once its import survives into the graph. This package's own `sideEffects: false` does not help there. - The generated reference already lists `` beside the other components, so there is + The generated reference already lists the wallet buttons beside the other components, so there is no documentation argument for collapsing the split either; see the root `CLAUDE.md`. - Nothing in `src/providers/` renders DOM of its own, so those folders carry no theme rules and no part classes: there is no markup to style. The authoring steps below are for components that @@ -81,9 +81,15 @@ prints the two it will not edit for you, 3 and 5. It decides nothing below; it o - **A component owns no user-facing copy it cannot justify.** Hardcoded English is as much an L3 decision as a colour, and there is no i18n seam here. Where a caller passes `children`, they win in - every state the component has — `ConnectButton` renders them while pending too, and supplies its own - two strings only when a caller passes none. State stays the component's: the spinner and - `data-pending` are rendered independently of the words. + every state the component has — `ConnectButton` renders them while pending too, and supplies its + own two strings only when a caller passes none; `WalletButton` hands them to the face it picks. + State stays the component's: the spinner and `data-pending` are rendered independently of the + words. `CancelButton`'s live region is the exception a caller cannot override, because a spinner + is `aria-hidden` and its accessible name has to stay on the action rather than the wait. +- The three wallet buttons publish `ref` (`ComponentPropsWithRef<'button'>`) where the rest of the + kit does not, because `WalletButton` swaps one face for another and has to hand focus to the one + that took over. `WalletButton` itself takes none: which element it landed on would depend on the + session. - Components import no CSS. `sideEffects: false` depends on it. - `tsconfig.json`'s `customConditions: ["development"]` is load-bearing: without it `@bootnodedev/canton-connect` resolves through its `types` entry into `dist/`, which is diff --git a/canton-dappbooster/README.md b/canton-dappbooster/README.md index b763206f..bfdca163 100644 --- a/canton-dappbooster/README.md +++ b/canton-dappbooster/README.md @@ -7,8 +7,9 @@ cannot carry a Canton amount without losing digits. `src/index.ts` is the public API, and every export carries JSDoc that your editor will surface at the call site and that is published at -[docs-canton-dappbooster.vercel.app](https://docs-canton-dappbooster.vercel.app). `` sits behind the `/connect` sub-path instead, because it is the one -component that reaches for the wallet session and so pulls in the Canton SDK. Authoring rules for +[docs.dappbooster.cc](https://docs.dappbooster.cc/). The wallet +buttons sit behind the `/connect` sub-path instead, because they reach for the wallet session and so +pull in the Canton SDK. Authoring rules for new components live in [`CLAUDE.md`](https://github.com/BootNodeDev/canton-dappbooster/blob/main/canton-dappbooster/CLAUDE.md). ## Scripts diff --git a/canton-dappbooster/architecture.md b/canton-dappbooster/architecture.md index 9c519a5c..ec7cac0d 100644 --- a/canton-dappbooster/architecture.md +++ b/canton-dappbooster/architecture.md @@ -53,24 +53,22 @@ Zag earns its place where the interaction is one HTML does not supply, such as a roving focus, a popup with dismiss and outside-click, or a composite navigated by arrow keys. Holding state is not the trigger; everything else is hand-rolled on plain React state. So a `@zag-js/*` dependency lands with the widget that needs it, never ahead of it: `@zag-js/dialog` and -`@zag-js/react` arrived with ``'s token select, `@zag-js/popover` with -``'s account face. The React adapter reaches for `react-dom` from `useMachine` down, +`@zag-js/react` arrived with ``'s token select. The React adapter reaches for +`react-dom` from `useMachine` down, not only from `Portal`, and declares it a peer of its own, which is why `react-dom` joins `react` as a peer dependency here; a consumer already has it, so nothing new is asked of them. -`@bootnodedev/canton-connect` is the third peer, and the one that does ask something. `` -reads the wallet session from its hooks rather than from props, so it throws without a -`` above it — the only component here that requires an ancestor. That is the +`@bootnodedev/canton-connect` is the third peer, and the one that does ask something. The three +components behind `/connect` read the wallet session from its hooks rather than from props, so they +throw without a `` above them — the only components here requiring an +ancestor. That is the price of one source of truth for the session: a prop mirroring it would let a caller contradict a connect already in flight (see the authoring rules in the root `CLAUDE.md`). Every other component in this package stays free of it. -Nothing unopened is paid for. How far that reaches depends on where the trigger lives. The token -select's sits in ``, outside the dialog, so the dialog itself is mounting-is-opening and -a field whose picker is never opened pays for no machine, no scope and no dismiss listeners. The -account popover owns its trigger, which needs `aria-expanded`, `aria-controls` and the toggle from -first paint, so its machine is mounted whenever the button is; what it defers is the panel, gated on -`api.open`, so a popover never opened costs no portal, no positioner and no dismiss layer. +Nothing unopened is paid for. The token select's trigger sits in ``, outside the dialog, +so the dialog itself is mounting-is-opening and a field whose picker is never opened pays for no +machine, no scope and no dismiss listeners. The trigger pays for that. Mounting *is* the open state, so the machine lives inside the dialog and ``'s symbol button cannot spread `api.getTriggerProps()`; it hand-rolls `aria-haspopup`, @@ -148,6 +146,48 @@ number is arbitrary and human-picked, which is the point — the row wraps and d without a cap its height is the consumer's to set and the card has no scroll of its own to catch the spill. +## Where a token's identity and its balance come from + +The list is the consumer's, and the kit reads no ledger. What that costs and buys: + +- **A token is identified by its `instrumentId`**, the admin party plus the id that registry gave + it, because nothing on Canton is a global address and two registries can both issue a `USDC`. + Nothing takes one string for a token, so `tokenKey` is what makes a map key, a React key or an + equality check out of the pair. Compare keys, never symbols. +- **`balance` sits on the token, beside the metadata.** A party's holdings are private to the + participant hosting it, so only a read through the connected wallet can supply them: `useHoldings` + on the `/connect` sub-path is that read, and `sumHoldings` groups its one-per-contract answer into + one row per instrument. An absent balance means the read has not reached, not that the party holds + nothing, so the row shows no figure and sorts below every one that has a figure. +- **A list is a catalogue, not a balance sheet.** `mergeTokens` builds one row per instrument out of + every source that knows about it, later sources winning field by field and an absent field meaning + a source had nothing to say. So holdings annotate rows rather than create them: a token nobody + holds is still offerable, which is what a swap's buy side needs, and an app that wants only what + it can spend filters the result itself. That filter is a screen's rule, never the list's. +- **Metadata comes from the registry, over plain HTTP.** `readInstruments` reads a registry's + catalogue and stamps its admin party, taken from that registry's own `/info`, onto every id: the + instrument list carries bare ids, and an id identifies a token only together with the party that + issued it. It serves no logo, so artwork stays the app's or a curated list's. No session, so it + sits on the main barrel beside `sumHoldings`. +- **The read lives here rather than in `canton-connect`**, even though it needs that package's + session. What it knows is the token standard — an interface id, the shape of a holding view, an + instrument id — and `canton-connect` is a layer over the wallet SDK, kept thin enough to delete. + Putting it there duplicated `InstrumentId` across two published packages and split one operation + in half, because the exact-decimal summing is here and that package cannot import it. + `useLedger` is the documented escape hatch for a read like this, and this is what it is for. +- **`balance` is what the party can spend, and `locked` is the rest.** Not the total, because + `balance` is also what ``'s Max fills and what it validates against, so a total would + have Max offer coin the ledger then refuses. The row shows locked as a second, quieter figure + under the first, and both reach the row's accessible name: the lock icon is `aria-hidden` and says + nothing on its own. Nothing locked and no read at all are one case — `getLockedFigure` drops both, + rather than putting a `0` on every row. +- **`TokenListProvider` owns the order**, balance first and then the order given, so the field, the + list and the favourites cannot disagree about which token leads. A query re-ranks on top of that, + by match kind, in `filterTokens`. +- **A token the metadata missed still renders.** `mergeTokens` fills `name` and `symbol` from the + raw instrument id rather than leaving them out: the row, the chip and the logo's initials all + need them, and a holding that renders as nothing is worse than one that renders as its id. + ## What `` does not take Three props a token field usually has are deliberately absent. **Precision** is not configurable diff --git a/canton-dappbooster/doc-fixtures.d.ts b/canton-dappbooster/doc-fixtures.d.ts index 918d1718..bf931945 100644 --- a/canton-dappbooster/doc-fixtures.d.ts +++ b/canton-dappbooster/doc-fixtures.d.ts @@ -6,7 +6,14 @@ type FixtureNode = import('react').ReactNode type FixtureElement = import('react').ReactElement -type FixtureToken = { id: string; logo?: FixtureNode; name: string; symbol: string } +type FixtureInstrumentId = { admin: string; id: string } +type FixtureToken = { + balance?: string + instrumentId: FixtureInstrumentId + logo?: FixtureNode + name: string + symbol: string +} /* Identifiers and amounts */ @@ -40,6 +47,13 @@ declare const darkSheet: string /* Tokens */ +declare const instrumentId: FixtureInstrumentId +declare const registryUrl: string +declare const holdings: readonly { + amount: string + instrumentId: FixtureInstrumentId + isLocked: boolean +}[] declare const token: FixtureToken declare const tokens: readonly FixtureToken[] declare const mockTokens: readonly FixtureToken[] @@ -51,6 +65,7 @@ declare const setAmount: (value: string) => void declare const setReceiver: (value: string) => void declare const setSelected: (token: FixtureToken) => void declare const setError: (error: unknown) => void +declare const toggleMenu: () => void /* Consumer-side components and host wiring */ diff --git a/canton-dappbooster/package.json b/canton-dappbooster/package.json index aba6bc42..601aa2ca 100644 --- a/canton-dappbooster/package.json +++ b/canton-dappbooster/package.json @@ -68,7 +68,6 @@ }, "dependencies": { "@zag-js/dialog": "^1.43.0", - "@zag-js/popover": "^1.43.0", "@zag-js/react": "^1.43.0" } } diff --git a/canton-dappbooster/src/components/ConnectButton/AccountPopover.tsx b/canton-dappbooster/src/components/ConnectButton/AccountPopover.tsx deleted file mode 100644 index ccf4cae7..00000000 --- a/canton-dappbooster/src/components/ConnectButton/AccountPopover.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { useConnect } from '@bootnodedev/canton-connect' -import * as popover from '@zag-js/popover' -import { mergeProps, normalizeProps, Portal, useMachine } from '@zag-js/react' -import { type ButtonHTMLAttributes, type ReactElement, type ReactNode, useId } from 'react' -import { anatomy, popoverAnatomy } from '#src/components/ConnectButton/anatomy' -import { Identifier } from '#src/components/Identifier' -import { truncateIdentifier } from '#src/components/Identifier/truncate' -import { ChevronDownIcon, LogoutIcon } from '#src/icons' -import { cx } from '#src/utils/cx' -import { SR_ONLY } from '#src/utils/srOnly' - -// The trigger sits in a header row, so the hint cannot keep the whole length a party may give it. -const HINT_LENGTH = 12 - -export type AccountPopoverProps = Omit, 'children'> & { - avatar?: (partyId: string) => ReactNode - partyId: string -} - -/** The face shown with a session: the party trigger and the popover it opens. */ -export const AccountPopover = ({ - avatar, - className, - partyId, - type = 'button', - ...rest -}: AccountPopoverProps): ReactElement => { - const session = useConnect() - const service = useMachine(popover.machine, { - id: useId(), - positioning: { gutter: 4, placement: 'bottom-end' }, - }) - const api = popover.connect(service, normalizeProps) - - const disconnect = (): void => { - api.setOpen(false) - void session.disconnect() - } - - return ( - <> - - {api.open && ( - -
-
- {/* Zag names the dialog only from a rendered title, and this one has nothing to show. */} -

- Account -

- - -
-
-
- )} - - ) -} diff --git a/canton-dappbooster/src/components/ConnectButton/Button.tsx b/canton-dappbooster/src/components/ConnectButton/Button.tsx deleted file mode 100644 index 2dd9eb6c..00000000 --- a/canton-dappbooster/src/components/ConnectButton/Button.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { useConnect } from '@bootnodedev/canton-connect' -import type { ButtonHTMLAttributes, MouseEvent, ReactElement } from 'react' -import { anatomy } from '#src/components/ConnectButton/anatomy' -import { cx } from '#src/utils/cx' - -export type ButtonProps = ButtonHTMLAttributes - -/** The face shown with no session: opens the wallet flow. */ -export const Button = ({ - children, - className, - onClick, - type = 'button', - ...rest -}: ButtonProps): ReactElement => { - const session = useConnect() - const pending = session.isConnecting - - const handleClick = (event: MouseEvent): void => { - onClick?.(event) - if (event.defaultPrevented) return - - void session.connect().catch(() => undefined) - } - - return ( - - ) -} diff --git a/canton-dappbooster/src/components/ConnectButton/ConnectButton.test.tsx b/canton-dappbooster/src/components/ConnectButton/ConnectButton.test.tsx deleted file mode 100644 index fc6b1ac9..00000000 --- a/canton-dappbooster/src/components/ConnectButton/ConnectButton.test.tsx +++ /dev/null @@ -1,208 +0,0 @@ -import { CantonConnectProvider, createMockAdapter } from '@bootnodedev/canton-connect' -import { createAutoPicker, FakeSessionProvider } from '@bootnodedev/canton-connect/testing' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import type { ReactElement } from 'react' -import { describe, expect, it, vi } from 'vitest' -import { ConnectButton } from '#src/components/ConnectButton' -import { anatomy, popoverAnatomy } from '#src/components/ConnectButton/anatomy' -import { stubResizeObserver } from '#src/testing/resizeObserver' - -const PARTY = 'nico::1220df946c5b01ad0f2d2b480f1f43b1d1f2e498f5a49c2f0b1cbb46' -const SHORT_PARTY = 'nico::1220df…0b1cbb46' -const NETWORK = 'canton:local' - -// Every state below the connect flow itself, so a markup assertion pays no SDK discovery sleep. -// null is a session naming no party at all, which an explicit undefined could not say here. -const renderInSession = ( - ui: ReactElement, - party: { partyId: string } | null = { partyId: PARTY }, -): ReturnType => - render( - - {ui} - , - ) - -const renderDisconnected = (ui: ReactElement): ReturnType => - render({ui}) - -// The connect flow is the SDK's, so the few tests that drive it drive the real provider. -const renderWithWallet = (ui: ReactElement): ReturnType => - render( - - {ui} - , - ) - -describe('ConnectButton', () => { - it('renders with the root part', () => { - renderDisconnected() - expect(screen.getByTestId('connect-button')).toHaveClass(anatomy.parts.root) - }) - - it('appends a consumer class to the root part', () => { - renderDisconnected() - expect(screen.getByTestId('connect-button')).toHaveClass(anatomy.parts.root, 'extra') - }) - - it('shows the connect face while no session exists', () => { - renderDisconnected() - expect(screen.getByTestId('connect-button')).toHaveAttribute(anatomy.states.mode, 'connect') - }) - - it('renames the connect face while pending, keeping it focusable to announce that', async () => { - renderWithWallet() - fireEvent.click(screen.getByRole('button', { name: 'Connect wallet' })) - const button = await screen.findByRole('button', { name: 'Connecting…' }) - - expect(button).toHaveAttribute('aria-disabled', 'true') - expect(button).toHaveAttribute(anatomy.states.pending, 'true') - expect(button).toBeEnabled() - }) - - it('keeps a caller-supplied label while pending, that caller owning what it says', async () => { - renderWithWallet(Confirm in your wallet) - const button = screen.getByRole('button', { name: 'Confirm in your wallet' }) - fireEvent.click(button) - - await waitFor(() => expect(button).toHaveAttribute(anatomy.states.pending, 'true')) - expect(button).toHaveAccessibleName('Confirm in your wallet') - }) - - it('runs a consumer handler on the connect face and still connects', async () => { - const onClick = vi.fn() - renderWithWallet(Connect wallet) - fireEvent.click(screen.getByRole('button', { name: 'Connect wallet' })) - - expect(onClick).toHaveBeenCalledTimes(1) - expect(await screen.findByRole('button', { name: SHORT_PARTY })).toBeInTheDocument() - }) - - it('lets a consumer handler bring its own connect by preventing the default', async () => { - renderWithWallet( - event.preventDefault()}>Connect wallet, - ) - fireEvent.click(screen.getByRole('button', { name: 'Connect wallet' })) - - expect(await screen.findByRole('button', { name: 'Connect wallet' })).toHaveAttribute( - anatomy.states.mode, - 'connect', - ) - }) - - it('renders nothing where the placement wants the account face and there is no session', () => { - renderDisconnected() - expect(screen.queryByRole('button')).not.toBeInTheDocument() - }) - - it('names the account face by its truncated party id once connected', () => { - renderInSession() - expect(screen.getByRole('button', { name: SHORT_PARTY })).toHaveAttribute( - anatomy.states.mode, - 'account', - ) - }) - - it('hands the party id to a consumer avatar, beside the party id', () => { - renderInSession( } />) - - expect(screen.getByRole('button', { name: SHORT_PARTY })).toContainElement( - screen.getByRole('presentation'), - ) - expect(screen.getByRole('presentation')).toHaveAttribute('src', `${PARTY}.svg`) - }) - - it('keeps the connect face where a session names no party', () => { - renderInSession(Connect wallet, null) - expect(screen.getByRole('button', { name: 'Connect wallet' })).toHaveAttribute( - anatomy.states.mode, - 'connect', - ) - }) - - it('keeps the connect face where the wallet names an empty party', () => { - renderInSession(Connect wallet, { partyId: '' }) - expect(screen.getByRole('button', { name: 'Connect wallet' })).toHaveAttribute( - anatomy.states.mode, - 'connect', - ) - }) - - it('renders no popover panel until it is opened, and drops it again on close', async () => { - stubResizeObserver() - renderInSession() - const trigger = screen.getByRole('button', { name: SHORT_PARTY }) - - expect(document.querySelector(`.${popoverAnatomy.parts.content}`)).toBeNull() - - fireEvent.click(trigger) - expect(await screen.findByRole('dialog', { name: 'Account' })).toBeInTheDocument() - - fireEvent.click(trigger) - await waitFor(() => - expect(document.querySelector(`.${popoverAnatomy.parts.content}`)).toBeNull(), - ) - }) - - it('names the popover it opens from the account face', async () => { - // Opening it starts the popper, which watches its reference through a ResizeObserver. - stubResizeObserver() - renderInSession() - fireEvent.click(screen.getByRole('button', { name: SHORT_PARTY })) - expect(await screen.findByRole('dialog', { name: 'Account' })).toBeInTheDocument() - }) - - it('runs a consumer handler on the account face and still opens the popover', async () => { - stubResizeObserver() - const onClick = vi.fn() - renderInSession() - fireEvent.click(screen.getByRole('button', { name: SHORT_PARTY })) - - expect(onClick).toHaveBeenCalledTimes(1) - expect(await screen.findByRole('dialog', { name: 'Account' })).toBeInTheDocument() - }) - - it('marks the party id it places in the popover with its own part', async () => { - stubResizeObserver() - renderInSession() - fireEvent.click(screen.getByRole('button', { name: SHORT_PARTY })) - - expect(await screen.findByRole('dialog', { name: 'Account' })).toContainElement( - document.querySelector(`.${popoverAnatomy.parts.partyId}`), - ) - }) - - it('ends the session from the popover, returning to the connect face', async () => { - stubResizeObserver() - renderInSession(Connect wallet) - fireEvent.click(screen.getByRole('button', { name: SHORT_PARTY })) - fireEvent.click(await screen.findByRole('button', { name: 'Disconnect' })) - - expect(await screen.findByRole('button', { name: 'Connect wallet' })).toHaveAttribute( - anatomy.states.mode, - 'connect', - ) - }) - - it('leaves the page where the placement only wants the connect face', () => { - renderInSession( - <> - - - Connect wallet - - , - ) - expect(screen.getByTestId('header')).toHaveAttribute(anatomy.states.mode, 'account') - expect(screen.queryByTestId('hero')).not.toBeInTheDocument() - }) -}) diff --git a/canton-dappbooster/src/components/ConnectButton/anatomy.ts b/canton-dappbooster/src/components/ConnectButton/anatomy.ts deleted file mode 100644 index 1c40ac18..00000000 --- a/canton-dappbooster/src/components/ConnectButton/anatomy.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Pins a {@link ConnectButton} placement to one face, for a screen that only ever wants that one. - * Omit it and the placement follows the session. A pinned placement renders nothing rather than - * the other face, so a header can carry the account face without sprouting a connect button. - * - * @example - * // header: nothing until there is a session - * - * @category Components - */ -export type ConnectButtonMode = 'account' | 'connect' - -export const anatomy = { - parts: { - root: 'cnc-connect-button', - party: 'cnc-connect-button__party', - spinner: 'cnc-connect-button__spinner', - }, - states: { mode: 'data-mode', pending: 'data-pending' }, -} as const - -export const popoverAnatomy = { - parts: { - content: 'cnc-account-popover', - disconnect: 'cnc-account-popover__disconnect', - partyId: 'cnc-account-popover__party-id', - positioner: 'cnc-account-popover__positioner', - title: 'cnc-account-popover__title', - }, -} as const diff --git a/canton-dappbooster/src/components/ConnectButton/index.tsx b/canton-dappbooster/src/components/ConnectButton/index.tsx deleted file mode 100644 index 3c982b3c..00000000 --- a/canton-dappbooster/src/components/ConnectButton/index.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { useParty } from '@bootnodedev/canton-connect' -import type { ButtonHTMLAttributes, ReactElement, ReactNode } from 'react' -import { AccountPopover } from '#src/components/ConnectButton/AccountPopover' -import type { ConnectButtonMode } from '#src/components/ConnectButton/anatomy' -import { Button } from '#src/components/ConnectButton/Button' - -/** - * Props for {@link ConnectButton}. - * - * @example - * } /> - * - * @category Components - */ -export type ConnectButtonProps = ButtonHTMLAttributes & { - mode?: ConnectButtonMode - avatar?: (partyId: string) => ReactNode -} - -/** - * One button that follows the wallet session: a connect trigger with no session, the account - * popover with one, read from `useParty` rather than a prop so nothing can contradict a connect - * already in flight. A `` above it is required, alone among the components - * here. `mode` pins it to one face, which renders nothing rather than the other one; `children` - * replace the whole label, the pending copy included, so word that off `useConnect().isConnecting`. - * Imported from `/connect`, the sub-path that pulls the Canton SDK into a consumer's graph. - * - * @example - * import { ConnectButton } from '@bootnodedev/canton-dappbooster/connect' - * - * - * {label} - * - * @see [anatomy.ts](https://github.com/BootNodeDev/canton-dappbooster/blob/main/canton-dappbooster/src/components/ConnectButton/anatomy.ts) for the part classes and state attributes the theme selects. - * - * @category Components - */ -export const ConnectButton = ({ - avatar, - mode, - ...rest -}: ConnectButtonProps): ReactElement | null => { - const { isConnected, party } = useParty() - const account = party?.partyId - const hasAccount = isConnected && account !== undefined && account !== '' - const showAccount = hasAccount && mode !== 'connect' - const showConnect = !hasAccount && mode !== 'account' - - return showAccount ? ( - - ) : showConnect ? ( - -) +}: TokenRowProps): ReactElement => { + const balance = formatFigure(token.balance) + const locked = getLockedFigure(token) + + return ( + + ) +} diff --git a/canton-dappbooster/src/components/TokenInput/TokenSearch.tsx b/canton-dappbooster/src/components/TokenInput/TokenSearch.tsx index dc4d9000..fff55bfd 100644 --- a/canton-dappbooster/src/components/TokenInput/TokenSearch.tsx +++ b/canton-dappbooster/src/components/TokenInput/TokenSearch.tsx @@ -24,7 +24,7 @@ export const TokenSearch = ({ onChange, ref, value }: TokenSearchProps): ReactEl autoComplete="off" className={anatomy.parts.search} onChange={(event) => onChange(event.target.value)} - placeholder="Search by name or symbol" + placeholder="Search by name, symbol, id or issuer" ref={ref} spellCheck={false} type="search" diff --git a/canton-dappbooster/src/components/TokenInput/TokenSelectDialog.test.tsx b/canton-dappbooster/src/components/TokenInput/TokenSelectDialog.test.tsx index 3064f78d..79fd9234 100644 --- a/canton-dappbooster/src/components/TokenInput/TokenSelectDialog.test.tsx +++ b/canton-dappbooster/src/components/TokenInput/TokenSelectDialog.test.tsx @@ -62,7 +62,7 @@ describe('TokenSelectDialog', () => { const onClose = vi.fn() const onSelect = vi.fn() const returnFocusTo = createRef() - render(dialog({ favoriteIds: ['canton-coin'], onClose, onSelect, returnFocusTo })) + render(dialog({ favoriteIds: [TOKENS[0].instrumentId], onClose, onSelect, returnFocusTo })) const favorites = screen.getByRole('region', { name: 'Favorite tokens' }) expect(favorites).toHaveClass(anatomy.parts.favorites) diff --git a/canton-dappbooster/src/components/TokenInput/TokenSelectDialog.tsx b/canton-dappbooster/src/components/TokenInput/TokenSelectDialog.tsx index a401246b..fb3ac937 100644 --- a/canton-dappbooster/src/components/TokenInput/TokenSelectDialog.tsx +++ b/canton-dappbooster/src/components/TokenInput/TokenSelectDialog.tsx @@ -6,11 +6,11 @@ import { TokenFavorites } from '#src/components/TokenInput/TokenFavorites' import { TokenList } from '#src/components/TokenInput/TokenList' import { TokenSearch } from '#src/components/TokenInput/TokenSearch' import { CloseIcon } from '#src/icons' -import type { Token } from '#src/providers/TokenListProvider/context' +import type { InstrumentId, Token } from '#src/providers/TokenListProvider/context' interface TokenSelectDialogProps { contentId: string - favoriteIds?: readonly string[] + favoriteIds?: readonly InstrumentId[] onClose: () => void onSelect: (token: Token) => void open: boolean @@ -32,8 +32,8 @@ const TokenSelect = ({ ids: { content: contentId }, initialFocusEl: () => searchRef.current, onEscapeKeyDown: (event) => { - // `input[type="search"]` clears on Escape, and the dialog dismisses on the same keydown, so - // the field takes it first rather than a keypress meant to clear the query losing the dialog. + // `input[type="search"]` clears on Escape and the dialog dismisses on the same keydown + // this makes the field take it first and avoids closing the dialog if (query === '' || document.activeElement !== searchRef.current) return event.preventDefault() setQuery('') diff --git a/canton-dappbooster/src/components/TokenInput/anatomy.ts b/canton-dappbooster/src/components/TokenInput/anatomy.ts index d550636f..f135614c 100644 --- a/canton-dappbooster/src/components/TokenInput/anatomy.ts +++ b/canton-dappbooster/src/components/TokenInput/anatomy.ts @@ -32,6 +32,9 @@ export const dialogAnatomy = { list: 'cnc-token-select-dialog__list', positioner: 'cnc-token-select-dialog__positioner', row: 'cnc-token-select-dialog__row', + rowBalance: 'cnc-token-select-dialog__row-balance', + rowFigures: 'cnc-token-select-dialog__row-figures', + rowLocked: 'cnc-token-select-dialog__row-locked', rowLogo: 'cnc-token-select-dialog__row-logo', rowName: 'cnc-token-select-dialog__row-name', rows: 'cnc-token-select-dialog__rows', diff --git a/canton-dappbooster/src/components/TokenInput/caret.ts b/canton-dappbooster/src/components/TokenInput/caret.ts index c1548809..53e77f56 100644 --- a/canton-dappbooster/src/components/TokenInput/caret.ts +++ b/canton-dappbooster/src/components/TokenInput/caret.ts @@ -1,7 +1,3 @@ -// Regrouping rewrites everything to the *left* of the edit, so the caret is anchored to the digits -// after it. Anchoring on the digits before it strands a just-typed decimal separator in front of -// the caret, and the next digit lands in the integer part. - const DIGIT = /\d/ export const countDigitsAfter = (value: string, start: number): number => { diff --git a/canton-dappbooster/src/components/TokenInput/filterTokens.test.ts b/canton-dappbooster/src/components/TokenInput/filterTokens.test.ts index 51e0899f..a67de15b 100644 --- a/canton-dappbooster/src/components/TokenInput/filterTokens.test.ts +++ b/canton-dappbooster/src/components/TokenInput/filterTokens.test.ts @@ -2,8 +2,16 @@ import { describe, expect, it } from 'vitest' import { filterTokens } from '#src/components/TokenInput/filterTokens' import type { Token } from '#src/providers/TokenListProvider/context' -const cc: Token = { id: '0xaaa1', name: 'Canton Coin', symbol: 'CC' } -const weth: Token = { id: '0xbbb2', name: 'Wrapped Ether', symbol: 'WETH' } +const cc: Token = { + instrumentId: { admin: 'dso::1220ab', id: 'Amulet' }, + name: 'Canton Coin', + symbol: 'CC', +} +const weth: Token = { + instrumentId: { admin: 'bridge::1220cd', id: 'WrappedEther' }, + name: 'Wrapped Ether', + symbol: 'WETH', +} const tokens: readonly Token[] = [cc, weth] describe('filterTokens', () => { @@ -19,16 +27,24 @@ describe('filterTokens', () => { expect(filterTokens(tokens, 'CC')).toEqual([cc]) }) - it('matches on id', () => { - expect(filterTokens(tokens, '0xbbb2')).toEqual([weth]) + it('matches on the instrument id', () => { + expect(filterTokens(tokens, 'WrappedEther')).toEqual([weth]) }) - it('leaves an id the query only appears inside alone', () => { - expect(filterTokens(tokens, 'bbb')).toEqual([]) + it('matches on the admin party', () => { + expect(filterTokens(tokens, 'bridge')).toEqual([weth]) }) - it('ranks what the caller can read ahead of an id prefix', () => { - const decoy: Token = { id: 'cc99', name: 'Decoy', symbol: 'DEC' } + it('leaves an instrument the query only appears inside alone', () => { + expect(filterTokens(tokens, 'rappedether')).toEqual([]) + }) + + it('ranks what the caller can read ahead of an instrument id prefix', () => { + const decoy: Token = { + instrumentId: { admin: 'decoy::1220ef', id: 'cc99' }, + name: 'Decoy', + symbol: 'DEC', + } expect(filterTokens([decoy, cc], 'cc')).toEqual([cc, decoy]) }) diff --git a/canton-dappbooster/src/components/TokenInput/filterTokens.ts b/canton-dappbooster/src/components/TokenInput/filterTokens.ts index 3ea13b6d..e6947fba 100644 --- a/canton-dappbooster/src/components/TokenInput/filterTokens.ts +++ b/canton-dappbooster/src/components/TokenInput/filterTokens.ts @@ -1,14 +1,21 @@ import type { Token } from '#src/providers/TokenListProvider/context' -// Held per token rather than per keystroke: the same three strings would otherwise be lowercased -// again for every character typed, over a list long enough to have needed windowing. -const haystacks = new WeakMap() +interface Haystack { + admin: string + id: string + name: string + symbol: string +} + +const haystacks = new WeakMap() -const haystack = (token: Token): { id: string; name: string; symbol: string } => { +// id, name, symbol or admin party +const haystack = (token: Token): Haystack => { const cached = haystacks.get(token) if (cached !== undefined) return cached const next = { - id: token.id.toLowerCase(), + admin: token.instrumentId.admin.toLowerCase(), + id: token.instrumentId.id.toLowerCase(), name: token.name.toLowerCase(), symbol: token.symbol.toLowerCase(), } @@ -26,7 +33,9 @@ const haystack = (token: Token): { id: string; name: string; symbol: string } => export const toNeedle = (query: string): string => query.trim().toLowerCase() /** - * Narrows a token list to what a query matches, symbol matches first, then name, then id. + * Narrows a token list to what a query matches: symbol matches first, then name, then either half + * of the instrument id. The admin party is what tells two registries issuing one symbol apart, so + * it is matched by prefix, which is how a pasted party id finds its token. * * @example * const shown = filterTokens(tokens, 'ca') @@ -36,15 +45,16 @@ export const filterTokens = (tokens: readonly Token[], query: string): readonly if (needle === '') return tokens const bySymbol: Token[] = [] const byName: Token[] = [] - const byId: Token[] = [] + const byInstrument: Token[] = [] for (const token of tokens) { - const { id, name, symbol } = haystack(token) - // `id` stands in for the address criterion: a `Token` carries no address field, and a caller - // with on-chain tokens puts the identifier it filters by there. Prefix-only and ranked last, so - // a short query does not bury the readable matches under every hash that happens to contain it. - if (symbol.includes(needle)) bySymbol.push(token) - else if (name.includes(needle)) byName.push(token) - else if (id.startsWith(needle)) byId.push(token) + const { admin, id, name, symbol } = haystack(token) + if (symbol.includes(needle)) { + bySymbol.push(token) + } else if (name.includes(needle)) { + byName.push(token) + } else if (id.startsWith(needle) || admin.startsWith(needle)) { + byInstrument.push(token) + } } - return [...bySymbol, ...byName, ...byId] + return [...bySymbol, ...byName, ...byInstrument] } diff --git a/canton-dappbooster/src/components/TokenInput/formatFigure.test.ts b/canton-dappbooster/src/components/TokenInput/formatFigure.test.ts new file mode 100644 index 00000000..cb5f32b6 --- /dev/null +++ b/canton-dappbooster/src/components/TokenInput/formatFigure.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { formatFigure } from '#src/components/TokenInput/formatFigure' + +describe('formatFigure', () => { + it('carries two decimals by default, rounding half up', () => { + expect(formatFigure('1234.5')).toBe('1,234.50') + expect(formatFigure('0')).toBe('0.00') + expect(formatFigure('105.9154321')).toBe('105.92') + }) + + it('reports nothing for an amount it cannot read', () => { + expect(formatFigure(undefined)).toBeUndefined() + expect(formatFigure('')).toBeUndefined() + expect(formatFigure('abc')).toBeUndefined() + }) + + it('takes the places the caller asks for', () => { + expect(formatFigure('1234.5678', { places: 0 })).toBe('1,235') + expect(formatFigure('1234.5678', { places: 4 })).toBe('1,234.5678') + expect(formatFigure('1234.5', { places: 4 })).toBe('1,234.5000') + }) + + it('clamps the places to what an amount can hold', () => { + expect(formatFigure('1.5', { places: 99 })).toBe(formatFigure('1.5', { places: 10 })) + expect(formatFigure('1.5', { places: -1 })).toBe(formatFigure('1.5', { places: 0 })) + }) + + it('groups under the locale it is given', () => { + expect(formatFigure('1234.5', { locale: 'de-DE' })).toBe('1.234,50') + }) +}) diff --git a/canton-dappbooster/src/components/TokenInput/formatFigure.ts b/canton-dappbooster/src/components/TokenInput/formatFigure.ts new file mode 100644 index 00000000..8923c2d0 --- /dev/null +++ b/canton-dappbooster/src/components/TokenInput/formatFigure.ts @@ -0,0 +1,44 @@ +import { DEFAULT_PRECISION, formatAmount, formatScaled, parseAmount } from '#src/utils/tokenAmount' + +const PLACES = 2 + +/** + * Overrides for {@link formatFigure}: the decimal places to round to, 2 by default and clamped to + * what a ledger amount holds, and the locale whose grouping and decimal separators to use, the + * runtime's own by default. + * + * @example + * formatFigure('1234.5', { places: 4, locale: 'de-DE' }) // '1.234,5000' + * + * @category Utilities + */ +export interface FormatFigureOptions { + locale?: string + places?: number +} + +/** + * Rounds an amount to a fixed number of decimals and groups it for reading, so a column of figures + * compares at a glance where {@link formatAmount} keeps every digit the ledger carries. An amount + * it cannot read formats to nothing rather than to a zero that would read as a real balance. + * + * @example + * formatFigure('1234.5') // '1,234.50' + * formatFigure('abc') // undefined + * + * @category Utilities + */ +export const formatFigure = ( + value: string | undefined, + { locale, places = PLACES }: FormatFigureOptions = {}, +): string | undefined => { + const scaled = value === undefined ? undefined : parseAmount(value) + + if (scaled === undefined) return undefined + + const at = Math.max(0, Math.min(Math.trunc(places), DEFAULT_PRECISION)) + const step = 10n ** BigInt(DEFAULT_PRECISION - at) + const [int, frac = ''] = formatScaled((scaled + step / 2n) / step, at).split('.') + + return formatAmount(at === 0 ? int : `${int}.${frac.padEnd(at, '0')}`, locale) +} diff --git a/canton-dappbooster/src/components/TokenInput/getLockedFigure.ts b/canton-dappbooster/src/components/TokenInput/getLockedFigure.ts new file mode 100644 index 00000000..1d43663f --- /dev/null +++ b/canton-dappbooster/src/components/TokenInput/getLockedFigure.ts @@ -0,0 +1,6 @@ +import { formatFigure } from '#src/components/TokenInput/formatFigure' +import type { Token } from '#src/providers/TokenListProvider/context' +import { parseAmount } from '#src/utils/tokenAmount' + +export const getLockedFigure = ({ locked }: Token): string | undefined => + locked !== undefined && parseAmount(locked) === 0n ? undefined : formatFigure(locked) diff --git a/canton-dappbooster/src/components/TokenInput/getTokenLabel.ts b/canton-dappbooster/src/components/TokenInput/getTokenLabel.ts new file mode 100644 index 00000000..0a360e0b --- /dev/null +++ b/canton-dappbooster/src/components/TokenInput/getTokenLabel.ts @@ -0,0 +1,20 @@ +import { formatFigure } from '#src/components/TokenInput/formatFigure' +import { getLockedFigure } from '#src/components/TokenInput/getLockedFigure' +import type { Token } from '#src/providers/TokenListProvider/context' + +// The accessible name a token is announced by +export const getTokenLabel = (token: Token): string => { + const balance = formatFigure(token.balance) + const locked = getLockedFigure(token) + const parts = [`${token.name} ${token.symbol}`] + + if (balance !== undefined) { + parts.push(`balance ${balance}`) + } + + if (locked !== undefined) { + parts.push(`${locked} locked`) + } + + return parts.join(', ') +} diff --git a/canton-dappbooster/src/components/TokenInput/index.tsx b/canton-dappbooster/src/components/TokenInput/index.tsx index 1d700f3e..10812378 100644 --- a/canton-dappbooster/src/components/TokenInput/index.tsx +++ b/canton-dappbooster/src/components/TokenInput/index.tsx @@ -12,7 +12,7 @@ import { anatomy } from '#src/components/TokenInput/anatomy' import { TokenSelectDialog } from '#src/components/TokenInput/TokenSelectDialog' import { useFormattedField } from '#src/components/TokenInput/useFormattedField' import { TokenLogo } from '#src/components/TokenLogo' -import type { Token } from '#src/providers/TokenListProvider/context' +import type { InstrumentId, Token } from '#src/providers/TokenListProvider/context' import { cx } from '#src/utils/cx' import { resolveInvalid } from '#src/utils/invalid' import { @@ -64,7 +64,7 @@ interface TokenInputOwnProps balance?: string balanceState?: 'loading' | 'error' disabled?: boolean - favoriteIds?: readonly string[] + favoriteIds?: readonly InstrumentId[] label?: string onBlur?: FocusEventHandler onChange: (value: string, error: TokenAmountError | undefined) => void diff --git a/canton-dappbooster/src/components/TokenInput/nextIndex.test.ts b/canton-dappbooster/src/components/TokenInput/nextIndex.test.ts new file mode 100644 index 00000000..dcbb5363 --- /dev/null +++ b/canton-dappbooster/src/components/TokenInput/nextIndex.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { nextIndex } from '#src/components/TokenInput/nextIndex' + +const PAGE = 10 +const LAST = 99 + +describe('nextIndex', () => { + it('steps one row with the arrows', () => { + expect(nextIndex('ArrowDown', 4, PAGE, LAST)).toBe(5) + expect(nextIndex('ArrowUp', 4, PAGE, LAST)).toBe(3) + }) + + it('steps a page with PageDown and PageUp', () => { + expect(nextIndex('PageDown', 4, PAGE, LAST)).toBe(14) + expect(nextIndex('PageUp', 40, PAGE, LAST)).toBe(30) + }) + + it('jumps to either end', () => { + expect(nextIndex('Home', 40, PAGE, LAST)).toBe(0) + expect(nextIndex('End', 40, PAGE, LAST)).toBe(LAST) + }) + + // Clamping is the caller's, which is what lets it clamp against a list length this cannot see. + it('runs past either end rather than clamping', () => { + expect(nextIndex('ArrowUp', 0, PAGE, LAST)).toBe(-1) + expect(nextIndex('PageDown', LAST, PAGE, LAST)).toBe(LAST + PAGE) + }) + + it('reports nothing for a key that moves no row', () => { + expect(nextIndex('Enter', 4, PAGE, LAST)).toBeUndefined() + expect(nextIndex('a', 4, PAGE, LAST)).toBeUndefined() + }) +}) diff --git a/canton-dappbooster/src/components/TokenInput/nextIndex.ts b/canton-dappbooster/src/components/TokenInput/nextIndex.ts new file mode 100644 index 00000000..965d497d --- /dev/null +++ b/canton-dappbooster/src/components/TokenInput/nextIndex.ts @@ -0,0 +1,30 @@ +/** + * The row a key moves the tab stop to, or `undefined` for a key that moves nothing and so must + * keep its default behaviour. Out-of-range results are the caller's to clamp. + * + * @example + * nextIndex('PageDown', 4, 10, 99) // 14 + */ +export const nextIndex = ( + key: string, + active: number, + page: number, + last: number, +): number | undefined => { + switch (key) { + case 'ArrowDown': + return active + 1 + case 'ArrowUp': + return active - 1 + case 'PageDown': + return active + page + case 'PageUp': + return active - page + case 'Home': + return 0 + case 'End': + return last + default: + return undefined + } +} diff --git a/canton-dappbooster/src/components/TokenInput/tokenLabel.ts b/canton-dappbooster/src/components/TokenInput/tokenLabel.ts deleted file mode 100644 index 7f53bc75..00000000 --- a/canton-dappbooster/src/components/TokenInput/tokenLabel.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { Token } from '#src/providers/TokenListProvider/context' - -// One name for both surfaces that offer a token: a list row and a favourite chip for the same token -// sit in one dialog and must never be named differently. -export const tokenLabel = (token: Token): string => `${token.name} ${token.symbol}` diff --git a/canton-dappbooster/src/components/TokenInput/useRovingFocus.ts b/canton-dappbooster/src/components/TokenInput/useRovingFocus.ts new file mode 100644 index 00000000..41b971c7 --- /dev/null +++ b/canton-dappbooster/src/components/TokenInput/useRovingFocus.ts @@ -0,0 +1,111 @@ +import { + type KeyboardEvent, + type RefObject, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' +import { dialogAnatomy as anatomy } from '#src/components/TokenInput/anatomy' +import { nextIndex } from '#src/components/TokenInput/nextIndex' +import type { Token } from '#src/providers/TokenListProvider/context' +import { tokenKey } from '#src/utils/tokenKey' + +interface UseRovingFocusOptions { + needle: string + rowHeight: number + scrollRef: RefObject + scrollRowIntoView: (index: number) => void + tokens: readonly Token[] +} + +interface UseRovingFocusResult { + active: number + onKeyDown: (event: KeyboardEvent) => void + onRowFocus: (token: Token) => void +} + +/** + * The one tab stop a windowed list walks on: which row holds it, the keys that move it, and the + * focus a scroll would otherwise drop when it unmounts the row holding it. + * + * @example + * const { active, onKeyDown, onRowFocus } = useRovingFocus({ + * needle, + * rowHeight, + * scrollRef, + * scrollRowIntoView, + * tokens, + * }) + */ +export const useRovingFocus = ({ + needle, + rowHeight, + scrollRef, + scrollRowIntoView, + tokens, +}: UseRovingFocusOptions): UseRovingFocusResult => { + // Held by key, not by index: a provider handing over an equal-but-new array must not move it. + const [activeKey, setActiveKey] = useState() + const active = useMemo( + () => + Math.max( + 0, + tokens.findIndex((token) => tokenKey(token.instrumentId) === activeKey), + ), + [activeKey, tokens], + ) + // Raised only by the keys that move the tab stop + // A re-render from scrolling never pulls focus. + const pullFocus = useRef(false) + const hadFocus = useRef(false) + + // A new needle makes the old tab stop meaningless, so it goes back to the top; no token has an + // unset key, which is what lands it on the first row. + const [applied, setApplied] = useState(needle) + if (applied !== needle) { + setApplied(needle) + setActiveKey(undefined) + } + + const focusActive = (): void => { + scrollRef.current?.querySelector(`.${anatomy.parts.row}[tabindex="0"]`)?.focus() + } + + // Puts focus back where it belongs, once per commit, before the browser paints. + useLayoutEffect(() => { + const node = scrollRef.current + if (node === null) return + if (pullFocus.current || (hadFocus.current && document.activeElement === document.body)) { + pullFocus.current = false + focusActive() + } + hadFocus.current = node.contains(document.activeElement) + }) + + const moveTo = (index: number): void => { + const next = Math.max(0, Math.min(tokens.length - 1, index)) + scrollRowIntoView(next) + if (next === active) { + focusActive() + return + } + pullFocus.current = true + setActiveKey(tokenKey(tokens[next].instrumentId)) + } + + return { + active, + onKeyDown: (event) => { + const page = Math.max(1, Math.floor((scrollRef.current?.clientHeight ?? 0) / rowHeight)) + const next = nextIndex(event.key, active, page, tokens.length - 1) + if (next === undefined) return + event.preventDefault() + moveTo(next) + }, + onRowFocus: (token) => { + hadFocus.current = true + setActiveKey(tokenKey(token.instrumentId)) + }, + } +} diff --git a/canton-dappbooster/src/components/WalletButton/CancelButton.test.tsx b/canton-dappbooster/src/components/WalletButton/CancelButton.test.tsx new file mode 100644 index 00000000..701e7bce --- /dev/null +++ b/canton-dappbooster/src/components/WalletButton/CancelButton.test.tsx @@ -0,0 +1,129 @@ +import { FakeSessionProvider } from '@bootnodedev/canton-connect/testing' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import type { FormEvent, ReactElement } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { cancelAnatomy } from '#src/components/WalletButton/anatomy' +import { CancelButton } from '#src/components/WalletButton/CancelButton' +import { ConnectButton } from '#src/components/WalletButton/ConnectButton' +import { hangingPicker, renderDisconnected, renderWithWallet } from '#src/testing/walletSession' + +const renderBesideConnect = (ui: ReactElement): ReturnType => + renderWithWallet( + <> + + {ui} + , + { walletPicker: hangingPicker }, + ) + +const startConnecting = async (): Promise => { + fireEvent.click(screen.getByRole('button', { name: 'Connect wallet' })) + const cancel = screen.getByRole('button', { name: 'Cancel' }) + await waitFor(() => expect(cancel).not.toHaveAttribute('aria-disabled')) + return cancel +} + +describe('CancelButton', () => { + it('renders with the root part', () => { + renderDisconnected() + expect(screen.getByTestId('cancel-button')).toHaveClass(cancelAnatomy.parts.root) + }) + + it('appends a consumer class to the root part', () => { + renderDisconnected() + expect(screen.getByTestId('cancel-button')).toHaveClass(cancelAnatomy.parts.root, 'extra') + }) + + it('names itself for the action it carries', () => { + renderDisconnected() + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument() + }) + + it('takes a caller label over its own', () => { + renderDisconnected(Stop) + expect(screen.getByRole('button', { name: 'Stop' })).toBeInTheDocument() + }) + + it('is inert with nothing to cancel', () => { + const onClick = vi.fn() + renderDisconnected() + const button = screen.getByRole('button', { name: 'Cancel' }) + + expect(button).toHaveAttribute('aria-disabled', 'true') + fireEvent.click(button) + expect(onClick).not.toHaveBeenCalled() + }) + + it('refuses to submit the form it sits in while idle', () => { + const onSubmit = vi.fn((event: FormEvent) => event.preventDefault()) + render( + +
+ + +
, + ) + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('arms once an attempt is in flight, spinning and saying so', async () => { + renderBesideConnect() + const cancel = await startConnecting() + + expect(cancel).toHaveAttribute(cancelAnatomy.states.pending, 'true') + expect(cancel.querySelector(`.${cancelAnatomy.parts.spinner}`)).toBeInTheDocument() + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Connecting…')) + }) + + it('announces the wait when it mounts mid-attempt', async () => { + render( + + + , + ) + + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Connecting…')) + }) + + it('leaves nothing for a screen reader to read out while idle', () => { + renderDisconnected() + + expect(screen.getByRole('status')).toBeEmptyDOMElement() + expect(document.querySelector(`.${cancelAnatomy.parts.spinner}`)).toBeNull() + }) + + it('cancels the attempt in flight', async () => { + renderBesideConnect() + fireEvent.click(await startConnecting()) + + await waitFor(() => + expect(screen.getByRole('button', { name: 'Connect wallet' })).not.toHaveAttribute( + 'aria-disabled', + ), + ) + }) + + it('ignores every click past the first', async () => { + const onClick = vi.fn() + renderBesideConnect() + const cancel = await startConnecting() + + fireEvent.click(cancel) + await waitFor(() => expect(cancel).toHaveAttribute('aria-disabled', 'true')) + fireEvent.click(cancel) + + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('lets a consumer handler keep the attempt by preventing the default', async () => { + renderBesideConnect( event.preventDefault()} />) + fireEvent.click(await startConnecting()) + + expect(screen.getByRole('button', { name: 'Connecting…' })).toHaveAttribute( + 'aria-disabled', + 'true', + ) + }) +}) diff --git a/canton-dappbooster/src/components/WalletButton/CancelButton.tsx b/canton-dappbooster/src/components/WalletButton/CancelButton.tsx new file mode 100644 index 00000000..1a0a1f7e --- /dev/null +++ b/canton-dappbooster/src/components/WalletButton/CancelButton.tsx @@ -0,0 +1,71 @@ +import { useConnect } from '@bootnodedev/canton-connect' +import { type ComponentPropsWithRef, type ReactElement, useEffect, useState } from 'react' +import { cancelAnatomy } from '#src/components/WalletButton/anatomy' +import { composeAction } from '#src/components/WalletButton/composeAction' +import { cx } from '#src/utils/cx' +import { SR_ONLY } from '#src/utils/srOnly' + +/** + * Props for {@link CancelButton}. + * + * @category Components + */ +export type CancelButtonProps = ComponentPropsWithRef<'button'> + +/** + * Abandons the connect attempt {@link ConnectButton} started, and is inert while there is none. + * Reach for it wherever a wallet prompt the user walked away from would otherwise leave the app + * pending forever. It spins while the attempt is in flight and keeps its accessible name on the + * action, so the wait is announced through the live region beside it rather than by renaming the + * button. + * + * @example + * import { CancelButton } from '@bootnodedev/canton-dappbooster/connect' + * + * + * + * @example + * {label} + * + * @see [anatomy.ts](https://github.com/BootNodeDev/canton-dappbooster/blob/main/canton-dappbooster/src/components/WalletButton/anatomy.ts) for the part classes and state attributes the theme selects. + * + * @category Components + */ +export const CancelButton = ({ + children, + className, + onClick, + type = 'button', + ...rest +}: CancelButtonProps): ReactElement => { + const { cancelConnect, isPending } = useConnect() + const [painted, setPainted] = useState(false) + + // A live region that arrives already filled is never read out, and a consumer swapping this + // button in mounts it mid-attempt, so the text has to land a paint later. + useEffect(() => setPainted(true), []) + + return ( + <> + + {/* Outside the button so it cannot join its accessible name, which stays the action. */} + + {painted && isPending ? 'Connecting…' : ''} + + + ) +} diff --git a/canton-dappbooster/src/components/WalletButton/ConnectButton.test.tsx b/canton-dappbooster/src/components/WalletButton/ConnectButton.test.tsx new file mode 100644 index 00000000..911c2022 --- /dev/null +++ b/canton-dappbooster/src/components/WalletButton/ConnectButton.test.tsx @@ -0,0 +1,115 @@ +import { useParty } from '@bootnodedev/canton-connect' +import { FakeSessionProvider } from '@bootnodedev/canton-connect/testing' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import type { FormEvent, ReactElement } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { connectAnatomy } from '#src/components/WalletButton/anatomy' +import { ConnectButton } from '#src/components/WalletButton/ConnectButton' +import { + hangingPicker, + PARTY, + renderDisconnected, + renderWithWallet, +} from '#src/testing/walletSession' + +// The button keeps its own face through a connect, so the session itself is what a connect asserts. +const Session = (): ReactElement => { + const { party } = useParty() + return {party?.partyId ?? 'none'} +} + +const withSession = (ui: ReactElement): ReactElement => ( + <> + {ui} + + +) + +describe('ConnectButton', () => { + it('renders with the root part', () => { + renderDisconnected() + expect(screen.getByTestId('connect-button')).toHaveClass(connectAnatomy.parts.root) + }) + + it('appends a consumer class to the root part', () => { + renderDisconnected() + expect(screen.getByTestId('connect-button')).toHaveClass(connectAnatomy.parts.root, 'extra') + }) + + it('stays put once a session stands', () => { + render( + + + , + ) + expect(screen.getByRole('button', { name: 'Connect wallet' })).toBeInTheDocument() + }) + + it('goes inert while pending, saying so in its own words', async () => { + renderWithWallet(, { walletPicker: hangingPicker }) + const button = screen.getByRole('button', { name: 'Connect wallet' }) + fireEvent.click(button) + + await waitFor(() => expect(button).toHaveAttribute(connectAnatomy.states.pending, 'true')) + expect(button).toHaveAttribute('aria-disabled', 'true') + expect(button).toHaveTextContent('Connecting…') + }) + + it('ignores every click past the first', async () => { + const onClick = vi.fn() + renderWithWallet(, { walletPicker: hangingPicker }) + const button = screen.getByRole('button', { name: 'Connect wallet' }) + fireEvent.click(button) + + await waitFor(() => expect(button).toHaveAttribute('aria-disabled', 'true')) + fireEvent.click(button) + + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('refuses to submit the form it sits in while pending', async () => { + const onSubmit = vi.fn((event: FormEvent) => event.preventDefault()) + renderWithWallet( +
+ + , + { walletPicker: hangingPicker }, + ) + const button = screen.getByRole('button', { name: 'Connect wallet' }) + fireEvent.click(button) + + await waitFor(() => expect(button).toHaveAttribute('aria-disabled', 'true')) + onSubmit.mockClear() + fireEvent.click(button) + + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('keeps a caller-supplied label while pending, that caller owning what it says', async () => { + renderWithWallet(Confirm in your wallet) + const button = screen.getByRole('button', { name: 'Confirm in your wallet' }) + fireEvent.click(button) + + await waitFor(() => expect(button).toHaveAttribute(connectAnatomy.states.pending, 'true')) + expect(button).toHaveAccessibleName('Confirm in your wallet') + }) + + it('runs a consumer handler and still connects', async () => { + const onClick = vi.fn() + renderWithWallet(withSession()) + fireEvent.click(screen.getByRole('button', { name: 'Connect wallet' })) + + expect(onClick).toHaveBeenCalledTimes(1) + await waitFor(() => expect(screen.getByTestId('session')).toHaveTextContent(PARTY)) + }) + + it('lets a consumer handler bring its own connect by preventing the default', async () => { + renderWithWallet(withSession( event.preventDefault()} />)) + fireEvent.click(screen.getByRole('button', { name: 'Connect wallet' })) + + await waitFor(() => expect(screen.getByTestId('session')).toHaveTextContent('none')) + expect(screen.getByRole('button', { name: 'Connect wallet' })).not.toHaveAttribute( + connectAnatomy.states.pending, + ) + }) +}) diff --git a/canton-dappbooster/src/components/WalletButton/ConnectButton.tsx b/canton-dappbooster/src/components/WalletButton/ConnectButton.tsx new file mode 100644 index 00000000..ebf3cbcf --- /dev/null +++ b/canton-dappbooster/src/components/WalletButton/ConnectButton.tsx @@ -0,0 +1,53 @@ +import { useConnect } from '@bootnodedev/canton-connect' +import type { ComponentPropsWithRef, ReactElement } from 'react' +import { connectAnatomy } from '#src/components/WalletButton/anatomy' +import { composeAction } from '#src/components/WalletButton/composeAction' +import { cx } from '#src/utils/cx' + +/** + * Props for {@link ConnectButton}. + * + * @category Components + */ +export type ConnectButtonProps = ComponentPropsWithRef<'button'> + +/** + * Connect button. Can be customized. Inert while an attempt is in flight, so a click only ever + * connects; pair it with {@link CancelButton}, or take {@link WalletButton}, to let the user + * abandon one. + * + * @example + * import { ConnectButton } from '@bootnodedev/canton-dappbooster/connect' + * + * + * {label} + * + * @see [anatomy.ts](https://github.com/BootNodeDev/canton-dappbooster/blob/main/canton-dappbooster/src/components/WalletButton/anatomy.ts) for the part classes and state attributes the theme selects. + * + * @category Components + */ +export const ConnectButton = ({ + children, + className, + onClick, + type = 'button', + ...rest +}: ConnectButtonProps): ReactElement => { + const { connect, isPending } = useConnect() + + return ( + + ) +} diff --git a/canton-dappbooster/src/components/WalletButton/DisconnectButton.test.tsx b/canton-dappbooster/src/components/WalletButton/DisconnectButton.test.tsx new file mode 100644 index 00000000..cd904615 --- /dev/null +++ b/canton-dappbooster/src/components/WalletButton/DisconnectButton.test.tsx @@ -0,0 +1,75 @@ +import { useParty } from '@bootnodedev/canton-connect' +import { FakeSessionProvider } from '@bootnodedev/canton-connect/testing' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import type { ReactElement } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { disconnectAnatomy } from '#src/components/WalletButton/anatomy' +import { DisconnectButton } from '#src/components/WalletButton/DisconnectButton' + +const PARTY = 'nico::1220df946c5b01ad0f2d2b480f1f43b1d1f2e498f5a49c2f0b1cbb46' +const NETWORK = 'canton:local' +const party = { + partyId: PARTY, + networkId: NETWORK, + namespace: PARTY.split('::')[1] ?? PARTY, + signingProviderId: 'test', +} + +// The button renders whatever the session says, so the session itself is what a disconnect asserts. +const Session = (): ReactElement => { + const { isConnected } = useParty() + return {isConnected ? 'connected' : 'disconnected'} +} + +const renderInSession = (ui: ReactElement): ReturnType => + render( + + {ui} + + , + ) + +describe('DisconnectButton', () => { + it('renders with the root part', () => { + renderInSession() + expect(screen.getByTestId('account-button')).toHaveClass(disconnectAnatomy.parts.root) + }) + + it('appends a consumer class to the root part', () => { + renderInSession() + expect(screen.getByTestId('account-button')).toHaveClass(disconnectAnatomy.parts.root, 'extra') + }) + + it('names itself for the action it carries', () => { + renderInSession() + expect(screen.getByRole('button', { name: 'Disconnect' })).toBeInTheDocument() + }) + + it('takes a caller label over its own', () => { + renderInSession(Account) + expect(screen.getByRole('button', { name: 'Account' })).toBeInTheDocument() + }) + + it('ends the session', async () => { + renderInSession() + fireEvent.click(screen.getByRole('button', { name: 'Disconnect' })) + + await waitFor(() => expect(screen.getByTestId('session')).toHaveTextContent('disconnected')) + }) + + it('runs a consumer handler and still disconnects', async () => { + const onClick = vi.fn() + renderInSession() + fireEvent.click(screen.getByRole('button', { name: 'Disconnect' })) + + expect(onClick).toHaveBeenCalledTimes(1) + await waitFor(() => expect(screen.getByTestId('session')).toHaveTextContent('disconnected')) + }) + + it('lets a consumer handler keep the session by preventing the default', async () => { + renderInSession( event.preventDefault()} />) + fireEvent.click(screen.getByRole('button', { name: 'Disconnect' })) + + await waitFor(() => expect(screen.getByTestId('session')).toHaveTextContent('connected')) + }) +}) diff --git a/canton-dappbooster/src/components/WalletButton/DisconnectButton.tsx b/canton-dappbooster/src/components/WalletButton/DisconnectButton.tsx new file mode 100644 index 00000000..20b1fb9c --- /dev/null +++ b/canton-dappbooster/src/components/WalletButton/DisconnectButton.tsx @@ -0,0 +1,51 @@ +import { useDisconnect } from '@bootnodedev/canton-connect' +import type { ComponentPropsWithRef, ReactElement } from 'react' +import { disconnectAnatomy } from '#src/components/WalletButton/anatomy' +import { composeAction } from '#src/components/WalletButton/composeAction' +import { cx } from '#src/utils/cx' + +/** + * Props for {@link DisconnectButton}. + * + * @category Components + */ +export type DisconnectButtonProps = ComponentPropsWithRef<'button'> + +/** + * Disconnect button. Can be customized. + * + * @example + * import { DisconnectButton } from '@bootnodedev/canton-dappbooster/connect' + * + * + * + * @example + * { event.preventDefault(); toggleMenu() }}> + * {label} + * + * + * @see [anatomy.ts](https://github.com/BootNodeDev/canton-dappbooster/blob/main/canton-dappbooster/src/components/WalletButton/anatomy.ts) for the part classes and state attributes the theme selects. + * + * @category Components + */ +export const DisconnectButton = ({ + children, + className, + onClick, + type = 'button', + ...rest +}: DisconnectButtonProps): ReactElement => { + const { disconnect } = useDisconnect() + const handleClick = composeAction(onClick, disconnect) + + return ( + + ) +} diff --git a/canton-dappbooster/src/components/WalletButton/WalletButton.test.tsx b/canton-dappbooster/src/components/WalletButton/WalletButton.test.tsx new file mode 100644 index 00000000..f60df470 --- /dev/null +++ b/canton-dappbooster/src/components/WalletButton/WalletButton.test.tsx @@ -0,0 +1,96 @@ +import { FakeSessionProvider } from '@bootnodedev/canton-connect/testing' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import type { ReactElement } from 'react' +import { describe, expect, it } from 'vitest' +import { WalletButton } from '#src/components/WalletButton' +import { + cancelAnatomy, + connectAnatomy, + disconnectAnatomy, +} from '#src/components/WalletButton/anatomy' +import { hangingPicker, PARTY, renderWithWallet } from '#src/testing/walletSession' + +const NETWORK = 'canton:local' +const party = { + partyId: PARTY, + networkId: NETWORK, + namespace: PARTY.split('::')[1] ?? PARTY, + signingProviderId: 'test', +} + +const renderInSession = (ui: ReactElement, isLocked = false): ReturnType => + render( + + {ui} + , + ) + +describe('WalletButton', () => { + it('shows the connect face with no session', () => { + render( + + + , + ) + expect(screen.getByRole('button', { name: 'Connect wallet' })).toHaveClass( + connectAnatomy.parts.root, + ) + }) + + it('shows the disconnect face once a session stands', () => { + renderInSession() + expect(screen.getByRole('button', { name: 'Disconnect' })).toHaveClass( + disconnectAnatomy.parts.root, + ) + }) + + // A lock clears the party but keeps the session, which is what the face has to follow. + it('keeps the disconnect face on a locked session', () => { + renderInSession(, true) + expect(screen.getByRole('button', { name: 'Disconnect' })).toHaveClass( + disconnectAnatomy.parts.root, + ) + }) + + it('passes children to the face it picks', () => { + renderInSession(Account) + expect(screen.getByRole('button', { name: 'Account' })).toHaveClass( + disconnectAnatomy.parts.root, + ) + }) + + it('shows the cancel face while a connect is in flight', async () => { + renderWithWallet(, { walletPicker: hangingPicker }) + fireEvent.click(screen.getByRole('button', { name: 'Connect wallet' })) + + const cancel = await screen.findByRole('button', { name: 'Cancel' }) + expect(cancel).toHaveClass(cancelAnatomy.parts.root) + expect(cancel).not.toHaveAttribute('aria-disabled') + }) + + it('hands focus to the face that took over', async () => { + renderWithWallet(, { walletPicker: hangingPicker }) + const connect = screen.getByRole('button', { name: 'Connect wallet' }) + connect.focus() + fireEvent.click(connect) + + const cancel = await screen.findByRole('button', { name: 'Cancel' }) + await waitFor(() => expect(cancel).toHaveFocus()) + }) + + it('goes back to the connect face when the attempt is cancelled', async () => { + renderWithWallet(, { walletPicker: hangingPicker }) + fireEvent.click(screen.getByRole('button', { name: 'Connect wallet' })) + fireEvent.click(await screen.findByRole('button', { name: 'Cancel' })) + + await waitFor(() => + expect(screen.getByRole('button', { name: 'Connect wallet' })).toHaveClass( + connectAnatomy.parts.root, + ), + ) + }) +}) diff --git a/canton-dappbooster/src/components/WalletButton/anatomy.ts b/canton-dappbooster/src/components/WalletButton/anatomy.ts new file mode 100644 index 00000000..a13b90ae --- /dev/null +++ b/canton-dappbooster/src/components/WalletButton/anatomy.ts @@ -0,0 +1,22 @@ +export const cancelAnatomy = { + parts: { + root: 'cnc-cancel-button', + spinner: 'cnc-cancel-button__spinner', + status: 'cnc-cancel-button__status', + }, + states: { pending: 'data-pending' }, +} as const + +export const connectAnatomy = { + parts: { + root: 'cnc-connect-button', + spinner: 'cnc-connect-button__spinner', + }, + states: { pending: 'data-pending' }, +} as const + +export const disconnectAnatomy = { + parts: { + root: 'cnc-disconnect-button', + }, +} as const diff --git a/canton-dappbooster/src/components/WalletButton/composeAction.ts b/canton-dappbooster/src/components/WalletButton/composeAction.ts new file mode 100644 index 00000000..d99e15f8 --- /dev/null +++ b/canton-dappbooster/src/components/WalletButton/composeAction.ts @@ -0,0 +1,16 @@ +import type { MouseEvent, MouseEventHandler } from 'react' + +// The consumer's handler runs first and `preventDefault` is how it opts out of the built-in action, +// so passing one never silently drops the behaviour the button exists for. +export const composeAction = + ( + onClick: MouseEventHandler | undefined, + action: () => void | Promise, + ): MouseEventHandler => + (event: MouseEvent): void => { + onClick?.(event) + if (event.defaultPrevented) return + + // Wrapped because a cancel is synchronous, and `.catch` on its void return would throw. + void Promise.resolve(action()).catch(() => undefined) + } diff --git a/canton-dappbooster/src/components/WalletButton/index.tsx b/canton-dappbooster/src/components/WalletButton/index.tsx new file mode 100644 index 00000000..7175dd8d --- /dev/null +++ b/canton-dappbooster/src/components/WalletButton/index.tsx @@ -0,0 +1,59 @@ +import { useConnect, useWalletStatus } from '@bootnodedev/canton-connect' +import { type ButtonHTMLAttributes, type ReactElement, useEffect, useRef } from 'react' +import { CancelButton } from '#src/components/WalletButton/CancelButton' +import { ConnectButton } from '#src/components/WalletButton/ConnectButton' +import { DisconnectButton } from '#src/components/WalletButton/DisconnectButton' + +/** + * Props for {@link WalletButton}. No `ref`: which element it lands on would depend on the session, + * so reach for the face you want instead. + * + * @category Components + */ +export type WalletButtonProps = ButtonHTMLAttributes + +type Face = 'cancel' | 'connect' | 'disconnect' + +const faceFor = (isPending: boolean, isConnected: boolean): Face => { + if (isPending) return 'cancel' + if (isConnected) return 'disconnect' + return 'connect' +} + +/** + * Follows the session, one button at a time: {@link CancelButton} while a connect is in flight, + * {@link DisconnectButton} once a session stands, {@link ConnectButton} otherwise. Each face is + * inert outside its own state, so whatever is on screen only ever does the thing it says. + * + * Swapping a face unmounts the focused button, so it hands focus to the one that took over. + * + * @example + * import { WalletButton } from '@bootnodedev/canton-dappbooster/connect' + * + * + * + * @see [anatomy.ts](https://github.com/BootNodeDev/canton-dappbooster/blob/main/canton-dappbooster/src/components/WalletButton/anatomy.ts) for the part classes and state attributes the theme selects. + * + * @category Components + */ +export const WalletButton = (props: WalletButtonProps): ReactElement => { + const { isConnected } = useWalletStatus() + const { isPending } = useConnect() + const face = faceFor(isPending, isConnected) + const button = useRef(null) + const previous = useRef(face) + + // Only after a real swap: focusing on first paint would move focus nobody asked to move. + useEffect(() => { + const swapped = previous.current !== face + previous.current = face + + if (swapped && document.activeElement === document.body) { + button.current?.focus() + } + }, [face]) + + if (face === 'cancel') return + if (face === 'disconnect') return + return +} diff --git a/canton-dappbooster/src/connect.ts b/canton-dappbooster/src/connect.ts index de7a6250..c63ff302 100644 --- a/canton-dappbooster/src/connect.ts +++ b/canton-dappbooster/src/connect.ts @@ -1,12 +1,21 @@ /** - * The `/connect` sub-path. Its one component reads the wallet session, so it is kept off the main + * The `/connect` sub-path. Its components read the wallet session, so they are kept off the main * barrel to keep the Canton SDK out of a consumer's graph unless they ask for it. Merged into Main - * here because a reader browsing components wants it beside the others; the import path is on the - * component itself. + * here because a reader browsing components wants them beside the others; the import path is on the + * components themselves. * * @module * @mergeModuleWith Main */ -export { ConnectButton, type ConnectButtonProps } from '#src/components/ConnectButton' -export type { ConnectButtonMode } from '#src/components/ConnectButton/anatomy' +export { WalletButton, type WalletButtonProps } from '#src/components/WalletButton' +export { + CancelButton, + type CancelButtonProps, +} from '#src/components/WalletButton/CancelButton' +export { ConnectButton, type ConnectButtonProps } from '#src/components/WalletButton/ConnectButton' +export { + DisconnectButton, + type DisconnectButtonProps, +} from '#src/components/WalletButton/DisconnectButton' +export { type UseHoldingsResult, useHoldings } from '#src/hooks/useHoldings' diff --git a/canton-dappbooster/src/hooks/useHoldings.test.tsx b/canton-dappbooster/src/hooks/useHoldings.test.tsx new file mode 100644 index 00000000..1cf96f69 --- /dev/null +++ b/canton-dappbooster/src/hooks/useHoldings.test.tsx @@ -0,0 +1,141 @@ +// The read path over a session that answers; the connect and lock guards are canton-connect's. + +import { FakeSessionProvider } from '@bootnodedev/canton-connect/testing' +import { renderHook, waitFor } from '@testing-library/react' +import type { ComponentProps, ReactNode } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { useHoldings } from '#src/hooks/useHoldings' + +const party = { + namespace: '1220ab', + networkId: 'canton:local', + partyId: 'alice::1220ab', + signingProviderId: '', +} +const amulet = { admin: 'DSO::1220ab', id: 'Amulet' } + +// Derived from the double rather than from the SDK, which this package does not depend on. +type LedgerApi = NonNullable< + NonNullable['sdk']>['ledgerApi'] +> +type LedgerAnswer = Awaited> + +const view = (instrumentId: typeof amulet, amount: string, lock: unknown = null) => ({ + viewValue: { owner: party.partyId, instrumentId, amount, lock }, +}) + +const row = (...views: unknown[]) => ({ + contractEntry: { JsActiveContract: { createdEvent: { interfaceViews: views } } }, +}) + +const session = (ledgerApi: LedgerApi, usable = true) => ({ + wrapper: ({ children }: { children: ReactNode }) => ( + + {children} + + ), +}) + +const answering = (rows: LedgerAnswer) => + vi + .fn() + .mockImplementation(async (params) => + params.resource === '/v2/state/ledger-end' ? { offset: 42 } : rows, + ) + +describe('useHoldings', () => { + it('reads the party holdings and reports one entry per contract', async () => { + const ledgerApi = answering([row(view(amulet, '10.5')), row(view(amulet, '4'))]) + const { result } = renderHook(() => useHoldings(), session(ledgerApi)) + + await waitFor(() => expect(result.current.holdings).toHaveLength(2)) + expect(result.current.holdings).toEqual([ + { amount: '10.5', instrumentId: amulet, isLocked: false }, + { amount: '4', instrumentId: amulet, isLocked: false }, + ]) + expect(result.current.error).toBeUndefined() + }) + + it('asks for every registry at once, by interface and at the ledger end', async () => { + const ledgerApi = answering([]) + renderHook(() => useHoldings(), session(ledgerApi)) + + await waitFor(() => expect(ledgerApi).toHaveBeenCalledTimes(2)) + const [, acs] = ledgerApi.mock.calls.map(([params]) => params) + expect(acs.body).toMatchObject({ + activeAtOffset: 42, + filter: { + filtersByParty: { + [party.partyId]: { + cumulative: [ + { + identifierFilter: { + InterfaceFilter: { + value: { + includeInterfaceView: true, + interfaceId: + '#splice-api-token-holding-v1:Splice.Api.Token.HoldingV1:Holding', + }, + }, + }, + }, + ], + }, + }, + }, + }) + }) + + it('reports a locked holding as locked', async () => { + const ledgerApi = answering([row(view(amulet, '1', { holders: ['escrow::1220cd'] }))]) + const { result } = renderHook(() => useHoldings(), session(ledgerApi)) + + await waitFor(() => expect(result.current.holdings?.[0]?.isLocked).toBe(true)) + }) + + it('drops a view it cannot read rather than reporting a holding with no amount', async () => { + const ledgerApi = answering([ + row({ viewValue: { instrumentId: amulet } }), + row(view(amulet, '2')), + ]) + const { result } = renderHook(() => useHoldings(), session(ledgerApi)) + + await waitFor(() => expect(result.current.holdings).toHaveLength(1)) + expect(result.current.holdings?.[0]?.amount).toBe('2') + }) + + it('reports an empty list for a party that holds nothing', async () => { + const { result } = renderHook(() => useHoldings(), session(answering([]))) + await waitFor(() => expect(result.current.holdings).toEqual([])) + }) + + it('lands a failed read in error and holds no stale list', async () => { + const ledgerApi = vi.fn().mockRejectedValue(new Error('no route')) + const { result } = renderHook(() => useHoldings(), session(ledgerApi)) + + await waitFor(() => expect(result.current.error?.message).toBe('no route')) + expect(result.current.holdings).toBeUndefined() + expect(result.current.isLoading).toBe(false) + }) + + it('reads nothing while no party is connected', () => { + const ledgerApi = answering([]) + const { result } = renderHook(() => useHoldings(), session(ledgerApi, false)) + + expect(ledgerApi).not.toHaveBeenCalled() + expect(result.current.isLoading).toBe(false) + }) + + it('reads again on refetch', async () => { + const ledgerApi = answering([]) + const { result } = renderHook(() => useHoldings(), session(ledgerApi)) + + await waitFor(() => expect(ledgerApi).toHaveBeenCalledTimes(2)) + result.current.refetch() + await waitFor(() => expect(ledgerApi).toHaveBeenCalledTimes(4)) + }) +}) diff --git a/canton-dappbooster/src/hooks/useHoldings.ts b/canton-dappbooster/src/hooks/useHoldings.ts new file mode 100644 index 00000000..c302c9da --- /dev/null +++ b/canton-dappbooster/src/hooks/useHoldings.ts @@ -0,0 +1,148 @@ +import { type LedgerApiParams, useLedger, useParty } from '@bootnodedev/canton-connect' +import { useCallback, useEffect, useRef, useState } from 'react' +import { valueAt } from '#src/utils/json' +import type { Holding } from '#src/utils/sumHoldings' + +// The v1 interface every standard token implements.`#package-name` form survives a package upgrade +const HOLDING_INTERFACE = '#splice-api-token-holding-v1:Splice.Api.Token.HoldingV1:Holding' + +const holdingFromView = (view: unknown): Holding | undefined => { + const value = valueAt(view, 'viewValue') + const admin = valueAt(value, 'instrumentId', 'admin') + const id = valueAt(value, 'instrumentId', 'id') + const amount = valueAt(value, 'amount') + + if (typeof admin !== 'string' || typeof id !== 'string' || typeof amount !== 'string') { + return undefined + } + + const lock = valueAt(value, 'lock') + + return { amount, instrumentId: { admin, id }, isLocked: lock !== null && lock !== undefined } +} + +const holdingsFromAcsRows = (rows: unknown): readonly Holding[] => { + if (!Array.isArray(rows)) return [] + + return rows.flatMap((row) => { + const views = valueAt( + row, + 'contractEntry', + 'JsActiveContract', + 'createdEvent', + 'interfaceViews', + ) + if (!Array.isArray(views)) return [] + return views.flatMap((view) => holdingFromView(view) ?? []) + }) +} + +const acsRequest = (partyId: string, offset: string | number): LedgerApiParams => ({ + requestMethod: 'post', + resource: '/v2/state/active-contracts', + body: { + filter: { + filtersByParty: { + [partyId]: { + cumulative: [ + { + identifierFilter: { + InterfaceFilter: { + value: { interfaceId: HOLDING_INTERFACE, includeInterfaceView: true }, + }, + }, + }, + ], + }, + }, + }, + activeAtOffset: offset, + verbose: true, + }, +}) + +const readPartyHoldings = async ( + ledgerApi: (params: LedgerApiParams) => Promise, + partyId: string, +): Promise => { + const end = await ledgerApi({ requestMethod: 'get', resource: '/v2/state/ledger-end' }) + const offset = valueAt(end, 'offset') + if (typeof offset !== 'string' && typeof offset !== 'number') { + throw new Error('the ledger did not return an offset') + } + return holdingsFromAcsRows(await ledgerApi(acsRequest(partyId, offset))) +} + +interface HoldingsState { + error: Error | undefined + holdings: readonly Holding[] | undefined + isLoading: boolean +} + +const IDLE: HoldingsState = { error: undefined, holdings: undefined, isLoading: false } +const LOADING: HoldingsState = { error: undefined, holdings: undefined, isLoading: true } + +const toError = (value: unknown): Error => + value instanceof Error ? value : new Error(String(value)) + +/** + * Return shape of {@link useHoldings}. `holdings` is `undefined` until the first read answers, and + * again whenever one fails, so an empty array means the party holds nothing. + * + * @category Hooks + */ +export interface UseHoldingsResult { + error: Error | undefined + holdings: readonly Holding[] | undefined + isLoading: boolean + refetch: () => void +} + +/** + * Every standard holding the connected party owns, one entry per contract, read again whenever the + * party changes. Imported from `@bootnodedev/canton-dappbooster/connect`. Pair it with + * {@link sumHoldings} to get one row per instrument, which is what a token list wants. + * + * @throws with no `` above it. A failed read lands in `error` instead. + * + * @example + * const { holdings } = useHoldings() + * const tokens = sumHoldings(holdings ?? []) + * + * @category Hooks + */ +export const useHoldings = (): UseHoldingsResult => { + const { ledgerApi, isReady } = useLedger() + const { party } = useParty() + const partyId = party?.partyId + const [state, setState] = useState(IDLE) + // Only the newest read may report. Bumped on unmount too, so a read in flight then lands nowhere. + const newest = useRef(0) + + const load = useCallback((): void => { + if (!isReady || partyId === undefined) { + setState(IDLE) + return + } + newest.current += 1 + const request = newest.current + setState(LOADING) + readPartyHoldings(ledgerApi, partyId).then( + (holdings) => { + if (request === newest.current) setState({ error: undefined, holdings, isLoading: false }) + }, + (err) => { + if (request === newest.current) setState({ ...IDLE, error: toError(err) }) + }, + ) + }, [isReady, ledgerApi, partyId]) + + useEffect(() => { + load() + return () => { + newest.current += 1 + } + }, [load]) + + return { ...state, refetch: load } +} diff --git a/canton-dappbooster/src/icons/ChevronDownIcon.tsx b/canton-dappbooster/src/icons/ChevronDownIcon.tsx deleted file mode 100644 index ad3ac3b2..00000000 --- a/canton-dappbooster/src/icons/ChevronDownIcon.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import type { ReactElement } from 'react' -import { Svg } from '#src/icons/Svg' - -export const ChevronDownIcon = (): ReactElement => ( - - - -) diff --git a/canton-dappbooster/src/icons/LockIcon.tsx b/canton-dappbooster/src/icons/LockIcon.tsx new file mode 100644 index 00000000..fdf2a1ba --- /dev/null +++ b/canton-dappbooster/src/icons/LockIcon.tsx @@ -0,0 +1,9 @@ +import type { ReactElement } from 'react' +import { Svg } from '#src/icons/Svg' + +export const LockIcon = (): ReactElement => ( + + + + +) diff --git a/canton-dappbooster/src/icons/LogoutIcon.tsx b/canton-dappbooster/src/icons/LogoutIcon.tsx deleted file mode 100644 index 8c0524bb..00000000 --- a/canton-dappbooster/src/icons/LogoutIcon.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import type { ReactElement } from 'react' -import { Svg } from '#src/icons/Svg' - -export const LogoutIcon = (): ReactElement => ( - - - - -) diff --git a/canton-dappbooster/src/icons/index.ts b/canton-dappbooster/src/icons/index.ts index 2bc60210..8fd0a4fc 100644 --- a/canton-dappbooster/src/icons/index.ts +++ b/canton-dappbooster/src/icons/index.ts @@ -1,7 +1,6 @@ export { CheckIcon } from '#src/icons/CheckIcon' -export { ChevronDownIcon } from '#src/icons/ChevronDownIcon' export { CloseIcon } from '#src/icons/CloseIcon' export { CopyIcon } from '#src/icons/CopyIcon' export { ExternalLinkIcon } from '#src/icons/ExternalLinkIcon' -export { LogoutIcon } from '#src/icons/LogoutIcon' +export { LockIcon } from '#src/icons/LockIcon' export { SearchIcon } from '#src/icons/SearchIcon' diff --git a/canton-dappbooster/src/index.ts b/canton-dappbooster/src/index.ts index 995d17c7..f2115f40 100644 --- a/canton-dappbooster/src/index.ts +++ b/canton-dappbooster/src/index.ts @@ -14,6 +14,10 @@ export { } from '#src/components/Identifier/truncate' export { PartyIdInput, type PartyIdInputProps } from '#src/components/PartyIdInput' export { TokenInput, type TokenInputProps, type TokenMeta } from '#src/components/TokenInput' +export { + type FormatFigureOptions, + formatFigure, +} from '#src/components/TokenInput/formatFigure' export { type CopyOutcome, type CopyState, @@ -35,9 +39,16 @@ export { TokenListProvider, type TokenListProviderProps, } from '#src/providers/TokenListProvider' -export type { Token, UseTokenListResult } from '#src/providers/TokenListProvider/context' +export type { + InstrumentId, + Token, + UseTokenListResult, +} from '#src/providers/TokenListProvider/context' export { useTokenList } from '#src/providers/TokenListProvider/useTokenList' +export { mergeTokens, type PartialToken } from '#src/utils/mergeTokens' export { isValidPartyId, type PartyIdError, validatePartyId } from '#src/utils/partyId' +export { type Instrument, readInstruments } from '#src/utils/readInstruments' +export { type Holding, type InstrumentBalance, sumHoldings } from '#src/utils/sumHoldings' export { DEFAULT_PRECISION, formatAmount, @@ -47,3 +58,4 @@ export { type TokenAmountError, validateAmount, } from '#src/utils/tokenAmount' +export { tokenKey } from '#src/utils/tokenKey' diff --git a/canton-dappbooster/src/providers/TokenListProvider/TokenListProvider.test.tsx b/canton-dappbooster/src/providers/TokenListProvider/TokenListProvider.test.tsx index 0b09b162..5ca30f2b 100644 --- a/canton-dappbooster/src/providers/TokenListProvider/TokenListProvider.test.tsx +++ b/canton-dappbooster/src/providers/TokenListProvider/TokenListProvider.test.tsx @@ -3,43 +3,65 @@ import { describe, expect, it, vi } from 'vitest' import { TokenListProvider } from '#src/providers/TokenListProvider' import type { Token } from '#src/providers/TokenListProvider/context' import { useTokenList } from '#src/providers/TokenListProvider/useTokenList' +import { tokenKey } from '#src/utils/tokenKey' -const CC: Token = { id: 'canton-coin', name: 'Canton Coin', symbol: 'CC' } -const USDC: Token = { id: 'usdc', name: 'USD Coin', symbol: 'USDC' } +const CC: Token = { + instrumentId: { admin: 'DSO::1220ab', id: 'Amulet' }, + name: 'Canton Coin', + symbol: 'CC', +} +const USDC: Token = { + instrumentId: { admin: 'circle::1220cd', id: 'USDC' }, + name: 'USD Coin', + symbol: 'USDC', +} +const held = (token: Token, balance: string): Token => ({ ...token, balance }) const Probe = (): React.JSX.Element => { - const { byId, tokens } = useTokenList() + const { byKey, tokens } = useTokenList() return ( <> {tokens.map((token) => token.symbol).join(',')} - {byId.get('usdc')?.name ?? 'none'} + {byKey.get(tokenKey(USDC.instrumentId))?.name ?? 'none'} ) } const shown = (id: 'symbols' | 'looked-up'): string | null => screen.getByTestId(id).textContent +const list = (tokens: Token[]) => + render( + + + , + ) + describe('TokenListProvider', () => { it('supplies the tokens in the order given', () => { - render( - - - , - ) - + list([CC, USDC]) expect(shown('symbols')).toBe('CC,USDC') }) - it('resolves a token by id', () => { - render( - - - , - ) - + it('resolves a token by its instrument id', () => { + list([CC, USDC]) expect(shown('looked-up')).toBe('USD Coin') }) + it('leads with the biggest balance', () => { + list([held(CC, '1.5'), held(USDC, '20')]) + expect(shown('symbols')).toBe('USDC,CC') + }) + + it('puts a token with no balance last, however small the balances above it', () => { + list([CC, held(USDC, '0')]) + expect(shown('symbols')).toBe('USDC,CC') + }) + + it('compares balances as amounts rather than as strings', () => { + list([held(CC, '9'), held(USDC, '10')]) + expect(shown('symbols')).toBe('USDC,CC') + }) + it('throws when the hook is used without a provider', () => { const error = vi.spyOn(console, 'error').mockImplementation(() => {}) diff --git a/canton-dappbooster/src/providers/TokenListProvider/context.ts b/canton-dappbooster/src/providers/TokenListProvider/context.ts index bebfe0e3..100e0b0b 100644 --- a/canton-dappbooster/src/providers/TokenListProvider/context.ts +++ b/canton-dappbooster/src/providers/TokenListProvider/context.ts @@ -1,33 +1,57 @@ import type { ReactNode } from 'react' import { type Context, createContext } from 'react' +/** + * What identifies a token on Canton: the party administering the registry that issued it, and the + * id it carries there. Two fields, so {@link tokenKey} is what turns one into a map or React key. + * + * @example + * const amulet: InstrumentId = { admin: 'DSO::1220ab', id: 'Amulet' } + * + * @category Hooks + */ +export interface InstrumentId { + admin: string + id: string +} + /** * A token in the list a picker chooses from. Structurally a `TokenMeta`, so a selected `Token` goes - * straight to `` without a mapping step. + * straight to ``. `balance` is what the party can spend and `locked` what it + * cannot, both off a holdings read: with no `balance` the row shows no figure and sorts last. * * @example - * const cc: Token = { id: 'canton-coin', name: 'Canton Coin', symbol: 'CC' } + * const cc: Token = { + * balance: '12.5', + * instrumentId: { admin: 'DSO::1220ab', id: 'Amulet' }, + * locked: '100', + * name: 'Canton Coin', + * symbol: 'CC', + * } * * @category Hooks */ export interface Token { - id: string + balance?: string + instrumentId: InstrumentId + locked?: string logo?: ReactNode name: string symbol: string } /** - * Return shape of {@link useTokenList}. `byId` is built from `tokens`, so the two never disagree. + * Return shape of {@link useTokenList}. `byKey` is built from `tokens`, so the two never disagree, + * and both carry the balance-first order the provider settled on. * * @example - * const { tokens, byId } = useTokenList() - * const selected = byId.get(storedId) ?? tokens[0] + * const { byKey, tokens } = useTokenList() + * const selected = byKey.get(tokenKey(instrumentId)) ?? tokens[0] * * @category Hooks */ export interface UseTokenListResult { - byId: ReadonlyMap + byKey: ReadonlyMap tokens: readonly Token[] } diff --git a/canton-dappbooster/src/providers/TokenListProvider/index.tsx b/canton-dappbooster/src/providers/TokenListProvider/index.tsx index aec59b0b..2eff4941 100644 --- a/canton-dappbooster/src/providers/TokenListProvider/index.tsx +++ b/canton-dappbooster/src/providers/TokenListProvider/index.tsx @@ -4,6 +4,17 @@ import { TokenListContext, type UseTokenListResult, } from '#src/providers/TokenListProvider/context' +import { parseAmount } from '#src/utils/tokenAmount' +import { tokenKey } from '#src/utils/tokenKey' + +// Turns a token's balance into a number the sort can compare. Taken once per token rather than +// inside the comparator, which would re-parse both sides of every comparison. +const held = (token: Token): bigint => parseAmount(token.balance ?? '') ?? -1n + +const byBalance = (left: { held: bigint }, right: { held: bigint }): number => { + if (left.held === right.held) return 0 + return left.held > right.held ? -1 : 1 +} /** * Props for {@link TokenListProvider}. `tokens` is read by identity, so hoist the array or memoise @@ -20,10 +31,7 @@ export interface TokenListProviderProps { } /** - * Supplies the token list every picker in the tree chooses from, plus the `byId` lookup a consumer - * needs to turn a stored token id back into a token. Renders no DOM of its own, and there is no - * ambient fallback: `useTokenList` throws without it, because a picker with nothing to pick from is - * worse than one that fails loudly in dev. + * Supplies the token list every picker in the tree chooses from. * * @example * @@ -34,13 +42,16 @@ export interface TokenListProviderProps { * @category Components */ export const TokenListProvider = ({ children, tokens }: TokenListProviderProps): ReactElement => { - const value = useMemo( - () => ({ - byId: new Map(tokens.map((token) => [token.id, token])), - tokens, - }), - [tokens], - ) + const value = useMemo(() => { + const sorted = tokens + .map((token) => ({ held: held(token), token })) + .sort(byBalance) + .map(({ token }) => token) + return { + byKey: new Map(sorted.map((token) => [tokenKey(token.instrumentId), token])), + tokens: sorted, + } + }, [tokens]) return {children} } diff --git a/canton-dappbooster/src/providers/TokenListProvider/useTokenList.ts b/canton-dappbooster/src/providers/TokenListProvider/useTokenList.ts index da1a15cb..ae87cdbf 100644 --- a/canton-dappbooster/src/providers/TokenListProvider/useTokenList.ts +++ b/canton-dappbooster/src/providers/TokenListProvider/useTokenList.ts @@ -9,7 +9,7 @@ import { TokenListContext, type UseTokenListResult } from '#src/providers/TokenL * * @example * const { tokens } = useTokenList() - * tokens.map((token) => ) + * tokens.map((token) => ) * * @category Hooks */ diff --git a/canton-dappbooster/src/testing/tokens.ts b/canton-dappbooster/src/testing/tokens.ts index 212ee3a3..7bfc8ca6 100644 --- a/canton-dappbooster/src/testing/tokens.ts +++ b/canton-dappbooster/src/testing/tokens.ts @@ -2,12 +2,13 @@ import type { Token } from '#src/providers/TokenListProvider/context' /** * The two-token list the token select's tests render against. Two is enough to tell a filtered row - * from an unfiltered one; a test needing a hundred builds its own. + * from an unfiltered one; a test needing a hundred builds its own. Neither carries a balance, so a + * test asserting on one sets it and the rest keep the order they were given. * * @example * render() */ export const TOKENS: Token[] = [ - { id: 'canton-coin', name: 'Canton Coin', symbol: 'CC' }, - { id: 'usdc', name: 'USD Coin', symbol: 'USDC' }, + { instrumentId: { admin: 'DSO::1220ab', id: 'Amulet' }, name: 'Canton Coin', symbol: 'CC' }, + { instrumentId: { admin: 'circle::1220cd', id: 'USDC' }, name: 'USD Coin', symbol: 'USDC' }, ] diff --git a/canton-dappbooster/src/testing/walletSession.tsx b/canton-dappbooster/src/testing/walletSession.tsx new file mode 100644 index 00000000..17d68d53 --- /dev/null +++ b/canton-dappbooster/src/testing/walletSession.tsx @@ -0,0 +1,33 @@ +import type { CantonConnectConfig } from '@bootnodedev/canton-connect' +import { CantonConnectProvider, createMockAdapter } from '@bootnodedev/canton-connect' +import { createAutoPicker, FakeSessionProvider } from '@bootnodedev/canton-connect/testing' +import { render } from '@testing-library/react' +import type { ReactElement } from 'react' + +export const PARTY = 'nico::1220df946c5b01ad0f2d2b480f1f43b1d1f2e498f5a49c2f0b1cbb46' + +// Reached through the config rather than the SDK: this package must not import dapp-sdk. +type Picker = NonNullable + +export const hangingPicker: Picker = () => new Promise(() => {}) + +export const renderDisconnected = (ui: ReactElement): ReturnType => + render({ui}) + +// The connect flow is the SDK's, so the tests that drive it drive the real provider. +export const renderWithWallet = ( + ui: ReactElement, + config: { walletPicker?: Picker } = {}, +): ReturnType => + render( + + {ui} + , + ) diff --git a/canton-dappbooster/src/utils/json.ts b/canton-dappbooster/src/utils/json.ts new file mode 100644 index 00000000..5f74553b --- /dev/null +++ b/canton-dappbooster/src/utils/json.ts @@ -0,0 +1,5 @@ +export const asRecord = (value: unknown): Record | undefined => + typeof value === 'object' && value !== null ? (value as Record) : undefined + +export const valueAt = (value: unknown, ...path: readonly string[]): unknown => + path.reduce((at, key) => asRecord(at)?.[key], value) diff --git a/canton-dappbooster/src/utils/mergeTokens.test.ts b/canton-dappbooster/src/utils/mergeTokens.test.ts new file mode 100644 index 00000000..7b0569bb --- /dev/null +++ b/canton-dappbooster/src/utils/mergeTokens.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { mergeTokens, type PartialToken } from '#src/utils/mergeTokens' + +const amulet = { admin: 'DSO::1220ab', id: 'Amulet' } +const usdc = { admin: 'circle::1220cd', id: 'USDC' } + +const row = (instrumentId: typeof amulet, rest: Partial = {}): PartialToken => ({ + instrumentId, + ...rest, +}) + +describe('mergeTokens', () => { + it('keeps a token no source but the catalogue knows, so a picker can offer it', () => { + expect(mergeTokens([[row(usdc, { symbol: 'USDC' })], []])).toEqual([ + { instrumentId: usdc, name: 'USDC', symbol: 'USDC' }, + ]) + }) + + it('annotates a row with the balance a later source reports', () => { + const [token] = mergeTokens([ + [row(amulet, { name: 'Amulet', symbol: 'AMT' })], + [row(amulet, { balance: '12.5', locked: '2' })], + ]) + + expect(token).toEqual({ + balance: '12.5', + instrumentId: amulet, + locked: '2', + name: 'Amulet', + symbol: 'AMT', + }) + }) + + it('lets a later source win the fields it fills', () => { + const [token] = mergeTokens([[row(amulet, { symbol: 'CC' })], [row(amulet, { symbol: 'AMT' })]]) + expect(token.symbol).toBe('AMT') + }) + + it('reads an absent field as nothing to say rather than as a correction', () => { + const [token] = mergeTokens([ + [row(amulet, { name: 'Amulet', symbol: 'AMT' })], + [row(amulet, { balance: '1' })], + ]) + + expect(token.name).toBe('Amulet') + expect(token.symbol).toBe('AMT') + }) + + it('names a token nothing named after its own id', () => { + const [token] = mergeTokens([[row(usdc)]]) + expect([token.name, token.symbol]).toEqual(['USDC', 'USDC']) + }) + + it('tells two registries issuing one id apart', () => { + const other = { admin: 'other::1220ef', id: 'USDC' } + expect(mergeTokens([[row(usdc), row(other)]])).toHaveLength(2) + }) + + it('reports nothing for sources that know nothing', () => { + expect(mergeTokens([[], []])).toEqual([]) + }) +}) diff --git a/canton-dappbooster/src/utils/mergeTokens.ts b/canton-dappbooster/src/utils/mergeTokens.ts new file mode 100644 index 00000000..239b1701 --- /dev/null +++ b/canton-dappbooster/src/utils/mergeTokens.ts @@ -0,0 +1,49 @@ +import type { InstrumentId, Token } from '#src/providers/TokenListProvider/context' +import { tokenKey } from '#src/utils/tokenKey' + +/** + * What one source knows about a token: its instrument id, and whichever fields it can fill. + * + * @example + * const fromRegistry: PartialToken[] = [{ instrumentId, name: 'Canton Coin', symbol: 'CC' }] + * + * @category Utilities + */ +export type PartialToken = Partial> & { instrumentId: InstrumentId } + +const fill = (into: PartialToken, from: PartialToken): PartialToken => { + const merged = { ...into } + for (const [field, value] of Object.entries(from)) { + // An undefined field is a source with nothing to say, never an instruction to forget. + if (value !== undefined) Object.assign(merged, { [field]: value }) + } + return merged +} + +/** + * Builds one row per instrument out of every source that knows about it, later sources winning + * field by field. A row is a token the picker can offer, so the union is the catalogue and a + * holdings source only annotates it: a token nobody holds is still a row, and one nothing named is + * a row under its own id. + * + * @example + * mergeTokens([[{ instrumentId, symbol: 'CC' }], sumHoldings(holdings)]) + * + * @category Utilities + */ +export const mergeTokens = (sources: readonly (readonly PartialToken[])[]): readonly Token[] => { + const rows = new Map() + + for (const source of sources) { + for (const partial of source) { + const key = tokenKey(partial.instrumentId) + rows.set(key, fill(rows.get(key) ?? { instrumentId: partial.instrumentId }, partial)) + } + } + + return [...rows.values()].map((row) => ({ + ...row, + name: row.name ?? row.instrumentId.id, + symbol: row.symbol ?? row.instrumentId.id, + })) +} diff --git a/canton-dappbooster/src/utils/readInstruments.test.ts b/canton-dappbooster/src/utils/readInstruments.test.ts new file mode 100644 index 00000000..1a53a3da --- /dev/null +++ b/canton-dappbooster/src/utils/readInstruments.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from 'vitest' +import { readInstruments } from '#src/utils/readInstruments' + +const REGISTRY = 'https://registry.example/api' +const ADMIN = 'DSO::1220ab' + +const amulet = { decimals: 10, id: 'Amulet', name: 'Amulet', symbol: 'AMT' } + +const ok = (body: unknown): Response => new Response(JSON.stringify(body), { status: 200 }) + +// Answers each path once, in the order the reader asks for them. +const serving = (pages: Record) => + vi.fn(async (url: string | URL) => { + const path = String(url).slice(REGISTRY.length) + const body = pages[path] + return body === undefined ? new Response('nope', { status: 404 }) : ok(body) + }) + +describe('readInstruments', () => { + it('reads the catalogue and stamps the registry admin on every id', async () => { + vi.stubGlobal( + 'fetch', + serving({ + '/registry/metadata/v1/info': { adminId: ADMIN, supportedApis: {} }, + '/registry/metadata/v1/instruments': { instruments: [amulet] }, + }), + ) + + expect(await readInstruments(REGISTRY)).toEqual([ + { decimals: 10, instrumentId: { admin: ADMIN, id: 'Amulet' }, name: 'Amulet', symbol: 'AMT' }, + ]) + }) + + it('follows every page, so a catalogue past the first is not silently cut', async () => { + const second = { ...amulet, id: 'Other', name: 'Other Coin', symbol: 'OTH' } + vi.stubGlobal( + 'fetch', + serving({ + '/registry/metadata/v1/info': { adminId: ADMIN, supportedApis: {} }, + '/registry/metadata/v1/instruments': { instruments: [amulet], nextPageToken: 'more' }, + '/registry/metadata/v1/instruments?pageToken=more': { instruments: [second] }, + }), + ) + + const found = await readInstruments(REGISTRY) + expect(found.map(({ symbol }) => symbol)).toEqual(['AMT', 'OTH']) + }) + + it('stops on a page token it has already followed', async () => { + const loop = { instruments: [amulet], nextPageToken: 'loop' } + const fetching = serving({ + '/registry/metadata/v1/info': { adminId: ADMIN, supportedApis: {} }, + '/registry/metadata/v1/instruments': loop, + '/registry/metadata/v1/instruments?pageToken=loop': loop, + }) + vi.stubGlobal('fetch', fetching) + + expect(await readInstruments(REGISTRY)).toHaveLength(2) + expect(fetching).toHaveBeenCalledTimes(3) + }) + + it('gives up on a registry that keeps handing out fresh page tokens', async () => { + let page = 0 + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string | URL) => { + page += 1 + return String(url).endsWith('/info') + ? ok({ adminId: ADMIN, supportedApis: {} }) + : ok({ instruments: [amulet], nextPageToken: `page-${page}` }) + }), + ) + + expect(await readInstruments(REGISTRY)).toHaveLength(101) + }) + + it('takes a trailing slash on the registry url', async () => { + vi.stubGlobal( + 'fetch', + serving({ + '/registry/metadata/v1/info': { adminId: ADMIN, supportedApis: {} }, + '/registry/metadata/v1/instruments': { instruments: [] }, + }), + ) + + expect(await readInstruments(`${REGISTRY}/`)).toEqual([]) + }) + + it('drops an entry missing what a row is rendered from', async () => { + vi.stubGlobal( + 'fetch', + serving({ + '/registry/metadata/v1/info': { adminId: ADMIN, supportedApis: {} }, + '/registry/metadata/v1/instruments': { instruments: [{ id: 'Bare' }, amulet] }, + }), + ) + + const found = await readInstruments(REGISTRY) + expect(found.map(({ instrumentId }) => instrumentId.id)).toEqual(['Amulet']) + }) + + it('throws on a registry that refuses the read', async () => { + vi.stubGlobal('fetch', serving({})) + await expect(readInstruments(REGISTRY)).rejects.toThrow(/404/) + }) + + it('throws where the registry names no admin party', async () => { + vi.stubGlobal('fetch', serving({ '/registry/metadata/v1/info': { supportedApis: {} } })) + await expect(readInstruments(REGISTRY)).rejects.toThrow(/adminId/) + }) +}) diff --git a/canton-dappbooster/src/utils/readInstruments.ts b/canton-dappbooster/src/utils/readInstruments.ts new file mode 100644 index 00000000..2b89b9d3 --- /dev/null +++ b/canton-dappbooster/src/utils/readInstruments.ts @@ -0,0 +1,98 @@ +import type { InstrumentId } from '#src/providers/TokenListProvider/context' +import { asRecord } from '#src/utils/json' + +const METADATA = 'registry/metadata/v1' + +// A registry's address comes from whatever list the app trusts, so how many pages it may spend the +// caller on is this reader's to decide rather than the registry's. +const MAX_PAGES = 100 + +/** + * What a registry says about one instrument it administers. No logo: the metadata API serves none, + * so artwork comes from the app or from a curated list. + * + * @example + * const [{ name, symbol }] = await readInstruments(registryUrl) + * + * @category Utilities + */ +export interface Instrument { + decimals: number + instrumentId: InstrumentId + name: string + symbol: string +} + +interface Page { + instruments: readonly unknown[] + nextPageToken: string | undefined +} + +const get = async (url: string): Promise => { + const response = await fetch(url, { headers: { accept: 'application/json' } }) + if (!response.ok) { + throw new Error(`${url} answered ${response.status}`) + } + return await response.json() +} + +const toInstrument = (admin: string, value: unknown): Instrument | undefined => { + const fields = asRecord(value) + const { decimals, id, name, symbol } = fields ?? {} + if (typeof id !== 'string' || typeof name !== 'string' || typeof symbol !== 'string') { + return undefined + } + return { + decimals: typeof decimals === 'number' ? decimals : 10, + instrumentId: { admin, id }, + name, + symbol, + } +} + +const toPage = (value: unknown): Page => { + const { instruments, nextPageToken } = asRecord(value) ?? {} + return { + instruments: Array.isArray(instruments) ? instruments : [], + nextPageToken: + typeof nextPageToken === 'string' && nextPageToken !== '' ? nextPageToken : undefined, + } +} + +/** + * Reads a registry's instrument metadata, following its pages up to a limit of 100, so the answer + * is the registry's whole catalogue and a registry that will not stop paging cannot hang the caller. + * + * @throws where either request answers anything but 200, or the reply is not JSON. + * + * @example + * const instruments = await readInstruments('https://registry.example/api') + * + * @category Utilities + */ +export const readInstruments = async (registryUrl: string): Promise => { + const base = `${registryUrl.replace(/\/$/, '')}/${METADATA}` + const admin = asRecord(await get(`${base}/info`))?.adminId + if (typeof admin !== 'string') { + throw new Error(`${base}/info returned no adminId`) + } + + const found: Instrument[] = [] + const followed = new Set() + let pageToken: string | undefined + + while (followed.size <= MAX_PAGES) { + const query = pageToken === undefined ? '' : `?pageToken=${encodeURIComponent(pageToken)}` + const page = toPage(await get(`${base}/instruments${query}`)) + for (const value of page.instruments) { + const instrument = toInstrument(admin, value) + if (instrument !== undefined) found.push(instrument) + } + // A token already followed can only serve the page it served before. + if (page.nextPageToken === undefined || followed.has(page.nextPageToken)) break + followed.add(page.nextPageToken) + pageToken = page.nextPageToken + } + + return found +} diff --git a/canton-dappbooster/src/utils/sumHoldings.test.ts b/canton-dappbooster/src/utils/sumHoldings.test.ts new file mode 100644 index 00000000..97d9ef99 --- /dev/null +++ b/canton-dappbooster/src/utils/sumHoldings.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { sumHoldings } from '#src/utils/sumHoldings' + +const amulet = { admin: 'DSO::1220ab', id: 'Amulet' } +const other = { admin: 'circle::1220cd', id: 'USDC' } + +const free = (amount: string, instrumentId = amulet) => ({ amount, instrumentId, isLocked: false }) +const locked = (amount: string, instrumentId = amulet) => ({ amount, instrumentId, isLocked: true }) + +describe('sumHoldings', () => { + it('sums the contracts of one instrument into one row', () => { + expect(sumHoldings([free('10.5'), free('4')])).toEqual([ + { balance: '14.5', instrumentId: amulet, locked: '0' }, + ]) + }) + + it('keeps locked apart from spendable', () => { + expect(sumHoldings([free('10'), locked('2.5')])).toEqual([ + { balance: '10', instrumentId: amulet, locked: '2.5' }, + ]) + }) + + it('adds exactly, where a float would drift', () => { + expect(sumHoldings([free('0.1'), free('0.2')])[0].balance).toBe('0.3') + }) + + it('tells two registries apart, whatever they call the instrument', () => { + const summed = sumHoldings([free('1'), free('2', { ...other, id: 'Amulet' })]) + expect(summed).toHaveLength(2) + expect(summed.map(({ balance }) => balance)).toEqual(['1', '2']) + }) + + it('reports a party that holds only locked coin as spendable zero', () => { + expect(sumHoldings([locked('7')])).toEqual([ + { balance: '0', instrumentId: amulet, locked: '7' }, + ]) + }) + + it('reports nothing for a read that returned nothing', () => { + expect(sumHoldings([])).toEqual([]) + }) + + it('drops a holding whose amount is not an amount', () => { + expect(sumHoldings([free('1'), free('n/a')])).toEqual([ + { balance: '1', instrumentId: amulet, locked: '0' }, + ]) + }) +}) diff --git a/canton-dappbooster/src/utils/sumHoldings.ts b/canton-dappbooster/src/utils/sumHoldings.ts new file mode 100644 index 00000000..ae2067e8 --- /dev/null +++ b/canton-dappbooster/src/utils/sumHoldings.ts @@ -0,0 +1,72 @@ +import type { InstrumentId } from '#src/providers/TokenListProvider/context' +import { formatScaled, parseAmount } from '#src/utils/tokenAmount' +import { tokenKey } from '#src/utils/tokenKey' + +/** + * One holding contract a party owns, as {@link useHoldings} reports it. A locked one is escrowed, + * which is why the two are summed apart. + * + * @example + * const spendable = holdings.filter((holding) => !holding.isLocked) + * + * @category Utilities + */ +export interface Holding { + amount: string + instrumentId: InstrumentId + isLocked: boolean +} + +/** + * What a party holds of one instrument: `balance` is spendable, `locked` is escrowed. + * + * @example + * const [{ balance, locked }] = sumHoldings(holdings) + * + * @category Utilities + */ +export interface InstrumentBalance { + balance: string + instrumentId: InstrumentId + locked: string +} + +interface Running { + balance: bigint + instrumentId: InstrumentId + locked: bigint +} + +/** + * Groups a holdings read by instrument and sums it, spendable apart from locked. + * + * @example + * sumHoldings([{ amount: '1.5', instrumentId, isLocked: false }]) + * // [{ balance: '1.5', instrumentId, locked: '0' }] + * + * @category Utilities + */ +export const sumHoldings = (holdings: readonly Holding[]): readonly InstrumentBalance[] => { + const totals = new Map() + + for (const { amount, instrumentId, isLocked } of holdings) { + // A ledger amount is `Numeric 10` and always parses, so this only drops one from a read that + // is already broken. + const scaled = parseAmount(amount) + + if (scaled === undefined) continue + + const key = tokenKey(instrumentId) + const at = totals.get(key) ?? { balance: 0n, instrumentId, locked: 0n } + totals.set( + key, + isLocked ? { ...at, locked: at.locked + scaled } : { ...at, balance: at.balance + scaled }, + ) + } + + return [...totals.values()].map(({ balance, instrumentId, locked }) => ({ + balance: formatScaled(balance), + instrumentId, + locked: formatScaled(locked), + })) +} diff --git a/canton-dappbooster/src/utils/tokenKey.test.ts b/canton-dappbooster/src/utils/tokenKey.test.ts new file mode 100644 index 00000000..c224a47c --- /dev/null +++ b/canton-dappbooster/src/utils/tokenKey.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { tokenKey } from '#src/utils/tokenKey' + +describe('tokenKey', () => { + it('joins the admin party and the id', () => { + expect(tokenKey({ admin: 'DSO::1220ab', id: 'Amulet' })).toBe('DSO::1220ab/Amulet') + }) + + it('tells two registries issuing the same id apart', () => { + expect(tokenKey({ admin: 'circle::1220cd', id: 'USDC' })).not.toBe( + tokenKey({ admin: 'other::1220ef', id: 'USDC' }), + ) + }) +}) diff --git a/canton-dappbooster/src/utils/tokenKey.ts b/canton-dappbooster/src/utils/tokenKey.ts new file mode 100644 index 00000000..25be1f98 --- /dev/null +++ b/canton-dappbooster/src/utils/tokenKey.ts @@ -0,0 +1,16 @@ +import type { InstrumentId } from '#src/providers/TokenListProvider/context' + +// A slash separates them because neither field can hold one: a party id is `hint::fingerprint` and +// a registry's instrument id is a Daml identifier, so no two instruments share a key. +const SEPARATOR = '/' + +/** + * The string identity of an instrument, for a map key, a React key or an equality check. Compare + * these rather than the `id` alone: two registries can both issue a `USDC`. + * + * @example + * tokenKey({ admin: 'DSO::1220ab', id: 'Amulet' }) // 'DSO::1220ab/Amulet' + * + * @category Utilities + */ +export const tokenKey = ({ admin, id }: InstrumentId): string => `${admin}${SEPARATOR}${id}` diff --git a/canton-dappbooster/vitest.config.ts b/canton-dappbooster/vitest.config.ts index 47beae2f..55374add 100644 --- a/canton-dappbooster/vitest.config.ts +++ b/canton-dappbooster/vitest.config.ts @@ -6,6 +6,8 @@ export default defineConfig({ test: { environment: 'jsdom', setupFiles: ['./vitest.setup.ts'], + // Node 26's own localStorage global shadows jsdom's under vitest 4; vitest 5 fixes it. + execArgv: ['--no-experimental-webstorage'], // A stub or spy surviving into the next test is an order-dependent pass; undo both centrally. restoreMocks: true, unstubGlobals: true, diff --git a/canton-theme/CLAUDE.md b/canton-theme/CLAUDE.md index 4cad9f6f..cf31bdc1 100644 --- a/canton-theme/CLAUDE.md +++ b/canton-theme/CLAUDE.md @@ -102,6 +102,20 @@ exactly the case the attribute exists for. once; a fallback would be a second copy that drifts and that nothing checks. - Select only on parts and states a component actually renders. `anatomy.ts` in [`../canton-dappbooster`](../canton-dappbooster) is the source of truth; never invent a selector. +- Where every entry of a selector list repeats the same trailing part, hoist it into `:is()` rather + than spelling the shared part once per entry. A list whose entries share no suffix + (`.cnc-token-input__token, .cnc-token-select-dialog__favorite`) stays a list, and a list only some + of whose entries share one is left alone: a half-hoist reads as two rules fused rather than one. +- **Hoist only entries of equal specificity.** `:is()` takes the specificity of its most specific + argument and hands it to every branch, so one `.cnc-token-input__token[data-interactive]` in the + list silently raises the six plain classes beside it from `(0,2,0)` to `(0,3,0)` and they start + beating overrides written against them. An attribute-carrying entry stays written out on its own + line beside the `:is()`, which is why the `:focus-visible` rule has two selectors. +- **Never nest with `&`,** however much repetition it would spare. `pnpm check:anatomy` harvests + class names by regex over each rule's own selector text, so a nested `&:disabled` contributes no + class and reports at the block head instead of the rule. Flat selectors are also what keeps + `rg cnc-connect-button` returning every rule that touches it, which is how the two rules above are + audited at all. - Put colour on the root part, never on the inner value part, so a consumer's utility class on the root still wins. - Never declare `font-size` on a primitive that can sit inside a heading, a row, or a table cell. @@ -151,24 +165,22 @@ exactly the case the attribute exists for. rule collapsing it. What announces the change is a separate live region the component hides inline and out of flow; never style it with `display: none`, which drops a live region out of the accessibility tree and silences the announcement it exists for. -- Depth is set twice, on the token select dialog's backdrop and positioner at `100` and the account - popover's positioner at `50`, because those sit above the page instead of in it. Both are - portalled, so document order cannot decide it: a host's own stacking context — a sticky header, - say — otherwise renders over them. The two values are ordered so a dialog covers a popover. - Everything else stacks in document order, and a third value means three components can fight - over depth, so treat adding one as a contract decision. -- **A part Zag marks `hidden` needs its own `[hidden] { display: none }` rule here.** Zag's - `getContentProps` closes a popover by setting the `hidden` attribute and leaves the hiding to CSS, - but `[hidden]` only carries `display: none` in the user-agent stylesheet, which any author `display` - loses to whatever the layer or the specificity. So `.cnc-account-popover`'s own `display: flex` - keeps the closed panel on screen for a consumer whose reset does not re-declare `[hidden]`; ours - only looked right because `dapp/frontend` pulls in Tailwind's preflight. This applies to every - future part whose machine hides it by attribute rather than by unmounting. -- The popover's `z-index` goes on its content, never its positioner. Zag's popper owns the - positioner's inline style and, on every placement, copies the *content's* computed `z-index` onto - it as `--z-index`; a rule on the positioner is overwritten with `auto`, and the popover ends up - behind the header it was opened from. The dialog is the other way round, on the positioner and - the backdrop, because its machine does not use the popper. +- Depth is set once, on the token select dialog's backdrop and positioner at `100`, because those + sit above the page instead of in it. Both are portalled, so document order cannot decide it: a + host's own stacking context — a sticky header, say — otherwise renders over them. Everything else + stacks in document order, and a second value means components can fight over depth, so treat + adding one as a contract decision. +- **A part Zag marks `hidden` needs its own `[hidden] { display: none }` rule here.** Zag closes a + panel by setting the `hidden` attribute and leaves the hiding to CSS, but `[hidden]` only carries + `display: none` in the user-agent stylesheet, which any author `display` loses to whatever the + layer or the specificity. So a part with its own `display` keeps the closed panel on screen for a + consumer whose reset does not re-declare `[hidden]`; ours only looked right because + `dapp/frontend` pulls in Tailwind's preflight. +- The dialog's `z-index` goes on its positioner and its backdrop, which is only safe because its + machine does not use Zag's popper. A popper-positioned part takes it on the *content* instead: the + popper owns the positioner's inline style and copies the content's computed `z-index` onto it as + `--z-index`, so a rule on the positioner is overwritten with `auto`. Nothing here is + popper-positioned today, so that half is for whoever adds the first one. - A `@keyframes` name is global whatever layer declares it, so it carries the `cnc-` prefix like a token does and is public the moment it ships. Its duration comes off the `duration` scale, by `calc()` where no step fits, for the same reason every other distance does. diff --git a/canton-theme/src/default.css b/canton-theme/src/default.css index 7dd37729..6840106c 100644 --- a/canton-theme/src/default.css +++ b/canton-theme/src/default.css @@ -2,7 +2,6 @@ @layer cnc { /* Button-like parts */ - .cnc-account-popover__disconnect, .cnc-identifier__copy, .cnc-explorer-link, .cnc-token-input__max, @@ -16,13 +15,16 @@ color var(--cnc-duration) ease; } - .cnc-account-popover__disconnect:focus-visible, - .cnc-connect-button:focus-visible, - .cnc-identifier__copy:focus-visible, - .cnc-explorer-link:focus-visible, - .cnc-token-input__max:focus-visible, - .cnc-token-input__token[data-interactive]:focus-visible, - .cnc-token-select-dialog__close:focus-visible { + :is( + .cnc-disconnect-button, + .cnc-connect-button, + .cnc-cancel-button, + .cnc-identifier__copy, + .cnc-explorer-link, + .cnc-token-input__max, + .cnc-token-select-dialog__close + ):focus-visible, + .cnc-token-input__token[data-interactive]:focus-visible { outline: 2px solid var(--cnc-accent); outline-offset: 2px; } @@ -34,8 +36,7 @@ outline-offset: 1px; } - .cnc-party-id-input::placeholder, - .cnc-token-input__field::placeholder { + :is(.cnc-party-id-input, .cnc-token-input__field)::placeholder { color: var(--cnc-text-muted); opacity: 0.7; } @@ -457,6 +458,28 @@ font-size: 0.8125em; } + .cnc-token-select-dialog__row-figures { + align-items: flex-end; + display: flex; + flex-direction: column; + flex-shrink: 0; + text-align: end; + } + + .cnc-token-select-dialog__row-balance { + font-size: 0.9375em; + font-variant-numeric: tabular-nums; + } + + .cnc-token-select-dialog__row-locked { + align-items: center; + color: var(--cnc-text-muted); + display: inline-flex; + font-size: 0.8125em; + font-variant-numeric: tabular-nums; + gap: var(--cnc-space-xs); + } + /* Token logo */ .cnc-token-logo { align-items: center; @@ -515,8 +538,10 @@ color: var(--cnc-swatch-8-fg); } - /* Connect button */ - .cnc-connect-button { + /* Wallet buttons */ + .cnc-disconnect-button, + .cnc-connect-button, + .cnc-cancel-button { align-items: center; background: var(--cnc-surface); border-radius: var(--cnc-radius); @@ -534,23 +559,32 @@ border-color var(--cnc-duration) ease; } - .cnc-connect-button:hover:not(:disabled, [data-pending]) { + :is(.cnc-disconnect-button, .cnc-connect-button, .cnc-cancel-button):hover:not( + :disabled, + [aria-disabled="true"] + ) { background: var(--cnc-surface-muted); border-color: var(--cnc-border-strong); } - .cnc-connect-button:active:not(:disabled, [data-pending]) { + :is(.cnc-disconnect-button, .cnc-connect-button, .cnc-cancel-button):active:not( + :disabled, + [aria-disabled="true"] + ) { background: var(--cnc-accent-subtle); border-color: var(--cnc-accent); } - .cnc-connect-button:disabled, - .cnc-connect-button[data-pending] { + :is(.cnc-disconnect-button, .cnc-connect-button, .cnc-cancel-button):is( + :disabled, + [aria-disabled="true"] + ) { cursor: not-allowed; opacity: 0.5; } - .cnc-connect-button__spinner { + .cnc-connect-button__spinner, + .cnc-cancel-button__spinner { animation: cnc-spin calc(var(--cnc-duration-xl) * 1.5) linear infinite; border-radius: 50%; border: 2px solid color-mix(in oklab, currentColor 25%, transparent); @@ -567,48 +601,9 @@ } @media (prefers-reduced-motion: reduce) { - .cnc-connect-button__spinner { + .cnc-connect-button__spinner, + .cnc-cancel-button__spinner { animation: none; } } - - /* Account popover */ - .cnc-account-popover { - background: var(--cnc-surface); - border-radius: var(--cnc-radius); - border: 1px solid var(--cnc-border); - box-shadow: var(--cnc-shadow); - color: var(--cnc-text); - display: flex; - flex-direction: column; - gap: var(--cnc-space); - min-width: 15rem; - padding: var(--cnc-space); - z-index: 50; - } - - .cnc-account-popover[hidden] { - display: none; - } - - .cnc-identifier.cnc-account-popover__party-id { - font-weight: 700; - justify-content: center; - } - - .cnc-account-popover__disconnect { - align-items: center; - background: var(--cnc-surface-muted); - display: flex; - font-size: 0.875rem; - font-weight: 500; - gap: var(--cnc-space-sm); - justify-content: center; - padding: calc(var(--cnc-space-xs) * 2.5) var(--cnc-space-sm); - } - - .cnc-account-popover__disconnect:hover { - background: var(--cnc-danger-subtle); - color: var(--cnc-danger); - } } diff --git a/dapp/frontend/CLAUDE.md b/dapp/frontend/CLAUDE.md index 7f4eca47..1c8be0ae 100644 --- a/dapp/frontend/CLAUDE.md +++ b/dapp/frontend/CLAUDE.md @@ -33,17 +33,37 @@ The internal seams are in [`architecture.md`](architecture.md). Deltas for this One implementation each, so a second one is a bug and not a choice: +- **Every interactive control is [Ark UI](https://ark-ui.com/react/docs/overview/introduction).** + Menus, the dialog, tooltips, the select, toasts, the wizard's steps, the number input and the + toggle groups are all Ark's, so nothing here re-derives dismissal, focus trapping or roving focus. + What the app writes is the markup's classes and its wording. The rules the library leaves open are + in [`architecture.md`](architecture.md). - **Hover explanation: `components/InfoTip`.** Never a native `title` (a one-second delay, no touch, unstyled) and never a hand-rolled bubble. With a string child it dash-underlines the words; with an - element it does not, so an icon trigger is a legal child. Childless it is a `?` badge. + element it does not, so an icon trigger is a legal child. Childless it is a `?` badge. It adds the + tap-to-open Ark leaves out. - **A figure: `components/AmountDisplay`.** It owns the grouping, the forced two decimals, and the instrument mark with its tooltip. `count` is the escape hatch for a tally, which owes neither. Where the surrounding text already spells out the unit, reach for `components/CompactAmount`, the same figure without the mark: it is what keeps the exact value in a tooltip and in the accessible - name once an outsized amount is abbreviated, so a hand-rolled `formatTokenCompact` loses it. + name once an outsized amount is abbreviated, so a hand-rolled `formatFigureCompact` loses it. - **Button classes on something that is not `components/Button`:** import `buttonClass`. The kit's own buttons take a `className` but cannot render ours. A button waiting on a submission takes - `pending`, which owns the spinner, the wording and the disable together. + `pending`, which owns the spinner, the wording and the disable together. The one exception is + `TopBar/AccountMenu`'s trigger, which transcribes the kit's `.cnc-connect-button` instead so the + header keeps one look across every face the session swaps between; `cn` is a plain join and + cannot override a `buttonClass` variant, so there is no way to have both. +- **A choice out of a short, always-visible set: `components/Pills`.** One Ark toggle group behind + two looks — `outline` for the dashboard's filters, `segmented` for the create form's curve switch. + It reports the choice as a radio group, so the picked pill is a checked radio rather than a class + name. A value picked from a dropdown is `components/Select`; a *view* picked from one is + `components/RoleSelect`, which is a menu rather than a listbox because it commands the page + instead of holding a value. All three panels take their surface from `utils/popover`. +- **A generic icon comes from `lucide-react`; `src/icons/` holds only the brand and house marks,** + each named `*Mark`. `App.tsx` sets lucide's size and stroke width once through `LucideProvider`, + so a call site passes `size` only where it wants something other than the shared default. The one + wrapper is `components/Spinner`, which owns `animate-spin`: lucide ships no animation, and a + second call site spelling that class out is how one of them ends up frozen. - **A grant's badges: `components/CurvePill` and `components/GrantStatusPill`;** where a claim cannot be offered, `components/GrantLock`. Each owns its own wording, tone and base classes, so a caller passes alignment at most and never re-spells the mapping. There is no drained-grant badge: a @@ -57,6 +77,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/README.md b/dapp/frontend/README.md index 63e648e3..c51f7d65 100644 --- a/dapp/frontend/README.md +++ b/dapp/frontend/README.md @@ -37,6 +37,10 @@ finds both once a wallet connects: the operator through the rights the bootstrap factory through an active-contracts read that returns its explicit-disclosure payload, without which a grant cannot be created. Re-running it supersedes the last one, on any ledger. +Funding a grant takes `DBT`, and the account menu has a faucet for it: **Tap dAppBooster Token** +taps 1000 DBT into the connected party, straight off the instrument's own config. It refuses an +amount above the `maxPerTap` the bootstrap set, and says so in the failure toast. + Connect with a CIP-0103 browser wallet; the party it reports is the one you act as, and the session is restored on reload by the wallet itself. Changing the wallet's primary account changes the party the dApp acts as. Its two env knobs, the explorer party ids diff --git a/dapp/frontend/architecture.md b/dapp/frontend/architecture.md index 8a86ad1c..eacc54ff 100644 --- a/dapp/frontend/architecture.md +++ b/dapp/frontend/architecture.md @@ -12,13 +12,13 @@ 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, `registry.ts`, which asks the canton-token-forge registry for the instrument and the `InstrumentConfig` disclosure, 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/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. | +| `src/backend/` | The `VestingBackend` interface, `LedgerBackend` (its one implementation), the pure ACS→domain mappers, the command builders, the `WalletFns` seam, `registry.ts`, which asks the canton-token-forge registry for the instrument, the `InstrumentConfig` disclosure and the network it sits on, `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 and the vested instrument 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`: the vested instrument'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 `REGISTRY_URL` and the other literals that validation left behind, and `tokens.tsx`, the one instrument this deployment knows. The two state modules whose view lives elsewhere are here too: `toast.ts` and `topLayer.ts`. | -| `src/components/` | What two or more places render: the shell, the top bar, the dialogs, and the primitives the pages compose. | -| `src/icons/` | One icon per file, over the shared `Svg` wrapper where the icon is stroke-based, re-exported from `index.ts`. | +| `src/utils/` | Pure helpers, `schedule.ts` chief among them, plus `env.ts`, the environment contract `vite.config.ts` validates against, `config.ts`, which reads `REGISTRY_URL` and the other literals that validation left behind, `network.ts`, the rule behind the wrong-network strip, `tokens.tsx`, the artwork and wording this deployment gives the instrument it vests, 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. | | `src/styles/` | The single stylesheet entry and the app's own tokens. | | `api/` | Vercel functions, published off the deployed origin. `registry.ts` is the only one, and it forwards three read-only registry routes that `vercel.json` rewrites onto it. Not part of the bundle and not reachable in `pnpm dev`. | @@ -86,6 +86,82 @@ three routes and refuses the rest: the registry is read-only, but republishing a the product's own domain is a decision rather than a default. `vercel.json`'s SPA catch-all is scoped away from `/api/` so it cannot answer the route with `index.html`. +The faucet in the account menu taps that same config. `LedgerBackend.tap` exercises +`InstrumentConfig_Tap` for `TAP_AMOUNT` on the contract id and resolved template id the registry +already disclosed, so the button costs no read the app was not making anyway, and the wallet signs +it as the connected party. The choice is nonconsuming on the admin-signed config, which is what +lets a connected wallet tap with nothing else disclosed. The registry publishes an instrument's id, +name, symbol and decimals but never its faucet, and the config carries no readable payload, so the +per-tap cap cannot be read from the app: `TAP_AMOUNT` has to stay at or under the `maxPerTap` +`scripts/bootstrap-vesting.mjs` sets, and above it the choice aborts and the message reaches the +user as the failure toast. + +## 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 factory and `InstrumentConfig` ids do not exist on the ledger the wallet +reaches. Both sides of that are read. +[`registry.ts`](src/backend/registry.ts) carries `fetchAppNetwork`, which returns the +`synchronizerId` the registry stamped on the `InstrumentConfig` disclosure, the network the app's +contracts are on. It reads that from the registry rather than from +[`config.ts`](src/backend/config.ts)'s `synchronizerId` off the factory row, which the deployment +already carries, and the reason is not cost but circularity: the factory row is read through the +connected wallet, so a wallet on another network returns no factory at all and the deployment's own +synchronizer can only ever agree with the wallet. The registry answers over its own HTTP service, +which is what lets the strip fire on a first load. It assumes the registry and the factory share a +synchronizer, the same assumption `submit` already makes when it stamps the factory's onto every +disclosure. +[`synchronizer.ts`](src/backend/synchronizer.ts) reads the other side, the synchronizers the +wallet's own participant is connected to. + +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. 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 the registry only its own. A strip saying "switch to canton:localnet" +while already claiming to be on it is worse than one naming no target. + +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 registry that is down, or a wallet that has just locked, is not a wrong +network. + +Only the wallet's side is on that poll. The registry serves one deployment for as long as it is up, +so the app's side is read once and kept, and every later check is a single read of the wallet's +participant. 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 — where its Try again is worth offering, which is why that button is +shown only off the wrong network. + ## Creating a grant takes one approval `VestingFactory_CreateVesting` splits the funder's inputs itself: it transfers them into one @@ -179,15 +255,41 @@ only place the two seams above meet. Its backend is `undefined` until both a dep restored-but-locked session reports itself connected while reporting no party, and the party is what every read filters on and every submit acts as. The shell holds the pages until the deployment has resolved either way, so inside a page a missing backend can only mean a missing party — which is -what makes `ConnectPrompt`'s copy, and the kit `ConnectButton` inside it, correct wherever it -renders. - -The session is the other chain, and none of it is this app's. `CantonConnectProvider` owns it, -`ConnectButton` from the kit drives it, and `useParty` +what makes `ConnectPrompt`'s copy, and the kit connect button inside it, correct wherever it +renders — that face never flips to the disconnect one, which is why the prompt takes it rather than +the kit's `WalletButton`. Once an attempt is in flight the connect button is swapped for +`CancelButton`, so one button is on screen at a time and each does one thing only: that button +carries the spinner for the wait and the word for the action. A double-click therefore lands its second click on the cancel and +ends the attempt it just started, which the split makes honest rather than hidden: the button the +user hits says Cancel. + +The swap itself is [`ConnectFace`](src/components/ConnectFace.tsx), which the prompt and the top bar +both render, so a third site cannot forget half of it. It is a leaf on purpose: it holds the only +`useConnect` subscription of the two, so an attempt re-renders one button rather than the header or +the empty state around it. It also owns the repair a swap needs — the focused button is the one +being unmounted, so focus falls to `` and the keyboard loses its place. It hands focus to +whichever button took over, and only then: a focus move nobody asked for on first paint would be +worse than the problem. + +The session is the other chain, and none of it is this app's. `CantonConnectProvider` owns it, the +kit's `ConnectButton`, `CancelButton` and `DisconnectButton` drive it, and `useParty` ([`src/hooks/useParty.ts`](src/hooks/useParty.ts)) narrows it to the `PartyRef` the UI wants, -standing the party hint in as a display name for the wallets that report none. The shell no longer -gates on it: it always mounts, so the top bar's connect button and the theme toggle stay reachable -and the wallet's own account switch is the only way the acting party changes. A connect that fails +standing the party hint in as a display name for the wallets that report none. Nothing else reaches +for a `canton-connect` hook except `ConnectFace` and the error toast, both of which need `isPending` +off `useConnect`. The top bar picks its face itself rather than reaching for the kit's +`WalletButton`, whose disconnect face is a plain button: the connected side here is a dropdown, +`TopBar/AccountMenu`, holding the copyable party id, the network the session is on, and the +disconnect. Everything short of that is `ConnectFace`, so an attempt started from the top bar can be +abandoned there rather than only from the prompt further down the page. +It picks on the party alone, never on `isConnected`: a standing session reports no party while the +account read is in flight, again after it fails, and again once a lock clears it, and the connect +face is the right answer to all three. It renders its own pending copy for the first and retries the +second. For the third it is the only way back, because a lock and a wallet-side disconnect are one +push the app cannot tell apart, and the machine accepts a connect from a standing session for +exactly that reason; a refresh there restores nothing and lands on the same face, so the two agree. +The shell no longer gates on the session: it always mounts, so the top bar's wallet control and the +theme toggle stay reachable, and the wallet's own account switch is the only way the acting party +changes. A connect that fails reaches the user through [`useConnectErrorToast`](src/hooks/useConnectErrorToast.ts), because the kit ships no user-facing copy and would otherwise fail silently; a cancel is a choice, not a failure, and stays quiet, which @@ -245,7 +347,7 @@ downstream. Party ids come from `@bootnodedev/canton-dappbooster`, styled by `@bootnodedev/canton-theme`. The app holds no truncation or copy-to-clipboard logic of its own. -Entry is the other half. [`CreateGrant`](src/components/CreateGrant/index.tsx)'s receiver field is +Entry is the other half. [`CreateGrant`](src/components/CreateGrant/Details.tsx)'s receiver field is the kit's ``, and the submit gate calls the same `validatePartyId` the field does, so the two can never disagree about what a party id is. Party ids are exact strings here: nothing trims, so a stray space is invalid rather than silently stripped on the way to the ledger. @@ -257,30 +359,80 @@ rendering nothing, and the red state is a Tailwind `aria-invalid:` variant rathe `canton-theme`'s, because the app's utilities sit above the `cnc` layer (see [`src/styles/index.css`](src/styles/index.css)). -Amounts run that same split twice more. [`CreateGrant`](src/components/CreateGrant/index.tsx)'s +Amounts run that same split twice more. [`CreateGrant`](src/components/CreateGrant/Details.tsx)'s total and [`Claim`](src/components/Claim.tsx)'s withdrawal are both the kit's ``: the field sets `aria-invalid` and reports an error *code*, and this app words it in [`src/utils/amountErrorText.ts`](src/utils/amountErrorText.ts), again an exhaustive `Record` so a code added upstream fails the build here. -**Neither field offers the token picker, and that is deliberate.** Both pass `token={DBT}` and no -`onTokenSelect`, which is what makes the kit render the symbol as a static mark rather than a button. -[`src/utils/tokens.tsx`](src/utils/tokens.tsx) holds `DBT` and nothing else, because that is the only -instrument this deployment knows, so a picker over it would open a dialog to choose the value already -chosen. The claim dialog has a second reason it will keep: what a grant pays out is fixed by the -contract, so there is nothing there to pick. - -Turning the create field back into a real picker takes three things, none of them wired yet: - -- **A list to choose from.** `TOKENS` in `src/utils/tokens.tsx` is a hardcoded one-entry array. It - becomes whatever enumerates the instruments a deployment actually holds, and the kit's - `TokenListProvider` is what the picker reads it through. -- **A selection to hold.** The field re-grows its own `useState(DBT)` and passes - `onTokenSelect`. Per-field rather than lifted, unless by then two amounts on one page must agree. -- **The rest of the app told about it.** Today the pick would be display-only: the re-lock floor's - wording, the claim toast, `AmountDisplay`'s mark and the grant that gets created all say - DBT in their own right. Each has to take the chosen token instead, or a pick would relabel - one field and silently mean nothing. +**The create field offers the picker; the claim dialog does not, and will not.** What a grant pays +out is fixed by the contract, so `Claim` passes `token={DBT}` and no `onTokenSelect`, which is what +makes the kit render the symbol as a static mark rather than a button. + +The list behind the picker is real. [`src/providers/Tokens.tsx`](src/providers/Tokens.tsx) reads the +kit's `useHoldings`, groups it with `sumHoldings`, reads the registry's catalogue with +`readInstruments`, and hands the merged result to `TokenListProvider`. + +The rows are the union of what every source knows, merged by the kit's `mergeTokens`: the curated +list, then the registries, then the balances, then the app's own artwork. Later sources win field by +field, so the registry has the last word on a label and the curated list supplies the logo it does +not serve. A source that will not answer costs labels and no rows. + +**A token the party holds none of is still a row.** The list is a catalogue, and a picker that only +offered what you already hold could never serve a swap's buy side. This form does not filter it +either: picking a token you hold nothing of simply leaves you unable to grant it, which is the +field's own rule to enforce. + +**The vested instrument's figures are this app's, not the ledger's.** Its `balance` is what +`balanceOf` reports, what is free to fund a grant, and its `locked` is the escrowed holdings plus +whatever a pending grant has reserved. The three still sum to everything held, so the row hides +nothing; it splits it the way this app can act on. Every other row keeps the ledger's own split. +Which row that is comes from `useBackend().instrument`, the `(admin, instrumentId)` pair the +deployment read off the registry, matched on both halves: a shared participant can hold another +admin's instrument under the same id. + +That is also why the create field no longer reads a balance of its own: it takes the row the picker +handed back, so its Max and its ceiling are the figure the row showed. Pick a token you hold none of +and Max is disabled, which is the correct dead end. + +A row carries a figure or it does not, so a read that failed looks exactly like one still running. +`useTokenFigures` is what tells them apart: `Tokens` publishes whether either read failed, and the +field turns that into the kit's `balanceState="error"`, which is the `Balance: N/A` face. + +Which registries to ask comes from the same curated list: the first URL each entry publishes, plus +`REGISTRY_URL`, the canton-token-forge registry this deployment vests against and the only address a +LocalNet has. Every one of them is read, not only the ones behind a holding, because a catalogue is +the point. Only same-origin ones, though: a registry on another origin sends no CORS headers, so the +browser blocks the read and the curated entry keeps the symbol and logo it already carries. + +The token standard's v1 APIs publish no way to find a registry, so every app keeps that mapping +itself. [CIP-0056](https://github.com/canton-foundation/cips/blob/main/cip-0056/cip-0056.md) is the +proposal to replace it: ask any SV's scan for the CNS entry at `/v0/ans-entries/by-party/` +and read the registry URL out of the JSON its description carries. It takes an admin party, so it +would answer where to ask about a token already listed and never which tokens exist, and no +LocalNet registers those entries. The curated list stays the source of both. + +The curated list is [`assets.json`](https://github.com/canton-network/wallet/blob/main/api-specs/assets.json) +in the Canton wallet repo, read by [`src/utils/assetList.ts`](src/utils/assetList.ts). It lives here +and not in the kit because it is one repository's file rather than a standard: no CIP, no schema, no +versioning, and its shape is whoever maintains it to change. So it is trusted for artwork and for +the symbol it publishes, never for identity or amounts, and the kit ships no reader for it. + +`ASSET_LIST_NETWORK` picks a top-level key of that file, and `undefined` skips the source +altogether. The published file covers `MainNet`, `TestNet` and `DevNet` and no LocalNet, so the dev +server serves the whole published list with a `LocalNet` section holding the DevNet entries. This +stack's own instrument is deliberately not written into it: the registry serves that, and a party +in a file would be one developer's and wrong for everyone else after the next `reset`. The list +failing costs labels and nothing else, so a stack that is down still serves one. + +[`src/utils/tokens.tsx`](src/utils/tokens.tsx) is down to the artwork, the name and the symbol. It +carries no `instrumentId` at all, because the admin is minted per bootstrap run: the identity comes +from the registry through `useBackend().instrument`, and this is only what the app calls it. + +One thing the pick still does not do: **it changes nothing but the field.** The re-lock floor's +wording, the claim toast, `AmountDisplay`'s mark and the grant that gets created all say `DBT` in +their own right, and `createVesting` funds from the vested instrument whatever the picker shows. +Each has to take the chosen token instead, or a pick relabels one field and means nothing. Both pages re-derive that code with the kit's own `validateAmount` rather than storing the one `onChange` handed them, because the bounds move on their own: the claim dialog's ceiling is a @@ -305,20 +457,21 @@ the ledger aborts on. `grantBacking` sits beside `deriveGrant` rather than insid `claimAvailable`: it does not move with the clock. A residual claim carries no schedule, so its two are the same amount and `Claim`'s `backing` prop defaults to `available`. -Only the claim dialog has a ceiling, and it is the grant's own `claimable`. The create form has a -balance without one: `VestingBackend.balanceOf` reads what the funder holds and the field offers it -through `Max`, but `validateAmount` is called with no `max`, so a larger amount is neither flagged -nor blocked and the selection refuses it instead, naming what is actually free. That is also why the -field's `aria-invalid` is passed in rather than left to the kit, which would flag an amount above -the `balance` it was given. +Both dialogs have a ceiling, and both hand it to the field as `balance`: the claim dialog's is the +grant's own `claimable`, the create form's is whatever figure the picked row carries. So neither +passes `aria-invalid` for the amount — the kit flags anything above the `balance` it was given — and +where a read leaves no figure to give, the selection refuses the amount instead, naming what is +actually free. A Canton balance is a set of holding contracts rather than a scalar, so the read is party-scoped and summed. It reports what a grant could actually spend rather than what the party owns, over the same set `selectHoldings` will draw from: a holding already escrowed is a `LockedToken` and so out by template, and the one an outstanding grant reserves is out because spending it would leave that -grant unacceptable. The two agreeing is the point: a `Max` that offered more would put an amount in the -field that the next step always refuses. The read runs once, on mount: nothing the form does moves -the funder's holdings. +grant unacceptable. The two agreeing is the point — a `Max` that offered more would put an amount in +the field that the next step always refuses. The read belongs to `Tokens` now rather than to the +form, so it runs when the party changes and again whenever the create dialog opens: a grant that +dialog created has reserved a holding since, and the figure it showed before would be the one from +before the grant. The amount field shows no validation message at all for now, which is why nothing words `MIN_GRANT_AMOUNT` or a bad decimal to the user; both still gate `Continue`. `AMOUNT_ERROR_TEXT` @@ -337,14 +490,16 @@ yet. Restoring it is passing `href={useExplorerLink(EXPLORER)(party)}` again at sites that want it: the kit composes URLs only from an `ExplorerConfig` because Canton has no canonical explorer, and the href stays a per-call-site decision the way the kit's own is optional. Counterparty ids go through one component: -[`src/components/CounterpartyId.tsx`](src/components/CounterpartyId.tsx) binds the from/to prefix, -the direction-specific label, and the copy toast, and `GrantCard` and `PendingGrantCard` render it. -Every `` -the app renders passes `announce={false}`: the `Toaster` is the app's live region, so the kit's own -would double-announce. That one region has to move: `Modal` opens a native `` with -`showModal()`, which inerts everything outside the dialog's subtree, so a toast raised over an open -dialog — every failed submit — would be neither clickable nor announced. `utils/topLayer.ts` carries -the open dialog element from `Modal` to the `Toaster`, which portals into it. +[`src/components/CounterpartyId.tsx`](src/components/CounterpartyId.tsx) binds the from/to prefix +and the direction-specific label, and `GrantCard` and `PendingGrantCard` render it. A copy raises +no toast: the icon swapping to a tick is the confirmation, and the kit's own live region announces +it, which is why no `` here turns `announce` off. The toast region stays where it +mounts, which takes one arrangement with the +dialog: Ark's `Dialog` aria-hides everything outside its own content but skips any element carrying +`aria-live`, and the toast region carries one, so a toast raised over an open dialog — every failed +submit — is still announced. Clicks are the other half. A modal dialog blocks the pointer outside +itself and reads a click there as a dismissal, so `Modal` names the region in Ark's +`persistentElements`, and `utils/toast.ts` exports the lookup that finds it by the id Zag gives it. That literal is the build's doing. [`vite.config.ts`](vite.config.ts) runs `parseEnv(loadEnv(...))` and `define`s the parsed values back onto `import.meta.env`, so a bad @@ -366,6 +521,42 @@ default storage key, and [`src/styles/tokens.css`](src/styles/tokens.css) keys t pre-paint script sits in `index.html`, are the kit's call: [`canton-dappbooster/architecture.md`](../../canton-dappbooster/architecture.md). +## Where the widgets come from + +Every menu, dialog, tooltip, select, toast, stepper, number input and progress bar is +[Ark UI](https://ark-ui.com/react/docs/overview/introduction), and every generic icon is +`lucide-react`. Ark wraps the same `@zag-js/*` machines `canton-dappbooster` already depends on and +pins them to the exact version the kit resolves, so the lock file holds one copy of each rather than +two. What the app still writes is the classes and the wording; what it stopped writing is +dismissal, focus trapping, roving focus, live-region announcement and popper placement. + +`canton-dappbooster` stays on raw Zag. Its anatomy class strings are what `canton-theme` selects +against, and Ark ships its own. + +Three things the library leaves to the caller, settled once here: + +- **A popper's z-index goes on its `Content`, never its `Positioner`.** Zag reads the content's + computed `z-index` and writes it onto the positioner as an inline `z-index: var(--z-index)`, so a + class on the positioner loses to that inline style and the panel lands on `auto`. +- **A popper opened inside `Modal` renders inline with `strategy: 'fixed'` rather than in a + `Portal`.** A portal would put the panel outside the dialog, where Ark aria-hides it and blocks + the pointer. Fixed positioning is what frees it from the dialog's own scroll box without leaving + the dialog. +- **`InfoTip` opens on tap.** Zag's tooltip ignores touch pointers by design, so the component adds + a `pointerup` toggle for `pointerType === 'touch'` and turns `closeOnClick` off, or the click that + follows the tap closes what the tap opened. +- **`Modal` refuses a focus-outside dismissal.** Ark closes a dismissable layer once focus lands + outside it, and a modal dialog only ever gets that by accident: mounting is what opens ours, so + no `Dialog.Trigger` is registered for Ark to exclude, and the focus trap handing focus back to + the button that opened it reads as an interaction outside. Under React's development remount + that happens on the way in, and the dialog shuts the moment it opens. A press outside and Escape + still close it. + +The toast stack is the one Ark part that needs CSS the app has to supply: Zag places each toast +absolutely and hands the offsets over as custom properties, so +[`src/styles/index.css`](src/styles/index.css) turns them into a `translate` and a transition. With +no rule there every toast draws on top of the one before it. + ## Stylesheet layering [`src/styles/index.css`](src/styles/index.css) is the single entry. Its leading diff --git a/dapp/frontend/package.json b/dapp/frontend/package.json index a01e2693..7c22a35e 100644 --- a/dapp/frontend/package.json +++ b/dapp/frontend/package.json @@ -23,12 +23,14 @@ "typecheck": "tsc -b --noEmit" }, "dependencies": { + "@ark-ui/react": "^5.39.1", "@bootnodedev/canton-connect": "workspace:*", "@bootnodedev/canton-dappbooster": "workspace:*", "@bootnodedev/canton-theme": "workspace:*", "@fontsource-variable/jetbrains-mono": "^5.2.8", "@fontsource-variable/manrope": "^5.2.8", "boring-avatars": "^2.0.4", + "lucide-react": "^1.39.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-router-dom": "^7.6.2", diff --git a/dapp/frontend/src/App.tsx b/dapp/frontend/src/App.tsx index 9eb14691..c3d5a7a7 100644 --- a/dapp/frontend/src/App.tsx +++ b/dapp/frontend/src/App.tsx @@ -1,23 +1,25 @@ import { type CantonConnectConfig, CantonConnectProvider } from '@bootnodedev/canton-connect' -import { ThemeProvider, TokenListProvider } from '@bootnodedev/canton-dappbooster' +import { ThemeProvider } from '@bootnodedev/canton-dappbooster' +import { LucideProvider } from 'lucide-react' import { createBrowserRouter, RouterProvider } from 'react-router-dom' import { Backend } from '@/providers/Backend' +import { Tokens } from '@/providers/Tokens' import { routes } from '@/routes' -import { TOKENS } from '@/utils/tokens' const router = createBrowserRouter(routes) -// Out here so the provider's memoized SDK survives every re-render. const connectConfig: CantonConnectConfig = { appName: 'Canton Vesting' } export const App = (): React.JSX.Element => ( - - + + - + + + - - + + ) diff --git a/dapp/frontend/src/backend/LedgerBackend.test.ts b/dapp/frontend/src/backend/LedgerBackend.test.ts index 2afb7e76..235048d4 100644 --- a/dapp/frontend/src/backend/LedgerBackend.test.ts +++ b/dapp/frontend/src/backend/LedgerBackend.test.ts @@ -325,7 +325,7 @@ describe('LedgerBackend.createVesting', () => { it('exercises the factory choice with the composed note, schedule and config', async () => { const { backend, submissions } = harness({ acs: { [TOKEN]: [tokenRow('t1', '1000')] } }) - const result = await backend.createVesting(grant) + await backend.createVesting(grant) expect(submissions[0]?.commands).toEqual([ { @@ -358,9 +358,6 @@ describe('LedgerBackend.createVesting', () => { }, ]), ) - expect(result.disclosedBytes).toBe( - CONFIG.createdEventBlob.length + deployment.factoryBlob.length, - ) }) it('omits the synchronizer id when the config carries none', async () => { @@ -405,9 +402,7 @@ describe('LedgerBackend.createVesting', () => { readsFailAfterSubmit: true, }) - await expect(backend.createVesting(grant)).resolves.toEqual({ - disclosedBytes: CONFIG.createdEventBlob.length + deployment.factoryBlob.length, - }) + await expect(backend.createVesting(grant)).resolves.toBeUndefined() }) }) diff --git a/dapp/frontend/src/backend/LedgerBackend.ts b/dapp/frontend/src/backend/LedgerBackend.ts index 1f2607b7..a311165f 100644 --- a/dapp/frontend/src/backend/LedgerBackend.ts +++ b/dapp/frontend/src/backend/LedgerBackend.ts @@ -305,9 +305,8 @@ export class LedgerBackend implements VestingBackend { // One submission, so one wallet approval: the factory splits the funder's inputs down to the grant // and hands the change back in the same transaction, so the funder never pre-splits the way the // Amulet version had to. The factory is the operator's and observer-less, so the funder cannot - // read it and its disclosure comes from the deployment; the disclosed size is what lets the UI - // surface that mechanic. - async createVesting(args: CreateVestInput): Promise<{ disclosedBytes: number }> { + // read it and its disclosure comes from the deployment. + async createVesting(args: CreateVestInput): Promise { const free = await this.freeTokens(args.proposer) const picked = selectHoldings(free, args.totalAmount) if (picked === undefined) { @@ -315,7 +314,7 @@ export class LedgerBackend implements VestingBackend { `only ${addAmounts(...free.map(tokenValue))} ${this.instrument.instrumentId} is free to fund this grant`, ) } - const disclosed = await this.submitWithConfig( + await this.submitWithConfig( args.proposer, ({ configCid }) => buildCreateVestingCommand(this.factory.templateId, this.factory.contractId, { @@ -336,9 +335,6 @@ export class LedgerBackend implements VestingBackend { await this.reconcileFunding(args.proposer).catch((cause: unknown) => { console.warn('could not read back the holding this grant reserves', cause) }) - return { - disclosedBytes: disclosed.reduce((total, one) => total + one.createdEventBlob.length, 0), - } } // The receiver has to disclose the holding a grant reserves, and only the funder can read it: a diff --git a/dapp/frontend/src/backend/VestingBackend.ts b/dapp/frontend/src/backend/VestingBackend.ts index 6b832b20..8dc72563 100644 --- a/dapp/frontend/src/backend/VestingBackend.ts +++ b/dapp/frontend/src/backend/VestingBackend.ts @@ -40,7 +40,7 @@ export interface VestingBackend { cancelProposal(args: { proposer: string; pendingCid: string }): Promise claimHistory(partyId: string, contractCid: string): Promise claimResidual(args: { receiver: string; claimCid: string; amount: string }): Promise - createVesting(args: CreateVestInput): Promise<{ disclosedBytes: number }> + createVesting(args: CreateVestInput): Promise rejectProposal(args: { receiver: string; pendingCid: string }): Promise tap(args: { amount: string; party: string }): Promise viewAs(partyId: string): Promise diff --git a/dapp/frontend/src/backend/commands.test.ts b/dapp/frontend/src/backend/commands.test.ts index 8738eb1f..2d3b8bbb 100644 --- a/dapp/frontend/src/backend/commands.test.ts +++ b/dapp/frontend/src/backend/commands.test.ts @@ -188,6 +188,27 @@ describe('command builders', () => { }) }) + // A Daml Numeric literal has no trailing-dot form; the input filters upstream let '1000.' through. + it('canonicalizes a trailing-dot amount before it reaches the payload', () => { + const cmd = buildCreateVestingCommand('TID', 'fcid', { + configCid: CONFIG_CID, + proposer: 'P', + receiver: 'B', + totalAmount: '1000.', + schedule: linear, + tokenCids: [], + }) + expect((cmd.ExerciseCommand.choiceArgument as { totalAmount: string }).totalAmount).toBe('1000') + expect( + buildWithdrawCommand('TID', 'cid', '100.', CONFIG_CID).ExerciseCommand.choiceArgument + .withdrawAmount, + ).toBe('100') + expect( + buildClaimResidualCommand('TID', 'rcid', '50.', CONFIG_CID).ExerciseCommand.choiceArgument + .withdrawAmount, + ).toBe('50') + }) + it('canonicalizes the amount and refuses one it cannot parse', () => { expect( buildWithdrawCommand('pkg:Vesting:VestingContract', 'c1', '10.50', CONFIG_CID).ExerciseCommand diff --git a/dapp/frontend/src/backend/commands.ts b/dapp/frontend/src/backend/commands.ts index 0abd09aa..e3bc817a 100644 --- a/dapp/frontend/src/backend/commands.ts +++ b/dapp/frontend/src/backend/commands.ts @@ -138,6 +138,13 @@ export const buildClaimResidualCommand = ( configCid, }) +// One fixed amount, so a tap is a menu item and not a form. The registry publishes an instrument's +// id, name, symbol and decimals but never its faucet, and the config is admin-signed with no +// payload in its disclosure, so the per-tap cap cannot be read from the app. This must stay at or +// under the `maxPerTap` scripts/bootstrap-vesting.mjs sets; above it, InstrumentConfig_Tap aborts +// and the message surfaces in the caller's toast. +export const TAP_AMOUNT = '1000' + // Nonconsuming on the admin-signed config, so a connected wallet taps with nothing disclosed but // the config itself. export const buildTapCommand = ( diff --git a/dapp/frontend/src/backend/config.ts b/dapp/frontend/src/backend/config.ts index 38475fc6..eff0b412 100644 --- a/dapp/frontend/src/backend/config.ts +++ b/dapp/frontend/src/backend/config.ts @@ -22,7 +22,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/registry.test.ts b/dapp/frontend/src/backend/registry.test.ts index 54949f93..74848316 100644 --- a/dapp/frontend/src/backend/registry.test.ts +++ b/dapp/frontend/src/backend/registry.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { fetchInstrument, fetchInstrumentConfig } from '@/backend/registry' +import { fetchAppNetwork, fetchInstrument, fetchInstrumentConfig } from '@/backend/registry' const ADMIN = 'instrument-admin-1700000000000::ns' const INSTRUMENT = { admin: ADMIN, instrumentId: 'DBT' } @@ -132,7 +132,9 @@ describe('fetchInstrumentConfig', () => { }) }) - it('returns the config reference and its disclosure without the synchronizer id', async () => { + // The synchronizer comes back beside the disclosure and not on it: `submit` stamps its own, and + // the loose one is what `fetchAppNetwork` compares the wallet against. + it('returns the config reference, its stripped disclosure and the synchronizer id', async () => { stubRegistry(factory) await expect(fetchInstrumentConfig('funder::1', INSTRUMENT)).resolves.toEqual({ @@ -145,6 +147,7 @@ describe('fetchInstrumentConfig', () => { createdEventBlob: 'YmxvYg==', }, ], + synchronizerId: 'global-domain::1220', }) }) @@ -221,3 +224,29 @@ describe('fetchInstrumentConfig', () => { ) }) }) + +describe('fetchAppNetwork', () => { + it('reads the network off the config disclosure the registry serves', async () => { + stubRegistry({ ...metadata, ...factory }) + + await expect(fetchAppNetwork('funder::1')).resolves.toBe('global-domain::1220') + }) + + // A registry that answers without one leaves the check with nothing to compare, which reads as + // no verdict rather than a mismatch. + it('reports no network when the disclosure carries none', async () => { + const { synchronizerId, ...bare } = CONFIG + stubRegistry({ + ...metadata, + '/registry/transfer-instruction/v1/transfer-factory': { + body: { + factoryId: '00cfg', + transferKind: 'self', + choiceContext: { choiceContextData: { values: {} }, disclosedContracts: [bare] }, + }, + }, + }) + + await expect(fetchAppNetwork('funder::1')).resolves.toBeUndefined() + }) +}) diff --git a/dapp/frontend/src/backend/registry.ts b/dapp/frontend/src/backend/registry.ts index 8c082423..8fc4ff3a 100644 --- a/dapp/frontend/src/backend/registry.ts +++ b/dapp/frontend/src/backend/registry.ts @@ -20,6 +20,7 @@ export type InstrumentConfigRef = { type WireDisclosure = { contractId?: string createdEventBlob?: string + synchronizerId?: string templateId?: string } @@ -90,7 +91,9 @@ export const fetchInstrument = async (): Promise => { export const fetchInstrumentConfig = async ( party: string, instrument: RegistryInstrument, -): Promise => { +): Promise< + InstrumentConfigRef & { disclosed: DisclosedContract[]; synchronizerId: string | undefined } +> => { const result = await call<{ choiceContext?: { disclosedContracts?: WireDisclosure[] } factoryId?: string @@ -121,7 +124,8 @@ export const fetchInstrumentConfig = async ( configCid: result.factoryId, configTemplateId: config.templateId, // Rebuilt field by field to drop the wire object's `synchronizerId`, which `submit` stamps from - // the factory's own deployment instead, assuming the two share one synchronizer. + // the factory's own deployment instead, assuming the two share one synchronizer. Returned + // alongside because that same assumption is what makes it the app's network. disclosed: [ { templateId: config.templateId, @@ -129,5 +133,14 @@ export const fetchInstrumentConfig = async ( createdEventBlob: config.createdEventBlob, }, ], + synchronizerId: config.synchronizerId, } } + +// The app's side of the wrong-network check. Read from the registry rather than off the deployment +// the wallet reads back: a wallet on another network returns no factory at all, so the deployment's +// own synchronizer can only ever agree with it, and the strip would never fire on a first load. +export const fetchAppNetwork = async (party: string): Promise => { + const { synchronizerId } = await fetchInstrumentConfig(party, await fetchInstrument()) + return synchronizerId +} 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/components/AppShell.tsx b/dapp/frontend/src/components/AppShell.tsx index 32872514..960b6a72 100644 --- a/dapp/frontend/src/components/AppShell.tsx +++ b/dapp/frontend/src/components/AppShell.tsx @@ -1,25 +1,39 @@ -import { Outlet } from 'react-router-dom' +import { useEffect } from 'react' +import { Outlet, ScrollRestoration } from 'react-router-dom' import { Button } from '@/components/Button' import { Card } from '@/components/Card' import { CreateGrant } from '@/components/CreateGrant' +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, retryConfig } = useBackend() + const { backend, configPending, configError, retryConfig, 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. Held - // until there is a backend so a deep link with no session still lands on the page's connect card. + // the action would otherwise repeat the mount, and a reader can open it from any of them. const [creating, setCreating] = useCreateGrant() useConnectErrorToast() + // A lock or a disconnect takes the action away, so the param goes too: left in the URL it would + // reopen the dialog on the next connect. Only once the session is settled, or a reload would drop + // it before the restore has had its chance. + const noSession = !sessionPending && !configPending && backend === undefined + useEffect(() => { + if (creating && noSession) { + setCreating(false) + } + }, [creating, noSession, setCreating]) + return (
diff --git a/dapp/frontend/src/components/Button.tsx b/dapp/frontend/src/components/Button.tsx index cb49554e..66e456f9 100644 --- a/dapp/frontend/src/components/Button.tsx +++ b/dapp/frontend/src/components/Button.tsx @@ -1,13 +1,11 @@ import type { ButtonHTMLAttributes } from 'react' import { Link } from 'react-router-dom' -import { SpinnerIcon } from '@/icons' +import { Spinner } from '@/components/Spinner' import { cn } from '@/utils/cn' type Variant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'danger-ghost' type Size = 'sm' | 'md' | 'lg' | 'icon' -// `aria-label` is declared here rather than left to the DOM props because the link form takes no -// DOM props, and an icon-only button has no text to read. interface BaseProps { 'aria-label'?: string className?: string @@ -15,8 +13,6 @@ interface BaseProps { variant?: Variant } -// `pending` owns the whole in-flight look, because every dialog that had it hand-rolled drifted: -// two rendered a spinner beside the word and the third rendered the word alone. interface ButtonAsButton extends BaseProps, ButtonHTMLAttributes { asLink?: false pending?: boolean @@ -34,24 +30,23 @@ const sizes: Record = { sm: 'h-9 px-4 text-sm', md: 'h-11 px-6 text-[0.95rem]', lg: 'h-12 px-7 text-base', - icon: 'size-9', + icon: 'size-8', } -// Primary carries the Aurora accent: brand gradient + glow on hover. const variants: Record = { primary: 'relative isolate overflow-hidden border border-primary bg-primary text-primary-fg ' + 'before:absolute before:inset-0 before:-z-10 before:bg-[image:var(--gradient-brand)] ' + - 'before:opacity-0 before:transition-opacity enabled:hover:border-transparent ' + - 'enabled:hover:shadow-[var(--glow)] enabled:hover:before:opacity-100', + 'before:opacity-0 before:transition-opacity not-disabled:hover:border-transparent ' + + 'not-disabled:hover:shadow-[var(--glow)] not-disabled:hover:before:opacity-100', secondary: - 'border border-border-strong bg-surface text-fg enabled:hover:border-primary enabled:hover:text-primary-strong', - ghost: 'border border-transparent text-fg-muted enabled:hover:bg-muted enabled:hover:text-fg', - danger: 'border border-danger bg-danger text-white enabled:hover:bg-danger/90', - 'danger-ghost': 'border border-transparent text-danger enabled:hover:bg-danger-soft', + 'border border-border-strong bg-surface text-fg not-disabled:hover:border-primary not-disabled:hover:text-primary-strong', + ghost: + 'border border-transparent text-fg-muted not-disabled:hover:bg-muted not-disabled:hover:text-fg', + danger: 'border border-danger bg-danger text-white not-disabled:hover:bg-danger/90', + 'danger-ghost': 'border border-transparent text-danger not-disabled:hover:bg-danger-soft', } -// Exported for the kit's own buttons, which cannot render this component but can take its classes. export const buttonClass = (variant: Variant, size: Size, className?: string): string => cn( 'inline-flex items-center justify-center gap-2 rounded-[8px] font-semibold transition-colors', @@ -96,7 +91,7 @@ export const Button = (props: ButtonProps): React.JSX.Element => { > {pending ? ( <> - + Submitting… ) : ( diff --git a/dapp/frontend/src/components/Claim.tsx b/dapp/frontend/src/components/Claim.tsx index 79f1f86c..1f8fa0da 100644 --- a/dapp/frontend/src/components/Claim.tsx +++ b/dapp/frontend/src/components/Claim.tsx @@ -6,7 +6,7 @@ import { Modal } from '@/components/Modal' import { isPositive } from '@/utils/amount' import { AMOUNT_ERROR_TEXT } from '@/utils/amountErrorText' import { errorText } from '@/utils/errorText' -import { formatTokenFull } from '@/utils/format' +import { formatFigureFull } from '@/utils/format' import { MIN_GRANT_AMOUNT, meetsRelockFloor } from '@/utils/schedule' import { toast } from '@/utils/toast' import { DBT } from '@/utils/tokens' @@ -56,7 +56,7 @@ export const Claim = ({ await onConfirm(raw) // Exact, not abbreviated: this is the only record of what the ledger took and it carries no // tooltip to recover the digits from. - toast.success(`Claimed ${formatTokenFull(raw)} ${DBT.symbol}`) + toast.success(`Claimed ${formatFigureFull(raw)} ${DBT.symbol}`) onClose() } catch (err) { toast.error(errorText(err)) diff --git a/dapp/frontend/src/components/CompactAmount.tsx b/dapp/frontend/src/components/CompactAmount.tsx index ed5046a4..b865217c 100644 --- a/dapp/frontend/src/components/CompactAmount.tsx +++ b/dapp/frontend/src/components/CompactAmount.tsx @@ -1,9 +1,9 @@ import { InfoTip } from '@/components/InfoTip' -import { formatTokenCompact, formatTokenFull, isCompacted } from '@/utils/format' +import { formatFigureCompact, formatFigureFull, isCompacted } from '@/utils/format' import { DBT } from '@/utils/tokens' -// A token figure, abbreviated past 10,000 with the exact one still reachable. Carries no unit -// and no mark, so it suits a line that already spells out DBT; `AmountDisplay` wraps it for the rest. +// A token figure, abbreviated past 10,000 with the exact one still reachable. Carries no unit and +// no mark, so it suits a line that already spells out DBT; `AmountDisplay` wraps it for the rest. // `plain` drops the tooltip for a caller that cannot legally nest its trigger, which is a button: // the exact figure then reaches a reader by ear only, and its unit comes from the caller's own text. export const CompactAmount = ({ @@ -13,11 +13,11 @@ export const CompactAmount = ({ plain?: boolean value: string }): React.JSX.Element => { - const figure = formatTokenCompact(value) + const figure = formatFigureCompact(value) if (!isCompacted(value)) { return <>{figure} } - const exact = formatTokenFull(value) + const exact = formatFigureFull(value) if (plain) { return ( <> diff --git a/dapp/frontend/src/components/ConnectFace.tsx b/dapp/frontend/src/components/ConnectFace.tsx new file mode 100644 index 00000000..7818b136 --- /dev/null +++ b/dapp/frontend/src/components/ConnectFace.tsx @@ -0,0 +1,31 @@ +import { useConnect } from '@bootnodedev/canton-connect' +import { CancelButton, ConnectButton } from '@bootnodedev/canton-dappbooster/connect' +import { useEffect, useRef } from 'react' + +export const ConnectFace = ({ + cancelClassName, + className, +}: { + cancelClassName?: string + className?: string +}): React.JSX.Element => { + const { isPending } = useConnect() + const button = useRef(null) + const previous = useRef(isPending) + + // Only after a real swap: focusing on first paint would move focus nobody asked to move. + useEffect(() => { + const swapped = previous.current !== isPending + previous.current = isPending + + if (swapped && document.activeElement === document.body) { + button.current?.focus() + } + }, [isPending]) + + return isPending ? ( + + ) : ( + + ) +} diff --git a/dapp/frontend/src/components/ConnectPrompt.tsx b/dapp/frontend/src/components/ConnectPrompt.tsx index 44a421cd..b459791b 100644 --- a/dapp/frontend/src/components/ConnectPrompt.tsx +++ b/dapp/frontend/src/components/ConnectPrompt.tsx @@ -1,14 +1,16 @@ -import { ConnectButton } from '@bootnodedev/canton-dappbooster/connect' import { buttonClass } from '@/components/Button' +import { ConnectFace } from '@/components/ConnectFace' import { EmptyState } from '@/components/EmptyState' -// Stands in wherever a page's ledger data would be, so the shell and its ConnectButton stay -// reachable instead of a gate replacing the whole app. Styled as the app's primary button, since -// here it is the call to action rather than the header's quiet chip. export const ConnectPrompt = (): React.JSX.Element => ( } + action={ + + } /> ) diff --git a/dapp/frontend/src/components/CopyButton.tsx b/dapp/frontend/src/components/CopyButton.tsx new file mode 100644 index 00000000..f2deb039 --- /dev/null +++ b/dapp/frontend/src/components/CopyButton.tsx @@ -0,0 +1,39 @@ +import { type CopyOutcome, useCopyToClipboard } from '@bootnodedev/canton-dappbooster' +import { Check, Copy } from 'lucide-react' +import { cn } from '@/utils/cn' + +interface CopyButtonProps extends React.ComponentPropsWithoutRef<'button'> { + label: string + onOutcome?: (outcome: CopyOutcome) => void + size?: number + value: string +} + +export const CopyButton = ({ + className, + label, + onClick, + onOutcome, + size = 16, + value, + ...rest +}: CopyButtonProps): React.JSX.Element => { + const { copy, state } = useCopyToClipboard() + const copied = state === 'copied' + return ( + + ) +} diff --git a/dapp/frontend/src/components/CounterpartyId.tsx b/dapp/frontend/src/components/CounterpartyId.tsx index 2142808c..bb56d7fc 100644 --- a/dapp/frontend/src/components/CounterpartyId.tsx +++ b/dapp/frontend/src/components/CounterpartyId.tsx @@ -1,5 +1,4 @@ import { Identifier } from '@bootnodedev/canton-dappbooster' -import { copyToast } from '@/utils/toast' interface CounterpartyIdProps { incoming: boolean @@ -13,11 +12,8 @@ export const CounterpartyId = ({ party, incoming }: CounterpartyIdProps): React. <> {incoming ? 'from' : 'to'}{' '} diff --git a/dapp/frontend/src/components/CreateGrant/Details.tsx b/dapp/frontend/src/components/CreateGrant/Details.tsx new file mode 100644 index 00000000..093d811f --- /dev/null +++ b/dapp/frontend/src/components/CreateGrant/Details.tsx @@ -0,0 +1,101 @@ +import { type PartyIdError, PartyIdInput, TokenInput } from '@bootnodedev/canton-dappbooster' +import { useState } from 'react' +import { inputClass, labelClass } from '@/components/CreateGrant/fields' +import type { FundingToken } from '@/components/CreateGrant/useFundingToken' +import { FieldError } from '@/components/FieldError' +import { cn } from '@/utils/cn' + +const RECEIVER_MESSAGE: Record = { + 'missing-separator': 'Use a full party id (hint::fingerprint).', + 'invalid-hint': 'The hint before :: cannot be blank or contain spaces.', + 'invalid-fingerprint': 'The fingerprint after :: must be 68 hex characters.', +} + +interface DetailsProps { + amount: string + funding: FundingToken + onAmountChange: (value: string) => void + onReceiverChange: (value: string) => void + onTitleChange: (value: string) => void + receiver: string + receiverIsSelf: boolean + title: string +} + +// What the grant is, who gets it and how much. +export const Details = ({ + amount, + funding, + onAmountChange, + onReceiverChange, + onTitleChange, + receiver, + receiverIsSelf, + title, +}: DetailsProps): React.JSX.Element => { + const [malformed, setMalformed] = useState(undefined) + const receiverMessage = + malformed !== undefined + ? RECEIVER_MESSAGE[malformed] + : receiverIsSelf + ? 'Cannot grant to your own party.' + : undefined + + return ( + <> + + onTitleChange(e.target.value)} + placeholder="What is this grant for?" + className={inputClass} + /> +
+
+ + { + onReceiverChange(value) + setMalformed(error) + }} + placeholder="bob::1220…" + value={receiver} + /> + {receiverMessage !== undefined && ( + + )} +
+
+ +

+ An outstanding grant reserves exactly this amount; the rest of your balance stays + spendable. Cancel the grant from Pending to release it before the receiver accepts. +

+
+
+ + ) +} diff --git a/dapp/frontend/src/components/CreateGrant/Milestones.tsx b/dapp/frontend/src/components/CreateGrant/Milestones.tsx new file mode 100644 index 00000000..e4108389 --- /dev/null +++ b/dapp/frontend/src/components/CreateGrant/Milestones.tsx @@ -0,0 +1,66 @@ +import { NumberInput } from '@ark-ui/react/number-input' +import { Trash2 } from 'lucide-react' +import { atMidnight, dateOf, inputClass, labelClass } from '@/components/CreateGrant/fields' +import { type MilestoneInput, newMilestone } from '@/components/CreateGrant/scheduleForm' +import { now } from '@/utils/clock' +import { cn } from '@/utils/cn' + +interface MilestonesProps { + className?: string + onChange: (list: MilestoneInput[]) => void + value: MilestoneInput[] +} + +// The milestone curve's rows: a date and the cumulative percent vested by it. +export const Milestones = ({ className, onChange, value }: MilestonesProps): React.JSX.Element => { + const setRow = (i: number, patch: Partial): void => + onChange(value.map((m, idx) => (idx === i ? { ...m, ...patch } : m))) + + return ( +
+ Milestones (date · cumulative %) +

Percentages are cumulative and must end at 100%.

+
+ {value.map((m, i) => ( +
+ setRow(i, { date: atMidnight(e.target.value) })} + className={cn(inputClass, 'mt-0 flex-1')} + /> + setRow(i, { pct: details.value })} + value={m.pct} + > + + + +
+ ))} + +
+
+ ) +} diff --git a/dapp/frontend/src/components/CreateGrant/Preview.tsx b/dapp/frontend/src/components/CreateGrant/Preview.tsx new file mode 100644 index 00000000..0d89ba23 --- /dev/null +++ b/dapp/frontend/src/components/CreateGrant/Preview.tsx @@ -0,0 +1,49 @@ +import { Identifier } from '@bootnodedev/canton-dappbooster' +import { AmountDisplay } from '@/components/AmountDisplay' +import { headingClass } from '@/components/CreateGrant/fields' +import { LiveScheduleCurve } from '@/components/CreateGrant/LiveScheduleCurve' +import { type VestingSchedule, validVestingSchedule } from '@/utils/schedule' + +interface PreviewProps { + amount: string + amountValid: boolean + receiver: string + schedule: VestingSchedule +} + +// The last step: what will be signed, with the curve drawn against a live clock. +export const Preview = ({ + amount, + amountValid, + receiver, + schedule, +}: PreviewProps): React.JSX.Element => ( + <> +

Preview

+
+ Total + +
+
+ Receiver + {receiver === '' ? ( + + ) : ( + + )} +
+
+ {validVestingSchedule(schedule) ? ( + + ) : ( +
+ Enter a valid schedule to preview the curve +
+ )} +
+ +) diff --git a/dapp/frontend/src/components/CreateGrant/Schedule.tsx b/dapp/frontend/src/components/CreateGrant/Schedule.tsx new file mode 100644 index 00000000..85c7e0f5 --- /dev/null +++ b/dapp/frontend/src/components/CreateGrant/Schedule.tsx @@ -0,0 +1,109 @@ +import { DateField } from '@/components/CreateGrant/DateField' +import { headingClass, labelClass } from '@/components/CreateGrant/fields' +import { Milestones } from '@/components/CreateGrant/Milestones' +import { + applyDemoPreset, + type CurveKind, + type ScheduleForm, + scheduleFormValid, +} from '@/components/CreateGrant/scheduleForm' +import { InfoTip } from '@/components/InfoTip' +import { Pills } from '@/components/Pills' +import { Select } from '@/components/Select' +import { now } from '@/utils/clock' + +const DEMO_DURATION_HINT = 'Schedule will be compressed into the selected amount of time' + +const PRESETS = [ + { value: 'none', label: 'Real time' }, + { value: '60000', label: '1 min' }, + { value: '120000', label: '2 min' }, + { value: '300000', label: '5 min' }, + { value: '600000', label: '10 min' }, +] + +const CURVES = [ + { value: 'linear', label: 'Linear' }, + { value: 'milestone', label: 'Milestone' }, +] as const satisfies readonly { label: string; value: CurveKind }[] + +interface ScheduleProps { + onChange: (next: ScheduleForm) => void + value: ScheduleForm +} + +// When the grant vests: the curve, its dates, and the demo presets that compress them into minutes. +export const Schedule = ({ onChange, value }: ScheduleProps): React.JSX.Element => { + const edit = (patch: Partial): void => onChange({ ...value, ...patch }) + const { curveKind, cliff, start, end, milestones, demo } = value + + // A curve switch rebuilds the picked preset for the new shape rather than dropping it, so the + // dates below stay compressed and the dropdown keeps saying what they are. + const setCurve = (kind: CurveKind): void => { + const next = { ...value, curveKind: kind } + onChange(demo === null ? next : applyDemoPreset(next, String(demo.durationMs), now())) + } + + return ( + <> +
+

Schedule

+ +
+
+ Demo duration + + setTitle(e.target.value)} - placeholder="What is this grant for?" - className={inputClass} - /> -
-
- - - {receiverMessage !== undefined && ( - - )} -
-
- {/* No `onTokenSelect` on purpose: the kit renders the symbol as a static mark - without it, and a picker over a one-entry list is a control that cannot do - anything. Restore it when a second instrument exists — see architecture.md. */} - {/* The field carries no message for now, so its flag is the app's own error and not - the kit's, which would also flag an amount above the balance. */} - -

- An outstanding grant reserves exactly this amount; the rest of your balance stays - spendable. Cancel the grant from Pending to release it before the receiver accepts. -

-
-
- - )} - - {step === 2 && ( - <> -
-

Schedule

-
- {(['linear', 'milestone'] as const).map((k) => ( - - ))} -
-
- -
- Demo duration - - setMilestone(i, { date: atMidnight(e.target.value) })} - className={cn(inputClass, 'mt-0 flex-1')} - /> - - setMilestone(i, { pct: e.target.value.replace(/[^0-9]/g, '') }) - } - className={cn(inputClass, 'mt-0 w-20 font-mono')} - /> - -
- ))} - -
- - )} - - {!scheduleValid && ( -

- Schedule is invalid. Check that dates ascend, the cliff sits within the schedule, and - milestone percentages strictly increase to 100%. -

- )} - - )} - - {step === 3 && ( - <> -

Preview

-
- Total - -
-
- Receiver - {receiver === '' ? ( - - ) : ( - + + + {step === 1 && } + + + + {/* Mounted on its own step only, so the curve's clock does not tick behind a hidden + panel. */} + {step === LAST_STEP && ( + )} -
- -
- {scheduleValid ? ( - + + +
+ {step > 0 && ( + + + + )} + {step < LAST_STEP ? ( + + + ) : ( -
- Enter a valid schedule to preview the curve -
+ )}
- - {backend === undefined ? ( -
- -
- ) : ( -

- The receiver must accept the grant to activate it. -

- )} - - )} - -
- {step > 1 && ( - - )} - {step < 3 ? ( - - ) : ( - backend !== undefined && ( - - ) - )} -
+ + ) } diff --git a/dapp/frontend/src/components/CreateGrant/scheduleForm.test.ts b/dapp/frontend/src/components/CreateGrant/scheduleForm.test.ts new file mode 100644 index 00000000..d2360c4f --- /dev/null +++ b/dapp/frontend/src/components/CreateGrant/scheduleForm.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest' +import { + applyDemoPreset, + defaultSchedule, + initialScheduleForm, + type ScheduleForm, + shiftSchedule, + toSchedule, +} from '@/components/CreateGrant/scheduleForm' +import { validVestingSchedule } from '@/utils/schedule' + +const PICKED = new Date('2025-01-01T00:00:00.000Z').getTime() +const SUBMITTED = new Date('2025-01-01T00:10:00.000Z').getTime() + +const form = (patch: Partial = {}): ScheduleForm => ({ + curveKind: 'linear', + ...defaultSchedule(new Date(PICKED)), + demo: null, + ...patch, +}) + +describe('initialScheduleForm', () => { + it('opens on the milestone curve with no preset', () => { + const initial = initialScheduleForm(new Date(PICKED)) + + expect(initial.curveKind).toBe('milestone') + expect(initial.demo).toBeNull() + expect(validVestingSchedule(toSchedule(initial))).toBe(true) + }) + + it('spaces the default dates out in calendar months', () => { + const initial = initialScheduleForm(new Date('2025-01-15T00:00:00.000Z')) + + expect(initial.start).toBe('2025-01-15T00:00:00.000Z') + expect(initial.cliff).toBe('2025-04-15T00:00:00.000Z') + expect(initial.end).toBe('2027-01-15T00:00:00.000Z') + }) +}) + +describe('applyDemoPreset', () => { + it('compresses a linear form into the picked window and records the preset', () => { + const next = applyDemoPreset(form(), '120000', PICKED) + + expect(next.demo).toEqual({ anchorMs: PICKED, durationMs: 120_000 }) + expect(next.cliff).toBe('2025-01-01T00:00:00.000Z') + expect(next.start).toBe('2025-01-01T00:00:00.000Z') + expect(next.end).toBe('2025-01-01T00:02:00.000Z') + expect(validVestingSchedule(toSchedule(next))).toBe(true) + }) + + it('anchors the window to the time the preset is picked, not to the form', () => { + const next = applyDemoPreset(form(), '60000', SUBMITTED) + + expect(next.cliff).toBe('2025-01-01T00:10:00.000Z') + expect(next.start).toBe('2025-01-01T00:10:00.000Z') + expect(next.end).toBe('2025-01-01T00:11:00.000Z') + }) + + it('rewrites the milestone rows from the preset points', () => { + const next = applyDemoPreset(form({ curveKind: 'milestone' }), '300000', PICKED) + + expect(next.milestones).toEqual([ + { id: 'd1', date: '2025-01-01T00:01:40.000Z', pct: '34' }, + { id: 'd2', date: '2025-01-01T00:03:20.000Z', pct: '67' }, + { id: 'd3', date: '2025-01-01T00:05:00.000Z', pct: '100' }, + ]) + expect(validVestingSchedule(toSchedule(next))).toBe(true) + }) + + it('drops the preset and restores the real-time default on none', () => { + const demo = applyDemoPreset(form(), '60000', PICKED) + const next = applyDemoPreset(demo, 'none', SUBMITTED) + + expect(next.demo).toBeNull() + expect(next).toEqual({ + curveKind: 'linear', + ...defaultSchedule(new Date(SUBMITTED)), + demo: null, + }) + }) + + it('keeps the curve the form is on when the default is restored', () => { + const next = applyDemoPreset(form({ curveKind: 'milestone' }), 'none', PICKED) + + expect(next.curveKind).toBe('milestone') + }) +}) + +describe('shiftSchedule', () => { + it('re-anchors a demo preset to submit time, keeping the window it was picked with', () => { + const picked = applyDemoPreset(form(), '60000', PICKED) + + const sent = shiftSchedule(toSchedule(picked), SUBMITTED - PICKED) + + expect(sent).toEqual({ + cliff: '2025-01-01T00:10:00.000Z', + curve: { + kind: 'linear', + start: '2025-01-01T00:10:00.000Z', + end: '2025-01-01T00:11:00.000Z', + }, + }) + }) + + it('carries a date edited after the pick, rather than rebuilding over it', () => { + const picked = applyDemoPreset(form({ curveKind: 'milestone' }), '300000', PICKED) + const edited = { + ...picked, + milestones: picked.milestones.map((m, i) => (i === 1 ? { ...m, pct: '80' } : m)), + } + + const sent = shiftSchedule(toSchedule(edited), SUBMITTED - PICKED) + + expect(sent.curve).toEqual({ + kind: 'milestone', + points: [ + { time: '2025-01-01T00:11:40.000Z', fraction: 0.34 }, + { time: '2025-01-01T00:13:20.000Z', fraction: 0.8 }, + { time: '2025-01-01T00:15:00.000Z', fraction: 1 }, + ], + }) + }) +}) diff --git a/dapp/frontend/src/components/CreateGrant/scheduleForm.ts b/dapp/frontend/src/components/CreateGrant/scheduleForm.ts new file mode 100644 index 00000000..e3c5c8df --- /dev/null +++ b/dapp/frontend/src/components/CreateGrant/scheduleForm.ts @@ -0,0 +1,119 @@ +import { randomId } from '@/utils/randomId' +import { toMs, type VestingSchedule, validVestingSchedule } from '@/utils/schedule' + +export type CurveKind = 'linear' | 'milestone' + +export interface MilestoneInput { + date: string + id: string + pct: string +} + +export interface DemoPreset { + anchorMs: number + durationMs: number +} + +export interface ScheduleForm { + cliff: string + curveKind: CurveKind + demo: DemoPreset | null + end: string + milestones: MilestoneInput[] + start: string +} + +const addMonths = (d: Date, m: number): Date => { + const copy = new Date(d) + copy.setMonth(copy.getMonth() + m) + return copy +} + +export const defaultSchedule = (base: Date): Omit => ({ + cliff: addMonths(base, 3).toISOString(), + end: addMonths(base, 24).toISOString(), + milestones: [ + { id: 'm1', date: addMonths(base, 3).toISOString(), pct: '25' }, + { id: 'm2', date: addMonths(base, 9).toISOString(), pct: '60' }, + { id: 'm3', date: addMonths(base, 18).toISOString(), pct: '100' }, + ], + start: base.toISOString(), +}) + +export const initialScheduleForm = (base: Date): ScheduleForm => ({ + curveKind: 'milestone', + ...defaultSchedule(base), + demo: null, +}) + +export const newMilestone = (base: Date): MilestoneInput => ({ + date: addMonths(base, 24).toISOString(), + id: randomId(), + pct: '100', +}) + +// The demo select's value: 'none' restores the real-time default, a duration in ms compresses the +// schedule into that window from the anchor. +export const applyDemoPreset = ( + current: ScheduleForm, + value: string, + anchorMs: number, +): ScheduleForm => { + if (value === 'none') { + return { curveKind: current.curveKind, ...defaultSchedule(new Date(anchorMs)), demo: null } + } + const durationMs = Number(value) + const at = (ms: number): string => new Date(anchorMs + ms).toISOString() + const step = durationMs / 3 + return { + ...current, + cliff: at(0), + ...(current.curveKind === 'linear' + ? { start: at(0), end: at(durationMs) } + : { + milestones: [ + { id: 'd1', date: at(step), pct: '34' }, + { id: 'd2', date: at(step * 2), pct: '67' }, + { id: 'd3', date: at(durationMs), pct: '100' }, + ], + }), + demo: { anchorMs, durationMs }, + } +} + +// A demo window has to start when the grant is created, not when the preset was picked, so the whole +// schedule moves by however long the form stayed open. Shifting rather than rebuilding is what keeps +// a date edited by hand after the pick. +export const shiftSchedule = (schedule: VestingSchedule, byMs: number): VestingSchedule => { + const at = (iso: string): string => new Date(toMs(iso) + byMs).toISOString() + const curve = schedule.curve + return { + cliff: at(schedule.cliff), + curve: + curve.kind === 'linear' + ? { kind: 'linear', start: at(curve.start), end: at(curve.end) } + : { kind: 'milestone', points: curve.points.map((p) => ({ ...p, time: at(p.time) })) }, + } +} + +export const toSchedule = ({ + cliff, + curveKind, + end, + milestones, + start, +}: ScheduleForm): VestingSchedule => { + if (curveKind === 'linear') { + return { cliff, curve: { kind: 'linear', start, end } } + } + return { + cliff, + curve: { + kind: 'milestone', + points: milestones.map((m) => ({ time: m.date, fraction: Number(m.pct) / 100 })), + }, + } +} + +export const scheduleFormValid = (form: ScheduleForm): boolean => + validVestingSchedule(toSchedule(form)) diff --git a/dapp/frontend/src/components/CreateGrant/useFundingToken.ts b/dapp/frontend/src/components/CreateGrant/useFundingToken.ts new file mode 100644 index 00000000..091af62e --- /dev/null +++ b/dapp/frontend/src/components/CreateGrant/useFundingToken.ts @@ -0,0 +1,43 @@ +import { type Token, type TokenMeta, tokenKey, useTokenList } from '@bootnodedev/canton-dappbooster' +import { useEffect, useState } from 'react' +import { useBackend } from '@/providers/Backend' +import { useTokenFigures } from '@/providers/Tokens' +import { DBT } from '@/utils/tokens' + +export interface FundingToken { + balance?: string + balanceState?: 'error' | 'loading' + onTokenSelect: (token: Token) => void + token: TokenMeta +} + +// The row the amount field shows and the balance it is bounded by. +export const useFundingToken = (): FundingToken => { + const [pickedKey, setPickedKey] = useState() + const { byKey, tokens } = useTokenList() + const { instrument } = useBackend() + // The instrument this deployment vests is the default, since it is the one a grant can be funded + // from; a shared participant can carry another admin's under the same id, so both halves match. + const token = + (pickedKey === undefined ? undefined : byKey.get(pickedKey)) ?? + tokens.find( + ({ instrumentId }) => + instrument !== undefined && + instrumentId.admin === instrument.admin && + instrumentId.id === instrument.instrumentId, + ) + const { failed, refresh } = useTokenFigures() + + useEffect(() => { + refresh() + }, [refresh]) + + const balance = failed ? undefined : token?.balance + + return { + balance, + balanceState: failed ? 'error' : balance === undefined ? 'loading' : undefined, + onTokenSelect: ({ instrumentId }) => setPickedKey(tokenKey(instrumentId)), + token: token ?? DBT, + } +} diff --git a/dapp/frontend/src/components/EmptyState.tsx b/dapp/frontend/src/components/EmptyState.tsx index 0eb250c9..daee1633 100644 --- a/dapp/frontend/src/components/EmptyState.tsx +++ b/dapp/frontend/src/components/EmptyState.tsx @@ -1,8 +1,6 @@ import type { ReactNode } from 'react' import { LogoMark } from '@/icons' -// `level` is the rank the surrounding page leaves free: 2 under a PageTitle, 1 where this state -// replaces the page and there is no other heading for it to sit beneath. export const EmptyState = ({ title, description, diff --git a/dapp/frontend/src/components/Footer.tsx b/dapp/frontend/src/components/Footer.tsx new file mode 100644 index 00000000..51245516 --- /dev/null +++ b/dapp/frontend/src/components/Footer.tsx @@ -0,0 +1,78 @@ +import type { ReactNode } from 'react' +import { BootNodeMark, DocsMark, GithubMark, LinkedInMark, TelegramMark, XMark } from '@/icons' +import { cn } from '@/utils/cn' + +const BOOTNODE = 'https://www.bootnode.dev/' + +const socials = [ + { label: 'Telegram', href: 'https://t.me/dAppBooster', Icon: TelegramMark }, + { label: 'GitHub', href: 'https://github.com/BootNodeDev', Icon: GithubMark }, + { label: 'Twitter/X', href: 'https://twitter.com/bootnodedev', Icon: XMark }, + { label: 'LinkedIn', href: 'https://www.linkedin.com/company/bootnode-dev/', Icon: LinkedInMark }, +] + +const resources = [ + { + label: 'GitHub', + href: 'https://github.com/BootNodeDev/canton-dappbooster', + Icon: GithubMark, + }, + { label: 'Docs', href: 'https://docs.dappbooster.cc/', Icon: DocsMark }, +] + +const Outbound = ({ + children, + className, + href, + label, +}: { + children: ReactNode + className?: string + href: string + label?: string +}): React.JSX.Element => ( + + {children} + +) + +export const Footer = (): React.JSX.Element => ( +
+
+ + Built by + + BootNode + + +
+ +
+) diff --git a/dapp/frontend/src/components/GrantLock.tsx b/dapp/frontend/src/components/GrantLock.tsx index b6dd4d98..be504dba 100644 --- a/dapp/frontend/src/components/GrantLock.tsx +++ b/dapp/frontend/src/components/GrantLock.tsx @@ -1,10 +1,9 @@ -import { LockIcon } from '@/icons' +import { Lock } from 'lucide-react' import { cn } from '@/utils/cn' import { standInClass } from '@/utils/standIn' -// Stands in for the claim button while nothing is claimable. export const GrantLock = ({ className }: { className?: string }): React.JSX.Element => ( - Locked + Locked ) diff --git a/dapp/frontend/src/components/InfoTip.tsx b/dapp/frontend/src/components/InfoTip.tsx index a033bbf1..6006df53 100644 --- a/dapp/frontend/src/components/InfoTip.tsx +++ b/dapp/frontend/src/components/InfoTip.tsx @@ -1,10 +1,12 @@ -import type { ReactNode } from 'react' -import { useId } from 'react' +import { Tooltip, useTooltip } from '@ark-ui/react/tooltip' +import type { PointerEvent, ReactNode } from 'react' import { cn } from '@/utils/cn' -// Wraps the words the tooltip explains, dash-underlined; with no children it is a `?` badge for -// where there is no phrase to underline. Described-by rather than labelled, so the trigger keeps -// its own reading and the note comes after it. +const badgeClass = + 'relative size-4 rounded-full border border-border text-[0.6rem] font-bold text-fg-muted before:absolute before:left-1/2 before:top-1/2 before:size-6 before:-translate-x-1/2 before:-translate-y-1/2 before:content-[""]' + +const wordsClass = 'underline decoration-fg-soft decoration-dashed underline-offset-4' + export const InfoTip = ({ label, children, @@ -14,46 +16,51 @@ export const InfoTip = ({ className?: string label: string }): React.JSX.Element => { - const id = useId() - // An element child must carry its own accessible name, because the trigger then stays out of the - // tab order: a focusable one lands every amount in it, and a dialog would autofocus the first. const wordy = children === undefined || typeof children === 'string' - // Only a worded trigger can point at the bubble: a badge has no reading to describe and takes the - // note as its own name, and an element child carries no describedby at all. Unreferenced, the - // bubble is decoration, and left exposed it would be read a second time as loose text. const described = typeof children === 'string' + const tooltip = useTooltip({ + 'aria-label': described ? undefined : label, + closeOnClick: false, + positioning: { placement: 'top', strategy: 'fixed' }, + }) + const toggleOnTouch = (event: PointerEvent): void => { + if (event.pointerType === 'touch') { + tooltip.setOpen(!tooltip.open) + } + } return ( - + {wordy ? ( - + ) : ( - {children} + + {children} + )} - - {label} - - - + + + + + + + + + + + + {label} + + + + + ) } diff --git a/dapp/frontend/src/components/Loading.tsx b/dapp/frontend/src/components/Loading.tsx index add622bd..73b55351 100644 --- a/dapp/frontend/src/components/Loading.tsx +++ b/dapp/frontend/src/components/Loading.tsx @@ -1,13 +1,11 @@ -import { SpinnerIcon } from '@/icons' +import { Spinner } from '@/components/Spinner' -// Only for a first read, where there is nothing on screen yet: a refresh after a write keeps the -// stale figures rather than replacing the page with this. export const Loading = (): React.JSX.Element => (
- + Loading
) diff --git a/dapp/frontend/src/components/Modal.tsx b/dapp/frontend/src/components/Modal.tsx index 8ce8d3bb..f2c85232 100644 --- a/dapp/frontend/src/components/Modal.tsx +++ b/dapp/frontend/src/components/Modal.tsx @@ -1,6 +1,9 @@ -import { type ReactNode, useEffect, useId, useRef } from 'react' +import { Dialog } from '@ark-ui/react/dialog' +import { Portal } from '@ark-ui/react/portal' +import { X } from 'lucide-react' +import type { ReactNode } from 'react' import { cn } from '@/utils/cn' -import { setTopLayerHost } from '@/utils/topLayer' +import { toastRegion } from '@/utils/toast' interface ModalProps { children: ReactNode @@ -10,85 +13,49 @@ interface ModalProps { title: string } -// Centered dialog over a native ``: the top layer carries the scrim, the focus trap, the -// focus restore, the inert background and Escape, so none of it is reimplemented here. Mounting is -// what opens it, so every caller renders it behind its own condition and there is no `open` prop. export const Modal = ({ onClose, title, description, children, className, -}: ModalProps): React.JSX.Element => { - const dialogRef = useRef(null) - const titleId = useId() - const descId = useId() - - useEffect(() => { - const dialog = dialogRef.current - if (dialog === null) { - return - } - // Only showModal() reaches the top layer; the `open` attribute renders the dialog inline with - // none of the modal behaviour. Guarded because StrictMode re-runs the effect on mount and a - // second call on an open dialog throws. - if (!dialog.open) { - dialog.showModal() - } - // The top layer inerts the page behind but does not stop it scrolling. - const previousOverflow = document.body.style.overflow - document.body.style.overflow = 'hidden' - setTopLayerHost(dialog) - return () => { - document.body.style.overflow = previousOverflow - setTopLayerHost(null) - } - }, []) - - return ( - { - event.preventDefault() +}: ModalProps): React.JSX.Element => ( + { + if (!details.open) { onClose() - }} - // Dismissed on press, not click: a text selection released outside the panel reports the - // dialog as its click target and would close it. - onMouseDown={(event) => { - if (event.target === event.currentTarget) { - onClose() - } - }} - className="fixed inset-0 h-full max-h-none w-full max-w-none place-items-center bg-transparent p-4 backdrop:bg-[var(--scrim)] backdrop:backdrop-blur-sm open:grid" - > -
-

- {title} -

- {description !== undefined && ( -

- {description} -

- )} -
{children}
- -
-
- ) -} + + {title} + + {description !== undefined && ( + + {description} + + )} +
{children}
+ + + + + + + +) diff --git a/dapp/frontend/src/components/Pills.tsx b/dapp/frontend/src/components/Pills.tsx new file mode 100644 index 00000000..ec3f5760 --- /dev/null +++ b/dapp/frontend/src/components/Pills.tsx @@ -0,0 +1,45 @@ +import { ToggleGroup } from '@ark-ui/react/toggle-group' + +type Variant = 'outline' | 'segmented' + +interface PillsProps { + label: string + onChange: (value: T) => void + options: readonly { label: string; value: T }[] + value: T + variant?: Variant +} + +const rootClass: Record = { + outline: 'flex flex-wrap items-center gap-2', + segmented: 'inline-flex rounded-lg border border-border bg-surface p-1', +} + +const itemClass: Record = { + outline: + 'rounded-full border border-border px-3 py-1.5 text-xs font-bold text-fg-muted transition-colors hover:text-fg data-[state=on]:border-primary data-[state=on]:bg-primary-soft data-[state=on]:text-fg', + segmented: + 'rounded-md px-3 py-1 text-xs font-bold text-fg-muted transition-colors hover:text-fg data-[state=on]:bg-primary-soft data-[state=on]:text-fg', +} + +export const Pills = ({ + label, + onChange, + options, + value, + variant = 'outline', +}: PillsProps): React.JSX.Element => ( + onChange(details.value[0] as T)} + value={[value]} + > + {options.map((option) => ( + + {option.label} + + ))} + +) diff --git a/dapp/frontend/src/components/RoleSelect.tsx b/dapp/frontend/src/components/RoleSelect.tsx index 68d2a043..d94b5032 100644 --- a/dapp/frontend/src/components/RoleSelect.tsx +++ b/dapp/frontend/src/components/RoleSelect.tsx @@ -1,17 +1,15 @@ -import type { FocusEvent, KeyboardEvent } from 'react' -import { useRef, useState } from 'react' -import { CaretDownIcon } from '@/icons' +import { Menu } from '@ark-ui/react/menu' +import { Portal } from '@ark-ui/react/portal' +import { ChevronDown } from 'lucide-react' import type { Role } from '@/store/types' import { cn } from '@/utils/cn' +import { popoverClass, popoverItemClass } from '@/utils/popover' const roles: { label: string; value: Role }[] = [ { value: 'receiver', label: 'Received' }, { value: 'funder', label: 'Created' }, ] -// The connected party is fixed; this lens chooses whether to view grants where the party is -// receiver or creator. Not a ` onChange(e.target.value as T)} - className={cn( - 'rounded-[8px] border border-border bg-surface py-1.5 pl-3 pr-9 text-xs font-semibold text-fg', - className, - )} - > - {options.map((o) => ( - - ))} - -) +}: SelectProps): React.JSX.Element => { + const collection = useMemo(() => createListCollection({ items: [...options] }), [options]) + + return ( + onChange(details.value[0] as T)} + positioning={{ sameWidth: true, strategy: 'fixed' }} + value={[value]} + > + {label} + + + + + + + {options.map((option) => ( + + {option.label} + + ))} + + + + ) +} diff --git a/dapp/frontend/src/components/Spinner.tsx b/dapp/frontend/src/components/Spinner.tsx new file mode 100644 index 00000000..178d6664 --- /dev/null +++ b/dapp/frontend/src/components/Spinner.tsx @@ -0,0 +1,5 @@ +import { LoaderCircle } from 'lucide-react' + +export const Spinner = ({ size }: { size?: number }): React.JSX.Element => ( + +) diff --git a/dapp/frontend/src/components/Toaster/ToastRow.tsx b/dapp/frontend/src/components/Toaster/ToastRow.tsx index 37439cf1..e437ecb3 100644 --- a/dapp/frontend/src/components/Toaster/ToastRow.tsx +++ b/dapp/frontend/src/components/Toaster/ToastRow.tsx @@ -1,8 +1,9 @@ -import { useCopyToClipboard } from '@bootnodedev/canton-dappbooster' +import { Toast, type ToastOptions, useToastContext } from '@ark-ui/react/toast' +import { Check, X } from 'lucide-react' import { Link } from 'react-router-dom' -import { CheckIcon, CopyIcon } from '@/icons' +import { CopyButton } from '@/components/CopyButton' import { cn } from '@/utils/cn' -import { type ToastItem, type ToastTone, useToastStore } from '@/utils/toast' +import { readToast, type ToastTone } from '@/utils/toast' const toneStyles: Record = { success: 'border-success/40 text-success', @@ -10,55 +11,39 @@ const toneStyles: Record = { info: 'border-accent/40 text-accent-strong', } -export const ToastRow = ({ item }: { item: ToastItem }): React.JSX.Element => { - const dismiss = useToastStore((s) => s.dismiss) - const { copy, state } = useCopyToClipboard() - // A ledger rejection arrives as a wall of text, so only that tone is worth scrolling and copying. - const isError = item.tone === 'error' +export const ToastRow = ({ toast }: { toast: ToastOptions }): React.JSX.Element => { + const { dismiss } = useToastContext() + const { action, message, tone } = readToast(toast) + const isError = tone === 'error' + return ( -
- {item.tone === 'success' && } + {tone === 'success' && }
-

{item.message}

- {item.action !== undefined && ( + + {message} + + {action !== undefined && ( dismiss(item.id)} + to={action.to} + onClick={dismiss} className="mt-1 block text-xs font-bold text-primary-strong hover:underline" > - {item.action.label} + {action.label} )}
- {isError && ( - - )} - + {isError && } + + +
-
+ ) } diff --git a/dapp/frontend/src/components/Toaster/index.tsx b/dapp/frontend/src/components/Toaster/index.tsx index 1fc22303..fedd6643 100644 --- a/dapp/frontend/src/components/Toaster/index.tsx +++ b/dapp/frontend/src/components/Toaster/index.tsx @@ -1,24 +1,10 @@ -import { createPortal } from 'react-dom' +import { Portal } from '@ark-ui/react/portal' +import { Toaster as ArkToaster } from '@ark-ui/react/toast' import { ToastRow } from '@/components/Toaster/ToastRow' -import { useToastStore } from '@/utils/toast' -import { useTopLayerHost } from '@/utils/topLayer' +import { toaster } from '@/utils/toast' -export const Toaster = (): React.JSX.Element => { - const toasts = useToastStore((s) => s.toasts) - const host = useTopLayerHost() - const viewport = ( -
- {toasts.map((item) => ( - - ))} -
- ) - // An open modal dialog inerts the rest of the document, so a toast raised over one (every failed - // submit) is only dismissable and only announced from inside it. - return host === null ? viewport : createPortal(viewport, host) -} +export const Toaster = (): React.JSX.Element => ( + + {(toast) => } + +) diff --git a/dapp/frontend/src/components/TopBar/AccountMenu.tsx b/dapp/frontend/src/components/TopBar/AccountMenu.tsx new file mode 100644 index 00000000..72f26eb9 --- /dev/null +++ b/dapp/frontend/src/components/TopBar/AccountMenu.tsx @@ -0,0 +1,124 @@ +import { Menu } from '@ark-ui/react/menu' +import { Portal } from '@ark-ui/react/portal' +import { truncateIdentifier } from '@bootnodedev/canton-dappbooster' +import { DisconnectButton } from '@bootnodedev/canton-dappbooster/connect' +import { ChevronDown, Droplet, Power } from 'lucide-react' +import { TAP_AMOUNT } from '@/backend/commands' +import type { VestingBackend } from '@/backend/VestingBackend' +import { CopyButton } from '@/components/CopyButton' +import { PartyAvatar } from '@/components/TopBar/PartyAvatar' +import type { PartyRef } from '@/hooks/useParty' +import { useBackend } from '@/providers/Backend' +import { cn } from '@/utils/cn' +import { errorText } from '@/utils/errorText' +import { popoverClass } from '@/utils/popover' +import { toast } from '@/utils/toast' +import { DBT } from '@/utils/tokens' + +const TRUNCATE = { head: 6, hint: 12, tail: 6 } + +const FINGERPRINT_TRUNCATE = { head: 10, tail: 10 } + +const fingerprintOf = (partyId: string): string => + partyId.includes('::') ? partyId.slice(partyId.indexOf('::') + 2) : partyId + +const triggerClass = + 'inline-flex h-11 items-center gap-2 rounded-[10px] border border-border bg-surface px-3 text-sm font-semibold text-fg transition-colors focus-visible:outline-none focus-visible:shadow-[var(--ring)]' + +const ruleClass = '-mx-4 border-border' + +interface AccountMenuProps { + party: PartyRef +} + +export const AccountMenu = ({ party }: AccountMenuProps): React.JSX.Element => { + const { backend } = useBackend() + + const runTap = (ledger: VestingBackend): void => { + toast.info(`Tapping ${TAP_AMOUNT} ${DBT.symbol}…`) + ledger.tap({ amount: TAP_AMOUNT, party: party.partyId }).then( + () => toast.success(`${TAP_AMOUNT} ${DBT.symbol} tapped`), + (err: unknown) => toast.error(errorText(err)), + ) + } + + return ( + + + + {truncateIdentifier(party.partyId, TRUNCATE)} + + + + + + + +
+ + {party.name} + +

+ + {truncateIdentifier(fingerprintOf(party.partyId), FINGERPRINT_TRUNCATE)} + + + { + if (!outcome.ok) { + toast.error('Could not copy party id') + } + }} + size={14} + value={party.partyId} + /> + +

+
+
+ + {backend !== undefined && ( + <> + runTap(backend)} + value="tap" + > + + + + + Tap {DBT.name} + + Get {TAP_AMOUNT} {DBT.symbol} from the faucet + + + + + + )} +
+

+ + Connected to + {party.networkId} +

+ + + + + +
+
+
+
+
+ ) +} diff --git a/dapp/frontend/src/components/TopBar/PartyAvatar.tsx b/dapp/frontend/src/components/TopBar/PartyAvatar.tsx index fbcee905..8d9e5c2d 100644 --- a/dapp/frontend/src/components/TopBar/PartyAvatar.tsx +++ b/dapp/frontend/src/components/TopBar/PartyAvatar.tsx @@ -1,6 +1,6 @@ import Avatar from 'boring-avatars' -const SIZE_PX = 24 +const DEFAULT_SIZE_PX = 24 const hslToHex = (h: number, s: number, l: number): string => { const c = (1 - Math.abs(2 * l - 1)) * s @@ -28,11 +28,20 @@ const AVATAR_COLORS = Array.from({ length: 256 }, (_value, i) => hslToHex((i * 137.508) % 360, 0.62 + (i % 3) * 0.08, 0.52 + (i % 2) * 0.08), ) -export const PartyAvatar = ({ partyId }: { partyId: string }): React.JSX.Element => ( +export const PartyAvatar = ({ + partyId, + size = DEFAULT_SIZE_PX, +}: { + partyId: string + size?: number +}): React.JSX.Element => ( ) diff --git a/dapp/frontend/src/components/TopBar/ThemeToggle.tsx b/dapp/frontend/src/components/TopBar/ThemeToggle.tsx index 27c5ee5f..25146739 100644 --- a/dapp/frontend/src/components/TopBar/ThemeToggle.tsx +++ b/dapp/frontend/src/components/TopBar/ThemeToggle.tsx @@ -1,5 +1,5 @@ import { useTheme } from '@bootnodedev/canton-dappbooster' -import { MoonIcon, SunIcon } from '@/icons' +import { Moon, Sun } from 'lucide-react' export const ThemeToggle = (): React.JSX.Element => { const { resolved, toggle } = useTheme() @@ -12,11 +12,7 @@ export const ThemeToggle = (): React.JSX.Element => { title={`Switch to ${next} theme`} className="inline-grid size-9 place-items-center rounded-full border border-border bg-surface text-fg-muted transition-colors hover:border-primary hover:text-primary-strong" > - {resolved === 'dark' ? ( - - ) : ( - - )} + {resolved === 'dark' ? : } ) } diff --git a/dapp/frontend/src/components/TopBar/index.tsx b/dapp/frontend/src/components/TopBar/index.tsx index 93edc21d..d0bdf511 100644 --- a/dapp/frontend/src/components/TopBar/index.tsx +++ b/dapp/frontend/src/components/TopBar/index.tsx @@ -1,10 +1,10 @@ -import { ConnectButton } from '@bootnodedev/canton-dappbooster/connect' import { NavLink, type NavLinkRenderProps } from 'react-router-dom' +import { ConnectFace } from '@/components/ConnectFace' +import { Spinner } from '@/components/Spinner' +import { AccountMenu } from '@/components/TopBar/AccountMenu' import { Logo } from '@/components/TopBar/Logo' -import { PartyAvatar } from '@/components/TopBar/PartyAvatar' import { ThemeToggle } from '@/components/TopBar/ThemeToggle' import { useParty } from '@/hooks/useParty' -import { SpinnerIcon } from '@/icons' import { useBackend } from '@/providers/Backend' import { useVestingStore } from '@/store/useVestingStore' import { cn } from '@/utils/cn' @@ -17,6 +17,7 @@ const items = [ export const TopBar = (): React.JSX.Element => { const { party } = useParty() const { sessionPending } = useBackend() + const wallet = party !== undefined ? : const pendingGrants = useVestingStore((s) => s.pendingGrants) const incoming = party === undefined ? 0 : pendingGrants.filter((p) => p.receiver === party.partyId).length @@ -33,18 +34,15 @@ export const TopBar = (): React.JSX.Element => { role="status" className="inline-flex size-9 items-center justify-center text-fg-muted" > - + Restoring wallet session
) : ( - } /> + wallet )}
- {/* Centred over the row above from md, where there is room beside the logo and the wallet - chip; below that it takes a row of its own, since hiding it left Pending reachable - only by typing the URL. */}
+ location.pathname} />
{ > 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} +

+ {/* Only off the wrong network: there the read failed for a reason retrying cannot + change, and the wallet is where the fix is. */} + {!wrongNetwork && ( + + )}
)} {configPending && } @@ -52,10 +79,7 @@ export const AppShell = (): React.JSX.Element => { )}
-
- - Canton · direct ledger -
+