@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,361 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { cantonServer, MppVerificationError, type CantonMppServerConfig } from "../server.js";
3
+ import type { CantonCredentialPayload, CantonRequest } from "../schemas.js";
4
+ import type { CredentialData } from "mppx";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Helpers
8
+ // ---------------------------------------------------------------------------
9
+
10
+ const GATEWAY_PARTY = "Gateway::1220bbbb";
11
+ const AGENT_PARTY = "Agent::1220aaaa";
12
+
13
+ const serverConfig: CantonMppServerConfig = {
14
+ ledgerUrl: "http://localhost:7575",
15
+ token: "server-jwt",
16
+ userId: "ledger-api-user",
17
+ recipientPartyId: GATEWAY_PARTY,
18
+ network: "testnet",
19
+ };
20
+
21
+ function makeCredential(
22
+ overrides?: Partial<CantonCredentialPayload>,
23
+ requestOverrides?: Partial<CantonRequest>,
24
+ ): CredentialData<CantonCredentialPayload, CantonRequest> {
25
+ return {
26
+ challenge: {
27
+ request: {
28
+ amount: "1.50",
29
+ currency: "USDCx",
30
+ recipient: GATEWAY_PARTY,
31
+ network: "testnet",
32
+ expiry: 300,
33
+ ...requestOverrides,
34
+ },
35
+ },
36
+ payload: {
37
+ updateId: "upd-123",
38
+ completionOffset: 42,
39
+ sender: AGENT_PARTY,
40
+ commandId: "cmd-456",
41
+ ...overrides,
42
+ },
43
+ };
44
+ }
45
+
46
+ function makeTransactionResponse(holdingAmount: string, recipientParty: string, senderParty: string) {
47
+ return {
48
+ updateId: "upd-123",
49
+ eventsById: {
50
+ "evt-1": {
51
+ exercisedEvent: {
52
+ contractId: "factory-cid",
53
+ choice: "TransferFactory_Transfer",
54
+ actingParties: [senderParty],
55
+ },
56
+ },
57
+ "evt-2": {
58
+ createdEvent: {
59
+ contractId: "new-holding",
60
+ templateId: "Splice.Api.Token.HoldingV1:Holding",
61
+ createArgument: { amount: holdingAmount, owner: recipientParty },
62
+ witnessParties: [recipientParty],
63
+ signatories: [recipientParty],
64
+ observers: [],
65
+ },
66
+ },
67
+ },
68
+ rootEventIds: ["evt-1"],
69
+ };
70
+ }
71
+
72
+ const mockFetch = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>();
73
+
74
+ beforeEach(() => {
75
+ vi.stubGlobal("fetch", mockFetch);
76
+ });
77
+
78
+ afterEach(() => {
79
+ vi.restoreAllMocks();
80
+ });
81
+
82
+ function jsonResponse(body: unknown, status = 200): Response {
83
+ return new Response(JSON.stringify(body), {
84
+ status,
85
+ headers: { "Content-Type": "application/json" },
86
+ });
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // cantonServer — verify — success
91
+ // ---------------------------------------------------------------------------
92
+
93
+ describe("cantonServer.verify — success", () => {
94
+ it("returns receipt on valid credential", async () => {
95
+ mockFetch.mockResolvedValueOnce(
96
+ jsonResponse(makeTransactionResponse("1.50", GATEWAY_PARTY, AGENT_PARTY)),
97
+ );
98
+
99
+ const server = cantonServer(serverConfig);
100
+ const receipt = await server.verify({ credential: makeCredential() });
101
+
102
+ expect(receipt.method).toBe("canton");
103
+ expect(receipt.reference).toBe("upd-123");
104
+ expect(receipt.status).toBe("success");
105
+ expect(receipt.timestamp).toBeTruthy();
106
+ });
107
+
108
+ it("accepts overpayment (amount > required)", async () => {
109
+ mockFetch.mockResolvedValueOnce(
110
+ jsonResponse(makeTransactionResponse("5.00", GATEWAY_PARTY, AGENT_PARTY)),
111
+ );
112
+
113
+ const server = cantonServer(serverConfig);
114
+ const receipt = await server.verify({ credential: makeCredential() });
115
+
116
+ expect(receipt.status).toBe("success");
117
+ });
118
+
119
+ it("fetches transaction with correct URL and auth header", async () => {
120
+ mockFetch.mockResolvedValueOnce(
121
+ jsonResponse(makeTransactionResponse("1.50", GATEWAY_PARTY, AGENT_PARTY)),
122
+ );
123
+
124
+ const server = cantonServer(serverConfig);
125
+ await server.verify({ credential: makeCredential() });
126
+
127
+ const [url, init] = mockFetch.mock.calls[0];
128
+ expect(url).toBe("http://localhost:7575/v2/updates/transaction-by-id/upd-123");
129
+ expect((init?.headers as Record<string, string>).Authorization).toBe("Bearer server-jwt");
130
+ });
131
+ });
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // cantonServer — verify — error cases
135
+ // ---------------------------------------------------------------------------
136
+
137
+ describe("cantonServer.verify — network mismatch", () => {
138
+ it("throws MppVerificationError on wrong network", async () => {
139
+ const server = cantonServer(serverConfig);
140
+
141
+ try {
142
+ await server.verify({
143
+ credential: makeCredential({}, { network: "mainnet" }),
144
+ });
145
+ expect.fail("should throw");
146
+ } catch (err) {
147
+ expect(err).toBeInstanceOf(MppVerificationError);
148
+ expect((err as MppVerificationError).problemCode).toBe("verification-failed");
149
+ expect((err as MppVerificationError).message).toContain("Network mismatch");
150
+ }
151
+ });
152
+ });
153
+
154
+ describe("cantonServer.verify — recipient mismatch", () => {
155
+ it("throws MppVerificationError on wrong recipient", async () => {
156
+ const server = cantonServer(serverConfig);
157
+
158
+ try {
159
+ await server.verify({
160
+ credential: makeCredential({}, { recipient: "Other::1220cccc" }),
161
+ });
162
+ expect.fail("should throw");
163
+ } catch (err) {
164
+ expect(err).toBeInstanceOf(MppVerificationError);
165
+ expect((err as MppVerificationError).problemCode).toBe("verification-failed");
166
+ expect((err as MppVerificationError).message).toContain("Recipient mismatch");
167
+ }
168
+ });
169
+ });
170
+
171
+ describe("cantonServer.verify — transaction not found", () => {
172
+ it("throws when ledger returns 404", async () => {
173
+ mockFetch.mockResolvedValueOnce(new Response("Not found", { status: 404 }));
174
+
175
+ const server = cantonServer(serverConfig);
176
+
177
+ try {
178
+ await server.verify({ credential: makeCredential() });
179
+ expect.fail("should throw");
180
+ } catch (err) {
181
+ expect(err).toBeInstanceOf(MppVerificationError);
182
+ expect((err as MppVerificationError).problemCode).toBe("verification-failed");
183
+ expect((err as MppVerificationError).message).toContain("Transaction not found");
184
+ }
185
+ });
186
+ });
187
+
188
+ describe("cantonServer.verify — insufficient amount", () => {
189
+ it("throws when received less than required", async () => {
190
+ mockFetch.mockResolvedValueOnce(
191
+ jsonResponse(makeTransactionResponse("0.50", GATEWAY_PARTY, AGENT_PARTY)),
192
+ );
193
+
194
+ const server = cantonServer(serverConfig);
195
+
196
+ try {
197
+ await server.verify({ credential: makeCredential() });
198
+ expect.fail("should throw");
199
+ } catch (err) {
200
+ expect(err).toBeInstanceOf(MppVerificationError);
201
+ expect((err as MppVerificationError).problemCode).toBe("payment-insufficient");
202
+ expect((err as MppVerificationError).message).toContain("Payment insufficient");
203
+ }
204
+ });
205
+ });
206
+
207
+ describe("cantonServer.verify — no holding for recipient", () => {
208
+ it("throws when no created Holding event for gateway party", async () => {
209
+ mockFetch.mockResolvedValueOnce(
210
+ jsonResponse({
211
+ updateId: "upd-123",
212
+ eventsById: {
213
+ "evt-1": {
214
+ exercisedEvent: {
215
+ contractId: "factory-cid",
216
+ choice: "TransferFactory_Transfer",
217
+ actingParties: [AGENT_PARTY],
218
+ },
219
+ },
220
+ // Holding created for wrong party
221
+ "evt-2": {
222
+ createdEvent: {
223
+ contractId: "new-holding",
224
+ templateId: "Splice.Api.Token.HoldingV1:Holding",
225
+ createArgument: { amount: "1.50", owner: "Other::1220dddd" },
226
+ witnessParties: ["Other::1220dddd"],
227
+ signatories: ["Other::1220dddd"],
228
+ observers: [],
229
+ },
230
+ },
231
+ },
232
+ rootEventIds: ["evt-1"],
233
+ }),
234
+ );
235
+
236
+ const server = cantonServer(serverConfig);
237
+
238
+ try {
239
+ await server.verify({ credential: makeCredential() });
240
+ expect.fail("should throw");
241
+ } catch (err) {
242
+ expect(err).toBeInstanceOf(MppVerificationError);
243
+ expect((err as MppVerificationError).message).toContain("No holding created for recipient");
244
+ }
245
+ });
246
+ });
247
+
248
+ describe("cantonServer.verify — sender mismatch", () => {
249
+ it("throws when sender did not exercise the transfer", async () => {
250
+ mockFetch.mockResolvedValueOnce(
251
+ jsonResponse({
252
+ updateId: "upd-123",
253
+ eventsById: {
254
+ // Different party exercised
255
+ "evt-1": {
256
+ exercisedEvent: {
257
+ contractId: "factory-cid",
258
+ choice: "TransferFactory_Transfer",
259
+ actingParties: ["Intruder::1220eeee"],
260
+ },
261
+ },
262
+ "evt-2": {
263
+ createdEvent: {
264
+ contractId: "new-holding",
265
+ templateId: "Splice.Api.Token.HoldingV1:Holding",
266
+ createArgument: { amount: "1.50", owner: GATEWAY_PARTY },
267
+ witnessParties: [GATEWAY_PARTY],
268
+ signatories: [GATEWAY_PARTY],
269
+ observers: [],
270
+ },
271
+ },
272
+ },
273
+ rootEventIds: ["evt-1"],
274
+ }),
275
+ );
276
+
277
+ const server = cantonServer(serverConfig);
278
+
279
+ try {
280
+ await server.verify({ credential: makeCredential() });
281
+ expect.fail("should throw");
282
+ } catch (err) {
283
+ expect(err).toBeInstanceOf(MppVerificationError);
284
+ expect((err as MppVerificationError).problemCode).toBe("verification-failed");
285
+ expect((err as MppVerificationError).message).toContain("Sender mismatch");
286
+ }
287
+ });
288
+ });
289
+
290
+ // ---------------------------------------------------------------------------
291
+ // Round-trip test
292
+ // ---------------------------------------------------------------------------
293
+
294
+ describe("round-trip: client credential → server verify", () => {
295
+ it("full flow succeeds", async () => {
296
+ // --- Client side: 3 fetch calls ---
297
+ // 1. getLedgerEnd
298
+ mockFetch.mockResolvedValueOnce(jsonResponse({ offset: 100 }));
299
+ // 2. queryActiveContracts
300
+ mockFetch.mockResolvedValueOnce(
301
+ jsonResponse({
302
+ contractEntry: [
303
+ {
304
+ createdEvent: {
305
+ contractId: "hold-rt",
306
+ templateId: "Splice.Api.Token.HoldingV1:Holding",
307
+ createArgument: { amount: "10.000000", owner: AGENT_PARTY },
308
+ witnessParties: [AGENT_PARTY],
309
+ signatories: [AGENT_PARTY],
310
+ observers: [],
311
+ },
312
+ },
313
+ ],
314
+ }),
315
+ );
316
+ // 3. submitAndWait
317
+ mockFetch.mockResolvedValueOnce(
318
+ jsonResponse({ updateId: "upd-roundtrip", completionOffset: 101 }),
319
+ );
320
+
321
+ // Create credential via client
322
+ const { cantonClient: makeClient } = await import("../client.js");
323
+ const client = makeClient({
324
+ ledgerUrl: "http://localhost:7575",
325
+ token: "agent-jwt",
326
+ userId: "ledger-api-user",
327
+ partyId: AGENT_PARTY,
328
+ network: "testnet",
329
+ });
330
+
331
+ const credentialStr = await client.createCredential({
332
+ challenge: {
333
+ request: {
334
+ amount: "2.00",
335
+ currency: "USDCx" as const,
336
+ recipient: GATEWAY_PARTY,
337
+ network: "testnet" as const,
338
+ expiry: 300,
339
+ },
340
+ },
341
+ });
342
+
343
+ // --- Server side: 1 fetch call ---
344
+ // Deserialize to get the payload
345
+ const { Credential } = await import("mppx");
346
+ const decoded = Credential.deserialize<CantonCredentialPayload, CantonRequest>(credentialStr);
347
+
348
+ // Mock the transaction fetch for server verification
349
+ mockFetch.mockResolvedValueOnce(
350
+ jsonResponse(makeTransactionResponse("2.00", GATEWAY_PARTY, AGENT_PARTY)),
351
+ );
352
+
353
+ const { cantonServer: makeServer } = await import("../server.js");
354
+ const server = makeServer(serverConfig);
355
+ const receipt = await server.verify({ credential: decoded });
356
+
357
+ expect(receipt.method).toBe("canton");
358
+ expect(receipt.reference).toBe("upd-roundtrip");
359
+ expect(receipt.status).toBe("success");
360
+ });
361
+ });
package/src/client.ts ADDED
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Canton MPP client — used by agents to pay for services.
3
+ *
4
+ * Flow:
5
+ * 1. Receive 402 challenge with amount, recipient, network
6
+ * 2. Validate network matches agent config
7
+ * 3. Query agent's USDCx holdings via Ledger API
8
+ * 4. Select holdings covering the required amount
9
+ * 5. Exercise TransferFactory_Transfer (1-step, requires recipient TransferPreapproval)
10
+ * 6. Return serialized credential with updateId + completionOffset
11
+ */
12
+
13
+ import { Credential, Method, type Challenge } from "mppx";
14
+ import { cantonMethod } from "./method.js";
15
+ import type { CantonRequest } from "./schemas.js";
16
+
17
+ const USDCX_HOLDING_TEMPLATE_ID = "Splice.Api.Token.HoldingV1:Holding";
18
+ const TRANSFER_FACTORY_TEMPLATE_ID = "Splice.Api.Token.TransferFactoryV1:TransferFactory";
19
+ const USDCX_INSTRUMENT_ID = "USDCx";
20
+
21
+ export type CantonNetwork = "mainnet" | "testnet" | "devnet";
22
+
23
+ export interface CantonMppClientConfig {
24
+ ledgerUrl: string;
25
+ token: string;
26
+ userId: string;
27
+ partyId: string;
28
+ network: CantonNetwork;
29
+ }
30
+
31
+ interface HoldingContract {
32
+ contractId: string;
33
+ amount: string;
34
+ }
35
+
36
+ async function fetchJson(url: string, token: string, init?: RequestInit): Promise<unknown> {
37
+ const response = await fetch(url, {
38
+ ...init,
39
+ headers: {
40
+ "Content-Type": "application/json",
41
+ Authorization: `Bearer ${token}`,
42
+ ...(init?.headers as Record<string, string>),
43
+ },
44
+ });
45
+
46
+ if (!response.ok) {
47
+ const text = await response.text().catch(() => "");
48
+ throw new Error(`Canton API error: HTTP ${response.status} — ${text}`);
49
+ }
50
+
51
+ return response.json();
52
+ }
53
+
54
+ async function getLedgerEnd(config: CantonMppClientConfig): Promise<number> {
55
+ const data = (await fetchJson(`${config.ledgerUrl}/v2/state/ledger-end`, config.token)) as {
56
+ offset: number;
57
+ };
58
+ return data.offset;
59
+ }
60
+
61
+ async function queryHoldings(config: CantonMppClientConfig): Promise<HoldingContract[]> {
62
+ const offset = await getLedgerEnd(config);
63
+
64
+ const data = (await fetchJson(`${config.ledgerUrl}/v2/state/active-contracts`, config.token, {
65
+ method: "POST",
66
+ body: JSON.stringify({
67
+ eventFormat: {
68
+ filtersByParty: {
69
+ [config.partyId]: {
70
+ cumulative: [
71
+ {
72
+ identifierFilter: {
73
+ TemplateFilter: {
74
+ value: { templateId: USDCX_HOLDING_TEMPLATE_ID },
75
+ },
76
+ },
77
+ },
78
+ ],
79
+ },
80
+ },
81
+ verbose: true,
82
+ },
83
+ activeAtOffset: offset,
84
+ }),
85
+ })) as {
86
+ contractEntry?: Array<{
87
+ createdEvent?: { contractId: string; createArgument: { amount: string } };
88
+ }>;
89
+ };
90
+
91
+ return (data.contractEntry ?? [])
92
+ .filter((e) => e.createdEvent != null)
93
+ .map((e) => ({
94
+ contractId: e.createdEvent!.contractId,
95
+ amount: e.createdEvent!.createArgument.amount,
96
+ }));
97
+ }
98
+
99
+ function selectHoldings(
100
+ holdings: HoldingContract[],
101
+ requiredAmount: string,
102
+ ): string[] {
103
+ if (holdings.length === 0) {
104
+ throw new Error(`Insufficient balance: no holdings available`);
105
+ }
106
+
107
+ // Sort descending
108
+ const sorted = [...holdings].sort(
109
+ (a, b) => parseFloat(b.amount) - parseFloat(a.amount),
110
+ );
111
+
112
+ // Try single holding
113
+ for (let i = sorted.length - 1; i >= 0; i--) {
114
+ if (parseFloat(sorted[i].amount) >= parseFloat(requiredAmount)) {
115
+ return [sorted[i].contractId];
116
+ }
117
+ }
118
+
119
+ // Accumulate multiple
120
+ let total = 0;
121
+ const selected: string[] = [];
122
+ for (const h of sorted) {
123
+ selected.push(h.contractId);
124
+ total += parseFloat(h.amount);
125
+ if (total >= parseFloat(requiredAmount)) {
126
+ return selected;
127
+ }
128
+ }
129
+
130
+ throw new Error(
131
+ `Insufficient balance: have ${total}, need ${requiredAmount}`,
132
+ );
133
+ }
134
+
135
+ export function cantonClient(config: CantonMppClientConfig) {
136
+ return Method.toClient(cantonMethod, {
137
+ async createCredential({ challenge }: { challenge: Challenge<CantonRequest> }) {
138
+ // 1. Validate network
139
+ if (challenge.request.network !== config.network) {
140
+ throw new Error(
141
+ `Network mismatch: challenge requires ${challenge.request.network}, agent configured for ${config.network}`,
142
+ );
143
+ }
144
+
145
+ // 2. Query holdings
146
+ const holdings = await queryHoldings(config);
147
+
148
+ // 3. Select holdings covering amount
149
+ const selectedCids = selectHoldings(holdings, challenge.request.amount);
150
+
151
+ // 4. Generate commandId
152
+ const commandId = crypto.randomUUID();
153
+
154
+ // 5. Exercise TransferFactory_Transfer
155
+ const result = (await fetchJson(
156
+ `${config.ledgerUrl}/v2/commands/submit-and-wait`,
157
+ config.token,
158
+ {
159
+ method: "POST",
160
+ body: JSON.stringify({
161
+ commands: [
162
+ {
163
+ ExerciseCommand: {
164
+ templateId: TRANSFER_FACTORY_TEMPLATE_ID,
165
+ contractId: selectedCids[0],
166
+ choice: "TransferFactory_Transfer",
167
+ choiceArgument: {
168
+ sender: config.partyId,
169
+ receiver: challenge.request.recipient,
170
+ amount: challenge.request.amount,
171
+ instrumentId: USDCX_INSTRUMENT_ID,
172
+ inputHoldingCids: selectedCids,
173
+ meta: {},
174
+ },
175
+ },
176
+ },
177
+ ],
178
+ userId: config.userId,
179
+ commandId,
180
+ actAs: [config.partyId],
181
+ readAs: [config.partyId],
182
+ }),
183
+ },
184
+ )) as { updateId: string; completionOffset: number };
185
+
186
+ // 6. Return serialized credential
187
+ return Credential.serialize({
188
+ challenge,
189
+ payload: {
190
+ updateId: result.updateId,
191
+ completionOffset: result.completionOffset,
192
+ sender: config.partyId,
193
+ commandId,
194
+ },
195
+ });
196
+ },
197
+ });
198
+ }
package/src/index.ts ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * @caypo/mpp-canton — MPP payment method for Canton Network.
3
+ * Uses CIP-56 TransferPreapproval for 1-step transfers.
4
+ */
5
+
6
+ export const MPP_CANTON_VERSION = "0.1.0";
7
+
8
+ // Method definition
9
+ export { cantonMethod } from "./method.js";
10
+
11
+ // Schemas for reuse
12
+ export {
13
+ credentialPayloadSchema,
14
+ receiptSchema,
15
+ requestSchema,
16
+ } from "./schemas.js";
17
+ export type {
18
+ CantonCredentialPayload,
19
+ CantonReceipt,
20
+ CantonRequest,
21
+ } from "./schemas.js";
22
+
23
+ // Client (agent side)
24
+ export { cantonClient } from "./client.js";
25
+ export type { CantonMppClientConfig } from "./client.js";
26
+
27
+ // Server (gateway side)
28
+ export { cantonServer, MppVerificationError } from "./server.js";
29
+ export type { CantonMppServerConfig } from "./server.js";
package/src/method.ts ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Canton MPP method definition.
3
+ *
4
+ * Defines the "canton" payment method using the mppx Method.from() pattern.
5
+ * The method specifies the request and credential schemas that both
6
+ * client (agent) and server (gateway) use.
7
+ */
8
+
9
+ import { Method } from "mppx";
10
+ import { credentialPayloadSchema, requestSchema } from "./schemas.js";
11
+
12
+ export const cantonMethod = Method.from({
13
+ intent: "charge",
14
+ name: "canton",
15
+ schema: {
16
+ credential: {
17
+ payload: credentialPayloadSchema,
18
+ },
19
+ request: requestSchema,
20
+ },
21
+ });
package/src/schemas.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Zod schemas for Canton MPP payment method.
3
+ * Exported separately for reuse in validation, gateway middleware, etc.
4
+ */
5
+
6
+ import { z } from "zod";
7
+
8
+ export const requestSchema = z.object({
9
+ amount: z.string().regex(/^\d+\.?\d{0,10}$/, "Invalid amount format"),
10
+ currency: z.enum(["USDCx", "CC"]),
11
+ recipient: z.string().min(1, "Recipient party ID required"),
12
+ network: z.enum(["mainnet", "testnet", "devnet"]),
13
+ description: z.string().optional(),
14
+ expiry: z.number().int().min(1).max(3600).default(300),
15
+ });
16
+
17
+ export const credentialPayloadSchema = z.object({
18
+ updateId: z.string().min(1, "updateId required"),
19
+ completionOffset: z.number().int(),
20
+ sender: z.string().min(1, "Sender party ID required"),
21
+ commandId: z.string().min(1, "commandId required"),
22
+ });
23
+
24
+ export const receiptSchema = z.object({
25
+ method: z.literal("canton"),
26
+ reference: z.string().min(1),
27
+ status: z.enum(["success", "failed"]),
28
+ timestamp: z.string(),
29
+ });
30
+
31
+ export type CantonRequest = z.infer<typeof requestSchema>;
32
+ export type CantonCredentialPayload = z.infer<typeof credentialPayloadSchema>;
33
+ export type CantonReceipt = z.infer<typeof receiptSchema>;