-
Notifications
You must be signed in to change notification settings - Fork 0
Home
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)
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).
if [ -d wiki/.git ]; then git -C wiki pull; else git clone https://github.com/Fairmint/ocp-canton-sdk.wiki.git wiki; finpm 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)ocp-canton-sdk # This SDK - OCP operations
│
canton-node-sdk # Low-level Canton client
│
Canton Network # DAML contracts
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.
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.
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.
- Discoverability: All code for an entity is in one folder
- Maintainability: Changes to an entity only affect its folder
- Scalability: Adding new entities doesn't bloat central files
- Code Review: PRs for new entities are self-contained
Each operation exports: Params, Result, function, and optionally buildXCommand.
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'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.
-
Batch key:
warrantIssuanceoncapTable.update().create('warrantIssuance', …)(same pattern as other cap-table creates). -
Read shape:
getWarrantIssuanceAsOcfinsrc/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.tsreturns OCF withobject_type: 'TX_WARRANT_ISSUANCE'. -
Optional dates: When present on the contract,
board_approval_dateandstockholder_approval_dateare 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_RIGHTwithRATIO_CONVERSIONmaps to DAMLOcfAnyConversionRighttagOcfRightStockClass(warrant rights useOcfRightWarrant); 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): DAMLOcfStockClassConversionRighthas no rounding field.CEILING/FLOORon OCF input fail fast at encode time; Canton readback always emitsNORMALfor 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-classconversion_mechanism, warrantconversion_righttags). Unknown tags / values surface as parse or validation errors withUNKNOWN_ENUM_VALUEwhere applicable. -
Quantity /
quantity_source: Encoder treats absent quantity like replication (explicitnullin DB still maps with the same truthiness rules as the quantity field); ifquantity_sourceis 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.
// 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)
};
}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:
- Validate before converting — Check for null/undefined before calling conversion functions
-
Use
.toString()notString()— When you've validated a value is not null/undefined -
Let validation functions throw — Functions like
normalizeNumericString()throw on invalid input - 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;-
Strict TypeScript — No
any; no broad assertions- Use type guards instead of
as anycasts - Use
unknownonly at untyped boundaries and narrow (type guards,typeof,in) before use — never as an untyped blob
- Use type guards instead of
- Fail fast — Validate early, throw with actionable messages
-
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
-
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 (createsNaN) - Example: ❌
String(amount)→ ✅if (!amount) throw new Error('...'); amount.toString()
-
NEVER use
-
Explicit fields — List all fields, never use spread (
...d) -
No defensive checks — Trust DAML types, don't check
Array.isArray() -
Use
nullfor DAML optional fields (notundefined) -
Use
??instead of||for nullish coalescing -
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", matchpackage.json)
- ✅ Correct:
- DRY code — Extract duplicated logic into reusable helpers
| 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 |
// 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');
});| 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:test → test:integration:ci
|
Run integration tests locally when your change touches DAML contracts, converters, or the LocalNet harness.
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:ciPrerequisite 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:
- Create
test/integration/entities/{entity}.integration.test.ts - Use
createIntegrationTestSuite()for automatic harness setup - Add data factory in
test/integration/utils/setupTestData.tsif needed - Reference
issuer.integration.test.tsorcapTableBatch.integration.test.tsas examples
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 testimport { validateOcfObject } from './test/utils/ocfSchemaValidator';
await validateOcfObject({ object_type: 'ISSUER', ... });- Create file:
src/functions/OpenCapTable/{entity}/{operation}.ts - Define
Params,Resultinterfaces - Implement operation function
- Add
buildXCommandfor batch support - Export from
src/functions/OpenCapTable/{entity}/index.ts - Add an
ENTITY_REGISTRYentry insrc/functions/OpenCapTable/capTable/batchTypes.ts(derived maps —ENTITY_TAG_MAP,ENTITY_DATA_FIELD_MAP, etc. — update automatically) - Add
[objectType]: '{entityType}'toOCF_OBJECT_TYPE_TO_ENTITY_TYPEin the same file (must mirrorENTITY_REGISTRY; skipplanSecurity*aliases). Unit test validates sync. - Add to
OcpClient.ts(dedicated reader orgenericEntityviagetEntityAsOcfwhen converters exist) - Add test fixture in
test/fixtures/
# 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:verifyForce a full rebuild start:
CANTON_LOCALNET_FORCE_FULL_START=true npm run localnet:startThe 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
resolveOpenCapTableDarPathfrom@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 siblingopen-captable-protocol-damlcheckout / env as the package defines. - This repo wraps it in
resolveOpenCapTableDarForOcpSdkRepo()(scripts/lib/resolveOpenCapTableDarForOcpSdkRepo.ts), used bytest/integration/setup/contractDeployment.tsandscripts/quickstart/deployContracts.ts. -
CI uses
check-pinned-deps(see.github/workflows/ci.yml) so exact versions inpackage.json(including@fairmint/open-captable-protocol-daml-js) stay pinned; align upgrades withpeerDependenciesand release notes. - The package root export is browser-safe (no
fs); DAR path resolution lives on theopenCapTableDarPathsubpath (resolveOpenCapTableDarPath). - Keep
@fairmint/open-captable-protocol-daml-jsat or above thepeerDependenciesfloor inpackage.json(currently ≥ 0.3.3). If resolution fails, install or upgrade the npm package or build the DAR fromopen-captable-protocol-damlas the error message describes.
Pre-push checklist:
-
npm run fix— Format and lint -
npm run test:ci— Unit tests + typecheck (CI parity) -
npm run test:integration— LocalNet integration tests (when ledger/DAML/converters change)
When upgrading @fairmint/open-captable-protocol-daml-js (peer range >=0.3.3 <0.4.0, OpenCapTable 0.3.x line):
- Peer alignment — Consumers must install daml-js ≥ 0.3.3; 0.2.x peers are no longer accepted (PR #395).
- Check release notes for breaking changes in field types or template IDs on the new package line.
- Run
npm run test:integration(not just unit tests). - Verify OCF-to-DAML converters encode union types correctly.
-
DAR path: LocalNet deploy and integration setup use
openCapTableDarPath/resolveOpenCapTableDarPath()from the package (viaresolveOpenCapTableDarForOcpSdkRepohere). You do not update versioned paths undernode_modulesin this repo. -
npm ci/npm installis sufficient — no postinstall patch.
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.)
-
PR → merge to
main(with review as usual). -
Publish workflow: installs deps, initializes the OCF submodule,
npm run build,npm run prepare-release(patch bump + changelog unless you already set minor/major inpackage.json), thennpm publish. -
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 setNODE_AUTH_TOKEN. With Trusted Publisher configured for this repo on npmjs.org, no long-livedNPM_TOKENsecret is needed for that workflow. -
Tag: creates and pushes
v{version}(e.g.v0.5.0) frompackage.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.
-
Patch:
prepare-releaseon each main merge (typical). -
Minor/Major: set
versioninpackage.jsonbefore merging.
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 publishSymptom: 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:
- Identify the failing row from the batch log (entity type + OCF ID).
-
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. -
Check the converter in
src/functions/OpenCapTable/<entity>/for direct passthrough of optional fields (e.g.,fieldName: d.fieldNamewithout?? nullor?? []). -
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
undefinedin output objects — usenullfor DAML optional fields. -
Always handle deprecated OCF field alternatives (check schema
oneOf/anyOf). -
Always normalize arrays with
?? []and strings withoptionalString().
Rule: Treat multi-value new_relationships as ambiguous and fail fast.
- If canonical
relationship_started/relationship_endedare present, use them. - If only legacy
new_relationshipsis present:- single value can map to
relationship_started - multiple values must throw a clear validation error
- single value can map to
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.
For stockClassConversionRatioAdjustmentDataToDaml:
- Always convert
conversion_priceviamonetaryToDaml(...) - Always normalize ratio numerator/denominator via
normalizeNumericString(...)
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.
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.templateIdas#PackageName:Module:Entityor ashash:Module:Entity. The SDK validates each row usingcreatedEvent.packageName(must match the pinned package) and the module + entity suffix oftemplateId(everything after the first:), aligned withOCP_TEMPLATES.capTable. Missing/emptypackageNameor a mismatched line/path throwsOcpContractErrorwithOcpErrorCodes.SCHEMA_MISMATCH. On success, the raw ledgertemplateIdis returned unchanged for downstream use. -
nullstate /noneclassification 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.
- These live in
src/functions/OpenCapTable/capTable/archiveFullCapTable.tsand takeLedgerJsonApiClient(notOcpClient) so explorer/CLI can reuse them. - Archive resolves
templateIdandcontext.system_operatorfrom the live CapTable (version-aware). OptionalsystemOperatorPartyIdskips the read when the caller already has it. -
archiveFullCapTablematches the caller’scapTableContractIdwhen 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.
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.
| 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 |
- End-user docs: ocp.canton.fairmint.com
- Code style: Contributing
- ADRs: ADRs
- Docs site source: Fairmint/web (
apps/ocp-canton-sdk) - Docs site setup: Docs-Site
Last reviewed: 2026-06-19