@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/LICENSE +2 -1
- package/README.md +1 -1
- package/dist/index.cjs +1174 -584
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +489 -136
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +489 -136
- package/dist/index.d.mts.map +1 -1
- package/dist/index.iife.js +1247 -659
- package/dist/index.iife.js.map +1 -1
- package/dist/index.mjs +1132 -563
- package/dist/index.mjs.map +1 -1
- package/package.json +22 -22
- package/src/crypto-utils.ts +9 -3
- package/src/date.ts +42 -0
- package/src/envelope.ts +122 -0
- package/src/error.ts +21 -0
- package/src/generator.ts +153 -2
- package/src/index.ts +24 -0
- package/src/mark-info.ts +38 -17
- package/src/mark.ts +399 -45
- package/src/resolution.ts +32 -9
- package/src/rng-state.ts +6 -0
- package/src/seed.ts +6 -0
- package/src/utils.ts +68 -39
- package/src/validate.ts +63 -57
- package/src/xoshiro256starstar.ts +6 -0
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
|
-
|
|
11
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
315
|
-
|
|
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
|
-
|
|
320
|
-
|
|
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
|
-
|
|
326
|
-
|
|
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
|
-
|
|
332
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
expected:
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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.
|
|
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: ${
|
|
803
|
+
`date: ${dateStr}`,
|
|
529
804
|
];
|
|
530
805
|
|
|
531
806
|
const info = this.info();
|
|
532
807
|
if (info !== undefined) {
|
|
533
|
-
|
|
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
|
|
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
|
|
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
|
|
308
|
+
return cbor(res);
|
|
286
309
|
}
|
|
287
310
|
|
|
288
311
|
/**
|
package/src/rng-state.ts
CHANGED