@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/mark.ts CHANGED
@@ -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/mark.rs
2
8
 
3
9
  import { toBase64, fromBase64, bytesToHex } from "./utils.js";
@@ -7,11 +13,16 @@ import {
7
13
  BytewordsStyle,
8
14
  encodeBytewords,
9
15
  decodeBytewords,
10
- encodeBytewordsIdentifier,
11
- encodeBytemojisIdentifier,
16
+ encodeToWords,
17
+ encodeToBytemojis,
18
+ encodeToMinimalBytewords,
19
+ UR,
12
20
  } from "@bcts/uniform-resources";
21
+ import { Envelope } from "@bcts/envelope";
13
22
 
14
23
  import { ProvenanceMarkError, ProvenanceMarkErrorType } from "./error.js";
24
+ import { validate as validateMarks } from "./validate.js";
25
+ import type { ValidationIssue, ValidationReport } from "./validate.js";
15
26
  import {
16
27
  type ProvenanceMarkResolution,
17
28
  linkLength,
@@ -30,6 +41,7 @@ import {
30
41
  resolutionToCbor,
31
42
  } from "./resolution.js";
32
43
  import { sha256, sha256Prefix, obfuscate } from "./crypto-utils.js";
44
+ import { dateToDisplay } from "./date.js";
33
45
 
34
46
  /**
35
47
  * A cryptographically-secured provenance mark.
@@ -267,30 +279,213 @@ export class ProvenanceMark {
267
279
  }
268
280
 
269
281
  /**
270
- * Get the first four bytes of the hash as a hex string identifier.
282
+ * The 32-byte Mark ID.
283
+ *
284
+ * The first `linkLength` bytes are the mark's stored hash. The remaining
285
+ * bytes come from the mark's fingerprint (SHA-256 of CBOR encoding),
286
+ * ensuring a full 32-byte value is always available regardless of
287
+ * resolution.
288
+ */
289
+ id(): Uint8Array {
290
+ const result = new Uint8Array(32);
291
+ const n = this._hash.length;
292
+ result.set(this._hash, 0);
293
+ if (n < 32) {
294
+ const fp = this.fingerprint();
295
+ result.set(fp.subarray(0, 32 - n), n);
296
+ }
297
+ return result;
298
+ }
299
+
300
+ /**
301
+ * The full 32-byte Mark ID as a 64-character hex string.
302
+ */
303
+ idHex(): string {
304
+ return bytesToHex(this.id());
305
+ }
306
+
307
+ /**
308
+ * The first `wordCount` bytes of the Mark ID as upper-case ByteWords.
309
+ *
310
+ * @param wordCount Number of bytes to encode, must be in `4..=32`.
311
+ * @param prefix If `true`, prepends the provenance-mark prefix character.
312
+ * @throws if `wordCount` is not in the range `4..=32`.
313
+ */
314
+ idBytewords(wordCount: number, prefix: boolean): string {
315
+ if (!Number.isInteger(wordCount) || wordCount < 4 || wordCount > 32) {
316
+ throw new Error(`word_count must be 4..=32, got ${wordCount}`);
317
+ }
318
+ const s = encodeToWords(this.id().subarray(0, wordCount)).toUpperCase();
319
+ return prefix ? `\u{1F15F} ${s}` : s;
320
+ }
321
+
322
+ /**
323
+ * The first `wordCount` bytes of the Mark ID as Bytemoji.
324
+ *
325
+ * @param wordCount Number of bytes to encode, must be in `4..=32`.
326
+ * @param prefix If `true`, prepends the provenance-mark prefix character.
327
+ * @throws if `wordCount` is not in the range `4..=32`.
328
+ */
329
+ idBytemoji(wordCount: number, prefix: boolean): string {
330
+ if (!Number.isInteger(wordCount) || wordCount < 4 || wordCount > 32) {
331
+ throw new Error(`word_count must be 4..=32, got ${wordCount}`);
332
+ }
333
+ const s = encodeToBytemojis(this.id().subarray(0, wordCount)).toUpperCase();
334
+ return prefix ? `\u{1F15F} ${s}` : s;
335
+ }
336
+
337
+ /**
338
+ * The first `wordCount` bytes of the Mark ID as upper-case minimal
339
+ * ByteWords (2 letters per byte, concatenated without separator).
340
+ *
341
+ * @param wordCount Number of bytes to encode, must be in `4..=32`.
342
+ * @param prefix If `true`, prepends the provenance-mark prefix character.
343
+ * @throws if `wordCount` is not in the range `4..=32`.
344
+ */
345
+ idBytewordsMinimal(wordCount: number, prefix: boolean): string {
346
+ if (!Number.isInteger(wordCount) || wordCount < 4 || wordCount > 32) {
347
+ throw new Error(`word_count must be 4..=32, got ${wordCount}`);
348
+ }
349
+ const s = encodeToMinimalBytewords(this.id().subarray(0, wordCount)).toUpperCase();
350
+ return prefix ? `\u{1F15F} ${s}` : s;
351
+ }
352
+
353
+ /**
354
+ * Legacy 8-character hex identifier — the first 4 bytes of the Mark ID.
355
+ *
356
+ * @deprecated Use {@link idHex} for the full 64-char hex, or
357
+ * `idHex().slice(0, 8)` for this legacy short form. Retained for
358
+ * backwards compatibility; will be removed in a future alpha.
271
359
  */
272
360
  identifier(): string {
273
- return Array.from(this._hash.slice(0, 4))
274
- .map((b) => b.toString(16).padStart(2, "0"))
275
- .join("");
361
+ return this.idHex().slice(0, 8);
276
362
  }
277
363
 
278
364
  /**
279
- * Get the first four bytes of the hash as upper-case ByteWords.
365
+ * Legacy 4-byte upper-case ByteWords identifier.
366
+ *
367
+ * @deprecated Equivalent to `idBytewords(4, prefix)`. Retained for
368
+ * backwards compatibility; will be removed in a future alpha.
280
369
  */
281
370
  bytewordsIdentifier(prefix: boolean): string {
282
- const bytes = this._hash.slice(0, 4);
283
- const s = encodeBytewordsIdentifier(bytes).toUpperCase();
284
- return prefix ? `\u{1F151} ${s}` : s;
371
+ return this.idBytewords(4, prefix);
372
+ }
373
+
374
+ /**
375
+ * Legacy 8-letter minimal ByteWords identifier (first+last letter of each
376
+ * of the 4 ByteWords). Example: "ABLE ACID ALSO APEX" -> "AEADAOAX".
377
+ *
378
+ * @deprecated Equivalent to `idBytewordsMinimal(4, prefix)`. Retained
379
+ * for backwards compatibility; will be removed in a future alpha.
380
+ */
381
+ bytewordsMinimalIdentifier(prefix: boolean): string {
382
+ return this.idBytewordsMinimal(4, prefix);
285
383
  }
286
384
 
287
385
  /**
288
- * Get the first four bytes of the hash as Bytemoji.
386
+ * Legacy 4-byte upper-case Bytemoji identifier.
387
+ *
388
+ * @deprecated Equivalent to `idBytemoji(4, prefix)`. Retained for
389
+ * backwards compatibility; will be removed in a future alpha.
289
390
  */
290
391
  bytemojiIdentifier(prefix: boolean): string {
291
- const bytes = this._hash.slice(0, 4);
292
- const s = encodeBytemojisIdentifier(bytes).toUpperCase();
293
- return prefix ? `\u{1F151} ${s}` : s;
392
+ return this.idBytemoji(4, prefix);
393
+ }
394
+
395
+ /**
396
+ * Computes the minimum prefix length (in bytes, `4..=32`) each mark needs
397
+ * so that every mark in the set has a unique Mark ID prefix.
398
+ *
399
+ * Non-colliding marks get the minimum of 4. Only marks whose 4-byte
400
+ * prefixes collide are extended.
401
+ */
402
+ private static minimalNoncollidingPrefixLengths(ids: Uint8Array[]): number[] {
403
+ const n = ids.length;
404
+ const lengths: number[] = new Array<number>(n).fill(4);
405
+
406
+ // Group by 4-byte prefix (fast path)
407
+ const groups = new Map<string, number[]>();
408
+ for (let i = 0; i < n; i++) {
409
+ const key = bytesToHex(ids[i].subarray(0, 4));
410
+ const g = groups.get(key);
411
+ if (g !== undefined) g.push(i);
412
+ else groups.set(key, [i]);
413
+ }
414
+
415
+ // Resolve each collision group
416
+ for (const indices of groups.values()) {
417
+ if (indices.length <= 1) continue;
418
+ ProvenanceMark.resolveCollisionGroup(ids, indices, lengths);
419
+ }
420
+
421
+ return lengths;
422
+ }
423
+
424
+ private static resolveCollisionGroup(
425
+ ids: Uint8Array[],
426
+ initialIndices: number[],
427
+ lengths: number[],
428
+ ): void {
429
+ let unresolved: number[] = [...initialIndices];
430
+
431
+ for (let prefixLen = 5; prefixLen <= 32; prefixLen++) {
432
+ const subGroups = new Map<string, number[]>();
433
+ for (const i of unresolved) {
434
+ const key = bytesToHex(ids[i].subarray(0, prefixLen));
435
+ const g = subGroups.get(key);
436
+ if (g !== undefined) g.push(i);
437
+ else subGroups.set(key, [i]);
438
+ }
439
+
440
+ const nextUnresolved: number[] = [];
441
+ for (const subIndices of subGroups.values()) {
442
+ if (subIndices.length === 1) {
443
+ lengths[subIndices[0]] = prefixLen;
444
+ } else {
445
+ nextUnresolved.push(...subIndices);
446
+ }
447
+ }
448
+
449
+ if (nextUnresolved.length === 0) return;
450
+ unresolved = nextUnresolved;
451
+ }
452
+
453
+ // At 32 bytes, truly identical IDs remain — assign 32
454
+ for (const i of unresolved) {
455
+ lengths[i] = 32;
456
+ }
457
+ }
458
+
459
+ /**
460
+ * Returns disambiguated upper-case ByteWords Mark IDs for a set of marks.
461
+ *
462
+ * Non-colliding marks get 4-word identifiers. Only marks whose 4-byte
463
+ * prefixes collide are extended with additional words (up to 32 bytes
464
+ * per identifier).
465
+ */
466
+ static disambiguatedIdBytewords(marks: ProvenanceMark[], prefix: boolean): string[] {
467
+ const ids = marks.map((m) => m.id());
468
+ const lengths = ProvenanceMark.minimalNoncollidingPrefixLengths(ids);
469
+ return ids.map((id, i) => {
470
+ const s = encodeToWords(id.subarray(0, lengths[i])).toUpperCase();
471
+ return prefix ? `\u{1F15F} ${s}` : s;
472
+ });
473
+ }
474
+
475
+ /**
476
+ * Returns disambiguated Bytemoji Mark IDs for a set of marks.
477
+ *
478
+ * Non-colliding marks get 4-emoji identifiers. Only marks whose 4-byte
479
+ * prefixes collide are extended with additional emojis (up to 32 bytes
480
+ * per identifier).
481
+ */
482
+ static disambiguatedIdBytemoji(marks: ProvenanceMark[], prefix: boolean): string[] {
483
+ const ids = marks.map((m) => m.id());
484
+ const lengths = ProvenanceMark.minimalNoncollidingPrefixLengths(ids);
485
+ return ids.map((id, i) => {
486
+ const s = encodeToBytemojis(id.subarray(0, lengths[i])).toUpperCase();
487
+ return prefix ? `\u{1F15F} ${s}` : s;
488
+ });
294
489
  }
295
490
 
296
491
  /**
@@ -307,30 +502,61 @@ export class ProvenanceMark {
307
502
 
308
503
  /**
309
504
  * Check if this mark precedes another mark, throwing on validation errors.
505
+ * Errors carry a structured `validationIssue` in their details, matching Rust's
506
+ * `Error::Validation(ValidationIssue)` pattern.
310
507
  */
311
508
  precedesOpt(next: ProvenanceMark): void {
312
509
  // `next` can't be a genesis
313
510
  if (next._seq === 0) {
314
- throw new ProvenanceMarkError(ProvenanceMarkErrorType.ValidationError, undefined, {
315
- message: "non-genesis mark at sequence 0",
316
- });
511
+ const issue: ValidationIssue = { type: "NonGenesisAtZero" };
512
+ throw new ProvenanceMarkError(
513
+ ProvenanceMarkErrorType.ValidationError,
514
+ "non-genesis mark at sequence 0",
515
+ { validationIssue: issue },
516
+ );
317
517
  }
318
518
  if (arraysEqual(next._key, next._chainId)) {
319
- throw new ProvenanceMarkError(ProvenanceMarkErrorType.ValidationError, undefined, {
320
- message: "genesis mark must have key equal to chain_id",
321
- });
519
+ const issue: ValidationIssue = { type: "InvalidGenesisKey" };
520
+ throw new ProvenanceMarkError(
521
+ ProvenanceMarkErrorType.ValidationError,
522
+ "genesis mark must have key equal to chain_id",
523
+ { validationIssue: issue },
524
+ );
322
525
  }
323
526
  // `next` must have the next highest sequence number
324
527
  if (this._seq !== next._seq - 1) {
325
- throw new ProvenanceMarkError(ProvenanceMarkErrorType.ValidationError, undefined, {
326
- message: `sequence gap: expected ${this._seq + 1}, got ${next._seq}`,
327
- });
528
+ const issue: ValidationIssue = {
529
+ type: "SequenceGap",
530
+ expected: this._seq + 1,
531
+ actual: next._seq,
532
+ };
533
+ throw new ProvenanceMarkError(
534
+ ProvenanceMarkErrorType.ValidationError,
535
+ `sequence gap: expected ${this._seq + 1}, got ${next._seq}`,
536
+ { validationIssue: issue },
537
+ );
328
538
  }
329
- // `next` must have an equal or later date
539
+ // `next` must have an equal or later date.
540
+ //
541
+ // Date strings use `dateToDisplay` so the `DateOrdering` issue
542
+ // payload matches Rust's `Date::Display` exactly: midnight UTC
543
+ // dates render as `YYYY-MM-DD` (no time suffix), times render
544
+ // RFC 3339 with second precision. Earlier this port emitted
545
+ // `2023-06-20T00:00:00Z` for date-only marks, breaking parity with
546
+ // Rust's `2023-06-20`.
330
547
  if (this._date > next._date) {
331
- throw new ProvenanceMarkError(ProvenanceMarkErrorType.ValidationError, undefined, {
332
- message: `date ordering: ${this._date.toISOString()} > ${next._date.toISOString()}`,
333
- });
548
+ const dateStr = dateToDisplay(this._date);
549
+ const nextDateStr = dateToDisplay(next._date);
550
+ const issue: ValidationIssue = {
551
+ type: "DateOrdering",
552
+ previous: dateStr,
553
+ next: nextDateStr,
554
+ };
555
+ throw new ProvenanceMarkError(
556
+ ProvenanceMarkErrorType.ValidationError,
557
+ `date ordering: ${dateStr} > ${nextDateStr}`,
558
+ { validationIssue: issue },
559
+ );
334
560
  }
335
561
  // `next` must reveal the key that was used to generate this mark's hash
336
562
  const expectedHash = ProvenanceMark.makeHash(
@@ -343,15 +569,16 @@ export class ProvenanceMark {
343
569
  this._infoBytes,
344
570
  );
345
571
  if (!arraysEqual(this._hash, expectedHash)) {
346
- throw new ProvenanceMarkError(ProvenanceMarkErrorType.ValidationError, undefined, {
347
- message: "hash mismatch",
348
- expected: Array.from(expectedHash)
349
- .map((b) => b.toString(16).padStart(2, "0"))
350
- .join(""),
351
- actual: Array.from(this._hash)
352
- .map((b) => b.toString(16).padStart(2, "0"))
353
- .join(""),
354
- });
572
+ const issue: ValidationIssue = {
573
+ type: "HashMismatch",
574
+ expected: bytesToHex(expectedHash),
575
+ actual: bytesToHex(this._hash),
576
+ };
577
+ throw new ProvenanceMarkError(
578
+ ProvenanceMarkErrorType.ValidationError,
579
+ `hash mismatch: expected ${bytesToHex(expectedHash)}, got ${bytesToHex(this._hash)}`,
580
+ { validationIssue: issue },
581
+ );
355
582
  }
356
583
  }
357
584
 
@@ -403,7 +630,7 @@ export class ProvenanceMark {
403
630
  }
404
631
 
405
632
  /**
406
- * Encode for URL (minimal bytewords of CBOR).
633
+ * Encode for URL (minimal bytewords of tagged CBOR).
407
634
  */
408
635
  toUrlEncoding(): string {
409
636
  return encodeBytewords(this.toCborData(), BytewordsStyle.Minimal);
@@ -418,6 +645,39 @@ export class ProvenanceMark {
418
645
  return ProvenanceMark.fromTaggedCbor(cborValue);
419
646
  }
420
647
 
648
+ /**
649
+ * Returns the {@link UR} representation of this mark (untagged CBOR
650
+ * with type `"provenance"`).
651
+ *
652
+ * Mirrors Rust `UREncodable::ur()` for `ProvenanceMark` — the
653
+ * blanket impl on `CBORTaggedEncodable` produces a UR whose
654
+ * payload is the *untagged* CBOR (the type name itself stands in
655
+ * for the tag). See `bc-ur-rust/src/ur_encodable.rs:8-18`.
656
+ */
657
+ ur(): UR {
658
+ return UR.new("provenance", this.untaggedCbor());
659
+ }
660
+
661
+ /**
662
+ * Get the UR string representation (e.g., "ur:provenance/...").
663
+ */
664
+ urString(): string {
665
+ return this.ur().string();
666
+ }
667
+
668
+ /**
669
+ * Create from a UR string.
670
+ */
671
+ static fromURString(urString: string): ProvenanceMark {
672
+ const ur = UR.fromURString(urString);
673
+ if (ur.urTypeStr() !== "provenance") {
674
+ throw new ProvenanceMarkError(ProvenanceMarkErrorType.CborError, undefined, {
675
+ message: `Expected UR type 'provenance', got '${ur.urTypeStr()}'`,
676
+ });
677
+ }
678
+ return ProvenanceMark.fromUntaggedCbor(ur.cbor());
679
+ }
680
+
421
681
  /**
422
682
  * Build a URL with this mark as a query parameter.
423
683
  */
@@ -511,26 +771,48 @@ export class ProvenanceMark {
511
771
 
512
772
  /**
513
773
  * Debug string representation.
774
+ *
775
+ * As of provenance-mark v0.24, this includes the full 64-character Mark ID
776
+ * hex (matching rust's `Display` impl). Pre-v0.24 callers that depended on
777
+ * the 8-character prefix should use `idHex().slice(0, 8)` directly.
514
778
  */
515
779
  toString(): string {
516
- return `ProvenanceMark(${this.identifier()})`;
780
+ return `ProvenanceMark(${this.idHex()})`;
517
781
  }
518
782
 
519
783
  /**
520
784
  * Detailed debug representation.
785
+ *
786
+ * Mirrors Rust `Mark::Debug` exactly: every field is rendered
787
+ * Rust-style (hex bytes for keys/hashes/IDs, plain integer for
788
+ * `seq`, `Date::Display` for the date). The Low-resolution test
789
+ * vector in Rust `tests/mark.rs::test_low_resolution` ends with
790
+ * `date: 2023-06-20` — i.e. midnight-UTC dates are rendered without
791
+ * a time suffix. We use {@link dateToDisplay} to mirror that
792
+ * exactly; earlier revisions of this port stripped just the
793
+ * `.000Z` fractional component, which left `2023-06-20T00:00:00Z`
794
+ * and broke the Low-resolution debug-string parity.
521
795
  */
522
796
  toDebugString(): string {
797
+ const dateStr = dateToDisplay(this._date);
523
798
  const components = [
524
799
  `key: ${bytesToHex(this._key)}`,
525
800
  `hash: ${bytesToHex(this._hash)}`,
526
801
  `chainID: ${bytesToHex(this._chainId)}`,
527
802
  `seq: ${this._seq}`,
528
- `date: ${this._date.toISOString()}`,
803
+ `date: ${dateStr}`,
529
804
  ];
530
805
 
531
806
  const info = this.info();
532
807
  if (info !== undefined) {
533
- components.push(`info: ${JSON.stringify(info)}`);
808
+ // Format info as the underlying string value, matching Rust Debug format
809
+ const textValue = info.asText();
810
+ if (textValue !== undefined) {
811
+ components.push(`info: "${textValue}"`);
812
+ } else {
813
+ // For non-text values, use diagnostic format
814
+ components.push(`info: ${info.toDiagnostic()}`);
815
+ }
534
816
  }
535
817
 
536
818
  return `ProvenanceMark(${components.join(", ")})`;
@@ -544,16 +826,20 @@ export class ProvenanceMark {
544
826
  }
545
827
 
546
828
  /**
547
- * JSON serialization.
829
+ * JSON serialization. Field order, names, and date format mirror Rust's
830
+ * `#[derive(Serialize)]` on `ProvenanceMark` (provenance-mark-rust/src/mark.rs):
831
+ * `seq, date, res, chain_id, key, hash[, info_bytes]`. The date uses
832
+ * `dateToDisplay()` (date-only when midnight, RFC3339-seconds with `Z`
833
+ * otherwise), matching Rust's `serialize_iso8601` / `Date::to_string()`.
548
834
  */
549
835
  toJSON(): Record<string, unknown> {
550
836
  const result: Record<string, unknown> = {
837
+ seq: this._seq,
838
+ date: dateToDisplay(this._date),
551
839
  res: this._res,
840
+ chain_id: toBase64(this._chainId),
552
841
  key: toBase64(this._key),
553
842
  hash: toBase64(this._hash),
554
- chainID: toBase64(this._chainId),
555
- seq: this._seq,
556
- date: this._date.toISOString(),
557
843
  };
558
844
  if (this._infoBytes.length > 0) {
559
845
  result["info_bytes"] = toBase64(this._infoBytes);
@@ -568,7 +854,8 @@ export class ProvenanceMark {
568
854
  const res = json["res"] as ProvenanceMarkResolution;
569
855
  const key = fromBase64(json["key"] as string);
570
856
  const hash = fromBase64(json["hash"] as string);
571
- const chainId = fromBase64(json["chainID"] as string);
857
+ const chainIdRaw = json["chain_id"] ?? json["chainID"]; // accept legacy `chainID` for back-compat
858
+ const chainId = fromBase64(chainIdRaw as string);
572
859
  const seq = json["seq"] as number;
573
860
  const dateStr = json["date"] as string;
574
861
  const date = new Date(dateStr);
@@ -579,10 +866,77 @@ export class ProvenanceMark {
579
866
  let infoBytes: Uint8Array = new Uint8Array(0);
580
867
  if (typeof json["info_bytes"] === "string") {
581
868
  infoBytes = fromBase64(json["info_bytes"]);
869
+ // Mirrors Rust `util::deserialize_cbor` — base64-decoded bytes
870
+ // must parse as well-formed CBOR before we accept them. Earlier
871
+ // revisions of this port accepted any base64 payload, deferring
872
+ // the failure to a later `info()` call. Surface bad CBOR here
873
+ // so the JSON deserializer reports it eagerly.
874
+ if (infoBytes.length > 0) {
875
+ try {
876
+ decodeCbor(infoBytes);
877
+ } catch (e) {
878
+ throw new ProvenanceMarkError(
879
+ ProvenanceMarkErrorType.CborError,
880
+ "info_bytes is not valid CBOR",
881
+ {
882
+ details: e instanceof Error ? e.message : String(e),
883
+ },
884
+ );
885
+ }
886
+ }
582
887
  }
583
888
 
584
889
  return new ProvenanceMark(res, key, hash, chainId, seqBytes, dateBytes, infoBytes, seq, date);
585
890
  }
891
+
892
+ // ============================================================================
893
+ // Validation (delegate to ValidationReport)
894
+ // ============================================================================
895
+
896
+ /**
897
+ * Validate a collection of provenance marks.
898
+ *
899
+ * Matches Rust: `ProvenanceMark::validate()` which delegates to
900
+ * `ValidationReport::validate()`.
901
+ */
902
+ static validate(marks: ProvenanceMark[]): ValidationReport {
903
+ return validateMarks(marks);
904
+ }
905
+
906
+ // ============================================================================
907
+ // Envelope Support (EnvelopeEncodable)
908
+ // ============================================================================
909
+
910
+ /**
911
+ * Convert this provenance mark to a Gordian Envelope.
912
+ *
913
+ * Creates a leaf envelope containing the tagged CBOR representation.
914
+ * Matches Rust: `Envelope::new(mark.to_cbor())` which creates a CBOR leaf.
915
+ */
916
+ intoEnvelope(): Envelope {
917
+ return Envelope.newLeaf(this.taggedCbor());
918
+ }
919
+
920
+ /**
921
+ * Extract a ProvenanceMark from a Gordian Envelope.
922
+ *
923
+ * Matches Rust: `envelope.subject().try_leaf()?.try_into()`
924
+ *
925
+ * @param envelope - The envelope to extract from
926
+ * @returns The extracted provenance mark
927
+ * @throws ProvenanceMarkError if extraction fails
928
+ */
929
+ static fromEnvelope(envelope: Envelope): ProvenanceMark {
930
+ // Extract the CBOR leaf from the envelope subject, matching Rust's try_leaf()
931
+ const leaf = envelope.subject().asLeaf();
932
+ if (leaf !== undefined) {
933
+ return ProvenanceMark.fromTaggedCbor(leaf);
934
+ }
935
+
936
+ throw new ProvenanceMarkError(ProvenanceMarkErrorType.CborError, undefined, {
937
+ message: "Could not extract ProvenanceMark from envelope",
938
+ });
939
+ }
586
940
  }
587
941
 
588
942
  /**
package/src/resolution.ts CHANGED
@@ -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/resolution.rs
2
8
 
3
9
  import { type Cbor, cbor, expectUnsigned } from "@bcts/dcbor";
@@ -50,7 +56,7 @@ export enum ProvenanceMarkResolution {
50
56
  * Convert a resolution to its numeric value.
51
57
  */
52
58
  export function resolutionToNumber(res: ProvenanceMarkResolution): number {
53
- return res as number;
59
+ return res;
54
60
  }
55
61
 
56
62
  /**
@@ -217,8 +223,21 @@ export function deserializeDate(res: ProvenanceMarkResolution, data: Uint8Array)
217
223
 
218
224
  /**
219
225
  * Serialize a sequence number into bytes based on the resolution.
226
+ *
227
+ * Mirrors Rust's typed `u32` parameter (`generator.rs::serialize_seq`)
228
+ * — the input must be a non-negative integer in `[0, 2^32-1]` (a u32).
229
+ * For Low resolution the upper bound additionally narrows to `2^16-1`
230
+ * (a u16) per Rust `if seq > 0xFFFF`. Earlier revisions of this port
231
+ * accepted any JS `number` for the 4-byte branch and would silently
232
+ * truncate values above `2^32-1`; now we raise `ResolutionError` so
233
+ * the wire output never deviates from Rust's u32 contract.
220
234
  */
221
235
  export function serializeSeq(res: ProvenanceMarkResolution, seq: number): Uint8Array {
236
+ if (!Number.isInteger(seq) || seq < 0) {
237
+ throw new ProvenanceMarkError(ProvenanceMarkErrorType.ResolutionError, undefined, {
238
+ details: `sequence number must be a non-negative integer, got ${seq}`,
239
+ });
240
+ }
222
241
  const len = seqBytesLength(res);
223
242
  if (len === 2) {
224
243
  if (seq > 0xffff) {
@@ -230,14 +249,18 @@ export function serializeSeq(res: ProvenanceMarkResolution, seq: number): Uint8A
230
249
  buf[0] = (seq >> 8) & 0xff;
231
250
  buf[1] = seq & 0xff;
232
251
  return buf;
233
- } else {
234
- const buf = new Uint8Array(4);
235
- buf[0] = (seq >> 24) & 0xff;
236
- buf[1] = (seq >> 16) & 0xff;
237
- buf[2] = (seq >> 8) & 0xff;
238
- buf[3] = seq & 0xff;
239
- return buf;
240
252
  }
253
+ if (seq > 0xffffffff) {
254
+ throw new ProvenanceMarkError(ProvenanceMarkErrorType.ResolutionError, undefined, {
255
+ details: `sequence number ${seq} out of range for 4-byte format (max ${0xffffffff})`,
256
+ });
257
+ }
258
+ const buf = new Uint8Array(4);
259
+ buf[0] = (seq >>> 24) & 0xff;
260
+ buf[1] = (seq >>> 16) & 0xff;
261
+ buf[2] = (seq >>> 8) & 0xff;
262
+ buf[3] = seq & 0xff;
263
+ return buf;
241
264
  }
242
265
 
243
266
  /**
@@ -282,7 +305,7 @@ export function resolutionToString(res: ProvenanceMarkResolution): string {
282
305
  * Convert a resolution to CBOR.
283
306
  */
284
307
  export function resolutionToCbor(res: ProvenanceMarkResolution): Cbor {
285
- return cbor(res as number);
308
+ return cbor(res);
286
309
  }
287
310
 
288
311
  /**
package/src/rng-state.ts CHANGED
@@ -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/rng_state.rs
2
8
 
3
9
  import { type Cbor, cbor, expectBytes } from "@bcts/dcbor";
package/src/seed.ts CHANGED
@@ -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/seed.rs
2
8
 
3
9
  import { type Cbor, cbor, expectBytes } from "@bcts/dcbor";