@andamio/core 0.1.0 → 0.2.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,47 +4,61 @@
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 hash_project_data function using
8
+ * raw byte concatenation and little-endian integer encoding.
8
9
  *
9
10
  * @module @andamio/core/hashing
10
11
  */
11
12
 
12
- import * as blake from "blakejs";
13
+ import blake from "blakejs";
13
14
 
14
15
  /**
15
- * Native asset in ListValue format: [policyId.tokenName, quantity]
16
+ * Native asset in Cardano format: [policyId, tokenName, quantity]
17
+ *
18
+ * @example
19
+ * ```typescript
20
+ * const asset: NativeAsset = [
21
+ * "abc123def456...".repeat(4), // 56 hex chars (28 bytes) - policy ID
22
+ * "746f6b656e", // hex-encoded token name
23
+ * 1000n // quantity as bigint
24
+ * ];
25
+ * ```
16
26
  */
17
- type NativeAsset = [string, number];
27
+ export type NativeAsset = [
28
+ policyId: string, // 56 hex chars (28 bytes)
29
+ tokenName: string, // hex encoded (0-64 chars / 0-32 bytes)
30
+ quantity: bigint, // arbitrary precision integer
31
+ ];
18
32
 
19
33
  /**
20
- * Task data structure matching the Atlas TX API ManageTasksTxRequest
34
+ * Task data structure matching the Aiken ProjectData type.
21
35
  *
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)
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)
27
41
  */
28
42
  export interface TaskData {
43
+ /** Task description (max 140 characters) */
29
44
  project_content: string;
30
- expiration_time: number;
31
- lovelace_amount: number;
32
- native_assets: NativeAsset[];
45
+ /** Unix timestamp in milliseconds */
46
+ expiration_time: bigint;
47
+ /** Lovelace amount (micro-ADA) */
48
+ lovelace_amount: bigint;
49
+ /** Native assets attached to task */
50
+ native_assets: readonly NativeAsset[];
33
51
  }
34
52
 
35
- /**
36
- * Plutus chunk size for byte strings.
37
- */
38
- const PLUTUS_CHUNK_SIZE = 64;
39
-
40
53
  /**
41
54
  * Compute the task hash (token name / task_hash) from task data.
42
55
  *
43
- * This produces the same hash as the on-chain Plutus validator, allowing
44
- * clients to pre-compute or verify task hashes.
56
+ * This produces the same hash as the on-chain Aiken hash_project_data function,
57
+ * allowing clients to pre-compute or verify task hashes.
45
58
  *
46
59
  * @param task - Task data object
47
60
  * @returns 64-character hex string (256-bit Blake2b hash)
61
+ * @throws Error if task data validation fails
48
62
  *
49
63
  * @example
50
64
  * ```typescript
@@ -52,8 +66,8 @@ const PLUTUS_CHUNK_SIZE = 64;
52
66
  *
53
67
  * const task = {
54
68
  * project_content: "Open Task #1",
55
- * expiration_time: 1769027280000,
56
- * lovelace_amount: 15000000,
69
+ * expiration_time: 1769027280000n,
70
+ * lovelace_amount: 15000000n,
57
71
  * native_assets: []
58
72
  * };
59
73
  *
@@ -62,11 +76,14 @@ const PLUTUS_CHUNK_SIZE = 64;
62
76
  * ```
63
77
  */
64
78
  export function computeTaskHash(task: TaskData): string {
65
- // Serialize task data matching Plutus format
66
- const cborData = encodeTaskAsPlutusData(task);
79
+ // Validate inputs
80
+ validateTaskData(task);
81
+
82
+ // Encode task as raw bytes matching Aiken format
83
+ const bytes = encodeTaskAsRawBytes(task);
67
84
 
68
85
  // Hash with Blake2b-256
69
- return blake.blake2bHex(cborData, undefined, 32);
86
+ return blake.blake2bHex(bytes, undefined, 32);
70
87
  }
71
88
 
72
89
  /**
@@ -94,194 +111,165 @@ export function isValidTaskHash(hash: string): boolean {
94
111
  }
95
112
 
96
113
  /**
97
- * Debug function to show the CBOR encoding of a task.
114
+ * Debug function to show the raw byte encoding of a task.
98
115
  * Useful for comparing against on-chain data.
99
116
  *
100
117
  * @param task - Task data object
101
- * @returns Hex string of the CBOR-encoded data (before hashing)
118
+ * @returns Hex string of the encoded data (before hashing)
102
119
  */
