@c7-digital/ledger
OpenAPI v2 compatible Daml ledger client (that could replace @daml/ledger) for the new Canton JSON API v2.
Overview
This package provides a TypeScript client for interacting with Canton's JSON Ledger API v2, which replaces the deprecated v1 API that @daml/ledger targets.
Features
- Type-safe API: Generated TypeScript types from OpenAPI specification
- OpenAPI v2 Support: Targets the new Canton JSON API v2 endpoints
- Auto-generated Types: Uses
openapi-typescriptto generate accurate type definitions
Versioning
The npm package has its own 0.0.x version line, independent of the Canton spec it targets. Pre-1.0, every release is a patch bump; a breaking change also gets a Migrating to 0.0.N note below. The Canton runtime and JSON API spec this client is built against are versioned separately and surfaced as SDK_VERSION (see below), not as the package version.
This package ships against two independently versioned things:
- JSON Ledger API spec (surfaced as
SDK_VERSION):3.6.5-snapshot.20261027, from Canton'srelease-line-3.6. Splice 0.9.x runs Canton 3.6.x, and that is what DevNet, TestNet and MainNet serve. Only this spec is checked in (specs/openapi_<version>.yamlandspecs/asyncapi_<version>.yaml). - Daml SDK (
@daml/types): a peer dependency (>=3.4.0 <4.0.0), so the app chooses its copy. The tests and CI use 3.5.2, the SDK that Splice 0.9.x builds with.
The supported versions table lists the Canton, Splice and Daml SDK versions of each release. A release targets one Canton line; it does not support older lines.
Installation
pnpm install @c7-digital/ledger
Or build from source:
pnpm install
pnpm build
Usage
import { Ledger } from "@c7-digital/ledger";
const ledger = new Ledger({
token: "your-jwt-token",
httpBaseUrl: "http://localhost:7575",
});
// Query contracts
const contracts = await ledger.query(MyTemplate);
// Create contracts
const result = await ledger.create(MyTemplate, payload, [actAsParty]);
// Exercise choices
const choiceResult = await ledger.exercise(MyChoice, contractId, argument, [actAsParty]);
// Read whole transactions (update id, record time, exercised choices) a page
// at a time; the participant can cut a page short, so loop on reachedOffset.
const participantId = await ledger.getParticipantId();
const end = await ledger.getLedgerEnd();
let offset = 0;
while (offset < end) {
const page = await ledger.getTransactionPage(
{ beginExclusive: offset, endInclusive: end },
{ parties: [party] },
);
for (const tx of page.transactions) console.log(tx.updateId, tx.recordTime);
offset = page.reachedOffset;
}
Service-account tokens
A server that acts on the ledger as a service account imports
@c7-digital/ledger/service-account. It gets tokens through the OAuth 2.0
client-credentials grant from any OpenID Connect issuer, and it keeps the token
valid. This entry point is separate from the main one for two reasons: only a
server can keep a client secret, and it needs the optional peer dependency
openid-client (pnpm add openid-client).
import { Ledger } from "@c7-digital/ledger";
import {
ClientCredentialsTokenSource,
TokenRefresher,
} from "@c7-digital/ledger/service-account";
const source = new ClientCredentialsTokenSource({
issuer: new URL("https://auth.example.com/realms/my-realm"),
clientId: "my-service",
clientSecret: process.env.CLIENT_SECRET!,
audience: "https://ledger.example.com", // only if the issuer needs it
});
let ledger: Ledger | null = null;
const refresher = new TokenRefresher({
source,
onToken: ({ token }) => ledger?.setToken(token),
onFailure: ({ error, failures, retryInMs }) =>
console.warn("token refresh failed", { error, failures, retryInMs }),
});
const { token } = await refresher.start(); // throws if the first token fails
ledger = new Ledger({ token, httpBaseUrl: "https://participant.example.com" });
TokenRefresherrefreshes at 80 % of the token lifetime. After a failure it retries with backoff (5 s, doubled each time, at most 60 s) until a refresh succeeds. A stream that holds its own token must also be updated inonToken. A token that expires too soon to refresh before it expires counts as a failure.onTokenandonFailurecan be async. A throw or a rejected promise from them is logged, and the schedule does not change.refreshNow()gets a new token at once, for example after the ledger rejects the current token. Calls during a refresh join that refresh.status()gives the last refresh, the next refresh, the token expiry, the last error and the number of failures, for a health page.stop()stops the refresh.ClientCredentialsTokenSourcefinds the token endpoint through the issuer's.well-known/openid-configuration. It sends the client secret in the request body when the issuer supportsclient_secret_post, else in the Authorization header (client_secret_basic). It refuses http unlessallowInsecureHttpis set; set it only for a local issuer. WithallowInsecureHttp, an http token endpoint must have the same origin (scheme, host and port) as the issuer, because the client secret goes to it.- Any other
TokenSource(an object withmint()) works withTokenRefreshertoo.
Errors
A non-OK HTTP response throws a LedgerApiError carrying the status and the
response body, tagged with how that body was read:
export type LedgerErrorBody =
| { kind: "canton"; error: JsCantonError } // a rejection Canton can explain
| { kind: "json"; value: unknown } // some other structured error
| { kind: "text"; text: string } // a proxy's HTML page, a plain message
| { kind: "empty" }; // no body, or unreadable
The tag records a decision made once, when the body is read, so a caller does
not re-derive it from typeof — which cannot tell a JSON string literal from
an HTML error page.
cantonError and responseBody are derived from body, so they cannot
disagree with it. Use cantonErrorOf to reach the Canton payload; it also
resolves a bare payload and a wallet-gateway wrapper, so one call covers every
path a rejection can arrive by.
try {
await ledger.exercise(MyChoice, cid, arg, [party]);
} catch (e) {
const canton = cantonErrorOf(e);
if (canton && isRetryable(canton)) return retry();
if (e instanceof LedgerApiError && e.body.kind === "text") {
// Markup here means a proxy answered, not the ledger.
}
}
For submit retry / idempotency, read Canton's structured classification (the
errorCategory integer and the exact code), never the rendered message. Three
helpers over unknown compose the transport (LedgerApiError) and Canton
(cantonError) signals so callers don't re-derive them:
- Should I retry at all?
isRetriableSubmit(e)— a transport transient (408 / 429 / 5xx) or a Canton-retryable rejection (errorCategory∈ contention / transient / deadline / seek-after-end). - Must I keep the same
commandId?isIndeterminateSubmit(e)— HTTP 503 or a Cantondeadline(category 3). The submit may already have committed, so retry with the samecommandIdafter a wait. A plain transient (429) is safe to retry fresh. Check this beforeisRetriableSubmit. - Did my resubmit hit dedup?
isAlreadyApplied(e)—DUPLICATE_COMMAND: a retry with the samecommandIdfound the participant already applied the prior (seemingly-lost) attempt. A caller retrying an indeterminate submit with a stablecommandIdtreats this as success. - Is my archive already a no-op?
isAlreadyArchived(e)—isAlreadyAppliedor the contract is already gone (CONTRACT_NOT_FOUND,LOCAL_VERDICT_INACTIVE_CONTRACTS). Reusable by any app that archives; the policy of treating it as success stays with the caller. - Confirmed permanent hard fail?
isConfirmedPermanentReject(e)— structured Canton rejection that Canton marks non-retryable, and not already-archived. Bare HTTP 4xx / plain Errors are false (fail closed for ignore-and-proceed).
Each takes unknown and reads the status structurally (httpStatusOf, also
exported), so they work directly on a catch binding — no instanceof needed,
and they survive duplicated package copies in a pnpm tree.
try {
await ledger.exercise(Archive, cid, arg, [party]);
} catch (e) {
if (isAlreadyArchived(e)) return; // idempotent: already gone
if (isIndeterminateSubmit(e)) return retrySameCommandId();
if (isRetriableSubmit(e)) return retry();
if (isConfirmedPermanentReject(e)) return markIgnored(); // domain policy
throw e;
}
message carries a summary of the body bounded to 120 characters, so logging
it cannot emit a whole error page. The full value stays on body.
Migrating to 0.0.48
Adds Ledger.getLatestPrunedOffsets() and
TypedHttpClient.getLatestPrunedOffsets(), which read
GET /v2/state/latest-pruned-offsets, and the type PrunedOffsets. A read of
the update history on a pruned participant must begin at or after
participantPrunedUpToInclusive. Additive; nothing removed.
const { participantPrunedUpToInclusive } = await ledger.getLatestPrunedOffsets();
const end = await ledger.getLedgerEnd();
const page = await ledger.getTransactionPage(
{ beginExclusive: participantPrunedUpToInclusive, endInclusive: end },
{ parties: [party] },
);
Migrating to 0.0.45
Adds the entry point @c7-digital/ledger/service-account (see
Service-account tokens): TokenRefresher,
ClientCredentialsTokenSource, retryDelayMs, and the types TokenSource,
MintedToken, RefreshFailure, TokenRefresherStatus,
TokenRefresherOptions and ClientCredentialsOptions. openid-client is a
new optional peer dependency, needed only for this entry point. Additive;
nothing removed.
Migrating to 0.0.43
Exports the request-body builders that Ledger.query and
Ledger.queryInterface use. Use them when the request goes over another
transport, for example a CIP-0103 wallet's ledgerApi proxy, so the body has
the same shape as the one Ledger sends. Additive; nothing removed.
activeContractsRequest(parties, filters, activeAtOffset, verbose?)builds thePOST /v2/state/active-contractsbody. The party filter is ineventFormat. Canton 3.6 participants reject the deprecated top-levelfilterby default (DEPRECATED_API_DISABLED).templateFilter(templateId, includeCreatedEventBlob?)andinterfaceFilter(interfaceId, includeCreatedEventBlob?)build theIdentifierFilters. The interface filter always asks for the interface view.- The types
IdentifierFilterandGetActiveContractsRequestare exported.
import { activeContractsRequest, templateFilter } from "@c7-digital/ledger/lite";
const body = activeContractsRequest([party], [templateFilter(Token.templateId)], offset);
Migrating to 0.0.42
Adds update-history and participant reads. Additive; nothing removed.
Ledger.getTransactionPage(range, { parties, shape, limit })returns whole transactions in(beginExclusive, endInclusive](default shape: ledger effects, so exercised choices are present) and thereachedOffsetto resume from.Ledger.getParticipantId()andTypedHttpClient.getParticipantId()readGET /v2/parties/participant-id.TypedHttpClient.getUpdates(request, { limit })sendsPOST /v2/updates.Ledger.getLedgerEnd()is now public (it was private); it keeps the one-second cache.
Migrating to 0.0.41
A query at "end" now sees the same Ledger instance's own writes. A
successful create, exercise, exerciseResult, submit or
submitWithDisclosures moves the cached ledger end forward to the
transaction's offset. Before, the one-second ledger-end cache could serve an
offset from before the write, so the query could still return contracts that
the write had archived. No API change.
Migrating to 0.0.40
Adds isConfirmedPermanentReject(e) — structured Canton non-retryable reject,
excluding already-archived no-ops. Bare transport 4xx / plain Errors stay
false so ignore-and-proceed callers fail closed. Additive; nothing removed.
Migrating to 0.0.39
Adds isAlreadyApplied(e) — DUPLICATE_COMMAND alone (the command already
committed), for callers that retry an indeterminate submit with a stable
commandId and must treat a dedup hit as success. isAlreadyArchived now
composes it (isAlreadyApplied or contract-gone) — same behavior, no change for
its callers. Additive; nothing removed.
Migrating to 0.0.38
Adds structured, reusable submit classifiers so consumers stop hand-rolling them:
isRetriableSubmit, isIndeterminateSubmit, and isAlreadyArchived (see above),
plus httpStatusOf for reading a status structurally. All read cantonError's
code / errorCategory and the HTTP status — no message-string matching.
The short-lived LedgerApiError.isTransient() / .isIndeterminate() methods
(0.0.37) are removed in favour of these free functions: a method forces an
instanceof check at the call site, which fails across duplicated package copies
and is the exact anti-pattern the free functions avoid.
// 0.0.37
if (e instanceof LedgerApiError && e.isIndeterminate()) retrySameCommandId();
// 0.0.38
if (isIndeterminateSubmit(e)) retrySameCommandId();
Migrating to 0.0.37
The submitError helpers (isArchiveAlreadyDoneError,
isIndeterminateArchiveTimeout, isIndeterminateSubmitError,
isLockedContractsError, isTransientArchiveError) are removed. They duplicated
cantonError — read Canton's classification instead:
// before
if (isTransientArchiveError(e)) retry();
if (isIndeterminateSubmitError(e)) retrySameCommandId();
// after (0.0.38 free functions)
if (isIndeterminateSubmit(e)) retrySameCommandId();
else if (isRetriableSubmit(e)) retry();
Whether a missing or inactive contract counts as success is caller policy —
isAlreadyArchived(e) reports the fact; the caller decides to stop as success.
Migrating to 0.0.34
The LedgerApiError constructor takes a tagged LedgerErrorBody instead of an
untagged unknown. Reading an error is unchanged — status, statusText,
cantonError, and responseBody all behave as before. Only code that
constructs one, which in practice means tests and mocks, needs updating:
new LedgerApiError(404, "Not Found", cantonPayload) // before
new LedgerApiError(404, "Not Found", { kind: "canton", error: cantonPayload })
new LedgerApiError(403, "Forbidden", "<html>…</html>") // before
new LedgerApiError(403, "Forbidden", { kind: "text", text: "<html>…</html>" })
The two-argument form still means "no body" and needs no change.
ScanApiError in @c7-digital/scan has the same status, statusText,
body, and responseBody, so a catch that can receive either reads one set
of fields. It has no canton case.
Migration from @daml/ledger
Key differences:
create()andexercise()methods now require anactAsparameter- Some method signatures have been updated to match OpenAPI v2 spec
- New type definitions generated from OpenAPI specification
Development
# Build the package
pnpm build
# Clean build artifacts
pnpm clean
# Watch for changes
pnpm watch
# Run tests
pnpm test
# Lint code
pnpm lint
OpenAPI Specification
The package uses the JSON Ledger API specification served by Canton, vendored as specs/openapi_<canton-version>.yaml and specs/asyncapi_<canton-version>.yaml. The version encodes the Canton runtime release.
To move to a new Canton line:
- Get the spec of the Canton line that the network runs. Two paths:
- From Canton (both files): on the
release-line-<major.minor>branch of the Canton repository (or the newest<major.minor>-open-source-<date>branch), the specs arecommunity/ledger/ledger-json-api/src/test/resources/json-api-docs/{openapi,asyncapi}.yaml. Splice vendors only the OpenAPI file, undercanton/of its release line. Use it to check that the paths and schemas agree. - From a live participant: curl
http://<participant>:<json-api-port>/docs/openapiand/docs/asyncapi.
- From Canton (both files): on the
- Replace the files in
ledger/specs/with{openapi,asyncapi}_<canton-version>.yaml, and remove the previous pair. - Change the build script's
--sdk-version=<version>flag inledger/package.json(the flag name is historical; it selects the spec file) and the expectedSDK_VERSIONinsrc/api-surface.test.ts. - Add a row to the supported versions table.
- Run
pnpm buildto regenerate types.