@mikeargento/bitgraph-player 0.15.0 → 0.16.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/README.md +25 -14
- package/dist/__tests__/base-floor-check.test.d.ts +2 -0
- package/dist/__tests__/base-floor-check.test.d.ts.map +1 -0
- package/dist/__tests__/base-floor-check.test.js +163 -0
- package/dist/__tests__/base-floor-check.test.js.map +1 -0
- package/dist/__tests__/check.test.js +1 -1
- package/dist/check.d.ts +58 -9
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +91 -26
- package/dist/check.js.map +1 -1
- package/dist/cli.js +0 -0
- package/dist/order.d.ts +4 -1
- package/dist/order.d.ts.map +1 -1
- package/dist/order.js +9 -0
- package/dist/order.js.map +1 -1
- package/dist/verdict.d.ts +1 -1
- package/dist/verdict.js +1 -1
- package/dist-web/verify.html +5 -4165
- package/package.json +3 -3
- package/src/__tests__/base-floor-check.test.ts +187 -0
- package/src/__tests__/check.test.ts +1 -1
- package/src/check.ts +156 -34
- package/src/order.ts +12 -1
- package/src/verdict.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mikeargento/bitgraph-player",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Deterministic evaluation of causal rules over BitGraph proof bundles: a pure function from (rule, verified evidence) to a reproducible verdict.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bitgraph",
|
|
@@ -43,8 +43,8 @@
|
|
|
43
43
|
"node": ">=20"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@mikeargento/bitgraph-audit": "^0.
|
|
47
|
-
"@mikeargento/bitgraph-verify": "^1.
|
|
46
|
+
"@mikeargento/bitgraph-audit": "^0.10.0",
|
|
47
|
+
"@mikeargento/bitgraph-verify": "^1.17.0"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@noble/curves": "^2.3.0",
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Copyright (c) 2024-2026 Argento Computing Inc. Licensed under the MIT License. See LICENSE.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Base floors (enclave v10) in `check` and `compare`.
|
|
5
|
+
*
|
|
6
|
+
* From enclave v10 a recording signs its floor as a Base block
|
|
7
|
+
* (commit.slotFloor) instead of an Ethereum anchor. An audit that reads it
|
|
8
|
+
* reports a not-before segment bound with source "signed-floor" and chain
|
|
9
|
+
* "base", a TemporalAnalysis.signedFloors record per floor, and
|
|
10
|
+
* floorProblems. The Player reads those fields structurally (it still builds
|
|
11
|
+
* against an older audit), so these tests hand it AuditResult shapes with
|
|
12
|
+
* them, exactly as the fixtures elsewhere do. No ledger writes, no network.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it } from "node:test";
|
|
16
|
+
import * as assert from "node:assert/strict";
|
|
17
|
+
import type { AuditResult, ObservedProof, SegmentBound, TemporalSegment } from "@mikeargento/bitgraph-audit";
|
|
18
|
+
import { buildCheckReport, renderCheckText } from "../check.js";
|
|
19
|
+
import type { SignedFloorRecord } from "../check.js";
|
|
20
|
+
import { compare } from "../order.js";
|
|
21
|
+
import { digestFor, makeAudit, makeBound, proofHashOf } from "./fixtures.js";
|
|
22
|
+
|
|
23
|
+
const BASE_BLOCK = 52271417;
|
|
24
|
+
const BASE_TIME = 1791332181;
|
|
25
|
+
const BASE_HASH = "0x71d926aeae9847fcc15fad2d4de8871923f56862d42db7a7463e95a838f8f9b0";
|
|
26
|
+
|
|
27
|
+
function complete(audit: AuditResult, temporal: Record<string, unknown>): AuditResult {
|
|
28
|
+
return {
|
|
29
|
+
...audit,
|
|
30
|
+
anomalies: { anomalies: [], divergences: [], boundaryEntryPoints: [] },
|
|
31
|
+
authorities: { groups: [], anomalies: [], sharedSignersAcrossEpochs: [] },
|
|
32
|
+
anchors: { anchors: [], metadataOnlyProofHashes: [], findings: [] },
|
|
33
|
+
witnesses: { outcomes: [], findings: [] },
|
|
34
|
+
attestations: {
|
|
35
|
+
records: [],
|
|
36
|
+
findings: [],
|
|
37
|
+
counts: {
|
|
38
|
+
proofsWithDeclaredMeasurement: 0, proofsWithDocument: 0, documentsValidated: 0, documentsFailed: 0,
|
|
39
|
+
pcr0Matches: 0, pcr0Mismatches: 0, userDataBound: 0, userDataUnbound: 0,
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
temporal: { ...audit.temporal, ...temporal },
|
|
43
|
+
} as unknown as AuditResult;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function baseBound(proofName: string, opts?: Partial<{ timeSource: "header" | "signed"; evidence: SegmentBound["evidence"] | "signed-floor"; blockNumber: number; timestamp: number }>): SegmentBound {
|
|
47
|
+
return {
|
|
48
|
+
...makeBound({
|
|
49
|
+
kind: "not-before",
|
|
50
|
+
anchorProofHash: proofHashOf(proofName),
|
|
51
|
+
blockHash: BASE_HASH,
|
|
52
|
+
blockNumber: String(opts?.blockNumber ?? BASE_BLOCK),
|
|
53
|
+
timestamp: opts?.timestamp ?? BASE_TIME,
|
|
54
|
+
evidence: (opts?.evidence ?? "signed-floor") as SegmentBound["evidence"],
|
|
55
|
+
claim: "fixture Base floor bound",
|
|
56
|
+
}),
|
|
57
|
+
source: "signed-floor",
|
|
58
|
+
chain: "base",
|
|
59
|
+
timeSource: opts?.timeSource ?? "header",
|
|
60
|
+
} as SegmentBound;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function floorRecord(proofName: string, opts?: Partial<SignedFloorRecord>): SignedFloorRecord {
|
|
64
|
+
return {
|
|
65
|
+
proofHash: proofHashOf(proofName),
|
|
66
|
+
chain: "base",
|
|
67
|
+
blockNumber: BASE_BLOCK,
|
|
68
|
+
blockHash: BASE_HASH,
|
|
69
|
+
blockTimestamp: BASE_TIME,
|
|
70
|
+
header: "checked",
|
|
71
|
+
headerPath: "base-floor/floor-header.json",
|
|
72
|
+
bound: "not-before",
|
|
73
|
+
...(opts ?? {}),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function segment(names: string[], lower: SegmentBound[], upper: SegmentBound[] = [], epochId = "E10"): TemporalSegment {
|
|
78
|
+
return {
|
|
79
|
+
partition: { publicKeyB64: "key-A", chainId: "bitgraph:main", epochId },
|
|
80
|
+
memberProofHashes: names.map(proofHashOf),
|
|
81
|
+
status: lower.length > 0 && upper.length > 0 ? "lower-bounded-with-following-anchor" : lower.length > 0 ? "lower-bounded" : upper.length > 0 ? "upper-bounded" : "ordered-but-unanchored",
|
|
82
|
+
lowerBounds: lower,
|
|
83
|
+
upperBounds: upper,
|
|
84
|
+
} as TemporalSegment;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function oneRecording(temporal: Record<string, unknown>, attribution?: Record<string, string>): AuditResult {
|
|
88
|
+
const audit = makeAudit({ proofs: [{ name: "r", digestB64: digestFor("r"), epochId: "E10", counter: "4", slotCounter: "3" }] });
|
|
89
|
+
if (attribution !== undefined) {
|
|
90
|
+
(audit.ingest.proofs[0]!.proof as unknown as Record<string, unknown>)["attribution"] = attribution;
|
|
91
|
+
}
|
|
92
|
+
return complete(audit, temporal);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
describe("check: a recording that signs a Base floor", () => {
|
|
96
|
+
it("is bounded after its Base block, with the header checked, and no upper bound", () => {
|
|
97
|
+
const report = buildCheckReport(oneRecording({ segments: [segment(["r"], [baseBound("r")])], signedFloors: [floorRecord("r")] }));
|
|
98
|
+
const rec = report.recordings[0]!;
|
|
99
|
+
assert.equal(rec.bounds.status, "lower-bounded");
|
|
100
|
+
assert.equal(rec.bounds.notBefore?.chain, "base");
|
|
101
|
+
assert.equal(rec.bounds.notBefore?.evidence, "signed-floor");
|
|
102
|
+
assert.equal(rec.bounds.notBefore?.blockNumber, String(BASE_BLOCK));
|
|
103
|
+
assert.equal(rec.bounds.notAfter, undefined);
|
|
104
|
+
assert.match(rec.bounds.detail, /after Base block 52271417 \(its floor, header checked in this bundle\)/);
|
|
105
|
+
assert.doesNotMatch(rec.bounds.detail, /Ethereum/);
|
|
106
|
+
assert.ok(report.notChecked.some((n) => /Base floor blocks are Base's own/.test(n)));
|
|
107
|
+
assert.doesNotMatch(renderCheckText(report), /[–—]/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("without the header, says the block needs a Base lookup", () => {
|
|
111
|
+
const report = buildCheckReport(
|
|
112
|
+
oneRecording({ segments: [segment(["r"], [baseBound("r", { timeSource: "signed" })])], signedFloors: [floorRecord("r", { header: "not-carried" })] })
|
|
113
|
+
);
|
|
114
|
+
assert.match(report.recordings[0]!.bounds.detail, /confirming the block needs a Base lookup/);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("compares bounds across chains by time, never by block number", () => {
|
|
118
|
+
// Ethereum block 25,000,000 is later in time than this Base block,
|
|
119
|
+
// whose NUMBER is larger: the tightest not-before is the Ethereum one.
|
|
120
|
+
const eth = makeBound({ kind: "not-before", anchorProofHash: "anchor-eth", blockNumber: "25000000", timestamp: BASE_TIME + 600 });
|
|
121
|
+
const report = buildCheckReport(oneRecording({ segments: [segment(["r"], [baseBound("r"), eth])], signedFloors: [floorRecord("r")] }));
|
|
122
|
+
const nb = report.recordings[0]!.bounds.notBefore!;
|
|
123
|
+
assert.equal(nb.chain, undefined);
|
|
124
|
+
assert.equal(nb.blockNumber, "25000000");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("a fuse/3 recording's fused floor is the Base block it signs", () => {
|
|
128
|
+
const report = buildCheckReport(
|
|
129
|
+
oneRecording({ segments: [segment(["r"], [baseBound("r")])], signedFloors: [floorRecord("r")] }, { name: "bitgraph-fuse/3", title: "trailer/1" })
|
|
130
|
+
);
|
|
131
|
+
const fused = report.recordings[0]!.fused!;
|
|
132
|
+
assert.equal(fused.floor?.chain, "base");
|
|
133
|
+
assert.equal(fused.floor?.evidence, "signed-floor");
|
|
134
|
+
assert.equal(fused.floor?.blockNumber, String(BASE_BLOCK));
|
|
135
|
+
assert.match(fused.floorDetail, /Base block #52271417 .*the Base block this recording signs as its floor/);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("a withheld floor bounds nothing and says why", () => {
|
|
139
|
+
const why = "the floor block is stamped after the attestation document was made";
|
|
140
|
+
const report = buildCheckReport(
|
|
141
|
+
oneRecording({ segments: [segment(["r"], [])], signedFloors: [floorRecord("r", { bound: "withheld", withheldReason: why })] }, { name: "bitgraph-fuse/3", title: "trailer/1" })
|
|
142
|
+
);
|
|
143
|
+
const rec = report.recordings[0]!;
|
|
144
|
+
assert.equal(rec.bounds.notBefore, undefined);
|
|
145
|
+
assert.match(rec.bounds.detail, /not used as a bound/);
|
|
146
|
+
assert.equal(rec.fused!.floor, null);
|
|
147
|
+
assert.match(rec.fused!.floorDetail, /not used as a bound/);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("a floor problem (a recording that signs two floors) is a contradiction: FALSE", () => {
|
|
151
|
+
const report = buildCheckReport(
|
|
152
|
+
oneRecording({
|
|
153
|
+
segments: [segment(["r"], [])],
|
|
154
|
+
floorProblems: [{ code: "floor-ambiguous", proofHash: proofHashOf("r"), message: "the proof signs two floors" }],
|
|
155
|
+
})
|
|
156
|
+
);
|
|
157
|
+
assert.equal(report.result, "FALSE");
|
|
158
|
+
assert.ok(report.contradictions.some((c) => c.detail.startsWith("floor-ambiguous")));
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("compare: a Base floor orders no epoch", () => {
|
|
163
|
+
it("an anchor's not-after in one epoch and a later Base floor in another answer unordered", () => {
|
|
164
|
+
const base = makeAudit({
|
|
165
|
+
proofs: [
|
|
166
|
+
{ name: "old", digestB64: digestFor("old"), epochId: "E9", counter: "2" },
|
|
167
|
+
{ name: "new", digestB64: digestFor("new"), epochId: "E10", counter: "2", publicKeyB64: "key-B" },
|
|
168
|
+
],
|
|
169
|
+
partitions: [
|
|
170
|
+
{ epochId: "E9", members: ["old"] },
|
|
171
|
+
{ epochId: "E10", members: ["new"], publicKeyB64: "key-B" },
|
|
172
|
+
],
|
|
173
|
+
});
|
|
174
|
+
const anchorUpper = makeBound({ kind: "not-after", anchorProofHash: "anchor-9", blockNumber: "25000000", timestamp: 1_700_000_000 });
|
|
175
|
+
const audit = complete(base, {
|
|
176
|
+
segments: [segment(["old"], [], [anchorUpper], "E9"), segment(["new"], [baseBound("new")], [], "E10")],
|
|
177
|
+
signedFloors: [floorRecord("new")],
|
|
178
|
+
});
|
|
179
|
+
const byName = (n: string): ObservedProof => audit.ingest.proofs.find((p) => p.proofHash === proofHashOf(n))!;
|
|
180
|
+
assert.equal(compare(byName("old"), byName("new"), audit).relation, "unordered");
|
|
181
|
+
assert.equal(compare(byName("new"), byName("old"), audit).relation, "unordered");
|
|
182
|
+
// The same shape with an anchor not-before on the new side does order them (old behaviour kept).
|
|
183
|
+
const anchorLower = makeBound({ kind: "not-before", anchorProofHash: "anchor-10", blockNumber: "25000100", timestamp: 1_700_001_200 });
|
|
184
|
+
const anchored = complete(base, { segments: [segment(["old"], [], [anchorUpper], "E9"), segment(["new"], [anchorLower], [], "E10")] });
|
|
185
|
+
assert.equal(compare(byName("old"), byName("new"), anchored).relation, "before");
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -147,7 +147,7 @@ describe("check: real export fixture (disk)", () => {
|
|
|
147
147
|
it("recognizes the enclave v5 measurement from PINS.md", () => {
|
|
148
148
|
const enclave = report.recordings[0]!.lines.find((l) => l.name === "enclave")!;
|
|
149
149
|
assert.match(enclave.detail, /enclave v5/);
|
|
150
|
-
assert.equal(KNOWN_ENCLAVE_MEASUREMENTS.length,
|
|
150
|
+
assert.equal(KNOWN_ENCLAVE_MEASUREMENTS.length, 9);
|
|
151
151
|
});
|
|
152
152
|
|
|
153
153
|
it("serializes deterministically: no timestamps, no machine paths", async () => {
|
package/src/check.ts
CHANGED
|
@@ -70,6 +70,43 @@ import type { ThreeValued } from "./types.js";
|
|
|
70
70
|
import { kleeneAll } from "./logic.js";
|
|
71
71
|
import { PLAYER_VERSION } from "./verdict.js";
|
|
72
72
|
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Base floors (enclave v10), read structurally
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* What an audit that reads Base floors adds (bitgraph-audit after 0.9.0:
|
|
79
|
+
* TemporalAnalysis.signedFloors and floorProblems, and source / chain /
|
|
80
|
+
* timeSource on a segment bound). Read structurally, so the Player builds and
|
|
81
|
+
* behaves exactly as before against an audit without them; every field is
|
|
82
|
+
* optional and absent means an Ethereum anchor bound.
|
|
83
|
+
* TODO(release): bump the bitgraph-audit dependency to the release that
|
|
84
|
+
* carries signedFloors, then these can be the audit's own exported types.
|
|
85
|
+
*/
|
|
86
|
+
export interface SignedFloorRecord {
|
|
87
|
+
proofHash: string;
|
|
88
|
+
chain: "base";
|
|
89
|
+
blockNumber: number;
|
|
90
|
+
blockHash: string;
|
|
91
|
+
blockTimestamp: number;
|
|
92
|
+
header: "checked" | "not-carried";
|
|
93
|
+
headerPath?: string;
|
|
94
|
+
bound: "not-before" | "withheld";
|
|
95
|
+
withheldReason?: string;
|
|
96
|
+
}
|
|
97
|
+
export interface FloorProblem {
|
|
98
|
+
code: string;
|
|
99
|
+
proofHash?: string;
|
|
100
|
+
path?: string;
|
|
101
|
+
message: string;
|
|
102
|
+
}
|
|
103
|
+
type FloorAwareBound = SegmentBound & { source?: "signed-floor"; chain?: "base"; timeSource?: "header" | "signed" };
|
|
104
|
+
|
|
105
|
+
function floorView(audit: AuditResult): { floors: SignedFloorRecord[]; problems: FloorProblem[] } {
|
|
106
|
+
const t = audit.temporal as { signedFloors?: SignedFloorRecord[]; floorProblems?: FloorProblem[] };
|
|
107
|
+
return { floors: t.signedFloors ?? [], problems: t.floorProblems ?? [] };
|
|
108
|
+
}
|
|
109
|
+
|
|
73
110
|
// ---------------------------------------------------------------------------
|
|
74
111
|
// Declared knowledge: published enclave measurements
|
|
75
112
|
// ---------------------------------------------------------------------------
|
|
@@ -119,7 +156,12 @@ export const KNOWN_ENCLAVE_MEASUREMENTS: ReadonlyArray<{ pcr0: string; label: st
|
|
|
119
156
|
{
|
|
120
157
|
pcr0: "934feb8bb6f4f7e2d2f85d902a7d5edd0981f706d9d2385638988ac096a05ea0583c3d00eef2a7947865ec66efc1fcf8",
|
|
121
158
|
label: "enclave v9 (reproducible; the allocation returns the floor anchor, for bitgraph-fuse/2)",
|
|
122
|
-
period: "2026-09-30
|
|
159
|
+
period: "2026-09-30 to 2026-10-07",
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
pcr0: "5a947cc66095adcceefa9e5ece5d1416dfe08c3470df1bcaa5b2bc5267b0480e6cdc172fe077cd06b0afb07614307973",
|
|
163
|
+
label: "enclave v10 (reproducible; the floor is a Base block fixed at allocation, for bitgraph-fuse/3)",
|
|
164
|
+
period: "2026-10-08 onward",
|
|
123
165
|
},
|
|
124
166
|
];
|
|
125
167
|
|
|
@@ -134,15 +176,24 @@ export interface CheckLine {
|
|
|
134
176
|
detail: string;
|
|
135
177
|
}
|
|
136
178
|
|
|
137
|
-
/**
|
|
179
|
+
/**
|
|
180
|
+
* A verified bound on a recording: an Ethereum anchor's, from a witness in
|
|
181
|
+
* the bundle, or (enclave v10) the Base block a recording signs as its floor,
|
|
182
|
+
* which is a not-before only.
|
|
183
|
+
*/
|
|
138
184
|
export interface CheckBound {
|
|
185
|
+
/** Absent for Ethereum (an anchor); "base" for a Base floor (commit.slotFloor). */
|
|
186
|
+
chain?: "base";
|
|
139
187
|
blockNumber?: string;
|
|
140
188
|
blockHash: string;
|
|
141
|
-
/** Unix seconds from the verified block header. */
|
|
189
|
+
/** Unix seconds: from the verified block header, or for a Base floor without one, the time the proof signs. */
|
|
142
190
|
timestamp: number;
|
|
191
|
+
/** The anchor proof, or for a Base floor the recording that signs it. */
|
|
143
192
|
anchorProofHash: string;
|
|
144
|
-
evidence: "chain-link" | "counter-order";
|
|
193
|
+
evidence: "chain-link" | "counter-order" | "signed-floor";
|
|
145
194
|
weaker: boolean;
|
|
195
|
+
/** Base floors only: "header" checked in the bundle, or "signed" (confirming the block needs a Base lookup). */
|
|
196
|
+
timeSource?: "header" | "signed";
|
|
146
197
|
}
|
|
147
198
|
|
|
148
199
|
export interface CheckBounds {
|
|
@@ -153,18 +204,26 @@ export interface CheckBounds {
|
|
|
153
204
|
}
|
|
154
205
|
|
|
155
206
|
/**
|
|
156
|
-
* The wall-clock floor of a fused artifact
|
|
157
|
-
* block preceding its SLOT counter in
|
|
158
|
-
* evidence by construction (no hash path
|
|
159
|
-
*
|
|
207
|
+
* The wall-clock floor of a fused artifact. Ethereum floors (enclave v9 and
|
|
208
|
+
* before): the last verified anchored block preceding its SLOT counter in
|
|
209
|
+
* the same epoch chain, counter-order evidence by construction (no hash path
|
|
210
|
+
* reaches a slot record). Base floors (enclave v10): the block the recording
|
|
211
|
+
* itself signs as commit.slotFloor, which a bitgraph-fuse/3 commitment binds
|
|
212
|
+
* ("signed-floor"). Either is reported with the same "genuine block"
|
|
213
|
+
* assumption as every other bound.
|
|
160
214
|
*/
|
|
161
215
|
export interface CheckFloor {
|
|
216
|
+
/** Absent for Ethereum; "base" for the recording's own Base floor. */
|
|
217
|
+
chain?: "base";
|
|
162
218
|
blockNumber?: string;
|
|
163
219
|
blockHash: string;
|
|
164
|
-
/** Unix seconds from the verified block header. */
|
|
220
|
+
/** Unix seconds from the verified block header, or for a Base floor without one, the signed time. */
|
|
165
221
|
timestamp: number;
|
|
222
|
+
/** The anchor proof, or for a Base floor the recording itself. */
|
|
166
223
|
anchorProofHash: string;
|
|
167
|
-
evidence: "counter-order";
|
|
224
|
+
evidence: "counter-order" | "signed-floor";
|
|
225
|
+
/** Base floors only: "header" checked in the bundle, or "signed" (confirming the block needs a Base lookup). */
|
|
226
|
+
timeSource?: "header" | "signed";
|
|
168
227
|
}
|
|
169
228
|
|
|
170
229
|
/** What the bundle establishes about a recording marked fused (profile bitgraph-fuse/1, working name). */
|
|
@@ -337,6 +396,7 @@ export function buildCheckReport(audit: AuditResult, options?: CheckOptions): Ch
|
|
|
337
396
|
const attestationByHash = new Map<string, ProofAttestationRecord>(
|
|
338
397
|
audit.attestations.records.map((r) => [r.proofHash, r])
|
|
339
398
|
);
|
|
399
|
+
const floorByHash = new Map<string, SignedFloorRecord>(floorView(audit).floors.map((f) => [f.proofHash, f]));
|
|
340
400
|
const segmentByHash = new Map<string, TemporalSegment>();
|
|
341
401
|
for (const segment of audit.temporal.segments) {
|
|
342
402
|
for (const hash of segment.memberProofHashes) segmentByHash.set(hash, segment);
|
|
@@ -373,7 +433,8 @@ export function buildCheckReport(audit: AuditResult, options?: CheckOptions): Ch
|
|
|
373
433
|
artifactPathByProof.get(proof.proofHash),
|
|
374
434
|
webCrypto,
|
|
375
435
|
options?.fuseEvidence,
|
|
376
|
-
anchorByHash
|
|
436
|
+
anchorByHash,
|
|
437
|
+
floorByHash.get(proof.proofHash)
|
|
377
438
|
)
|
|
378
439
|
);
|
|
379
440
|
}
|
|
@@ -383,7 +444,7 @@ export function buildCheckReport(audit: AuditResult, options?: CheckOptions): Ch
|
|
|
383
444
|
sortByPosition(anchors);
|
|
384
445
|
const contradictions = collectContradictions(audit);
|
|
385
446
|
const notes = collectNotes(audit, recordings, anchors, options?.fuseOriginHexes);
|
|
386
|
-
const notChecked = collectNotChecked(anchors.length > 0);
|
|
447
|
+
const notChecked = collectNotChecked(anchors.length > 0, floorByHash.size > 0);
|
|
387
448
|
|
|
388
449
|
const allLines: ThreeValued[] = [
|
|
389
450
|
...recordings.map((r) => r.result),
|
|
@@ -437,7 +498,8 @@ function buildRecording(
|
|
|
437
498
|
filePath: string | undefined,
|
|
438
499
|
webCrypto: boolean,
|
|
439
500
|
fuseEvidence?: ReadonlyMap<string, FuseVerifyResult>,
|
|
440
|
-
anchorByHash?: ReadonlyMap<string, AnchorRecord
|
|
501
|
+
anchorByHash?: ReadonlyMap<string, AnchorRecord>,
|
|
502
|
+
signedFloor?: SignedFloorRecord
|
|
441
503
|
): CheckRecording {
|
|
442
504
|
const lines: CheckLine[] = [];
|
|
443
505
|
const v = proof.verification;
|
|
@@ -493,12 +555,13 @@ function buildRecording(
|
|
|
493
555
|
// fused: the proof's signed attribution marks a fused artifact (profile
|
|
494
556
|
// bitgraph-fuse/1). The commitment check runs over the fused bytes, or
|
|
495
557
|
// over the original by reconstruction; its floor is the last verified
|
|
496
|
-
// anchor preceding the SLOT, not the commit
|
|
558
|
+
// anchor preceding the SLOT, not the commit, or for a recording that signs
|
|
559
|
+
// a Base floor (enclave v10, bitgraph-fuse/3) that block itself.
|
|
497
560
|
const marker = readFuseAttribution(proof.proof);
|
|
498
561
|
let fused: CheckFused | undefined;
|
|
499
562
|
if (marker !== null) {
|
|
500
|
-
const floor = fusedFloor(segment, anchorByHash, proof.slotCounter, proof.chainId);
|
|
501
|
-
const built = fusedLine(marker.placement, marker.originDigest !== undefined ? proof.proof.attribution?.message ?? null : null, fuseEvidence?.get(proof.proofHash), floor, proof);
|
|
563
|
+
const floor = signedFloor !== undefined ? ownBaseFloor(signedFloor) : fusedFloor(segment, anchorByHash, proof.slotCounter, proof.chainId);
|
|
564
|
+
const built = fusedLine(marker.placement, marker.originDigest !== undefined ? proof.proof.attribution?.message ?? null : null, fuseEvidence?.get(proof.proofHash), floor, proof, signedFloor);
|
|
502
565
|
lines.push(built.line);
|
|
503
566
|
fused = built.fused;
|
|
504
567
|
}
|
|
@@ -516,7 +579,7 @@ function buildRecording(
|
|
|
516
579
|
...(filePath !== undefined && v?.tier === "full" ? { filePath } : {}),
|
|
517
580
|
lines,
|
|
518
581
|
result,
|
|
519
|
-
bounds: boundsFor(segment),
|
|
582
|
+
bounds: boundsFor(segment, signedFloor),
|
|
520
583
|
...(fused !== undefined ? { fused } : {}),
|
|
521
584
|
};
|
|
522
585
|
}
|
|
@@ -561,11 +624,29 @@ export function fusedFloor(
|
|
|
561
624
|
};
|
|
562
625
|
}
|
|
563
626
|
|
|
627
|
+
/**
|
|
628
|
+
* The fused floor of a recording that signs a Base floor (enclave v10): the
|
|
629
|
+
* block itself, which a bitgraph-fuse/3 commitment binds. Null when the audit
|
|
630
|
+
* withheld it as a bound (the reason is in the floor detail).
|
|
631
|
+
*/
|
|
632
|
+
export function ownBaseFloor(floor: SignedFloorRecord): CheckFloor | null {
|
|
633
|
+
if (floor.bound !== "not-before") return null;
|
|
634
|
+
return {
|
|
635
|
+
chain: "base",
|
|
636
|
+
blockNumber: String(floor.blockNumber),
|
|
637
|
+
blockHash: floor.blockHash,
|
|
638
|
+
timestamp: floor.blockTimestamp,
|
|
639
|
+
anchorProofHash: floor.proofHash,
|
|
640
|
+
evidence: "signed-floor",
|
|
641
|
+
timeSource: floor.header === "checked" ? "header" : "signed",
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
|
|
564
645
|
const FLOOR_UNDETERMINED = "floor undetermined: no anchor precedes this slot in its epoch";
|
|
565
646
|
|
|
566
647
|
function floorClause(floor: CheckFloor): string {
|
|
567
648
|
const block = floor.blockNumber !== undefined ? `#${floor.blockNumber}` : floor.blockHash;
|
|
568
|
-
return
|
|
649
|
+
return `${floor.chain === "base" ? "Base" : "anchored"} block ${block} (${new Date(floor.timestamp * 1000).toISOString()})`;
|
|
569
650
|
}
|
|
570
651
|
|
|
571
652
|
function fusedLine(
|
|
@@ -573,13 +654,19 @@ function fusedLine(
|
|
|
573
654
|
originMessage: string | null,
|
|
574
655
|
ev: FuseVerifyResult | undefined,
|
|
575
656
|
floor: CheckFloor | null,
|
|
576
|
-
proof: ObservedProof
|
|
657
|
+
proof: ObservedProof,
|
|
658
|
+
signedFloor?: SignedFloorRecord
|
|
577
659
|
): { line: CheckLine; fused: CheckFused } {
|
|
578
660
|
const slot = proof.slotCounter ?? "?";
|
|
579
661
|
const where = placement !== null ? ` (placement ${placement})` : " (placement undeclared)";
|
|
580
|
-
const floorDetail = floor !== null
|
|
662
|
+
const floorDetail = floor !== null && floor.chain === "base"
|
|
663
|
+
? `assembled after ${floorClause(floor)}, the Base block this recording signs as its floor` +
|
|
664
|
+
(floor.timeSource === "signed" ? "; no header for it is in this bundle, so confirming the block needs a Base lookup" : "")
|
|
665
|
+
: floor !== null
|
|
581
666
|
? `assembled after ${floorClause(floor)}, the last verified anchor preceding slot ${slot} in this epoch`
|
|
582
|
-
:
|
|
667
|
+
: signedFloor !== undefined
|
|
668
|
+
? `floor undetermined: the Base floor this recording signs is not used as a bound (${signedFloor.withheldReason ?? "withheld"})`
|
|
669
|
+
: proof.chainId !== ANCHORED_CHAIN
|
|
583
670
|
? `floor undetermined: the slot is on chain "${proof.chainId}", not the anchored chain`
|
|
584
671
|
: FLOOR_UNDETERMINED;
|
|
585
672
|
const span = ev?.span !== undefined && ev.span !== null
|
|
@@ -713,9 +800,13 @@ function enclaveLine(attestedPcr0: string | undefined, attestationResult: ThreeV
|
|
|
713
800
|
};
|
|
714
801
|
}
|
|
715
802
|
|
|
716
|
-
function boundsFor(segment: TemporalSegment | undefined): CheckBounds {
|
|
803
|
+
function boundsFor(segment: TemporalSegment | undefined, signedFloor?: SignedFloorRecord): CheckBounds {
|
|
804
|
+
const withheld =
|
|
805
|
+
signedFloor !== undefined && signedFloor.bound === "withheld"
|
|
806
|
+
? `; the Base floor it signs is not used as a bound (${signedFloor.withheldReason ?? "withheld"})`
|
|
807
|
+
: "";
|
|
717
808
|
if (segment === undefined) {
|
|
718
|
-
return { status: "unanchored", detail:
|
|
809
|
+
return { status: "unanchored", detail: `no verified Ethereum anchor bounds this recording in this bundle${withheld}` };
|
|
719
810
|
}
|
|
720
811
|
const lower = tightest(segment.lowerBounds, "not-before");
|
|
721
812
|
const upper = tightest(segment.upperBounds, "not-after");
|
|
@@ -731,8 +822,8 @@ function boundsFor(segment: TemporalSegment | undefined): CheckBounds {
|
|
|
731
822
|
: "unanchored";
|
|
732
823
|
const detail =
|
|
733
824
|
status === "unanchored"
|
|
734
|
-
?
|
|
735
|
-
: `recorded ${boundsPhrase(status, notBefore, notAfter)}`;
|
|
825
|
+
? `no verified Ethereum anchor bounds this recording in this bundle${withheld}`
|
|
826
|
+
: `recorded ${boundsPhrase(status, notBefore, notAfter)}${withheld}`;
|
|
736
827
|
return {
|
|
737
828
|
status,
|
|
738
829
|
...(notBefore !== undefined ? { notBefore } : {}),
|
|
@@ -756,11 +847,11 @@ function boundsPhrase(
|
|
|
756
847
|
switch (status) {
|
|
757
848
|
case "lower-bounded-with-following-anchor":
|
|
758
849
|
return (
|
|
759
|
-
|
|
850
|
+
`${afterBlock(notBefore as CheckBound)}; precedes an anchor that consumed block ${blockRef(notAfter as CheckBound)} (header verified), an anchor-latency assumption rather than an upper bound on this recording` +
|
|
760
851
|
weakerSuffix((notBefore as CheckBound).weaker || (notAfter as CheckBound).weaker)
|
|
761
852
|
);
|
|
762
853
|
case "lower-bounded":
|
|
763
|
-
return
|
|
854
|
+
return `${afterBlock(notBefore as CheckBound)}; no verified upper bound here` + weakerSuffix((notBefore as CheckBound).weaker);
|
|
764
855
|
case "upper-bounded":
|
|
765
856
|
return `with no verified lower bound in this bundle; an anchor that consumed block ${blockRef(notAfter as CheckBound)} followed (header verified), which is not an upper bound` + weakerSuffix((notAfter as CheckBound).weaker);
|
|
766
857
|
default:
|
|
@@ -768,17 +859,31 @@ function boundsPhrase(
|
|
|
768
859
|
}
|
|
769
860
|
}
|
|
770
861
|
|
|
862
|
+
/**
|
|
863
|
+
* "after Ethereum block N (header verified in this bundle)", or for a Base
|
|
864
|
+
* floor (enclave v10) "after Base block N" with where its time comes from.
|
|
865
|
+
*/
|
|
866
|
+
function afterBlock(b: CheckBound): string {
|
|
867
|
+
if (b.chain !== "base") return `after Ethereum block ${blockRef(b)} (header verified in this bundle)`;
|
|
868
|
+
return b.timeSource === "header"
|
|
869
|
+
? `after Base block ${blockRef(b)} (its floor, header checked in this bundle)`
|
|
870
|
+
: `after Base block ${blockRef(b)} (its floor, at the time the proof signs; confirming the block needs a Base lookup)`;
|
|
871
|
+
}
|
|
872
|
+
|
|
771
873
|
/**
|
|
772
874
|
* The tightest bound of a kind: for not-before the LARGEST block number
|
|
773
875
|
* (latest verified anchor known to precede), for not-after the SMALLEST.
|
|
876
|
+
* Block numbers compare only within one chain: across Ethereum and Base
|
|
877
|
+
* (enclave v10 floors) the block times compare instead.
|
|
774
878
|
* Ties resolve by preferring chain-link evidence over counter-order.
|
|
775
879
|
*/
|
|
776
|
-
function tightest(bounds:
|
|
880
|
+
function tightest(bounds: FloorAwareBound[], kind: "not-before" | "not-after"): FloorAwareBound | undefined {
|
|
777
881
|
const ofKind = bounds.filter((b) => b.kind === kind);
|
|
778
882
|
if (ofKind.length === 0) return undefined;
|
|
779
883
|
const sorted = [...ofKind].sort((a, b) => {
|
|
780
|
-
const
|
|
781
|
-
const
|
|
884
|
+
const sameChain = a.chain === b.chain;
|
|
885
|
+
const an = sameChain && a.blockNumber !== undefined ? BigInt(a.blockNumber) : BigInt(a.timestamp);
|
|
886
|
+
const bn = sameChain && b.blockNumber !== undefined ? BigInt(b.blockNumber) : BigInt(b.timestamp);
|
|
782
887
|
if (an !== bn) return kind === "not-before" ? (an > bn ? -1 : 1) : an < bn ? -1 : 1;
|
|
783
888
|
if (a.weaker !== b.weaker) return a.weaker ? 1 : -1;
|
|
784
889
|
return 0;
|
|
@@ -786,14 +891,16 @@ function tightest(bounds: SegmentBound[], kind: "not-before" | "not-after"): Seg
|
|
|
786
891
|
return sorted[0];
|
|
787
892
|
}
|
|
788
893
|
|
|
789
|
-
function toBound(b:
|
|
894
|
+
function toBound(b: FloorAwareBound): CheckBound {
|
|
790
895
|
return {
|
|
896
|
+
...(b.chain === "base" ? { chain: "base" as const } : {}),
|
|
791
897
|
...(b.blockNumber !== undefined ? { blockNumber: b.blockNumber } : {}),
|
|
792
898
|
blockHash: b.blockHash,
|
|
793
899
|
timestamp: b.timestamp,
|
|
794
900
|
anchorProofHash: b.anchorProofHash,
|
|
795
901
|
evidence: b.evidence,
|
|
796
902
|
weaker: b.weaker,
|
|
903
|
+
...(b.timeSource !== undefined ? { timeSource: b.timeSource } : {}),
|
|
797
904
|
};
|
|
798
905
|
}
|
|
799
906
|
|
|
@@ -801,9 +908,9 @@ function toBound(b: SegmentBound): CheckBound {
|
|
|
801
908
|
function shortBoundsPhrase(b: CheckBounds): string {
|
|
802
909
|
switch (b.status) {
|
|
803
910
|
case "lower-bounded-with-following-anchor":
|
|
804
|
-
return `after
|
|
911
|
+
return `after ${chainWord(b.notBefore as CheckBound)} block ${blockRef(b.notBefore as CheckBound)}, then an anchor at block ${blockRef(b.notAfter as CheckBound)}`;
|
|
805
912
|
case "lower-bounded":
|
|
806
|
-
return `after
|
|
913
|
+
return `after ${chainWord(b.notBefore as CheckBound)} block ${blockRef(b.notBefore as CheckBound)}`;
|
|
807
914
|
case "upper-bounded":
|
|
808
915
|
return `with no lower bound in this bundle, then an anchor at block ${blockRef(b.notAfter as CheckBound)} (not an upper bound)`;
|
|
809
916
|
default:
|
|
@@ -811,6 +918,10 @@ function shortBoundsPhrase(b: CheckBounds): string {
|
|
|
811
918
|
}
|
|
812
919
|
}
|
|
813
920
|
|
|
921
|
+
function chainWord(b: CheckBound): string {
|
|
922
|
+
return b.chain === "base" ? "Base" : "Ethereum";
|
|
923
|
+
}
|
|
924
|
+
|
|
814
925
|
function blockRef(b: CheckBound): string {
|
|
815
926
|
return b.blockNumber !== undefined ? b.blockNumber : b.blockHash;
|
|
816
927
|
}
|
|
@@ -915,6 +1026,12 @@ function collectContradictions(audit: AuditResult): CheckLine[] {
|
|
|
915
1026
|
push(`${finding.code}: ${finding.message}`);
|
|
916
1027
|
}
|
|
917
1028
|
}
|
|
1029
|
+
// A floor problem (enclave v10 floors): a recording that signs two floors,
|
|
1030
|
+
// a malformed or off-schedule Base floor, a floor header that contradicts
|
|
1031
|
+
// the signed floor or matches none. Evidence that contradicts itself.
|
|
1032
|
+
for (const problem of floorView(audit).problems) {
|
|
1033
|
+
push(`${problem.code}: ${problem.message}${problem.path !== undefined ? ` (${problem.path})` : ""}`);
|
|
1034
|
+
}
|
|
918
1035
|
// An embedded proofHash that does not match the recomputed one: the
|
|
919
1036
|
// stored proof file was altered after the ledger wrote it, or is not
|
|
920
1037
|
// what it claims.
|
|
@@ -1000,13 +1117,18 @@ function collectNotes(audit: AuditResult, recordings: CheckRecording[], anchors:
|
|
|
1000
1117
|
return notes;
|
|
1001
1118
|
}
|
|
1002
1119
|
|
|
1003
|
-
function collectNotChecked(hasAnchors: boolean): string[] {
|
|
1120
|
+
function collectNotChecked(hasAnchors: boolean, hasBaseFloors = false): string[] {
|
|
1004
1121
|
const out: string[] = [];
|
|
1005
1122
|
if (hasAnchors) {
|
|
1006
1123
|
out.push(
|
|
1007
1124
|
"whether the anchored Ethereum blocks are canonical: their headers are recomputed here, but canonicality needs an Ethereum node or a block explorer"
|
|
1008
1125
|
);
|
|
1009
1126
|
}
|
|
1127
|
+
if (hasBaseFloors) {
|
|
1128
|
+
out.push(
|
|
1129
|
+
"whether the Base floor blocks are Base's own: a header in the bundle is checked here against the signed block, but that the block is on Base's chain needs a Base node or a block explorer"
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
1010
1132
|
out.push(
|
|
1011
1133
|
"whether the anchors in this bundle are the ones BitGraph published: this is an offline check; the published anchors are listed at bitgraph.ing/ledger"
|
|
1012
1134
|
);
|
package/src/order.ts
CHANGED
|
@@ -31,7 +31,10 @@
|
|
|
31
31
|
* - "anchor-bounds": strict block separation between verified segment
|
|
32
32
|
* bounds; always assumption-dependent (anchor freshness). If BOTH
|
|
33
33
|
* directions satisfy strict separation the evidence is contradictory
|
|
34
|
-
* and answers nothing.
|
|
34
|
+
* and answers nothing. Only ANCHOR bounds take part: a Base floor
|
|
35
|
+
* (enclave v10, a segment bound with source "signed-floor") is a
|
|
36
|
+
* not-before for its own recording and orders nothing across epochs,
|
|
37
|
+
* and its block numbers are Base's, never comparable to Ethereum's.
|
|
35
38
|
* Epoch pairs supported by neither are concurrent-or-unordered, and the
|
|
36
39
|
* honest answer is "unordered", never a coin flip.
|
|
37
40
|
*/
|
|
@@ -215,13 +218,21 @@ function boundsStrictlyOrdered(upper: SegmentBound, lower: SegmentBound): boolea
|
|
|
215
218
|
return upper.timestamp < lower.timestamp;
|
|
216
219
|
}
|
|
217
220
|
|
|
221
|
+
/** A Base floor bound (enclave v10; audit field source "signed-floor"), read structurally. */
|
|
222
|
+
function isFloorBound(b: SegmentBound): boolean {
|
|
223
|
+
return (b as { source?: string }).source !== undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
218
226
|
function anchorPrecedes(
|
|
219
227
|
firstSegment: TemporalSegment,
|
|
220
228
|
secondSegment: TemporalSegment
|
|
221
229
|
): { weaker: boolean; upper: SegmentBound; lower: SegmentBound } | undefined {
|
|
222
230
|
let best: { weaker: boolean; upper: SegmentBound; lower: SegmentBound } | undefined;
|
|
223
231
|
for (const upper of firstSegment.upperBounds) {
|
|
232
|
+
if (isFloorBound(upper)) continue;
|
|
224
233
|
for (const lower of secondSegment.lowerBounds) {
|
|
234
|
+
// Anchor bounds only: a Base floor orders no epoch.
|
|
235
|
+
if (isFloorBound(lower)) continue;
|
|
225
236
|
if (!boundsStrictlyOrdered(upper, lower)) continue;
|
|
226
237
|
const weaker = upper.weaker || lower.weaker;
|
|
227
238
|
if (best === undefined || (best.weaker && !weaker)) {
|
package/src/verdict.ts
CHANGED
|
@@ -31,7 +31,7 @@ import type { DeclaredEntry, Resolution, Rule, Verdict } from "./types.js";
|
|
|
31
31
|
* A unit test asserts this equals package.json's version, so the
|
|
32
32
|
* constant cannot drift silently across releases.
|
|
33
33
|
*/
|
|
34
|
-
export const PLAYER_VERSION = "0.
|
|
34
|
+
export const PLAYER_VERSION = "0.16.0";
|
|
35
35
|
|
|
36
36
|
/** The player package's own version. */
|
|
37
37
|
export function playerVersion(): string {
|