103
- export function debugTaskCBOR(task: TaskData): string {
104
- const cborData = encodeTaskAsPlutusData(task);
105
- return uint8ArrayToHex(cborData);
120
+ export function debugTaskBytes(task: TaskData): string {
121
+ validateTaskData(task);
122
+ const bytes = encodeTaskAsRawBytes(task);
123
+ return uint8ArrayToHex(bytes);
106
124
  }
107
125
 
108
126
  // =============================================================================
109
- // Plutus Data Encoding (Internal)
127
+ // Raw Byte Encoding (Internal) - Matches Aiken hash_project_data
110
128
  // =============================================================================
111
129
 
112
130
  /**
113
- * Encode task data matching Plutus serialiseData $ toBuiltinData format.
114
- *
115
- * Plutus represents this as a constructor with fields in an INDEFINITE array:
116
- * Constr 0 [project_content, expiration_time, lovelace_amount, native_assets]
131
+ * Encode task data as raw bytes matching Aiken's hash_project_data format.
117
132
  *
118
- * IMPORTANT: Plutus uses indefinite-length arrays (0x9f...0xff) not definite (0x84).
133
+ * Format: project_content ++ int_to_bbs(deadline) ++ int_to_bbs(lovelace) ++ combine_flat_val(tokens)
119
134
  *
120
135
  * @internal
121
136
  */
122
- 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);
137
+ function encodeTaskAsRawBytes(task: TaskData): Uint8Array {
138
+ // Normalize Unicode for consistent hashing
139
+ const normalizedContent = task.project_content.normalize("NFC");
140
+
141
+ return concatUint8Arrays([
142
+ new TextEncoder().encode(normalizedContent),
143
+ intToBytesLittleEndian(task.expiration_time),
144
+ intToBytesLittleEndian(task.lovelace_amount),
145
+ combineNativeAssets(task.native_assets),
146
+ ]);
146
147
  }
147
148
 
148
149
  /**
149
- * Encode native assets as Plutus List of (AssetClass, Integer) pairs.
150
- *
151
- * Each asset is a pair: (policyId.tokenName, quantity)
152
- * In Plutus, this is: List [(ByteString, Integer)]
150
+ * Validate TaskData before hashing.
153
151
  *
152
+ * @throws Error if validation fails
154
153
  * @internal
155
154
  */
156
- function encodeNativeAssets(assets: NativeAsset[]): Uint8Array {
157
- if (assets.length === 0) {
158
- // Empty list: definite-length array of 0 elements
159
- return new Uint8Array([0x80]); // Array(0)
155
+ function validateTaskData(task: TaskData): void {
156
+ // Validate project_content
157
+ if (task.project_content.length > 140) {
158
+ throw new Error(
159
+ `project_content exceeds 140 characters (got ${task.project_content.length})`,
160
+ );
160
161
  }
161
162
 
162
- const chunks: Uint8Array[] = [];
163
-
164
- // Start indefinite array
165
- chunks.push(new Uint8Array([0x9f]));
166
-
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));
163
+ // Validate numeric fields
164
+ if (task.expiration_time < 0n) {
165
+ throw new Error("expiration_time must be non-negative");
166
+ }
167
+ if (task.lovelace_amount < 0n) {
168
+ throw new Error("lovelace_amount must be non-negative");
172
169
  }
173
170
 
174
- // End indefinite array
175
- chunks.push(new Uint8Array([0xff]));
176
-
177
- return concatUint8Arrays(chunks);
171
+ // Validate native assets
172
+ for (const [policyId, tokenName, quantity] of task.native_assets) {
173
+ if (policyId.length !== 56) {
174
+ throw new Error(
175
+ `policyId must be 56 hex chars (got ${policyId.length})`,
176
+ );
177
+ }
178
+ if (!/^[0-9a-fA-F]*$/.test(policyId)) {
179
+ throw new Error("policyId contains invalid hex characters");
180
+ }
181
+ if (tokenName.length > 64 || tokenName.length % 2 !== 0) {
182
+ throw new Error(
183
+ `tokenName must be 0-64 hex chars with even length (got ${tokenName.length})`,
184
+ );
185
+ }
186
+ if (tokenName.length > 0 && !/^[0-9a-fA-F]*$/.test(tokenName)) {
187
+ throw new Error("tokenName contains invalid hex characters");
188
+ }
189
+ if (quantity < 0n) {
190
+ throw new Error("asset quantity must be non-negative");
191
+ }
192
+ }
178
193
  }
