Generate Canton TypeScript Tests
Generate TypeScript integration tests that mirror Daml test scripts, using the SDK.
Prerequisites
This skill requires a pre-generated SDK. Before running:
- •Check for SDK at
<project-path>/sdk - •Verify SDK has
<project-name>-api.tswith template namespaces - •If SDK doesn't exist, tell user: "Please run the canton-sdk-generator skill first to create the SDK."
STEP 1: Discover Daml Project
Look for Daml project configuration:
- •Check for
daml.yamlin workspace - extract project name - •Verify SDK exists at
<project-path>/sdk/<project-name>-api.ts
STEP 2: Check for Daml Test Scripts
Look for Daml test scripts:
ls <project-path>/daml/Scripts/tests/
If directory does NOT exist or is empty:
- •Inform user: "No Daml test scripts found at
<project-path>/daml/Scripts/tests/" - •Explain: "This skill generates TypeScript tests by reading Daml test scripts. To use it, create
.damlscript files that define test workflows." - •Stop - do not proceed.
STEP 3: Check for Setup.daml (Convention)
Convention: If <project-path>/daml/Scripts/Setup.daml exists, it contains ledger setup logic (party allocation, initial contracts, configuration) that should run before all tests.
Check for the setup file:
ls <project-path>/daml/Scripts/Setup.daml
If Setup.daml exists, create sdk/__tests__/testSetup.ts:
- •Read
Setup.damland understand what it sets up (factories, instruments, accounts, initial state) - •Extract actual values - note the specific IDs, amounts, and configuration values used (e.g.,
vaultId = "vault-001", not made-up values) - •Create a
testSetup.tsthat CREATES the same contracts - the setup file must replicate the Setup.daml logic using the SDK, actually creating contracts on the ledger - •Export parties (from environment variables), ledger clients, and any contract IDs created
- •Export configuration values as constants so tests use the same values as Setup.daml
- •Use vitest's
beforeAllto run the setup once before all tests - •Test files will import what they need from this setup file
CRITICAL: The testSetup.ts must actually CREATE contracts, not just verify they exist. It replaces Setup.daml for TypeScript tests. The tests should be runnable on a fresh ledger with only the DAR deployed.
CRITICAL: Never hardcode different values than what Setup.daml uses. Read the setup script and use the EXACT same IDs, amounts, and configuration.
testSetup.ts Structure
The setup file should:
- •Check if setup already ran - Query for a key contract (e.g., VaultState). If it exists, skip creation to allow re-running tests.
- •Re-fund user accounts if needed - When reusing existing state, users may have consumed their holdings from previous test runs. Check and re-fund if necessary.
- •Create factories - Account factory, holding factory, token factory
- •Create instruments - Tokens/assets the system uses
- •Create accounts - User accounts via workflow requests
- •Fund accounts - Credit initial balances via workflow requests
- •Create state contracts - Config, state, and other initial contracts
- •Export everything tests need - Parties, clients, keys, contract IDs, constants
// testSetup.ts structure
import { beforeAll } from 'vitest';
import { TemplateIds, Query, Account_Account_Factory, ... } from '../<project>-api';
import type { Party, ContractId, Id, Numeric } from '../core/primitives';
import type { AccountKey, InstrumentKey, HoldingFactoryKey } from '../core/interfaces';
import { createLedgerClient } from '../ledger';
// Party configuration from environment (names depend on your Setup.daml)
export const user = process.env.USER_PARTY as Party;
export const operator = process.env.OPERATOR_PARTY as Party;
if (!user || !operator) {
throw new Error('USER_PARTY and OPERATOR_PARTY environment variables required');
}
// Ledger clients - one per party
export const userLedger = createLedgerClient(user);
export const operatorLedger = createLedgerClient(operator);
// Configuration constants (MUST match Setup.daml exactly - read from the file!)
export const configId: Id = { unpack: "config-001" }; // Example - use actual value from Setup.daml
export const initialAmount: Numeric = "1000.0"; // Example - use actual value from Setup.daml
// ... other constants extracted from Setup.daml
// Keys and IDs that will be populated during setup
export let userAccount: AccountKey;
export let primaryInstrument: InstrumentKey;
export let mainConfigCid: ContractId<unknown>;
// ... other exports based on what Setup.daml creates
beforeAll(async () => {
// Check if setup already ran by querying for a key contract from Setup.daml
const existingState = await operatorLedger.query(TemplateIds.YourProject_State_MainState);
if (existingState.length > 0) {
console.log('Setup already completed, reusing existing contracts');
// Populate exports from existing contracts (keys, instruments, etc.)
// IMPORTANT: Re-fund user accounts if they consumed holdings in previous runs
const userHoldings = await userLedger.query(TemplateIds.Holding_TransferableFungible);
const relevantHoldings = userHoldings.filter(h =>
h.payload.instrument.id.unpack === "TOKEN-ID" && h.payload.account.owner === user
);
if (relevantHoldings.length === 0) {
console.log('Re-funding user account...');
// Credit user with initial balance again using workflow
const creditCmd = Workflow_CreditAccount_Request.create({ ... });
// ... accept the credit request
}
return;
}
console.log('Running ledger setup...');
// 1. Create factories (Daml Finance standard pattern)
const accountFactoryCmd = Account_Account_Factory.create({ provider: operator, observers: [] });
const accountFactoryCid = await operatorLedger.create(accountFactoryCmd.templateId, accountFactoryCmd.argument);
// Create Holding Factory AND its Reference (Reference is required for account creation)
const holdingFactoryCmd = Holding_Factory.create({
provider: operator,
id: { unpack: "Holding Factory" },
observers: []
});
const holdingFactoryCid = await operatorLedger.create(holdingFactoryCmd.templateId, holdingFactoryCmd.argument);
// IMPORTANT: Create Holding Factory Reference - accounts need this to find the factory
const holdingFactoryRefCmd = Holding_Factory_Reference.create({
factoryView: { provider: operator, id: { unpack: "Holding Factory" } } as any,
cid: holdingFactoryCid as any,
observers: []
});
await operatorLedger.create(holdingFactoryRefCmd.templateId, holdingFactoryRefCmd.argument);
// 2. Create instruments (token factory pattern)
// ... create Token_Factory, then Token_Instrument for each asset
// 3. Create accounts via workflow - pass holdingFactory key (not CID)
const holdingFactory: HoldingFactoryKey = {
provider: operator,
id: { unpack: "Holding Factory" }
};
// CreateAccount.Request.accept needs: accountFactoryCid, holdingFactory, observers
// ... CreateAccount.Request + Accept for each user
// 4. Fund accounts (credit initial balances)
// ... CreditAccount.Request + Accept for initial holdings
// 5. Create application-specific state contracts
// ... Your project's config and state templates
console.log('Setup complete!');
}, 60000); // 60 second timeout for setup
If Setup.daml does NOT exist, skip this step. Tests will handle their own setup.
STEP 4: Translate Each Daml Script to TypeScript
For each .daml file in Scripts/tests/:
Goal: Create a TypeScript test that achieves the same end result as the DAML script, using the SDK. The translation doesn't need to be 1:1 - what matters is that the test verifies the same workflow behavior.
Translation Rules
- •Read the DAML script completely - understand what workflow is being tested and what the expected outcome is
- •Extract values from DAML - use the same IDs, amounts, and references as the DAML script (don't invent new values)
- •Write clean TypeScript - use the SDK to achieve the same result:
- •Create contracts with
TemplateName.create({...})+ledger.create() - •Exercise choices with
TemplateName.choiceName(cid, args)+ledger.exercise() - •Query contracts using the
Queryhelper - IMPORTANT: TheQuery.<templateName>()functions return aQuerySpecobject withtemplateIdandfilterproperties. You must pass these separately toledger.query():typescript// CORRECT - destructure the QuerySpec const spec = Query.yourProject_State_MainState({ operator }); const contracts = await ledger.query(spec.templateId, spec.filter); // Or inline destructuring: const { templateId, filter } = Query.holding_Fungible({ account: accountKey }); const holdings = await ledger.query(templateId, filter); // WRONG - do NOT pass QuerySpec directly (causes "templateId.split is not a function") // const contracts = await ledger.query(Query.yourProject_State_MainState());
- •Create contracts with
- •Apply DRY - extract helpers for repeated patterns, don't duplicate code
- •Map parties - DAML party names become environment variables (e.g.
alice→process.env.ALICE_PARTY) - •Verify the same outcome - the test should assert that the final state matches what the DAML script expects
Fee / Net vs Gross Assertions (Common Pitfall)
If a workflow charges fees, do not assume “vault assets decrease by what the user received”. Often:
- •User payout is net (after fee)
- •State like totalAssets moves by the gross amount (before fee), depending on the model
Derive expectations from the DAML logic / returned values to avoid off‑by‑fee errors.
Test Independence
Each test should be runnable independently where possible:
- •If a test depends on prior state (e.g., redemption requires a prior deposit), either:
- •Create the required state in the test's own
beforeAll - •OR clearly document the dependency and required test order
- •Create the required state in the test's own
IMPORTANT: Test file execution order is not guaranteed. Prefer making each test self-contained.
If you must enforce order (last resort), prefix filenames with numbers (e.g., 01_, 02_, 03_).
Setup Verification
In beforeAll(), verify the ledger is properly set up before running tests:
- •Query for expected contracts from Setup.daml
- •Throw a clear error if setup contracts are missing (e.g., "VaultState not found. Ensure
daml startcompleted successfully.")
CRITICAL: Query API Pattern
The SDK's Query.<templateName>() functions return a QuerySpec object, NOT a template ID string. You MUST destructure the result before passing to ledger.query():
// ✅ CORRECT - destructure templateId and filter from QuerySpec
const spec = Query.yourProject_State_MainState({ operator });
const contracts = await ledger.query(spec.templateId, spec.filter);
// ✅ CORRECT - inline destructuring also works
const { templateId, filter } = Query.holding_Fungible({ account });
const holdings = await ledger.query(templateId, filter);
// ❌ WRONG - passing QuerySpec directly causes runtime error
// "TypeError: templateId.split is not a function"
const contracts = await ledger.query(Query.yourProject_State_MainState());
This pattern applies to ALL query calls in tests. Always destructure first.
CRITICAL: Canton Type System
Canton/Daml Finance uses wrapped types for type safety. You MUST use these correctly:
1. Id Type - Wrapped string identifier
The Id type is NOT a plain string. It has an unpack property:
// SDK type definition:
export interface Id {
unpack: string;
}
// ✅ CORRECT - use { unpack: "value" } format
const configId: Id = { unpack: "config-001" };
const instrumentId: Id = { unpack: "USD" };
const accountId: Id = { unpack: "User@Operator" };
// ❌ WRONG - plain strings cause type errors
const configId = "config-001"; // Type 'string' is not assignable to type 'Id'
This applies to ALL id fields: configId, instrument.id, account.id, holdingFactory.id, etc.
2. Numeric Type - String-based decimal
The Numeric type is a string, not a number. This preserves decimal precision:
// SDK type definition: export type Numeric = string; // ✅ CORRECT - use string literals for amounts const amount: Numeric = "500.0"; const sharePrice: Numeric = "1.0"; const initialBalance: Numeric = "1000.0"; // ❌ WRONG - numbers cause type errors const amount = 500.0; // Type 'number' is not assignable to type 'string'
3. Arithmetic with Numeric
Since Numeric is a string, you must parse before arithmetic:
// ✅ CORRECT - parse to number for calculations const depositAmount = "500.0"; const sharePrice = "1.0"; const expectedShares = parseFloat(depositAmount) / parseFloat(sharePrice); // For comparisons with contract data: const actualShares = parseFloat(vaultState.payload.totalShares); expect(actualShares).toBeCloseTo(expectedShares, 6); // ❌ WRONG - direct arithmetic on strings const shares = depositAmount / sharePrice; // NaN or type error
4. InstrumentKey and AccountKey
These compound types contain Id fields:
// ✅ CORRECT - nested Id objects with all required fields
const instrumentKey: InstrumentKey = {
depository: custodianParty,
issuer: custodianParty,
id: { unpack: "USD" }, // Id type, not string
version: "0",
holdingStandard: { tag: "TransferableFungible" } // Enum as tagged object
};
const accountKey: AccountKey = {
custodian: custodianParty,
owner: aliceParty,
id: { unpack: "Alice@Vault" } // Id type, not string
};
// ❌ WRONG - plain string ids
const instrumentKey = {
depository: custodianParty,
issuer: custodianParty,
id: "USD", // Type error!
version: "0"
};
5. ContractId Type Casting
When exercising choices, you often need to cast ContractId<unknown> to the specific payload type:
// The create() returns ContractId<unknown>, but accept() needs ContractId<Payload>
const requestCid = await ledger.create(requestCmd.templateId, requestCmd.argument);
// ✅ CORRECT - cast to specific ContractId type
const acceptCmd = Workflow_CreateAccount_Request.accept(
requestCid as ContractId<Workflow_CreateAccount_Request.Payload>
);
// Then exercise:
await ledger.exercise(
acceptCmd.templateId!,
requestCid,
acceptCmd.choice!,
{ /* choice arguments */ }
);
// ❌ WRONG - using ContractId<unknown> directly causes type errors
const acceptCmd = Workflow_CreateAccount_Request.accept(requestCid); // Type error
Extracting Choice Results: Daml choices can return values. Access them via exerciseResult:
// Exercise returns the choice's return value in exerciseResult
const result = await ledger.exercise(
acceptCmd.templateId!,
requestCid,
acceptCmd.choice!,
{ label: "User@Operator", description: "User account", accountFactoryCid, holdingFactory, observers: [] }
);
// Cast the result to the expected return type (depends on what the Daml choice returns)
const accountKey = result.exerciseResult as AccountKey;
6. Daml Enum vs Variant JSON Encoding (CRITICAL)
Daml has enums and variants and they serialize differently in the JSON API:
- •Enums (no payload) serialize as strings:
- •JSON:
"TransferableFungible"
- •JSON:
- •Variants (sum types) serialize as objects:
- •JSON:
{ "tag": "Constructor", "value": <payload> } - •If the constructor has no payload, the JSON may be
{ "tag": "Constructor" }
- •JSON:
Daml Finance note: HoldingStandard is commonly an enum in practice, so the JSON API expects a string.
// Example enum value (JSON: "TransferableFungible")
const holdingStandard = "TransferableFungible" as any;
// Example variant value (JSON: { tag, value })
const someVariant = { tag: "SomeCase", value: { /* payload */ } } as any;
Rule of thumb:
- •If the generated type looks like a string union, pass a string.
- •If it looks like a union of
{ tag: ... }objects, pass the tagged object.
7. Type Imports
Import the necessary types from the SDK:
import type { Id, Numeric, Party, ContractId, DamlMap } from '../core/primitives';
import type { InstrumentKey, AccountKey, HoldingFactoryKey } from '../core/interfaces';
8. Map Types in Daml JSON API
Daml Map types are serialized as arrays of key-value pairs, NOT as objects:
// Daml type: Map Text (Set Party)
// Used for: observers field in many Daml Finance templates
// ✅ CORRECT - empty Map is an empty array
const observers: DamlMap<string, Party[]> = [];
// ✅ CORRECT - Map with entries is array of [key, value] pairs
const observersWithData: DamlMap<string, Party[]> = [["label1", [party1, party2]]];
// ❌ WRONG - object format causes serialization errors
const observers = {}; // Will fail at runtime
This applies to all observers fields in Daml Finance templates (Account_Factory, Holding_Factory, Token_Instrument, etc.).
8.1 Query Filters: Avoid Enums/Variants When Possible
The JSON API query predicate parser can be fragile with nested enum/variant fields inside filters.
If a record field contains an enum/variant (e.g., InstrumentKey.holdingStandard), prefer query-safe filters:
- •Build a filter that omits enum/variant fields (partial record matching), e.g.
Omit<InstrumentKey, 'holdingStandard'> - •Or filter by primitive sub-fields (issuer/depository/id/version) rather than the entire nested record
9. Daml Finance Holdings Behavior
When working with holdings in Daml Finance workflows:
// IMPORTANT: Many Daml workflows transfer the ENTIRE holding, regardless of amount parameter // The amount field is often used only for calculations (e.g., share allocation), // not for splitting the holding. // If you need to transfer a partial amount: // 1. First split the holding using Fungible.Split interface // 2. Then use the split holding in the workflow // Example: If Alice has 1000 USD and wants to deposit 500 // Option A: Deposit all 1000 (simpler) const depositAmount = "1000.0"; // Full balance // Option B: Split first (if Daml contract supports it) // const splitCmd = ... // Split 1000 into 500 + 500 // Use the 500 holding for deposit, keep the other
STEP 5: Generate TypeScript Test Files
For each Daml script, create a corresponding test file at sdk/__tests__/<script-name>.test.ts.
Required: Prerequisites Comment
Every test file MUST start with a prerequisites comment:
/** * Tests generated from: <script-name>.daml * * Prerequisites: * 1. Start Canton ledger: `daml start` * 2. Setup script runs automatically via init-script in daml.yaml * 3. Set environment variables: USER_PARTY, OPERATOR_PARTY (from ledger output) * * Workflow: <brief description of what this tests> */
Required: Descriptive Assertions
Use descriptive assertion messages that explain what's being checked:
// BAD - no context on failure expect(holdings.length).toBeGreaterThan(0); // GOOD - explains what should exist and why expect(holdings.length, 'Alice should have USD holdings from setup script').toBeGreaterThan(0);
Test File Structure (with Setup)
If testSetup.ts was created, import from it:
/**
* Tests generated from: <script-name>.daml
*
* Prerequisites:
* 1. Start Canton ledger: `daml start`
* 2. Setup script runs automatically via init-script in daml.yaml
* 3. Set environment variables: ALICE_PARTY, BOB_PARTY
*
* Workflow: <brief description>
*/
import { describe, test, expect, beforeAll } from 'vitest';
import { TemplateIds, Query } from '../<project-name>-api';
import type { ContractId } from '../core/primitives';
import { user, operator, userLedger, operatorLedger, configId } from './testSetup';
describe('<ScriptName> Workflow', () => {
// Verify setup completed before running tests
beforeAll(async () => {
// Use Query helper with destructuring - never pass QuerySpec directly to ledger.query()
const spec = Query.expectedContract({ /* filter */ });
const setupContracts = await operatorLedger.query(spec.templateId, spec.filter);
if (setupContracts.length === 0) {
throw new Error('Setup contracts not found. Ensure `daml start` completed successfully.');
}
});
test('complete workflow', async () => {
// Query example with destructuring
const { templateId, filter } = Query.someTemplate({ owner: user });
const contracts = await userLedger.query(templateId, filter);
// Use values from testSetup (configId, etc.) - never hardcode different values
});
});
Test File Structure (without Setup)
If no Setup.daml exists, tests are self-contained:
/**
* Tests generated from: <script-name>.daml
*
* Prerequisites:
* 1. Start Canton ledger: `daml start`
* 2. Set environment variables: ALICE_PARTY, BOB_PARTY
*
* Workflow: <brief description>
*/
import { describe, test, expect, beforeAll } from 'vitest';
import { TemplateIds, Query } from '../<project-name>-api';
import type { Party, ContractId } from '../core/primitives';
import { createLedgerClient, type CantonLedgerClient } from '../ledger';
const user = process.env.USER_PARTY as Party;
const operator = process.env.OPERATOR_PARTY as Party;
if (!user) {
throw new Error('USER_PARTY environment variable required');
}
describe('<ScriptName> Workflow', () => {
let userLedger: CantonLedgerClient;
let operatorLedger: CantonLedgerClient;
beforeAll(() => {
userLedger = createLedgerClient(user);
if (operator) operatorLedger = createLedgerClient(operator);
});
test('complete workflow', async () => {
// Query contracts - ALWAYS destructure QuerySpec, never pass directly
const { templateId, filter } = Query.someTemplate({ owner: user });
const contracts = await userLedger.query(templateId, filter);
// Each test creates its own data - fully independent
});
});
STEP 6: Create Vitest Configuration
If sdk/vitest.config.ts doesn't exist, create it:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['__tests__/**/*.test.ts'],
setupFiles: ['__tests__/testSetup.ts'], // Only if testSetup.ts was created
testTimeout: 30000,
// Canton often hits LOCKED_CONTRACTS if multiple files mutate shared contracts in parallel.
// Force files to run sequentially.
fileParallelism: false,
sequence: {
concurrent: false,
},
},
});
Note: Only include setupFiles if testSetup.ts was generated from Setup.daml.
STEP 7: Validate
Run TypeScript validation (do NOT run the tests):
cd <project-path>/sdk && npx tsc --noEmit
Fix any compilation errors.
OUTPUT
Report when complete:
- •Project name:
<project-name> - •SDK path:
<project-path>/sdk - •Setup file:
testSetup.tscreated ✅ / not needed (no Setup.daml) - •Daml scripts processed: (list each with brief description)
- •Test files generated: (list each)
- •TypeScript validation: ✅/❌
Then tell the user:
To run the tests:
- •Start the Canton environment:
cd <project-path> && daml start- •In another terminal, run:
cd <project-path>/sdk && npx vitest run