@mikeargento/bitgraph 1.3.0 → 1.5.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/dist/__tests__/fuse-set-sdk.test.d.ts +2 -0
- package/dist/__tests__/fuse-set-sdk.test.d.ts.map +1 -0
- package/dist/__tests__/fuse-set-sdk.test.js +575 -0
- package/dist/__tests__/fuse-set-sdk.test.js.map +1 -0
- package/dist/__tests__/fuse-set.test.d.ts +2 -0
- package/dist/__tests__/fuse-set.test.d.ts.map +1 -0
- package/dist/__tests__/fuse-set.test.js +863 -0
- package/dist/__tests__/fuse-set.test.js.map +1 -0
- package/dist/fuse-cli.js +112 -32
- package/dist/fuse-cli.js.map +1 -1
- package/dist/fuse.d.ts +107 -3
- package/dist/fuse.d.ts.map +1 -1
- package/dist/fuse.js +302 -48
- package/dist/fuse.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/fuse-set-sdk.test.ts +700 -0
- package/src/__tests__/fuse-set.test.ts +1000 -0
- package/src/fuse-cli.ts +98 -27
- package/src/fuse.ts +401 -46
- package/src/index.ts +4 -1
package/src/fuse.ts
CHANGED
|
@@ -15,6 +15,16 @@
|
|
|
15
15
|
* 4. fill: commit that digest under the same slot, with the placement id
|
|
16
16
|
* and the origin digest in the signed attribution.
|
|
17
17
|
*
|
|
18
|
+
* fuseSet(members, options): the same four beats for N files under ONE slot.
|
|
19
|
+
* The commitment is computed once and written into every member by that
|
|
20
|
+
* member's own placement; the committed artifact is the canonical set
|
|
21
|
+
* manifest (placement set/1, built by the verify package); the signed title
|
|
22
|
+
* is "set/1" with no origin, because a set has no single origin; the parsed
|
|
23
|
+
* manifest rides along as unsigned metadata. Nothing is committed unless
|
|
24
|
+
* every member's bytes carry the commitment and embed its own origin, and no
|
|
25
|
+
* proof is returned unless the manifest verifies FUSED_DIRECT and every
|
|
26
|
+
* member SET_MEMBER_DIRECT against the explicit manifest bytes.
|
|
27
|
+
*
|
|
18
28
|
* What this module never does: write the nonce anywhere but process memory,
|
|
19
29
|
* put it in a message, or fall back to an ordinary recording when the fused
|
|
20
30
|
* commit fails. A failure is reported as a failure and the slot expires on
|
|
@@ -28,18 +38,54 @@
|
|
|
28
38
|
import { sha256 } from "@noble/hashes/sha256";
|
|
29
39
|
import {
|
|
30
40
|
buildFrame,
|
|
41
|
+
buildSetManifest,
|
|
42
|
+
bytesEqual,
|
|
31
43
|
bytesToBase64,
|
|
32
44
|
bytesToHex,
|
|
33
45
|
computeSlotCommitment,
|
|
34
46
|
computeSlotRecordHash,
|
|
35
47
|
fuseAttribution,
|
|
36
48
|
getPlacement,
|
|
49
|
+
parseSetManifest,
|
|
50
|
+
readSetMetadata,
|
|
51
|
+
SET_METADATA_KEY,
|
|
37
52
|
verifyFuse,
|
|
53
|
+
verifyFuseMember,
|
|
38
54
|
base64ToBytes,
|
|
39
55
|
} from "@mikeargento/bitgraph-verify";
|
|
40
|
-
import type {
|
|
56
|
+
import type {
|
|
57
|
+
BitGraphProof,
|
|
58
|
+
FuseFrame,
|
|
59
|
+
FuseMemberResult,
|
|
60
|
+
FuseVerifyResult,
|
|
61
|
+
Located,
|
|
62
|
+
Placement,
|
|
63
|
+
PlacementId,
|
|
64
|
+
SetManifest,
|
|
65
|
+
SetMember,
|
|
66
|
+
SlotAllocation,
|
|
67
|
+
} from "@mikeargento/bitgraph-verify";
|
|
41
68
|
|
|
42
|
-
|
|
69
|
+
/**
|
|
70
|
+
* SHA-256 over bytes: the platform's native hasher when one is present
|
|
71
|
+
* (WebCrypto, in browsers and in Node), else the JavaScript library. The
|
|
72
|
+
* native path runs about ten times faster over large files and both give
|
|
73
|
+
* the same digest; a test pins that. A platform that refuses the input (a
|
|
74
|
+
* shared or detached buffer) falls back to the library.
|
|
75
|
+
*/
|
|
76
|
+
export async function digest(bytes: Uint8Array): Promise<Uint8Array> {
|
|
77
|
+
const subtle = (globalThis as { crypto?: { subtle?: { digest?: (alg: string, data: Uint8Array) => Promise<ArrayBuffer> } } }).crypto?.subtle;
|
|
78
|
+
if (subtle !== undefined && typeof subtle.digest === "function") {
|
|
79
|
+
try {
|
|
80
|
+
return new Uint8Array(await subtle.digest("SHA-256", bytes));
|
|
81
|
+
} catch {
|
|
82
|
+
// fall through to the library
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return sha256(bytes);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type { FuseFrame, PlacementId, SlotAllocation, BitGraphProof, SetManifest, FuseMemberResult, FuseVerifyResult } from "@mikeargento/bitgraph-verify";
|
|
43
89
|
|
|
44
90
|
/** What the builder receives. The raw nonce is deliberately absent. */
|
|
45
91
|
export interface BuilderInput {
|
|
@@ -119,11 +165,14 @@ export type FuseErrorCode =
|
|
|
119
165
|
export class FuseError extends Error {
|
|
120
166
|
readonly code: FuseErrorCode;
|
|
121
167
|
readonly status: number | null;
|
|
122
|
-
|
|
168
|
+
/** The caller's 0-based index into a set's members when the failure is one member's; null otherwise. fuse() never sets it. */
|
|
169
|
+
readonly member: number | null;
|
|
170
|
+
constructor(code: FuseErrorCode, message: string, status: number | null = null, member: number | null = null) {
|
|
123
171
|
super(message);
|
|
124
172
|
this.name = "FuseError";
|
|
125
173
|
this.code = code;
|
|
126
174
|
this.status = status;
|
|
175
|
+
this.member = member;
|
|
127
176
|
}
|
|
128
177
|
}
|
|
129
178
|
|
|
@@ -181,6 +230,9 @@ const DEFAULTS = {
|
|
|
181
230
|
recoveryDelayMs: 1_500,
|
|
182
231
|
} as const;
|
|
183
232
|
|
|
233
|
+
/** A transport with every default filled in: what the beats below take. */
|
|
234
|
+
type BoundTransport = Required<Pick<FuseTransport, keyof typeof DEFAULTS>> & FuseTransport;
|
|
235
|
+
|
|
184
236
|
const B64_32 = /^[A-Za-z0-9+/]{43}=$/;
|
|
185
237
|
const B64_64 = /^[A-Za-z0-9+/]{86}==$/;
|
|
186
238
|
|
|
@@ -267,23 +319,8 @@ async function recover(
|
|
|
267
319
|
return null;
|
|
268
320
|
}
|
|
269
321
|
|
|
270
|
-
/**
|
|
271
|
-
|
|
272
|
-
* throws a FuseError; it never returns an ordinary recording in place of a
|
|
273
|
-
* fused one.
|
|
274
|
-
*/
|
|
275
|
-
export async function fuse(builder: FuseBuilder, options: FuseOptions): Promise<FuseResult> {
|
|
276
|
-
const placement = getPlacement(options.placement);
|
|
277
|
-
if (placement === undefined) throw new FuseError("bad-placement", `placement "${options.placement}" is not registered`);
|
|
278
|
-
if (placement.form !== "C" && options.original === undefined) throw new FuseError("bad-input", `${placement.id} needs the original bytes`);
|
|
279
|
-
if (placement.form === "C" && options.original !== undefined) throw new FuseError("bad-input", "produced/1 takes no original; pass originDigest to name a source");
|
|
280
|
-
if (options.originDigest !== undefined && options.originDigest.length !== 32) throw new FuseError("bad-input", "originDigest must be 32 bytes");
|
|
281
|
-
|
|
282
|
-
const t = { ...DEFAULTS, ...(options.transport ?? {}) };
|
|
283
|
-
const originDigest = options.original !== undefined ? sha256(options.original) : options.originDigest;
|
|
284
|
-
const originDigestB64 = originDigest !== undefined ? bytesToBase64(originDigest) : null;
|
|
285
|
-
|
|
286
|
-
// 1. nonce
|
|
322
|
+
/** 1. nonce. The signed slot record from the boundary; it must sit on the anchored chain. */
|
|
323
|
+
async function allocateSlot(t: BoundTransport): Promise<SlotAllocation> {
|
|
287
324
|
const alloc = await request(t, t.allocatePath, { method: "POST", body: {} });
|
|
288
325
|
if (alloc.status === 503 && codeOf(alloc.json) === "tee-restarting") throw new FuseError("tee-restarting", messageOf(alloc.json, "the boundary is restarting"), 503);
|
|
289
326
|
if (alloc.status !== 200) throw new FuseError("allocate-failed", messageOf(alloc.json, `allocation failed (${alloc.status})`), alloc.status);
|
|
@@ -291,37 +328,29 @@ export async function fuse(builder: FuseBuilder, options: FuseOptions): Promise<
|
|
|
291
328
|
const slot = (alloc.json as { slot?: unknown } | null)?.slot;
|
|
292
329
|
if (!isSlotRecord(slot) || slotId !== slot.nonceB64) throw new FuseError("allocate-failed", "the allocation response is not a slot record", alloc.status);
|
|
293
330
|
if (slot.chainId !== "bitgraph:main") throw new FuseError("allocate-failed", "the slot is not on the anchored chain; a fused floor needs bitgraph:main");
|
|
331
|
+
return slot;
|
|
332
|
+
}
|
|
294
333
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
throw new FuseError("builder-failed", `the builder threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
302
|
-
}
|
|
303
|
-
if (!(fused instanceof Uint8Array)) throw new FuseError("builder-failed", "the builder must return a Uint8Array");
|
|
304
|
-
// Fail closed: never commit bytes that do not carry the commitment.
|
|
334
|
+
/**
|
|
335
|
+
* Fail closed: never commit bytes that do not carry the commitment. Returns
|
|
336
|
+
* what the placement located, for any further check. `member` names the
|
|
337
|
+
* set member the bytes belong to; null for a single fused artifact.
|
|
338
|
+
*/
|
|
339
|
+
function requireCommitment(placement: Placement, fused: Uint8Array, commitment: Uint8Array, member: number | null = null): Located {
|
|
305
340
|
const located = placement.locate(fused);
|
|
306
341
|
if (located === null || bytesToHex(located.commitment) !== bytesToHex(commitment)) {
|
|
307
|
-
|
|
342
|
+
const label = member !== null ? `member ${member}: ` : "";
|
|
343
|
+
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);
|
|
308
344
|
}
|
|
345
|
+
return located;
|
|
346
|
+
}
|
|
309
347
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
const body: Record<string, unknown> = {
|
|
317
|
-
digests: [{ digestB64: artifactDigestB64, hashAlg: "sha256" }],
|
|
318
|
-
slotId: slot.nonceB64,
|
|
319
|
-
slot,
|
|
320
|
-
chainId: "bitgraph:main",
|
|
321
|
-
attribution,
|
|
322
|
-
};
|
|
323
|
-
if (options.agency !== undefined) body.agency = options.agency;
|
|
324
|
-
|
|
348
|
+
/**
|
|
349
|
+
* 4. fill. Commit under the held slot; on a lost or refused response read
|
|
350
|
+
* back by digest and match the slot record; never allocate again; refuse a
|
|
351
|
+
* proof under any other slot.
|
|
352
|
+
*/
|
|
353
|
+
async function commitUnderSlot(t: BoundTransport, body: Record<string, unknown>, artifactDigestB64: string, slot: SlotAllocation): Promise<{ proof: BitGraphProof; recovered: boolean }> {
|
|
325
354
|
let proof: BitGraphProof | null = null;
|
|
326
355
|
let recovered = false;
|
|
327
356
|
let commit: { status: number; json: unknown } | null = null;
|
|
@@ -356,6 +385,55 @@ export async function fuse(builder: FuseBuilder, options: FuseOptions): Promise<
|
|
|
356
385
|
if (proof.slotAllocation?.nonceB64 !== slot.nonceB64 || proof.commit?.nonceB64 !== slot.nonceB64) {
|
|
357
386
|
throw new FuseError("slot-mismatch", "the boundary returned a proof under a different slot; nothing is labelled fused");
|
|
358
387
|
}
|
|
388
|
+
return { proof, recovered };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Allocate, fuse, hash, fill. Returns the Frame with the unchanged proof, or
|
|
393
|
+
* throws a FuseError; it never returns an ordinary recording in place of a
|
|
394
|
+
* fused one.
|
|
395
|
+
*/
|
|
396
|
+
export async function fuse(builder: FuseBuilder, options: FuseOptions): Promise<FuseResult> {
|
|
397
|
+
const placement = getPlacement(options.placement);
|
|
398
|
+
if (placement === undefined) throw new FuseError("bad-placement", `placement "${options.placement}" is not registered`);
|
|
399
|
+
if (placement.form !== "C" && options.original === undefined) throw new FuseError("bad-input", `${placement.id} needs the original bytes`);
|
|
400
|
+
if (placement.form === "C" && options.original !== undefined) throw new FuseError("bad-input", "produced/1 takes no original; pass originDigest to name a source");
|
|
401
|
+
if (options.originDigest !== undefined && options.originDigest.length !== 32) throw new FuseError("bad-input", "originDigest must be 32 bytes");
|
|
402
|
+
|
|
403
|
+
const t: BoundTransport = { ...DEFAULTS, ...(options.transport ?? {}) };
|
|
404
|
+
const originDigest = options.original !== undefined ? await digest(options.original) : options.originDigest;
|
|
405
|
+
const originDigestB64 = originDigest !== undefined ? bytesToBase64(originDigest) : null;
|
|
406
|
+
|
|
407
|
+
// 1. nonce
|
|
408
|
+
const slot = await allocateSlot(t);
|
|
409
|
+
|
|
410
|
+
// 2. fuse
|
|
411
|
+
const commitment = computeSlotCommitment(slot);
|
|
412
|
+
let fused: Uint8Array;
|
|
413
|
+
try {
|
|
414
|
+
fused = await builder({ commitment, commitmentHex: bytesToHex(commitment), ...(originDigest !== undefined ? { originDigest } : {}), slot });
|
|
415
|
+
} catch (err) {
|
|
416
|
+
throw new FuseError("builder-failed", `the builder threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
417
|
+
}
|
|
418
|
+
if (!(fused instanceof Uint8Array)) throw new FuseError("builder-failed", "the builder must return a Uint8Array");
|
|
419
|
+
requireCommitment(placement, fused, commitment);
|
|
420
|
+
|
|
421
|
+
// 3. hash
|
|
422
|
+
const artifactDigest = await digest(fused);
|
|
423
|
+
const artifactDigestB64 = bytesToBase64(artifactDigest);
|
|
424
|
+
|
|
425
|
+
// 4. fill
|
|
426
|
+
const attribution = fuseAttribution(placement.id, originDigest);
|
|
427
|
+
const body: Record<string, unknown> = {
|
|
428
|
+
digests: [{ digestB64: artifactDigestB64, hashAlg: "sha256" }],
|
|
429
|
+
slotId: slot.nonceB64,
|
|
430
|
+
slot,
|
|
431
|
+
chainId: "bitgraph:main",
|
|
432
|
+
attribution,
|
|
433
|
+
};
|
|
434
|
+
if (options.agency !== undefined) body.agency = options.agency;
|
|
435
|
+
const { proof, recovered } = await commitUnderSlot(t, body, artifactDigestB64, slot);
|
|
436
|
+
|
|
359
437
|
// A minted proof is verified by a reader before it is called a proof.
|
|
360
438
|
const verification = await verifyFuse({ proof, bytes: fused });
|
|
361
439
|
if (verification.category !== "FUSED_DIRECT") {
|
|
@@ -382,6 +460,283 @@ export async function fuse(builder: FuseBuilder, options: FuseOptions): Promise<
|
|
|
382
460
|
};
|
|
383
461
|
}
|
|
384
462
|
|
|
463
|
+
// ---------------------------------------------------------------------------
|
|
464
|
+
// Sets: N files fused under ONE slot
|
|
465
|
+
// ---------------------------------------------------------------------------
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* The most members one set takes. Measured: one canonical row is 246 bytes
|
|
469
|
+
* (container/1, the longest id this phase), so 2000 rows is 492,174 bytes.
|
|
470
|
+
* The parent refuses raw bodies over 1 MB (server.ts:249), which would land
|
|
471
|
+
* AFTER allocation and burn the slot. Half the cap is left for the slot
|
|
472
|
+
* record, an agency envelope, and future longer placement ids. 4000 rows
|
|
473
|
+
* (984 KB) leaves 63 KB and is refused; 10000 rows (2.4 MB) cannot pass at
|
|
474
|
+
* all. A test pins the budget.
|
|
475
|
+
*/
|
|
476
|
+
export const MAX_SET_MEMBERS = 2000;
|
|
477
|
+
|
|
478
|
+
/** The placements a set member takes: Forms A and B, one original per member. */
|
|
479
|
+
export type SetMemberPlacement = "trailer/1" | "container/1";
|
|
480
|
+
|
|
481
|
+
export interface FuseSetMember {
|
|
482
|
+
/** The original bytes. Never modified. */
|
|
483
|
+
original: Uint8Array;
|
|
484
|
+
/** Default: placementForBytes(original). */
|
|
485
|
+
placement?: SetMemberPlacement;
|
|
486
|
+
/** Advisory; feeds fusedNamesFor. */
|
|
487
|
+
name?: string;
|
|
488
|
+
/** Default: builderFor(placement, original). The locate and origin guards run regardless. */
|
|
489
|
+
builder?: FuseBuilder;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export interface FuseSetProgress {
|
|
493
|
+
/**
|
|
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.
|
|
496
|
+
* "commit": 0 of 1 before the request, 1 of 1 when the proof is back.
|
|
497
|
+
* "verify": only with verifyMembers, one per member.
|
|
498
|
+
*/
|
|
499
|
+
phase: "hash" | "fuse" | "commit" | "verify";
|
|
500
|
+
done: number;
|
|
501
|
+
total: number;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export interface FuseSetOptions {
|
|
505
|
+
/** Return each member's fused bytes. Default false: they are virtual, rebuilt from the original and the proof. */
|
|
506
|
+
keepFused?: boolean;
|
|
507
|
+
/**
|
|
508
|
+
* Run the full verifier (verifyFuseMember) over every member's fused bytes
|
|
509
|
+
* after the commit and return each verdict under `verification`. Default
|
|
510
|
+
* false: every member is bound to the returned proof by digest, its row in
|
|
511
|
+
* the committed manifest, which is itself verified FUSED_DIRECT; that is
|
|
512
|
+
* 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.
|
|
514
|
+
*/
|
|
515
|
+
verifyMembers?: boolean;
|
|
516
|
+
/** Called as the set advances. A throw inside it is ignored: a progress hook never changes the outcome. */
|
|
517
|
+
onProgress?: (progress: FuseSetProgress) => void;
|
|
518
|
+
/** Actor-bound commits: an agency envelope passed through untouched. */
|
|
519
|
+
agency?: unknown;
|
|
520
|
+
transport?: FuseTransport;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export interface FuseSetMemberResult {
|
|
524
|
+
/** The caller's index into members. */
|
|
525
|
+
index: number;
|
|
526
|
+
/** The row's position in the sorted manifest; equals verification.set.member.index. */
|
|
527
|
+
manifestIndex: number;
|
|
528
|
+
placement: SetMemberPlacement;
|
|
529
|
+
originDigestB64: string;
|
|
530
|
+
/** SHA-256 of the member's fused bytes; the row's artifact. */
|
|
531
|
+
artifactDigestB64: string;
|
|
532
|
+
/** fusedNamesFor(name, placement); null when the member has no name. */
|
|
533
|
+
fusedName: string | null;
|
|
534
|
+
/** Advisory; no Frame is written for a set member this phase. */
|
|
535
|
+
frameName: string | null;
|
|
536
|
+
/** Present only when keepFused is true. */
|
|
537
|
+
fusedBytes?: Uint8Array;
|
|
538
|
+
/** 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
|
+
verification?: FuseMemberResult;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
export interface FuseSetResult {
|
|
543
|
+
proof: BitGraphProof;
|
|
544
|
+
/** The committed artifact. Keep it beside the proof. */
|
|
545
|
+
manifestBytes: Uint8Array;
|
|
546
|
+
/** JSON.parse of manifestBytes; the exact object sent under metadata. */
|
|
547
|
+
manifest: SetManifest;
|
|
548
|
+
/** SHA-256 of manifestBytes; equals proof.artifact.digestB64. */
|
|
549
|
+
artifactDigestB64: string;
|
|
550
|
+
slotCommitmentB64: string;
|
|
551
|
+
/** In the caller's order. */
|
|
552
|
+
members: FuseSetMemberResult[];
|
|
553
|
+
/** True when the commit response was lost and the proof was read back by the manifest digest. */
|
|
554
|
+
recovered: boolean;
|
|
555
|
+
/** True only when readSetMetadata(proof) is byte-equal to manifestBytes. */
|
|
556
|
+
manifestEchoed: boolean;
|
|
557
|
+
/** verifyFuse over manifestBytes. Always FUSED_DIRECT under "set/1" on success. */
|
|
558
|
+
verification: FuseVerifyResult;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Allocate once, fuse every member with the one commitment, hash the set
|
|
563
|
+
* manifest, fill the slot with it. Returns the proof with the manifest bytes
|
|
564
|
+
* beside it, or throws a FuseError; it never commits a partial set and never
|
|
565
|
+
* allocates a second slot.
|
|
566
|
+
*/
|
|
567
|
+
export async function fuseSet(members: readonly FuseSetMember[], options: FuseSetOptions = {}): Promise<FuseSetResult> {
|
|
568
|
+
// 0. validate, before any request. A refusal here burns nothing.
|
|
569
|
+
if (!Array.isArray(members) || members.length === 0) throw new FuseError("bad-input", "a set lists at least one member");
|
|
570
|
+
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 }
|
|
572
|
+
const checked: Checked[] = [];
|
|
573
|
+
const seen = new Map<string, number>();
|
|
574
|
+
const report = (phase: FuseSetProgress["phase"], done: number, total: number) => {
|
|
575
|
+
if (options.onProgress === undefined) return;
|
|
576
|
+
try {
|
|
577
|
+
options.onProgress({ phase, done, total });
|
|
578
|
+
} catch {
|
|
579
|
+
// a progress hook never changes the outcome
|
|
580
|
+
}
|
|
581
|
+
};
|
|
582
|
+
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);
|
|
587
|
+
const placement = getPlacement(id);
|
|
588
|
+
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);
|
|
593
|
+
// The same original under the same placement fuses to the same bytes, which one manifest lists once.
|
|
594
|
+
const key = `${id}:${bytesToHex(originDigest)}`;
|
|
595
|
+
const j = seen.get(key);
|
|
596
|
+
if (j !== undefined) {
|
|
597
|
+
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
|
+
}
|
|
599
|
+
seen.set(key, i);
|
|
600
|
+
checked.push({ placement, id, original: m.original, originDigest, name: m.name ?? null, builder: m.builder ?? builderFor(id, m.original) });
|
|
601
|
+
report("hash", i + 1, members.length);
|
|
602
|
+
}
|
|
603
|
+
const t: BoundTransport = { ...DEFAULTS, ...(options.transport ?? {}) };
|
|
604
|
+
|
|
605
|
+
// 1. nonce: one slot for the whole set
|
|
606
|
+
const slot = await allocateSlot(t);
|
|
607
|
+
|
|
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.
|
|
610
|
+
const commitment = computeSlotCommitment(slot);
|
|
611
|
+
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
|
+
const fusedBytes: (Uint8Array | null)[] = [];
|
|
618
|
+
const rows: SetMember[] = [];
|
|
619
|
+
for (let i = 0; i < checked.length; i++) {
|
|
620
|
+
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);
|
|
640
|
+
}
|
|
641
|
+
rows.push({ artifact: await digest(fused), origin: c.originDigest, placement: c.id });
|
|
642
|
+
fusedBytes.push(keep || verifyMembers ? fused : null);
|
|
643
|
+
report("fuse", i + 1, checked.length);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// 3. hash: the canonical manifest is the artifact
|
|
647
|
+
let manifestBytes: Uint8Array;
|
|
648
|
+
try {
|
|
649
|
+
manifestBytes = buildSetManifest(commitment, rows);
|
|
650
|
+
} 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`);
|
|
652
|
+
}
|
|
653
|
+
const artifactDigestB64 = bytesToBase64(await digest(manifestBytes));
|
|
654
|
+
const manifest = JSON.parse(new TextDecoder().decode(manifestBytes)) as SetManifest;
|
|
655
|
+
|
|
656
|
+
// 4. fill: one commit, the parsed manifest riding along as unsigned metadata
|
|
657
|
+
const body: Record<string, unknown> = {
|
|
658
|
+
digests: [{ digestB64: artifactDigestB64, hashAlg: "sha256" }],
|
|
659
|
+
slotId: slot.nonceB64,
|
|
660
|
+
slot,
|
|
661
|
+
chainId: "bitgraph:main",
|
|
662
|
+
attribution: fuseAttribution("set/1"),
|
|
663
|
+
metadata: { [SET_METADATA_KEY]: manifest },
|
|
664
|
+
};
|
|
665
|
+
if (options.agency !== undefined) body.agency = options.agency;
|
|
666
|
+
report("commit", 0, 1);
|
|
667
|
+
const { proof, recovered } = await commitUnderSlot(t, body, artifactDigestB64, slot);
|
|
668
|
+
report("commit", 1, 1);
|
|
669
|
+
|
|
670
|
+
// The manifest is verified by a reader before the proof is called a set proof.
|
|
671
|
+
const verification = await verifyFuse({ proof, bytes: manifestBytes });
|
|
672
|
+
if (verification.category !== "FUSED_DIRECT" || verification.placement !== "set/1") {
|
|
673
|
+
throw new FuseError("verification-failed", `the returned proof does not verify as a set: ${verification.category}${verification.reason ? ` (${verification.reason})` : ""}`);
|
|
674
|
+
}
|
|
675
|
+
// The echo is unsigned and advisory. Absent is normal for a boundary that
|
|
676
|
+
// drops metadata on a held-slot commit (enclaves before v6, and a proxy
|
|
677
|
+
// that does not forward it); differing means a boundary rewrote the
|
|
678
|
+
// response.
|
|
679
|
+
const echoed = readSetMetadata(proof);
|
|
680
|
+
if (echoed !== null && !bytesEqual(echoed, manifestBytes)) {
|
|
681
|
+
throw new FuseError("verification-failed", `the returned proof echoes a set manifest under metadata["${SET_METADATA_KEY}"] that differs from the committed one`);
|
|
682
|
+
}
|
|
683
|
+
const manifestEchoed = echoed !== null;
|
|
684
|
+
// Every member is bound to the returned proof by its row: the manifest the
|
|
685
|
+
// proof commits (verified FUSED_DIRECT above) is parsed strictly, and each
|
|
686
|
+
// member's computed fused digest, origin and placement must sit in it. No
|
|
687
|
+
// member's bytes are read again. With verifyMembers the full verifier runs
|
|
688
|
+
// over each member's fused bytes as well, against the explicit manifest
|
|
689
|
+
// bytes so no verdict depends on the echo, and its verdict is returned.
|
|
690
|
+
const parsed = parseSetManifest(manifestBytes);
|
|
691
|
+
if (parsed === null) throw new FuseError("verification-failed", "the committed manifest does not parse as a set manifest");
|
|
692
|
+
const rowIndex = new Map<string, number>();
|
|
693
|
+
parsed.members.forEach((row, k) => rowIndex.set(bytesToHex(row.artifact), k));
|
|
694
|
+
const results: FuseSetMemberResult[] = [];
|
|
695
|
+
for (let i = 0; i < checked.length; i++) {
|
|
696
|
+
const c = checked[i]!;
|
|
697
|
+
const row = rows[i]!;
|
|
698
|
+
const k = rowIndex.get(bytesToHex(row.artifact));
|
|
699
|
+
const listed = k !== undefined ? parsed.members[k] : undefined;
|
|
700
|
+
if (k === undefined || listed === undefined || !bytesEqual(listed.origin, row.origin) || listed.placement !== row.placement) {
|
|
701
|
+
throw new FuseError("verification-failed", `member ${i}: the committed manifest does not list this member's fused digest with its origin and placement`, null, i);
|
|
702
|
+
}
|
|
703
|
+
const memberArtifactB64 = bytesToBase64(row.artifact);
|
|
704
|
+
let verification: FuseMemberResult | undefined;
|
|
705
|
+
if (verifyMembers) {
|
|
706
|
+
const v = await verifyFuseMember({ proof, bytes: fusedBytes[i]!, manifest: manifestBytes });
|
|
707
|
+
const member = v.set?.member ?? null;
|
|
708
|
+
if (v.category !== "SET_MEMBER_DIRECT" || member === null || member.fusedDigestB64 !== memberArtifactB64 || member.index !== k) {
|
|
709
|
+
throw new FuseError("verification-failed", `member ${i}: the returned proof does not verify this member: ${v.category}${v.reason ? ` (${v.reason})` : ""}`, null, i);
|
|
710
|
+
}
|
|
711
|
+
verification = v;
|
|
712
|
+
report("verify", i + 1, checked.length);
|
|
713
|
+
}
|
|
714
|
+
const names = c.name !== null ? fusedNamesFor(c.name, c.id) : null;
|
|
715
|
+
results.push({
|
|
716
|
+
index: i,
|
|
717
|
+
manifestIndex: k,
|
|
718
|
+
placement: c.id,
|
|
719
|
+
originDigestB64: bytesToBase64(c.originDigest),
|
|
720
|
+
artifactDigestB64: memberArtifactB64,
|
|
721
|
+
fusedName: names?.fusedName ?? null,
|
|
722
|
+
frameName: names?.frameName ?? null,
|
|
723
|
+
...(keep ? { fusedBytes: fusedBytes[i]! } : {}),
|
|
724
|
+
...(verification !== undefined ? { verification } : {}),
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
return {
|
|
728
|
+
proof,
|
|
729
|
+
manifestBytes,
|
|
730
|
+
manifest,
|
|
731
|
+
artifactDigestB64,
|
|
732
|
+
slotCommitmentB64: bytesToBase64(commitment),
|
|
733
|
+
members: results,
|
|
734
|
+
recovered,
|
|
735
|
+
manifestEchoed,
|
|
736
|
+
verification,
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
|
|
385
740
|
/** Decode a standard-base64 digest, for callers holding one as text. */
|
|
386
741
|
export function digestFromBase64(b64: string): Uint8Array {
|
|
387
742
|
const d = base64ToBytes(b64);
|
package/src/index.ts
CHANGED
|
@@ -40,8 +40,11 @@ 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, builderFor, FuseError, digestFromBase64, placementForBytes, fusedNamesFor } from "./fuse.js";
|
|
43
|
+
export { fuse, fuseSet, MAX_SET_MEMBERS, 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";
|
|
46
|
+
// The verify-package types those results are made of, so the core entry names everything it returns.
|
|
47
|
+
export type { FuseFrame, PlacementId, SetManifest, FuseMemberResult, FuseVerifyResult } from "@mikeargento/bitgraph-verify";
|
|
45
48
|
|
|
46
49
|
// Policy parsing, hashing, and validation
|
|
47
50
|
export {
|