diff --git a/CLAUDE.md b/CLAUDE.md index 46eabc08..80478ae0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,13 +10,12 @@ Each subproject can layer its own `CLAUDE.md` for stack-specific deltas: - [`canton-dappbooster/CLAUDE.md`](canton-dappbooster/CLAUDE.md) — L2 component authoring and file layout - [`canton-theme/CLAUDE.md`](canton-theme/CLAUDE.md) — L3 `--cnc-*` token naming convention - [`canton-barebones/wallet-service/CLAUDE.md`](canton-barebones/wallet-service/CLAUDE.md) — wallet-service bridge rules -- `canton-barebones/`, `dapp/daml/vesting-lite/`, `dapp/frontend/` — see each subproject's `README.md` +- [`dapp/frontend/CLAUDE.md`](dapp/frontend/CLAUDE.md) — app layout and naming deltas; its seams are in [`dapp/frontend/architecture.md`](dapp/frontend/architecture.md) +- `canton-barebones/`, `dapp/daml/vesting-lite/` — see each subproject's `README.md` -`dapp/frontend/` has no `CLAUDE.md`; its seams are in -[`dapp/frontend/architecture.md`](dapp/frontend/architecture.md). - -The Carpincho wallet (CIP-0103 browser wallet) lives in its own repository at -[github.com/BootNodeDev/carpincho-wallet](https://github.com/BootNodeDev/carpincho-wallet); it is no longer part of this monorepo. +The dApp connects through any CIP-0103 browser wallet; no wallet lives in this monorepo. This stack +was developed against Carpincho, which has its own repository at +[github.com/BootNodeDev/carpincho-wallet](https://github.com/BootNodeDev/carpincho-wallet). For the system shape (data flow, components, ports), see [`architecture.md`](architecture.md). @@ -41,7 +40,7 @@ Current distribution: | root | yes | shim | yes | yes | Canonical repo rules and cross-component seams. | | `canton-connect/` | yes | shim | yes | yes | Public hook API, the facade's adapter/picker seams, provider event wiring. | | `canton-barebones/wallet-service/` | yes | shim | yes | no | Local bridge rules are useful; README API boundary is enough architecture for now. | -| `dapp/frontend/` | yes | no | no | yes | Canton Coin vesting dApp; root rules suffice for authoring, but its internal seams outgrew the README. Carries a `PROVENANCE.md` recording the vendored source. | +| `dapp/frontend/` | yes | shim | yes | yes | Canton Coin vesting dApp; `CLAUDE.md` carries the page-owns-its-components layout and the naming rules an agent would otherwise get wrong, architecture.md its internal seams. Carries a `PROVENANCE.md` recording the vendored source. | | `dapp/daml/` | yes | no | no | no | Single DAML package (`vesting-lite`) plus its `daml-test` scenarios. Carries a `PROVENANCE.md` recording the vendored source. | | `canton-barebones/` | yes | no | no | no | Docker/Bash local participant wrapper. | | `canton-dappbooster/` | yes | shim | yes | yes | L2 headless components; `CLAUDE.md` carries the folder-per-component layout an agent would otherwise get wrong, architecture.md the authoring seam (anatomy contract, L2/L3 split, Zag boundary). | @@ -77,7 +76,8 @@ A README may state that a contract exists and link to it. It may not restate it. | Doc reference + gate | typedoc | Root `typedoc.json` over `canton-dappbooster` and `canton-connect`, each declaring its entry points in its own `typedoc.json` and extending `typedoc.shared.json` for every option that resolves per package. `pnpm docs:check` validates without emitting; `pnpm docs:build` writes the site to `typedoc/`. One config for both, strict: every validation on, `treatValidationWarningsAsErrors` and `treatWarningsAsErrors` | | Doc rules gate | `scripts/docs-check.mjs` | `pnpm docs:check` runs it after typedoc. Owns what typedoc cannot see: barrel completeness, `@example` presence and naming by tier, snippet compilation, comment width, tier caps, `@category` values, the `@throws` and anatomy-`@see` requirements, the `@param`/`@returns` refusals, and description presence on exported functions (see the splits below) | | Anatomy parity gate | `scripts/check-anatomy.mjs` | `pnpm check:anatomy` checks every class and `data-*` selector in `canton-theme` against the `anatomy.parts.*` / `anatomy.states.*` strings in `canton-dappbooster`, and requires each anatomy to be reached by at least one selector. Asymmetric on purpose, for the reason its header gives: an unstyled part is a legitimate consumer hook, so there is no per-part check the other way. `aria-*` states are outside it. A styling gate, not a doc one | -| Reference site | Vercel | Project `docs-canton-dappbooster` under the BootNode team, production branch `main`, built by the git integration from `pnpm docs:build`. Build settings live in `vercel.json` | +| Reference site | Vercel | Project `docs.canton-dappbooster` under the BootNode team, production branch `main`, built by the git integration from `pnpm docs:build`. Its root directory is the repo root, so the root `vercel.json` is its build settings and nobody else's | +| Demo deployment | Vercel | Project `demo.canton-dappbooster` under the same team, root directory `dapp/frontend`, so it reads `dapp/frontend/vercel.json`. A project resolves `vercel.json` relative to its own root directory, which is what keeps the two from colliding. `sourceFilesOutsideRootDirectory` is on and the build command runs from the workspace root, because a production build resolves both libraries to their `dist` rather than their source. No git integration yet, so nothing deploys on push | | CI | GitHub Actions | `.github/workflows/pr.yml` gate on every PR (biome, typecheck+build+knip+docs, test, commitlint, gitleaks). `main` is protected: 1 approval + all checks green. `add-to-project` and `pr-assign` automate the board and PR assignee | | Dependency updates | Renovate | `renovate.json`: non-major updates batched weekly, no auto-merge; the `@canton-network/*` SDK graph is held for manual approval on the Dependency Dashboard | @@ -149,8 +149,9 @@ Placement: never rejects a file, so each of its modules is named for what it holds (`partyId.ts`, `cx.ts`), never `helpers.ts` or an `index.ts` barrel. - Components live in `components/`, which is a kind folder like the rest and gets no special case. - Routed pages are the one thing kept apart, in `features/`, because the router enters them rather - than a parent composing them. + Routed pages are the one thing kept apart, in `pages/`, because the router enters them rather + than a parent composing them. A page is a consumer like any other, so what only one page renders + lives beside it. - A component whose job is to supply context rather than render markup lives in `providers/`, named `Provider`, so what wraps the tree is one place to look instead of a hunt through feature folders. @@ -165,8 +166,8 @@ Placement: where a caller could reasonably pick a different export, when to reach for it. Do not restate the type. How much prose, and whether an `@example` is required at all, follows the tier table under Doc blocks below. -- **A module has one legal spelling, and it is never relative.** `./components/toast` and - `@/components/toast` both resolved, so which one landed was down to who or what wrote the file. +- **A module has one legal spelling, and it is never relative.** `./utils/toast` and + `@/utils/toast` both resolved, so which one landed was down to who or what wrote the file. Relative specifiers (`.`, `..`, `./*`, `../*`) are now a Biome error in `dapp/frontend`, `canton-dappbooster`, and `canton-connect`, in all four positions: `import … from`, `export … from`, `export *`, and dynamic `import()`. @@ -334,10 +335,10 @@ package, because only `canton-dappbooster` splits markup from styles across a pa - `pnpm run app:dev` - `node scripts/add-component.mjs ` scaffolds a `canton-dappbooster` component folder. Not wired into `package.json`: it is an authoring convenience, not part of the loop above. -- `node scripts/bootstrap-vesting-lite.mjs` creates the vesting operator and its factory and writes - `dapp/frontend/public/vesting-lite-parties.json`, which the dApp cannot start without. Run it after - the DAR is deployed. It takes the package id from the participant, never a default, because a stale - one shows as an empty dashboard with no error. +- `node scripts/bootstrap-vesting-lite.mjs` creates the vesting operator and its factory, which the + dApp cannot start without. Run it after the DAR is deployed. It writes no file: the dApp reads + both back off the ledger once a wallet connects, so nothing can go stale between the two, and + pointing the wallet at another participant is the whole of switching networks. - Local ports are intentionally assigned in the `3010+` range (see table above). Do not change them without updating every subproject's defaults. - Treat the single root `pnpm-lock.yaml` as authoritative. Do not regenerate it as part of unrelated changes, and do not reintroduce per-package lockfiles. - `pnpm-workspace.yaml` pins `@canton-network/wallet-sdk` and `core-acs-reader` via `overrides`, at the versions wallet-service was verified against. `canton-connect`'s `@canton-network/*` deps (`dapp-sdk`, `core-types`) are not part of these overrides — they live on the ranges in its own `package.json`; bump those directly and test the connect flow, not `pnpm-workspace.yaml`. Both its `core-types` and its `dapp-sdk` devDependencies are pinned exact, not caret: Renovate's `@canton-network/**` hold only blocks version PRs, so a caret let lock file maintenance re-resolve the SDK past the hold (PR #79). The peer ranges stay caret so consumers keep a range, which is why the peer says `^1.4.0` while the pinned dev dependency is `1.5.1`. diff --git a/architecture.md b/architecture.md index 3a39cdc6..bdc04b26 100644 --- a/architecture.md +++ b/architecture.md @@ -5,7 +5,7 @@ | Subproject | Stack | Purpose | | --- | --- | --- | | `canton-barebones/` | Bash + Docker Compose + official Splice LocalNet bundle | Starts `sv + app-user`, health checks, token helper, DAR upload | -| `canton-barebones/wallet-service/` | Node 24 + Express 5 + TypeScript + `@canton-network/wallet-sdk` | Bridge Carpincho uses for external-party onboarding and participant JSON API calls | +| `canton-barebones/wallet-service/` | Node 24 + Express 5 + TypeScript + `@canton-network/wallet-sdk` | Bridge the wallet uses for external-party onboarding and participant JSON API calls | | `dapp/frontend/` | Vite + React + Tailwind v4 + zustand + react-router | Canton Coin **vesting** dApp; every read and write goes through the connected CIP-0103 wallet via `canton-connect` | | `dapp/daml/vesting-lite/` | DAML | `vesting-lite` DAR: the vesting factory, proposal, contract and residual-claim templates | | `canton-connect/` | TypeScript + React 19 | wagmi-style hooks wrapping the dapp-sdk facade | @@ -17,7 +17,7 @@ ```mermaid flowchart TD fe["dapp/frontend
http://localhost:3012"] - wallet["carpincho-wallet (separate repo)
http://localhost:3011"] + wallet["CIP-0103 browser wallet (separate repo)
http://localhost:3011"] ws["wallet-service
http://localhost:3010"] au["Splice app-user
JSON API http://localhost:2975"] sv["Splice sv
DSO / synchronizer side"] @@ -43,30 +43,30 @@ containers still expose app-provider backend ports. State boundaries: -- The CIP-0103 path: a dApp talks to Carpincho through the provider surface, which is how the vesting dApp in `dapp/frontend` gets its session, its ledger reads, and its submissions. -- Carpincho owns user keys and signs locally. +- The CIP-0103 path: a dApp talks to the wallet through the provider surface, which is how the vesting dApp in `dapp/frontend` gets its session, its ledger reads, and its submissions. +- The wallet owns user keys and signs locally. - wallet-service holds `CANTON_BACKEND_TOKEN` and remains the external-party onboarding bridge. - Splice LocalNet owns the app-user participant/validator, Scan, SV, and CC infrastructure. - Splice and wallet-service share the `canton-barebones` Docker Compose project. -- Carpincho should use generated bearer tokens for direct LocalNet endpoints; it should not copy `CANTON_AUTH_SECRET` into the browser. +- The wallet should use generated bearer tokens for direct LocalNet endpoints; it should not copy `CANTON_AUTH_SECRET` into the browser. ## Services And Ports | Service | URL / Port | Purpose | | --- | --- | --- | -| wallet-service | `http://localhost:3010` | Carpincho bridge for onboarding and JSON API calls | -| Carpincho wallet | `http://localhost:3011` | browser wallet UI/provider | +| wallet-service | `http://localhost:3010` | wallet bridge for onboarding and JSON API calls | +| CIP-0103 browser wallet | `http://localhost:3011` | browser wallet UI/provider, run from its own repo | | dApp frontend | `http://localhost:3012` | example dApp | | app-user Wallet UI | `http://wallet.localhost:2000` | optional official Splice wallet UI | | app-user Ledger API | `grpc://localhost:2901` | SDK/tools | | app-user Admin API | `grpc://localhost:2902` | wallet-service/tools | | app-user Validator API | `http://localhost:2903` | health/tools | | app-user JSON API | `http://localhost:2975` | wallet-service/tools | -| app-user Validator proxy | `http://localhost:2000/api/validator` | Carpincho/tools | +| app-user Validator proxy | `http://localhost:2000/api/validator` | wallet/tools | | app-provider backend APIs | `grpc://localhost:3901`, `grpc://localhost:3902`, `http://localhost:3903`, `http://localhost:3975` | official bundle wiring, unused | | app-provider UI port | `http://localhost:3000` | exposed by Nginx, routes disabled | | Scan UI | `http://scan.localhost:4000` | explorer/read model UI | -| Scan API | `http://scan.localhost:4000/api/scan` | Carpincho/tools | +| Scan API | `http://scan.localhost:4000/api/scan` | wallet/tools | | Amulet Registry | `http://localhost:2000/api/validator/v0/scan-proxy` | token metadata | | SV UI | `http://sv.localhost:4000` | Super Validator operations UI | | sv Ledger/Admin/JSON APIs | `grpc://localhost:4901`, `grpc://localhost:4902`, `http://localhost:4975` | Splice internals/tools | @@ -83,7 +83,7 @@ State boundaries: `CANTON_AUTH_AUDIENCE` plus `CANTON_AUTH_SECRET` is the local signing recipe. `CANTON_BACKEND_TOKEN` is the generated token. The token script defaults the -JWT subject to `ledger-api-user`; Carpincho can use a separate token generated +JWT subject to `ledger-api-user`; the wallet can use a separate token generated with the same script, configured manually in its LocalNet settings. ## Orchestration diff --git a/biome.json b/biome.json index 95120643..d7ac3dec 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.5.8/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.10/schema.json", "assist": { "actions": { "source": { "organizeImports": "on" } } }, "vcs": { "clientKind": "git", diff --git a/canton-barebones/.env.example b/canton-barebones/.env.example index c35c110a..9f3fc33b 100644 --- a/canton-barebones/.env.example +++ b/canton-barebones/.env.example @@ -12,7 +12,7 @@ SPLICE_BUNDLE_DIR=${HOME}/.canton-dappbooster/splice-localnet SPLICE_COMPOSE_PROJECT_NAME=canton-barebones # LocalNet dev JWT recipe. These values are used only by scripts/mint-token.mjs. -# Do not copy CANTON_AUTH_SECRET into Carpincho. +# Do not copy CANTON_AUTH_SECRET into the wallet. CANTON_AUTH_AUDIENCE=https://canton.network.global CANTON_AUTH_SECRET=unsafe @@ -24,7 +24,7 @@ CANTON_BACKEND_TOKEN= # Wallet-service. It is started by pnpm run canton:up after Splice LocalNet. WALLET_SERVICE_PORT=3010 -# Only Carpincho's dev server needs listing. The dApp never calls this service, and the unpacked +# Only the wallet's dev server needs listing. The dApp never calls this service, and an unpacked # extension is exempt from CORS through its own host_permissions. WALLET_SERVICE_CORS_ORIGINS=http://localhost:3011 NETWORK=canton:localnet diff --git a/canton-barebones/README.md b/canton-barebones/README.md index 07f6130b..07ad059b 100644 --- a/canton-barebones/README.md +++ b/canton-barebones/README.md @@ -24,8 +24,8 @@ that config in. Splice and wallet-service share the `canton-barebones` Docker Compose project, so Docker groups the full local stack together. -`app-user` is Splice's primary local validator name. It is not the Carpincho -user and not a product user. +`app-user` is Splice's primary local validator name. It is not a wallet user +and not a product user. ## Start @@ -60,7 +60,7 @@ CANTON_AUTH_SECRET It prints a JWT. It does not edit `.env`. Pass a subject as the first argument only if LocalNet expects something other than `ledger-api-user`. -Do not put `CANTON_AUTH_SECRET` in Carpincho. Generate a token and paste only +Do not put `CANTON_AUTH_SECRET` in the wallet. Generate a token and paste only the token. ## Wallet Service @@ -79,7 +79,7 @@ Admin API grpc://host.docker.internal:2902 | Service | What It Is | URL / Port | | --- | --- | --- | -| wallet-service | Carpincho bridge | `http://localhost:3010` | +| wallet-service | wallet bridge | `http://localhost:3010` | | app-user Wallet UI | official Splice wallet UI | `http://wallet.localhost:2000` | | app-user Ledger API | gRPC Ledger API | `grpc://localhost:2901` | | app-user Admin API | gRPC Admin API | `grpc://localhost:2902` | diff --git a/canton-barebones/scripts/health-check.sh b/canton-barebones/scripts/health-check.sh index ae158a38..ef492fbc 100755 --- a/canton-barebones/scripts/health-check.sh +++ b/canton-barebones/scripts/health-check.sh @@ -31,7 +31,7 @@ check_http "Scan UI" "http://scan.localhost:4000" check_http "SV UI" "http://sv.localhost:4000" echo "" -echo "Carpincho bridge" +echo "Wallet bridge" check_http "wallet-service" "http://localhost:${WALLET_SERVICE_PORT:-3010}/health" echo "" diff --git a/canton-barebones/scripts/mint-token.mjs b/canton-barebones/scripts/mint-token.mjs index deab5dfb..37e42886 100755 --- a/canton-barebones/scripts/mint-token.mjs +++ b/canton-barebones/scripts/mint-token.mjs @@ -72,7 +72,7 @@ const main = () => { process.stdout.write(`${token}\n\n`) process.stdout.write('For wallet-service:\n') process.stdout.write(` CANTON_BACKEND_TOKEN=${token}\n\n`) - process.stdout.write('For Carpincho LocalNet settings:\n') + process.stdout.write('For the wallet LocalNet settings:\n') process.stdout.write(' Use this token as the LocalNet bearer token.\n') process.stdout.write( ' You may reuse it for local dev or generate another token with this script.\n', diff --git a/canton-barebones/test/mint-token.test.mjs b/canton-barebones/test/mint-token.test.mjs index d3d0a65b..38668631 100644 --- a/canton-barebones/test/mint-token.test.mjs +++ b/canton-barebones/test/mint-token.test.mjs @@ -16,8 +16,8 @@ const b64urlDecode = (value) => { describe('Canton token generation', () => { it('creates the HS256 JWT Splice LocalNet accepts for local auth', () => { // Scenario: Splice LocalNet services share the unsafe local JWT recipe. - // The generated token is what operators paste into wallet-service or - // Carpincho dev settings, while the signing secret stays in .env. + // The generated token is what operators paste into wallet-service or the + // wallet's dev settings, while the signing secret stays in .env. const token = createCantonToken({ subject: 'ledger-api-user', audience: 'https://canton.network.global', @@ -56,6 +56,6 @@ describe('Canton token generation', () => { assert.match(output, /eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/) assert.match(output, /CANTON_BACKEND_TOKEN=/) - assert.match(output, /Carpincho LocalNet settings/) + assert.match(output, /wallet LocalNet settings/) }) }) diff --git a/canton-barebones/wallet-service/CLAUDE.md b/canton-barebones/wallet-service/CLAUDE.md index 517c897d..ae1bb75b 100644 --- a/canton-barebones/wallet-service/CLAUDE.md +++ b/canton-barebones/wallet-service/CLAUDE.md @@ -4,12 +4,12 @@ This file applies only to `canton-barebones/wallet-service/`. For monorepo-wide ## Scope -The wallet-service is a consumer-dApp-agnostic Express JSON-RPC bridge between Carpincho and the local Canton participant. It holds the Canton bearer token boundary, prepares and executes transactions, proxies participant reads, exposes CIP-56 token-standard reads/transfers and Amulet (Canton Coin) preapproval management and DevNet faucet tap, and handles wallet-internal party onboarding. +The wallet-service is a consumer-dApp-agnostic Express JSON-RPC bridge between a CIP-0103 wallet and the local Canton participant. It holds the Canton bearer token boundary, prepares and executes transactions, proxies participant reads, exposes CIP-56 token-standard reads/transfers and Amulet (Canton Coin) preapproval management and DevNet faucet tap, and handles wallet-internal party onboarding. ## Working Rules - Keep this service agnostic to the *consumer dApp*. Canton-standard logic is in scope: CIP-56 token-standard reads/transfers and Amulet (Canton Coin) preapproval — including the Splice/Amulet template ids those require. What stays out is consumer-dApp-specific routes, template ids, or command logic (e.g. the `vesting-lite` templates). -- Keep the public dApp-facing API in Carpincho. This service exposes only the HTTP bridge Carpincho needs. +- Keep the public dApp-facing API in the wallet. This service exposes only the HTTP bridge the wallet needs. - Keep wallet-internal party onboarding under `/admin/party/*`, not on the `/rpc` dApp surface. - Keep `ledgerApi` as a participant-native pass-through. Do not silently translate request bodies or wrap participant responses. - Keep token handling inside this service boundary. Do not expose `CANTON_BACKEND_TOKEN` to the dApp or wallet UI. diff --git a/canton-barebones/wallet-service/README.md b/canton-barebones/wallet-service/README.md index 9a5ef64b..dee126c6 100644 --- a/canton-barebones/wallet-service/README.md +++ b/canton-barebones/wallet-service/README.md @@ -1,10 +1,10 @@ # Wallet Service -Express JSON-RPC bridge between Carpincho and the Splice LocalNet `app-user` -participant. +Express JSON-RPC bridge between a CIP-0103 wallet and the Splice LocalNet +`app-user` participant. It is intentionally app-agnostic: app-specific Daml commands come from the -consumer, Carpincho owns signing and approval UI, and this service only handles +consumer, the wallet owns signing and approval UI, and this service only handles Canton connectivity, participant reads, prepared transaction execution, and wallet-internal party onboarding. @@ -34,8 +34,8 @@ Paste the printed `CANTON_BACKEND_TOKEN=...` line into ## API Boundary -The public dApp surface is CIP-0103. Carpincho exposes that provider to dApps; -this service exposes only the HTTP JSON-RPC bridge Carpincho needs at: +The public dApp surface is CIP-0103. The wallet exposes that provider to dApps; +this service exposes only the HTTP JSON-RPC bridge the wallet needs at: ```text POST /rpc @@ -49,9 +49,9 @@ Service-specific methods: | Method | Caller | Purpose | | -------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------- | -| `prepareTransaction` | Carpincho | Calls Canton interactive submission prepare and returns the prepared transaction payload/hash for local signing. | -| `executePrepared` | Carpincho | Submits Carpincho's signature over a prepared transaction to Canton. | -| `ledgerApi` | Carpincho on behalf of the dApp | Proxies app-user JSON API reads/writes and injects `CANTON_BACKEND_TOKEN`. | +| `prepareTransaction` | the wallet | Calls Canton interactive submission prepare and returns the prepared transaction payload/hash for local signing. | +| `executePrepared` | the wallet | Submits the wallet's signature over a prepared transaction to Canton. | +| `ledgerApi` | the wallet on behalf of the dApp | Proxies app-user JSON API reads/writes and injects `CANTON_BACKEND_TOKEN`. | ### CIP-56 token methods @@ -70,10 +70,10 @@ These add Canton token-standard reads and transfers plus Amulet (Canton Coin) pr | `amulet.preapproval.acceptProposal` | Accepts a `TransferPreapprovalProposal` for the receiver. | | `amulet.tap` | Prepares the fixed 100 AMT Splice DevNet faucet tap for a receiver (DevNet only). | -The write methods (`create*`, `acceptTransfer`, `amulet.preapproval.create/cancel/acceptProposal`, `amulet.tap`) return prepared transactions; Carpincho signs locally and submits via `executePrepared`. +The write methods (`create*`, `acceptTransfer`, `amulet.preapproval.create/cancel/acceptProposal`, `amulet.tap`) return prepared transactions; the wallet signs locally and submits via `executePrepared`. -`prepareExecute`, `prepareExecuteAndWait`, and `signMessage` stay in -Carpincho because they require the user's key and approval UI. +`prepareExecute`, `prepareExecuteAndWait`, and `signMessage` stay in the +wallet because they require the user's key and approval UI. For `ledgerApi` semantics, read the upstream spec instead of duplicating it: @@ -87,7 +87,7 @@ External party onboarding is wallet/provider operational logic, not generic dApp API. See [CIP-0103 topology-related capabilities](https://github.com/canton-foundation/cips/blob/main/cip-0103/cip-0103.md#topology-related-capabilities). -Carpincho uses these wallet-internal endpoints: +The wallet uses these wallet-internal endpoints: | Endpoint | Purpose | | ---------------------------- | ---------------------------------------------------------------------------------------------------- | diff --git a/canton-barebones/wallet-service/src/rpc.ts b/canton-barebones/wallet-service/src/rpc.ts index 1fe48a24..14b002ed 100644 --- a/canton-barebones/wallet-service/src/rpc.ts +++ b/canton-barebones/wallet-service/src/rpc.ts @@ -279,7 +279,7 @@ export const rpcError = ( const unsupported = (id: JsonRpcId, method: string): JsonRpcError => rpcError(id, -32004, 'Method not supported', { method, - reason: 'This wallet-service has no private keys. Carpincho signs.', + reason: 'This wallet-service has no private keys. The wallet signs.', }) // SDK rejections are plain JsCantonError objects ({ code, cause, ... }), not @@ -401,7 +401,7 @@ const isAmuletInstrument = (instrumentId?: TokenInstrumentId): boolean => instrumentId.id.trim().toLowerCase(), ) -// Creates the same grouping key Carpincho uses for token rows. +// Creates the same grouping key the wallet uses for token rows. const instrumentKey = (instrumentId?: TokenInstrumentId): string => `${instrumentId?.admin ?? 'unknown-admin'}:${instrumentId?.id ?? 'unknown-token'}` @@ -861,7 +861,7 @@ export const createRpc = (config: WalletServiceConfig, deps: RpcDependencies = { return { commands, disclosedContracts } } - // Normalizes SDK preapproval status into a stable JSON-RPC shape for Carpincho. + // Normalizes SDK preapproval status into a stable JSON-RPC shape for the wallet. const amuletPreapprovalStatus = async (params: unknown): Promise => { const p = objectParam>(params, 'amulet.preapproval.status') const receiver = requiredStringParam(p, 'receiver') @@ -882,7 +882,7 @@ export const createRpc = (config: WalletServiceConfig, deps: RpcDependencies = { } } - // Prepares the fixed DevNet tap command so Carpincho can sign as the receiver. + // Prepares the fixed DevNet tap command so the wallet can sign as the receiver. const amuletTap = async ( params: unknown, ): Promise<{ commands: unknown; disclosedContracts: unknown[] }> => { @@ -1136,8 +1136,8 @@ export const createRpc = (config: WalletServiceConfig, deps: RpcDependencies = { const serviceInfo = (): Record => ({ service: 'wallet-service', rpcEndpoint: '/rpc', - api: 'Carpincho service bridge over JSON-RPC 2.0', - dappApi: 'CIP-0103 is exposed by Carpincho over WalletConnect; this service has no signer.', + api: 'Wallet service bridge over JSON-RPC 2.0', + dappApi: 'CIP-0103 is exposed by the wallet over WalletConnect; this service has no signer.', supportedMethods: [ 'status', 'connect', diff --git a/canton-barebones/wallet-service/test/rpc.test.ts b/canton-barebones/wallet-service/test/rpc.test.ts index a01ba37d..96ffa809 100644 --- a/canton-barebones/wallet-service/test/rpc.test.ts +++ b/canton-barebones/wallet-service/test/rpc.test.ts @@ -70,7 +70,7 @@ describe('rpc dispatcher', () => { assert.equal(res.error.code, -32601) }) - it('returns -32004 for prepareExecute (carpincho signs)', async () => { + it('returns -32004 for prepareExecute (the wallet signs)', async () => { const rpc = createRpc(baseConfig()) const res = (await rpc.handle({ jsonrpc: '2.0', @@ -195,7 +195,7 @@ describe('ledgerApi pass-through', () => { describe('CIP-56 token helpers', () => { it('lists pending transfers through the SDK token namespace without reshaping contracts', async () => { // Scenario: wallet-service owns the Node-only wallet-sdk dependency, but - // Carpincho should still see the SDK contract payload directly so future + // the wallet should still see the SDK contract payload directly so future // browser-SDK migration does not need a second DTO translation. const pendingContracts = [ { @@ -253,7 +253,7 @@ describe('CIP-56 token helpers', () => { it('prepares an accept-transfer command through the SDK token namespace', async () => { // Scenario: accepting a pending CIP-56 transfer requires SDK registry - // context, but Carpincho must still sign the prepared transaction itself. + // context, but the wallet must still sign the prepared transaction itself. // wallet-service returns the SDK command and disclosed contracts only. const disclosedContracts = [{ contractId: 'registry-context-cid', createdEventBlob: 'blob' }] const seen: { transferInstructionCid?: string; registryUrl?: string } = {} @@ -289,7 +289,7 @@ describe('CIP-56 token helpers', () => { it('prepares a token transfer command through the SDK token namespace', async () => { // Scenario: sending CIP-56 tokens requires wallet-service to ask the - // Node-only SDK for transfer commands, while Carpincho remains responsible + // Node-only SDK for transfer commands, while the wallet remains responsible // for signing the prepared transaction hash with the sender's local key. const disclosedContracts = [{ contractId: 'transfer-context-cid', createdEventBlob: 'blob' }] const expirationDate = '2026-06-10T15:00:00.000Z' @@ -341,7 +341,7 @@ describe('CIP-56 token helpers', () => { }) it('looks up an Amulet transfer preapproval through the SDK Amulet namespace', async () => { - // Scenario: Carpincho needs to know whether the selected party already + // Scenario: the wallet needs to know whether the selected party already // allows automatic Amulet receipts before rendering the enable/disable UI. const preapproval = { contract: { @@ -466,7 +466,7 @@ describe('CIP-56 token helpers', () => { }) it('prepares a fixed DevNet Amulet tap command for the receiver party', async () => { - // Scenario: Carpincho needs a test-only faucet button that requests a fixed + // Scenario: the wallet needs a test-only faucet button that requests a fixed // 100 AMT for the selected external party while preserving local signing. const disclosedContracts = [{ contractId: 'tap-context-cid', createdEventBlob: 'blob' }] const seen: { receiver?: string; amount?: string } = {} @@ -499,7 +499,7 @@ describe('CIP-56 token helpers', () => { }) it('accepts an Amulet transfer preapproval proposal as the validator provider', async () => { - // Scenario: after Carpincho creates the receiver-signed proposal, the local + // Scenario: after the wallet creates the receiver-signed proposal, the local // validator provider must accept it with a normal participant submit. const seen: { acs?: unknown; submit?: unknown; inputOwner?: string } = {} const rpc = createRpc(withToken(), { @@ -771,7 +771,7 @@ describe('CIP-56 token helpers', () => { }) it('lists token holding UTXOs through the SDK token namespace without reshaping contracts', async () => { - // Scenario: Carpincho needs the active CIP-56 holdings for a party, but the + // Scenario: the wallet needs the active CIP-56 holdings for a party, but the // Node-only wallet SDK must stay behind wallet-service. The RPC returns the // SDK holding contracts unchanged so the browser boundary remains thin. const holdingContracts = [ @@ -1030,7 +1030,7 @@ describe('CIP-56 token helpers', () => { }) it('rejects CIP-56 helper calls without required params', async () => { - // Scenario: malformed Carpincho calls should fail as JSON-RPC invalid + // Scenario: malformed wallet calls should fail as JSON-RPC invalid // params before the SDK is initialized or any Splice service is contacted. const rpc = createRpc(withToken(), { sdkFactory: async () => { diff --git a/canton-barebones/wallet-service/test/smoke.test.ts b/canton-barebones/wallet-service/test/smoke.test.ts index 4f9a8ae9..c62bdf23 100644 --- a/canton-barebones/wallet-service/test/smoke.test.ts +++ b/canton-barebones/wallet-service/test/smoke.test.ts @@ -72,7 +72,7 @@ describe('config loader', () => { }) it('defaults Splice service URLs for token and Amulet SDK helpers', () => { - // Scenario: wallet-service owns SDK helper configuration so Carpincho can + // Scenario: wallet-service owns SDK helper configuration so the wallet can // keep a single wallet-service URL. LocalNet defaults should match the // Splice services exposed by canton-barebones. process.env.CANTON_BACKEND_TOKEN = 'explicit.jwt.value' @@ -88,7 +88,7 @@ describe('config loader', () => { it('allows Splice service URLs to be overridden by environment', () => { // Scenario: non-default LocalNet layouts can move Splice endpoints without - // changing Carpincho runtime config. wallet-service reads these values once + // changing the wallet runtime config. wallet-service reads these values once // at startup and passes them to the SDK namespaces. process.env.CANTON_BACKEND_TOKEN = 'explicit.jwt.value' process.env.SPLICE_VALIDATOR_URL = 'http://validator.example/api/validator' diff --git a/canton-dappbooster/CLAUDE.md b/canton-dappbooster/CLAUDE.md index 7a2b1753..33321312 100644 --- a/canton-dappbooster/CLAUDE.md +++ b/canton-dappbooster/CLAUDE.md @@ -91,7 +91,7 @@ prints the two it will not edit for you, 3 and 5. It decides nothing below; it o from an earlier build is sitting there. - Keep this package app-agnostic: do not import from `dapp/` or `canton-barebones/`. - React 19 only, peer and dev alike. -- `dapp/frontend` keeps its own copy/check icons in `components/icons.tsx`. Leave them: the kit's +- `dapp/frontend` keeps its own copy/check icons in its `src/icons/`. Leave them: the kit's icons are internal, and exporting them is a public-API decision, not a deduplication chore. ## Testing diff --git a/dapp/frontend/.gitignore b/dapp/frontend/.gitignore index fa4ff20c..77c1cef3 100644 --- a/dapp/frontend/.gitignore +++ b/dapp/frontend/.gitignore @@ -49,6 +49,3 @@ lerna-debug.log* .playwright-mcp docs .vercel - -# Generated by scripts/bootstrap-vesting-lite.mjs (deploy-specific party ids + pkg) -public/vesting-lite-parties.json diff --git a/dapp/frontend/AGENTS.md b/dapp/frontend/AGENTS.md new file mode 100644 index 00000000..a7afa29b --- /dev/null +++ b/dapp/frontend/AGENTS.md @@ -0,0 +1,3 @@ +# Agent Configuration +This project's canonical agent configuration lives in [`CLAUDE.md`](./CLAUDE.md). +Agents read `AGENTS.md` natively; load `CLAUDE.md` as the single source of truth. diff --git a/dapp/frontend/CLAUDE.md b/dapp/frontend/CLAUDE.md new file mode 100644 index 00000000..09159d8b --- /dev/null +++ b/dapp/frontend/CLAUDE.md @@ -0,0 +1,56 @@ +# Agent Configuration — dapp/frontend + +This file applies only to `dapp/frontend/`. For monorepo-wide rules, including the filename casing +table and the colocate-then-promote placement rules it layers on, see [`../../CLAUDE.md`](../../CLAUDE.md). +The internal seams are in [`architecture.md`](architecture.md). Deltas for this app only are below. + +## Layout + +- Routed pages live in `pages/`, not `features/`. A page is a folder: `index.tsx` is the route, and + whatever only that page composes sits beside it. +- **One consumer means colocate, and a page counts as a consumer.** `components/` holds what two or + more places use; everything else lives beside the single thing that renders it, so + `GrantCard` is `pages/Dashboard/GrantCard/`, not a flat file two folders away. A second consumer + appearing is what promotes a module to `components/`, and that move is the whole change. +- A component earns a folder when it outgrows one file, never before: entry `index.tsx`, + subcomponents PascalCase beside it, and anything both the entry and a subcomponent need in a + camelCase leaf module (`CreateGrant/fields.ts`). +- A module that is both store and view splits along that seam: the store and its imperative API in + `utils/`, the viewport in `components/` (`utils/toast.ts` and `components/Toaster/`). +- `providers/` names what it provides, not the role the folder already states: `Backend`, not + `BackendProvider`. + +## Shared pieces to reach for + +One implementation each, so a second one is a bug and not a choice: + +- **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. +- **A figure: `components/AmountDisplay`.** It owns the grouping, the forced two decimals, and the + Canton Coin 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 `formatCCCompact` 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. +- **A grant's badges: `components/CurvePill` and `components/GrantStatusPill`;** where a claim cannot + be offered, `components/GrantLock` or `components/GrantClaimed`. Each owns its own wording, tone + and base classes, so a caller passes alignment at most and never re-spells the mapping. +- **A blank state: `components/EmptyState`,** and `components/Loading` while a first read is in + flight. `Loading` is for an empty collection only: a refresh after a write keeps its rows. + `EmptyState` takes the heading rank the page leaves free: the default 2 sits under a `PageTitle`, + and 1 is for a state that replaces the page and has no other heading above it. +- **A brand colour that has to be read: the `-strong` token.** `--primary`, `--accent` and `--pink` + are fill and graphic values, and each falls below 4.5:1 as small text in one theme or the other. + 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. + +## Naming + +- No name repeats what its folder, its parent, or its own markup already says. `Claim`, not + `ClaimDialog`; `pages/Dashboard`, not `DashboardPage`. Where the word is the meaning rather than + the mechanism it stays: `PageTitle` is a page's title wherever it is rendered from. +- Type and interface members are alphabetical, as in `canton-dappbooster`. Blank-line groups inside + a declaration are sorted within the group. diff --git a/dapp/frontend/README.md b/dapp/frontend/README.md index 6eef84c7..81699cef 100644 --- a/dapp/frontend/README.md +++ b/dapp/frontend/README.md @@ -2,7 +2,7 @@ dApp for **Canton Coin vesting**: propose a grant, the beneficiary accepts, claim as it vests, or cancel into a residual claim. Grants render live vested/claimable figures from -the pure schedule math in [`src/lib/schedule.ts`](src/lib/schedule.ts). +the pure schedule math in [`src/utils/schedule.ts`](src/utils/schedule.ts). Every read and every write goes through the connected CIP-0103 wallet, so the app acts as the wallet's primary account and each write raises a real approval prompt. There is no @@ -20,13 +20,14 @@ From the repo root (one `pnpm install` links every workspace): ```bash pnpm run canton:up pnpm run deploy-dar -- canton-barebones/dars/vesting-lite-0.0.1.dar -node scripts/bootstrap-vesting-lite.mjs # writes public/vesting-lite-parties.json +node scripts/bootstrap-vesting-lite.mjs # creates the operator and its factory pnpm run app:dev # → http://localhost:3012 ``` -The bootstrap file is the deployment: it carries the package id and the operator factory's -explicit-disclosure payload, without which a grant cannot be created. It is git-ignored, -so it is per-machine and re-runnable. +The bootstrap writes nothing. It leaves the operator and the factory on the ledger, and the dApp +finds both once a wallet connects: the operator through the rights the bootstrap granted, the +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. 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 diff --git a/dapp/frontend/architecture.md b/dapp/frontend/architecture.md index bc45793f..5431d8f4 100644 --- a/dapp/frontend/architecture.md +++ b/dapp/frontend/architecture.md @@ -13,12 +13,13 @@ interfaces carry that, and every other decision hangs off them. | Path | Role | |------|------| | `src/backend/` | The `VestingBackend` interface, `LiteBackend` (its one implementation), the pure ACS→domain mappers, the command builders, the `WalletFns` seam, and `config.ts`, which loads the deployment. | -| `src/providers/` | `BackendProvider`: 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 `useToken` holds one field's token selection. | +| `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/store/useVestingStore.ts` | Backend-backed zustand store; actions submit then refresh. | -| `src/lib/` | Pure helpers, `schedule.ts` chief among them, plus `env.ts`, the environment contract `vite.config.ts` validates against, `config.ts`, which reads the literals that validation left behind, and `tokens.ts`, the one instrument this deployment knows. | -| `src/components/` | The shell, the top bar and sidebar, and the cards, dialogs, table, and charts they compose. | -| `src/features/` | Dashboard, proposals, create, grant detail. | +| `src/utils/` | Pure helpers, `schedule.ts` chief among them, plus `env.ts`, the environment contract `vite.config.ts` validates against, `config.ts`, which reads the literals that validation left behind, and `tokens.tsx`, the one instrument this deployment knows. 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 inline icon per file over a shared `Svg` wrapper, re-exported from `index.ts`. | +| `src/pages/` | Dashboard, proposals 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. | ## The two seams @@ -36,22 +37,24 @@ which is why they are injected at all: hooks cannot be called from a class, and tests need it constructible without React. Both halves of the pairing are runtime state. The deployment comes from -[`config.ts`](src/backend/config.ts), which reads `/vesting-lite-parties.json` — `pkg`, the factory's -contract id, and its `createdEventBlob` — written into `public/` by -[`scripts/bootstrap-vesting-lite.mjs`](../../scripts/bootstrap-vesting-lite.mjs). Absent or -malformed is a hard error surfaced by `AppShell`, not a fallback: without a package id there is -nothing to query and without the blob there is no factory to disclose. +[`config.ts`](src/backend/config.ts), which reads it off the ledger through that same `ledgerApi`: +the newest `vesting-operator-*` among the connected user's rights, then an active-contracts read as +that operator for the factory, which yields `pkg`, the contract id and the `createdEventBlob`. +Nothing is configured, so nothing can go stale against the participant the wallet is pointed at. +Missing is a hard error surfaced by `AppShell`, not a fallback: without a package id there is +nothing to query and without the blob there is no factory to disclose. It needs a session to read +through, so it resolves after connect rather than before. ## Data flow ``` config.ts ────────────┐ - ├─▶ BackendProvider ──▶ useBackend ──▶ useVestingStore ──▶ components + ├─▶ Backend ──────────▶ useBackend ──▶ useVestingStore ──▶ components CantonConnectProvider ┤ ▲ └─▶ useParty ───────────────────────────────────────────────┘ ``` -`BackendProvider` ([`src/providers/BackendProvider.tsx`](src/providers/BackendProvider.tsx)) is the +`Backend` ([`src/providers/Backend.tsx`](src/providers/Backend.tsx)) is the only place the two seams above meet. Its backend is `undefined` until both a deployment and a wallet *party* exist, because neither half alone can reach the ledger, and a page with no backend renders `ConnectPrompt` where its data would be. The party rather than the connection status is the gate: a @@ -88,17 +91,17 @@ archives for good, and the page navigates away instead. `deriveGrant` in [`src/store/useVestingStore.ts`](src/store/useVestingStore.ts) is a pure projection of a grant at a moment in time — vested, claimable, claimed, status. It and -[`src/lib/schedule.ts`](src/lib/schedule.ts) are the single source of every per-grant figure, and +[`src/utils/schedule.ts`](src/utils/schedule.ts) are the single source of every per-grant figure, and they mirror the on-ledger math deliberately, so a preview and the choice that follows it agree — the contracts recompute `vestedAmount` themselves and reject anything above it. A component that derives a grant's own vesting figures is a bug. `claimAvailable`, beside it, is the same rule for a residual claim, which carries no schedule and so has no projection of its own: what the dashboard shows, sums and submits for one is a single subtraction in a single place. Two components legitimately compute on top of that projection rather than beside it: `MilestoneTimeline` splits a -total across milestone steps for display, and `DashboardPage` sums `deriveGrant`'s output into the +total across milestone steps for display, and `Dashboard` sums `deriveGrant`'s output into the KPI row. Both take the projection as their input; neither re-derives it. -Under both sits [`src/lib/amount.ts`](src/lib/amount.ts), the arithmetic floor. Every add, +Under both sits [`src/utils/amount.ts`](src/utils/amount.ts), the arithmetic floor. Every add, subtract, floor-at-zero, fraction scale, and round in the app goes through it, on scaled `bigint`s, and `schedule.ts` builds on it too. Nothing computes an amount any other way. @@ -116,7 +119,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. [`CreateGrantPage`](src/features/CreateGrantPage.tsx)'s receiver field is +Entry is the other half. [`CreateGrant`](src/components/CreateGrant/index.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. @@ -128,17 +131,30 @@ 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. [`CreateGrantPage`](src/features/CreateGrantPage.tsx)'s -total and [`ClaimDialog`](src/components/ClaimDialog.tsx)'s withdrawal are both the kit's +Amounts run that same split twice more. [`CreateGrant`](src/components/CreateGrant/index.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/lib/amountErrorText.ts`](src/lib/amountErrorText.ts), again an exhaustive `Record` so a +in [`src/utils/amountErrorText.ts`](src/utils/amountErrorText.ts), again an exhaustive `Record` so a code added upstream fails the build here. -Both fields also open the kit's token picker, and the list it shows is one entry: -[`src/lib/tokens.ts`](src/lib/tokens.ts) holds `CC` and nothing else, because that is the only -instrument this deployment knows. The pick is display-only — it relabels the field, and the re-lock -floor's wording, the claim toast, and the grant that gets created are all still Canton Coin. The -picker is wired ahead of a second instrument on purpose. +**Neither field offers the token picker, and that is deliberate.** Both pass `token={CC}` 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 `CC` 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(CC)` 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 coin mark and the grant that gets created all say + Canton Coin in their own right. Each has to take the chosen token instead, or a pick would relabel + one field and silently mean 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 @@ -150,7 +166,7 @@ The division of labour underneath is the part neither side announces. The kit ow one precision: parse, format, sanitize a keystroke, validate against the ledger's own limits and a `max`. It knows nothing about a second amount, so everything that combines two of them is this app's, in -[`src/lib/amount.ts`](src/lib/amount.ts), built on the kit's `parseAmount` / `formatScaled` pair and +[`src/utils/amount.ts`](src/utils/amount.ts), built on the kit's `parseAmount` / `formatScaled` pair and on nothing else of the kit's. So the field's `balance` is the ceiling, while both floors are the app's: the create form's `MIN_GRANT_AMOUNT`, and the claim dialog's re-lock floor, which is a rule about the *remainder* and so about two amounts at once. @@ -171,7 +187,7 @@ or a sentence it uses the pure `truncateIdentifier` / `partyHint` formatters ins element. The explorer those ids link to is the app's to supply: Canton has no canonical one, so the kit -composes URLs only from an `ExplorerConfig`. [`src/lib/config.ts`](src/lib/config.ts) holds that +composes URLs only from an `ExplorerConfig`. [`src/utils/config.ts`](src/utils/config.ts) holds that config as a literal baked in at build time from `VITE_EXPLORER_URL`, not parsed at startup, and the kit's `useExplorerLink` turns it into hrefs. Counterparty ids go through one component: [`src/components/CounterpartyId.tsx`](src/components/CounterpartyId.tsx) binds the from/to prefix, @@ -179,12 +195,15 @@ the direction-specific label, and the copy toast, and `GrantCard` and `ProposalC href stays a per-call-site decision, the way the kit's own `href` is optional: linking an id to an explorer is a choice each surface makes, not something the app does everywhere. Every `` the app renders passes `announce={false}`: the `Toaster` is the app's live region, so the kit's own -would double-announce. +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. 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 `VITE_EXPLORER_URL` fails the build rather than the page load and the client ships no validator at -all. [`src/lib/env.ts`](src/lib/env.ts) holds that contract, and is the only module under `src/` +all. [`src/utils/env.ts`](src/utils/env.ts) holds that contract, and is the only module under `src/` that runs outside the browser. The connect button's copy is the kit's: passed no `children` it renders its own label and swaps it @@ -221,3 +240,10 @@ pointing at a `--cnc-*` token inherits the kit's dark value and so needs no coun by construction, which the brand hues (`--accent`, `--pink`, `--gradient-brand`) are. `--surface-2` and `--muted` resolve to the same grey and stay separate names because components already pick one or the other. + +Those hues are mode-independent because they are fills, and a fill answers to 3:1 while the text +over it answers to 4.5:1. Each one a component also wanted to set text in fails that in one mode or +the other, so `--accent-strong`, `--primary-strong` and `--pink-strong` carry the readable value and +are per-theme wherever the plain hue is not. Which to reach for is in +[`CLAUDE.md`](CLAUDE.md); that they are separate names rather than a darker `--accent` is because +`--primary` also has to keep white legible on `bg-primary`, so one value cannot serve both sides. diff --git a/dapp/frontend/index.html b/dapp/frontend/index.html index 9ef4007c..cd8761d7 100644 --- a/dapp/frontend/index.html +++ b/dapp/frontend/index.html @@ -6,6 +6,8 @@ Canton Vesting + +
diff --git a/dapp/frontend/src/App.tsx b/dapp/frontend/src/App.tsx index 3f6028b7..9eb14691 100644 --- a/dapp/frontend/src/App.tsx +++ b/dapp/frontend/src/App.tsx @@ -1,10 +1,9 @@ import { type CantonConnectConfig, CantonConnectProvider } from '@bootnodedev/canton-connect' import { ThemeProvider, TokenListProvider } from '@bootnodedev/canton-dappbooster' import { createBrowserRouter, RouterProvider } from 'react-router-dom' -import { Toaster } from '@/components/toast' -import { TOKENS } from '@/lib/tokens' -import { BackendProvider } from '@/providers/BackendProvider' +import { Backend } from '@/providers/Backend' import { routes } from '@/routes' +import { TOKENS } from '@/utils/tokens' const router = createBrowserRouter(routes) @@ -15,10 +14,9 @@ export const App = (): React.JSX.Element => ( - + - - + diff --git a/dapp/frontend/src/assets/canton-coin.png b/dapp/frontend/src/assets/canton-coin.png new file mode 100644 index 00000000..254e0d7d Binary files /dev/null and b/dapp/frontend/src/assets/canton-coin.png differ diff --git a/dapp/frontend/src/backend/LiteBackend.test.ts b/dapp/frontend/src/backend/LiteBackend.test.ts index 1902ca3d..c43a0db7 100644 --- a/dapp/frontend/src/backend/LiteBackend.test.ts +++ b/dapp/frontend/src/backend/LiteBackend.test.ts @@ -29,8 +29,8 @@ type PartyFilter = { } type AcsQuery = { - filter?: { filtersByParty?: Record } activeAtOffset?: unknown + filter?: { filtersByParty?: Record } } type Read = { requestMethod: string; resource: string; body?: AcsQuery } @@ -216,9 +216,9 @@ describe('LiteBackend.viewAs', () => { expect(reads[0]?.resource).toBe('/v2/state/ledger-end') const acsReads = reads.slice(1) expect(acsReads.map(filteredTemplate)).toEqual([ - 'pkg1:Vesting:VestingProposal', - 'pkg1:Vesting:VestingContract', - 'pkg1:Vesting:VestedClaim', + '#vesting-lite:Vesting:VestingProposal', + '#vesting-lite:Vesting:VestingContract', + '#vesting-lite:Vesting:VestedClaim', ]) expect(acsReads.map(filteredParty)).toEqual(['receiver::1', 'receiver::1', 'receiver::1']) expect(acsReads.every((read) => read.body?.activeAtOffset === 42)).toBe(true) @@ -227,7 +227,7 @@ describe('LiteBackend.viewAs', () => { it('maps the rows it gets back into the domain view', async () => { const { backend } = harness({ acs: { - 'pkg1:Vesting:VestingContract': [ + '#vesting-lite:Vesting:VestingContract': [ row('c1', { provider: 'operator::1', proposer: 'funder::1', diff --git a/dapp/frontend/src/backend/LiteBackend.ts b/dapp/frontend/src/backend/LiteBackend.ts index 117ab912..d90d4703 100644 --- a/dapp/frontend/src/backend/LiteBackend.ts +++ b/dapp/frontend/src/backend/LiteBackend.ts @@ -11,11 +11,15 @@ import { import type { Deployment } from '@/backend/config' import { type AcsRow, + type ClaimRecord, type CreateVestInput, + claimChain, composeNote, + lastUpdateOffset, rowToClaim, rowToGrant, rowToProposal, + updatesToClaims, type VestingBackend, type VestingView, } from '@/backend/VestingBackend' @@ -24,6 +28,32 @@ import type { DisclosedContract, LedgerCommand, WalletFns } from '@/backend/wall const mapRows = (rows: AcsRow[], mapper: (row: AcsRow) => T | undefined): T[] => rows.map(mapper).filter((value): value is T => value !== undefined) +// The JSON Ledger API's party/template filter, shared by the ACS read and the update stream. Built +// in one place because a typo in this nesting yields a silent empty read rather than an error, and +// because a filter takes the package-name reference where a command takes the resolved package id. +const templateFilter = (party: string, entity: string): Record => ({ + filtersByParty: { + [party]: { + cumulative: [ + { + identifierFilter: { + TemplateFilter: { value: { templateId: `#vesting-lite:Vesting:${entity}` } }, + }, + }, + ], + }, + }, +}) + +// A page of claims, and how long the stream may sit quiet before it returns what it has: the +// endpoint is a stream, so without the idle timeout the read never completes. +const CLAIM_HISTORY_LIMIT = 1000 +const STREAM_IDLE_MS = 1000 +// `limit` counts forward from `beginExclusive` and the endpoint offers no reverse order, so a party +// past one page keeps its oldest claims and loses the recent ones unless the pages are followed. +// Bounded so an offset that fails to advance cannot spin. +const CLAIM_HISTORY_PAGES = 20 + export class LiteBackend implements VestingBackend { private readonly wallet: WalletFns private readonly factory: DisclosedContract @@ -57,22 +87,12 @@ export class LiteBackend implements VestingBackend { return result.offset } - private async readAcs( - party: string, - templateId: string, - offset: string | number, - ): Promise { + private async readAcs(party: string, entity: string, offset: string | number): Promise { const rows = await this.wallet.ledgerApi({ requestMethod: 'post', resource: '/v2/state/active-contracts', body: { - filter: { - filtersByParty: { - [party]: { - cumulative: [{ identifierFilter: { TemplateFilter: { value: { templateId } } } }], - }, - }, - }, + filter: templateFilter(party, entity), activeAtOffset: offset, verbose: true, }, @@ -99,9 +119,9 @@ export class LiteBackend implements VestingBackend { // One ledger-end fetch for all three reads, so they share a consistent snapshot offset. const offset = await this.ledgerEnd() const [proposalRows, contractRows, claimRows] = await Promise.all([ - this.readAcs(partyId, this.proposalTid, offset), - this.readAcs(partyId, this.contractTid, offset), - this.readAcs(partyId, this.claimTid, offset), + this.readAcs(partyId, 'VestingProposal', offset), + this.readAcs(partyId, 'VestingContract', offset), + this.readAcs(partyId, 'VestedClaim', offset), ]) return { proposals: mapRows(proposalRows, rowToProposal), @@ -125,6 +145,53 @@ export class LiteBackend implements VestingBackend { return { disclosedBytes: this.factory.createdEventBlob.length } } + // `TRANSACTION_SHAPE_LEDGER_EFFECTS` is what carries the exercise; the default ACS-delta shape + // would only show the contract being replaced. + private readUpdates( + partyId: string, + beginExclusive: string | number, + endInclusive: string | number, + ): Promise { + return this.wallet.ledgerApi({ + requestMethod: 'post', + resource: '/v2/updates', + query: { limit: CLAIM_HISTORY_LIMIT, stream_idle_timeout_ms: STREAM_IDLE_MS }, + body: { + beginExclusive, + endInclusive, + updateFormat: { + includeTransactions: { + transactionShape: 'TRANSACTION_SHAPE_LEDGER_EFFECTS', + eventFormat: { + verbose: true, + ...templateFilter(partyId, 'VestingContract'), + }, + }, + }, + }, + }) + } + + // The ledger keeps no claim log of its own, so the history is the transaction stream: every + // `Contract_Claim` this party can see, read once rather than followed, since the page asks again + // after each claim. The stream is party-wide, so the one grant's chain is picked out of it here + // and no caller has to know a claim replaces the contract it is claimed from. + async claimHistory(partyId: string, contractCid: string): Promise { + const endInclusive = await this.ledgerEnd() + const records: ClaimRecord[] = [] + let beginExclusive: string | number = 0 + for (let page = 0; page < CLAIM_HISTORY_PAGES; page++) { + const updates = await this.readUpdates(partyId, beginExclusive, endInclusive) + records.push(...updatesToClaims(updates)) + const last = lastUpdateOffset(updates) + if (!Array.isArray(updates) || updates.length < CLAIM_HISTORY_LIMIT || last === undefined) { + break + } + beginExclusive = last + } + return claimChain(records, contractCid) + } + async accept(args: { receiver: string; proposalCid: string }): Promise { await this.submit(args.receiver, buildAcceptCommand(this.proposalTid, args.proposalCid)) } diff --git a/dapp/frontend/src/backend/VestingBackend.test.ts b/dapp/frontend/src/backend/VestingBackend.test.ts index a07ea4a8..f3fbc058 100644 --- a/dapp/frontend/src/backend/VestingBackend.test.ts +++ b/dapp/frontend/src/backend/VestingBackend.test.ts @@ -1,11 +1,14 @@ import { describe, expect, it } from 'vitest' import { encodeSchedule } from '@/backend/commands' import { + claimChain, composeNote, + lastUpdateOffset, rowToClaim, rowToGrant, rowToProposal, splitNote, + updatesToClaims, } from '@/backend/VestingBackend' const linearEncoded = encodeSchedule({ @@ -150,3 +153,112 @@ describe('rowToClaim', () => { }) }) }) + +const claimUpdate = ( + offset: number, + replaces: string, + successor: string, + claimed: string, + amount: string, +) => ({ + update: { + Transaction: { + value: { + effectiveAt: '2026-03-01T00:00:00Z', + offset, + events: [ + { + ExercisedEvent: { + choice: 'Contract_Claim', + choiceArgument: { amount }, + contractId: replaces, + }, + }, + { + CreatedEvent: { + contractId: successor, + createArgument: { + provider: 'OP', + proposer: 'funder', + beneficiary: 'receiver', + total: '1000', + claimed, + schedule: linearEncoded, + note: 'Advisor grant', + }, + }, + }, + ], + }, + }, + }, +}) + +describe('updatesToClaims', () => { + it('carries the id the claim consumed alongside the successor it created', () => { + const [record] = updatesToClaims([claimUpdate(7, 'c1', 'c2', '250', '250')]) + expect(record?.replaces).toBe('c1') + expect(record?.grant.id).toBe('c2') + expect(record?.amount).toBe('250') + }) + + it('drops a transaction with no exercised contract id, which cannot be placed in a chain', () => { + const full = claimUpdate(7, 'c1', 'c2', '250', '250') + const events = full.update.Transaction.value.events + const orphan = { + update: { + Transaction: { + value: { + ...full.update.Transaction.value, + events: [ + { ExercisedEvent: { ...events[0].ExercisedEvent, contractId: undefined } }, + events[1], + ], + }, + }, + }, + } + expect(updatesToClaims([orphan])).toEqual([]) + }) + + it('ignores anything that is not an array of transactions', () => { + expect(updatesToClaims(undefined)).toEqual([]) + expect(updatesToClaims([{}])).toEqual([]) + }) +}) + +describe('claimChain', () => { + const records = updatesToClaims([ + claimUpdate(7, 'c1', 'c2', '250', '250'), + claimUpdate(9, 'c2', 'c3', '500', '250'), + claimUpdate(11, 'other1', 'other2', '10', '10'), + ]) + + it('walks a grant back through the contracts its own claims replaced, newest first', () => { + expect(claimChain(records, 'c3').map((r) => r.grant.id)).toEqual(['c3', 'c2']) + }) + + it('leaves out another grant chain the same party can see', () => { + expect(claimChain(records, 'c3').map((r) => r.replaces)).not.toContain('other1') + }) + + it('is empty for a contract nothing has claimed from yet', () => { + expect(claimChain(records, 'never-claimed')).toEqual([]) + }) +}) + +describe('lastUpdateOffset', () => { + it('reports the offset of the final entry, which is where the next page resumes', () => { + expect( + lastUpdateOffset([ + claimUpdate(7, 'c1', 'c2', '250', '250'), + claimUpdate(9, 'c2', 'c3', '500', '250'), + ]), + ).toBe(9) + }) + + it('is undefined for an empty or non-array page, so paging stops', () => { + expect(lastUpdateOffset([])).toBeUndefined() + expect(lastUpdateOffset(undefined)).toBeUndefined() + }) +}) diff --git a/dapp/frontend/src/backend/VestingBackend.ts b/dapp/frontend/src/backend/VestingBackend.ts index 2b39ec0b..39052b80 100644 --- a/dapp/frontend/src/backend/VestingBackend.ts +++ b/dapp/frontend/src/backend/VestingBackend.ts @@ -2,32 +2,59 @@ // transport details. The mappers below turn active-contract rows into those domain types. import { decodeSchedule } from '@/backend/commands' -import { isAmount } from '@/lib/amount' -import type { VestingSchedule } from '@/lib/schedule' import type { Grant, PartyId, Proposal, VestedClaim } from '@/store/types' +import { isAmount } from '@/utils/amount' +import type { VestingSchedule } from '@/utils/schedule' export interface VestingView { + claims: VestedClaim[] grants: Grant[] proposals: Proposal[] - claims: VestedClaim[] } export interface CreateVestInput { + note?: string proposer: string receiver: string - totalAmount: string schedule: VestingSchedule title: string - note?: string + totalAmount: string +} + +// One `Contract_Claim` off the ledger: the amount and ledger time from the transaction, plus the +// two contract ids it sits between. `replaces` is what the claim consumed and `grant` what it +// created, so a caller can walk a grant's ancestry rather than match on fields two grants can share. +export interface ClaimRecord { + amount: string + at: string + grant: Grant + replaces: string } export interface VestingBackend { - viewAs(partyId: string): Promise - createVesting(args: CreateVestInput): Promise<{ disclosedBytes: number }> accept(args: { receiver: string; proposalCid: string }): Promise - withdraw(args: { receiver: string; contractCid: string; amount: string }): Promise cancel(args: { creator: string; contractCid: string }): Promise + claimHistory(partyId: string, contractCid: string): Promise claimResidual(args: { receiver: string; claimCid: string; amount: string }): Promise + createVesting(args: CreateVestInput): Promise<{ disclosedBytes: number }> + viewAs(partyId: string): Promise + withdraw(args: { receiver: string; contractCid: string; amount: string }): Promise +} + +// A claim consumes the contract and creates its successor, so one grant's history is its ancestry: +// walk back from the id asked about. Matching on the fields instead merges two grants a funder made +// identical, and the walk arrives newest-first, which is the order a caller wants to render. +export const claimChain = (records: ClaimRecord[], contractCid: string): ClaimRecord[] => { + const bySuccessor = new Map(records.map((record) => [record.grant.id, record])) + const chain: ClaimRecord[] = [] + for ( + let record = bySuccessor.get(contractCid); + record !== undefined; + record = bySuccessor.get(record.replaces) + ) { + chain.push(record) + } + return chain } // ── Domain-mapping convention ────────────────────────────────────────────────── @@ -78,12 +105,12 @@ export const splitNote = ( // The fields every template carries alike; each mapper layers its own on top. type DecodedBase = { arg: Record + funder: PartyId id: string - title: string note?: string provider: PartyId - funder: PartyId receiver: PartyId + title: string } const decodeBase = (row: AcsRow): DecodedBase | undefined => { @@ -139,6 +166,64 @@ export const rowToGrant = (row: AcsRow): Grant | undefined => { } } +// A ledger-effects transaction, as `/v2/updates` returns it. Only the two events a claim produces +// are read: the exercise carries the amount, the create carries which grant it left behind. +type UpdateEntry = { + update?: { + Transaction?: { + value?: { + effectiveAt?: string + events?: { + CreatedEvent?: { contractId?: string; createArgument?: Record } + ExercisedEvent?: { + choice?: string + choiceArgument?: Record + contractId?: string + } + }[] + offset?: number + } + } + } +} + +// Where a page of updates ended, which is where the next one resumes. +export const lastUpdateOffset = (updates: unknown): number | undefined => { + const entries = Array.isArray(updates) ? (updates as UpdateEntry[]) : [] + return entries.at(-1)?.update?.Transaction?.value?.offset +} + +export const updatesToClaims = (updates: unknown): ClaimRecord[] => + (Array.isArray(updates) ? (updates as UpdateEntry[]) : []).flatMap((entry) => { + const transaction = entry.update?.Transaction?.value + const events = transaction?.events ?? [] + const claim = events.find( + (event) => event.ExercisedEvent?.choice === 'Contract_Claim', + )?.ExercisedEvent + const created = events.find((event) => event.CreatedEvent !== undefined)?.CreatedEvent + const amount = claim?.choiceArgument?.amount + const replaces = claim?.contractId + if ( + created === undefined || + transaction?.effectiveAt === undefined || + amount === undefined || + replaces === undefined + ) { + return [] + } + const grant = rowToGrant({ contractEntry: { JsActiveContract: { createdEvent: created } } }) + return grant === undefined + ? [] + : [ + { + amount: amountOf(amount, 'amount', grant.id), + at: transaction.effectiveAt, + grant, + replaces, + }, + ] + }) + export const rowToClaim = (row: AcsRow): VestedClaim | undefined => { const base = decodeBase(row) if (base === undefined) { diff --git a/dapp/frontend/src/backend/commands.test.ts b/dapp/frontend/src/backend/commands.test.ts index 54069a66..22445131 100644 --- a/dapp/frontend/src/backend/commands.test.ts +++ b/dapp/frontend/src/backend/commands.test.ts @@ -8,7 +8,7 @@ import { decodeSchedule, encodeSchedule, } from '@/backend/commands' -import type { VestingSchedule } from '@/lib/schedule' +import type { VestingSchedule } from '@/utils/schedule' const linear: VestingSchedule = { cliff: '2026-01-01T00:00:00Z', diff --git a/dapp/frontend/src/backend/commands.ts b/dapp/frontend/src/backend/commands.ts index c7991bd7..04f3bb85 100644 --- a/dapp/frontend/src/backend/commands.ts +++ b/dapp/frontend/src/backend/commands.ts @@ -1,8 +1,8 @@ // JSON-Ledger-API v2 command builders and the one curve encode/decode pair. No I/O, so it is // unit-tested directly in commands.test.ts. -import { canonicalAmount } from '@/lib/amount' -import type { VestingSchedule } from '@/lib/schedule' +import { canonicalAmount } from '@/utils/amount' +import type { VestingSchedule } from '@/utils/schedule' // ── Curve variant encoding ──────────────────────────────────────────────────── // The one place the JSON-LF convention lives, mirrored by decodeSchedule: a variant is @@ -10,10 +10,10 @@ import type { VestingSchedule } from '@/lib/schedule' // string. type EncodedCurve = - | { tag: 'LinearVesting'; value: { start: string; end: string } } + | { tag: 'LinearVesting'; value: { end: string; start: string } } | { tag: 'MilestoneVesting'; value: { points: { _1: string; _2: string }[] } } -export type EncodedSchedule = { curve: EncodedCurve; cliff: string } +export type EncodedSchedule = { cliff: string; curve: EncodedCurve } export const encodeSchedule = (schedule: VestingSchedule): EncodedSchedule => { const curve = schedule.curve @@ -68,11 +68,11 @@ export const decodeSchedule = (raw: unknown): VestingSchedule => { // ── Command builders ──────────────────────────────────────────────────────── type CreateVestingArgs = { - proposer: string beneficiary: string - total: string - schedule: VestingSchedule note?: string + proposer: string + schedule: VestingSchedule + total: string } export const buildCreateVestingCommand = ( diff --git a/dapp/frontend/src/backend/config.test.ts b/dapp/frontend/src/backend/config.test.ts index 91110702..6210b8a7 100644 --- a/dapp/frontend/src/backend/config.test.ts +++ b/dapp/frontend/src/backend/config.test.ts @@ -1,65 +1,100 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { loadBackendConfig } from '@/backend/config' +import { describe, expect, it } from 'vitest' +import { type LedgerApi, loadBackendConfig } from '@/backend/config' -const respond = (init: { ok?: boolean; status?: number; body?: unknown; text?: string }): void => { - vi.stubGlobal( - 'fetch', - vi.fn(async () => ({ - ok: init.ok ?? true, - status: init.status ?? 200, - json: async () => { - if (init.text !== undefined) { - throw new SyntaxError('Unexpected token < in JSON') - } - return init.body - }, - })), - ) +const OPERATOR = 'vesting-operator-1700000000001::ns' +const OLDER = 'vesting-operator-1600000000000::ns' + +const factoryRow = ( + createdEvent: Record, + synchronizerId?: string, +): Record => ({ + contractEntry: { + JsActiveContract: { createdEvent, ...(synchronizerId ? { synchronizerId } : {}) }, + }, +}) + +const created = { + contractId: '00cid', + createdEventBlob: 'YmxvYg==', + templateId: 'abc123:Vesting:VestingFactory', } -const valid = { - pkg: 'abc123', - factoryCid: '00cid', - factoryBlob: 'YmxvYg==', - synchronizerId: 'sync::1', +// The four reads loadBackendConfig makes, keyed by resource so a test overrides only what it is +// about. `acs` doubles as the record of which party the last filter named. +const ledger = ( + overrides: { rights?: unknown[]; acs?: unknown[]; user?: unknown } = {}, +): { ledgerApi: LedgerApi; filteredParty: () => string | undefined } => { + let filteredParty: string | undefined + const ledgerApi: LedgerApi = async (params) => { + const resource = params.resource as string + if (resource === '/v2/authenticated-user') { + return overrides.user ?? { user: { id: 'user-1' } } + } + if (resource.endsWith('/rights')) { + return { + rights: overrides.rights ?? [ + { kind: { CanActAs: { value: { party: OLDER } } } }, + { kind: { CanActAs: { value: { party: OPERATOR } } } }, + { kind: { ParticipantAdmin: { value: {} } } }, + ], + } + } + if (resource === '/v2/state/ledger-end') { + return { offset: 42 } + } + const filter = (params.body as { filter?: { filtersByParty?: Record } }) + ?.filter + filteredParty = Object.keys(filter?.filtersByParty ?? {})[0] + return overrides.acs ?? [factoryRow(created, 'sync::1')] + } + return { ledgerApi, filteredParty: () => filteredParty } } describe('loadBackendConfig', () => { - afterEach(() => { - vi.unstubAllGlobals() + it('returns the deployment it reads back, synchronizer id included', async () => { + const { ledgerApi } = ledger() + await expect(loadBackendConfig(ledgerApi)).resolves.toEqual({ + factoryBlob: 'YmxvYg==', + factoryCid: '00cid', + pkg: 'abc123', + synchronizerId: 'sync::1', + }) }) - it('returns the deployment, synchronizer id included', async () => { - respond({ body: valid }) - await expect(loadBackendConfig()).resolves.toEqual(valid) + it('omits the synchronizer id when the row carries none', async () => { + const { ledgerApi } = ledger({ acs: [factoryRow(created)] }) + await expect(loadBackendConfig(ledgerApi)).resolves.not.toHaveProperty('synchronizerId') }) - it('accepts a config with no synchronizer id, which is optional', async () => { - const { synchronizerId, ...rest } = valid - respond({ body: rest }) - await expect(loadBackendConfig()).resolves.toEqual(rest) + // Every run leaves its operator behind, so the newest is the one whose factory the config means. + it('reads as the newest operator among the rights', async () => { + const { ledgerApi, filteredParty } = ledger() + await loadBackendConfig(ledgerApi) + expect(filteredParty()).toBe(OPERATOR) }) - it('names the bootstrap script when the file is missing', async () => { - respond({ ok: false, status: 404 }) - await expect(loadBackendConfig()).rejects.toThrow( - /is missing \(HTTP 404\).*bootstrap-vesting-lite/, + it('names the bootstrap script when no operator was ever created', async () => { + const { ledgerApi } = ledger({ rights: [{ kind: { ParticipantAdmin: { value: {} } } }] }) + await expect(loadBackendConfig(ledgerApi)).rejects.toThrow( + /no vesting operator.*bootstrap-vesting-lite/, ) }) - // A dev server answers a missing file with index.html, so a 200 proves nothing. - it('rejects the SPA fallback rather than reading it as a deployment', async () => { - respond({ text: '' }) - await expect(loadBackendConfig()).rejects.toThrow(/is not a JSON object/) + // A factory with no blob cannot be disclosed, so it is as good as absent. + it('rejects a factory row that came back without its disclosure blob', async () => { + const { ledgerApi } = ledger({ acs: [factoryRow({ contractId: '00cid' })] }) + await expect(loadBackendConfig(ledgerApi)).rejects.toThrow(/no factory disclosable/) }) - it('rejects a JSON body that is not an object, null included', async () => { - respond({ body: null }) - await expect(loadBackendConfig()).rejects.toThrow(/is not a JSON object/) + it('rejects an empty active-contracts read', async () => { + const { ledgerApi } = ledger({ acs: [] }) + await expect(loadBackendConfig(ledgerApi)).rejects.toThrow(/no factory disclosable/) }) - it('names every required key that is absent or blank', async () => { - respond({ body: { pkg: 'abc123', factoryCid: '', factoryBlob: undefined } }) - await expect(loadBackendConfig()).rejects.toThrow(/has no factoryCid, factoryBlob/) + it('throws when the wallet reports no authenticated user', async () => { + const { ledgerApi } = ledger({ user: {} }) + await expect(loadBackendConfig(ledgerApi)).rejects.toThrow( + /did not report an authenticated user/, + ) }) }) diff --git a/dapp/frontend/src/backend/config.ts b/dapp/frontend/src/backend/config.ts index 83e2490a..b0e14427 100644 --- a/dapp/frontend/src/backend/config.ts +++ b/dapp/frontend/src/backend/config.ts @@ -1,35 +1,109 @@ -// The deployment the bootstrap script wrote. There is no fallback: without it the app has no -// package id and no factory to disclose, so nothing it does can reach the ledger. +// The deployment the bootstrap left on the ledger, read back through the wallet rather than carried +// in a file: every bootstrap run mints a fresh operator and factory, and a stale copy of either +// shows as an empty dashboard with no error. + +import type { LedgerApiParams } from '@bootnodedev/canton-connect' export type Deployment = { - pkg: string - factoryCid: string factoryBlob: string + factoryCid: string + pkg: string synchronizerId?: string } -const CONFIG_FILE = '/vesting-lite-parties.json' +export type LedgerApi = (params: LedgerApiParams) => Promise -// Typed against Deployment so renaming a field cannot leave this naming a key that is gone. -const REQUIRED: readonly (keyof Deployment)[] = ['pkg', 'factoryCid', 'factoryBlob'] +// A filter takes the package-name reference, never the id it resolves to. +const FACTORY = '#vesting-lite:Vesting:VestingFactory' +const OPERATOR_HINT = 'vesting-operator-' const advice = (reason: string): Error => - new Error(`${CONFIG_FILE} ${reason} — run node scripts/bootstrap-vesting-lite.mjs`) + new Error(`${reason} — run node scripts/bootstrap-vesting-lite.mjs`) + +const call = async (ledgerApi: LedgerApi, params: LedgerApiParams): Promise => + (await ledgerApi(params)) as T + +type ActiveContract = { + contractEntry?: { + JsActiveContract?: { + createdEvent?: { contractId?: string; createdEventBlob?: string; templateId?: string } + synchronizerId?: string + } + } +} -export const loadBackendConfig = async (): Promise => { - const response = await fetch(CONFIG_FILE) - if (!response.ok) { - throw advice(`is missing (HTTP ${response.status})`) +// The bootstrap grants this user `CanActAs` on each operator it creates, so its rights are the +// operator list. Reading the ledger's parties instead would also return everyone else's on a shared +// participant. The hint carries the run's timestamp, so the last one sorted is the newest. +const newestOperator = async (ledgerApi: LedgerApi): Promise => { + const { user } = await call<{ user?: { id?: string } }>(ledgerApi, { + requestMethod: 'get', + resource: '/v2/authenticated-user', + }) + if (user?.id === undefined) { + throw new Error('the wallet did not report an authenticated user') + } + const { rights } = await call<{ + rights?: { kind?: { CanActAs?: { value?: { party?: string } } } }[] + }>(ledgerApi, { requestMethod: 'get', resource: `/v2/users/${user.id}/rights` }) + const operator = (rights ?? []) + .map((right) => right.kind?.CanActAs?.value?.party) + .filter((party): party is string => party?.startsWith(OPERATOR_HINT) === true) + .sort() + .at(-1) + if (operator === undefined) { + throw advice('no vesting operator on this ledger') + } + return operator +} + +export const loadBackendConfig = async (ledgerApi: LedgerApi): Promise => { + const operator = await newestOperator(ledgerApi) + const { offset } = await call<{ offset?: string | number }>(ledgerApi, { + requestMethod: 'get', + resource: '/v2/state/ledger-end', + }) + if (offset === undefined) { + throw new Error('the ledger did not return an offset') + } + const rows = await call(ledgerApi, { + requestMethod: 'post', + resource: '/v2/state/active-contracts', + body: { + filter: { + filtersByParty: { + [operator]: { + cumulative: [ + { + identifierFilter: { + TemplateFilter: { value: { templateId: FACTORY, includeCreatedEventBlob: true } }, + }, + }, + ], + }, + }, + }, + activeAtOffset: offset, + verbose: true, + }, + }) + // The blob is the disclosure payload a funder cannot read the factory without, so a row lacking + // one is no use even though the contract exists. + const factory = (Array.isArray(rows) ? rows : []) + .map((row) => row.contractEntry?.JsActiveContract) + .find((entry) => entry?.createdEvent?.createdEventBlob !== undefined) + const created = factory?.createdEvent + if (created?.contractId === undefined || created.createdEventBlob === undefined) { + throw advice(`no factory disclosable by ${operator}`) } - // A dev server answers a missing file with the SPA fallback, so a 200 alone proves nothing. - const body: unknown = await response.json().catch(() => undefined) - if (typeof body !== 'object' || body === null) { - throw advice('is not a JSON object') + const pkg = created.templateId?.split(':')[0] + if (pkg === undefined || pkg === '') { + throw advice('the factory came back with no package id') } - const data = body as Partial - const missing = REQUIRED.filter((key) => typeof data[key] !== 'string' || data[key] === '') - if (missing.length > 0) { - throw advice(`has no ${missing.join(', ')}`) + return { + factoryBlob: created.createdEventBlob, + factoryCid: created.contractId, + pkg, + ...(factory?.synchronizerId === undefined ? {} : { synchronizerId: factory.synchronizerId }), } - return data as Deployment } diff --git a/dapp/frontend/src/backend/wallet.ts b/dapp/frontend/src/backend/wallet.ts index c198bb43..94d34cb6 100644 --- a/dapp/frontend/src/backend/wallet.ts +++ b/dapp/frontend/src/backend/wallet.ts @@ -6,19 +6,19 @@ import type { LedgerApiParams, PrepareExecuteParams } from '@bootnodedev/canton- // JSON Ledger API v2 command — always an ExerciseCommand in this dApp. export type LedgerCommand = { ExerciseCommand: { - templateId: string - contractId: string choice: string choiceArgument: Record + contractId: string + templateId: string } } // Explicitly-disclosed contract (JSON Ledger API v2 disclosedContracts entry). export type DisclosedContract = { - templateId: string contractId: string createdEventBlob: string synchronizerId?: string + templateId: string } export type WalletFns = { diff --git a/dapp/frontend/src/components/AmountDisplay.tsx b/dapp/frontend/src/components/AmountDisplay.tsx index ff521b69..ccd173be 100644 --- a/dapp/frontend/src/components/AmountDisplay.tsx +++ b/dapp/frontend/src/components/AmountDisplay.tsx @@ -1,22 +1,40 @@ -import { cn } from '@/lib/cn' -import { formatCC } from '@/lib/format' +import { formatAmount } from '@bootnodedev/canton-dappbooster' +import cantonCoin from '@/assets/canton-coin.png' +import { CompactAmount } from '@/components/CompactAmount' +import { InfoTip } from '@/components/InfoTip' +import { cn } from '@/utils/cn' +import { CC } from '@/utils/tokens' interface AmountDisplayProps { - value: string - unit?: string className?: string + count?: boolean gradient?: boolean + value: string } -// Mono numeral + a muted unit suffix. The canonical way amounts appear. +// The token mark is the only thing naming the unit, so it carries the name rather than an empty alt. +const UNIT = `${CC.name} (${CC.symbol})` + +// Never varies, so hoisting it out lets React skip the subtree by element identity. The dashboard +// re-renders every amount once a second off the live clock, and each mark carries a `useId` tooltip. +const MARK = ( + + + {UNIT} + + +) + +// Mono numeral + the token mark. The canonical way amounts appear; `count` is for a plain tally, +// which owes neither the mark nor the forced 2 decimals. export const AmountDisplay = ({ value, - unit = 'CC', className, + count = false, gradient = false, }: AmountDisplayProps): React.JSX.Element => ( - {formatCC(value)} - {unit !== '' && {unit}} + {count ? formatAmount(value) : } + {!count && MARK} ) diff --git a/dapp/frontend/src/components/AppShell.tsx b/dapp/frontend/src/components/AppShell.tsx index 5e8c5b61..18203648 100644 --- a/dapp/frontend/src/components/AppShell.tsx +++ b/dapp/frontend/src/components/AppShell.tsx @@ -1,51 +1,59 @@ -import { Outlet, useLocation } from 'react-router-dom' +import { Outlet } from 'react-router-dom' import { Card } from '@/components/Card' -import { Sidebar } from '@/components/Sidebar' +import { CreateGrant } from '@/components/CreateGrant' +import { Loading } from '@/components/Loading' +import { Toaster } from '@/components/Toaster' import { TopBar } from '@/components/TopBar' import { useConnectErrorToast } from '@/hooks/useConnectErrorToast' -import { useBackend } from '@/providers/BackendProvider' -import { useUiStore } from '@/store/useUiStore' - -const titleFor = (pathname: string, role: string): { title: string; crumb: string } => { - if (pathname.startsWith('/proposals')) { - return { title: 'Proposals', crumb: role } - } - if (pathname.startsWith('/create')) { - return { title: 'Create grant', crumb: 'Funder' } - } - if (pathname.startsWith('/grants/')) { - return { title: 'Grant detail', crumb: role } - } - return { title: role === 'funder' ? 'Granted by me' : 'Dashboard', crumb: role } -} +import { useCreateGrant } from '@/hooks/useCreateGrant' +import { useBackend } from '@/providers/Backend' export const AppShell = (): React.JSX.Element => { - const role = useUiStore((s) => s.role) - const location = useLocation() - const { configPending, configError } = useBackend() + const { backend, configPending, configError } = 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. + const [creating, setCreating] = useCreateGrant() useConnectErrorToast() - const { title, crumb } = titleFor(location.pathname, role) return (
- -
- -
- {/* The card swaps in asynchronously with no focus move, so `role="alert"` is what - carries it to a reader. */} +
+ + Skip to main content + + +
{configError !== undefined && ( -

No deployment

+

No deployment

{configError}

)} - {/* Every page needs the deployment, so none mounts before it has resolved either way: a - page without one renders a connect placeholder a connected user does not need. */} - {configError === undefined && !configPending && } + {configPending && } + {configError === undefined && !configPending && ( + <> + + {creating && backend !== undefined && ( + setCreating(false)} /> + )} + + )}
+
+ + Canton · direct ledger +
+
) } diff --git a/dapp/frontend/src/components/Button.tsx b/dapp/frontend/src/components/Button.tsx index 9b02d7c6..cb49554e 100644 --- a/dapp/frontend/src/components/Button.tsx +++ b/dapp/frontend/src/components/Button.tsx @@ -1,24 +1,31 @@ import type { ButtonHTMLAttributes } from 'react' import { Link } from 'react-router-dom' -import { cn } from '@/lib/cn' +import { SpinnerIcon } from '@/icons' +import { cn } from '@/utils/cn' -type Variant = 'primary' | 'secondary' | 'ghost' | 'danger' -type Size = 'sm' | 'md' +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 { - variant?: Variant - size?: Size + 'aria-label'?: string className?: string + size?: Size + 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 } interface ButtonAsLink extends BaseProps { asLink: true - to: string children?: React.ReactNode + to: string } type ButtonProps = ButtonAsButton | ButtonAsLink @@ -26,6 +33,8 @@ type ButtonProps = ButtonAsButton | ButtonAsLink 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', } // Primary carries the Aurora accent: brand gradient + glow on hover. @@ -36,14 +45,16 @@ const variants: Record = { 'before:opacity-0 before:transition-opacity enabled:hover:border-transparent ' + 'enabled:hover:shadow-[var(--glow)] enabled:hover:before:opacity-100', secondary: - 'border border-border-strong bg-surface text-fg enabled:hover:border-primary enabled:hover:text-primary', + '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/40 bg-surface text-danger enabled:hover:bg-danger-soft', + 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', } -const classesFor = (variant: Variant, size: Size, className?: string): string => +// 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-xl font-semibold transition-colors', + 'inline-flex items-center justify-center gap-2 rounded-[8px] font-semibold transition-colors', 'focus-visible:outline-none focus-visible:shadow-[var(--ring)]', 'disabled:cursor-not-allowed disabled:opacity-45', sizes[size], @@ -55,7 +66,11 @@ export const Button = (props: ButtonProps): React.JSX.Element => { if (props.asLink === true) { const { variant = 'primary', size = 'md', className, to, children } = props return ( - + {children} ) @@ -65,8 +80,28 @@ export const Button = (props: ButtonProps): React.JSX.Element => { size = 'md', className, type = 'button', + pending = false, + disabled = false, + children, asLink: _a, ...rest } = props - return + ) } diff --git a/dapp/frontend/src/components/CancelGrantDialog.tsx b/dapp/frontend/src/components/CancelGrant.tsx similarity index 69% rename from dapp/frontend/src/components/CancelGrantDialog.tsx rename to dapp/frontend/src/components/CancelGrant.tsx index 36ae2ee9..bb8baadb 100644 --- a/dapp/frontend/src/components/CancelGrantDialog.tsx +++ b/dapp/frontend/src/components/CancelGrant.tsx @@ -3,33 +3,31 @@ import { AmountDisplay } from '@/components/AmountDisplay' import { Button } from '@/components/Button' import { FieldError } from '@/components/FieldError' import { Modal } from '@/components/Modal' -import { toast } from '@/components/toast' -import { errorText } from '@/lib/errorText' -import { MIN_GRANT_AMOUNT, residualMeetsFloor } from '@/lib/schedule' import type { Grant } from '@/store/types' import { deriveGrant } from '@/store/useVestingStore' +import { errorText } from '@/utils/errorText' +import { MIN_GRANT_AMOUNT, residualMeetsFloor } from '@/utils/schedule' +import { toast } from '@/utils/toast' -interface CancelGrantDialogProps { - open: boolean - onClose: () => void +interface CancelGrantProps { + description: string grant: Grant nowMs: number - description: string - successMessage: string + onClose: () => void onConfirm: () => Promise + successMessage: string } -// Cancel-grant confirmation shared by the dashboard and grant-detail pages. Like ClaimDialog, it +// Cancel-grant confirmation shared by the dashboard and grant-detail pages. Like Claim, it // owns the submit, toast, error and submitting lifecycle so the pages do not. -export const CancelGrantDialog = ({ - open, +export const CancelGrant = ({ onClose, grant, nowMs, description, successMessage, onConfirm, -}: CancelGrantDialogProps): React.JSX.Element => { +}: CancelGrantProps): React.JSX.Element => { const [submitting, setSubmitting] = useState(false) const derived = deriveGrant(grant, nowMs) // Recomputed each tick with `nowMs`, so a residual growing past the floor re-enables the button @@ -50,7 +48,7 @@ export const CancelGrantDialog = ({ } return ( - +
@@ -68,20 +66,17 @@ export const CancelGrantDialog = ({ message={`The residual must be 0 or at least ${MIN_GRANT_AMOUNT} CC. Cancel once more has vested, or let the receiver claim it down to zero.`} /> )} -
- - -
+
) diff --git a/dapp/frontend/src/components/Card.tsx b/dapp/frontend/src/components/Card.tsx index d27fb6bd..a3d7b30e 100644 --- a/dapp/frontend/src/components/Card.tsx +++ b/dapp/frontend/src/components/Card.tsx @@ -1,11 +1,11 @@ import type { HTMLAttributes } from 'react' -import { cn } from '@/lib/cn' +import { cn } from '@/utils/cn' // Surface card with the standard border + soft elevation. export const Card = ({ className, ...rest }: HTMLAttributes): React.JSX.Element => (
void - title: string +interface ClaimProps { available: string + onClose: () => void onConfirm: (amount: string) => Promise + title: string } // Amount-entry dialog shared by grant withdraw and residual claim. Enforces the re-lock floor: the // remainder must be zero or at least MIN_GRANT_AMOUNT. -export const ClaimDialog = ({ - open, - onClose, - title, - available, - onConfirm, -}: ClaimDialogProps): React.JSX.Element => { - const [token, setToken] = useToken() +export const Claim = ({ onClose, title, available, onConfirm }: ClaimProps): React.JSX.Element => { const [raw, setRaw] = useState('') const [submitting, setSubmitting] = useState(false) - // Seed the max only on the open transition: `available` reticks each second on a live grant, and - // re-seeding would overwrite what the user types. - const seeded = useRef(false) - useEffect(() => { - if (open && !seeded.current) { - seeded.current = true - setRaw(available) - } else if (!open) { - seeded.current = false - } - }, [open, available]) - // Recomputed from `available` rather than stored from the last keystroke: it drops each second // for a live-vesting grant, so a stored code would keep flagging an amount the field itself has // already accepted (and vice versa) until the user typed again. Same bounds the field uses. @@ -66,7 +46,9 @@ export const ClaimDialog = ({ setSubmitting(true) try { await onConfirm(raw) - toast.success(`Claimed ${formatCC(raw)} CC`) + // 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 ${formatCCFull(raw)} ${CC.symbol}`) onClose() } catch (err) { toast.error(errorText(err)) @@ -76,33 +58,30 @@ export const ClaimDialog = ({ } return ( - + setRaw(next)} - onTokenSelect={setToken} - token={token} + token={CC} + usdValue="Not Available" value={raw} /> {message !== undefined && ( )} -
- - -
+
) } diff --git a/dapp/frontend/src/components/CompactAmount.tsx b/dapp/frontend/src/components/CompactAmount.tsx new file mode 100644 index 00000000..f5636d9b --- /dev/null +++ b/dapp/frontend/src/components/CompactAmount.tsx @@ -0,0 +1,40 @@ +import { InfoTip } from '@/components/InfoTip' +import { formatCCCompact, formatCCFull, isCompacted } from '@/utils/format' +import { CC } from '@/utils/tokens' + +// A Canton Coin 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 CC; `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 = ({ + value, + plain = false, +}: { + plain?: boolean + value: string +}): React.JSX.Element => { + const figure = formatCCCompact(value) + if (!isCompacted(value)) { + return <>{figure} + } + const exact = formatCCFull(value) + if (plain) { + return ( + <> + + {exact} + + ) + } + const labelled = `${exact} ${CC.symbol}` + return ( + <> + + + + {/* The tooltip only reveals on hover, so without this the exact figure reaches nobody reading + by ear, and the abbreviation is the only number the page still holds. */} + {labelled} + + ) +} diff --git a/dapp/frontend/src/components/ConnectPrompt.tsx b/dapp/frontend/src/components/ConnectPrompt.tsx index 99fe78e9..44a421cd 100644 --- a/dapp/frontend/src/components/ConnectPrompt.tsx +++ b/dapp/frontend/src/components/ConnectPrompt.tsx @@ -1,12 +1,14 @@ import { ConnectButton } from '@bootnodedev/canton-dappbooster/connect' +import { buttonClass } from '@/components/Button' 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. -export const ConnectPrompt = ({ description }: { description: string }): React.JSX.Element => ( +// 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 => ( } + level={1} + title="Canton Vesting" + action={} /> ) diff --git a/dapp/frontend/src/components/CounterpartyId.tsx b/dapp/frontend/src/components/CounterpartyId.tsx index c086ce63..2e540213 100644 --- a/dapp/frontend/src/components/CounterpartyId.tsx +++ b/dapp/frontend/src/components/CounterpartyId.tsx @@ -1,10 +1,10 @@ import { Identifier } from '@bootnodedev/canton-dappbooster' -import { copyToast } from '@/components/toast' +import { copyToast } from '@/utils/toast' interface CounterpartyIdProps { - party: string - incoming: boolean href?: string + incoming: boolean + party: string } // The other party on a grant or proposal: a from/to prefix and the id, copyable. Renders inline, so diff --git a/dapp/frontend/src/components/CreateGrant/DateField.tsx b/dapp/frontend/src/components/CreateGrant/DateField.tsx new file mode 100644 index 00000000..8d69a7cb --- /dev/null +++ b/dapp/frontend/src/components/CreateGrant/DateField.tsx @@ -0,0 +1,30 @@ +import { atMidnight, dateOf, inputClass, labelClass } from '@/components/CreateGrant/fields' + +// One of the schedule's three labelled dates. It hands back a full ISO string, so the caller never +// sees the calendar-day form the input binds to. +export const DateField = ({ + id, + label, + value, + onChange, + className, +}: { + className?: string + id: string + label: string + onChange: (iso: string) => void + value: string +}): React.JSX.Element => ( +
+ + onChange(atMidnight(e.target.value))} + className={inputClass} + /> +
+) diff --git a/dapp/frontend/src/components/CreateGrant/LiveScheduleCurve.tsx b/dapp/frontend/src/components/CreateGrant/LiveScheduleCurve.tsx new file mode 100644 index 00000000..f9de3d99 --- /dev/null +++ b/dapp/frontend/src/components/CreateGrant/LiveScheduleCurve.tsx @@ -0,0 +1,11 @@ +import { ScheduleCurve } from '@/components/ScheduleCurve' +import { useNow } from '@/utils/clock' +import type { VestingSchedule } from '@/utils/schedule' + +// Isolates the clock to the preview marker so typing in the form does not reconcile the whole page +// each tick. +export const LiveScheduleCurve = ({ + schedule, +}: { + schedule: VestingSchedule +}): React.JSX.Element => diff --git a/dapp/frontend/src/components/CreateGrant/fields.ts b/dapp/frontend/src/components/CreateGrant/fields.ts new file mode 100644 index 00000000..dc30f83a --- /dev/null +++ b/dapp/frontend/src/components/CreateGrant/fields.ts @@ -0,0 +1,8 @@ +// Schedule timestamps are full ISO so demo presets can build sub-day windows; the date inputs bind +// to the calendar-day part and reset the time to midnight when edited. +export const dateOf = (iso: string): string => iso.slice(0, 10) +export const atMidnight = (date: string): string => `${date}T00:00:00.000Z` + +export const labelClass = 'block text-xs font-bold uppercase tracking-[0.06em] text-fg-muted' +export const inputClass = + 'mt-1.5 h-11 w-full rounded-[8px] border border-border bg-bg px-3 text-fg outline-none focus:shadow-[var(--ring)]' diff --git a/dapp/frontend/src/features/CreateGrantPage.tsx b/dapp/frontend/src/components/CreateGrant/index.tsx similarity index 63% rename from dapp/frontend/src/features/CreateGrantPage.tsx rename to dapp/frontend/src/components/CreateGrant/index.tsx index a4274d49..d14fff64 100644 --- a/dapp/frontend/src/features/CreateGrantPage.tsx +++ b/dapp/frontend/src/components/CreateGrant/index.tsx @@ -1,60 +1,63 @@ import { + Identifier, isValidPartyId, type PartyIdError, PartyIdInput, - partyHint, TokenInput, + useExplorerLink, validateAmount, } from '@bootnodedev/canton-dappbooster' import { useMemo, useState } from 'react' -import { useNavigate } from 'react-router-dom' import { AmountDisplay } from '@/components/AmountDisplay' import { Button } from '@/components/Button' -import { Card } from '@/components/Card' import { ConnectPrompt } from '@/components/ConnectPrompt' +import { DateField } from '@/components/CreateGrant/DateField' +import { atMidnight, dateOf, inputClass, labelClass } from '@/components/CreateGrant/fields' +import { LiveScheduleCurve } from '@/components/CreateGrant/LiveScheduleCurve' import { FieldError } from '@/components/FieldError' -import { ScheduleCurve } from '@/components/ScheduleCurve' -import { toast } from '@/components/toast' +import { InfoTip } from '@/components/InfoTip' +import { Modal } from '@/components/Modal' +import { Select } from '@/components/Select' import { useParty } from '@/hooks/useParty' -import { useToken } from '@/hooks/useToken' -import { compareAmounts } from '@/lib/amount' -import { AMOUNT_ERROR_TEXT } from '@/lib/amountErrorText' -import { now, useNow } from '@/lib/clock' -import { cn } from '@/lib/cn' -import { errorText } from '@/lib/errorText' -import { MIN_GRANT_AMOUNT, type VestingSchedule, validVestingSchedule } from '@/lib/schedule' -import { useVesting, useVestingStore } from '@/store/useVestingStore' +import { TrashIcon } from '@/icons' +import { useBackend } from '@/providers/Backend' +import { useVestingStore } from '@/store/useVestingStore' +import { compareAmounts } from '@/utils/amount' +import { AMOUNT_ERROR_TEXT } from '@/utils/amountErrorText' +import { now } from '@/utils/clock' +import { cn } from '@/utils/cn' +import { EXPLORER } from '@/utils/config' +import { errorText } from '@/utils/errorText' +import { randomId } from '@/utils/randomId' +import { MIN_GRANT_AMOUNT, type VestingSchedule, validVestingSchedule } from '@/utils/schedule' +import { copyToast, toast } from '@/utils/toast' +import { CC } from '@/utils/tokens' type CurveKind = 'linear' | 'milestone' interface MilestoneInput { - id: string date: string + id: string pct: string } // A demo preset; the actual schedule is re-anchored to submit time (see submit()). interface DemoPreset { - kind: CurveKind durationMs: number + kind: CurveKind } // One state for the whole schedule, because no edit touches only one field: every manual one also // has to clear `demo`, and a preset rewrites most of the rest. interface ScheduleForm { - curveKind: CurveKind cliff: string - start: string + curveKind: CurveKind + demo: DemoPreset | null end: string milestones: MilestoneInput[] - demo: DemoPreset | null + start: string } -// Schedule timestamps are full ISO so demo presets can build sub-day windows; the date inputs bind -// to the calendar-day part and reset the time to midnight when edited. -const dateOf = (iso: string): string => iso.slice(0, 10) -const atMidnight = (date: string): string => `${date}T00:00:00.000Z` -const relIso = (msFromNow: number): string => new Date(now() + msFromNow).toISOString() const addMonths = (d: Date, m: number): Date => { const copy = new Date(d) copy.setMonth(copy.getMonth() + m) @@ -75,12 +78,6 @@ const defaultSchedule = ( ], }) -// Isolates the clock to the preview marker so typing in the form does not reconcile the whole page -// each tick. -const LiveScheduleCurve = ({ schedule }: { schedule: VestingSchedule }): React.JSX.Element => ( - -) - // Build a short demo schedule anchored at `anchorMs` (cliff = anchor, vests over duration). const buildDemoSchedule = (preset: DemoPreset, anchorMs: number): VestingSchedule => { const at = (ms: number): string => new Date(anchorMs + ms).toISOString() @@ -101,38 +98,14 @@ const buildDemoSchedule = (preset: DemoPreset, anchorMs: number): VestingSchedul } } -const labelClass = 'block text-xs font-bold uppercase tracking-[0.06em] text-fg-muted' -const inputClass = - 'mt-1.5 h-11 w-full rounded-xl border border-border bg-bg px-3 text-fg outline-none focus:shadow-[var(--ring)]' -const chipClass = - 'rounded-full border border-border px-3 py-1 text-xs font-semibold text-fg-muted transition-colors hover:border-primary hover:text-primary' - -// One of the schedule's three labelled dates. It hands back a full ISO string, so the caller never -// sees the calendar-day form the input binds to. -const DateField = ({ - id, - label, - value, - onChange, -}: { - id: string - label: string - value: string - onChange: (iso: string) => void -}): React.JSX.Element => ( -
- - onChange(atMidnight(e.target.value))} - className={inputClass} - /> -
-) +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' }, +] // The kit ships codes, not copy, so the wording is the app's. Exhaustive by construction: a new // code stops this compiling rather than rendering nothing. @@ -142,12 +115,14 @@ const RECEIVER_MESSAGE: Record = { 'invalid-fingerprint': 'The fingerprint after :: must be 68 hex characters.', } -export const CreateGrantPage = (): React.JSX.Element => { - const navigate = useNavigate() +export const CreateGrant = ({ onClose }: { onClose: () => void }): React.JSX.Element => { const { party } = useParty() - const { backend, partyId } = useVesting() + // Not `useVesting`: this mounts over a page that already holds the ACS read, and a second one + // would bump the refresh epoch and discard the page's own read mid-flight. + const { backend } = useBackend() + const partyId = party?.partyId ?? '' const createVesting = useVestingStore((s) => s.createVesting) - const [token, setToken] = useToken() + const explorerLink = useExplorerLink(EXPLORER) const [receiver, setReceiver] = useState('') // What the field is currently flagging: the kit reports it, this page words and places it. @@ -159,19 +134,13 @@ export const CreateGrantPage = (): React.JSX.Element => { demo: null, })) const { curveKind, cliff, start, end, milestones, demo } = scheduleForm - const [note, setNote] = useState('') + const [title, setTitle] = useState('') const [submitting, setSubmitting] = useState(false) - const [disclosedBytes, setDisclosedBytes] = useState(null) + const [step, setStep] = useState(1) - // Editing the grant's identity clears the created panel, restoring the submit button. const editReceiver = (value: string, error: PartyIdError | undefined): void => { setReceiver(value) setReceiverError(error) - setDisclosedBytes(null) - } - const editAmount = (value: string): void => { - setAmount(value) - setDisclosedBytes(null) } const schedule = useMemo(() => { @@ -210,7 +179,8 @@ export const CreateGrantPage = (): React.JSX.Element => { : isSelf ? 'Cannot grant to your own party.' : undefined - const valid = scheduleValid && amountValid && receiverValid && backend !== undefined + const titleValid = title.trim() !== '' + const valid = scheduleValid && amountValid && receiverValid && titleValid && backend !== undefined // Any manual schedule edit drops the demo flag so the entered dates are used verbatim, which is // why every one of them goes through here. @@ -225,30 +195,35 @@ export const CreateGrantPage = (): React.JSX.Element => { const setMilestone = (i: number, patch: Partial): void => editMilestones((list) => list.map((m, idx) => (idx === i ? { ...m, ...patch } : m))) - const demoLinear = (durationMs: number): void => - setScheduleForm((current) => ({ - ...current, - curveKind: 'linear', - start: relIso(0), - cliff: relIso(0), - end: relIso(durationMs), - demo: { kind: 'linear', durationMs }, - })) - const demoMilestones = (): void => + // The fields are filled from buildDemoSchedule rather than rebuilt here, or the preview and the + // submitted schedule are two copies of the same step maths and drift apart. 'none' restores the + // default months-out schedule. + const applyPreset = (value: string): void => { + if (value === 'none') { + setScheduleForm((current) => ({ + curveKind: current.curveKind, + ...defaultSchedule(new Date(now())), + demo: null, + })) + return + } + const preset: DemoPreset = { kind: curveKind, durationMs: Number(value) } + const built = buildDemoSchedule(preset, now()) setScheduleForm((current) => ({ ...current, - curveKind: 'milestone', - cliff: relIso(0), - milestones: [ - { id: 'd1', date: relIso(30_000), pct: '34' }, - { id: 'd2', date: relIso(60_000), pct: '67' }, - { id: 'd3', date: relIso(90_000), pct: '100' }, - ], - demo: { kind: 'milestone', durationMs: 90_000 }, + cliff: built.cliff, + ...(built.curve.kind === 'linear' + ? { start: built.curve.start, end: built.curve.end } + : { + milestones: built.curve.points.map((point, i) => ({ + id: `d${i + 1}`, + date: point.time, + pct: String(Math.round(point.fraction * 100)), + })), + }), + demo: preset, })) - // Restore the default months-out schedule (undo a quick-demo preset). - const resetSchedule = (): void => - setScheduleForm({ curveKind: 'linear', ...defaultSchedule(new Date(now())), demo: null }) + } const submit = async (): Promise => { if (!valid || party === undefined || backend === undefined) { @@ -257,11 +232,6 @@ export const CreateGrantPage = (): React.JSX.Element => { // Re-anchor a demo preset to now, or its short window is mostly vested before the receiver // accepts. const finalSchedule = demo === null ? schedule : buildDemoSchedule(demo, now()) - const trimmedNote = note.trim() - const title = - trimmedNote !== '' - ? trimmedNote.split(/[.\n]/)[0].slice(0, 60) - : `Grant to ${partyHint(receiver)}` setSubmitting(true) try { const result = await createVesting(backend, partyId, { @@ -269,25 +239,41 @@ export const CreateGrantPage = (): React.JSX.Element => { receiver, totalAmount: amount, schedule: finalSchedule, - title, - note: trimmedNote === '' ? undefined : trimmedNote, + title: title.trim(), }) - setDisclosedBytes(result.disclosedBytes) + onClose() toast.success( `Proposal created · delivered via explicit disclosure · ${result.disclosedBytes} bytes`, + { action: { label: 'View proposals', to: '/proposals?role=funder' } }, ) } catch (err) { - toast.error(errorText(err)) + toast.error(errorText(err), { sticky: true }) } finally { setSubmitting(false) } } + const stepValid = step === 1 ? titleValid && receiverValid && amountValid : scheduleValid + return ( -
-
- -

Receiver & amount

+ + {step === 1 && ( + <> + + setTitle(e.target.value)} + placeholder="What is this grant for?" + className={inputClass} + />
+ {/* 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. */} {amountMessage !== undefined && ( @@ -325,8 +314,11 @@ export const CreateGrantPage = (): React.JSX.Element => { )}
-
- + + )} + + {step === 2 && ( + <>

Schedule

@@ -334,6 +326,7 @@ export const CreateGrantPage = (): React.JSX.Element => {
- - Quick demo - - - - - + 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, '') }) @@ -418,11 +401,12 @@ export const CreateGrantPage = (): React.JSX.Element => { />
))} @@ -432,20 +416,17 @@ export const CreateGrantPage = (): React.JSX.Element => { editMilestones((l) => [ ...l, { - id: crypto.randomUUID().slice(0, 8), + id: randomId().slice(0, 8), date: addMonths(new Date(now()), 24).toISOString(), pct: '100', }, ]) } - className="self-start text-xs font-bold text-primary hover:underline" + className="self-start text-xs font-bold text-primary-strong hover:underline" > + Add milestone
-

- Percentages are cumulative and must end at 100%. -

)}
@@ -455,25 +436,11 @@ export const CreateGrantPage = (): React.JSX.Element => { milestone percentages strictly increase to 100%.

)} - + + )} - - -