From 57c0bd40e557acb6470858c0a69f2411ca59bd4c Mon Sep 17 00:00:00 2001 From: Marc Juchli Date: Mon, 9 Mar 2026 23:58:28 +0100 Subject: [PATCH 01/10] feat: cip-conformance Signed-off-by: Marc Juchli --- package.json | 4 + tools/cip103-conformance/README.md | 47 +++++ tools/cip103-conformance/package.json | 49 +++++ ...ider.config.browser-extension.example.json | 11 ++ .../provider.config.remote.example.json | 6 + tools/cip103-conformance/src/artifact.ts | 96 +++++++++ tools/cip103-conformance/src/cli.ts | 184 ++++++++++++++++++ tools/cip103-conformance/src/index.ts | 18 ++ tools/cip103-conformance/src/openrpc.ts | 30 +++ tools/cip103-conformance/src/rpc.ts | 58 ++++++ tools/cip103-conformance/src/runner.ts | 177 +++++++++++++++++ tools/cip103-conformance/src/schemas.ts | 57 ++++++ tools/cip103-conformance/tsconfig.json | 9 + tools/cip103-conformance/tsconfig.types.json | 15 ++ tools/cip103-conformance/tsup.config.ts | 14 ++ yarn.lock | 15 ++ 16 files changed, 790 insertions(+) create mode 100644 tools/cip103-conformance/README.md create mode 100644 tools/cip103-conformance/package.json create mode 100644 tools/cip103-conformance/provider.config.browser-extension.example.json create mode 100644 tools/cip103-conformance/provider.config.remote.example.json create mode 100644 tools/cip103-conformance/src/artifact.ts create mode 100644 tools/cip103-conformance/src/cli.ts create mode 100644 tools/cip103-conformance/src/index.ts create mode 100644 tools/cip103-conformance/src/openrpc.ts create mode 100644 tools/cip103-conformance/src/rpc.ts create mode 100644 tools/cip103-conformance/src/runner.ts create mode 100644 tools/cip103-conformance/src/schemas.ts create mode 100644 tools/cip103-conformance/tsconfig.json create mode 100644 tools/cip103-conformance/tsconfig.types.json create mode 100644 tools/cip103-conformance/tsup.config.ts 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/tools/cip103-conformance/README.md b/tools/cip103-conformance/README.md new file mode 100644 index 000000000..011dd6fa2 --- /dev/null +++ b/tools/cip103-conformance/README.md @@ -0,0 +1,47 @@ +# 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. + +## Commands + +- `conformance-cli run --profile sync|async --provider-config ` +- `conformance-cli validate-artifact --artifact [--public-key ] [--require-signature]` +- `conformance-cli export-badge --artifact [--out ]` + +## Example Provider Config + +```json +{ + "name": "Example Wallet Provider", + "version": "1.2.3", + "endpoint": "http://localhost:8081/json-rpc", + "timeoutMs": 10000 +} +``` + +## Example Provider Config (Browser Extension Wallet) + +Current implementation note: the conformance runner executes JSON-RPC over HTTP. +For browser extension wallets, use a local bridge endpoint that exposes the extension methods over JSON-RPC. + +```json +{ + "name": "Example Browser Extension Wallet", + "version": "1.0.0", + "endpoint": "http://127.0.0.1:12481/json-rpc", + "timeoutMs": 10000, + "headers": { + "x-provider-kind": "browser-extension" + }, + "extensionId": "abcdefghijklmnoabcdefghijklmn", + "injectedNamespace": "window.canton" +} +``` + +See `provider.config.browser-extension.example.json` for a copy-ready template. + +## Profile Mapping + +- `sync` -> `api-specs/openrpc-dapp-api.json` +- `async` -> `api-specs/openrpc-dapp-remote-api.json` diff --git a/tools/cip103-conformance/package.json b/tools/cip103-conformance/package.json new file mode 100644 index 000000000..3f265b3f3 --- /dev/null +++ b/tools/cip103-conformance/package.json @@ -0,0 +1,49 @@ +{ + "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" + } + }, + "scripts": { + "build": "tsup && tsc -p tsconfig.types.json", + "dev": "tsup --watch --onSuccess \"tsc -p tsconfig.types.json\"", + "clean": "tsc -b --clean; rm -rf dist", + "run": "tsx src/cli.ts" + }, + "dependencies": { + "commander": "^14.0.3", + "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.browser-extension.example.json b/tools/cip103-conformance/provider.config.browser-extension.example.json new file mode 100644 index 000000000..2e1ee5ed4 --- /dev/null +++ b/tools/cip103-conformance/provider.config.browser-extension.example.json @@ -0,0 +1,11 @@ +{ + "name": "Example Browser Extension Wallet", + "version": "1.0.0", + "endpoint": "http://127.0.0.1:12481/json-rpc", + "timeoutMs": 10000, + "headers": { + "x-provider-kind": "browser-extension" + }, + "extensionId": "abcdefghijklmnoabcdefghijklmn", + "injectedNamespace": "window.canton" +} 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..6c447169c --- /dev/null +++ b/tools/cip103-conformance/provider.config.remote.example.json @@ -0,0 +1,6 @@ +{ + "name": "Example Wallet Provider", + "version": "0.0.0", + "endpoint": "http://localhost:8081/json-rpc", + "timeoutMs": 10000 +} 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/cli.ts b/tools/cip103-conformance/src/cli.ts new file mode 100644 index 000000000..c2345fc74 --- /dev/null +++ b/tools/cip103-conformance/src/cli.ts @@ -0,0 +1,184 @@ +// 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 { Command, InvalidArgumentError } from 'commander' +import { + readArtifact, + signArtifact, + toBadgeData, + verifyArtifactSignature, + writeArtifact, +} from './artifact' +import { runConformance } from './runner' +import { + ProfileSchema, + ProviderConfigSchema, + type ProviderConfig, +} 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') +} + +async function readProviderConfig(path: string): Promise { + const raw = await readFile(resolve(process.cwd(), path), 'utf8') + return ProviderConfigSchema.parse(JSON.parse(raw)) +} + +async function runCommand(options: { + profile: 'sync' | 'async' + providerConfig: string + out: string + signingKey?: string + keyId?: string +}): Promise { + if (options.keyId && !options.signingKey) { + throw new Error('--key-id requires --signing-key.') + } + + const provider = await readProviderConfig(options.providerConfig) + let artifact = await runConformance(options.profile, provider) + if (options.signingKey) { + artifact = await signArtifact( + artifact, + options.signingKey, + options.keyId + ) + } + + await writeArtifact(options.out, artifact) + 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 artifact = await readArtifact(options.artifact) + if (options.requireSignature && !artifact.signature) { + throw new Error( + 'Artifact does not contain a signature. Use a signed artifact.' + ) + } + + if (!options.publicKey) { + console.log( + `Artifact is valid (unsigned check): ${artifact.summary.status.toUpperCase()}` + ) + return + } + const ok = await verifyArtifactSignature(artifact, options.publicKey) + if (!ok) { + throw new Error('Signature validation failed.') + } + console.log('Artifact + signature validation succeeded.') +} + +async function exportBadgeCommand(options: { + artifact: string + out: string +}): Promise { + const artifact = await readArtifact(options.artifact) + const badge = toBadgeData(artifact) + const absoluteOut = resolve(process.cwd(), options.out) + await writeFile(absoluteOut, `${JSON.stringify(badge, null, 2)}\n`, 'utf8') + 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/index.ts b/tools/cip103-conformance/src/index.ts new file mode 100644 index 000000000..8df0c8094 --- /dev/null +++ b/tools/cip103-conformance/src/index.ts @@ -0,0 +1,18 @@ +// 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 { + ArtifactSchema, + ProfileSchema, + ProviderConfigSchema, + TestResultSchema, +} from './schemas' +export { + readArtifact, + signArtifact, + toBadgeData, + verifyArtifactSignature, + writeArtifact, +} from './artifact' +export type { Artifact, Profile, ProviderConfig, TestResult } from './schemas' diff --git a/tools/cip103-conformance/src/openrpc.ts b/tools/cip103-conformance/src/openrpc.ts new file mode 100644 index 000000000..22659db97 --- /dev/null +++ b/tools/cip103-conformance/src/openrpc.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import type { Profile } from './schemas' + +interface OpenRpcMethod { + name: string +} + +interface OpenRpcDocument { + methods?: OpenRpcMethod[] +} + +function openRpcPath(profile: Profile): string { + const file = + profile === 'sync' + ? 'openrpc-dapp-api.json' + : 'openrpc-dapp-remote-api.json' + return resolve(process.cwd(), 'api-specs', file) +} + +export async function readRequiredMethods(profile: Profile): Promise { + const path = 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/rpc.ts b/tools/cip103-conformance/src/rpc.ts new file mode 100644 index 000000000..0c879083c --- /dev/null +++ b/tools/cip103-conformance/src/rpc.ts @@ -0,0 +1,58 @@ +// 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 type { ProviderConfig } from './schemas' + +export interface JsonRpcError { + code: number + message: string +} + +export interface JsonRpcResponse { + result?: unknown + error?: JsonRpcError +} + +function buildHeaders(provider: ProviderConfig): HeadersInit { + return { + 'content-type': 'application/json', + ...provider.headers, + } +} + +export async function jsonRpcRequest( + provider: ProviderConfig, + 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) + } +} diff --git a/tools/cip103-conformance/src/runner.ts b/tools/cip103-conformance/src/runner.ts new file mode 100644 index 000000000..f677e03ad --- /dev/null +++ b/tools/cip103-conformance/src/runner.ts @@ -0,0 +1,177 @@ +// 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 { jsonRpcRequest } from './rpc' +import type { + Artifact, + 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 } +} + +async function runProtocolTests( + provider: ProviderConfig +): Promise { + const results: TestResult[] = [] + + { + const start = Date.now() + const response = await jsonRpcRequest(provider, { + jsonrpc: '2.0', + id: 'protocol-unknown', + method: '__cip103_unknown_method__', + params: {}, + }) + const pass = response.error?.code === -32601 + results.push( + makeResult( + 'CIP103-RPC-001', + 'Unknown method returns method-not-found', + 'protocol', + pass ? 'pass' : 'fail', + duration(start), + pass + ? undefined + : `Expected -32601, got ${response.error?.code ?? 'no error'}` + ) + ) + } + + { + const start = Date.now() + const response = await jsonRpcRequest(provider, { nonsense: true }) + 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, + provider: ProviderConfig +): Promise { + const methodNames = await readRequiredMethods(profile) + const results: TestResult[] = [] + + for (const methodName of methodNames) { + const start = Date.now() + const response = await jsonRpcRequest(provider, { + jsonrpc: '2.0', + id: `schema-${methodName}`, + method: methodName, + params: {}, + }) + + // For existence probing, anything except method-not-found counts as implemented. + const pass = response.error?.code !== -32601 + results.push( + makeResult( + `CIP103-SCHEMA-${methodName}`, + `Method '${methodName}' is implemented`, + 'schema', + pass ? 'pass' : 'fail', + duration(start), + pass ? undefined : `Method returned -32601 (not found)` + ) + ) + } + + return results +} + +async function runBehaviorSmokeTests( + profile: Profile, + provider: ProviderConfig +): Promise { + const start = Date.now() + const response = await jsonRpcRequest(provider, { + jsonrpc: '2.0', + id: 'behavior-connect', + method: 'connect', + params: {}, + }) + + const pass = response.error?.code !== -32601 + return [ + makeResult( + profile === 'sync' ? 'CIP103-BEH-001' : 'CIP103-BEH-101', + "Provider exposes 'connect' lifecycle method", + 'behavior', + pass ? 'pass' : 'fail', + duration(start), + pass ? undefined : "'connect' method was not found" + ), + ] +} + +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 [protocol, schema, behavior] = await Promise.all([ + runProtocolTests(provider), + runSchemaTests(profile, provider), + runBehaviorSmokeTests(profile, provider), + ]) + const results = [...protocol, ...schema, ...behavior] + return { + schemaVersion: 1, + suite: 'cip-103-conformance', + profile, + provider: { + name: provider.name, + version: provider.version, + endpoint: provider.endpoint, + }, + generatedAt: nowIso(), + summary: summarize(results), + results, + } +} diff --git a/tools/cip103-conformance/src/schemas.ts b/tools/cip103-conformance/src/schemas.ts new file mode 100644 index 000000000..5c0228e9f --- /dev/null +++ b/tools/cip103-conformance/src/schemas.ts @@ -0,0 +1,57 @@ +// 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 + +export const ProviderConfigSchema = z.object({ + name: z.string().min(1), + version: z.string().min(1).optional(), + endpoint: z.url(), + headers: z.record(z.string(), z.string()).optional(), + timeoutMs: z.number().int().positive().optional(), +}) +export type ProviderConfig = z.infer + +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(), + endpoint: z.url(), + }), + 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..fbf9ac7ff --- /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/cli.ts'], + format: ['esm', 'cjs'], + dts: false, + sourcemap: true, + clean: true, + target: 'es2022', + outDir: 'dist', +}) diff --git a/yarn.lock b/yarn.lock index c6278ad93..7de6970db 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1419,6 +1419,21 @@ __metadata: languageName: unknown linkType: soft +"@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" + 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" From 341f7b9e926bef34fda7de4b734d70ee0c5589c1 Mon Sep 17 00:00:00 2001 From: Marc Juchli Date: Wed, 11 Mar 2026 12:10:19 +0100 Subject: [PATCH 02/10] fixing paths and extend docs Signed-off-by: Marc Juchli --- tools/cip103-conformance/README.md | 95 ++- tools/cip103-conformance/package.json | 10 +- .../provider.config.remote.example.json | 5 +- .../scripts/sync-openrpc-specs.ts | 27 + .../specs/openrpc-dapp-api.json | 654 ++++++++++++++++++ .../specs/openrpc-dapp-remote-api.json | 190 +++++ tools/cip103-conformance/src/openrpc.ts | 9 +- 7 files changed, 979 insertions(+), 11 deletions(-) create mode 100644 tools/cip103-conformance/scripts/sync-openrpc-specs.ts create mode 100644 tools/cip103-conformance/specs/openrpc-dapp-api.json create mode 100644 tools/cip103-conformance/specs/openrpc-dapp-remote-api.json diff --git a/tools/cip103-conformance/README.md b/tools/cip103-conformance/README.md index 011dd6fa2..d4404ec7e 100644 --- a/tools/cip103-conformance/README.md +++ b/tools/cip103-conformance/README.md @@ -3,11 +3,79 @@ 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 -- `conformance-cli run --profile sync|async --provider-config ` -- `conformance-cli validate-artifact --artifact [--public-key ] [--require-signature]` -- `conformance-cli export-badge --artifact [--out ]` +### `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 @@ -20,9 +88,28 @@ The CLI is built with `commander` and provides strict option validation. } ``` +## 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", + "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) -Current implementation note: the conformance runner executes JSON-RPC over HTTP. +Current implementation note: the conformance runner executes JSON-RPC over HTTP. For browser extension wallets, use a local bridge endpoint that exposes the extension methods over JSON-RPC. ```json diff --git a/tools/cip103-conformance/package.json b/tools/cip103-conformance/package.json index 3f265b3f3..f4773401d 100644 --- a/tools/cip103-conformance/package.json +++ b/tools/cip103-conformance/package.json @@ -20,9 +20,10 @@ } }, "scripts": { - "build": "tsup && tsc -p tsconfig.types.json", - "dev": "tsup --watch --onSuccess \"tsc -p tsconfig.types.json\"", - "clean": "tsc -b --clean; rm -rf dist", + "sync-specs": "tsx ./scripts/sync-openrpc-specs.ts", + "build": "yarn sync-specs && tsup && tsc -p tsconfig.types.json", + "dev": "yarn sync-specs && tsup --watch --onSuccess \"tsc -p tsconfig.types.json\"", + "clean": "tsc -b --clean; rm -rf dist specs", "run": "tsx src/cli.ts" }, "dependencies": { @@ -36,7 +37,8 @@ "typescript": "^5.9.3" }, "files": [ - "dist/**" + "dist/**", + "specs/**" ], "publishConfig": { "access": "public" diff --git a/tools/cip103-conformance/provider.config.remote.example.json b/tools/cip103-conformance/provider.config.remote.example.json index 6c447169c..0671f7ee0 100644 --- a/tools/cip103-conformance/provider.config.remote.example.json +++ b/tools/cip103-conformance/provider.config.remote.example.json @@ -2,5 +2,8 @@ "name": "Example Wallet Provider", "version": "0.0.0", "endpoint": "http://localhost:8081/json-rpc", - "timeoutMs": 10000 + "timeoutMs": 10000, + "headers": { + "authorization": "Bearer eyJ0eXAiOiJKV1QiLCJraWQiOiJmZGRlZjcyN2M1ZWNlNzU3NmQyYjg3YTJlY2Q5ZjQ4ZGUwZjY0MzgzYTc5OGE3NzE0MTMyZjUyNDBkM2RlZGQ0ZmNhMGY2MmRkNTQxMGEzNyIsImFsZyI6IlJTMjU2In0.eyJpc3MiOiJodHRwOi8vMTI3LjAuMC4xOjg4ODkiLCJpYXQiOjE3NzMyMjcwNzYsImV4cCI6MTc3MzIzMDY3NiwibmJmIjoxNzczMjI3MDY2LCJzdWIiOiJvcGVyYXRvciIsImFtciI6WyJwd2QiXSwic2NvcGUiOiJkYW1sX2xlZGdlcl9hcGkiLCJhdWQiOiJodHRwczovL2RhbWwuY29tL2p3dC9hdWQvcGFydGljaXBhbnQvcGFydGljaXBhbnQxOjoxMjIwZDQ0ZmMxYzNiYTBiNWJkZjdiOTU2ZWU3MWJjOTRlYmUyZDIzMjU4ZGMyNjhmZGYwODI0ZmJhZWZmMmM2MTQyNCJ9.QkUyPvfAcVD_K_1nPjrCltq2sRk98fyosogEVaayH9AlZKr2HdP7X_RnCY_AisVFqE4PLHbYW69BqCIdhRR0WaBBmq83rH2K1xg8s8d0kITDQLdYo3n8sWbx1y2q2dnUEYJ3pUmfftuRAcLEvDCMaEG6RF6xLCvjMkb2yg6Uy33TafR6-Foj1uXpP5Cl2Ir6ONj3166yzjuPfuvwPbntNGD-dWzmFhaxoB1aM2sAnbnxdNdgemf55VIPORj4dL4eu9NHmTkCzgRAuv-qzVOvPp6ELKIVMPhD84r6iKCHm6jhE0iEF1PyovYHd-aLEeTm6_QUWz6POQyTMLaP6re-Ng" + } } diff --git a/tools/cip103-conformance/scripts/sync-openrpc-specs.ts b/tools/cip103-conformance/scripts/sync-openrpc-specs.ts new file mode 100644 index 000000000..675879246 --- /dev/null +++ b/tools/cip103-conformance/scripts/sync-openrpc-specs.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { copyFile, mkdir } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) +const packageRoot = resolve(__dirname, '..') +const repoRoot = resolve(packageRoot, '..', '..') + +const files = ['openrpc-dapp-api.json', 'openrpc-dapp-remote-api.json'] as const + +async function main(): Promise { + const sourceDir = resolve(repoRoot, 'api-specs') + const targetDir = resolve(packageRoot, 'specs') + await mkdir(targetDir, { recursive: true }) + + for (const fileName of files) { + const sourcePath = resolve(sourceDir, fileName) + const targetPath = resolve(targetDir, fileName) + await copyFile(sourcePath, targetPath) + } +} + +void main() diff --git a/tools/cip103-conformance/specs/openrpc-dapp-api.json b/tools/cip103-conformance/specs/openrpc-dapp-api.json new file mode 100644 index 000000000..ab52962d7 --- /dev/null +++ b/tools/cip103-conformance/specs/openrpc-dapp-api.json @@ -0,0 +1,654 @@ +{ + "openrpc": "1.2.6", + "info": { + "title": "Splice Wallet JSON-RPC dApp API", + "version": "0.5.0", + "description": "An OpenRPC specification for the dapp to interact with a Wallet Provider." + }, + "methods": [ + { + "name": "status", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/StatusEvent" + } + }, + "description": "Returns the current status of the wallet provider session." + }, + { + "name": "connect", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/ConnectResult" + } + }, + "description": "Ensures ledger connectivity and returns the connected network information along with the session information." + }, + { + "name": "disconnect", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/Null" + } + }, + "description": "Invoke a disconnect of the wallet provider session." + }, + { + "name": "getActiveNetwork", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/Network" + } + }, + "description": "Returns the active network." + }, + { + "name": "prepareExecute", + "params": [ + { + "name": "params", + "schema": { + "title": "prepareExecuteParams", + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/JsPrepareSubmissionRequest" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/Null" + } + }, + "description": "Prepares a transaction for subsequent signing & execution." + }, + { + "name": "prepareExecuteAndWait", + "params": [ + { + "name": "params", + "schema": { + "title": "prepareExecuteParams", + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/JsPrepareSubmissionRequest" + } + } + ], + "result": { + "name": "result", + "schema": { + "title": "prepareExecuteAndWaitResult", + "type": "object", + "additionalProperties": false, + "properties": { + "tx": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/TxChangedExecutedEvent" + } + }, + "required": ["tx"] + } + }, + "description": "Like prepareExecute, but waits for the transaction to be executed on the ledger." + }, + { + "name": "signMessage", + "params": [ + { + "name": "params", + "schema": { + "title": "signMessageParams", + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/SignMessageRequest" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/SignMessageResult" + } + }, + "description": "Signs a message." + }, + { + "name": "ledgerApi", + "params": [ + { + "name": "params", + "schema": { + "title": "ledgerApiParams", + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/LedgerApiRequest" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/LedgerApiResult" + } + }, + "description": "Proxy for the JSON-API endpoints. Injects authorization headers automatically." + }, + { + "name": "accountsChanged", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/AccountsChangedEvent" + } + } + }, + { + "name": "getPrimaryAccount", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "api-specs/openrpc-user-api.json#/components/schemas/Wallet" + } + }, + "description": "Returns the primary account." + }, + { + "name": "listAccounts", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/ListAccountsResult" + } + }, + "description": "Lists the addresses (wallets) with their properties; including which network they are associated to and with signing provider is used." + }, + { + "name": "txChanged", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/TxChangedEvent" + } + } + } + ], + "components": { + "schemas": { + "Null": { + "title": "Null", + "type": "null", + "description": "Represents a null value, used in responses where no data is returned." + }, + "Provider": { + "title": "Provider", + "type": "object", + "description": "Represents a Provider.", + "additionalProperties": false, + "properties": { + "id": { + "title": "providerId", + "type": "string", + "description": "The unique identifier of the Provider." + }, + "version": { + "title": "version", + "type": "string", + "description": "The version of the Provider." + }, + "providerType": { + "title": "providerType", + "type": "string", + "enum": ["browser", "desktop", "mobile", "remote"], + "description": "The type of client that implements the Provider." + }, + "url": { + "title": "url", + "type": "string", + "description": "The URL of the Wallet Provider." + }, + "userUrl": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/UserUrl" + } + }, + "required": ["id", "clientType"] + }, + "LedgerApiResult": { + "title": "LedgerApiResult", + "type": "object", + "description": "Ledger Api configuration options", + "additionalProperties": false, + "properties": { + "response": { + "title": "response", + "type": "string" + } + }, + "required": ["response"] + }, + "UserUrl": { + "title": "UserUrl", + "type": "string", + "format": "uri", + "description": "A URL that points to a user interface." + }, + "JsPrepareSubmissionRequest": { + "title": "JsPrepareSubmissionRequest", + "type": "object", + "description": "Structure representing the request for prepare and execute calls", + "additionalProperties": false, + "properties": { + "commandId": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/CommandId" + }, + "commands": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/JsCommands" + }, + "actAs": { + "title": "actAs", + "type": "array", + "description": "Set of parties on whose behalf the command should be executed, if submitted. If not set, the primary wallet's party is used.", + "items": { + "title": "party", + "type": "string" + } + }, + "readAs": { + "title": "readAs", + "type": "array", + "description": "Set of parties that should be granted read access to the command, if submitted. If not set, no additional read parties are granted.", + "items": { + "title": "party", + "type": "string" + } + }, + "disclosedContracts": { + "title": "disclosedContracts", + "type": "array", + "description": "List of contract IDs to be disclosed with the command.", + "items": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/DisclosedContract" + } + }, + "synchronizerId": { + "title": "synchronizerId", + "type": "string", + "description": "If not set, a suitable synchronizer that this node is connected to will be chosen." + }, + "packageIdSelectionPreference": { + "title": "packageIdSelectionPreference", + "type": "array", + "description": "The package-id selection preference of the client for resolving package names and interface instances in command submission and interpretation", + "items": { + "title": "packageId", + "type": "string" + } + } + }, + "required": ["commands"] + }, + "DisclosedContract": { + "title": "DisclosedContract", + "type": "object", + "description": "Structure representing a disclosed contract for transaction execution", + "additionalProperties": false, + "properties": { + "templateId": { + "title": "templateId", + "type": "string", + "description": "The template identifier of the disclosed contract." + }, + "contractId": { + "title": "contractId", + "type": "string", + "description": "The unique identifier of the disclosed contract." + }, + "createdEventBlob": { + "title": "createdEventBlob", + "type": "string", + "description": "The blob data of the created event for the disclosed contract." + }, + "synchronizerId": { + "title": "synchronizerId", + "type": "string", + "description": "The synchronizer identifier associated with the disclosed contract." + } + }, + "required": ["createdEventBlob"] + }, + "JsCommands": { + "title": "JsCommands", + "type": "object", + "description": "Structure representing JS commands for transaction execution", + "additionalProperties": true + }, + "JsPrepareSubmissionResponse": { + "title": "JsPrepareSubmissionResponse", + "type": "object", + "description": "Structure representing the result of a prepareReturn call", + "additionalProperties": false, + "properties": { + "preparedTransaction": { + "title": "preparedTransaction", + "type": "string", + "description": "The prepared transaction data." + }, + "preparedTransactionHash": { + "title": "preparedTransactionHash", + "type": "string", + "description": "The hash of the prepared transaction." + } + } + }, + "LedgerApiRequest": { + "title": "LedgerApiRequest", + "type": "object", + "description": "Ledger API request structure", + "additionalProperties": false, + "properties": { + "requestMethod": { + "title": "requestMethod", + "type": "string", + "enum": ["GET", "POST", "PUT", "DELETE"] + }, + "resource": { + "title": "resource", + "type": "string" + }, + "body": { + "title": "body", + "type": "string" + } + }, + "required": ["requestMethod", "resource"] + }, + "AccountsChangedEvent": { + "title": "AccountsChangedEvent", + "type": "array", + "description": "Event emitted when the user's accounts change.", + "items": { + "$ref": "api-specs/openrpc-user-api.json#/components/schemas/Wallet" + } + }, + "ListAccountsResult": { + "title": "ListAccountsResult", + "type": "array", + "description": "An array of accounts that the user has authorized the dapp to access..", + "items": { + "$ref": "api-specs/openrpc-user-api.json#/components/schemas/Wallet" + } + }, + "CommandId": { + "title": "CommandId", + "type": "string", + "description": "The unique identifier of the command associated with the transaction." + }, + "TxChangedPendingEvent": { + "title": "TxChangedPendingEvent", + "description": "Event emitted when a transaction is pending.", + "type": "object", + "additionalProperties": false, + "properties": { + "status": { + "title": "statusPending", + "type": "string", + "enum": ["pending"], + "description": "The status of the transaction." + }, + "commandId": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/CommandId" + } + }, + "required": ["status", "commandId"] + }, + "TxChangedSignedPayload": { + "type": "object", + "title": "TxChangedSignedPayload", + "description": "Payload for the TxChangedSignedEvent.", + "additionalProperties": false, + "properties": { + "signature": { + "title": "signature", + "type": "string", + "description": "The signature of the transaction." + }, + "signedBy": { + "title": "signedBy", + "type": "string", + "description": "The identifier of the provider that signed the transaction." + }, + "party": { + "title": "party", + "type": "string", + "description": "The party that signed the transaction." + } + }, + "required": ["signature", "signedBy", "party"] + }, + "TxChangedSignedEvent": { + "title": "TxChangedSignedEvent", + "description": "Event emitted when a transaction has been signed.", + "type": "object", + "additionalProperties": false, + "properties": { + "status": { + "title": "statusSigned", + "type": "string", + "enum": ["signed"], + "description": "The status of the transaction." + }, + "commandId": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/CommandId" + }, + "payload": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/TxChangedSignedPayload" + } + }, + "required": ["status", "commandId", "payload"] + }, + "TxChangedExecutedPayload": { + "type": "object", + "title": "TxChangedExecutedPayload", + "description": "Payload for the TxChangedExecutedEvent.", + "additionalProperties": false, + "properties": { + "updateId": { + "title": "updateId", + "type": "string", + "description": "The update ID corresponding to the transaction." + }, + "completionOffset": { + "title": "completionOffset", + "type": "integer" + } + }, + "required": ["updateId", "completionOffset"] + }, + "TxChangedExecutedEvent": { + "title": "TxChangedExecutedEvent", + "description": "Event emitted when a transaction is executed against the participant.", + "type": "object", + "additionalProperties": false, + "properties": { + "status": { + "title": "statusExecuted", + "type": "string", + "enum": ["executed"], + "description": "The status of the transaction." + }, + "commandId": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/CommandId" + }, + "payload": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/TxChangedExecutedPayload" + } + }, + "required": ["status", "commandId", "payload"] + }, + "TxChangedFailedEvent": { + "title": "TxChangedFailedEvent", + "description": "Event emitted when a transaction has failed.", + "type": "object", + "additionalProperties": false, + "properties": { + "status": { + "title": "statusFailed", + "type": "string", + "enum": ["failed"], + "description": "The status of the transaction." + }, + "commandId": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/CommandId" + } + }, + "required": ["status", "commandId"] + }, + "TxChangedEvent": { + "title": "TxChangedEvent", + "description": "Event emitted when a transaction changes.", + "oneOf": [ + { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/TxChangedPendingEvent" + }, + { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/TxChangedSignedEvent" + }, + { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/TxChangedExecutedEvent" + }, + { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/TxChangedFailedEvent" + } + ] + }, + "ConnectResult": { + "title": "ConnectResult", + "type": "object", + "additionalProperties": false, + "properties": { + "isConnected": { + "title": "isConnected", + "type": "boolean", + "description": "Whether or not the user is authenticated with the Wallet." + }, + "reason": { + "title": "reason", + "type": "string", + "description": "The reason why the user is not connected to the Wallet." + }, + "isNetworkConnected": { + "title": "isNetworkConnected", + "type": "boolean", + "description": "Whether or not a connection to a network is established." + }, + "networkReason": { + "title": "networkReason", + "type": "string", + "description": "If not connected to a network, the reason why." + }, + "userUrl": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/UserUrl" + } + }, + "required": ["isConnected", "isNetworkConnected"] + }, + "StatusEvent": { + "title": "StatusEvent", + "type": "object", + "additionalProperties": false, + "properties": { + "provider": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/Provider" + }, + "connection": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/ConnectResult" + }, + "network": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/Network" + }, + "session": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/Session" + } + }, + "required": ["provider", "connection"] + }, + "SignMessageRequest": { + "title": "SignMessageRequest", + "type": "object", + "description": "Request to sign a message.", + "additionalProperties": false, + "properties": { + "message": { + "title": "message", + "type": "string", + "description": "The message to sign." + } + }, + "required": ["message"] + }, + "SignMessageResult": { + "title": "SignMessageResult", + "type": "object", + "additionalProperties": false, + "description": "Result of signing a message.", + "properties": { + "signature": { + "title": "signature", + "type": "string", + "description": "The signature of the message." + } + }, + "required": ["signature"] + }, + "Network": { + "title": "network", + "type": "object", + "description": "Network information, if connected to a network.", + "additionalProperties": false, + "properties": { + "networkId": { + "title": "networkId", + "type": "string", + "description": "A CAIP-2 compliant chain ID, e.g. 'canton:da-mainnet'." + }, + "ledgerApi": { + "title": "ledgerApiUrl", + "type": "string", + "description": "The base URL of the ledger API.", + "format": "uri" + }, + "accessToken": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/AccessToken" + } + }, + "required": ["networkId"] + }, + "AccessToken": { + "title": "accessToken", + "type": "string", + "description": "JWT authentication token." + }, + "Session": { + "title": "session", + "type": "object", + "description": "Session information, if authenticated.", + "additionalProperties": false, + "properties": { + "accessToken": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/AccessToken" + }, + "userId": { + "title": "userId", + "type": "string", + "description": "The user identifier." + } + }, + "required": ["accessToken", "userId"] + } + } + } +} diff --git a/tools/cip103-conformance/specs/openrpc-dapp-remote-api.json b/tools/cip103-conformance/specs/openrpc-dapp-remote-api.json new file mode 100644 index 000000000..7d694a9a2 --- /dev/null +++ b/tools/cip103-conformance/specs/openrpc-dapp-remote-api.json @@ -0,0 +1,190 @@ +{ + "openrpc": "1.2.6", + "info": { + "title": "Splice Wallet JSON-RPC Remote dApp API", + "version": "0.1.0", + "description": "An OpenRPC specification for remotely hosted Wallet Providers. Due to the remote nature, an implementing provider must bridge certain functionality on the client-side to satisfy the general dApp API spec." + }, + "methods": [ + { + "name": "status", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/StatusEvent" + } + }, + "description": "Returns the current status of the wallet provider session." + }, + { + "name": "connect", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/ConnectResult" + } + }, + "description": "Ensures ledger connectivity and returns the connected network information." + }, + { + "name": "disconnect", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/Null" + } + }, + "description": "Invoke a disconnect of the wallet gateway session." + }, + { + "name": "getActiveNetwork", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/Network" + } + }, + "description": "Returns the active network." + }, + { + "name": "prepareExecute", + "params": [ + { + "name": "params", + "schema": { + "title": "prepareExecuteParams", + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/JsPrepareSubmissionRequest" + } + } + ], + "result": { + "name": "result", + "schema": { + "title": "prepareExecuteResult", + "type": "object", + "additionalProperties": false, + "properties": { + "userUrl": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/UserUrl" + } + }, + "required": ["userUrl"] + } + }, + "description": "Prepares, signs, and executes a transaction." + }, + { + "name": "signMessage", + "params": [ + { + "name": "params", + "schema": { + "title": "signMessageParams", + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/SignMessageRequest" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/SignMessageResult" + } + }, + "description": "Signs a message." + }, + { + "name": "ledgerApi", + "params": [ + { + "name": "params", + "schema": { + "title": "ledgerApiParams", + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/LedgerApiRequest" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/LedgerApiResult" + } + }, + "description": "Proxy for the JSON-API endpoints. Injects authorization headers automatically." + }, + { + "name": "connected", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/StatusEvent" + } + }, + "description": "Informs when the user connects to a network." + }, + { + "name": "onStatusChanged", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/StatusEvent" + } + } + }, + { + "name": "accountsChanged", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/AccountsChangedEvent" + } + } + }, + { + "name": "getPrimaryAccount", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "api-specs/openrpc-user-api.json#/components/schemas/Wallet" + } + }, + "description": "Returns the primary account." + }, + { + "name": "listAccounts", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/ListAccountsResult" + } + } + }, + { + "name": "txChanged", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/TxChangedEvent" + } + } + } + ], + "components": { + "schemas": { + "Null": { + "title": "Null", + "type": "null", + "description": "Represents a null value, used in responses where no data is returned." + } + } + } +} diff --git a/tools/cip103-conformance/src/openrpc.ts b/tools/cip103-conformance/src/openrpc.ts index 22659db97..c663e1223 100644 --- a/tools/cip103-conformance/src/openrpc.ts +++ b/tools/cip103-conformance/src/openrpc.ts @@ -2,7 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { readFile } from 'node:fs/promises' -import { resolve } from 'node:path' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' import type { Profile } from './schemas' interface OpenRpcMethod { @@ -14,11 +15,15 @@ interface OpenRpcDocument { } function openRpcPath(profile: Profile): string { + const moduleDir = + typeof __dirname === 'string' + ? __dirname + : dirname(fileURLToPath(import.meta.url)) const file = profile === 'sync' ? 'openrpc-dapp-api.json' : 'openrpc-dapp-remote-api.json' - return resolve(process.cwd(), 'api-specs', file) + return resolve(moduleDir, '..', 'specs', file) } export async function readRequiredMethods(profile: Profile): Promise { From ce4b61dcafe999df63fac78bcac6219cc2f4e99c Mon Sep 17 00:00:00 2001 From: Marc Juchli Date: Wed, 11 Mar 2026 12:15:36 +0100 Subject: [PATCH 03/10] injected transport variant Signed-off-by: Marc Juchli --- tools/cip103-conformance/.gitignore | 1 + tools/cip103-conformance/README.md | 25 +- tools/cip103-conformance/package.json | 1 + ...ider.config.browser-extension.example.json | 13 +- .../provider.config.remote.example.json | 1 + tools/cip103-conformance/src/index.ts | 9 +- tools/cip103-conformance/src/rpc.ts | 214 +++++++++++++++++- tools/cip103-conformance/src/runner.ts | 106 +++++---- tools/cip103-conformance/src/schemas.ts | 29 ++- yarn.lock | 3 +- 10 files changed, 334 insertions(+), 68 deletions(-) create mode 100644 tools/cip103-conformance/.gitignore 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 index d4404ec7e..e13b0843e 100644 --- a/tools/cip103-conformance/README.md +++ b/tools/cip103-conformance/README.md @@ -83,6 +83,7 @@ conformance-cli export-badge --artifact dist/conformance/remote-result.json --ou { "name": "Example Wallet Provider", "version": "1.2.3", + "transport": "http", "endpoint": "http://localhost:8081/json-rpc", "timeoutMs": 10000 } @@ -97,6 +98,7 @@ In many setups, an Authorization header is required. { "name": "Example Server-Side Wallet", "version": "1.2.3", + "transport": "http", "endpoint": "https://wallet.example.com/api/v0/dapp", "timeoutMs": 15000, "headers": { @@ -109,26 +111,27 @@ See `provider.config.remote.example.json` for a copy-ready template. ## Example Provider Config (Browser Extension Wallet) -Current implementation note: the conformance runner executes JSON-RPC over HTTP. -For browser extension wallets, use a local bridge endpoint that exposes the extension methods over JSON-RPC. +Use injected mode to call the extension provider directly in a browser context. +The runner opens `appUrl` and resolves the provider from `injectedNamespace`. ```json { "name": "Example Browser Extension Wallet", "version": "1.0.0", - "endpoint": "http://127.0.0.1:12481/json-rpc", - "timeoutMs": 10000, - "headers": { - "x-provider-kind": "browser-extension" - }, - "extensionId": "abcdefghijklmnoabcdefghijklmn", - "injectedNamespace": "window.canton" + "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` -> `api-specs/openrpc-dapp-api.json` -- `async` -> `api-specs/openrpc-dapp-remote-api.json` +- `sync` -> bundled `openrpc-dapp-api.json` +- `async` -> bundled `openrpc-dapp-remote-api.json` diff --git a/tools/cip103-conformance/package.json b/tools/cip103-conformance/package.json index f4773401d..50348869b 100644 --- a/tools/cip103-conformance/package.json +++ b/tools/cip103-conformance/package.json @@ -28,6 +28,7 @@ }, "dependencies": { "commander": "^14.0.3", + "playwright": "^1.58.2", "zod": "^4.3.6" }, "devDependencies": { diff --git a/tools/cip103-conformance/provider.config.browser-extension.example.json b/tools/cip103-conformance/provider.config.browser-extension.example.json index 2e1ee5ed4..e31577bc2 100644 --- a/tools/cip103-conformance/provider.config.browser-extension.example.json +++ b/tools/cip103-conformance/provider.config.browser-extension.example.json @@ -1,11 +1,10 @@ { "name": "Example Browser Extension Wallet", "version": "1.0.0", - "endpoint": "http://127.0.0.1:12481/json-rpc", - "timeoutMs": 10000, - "headers": { - "x-provider-kind": "browser-extension" - }, - "extensionId": "abcdefghijklmnoabcdefghijklmn", - "injectedNamespace": "window.canton" + "transport": "injected", + "appUrl": "http://localhost:8080", + "injectedNamespace": "window.canton", + "extensionPath": "/absolute/path/to/unpacked-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 index 0671f7ee0..862559f95 100644 --- a/tools/cip103-conformance/provider.config.remote.example.json +++ b/tools/cip103-conformance/provider.config.remote.example.json @@ -1,6 +1,7 @@ { "name": "Example Wallet Provider", "version": "0.0.0", + "transport": "http", "endpoint": "http://localhost:8081/json-rpc", "timeoutMs": 10000, "headers": { diff --git a/tools/cip103-conformance/src/index.ts b/tools/cip103-conformance/src/index.ts index 8df0c8094..ced5f23b6 100644 --- a/tools/cip103-conformance/src/index.ts +++ b/tools/cip103-conformance/src/index.ts @@ -15,4 +15,11 @@ export { verifyArtifactSignature, writeArtifact, } from './artifact' -export type { Artifact, Profile, ProviderConfig, TestResult } from './schemas' +export type { + Artifact, + HttpProviderConfig, + InjectedProviderConfig, + Profile, + ProviderConfig, + TestResult, +} from './schemas' diff --git a/tools/cip103-conformance/src/rpc.ts b/tools/cip103-conformance/src/rpc.ts index 0c879083c..e787e2b7e 100644 --- a/tools/cip103-conformance/src/rpc.ts +++ b/tools/cip103-conformance/src/rpc.ts @@ -2,7 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 import { setTimeout as sleep } from 'node:timers/promises' -import type { ProviderConfig } from './schemas' +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 @@ -14,15 +21,27 @@ export interface JsonRpcResponse { error?: JsonRpcError } -function buildHeaders(provider: ProviderConfig): HeadersInit { +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, } } -export async function jsonRpcRequest( - provider: ProviderConfig, +async function jsonRpcHttpRequest( + provider: HttpProviderConfig, payload: unknown ): Promise { const controller = new AbortController() @@ -56,3 +75,190 @@ export async function jsonRpcRequest( 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.injectedNamespace ?? 'window.canton' + + 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 index f677e03ad..c460b9a4a 100644 --- a/tools/cip103-conformance/src/runner.ts +++ b/tools/cip103-conformance/src/runner.ts @@ -2,9 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { readRequiredMethods } from './openrpc' -import { jsonRpcRequest } from './rpc' +import { createTransport, type ConformanceTransport } from './rpc' import type { Artifact, + HttpProviderConfig, + InjectedProviderConfig, Profile, ProviderConfig, TestResult, @@ -31,18 +33,16 @@ function makeResult( } async function runProtocolTests( - provider: ProviderConfig + transport: ConformanceTransport ): Promise { const results: TestResult[] = [] { const start = Date.now() - const response = await jsonRpcRequest(provider, { - jsonrpc: '2.0', - id: 'protocol-unknown', - method: '__cip103_unknown_method__', - params: {}, - }) + const response = await transport.request( + '__cip103_unknown_method__', + {} + ) const pass = response.error?.code === -32601 results.push( makeResult( @@ -60,7 +60,20 @@ async function runProtocolTests( { const start = Date.now() - const response = await jsonRpcRequest(provider, { nonsense: true }) + 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( @@ -81,19 +94,14 @@ async function runProtocolTests( async function runSchemaTests( profile: Profile, - provider: ProviderConfig + transport: ConformanceTransport ): Promise { const methodNames = await readRequiredMethods(profile) const results: TestResult[] = [] for (const methodName of methodNames) { const start = Date.now() - const response = await jsonRpcRequest(provider, { - jsonrpc: '2.0', - id: `schema-${methodName}`, - method: methodName, - params: {}, - }) + const response = await transport.request(methodName, {}) // For existence probing, anything except method-not-found counts as implemented. const pass = response.error?.code !== -32601 @@ -114,15 +122,10 @@ async function runSchemaTests( async function runBehaviorSmokeTests( profile: Profile, - provider: ProviderConfig + transport: ConformanceTransport ): Promise { const start = Date.now() - const response = await jsonRpcRequest(provider, { - jsonrpc: '2.0', - id: 'behavior-connect', - method: 'connect', - params: {}, - }) + const response = await transport.request('connect', {}) const pass = response.error?.code !== -32601 return [ @@ -137,6 +140,26 @@ async function runBehaviorSmokeTests( ] } +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 @@ -155,23 +178,24 @@ export async function runConformance( profile: Profile, provider: ProviderConfig ): Promise { - const [protocol, schema, behavior] = await Promise.all([ - runProtocolTests(provider), - runSchemaTests(profile, provider), - runBehaviorSmokeTests(profile, provider), - ]) - const results = [...protocol, ...schema, ...behavior] - return { - schemaVersion: 1, - suite: 'cip-103-conformance', - profile, - provider: { - name: provider.name, - version: provider.version, - endpoint: provider.endpoint, - }, - generatedAt: nowIso(), - summary: summarize(results), - results, + 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 index 5c0228e9f..cda185ab7 100644 --- a/tools/cip103-conformance/src/schemas.ts +++ b/tools/cip103-conformance/src/schemas.ts @@ -6,14 +6,35 @@ import { z } from 'zod' export const ProfileSchema = z.enum(['sync', 'async']) export type Profile = z.infer -export const ProviderConfigSchema = z.object({ +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(), - timeoutMs: z.number().int().positive().optional(), }) + +export const InjectedProviderConfigSchema = BaseProviderConfigSchema.extend({ + transport: z.literal('injected'), + appUrl: z.url(), + injectedNamespace: 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 @@ -35,7 +56,9 @@ export const ArtifactSchema = z.object({ provider: z.object({ name: z.string(), version: z.string().optional(), - endpoint: z.url(), + transport: z.enum(['http', 'injected']), + endpoint: z.url().optional(), + appUrl: z.url().optional(), }), generatedAt: z.string(), summary: z.object({ diff --git a/yarn.lock b/yarn.lock index 7de6970db..71099c9ee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1425,6 +1425,7 @@ __metadata: 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" @@ -16255,7 +16256,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: From 7e4f97d7cb2ee0b027c3d4607db292f57f3c4dcf Mon Sep 17 00:00:00 2001 From: Marc Juchli Date: Wed, 11 Mar 2026 12:21:54 +0100 Subject: [PATCH 04/10] copy specs in postbuild Signed-off-by: Marc Juchli --- tools/cip103-conformance/package.json | 11 +- .../scripts/sync-openrpc-specs.ts | 27 - .../specs/openrpc-dapp-api.json | 654 ------------------ .../specs/openrpc-dapp-remote-api.json | 190 ----- tools/cip103-conformance/src/openrpc.ts | 17 +- 5 files changed, 18 insertions(+), 881 deletions(-) delete mode 100644 tools/cip103-conformance/scripts/sync-openrpc-specs.ts delete mode 100644 tools/cip103-conformance/specs/openrpc-dapp-api.json delete mode 100644 tools/cip103-conformance/specs/openrpc-dapp-remote-api.json diff --git a/tools/cip103-conformance/package.json b/tools/cip103-conformance/package.json index 50348869b..7193d0907 100644 --- a/tools/cip103-conformance/package.json +++ b/tools/cip103-conformance/package.json @@ -20,10 +20,10 @@ } }, "scripts": { - "sync-specs": "tsx ./scripts/sync-openrpc-specs.ts", - "build": "yarn sync-specs && tsup && tsc -p tsconfig.types.json", - "dev": "yarn sync-specs && tsup --watch --onSuccess \"tsc -p tsconfig.types.json\"", - "clean": "tsc -b --clean; rm -rf dist specs", + "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" }, "dependencies": { @@ -38,8 +38,7 @@ "typescript": "^5.9.3" }, "files": [ - "dist/**", - "specs/**" + "dist/**" ], "publishConfig": { "access": "public" diff --git a/tools/cip103-conformance/scripts/sync-openrpc-specs.ts b/tools/cip103-conformance/scripts/sync-openrpc-specs.ts deleted file mode 100644 index 675879246..000000000 --- a/tools/cip103-conformance/scripts/sync-openrpc-specs.ts +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { copyFile, mkdir } from 'node:fs/promises' -import { dirname, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = dirname(__filename) -const packageRoot = resolve(__dirname, '..') -const repoRoot = resolve(packageRoot, '..', '..') - -const files = ['openrpc-dapp-api.json', 'openrpc-dapp-remote-api.json'] as const - -async function main(): Promise { - const sourceDir = resolve(repoRoot, 'api-specs') - const targetDir = resolve(packageRoot, 'specs') - await mkdir(targetDir, { recursive: true }) - - for (const fileName of files) { - const sourcePath = resolve(sourceDir, fileName) - const targetPath = resolve(targetDir, fileName) - await copyFile(sourcePath, targetPath) - } -} - -void main() diff --git a/tools/cip103-conformance/specs/openrpc-dapp-api.json b/tools/cip103-conformance/specs/openrpc-dapp-api.json deleted file mode 100644 index ab52962d7..000000000 --- a/tools/cip103-conformance/specs/openrpc-dapp-api.json +++ /dev/null @@ -1,654 +0,0 @@ -{ - "openrpc": "1.2.6", - "info": { - "title": "Splice Wallet JSON-RPC dApp API", - "version": "0.5.0", - "description": "An OpenRPC specification for the dapp to interact with a Wallet Provider." - }, - "methods": [ - { - "name": "status", - "params": [], - "result": { - "name": "result", - "schema": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/StatusEvent" - } - }, - "description": "Returns the current status of the wallet provider session." - }, - { - "name": "connect", - "params": [], - "result": { - "name": "result", - "schema": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/ConnectResult" - } - }, - "description": "Ensures ledger connectivity and returns the connected network information along with the session information." - }, - { - "name": "disconnect", - "params": [], - "result": { - "name": "result", - "schema": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/Null" - } - }, - "description": "Invoke a disconnect of the wallet provider session." - }, - { - "name": "getActiveNetwork", - "params": [], - "result": { - "name": "result", - "schema": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/Network" - } - }, - "description": "Returns the active network." - }, - { - "name": "prepareExecute", - "params": [ - { - "name": "params", - "schema": { - "title": "prepareExecuteParams", - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/JsPrepareSubmissionRequest" - } - } - ], - "result": { - "name": "result", - "schema": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/Null" - } - }, - "description": "Prepares a transaction for subsequent signing & execution." - }, - { - "name": "prepareExecuteAndWait", - "params": [ - { - "name": "params", - "schema": { - "title": "prepareExecuteParams", - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/JsPrepareSubmissionRequest" - } - } - ], - "result": { - "name": "result", - "schema": { - "title": "prepareExecuteAndWaitResult", - "type": "object", - "additionalProperties": false, - "properties": { - "tx": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/TxChangedExecutedEvent" - } - }, - "required": ["tx"] - } - }, - "description": "Like prepareExecute, but waits for the transaction to be executed on the ledger." - }, - { - "name": "signMessage", - "params": [ - { - "name": "params", - "schema": { - "title": "signMessageParams", - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/SignMessageRequest" - } - } - ], - "result": { - "name": "result", - "schema": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/SignMessageResult" - } - }, - "description": "Signs a message." - }, - { - "name": "ledgerApi", - "params": [ - { - "name": "params", - "schema": { - "title": "ledgerApiParams", - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/LedgerApiRequest" - } - } - ], - "result": { - "name": "result", - "schema": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/LedgerApiResult" - } - }, - "description": "Proxy for the JSON-API endpoints. Injects authorization headers automatically." - }, - { - "name": "accountsChanged", - "params": [], - "result": { - "name": "result", - "schema": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/AccountsChangedEvent" - } - } - }, - { - "name": "getPrimaryAccount", - "params": [], - "result": { - "name": "result", - "schema": { - "$ref": "api-specs/openrpc-user-api.json#/components/schemas/Wallet" - } - }, - "description": "Returns the primary account." - }, - { - "name": "listAccounts", - "params": [], - "result": { - "name": "result", - "schema": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/ListAccountsResult" - } - }, - "description": "Lists the addresses (wallets) with their properties; including which network they are associated to and with signing provider is used." - }, - { - "name": "txChanged", - "params": [], - "result": { - "name": "result", - "schema": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/TxChangedEvent" - } - } - } - ], - "components": { - "schemas": { - "Null": { - "title": "Null", - "type": "null", - "description": "Represents a null value, used in responses where no data is returned." - }, - "Provider": { - "title": "Provider", - "type": "object", - "description": "Represents a Provider.", - "additionalProperties": false, - "properties": { - "id": { - "title": "providerId", - "type": "string", - "description": "The unique identifier of the Provider." - }, - "version": { - "title": "version", - "type": "string", - "description": "The version of the Provider." - }, - "providerType": { - "title": "providerType", - "type": "string", - "enum": ["browser", "desktop", "mobile", "remote"], - "description": "The type of client that implements the Provider." - }, - "url": { - "title": "url", - "type": "string", - "description": "The URL of the Wallet Provider." - }, - "userUrl": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/UserUrl" - } - }, - "required": ["id", "clientType"] - }, - "LedgerApiResult": { - "title": "LedgerApiResult", - "type": "object", - "description": "Ledger Api configuration options", - "additionalProperties": false, - "properties": { - "response": { - "title": "response", - "type": "string" - } - }, - "required": ["response"] - }, - "UserUrl": { - "title": "UserUrl", - "type": "string", - "format": "uri", - "description": "A URL that points to a user interface." - }, - "JsPrepareSubmissionRequest": { - "title": "JsPrepareSubmissionRequest", - "type": "object", - "description": "Structure representing the request for prepare and execute calls", - "additionalProperties": false, - "properties": { - "commandId": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/CommandId" - }, - "commands": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/JsCommands" - }, - "actAs": { - "title": "actAs", - "type": "array", - "description": "Set of parties on whose behalf the command should be executed, if submitted. If not set, the primary wallet's party is used.", - "items": { - "title": "party", - "type": "string" - } - }, - "readAs": { - "title": "readAs", - "type": "array", - "description": "Set of parties that should be granted read access to the command, if submitted. If not set, no additional read parties are granted.", - "items": { - "title": "party", - "type": "string" - } - }, - "disclosedContracts": { - "title": "disclosedContracts", - "type": "array", - "description": "List of contract IDs to be disclosed with the command.", - "items": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/DisclosedContract" - } - }, - "synchronizerId": { - "title": "synchronizerId", - "type": "string", - "description": "If not set, a suitable synchronizer that this node is connected to will be chosen." - }, - "packageIdSelectionPreference": { - "title": "packageIdSelectionPreference", - "type": "array", - "description": "The package-id selection preference of the client for resolving package names and interface instances in command submission and interpretation", - "items": { - "title": "packageId", - "type": "string" - } - } - }, - "required": ["commands"] - }, - "DisclosedContract": { - "title": "DisclosedContract", - "type": "object", - "description": "Structure representing a disclosed contract for transaction execution", - "additionalProperties": false, - "properties": { - "templateId": { - "title": "templateId", - "type": "string", - "description": "The template identifier of the disclosed contract." - }, - "contractId": { - "title": "contractId", - "type": "string", - "description": "The unique identifier of the disclosed contract." - }, - "createdEventBlob": { - "title": "createdEventBlob", - "type": "string", - "description": "The blob data of the created event for the disclosed contract." - }, - "synchronizerId": { - "title": "synchronizerId", - "type": "string", - "description": "The synchronizer identifier associated with the disclosed contract." - } - }, - "required": ["createdEventBlob"] - }, - "JsCommands": { - "title": "JsCommands", - "type": "object", - "description": "Structure representing JS commands for transaction execution", - "additionalProperties": true - }, - "JsPrepareSubmissionResponse": { - "title": "JsPrepareSubmissionResponse", - "type": "object", - "description": "Structure representing the result of a prepareReturn call", - "additionalProperties": false, - "properties": { - "preparedTransaction": { - "title": "preparedTransaction", - "type": "string", - "description": "The prepared transaction data." - }, - "preparedTransactionHash": { - "title": "preparedTransactionHash", - "type": "string", - "description": "The hash of the prepared transaction." - } - } - }, - "LedgerApiRequest": { - "title": "LedgerApiRequest", - "type": "object", - "description": "Ledger API request structure", - "additionalProperties": false, - "properties": { - "requestMethod": { - "title": "requestMethod", - "type": "string", - "enum": ["GET", "POST", "PUT", "DELETE"] - }, - "resource": { - "title": "resource", - "type": "string" - }, - "body": { - "title": "body", - "type": "string" - } - }, - "required": ["requestMethod", "resource"] - }, - "AccountsChangedEvent": { - "title": "AccountsChangedEvent", - "type": "array", - "description": "Event emitted when the user's accounts change.", - "items": { - "$ref": "api-specs/openrpc-user-api.json#/components/schemas/Wallet" - } - }, - "ListAccountsResult": { - "title": "ListAccountsResult", - "type": "array", - "description": "An array of accounts that the user has authorized the dapp to access..", - "items": { - "$ref": "api-specs/openrpc-user-api.json#/components/schemas/Wallet" - } - }, - "CommandId": { - "title": "CommandId", - "type": "string", - "description": "The unique identifier of the command associated with the transaction." - }, - "TxChangedPendingEvent": { - "title": "TxChangedPendingEvent", - "description": "Event emitted when a transaction is pending.", - "type": "object", - "additionalProperties": false, - "properties": { - "status": { - "title": "statusPending", - "type": "string", - "enum": ["pending"], - "description": "The status of the transaction." - }, - "commandId": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/CommandId" - } - }, - "required": ["status", "commandId"] - }, - "TxChangedSignedPayload": { - "type": "object", - "title": "TxChangedSignedPayload", - "description": "Payload for the TxChangedSignedEvent.", - "additionalProperties": false, - "properties": { - "signature": { - "title": "signature", - "type": "string", - "description": "The signature of the transaction." - }, - "signedBy": { - "title": "signedBy", - "type": "string", - "description": "The identifier of the provider that signed the transaction." - }, - "party": { - "title": "party", - "type": "string", - "description": "The party that signed the transaction." - } - }, - "required": ["signature", "signedBy", "party"] - }, - "TxChangedSignedEvent": { - "title": "TxChangedSignedEvent", - "description": "Event emitted when a transaction has been signed.", - "type": "object", - "additionalProperties": false, - "properties": { - "status": { - "title": "statusSigned", - "type": "string", - "enum": ["signed"], - "description": "The status of the transaction." - }, - "commandId": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/CommandId" - }, - "payload": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/TxChangedSignedPayload" - } - }, - "required": ["status", "commandId", "payload"] - }, - "TxChangedExecutedPayload": { - "type": "object", - "title": "TxChangedExecutedPayload", - "description": "Payload for the TxChangedExecutedEvent.", - "additionalProperties": false, - "properties": { - "updateId": { - "title": "updateId", - "type": "string", - "description": "The update ID corresponding to the transaction." - }, - "completionOffset": { - "title": "completionOffset", - "type": "integer" - } - }, - "required": ["updateId", "completionOffset"] - }, - "TxChangedExecutedEvent": { - "title": "TxChangedExecutedEvent", - "description": "Event emitted when a transaction is executed against the participant.", - "type": "object", - "additionalProperties": false, - "properties": { - "status": { - "title": "statusExecuted", - "type": "string", - "enum": ["executed"], - "description": "The status of the transaction." - }, - "commandId": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/CommandId" - }, - "payload": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/TxChangedExecutedPayload" - } - }, - "required": ["status", "commandId", "payload"] - }, - "TxChangedFailedEvent": { - "title": "TxChangedFailedEvent", - "description": "Event emitted when a transaction has failed.", - "type": "object", - "additionalProperties": false, - "properties": { - "status": { - "title": "statusFailed", - "type": "string", - "enum": ["failed"], - "description": "The status of the transaction." - }, - "commandId": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/CommandId" - } - }, - "required": ["status", "commandId"] - }, - "TxChangedEvent": { - "title": "TxChangedEvent", - "description": "Event emitted when a transaction changes.", - "oneOf": [ - { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/TxChangedPendingEvent" - }, - { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/TxChangedSignedEvent" - }, - { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/TxChangedExecutedEvent" - }, - { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/TxChangedFailedEvent" - } - ] - }, - "ConnectResult": { - "title": "ConnectResult", - "type": "object", - "additionalProperties": false, - "properties": { - "isConnected": { - "title": "isConnected", - "type": "boolean", - "description": "Whether or not the user is authenticated with the Wallet." - }, - "reason": { - "title": "reason", - "type": "string", - "description": "The reason why the user is not connected to the Wallet." - }, - "isNetworkConnected": { - "title": "isNetworkConnected", - "type": "boolean", - "description": "Whether or not a connection to a network is established." - }, - "networkReason": { - "title": "networkReason", - "type": "string", - "description": "If not connected to a network, the reason why." - }, - "userUrl": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/UserUrl" - } - }, - "required": ["isConnected", "isNetworkConnected"] - }, - "StatusEvent": { - "title": "StatusEvent", - "type": "object", - "additionalProperties": false, - "properties": { - "provider": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/Provider" - }, - "connection": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/ConnectResult" - }, - "network": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/Network" - }, - "session": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/Session" - } - }, - "required": ["provider", "connection"] - }, - "SignMessageRequest": { - "title": "SignMessageRequest", - "type": "object", - "description": "Request to sign a message.", - "additionalProperties": false, - "properties": { - "message": { - "title": "message", - "type": "string", - "description": "The message to sign." - } - }, - "required": ["message"] - }, - "SignMessageResult": { - "title": "SignMessageResult", - "type": "object", - "additionalProperties": false, - "description": "Result of signing a message.", - "properties": { - "signature": { - "title": "signature", - "type": "string", - "description": "The signature of the message." - } - }, - "required": ["signature"] - }, - "Network": { - "title": "network", - "type": "object", - "description": "Network information, if connected to a network.", - "additionalProperties": false, - "properties": { - "networkId": { - "title": "networkId", - "type": "string", - "description": "A CAIP-2 compliant chain ID, e.g. 'canton:da-mainnet'." - }, - "ledgerApi": { - "title": "ledgerApiUrl", - "type": "string", - "description": "The base URL of the ledger API.", - "format": "uri" - }, - "accessToken": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/AccessToken" - } - }, - "required": ["networkId"] - }, - "AccessToken": { - "title": "accessToken", - "type": "string", - "description": "JWT authentication token." - }, - "Session": { - "title": "session", - "type": "object", - "description": "Session information, if authenticated.", - "additionalProperties": false, - "properties": { - "accessToken": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/AccessToken" - }, - "userId": { - "title": "userId", - "type": "string", - "description": "The user identifier." - } - }, - "required": ["accessToken", "userId"] - } - } - } -} diff --git a/tools/cip103-conformance/specs/openrpc-dapp-remote-api.json b/tools/cip103-conformance/specs/openrpc-dapp-remote-api.json deleted file mode 100644 index 7d694a9a2..000000000 --- a/tools/cip103-conformance/specs/openrpc-dapp-remote-api.json +++ /dev/null @@ -1,190 +0,0 @@ -{ - "openrpc": "1.2.6", - "info": { - "title": "Splice Wallet JSON-RPC Remote dApp API", - "version": "0.1.0", - "description": "An OpenRPC specification for remotely hosted Wallet Providers. Due to the remote nature, an implementing provider must bridge certain functionality on the client-side to satisfy the general dApp API spec." - }, - "methods": [ - { - "name": "status", - "params": [], - "result": { - "name": "result", - "schema": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/StatusEvent" - } - }, - "description": "Returns the current status of the wallet provider session." - }, - { - "name": "connect", - "params": [], - "result": { - "name": "result", - "schema": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/ConnectResult" - } - }, - "description": "Ensures ledger connectivity and returns the connected network information." - }, - { - "name": "disconnect", - "params": [], - "result": { - "name": "result", - "schema": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/Null" - } - }, - "description": "Invoke a disconnect of the wallet gateway session." - }, - { - "name": "getActiveNetwork", - "params": [], - "result": { - "name": "result", - "schema": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/Network" - } - }, - "description": "Returns the active network." - }, - { - "name": "prepareExecute", - "params": [ - { - "name": "params", - "schema": { - "title": "prepareExecuteParams", - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/JsPrepareSubmissionRequest" - } - } - ], - "result": { - "name": "result", - "schema": { - "title": "prepareExecuteResult", - "type": "object", - "additionalProperties": false, - "properties": { - "userUrl": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/UserUrl" - } - }, - "required": ["userUrl"] - } - }, - "description": "Prepares, signs, and executes a transaction." - }, - { - "name": "signMessage", - "params": [ - { - "name": "params", - "schema": { - "title": "signMessageParams", - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/SignMessageRequest" - } - } - ], - "result": { - "name": "result", - "schema": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/SignMessageResult" - } - }, - "description": "Signs a message." - }, - { - "name": "ledgerApi", - "params": [ - { - "name": "params", - "schema": { - "title": "ledgerApiParams", - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/LedgerApiRequest" - } - } - ], - "result": { - "name": "result", - "schema": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/LedgerApiResult" - } - }, - "description": "Proxy for the JSON-API endpoints. Injects authorization headers automatically." - }, - { - "name": "connected", - "params": [], - "result": { - "name": "result", - "schema": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/StatusEvent" - } - }, - "description": "Informs when the user connects to a network." - }, - { - "name": "onStatusChanged", - "params": [], - "result": { - "name": "result", - "schema": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/StatusEvent" - } - } - }, - { - "name": "accountsChanged", - "params": [], - "result": { - "name": "result", - "schema": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/AccountsChangedEvent" - } - } - }, - { - "name": "getPrimaryAccount", - "params": [], - "result": { - "name": "result", - "schema": { - "$ref": "api-specs/openrpc-user-api.json#/components/schemas/Wallet" - } - }, - "description": "Returns the primary account." - }, - { - "name": "listAccounts", - "params": [], - "result": { - "name": "result", - "schema": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/ListAccountsResult" - } - } - }, - { - "name": "txChanged", - "params": [], - "result": { - "name": "result", - "schema": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/TxChangedEvent" - } - } - } - ], - "components": { - "schemas": { - "Null": { - "title": "Null", - "type": "null", - "description": "Represents a null value, used in responses where no data is returned." - } - } - } -} diff --git a/tools/cip103-conformance/src/openrpc.ts b/tools/cip103-conformance/src/openrpc.ts index c663e1223..831c98968 100644 --- a/tools/cip103-conformance/src/openrpc.ts +++ b/tools/cip103-conformance/src/openrpc.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { readFile } from 'node:fs/promises' +import { access, readFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import type { Profile } from './schemas' @@ -14,7 +14,7 @@ interface OpenRpcDocument { methods?: OpenRpcMethod[] } -function openRpcPath(profile: Profile): string { +async function openRpcPath(profile: Profile): Promise { const moduleDir = typeof __dirname === 'string' ? __dirname @@ -23,11 +23,20 @@ function openRpcPath(profile: Profile): string { profile === 'sync' ? 'openrpc-dapp-api.json' : 'openrpc-dapp-remote-api.json' - return resolve(moduleDir, '..', 'specs', file) + + // 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 = openRpcPath(profile) + 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) ?? [] From 31f3e7cb7ebd83076c80af0090093a760cccaf40 Mon Sep 17 00:00:00 2001 From: Marc Juchli Date: Wed, 11 Mar 2026 14:33:09 +0100 Subject: [PATCH 05/10] cleanup and move into service Signed-off-by: Marc Juchli --- tools/cip103-conformance/src/cli.ts | 72 ++++-------- .../src/conformance-service.ts | 105 ++++++++++++++++++ tools/cip103-conformance/src/index.ts | 7 ++ 3 files changed, 133 insertions(+), 51 deletions(-) create mode 100644 tools/cip103-conformance/src/conformance-service.ts diff --git a/tools/cip103-conformance/src/cli.ts b/tools/cip103-conformance/src/cli.ts index c2345fc74..4e4651cca 100644 --- a/tools/cip103-conformance/src/cli.ts +++ b/tools/cip103-conformance/src/cli.ts @@ -1,22 +1,9 @@ // 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 { Command, InvalidArgumentError } from 'commander' -import { - readArtifact, - signArtifact, - toBadgeData, - verifyArtifactSignature, - writeArtifact, -} from './artifact' -import { runConformance } from './runner' -import { - ProfileSchema, - ProviderConfigSchema, - type ProviderConfig, -} from './schemas' +import { ConformanceService } from './conformance-service' +import { ProfileSchema } from './schemas' function asProfile(value: string): 'sync' | 'async' { const parsed = ProfileSchema.safeParse(value) @@ -40,10 +27,7 @@ function toPath(value: string): string { return nonEmpty(value, 'Path') } -async function readProviderConfig(path: string): Promise { - const raw = await readFile(resolve(process.cwd(), path), 'utf8') - return ProviderConfigSchema.parse(JSON.parse(raw)) -} +const conformanceService = new ConformanceService() async function runCommand(options: { profile: 'sync' | 'async' @@ -52,21 +36,13 @@ async function runCommand(options: { signingKey?: string keyId?: string }): Promise { - if (options.keyId && !options.signingKey) { - throw new Error('--key-id requires --signing-key.') - } - - const provider = await readProviderConfig(options.providerConfig) - let artifact = await runConformance(options.profile, provider) - if (options.signingKey) { - artifact = await signArtifact( - artifact, - options.signingKey, - options.keyId - ) - } - - await writeArtifact(options.out, artifact) + 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})` ) @@ -81,23 +57,17 @@ async function validateArtifactCommand(options: { publicKey?: string requireSignature: boolean }): Promise { - const artifact = await readArtifact(options.artifact) - if (options.requireSignature && !artifact.signature) { - throw new Error( - 'Artifact does not contain a signature. Use a signed artifact.' - ) - } - - if (!options.publicKey) { + 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): ${artifact.summary.status.toUpperCase()}` + `Artifact is valid (unsigned check): ${result.artifact.summary.status.toUpperCase()}` ) return } - const ok = await verifyArtifactSignature(artifact, options.publicKey) - if (!ok) { - throw new Error('Signature validation failed.') - } console.log('Artifact + signature validation succeeded.') } @@ -105,10 +75,10 @@ async function exportBadgeCommand(options: { artifact: string out: string }): Promise { - const artifact = await readArtifact(options.artifact) - const badge = toBadgeData(artifact) - const absoluteOut = resolve(process.cwd(), options.out) - await writeFile(absoluteOut, `${JSON.stringify(badge, null, 2)}\n`, 'utf8') + await conformanceService.exportBadge({ + artifactPath: options.artifact, + outPath: options.out, + }) console.log(`Badge exported: ${options.out}`) } 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 index ced5f23b6..9c111e2b8 100644 --- a/tools/cip103-conformance/src/index.ts +++ b/tools/cip103-conformance/src/index.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 export { runConformance } from './runner' +export { ConformanceService } from './conformance-service' export { ArtifactSchema, ProfileSchema, @@ -23,3 +24,9 @@ export type { ProviderConfig, TestResult, } from './schemas' +export type { + ExportBadgeCommandOptions, + RunConformanceCommandOptions, + ValidateArtifactCommandOptions, + ValidateArtifactResult, +} from './conformance-service' From 95ea7bb76ac345a80ffa10d30b35c881a663c5e4 Mon Sep 17 00:00:00 2001 From: Marc Juchli Date: Fri, 27 Mar 2026 00:34:22 +0100 Subject: [PATCH 06/10] tmp commit for browser extension Signed-off-by: Marc Juchli --- tools/cip103-conformance/README.md | 41 ++++- ...ider.config.browser-extension.example.json | 2 +- tools/cip103-conformance/src/rpc.ts | 158 +++++++++++++++++- tools/cip103-conformance/src/schemas.ts | 13 ++ 4 files changed, 211 insertions(+), 3 deletions(-) diff --git a/tools/cip103-conformance/README.md b/tools/cip103-conformance/README.md index e13b0843e..d6d5d9da3 100644 --- a/tools/cip103-conformance/README.md +++ b/tools/cip103-conformance/README.md @@ -112,7 +112,13 @@ 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 from `injectedNamespace`. +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 { @@ -120,6 +126,39 @@ The runner opens `appUrl` and resolves the provider from `injectedNamespace`. "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, diff --git a/tools/cip103-conformance/provider.config.browser-extension.example.json b/tools/cip103-conformance/provider.config.browser-extension.example.json index e31577bc2..b1855560c 100644 --- a/tools/cip103-conformance/provider.config.browser-extension.example.json +++ b/tools/cip103-conformance/provider.config.browser-extension.example.json @@ -3,7 +3,7 @@ "version": "1.0.0", "transport": "injected", "appUrl": "http://localhost:8080", - "injectedNamespace": "window.canton", + "injectedNamespace": "canton.", "extensionPath": "/absolute/path/to/unpacked-extension", "headless": false, "timeoutMs": 10000 diff --git a/tools/cip103-conformance/src/rpc.ts b/tools/cip103-conformance/src/rpc.ts index e787e2b7e..4d9967063 100644 --- a/tools/cip103-conformance/src/rpc.ts +++ b/tools/cip103-conformance/src/rpc.ts @@ -111,7 +111,163 @@ async function createInjectedTransport( }) const page = context.pages().at(0) ?? (await context.newPage()) await page.goto(provider.appUrl, { waitUntil: 'domcontentloaded' }) - const namespace = provider.injectedNamespace ?? 'window.canton' + 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, diff --git a/tools/cip103-conformance/src/schemas.ts b/tools/cip103-conformance/src/schemas.ts index cda185ab7..4fc94e777 100644 --- a/tools/cip103-conformance/src/schemas.ts +++ b/tools/cip103-conformance/src/schemas.ts @@ -22,6 +22,19 @@ 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(), }) From af582ae071b112274832d5f6f67468af015d2f49 Mon Sep 17 00:00:00 2001 From: Marc Juchli Date: Tue, 31 Mar 2026 16:36:49 +0200 Subject: [PATCH 07/10] result section and result website generator Signed-off-by: Marc Juchli --- tools/cip103-conformance/README.md | 40 +++ tools/cip103-conformance/generate-report.mjs | 313 +++++++++++++++++++ tools/cip103-conformance/package.json | 3 +- 3 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 tools/cip103-conformance/generate-report.mjs diff --git a/tools/cip103-conformance/README.md b/tools/cip103-conformance/README.md index d6d5d9da3..348d26440 100644 --- a/tools/cip103-conformance/README.md +++ b/tools/cip103-conformance/README.md @@ -174,3 +174,43 @@ See `provider.config.browser-extension.example.json` for a copy-ready template. - `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`. + +### 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. 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 index 7193d0907..7fec1c6de 100644 --- a/tools/cip103-conformance/package.json +++ b/tools/cip103-conformance/package.json @@ -24,7 +24,8 @@ "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" + "run": "tsx src/cli.ts", + "report:html": "node ./generate-report.mjs" }, "dependencies": { "commander": "^14.0.3", From c68e297fb53929a22608269df617cc689c457b15 Mon Sep 17 00:00:00 2001 From: Marc Juchli Date: Wed, 1 Apr 2026 17:17:57 +0200 Subject: [PATCH 08/10] progress Signed-off-by: Marc Juchli --- examples/conformance-runner/.gitignore | 31 + examples/conformance-runner/README.md | 28 + examples/conformance-runner/index.html | 12 + examples/conformance-runner/package.json | 31 + examples/conformance-runner/src/App.tsx | 344 +++++++++ .../src/conformance/runner.ts | 278 +++++++ .../src/conformance/transport.ts | 116 +++ examples/conformance-runner/src/main.tsx | 10 + examples/conformance-runner/src/styles.css | 262 +++++++ examples/conformance-runner/tsconfig.app.json | 28 + examples/conformance-runner/tsconfig.json | 7 + .../conformance-runner/tsconfig.node.json | 25 + examples/conformance-runner/vite.config.ts | 20 + tools/cip103-conformance/README.md | 8 + .../conformance-report.html | 726 ++++++++++++++++++ tools/cip103-conformance/package.json | 6 + .../provider.config.bron.json | 10 + .../provider.config.cantor8.json | 10 + .../provider.config.console.json | 10 + .../provider.config.loop.json | 10 + .../provider.config.nightly.json | 10 + ...provider.config.wallet-gateway-remote.json | 10 + .../result-wallet-gateway-remote.json | 127 +++ tools/cip103-conformance/src/browser.ts | 11 + tools/cip103-conformance/src/index.ts | 1 + .../src/required-methods.ts | 64 ++ tools/cip103-conformance/tsup.config.ts | 2 +- wallet-gateway/test/config.json | 6 +- yarn.lock | 25 +- 29 files changed, 2225 insertions(+), 3 deletions(-) create mode 100644 examples/conformance-runner/.gitignore create mode 100644 examples/conformance-runner/README.md create mode 100644 examples/conformance-runner/index.html create mode 100644 examples/conformance-runner/package.json create mode 100644 examples/conformance-runner/src/App.tsx create mode 100644 examples/conformance-runner/src/conformance/runner.ts create mode 100644 examples/conformance-runner/src/conformance/transport.ts create mode 100644 examples/conformance-runner/src/main.tsx create mode 100644 examples/conformance-runner/src/styles.css create mode 100644 examples/conformance-runner/tsconfig.app.json create mode 100644 examples/conformance-runner/tsconfig.json create mode 100644 examples/conformance-runner/tsconfig.node.json create mode 100644 examples/conformance-runner/vite.config.ts create mode 100644 tools/cip103-conformance/conformance-report.html create mode 100644 tools/cip103-conformance/provider.config.bron.json create mode 100644 tools/cip103-conformance/provider.config.cantor8.json create mode 100644 tools/cip103-conformance/provider.config.console.json create mode 100644 tools/cip103-conformance/provider.config.loop.json create mode 100644 tools/cip103-conformance/provider.config.nightly.json create mode 100644 tools/cip103-conformance/provider.config.wallet-gateway-remote.json create mode 100644 tools/cip103-conformance/result-wallet-gateway-remote.json create mode 100644 tools/cip103-conformance/src/browser.ts create mode 100644 tools/cip103-conformance/src/required-methods.ts 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..ecbcff319 --- /dev/null +++ b/examples/conformance-runner/package.json @@ -0,0 +1,31 @@ +{ + "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/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..57764b4cb --- /dev/null +++ b/examples/conformance-runner/src/App.tsx @@ -0,0 +1,344 @@ +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 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 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({}) + + 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) { + setObservedResponses((prev) => ({ + ...prev, + [e.lastResult!.id]: + e.lastResponse === undefined + ? null + : e.lastResponse, + })) + } + }, + }) + 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} +
+ )} +
+ 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..5b948b8a8 --- /dev/null +++ b/examples/conformance-runner/src/conformance/runner.ts @@ -0,0 +1,278 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ConformanceTransport, JsonRpcResponse } from './transport' + +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 +} + +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 + }) => 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 response = await transport.request( + '__cip103_unknown_method__', + {} + ) + const pass = response.error?.code === -32601 + const r = makeResult( + 'CIP103-RPC-001', + 'Unknown method returns method-not-found', + 'protocol', + pass ? 'pass' : 'fail', + duration(start), + pass + ? undefined + : `Expected -32601, got ${response.error?.code ?? 'no error'}` + ) + results.push(r) + onProgress?.({ + phase: 'protocol', + message: r.title, + lastResult: r, + lastResponse: response, + }) + } + + { + 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, + }) + } else { + const pass = 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, + }) + } + } + + 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 response = await transport.request( + methodName, + schemaProbeParams(methodName) + ) + const pass = response.error?.code !== -32601 + const r = makeResult( + `CIP103-SCHEMA-${methodName}`, + `Method '${methodName}' is implemented`, + 'schema', + pass ? 'pass' : 'fail', + duration(start), + pass ? undefined : `Method returned -32601 (not found)` + ) + results.push(r) + onProgress?.({ + phase: 'schema', + current: i, + total: requiredMethods.length, + message: `Checked ${methodName}`, + lastResult: r, + lastResponse: response, + }) + } + + onProgress?.({ + phase: 'behavior', + message: 'Running behavior smoke checks…', + }) + { + const start = Date.now() + const response = await transport.request('connect', {}) + const pass = response.error?.code !== -32601 + 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' method was not found" + ) + results.push(r) + onProgress?.({ + phase: 'behavior', + message: r.title, + lastResult: r, + lastResponse: response, + }) + } + + 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..0bc345cc7 --- /dev/null +++ b/examples/conformance-runner/src/conformance/transport.ts @@ -0,0 +1,116 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +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 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) + } + ) + }) +} + +function normalizeUnknownError(error: unknown): JsonRpcError { + if (typeof error === 'object' && error !== null) { + // Many of our transports throw { error: { code, message, ... } }. + const nested = (error as { error?: unknown }).error + if (typeof nested === 'object' && nested !== null) { + const maybeCode = (nested as { code?: unknown }).code + const maybeMessage = (nested as { message?: unknown }).message + const maybeData = (nested as { data?: unknown }).data + if ( + typeof maybeCode === 'number' && + typeof maybeMessage === 'string' + ) { + if (typeof maybeData === 'string' && maybeData.trim()) { + return { + code: maybeCode, + message: `${maybeMessage}\n\n${maybeData}`, + } + } + return { code: maybeCode, message: maybeMessage } + } + if (typeof maybeMessage === 'string') { + return { code: maybeCode as number, message: maybeMessage } + } + } + + 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: maybeCode as number, message: maybeMessage } + } + + // Last resort: stringify the object so users can debug (avoid "[object Object]"). + try { + return { code: maybeCode as number, message: JSON.stringify(error) } + } catch { + // ignore + } + } + return { + code: (error as { code?: number })?.code ?? -32001, + message: error instanceof Error ? error.message : String(error), + } +} + +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 { result } + } catch (error) { + return { error: normalizeUnknownError(error) } + } + }, + async requestInvalidEnvelope(): Promise { + // Connected-provider API does not allow sending raw invalid JSON-RPC. + 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..2f3d9cd29 --- /dev/null +++ b/examples/conformance-runner/src/styles.css @@ -0,0 +1,262 @@ +: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.75); + 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; + } + .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/tools/cip103-conformance/README.md b/tools/cip103-conformance/README.md index 348d26440..3c02fb9de 100644 --- a/tools/cip103-conformance/README.md +++ b/tools/cip103-conformance/README.md @@ -214,3 +214,11 @@ Use these IDs to: - **`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/package.json b/tools/cip103-conformance/package.json index 7fec1c6de..8d1d9fe4a 100644 --- a/tools/cip103-conformance/package.json +++ b/tools/cip103-conformance/package.json @@ -17,6 +17,12 @@ "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": { 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.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.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/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/index.ts b/tools/cip103-conformance/src/index.ts index 9c111e2b8..e0d11412e 100644 --- a/tools/cip103-conformance/src/index.ts +++ b/tools/cip103-conformance/src/index.ts @@ -3,6 +3,7 @@ export { runConformance } from './runner' export { ConformanceService } from './conformance-service' +export { readRequiredMethodsBundled } from './required-methods' export { ArtifactSchema, ProfileSchema, 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/tsup.config.ts b/tools/cip103-conformance/tsup.config.ts index fbf9ac7ff..c7e79364f 100644 --- a/tools/cip103-conformance/tsup.config.ts +++ b/tools/cip103-conformance/tsup.config.ts @@ -4,7 +4,7 @@ import { defineConfig } from 'tsup' export default defineConfig({ - entry: ['src/index.ts', 'src/cli.ts'], + entry: ['src/index.ts', 'src/browser.ts', 'src/cli.ts'], format: ['esm', 'cjs'], dts: false, sourcemap: true, 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 71099c9ee..21f01c1fc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1419,7 +1419,7 @@ __metadata: languageName: unknown linkType: soft -"@canton-network/cip103-conformance@workspace:tools/cip103-conformance": +"@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: @@ -2171,6 +2171,29 @@ __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/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" From ddeda58e97c9f9039716bb7d9ea00dc2ac9f3c06 Mon Sep 17 00:00:00 2001 From: Marc Juchli Date: Wed, 1 Apr 2026 23:36:20 +0200 Subject: [PATCH 09/10] show request; treat errors Signed-off-by: Marc Juchli --- examples/conformance-runner/src/App.tsx | 25 ++++++- .../src/conformance/runner.ts | 72 ++++++++++++++++--- examples/conformance-runner/src/styles.css | 12 +++- tools/cip103-conformance/README.md | 2 +- tools/cip103-conformance/src/runner.ts | 44 +++++++++--- 5 files changed, 133 insertions(+), 22 deletions(-) diff --git a/examples/conformance-runner/src/App.tsx b/examples/conformance-runner/src/App.tsx index 57764b4cb..d2d14e90d 100644 --- a/examples/conformance-runner/src/App.tsx +++ b/examples/conformance-runner/src/App.tsx @@ -7,6 +7,7 @@ import { import { runConformanceAgainstConnectedProvider, type Artifact, + type CapturedRequest, type Profile, } from './conformance/runner' import { readRequiredMethodsBundled } from '@canton-network/cip103-conformance/browser' @@ -58,6 +59,9 @@ export default function App() { const [observedResponses, setObservedResponses] = useState< Record >({}) + const [observedRequests, setObservedRequests] = useState< + Record + >({}) const canRun = connected && !running @@ -90,6 +94,7 @@ export default function App() { setArtifact(undefined) setProgress({ message: 'Starting…' }) setObservedResponses({}) + setObservedRequests({}) try { const connectedProvider: ConnectedProvider | null = @@ -128,13 +133,21 @@ export default function App() { lastStatus: e.lastResult?.status, }) if (e.lastResult?.id) { + const id = e.lastResult.id setObservedResponses((prev) => ({ ...prev, - [e.lastResult!.id]: + [id]: e.lastResponse === undefined ? null : e.lastResponse, })) + setObservedRequests((prev) => ({ + ...prev, + [id]: + e.lastRequest === undefined + ? null + : e.lastRequest, + })) } }, }) @@ -323,6 +336,16 @@ export default function App() { {r.details} )} +
+ Request +
+                                            {JSON.stringify(
+                                                observedRequests[r.id] ?? null,
+                                                null,
+                                                2
+                                            )}
+                                        
+
Observed response
diff --git a/examples/conformance-runner/src/conformance/runner.ts b/examples/conformance-runner/src/conformance/runner.ts
index 5b948b8a8..2ccd07070 100644
--- a/examples/conformance-runner/src/conformance/runner.ts
+++ b/examples/conformance-runner/src/conformance/runner.ts
@@ -68,6 +68,22 @@ export type ConformanceProviderMeta = {
     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?: { code?: number }): 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
@@ -110,6 +126,7 @@ export async function runConformanceAgainstConnectedProvider(args: {
               message: string
               lastResult?: TestResult | undefined
               lastResponse?: JsonRpcResponse | null | undefined
+              lastRequest?: CapturedRequest | null | undefined
           }) => void)
         | undefined
 }): Promise {
@@ -124,11 +141,15 @@ export async function runConformanceAgainstConnectedProvider(args: {
         })
         {
             const start = Date.now()
+            const req001: CapturedRequest = {
+                method: '__cip103_unknown_method__',
+                params: {},
+            }
             const response = await transport.request(
-                '__cip103_unknown_method__',
-                {}
+                req001.method,
+                req001.params
             )
-            const pass = response.error?.code === -32601
+            const pass = response.error?.code === JSON_RPC_METHOD_NOT_FOUND
             const r = makeResult(
                 'CIP103-RPC-001',
                 'Unknown method returns method-not-found',
@@ -137,7 +158,7 @@ export async function runConformanceAgainstConnectedProvider(args: {
                 duration(start),
                 pass
                     ? undefined
-                    : `Expected -32601, got ${response.error?.code ?? 'no error'}`
+                    : `Expected ${JSON_RPC_METHOD_NOT_FOUND}, got ${response.error?.code ?? 'no error'}`
             )
             results.push(r)
             onProgress?.({
@@ -145,6 +166,7 @@ export async function runConformanceAgainstConnectedProvider(args: {
                 message: r.title,
                 lastResult: r,
                 lastResponse: response,
+                lastRequest: req001,
             })
         }
 
@@ -166,6 +188,12 @@ export async function runConformanceAgainstConnectedProvider(args: {
                     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 = Boolean(response.error)
@@ -185,6 +213,10 @@ export async function runConformanceAgainstConnectedProvider(args: {
                     message: r.title,
                     lastResult: r,
                     lastResponse: response,
+                    lastRequest: {
+                        method: '(malformed JSON-RPC envelope)',
+                        params: { note: 'Non-standard payload per transport' },
+                    },
                 })
             }
         }
@@ -205,18 +237,25 @@ export async function runConformanceAgainstConnectedProvider(args: {
             })
             i += 1
             const start = Date.now()
+            const probeParams = schemaProbeParams(methodName)
+            const reqSchema: CapturedRequest = {
+                method: methodName,
+                params: probeParams,
+            }
             const response = await transport.request(
-                methodName,
-                schemaProbeParams(methodName)
+                reqSchema.method,
+                reqSchema.params
             )
-            const pass = response.error?.code !== -32601
+            const pass = !isMissingOrUnsupportedMethod(response.error)
             const r = makeResult(
                 `CIP103-SCHEMA-${methodName}`,
                 `Method '${methodName}' is implemented`,
                 'schema',
                 pass ? 'pass' : 'fail',
                 duration(start),
-                pass ? undefined : `Method returned -32601 (not found)`
+                pass
+                    ? undefined
+                    : `Method missing or unsupported (expected neither ${JSON_RPC_METHOD_NOT_FOUND} nor ${METHOD_NOT_SUPPORTED}; got ${response.error?.code ?? 'no error'})`
             )
             results.push(r)
             onProgress?.({
@@ -226,6 +265,7 @@ export async function runConformanceAgainstConnectedProvider(args: {
                 message: `Checked ${methodName}`,
                 lastResult: r,
                 lastResponse: response,
+                lastRequest: reqSchema,
             })
         }
 
@@ -235,15 +275,24 @@ export async function runConformanceAgainstConnectedProvider(args: {
         })
         {
             const start = Date.now()
-            const response = await transport.request('connect', {})
-            const pass = response.error?.code !== -32601
+            const reqConnect: CapturedRequest = {
+                method: 'connect',
+                params: {},
+            }
+            const response = await transport.request(
+                reqConnect.method,
+                reqConnect.params
+            )
+            const pass = !isMissingOrUnsupportedMethod(response.error)
             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' method was not found"
+                pass
+                    ? undefined
+                    : `'connect' missing or not supported (code ${response.error?.code ?? 'n/a'})`
             )
             results.push(r)
             onProgress?.({
@@ -251,6 +300,7 @@ export async function runConformanceAgainstConnectedProvider(args: {
                 message: r.title,
                 lastResult: r,
                 lastResponse: response,
+                lastRequest: reqConnect,
             })
         }
 
diff --git a/examples/conformance-runner/src/styles.css b/examples/conformance-runner/src/styles.css
index 2f3d9cd29..9d5aaa5bc 100644
--- a/examples/conformance-runner/src/styles.css
+++ b/examples/conformance-runner/src/styles.css
@@ -209,7 +209,7 @@ select {
     margin-top: 10px;
     padding-top: 10px;
     border-top: 1px solid rgba(255, 255, 255, 0.08);
-    color: rgba(230, 234, 242, 0.75);
+    color: rgba(230, 234, 242, 0.88);
     white-space: pre-wrap;
 }
 
@@ -232,6 +232,16 @@ select {
         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 {
diff --git a/tools/cip103-conformance/README.md b/tools/cip103-conformance/README.md
index 3c02fb9de..e0db81bb3 100644
--- a/tools/cip103-conformance/README.md
+++ b/tools/cip103-conformance/README.md
@@ -208,7 +208,7 @@ Use these IDs to:
 ### Schema
 
 - **`CIP103-SCHEMA-`**: Existence probe for each required OpenRPC method in the selected profile.
-  Example: `CIP103-SCHEMA-listAccounts`.
+  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
 
diff --git a/tools/cip103-conformance/src/runner.ts b/tools/cip103-conformance/src/runner.ts
index c460b9a4a..575bddcc6 100644
--- a/tools/cip103-conformance/src/runner.ts
+++ b/tools/cip103-conformance/src/runner.ts
@@ -32,6 +32,28 @@ function makeResult(
     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 {
@@ -43,7 +65,7 @@ async function runProtocolTests(
             '__cip103_unknown_method__',
             {}
         )
-        const pass = response.error?.code === -32601
+        const pass = response.error?.code === JSON_RPC_METHOD_NOT_FOUND
         results.push(
             makeResult(
                 'CIP103-RPC-001',
@@ -53,7 +75,7 @@ async function runProtocolTests(
                 duration(start),
                 pass
                     ? undefined
-                    : `Expected -32601, got ${response.error?.code ?? 'no error'}`
+                    : `Expected ${JSON_RPC_METHOD_NOT_FOUND}, got ${response.error?.code ?? 'no error'}`
             )
         )
     }
@@ -101,10 +123,12 @@ async function runSchemaTests(
 
     for (const methodName of methodNames) {
         const start = Date.now()
-        const response = await transport.request(methodName, {})
+        const response = await transport.request(
+            methodName,
+            schemaProbeParams(methodName)
+        )
 
-        // For existence probing, anything except method-not-found counts as implemented.
-        const pass = response.error?.code !== -32601
+        const pass = !isMissingOrUnsupportedMethod(response.error)
         results.push(
             makeResult(
                 `CIP103-SCHEMA-${methodName}`,
@@ -112,7 +136,9 @@ async function runSchemaTests(
                 'schema',
                 pass ? 'pass' : 'fail',
                 duration(start),
-                pass ? undefined : `Method returned -32601 (not found)`
+                pass
+                    ? undefined
+                    : `Method missing or unsupported (expected neither ${JSON_RPC_METHOD_NOT_FOUND} nor ${METHOD_NOT_SUPPORTED}; got ${response.error?.code ?? 'no error'})`
             )
         )
     }
@@ -127,7 +153,7 @@ async function runBehaviorSmokeTests(
     const start = Date.now()
     const response = await transport.request('connect', {})
 
-    const pass = response.error?.code !== -32601
+    const pass = !isMissingOrUnsupportedMethod(response.error)
     return [
         makeResult(
             profile === 'sync' ? 'CIP103-BEH-001' : 'CIP103-BEH-101',
@@ -135,7 +161,9 @@ async function runBehaviorSmokeTests(
             'behavior',
             pass ? 'pass' : 'fail',
             duration(start),
-            pass ? undefined : "'connect' method was not found"
+            pass
+                ? undefined
+                : `'connect' missing or not supported (code ${response.error?.code ?? 'n/a'})`
         ),
     ]
 }

From 61b507becafcfb8dda5f574bfbcca4845b69e373 Mon Sep 17 00:00:00 2001
From: Marc Juchli 
Date: Thu, 2 Apr 2026 10:30:23 +0200
Subject: [PATCH 10/10] reuse types

Signed-off-by: Marc Juchli 
---
 examples/conformance-runner/package.json      |  2 +
 .../src/conformance/runner.ts                 | 31 ++++--
 .../src/conformance/transport.ts              | 96 ++++++++-----------
 sdk/dapp-sdk/src/sdk-controller.ts            | 25 +++--
 yarn.lock                                     |  2 +
 5 files changed, 80 insertions(+), 76 deletions(-)

diff --git a/examples/conformance-runner/package.json b/examples/conformance-runner/package.json
index ecbcff319..a3f69a787 100644
--- a/examples/conformance-runner/package.json
+++ b/examples/conformance-runner/package.json
@@ -10,6 +10,8 @@
     },
     "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"
diff --git a/examples/conformance-runner/src/conformance/runner.ts b/examples/conformance-runner/src/conformance/runner.ts
index 2ccd07070..ecbb6ff24 100644
--- a/examples/conformance-runner/src/conformance/runner.ts
+++ b/examples/conformance-runner/src/conformance/runner.ts
@@ -1,7 +1,15 @@
 // Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
 // SPDX-License-Identifier: Apache-2.0
 
-import type { ConformanceTransport, JsonRpcResponse } from './transport'
+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'
@@ -79,7 +87,9 @@ 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 {
+function isMissingOrUnsupportedMethod(
+    error?: ErrorResponse['error'] | null
+): boolean {
     const c = error?.code
     return c === JSON_RPC_METHOD_NOT_FOUND || c === METHOD_NOT_SUPPORTED
 }
@@ -149,7 +159,8 @@ export async function runConformanceAgainstConnectedProvider(args: {
                 req001.method,
                 req001.params
             )
-            const pass = response.error?.code === JSON_RPC_METHOD_NOT_FOUND
+            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',
@@ -158,7 +169,7 @@ export async function runConformanceAgainstConnectedProvider(args: {
                 duration(start),
                 pass
                     ? undefined
-                    : `Expected ${JSON_RPC_METHOD_NOT_FOUND}, got ${response.error?.code ?? 'no error'}`
+                    : `Expected ${JSON_RPC_METHOD_NOT_FOUND}, got ${err?.code ?? 'no error'}`
             )
             results.push(r)
             onProgress?.({
@@ -196,7 +207,7 @@ export async function runConformanceAgainstConnectedProvider(args: {
                     },
                 })
             } else {
-                const pass = Boolean(response.error)
+                const pass = 'error' in response && Boolean(response.error)
                 const r = makeResult(
                     'CIP103-RPC-002',
                     'Invalid JSON-RPC request returns error',
@@ -246,7 +257,8 @@ export async function runConformanceAgainstConnectedProvider(args: {
                 reqSchema.method,
                 reqSchema.params
             )
-            const pass = !isMissingOrUnsupportedMethod(response.error)
+            const err = rpcError(response)
+            const pass = !isMissingOrUnsupportedMethod(err)
             const r = makeResult(
                 `CIP103-SCHEMA-${methodName}`,
                 `Method '${methodName}' is implemented`,
@@ -255,7 +267,7 @@ export async function runConformanceAgainstConnectedProvider(args: {
                 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'})`
+                    : `Method missing or unsupported (expected neither ${JSON_RPC_METHOD_NOT_FOUND} nor ${METHOD_NOT_SUPPORTED}; got ${err?.code ?? 'no error'})`
             )
             results.push(r)
             onProgress?.({
@@ -283,7 +295,8 @@ export async function runConformanceAgainstConnectedProvider(args: {
                 reqConnect.method,
                 reqConnect.params
             )
-            const pass = !isMissingOrUnsupportedMethod(response.error)
+            const err = rpcError(response)
+            const pass = !isMissingOrUnsupportedMethod(err)
             const r = makeResult(
                 profile === 'sync' ? 'CIP103-BEH-001' : 'CIP103-BEH-101',
                 "Provider exposes 'connect' lifecycle method",
@@ -292,7 +305,7 @@ export async function runConformanceAgainstConnectedProvider(args: {
                 duration(start),
                 pass
                     ? undefined
-                    : `'connect' missing or not supported (code ${response.error?.code ?? 'n/a'})`
+                    : `'connect' missing or not supported (code ${err?.code ?? 'n/a'})`
             )
             results.push(r)
             onProgress?.({
diff --git a/examples/conformance-runner/src/conformance/transport.ts b/examples/conformance-runner/src/conformance/transport.ts
index 0bc345cc7..6a965987a 100644
--- a/examples/conformance-runner/src/conformance/transport.ts
+++ b/examples/conformance-runner/src/conformance/transport.ts
@@ -1,14 +1,44 @@
 // Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
 // SPDX-License-Identifier: Apache-2.0
 
-export interface JsonRpcError {
-    code: number
-    message: string
-}
+import type { ErrorResponse, JsonRpcResponse } from '@canton-network/core-types'
+
+const DEFAULT_CODE = -32001
 
-export interface JsonRpcResponse {
-    result?: unknown
-    error?: JsonRpcError
+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 {
@@ -36,53 +66,6 @@ function withTimeout(promise: Promise, timeoutMs: number): Promise {
     })
 }
 
-function normalizeUnknownError(error: unknown): JsonRpcError {
-    if (typeof error === 'object' && error !== null) {
-        // Many of our transports throw { error: { code, message, ... } }.
-        const nested = (error as { error?: unknown }).error
-        if (typeof nested === 'object' && nested !== null) {
-            const maybeCode = (nested as { code?: unknown }).code
-            const maybeMessage = (nested as { message?: unknown }).message
-            const maybeData = (nested as { data?: unknown }).data
-            if (
-                typeof maybeCode === 'number' &&
-                typeof maybeMessage === 'string'
-            ) {
-                if (typeof maybeData === 'string' && maybeData.trim()) {
-                    return {
-                        code: maybeCode,
-                        message: `${maybeMessage}\n\n${maybeData}`,
-                    }
-                }
-                return { code: maybeCode, message: maybeMessage }
-            }
-            if (typeof maybeMessage === 'string') {
-                return { code: maybeCode as number, message: maybeMessage }
-            }
-        }
-
-        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: maybeCode as number, message: maybeMessage }
-        }
-
-        // Last resort: stringify the object so users can debug (avoid "[object Object]").
-        try {
-            return { code: maybeCode as number, message: JSON.stringify(error) }
-        } catch {
-            // ignore
-        }
-    }
-    return {
-        code: (error as { code?: number })?.code ?? -32001,
-        message: error instanceof Error ? error.message : String(error),
-    }
-}
-
 export type ConnectedProvider = {
     request(args: { method: string; params?: unknown }): Promise
 }
@@ -102,13 +85,12 @@ export function createConnectedProviderTransport(
                     provider.request({ method, params }),
                     timeoutMs
                 )
-                return { result }
+                return { jsonrpc: '2.0', result }
             } catch (error) {
-                return { error: normalizeUnknownError(error) }
+                return { jsonrpc: '2.0', error: normalizeUnknownError(error) }
             }
         },
         async requestInvalidEnvelope(): Promise {
-            // Connected-provider API does not allow sending raw invalid JSON-RPC.
             return null
         },
         async close(): Promise {},
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/yarn.lock b/yarn.lock
index 21f01c1fc..28a1f4dc3 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2176,6 +2176,8 @@ __metadata:
   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"