diff --git a/CLAUDE.md b/CLAUDE.md index 3b016c20..c406a658 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,7 @@ Use one reader per doc type, layered by scope: | File | Reader / question | Distribution rule | |------|-------------------|-------------------| -| `README.md` | Human: what is this and how does someone run it? | Every unit that builds, runs, publishes or tests on its own gets one. A subproject's README covers only that unit and links to the root README for shared setup. | +| `README.md` | Human: what is this, how do I start, and where do I go next? | Every unit that builds, runs, publishes or tests on its own gets one. A subproject's README covers only that unit and links to the root README for shared setup. | | `CLAUDE.md` | Agent: what local rules change how an agent edits here? | Root always. Subproject only when local conventions differ from root enough that an agent editing only that directory would get it wrong. Deltas only; link upward for repo-wide rules. | | `CONTRIBUTING.md` | Human: how does someone work on this repo? | Root only. Setup, the local development command and the checks to run before a PR. A user creating a project reads the README instead. | | `AGENTS.md` | Agent compatibility loader | Three-line shim beside every `CLAUDE.md`, pointing to the sibling `CLAUDE.md`. It is never canonical. | @@ -47,16 +47,32 @@ Current distribution: Subproject docs must not restate root rules. They should describe only their local delta and link upward. -**Reference material never goes in a README.** A README answers "what is this and how does someone run it". -Enumerations belong elsewhere however short they are: token lists, prop tables, exported-symbol -indexes, config keys. +**A README is the page a reader meets first, and most try the unit before reading anything else.** +It introduces the unit, gets the reader running, and says where to go next. It may repeat what +another document holds whenever that serves those three jobs: an install line, a usage example, a +gotcha, a short table. The authoritative version stays where the rules below put it, the README +links to it, and the same-commit sync rule covers both copies. - When the code *is* the list (a token file, a type, a barrel), the code is the doc. Group it with category comments; a prose copy drifts within two PRs. - A rule constraining how to author the code goes in the nearest `CLAUDE.md`. - A seam between units goes in `architecture.md`. -A README may state that a contract exists and link to it. It may not restate it. +A caveat a reader could miss in running text goes in a GitHub alert, one fact per block. GitHub and +TypeDoc render the five kinds; npm shows the tag as text, which is accepted. + +Every pointer out of a README names what the reader finds there: the generated reference for the +API, `architecture.md` for the why, `CLAUDE.md` for the authoring rules, `CONTRIBUTING.md` for +working on the repo. + +**Never count what the repo can grow.** "The examples", not "the two examples"; "its DARs", not +"both DARs". The number drifts the day a folder is added, and nothing flags the sentence. A count +stays only where it is the point: a pair the sentence contrasts, or a figure fixed in history. + +**No document is an orphan.** Every `.md` in the repo is linked or named by path from at least one +other, and every one is reachable from the root `README.md` by following those links and names. The +number of hops does not matter; a page nothing points at does. Nothing enforces this yet, so it is a +review check. ## Stack and conventions (monorepo) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 97405d19..ae9625ad 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,13 +53,28 @@ token in `example-dapps/amulet-vesting/.env`. ```bash pnpm dev:token-vesting # interactive menu -pnpm dev:token-vesting up # LocalNet, both DARs, token registry, Wallet Gateway and dApp +pnpm dev:token-vesting up # LocalNet, its DARs, token registry, Wallet Gateway and dApp pnpm dev:token-vesting down # stop everything and keep the ledger ``` The dApp runs on http://localhost:3012, its token registry on http://localhost:3013 and Wallet -Gateway on http://localhost:3030. Both examples share the LocalNet, so stop one before you start the -other. +Gateway on http://localhost:3030. The examples share the LocalNet, so stop one before you start +another. + +### Installer + +```bash +pnpm -C canton-create try # pack the installer and every example into canton-create/.try +pnpm -C canton-create try:clean # stop the stack, reset the LocalNet and remove .try +``` + +`try` then prints the commands that run them there, as a user would. + +## Where to read next + +- [`architecture.md`](architecture.md) maps the stack: data flow, services and ports, auth, orchestration and packaging; +- each package's `README.md` says what it is and how to use it, and its `architecture.md` how it works: [`canton-connect`](canton-connect/architecture.md), [`canton-dappbooster`](canton-dappbooster/architecture.md), [`canton-create`](canton-create/architecture.md), [`amulet-vesting`](example-dapps/amulet-vesting/architecture.md) and [`token-vesting`](example-dapps/token-vesting/architecture.md). `canton-theme` has none: the split between components and theme is in canton-dappbooster's; +- the `CLAUDE.md` files hold the rules for writing code here. They are addressed to agents and bind humans too: [`CLAUDE.md`](CLAUDE.md) for the repo, and one in each package for its deltas. ## Before opening a pull request diff --git a/README.md b/README.md index cd8c1cea..c30f1029 100644 --- a/README.md +++ b/README.md @@ -20,3 +20,7 @@ pnpm create canton-dappbooster ``` More details are available in the [documentation](https://docs.dappbooster.cc/). + +## Contributing + +To work on this repository, see [CONTRIBUTING.md](https://github.com/BootNodeDev/canton-dappbooster/blob/main/CONTRIBUTING.md). diff --git a/architecture.md b/architecture.md index 7c70c41b..59dd06cc 100644 --- a/architecture.md +++ b/architecture.md @@ -7,11 +7,11 @@ | LocalNet (external: [`BootNodeDev/canton-barebones`](https://github.com/BootNodeDev/canton-barebones)) | Node command-line tool over Docker Compose + the official Splice LocalNet bundle | Starts `sv + app-user`. A pinned dev dependency; `pnpm dev:amulet-vesting` scaffolds its config into the gitignored `.canton-localnet/` | | Wallet Gateway (external: [`canton-network/wallet`](https://github.com/canton-network/wallet)) | Node + Express + SQLite | The CIP-0103 wallet the dApp connects to: it holds the session, signs through the participant, and serves its own user UI. A root dev dependency, which `pnpm dev:amulet-vesting` runs on the host from `canton-create/scaffold/localnet/wallet-gateway.config.json` | | `scripts/` | Node + Bash + JSON | The root tooling: the doc, anatomy and version checks, the release bump, the `typedoc` configs and the gitleaks installer. See [`CLAUDE.md`](CLAUDE.md) | -| `example-dapps/amulet-vesting/` | Vite + React + Ark UI + Tailwind v4 + `zustand` + react-router + Daml | Canton Coin **vesting** dApp, published as an example. Every read and write goes through the connected CIP-0103 wallet via `canton-connect`. Its `daml/` holds the `amulet-vesting` DAR: the vesting factory, proposal, contract and residual-claim templates. They hold real Canton Coin in escrow as a Splice `LockedAmulet`, and come vendored from [`BootNodeDev/cc-vesting-contracts`](https://github.com/BootNodeDev/cc-vesting-contracts). The build fetches its Splice data-dependencies rather than committing them. `scripts/bootstrap-vesting.mjs` creates the operator and its factory | -| `example-dapps/token-vesting/` | Vite + React + Ark UI + Tailwind v4 + `zustand` + react-router | The same **vesting** dApp over a `DBT` instrument from [`BootNodeDev/canton-token-forge`](https://github.com/BootNodeDev/canton-token-forge), published as an example. Its `vesting` and `canton-token-forge` DARs ship as binaries in `vendor/`, so it builds no contract. `scripts/bootstrap-vesting.mjs` uploads them and creates the operator, its factory and the instrument; `scripts/registry.sh` runs the canton-token-forge registry the dApp reads the instrument from | +| `example-dapps/amulet-vesting/` | Vite + React + Ark UI + Tailwind v4 + `zustand` + react-router + Daml | Canton Coin **vesting** dApp, published as an example. Every read and write goes through the connected CIP-0103 wallet via `canton-connect`. Its `daml/` holds the `amulet-vesting` DAR: the vesting factory, proposal, contract and residual-claim templates. They hold real Canton Coin in escrow as a Splice `LockedAmulet`, and come vendored from [`BootNodeDev/cc-vesting-contracts`](https://github.com/BootNodeDev/cc-vesting-contracts). The build fetches its Splice data-dependencies rather than committing them. `scripts/bootstrap-vesting.mjs` creates the operator and its factory. See [`example-dapps/amulet-vesting/architecture.md`](example-dapps/amulet-vesting/architecture.md) | +| `example-dapps/token-vesting/` | Vite + React + Ark UI + Tailwind v4 + `zustand` + react-router | The same **vesting** dApp over a `DBT` instrument from [`BootNodeDev/canton-token-forge`](https://github.com/BootNodeDev/canton-token-forge), published as an example. Its `vesting` and `canton-token-forge` DARs ship as binaries in `vendor/`, so it builds no contract. `scripts/bootstrap-vesting.mjs` uploads them and creates the operator, its factory and the instrument; `scripts/registry.sh` runs the canton-token-forge registry the dApp reads the instrument from. See [`example-dapps/token-vesting/architecture.md`](example-dapps/token-vesting/architecture.md) | | `canton-create/` | TypeScript + Ink + React 19 + `tsdown` | The `create-canton-dappbooster` installer. Its `scaffold/daml-tooling/` and `scaffold/localnet/` scripts are also the local loop: the Splice dependency fetch, the DAR build and upload, the token mint and `dev-stack.sh`. See [`canton-create/architecture.md`](canton-create/architecture.md) | -| `canton-connect/` | TypeScript + React 19 | wagmi-style hooks wrapping the `dapp-sdk` facade | -| `canton-dappbooster/` | TypeScript + React 19 + `tsdown` | L2 headless UI components, zero styling, plus the theme runtime and the pure utilities under the components, exact-decimal amounts included | +| `canton-connect/` | TypeScript + React 19 | wagmi-style hooks wrapping the `dapp-sdk` facade. See [`canton-connect/architecture.md`](canton-connect/architecture.md) | +| `canton-dappbooster/` | TypeScript + React 19 + `tsdown` | L2 headless UI components, zero styling, plus the theme runtime and the pure utilities under the components, exact-decimal amounts included. See [`canton-dappbooster/architecture.md`](canton-dappbooster/architecture.md) | | `canton-theme/` | CSS | L3 plain-CSS theme: `--cnc-*` tokens + styled defaults | Each subproject's `architecture.md` is the index of its own seams. A subsystem that needs more than diff --git a/canton-connect/README.md b/canton-connect/README.md index 44fb2220..52887437 100644 --- a/canton-connect/README.md +++ b/canton-connect/README.md @@ -1,87 +1,152 @@ -# canton-connect +# @bootnodedev/canton-connect > [Canton dAppBooster](https://dappbooster.cc) is an open-source stack by BootNode for building Canton dApps on your own machine: a local Canton Network stack, wagmi-style hooks, UI components and CIP-0103 wallet support, wired together from the local network up to the UI. This package is its hooks layer: the wallet connection and the ledger reads. The complete kit, the installer and the guides live at [dappbooster.cc](https://dappbooster.cc). -React hooks for connecting a Canton dApp to a CIP-0103 wallet, in the style of wagmi. They sit on top of the `DappSDK` facade from `@canton-network/dapp-sdk`. +React hooks for connecting a Canton dApp to a CIP-0103 wallet, in the style of wagmi. They wrap the `DappSDK` facade from `@canton-network/dapp-sdk`. You don't need to call the SDK yourself, and the hooks take its own parameter types. [Coming from wagmi](https://components.dappbooster.cc/documents/_bootnodedev_canton-connect.Coming_from_wagmi.html) maps the hook results to what you already know. -## Why +Every hook and config field has JSDoc, shown in your editor and published at [components.dappbooster.cc](https://components.dappbooster.cc/); start with `CantonConnectProvider` and `CantonConnectConfig`. [`architecture.md`](https://github.com/BootNodeDev/canton-dappbooster/blob/main/canton-connect/architecture.md) maps the seams, with a chapter each on the [connection machine](https://github.com/BootNodeDev/canton-dappbooster/blob/main/canton-connect/architecture/connection-machine.md) and the [popup close guard](https://github.com/BootNodeDev/canton-dappbooster/blob/main/canton-connect/architecture/popup-close-guard.md). [`CLAUDE.md`](https://github.com/BootNodeDev/canton-dappbooster/blob/main/canton-connect/CLAUDE.md) has the rules for working on the hooks. -`dapp-sdk` already does wallet discovery, the connect picker, the session and every transport (browser extension, WalletConnect, remote gateway). What it does not ship is React hooks, so this package adds them. You never call the SDK yourself. You install it as a peer, and the hooks take the SDK's own parameter types. +## Installation -The alternative is [`@partylayer/react`](https://partylayer.xyz), which comes with its own wallet adapters. This package wraps Digital Asset's official SDK instead, since these dApps depend on it anyway, and it stays thin enough to remove later. If you are coming from wagmi, [coming-from-wagmi.md](https://github.com/BootNodeDev/canton-dappbooster/blob/main/canton-connect/coming-from-wagmi.md) maps the hook results to what you already know. +With npm: -## Why a state machine - -On paper the connection has four states: idle, connecting, connected, disconnected. In practice the hard part is canceling work after the state that started it is gone. A picker the user closed, a lock racing the account read, a connect requested in the middle of a disconnect, a wallet that answers late or never. We first handled those one by one and ended up with five separate races (#76). A state machine puts them in one model. Work invoked by a state is canceled when that state is left, and the combinations that used to be bugs (connected with no party, an error next to a live session) become named states such as `session.unauthenticated` and `session.authenticated.unavailable`. - -Most of that machinery exists to work around gaps in `@canton-network/dapp-sdk`, not because the domain is complex (see the table). Once those gaps close upstream, the machine shrinks to its floor: idle versus disconnected, the account-read states, and the lock/disconnect ambiguity in CIP-0103. At that size a lighter store wins on bundle size. We tried a zustand rewrite as a spike and got full behavioral parity, but the only gains were bundle size and one dependency less, not less logic. So the plan is to switch once the gaps close. Until then the machine pays for itself. - -| dapp-sdk gap | what it costs us | gone when | -|---|---|---| -| `connect()` can't be aborted; a closed popup hangs it (#49) | `guardedConnect`, `settleAbandonedConnect`, `PickerClosedError`, the `retiring` state, `retireSdk`'s picker swap | `connect(signal)` truly aborts | -| `init()` caches a rejected promise forever | `retireSdk`, the `retiring` state, `InitFailedError` | `init()` retries after a failure | -| `disconnect()` has no timeout (#105) | `DISCONNECT_TIMEOUT_MS`, and `retireSdk` when it fires | `disconnect()` times out itself | -| lock and wallet-side disconnect are one push | `session.unauthenticated`, party-dropped-on-lock | CIP-0103 separates them (spec, not SDK) | - -## Status +```bash +npm install @bootnodedev/canton-connect @canton-network/dapp-sdk @walletconnect/sign-client +``` -Published to npm as `@bootnodedev/canton-connect`. `example-dapps/amulet-vesting` and `example-dapps/token-vesting` in this repo use it too, and link the local folder as long as the version satisfies the declared range. +With pnpm: -## Install +```bash +pnpm add @bootnodedev/canton-connect @canton-network/dapp-sdk @walletconnect/sign-client +``` -Install these peers next to it: `@canton-network/dapp-sdk`, `react` 19 and `@walletconnect/sign-client`. The last one is required here even though `dapp-sdk` marks it optional. `dapp-sdk` imports it statically at the top of its bundle (checked on 1.8.0), so it has to be present whether or not you set `walletConnectProjectId`. Only the session is lazy: `SignClient.init()` runs when a pairing starts, not at import time. +> [!NOTE] +> `@walletconnect/sign-client` is required even though `dapp-sdk` marks it optional: `dapp-sdk` imports it statically (checked on 1.8.0), with or without `walletConnectProjectId`. Only the session is lazy; `SignClient.init()` runs when a pairing starts. ## Usage ```tsx import { CantonConnectProvider, - useConnect, + ConnectCancelledError, useAccount, - useWalletStatus, - useSignMessage, + useConnect, useExecute, - useLedger, + usePartyType, + useSignMessage, + useWalletStatus, } from '@bootnodedev/canton-connect' -function App() { +// Wrap the app once; networkId is the CAIP-2 chain the wallet must serve +function Root() { return ( - + ) } -function Dapp() { - const { connect, isPending, isConnected, error } = useConnect() +// Connect +function Connect() { + const { connect, isPending, error } = useConnect() + + // connect() opens the wallet picker; closing it rejects with ConnectCancelledError + const open = () => connect().catch(() => undefined) + + // a wallet failure lands in error; a cancel is not worth a message + const failure = error instanceof ConnectCancelledError ? undefined : error + + return ( + <> + + {failure !== undefined &&

