Skip to content
Cursor Agent edited this page Jun 19, 2026 · 50 revisions

OCP Canton SDK (@open-captable-protocol/canton)

High-level TypeScript SDK for Open Cap Table Protocol contracts on Canton Network.

Current release: @open-captable-protocol/canton 0.5.0 (see package.json).

The repository README is contributor-focused (implementation guidance for maintainers and AI assistants). Use this wiki for consumer-facing links, architecture, and workflows.

End-user / docs site: ocp.canton.fairmint.com (source: Fairmint/web)

License

The SDK is GNU General Public License v3.0 only (GPL-3.0-only). Full text: LICENSE in the repository root; npm metadata matches (package.json).

Clone this wiki locally (AI agents: run first)

if [ -d wiki/.git ]; then git -C wiki pull; else git clone https://github.com/Fairmint/ocp-canton-sdk.wiki.git wiki; fi

Quick Commands

npm run fix                   # ESLint + Prettier auto-fix (REQUIRED before push)
npm run test:ci               # Unit tests + typecheck (CI parity)
npm run test:integration      # LocalNet integration tests (when ledger/DAML changes apply)

Architecture

Layer Stack

ocp-canton-sdk          # This SDK - OCP operations
       │
canton-node-sdk         # Low-level Canton client
       │
Canton Network          # DAML contracts

File Structure

src/
├── functions/              # One operation per file
│   ├── OpenCapTable/       # OCF object operations
│   │   ├── factory/
│   │   │   └── createFactory.ts    # OcpFactory deploy (localnet, staging, custom networks)
│   │   ├── issuer/
│   │   │   ├── createIssuer.ts
│   │   │   └── getIssuerAsOcf.ts
│   │   ├── stakeholder/
│   │   ├── stockClass/
│   │   └── ...
├── types/
│   ├── native.ts           # OCF-native input types (OcfIssuer, OcfStakeholder, etc.)
│   ├── output.ts           # OCF output types with object_type discriminant (OcfIssuerOutput, etc.)
│   ├── common.ts           # Shared types (GetByContractIdParams, ContractResult<T>, re-exports)
│   ├── branded.ts          # Branded types (ContractId, PartyId) - optional strict typing
│   └── daml.ts             # Re-exported DAML package types
├── utils/
│   ├── typeConversions.ts  # DAML ↔ OCF conversions
│   └── …                   # helpers (e.g. replication, validation); `TransactionBatch` lives in canton-node-sdk via `OcpClient.createBatch`
├── environment.ts          # Presets, env-var loading, Canton client config (`fromEnv`, `create`, presets)
└── OcpClient.ts            # Facade; consumes injected ledger (and optional validator) clients

test/
├── createOcf/              # Unit tests (mock-based)
├── fixtures/               # Test data fixtures
├── integration/            # LocalNet integration tests
│   ├── setup/              # Test harness and factories
│   │   ├── integrationTestHarness.ts  # Shared context, client init
│   │   ├── entityTestFactory.ts       # Generic test patterns
│   │   ├── contractDeployment.ts      # Factory deploy, issuer auth
│   │   └── index.ts
│   ├── entities/           # Per-entity test files
│   │   ├── issuer.integration.test.ts
│   │   ├── capTableBatch.integration.test.ts
│   │   ├── stockClassAdjustments.integration.test.ts
│   │   └── ...             # acceptance, exercise, transfer, etc.
│   ├── workflows/          # Multi-entity workflow tests
│   │   ├── capTableWorkflow.integration.test.ts
│   │   └── batchOperations.integration.test.ts
│   ├── production/         # Production fixture roundtrip tests
│   │   └── productionDataRoundtrip.integration.test.ts
│   ├── utils/              # Test data factories and helpers
│   │   ├── setupTestData.ts
│   │   ├── transactionHelpers.ts
│   │   └── index.ts
│   └── quickstart.smoke.test.ts
├── mocks/                  # Mock implementations
└── utils/                  # Shared test utilities
    ├── testConfig.ts       # Environment config
    ├── fixtureHelpers.ts   # Fixture management
    └── ocfSchemaValidator.ts

