@mikeargento/bitgraph 1.10.1 → 1.12.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
@@ -1,6 +1,16 @@
1
1
  // Copyright (c) Argento Computing Inc. All rights reserved. See LICENSE.
2
2
 
3
3
  /**
4
+ * fuseTree(members, options): the producer of every NEW BitGraph since
5
+ * 2026-10-03 (tree/1). One position, one Merkle tree of 1 to N files; a
6
+ * single file is a tree of one. Each file is a 65-byte leaf (placement code,
7
+ * committed digest, origin digest); the committed artifact is the 84-byte
8
+ * root document; the signed attribution is bitgraph-fuse/2 with title
9
+ * "tree/1" and the spec's hash as its message. tree/1 needs the floor the
10
+ * allocation returns (enclave v9 and later): without it nothing is made.
11
+ * fuse() and fuseSet() below are superseded by it and kept so code that
12
+ * imports them keeps working; what they made stays readable.
13
+ *
4
14
  * fuse(builder, options): the producer interface of the bitgraph-fuse/1
5
15
  * profile (working name; outwardly this is simply BitGraph).
6
16
  *
@@ -63,8 +73,21 @@ import {
63
73
  verifyFuse,
64
74
  verifyFuseMember,
65
75
  base64ToBytes,
76
+ buildTree,
77
+ buildTreeMemberEvidence,
78
+ buildTreeRootDocument,
79
+ currentTreeSpecHash,
80
+ leafCodeOf,
81
+ LEAF_AS_IS,
82
+ MAX_TREE_LEAVES,
83
+ readTreeMetadata,
84
+ TREE_METADATA_KEY,
85
+ treeAttribution,
86
+ treeRootFromMember,
87
+ verifyTreeMember,
88
+ MAX_CONTAINER_ENTRY_BYTES,
66
89
  } from "@mikeargento/bitgraph-verify";
67
- import type { BitGraphProof, FuseFrame, FuseMemberResult, FuseVerifyResult, Located, Placement, PlacementId, SetManifest, SetMember, SetMemberProof, SetRoot, SlotAllocation } from "@mikeargento/bitgraph-verify";
90
+ import type { BitGraphProof, FuseFrame, FuseMemberResult, FuseVerifyResult, Located, MerkleTree, Placement, PlacementId, SetManifest, SetMember, SetMemberProof, SetRoot, SlotAllocation, TreeLeaf, TreeMemberEvidence, TreeVerifyResult } from "@mikeargento/bitgraph-verify";
68
91
 
69
92
  /**
70
93
  * SHA-256 over bytes: the platform's native hasher when one is present
@@ -94,15 +117,36 @@ export interface AnchorMark {
94
117
  blockHash: string;
95
118
  }
96
119
 
120
+ /** The Base floor an enclave v10 allocation hands back: the one it signs at commit as commit.slotFloor. */
121
+ export interface BaseFloorMark {
122
+ chain: "base";
123
+ evmChainId: 8453;
124
+ blockNumber: number;
125
+ blockHash: string;
126
+ blockTimestamp: number;
127
+ }
128
+
129
+ /** Either floor an allocation can return. A Base floor makes a fuse/3 commitment, an Ethereum anchor a fuse/2 one. */
130
+ export type FloorMark = AnchorMark | BaseFloorMark;
131
+
132
+ export function isBaseFloorMark(x: unknown): x is BaseFloorMark {
133
+ if (x === null || typeof x !== "object" || Array.isArray(x)) return false;
134
+ const a = x as Record<string, unknown>;
135
+ return a.chain === "base" && a.evmChainId === 8453
136
+ && typeof a.blockNumber === "number" && Number.isSafeInteger(a.blockNumber) && a.blockNumber > 0
137
+ && typeof a.blockHash === "string" && /^0x[0-9a-f]{64}$/.test(a.blockHash)
138
+ && typeof a.blockTimestamp === "number" && Number.isSafeInteger(a.blockTimestamp);
139
+ }
140
+
97
141
  /** What the builder receives. The raw nonce is deliberately absent. */