{failure.message}

} + + ) +} + +// Session state +function Session() { const { account } = useAccount() const { isLocked } = useWalletStatus() - const { signMessage } = useSignMessage() - const { execute } = useExecute() - const { ledgerApi } = useLedger() - - if (!isConnected) { - return ( -
- - {error !== undefined &&

{error.message}

} -
- ) - } + // the wallet keeps the session but answers nothing until the user unlocks it if (isLocked) { return

Wallet locked. Unlock it to continue.

} - // ... your dApp: account.partyId, signMessage(text), execute(params), ledgerApi(params) + // nothing connected, or the account read still in flight + if (account === undefined) { + return

No session.

+ } + + return {account.partyId} +} + +// Sign a message +function Sign() { + const { signMessage, signature, isPending, error } = useSignMessage() + + // throws without a session, while locked or with no party; a wallet refusal lands in error too + const sign = () => signMessage('Approve vesting claim').catch(() => undefined) + + return ( + <> + + {signature !== undefined && {signature}} + {error !== undefined &&

{error.message}

} + + ) +} + +// Submit commands +function Claim() { + const { execute, lastTx, isPending } = useExecute() + + // acts as the party useAccount reports and resolves once executed; the same guards as signMessage + const claim = () => execute({ commandId: 'claim-1', commands }).catch(() => undefined) + + // the wallet pushes pending, signed, executed or failed into lastTx as it goes + return ( + <> + + {lastTx !== undefined &&

