@caypo/mpp-canton 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.
@@ -0,0 +1,216 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { Credential } from "mppx";
3
+ import { cantonClient, type CantonMppClientConfig } from "../client.js";
4
+ import type { CantonCredentialPayload, CantonRequest } from "../schemas.js";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Helpers
8
+ // ---------------------------------------------------------------------------
9
+
10
+ const AGENT_PARTY = "Agent::1220aaaa";
11
+ const GATEWAY_PARTY = "Gateway::1220bbbb";
12
+
13
+ const clientConfig: CantonMppClientConfig = {
14
+ ledgerUrl: "http://localhost:7575",
15
+ token: "test-jwt",
16
+ userId: "ledger-api-user",
17
+ partyId: AGENT_PARTY,
18
+ network: "testnet",
19
+ };
20
+
21
+ function makeChallenge(overrides?: Partial<CantonRequest>) {
22
+ return {
23
+ request: {
24
+ amount: "1.50",
25
+ currency: "USDCx" as const,
26
+ recipient: GATEWAY_PARTY,
27
+ network: "testnet" as const,
28
+ expiry: 300,
29
+ ...overrides,
30
+ },
31
+ };
32
+ }
33
+
34
+ const mockFetch = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>();
35
+
36
+ beforeEach(() => {
37
+ vi.stubGlobal("fetch", mockFetch);
38
+ });
39
+
40
+ afterEach(() => {
41
+ vi.restoreAllMocks();
42
+ });
43
+
44
+ function jsonResponse(body: unknown, status = 200): Response {
45
+ return new Response(JSON.stringify(body), {
46
+ status,
47
+ headers: { "Content-Type": "application/json" },
48
+ });
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // cantonClient — createCredential
53
+ // ---------------------------------------------------------------------------
54
+
55
+ describe("cantonClient.createCredential", () => {
56
+ it("completes full flow: ledger-end → holdings → submit → credential", async () => {
57
+ // 1. getLedgerEnd
58
+ mockFetch.mockResolvedValueOnce(jsonResponse({ offset: 50 }));
59
+ // 2. queryActiveContracts (holdings)
60
+ mockFetch.mockResolvedValueOnce(
61
+ jsonResponse({
62
+ contractEntry: [
63
+ {
64
+ createdEvent: {
65
+ contractId: "hold-1",
66
+ templateId: "Splice.Api.Token.HoldingV1:Holding",
67
+ createArgument: { amount: "10.000000", owner: AGENT_PARTY },
68
+ witnessParties: [AGENT_PARTY],
69
+ signatories: [AGENT_PARTY],
70
+ observers: [],
71
+ },
72
+ },
73
+ ],
74
+ }),
75
+ );
76
+ // 3. submitAndWait
77
+ mockFetch.mockResolvedValueOnce(
78
+ jsonResponse({ updateId: "upd-abc", completionOffset: 51 }),
79
+ );
80
+
81
+ const client = cantonClient(clientConfig);
82
+ const credentialStr = await client.createCredential({
83
+ challenge: makeChallenge(),
84
+ });
85
+
86
+ expect(typeof credentialStr).toBe("string");
87
+
88
+ // Decode and verify
89
+ const decoded = Credential.deserialize<CantonCredentialPayload, CantonRequest>(credentialStr);
90
+ expect(decoded.payload.updateId).toBe("upd-abc");
91
+ expect(decoded.payload.completionOffset).toBe(51);
92
+ expect(decoded.payload.sender).toBe(AGENT_PARTY);
93
+ expect(decoded.payload.commandId).toMatch(/^[0-9a-f]{8}-/);
94
+ expect(decoded.challenge.request.amount).toBe("1.50");
95
+
96
+ // Verify 3 fetch calls
97
+ expect(mockFetch).toHaveBeenCalledTimes(3);
98
+
99
+ // Verify submit command includes TransferFactory_Transfer
100
+ const submitCall = mockFetch.mock.calls[2];
101
+ const submitBody = JSON.parse(submitCall[1]?.body as string);
102
+ const cmd = submitBody.commands[0].ExerciseCommand;
103
+ expect(cmd.choice).toBe("TransferFactory_Transfer");
104
+ expect(cmd.choiceArgument.sender).toBe(AGENT_PARTY);
105
+ expect(cmd.choiceArgument.receiver).toBe(GATEWAY_PARTY);
106
+ expect(cmd.choiceArgument.amount).toBe("1.50");
107
+ expect(cmd.choiceArgument.inputHoldingCids).toEqual(["hold-1"]);
108
+ });
109
+
110
+ it("throws on network mismatch", async () => {
111
+ const client = cantonClient(clientConfig);
112
+
113
+ await expect(
114
+ client.createCredential({
115
+ challenge: makeChallenge({ network: "mainnet" }),
116
+ }),
117
+ ).rejects.toThrow("Network mismatch");
118
+ });
119
+
120
+ it("throws on insufficient balance (no holdings)", async () => {
121
+ // getLedgerEnd
122
+ mockFetch.mockResolvedValueOnce(jsonResponse({ offset: 10 }));
123
+ // empty holdings
124
+ mockFetch.mockResolvedValueOnce(jsonResponse({ contractEntry: [] }));
125
+
126
+ const client = cantonClient(clientConfig);
127
+
128
+ await expect(
129
+ client.createCredential({ challenge: makeChallenge() }),
130
+ ).rejects.toThrow("Insufficient balance");
131
+ });
132
+
133
+ it("throws on insufficient balance (holdings too small)", async () => {
134
+ mockFetch.mockResolvedValueOnce(jsonResponse({ offset: 10 }));
135
+ mockFetch.mockResolvedValueOnce(
136
+ jsonResponse({
137
+ contractEntry: [
138
+ {
139
+ createdEvent: {
140
+ contractId: "hold-small",
141
+ templateId: "t",
142
+ createArgument: { amount: "0.50" },
143
+ witnessParties: [AGENT_PARTY],
144
+ signatories: [AGENT_PARTY],
145
+ observers: [],
146
+ },
147
+ },
148
+ ],
149
+ }),
150
+ );
151
+
152
+ const client = cantonClient(clientConfig);
153
+
154
+ await expect(
155
+ client.createCredential({ challenge: makeChallenge({ amount: "100.00" }) }),
156
+ ).rejects.toThrow("Insufficient balance");
157
+ });
158
+
159
+ it("throws when ledger API returns error", async () => {
160
+ mockFetch.mockResolvedValueOnce(
161
+ new Response("Internal error", { status: 500 }),
162
+ );
163
+
164
+ const client = cantonClient(clientConfig);
165
+
166
+ await expect(
167
+ client.createCredential({ challenge: makeChallenge() }),
168
+ ).rejects.toThrow("Canton API error: HTTP 500");
169
+ });
170
+
171
+ it("selects multiple holdings when needed", async () => {
172
+ mockFetch.mockResolvedValueOnce(jsonResponse({ offset: 20 }));
173
+ mockFetch.mockResolvedValueOnce(
174
+ jsonResponse({
175
+ contractEntry: [
176
+ {
177
+ createdEvent: {
178
+ contractId: "h-a",
179
+ templateId: "t",
180
+ createArgument: { amount: "3.0" },
181
+ witnessParties: [AGENT_PARTY],
182
+ signatories: [AGENT_PARTY],
183
+ observers: [],
184
+ },
185
+ },
186
+ {
187
+ createdEvent: {
188
+ contractId: "h-b",
189
+ templateId: "t",
190
+ createArgument: { amount: "4.0" },
191
+ witnessParties: [AGENT_PARTY],
192
+ signatories: [AGENT_PARTY],
193
+ observers: [],
194
+ },
195
+ },
196
+ ],
197
+ }),
198
+ );
199
+ mockFetch.mockResolvedValueOnce(
200
+ jsonResponse({ updateId: "upd-multi", completionOffset: 21 }),
201
+ );
202
+
203
+ const client = cantonClient(clientConfig);
204
+ const credentialStr = await client.createCredential({
205
+ challenge: makeChallenge({ amount: "6.0" }),
206
+ });
207
+
208
+ const decoded = Credential.deserialize<CantonCredentialPayload>(credentialStr);
209
+ expect(decoded.payload.updateId).toBe("upd-multi");
210
+
211
+ // Verify multiple inputHoldingCids
212
+ const submitBody = JSON.parse(mockFetch.mock.calls[2][1]?.body as string);
213
+ const cids = submitBody.commands[0].ExerciseCommand.choiceArgument.inputHoldingCids;
214
+ expect(cids.length).toBeGreaterThanOrEqual(2);
215
+ });
216
+ });
@@ -0,0 +1,140 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { cantonMethod } from "../method.js";
3
+ import { credentialPayloadSchema, requestSchema } from "../schemas.js";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // cantonMethod definition
7
+ // ---------------------------------------------------------------------------
8
+
9
+ describe("cantonMethod", () => {
10
+ it("has correct name and intent", () => {
11
+ expect(cantonMethod.name).toBe("canton");
12
+ expect(cantonMethod.intent).toBe("charge");
13
+ });
14
+
15
+ it("has request and credential schemas", () => {
16
+ expect(cantonMethod.schema.request).toBeDefined();
17
+ expect(cantonMethod.schema.credential.payload).toBeDefined();
18
+ });
19
+ });
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // requestSchema validation
23
+ // ---------------------------------------------------------------------------
24
+
25
+ describe("requestSchema", () => {
26
+ const valid = {
27
+ amount: "1.50",
28
+ currency: "USDCx" as const,
29
+ recipient: "Gateway::1220abcd",
30
+ network: "mainnet" as const,
31
+ };
32
+
33
+ it("accepts valid request with defaults", () => {
34
+ const result = requestSchema.parse(valid);
35
+ expect(result.amount).toBe("1.50");
36
+ expect(result.currency).toBe("USDCx");
37
+ expect(result.expiry).toBe(300); // default
38
+ expect(result.description).toBeUndefined();
39
+ });
40
+
41
+ it("accepts request with all fields", () => {
42
+ const result = requestSchema.parse({
43
+ ...valid,
44
+ description: "API call payment",
45
+ expiry: 60,
46
+ });
47
+ expect(result.description).toBe("API call payment");
48
+ expect(result.expiry).toBe(60);
49
+ });
50
+
51
+ it("accepts CC currency", () => {
52
+ const result = requestSchema.parse({ ...valid, currency: "CC" });
53
+ expect(result.currency).toBe("CC");
54
+ });
55
+
56
+ it("accepts all network types", () => {
57
+ for (const network of ["mainnet", "testnet", "devnet"] as const) {
58
+ const result = requestSchema.parse({ ...valid, network });
59
+ expect(result.network).toBe(network);
60
+ }
61
+ });
62
+
63
+ it("accepts amount with up to 10 decimals", () => {
64
+ expect(() => requestSchema.parse({ ...valid, amount: "0.0000000001" })).not.toThrow();
65
+ expect(() => requestSchema.parse({ ...valid, amount: "999999" })).not.toThrow();
66
+ expect(() => requestSchema.parse({ ...valid, amount: "0" })).not.toThrow();
67
+ });
68
+
69
+ it("rejects invalid amount format", () => {
70
+ expect(() => requestSchema.parse({ ...valid, amount: "" })).toThrow();
71
+ expect(() => requestSchema.parse({ ...valid, amount: "-1" })).toThrow();
72
+ expect(() => requestSchema.parse({ ...valid, amount: "abc" })).toThrow();
73
+ expect(() => requestSchema.parse({ ...valid, amount: ".5" })).toThrow();
74
+ expect(() => requestSchema.parse({ ...valid, amount: "1.00000000001" })).toThrow(); // 11 decimals
75
+ });
76
+
77
+ it("rejects invalid currency", () => {
78
+ expect(() => requestSchema.parse({ ...valid, currency: "ETH" })).toThrow();
79
+ });
80
+
81
+ it("rejects invalid network", () => {
82
+ expect(() => requestSchema.parse({ ...valid, network: "ethereum" })).toThrow();
83
+ });
84
+
85
+ it("rejects empty recipient", () => {
86
+ expect(() => requestSchema.parse({ ...valid, recipient: "" })).toThrow();
87
+ });
88
+
89
+ it("rejects expiry out of range", () => {
90
+ expect(() => requestSchema.parse({ ...valid, expiry: 0 })).toThrow();
91
+ expect(() => requestSchema.parse({ ...valid, expiry: 3601 })).toThrow();
92
+ expect(() => requestSchema.parse({ ...valid, expiry: -1 })).toThrow();
93
+ });
94
+
95
+ it("rejects expiry with non-integer", () => {
96
+ expect(() => requestSchema.parse({ ...valid, expiry: 1.5 })).toThrow();
97
+ });
98
+ });
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // credentialPayloadSchema validation
102
+ // ---------------------------------------------------------------------------
103
+
104
+ describe("credentialPayloadSchema", () => {
105
+ const valid = {
106
+ updateId: "update-123",
107
+ completionOffset: 42,
108
+ sender: "Agent::1220abcd",
109
+ commandId: "cmd-456",
110
+ };
111
+
112
+ it("accepts valid payload", () => {
113
+ const result = credentialPayloadSchema.parse(valid);
114
+ expect(result.updateId).toBe("update-123");
115
+ expect(result.completionOffset).toBe(42);
116
+ expect(result.sender).toBe("Agent::1220abcd");
117
+ expect(result.commandId).toBe("cmd-456");
118
+ });
119
+
120
+ it("rejects empty updateId", () => {
121
+ expect(() => credentialPayloadSchema.parse({ ...valid, updateId: "" })).toThrow();
122
+ });
123
+
124
+ it("rejects empty sender", () => {
125
+ expect(() => credentialPayloadSchema.parse({ ...valid, sender: "" })).toThrow();
126
+ });
127
+
128
+ it("rejects empty commandId", () => {
129
+ expect(() => credentialPayloadSchema.parse({ ...valid, commandId: "" })).toThrow();
130
+ });
131
+
132
+ it("rejects non-integer completionOffset", () => {
133
+ expect(() => credentialPayloadSchema.parse({ ...valid, completionOffset: 1.5 })).toThrow();
134
+ });
135
+
136
+ it("rejects missing fields", () => {
137
+ expect(() => credentialPayloadSchema.parse({})).toThrow();
138
+ expect(() => credentialPayloadSchema.parse({ updateId: "x" })).toThrow();
139
+ });
140
+ });