@caypo/canton-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/.turbo/turbo-build.log +26 -0
  2. package/.turbo/turbo-test.log +23 -0
  3. package/README.md +120 -0
  4. package/SPEC.md +223 -0
  5. package/dist/amount-L2SDLRZT.js +15 -0
  6. package/dist/amount-L2SDLRZT.js.map +1 -0
  7. package/dist/chunk-GSDB5FKZ.js +110 -0
  8. package/dist/chunk-GSDB5FKZ.js.map +1 -0
  9. package/dist/index.cjs +1158 -0
  10. package/dist/index.cjs.map +1 -0
  11. package/dist/index.d.cts +673 -0
  12. package/dist/index.d.ts +673 -0
  13. package/dist/index.js +986 -0
  14. package/dist/index.js.map +1 -0
  15. package/package.json +50 -0
  16. package/src/__tests__/agent.test.ts +217 -0
  17. package/src/__tests__/amount.test.ts +202 -0
  18. package/src/__tests__/client.test.ts +516 -0
  19. package/src/__tests__/e2e/canton-client.e2e.test.ts +190 -0
  20. package/src/__tests__/e2e/mpp-flow.e2e.test.ts +346 -0
  21. package/src/__tests__/e2e/setup.ts +112 -0
  22. package/src/__tests__/e2e/usdcx.e2e.test.ts +114 -0
  23. package/src/__tests__/keystore.test.ts +197 -0
  24. package/src/__tests__/pay-client.test.ts +257 -0
  25. package/src/__tests__/safeguards.test.ts +333 -0
  26. package/src/__tests__/usdcx.test.ts +374 -0
  27. package/src/accounts/checking.ts +118 -0
  28. package/src/agent.ts +132 -0
  29. package/src/canton/amount.ts +167 -0
  30. package/src/canton/client.ts +218 -0
  31. package/src/canton/errors.ts +45 -0
  32. package/src/canton/holdings.ts +90 -0
  33. package/src/canton/index.ts +51 -0
  34. package/src/canton/types.ts +214 -0
  35. package/src/canton/usdcx.ts +166 -0
  36. package/src/index.ts +97 -0
  37. package/src/mpp/pay-client.ts +170 -0
  38. package/src/safeguards/manager.ts +183 -0
  39. package/src/traffic/manager.ts +95 -0
  40. package/src/wallet/config.ts +88 -0
  41. package/src/wallet/keystore.ts +164 -0
  42. package/tsconfig.json +8 -0
  43. package/tsup.config.ts +9 -0
  44. package/vitest.config.ts +7 -0