{lastTx.status}

} + + ) +} + +// Local or external party +function SignGuard() { + const { readPartyType, isReady } = usePartyType() + + // the reference gateway refuses signMessage for a local party, so ask before offering it + const check = async () => { + if ((await readPartyType()) === 'local') { + toast.error('This wallet cannot sign messages for a local party') + } + } + + return } ``` -`connect()` opens the SDK's wallet picker, a popup by default. There is no mode argument, the picker is where the wallet gets chosen. Closing it rejects with `ConnectCancelledError`. Check for it with `instanceof`, not by message. Whether `error` records it too depends on which side saw the close, so don't rely on that. +### Reading the ledger directly + +The participant's JSON API, untyped, for what the hooks above do not cover. + +```tsx +import { useLedger } from '@bootnodedev/canton-connect' -`signMessage`, `execute` and `ledgerApi` refuse to run without a session, and again while the wallet reports it is not authenticated. That second case is `isLocked`, and it can happen after a successful connect. `signMessage` and `execute` also need a party, `ledgerApi` does not. The reference gateway refuses `signMessage` for a local party as well; `usePartyType().readPartyType()` tells you whether a party is local or external. The SDK's status has a single `isConnected` flag, so a lock and a wallet-side disconnect look the same from here. `useLedger().isReady` covers both, and `useAccount().account` is `undefined` for as long as it lasts. Gate session content on the party, and use `isLocked` only to explain why it went away. +function UserRights() { + const { ledgerApi, isReady } = useLedger() + + // keep the route a template and put the values in path; the gateway refuses an interpolated id + const load = () => + ledgerApi({ requestMethod: 'get', resource: '/v2/users/{user-id}/rights', path: { 'user-id': 'alice' } }) + + // isReady is false without a session and while locked, when ledgerApi would throw + return +} +``` ### Connecting through a Wallet Gateway @@ -97,7 +162,7 @@ const config = { ``` > [!NOTE] -> The dapp-sdk wallet picker also lets a user paste any gateway URL and connect to it without the dApp listing it. That session does not survive a reload. One with a gateway listed here does. +> The picker also lets a user paste any gateway URL. That session does not survive a reload; one with a gateway listed here does. Details in [architecture.md](https://github.com/BootNodeDev/canton-dappbooster/blob/main/canton-connect/architecture.md#remote-gateway). @@ -111,18 +176,12 @@ const config = { } ``` -`networkId` is the Canton network the dApp targets, as a CAIP-2 chain id. - Details in [architecture.md](https://github.com/BootNodeDev/canton-dappbooster/blob/main/canton-connect/architecture.md#walletconnect). -## Reference - -Every hook and config field has JSDoc. Your editor shows it at the call site, and the same text is published at [components.dappbooster.cc](https://components.dappbooster.cc/). Start with `CantonConnectProvider` and `CantonConnectConfig`. - ## Testing helpers -- `createMockAdapter()`, exported from the package root, is a `ProviderAdapter` that answers `connect`, `disconnect`, `status` and `listAccounts` with no wallet installed, so a dApp or a test can connect and show a party. Every other method throws and names itself. -- `@bootnodedev/canton-connect/testing` has the rest: `createFakeWallet()`, a CIP-0103 extension driven over `postMessage` that goes through the SDK's real announce and detect path; `createAutoPicker()`, a headless picker so `connect()` runs without a popup; `FakeSessionProvider`, the context rehydrated at the session you ask for with no SDK behind it; and `pause(ms)`, a sleep on real timers. +- `createMockAdapter()`, from the package root: a `ProviderAdapter` that answers `connect`, `disconnect`, `status` and `listAccounts` with no wallet installed, so a dApp or a test can connect and show a party. Every other method throws. +- `@bootnodedev/canton-connect/testing`: `createFakeWallet()`, a CIP-0103 extension driven over `postMessage` through the SDK's real announce and detect path; `createAutoPicker()`, a headless picker so `connect()` needs no popup; `FakeSessionProvider`, the context at the session you ask for, no SDK behind it; `pause(ms)`, a sleep on real timers. ```tsx import { CantonConnectProvider, createMockAdapter } from '@bootnodedev/canton-connect' @@ -135,10 +194,19 @@ const config = { } ``` -## Architecture +## Working on the package + +### Scripts + +| Script | What it does | +| --- | --- | +| `pnpm build` | tsdown build into `dist/`: ESM `index.js` and `testing/index.js`, each with its `.d.ts` | +| `pnpm test` | vitest with jsdom and Testing Library, run against `src` | +| `pnpm coverage` | the same suite under v8, excluding `testing/`, `mock/` and the barrel | +| `pnpm typecheck` | `tsc --noEmit` | -[`architecture.md`](https://github.com/BootNodeDev/canton-dappbooster/blob/main/canton-connect/architecture.md) maps the seams. The chapters under `architecture/` cover the connection machine and the popup close guard. +Published to npm. The examples in `example-dapps/` use it and link this folder while their range matches its version. -## Testing +### Why a state machine -`pnpm test` runs vitest with jsdom and Testing Library. `pnpm coverage` runs the same suite under v8, excluding `testing/`, `mock/` and the barrel. +Four states on paper. In practice `dapp-sdk` leaves gaps, listed with their cost under [The lifecycle](https://github.com/BootNodeDev/canton-dappbooster/blob/main/canton-connect/architecture.md#the-lifecycle-machine): a connect that cannot be aborted, an init that caches its failure, a disconnect with no timeout, a lock indistinguishable from a disconnect. Handled one by one, those were five races ([#76](https://github.com/BootNodeDev/canton-dappbooster/issues/76)), so the SDK is wrapped in a state machine to ensure that leaving a state cancels whatever it started. The machine shrinks as the gaps close upstream; the machine itself is in [connection-machine.md](https://github.com/BootNodeDev/canton-dappbooster/blob/main/canton-connect/architecture/connection-machine.md). diff --git a/canton-connect/architecture.md b/canton-connect/architecture.md index 2b76dd7b..363bcc49 100644 --- a/canton-connect/architecture.md +++ b/canton-connect/architecture.md @@ -61,8 +61,9 @@ flowchart LR ### The lifecycle: `machine/` -One model of connecting, session, lock and disconnect, so the impossible combinations (an account -with no live session, an error beside a live session) cannot be built. Three decisions carry the weight: +The machine is one model of connecting, session, lock and disconnect. On paper that is four states: idle, connecting, connected, disconnected. The hard part is canceling work after the state that started it is gone: a picker the user closed, a lock racing the account read, a connect requested in the middle of a disconnect, a wallet that answers late or never. Handled one by one, those were five separate races (#76). In the machine, work invoked by a state is canceled when that state is left. + +The combinations that used to be bugs (connected with no party, an error next to a live session) are named states, such as `session.unauthenticated` and `session.authenticated.unavailable`. Three decisions carry the weight: - `idle` is not `disconnected`. `idle` means the boot restore has not answered; `disconnected` means it has, and there is nothing. @@ -72,6 +73,15 @@ with no live session, an error beside a live session) cannot be built. Three dec - The account read is a child machine, so a failed read cannot end the session; only the promise carries the failure. +Most of the machine works around gaps in `@canton-network/dapp-sdk`, not domain complexity: + +| dapp-sdk gap | what it costs | gone when | +|---|---|---| +| `connect()` can't be aborted; a closed popup hangs it (#49) | `guardedConnect`, `settleAbandonedConnect`, `PickerClosedError`, the `retiring` state, `retireSdk`'s picker swap | `connect(signal)` truly aborts | +| `init()` caches a rejected promise forever | `retireSdk`, the `retiring` state, `InitFailedError` | `init()` retries after a failure | +| `disconnect()` has no timeout | `DISCONNECT_TIMEOUT_MS`, and `retireSdk` when it fires | `disconnect()` times out itself | +| lock and wallet-side disconnect are one push | `session.unauthenticated`, and `account` cleared on leaving `session.authenticated` | CIP-0103 separates them (spec, not SDK) | + ### The bridges `connect()` and `disconnect()` are a send plus a wait on a tag, so the promise over a transition @@ -178,5 +188,6 @@ announce, detect and connect path. `createAutoPicker` answers the picker headles - Themed in-page picker (#50): its PR (#63) was closed unmerged, so the SDK popup is still the only picker; a new attempt starts from the `walletPicker` seam. +- A lighter store in place of the machine. Once the gaps in the table under [The lifecycle](#the-lifecycle-machine) close upstream, the machine shrinks to its floor (idle versus disconnected, the account-read states, and the lock/disconnect ambiguity in CIP-0103), where a lighter store wins on bundle size. A zustand rewrite, tried as a spike, reached full behavioral parity, but its only gains were bundle size and one dependency less, not less logic, so the switch waits for those gaps to close. For the stack around this package: the root [`architecture.md`](../architecture.md). diff --git a/canton-connect/src/connectError.ts b/canton-connect/src/connectError.ts index 730015bf..b2bbeb3c 100644 --- a/canton-connect/src/connectError.ts +++ b/canton-connect/src/connectError.ts @@ -34,6 +34,8 @@ export class InitFailedError extends Error { /** * A connect the user walked away from: the picker was closed rather than a wallet failing. + * `useConnect().error` records it only when the SDK saw the close rather than this package's popup + * guard, so detect a cancel on the rejected `connect()` promise and use `error` to exclude it. * * @example * if (error !== undefined && !(error instanceof ConnectCancelledError)) { diff --git a/canton-connect/src/hooks/useAccount.ts b/canton-connect/src/hooks/useAccount.ts index 860ec9b0..fe06efc3 100644 --- a/canton-connect/src/hooks/useAccount.ts +++ b/canton-connect/src/hooks/useAccount.ts @@ -22,8 +22,11 @@ export interface UseAccountResult { * @throws with no {@link CantonConnectProvider} above it. * * @example - * const { account, isConnected } = useAccount() - * isConnected && {account?.hint} + * const { account } = useAccount() + * + * if (account === undefined) return

No session.

