diff --git a/.gitignore b/.gitignore index f960173..370136f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ node_modules/ dist/ dist-extension/ +# DAML build cache (per-package + multi-package root) +**/.daml/ + # env .env .env.local diff --git a/AGENTS.md b/AGENTS.md index 0442402..ae6218e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,7 +82,7 @@ Subproject docs must not restate root rules. They should describe only their loc - `npm run build-dar -- ` / `npm run deploy-dar -- ` - `npm run carpincho:build:extension` - `npm run app:dev` -- For local-stack convenience, [`scripts/dev-stack.sh`](scripts/dev-stack.sh) wraps the shortcuts above behind an interactive menu (run with no args) or direct subcommands (`install`, `docker-up`, `up`, `down`, `docker-down`, `mock-up`, `mock-down`, `extension`, `status`). The `npm` scripts remain canonical; the helper just orchestrates them. See [`README.md`](README.md). +- For local-stack convenience, [`scripts/dev-stack.sh`](scripts/dev-stack.sh) wraps the shortcuts above behind an interactive menu (run with no args) or direct subcommands (`install`, `docker-up`, `up`, `down`, `amulet-up`, `amulet-down`, `docker-down`, `mock-up`, `mock-down`, `extension`, `status`). The `amulet-*` pair drives the Splice LocalNet stack (`:3020` proxy + dApp); LocalNet itself stays external (`canton builder`). The `npm` scripts remain canonical; the helper just orchestrates them. See [`README.md`](README.md). - Local ports are intentionally assigned in the `3010+` range (see table above). Do not change them without updating every subproject's defaults. - Treat the single root `package-lock.json` as authoritative. Do not regenerate it as part of unrelated changes, and do not reintroduce per-package lockfiles. - The root `package.json` pins `@canton-network/dapp-sdk` to `1.1.0` via `overrides`: consumers declare `^1.1.0`, but `1.2.0` is intentionally held back. npm 11 does not persist `overrides` into `package-lock.json`, so the pin is enforced by the override on every relock and by the resolved `1.1.0` entry in the lock on every plain install. Do not bump it without testing the dApp flow against the newer SDK. diff --git a/README.md b/README.md index 67d1a86..724c726 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,8 @@ Or call an action directly: | Docker down | `docker-down` | Quit Docker Desktop (macOS only). | | Stack up | `up` | Bring up containers, build + deploy the DAR, start the wallet (3011) and dApp (3012) dev servers, build the extension. | | Stack down | `down` | Stop the dev servers and tear down the containers. | +| Amulet up | `amulet-up` | Splice LocalNet path: bootstrap parties/factory/funding, start the `:3020` wallet-service proxy, serve the dApp (3012). Assumes LocalNet is already booted. | +| Amulet down | `amulet-down` | Stop the amulet proxy + dApp dev server (LocalNet is left running). | | Wallet up | `mock-up` | Start the mocked wallet-service (3010) + Carpincho web app (3011) with no Docker. | | Wallet down | `mock-down` | Stop the mocked wallet-service + Carpincho web app only. | | Build extension | `extension` | Build the Chrome extension and copy it to `~/Desktop/dist-extension`. | @@ -87,6 +89,7 @@ Or call an action directly: Notes: - Docker lifecycle is managed separately from the stack: `up` and `down` assume Docker is already running and never start or quit it. Start/quit Docker with `docker-up` / `docker-down`, the Docker app, or your own CLI. +- The `amulet-*` actions target the Splice LocalNet stack (ports 3020/3975/4000), not bare Canton. LocalNet is run by the external `canton builder` tool — the script preflights it but never boots or stops it. Boot it first with `canton builder start --validators app-provider` then `canton builder deploy canton-barebones/dars/amulet-vesting-0.0.1.dar`. - `up` requires Docker running and `dpm` on `PATH` (for the DAR build). It fails fast with a clear message if the daemon is not reachable. - Background dev-server PIDs and logs live under `${TMPDIR:-/tmp}/cn-dev-stack/`. - The two Docker actions are macOS only; on other platforms they warn and no-op, while every other action runs unchanged. diff --git a/canton-barebones/dars/amulet-vesting-0.0.1.dar b/canton-barebones/dars/amulet-vesting-0.0.1.dar new file mode 100644 index 0000000..4eff96a Binary files /dev/null and b/canton-barebones/dars/amulet-vesting-0.0.1.dar differ diff --git a/canton-barebones/wallet-service/src/config.ts b/canton-barebones/wallet-service/src/config.ts index ab24d91..34c4f22 100644 --- a/canton-barebones/wallet-service/src/config.ts +++ b/canton-barebones/wallet-service/src/config.ts @@ -19,6 +19,8 @@ export interface WalletServiceConfig { jsonApiUrl: string ledgerApiUrl: string adminApiUrl: string + scanUrl: string + scanHost: string backendUserId: string backendToken?: string tokenSource: TokenSource @@ -86,6 +88,8 @@ export const loadConfig = (): WalletServiceConfig => { jsonApiUrl: optional('CANTON_JSON_API_URL') ?? 'http://localhost:3013', ledgerApiUrl: optional('CANTON_LEDGER_API_URL') ?? 'grpc://localhost:3014', adminApiUrl: optional('CANTON_ADMIN_API_URL') ?? 'grpc://localhost:3015', + scanUrl: optional('CANTON_SCAN_URL') ?? 'http://localhost:4000', + scanHost: optional('CANTON_SCAN_HOST') ?? 'scan.localhost', backendUserId, backendToken: resolved.token, tokenSource: resolved.source, diff --git a/canton-barebones/wallet-service/src/rpc.ts b/canton-barebones/wallet-service/src/rpc.ts index be6e788..8000bdc 100644 --- a/canton-barebones/wallet-service/src/rpc.ts +++ b/canton-barebones/wallet-service/src/rpc.ts @@ -1,3 +1,5 @@ +import * as http from 'node:http' +import * as https from 'node:https' import { SDK } from '@canton-network/wallet-sdk' import type { WalletServiceConfig } from './config.ts' import type { @@ -11,6 +13,7 @@ import type { LedgerApiRequest, Network, Provider, + ScanApiRequest, StatusEvent, Wallet, } from './types.ts' @@ -347,6 +350,67 @@ export const createRpc = (config: WalletServiceConfig): Rpc => { return parsed } + // Transparent proxy to the Splice scan service. Returns raw parsed JSON from + // the scan service with no Authorization header — the scan service is public. + // Uses node:http directly so the Host header is actually sent; undici/fetch + // silently drops Host (it's a forbidden header), which breaks nginx vhost routing. + const scanApi = async (params: unknown): Promise => { + const p = objectParam(params, 'scanApi') + if (typeof p.resource !== 'string' || p.resource.length === 0) { + throw new InvalidParams('resource is required') + } + const method = (p.requestMethod ?? 'post').toUpperCase() + const payload = + method !== 'GET' && method !== 'HEAD' && p.body !== undefined + ? JSON.stringify(p.body) + : undefined + const target = new URL(config.canton.scanUrl.replace(/\/$/, '') + p.resource) + // node:http(s) directly (not fetch) so the Host header survives for nginx vhost + // routing. Pick the transport + default port from the URL scheme so an https + // scanUrl is not silently sent as cleartext to port 80. + const isHttps = target.protocol === 'https:' + const transport = isHttps ? https : http + return new Promise((resolve, reject) => { + const req = transport.request( + { + hostname: target.hostname, + port: target.port || (isHttps ? 443 : 80), + path: target.pathname + target.search, + method, + headers: { + 'content-type': 'application/json', + host: config.canton.scanHost, + ...(payload !== undefined ? { 'content-length': Buffer.byteLength(payload) } : {}), + }, + }, + (res) => { + const chunks: Buffer[] = [] + res.on('data', (chunk: Buffer) => chunks.push(chunk)) + res.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8') + const contentType = res.headers['content-type'] ?? '' + const isJson = text.length > 0 && contentType.includes('json') + const parsed: unknown = isJson ? safeJsonParse(text) : text + const status = res.statusCode ?? 0 + if (status < 200 || status >= 300) { + const detail = typeof parsed === 'string' ? parsed : JSON.stringify(parsed) + reject(new Error(`Scan API ${method} ${p.resource} → HTTP ${status}: ${detail}`)) + } else { + resolve(parsed) + } + }) + res.on('error', reject) + }, + ) + req.setTimeout(15_000, () => req.destroy(new Error('Scan API timed out'))) + req.on('error', reject) + if (payload !== undefined) { + req.write(payload) + } + req.end() + }) + } + // 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 => { @@ -417,6 +481,8 @@ export const createRpc = (config: WalletServiceConfig): Rpc => { return rpcResult(id, await executePrepared(request.params)) case 'ledgerApi': return rpcResult(id, await ledgerApi(request.params)) + case 'scanApi': + return rpcResult(id, await scanApi(request.params)) case 'prepareExecute': case 'prepareExecuteAndWait': case 'signMessage': @@ -458,6 +524,7 @@ export const createRpc = (config: WalletServiceConfig): Rpc => { 'listAccounts', 'getPrimaryAccount', 'ledgerApi', + 'scanApi', 'prepareTransaction', 'executePrepared', ], @@ -469,6 +536,8 @@ export const createRpc = (config: WalletServiceConfig): Rpc => { jsonApiUrl: config.canton.jsonApiUrl, ledgerApiUrl: config.canton.ledgerApiUrl, adminApiUrl: config.canton.adminApiUrl, + scanUrl: config.canton.scanUrl, + scanHost: config.canton.scanHost, backendUserId: config.canton.backendUserId, hasBackendToken: config.canton.backendToken !== undefined, }, diff --git a/canton-barebones/wallet-service/src/types.ts b/canton-barebones/wallet-service/src/types.ts index 81110c2..eda2cbf 100644 --- a/canton-barebones/wallet-service/src/types.ts +++ b/canton-barebones/wallet-service/src/types.ts @@ -97,6 +97,12 @@ export type LedgerApiRequest = { export type LedgerApiResult = Record +export type ScanApiRequest = { + resource: string + requestMethod?: 'get' | 'post' + body?: unknown +} + export type SignMessageRequest = { message: string } export type SignMessageResult = { signature: string } diff --git a/canton-barebones/wallet-service/test/rpc.test.ts b/canton-barebones/wallet-service/test/rpc.test.ts index 707542a..13d1ae8 100644 --- a/canton-barebones/wallet-service/test/rpc.test.ts +++ b/canton-barebones/wallet-service/test/rpc.test.ts @@ -1,4 +1,5 @@ import { strict as assert } from 'node:assert' +import * as http from 'node:http' import { describe, it } from 'node:test' import { createRpc, errorData, errorMessage } from '../src/rpc.ts' import type { JsonRpcResponse } from '../src/types.ts' @@ -18,6 +19,8 @@ const baseConfig = () => ({ jsonApiUrl: 'http://localhost:3013', ledgerApiUrl: 'grpc://localhost:3014', adminApiUrl: 'grpc://localhost:3015', + scanUrl: 'http://localhost:4000', + scanHost: 'scan.localhost', backendUserId: 'wallet-service', backendToken: undefined as string | undefined, tokenSource: 'none' as const, @@ -287,6 +290,57 @@ describe('party methods are off the dapp-api surface', () => { }) }) +describe('scanApi pass-through', () => { + it('forwards to scanUrl + resource with Host header and returns parsed body', async () => { + const responseBody = { rounds: [{ number: 1 }] } + let capturedHost: string | undefined + let capturedAuth: string | undefined + + const server = http.createServer((req, res) => { + capturedHost = req.headers.host + capturedAuth = req.headers.authorization + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify(responseBody)) + }) + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as { port: number } + + const config = baseConfig() + config.canton.scanUrl = `http://127.0.0.1:${port}` + + try { + const rpc = createRpc(config) + const res = (await rpc.handle({ + jsonrpc: '2.0', + id: 1, + method: 'scanApi', + params: { resource: '/v0/domains/global-domain/rounds/open', requestMethod: 'get' }, + })) as JsonRpcResponse + assert.ok('result' in res, `expected result, got: ${JSON.stringify(res)}`) + assert.deepEqual(res.result, responseBody) + assert.equal(capturedHost, 'scan.localhost') + assert.equal(capturedAuth, undefined) + } finally { + await new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())), + ) + } + }) + + it('returns -32602 when resource is missing', async () => { + const rpc = createRpc(baseConfig()) + const res = (await rpc.handle({ + jsonrpc: '2.0', + id: 1, + method: 'scanApi', + params: { requestMethod: 'get' }, + })) as JsonRpcResponse + assert.ok('error' in res) + assert.equal(res.error.code, -32602) + }) +}) + describe('ledgerApi pass-through', () => { it('accepts requestMethod=get', async () => { const rpc = createRpc(baseConfig()) diff --git a/dapp/daml/amulet-vesting/.gitignore b/dapp/daml/amulet-vesting/.gitignore new file mode 100644 index 0000000..ce075f6 --- /dev/null +++ b/dapp/daml/amulet-vesting/.gitignore @@ -0,0 +1 @@ +.daml/ diff --git a/dapp/daml/amulet-vesting/daml.yaml b/dapp/daml/amulet-vesting/daml.yaml new file mode 100644 index 0000000..ccb73ae --- /dev/null +++ b/dapp/daml/amulet-vesting/daml.yaml @@ -0,0 +1,21 @@ +# for config file options, refer to +# https://docs.digitalasset.com/build/3.4/dpm/configuration.html#project-configuration +# SDK pinned to 3.4.11 / LF target 2.1 to match the vendored Splice deps +# (splice-amulet et al. are built with sdk 3.4.11 + --target=2.1). Keeping the +# same SDK/LF as the deps lets the amulet-vesting-test package data-depend on this +# DAR directly instead of recompiling the source at a different target. +sdk-version: 3.4.11 +name: amulet-vesting +source: daml +version: 0.0.1 +dependencies: + - daml-prim + - daml-stdlib +data-dependencies: + # paths are relative to this package dir (dapp/daml/amulet-vesting/); ../../ is dapp/ (the vendor root in this repo) + - ../../deps/splice-daml/dars/splice-amulet-0.1.19.dar + - ../../deps/splice-daml/dars/splice-api-token-holding-v1-1.0.0.dar + - ../../deps/splice-daml/dars/splice-api-token-metadata-v1-1.0.0.dar + - ../../deps/splice-daml/dars/splice-api-token-transfer-instruction-v1-1.0.0.dar +build-options: + - --target=2.1 diff --git a/dapp/daml/amulet-vesting/daml/AmuletVesting.daml b/dapp/daml/amulet-vesting/daml/AmuletVesting.daml new file mode 100644 index 0000000..bd9b88f --- /dev/null +++ b/dapp/daml/amulet-vesting/daml/AmuletVesting.daml @@ -0,0 +1,376 @@ +module AmuletVesting where + +import Splice.Amulet +import Splice.AmuletRules +import Splice.Expiry (TimeLock(..), maxTime) +import AmuletVesting.Schedule (VestingSchedule, vestedAmount, validVestingSchedule, minGrantAmount) + +-- --------------------------------------------------------------------------- +-- Shared escrow mechanic +-- --------------------------------------------------------------------------- + +-- | The one tricky escrow mechanic, shared by both withdraw choices: +-- unlock the escrow, push `releaseAmount` to the receiver, re-lock the remainder +-- back to the owner. The re-lock holders are {provider, receiver} minus the owner, so the +-- unlock controller is always {owner, provider, receiver} -- collusion-resistant: +-- the live escrow has owner=creator (holders [provider, receiver]); a AmuletVestedClaim has +-- owner=receiver (holders [provider]). expiresAt = maxTime in both cases. +-- `backing` is the amount currently in the lock (caller knows it). +-- Returns the receiver's unlocked Amulet and the optional re-locked remainder. +-- Authority must be supplied by the calling choice (owner + provider + receiver for the +-- unlock; transfer controllers for the re-transfer). +releaseAndRelock + : AppTransferContext + -> Party -- dso + -> Party -- provider + -> Party -- owner (creator) + -> Party -- receiver + -> ContractId LockedAmulet + -> Decimal -- backing + -> Decimal -- releaseAmount + -> Text -- relockContext: on-ledger optContext tag for the re-locked remainder + -> Update (ContractId Amulet, Optional (ContractId LockedAmulet)) +releaseAndRelock ctx dso provider owner receiver lockedCid backing releaseAmount relockContext = do + unlockRes <- exercise lockedCid LockedAmulet_UnlockV2 + -- `remainder` is exact by construction: releaseAmount + remainder == backing == + -- the unlocked input's initialAmount, so the transfer is an exact split (inputs == + -- outputs, no sender-change). Any positive sub-dust remainder re-locks rather than + -- being discarded; a zero remainder means the escrow is fully drained. + -- + -- ZERO-FEE DEPENDENCY (CIP-78): this exact split leaves no headroom for Amulet + -- transfer/create/lock fees. If any were charged, `leftOverAmount < 0` and the + -- AmuletRules balance check would abort with InsufficientFunds, stranding the grant. + -- This is safe because CIP-78 removed those fees at the protocol level: a valid + -- AmuletRules contract MUST satisfy `validTransferConfig`, which forces + -- createFee == transferFee == lockHolderFee == 0 (Splice.AmuletConfig.validTransferConfig, + -- enforced by the AmuletRules `ensure`). The only non-zero fee the protocol still + -- permits is the holding fee, which is not charged on transfers (only on Amulet_Expire). + -- If a future Splice version reintroduces non-zero transfer/create/lock fees, this + -- model must add explicit fee headroom (e.g. a receiverFeeRatio or a fee allowance + -- passed in by the off-ledger backend). See docs/ARCHITECTURE.md. + let remainder = backing - releaseAmount + -- Never leave a sub-`minGrantAmount` dust remainder re-locked. A tiny LockedAmulet + -- is DSO-expirable within ~minutes (amountExpiresAt grows with the locked amount), which + -- would archive the backing lock and strand the tail. The caller must either drain fully + -- (remainder == 0) or leave at least a viable floor. + assertMsg "re-lock remainder is below minGrantAmount (drain fully or leave >= the floor)" + (remainder == 0.0 || remainder >= minGrantAmount) + let relockOutputs = + if remainder > 0.0 + then [ TransferOutput with + receiver = owner + receiverFeeRatio = 0.0 + amount = remainder + lock = Some TimeLock with + -- Hold the remainder for {provider, receiver} minus the owner. The owner is + -- already an unlock controller (owner :: holders), so listing the other two + -- makes the unlock require all of {owner, provider, receiver}: + -- escrow re-lock (owner=creator) -> holders [provider, receiver] + -- claim re-lock (owner=receiver) -> holders [provider] + holders = filter (/= owner) [provider, receiver] + expiresAt = maxTime + optContext = Some relockContext ] + else [] + outputs = + TransferOutput with + receiver = receiver + receiverFeeRatio = 0.0 + amount = releaseAmount + lock = None + :: relockOutputs + result <- exerciseAppTransfer dso ctx Transfer with + sender = owner + provider = provider + inputs = [InputAmulet unlockRes.amuletCid] + outputs + beneficiaries = None + -- createdAmulets follows outputs order: [receiver Amulet] (++ [re-locked LockedAmulet]). + case result.createdAmulets of + [TransferResultAmulet rcv] -> pure (rcv, None) + [TransferResultAmulet rcv, TransferResultLockedAmulet relocked] -> pure (rcv, Some relocked) + _ -> abort "releaseAndRelock: unexpected transfer outputs" + +-- --------------------------------------------------------------------------- +-- Main vesting contract +-- --------------------------------------------------------------------------- +template AmuletVestingContract + with + provider : Party -- ^ the app provider Party + creator : Party -- ^ locked the CC; gets unvested CC on cancel; is the lock owner + receiver : Party -- ^ beneficiary; can withdraw vested CC; co-holder of the escrow lock + dso : Party -- ^ Canton Coin DSO (Amulet admin); passed as expectedDso to AmuletRules. Not a signatory or lock holder. + totalAmount : Decimal -- ^ total CC originally locked + schedule : VestingSchedule + alreadyWithdrawn : Decimal -- ^ running total already sent to receiver + lockedAmuletCid : ContractId LockedAmulet -- ^ the single locked holding backing this contract + note : Optional Text + where + -- The receiver is a signatory (not merely an observer): this makes the receiver's + -- authority ambient in every choice on this contract, including the creator-controlled + -- Cancel. That, in turn, lets the escrow lock list the receiver as a holder + -- (holders = [provider, receiver]) so LockedAmulet_UnlockV2's controller becomes + -- {creator, provider, receiver} -- all three are required to unlock. Creator + provider + -- alone can therefore NOT drain the escrow out-of-band, while the creator still cancels + -- unilaterally (the receiver's consent was given once, at Accept). The receiver signs + -- by exercising Accept on the proposal. + signatory provider, creator, receiver + + -- Re-assert schedule well-formedness here (not only in the Factory) so a malformed + -- schedule can never back a live contract, even via a direct create that bypasses the + -- validating Factory. The schedule is immutable, so this holds across every withdraw + -- successor too. `validVestingSchedule` is a pure Bool, usable in `ensure`. + -- `alreadyWithdrawn < totalAmount` is STRICT. A live contract therefore always has + -- backing = totalAmount - alreadyWithdrawn > 0, which makes the Cancel `[] -> (None, None)` + -- branch provably unreachable (a zero-backing Cancel would silently return the residual to + -- the creator as sender-change). The full-drain Withdraw archives instead of creating a + -- zero-backing successor, so the strict bound never rejects a legitimate successor. + ensure + totalAmount > 0.0 + && alreadyWithdrawn >= 0.0 + && alreadyWithdrawn < totalAmount + && validVestingSchedule schedule + + -- ----------------------------------------------------------------------- + -- Receiver withdraws currently-available Amulet (vested - alreadyWithdrawn) + -- ----------------------------------------------------------------------- + choice AmuletVestingContract_Withdraw : AmuletVestingContract_WithdrawResult + with + withdrawAmount : Decimal + ctx : AppTransferContext + controller receiver + do + now <- getTime + assertMsg "cliff not reached" (now >= schedule.cliff) + let vested = vestedAmount schedule totalAmount now + available = vested - alreadyWithdrawn + backing = totalAmount - alreadyWithdrawn + assertMsg "withdrawAmount must be positive" (withdrawAmount > 0.0) + assertMsg "withdrawAmount exceeds available vested amount" (withdrawAmount <= available) + (receivedAmuletCid, optRelocked) <- + releaseAndRelock ctx dso provider creator receiver lockedAmuletCid backing withdrawAmount "vesting-withdraw-remainder" + vestingCid <- case optRelocked of + None -> pure None -- fully withdrawn (only possible once fully vested); contract archived + Some newLockedCid -> fmap Some $ create this with + alreadyWithdrawn = alreadyWithdrawn + withdrawAmount + lockedAmuletCid = newLockedCid + pure AmuletVestingContract_WithdrawResult with vestingCid; receivedAmuletCid + + -- ----------------------------------------------------------------------- + -- Creator cancels: receiver keeps accrued share, creator gets the rest + -- ----------------------------------------------------------------------- + choice AmuletVestingContract_Cancel : AmuletVestingContract_CancelResult + with + ctx : AppTransferContext + controller creator + do + now <- getTime + let vested = vestedAmount schedule totalAmount now + clawback = totalAmount - vested -- unvested -> creator (unlocked) + owed = vested - alreadyWithdrawn -- earned, unwithdrawn -> AmuletVestedClaim + -- clawback + owed == totalAmount - alreadyWithdrawn == backing of the escrow + -- The owed leg is re-locked into the AmuletVestedClaim; forbid a sub-floor dust + -- lock the DSO could expire out from under the receiver. Either nothing is owed + -- (owed == 0) or the residual must be at least minGrantAmount. The clawback leg is + -- handed back unlocked, so it needs no floor. + assertMsg "owed residual is below minGrantAmount (would re-lock as expirable dust)" + (owed == 0.0 || owed >= minGrantAmount) + unlockRes <- exercise lockedAmuletCid LockedAmulet_UnlockV2 + let clawbackOutputs = + if clawback > 0.0 + then [ TransferOutput with + receiver = creator; receiverFeeRatio = 0.0; amount = clawback; lock = None ] + else [] + owedOutputs = + if owed > 0.0 + -- The owed residual is unconditionally the receiver's, so re-lock it with + -- owner = receiver (holders = [provider]); unlock controller = {receiver, provider}. + -- The creator can no longer reach it. Feasible because the receiver is a signatory + -- of this contract, so its authority is present in this creator-controlled Cancel. + then [ TransferOutput with + receiver = receiver; receiverFeeRatio = 0.0; amount = owed + lock = Some TimeLock with + holders = [provider]; expiresAt = maxTime + optContext = Some "vesting-cancel-owed" ] + else [] + result <- exerciseAppTransfer dso ctx Transfer with + sender = creator + provider = provider + inputs = [InputAmulet unlockRes.amuletCid] + outputs = clawbackOutputs ++ owedOutputs + beneficiaries = None + -- createdAmulets order: [clawback Amulet?] ++ [owed LockedAmulet?] + let (clawbackAmuletCid, owedLockedCid) = case result.createdAmulets of + [] -> (None, None) + [TransferResultAmulet cb] -> (Some cb, None) + [TransferResultLockedAmulet ow] -> (None, Some ow) + [TransferResultAmulet cb, TransferResultLockedAmulet ow] -> (Some cb, Some ow) + _ -> error "AmuletVestingContract_Cancel: unexpected transfer outputs" + vestedClaimCid <- case owedLockedCid of + None -> pure None + Some ow -> fmap Some $ create AmuletVestedClaim with + provider; creator; receiver; dso + amount = owed; withdrawn = 0.0 + lockedAmuletCid = ow; note + pure AmuletVestingContract_CancelResult with clawbackAmuletCid; vestedClaimCid + +-- --------------------------------------------------------------------------- +-- Vested residual handed to the receiver at cancel time +-- --------------------------------------------------------------------------- +template AmuletVestedClaim + with + provider : Party + creator : Party + receiver : Party + dso : Party + amount : Decimal -- total earned residual handed over at cancel + withdrawn : Decimal -- running total already pulled by receiver + lockedAmuletCid : ContractId LockedAmulet + note : Optional Text + where + signatory provider, creator + observer receiver + + ensure + amount > 0.0 + && withdrawn >= 0.0 + && withdrawn <= amount + + -- Receiver pulls the fully-earned residual. No cliff, no schedule. + choice AmuletVestedClaim_Withdraw : AmuletVestedClaim_WithdrawResult + with + withdrawAmount : Decimal + ctx : AppTransferContext + controller receiver + do + -- The claim has no schedule, so the whole un-withdrawn residual is available, and it + -- is also exactly what backs the lock: available == backing == amount - withdrawn. + let available = amount - withdrawn + assertMsg "withdrawAmount must be positive" (withdrawAmount > 0.0) + assertMsg "withdrawAmount exceeds available amount" (withdrawAmount <= available) + -- owner = receiver: the claim residual is wholly the receiver's, so the lock (and any + -- re-locked remainder) is receiver-owned; unlock controller = {receiver, provider}. + (receivedAmuletCid, optRelocked) <- + releaseAndRelock ctx dso provider receiver receiver lockedAmuletCid available withdrawAmount "vesting-claim-remainder" + claimCid <- case optRelocked of + None -> pure None + Some newLockedCid -> fmap Some $ create this with + withdrawn = withdrawn + withdrawAmount + lockedAmuletCid = newLockedCid + pure AmuletVestedClaim_WithdrawResult with claimCid; receivedAmuletCid + +template AmuletVestingProposal + with + provider : Party + proposer : Party + receiver : Party + totalAmount : Decimal + schedule : VestingSchedule + amuletCids : [ContractId Amulet] + note : Optional Text + where + signatory provider, proposer + observer receiver + + choice AmuletVestingProposal_Accept : ContractId AmuletVestingContract + with + ctx : AppTransferContext -- supplied off-ledger by the app backend + controller receiver + do + -- Derive the DSO from the funder's own input Amulets rather than trusting a + -- caller-supplied `dso`. An `Amulet` is signed by `{dso, owner}`, and the proposer + -- (owner of these amulets) is a signatory of this proposal, so fetching one is + -- authorized - whereas the DSO-signed `AmuletRules` is NOT fetchable by us. The + -- derived `dso` is the actual admin of the very CC being locked, so the stored `dso` + -- is correct by construction and consistent with the transfer's `expectedDso`; this + -- removes the footgun where a typo'd `dso` produced a proposal that could never Accept. + dso <- case amuletCids of + (cid :: _) -> (.dso) <$> fetch cid + [] -> abort "AmuletVestingProposal_Accept: no input amulets to fund the grant" + -- Self-transfer the funder's CC into a single LockedAmulet escrow: + -- owner = proposer (creator), holders = [provider, receiver], expiresAt = maxTime. + -- Listing the receiver as a holder makes LockedAmulet_UnlockV2's controller + -- {creator, provider, receiver}, so creator+provider cannot unlock it out-of-band. + -- Authority present: {provider, proposer, receiver} (proposal sigs + Accept controller); + -- the transfer needs {sender=proposer, lock-holders=provider,receiver} -- all present. + result <- exerciseAppTransfer dso ctx Transfer with + sender = proposer + provider = provider + inputs = map InputAmulet amuletCids + outputs = + [ TransferOutput with + receiver = proposer + receiverFeeRatio = 0.0 + amount = totalAmount + lock = Some TimeLock with + holders = [provider, receiver] + expiresAt = maxTime + optContext = Some "vesting-escrow" + ] + beneficiaries = None + lockedCid <- case result.createdAmulets of + [TransferResultLockedAmulet cid] -> pure cid + _ -> abort "AmuletVestingProposal_Accept: expected exactly one locked output" + create AmuletVestingContract with + provider + creator = proposer + receiver + dso + totalAmount + schedule + alreadyWithdrawn = 0.0 + lockedAmuletCid = lockedCid + note + +-- | Provider-signed, observer-less factory. Because there are no on-ledger observers, +-- proposers cannot see this contract through normal visibility rules; instead the app +-- backend hands them the factory contract via explicit disclosure so they can exercise +-- AmuletVestingFactory_CreateVesting without requiring on-ledger visibility. +-- +-- The factory is a long-lived, reusable singleton: AmuletVestingFactory_CreateVesting is +-- nonconsuming, so a single factory contract originates many vestings and is not archived +-- per grant (mirroring the upstream AmuletRules / ExternalPartyAmuletRules / TransferFactory +-- nonconsuming-factory pattern). One factory per provider, reused for every grant. +template AmuletVestingFactory + with + factoryOwner : Party + where + signatory factoryOwner + + nonconsuming choice AmuletVestingFactory_CreateVesting : ContractId AmuletVestingProposal + with + proposer : Party + receiver : Party + totalAmount : Decimal + schedule : VestingSchedule + amuletCids : [ContractId Amulet] + note : Optional Text + controller proposer + do + assertMsg "schedule is not well-formed" (validVestingSchedule schedule) + assertMsg "grant total below minimum" (totalAmount >= minGrantAmount) + create AmuletVestingProposal with + provider = factoryOwner + proposer + receiver + totalAmount + schedule + amuletCids + note + +-- Result records -------------------------------------------------------------- + +data AmuletVestingContract_WithdrawResult = AmuletVestingContract_WithdrawResult + with + vestingCid : Optional (ContractId AmuletVestingContract) + receivedAmuletCid : ContractId Amulet + deriving (Eq, Show) + +data AmuletVestingContract_CancelResult = AmuletVestingContract_CancelResult with + clawbackAmuletCid : Optional (ContractId Amulet) -- unvested CC returned to creator + vestedClaimCid : Optional (ContractId AmuletVestedClaim) -- earned residual for the receiver + deriving (Eq, Show) + +data AmuletVestedClaim_WithdrawResult = AmuletVestedClaim_WithdrawResult with + claimCid : Optional (ContractId AmuletVestedClaim) + receivedAmuletCid : ContractId Amulet + deriving (Eq, Show) diff --git a/dapp/daml/amulet-vesting/daml/AmuletVesting/Schedule.daml b/dapp/daml/amulet-vesting/daml/AmuletVesting/Schedule.daml new file mode 100644 index 0000000..7a12b41 --- /dev/null +++ b/dapp/daml/amulet-vesting/daml/AmuletVesting/Schedule.daml @@ -0,0 +1,71 @@ +module AmuletVesting.Schedule where + +import DA.Time (subTime, convertRelTimeToMicroseconds) +import DA.List (last, head) + +-- | How CC accrues over time. Fraction-based so escrow logic stays curve-agnostic. +data VestingCurve + = LinearVesting with start : Time; end : Time + -- ^ continuous linear accrual from start (0) to end (1) + | MilestoneVesting with points : [(Time, Decimal)] + -- ^ step function; each (time, CUMULATIVE fraction), strictly ascending, last == 1.0 + deriving (Eq, Show) + +data VestingSchedule = VestingSchedule with + curve : VestingCurve + cliff : Time -- ^ nothing is *earned* before this (true cliff) + deriving (Eq, Show) + +-- | Fraction of the grant vested by `now`, in [0,1]. The ONE place curve shape matters. +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) -- cumulative fraction of the last reached point + +-- | CC vested by `now`, given the grant total. total * fraction. +vestedAmount : VestingSchedule -> Decimal -> Time -> Decimal +vestedAmount sched total now = total * vestedFraction sched now + +-- | Conservative minimum grant size (CC). This is a sanity floor against absurdly +-- small grants, NOT a guarantee that the escrow survives the whole vesting horizon. +-- +-- A LockedAmulet of `initialAmount` is DSO-reclaimable after +-- `ceil(initialAmount / holdingRate)` rounds (Splice.Expiry.amountExpiresAt). At the +-- reference rate 0.00002, `initialAmount = 1.0` survives ~50,000 rounds ≈ ~1 year at +-- 10-min rounds - so a 1.0-CC grant vesting over more than ~1 year can have its entire +-- escrow expired by the DSO mid-vest. The survival horizon scales with the grant +-- size, but the holding rate is off-ledger and cannot be read by an on-ledger choice, +-- so this floor is a flat conservative bound, not a horizon-derived one. +-- +-- The off-ledger backend is responsible for sizing grants so the escrow outlives the +-- schedule (`initialAmount >= holdingRate * roundsUntil(vestingEnd)`). Tunable. +minGrantAmount : Decimal +minGrantAmount = 1.0 + +-- | Strictly-ascending check on a list given a comparison projection. +strictlyAscending : Ord b => (a -> b) -> [a] -> Bool +strictlyAscending f xs = and (zipWith (\a b -> f a < f b) xs (drop 1 xs)) + +-- | Schedule well-formedness. Enforced at proposal creation (Factory choice). +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 -- times ascending + && strictlyAscending snd points -- cumulative fractions ascending + && all (\(_, fr) -> fr > 0.0 && fr <= 1.0) points -- in (0, 1] + && snd (last points) == 1.0 -- last == 1.0 + && sched.cliff <= fst (head points) -- cliff at/before first point diff --git a/dapp/daml/multi-package.yaml b/dapp/daml/multi-package.yaml index 090e637..ca2a88a 100644 --- a/dapp/daml/multi-package.yaml +++ b/dapp/daml/multi-package.yaml @@ -1,3 +1,4 @@ -# Single-package umbrella for vesting-lite. +# Multi-package umbrella: vesting-lite (bare Canton) + amulet-vesting (Splice runtime). packages: - ./vesting-lite + - ./amulet-vesting diff --git a/dapp/deps/splice-daml/dars/splice-amulet-0.1.19.dar b/dapp/deps/splice-daml/dars/splice-amulet-0.1.19.dar new file mode 100644 index 0000000..d1f653c Binary files /dev/null and b/dapp/deps/splice-daml/dars/splice-amulet-0.1.19.dar differ diff --git a/dapp/deps/splice-daml/dars/splice-api-token-holding-v1-1.0.0.dar b/dapp/deps/splice-daml/dars/splice-api-token-holding-v1-1.0.0.dar new file mode 100644 index 0000000..cb12fcf Binary files /dev/null and b/dapp/deps/splice-daml/dars/splice-api-token-holding-v1-1.0.0.dar differ diff --git a/dapp/deps/splice-daml/dars/splice-api-token-metadata-v1-1.0.0.dar b/dapp/deps/splice-daml/dars/splice-api-token-metadata-v1-1.0.0.dar new file mode 100644 index 0000000..535a7a0 Binary files /dev/null and b/dapp/deps/splice-daml/dars/splice-api-token-metadata-v1-1.0.0.dar differ diff --git a/dapp/deps/splice-daml/dars/splice-api-token-transfer-instruction-v1-1.0.0.dar b/dapp/deps/splice-daml/dars/splice-api-token-transfer-instruction-v1-1.0.0.dar new file mode 100644 index 0000000..8d0cd9c Binary files /dev/null and b/dapp/deps/splice-daml/dars/splice-api-token-transfer-instruction-v1-1.0.0.dar differ diff --git a/dapp/frontend/.gitignore b/dapp/frontend/.gitignore index fa4ff20..ecdd0e4 100644 --- a/dapp/frontend/.gitignore +++ b/dapp/frontend/.gitignore @@ -52,3 +52,5 @@ docs # Generated by scripts/bootstrap-vesting-lite.mjs (deploy-specific party ids + pkg) public/vesting-lite-parties.json +# Generated by scripts/bootstrap-amulet.mjs (LocalNet-specific party ids + factory cid) +public/amulet-parties.json diff --git a/dapp/frontend/CLAUDE.md b/dapp/frontend/CLAUDE.md index 0787f82..df3b65b 100644 --- a/dapp/frontend/CLAUDE.md +++ b/dapp/frontend/CLAUDE.md @@ -153,6 +153,6 @@ The `/sdlc:issue` skill applies these labels automatically when creating issues stale training data. --> - [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 +- [canton-connect-kit](https://github.com/BootNodeDev/bn-canton-dev-stack) — wallet hook API that `src/wallet/` mirrors; `src/wallet/` is now a live wallet-service client (`StealthWallet`), not a mock - [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/architecture.md b/dapp/frontend/architecture.md index 222b77a..11cb34b 100644 --- a/dapp/frontend/architecture.md +++ b/dapp/frontend/architecture.md @@ -5,11 +5,12 @@ session start. Focus on the "shape" of the system — not usage instructions (that's CLAUDE.md) or API docs (that's code comments). --> -This is a **static, mocked-data** frontend for the +This is a **live** 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. +(DAML/Canton; token is Splice Amulet / Canton Coin). It talks to a real ledger: reads of the +Active Contract Set and command submission go through the wallet-service `ledgerApi`/`scanApi` +JSON-RPC proxy. Both the wallet and the ledger sit behind a swappable backend boundary +(`src/backend/`, `src/wallet/`) so the integration surface stays isolated from the UI. ## Tech Stack @@ -18,14 +19,14 @@ layers deliberately mirror the eventual integration surface so they can be swapp | 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 | +| Routing | React Router 7 | `createBrowserRouter`; pages lazy-loaded | +| State | Zustand 5 | 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 | +| Testing | Vitest 3 | Unit tests for vesting math + helpers | | Lint/format | Biome 2 | Husky + lint-staged + commitlint | | Fonts | Manrope, JetBrains Mono | `@fontsource-variable/*` | -| Runtime | Node `>=24` | pnpm | +| Runtime | Node `>=24` | npm (root workspaces) | ## Project Structure @@ -33,37 +34,46 @@ layers deliberately mirror the eventual integration surface so they can be swapp 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) + routes.tsx Route table (AppShell layout + 3 lazy-loaded 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 + AppShell.tsx Gates on wallet (ConnectScreen when disconnected); TopBar + Suspense Outlet + ConnectScreen.tsx Landing party picker (enters the app on select) + TopBar.tsx Logo/title + ThemeToggle + WalletControl + WalletControl.tsx Party switcher: acting-party pill + pool menu (copy id, sign out) ThemeToggle.tsx Light ⇄ dark - components/ Reusable UI (Card, Button, KpiCard, GrantCard, GrantTable, - ScheduleBar, ScheduleCurve, MilestoneTimeline, StatusPill, - AmountDisplay, Legend, ClaimDialog, Modal, ProposalCard, - EmptyState, toast, icons) + backend/ Swappable ledger boundary + VestingBackend.ts Backend interface + row→domain mappers + AmuletBackend.ts Live backend: ACS reads + scan service + explicitly-disclosed commands + amuletCommands.ts AmuletVesting choice command builders + commands.ts Disclosure shaping + curve encode/decode (pure) + createBackend.ts Builds the active backend from runtime config + ledgerApi.ts JSON-RPC client for the wallet-service proxy + components/ Reusable UI (Card, Button, KpiCard, GrantCard, GrantTable, ScheduleBar, + ScheduleCurve, MilestoneTimeline, StatusPill, AmountDisplay, Legend, + ClaimDialog, Modal, TokenAmountField, CcCoin, PartyAvatar, InfoTooltip, + EmptyState, Spinner, 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) + useVestingStore.ts Zustand store; actions ≙ choices; deriveGrant() projection; status helpers + useUiStore.ts Dashboard view (cards/table) lib/ Pure utilities - schedule.ts Vesting math, ports AmuletVesting.Schedule + schedule.ts Vesting math + re-lock helpers, ports AmuletVesting.Schedule format.ts CC amounts, party ids, dates, relative time clock.ts Shared 1s "now" clock (useNow) for live accrual + clipboard.ts copyPartyId (clipboard write + toast) 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 + uuid.ts Secure-context-safe UUID with fallback + motion.ts Shared framer-motion variants + wallet/ Wallet boundary (wallet-service client) + Wallet.ts Wallet interface (listParties / execute) + StealthWallet.ts Hosted wallet over the wallet-service (listAccounts + ledgerApi submit) + WalletProvider.tsx Connect/party/pool context + backend wiring + hooks.ts useParty / useConnect / useParties / useBackend + theme/ ThemeProvider.tsx + tokens.css (dual-mode design tokens) styles/index.css Tailwind import + @theme inline mapping + base styles ``` @@ -72,58 +82,60 @@ src/ - **`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. + `statusPillLabel` / `statusPillTone` derive the shared status chip from that status. - **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. + (linear + milestone + true cliff). `canClaim` / `remainderAfter` / `floorOk` mirror the + on-ledger re-lock floor so the UI never offers a withdraw the contract will reject. Pure and + unit-tested. +- **Identity + tabs** — the connected wallet party is the actor (chosen in `WalletControl`). + The dashboard's per-escrow tabs (`received` vs `created`) only change which side of the + user's own escrows is shown; they never change identity. ### Data Access Layer -Two swap points isolate the mock from the eventual integration: +Two swap points isolate integration from the UI: -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. +1. **Wallet** — `src/wallet/` defines a `Wallet` interface; `StealthWallet` implements it over + the wallet-service (`listAccounts` for the CanActAs pool, `ledgerApi` submit for commands — + no signing popup). Components only import from `@/wallet`. +2. **Ledger** — `src/backend/` defines `VestingBackend`; `AmuletBackend` implements it against + Splice LocalNet. It reads the ACS via the wallet-service `ledgerApi` proxy, fetches + `AmuletRules` + the latest opened `OpenMiningRound` from the `scanApi`, and builds commands + that carry an explicitly-disclosed `AppTransferContext` on every mutating choice. Components + call store actions and read derived selectors; they never construct commands directly. ## 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 | +| `/dashboard` | `features/dashboard/DashboardPage` | KPIs + grants (cards/table) + pending proposals + residual claims | | `/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 + ``. +Pages are `React.lazy`-loaded; `AppShell` renders `ConnectScreen` when disconnected, otherwise +the top bar + a ``-wrapped `` (full-screen spinner fallback) under `
`. ## 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 +wallet-service (ledgerApi + scanApi) ─▶ AmuletBackend ─▶ useVestingStore (Zustand) ─▶ deriveGrant(grant, now) ─▶ UI + ▲ ▲ ▲ + StealthWallet.execute() submits commands refresh() re-reads ACS useNow() ticks 1s → live accrual + ▲ + store actions (withdraw / cancel / accept / createVesting / claimResidual) ``` -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 +The connected party comes from `useParty()`; the dashboard tabs filter which of the user's +escrows show. There is no caching layer beyond the in-memory Zustand store; 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. +The app reads no `import.meta.env` at runtime. Live wiring comes from a runtime config file +fetched at startup (`/public/amulet-parties.json`): `rpcUrl` (wallet-service proxy), `pkg` +(amulet-vesting package id), `operator` (app-provider party), and optional `splicePkg`. ## Scripts @@ -132,7 +144,7 @@ None. The app is fully static and mocked; nothing reads `import.meta.env` at run | `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) | +| `test` | Vitest unit tests | | `lint` / `lint:fix` | Biome check (write) | | `typecheck` | `tsc -b --noEmit` | @@ -144,13 +156,14 @@ None. The app is fully static and mocked; nothing reads `import.meta.env` at run 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. +`lib/format.ts` (`formatCC`, grouped, ≤2 decimals; `formatCCFull` for exact figures). +`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) +### Smart Contract Architecture -The UI mirrors four DAML templates and their choices (see `store/types.ts`): +The UI mirrors three DAML templates and their choices (see `store/types.ts`): - `Grant` ≙ `AmuletVestingContract`, `Proposal` ≙ `AmuletVestingProposal`, `VestedClaim` ≙ `AmuletVestedClaim`. @@ -159,5 +172,5 @@ The UI mirrors four DAML templates and their choices (see `store/types.ts`): - 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. +- `dso` is never a UI actor; escrow funding inputs (`amuletCids`) are selected live from the + proposer's own Amulet holdings in the create form. diff --git a/dapp/frontend/index.html b/dapp/frontend/index.html index 1beda6a..ec0aa68 100644 --- a/dapp/frontend/index.html +++ b/dapp/frontend/index.html @@ -3,9 +3,24 @@ + + Canton Vesting + + + + + + +