API IDs: Parameters accept plain string contract and party IDs. Optional helpers toPartyId / toContractId in types/branded.ts tighten nominal types where you want them; typical examples do not require them.

Entity Folder Organization (CRITICAL)

Each OCF entity type MUST have its own folder with converter implementations:

src/functions/OpenCapTable/{entityType}/
├── {entityType}DataToDaml.ts   # OCF→DAML converter (batch creates/edits)
├── get{EntityType}AsOcf.ts     # DAML→OCF converter (reads)
├── create{EntityType}.ts       # Optional — legacy issuer-scoped commands only (e.g. issuer, stockTransfer)
└── index.ts

Most entity folders contain only *DataToDaml.ts and get*AsOcf.ts; batch UpdateCapTable is the primary write path.

Dispatcher Files (capTable/)

The capTable/ folder contains dispatchers only that import from entity folders:

// capTable/ocfToDaml.ts - DISPATCHER ONLY
import { stakeholderDataToDaml } from '../stakeholder/stakeholderDataToDaml';
import { stockTransferDataToDaml } from '../stockTransfer/createStockTransfer';
// ... imports from entity folders

export function convertToDaml(type: OcfEntityType, data: OcfEntityData): unknown {
  switch (type) {
    case 'stakeholder':
      return stakeholderDataToDaml(data);
    // ... routes to imported functions
  }
}

DO NOT put converter implementations in capTable/ocfToDaml.ts or capTable/damlToOcf.ts.

Why This Matters

  1. Discoverability: All code for an entity is in one folder
  2. Maintainability: Changes to an entity only affect its folder
  3. Scalability: Adding new entities doesn't bloat central files
  4. Code Review: PRs for new entities are self-contained

Function Pattern

Each operation exports: Params, Result, function, and optionally buildXCommand.

OcpClient API (consumer-facing)

The OcpClient is the primary API surface. Construct it with LedgerJsonApiClient (required) and ValidatorApiClient (optional), typically from new Canton({ ... }) in @fairmint/canton-node-sdk. The SDK does not build hidden clients; the same instances you pass in are exposed as ocp.ledger and ocp.validator.

OCP Factory (issuer authorization): On mainnet and devnet, the SDK resolves the deployed factory from the bundled network map in @fairmint/open-captable-protocol-daml-js. For localnet, testnet, scratchnet, custom, or any other non-bundled deployment, pass factory: { contractId, templateId } on the client (type OcpFactoryCoordinates), or pass factoryContractId / factoryTemplateId on each OpenCapTable.issuerAuthorization.authorize() call. If both per-call fields are omitted, the client-level factory is merged in; if you pass either per-call field, you must pass both—they replace the client default and are not merged with partial client coords. To deploy a factory on a fresh ledger, use createFactory(ledger, { systemOperator, templateId? }) (exported from the package); its result shape matches the factory constructor option.

Methods are grouped under OpenCapTable and context. Company valuation reports (OpenCapTableReports DAML) are not exported from this package — use @fairmint/canton-fairmint-sdk (createFairmintOcpClient) with @fairmint/daml-js (v0.5.0+; see OcpClient module TSDoc on main). This package stays focused on OCP cap-table operations and OCF-shaped reads.