+ * + * return {account.hint} * * @category Hooks */ diff --git a/canton-connect/src/hooks/useConnect.ts b/canton-connect/src/hooks/useConnect.ts index c8642d86..d142cbd3 100644 --- a/canton-connect/src/hooks/useConnect.ts +++ b/canton-connect/src/hooks/useConnect.ts @@ -23,8 +23,9 @@ export interface UseConnectResult { /** * Connects the wallet and reports that transition. `connect` takes no argument: the picker chooses - * the wallet, so there is no mode to pass. Gate a pending face on `isPending` and - * session-dependent content on `useAccount().account`, not on `isConnected`. + * the wallet, so there is no mode to pass. Closing it rejects with {@link ConnectCancelledError}. + * Gate a pending face on `isPending` and session-dependent content on `useAccount().account`, not + * on `isConnected`. * * @throws with no {@link CantonConnectProvider} above it, as every hook here does. * diff --git a/canton-connect/src/hooks/useExecute.ts b/canton-connect/src/hooks/useExecute.ts index d370d25a..e24838eb 100644 --- a/canton-connect/src/hooks/useExecute.ts +++ b/canton-connect/src/hooks/useExecute.ts @@ -40,7 +40,11 @@ export interface UseExecuteResult { * in `error`, always as an `Error`: see {@link toError}. * * @example + * const { account } = useAccount() * const { execute, lastTx } = useExecute() + * + * if (account === undefined) return

Connect and unlock the wallet first.

+ * * await execute({ commandId: 'claim-1', commands }) * lastTx?.status // 'pending' | 'signed' | 'executed' | 'failed' * diff --git a/canton-connect/src/hooks/useLedger.ts b/canton-connect/src/hooks/useLedger.ts index 7ce01a7a..1c7f1ca5 100644 --- a/canton-connect/src/hooks/useLedger.ts +++ b/canton-connect/src/hooks/useLedger.ts @@ -26,7 +26,10 @@ export interface UseLedgerResult { * throws too, always as an `Error`: see {@link toError}. * * @example - * const { ledgerApi } = useLedger() + * const { ledgerApi, isReady } = useLedger() + * + * if (!isReady) return

Connect and unlock the wallet first.

+ * * await ledgerApi({ * requestMethod: 'get', * resource: '/v2/users/{user-id}/rights', diff --git a/canton-connect/src/hooks/usePartyType.ts b/canton-connect/src/hooks/usePartyType.ts index 6abb2f01..4a38bf6d 100644 --- a/canton-connect/src/hooks/usePartyType.ts +++ b/canton-connect/src/hooks/usePartyType.ts @@ -43,7 +43,10 @@ export interface UsePartyTypeResult { * read, always as an `Error`: see {@link toError}. * * @example - * const { readPartyType } = usePartyType() + * const { readPartyType, isReady } = usePartyType() + * + * if (!isReady) return

Connect and unlock the wallet first.

+ * * if ((await readPartyType()) === 'local') { * toast.error('This wallet cannot sign messages for a local party') * } diff --git a/canton-connect/src/hooks/useSignMessage.ts b/canton-connect/src/hooks/useSignMessage.ts index c73b5584..d236ee40 100644 --- a/canton-connect/src/hooks/useSignMessage.ts +++ b/canton-connect/src/hooks/useSignMessage.ts @@ -24,7 +24,11 @@ export interface UseSignMessageResult { * `error`, always as an `Error`: see {@link toError}. * * @example + * const { account } = useAccount() * const { signMessage } = useSignMessage() + * + * if (account === undefined) return

Connect and unlock the wallet first.

+ * * const signed = await signMessage('Approve vesting claim') * * @category Hooks diff --git a/canton-connect/src/hooks/useWalletStatus.ts b/canton-connect/src/hooks/useWalletStatus.ts index 2180781b..44f22ff8 100644 --- a/canton-connect/src/hooks/useWalletStatus.ts +++ b/canton-connect/src/hooks/useWalletStatus.ts @@ -23,7 +23,9 @@ export interface UseWalletStatusResult { * * @example * const { isConnected, isLocked } = useWalletStatus() + * * if (!isConnected) return

No session.

+ * * return isLocked ?

Unlock your wallet to continue.

:

Ready.

* * @category Hooks diff --git a/canton-connect/src/index.ts b/canton-connect/src/index.ts index 4953d5a4..f1f83cb6 100644 --- a/canton-connect/src/index.ts +++ b/canton-connect/src/index.ts @@ -5,9 +5,6 @@ * @module Main */ -// canton-connect — wagmi-style React hooks for connecting Canton dApps -// to CIP-0103 wallets. See README.md for the design rationale. - export type { CantonConnectProviderProps } from '#src/CantonConnectProvider' export { CantonConnectProvider, useCantonConnectContext } from '#src/CantonConnectProvider' export { ConnectCancelledError, toError } from '#src/connectError' diff --git a/canton-create/README.md b/canton-create/README.md index 3164272f..643c6b2f 100644 --- a/canton-create/README.md +++ b/canton-create/README.md @@ -1,30 +1,28 @@ # Canton dAppBooster installer +Scaffolds a Canton dApp: a React and Vite app on the Canton dApp kit, optionally with a local network. + +Terminal recording of the Canton dAppBooster installer. + ## Requirements -- Node >= 22.12.0 to run the installer, and >= 24.15.0 for the project it makes, which the Canton - dApp kit libraries require +- Node >= 24.15.0; - npm, pnpm, yarn or bun. The installer installs the packages with the one that runs it. -The local network also needs Docker and pnpm, and building a contract needs `dpm`. Check the -[documentation](https://docs.dappbooster.cc/) for more info. +The local network also needs Docker and pnpm, and building a contract needs `dpm`. Check the [documentation](https://docs.dappbooster.cc/) for more info. ## Interactive mode -Terminal recording of the Canton dAppBooster installer. - ```shell pnpm create canton-dappbooster ``` -The wizard asks for the project name, the local network and the dApp. It then copies the dApp, -creates `.env` from `.env.example`, installs the packages, makes a git repository with a first -commit, and prints the next steps. If a step fails or you press Ctrl-C, it removes what it wrote. +The wizard asks for the project name, the local network and the dApp. It then copies the dApp, creates `.env` from `.env.example`, installs the packages, makes a git repository with a first commit, and prints the next steps. If a step fails or you press Ctrl-C, it removes what it wrote. Alternatively, you can start with a preselected dApp or local network: -- **Amulet Vesting:** a vesting dApp frontend and its contracts. -- **Token Vesting:** a vesting dApp over a canton-token-forge token, with its registry. +- **Amulet Vesting:** a vesting dApp frontend and its contracts; +- **Token Vesting:** a vesting dApp over a canton-token-forge token, with its registry; - **Barebones:** a minimal local Canton stack for developer workflows. With npm: @@ -45,13 +43,12 @@ pnpm create canton-dappbooster --localnet # Barebones local netw ## Non-interactive mode (agents and CI) -Run without an interactive terminal, the installer asks nothing, and the choices the flags leave out -take the defaults marked below. +Without an interactive terminal the installer asks nothing, and the choices the flags leave out take the defaults marked below. | Flag | Purpose | |---|---| | `[directory]` | Folder to create the project in, new or empty. Required without a terminal | -| `--example ` | Start from an example dApp instead of the starter. The value is a name from the table below, with or without `@`, or a scoped package, a tarball, a git URL or a folder | +| `--example ` | Start from an example dApp instead of the starter. The value is a name from the dApps table, with or without `@`, or a scoped package, a tarball, a git URL or a folder | | `--localnet` | Add the Barebones local network | | `--skip-install` | Write the files and stop before installing the packages | | `--disable-git` | Skip the git repository. The installer also skips it when the folder is already inside one | @@ -62,6 +59,9 @@ pnpm create canton-dappbooster my-dapp # s pnpm create canton-dappbooster my-dapp --example amulet-vesting --localnet # Amulet Vesting with Barebones ``` +> [!NOTE] +> With npm, put `--` before the flags. + ### dApps | dApp | Flag | Default | Description | @@ -77,17 +77,4 @@ pnpm create canton-dappbooster my-dapp --example amulet-vesting --localnet # A | `No localnet` | none | ✓ | You provide your own | | `Barebones` | `--localnet` | | Minimal local Canton stack for developer workflows | -## Installer development - -```shell -git clone git@github.com:BootNodeDev/canton-dappbooster.git -cd canton-dappbooster -nvm use -corepack enable -pnpm i -pnpm -C canton-create try -``` - -`try` packs the installer and every example dApp into `canton-create/.try` and prints the commands -that run them there, as a user would. `pnpm -C canton-create try:clean` stops whatever those -projects started and removes `.try`. +More details are available in the [documentation](https://docs.dappbooster.cc/). How a run builds a project is in [`architecture.md`](https://github.com/BootNodeDev/canton-dappbooster/blob/main/canton-create/architecture.md). diff --git a/canton-create/demo.svg b/canton-create/demo.svg index 7f880bc6..b74eaf4b 100644 --- a/canton-create/demo.svg +++ b/canton-create/demo.svg @@ -1 +1 @@ -$pnpmcreatecanton-dappbooster╔╦╗╔═╗╔═╗╔═╗╔╗╔═╗╔═╗╔═╗╔╦╗╔═╗╦═╗║║╠═╣╠═╝╠═╝╠╩╗║║║║╚═╗║║╣╠╦╝═╩╝╩╩╩╩╚═╝╚═╝╚═╝╚═╝╩╚═╝╩╚═CantonThefoldertocreatetheprojectin,neworempty.Projectname:my-dapp───────────────────────────LocalNetInstallation────────────────────────────ChoosewhethertoinstallaCantonlocalnetwork.You'llprovideyourown.MinimallocalCantonstackfordeveloperworkflows.LocalNet:Barebones─────────────────────────────dAppInstallation──────────────────────────────dApp:StarterdApp──────────────────────────────────Scaffold──────────────────────────────────✔Copyingstarterintomy-dapp✔Created.envfrom.env.example────────────────────────────────Installation────────────────────────────────⠙Installingpackageswithpnpm⠸Installingpackageswithpnpm⠴Installingpackageswithpnpm⠧Installingpackageswithpnpm⠏Installingpackageswithpnpm$$pn$pnpm$pnpmc$pnpmcre$pnpmcreat$pnpmcreate$pnpmcreateca$pnpmcreatecant$pnpmcreatecanton$pnpmcreatecanton-d$pnpmcreatecanton-dap$pnpmcreatecanton-dappb$pnpmcreatecanton-dappboo$pnpmcreatecanton-dappboostProjectname:my-canton-dappProjectname:myProjectname:my-dProjectname:my-dapProjectname:my-dapp❯NolocalnetBarebonesNolocalnet❯BarebonesChoosethedAppyourprojectstartsfrom.❯StarterdAppBarebones,connectstoawalletandgivesyouasamplecontract.AmuletVestingAvestingdAppfrontendanditscontracts.TokenVestingAvestingdAppoveracanton-token-forgetoken,withitsregistry.⠹Installingpackageswithpnpm \ No newline at end of file +$pnpmcreatecanton-dappbooster╔╦╗╔═╗╔═╗╔═╗╔╗╔═╗╔═╗╔═╗╔╦╗╔═╗╦═╗║║╠═╣╠═╝╠═╝╠╩╗║║║║╚═╗║║╣╠╦╝═╩╝╩╩╩╩╚═╝╚═╝╚═╝╚═╝╩╚═╝╩╚═CantonThefoldertocreatetheprojectin,neworempty.Projectname:my-dapp───────────────────────────LocalNetInstallation────────────────────────────ChoosewhethertoinstallaCantonlocalnetwork.You'llprovideyourown.MinimallocalCantonstackfordeveloperworkflows.LocalNet:Barebones─────────────────────────────dAppInstallation──────────────────────────────dApp:StarterdApp──────────────────────────────────Scaffold──────────────────────────────────✔Copyingstarterintomy-dapp✔Created.envfrom.env.example────────────────────────────────Installation────────────────────────────────⠙Installingpackageswithpnpm$$pnp$pnpmc$pnpmcrea$pnpmcreate$pnpmcreatecan$pnpmcreatecanton$pnpmcreatecanton-da$pnpmcreatecanton-dappb$pnpmcreatecanton-dappboosProjectname:my-canton-dappProjectname:myProjectname:my-dProjectname:my-dapProjectname:my-dapp❯NolocalnetBarebonesNolocalnet❯BarebonesChoosethedAppyourprojectstartsfrom.❯StarterdAppBarebones,connectstoawalletandgivesyouasamplecontract.AmuletVestingAvestingdAppfrontendanditscontracts.TokenVestingAvestingdAppoveracanton-token-forgetoken,withitsregistry.⠸Installingpackageswithpnpm⠴Installingpackageswithpnpm⠧Installingpackageswithpnpm⠏Installingpackageswithpnpm⠹Installingpackageswithpnpm \ No newline at end of file diff --git a/canton-create/package.json b/canton-create/package.json index ad2a5ad0..79a3eb31 100644 --- a/canton-create/package.json +++ b/canton-create/package.json @@ -26,7 +26,7 @@ "!scaffold/*/scripts/*.test.mjs" ], "engines": { - "node": ">=22.12.0" + "node": ">=24.15.0" }, "scripts": { "build": "tsdown", diff --git a/canton-create/scaffold/daml-tooling/README.md b/canton-create/scaffold/daml-tooling/README.md index 788686eb..b604d89e 100644 --- a/canton-create/scaffold/daml-tooling/README.md +++ b/canton-create/scaffold/daml-tooling/README.md @@ -1,17 +1,16 @@ ## Contract -`daml/` holds the app's Daml model, and `dpm` from the Daml SDK 3.5 builds it. `daml/daml.yaml` -pins a tested patch release, and any installed 3.5 release builds the model. +`daml/` holds the app's Daml model, and `dpm` from the Daml SDK 3.5 builds it. `daml/daml.yaml` pins a tested patch release, and any installed 3.5 release builds the model. -```bash -pnpm build-dar # fetch the Splice DARs it depends on, then build -pnpm deploy-dar -- daml/.daml/dist/-.dar # upload it to CANTON_JSON_API_URL -``` +| Script | What it does | +| --- | --- | +| `pnpm build-dar` | fetches the Splice DARs it depends on, then builds | +| `pnpm deploy-dar -- daml/.daml/dist/-.dar` | uploads it to `CANTON_JSON_API_URL` | -When `daml/daml.yaml` lists Splice DARs, `build-dar` fetches the ones your network's Splice release -ships. With the local network it reads that release from the LocalNet's config. Without one, set -`SPLICE_TAG` in `.env` to the release your network runs. +When `daml/daml.yaml` lists Splice DARs, `build-dar` fetches the ones your network's Splice release ships. With the local network it reads that release from the LocalNet's config. Without one, set `SPLICE_TAG` in `.env` to the release your network runs. -`deploy-dar` sends the `CANTON_BACKEND_TOKEN` from `.env`. If the project has a `bootstrap` script, -run it after `deploy-dar`. It creates what the model needs on the ledger first. +`deploy-dar` sends the `CANTON_BACKEND_TOKEN` from `.env`. If the project has a `bootstrap` script, run it after `deploy-dar`: it creates on the ledger what the app needs before it can start. + +> [!WARNING] +> Any edit to the model changes the package id, and a participant refuses a second package at the same name and version (`KNOWN_PACKAGE_VERSION`). Redeploying after a source change means bumping `version` in `daml/daml.yaml`, which the participant accepts only for a valid upgrade, or, with the local network, wiping the ledger as the Local network section describes. diff --git a/canton-create/scaffold/localnet/README.md b/canton-create/scaffold/localnet/README.md index dcee9231..854f6354 100644 --- a/canton-create/scaffold/localnet/README.md +++ b/canton-create/scaffold/localnet/README.md @@ -3,26 +3,22 @@ This project also carries a local Canton stack: -- `@bootnodedev/canton-barebones` runs a Splice LocalNet in Docker, with its config in - `.canton-localnet/` -- `@canton-network/wallet-gateway-remote` is [Wallet Gateway](https://github.com/canton-network/wallet), - which the dApp connects to on http://localhost:3030, with its config in `wallet-gateway.config.json` +- `@bootnodedev/canton-barebones` runs a Splice LocalNet in Docker, with its config in `.canton-localnet/`; +- `@canton-network/wallet-gateway-remote` is [Wallet Gateway](https://github.com/canton-network/wallet), which the dApp connects to on http://localhost:3030, with its config in `wallet-gateway.config.json`. It needs Docker running and pnpm, plus `dpm` when the app has a contract. -```bash -pnpm stack up # LocalNet, the contract if there is one, Wallet Gateway, dev server -pnpm stack down # stop everything and keep the ledger -pnpm stack # interactive menu -``` +| Script | What it does | +| --- | --- | +| `pnpm stack up` | LocalNet, the contract if there is one, Wallet Gateway, dev server | +| `pnpm stack down` | stops everything and keeps the ledger | +| `pnpm stack` | interactive menu | -Then pick Wallet Gateway in the dApp's picker and log in with the client secret `unsafe`. Create a -party in the gateway. +Then pick Wallet Gateway in the dApp's picker and log in with the client secret `unsafe`. Create a party in the gateway. -On its first run, `pnpm stack up` mints `CANTON_BACKEND_TOKEN` into `.env`. When the project has a -contract, it then builds and deploys it. It runs the `bootstrap` script if there is one, then the -`stack:up` script, which starts anything else the app needs. `pnpm stack down` runs `stack:down`. To -wipe the ledger, run `canton-barebones reset` inside `.canton-localnet/`. You then have to create -the gateway's parties again. +`pnpm stack up` mints `CANTON_BACKEND_TOKEN` into `.env` when it is missing. When the project has a contract, it builds and deploys it. It then runs the `bootstrap` script if there is one, and the `stack:up` script, which starts anything else the app needs. `pnpm stack down` runs `stack:down`. Each step is also a script of its own: `pnpm mint-token` and `pnpm wallet-gateway`. + +> [!CAUTION] +> To wipe the ledger, run `pnpm exec canton-barebones reset` inside `.canton-localnet/`. You then have to create the gateway's parties again. diff --git a/canton-create/scaffold/starter/README.md b/canton-create/scaffold/starter/README.md index e7ca6bb8..5cfee7f4 100644 --- a/canton-create/scaffold/starter/README.md +++ b/canton-create/scaffold/starter/README.md @@ -2,31 +2,34 @@ A React and Vite app on the Canton dApp kit: -- `@bootnodedev/canton-connect` for the wallet session and its hooks -- `@bootnodedev/canton-dappbooster` for the headless components -- `@bootnodedev/canton-theme` for the default style sheet, which `src/index.css` imports once +- `@bootnodedev/canton-connect` for the wallet session and its hooks; +- `@bootnodedev/canton-dappbooster` for the headless components; +- `@bootnodedev/canton-theme` for the default style sheet, which `src/index.css` imports once. ## Run the app -If the installer skipped the install, run `pnpm install` first. Then start the dev server: +The dev server serves on http://localhost:3012. + +With npm: ```bash -pnpm dev +npm install # only if the installer skipped it +npm run dev ``` -With npm, the command is `npm run dev`. The app serves on http://localhost:3012. +With pnpm: + +```bash +pnpm install # only if the installer skipped it +pnpm dev +``` -`.env` sets `VITE_MOCK_WALLET=true`, so the wallet picker offers a mock wallet, and you can connect -before a real one is reachable. +`.env` sets `VITE_MOCK_WALLET=true`, so the wallet picker offers a mock wallet, and you can connect before a real one is reachable. ## Connect a real wallet -Set `VITE_WALLET_GATEWAY_URL` to a [Wallet Gateway](https://github.com/canton-network/wallet) and it -appears in the picker. The picker also lists any CIP-0103 browser wallet it finds in the page. Unset -`VITE_MOCK_WALLET` when you no longer need the mock. +Set `VITE_WALLET_GATEWAY_URL` in `.env` to a [Wallet Gateway](https://github.com/canton-network/wallet) and it appears in the picker. The picker also lists any CIP-0103 browser wallet it finds in the page. Unset `VITE_MOCK_WALLET` when you no longer need the mock. ## Learn more -- The [documentation](https://docs.dappbooster.cc/) covers the stack and the local network. -- The [components documentation](https://components.dappbooster.cc/) is the reference for the kit's - hooks and components. +The [documentation](https://docs.dappbooster.cc/) covers the stack and the local network, and the [components documentation](https://components.dappbooster.cc/) is the reference for the kit's hooks and components. diff --git a/canton-create/scaffold/starter/package.json b/canton-create/scaffold/starter/package.json index b503bf80..28ddfe98 100644 --- a/canton-create/scaffold/starter/package.json +++ b/canton-create/scaffold/starter/package.json @@ -24,5 +24,8 @@ "@vitejs/plugin-react": "^6.0.0", "typescript": "^5.9.3", "vite": "^8.0.0" + }, + "engines": { + "node": ">=24.15.0" } } diff --git a/canton-create/scripts/record-demo.ts b/canton-create/scripts/record-demo.ts index e99d95ed..c0b84fc2 100755 --- a/canton-create/scripts/record-demo.ts +++ b/canton-create/scripts/record-demo.ts @@ -6,9 +6,11 @@ import { stripVTControlCharacters } from 'node:util' const COLS = 92 const ROWS = 26 -const IDLE_CAP_SECONDS = 2 -const TIME_LIMIT_SECONDS = 15 +const IDLE_CAP_SECONDS = 3 const FRAME_MERGE_SECONDS = 0.15 +const INSTALL_SPINNER_SECONDS = 1 +const TYPING_SECONDS = 0.085 +const COMMAND_TYPING_SECONDS = 0.05 const PROJECT = 'my-dapp' const ENTER = '\r' const DOWN = '\u001B[B' @@ -98,17 +100,17 @@ const expect = async (text: string, timeoutSeconds: number): Promise => { throw new Error(`timed out waiting for "${text}"\n--- tail ---\n${seen.slice(-1500)}`) } -const typeOut = async (text: string): Promise => { +const typeOut = async (text: string, perChar = TYPING_SECONDS): Promise => { for (const char of text) { terminal.write(char) - await pause(0.085) + await pause(perChar) } } try { await expect('$ ', 15) await pause(0.7) - await typeOut('pnpm create canton-dappbooster') + await typeOut('pnpm create canton-dappbooster', COMMAND_TYPING_SECONDS) await pause(0.5) terminal.write(ENTER) @@ -125,20 +127,23 @@ try { terminal.write(ENTER) await expect('Choose the dApp your project starts from', 15) - await pause(1.3) + await pause(2.5) terminal.write(ENTER) - // Past the time limit on purpose, so the trim lands on a whole frame. + // The demo is the wizard, so it ends on the install spinner; a little past the cut is enough. await expect('Installing packages', 60) - await pause(6) + await pause(2) } finally { terminal.close() proc.kill() await proc.exited } +const INSTALL_SPINNER = /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Installing packages/ +const INSTALL_DONE = /✔ Installing packages|Creating a git repository/ let shift = 0 let previous = 0 +let installShownAt: number | undefined const frames: Chunk[] = [] for (const [stamp, data] of events) { const gap = stamp - previous @@ -147,7 +152,14 @@ for (const [stamp, data] of events) { } previous = stamp const capped = Number((stamp - shift).toFixed(6)) - if (capped > TIME_LIMIT_SECONDS) { + const text = stripVTControlCharacters(data) + if (installShownAt === undefined && INSTALL_SPINNER.test(text)) { + installShownAt = capped + } + if ( + installShownAt !== undefined && + (capped - installShownAt > INSTALL_SPINNER_SECONDS || INSTALL_DONE.test(text)) + ) { break } const last = frames.at(-1) diff --git a/canton-dappbooster/README.md b/canton-dappbooster/README.md index 720fe7e9..2a58590a 100644 --- a/canton-dappbooster/README.md +++ b/canton-dappbooster/README.md @@ -4,29 +4,25 @@ Reusable UI components for Canton dApps: reading Canton identifiers (display, truncation, copy-to-clipboard, explorer links) and entering them (validated party-id input). Amounts are the other half: a token-amount field, plus the exact-decimal utilities under it, because a `number` cannot carry a Canton amount without losing digits. -`src/index.ts` is the public API. Every export has JSDoc, which your editor shows at the call site and which is published at [components.dappbooster.cc](https://components.dappbooster.cc/). The wallet buttons and the `useHoldings` hook live under the `/connect` sub-path instead, because they read the wallet session and that pulls in the Canton SDK. The rules for writing new components are in [`CLAUDE.md`](https://github.com/BootNodeDev/canton-dappbooster/blob/main/canton-dappbooster/CLAUDE.md). +Every export carries JSDoc, which your editor shows at the call site and which is published at [components.dappbooster.cc](https://components.dappbooster.cc/). The wallet buttons and the `useHoldings` hook import from `@bootnodedev/canton-dappbooster/connect` instead, because they read the wallet session and that pulls in the Canton SDK. The rules for writing new components are in [`CLAUDE.md`](https://github.com/BootNodeDev/canton-dappbooster/blob/main/canton-dappbooster/CLAUDE.md). -## Scripts +## Installation -| Script | What it does | -| --- | --- | -| `pnpm build` | tsdown build into `dist/`: ESM `index.js` and `connect.js`, each with its `.d.ts` | -| `pnpm test` | vitest with jsdom and Testing Library, run against `src` | -| `pnpm typecheck` | `tsc --noEmit` | - -## Build & dev loop - -tsdown emits ESM and `.d.ts` files into `dist/`. The components carry no CSS, so `sideEffects: false` is safe and the bundle tree-shakes. The `exports` map has a `development` condition that points at `src`, so Vite serves the source directly in dev. Production builds and the published package use `dist`. +With npm: -In dev, a consumer resolves and typechecks against the source, with no kit build needed. Its production build resolves `dist`, so build the kit first, or run `pnpm build` from the repo root, which builds the workspaces in order. +```bash +npm install @bootnodedev/canton-dappbooster @bootnodedev/canton-connect @canton-network/dapp-sdk @walletconnect/sign-client +``` -React 19 only, both as a peer and as a dev dependency. +With pnpm: -If the consumer's React resolves to a different copy than the kit's, you get two Reacts in one bundle. Hooks then read a null dispatcher and every render throws. You only see this in a production build, because the `development` condition resolves the kit to its source. The fix is `resolve.dedupe` in the bundler. +```bash +pnpm add @bootnodedev/canton-dappbooster @bootnodedev/canton-connect @canton-network/dapp-sdk @walletconnect/sign-client +``` ## Styling: components carry none -The components (L2) have no styling opinion at all. Styling comes from the separate [`@bootnodedev/canton-theme`](https://github.com/BootNodeDev/canton-dappbooster/blob/main/canton-theme/README.md) package (L3), which you import yourself: +The components (L2) have no styling opinion at all. Styling comes from the separate [`@bootnodedev/canton-theme`](https://github.com/BootNodeDev/canton-dappbooster/tree/main/canton-theme) package (L3), which you import yourself: ```ts import '@bootnodedev/canton-theme/tokens.css' @@ -39,9 +35,11 @@ The contract between the two is the DOM each component renders, not code. The re This is the one styling-related runtime the package does ship. `` owns the mode and writes `data-theme` on ``, which is the attribute the theme keys its dark values on. `useTheme()` reads and sets it. No token names live here. -On a reload the page background flashes before React applies the attribute, and this package ships nothing to prevent that. See [`architecture.md`](https://github.com/BootNodeDev/canton-dappbooster/blob/main/canton-dappbooster/architecture.md) for why. +> [!NOTE] +> On a reload the page background flashes before React applies the attribute, and this package ships nothing to prevent that. See [Theme runtime](https://github.com/BootNodeDev/canton-dappbooster/blob/main/canton-dappbooster/architecture.md#theme-runtime) in `architecture.md` for why. -Client only: the provider reads the OS preference when it mounts, so a server render throws. +> [!WARNING] +> Client only: the provider reads the OS preference when it mounts, so a server render throws. ```tsx import { ThemeProvider, useTheme } from '@bootnodedev/canton-dappbooster' @@ -57,3 +55,24 @@ const ModeToggle = () => { return } ``` + +## Working on the package + +### Scripts + +| Script | What it does | +| --- | --- | +| `pnpm build` | tsdown build into `dist/`: ESM `index.js` and `connect.js`, each with its `.d.ts` | +| `pnpm test` | vitest with jsdom and Testing Library, run against `src` | +| `pnpm typecheck` | `tsc --noEmit` | + +### Build & dev loop + +tsdown emits ESM and `.d.ts` files into `dist/`. The components carry no CSS, so `sideEffects: false` is safe and the bundle tree-shakes. The `exports` map has a `development` condition that points at `src`, so Vite serves the source directly in dev. Production builds and the published package use `dist`. + +In dev, a consumer resolves and typechecks against the source, with no kit build needed. Its production build resolves `dist`, so build the kit first, or run `pnpm build` from the repo root, which builds the workspaces in order. + +React 19 only, both as a peer and as a dev dependency. + +> [!WARNING] +> If the consumer's React resolves to a different copy than the kit's, you get two Reacts in one bundle. Hooks then read a null dispatcher and every render throws. You only see this in a production build, because the `development` condition resolves the kit to its source. The fix is `resolve.dedupe` in the bundler. diff --git a/canton-dappbooster/architecture.md b/canton-dappbooster/architecture.md index 1fc2748b..4530ae9e 100644 --- a/canton-dappbooster/architecture.md +++ b/canton-dappbooster/architecture.md @@ -254,6 +254,7 @@ throws rather than guessing a mode the client would then hydrate away from. only at `dist`, and `prepack: tsdown` rebuilds `dist` before it ships. The two maps are hand-maintained copies: a new sub-path goes in both. See the packaging rules in the root [`CLAUDE.md`](https://github.com/BootNodeDev/canton-dappbooster/blob/main/CLAUDE.md). +- An app in this repo whose React resolves to a different copy than the kit's bundles two Reacts: hooks read a null dispatcher and every render throws. Only its production build shows it, because the `development` condition resolves the kit to its source in dev. `resolve.dedupe` in the app's bundler is the fix. ## Deferred (not this package's concern yet) diff --git a/canton-theme/README.md b/canton-theme/README.md index 524abbe1..6c4c0b2f 100644 --- a/canton-theme/README.md +++ b/canton-theme/README.md @@ -2,7 +2,7 @@ > [Canton dAppBooster](https://dappbooster.cc) is an open-source stack by BootNode for building Canton dApps on your own machine: a local Canton Network stack, wagmi-style hooks, UI components and CIP-0103 wallet support, wired together from the local network up to the UI. This package is its styling layer: the plain-CSS theme for those components. The complete kit, the installer and the guides live at [dappbooster.cc](https://dappbooster.cc). -A plain-CSS theme (L3) for the [`@bootnodedev/canton-dappbooster`](https://github.com/BootNodeDev/canton-dappbooster/blob/main/canton-dappbooster/README.md) components. No JavaScript and no runtime. It ships two files: +A plain-CSS theme (L3) for the [`@bootnodedev/canton-dappbooster`](https://github.com/BootNodeDev/canton-dappbooster/tree/main/canton-dappbooster) components. No JavaScript and no runtime. It ships two files: | Export | What it is | | --- | --- | @@ -11,6 +11,18 @@ A plain-CSS theme (L3) for the [`@bootnodedev/canton-dappbooster`](https://githu ## Usage +With npm: + +```bash +npm install @bootnodedev/canton-theme +``` + +With pnpm: + +```bash +pnpm add @bootnodedev/canton-theme +``` + The components ship no styling. Import the theme once, at your app entry: ```ts @@ -18,7 +30,7 @@ import '@bootnodedev/canton-theme/default.css' ``` - `default.css` imports `tokens.css`, so that one line is the whole theme. Import `tokens.css` on its own if you want the contract without the prestyled defaults. If you define the `--cnc-*` properties yourself instead, remember to set `color-scheme` too, one explicit value per mode. It is not a `--cnc-*` property, and without it the browser paints scrollbars, form controls and the caret in the wrong mode. -- Dark mode turns on with `[data-theme="dark"]`. Set that attribute on `` as early as you can, because setting it after first paint flashes. The theme deliberately ignores `prefers-color-scheme` on its own, so that a mode toggle can override the OS preference in either direction. The `` in [`canton-dappbooster`](https://github.com/BootNodeDev/canton-dappbooster/blob/main/canton-dappbooster/README.md) drives the attribute from React, but nothing here depends on it, and setting the attribute yourself works just as well. +- Dark mode turns on with `[data-theme="dark"]`. Set that attribute on `` as early as you can, because setting it after first paint flashes. The theme deliberately ignores `prefers-color-scheme` on its own, so that a mode toggle can override the OS preference in either direction. The `` in [`canton-dappbooster`](https://github.com/BootNodeDev/canton-dappbooster/tree/main/canton-dappbooster) drives the attribute from React, but nothing here depends on it, and setting the attribute yourself works just as well. - When you override a token, override it in both modes. Your `:root` block also beats our `[data-theme="dark"]` block, so a light-only override stays in effect in dark mode. - Everything in the package sits in `@layer cnc`, so any unlayered CSS of yours wins without a specificity fight, whether you import the theme first or last. diff --git a/example-dapps/amulet-vesting/CLAUDE.md b/example-dapps/amulet-vesting/CLAUDE.md index 5730f9ca..0bce1e50 100644 --- a/example-dapps/amulet-vesting/CLAUDE.md +++ b/example-dapps/amulet-vesting/CLAUDE.md @@ -22,6 +22,8 @@ filename casing table and the colocate-then-promote placement rules it layers on `BackendProvider`. - No server code, and so no folder beside `src/` for it: this app is a static bundle, `vercel.json` answers every path with `index.html`, and every call goes straight to the service answering it. +- The Daml package sits directly in `daml/`, not in a folder named after itself, so a rename + touches `daml/daml.yaml` and nothing else. ## Shared pieces to reach for diff --git a/example-dapps/amulet-vesting/README.md b/example-dapps/amulet-vesting/README.md index e9c3ab4c..0a2c1acd 100644 --- a/example-dapps/amulet-vesting/README.md +++ b/example-dapps/amulet-vesting/README.md @@ -1,66 +1,91 @@ # Amulet vesting dApp -`@bootnodedev/canton-example-amulet-vesting` is a dApp for Amulet vesting. A funder proposes a grant and the -receiver accepts it. The receiver claims Amulet as the grant vests. If the funder cancels the grant, -the receiver keeps the vested but unclaimed Amulet as a residual claim. +> [Canton dAppBooster](https://dappbooster.cc) is an open-source stack by BootNode for building Canton dApps on your own machine: a local Canton Network stack, wagmi-style hooks, UI components and CIP-0103 wallet support, wired together from the local network up to the UI. This package is its Canton Coin vesting example. The complete kit, the installer and the guides live at [dappbooster.cc](https://dappbooster.cc). -Acceptance locks the funder's Amulet in escrow, and each claim releases part of it. The figures on -screen are therefore real holdings. Each grant shows its vested and claimable amounts live, and -[`src/utils/schedule.ts`](src/utils/schedule.ts) computes them. +`@bootnodedev/canton-example-amulet-vesting` is a dApp for Amulet vesting. A funder proposes a grant and the receiver accepts it. The receiver claims Amulet as the grant vests. If the funder cancels the grant, the receiver keeps the vested but unclaimed Amulet as a residual claim. -Every read and every write goes through the connected CIP-0103 wallet. The app acts as the wallet's -primary account, and each write raises a real approval prompt. There is no mock mode. If the ledger -has no vesting operator and factory, or you have not connected a wallet, the pages show a connect -placeholder. +Its Daml model is in [`daml/`](daml/), and [`daml/README.md`](daml/README.md) covers the build. -> This frontend comes from `cn-dappbooster@feat/vesting-lite`. See -> [`PROVENANCE.md`](https://github.com/BootNodeDev/canton-dappbooster/blob/main/example-dapps/amulet-vesting/PROVENANCE.md). -> The Daml package it uses is in [`daml/`](daml/). +> [!NOTE] +> The model ships as source, not as a binary: it moves Canton Coin, so it must be built against the Splice release your network runs. -## Run the app +[`architecture.md`](https://github.com/BootNodeDev/canton-dappbooster/blob/main/example-dapps/amulet-vesting/architecture.md) describes the app's internal structure and the reasoning behind it, [`CLAUDE.md`](https://github.com/BootNodeDev/canton-dappbooster/blob/main/example-dapps/amulet-vesting/CLAUDE.md) has the rules for working on the app, and [`PROVENANCE.md`](https://github.com/BootNodeDev/canton-dappbooster/blob/main/example-dapps/amulet-vesting/PROVENANCE.md) records where the frontend and the model came from. -`create-canton-dappbooster --example amulet-vesting` makes a project from this package. With -`--localnet`, `pnpm stack up` starts everything the app needs and the app itself. The installer -appends a Local network section to this README that describes it. +## How it works -Without the local network, point `.env` at your network first: `CANTON_JSON_API_URL`, -`CANTON_BACKEND_TOKEN`, the `VITE_` URLs, and `SPLICE_TAG` for the contract build. Then build and -deploy the contract as the Contract section the installer appends describes, and run: +```mermaid +sequenceDiagram + actor Funder + actor Receiver + participant App as dApp + participant Ledger as Participant, via the CIP-0103 wallet -```bash -pnpm bootstrap # creates the operator and its factory -pnpm dev # serves http://localhost:3012 + Funder->>App: "Create grant" button + App->>Ledger: funder approves, 1 of 2
(AmuletRules_Transfer, an Amulet of exactly the grant) + App->>Ledger: funder approves, 2 of 2
(AmuletVestingFactory_CreateVesting, disclosing the factory
and the AmuletRules and open mining round read from Scan) + Note over Ledger: proposal created + Receiver->>App: "Accept" button + App->>Ledger: receiver approves
(AmuletVestingProposal_Accept, disclosing the funder's Amulet) + Note over Ledger: Amulet locked in escrow + Receiver->>App: "Claim" button + App->>Ledger: receiver approves
(AmuletVestingContract_Withdraw, disclosing the rules and the round) + Note over Ledger: vested Amulet released ``` -Each `bootstrap` run creates a new operator and factory, and the dApp uses the newest. It writes no -file. -[`architecture.md`](https://github.com/BootNodeDev/canton-dappbooster/blob/main/example-dapps/amulet-vesting/architecture.md) -explains how the dApp finds them on the ledger. +Acceptance locks the funder's Amulet in escrow, and each claim releases part of it. The figures on screen are therefore real holdings. Each grant shows its vested and claimable amounts live, and [`src/utils/schedule.ts`](src/utils/schedule.ts) computes them. + +## Run the app + +### With the installer + +`pnpm create canton-dappbooster --example amulet-vesting` makes a project from this package. With `--localnet`, `pnpm stack up` starts the app and everything it needs. The Local network section the installer appends to this README describes it. + +### On your own network + +Point `.env` at your network first: `CANTON_JSON_API_URL`, `CANTON_BACKEND_TOKEN`, the `VITE_` URLs, and `SPLICE_TAG` for the contract build. Then build and deploy the contract as the Contract section the installer appends describes, and run: + +| Script | What it does | +| --- | --- | +| `pnpm bootstrap` | creates the operator and its factory | +| `pnpm dev` | serves http://localhost:3012 | -## Get Amulet and connect a wallet +`bootstrap` writes no file: the dApp reads the operator and its factory back off the ledger once a wallet connects. [`architecture.md`](https://github.com/BootNodeDev/canton-dappbooster/blob/main/example-dapps/amulet-vesting/architecture.md) explains how it finds them. -Funding a grant takes Amulet. The **Tap Amulet** faucet in the account menu taps 100 AMT into the -connected party. It works on LocalNet and DevNet, where `AmuletRules_DevNet_Tap` exists. It fails -until the first mining round opens, about ten minutes after a fresh LocalNet start. +## Use the app -Connect with a CIP-0103 wallet. With the local network, `pnpm stack up` starts Wallet Gateway on -http://localhost:3030. When you create a party in Wallet Gateway, choose the `wallet-kernel` -signing provider. With `participant`, the validator merges the party's Amulets, and accepting a -pending grant then fails. +### Connect a wallet -You act as the party the wallet reports, and the wallet restores the session on reload. If you -change the wallet's primary party, the dApp acts as the new one. +Every read and every write goes through the connected CIP-0103 wallet. The app acts as the wallet's primary account, and each write raises a real approval prompt. There is no mock mode. Without a connected wallet, the top bar guides you to connect one. When the ledger has no vesting operator and factory, the pages show a `No deployment` card. + +With the local network, `pnpm stack up` starts Wallet Gateway on http://localhost:3030. + +> [!WARNING] +> When you create a party in Wallet Gateway, choose the `wallet-kernel` signing provider. With `participant`, the validator merges the party's Amulets, and accepting a pending grant then fails. + +You act as the party the wallet reports, and the wallet restores the session on reload. If you change the wallet's primary party, the dApp acts as the new one. + +### Get Amulet + +Funding a grant takes Amulet. The **Tap Amulet** faucet in the account menu taps 100 AMT into the connected party. It works on LocalNet and DevNet, where `AmuletRules_DevNet_Tap` exists. It fails until the first mining round opens, about ten minutes after a fresh LocalNet start. ## Configure the app -The app reads its variables from `.env` in the project folder, and [`.env.example`](.env.example) -lists them. The URL variables default to the local stack. +The app reads its variables from `.env` in the project folder, and [`.env.example`](.env.example) lists them. The URL variables default to the local stack. + +WalletConnect is off unless you set `VITE_WALLET_CONNECT_PROJECT_ID` to a Reown project id. Once you set it, `VITE_NETWORK_ID` must match the network the wallet serves. For the local gateway, that is `canton:localnet`. + +## Working on the package -WalletConnect is off unless you set `VITE_WALLET_CONNECT_PROJECT_ID` to a Reown project id. Once you -set it, `VITE_NETWORK_ID` must match the network the wallet serves. For the local gateway, that is -`canton:localnet`. +### Scripts -## How it fits together +| Script | What it does | +| --- | --- | +| `pnpm dev` | Vite dev server on http://localhost:3012 | +| `pnpm build` | `tsc -b`, then the Vite production build into `dist/` | +| `pnpm preview` | serves the production build | +| `pnpm test` | vitest, once | +| `pnpm test:watch` | vitest in watch mode | +| `pnpm typecheck` | `tsc -b --noEmit` | +| `pnpm bootstrap` | creates the operator and its factory on the ledger `.env` names | -[`architecture.md`](https://github.com/BootNodeDev/canton-dappbooster/blob/main/example-dapps/amulet-vesting/architecture.md) -describes the app's internal structure and the reasoning behind it. +Published to npm. The installer fetches that package with `npm pack`, not this folder. In the monorepo, `pnpm dev:amulet-vesting` from the root runs the installer's local network scripts against this folder, `build-dar` and `deploy-dar` included, which is why neither is a script here. diff --git a/example-dapps/amulet-vesting/daml/README.md b/example-dapps/amulet-vesting/daml/README.md index 5bd179be..2cb2eb63 100644 --- a/example-dapps/amulet-vesting/daml/README.md +++ b/example-dapps/amulet-vesting/daml/README.md @@ -1,40 +1,23 @@ # dApp Daml -The vesting dApp's Daml code: one package, `amulet-vesting`, holding the factory, proposal, -contract and residual claim, escrowing real Canton Coin as a Splice `LockedAmulet`. It sits -directly here rather than in a folder named after itself, so a rename touches -[`daml.yaml`](daml.yaml) and nothing else. Its scenarios stay in the repository it was vendored -from; see -[`PROVENANCE.md`](https://github.com/BootNodeDev/canton-dappbooster/blob/main/example-dapps/amulet-vesting/daml/PROVENANCE.md). +The vesting dApp's Daml model: one package, `amulet-vesting`, holding the factory, the proposal, the contract and the residual claim. It escrows real Canton Coin as a Splice `LockedAmulet`. Its scenarios stay in the repository it was vendored from; see [`PROVENANCE.md`](https://github.com/BootNodeDev/canton-dappbooster/blob/main/example-dapps/amulet-vesting/daml/PROVENANCE.md). ## Build and deploy -With the local network (`--localnet`), `pnpm stack up` builds and deploys the DAR, after checking -that `dpm` is on your PATH. Without it, run this from the project root, with `CANTON_JSON_API_URL` -and `CANTON_BACKEND_TOKEN` set in `.env`: +With the local network (`--localnet`), `pnpm stack up` builds and deploys the DAR. Without it, run this from the project root, with `CANTON_JSON_API_URL` and `CANTON_BACKEND_TOKEN` set in `.env`: ```bash pnpm build-dar pnpm deploy-dar -- daml/.daml/dist/amulet-vesting-.dar ``` -`` is the one in [`daml.yaml`](daml.yaml). `build-dar` needs the Daml SDK (`dpm`). -`deploy-dar` uploads to `CANTON_JSON_API_URL` and sends `CANTON_BACKEND_TOKEN`. +`` is the one in [`daml.yaml`](daml.yaml). `build-dar` needs the Daml SDK (`dpm`). `deploy-dar` uploads to `CANTON_JSON_API_URL` and sends `CANTON_BACKEND_TOKEN`. -Any edit to the model changes the package id, and a participant refuses a second package at the -same name and version (`KNOWN_PACKAGE_VERSION`). So a redeploy after a source change means bumping -`version` in [`daml.yaml`](daml.yaml) or, with the local network, resetting it -(`pnpm exec canton-barebones reset` in `.canton-localnet/`). +> [!WARNING] +> Any edit to the model changes the package id, and a participant refuses a second package at the same name and version (`KNOWN_PACKAGE_VERSION`). Redeploying after a source change means bumping `version` in [`daml.yaml`](daml.yaml), which the participant accepts only for a valid upgrade, or, with the local network, running `pnpm exec canton-barebones reset` in `.canton-localnet/`. ## The Splice dependencies -`amulet-vesting` data-depends on four Splice DARs. They are not committed: `pnpm build-dar` runs -`scripts/fetch-daml-deps.mjs` first, which fetches them into the gitignored `deps/` and skips the -work once they are there. +`amulet-vesting` data-depends on Splice DARs. They are not committed: `pnpm build-dar` fetches them into the gitignored `deps/` first and skips the work once they are there. -Which Splice release they come from is not a choice. An Amulet-moving choice is exercised against -the `AmuletRules` the network is running, so the build has to use that network's Amulet. Set -`SPLICE_TAG` in `.env` to the release your network runs. Without it, the script reads `splice.tag` -off the pinned `@bootnodedev/canton-barebones` template, which a project has only with -`--localnet`, so bumping the local network moves the DAR with it. Either way it takes the highest -`splice-amulet` version that release ships; delete `deps/` to force a refetch. +Which Splice release they come from is not a choice. An Amulet-moving choice is exercised against the `AmuletRules` the network is running, so the build has to use that network's Amulet. Set `SPLICE_TAG` in `.env` to the release your network runs. Without it, the build reads the release off the local network's config, which a project has only with `--localnet`, so bumping the local network moves the DAR with it. Either way it takes the highest `splice-amulet` version that release ships. Delete `deps/` to force a refetch. diff --git a/example-dapps/token-vesting/README.md b/example-dapps/token-vesting/README.md index b156a413..815fef44 100644 --- a/example-dapps/token-vesting/README.md +++ b/example-dapps/token-vesting/README.md @@ -1,77 +1,96 @@ # Token vesting dApp -`@bootnodedev/canton-example-token-vesting` is a dApp for vesting a -[canton-token-forge](https://github.com/BootNodeDev/canton-token-forge) token, `DBT`. A funder -proposes a grant, and the receiver accepts or declines it. The funder can cancel it until the -receiver accepts. The receiver claims `DBT` as the grant vests. If the funder cancels an accepted -grant, the receiver keeps the vested but unclaimed `DBT` as a residual claim. +> [Canton dAppBooster](https://dappbooster.cc) is an open-source stack by BootNode for building Canton dApps on your own machine: a local Canton Network stack, wagmi-style hooks, UI components and CIP-0103 wallet support, wired together from the local network up to the UI. This package is its token vesting example, over a [canton-token-forge](https://github.com/BootNodeDev/canton-token-forge) token. The complete kit, the installer and the guides live at [dappbooster.cc](https://dappbooster.cc). -Acceptance locks the funder's `DBT` in a `LockedToken` escrow, and each claim releases part of it. -The figures on screen are therefore real holdings. Each grant shows its vested and claimable amounts -live, and [`src/utils/schedule.ts`](src/utils/schedule.ts) computes them. +`@bootnodedev/canton-example-token-vesting` is a dApp for vesting a [canton-token-forge](https://github.com/BootNodeDev/canton-token-forge) token, `DBT`. A funder proposes a grant, and the receiver accepts or declines it. The funder can cancel it until the receiver accepts. The receiver claims `DBT` as the grant vests. If the funder cancels an accepted grant, the receiver keeps the vested but unclaimed `DBT` as a residual claim. -Every read and every write goes through the connected CIP-0103 wallet. The app acts as the wallet's -primary account, and each write raises a real approval prompt. There is no mock mode. If the ledger -has no vesting operator and factory, or you have not connected a wallet, the pages show a connect -placeholder. +The Daml packages ship prebuilt in [`vendor/`](vendor/), and `bootstrap` uploads them: the vesting model from [canton-vesting-forge](https://github.com/BootNodeDev/canton-vesting-forge) and the token from [canton-token-forge](https://github.com/BootNodeDev/canton-token-forge). A canton-token-forge registry serves the token's metadata. [`vendor/PROVENANCE.md`](vendor/PROVENANCE.md) records their releases and checksums. -It needs no Splice reference data and no Daml toolchain. The two Daml packages ship as binaries in -[`vendor/`](vendor/), and a canton-token-forge registry serves the token's metadata. +> [!NOTE] +> There is no `daml/` here. Nothing in those packages depends on the Splice release your network runs, so one build works on any network and the app needs no Daml toolchain. -> This frontend comes from the Amulet Vesting example. See -> [`PROVENANCE.md`](https://github.com/BootNodeDev/canton-dappbooster/blob/main/example-dapps/token-vesting/PROVENANCE.md). +[`architecture.md`](https://github.com/BootNodeDev/canton-dappbooster/blob/main/example-dapps/token-vesting/architecture.md) describes the app's internal structure and the reasoning behind it, [`CLAUDE.md`](https://github.com/BootNodeDev/canton-dappbooster/blob/main/example-dapps/token-vesting/CLAUDE.md) has the rules for working on the app, and [`PROVENANCE.md`](https://github.com/BootNodeDev/canton-dappbooster/blob/main/example-dapps/token-vesting/PROVENANCE.md) records where the frontend came from. + +## How it works + +```mermaid +sequenceDiagram + actor Funder + actor Receiver + participant App as dApp + participant Ledger as Participant, via the CIP-0103 wallet + + Funder->>App: "Create grant" button + App->>Ledger: funder approves, once
(VestingFactory_CreateVesting, disclosing the factory
and the InstrumentConfig read from the token registry) + Note over Ledger: the factory splits the holdings and creates the proposal + Receiver->>App: "Accept" button + App->>Ledger: receiver approves
(VestingProposal_Accept, disclosing the reserved holding and the config) + Note over Ledger: DBT locked in escrow + Receiver->>App: "Claim" button + App->>Ledger: receiver approves
(VestingContract_Withdraw, disclosing the config) + Note over Ledger: vested DBT released +``` + +Acceptance locks the funder's `DBT` in a `LockedToken` escrow, and each claim releases part of it. The figures on screen are therefore real holdings. Each grant shows its vested and claimable amounts live, and [`src/utils/schedule.ts`](src/utils/schedule.ts) computes them. ## Run the app -`create-canton-dappbooster --example token-vesting` makes a project from this package. With -`--localnet`, `pnpm stack up` starts the app and everything it needs. That includes the token -registry on http://localhost:3013. The installer appends a Local network section to this -README that describes it. +### With the installer -Without the local network, point `.env` at your network first: `CANTON_JSON_API_URL`, -`CANTON_BACKEND_TOKEN` and the `VITE_` URLs. Then run: +`pnpm create canton-dappbooster --example token-vesting` makes a project from this package. With `--localnet`, `pnpm stack up` starts the app and everything it needs, the token registry on http://localhost:3013 included. The Local network section the installer appends to this README describes it. -```bash -pnpm bootstrap # uploads both DARs, creates the operator, its factory and the DBT instrument -pnpm stack:up # starts the token registry on http://localhost:3013 -pnpm dev # serves http://localhost:3012 -``` +### On your own network + +Point `.env` at your network first: `CANTON_JSON_API_URL`, `CANTON_BACKEND_TOKEN` and the `VITE_` URLs. Then run: -`bootstrap` creates only what is missing, so running it again is safe. The one file it writes is -`.env.registry`, the registry's settings, which `stack:up` starts the registry from. -`pnpm stack:down` stops it. -[`architecture.md`](https://github.com/BootNodeDev/canton-dappbooster/blob/main/example-dapps/token-vesting/architecture.md) -explains how the dApp finds the operator and the factory on the ledger. +| Script | What it does | +| --- | --- | +| `pnpm bootstrap` | uploads the DARs, creates the operator, its factory and the DBT instrument | +| `pnpm stack:up` | starts the token registry on http://localhost:3013 | +| `pnpm dev` | serves http://localhost:3012 | -## Get DBT and connect a wallet +`bootstrap` creates only what is missing, so running it again is safe. The one file it writes is `.env.registry`, the registry's settings, which `stack:up` starts the registry from. `pnpm stack:down` stops it. [`architecture.md`](https://github.com/BootNodeDev/canton-dappbooster/blob/main/example-dapps/token-vesting/architecture.md) explains how the dApp finds the operator and the factory on the ledger. -Funding a grant takes `DBT`. The **Tap dAppBooster Token** faucet in the account menu taps 1000 DBT -into the connected party, straight off the instrument's own config. It refuses an amount over the -`maxPerTap` that `bootstrap` set, and the failure toast says so. +## Use the app -Connect with a CIP-0103 wallet. With the local network, `pnpm stack up` starts Wallet Gateway on -http://localhost:3030. +### Connect a wallet -You act as the party the wallet reports, and the wallet restores the session on reload. If you -change the wallet's primary party, the dApp acts as the new one. +Every read and every write goes through the connected CIP-0103 wallet. The app acts as the wallet's primary account, and each write raises a real approval prompt. There is no mock mode. Without a connected wallet, the top bar guides you to connect one. When the ledger has no vesting operator and factory, the pages show a `No deployment` card. + +With the local network, `pnpm stack up` starts Wallet Gateway on http://localhost:3030. + +You act as the party the wallet reports, and the wallet restores the session on reload. If you change the wallet's primary party, the dApp acts as the new one. + +### Get DBT + +Funding a grant takes `DBT`. The **Tap dAppBooster Token** faucet in the account menu taps 1000 DBT into the connected party, straight off the instrument's own config. The amount is fixed in the app, under the `maxPerTap` that `bootstrap` sets on the instrument. ## Configure the app -The app reads its variables from `.env` in the project folder, and [`.env.example`](.env.example) -lists them. The explorer, registry and gateway URLs default to the local stack. Open the dApp on any -address other than http://localhost:3012 and the registry refuses it until `CORS_ORIGINS` lists -that address. +The app reads its variables from `.env` in the project folder, and [`.env.example`](.env.example) lists them. The explorer, registry and gateway URLs default to the local stack. + +> [!NOTE] +> Open the dApp on any address other than http://localhost:3012 and the registry refuses it until `CORS_ORIGINS` lists that address. + +WalletConnect is off unless you set `VITE_WALLET_CONNECT_PROJECT_ID` to a Reown project id. Once you set it, `VITE_NETWORK_ID` must match the network the wallet serves. For the local gateway, that is `canton:localnet`. + +> [!NOTE] +> A build you deploy takes no defaults for the URL variables. With `DEPLOYED_BUILD=1`, an unset URL fails the build rather than baking in a `localhost` address that an https page blocks as mixed content. On Vercel, [`api/registry.ts`](api/registry.ts) forwards the registry's routes from the deployed origin to the registry `REGISTRY_URL` names. The Vercel block in `.env.example` lists what to set. -WalletConnect is off unless you set `VITE_WALLET_CONNECT_PROJECT_ID` to a Reown project id. Once you -set it, `VITE_NETWORK_ID` must match the network the wallet serves. For the local gateway, that is -`canton:localnet`. +## Working on the package -A build you deploy takes no defaults for the three URLs. With `DEPLOYED_BUILD=1`, an -unset URL fails the build rather than baking in a `localhost` address that an https page blocks as -mixed content. On Vercel, [`api/registry.ts`](api/registry.ts) forwards the registry's routes from -the deployed origin, to the registry `REGISTRY_URL` names. +### Scripts -## How it fits together +| Script | What it does | +| --- | --- | +| `pnpm dev` | Vite dev server on http://localhost:3012 | +| `pnpm build` | `tsc -b`, then the Vite production build into `dist/` | +| `pnpm preview` | serves the production build | +| `pnpm test` | vitest, once, `scripts/bootstrap-vesting.test.mjs` included | +| `pnpm test:watch` | vitest in watch mode | +| `pnpm typecheck` | `tsc -b --noEmit` | +| `pnpm bootstrap` | uploads the DARs and creates the operator, its factory and the instrument on the ledger `.env` names | +| `pnpm stack:up` | starts the token registry from `.env.registry` | +| `pnpm stack:down` | stops it | -[`architecture.md`](https://github.com/BootNodeDev/canton-dappbooster/blob/main/example-dapps/token-vesting/architecture.md) -describes the app's internal structure and the reasoning behind it. +Published to npm. The installer fetches that package with `npm pack`, not this folder. In the monorepo, `pnpm dev:token-vesting` from the root runs the installer's local network scripts against this folder, and they call `stack:up` and `stack:down` around the registry.