@mikeargento/bitgraph-audit 0.1.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/LICENSE +21 -0
- package/README.md +11 -0
- package/dist/anchors.d.ts +31 -0
- package/dist/anchors.d.ts.map +1 -0
- package/dist/anchors.js +116 -0
- package/dist/anchors.js.map +1 -0
- package/dist/anomalies.d.ts +42 -0
- package/dist/anomalies.d.ts.map +1 -0
- package/dist/anomalies.js +642 -0
- package/dist/anomalies.js.map +1 -0
- package/dist/attestation.d.ts +20 -0
- package/dist/attestation.d.ts.map +1 -0
- package/dist/attestation.js +795 -0
- package/dist/attestation.js.map +1 -0
- package/dist/audit.d.ts +19 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +116 -0
- package/dist/audit.js.map +1 -0
- package/dist/authority.d.ts +37 -0
- package/dist/authority.d.ts.map +1 -0
- package/dist/authority.js +126 -0
- package/dist/authority.js.map +1 -0
- package/dist/aws-nitro-root-ca.d.ts +24 -0
- package/dist/aws-nitro-root-ca.d.ts.map +1 -0
- package/dist/aws-nitro-root-ca.js +44 -0
- package/dist/aws-nitro-root-ca.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +257 -0
- package/dist/cli.js.map +1 -0
- package/dist/contents-hash.d.ts +30 -0
- package/dist/contents-hash.d.ts.map +1 -0
- package/dist/contents-hash.js +73 -0
- package/dist/contents-hash.js.map +1 -0
- package/dist/export.d.ts +64 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +192 -0
- package/dist/export.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/ingest.d.ts +36 -0
- package/dist/ingest.d.ts.map +1 -0
- package/dist/ingest.js +828 -0
- package/dist/ingest.js.map +1 -0
- package/dist/reconstruct.d.ts +40 -0
- package/dist/reconstruct.d.ts.map +1 -0
- package/dist/reconstruct.js +400 -0
- package/dist/reconstruct.js.map +1 -0
- package/dist/report-json.d.ts +3 -0
- package/dist/report-json.d.ts.map +1 -0
- package/dist/report-json.js +290 -0
- package/dist/report-json.js.map +1 -0
- package/dist/report-md.d.ts +3 -0
- package/dist/report-md.d.ts.map +1 -0
- package/dist/report-md.js +853 -0
- package/dist/report-md.js.map +1 -0
- package/dist/rlp.d.ts +37 -0
- package/dist/rlp.d.ts.map +1 -0
- package/dist/rlp.js +120 -0
- package/dist/rlp.js.map +1 -0
- package/dist/tar.d.ts +47 -0
- package/dist/tar.d.ts.map +1 -0
- package/dist/tar.js +483 -0
- package/dist/tar.js.map +1 -0
- package/dist/temporal.d.ts +55 -0
- package/dist/temporal.d.ts.map +1 -0
- package/dist/temporal.js +461 -0
- package/dist/temporal.js.map +1 -0
- package/dist/types.d.ts +1389 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/validity.d.ts +21 -0
- package/dist/validity.d.ts.map +1 -0
- package/dist/validity.js +86 -0
- package/dist/validity.js.map +1 -0
- package/dist/verify-tiers.d.ts +11 -0
- package/dist/verify-tiers.d.ts.map +1 -0
- package/dist/verify-tiers.js +120 -0
- package/dist/verify-tiers.js.map +1 -0
- package/dist/witness.d.ts +25 -0
- package/dist/witness.d.ts.map +1 -0
- package/dist/witness.js +308 -0
- package/dist/witness.js.map +1 -0
- package/package.json +42 -0
- package/src/anchors.ts +168 -0
- package/src/anomalies.ts +773 -0
- package/src/attestation.ts +878 -0
- package/src/audit.ts +136 -0
- package/src/authority.ts +165 -0
- package/src/aws-nitro-root-ca.ts +47 -0
- package/src/cli.ts +284 -0
- package/src/contents-hash.ts +95 -0
- package/src/export.ts +278 -0
- package/src/index.ts +122 -0
- package/src/ingest.ts +967 -0
- package/src/reconstruct.ts +491 -0
- package/src/report-json.ts +331 -0
- package/src/report-md.ts +1067 -0
- package/src/rlp.ts +142 -0
- package/src/tar.ts +587 -0
- package/src/temporal.ts +629 -0
- package/src/types.ts +1528 -0
- package/src/validity.ts +90 -0
- package/src/verify-tiers.ts +137 -0
- package/src/witness.ts +407 -0
package/src/temporal.ts
ADDED
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
// Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* bitgraph-audit temporal bounds
|
|
5
|
+
*
|
|
6
|
+
* Turns verified anchor witnesses into one-sided wall-clock bounds on
|
|
7
|
+
* segments of the observed record. Only anchors with a VERIFIED witness
|
|
8
|
+
* (witness.ts) contribute: an anchor without one still establishes causal
|
|
9
|
+
* order, but confers no wall-clock evidence. Nothing here reads a block
|
|
10
|
+
* number as a time, an unsigned metadata timestamp, or a manifest time.
|
|
11
|
+
*
|
|
12
|
+
* Bound semantics, stated once and enforced everywhere:
|
|
13
|
+
*
|
|
14
|
+
* not-before (lower bound): a proof causally AFTER an anchor that
|
|
15
|
+
* consumed the hash of a block published at time T was COMMITTED no
|
|
16
|
+
* earlier than T. Grounded in block-hash unpredictability: the hash did
|
|
17
|
+
* not exist before T and the covered proofs embed it through the chain.
|
|
18
|
+
* This additionally assumes the anchored header is a genuine, publicly
|
|
19
|
+
* published Ethereum block, which this offline audit cannot confirm: it
|
|
20
|
+
* checks the header's structure and hash binding, not proof-of-work,
|
|
21
|
+
* consensus, or chain membership. Every not-before claim states that
|
|
22
|
+
* assumption. Sound along chain-link evidence, subject to it.
|
|
23
|
+
*
|
|
24
|
+
* not-after (upper bound): a proof causally BEFORE an anchor existed
|
|
25
|
+
* before that anchor's commit, and the consumed block proves the commit
|
|
26
|
+
* came AT OR AFTER its timestamp T, not how promptly. Reading T as a
|
|
27
|
+
* wall-clock ceiling therefore additionally assumes the anchor consumed
|
|
28
|
+
* a recently published block. The deployed anchor service commits the
|
|
29
|
+
* latest block on a short interval, but that is service behavior, not
|
|
30
|
+
* proof; the assumption is stated on every not-after bound and on every
|
|
31
|
+
* cross-epoch ordering derived from one. This is a deliberate
|
|
32
|
+
* correction toward honesty over the looser "existed by T" phrasing:
|
|
33
|
+
* the anchor mechanism is inbound-only (a block hash committed INTO the
|
|
34
|
+
* chain), and an inbound commitment cannot cryptographically upper-bound
|
|
35
|
+
* prior events.
|
|
36
|
+
*
|
|
37
|
+
* Evidence classes: a bound holds along a verified prevB64 hash-link path
|
|
38
|
+
* ("chain-link") or, weaker, by commit-counter ordering within the same
|
|
39
|
+
* partition ("counter-order", which relies on the authority's counter
|
|
40
|
+
* discipline rather than verifiable links). Every bound record states
|
|
41
|
+
* which class supports it. Bounds never cross partitions; epoch-level
|
|
42
|
+
* aggregation only collects the per-partition results.
|
|
43
|
+
*
|
|
44
|
+
* Bounds attach to segments, never to individual proofs, and a one-sided
|
|
45
|
+
* bound is never presented as an interval. Segments with no verified
|
|
46
|
+
* anchor evidence are ordered-but-unanchored. Cross-epoch ordering pairs
|
|
47
|
+
* are evidence, never divergence; overlapping or absent bounds mean
|
|
48
|
+
* concurrent-or-unordered.
|
|
49
|
+
*
|
|
50
|
+
* Run after verifyObservedProofs, reconstructChains, identifyAnchors, and
|
|
51
|
+
* verifyAnchorWitnesses. Populates EpochRecord.anchorBounds on the given
|
|
52
|
+
* reconstruction (the typed Phase 4c extension point) and returns the
|
|
53
|
+
* segment-level analysis.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
import type {
|
|
57
|
+
AnchorIdentification,
|
|
58
|
+
AnchorOrderedPair,
|
|
59
|
+
AnchorWitnessAnalysis,
|
|
60
|
+
BoundEvidence,
|
|
61
|
+
ChainPartition,
|
|
62
|
+
EpochAnchorBound,
|
|
63
|
+
IngestResult,
|
|
64
|
+
ObservedProof,
|
|
65
|
+
ReconstructionResult,
|
|
66
|
+
SegmentBound,
|
|
67
|
+
TemporalAnalysis,
|
|
68
|
+
TemporalSegment,
|
|
69
|
+
} from "./types.js";
|
|
70
|
+
import { byCounterThenHash, parseCounter, pushMap } from "./validity.js";
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Public API
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
export function deriveTemporalBounds(
|
|
77
|
+
ingest: IngestResult,
|
|
78
|
+
reconstruction: ReconstructionResult,
|
|
79
|
+
identification: AnchorIdentification,
|
|
80
|
+
witnessAnalysis: AnchorWitnessAnalysis
|
|
81
|
+
): TemporalAnalysis {
|
|
82
|
+
const byHash = new Map<string, ObservedProof>(ingest.proofs.map((p) => [p.proofHash, p]));
|
|
83
|
+
|
|
84
|
+
// Verified wall-clock evidence per anchor. Multiple verified witnesses
|
|
85
|
+
// for one anchor are necessarily identical (the hash comparison pins
|
|
86
|
+
// the exact header bytes); the first is kept.
|
|
87
|
+
const evidence = new Map<string, VerifiedAnchorEvidence>();
|
|
88
|
+
for (const outcome of witnessAnalysis.outcomes) {
|
|
89
|
+
if (!outcome.verified || outcome.anchorProofHash === undefined) continue;
|
|
90
|
+
if (evidence.has(outcome.anchorProofHash)) continue;
|
|
91
|
+
evidence.set(outcome.anchorProofHash, {
|
|
92
|
+
anchorProofHash: outcome.anchorProofHash,
|
|
93
|
+
timestamp: outcome.timestamp as number,
|
|
94
|
+
blockHash: outcome.computedBlockHash as string,
|
|
95
|
+
...(outcome.blockNumber !== undefined ? { blockNumber: outcome.blockNumber } : {}),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const verifiedAnchorProofHashes = [...evidence.keys()].sort();
|
|
100
|
+
const unverifiedAnchorProofHashes = identification.anchors
|
|
101
|
+
.map((a) => a.proofHash)
|
|
102
|
+
.filter((h) => !evidence.has(h))
|
|
103
|
+
.sort();
|
|
104
|
+
|
|
105
|
+
// Per-partition segments.
|
|
106
|
+
const segments: TemporalSegment[] = [];
|
|
107
|
+
for (const partition of reconstruction.partitions) {
|
|
108
|
+
segments.push(...buildPartitionSegments(partition, byHash, evidence));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Epoch-level aggregation and cross-epoch ordering.
|
|
112
|
+
const anchorOrderedPairs = aggregateEpochs(reconstruction, segments);
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
segments,
|
|
116
|
+
anchorOrderedPairs,
|
|
117
|
+
verifiedAnchorProofHashes,
|
|
118
|
+
unverifiedAnchorProofHashes,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface VerifiedAnchorEvidence {
|
|
123
|
+
anchorProofHash: string;
|
|
124
|
+
timestamp: number;
|
|
125
|
+
blockHash: string;
|
|
126
|
+
blockNumber?: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Per-partition bound computation
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
interface MemberBounds {
|
|
134
|
+
member: ObservedProof;
|
|
135
|
+
lower: SegmentBound[];
|
|
136
|
+
upper: SegmentBound[];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function buildPartitionSegments(
|
|
140
|
+
partition: ChainPartition,
|
|
141
|
+
byHash: Map<string, ObservedProof>,
|
|
142
|
+
evidence: Map<string, VerifiedAnchorEvidence>
|
|
143
|
+
): TemporalSegment[] {
|
|
144
|
+
const members = partition.memberProofHashes.map((h) => byHash.get(h) as ObservedProof);
|
|
145
|
+
const memberSet = new Map<string, ObservedProof>(members.map((m) => [m.proofHash, m]));
|
|
146
|
+
|
|
147
|
+
const anchors = members.filter((m) => evidence.has(m.proofHash));
|
|
148
|
+
|
|
149
|
+
// Hash-link structure within the partition.
|
|
150
|
+
const successors = new Map<string, ObservedProof[]>();
|
|
151
|
+
for (const m of members) {
|
|
152
|
+
if (m.prevB64 === undefined) continue;
|
|
153
|
+
const pred = memberSet.get(m.prevB64);
|
|
154
|
+
if (pred === undefined || pred.proofHash === m.proofHash) continue;
|
|
155
|
+
pushMap(successors, pred.proofHash, m);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// For each verified anchor: members causally after it (descendants via
|
|
159
|
+
// hash links) and causally before it (ancestors via the prevB64 walk).
|
|
160
|
+
const chainAfter = new Map<string, Set<string>>();
|
|
161
|
+
const chainBefore = new Map<string, Set<string>>();
|
|
162
|
+
for (const anchor of anchors) {
|
|
163
|
+
chainAfter.set(anchor.proofHash, collectDescendants(anchor, successors));
|
|
164
|
+
chainBefore.set(anchor.proofHash, collectAncestors(anchor, memberSet));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Candidate bounds per member.
|
|
168
|
+
const memberBounds: MemberBounds[] = members.map((member) => {
|
|
169
|
+
const lowerCandidates = new Map<string, BoundEvidence>();
|
|
170
|
+
const upperCandidates = new Map<string, BoundEvidence>();
|
|
171
|
+
const memberCounter = parseCounter(member.counter);
|
|
172
|
+
|
|
173
|
+
for (const anchor of anchors) {
|
|
174
|
+
const anchorHash = anchor.proofHash;
|
|
175
|
+
const anchorCounter = parseCounter(anchor.counter);
|
|
176
|
+
|
|
177
|
+
// Lower (not-before): the anchor itself consumed the block hash, so
|
|
178
|
+
// it is its own strongest lower bound; descendants inherit through
|
|
179
|
+
// the chain; counter ordering is the weaker fallback.
|
|
180
|
+
if (member.proofHash === anchorHash || (chainAfter.get(anchorHash) as Set<string>).has(member.proofHash)) {
|
|
181
|
+
lowerCandidates.set(anchorHash, "chain-link");
|
|
182
|
+
} else if (
|
|
183
|
+
memberCounter !== undefined &&
|
|
184
|
+
anchorCounter !== undefined &&
|
|
185
|
+
memberCounter > anchorCounter
|
|
186
|
+
) {
|
|
187
|
+
if (!lowerCandidates.has(anchorHash)) lowerCandidates.set(anchorHash, "counter-order");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Upper (not-after): members the anchor's chain state commits to;
|
|
191
|
+
// counter ordering as the weaker fallback. Never from the anchor to
|
|
192
|
+
// itself.
|
|
193
|
+
if ((chainBefore.get(anchorHash) as Set<string>).has(member.proofHash)) {
|
|
194
|
+
upperCandidates.set(anchorHash, "chain-link");
|
|
195
|
+
} else if (
|
|
196
|
+
member.proofHash !== anchorHash &&
|
|
197
|
+
memberCounter !== undefined &&
|
|
198
|
+
anchorCounter !== undefined &&
|
|
199
|
+
memberCounter < anchorCounter
|
|
200
|
+
) {
|
|
201
|
+
if (!upperCandidates.has(anchorHash)) upperCandidates.set(anchorHash, "counter-order");
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
member,
|
|
207
|
+
lower: selectBounds("not-before", lowerCandidates, evidence),
|
|
208
|
+
upper: selectBounds("not-after", upperCandidates, evidence),
|
|
209
|
+
};
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// Group members sharing an identical bound set into segments.
|
|
213
|
+
const groups = new Map<string, MemberBounds[]>();
|
|
214
|
+
for (const mb of memberBounds) {
|
|
215
|
+
pushMap(groups, boundSetKey(mb), mb);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const segments: TemporalSegment[] = [];
|
|
219
|
+
for (const group of groups.values()) {
|
|
220
|
+
const groupMembers = group.map((g) => g.member).sort(byCounterThenHash);
|
|
221
|
+
const first = group[0] as MemberBounds;
|
|
222
|
+
const lowerBounds = first.lower;
|
|
223
|
+
const upperBounds = first.upper;
|
|
224
|
+
const status =
|
|
225
|
+
lowerBounds.length > 0 && upperBounds.length > 0
|
|
226
|
+
? ("bracketed" as const)
|
|
227
|
+
: lowerBounds.length > 0
|
|
228
|
+
? ("lower-bounded" as const)
|
|
229
|
+
: upperBounds.length > 0
|
|
230
|
+
? ("upper-bounded" as const)
|
|
231
|
+
: ("ordered-but-unanchored" as const);
|
|
232
|
+
|
|
233
|
+
const range = positionRange(groupMembers);
|
|
234
|
+
segments.push({
|
|
235
|
+
partition: partition.key,
|
|
236
|
+
memberProofHashes: groupMembers.map((m) => m.proofHash),
|
|
237
|
+
...(range !== undefined ? { positionRange: range } : {}),
|
|
238
|
+
status,
|
|
239
|
+
lowerBounds,
|
|
240
|
+
upperBounds,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
segments.sort(compareSegments);
|
|
245
|
+
return segments;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Select the reported bounds from the candidate set: the tightest bound
|
|
250
|
+
* overall (max timestamp for not-before, min for not-after), plus the
|
|
251
|
+
* tightest chain-link bound when the overall tightest rests only on
|
|
252
|
+
* counter ordering. At most two entries, tightest first.
|
|
253
|
+
*/
|
|
254
|
+
function selectBounds(
|
|
255
|
+
kind: "not-before" | "not-after",
|
|
256
|
+
candidates: Map<string, BoundEvidence>,
|
|
257
|
+
evidence: Map<string, VerifiedAnchorEvidence>
|
|
258
|
+
): SegmentBound[] {
|
|
259
|
+
if (candidates.size === 0) return [];
|
|
260
|
+
|
|
261
|
+
const entries = [...candidates.entries()].map(([anchorHash, evidenceKind]) => ({
|
|
262
|
+
anchorHash,
|
|
263
|
+
evidenceKind,
|
|
264
|
+
info: evidence.get(anchorHash) as VerifiedAnchorEvidence,
|
|
265
|
+
}));
|
|
266
|
+
|
|
267
|
+
const better = (
|
|
268
|
+
a: (typeof entries)[number],
|
|
269
|
+
b: (typeof entries)[number] | undefined
|
|
270
|
+
): boolean => {
|
|
271
|
+
if (b === undefined) return true;
|
|
272
|
+
if (a.info.timestamp !== b.info.timestamp) {
|
|
273
|
+
return kind === "not-before"
|
|
274
|
+
? a.info.timestamp > b.info.timestamp
|
|
275
|
+
: a.info.timestamp < b.info.timestamp;
|
|
276
|
+
}
|
|
277
|
+
// Tie: prefer chain-link evidence, then the lower anchor hash.
|
|
278
|
+
if (a.evidenceKind !== b.evidenceKind) return a.evidenceKind === "chain-link";
|
|
279
|
+
return a.anchorHash < b.anchorHash;
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
let best: (typeof entries)[number] | undefined;
|
|
283
|
+
let bestChain: (typeof entries)[number] | undefined;
|
|
284
|
+
for (const entry of entries) {
|
|
285
|
+
if (better(entry, best)) best = entry;
|
|
286
|
+
if (entry.evidenceKind === "chain-link" && better(entry, bestChain)) bestChain = entry;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const bounds: SegmentBound[] = [makeBound(kind, best as (typeof entries)[number])];
|
|
290
|
+
if (
|
|
291
|
+
bestChain !== undefined &&
|
|
292
|
+
((best as (typeof entries)[number]).anchorHash !== bestChain.anchorHash ||
|
|
293
|
+
(best as (typeof entries)[number]).evidenceKind !== "chain-link")
|
|
294
|
+
) {
|
|
295
|
+
bounds.push(makeBound(kind, bestChain));
|
|
296
|
+
}
|
|
297
|
+
return bounds;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function makeBound(
|
|
301
|
+
kind: "not-before" | "not-after",
|
|
302
|
+
entry: { anchorHash: string; evidenceKind: BoundEvidence; info: VerifiedAnchorEvidence }
|
|
303
|
+
): SegmentBound {
|
|
304
|
+
const { info, evidenceKind } = entry;
|
|
305
|
+
const iso = new Date(info.timestamp * 1000).toISOString();
|
|
306
|
+
const blockName =
|
|
307
|
+
info.blockNumber !== undefined ? `Ethereum block ${info.blockNumber}` : "an Ethereum block";
|
|
308
|
+
const evidenceSentence =
|
|
309
|
+
evidenceKind === "chain-link"
|
|
310
|
+
? "Evidence: a verified hash-link path connects these proofs and the anchor."
|
|
311
|
+
: "Evidence: commit-counter ordering within the partition only, which relies on the " +
|
|
312
|
+
"authority's counter discipline rather than verifiable hash links (weaker).";
|
|
313
|
+
const claim =
|
|
314
|
+
kind === "not-before"
|
|
315
|
+
? `These proofs were committed no earlier than ${iso} (unix ${info.timestamp}), the ` +
|
|
316
|
+
`timestamp of ${blockName}: the block hash was unpredictable before that time and the ` +
|
|
317
|
+
`anchor commit consumed it. Reading this as a wall-clock floor additionally assumes the ` +
|
|
318
|
+
`anchored header is a genuine, publicly published Ethereum block: this offline audit checks ` +
|
|
319
|
+
`the header's structure and hash binding, not proof-of-work, consensus, or chain membership, ` +
|
|
320
|
+
`so it cannot confirm the block is real. ${evidenceSentence}`
|
|
321
|
+
: `These proofs existed before the anchor commit that consumed the hash of ${blockName} ` +
|
|
322
|
+
`(block timestamp ${iso}, unix ${info.timestamp}). The block timestamp proves the anchor ` +
|
|
323
|
+
`commit came at or after it, not how promptly; reading it as a wall-clock ceiling ` +
|
|
324
|
+
`additionally assumes the anchor consumed a recently published block. ${evidenceSentence}`;
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
kind,
|
|
328
|
+
anchorProofHash: entry.anchorHash,
|
|
329
|
+
...(info.blockNumber !== undefined ? { blockNumber: info.blockNumber } : {}),
|
|
330
|
+
blockHash: info.blockHash,
|
|
331
|
+
timestamp: info.timestamp,
|
|
332
|
+
evidence: evidenceKind,
|
|
333
|
+
weaker: evidenceKind === "counter-order",
|
|
334
|
+
basis: kind === "not-before" ? "block-hash-unpredictability" : "causal-precedence",
|
|
335
|
+
claim,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function boundSetKey(mb: MemberBounds): string {
|
|
340
|
+
const part = (bounds: SegmentBound[]): string =>
|
|
341
|
+
bounds.map((b) => `${b.anchorProofHash}:${b.evidence}`).join(",");
|
|
342
|
+
return `${part(mb.lower)}|${part(mb.upper)}`;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function collectDescendants(
|
|
346
|
+
anchor: ObservedProof,
|
|
347
|
+
successors: Map<string, ObservedProof[]>
|
|
348
|
+
): Set<string> {
|
|
349
|
+
const reached = new Set<string>();
|
|
350
|
+
const stack = [...(successors.get(anchor.proofHash) ?? [])];
|
|
351
|
+
while (stack.length > 0) {
|
|
352
|
+
const node = stack.pop() as ObservedProof;
|
|
353
|
+
if (reached.has(node.proofHash)) continue;
|
|
354
|
+
reached.add(node.proofHash);
|
|
355
|
+
for (const next of successors.get(node.proofHash) ?? []) stack.push(next);
|
|
356
|
+
}
|
|
357
|
+
return reached;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function collectAncestors(
|
|
361
|
+
anchor: ObservedProof,
|
|
362
|
+
memberSet: Map<string, ObservedProof>
|
|
363
|
+
): Set<string> {
|
|
364
|
+
const reached = new Set<string>();
|
|
365
|
+
let cursor: ObservedProof | undefined = anchor;
|
|
366
|
+
while (cursor !== undefined && cursor.prevB64 !== undefined) {
|
|
367
|
+
const pred: ObservedProof | undefined = memberSet.get(cursor.prevB64);
|
|
368
|
+
if (pred === undefined || reached.has(pred.proofHash) || pred.proofHash === anchor.proofHash) {
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
reached.add(pred.proofHash);
|
|
372
|
+
cursor = pred;
|
|
373
|
+
}
|
|
374
|
+
return reached;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function positionRange(
|
|
378
|
+
members: ObservedProof[]
|
|
379
|
+
): { min: string; max: string } | undefined {
|
|
380
|
+
let min: bigint | undefined;
|
|
381
|
+
let max: bigint | undefined;
|
|
382
|
+
for (const m of members) {
|
|
383
|
+
for (const value of [m.counter, m.slotCounter]) {
|
|
384
|
+
const n = parseCounter(value);
|
|
385
|
+
if (n === undefined) continue;
|
|
386
|
+
if (min === undefined || n < min) min = n;
|
|
387
|
+
if (max === undefined || n > max) max = n;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return min !== undefined && max !== undefined
|
|
391
|
+
? { min: String(min), max: String(max) }
|
|
392
|
+
: undefined;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function compareSegments(a: TemporalSegment, b: TemporalSegment): number {
|
|
396
|
+
const ma = a.positionRange?.min;
|
|
397
|
+
const mb = b.positionRange?.min;
|
|
398
|
+
if (ma !== undefined && mb !== undefined && ma !== mb) {
|
|
399
|
+
return BigInt(ma) < BigInt(mb) ? -1 : 1;
|
|
400
|
+
}
|
|
401
|
+
if (ma !== undefined && mb === undefined) return -1;
|
|
402
|
+
if (ma === undefined && mb !== undefined) return 1;
|
|
403
|
+
const ha = a.memberProofHashes[0] ?? "";
|
|
404
|
+
const hb = b.memberProofHashes[0] ?? "";
|
|
405
|
+
return ha < hb ? -1 : ha > hb ? 1 : 0;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
// Epoch aggregation and cross-epoch ordering
|
|
410
|
+
// ---------------------------------------------------------------------------
|
|
411
|
+
|
|
412
|
+
interface CoverageRepresentative {
|
|
413
|
+
timestamp: number;
|
|
414
|
+
anchorProofHash: string;
|
|
415
|
+
blockHash: string;
|
|
416
|
+
blockNumber?: string;
|
|
417
|
+
/** Evidence class of the bound chosen as the conservative representative. */
|
|
418
|
+
evidence: BoundEvidence;
|
|
419
|
+
/** True when that representative rests on counter-order evidence. */
|
|
420
|
+
weaker: boolean;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
interface EpochCoverage {
|
|
424
|
+
totalProofCount: number;
|
|
425
|
+
/** Distinct members covered by any not-before bound, and the most conservative (minimum) covering timestamp. */
|
|
426
|
+
lowerCovered: Set<string>;
|
|
427
|
+
lowerMin?: CoverageRepresentative;
|
|
428
|
+
/** Distinct members covered by any not-after bound, and the most conservative (maximum) covering timestamp. */
|
|
429
|
+
upperCovered: Set<string>;
|
|
430
|
+
upperMax?: CoverageRepresentative;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Populate EpochRecord.anchorBounds and derive covered-portion ordering
|
|
435
|
+
* pairs.
|
|
436
|
+
*
|
|
437
|
+
* The epoch-level not-before is the MINIMUM timestamp over the members'
|
|
438
|
+
* lower bounds (every covered member is not-before at least that), and
|
|
439
|
+
* the epoch-level not-after is the MAXIMUM over the members' upper bounds
|
|
440
|
+
* (every covered member existed before an anchor commit at or after that).
|
|
441
|
+
* These conservative representatives make the cross-epoch comparison
|
|
442
|
+
* sound for the covered portions: epoch A's not-after strictly below
|
|
443
|
+
* epoch B's not-before orders the covered portion of A before the covered
|
|
444
|
+
* portion of B, subject to the not-after freshness assumption.
|
|
445
|
+
*/
|
|
446
|
+
/** Sentence appended to an epoch-level claim when its representative bound rests on counter-order evidence. */
|
|
447
|
+
function weakerCaveat(weaker: boolean): string {
|
|
448
|
+
return weaker
|
|
449
|
+
? " The representative bound rests on commit-counter ordering rather than a verified hash-link " +
|
|
450
|
+
"path, which relies on the authority's counter discipline (weaker evidence)."
|
|
451
|
+
: "";
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function aggregateEpochs(
|
|
455
|
+
reconstruction: ReconstructionResult,
|
|
456
|
+
segments: TemporalSegment[]
|
|
457
|
+
): AnchorOrderedPair[] {
|
|
458
|
+
const coverage = new Map<string, EpochCoverage>();
|
|
459
|
+
for (const epoch of reconstruction.epochRelationships.epochs) {
|
|
460
|
+
coverage.set(epoch.epochId, {
|
|
461
|
+
totalProofCount: epoch.proofCount,
|
|
462
|
+
lowerCovered: new Set(),
|
|
463
|
+
upperCovered: new Set(),
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
for (const segment of segments) {
|
|
468
|
+
const epochId = segment.partition.epochId;
|
|
469
|
+
if (epochId === undefined) continue;
|
|
470
|
+
const cov = coverage.get(epochId);
|
|
471
|
+
if (cov === undefined) continue;
|
|
472
|
+
|
|
473
|
+
// The tightest bound is entry 0 by construction; every listed bound
|
|
474
|
+
// covers the members, so the conservative representative scans all.
|
|
475
|
+
// Conservative representative: minimum timestamp for not-before. At an
|
|
476
|
+
// equal timestamp, prefer chain-link evidence so the epoch bound is
|
|
477
|
+
// never marked weaker when a hash-link bound justifies the same time.
|
|
478
|
+
for (const bound of segment.lowerBounds) {
|
|
479
|
+
for (const h of segment.memberProofHashes) cov.lowerCovered.add(h);
|
|
480
|
+
if (
|
|
481
|
+
cov.lowerMin === undefined ||
|
|
482
|
+
bound.timestamp < cov.lowerMin.timestamp ||
|
|
483
|
+
(bound.timestamp === cov.lowerMin.timestamp && cov.lowerMin.weaker && !bound.weaker)
|
|
484
|
+
) {
|
|
485
|
+
cov.lowerMin = {
|
|
486
|
+
timestamp: bound.timestamp,
|
|
487
|
+
anchorProofHash: bound.anchorProofHash,
|
|
488
|
+
blockHash: bound.blockHash,
|
|
489
|
+
...(bound.blockNumber !== undefined ? { blockNumber: bound.blockNumber } : {}),
|
|
490
|
+
evidence: bound.evidence,
|
|
491
|
+
weaker: bound.weaker,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
// Maximum timestamp for not-after, same chain-link tie preference.
|
|
496
|
+
for (const bound of segment.upperBounds) {
|
|
497
|
+
for (const h of segment.memberProofHashes) cov.upperCovered.add(h);
|
|
498
|
+
if (
|
|
499
|
+
cov.upperMax === undefined ||
|
|
500
|
+
bound.timestamp > cov.upperMax.timestamp ||
|
|
501
|
+
(bound.timestamp === cov.upperMax.timestamp && cov.upperMax.weaker && !bound.weaker)
|
|
502
|
+
) {
|
|
503
|
+
cov.upperMax = {
|
|
504
|
+
timestamp: bound.timestamp,
|
|
505
|
+
anchorProofHash: bound.anchorProofHash,
|
|
506
|
+
blockHash: bound.blockHash,
|
|
507
|
+
...(bound.blockNumber !== undefined ? { blockNumber: bound.blockNumber } : {}),
|
|
508
|
+
evidence: bound.evidence,
|
|
509
|
+
weaker: bound.weaker,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Populate EpochRecord.anchorBounds (the Phase 4c extension point).
|
|
516
|
+
for (const epoch of reconstruction.epochRelationships.epochs) {
|
|
517
|
+
const cov = coverage.get(epoch.epochId) as EpochCoverage;
|
|
518
|
+
const bounds: EpochAnchorBound[] = [];
|
|
519
|
+
if (cov.lowerMin !== undefined) {
|
|
520
|
+
bounds.push({
|
|
521
|
+
kind: "not-before",
|
|
522
|
+
anchorProofHash: cov.lowerMin.anchorProofHash,
|
|
523
|
+
...(cov.lowerMin.blockNumber !== undefined ? { blockNumber: cov.lowerMin.blockNumber } : {}),
|
|
524
|
+
blockHash: cov.lowerMin.blockHash,
|
|
525
|
+
witnessTimestamp: cov.lowerMin.timestamp,
|
|
526
|
+
coverage: "members-after-anchor",
|
|
527
|
+
coveredProofCount: cov.lowerCovered.size,
|
|
528
|
+
totalProofCount: cov.totalProofCount,
|
|
529
|
+
basis: "block-hash-unpredictability",
|
|
530
|
+
evidence: cov.lowerMin.evidence,
|
|
531
|
+
weaker: cov.lowerMin.weaker,
|
|
532
|
+
claim:
|
|
533
|
+
`${cov.lowerCovered.size} of ${cov.totalProofCount} observed proofs of this epoch were ` +
|
|
534
|
+
`committed no earlier than unix ${cov.lowerMin.timestamp}, grounded in block-hash ` +
|
|
535
|
+
`unpredictability through verified anchor witnesses. This additionally assumes the anchored ` +
|
|
536
|
+
`header is a genuine, publicly published Ethereum block, which this offline audit cannot ` +
|
|
537
|
+
`confirm.` +
|
|
538
|
+
weakerCaveat(cov.lowerMin.weaker) +
|
|
539
|
+
` The remaining proofs sit causally before the covering anchors and carry no lower bound ` +
|
|
540
|
+
`from this evidence.`,
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
if (cov.upperMax !== undefined) {
|
|
544
|
+
bounds.push({
|
|
545
|
+
kind: "not-after",
|
|
546
|
+
anchorProofHash: cov.upperMax.anchorProofHash,
|
|
547
|
+
...(cov.upperMax.blockNumber !== undefined ? { blockNumber: cov.upperMax.blockNumber } : {}),
|
|
548
|
+
blockHash: cov.upperMax.blockHash,
|
|
549
|
+
witnessTimestamp: cov.upperMax.timestamp,
|
|
550
|
+
coverage: "members-before-anchor",
|
|
551
|
+
coveredProofCount: cov.upperCovered.size,
|
|
552
|
+
totalProofCount: cov.totalProofCount,
|
|
553
|
+
basis: "causal-precedence",
|
|
554
|
+
evidence: cov.upperMax.evidence,
|
|
555
|
+
weaker: cov.upperMax.weaker,
|
|
556
|
+
claim:
|
|
557
|
+
`${cov.upperCovered.size} of ${cov.totalProofCount} observed proofs of this epoch ` +
|
|
558
|
+
`existed before an anchor commit that consumed a block published at unix ` +
|
|
559
|
+
`${cov.upperMax.timestamp}. Reading that timestamp as a wall-clock ceiling additionally ` +
|
|
560
|
+
`assumes the anchor consumed a recently published block; the causal precedence itself ` +
|
|
561
|
+
`is verified.` +
|
|
562
|
+
weakerCaveat(cov.upperMax.weaker) +
|
|
563
|
+
` Proofs after the covering anchors are not covered.`,
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
if (bounds.length > 0) epoch.anchorBounds = bounds;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Covered-portion ordering pairs. Strict inequality: equal timestamps
|
|
570
|
+
// order nothing. Overlaps produce no pair (concurrent-or-unordered),
|
|
571
|
+
// never divergence.
|
|
572
|
+
const pairs: AnchorOrderedPair[] = [];
|
|
573
|
+
const epochs = reconstruction.epochRelationships.epochs;
|
|
574
|
+
for (const before of epochs) {
|
|
575
|
+
const covA = coverage.get(before.epochId) as EpochCoverage;
|
|
576
|
+
if (covA.upperMax === undefined) continue;
|
|
577
|
+
for (const after of epochs) {
|
|
578
|
+
if (after.epochId === before.epochId) continue;
|
|
579
|
+
const covB = coverage.get(after.epochId) as EpochCoverage;
|
|
580
|
+
if (covB.lowerMin === undefined) continue;
|
|
581
|
+
if (covA.upperMax.timestamp >= covB.lowerMin.timestamp) continue;
|
|
582
|
+
const upperEvidence = covA.upperMax.evidence;
|
|
583
|
+
const lowerEvidence = covB.lowerMin.evidence;
|
|
584
|
+
const weaker = covA.upperMax.weaker || covB.lowerMin.weaker;
|
|
585
|
+
pairs.push({
|
|
586
|
+
beforeEpochId: before.epochId,
|
|
587
|
+
afterEpochId: after.epochId,
|
|
588
|
+
upperAnchorProofHash: covA.upperMax.anchorProofHash,
|
|
589
|
+
upperBoundTimestamp: covA.upperMax.timestamp,
|
|
590
|
+
lowerAnchorProofHash: covB.lowerMin.anchorProofHash,
|
|
591
|
+
lowerBoundTimestamp: covB.lowerMin.timestamp,
|
|
592
|
+
basis: "anchor-bounds",
|
|
593
|
+
assumptionDependent: true,
|
|
594
|
+
upperEvidence,
|
|
595
|
+
lowerEvidence,
|
|
596
|
+
weaker,
|
|
597
|
+
beforeCoveredProofCount: covA.upperCovered.size,
|
|
598
|
+
beforeTotalProofCount: covA.totalProofCount,
|
|
599
|
+
afterCoveredProofCount: covB.lowerCovered.size,
|
|
600
|
+
afterTotalProofCount: covB.totalProofCount,
|
|
601
|
+
note:
|
|
602
|
+
`The anchor-covered portion of epoch ${before.epochId} ` +
|
|
603
|
+
`(${covA.upperCovered.size} of ${covA.totalProofCount} proofs) precedes the ` +
|
|
604
|
+
`anchor-covered portion of epoch ${after.epochId} ` +
|
|
605
|
+
`(${covB.lowerCovered.size} of ${covB.totalProofCount} proofs): the first is bounded ` +
|
|
606
|
+
`not-after unix ${covA.upperMax.timestamp} and the second not-before unix ` +
|
|
607
|
+
`${covB.lowerMin.timestamp}. One-sided evidence about the covered portions only; the ` +
|
|
608
|
+
`not-after side rests on the anchor-freshness assumption documented on its bound.` +
|
|
609
|
+
(weaker
|
|
610
|
+
? ` At least one side rests on commit-counter ordering rather than a verified hash-link ` +
|
|
611
|
+
`path (weaker evidence), which relies on the authority's counter discipline.`
|
|
612
|
+
: "") +
|
|
613
|
+
` This is ordering evidence, never divergence.`,
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
pairs.sort((a, b) =>
|
|
618
|
+
a.beforeEpochId !== b.beforeEpochId
|
|
619
|
+
? a.beforeEpochId < b.beforeEpochId
|
|
620
|
+
? -1
|
|
621
|
+
: 1
|
|
622
|
+
: a.afterEpochId < b.afterEpochId
|
|
623
|
+
? -1
|
|
624
|
+
: a.afterEpochId > b.afterEpochId
|
|
625
|
+
? 1
|
|
626
|
+
: 0
|
|
627
|
+
);
|
|
628
|
+
return pairs;
|
|
629
|
+
}
|