@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,197 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { Keystore } from "../wallet/keystore.js";
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Setup / teardown
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
let tmpDir: string;
|
|
12
|
+
let walletPath: string;
|
|
13
|
+
|
|
14
|
+
const PIN = "1234";
|
|
15
|
+
const PARTY = "Agent::1220abcdef1234567890";
|
|
16
|
+
const JWT = "eyJhbGciOiJSUzI1NiJ9.test-jwt-payload";
|
|
17
|
+
const USER_ID = "ledger-api-user";
|
|
18
|
+
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
tmpDir = await mkdtemp(join(tmpdir(), "caypo-keystore-test-"));
|
|
21
|
+
walletPath = join(tmpDir, "wallet.key");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(async () => {
|
|
25
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Keystore.create
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
describe("Keystore.create", () => {
|
|
33
|
+
it("creates a new wallet and returns a Keystore instance", async () => {
|
|
34
|
+
const ks = await Keystore.create(
|
|
35
|
+
PIN,
|
|
36
|
+
{ partyId: PARTY, jwt: JWT, userId: USER_ID },
|
|
37
|
+
walletPath,
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
expect(ks.address).toBe(PARTY);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("getCredentials returns partyId, jwt, userId", async () => {
|
|
44
|
+
const ks = await Keystore.create(
|
|
45
|
+
PIN,
|
|
46
|
+
{ partyId: PARTY, jwt: JWT, userId: USER_ID },
|
|
47
|
+
walletPath,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const creds = ks.getCredentials();
|
|
51
|
+
expect(creds.partyId).toBe(PARTY);
|
|
52
|
+
expect(creds.jwt).toBe(JWT);
|
|
53
|
+
expect(creds.userId).toBe(USER_ID);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("creates parent directories if they don't exist", async () => {
|
|
57
|
+
const nested = join(tmpDir, "deep", "nested", "wallet.key");
|
|
58
|
+
const ks = await Keystore.create(
|
|
59
|
+
PIN,
|
|
60
|
+
{ partyId: PARTY, jwt: JWT, userId: USER_ID },
|
|
61
|
+
nested,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
expect(ks.address).toBe(PARTY);
|
|
65
|
+
|
|
66
|
+
// Verify it can be loaded back
|
|
67
|
+
const loaded = await Keystore.load(PIN, nested);
|
|
68
|
+
expect(loaded.address).toBe(PARTY);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Keystore.load
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
describe("Keystore.load", () => {
|
|
77
|
+
it("loads and decrypts an existing wallet with correct PIN", async () => {
|
|
78
|
+
await Keystore.create(
|
|
79
|
+
PIN,
|
|
80
|
+
{ partyId: PARTY, jwt: JWT, userId: USER_ID },
|
|
81
|
+
walletPath,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const loaded = await Keystore.load(PIN, walletPath);
|
|
85
|
+
|
|
86
|
+
expect(loaded.address).toBe(PARTY);
|
|
87
|
+
const creds = loaded.getCredentials();
|
|
88
|
+
expect(creds.partyId).toBe(PARTY);
|
|
89
|
+
expect(creds.jwt).toBe(JWT);
|
|
90
|
+
expect(creds.userId).toBe(USER_ID);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("throws with wrong PIN", async () => {
|
|
94
|
+
await Keystore.create(
|
|
95
|
+
PIN,
|
|
96
|
+
{ partyId: PARTY, jwt: JWT, userId: USER_ID },
|
|
97
|
+
walletPath,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
await expect(Keystore.load("wrong-pin", walletPath)).rejects.toThrow(
|
|
101
|
+
"Invalid PIN or corrupted wallet file",
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("throws if file does not exist", async () => {
|
|
106
|
+
await expect(Keystore.load(PIN, join(tmpDir, "nonexistent.key"))).rejects.toThrow();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("different PINs produce different encryptions", async () => {
|
|
110
|
+
const path1 = join(tmpDir, "w1.key");
|
|
111
|
+
const path2 = join(tmpDir, "w2.key");
|
|
112
|
+
|
|
113
|
+
await Keystore.create(PIN, { partyId: PARTY, jwt: JWT, userId: USER_ID }, path1);
|
|
114
|
+
await Keystore.create("5678", { partyId: PARTY, jwt: JWT, userId: USER_ID }, path2);
|
|
115
|
+
|
|
116
|
+
// Files should have different content (different salt, IV)
|
|
117
|
+
const { readFile } = await import("node:fs/promises");
|
|
118
|
+
const f1 = await readFile(path1, "utf8");
|
|
119
|
+
const f2 = await readFile(path2, "utf8");
|
|
120
|
+
expect(f1).not.toBe(f2);
|
|
121
|
+
|
|
122
|
+
// But both should load correctly with their own PINs
|
|
123
|
+
const ks1 = await Keystore.load(PIN, path1);
|
|
124
|
+
const ks2 = await Keystore.load("5678", path2);
|
|
125
|
+
expect(ks1.address).toBe(PARTY);
|
|
126
|
+
expect(ks2.address).toBe(PARTY);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Keystore.changePin
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
describe("Keystore.changePin", () => {
|
|
135
|
+
it("re-encrypts with new PIN, old PIN no longer works", async () => {
|
|
136
|
+
const ks = await Keystore.create(
|
|
137
|
+
PIN,
|
|
138
|
+
{ partyId: PARTY, jwt: JWT, userId: USER_ID },
|
|
139
|
+
walletPath,
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
await ks.changePin(PIN, "9999");
|
|
143
|
+
|
|
144
|
+
// New PIN works
|
|
145
|
+
const loaded = await Keystore.load("9999", walletPath);
|
|
146
|
+
expect(loaded.address).toBe(PARTY);
|
|
147
|
+
expect(loaded.getCredentials().jwt).toBe(JWT);
|
|
148
|
+
|
|
149
|
+
// Old PIN fails
|
|
150
|
+
await expect(Keystore.load(PIN, walletPath)).rejects.toThrow(
|
|
151
|
+
"Invalid PIN or corrupted wallet file",
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("throws if old PIN is wrong", async () => {
|
|
156
|
+
const ks = await Keystore.create(
|
|
157
|
+
PIN,
|
|
158
|
+
{ partyId: PARTY, jwt: JWT, userId: USER_ID },
|
|
159
|
+
walletPath,
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
await expect(ks.changePin("wrong", "9999")).rejects.toThrow(
|
|
163
|
+
"Invalid PIN or corrupted wallet file",
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// Keystore.exportKey
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
describe("Keystore.exportKey", () => {
|
|
173
|
+
it("returns a 64-character hex string (32 bytes)", async () => {
|
|
174
|
+
const ks = await Keystore.create(
|
|
175
|
+
PIN,
|
|
176
|
+
{ partyId: PARTY, jwt: JWT, userId: USER_ID },
|
|
177
|
+
walletPath,
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const key = ks.exportKey(PIN);
|
|
181
|
+
expect(key).toMatch(/^[0-9a-f]{64}$/);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("returns consistent key across load cycles", async () => {
|
|
185
|
+
const ks = await Keystore.create(
|
|
186
|
+
PIN,
|
|
187
|
+
{ partyId: PARTY, jwt: JWT, userId: USER_ID },
|
|
188
|
+
walletPath,
|
|
189
|
+
);
|
|
190
|
+
const key1 = ks.exportKey(PIN);
|
|
191
|
+
|
|
192
|
+
const loaded = await Keystore.load(PIN, walletPath);
|
|
193
|
+
const key2 = loaded.exportKey(PIN);
|
|
194
|
+
|
|
195
|
+
expect(key1).toBe(key2);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { MppPayClient, parseWwwAuthenticate } from "../mpp/pay-client.js";
|
|
3
|
+
import type { USDCxService } from "../canton/usdcx.js";
|
|
4
|
+
import { SafeguardManager } from "../safeguards/manager.js";
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Helpers
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
const AGENT_PARTY = "Agent::1220aaaa";
|
|
11
|
+
const GATEWAY_PARTY = "Gateway::1220bbbb";
|
|
12
|
+
|
|
13
|
+
const mockFetch = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>();
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
vi.restoreAllMocks();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function mockUsdcx(): USDCxService {
|
|
24
|
+
return {
|
|
25
|
+
getHoldings: vi.fn().mockResolvedValue([]),
|
|
26
|
+
getBalance: vi.fn().mockResolvedValue("100"),
|
|
27
|
+
transfer: vi.fn().mockResolvedValue({
|
|
28
|
+
updateId: "upd-pay",
|
|
29
|
+
completionOffset: 50,
|
|
30
|
+
commandId: "cmd-pay",
|
|
31
|
+
}),
|
|
32
|
+
mergeHoldings: vi.fn(),
|
|
33
|
+
} as unknown as USDCxService;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function make402Response(amount: string): Response {
|
|
37
|
+
return new Response("Payment Required", {
|
|
38
|
+
status: 402,
|
|
39
|
+
headers: {
|
|
40
|
+
"WWW-Authenticate": `Payment method="canton", amount="${amount}", currency="USDCx", recipient="${GATEWAY_PARTY}", network="testnet"`,
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// parseWwwAuthenticate
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
describe("parseWwwAuthenticate", () => {
|
|
50
|
+
it("parses valid Canton payment challenge", () => {
|
|
51
|
+
const result = parseWwwAuthenticate(
|
|
52
|
+
`Payment method="canton", amount="0.01", currency="USDCx", recipient="${GATEWAY_PARTY}", network="mainnet"`,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(result).toEqual({
|
|
56
|
+
amount: "0.01",
|
|
57
|
+
currency: "USDCx",
|
|
58
|
+
recipient: GATEWAY_PARTY,
|
|
59
|
+
network: "mainnet",
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("parses challenge with description", () => {
|
|
64
|
+
const result = parseWwwAuthenticate(
|
|
65
|
+
`Payment method="canton", amount="0.50", currency="USDCx", recipient="Gw::1220", network="testnet", description="API call"`,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
expect(result?.description).toBe("API call");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("defaults currency to USDCx if missing", () => {
|
|
72
|
+
const result = parseWwwAuthenticate(
|
|
73
|
+
`Payment method="canton", amount="1.0", recipient="R::1220", network="testnet"`,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
expect(result?.currency).toBe("USDCx");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("returns null for non-Payment header", () => {
|
|
80
|
+
expect(parseWwwAuthenticate("Bearer realm=\"api\"")).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("returns null for wrong method", () => {
|
|
84
|
+
expect(
|
|
85
|
+
parseWwwAuthenticate(`Payment method="stripe", amount="1.0", recipient="x", network="y"`),
|
|
86
|
+
).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("returns null for missing required fields", () => {
|
|
90
|
+
expect(parseWwwAuthenticate(`Payment method="canton"`)).toBeNull();
|
|
91
|
+
expect(parseWwwAuthenticate(`Payment method="canton", amount="1.0"`)).toBeNull();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// MppPayClient.pay — non-402 response
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
describe("MppPayClient.pay — non-402", () => {
|
|
100
|
+
it("returns response as-is with paid=false", async () => {
|
|
101
|
+
mockFetch.mockResolvedValueOnce(
|
|
102
|
+
new Response(JSON.stringify({ data: "ok" }), { status: 200 }),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const safeguards = new SafeguardManager();
|
|
106
|
+
const client = new MppPayClient(mockUsdcx(), safeguards, AGENT_PARTY, "testnet");
|
|
107
|
+
|
|
108
|
+
const result = await client.pay("https://api.example.com/data");
|
|
109
|
+
|
|
110
|
+
expect(result.paid).toBe(false);
|
|
111
|
+
expect(result.receipt).toBeUndefined();
|
|
112
|
+
expect(result.response.status).toBe(200);
|
|
113
|
+
expect(mockFetch).toHaveBeenCalledOnce();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// MppPayClient.pay — full 402 flow
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
describe("MppPayClient.pay — 402 flow", () => {
|
|
122
|
+
it("pays and retries on 402", async () => {
|
|
123
|
+
// First call: 402
|
|
124
|
+
mockFetch.mockResolvedValueOnce(make402Response("0.01"));
|
|
125
|
+
// Second call: 200 (after payment)
|
|
126
|
+
mockFetch.mockResolvedValueOnce(
|
|
127
|
+
new Response(JSON.stringify({ result: "success" }), { status: 200 }),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const usdcx = mockUsdcx();
|
|
131
|
+
const safeguards = new SafeguardManager();
|
|
132
|
+
const client = new MppPayClient(usdcx, safeguards, AGENT_PARTY, "testnet");
|
|
133
|
+
|
|
134
|
+
const result = await client.pay("https://api.example.com/data");
|
|
135
|
+
|
|
136
|
+
expect(result.paid).toBe(true);
|
|
137
|
+
expect(result.receipt).toBeDefined();
|
|
138
|
+
expect(result.receipt!.updateId).toBe("upd-pay");
|
|
139
|
+
expect(result.receipt!.amount).toBe("0.01");
|
|
140
|
+
expect(result.response.status).toBe(200);
|
|
141
|
+
|
|
142
|
+
// Verify transfer was called
|
|
143
|
+
expect(usdcx.transfer).toHaveBeenCalledWith({
|
|
144
|
+
recipient: GATEWAY_PARTY,
|
|
145
|
+
amount: "0.01",
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Verify retry includes Authorization header
|
|
149
|
+
const retryCall = mockFetch.mock.calls[1];
|
|
150
|
+
const retryHeaders = retryCall[1]?.headers as Record<string, string>;
|
|
151
|
+
expect(retryHeaders.Authorization).toMatch(/^Payment /);
|
|
152
|
+
|
|
153
|
+
// Verify credential is valid base64 JSON
|
|
154
|
+
const credBase64 = retryHeaders.Authorization.replace("Payment ", "");
|
|
155
|
+
const cred = JSON.parse(Buffer.from(credBase64, "base64").toString("utf-8"));
|
|
156
|
+
expect(cred.updateId).toBe("upd-pay");
|
|
157
|
+
expect(cred.sender).toBe(AGENT_PARTY);
|
|
158
|
+
expect(cred.commandId).toBe("cmd-pay");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("records spend in safeguards", async () => {
|
|
162
|
+
mockFetch.mockResolvedValueOnce(make402Response("5.00"));
|
|
163
|
+
mockFetch.mockResolvedValueOnce(new Response("ok", { status: 200 }));
|
|
164
|
+
|
|
165
|
+
const safeguards = new SafeguardManager();
|
|
166
|
+
const client = new MppPayClient(mockUsdcx(), safeguards, AGENT_PARTY, "testnet");
|
|
167
|
+
|
|
168
|
+
await client.pay("https://api.example.com/data");
|
|
169
|
+
|
|
170
|
+
expect(safeguards.settings().dailySpent).toBe("5");
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// MppPayClient.pay — error cases
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
describe("MppPayClient.pay — errors", () => {
|
|
179
|
+
it("throws on network mismatch", async () => {
|
|
180
|
+
const header = `Payment method="canton", amount="0.01", currency="USDCx", recipient="${GATEWAY_PARTY}", network="mainnet"`;
|
|
181
|
+
mockFetch.mockResolvedValueOnce(
|
|
182
|
+
new Response("", { status: 402, headers: { "WWW-Authenticate": header } }),
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
const client = new MppPayClient(mockUsdcx(), new SafeguardManager(), AGENT_PARTY, "testnet");
|
|
186
|
+
|
|
187
|
+
await expect(client.pay("https://api.example.com/data")).rejects.toThrow(
|
|
188
|
+
"Network mismatch",
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("throws when price exceeds maxPrice", async () => {
|
|
193
|
+
mockFetch.mockResolvedValueOnce(make402Response("10.00"));
|
|
194
|
+
|
|
195
|
+
const client = new MppPayClient(mockUsdcx(), new SafeguardManager(), AGENT_PARTY, "testnet");
|
|
196
|
+
|
|
197
|
+
await expect(
|
|
198
|
+
client.pay("https://api.example.com/data", { maxPrice: "5.00" }),
|
|
199
|
+
).rejects.toThrow("exceeds maxPrice");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("throws when safeguards block payment", async () => {
|
|
203
|
+
mockFetch.mockResolvedValueOnce(make402Response("0.01"));
|
|
204
|
+
|
|
205
|
+
const safeguards = new SafeguardManager();
|
|
206
|
+
safeguards.lock("1234");
|
|
207
|
+
const client = new MppPayClient(mockUsdcx(), safeguards, AGENT_PARTY, "testnet");
|
|
208
|
+
|
|
209
|
+
await expect(client.pay("https://api.example.com/data")).rejects.toThrow(
|
|
210
|
+
"Safeguard rejected",
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("throws on 402 without valid challenge header", async () => {
|
|
215
|
+
mockFetch.mockResolvedValueOnce(
|
|
216
|
+
new Response("", { status: 402, headers: { "WWW-Authenticate": "Bearer realm=\"api\"" } }),
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const client = new MppPayClient(mockUsdcx(), new SafeguardManager(), AGENT_PARTY, "testnet");
|
|
220
|
+
|
|
221
|
+
await expect(client.pay("https://api.example.com/data")).rejects.toThrow(
|
|
222
|
+
"no valid Canton payment challenge",
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("throws on 402 without WWW-Authenticate header", async () => {
|
|
227
|
+
mockFetch.mockResolvedValueOnce(new Response("", { status: 402 }));
|
|
228
|
+
|
|
229
|
+
const client = new MppPayClient(mockUsdcx(), new SafeguardManager(), AGENT_PARTY, "testnet");
|
|
230
|
+
|
|
231
|
+
await expect(client.pay("https://api.example.com/data")).rejects.toThrow(
|
|
232
|
+
"no valid Canton payment challenge",
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("passes custom method and body to requests", async () => {
|
|
237
|
+
mockFetch.mockResolvedValueOnce(make402Response("0.01"));
|
|
238
|
+
mockFetch.mockResolvedValueOnce(new Response("ok", { status: 200 }));
|
|
239
|
+
|
|
240
|
+
const client = new MppPayClient(mockUsdcx(), new SafeguardManager(), AGENT_PARTY, "testnet");
|
|
241
|
+
|
|
242
|
+
await client.pay("https://api.example.com/data", {
|
|
243
|
+
method: "POST",
|
|
244
|
+
body: JSON.stringify({ prompt: "hello" }),
|
|
245
|
+
headers: { "Content-Type": "application/json" },
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// Both requests should use POST
|
|
249
|
+
expect(mockFetch.mock.calls[0][1]?.method).toBe("POST");
|
|
250
|
+
expect(mockFetch.mock.calls[1][1]?.method).toBe("POST");
|
|
251
|
+
|
|
252
|
+
// Retry should include both content-type and authorization
|
|
253
|
+
const retryHeaders = mockFetch.mock.calls[1][1]?.headers as Record<string, string>;
|
|
254
|
+
expect(retryHeaders["Content-Type"]).toBe("application/json");
|
|
255
|
+
expect(retryHeaders.Authorization).toMatch(/^Payment /);
|
|
256
|
+
});
|
|
257
|
+
});
|