@mikeargento/bitgraph 1.11.0 → 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
@@ -117,15 +117,36 @@ export interface AnchorMark {
117
117
  blockHash: string;
118
118
  }
119
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
+
120
141
  /** What the builder receives. The raw nonce is deliberately absent. */
121
142
  export interface BuilderInput {
122
- /** 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. */
123
144
  commitment: Uint8Array;
124
145
  commitmentHex: string;
125
- /** Which commitment this is: 2 when the boundary returned its floor anchor (enclave v9 and later), else 1. */
126
- fuseVersion: 1 | 2;
127
- /** The floor bound into a fuse/2 commitment, when there is one. */
128
- 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;
129
150
  /** The origin digest, when the fused artifact names a source. */
130
151
  originDigest?: Uint8Array;
131
152
  /** The signed slot record, for producers that want to embed its fields. Contains the nonce: do not copy it into the artifact. */
@@ -368,11 +389,12 @@ function isAnchorMark(x: unknown): x is AnchorMark {
368
389
 
369
390
  /**
370
391
  * 1. nonce. The signed slot record from the boundary; it must sit on the
371
- * anchored chain. Since enclave v9 the response also carries the floor anchor
372
- * the boundary will sign at commit; with it the producer makes a
373
- * 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.
374
396
  */
375
- async function allocateSlot(t: BoundTransport): Promise<{ slot: SlotAllocation; anchor: AnchorMark | null }> {
397
+ async function allocateSlot(t: BoundTransport): Promise<{ slot: SlotAllocation; anchor: FloorMark | null }> {
376
398
  const alloc = await request(t, t.allocatePath, { method: "POST", body: {} });
377
399
  if (alloc.status === 503 && codeOf(alloc.json) === "tee-restarting") throw new FuseError("tee-restarting", messageOf(alloc.json, "the boundary is restarting"), 503);
378
400
  if (alloc.status !== 200) throw new FuseError("allocate-failed", messageOf(alloc.json, `allocation failed (${alloc.status})`), alloc.status);
@@ -381,7 +403,11 @@ async function allocateSlot(t: BoundTransport): Promise<{ slot: SlotAllocation;
381
403
  if (!isSlotRecord(slot) || slotId !== slot.nonceB64) throw new FuseError("allocate-failed", "the allocation response is not a slot record", alloc.status);
382
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");
383
405
  const anchorRaw = (alloc.json as { anchor?: unknown } | null)?.anchor;
384
- 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 };
385
411
  }
386
412
 
387
413
  /**
@@ -492,7 +518,8 @@ export async function fuse(builder: FuseBuilder, options: FuseOptions): Promise<
492
518
  chainId: "bitgraph:main",
493
519
  attribution,
494
520
  // fuse/2: the boundary checks the bound floor against its ledger before spending the slot.
495
- ...(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 } : {}),
496
523
  };
497
524
  if (options.agency !== undefined) body.agency = options.agency;
498
525
  const { proof, recovered } = await commitUnderSlot(t, body, artifactDigestB64, slot);
@@ -543,10 +570,10 @@ export type SetMemberPlacement = "trailer/1" | "container/1" | "container/2";
543
570
 
544
571
  /** What a hashed member's fused digest is computed for: the held slot and its commitment. */
545
572
  export interface FusedDigestInput {
546
- /** Which commitment this is: 2 when the boundary returned its floor anchor, else 1. */
547
- fuseVersion: 1 | 2;
548
- /** The floor bound into a fuse/2 commitment, when there is one. */
549
- 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;
550
577
  commitment: Uint8Array;
551
578
  commitmentHex: string;
552
579
  slot: SlotAllocation;
@@ -887,7 +914,7 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
887
914
  chainId: "bitgraph:main",
888
915
  attribution: fuseAttribution(setKind === "set/1" ? SET_PLACEMENT_ID_LOCAL : SET2_PLACEMENT_ID, undefined, version),
889
916
  metadata: { [SET_METADATA_KEY]: manifest },
890
- ...(version === 2 ? { anchor } : {}),
917
+ ...(version === 2 ? { anchor } : version === 3 ? { floor: anchor } : {}),
891
918
  };
892
919
  if (options.agency !== undefined) body.agency = options.agency;
893
920
  report("commit", 0, 1);
@@ -1099,10 +1126,10 @@ export interface FuseTreeResult {
1099
1126
  count: number;
1100
1127
  /** The tree's root, lowercase hex. */
1101
1128
  rootHex: string;
1102
- /** commitment/2, which every placed member's committed bytes carry. committedBytesFor(member.code, original, commitment) rebuilds them. */
1129
+ /** commitment/2 or /3, which every placed member's committed bytes carry. committedBytesFor(member.code, original, commitment) rebuilds them. */
1103
1130
  commitment: Uint8Array;
1104
- /** The floor block the commitment binds, as the proof signs it (commit.slotAnchor). */
1105
- floor: AnchorMark;
1131
+ /** The floor block the commitment binds, as the proof signs it (commit.slotAnchor or commit.slotFloor). */
1132
+ floor: FloorMark;
1106
1133
  /** The spec hash the signed attribution pins, standard base64. */
1107
1134
  specHashB64: string;
1108
1135
  /** Every leaf, in tree order (strictly ascending artifact digest). */
@@ -1238,10 +1265,16 @@ export async function fuseTree(members: readonly FuseTreeMember[], options: Fuse
1238
1265
  // 1. nonce: one slot for the whole tree, and the floor it binds.
1239
1266
  const { slot, anchor } = await allocateSlot(t);
1240
1267
  if (anchor === null) {
1241
- throw new FuseError("floor-missing", "the allocation returned no floor anchor, so no tree/1 commitment can be made (tree/1 binds the floor block: bitgraph-fuse/2, enclave v9 and later); nothing was committed and the position will expire");
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");
1242
1269
  }
1243
1270
  const { commitment, version } = producerCommitment(slot, anchor);
1244
- if (version !== 2) throw new FuseError("floor-missing", "the floor anchor could not be bound into the commitment; nothing was committed and the position will expire");
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
+ }
1245
1278
  const commitmentHex = bytesToHex(commitment);
1246
1279
  const expiring = "nothing was committed and the slot will expire";
1247
1280
 
@@ -1330,7 +1363,8 @@ export async function fuseTree(members: readonly FuseTreeMember[], options: Fuse
1330
1363
  attribution: treeAttribution(specHash),
1331
1364
  metadata: { [TREE_METADATA_KEY]: bytesToHex(rootDocument) },
1332
1365
  // fuse/2: the boundary checks the bound floor against its ledger before spending the slot.
1333
- anchor,
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 }),
1334
1368
  };
1335
1369
  if (options.agency !== undefined) body.agency = options.agency;
1336
1370
  report("commit", 0, 1);
@@ -1357,7 +1391,8 @@ export async function fuseTree(members: readonly FuseTreeMember[], options: Fuse
1357
1391
  }
1358
1392
  rootDocumentEchoed = true;
1359
1393
  }
1360
- const floor = proof.commit.slotAnchor!;
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!;
1361
1396
 
1362
1397
  // Every member is bound to the verified root by its own path (the
1363
1398
  // verifier's check, run here once per member); no member's bytes are read
@@ -1409,7 +1444,7 @@ export async function fuseTree(members: readonly FuseTreeMember[], options: Fuse
1409
1444
  count,
1410
1445
  rootHex: bytesToHex(built.root),
1411
1446
  commitment,
1412
- floor: { counter: floor.counter, blockNumber: floor.blockNumber, blockHash: floor.blockHash },
1447
+ floor: "chain" in floor ? { ...floor } : { counter: floor.counter, blockNumber: floor.blockNumber, blockHash: floor.blockHash },
1413
1448
  specHashB64,
1414
1449
  leaves: built.sorted,
1415
1450
  tree: built.tree,