@mikeargento/bitgraph 1.5.0 → 1.6.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/fuse.ts CHANGED
@@ -49,6 +49,8 @@ import {
49
49
  parseSetManifest,
50
50
  readSetMetadata,
51
51
  SET_METADATA_KEY,
52
+ TRAILER_LENGTH,
53
+ TRAILER_MAGIC,
52
54
  verifyFuse,
53
55
  verifyFuseMember,
54
56
  base64ToBytes,
@@ -153,6 +155,7 @@ export type FuseErrorCode =
153
155
  | "bad-input"
154
156
  | "allocate-failed"
155
157
  | "builder-failed"
158
+ | "load-failed"
156
159
  | "commitment-missing"
157
160
  | "commit-refused"
158
161
  | "slot-unavailable"
@@ -478,7 +481,18 @@ export const MAX_SET_MEMBERS = 2000;
478
481
  /** The placements a set member takes: Forms A and B, one original per member. */
479
482
  export type SetMemberPlacement = "trailer/1" | "container/1";
480
483
 
481
- export interface FuseSetMember {
484
+ /** What a hashed member's fused digest is computed for: the held slot and its commitment. */
485
+ export interface FusedDigestInput {
486
+ commitment: Uint8Array;
487
+ commitmentHex: string;
488
+ slot: SlotAllocation;
489
+ }
490
+
491
+ /**
492
+ * A member given as bytes. The core hashes the original, builds the fused
493
+ * bytes under the slot's commitment, checks them, and hashes them.
494
+ */
495
+ export interface FuseSetBytesMember {
482
496
  /** The original bytes. Never modified. */
483
497
  original: Uint8Array;
484
498
  /** Default: placementForBytes(original). */
@@ -489,10 +503,62 @@ export interface FuseSetMember {
489
503
  builder?: FuseBuilder;
490
504
  }
491
505
 
506
+ /**
507
+ * A member whose bytes are read only when it is that member's turn, after
508
+ * the slot is held, and released once hashed: one member's bytes in memory
509
+ * at a time, however large the set. Nothing can be read before allocation
510
+ * without reading twice, so the caller names the placement and the origin
511
+ * digest up front; the digest is checked against the loaded bytes, and the
512
+ * byte guards run as for a bytes member.
513
+ */
514
+ export interface FuseSetLoadedMember {
515
+ load: () => Promise<Uint8Array> | Uint8Array;
516
+ originDigest: Uint8Array;
517
+ placement: SetMemberPlacement;
518
+ /** Advisory; feeds fusedNamesFor. */
519
+ name?: string;
520
+ /** Default: builderFor(placement, bytes). The locate and origin guards run regardless. */
521
+ builder?: FuseBuilder;
522
+ }
523
+
524
+ /**
525
+ * A member the caller hashes itself: it answers the fused digest for the
526
+ * held slot's commitment. For trailer/1 that is a hash state saved after
527
+ * the original and finished with trailerBytesFor(commitment), so the bytes
528
+ * are read once, when they are scanned, and never again. The core never
529
+ * sees this member's bytes: no byte guard runs, keepFused returns nothing
530
+ * for it, and verifyMembers refuses it before any request. Its row is bound
531
+ * to the committed manifest by digest like every other.
532
+ */
533
+ export interface FuseSetHashedMember {
534
+ originDigest: Uint8Array;
535
+ placement: SetMemberPlacement;
536
+ fusedDigest: (input: FusedDigestInput) => Promise<Uint8Array> | Uint8Array;
537
+ /** Advisory; feeds fusedNamesFor. */
538
+ name?: string;
539
+ }
540
+
541
+ export type FuseSetMember = FuseSetBytesMember | FuseSetLoadedMember | FuseSetHashedMember;
542
+
543
+ /**
544
+ * The 48 bytes trailer/1 appends after the original: the magic, eight
545
+ * reserved zero bytes, the commitment. A hasher whose state was saved after
546
+ * the original finishes with these and holds the member's fused digest
547
+ * without reading the original again. A test pins them against the
548
+ * placement's own build.
549
+ */
550
+ export function trailerBytesFor(commitment: Uint8Array): Uint8Array {
551
+ if (!(commitment instanceof Uint8Array) || commitment.length !== 32) throw new FuseError("bad-input", "a slot commitment is 32 bytes");
552
+ const out = new Uint8Array(TRAILER_LENGTH);
553
+ out.set(new TextEncoder().encode(TRAILER_MAGIC), 0);
554
+ out.set(commitment, TRAILER_LENGTH - 32);
555
+ return out;
556
+ }
557
+
492
558
  export interface FuseSetProgress {
493
559
  /**
494
- * "hash": origin digests, one per member, before any request.
495
- * "fuse": each member built and its fused digest taken, after the slot is held.
560
+ * "hash": each member checked before any request (a bytes member's origin digest is taken here).
561
+ * "fuse": each member's fused digest taken, after the slot is held.
496
562
  * "commit": 0 of 1 before the request, 1 of 1 when the proof is back.
497
563
  * "verify": only with verifyMembers, one per member.
498
564
  */
@@ -502,7 +568,7 @@ export interface FuseSetProgress {
502
568
  }
503
569
 
504
570
  export interface FuseSetOptions {
505
- /** Return each member's fused bytes. Default false: they are virtual, rebuilt from the original and the proof. */
571
+ /** Return each member's fused bytes. Default false: they are virtual, rebuilt from the original and the proof. A hashed member has none to return. */
506
572
  keepFused?: boolean;
507
573
  /**
508
574
  * Run the full verifier (verifyFuseMember) over every member's fused bytes
@@ -510,7 +576,8 @@ export interface FuseSetOptions {
510
576
  * false: every member is bound to the returned proof by digest, its row in
511
577
  * the committed manifest, which is itself verified FUSED_DIRECT; that is
512
578
  * linear and reads no bytes. The full pass re-hashes every member with the
513
- * verifier's own hasher and grows with the square of the member count.
579
+ * verifier's own hasher and grows with the square of the member count. A
580
+ * set with a hashed member refuses it before any request.
514
581
  */
515
582
  verifyMembers?: boolean;
516
583
  /** Called as the set advances. A throw inside it is ignored: a progress hook never changes the outcome. */
@@ -533,7 +600,7 @@ export interface FuseSetMemberResult {
533
600
  fusedName: string | null;
534
601
  /** Advisory; no Frame is written for a set member this phase. */
535
602
  frameName: string | null;
536
- /** Present only when keepFused is true. */
603
+ /** Present only when keepFused is true and the member's bytes passed through the core (never for a hashed member). */
537
604
  fusedBytes?: Uint8Array;
538
605
  /** Present only with verifyMembers: the verifier's own verdict against this member's fused bytes. Always SET_MEMBER_DIRECT on success, with set.manifestSource "argument". */
539
606
  verification?: FuseMemberResult;
@@ -562,13 +629,28 @@ export interface FuseSetResult {
562
629
  * Allocate once, fuse every member with the one commitment, hash the set
563
630
  * manifest, fill the slot with it. Returns the proof with the manifest bytes
564
631
  * beside it, or throws a FuseError; it never commits a partial set and never
565
- * allocates a second slot.
632
+ * allocates a second slot. Members may be given as bytes, as a loader read
633
+ * one at a time after the slot is held, or as a digest the caller finishes
634
+ * itself; one set may mix them.
566
635
  */
567
636
  export async function fuseSet(members: readonly FuseSetMember[], options: FuseSetOptions = {}): Promise<FuseSetResult> {
568
637
  // 0. validate, before any request. A refusal here burns nothing.
569
638
  if (!Array.isArray(members) || members.length === 0) throw new FuseError("bad-input", "a set lists at least one member");
570
639
  if (members.length > MAX_SET_MEMBERS) throw new FuseError("bad-input", `a set lists at most ${MAX_SET_MEMBERS} members (got ${members.length})`);
571
- interface Checked { placement: Placement; id: SetMemberPlacement; original: Uint8Array; originDigest: Uint8Array; name: string | null; builder: FuseBuilder }
640
+ const keep = options.keepFused === true;
641
+ const verifyMembers = options.verifyMembers === true;
642
+ type Kind = "bytes" | "loaded" | "hashed";
643
+ interface Checked {
644
+ kind: Kind;
645
+ placement: Placement;
646
+ id: SetMemberPlacement;
647
+ originDigest: Uint8Array;
648
+ name: string | null;
649
+ original: Uint8Array | null;
650
+ load: (() => Promise<Uint8Array> | Uint8Array) | null;
651
+ builder: FuseBuilder | null;
652
+ fusedDigest: ((input: FusedDigestInput) => Promise<Uint8Array> | Uint8Array) | null;
653
+ }
572
654
  const checked: Checked[] = [];
573
655
  const seen = new Map<string, number>();
574
656
  const report = (phase: FuseSetProgress["phase"], done: number, total: number) => {
@@ -579,17 +661,23 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
579
661
  // a progress hook never changes the outcome
580
662
  }
581
663
  };
664
+ const bad = (i: number, message: string) => new FuseError("bad-input", `member ${i}: ${message}`, null, i);
582
665
  for (let i = 0; i < members.length; i++) {
583
- const m = members[i];
584
- // A null, undefined or missing element is refused like any other member without original bytes.
585
- if (m === null || typeof m !== "object" || !(m.original instanceof Uint8Array)) throw new FuseError("bad-input", `member ${i}: original must be a Uint8Array`, null, i);
586
- const id = m.placement ?? placementForBytes(m.original);
666
+ const m = members[i] as Partial<FuseSetBytesMember & FuseSetLoadedMember & FuseSetHashedMember> | null | undefined;
667
+ // A null, undefined or missing element is refused like any other member without bytes, a loader or a digest.
668
+ if (m === null || typeof m !== "object") throw bad(i, "original must be a Uint8Array, or load or fusedDigest a function");
669
+ const kind: Kind | null = m.original instanceof Uint8Array ? "bytes" : typeof m.load === "function" ? "loaded" : typeof m.fusedDigest === "function" ? "hashed" : null;
670
+ if (kind === null) throw bad(i, "original must be a Uint8Array, or load or fusedDigest a function");
671
+ if (kind !== "bytes" && m.placement === undefined) throw bad(i, `a ${kind} member names its placement`);
672
+ const id = m.placement ?? placementForBytes(m.original as Uint8Array);
587
673
  const placement = getPlacement(id);
588
674
  if (placement === undefined) throw new FuseError("bad-placement", `member ${i}: placement "${id}" is not registered`, null, i);
589
- if (placement.form === "C") throw new FuseError("bad-input", `member ${i}: ${id} takes no original; a set holds trailer/1 and container/1 members only`, null, i);
590
- if (m.name !== undefined && typeof m.name !== "string") throw new FuseError("bad-input", `member ${i}: name must be a string`, null, i);
591
- if (m.builder !== undefined && typeof m.builder !== "function") throw new FuseError("bad-input", `member ${i}: builder must be a function`, null, i);
592
- const originDigest = await digest(m.original);
675
+ if (placement.form === "C") throw bad(i, `${id} takes no original; a set holds trailer/1 and container/1 members only`);
676
+ if (m.name !== undefined && typeof m.name !== "string") throw bad(i, "name must be a string");
677
+ if (m.builder !== undefined && typeof m.builder !== "function") throw bad(i, "builder must be a function");
678
+ if (kind !== "bytes" && !(m.originDigest instanceof Uint8Array && m.originDigest.length === 32)) throw bad(i, `a ${kind} member names its originDigest, 32 bytes`);
679
+ if (kind === "hashed" && verifyMembers) throw bad(i, "a hashed member cannot be verified in full; pass its bytes or drop verifyMembers");
680
+ const originDigest = kind === "bytes" ? await digest(m.original as Uint8Array) : (m.originDigest as Uint8Array);
593
681
  // The same original under the same placement fuses to the same bytes, which one manifest lists once.
594
682
  const key = `${id}:${bytesToHex(originDigest)}`;
595
683
  const j = seen.get(key);
@@ -597,7 +685,17 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
597
685
  throw new FuseError("bad-input", `members ${j} and ${i} are the same original under the same placement (${id}) and would fuse to the same bytes; a set lists each fused artifact once`, null, i);
598
686
  }
599
687
  seen.set(key, i);
600
- checked.push({ placement, id, original: m.original, originDigest, name: m.name ?? null, builder: m.builder ?? builderFor(id, m.original) });
688
+ checked.push({
689
+ kind,
690
+ placement,
691
+ id,
692
+ originDigest,
693
+ name: m.name ?? null,
694
+ original: kind === "bytes" ? (m.original as Uint8Array) : null,
695
+ load: kind === "loaded" ? (m.load as Checked["load"]) : null,
696
+ builder: kind !== "hashed" && m.builder !== undefined ? (m.builder as FuseBuilder) : null,
697
+ fusedDigest: kind === "hashed" ? (m.fusedDigest as Checked["fusedDigest"]) : null,
698
+ });
601
699
  report("hash", i + 1, members.length);
602
700
  }
603
701
  const t: BoundTransport = { ...DEFAULTS, ...(options.transport ?? {}) };
@@ -605,41 +703,72 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
605
703
  // 1. nonce: one slot for the whole set
606
704
  const slot = await allocateSlot(t);
607
705
 
608
- // 2. fuse: the commitment once, every member's bytes carrying it. The slot
609
- // is held and its TTL is running; a throw here burns it but commits nothing.
706
+ // 2. fuse: the commitment once, every member's digest under it. The slot
707
+ // is held and its TTL is running; a throw here burns it but commits
708
+ // nothing. A member's fused bytes are virtual: each is built, hashed
709
+ // and released in turn, so memory holds one member's bytes at a time.
710
+ // They are held only for a caller who keeps them or asks the full
711
+ // verifier to read them.
610
712
  const commitment = computeSlotCommitment(slot);
611
713
  const commitmentHex = bytesToHex(commitment);
612
- const keep = options.keepFused === true;
613
- const verifyMembers = options.verifyMembers === true;
614
- // A member's fused bytes are virtual: each is built, hashed and released in
615
- // turn, so memory holds the originals and one fused copy. They are held only
616
- // for a caller who keeps them or asks the full verifier to read them.
617
714
  const fusedBytes: (Uint8Array | null)[] = [];
618
715
  const rows: SetMember[] = [];
716
+ const expiring = "nothing was committed and the slot will expire";
619
717
  for (let i = 0; i < checked.length; i++) {
620
718
  const c = checked[i]!;
621
- let fused: Uint8Array;
622
- try {
623
- fused = await c.builder({ commitment, commitmentHex, originDigest: c.originDigest, slot });
624
- } catch (err) {
625
- throw new FuseError("builder-failed", `member ${i}: the builder threw: ${err instanceof Error ? err.message : String(err)}`, null, i);
626
- }
627
- if (!(fused instanceof Uint8Array)) throw new FuseError("builder-failed", `member ${i}: the builder must return a Uint8Array`, null, i);
628
- const located = requireCommitment(c.placement, fused, commitment, i);
629
- // The row's origin must be the origin the bytes embed, else the member
630
- // would verify INVALID_ORIGIN_ATTRIBUTION after the slot is spent. Both
631
- // facts are checked when both are present: the digest the bytes declare
632
- // (container/1's payload) and the bytes they carry, compared byte for
633
- // byte with the member's original rather than hashed again, so a builder
634
- // cannot pack other bytes under the member's digest and leave a member no
635
- // original rebuilds.
636
- const declared = located.originDigest;
637
- const carried = located.originalBytes;
638
- if ((declared !== undefined && !bytesEqual(declared, c.originDigest)) || (carried !== undefined && !bytesEqual(carried, c.original))) {
639
- throw new FuseError("builder-failed", `member ${i}: the fused bytes embed an origin that is not the member's original; nothing was committed and the slot will expire`, null, i);
719
+ let artifact: Uint8Array;
720
+ let held: Uint8Array | null = null;
721
+ if (c.kind === "hashed") {
722
+ let d: unknown;
723
+ try {
724
+ d = await c.fusedDigest!({ commitment, commitmentHex, slot });
725
+ } catch (err) {
726
+ throw new FuseError("builder-failed", `member ${i}: fusedDigest threw: ${err instanceof Error ? err.message : String(err)}; ${expiring}`, null, i);
727
+ }
728
+ if (!(d instanceof Uint8Array) || d.length !== 32) throw new FuseError("builder-failed", `member ${i}: fusedDigest must return a 32-byte digest; ${expiring}`, null, i);
729
+ artifact = d;
730
+ } else {
731
+ let original: Uint8Array;
732
+ if (c.kind === "loaded") {
733
+ let loaded: unknown;
734
+ try {
735
+ loaded = await c.load!();
736
+ } catch (err) {
737
+ throw new FuseError("load-failed", `member ${i}: load threw: ${err instanceof Error ? err.message : String(err)}; ${expiring}`, null, i);
738
+ }
739
+ if (!(loaded instanceof Uint8Array)) throw new FuseError("load-failed", `member ${i}: load must return a Uint8Array; ${expiring}`, null, i);
740
+ original = loaded;
741
+ // The digest the caller named is the row's origin; it must be these bytes' own.
742
+ if (!bytesEqual(await digest(original), c.originDigest)) throw new FuseError("bad-input", `member ${i}: originDigest is not the SHA-256 of the loaded bytes; ${expiring}`, null, i);
743
+ } else {
744
+ original = c.original!;
745
+ }
746
+ const builder = c.builder ?? builderFor(c.id, original);
747
+ let fused: Uint8Array;
748
+ try {
749
+ fused = await builder({ commitment, commitmentHex, originDigest: c.originDigest, slot });
750
+ } catch (err) {
751
+ throw new FuseError("builder-failed", `member ${i}: the builder threw: ${err instanceof Error ? err.message : String(err)}`, null, i);
752
+ }
753
+ if (!(fused instanceof Uint8Array)) throw new FuseError("builder-failed", `member ${i}: the builder must return a Uint8Array`, null, i);
754
+ const located = requireCommitment(c.placement, fused, commitment, i);
755
+ // The row's origin must be the origin the bytes embed, else the member
756
+ // would verify INVALID_ORIGIN_ATTRIBUTION after the slot is spent. Both
757
+ // facts are checked when both are present: the digest the bytes declare
758
+ // (container/1's payload) and the bytes they carry, compared byte for
759
+ // byte with the member's original rather than hashed again, so a builder
760
+ // cannot pack other bytes under the member's digest and leave a member no
761
+ // original rebuilds.
762
+ const declared = located.originDigest;
763
+ const carried = located.originalBytes;
764
+ if ((declared !== undefined && !bytesEqual(declared, c.originDigest)) || (carried !== undefined && !bytesEqual(carried, original))) {
765
+ throw new FuseError("builder-failed", `member ${i}: the fused bytes embed an origin that is not the member's original; ${expiring}`, null, i);
766
+ }
767
+ artifact = await digest(fused);
768
+ if (keep || verifyMembers) held = fused;
640
769
  }
641
- rows.push({ artifact: await digest(fused), origin: c.originDigest, placement: c.id });
642
- fusedBytes.push(keep || verifyMembers ? fused : null);
770
+ rows.push({ artifact, origin: c.originDigest, placement: c.id });
771
+ fusedBytes.push(held);
643
772
  report("fuse", i + 1, checked.length);
644
773
  }
645
774
 
@@ -648,7 +777,7 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
648
777
  try {
649
778
  manifestBytes = buildSetManifest(commitment, rows);
650
779
  } catch (err) {
651
- throw new FuseError("bad-input", `the set manifest could not be built: ${err instanceof Error ? err.message : String(err)}; nothing was committed and the slot will expire`);
780
+ throw new FuseError("bad-input", `the set manifest could not be built: ${err instanceof Error ? err.message : String(err)}; ${expiring}`);
652
781
  }
653
782
  const artifactDigestB64 = bytesToBase64(await digest(manifestBytes));
654
783
  const manifest = JSON.parse(new TextDecoder().decode(manifestBytes)) as SetManifest;
@@ -712,6 +841,7 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
712
841
  report("verify", i + 1, checked.length);
713
842
  }
714
843
  const names = c.name !== null ? fusedNamesFor(c.name, c.id) : null;
844
+ const held = fusedBytes[i];
715
845
  results.push({
716
846
  index: i,
717
847
  manifestIndex: k,
@@ -720,7 +850,7 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
720
850
  artifactDigestB64: memberArtifactB64,
721
851
  fusedName: names?.fusedName ?? null,
722
852
  frameName: names?.frameName ?? null,
723
- ...(keep ? { fusedBytes: fusedBytes[i]! } : {}),
853
+ ...(keep && held !== null ? { fusedBytes: held } : {}),
724
854
  ...(verification !== undefined ? { verification } : {}),
725
855
  });
726
856
  }
package/src/index.ts CHANGED
@@ -40,9 +40,9 @@ export { Constructor } from "./constructor.js";
40
40
  // The producer profile over the primitive (working name Fuse): allocate a
41
41
  // slot, write a commitment to it into the artifact, hash, commit under the
42
42
  // same slot. The resulting proof is ordinary bitgraph/1.
43
- export { fuse, fuseSet, MAX_SET_MEMBERS, builderFor, FuseError, digestFromBase64, placementForBytes, fusedNamesFor } from "./fuse.js";
43
+ export { fuse, fuseSet, MAX_SET_MEMBERS, trailerBytesFor, builderFor, FuseError, digestFromBase64, placementForBytes, fusedNamesFor } from "./fuse.js";
44
44
  export type { FuseBuilder, BuilderInput, FuseOptions, FuseResult, FuseTransport, FuseErrorCode } from "./fuse.js";
45
- export type { FuseSetMember, SetMemberPlacement, FuseSetOptions, FuseSetProgress, FuseSetMemberResult, FuseSetResult } from "./fuse.js";
45
+ export type { FuseSetMember, FuseSetBytesMember, FuseSetLoadedMember, FuseSetHashedMember, FusedDigestInput, SetMemberPlacement, FuseSetOptions, FuseSetProgress, FuseSetMemberResult, FuseSetResult } from "./fuse.js";
46
46
  // The verify-package types those results are made of, so the core entry names everything it returns.
47
47
  export type { FuseFrame, PlacementId, SetManifest, FuseMemberResult, FuseVerifyResult } from "@mikeargento/bitgraph-verify";
48
48