diff --git a/examples/conformance-runner/.gitignore b/examples/conformance-runner/.gitignore new file mode 100644 index 000000000..712043ef2 --- /dev/null +++ b/examples/conformance-runner/.gitignore @@ -0,0 +1,31 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Playwright +*.png +test-results/ +playwright-report/ +blob-report/ +playwright/.cache/ diff --git a/examples/conformance-runner/README.md b/examples/conformance-runner/README.md new file mode 100644 index 000000000..883e30009 --- /dev/null +++ b/examples/conformance-runner/README.md @@ -0,0 +1,28 @@ +## CIP-103 Conformance Runner (dApp) + +This is a small web dApp that: + +- connects to a wallet using `@canton-network/dapp-sdk` discovery +- runs a subset of the CIP-103 conformance suite against the **currently connected** provider (no Playwright) +- lets you download an artifact JSON compatible with `tools/cip103-conformance/generate-report.mjs` + +### Run + +From repo root: + +```bash +yarn install --mode=skip-build --no-immutable +yarn workspace @canton-network/example-conformance-runner run dev +``` + +Then open the page, connect a wallet, select a profile, and click **Run conformance**. + +### Download artifact + +Click **Download artifact JSON** to save a `result-connected-.json` file. + +To include it in the HTML report site, copy it under `tools/cip103-conformance/` as `result-.json` and run: + +```bash +yarn workspace @canton-network/cip103-conformance report:html +``` diff --git a/examples/conformance-runner/index.html b/examples/conformance-runner/index.html new file mode 100644 index 000000000..953f55f8b --- /dev/null +++ b/examples/conformance-runner/index.html @@ -0,0 +1,12 @@ + + + + + + CIP-103 Conformance Runner + + +
+ + + diff --git a/examples/conformance-runner/package.json b/examples/conformance-runner/package.json new file mode 100644 index 000000000..a3f69a787 --- /dev/null +++ b/examples/conformance-runner/package.json @@ -0,0 +1,33 @@ +{ + "name": "@canton-network/example-conformance-runner", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@canton-network/cip103-conformance": "workspace:^", + "@canton-network/core-rpc-errors": "workspace:^", + "@canton-network/core-types": "workspace:^", + "@canton-network/dapp-sdk": "workspace:^", + "react": "^19.2.4", + "react-dom": "^19.2.4" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.4", + "eslint": "^10.0.2", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "nx": "^22.5.3", + "typescript": "~5.9.3", + "typescript-eslint": "^8.56.1", + "vite": "^7.3.1" + } +} diff --git a/examples/conformance-runner/src/App.tsx b/examples/conformance-runner/src/App.tsx new file mode 100644 index 000000000..d2d14e90d --- /dev/null +++ b/examples/conformance-runner/src/App.tsx @@ -0,0 +1,367 @@ +import { useEffect, useState } from 'react' +import * as sdk from '@canton-network/dapp-sdk' +import { + createConnectedProviderTransport, + type ConnectedProvider, +} from './conformance/transport' +import { + runConformanceAgainstConnectedProvider, + type Artifact, + type CapturedRequest, + type Profile, +} from './conformance/runner' +import { readRequiredMethodsBundled } from '@canton-network/cip103-conformance/browser' + +function downloadJson(filename: string, data: unknown) { + const blob = new Blob([JSON.stringify(data, null, 2)], { + type: 'application/json', + }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) +} + +async function bestEffortProviderMeta(): Promise<{ + name: string + version: string + appUrl?: string +}> { + try { + const status: sdk.dappAPI.StatusEvent = await sdk.status() + const name = status.provider?.id ?? 'Connected wallet' + const version = status.provider?.version ?? 'unknown' + return { name, version } + } catch { + return { name: 'Connected wallet', version: 'unknown' } + } +} + +export default function App() { + const [connectResult, setConnectResult] = + useState() + const connected = connectResult?.isConnected ?? false + + const [profile, setProfile] = useState('sync') + const [running, setRunning] = useState(false) + const [artifact, setArtifact] = useState() + const [error, setError] = useState() + const [progress, setProgress] = useState<{ + message: string + phase?: string | undefined + current?: number | undefined + total?: number | undefined + lastId?: string | undefined + lastStatus?: string | undefined + }>() + const [observedResponses, setObservedResponses] = useState< + Record + >({}) + const [observedRequests, setObservedRequests] = useState< + Record + >({}) + + const canRun = connected && !running + + useEffect(() => { + sdk.status() + .then((status: sdk.dappAPI.StatusEvent) => + setConnectResult(status.connection) + ) + .catch(() => setConnectResult(undefined)) + }, []) + + async function connect() { + setError(undefined) + setArtifact(undefined) + const result = await sdk.connect({ + defaultAdapters: [], + }) + setConnectResult(result) + } + + async function disconnect() { + setError(undefined) + await sdk.disconnect() + setConnectResult(undefined) + } + + async function run() { + setRunning(true) + setError(undefined) + setArtifact(undefined) + setProgress({ message: 'Starting…' }) + setObservedResponses({}) + setObservedRequests({}) + + try { + const connectedProvider: ConnectedProvider | null = + sdk.getConnectedProvider() + if (!connectedProvider) { + throw new Error( + 'No connected provider instance available. Connect a wallet first.' + ) + } + + setProgress({ message: 'Loading required methods…' }) + const requiredMethods = await readRequiredMethodsBundled(profile) + const meta = await bestEffortProviderMeta() + const transport = createConnectedProviderTransport( + connectedProvider, + { + timeoutMs: 15000, + } + ) + const next = await runConformanceAgainstConnectedProvider({ + profile, + transport, + requiredMethods, + providerMeta: { + name: meta.name, + version: meta.version, + appUrl: window.location.href, + }, + onProgress: (e) => { + setProgress({ + phase: e.phase, + message: e.message, + current: e.current, + total: e.total, + lastId: e.lastResult?.id, + lastStatus: e.lastResult?.status, + }) + if (e.lastResult?.id) { + const id = e.lastResult.id + setObservedResponses((prev) => ({ + ...prev, + [id]: + e.lastResponse === undefined + ? null + : e.lastResponse, + })) + setObservedRequests((prev) => ({ + ...prev, + [id]: + e.lastRequest === undefined + ? null + : e.lastRequest, + })) + } + }, + }) + setArtifact(next) + setProgress({ message: 'Done.' }) + } catch (e) { + setError(e instanceof Error ? (e.stack ?? e.message) : String(e)) + setProgress(undefined) + } finally { + setRunning(false) + } + } + + return ( +
+
+
+
CIP-103 Conformance Runner
+
+ Runs conformance checks against the currently connected + wallet provider. +
+
+
+ {connected ? ( + + ) : ( + + )} + +
+
+ +
+
+
+ Status + + {connected ? 'Connected' : 'Not connected'} + +
+ +
+ Profile + +
+
+ +
+ + +
+ + {running && progress && ( +
+
Progress
+
{progress.message}
+ {(progress.current !== undefined || + progress.total !== undefined || + progress.lastId) && ( +
+ {progress.phase ? `[${progress.phase}] ` : ''} + {progress.current !== undefined && + progress.total !== undefined + ? `${progress.current}/${progress.total} ` + : ''} + {progress.lastId ? `${progress.lastId} ` : ''} + {progress.lastStatus + ? `(${progress.lastStatus})` + : ''} +
+ )} +
+ )} + + {error && ( +
+
Error
+
{error}
+
+ )} +
+ + {artifact && ( +
+
Summary
+
+
+ Overall + + {artifact.summary.status.toUpperCase()} + +
+
+ Passed + + {artifact.summary.passed} + +
+
+ Failed + + {artifact.summary.failed} + +
+
+ Skipped + + {artifact.summary.skipped} + +
+
+ Total + {artifact.summary.total} +
+
+ +
+ Results ({artifact.results.length}) +
+ {artifact.results.map((r) => ( +
+
+
{r.id}
+
+ {r.title} +
+
+
+ + {r.status} + + + {r.elapsedMs}ms + +
+ {r.details && ( +
+ {r.details} +
+ )} +
+ Request +
+                                            {JSON.stringify(
+                                                observedRequests[r.id] ?? null,
+                                                null,
+                                                2
+                                            )}
+                                        
+
+
+ Observed response +
+                                            {JSON.stringify(
+                                                observedResponses[r.id] ?? null,
+                                                null,
+                                                2
+                                            )}
+                                        
+
+
+ ))} +
+
+
+ )} +
+ ) +} diff --git a/examples/conformance-runner/src/conformance/runner.ts b/examples/conformance-runner/src/conformance/runner.ts new file mode 100644 index 000000000..ecbb6ff24 --- /dev/null +++ b/examples/conformance-runner/src/conformance/runner.ts @@ -0,0 +1,341 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ErrorResponse, JsonRpcResponse } from '@canton-network/core-types' + +import type { ConformanceTransport } from './transport' + +function rpcError( + response: JsonRpcResponse +): ErrorResponse['error'] | undefined { + return 'error' in response ? response.error : undefined +} + +export type Profile = 'sync' | 'async' +export type TestStatus = 'pass' | 'fail' | 'skip' + +export type TestCategory = 'protocol' | 'schema' | 'behavior' + +export type TestResult = { + id: string + title: string + category: TestCategory + status: TestStatus + elapsedMs: number + details?: string | undefined +} + +export type ArtifactProvider = { + name: string + version: string + transport: 'injected' + appUrl?: string | undefined +} + +export type ArtifactSummary = { + total: number + passed: number + failed: number + skipped: number + status: 'pass' | 'fail' +} + +export type Artifact = { + schemaVersion: 1 + suite: 'cip-103-conformance' + profile: Profile + provider: ArtifactProvider + generatedAt: string + summary: ArtifactSummary + results: TestResult[] +} + +function nowIso(): string { + return new Date().toISOString() +} + +function duration(startMs: number): number { + return Math.max(0, Date.now() - startMs) +} + +function makeResult( + id: string, + title: string, + category: TestCategory, + status: TestStatus, + elapsedMs: number, + details?: string +): TestResult { + return { id, title, category, status, elapsedMs, details } +} + +export type ConformanceProviderMeta = { + name: string + version: string + transport?: 'injected' | undefined + appUrl?: string | undefined +} + +/** What the harness sent to the wallet for a given probe (for UI / debugging). */ +export type CapturedRequest = { + method: string + params: unknown +} + +/** JSON-RPC standard: method not found. */ +const JSON_RPC_METHOD_NOT_FOUND = -32601 +/** Used by some providers when the method is known but not supported for this wallet/session. */ +const METHOD_NOT_SUPPORTED = -32004 + +function isMissingOrUnsupportedMethod( + error?: ErrorResponse['error'] | null +): boolean { + const c = error?.code + return c === JSON_RPC_METHOD_NOT_FOUND || c === METHOD_NOT_SUPPORTED +} + +function schemaProbeParams(methodName: string): unknown { + // Some methods require non-empty params; probing them with {} can trigger + // server-side errors that are artifacts of the test harness rather than the + // provider implementation. + switch (methodName) { + case 'ledgerApi': + return { + requestMethod: 'get', + resource: '/v2/version', + } + default: + return {} + } +} + +function summarize(results: TestResult[]): ArtifactSummary { + const passed = results.filter((r) => r.status === 'pass').length + const failed = results.filter((r) => r.status === 'fail').length + const skipped = results.filter((r) => r.status === 'skip').length + const total = results.length + return { + total, + passed, + failed, + skipped, + status: failed > 0 ? 'fail' : 'pass', + } +} + +export async function runConformanceAgainstConnectedProvider(args: { + profile: Profile + transport: ConformanceTransport + requiredMethods: string[] + providerMeta: ConformanceProviderMeta + onProgress?: + | ((event: { + phase: 'protocol' | 'schema' | 'behavior' | 'finalize' + current?: number | undefined + total?: number | undefined + message: string + lastResult?: TestResult | undefined + lastResponse?: JsonRpcResponse | null | undefined + lastRequest?: CapturedRequest | null | undefined + }) => void) + | undefined +}): Promise { + const { profile, transport, requiredMethods, providerMeta, onProgress } = + args + try { + const results: TestResult[] = [] + + onProgress?.({ + phase: 'protocol', + message: 'Running protocol checks…', + }) + { + const start = Date.now() + const req001: CapturedRequest = { + method: '__cip103_unknown_method__', + params: {}, + } + const response = await transport.request( + req001.method, + req001.params + ) + const err = rpcError(response) + const pass = err?.code === JSON_RPC_METHOD_NOT_FOUND + const r = makeResult( + 'CIP103-RPC-001', + 'Unknown method returns method-not-found', + 'protocol', + pass ? 'pass' : 'fail', + duration(start), + pass + ? undefined + : `Expected ${JSON_RPC_METHOD_NOT_FOUND}, got ${err?.code ?? 'no error'}` + ) + results.push(r) + onProgress?.({ + phase: 'protocol', + message: r.title, + lastResult: r, + lastResponse: response, + lastRequest: req001, + }) + } + + { + const start = Date.now() + const response = await transport.requestInvalidEnvelope() + if (!response) { + const r = makeResult( + 'CIP103-RPC-002', + 'Invalid JSON-RPC request returns error', + 'protocol', + 'skip', + duration(start), + 'Skipped: connected provider does not expose raw JSON-RPC envelope' + ) + results.push(r) + onProgress?.({ + phase: 'protocol', + message: r.title, + lastResult: r, + lastResponse: null, + lastRequest: { + method: '(skipped)', + params: { + reason: 'Connected transport does not expose a raw malformed JSON-RPC envelope', + }, + }, + }) + } else { + const pass = 'error' in response && Boolean(response.error) + const r = makeResult( + 'CIP103-RPC-002', + 'Invalid JSON-RPC request returns error', + 'protocol', + pass ? 'pass' : 'fail', + duration(start), + pass + ? undefined + : 'Expected JSON-RPC error for malformed request' + ) + results.push(r) + onProgress?.({ + phase: 'protocol', + message: r.title, + lastResult: r, + lastResponse: response, + lastRequest: { + method: '(malformed JSON-RPC envelope)', + params: { note: 'Non-standard payload per transport' }, + }, + }) + } + } + + onProgress?.({ + phase: 'schema', + current: 0, + total: requiredMethods.length, + message: `Checking method presence (${requiredMethods.length})…`, + }) + let i = 0 + for (const methodName of requiredMethods) { + onProgress?.({ + phase: 'schema', + current: i, + total: requiredMethods.length, + message: `Requesting ${methodName}…`, + }) + i += 1 + const start = Date.now() + const probeParams = schemaProbeParams(methodName) + const reqSchema: CapturedRequest = { + method: methodName, + params: probeParams, + } + const response = await transport.request( + reqSchema.method, + reqSchema.params + ) + const err = rpcError(response) + const pass = !isMissingOrUnsupportedMethod(err) + const r = makeResult( + `CIP103-SCHEMA-${methodName}`, + `Method '${methodName}' is implemented`, + 'schema', + pass ? 'pass' : 'fail', + duration(start), + pass + ? undefined + : `Method missing or unsupported (expected neither ${JSON_RPC_METHOD_NOT_FOUND} nor ${METHOD_NOT_SUPPORTED}; got ${err?.code ?? 'no error'})` + ) + results.push(r) + onProgress?.({ + phase: 'schema', + current: i, + total: requiredMethods.length, + message: `Checked ${methodName}`, + lastResult: r, + lastResponse: response, + lastRequest: reqSchema, + }) + } + + onProgress?.({ + phase: 'behavior', + message: 'Running behavior smoke checks…', + }) + { + const start = Date.now() + const reqConnect: CapturedRequest = { + method: 'connect', + params: {}, + } + const response = await transport.request( + reqConnect.method, + reqConnect.params + ) + const err = rpcError(response) + const pass = !isMissingOrUnsupportedMethod(err) + const r = makeResult( + profile === 'sync' ? 'CIP103-BEH-001' : 'CIP103-BEH-101', + "Provider exposes 'connect' lifecycle method", + 'behavior', + pass ? 'pass' : 'fail', + duration(start), + pass + ? undefined + : `'connect' missing or not supported (code ${err?.code ?? 'n/a'})` + ) + results.push(r) + onProgress?.({ + phase: 'behavior', + message: r.title, + lastResult: r, + lastResponse: response, + lastRequest: reqConnect, + }) + } + + onProgress?.({ + phase: 'finalize', + message: 'Finalizing report…', + }) + return { + schemaVersion: 1, + suite: 'cip-103-conformance', + profile, + provider: { + name: providerMeta.name, + version: providerMeta.version, + transport: 'injected', + appUrl: providerMeta.appUrl, + }, + generatedAt: nowIso(), + summary: summarize(results), + results, + } + } finally { + await transport.close() + } +} diff --git a/examples/conformance-runner/src/conformance/transport.ts b/examples/conformance-runner/src/conformance/transport.ts new file mode 100644 index 000000000..6a965987a --- /dev/null +++ b/examples/conformance-runner/src/conformance/transport.ts @@ -0,0 +1,98 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ErrorResponse, JsonRpcResponse } from '@canton-network/core-types' + +const DEFAULT_CODE = -32001 + +function normalizeUnknownError(error: unknown): ErrorResponse['error'] { + if (typeof error === 'object' && error !== null) { + const o = error as Record + const inner = o.error + if (typeof inner === 'object' && inner !== null) { + const n = inner as Record + if (typeof n.code === 'number' && typeof n.message === 'string') { + const data = n.data + const message = + typeof data === 'string' && data.trim() + ? `${n.message}\n\n${data}` + : n.message + return { code: n.code, message } + } + } + if (typeof o.code === 'number' && typeof o.message === 'string') { + return { code: o.code, message: o.message } + } + if (typeof o.message === 'string') { + return { + code: typeof o.code === 'number' ? o.code : DEFAULT_CODE, + message: o.message, + } + } + try { + return { code: DEFAULT_CODE, message: JSON.stringify(error) } + } catch { + // fall through + } + } + return { + code: DEFAULT_CODE, + message: error instanceof Error ? error.message : String(error), + } +} + +export interface ConformanceTransport { + request(method: string, params: unknown): Promise + requestInvalidEnvelope(): Promise + close(): Promise +} + +function withTimeout(promise: Promise, timeoutMs: number): Promise { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return promise + return new Promise((resolve, reject) => { + const t = setTimeout(() => { + reject(new Error(`Timeout after ${timeoutMs}ms`)) + }, timeoutMs) + promise.then( + (v) => { + clearTimeout(t) + resolve(v) + }, + (e) => { + clearTimeout(t) + reject(e) + } + ) + }) +} + +export type ConnectedProvider = { + request(args: { method: string; params?: unknown }): Promise +} + +export function createConnectedProviderTransport( + provider: ConnectedProvider, + options?: { timeoutMs?: number | undefined } +): ConformanceTransport { + const timeoutMs = options?.timeoutMs ?? 15000 + return { + async request( + method: string, + params: unknown + ): Promise { + try { + const result = await withTimeout( + provider.request({ method, params }), + timeoutMs + ) + return { jsonrpc: '2.0', result } + } catch (error) { + return { jsonrpc: '2.0', error: normalizeUnknownError(error) } + } + }, + async requestInvalidEnvelope(): Promise { + return null + }, + async close(): Promise {}, + } +} diff --git a/examples/conformance-runner/src/main.tsx b/examples/conformance-runner/src/main.tsx new file mode 100644 index 000000000..b6f0a0059 --- /dev/null +++ b/examples/conformance-runner/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import App from './App' +import './styles.css' + +createRoot(document.getElementById('root')!).render( + + + +) diff --git a/examples/conformance-runner/src/styles.css b/examples/conformance-runner/src/styles.css new file mode 100644 index 000000000..9d5aaa5bc --- /dev/null +++ b/examples/conformance-runner/src/styles.css @@ -0,0 +1,272 @@ +:root { + color-scheme: light dark; + font-family: + ui-sans-serif, + system-ui, + -apple-system, + Segoe UI, + Roboto, + Helvetica, + Arial, + 'Apple Color Emoji', + 'Segoe UI Emoji'; +} + +body { + margin: 0; + background: #0b0f17; + color: #e6eaf2; +} + +button, +select { + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.06); + color: inherit; + padding: 10px 12px; +} + +button:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +select { + padding: 8px 10px; +} + +.page { + max-width: 1000px; + margin: 0 auto; + padding: 28px 18px 60px; +} + +.header { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-start; + margin-bottom: 16px; +} + +.title { + font-size: 22px; + font-weight: 700; + letter-spacing: 0.2px; +} + +.subtitle { + margin-top: 4px; + color: rgba(230, 234, 242, 0.7); +} + +.headerActions { + display: flex; + gap: 10px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.card { + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.04); + border-radius: 14px; + padding: 14px; + margin-top: 14px; +} + +.row { + display: flex; + gap: 10px; + flex-wrap: wrap; + align-items: center; + margin-top: 10px; +} + +.pill { + display: inline-flex; + gap: 10px; + align-items: center; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.03); + padding: 8px 10px; + border-radius: 999px; +} + +.pillLabel { + color: rgba(230, 234, 242, 0.7); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.12em; +} + +.sectionTitle { + font-weight: 700; +} + +.mono { + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + 'Liberation Mono', 'Courier New', monospace; +} + +.muted { + color: rgba(230, 234, 242, 0.65); +} + +.ok { + color: #68f0b2; +} + +.bad { + color: #ff7a8a; +} + +.tag { + border-radius: 999px; + padding: 4px 10px; + border: 1px solid rgba(255, 255, 255, 0.12); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.tag.ok { + background: rgba(104, 240, 178, 0.12); + border-color: rgba(104, 240, 178, 0.25); +} + +.tag.bad { + background: rgba(255, 122, 138, 0.12); + border-color: rgba(255, 122, 138, 0.25); +} + +.tag.muted { + background: rgba(230, 234, 242, 0.08); +} + +.errorBox { + margin-top: 12px; + padding: 12px; + border-radius: 12px; + border: 1px solid rgba(255, 122, 138, 0.35); + background: rgba(255, 122, 138, 0.08); +} + +.errorTitle { + font-weight: 700; + margin-bottom: 8px; +} + +.progressBox { + margin-top: 12px; + padding: 12px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.16); + background: rgba(255, 255, 255, 0.05); +} + +.progressTitle { + font-weight: 700; + margin-bottom: 8px; +} + +.details { + margin-top: 12px; +} + +.results { + margin-top: 10px; + display: grid; + gap: 10px; +} + +.resultRow { + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(0, 0, 0, 0.14); + border-radius: 12px; + padding: 10px; +} + +.resultLeft { + display: grid; + gap: 4px; +} + +.resultTitle { + color: rgba(230, 234, 242, 0.85); +} + +.resultRight { + margin-top: 10px; + display: flex; + gap: 10px; + align-items: center; +} + +.resultDetails { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid rgba(255, 255, 255, 0.08); + color: rgba(230, 234, 242, 0.88); + white-space: pre-wrap; +} + +.observed { + margin-top: 10px; +} + +.observedPre { + margin: 10px 0 0; + padding: 10px; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(0, 0, 0, 0.14); + overflow: auto; + max-height: 260px; +} + +@media (prefers-color-scheme: light) { + body { + background: #f5f7fb; + color: #0f172a; + } + .resultTitle { + color: rgba(15, 23, 42, 0.88); + } + .resultDetails, + .resultDetails.mono { + color: rgba(15, 23, 42, 0.9); + } + .observedPre { + color: rgba(15, 23, 42, 0.92); + } + .subtitle, + .muted, + .pillLabel { + color: rgba(15, 23, 42, 0.65); + } + .card, + button, + select, + .pill, + .tag, + .resultRow, + .observedPre { + border-color: rgba(15, 23, 42, 0.12); + background: rgba(15, 23, 42, 0.03); + } + .tag.ok { + background: rgba(16, 185, 129, 0.12); + border-color: rgba(16, 185, 129, 0.25); + } + .tag.bad { + background: rgba(239, 68, 68, 0.12); + border-color: rgba(239, 68, 68, 0.25); + } + .errorBox { + border-color: rgba(239, 68, 68, 0.28); + background: rgba(239, 68, 68, 0.08); + } +} diff --git a/examples/conformance-runner/tsconfig.app.json b/examples/conformance-runner/tsconfig.app.json new file mode 100644 index 000000000..ce3558bea --- /dev/null +++ b/examples/conformance-runner/tsconfig.app.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "types": ["vite/client"], + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/examples/conformance-runner/tsconfig.json b/examples/conformance-runner/tsconfig.json new file mode 100644 index 000000000..f6df6c7c5 --- /dev/null +++ b/examples/conformance-runner/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/examples/conformance-runner/tsconfig.node.json b/examples/conformance-runner/tsconfig.node.json new file mode 100644 index 000000000..802fd6bd5 --- /dev/null +++ b/examples/conformance-runner/tsconfig.node.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/examples/conformance-runner/vite.config.ts b/examples/conformance-runner/vite.config.ts new file mode 100644 index 000000000..7dc924bf5 --- /dev/null +++ b/examples/conformance-runner/vite.config.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + port: 8082, + proxy: { + // Avoid CORS when connecting to the local Wallet Gateway during dev. + // The dApp calls same-origin `/api/v0/dapp`, Vite forwards to the gateway. + '/api': { + target: 'http://localhost:3030', + changeOrigin: true, + }, + }, + }, +}) diff --git a/package.json b/package.json index f5d4d7414..f4e0277cc 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,9 @@ "script:test:examples": "yarn node --trace-uncaught --enable-source-maps --import tsx ./scripts/src/test-example-scripts.ts", "script:test:examples-stress": "tsx ./scripts/src/test-examples-scripts-under-stress.ts", "script:test:stress-scripts": "tsx ./scripts/src/test-stress-scripts.ts", + "script:conformance:run": "yarn workspace @canton-network/cip103-conformance run run", + "script:conformance:validate-artifact": "yarn workspace @canton-network/cip103-conformance run validate-artifact", + "script:conformance:export-badge": "yarn workspace @canton-network/cip103-conformance run export-badge", "script:release": "tsx ./scripts/src/release.ts", "script:retag": "tsx ./scripts/src/retag.ts", "script:flat-pack": "tsx ./scripts/src/flat-pack.ts", @@ -55,6 +58,7 @@ "example-scripts", "sdk/**", "sdk-support/**", + "tools/**", "scripts", "mock-oauth2", "wallet-gateway/**" diff --git a/sdk/dapp-sdk/src/sdk-controller.ts b/sdk/dapp-sdk/src/sdk-controller.ts index c89126fae..3890f2684 100644 --- a/sdk/dapp-sdk/src/sdk-controller.ts +++ b/sdk/dapp-sdk/src/sdk-controller.ts @@ -9,6 +9,7 @@ import { Network, PrepareExecuteAndWaitResult, PrepareExecuteParams, + SignMessageParams, SignMessageResult, Wallet, } from './dapp-api/rpc-gen/typings' @@ -142,15 +143,19 @@ export const dappSDKController = (provider: DappAsyncProvider) => txChanged: async () => { throw new Error('Only for events.') }, - getActiveNetwork: function (): Promise { - throw new Error('Function not implemented.') - }, - signMessage: function (): Promise { - throw new Error('Function not implemented.') - }, - getPrimaryAccount: function (): Promise { - return provider.request({ + getActiveNetwork: async (): Promise => + provider.request({ + method: 'getActiveNetwork', + }), + signMessage: async ( + params: SignMessageParams + ): Promise => + provider.request({ + method: 'signMessage', + params, + }), + getPrimaryAccount: async (): Promise => + provider.request({ method: 'getPrimaryAccount', - }) - }, + }), }) diff --git a/tools/cip103-conformance/.gitignore b/tools/cip103-conformance/.gitignore new file mode 100644 index 000000000..7d0390754 --- /dev/null +++ b/tools/cip103-conformance/.gitignore @@ -0,0 +1 @@ +*-result.json diff --git a/tools/cip103-conformance/README.md b/tools/cip103-conformance/README.md new file mode 100644 index 000000000..e0db81bb3 --- /dev/null +++ b/tools/cip103-conformance/README.md @@ -0,0 +1,224 @@ +# CIP-103 Conformance CLI + +Self-serve conformance checks for wallet providers against CIP-103 sync/async profiles. +The CLI is built with `commander` and provides strict option validation. + +## Installation + +Install the CLI globally (or run it with your preferred package runner): + +```bash +npm install -g @canton-network/cip103-conformance +``` + +The package bundles the required OpenRPC specs internally, so users do not need a local `api-specs` folder. + +## Commands + +### `run` + +Runs the conformance suite against a provider and writes a result artifact JSON. + +```bash +conformance-cli run --profile sync|async --provider-config [--out ] [--signing-key ] [--key-id ] +``` + +- required: + - `--profile`: `sync` or `async` + - `--provider-config`: path to provider config JSON +- optional: + - `--out`: artifact output path (default: `dist/conformance/result.json`) + - `--signing-key`: Ed25519 private key PEM to sign artifact + - `--key-id`: key identifier embedded in `artifact.signature.keyId` (requires `--signing-key`) + +Example: + +```bash +conformance-cli run --profile async --provider-config provider.config.remote.example.json --out dist/conformance/remote-result.json +``` + +### `validate-artifact` + +Validates artifact schema, and optionally validates signature. + +```bash +conformance-cli validate-artifact --artifact [--public-key ] [--require-signature] +``` + +- required: + - `--artifact`: path to generated result artifact +- optional: + - `--public-key`: Ed25519 public key PEM for signature verification + - `--require-signature`: fail if artifact is unsigned + +Examples: + +```bash +conformance-cli validate-artifact --artifact dist/conformance/remote-result.json +conformance-cli validate-artifact --artifact dist/conformance/remote-result.json --public-key ./keys/provider.pub.pem --require-signature +``` + +### `export-badge` + +Generates badge JSON (`pass`/`fail`) from an artifact. + +```bash +conformance-cli export-badge --artifact [--out ] +``` + +- required: + - `--artifact`: path to generated result artifact +- optional: + - `--out`: badge output path (default: `dist/conformance/badge.json`) + +Example: + +```bash +conformance-cli export-badge --artifact dist/conformance/remote-result.json --out dist/conformance/remote-badge.json +``` + +## Example Provider Config + +```json +{ + "name": "Example Wallet Provider", + "version": "1.2.3", + "transport": "http", + "endpoint": "http://localhost:8081/json-rpc", + "timeoutMs": 10000 +} +``` + +## Example Provider Config (Server-Side Wallet) + +Use this for remote wallet gateways or any server-hosted JSON-RPC provider. +In many setups, an Authorization header is required. + +```json +{ + "name": "Example Server-Side Wallet", + "version": "1.2.3", + "transport": "http", + "endpoint": "https://wallet.example.com/api/v0/dapp", + "timeoutMs": 15000, + "headers": { + "authorization": "Bearer " + } +} +``` + +See `provider.config.remote.example.json` for a copy-ready template. + +## Example Provider Config (Browser Extension Wallet) + +Use injected mode to call the extension provider directly in a browser context. +The runner opens `appUrl` and resolves the provider in one of two ways: + +- **Namespaced injection (recommended when available)**: set `providerId` (e.g. `"canton.console"`) to resolve `window.canton.console`. +- **Targeted postMessage (recommended for multiple installed extensions)**: set `extensionTarget` to route JSON-RPC requests deterministically to a specific extension. +- **Legacy**: set `injectedNamespace` (defaults to `"window.canton"`). + +Resolution precedence: `providerId` > `injectedNamespace`. + +```json +{ + "name": "Example Browser Extension Wallet", + "version": "1.0.0", + "transport": "injected", + "appUrl": "http://localhost:8080", + "providerId": "canton.", + "extensionPath": "/absolute/path/to/unpacked-extension", + "headless": false, + "timeoutMs": 10000 +} +``` + +### Example (Injected, targeted extension selection) + +Use this when multiple extensions might otherwise compete for `window.postMessage` requests. +Set `extensionTarget` to the extension identifier used by the wallet (for Splice Wallet Gateway extension this is the browser runtime id). + +```json +{ + "name": "Example Browser Extension Wallet (Targeted)", + "version": "1.0.0", + "transport": "injected", + "appUrl": "http://localhost:8080", + "extensionTarget": "", + "extensionPath": "/absolute/path/to/unpacked-extension", + "headless": false, + "timeoutMs": 10000 +} +``` + +### Example (Injected, legacy namespace) + +```json +{ + "name": "Example Browser Extension Wallet (Legacy)", + "version": "1.0.0", + "transport": "injected", + "appUrl": "http://localhost:8080", + "injectedNamespace": "window.canton", + "extensionPath": "/absolute/path/to/unpacked-extension", + "headless": false, + "timeoutMs": 10000 +} +``` + +If your setup still uses an HTTP bridge for extensions, set `transport: "http"` and provide `endpoint`. + +See `provider.config.browser-extension.example.json` for a copy-ready template. + +## Profile Mapping + +- `sync` -> bundled `openrpc-dapp-api.json` +- `async` -> bundled `openrpc-dapp-remote-api.json` + +## Result format + +This section refers to the **generated conformance artifact JSON** written by `conformance-cli run` (the file at `--out`, defaulting to `dist/conformance/result.json`). + +Inside that JSON, each test produces an entry in the `results` array with a stable identifier at `results[].id`: + +```json +{ + "results": [ + { + "id": "CIP103-RPC-001", + "status": "pass", + "title": "…", + "category": "protocol" + } + ] +} +``` + +Use these IDs to: + +- link to a specific failing check in CI logs +- build allow/deny lists in downstream tooling +- aggregate results across many runs/providers + +### Protocol + +- **`CIP103-RPC-001`**: Unknown method returns JSON-RPC “method not found” (`-32601`). +- **`CIP103-RPC-002`**: Invalid JSON-RPC request returns an error response (may be `skip` for injected transport which doesn't expose raw envelopes). + +### Schema + +- **`CIP103-SCHEMA-`**: Existence probe for each required OpenRPC method in the selected profile. + Example: `CIP103-SCHEMA-listAccounts`. The check **fails** if the wallet returns JSON-RPC **`-32601` (method not found)** or **`-32004` (method not supported)**; other errors still count as “implemented” (the method is routed) but may reflect auth, params, or runtime state. + +### Behavior + +- **`CIP103-BEH-001`**: Sync profile smoke test that the provider exposes the `connect` lifecycle method. +- **`CIP103-BEH-101`**: Async profile smoke test that the provider exposes the `connect` lifecycle method. + +## Conformance runner dApp (connected provider) + +For browser/manual testing you can run the conformance suite directly against the **currently connected** wallet provider (no Playwright, no `extensionPath`) using the example dApp: + +- `examples/conformance-runner` + +This dApp produces an artifact JSON with the **same top-level shape** as the CLI output, so you can drop the downloaded file next to your other artifacts and reuse `generate-report.mjs` to build the HTML report site. diff --git a/tools/cip103-conformance/conformance-report.html b/tools/cip103-conformance/conformance-report.html new file mode 100644 index 000000000..f38ce3d66 --- /dev/null +++ b/tools/cip103-conformance/conformance-report.html @@ -0,0 +1,726 @@ + + + + + + CIP-103 Conformance Report + + + +
+
+

CIP-103 Conformance Report

+
+ Generated 2026-03-31T11:25:49.453Z • Source: + tools/cip103-conformance/result-*.json +
+
+ +
+
+ + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
WalletOverallProfileMethods coveredArtifact
+
+ Example Wallet Provider +
+
+ http • http://localhost:8081/json-rpc +
+
FAILsync12/12result-console.json + +
+
+ Wallet Gateway (Remote) +
+
+ http • http://localhost:3030/api/v0/dapp +
+
FAILsync12/12 + result-wallet-gateway-remote.json + + +
+
+
+ + + + diff --git a/tools/cip103-conformance/generate-report.mjs b/tools/cip103-conformance/generate-report.mjs new file mode 100644 index 000000000..5c1458a99 --- /dev/null +++ b/tools/cip103-conformance/generate-report.mjs @@ -0,0 +1,313 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from 'node:fs' +import path from 'node:path' +import url from 'node:url' + +const __dirname = path.dirname(url.fileURLToPath(import.meta.url)) + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')) +} + +function escapeHtml(s) { + return String(s) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +function normalizeStatus(s) { + if (s === 'pass' || s === 'fail' || s === 'skip') return s + return 'fail' +} + +function statusBadge(status) { + const cls = + status === 'pass' + ? 'badge pass' + : status === 'skip' + ? 'badge skip' + : 'badge fail' + return `${escapeHtml(status.toUpperCase())}` +} + +function summarizeMethods(results) { + // schema tests are of the form CIP103-SCHEMA- + const methods = new Map() + for (const r of results) { + if (!r?.id || typeof r.id !== 'string') continue + if (!r.id.startsWith('CIP103-SCHEMA-')) continue + const method = r.id.slice('CIP103-SCHEMA-'.length) + if (!method) continue + methods.set(method, normalizeStatus(r.status)) + } + const all = Array.from(methods.entries()).sort(([a], [b]) => + a.localeCompare(b) + ) + const covered = all.filter(([, st]) => st === 'pass').length + return { all, covered, total: all.length } +} + +function groupByCategory(results) { + const groups = new Map() + for (const r of results) { + const cat = r?.category ?? 'unknown' + if (!groups.has(cat)) groups.set(cat, []) + groups.get(cat).push(r) + } + for (const [, arr] of groups) { + arr.sort((a, b) => String(a.id).localeCompare(String(b.id))) + } + return groups +} + +const resultFiles = fs + .readdirSync(__dirname) + .filter((f) => f.startsWith('result-') && f.endsWith('.json')) + .map((f) => path.join(__dirname, f)) + +const artifacts = resultFiles.map((filePath) => { + const artifact = readJson(filePath) + return { + fileName: path.basename(filePath), + filePath, + suite: artifact.suite, + profile: artifact.profile, + provider: artifact.provider, + generatedAt: artifact.generatedAt, + summary: artifact.summary, + results: artifact.results ?? [], + } +}) + +artifacts.sort((a, b) => + String(a.provider?.name ?? a.fileName).localeCompare( + String(b.provider?.name ?? b.fileName) + ) +) + +const generatedAt = new Date().toISOString() +const outPath = path.join(__dirname, 'conformance-report.html') + +const rowsHtml = artifacts + .map((a, idx) => { + const providerName = a.provider?.name ?? a.fileName + const providerTransport = a.provider?.transport ?? 'unknown' + const providerEndpoint = + a.provider?.endpoint ?? a.provider?.appUrl ?? '' + const overall = a.summary?.status ?? 'fail' + const methods = summarizeMethods(a.results) + const groups = groupByCategory(a.results) + + const methodsHtml = + methods.total === 0 + ? 'No schema checks found.' + : `${methods.covered}/${methods.total}` + + const detailId = `detail-${idx}` + const compactChecks = ['protocol', 'behavior', 'stability'] + .flatMap((cat) => (groups.get(cat) ?? []).map((r) => r)) + .map((r) => { + const st = normalizeStatus(r.status) + return ` + ${escapeHtml(r.id)} + ${escapeHtml(r.title ?? '')} + ${escapeHtml(r.category ?? '')} + ${statusBadge(st)} + ${escapeHtml(r.details ?? '')} +` + }) + .join('\n') + + const methodRows = methods.all + .map(([m, st]) => { + return ` + ${escapeHtml(m)} + ${statusBadge(st)} +` + }) + .join('\n') + + return ` + + +
${escapeHtml(providerName)}
+
${escapeHtml(providerTransport)} ${providerEndpoint ? '• ' + escapeHtml(providerEndpoint) : ''}
+ + ${statusBadge(overall)} + ${escapeHtml(a.profile ?? '')} + ${methodsHtml} + ${escapeHtml(a.fileName)} + + + + +
+
+

Method coverage (schema)

+ + + + ${methodRows || ''} + +
MethodStatus
No schema checks.
+
+
+

Other checks

+ + + + ${compactChecks || ''} + +
IDTitleCategoryStatusDetails
No checks.
+
+
+ + +` + }) + .join('\n') + +const html = ` + + + + + CIP-103 Conformance Report + + + +
+
+

CIP-103 Conformance Report

+
Generated ${escapeHtml(generatedAt)} • Source: tools/cip103-conformance/result-*.json
+
+ +
+
+ + + +
+
+ +
+ + + + + + + + + + + + ${rowsHtml} +
WalletOverallProfileMethods coveredArtifact
+
+
+ + + + +` + +fs.writeFileSync(outPath, html, 'utf8') +console.log(`Wrote ${outPath}`) diff --git a/tools/cip103-conformance/package.json b/tools/cip103-conformance/package.json new file mode 100644 index 000000000..8d1d9fe4a --- /dev/null +++ b/tools/cip103-conformance/package.json @@ -0,0 +1,58 @@ +{ + "name": "@canton-network/cip103-conformance", + "version": "0.1.0", + "type": "module", + "description": "Self-serve CIP-103 conformance CLI for sync/async wallet provider profiles.", + "license": "Apache-2.0", + "author": "Digital Asset", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "conformance-cli": "./dist/cli.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs", + "default": "./dist/index.js" + }, + "./browser": { + "types": "./dist/browser.d.ts", + "import": "./dist/browser.js", + "require": "./dist/browser.cjs", + "default": "./dist/browser.js" + } + }, + "scripts": { + "build": "tsup && tsc -p tsconfig.types.json && yarn postbuild", + "postbuild": "mkdir -p dist/specs && cp ../../api-specs/openrpc-dapp-api.json ../../api-specs/openrpc-dapp-remote-api.json dist/specs/", + "dev": "tsup --watch --onSuccess \"tsc -p tsconfig.types.json\"", + "clean": "tsc -b --clean; rm -rf dist", + "run": "tsx src/cli.ts", + "report:html": "node ./generate-report.mjs" + }, + "dependencies": { + "commander": "^14.0.3", + "playwright": "^1.58.2", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/node": "^25.3.3", + "tsup": "^8.5.1", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + }, + "files": [ + "dist/**" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/hyperledger-labs/splice-wallet-kernel.git", + "directory": "tools/cip103-conformance" + } +} diff --git a/tools/cip103-conformance/provider.config.bron.json b/tools/cip103-conformance/provider.config.bron.json new file mode 100644 index 000000000..22cc9f3f2 --- /dev/null +++ b/tools/cip103-conformance/provider.config.bron.json @@ -0,0 +1,10 @@ +{ + "name": "Bron (Enterprise)", + "version": "unknown", + "transport": "http", + "endpoint": "https://bron.example.com/api/json-rpc", + "timeoutMs": 15000, + "headers": { + "authorization": "Bearer " + } +} diff --git a/tools/cip103-conformance/provider.config.browser-extension.example.json b/tools/cip103-conformance/provider.config.browser-extension.example.json new file mode 100644 index 000000000..b1855560c --- /dev/null +++ b/tools/cip103-conformance/provider.config.browser-extension.example.json @@ -0,0 +1,10 @@ +{ + "name": "Example Browser Extension Wallet", + "version": "1.0.0", + "transport": "injected", + "appUrl": "http://localhost:8080", + "injectedNamespace": "canton.", + "extensionPath": "/absolute/path/to/unpacked-extension", + "headless": false, + "timeoutMs": 10000 +} diff --git a/tools/cip103-conformance/provider.config.cantor8.json b/tools/cip103-conformance/provider.config.cantor8.json new file mode 100644 index 000000000..6bf022760 --- /dev/null +++ b/tools/cip103-conformance/provider.config.cantor8.json @@ -0,0 +1,10 @@ +{ + "name": "Cantor8 (C8)", + "version": "unknown", + "transport": "http", + "endpoint": "http://localhost:8081/json-rpc", + "timeoutMs": 15000, + "headers": { + "authorization": "Bearer " + } +} diff --git a/tools/cip103-conformance/provider.config.console.json b/tools/cip103-conformance/provider.config.console.json new file mode 100644 index 000000000..781611afd --- /dev/null +++ b/tools/cip103-conformance/provider.config.console.json @@ -0,0 +1,10 @@ +{ + "name": "Console Wallet", + "version": "unknown", + "transport": "injected", + "appUrl": "http://localhost:8080", + "providerId": "canton.console", + "extensionPath": "/absolute/path/to/unpacked-console-wallet-extension", + "headless": false, + "timeoutMs": 10000 +} diff --git a/tools/cip103-conformance/provider.config.loop.json b/tools/cip103-conformance/provider.config.loop.json new file mode 100644 index 000000000..857b8b1aa --- /dev/null +++ b/tools/cip103-conformance/provider.config.loop.json @@ -0,0 +1,10 @@ +{ + "name": "5N Loop Wallet", + "version": "unknown", + "transport": "http", + "endpoint": "http://localhost:8081/json-rpc", + "timeoutMs": 15000, + "headers": { + "authorization": "Bearer " + } +} diff --git a/tools/cip103-conformance/provider.config.nightly.json b/tools/cip103-conformance/provider.config.nightly.json new file mode 100644 index 000000000..fa65a5414 --- /dev/null +++ b/tools/cip103-conformance/provider.config.nightly.json @@ -0,0 +1,10 @@ +{ + "name": "Nightly Wallet", + "version": "unknown", + "transport": "injected", + "appUrl": "http://localhost:8080", + "providerId": "canton.nightly", + "extensionPath": "/absolute/path/to/unpacked-nightly-extension", + "headless": false, + "timeoutMs": 10000 +} diff --git a/tools/cip103-conformance/provider.config.remote.example.json b/tools/cip103-conformance/provider.config.remote.example.json new file mode 100644 index 000000000..862559f95 --- /dev/null +++ b/tools/cip103-conformance/provider.config.remote.example.json @@ -0,0 +1,10 @@ +{ + "name": "Example Wallet Provider", + "version": "0.0.0", + "transport": "http", + "endpoint": "http://localhost:8081/json-rpc", + "timeoutMs": 10000, + "headers": { + "authorization": "Bearer eyJ0eXAiOiJKV1QiLCJraWQiOiJmZGRlZjcyN2M1ZWNlNzU3NmQyYjg3YTJlY2Q5ZjQ4ZGUwZjY0MzgzYTc5OGE3NzE0MTMyZjUyNDBkM2RlZGQ0ZmNhMGY2MmRkNTQxMGEzNyIsImFsZyI6IlJTMjU2In0.eyJpc3MiOiJodHRwOi8vMTI3LjAuMC4xOjg4ODkiLCJpYXQiOjE3NzMyMjcwNzYsImV4cCI6MTc3MzIzMDY3NiwibmJmIjoxNzczMjI3MDY2LCJzdWIiOiJvcGVyYXRvciIsImFtciI6WyJwd2QiXSwic2NvcGUiOiJkYW1sX2xlZGdlcl9hcGkiLCJhdWQiOiJodHRwczovL2RhbWwuY29tL2p3dC9hdWQvcGFydGljaXBhbnQvcGFydGljaXBhbnQxOjoxMjIwZDQ0ZmMxYzNiYTBiNWJkZjdiOTU2ZWU3MWJjOTRlYmUyZDIzMjU4ZGMyNjhmZGYwODI0ZmJhZWZmMmM2MTQyNCJ9.QkUyPvfAcVD_K_1nPjrCltq2sRk98fyosogEVaayH9AlZKr2HdP7X_RnCY_AisVFqE4PLHbYW69BqCIdhRR0WaBBmq83rH2K1xg8s8d0kITDQLdYo3n8sWbx1y2q2dnUEYJ3pUmfftuRAcLEvDCMaEG6RF6xLCvjMkb2yg6Uy33TafR6-Foj1uXpP5Cl2Ir6ONj3166yzjuPfuvwPbntNGD-dWzmFhaxoB1aM2sAnbnxdNdgemf55VIPORj4dL4eu9NHmTkCzgRAuv-qzVOvPp6ELKIVMPhD84r6iKCHm6jhE0iEF1PyovYHd-aLEeTm6_QUWz6POQyTMLaP6re-Ng" + } +} diff --git a/tools/cip103-conformance/provider.config.wallet-gateway-remote.json b/tools/cip103-conformance/provider.config.wallet-gateway-remote.json new file mode 100644 index 000000000..1dfff4363 --- /dev/null +++ b/tools/cip103-conformance/provider.config.wallet-gateway-remote.json @@ -0,0 +1,10 @@ +{ + "name": "Wallet Gateway (Remote)", + "version": "unknown", + "transport": "http", + "endpoint": "http://localhost:3030/api/v0/dapp", + "timeoutMs": 15000, + "headers": { + "authorization": "Bearer " + } +} diff --git a/tools/cip103-conformance/result-wallet-gateway-remote.json b/tools/cip103-conformance/result-wallet-gateway-remote.json new file mode 100644 index 000000000..fde815810 --- /dev/null +++ b/tools/cip103-conformance/result-wallet-gateway-remote.json @@ -0,0 +1,127 @@ +{ + "schemaVersion": 1, + "suite": "cip-103-conformance", + "profile": "sync", + "provider": { + "name": "Wallet Gateway (Remote)", + "version": "unknown", + "transport": "http", + "endpoint": "http://localhost:3030/api/v0/dapp" + }, + "generatedAt": "2026-03-31T11:22:50.856Z", + "summary": { + "total": 15, + "passed": 14, + "failed": 1, + "skipped": 0, + "status": "fail" + }, + "results": [ + { + "id": "CIP103-RPC-001", + "title": "Unknown method returns method-not-found", + "category": "protocol", + "status": "fail", + "elapsedMs": 43, + "details": "Expected -32601, got -32001" + }, + { + "id": "CIP103-RPC-002", + "title": "Invalid JSON-RPC request returns error", + "category": "protocol", + "status": "pass", + "elapsedMs": 2 + }, + { + "id": "CIP103-SCHEMA-accountsChanged", + "title": "Method 'accountsChanged' is implemented", + "category": "schema", + "status": "pass", + "elapsedMs": 4 + }, + { + "id": "CIP103-SCHEMA-connect", + "title": "Method 'connect' is implemented", + "category": "schema", + "status": "pass", + "elapsedMs": 3 + }, + { + "id": "CIP103-SCHEMA-disconnect", + "title": "Method 'disconnect' is implemented", + "category": "schema", + "status": "pass", + "elapsedMs": 2 + }, + { + "id": "CIP103-SCHEMA-getActiveNetwork", + "title": "Method 'getActiveNetwork' is implemented", + "category": "schema", + "status": "pass", + "elapsedMs": 2 + }, + { + "id": "CIP103-SCHEMA-getPrimaryAccount", + "title": "Method 'getPrimaryAccount' is implemented", + "category": "schema", + "status": "pass", + "elapsedMs": 3 + }, + { + "id": "CIP103-SCHEMA-ledgerApi", + "title": "Method 'ledgerApi' is implemented", + "category": "schema", + "status": "pass", + "elapsedMs": 3 + }, + { + "id": "CIP103-SCHEMA-listAccounts", + "title": "Method 'listAccounts' is implemented", + "category": "schema", + "status": "pass", + "elapsedMs": 2 + }, + { + "id": "CIP103-SCHEMA-prepareExecute", + "title": "Method 'prepareExecute' is implemented", + "category": "schema", + "status": "pass", + "elapsedMs": 2 + }, + { + "id": "CIP103-SCHEMA-prepareExecuteAndWait", + "title": "Method 'prepareExecuteAndWait' is implemented", + "category": "schema", + "status": "pass", + "elapsedMs": 3 + }, + { + "id": "CIP103-SCHEMA-signMessage", + "title": "Method 'signMessage' is implemented", + "category": "schema", + "status": "pass", + "elapsedMs": 2 + }, + { + "id": "CIP103-SCHEMA-status", + "title": "Method 'status' is implemented", + "category": "schema", + "status": "pass", + "elapsedMs": 2 + }, + { + "id": "CIP103-SCHEMA-txChanged", + "title": "Method 'txChanged' is implemented", + "category": "schema", + "status": "pass", + "elapsedMs": 5 + }, + { + "id": "CIP103-BEH-001", + "title": "Provider exposes 'connect' lifecycle method", + "category": "behavior", + "status": "pass", + "elapsedMs": 24 + } + ] +} diff --git a/tools/cip103-conformance/src/artifact.ts b/tools/cip103-conformance/src/artifact.ts new file mode 100644 index 000000000..3f24c3ee2 --- /dev/null +++ b/tools/cip103-conformance/src/artifact.ts @@ -0,0 +1,96 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createPrivateKey, createPublicKey, sign, verify } from 'node:crypto' +import { readFile, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { mkdir } from 'node:fs/promises' +import { ArtifactSchema, type Artifact } from './schemas' + +function canonicalize(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(canonicalize).join(',')}]` + } + if (value && typeof value === 'object') { + const entries = Object.entries(value as Record).sort( + ([a], [b]) => a.localeCompare(b) + ) + return `{${entries + .map(([key, val]) => `${JSON.stringify(key)}:${canonicalize(val)}`) + .join(',')}}` + } + return JSON.stringify(value) +} + +function toSignPayload(artifact: Artifact): Buffer { + const unsigned = { ...artifact, signature: undefined } + return Buffer.from(canonicalize(unsigned), 'utf8') +} + +export async function writeArtifact( + path: string, + artifact: Artifact +): Promise { + const absolutePath = resolve(process.cwd(), path) + await mkdir(dirname(absolutePath), { recursive: true }) + await writeFile( + absolutePath, + `${JSON.stringify(artifact, null, 2)}\n`, + 'utf8' + ) +} + +export async function readArtifact(path: string): Promise { + const absolutePath = resolve(process.cwd(), path) + const raw = await readFile(absolutePath, 'utf8') + return ArtifactSchema.parse(JSON.parse(raw)) +} + +export async function signArtifact( + artifact: Artifact, + privateKeyPath: string, + keyId?: string +): Promise { + const rawKey = await readFile( + resolve(process.cwd(), privateKeyPath), + 'utf8' + ) + const privateKey = createPrivateKey(rawKey) + const signature = sign(null, toSignPayload(artifact), privateKey).toString( + 'base64' + ) + return { + ...artifact, + signature: { + algorithm: 'ed25519', + value: signature, + keyId, + }, + } +} + +export async function verifyArtifactSignature( + artifact: Artifact, + publicKeyPath: string +): Promise { + if (!artifact.signature) { + return false + } + const rawKey = await readFile(resolve(process.cwd(), publicKeyPath), 'utf8') + const publicKey = createPublicKey(rawKey) + return verify( + null, + toSignPayload(artifact), + publicKey, + Buffer.from(artifact.signature.value, 'base64') + ) +} + +export function toBadgeData(artifact: Artifact): Record { + return { + schemaVersion: 1, + label: `cip-103 ${artifact.profile}`, + message: artifact.summary.status, + color: artifact.summary.status === 'pass' ? 'green' : 'red', + } +} diff --git a/tools/cip103-conformance/src/browser.ts b/tools/cip103-conformance/src/browser.ts new file mode 100644 index 000000000..c07b18140 --- /dev/null +++ b/tools/cip103-conformance/src/browser.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Browser-safe exports for web-based conformance runners. + * + * This entrypoint must not import Node-only modules (fs, path, playwright, etc.). + */ + +export { readRequiredMethodsBundled } from './required-methods' +export type { Profile } from './schemas' diff --git a/tools/cip103-conformance/src/cli.ts b/tools/cip103-conformance/src/cli.ts new file mode 100644 index 000000000..4e4651cca --- /dev/null +++ b/tools/cip103-conformance/src/cli.ts @@ -0,0 +1,154 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Command, InvalidArgumentError } from 'commander' +import { ConformanceService } from './conformance-service' +import { ProfileSchema } from './schemas' + +function asProfile(value: string): 'sync' | 'async' { + const parsed = ProfileSchema.safeParse(value) + if (!parsed.success) { + throw new InvalidArgumentError( + `Invalid profile '${value}'. Use 'sync' or 'async'.` + ) + } + return parsed.data +} + +function nonEmpty(value: string, optionName: string): string { + const trimmed = value.trim() + if (!trimmed) { + throw new InvalidArgumentError(`${optionName} cannot be empty.`) + } + return trimmed +} + +function toPath(value: string): string { + return nonEmpty(value, 'Path') +} + +const conformanceService = new ConformanceService() + +async function runCommand(options: { + profile: 'sync' | 'async' + providerConfig: string + out: string + signingKey?: string + keyId?: string +}): Promise { + const artifact = await conformanceService.run({ + profile: options.profile, + providerConfigPath: options.providerConfig, + outPath: options.out, + ...(options.signingKey ? { signingKeyPath: options.signingKey } : {}), + ...(options.keyId ? { keyId: options.keyId } : {}), + }) + console.log( + `Conformance completed: ${artifact.summary.status.toUpperCase()} (${artifact.summary.passed}/${artifact.summary.total})` + ) + console.log(`Artifact written: ${options.out}`) + if (artifact.summary.status !== 'pass') { + process.exitCode = 1 + } +} + +async function validateArtifactCommand(options: { + artifact: string + publicKey?: string + requireSignature: boolean +}): Promise { + const result = await conformanceService.validate({ + artifactPath: options.artifact, + ...(options.publicKey ? { publicKeyPath: options.publicKey } : {}), + requireSignature: options.requireSignature, + }) + if (!result.signatureValidated) { + console.log( + `Artifact is valid (unsigned check): ${result.artifact.summary.status.toUpperCase()}` + ) + return + } + console.log('Artifact + signature validation succeeded.') +} + +async function exportBadgeCommand(options: { + artifact: string + out: string +}): Promise { + await conformanceService.exportBadge({ + artifactPath: options.artifact, + outPath: options.out, + }) + console.log(`Badge exported: ${options.out}`) +} + +async function main(): Promise { + const program = new Command() + .name('conformance-cli') + .description('CIP-103 sync/async conformance runner') + .showSuggestionAfterError(true) + .showHelpAfterError() + + program + .command('run') + .description('Run conformance checks against a provider endpoint') + .requiredOption( + '--profile ', + "Conformance profile: 'sync' or 'async'", + asProfile + ) + .requiredOption( + '--provider-config ', + 'Provider config JSON file path', + toPath + ) + .option( + '--out ', + 'Output path for conformance artifact', + 'dist/conformance/result.json' + ) + .option( + '--signing-key ', + 'Optional Ed25519 private key (PEM) path', + toPath + ) + .option( + '--key-id ', + 'Optional key identifier to include in the artifact', + toPath + ) + .action(runCommand) + + program + .command('validate-artifact') + .description( + 'Validate a conformance artifact (and signature if provided)' + ) + .requiredOption('--artifact ', 'Artifact JSON path', toPath) + .option( + '--public-key ', + 'Optional Ed25519 public key (PEM) path', + toPath + ) + .option( + '--require-signature', + 'Fail when artifact has no signature (use for CI ingestion checks)', + false + ) + .action(validateArtifactCommand) + + program + .command('export-badge') + .description('Export badge JSON from a conformance artifact') + .requiredOption('--artifact ', 'Artifact JSON path', toPath) + .option( + '--out ', + 'Output path for badge JSON', + 'dist/conformance/badge.json' + ) + .action(exportBadgeCommand) + + await program.parseAsync(process.argv) +} + +void main() diff --git a/tools/cip103-conformance/src/conformance-service.ts b/tools/cip103-conformance/src/conformance-service.ts new file mode 100644 index 000000000..2c67f0163 --- /dev/null +++ b/tools/cip103-conformance/src/conformance-service.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { readFile, writeFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { + readArtifact, + signArtifact, + toBadgeData, + verifyArtifactSignature, + writeArtifact, +} from './artifact' +import { runConformance } from './runner' +import { + ProviderConfigSchema, + type Artifact, + type Profile, + type ProviderConfig, +} from './schemas' + +export interface RunConformanceCommandOptions { + profile: Profile + providerConfigPath: string + outPath: string + signingKeyPath?: string | undefined + keyId?: string | undefined +} + +export interface ValidateArtifactCommandOptions { + artifactPath: string + publicKeyPath?: string | undefined + requireSignature: boolean +} + +export interface ExportBadgeCommandOptions { + artifactPath: string + outPath: string +} + +export interface ValidateArtifactResult { + artifact: Artifact + signatureValidated: boolean +} + +export class ConformanceService { + async readProviderConfig(path: string): Promise { + const raw = await readFile(resolve(process.cwd(), path), 'utf8') + return ProviderConfigSchema.parse(JSON.parse(raw)) + } + + async run(options: RunConformanceCommandOptions): Promise { + if (options.keyId && !options.signingKeyPath) { + throw new Error('--key-id requires --signing-key.') + } + + const provider = await this.readProviderConfig( + options.providerConfigPath + ) + let artifact = await runConformance(options.profile, provider) + if (options.signingKeyPath) { + artifact = await signArtifact( + artifact, + options.signingKeyPath, + options.keyId + ) + } + await writeArtifact(options.outPath, artifact) + return artifact + } + + async validate( + options: ValidateArtifactCommandOptions + ): Promise { + const artifact = await readArtifact(options.artifactPath) + if (options.requireSignature && !artifact.signature) { + throw new Error( + 'Artifact does not contain a signature. Use a signed artifact.' + ) + } + + if (!options.publicKeyPath) { + return { artifact, signatureValidated: false } + } + + const ok = await verifyArtifactSignature( + artifact, + options.publicKeyPath + ) + if (!ok) { + throw new Error('Signature validation failed.') + } + return { artifact, signatureValidated: true } + } + + async exportBadge(options: ExportBadgeCommandOptions): Promise { + const artifact = await readArtifact(options.artifactPath) + const badge = toBadgeData(artifact) + const absoluteOut = resolve(process.cwd(), options.outPath) + await writeFile( + absoluteOut, + `${JSON.stringify(badge, null, 2)}\n`, + 'utf8' + ) + } +} diff --git a/tools/cip103-conformance/src/index.ts b/tools/cip103-conformance/src/index.ts new file mode 100644 index 000000000..e0d11412e --- /dev/null +++ b/tools/cip103-conformance/src/index.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { runConformance } from './runner' +export { ConformanceService } from './conformance-service' +export { readRequiredMethodsBundled } from './required-methods' +export { + ArtifactSchema, + ProfileSchema, + ProviderConfigSchema, + TestResultSchema, +} from './schemas' +export { + readArtifact, + signArtifact, + toBadgeData, + verifyArtifactSignature, + writeArtifact, +} from './artifact' +export type { + Artifact, + HttpProviderConfig, + InjectedProviderConfig, + Profile, + ProviderConfig, + TestResult, +} from './schemas' +export type { + ExportBadgeCommandOptions, + RunConformanceCommandOptions, + ValidateArtifactCommandOptions, + ValidateArtifactResult, +} from './conformance-service' diff --git a/tools/cip103-conformance/src/openrpc.ts b/tools/cip103-conformance/src/openrpc.ts new file mode 100644 index 000000000..831c98968 --- /dev/null +++ b/tools/cip103-conformance/src/openrpc.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { access, readFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Profile } from './schemas' + +interface OpenRpcMethod { + name: string +} + +interface OpenRpcDocument { + methods?: OpenRpcMethod[] +} + +async function openRpcPath(profile: Profile): Promise { + const moduleDir = + typeof __dirname === 'string' + ? __dirname + : dirname(fileURLToPath(import.meta.url)) + const file = + profile === 'sync' + ? 'openrpc-dapp-api.json' + : 'openrpc-dapp-remote-api.json' + + // Built package path: dist/specs/*.json (moduleDir is dist) + const distPath = resolve(moduleDir, 'specs', file) + try { + await access(distPath) + return distPath + } catch { + // Dev path when running source directly via tsx. + return resolve(moduleDir, '..', 'dist', 'specs', file) + } +} + +export async function readRequiredMethods(profile: Profile): Promise { + const path = await openRpcPath(profile) + const raw = await readFile(path, 'utf8') + const parsed = JSON.parse(raw) as OpenRpcDocument + const methods = parsed.methods?.map((m) => m.name).filter(Boolean) ?? [] + return [...new Set(methods)].sort((a, b) => a.localeCompare(b)) +} diff --git a/tools/cip103-conformance/src/required-methods.ts b/tools/cip103-conformance/src/required-methods.ts new file mode 100644 index 000000000..d1d01a24f --- /dev/null +++ b/tools/cip103-conformance/src/required-methods.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Profile } from './schemas' + +interface OpenRpcMethod { + name: string +} + +interface OpenRpcDocument { + methods?: OpenRpcMethod[] +} + +function sortedUnique(values: string[]): string[] { + return [...new Set(values)].sort((a, b) => a.localeCompare(b)) +} + +const EVENT_LIKE_METHODS = new Set([ + // These are delivered as events/notifications, not requestable JSON-RPC methods. + 'connected', + 'accountsChanged', + 'txChanged', + 'statusChanged', +]) + +const SIDE_EFFECTFUL_METHODS = new Set([ + // These can change session state or trigger UI; don't probe them in the generic + // "is implemented" schema loop. + 'connect', + 'disconnect', + 'prepareExecute', + 'prepareExecuteAndWait', +]) + +async function loadSpecJson(relativePath: string): Promise { + const url = new URL(relativePath, import.meta.url) + const res = await fetch(url) + if (!res.ok) { + throw new Error( + `Failed to load OpenRPC spec (${relativePath}): HTTP ${res.status}` + ) + } + return (await res.json()) as OpenRpcDocument +} + +/** + * Browser-friendly method list loader. + * + * Loads the spec JSON that is copied into `dist/specs/` as part of the package build. + * This avoids Node `fs` usage and keeps consumers (like web dApps) lightweight. + */ +export async function readRequiredMethodsBundled( + profile: Profile +): Promise { + const file = + profile === 'sync' + ? './specs/openrpc-dapp-api.json' + : './specs/openrpc-dapp-remote-api.json' + const parsed = await loadSpecJson(file) + const methods = parsed.methods?.map((m) => m.name).filter(Boolean) ?? [] + return sortedUnique(methods).filter( + (m) => !EVENT_LIKE_METHODS.has(m) && !SIDE_EFFECTFUL_METHODS.has(m) + ) +} diff --git a/tools/cip103-conformance/src/rpc.ts b/tools/cip103-conformance/src/rpc.ts new file mode 100644 index 000000000..4d9967063 --- /dev/null +++ b/tools/cip103-conformance/src/rpc.ts @@ -0,0 +1,420 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { setTimeout as sleep } from 'node:timers/promises' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { + InjectedProviderConfig, + ProviderConfig, + HttpProviderConfig, +} from './schemas' + +export interface JsonRpcError { + code: number + message: string +} + +export interface JsonRpcResponse { + result?: unknown + error?: JsonRpcError +} + +export interface ConformanceTransport { + request(method: string, params: unknown): Promise + requestInvalidEnvelope(): Promise + close(): Promise +} + +function isInjectedProvider( + provider: ProviderConfig +): provider is InjectedProviderConfig { + return provider.transport === 'injected' +} + +function buildHeaders(provider: HttpProviderConfig): HeadersInit { + return { + 'content-type': 'application/json', + ...provider.headers, + } +} + +async function jsonRpcHttpRequest( + provider: HttpProviderConfig, + payload: unknown +): Promise { + const controller = new AbortController() + const timeout = setTimeout( + () => controller.abort(), + provider.timeoutMs ?? 10000 + ) + + try { + const response = await fetch(provider.endpoint, { + method: 'POST', + headers: buildHeaders(provider), + body: JSON.stringify(payload), + signal: controller.signal, + }) + const text = await response.text() + if (!text.trim()) { + return { + error: { + code: -32000, + message: `Empty response with HTTP ${response.status}`, + }, + } + } + return JSON.parse(text) as JsonRpcResponse + } catch (error) { + await sleep(1) + const message = error instanceof Error ? error.message : String(error) + return { error: { code: -32001, message } } + } finally { + clearTimeout(timeout) + } +} + +function normalizeUnknownError(error: unknown): JsonRpcError { + if (typeof error === 'object' && error !== null) { + const maybeCode = (error as { code?: unknown }).code + const maybeMessage = (error as { message?: unknown }).message + if (typeof maybeCode === 'number' && typeof maybeMessage === 'string') { + return { code: maybeCode, message: maybeMessage } + } + if (typeof maybeMessage === 'string') { + return { code: -32001, message: maybeMessage } + } + } + return { + code: -32001, + message: error instanceof Error ? error.message : String(error), + } +} + +async function createInjectedTransport( + provider: InjectedProviderConfig +): Promise { + const { chromium } = await import('playwright') + const userDataDir = await mkdtemp(join(tmpdir(), 'cip103-conformance-')) + const args = provider.extensionPath + ? [ + `--disable-extensions-except=${provider.extensionPath}`, + `--load-extension=${provider.extensionPath}`, + ] + : [] + + const context = await chromium.launchPersistentContext(userDataDir, { + headless: provider.headless ?? false, + args, + }) + const page = context.pages().at(0) ?? (await context.newPage()) + await page.goto(provider.appUrl, { waitUntil: 'domcontentloaded' }) + const namespace = + provider.providerId && !provider.providerId.startsWith('window.') + ? `window.${provider.providerId}` + : (provider.providerId ?? + provider.injectedNamespace ?? + 'window.canton') + + if (provider.extensionTarget) { + const target = provider.extensionTarget + const request = async ( + method: string, + params: unknown + ): Promise => { + try { + const response = await page.evaluate( + async ({ + rpcMethod, + rpcParams, + target, + timeoutMs, + }: { + rpcMethod: string + rpcParams: unknown + target: string + timeoutMs: number + }) => { + const id = + typeof crypto !== 'undefined' && + 'randomUUID' in crypto && + typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `req-${Date.now()}-${Math.random()}` + + const requestMessage = { + type: 'SPLICE_WALLET_REQUEST', + target, + request: { + jsonrpc: '2.0', + id, + method: rpcMethod, + params: rpcParams, + }, + } + + const awaitResponse = () => + new Promise((resolve) => { + const listener = (event: MessageEvent) => { + const data = event.data as { + type?: unknown + response?: { + id?: unknown + error?: unknown + } + } + if ( + !data || + data.type !== + 'SPLICE_WALLET_RESPONSE' || + !data.response || + data.response.id !== id + ) { + return + } + window.removeEventListener( + 'message', + listener + ) + if ('error' in data.response) { + resolve({ + error: data.response.error as { + code: number + message: string + }, + }) + } else { + resolve({ + result: ( + data.response as { + result?: unknown + } + ).result, + }) + } + } + + window.addEventListener('message', listener) + window.postMessage(requestMessage, '*') + setTimeout(() => { + window.removeEventListener( + 'message', + listener + ) + resolve({ + error: { + code: -32001, + message: `Timeout waiting for extensionTarget='${target}'`, + }, + }) + }, timeoutMs) + }) + + return await awaitResponse() + }, + { + rpcMethod: method, + rpcParams: params, + target, + timeoutMs: provider.timeoutMs ?? 10000, + } + ) + return response as JsonRpcResponse + } catch (error) { + return { error: normalizeUnknownError(error) } + } + } + + return { + request, + async requestInvalidEnvelope(): Promise { + return null + }, + async close(): Promise { + await context.close() + await rm(userDataDir, { recursive: true, force: true }) + }, + } + } + + try { + await page.waitForFunction( + (namespacePath: string) => { + const getAtPath = ( + root: Record, + path: string + ): unknown => + path + .split('.') + .reduce( + (acc, key) => + acc && typeof acc === 'object' + ? (acc as Record)[key] + : undefined, + root + ) + + const value = getAtPath( + globalThis as Record, + namespacePath + ) + return !!value + }, + namespace, + { timeout: provider.timeoutMs ?? 10000 } + ) + } catch { + // handled in request() with a clearer error message + } + + const request = async ( + method: string, + params: unknown + ): Promise => { + try { + const response = await page.evaluate( + async ({ + namespacePath, + rpcMethod, + rpcParams, + }: { + namespacePath: string + rpcMethod: string + rpcParams: unknown + }) => { + const getAtPath = ( + root: Record, + path: string + ): unknown => + path + .split('.') + .reduce( + (acc, key) => + acc && typeof acc === 'object' + ? (acc as Record)[key] + : undefined, + root + ) + + const value = getAtPath( + globalThis as Record, + namespacePath + ) + if (!value || typeof value !== 'object') { + return { + error: { + code: -32001, + message: `Injected provider '${namespacePath}' not found`, + }, + } + } + + const requestFn = (value as { request?: unknown }).request + if (typeof requestFn !== 'function') { + return { + error: { + code: -32001, + message: `Injected provider '${namespacePath}' has no request() method`, + }, + } + } + + try { + const result = await ( + requestFn as (args: { + method: string + params: unknown + }) => Promise + )({ + method: rpcMethod, + params: rpcParams, + }) + return { result } + } catch (error) { + if (typeof error === 'object' && error !== null) { + const maybeCode = (error as { code?: unknown }).code + const maybeMessage = ( + error as { message?: unknown } + ).message + if ( + typeof maybeCode === 'number' && + typeof maybeMessage === 'string' + ) { + return { + error: { + code: maybeCode, + message: maybeMessage, + }, + } + } + if (typeof maybeMessage === 'string') { + return { + error: { + code: -32001, + message: maybeMessage, + }, + } + } + } + return { + error: { + code: -32001, + message: + error instanceof Error + ? error.message + : String(error), + }, + } + } + }, + { + namespacePath: namespace, + rpcMethod: method, + rpcParams: params, + } + ) + return response as JsonRpcResponse + } catch (error) { + return { error: normalizeUnknownError(error) } + } + } + + return { + request, + async requestInvalidEnvelope(): Promise { + return null + }, + async close(): Promise { + await context.close() + await rm(userDataDir, { recursive: true, force: true }) + }, + } +} + +export async function createTransport( + provider: ProviderConfig +): Promise { + if (isInjectedProvider(provider)) { + return createInjectedTransport(provider) + } + + return { + async request( + method: string, + params: unknown + ): Promise { + return jsonRpcHttpRequest(provider, { + jsonrpc: '2.0', + id: `rpc-${method}`, + method, + params, + }) + }, + async requestInvalidEnvelope(): Promise { + return jsonRpcHttpRequest(provider, { nonsense: true }) + }, + async close(): Promise {}, + } +} diff --git a/tools/cip103-conformance/src/runner.ts b/tools/cip103-conformance/src/runner.ts new file mode 100644 index 000000000..575bddcc6 --- /dev/null +++ b/tools/cip103-conformance/src/runner.ts @@ -0,0 +1,229 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { readRequiredMethods } from './openrpc' +import { createTransport, type ConformanceTransport } from './rpc' +import type { + Artifact, + HttpProviderConfig, + InjectedProviderConfig, + Profile, + ProviderConfig, + TestResult, + TestStatus, +} from './schemas' + +function nowIso(): string { + return new Date().toISOString() +} + +function duration(startMs: number): number { + return Math.max(0, Date.now() - startMs) +} + +function makeResult( + id: string, + title: string, + category: TestResult['category'], + status: TestStatus, + elapsedMs: number, + details?: string +): TestResult { + return { id, title, category, status, elapsedMs, details } +} + +/** JSON-RPC standard: method not found. */ +const JSON_RPC_METHOD_NOT_FOUND = -32601 +/** Used by some providers when the method is known but not supported for this wallet/session. */ +const METHOD_NOT_SUPPORTED = -32004 + +function isMissingOrUnsupportedMethod(error?: { code?: number }): boolean { + const c = error?.code + return c === JSON_RPC_METHOD_NOT_FOUND || c === METHOD_NOT_SUPPORTED +} + +function schemaProbeParams(methodName: string): unknown { + switch (methodName) { + case 'ledgerApi': + return { + requestMethod: 'get', + resource: '/v2/version', + } + default: + return {} + } +} + +async function runProtocolTests( + transport: ConformanceTransport +): Promise { + const results: TestResult[] = [] + + { + const start = Date.now() + const response = await transport.request( + '__cip103_unknown_method__', + {} + ) + const pass = response.error?.code === JSON_RPC_METHOD_NOT_FOUND + results.push( + makeResult( + 'CIP103-RPC-001', + 'Unknown method returns method-not-found', + 'protocol', + pass ? 'pass' : 'fail', + duration(start), + pass + ? undefined + : `Expected ${JSON_RPC_METHOD_NOT_FOUND}, got ${response.error?.code ?? 'no error'}` + ) + ) + } + + { + const start = Date.now() + const response = await transport.requestInvalidEnvelope() + if (!response) { + results.push( + makeResult( + 'CIP103-RPC-002', + 'Invalid JSON-RPC request returns error', + 'protocol', + 'skip', + duration(start), + 'Skipped: injected transport does not expose raw JSON-RPC envelope' + ) + ) + return results + } + const pass = Boolean(response.error) + results.push( + makeResult( + 'CIP103-RPC-002', + 'Invalid JSON-RPC request returns error', + 'protocol', + pass ? 'pass' : 'fail', + duration(start), + pass + ? undefined + : 'Expected JSON-RPC error for malformed request' + ) + ) + } + + return results +} + +async function runSchemaTests( + profile: Profile, + transport: ConformanceTransport +): Promise { + const methodNames = await readRequiredMethods(profile) + const results: TestResult[] = [] + + for (const methodName of methodNames) { + const start = Date.now() + const response = await transport.request( + methodName, + schemaProbeParams(methodName) + ) + + const pass = !isMissingOrUnsupportedMethod(response.error) + results.push( + makeResult( + `CIP103-SCHEMA-${methodName}`, + `Method '${methodName}' is implemented`, + 'schema', + pass ? 'pass' : 'fail', + duration(start), + pass + ? undefined + : `Method missing or unsupported (expected neither ${JSON_RPC_METHOD_NOT_FOUND} nor ${METHOD_NOT_SUPPORTED}; got ${response.error?.code ?? 'no error'})` + ) + ) + } + + return results +} + +async function runBehaviorSmokeTests( + profile: Profile, + transport: ConformanceTransport +): Promise { + const start = Date.now() + const response = await transport.request('connect', {}) + + const pass = !isMissingOrUnsupportedMethod(response.error) + return [ + makeResult( + profile === 'sync' ? 'CIP103-BEH-001' : 'CIP103-BEH-101', + "Provider exposes 'connect' lifecycle method", + 'behavior', + pass ? 'pass' : 'fail', + duration(start), + pass + ? undefined + : `'connect' missing or not supported (code ${response.error?.code ?? 'n/a'})` + ), + ] +} + +function artifactProvider(provider: ProviderConfig): Artifact['provider'] { + if (provider.transport === 'injected') { + const injected = provider as InjectedProviderConfig + return { + name: injected.name, + version: injected.version, + transport: 'injected', + appUrl: injected.appUrl, + } + } + + const http = provider as HttpProviderConfig + return { + name: http.name, + version: http.version, + transport: 'http', + endpoint: http.endpoint, + } +} + +function summarize(results: TestResult[]): Artifact['summary'] { + const passed = results.filter((r) => r.status === 'pass').length + const failed = results.filter((r) => r.status === 'fail').length + const skipped = results.filter((r) => r.status === 'skip').length + const total = results.length + return { + total, + passed, + failed, + skipped, + status: failed > 0 ? 'fail' : 'pass', + } +} + +export async function runConformance( + profile: Profile, + provider: ProviderConfig +): Promise { + const transport = await createTransport(provider) + try { + const [protocol, schema, behavior] = await Promise.all([ + runProtocolTests(transport), + runSchemaTests(profile, transport), + runBehaviorSmokeTests(profile, transport), + ]) + const results = [...protocol, ...schema, ...behavior] + return { + schemaVersion: 1, + suite: 'cip-103-conformance', + profile, + provider: artifactProvider(provider), + generatedAt: nowIso(), + summary: summarize(results), + results, + } + } finally { + await transport.close() + } +} diff --git a/tools/cip103-conformance/src/schemas.ts b/tools/cip103-conformance/src/schemas.ts new file mode 100644 index 000000000..4fc94e777 --- /dev/null +++ b/tools/cip103-conformance/src/schemas.ts @@ -0,0 +1,93 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { z } from 'zod' + +export const ProfileSchema = z.enum(['sync', 'async']) +export type Profile = z.infer + +const BaseProviderConfigSchema = z.object({ + name: z.string().min(1), + version: z.string().min(1).optional(), + timeoutMs: z.number().int().positive().optional(), +}) + +export const HttpProviderConfigSchema = BaseProviderConfigSchema.extend({ + transport: z.literal('http').optional(), + endpoint: z.url(), + headers: z.record(z.string(), z.string()).optional(), +}) + +export const InjectedProviderConfigSchema = BaseProviderConfigSchema.extend({ + transport: z.literal('injected'), + appUrl: z.url(), + injectedNamespace: z.string().min(1).optional(), + /** + * Convenience selector for namespaced injected providers, e.g.: + * - "canton.console" -> resolves "window.canton.console" + * - "splice" -> resolves "window.splice" + * + * If provided, this takes precedence over injectedNamespace. + */ + providerId: z.string().min(1).optional(), + /** + * Deterministic selector for postMessage-based extension wallets. + * When set, JSON-RPC requests are sent via window.postMessage with `target`. + */ + extensionTarget: z.string().min(1).optional(), + extensionPath: z.string().min(1).optional(), + headless: z.boolean().optional(), +}) + +export const ProviderConfigSchema = z.union([ + HttpProviderConfigSchema, + InjectedProviderConfigSchema, +]) +export type ProviderConfig = z.infer +export type HttpProviderConfig = z.infer +export type InjectedProviderConfig = z.infer< + typeof InjectedProviderConfigSchema +> + +export const TestStatusSchema = z.enum(['pass', 'fail', 'skip']) +export type TestStatus = z.infer + +export const TestResultSchema = z.object({ + id: z.string(), + title: z.string(), + category: z.enum(['protocol', 'schema', 'behavior', 'stability']), + status: TestStatusSchema, + details: z.string().optional(), + elapsedMs: z.number().int().nonnegative(), +}) +export type TestResult = z.infer + +export const ArtifactSchema = z.object({ + schemaVersion: z.literal(1), + suite: z.literal('cip-103-conformance'), + profile: ProfileSchema, + provider: z.object({ + name: z.string(), + version: z.string().optional(), + transport: z.enum(['http', 'injected']), + endpoint: z.url().optional(), + appUrl: z.url().optional(), + }), + generatedAt: z.string(), + summary: z.object({ + total: z.number().int().nonnegative(), + passed: z.number().int().nonnegative(), + failed: z.number().int().nonnegative(), + skipped: z.number().int().nonnegative(), + status: z.enum(['pass', 'fail']), + }), + results: z.array(TestResultSchema), + signature: z + .object({ + algorithm: z.literal('ed25519'), + value: z.string(), + keyId: z.string().optional(), + }) + .optional(), +}) +export type Artifact = z.infer diff --git a/tools/cip103-conformance/tsconfig.json b/tools/cip103-conformance/tsconfig.json new file mode 100644 index 000000000..8d75dffa0 --- /dev/null +++ b/tools/cip103-conformance/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "moduleResolution": "bundler" + }, + "include": ["src"] +} diff --git a/tools/cip103-conformance/tsconfig.types.json b/tools/cip103-conformance/tsconfig.types.json new file mode 100644 index 000000000..e522515b7 --- /dev/null +++ b/tools/cip103-conformance/tsconfig.types.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.web.json", + "compilerOptions": { + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "rootDir": "./src", + "outDir": "./dist", + "moduleResolution": "bundler", + "noEmit": false, + "composite": false + }, + "include": ["src/**/*.ts", "src/**/*.d.ts"], + "exclude": ["**/*.test.*", "**/*.spec.*", "**/__tests__/**"] +} diff --git a/tools/cip103-conformance/tsup.config.ts b/tools/cip103-conformance/tsup.config.ts new file mode 100644 index 000000000..c7e79364f --- /dev/null +++ b/tools/cip103-conformance/tsup.config.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: ['src/index.ts', 'src/browser.ts', 'src/cli.ts'], + format: ['esm', 'cjs'], + dts: false, + sourcemap: true, + clean: true, + target: 'es2022', + outDir: 'dist', +}) diff --git a/wallet-gateway/test/config.json b/wallet-gateway/test/config.json index f9f530379..851590931 100644 --- a/wallet-gateway/test/config.json +++ b/wallet-gateway/test/config.json @@ -9,7 +9,11 @@ "tls": false, "dappPath": "/api/v0/dapp", "userPath": "/api/v0/user", - "allowedOrigins": ["http://localhost:8080", "http://localhost:8081"], + "allowedOrigins": [ + "http://localhost:8080", + "http://localhost:8081", + "http://localhost:8082" + ], "admin": "operator" }, "store": { diff --git a/yarn.lock b/yarn.lock index c6278ad93..28a1f4dc3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1419,6 +1419,22 @@ __metadata: languageName: unknown linkType: soft +"@canton-network/cip103-conformance@workspace:^, @canton-network/cip103-conformance@workspace:tools/cip103-conformance": + version: 0.0.0-use.local + resolution: "@canton-network/cip103-conformance@workspace:tools/cip103-conformance" + dependencies: + "@types/node": "npm:^25.3.3" + commander: "npm:^14.0.3" + playwright: "npm:^1.58.2" + tsup: "npm:^8.5.1" + tsx: "npm:^4.21.0" + typescript: "npm:^5.9.3" + zod: "npm:^4.3.6" + bin: + conformance-cli: ./dist/cli.js + languageName: unknown + linkType: soft + "@canton-network/core-acs-reader@workspace:^, @canton-network/core-acs-reader@workspace:core/acs-reader": version: 0.0.0-use.local resolution: "@canton-network/core-acs-reader@workspace:core/acs-reader" @@ -2155,6 +2171,31 @@ __metadata: languageName: unknown linkType: soft +"@canton-network/example-conformance-runner@workspace:examples/conformance-runner": + version: 0.0.0-use.local + resolution: "@canton-network/example-conformance-runner@workspace:examples/conformance-runner" + dependencies: + "@canton-network/cip103-conformance": "workspace:^" + "@canton-network/core-rpc-errors": "workspace:^" + "@canton-network/core-types": "workspace:^" + "@canton-network/dapp-sdk": "workspace:^" + "@eslint/js": "npm:^10.0.1" + "@types/react": "npm:^19.2.14" + "@types/react-dom": "npm:^19.2.3" + "@vitejs/plugin-react": "npm:^5.1.4" + eslint: "npm:^10.0.2" + eslint-plugin-react-hooks: "npm:^7.0.1" + eslint-plugin-react-refresh: "npm:^0.5.2" + globals: "npm:^17.4.0" + nx: "npm:^22.5.3" + react: "npm:^19.2.4" + react-dom: "npm:^19.2.4" + typescript: "npm:~5.9.3" + typescript-eslint: "npm:^8.56.1" + vite: "npm:^7.3.1" + languageName: unknown + linkType: soft + "@canton-network/example-ping@workspace:examples/ping": version: 0.0.0-use.local resolution: "@canton-network/example-ping@workspace:examples/ping" @@ -16240,7 +16281,7 @@ __metadata: languageName: node linkType: hard -"playwright@npm:1.58.2": +"playwright@npm:1.58.2, playwright@npm:^1.58.2": version: 1.58.2 resolution: "playwright@npm:1.58.2" dependencies: