@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.
@@ -4,7 +4,10 @@
4
4
  * Computes the task token name (hash) from task data.
5
5
  * This hash is used as the task_hash on-chain (content-addressed identifier).
6
6
  *
7
- * The algorithm matches the on-chain Plutus validator serialization.
7
+ * The algorithm matches the on-chain Aiken/Haskell hash_project_data function:
8
+ * - Serialize task data as Plutus Data (CBOR with tag 121 for Constructor 0)
9
+ * - Use indefinite-length arrays for constructor fields
10
+ * - Hash with Blake2b-256
8
11
  *
9
12
  * @module @andamio/core/hashing
10
13
  */
@@ -12,39 +15,52 @@
12
15
  import blake from "blakejs";
13
16
 
14
17
  /**
15
- * Native asset in ListValue format: [policyId.tokenName, quantity]
18
+ * Native asset in Cardano format: [policyId, tokenName, quantity]
19
+ *
20
+ * @example
21
+ * ```typescript
22
+ * const asset: NativeAsset = [
23
+ * "abc123def456...".repeat(4), // 56 hex chars (28 bytes) - policy ID
24
+ * "746f6b656e", // hex-encoded token name
25
+ * 1000n // quantity as bigint
26
+ * ];
27
+ * ```
16
28
  */
17
- type NativeAsset = [string, number];
29
+ export type NativeAsset = [
30
+ policyId: string, // 56 hex chars (28 bytes)
31
+ tokenName: string, // hex encoded (0-64 chars / 0-32 bytes)
32
+ quantity: bigint, // arbitrary precision integer
33
+ ];
18
34
 
19
35
  /**
20
- * Task data structure matching the Atlas TX API ManageTasksTxRequest
36
+ * Task data structure matching the Aiken ProjectData type.
21
37
  *
22
- * Fields must be arranged in this specific order for hashing:
23
- * 1. project_content (string, max 140 chars)
24
- * 2. expiration_time (number, Unix timestamp in milliseconds)
25
- * 3. lovelace_amount (number)
26
- * 4. native_assets (array of [asset_class, quantity] tuples)
38
+ * Serialized as Plutus Data Constructor 0 (CBOR tag 121) with fields:
39
+ * 1. project_content (ByteArray - UTF-8 encoded, NFC normalized)
40
+ * 2. deadline (Int - milliseconds)
41
+ * 3. lovelace_am (Int - micro-ADA)
42
+ * 4. tokens (List<FlatValue> - native assets)
27
43
  */
28
44
  export interface TaskData {
45
+ /** Task description (max 140 characters) */
29
46
  project_content: string;
30
- expiration_time: number;
31
- lovelace_amount: number;
32
- native_assets: NativeAsset[];
47
+ /** Unix timestamp in milliseconds */
48
+ expiration_time: bigint;
49
+ /** Lovelace amount (micro-ADA) */
50
+ lovelace_amount: bigint;
51
+ /** Native assets attached to task */
52
+ native_assets: readonly NativeAsset[];
33
53
  }
34
54
 
35
- /**
36
- * Plutus chunk size for byte strings.
37
- */
38
- const PLUTUS_CHUNK_SIZE = 64;
39
-
40
55
  /**
41
56
  * Compute the task hash (token name / task_hash) from task data.
42
57
  *
43
- * This produces the same hash as the on-chain Plutus validator, allowing
44
- * clients to pre-compute or verify task hashes.
58
+ * This produces the same hash as the on-chain Aiken hash_project_data function,
59
+ * allowing clients to pre-compute or verify task hashes.
45
60
  *
46
61
  * @param task - Task data object
47
62
  * @returns 64-character hex string (256-bit Blake2b hash)
63
+ * @throws Error if task data validation fails
48
64
  *
49
65
  * @example
50
66
  * ```typescript
@@ -52,8 +68,8 @@ const PLUTUS_CHUNK_SIZE = 64;
52
68
  *
53
69
  * const task = {
54
70
  * project_content: "Open Task #1",
55
- * expiration_time: 1769027280000,
56
- * lovelace_amount: 15000000,
71
+ * expiration_time: 1769027280000n,
72
+ * lovelace_amount: 15000000n,
57
73
  * native_assets: []
58
74
  * };
59
75
  *
@@ -62,11 +78,9 @@ const PLUTUS_CHUNK_SIZE = 64;
62
78
  * ```
63
79
  */
