@andamio/core 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.
@@ -0,0 +1,193 @@
1
+ /**
2
+ * SLT Hash Utility
3
+ *
4
+ * Computes the module token name (hash) from a list of Student Learning Targets (SLTs).
5
+ * This hash is used as the token name when minting module tokens on-chain.
6
+ *
7
+ * The algorithm matches the on-chain Plutus validator:
8
+ * ```haskell
9
+ * sltsToBbs MintModuleV2{slts} = blake2b_256 $ serialiseData $ toBuiltinData $ map stringToBuiltinByteString slts
10
+ * ```
11
+ *
12
+ * Serialization format:
13
+ * 1. Convert each SLT string to UTF-8 bytes
14
+ * 2. Encode as CBOR indefinite-length array of byte strings
15
+ * 3. Hash with Blake2b-256 (32 bytes / 256 bits)
16
+ *
17
+ * @module @andamio/core/hashing
18
+ */
19
+
20
+ import * as blake from "blakejs";
21
+
22
+ /**
23
+ * Plutus chunk size for byte strings.
24
+ * Strings longer than this are encoded as indefinite-length chunked byte strings.
25
+ */
26
+ const PLUTUS_CHUNK_SIZE = 64;
27
+
28
+ /**
29
+ * Compute the module hash matching Plutus on-chain encoding.
30
+ *
31
+ * Plutus's `stringToBuiltinByteString` chunks byte strings at 64 bytes.
32
+ * This function replicates that behavior:
33
+ * - Strings <= 64 bytes: encoded as regular CBOR byte strings
34
+ * - Strings > 64 bytes: encoded as indefinite-length chunked byte strings
35
+ *
36
+ * @param slts - Array of Student Learning Target strings
37
+ * @returns 64-character hex string (256-bit Blake2b hash)
38
+ *
39
+ * @example
40
+ * ```typescript
41
+ * import { computeSltHash } from "@andamio/core/hashing";
42
+ *
43
+ * const slts = [
44
+ * "I can mint an access token.",
45
+ * "I can complete an assignment to earn a credential."
46
+ * ];
47
+ *
48
+ * const moduleHash = computeSltHash(slts);
49
+ * // Returns: "8dcbe1b925d87e6c547bbd8071c23a712db4c32751454b0948f8c846e9246b5c"
50
+ * ```
51
+ */
52
+ export function computeSltHash(slts: string[]): string {
53
+ const sltBytes = slts.map((slt) => new TextEncoder().encode(slt));
54
+ const cborData = encodeAsPlutusArray(sltBytes);
55
+ return blake.blake2bHex(cborData, undefined, 32);
56
+ }
57
+
58
+ /**
59
+ * @deprecated Use `computeSltHash` instead. This alias is kept for backwards compatibility.
60
+ */
61
+ export const computeSltHashDefinite = computeSltHash;
62
+
63
+ /**
64
+ * Verify that a given hash matches the computed hash for SLTs.
65
+ *
66
+ * @param slts - Array of Student Learning Target strings
67
+ * @param expectedHash - The hash to verify (64-character hex string)
68
+ * @returns true if the computed hash matches the expected hash
69
+ */
70
+ export function verifySltHash(slts: string[], expectedHash: string): boolean {
71
+ const computedHash = computeSltHash(slts);
72
+ return computedHash.toLowerCase() === expectedHash.toLowerCase();
73
+ }
74
+
75
+ /**
76
+ * Validate that a string is a valid SLT hash format.
77
+ *
78
+ * SLT hashes are 64-character hexadecimal strings (256-bit Blake2b hash).
79
+ *
80
+ * @param hash - String to validate
81
+ * @returns true if the string is a valid SLT hash format
82
+ */
83
+ export function isValidSltHash(hash: string): boolean {
84
+ if (hash.length !== 64) {
85
+ return false;
86
+ }
87
+ return /^[0-9a-fA-F]{64}$/.test(hash);
88
+ }
89
+
90
+ // =============================================================================
91
+ // Plutus-Compatible Encoding (Internal)
92
+ // =============================================================================
93
+
94
+ /**
95
+ * Encode an array of byte buffers matching Plutus serialization.
96
+ *
97
+ * Uses indefinite-length array with chunked byte strings for long values.
98
+ *
99
+ * @internal
100
+ */
101
+ function encodeAsPlutusArray(items: Uint8Array[]): Uint8Array {
102
+ const chunks: Uint8Array[] = [];
103
+
104
+ // Start indefinite array
105
+ chunks.push(new Uint8Array([0x9f]));
106
+
107
+ // Encode each item (with chunking for long strings)
108
+ for (const item of items) {
109
+ chunks.push(encodePlutusBuiltinByteString(item));
110
+ }
111
+
112
+ // End indefinite array
113
+ chunks.push(new Uint8Array([0xff]));
114
+
115
+ return concatUint8Arrays(chunks);
116
+ }
117
+
118
+ /**
119
+ * Encode a byte buffer matching Plutus's stringToBuiltinByteString.
120
+ *
121
+ * - Strings <= 64 bytes: regular CBOR byte string
122
+ * - Strings > 64 bytes: indefinite-length chunked byte string (64-byte chunks)
123
+ *
124
+ * @internal
125
+ */
126
+ function encodePlutusBuiltinByteString(buffer: Uint8Array): Uint8Array {
127
+ if (buffer.length <= PLUTUS_CHUNK_SIZE) {
128
+ // Short string: encode normally
129
+ return encodeCBORByteString(buffer);
130
+ }
131
+
132
+ // Long string: use indefinite-length chunked encoding
133
+ const chunks: Uint8Array[] = [];
134
+ chunks.push(new Uint8Array([0x5f])); // Start indefinite byte string
135
+
136
+ for (let i = 0; i < buffer.length; i += PLUTUS_CHUNK_SIZE) {
137
+ const chunk = buffer.subarray(i, Math.min(i + PLUTUS_CHUNK_SIZE, buffer.length));
138
+ chunks.push(encodeCBORByteString(chunk));
139
+ }
140
+
141
+ chunks.push(new Uint8Array([0xff])); // Break
142
+ return concatUint8Arrays(chunks);
143
+ }
144
+
145
+ /**
146
+ * Encode a byte buffer as a CBOR byte string (definite length).
147
+ *
148
+ * @internal
149
+ */
150
+ function encodeCBORByteString(buffer: Uint8Array): Uint8Array {
151
+ const len = buffer.length;
152
+
153
+ // CBOR byte string encoding (major type 2 = 0x40):
154
+ // - 0-23 bytes: length inline (0x40 + len)
155
+ // - 24-255 bytes: 0x58 + 1-byte length
156
+ // - 256-65535 bytes: 0x59 + 2-byte length (big-endian)
157
+ if (len <= 23) {
158
+ const result = new Uint8Array(1 + len);
159
+ result[0] = 0x40 + len;
160
+ result.set(buffer, 1);
161
+ return result;
162
+ } else if (len <= 255) {
163
+ const result = new Uint8Array(2 + len);
164
+ result[0] = 0x58;
165
+ result[1] = len;
166
+ result.set(buffer, 2);
167
+ return result;
168
+ } else if (len <= 65535) {
169
+ const result = new Uint8Array(3 + len);
170
+ result[0] = 0x59;
171
+ result[1] = len >> 8;
172
+ result[2] = len & 0xff;
173
+ result.set(buffer, 3);
174
+ return result;
175
+ }
176
+ throw new Error("Byte string too long for CBOR encoding");
177
+ }
178
+
179
+ /**
180
+ * Concatenate multiple Uint8Arrays into one
181
+ *
182
+ * @internal
183
+ */
184
+ function concatUint8Arrays(arrays: Uint8Array[]): Uint8Array {
185
+ const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
186
+ const result = new Uint8Array(totalLength);
187
+ let offset = 0;
188
+ for (const arr of arrays) {
189
+ result.set(arr, offset);
190
+ offset += arr.length;
191
+ }
192
+ return result;
193
+ }
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Task Hash Utility
3
+ *
4
+ * Computes the task token name (hash) from task data.
5
+ * This hash is used as the task_hash on-chain (content-addressed identifier).
6
+ *
7
+ * The algorithm matches the on-chain Plutus validator serialization.
8
+ *
9
+ * @module @andamio/core/hashing
10
+ */
11
+
12
+ import * as blake from "blakejs";
13
+
14
+ /**
15
+ * Native asset in ListValue format: [policyId.tokenName, quantity]
16
+ */
17
+ type NativeAsset = [string, number];
18
+
19
+ /**
20
+ * Task data structure matching the Atlas TX API ManageTasksTxRequest
21
+ *
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)
27
+ */
28
+ export interface TaskData {
29
+ project_content: string;
30
+ expiration_time: number;
31
+ lovelace_amount: number;
32
+ native_assets: NativeAsset[];
33
+ }
34
+
35
+ /**
36
+ * Plutus chunk size for byte strings.
37
+ */
38
+ const PLUTUS_CHUNK_SIZE = 64;
39
+
40
+ /**
41
+ * Compute the task hash (token name / task_hash) from task data.
42
+ *
43
+ * This produces the same hash as the on-chain Plutus validator, allowing
44
+ * clients to pre-compute or verify task hashes.
45
+ *
46
+ * @param task - Task data object
47
+ * @returns 64-character hex string (256-bit Blake2b hash)
48
+ *
49
+ * @example
50
+ * ```typescript
51
+ * import { computeTaskHash } from "@andamio/core/hashing";
52
+ *
53
+ * const task = {
54
+ * project_content: "Open Task #1",
55
+ * expiration_time: 1769027280000,
56
+ * lovelace_amount: 15000000,
57
+ * native_assets: []
58
+ * };
59
+ *
60
+ * const taskHash = computeTaskHash(task);
61
+ * // Returns the on-chain task_hash
62
+ * ```
63
+ */
64
+ 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);
70
+ }
71
+
72
+ /**
73
+ * Verify that a given hash matches the computed hash for a task.
74
+ *
75
+ * @param task - Task data object
76
+ * @param expectedHash - The hash to verify (64-character hex string)
77
+ * @returns true if the computed hash matches the expected hash
78
+ */
79
+ export function verifyTaskHash(task: TaskData, expectedHash: string): boolean {
80
+ const computedHash = computeTaskHash(task);
81
+ return computedHash.toLowerCase() === expectedHash.toLowerCase();
82
+ }
83
+
84
+ /**
85
+ * Validate that a string is a valid task hash format.
86
+ *
87
+ * Task hashes are 64-character hexadecimal strings (256-bit Blake2b hash).
88
+ */
89
+ export function isValidTaskHash(hash: string): boolean {
90
+ if (hash.length !== 64) {
91
+ return false;
92
+ }
93
+ return /^[0-9a-fA-F]{64}$/.test(hash);
94
+ }
95
+
96
+ /**
97
+ * Debug function to show the CBOR encoding of a task.
98
+ * Useful for comparing against on-chain data.
99
+ *
100
+ * @param task - Task data object
101
+ * @returns Hex string of the CBOR-encoded data (before hashing)
102
+ */
103
+ export function debugTaskCBOR(task: TaskData): string {
104
+ const cborData = encodeTaskAsPlutusData(task);
105
+ return uint8ArrayToHex(cborData);
106
+ }
107
+
108
+ // =============================================================================
109
+ // Plutus Data Encoding (Internal)
110
+ // =============================================================================
111
+
112
+ /**
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]
117
+ *
118
+ * IMPORTANT: Plutus uses indefinite-length arrays (0x9f...0xff) not definite (0x84).
119
+ *
120
+ * @internal
121
+ */
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);
146
+ }
147
+
148
+ /**
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)]
153
+ *
154
+ * @internal
155
+ */
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)
160
+ }
161
+
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));
172
+ }
173
+
174
+ // End indefinite array
175
+ chunks.push(new Uint8Array([0xff]));
176
+
177
+ return concatUint8Arrays(chunks);
178
+ }
179
+
180
+ /**
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)
185
+ *
186
+ * @internal
187
+ */
188
+ function encodePlutusBuiltinByteString(buffer: Uint8Array): Uint8Array {
189
+ if (buffer.length <= PLUTUS_CHUNK_SIZE) {
190
+ return encodeCBORByteString(buffer);
191
+ }
192
+
193
+ // Long string: use indefinite-length chunked encoding
194
+ const chunks: Uint8Array[] = [];
195
+ chunks.push(new Uint8Array([0x5f])); // Start indefinite byte string
196
+
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));
200
+ }
201
+
202
+ chunks.push(new Uint8Array([0xff])); // Break
203
+ return concatUint8Arrays(chunks);
204
+ }
205
+
206
+ /**
207
+ * Encode a number as a CBOR integer (Plutus Integer).
208
+ *
209
+ * @internal
210
+ */
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
+ }
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Encode a byte buffer as a CBOR byte string (definite length).
255
+ *
256
+ * @internal
257
+ */
258
+ function encodeCBORByteString(buffer: Uint8Array): Uint8Array {
259
+ const len = buffer.length;
260
+
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;
279
+ }
280
+ throw new Error("Byte string too long for CBOR encoding");
281
+ }
282
+
283
+ /**
284
+ * Concatenate multiple Uint8Arrays into one
285
+ *
286
+ * @internal
287
+ */
288
+ function concatUint8Arrays(arrays: Uint8Array[]): Uint8Array {
289
+ const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
290
+ const result = new Uint8Array(totalLength);
291
+ let offset = 0;
292
+ for (const arr of arrays) {
293
+ result.set(arr, offset);
294
+ offset += arr.length;
295
+ }
296
+ return result;
297
+ }
298
+
299
+ /**
300
+ * Convert Uint8Array to hex string
301
+ *
302
+ * @internal
303
+ */
304
+ function uint8ArrayToHex(arr: Uint8Array): string {
305
+ return Array.from(arr)
306
+ .map((b) => b.toString(16).padStart(2, "0"))
307
+ .join("");
308
+ }