@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.
- package/.turbo/turbo-build.log +26 -0
- package/.turbo/turbo-test.log +23 -0
- package/README.md +120 -0
- package/SPEC.md +223 -0
- package/dist/amount-L2SDLRZT.js +15 -0
- package/dist/amount-L2SDLRZT.js.map +1 -0
- package/dist/chunk-GSDB5FKZ.js +110 -0
- package/dist/chunk-GSDB5FKZ.js.map +1 -0
- package/dist/index.cjs +1158 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +673 -0
- package/dist/index.d.ts +673 -0
- package/dist/index.js +986 -0
- package/dist/index.js.map +1 -0
- package/package.json +50 -0
- package/src/__tests__/agent.test.ts +217 -0
- package/src/__tests__/amount.test.ts +202 -0
- package/src/__tests__/client.test.ts +516 -0
- package/src/__tests__/e2e/canton-client.e2e.test.ts +190 -0
- package/src/__tests__/e2e/mpp-flow.e2e.test.ts +346 -0
- package/src/__tests__/e2e/setup.ts +112 -0
- package/src/__tests__/e2e/usdcx.e2e.test.ts +114 -0
- package/src/__tests__/keystore.test.ts +197 -0
- package/src/__tests__/pay-client.test.ts +257 -0
- package/src/__tests__/safeguards.test.ts +333 -0
- package/src/__tests__/usdcx.test.ts +374 -0
- package/src/accounts/checking.ts +118 -0
- package/src/agent.ts +132 -0
- package/src/canton/amount.ts +167 -0
- package/src/canton/client.ts +218 -0
- package/src/canton/errors.ts +45 -0
- package/src/canton/holdings.ts +90 -0
- package/src/canton/index.ts +51 -0
- package/src/canton/types.ts +214 -0
- package/src/canton/usdcx.ts +166 -0
- package/src/index.ts +97 -0
- package/src/mpp/pay-client.ts +170 -0
- package/src/safeguards/manager.ts +183 -0
- package/src/traffic/manager.ts +95 -0
- package/src/wallet/config.ts +88 -0
- package/src/wallet/keystore.ts +164 -0
- package/tsconfig.json +8 -0
- package/tsup.config.ts +9 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { CantonClient } from "../canton/client.js";
|
|
3
|
+
import { CantonApiError, CantonAuthError, CantonTimeoutError } from "../canton/errors.js";
|
|
4
|
+
import type { Command, LedgerError } from "../canton/types.js";
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Helpers
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
const BASE_URL = "http://localhost:7575";
|
|
11
|
+
const TOKEN = "test-jwt-token";
|
|
12
|
+
const USER_ID = "ledger-api-user";
|
|
13
|
+
const PARTY = "Alice::122084768362d0ce21f1ffec870e55e365a292cdf8f54c5c38ad7775b9bdd462e141";
|
|
14
|
+
|
|
15
|
+
function makeClient(overrides?: { timeout?: number }) {
|
|
16
|
+
return new CantonClient({
|
|
17
|
+
ledgerUrl: BASE_URL,
|
|
18
|
+
token: TOKEN,
|
|
19
|
+
userId: USER_ID,
|
|
20
|
+
timeout: overrides?.timeout,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
25
|
+
return new Response(JSON.stringify(body), {
|
|
26
|
+
status,
|
|
27
|
+
headers: { "Content-Type": "application/json" },
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function errorResponse(error: LedgerError, status = 400): Response {
|
|
32
|
+
return new Response(JSON.stringify(error), {
|
|
33
|
+
status,
|
|
34
|
+
headers: { "Content-Type": "application/json" },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const mockFetch = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>();
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
vi.restoreAllMocks();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Auth header inclusion
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
describe("auth header", () => {
|
|
53
|
+
it("includes Bearer token on every request", async () => {
|
|
54
|
+
const client = makeClient();
|
|
55
|
+
mockFetch.mockResolvedValueOnce(jsonResponse({ offset: 42 }));
|
|
56
|
+
|
|
57
|
+
await client.getLedgerEnd();
|
|
58
|
+
|
|
59
|
+
expect(mockFetch).toHaveBeenCalledOnce();
|
|
60
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
61
|
+
expect((init?.headers as Record<string, string>)["Authorization"]).toBe(`Bearer ${TOKEN}`);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// submitAndWait
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
describe("submitAndWait", () => {
|
|
70
|
+
const commands: Command[] = [
|
|
71
|
+
{
|
|
72
|
+
CreateCommand: {
|
|
73
|
+
templateId: "#json-tests:Main:Asset",
|
|
74
|
+
createArguments: { issuer: PARTY, owner: PARTY, name: "Asset" },
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
it("sends correct request and returns updateId + completionOffset", async () => {
|
|
80
|
+
const client = makeClient();
|
|
81
|
+
mockFetch.mockResolvedValueOnce(
|
|
82
|
+
jsonResponse({ updateId: "update-123", completionOffset: 20 }),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const result = await client.submitAndWait({
|
|
86
|
+
commands,
|
|
87
|
+
commandId: "cmd-1",
|
|
88
|
+
actAs: [PARTY],
|
|
89
|
+
readAs: [PARTY],
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(result).toEqual({ updateId: "update-123", completionOffset: 20 });
|
|
93
|
+
|
|
94
|
+
const [url, init] = mockFetch.mock.calls[0];
|
|
95
|
+
expect(url).toBe(`${BASE_URL}/v2/commands/submit-and-wait`);
|
|
96
|
+
expect(init?.method).toBe("POST");
|
|
97
|
+
expect((init?.headers as Record<string, string>)["Content-Type"]).toBe("application/json");
|
|
98
|
+
|
|
99
|
+
const body = JSON.parse(init?.body as string);
|
|
100
|
+
expect(body.userId).toBe(USER_ID);
|
|
101
|
+
expect(body.commandId).toBe("cmd-1");
|
|
102
|
+
expect(body.actAs).toEqual([PARTY]);
|
|
103
|
+
expect(body.readAs).toEqual([PARTY]);
|
|
104
|
+
expect(body.commands).toEqual(commands);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("throws CantonApiError on ledger error response", async () => {
|
|
108
|
+
const client = makeClient();
|
|
109
|
+
const ledgerError: LedgerError = {
|
|
110
|
+
cause: "Invalid template ID",
|
|
111
|
+
code: "INVALID_ARGUMENT",
|
|
112
|
+
context: { category: "8", participant: "participant1" },
|
|
113
|
+
errorCategory: 8,
|
|
114
|
+
grpcCodeValue: 3,
|
|
115
|
+
};
|
|
116
|
+
mockFetch.mockResolvedValueOnce(errorResponse(ledgerError));
|
|
117
|
+
|
|
118
|
+
await expect(
|
|
119
|
+
client.submitAndWait({ commands, commandId: "cmd-2", actAs: [PARTY] }),
|
|
120
|
+
).rejects.toThrow(CantonApiError);
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
await client.submitAndWait({ commands, commandId: "cmd-2", actAs: [PARTY] });
|
|
124
|
+
} catch (err) {
|
|
125
|
+
// fetch was only mocked once, so we test the first rejection
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// submitAndWaitForTransaction
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
describe("submitAndWaitForTransaction", () => {
|
|
135
|
+
it("sends to correct endpoint and returns transaction", async () => {
|
|
136
|
+
const client = makeClient();
|
|
137
|
+
const txResponse = {
|
|
138
|
+
transaction: {
|
|
139
|
+
updateId: "tx-456",
|
|
140
|
+
commandId: "cmd-3",
|
|
141
|
+
effectiveAt: "2025-01-01T00:00:00Z",
|
|
142
|
+
offset: 30,
|
|
143
|
+
events: [{ createdEvent: { contractId: "cid-1", templateId: "pkg:M:T", createArgument: {} } }],
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
mockFetch.mockResolvedValueOnce(jsonResponse(txResponse));
|
|
147
|
+
|
|
148
|
+
const result = await client.submitAndWaitForTransaction({
|
|
149
|
+
commands: [
|
|
150
|
+
{
|
|
151
|
+
ExerciseCommand: {
|
|
152
|
+
templateId: "pkg:Module:Template",
|
|
153
|
+
contractId: "00572c50",
|
|
154
|
+
choice: "ChoiceName",
|
|
155
|
+
choiceArgument: { field: "value" },
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
commandId: "cmd-3",
|
|
160
|
+
actAs: [PARTY],
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
expect(result.transaction.updateId).toBe("tx-456");
|
|
164
|
+
|
|
165
|
+
const [url] = mockFetch.mock.calls[0];
|
|
166
|
+
expect(url).toBe(`${BASE_URL}/v2/commands/submit-and-wait-for-transaction`);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// queryActiveContracts
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
describe("queryActiveContracts", () => {
|
|
175
|
+
it("returns parsed active contracts", async () => {
|
|
176
|
+
const client = makeClient();
|
|
177
|
+
mockFetch.mockResolvedValueOnce(
|
|
178
|
+
jsonResponse({
|
|
179
|
+
contractEntry: [
|
|
180
|
+
{
|
|
181
|
+
createdEvent: {
|
|
182
|
+
contractId: "cid-100",
|
|
183
|
+
templateId: "pkg:Token:Holding",
|
|
184
|
+
createArgument: { amount: "5.000000" },
|
|
185
|
+
witnessParties: [PARTY],
|
|
186
|
+
signatories: [PARTY],
|
|
187
|
+
observers: [],
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
createdEvent: {
|
|
192
|
+
contractId: "cid-101",
|
|
193
|
+
templateId: "pkg:Token:Holding",
|
|
194
|
+
createArgument: { amount: "3.000000" },
|
|
195
|
+
witnessParties: [PARTY],
|
|
196
|
+
signatories: [PARTY],
|
|
197
|
+
observers: [],
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const contracts = await client.queryActiveContracts({
|
|
205
|
+
filtersByParty: {
|
|
206
|
+
[PARTY]: {
|
|
207
|
+
cumulative: [
|
|
208
|
+
{
|
|
209
|
+
identifierFilter: {
|
|
210
|
+
TemplateFilter: { value: { templateId: "pkg:Token:Holding" } },
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
],
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
activeAtOffset: 20,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
expect(contracts).toHaveLength(2);
|
|
220
|
+
expect(contracts[0].contractId).toBe("cid-100");
|
|
221
|
+
expect(contracts[1].createArgument).toEqual({ amount: "3.000000" });
|
|
222
|
+
|
|
223
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1]?.body as string);
|
|
224
|
+
expect(body.activeAtOffset).toBe(20);
|
|
225
|
+
expect(body.eventFormat.verbose).toBe(true);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("returns empty array when no contractEntry", async () => {
|
|
229
|
+
const client = makeClient();
|
|
230
|
+
mockFetch.mockResolvedValueOnce(jsonResponse({}));
|
|
231
|
+
|
|
232
|
+
const contracts = await client.queryActiveContracts({ activeAtOffset: 0 });
|
|
233
|
+
expect(contracts).toEqual([]);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// getTransactionById
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
describe("getTransactionById", () => {
|
|
242
|
+
it("returns transaction tree on success", async () => {
|
|
243
|
+
const client = makeClient();
|
|
244
|
+
const tree = {
|
|
245
|
+
updateId: "upd-789",
|
|
246
|
+
commandId: "cmd-4",
|
|
247
|
+
effectiveAt: "2025-06-01T12:00:00Z",
|
|
248
|
+
offset: 50,
|
|
249
|
+
eventsById: {},
|
|
250
|
+
rootEventIds: ["evt-1"],
|
|
251
|
+
};
|
|
252
|
+
mockFetch.mockResolvedValueOnce(jsonResponse(tree));
|
|
253
|
+
|
|
254
|
+
const result = await client.getTransactionById("upd-789");
|
|
255
|
+
|
|
256
|
+
expect(result).toEqual(tree);
|
|
257
|
+
const [url] = mockFetch.mock.calls[0];
|
|
258
|
+
expect(url).toBe(`${BASE_URL}/v2/updates/transaction-by-id/upd-789`);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("returns null when NOT_FOUND", async () => {
|
|
262
|
+
const client = makeClient();
|
|
263
|
+
mockFetch.mockResolvedValueOnce(
|
|
264
|
+
errorResponse(
|
|
265
|
+
{
|
|
266
|
+
cause: "Transaction not found",
|
|
267
|
+
code: "NOT_FOUND",
|
|
268
|
+
context: {},
|
|
269
|
+
errorCategory: 5,
|
|
270
|
+
grpcCodeValue: 5,
|
|
271
|
+
},
|
|
272
|
+
404,
|
|
273
|
+
),
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const result = await client.getTransactionById("nonexistent");
|
|
277
|
+
expect(result).toBeNull();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("URL-encodes the updateId", async () => {
|
|
281
|
+
const client = makeClient();
|
|
282
|
+
mockFetch.mockResolvedValueOnce(
|
|
283
|
+
jsonResponse({ updateId: "id/with/slashes", commandId: "", effectiveAt: "", offset: 0, eventsById: {}, rootEventIds: [] }),
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
await client.getTransactionById("id/with/slashes");
|
|
287
|
+
|
|
288
|
+
const [url] = mockFetch.mock.calls[0];
|
|
289
|
+
expect(url).toBe(`${BASE_URL}/v2/updates/transaction-by-id/id%2Fwith%2Fslashes`);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
// getLedgerEnd
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
describe("getLedgerEnd", () => {
|
|
298
|
+
it("returns the offset number", async () => {
|
|
299
|
+
const client = makeClient();
|
|
300
|
+
mockFetch.mockResolvedValueOnce(jsonResponse({ offset: 42 }));
|
|
301
|
+
|
|
302
|
+
const offset = await client.getLedgerEnd();
|
|
303
|
+
expect(offset).toBe(42);
|
|
304
|
+
|
|
305
|
+
const [url, init] = mockFetch.mock.calls[0];
|
|
306
|
+
expect(url).toBe(`${BASE_URL}/v2/state/ledger-end`);
|
|
307
|
+
expect(init?.method).toBe("GET");
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
// allocateParty
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
describe("allocateParty", () => {
|
|
316
|
+
it("sends partyIdHint and returns PartyDetails", async () => {
|
|
317
|
+
const client = makeClient();
|
|
318
|
+
const partyDetails = {
|
|
319
|
+
party: PARTY,
|
|
320
|
+
isLocal: true,
|
|
321
|
+
localMetadata: { resourceVersion: "0", annotations: {} },
|
|
322
|
+
identityProviderId: "",
|
|
323
|
+
};
|
|
324
|
+
mockFetch.mockResolvedValueOnce(jsonResponse({ partyDetails }));
|
|
325
|
+
|
|
326
|
+
const result = await client.allocateParty("Alice");
|
|
327
|
+
|
|
328
|
+
expect(result).toEqual(partyDetails);
|
|
329
|
+
|
|
330
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1]?.body as string);
|
|
331
|
+
expect(body.partyIdHint).toBe("Alice");
|
|
332
|
+
expect(body.identityProviderId).toBe("");
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
// listParties
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
describe("listParties", () => {
|
|
341
|
+
it("returns array of PartyDetails", async () => {
|
|
342
|
+
const client = makeClient();
|
|
343
|
+
const parties = [
|
|
344
|
+
{
|
|
345
|
+
party: PARTY,
|
|
346
|
+
isLocal: true,
|
|
347
|
+
localMetadata: { resourceVersion: "0", annotations: {} },
|
|
348
|
+
identityProviderId: "",
|
|
349
|
+
},
|
|
350
|
+
];
|
|
351
|
+
mockFetch.mockResolvedValueOnce(jsonResponse({ partyDetails: parties }));
|
|
352
|
+
|
|
353
|
+
const result = await client.listParties();
|
|
354
|
+
expect(result).toHaveLength(1);
|
|
355
|
+
expect(result[0].party).toBe(PARTY);
|
|
356
|
+
|
|
357
|
+
const [url, init] = mockFetch.mock.calls[0];
|
|
358
|
+
expect(url).toBe(`${BASE_URL}/v2/parties`);
|
|
359
|
+
expect(init?.method).toBe("GET");
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
// isHealthy
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
describe("isHealthy", () => {
|
|
368
|
+
it("returns true when /livez responds 200", async () => {
|
|
369
|
+
const client = makeClient();
|
|
370
|
+
mockFetch.mockResolvedValueOnce(new Response("ok", { status: 200 }));
|
|
371
|
+
|
|
372
|
+
expect(await client.isHealthy()).toBe(true);
|
|
373
|
+
|
|
374
|
+
const [url] = mockFetch.mock.calls[0];
|
|
375
|
+
expect(url).toBe(`${BASE_URL}/livez`);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("returns false when /livez responds non-200", async () => {
|
|
379
|
+
const client = makeClient();
|
|
380
|
+
mockFetch.mockResolvedValueOnce(new Response("", { status: 503 }));
|
|
381
|
+
|
|
382
|
+
expect(await client.isHealthy()).toBe(false);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("returns false when fetch throws (network error)", async () => {
|
|
386
|
+
const client = makeClient();
|
|
387
|
+
mockFetch.mockRejectedValueOnce(new Error("ECONNREFUSED"));
|
|
388
|
+
|
|
389
|
+
expect(await client.isHealthy()).toBe(false);
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
// Error handling
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
|
|
397
|
+
describe("error handling", () => {
|
|
398
|
+
it("throws CantonAuthError on 401", async () => {
|
|
399
|
+
const client = makeClient();
|
|
400
|
+
mockFetch.mockResolvedValueOnce(new Response("Unauthorized", { status: 401 }));
|
|
401
|
+
|
|
402
|
+
await expect(client.getLedgerEnd()).rejects.toThrow(CantonAuthError);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("throws CantonAuthError on 403", async () => {
|
|
406
|
+
const client = makeClient();
|
|
407
|
+
mockFetch.mockResolvedValueOnce(new Response("Forbidden", { status: 403 }));
|
|
408
|
+
|
|
409
|
+
await expect(client.getLedgerEnd()).rejects.toThrow(CantonAuthError);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("CantonAuthError preserves status code", async () => {
|
|
413
|
+
const client = makeClient();
|
|
414
|
+
mockFetch.mockResolvedValueOnce(new Response("Forbidden", { status: 403 }));
|
|
415
|
+
|
|
416
|
+
try {
|
|
417
|
+
await client.getLedgerEnd();
|
|
418
|
+
} catch (err) {
|
|
419
|
+
expect(err).toBeInstanceOf(CantonAuthError);
|
|
420
|
+
expect((err as CantonAuthError).statusCode).toBe(403);
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("throws CantonApiError with full details on structured error", async () => {
|
|
425
|
+
const client = makeClient();
|
|
426
|
+
const ledgerError: LedgerError = {
|
|
427
|
+
cause: "Contract already exists",
|
|
428
|
+
code: "ALREADY_EXISTS",
|
|
429
|
+
context: { category: "6" },
|
|
430
|
+
errorCategory: 6,
|
|
431
|
+
grpcCodeValue: 6,
|
|
432
|
+
};
|
|
433
|
+
mockFetch.mockResolvedValueOnce(errorResponse(ledgerError, 409));
|
|
434
|
+
|
|
435
|
+
try {
|
|
436
|
+
await client.getLedgerEnd();
|
|
437
|
+
expect.fail("should have thrown");
|
|
438
|
+
} catch (err) {
|
|
439
|
+
expect(err).toBeInstanceOf(CantonApiError);
|
|
440
|
+
const apiErr = err as CantonApiError;
|
|
441
|
+
expect(apiErr.code).toBe("ALREADY_EXISTS");
|
|
442
|
+
expect(apiErr.ledgerCause).toBe("Contract already exists");
|
|
443
|
+
expect(apiErr.grpcCodeValue).toBe(6);
|
|
444
|
+
expect(apiErr.errorCategory).toBe(6);
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("throws generic Error on non-structured error response", async () => {
|
|
449
|
+
const client = makeClient();
|
|
450
|
+
mockFetch.mockResolvedValueOnce(new Response("Internal Server Error", { status: 500 }));
|
|
451
|
+
|
|
452
|
+
await expect(client.getLedgerEnd()).rejects.toThrow("Canton API error: HTTP 500");
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// ---------------------------------------------------------------------------
|
|
457
|
+
// Timeout handling
|
|
458
|
+
// ---------------------------------------------------------------------------
|
|
459
|
+
|
|
460
|
+
describe("timeout handling", () => {
|
|
461
|
+
it("throws CantonTimeoutError when request times out", async () => {
|
|
462
|
+
const client = makeClient({ timeout: 100 });
|
|
463
|
+
mockFetch.mockImplementationOnce(() => {
|
|
464
|
+
const err = new DOMException("The operation was aborted.", "TimeoutError");
|
|
465
|
+
return Promise.reject(err);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
await expect(client.getLedgerEnd()).rejects.toThrow(CantonTimeoutError);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it("CantonTimeoutError includes path and timeout value", async () => {
|
|
472
|
+
const client = makeClient({ timeout: 5000 });
|
|
473
|
+
mockFetch.mockImplementationOnce(() => {
|
|
474
|
+
return Promise.reject(new DOMException("Aborted", "TimeoutError"));
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
try {
|
|
478
|
+
await client.getLedgerEnd();
|
|
479
|
+
expect.fail("should have thrown");
|
|
480
|
+
} catch (err) {
|
|
481
|
+
expect(err).toBeInstanceOf(CantonTimeoutError);
|
|
482
|
+
const timeoutErr = err as CantonTimeoutError;
|
|
483
|
+
expect(timeoutErr.path).toBe("/v2/state/ledger-end");
|
|
484
|
+
expect(timeoutErr.timeoutMs).toBe(5000);
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("treats AbortError as timeout", async () => {
|
|
489
|
+
const client = makeClient({ timeout: 100 });
|
|
490
|
+
mockFetch.mockImplementationOnce(() => {
|
|
491
|
+
return Promise.reject(new DOMException("Aborted", "AbortError"));
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
await expect(client.getLedgerEnd()).rejects.toThrow(CantonTimeoutError);
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
// ---------------------------------------------------------------------------
|
|
499
|
+
// URL construction
|
|
500
|
+
// ---------------------------------------------------------------------------
|
|
501
|
+
|
|
502
|
+
describe("URL construction", () => {
|
|
503
|
+
it("strips trailing slash from ledgerUrl", async () => {
|
|
504
|
+
const client = new CantonClient({
|
|
505
|
+
ledgerUrl: "http://localhost:7575/",
|
|
506
|
+
token: TOKEN,
|
|
507
|
+
userId: USER_ID,
|
|
508
|
+
});
|
|
509
|
+
mockFetch.mockResolvedValueOnce(jsonResponse({ offset: 1 }));
|
|
510
|
+
|
|
511
|
+
await client.getLedgerEnd();
|
|
512
|
+
|
|
513
|
+
const [url] = mockFetch.mock.calls[0];
|
|
514
|
+
expect(url).toBe("http://localhost:7575/v2/state/ledger-end");
|
|
515
|
+
});
|
|
516
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CantonClient E2E tests — runs against a real Canton sandbox.
|
|
3
|
+
* Uses capability detection to skip tests that need unavailable features.
|
|
4
|
+
* Skipped entirely if no Canton node is reachable.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { beforeAll, describe, expect, it } from "vitest";
|
|
8
|
+
import { CantonClient } from "../../canton/client.js";
|
|
9
|
+
import { CantonApiError, CantonAuthError } from "../../canton/errors.js";
|
|
10
|
+
import {
|
|
11
|
+
getCantonConnection,
|
|
12
|
+
detectCapabilities,
|
|
13
|
+
createTestClient,
|
|
14
|
+
createTestParty,
|
|
15
|
+
type CantonCapabilities,
|
|
16
|
+
} from "./setup.js";
|
|
17
|
+
|
|
18
|
+
let client: CantonClient;
|
|
19
|
+
let cantonAvailable = false;
|
|
20
|
+
let caps: CantonCapabilities = {
|
|
21
|
+
cantonAvailable: false,
|
|
22
|
+
v2ApiAvailable: false,
|
|
23
|
+
partyCreation: false,
|
|
24
|
+
authRequired: false,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
beforeAll(async () => {
|
|
28
|
+
const conn = await getCantonConnection();
|
|
29
|
+
cantonAvailable = conn.isAvailable;
|
|
30
|
+
if (cantonAvailable) {
|
|
31
|
+
client = createTestClient(conn.ledgerUrl);
|
|
32
|
+
caps = await detectCapabilities(conn.ledgerUrl);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Core — only needs /livez
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
describe.skipIf(!cantonAvailable)("CantonClient E2E — Core", () => {
|
|
41
|
+
it("should confirm Canton node is healthy via /livez", async () => {
|
|
42
|
+
expect(await client.isHealthy()).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("should return false for non-routable IP", async () => {
|
|
46
|
+
const bad = new CantonClient({ ledgerUrl: "http://192.0.2.1:7575", token: "t", userId: "u", timeout: 1000 });
|
|
47
|
+
expect(await bad.isHealthy()).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// v2 API — needs /v2/state/ledger-end
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
describe.skipIf(!caps.v2ApiAvailable)("CantonClient E2E — v2 API", () => {
|
|
56
|
+
it("should return ledger offset as number >= 0", async () => {
|
|
57
|
+
const offset = await client.getLedgerEnd();
|
|
58
|
+
expect(typeof offset).toBe("number");
|
|
59
|
+
expect(offset).toBeGreaterThanOrEqual(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("should query active contracts with WildcardFilter", async () => {
|
|
63
|
+
const offset = await client.getLedgerEnd();
|
|
64
|
+
const contracts = await client.queryActiveContracts({
|
|
65
|
+
filtersForAnyParty: {
|
|
66
|
+
cumulative: [{ identifierFilter: { WildcardFilter: { value: { includeCreatedEventBlob: false } } } }],
|
|
67
|
+
},
|
|
68
|
+
activeAtOffset: offset,
|
|
69
|
+
});
|
|
70
|
+
expect(Array.isArray(contracts)).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("should return null for non-existent transaction", async () => {
|
|
74
|
+
expect(await client.getTransactionById("nonexistent-99999")).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Parties — needs party creation
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
describe.skipIf(!caps.partyCreation)("CantonClient E2E — Parties", () => {
|
|
83
|
+
it("should allocate party matching Name::hexfingerprint", async () => {
|
|
84
|
+
const party = await createTestParty(client, "e2e-test");
|
|
85
|
+
expect(party).toMatch(/^e2e-test.*::[a-f0-9]+$/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("should list parties including newly created one", async () => {
|
|
89
|
+
const newParty = await createTestParty(client, "e2e-list");
|
|
90
|
+
const parties = await client.listParties();
|
|
91
|
+
expect(parties.length).toBeGreaterThan(0);
|
|
92
|
+
expect(parties.some((p) => p.party === newParty)).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("should return isLocal=true with valid localMetadata", async () => {
|
|
96
|
+
const details = await client.allocateParty(`e2e-local-${Date.now()}`);
|
|
97
|
+
expect(details.isLocal).toBe(true);
|
|
98
|
+
expect(details.localMetadata).toBeDefined();
|
|
99
|
+
expect(details.localMetadata.resourceVersion).toBeDefined();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("should produce unique party IDs for same prefix", async () => {
|
|
103
|
+
const p1 = await createTestParty(client, "e2e-dup");
|
|
104
|
+
const p2 = await createTestParty(client, "e2e-dup");
|
|
105
|
+
expect(p1).not.toBe(p2);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("should advance ledger offset after party creation", async () => {
|
|
109
|
+
const off1 = await client.getLedgerEnd();
|
|
110
|
+
await createTestParty(client, "e2e-advance");
|
|
111
|
+
const off2 = await client.getLedgerEnd();
|
|
112
|
+
expect(off2).toBeGreaterThanOrEqual(off1);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("should return empty contracts for fresh party", async () => {
|
|
116
|
+
const party = await createTestParty(client, "e2e-empty");
|
|
117
|
+
const offset = await client.getLedgerEnd();
|
|
118
|
+
const contracts = await client.queryActiveContracts({
|
|
119
|
+
filtersByParty: {
|
|
120
|
+
[party]: { cumulative: [{ identifierFilter: { WildcardFilter: { value: {} } } }] },
|
|
121
|
+
},
|
|
122
|
+
activeAtOffset: offset,
|
|
123
|
+
});
|
|
124
|
+
expect(contracts).toEqual([]);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Errors — needs v2 API
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
describe.skipIf(!caps.v2ApiAvailable)("CantonClient E2E — Errors", () => {
|
|
133
|
+
it("should throw structured error for invalid command", async () => {
|
|
134
|
+
try {
|
|
135
|
+
await client.submitAndWait({
|
|
136
|
+
commands: [{ CreateCommand: { createArguments: {}, templateId: "nonexistent:Module:Template" } }],
|
|
137
|
+
commandId: `err-${Date.now()}`,
|
|
138
|
+
actAs: ["nonexistent::0000"],
|
|
139
|
+
});
|
|
140
|
+
expect.fail("Should have thrown");
|
|
141
|
+
} catch (err) {
|
|
142
|
+
expect(err instanceof CantonApiError || err instanceof CantonAuthError || err instanceof Error).toBe(true);
|
|
143
|
+
if (err instanceof CantonApiError) {
|
|
144
|
+
expect(err.code).toBeTruthy();
|
|
145
|
+
expect(typeof err.grpcCodeValue).toBe("number");
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// CreateCommand + Query cycle — requires DAR
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
describe.skipIf(!caps.partyCreation)("CantonClient E2E — Command Cycle", () => {
|
|
156
|
+
it("should submit CreateCommand and query back (if DAR loaded)", async () => {
|
|
157
|
+
const party = await createTestParty(client, "e2e-create");
|
|
158
|
+
try {
|
|
159
|
+
const result = await client.submitAndWait({
|
|
160
|
+
commands: [{
|
|
161
|
+
CreateCommand: {
|
|
162
|
+
templateId: "#caypo-test-token:Token:Token",
|
|
163
|
+
createArguments: { issuer: party, owner: party, amount: "100.0", name: "TestUSDCx" },
|
|
164
|
+
},
|
|
165
|
+
}],
|
|
166
|
+
commandId: `create-${Date.now()}`,
|
|
167
|
+
actAs: [party],
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
expect(result.updateId).toBeTruthy();
|
|
171
|
+
expect(result.completionOffset).toBeGreaterThan(0);
|
|
172
|
+
|
|
173
|
+
// Query back
|
|
174
|
+
const offset = await client.getLedgerEnd();
|
|
175
|
+
const contracts = await client.queryActiveContracts({
|
|
176
|
+
filtersByParty: { [party]: { cumulative: [{ identifierFilter: { WildcardFilter: { value: {} } } }] } },
|
|
177
|
+
activeAtOffset: offset,
|
|
178
|
+
});
|
|
179
|
+
expect(contracts.length).toBeGreaterThan(0);
|
|
180
|
+
const token = contracts.find((c) => c.createArgument.name === "TestUSDCx");
|
|
181
|
+
expect(token).toBeDefined();
|
|
182
|
+
} catch (err) {
|
|
183
|
+
// DAR not loaded — expected on bare sandbox
|
|
184
|
+
if (err instanceof CantonApiError && (err.code === "INVALID_ARGUMENT" || err.code === "NOT_FOUND")) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
throw err;
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
});
|