diff --git a/AGENTS.md b/AGENTS.md index 641bea5..0442402 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ Each subproject can layer its own `AGENTS.md` for stack-specific deltas: - [`canton-connect-kit/AGENTS.md`](canton-connect-kit/AGENTS.md) — wagmi-style React hooks for Canton dApps - [`canton-barebones/wallet-service/AGENTS.md`](canton-barebones/wallet-service/AGENTS.md) — wallet-service bridge rules - [`dapp/e2e/AGENTS.md`](dapp/e2e/AGENTS.md) — Playwright black-box integration test rules -- `canton-barebones/`, `dapp/daml/`, `dapp/frontend/` — see each subproject's `README.md` +- `canton-barebones/`, `dapp/daml/vesting-lite/`, `dapp/frontend/` — see each subproject's `README.md` For the system shape (data flow, components, ports), see [`architecture.md`](architecture.md). @@ -35,7 +35,7 @@ Current distribution: | `canton-barebones/wallet-service/` | yes | yes | shim | no | Local bridge rules are useful; README API boundary is enough architecture for now. | | `dapp/e2e/` | yes | yes | shim | no | Independent Playwright package with strict black-box testing conventions. | | `dapp/frontend/` | yes | no | no | no | Small dApp UI; root rules and README are enough. | -| `dapp/daml/` | yes | no | no | no | Single DAML package. | +| `dapp/daml/vesting-lite/` | yes | no | no | no | Single DAML package. | | `canton-barebones/` | yes | no | no | no | Docker/Bash local participant wrapper. | Subproject docs must not restate root rules. They should describe only their local delta and link upward. @@ -44,7 +44,7 @@ Subproject docs must not restate root rules. They should describe only their loc | Category | Technology | Notes | |----------|-----------|-------| -| Languages | TypeScript, DAML, Bash | TypeScript across the JS subprojects; DAML in `dapp/daml/`; Bash for canton-barebones scripts | +| Languages | TypeScript, DAML, Bash | TypeScript across the JS subprojects; DAML in `dapp/daml/vesting-lite/`; Bash for canton-barebones scripts | | Package manager | npm workspaces | Single root `package-lock.json`; one root `npm install` links every workspace. Root `package.json` orchestrates scripts via `npm --prefix ` | | Node | 24 | Pinned via root `.nvmrc`; inherits to every Node subproject | | Container runtime | Docker | Used by `canton-barebones/` for the local participant + Postgres | @@ -58,10 +58,10 @@ Subproject docs must not restate root rules. They should describe only their loc | Path | Purpose | Stack | Port | |------|---------|-------|------| | [`canton-barebones/`](canton-barebones/) | Local Canton participant + Postgres via docker-compose; deploy + health + token scripts | Docker, Bash, Node scripts | 3013/3014/3015/3016/3017/3018 | -| [`dapp/daml/`](dapp/daml/) | `quickstart-tally` DAML model | DAML | n/a (DAR artifact) | +| [`dapp/daml/vesting-lite/`](dapp/daml/vesting-lite/) | `vesting-lite` DAML model | DAML | n/a (DAR artifact) | | [`canton-barebones/wallet-service/`](canton-barebones/wallet-service/) | JSON-RPC bridge between the wallet and the Canton participant. Started by `npm run canton:up`. Self-mints its Canton JWT. | Node + Express + TypeScript | 3010 | | [`carpincho-wallet/`](carpincho-wallet/) | CIP-0103 wallet — vault, signing, WalletConnect, Chrome extension | Vite 6 + React 18 + Tailwind v4 + Biome | 3011 | -| [`dapp/frontend/`](dapp/frontend/) | dApp UI | Vite + React + Tailwind v4 + Radix UI + Biome | 3012 | +| [`dapp/frontend/`](dapp/frontend/) | dApp UI — direct-access via wallet-service JSON-RPC | Vite 6 + React 19 + Tailwind v4 + zustand + react-router-dom + Vitest + Biome | 3012 | | [`dapp/e2e/`](dapp/e2e/) | dApp integration tests | Playwright + TypeScript | n/a | | [`canton-connect-kit/`](canton-connect-kit/) | wagmi-style React hooks for connecting Canton dApps to CIP-0103 wallets | TypeScript + React 18 + Biome | n/a (library) | @@ -96,7 +96,7 @@ See [`architecture.md`](architecture.md) for the system shape, subproject layout - Each subproject owns its own test runner. Run from the subproject directory or via `npm --prefix`: - `carpincho-wallet`: `npm test` (Node `node:test` + `tsx` + happy-dom) - - `dapp/frontend`: `npm test` (Node `node:test` with `--experimental-strip-types`) + - `dapp/frontend`: `npm test` (Vitest) - `dapp/e2e`: `npm test` (Playwright against the running local stack) - `canton-barebones`: `npm test` (Node `node:test` against the scripts) - Cover the paths that matter — business logic, API integrations, component behaviour. Skip styling, third-party library internals, trivial getters/setters. diff --git a/README.md b/README.md index 6a6da6a..67d1a86 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ flowchart TD wallet["carpincho-wallet
Vault + signer
http://localhost:3011"] ws["canton-barebones/wallet-service
Canton bridge
http://localhost:3010"] cb["canton-barebones
Participant JSON API http://localhost:3013
Ledger/Admin gRPC localhost:3014 / 3015"] - dar["dapp/daml
quickstart-tally DAR
.daml/dist/*.dar"] + dar["dapp/daml/vesting-lite
vesting-lite DAR
.daml/dist/*.dar"] fe <-->|"Injected CIP-0103 provider
optional WalletConnect"| wallet wallet -->|"JSON-RPC /rpc
prepare, execute, read, onboard"| ws @@ -16,7 +16,7 @@ flowchart TD dar -->|"deploy DAR package"| cb ``` -The dApp frontend knows the Tally DAML signature and talks to Carpincho through the injected CIP-0103 browser provider. Carpincho owns the local signing key and uses the wallet service to prepare, read, and execute against the Canton participant. WalletConnect remains available as an optional fallback path. +The dApp frontend implements the vesting-lite DAML signature and talks to Carpincho through the injected CIP-0103 browser provider. Carpincho owns the local signing key and uses the wallet service to prepare, read, and execute against the Canton participant. WalletConnect remains available as an optional fallback path. ## Installation @@ -111,7 +111,7 @@ npm run canton:health Build: ```bash -npm run build-dar -- dapp/daml +npm run build-dar -- dapp/daml/vesting-lite ``` Make sure Canton is running: @@ -123,7 +123,7 @@ npm run canton:health Deploy DAR: ```bash -npm run deploy-dar -- dapp/daml/.daml/dist/quickstart-tally-0.0.1.dar +npm run deploy-dar -- dapp/daml/vesting-lite/.daml/dist/vesting-lite-0.0.1.dar ``` Use the same format for any other DAML project and DAR: diff --git a/architecture.md b/architecture.md index b9de9c9..459953c 100644 --- a/architecture.md +++ b/architecture.md @@ -10,10 +10,10 @@ | Subproject | Stack | Purpose | |------------|-------|---------| | `canton-barebones/` | Docker Compose + Bash + Node scripts | Local Canton participant node, Postgres, mint-token helper, DAR deploy script, health-check | -| `dapp/daml/` | DAML (`dpm` build) | `quickstart-tally` model — DAR consumed by Canton | +| `dapp/daml/vesting-lite/` | DAML (`dpm` build) | `vesting-lite` model — DAR consumed by Canton | | `canton-barebones/wallet-service/` | Node 24 + Express 5 + TypeScript + `@canton-network/wallet-sdk` | JSON-RPC bridge between the wallet and the Canton participant JSON API. Started by `npm run canton:up` as a docker-compose service. Self-mints its Canton JWT at boot from `CANTON_AUTH_AUDIENCE` / `CANTON_AUTH_SECRET`. `WALLET_SERVICE_MOCK=1` (`src/mock.ts`) short-circuits the dispatcher with canned responses. | | `carpincho-wallet/` | Vite 6 + React 18 + Tailwind v4 + Radix UI + Biome + WalletConnect Sign Client 2.x + `@noble/ed25519` | CIP-0103 wallet (web + Chrome extension), encrypted local vault, signing, injected provider, optional WalletConnect | -| `dapp/frontend/` | Vite + React + `@canton-network/dapp-sdk` + Tailwind v4 + Radix UI + Biome | dApp UI that talks to the wallet through the injected CIP-0103 provider, with optional WalletConnect fallback | +| `dapp/frontend/` | Vite 6 + React 19 + Tailwind v4 + zustand + react-router-dom + Vitest + Biome | Direct-access dApp UI — talks to the wallet-service over JSON-RPC (no CIP-0103 injected provider, no Radix UI, no dapp-sdk) | | `dapp/e2e/` | Playwright + TypeScript | Black-box integration tests for the dApp, Carpincho, wallet-service, and Canton stack | ## Project Structure @@ -31,7 +31,7 @@ ├── canton-connect-kit/ React hooks for CIP-0103 wallet connections ├── carpincho-wallet/ CIP-0103 wallet (web + Chrome extension) ├── dapp/ -│ ├── daml/ quickstart-tally DAML model +│ ├── daml/vesting-lite/ vesting-lite DAML model │ ├── frontend/ dApp UI │ └── e2e/ Black-box integration tests ├── AGENTS.md Agent rules — monorepo-wide @@ -46,7 +46,7 @@ ## Data Flow -The whole local stack is one signing loop. The dApp frontend discovers Carpincho through the injected CIP-0103 browser provider; Carpincho signs locally and routes the signed transaction through the wallet-service JSON-RPC bridge, which calls the Canton participant's JSON API; the participant materialises the change against the deployed `quickstart-tally` DAR. WalletConnect remains available as an opt-in fallback path. +The whole local stack is one signing loop. The dApp frontend discovers Carpincho through the injected CIP-0103 browser provider; Carpincho signs locally and routes the signed transaction through the wallet-service JSON-RPC bridge, which calls the Canton participant's JSON API; the participant materialises the change against the deployed `vesting-lite` DAR. WalletConnect remains available as an opt-in fallback path. ```mermaid flowchart TD @@ -54,7 +54,7 @@ flowchart TD wallet["carpincho-wallet
Vault + signer
http://localhost:3011"] ws["canton-barebones/wallet-service
Canton bridge
http://localhost:3010"] cb["canton-barebones
Participant JSON API http://localhost:3013
Ledger/Admin gRPC localhost:3014 / 3015"] - dar["dapp/daml
quickstart-tally DAR
.daml/dist/*.dar"] + dar["dapp/daml/vesting-lite
vesting-lite DAR
.daml/dist/*.dar"] fe <-->|"Injected CIP-0103 provider
optional WalletConnect"| wallet wallet -->|"JSON-RPC /rpc
prepare, execute, read, onboard"| ws @@ -118,7 +118,7 @@ For the full bring-up sequence, follow [`README.md`](README.md). - [`carpincho-wallet/architecture.md`](carpincho-wallet/architecture.md) — wallet-internal architecture: Vault crypto, CIP-0103 dispatcher, WalletConnect integration, Chrome extension bridge, theming, auth/session - [`canton-connect-kit/architecture.md`](canton-connect-kit/architecture.md) — connect-kit internals: provider context, connectors, hooks, event bridge - [`canton-barebones/README.md`](canton-barebones/README.md) — local participant setup -- [`dapp/daml/README.md`](dapp/daml/README.md) — DAML model +- [`dapp/daml/vesting-lite/README.md`](dapp/daml/vesting-lite/README.md) — DAML model - [`canton-barebones/wallet-service/README.md`](canton-barebones/wallet-service/README.md) — JSON-RPC bridge - [`dapp/frontend/README.md`](dapp/frontend/README.md) — dApp UI - [`dapp/e2e/README.md`](dapp/e2e/README.md) — black-box integration tests diff --git a/canton-barebones/.env.example b/canton-barebones/.env.example index c39a799..db1d4fa 100644 --- a/canton-barebones/.env.example +++ b/canton-barebones/.env.example @@ -32,7 +32,9 @@ CANTON_ADMIN_TOKEN=mock-token-replace-with-scripts-mint-token-output # Wallet-service (started by docker compose alongside Canton) WALLET_SERVICE_PORT=3010 -WALLET_SERVICE_CORS_ORIGINS=http://localhost:3011 +WALLET_SERVICE_CORS_ORIGINS=http://localhost:3011,http://localhost:3012 +# listAccounts returns only parties whose hint starts with this prefix (empty = all) +WALLET_SERVICE_ACCOUNTS_PREFIX=vesting- NETWORK=canton:local WALLET_PROVIDER_ID=wallet-service WALLET_PROVIDER_VERSION=0.1.0 diff --git a/canton-barebones/dars/quickstart-tally-0.0.1.dar b/canton-barebones/dars/vesting-lite-0.0.1.dar similarity index 79% rename from canton-barebones/dars/quickstart-tally-0.0.1.dar rename to canton-barebones/dars/vesting-lite-0.0.1.dar index 4b14bbf..a51abf6 100644 Binary files a/canton-barebones/dars/quickstart-tally-0.0.1.dar and b/canton-barebones/dars/vesting-lite-0.0.1.dar differ diff --git a/canton-barebones/docker-compose.yaml b/canton-barebones/docker-compose.yaml index a233c3a..c39e912 100644 --- a/canton-barebones/docker-compose.yaml +++ b/canton-barebones/docker-compose.yaml @@ -76,6 +76,7 @@ services: CANTON_ADMIN_API_URL: "grpc://canton:5012" WALLET_SERVICE_PORT: "3010" WALLET_SERVICE_CORS_ORIGINS: "${WALLET_SERVICE_CORS_ORIGINS:-http://localhost:3011}" + WALLET_SERVICE_ACCOUNTS_PREFIX: "${WALLET_SERVICE_ACCOUNTS_PREFIX:-}" NETWORK: "${NETWORK:-canton:local}" WALLET_PROVIDER_ID: "${WALLET_PROVIDER_ID:-wallet-service}" WALLET_PROVIDER_VERSION: "${WALLET_PROVIDER_VERSION:-0.1.0}" diff --git a/canton-barebones/wallet-service/src/config.ts b/canton-barebones/wallet-service/src/config.ts index 7439f7f..ab24d91 100644 --- a/canton-barebones/wallet-service/src/config.ts +++ b/canton-barebones/wallet-service/src/config.ts @@ -7,6 +7,8 @@ export interface WalletServiceConfig { port: number corsOrigins: string[] network: string + // When set, listAccounts returns only parties whose hint starts with this prefix. + accountsHintPrefix?: string provider: { id: string version: string @@ -73,6 +75,7 @@ export const loadConfig = (): WalletServiceConfig => { .map((origin) => origin.trim()) .filter((origin) => origin.length > 0), network: optional('NETWORK') ?? 'canton:local', + accountsHintPrefix: optional('WALLET_SERVICE_ACCOUNTS_PREFIX'), provider: { id: optional('WALLET_PROVIDER_ID') ?? 'wallet-service', version: optional('WALLET_PROVIDER_VERSION') ?? '0.1.0', diff --git a/canton-barebones/wallet-service/src/mock.ts b/canton-barebones/wallet-service/src/mock.ts index 3f9c1ae..7a4a7c2 100644 --- a/canton-barebones/wallet-service/src/mock.ts +++ b/canton-barebones/wallet-service/src/mock.ts @@ -138,6 +138,7 @@ export const createMockRpc = ( case 'getActiveNetwork': return rpcResult(id, mockNetwork(config)) case 'listAccounts': + // Intentional: mock has no ledger to query; callers should handle an empty list. return rpcResult(id, []) case 'getPrimaryAccount': return rpcError(id, -32001, 'Resource not found', { diff --git a/canton-barebones/wallet-service/src/rpc.ts b/canton-barebones/wallet-service/src/rpc.ts index 5aaa215..be6e788 100644 --- a/canton-barebones/wallet-service/src/rpc.ts +++ b/canton-barebones/wallet-service/src/rpc.ts @@ -12,6 +12,7 @@ import type { Network, Provider, StatusEvent, + Wallet, } from './types.ts' export class InvalidParams extends Error { @@ -346,6 +347,46 @@ export const createRpc = (config: WalletServiceConfig): Rpc => { return parsed } + // Reads the backend user's CanActAs rights (what the bootstrap grants), optionally + // narrowed to a hint prefix so a long-lived dev ledger's stale grants don't leak in. + const listAccounts = async (): Promise => { + if (config.canton.backendToken === undefined) { + throw new Error('CANTON_BACKEND_TOKEN is required for Canton JSON API calls') + } + const base = config.canton.jsonApiUrl.replace(/\/$/, '') + const url = `${base}/v2/users/${encodeURIComponent(config.canton.backendUserId)}/rights` + const response = await fetch(url, { + headers: { authorization: `Bearer ${config.canton.backendToken}` }, + signal: AbortSignal.timeout(15_000), + }) + if (!response.ok) { + throw new Error( + `Canton JSON API GET /v2/users/${config.canton.backendUserId}/rights → HTTP ${response.status}`, + ) + } + const text = await response.text() + const isJson = + text.length > 0 && response.headers.get('content-type')?.includes('json') === true + const body = (isJson ? safeJsonParse(text) : {}) as { + rights?: Array<{ kind?: { CanActAs?: { value?: { party?: string } } } }> + } + const prefix = config.accountsHintPrefix + const parties = (body.rights ?? []) + .map((right) => right.kind?.CanActAs?.value?.party) + .filter((party): party is string => typeof party === 'string' && party.length > 0) + .filter((party) => prefix === undefined || party.split('::')[0].startsWith(prefix)) + return parties.map((partyId) => ({ + primary: false, + partyId, + status: 'allocated' as const, + hint: partyId.split('::')[0], + publicKey: '', + namespace: partyId.split('::')[1] ?? '', + networkId: config.network, + signingProviderId: config.provider.id, + })) + } + const dispatch = async (id: JsonRpcId, request: JsonRpcRequest): Promise => { if (request.jsonrpc !== undefined && request.jsonrpc !== '2.0') { return rpcError(id, -32600, 'Invalid request', { reason: 'jsonrpc must be "2.0"' }) @@ -365,7 +406,7 @@ export const createRpc = (config: WalletServiceConfig): Rpc => { case 'getActiveNetwork': return rpcResult(id, network()) case 'listAccounts': - return rpcResult(id, []) + return rpcResult(id, await listAccounts()) case 'getPrimaryAccount': return rpcError(id, -32001, 'Resource not found', { reason: 'No primary account configured yet.', diff --git a/canton-barebones/wallet-service/test/rpc.test.ts b/canton-barebones/wallet-service/test/rpc.test.ts index 1096fb1..707542a 100644 --- a/canton-barebones/wallet-service/test/rpc.test.ts +++ b/canton-barebones/wallet-service/test/rpc.test.ts @@ -7,6 +7,7 @@ const baseConfig = () => ({ port: 3010, corsOrigins: ['http://localhost:3011'], network: 'canton:local', + accountsHintPrefix: undefined as string | undefined, provider: { id: 'wallet-service', version: '0.1.0', @@ -68,15 +69,139 @@ describe('rpc dispatcher', () => { assert.equal(res.error.code, -32004) }) - it('listAccounts returns []', async () => { - const rpc = createRpc(baseConfig()) - const res = (await rpc.handle({ - jsonrpc: '2.0', - id: 1, - method: 'listAccounts', - })) as JsonRpcResponse - assert.ok('result' in res) - assert.deepEqual(res.result, []) + describe('listAccounts (CanActAs rights)', () => { + it('errors when no backend token is configured', async () => { + const rpc = createRpc(baseConfig()) + const res = (await rpc.handle({ + jsonrpc: '2.0', + id: 1, + method: 'listAccounts', + })) as JsonRpcResponse + assert.ok('error' in res) + assert.equal(res.error.code, -32000) + }) + + it('maps the user CanActAs rights to accounts', async () => { + const config = baseConfig() + config.canton.backendToken = 'test-token' + const originalFetch = globalThis.fetch + globalThis.fetch = (async () => ({ + ok: true, + text: async () => + JSON.stringify({ + rights: [ + { kind: { CanActAs: { value: { party: 'vesting-pablo-123::1220abc' } } } }, + { kind: { ParticipantAdmin: {} } }, + { kind: { CanActAs: { value: { party: 'vesting-operator-123::1220def' } } } }, + ], + }), + headers: { get: (_: string) => 'application/json' }, + })) as unknown as typeof fetch + try { + const rpc = createRpc(config) + const res = (await rpc.handle({ + jsonrpc: '2.0', + id: 1, + method: 'listAccounts', + })) as JsonRpcResponse + assert.ok('result' in res) + const accounts = res.result as Array<{ partyId: string; hint: string }> + assert.equal(accounts.length, 2) + assert.deepEqual( + accounts.map((a) => a.partyId), + ['vesting-pablo-123::1220abc', 'vesting-operator-123::1220def'], + ) + assert.equal(accounts[0]?.hint, 'vesting-pablo-123') + } finally { + globalThis.fetch = originalFetch + } + }) + + it('hits the correct URL and sends Authorization: Bearer ', async () => { + const config = baseConfig() + config.canton.backendToken = 'my-secret-token' + config.canton.backendUserId = 'vesting-service' + const originalFetch = globalThis.fetch + let capturedUrl: string | undefined + let capturedAuth: string | undefined + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + capturedUrl = String(input) + capturedAuth = (init?.headers as Record)?.authorization + return { + ok: true, + text: async () => JSON.stringify({ rights: [] }), + headers: { get: (_: string) => 'application/json' }, + } + }) as unknown as typeof fetch + try { + const rpc = createRpc(config) + await rpc.handle({ jsonrpc: '2.0', id: 1, method: 'listAccounts' }) + assert.ok( + capturedUrl?.endsWith('/v2/users/vesting-service/rights'), + `expected URL ending with /v2/users/vesting-service/rights, got: ${capturedUrl}`, + ) + assert.equal(capturedAuth, 'Bearer my-secret-token') + } finally { + globalThis.fetch = originalFetch + } + }) + + it('returns empty accounts and does not throw when the 200 body is non-JSON', async () => { + const config = baseConfig() + config.canton.backendToken = 'test-token' + const originalFetch = globalThis.fetch + globalThis.fetch = (async () => ({ + ok: true, + text: async () => 'unexpected plain text from server', + headers: { get: (_: string) => 'text/html' }, + })) as unknown as typeof fetch + try { + const rpc = createRpc(config) + const res = (await rpc.handle({ + jsonrpc: '2.0', + id: 1, + method: 'listAccounts', + })) as JsonRpcResponse + assert.ok('result' in res, 'expected result, got error') + assert.deepEqual(res.result, []) + } finally { + globalThis.fetch = originalFetch + } + }) + + it('filters accounts by accountsHintPrefix when configured', async () => { + const config = baseConfig() + config.canton.backendToken = 'test-token' + config.accountsHintPrefix = 'vesting-' + const originalFetch = globalThis.fetch + globalThis.fetch = (async () => ({ + ok: true, + text: async () => + JSON.stringify({ + rights: [ + { kind: { CanActAs: { value: { party: 'vesting-pablo-1::1220a' } } } }, + { kind: { CanActAs: { value: { party: 'spike-owner-9::1220z' } } } }, + ], + }), + headers: { get: (_: string) => 'application/json' }, + })) as unknown as typeof fetch + try { + const rpc = createRpc(config) + const res = (await rpc.handle({ + jsonrpc: '2.0', + id: 1, + method: 'listAccounts', + })) as JsonRpcResponse + assert.ok('result' in res) + const accounts = res.result as Array<{ partyId: string }> + assert.deepEqual( + accounts.map((account) => account.partyId), + ['vesting-pablo-1::1220a'], + ) + } finally { + globalThis.fetch = originalFetch + } + }) }) it('getActiveNetwork returns the configured network', async () => { diff --git a/dapp/README.md b/dapp/README.md index f14f039..4ff0758 100644 --- a/dapp/README.md +++ b/dapp/README.md @@ -2,8 +2,8 @@ Minimal dApp split into: -- `daml`: Tally Daml package. -- `frontend`: React dApp that knows the Tally signature and talks to Carpincho through the injected CIP-0103 provider. +- `daml/vesting-lite`: vesting-lite Daml package. +- `frontend`: React dApp (vesting UI, port 3012) that implements the vesting-lite signature. Direct-access: talks to the wallet-service over JSON-RPC (no injected CIP-0103 provider). - `e2e`: Playwright tests for the dApp integration flow. The Canton barebones lives in `../canton-barebones`. diff --git a/dapp/daml-test/.gitignore b/dapp/daml-test/.gitignore new file mode 100644 index 0000000..ce075f6 --- /dev/null +++ b/dapp/daml-test/.gitignore @@ -0,0 +1 @@ +.daml/ diff --git a/dapp/daml-test/daml.yaml b/dapp/daml-test/daml.yaml new file mode 100644 index 0000000..d4ed4e6 --- /dev/null +++ b/dapp/daml-test/daml.yaml @@ -0,0 +1,10 @@ +sdk-version: 3.4.11 +name: vesting-lite-test +source: daml +version: 0.0.1 +dependencies: + - daml-prim + - daml-stdlib + - daml-script +data-dependencies: + - ../daml/vesting-lite/.daml/dist/vesting-lite-0.0.1.dar diff --git a/dapp/daml-test/daml/Vesting/Tests/IntegrationTest.daml b/dapp/daml-test/daml/Vesting/Tests/IntegrationTest.daml new file mode 100644 index 0000000..5a554fc --- /dev/null +++ b/dapp/daml-test/daml/Vesting/Tests/IntegrationTest.daml @@ -0,0 +1,194 @@ +module Vesting.Tests.IntegrationTest where + +import Daml.Script + +import DA.Assert ((===)) +import DA.Time (addRelTime, hours) +import DA.Optional (fromSome) + +import Vesting +import Vesting.Schedule + +-- Build a linear schedule anchored at the given start time, cliff == start. +linearSchedule : Time -> Time -> VestingSchedule +linearSchedule start end = VestingSchedule with + curve = LinearVesting with start; end + cliff = start + +-- Full lifecycle: factory -> create -> accept -> partial claim -> full claim. +testHappyPath : Script () +testHappyPath = script do + provider <- allocateParty "provider" + proposer <- allocateParty "proposer" + beneficiary <- allocateParty "beneficiary" + + -- Anchor schedule before "now" so the contract is fully vested when claimed. + now <- getTime + let start = addRelTime now (hours (-100)) + end = addRelTime now (hours (-50)) + sched = linearSchedule start end + + factoryCid <- submit provider do + createCmd VestingFactory with provider + + -- The factory is observer-less: the proposer can only exercise it via explicit + -- disclosure of the provider's created-event blob. + disclosedFactory <- fromSome <$> queryDisclosure provider factoryCid + + proposalCid <- submit (actAs proposer <> disclose disclosedFactory) do + exerciseCmd factoryCid Factory_CreateVesting with + proposer + beneficiary + total = 100.0 + schedule = sched + note = Some "grant" + + contractCid <- submit beneficiary do + exerciseCmd proposalCid Proposal_Accept + + -- Fully vested (end is in the past): claim a partial amount first. + contractCid2 <- submit beneficiary do + exerciseCmd contractCid Contract_Claim with amount = 40.0 + + Some c2 <- queryContractId beneficiary contractCid2 + c2.claimed === 40.0 + + -- Then claim the remaining 60.0 (full). + contractCid3 <- submit beneficiary do + exerciseCmd contractCid2 Contract_Claim with amount = 60.0 + + Some c3 <- queryContractId beneficiary contractCid3 + c3.claimed === 100.0 + + pure () + +-- Cancel with sub-floor residual must be rejected. +-- The 1.0 CC floor is intentional and mirrors the real cc-vesting-contracts: a +-- Contract_Cancel that would strand 0 < owed < minGrantAmount is blocked because +-- releasing dust below the minimum grant size is not a valid ledger state. The +-- proposer must either let the beneficiary drain more first (owed reaches 0) or +-- wait until vesting advances past the floor. This is faithful behaviour, not a bug. +testCancelSubMinGrantAmountFails : Script () +testCancelSubMinGrantAmountFails = script do + provider <- allocateParty "provider3" + proposer <- allocateParty "proposer3" + beneficiary <- allocateParty "beneficiary3" + + -- Tiny total of 1.5. Schedule barely started: only ~0.4 CC vested at cancel + -- time, leaving owed = 0.4 which is below minGrantAmount (1.0). + now <- getTime + let start = addRelTime now (hours (-4)) + end = addRelTime now (hours 96) -- 100h total; 4h elapsed → 4% = 0.06 * 1.5 ≈ 0.06 CC + sched = linearSchedule start end + total = 1.5 + + factoryCid <- submit provider do + createCmd VestingFactory with provider + + disclosedFactory <- fromSome <$> queryDisclosure provider factoryCid + + proposalCid <- submit (actAs proposer <> disclose disclosedFactory) do + exerciseCmd factoryCid Factory_CreateVesting with + proposer; beneficiary; total; schedule = sched; note = None + + contractCid <- submit beneficiary do + exerciseCmd proposalCid Proposal_Accept + + -- owed at this point is total * (4/100) = 0.06, which is < minGrantAmount (1.0). + submitMustFail proposer do + exerciseCmd contractCid Contract_Cancel + + pure () + +-- Cancel after the grant is fully claimed: owed == 0, so the None branch is taken +-- and no VestedClaim / residual contract is created. +testCancelFullyClaimed : Script () +testCancelFullyClaimed = script do + provider <- allocateParty "provider4" + proposer <- allocateParty "proposer4" + beneficiary <- allocateParty "beneficiary4" + + now <- getTime + let start = addRelTime now (hours (-100)) + end = addRelTime now (hours (-50)) + sched = linearSchedule start end + total = 100.0 + + factoryCid <- submit provider do + createCmd VestingFactory with provider + + disclosedFactory <- fromSome <$> queryDisclosure provider factoryCid + + proposalCid <- submit (actAs proposer <> disclose disclosedFactory) do + exerciseCmd factoryCid Factory_CreateVesting with + proposer; beneficiary; total; schedule = sched; note = None + + contractCid <- submit beneficiary do + exerciseCmd proposalCid Proposal_Accept + + -- Fully vested and fully claimed before cancel. + contractCid2 <- submit beneficiary do + exerciseCmd contractCid Contract_Claim with amount = 100.0 + + -- owed == 0 → Contract_Cancel returns None (no residual contract created). + result <- submit proposer do + exerciseCmd contractCid2 Contract_Cancel + result === None + + pure () + +-- Cancel mid-vest yields a VestedClaim of (vested - claimed), then withdraw drains it. +testCancelResidual : Script () +testCancelResidual = script do + provider <- allocateParty "provider2" + proposer <- allocateParty "proposer2" + beneficiary <- allocateParty "beneficiary2" + + -- Half-vested at "now": start 50h ago, end 50h ahead, cliff at start. + now <- getTime + let start = addRelTime now (hours (-50)) + end = addRelTime now (hours 50) + sched = linearSchedule start end + total = 100.0 + + factoryCid <- submit provider do + createCmd VestingFactory with provider + + -- Observer-less factory: disclose the provider's blob so the proposer can act. + disclosedFactory <- fromSome <$> queryDisclosure provider factoryCid + + proposalCid <- submit (actAs proposer <> disclose disclosedFactory) do + exerciseCmd factoryCid Factory_CreateVesting with + proposer + beneficiary + total + schedule = sched + note = Some "grant2" + + contractCid <- submit beneficiary do + exerciseCmd proposalCid Proposal_Accept + + -- Claim a small amount before cancel, so claimed > 0. + contractCid2 <- submit beneficiary do + exerciseCmd contractCid Contract_Claim with amount = 10.0 + + -- Compute expected residual at cancel time using the same pure function. + cancelNow <- getTime + let vested = vestedAmount sched total cancelNow + claimed = 10.0 + owed = vested - claimed + + mClaimCid <- submit proposer do + exerciseCmd contractCid2 Contract_Cancel + + let claimCid = fromSome mClaimCid + Some claim <- queryContractId beneficiary claimCid + claim.amount === owed + claim.withdrawn === 0.0 + + -- Drain the residual fully; choice returns None when fully withdrawn. + result <- submit beneficiary do + exerciseCmd claimCid Claim_Withdraw with withdrawAmount = owed + result === None + + pure () diff --git a/dapp/daml-test/daml/Vesting/Tests/ScheduleTest.daml b/dapp/daml-test/daml/Vesting/Tests/ScheduleTest.daml new file mode 100644 index 0000000..ad0d199 --- /dev/null +++ b/dapp/daml-test/daml/Vesting/Tests/ScheduleTest.daml @@ -0,0 +1,84 @@ +module Vesting.Tests.ScheduleTest where + +import Daml.Script + +import DA.Assert ((===)) +import DA.Date (date, Month(Jan)) +import DA.Time (time, addRelTime, hours) +import Vesting.Schedule + +-- A fixed reference epoch for building deterministic Time values. +epoch : Time +epoch = time (date 2026 Jan 1) 0 0 0 + +at : Int -> Time +at h = addRelTime epoch (hours h) + +-- vestedFraction is 0 before the cliff (linear curve, cliff after start). +testFractionZeroBeforeCliff : Script () +testFractionZeroBeforeCliff = script do + let sched = VestingSchedule with + curve = LinearVesting with start = at 0; end = at 100 + cliff = at 10 + vestedFraction sched (at 5) === 0.0 + +-- Linear midpoint is ~0.5 (cliff at start so it does not clamp). +testLinearMidpoint : Script () +testLinearMidpoint = script do + let sched = VestingSchedule with + curve = LinearVesting with start = at 0; end = at 100 + cliff = at 0 + let frac = vestedFraction sched (at 50) + assertMsg ("midpoint fraction ~ 0.5, got " <> show frac) + (abs (frac - 0.5) < 0.0001) + +-- Milestone steps to the last-reached cumulative fraction. +testMilestoneSteps : Script () +testMilestoneSteps = script do + let sched = VestingSchedule with + curve = MilestoneVesting with + points = [(at 10, 0.25), (at 20, 0.5), (at 30, 1.0)] + cliff = at 10 + vestedFraction sched (at 5) === 0.0 -- before cliff + vestedFraction sched (at 15) === 0.25 -- first milestone reached + vestedFraction sched (at 25) === 0.5 -- second reached + vestedFraction sched (at 35) === 1.0 -- all reached + +-- validVestingSchedule accepts a good linear and a good milestone. +testValidAccepts : Script () +testValidAccepts = script do + let linear = VestingSchedule with + curve = LinearVesting with start = at 0; end = at 100 + cliff = at 10 + let milestone = VestingSchedule with + curve = MilestoneVesting with + points = [(at 10, 0.25), (at 20, 0.5), (at 30, 1.0)] + cliff = at 10 + validVestingSchedule linear === True + validVestingSchedule milestone === True + +-- validVestingSchedule rejects descending times, last != 1.0, and cliff < start. +testValidRejects : Script () +testValidRejects = script do + -- descending milestone times + let descendingTimes = VestingSchedule with + curve = MilestoneVesting with + points = [(at 30, 0.25), (at 20, 0.5), (at 10, 1.0)] + cliff = at 0 + validVestingSchedule descendingTimes === False + + -- last cumulative fraction is not 1.0 + let lastNotOne = VestingSchedule with + curve = MilestoneVesting with + points = [(at 10, 0.25), (at 20, 0.5), (at 30, 0.9)] + cliff = at 10 + validVestingSchedule lastNotOne === False + + -- cliff before start: the linear rule requires start <= cliff <= end, so a + -- cliff that precedes start is rejected. (The plan says "cliff>start"; the + -- ground-truth code actually constrains cliff to [start, end], so the genuine + -- rejection is cliff < start.) + let cliffBeforeStart = VestingSchedule with + curve = LinearVesting with start = at 50; end = at 100 + cliff = at 10 + validVestingSchedule cliffBeforeStart === False diff --git a/dapp/daml/README.md b/dapp/daml/README.md deleted file mode 100644 index 8ebe213..0000000 --- a/dapp/daml/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# Tally Daml - -Build the DAR: - -```bash -dpm build -``` - -The compiled DAR is written to: - -```bash -.daml/dist/quickstart-tally-0.0.1.dar -``` - -For local deployment, follow the root [DAR deployment step](../../README.md#deploy-dars). - -This folder owns the app Daml code. The base Canton folder should not keep app DARs checked in. diff --git a/dapp/daml/daml/Tally/Tally.daml b/dapp/daml/daml/Tally/Tally.daml deleted file mode 100644 index 0ec96da..0000000 --- a/dapp/daml/daml/Tally/Tally.daml +++ /dev/null @@ -1,61 +0,0 @@ -module Tally.Tally where - -import DA.Set (Set, insert) -import DA.Map (Map) -import qualified DA.Map as Map - - -template TallyWriter - with - issuer: Party - user: Party - where - signatory issuer - observer user - - nonconsuming choice TallyWriter_Increment : ContractId Tally - with - tallyId: ContractId Tally - controller user - do - exercise tallyId Tally_Increment - - -template Tally - with - issuer: Party - value: Int - writers: Map Party (ContractId TallyWriter) - viewers: Set Party - where - signatory issuer - observer Map.keys writers, viewers - - choice Tally_Increment : ContractId Tally - controller issuer - do - create this with value = value + 1 - - nonconsuming choice Tally_GrantWriter : (ContractId Tally, ContractId TallyWriter) - with - newWriter: Party - controller issuer - do - case Map.lookup newWriter writers of - Some existing -> - return (self, existing) - None -> do - writerId <- create TallyWriter with - issuer = issuer - user = newWriter - archive self - tallyId <- create this with - writers = Map.insert newWriter writerId writers - return (tallyId, writerId) - - choice Tally_GrantViewer : ContractId Tally - with - newViewer: Party - controller issuer - do - create this with viewers = insert newViewer viewers diff --git a/dapp/daml/multi-package.yaml b/dapp/daml/multi-package.yaml new file mode 100644 index 0000000..090e637 --- /dev/null +++ b/dapp/daml/multi-package.yaml @@ -0,0 +1,3 @@ +# Single-package umbrella for vesting-lite. +packages: + - ./vesting-lite diff --git a/dapp/daml/package.json b/dapp/daml/package.json deleted file mode 100644 index fbc3037..0000000 --- a/dapp/daml/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "@canton-dappbooster/daml", - "private": true, - "version": "0.1.0", - "scripts": { - "build": "dpm build", - "deploy": "../../canton-barebones/scripts/deploy-dar.sh .daml/dist/quickstart-counter-0.0.1.dar" - } -} diff --git a/dapp/daml/.gitignore b/dapp/daml/vesting-lite/.gitignore similarity index 100% rename from dapp/daml/.gitignore rename to dapp/daml/vesting-lite/.gitignore diff --git a/dapp/daml/vesting-lite/README.md b/dapp/daml/vesting-lite/README.md new file mode 100644 index 0000000..3841d1e --- /dev/null +++ b/dapp/daml/vesting-lite/README.md @@ -0,0 +1,4 @@ +# Vesting Lite (Daml) + +The canonical Daml package for the vesting dApp: `vesting-lite` (module `Vesting`). +Build: `dpm build` → `.daml/dist/vesting-lite-0.0.1.dar`. Tests live in `../../daml-test`. diff --git a/dapp/daml/daml.yaml b/dapp/daml/vesting-lite/daml.yaml similarity index 80% rename from dapp/daml/daml.yaml rename to dapp/daml/vesting-lite/daml.yaml index 0504ab5..34c59db 100644 --- a/dapp/daml/daml.yaml +++ b/dapp/daml/vesting-lite/daml.yaml @@ -1,5 +1,5 @@ sdk-version: 3.4.11 -name: quickstart-tally +name: vesting-lite source: daml version: 0.0.1 dependencies: diff --git a/dapp/daml/vesting-lite/daml/Vesting.daml b/dapp/daml/vesting-lite/daml/Vesting.daml new file mode 100644 index 0000000..20c5b55 --- /dev/null +++ b/dapp/daml/vesting-lite/daml/Vesting.daml @@ -0,0 +1,115 @@ +module Vesting where + +import Vesting.Schedule (VestingSchedule, vestedAmount, validVestingSchedule, minGrantAmount) + +-- Observer-less factory: a non-stakeholder proposer can exercise this only via +-- explicit disclosure (the provider hands over the createdEventBlob out-of-band). +template VestingFactory + with + provider : Party + where + signatory provider + + nonconsuming choice Factory_CreateVesting : ContractId VestingProposal + with + proposer : Party + beneficiary : Party + total : Decimal + schedule : VestingSchedule + note : Optional Text + controller proposer + do + assertMsg "schedule is not well-formed" (validVestingSchedule schedule) + assertMsg "total below minimum" (total >= minGrantAmount) + create VestingProposal with provider, proposer, beneficiary, total, schedule, note + +template VestingProposal + with + provider : Party + proposer : Party + beneficiary : Party + total : Decimal + schedule : VestingSchedule + note : Optional Text + where + signatory provider, proposer + observer beneficiary + + choice Proposal_Accept : ContractId VestingContract + controller beneficiary + do + create VestingContract with + provider, proposer, beneficiary, total, schedule, note, claimed = 0.0 + +template VestingContract + with + provider : Party + proposer : Party + beneficiary : Party + total : Decimal + schedule : VestingSchedule + claimed : Decimal + note : Optional Text + where + signatory provider, proposer, beneficiary + ensure + total > 0.0 + && claimed >= 0.0 + && claimed <= total + && validVestingSchedule schedule + + -- Beneficiary withdraws vested-minus-claimed; real ledger time via getTime. + choice Contract_Claim : ContractId VestingContract + with + amount : Decimal + controller beneficiary + do + now <- getTime + assertMsg "cliff not reached" (now >= schedule.cliff) + let vested = vestedAmount schedule total now + assertMsg "amount must be positive" (amount > 0.0) + assertMsg "cannot exceed vested minus claimed" (amount <= vested - claimed) + create this with claimed = claimed + amount + + -- Creator cancels: beneficiary keeps the earned-but-unclaimed residual as a + -- VestedClaim (value-preserving); contract archived. Numeric lite analog of the + -- real escrow split (no LockedAmulet). + choice Contract_Cancel : Optional (ContractId VestedClaim) + controller proposer + do + now <- getTime + let vested = vestedAmount schedule total now + owed = vested - claimed + assertMsg "owed residual below minimum (drain or leave >= floor)" + (owed == 0.0 || owed >= minGrantAmount) + if owed > 0.0 + then fmap Some $ create VestedClaim with + provider, proposer, beneficiary, amount = owed, withdrawn = 0.0, note + else pure None + +-- Earned residual handed to the beneficiary at cancel. No cliff, no schedule. +template VestedClaim + with + provider : Party + proposer : Party + beneficiary : Party + amount : Decimal + withdrawn : Decimal + note : Optional Text + where + signatory provider, proposer + observer beneficiary + ensure amount > 0.0 && withdrawn >= 0.0 && withdrawn <= amount + + choice Claim_Withdraw : Optional (ContractId VestedClaim) + with + withdrawAmount : Decimal + controller beneficiary + do + let available = amount - withdrawn + assertMsg "withdrawAmount must be positive" (withdrawAmount > 0.0) + assertMsg "withdrawAmount exceeds available" (withdrawAmount <= available) + let nextWithdrawn = withdrawn + withdrawAmount + if nextWithdrawn >= amount + then pure None + else fmap Some $ create this with withdrawn = nextWithdrawn diff --git a/dapp/daml/vesting-lite/daml/Vesting/Schedule.daml b/dapp/daml/vesting-lite/daml/Vesting/Schedule.daml new file mode 100644 index 0000000..d743511 --- /dev/null +++ b/dapp/daml/vesting-lite/daml/Vesting/Schedule.daml @@ -0,0 +1,53 @@ +module Vesting.Schedule where + +import DA.Time (subTime, convertRelTimeToMicroseconds) +import DA.List (last, head) + +-- How CC accrues over time. Fraction-based; cliff is curve-agnostic. +data VestingCurve + = LinearVesting with start : Time; end : Time + | MilestoneVesting with points : [(Time, Decimal)] -- (time, CUMULATIVE fraction), ascending, last == 1.0 + deriving (Eq, Show) + +data VestingSchedule = VestingSchedule with + curve : VestingCurve + cliff : Time -- nothing earned before this (true cliff) + deriving (Eq, Show) + +vestedFraction : VestingSchedule -> Time -> Decimal +vestedFraction sched now + | now < sched.cliff = 0.0 + | otherwise = case sched.curve of + LinearVesting start end + | now >= end -> 1.0 + | now <= start -> 0.0 + | otherwise -> + let elapsedUs = convertRelTimeToMicroseconds (subTime now start) + durationUs = convertRelTimeToMicroseconds (subTime end start) + in intToDecimal elapsedUs / intToDecimal durationUs + MilestoneVesting points -> + case filter (\(t, _) -> t <= now) points of + [] -> 0.0 + reached -> snd (last reached) + +vestedAmount : VestingSchedule -> Decimal -> Time -> Decimal +vestedAmount sched total now = total * vestedFraction sched now + +-- Conservative minimum grant size (CC). Sanity floor. +minGrantAmount : Decimal +minGrantAmount = 1.0 + +strictlyAscending : Ord b => (a -> b) -> [a] -> Bool +strictlyAscending f xs = and (zipWith (\a b -> f a < f b) xs (drop 1 xs)) + +validVestingSchedule : VestingSchedule -> Bool +validVestingSchedule sched = case sched.curve of + LinearVesting start end -> + start < end && start <= sched.cliff && sched.cliff <= end + MilestoneVesting points -> + not (null points) + && strictlyAscending fst points + && strictlyAscending snd points + && all (\(_, fr) -> fr > 0.0 && fr <= 1.0) points + && snd (last points) == 1.0 + && sched.cliff <= fst (head points) diff --git a/dapp/daml/vesting-lite/package.json b/dapp/daml/vesting-lite/package.json new file mode 100644 index 0000000..fd47350 --- /dev/null +++ b/dapp/daml/vesting-lite/package.json @@ -0,0 +1,10 @@ +{ + "name": "@canton-dappbooster/daml", + "private": true, + "version": "0.1.0", + "scripts": { + "build": "dpm build", + "deploy": "../../../canton-barebones/scripts/deploy-dar.sh .daml/dist/vesting-lite-0.0.1.dar", + "daml:test": "LANG=C.UTF-8 dpm build && cd ../../daml-test && LANG=C.UTF-8 dpm test" + } +} diff --git a/dapp/e2e/tests/features/loyalty/tx-changed.spec.ts b/dapp/e2e/tests/features/loyalty/tx-changed.spec.ts deleted file mode 100644 index fa8c0b2..0000000 --- a/dapp/e2e/tests/features/loyalty/tx-changed.spec.ts +++ /dev/null @@ -1,79 +0,0 @@ -// End-to-end test for the wallet-pushed `txChanged` dapp-api event lifecycle. -// -// Carpincho's pending-approval flow orchestrates: prepareTransaction (wallet- -// service) → local sign with the vault key → executePrepared (wallet-service). -// Per the dapp-api spec, the wallet should emit `txChanged` events at each -// transition: pending → signed → executed (or failed). -// -// We can't easily assert each transient `pending`/`signed` from the dApp DOM -// because they fire within ~hundreds of ms. The deterministic invariant is: -// after a successful prepareExecuteAndWait, the dApp surfaces `executed` and -// the commandId it dispatched. The pending/signed events are validated by a -// JS-side capture installed before the action. - -import { connectViaExtension, onboardWallet } from '../../../fixtures/onboarding.ts' -import { DAPP_URL, expect, test } from '../../../fixtures/stack.ts' - -const PARTY_HINT = `e2e-tx-${Date.now().toString(36)}` - -test('txChanged lifecycle reaches the dApp during prepareExecuteAndWait', async ({ - context, - extensionId, -}) => { - test.setTimeout(90_000) - - // Vault setup + party create. - const wallet = await context.newPage() - await onboardWallet(wallet, extensionId, PARTY_HINT) - - // dApp connect. - const dapp = await context.newPage() - await dapp.goto(DAPP_URL) - await connectViaExtension(dapp) - // The visible New card action is the connected, unlocked workspace state - // that can dispatch prepareExecuteAndWait. - await expect(dapp.getByTestId('new-card')).toBeVisible() - - // Install a capture for all SPLICE_WALLET_EVENT messages with eventName=txChanged - // BEFORE clicking the button — otherwise the pending/signed events that fire - // before the user-visible "executed" state get missed. - await dapp.evaluate(() => { - ;(window as unknown as { __txEvents: unknown[] }).__txEvents = [] - window.addEventListener('message', (event: MessageEvent) => { - const data = event.data as { type?: string; eventName?: string; payload?: unknown } | null - if (data?.type === 'SPLICE_WALLET_EVENT' && data?.eventName === 'txChanged') { - ;(window as unknown as { __txEvents: unknown[] }).__txEvents.push(data.payload) - } - }) - }) - - // Trigger a transaction. "New card" exercises prepareExecuteAndWait. - await dapp.getByTestId('new-card').click() - - // Approve in Carpincho. - await wallet.bringToFront() - await expect(wallet.getByTestId('pending-approve')).toBeVisible() - await wallet.getByTestId('pending-approve').click() - - // Wait for the dApp to surface the executed status. - await dapp.bringToFront() - await expect(dapp.getByTestId('tx-status')).toHaveAttribute('data-tx-status', 'executed', { - timeout: 30_000, - }) - - // Pull the captured event sequence and assert the spec-shaped lifecycle. - const captured = await dapp.evaluate( - () => - (window as unknown as { __txEvents: Array<{ status?: string; commandId?: string }> }) - .__txEvents, - ) - const statuses = captured.map((e) => e.status) - expect(statuses).toEqual(['pending', 'signed', 'executed']) - - // commandId should be consistent across all three events for this transaction. - const commandIds = new Set(captured.map((e) => e.commandId)) - expect(commandIds.size).toBe(1) - const onlyId = [...commandIds][0] - expect(typeof onlyId).toBe('string') - expect((onlyId as string).length).toBeGreaterThan(0) -}) diff --git a/dapp/e2e/tests/features/sign-message/sign-message.spec.ts b/dapp/e2e/tests/features/sign-message/sign-message.spec.ts deleted file mode 100644 index 6d5d39c..0000000 --- a/dapp/e2e/tests/features/sign-message/sign-message.spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -// End-to-end test for CIP-0103 signMessage via the injected provider. -// -// Walks the full user journey: -// 1. Set up the Carpincho vault from a clean extension state -// 2. Create a party (drives /admin/party/* via the wallet's add-account form) -// 3. Open the dApp and connect via the injected extension provider -// 4. Fill the "Sign message" demo input and click Sign -// 5. Approve in the Carpincho popup -// 6. Verify the dApp surfaces a non-empty base64 signature -// -// Spec invariants checked: -// * client.signMessage({message: base64}) → {signature: base64} -// * The signature is non-empty and looks like base64 (Ed25519 is 64 bytes → 88 chars) - -import { connectViaExtension, onboardWallet } from '../../../fixtures/onboarding.ts' -import { DAPP_URL, expect, test } from '../../../fixtures/stack.ts' - -const PARTY_HINT = `e2e-sign-${Date.now().toString(36)}` - -test('signMessage round-trips a base64 signature through the injected provider', async ({ - context, - extensionId, -}) => { - test.setTimeout(60_000) - - // 1-2. Vault setup + party create — exercises wallet-service /admin/party/{prepare,complete}. - const wallet = await context.newPage() - await onboardWallet(wallet, extensionId, PARTY_HINT) - - // 3. Open the standalone signMessage example page and connect via the provider. - const dapp = await context.newPage() - await dapp.goto(`${DAPP_URL}/sign-demo`) - await connectViaExtension(dapp) - await expect(dapp.getByTestId('connected-party')).toHaveAttribute( - 'data-party-id', - new RegExp(`^${PARTY_HINT}::`), - ) - - // 4. Fill the message and click Sign. - const message = 'verify this party owns the key' - await dapp.getByTestId('sign-input').fill(message) - await dapp.getByTestId('sign-message').click() - - // 5. Carpincho shows the pending-approval card; click Approve. - await wallet.bringToFront() - await expect(wallet.getByTestId('pending-approve')).toBeVisible() - await wallet.getByTestId('pending-approve').click() - - // 6. dApp records the returned signature. - await dapp.bringToFront() - await expect(dapp.getByTestId('signature-output')).toHaveAttribute( - 'data-signature', - /^[A-Za-z0-9+/]+={0,2}$/, - ) - const signature = await dapp.getByTestId('signature-output').getAttribute('data-signature') - - // Ed25519 signature is 64 bytes → 88 base64 chars including '=' padding. - expect(signature).not.toBeNull() - expect(signature!.length).toBe(88) - expect(signature).toMatch(/^[A-Za-z0-9+/]+={0,2}$/) -}) diff --git a/dapp/frontend/.env.local.example b/dapp/frontend/.env.local.example deleted file mode 100644 index c479caa..0000000 --- a/dapp/frontend/.env.local.example +++ /dev/null @@ -1,2 +0,0 @@ -# Optional. Required only for the WalletConnect fallback. -# VITE_WC_PROJECT_ID=replace-with-cloud.reown.com-id diff --git a/dapp/frontend/.gitignore b/dapp/frontend/.gitignore index 8b865fa..fa4ff20 100644 --- a/dapp/frontend/.gitignore +++ b/dapp/frontend/.gitignore @@ -1,4 +1,54 @@ -dist +# Dependencies node_modules +.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# Build output +dist +dist-ssr +build +*.local + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Env +.env +.env.* +!.env.example + +# Editor / OS +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Caches +.cache +.eslintcache *.tsbuildinfo -.env.local + +# Project-specific +.mockups +.playwright-mcp +docs +.vercel + +# Generated by scripts/bootstrap-vesting-lite.mjs (deploy-specific party ids + pkg) +public/vesting-lite-parties.json diff --git a/dapp/frontend/.nvmrc b/dapp/frontend/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/dapp/frontend/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/dapp/frontend/AGENTS.md b/dapp/frontend/AGENTS.md new file mode 100644 index 0000000..7f0d014 --- /dev/null +++ b/dapp/frontend/AGENTS.md @@ -0,0 +1,5 @@ +# Agent Configuration + +This project's agent configuration lives in [`CLAUDE.md`](./CLAUDE.md). + +Claude Code reads `CLAUDE.md` natively. If your agent reads `AGENTS.md` (Cursor, Windsurf, etc.), load rules from `CLAUDE.md` -- it is the single source of truth for this project's conventions, stack, and working rules. diff --git a/dapp/frontend/CLAUDE.md b/dapp/frontend/CLAUDE.md new file mode 100644 index 0000000..0787f82 --- /dev/null +++ b/dapp/frontend/CLAUDE.md @@ -0,0 +1,158 @@ +# Agent Configuration + + + +--- + +## Stack & Conventions + +This is a LIVE direct/explicit-disclosure frontend for the `cc-vesting-contracts` (DAML/Canton) +vesting app. It is no longer mocked: it talks to a real ledger through the wallet-service +`ledgerApi` proxy (the JSON-RPC `ledgerApi` method, default `http://localhost:3010/rpc`), reading +the ACS and submitting commands directly. It lives in the repo's root **npm workspaces**. + +| Category | Technology | Notes | +|----------|-----------|-------| +| Language | TypeScript (strict mode) | | +| Framework | Vite 6 + React 19 | Function components only | +| Routing | React Router 7 | `createBrowserRouter` (`src/routes.tsx`) | +| State | Zustand 5 | `useVestingStore` (domain), `useUiStore` (UI) | +| Styling | Tailwind CSS v4 | CSS-first `@theme inline`; no `tailwind.config` | +| Package manager | npm (root workspaces) | Node `>=24` (`.nvmrc`) | +| Linting | Biome 2 | Run `npm run lint` before committing | +| Testing | Vitest 3 | Unit tests for vesting math (`lib/schedule.test.ts`) | +| Naming | camelCase vars/functions, PascalCase components/types | Component files PascalCase, others camelCase | + +## Code Style + +Enforced by Biome (`biome.json`) — do not hand-fight the formatter: + +- **Semicolons:** no (`asNeeded`) +- **Quotes:** single +- **Print width:** 100 +- **Trailing commas:** all +- **Indent:** spaces, width 2 +- **Import ordering:** Biome `organizeImports` (on) — let it sort +- **Imports:** use the `@/` alias; never include the file extension in imports (Biome errors) + +## Working Rules + +- Use **npm** only (the repo uses root npm workspaces; never pnpm or yarn) +- Path alias: `@/*` maps to `src/` (tsconfig `paths` + Vite `resolve.alias`) +- Tailwind v4 is CSS-first: tokens live in `src/theme/tokens.css` (dual-mode) and are mapped to + utilities in `src/styles/index.css` via `@theme inline`. There is **no** `tailwind.config`. +- The ledger is live behind a swappable backend boundary (`src/backend/`): commands and ACS reads + go through the wallet-service `ledgerApi` proxy. Keep the public shapes aligned with the DAML + templates and the `ledgerApi` request/response surface. +- Components read figures from `deriveGrant()` / `lib/schedule.ts` — never recompute vesting inline. + +## Architecture + + + +See [`architecture.md`](architecture.md) for project structure, data flow, and key abstractions. + +## Testing + + + +- **Framework:** Vitest (React Testing Library not set up yet — add it if testing components) +- **Run tests:** `npm test` +- **What to test:** Vesting math (`lib/schedule.ts`) and store actions/selectors — the logic + that must stay faithful to the contracts. Component behavior once RTL is added. +- **What not to test:** Styling, third-party library internals, trivial getters/setters +- **Coverage:** Aim for meaningful coverage, not a number. Cover the paths that matter. + +## Commit Standards + +Use [Conventional Commits](https://www.conventionalcommits.org/): + +**Format:** `type(scope): subject` + +- **Scope** is optional: `feat: add login` and `feat(auth): add login` are both valid +- **Subject** uses imperative mood, lowercase after the colon, no trailing period +- **Body** (optional): separated by a blank line, explains *what* and *why* + +**Prefixes:** + +| Prefix | Purpose | +|--------|---------| +| `feat` | New feature | +| `fix` | Bug fix | +| `chore` | Maintenance, dependencies, config | +| `docs` | Documentation only | +| `refactor` | Code change that neither fixes a bug nor adds a feature | +| `test` | Adding or updating tests | +| `style` | Formatting, whitespace, semicolons | +| `ci` | CI/CD pipeline changes | +| `perf` | Performance improvement | +| `build` | Build system or external dependencies | +| `revert` | Reverts a previous commit | +| `wip` | Work in progress (avoid on main) | +| `release` | Release-related changes | + +## PR Workflow + +- Every PR must reference an issue (`Closes #`) + + > No related issue? Use `No related issue.` as the first line of the Summary section. + +- Mirror the issue's acceptance criteria in the PR +- Self-review your diff before requesting peer review +- Keep PRs small and focused -- one issue, one PR +- PR titles use the same conventional commit format (`feat: add user dashboard`) +- Use `/sdlc:create-pr` to create PRs -- it reads the template and fills every section automatically + +## Label Conventions + +GitHub form dropdowns (like the Priority field in issue templates) only work through the web UI. When issues are created via `gh` CLI or REST API, dropdown values become unstructured body text -- not queryable, not consistent. **Labels are the API-reliable mechanism for structured metadata.** + +**Priority** (bugs, features, and epics): + +| Label | Description | +|-------|-------------| +| `priority: critical` | Blocking work, system down, or security issue | +| `priority: high` | Must be addressed in current sprint | +| `priority: medium` | Should be addressed soon | +| `priority: low` | Nice to have, can wait | + +Labels are queryable: `gh issue list --label "priority: high"`. + +The `/sdlc:issue` skill applies these labels automatically when creating issues via CLI. Bug, feature, and epic templates include a Priority dropdown for web UI users, but labels are the source of truth for programmatic workflows. + +## Guardrails + +- Do not commit secrets, API keys, or credentials +- Do not modify CI/CD pipelines without team review +- Do not skip tests or linting to make a build pass +- When in doubt, ask -- don't assume + +## Change Strategy + +- Prefer small, focused diffs over broad refactors +- Preserve existing UX unless the task explicitly changes it +- Avoid introducing new patterns when a project pattern already exists +- Update docs only when behavior or workflow changes + +## Validation Checklist + + + +- `npm run lint` +- `npm test` +- `npm run build` (when feasible for runtime-impacting changes) + +## References + + + +- [cc-vesting-contracts](https://github.com/BootNodeDev/cc-vesting-contracts) — the DAML/Canton contracts this UI targets (templates, choices, schedule math) +- [canton-connect-kit](https://github.com/BootNodeDev/bn-canton-dev-stack) — wallet hook API the mocked `src/wallet/` mirrors; Carpincho extension integration +- [dappbooster-canton-landing](https://github.com/BootNodeDev/dappbooster-canton-landing) — source of the palette and typography tokens +- Live deploy: https://cc-vesting-contracts-ui.vercel.app diff --git a/dapp/frontend/README.md b/dapp/frontend/README.md index b73ca76..89811da 100644 --- a/dapp/frontend/README.md +++ b/dapp/frontend/README.md @@ -1,71 +1,42 @@ -# dApp Frontend (starter) +# vesting-ui -A minimal React dApp shell built on `canton-connect-kit`. `App.tsx` wires the -`ConnectKitProvider` and a `ConnectionBar` (connect via the Carpincho extension -or WalletConnect, account/lock handling) around a workspace that renders your -feature components only when the wallet is connected and unlocked. +Direct-access dApp for **Canton Coin vesting**: propose a grant, the beneficiary accepts, claim as it vests, or cancel into a residual claim. It talks to a local Canton ledger straight from the browser (no external wallet) through the wallet-service `ledgerApi` proxy, using **explicit disclosure** of an observer-less factory. -It talks to Carpincho through the injected CIP-0103 provider by default, with -WalletConnect available as an optional fallback: +Adapted from the static [`cc-vesting-contracts-ui`](https://github.com/BootNodeDev/cc-vesting-contracts-ui) — same pages and look, but its mock wallet and mock store are replaced by a live data layer. -```text -frontend -> injected CIP-0103 provider -> carpincho-wallet -> wallet-service -> Canton participant -``` +## Parts -Two demo features ship under `src/features/` (`loyalty`, `sign-message`) and are -meant to be deleted once you start building your own app. +| Path | Role | +|------|------| +| `dapp/daml/vesting-lite/` | DAML package `vesting-lite`: observer-less `VestingFactory` → `VestingProposal` → `VestingContract` → residual `VestedClaim`. Linear + milestone curves, real `getTime`. Mirrors the real `cc-vesting-contracts`, minus the LockedAmulet escrow. | +| `src/backend/` | `VestingBackend` interface + `LiteBackend` (ACS reads, command submits, explicit disclosure over the proxy) + `AmuletBackend` (Splice — C2 stub) + `createBackend(mode)`. | +| `src/wallet/` | `DirectWalletProvider`: the party pool, the "acting as" party, and the Lite/Amulet mode. Exposes `useParty` / `useConnect` / `useParties`. | +| `src/store/useVestingStore.ts` | Ledger-backed store; actions submit then refresh. Pure `deriveGrant` + `lib/schedule.ts` mirror the on-ledger math. | +| `src/app/` | Shell: landing party-picker, top-right party-switcher dropdown, mode chip, receiver/funder lens. | +| `src/features/` | Pages: dashboard, proposals, create, grant-detail. | +| `scripts/bootstrap-vesting-lite.mjs` | Creates the operator + party pool + factory; writes `public/vesting-lite-parties.json` (gitignored, deploy-specific). | ## Run -For the full local stack, follow the root [quick start](../../README.md#quick-start). -This package can also run by itself against the configured wallet URL: +With the local Canton stack running (`./scripts/dev-stack.sh`): ```bash -npm install -npm run dev +./scripts/dev-stack.sh vesting-up # build+deploy the DAR, bootstrap parties, start the dev server +# → http://localhost:3019 ``` -For the optional WalletConnect fallback, copy `.env.local.example` to `.env.local` -and set `VITE_WC_PROJECT_ID` to a WalletConnect/Reown project id. - -The Canton network and Carpincho URL are read from `localStorage`. Defaults: - -- Canton network: `canton:local` -- Carpincho URL: `http://localhost:3011` - -The active Carpincho account must already have a Canton party on the participant. +Manual (DAR already deployed): -## Project shape - -```text -src/ - App.tsx provider + wrapping the feature slots - ConnectionBar.tsx wallet connectivity: connect/lock UX + workspace gate - components/ui/ shared UI primitives (Button, Card, Sheet, TextInput, Tooltip, ToastProvider, icons) - theme/ ThemeProvider + useTheme - index.css shell + base styles - runtimeConfig.ts network / wallet URL (localStorage) - utils/ shared helpers (clipboard, cn, errorMessage, formatPartyId) - features/ - loyalty/ demo feature (DAML-backed stamp card) — removable - sign-message/ demo feature (CIP-0103 signMessage) — removable +```bash +node scripts/bootstrap-vesting-lite.mjs # writes public/vesting-lite-parties.json +npm run vesting-ui:dev # → http://localhost:3019 ``` -A feature folder is self-contained: its component, styles, unit test, and DAML -signature (loyalty only) live together, and it is wired into the app by a single -import + render line in `App.tsx`. - -## Removing a feature - -Each `src/features//` folder is a removable demo. To drop one: +Use the **Create** page's "Quick demo" presets for short (1–2 min) schedules so vesting accrues live during a demo. -1. Delete `src/features//`. -2. Delete its `import` and its `<…/>` line in `src/App.tsx`. -3. Delete its e2e specs at `../e2e/tests/features//`. +## Lite vs Amulet -To fully remove the **loyalty** specifically, also delete its DAML module -directory `../daml/daml/Tally/`, then rebuild the DAR and regenerate codegen -(the frontend's generated types go away with the deleted feature folder). +The mode toggle picks the backend behind the same `VestingBackend` interface: -Delete every feature and you are left with a clean connect-shell starter -(`App.tsx` + `ConnectionBar`) ready for your own contracts and UI. +- **Lite** (now): numeric balances, unfunded grants, on the bare Canton stack. Fully working. +- **Amulet** (C2): real `LockedAmulet` escrow funded from Canton Coin holdings on Splice LocalNet. Same UI — currently offline. diff --git a/dapp/frontend/architecture.md b/dapp/frontend/architecture.md new file mode 100644 index 0000000..222b77a --- /dev/null +++ b/dapp/frontend/architecture.md @@ -0,0 +1,163 @@ +# Architecture Overview + + + +This is a **static, mocked-data** frontend for the +[`cc-vesting-contracts`](https://github.com/BootNodeDev/cc-vesting-contracts) vesting app +(DAML/Canton; token is Splice Amulet / Canton Coin). There is no backend and no live ledger +yet. An in-memory store stands in for the Active Contract Set, and the wallet is mocked. Both +layers deliberately mirror the eventual integration surface so they can be swapped in place. + +## Tech Stack + +| Category | Technology | Notes | +|----------|-----------|-------| +| Framework | React 19 | Function components only | +| Build tool | Vite 6 | `@vitejs/plugin-react`, `@tailwindcss/vite` | +| Language | TypeScript 5 (strict) | No semicolons; single quotes (Biome) | +| Routing | React Router 7 | `createBrowserRouter` | +| State | Zustand 5 | Mock domain store + UI store | +| Styling | Tailwind CSS v4 | CSS-first `@theme inline`, no `tailwind.config` | +| UI primitives | In-house | No component library | +| Testing | Vitest 3 | Unit tests for vesting math | +| Lint/format | Biome 2 | Husky + lint-staged + commitlint | +| Fonts | Manrope, JetBrains Mono | `@fontsource-variable/*` | +| Runtime | Node `>=24` | pnpm | + +## Project Structure + +``` +src/ + main.tsx App entry; mounts , imports global CSS + App.tsx Provider stack (Theme → Wallet → Router) + Toaster + routes.tsx Route table (AppShell layout + 4 pages) + app/ Application chrome + AppShell.tsx Gates on wallet (connect/locked) then renders Sidebar + TopBar + Outlet + Sidebar.tsx Left nav (Dashboard/Proposals/Create) + proposal badge + TopBar.tsx Title/crumb + RoleToggle + ThemeToggle + WalletControl + ConnectScreen.tsx Pre-connection welcome (Carpincho / WalletConnect) + WalletControl.tsx Connect dropdown / connected-party account menu + RoleToggle.tsx Receiver ⇄ Funder view lens + ThemeToggle.tsx Light ⇄ dark + components/ Reusable UI (Card, Button, KpiCard, GrantCard, GrantTable, + ScheduleBar, ScheduleCurve, MilestoneTimeline, StatusPill, + AmountDisplay, Legend, ClaimDialog, Modal, ProposalCard, + EmptyState, toast, icons) + features/ One folder per screen + dashboard/ KPIs + grant cards/table + residual claims + claim/cancel dialogs + proposals/ Incoming/outgoing proposals (accept/decline) + create/ Create-grant form with live schedule-curve preview + grant-detail/ Single grant: curve, milestones, parties, history, actions + store/ Domain model + state + types.ts Grant / Proposal / VestedClaim / Role (mirror DAML templates) + mockData.ts Seed ACS + demo parties (PARTIES.me is the connected party) + useVestingStore.ts Zustand store; actions ≙ choices; deriveGrant() projection + useUiStore.ts Role + dashboard view (cards/table) + lib/ Pure utilities + schedule.ts Vesting math, ports AmuletVesting.Schedule + format.ts CC amounts, party ids, dates, relative time + clock.ts Shared 1s "now" clock (useNow) for live accrual + cn.ts className joiner + wallet/ Mocked CIP-0103 wallet (mirrors canton-connect-kit) + types.ts, WalletProvider.tsx, hooks.ts + theme/ tokens.css (dual-mode design tokens) + ThemeProvider.tsx + styles/index.css Tailwind import + @theme inline mapping + base styles +``` + +## Key Abstractions + +- **`deriveGrant(grant, nowMs)`** (`store/useVestingStore.ts`) — the single projection that + turns a stored grant + the current time into `{ fraction, vested, claimable, claimed, + unvested, status }`. Every screen reads figures from here; nothing recomputes vesting inline. +- **Vesting math** (`lib/schedule.ts`) — `vestedFraction` / `vestedAmount` / + `validVestingSchedule` / `MIN_GRANT_AMOUNT`, a direct port of `AmuletVesting.Schedule` + (linear + milestone + true cliff). Kept pure and unit-tested. +- **Role as a lens** — the connected wallet party is the actor; `useUiStore.role` + (`receiver` | `funder`) only changes which grants are shown (receiver === party vs + creator === party) and which actions render. + +### Data Access Layer + +Two swap points isolate the mock from the eventual integration: + +1. **Wallet** — `src/wallet/` reproduces the public hook API of + `bn-canton-dev-stack`'s `canton-connect-kit` (`useConnect`, `useParty`, `useWalletStatus`, + `useExecute`, and `WalletProvider` ≙ `ConnectKitProvider`). The connector is mocked + (instant connect as a demo party, simulated lock + sign→execute lifecycle). Components only + import from `@/wallet`; swapping to the real package needs no UI changes. Carpincho + (browser extension) is the primary connect path; WalletConnect is secondary. +2. **Ledger** — `useVestingStore` is the in-memory ACS. Actions (`withdraw`, `cancel`, + `accept`, `createVesting`, `claimResidual`) mutate state in place; a real client would + submit a command and refresh from the ledger. Components never construct grants directly — + they call store actions and read derived selectors. + +## Routes + +| Route | Module | Description | +|-------|--------|-------------| +| `/` | `routes.tsx` | Redirects to `/dashboard` | +| `/dashboard` | `features/dashboard/DashboardPage` | KPIs + grants (cards/table) + residual claims | +| `/proposals` | `features/proposals/ProposalsPage` | Incoming (accept/decline) or outgoing proposals | +| `/create` | `features/create/CreateGrantPage` | Create-grant form + live schedule preview | +| `/grants/:id` | `features/grant-detail/GrantDetailPage` | Curve, milestones, parties, history, actions | + +`AppShell` wraps all routes: it renders `ConnectScreen` when disconnected, a locked notice +when the wallet is locked, otherwise the sidebar + top bar + ``. + +## Data Flow + +``` +mockData (seed ACS) ─▶ useVestingStore (Zustand) ─▶ deriveGrant(grant, now) ─▶ UI + ▲ ▲ + actions (withdraw/cancel/accept/create) useNow() ticks 1s → live accrual + ▲ + useExecute() (mocked Carpincho sign→execute) gates write actions +``` + +The connected party comes from `useParty()`; `useUiStore.role` filters which grants each +screen shows. There is no caching layer — state is a single in-memory store and re-renders are +driven by Zustand subscriptions plus the shared clock. + +## Environment Variables + +None. The app is fully static and mocked; nothing reads `import.meta.env` at runtime. + +## Scripts + +| Command | Purpose | +|---------|---------| +| `dev` | Vite dev server with HMR | +| `build` | `tsc -b` then `vite build` to `dist/` | +| `preview` | Serve the built `dist/` | +| `test` | Vitest unit tests (vesting math) | +| `lint` / `lint:fix` | Biome check (write) | +| `typecheck` | `tsc -b --noEmit` | + +--- + +## Domain-Specific Sections + +### Number / Precision Handling + +Amounts are Canton Coin (CC), modeled on DAML `Decimal` (fixed-point, up to 10 fractional +digits on-ledger). The UI works in JavaScript numbers for display only and formats via +`lib/format.ts` (`formatCC`, grouped, ≤2 decimals). `MIN_GRANT_AMOUNT = 1.0` is enforced for +new grants and for the re-lock floor on partial withdrawals/cancels, matching the contract. +Amounts and party ids always render in JetBrains Mono. + +### Smart Contract Architecture (target integration) + +The UI mirrors four DAML templates and their choices (see `store/types.ts`): + +- `Grant` ≙ `AmuletVestingContract`, `Proposal` ≙ `AmuletVestingProposal`, + `VestedClaim` ≙ `AmuletVestedClaim`. +- Origination: `AmuletVestingFactory` → `CreateVesting` → `Proposal` → receiver `Accept` + funds a single `LockedAmulet` escrow → live `Grant`. +- Lifecycle: receiver `Withdraw` (cliff-gated, partial, over-withdraw guarded); funder + `Cancel` (unvested → funder, vested-but-unclaimed → `VestedClaim`); receiver claims the + residual with no cliff. +- `dso` is never a UI actor; escrow funding inputs (`amuletCids`) are represented as the + mocked "fund source" in the create form. diff --git a/dapp/frontend/biome.json b/dapp/frontend/biome.json new file mode 100644 index 0000000..b9041fa --- /dev/null +++ b/dapp/frontend/biome.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json", + "root": false, + "assist": { "actions": { "source": { "organizeImports": "on" } } }, + "vcs": { + "clientKind": "git", + "enabled": true, + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": true + }, + "formatter": { + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "semicolons": "asNeeded" + } + }, + "css": { + "parser": { + "cssModules": false, + "tailwindDirectives": true + } + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "complexity": { + "noImportantStyles": "off" + }, + "style": { + "noDescendingSpecificity": "off" + } + } + }, + "overrides": [ + { + "includes": ["src/**", "!src/**/*.test.ts", "!src/**/*.test.tsx"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"], + "message": "Do not include the file extension in imports." + } + ] + } + } + } + } + } + } + ] +} diff --git a/dapp/frontend/index.html b/dapp/frontend/index.html index 8153499..1beda6a 100644 --- a/dapp/frontend/index.html +++ b/dapp/frontend/index.html @@ -3,36 +3,21 @@ - - - - - dAppBooster Canton Stampbook - - - - - - - - - - - - - - - + Canton Vesting + +
diff --git a/dapp/frontend/package.json b/dapp/frontend/package.json index 9ee7694..85fc4d1 100644 --- a/dapp/frontend/package.json +++ b/dapp/frontend/package.json @@ -8,35 +8,31 @@ }, "scripts": { "dev": "vite", - "build": "tsc -b --noEmit && vite build", + "build": "tsc -b && vite build", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", "lint": "biome check", "lint:fix": "biome check --write", "format": "biome format --write", - "test": "node --test --experimental-strip-types \"test/**/*.test.ts\" \"src/**/*.test.ts\" \"src/**/*.test.tsx\"", - "preview": "vite preview" + "typecheck": "tsc -b --noEmit" }, "dependencies": { - "@canton-network/dapp-sdk": "^1.1.0", "@fontsource-variable/jetbrains-mono": "^5.2.8", "@fontsource-variable/manrope": "^5.2.8", - "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-toast": "^1.2.15", - "@radix-ui/react-tooltip": "^1.2.8", - "@walletconnect/sign-client": "^2.23.9", - "@walletconnect/types": "^2.23.9", - "canton-connect-kit": "^0.1.0", - "clsx": "^2.1.1", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "tailwind-merge": "^3.6.0" + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router-dom": "^7.6.2", + "zustand": "^5.0.5" }, "devDependencies": { "@tailwindcss/vite": "^4.3.0", - "@types/react": "^18.3.28", - "@types/react-dom": "^18.3.7", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", "@vitejs/plugin-react": "^4.7.0", "tailwindcss": "^4.3.0", "typescript": "^5.9.3", - "vite": "^6.4.2" + "vite": "^6.4.2", + "vitest": "^3.2.2" } } diff --git a/dapp/frontend/public/apple-touch-icon.png b/dapp/frontend/public/apple-touch-icon.png deleted file mode 100644 index 4e520e0..0000000 Binary files a/dapp/frontend/public/apple-touch-icon.png and /dev/null differ diff --git a/dapp/frontend/public/carpincho-icon.svg b/dapp/frontend/public/carpincho-icon.svg deleted file mode 100644 index c75ab8c..0000000 --- a/dapp/frontend/public/carpincho-icon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/dapp/frontend/public/favicon.svg b/dapp/frontend/public/favicon.svg index cc3c5af..0582772 100644 --- a/dapp/frontend/public/favicon.svg +++ b/dapp/frontend/public/favicon.svg @@ -1,14 +1,10 @@ - + - - - + + + - - + + diff --git a/dapp/frontend/public/og.webp b/dapp/frontend/public/og.webp deleted file mode 100644 index 3bddcb5..0000000 Binary files a/dapp/frontend/public/og.webp and /dev/null differ diff --git a/dapp/frontend/public/walletconnect-logo.webp b/dapp/frontend/public/walletconnect-logo.webp deleted file mode 100644 index d1e4aa0..0000000 Binary files a/dapp/frontend/public/walletconnect-logo.webp and /dev/null differ diff --git a/dapp/frontend/src/App.tsx b/dapp/frontend/src/App.tsx index 26eee1b..da2d775 100644 --- a/dapp/frontend/src/App.tsx +++ b/dapp/frontend/src/App.tsx @@ -1,38 +1,16 @@ -import { ConnectKitProvider } from 'canton-connect-kit' -import { useState } from 'react' -import { ToastProvider } from '@/components/ui/ToastProvider' -import { TooltipProvider } from '@/components/ui/Tooltip' -import { ConnectionBar } from './ConnectionBar' -import { LoyaltyCard } from './features/loyalty/index' -import { SignMessageDemo } from './features/sign-message/index' -import { loadRuntimeConfig } from './runtimeConfig' +import { createBrowserRouter, RouterProvider } from 'react-router-dom' +import { Toaster } from '@/components/toast' +import { ThemeProvider } from '@/theme/ThemeProvider' +import { WalletProvider } from '@/wallet/WalletProvider' +import { routes } from './routes' -const envString = (name: string): string => - ((import.meta.env[name] as string | undefined) ?? '').trim() +const router = createBrowserRouter(routes) -// dApp starter shell. Everything under src/features// is a removable demo: -// to drop one, delete its folder + its import and <…/> line below, plus -// ../e2e/tests/features//. See README "Removing a feature". -export const App = (): JSX.Element => { - const [runtimeConfig] = useState(() => loadRuntimeConfig()) - // /sign-demo serves the standalone signMessage example; every other path is - // the Stampbook app. Keeps the off-topic demo out of the product UI while - // leaving it reachable (and e2e-testable) on its own route. - const isSignDemo = window.location.pathname === '/sign-demo' - return ( - - - - {isSignDemo ? : } - - - - ) -} +export const App = (): React.JSX.Element => ( + + + + + + +) diff --git a/dapp/frontend/src/ConnectionBar.tsx b/dapp/frontend/src/ConnectionBar.tsx deleted file mode 100644 index 1f4f757..0000000 --- a/dapp/frontend/src/ConnectionBar.tsx +++ /dev/null @@ -1,483 +0,0 @@ -import { useConnect, useParty, useWalletStatus } from 'canton-connect-kit' -import type { ReactNode } from 'react' -import { useEffect, useRef, useState } from 'react' -import { - CHEVRON_DOWN_ICON, - COPY_ICON, - DISCONNECT_ICON, - MOON_ICON, - SUN_ICON, -} from '@/components/ui/icons' -import { Sheet } from '@/components/ui/Sheet' -import { toast } from '@/components/ui/toast' -import { useTheme } from '@/theme/useTheme' -import { copyToClipboard } from './utils/clipboard' -import { errorMessage } from './utils/errorMessage' -import { formatPartyId, shortenIdentifier } from './utils/formatPartyId' - -const ICON_CHIP_CLASS = - 'inline-grid size-9 place-items-center rounded-full border border-border bg-surface ' + - 'text-muted-foreground transition-colors hover:text-primary hover:bg-primary-soft' - -// Remember an extension connection so a reload can silently reconnect. -const RECONNECT_KEY = 'bn-canton-stampbook:reconnect' -const readReconnect = (): string | null => { - try { - return window.localStorage.getItem(RECONNECT_KEY) - } catch { - return null - } -} -const writeReconnect = (value: string | null): void => { - try { - if (value === null) { - window.localStorage.removeItem(RECONNECT_KEY) - } else { - window.localStorage.setItem(RECONNECT_KEY, value) - } - } catch { - // ignore quota / privacy errors - } -} - -// App brand mark (stamp on the brand gradient); carpincho's logo marks the wallet. -const StarMark = ({ className }: { className: string }): JSX.Element => ( - - - -) - -// Wallet header (connect/account + theme), welcome hero, WC pairing, and lock -// gating; renders children only when connected + unlocked behind workspace-ready. -export const ConnectionBar = ({ children }: { children: ReactNode }): JSX.Element => { - const { connect, disconnect, isConnecting, isConnected, pairingUri } = useConnect() - const { party } = useParty() - const { isLocked } = useWalletStatus() - const { mode, setMode } = useTheme() - - const [pairingCopied, setPairingCopied] = useState(false) - const [accountOpen, setAccountOpen] = useState(false) - const [connectMenuOpen, setConnectMenuOpen] = useState(false) - // Seeded before first paint so the reconnect check shows a spinner, not the hero. - const [reconnecting, setReconnecting] = useState(() => readReconnect() === 'extension') - const [connectMode, setConnectMode] = useState<'extension' | 'walletconnect' | undefined>( - undefined, - ) - - const connectMenuRef = useRef(null) - const accountMenuRef = useRef(null) - // Set by a user-initiated connect; the success toast fires from the effect - // below once `party` lands (connect() resolves before the context updates). - const connectToastPending = useRef(false) - // Guards the mount-only silent reconnect against StrictMode's double-invoke. - const reconnectStarted = useRef(false) - - useEffect(() => { - if (party !== undefined && connectToastPending.current) { - connectToastPending.current = false - toast.success(`Connected as ${formatPartyId(party.partyId)}`) - } - }, [party]) - - // Close header menus on outside click / Escape (header backdrop-blur traps a - // fixed backdrop, so use a document listener). - useEffect(() => { - if (!connectMenuOpen && !accountOpen) { - return - } - const onPointerDown = (event: PointerEvent): void => { - const target = event.target as Node - if (connectMenuRef.current !== null && !connectMenuRef.current.contains(target)) { - setConnectMenuOpen(false) - } - if (accountMenuRef.current !== null && !accountMenuRef.current.contains(target)) { - setAccountOpen(false) - } - } - const onKeyDown = (event: KeyboardEvent): void => { - if (event.key === 'Escape') { - setConnectMenuOpen(false) - setAccountOpen(false) - } - } - document.addEventListener('pointerdown', onPointerDown) - document.addEventListener('keydown', onKeyDown) - return () => { - document.removeEventListener('pointerdown', onPointerDown) - document.removeEventListener('keydown', onKeyDown) - } - }, [connectMenuOpen, accountOpen]) - - // Toggle flips light/dark only; resolve `system` so the first click inverts. - const resolvedTheme: 'light' | 'dark' = - mode === 'system' - ? window.matchMedia('(prefers-color-scheme: dark)').matches - ? 'dark' - : 'light' - : mode - - const toggleTheme = (): void => { - setMode(resolvedTheme === 'dark' ? 'light' : 'dark') - } - - // Silently reconnect a prior extension session on reload (WC reconnects manually). - // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount - useEffect(() => { - if (isConnected || readReconnect() !== 'extension') { - setReconnecting(false) - return - } - if (reconnectStarted.current) { - return - } - reconnectStarted.current = true - void connect('extension') - // Wallet no longer authorized / not present — stay on the welcome screen. - .catch(() => writeReconnect(null)) - .finally(() => setReconnecting(false)) - }, []) - - const onConnect = async (connectVia: 'extension' | 'walletconnect'): Promise => { - setConnectMode(connectVia) - connectToastPending.current = true - try { - await connect(connectVia) - writeReconnect(connectVia) - } catch (err) { - connectToastPending.current = false - toast.error(errorMessage(err)) - } finally { - setConnectMode(undefined) - } - } - - const onDisconnect = async (): Promise => { - setAccountOpen(false) - setPairingCopied(false) - // Drop any armed connect toast so a later party change can't fire a stale - // "Connected as" after this disconnect. - connectToastPending.current = false - writeReconnect(null) - await disconnect() - toast.success('Disconnected.') - } - - const copyPartyId = async (): Promise => { - if (party === undefined) { - return - } - await copyToClipboard(party.partyId, 'Party id copied.') - } - - const copyPairingUri = async (): Promise => { - if (pairingUri === undefined) { - return - } - await copyToClipboard(pairingUri, () => { - setPairingCopied(true) - window.setTimeout(() => setPairingCopied(false), 1400) - }) - } - - const nextTheme = resolvedTheme === 'dark' ? 'light' : 'dark' - - const themeToggle = ( - - ) - - const connectControls = !isConnected ? ( -
- - {connectMenuOpen && ( -
- - -
- )} -
- ) : ( -
- - {accountOpen && ( -
- - Connected party - -
- - {formatPartyId(party?.partyId ?? '')} - - -
- -
- )} -
- ) - - return ( -
- - Skip to main content - -
-
-
- - - Stampbook - -
-
- {themeToggle} - {reconnecting && !isConnected ? ( - - - - ) : ( - connectControls - )} -
-
-
- - { - if (!open) { - void disconnect() - } - }} - side="center" - title="WalletConnect" - description="Pair a WalletConnect-compatible wallet." - > - {pairingUri === undefined ? ( -
- - Preparing WalletConnect… -
- ) : ( - <> -

- Paste this pairing link into your WalletConnect-compatible wallet. -

- - {shortenIdentifier(pairingUri)} - -
- -
- - )} -
- -
- {reconnecting && !isConnected ? ( -
- -

Checking your wallet…

-
- ) : !isConnected ? ( -
- -

- Loyalty stamp cards, -
- on-ledger -

-

- Stampbook demo: a merchant issues a stamp card, delegates stamping to staff, and - cardholders watch their stamps add up toward a reward. Every stamp is a real Canton - transaction. -

-

- Connect your wallet to begin -

- -

(browser extension)

- -
- - or - -
- - -

(carpincho web app)

-
- ) : isLocked ? ( -
- - Wallet locked - -

- Unlock Carpincho to continue -

-

- Your wallet is locked. Open Carpincho and enter your password — this dApp will resume - automatically. -

-
- ) : party === undefined ? ( -
-

- Select an account in Carpincho to continue. -

-
- ) : ( -
{children}
- )} -
-
- ) -} diff --git a/dapp/frontend/src/app/AppShell.tsx b/dapp/frontend/src/app/AppShell.tsx new file mode 100644 index 0000000..1314a66 --- /dev/null +++ b/dapp/frontend/src/app/AppShell.tsx @@ -0,0 +1,43 @@ +import { Outlet, useLocation } from 'react-router-dom' +import { useUiStore } from '@/store/useUiStore' +import { useParty } from '@/wallet/hooks' +import { ConnectScreen } from './ConnectScreen' +import { Sidebar } from './Sidebar' +import { TopBar } from './TopBar' + +const titleFor = (pathname: string, role: string): { title: string; crumb: string } => { + if (pathname.startsWith('/proposals')) { + return { title: 'Proposals', crumb: role } + } + if (pathname.startsWith('/create')) { + return { title: 'Create grant', crumb: 'Funder' } + } + if (pathname.startsWith('/grants/')) { + return { title: 'Grant detail', crumb: role } + } + return { title: role === 'funder' ? 'Granted by me' : 'Dashboard', crumb: role } +} + +export const AppShell = (): React.JSX.Element => { + const { isConnected } = useParty() + const role = useUiStore((s) => s.role) + const location = useLocation() + + if (!isConnected) { + return + } + + const { title, crumb } = titleFor(location.pathname, role) + + return ( +
+ +
+ +
+ +
+
+
+ ) +} diff --git a/dapp/frontend/src/app/ConnectScreen.tsx b/dapp/frontend/src/app/ConnectScreen.tsx new file mode 100644 index 0000000..0ff36b9 --- /dev/null +++ b/dapp/frontend/src/app/ConnectScreen.tsx @@ -0,0 +1,69 @@ +import { shortenParty } from '@/lib/format' +import { useConnect, useParties } from '@/wallet/hooks' +import { ThemeToggle } from './ThemeToggle' + +// Landing party picker. Picking a party enters the app. The party is remembered +// in localStorage so a reload lands back in the same session. + +export const ConnectScreen = (): React.JSX.Element => { + const { connect } = useConnect() + const { pool, operator } = useParties() + + return ( +
+
+
+ + Canton Vesting +
+ +
+ +
+ +