64
80
  export function computeTaskHash(task: TaskData): string {
65
- // Serialize task data matching Plutus format
66
- const cborData = encodeTaskAsPlutusData(task);
67
-
68
- // Hash with Blake2b-256
69
- return blake.blake2bHex(cborData, undefined, 32);
81
+ validateTaskData(task);
82
+ const bytes = encodeTaskAsPlutusData(task);
83
+ return blake.blake2bHex(bytes, undefined, 32);
70
84
  }
71
85
 
72
86
  /**
@@ -98,190 +112,225 @@ export function isValidTaskHash(hash: string): boolean {
98
112
  * Useful for comparing against on-chain data.
99
113
  *
100
114
  * @param task - Task data object
101
- * @returns Hex string of the CBOR-encoded data (before hashing)
115
+ * @returns Hex string of the CBOR-encoded Plutus Data (before hashing)
102
116
  */
103
- export function debugTaskCBOR(task: TaskData): string {
104
- const cborData = encodeTaskAsPlutusData(task);
105
- return uint8ArrayToHex(cborData);
117
+ export function debugTaskBytes(task: TaskData): string {
118
+ validateTaskData(task);
119
+ const bytes = encodeTaskAsPlutusData(task);
120
+ return uint8ArrayToHex(bytes);
106
121
  }
107
122
 
108
123
  // =============================================================================
109
- // Plutus Data Encoding (Internal)
124
+ // Plutus Data CBOR Encoding (Internal)
110
125
  // =============================================================================
111
126
 
112
127
  /**
113
- * Encode task data matching Plutus serialiseData $ toBuiltinData format.
128
+ * Encode task data as Plutus Data matching Aiken/Haskell serialization.
114
129
  *
115
- * Plutus represents this as a constructor with fields in an INDEFINITE array:
116
- * Constr 0 [project_content, expiration_time, lovelace_amount, native_assets]
130
+ * Format: tag(121) + indefinite-array + [content, deadline, lovelace, tokens] + break
117
131
  *
118
- * IMPORTANT: Plutus uses indefinite-length arrays (0x9f...0xff) not definite (0x84).
132
+ * The key insight is that Haskell's `serialiseData . toBuiltinData` uses
133
+ * indefinite-length CBOR arrays (0x9f ... 0xff) for Plutus Data constructors.
119
134
  *
120
135
  * @internal
121
136
  */
122
137
  function encodeTaskAsPlutusData(task: TaskData): Uint8Array {
123
- const chunks: Uint8Array[] = [];
124
-
125
- // Plutus Constr 0 with indefinite array
126
- // CBOR tag 121 (0xd879) = Constr 0 in Plutus Data
127
- chunks.push(new Uint8Array([0xd8, 0x79])); // Tag 121 (Constr 0)
128
- chunks.push(new Uint8Array([0x9f])); // Start indefinite array
129
-
130
- // Field 1: project_content as BuiltinByteString
131
- chunks.push(encodePlutusBuiltinByteString(new TextEncoder().encode(task.project_content)));
132
-
133
- // Field 2: expiration_time as Integer
134
- chunks.push(encodePlutusInteger(task.expiration_time));
135
-
136
- // Field 3: lovelace_amount as Integer
137
- chunks.push(encodePlutusInteger(task.lovelace_amount));
138
-
139
- // Field 4: native_assets as List of pairs
140
- chunks.push(encodeNativeAssets(task.native_assets));
141
-
142
- // End indefinite array
143
- chunks.push(new Uint8Array([0xff])); // Break
144
-
145
- return concatUint8Arrays(chunks);
138
+ const normalizedContent = task.project_content.normalize("NFC");
139
+ const contentBytes = new TextEncoder().encode(normalizedContent);
140
+
141
+ return concatUint8Arrays([
142
+ // Tag 121 (Plutus Data Constructor 0) + indefinite array start
143
+ new Uint8Array([0xd8, 121, 0x9f]),
144
+ // Field 1: project_content (ByteArray)
145
+ encodeCborBytes(contentBytes),
146
+ // Field 2: deadline (Int)
147
+ encodeCborUint(task.expiration_time),
148
+ // Field 3: lovelace_am (Int)
149
+ encodeCborUint(task.lovelace_amount),
150
+ // Field 4: tokens (List<FlatValue>)
151
+ encodeTokensList(task.native_assets),
152
+ // Break (end of indefinite array)
153
+ new Uint8Array([0xff]),
154
+ ]);
146
155
  }
147
156
 
148
157
  /**
149
- * Encode native assets as Plutus List of (AssetClass, Integer) pairs.
158
+ * Encode a list of native assets as Plutus Data.
150
159
  *
151
- * Each asset is a pair: (policyId.tokenName, quantity)
152
- * In Plutus, this is: List [(ByteString, Integer)]
160
+ * Each FlatValue is encoded as a Plutus Data constructor with:
161
+ * - PolicyId (ByteArray)
162
+ * - AssetName (ByteArray)
163
+ * - Quantity (Int)
153
164
  *
154
165
  * @internal
155
166
  */
156
- function encodeNativeAssets(assets: NativeAsset[]): Uint8Array {
167
+ function encodeTokensList(assets: readonly NativeAsset[]): Uint8Array {
157
168
  if (assets.length === 0) {
158
- // Empty list: definite-length array of 0 elements
159
- return new Uint8Array([0x80]); // Array(0)
169
+ // Empty definite-length array
170
+ return new Uint8Array([0x80]);
160
171
  }
161
172
 
162
- const chunks: Uint8Array[] = [];
163
-
164
- // Start indefinite array
165
- chunks.push(new Uint8Array([0x9f]));
173
+ // Encode as indefinite-length array of FlatValue constructors
174
+ const parts: Uint8Array[] = [new Uint8Array([0x9f])]; // indefinite array start
166
175
 
167
- for (const [assetClass, quantity] of assets) {
168
- // Each asset is a 2-element array: [bytestring, integer]
169
- chunks.push(new Uint8Array([0x82])); // Array of 2 elements
170
- chunks.push(encodePlutusBuiltinByteString(new TextEncoder().encode(assetClass)));
171
- chunks.push(encodePlutusInteger(quantity));
176
+ for (const [policyId, tokenName, quantity] of assets) {
177
+ // Each FlatValue is Constructor 0 with 3 fields
178
+ parts.push(new Uint8Array([0xd8, 121, 0x9f])); // tag 121, indefinite array
179
+ parts.push(encodeCborBytes(hexToBytes(policyId)));
180
+ parts.push(encodeCborBytes(hexToBytes(tokenName)));
181
+ parts.push(encodeCborUint(quantity));
182
+ parts.push(new Uint8Array([0xff])); // break
172
183
  }
173
184
 
174
- // End indefinite array
175
- chunks.push(new Uint8Array([0xff]));
176
-
177
- return concatUint8Arrays(chunks);
185
+ parts.push(new Uint8Array([0xff])); // break (end of list)
186
+ return concatUint8Arrays(parts);
178
187
  }
179
188
 
180
189
  /**
181
- * Encode a byte buffer matching Plutus's stringToBuiltinByteString.
182
- *
183
- * - Strings <= 64 bytes: regular CBOR byte string
184
- * - Strings > 64 bytes: indefinite-length chunked byte string (64-byte chunks)
190
+ * Maximum value for CBOR uint64 encoding.
191
+ * @internal
192
+ */
193
+ const MAX_UINT64 = 18446744073709551615n; // 2^64 - 1
194
+
195
+ /**
196
+ * Encode CBOR unsigned integer (major type 0).
185
197
  *
186
198
  * @internal
187
199
  */
188
- function encodePlutusBuiltinByteString(buffer: Uint8Array): Uint8Array {
189
- if (buffer.length <= PLUTUS_CHUNK_SIZE) {
190
- return encodeCBORByteString(buffer);
200
+ function encodeCborUint(n: bigint): Uint8Array {
201
+ if (n < 0n) {
202
+ throw new Error("Negative integers not supported");
203
+ }
204
+ if (n > MAX_UINT64) {
205
+ throw new Error(
206
+ `Integer exceeds maximum CBOR uint64 value (got ${n}, max ${MAX_UINT64})`,
207
+ );
191
208
  }
192
209
 
193
- // Long string: use indefinite-length chunked encoding
194
- const chunks: Uint8Array[] = [];
195
- chunks.push(new Uint8Array([0x5f])); // Start indefinite byte string
210
+ if (n < 24n) {
211
+ return new Uint8Array([Number(n)]);
212
+ } else if (n < 256n) {
213
+ return new Uint8Array([0x18, Number(n)]);
214
+ } else if (n < 65536n) {
215
+ return new Uint8Array([0x19, Number(n >> 8n) & 0xff, Number(n) & 0xff]);
216
+ } else if (n < 4294967296n) {
217
+ return new Uint8Array([
218
+ 0x1a,
219
+ Number((n >> 24n) & 0xffn),
220
+ Number((n >> 16n) & 0xffn),
221
+ Number((n >> 8n) & 0xffn),
222
+ Number(n & 0xffn),
223
+ ]);
224
+ } else {
225
+ // 8-byte unsigned integer
226
+ return new Uint8Array([
227
+ 0x1b,
228
+ Number((n >> 56n) & 0xffn),
229
+ Number((n >> 48n) & 0xffn),
230
+ Number((n >> 40n) & 0xffn),
231
+ Number((n >> 32n) & 0xffn),
232
+ Number((n >> 24n) & 0xffn),
233
+ Number((n >> 16n) & 0xffn),
234
+ Number((n >> 8n) & 0xffn),
235
+ Number(n & 0xffn),
236
+ ]);
237
+ }
238
+ }
196
239
 
197
- for (let i = 0; i < buffer.length; i += PLUTUS_CHUNK_SIZE) {
198
- const chunk = buffer.subarray(i, Math.min(i + PLUTUS_CHUNK_SIZE, buffer.length));
199
- chunks.push(encodeCBORByteString(chunk));
240
+ /**
241
+ * Encode CBOR byte string (major type 2).
242
+ *
243
+ * @internal
244
+ */
245
+ function encodeCborBytes(bytes: Uint8Array): Uint8Array {
246
+ const len = bytes.length;
247
+ let header: Uint8Array;
248
+
249
+ if (len < 24) {
250
+ header = new Uint8Array([0x40 + len]);
251
+ } else if (len < 256) {
252
+ header = new Uint8Array([0x58, len]);
253
+ } else if (len < 65536) {
254
+ header = new Uint8Array([0x59, (len >> 8) & 0xff, len & 0xff]);
255
+ } else {
256
+ throw new Error("Byte string too long for CBOR encoding");
200
257
  }
201
258
 
202
- chunks.push(new Uint8Array([0xff])); // Break
203
- return concatUint8Arrays(chunks);
259
+ return concatUint8Arrays([header, bytes]);
204
260
  }
205
261
 
206
262
  /**
207
- * Encode a number as a CBOR integer (Plutus Integer).
263
+ * Validate TaskData before hashing.
208
264
  *
265
+ * @throws Error if validation fails
209
266
  * @internal
210
267
  */
211
- function encodePlutusInteger(n: number): Uint8Array {
212
- // CBOR integer encoding (major type 0 for positive, 1 for negative)
213
- if (n >= 0) {
214
- if (n <= 23) {
215
- return new Uint8Array([n]);
216
- } else if (n <= 0xff) {
217
- return new Uint8Array([0x18, n]);
218
- } else if (n <= 0xffff) {
219
- return new Uint8Array([0x19, n >> 8, n & 0xff]);
220
- } else if (n <= 0xffffffff) {
221
- return new Uint8Array([0x1a, (n >> 24) & 0xff, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]);
222
- } else {
223
- // 64-bit integer - use BigInt for precision
224
- const buf = new Uint8Array(9);
225
- buf[0] = 0x1b;
226
- const big = BigInt(n);
227
- const view = new DataView(buf.buffer);
228
- view.setBigUint64(1, big, false); // false = big-endian
229
- return buf;
268
+ function validateTaskData(task: TaskData): void {
269
+ if (task.project_content.length > 140) {
270
+ throw new Error(
271
+ `project_content exceeds 140 characters (got ${task.project_content.length})`,
272
+ );
273
+ }
274
+
275
+ if (task.expiration_time < 0n) {
276
+ throw new Error("expiration_time must be non-negative");
277
+ }
278
+ if (task.lovelace_amount < 0n) {
279
+ throw new Error("lovelace_amount must be non-negative");
280
+ }
281
+
282
+ for (const [policyId, tokenName, quantity] of task.native_assets) {
283
+ if (policyId.length !== 56) {
284
+ throw new Error(
285
+ `policyId must be 56 hex chars (got ${policyId.length})`,
286
+ );
230
287
  }
231
- } else {
232
- // Negative integers: major type 1, encode (-1 - n)
233
- const absVal = -1 - n;
234
- if (absVal <= 23) {
235
- return new Uint8Array([0x20 + absVal]);
236
- } else if (absVal <= 0xff) {
237
- return new Uint8Array([0x38, absVal]);
238
- } else if (absVal <= 0xffff) {
239
- return new Uint8Array([0x39, absVal >> 8, absVal & 0xff]);
240
- } else if (absVal <= 0xffffffff) {
241
- return new Uint8Array([0x3a, (absVal >> 24) & 0xff, (absVal >> 16) & 0xff, (absVal >> 8) & 0xff, absVal & 0xff]);
242
- } else {
243
- const buf = new Uint8Array(9);
244
- buf[0] = 0x3b;
245
- const big = BigInt(absVal);
246
- const view = new DataView(buf.buffer);
247
- view.setBigUint64(1, big, false);
248
- return buf;
288
+ if (!/^[0-9a-fA-F]*$/.test(policyId)) {
289
+ throw new Error("policyId contains invalid hex characters");
290
+ }
291
+ if (tokenName.length > 64 || tokenName.length % 2 !== 0) {
292
+ throw new Error(
293
+ `tokenName must be 0-64 hex chars with even length (got ${tokenName.length})`,
294
+ );
295
+ }
296
+ if (tokenName.length > 0 && !/^[0-9a-fA-F]*$/.test(tokenName)) {
297
+ throw new Error("tokenName contains invalid hex characters");
298
+ }
299
+ if (quantity < 0n) {
300
+ throw new Error("asset quantity must be non-negative");
249
301
  }
250
302
  }
251
303
  }
252
304
 
253
305
  /**
254
- * Encode a byte buffer as a CBOR byte string (definite length).
306
+ * Convert hex string to Uint8Array.
255
307
  *
256
308
  * @internal
257
309
  */
258
- function encodeCBORByteString(buffer: Uint8Array): Uint8Array {
259
- const len = buffer.length;
310
+ function hexToBytes(hex: string): Uint8Array {
311
+ if (hex.length === 0) {
312
+ return new Uint8Array([]);
313
+ }
260
314
 
261
- if (len <= 23) {
262
- const result = new Uint8Array(1 + len);
263
- result[0] = 0x40 + len;
264
- result.set(buffer, 1);
265
- return result;
266
- } else if (len <= 255) {
267
- const result = new Uint8Array(2 + len);
268
- result[0] = 0x58;
269
- result[1] = len;
270
- result.set(buffer, 2);
271
- return result;
272
- } else if (len <= 65535) {
273
- const result = new Uint8Array(3 + len);
274
- result[0] = 0x59;
275
- result[1] = len >> 8;
276
- result[2] = len & 0xff;
277
- result.set(buffer, 3);
278
- return result;
315
+ if (hex.length % 2 !== 0) {
316
+ throw new Error(`Invalid hex string: odd length (${hex.length})`);
317
+ }
318
+
319
+ const bytes = new Uint8Array(hex.length / 2);
320
+ for (let i = 0; i < bytes.length; i++) {
321
+ const byte = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
322
+ if (Number.isNaN(byte)) {
323
+ throw new Error(
324
+ `Invalid hex character at position ${i * 2}: "${hex.slice(i * 2, i * 2 + 2)}"`,
325
+ );
326
+ }
327
+ bytes[i] = byte;
279
328
  }
280
- throw new Error("Byte string too long for CBOR encoding");
329
+ return bytes;
281
330
  }
282
331
 
283
332
  /**
284
- * Concatenate multiple Uint8Arrays into one
333
+ * Concatenate multiple Uint8Arrays into one.
285
334
  *
286
335
  * @internal
287
336
  */
@@ -297,7 +346,7 @@ function concatUint8Arrays(arrays: Uint8Array[]): Uint8Array {
297
346
  }
298
347
 
299
348
  /**
300
- * Convert Uint8Array to hex string
349
+ * Convert Uint8Array to hex string.
301
350
  *
302
351
  * @internal
303
352
  */