Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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.
Expand All @@ -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 <dir>` |
| Node | 24 | Pinned via root `.nvmrc`; inherits to every Node subproject |
| Container runtime | Docker | Used by `canton-barebones/` for the local participant + Postgres |
Expand All @@ -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) |

Expand Down Expand Up @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@ flowchart TD
wallet["carpincho-wallet<br/>Vault + signer<br/>http://localhost:3011"]
ws["canton-barebones/wallet-service<br/>Canton bridge<br/>http://localhost:3010"]
cb["canton-barebones<br/>Participant JSON API http://localhost:3013<br/>Ledger/Admin gRPC localhost:3014 / 3015"]
dar["dapp/daml<br/>quickstart-tally DAR<br/>.daml/dist/*.dar"]
dar["dapp/daml/vesting-lite<br/>vesting-lite DAR<br/>.daml/dist/*.dar"]

fe <-->|"Injected CIP-0103 provider<br/>optional WalletConnect"| wallet
wallet -->|"JSON-RPC /rpc<br/>prepare, execute, read, onboard"| ws
ws -->|"Canton JSON API<br/>self-minted JWT"| cb
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

Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
12 changes: 6 additions & 6 deletions architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -46,15 +46,15 @@

## 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
fe["dapp/frontend<br/>dApp frontend<br/>http://localhost:3012"]
wallet["carpincho-wallet<br/>Vault + signer<br/>http://localhost:3011"]
ws["canton-barebones/wallet-service<br/>Canton bridge<br/>http://localhost:3010"]
cb["canton-barebones<br/>Participant JSON API http://localhost:3013<br/>Ledger/Admin gRPC localhost:3014 / 3015"]
dar["dapp/daml<br/>quickstart-tally DAR<br/>.daml/dist/*.dar"]
dar["dapp/daml/vesting-lite<br/>vesting-lite DAR<br/>.daml/dist/*.dar"]

fe <-->|"Injected CIP-0103 provider<br/>optional WalletConnect"| wallet
wallet -->|"JSON-RPC /rpc<br/>prepare, execute, read, onboard"| ws
Expand Down Expand Up @@ -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
4 changes: 3 additions & 1 deletion canton-barebones/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file not shown.
1 change: 1 addition & 0 deletions canton-barebones/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
3 changes: 3 additions & 0 deletions canton-barebones/wallet-service/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions canton-barebones/wallet-service/src/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down
43 changes: 42 additions & 1 deletion canton-barebones/wallet-service/src/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
Network,
Provider,
StatusEvent,
Wallet,
} from './types.ts'

export class InvalidParams extends Error {
Expand Down Expand Up @@ -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<Wallet[]> => {
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<JsonRpcResponse> => {
if (request.jsonrpc !== undefined && request.jsonrpc !== '2.0') {
return rpcError(id, -32600, 'Invalid request', { reason: 'jsonrpc must be "2.0"' })
Expand All @@ -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.',
Expand Down
Loading