OcpClient.context caches issuer party and cap table contract id only. FeaturedAppRight is not stored on context (setFeaturedAppRight / requireFeaturedAppRight removed in PR #380); pass featuredAppRightContractDetails on each command or helper that requires that disclosure (often from validator lookup), as in integration tests and CreateStakeholderParams-style examples below.

Observability: Optional logger, metrics, defaultContext, and per-call context on OCP writes (plus commandId on capTable.update()). Configure via new OcpClient({ ledger, logger, metrics, defaultContext }); environment helpers (fromEnv, forLocalNet, etc.) do not forward these options yet. See Observability-and-Tracing.

Environment configuration: Use OcpClient.fromEnv(), create(), or preset helpers (forLocalNet, forDevNet, forTestNet, forMainNet) when the SDK should build Canton clients from presets or CANTON_* env vars. See Environment-Configuration for detectEnvironment, factory requirements, and exported config helpers.

Payment-stream, coupon-minter, and similar validator-backed flows are not part of this package (removed in v0.4.0); pass validator when your integration needs them. Use createBatch for custom TransactionBatch composition; for cap-table updates prefer OpenCapTable.capTable.update. See OcpClient in source and dist/index.d.ts for the full surface.

import { Canton } from '@fairmint/canton-node-sdk';
import { OcpClient } from '@open-captable-protocol/canton';

const canton = new Canton({ network: 'localnet' });
const ocp = new OcpClient({ ledger: canton.ledger, validator: canton.validator });
// Optional: logger, metrics, defaultContext — see [[Observability-and-Tracing|Observability and Tracing]]

// Custom / localnet factory (optional): new OcpClient({ ledger, factory: { contractId, templateId } })
// Deploy first with createFactory(ledger, { systemOperator }) if the network has no bundled factory.

// All get() methods return ContractResult<T> with { data, contractId }
const { data: issuer } = await ocp.OpenCapTable.issuer.get({ contractId: '...' });
console.log(issuer.object_type); // 'ISSUER' - discriminated union
console.log(issuer.legal_name);

// Batch cap table updates (UpdateCapTable) — params match CapTableUpdateParams
const batch = ocp.OpenCapTable.capTable.update({
  capTableContractId: '...',
  actAs: ['issuerParty'],
  // readAs: ['observerParty'], // optional
  // capTableContractDetails: { templateId: '...' }, // optional; use ledger template id when not the package default
  // commandId: 'my-deterministic-id', // optional; reuse for idempotent UpdateCapTable retries
});
batch.create('stakeholder', stakeholderData);
await batch.execute();

Generic entity reads: OcpClient exposes get() for all batch entity types. Recent additions use the shared getEntityAsOcf dispatcher: stockRetraction, warrantRetraction, convertibleRetraction, equityCompensationRetraction, equityCompensationRelease, equityCompensationRepricing, stockPlanReturnToPool.

Read by OCF object_type: OpenCapTable.getByObjectType({ objectType, contractId, readAs? }) delegates to the matching entity .get() reader via OCF_OBJECT_TYPE_TO_ENTITY_TYPE. Unsupported types throw OcpValidationError with UNKNOWN_ENUM_VALUE. Helpers: mapOcfObjectTypeToEntityType(), isOcfReadableObjectType(), types OcfReadableObjectType / OcfOutputForObjectType<T>.

import { OCF_OBJECT_TYPE_TO_ENTITY_TYPE, type OcfReadableObjectType } from '@open-captable-protocol/canton';

const objectType: OcfReadableObjectType = 'STOCK_CLASS';
const result = await ocp.OpenCapTable.getByObjectType({
  objectType,
  contractId: stockClassContractId,
  readAs: [issuerParty],
});

console.log(result.contractId);
console.log(result.data.object_type); // 'STOCK_CLASS'
console.log(OCF_OBJECT_TYPE_TO_ENTITY_TYPE[objectType]); // 'stockClass'

OcpFactory and createFactory (custom networks)

authorizeIssuer exercises choices on the OcpFactory contract (Fairmint.OpenCapTable.OcpFactory). When ledger.getNetwork() is mainnet or devnet, contract id and template id resolve from ocp-factory-contract-id.json in the pinned @fairmint/open-captable-protocol-daml-js package—no createFactory call.

For localnet, testnet, scratchnet, custom, and other networks without bundled coords, deploy once, then pass { contractId, templateId } via new OcpClient({ …, factory }) (see merge rules above) or per-call on authorize:

import { Canton } from '@fairmint/canton-node-sdk';
import { OcpClient, createFactory } from '@open-captable-protocol/canton';

const canton = new Canton({ network: 'localnet' });
const deployed = await createFactory(canton.ledger, {
  systemOperator: systemOperatorPartyId,
  // templateId optional — defaults to OCP_TEMPLATES.ocpFactory
});

const ocp = new OcpClient({
  ledger: canton.ledger,
  validator: canton.validator,
  factory: { contractId: deployed.contractId, templateId: deployed.templateId },
});

await ocp.OpenCapTable.issuerAuthorization.authorize({ issuer: issuerPartyId });

API: createFactory(client, params){ contractId, templateId, updateId }. Source: src/functions/OpenCapTable/factory/createFactory.ts (root export). See PR #382.

Warrant issuance (batch + readback)

  • Batch key: warrantIssuance on capTable.update().create('warrantIssuance', …) (same pattern as other cap-table creates).
  • Read shape: getWarrantIssuanceAsOcf in src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts returns OCF with object_type: 'TX_WARRANT_ISSUANCE'.
  • Optional dates: When present on the contract, board_approval_date and stockholder_approval_date are included in DAML→OCF conversion (damlWarrantIssuanceDataToNative) so Canton readback matches stored OCF and replication parity checks stay clean.
  • Stock-class conversion triggers: OCF STOCK_CLASS_CONVERSION_RIGHT with RATIO_CONVERSION maps to DAML OcfAnyConversionRight tag OcfRightStockClass (warrant rights use OcfRightWarrant); readback accepts both tags.
  • Warrant conversion_mechanism: OCF→DAML rejects missing or non-object mechanisms (explicit null guard) so invalid JSON does not reach the ledger API.
  • rounding_type (stock-class ratio path): DAML OcfStockClassConversionRight has no rounding field. CEILING / FLOOR on OCF input fail fast at encode time; Canton readback always emits NORMAL for that OCF field on this path (matches stock-class readback elsewhere).
  • DAML enum JSON: Read paths tolerate enums as plain strings or { tag: "…" } where the JSON API varies (e.g. stock-class conversion_mechanism, warrant conversion_right tags). Unknown tags / values surface as parse or validation errors with UNKNOWN_ENUM_VALUE where applicable.
  • Quantity / quantity_source: Encoder treats absent quantity like replication (explicit null in DB still maps with the same truthiness rules as the quantity field); if quantity_source is present on the contract but not mappable, readback throws instead of silently dropping it.
  • Schema: WarrantIssuance.schema.json (OCF).
// Cap table snapshot (replication): null if none; errors if >1 active CapTable on the supported package line
const state = await ocp.OpenCapTable.capTable.getState(issuerPartyId);
const classification = await ocp.OpenCapTable.capTable.classify(issuerPartyId); // 'current' | 'none'

Read scope (readAs): Single-contract reads use getEventsByContractId and accept optional readAs on params (e.g. get*AsOcf, extractCantonOcfManifest). getCapTableState applies readAs: [issuerPartyId] when loading the issuer contract’s create event so issuer-scoped visibility works even when the Ledger API user is not the issuer party. Pass readAs into manifest extraction (and other readers) when your client acts for a different visibility party—same idea as optional readAs on capTable.update.

Internal converter pattern (batch-first)

// src/functions/OpenCapTable/stakeholder/stakeholderDataToDaml.ts
export function stakeholderDataToDaml(d: OcfStakeholder): Fairmint.OpenCapTable.OCF.Stakeholder.OcfStakeholder {
  validateStakeholderData(d);
  if (!d.id) throw new Error('stakeholder.id is required');

  return {
    id: d.id,
    name: nameToDaml(d.name),
    stakeholder_type: stakeholderTypeToDaml(d.stakeholder_type),
    // ... all fields explicit (NO spread)
  };
}

Type Conversions

Use utilities from src/utils/typeConversions.ts:

import {
  normalizeNumericString, // Normalize and validate numeric strings (throws on invalid input)
  optionalString, // empty/undefined → null
  cleanComments, // filter comments array
  dateStringToDAMLTime, // ISO date → DAML time
  monetaryToDaml, // monetary object → DAML format
  damlMonetaryToNative, // DAML monetary → native (with validation)
} from '../../utils/typeConversions';

Type conversion best practices:

  1. Validate before converting — Check for null/undefined before calling conversion functions
  2. Use .toString() not String() — When you've validated a value is not null/undefined
  3. Let validation functions throw — Functions like normalizeNumericString() throw on invalid input
  4. Example pattern:
// ❌ Bad - creates "undefined" if amount is undefined
const amount = String(data.amount);

// ✅ Good - validates then converts
if (data.amount === undefined || data.amount === null) {
  throw new Error('amount is required');
}
const amount = typeof data.amount === 'number' ? data.amount.toString() : data.amount;

Non-Negotiables

  1. Strict TypeScript — No any; no broad assertions
    • Use type guards instead of as any casts
    • Use unknown only at untyped boundaries and narrow (type guards, typeof, in) before use — never as an untyped blob
  2. Fail fast — Validate early, throw with actionable messages
  3. Never silently ignore errors — Prefer failing over hiding problems
    • No empty catch blocks that swallow errors
    • No early returns that make tests "pass" when they didn't run
    • Tests must actually test — if infrastructure is missing, tests should fail, not skip
  4. No unsafe type coercion — ALWAYS validate before converting types
    • NEVER use String(value) on potentially undefined/null values
    • NEVER use Number(value) on potentially invalid strings (creates NaN)
    • Example: ❌ String(amount) → ✅ if (!amount) throw new Error('...'); amount.toString()
  5. Explicit fields — List all fields, never use spread (...d)
  6. No defensive checks — Trust DAML types, don't check Array.isArray()
  7. Use null for DAML optional fields (not undefined)
  8. Use ?? instead of || for nullish coalescing
  9. Pinned dependencies — ALWAYS use exact versions without ^ or ~
    • ✅ Correct: "jsonwebtoken": "9.0.3"
    • ❌ Wrong: "jsonwebtoken": "^9.0.3" or "~9.0.3"
    • peerDependencies should use ranges (e.g. ">=0.3.3 <0.4.0", match package.json)
  10. DRY code — Extract duplicated logic into reusable helpers

Testing Strategy

Test Type Command Purpose
Unit tests npm test Mock-based tests for conversions, validation
Unit tests (CI) npm run test:ci Typecheck + Jest in-band (.github/workflows/ci.yml)
Integration tests npm run test:integration LocalNet tests validating DAML contracts
Integration (CI) npm run test:integration:ci Same suite, in-band (used by localnet:test)
Type checking npm run typecheck TypeScript compilation checks

Unit Tests (Mock-based)

// test/createOcf/stockIssuanceReadConversions.test.ts
import { damlStockIssuanceDataToNative } from '../../src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf';

test('returns RSA when issuance_type is OcfStockIssuanceRSA', () => {
  const result = damlStockIssuanceDataToNative(damlFixture);
  expect(result.issuance_type).toBe('RSA');
});

CI integration testing

Workflow Scope
ci.yml Lint, build, npm run test:ci (unit tests only)
test-ocp-quickstart.yml CN-Quickstart LocalNet: npm run localnet:smoke then localnet:testtest:integration:ci

Run integration tests locally when your change touches DAML contracts, converters, or the LocalNet harness.

Integration Tests (LocalNet)

Integration tests run against a real Canton LocalNet environment. They catch issues that mocks miss:

  • Invalid DAML command structure
  • Type conversion errors that only surface at runtime
  • Incorrect template IDs or choice names
  • Contract workflow validation
# Start LocalNet (cn-quickstart) first
npm run test:integration
# or: npm run localnet:test  # smoke + test:integration:ci

Prerequisite chain: Transaction tests must create real ledger entities before posting dependent events. Use helpers from test/integration/utils/setupTestData.ts — e.g. setupStockSecurity, setupWarrantSecurity, then getCapTableDetails for fresh capTableContractDetails after each batch.

Production roundtrip: test/integration/production/productionDataRoundtrip.integration.test.ts replays production-shaped fixtures end-to-end on LocalNet.

Integration test pattern (using harness):

// test/integration/entities/issuer.integration.test.ts
import { createIntegrationTestSuite } from '../setup';
import { generateTestId, setupTestIssuer } from '../utils';
import { validateOcfObject } from '../../utils/ocfSchemaValidator';

createIntegrationTestSuite('Issuer operations', (getContext) => {
  test('creates issuer and reads it back as valid OCF', async () => {
    const ctx = getContext(); // Throws if LocalNet not available

    const testSetup = await setupTestIssuer(ctx.ocp, {
      issuerParty: ctx.issuerParty,
      systemOperatorParty: ctx.systemOperatorParty,
      ocpFactoryContractId: ctx.ocpFactoryContractId,
      issuerData: { id: generateTestId('issuer'), legal_name: 'Test Corp' },
    });

    const ocfResult = await ctx.ocp.OpenCapTable.issuer.get({
      contractId: testSetup.issuerOcfContractId,
    });

    expect(ocfResult.data.object_type).toBe('ISSUER');
    await validateOcfObject(ocfResult.data as unknown as Record<string, unknown>);
  });
});

Adding tests for a new entity type:

  1. Create test/integration/entities/{entity}.integration.test.ts
  2. Use createIntegrationTestSuite() for automatic harness setup
  3. Add data factory in test/integration/utils/setupTestData.ts if needed
  4. Reference issuer.integration.test.ts or capTableBatch.integration.test.ts as examples

OCF Schema Validation

Initialize the OCF schema submodule (schemas live under libs/Open-Cap-Format-OCF/schema/):

git submodule update --init --recursive libs/Open-Cap-Format-OCF
npm test
import { validateOcfObject } from './test/utils/ocfSchemaValidator';
await validateOcfObject({ object_type: 'ISSUER', ... });

Adding a New OCF Operation

  1. Create file: src/functions/OpenCapTable/{entity}/{operation}.ts
  2. Define Params, Result interfaces
  3. Implement operation function
  4. Add buildXCommand for batch support
  5. Export from src/functions/OpenCapTable/{entity}/index.ts
  6. Add an ENTITY_REGISTRY entry in src/functions/OpenCapTable/capTable/batchTypes.ts (derived maps — ENTITY_TAG_MAP, ENTITY_DATA_FIELD_MAP, etc. — update automatically)
  7. Add [objectType]: '{entityType}' to OCF_OBJECT_TYPE_TO_ENTITY_TYPE in the same file (must mirror ENTITY_REGISTRY; skip planSecurity* aliases). Unit test validates sync.
  8. Add to OcpClient.ts (dedicated reader or genericEntity via getEntityAsOcf when converters exist)
  9. Add test fixture in test/fixtures/

LocalNet (cn-quickstart)

# Start localnet (uses fast path when artifacts exist)
npm run localnet:start

# Check status at any time
npm run localnet:status

# Run smoke checks
npm run localnet:smoke

# Run integration tests
npm run localnet:test

# Stop localnet when done
npm run localnet:stop

# One-shot: setup + start + smoke + test
npm run localnet:verify

Force a full rebuild start:

CANTON_LOCALNET_FORCE_FULL_START=true npm run localnet:start

OCP DAR file (integration deploy & quickstart)

The Open Cap Table DAR path is resolved from the pinned @fairmint/open-captable-protocol-daml-js package only (no hardcoded OpenCapTable-v* folders in this repo).

  • Import resolveOpenCapTableDarPath from @fairmint/open-captable-protocol-daml-js/openCapTableDarPath. This module is the single source of truth; it resolves the packaged DAR or a build from a sibling open-captable-protocol-daml checkout / env as the package defines.
  • This repo wraps it in resolveOpenCapTableDarForOcpSdkRepo() (scripts/lib/resolveOpenCapTableDarForOcpSdkRepo.ts), used by test/integration/setup/contractDeployment.ts and scripts/quickstart/deployContracts.ts.
  • CI uses check-pinned-deps (see .github/workflows/ci.yml) so exact versions in package.json (including @fairmint/open-captable-protocol-daml-js) stay pinned; align upgrades with peerDependencies and release notes.
  • The package root export is browser-safe (no fs); DAR path resolution lives on the openCapTableDarPath subpath (resolveOpenCapTableDarPath).
  • Keep @fairmint/open-captable-protocol-daml-js at or above the peerDependencies floor in package.json (currently ≥ 0.3.3). If resolution fails, install or upgrade the npm package or build the DAR from open-captable-protocol-daml as the error message describes.

Pre-push checklist:

  1. npm run fix — Format and lint
  2. npm run test:ci — Unit tests + typecheck (CI parity)
  3. npm run test:integration — LocalNet integration tests (when ledger/DAML/converters change)

DAML Upgrade Testing

When upgrading @fairmint/open-captable-protocol-daml-js (peer range >=0.3.3 <0.4.0, OpenCapTable 0.3.x line):

  1. Peer alignment — Consumers must install daml-js ≥ 0.3.3; 0.2.x peers are no longer accepted (PR #395).
  2. Check release notes for breaking changes in field types or template IDs on the new package line.
  3. Run npm run test:integration (not just unit tests).
  4. Verify OCF-to-DAML converters encode union types correctly.
  5. DAR path: LocalNet deploy and integration setup use openCapTableDarPath / resolveOpenCapTableDarPath() from the package (via resolveOpenCapTableDarForOcpSdkRepo here). You do not update versioned paths under node_modules in this repo.
  6. npm ci / npm install is sufficient — no postinstall patch.

NPM Publishing

Releases run from .github/workflows/publish.yml on pushes to main or workflow_dispatch. (In GitHub’s UI the workflow may still appear under a historical name such as “Deploy Docs”; this repository only publishes the package to npm.)

How It Works

  1. PR → merge to main (with review as usual).
  2. Publish workflow: installs deps, initializes the OCF submodule, npm run build, npm run prepare-release (patch bump + changelog unless you already set minor/major in package.json), then npm publish.
  3. npm Trusted Publishing (OIDC): the job sets id-token: write, uses Node 22.14, and upgrades to npm 11.10.0 (per workflow comments). It does not set NODE_AUTH_TOKEN. With Trusted Publisher configured for this repo on npmjs.org, no long-lived NPM_TOKEN secret is needed for that workflow.
  4. Tag: creates and pushes v{version} (e.g. v0.5.0) from package.json.

Public documentation is published from the Fairmint/web monorepo (apps/ocp-canton-sdk); this repository's npm publish workflow no longer runs TypeDoc or deploys GitHub Pages.

PR validation (lint, tests) lives in ci.yml and related workflows — not in publish.yml.

Version Bumping

  • Patch: prepare-release on each main merge (typical).
  • Minor/Major: set version in package.json before merging.

Manual Release (Local)

Local npm publish is separate from CI: use npm login or a token the way npm documents for your machine — not the OIDC path above.

npm run prepare-release   # bumps version, changelog
npm publish

Troubleshooting

commands.0: Invalid input (batch parameter validation failure)

Symptom: CapTableBatch.execute() throws Batch execution failed: Parameter validation failed: commands.0: Invalid input.

Root cause: A converter (*DataToDaml) emitted undefined for an optional field instead of null. The Canton JSON API uses strict Zod schema validation; undefined is not valid JSON.

Triage checklist:

  1. Identify the failing row from the batch log (entity type + OCF ID).
  2. Validate the source data against the OCF JSON schema in libs/Open-Cap-Format-OCF/schema/. If the data is schema-valid, this is a converter bug, not a data issue.
  3. Check the converter in src/functions/OpenCapTable/<entity>/ for direct passthrough of optional fields (e.g., fieldName: d.fieldName without ?? null or ?? []).
  4. Check for deprecated OCF fields — the schema may allow alternative field names via oneOf.

Safety net: CapTableBatch.build() runs assertJsonSafe() which recursively detects undefined values and throws with the exact JSON-path.

Converter rules:

  • Never emit undefined in output objects — use null for DAML optional fields.
  • Always handle deprecated OCF field alternatives (check schema oneOf/anyOf).
  • Always normalize arrays with ?? [] and strings with optionalString().

Ambiguous legacy stakeholder relationship events (new_relationships)

Rule: Treat multi-value new_relationships as ambiguous and fail fast.

  • If canonical relationship_started / relationship_ended are present, use them.
  • If only legacy new_relationships is present:
    • single value can map to relationship_started
    • multiple values must throw a clear validation error

Stakeholder event wrapper compatibility (event_data)

Fix: Update the entity's ENTITY_REGISTRY entry in batchTypes.ts — set dataField and optional dataFieldFallbacks (e.g. stakeholderRelationshipChangeEvent / stakeholderStatusChangeEvent use event_data). Derived ENTITY_DATA_FIELD_MAP and ENTITY_DATA_FIELD_FALLBACK_MAP follow from the registry; do not hand-edit those maps.

Stock class conversion ratio adjustment normalization

For stockClassConversionRatioAdjustmentDataToDaml:

  • Always convert conversion_price via monetaryToDaml(...)
  • Always normalize ratio numerator/denominator via normalizeNumericString(...)

Stale issuer contract reference (CONTRACT_EVENTS_NOT_FOUND during extraction)

Root cause: The CapTable stores the issuer as a single contractId in payload.issuer. If that contract is archived or missing, issuer rows are omitted from entities / contractIds, but issuerContractId on the state object still reflects the payload reference.

Fix: Extractors should use cantonState.contractIds.get('issuer') (populated only when the issuer contract resolves) instead of re-reading raw payload IDs. Resolution uses contract-events reads with readAs scoped to the issuer party where applicable; if you still see visibility errors, confirm your readAs on extractCantonOcfManifest / get*AsOcf matches the parties that can see those contracts.

Cap table template line and state reads

getCapTableState / classifyIssuerCapTables query active contracts with the symbolic package-name template id from the pinned @fairmint/open-captable-protocol-daml-js package (OCP_TEMPLATES.capTable). Older OpenCapTable package lines on the ledger are ignored for “current” classification. More than one active CapTable on that package line for the same issuer party is treated as a schema error.

  • The ledger may echo createdEvent.templateId as #PackageName:Module:Entity or as hash:Module:Entity. The SDK validates each row using createdEvent.packageName (must match the pinned package) and the module + entity suffix of templateId (everything after the first :), aligned with OCP_TEMPLATES.capTable. Missing/empty packageName or a mismatched line/path throws OcpContractError with OcpErrorCodes.SCHEMA_MISMATCH. On success, the raw ledger templateId is returned unchanged for downstream use.
  • null state / none classification mean no contract on that pinned package line — not “the issuer has no CapTable-shaped contract anywhere.” Other package lines are out of scope for this status.

Multi-repo SDK sharing (archiveFullCapTable, getSystemOperatorPartyId)

  • These live in src/functions/OpenCapTable/capTable/archiveFullCapTable.ts and take LedgerJsonApiClient (not OcpClient) so explorer/CLI can reuse them.
  • Archive resolves templateId and context.system_operator from the live CapTable (version-aware). Optional systemOperatorPartyId skips the read when the caller already has it.
  • archiveFullCapTable matches the caller’s capTableContractId when provided so the correct table is archived if multiple were ever visible.
  • Consumers need a published SDK release to pick up new exports — coordinate multi-repo changes accordingly.

Architecture Decision Records (ADRs)

See ADRs for the ADR index and lifecycle. When adding an ADR, create ADR-NNN-Short-Title, update ADRs, and link it from related notes.

Related Repos

Repo Purpose Docs
canton Trading infrastructure, ADRs AGENTS.md
canton-explorer Next.js explorer UI AGENTS.md, cantonops.fairmint.com
canton-node-sdk Low-level Canton client AGENTS.md, sdk.canton.fairmint.com
ocp-equity-certificate Soulbound equity certificate smart contracts AGENTS.md
open-captable-protocol-daml DAML contracts (OCF impl) AGENTS.md
Open-Cap-Format-OCF (libs/Open-Cap-Format-OCF) OCF JSON schemas (git submodule) GitHub

Docs

Last reviewed: 2026-06-19

Clone this wiki locally