@andamio/core 0.2.0 → 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,8 +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 Aiken hash_project_data function using
8
- * raw byte concatenation and little-endian integer encoding.
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
9
11
  *
10
12
  * @module @andamio/core/hashing
11
13
  */
@@ -33,11 +35,11 @@ export type NativeAsset = [
33
35
  /**
34
36
  * Task data structure matching the Aiken ProjectData type.
35
37
  *
36
- * Fields are concatenated in this order for hashing:
37
- * 1. project_content (UTF-8 bytes, NFC normalized)
38
- * 2. expiration_time (little-endian, minimal bytes)
39
- * 3. lovelace_amount (little-endian, minimal bytes)
40
- * 4. native_assets (raw concatenation of each asset's bytes)
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)
41
43
  */
42
44
  export interface TaskData {
43
45
  /** Task description (max 140 characters) */
@@ -76,13 +78,8 @@ export interface TaskData {
76
78
  * ```
77
79
  */
78
80
  export function computeTaskHash(task: TaskData): string {
79
- // Validate inputs
80
81
  validateTaskData(task);
81
-
82
- // Encode task as raw bytes matching Aiken format
83
- const bytes = encodeTaskAsRawBytes(task);
84
-
85
- // Hash with Blake2b-256
82
+ const bytes = encodeTaskAsPlutusData(task);
86
83
  return blake.blake2bHex(bytes, undefined, 32);
87
84
  }
88
85
 
@@ -111,41 +108,157 @@ export function isValidTaskHash(hash: string): boolean {
111
108
  }
112
109
 
113
110
  /**
114
- * Debug function to show the raw byte encoding of a task.
111
+ * Debug function to show the CBOR encoding of a task.
115
112
  * Useful for comparing against on-chain data.
116
113
  *
117
114
  * @param task - Task data object
118
- * @returns Hex string of the encoded data (before hashing)
115
+ * @returns Hex string of the CBOR-encoded Plutus Data (before hashing)
119
116
  */
120
117
  export function debugTaskBytes(task: TaskData): string {
121
118
  validateTaskData(task);
122
- const bytes = encodeTaskAsRawBytes(task);
119
+ const bytes = encodeTaskAsPlutusData(task);
123
120
  return uint8ArrayToHex(bytes);
124
121
  }
125
122
 
126
123
  // =============================================================================
127
- // Raw Byte Encoding (Internal) - Matches Aiken hash_project_data
124
+ // Plutus Data CBOR Encoding (Internal)
128
125
  // =============================================================================
129
126
 
130
127
  /**
131
- * Encode task data as raw bytes matching Aiken's hash_project_data format.
128
+ * Encode task data as Plutus Data matching Aiken/Haskell serialization.
129
+ *
130
+ * Format: tag(121) + indefinite-array + [content, deadline, lovelace, tokens] + break
132
131
  *
133
- * Format: project_content ++ int_to_bbs(deadline) ++ int_to_bbs(lovelace) ++ combine_flat_val(tokens)
132
+ * The key insight is that Haskell's `serialiseData . toBuiltinData` uses
133
+ * indefinite-length CBOR arrays (0x9f ... 0xff) for Plutus Data constructors.
134
134
  *
135
135
  * @internal
136
136
  */
137
- function encodeTaskAsRawBytes(task: TaskData): Uint8Array {
138
- // Normalize Unicode for consistent hashing
137
+ function encodeTaskAsPlutusData(task: TaskData): Uint8Array {
139
138
  const normalizedContent = task.project_content.normalize("NFC");
139
+ const contentBytes = new TextEncoder().encode(normalizedContent);
140
140
 
141
141
  return concatUint8Arrays([
142
- new TextEncoder().encode(normalizedContent),
143
- intToBytesLittleEndian(task.expiration_time),
144
- intToBytesLittleEndian(task.lovelace_amount),
145
- combineNativeAssets(task.native_assets),
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]),
146
154
  ]);
147
155
  }
148
156
 
157
+ /**
158
+ * Encode a list of native assets as Plutus Data.
159
+ *
160
+ * Each FlatValue is encoded as a Plutus Data constructor with:
161
+ * - PolicyId (ByteArray)
162
+ * - AssetName (ByteArray)
163
+ * - Quantity (Int)
164
+ *
165
+ * @internal
166
+ */
167
+ function encodeTokensList(assets: readonly NativeAsset[]): Uint8Array {
168
+ if (assets.length === 0) {
169
+ // Empty definite-length array
170
+ return new Uint8Array([0x80]);
171
+ }
172
+
173
+ // Encode as indefinite-length array of FlatValue constructors
174
+ const parts: Uint8Array[] = [new Uint8Array([0x9f])]; // indefinite array start
175
+
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
183
+ }
184
+
185
+ parts.push(new Uint8Array([0xff])); // break (end of list)
186
+ return concatUint8Arrays(parts);
187
+ }
188
+
189
+ /**
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).
197
+ *
198
+ * @internal
199
+ */
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
+ );
208
+ }
209
+
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
+ }
239
+
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");
257
+ }
258
+
259
+ return concatUint8Arrays([header, bytes]);
260
+ }
261
+
149
262
  /**
150
263
  * Validate TaskData before hashing.
151
264
  *
@@ -153,14 +266,12 @@ function encodeTaskAsRawBytes(task: TaskData): Uint8Array {
153
266
  * @internal
154
267
  */
155
268
  function validateTaskData(task: TaskData): void {
156
- // Validate project_content
157
269
  if (task.project_content.length > 140) {
158
270
  throw new Error(
159
271
  `project_content exceeds 140 characters (got ${task.project_content.length})`,
160
272
  );
161
273
  }
162
274
 
163
- // Validate numeric fields
164
275
  if (task.expiration_time < 0n) {
165
276
  throw new Error("expiration_time must be non-negative");
166
277
  }
@@ -168,7 +279,6 @@ function validateTaskData(task: TaskData): void {
168
279
  throw new Error("lovelace_amount must be non-negative");
169
280
  }
170
281
 
171
- // Validate native assets
172
282
  for (const [policyId, tokenName, quantity] of task.native_assets) {
173
283
  if (policyId.length !== 56) {
174
284
  throw new Error(
@@ -192,63 +302,9 @@ function validateTaskData(task: TaskData): void {
192
302
  }
193
303
  }
194
304
 
195
- /**
196
- * Convert a non-negative bigint to little-endian byte representation.
197
- * Matches Aiken's integer_to_bytearray(False, 0, int).
198
- *
199
- * - False = little-endian byte order
200
- * - 0 = minimal byte length (no zero-padding)
201
- *
202
- * @param n - Non-negative bigint to convert
203
- * @returns Uint8Array with little-endian byte representation
204
- * @internal
205
- */
206
- function intToBytesLittleEndian(n: bigint): Uint8Array {
207
- if (n < 0n) {
208
- throw new Error("Negative integers not supported");
209
- }
210
- if (n === 0n) {
211
- return new Uint8Array([0]);
212
- }
213
-
214
- const bytes: number[] = [];
215
- let remaining = n;
216
-
217
- while (remaining > 0n) {
218
- bytes.push(Number(remaining & 0xffn));
219
- remaining = remaining >> 8n;
220
- }
221
-
222
- return new Uint8Array(bytes);
223
- }
224
-
225
- /**
226
- * Combine native assets into raw bytes matching Aiken's combine_flat_val.
227
- * Format: policy_id ++ token_name ++ quantity for each asset.
228
- *
229
- * @param assets - Array of native assets
230
- * @returns Uint8Array of concatenated asset bytes
231
- * @internal
232
- */
233
- function combineNativeAssets(assets: readonly NativeAsset[]): Uint8Array {
234
- if (assets.length === 0) {
235
- return new Uint8Array([]);
236
- }
237
-
238
- const chunks: Uint8Array[] = [];
239
- for (const [policyId, tokenName, quantity] of assets) {
240
- chunks.push(hexToBytes(policyId));
241
- chunks.push(hexToBytes(tokenName));
242
- chunks.push(intToBytesLittleEndian(quantity));
243
- }
244
- return concatUint8Arrays(chunks);
245
- }
246
-
247
305
  /**
248
306
  * Convert hex string to Uint8Array.
249
307
  *
250
- * @param hex - Hexadecimal string (must be even length)
251
- * @returns Uint8Array of bytes
252
308
  * @internal
253
309
  */
254
310
  function hexToBytes(hex: string): Uint8Array {
@@ -256,14 +312,19 @@ function hexToBytes(hex: string): Uint8Array {
256
312
  return new Uint8Array([]);
257
313
  }
258
314
 
259
- // Validation already done in validateTaskData, but defensive check
260
315
  if (hex.length % 2 !== 0) {
261
316
  throw new Error(`Invalid hex string: odd length (${hex.length})`);
262
317
  }
263
318
 
264
319
  const bytes = new Uint8Array(hex.length / 2);
265
320
  for (let i = 0; i < bytes.length; i++) {
266
- bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
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;
267
328
  }
268
329
  return bytes;
269
330
  }