179
194
 
180
195
  /**
181
- * Encode a byte buffer matching Plutus's stringToBuiltinByteString.
196
+ * Convert a non-negative bigint to little-endian byte representation.
197
+ * Matches Aiken's integer_to_bytearray(False, 0, int).
182
198
  *
183
- * - Strings <= 64 bytes: regular CBOR byte string
184
- * - Strings > 64 bytes: indefinite-length chunked byte string (64-byte chunks)
199
+ * - False = little-endian byte order
200
+ * - 0 = minimal byte length (no zero-padding)
185
201
  *
202
+ * @param n - Non-negative bigint to convert
203
+ * @returns Uint8Array with little-endian byte representation
186
204
  * @internal
187
205
  */
188
- function encodePlutusBuiltinByteString(buffer: Uint8Array): Uint8Array {
189
- if (buffer.length <= PLUTUS_CHUNK_SIZE) {
190
- return encodeCBORByteString(buffer);
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]);
191
212
  }
192
213
 
193
- // Long string: use indefinite-length chunked encoding
194
- const chunks: Uint8Array[] = [];
195
- chunks.push(new Uint8Array([0x5f])); // Start indefinite byte string
214
+ const bytes: number[] = [];
215
+ let remaining = n;
196
216
 
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));
217
+ while (remaining > 0n) {
218
+ bytes.push(Number(remaining & 0xffn));
219
+ remaining = remaining >> 8n;
200
220
  }
201
221
 
202
- chunks.push(new Uint8Array([0xff])); // Break
203
- return concatUint8Arrays(chunks);
222
+ return new Uint8Array(bytes);
204
223
  }
205
224
 
206
225
  /**
207
- * Encode a number as a CBOR integer (Plutus Integer).
226
+ * Combine native assets into raw bytes matching Aiken's combine_flat_val.
227
+ * Format: policy_id ++ token_name ++ quantity for each asset.
208
228
  *
229
+ * @param assets - Array of native assets
230
+ * @returns Uint8Array of concatenated asset bytes
209
231
  * @internal
210
232
  */
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;
230
- }
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;
249
- }
233
+ function combineNativeAssets(assets: readonly NativeAsset[]): Uint8Array {
234
+ if (assets.length === 0) {
235
+ return new Uint8Array([]);
250
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);
251
245
  }
252
246
 
253
247
  /**
254
- * Encode a byte buffer as a CBOR byte string (definite length).
248
+ * Convert hex string to Uint8Array.
255
249
  *
250
+ * @param hex - Hexadecimal string (must be even length)
251
+ * @returns Uint8Array of bytes
256
252
  * @internal
257
253
  */
258
- function encodeCBORByteString(buffer: Uint8Array): Uint8Array {
259
- const len = buffer.length;
254
+ function hexToBytes(hex: string): Uint8Array {
255
+ if (hex.length === 0) {
256
+ return new Uint8Array([]);
257
+ }
258
+
259
+ // Validation already done in validateTaskData, but defensive check
260
+ if (hex.length % 2 !== 0) {
261
+ throw new Error(`Invalid hex string: odd length (${hex.length})`);
262
+ }
260
263
 
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;
264
+ const bytes = new Uint8Array(hex.length / 2);
265
+ for (let i = 0; i < bytes.length; i++) {
266
+ bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
279
267
  }
280
- throw new Error("Byte string too long for CBOR encoding");
268
+ return bytes;
281
269
  }
282
270
 
283
271
  /**
284
- * Concatenate multiple Uint8Arrays into one
272
+ * Concatenate multiple Uint8Arrays into one.
285
273
  *
286
274
  * @internal
287
275
  */
@@ -297,7 +285,7 @@ function concatUint8Arrays(arrays: Uint8Array[]): Uint8Array {
297
285
  }
298
286
 
299
287
  /**
300
- * Convert Uint8Array to hex string
288
+ * Convert Uint8Array to hex string.
301
289
  *
302
290
  * @internal
303
291
  */