@bcts/provenance-mark 1.0.0-alpha.8 → 1.0.0-beta.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.
package/src/utils.ts CHANGED
@@ -1,13 +1,14 @@
1
1
  /**
2
+ * Copyright © 2023-2026 Blockchain Commons, LLC
3
+ * Copyright © 2025-2026 Parity Technologies
4
+ *
5
+ *
2
6
  * Utility functions for byte array conversions.
3
7
  *
4
8
  * These functions provide cross-platform support for common byte manipulation
5
9
  * operations needed in provenance mark encoding.
6
10
  */
7
11
 
8
- // Declare Node.js types for environments where they might not be available
9
- declare const require: ((module: string) => unknown) | undefined;
10
-
11
12
  /**
12
13
  * Convert a Uint8Array to a lowercase hexadecimal string.
13
14
  *
@@ -50,26 +51,13 @@ export function hexToBytes(hex: string): Uint8Array {
50
51
  * @returns A base64-encoded string
51
52
  */
52
53
  export function toBase64(data: Uint8Array): string {
53
- // Use globalThis.btoa for browser/modern Node.js compatibility
54
- const globalBtoa = globalThis.btoa as ((data: string) => string) | undefined;
55
- if (typeof globalBtoa === "function") {
56
- // Convert bytes to binary string without spread operator to avoid
57
- // call stack limits for large arrays
58
- let binary = "";
59
- for (const byte of data) {
60
- binary += String.fromCharCode(byte);
61
- }
62
- return globalBtoa(binary);
54
+ // Convert bytes to binary string without spread operator to avoid
55
+ // call stack limits for large arrays
56
+ let binary = "";
57
+ for (const byte of data) {
58
+ binary += String.fromCharCode(byte);
63
59
  }
64
- // Node.js environment (fallback for Node < 18)
65
- const requireFn = require;
66
- if (typeof requireFn === "function") {
67
- const { Buffer: NodeBuffer } = requireFn("buffer") as {
68
- Buffer: { from: (data: Uint8Array) => { toString: (encoding: string) => string } };
69
- };
70
- return NodeBuffer.from(data).toString("base64");
71
- }
72
- throw new Error("btoa not available and require is not defined");
60
+ return btoa(binary);
73
61
  }
74
62
 
75
63
  /**
@@ -81,23 +69,64 @@ export function toBase64(data: Uint8Array): string {
81
69
  * @returns The decoded byte array
82
70
  */
83
71
  export function fromBase64(base64: string): Uint8Array {
84
- // Use globalThis.atob for browser/modern Node.js compatibility
85
- const globalAtob = globalThis.atob as ((data: string) => string) | undefined;
86
- if (typeof globalAtob === "function") {
87
- const binary = globalAtob(base64);
88
- const bytes = new Uint8Array(binary.length);
89
- for (let i = 0; i < binary.length; i++) {
90
- bytes[i] = binary.charCodeAt(i);
91
- }
92
- return bytes;
72
+ const binary = atob(base64);
73
+ const bytes = new Uint8Array(binary.length);
74
+ for (let i = 0; i < binary.length; i++) {
75
+ bytes[i] = binary.charCodeAt(i);
93
76
  }
94
- // Node.js environment (fallback for Node < 18)
95
- const requireFn = require;
96
- if (typeof requireFn === "function") {
97
- const { Buffer: NodeBuffer } = requireFn("buffer") as {
98
- Buffer: { from: (data: string, encoding: string) => Uint8Array };
99
- };
100
- return new Uint8Array(NodeBuffer.from(base64, "base64"));
77
+ return bytes;
78
+ }
79
+
80
+ /**
81
+ * Parse a base64-encoded provenance seed.
82
+ *
83
+ * Mirrors Rust's `parse_seed` user helper
84
+ * (`provenance-mark-rust/src/util.rs:34-38`), which round-trips the
85
+ * input through serde JSON / `deserialize_block` and so requires the
86
+ * decoded bytes to be exactly {@link PROVENANCE_SEED_LENGTH} (32) long.
87
+ * The TS equivalent decodes the base64 directly and delegates to
88
+ * {@link ProvenanceSeed.fromBytes} for the length check.
89
+ *
90
+ * @param s - Base64-encoded 32-byte seed string.
91
+ * @returns The decoded {@link ProvenanceSeed}.
92
+ * @throws {ProvenanceMarkError} If the input is not valid base64 or the
93
+ * decoded length is not exactly 32 bytes.
94
+ */
95
+ export function parseSeed(s: string): ProvenanceSeed {
96
+ let bytes: Uint8Array;
97
+ try {
98
+ bytes = fromBase64(s);
99
+ } catch (e) {
100
+ throw new ProvenanceMarkError(
101
+ ProvenanceMarkErrorType.Base64Error,
102
+ "invalid base64 encoding for provenance seed",
103
+ { details: e instanceof Error ? e.message : String(e) },
104
+ );
101
105
  }
102
- throw new Error("atob not available and require is not defined");
106
+ return ProvenanceSeed.fromBytes(bytes);
103
107
  }
108
+
109
+ /**
110
+ * Parse a date string (`YYYY-MM-DD` or full RFC 3339) into a `Date`.
111
+ *
112
+ * Mirrors Rust's `parse_date` user helper
113
+ * (`provenance-mark-rust/src/util.rs:40-42`), which delegates to
114
+ * `Date::from_string` (the same parser the JSON deserializer uses).
115
+ * Accepts the same shapes the Rust parser does:
116
+ *
117
+ * - `YYYY-MM-DD` (interpreted as UTC midnight, matching JS spec).
118
+ * - Full RFC 3339, e.g. `2023-06-20T15:30:45Z` or
119
+ * `2023-06-20T15:30:45.123Z`.
120
+ *
121
+ * @throws {ProvenanceMarkError} If the input fails to parse.
122
+ */
123
+ export function parseDate(s: string): Date {
124
+ const date = new Date(s);
125
+ if (Number.isNaN(date.getTime())) {
126
+ throw new ProvenanceMarkError(ProvenanceMarkErrorType.InvalidDate, `cannot parse date: ${s}`);
127
+ }
128
+ return date;
129
+ }
130
+
131
+ import { ProvenanceSeed } from "./seed.js";
132
+ import { ProvenanceMarkError, ProvenanceMarkErrorType } from "./error.js";
package/src/validate.ts CHANGED
@@ -1,6 +1,13 @@
1
+ /**
2
+ * Copyright © 2023-2026 Blockchain Commons, LLC
3
+ * Copyright © 2025-2026 Parity Technologies
4
+ *
5
+ */
6
+
1
7
  // Ported from provenance-mark-rust/src/validate.rs
2
8
 
3
9
  import type { ProvenanceMark } from "./mark.js";
10
+ import { ProvenanceMarkError } from "./error.js";
4
11
 
5
12
  /**
6
13
  * Format for validation report output.
@@ -186,7 +193,7 @@ function formatText(report: ValidationReport): string {
186
193
  // Report each mark in the sequence
187
194
  for (const flaggedMark of seq.marks) {
188
195
  const mark = flaggedMark.mark;
189
- const shortId = mark.identifier();
196
+ const shortId = mark.idHex().slice(0, 8);
190
197
  const seqNum = mark.seq();
191
198
 
192
199
  // Build the mark line with annotations
@@ -257,23 +264,47 @@ export function formatReport(report: ValidationReport, format: ValidationReportF
257
264
  */
258
265
  function reportToJSON(report: ValidationReport): unknown {
259
266
  return {
260
- marks: report.marks.map((m) => m.toUrlEncoding()),
267
+ marks: report.marks.map((m) => m.urString()),
261
268
  chains: report.chains.map((chain) => ({
262
269
  chain_id: hexEncode(chain.chainId),
263
270
  has_genesis: chain.hasGenesis,
264
- marks: chain.marks.map((m) => m.toUrlEncoding()),
271
+ marks: chain.marks.map((m) => m.urString()),
265
272
  sequences: chain.sequences.map((seq) => ({
266
273
  start_seq: seq.startSeq,
267
274
  end_seq: seq.endSeq,
268
275
  marks: seq.marks.map((fm) => ({
269
- mark: fm.mark.toUrlEncoding(),
270
- issues: fm.issues,
276
+ mark: fm.mark.urString(),
277
+ issues: fm.issues.map(issueToJSON),
271
278
  })),
272
279
  })),
273
280
  })),
274
281
  };
275
282
  }
276
283
 
284
+ /**
285
+ * Convert a ValidationIssue to JSON matching Rust's serde format.
286
+ *
287
+ * Rust uses `#[serde(tag = "type", content = "data")]` which wraps
288
+ * struct variant data in a `"data"` field. Unit variants have no
289
+ * `"data"` field.
290
+ */
291
+ function issueToJSON(issue: ValidationIssue): unknown {
292
+ switch (issue.type) {
293
+ case "HashMismatch":
294
+ return { type: "HashMismatch", data: { expected: issue.expected, actual: issue.actual } };
295
+ case "SequenceGap":
296
+ return { type: "SequenceGap", data: { expected: issue.expected, actual: issue.actual } };
297
+ case "DateOrdering":
298
+ return { type: "DateOrdering", data: { previous: issue.previous, next: issue.next } };
299
+ case "KeyMismatch":
300
+ return { type: "KeyMismatch" };
301
+ case "NonGenesisAtZero":
302
+ return { type: "NonGenesisAtZero" };
303
+ case "InvalidGenesisKey":
304
+ return { type: "InvalidGenesisKey" };
305
+ }
306
+ }
307
+
277
308
  /**
278
309
  * Build sequence bins for a chain.
279
310
  */
@@ -301,8 +332,14 @@ function buildSequenceBins(marks: ProvenanceMark[]): SequenceReport[] {
301
332
  sequences.push(createSequenceReport(currentSequence));
302
333
  }
303
334
 
304
- // Parse the error to determine the issue type
305
- const issue = parseValidationError(e, prev, mark);
335
+ // Extract structured issue directly from the error
336
+ // Matches Rust: Error::Validation(v) => v, _ => ValidationIssue::KeyMismatch
337
+ let issue: ValidationIssue;
338
+ if (e instanceof ProvenanceMarkError && e.details?.["validationIssue"] !== undefined) {
339
+ issue = e.details["validationIssue"] as ValidationIssue;
340
+ } else {
341
+ issue = { type: "KeyMismatch" }; // Fallback
342
+ }
306
343
 
307
344
  // Start new sequence with this mark, flagged with the issue
308
345
  currentSequence = [{ mark, issues: [issue] }];
@@ -318,53 +355,6 @@ function buildSequenceBins(marks: ProvenanceMark[]): SequenceReport[] {
318
355
  return sequences;
319
356
  }
320
357
 
321
- /**
322
- * Parse a validation error into a ValidationIssue.
323
- */
324
- function parseValidationError(
325
- e: unknown,
326
- prev: ProvenanceMark,
327
- next: ProvenanceMark,
328
- ): ValidationIssue {
329
- const message = e instanceof Error ? e.message : "";
330
-
331
- if (message !== "" && message.includes("non-genesis mark at sequence 0")) {
332
- return { type: "NonGenesisAtZero" };
333
- }
334
- if (message !== "" && message.includes("genesis mark must have key equal to chain_id")) {
335
- return { type: "InvalidGenesisKey" };
336
- }
337
- if (message !== "" && message.includes("sequence gap")) {
338
- const seqGapRegex = /expected (\d+), got (\d+)/;
339
- const match = seqGapRegex.exec(message);
340
- if (match !== null) {
341
- return {
342
- type: "SequenceGap",
343
- expected: parseInt(match[1], 10),
344
- actual: parseInt(match[2], 10),
345
- };
346
- }
347
- }
348
- if (message !== "" && message.includes("date ordering")) {
349
- return {
350
- type: "DateOrdering",
351
- previous: prev.date().toISOString(),
352
- next: next.date().toISOString(),
353
- };
354
- }
355
- if (message !== "" && message.includes("hash mismatch")) {
356
- const hashRegex = /expected: (\w+), actual: (\w+)/;
357
- const match = hashRegex.exec(message);
358
- if (match !== null) {
359
- return { type: "HashMismatch", expected: match[1], actual: match[2] };
360
- }
361
- return { type: "HashMismatch", expected: "", actual: "" };
362
- }
363
-
364
- // Fallback
365
- return { type: "KeyMismatch" };
366
- }
367
-
368
358
  /**
369
359
  * Create a sequence report from flagged marks.
370
360
  */
@@ -379,10 +369,11 @@ function createSequenceReport(marks: FlaggedMark[]): SequenceReport {
379
369
  */
380
370
  export function validate(marks: ProvenanceMark[]): ValidationReport {
381
371
  // Deduplicate exact duplicates
372
+ // Matches Rust semantics: PartialEq compares (res, message())
382
373
  const seen = new Set<string>();
383
374
  const deduplicatedMarks: ProvenanceMark[] = [];
384
375
  for (const mark of marks) {
385
- const key = mark.toUrlEncoding();
376
+ const key = `${mark.res()}:${hexEncode(mark.message())}`;
386
377
  if (!seen.has(key)) {
387
378
  seen.add(key);
388
379
  deduplicatedMarks.push(mark);
@@ -422,8 +413,23 @@ export function validate(marks: ProvenanceMark[]): ValidationReport {
422
413
  });
423
414
  }
424
415
 
425
- // Sort chains by chain ID for consistent output
426
- chains.sort((a, b) => hexEncode(a.chainId).localeCompare(hexEncode(b.chainId)));
416
+ // Sort chains by chain ID for consistent output.
417
+ //
418
+ // Mirrors Rust `chains.sort_by_key(|c| c.chain_id.clone())` which
419
+ // uses `Vec<u8>::cmp` (lexicographic byte comparison). We compare
420
+ // hex-encoded chain IDs with raw `<`/`>` rather than `localeCompare`,
421
+ // because hex-digit ordering is locale-independent under JS `<`/`>`
422
+ // (UTF-16 code-unit compare) but `localeCompare` is locale-aware and
423
+ // could in principle drift on a non-default locale. Using a pure
424
+ // bytewise compare here keeps the chain order byte-identical to Rust
425
+ // regardless of locale.
426
+ chains.sort((a, b) => {
427
+ const aHex = hexEncode(a.chainId);
428
+ const bHex = hexEncode(b.chainId);
429
+ if (aHex < bHex) return -1;
430
+ if (aHex > bHex) return 1;
431
+ return 0;
432
+ });
427
433
 
428
434
  return { marks: deduplicatedMarks, chains };
429
435
  }
@@ -1,3 +1,9 @@
1
+ /**
2
+ * Copyright © 2023-2026 Blockchain Commons, LLC
3
+ * Copyright © 2025-2026 Parity Technologies
4
+ *
5
+ */
6
+
1
7
  // Ported from provenance-mark-rust/src/xoshiro256starstar.rs
2
8
 
3
9
  /**