@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,333 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { SafeguardManager, type SafeguardConfig } from "../safeguards/manager.js";
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Setup / teardown
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
let tmpDir: string;
|
|
12
|
+
let safeguardsPath: string;
|
|
13
|
+
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
tmpDir = await mkdtemp(join(tmpdir(), "caypo-safeguards-test-"));
|
|
16
|
+
safeguardsPath = join(tmpDir, "safeguards.json");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
21
|
+
vi.restoreAllMocks();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function makeConfig(overrides?: Partial<SafeguardConfig>): SafeguardConfig {
|
|
25
|
+
return {
|
|
26
|
+
txLimit: "100",
|
|
27
|
+
dailyLimit: "1000",
|
|
28
|
+
locked: false,
|
|
29
|
+
lockedPinHash: "",
|
|
30
|
+
dailySpent: "0",
|
|
31
|
+
lastResetDate: new Date().toISOString().slice(0, 10),
|
|
32
|
+
...overrides,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// SafeguardManager.settings
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
describe("SafeguardManager.settings", () => {
|
|
41
|
+
it("returns default settings when created without config", () => {
|
|
42
|
+
const mgr = new SafeguardManager(undefined, safeguardsPath);
|
|
43
|
+
const s = mgr.settings();
|
|
44
|
+
|
|
45
|
+
expect(s.txLimit).toBe("100");
|
|
46
|
+
expect(s.dailyLimit).toBe("1000");
|
|
47
|
+
expect(s.locked).toBe(false);
|
|
48
|
+
expect(s.dailySpent).toBe("0");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("returns a copy (mutations don't affect internal state)", () => {
|
|
52
|
+
const mgr = new SafeguardManager(undefined, safeguardsPath);
|
|
53
|
+
const s = mgr.settings();
|
|
54
|
+
s.txLimit = "999999";
|
|
55
|
+
|
|
56
|
+
expect(mgr.settings().txLimit).toBe("100");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// setTxLimit / setDailyLimit
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
describe("setTxLimit", () => {
|
|
65
|
+
it("updates the per-transaction limit", () => {
|
|
66
|
+
const mgr = new SafeguardManager(undefined, safeguardsPath);
|
|
67
|
+
mgr.setTxLimit("50");
|
|
68
|
+
expect(mgr.settings().txLimit).toBe("50");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("setDailyLimit", () => {
|
|
73
|
+
it("updates the daily limit", () => {
|
|
74
|
+
const mgr = new SafeguardManager(undefined, safeguardsPath);
|
|
75
|
+
mgr.setDailyLimit("500");
|
|
76
|
+
expect(mgr.settings().dailyLimit).toBe("500");
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// lock / unlock
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
describe("lock / unlock", () => {
|
|
85
|
+
it("lock sets locked to true", () => {
|
|
86
|
+
const mgr = new SafeguardManager(undefined, safeguardsPath);
|
|
87
|
+
mgr.lock("1234");
|
|
88
|
+
expect(mgr.settings().locked).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("unlock with correct PIN sets locked to false", () => {
|
|
92
|
+
const mgr = new SafeguardManager(undefined, safeguardsPath);
|
|
93
|
+
mgr.lock("1234");
|
|
94
|
+
mgr.unlock("1234");
|
|
95
|
+
expect(mgr.settings().locked).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("unlock with wrong PIN throws", () => {
|
|
99
|
+
const mgr = new SafeguardManager(undefined, safeguardsPath);
|
|
100
|
+
mgr.lock("1234");
|
|
101
|
+
expect(() => mgr.unlock("wrong")).toThrow("Invalid PIN");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("lock without PIN can be unlocked with any PIN", () => {
|
|
105
|
+
const mgr = new SafeguardManager(undefined, safeguardsPath);
|
|
106
|
+
mgr.lock();
|
|
107
|
+
mgr.unlock("anything");
|
|
108
|
+
expect(mgr.settings().locked).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// check — basic scenarios
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
describe("check — allowed", () => {
|
|
117
|
+
it("allows transaction within both limits", () => {
|
|
118
|
+
const mgr = new SafeguardManager(makeConfig(), safeguardsPath);
|
|
119
|
+
const result = mgr.check("50");
|
|
120
|
+
|
|
121
|
+
expect(result.allowed).toBe(true);
|
|
122
|
+
expect(result.reason).toBeUndefined();
|
|
123
|
+
expect(result.dailyRemaining).toBe("1000");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("allows transaction exactly at tx limit", () => {
|
|
127
|
+
const mgr = new SafeguardManager(makeConfig({ txLimit: "100" }), safeguardsPath);
|
|
128
|
+
const result = mgr.check("100");
|
|
129
|
+
expect(result.allowed).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("allows transaction exactly filling daily limit", () => {
|
|
133
|
+
const mgr = new SafeguardManager(
|
|
134
|
+
makeConfig({ dailyLimit: "100", dailySpent: "50" }),
|
|
135
|
+
safeguardsPath,
|
|
136
|
+
);
|
|
137
|
+
const result = mgr.check("50");
|
|
138
|
+
expect(result.allowed).toBe(true);
|
|
139
|
+
expect(result.dailyRemaining).toBe("50");
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// check — rejected scenarios
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
describe("check — locked", () => {
|
|
148
|
+
it("rejects when wallet is locked", () => {
|
|
149
|
+
const mgr = new SafeguardManager(makeConfig({ locked: true }), safeguardsPath);
|
|
150
|
+
const result = mgr.check("1");
|
|
151
|
+
|
|
152
|
+
expect(result.allowed).toBe(false);
|
|
153
|
+
expect(result.reason).toContain("locked");
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("check — exceeds tx limit", () => {
|
|
158
|
+
it("rejects when amount > txLimit", () => {
|
|
159
|
+
const mgr = new SafeguardManager(makeConfig({ txLimit: "10" }), safeguardsPath);
|
|
160
|
+
const result = mgr.check("10.01");
|
|
161
|
+
|
|
162
|
+
expect(result.allowed).toBe(false);
|
|
163
|
+
expect(result.reason).toContain("per-transaction limit");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("rejects large amount against small limit", () => {
|
|
167
|
+
const mgr = new SafeguardManager(makeConfig({ txLimit: "0.01" }), safeguardsPath);
|
|
168
|
+
const result = mgr.check("1");
|
|
169
|
+
|
|
170
|
+
expect(result.allowed).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("check — exceeds daily limit", () => {
|
|
175
|
+
it("rejects when dailySpent + amount > dailyLimit", () => {
|
|
176
|
+
const mgr = new SafeguardManager(
|
|
177
|
+
makeConfig({ dailyLimit: "100", dailySpent: "90" }),
|
|
178
|
+
safeguardsPath,
|
|
179
|
+
);
|
|
180
|
+
const result = mgr.check("20");
|
|
181
|
+
|
|
182
|
+
expect(result.allowed).toBe(false);
|
|
183
|
+
expect(result.reason).toContain("daily limit");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("rejects when daily budget is exhausted", () => {
|
|
187
|
+
const mgr = new SafeguardManager(
|
|
188
|
+
makeConfig({ dailyLimit: "100", dailySpent: "100" }),
|
|
189
|
+
safeguardsPath,
|
|
190
|
+
);
|
|
191
|
+
const result = mgr.check("0.01");
|
|
192
|
+
|
|
193
|
+
expect(result.allowed).toBe(false);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
// check — daily auto-reset
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
describe("check — daily auto-reset", () => {
|
|
202
|
+
it("resets dailySpent when date changes", () => {
|
|
203
|
+
const mgr = new SafeguardManager(
|
|
204
|
+
makeConfig({
|
|
205
|
+
dailyLimit: "100",
|
|
206
|
+
dailySpent: "99",
|
|
207
|
+
lastResetDate: "2025-01-01", // old date
|
|
208
|
+
}),
|
|
209
|
+
safeguardsPath,
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
// Should be reset since lastResetDate is in the past
|
|
213
|
+
const result = mgr.check("50");
|
|
214
|
+
expect(result.allowed).toBe(true);
|
|
215
|
+
expect(result.dailyRemaining).toBe("100");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("does not reset when date is current", () => {
|
|
219
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
220
|
+
const mgr = new SafeguardManager(
|
|
221
|
+
makeConfig({
|
|
222
|
+
dailyLimit: "100",
|
|
223
|
+
dailySpent: "90",
|
|
224
|
+
lastResetDate: today,
|
|
225
|
+
}),
|
|
226
|
+
safeguardsPath,
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
const result = mgr.check("50");
|
|
230
|
+
expect(result.allowed).toBe(false);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// recordSpend
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
describe("recordSpend", () => {
|
|
239
|
+
it("accumulates daily spending", () => {
|
|
240
|
+
const mgr = new SafeguardManager(makeConfig(), safeguardsPath);
|
|
241
|
+
|
|
242
|
+
mgr.recordSpend("10");
|
|
243
|
+
expect(mgr.settings().dailySpent).toBe("10");
|
|
244
|
+
|
|
245
|
+
mgr.recordSpend("5.5");
|
|
246
|
+
expect(mgr.settings().dailySpent).toBe("15.5");
|
|
247
|
+
|
|
248
|
+
mgr.recordSpend("0.01");
|
|
249
|
+
expect(mgr.settings().dailySpent).toBe("15.51");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("auto-resets before recording if date changed", () => {
|
|
253
|
+
const mgr = new SafeguardManager(
|
|
254
|
+
makeConfig({
|
|
255
|
+
dailySpent: "500",
|
|
256
|
+
lastResetDate: "2025-01-01",
|
|
257
|
+
}),
|
|
258
|
+
safeguardsPath,
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
mgr.recordSpend("10");
|
|
262
|
+
expect(mgr.settings().dailySpent).toBe("10");
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
// resetDaily
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
describe("resetDaily", () => {
|
|
271
|
+
it("resets dailySpent to 0 and updates lastResetDate", () => {
|
|
272
|
+
const mgr = new SafeguardManager(
|
|
273
|
+
makeConfig({ dailySpent: "500" }),
|
|
274
|
+
safeguardsPath,
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
mgr.resetDaily();
|
|
278
|
+
|
|
279
|
+
const s = mgr.settings();
|
|
280
|
+
expect(s.dailySpent).toBe("0");
|
|
281
|
+
expect(s.lastResetDate).toBe(new Date().toISOString().slice(0, 10));
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// SafeguardManager.load (from disk)
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
describe("SafeguardManager.load", () => {
|
|
290
|
+
it("loads config from file", async () => {
|
|
291
|
+
const config = makeConfig({ txLimit: "42", dailySpent: "17" });
|
|
292
|
+
await writeFile(safeguardsPath, JSON.stringify(config), "utf8");
|
|
293
|
+
|
|
294
|
+
const mgr = await SafeguardManager.load(safeguardsPath);
|
|
295
|
+
expect(mgr.settings().txLimit).toBe("42");
|
|
296
|
+
expect(mgr.settings().dailySpent).toBe("17");
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("returns defaults if file does not exist", async () => {
|
|
300
|
+
const mgr = await SafeguardManager.load(join(tmpDir, "nonexistent.json"));
|
|
301
|
+
expect(mgr.settings().txLimit).toBe("100");
|
|
302
|
+
expect(mgr.settings().dailyLimit).toBe("1000");
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
// Integration: check → recordSpend → check
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
describe("integration: check → spend → check", () => {
|
|
311
|
+
it("tracks spending across multiple transactions", () => {
|
|
312
|
+
const mgr = new SafeguardManager(
|
|
313
|
+
makeConfig({ txLimit: "50", dailyLimit: "100" }),
|
|
314
|
+
safeguardsPath,
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
// First tx: 40, allowed
|
|
318
|
+
expect(mgr.check("40").allowed).toBe(true);
|
|
319
|
+
mgr.recordSpend("40");
|
|
320
|
+
|
|
321
|
+
// Second tx: 40, allowed (40 + 40 = 80 < 100)
|
|
322
|
+
expect(mgr.check("40").allowed).toBe(true);
|
|
323
|
+
mgr.recordSpend("40");
|
|
324
|
+
|
|
325
|
+
// Third tx: 40, rejected (80 + 40 = 120 > 100)
|
|
326
|
+
const result = mgr.check("40");
|
|
327
|
+
expect(result.allowed).toBe(false);
|
|
328
|
+
expect(result.dailyRemaining).toBe("20");
|
|
329
|
+
|
|
330
|
+
// But 20 is still allowed
|
|
331
|
+
expect(mgr.check("20").allowed).toBe(true);
|
|
332
|
+
});
|
|
333
|
+
});
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { CantonClient } from "../canton/client.js";
|
|
3
|
+
import { InsufficientBalanceError, selectHoldings, type USDCxHolding } from "../canton/holdings.js";
|
|
4
|
+
import {
|
|
5
|
+
TRANSFER_FACTORY_TEMPLATE_ID,
|
|
6
|
+
USDCX_HOLDING_TEMPLATE_ID,
|
|
7
|
+
USDCX_INSTRUMENT_ID,
|
|
8
|
+
USDCxService,
|
|
9
|
+
} from "../canton/usdcx.js";
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Mock CantonClient
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
function mockClient(overrides?: Partial<CantonClient>): CantonClient {
|
|
16
|
+
return {
|
|
17
|
+
getLedgerEnd: vi.fn().mockResolvedValue(42),
|
|
18
|
+
queryActiveContracts: vi.fn().mockResolvedValue([]),
|
|
19
|
+
submitAndWait: vi.fn().mockResolvedValue({ updateId: "upd-1", completionOffset: 43 }),
|
|
20
|
+
submitAndWaitForTransaction: vi.fn(),
|
|
21
|
+
getTransactionById: vi.fn(),
|
|
22
|
+
allocateParty: vi.fn(),
|
|
23
|
+
listParties: vi.fn(),
|
|
24
|
+
isHealthy: vi.fn(),
|
|
25
|
+
...overrides,
|
|
26
|
+
} as unknown as CantonClient;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const PARTY = "Agent::1220abcdef1234567890";
|
|
30
|
+
|
|
31
|
+
function makeHoldingContract(contractId: string, amount: string) {
|
|
32
|
+
return {
|
|
33
|
+
contractId,
|
|
34
|
+
templateId: USDCX_HOLDING_TEMPLATE_ID,
|
|
35
|
+
createArgument: { owner: PARTY, amount },
|
|
36
|
+
createdAt: "",
|
|
37
|
+
signatories: [PARTY],
|
|
38
|
+
observers: [],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// USDCxService.getHoldings
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
describe("USDCxService.getHoldings", () => {
|
|
47
|
+
it("queries active contracts with correct filter and parses holdings", async () => {
|
|
48
|
+
const queryFn = vi.fn().mockResolvedValue([
|
|
49
|
+
makeHoldingContract("cid-1", "5.000000"),
|
|
50
|
+
makeHoldingContract("cid-2", "3.500000"),
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
const client = mockClient({ queryActiveContracts: queryFn });
|
|
54
|
+
const service = new USDCxService(client, PARTY);
|
|
55
|
+
|
|
56
|
+
const holdings = await service.getHoldings();
|
|
57
|
+
|
|
58
|
+
expect(holdings).toHaveLength(2);
|
|
59
|
+
expect(holdings[0]).toEqual({
|
|
60
|
+
contractId: "cid-1",
|
|
61
|
+
owner: PARTY,
|
|
62
|
+
amount: "5.000000",
|
|
63
|
+
templateId: USDCX_HOLDING_TEMPLATE_ID,
|
|
64
|
+
});
|
|
65
|
+
expect(holdings[1].amount).toBe("3.500000");
|
|
66
|
+
|
|
67
|
+
// Verify the query was made with correct filter
|
|
68
|
+
expect(queryFn).toHaveBeenCalledWith({
|
|
69
|
+
filtersByParty: {
|
|
70
|
+
[PARTY]: {
|
|
71
|
+
cumulative: [
|
|
72
|
+
{
|
|
73
|
+
identifierFilter: {
|
|
74
|
+
TemplateFilter: {
|
|
75
|
+
value: { templateId: USDCX_HOLDING_TEMPLATE_ID },
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
activeAtOffset: 42,
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("returns empty array when no holdings", async () => {
|
|
87
|
+
const client = mockClient();
|
|
88
|
+
const service = new USDCxService(client, PARTY);
|
|
89
|
+
|
|
90
|
+
const holdings = await service.getHoldings();
|
|
91
|
+
expect(holdings).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// USDCxService.getBalance
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
describe("USDCxService.getBalance", () => {
|
|
100
|
+
it("sums all holding amounts", async () => {
|
|
101
|
+
const client = mockClient({
|
|
102
|
+
queryActiveContracts: vi.fn().mockResolvedValue([
|
|
103
|
+
makeHoldingContract("cid-1", "5.000000"),
|
|
104
|
+
makeHoldingContract("cid-2", "3.500000"),
|
|
105
|
+
makeHoldingContract("cid-3", "1.500000"),
|
|
106
|
+
]),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const service = new USDCxService(client, PARTY);
|
|
110
|
+
const balance = await service.getBalance();
|
|
111
|
+
|
|
112
|
+
expect(balance).toBe("10");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("returns '0' when no holdings", async () => {
|
|
116
|
+
const client = mockClient();
|
|
117
|
+
const service = new USDCxService(client, PARTY);
|
|
118
|
+
|
|
119
|
+
const balance = await service.getBalance();
|
|
120
|
+
expect(balance).toBe("0");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("handles single holding", async () => {
|
|
124
|
+
const client = mockClient({
|
|
125
|
+
queryActiveContracts: vi.fn().mockResolvedValue([
|
|
126
|
+
makeHoldingContract("cid-1", "42.123456"),
|
|
127
|
+
]),
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const service = new USDCxService(client, PARTY);
|
|
131
|
+
const balance = await service.getBalance();
|
|
132
|
+
|
|
133
|
+
expect(balance).toBe("42.123456");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("handles very small amounts", async () => {
|
|
137
|
+
const client = mockClient({
|
|
138
|
+
queryActiveContracts: vi.fn().mockResolvedValue([
|
|
139
|
+
makeHoldingContract("cid-1", "0.000001"),
|
|
140
|
+
makeHoldingContract("cid-2", "0.000002"),
|
|
141
|
+
]),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const service = new USDCxService(client, PARTY);
|
|
145
|
+
const balance = await service.getBalance();
|
|
146
|
+
|
|
147
|
+
expect(balance).toBe("0.000003");
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// USDCxService.transfer
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
describe("USDCxService.transfer", () => {
|
|
156
|
+
it("builds correct ExerciseCommand and submits", async () => {
|
|
157
|
+
const submitFn = vi.fn().mockResolvedValue({ updateId: "upd-tx", completionOffset: 50 });
|
|
158
|
+
const client = mockClient({
|
|
159
|
+
queryActiveContracts: vi.fn().mockResolvedValue([
|
|
160
|
+
makeHoldingContract("cid-100", "10.000000"),
|
|
161
|
+
]),
|
|
162
|
+
submitAndWait: submitFn,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const service = new USDCxService(client, PARTY);
|
|
166
|
+
const result = await service.transfer({
|
|
167
|
+
recipient: "Gateway::1220ffff",
|
|
168
|
+
amount: "5.0",
|
|
169
|
+
commandId: "cmd-fixed",
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(result.updateId).toBe("upd-tx");
|
|
173
|
+
expect(result.completionOffset).toBe(50);
|
|
174
|
+
expect(result.commandId).toBe("cmd-fixed");
|
|
175
|
+
|
|
176
|
+
// Verify the command structure
|
|
177
|
+
const submitCall = submitFn.mock.calls[0][0];
|
|
178
|
+
expect(submitCall.commandId).toBe("cmd-fixed");
|
|
179
|
+
expect(submitCall.actAs).toEqual([PARTY]);
|
|
180
|
+
expect(submitCall.readAs).toEqual([PARTY]);
|
|
181
|
+
|
|
182
|
+
const cmd = submitCall.commands[0].ExerciseCommand;
|
|
183
|
+
expect(cmd.templateId).toBe(TRANSFER_FACTORY_TEMPLATE_ID);
|
|
184
|
+
expect(cmd.choice).toBe("TransferFactory_Transfer");
|
|
185
|
+
expect(cmd.choiceArgument.sender).toBe(PARTY);
|
|
186
|
+
expect(cmd.choiceArgument.receiver).toBe("Gateway::1220ffff");
|
|
187
|
+
expect(cmd.choiceArgument.amount).toBe("5.0000000000");
|
|
188
|
+
expect(cmd.choiceArgument.instrumentId).toBe(USDCX_INSTRUMENT_ID);
|
|
189
|
+
expect(cmd.choiceArgument.inputHoldingCids).toEqual(["cid-100"]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("auto-generates commandId when not provided", async () => {
|
|
193
|
+
const submitFn = vi.fn().mockResolvedValue({ updateId: "upd-auto", completionOffset: 1 });
|
|
194
|
+
const client = mockClient({
|
|
195
|
+
queryActiveContracts: vi.fn().mockResolvedValue([
|
|
196
|
+
makeHoldingContract("cid-1", "100"),
|
|
197
|
+
]),
|
|
198
|
+
submitAndWait: submitFn,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const service = new USDCxService(client, PARTY);
|
|
202
|
+
const result = await service.transfer({
|
|
203
|
+
recipient: "Bob::1220aaaa",
|
|
204
|
+
amount: "1",
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// commandId should be a UUID
|
|
208
|
+
expect(result.commandId).toMatch(
|
|
209
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("throws InsufficientBalanceError when balance is too low", async () => {
|
|
214
|
+
const client = mockClient({
|
|
215
|
+
queryActiveContracts: vi.fn().mockResolvedValue([
|
|
216
|
+
makeHoldingContract("cid-1", "1.000000"),
|
|
217
|
+
]),
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const service = new USDCxService(client, PARTY);
|
|
221
|
+
|
|
222
|
+
await expect(
|
|
223
|
+
service.transfer({ recipient: "Bob::1220", amount: "5.0" }),
|
|
224
|
+
).rejects.toThrow(InsufficientBalanceError);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("selects multiple holdings for merge-then-transfer", async () => {
|
|
228
|
+
const submitFn = vi.fn().mockResolvedValue({ updateId: "upd-merge", completionOffset: 60 });
|
|
229
|
+
const client = mockClient({
|
|
230
|
+
queryActiveContracts: vi.fn().mockResolvedValue([
|
|
231
|
+
makeHoldingContract("cid-a", "3.000000"),
|
|
232
|
+
makeHoldingContract("cid-b", "4.000000"),
|
|
233
|
+
]),
|
|
234
|
+
submitAndWait: submitFn,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const service = new USDCxService(client, PARTY);
|
|
238
|
+
const result = await service.transfer({
|
|
239
|
+
recipient: "Charlie::1220cccc",
|
|
240
|
+
amount: "6.0",
|
|
241
|
+
commandId: "cmd-multi",
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
expect(result.updateId).toBe("upd-merge");
|
|
245
|
+
|
|
246
|
+
// Both holdings should be in inputHoldingCids
|
|
247
|
+
const cmd = submitFn.mock.calls[0][0].commands[0].ExerciseCommand;
|
|
248
|
+
expect(cmd.choiceArgument.inputHoldingCids).toHaveLength(2);
|
|
249
|
+
expect(cmd.choiceArgument.inputHoldingCids).toContain("cid-a");
|
|
250
|
+
expect(cmd.choiceArgument.inputHoldingCids).toContain("cid-b");
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// USDCxService.mergeHoldings
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
describe("USDCxService.mergeHoldings", () => {
|
|
259
|
+
it("submits merge command with correct structure", async () => {
|
|
260
|
+
const submitFn = vi.fn().mockResolvedValue({ updateId: "upd-merge", completionOffset: 70 });
|
|
261
|
+
const client = mockClient({ submitAndWait: submitFn });
|
|
262
|
+
|
|
263
|
+
const service = new USDCxService(client, PARTY);
|
|
264
|
+
const commandId = await service.mergeHoldings(["cid-1", "cid-2", "cid-3"]);
|
|
265
|
+
|
|
266
|
+
expect(commandId).toMatch(/^[0-9a-f]{8}-/);
|
|
267
|
+
|
|
268
|
+
const call = submitFn.mock.calls[0][0];
|
|
269
|
+
const cmd = call.commands[0].ExerciseCommand;
|
|
270
|
+
expect(cmd.templateId).toBe(USDCX_HOLDING_TEMPLATE_ID);
|
|
271
|
+
expect(cmd.contractId).toBe("cid-1");
|
|
272
|
+
expect(cmd.choice).toBe("Merge");
|
|
273
|
+
expect(cmd.choiceArgument.holdingCids).toEqual(["cid-2", "cid-3"]);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("throws if fewer than 2 holdings", async () => {
|
|
277
|
+
const client = mockClient();
|
|
278
|
+
const service = new USDCxService(client, PARTY);
|
|
279
|
+
|
|
280
|
+
await expect(service.mergeHoldings(["cid-1"])).rejects.toThrow(
|
|
281
|
+
"Need at least 2 holdings to merge",
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
await expect(service.mergeHoldings([])).rejects.toThrow(
|
|
285
|
+
"Need at least 2 holdings to merge",
|
|
286
|
+
);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
// selectHoldings (unit tests)
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
describe("selectHoldings", () => {
|
|
295
|
+
function h(id: string, amount: string): USDCxHolding {
|
|
296
|
+
return { contractId: id, owner: PARTY, amount, templateId: "t" };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
it("selects single exact match", () => {
|
|
300
|
+
const result = selectHoldings([h("a", "5"), h("b", "10")], "5");
|
|
301
|
+
expect(result.type).toBe("single");
|
|
302
|
+
expect(result.contractIds).toEqual(["a"]);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("selects smallest sufficient single holding", () => {
|
|
306
|
+
const result = selectHoldings(
|
|
307
|
+
[h("a", "100"), h("b", "10"), h("c", "7")],
|
|
308
|
+
"5",
|
|
309
|
+
);
|
|
310
|
+
// Should pick "c" (7) — smallest that covers 5
|
|
311
|
+
expect(result.type).toBe("single");
|
|
312
|
+
expect(result.contractIds).toEqual(["c"]);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("accumulates multiple when no single is sufficient", () => {
|
|
316
|
+
const result = selectHoldings(
|
|
317
|
+
[h("a", "3"), h("b", "4"), h("c", "2")],
|
|
318
|
+
"8",
|
|
319
|
+
);
|
|
320
|
+
expect(result.type).toBe("merge-then-transfer");
|
|
321
|
+
expect(result.contractIds).toHaveLength(3);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("accumulates in descending order (greedy)", () => {
|
|
325
|
+
const result = selectHoldings(
|
|
326
|
+
[h("a", "1"), h("b", "5"), h("c", "3")],
|
|
327
|
+
"7",
|
|
328
|
+
);
|
|
329
|
+
expect(result.type).toBe("merge-then-transfer");
|
|
330
|
+
// descending: b(5), c(3) => 8 >= 7
|
|
331
|
+
expect(result.contractIds).toEqual(["b", "c"]);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("throws InsufficientBalanceError when total < required", () => {
|
|
335
|
+
expect(() =>
|
|
336
|
+
selectHoldings([h("a", "1"), h("b", "2")], "10"),
|
|
337
|
+
).toThrow(InsufficientBalanceError);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("throws InsufficientBalanceError for empty holdings", () => {
|
|
341
|
+
expect(() => selectHoldings([], "1")).toThrow(InsufficientBalanceError);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("InsufficientBalanceError has available and required fields", () => {
|
|
345
|
+
try {
|
|
346
|
+
selectHoldings([h("a", "2.5"), h("b", "1.5")], "10");
|
|
347
|
+
expect.fail("should throw");
|
|
348
|
+
} catch (err) {
|
|
349
|
+
expect(err).toBeInstanceOf(InsufficientBalanceError);
|
|
350
|
+
const e = err as InsufficientBalanceError;
|
|
351
|
+
expect(e.available).toBe("4");
|
|
352
|
+
expect(e.required).toBe("10");
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("handles decimal amounts", () => {
|
|
357
|
+
const result = selectHoldings(
|
|
358
|
+
[h("a", "0.5"), h("b", "0.3"), h("c", "0.4")],
|
|
359
|
+
"0.6",
|
|
360
|
+
);
|
|
361
|
+
// descending: a(0.5), c(0.4) => 0.9 >= 0.6 but a alone is not enough (0.5 < 0.6)
|
|
362
|
+
expect(result.type).toBe("merge-then-transfer");
|
|
363
|
+
expect(result.contractIds).toEqual(["a", "c"]);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("prefers single holding over merge", () => {
|
|
367
|
+
const result = selectHoldings(
|
|
368
|
+
[h("a", "5"), h("b", "5"), h("c", "10")],
|
|
369
|
+
"10",
|
|
370
|
+
);
|
|
371
|
+
expect(result.type).toBe("single");
|
|
372
|
+
expect(result.contractIds).toEqual(["c"]);
|
|
373
|
+
});
|
|
374
|
+
});
|