@andamio/core 0.1.1 → 0.3.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,473 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ computeTaskHash,
4
+ verifyTaskHash,
5
+ isValidTaskHash,
6
+ debugTaskBytes,
7
+ } from "./task-hash";
8
+ import type { TaskData, NativeAsset } from "./task-hash";
9
+
10
+ describe("computeTaskHash", () => {
11
+ // All 7 on-chain test vectors from project 490e6da6be3dbfae3baa8431351dc148dd8bdebc62e2dd7772675e76
12
+ // All have expiration_time: 1782792000000n and native_assets: []
13
+ const ON_CHAIN_VECTORS: Array<{
14
+ title: string;
15
+ lovelace: bigint;
16
+ expected_hash: string;
17
+ }> = [
18
+ {
19
+ title: "Introduce Yourself",
20
+ lovelace: 5000000n,
21
+ expected_hash:
22
+ "b1e5c9234e8a4481da7cb3fb525fc54430f8df127ab9f10464ddc8a4e7560614",
23
+ },
24
+ {
25
+ title: "Review the Docs",
26
+ lovelace: 8000000n,
27
+ expected_hash:
28
+ "9d113eafdbe599d624c1ae3e545083e3ec7a053e14ebb6cb730eb3fb59eb3363",
29
+ },
30
+ {
31
+ title: "Find a Typo",
32
+ lovelace: 5000000n,
33
+ expected_hash:
34
+ "c79b778c46a26148c5a33ad669b3452ecf0263539270513003abef73c5858cb2",
35
+ },
36
+ {
37
+ title: "Attend a Sync Call",
38
+ lovelace: 8000000n,
39
+ expected_hash:
40
+ "090391c308370ca1846e6cf39641dc975e8b2f3e370fb812f61bebcacb6902aa",
41
+ },
42
+ {
43
+ title: "Test a Feature",
44
+ lovelace: 10000000n,
45
+ expected_hash:
46
+ "801eae4957a456034025e61f23f2a508eb8a6e15f8d55edb239712033ff06d18",
47
+ },
48
+ {
49
+ title: "Write a How-To",
50
+ lovelace: 15000000n,
51
+ expected_hash:
52
+ "b6ac09b203c7a81d1cd819bc6064eec2f713e64a6cc5a2fac16f864fcfeee949",
53
+ },
54
+ {
55
+ title: "Propose an Improvement",
56
+ lovelace: 5000000n,
57
+ expected_hash:
58
+ "eb14effb2a81bece91708a2fb2478bd36711b06804f1fa5fca049d0a9192c784",
59
+ },
60
+ ];
61
+
62
+ const DEADLINE = 1782792000000n;
63
+
64
+ describe("on-chain compatibility", () => {
65
+ for (const vector of ON_CHAIN_VECTORS) {
66
+ it(`matches on-chain hash for "${vector.title}"`, () => {
67
+ const task: TaskData = {
68
+ project_content: vector.title,
69
+ expiration_time: DEADLINE,
70
+ lovelace_amount: vector.lovelace,
71
+ native_assets: [],
72
+ };
73
+ const hash = computeTaskHash(task);
74
+ expect(hash).toBe(vector.expected_hash);
75
+ });
76
+ }
77
+ });
78
+
79
+ describe("CBOR encoding", () => {
80
+ it("uses Plutus Data Constructor 0 (tag 121)", () => {
81
+ const task: TaskData = {
82
+ project_content: "Test",
83
+ expiration_time: 1n,
84
+ lovelace_amount: 1n,
85
+ native_assets: [],
86
+ };
87
+ const bytes = debugTaskBytes(task);
88
+ // Should start with d8 79 (tag 121) and 9f (indefinite array)
89
+ expect(bytes.startsWith("d8799f")).toBe(true);
90
+ });
91
+
92
+ it("uses indefinite-length array encoding", () => {
93
+ const task: TaskData = {
94
+ project_content: "Test",
95
+ expiration_time: 1n,
96
+ lovelace_amount: 1n,
97
+ native_assets: [],
98
+ };
99
+ const bytes = debugTaskBytes(task);
100
+ // Should start with 9f (indefinite array) after tag and end with ff (break)
101
+ expect(bytes.slice(4, 6)).toBe("9f"); // after d879
102
+ expect(bytes.endsWith("ff")).toBe(true);
103
+ });
104
+
105
+ it("encodes empty tokens list as empty definite array (0x80)", () => {
106
+ const task: TaskData = {
107
+ project_content: "A",
108
+ expiration_time: 1n,
109
+ lovelace_amount: 1n,
110
+ native_assets: [],
111
+ };
112
+ const bytes = debugTaskBytes(task);
113
+ // Format: d8799f [content] [deadline] [lovelace] 80 ff
114
+ // The 80 (empty array) should be right before the final ff (break)
115
+ expect(bytes.slice(-4)).toBe("80ff");
116
+ });
117
+
118
+ it("encodes content as CBOR byte string", () => {
119
+ const task: TaskData = {
120
+ project_content: "Hi",
121
+ expiration_time: 1n,
122
+ lovelace_amount: 1n,
123
+ native_assets: [],
124
+ };
125
+ const bytes = debugTaskBytes(task);
126
+ // "Hi" = 2 bytes, so CBOR header is 0x42 (0x40 + 2), then 4869
127
+ expect(bytes).toContain("424869");
128
+ });
129
+
130
+ it("encodes integers as CBOR unsigned integers (big-endian)", () => {
131
+ const task: TaskData = {
132
+ project_content: "",
133
+ expiration_time: 0x12345678n,
134
+ lovelace_amount: 0n,
135
+ native_assets: [],
136
+ };
137
+ const bytes = debugTaskBytes(task);
138
+ // 0x12345678 as CBOR uint32: 1a 12345678 (big-endian, NOT little-endian)
139
+ expect(bytes).toContain("1a12345678");
140
+ });
141
+ });
142
+
143
+ describe("edge cases", () => {
144
+ it("handles zero lovelace amount", () => {
145
+ const task: TaskData = {
146
+ project_content: "Test",
147
+ expiration_time: 1700000000000n,
148
+ lovelace_amount: 0n,
149
+ native_assets: [],
150
+ };
151
+ const hash = computeTaskHash(task);
152
+ expect(hash).toHaveLength(64);
153
+ expect(hash).toMatch(/^[0-9a-f]{64}$/);
154
+ });
155
+
156
+ it("handles empty content", () => {
157
+ const task: TaskData = {
158
+ project_content: "",
159
+ expiration_time: 0n,
160
+ lovelace_amount: 0n,
161
+ native_assets: [],
162
+ };
163
+ const bytes = debugTaskBytes(task);
164
+ // Empty byte string is 0x40
165
+ expect(bytes).toContain("40");
166
+ });
167
+
168
+ it("handles empty token name", () => {
169
+ const task: TaskData = {
170
+ project_content: "Test",
171
+ expiration_time: 1n,
172
+ lovelace_amount: 1n,
173
+ native_assets: [["a".repeat(56), "", 100n]],
174
+ };
175
+ expect(() => computeTaskHash(task)).not.toThrow();
176
+ });
177
+
178
+ it("handles large bigint values beyond Number.MAX_SAFE_INTEGER", () => {
179
+ const task: TaskData = {
180
+ project_content: "Test",
181
+ expiration_time: BigInt(Number.MAX_SAFE_INTEGER) + 1n,
182
+ lovelace_amount: 9999999999999999999n,
183
+ native_assets: [],
184
+ };
185
+ expect(() => computeTaskHash(task)).not.toThrow();
186
+ expect(computeTaskHash(task)).toHaveLength(64);
187
+ });
188
+
189
+ it("normalizes Unicode strings (NFC)", () => {
190
+ // cafe with combining acute accent vs precomposed
191
+ const task1: TaskData = {
192
+ project_content: "cafe\u0301", // e + combining acute
193
+ expiration_time: 1n,
194
+ lovelace_amount: 1n,
195
+ native_assets: [],
196
+ };
197
+ const task2: TaskData = {
198
+ project_content: "caf\u00e9", // precomposed e
199
+ expiration_time: 1n,
200
+ lovelace_amount: 1n,
201
+ native_assets: [],
202
+ };
203
+ expect(computeTaskHash(task1)).toBe(computeTaskHash(task2));
204
+ });
205
+
206
+ it("handles multiple native assets in order", () => {
207
+ const policyId1 = "a".repeat(56);
208
+ const policyId2 = "b".repeat(56);
209
+ const task: TaskData = {
210
+ project_content: "",
211
+ expiration_time: 0n,
212
+ lovelace_amount: 0n,
213
+ native_assets: [
214
+ [policyId1, "01", 1n],
215
+ [policyId2, "02", 2n],
216
+ ],
217
+ };
218
+ const bytes = debugTaskBytes(task);
219
+ // Should contain policy IDs in order provided
220
+ const policyId1Hex = "aa".repeat(28);
221
+ const policyId2Hex = "bb".repeat(28);
222
+ expect(bytes).toContain(policyId1Hex);
223
+ expect(bytes).toContain(policyId2Hex);
224
+ expect(bytes.indexOf(policyId1Hex)).toBeLessThan(
225
+ bytes.indexOf(policyId2Hex),
226
+ );
227
+ });
228
+ });
229
+
230
+ describe("determinism", () => {
231
+ it("produces identical output for identical input", () => {
232
+ const task: TaskData = {
233
+ project_content: "Test",
234
+ expiration_time: 12345n,
235
+ lovelace_amount: 67890n,
236
+ native_assets: [],
237
+ };
238
+ expect(computeTaskHash(task)).toBe(computeTaskHash(task));
239
+ });
240
+
241
+ it("produces different hashes for different inputs", () => {
242
+ const task1: TaskData = {
243
+ project_content: "Test1",
244
+ expiration_time: 1n,
245
+ lovelace_amount: 1n,
246
+ native_assets: [],
247
+ };
248
+ const task2: TaskData = {
249
+ project_content: "Test2",
250
+ expiration_time: 1n,
251
+ lovelace_amount: 1n,
252
+ native_assets: [],
253
+ };
254
+ expect(computeTaskHash(task1)).not.toBe(computeTaskHash(task2));
255
+ });
256
+ });
257
+ });
258
+
259
+ describe("input validation", () => {
260
+ it("rejects project_content over 140 characters", () => {
261
+ const task: TaskData = {
262
+ project_content: "x".repeat(141),
263
+ expiration_time: 1n,
264
+ lovelace_amount: 1n,
265
+ native_assets: [],
266
+ };
267
+ expect(() => computeTaskHash(task)).toThrow(/exceeds 140 characters/);
268
+ });
269
+
270
+ it("accepts project_content at exactly 140 characters", () => {
271
+ const task: TaskData = {
272
+ project_content: "x".repeat(140),
273
+ expiration_time: 1n,
274
+ lovelace_amount: 1n,
275
+ native_assets: [],
276
+ };
277
+ expect(() => computeTaskHash(task)).not.toThrow();
278
+ });
279
+
280
+ it("rejects negative expiration_time", () => {
281
+ const task: TaskData = {
282
+ project_content: "Test",
283
+ expiration_time: -1n,
284
+ lovelace_amount: 1n,
285
+ native_assets: [],
286
+ };
287
+ expect(() => computeTaskHash(task)).toThrow(/non-negative/);
288
+ });
289
+
290
+ it("rejects negative lovelace_amount", () => {
291
+ const task: TaskData = {
292
+ project_content: "Test",
293
+ expiration_time: 1n,
294
+ lovelace_amount: -1n,
295
+ native_assets: [],
296
+ };
297
+ expect(() => computeTaskHash(task)).toThrow(/non-negative/);
298
+ });
299
+
300
+ it("rejects invalid policyId length (too short)", () => {
301
+ const task: TaskData = {
302
+ project_content: "Test",
303
+ expiration_time: 1n,
304
+ lovelace_amount: 1n,
305
+ native_assets: [["abc", "def0", 1n]],
306
+ };
307
+ expect(() => computeTaskHash(task)).toThrow(/policyId must be 56 hex chars/);
308
+ });
309
+
310
+ it("rejects invalid policyId length (too long)", () => {
311
+ const task: TaskData = {
312
+ project_content: "Test",
313
+ expiration_time: 1n,
314
+ lovelace_amount: 1n,
315
+ native_assets: [["a".repeat(58), "", 1n]],
316
+ };
317
+ expect(() => computeTaskHash(task)).toThrow(/policyId must be 56 hex chars/);
318
+ });
319
+
320
+ it("rejects invalid hex characters in policyId", () => {
321
+ const task: TaskData = {
322
+ project_content: "Test",
323
+ expiration_time: 1n,
324
+ lovelace_amount: 1n,
325
+ native_assets: [["g".repeat(56), "", 1n]],
326
+ };
327
+ expect(() => computeTaskHash(task)).toThrow(/invalid hex characters/);
328
+ });
329
+
330
+ it("rejects tokenName with odd length", () => {
331
+ const task: TaskData = {
332
+ project_content: "Test",
333
+ expiration_time: 1n,
334
+ lovelace_amount: 1n,
335
+ native_assets: [["a".repeat(56), "abc", 1n]], // odd length
336
+ };
337
+ expect(() => computeTaskHash(task)).toThrow(/even length/);
338
+ });
339
+
340
+ it("rejects tokenName over 64 characters", () => {
341
+ const task: TaskData = {
342
+ project_content: "Test",
343
+ expiration_time: 1n,
344
+ lovelace_amount: 1n,
345
+ native_assets: [["a".repeat(56), "a".repeat(66), 1n]],
346
+ };
347
+ expect(() => computeTaskHash(task)).toThrow(/0-64 hex chars/);
348
+ });
349
+
350
+ it("rejects invalid hex characters in tokenName", () => {
351
+ const task: TaskData = {
352
+ project_content: "Test",
353
+ expiration_time: 1n,
354
+ lovelace_amount: 1n,
355
+ native_assets: [["a".repeat(56), "gg", 1n]],
356
+ };
357
+ expect(() => computeTaskHash(task)).toThrow(/invalid hex characters/);
358
+ });
359
+
360
+ it("rejects negative asset quantity", () => {
361
+ const task: TaskData = {
362
+ project_content: "Test",
363
+ expiration_time: 1n,
364
+ lovelace_amount: 1n,
365
+ native_assets: [["a".repeat(56), "", -1n]],
366
+ };
367
+ expect(() => computeTaskHash(task)).toThrow(/non-negative/);
368
+ });
369
+ });
370
+
371
+ describe("verifyTaskHash", () => {
372
+ it("returns true for matching hash", () => {
373
+ const task: TaskData = {
374
+ project_content: "Test",
375
+ expiration_time: 1n,
376
+ lovelace_amount: 1n,
377
+ native_assets: [],
378
+ };
379
+ const hash = computeTaskHash(task);
380
+ expect(verifyTaskHash(task, hash)).toBe(true);
381
+ });
382
+
383
+ it("returns false for non-matching hash", () => {
384
+ const task: TaskData = {
385
+ project_content: "Test",
386
+ expiration_time: 1n,
387
+ lovelace_amount: 1n,
388
+ native_assets: [],
389
+ };
390
+ expect(verifyTaskHash(task, "0".repeat(64))).toBe(false);
391
+ });
392
+
393
+ it("handles case-insensitive comparison", () => {
394
+ const task: TaskData = {
395
+ project_content: "Test",
396
+ expiration_time: 1n,
397
+ lovelace_amount: 1n,
398
+ native_assets: [],
399
+ };
400
+ const hash = computeTaskHash(task);
401
+ expect(verifyTaskHash(task, hash.toUpperCase())).toBe(true);
402
+ });
403
+
404
+ it("verifies on-chain test vector", () => {
405
+ const task: TaskData = {
406
+ project_content: "Introduce Yourself",
407
+ expiration_time: 1782792000000n,
408
+ lovelace_amount: 5000000n,
409
+ native_assets: [],
410
+ };
411
+ expect(
412
+ verifyTaskHash(
413
+ task,
414
+ "b1e5c9234e8a4481da7cb3fb525fc54430f8df127ab9f10464ddc8a4e7560614",
415
+ ),
416
+ ).toBe(true);
417
+ });
418
+ });
419
+
420
+ describe("isValidTaskHash", () => {
421
+ it("validates correct lowercase hash", () => {
422
+ expect(isValidTaskHash("a".repeat(64))).toBe(true);
423
+ });
424
+
425
+ it("validates correct uppercase hash", () => {
426
+ expect(isValidTaskHash("A".repeat(64))).toBe(true);
427
+ });
428
+
429
+ it("validates correct mixed case hash", () => {
430
+ expect(isValidTaskHash("0123456789abcdefABCDEF".repeat(3).slice(0, 64))).toBe(true);
431
+ });
432
+
433
+ it("rejects hash that is too short", () => {
434
+ expect(isValidTaskHash("a".repeat(63))).toBe(false);
435
+ });
436
+
437
+ it("rejects hash that is too long", () => {
438
+ expect(isValidTaskHash("a".repeat(65))).toBe(false);
439
+ });
440
+
441
+ it("rejects hash with invalid characters", () => {
442
+ expect(isValidTaskHash("g".repeat(64))).toBe(false);
443
+ });
444
+
445
+ it("rejects empty string", () => {
446
+ expect(isValidTaskHash("")).toBe(false);
447
+ });
448
+ });
449
+
450
+ describe("debugTaskBytes", () => {
451
+ it("returns hex representation of CBOR-encoded Plutus Data", () => {
452
+ const task: TaskData = {
453
+ project_content: "Hi",
454
+ expiration_time: 1n,
455
+ lovelace_amount: 2n,
456
+ native_assets: [],
457
+ };
458
+ const bytes = debugTaskBytes(task);
459
+ // Should be Plutus Data: d879 9f 42 4869 01 02 80 ff
460
+ // tag 121, indef array, 2-byte string "Hi", int 1, int 2, empty array, break
461
+ expect(bytes).toBe("d8799f42486901028 0ff".replace(/ /g, ""));
462
+ });
463
+
464
+ it("validates input before encoding", () => {
465
+ const task: TaskData = {
466
+ project_content: "x".repeat(141),
467
+ expiration_time: 1n,
468
+ lovelace_amount: 1n,
469
+ native_assets: [],
470
+ };
471
+ expect(() => debugTaskBytes(task)).toThrow(/exceeds 140 characters/);
472
+ });
473
+ });