diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..93e81659 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.dar binary diff --git a/.gitignore b/.gitignore index 34dab813..14d2be45 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ typedoc/ .env .env.local .env.fivenorth +.env.registry # typescript *.tsbuildinfo diff --git a/.lintstagedrc.mjs b/.lintstagedrc.mjs index 0e702fcf..a696326d 100644 --- a/.lintstagedrc.mjs +++ b/.lintstagedrc.mjs @@ -1,9 +1,13 @@ +const examples = (files) => [...new Set(files.map((file) => file.match(/example-dapps\/[^/]+/)?.[0]))] + export default { 'canton-connect/src/**/*.{ts,tsx,js,jsx}': () => 'pnpm -C canton-connect test', 'canton-dappbooster/src/**/*.{ts,tsx,js,jsx}': () => 'pnpm -C canton-dappbooster test', 'example-dapps/*/src/**/*.{ts,tsx,js,jsx}': (files) => - [...new Set(files.map((file) => file.match(/example-dapps\/[^/]+/)?.[0]))].map( - (example) => `pnpm -C ${example} test`, + examples(files).map((example) => `pnpm -C ${example} test`), + 'example-dapps/*/{api,scripts}/**/*.{ts,mjs,sh}': (files) => + examples(files).map( + (example) => `pnpm -C ${example} exec vitest run --passWithNoTests api scripts`, ), 'canton-create/**/*.{ts,tsx,mjs,sh,json}': () => 'pnpm -C canton-create test', '{canton-create/scaffold/*/scripts,scripts}/*.{mjs,sh}': () => diff --git a/CLAUDE.md b/CLAUDE.md index cbe2bd9d..747b1883 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,6 +11,7 @@ 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): the L3 `--cnc-*` token naming convention - [`example-dapps/amulet-vesting/CLAUDE.md`](example-dapps/amulet-vesting/CLAUDE.md): the vesting example's layout and naming deltas. Its seams are in [`example-dapps/amulet-vesting/architecture.md`](example-dapps/amulet-vesting/architecture.md), and its Daml in `daml/README.md` +- [`example-dapps/token-vesting/CLAUDE.md`](example-dapps/token-vesting/CLAUDE.md): the token vesting example, the same layout and naming deltas. Its seams are in [`example-dapps/token-vesting/architecture.md`](example-dapps/token-vesting/architecture.md), and its vendored DARs in `vendor/PROVENANCE.md` The dApp connects through any CIP-0103 wallet; no wallet lives in this monorepo. @@ -40,6 +41,7 @@ Current distribution: | `canton-connect/` | yes | shim | yes | yes, plus `architecture/` | Public hook API, the machine-owned lifecycle, the picker/adapter seams; pages for the connection machine and the popup close guard. | | `canton-create/` | yes | shim | yes | yes | The `create-canton-dappbooster` installer. `CLAUDE.md` carries the flag rule, the scaffold rules and the demo recording, and `architecture.md` how a run and the scaffold layers fit together. A project reads the `README.md` files in `scaffold/`: `starter/`'s whole, and `daml-tooling/`'s and `localnet/`'s as sections appended to the app's. | | `example-dapps/amulet-vesting/` | yes | shim | yes | yes | Canton Coin vesting example, published on its own. `CLAUDE.md` carries the page-owns-its-components layout and the naming rules an agent would otherwise get wrong, and architecture.md its internal seams. Its single Daml package (`amulet-vesting`) sits in `daml/` with a `README.md` of its own. `PROVENANCE.md` and `daml/PROVENANCE.md` record the vendored sources. | +| `example-dapps/token-vesting/` | yes | shim | yes | yes | Token vesting example, published on its own. It started as a copy of the Amulet one, so its `CLAUDE.md` carries the same deltas plus the `api/` rule. Its two Daml packages are binaries in `vendor/`, and `vendor/PROVENANCE.md` records their releases and checksums. | | `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). | | `canton-theme/` | yes | shim | yes | no | Plain-CSS theme (L3); README covers the two CSS exports, `CLAUDE.md` the `--cnc-*` naming convention an agent adding a token would otherwise invent. | @@ -62,7 +64,7 @@ A README may state that a contract exists and link to it. It may not restate it. |----------|-----------|-------| | Languages | TypeScript, Daml, Bash | TypeScript across the JS subprojects; Daml in each app's `daml/`; Bash and Node for the root `scripts/` and the scaffold's | | Package manager | pnpm workspaces | Single root `pnpm-lock.yaml`; one root `pnpm install` links every workspace. Workspace layout, `linkWorkspacePackages` and `allowBuilds` live in `pnpm-workspace.yaml`. Root `package.json` orchestrates scripts via `pnpm -C ` | -| Node | 24 | Exact version pinned via root `.nvmrc`; inherits to every Node package. Root, `canton-connect`, `canton-dappbooster` and `example-dapps/amulet-vesting` declare `engines.node` at `>=24.15.0`, which is what `jsdom` 30 requires. `canton-create` declares `>=22.12.0` | +| Node | 24 | Exact version pinned via root `.nvmrc`; inherits to every Node package. Root, `canton-connect`, `canton-dappbooster` and both examples declare `engines.node` at `>=24.15.0`, which is what `jsdom` 30 requires. `canton-create` declares `>=22.12.0` | | Container runtime | Docker | Required by the `@bootnodedev/canton-barebones` LocalNet; nothing in this repository builds an image | | Wallet | `@canton-network/wallet-gateway-remote` | The CIP-0103 wallet the dApp connects to, from the Splice wallet kernel. Pinned exact in the root `devDependencies`, run by `pnpm run wallet-gateway` on port 3030, configured by the committed `canton-create/scaffold/localnet/wallet-gateway.config.json`, the file every project with a local network gets. `example-dapps/amulet-vesting` registers it as a `RemoteAdapter` in `additionalAdapters`, which is what lets a session survive a reload | | LocalNet | `@bootnodedev/canton-barebones` | Pinned exact in `canton-create/scaffold/localnet/package.json`, the only manifest that declares it. `publicHoistPattern` in `pnpm-workspace.yaml` links it and its command into the root `node_modules`, so `pnpm exec canton-barebones` and the scaffold's scripts reach that one copy from the root. The repo commits none of its config. `canton-create/scaffold/localnet/scripts/localnet-config.mjs` scaffolds the gitignored `.canton-localnet/` from the tool's own template and turns on `validators.appUser.ui` and `sv.scanUI`. Without them, nginx serves no `/api/validator` or `/api/scan`. The Splice checkout and the runtime env land in `.canton-localnet/.generated/` | @@ -86,6 +88,7 @@ A README may state that a contract exists and link to it. It may not restate it. | Path | Purpose | Stack | Port | |------|---------|-------|------| | [`example-dapps/amulet-vesting/`](example-dapps/amulet-vesting/) | Canton Coin vesting dApp, an example `create-canton-dappbooster` scaffolds, published as `@bootnodedev/canton-example-amulet-vesting`. Every read and write goes through the connected CIP-0103 wallet via `canton-connect`; the operator's factory, the `AmuletRules` and the open mining round all arrive by explicit disclosure. Its `amulet-vesting` Daml model in `daml/` covers the factory, the proposal, the contract and the residual claim, and holds Canton Coin in escrow as a Splice `LockedAmulet`. It is vendored from [cc-vesting-contracts](https://github.com/BootNodeDev/cc-vesting-contracts), where its scenarios stay. Imported from `cn-dappbooster@feat/vesting-lite` (see its `PROVENANCE.md`). | Vite + React + Ark UI + lucide-react + Tailwind v4 + `zustand` + react-router + Daml | 3012 | +| [`example-dapps/token-vesting/`](example-dapps/token-vesting/) | The same vesting dApp over a `DBT` instrument from [canton-token-forge](https://github.com/BootNodeDev/canton-token-forge), published as `@bootnodedev/canton-example-token-vesting`. A canton-token-forge registry serves the instrument and the one `InstrumentConfig` each write discloses, so no Splice reference data reaches a write. Its `vesting` and `canton-token-forge` DARs ship as binaries in `vendor/`, so it needs no `dpm`. Its bootstrap uploads them and writes the registry's settings to the gitignored `.env.registry`. `scripts/registry.sh` runs the registry, from the registry's git dependency. | Vite + React + Ark UI + lucide-react + Tailwind v4 + `zustand` + react-router | 3012, registry 3013 | | [`canton-create/`](canton-create/) | The `create-canton-dappbooster` installer, published on its own. [`canton-create/architecture.md`](canton-create/architecture.md) covers how it builds a project. | TypeScript + Ink + React 19 + `tsdown` + `vitest` | n/a (command-line tool) | | [`canton-connect/`](canton-connect/) | wagmi-style React hooks wrapping the `dapp-sdk` facade; the SDK owns discovery, the picker, the session and the transports | TypeScript + React 19 + `xstate` 5 + Biome | n/a (library) | | [`canton-dappbooster/`](canton-dappbooster/) | L2 headless UI components for Canton dApps, built with `tsdown` and carrying no styling. It also holds the light/dark/system theme runtime that drives `data-theme`, and the pure utilities the components build on, the exact-decimal amount ones included. Styling lives in `canton-theme`. `src/index.ts` is the public API. `src/connect.ts` is the `/connect` sub-path, holding the components that read the wallet session so the main barrel stays free of the Canton SDK. | TypeScript + React 19 + `tsdown` + `vitest` + Biome | n/a (library) | @@ -357,7 +360,7 @@ its `CLAUDE.md` owns that contract. Nothing below differs. - Use **pnpm** only (never npm or yarn). - This is a pnpm workspaces monorepo: one `pnpm install` from the repo root installs and links every package. There is no per-package install step. -- Run a subproject script either by `cd ` or by using `pnpm -C run + + diff --git a/example-dapps/token-vesting/package.json b/example-dapps/token-vesting/package.json new file mode 100644 index 00000000..03412902 --- /dev/null +++ b/example-dapps/token-vesting/package.json @@ -0,0 +1,79 @@ +{ + "name": "@bootnodedev/canton-example-token-vesting", + "version": "0.6.2", + "description": "Token vesting dApp over a canton-token-forge instrument: an example app for create-canton-dappbooster", + "repository": { + "type": "git", + "url": "git+https://github.com/BootNodeDev/canton-dappbooster.git", + "directory": "example-dapps/token-vesting" + }, + "license": "MIT", + "type": "module", + "private": false, + "publishConfig": { + "access": "public" + }, + "files": [ + "index.html", + "public", + "src", + "api", + "vendor", + "scripts", + "tsconfig.json", + "tsconfig.app.json", + "tsconfig.node.json", + "vite.config.ts", + ".env.example", + "_gitignore", + "_pnpm-workspace.yaml" + ], + "engines": { + "node": ">=24.15.0" + }, + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc -b --noEmit", + "bootstrap": "node scripts/bootstrap-vesting.mjs", + "stack:up": "bash scripts/registry.sh start", + "stack:down": "bash scripts/registry.sh stop" + }, + "dependencies": { + "@ark-ui/react": "^5.39.1", + "@bootnodedev/canton-connect": "^0.6.2", + "@bootnodedev/canton-dappbooster": "^0.6.2", + "@bootnodedev/canton-theme": "^0.6.2", + "@canton-network/dapp-sdk": "1.8.0", + "@fontsource-variable/jetbrains-mono": "^5.2.8", + "@fontsource-variable/manrope": "^5.2.8", + "@walletconnect/sign-client": "^2.23.9", + "boring-avatars": "^2.0.4", + "lucide-react": "^1.39.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router-dom": "^7.6.2", + "zustand": "^5.0.5" + }, + "devDependencies": { + "@bootnodedev/canton-token-forge": "github:BootNodeDev/canton-token-forge#v0.3.0", + "@tailwindcss/vite": "^4.3.0", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^6.0.0", + "jsdom": "^30.0.1", + "tailwindcss": "^4.3.0", + "typescript": "^5.9.3", + "vite": "^8.0.0", + "vitest": "^5.0.0" + }, + "imports": { + "#api/*": { + "types": "./api/*.ts", + "default": "./api/*" + } + } +} diff --git a/example-dapps/token-vesting/public/favicon.svg b/example-dapps/token-vesting/public/favicon.svg new file mode 100644 index 00000000..5f1779da --- /dev/null +++ b/example-dapps/token-vesting/public/favicon.svg @@ -0,0 +1,15 @@ + + + + + + + + + diff --git a/example-dapps/token-vesting/scripts/bootstrap-vesting.mjs b/example-dapps/token-vesting/scripts/bootstrap-vesting.mjs new file mode 100644 index 00000000..94443030 --- /dev/null +++ b/example-dapps/token-vesting/scripts/bootstrap-vesting.mjs @@ -0,0 +1,406 @@ +#!/usr/bin/env node +// Bootstrap the vesting demo: create the backstage operator and pre-create the observer-less +// VestingFactory it signs, plus the instrument admin and its DBT InstrumentConfig. Funder and +// receiver are wallet accounts, so no other party is created here. +// +// The one file it writes is .env.registry, the token registry's settings. The dApp finds the +// operator and its factory by reading them back off the ledger, so it can already see this run. +// +// Run with the local stack up. It uploads the vendored DARs itself, so no deploy step comes first. + +import { readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' + +// A caller-exported value wins over .env, the same precedence deploy-dar.sh spells out by hand. +try { + process.loadEnvFile(new URL('../.env', import.meta.url)) +} catch { + // No .env: the caller's environment is the whole configuration. +} + +const JSON_API_URL = process.env.CANTON_JSON_API_URL || 'http://localhost:2975' +const TOKEN = process.env.CANTON_BACKEND_TOKEN +const PACKAGE_NAME = 'vesting' +const STAMP = Date.now() + +const TOKEN_FORGE_PACKAGE = 'canton-token-forge' +const INSTRUMENT = { + instrumentId: 'DBT', + name: 'dAppBooster Token', + symbol: 'DBT', + decimals: 10, + maxPerTap: '1000.0', +} + +export const REGISTRY_PORT = 3013 + +// Spelled out rather than built from a helper, so these read the same here, in +// the registry's own .env.example, and in a grep. +export const REGISTRY_TEMPLATE_IDS = { + INSTRUMENT_CONFIG_TEMPLATE_ID: '#canton-token-forge:Canton.TokenForge.Registry:InstrumentConfig', + PREAPPROVAL_TEMPLATE_ID: + '#canton-token-forge:Canton.TokenForge.Registry:TokenTransferPreapproval', + LOCKED_TOKEN_TEMPLATE_ID: '#canton-token-forge:Canton.TokenForge.Locked:LockedToken', + TRANSFER_INSTRUCTION_TEMPLATE_ID: + '#canton-token-forge:Canton.TokenForge.Instruction:TokenTransferInstruction', + ALLOCATION_TEMPLATE_ID: '#canton-token-forge:Canton.TokenForge.Allocation:TokenAllocation', +} + +// Every template id is single-quoted: a value starting with `#` is otherwise read +// as a comment and the variable parses as empty. +export const formatRegistryEnv = ({ ledgerApiUrl, adminParty, port }) => + [ + `LEDGER_API_URL=${ledgerApiUrl}`, + `ADMIN_PARTY=${adminParty}`, + ...Object.entries(REGISTRY_TEMPLATE_IDS).map(([key, id]) => `${key}='${id}'`), + `PORT=${port}`, + ].join('\n') + +// Checked on the first call rather than at import, so the test importing the exports above runs +// in CI, which has no .env. +const bearer = () => { + if (!TOKEN) { + throw new Error('CANTON_BACKEND_TOKEN is required. Generate one with: pnpm run mint-token') + } + return `Bearer ${TOKEN}` +} + +const ledger = async (requestMethod, resource, body, query) => { + const url = new URL(JSON_API_URL + resource) + for (const [key, value] of Object.entries(query ?? {})) { + url.searchParams.set(key, value) + } + const response = await fetch(url, { + method: requestMethod.toUpperCase(), + headers: { + authorization: bearer(), + ...(body === undefined ? {} : { 'content-type': 'application/json' }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }) + const text = await response.text() + // Status before parse: an error page would otherwise surface as a JSON syntax error. + if (!response.ok) { + throw new Error( + `${requestMethod} ${resource} failed: HTTP ${response.status} ${text.slice(0, 400)}`, + ) + } + try { + return JSON.parse(text) + } catch { + throw new Error(`${requestMethod} ${resource} returned no JSON: ${text.slice(0, 400)}`) + } +} + +// canton-token-forge first: vesting data-depends on it. +const VENDORED_DARS = ['canton-token-forge.dar', 'vesting.dar'] + +// A participant that already has the package answers 409, so a re-run uploads nothing new. +const uploadDars = async () => { + for (const dar of VENDORED_DARS) { + const response = await fetch(`${JSON_API_URL}/v2/packages`, { + method: 'POST', + headers: { authorization: bearer(), 'content-type': 'application/octet-stream' }, + body: readFileSync(path.join(import.meta.dirname, '..', 'vendor', dar)), + }) + if (!response.ok && response.status !== 409) { + const text = await response.text() + throw new Error(`uploading ${dar} failed: HTTP ${response.status} ${text.slice(0, 400)}`) + } + console.log(`dar ${dar}${response.status === 409 ? ' (existing)' : ''}`) + } +} + +const PARTY_PAGES = 20 + +// A party this participant hosts under this hint, or undefined. Party ids are +// `::`, so the hint is a prefix and not the id itself, and +// `isLocal` is what separates a party this participant hosts from one it merely +// knows about: allocating rights on somebody else's party succeeds and every +// command as that party then fails. +const findParty = async (hint) => { + let pageToken + for (let page = 0; page < PARTY_PAGES; page++) { + const result = await ledger( + 'get', + '/v2/parties', + undefined, + pageToken === undefined ? undefined : { pageToken }, + ) + const details = result?.partyDetails + if (!Array.isArray(details)) { + throw new Error(`/v2/parties did not answer with a party list: ${JSON.stringify(result)}`) + } + const found = details.find( + (one) => + typeof one?.party === 'string' && + one.party.startsWith(`${hint}::`) && + one.isLocal !== false, + ) + if (found !== undefined) { + return found.party + } + pageToken = result?.nextPageToken + if (typeof pageToken !== 'string' || pageToken === '') { + return undefined + } + } + // Never "not allocated": the next step would allocate the hint again and the participant would + // refuse a party it already hosts, naming nothing about the pages this stopped short of. + throw new Error(`gave up looking for ${hint} after ${PARTY_PAGES} pages of parties`) +} + +// Allocates the party if the participant does not already know it, and grants the +// authenticated user rights over it either way. That user is whoever the participant +// authenticated CANTON_BACKEND_TOKEN as, which is also the token the registry reads +// with, so this grant is what lets it read as the admin. Granting again is harmless +// and is what makes a re-run against a party from an earlier run complete. +const ensureParty = async (hint) => { + const existing = await findParty(hint) + let party = existing + if (party === undefined) { + const result = await ledger('post', '/v2/parties', { partyIdHint: hint }) + party = result?.partyDetails?.party + if (typeof party !== 'string' || party.length === 0) { + throw new Error(`no party id for hint ${hint}: ${JSON.stringify(result)}`) + } + } + // Asked rather than decoded off the token, so a token minted for another subject still works. + const userId = (await ledger('get', '/v2/authenticated-user'))?.user?.id + if (typeof userId !== 'string' || userId.length === 0) { + throw new Error('participant did not report an authenticated user') + } + await ledger('post', `/v2/users/${userId}/rights`, { + userId, + identityProviderId: '', + rights: [{ kind: { CanActAs: { value: { party } } } }], + }) + return { party, reused: existing !== undefined } +} + +// Ask the participant which package it would pick for the name, so a stale id cannot silently +// produce an empty dashboard. Fails loudly with PACKAGE_NAMES_NOT_FOUND if the DAR is not deployed. +const resolvePackage = async (party, packageName = PACKAGE_NAME) => { + const result = await ledger( + 'get', + '/v2/interactive-submission/preferred-package-version', + undefined, + { 'package-name': packageName, parties: party }, + ) + const pkg = result?.packagePreference?.packageReference?.packageId + if (typeof pkg !== 'string' || pkg.length === 0) { + throw new Error(`participant returned no package id for ${packageName}`) + } + return pkg +} + +// Read back from the ACS rather than out of the submission response, so this does not +// depend on where a given Canton version puts a create result in the envelope. The +// filter takes the package-name reference; the participant rejects a package id here. +const findInstrumentConfig = async (admin) => { + const end = await ledger('get', '/v2/state/ledger-end') + if (end?.offset === undefined) { + throw new Error('participant returned no ledger-end offset') + } + const rows = await ledger('post', '/v2/state/active-contracts', { + filter: { + filtersByParty: { + [admin]: { + cumulative: [ + { + identifierFilter: { + TemplateFilter: { + value: { + templateId: REGISTRY_TEMPLATE_IDS.INSTRUMENT_CONFIG_TEMPLATE_ID, + includeCreatedEventBlob: false, + }, + }, + }, + }, + ], + }, + }, + }, + activeAtOffset: end.offset, + verbose: true, + }) + const configs = (Array.isArray(rows) ? rows : []) + .map((row) => row?.contractEntry?.JsActiveContract?.createdEvent) + .filter((event) => event?.createArgument?.instrumentId === INSTRUMENT.instrumentId) + // More than one is the state the registry cannot serve, since it scopes every route to its + // admin and would find two configs for one instrument. None is an admin with nothing created + // yet, which is the caller's cue to create it. + if (configs.length > 1) { + throw new Error( + `expected at most one ${INSTRUMENT.instrumentId} InstrumentConfig for ${admin}, found ${configs.length}`, + ) + } + return configs[0] +} + +// How the config on the ledger differs from what INSTRUMENT above asks for. The contract is +// admin-signed and created once, so an edit here reaches a ledger that already carries one only +// through a reset: creating a second is the state findInstrumentConfig refuses just above. +export const instrumentDrift = (createArgument) => { + const arg = createArgument ?? {} + const exactly = (found, wanted) => found === wanted + // A Daml Decimal comes back padded to its full scale, so 1000.0 reads as 1000.0000000000. + const sameNumber = (found, wanted) => Number(found) === Number(wanted) + return [ + ['name', arg.name, INSTRUMENT.name, exactly], + ['symbol', arg.symbol, INSTRUMENT.symbol, exactly], + ['decimals', arg.decimals, String(INSTRUMENT.decimals), exactly], + ['maxPerTap', arg.faucet?.maxPerTap, INSTRUMENT.maxPerTap, sameNumber], + ] + .filter(([, found, wanted, matches]) => !matches(found, wanted)) + .map(([field, found, wanted]) => `${field} is ${found}, this script asks for ${wanted}`) +} + +// The operator's own factory, if this operator already signed one. Observer-less, so only the +// operator can see it, which is also why the dApp reads it back as the operator and not as a +// funder. +const findFactory = async (operator) => { + const end = await ledger('get', '/v2/state/ledger-end') + if (end?.offset === undefined) { + throw new Error('participant returned no ledger-end offset') + } + const rows = await ledger('post', '/v2/state/active-contracts', { + filter: { + filtersByParty: { + [operator]: { + cumulative: [ + { + identifierFilter: { + // A filter takes the package-name reference; a participant rejects the package id + // the CreateCommand carries. + TemplateFilter: { + value: { + templateId: `#${PACKAGE_NAME}:Vesting:VestingFactory`, + includeCreatedEventBlob: true, + }, + }, + }, + }, + ], + }, + }, + }, + activeAtOffset: end.offset, + verbose: true, + }) + return (Array.isArray(rows) ? rows : []) + .map((row) => row?.contractEntry?.JsActiveContract) + .find((entry) => entry?.createdEvent?.createdEventBlob !== undefined) +} + +// Both exits write it, because a reused deployment configures the registry exactly as a fresh one. +const writeRegistryEnv = (adminParty) => { + const file = path.join(import.meta.dirname, '..', '.env.registry') + writeFileSync( + file, + `${formatRegistryEnv({ ledgerApiUrl: JSON_API_URL, adminParty, port: REGISTRY_PORT })}\n`, + ) + console.log(`registry settings in ${path.relative(process.cwd(), file)}`) +} + +const main = async () => { + await uploadDars() + + // Stable hints, and everything below is created only where it is missing. `dev-stack.sh up` runs + // this on every start and the LocalNet's volumes outlive a `down`, so minting a fresh operator and + // admin each time left every holding and every grant of the previous run on a ledger where + // nothing in the dApp matches their (admin, instrumentId) any more: an empty dashboard, a zero + // balance, and no error to read. A reset drops the parties with the ledger, so a genuinely fresh + // stack still gets fresh ones. + const { party: operator, reused: hadOperator } = await ensureParty('vesting-operator') + console.log(`operator ${operator}${hadOperator ? ' (existing)' : ''}`) + + const pkg = process.env.PKG ?? (await resolvePackage(operator)) + const factoryTid = `${pkg}:Vesting:VestingFactory` + console.log(`package ${pkg}${process.env.PKG === undefined ? '' : ' (from PKG)'}`) + + let active = await findFactory(operator) + const hadFactory = active !== undefined + if (!hadFactory) { + await ledger('post', '/v2/commands/submit-and-wait-for-transaction-tree', { + commandId: `vesting-factory-${STAMP}`, + actAs: [operator], + readAs: [operator], + commands: [ + { CreateCommand: { templateId: factoryTid, createArguments: { factoryOwner: operator } } }, + ], + }) + // Read back from the ACS rather than out of the submission response, so this does not depend + // on where a given Canton version puts a create result in the envelope. + active = await findFactory(operator) + } + if (active === undefined) { + throw new Error('factory created but no createdEventBlob came back from the ACS read') + } + console.log(`factory ${active.createdEvent.contractId}${hadFactory ? ' (existing)' : ''}`) + // The filter above takes the package *name*, so a factory signed under a superseded version of it + // is reused like any other, and the dApp takes its package id from this very contract. + const factoryPkg = active.createdEvent.templateId?.split(':')[0] + if (hadFactory && factoryPkg !== pkg) { + console.warn(`warning: that factory is on package ${factoryPkg}, not the preferred ${pkg}.`) + console.warn(' The dApp follows the contract, so a newly deployed DAR takes effect') + console.warn(' only after `canton-barebones reset`.') + } + + // Preflight before the admin exists, so an undeployed vendor/canton-token-forge.dar fails + // with PACKAGE_NAMES_NOT_FOUND rather than a raw create error and a stray party. + await resolvePackage(operator, TOKEN_FORGE_PACKAGE) + + const { party: admin } = await ensureParty('instrument-admin') + + const existingConfig = await findInstrumentConfig(admin) + if (existingConfig !== undefined) { + console.log(`admin ${admin}`) + console.log( + `instrument ${INSTRUMENT.instrumentId} (${INSTRUMENT.symbol}) ${existingConfig.contractId} (existing)`, + ) + for (const line of instrumentDrift(existingConfig.createArgument)) { + console.warn(`warning: ${line}; reset the ledger to pick that up.`) + } + writeRegistryEnv(admin) + return + } + + await ledger('post', '/v2/commands/submit-and-wait-for-transaction-tree', { + commandId: `instrument-config-${STAMP}`, + actAs: [admin], + readAs: [admin], + commands: [ + { + CreateCommand: { + templateId: REGISTRY_TEMPLATE_IDS.INSTRUMENT_CONFIG_TEMPLATE_ID, + createArguments: { + admin, + instrumentId: INSTRUMENT.instrumentId, + name: INSTRUMENT.name, + symbol: INSTRUMENT.symbol, + // Int64 is encoded as a JSON string; a bare number is rejected. + decimals: String(INSTRUMENT.decimals), + faucet: { maxPerTap: INSTRUMENT.maxPerTap }, + meta: { values: {} }, + }, + }, + }, + ], + }) + + const created = await findInstrumentConfig(admin) + if (created === undefined) { + throw new Error(`created the ${INSTRUMENT.instrumentId} InstrumentConfig but it is not active`) + } + console.log(`admin ${admin}`) + console.log(`instrument ${INSTRUMENT.instrumentId} (${INSTRUMENT.symbol}) ${created.contractId}`) + writeRegistryEnv(admin) +} + +// import.meta.main, not a comparison against argv[1]: the loader realpaths +// import.meta.filename while argv[1] keeps symlinks, so a symlinked path made the +// guard false and exited 0 having created nothing. +if (import.meta.main) { + await main() +} diff --git a/example-dapps/token-vesting/scripts/bootstrap-vesting.test.mjs b/example-dapps/token-vesting/scripts/bootstrap-vesting.test.mjs new file mode 100644 index 00000000..a1cc9338 --- /dev/null +++ b/example-dapps/token-vesting/scripts/bootstrap-vesting.test.mjs @@ -0,0 +1,76 @@ +// @vitest-environment node +import assert from 'node:assert/strict' +import { parseEnv } from 'node:util' +import { describe, it } from 'vitest' +import { + formatRegistryEnv, + instrumentDrift, + REGISTRY_PORT, + REGISTRY_TEMPLATE_IDS, +} from './bootstrap-vesting.mjs' + +const block = formatRegistryEnv({ + ledgerApiUrl: 'http://localhost:2975', + adminParty: 'admin-1::abc', + port: REGISTRY_PORT, +}) + +describe('the .env.registry bootstrap writes', () => { + it('quotes every template id, so a dotenv parse keeps the leading #', () => { + const parsed = parseEnv(block) + for (const [key, id] of Object.entries(REGISTRY_TEMPLATE_IDS)) { + assert.ok(block.split('\n').includes(`${key}='${id}'`)) + assert.equal(parsed[key], id) + assert.ok(id.startsWith('#canton-token-forge:'), `${key} is not package-name form`) + } + }) + + it('carries the party and the url only this run knows', () => { + assert.match(block, /^ADMIN_PARTY=admin-1::abc$/m) + assert.match(block, /^LEDGER_API_URL=http:\/\/localhost:2975$/m) + assert.match(block, /^PORT=3013$/m) + }) + + it('leaves the bearer token out, since registry.sh passes it in the environment', () => { + assert.doesNotMatch(block, /LEDGER_API_TOKEN/) + assert.doesNotMatch(block, /eyJ[A-Za-z0-9_-]+\./) + }) + + it('holds the registry settings in a fixed order', () => { + assert.deepEqual( + block.split('\n').map((line) => line.split('=')[0]), + ['LEDGER_API_URL', 'ADMIN_PARTY', ...Object.keys(REGISTRY_TEMPLATE_IDS), 'PORT'], + ) + }) +}) + +// The InstrumentConfig is admin-signed and created once, so a bootstrap that finds one reuses it +// whatever this script now asks for, and saying nothing would make the constants write-once. +describe('the drift between the ledger and the INSTRUMENT constants', () => { + const onLedger = { + admin: 'instrument-admin::ns', + instrumentId: 'DBT', + name: 'dAppBooster Token', + symbol: 'DBT', + decimals: '10', + faucet: { maxPerTap: '1000.0000000000' }, + } + + it('reports none for the config this script would have created', () => { + assert.deepEqual(instrumentDrift(onLedger), []) + }) + + it('compares the tap ceiling as a number, since Daml pads a Decimal to its full scale', () => { + assert.deepEqual(instrumentDrift({ ...onLedger, faucet: { maxPerTap: '5000.0000000000' } }), [ + 'maxPerTap is 5000.0000000000, this script asks for 1000.0', + ]) + }) + + it('names every field that differs, and reports a missing faucet as one', () => { + const drift = instrumentDrift({ ...onLedger, symbol: 'OLD', faucet: undefined }) + assert.deepEqual(drift, [ + 'symbol is OLD, this script asks for DBT', + 'maxPerTap is undefined, this script asks for 1000.0', + ]) + }) +}) diff --git a/example-dapps/token-vesting/scripts/registry.sh b/example-dapps/token-vesting/scripts/registry.sh new file mode 100755 index 00000000..025d4bee --- /dev/null +++ b/example-dapps/token-vesting/scripts/registry.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# +# registry.sh start|stop: the canton-token-forge registry the dApp reads its instrument from. +# +# It runs from the .env.registry that bootstrap writes. dev-stack.sh calls it through the +# `stack:up` and `stack:down` scripts and passes DEV_STACK_RUN_DIR, where the registry's log goes. + +set -euo pipefail + +APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +RUN_DIR="${DEV_STACK_RUN_DIR:-$APP_DIR/.dev-stack}" +REGISTRY_ENV="$APP_DIR/.env.registry" +REGISTRY_LOG="$RUN_DIR/registry.log" + +# The registry's own options, which bootstrap knows nothing about, passed through from the caller or .env. +REGISTRY_OPTION_KEYS=( + CORS_ORIGINS + DIRECT_TRANSFER_MARGIN_MS + LEDGER_USER_ID + SHUTDOWN_TIMEOUT_MS +) + +log() { printf '==> %s\n' "$*"; } +die() { + printf '[x] %s\n' "$*" >&2 + exit 1 +} + +# 3013 is the fallback for a `stop` that runs before bootstrap ever wrote the file. +registry_port() { + local port='' + if [ -f "$REGISTRY_ENV" ]; then + port="$(sed -n 's/^PORT=//p' "$REGISTRY_ENV")" + fi + printf '%s' "${port:-3013}" +} + +wait_until() { # wait_until + local deadline=$((SECONDS + $1)) + shift + until "$@"; do + [ "$SECONDS" -lt "$deadline" ] || return 1 + sleep 1 + done +} + +port_free() { ! lsof -nP -iTCP:"$1" -sTCP:LISTEN >/dev/null 2>&1; } + +# Matched on the registry's own path too, so another service on the port is left alone. +stop_registry() { + local pid + for pid in $(lsof -t -nP -iTCP:"$(registry_port)" -sTCP:LISTEN 2>/dev/null); do + if ps -p "$pid" -o args= 2>/dev/null | grep -q 'canton-token-forge/registry/dist'; then + log "Stopping the token registry (pid $pid)" + kill "$pid" 2>/dev/null || true + fi + done +} + +start_registry() { + local key port preset + local -a options=() + [ -f "$REGISTRY_ENV" ] || die "No $REGISTRY_ENV; run bootstrap first." + port="$(registry_port)" + + # Re-applied after sourcing, so what the caller exported beats .env, as in deploy-dar.sh. + preset="$(declare -p CANTON_BACKEND_TOKEN "${REGISTRY_OPTION_KEYS[@]}" 2>/dev/null || true)" + if [ -f "$APP_DIR/.env" ]; then + # shellcheck disable=SC1091 + source "$APP_DIR/.env" + fi + eval "$preset" + [ -n "${CANTON_BACKEND_TOKEN:-}" ] || die "CANTON_BACKEND_TOKEN is empty; the registry reads the ledger with it." + # Set but empty is passed on: for CORS_ORIGINS it is the only way to allow no origin at all. + for key in "${REGISTRY_OPTION_KEYS[@]}"; do + if [ -n "${!key+set}" ]; then + options+=("$key=${!key}") + fi + done + + # Restarted rather than reused: a leftover one was configured by an earlier run, before any reset. + if ! port_free "$port"; then + log "Port $port in use; restarting the token registry." + stop_registry + # Past the registry's SHUTDOWN_TIMEOUT_MS, 8s by default, which a draining registry can take. + wait_until 20 port_free "$port" || die "Port $port is still held after 20s; free it, then run 'up' again." + fi + + mkdir -p "$RUN_DIR" + log "Starting the token registry -> http://localhost:$port" + # The token travels in the environment, where `ps` cannot show it. + LEDGER_API_TOKEN="$CANTON_BACKEND_TOKEN" \ + nohup env DOTENV_CONFIG_PATH="$REGISTRY_ENV" ${options[@]+"${options[@]}"} \ + "$APP_DIR/node_modules/.bin/canton-token-forge-registry" >"$REGISTRY_LOG" 2>&1 & + + # /readyz reads the ledger end, so a 2xx proves the registry reaches the participant. + wait_until 60 curl -fs -o /dev/null -m 2 "http://localhost:$port/readyz" \ + || die "The token registry is not ready on $port (log: $REGISTRY_LOG)." + log "Token registry ready on http://localhost:$port (log: $REGISTRY_LOG)" +} + +case "${1:-}" in + start) start_registry ;; + stop) stop_registry ;; + *) die "Usage: scripts/registry.sh start|stop" ;; +esac diff --git a/example-dapps/token-vesting/src/App.tsx b/example-dapps/token-vesting/src/App.tsx new file mode 100644 index 00000000..4116436f --- /dev/null +++ b/example-dapps/token-vesting/src/App.tsx @@ -0,0 +1,32 @@ +import { type CantonConnectConfig, CantonConnectProvider } from '@bootnodedev/canton-connect' +import { ThemeProvider } from '@bootnodedev/canton-dappbooster' +import { RemoteAdapter } from '@canton-network/dapp-sdk' +import { LucideProvider } from 'lucide-react' +import { createBrowserRouter, RouterProvider } from 'react-router-dom' +import { Backend } from '@/providers/Backend' +import { Tokens } from '@/providers/Tokens' +import { routes } from '@/routes' +import { NETWORK_ID, WALLET_CONNECT_PROJECT_ID, WALLET_GATEWAY_URL } from '@/utils/config' + +const router = createBrowserRouter(routes) + +const connectConfig: CantonConnectConfig = { + appName: 'Canton Vesting', + networkId: NETWORK_ID, + walletConnectProjectId: WALLET_CONNECT_PROJECT_ID, + additionalAdapters: [new RemoteAdapter({ name: 'Wallet Gateway', rpcUrl: WALLET_GATEWAY_URL })], +} + +export const App = (): React.JSX.Element => ( + + + + + + + + + + + +) diff --git a/example-dapps/token-vesting/src/assets/gateway-login.png b/example-dapps/token-vesting/src/assets/gateway-login.png new file mode 100644 index 00000000..dddc05c2 Binary files /dev/null and b/example-dapps/token-vesting/src/assets/gateway-login.png differ diff --git a/example-dapps/token-vesting/src/assets/gateway-parties.png b/example-dapps/token-vesting/src/assets/gateway-parties.png new file mode 100644 index 00000000..1aa28dbb Binary files /dev/null and b/example-dapps/token-vesting/src/assets/gateway-parties.png differ diff --git a/example-dapps/token-vesting/src/assets/wallet-picker.png b/example-dapps/token-vesting/src/assets/wallet-picker.png new file mode 100644 index 00000000..d3bf8509 Binary files /dev/null and b/example-dapps/token-vesting/src/assets/wallet-picker.png differ diff --git a/example-dapps/token-vesting/src/backend/LedgerBackend.test.ts b/example-dapps/token-vesting/src/backend/LedgerBackend.test.ts new file mode 100644 index 00000000..7e4d6f4e --- /dev/null +++ b/example-dapps/token-vesting/src/backend/LedgerBackend.test.ts @@ -0,0 +1,1161 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { encodeSchedule } from '@/backend/commands' +import type { Deployment } from '@/backend/config' +import { LedgerBackend } from '@/backend/LedgerBackend' +import type { DisclosedContract, LedgerCommand, WalletFns } from '@/backend/wallet' +import { addAmounts, isZero, subtractAmounts } from '@/utils/amount' + +const deployment: Deployment = { + admin: 'instrument-admin::1', + factoryBlob: 'YmxvYg==', + factoryCid: 'factory-cid', + instrumentId: 'DBT', + pkg: 'pkg1', + synchronizerId: 'sync::1', +} + +const schedule = { + cliff: '2026-01-01T00:00:00Z', + curve: { kind: 'linear', start: '2026-01-01T00:00:00Z', end: '2027-01-01T00:00:00Z' }, +} as const + +const TOKEN = '#canton-token-forge:Canton.TokenForge.Token:Token' +const PENDING = '#vesting:Vesting:VestingProposal' +const CONTRACT = '#vesting:Vesting:VestingContract' +const CLAIM = '#vesting:Vesting:VestedClaim' + +type Submission = { + actAs?: string[] + commands?: LedgerCommand[] + disclosedContracts?: DisclosedContract[] + synchronizerId?: string +} + +// As much of the ACS query LedgerBackend builds as these tests read back, named once so the two +// accessors below share it rather than each casting the body to its own shape. +type PartyFilter = { + cumulative?: { + identifierFilter?: { + TemplateFilter?: { value?: { includeCreatedEventBlob?: boolean; templateId?: string } } + } + }[] +} + +type AcsQuery = { + activeAtOffset?: unknown + filter?: { filtersByParty?: Record } +} + +type Read = { + requestMethod: string + resource: string + body?: AcsQuery +} + +const byParty = (read: Read): Record => read.body?.filter?.filtersByParty ?? {} + +// The template a read filters on, which is what a party-scoped ACS query is keyed by here. +const filteredTemplate = (read: Read): string | undefined => + Object.values(byParty(read))[0]?.cumulative?.[0]?.identifierFilter?.TemplateFilter?.value + ?.templateId + +const filteredParty = (read: Read): string | undefined => Object.keys(byParty(read))[0] + +const readsBlobs = (read: Read): boolean => + byParty(read)[filteredParty(read) ?? '']?.cumulative?.[0]?.identifierFilter?.TemplateFilter?.value + ?.includeCreatedEventBlob === true + +const row = (contractId: string, arg: Record): unknown => ({ + contractEntry: { JsActiveContract: { createdEvent: { contractId, createArgument: arg } } }, +}) + +const tokenRow = (contractId: string, amount: string, overrides: object = {}): unknown => ({ + contractEntry: { + JsActiveContract: { + createdEvent: { + contractId, + createArgument: { admin: 'instrument-admin::1', instrumentId: 'DBT', amount, ...overrides }, + createdEventBlob: `blob-${contractId}`, + templateId: 'tfpkg:Canton.TokenForge.Token:Token', + }, + }, + }, +}) + +const disclosedToken = (contractId: string): DisclosedContract => ({ + templateId: 'tfpkg:Canton.TokenForge.Token:Token', + contractId, + createdEventBlob: `blob-${contractId}`, +}) + +// An outstanding grant of this deployment's instrument, reserving the named holding. +const reserving = (tokenCid: string): unknown => + row(`pending-${tokenCid}`, { + admin: 'instrument-admin::1', + instrumentId: 'DBT', + provider: 'operator::1', + proposer: 'funder::1', + receiver: 'receiver::1', + totalAmount: '1000', + tokenCid, + schedule: encodeSchedule(schedule), + note: 'Advisor grant', + }) + +// What the funder kept for the receiver, read the way the backend keys it: `accept` picks one blob +// out of this, so only a direct look says whether the store itself is growing. +const storedTokens = (): DisclosedContract[] => + JSON.parse(localStorage.getItem('vesting.tokenDisclosures') ?? '[]') + +// The other half of what a grant leaves behind: how many blob-bearing reads this browser has spent +// looking for a holding it never found. +const readMisses = (): Record => + JSON.parse(localStorage.getItem('vesting.tokenReadMisses') ?? '{}') + +const CONFIG = { + templateId: '20d54824:Canton.TokenForge.Registry:InstrumentConfig', + contractId: '00cfg', + createdEventBlob: 'cfg-blob', +} + +// LedgerBackend owes putting whatever comes back into every write, so the fetch is replaced rather +// than stubbed, and what the registry returns is registry.test.ts's rule. Hoisted, because a vi.mock +// factory is lifted above every other statement in the file. +const registryAnswer = vi.hoisted(() => ({ synchronizerId: undefined as string | undefined })) + +vi.mock('@/backend/registry', () => ({ + fetchInstrumentConfig: async () => ({ + configCid: CONFIG.contractId, + configTemplateId: CONFIG.templateId, + // Stamped the way the real client leaves it, because the registry is what vouches for where its + // own config lives. + disclosed: [ + registryAnswer.synchronizerId === undefined + ? CONFIG + : { ...CONFIG, synchronizerId: registryAnswer.synchronizerId }, + ], + synchronizerId: registryAnswer.synchronizerId, + }), +})) + +type Created = { + contractId?: string + createArgument?: { admin?: string; amount?: string; instrumentId?: string } +} + +const createdEvent = (contract: unknown): Created => + (contract as { contractEntry?: { JsActiveContract?: { createdEvent?: Created } } }).contractEntry + ?.JsActiveContract?.createdEvent ?? {} + +const cidOf = (contract: unknown): string => createdEvent(contract).contractId ?? '' + +// The harness behaves like the ledger for the one submission a grant takes: the factory splits the +// holdings it is given, so those are consumed and replaced by one holding of exactly the grant plus +// the funder's change, and the pending grant names the first of the two. Without that, a later +// selection would happily spend the holding an outstanding grant is waiting on, and no holding +// would exist for the funder to disclose to the receiver. +const settle = (acs: Record, submission: Submission): void => { + const exercise = submission.commands?.[0]?.ExerciseCommand + if (exercise?.choice !== 'VestingFactory_CreateVesting') { + return + } + const { note, receiver, tokenCids, totalAmount } = exercise.choiceArgument as { + note: string | null + receiver: string + tokenCids: string[] + totalAmount: string + } + const held = acs[TOKEN] ?? [] + const spent = held.filter((contract) => tokenCids.includes(cidOf(contract))) + const change = subtractAmounts( + addAmounts(...spent.map((contract) => createdEvent(contract).createArgument?.amount ?? '0')), + totalAmount, + ) + // The choice takes no instrument of its own: what comes out is the instrument of what went in, so + // a test that funds from another admin's holdings gets a proposal of that admin here too. + const { admin, instrumentId } = createdEvent(spent[0]).createArgument ?? {} + const instrument = { admin, instrumentId } + const funding = tokenRow(`funding-${tokenCids[0]}`, totalAmount, instrument) + const returned = isZero(change) ? [] : [tokenRow(`change-${tokenCids[0]}`, change, instrument)] + const pending = row(`pending-for-${tokenCids[0]}`, { + ...instrument, + provider: 'operator::1', + proposer: submission.actAs?.[0], + receiver, + totalAmount, + tokenCid: cidOf(funding), + note, + }) + acs[TOKEN] = [ + ...held.filter((contract) => !tokenCids.includes(cidOf(contract))), + funding, + ...returned, + ] + acs[PENDING] = [...(acs[PENDING] ?? []), pending] +} + +// The deployment names one here, so every disclosure it does not already carry one for is filled in +// with it. +const onSync = (contracts: T[]): (T & { synchronizerId: string })[] => + contracts.map((contract) => ({ ...contract, synchronizerId: 'sync::1' })) + +const harness = ( + options: { + acs?: Record + declines?: boolean + deployment?: Deployment + ledgerEnd?: unknown + readsFailAfterSubmit?: boolean + readsFailOnBlobs?: boolean + } = {}, +): { backend: LedgerBackend; submissions: Submission[]; reads: Read[] } => { + const { + acs = { [TOKEN]: [tokenRow('t1', '1000')] }, + declines = false, + ledgerEnd = { offset: 42 }, + deployment: config = deployment, + readsFailAfterSubmit = false, + readsFailOnBlobs = false, + } = options + const submissions: Submission[] = [] + const reads: Read[] = [] + const wallet: WalletFns = { + execute: async (params) => { + if (declines) { + throw new Error('user rejected the request') + } + const submission = params as Submission + submissions.push(submission) + settle(acs, submission) + // The wallet answers with the transaction's own ids, none of which name a contract it + // created. + return { tx: { payload: { updateId: `update-${submissions.length}` } } } + }, + ledgerApi: async (params) => { + const read = params as Read + reads.push(read) + if (readsFailAfterSubmit && submissions.length > 0) { + throw new Error('the participant is not answering') + } + if (readsFailOnBlobs && read.body?.filter !== undefined && readsBlobs(read)) { + throw new Error('the participant is not answering') + } + if (read.resource === '/v2/state/ledger-end') { + return ledgerEnd + } + return acs[filteredTemplate(read) ?? ''] ?? [] + }, + } + return { backend: new LedgerBackend(config, wallet), submissions, reads } +} + +beforeEach(() => { + localStorage.clear() + registryAnswer.synchronizerId = undefined +}) + +describe('LedgerBackend.createVesting', () => { + const grant = { + proposer: 'funder::1', + receiver: 'receiver::1', + totalAmount: '1000', + schedule, + title: 'Advisor grant', + note: 'linear', + } + + // One submission, not two: the factory splits the funder's inputs itself, so the funder never has + // to pre-split and the change comes back in the same transaction. + it('funds the grant in a single submission', async () => { + const { backend, submissions } = harness({ + acs: { [TOKEN]: [tokenRow('t1', '600'), tokenRow('t2', '900')] }, + }) + + await backend.createVesting(grant) + + expect(submissions).toHaveLength(1) + expect(submissions[0]?.commands?.[0]?.ExerciseCommand.choice).toBe( + 'VestingFactory_CreateVesting', + ) + }) + + it('names the largest holdings first, and only as many as cover the total', async () => { + const { backend, submissions } = harness({ + acs: { [TOKEN]: [tokenRow('small', '100'), tokenRow('big', '900'), tokenRow('mid', '400')] }, + }) + + await backend.createVesting(grant) + + expect(submissions[0]?.commands?.[0]?.ExerciseCommand.choiceArgument.tokenCids).toEqual([ + 'big', + 'mid', + ]) + }) + + it('leaves out the holding an outstanding grant has already reserved', async () => { + const { backend, submissions } = harness({ + acs: { + [TOKEN]: [tokenRow('pledged', '1000'), tokenRow('free', '1000')], + [PENDING]: [row('p1', { tokenCid: 'pledged' })], + }, + }) + + await backend.createVesting(grant) + + expect(submissions[0]?.commands?.[0]?.ExerciseCommand.choiceArgument.tokenCids).toEqual([ + 'free', + ]) + }) + + it('refuses when what is left unreserved cannot cover the grant', async () => { + const { backend } = harness({ + acs: { + [TOKEN]: [tokenRow('pledged', '1000'), tokenRow('free', '400')], + [PENDING]: [row('p1', { tokenCid: 'pledged' })], + }, + }) + + await expect(backend.createVesting(grant)).rejects.toThrow(/only 400 DBT is free/) + }) + + it('refuses a grant the funder holds nothing for', async () => { + const { backend } = harness({ acs: {} }) + + await expect(backend.createVesting(grant)).rejects.toThrow(/only 0 DBT is free/) + }) + + // A shared participant can carry another admin's DBT, and spending one would fail at the + // transfer's own instrument check rather than here. + it('ignores a holding of another instrument entirely', async () => { + const { backend } = harness({ + acs: { [TOKEN]: [tokenRow('foreign', '5000', { admin: 'other::1' })] }, + }) + + await expect(backend.createVesting(grant)).rejects.toThrow(/only 0 DBT is free/) + }) + + it('exercises the factory choice with the composed note, schedule and config', async () => { + const { backend, submissions } = harness({ acs: { [TOKEN]: [tokenRow('t1', '1000')] } }) + + await backend.createVesting(grant) + + expect(submissions[0]?.commands).toEqual([ + { + ExerciseCommand: { + templateId: 'pkg1:Vesting:VestingFactory', + contractId: 'factory-cid', + choice: 'VestingFactory_CreateVesting', + choiceArgument: { + proposer: 'funder::1', + receiver: 'receiver::1', + totalAmount: '1000', + schedule: encodeSchedule(schedule), + tokenCids: ['t1'], + configCid: '00cfg', + note: 'Advisor grant\nlinear', + }, + }, + }, + ]) + // The config because the split runs against it, the factory because the funder is not a + // stakeholder of the observer-less factory and so cannot read it. The inputs are the funder's + // own, so they need no disclosure. + expect(submissions[0]?.disclosedContracts).toEqual( + onSync([ + CONFIG, + { + templateId: 'pkg1:Vesting:VestingFactory', + contractId: 'factory-cid', + createdEventBlob: 'YmxvYg==', + }, + ]), + ) + }) + + it('omits the synchronizer id when neither the deployment nor the registry names one', async () => { + const { synchronizerId, ...rest } = deployment + const { backend, submissions } = harness({ deployment: rest }) + + await backend.createVesting(grant) + + expect(submissions[0]?.disclosedContracts?.[0]).not.toHaveProperty('synchronizerId') + expect(submissions[0]).not.toHaveProperty('synchronizerId') + }) + + // The config the registry discloses is rebuilt without the synchronizer it arrived stamped with, + // so what the registry knows would be thrown away rather than used where the deployment is silent. + it('falls back to the synchronizer the registry stamped when the deployment names none', async () => { + const { synchronizerId, ...rest } = deployment + registryAnswer.synchronizerId = 'sync::1' + const { backend, submissions } = harness({ deployment: rest }) + + await backend.createVesting(grant) + + expect(submissions[0]?.synchronizerId).toBe('sync::1') + expect(submissions[0]?.disclosedContracts?.[0]).toHaveProperty('synchronizerId', 'sync::1') + }) + + // The registry vouches for its own config and for nothing else, so its answer cannot travel onto + // the factory's blob: where that contract lives is the deployment's to say or nobody's. + it('leaves the factory blob unstamped when the deployment names no synchronizer', async () => { + const { synchronizerId, ...rest } = deployment + registryAnswer.synchronizerId = 'sync::1' + const { backend, submissions } = harness({ deployment: rest }) + + await backend.createVesting(grant) + + expect(submissions[0]?.disclosedContracts?.[1]).not.toHaveProperty('synchronizerId') + }) + + // One submission names one synchronizer and the two contracts it needs come from two sources, so + // a disagreement has to be said here: sent, it is a rejection that names neither. + it('refuses to submit when the registry and the deployment disagree on the synchronizer', async () => { + registryAnswer.synchronizerId = 'sync::2' + const { backend, submissions } = harness() + + await expect(backend.createVesting(grant)).rejects.toThrow(/sync::2.*sync::1/) + expect(submissions).toHaveLength(0) + }) + + // The acceptance criterion this issue exists for: the funder keeps everything the grant did not + // take, rather than the whole holding it was funded from. + it('leaves the funder everything the grant did not reserve', async () => { + const { backend } = harness({ acs: { [TOKEN]: [tokenRow('t1', '1500')] } }) + + await backend.createVesting(grant) + + await expect(backend.balanceOf('funder::1')).resolves.toBe('500') + }) + + // The holding the receiver will have to disclose is created by this very submission, so the read + // that carries its blob runs after the write and not with the selection before it. + it('reads the split holding back only once the grant is on the ledger', async () => { + const { backend, reads } = harness({ acs: { [TOKEN]: [tokenRow('t1', '1500')] } }) + + await backend.createVesting(grant) + + const withBlobs = reads.filter( + (read) => + read.body?.filter !== undefined && readsBlobs(read) && filteredTemplate(read) === TOKEN, + ) + expect(withBlobs).toHaveLength(1) + }) + + // The grant is on the ledger by then, so reporting a failure would invite the funder to make a + // second one; the Accept is what says the blob is missing. + it('reports a grant whose funding holding could not be read back as created', async () => { + const { backend } = harness({ + acs: { [TOKEN]: [tokenRow('t1', '1500')] }, + readsFailAfterSubmit: true, + }) + + await expect(backend.createVesting(grant)).resolves.toBeUndefined() + }) +}) + +describe('LedgerBackend.balanceOf', () => { + it('sums the unreserved holdings of this deployment’s instrument', async () => { + const { backend } = harness({ + acs: { + [TOKEN]: [ + tokenRow('a', '600'), + tokenRow('b', '400'), + tokenRow('reserved', '900'), + tokenRow('foreign', '5000', { instrumentId: 'OTHER' }), + ], + [PENDING]: [row('p1', { tokenCid: 'reserved' })], + }, + }) + + await expect(backend.balanceOf('funder::1')).resolves.toBe('1000') + }) +}) + +describe('LedgerBackend.tap', () => { + // The config is nonconsuming and admin-signed, so its disclosure is the whole of what a tap needs + // and the resolved template id has to come from that disclosure rather than from the deployment. + it('exercises the faucet on the disclosed config, naming the connected party', async () => { + const { backend, submissions } = harness() + + await backend.tap({ amount: '1000', party: 'funder::1' }) + + expect(submissions[0]?.commands).toEqual([ + { + ExerciseCommand: { + templateId: CONFIG.templateId, + contractId: '00cfg', + choice: 'InstrumentConfig_Tap', + choiceArgument: { user: 'funder::1', amount: '1000' }, + }, + }, + ]) + expect(submissions[0]?.disclosedContracts).toEqual(onSync([CONFIG])) + expect(submissions[0]?.actAs).toEqual(['funder::1']) + }) +}) + +// Every write goes out as the party the UI is acting as, rather than left to the wallet's own +// primary account: a mismatch is then a participant rejection, not a submission signed by the +// wrong key. +describe('LedgerBackend submissions', () => { + it('sends actAs for the acting party on every choice', async () => { + const { backend, submissions } = harness() + + await backend.createVesting({ + proposer: 'funder::1', + receiver: 'receiver::1', + totalAmount: '1000', + schedule, + title: 'Advisor grant', + }) + await backend.withdraw({ receiver: 'receiver::1', contractCid: 'c1', amount: '10' }) + await backend.cancel({ creator: 'funder::1', contractCid: 'c1' }) + await backend.claimResidual({ receiver: 'receiver::1', claimCid: 'r1', amount: '10' }) + + expect(submissions.map((submission) => submission.actAs)).toEqual([ + ['funder::1'], + ['receiver::1'], + ['funder::1'], + ['receiver::1'], + ]) + }) + + it('discloses the instrument config, stamped with the synchronizer, on every write that moves a holding', async () => { + const { backend, submissions } = harness() + + await backend.withdraw({ receiver: 'receiver::1', contractCid: 'c1', amount: '10' }) + await backend.cancel({ creator: 'funder::1', contractCid: 'c1' }) + await backend.claimResidual({ receiver: 'receiver::1', claimCid: 'r1', amount: '10' }) + + const stamped = onSync([CONFIG]) + expect(submissions.map((submission) => submission.disclosedContracts)).toEqual([ + stamped, + stamped, + stamped, + ]) + expect(submissions.map((submission) => submission.synchronizerId)).toEqual([ + 'sync::1', + 'sync::1', + 'sync::1', + ]) + }) + + it('names the template and choice each write exercises', async () => { + const { backend, submissions } = harness() + + await backend.withdraw({ receiver: 'receiver::1', contractCid: 'c1', amount: '10.5' }) + await backend.cancel({ creator: 'funder::1', contractCid: 'c1' }) + await backend.claimResidual({ receiver: 'receiver::1', claimCid: 'r1', amount: '2' }) + + expect( + submissions.map((submission) => { + const command = submission.commands?.[0]?.ExerciseCommand + return [command?.templateId, command?.choice, command?.contractId] + }), + ).toEqual([ + ['pkg1:Vesting:VestingContract', 'VestingContract_Withdraw', 'c1'], + ['pkg1:Vesting:VestingContract', 'VestingContract_Cancel', 'c1'], + ['pkg1:Vesting:VestedClaim', 'VestedClaim_Withdraw', 'r1'], + ]) + }) + + it('carries the config into every choice argument that takes one', async () => { + const { backend, submissions } = harness() + + await backend.withdraw({ receiver: 'receiver::1', contractCid: 'c1', amount: '10' }) + + expect(submissions[0]?.commands?.[0]?.ExerciseCommand.choiceArgument).toEqual({ + withdrawAmount: '10', + configCid: '00cfg', + }) + }) +}) + +// Accept is the one write disclosing contracts the submitting party cannot read for itself: the +// funder's holdings, whose blobs were kept when the funder created the pending grant. +describe('LedgerBackend.accept', () => { + const grant = (title: string) => ({ + proposer: 'funder::1', + receiver: 'receiver::1', + totalAmount: '1000', + schedule, + title, + }) + + it('discloses the holding the grant names and nothing else it has ever stored', async () => { + const acs: Record = { + [TOKEN]: [tokenRow('t1', '1500'), tokenRow('t2', '1500')], + } + const funder = harness({ acs }) + await funder.backend.createVesting(grant('First grant')) + await funder.backend.createVesting(grant('Second grant')) + const { backend, submissions } = harness({ acs }) + + await backend.accept({ receiver: 'receiver::1', pendingCid: 'pending-for-t1' }) + + // The split output, never the input it came out of: that one is archived, and re-disclosing it + // would only fail the transfer's own fetch. + expect(submissions[0]?.disclosedContracts).toEqual( + onSync([CONFIG, disclosedToken('funding-t1')]), + ) + expect(submissions[0]?.commands?.[0]?.ExerciseCommand.choice).toBe('VestingProposal_Accept') + }) + + // A blob the funder never handed over and a grant that is simply gone are two different things, + // and telling a stale dashboard about the blob store sends the receiver looking in the wrong + // place entirely. + it('refuses an Accept whose blob this browser never kept', async () => { + const acs: Record = { [TOKEN]: [tokenRow('t1', '1500')] } + const funder = harness({ acs }) + await funder.backend.createVesting(grant('First grant')) + localStorage.clear() + const { backend, submissions } = harness({ acs }) + + await expect( + backend.accept({ receiver: 'receiver::1', pendingCid: 'pending-for-t1' }), + ).rejects.toThrow(/not disclosable/) + expect(submissions).toHaveLength(0) + }) + + it('refuses an Accept for a grant no longer in the receiver’s view', async () => { + const { backend, submissions } = harness() + + await expect( + backend.accept({ receiver: 'receiver::1', pendingCid: 'pending-for-t1' }), + ).rejects.toThrow(/no longer outstanding/) + expect(submissions).toHaveLength(0) + }) + + // Why every outstanding grant is reconciled and not only the one just made: a read that failed + // once would otherwise leave a grant nobody can ever accept. + it('picks up a blob a failed read left behind when the next grant is created', async () => { + const acs: Record = { + [TOKEN]: [tokenRow('t1', '1500'), tokenRow('t2', '1500')], + } + const flaky = harness({ acs, readsFailAfterSubmit: true }) + await flaky.backend.createVesting(grant('First grant')) + const funder = harness({ acs }) + await funder.backend.createVesting(grant('Second grant')) + const { backend, submissions } = harness({ acs }) + + await backend.accept({ receiver: 'receiver::1', pendingCid: 'pending-for-t1' }) + + expect(submissions[0]?.disclosedContracts).toEqual( + onSync([CONFIG, disclosedToken('funding-t1')]), + ) + }) + + // The funder may never create a second grant, so the next grant cannot be the only repair: their + // own dashboard is where an unacceptable grant would otherwise sit unnoticed forever. + it('picks up a blob a failed read left behind on the funder’s next view', async () => { + const acs: Record = { [TOKEN]: [tokenRow('t1', '1500')] } + const flaky = harness({ acs, readsFailAfterSubmit: true }) + await flaky.backend.createVesting(grant('First grant')) + const funder = harness({ acs }) + await funder.backend.viewAs('funder::1') + const { backend, submissions } = harness({ acs }) + + await backend.accept({ receiver: 'receiver::1', pendingCid: 'pending-for-t1' }) + + expect(submissions[0]?.disclosedContracts).toEqual( + onSync([CONFIG, disclosedToken('funding-t1')]), + ) + }) + + it('keeps the blob of a live grant when a later one is declined in the wallet', async () => { + const acs: Record = { [TOKEN]: [tokenRow('t1', '2500')] } + const first = harness({ acs }) + await first.backend.createVesting(grant('First grant')) + const declined = harness({ acs: { [TOKEN]: [tokenRow('t9', '1000')] }, declines: true }) + await expect(declined.backend.createVesting(grant('Second grant'))).rejects.toThrow(/rejected/) + const { backend, submissions } = harness({ acs }) + + await backend.accept({ receiver: 'receiver::1', pendingCid: 'pending-for-t1' }) + + expect(submissions[0]?.disclosedContracts).toEqual( + onSync([CONFIG, disclosedToken('funding-t1')]), + ) + }) + + // A miss counted before the blob was finally found used to outlive the grant entirely: `accept` + // dropped the blob and left the count behind for a contract id that can never come back. + it('drops a stale miss count when the grant is finally accepted', async () => { + const missed = harness({ + acs: { [TOKEN]: [tokenRow('unrelated', '500')], [PENDING]: [reserving('funding-t1')] }, + }) + await missed.backend.viewAs('funder::1') + expect(readMisses()['funding-t1']).toBe(1) + const found = harness({ + acs: { [TOKEN]: [tokenRow('funding-t1', '1000')], [PENDING]: [reserving('funding-t1')] }, + }) + await found.backend.viewAs('funder::1') + + await found.backend.accept({ receiver: 'receiver::1', pendingCid: 'pending-funding-t1' }) + + expect(storedTokens()).toEqual([]) + expect(readMisses()).toEqual({}) + }) +}) + +// Both exits are bodyless and archive the proposal on the controller's own authority, so the +// interesting part is not the ledger but what stops being kept in this browser afterwards. +describe('LedgerBackend.cancelProposal and rejectProposal', () => { + const grant = (title = 'Advisor grant') => ({ + proposer: 'funder::1', + receiver: 'receiver::1', + totalAmount: '1000', + schedule, + title, + }) + + it('cancels as the funder, disclosing nothing', async () => { + const acs: Record = { [TOKEN]: [tokenRow('t1', '1500')] } + const { backend, submissions } = harness({ acs }) + await backend.createVesting(grant()) + + await backend.cancelProposal({ proposer: 'funder::1', pendingCid: 'pending-for-t1' }) + + const submission = submissions.at(-1) + expect(submission?.commands?.[0]?.ExerciseCommand.choice).toBe('VestingProposal_Cancel') + expect(submission?.actAs).toEqual(['funder::1']) + // Empty, not the config: nothing is disclosed, which is also what proves the registry was + // never asked. + expect(submission?.disclosedContracts).toEqual([]) + // The one write that discloses nothing, so the only one where a synchronizer stamped on the + // disclosures alone would leave the wallet to pick its own. + expect(submission?.synchronizerId).toBe('sync::1') + }) + + // The exits fetch no config, so the registry's answer has to be the one an earlier write kept: + // without it these two would be the only writes naming no synchronizer at all, on a deployment + // where every other write names the registry's. + it('names the synchronizer an earlier write learned from the registry', async () => { + const { synchronizerId, ...rest } = deployment + registryAnswer.synchronizerId = 'sync::9' + const acs: Record = { [TOKEN]: [tokenRow('t1', '1500')] } + const { backend, submissions } = harness({ acs, deployment: rest }) + await backend.createVesting(grant()) + + await backend.cancelProposal({ proposer: 'funder::1', pendingCid: 'pending-for-t1' }) + + expect(submissions.at(-1)?.disclosedContracts).toEqual([]) + expect(submissions.at(-1)?.synchronizerId).toBe('sync::9') + }) + + it('rejects as the receiver, disclosing nothing', async () => { + const acs: Record = { [TOKEN]: [tokenRow('t1', '1500')] } + const funder = harness({ acs }) + await funder.backend.createVesting(grant()) + const { backend, submissions } = harness({ acs }) + + await backend.rejectProposal({ receiver: 'receiver::1', pendingCid: 'pending-for-t1' }) + + expect(submissions[0]?.commands?.[0]?.ExerciseCommand.choice).toBe('VestingProposal_Reject') + expect(submissions[0]?.actAs).toEqual(['receiver::1']) + expect(submissions[0]?.disclosedContracts).toEqual([]) + }) + + // The read is for the prune, never for a guard: the receiver is not who the blob belongs to, so a + // participant that will not answer must cost them a stale entry rather than the decline itself. + it('declines even when the proposal cannot be read', async () => { + const submissions: Submission[] = [] + const backend = new LedgerBackend(deployment, { + execute: async (params) => { + submissions.push(params as Submission) + return {} + }, + ledgerApi: async () => { + throw new Error('the participant is not answering') + }, + }) + + await backend.rejectProposal({ receiver: 'receiver::1', pendingCid: 'pending-for-t1' }) + + expect(submissions[0]?.commands?.[0]?.ExerciseCommand.choice).toBe('VestingProposal_Reject') + }) + + // Both parties are accounts of one browser, so the grant a receiver declines can be one this same + // browser funded and still holds the blob for. Nothing else would ever drop it. + it('drops the blob of a grant the receiver declined', async () => { + const acs: Record = { + [TOKEN]: [tokenRow('t1', '1500'), tokenRow('t2', '1500')], + } + const { backend } = harness({ acs }) + await backend.createVesting(grant('First grant')) + await backend.createVesting(grant('Second grant')) + + await backend.rejectProposal({ receiver: 'receiver::1', pendingCid: 'pending-for-t1' }) + + expect(storedTokens().map((one) => one.contractId)).toEqual(['funding-t2']) + }) + + it('drops the blob of the grant it ended and keeps every other one', async () => { + const acs: Record = { + [TOKEN]: [tokenRow('t1', '1500'), tokenRow('t2', '1500')], + } + const { backend } = harness({ acs }) + await backend.createVesting(grant('First grant')) + await backend.createVesting(grant('Second grant')) + + await backend.cancelProposal({ proposer: 'funder::1', pendingCid: 'pending-for-t1' }) + + expect(storedTokens().map((one) => one.contractId)).toEqual(['funding-t2']) + }) + + it('drops the read misses counted against the grant it ended', async () => { + const { backend } = harness({ + acs: { [TOKEN]: [tokenRow('t1', '500')], [PENDING]: [reserving('archived-elsewhere')] }, + }) + await backend.viewAs('funder::1') + expect(readMisses()['archived-elsewhere']).toBe(1) + + await backend.cancelProposal({ + proposer: 'funder::1', + pendingCid: 'pending-archived-elsewhere', + }) + + expect(readMisses()).toEqual({}) + }) + + it('refuses to end a grant that is no longer outstanding', async () => { + const { backend, submissions } = harness() + + await expect( + backend.cancelProposal({ proposer: 'funder::1', pendingCid: 'pending-for-t1' }), + ).rejects.toThrow(/no longer outstanding/) + expect(submissions).toHaveLength(0) + }) + + // A prompt the wallet declines leaves a grant that is still outstanding and still acceptable, so + // forgetting its blob would break the one thing this browser is holding for it. + it('keeps the blob when the wallet declines the cancel', async () => { + const acs: Record = { [TOKEN]: [tokenRow('t1', '1500')] } + const funder = harness({ acs }) + await funder.backend.createVesting(grant()) + const declined = harness({ acs, declines: true }) + + await expect( + declined.backend.cancelProposal({ proposer: 'funder::1', pendingCid: 'pending-for-t1' }), + ).rejects.toThrow(/rejected/) + + expect(storedTokens().map((one) => one.contractId)).toEqual(['funding-t1']) + }) + + // The other way round: the exit is already on the ledger, so a browser that refuses to write must + // not report a failure the funder would read as the grant surviving. + it('reports a cancel this browser cannot record as done', async () => { + const acs: Record = { [TOKEN]: [tokenRow('t1', '1500')] } + const { backend } = harness({ acs }) + await backend.createVesting(grant()) + const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementationOnce(() => { + throw new DOMException('storage is blocked', 'SecurityError') + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await expect( + backend.cancelProposal({ proposer: 'funder::1', pendingCid: 'pending-for-t1' }), + ).resolves.toBeUndefined() + + expect(warn).toHaveBeenCalled() + setItem.mockRestore() + warn.mockRestore() + }) + + // Criterion 3, and it costs no ledger work: the holding is excluded only while a proposal names + // it, so archiving the proposal is what returns it. + it('returns the reserved holding to the funder’s balance', async () => { + const acs: Record = { [TOKEN]: [tokenRow('t1', '1500')] } + const { backend } = harness({ acs }) + await backend.createVesting(grant()) + expect(await backend.balanceOf('funder::1')).toBe('500') + + await backend.cancelProposal({ proposer: 'funder::1', pendingCid: 'pending-for-t1' }) + // The harness settles only the factory choice, so the archive the participant would do is done + // here. + acs[PENDING] = [] + + expect(await backend.balanceOf('funder::1')).toBe('1500') + }) +}) + +describe('LedgerBackend.viewAs', () => { + // Coerced to an empty list, a reply the participant could not have meant reads as a party that + // holds nothing and owns no grants: a blank dashboard for a failure, and the reconcile below + // unable to tell a reservation that is gone from one it never saw. + it('reports a reply that is not a list of contracts rather than reading it as an empty ACS', async () => { + const backend = new LedgerBackend(deployment, { + execute: async () => ({}), + ledgerApi: async (params) => { + const read = params as Read + return read.resource === '/v2/state/ledger-end' ? { offset: 42 } : { error: 'UNAVAILABLE' } + }, + }) + + await expect(backend.viewAs('funder::1')).rejects.toThrow(/list of contracts/) + }) + + it('reads all three templates as the connected party at one shared offset', async () => { + const { backend, reads } = harness() + + await backend.viewAs('receiver::1') + + expect(reads[0]?.resource).toBe('/v2/state/ledger-end') + const acsReads = reads.slice(1) + expect(acsReads.map(filteredTemplate)).toEqual([PENDING, CONTRACT, CLAIM]) + expect(acsReads.map(filteredParty)).toEqual(['receiver::1', 'receiver::1', 'receiver::1']) + expect(acsReads.every((read) => read.body?.activeAtOffset === 42)).toBe(true) + }) + + it('maps the rows it gets back into the domain view', async () => { + const { backend } = harness({ + acs: { + [CONTRACT]: [ + row('c1', { + admin: 'instrument-admin::1', + instrumentId: 'DBT', + provider: 'operator::1', + creator: 'funder::1', + receiver: 'receiver::1', + totalAmount: '1000', + alreadyWithdrawn: '250', + schedule: encodeSchedule(schedule), + note: 'Advisor grant', + }), + ], + }, + }) + + const view = await backend.viewAs('receiver::1') + + expect(view.grants).toHaveLength(1) + expect(view.grants[0]?.alreadyWithdrawn).toBe('250') + expect(view.grants[0]?.creator).toBe('funder::1') + expect(view.pendingGrants).toEqual([]) + expect(view.claims).toEqual([]) + }) + + it('throws rather than querying at an undefined offset', async () => { + const { backend } = harness({ ledgerEnd: {} }) + await expect(backend.viewAs('receiver::1')).rejects.toThrow(/did not return an offset/) + }) + + // The reconcile is the funder's alone: only they can read the holding a grant reserves, so + // reading the holdings on a receiver's every poll would buy nothing. + it('leaves the holdings unread for a party whose pending grants are all incoming', async () => { + const acs: Record = { [TOKEN]: [tokenRow('t1', '1500')] } + const funder = harness({ acs }) + await funder.backend.createVesting({ + proposer: 'funder::1', + receiver: 'receiver::1', + totalAmount: '1000', + schedule, + title: 'Advisor grant', + }) + // Cleared, so the blob being absent is not what keeps the read away: the receiver could never + // supply it, and it is whose grant this is that decides. + localStorage.clear() + const { backend, reads } = harness({ acs }) + + await backend.viewAs('receiver::1') + + expect(reads.filter((read) => filteredTemplate(read) === TOKEN)).toEqual([]) + }) + + // A dashboard that goes blank because a background repair failed would be a worse bug than the + // one the repair is there for. + it('returns the view even when the holdings cannot be read back', async () => { + const acs: Record = { [TOKEN]: [tokenRow('t1', '1500')] } + const funder = harness({ acs }) + await funder.backend.createVesting({ + proposer: 'funder::1', + receiver: 'receiver::1', + totalAmount: '1000', + schedule, + title: 'Advisor grant', + }) + localStorage.clear() + const { backend } = harness({ acs, readsFailOnBlobs: true }) + + const view = await backend.viewAs('funder::1') + + expect(view.pendingGrants.map((one) => one.title)).toEqual(['Advisor grant']) + }) + + // Nothing on-ledger stops a funder spending the reserved holding through their wallet, which + // leaves a grant nobody can accept. The read that would find it carries a blob per holding, so it + // is given up on rather than paid for on every view from then on. + it('stops reading the holdings for a reservation that never turns up', async () => { + const { backend, reads } = harness({ + acs: { [TOKEN]: [tokenRow('t1', '500')], [PENDING]: [reserving('archived-elsewhere')] }, + }) + + for (let view = 0; view < 5; view++) { + await backend.viewAs('funder::1') + } + + expect(reads.filter((read) => filteredTemplate(read) === TOKEN)).toHaveLength(3) + }) + + // The bound stops a read this browser cannot satisfy costing one per view; it must not also mean + // the grant is unacceptable for good, so a receiver asking for it is what buys the reads back. + it('looks again once a receiver has asked for a grant it gave up on', async () => { + const acs: Record = { + [TOKEN]: [tokenRow('t1', '500')], + [PENDING]: [reserving('archived-elsewhere')], + } + const { backend, reads } = harness({ acs }) + const tokenReads = (): number => reads.filter((read) => filteredTemplate(read) === TOKEN).length + for (let view = 0; view < 5; view++) { + await backend.viewAs('funder::1') + } + expect(tokenReads()).toBe(3) + + await expect( + backend.accept({ receiver: 'receiver::1', pendingCid: 'pending-archived-elsewhere' }), + ).rejects.toThrow(/not disclosable/) + await backend.viewAs('funder::1') + + expect(tokenReads()).toBe(4) + }) + + // The state the bound exists for: the reservation is gone and the funder holds nothing else, so + // an empty read is the only answer they will ever get. `readAcs` throws rather than answering + // `[]` for a reply it could not read, so this is a real answer and counts like any other. + it('spends that budget on a read that came back empty, and no more', async () => { + const { backend, reads } = harness({ acs: { [PENDING]: [reserving('archived-elsewhere')] } }) + + for (let view = 0; view < 5; view++) { + await backend.viewAs('funder::1') + } + + expect(reads.filter((read) => filteredTemplate(read) === TOKEN)).toHaveLength(3) + }) + + // A holding whose blob the read did not carry is as unusable as one that never came back: nothing + // can be stored for it, so treating it as found would leave it wanted for good and never reach + // the limit that stops the read. + it('counts a holding that comes back without its blob as a miss', async () => { + const blobless = { + contractEntry: { + JsActiveContract: { + createdEvent: { + contractId: 'reserved', + createArgument: { admin: 'instrument-admin::1', instrumentId: 'DBT', amount: '1000' }, + }, + }, + }, + } + const { backend, reads } = harness({ + acs: { [TOKEN]: [blobless], [PENDING]: [reserving('reserved')] }, + }) + + for (let view = 0; view < 5; view++) { + await backend.viewAs('funder::1') + } + + expect(reads.filter((read) => filteredTemplate(read) === TOKEN)).toHaveLength(3) + }) + + // Two reconciles can be in flight at once, a dashboard refresh alongside a fresh grant, and each + // decides what it is missing before either has written. Appending what it found would store the + // same blob twice, and nothing else bounds the store. + it('keeps one blob per holding when two reconciles overlap', async () => { + const acs: Record = { [TOKEN]: [tokenRow('t1', '1500')] } + const funder = harness({ acs }) + await funder.backend.createVesting({ + proposer: 'funder::1', + receiver: 'receiver::1', + totalAmount: '1000', + schedule, + title: 'Advisor grant', + }) + localStorage.clear() + const { backend } = harness({ acs }) + + await Promise.all([backend.viewAs('funder::1'), backend.viewAs('funder::1')]) + + expect(storedTokens().map((one) => one.contractId)).toEqual(['funding-t1']) + }) + + // The proposal now stores the admin and the instrument it is denominated in, so a pending grant + // is filtered like the other two rather than taken on trust. + it('drops pending grants of another instrument', async () => { + const pending = (contractId: string, admin: string): unknown => + row(contractId, { + admin, + instrumentId: 'DBT', + provider: 'operator::1', + proposer: 'funder::1', + receiver: 'receiver::1', + totalAmount: '1000', + tokenCid: 'funding', + schedule: encodeSchedule(schedule), + note: 'Advisor grant', + }) + const { backend } = harness({ + acs: { [PENDING]: [pending('mine', 'instrument-admin::1'), pending('theirs', 'other::1')] }, + }) + + const view = await backend.viewAs('receiver::1') + + expect(view.pendingGrants.map((one) => one.id)).toEqual(['mine']) + }) + + // The instrument a grant is denominated in comes from the holdings it was funded from, so a + // deployment pointed at another one sees its own grants and not an empty dashboard. + it('keeps a grant funded under the deployment’s own instrument', async () => { + const instrument = { admin: 'other::1', instrumentId: 'XYZ' } + const { backend } = harness({ + acs: { [TOKEN]: [tokenRow('t1', '1500', instrument)] }, + deployment: { ...deployment, ...instrument }, + }) + await backend.createVesting({ + proposer: 'funder::1', + receiver: 'receiver::1', + totalAmount: '1000', + schedule, + title: 'Advisor grant', + }) + + const view = await backend.viewAs('funder::1') + + expect(view.pendingGrants.map((one) => one.title)).toEqual(['Advisor grant']) + }) + + // A shared participant can carry another admin's grants under the same package, and rendering one + // under this deployment's symbol would be a lie about what it holds. + it('drops grants and claims of another instrument', async () => { + const { backend } = harness({ + acs: { + [CONTRACT]: [ + row('mine', { + admin: 'instrument-admin::1', + instrumentId: 'DBT', + provider: 'operator::1', + creator: 'funder::1', + receiver: 'receiver::1', + totalAmount: '1000', + alreadyWithdrawn: '0', + schedule: encodeSchedule(schedule), + note: 'Mine', + }), + row('theirs', { + admin: 'other::1', + instrumentId: 'DBT', + provider: 'operator::1', + creator: 'funder::1', + receiver: 'receiver::1', + totalAmount: '1000', + alreadyWithdrawn: '0', + schedule: encodeSchedule(schedule), + note: 'Theirs', + }), + ], + }, + }) + + const view = await backend.viewAs('receiver::1') + + expect(view.grants.map((one) => one.id)).toEqual(['mine']) + }) +}) diff --git a/example-dapps/token-vesting/src/backend/LedgerBackend.ts b/example-dapps/token-vesting/src/backend/LedgerBackend.ts new file mode 100644 index 00000000..4cab2079 --- /dev/null +++ b/example-dapps/token-vesting/src/backend/LedgerBackend.ts @@ -0,0 +1,589 @@ +// The VestingBackend over the canton-vesting-forge templates, reached through the connected wallet: +// reads go out as the connected party, writes come back with a real approval prompt. + +import { + buildAcceptCommand, + buildCancelCommand, + buildCancelProposalCommand, + buildClaimResidualCommand, + buildCreateVestingCommand, + buildRejectProposalCommand, + buildTapCommand, + buildWithdrawCommand, +} from '@/backend/commands' +import type { Deployment } from '@/backend/config' +import { + fetchInstrumentConfig, + type InstrumentConfigRef, + type RegistryInstrument, +} from '@/backend/registry' +import { + type AcsRow, + type ClaimRecord, + type CreateVestInput, + claimChain, + composeNote, + fundedBy, + lastUpdateOffset, + matchesInstrument, + reservedToken, + rowToClaim, + rowToGrant, + rowToPendingGrant, + selectHoldings, + tokenValue, + updatesToClaims, + type VestingBackend, + type VestingView, +} from '@/backend/VestingBackend' +import type { DisclosedContract, LedgerCommand, WalletFns } from '@/backend/wallet' +import { addAmounts } from '@/utils/amount' + +const mapRows = (rows: AcsRow[], mapper: (row: AcsRow) => T | undefined): T[] => + rows.map(mapper).filter((value): value is T => value !== undefined) + +// A filter takes the package-name reference; the participant rejects the resolved id a command +// carries with INVALID_FIELD. +const vesting = (entity: string): string => `#vesting:Vesting:${entity}` +const TOKEN = '#canton-token-forge:Canton.TokenForge.Token:Token' + +const templateFilter = ( + party: string, + templateId: string, + includeCreatedEventBlob = false, +): Record => ({ + filtersByParty: { + [party]: { + cumulative: [ + { + identifierFilter: { TemplateFilter: { value: { templateId, includeCreatedEventBlob } } }, + }, + ], + }, + }, +}) + +const CLAIM_HISTORY_LIMIT = 1000 +const STREAM_IDLE_MS = 1000 +const CLAIM_HISTORY_PAGES = 20 + +// Accept locks the holding a grant reserves, which the receiver is no stakeholder of and so cannot +// read the disclosure blob for. It is kept here as the funder submits the pending grant. Persisted, +// because the two parties are two wallet accounts and switching between them reloads the app. +const TOKEN_STORE_KEY = 'vesting.tokenDisclosures' +// How often a reservation this browser cannot find is looked for again, counted across reloads. The +// read that answers it carries a blob per holding and runs on the funder's dashboard, so a holding +// archived outside this dApp would otherwise cost that read on every view forever, for a grant +// nobody can accept any more. Counted rather than given up on the first miss, because a read that +// answers short is indistinguishable from one that answers in full. +const MISS_STORE_KEY = 'vesting.tokenReadMisses' +const MISS_LIMIT = 3 + +// Shared by every exit that finds its proposal already gone, so the wording cannot drift between them. +const PROPOSAL_GONE_MESSAGE = 'this grant is no longer outstanding: reload to see where it went' + +const storedTokens = (): DisclosedContract[] => { + try { + const stored = JSON.parse(localStorage.getItem(TOKEN_STORE_KEY) ?? '[]') + return Array.isArray(stored) ? (stored as DisclosedContract[]) : [] + } catch { + return [] + } +} + +const readMisses = (): Record => { + try { + const stored: unknown = JSON.parse(localStorage.getItem(MISS_STORE_KEY) ?? '{}') + return typeof stored === 'object' && stored !== null && !Array.isArray(stored) + ? (stored as Record) + : {} + } catch { + return {} + } +} + +const cidOf = (row: AcsRow): string => + row.contractEntry?.JsActiveContract?.createdEvent?.contractId ?? '' + +const rowToDisclosed = (row: AcsRow): DisclosedContract | undefined => { + const { contractId, createdEventBlob, templateId } = + row.contractEntry?.JsActiveContract?.createdEvent ?? {} + return contractId === undefined || createdEventBlob === undefined || templateId === undefined + ? undefined + : { templateId, contractId, createdEventBlob } +} + +// The holdings this party's own outstanding grants reserve that this browser has neither a blob for +// nor given up on. Empty is the common answer, which is what lets the caller skip reading the +// holdings at all. +const unstoredReservations = (party: string, pendingRows: AcsRow[]): Set => { + const known = new Set(storedTokens().map((one) => one.contractId)) + const misses = readMisses() + const reserved = mapRows( + pendingRows.filter((row) => fundedBy(row, party)), + reservedToken, + ) + return new Set( + reserved.filter( + (contractId) => !known.has(contractId) && (misses[contractId] ?? 0) < MISS_LIMIT, + ), + ) +} + +// Merged by contract id rather than appended, because two reconciles can overlap, a dashboard +// refresh racing a fresh grant, and each computes what it is missing from a store snapshot taken +// before its own read. Answers with what it stored, which is what a miss is counted against. +const storeTokens = (held: AcsRow[], wanted: Set): Set => { + const missing = mapRows( + held.filter((row) => wanted.has(cidOf(row))), + rowToDisclosed, + ) + if (missing.length === 0) { + return new Set() + } + const merged = new Map(storedTokens().map((one) => [one.contractId, one])) + for (const one of missing) { + merged.set(one.contractId, one) + } + localStorage.setItem(TOKEN_STORE_KEY, JSON.stringify([...merged.values()])) + return new Set(missing.map((one) => one.contractId)) +} + +// Counted against what was stored rather than against what came back, because a holding that +// arrives without its blob is as unusable as one that never arrived: calling it found would leave +// it wanted with no miss ever recorded, and so no bound on the read it costs. +const recordMisses = (wanted: Set, stored: Set): void => { + const missed = [...wanted].filter((contractId) => !stored.has(contractId)) + if (missed.length === 0) { + return + } + const misses = readMisses() + const bumped = Object.fromEntries(missed.map((one) => [one, (misses[one] ?? 0) + 1])) + const abandoned = Object.entries(bumped).filter(([, count]) => count === MISS_LIMIT) + if (abandoned.length > 0) { + console.warn( + 'giving up on the holdings these grants reserve: they cannot be accepted from this browser', + abandoned.map(([contractId]) => contractId), + ) + } + localStorage.setItem(MISS_STORE_KEY, JSON.stringify({ ...misses, ...bumped })) +} + +const dropMiss = (tokenCid: string): void => { + const kept = Object.entries(readMisses()).filter(([contractId]) => contractId !== tokenCid) + localStorage.setItem(MISS_STORE_KEY, JSON.stringify(Object.fromEntries(kept))) +} + +// Puts a reservation this browser has given up on back in front of the reconcile. The bound exists +// so that a holding nobody can find stops costing a blob-bearing read on every view, not so that a +// read which came back short once leaves a live grant unacceptable until site data is wiped. +const retryFunding = (tokenCid: string): void => { + try { + dropMiss(tokenCid) + } catch (cause: unknown) { + console.warn('could not reset the read count for the holding a grant reserves', cause) + } +} + +// What a grant leaves in this browser once it can no longer be accepted: the blob its Accept would +// have disclosed, and the count of reads spent looking for that holding. Both are keyed by the +// holding, so both go whichever way the grant ended. Every caller runs after its submission has +// landed, so a browser refusing to write must not turn a committed exit into a reported failure: +// what is left behind names a proposal that no longer exists and can only mislead a later Accept, +// which already says so on its own. +const forgetFunding = (tokenCid: string | undefined): void => { + if (tokenCid === undefined) { + return + } + try { + localStorage.setItem( + TOKEN_STORE_KEY, + JSON.stringify(storedTokens().filter((one) => one.contractId !== tokenCid)), + ) + dropMiss(tokenCid) + } catch (cause: unknown) { + console.warn('could not forget what this browser kept for a grant that has ended', cause) + } +} + +export class LedgerBackend implements VestingBackend { + private readonly wallet: WalletFns + private readonly factory: DisclosedContract + private readonly instrument: RegistryInstrument + private readonly synchronizerId: string | undefined + private readonly pkg: string + // The registry's answer to the same question, kept from the last write that fetched a config, for + // the two exits that fetch none and would otherwise name no synchronizer at all. + private fromRegistry: string | undefined + + constructor(deployment: Deployment, wallet: WalletFns) { + this.wallet = wallet + this.synchronizerId = deployment.synchronizerId + this.pkg = deployment.pkg + this.instrument = { admin: deployment.admin, instrumentId: deployment.instrumentId } + this.factory = { + templateId: this.tid('VestingFactory'), + contractId: deployment.factoryCid, + createdEventBlob: deployment.factoryBlob, + // Its own deployment's, never the submission's: nothing else knows where this contract lives, + // so a deployment that reports none must leave the participant to resolve it. + ...(deployment.synchronizerId === undefined + ? {} + : { synchronizerId: deployment.synchronizerId }), + } + } + + // The resolved-id twin of `vesting()`: a command carries this spelling, a filter the other one. + private tid(entity: string): string { + return `${this.pkg}:Vesting:${entity}` + } + + private async ledgerEnd(): Promise { + const result = (await this.wallet.ledgerApi({ + requestMethod: 'get', + resource: '/v2/state/ledger-end', + })) as { offset?: string | number } + if (result.offset === undefined) { + throw new Error('Ledger API did not return an offset') + } + return result.offset + } + + private async readAcs( + party: string, + templateId: string, + offset: string | number, + includeCreatedEventBlob = false, + ): Promise { + const rows = await this.wallet.ledgerApi({ + requestMethod: 'post', + resource: '/v2/state/active-contracts', + body: { + filter: templateFilter(party, templateId, includeCreatedEventBlob), + activeAtOffset: offset, + verbose: true, + }, + }) + // An answer that is not a list is a failure, not an empty ACS. Coercing it to `[]` made "this + // party holds nothing" and "the read did not work" one answer, which is what left the reconcile + // below unable to tell a reservation that is gone from one it simply failed to see. + if (!Array.isArray(rows)) { + throw new Error(`the participant did not answer ${templateId} with a list of contracts`) + } + return rows + } + + // actAs is explicit rather than left to the wallet's primary account, so a submission that would + // be signed by the wrong key is rejected by the participant instead of silently reassigned. The + // synchronizer goes on the submission as well, because a write that discloses nothing would + // otherwise reach a wallet on more than one synchronizer with none named and land on its default. + private submit( + actAs: string, + command: LedgerCommand, + disclosed: DisclosedContract[], + sync: string | undefined = this.synchronizerId ?? this.fromRegistry, + ): Promise { + return this.wallet.execute({ + actAs: [actAs], + readAs: [actAs], + commands: [command], + ...(sync === undefined ? {} : { synchronizerId: sync }), + // Filled from the deployment alone, and only where whoever built the disclosure named none: + // the registry vouches for its own config and for nothing else, so a fallback to its answer + // must not travel onto a blob it has never seen. The participant resolves an unstamped one. + disclosedContracts: disclosed.map((one) => + one.synchronizerId === undefined && this.synchronizerId !== undefined + ? { ...one, synchronizerId: this.synchronizerId } + : one, + ), + }) + } + + // What is free to fund a grant, and not simply what the party holds: a holding already escrowed is + // a LockedToken and so out by template, and the holding an outstanding grant reserves is out + // because spending it would leave that grant unacceptable. Offering more than this would put an + // amount in the field that the next step always refuses. + async balanceOf(partyId: string): Promise { + const free = await this.freeTokens(partyId) + return addAmounts(...free.map(tokenValue)) + } + + async viewAs(partyId: string): Promise { + const offset = await this.ledgerEnd() + const [pendingGrantRows, contractRows, claimRows] = await Promise.all([ + this.readAcs(partyId, vesting('VestingProposal'), offset), + this.readAcs(partyId, vesting('VestingContract'), offset), + this.readAcs(partyId, vesting('VestedClaim'), offset), + ]) + // The funder's own view is the one place a grant whose blob never got recorded is seen again, + // so it is also where that is repaired: without it a funder who makes one grant and stops has + // an unacceptable grant and no way to notice. A failure here must not blank the dashboard. + await this.storeFunding(partyId, offset, pendingGrantRows).catch((cause: unknown) => { + console.warn('could not read back the holdings this party’s grants reserve', cause) + }) + return { + pendingGrants: mapRows(this.ofInstrument(pendingGrantRows), rowToPendingGrant), + grants: mapRows(this.ofInstrument(contractRows), rowToGrant), + claims: mapRows(this.ofInstrument(claimRows), rowToClaim), + } + } + + // One submission, so one wallet approval: the factory splits the funder's inputs down to the grant + // and hands the change back in the same transaction, so the funder never pre-splits the way the + // Amulet version had to. The factory is the operator's and observer-less, so the funder cannot + // read it and its disclosure comes from the deployment. + async createVesting(args: CreateVestInput): Promise { + const free = await this.freeTokens(args.proposer) + const picked = selectHoldings(free, args.totalAmount) + if (picked === undefined) { + throw new Error( + `only ${addAmounts(...free.map(tokenValue))} ${this.instrument.instrumentId} is free to fund this grant`, + ) + } + await this.submitWithConfig( + args.proposer, + ({ configCid }) => + buildCreateVestingCommand(this.factory.templateId, this.factory.contractId, { + configCid, + proposer: args.proposer, + receiver: args.receiver, + totalAmount: args.totalAmount, + schedule: args.schedule, + tokenCids: picked.map(cidOf), + note: composeNote(args.title, args.note), + }), + [this.factory], + ) + // The grant is on the ledger by now, so a failure to record the blob must not report one: it + // would invite the funder to make a second grant, and `accept` is where a missing blob is felt + // and said. Logged rather than swallowed outright, because the next reconcile is what repairs + // it and nothing else would say one was needed. + await this.reconcileFunding(args.proposer).catch((cause: unknown) => { + console.warn('could not read back the holding this grant reserves', cause) + }) + } + + // The receiver has to disclose the holding a grant reserves, and only the funder can read it: a + // `Token` has no observers. Every outstanding grant of this funder is reconciled rather than just + // the one just made, so a read that failed once is repaired by their next grant or their next + // dashboard load instead of leaving a grant nobody can accept. The holdings are read only when + // something is actually missing, since this runs on every view, and a reservation the read keeps + // not answering is given up on so that this cannot become a read on every view for good. + private async storeFunding( + party: string, + offset: string | number, + pendingRows: AcsRow[], + ): Promise { + const wanted = unstoredReservations(party, pendingRows) + if (wanted.size === 0) { + return + } + const held = await this.readAcs(party, TOKEN, offset, true) + // An empty read counts like any other: it is the answer a funder who no longer holds the + // reservation gets, and it is the only one they ever get, so skipping it left the bound below + // unreachable in exactly the case it exists for. What keeps that from condemning a grant whose + // read was merely short is `retryFunding`, which the receiver's own Accept reaches. + recordMisses(wanted, storeTokens(held, wanted)) + } + + // Reconciles against a snapshot of its own, for a caller that has read no rows to hand over. + // Taken after the submit, never before, because the factory creates the holding in that very + // submission and a grant the wallet declined must leave no blob behind. + private async reconcileFunding(party: string): Promise { + const offset = await this.ledgerEnd() + const pendingRows = await this.readAcs(party, vesting('VestingProposal'), offset) + await this.storeFunding(party, offset, pendingRows) + } + + async tap(args: { amount: string; party: string }): Promise { + await this.submitWithConfig(args.party, ({ configCid, configTemplateId }) => + buildTapCommand(configTemplateId, configCid, { amount: args.amount, user: args.party }), + ) + } + + // A transfer consumes every holding it is given, so the one an outstanding grant reserves has to + // stay out: consuming it is exactly what leaves that grant unacceptable. + private async freeTokens(owner: string): Promise { + const offset = await this.ledgerEnd() + const [held, pendingRows] = await Promise.all([ + this.readAcs(owner, TOKEN, offset), + this.readAcs(owner, vesting('VestingProposal'), offset), + ]) + const reserved = new Set(pendingRows.map(reservedToken)) + return held.filter( + (row) => matchesInstrument(row, this.instrument) && !reserved.has(cidOf(row)), + ) + } + + private ofInstrument(rows: AcsRow[]): AcsRow[] { + return rows.filter((row) => matchesInstrument(row, this.instrument)) + } + + // Every choice that moves a holding takes the same config and the same one disclosure, so the + // invariant is held here rather than re-spelled per choice; `extra` is what only create and + // Accept add. It answers with what it disclosed, which only create has a use for. + private async submitWithConfig( + actAs: string, + build: (config: InstrumentConfigRef) => LedgerCommand, + extra: DisclosedContract[] = [], + ): Promise { + const { disclosed, synchronizerId, ...config } = await fetchInstrumentConfig( + actAs, + this.instrument, + ) + const sent = [...disclosed, ...extra] + await this.submit(actAs, build(config), sent, this.agreedSynchronizer(synchronizerId)) + return sent + } + + // One submission names one synchronizer, and the two contracts it needs are stamped by two + // different sources: the registry stamps the config it discloses, the deployment carries the + // factory's. The app assumes they agree, so a disagreement is said here rather than sent as a + // submission the participant refuses without naming either. + private agreedSynchronizer(fromConfig: string | undefined): string | undefined { + if ( + fromConfig !== undefined && + this.synchronizerId !== undefined && + fromConfig !== this.synchronizerId + ) { + throw new Error( + `the registry's InstrumentConfig is on synchronizer ${fromConfig} and the vesting factory on ${this.synchronizerId}: this dApp needs both on one`, + ) + } + this.fromRegistry = fromConfig ?? this.fromRegistry + return this.synchronizerId ?? fromConfig + } + + 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, vesting('VestingContract')), + }, + }, + }, + }, + }) + } + + 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, this.instrument, this.tid('VestingContract'))) + const last = lastUpdateOffset(updates) + if (!Array.isArray(updates) || updates.length < CLAIM_HISTORY_LIMIT || last === undefined) { + break + } + beginExclusive = last + } + return claimChain(records, contractCid) + } + + // The grant names the holding its Accept locks, and the receiver is an observer of the grant, so + // which blob to send is read off the ledger rather than guessed at. Sending the whole store + // instead would re-disclose holdings earlier accepts already consumed, which the participant + // rejects. + async accept(args: { receiver: string; pendingCid: string }): Promise { + const proposal = await this.findProposal(args.receiver, args.pendingCid) + // Two different failures, and pointing a stale view at the blob store would send the receiver + // hunting for a browser that never had anything to do with it. + if (proposal === undefined) { + throw new Error(PROPOSAL_GONE_MESSAGE) + } + const wanted = reservedToken(proposal) + const token = storedTokens().find((one) => one.contractId === wanted) + if (wanted === undefined || token === undefined) { + // A receiver asking for a grant this browser gave up finding the funding for is the one signal + // that it is worth another look, so the funder's next view gets its read budget back. + if (wanted !== undefined) { + retryFunding(wanted) + } + throw new Error('the funder holding this grant locks is not disclosable from this browser') + } + await this.submitWithConfig( + args.receiver, + ({ configCid }) => + buildAcceptCommand(this.tid('VestingProposal'), args.pendingCid, configCid), + [token], + ) + // The submission archived the proposal, so nothing this browser kept for it can do anything but + // mislead a later Accept. + forgetFunding(wanted) + } + + // Neither exit moves anything, so neither takes the config or discloses a contract: the proposal + // is archived on its controller's own authority and the holding it reserved is already an + // ordinary Token of the funder's, back in their balance as soon as nothing names it. + private endProposal( + party: string, + pendingCid: string, + build: (templateId: string, pendingCid: string) => LedgerCommand, + ): Promise { + return this.submit(party, build(this.tid('VestingProposal'), pendingCid), []) + } + + // Read before the archive for the holding the proposal names, since reading it after would be too + // late. + async cancelProposal(args: { proposer: string; pendingCid: string }): Promise { + const proposal = await this.findProposal(args.proposer, args.pendingCid) + if (proposal === undefined) { + throw new Error(PROPOSAL_GONE_MESSAGE) + } + await this.endProposal(args.proposer, args.pendingCid, buildCancelProposalCommand) + // Only once the submission has landed: a prompt the wallet declines leaves a grant that is + // still outstanding and still acceptable. + forgetFunding(reservedToken(proposal)) + } + + // The two parties are two accounts of one browser, so a grant this receiver declines can be one + // this same browser funded and still holds the blob for. Read like the cancel above, but never + // guarded on: the receiver is not who the prune is for, so a read that fails must cost them a + // stale entry rather than a decline they cannot make. + async rejectProposal(args: { receiver: string; pendingCid: string }): Promise { + const proposal = await this.findProposal(args.receiver, args.pendingCid).catch(() => undefined) + await this.endProposal(args.receiver, args.pendingCid, buildRejectProposalCommand) + forgetFunding(proposal === undefined ? undefined : reservedToken(proposal)) + } + + // One proposal by id, for the three exits that each need its row before archiving it: accept for + // the holding to disclose, cancel and decline for the holding to forget. + private async findProposal(party: string, pendingCid: string): Promise { + const offset = await this.ledgerEnd() + const rows = await this.readAcs(party, vesting('VestingProposal'), offset) + return rows.find((row) => cidOf(row) === pendingCid) + } + + async withdraw(args: { receiver: string; contractCid: string; amount: string }): Promise { + await this.submitWithConfig(args.receiver, ({ configCid }) => + buildWithdrawCommand(this.tid('VestingContract'), args.contractCid, args.amount, configCid), + ) + } + + async cancel(args: { creator: string; contractCid: string }): Promise { + await this.submitWithConfig(args.creator, ({ configCid }) => + buildCancelCommand(this.tid('VestingContract'), args.contractCid, configCid), + ) + } + + async claimResidual(args: { receiver: string; claimCid: string; amount: string }): Promise { + await this.submitWithConfig(args.receiver, ({ configCid }) => + buildClaimResidualCommand(this.tid('VestedClaim'), args.claimCid, args.amount, configCid), + ) + } +} diff --git a/example-dapps/token-vesting/src/backend/VestingBackend.test.ts b/example-dapps/token-vesting/src/backend/VestingBackend.test.ts new file mode 100644 index 00000000..377f3408 --- /dev/null +++ b/example-dapps/token-vesting/src/backend/VestingBackend.test.ts @@ -0,0 +1,411 @@ +import { describe, expect, it } from 'vitest' +import { encodeSchedule } from '@/backend/commands' +import type { AcsRow } from '@/backend/VestingBackend' +import { + claimChain, + composeNote, + lastUpdateOffset, + matchesInstrument, + reservedToken, + rowToClaim, + rowToGrant, + rowToPendingGrant, + selectHoldings, + splitNote, + tokenValue, + updatesToClaims, +} from '@/backend/VestingBackend' + +const linearEncoded = encodeSchedule({ + cliff: '2026-01-01T00:00:00Z', + curve: { kind: 'linear', start: '2026-01-01T00:00:00Z', end: '2027-01-01T00:00:00Z' }, +}) + +const INSTRUMENT = { admin: 'admin::1', instrumentId: 'DBT' } + +const row = (contractId: string, arg: Record) => ({ + contractEntry: { JsActiveContract: { createdEvent: { contractId, createArgument: arg } } }, +}) + +describe('splitNote / composeNote', () => { + it('splits on the first newline into title + note', () => { + expect(splitNote('My grant\nthe rest\nmore', 'cid1234')).toEqual({ + title: 'My grant', + note: 'the rest\nmore', + }) + }) + + it('treats a note with no newline as title-only', () => { + expect(splitNote('Just a title', 'cid1234')).toEqual({ title: 'Just a title' }) + }) + + it('falls back to a short-cid title when the note is empty', () => { + expect(splitNote('', 'cid12345678')).toEqual({ title: 'Vesting cid12345' }) + }) + + it('composeNote joins title + note with a newline, title-only when note absent', () => { + expect(composeNote('T', 'body')).toBe('T\nbody') + expect(composeNote('T')).toBe('T') + expect(composeNote('T', '')).toBe('T') + }) +}) + +describe('rowToPendingGrant', () => { + it('maps proposer→proposer, receiver→receiver, decodes the schedule, splits the note', () => { + const pendingGrant = rowToPendingGrant( + row('p1', { + provider: 'OP', + proposer: 'funder', + receiver: 'receiver', + totalAmount: '1000.0000000000', + schedule: linearEncoded, + note: 'Advisor grant\n24-month linear', + }), + ) + expect(pendingGrant).toEqual({ + id: 'p1', + title: 'Advisor grant', + provider: 'OP', + proposer: 'funder', + receiver: 'receiver', + // Passed through unparsed: a Daml Numeric arrives as a string, verbatim ledger padding included. + totalAmount: '1000.0000000000', + schedule: { + cliff: '2026-01-01T00:00:00Z', + curve: { kind: 'linear', start: '2026-01-01T00:00:00Z', end: '2027-01-01T00:00:00Z' }, + }, + note: '24-month linear', + }) + }) + + it('returns undefined when the createArgument is absent', () => { + expect(rowToPendingGrant({})).toBeUndefined() + }) +}) + +describe('rowToGrant', () => { + it('maps a contract row, parsing alreadyWithdrawn and using creator as the funder', () => { + const grant = rowToGrant( + row('c1', { + provider: 'OP', + creator: 'funder', + receiver: 'receiver', + totalAmount: '1000', + alreadyWithdrawn: '250', + schedule: linearEncoded, + note: 'Core grant', + }), + ) + expect(grant?.id).toBe('c1') + expect(grant?.title).toBe('Core grant') + expect(grant?.creator).toBe('funder') + expect(grant?.receiver).toBe('receiver') + expect(grant?.totalAmount).toBe('1000') + expect(grant?.alreadyWithdrawn).toBe('250') + expect(grant?.note).toBeUndefined() + }) + + it('throws naming the field rather than folding a non-string amount to zero', () => { + expect(() => + rowToGrant( + row('c2', { + provider: 'OP', + creator: 'funder', + receiver: 'receiver', + totalAmount: '1000', + alreadyWithdrawn: 250, // wrong shape: a Daml Numeric always arrives as a string + schedule: linearEncoded, + }), + ), + ).toThrow(/c2.*alreadyWithdrawn/) + }) + + it('throws on a string that is not a decimal, which would parse to zero downstream', () => { + expect(() => + rowToGrant( + row('c3', { + provider: 'OP', + creator: 'funder', + receiver: 'receiver', + totalAmount: '1e3', // right shape, unparseable value + alreadyWithdrawn: '250', + schedule: linearEncoded, + }), + ), + ).toThrow(/c3.*totalAmount/) + }) +}) + +describe('rowToClaim', () => { + it('maps a residual claim row with amount + withdrawn', () => { + const claim = rowToClaim( + row('r1', { + provider: 'OP', + creator: 'funder', + receiver: 'receiver', + amount: '500', + withdrawn: '100', + note: 'Residual\nfrom cancelled grant', + }), + ) + expect(claim).toEqual({ + id: 'r1', + title: 'Residual', + provider: 'OP', + creator: 'funder', + receiver: 'receiver', + amount: '500', + withdrawn: '100', + note: 'from cancelled grant', + }) + }) +}) + +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: 'VestingContract_Withdraw', + choiceArgument: { withdrawAmount: amount }, + contractId: replaces, + }, + }, + { + CreatedEvent: { + contractId: successor, + templateId: 'pkg1:Vesting:VestingContract', + createArgument: { + admin: 'admin::1', + instrumentId: 'DBT', + provider: 'OP', + creator: 'funder', + receiver: 'receiver', + totalAmount: '1000', + alreadyWithdrawn: claimed, + schedule: linearEncoded, + note: 'Advisor grant', + }, + }, + }, + ], + }, + }, + }, +}) + +// The resolved id LedgerBackend spells its own commands with, which is where this comes from. +const SUCCESSOR = 'pkg1:Vesting:VestingContract' + +describe('updatesToClaims', () => { + // A withdraw pays the receiver as well as replacing the grant, so its transaction carries a + // `Token` create the receiver is an informee of. Its payload is `{admin, instrumentId, amount}`, + // which the instrument filter accepts, so picking the first create rather than the successor's + // own template threw on the `totalAmount` it has no field for and lost the whole history. + it('skips the holding the withdraw paid out, even when it comes first', () => { + const { update } = claimUpdate(7, 'c1', 'c2', '250', '250') + const paidOut = { + CreatedEvent: { + contractId: 'paid-out', + templateId: 'tfpkg:Canton.TokenForge.Token:Token', + createArgument: { admin: 'admin::1', instrumentId: 'DBT', amount: '250' }, + }, + } + const { events, ...value } = update.Transaction.value + const withHolding = { + update: { Transaction: { value: { ...value, events: [events[0], paidOut, events[1]] } } }, + } + + const [record] = updatesToClaims([withHolding], INSTRUMENT, SUCCESSOR) + + expect(record?.grant.id).toBe('c2') + }) + + // A grant created before the package was upgraded is still its own successor, so the match is on + // module and entity rather than on the whole resolved id. + it('accepts a successor created under another version of the package', () => { + const [record] = updatesToClaims( + [claimUpdate(7, 'c1', 'c2', '250', '250')], + INSTRUMENT, + 'otherpkg:Vesting:VestingContract', + ) + expect(record?.grant.id).toBe('c2') + }) + + it('carries the id the claim consumed alongside the successor it created', () => { + const [record] = updatesToClaims( + [claimUpdate(7, 'c1', 'c2', '250', '250')], + INSTRUMENT, + SUCCESSOR, + ) + 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], INSTRUMENT, SUCCESSOR)).toEqual([]) + }) + + it('ignores anything that is not an array of transactions', () => { + expect(updatesToClaims(undefined, INSTRUMENT, SUCCESSOR)).toEqual([]) + expect(updatesToClaims([{}], INSTRUMENT, SUCCESSOR)).toEqual([]) + }) +}) + +describe('claimChain', () => { + const records = updatesToClaims( + [ + claimUpdate(7, 'c1', 'c2', '250', '250'), + claimUpdate(9, 'c2', 'c3', '500', '250'), + claimUpdate(11, 'other1', 'other2', '10', '10'), + ], + INSTRUMENT, + SUCCESSOR, + ) + + 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() + }) +}) + +const tokenRow = (contractId: string, amount: string, overrides: Record = {}) => + ({ + contractEntry: { + JsActiveContract: { + createdEvent: { + contractId, + createArgument: { admin: 'admin::1', instrumentId: 'DBT', amount, ...overrides }, + }, + }, + }, + }) as AcsRow + +describe('tokenValue', () => { + it('reads the holding amount off the payload', () => { + expect(tokenValue(tokenRow('t1', '1000.5'))).toBe('1000.5') + }) + + // Lenient where the mappers throw: this feeds a balance, and one odd row must not blank it. + it('reads a row carrying no amount as zero', () => { + expect(tokenValue({} as AcsRow)).toBe('0') + }) +}) + +describe('matchesInstrument', () => { + it('accepts a holding of the deployment’s own instrument', () => { + expect(matchesInstrument(tokenRow('t1', '1'), INSTRUMENT)).toBe(true) + }) + + it('rejects the same instrument id under another admin', () => { + expect(matchesInstrument(tokenRow('t1', '1', { admin: 'other::1' }), INSTRUMENT)).toBe(false) + }) + + it('rejects another instrument of the same admin', () => { + expect(matchesInstrument(tokenRow('t1', '1', { instrumentId: 'OTHER' }), INSTRUMENT)).toBe( + false, + ) + }) +}) + +describe('reservedToken', () => { + it('reads the holding a pending grant’s Accept will consume', () => { + const pending = { + contractEntry: { + JsActiveContract: { + createdEvent: { + contractId: 'p1', + createArgument: { tokenCid: 't1' }, + }, + }, + }, + } as AcsRow + expect(reservedToken(pending)).toBe('t1') + }) + + it('reads a row naming none as undefined', () => { + expect(reservedToken({} as AcsRow)).toBeUndefined() + }) +}) + +describe('selectHoldings', () => { + // Largest first, so the factory splits the fewest inputs: the receiver carries one disclosure + // whatever is picked, since the split leaves a single holding behind. + it('takes the largest holdings first and stops once they cover the total', () => { + const rows = [tokenRow('small', '100'), tokenRow('big', '900'), tokenRow('mid', '400')] + expect(selectHoldings(rows, '1000')?.map((row) => tokenValue(row))).toEqual(['900', '400']) + }) + + it('takes one holding when one covers the total', () => { + const rows = [tokenRow('big', '900'), tokenRow('small', '100')] + expect(selectHoldings(rows, '500')?.map((row) => tokenValue(row))).toEqual(['900']) + }) + + it('takes everything when the total needs everything', () => { + const rows = [tokenRow('a', '600'), tokenRow('b', '400')] + expect(selectHoldings(rows, '1000')).toHaveLength(2) + }) + + it('returns undefined rather than an under-covering set', () => { + expect(selectHoldings([tokenRow('a', '600')], '1000')).toBeUndefined() + }) + + it('returns undefined when there is nothing to select from', () => { + expect(selectHoldings([], '1')).toBeUndefined() + }) + + it('returns undefined for a non-positive total rather than an empty set', () => { + const rows = [tokenRow('a', '600')] + expect(selectHoldings(rows, '0')).toBeUndefined() + expect(selectHoldings(rows, '-1')).toBeUndefined() + }) +}) diff --git a/example-dapps/token-vesting/src/backend/VestingBackend.ts b/example-dapps/token-vesting/src/backend/VestingBackend.ts new file mode 100644 index 00000000..8df89863 --- /dev/null +++ b/example-dapps/token-vesting/src/backend/VestingBackend.ts @@ -0,0 +1,331 @@ +// The backend seam: the UI depends only on this interface and the domain types, never on DAML or +// transport details. The mappers below turn active-contract rows into those domain types. + +import { decodeSchedule } from '@/backend/commands' +import type { RegistryInstrument } from '@/backend/registry' +import type { Grant, PartyId, PendingGrant, VestedClaim } from '@/store/types' +import { addAmounts, compareAmounts, isAmount } from '@/utils/amount' +import type { VestingSchedule } from '@/utils/schedule' + +export interface VestingView { + claims: VestedClaim[] + grants: Grant[] + pendingGrants: PendingGrant[] +} + +export interface CreateVestInput { + note?: string + proposer: string + receiver: string + schedule: VestingSchedule + title: string + totalAmount: string +} + +// One `VestingContract_Withdraw` 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 { + accept(args: { receiver: string; pendingCid: string }): Promise + balanceOf(partyId: string): Promise + cancel(args: { creator: string; contractCid: string }): Promise + cancelProposal(args: { proposer: string; pendingCid: string }): Promise + claimHistory(partyId: string, contractCid: string): Promise + claimResidual(args: { receiver: string; claimCid: string; amount: string }): Promise + createVesting(args: CreateVestInput): Promise + rejectProposal(args: { receiver: string; pendingCid: string }): Promise + tap(args: { amount: string; party: string }): Promise + viewAs(partyId: string): Promise + withdraw(args: { receiver: string; contractCid: string; amount: string }): Promise +} + +// A withdrawal 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 ────────────────────────────────────────────────── +// On-ledger `note` is `"${title}\n${note}"`, decimals arrive as strings, and the curve as a +// JSON-LF variant. A row missing its createArgument maps to undefined so it never crashes a view. + +export type AcsRow = { + contractEntry?: { + JsActiveContract?: { + createdEvent?: { + contractId?: string + createArgument?: Record + createdEventBlob?: string + templateId?: string + } + } + } +} + +// A Daml Numeric arrives as a string and is carried through unparsed, so ledger precision survives. +// Throws rather than let a surprise here fold into what looks like a real zero balance downstream. +const amountOf = (value: unknown, field: string, contractId: string): string => { + if (typeof value !== 'string' || !isAmount(value)) { + throw new Error(`Contract ${contractId} field '${field}' is not a Numeric string: ${value}`) + } + return value +} + +const shortCid = (contractId: string): string => contractId.slice(0, 8) + +// Compose the on-ledger note from a UI title + optional note. Mirror of splitNote. +export const composeNote = (title: string, note?: string): string => + note === undefined || note === '' ? title : `${title}\n${note}` + +// Split the on-ledger note back into title + note, on the first newline only. +export const splitNote = ( + rawNote: unknown, + contractId: string, +): { title: string; note?: string } => { + const text = typeof rawNote === 'string' ? rawNote : '' + const newlineAt = text.indexOf('\n') + if (text === '') { + return { title: `Vesting ${shortCid(contractId)}` } + } + if (newlineAt === -1) { + return { title: text } + } + const title = text.slice(0, newlineAt) + const note = text.slice(newlineAt + 1) + return { title: title === '' ? `Vesting ${shortCid(contractId)}` : title, note } +} + +// The fields every template carries alike; each mapper layers its own on top. +type DecodedBase = { + arg: Record + funder: PartyId + id: string + note?: string + provider: PartyId + receiver: PartyId + title: string +} + +const decodeBase = (row: AcsRow): DecodedBase | undefined => { + const event = row.contractEntry?.JsActiveContract?.createdEvent + const arg = event?.createArgument + if (event?.contractId === undefined || arg === undefined) { + return undefined + } + const { title, note } = splitNote(arg.note, event.contractId) + return { + arg, + id: event.contractId, + title, + note, + provider: String(arg.provider ?? '') as PartyId, + // The pending grant names the funder `proposer`; once accepted it is the contract's `creator`. + funder: String(arg.creator ?? arg.proposer ?? '') as PartyId, + receiver: String(arg.receiver ?? '') as PartyId, + } +} + +export const rowToPendingGrant = (row: AcsRow): PendingGrant | undefined => { + const base = decodeBase(row) + if (base === undefined) { + return undefined + } + return { + id: base.id, + title: base.title, + provider: base.provider, + proposer: base.funder, + receiver: base.receiver, + totalAmount: amountOf(base.arg.totalAmount, 'totalAmount', base.id), + schedule: decodeSchedule(base.arg.schedule), + note: base.note, + } +} + +export const rowToGrant = (row: AcsRow): Grant | undefined => { + const base = decodeBase(row) + if (base === undefined) { + return undefined + } + return { + id: base.id, + title: base.title, + provider: base.provider, + creator: base.funder, + receiver: base.receiver, + totalAmount: amountOf(base.arg.totalAmount, 'totalAmount', base.id), + schedule: decodeSchedule(base.arg.schedule), + alreadyWithdrawn: amountOf(base.arg.alreadyWithdrawn, 'alreadyWithdrawn', base.id), + note: base.note, + } +} + +// 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 + templateId?: string + } + 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, + instrument: RegistryInstrument, + successorTemplateId: string, +): ClaimRecord[] => { + // Module and entity rather than the whole id, so a grant created under an earlier version of the + // package still joins its own chain; taken from the caller's resolved id rather than spelled here + // a third time, so a Daml rename cannot leave this one behind. + const entity = successorTemplateId.slice(successorTemplateId.indexOf(':')) + return (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 === 'VestingContract_Withdraw', + )?.ExercisedEvent + // The successor contract, picked by template rather than by being the first create: a withdraw + // also creates the receiver's `Token`, whose payload carries the same flat admin/instrumentId + // pair, so the instrument filter below cannot tell the two apart and `rowToGrant` would throw on + // the missing `totalAmount` rather than skip the event. + const created = events.find((event) => + event.CreatedEvent?.templateId?.endsWith(entity), + )?.CreatedEvent + const amount = claim?.choiceArgument?.withdrawAmount + const replaces = claim?.contractId + if ( + created === undefined || + transaction?.effectiveAt === undefined || + amount === undefined || + replaces === undefined + ) { + return [] + } + const row: AcsRow = { contractEntry: { JsActiveContract: { createdEvent: created } } } + if (!matchesInstrument(row, instrument)) { + return [] + } + const grant = rowToGrant(row) + return grant === undefined + ? [] + : [ + { + amount: amountOf(amount, 'amount', grant.id), + at: transaction.effectiveAt, + grant, + replaces, + }, + ] + }) +} + +type TokenArg = { admin?: string; amount?: string; instrumentId?: string } + +const argOf = (row: AcsRow): Record => + row.contractEntry?.JsActiveContract?.createdEvent?.createArgument ?? {} + +// What a holding is worth, which is the field itself: a token-forge Token does not decay, so there +// is no decayed value to compute. Lenient where the mappers throw, because this feeds a balance and +// one odd row must not blank the field. +export const tokenValue = (row: AcsRow): string => { + const { amount } = argOf(row) as TokenArg + return typeof amount === 'string' ? amount : '0' +} + +// A shared participant can hold another admin's instrument under the same id, so both halves of the +// pair are compared. +export const matchesInstrument = (row: AcsRow, instrument: RegistryInstrument): boolean => { + const { admin, instrumentId } = argOf(row) as TokenArg + return admin === instrument.admin && instrumentId === instrument.instrumentId +} + +// The holding a pending grant reserves: the factory split it off at exactly the grant, and its +// Accept consumes that one contract, so nothing else may spend it while the grant is outstanding. +export const reservedToken = (row: AcsRow): string | undefined => { + const cid = argOf(row).tokenCid + return typeof cid === 'string' ? cid : undefined +} + +// Whether a pending grant is one this party funded. A `Token` has no observers, so only the funder +// can read the holding their own grant reserves, and the grants they received are nobody's to +// reconcile from here. +export const fundedBy = (row: AcsRow, party: string): boolean => argOf(row).proposer === party + +// Largest first, so the factory splits the fewest inputs. Not for the receiver's sake any more: the +// split leaves one holding whatever is picked. Undefined rather than a partial set, so the caller +// can report by how much the funder is short. +export const selectHoldings = (rows: AcsRow[], total: string): AcsRow[] | undefined => { + // The empty set covers a non-positive total, and a grant submitted with no inputs aborts at + // Accept, leaving the receiver to decline it for nothing. + if (compareAmounts(total, '0') <= 0) { + return undefined + } + const ordered = [...rows].sort((a, b) => compareAmounts(tokenValue(b), tokenValue(a))) + const picked: AcsRow[] = [] + let covered = '0' + for (const row of ordered) { + if (compareAmounts(covered, total) >= 0) { + break + } + picked.push(row) + covered = addAmounts(covered, tokenValue(row)) + } + return compareAmounts(covered, total) >= 0 ? picked : undefined +} + +export const rowToClaim = (row: AcsRow): VestedClaim | undefined => { + const base = decodeBase(row) + if (base === undefined) { + return undefined + } + return { + id: base.id, + title: base.title, + provider: base.provider, + creator: base.funder, + receiver: base.receiver, + amount: amountOf(base.arg.amount, 'amount', base.id), + withdrawn: amountOf(base.arg.withdrawn, 'withdrawn', base.id), + note: base.note, + } +} diff --git a/example-dapps/token-vesting/src/backend/commands.test.ts b/example-dapps/token-vesting/src/backend/commands.test.ts new file mode 100644 index 00000000..2d3b8bbb --- /dev/null +++ b/example-dapps/token-vesting/src/backend/commands.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it } from 'vitest' +import { + buildAcceptCommand, + buildCancelCommand, + buildCancelProposalCommand, + buildClaimResidualCommand, + buildCreateVestingCommand, + buildRejectProposalCommand, + buildTapCommand, + buildWithdrawCommand, + decodeSchedule, + encodeSchedule, +} from '@/backend/commands' +import type { VestingSchedule } from '@/utils/schedule' + +const CONFIG_CID = '00cfg' + +const linear: VestingSchedule = { + cliff: '2026-01-01T00:00:00Z', + curve: { kind: 'linear', start: '2026-01-01T00:00:00Z', end: '2027-01-01T00:00:00Z' }, +} + +const milestone: VestingSchedule = { + cliff: '2026-02-01T00:00:00Z', + curve: { + kind: 'milestone', + points: [ + { time: '2026-02-01T00:00:00Z', fraction: 0.4 }, + { time: '2026-08-01T00:00:00Z', fraction: 1.0 }, + ], + }, +} + +describe('encodeSchedule', () => { + it('encodes a linear curve as a tagged variant with an ISO start/end record', () => { + expect(encodeSchedule(linear)).toEqual({ + curve: { + tag: 'LinearVesting', + value: { start: '2026-01-01T00:00:00Z', end: '2027-01-01T00:00:00Z' }, + }, + cliff: '2026-01-01T00:00:00Z', + }) + }) + + it('encodes a milestone curve as tagged points with _1/_2 tuple records, Decimal as string', () => { + expect(encodeSchedule(milestone)).toEqual({ + curve: { + tag: 'MilestoneVesting', + value: { + points: [ + { _1: '2026-02-01T00:00:00Z', _2: '0.4' }, + { _1: '2026-08-01T00:00:00Z', _2: '1' }, + ], + }, + }, + cliff: '2026-02-01T00:00:00Z', + }) + }) +}) + +describe('decodeSchedule', () => { + it('round-trips a linear schedule', () => { + expect(decodeSchedule(encodeSchedule(linear))).toEqual(linear) + }) + + it('round-trips a milestone schedule', () => { + expect(decodeSchedule(encodeSchedule(milestone))).toEqual(milestone) + }) + + it('falls back to a degenerate linear curve on garbage input', () => { + expect(decodeSchedule(undefined)).toEqual({ + cliff: '', + curve: { kind: 'linear', start: '', end: '' }, + }) + }) +}) + +describe('command builders', () => { + it('builds the factory choice with the canonical amount, encoded schedule and token ids', () => { + expect( + buildCreateVestingCommand('pkg:Vesting:VestingFactory', 'factory-cid', { + configCid: CONFIG_CID, + proposer: 'funder::1', + receiver: 'receiver::1', + totalAmount: '1000.00', + schedule: linear, + tokenCids: ['t1', 't2'], + }), + ).toEqual({ + ExerciseCommand: { + templateId: 'pkg:Vesting:VestingFactory', + contractId: 'factory-cid', + choice: 'VestingFactory_CreateVesting', + choiceArgument: { + proposer: 'funder::1', + receiver: 'receiver::1', + totalAmount: '1000', + schedule: encodeSchedule(linear), + tokenCids: ['t1', 't2'], + configCid: CONFIG_CID, + note: null, + }, + }, + }) + }) + + it('builds Accept carrying nothing but the config', () => { + expect(buildAcceptCommand('pkg:Vesting:VestingProposal', 'p1', CONFIG_CID)).toEqual({ + ExerciseCommand: { + templateId: 'pkg:Vesting:VestingProposal', + contractId: 'p1', + choice: 'VestingProposal_Accept', + choiceArgument: { configCid: CONFIG_CID }, + }, + }) + }) + + it("builds the funder's proposal cancel with no argument at all", () => { + expect(buildCancelProposalCommand('pkg:Vesting:VestingProposal', 'p1')).toEqual({ + ExerciseCommand: { + templateId: 'pkg:Vesting:VestingProposal', + contractId: 'p1', + choice: 'VestingProposal_Cancel', + choiceArgument: {}, + }, + }) + }) + + it("builds the receiver's proposal reject with no argument at all", () => { + expect(buildRejectProposalCommand('pkg:Vesting:VestingProposal', 'p1')).toEqual({ + ExerciseCommand: { + templateId: 'pkg:Vesting:VestingProposal', + contractId: 'p1', + choice: 'VestingProposal_Reject', + choiceArgument: {}, + }, + }) + }) + + it('builds Withdraw with a canonical amount beside the config', () => { + expect(buildWithdrawCommand('pkg:Vesting:VestingContract', 'c1', '10.5', CONFIG_CID)).toEqual({ + ExerciseCommand: { + templateId: 'pkg:Vesting:VestingContract', + contractId: 'c1', + choice: 'VestingContract_Withdraw', + choiceArgument: { withdrawAmount: '10.5', configCid: CONFIG_CID }, + }, + }) + }) + + it('builds Cancel with the config alone', () => { + expect(buildCancelCommand('pkg:Vesting:VestingContract', 'c1', CONFIG_CID)).toEqual({ + ExerciseCommand: { + templateId: 'pkg:Vesting:VestingContract', + contractId: 'c1', + choice: 'VestingContract_Cancel', + choiceArgument: { configCid: CONFIG_CID }, + }, + }) + }) + + it('builds the residual withdraw against the claim template', () => { + expect(buildClaimResidualCommand('pkg:Vesting:VestedClaim', 'r1', '2.00', CONFIG_CID)).toEqual({ + ExerciseCommand: { + templateId: 'pkg:Vesting:VestedClaim', + contractId: 'r1', + choice: 'VestedClaim_Withdraw', + choiceArgument: { withdrawAmount: '2', configCid: CONFIG_CID }, + }, + }) + }) + + // The faucet is exercised on the config itself, so it takes the resolved template id the + // registry's disclosure carries rather than the package-name form a filter takes. + it('builds the faucet tap against the resolved config template id', () => { + expect( + buildTapCommand('20d54824:Canton.TokenForge.Registry:InstrumentConfig', CONFIG_CID, { + amount: '1000.00', + user: 'funder::1', + }), + ).toEqual({ + ExerciseCommand: { + templateId: '20d54824:Canton.TokenForge.Registry:InstrumentConfig', + contractId: CONFIG_CID, + choice: 'InstrumentConfig_Tap', + choiceArgument: { user: 'funder::1', amount: '1000' }, + }, + }) + }) + + // A Daml Numeric literal has no trailing-dot form; the input filters upstream let '1000.' through. + it('canonicalizes a trailing-dot amount before it reaches the payload', () => { + const cmd = buildCreateVestingCommand('TID', 'fcid', { + configCid: CONFIG_CID, + proposer: 'P', + receiver: 'B', + totalAmount: '1000.', + schedule: linear, + tokenCids: [], + }) + expect((cmd.ExerciseCommand.choiceArgument as { totalAmount: string }).totalAmount).toBe('1000') + expect( + buildWithdrawCommand('TID', 'cid', '100.', CONFIG_CID).ExerciseCommand.choiceArgument + .withdrawAmount, + ).toBe('100') + expect( + buildClaimResidualCommand('TID', 'rcid', '50.', CONFIG_CID).ExerciseCommand.choiceArgument + .withdrawAmount, + ).toBe('50') + }) + + it('canonicalizes the amount and refuses one it cannot parse', () => { + expect( + buildWithdrawCommand('pkg:Vesting:VestingContract', 'c1', '10.50', CONFIG_CID).ExerciseCommand + .choiceArgument.withdrawAmount, + ).toBe('10.5') + expect(() => + buildWithdrawCommand('pkg:Vesting:VestingContract', 'c1', 'not-a-number', CONFIG_CID), + ).toThrow() + }) +}) diff --git a/example-dapps/token-vesting/src/backend/commands.ts b/example-dapps/token-vesting/src/backend/commands.ts new file mode 100644 index 00000000..e3bc817a --- /dev/null +++ b/example-dapps/token-vesting/src/backend/commands.ts @@ -0,0 +1,158 @@ +// 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 '@/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 +// `{tag, value}`, a `(Time, Decimal)` tuple `{_1, _2}`, Time an ISO-8601 string and Decimal a +// string. + +type EncodedCurve = + | { tag: 'LinearVesting'; value: { end: string; start: string } } + | { tag: 'MilestoneVesting'; value: { points: { _1: string; _2: string }[] } } + +export type EncodedSchedule = { cliff: string; curve: EncodedCurve } + +export const encodeSchedule = (schedule: VestingSchedule): EncodedSchedule => { + const curve = schedule.curve + if (curve.kind === 'linear') { + return { + curve: { tag: 'LinearVesting', value: { start: curve.start, end: curve.end } }, + cliff: schedule.cliff, + } + } + return { + curve: { + tag: 'MilestoneVesting', + value: { + points: curve.points.map((point) => ({ _1: point.time, _2: String(point.fraction) })), + }, + }, + cliff: schedule.cliff, + } +} + +// Mirror of encodeSchedule. A missing or garbled payload yields a degenerate but well-typed +// schedule rather than throwing inside a mapper. +export const decodeSchedule = (raw: unknown): VestingSchedule => { + const record = (raw ?? {}) as { curve?: unknown; cliff?: unknown } + const cliff = typeof record.cliff === 'string' ? record.cliff : '' + const curve = (record.curve ?? {}) as { tag?: unknown; value?: unknown } + if (curve.tag === 'MilestoneVesting') { + const value = (curve.value ?? {}) as { points?: unknown } + const points = Array.isArray(value.points) ? value.points : [] + return { + cliff, + curve: { + kind: 'milestone', + points: points.map((point) => { + const tuple = (point ?? {}) as { _1?: unknown; _2?: unknown } + return { time: String(tuple._1 ?? ''), fraction: Number(tuple._2 ?? 0) } + }), + }, + } + } + const value = (curve.value ?? {}) as { start?: unknown; end?: unknown } + return { + cliff, + curve: { + kind: 'linear', + start: typeof value.start === 'string' ? value.start : '', + end: typeof value.end === 'string' ? value.end : '', + }, + } +} + +// ── Command builders ──────────────────────────────────────────────────────── + +type CreateVestingArgs = { + configCid: string + note?: string + proposer: string + receiver: string + schedule: VestingSchedule + tokenCids: string[] + totalAmount: string +} + +const exercise = ( + templateId: string, + contractId: string, + choice: string, + choiceArgument: Record, +) => ({ ExerciseCommand: { templateId, contractId, choice, choiceArgument } }) + +// `tokenCids` are the funder's inputs, which the choice splits down to the grant; the holding the +// proposal ends up naming is the exact-size output that split leaves behind. +export const buildCreateVestingCommand = ( + templateId: string, + factoryCid: string, + args: CreateVestingArgs, +) => + exercise(templateId, factoryCid, 'VestingFactory_CreateVesting', { + proposer: args.proposer, + receiver: args.receiver, + totalAmount: canonicalAmount(args.totalAmount), + schedule: encodeSchedule(args.schedule), + tokenCids: args.tokenCids, + configCid: args.configCid, + note: args.note ?? null, + }) + +export const buildAcceptCommand = (templateId: string, pendingCid: string, configCid: string) => + exercise(templateId, pendingCid, 'VestingProposal_Accept', { configCid }) + +// Neither exit takes the config: both choices are bodyless and move no holding, so a proposal is +// archived on its controller's authority alone. +export const buildCancelProposalCommand = (templateId: string, pendingCid: string) => + exercise(templateId, pendingCid, 'VestingProposal_Cancel', {}) + +export const buildRejectProposalCommand = (templateId: string, pendingCid: string) => + exercise(templateId, pendingCid, 'VestingProposal_Reject', {}) + +// No nowMicros: the choice reads on-ledger getTime. +export const buildWithdrawCommand = ( + templateId: string, + contractCid: string, + withdrawAmount: string, + configCid: string, +) => + exercise(templateId, contractCid, 'VestingContract_Withdraw', { + withdrawAmount: canonicalAmount(withdrawAmount), + configCid, + }) + +export const buildCancelCommand = (templateId: string, contractCid: string, configCid: string) => + exercise(templateId, contractCid, 'VestingContract_Cancel', { configCid }) + +export const buildClaimResidualCommand = ( + templateId: string, + claimCid: string, + withdrawAmount: string, + configCid: string, +) => + exercise(templateId, claimCid, 'VestedClaim_Withdraw', { + withdrawAmount: canonicalAmount(withdrawAmount), + configCid, + }) + +// One fixed amount, so a tap is a menu item and not a form. The registry publishes an instrument's +// id, name, symbol and decimals but never its faucet, and the config is admin-signed with no +// payload in its disclosure, so the per-tap cap cannot be read from the app. This must stay at or +// under the `maxPerTap` scripts/bootstrap-vesting.mjs sets; above it, InstrumentConfig_Tap aborts +// and the message surfaces in the caller's toast. +export const TAP_AMOUNT = '1000' + +// Nonconsuming on the admin-signed config, so a connected wallet taps with nothing disclosed but +// the config itself. +export const buildTapCommand = ( + templateId: string, + configCid: string, + args: { amount: string; user: string }, +) => + exercise(templateId, configCid, 'InstrumentConfig_Tap', { + user: args.user, + amount: canonicalAmount(args.amount), + }) diff --git a/example-dapps/token-vesting/src/backend/config.test.ts b/example-dapps/token-vesting/src/backend/config.test.ts new file mode 100644 index 00000000..7aba5e8d --- /dev/null +++ b/example-dapps/token-vesting/src/backend/config.test.ts @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { type LedgerApi, loadBackendConfig } from '@/backend/config' + +const OPERATOR = 'vesting-operator::ns' +// What a bootstrap predating the stable hint left behind. A different party, whose factory is not +// the one the registry is now configured for. +const LEGACY = 'vesting-operator-1700000000001::ns' + +const factoryRow = ( + createdEvent: Record, + synchronizerId?: string, +): Record => ({ + contractEntry: { + JsActiveContract: { createdEvent, ...(synchronizerId ? { synchronizerId } : {}) }, + }, +}) + +const created = { + contractId: '00cid', + createdEventBlob: 'YmxvYg==', + templateId: 'abc123:Vesting:VestingFactory', +} + +// 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: LEGACY } } } }, + { 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 } +} + +const ADMIN = 'instrument-admin-1700000000000::ns' + +// loadBackendConfig now reads the instrument off the registry as well as the factory off the +// ledger, so every case needs the happy registry unless it is about the registry failing. +beforeEach(() => { + vi.stubGlobal('fetch', async (url: string) => ({ + ok: true, + status: 200, + json: async () => + String(url).endsWith('/instruments') + ? { instruments: [{ id: 'DBT' }] } + : { adminId: ADMIN, supportedApis: {} }, + })) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('loadBackendConfig', () => { + it('returns the deployment it reads back, synchronizer id included', async () => { + const { ledgerApi } = ledger() + await expect(loadBackendConfig(ledgerApi)).resolves.toEqual({ + admin: ADMIN, + factoryBlob: 'YmxvYg==', + factoryCid: '00cid', + instrumentId: 'DBT', + pkg: 'abc123', + synchronizerId: 'sync::1', + }) + }) + + // The pair comes from the registry because nothing on the ledger carries it: the factory is this + // repo's own operator, the instrument admin is a third party. + it('carries the registry’s instrument into the deployment', async () => { + const { ledgerApi } = ledger() + const deployment = await loadBackendConfig(ledgerApi) + expect([deployment.admin, deployment.instrumentId]).toEqual([ADMIN, 'DBT']) + }) + + 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('reads as the operator allocated under the bootstrap hint', async () => { + const { ledgerApi, filteredParty } = ledger() + await loadBackendConfig(ledgerApi) + expect(filteredParty()).toBe(OPERATOR) + }) + + // The stamped spelling is a prefix of nothing the current bootstrap creates, and adopting it would + // point the app at that run's factory while the registry serves this run's admin. + it('does not adopt a stamped operator from an earlier bootstrap', async () => { + const { ledgerApi } = ledger({ rights: [{ kind: { CanActAs: { value: { party: LEGACY } } } }] }) + await expect(loadBackendConfig(ledgerApi)).rejects.toThrow(/no vesting operator/) + }) + + 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.*pnpm run bootstrap/, + ) + }) + + // 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 an empty active-contracts read', async () => { + const { ledgerApi } = ledger({ acs: [] }) + await expect(loadBackendConfig(ledgerApi)).rejects.toThrow(/no factory disclosable/) + }) + + 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/example-dapps/token-vesting/src/backend/config.ts b/example-dapps/token-vesting/src/backend/config.ts new file mode 100644 index 00000000..d0f74688 --- /dev/null +++ b/example-dapps/token-vesting/src/backend/config.ts @@ -0,0 +1,120 @@ +// The deployment the bootstrap left on the ledger, read back through the wallet rather than carried +// in a file: a stale copy of the operator or the factory shows as an empty dashboard with no error. + +import type { LedgerApiParams } from '@bootnodedev/canton-connect' +import { fetchInstrument } from '@/backend/registry' + +export type Deployment = { + admin: string + factoryBlob: string + factoryCid: string + instrumentId: string + pkg: string + synchronizerId?: string +} + +export type LedgerApi = (params: LedgerApiParams) => Promise + +// A filter takes the package-name reference, never the id it resolves to. +const FACTORY = '#vesting:Vesting:VestingFactory' +// Canton mints `::`, so the separator belongs to the prefix: without it a +// `vesting-operator-::ns` party from a bootstrap predating the stable hint matches too, and +// the app runs on that run's factory while bootstrap configures the registry for this one. +const OPERATOR_HINT = 'vesting-operator::' + +const advice = (reason: string): Error => new Error(`${reason} — run pnpm run bootstrap`) + +export 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 + } + } +} + +// The bootstrap grants this user `CanActAs` on each operator it creates, so its rights are the +// operator list, where reading the ledger's parties would also return everyone else's. Sorted only +// so that a shared participant, where the hint can resolve under two namespaces, picks one of them +// on every load rather than whichever came first. +const operatorParty = 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', + // A gateway allowlists the route, so the id travels in `path` rather than written in here. + resource: '/v2/users/{user-id}/rights', + path: { 'user-id': user.id }, + }) + 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, instrument] = await Promise.all([operatorParty(ledgerApi), fetchInstrument()]) + 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, + }, + }) + // Without the blob a funder cannot disclose the factory, so such a row is no use. + 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}`) + } + const pkg = created.templateId?.split(':')[0] + if (pkg === undefined || pkg === '') { + throw advice('the factory came back with no package id') + } + return { + admin: instrument.admin, + factoryBlob: created.createdEventBlob, + factoryCid: created.contractId, + instrumentId: instrument.instrumentId, + pkg, + ...(factory?.synchronizerId === undefined ? {} : { synchronizerId: factory.synchronizerId }), + } +} diff --git a/example-dapps/token-vesting/src/backend/registry.test.ts b/example-dapps/token-vesting/src/backend/registry.test.ts new file mode 100644 index 00000000..5f3622e5 --- /dev/null +++ b/example-dapps/token-vesting/src/backend/registry.test.ts @@ -0,0 +1,253 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { fetchAppNetwork, fetchInstrument, fetchInstrumentConfig } from '@/backend/registry' + +const ADMIN = 'instrument-admin-1700000000000::ns' +const INSTRUMENT = { admin: ADMIN, instrumentId: 'DBT' } + +const CONFIG = { + templateId: '20d54824:Canton.TokenForge.Registry:InstrumentConfig', + contractId: '00cfg', + createdEventBlob: 'YmxvYg==', + synchronizerId: 'global-domain::1220', +} + +// Keyed by the path each route answers, so a case names only the response it is about. `calls` +// records the request bodies, which is how the transfer-factory case reads back what was sent. +const stubRegistry = ( + routes: Record, +): { calls: { path: string; body: unknown }[] } => { + const calls: { path: string; body: unknown }[] = [] + vi.stubGlobal('fetch', async (url: string, init?: { body?: string }) => { + const path = new URL(url, 'http://localhost:3013').pathname + calls.push({ path, body: init?.body === undefined ? undefined : JSON.parse(init.body) }) + const route = routes[path] + if (route === undefined) { + return { ok: false, status: 404, json: async () => ({ error: 'not found' }) } + } + const status = route.status ?? 200 + return { ok: status >= 200 && status < 300, status, json: async () => route.body } + }) + return { calls } +} + +const metadata = { + '/registry/metadata/v1/info': { body: { adminId: ADMIN, supportedApis: {} } }, + '/registry/metadata/v1/instruments': { + body: { instruments: [{ id: 'DBT', name: 'dAppBooster Token', symbol: 'DBT', decimals: 10 }] }, + }, +} + +const factory = { + '/registry/transfer-instruction/v1/transfer-factory': { + body: { + factoryId: '00cfg', + transferKind: 'self', + choiceContext: { choiceContextData: { values: {} }, disclosedContracts: [CONFIG] }, + }, + }, +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('fetchInstrument', () => { + it('pairs the admin from /info with the one instrument from /instruments', async () => { + stubRegistry(metadata) + + await expect(fetchInstrument()).resolves.toEqual(INSTRUMENT) + }) + + it('names the bootstrap script when the registry administers no instrument', async () => { + stubRegistry({ + ...metadata, + '/registry/metadata/v1/instruments': { body: { instruments: [] } }, + }) + + await expect(fetchInstrument()).rejects.toThrow(/no instrument.*pnpm run bootstrap/s) + }) + + // Two live configs for one admin is what bootstrap's own findInstrumentConfig refuses, and the + // dApp knows exactly one instrument, so picking one silently would render grants under a symbol + // that is not theirs. + it('refuses to guess when the registry lists more than one instrument', async () => { + stubRegistry({ + ...metadata, + '/registry/metadata/v1/instruments': { + body: { instruments: [{ id: 'DBT' }, { id: 'OTHER' }] }, + }, + }) + + await expect(fetchInstrument()).rejects.toThrow(/more than one instrument/) + }) + + it('reports the route that failed rather than a bare status', async () => { + stubRegistry({ ...metadata, '/registry/metadata/v1/info': { status: 503, body: {} } }) + + await expect(fetchInstrument()).rejects.toThrow( + /registry answered 503 for \/registry\/metadata\/v1\/info/, + ) + }) + + it('names the registry and the url when the fetch itself never answers', async () => { + vi.stubGlobal('fetch', async () => { + throw new TypeError('Failed to fetch') + }) + + await expect(fetchInstrument()).rejects.toThrow( + /registry is unreachable at http:\/\/localhost:3013\/registry\/metadata\/v1\/info/, + ) + }) + + it('refuses an /info body with no admin party', async () => { + stubRegistry({ ...metadata, '/registry/metadata/v1/info': { body: { supportedApis: {} } } }) + + await expect(fetchInstrument()).rejects.toThrow(/no admin party/) + }) + + it('names the bootstrap script when the one instrument came back with no id', async () => { + stubRegistry({ + ...metadata, + '/registry/metadata/v1/instruments': { body: { instruments: [{}] } }, + }) + + await expect(fetchInstrument()).rejects.toThrow(/no id.*pnpm run bootstrap/s) + }) +}) + +describe('fetchInstrumentConfig', () => { + it('asks the self branch, naming the connected party as both ends', async () => { + const { calls } = stubRegistry(factory) + + await fetchInstrumentConfig('funder::1', INSTRUMENT) + + expect(calls[0]?.body).toEqual({ + choiceArguments: { + transfer: { + instrumentId: { admin: ADMIN, id: 'DBT' }, + receiver: 'funder::1', + sender: 'funder::1', + }, + }, + }) + }) + + // The synchronizer stays on the disclosure, because the registry is what vouches for where that + // contract lives, and comes back loose as well for `fetchAppNetwork` to compare the wallet against. + it('returns the config reference, its rebuilt disclosure and the synchronizer id', async () => { + stubRegistry(factory) + + await expect(fetchInstrumentConfig('funder::1', INSTRUMENT)).resolves.toEqual({ + configCid: '00cfg', + configTemplateId: CONFIG.templateId, + disclosed: [ + { + templateId: CONFIG.templateId, + contractId: '00cfg', + createdEventBlob: 'YmxvYg==', + synchronizerId: 'global-domain::1220', + }, + ], + synchronizerId: 'global-domain::1220', + }) + }) + + it('refuses a 200 that disclosed no config', async () => { + stubRegistry({ + '/registry/transfer-instruction/v1/transfer-factory': { + body: { factoryId: '00cfg', choiceContext: { disclosedContracts: [] } }, + }, + }) + + await expect(fetchInstrumentConfig('funder::1', INSTRUMENT)).rejects.toThrow( + /disclosed no InstrumentConfig/, + ) + }) + + it('refuses a disclosure whose templateId names something other than InstrumentConfig', async () => { + stubRegistry({ + '/registry/transfer-instruction/v1/transfer-factory': { + body: { + factoryId: '00cfg', + choiceContext: { + disclosedContracts: [ + { ...CONFIG, templateId: '20d54824:Canton.TokenForge.Registry:Token' }, + ], + }, + }, + }, + }) + + await expect(fetchInstrumentConfig('funder::1', INSTRUMENT)).rejects.toThrow( + /disclosed no InstrumentConfig/, + ) + }) + + it('refuses a factoryId the disclosure does not vouch for', async () => { + stubRegistry({ + '/registry/transfer-instruction/v1/transfer-factory': { + body: { + factoryId: '00other', + choiceContext: { disclosedContracts: [CONFIG] }, + }, + }, + }) + + await expect(fetchInstrumentConfig('funder::1', INSTRUMENT)).rejects.toThrow( + /disclosed no InstrumentConfig/, + ) + }) + + it('surfaces the registry’s own error message', async () => { + stubRegistry({ + '/registry/transfer-instruction/v1/transfer-factory': { + status: 409, + body: { error: 'multiple active configs' }, + }, + }) + + await expect(fetchInstrumentConfig('funder::1', INSTRUMENT)).rejects.toThrow( + /multiple active configs/, + ) + }) + + it('reports a non-JSON body rather than a parse error', async () => { + vi.stubGlobal('fetch', async () => ({ + ok: true, + status: 200, + json: async () => { + throw new Error('Unexpected token <') + }, + })) + + await expect(fetchInstrumentConfig('funder::1', INSTRUMENT)).rejects.toThrow( + /registry answered/, + ) + }) +}) + +describe('fetchAppNetwork', () => { + it('reads the network off the config disclosure the registry serves', async () => { + stubRegistry({ ...metadata, ...factory }) + + await expect(fetchAppNetwork('funder::1')).resolves.toBe('global-domain::1220') + }) + + // A registry that answers without one leaves the check with nothing to compare, which reads as + // no verdict rather than a mismatch. + it('reports no network when the disclosure carries none', async () => { + const { synchronizerId, ...bare } = CONFIG + stubRegistry({ + ...metadata, + '/registry/transfer-instruction/v1/transfer-factory': { + body: { + factoryId: '00cfg', + transferKind: 'self', + choiceContext: { choiceContextData: { values: {} }, disclosedContracts: [bare] }, + }, + }, + }) + + await expect(fetchAppNetwork('funder::1')).resolves.toBeUndefined() + }) +}) diff --git a/example-dapps/token-vesting/src/backend/registry.ts b/example-dapps/token-vesting/src/backend/registry.ts new file mode 100644 index 00000000..a5a7232f --- /dev/null +++ b/example-dapps/token-vesting/src/backend/registry.ts @@ -0,0 +1,161 @@ +// The canton-token-forge registry, which is where the InstrumentConfig disclosure every write +// carries comes from. Reading the config off the ledger instead would only work on LocalNet: the +// instrument admin is a third party, so no connected party is a stakeholder of its contract. + +import type { DisclosedContract } from '@/backend/wallet' +import { REGISTRY_URL } from '@/utils/config' + +export type RegistryInstrument = { + admin: string + instrumentId: string +} + +// The two halves a command needs: the contract id to exercise, and the resolved template id a +// command must carry, which the package-name filters cannot supply. +export type InstrumentConfigRef = { + configCid: string + configTemplateId: string +} + +type WireDisclosure = { + contractId?: string + createdEventBlob?: string + synchronizerId?: string + templateId?: string +} + +const advice = (reason: string): Error => new Error(`${reason}, run pnpm run bootstrap`) + +// A registry that accepts the socket and never answers would otherwise hang the write that is +// waiting on it for good, with only the confirm button's spinner to show for it. The same budget the +// deployed proxy gives its own upstream hop, which `api/_registry.test.ts` is what holds. +const REGISTRY_TIMEOUT_MS = 15_000 + +// The registry answers a refusal with a status and a JSON `error`, so read both: a stopped service +// is fronted by an html error page, which would otherwise surface as a JSON syntax error. +const call = async (path: string, body?: unknown): Promise => { + const init = { + ...(body === undefined + ? { method: 'GET' } + : { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }), + } + let response: Response + try { + response = await fetch(`${REGISTRY_URL}${path}`, { + ...init, + signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS), + }) + } catch (cause: unknown) { + // A service that is down, a CORS refusal and a mixed-content block all reject alike, as a bare + // `Failed to fetch` naming neither the registry nor where it was looked for. A timeout is told + // apart from those, because it is the one where the registry did answer the connection. + if (cause instanceof DOMException && cause.name === 'TimeoutError') { + throw new Error( + `the registry did not answer ${path} within ${REGISTRY_TIMEOUT_MS / 1000}s (${REGISTRY_URL})`, + ) + } + throw new Error(`the registry is unreachable at ${REGISTRY_URL}${path}`) + } + const parsed = (await response.json().catch(() => undefined)) as { error?: string } | undefined + const reason = parsed?.error + if (reason !== undefined) { + throw new Error(`the registry refused ${path}: ${reason}`) + } + if (!response.ok || typeof parsed !== 'object' || parsed === null) { + throw new Error(`the registry answered ${response.status} for ${path}`) + } + return parsed as T +} + +// Once per session, from `loadBackendConfig`. Two calls because /info is CIP-56 registry metadata +// and carries only the admin; the instruments are their own route. +export const fetchInstrument = async (): Promise => { + const [info, listing] = await Promise.all([ + call<{ adminId?: string }>('/registry/metadata/v1/info'), + call<{ instruments?: { id?: string }[] }>('/registry/metadata/v1/instruments'), + ]) + const instruments = listing.instruments ?? [] + if (info.adminId === undefined) { + throw new Error('the registry reported no admin party') + } + if (instruments.length === 0) { + throw advice('the registry administers no instrument') + } + // The dApp knows exactly one instrument, so picking one of several would render a grant under a + // symbol that is not its own. + if (instruments.length > 1) { + throw new Error( + `the registry lists more than one instrument (${instruments.map((one) => one.id).join(', ')})`, + ) + } + const instrumentId = instruments[0]?.id + if (instrumentId === undefined) { + throw advice('the registry listed an instrument with no id') + } + return { admin: info.adminId, instrumentId } +} + +// Per write, not cached: one round trip against a contract that can be archived underneath us. +// sender === receiver takes the route's `self` branch, whose choice context is the config alone; +// `transferKind` is not read, because the disclosure is the same on every branch. +export const fetchInstrumentConfig = async ( + party: string, + instrument: RegistryInstrument, +): Promise< + InstrumentConfigRef & { disclosed: DisclosedContract[]; synchronizerId: string | undefined } +> => { + const result = await call<{ + choiceContext?: { disclosedContracts?: WireDisclosure[] } + factoryId?: string + }>('/registry/transfer-instruction/v1/transfer-factory', { + choiceArguments: { + transfer: { + instrumentId: { admin: instrument.admin, id: instrument.instrumentId }, + receiver: party, + sender: party, + }, + }, + }) + const config = (result.choiceContext?.disclosedContracts ?? []).at(0) + // Defence in depth: the four vesting choices re-derive `expectedAdmin`/`expectedInstrumentId` in + // Daml regardless, but `tap` exercises directly on the templateId and contract id below, so a + // pair the disclosure does not vouch for is refused here rather than handed to the wallet. + if ( + result.factoryId === undefined || + config?.contractId === undefined || + config.contractId !== result.factoryId || + config.createdEventBlob === undefined || + config.templateId === undefined || + !config.templateId.endsWith(':Canton.TokenForge.Registry:InstrumentConfig') + ) { + throw new Error('the registry disclosed no InstrumentConfig for this instrument') + } + return { + configCid: result.factoryId, + configTemplateId: config.templateId, + // Rebuilt field by field so nothing else off the wire travels, `synchronizerId` kept: the + // registry is what vouches for where this contract lives. Returned alongside as well, because + // the submission carrying it has to agree with the deployment's own. + disclosed: [ + { + templateId: config.templateId, + contractId: config.contractId, + createdEventBlob: config.createdEventBlob, + ...(config.synchronizerId === undefined ? {} : { synchronizerId: config.synchronizerId }), + }, + ], + synchronizerId: config.synchronizerId, + } +} + +// The app's side of the wrong-network check. Read from the registry rather than off the deployment +// the wallet reads back: a wallet on another network returns no factory at all, so the deployment's +// own synchronizer can only ever agree with it, and the strip would never fire on a first load. +export const fetchAppNetwork = async (party: string): Promise => { + const { synchronizerId } = await fetchInstrumentConfig(party, await fetchInstrument()) + return synchronizerId +} diff --git a/example-dapps/token-vesting/src/backend/synchronizer.test.ts b/example-dapps/token-vesting/src/backend/synchronizer.test.ts new file mode 100644 index 00000000..58d58e0e --- /dev/null +++ b/example-dapps/token-vesting/src/backend/synchronizer.test.ts @@ -0,0 +1,55 @@ +import type { LedgerApiParams } from '@bootnodedev/canton-connect' +import { describe, expect, it } from 'vitest' +import type { LedgerApi } from '@/backend/config' +import { walletSynchronizers } from '@/backend/synchronizer' + +const stubLedger = (answer: unknown): { calls: LedgerApiParams[]; ledgerApi: LedgerApi } => { + const calls: LedgerApiParams[] = [] + return { + calls, + ledgerApi: async (params) => { + calls.push(params) + return answer + }, + } +} + +describe('walletSynchronizers', () => { + it('returns every synchronizer the participant reports', async () => { + const { ledgerApi } = stubLedger({ + connectedSynchronizers: [ + { synchronizerAlias: 'global', synchronizerId: 'global-domain::1220a' }, + { synchronizerAlias: 'other', synchronizerId: 'other-domain::1220b' }, + ], + }) + + await expect(walletSynchronizers(ledgerApi, 'alice::1')).resolves.toEqual([ + 'global-domain::1220a', + 'other-domain::1220b', + ]) + }) + + it('asks the route by name and passes the party as a query parameter', async () => { + const { calls, ledgerApi } = stubLedger({ connectedSynchronizers: [] }) + + await walletSynchronizers(ledgerApi, 'alice::1') + + expect(calls).toEqual([ + { + requestMethod: 'get', + resource: '/v2/state/connected-synchronizers', + query: { party: 'alice::1' }, + }, + ]) + }) + + it.each([ + ['the key is absent', {}], + ['the list is empty', { connectedSynchronizers: [] }], + ['an entry carries no id', { connectedSynchronizers: [{ synchronizerAlias: 'global' }] }], + ])('reports nothing when %s', async (_case, answer) => { + const { ledgerApi } = stubLedger(answer) + + await expect(walletSynchronizers(ledgerApi, 'alice::1')).resolves.toEqual([]) + }) +}) diff --git a/example-dapps/token-vesting/src/backend/synchronizer.ts b/example-dapps/token-vesting/src/backend/synchronizer.ts new file mode 100644 index 00000000..9ed64d8c --- /dev/null +++ b/example-dapps/token-vesting/src/backend/synchronizer.ts @@ -0,0 +1,20 @@ +// Which synchronizers the wallet's participant will submit to. Read on its own rather than off +// `config.ts`'s factory row, whose answer rebuilds the backend and would re-run every ledger read. + +import { call, type LedgerApi } from '@/backend/config' + +type ConnectedSynchronizers = { connectedSynchronizers?: { synchronizerId?: string }[] } + +export const walletSynchronizers = async ( + ledgerApi: LedgerApi, + party: string, +): Promise => { + const { connectedSynchronizers } = await call(ledgerApi, { + requestMethod: 'get', + resource: '/v2/state/connected-synchronizers', + query: { party }, + }) + return (connectedSynchronizers ?? []) + .map((one) => one.synchronizerId) + .filter((id): id is string => id !== undefined) +} diff --git a/example-dapps/token-vesting/src/backend/wallet.ts b/example-dapps/token-vesting/src/backend/wallet.ts new file mode 100644 index 00000000..0854cd78 --- /dev/null +++ b/example-dapps/token-vesting/src/backend/wallet.ts @@ -0,0 +1,27 @@ +// The two wallet-session calls the backend needs, injected as plain functions so LedgerBackend stays +// constructible outside React while the functions themselves come from canton-connect's hooks. + +import type { LedgerApiParams, PrepareExecuteParams } from '@bootnodedev/canton-connect' + +// JSON Ledger API v2 command — always an ExerciseCommand in this dApp. +export type LedgerCommand = { + ExerciseCommand: { + choice: string + choiceArgument: Record + contractId: string + templateId: string + } +} + +// Explicitly-disclosed contract (JSON Ledger API v2 disclosedContracts entry). +export type DisclosedContract = { + contractId: string + createdEventBlob: string + synchronizerId?: string + templateId: string +} + +export type WalletFns = { + execute: (params: PrepareExecuteParams) => Promise + ledgerApi: (params: LedgerApiParams) => Promise +} diff --git a/example-dapps/token-vesting/src/components/AmountDisplay.tsx b/example-dapps/token-vesting/src/components/AmountDisplay.tsx new file mode 100644 index 00000000..1985802e --- /dev/null +++ b/example-dapps/token-vesting/src/components/AmountDisplay.tsx @@ -0,0 +1,51 @@ +import { formatAmount } from '@bootnodedev/canton-dappbooster' +import { CompactAmount } from '@/components/CompactAmount' +import { InfoTip } from '@/components/InfoTip' +import { TokenMark } from '@/icons' +import { cn } from '@/utils/cn' +import { DBT } from '@/utils/tokens' + +interface AmountDisplayProps { + className?: string + count?: boolean + fixedMark?: boolean + gradient?: boolean + value: string +} + +// The token mark is the only thing naming the unit, so it carries the name rather than an empty alt. +const UNIT = `${DBT.name} (${DBT.symbol})` + +// Neither varies, so hoisting them 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. +// The mark scales with its figure except on a KPI, where the figures differ in size but their marks +// should not. `InfoTip` gives an element child no accessible name of its own, so the mark carries +// its own `role="img"` and drops the `aria-hidden` it has by default outside this wrapper. +const mark = (size: string): React.JSX.Element => ( + + + +) +const MARK = mark('size-[0.92em]') +const MARK_FIXED = mark('size-[22px]') + +// 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, + className, + count = false, + fixedMark = false, + gradient = false, +}: AmountDisplayProps): React.JSX.Element => ( + + {count ? formatAmount(value) : } + {!count && (fixedMark ? MARK_FIXED : MARK)} + +) diff --git a/example-dapps/token-vesting/src/components/AppShell.test.tsx b/example-dapps/token-vesting/src/components/AppShell.test.tsx new file mode 100644 index 00000000..41b4b706 --- /dev/null +++ b/example-dapps/token-vesting/src/components/AppShell.test.tsx @@ -0,0 +1,81 @@ +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { BackendState } from '@/providers/Backend' + +const backendState = vi.hoisted(() => ({ current: {} as BackendState })) + +vi.mock('react-router-dom', () => ({ + Outlet: () => null, + ScrollRestoration: () => null, +})) +vi.mock('@/components/CreateGrant', () => ({ CreateGrant: () => null })) +vi.mock('@/components/Footer', () => ({ Footer: () => null })) +vi.mock('@/components/Toaster', () => ({ Toaster: () => null })) +vi.mock('@/components/TopBar', () => ({ TopBar: () => null })) +vi.mock('@/components/WrongNetwork', () => ({ WrongNetwork: () => null })) +vi.mock('@/hooks/useConnectErrorToast', () => ({ useConnectErrorToast: () => undefined })) +vi.mock('@/hooks/useCreateGrant', () => ({ useCreateGrant: () => [false, () => undefined] })) +vi.mock('@/providers/Backend', () => ({ useBackend: () => backendState.current })) + +const { AppShell } = await import('@/components/AppShell') + +const BOOTSTRAP = 'No factory found on the ledger. Run `pnpm run bootstrap`.' + +const render = async (state: Partial): Promise => { + backendState.current = { + backend: undefined, + configError: BOOTSTRAP, + configPending: false, + networkStatus: undefined, + sessionPending: false, + ...state, + } as BackendState + const container = document.createElement('div') + const root = createRoot(container) + roots.push(root) + await act(async () => { + root.render() + }) + return container +} + +const roots: { unmount: () => void }[] = [] + +describe('AppShell', () => { + afterEach(async () => { + await act(async () => { + for (const root of roots) { + root.unmount() + } + }) + roots.length = 0 + }) + + it('shows the deployment error once the check reports the network is fine', async () => { + const container = await render({ networkStatus: 'ok' }) + + expect(container.querySelector('h1')?.textContent).toBe('No deployment') + expect(container.textContent).toContain(BOOTSTRAP) + }) + + it('keeps the deployment error while the check cannot answer', async () => { + const container = await render({ networkStatus: 'unknown' }) + + expect(container.querySelector('h1')?.textContent).toBe('Undefined network') + expect(container.textContent).toContain(BOOTSTRAP) + }) + + it('keeps the deployment error before the check has answered', async () => { + const container = await render({ networkStatus: undefined }) + + expect(container.textContent).toContain(BOOTSTRAP) + }) + + it('holds the deployment error back on the wrong network', async () => { + const container = await render({ networkStatus: 'wrong' }) + + expect(container.querySelector('h1')?.textContent).toBe('Wrong network') + expect(container.textContent).not.toContain(BOOTSTRAP) + }) +}) diff --git a/example-dapps/token-vesting/src/components/AppShell.tsx b/example-dapps/token-vesting/src/components/AppShell.tsx new file mode 100644 index 00000000..cc3b749b --- /dev/null +++ b/example-dapps/token-vesting/src/components/AppShell.tsx @@ -0,0 +1,98 @@ +import { useEffect } from 'react' +import { Outlet, ScrollRestoration } from 'react-router-dom' +import { Button } from '@/components/Button' +import { Card } from '@/components/Card' +import { CreateGrant } from '@/components/CreateGrant' +import { Footer } from '@/components/Footer' +import { Loading } from '@/components/Loading' +import { ReadError } from '@/components/ReadError' +import { Toaster } from '@/components/Toaster' +import { TopBar } from '@/components/TopBar' +import { WrongNetwork } from '@/components/WrongNetwork' +import { useConnectErrorToast } from '@/hooks/useConnectErrorToast' +import { useCreateGrant } from '@/hooks/useCreateGrant' +import { useBackend } from '@/providers/Backend' +import type { NetworkStatus } from '@/utils/network' + +const NETWORK_CARD: Record = { + ok: { title: 'No deployment' }, + unknown: { + title: 'Undefined network', + body: 'Network could not be fetched. This might be a temporary issue, check again in a few minutes.', + }, + wrong: { + title: 'Wrong network', + body: 'The wallet is connected to the wrong network.', + }, +} + +export const AppShell = (): React.JSX.Element => { + const { backend, configPending, configError, networkStatus, retryConfig, sessionPending } = + useBackend() + // Mounted here rather than per page, because `?create=1` is route state: every page that offers + // the action would otherwise repeat the mount, and a reader can open it from any of them. + const [creating, setCreating] = useCreateGrant() + + useConnectErrorToast() + + const card = NETWORK_CARD[networkStatus ?? 'unknown'] + + const noSession = !sessionPending && !configPending && backend === undefined + useEffect(() => { + if (creating && noSession) { + setCreating(false) + } + }, [creating, noSession, setCreating]) + + return ( +
+ location.pathname} /> +
+ + Skip to main content + + + +
+ {configError !== undefined && ( + +

{card.title}

+ {card.body !== undefined && ( +

{card.body}

+ )} + {/* Only off the wrong network: there the read failed for a reason retrying cannot + change, and the wallet is where the fix is. */} + {networkStatus !== 'wrong' && ( + <> +

{configError}

+ + + )} +
+ )} + {configPending && } + {configError === undefined && !configPending && ( + <> + + + {creating && backend !== undefined && ( + setCreating(false)} /> + )} + + )} +
+
+
+ +
+ ) +} diff --git a/example-dapps/token-vesting/src/components/Button.tsx b/example-dapps/token-vesting/src/components/Button.tsx new file mode 100644 index 00000000..c7ad27e6 --- /dev/null +++ b/example-dapps/token-vesting/src/components/Button.tsx @@ -0,0 +1,108 @@ +import type { ButtonHTMLAttributes } from 'react' +import { Link } from 'react-router-dom' +import { Spinner } from '@/components/Spinner' +import { cn } from '@/utils/cn' + +type Variant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'danger-ghost' +type Size = 'sm' | 'md' | 'lg' | 'icon' + +interface BaseProps { + 'aria-label'?: string + className?: string + size?: Size + variant?: Variant +} + +interface ButtonAsButton extends BaseProps, ButtonHTMLAttributes { + asLink?: false + pending?: boolean +} + +interface ButtonAsLink extends BaseProps { + asLink: true + children?: React.ReactNode + to: string +} + +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-8', +} + +const variants: Record = { + primary: + 'relative isolate overflow-hidden border border-primary bg-primary text-primary-fg ' + + 'before:absolute before:inset-0 before:-z-10 before:bg-[image:var(--gradient-brand)] ' + + 'before:opacity-0 before:transition-opacity not-disabled:hover:border-transparent ' + + 'not-disabled:hover:shadow-[var(--glow)] not-disabled:hover:before:opacity-100', + secondary: + 'border border-border-strong bg-surface text-fg not-disabled:hover:border-primary not-disabled:hover:text-primary-strong', + ghost: + 'border border-transparent text-fg-muted not-disabled:hover:bg-muted not-disabled:hover:text-fg', + danger: 'border border-danger bg-danger text-white not-disabled:hover:bg-danger/90', + 'danger-ghost': 'border border-transparent text-danger not-disabled:hover:bg-danger-soft', +} + +export const buttonClass = (variant: Variant, size: Size, className?: string): string => + cn( + 'inline-flex items-center justify-center gap-2 rounded-[8px] font-semibold transition-colors', + 'focus-visible:outline-none focus-visible:shadow-[var(--ring)]', + 'disabled:cursor-not-allowed disabled:opacity-45', + sizes[size], + variants[variant], + className, + ) + +export const Button = (props: ButtonProps): React.JSX.Element => { + if (props.asLink === true) { + const { variant = 'primary', size = 'md', className, to, children } = props + return ( + + {children} + + ) + } + const { + variant = 'primary', + size = 'md', + className, + type = 'button', + pending = false, + disabled = false, + children, + asLink: _a, + 'aria-label': ariaLabel, + ...rest + } = props + return ( + + ) +} diff --git a/example-dapps/token-vesting/src/components/CancelGrant.tsx b/example-dapps/token-vesting/src/components/CancelGrant.tsx new file mode 100644 index 00000000..5880f76d --- /dev/null +++ b/example-dapps/token-vesting/src/components/CancelGrant.tsx @@ -0,0 +1,89 @@ +import { useState } from 'react' +import { AmountDisplay } from '@/components/AmountDisplay' +import { Button } from '@/components/Button' +import { FieldError } from '@/components/FieldError' +import { Modal } from '@/components/Modal' +import { useOnScreen } from '@/hooks/useOnScreen' +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 CancelGrantProps { + description: string + grant: Grant + nowMs: number + onClose: () => void + onConfirm: () => Promise + successMessage: string +} + +// 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 CancelGrant = ({ + onClose, + grant, + nowMs, + description, + successMessage, + onConfirm, +}: 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 + // on its own rather than sending a submission the contract will assert on. + const floorOk = residualMeetsFloor(derived.claimable) + // Only the confirm button is disabled while a submission is in flight, so the dialog can still be + // dismissed over the wallet prompt; the toast still fires, because the cancel did land. + const onScreen = useOnScreen() + + const submit = async (): Promise => { + setSubmitting(true) + try { + await onConfirm() + toast.success(successMessage) + if (onScreen.current) { + onClose() + } + } catch (err) { + toast.error(errorText(err)) + } finally { + setSubmitting(false) + } + } + + return ( + +
+
+
+ Returned to you + +
+
+ Residual to receiver + +
+
+ {!floorOk && ( + + )} + +
+
+ ) +} diff --git a/example-dapps/token-vesting/src/components/Card.tsx b/example-dapps/token-vesting/src/components/Card.tsx new file mode 100644 index 00000000..a3d7b30e --- /dev/null +++ b/example-dapps/token-vesting/src/components/Card.tsx @@ -0,0 +1,13 @@ +import type { HTMLAttributes } from 'react' +import { cn } from '@/utils/cn' + +// Surface card with the standard border + soft elevation. +export const Card = ({ className, ...rest }: HTMLAttributes): React.JSX.Element => ( +
+) diff --git a/example-dapps/token-vesting/src/components/Claim.tsx b/example-dapps/token-vesting/src/components/Claim.tsx new file mode 100644 index 00000000..6d34f89d --- /dev/null +++ b/example-dapps/token-vesting/src/components/Claim.tsx @@ -0,0 +1,101 @@ +import { TokenInput, validateAmount } from '@bootnodedev/canton-dappbooster' +import { useState } from 'react' +import { Button } from '@/components/Button' +import { FieldError } from '@/components/FieldError' +import { Modal } from '@/components/Modal' +import { useOnScreen } from '@/hooks/useOnScreen' +import { isPositive } from '@/utils/amount' +import { AMOUNT_ERROR_TEXT } from '@/utils/amountErrorText' +import { errorText } from '@/utils/errorText' +import { formatFigureFull } from '@/utils/format' +import { MIN_GRANT_AMOUNT, meetsRelockFloor } from '@/utils/schedule' +import { toast } from '@/utils/toast' +import { DBT } from '@/utils/tokens' + +interface ClaimProps { + available: string + backing: string + onClose: () => void + onConfirm: (amount: string) => Promise + title: string +} + +// Amount-entry dialog shared by grant withdraw and residual claim. `available` is the ceiling and +// `backing` the escrow the re-lock floor is measured against; a residual claim has no schedule, so +// for it they are one amount. +export const Claim = ({ + onClose, + title, + available, + backing, + onConfirm, +}: ClaimProps): React.JSX.Element => { + const [raw, setRaw] = useState('') + const [submitting, setSubmitting] = useState(false) + // Only the confirm button is disabled while a submission is in flight, so the dialog can still be + // dismissed over the wallet prompt; the toast still fires, because the claim did land. + const onScreen = useOnScreen() + + // 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. + const amountError = validateAmount(raw, { max: available }) + const floorOk = meetsRelockFloor(backing, raw) + // The kit's error wins when both apply, so the two sentences are never shown at once. + const message = + amountError !== undefined + ? AMOUNT_ERROR_TEXT[amountError] + : !floorOk && isPositive(raw) + ? `Remainder must be 0 or at least ${MIN_GRANT_AMOUNT} DBT (re-lock floor).` + : undefined + + const valid = amountError === undefined && isPositive(raw) && floorOk + + const submit = async (): Promise => { + if (!valid) { + return + } + setSubmitting(true) + try { + await onConfirm(raw) + // Exact, not abbreviated: this is the only record of what the ledger took and it carries no + // tooltip to recover the digits from. + toast.success(`Claimed ${formatFigureFull(raw)} ${DBT.symbol}`) + if (onScreen.current) { + onClose() + } + } catch (err) { + toast.error(errorText(err)) + } finally { + setSubmitting(false) + } + } + + return ( + + setRaw(next)} + token={DBT} + usdValue="Not Available" + value={raw} + /> + {message !== undefined && ( + + )} + + + ) +} diff --git a/example-dapps/token-vesting/src/components/CompactAmount.tsx b/example-dapps/token-vesting/src/components/CompactAmount.tsx new file mode 100644 index 00000000..b865217c --- /dev/null +++ b/example-dapps/token-vesting/src/components/CompactAmount.tsx @@ -0,0 +1,40 @@ +import { InfoTip } from '@/components/InfoTip' +import { formatFigureCompact, formatFigureFull, isCompacted } from '@/utils/format' +import { DBT } from '@/utils/tokens' + +// A token figure, abbreviated past 10,000 with the exact one still reachable. Carries no unit and +// no mark, so it suits a line that already spells out DBT; `AmountDisplay` wraps it for the rest. +// `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 = formatFigureCompact(value) + if (!isCompacted(value)) { + return <>{figure} + } + const exact = formatFigureFull(value) + if (plain) { + return ( + <> + + {exact} + + ) + } + const labelled = `${exact} ${DBT.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/example-dapps/token-vesting/src/components/ConnectFace.tsx b/example-dapps/token-vesting/src/components/ConnectFace.tsx new file mode 100644 index 00000000..7818b136 --- /dev/null +++ b/example-dapps/token-vesting/src/components/ConnectFace.tsx @@ -0,0 +1,31 @@ +import { useConnect } from '@bootnodedev/canton-connect' +import { CancelButton, ConnectButton } from '@bootnodedev/canton-dappbooster/connect' +import { useEffect, useRef } from 'react' + +export const ConnectFace = ({ + cancelClassName, + className, +}: { + cancelClassName?: string + className?: string +}): React.JSX.Element => { + const { isPending } = useConnect() + const button = useRef(null) + const previous = useRef(isPending) + + // Only after a real swap: focusing on first paint would move focus nobody asked to move. + useEffect(() => { + const swapped = previous.current !== isPending + previous.current = isPending + + if (swapped && document.activeElement === document.body) { + button.current?.focus() + } + }, [isPending]) + + return isPending ? ( + + ) : ( + + ) +} diff --git a/example-dapps/token-vesting/src/components/ConnectPrompt.tsx b/example-dapps/token-vesting/src/components/ConnectPrompt.tsx new file mode 100644 index 00000000..b459791b --- /dev/null +++ b/example-dapps/token-vesting/src/components/ConnectPrompt.tsx @@ -0,0 +1,16 @@ +import { buttonClass } from '@/components/Button' +import { ConnectFace } from '@/components/ConnectFace' +import { EmptyState } from '@/components/EmptyState' + +export const ConnectPrompt = (): React.JSX.Element => ( + + } + /> +) diff --git a/example-dapps/token-vesting/src/components/CopyButton.tsx b/example-dapps/token-vesting/src/components/CopyButton.tsx new file mode 100644 index 00000000..f2deb039 --- /dev/null +++ b/example-dapps/token-vesting/src/components/CopyButton.tsx @@ -0,0 +1,39 @@ +import { type CopyOutcome, useCopyToClipboard } from '@bootnodedev/canton-dappbooster' +import { Check, Copy } from 'lucide-react' +import { cn } from '@/utils/cn' + +interface CopyButtonProps extends React.ComponentPropsWithoutRef<'button'> { + label: string + onOutcome?: (outcome: CopyOutcome) => void + size?: number + value: string +} + +export const CopyButton = ({ + className, + label, + onClick, + onOutcome, + size = 16, + value, + ...rest +}: CopyButtonProps): React.JSX.Element => { + const { copy, state } = useCopyToClipboard() + const copied = state === 'copied' + return ( + + ) +} diff --git a/example-dapps/token-vesting/src/components/CounterpartyId.tsx b/example-dapps/token-vesting/src/components/CounterpartyId.tsx new file mode 100644 index 00000000..bb56d7fc --- /dev/null +++ b/example-dapps/token-vesting/src/components/CounterpartyId.tsx @@ -0,0 +1,21 @@ +import { Identifier } from '@bootnodedev/canton-dappbooster' + +interface CounterpartyIdProps { + incoming: boolean + party: string +} + +// The other party on a grant or pending grant: a from/to prefix and the id, copyable. Renders inline, so +// it needs a block parent to carry the type styles. +export const CounterpartyId = ({ party, incoming }: CounterpartyIdProps): React.JSX.Element => { + return ( + <> + {incoming ? 'from' : 'to'}{' '} + + + ) +} diff --git a/example-dapps/token-vesting/src/components/CreateGrant/DateField.tsx b/example-dapps/token-vesting/src/components/CreateGrant/DateField.tsx new file mode 100644 index 00000000..8d69a7cb --- /dev/null +++ b/example-dapps/token-vesting/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/example-dapps/token-vesting/src/components/CreateGrant/Details.tsx b/example-dapps/token-vesting/src/components/CreateGrant/Details.tsx new file mode 100644 index 00000000..093d811f --- /dev/null +++ b/example-dapps/token-vesting/src/components/CreateGrant/Details.tsx @@ -0,0 +1,101 @@ +import { type PartyIdError, PartyIdInput, TokenInput } from '@bootnodedev/canton-dappbooster' +import { useState } from 'react' +import { inputClass, labelClass } from '@/components/CreateGrant/fields' +import type { FundingToken } from '@/components/CreateGrant/useFundingToken' +import { FieldError } from '@/components/FieldError' +import { cn } from '@/utils/cn' + +const RECEIVER_MESSAGE: Record = { + 'missing-separator': 'Use a full party id (hint::fingerprint).', + 'invalid-hint': 'The hint before :: cannot be blank or contain spaces.', + 'invalid-fingerprint': 'The fingerprint after :: must be 68 hex characters.', +} + +interface DetailsProps { + amount: string + funding: FundingToken + onAmountChange: (value: string) => void + onReceiverChange: (value: string) => void + onTitleChange: (value: string) => void + receiver: string + receiverIsSelf: boolean + title: string +} + +// What the grant is, who gets it and how much. +export const Details = ({ + amount, + funding, + onAmountChange, + onReceiverChange, + onTitleChange, + receiver, + receiverIsSelf, + title, +}: DetailsProps): React.JSX.Element => { + const [malformed, setMalformed] = useState(undefined) + const receiverMessage = + malformed !== undefined + ? RECEIVER_MESSAGE[malformed] + : receiverIsSelf + ? 'Cannot grant to your own party.' + : undefined + + return ( + <> + + onTitleChange(e.target.value)} + placeholder="What is this grant for?" + className={inputClass} + /> +
+
+ + { + onReceiverChange(value) + setMalformed(error) + }} + placeholder="bob::1220…" + value={receiver} + /> + {receiverMessage !== undefined && ( + + )} +
+
+ +

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

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

Percentages are cumulative and must end at 100%.

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

Preview

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

Schedule

+ +
+
+ Demo duration + +