98
142
  export interface BuilderInput {
99
- /** 32-byte commitment to the signed slot record (bitgraph-fuse/2 also binds the floor block). Write this into the artifact. */
143
+ /** 32-byte commitment to the signed slot record (fuse/2 and fuse/3 also bind the floor block). Write this into the artifact. */
100
144
  commitment: Uint8Array;
101
145
  commitmentHex: string;
102
- /** Which commitment this is: 2 when the boundary returned its floor anchor (enclave v9 and later), else 1. */
103
- fuseVersion: 1 | 2;
104
- /** The floor bound into a fuse/2 commitment, when there is one. */
105
- floor?: AnchorMark;
146
+ /** Which commitment this is: 3 for a Base floor (enclave v10), 2 for an Ethereum floor anchor (v9), else 1. */
147
+ fuseVersion: 1 | 2 | 3;
148
+ /** The floor bound into the commitment, when there is one. */
149
+ floor?: FloorMark;
106
150
  /** The origin digest, when the fused artifact names a source. */
107
151
  originDigest?: Uint8Array;
108
152
  /** The signed slot record, for producers that want to embed its fields. Contains the nonce: do not copy it into the artifact. */
@@ -172,7 +216,9 @@ export type FuseErrorCode =
172
216
  | "network"
173
217
  | "slot-mismatch"
174
218
  | "verification-failed"
175
- | "transport";
219
+ | "transport"
220
+ /** tree/1 only: the allocation returned no floor anchor (a boundary before enclave v9), so no tree/1 commitment can be made. */
221
+ | "floor-missing";
176
222
 
177
223
  export class FuseError extends Error {
178
224
  readonly code: FuseErrorCode;
@@ -343,11 +389,12 @@ function isAnchorMark(x: unknown): x is AnchorMark {
343
389
 
344
390
  /**
345
391
  * 1. nonce. The signed slot record from the boundary; it must sit on the
346
- * anchored chain. Since enclave v9 the response also carries the floor anchor
347
- * the boundary will sign at commit; with it the producer makes a
348
- * bitgraph-fuse/2 commitment, without it a fuse/1 one.
392
+ * anchored chain. The response also carries the floor the boundary will sign
393
+ * at commit: a Base block since enclave v10 (a fuse/3 commitment), an Ethereum
394
+ * anchor on v9 (fuse/2); without one the producer makes a fuse/1 commitment.
395
+ * A response carrying both is refused: a proof has one floor.
349
396
  */
350
- async function allocateSlot(t: BoundTransport): Promise<{ slot: SlotAllocation; anchor: AnchorMark | null }> {
397
+ async function allocateSlot(t: BoundTransport): Promise<{ slot: SlotAllocation; anchor: FloorMark | null }> {
351
398
  const alloc = await request(t, t.allocatePath, { method: "POST", body: {} });
352
399
  if (alloc.status === 503 && codeOf(alloc.json) === "tee-restarting") throw new FuseError("tee-restarting", messageOf(alloc.json, "the boundary is restarting"), 503);
353
400
  if (alloc.status !== 200) throw new FuseError("allocate-failed", messageOf(alloc.json, `allocation failed (${alloc.status})`), alloc.status);
@@ -356,7 +403,11 @@ async function allocateSlot(t: BoundTransport): Promise<{ slot: SlotAllocation;
356
403
  if (!isSlotRecord(slot) || slotId !== slot.nonceB64) throw new FuseError("allocate-failed", "the allocation response is not a slot record", alloc.status);
357
404
  if (slot.chainId !== "bitgraph:main") throw new FuseError("allocate-failed", "the slot is not on the anchored chain; a fused floor needs bitgraph:main");
358
405
  const anchorRaw = (alloc.json as { anchor?: unknown } | null)?.anchor;
359
- return { slot, anchor: isAnchorMark(anchorRaw) ? anchorRaw : null };
406
+ const floorRaw = (alloc.json as { floor?: unknown } | null)?.floor;
407
+ const eth = isAnchorMark(anchorRaw) ? anchorRaw : null;
408
+ const base = isBaseFloorMark(floorRaw) ? floorRaw : null;
409
+ if (eth && base) throw new FuseError("allocate-failed", "the allocation returned two floors (an Ethereum anchor and a Base block); a proof has one, so nothing was bound");
410
+ return { slot, anchor: base ?? eth };
360
411
  }
361
412
 
362
413
  /**
@@ -368,7 +419,12 @@ function requireCommitment(placement: Placement, fused: Uint8Array, commitment:
368
419
  const located = placement.locate(fused);
369
420
  if (located === null || bytesToHex(located.commitment) !== bytesToHex(commitment)) {
370
421
  const label = member !== null ? `member ${member}: ` : "";
371
- throw new FuseError("commitment-missing", `${label}the fused bytes do not carry the ${placement.id} commitment; nothing was committed and the slot will expire`, null, member);
422
+ // A container is located only when its archive, the original inside it
423
+ // (hashed against the origin it declares) and the commitment all hold.
424
+ const what = placement.form === "B"
425
+ ? `are not a valid ${placement.id} carrying this position's commitment: the archive, the original inside it or the commitment does not hold`
426
+ : `do not carry the ${placement.id} commitment`;
427
+ throw new FuseError("commitment-missing", `${label}the fused bytes ${what}; nothing was committed and the slot will expire`, null, member);
372
428
  }
373
429
  return located;
374
430
  }
@@ -420,6 +476,9 @@ async function commitUnderSlot(t: BoundTransport, body: Record<string, unknown>,
420
476
  * Allocate, fuse, hash, fill. Returns the Frame with the unchanged proof, or
421
477
  * throws a FuseError; it never returns an ordinary recording in place of a
422
478
  * fused one.
479
+ *
480
+ * Superseded by fuseTree (tree/1, 2026-10-03): a new BitGraph of one file is
481
+ * a tree of one. Kept so code that imports it keeps working.
423
482
  */
424
483
  export async function fuse(builder: FuseBuilder, options: FuseOptions): Promise<FuseResult> {
425
484
  const placement = getPlacement(options.placement);
@@ -459,7 +518,8 @@ export async function fuse(builder: FuseBuilder, options: FuseOptions): Promise<
459
518
  chainId: "bitgraph:main",
460
519
  attribution,
461
520
  // fuse/2: the boundary checks the bound floor against its ledger before spending the slot.
462
- ...(version === 2 ? { anchor } : {}),
521
+ // fuse/3: the bound Base floor, so the boundary can check it is the one it handed out.
522
+ ...(version === 2 ? { anchor } : version === 3 ? { floor: anchor } : {}),
463
523
  };
464
524
  if (options.agency !== undefined) body.agency = options.agency;
465
525
  const { proof, recovered } = await commitUnderSlot(t, body, artifactDigestB64, slot);
@@ -510,10 +570,10 @@ export type SetMemberPlacement = "trailer/1" | "container/1" | "container/2";
510
570
 
511
571
  /** What a hashed member's fused digest is computed for: the held slot and its commitment. */
512
572
  export interface FusedDigestInput {
513
- /** Which commitment this is: 2 when the boundary returned its floor anchor, else 1. */
514
- fuseVersion: 1 | 2;
515
- /** The floor bound into a fuse/2 commitment, when there is one. */
516
- floor?: AnchorMark;
573
+ /** Which commitment this is: 3 for a Base floor, 2 for an Ethereum floor anchor, else 1. */
574
+ fuseVersion: 1 | 2 | 3;
575
+ /** The floor bound into the commitment, when there is one. */
576
+ floor?: FloorMark;
517
577
  commitment: Uint8Array;
518
578
  commitmentHex: string;
519
579
  slot: SlotAllocation;
@@ -681,6 +741,9 @@ export interface FuseSetResult {
681
741
  * allocates a second slot. Members may be given as bytes, as a loader read
682
742
  * one at a time after the slot is held, or as a digest the caller finishes
683
743
  * itself; one set may mix them.
744
+ *
745
+ * Superseded by fuseTree (tree/1, 2026-10-03): N files are one tree under one
746
+ * position. Kept so code that imports it keeps working.
684
747
  */
685
748
  export async function fuseSet(members: readonly FuseSetMember[], options: FuseSetOptions = {}): Promise<FuseSetResult> {
686
749
  // 0. validate, before any request. A refusal here burns nothing.
@@ -851,7 +914,7 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
851
914
  chainId: "bitgraph:main",
852
915
  attribution: fuseAttribution(setKind === "set/1" ? SET_PLACEMENT_ID_LOCAL : SET2_PLACEMENT_ID, undefined, version),
853
916
  metadata: { [SET_METADATA_KEY]: manifest },
854
- ...(version === 2 ? { anchor } : {}),
917
+ ...(version === 2 ? { anchor } : version === 3 ? { floor: anchor } : {}),
855
918
  };
856
919
  if (options.agency !== undefined) body.agency = options.agency;
857
920
  report("commit", 0, 1);
@@ -954,6 +1017,445 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
954
1017
  };
955
1018
  }
956
1019
 
1020
+ // ---------------------------------------------------------------------------
1021
+ // tree/1: every BitGraph is a Merkle tree under one position (2026-10-03)
1022
+ // ---------------------------------------------------------------------------
1023
+
1024
+ /** How a file goes into a tree: as is (0x00), or placed into committed bytes that carry the commitment. */
1025
+ export type TreeMemberPlacement = "as-is" | SetMemberPlacement;
1026
+
1027
+ /**
1028
+ * The placement a file takes in a tree, from its size and its first bytes
1029
+ * (every magic number placementForBytes reads sits in the first 16): trailer/1
1030
+ * for the formats that ignore trailing bytes, container/2 for everything else,
1031
+ * except that a file too large for a ustar entry (MAX_CONTAINER_ENTRY_BYTES,
1032
+ * 8 GiB - 1, SPEC 7.3) takes trailer/1 whatever its bytes: the committed
1033
+ * bytes are virtual, so a trailer costs the file nothing. Never as is: that
1034
+ * is the user's choice, and no producer decides it by size (SPEC 7.5, 8.1).
1035
+ */
1036
+ export function treePlacementFor(size: number, head: Uint8Array): "trailer/1" | "container/2" {
1037
+ return size > MAX_CONTAINER_ENTRY_BYTES ? "trailer/1" : placementForBytes(head);
1038
+ }
1039
+
1040
+ /** A tree member given as bytes: hashed, placed (unless as is), checked and hashed again by the core. */
1041
+ export interface FuseTreeBytesMember {
1042
+ original: Uint8Array;
1043
+ /** Default: treePlacementFor(original.length, original). As is is asked for by name, never by size. */
1044
+ placement?: TreeMemberPlacement;
1045
+ /** Unsigned and informational: the owner's export lists it beside the leaf. */
1046
+ name?: string;
1047
+ /** Placed members only. Default: the placement's own build. The locate and origin guards run regardless. */
1048
+ builder?: FuseBuilder;
1049
+ }
1050
+
1051
+ /** A placed member read only when it is its turn, after the slot is held, and checked against its named digest. */
1052
+ export interface FuseTreeLoadedMember {
1053
+ load: () => Promise<Uint8Array> | Uint8Array;
1054
+ originDigest: Uint8Array;
1055
+ placement: SetMemberPlacement;
1056
+ name?: string;
1057
+ builder?: FuseBuilder;
1058
+ }
1059
+
1060
+ /**
1061
+ * A placed member whose committed digest the caller finishes itself for the
1062
+ * held commitment (a scanner's saved hash state finished with the placement's
1063
+ * suffix). The core never sees its bytes.
1064
+ */
1065
+ export interface FuseTreeHashedMember {
1066
+ originDigest: Uint8Array;
1067
+ placement: SetMemberPlacement;
1068
+ fusedDigest: (input: FusedDigestInput) => Promise<Uint8Array> | Uint8Array;
1069
+ name?: string;
1070
+ }
1071
+
1072
+ /** A file that goes in as is, given by its digest alone: nothing is read, placed or loaded. */
1073
+ export interface FuseTreeAsIsMember {
1074
+ originDigest: Uint8Array;
1075
+ placement: "as-is";
1076
+ name?: string;
1077
+ }
1078
+
1079
+ export type FuseTreeMember = FuseTreeBytesMember | FuseTreeLoadedMember | FuseTreeHashedMember | FuseTreeAsIsMember;
1080
+
1081
+ /** Progress, in fuseSet's phases: hash (before any request), fuse (each leaf, after the slot is held), tree, commit, verify (only with verifyMembers). */
1082
+ export type FuseTreeProgress = FuseSetProgress;
1083
+
1084
+ export interface FuseTreeOptions {
1085
+ /** Return each member's committed bytes (for as is, the original itself) when they passed through the core. Default false: they are virtual, rebuilt from the original and the proof. */
1086
+ keepCommitted?: boolean;
1087
+ /**
1088
+ * Run verifyTreeMember over every member's committed bytes after the
1089
+ * commit and return each verdict. Default false: every member's leaf is
1090
+ * bound to the returned proof by its path to the verified root, which
1091
+ * reads no bytes. A hashed member, or an as-is member given by its digest,
1092
+ * has no bytes here and is refused before any request.
1093
+ */
1094
+ verifyMembers?: boolean;
1095
+ /** Called as the tree advances. A throw inside it is ignored: a progress hook never changes the outcome. */
1096
+ onProgress?: (progress: FuseTreeProgress) => void;
1097
+ /** Actor-bound commits: an agency envelope passed through untouched. */
1098
+ agency?: unknown;
1099
+ transport?: FuseTransport;
1100
+ }
1101
+
1102
+ export interface FuseTreeMemberResult {
1103
+ /** The caller's index into members. */
1104
+ index: number;
1105
+ /** The member's leaf in the sorted tree: its evidence's index. */
1106
+ leafIndex: number;
1107
+ placement: TreeMemberPlacement;
1108
+ /** The leaf's placement code: 0x00 as is, 0x01 trailer/1, 0x02 container/1, 0x03 container/2. */
1109
+ code: number;
1110
+ originDigestB64: string;
1111
+ /** SHA-256 of the committed bytes: the leaf's artifact. For as is, the file's own digest. */
1112
+ artifactDigestB64: string;
1113
+ name: string | null;
1114
+ /** Present only with keepCommitted, for a member whose bytes passed through the core (never a hashed member, nor an as-is member given by its digest). */
1115
+ committedBytes?: Uint8Array;
1116
+ /** Present only with verifyMembers: the verifier's verdict on the committed bytes, TREE_MEMBER_DIRECT (TREE_MEMBER_AS_IS for as is) on success. */
1117
+ verification?: TreeVerifyResult;
1118
+ }
1119
+
1120
+ export interface FuseTreeResult {
1121
+ proof: BitGraphProof;
1122
+ /** The committed artifact: the 84-byte root document. Its SHA-256 is the signed digest; every export carries it. */
1123
+ rootDocument: Uint8Array;
1124
+ /** SHA-256 of rootDocument, standard base64; equals proof.artifact.digestB64. */
1125
+ artifactDigestB64: string;
1126
+ count: number;
1127
+ /** The tree's root, lowercase hex. */
1128
+ rootHex: string;
1129
+ /** commitment/2 or /3, which every placed member's committed bytes carry. committedBytesFor(member.code, original, commitment) rebuilds them. */
1130
+ commitment: Uint8Array;
1131
+ /** The floor block the commitment binds, as the proof signs it (commit.slotAnchor or commit.slotFloor). */
1132
+ floor: FloorMark;
1133
+ /** The spec hash the signed attribution pins, standard base64. */
1134
+ specHashB64: string;
1135
+ /** Every leaf, in tree order (strictly ascending artifact digest). */
1136
+ leaves: TreeLeaf[];
1137
+ /** The tree over those leaves: tree.path(k) is leaf k's path. */
1138
+ tree: MerkleTree;
1139
+ /** In the caller's order. */
1140
+ members: FuseTreeMemberResult[];
1141
+ /** A member's evidence (TreeMemberEvidence JSON) by the caller's index, built on demand so a large tree holds no path it is not asked for. */
1142
+ memberEvidence: (index: number) => TreeMemberEvidence;
1143
+ /** True when the commit response was lost and the proof was read back by the root document's digest. */
1144
+ recovered: boolean;
1145
+ /** True only when the proof's metadata carries this root document. Absent is normal for a boundary that drops metadata; exports carry the document either way. */
1146
+ rootDocumentEchoed: boolean;
1147
+ /** verifyTreeMember over the proof and the root document: TREE_ROOT_VALID on success. */
1148
+ verification: TreeVerifyResult;
1149
+ }
1150
+
1151
+ function compareDigests(a: Uint8Array, b: Uint8Array): number {
1152
+ for (let i = 0; i < a.length && i < b.length; i++) if (a[i] !== b[i]) return a[i]! - b[i]!;
1153
+ return a.length - b.length;
1154
+ }
1155
+
1156
+ /** The index of the leaf with this artifact digest in a sorted list, or -1. */
1157
+ function leafIndexOf(sorted: readonly TreeLeaf[], artifact: Uint8Array): number {
1158
+ let lo = 0;
1159
+ let hi = sorted.length - 1;
1160
+ while (lo <= hi) {
1161
+ const mid = (lo + hi) >>> 1;
1162
+ const c = compareDigests(sorted[mid]!.artifact, artifact);
1163
+ if (c === 0) return mid;
1164
+ if (c < 0) lo = mid + 1;
1165
+ else hi = mid - 1;
1166
+ }
1167
+ return -1;
1168
+ }
1169
+
1170
+ /**
1171
+ * Make ONE tree/1 BitGraph of 1 to N files: allocate one slot, take its floor,
1172
+ * make every member's leaf under commitment/2, build the tree and its root
1173
+ * document, commit the document's digest under the same slot with the spec
1174
+ * pinned in the signed attribution, and verify what comes back before
1175
+ * returning it. Throws a FuseError otherwise; it never commits a partial
1176
+ * tree, never allocates a second slot, and never makes a tree without the
1177
+ * floor. Members may be bytes, a loader read after the slot is held, a digest
1178
+ * the caller finishes from a saved hash state, or (for as is) a digest alone;
1179
+ * one tree may mix them.
1180
+ */
1181
+ export async function fuseTree(members: readonly FuseTreeMember[], options: FuseTreeOptions = {}): Promise<FuseTreeResult> {
1182
+ // 0. validate, before any request. A refusal here burns nothing.
1183
+ if (!Array.isArray(members) || members.length === 0) throw new FuseError("bad-input", "a tree lists at least one member");
1184
+ if (members.length > MAX_TREE_LEAVES) throw new FuseError("bad-input", `a tree lists at most ${MAX_TREE_LEAVES} members (got ${members.length})`);
1185
+ const keep = options.keepCommitted === true;
1186
+ const verifyMembers = options.verifyMembers === true;
1187
+ let specHash: Uint8Array;
1188
+ try {
1189
+ specHash = currentTreeSpecHash();
1190
+ } catch (err) {
1191
+ throw new FuseError("bad-input", `no tree/1 spec hash to pin: ${err instanceof Error ? err.message : String(err)}`);
1192
+ }
1193
+ type Kind = "bytes" | "loaded" | "hashed" | "as-is";
1194
+ interface Checked {
1195
+ kind: Kind;
1196
+ id: TreeMemberPlacement;
1197
+ code: number;
1198
+ /** The registered placement; null for as is. */
1199
+ placement: Placement | null;
1200
+ originDigest: Uint8Array;
1201
+ name: string | null;
1202
+ original: Uint8Array | null;
1203
+ load: (() => Promise<Uint8Array> | Uint8Array) | null;
1204
+ builder: FuseBuilder | null;
1205
+ fusedDigest: ((input: FusedDigestInput) => Promise<Uint8Array> | Uint8Array) | null;
1206
+ }
1207
+ const checked: Checked[] = [];
1208
+ const seen = new Map<string, number>();
1209
+ const report = (phase: FuseTreeProgress["phase"], done: number, total: number) => {
1210
+ if (options.onProgress === undefined) return;
1211
+ try {
1212
+ options.onProgress({ phase, done, total });
1213
+ } catch {
1214
+ // a progress hook never changes the outcome
1215
+ }
1216
+ };
1217
+ const bad = (i: number, message: string) => new FuseError("bad-input", `member ${i}: ${message}`, null, i);
1218
+ const shapes = 'original must be a Uint8Array, or load or fusedDigest a function, or placement "as-is" with an originDigest';
1219
+ interface Loose { original?: unknown; load?: unknown; fusedDigest?: unknown; placement?: unknown; originDigest?: unknown; name?: unknown; builder?: unknown }
1220
+ for (let i = 0; i < members.length; i++) {
1221
+ // A null, undefined or missing element is refused like any other member without bytes, a loader or a digest.
1222
+ const m = members[i] as Loose | null | undefined;
1223
+ if (m === null || m === undefined || typeof m !== "object") throw bad(i, shapes);
1224
+ const kind: Kind | null = m.original instanceof Uint8Array ? "bytes" : typeof m.load === "function" ? "loaded" : typeof m.fusedDigest === "function" ? "hashed" : m.placement === "as-is" ? "as-is" : null;
1225
+ if (kind === null) throw bad(i, shapes);
1226
+ if (kind !== "bytes" && m.placement === undefined) throw bad(i, `a ${kind} member names its placement`);
1227
+ if (m.placement !== undefined && typeof m.placement !== "string") throw bad(i, "placement must be a string");
1228
+ const original = kind === "bytes" ? (m.original as Uint8Array) : null;
1229
+ const id = m.placement !== undefined ? (m.placement as string) : treePlacementFor(original!.length, original!);
1230
+ const code = leafCodeOf(id);
1231
+ if (code === null) {
1232
+ if (getPlacement(id) === undefined) throw new FuseError("bad-placement", `member ${i}: placement "${id}" is not registered`, null, i);
1233
+ throw bad(i, `${id} is not a tree placement; a tree holds as-is, trailer/1, container/1 and container/2 members`);
1234
+ }
1235
+ if ((kind === "loaded" || kind === "hashed") && code === LEAF_AS_IS) throw bad(i, `an as-is member is given by its bytes or its originDigest alone; a ${kind} member is placed`);
1236
+ if (m.name !== undefined && typeof m.name !== "string") throw bad(i, "name must be a string");
1237
+ if (m.builder !== undefined && typeof m.builder !== "function") throw bad(i, "builder must be a function");
1238
+ if (code === LEAF_AS_IS && m.builder !== undefined) throw bad(i, "an as-is member takes no builder: nothing is placed in it");
1239
+ if (kind !== "bytes" && !(m.originDigest instanceof Uint8Array && m.originDigest.length === 32)) throw bad(i, `${kind === "as-is" ? "an as-is" : `a ${kind}`} member names its originDigest, 32 bytes`);
1240
+ if (verifyMembers && (kind === "hashed" || kind === "as-is")) throw bad(i, `${kind === "as-is" ? "an as-is member given by its digest" : "a hashed member"} cannot be verified in full; pass its bytes or drop verifyMembers`);
1241
+ const originDigest = original !== null ? await digest(original) : (m.originDigest as Uint8Array);
1242
+ // The same original under the same placement makes the same leaf, which a tree lists once.
1243
+ const key = `${code}:${bytesToHex(originDigest)}`;
1244
+ const j = seen.get(key);
1245
+ if (j !== undefined) {
1246
+ throw new FuseError("bad-input", `members ${j} and ${i} are the same original under the same placement (${id}) and would make the same leaf; a tree lists each leaf once`, null, i);
1247
+ }
1248
+ seen.set(key, i);
1249
+ checked.push({
1250
+ kind,
1251
+ id: id as TreeMemberPlacement,
1252
+ code,
1253
+ placement: code === LEAF_AS_IS ? null : getPlacement(id)!,
1254
+ originDigest,
1255
+ name: typeof m.name === "string" ? m.name : null,
1256
+ original,
1257
+ load: kind === "loaded" ? (m.load as Checked["load"]) : null,
1258
+ builder: (kind === "bytes" || kind === "loaded") && m.builder !== undefined ? (m.builder as FuseBuilder) : null,
1259
+ fusedDigest: kind === "hashed" ? (m.fusedDigest as Checked["fusedDigest"]) : null,
1260
+ });
1261
+ report("hash", i + 1, members.length);
1262
+ }
1263
+ const t: BoundTransport = { ...DEFAULTS, ...(options.transport ?? {}) };
1264
+
1265
+ // 1. nonce: one slot for the whole tree, and the floor it binds.
1266
+ const { slot, anchor } = await allocateSlot(t);
1267
+ if (anchor === null) {
1268
+ throw new FuseError("floor-missing", "the allocation returned no floor, so no tree/1 commitment can be made (tree/1 binds the floor block: bitgraph-fuse/2 or /3, enclave v9 and later); nothing was committed and the position will expire");
1269
+ }
1270
+ const { commitment, version } = producerCommitment(slot, anchor);
1271
+ if (version === 1) throw new FuseError("floor-missing", "the floor could not be bound into the commitment; nothing was committed and the position will expire");
1272
+ // The spec follows the floor: SPEC v1 defines tree/1 under fuse/2, SPEC v2 under fuse/3.
1273
+ try {
1274
+ specHash = currentTreeSpecHash(version);
1275
+ } catch (err) {
1276
+ throw new FuseError("bad-input", `no tree/1 spec hash to pin for bitgraph-fuse/${version}: ${err instanceof Error ? err.message : String(err)}; nothing was committed and the position will expire`);
1277
+ }
1278
+ const commitmentHex = bytesToHex(commitment);
1279
+ const expiring = "nothing was committed and the slot will expire";
1280
+
1281
+ // 2. leaves: every member's under the one commitment. Committed bytes are
1282
+ // virtual: each is built, checked, hashed and released in turn, held only
1283
+ // for a caller who keeps them or asks the full verifier to read them.
1284
+ const leaves: TreeLeaf[] = [];
1285
+ const held: (Uint8Array | null)[] = [];
1286
+ for (let i = 0; i < checked.length; i++) {
1287
+ const c = checked[i]!;
1288
+ let artifact: Uint8Array;
1289
+ let bytes: Uint8Array | null = null;
1290
+ if (c.code === LEAF_AS_IS) {
1291
+ // As is: the file is its own committed bytes and its digest its artifact.
1292
+ artifact = c.originDigest;
1293
+ if (c.original !== null && (keep || verifyMembers)) bytes = c.original;
1294
+ } else if (c.kind === "hashed") {
1295
+ let d: unknown;
1296
+ try {
1297
+ d = await c.fusedDigest!({ commitment, commitmentHex, fuseVersion: version, floor: anchor, slot });
1298
+ } catch (err) {
1299
+ throw new FuseError("builder-failed", `member ${i}: fusedDigest threw: ${err instanceof Error ? err.message : String(err)}; ${expiring}`, null, i);
1300
+ }
1301
+ if (!(d instanceof Uint8Array) || d.length !== 32) throw new FuseError("builder-failed", `member ${i}: fusedDigest must return a 32-byte digest; ${expiring}`, null, i);
1302
+ artifact = d;
1303
+ } else {
1304
+ let original: Uint8Array;
1305
+ if (c.kind === "loaded") {
1306
+ let loaded: unknown;
1307
+ try {
1308
+ loaded = await c.load!();
1309
+ } catch (err) {
1310
+ throw new FuseError("load-failed", `member ${i}: load threw: ${err instanceof Error ? err.message : String(err)}; ${expiring}`, null, i);
1311
+ }
1312
+ if (!(loaded instanceof Uint8Array)) throw new FuseError("load-failed", `member ${i}: load must return a Uint8Array; ${expiring}`, null, i);
1313
+ original = loaded;
1314
+ 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);
1315
+ } else {
1316
+ original = c.original!;
1317
+ }
1318
+ const builder = c.builder ?? builderFor(c.id as PlacementId, original);
1319
+ let committed: Uint8Array;
1320
+ try {
1321
+ committed = await builder({ commitment, commitmentHex, fuseVersion: version, floor: anchor, originDigest: c.originDigest, slot });
1322
+ } catch (err) {
1323
+ throw new FuseError("builder-failed", `member ${i}: the builder threw: ${err instanceof Error ? err.message : String(err)}; ${expiring}`, null, i);
1324
+ }
1325
+ if (!(committed instanceof Uint8Array)) throw new FuseError("builder-failed", `member ${i}: the builder must return a Uint8Array; ${expiring}`, null, i);
1326
+ const located = requireCommitment(c.placement!, committed, commitment, i);
1327
+ // The leaf's origin must be the origin the bytes embed (declared, and
1328
+ // carried byte for byte), else the member would verify INVALID_ORIGIN
1329
+ // after the slot is spent; the same two checks fuseSet runs.
1330
+ const declared = located.originDigest;
1331
+ const carried = located.originalBytes;
1332
+ if ((declared !== undefined && !bytesEqual(declared, c.originDigest)) || (carried !== undefined && !bytesEqual(carried, original))) {
1333
+ throw new FuseError("builder-failed", `member ${i}: the committed bytes embed an origin that is not the member's original; ${expiring}`, null, i);
1334
+ }
1335
+ artifact = await digest(committed);
1336
+ if (keep || verifyMembers) bytes = committed;
1337
+ }
1338
+ leaves.push({ placement: c.code, artifact, origin: c.originDigest });
1339
+ held.push(bytes);
1340
+ report("fuse", i + 1, checked.length);
1341
+ }
1342
+
1343
+ // 3. hash: the tree, and the root document that is the committed artifact.
1344
+ report("tree", 0, 1);
1345
+ let built: ReturnType<typeof buildTree>;
1346
+ try {
1347
+ built = buildTree(leaves);
1348
+ } catch (err) {
1349
+ throw new FuseError("bad-input", `the tree could not be built: ${err instanceof Error ? err.message : String(err)}; ${expiring}`);
1350
+ }
1351
+ const count = built.sorted.length;
1352
+ const rootDocument = buildTreeRootDocument(commitment, count, built.root);
1353
+ report("tree", 1, 1);
1354
+ const artifactDigestB64 = bytesToBase64(await digest(rootDocument));
1355
+ const specHashB64 = bytesToBase64(specHash);
1356
+
1357
+ // 4. fill: one commit; the root document rides as unsigned metadata, bound by its hash.
1358
+ const body: Record<string, unknown> = {
1359
+ digests: [{ digestB64: artifactDigestB64, hashAlg: "sha256" }],
1360
+ slotId: slot.nonceB64,
1361
+ slot,
1362
+ chainId: "bitgraph:main",
1363
+ attribution: treeAttribution(specHash),
1364
+ metadata: { [TREE_METADATA_KEY]: bytesToHex(rootDocument) },
1365
+ // fuse/2: the boundary checks the bound floor against its ledger before spending the slot.
1366
+ // fuse/3: the bound Base floor, so the boundary can check it is the one it handed out.
1367
+ ...(version === 2 ? { anchor } : { floor: anchor }),
1368
+ };
1369
+ if (options.agency !== undefined) body.agency = options.agency;
1370
+ report("commit", 0, 1);
1371
+ const { proof, recovered } = await commitUnderSlot(t, body, artifactDigestB64, slot);
1372
+ report("commit", 1, 1);
1373
+
1374
+ // A reader verifies the proof before it is called a tree: the signature,
1375
+ // fuse/2 and a known spec, the commitment recomputed from the proof's own
1376
+ // slot record and signed floor, and this root document under the signed
1377
+ // digest. The explicit document is used, so no verdict rests on the echo.
1378
+ const verification = await verifyTreeMember({ proof, rootDocument });
1379
+ if (verification.category !== "TREE_ROOT_VALID") {
1380
+ throw new FuseError("verification-failed", `the returned proof does not verify as this tree: ${verification.category} (${verification.reason})`);
1381
+ }
1382
+ if (proof.attribution?.message !== specHashB64) {
1383
+ throw new FuseError("verification-failed", "the returned proof pins a different spec than the one sent");
1384
+ }
1385
+ // The echo is unsigned and advisory: absent is normal, different is a rewrite.
1386
+ let rootDocumentEchoed = false;
1387
+ if (proof.metadata?.[TREE_METADATA_KEY] !== undefined) {
1388
+ const echoed = readTreeMetadata(proof);
1389
+ if (echoed === null || !bytesEqual(echoed, rootDocument)) {
1390
+ throw new FuseError("verification-failed", `the returned proof echoes a root document under metadata["${TREE_METADATA_KEY}"] that differs from the committed one`);
1391
+ }
1392
+ rootDocumentEchoed = true;
1393
+ }
1394
+ // The floor the proof signs: a Base block (fuse/3) or an Ethereum anchor (fuse/2).
1395
+ const floor: FloorMark = proof.commit.slotFloor ? { ...proof.commit.slotFloor } : proof.commit.slotAnchor!;
1396
+
1397
+ // Every member is bound to the verified root by its own path (the
1398
+ // verifier's check, run here once per member); no member's bytes are read
1399
+ // again. With verifyMembers the full verifier reads the committed bytes too.
1400
+ const results: FuseTreeMemberResult[] = [];
1401
+ for (let i = 0; i < checked.length; i++) {
1402
+ const c = checked[i]!;
1403
+ const leaf = leaves[i]!;
1404
+ const k = leafIndexOf(built.sorted, leaf.artifact);
1405
+ const listed = k >= 0 ? built.sorted[k] : undefined;
1406
+ if (listed === undefined || listed.placement !== leaf.placement || !bytesEqual(listed.origin, leaf.origin)) {
1407
+ throw new FuseError("verification-failed", `member ${i}: the committed tree does not list this member's leaf`, null, i);
1408
+ }
1409
+ const path = built.tree.path(k);
1410
+ const reached = treeRootFromMember(listed, k, count, path);
1411
+ if (reached === null || !bytesEqual(reached, built.root)) throw new FuseError("verification-failed", `member ${i}: its path does not recompute the committed root`, null, i);
1412
+ let memberVerification: TreeVerifyResult | undefined;
1413
+ if (verifyMembers) {
1414
+ const want = c.code === LEAF_AS_IS ? "TREE_MEMBER_AS_IS" : "TREE_MEMBER_DIRECT";
1415
+ const v = await verifyTreeMember({ proof, rootDocument, member: buildTreeMemberEvidence(listed, k, count, path), bytes: held[i]!, proofAlreadyVerified: true });
1416
+ if (v.category !== want || v.member?.index !== k) {
1417
+ throw new FuseError("verification-failed", `member ${i}: the returned proof does not verify this member: ${v.category} (${v.reason})`, null, i);
1418
+ }
1419
+ memberVerification = v;
1420
+ report("verify", i + 1, checked.length);
1421
+ }
1422
+ const kept = held[i];
1423
+ results.push({
1424
+ index: i,
1425
+ leafIndex: k,
1426
+ placement: c.id,
1427
+ code: c.code,
1428
+ originDigestB64: bytesToBase64(c.originDigest),
1429
+ artifactDigestB64: bytesToBase64(leaf.artifact),
1430
+ name: c.name,
1431
+ ...(keep && kept !== null && kept !== undefined ? { committedBytes: kept } : {}),
1432
+ ...(memberVerification !== undefined ? { verification: memberVerification } : {}),
1433
+ });
1434
+ }
1435
+ const memberEvidence = (index: number): TreeMemberEvidence => {
1436
+ const r = results[index];
1437
+ if (r === undefined) throw new RangeError(`no member ${index}`);
1438
+ return buildTreeMemberEvidence(built.sorted[r.leafIndex]!, r.leafIndex, count, built.tree.path(r.leafIndex));
1439
+ };
1440
+ return {
1441
+ proof,
1442
+ rootDocument,
1443
+ artifactDigestB64,
1444
+ count,
1445
+ rootHex: bytesToHex(built.root),
1446
+ commitment,
1447
+ floor: "chain" in floor ? { ...floor } : { counter: floor.counter, blockNumber: floor.blockNumber, blockHash: floor.blockHash },
1448
+ specHashB64,
1449
+ leaves: built.sorted,
1450
+ tree: built.tree,
1451
+ members: results,
1452
+ memberEvidence,
1453
+ recovered,
1454
+ rootDocumentEchoed,
1455
+ verification,
1456
+ };
1457
+ }
1458
+
957
1459
  /** Decode a standard-base64 digest, for callers holding one as text. */
958
1460
  export function digestFromBase64(b64: string): Uint8Array {
959
1461
  const d = base64ToBytes(b64);