@@ -0,0 +1,118 @@
1
+ /**
2
+ * CheckingAccount — high-level USDCx checking account.
3
+ *
4
+ * Wraps USDCxService with safeguard checks and transaction history.
5
+ */
6
+
7
+ import type { CantonClient } from "../canton/client.js";
8
+ import type { USDCxService } from "../canton/usdcx.js";
9
+ import type { TransferResult } from "../canton/usdcx.js";
10
+ import type { SafeguardManager } from "../safeguards/manager.js";
11
+
12
+ export interface SendOptions {
13
+ memo?: string;
14
+ commandId?: string;
15
+ }
16
+
17
+ export interface TransactionRecord {
18
+ updateId: string;
19
+ commandId: string;
20
+ effectiveAt: string;
21
+ offset: number;
22
+ type: "send" | "receive" | "unknown";
23
+ amount?: string;
24
+ counterparty?: string;
25
+ }
26
+
27
+ export class CheckingAccount {
28
+ constructor(
29
+ private readonly usdcx: USDCxService,
30
+ private readonly safeguards: SafeguardManager,
31
+ private readonly client: CantonClient,
32
+ private readonly partyId: string,
33
+ ) {}
34
+
35
+ /** Get USDCx balance: total available and number of UTXO holdings. */
36
+ async balance(): Promise<{ available: string; holdingCount: number }> {
37
+ const holdings = await this.usdcx.getHoldings();
38
+ let available = "0";
39
+
40
+ // Use addAmounts from the holdings themselves
41
+ const { addAmounts } = await import("../canton/amount.js");
42
+ for (const h of holdings) {
43
+ available = addAmounts(available, h.amount);
44
+ }
45
+
46
+ return { available, holdingCount: holdings.length };
47
+ }
48
+
49
+ /**
50
+ * Send USDCx to a recipient.
51
+ * Checks safeguards before executing the transfer.
52
+ */
53
+ async send(
54
+ recipient: string,
55
+ amount: string,
56
+ opts?: SendOptions,
57
+ ): Promise<TransferResult> {
58
+ // 1. Check safeguards
59
+ const check = this.safeguards.check(amount);
60
+ if (!check.allowed) {
61
+ throw new Error(`Safeguard rejected: ${check.reason}`);
62
+ }
63
+
64
+ // 2. Execute transfer
65
+ const result = await this.usdcx.transfer({
66
+ recipient,
67
+ amount,
68
+ commandId: opts?.commandId,
69
+ });
70
+
71
+ // 3. Record spend
72
+ this.safeguards.recordSpend(amount);
73
+
74
+ return result;
75
+ }
76
+
77
+ /** Party ID for receiving payments. */
78
+ address(): string {
79
+ return this.partyId;
80
+ }
81
+
82
+ /**
83
+ * Query transaction history via /v2/updates/flats.
84
+ * Returns recent flat transactions involving this party.
85
+ */
86
+ async history(opts?: { limit?: number }): Promise<TransactionRecord[]> {
87
+ const limit = opts?.limit ?? 20;
88
+ const offset = await this.client.getLedgerEnd();
89
+
90
+ // Query recent transactions using active contracts approach
91
+ // Note: full streaming history requires WebSocket /v2/updates/flats
92
+ // For now, return what we can derive from recent contract state
93
+ const contracts = await this.client.queryActiveContracts({
94
+ filtersByParty: {
95
+ [this.partyId]: {
96
+ cumulative: [
97
+ {
98
+ identifierFilter: {
99
+ WildcardFilter: { value: { includeCreatedEventBlob: false } },
100
+ },
101
+ },
102
+ ],
103
+ },
104
+ },
105
+ activeAtOffset: offset,
106
+ });
107
+
108
+ // Map contracts to transaction records (limited view — full history needs WebSocket streaming)
109
+ return contracts.slice(0, limit).map((c) => ({
110
+ updateId: "",
111
+ commandId: "",
112
+ effectiveAt: c.createdAt,
113
+ offset: 0,
114
+ type: "unknown" as const,
115
+ amount: typeof c.createArgument.amount === "string" ? c.createArgument.amount : undefined,
116
+ }));
117
+ }
118
+ }
package/src/agent.ts ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * CantonAgent — high-level entry point for the Canton SDK.
3
+ *
4
+ * Creates and wires together all sub-services:
5
+ * - CheckingAccount (USDCx balance, send, history)
6
+ * - SafeguardManager (tx limits, daily limits, lock)
7
+ * - TrafficManager (validator traffic budgets)
8
+ * - MppPayClient (HTTP 402 auto-pay)
9
+ */
10
+
11
+ import { CheckingAccount } from "./accounts/checking.js";
12
+ import { CantonClient } from "./canton/client.js";
13
+ import { USDCxService } from "./canton/usdcx.js";
14
+ import { MppPayClient } from "./mpp/pay-client.js";
15
+ import { SafeguardManager } from "./safeguards/manager.js";
16
+ import { TrafficManager } from "./traffic/manager.js";
17
+ import {
18
+ loadConfig,
19
+ type AgentConfig,
20
+ } from "./wallet/config.js";
21
+
22
+ export interface WalletInfo {
23
+ address: string;
24
+ partyId: string;
25
+ network: string;
26
+ }
27
+
28
+ export interface CantonAgentConfig {
29
+ ledgerUrl?: string;
30
+ token?: string;
31
+ userId?: string;
32
+ partyId?: string;
33
+ network?: "mainnet" | "testnet" | "devnet";
34
+ configPath?: string;
35
+ safeguardsPath?: string;
36
+ }
37
+
38
+ export class CantonAgent {
39
+ readonly checking: CheckingAccount;
40
+ readonly safeguards: SafeguardManager;
41
+ readonly traffic: TrafficManager;
42
+ readonly mpp: MppPayClient;
43
+ readonly wallet: WalletInfo;
44
+
45
+ private readonly client: CantonClient;
46
+ private readonly usdcx: USDCxService;
47
+
48
+ private constructor(
49
+ client: CantonClient,
50
+ usdcx: USDCxService,
51
+ checking: CheckingAccount,
52
+ safeguards: SafeguardManager,
53
+ traffic: TrafficManager,
54
+ mpp: MppPayClient,
55
+ wallet: WalletInfo,
56
+ ) {
57
+ this.client = client;
58
+ this.usdcx = usdcx;
59
+ this.checking = checking;
60
+ this.safeguards = safeguards;
61
+ this.traffic = traffic;
62
+ this.mpp = mpp;
63
+ this.wallet = wallet;
64
+ }
65
+
66
+ /**
67
+ * Create a new CantonAgent from config.
68
+ *
69
+ * Loads configuration from ~/.caypo/config.json (or overrides),
70
+ * initializes all sub-services, and wires them together.
71
+ */
72
+ static async create(config?: CantonAgentConfig): Promise<CantonAgent> {
73
+ // 1. Load config (file + overrides)
74
+ const fileConfig = await loadConfig(config?.configPath);
75
+ const merged: AgentConfig = {
76
+ ...fileConfig,
77
+ ...(config?.ledgerUrl && { ledgerUrl: config.ledgerUrl }),
78
+ ...(config?.partyId && { partyId: config.partyId }),
79
+ ...(config?.userId && { userId: config.userId }),
80
+ ...(config?.network && { network: config.network }),
81
+ };
82
+
83
+ const token = config?.token ?? "";
84
+
85
+ // 2. Create low-level client
86
+ const client = new CantonClient({
87
+ ledgerUrl: merged.ledgerUrl,
88
+ token,
89
+ userId: merged.userId,
90
+ });
91
+
92
+ // 3. Create services
93
+ const usdcx = new USDCxService(client, merged.partyId);
94
+ const safeguards = await SafeguardManager.load(config?.safeguardsPath);
95
+ const traffic = new TrafficManager(client, merged.partyId);
96
+ const checking = new CheckingAccount(usdcx, safeguards, client, merged.partyId);
97
+ const mpp = new MppPayClient(usdcx, safeguards, merged.partyId, merged.network);
98
+
99
+ const wallet: WalletInfo = {
100
+ address: merged.partyId,
101
+ partyId: merged.partyId,
102
+ network: merged.network,
103
+ };
104
+
105
+ return new CantonAgent(client, usdcx, checking, safeguards, traffic, mpp, wallet);
106
+ }
107
+
108
+ /**
109
+ * Create a CantonAgent from explicit parameters (no file I/O).
110
+ * Useful for testing and programmatic setup.
111
+ */
112
+ static fromParams(params: {
113
+ client: CantonClient;
114
+ partyId: string;
115
+ network: string;
116
+ safeguards?: SafeguardManager;
117
+ }): CantonAgent {
118
+ const safeguards = params.safeguards ?? new SafeguardManager();
119
+ const usdcx = new USDCxService(params.client, params.partyId);
120
+ const traffic = new TrafficManager(params.client, params.partyId);
121
+ const checking = new CheckingAccount(usdcx, safeguards, params.client, params.partyId);
122
+ const mpp = new MppPayClient(usdcx, safeguards, params.partyId, params.network);
123
+
124
+ const wallet: WalletInfo = {
125
+ address: params.partyId,
126
+ partyId: params.partyId,
127
+ network: params.network,
128
+ };
129
+
130
+ return new CantonAgent(params.client, usdcx, checking, safeguards, traffic, mpp, wallet);
131
+ }
132
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * String-based decimal arithmetic for Canton amounts.
3
+ *
4
+ * Canton uses Numeric 10 (up to 10 decimal places). USDCx has 6 meaningful decimals.
5
+ * ALL arithmetic is pure string manipulation — NO floating point.
6
+ */
7
+
8
+ const AMOUNT_RE = /^\d+(\.\d+)?$/;
9
+
10
+ /**
11
+ * Validate that a string is a valid non-negative decimal amount.
12
+ */
13
+ export function isValidAmount(s: string): boolean {
14
+ return AMOUNT_RE.test(s);
15
+ }
16
+
17
+ /**
18
+ * Parse an amount string into integer and fractional digit arrays.
19
+ * Returns [intDigits, fracDigits] where each is a number[].
20
+ */
21
+ function parse(s: string): [number[], number[]] {
22
+ const dot = s.indexOf(".");
23
+ const intPart = dot === -1 ? s : s.slice(0, dot);
24
+ const fracPart = dot === -1 ? "" : s.slice(dot + 1);
25
+ return [
26
+ intPart.split("").map(Number),
27
+ fracPart.split("").map(Number),
28
+ ];
29
+ }
30
+
31
+ /**
32
+ * Normalise two amount strings so their fractional parts have equal length.
33
+ * Returns [aParts, bParts, fracLen].
34
+ */
35
+ function align(a: string, b: string): [number[], number[], number[], number[], number] {
36
+ const [aInt, aFrac] = parse(a);
37
+ const [bInt, bFrac] = parse(b);
38
+ const fracLen = Math.max(aFrac.length, bFrac.length);
39
+ while (aFrac.length < fracLen) aFrac.push(0);
40
+ while (bFrac.length < fracLen) bFrac.push(0);
41
+ // Also align integer parts to equal length by prepending zeros
42
+ const intLen = Math.max(aInt.length, bInt.length);
43
+ while (aInt.length < intLen) aInt.unshift(0);
44
+ while (bInt.length < intLen) bInt.unshift(0);
45
+ return [aInt, aFrac, bInt, bFrac, fracLen];
46
+ }
47
+
48
+ /**
49
+ * Strip trailing zeros from fractional part and leading zeros from integer part.
50
+ */
51
+ function formatResult(intDigits: number[], fracDigits: number[]): string {
52
+ // Remove trailing zeros from fraction
53
+ while (fracDigits.length > 0 && fracDigits[fracDigits.length - 1] === 0) {
54
+ fracDigits.pop();
55
+ }
56
+ // Remove leading zeros from integer (keep at least one)
57
+ while (intDigits.length > 1 && intDigits[0] === 0) {
58
+ intDigits.shift();
59
+ }
60
+ const intStr = intDigits.join("");
61
+ return fracDigits.length > 0 ? `${intStr}.${fracDigits.join("")}` : intStr;
62
+ }
63
+
64
+ /**
65
+ * Compare two decimal amount strings.
66
+ * Returns -1 if a < b, 0 if a === b, 1 if a > b.
67
+ */
68
+ export function compareAmounts(a: string, b: string): -1 | 0 | 1 {
69
+ const [aInt, aFrac, bInt, bFrac] = align(a, b);
70
+
71
+ // Compare integer parts digit by digit
72
+ for (let i = 0; i < aInt.length; i++) {
73
+ if (aInt[i] < bInt[i]) return -1;
74
+ if (aInt[i] > bInt[i]) return 1;
75
+ }
76
+
77
+ // Compare fractional parts digit by digit
78
+ for (let i = 0; i < aFrac.length; i++) {
79
+ if (aFrac[i] < bFrac[i]) return -1;
80
+ if (aFrac[i] > bFrac[i]) return 1;
81
+ }
82
+
83
+ return 0;
84
+ }
85
+
86
+ /**
87
+ * Add two decimal amount strings. Returns the sum as a string.
88
+ */
89
+ export function addAmounts(a: string, b: string): string {
90
+ const [aInt, aFrac, bInt, bFrac, fracLen] = align(a, b);
91
+
92
+ // Combine into single digit arrays (integer + fractional)
93
+ const aAll = [...aInt, ...aFrac];
94
+ const bAll = [...bInt, ...bFrac];
95
+
96
+ const result: number[] = new Array(aAll.length).fill(0);
97
+ let carry = 0;
98
+
99
+ for (let i = aAll.length - 1; i >= 0; i--) {
100
+ const sum = aAll[i] + bAll[i] + carry;
101
+ result[i] = sum % 10;
102
+ carry = Math.floor(sum / 10);
103
+ }
104
+
105
+ if (carry > 0) {
106
+ result.unshift(carry);
107
+ }
108
+
109
+ // Split back into integer and fractional parts
110
+ const intDigits = result.slice(0, result.length - fracLen);
111
+ const fracDigits = result.slice(result.length - fracLen);
112
+
113
+ return formatResult(
114
+ intDigits.length === 0 ? [0] : intDigits,
115
+ fracDigits,
116
+ );
117
+ }
118
+
119
+ /**
120
+ * Subtract b from a. Both must be valid amounts and a >= b.
121
+ * Throws if a < b.
122
+ */
123
+ export function subtractAmounts(a: string, b: string): string {
124
+ if (compareAmounts(a, b) < 0) {
125
+ throw new Error(`Cannot subtract: ${a} < ${b}`);
126
+ }
127
+
128
+ const [aInt, aFrac, bInt, bFrac, fracLen] = align(a, b);
129
+
130
+ const aAll = [...aInt, ...aFrac];
131
+ const bAll = [...bInt, ...bFrac];
132
+
133
+ const result: number[] = new Array(aAll.length).fill(0);
134
+ let borrow = 0;
135
+
136
+ for (let i = aAll.length - 1; i >= 0; i--) {
137
+ let diff = aAll[i] - bAll[i] - borrow;
138
+ if (diff < 0) {
139
+ diff += 10;
140
+ borrow = 1;
141
+ } else {
142
+ borrow = 0;
143
+ }
144
+ result[i] = diff;
145
+ }
146
+
147
+ const intDigits = result.slice(0, result.length - fracLen);
148
+ const fracDigits = result.slice(result.length - fracLen);
149
+
150
+ return formatResult(
151
+ intDigits.length === 0 ? [0] : intDigits,
152
+ fracDigits,
153
+ );
154
+ }
155
+
156
+ /**
157
+ * Pad (or truncate) a decimal string to exactly N decimal places.
158
+ * Default: 10 (Canton Numeric 10).
159
+ */
160
+ export function toCantonAmount(s: string, decimals = 10): string {
161
+ const dot = s.indexOf(".");
162
+ const intPart = dot === -1 ? s : s.slice(0, dot);
163
+ const fracPart = dot === -1 ? "" : s.slice(dot + 1);
164
+
165
+ const padded = fracPart.padEnd(decimals, "0").slice(0, decimals);
166
+ return `${intPart}.${padded}`;
167
+ }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Canton JSON Ledger API v2 — Client
3
+ *
4
+ * All requests target config.ledgerUrl + path.
5
+ * All requests include Authorization: Bearer <token>.
6
+ * Errors are parsed into typed CantonApiError / CantonAuthError / CantonTimeoutError.
7
+ */
8
+
9
+ import { CantonApiError, CantonAuthError, CantonTimeoutError } from "./errors.js";
10
+ import type {
11
+ ActiveContract,
12
+ CantonClientConfig,
13
+ CreatedEvent,
14
+ LedgerError,
15
+ PartyDetails,
16
+ QueryActiveContractsParams,
17
+ SubmitAndWaitResponse,
18
+ SubmitParams,
19
+ TransactionResponse,
20
+ TransactionTree,
21
+ } from "./types.js";
22
+
23
+ const DEFAULT_TIMEOUT = 30_000;
24
+
25
+ export class CantonClient {
26
+ private readonly ledgerUrl: string;
27
+ private readonly token: string;
28
+ private readonly userId: string;
29
+ private readonly timeout: number;
30
+
31
+ constructor(config: CantonClientConfig) {
32
+ this.ledgerUrl = config.ledgerUrl.replace(/\/+$/, "");
33
+ this.token = config.token;
34
+ this.userId = config.userId;
35
+ this.timeout = config.timeout ?? DEFAULT_TIMEOUT;
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Command Submission
40
+ // ---------------------------------------------------------------------------
41
+
42
+ async submitAndWait(params: SubmitParams): Promise<SubmitAndWaitResponse> {
43
+ return this.request<SubmitAndWaitResponse>("POST", "/v2/commands/submit-and-wait", {
44
+ commands: params.commands,
45
+ userId: this.userId,
46
+ commandId: params.commandId,
47
+ actAs: params.actAs,
48
+ readAs: params.readAs,
49
+ });
50
+ }
51
+
52
+ async submitAndWaitForTransaction(params: SubmitParams): Promise<TransactionResponse> {
53
+ return this.request<TransactionResponse>(
54
+ "POST",
55
+ "/v2/commands/submit-and-wait-for-transaction",
56
+ {
57
+ commands: params.commands,
58
+ userId: this.userId,
59
+ commandId: params.commandId,
60
+ actAs: params.actAs,
61
+ readAs: params.readAs,
62
+ },
63
+ );
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Active Contract Queries
68
+ // ---------------------------------------------------------------------------
69
+
70
+ async queryActiveContracts(params: QueryActiveContractsParams): Promise<ActiveContract[]> {
71
+ const body = {
72
+ eventFormat: {
73
+ filtersByParty: params.filtersByParty,
74
+ filtersForAnyParty: params.filtersForAnyParty,
75
+ verbose: true,
76
+ },
77
+ activeAtOffset: params.activeAtOffset,
78
+ };
79
+
80
+ const response = await this.request<{
81
+ contractEntry?: Array<{ createdEvent?: CreatedEvent }>;
82
+ }>("POST", "/v2/state/active-contracts", body);
83
+
84
+ if (!response.contractEntry) {
85
+ return [];
86
+ }
87
+
88
+ return response.contractEntry
89
+ .filter((entry) => entry.createdEvent != null)
90
+ .map((entry) => {
91
+ const evt = entry.createdEvent!;
92
+ return {
93
+ contractId: evt.contractId,
94
+ templateId: evt.templateId,
95
+ createArgument: evt.createArgument,
96
+ createdAt: "",
97
+ signatories: evt.signatories,
98
+ observers: evt.observers,
99
+ };
100
+ });
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Transaction Lookup
105
+ // ---------------------------------------------------------------------------
106
+
107
+ async getTransactionById(updateId: string): Promise<TransactionTree | null> {
108
+ try {
109
+ return await this.request<TransactionTree>(
110
+ "GET",
111
+ `/v2/updates/transaction-by-id/${encodeURIComponent(updateId)}`,
112
+ );
113
+ } catch (err) {
114
+ if (err instanceof CantonApiError && err.code === "NOT_FOUND") {
115
+ return null;
116
+ }
117
+ throw err;
118
+ }
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Ledger State
123
+ // ---------------------------------------------------------------------------
124
+
125
+ async getLedgerEnd(): Promise<number> {
126
+ const response = await this.request<{ offset: number }>("GET", "/v2/state/ledger-end");
127
+ return response.offset;
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Party Management
132
+ // ---------------------------------------------------------------------------
133
+
134
+ async allocateParty(hint: string): Promise<PartyDetails> {
135
+ const response = await this.request<{ partyDetails: PartyDetails }>("POST", "/v2/parties", {
136
+ partyIdHint: hint,
137
+ identityProviderId: "",
138
+ });
139
+ return response.partyDetails;
140
+ }
141
+
142
+ async listParties(): Promise<PartyDetails[]> {
143
+ const response = await this.request<{ partyDetails: PartyDetails[] }>("GET", "/v2/parties");
144
+ return response.partyDetails;
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Health Check
149
+ // ---------------------------------------------------------------------------
150
+
151
+ async isHealthy(): Promise<boolean> {
152
+ try {
153
+ const response = await fetch(`${this.ledgerUrl}/livez`, {
154
+ method: "GET",
155
+ headers: { Authorization: `Bearer ${this.token}` },
156
+ signal: AbortSignal.timeout(this.timeout),
157
+ });
158
+ return response.ok;
159
+ } catch {
160
+ return false;
161
+ }
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Internal
166
+ // ---------------------------------------------------------------------------
167
+
168
+ private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
169
+ const url = `${this.ledgerUrl}${path}`;
170
+
171
+ const headers: Record<string, string> = {
172
+ Authorization: `Bearer ${this.token}`,
173
+ };
174
+
175
+ if (body !== undefined) {
176
+ headers["Content-Type"] = "application/json";
177
+ }
178
+
179
+ let response: Response;
180
+ try {
181
+ response = await fetch(url, {
182
+ method,
183
+ headers,
184
+ body: body !== undefined ? JSON.stringify(body) : undefined,
185
+ signal: AbortSignal.timeout(this.timeout),
186
+ });
187
+ } catch (err: unknown) {
188
+ if (err instanceof DOMException && err.name === "TimeoutError") {
189
+ throw new CantonTimeoutError(path, this.timeout);
190
+ }
191
+ if (err instanceof DOMException && err.name === "AbortError") {
192
+ throw new CantonTimeoutError(path, this.timeout);
193
+ }
194
+ throw err;
195
+ }
196
+
197
+ if (response.status === 401 || response.status === 403) {
198
+ const text = await response.text().catch(() => "");
199
+ throw new CantonAuthError(response.status, text || undefined);
200
+ }
201
+
202
+ if (!response.ok) {
203
+ const errorBody = await response.json().catch(() => null);
204
+ if (errorBody && typeof errorBody === "object" && "code" in errorBody) {
205
+ throw new CantonApiError(errorBody as LedgerError);
206
+ }
207
+ throw new Error(`Canton API error: HTTP ${response.status} on ${method} ${path}`);
208
+ }
209
+
210
+ // Some endpoints (like livez) may return empty body
211
+ const text = await response.text();
212
+ if (!text) {
213
+ return {} as T;
214
+ }
215
+
216
+ return JSON.parse(text) as T;
217
+ }
218
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Canton SDK — Custom Error Classes
3
+ */
4
+
5
+ import type { CantonErrorCode, LedgerError } from "./types.js";
6
+
7
+ export class CantonApiError extends Error {
8
+ readonly code: CantonErrorCode;
9
+ readonly ledgerCause: string;
10
+ readonly grpcCodeValue: number;
11
+ readonly errorCategory: number;
12
+ readonly context: Record<string, string>;
13
+
14
+ constructor(ledgerError: LedgerError) {
15
+ super(`Canton API error [${ledgerError.code}]: ${ledgerError.cause}`);
16
+ this.name = "CantonApiError";
17
+ this.code = ledgerError.code;
18
+ this.ledgerCause = ledgerError.cause;
19
+ this.grpcCodeValue = ledgerError.grpcCodeValue;
20
+ this.errorCategory = ledgerError.errorCategory;
21
+ this.context = ledgerError.context;
22
+ }
23
+ }
24
+
25
+ export class CantonTimeoutError extends Error {
26
+ readonly timeoutMs: number;
27
+ readonly path: string;
28
+
29
+ constructor(path: string, timeoutMs: number) {
30
+ super(`Canton request to ${path} timed out after ${timeoutMs}ms`);
31
+ this.name = "CantonTimeoutError";
32
+ this.timeoutMs = timeoutMs;
33
+ this.path = path;
34
+ }
35
+ }
36
+
37
+ export class CantonAuthError extends Error {
38
+ readonly statusCode: number;
39
+
40
+ constructor(statusCode: number, message?: string) {
41
+ super(message ?? `Canton authentication failed (HTTP ${statusCode})`);
42
+ this.name = "CantonAuthError";
43
+ this.statusCode = statusCode;
44
+ }
45
+ }