+ Vesting for Canton Coin +

+

+ Track grants vesting to you, claim what has unlocked, and create grants for others. Every + claim is a real Canton transaction; the factory is delivered via explicit disclosure. +

+ +

Choose a party to act as

+ +
+ {pool.length === 0 ? ( +
+ No parties available. Run the vest-lite bootstrap to populate the pool. +
+ ) : ( +
    + {pool.map((party) => ( +
  • + +
  • + ))} +
+ )} + {operator !== '' && ( +

+ factory owner · {shortenParty(operator)} +

+ )} +
+
+
+ ) +} diff --git a/dapp/frontend/src/app/RoleToggle.tsx b/dapp/frontend/src/app/RoleToggle.tsx new file mode 100644 index 0000000..d776c37 --- /dev/null +++ b/dapp/frontend/src/app/RoleToggle.tsx @@ -0,0 +1,34 @@ +import { cn } from '@/lib/cn' +import type { Role } from '@/store/types' +import { useUiStore } from '@/store/useUiStore' + +const roles: { value: Role; label: string }[] = [ + { value: 'receiver', label: 'Receiver' }, + { value: 'funder', label: 'Funder' }, +] + +// The connected party is fixed; this lens chooses whether to view grants where +// the party is receiver or creator. +export const RoleToggle = (): React.JSX.Element => { + const role = useUiStore((s) => s.role) + const setRole = useUiStore((s) => s.setRole) + return ( +
+ {roles.map((r) => ( + + ))} +
+ ) +} diff --git a/dapp/frontend/src/app/Sidebar.tsx b/dapp/frontend/src/app/Sidebar.tsx new file mode 100644 index 0000000..2977d8d --- /dev/null +++ b/dapp/frontend/src/app/Sidebar.tsx @@ -0,0 +1,69 @@ +import type { ComponentType, SVGProps } from 'react' +import { NavLink } from 'react-router-dom' +import { DashboardIcon, InboxIcon, PlusCircleIcon } from '@/components/icons' +import { cn } from '@/lib/cn' +import { useVestingStore } from '@/store/useVestingStore' +import { useParty } from '@/wallet/hooks' + +interface NavItem { + to: string + label: string + Icon: ComponentType> +} + +const items: NavItem[] = [ + { to: '/dashboard', label: 'Dashboard', Icon: DashboardIcon }, + { to: '/proposals', label: 'Proposals', Icon: InboxIcon }, + { to: '/create', label: 'Create grant', Icon: PlusCircleIcon }, +] + +export const Sidebar = (): React.JSX.Element => { + const { party } = useParty() + const proposals = useVestingStore((s) => s.proposals) + const incoming = + party === undefined ? 0 : proposals.filter((p) => p.receiver === party.partyId).length + + return ( + + ) +} diff --git a/dapp/frontend/src/app/ThemeToggle.tsx b/dapp/frontend/src/app/ThemeToggle.tsx new file mode 100644 index 0000000..243c811 --- /dev/null +++ b/dapp/frontend/src/app/ThemeToggle.tsx @@ -0,0 +1,22 @@ +import { MoonIcon, SunIcon } from '@/components/icons' +import { useTheme } from '@/theme/ThemeProvider' + +export const ThemeToggle = (): React.JSX.Element => { + const { resolved, toggle } = useTheme() + const next = resolved === 'dark' ? 'light' : 'dark' + return ( + + ) +} diff --git a/dapp/frontend/src/app/TopBar.tsx b/dapp/frontend/src/app/TopBar.tsx new file mode 100644 index 0000000..6ac0e4f --- /dev/null +++ b/dapp/frontend/src/app/TopBar.tsx @@ -0,0 +1,28 @@ +import { RoleToggle } from './RoleToggle' +import { ThemeToggle } from './ThemeToggle' +import { WalletControl } from './WalletControl' + +interface TopBarProps { + title: string + crumb?: string +} + +export const TopBar = ({ title, crumb }: TopBarProps): React.JSX.Element => ( +
+
+
+ {crumb !== undefined && ( +
+ {crumb} +
+ )} +

{title}

+
+
+ + + +
+
+
+) diff --git a/dapp/frontend/src/app/WalletControl.tsx b/dapp/frontend/src/app/WalletControl.tsx new file mode 100644 index 0000000..7f0d82f --- /dev/null +++ b/dapp/frontend/src/app/WalletControl.tsx @@ -0,0 +1,144 @@ +import { useEffect, useRef, useState } from 'react' +import { ChevronDownIcon, CopyIcon, LogoutIcon } from '@/components/icons' +import { toast } from '@/components/toast' +import { partyHint, shortenParty } from '@/lib/format' +import { useConnect, useParties, useParty } from '@/wallet/hooks' + +// Party switcher. Pill shows the acting party hint + chevron. The menu lets you +// copy the acting id, switch to another party in the pool, see the operator as the +// (non-selectable) factory owner, and sign out back to the picker. +export const WalletControl = (): React.JSX.Element | null => { + const { connect, disconnect } = useConnect() + const { party } = useParty() + const { pool, operator } = useParties() + const [open, setOpen] = useState(false) + const ref = useRef(null) + + useEffect(() => { + if (!open) { + return + } + const onDown = (e: PointerEvent): void => { + if (ref.current !== null && !ref.current.contains(e.target as Node)) { + setOpen(false) + } + } + document.addEventListener('pointerdown', onDown) + return () => document.removeEventListener('pointerdown', onDown) + }, [open]) + + if (party === undefined) { + return null + } + + const copyId = async (id: string): Promise => { + try { + await navigator.clipboard.writeText(id) + toast.success('Party id copied') + } catch { + toast.error('Could not copy') + } + } + + const others = pool.filter((candidate) => candidate.partyId !== party.partyId) + + return ( +
+ + {open && ( +
+ + Acting as + +
+ + {shortenParty(party.partyId)} + + +
+ + {others.length > 0 && ( +
+ + Switch party + +
    + {others.map((candidate) => ( +
  • + + +
  • + ))} +
+
+ )} + + {operator !== '' && ( +
+ + factory owner + +
+ {shortenParty(operator)} +
+
+ )} + + +
+ )} +
+ ) +} diff --git a/dapp/frontend/src/backend/LiteBackend.ts b/dapp/frontend/src/backend/LiteBackend.ts new file mode 100644 index 0000000..ae9701c --- /dev/null +++ b/dapp/frontend/src/backend/LiteBackend.ts @@ -0,0 +1,168 @@ +// LiteBackend: the VestingBackend over the vesting-lite DAML on the fast local +// stack, via the wallet-service ledgerApi proxy. Implements the VestingBackend +// interface against vesting-lite templates (residual claims, getTime, +// value-preserving cancel). + +import type { DisclosedContract, LedgerCommand, Wallet } from '@/wallet/Wallet' +import { + buildAcceptCommand, + buildCancelCommand, + buildClaimCommand, + buildClaimResidualCommand, + buildCreateVestingCommand, + buildDisclosedContract, + extractCreatedEventBlob, +} from './commands' +import { walletServiceRequest } from './ledgerApi' +import { + type CreateVestInput, + composeNote, + type Deployment, + rowToClaim, + rowToGrant, + rowToProposal, + type VestingBackend, + type VestingView, +} from './VestingBackend' + +export class LiteBackend implements VestingBackend { + readonly mode = 'lite' as const + private readonly rpcUrl: string + private readonly wallet: Wallet + private readonly operator: string + private readonly factoryTid: string + private readonly proposalTid: string + private readonly contractTid: string + private readonly claimTid: string + + constructor(rpcUrl: string, deployment: Deployment, wallet: Wallet) { + this.rpcUrl = rpcUrl + this.wallet = wallet + this.operator = deployment.operator + this.factoryTid = `${deployment.pkg}:Vesting:VestingFactory` + this.proposalTid = `${deployment.pkg}:Vesting:VestingProposal` + this.contractTid = `${deployment.pkg}:Vesting:VestingContract` + this.claimTid = `${deployment.pkg}:Vesting:VestedClaim` + } + + // Reachable if the ledger end responds. Pinging is enough; the bootstrap + // guarantees the operator factory exists. + async isAvailable(): Promise { + try { + await this.ledgerEnd() + return true + } catch { + return false + } + } + + private async ledgerEnd(): Promise { + const result = await walletServiceRequest<{ offset?: string | number }>( + this.rpcUrl, + 'ledgerApi', + { requestMethod: 'get', resource: '/v2/state/ledger-end' }, + ) + if (result.offset === undefined) { + throw new Error('Ledger API did not return an offset') + } + return result.offset + } + + private async readAcs(party: string, templateId: string): Promise { + const offset = await this.ledgerEnd() + const rows = await walletServiceRequest(this.rpcUrl, 'ledgerApi', { + requestMethod: 'post', + resource: '/v2/state/active-contracts', + body: { + filter: { + filtersByParty: { + [party]: { + cumulative: [ + { + identifierFilter: { + TemplateFilter: { + value: { templateId, includeCreatedEventBlob: true }, + }, + }, + }, + ], + }, + }, + }, + activeAtOffset: offset, + verbose: true, + }, + }) + return Array.isArray(rows) ? rows : [] + } + + private submit( + actAs: string, + command: LedgerCommand, + disclosed?: DisclosedContract[], + ): Promise { + return this.wallet.execute(actAs, command, disclosed) + } + + async viewAs(partyId: string): Promise { + const [proposalRows, contractRows, claimRows] = await Promise.all([ + this.readAcs(partyId, this.proposalTid), + this.readAcs(partyId, this.contractTid), + this.readAcs(partyId, this.claimTid), + ]) + const proposals = proposalRows + .map((row) => rowToProposal(row as Parameters[0])) + .filter((proposal): proposal is NonNullable => proposal !== undefined) + const grants = contractRows + .map((row) => rowToGrant(row as Parameters[0])) + .filter((grant): grant is NonNullable => grant !== undefined) + const claims = claimRows + .map((row) => rowToClaim(row as Parameters[0])) + .filter((claim): claim is NonNullable => claim !== undefined) + return { proposals, grants, claims } + } + + // The proposer is not a stakeholder of the operator's factory, so it is delivered + // via explicit disclosure. Returns the disclosed blob size so the UI can surface + // the mechanic. + async createVesting(args: CreateVestInput): Promise<{ disclosedBytes: number }> { + const factoryRows = await this.readAcs(this.operator, this.factoryTid) + const ref = factoryRows + .map((row) => extractCreatedEventBlob(row as Parameters[0])) + .find((candidate) => candidate !== undefined) + if (ref === undefined) { + throw new Error('operator factory not found — run the vest-lite bootstrap') + } + const command = buildCreateVestingCommand(this.factoryTid, ref.contractId, { + proposer: args.proposer, + beneficiary: args.receiver, + total: args.totalAmount, + schedule: args.schedule, + note: composeNote(args.title, args.note), + }) + await this.submit(args.proposer, command, [buildDisclosedContract(this.factoryTid, ref)]) + return { disclosedBytes: ref.createdEventBlob.length } + } + + async accept(args: { receiver: string; proposalCid: string }): Promise { + await this.submit(args.receiver, buildAcceptCommand(this.proposalTid, args.proposalCid)) + } + + async withdraw(args: { receiver: string; contractCid: string; amount: number }): Promise { + await this.submit( + args.receiver, + buildClaimCommand(this.contractTid, args.contractCid, args.amount), + ) + } + + async cancel(args: { creator: string; contractCid: string }): Promise { + await this.submit(args.creator, buildCancelCommand(this.contractTid, args.contractCid)) + } + + async claimResidual(args: { receiver: string; claimCid: string; amount: number }): Promise { + await this.submit( + args.receiver, + buildClaimResidualCommand(this.claimTid, args.claimCid, args.amount), + ) + } +} diff --git a/dapp/frontend/src/backend/VestingBackend.test.ts b/dapp/frontend/src/backend/VestingBackend.test.ts new file mode 100644 index 0000000..5f4c28e --- /dev/null +++ b/dapp/frontend/src/backend/VestingBackend.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest' +import { encodeSchedule } from './commands' +import { composeNote, rowToClaim, rowToGrant, rowToProposal, splitNote } from './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 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('rowToProposal', () => { + it('maps proposer→proposer, beneficiary→receiver, decodes the schedule, splits the note', () => { + const proposal = rowToProposal( + row('p1', { + provider: 'OP', + proposer: 'funder', + beneficiary: 'receiver', + total: '1000.0000000000', + schedule: linearEncoded, + note: 'Advisor grant\n24-month linear', + }), + ) + expect(proposal).toEqual({ + id: 'p1', + title: 'Advisor grant', + provider: 'OP', + proposer: 'funder', + receiver: 'receiver', + totalAmount: 1000, + 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(rowToProposal({})).toBeUndefined() + }) +}) + +describe('rowToGrant', () => { + it('maps a contract row, parsing claimed and using proposer as creator', () => { + const grant = rowToGrant( + row('c1', { + provider: 'OP', + proposer: 'funder', + beneficiary: 'receiver', + total: '1000', + claimed: '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() + }) +}) + +describe('rowToClaim', () => { + it('maps a residual claim row with amount + withdrawn', () => { + const claim = rowToClaim( + row('r1', { + provider: 'OP', + proposer: 'funder', + beneficiary: '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', + }) + }) +}) diff --git a/dapp/frontend/src/backend/VestingBackend.ts b/dapp/frontend/src/backend/VestingBackend.ts new file mode 100644 index 0000000..32af9ee --- /dev/null +++ b/dapp/frontend/src/backend/VestingBackend.ts @@ -0,0 +1,149 @@ +// The backend seam. The UI depends only on this interface + the domain types +// (@/store/types) — never on DAML/transport details. LiteBackend implements it +// against the vesting-lite DAML via the wallet-service ledgerApi proxy. The pure +// mappers here turn JSON-Ledger-API active-contract rows into Grant/Proposal/VestedClaim. + +import type { VestingSchedule } from '@/lib/schedule' +import type { Grant, PartyId, Proposal, VestedClaim } from '@/store/types' +import { decodeSchedule } from './commands' + +export type PartyRef = { name: string; partyId: string } +export type Deployment = { pkg: string; operator: string } +export type Mode = 'lite' + +export interface VestingView { + grants: Grant[] + proposals: Proposal[] + claims: VestedClaim[] +} + +export interface CreateVestInput { + proposer: string + receiver: string + totalAmount: number + schedule: VestingSchedule + title: string + note?: string +} + +export interface VestingBackend { + readonly mode: Mode + isAvailable(): Promise + viewAs(partyId: string): Promise + createVesting(args: CreateVestInput): Promise<{ disclosedBytes: number }> + accept(args: { receiver: string; proposalCid: string }): Promise + withdraw(args: { receiver: string; contractCid: string; amount: number }): Promise + cancel(args: { creator: string; contractCid: string }): Promise + claimResidual(args: { receiver: string; claimCid: string; amount: number }): Promise +} + +// ── Domain-mapping convention ────────────────────────────────────────────────── +// On-ledger `note` carries `"${title}\n${note}"`; we split on the FIRST newline → +// title (fallback `Vesting ${shortCid}`) + note. `id` = contractId. The DAML +// `proposer` is the UI `creator`/`proposer` (funder); DAML `beneficiary` is the UI +// `receiver`. Decimals arrive as strings; the schedule curve as a JSON-LF variant +// (decodeSchedule). Each mapper tolerates a missing createArgument (returns +// undefined) so a stray row never crashes a view. + +type AcsRow = { + contractEntry?: { + JsActiveContract?: { + createdEvent?: { contractId?: string; createArgument?: Record } + } + } +} + +type CreatedArg = { contractId: string; arg: Record } + +const num = (value: unknown): number => Number(value ?? 0) + +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 } +} + +const created = (row: AcsRow): CreatedArg | undefined => { + const event = row.contractEntry?.JsActiveContract?.createdEvent + const arg = event?.createArgument + if (event?.contractId === undefined || arg === undefined) { + return undefined + } + return { contractId: event.contractId, arg } +} + +export const rowToProposal = (row: AcsRow): Proposal | undefined => { + const entry = created(row) + if (entry === undefined) { + return undefined + } + const { contractId, arg } = entry + const { title, note } = splitNote(arg.note, contractId) + return { + id: contractId, + title, + provider: String(arg.provider ?? '') as PartyId, + proposer: String(arg.proposer ?? '') as PartyId, + receiver: String(arg.beneficiary ?? '') as PartyId, + totalAmount: num(arg.total), + schedule: decodeSchedule(arg.schedule), + note, + } +} + +export const rowToGrant = (row: AcsRow): Grant | undefined => { + const entry = created(row) + if (entry === undefined) { + return undefined + } + const { contractId, arg } = entry + const { title, note } = splitNote(arg.note, contractId) + return { + id: contractId, + title, + provider: String(arg.provider ?? '') as PartyId, + creator: String(arg.proposer ?? '') as PartyId, + receiver: String(arg.beneficiary ?? '') as PartyId, + totalAmount: num(arg.total), + schedule: decodeSchedule(arg.schedule), + alreadyWithdrawn: num(arg.claimed), + note, + } +} + +export const rowToClaim = (row: AcsRow): VestedClaim | undefined => { + const entry = created(row) + if (entry === undefined) { + return undefined + } + const { contractId, arg } = entry + const { title, note } = splitNote(arg.note, contractId) + return { + id: contractId, + title, + provider: String(arg.provider ?? '') as PartyId, + creator: String(arg.proposer ?? '') as PartyId, + receiver: String(arg.beneficiary ?? '') as PartyId, + amount: num(arg.amount), + withdrawn: num(arg.withdrawn), + note, + } +} diff --git a/dapp/frontend/src/backend/commands.test.ts b/dapp/frontend/src/backend/commands.test.ts new file mode 100644 index 0000000..0b143c0 --- /dev/null +++ b/dapp/frontend/src/backend/commands.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from 'vitest' +import type { VestingSchedule } from '@/lib/schedule' +import { + buildAcceptCommand, + buildCancelCommand, + buildClaimCommand, + buildClaimResidualCommand, + buildCreateVestingCommand, + buildDisclosedContract, + decodeSchedule, + encodeSchedule, + extractCreatedEventBlob, +} from './commands' + +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('extractCreatedEventBlob', () => { + it('pulls cid + blob + synchronizerId from an ACS row', () => { + const row = { + contractEntry: { + JsActiveContract: { + createdEvent: { contractId: 'cid1', createdEventBlob: 'BLOB' }, + synchronizerId: 'sync1', + }, + }, + } + expect(extractCreatedEventBlob(row)).toEqual({ + contractId: 'cid1', + createdEventBlob: 'BLOB', + synchronizerId: 'sync1', + }) + }) + + it('returns undefined when the blob is missing', () => { + const row = { contractEntry: { JsActiveContract: { createdEvent: { contractId: 'c' } } } } + expect(extractCreatedEventBlob(row)).toBeUndefined() + }) +}) + +describe('buildDisclosedContract', () => { + it('shapes a disclosedContracts entry', () => { + expect( + buildDisclosedContract('TID', { + contractId: 'c', + createdEventBlob: 'b', + synchronizerId: 's', + }), + ).toEqual({ templateId: 'TID', contractId: 'c', createdEventBlob: 'b', synchronizerId: 's' }) + }) + + it('omits synchronizerId when absent', () => { + expect(buildDisclosedContract('TID', { contractId: 'c', createdEventBlob: 'b' })).toEqual({ + templateId: 'TID', + contractId: 'c', + createdEventBlob: 'b', + }) + }) +}) + +describe('command builders', () => { + it('buildCreateVestingCommand shapes Factory_CreateVesting args with the encoded schedule', () => { + const cmd = buildCreateVestingCommand('TID', 'fcid', { + proposer: 'P', + beneficiary: 'B', + total: 1000, + schedule: linear, + note: 'Title\nbody', + }) + expect(cmd).toEqual({ + ExerciseCommand: { + templateId: 'TID', + contractId: 'fcid', + choice: 'Factory_CreateVesting', + choiceArgument: { + proposer: 'P', + beneficiary: 'B', + total: '1000', + schedule: encodeSchedule(linear), + note: 'Title\nbody', + }, + }, + }) + }) + + it('buildCreateVestingCommand sends null note when omitted', () => { + const cmd = buildCreateVestingCommand('TID', 'fcid', { + proposer: 'P', + beneficiary: 'B', + total: 1, + schedule: linear, + }) + expect((cmd.ExerciseCommand.choiceArgument as { note: unknown }).note).toBeNull() + }) + + it('buildClaimCommand stringifies amount and carries no nowMicros (getTime)', () => { + expect(buildClaimCommand('TID', 'cid', 100)).toEqual({ + ExerciseCommand: { + templateId: 'TID', + contractId: 'cid', + choice: 'Contract_Claim', + choiceArgument: { amount: '100' }, + }, + }) + }) + + it('buildAcceptCommand targets Proposal_Accept', () => { + expect(buildAcceptCommand('TID', 'pcid')).toEqual({ + ExerciseCommand: { + templateId: 'TID', + contractId: 'pcid', + choice: 'Proposal_Accept', + choiceArgument: {}, + }, + }) + }) + + it('buildCancelCommand targets Contract_Cancel with no args', () => { + expect(buildCancelCommand('TID', 'ccid')).toEqual({ + ExerciseCommand: { + templateId: 'TID', + contractId: 'ccid', + choice: 'Contract_Cancel', + choiceArgument: {}, + }, + }) + }) + + it('buildClaimResidualCommand targets Claim_Withdraw and stringifies withdrawAmount', () => { + expect(buildClaimResidualCommand('TID', 'rcid', 50)).toEqual({ + ExerciseCommand: { + templateId: 'TID', + contractId: 'rcid', + choice: 'Claim_Withdraw', + choiceArgument: { withdrawAmount: '50' }, + }, + }) + }) +}) diff --git a/dapp/frontend/src/backend/commands.ts b/dapp/frontend/src/backend/commands.ts new file mode 100644 index 0000000..0f44c3b --- /dev/null +++ b/dapp/frontend/src/backend/commands.ts @@ -0,0 +1,180 @@ +// JSON-Ledger-API v2 command builders + explicit-disclosure shaping + the single +// curve encode/decode pair. No I/O — unit-tested in commands.test.ts. Salvaged from +// dapp/frontend's vesting.ts and adapted to the upgraded vest-lite domain: +// - claim drops nowMicros (the contract reads on-ledger getTime), +// - the schedule carries a VestingCurve variant + ISO cliff, +// - adds cancel (Contract_Cancel) and residual withdraw (Claim_Withdraw). + +import type { VestingSchedule } from '@/lib/schedule' + +export type DisclosedRef = { + contractId: string + createdEventBlob: string + synchronizerId?: string +} + +type AcsRow = { + contractEntry?: { + JsActiveContract?: { + createdEvent?: { contractId?: string; createdEventBlob?: string } + synchronizerId?: string + } + } +} + +// Pull the disclosure payload out of a JSON-Ledger-API v2 active-contracts row +// (requires the read to set includeCreatedEventBlob: true). +export const extractCreatedEventBlob = (row: AcsRow): DisclosedRef | undefined => { + const active = row.contractEntry?.JsActiveContract + const event = active?.createdEvent + if (event?.contractId === undefined || event.createdEventBlob === undefined) { + return undefined + } + return { + contractId: event.contractId, + createdEventBlob: event.createdEventBlob, + synchronizerId: active?.synchronizerId, + } +} + +export const buildDisclosedContract = (templateId: string, ref: DisclosedRef) => ({ + templateId, + contractId: ref.contractId, + createdEventBlob: ref.createdEventBlob, + ...(ref.synchronizerId === undefined ? {} : { synchronizerId: ref.synchronizerId }), +}) + +// ── Curve variant encoding ──────────────────────────────────────────────────── +// THE ONE GENUINE UNKNOWN. The Daml JSON Ledger API v2 encodes a DAML variant as +// `{ "tag": "", "value": }`, a tuple `(Time, Decimal)` +// as a record `{ "_1":