@c7-digital/ledger

中文文档
0.0.48发布于 2026/11/07TypeScriptES Module构建来源已验证近 30 天下载 2209

OpenAPI v2 compatible Daml ledger client

原始使用文档
文档目录20 节
  1. Overview
  2. Features
  3. Versioning
  4. Installation
  5. Usage
  6. Service-account tokens
  7. Errors
  8. Migrating to 0.0.48
  9. Migrating to 0.0.45
  10. Migrating to 0.0.43
  11. Migrating to 0.0.42
  12. Migrating to 0.0.41
  13. Migrating to 0.0.40
  14. Migrating to 0.0.39
  15. Migrating to 0.0.38
  16. Migrating to 0.0.37
  17. Migrating to 0.0.34
  18. Migration from @daml/ledger
  19. Development
  20. OpenAPI Specification

@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-typescript to 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's release-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>.yaml and specs/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" });
  • TokenRefresher refreshes 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 in onToken. A token that expires too soon to refresh before it expires counts as a failure.
  • onToken and onFailure can 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.
  • ClientCredentialsTokenSource finds the token endpoint through the issuer's .well-known/openid-configuration. It sends the client secret in the request body when the issuer supports client_secret_post, else in the Authorization header (client_secret_basic). It refuses http unless allowInsecureHttp is set; set it only for a local issuer. With allowInsecureHttp, 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 with mint()) works with TokenRefresher too.

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 Canton deadline (category 3). The submit may already have committed, so retry with the same commandId after a wait. A plain transient (429) is safe to retry fresh. Check this before isRetriableSubmit.
  • Did my resubmit hit dedup? isAlreadyApplied(e) — DUPLICATE_COMMAND: a retry with the same commandId found the participant already applied the prior (seemingly-lost) attempt. A caller retrying an indeterminate submit with a stable commandId treats this as success.
  • Is my archive already a no-op? isAlreadyArchived(e) — isAlreadyApplied or 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 the POST /v2/state/active-contracts body. The party filter is in eventFormat. Canton 3.6 participants reject the deprecated top-level filter by default (DEPRECATED_API_DISABLED).
  • templateFilter(templateId, includeCreatedEventBlob?) and interfaceFilter(interfaceId, includeCreatedEventBlob?) build the IdentifierFilters. The interface filter always asks for the interface view.
  • The types IdentifierFilter and GetActiveContractsRequest are 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 the reachedOffset to resume from.
  • Ledger.getParticipantId() and TypedHttpClient.getParticipantId() read GET /v2/parties/participant-id.
  • TypedHttpClient.getUpdates(request, { limit }) sends POST /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() and exercise() methods now require an actAs parameter
  • 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:

  1. 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 are community/ledger/ledger-json-api/src/test/resources/json-api-docs/{openapi,asyncapi}.yaml. Splice vendors only the OpenAPI file, under canton/ 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/openapi and /docs/asyncapi.
  2. Replace the files in ledger/specs/ with {openapi,asyncapi}_<canton-version>.yaml, and remove the previous pair.
  3. Change the build script's --sdk-version=<version> flag in ledger/package.json (the flag name is historical; it selects the spec file) and the expected SDK_VERSION in src/api-surface.test.ts.
  4. Add a row to the supported versions table.
  5. Run pnpm build to regenerate types.

版本与发布记录

@c7-digital/ledger 共发布 48 个版本,首次发布于 2025/12/16;过去 12 个月发布了 48 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.0.48latest2026/11/071 天前
0.0.472026/11/045 天前
0.0.462026/11/026 天前
0.0.452026/11/018 天前
0.0.442026/10/2416 天前
0.0.432026/10/1624 天前

关于 @c7-digital/ledger 的常见问题

@c7-digital/ledger 是什么?

OpenAPI v2 compatible Daml ledger client

如何安装 @c7-digital/ledger?

在项目目录运行 npm install @c7-digital/ledger,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:@daml/types(>=3.4.0 <4.0.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。它还提供命令行工具 ledger-build,可以用 npx ledger-build 直接运行。

@c7-digital/ledger 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@c7-digital/ledger 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@c7-digital/ledger 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=24.0.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@c7-digital/ledger 可以商用吗?

@c7-digital/ledger 使用 未声明 许可。package.json 没有声明许可证。没有许可证的代码默认保留全部权利,商用前请联系作者或查看仓库中的 LICENSE 文件。以上为概括,不构成法律意见,请以许可原文为准。

@c7-digital/ledger 的下载量有多少?

根据 npm 官方下载统计,@c7-digital/ledger 在 2026/10/09 – 2026/11/07 这 30 天内被下载 2,209 次,日均约 74 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@c7-digital/ledger 还在维护吗?

最新版本 0.0.48 发布于 2026/11/07(1 天前);至今共发布 48 个版本,其中过去 12 个月发布了 48 个;首次发布于 2025/12/16。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。